refactor: Button components (#6929)

* feat: refactor button components

* fix: qa

* fix: storybook

* a11y: pass

* fix: build

* fix: rename default ->md

* fix: lint

* docs: buttons.md

* refactor part 1

* refactor part 2

* fix: undo refactor for fresh restart

* refactor

* Revert "refactor"

This reverts commit 96d65902d7.

* refactor: part 1

* fix: qa

* fix: qa

* fix: qa

* fix: qa

* fix: qa

* fix: qa

* fix: qa

* fix: qa

* fix: remove text-contrast

* fix: qa

* fix: v-tooltip

* fix: lint

* fix: split broken

* fix: passkey qa

* fix: qa

* fix: qa

* fix: prepr

* fix: splitbutton

* improve button group seam

---------

Signed-off-by: Calum H. <calum@modrinth.com>
Co-authored-by: Prospector <6166773+Prospector@users.noreply.github.com>
This commit is contained in:
Calum H.
2026-08-04 13:54:04 -07:00
committed by GitHub
co-authored by Prospector
parent 6cfe999cb3
commit bb2193b6f5
398 changed files with 13688 additions and 13133 deletions
@@ -0,0 +1,35 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import ButtonFrame from './ButtonFrame.vue'
import type { ButtonProps } from './types'
const props = withDefaults(defineProps<ButtonProps>(), {
type: 'base',
size: 'md',
nativeType: 'button',
disabled: false,
loading: false,
})
const frame = ref<InstanceType<typeof ButtonFrame> | null>(null)
const element = computed(() => frame.value?.element ?? null)
defineExpose({ element })
</script>
<template>
<ButtonFrame
ref="frame"
as="button"
:type="props.type"
:color="props.color"
:size="props.size"
:interaction="props.interaction"
:native-type="props.nativeType"
:disabled="props.disabled || props.loading"
:aria-busy="props.loading || undefined"
>
<slot />
</ButtonFrame>
</template>
@@ -0,0 +1,165 @@
<script setup lang="ts">
import type { Component, CSSProperties } from 'vue'
import { computed, ref } from 'vue'
import type {
ButtonColor,
ButtonInteraction,
ButtonNativeType,
ButtonSize,
ButtonType,
} from './types'
const baseClasses = [
// Base
'relative inline-flex min-w-0 shrink-0 items-center justify-center',
'whitespace-nowrap border-0 no-underline',
// Interactions
'touch-manipulation cursor-pointer select-none transition-[background-color,color,box-shadow,filter,opacity,transform] duration-150 ease-out',
'enabled:active:scale-[0.97]',
// Hovering
'[&:not(:disabled):not([aria-disabled=true]):hover]:brightness-[--hover-brightness]',
// Accessibility
'[&:not(:disabled):not([aria-disabled=true]):focus-visible]:brightness-[--hover-brightness] focus-visible:outline-none [&:not(:disabled):not([aria-disabled=true]):focus-visible]:ring-4 [&:not(:disabled):not([aria-disabled=true]):focus-visible]:ring-brand-shadow',
'disabled:cursor-not-allowed disabled:opacity-50',
'[&[aria-disabled=true]]:cursor-not-allowed [&[aria-disabled=true]]:opacity-50',
].join(' ')
const sizeClasses: Record<ButtonSize, string> = {
xs: 'h-7 gap-1 rounded-lg px-1.5 text-sm font-semibold leading-5 [&>svg]:size-4 [&>svg]:min-h-4 [&>svg]:min-w-4 [&>svg]:shrink-0',
sm: 'h-8 gap-1 rounded-[10px] px-1.5 text-sm font-semibold leading-5 [&>svg]:size-4 [&>svg]:min-h-4 [&>svg]:min-w-4 [&>svg]:shrink-0',
md: 'h-9 gap-1.5 rounded-xl px-2.5 text-base font-semibold leading-5 [&>svg]:size-5 [&>svg]:min-h-5 [&>svg]:min-w-5 [&>svg]:shrink-0',
lg: 'h-10 gap-2 rounded-[14px] px-4 text-base font-semibold leading-5 [&>svg]:size-5 [&>svg]:min-h-5 [&>svg]:min-w-5 [&>svg]:shrink-0',
xl: 'h-12 gap-2 rounded-2xl px-3.5 text-base font-extrabold leading-5 [&>svg]:size-6 [&>svg]:min-h-6 [&>svg]:min-w-6 [&>svg]:shrink-0',
}
const iconOnlySizeClasses: Record<ButtonSize, string> = {
xs: 'w-7 !px-0',
sm: 'w-8 !px-0',
md: 'w-9 !px-0',
lg: 'w-10 !px-0',
xl: 'w-12 !px-0',
}
const typeClasses: Record<ButtonType, string> = {
base: 'button-frame--base bg-surface-4 text-contrast [&>svg]:text-primary',
colored:
'button-frame--colored bg-[--button-color] text-[var(--color-accent-contrast)] [&>svg]:text-inherit',
outlined:
'button-frame--outlined bg-transparent text-[var(--button-color,var(--color-contrast))] [&>svg]:text-inherit',
quiet: 'button-frame--quiet bg-transparent [&>svg]:text-inherit',
}
const interactionClasses: Record<ButtonInteraction, string> = {
surface:
'[&:not(:disabled):not([aria-disabled=true]):hover]:bg-surface-4 [&:not(:disabled):not([aria-disabled=true]):focus-visible]:bg-surface-4',
filled:
'[&:not(:disabled):not([aria-disabled=true]):hover]:!bg-[--button-color] [&:not(:disabled):not([aria-disabled=true]):focus-visible]:!bg-[--button-color] [&:not(:disabled):not([aria-disabled=true]):hover]:!text-[var(--color-accent-contrast)] [&:not(:disabled):not([aria-disabled=true]):focus-visible]:!text-[var(--color-accent-contrast)]',
none: '[&:not(:disabled):not([aria-disabled=true]):hover]:!brightness-100 [&:not(:disabled):not([aria-disabled=true]):focus-visible]:!brightness-100',
}
const colorVariables: Record<ButtonColor, string> = {
brand: 'var(--color-brand)',
red: 'var(--color-red)',
orange: 'var(--color-orange)',
green: 'var(--color-green)',
blue: 'var(--color-blue)',
purple: 'var(--color-purple)',
medal_promotion: 'var(--medal-promotion-text-orange, var(--color-orange))',
}
const props = withDefaults(
defineProps<{
as: string | Component
type?: ButtonType
color?: ButtonColor
size?: ButtonSize
interaction?: ButtonInteraction
iconOnly?: boolean
circular?: boolean
nativeType?: ButtonNativeType
}>(),
{
type: 'base',
size: 'md',
interaction: 'surface',
iconOnly: false,
circular: false,
nativeType: undefined,
},
)
const element = ref<HTMLElement | null>(null)
const classes = computed(() => [
baseClasses,
typeClasses[props.type],
props.type === 'quiet' ? interactionClasses[props.interaction] : '',
sizeClasses[props.size],
props.iconOnly ? iconOnlySizeClasses[props.size] : '',
props.circular ? '!rounded-full' : '',
])
const style = computed((): CSSProperties | undefined => {
if ((props.type === 'outlined' || props.type === 'quiet') && !props.color) return undefined
if (props.type !== 'colored' && props.type !== 'outlined' && props.type !== 'quiet')
return undefined
return {
'--button-color': colorVariables[props.color ?? 'brand'],
} as CSSProperties
})
defineExpose({ element })
</script>
<template>
<component
:is="as"
ref="element"
data-button
:type="props.nativeType"
:class="classes"
:style="style"
>
<slot />
</component>
</template>
<style scoped>
.button-frame--base {
box-shadow:
inset 0 0 0 1px var(--surface-5),
0 1px 1px rgba(0, 0, 0, 0.12);
}
.button-frame--colored {
box-shadow:
0 0 0 1px color-mix(in srgb, var(--button-color) 30%, transparent),
0 2px 4px rgba(0, 0, 0, 0.04),
0 5px 8px rgba(0, 0, 0, 0.04),
0 10px 18px rgba(0, 0, 0, 0.03),
0 24px 48px rgba(0, 0, 0, 0.03);
}
.button-frame--colored::before {
position: absolute;
inset: 0;
padding: 1px;
pointer-events: none;
content: '';
border-radius: inherit;
background: linear-gradient(180deg, rgba(255, 255, 255, 0.3), rgba(255, 255, 255, 0));
-webkit-mask:
linear-gradient(#000 0 0) content-box,
linear-gradient(#000 0 0);
-webkit-mask-composite: xor;
mask-composite: exclude;
}
.button-frame--outlined {
box-shadow: inset 0 0 0 1px var(--button-color, var(--surface-5));
}
.button-frame--quiet {
color: var(--button-color, var(--color-base));
}
</style>
@@ -0,0 +1,85 @@
<script setup lang="ts">
withDefaults(
defineProps<{
label?: string
}>(),
{
label: undefined,
},
)
</script>
<template>
<div
:role="label ? 'group' : undefined"
:aria-label="label"
class="button-group inline-flex [&>[data-button]+[data-button]]:-ms-px [&>[data-button]:active]:z-10 [&>[data-button]:focus-visible]:z-10 [&>[data-button]:not(:disabled):not([aria-disabled=true]):hover]:z-10 [&>[data-button]:not(:first-child)]:rounded-l-none [&>[data-button]:not(:last-child)]:rounded-r-none"
>
<slot />
</div>
</template>
<style scoped>
.button-group
:deep([data-button].button-frame--colored:first-child:not(:last-child):not(:focus-visible)) {
clip-path: inset(-4rem var(--button-group-inner-clip, 1px) -4rem -4rem);
}
.button-group
:deep(
[data-button].button-frame--colored:not(:first-child):not(:last-child):not(:focus-visible)
) {
clip-path: inset(
-4rem var(--button-group-inner-clip, 1px) -4rem var(--button-group-leading-clip, 0px)
);
}
.button-group
:deep([data-button].button-frame--colored:last-child:not(:first-child):not(:focus-visible)) {
clip-path: inset(-4rem -4rem -4rem var(--button-group-leading-clip, 0px));
}
:dir(rtl)
.button-group
:deep([data-button].button-frame--colored:first-child:not(:last-child):not(:focus-visible)) {
clip-path: inset(-4rem -4rem -4rem var(--button-group-inner-clip, 1px));
}
:dir(rtl)
.button-group
:deep(
[data-button].button-frame--colored:not(:first-child):not(:last-child):not(:focus-visible)
) {
clip-path: inset(
-4rem var(--button-group-leading-clip, 0px) -4rem var(--button-group-inner-clip, 1px)
);
}
:dir(rtl)
.button-group
:deep([data-button].button-frame--colored:last-child:not(:first-child):not(:focus-visible)) {
clip-path: inset(-4rem var(--button-group-leading-clip, 0px) -4rem -4rem);
}
.button-group :deep([data-button].button-frame--colored:active) {
--button-group-inner-clip: 0;
}
/*
* the seam is normally drawn by the button on the right, but hovering over the left one will make it draw the seam instead. when pressed, make the right one draw its own seam again.
*/
.button-group
:deep([data-button].button-frame--colored:not(:disabled):not([aria-disabled='true']):hover) {
--button-group-inner-clip: 0;
}
.button-group
:deep(
[data-button].button-frame--colored:not(:disabled):not([aria-disabled='true']):hover:not(
:active
)
+ [data-button]
) {
--button-group-leading-clip: 1px;
}
</style>
@@ -0,0 +1,67 @@
<script setup lang="ts">
import { computed } from 'vue'
import { RouterLink } from 'vue-router'
import ButtonFrame from './ButtonFrame.vue'
import type {
ButtonColor,
ButtonInteraction,
ButtonLinkDestination,
ButtonSize,
ButtonType,
} from './types'
type ButtonLinkProps = ButtonLinkDestination & {
type?: ButtonType
color?: ButtonColor
size?: ButtonSize
interaction?: ButtonInteraction
target?: string
rel?: string
download?: string | boolean
disabled?: boolean
}
const props = withDefaults(defineProps<ButtonLinkProps>(), {
type: 'base',
size: 'md',
target: undefined,
rel: undefined,
download: undefined,
disabled: false,
})
const usesRouter = computed(() => props.to !== undefined && !props.disabled)
const component = computed(() => (usesRouter.value ? RouterLink : 'a'))
const resolvedRel = computed(() => {
if (props.rel) return props.rel
return props.target === '_blank' ? 'noopener noreferrer' : undefined
})
function handleClick(event: MouseEvent) {
if (!props.disabled) return
event.preventDefault()
event.stopImmediatePropagation()
}
</script>
<template>
<ButtonFrame
:as="component"
:type="props.type"
:color="props.color"
:size="props.size"
:interaction="props.interaction"
:to="usesRouter ? props.to : undefined"
:href="!usesRouter && !props.disabled ? props.href : undefined"
:target="props.target"
:rel="resolvedRel"
:download="!props.disabled ? props.download : undefined"
:aria-disabled="props.disabled || undefined"
:role="props.disabled ? 'link' : undefined"
:tabindex="props.disabled ? -1 : undefined"
@click="handleClick"
>
<slot />
</ButtonFrame>
</template>
@@ -0,0 +1,85 @@
<script setup lang="ts">
import { fileIsValid } from '@modrinth/utils'
import { useFormatBytes } from '../../../composables'
import ButtonFrame from './ButtonFrame.vue'
import type { ButtonColor, ButtonInteraction, ButtonSize, ButtonType } from './types'
const props = withDefaults(
defineProps<{
prompt?: string
multiple?: boolean
accept?: string
maxSize?: number | null
disabled?: boolean
allowDrop?: boolean
type?: ButtonType
color?: ButtonColor
size?: ButtonSize
interaction?: ButtonInteraction
}>(),
{
prompt: 'Select file',
multiple: false,
accept: undefined,
maxSize: undefined,
disabled: false,
allowDrop: true,
type: 'base',
size: 'md',
},
)
const emit = defineEmits<{
change: [files: File[]]
}>()
const formatBytes = useFormatBytes()
function selectFiles(incoming: FileList) {
if (props.disabled) return
const validationOptions = { maxSize: props.maxSize, alertOnInvalid: true }
const validFiles = Array.from(incoming).filter((file) =>
fileIsValid(file, validationOptions, formatBytes),
)
if (validFiles.length > 0) emit('change', validFiles)
}
function handleChange(event: Event) {
const input = event.target as HTMLInputElement
if (input.files) selectFiles(input.files)
input.value = ''
}
function handleDrop(event: DragEvent) {
if (!props.allowDrop || !event.dataTransfer) return
selectFiles(event.dataTransfer.files)
}
</script>
<template>
<ButtonFrame
as="label"
:type="props.type"
:color="props.color"
:size="props.size"
:interaction="props.interaction"
:aria-disabled="props.disabled || undefined"
class="focus-within:outline-none focus-within:ring-4 focus-within:ring-brand-shadow"
@drop.prevent="handleDrop"
@dragover.prevent
>
<slot />
{{ props.prompt }}
<input
type="file"
:multiple="props.multiple"
:accept="props.accept"
:disabled="props.disabled"
class="absolute size-px overflow-hidden whitespace-nowrap [clip:rect(0,0,0,0)]"
@change="handleChange"
/>
</ButtonFrame>
</template>
@@ -0,0 +1,58 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import ButtonFrame from './ButtonFrame.vue'
import type {
ButtonColor,
ButtonInteraction,
ButtonNativeType,
ButtonSize,
ButtonType,
} from './types'
const props = withDefaults(
defineProps<{
label: string
type?: ButtonType
color?: ButtonColor
size?: ButtonSize
interaction?: ButtonInteraction
nativeType?: ButtonNativeType
circular?: boolean
disabled?: boolean
loading?: boolean
}>(),
{
type: 'base',
size: 'md',
nativeType: 'button',
circular: true,
disabled: false,
loading: false,
},
)
const frame = ref<InstanceType<typeof ButtonFrame> | null>(null)
const element = computed(() => frame.value?.element ?? null)
defineExpose({ element })
</script>
<template>
<ButtonFrame
ref="frame"
as="button"
icon-only
:circular="props.circular"
:type="props.type"
:color="props.color"
:size="props.size"
:interaction="props.interaction"
:native-type="props.nativeType"
:disabled="props.disabled || props.loading"
:aria-label="props.label"
:aria-busy="props.loading || undefined"
>
<slot />
</ButtonFrame>
</template>
@@ -0,0 +1,88 @@
<script setup lang="ts">
import { DropdownIcon } from '@modrinth/assets'
import { computed, useSlots } from 'vue'
import Button from './Button.vue'
import ButtonGroup from './ButtonGroup.vue'
import TeleportOverflowMenu from './TeleportOverflowMenu.vue'
import type {
ButtonColor,
ButtonInteraction,
ButtonNativeType,
ButtonSize,
ButtonType,
OverflowMenuAction,
OverflowMenuLink,
OverflowMenuOption,
TeleportPlacement,
} from './types'
const props = withDefaults(
defineProps<{
menuLabel: string
options: OverflowMenuOption[]
groupLabel?: string
type?: ButtonType
color?: ButtonColor
size?: ButtonSize
interaction?: ButtonInteraction
nativeType?: ButtonNativeType
disabled?: boolean
primaryDisabled?: boolean
menuDisabled?: boolean
placement?: TeleportPlacement
}>(),
{
groupLabel: undefined,
type: 'base',
size: 'md',
nativeType: 'button',
disabled: false,
primaryDisabled: false,
menuDisabled: false,
placement: 'bottom-end',
},
)
const emit = defineEmits<{
click: [event: MouseEvent]
select: [option: OverflowMenuAction | OverflowMenuLink]
}>()
const slots = useSlots()
const forwardedSlots = computed(() => Object.keys(slots).filter((name) => name !== 'default'))
</script>
<template>
<ButtonGroup :label="props.groupLabel">
<Button
:type="props.type"
:color="props.color"
:size="props.size"
:interaction="props.interaction"
:native-type="props.nativeType"
:disabled="props.disabled || props.primaryDisabled"
@click="emit('click', $event)"
>
<slot />
</Button>
<TeleportOverflowMenu
:label="props.menuLabel"
:options="props.options"
:type="props.type"
:color="props.color"
:size="props.size"
:interaction="props.interaction"
:circular="false"
:disabled="props.disabled || props.menuDisabled"
:placement="props.placement"
@select="emit('select', $event)"
>
<DropdownIcon aria-hidden="true" />
<template v-for="slotName in forwardedSlots" #[slotName]="slotProps">
<slot :name="slotName" v-bind="slotProps" />
</template>
</TeleportOverflowMenu>
</ButtonGroup>
</template>
@@ -0,0 +1,469 @@
<script setup lang="ts">
import type { CSSProperties } from 'vue'
import { computed, nextTick, onUnmounted, ref, useId, watch } from 'vue'
import { RouterLink } from 'vue-router'
import { useAnchoredTeleport } from '../../../utils/use-anchored-teleport'
import Button from './Button.vue'
import IconButton from './IconButton.vue'
import type {
ButtonColor,
ButtonElementHandle,
ButtonInteraction,
ButtonSize,
ButtonType,
OverflowMenuAction,
OverflowMenuLink,
OverflowMenuOption,
TeleportPlacement,
} from './types'
defineOptions({ inheritAttrs: false })
const props = withDefaults(
defineProps<{
label: string
options: OverflowMenuOption[]
type?: ButtonType
color?: ButtonColor
size?: ButtonSize
interaction?: ButtonInteraction
disabled?: boolean
iconOnly?: boolean
circular?: boolean
tooltip?: string
placement?: TeleportPlacement
distance?: number
hoverable?: boolean
}>(),
{
type: 'base',
size: 'md',
disabled: false,
iconOnly: true,
circular: true,
placement: 'bottom-end',
distance: 8,
hoverable: false,
},
)
const emit = defineEmits<{
select: [option: OverflowMenuAction | OverflowMenuLink]
open: []
close: []
}>()
const triggerButton = ref<ButtonElementHandle | null>(null)
const triggerElement = computed(() => triggerButton.value?.element ?? null)
const panelElement = ref<HTMLElement | null>(null)
const resolvedPlacement = computed(() => props.placement)
const resolvedDistance = computed(() => props.distance)
const menuId = `button-overflow-${useId()}`
const selectedIndex = ref(-1)
const typeahead = ref('')
let typeaheadTimer: ReturnType<typeof setTimeout> | undefined
let hoverCloseTimer: ReturnType<typeof setTimeout> | undefined
const triggerComponent = computed(() => (props.iconOnly ? IconButton : Button))
const visibleOptions = computed(() => props.options.filter((option) => option.shown !== false))
const menuOptions = computed(() =>
visibleOptions.value.filter(
(option): option is OverflowMenuAction | OverflowMenuLink => option.type !== 'divider',
),
)
const { isOpen, panelStyle, anchorStyle, resolvedSide, open, close } = useAnchoredTeleport(
triggerElement,
panelElement,
resolvedPlacement,
resolvedDistance,
)
const menuItemClasses =
'overflow-menu-item flex min-h-10 w-full items-center gap-2 rounded-[10px] border-0 bg-transparent px-3 py-2 text-left text-base font-semibold leading-5 text-contrast no-underline ' +
'cursor-pointer whitespace-nowrap hover:bg-surface-4 focus-visible:bg-surface-4 focus-visible:outline-none ' +
'disabled:cursor-not-allowed disabled:opacity-50 [&[aria-disabled=true]]:cursor-not-allowed [&[aria-disabled=true]]:opacity-50 ' +
'[&>svg]:size-5 [&>svg]:shrink-0 [&>svg]:text-primary'
const toneVariables: Record<ButtonColor, string> = {
brand: 'var(--color-brand)',
red: 'var(--color-red)',
orange: 'var(--color-orange)',
green: 'var(--color-green)',
blue: 'var(--color-blue)',
purple: 'var(--color-purple)',
medal_promotion: 'var(--medal-promotion-text-orange, var(--color-orange))',
}
function getMenuItemStyle(option: OverflowMenuAction | OverflowMenuLink) {
if (!option.tone || option.tone === 'default') return undefined
return {
'--overflow-menu-item-tone': toneVariables[option.tone],
} as CSSProperties
}
function isDivider(
option: OverflowMenuOption,
): option is Extract<OverflowMenuOption, { type: 'divider' }> {
return option.type === 'divider'
}
function isLink(option: OverflowMenuOption): option is OverflowMenuLink {
return option.type === 'link'
}
function getMenuItems() {
if (!panelElement.value) return []
return Array.from(panelElement.value.querySelectorAll<HTMLElement>('[role="menuitem"]'))
}
function focusItem(index: number) {
const items = getMenuItems()
if (items.length === 0) return
selectedIndex.value = (index + items.length) % items.length
items[selectedIndex.value]?.focus()
}
async function openMenu(position: 'first' | 'last' = 'first', focus = true) {
if (props.disabled || isOpen.value) return
await open()
emit('open')
if (!focus) return
await nextTick()
focusItem(position === 'first' ? 0 : getMenuItems().length - 1)
}
function closeMenu(restoreFocus = false) {
if (!isOpen.value) return
selectedIndex.value = -1
close(restoreFocus)
}
async function toggleMenu(event?: MouseEvent) {
if (props.hoverable && window.matchMedia('(hover: hover)').matches && event?.detail) return
if (isOpen.value) closeMenu()
else await openMenu()
}
function clearHoverCloseTimer() {
if (!hoverCloseTimer) return
clearTimeout(hoverCloseTimer)
hoverCloseTimer = undefined
}
function handleMouseEnter() {
if (!props.hoverable || !window.matchMedia('(hover: hover)').matches) return
clearHoverCloseTimer()
openMenu('first', false)
}
function handleMouseLeave() {
if (!props.hoverable || !window.matchMedia('(hover: hover)').matches) return
clearHoverCloseTimer()
hoverCloseTimer = setTimeout(() => closeMenu(), 250)
}
function handleTriggerKeydown(event: KeyboardEvent) {
if (event.key !== 'ArrowDown' && event.key !== 'ArrowUp') return
event.preventDefault()
openMenu(event.key === 'ArrowDown' ? 'first' : 'last')
}
function handleAction(option: OverflowMenuAction, event: MouseEvent) {
if (option.disabled) return
option.action(event)
emit('select', option)
if (!option.remainOpen) closeMenu(true)
}
function handleLink(option: OverflowMenuLink, event: MouseEvent) {
if (option.disabled) {
event.preventDefault()
return
}
emit('select', option)
if (!option.remainOpen) closeMenu()
}
function handleLinkKeydown(option: OverflowMenuLink, event: KeyboardEvent) {
if (event.key !== ' ') return
event.preventDefault()
if (option.disabled) return
;(event.currentTarget as HTMLElement).click()
}
function handleMenuKeydown(event: KeyboardEvent) {
const items = getMenuItems()
if (items.length === 0) return
switch (event.key) {
case 'ArrowDown':
event.preventDefault()
focusItem(selectedIndex.value + 1)
break
case 'ArrowUp':
event.preventDefault()
focusItem(selectedIndex.value - 1)
break
case 'Home':
event.preventDefault()
focusItem(0)
break
case 'End':
event.preventDefault()
focusItem(items.length - 1)
break
case 'Escape':
event.preventDefault()
closeMenu(true)
break
case 'Tab':
triggerElement.value?.focus()
closeMenu()
break
default: {
if (
event.key === ' ' ||
event.key.length !== 1 ||
event.ctrlKey ||
event.metaKey ||
event.altKey
)
return
const character = event.key.toLocaleLowerCase()
const query = typeahead.value === character ? character : `${typeahead.value}${character}`
const startIndex = query.length === 1 ? selectedIndex.value + 1 : 0
typeahead.value = query
for (let offset = 0; offset < menuOptions.value.length; offset++) {
const index = (startIndex + offset) % menuOptions.value.length
if (menuOptions.value[index]?.label.toLocaleLowerCase().startsWith(query)) {
focusItem(index)
break
}
}
if (typeaheadTimer) clearTimeout(typeaheadTimer)
typeaheadTimer = setTimeout(() => {
typeahead.value = ''
}, 500)
}
}
}
watch(isOpen, (openState, previousOpenState) => {
if (!openState && previousOpenState) emit('close')
if (!openState && typeaheadTimer) {
clearTimeout(typeaheadTimer)
typeaheadTimer = undefined
typeahead.value = ''
}
})
onUnmounted(clearHoverCloseTimer)
defineExpose({ open: openMenu, close: closeMenu })
</script>
<template>
<component
:is="triggerComponent"
ref="triggerButton"
v-bind="$attrs"
v-tooltip="props.tooltip"
:label="props.iconOnly ? props.label : undefined"
:aria-label="props.iconOnly ? undefined : props.label"
:circular="props.iconOnly ? props.circular : undefined"
:type="props.type"
:color="props.color"
:size="props.size"
:interaction="props.interaction"
:disabled="props.disabled"
:aria-expanded="isOpen"
:aria-controls="menuId"
aria-haspopup="menu"
@click="toggleMenu"
@keydown="handleTriggerKeydown"
@mouseenter="handleMouseEnter"
@mouseleave="handleMouseLeave"
>
<slot />
</component>
<Teleport to="body">
<Transition
enter-active-class="transition duration-125 ease-out"
enter-from-class="scale-95 opacity-0"
enter-to-class="scale-100 opacity-100"
leave-active-class="transition duration-100 ease-in"
leave-from-class="scale-100 opacity-100"
leave-to-class="scale-95 opacity-0"
>
<div
v-if="isOpen"
:id="menuId"
ref="panelElement"
class="fixed isolate z-[9999] flex min-w-48 flex-col gap-1 rounded-[14px] bg-surface-3 p-2 shadow-lg ring-1 ring-surface-5"
:style="panelStyle"
role="menu"
:aria-label="props.label"
@keydown="handleMenuKeydown"
@mouseenter="handleMouseEnter"
@mouseleave="handleMouseLeave"
>
<span
aria-hidden="true"
class="overflow-menu-arrow"
:data-side="resolvedSide"
:style="anchorStyle"
/>
<template v-for="(option, index) in visibleOptions" :key="option.id ?? `divider-${index}`">
<div v-if="isDivider(option)" role="separator" class="my-1 h-px bg-surface-5" />
<RouterLink
v-else-if="isLink(option) && option.to !== undefined && !option.disabled"
v-tooltip="option.tooltip"
:to="option.to"
:class="menuItemClasses"
:style="getMenuItemStyle(option)"
:data-tone="option.tone && option.tone !== 'default' ? option.tone : undefined"
:data-hover-filled="option.hoverFilled || option.hoverFilledOnly || undefined"
:data-hover-filled-only="option.hoverFilledOnly || undefined"
role="menuitem"
tabindex="-1"
@click="handleLink(option, $event)"
@keydown="handleLinkKeydown(option, $event)"
@focus="selectedIndex = getMenuItems().indexOf($event.currentTarget as HTMLElement)"
>
<slot :name="option.id" :option="option">
<component :is="option.icon" v-if="option.icon" aria-hidden="true" />
{{ option.label }}
</slot>
</RouterLink>
<a
v-else-if="isLink(option)"
v-tooltip="option.tooltip"
:href="option.disabled ? undefined : option.href"
:target="option.target"
:rel="option.rel ?? (option.target === '_blank' ? 'noopener noreferrer' : undefined)"
:download="option.download"
:aria-disabled="option.disabled || undefined"
:class="menuItemClasses"
:style="getMenuItemStyle(option)"
:data-tone="option.tone && option.tone !== 'default' ? option.tone : undefined"
:data-hover-filled="option.hoverFilled || option.hoverFilledOnly || undefined"
:data-hover-filled-only="option.hoverFilledOnly || undefined"
role="menuitem"
tabindex="-1"
@click="handleLink(option, $event)"
@keydown="handleLinkKeydown(option, $event)"
@focus="selectedIndex = getMenuItems().indexOf($event.currentTarget as HTMLElement)"
>
<slot :name="option.id" :option="option">
<component :is="option.icon" v-if="option.icon" aria-hidden="true" />
{{ option.label }}
</slot>
</a>
<button
v-else
v-tooltip="option.tooltip"
type="button"
:aria-disabled="option.disabled || undefined"
:class="menuItemClasses"
:style="getMenuItemStyle(option)"
:data-tone="option.tone && option.tone !== 'default' ? option.tone : undefined"
:data-hover-filled="option.hoverFilled || option.hoverFilledOnly || undefined"
:data-hover-filled-only="option.hoverFilledOnly || undefined"
role="menuitem"
tabindex="-1"
@click="handleAction(option, $event)"
@focus="selectedIndex = getMenuItems().indexOf($event.currentTarget as HTMLElement)"
>
<slot :name="option.id" :option="option">
<component :is="option.icon" v-if="option.icon" aria-hidden="true" />
{{ option.label }}
</slot>
</button>
</template>
</div>
</Transition>
</Teleport>
</template>
<style scoped>
.overflow-menu-arrow {
position: absolute;
width: 0;
height: 0;
pointer-events: none;
}
.overflow-menu-arrow::before {
position: absolute;
width: 10px;
height: 10px;
content: '';
background-color: var(--surface-3);
transform: translate(-50%, -50%) rotate(45deg);
}
.overflow-menu-arrow[data-side='bottom'] {
top: 0;
}
.overflow-menu-arrow[data-side='bottom']::before {
border-top: 1px solid var(--surface-5);
border-left: 1px solid var(--surface-5);
}
.overflow-menu-arrow[data-side='top'] {
bottom: 0;
}
.overflow-menu-arrow[data-side='top']::before {
border-right: 1px solid var(--surface-5);
border-bottom: 1px solid var(--surface-5);
}
.overflow-menu-arrow[data-side='right'] {
left: 0;
}
.overflow-menu-arrow[data-side='right']::before {
border-bottom: 1px solid var(--surface-5);
border-left: 1px solid var(--surface-5);
}
.overflow-menu-arrow[data-side='left'] {
right: 0;
}
.overflow-menu-arrow[data-side='left']::before {
border-top: 1px solid var(--surface-5);
border-right: 1px solid var(--surface-5);
}
.overflow-menu-item[data-tone]:not([data-hover-filled-only]) {
color: var(--overflow-menu-item-tone);
}
.overflow-menu-item[data-tone]:not([data-hover-filled-only]) :deep(svg) {
color: var(--overflow-menu-item-tone);
}
.overflow-menu-item[data-tone][data-hover-filled]:hover,
.overflow-menu-item[data-tone][data-hover-filled]:focus-visible {
color: var(--color-accent-contrast);
background-color: var(--overflow-menu-item-tone);
}
.overflow-menu-item[data-tone][data-hover-filled]:hover :deep(svg),
.overflow-menu-item[data-tone][data-hover-filled]:focus-visible :deep(svg) {
color: inherit;
}
</style>
@@ -0,0 +1,149 @@
<script setup lang="ts">
import { computed, nextTick, ref, useId, watch } from 'vue'
import { useAnchoredTeleport } from '../../../utils/use-anchored-teleport'
import Button from './Button.vue'
import IconButton from './IconButton.vue'
import type {
ButtonColor,
ButtonElementHandle,
ButtonInteraction,
ButtonSize,
ButtonType,
TeleportPlacement,
} from './types'
defineOptions({ inheritAttrs: false })
const props = withDefaults(
defineProps<{
label: string
type?: ButtonType
color?: ButtonColor
size?: ButtonSize
interaction?: ButtonInteraction
disabled?: boolean
iconOnly?: boolean
tooltip?: string
autoFocus?: boolean
placement?: TeleportPlacement
panelRole?: 'dialog' | 'region'
}>(),
{
type: 'base',
size: 'md',
disabled: false,
iconOnly: false,
autoFocus: true,
placement: 'bottom-end',
panelRole: 'dialog',
},
)
const emit = defineEmits<{
open: []
close: []
}>()
const triggerButton = ref<ButtonElementHandle | null>(null)
const triggerElement = computed(() => triggerButton.value?.element ?? null)
const panelElement = ref<HTMLElement | null>(null)
const resolvedPlacement = computed(() => props.placement)
const panelId = `button-popout-${useId()}`
const triggerComponent = computed(() => (props.iconOnly ? IconButton : Button))
const { isOpen, panelStyle, open, close } = useAnchoredTeleport(
triggerElement,
panelElement,
resolvedPlacement,
)
function focusPanel() {
const focusable = panelElement.value?.querySelector<HTMLElement>(
'button:not([disabled]), a[href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])',
)
;(focusable ?? panelElement.value)?.focus()
}
async function openMenu() {
if (props.disabled || isOpen.value) return
await open()
emit('open')
if (props.autoFocus) await nextTick(focusPanel)
}
function closeMenu(restoreFocus = true) {
if (!isOpen.value) return
close(restoreFocus)
}
async function toggleMenu() {
if (isOpen.value) closeMenu()
else await openMenu()
}
function handleTriggerKeydown(event: KeyboardEvent) {
if (event.key !== 'Escape' || !isOpen.value) return
event.preventDefault()
closeMenu()
}
function handlePanelKeydown(event: KeyboardEvent) {
if (event.key !== 'Escape') return
event.preventDefault()
closeMenu(true)
}
watch(isOpen, (openState, previousOpenState) => {
if (!openState && previousOpenState) emit('close')
})
defineExpose({ open: openMenu, close: closeMenu })
</script>
<template>
<component
:is="triggerComponent"
ref="triggerButton"
v-bind="$attrs"
v-tooltip="props.tooltip"
:label="props.iconOnly ? props.label : undefined"
:type="props.type"
:color="props.color"
:size="props.size"
:interaction="props.interaction"
:disabled="props.disabled"
:aria-expanded="isOpen"
:aria-controls="panelId"
:aria-haspopup="props.panelRole === 'dialog' ? 'dialog' : undefined"
@click="toggleMenu"
@keydown="handleTriggerKeydown"
>
<slot name="trigger" />
</component>
<Teleport to="body">
<Transition
enter-active-class="transition duration-125 ease-out"
enter-from-class="scale-95 opacity-0"
enter-to-class="scale-100 opacity-100"
leave-active-class="transition duration-100 ease-in"
leave-from-class="scale-100 opacity-100"
leave-to-class="scale-95 opacity-0"
>
<div
v-if="isOpen"
:id="panelId"
ref="panelElement"
class="fixed isolate z-[9999] rounded-[14px] bg-surface-3 p-4 text-primary shadow-lg ring-1 ring-surface-5"
:style="panelStyle"
:role="props.panelRole"
:aria-label="props.label"
tabindex="-1"
@keydown="handlePanelKeydown"
>
<slot name="panel" :close="closeMenu" />
</div>
</Transition>
</Teleport>
</template>
@@ -0,0 +1,23 @@
export { default as Button } from './Button.vue'
export { default as ButtonGroup } from './ButtonGroup.vue'
export { default as ButtonLink } from './ButtonLink.vue'
export { default as FileButton } from './FileButton.vue'
export { default as IconButton } from './IconButton.vue'
export { default as SplitButton } from './SplitButton.vue'
export { default as TeleportOverflowMenu } from './TeleportOverflowMenu.vue'
export { default as TeleportPopoutMenu } from './TeleportPopoutMenu.vue'
export type {
ButtonColor,
ButtonElementHandle,
ButtonInteraction,
ButtonLinkDestination,
ButtonNativeType,
ButtonSize,
ButtonType,
ButtonVisualProps,
OverflowMenuAction,
OverflowMenuDivider,
OverflowMenuLink,
OverflowMenuOption,
TeleportPlacement,
} from './types'
@@ -0,0 +1,105 @@
import type { Component } from 'vue'
import type { RouteLocationRaw } from 'vue-router'
import type { AnchoredTeleportPlacement } from '../../../utils/use-anchored-teleport'
export type ButtonType = 'base' | 'colored' | 'outlined' | 'quiet'
export type ButtonSize = 'xs' | 'sm' | 'md' | 'lg' | 'xl'
export type ButtonInteraction = 'surface' | 'filled' | 'none'
// TODO: Standardized color string enum props across @modrinth/ui
export type ButtonColor =
| 'brand'
| 'red'
| 'orange'
| 'green'
| 'blue'
| 'purple'
| 'medal_promotion'
export type ButtonVisualProps = {
size?: ButtonSize
interaction?: ButtonInteraction
} & (
| {
type?: 'base'
color?: never
}
| {
type: 'outlined'
color?: ButtonColor
}
| {
type: 'colored'
color?: ButtonColor
}
| {
type: 'quiet'
color?: ButtonColor
}
)
export type ButtonNativeType = 'button' | 'submit' | 'reset'
export interface ButtonProps {
type?: ButtonType
color?: ButtonColor
size?: ButtonSize
interaction?: ButtonInteraction
nativeType?: ButtonNativeType
disabled?: boolean
loading?: boolean
}
export type ButtonLinkDestination =
| {
to: RouteLocationRaw
href?: never
}
| {
href: string
to?: never
}
export type TeleportPlacement = AnchoredTeleportPlacement
export interface OverflowMenuItemBase {
id: string
label: string
icon?: Component
shown?: boolean
disabled?: boolean
tooltip?: string
remainOpen?: boolean
tone?: 'default' | ButtonColor
hoverFilled?: boolean
hoverFilledOnly?: boolean
}
export interface OverflowMenuAction extends OverflowMenuItemBase {
type?: 'action'
action: (event: MouseEvent) => void
}
export interface OverflowMenuLink extends OverflowMenuItemBase {
type: 'link'
to?: RouteLocationRaw
href?: string
target?: string
rel?: string
download?: string | boolean
}
export interface OverflowMenuDivider {
type: 'divider'
id?: string
shown?: boolean
}
export type OverflowMenuOption = OverflowMenuAction | OverflowMenuLink | OverflowMenuDivider
export interface ButtonElementHandle {
element: HTMLElement | null
}