Compare commits

...
Author SHA1 Message Date
Calum H.andGitHub a60c0dc395 Merge branch 'main' into cal/styled-button 2026-07-16 15:50:18 +01:00
Calum H. (IMB11) 72fa9dd131 feat: refactor button components 2026-07-16 15:26:16 +01:00
19 changed files with 1625 additions and 2 deletions
+4 -2
View File
@@ -1,4 +1,4 @@
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import type { StorybookConfig } from '@storybook/vue3-vite'
import { mergeConfig } from 'vite'
@@ -16,7 +16,9 @@ const config: StorybookConfig = {
mergeConfig(config, {
resolve: {
alias: {
'@modrinth/api-client': path.resolve(__dirname, '../../api-client/src/index.ts'),
'@modrinth/api-client': fileURLToPath(
new URL('../../api-client/src/index.ts', import.meta.url),
),
},
},
}),
@@ -0,0 +1,44 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import ButtonFrame from './ButtonFrame.vue'
import type { ButtonNativeType, ButtonSize, ButtonTone, ButtonVariant } from './types'
const props = withDefaults(
defineProps<{
variant?: ButtonVariant
tone?: ButtonTone
size?: ButtonSize
type?: ButtonNativeType
disabled?: boolean
loading?: boolean
}>(),
{
variant: 'base',
size: 'default',
type: '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"
:variant="props.variant"
:tone="props.tone"
:size="props.size"
:type="props.type"
:disabled="props.disabled || props.loading"
:aria-busy="props.loading || undefined"
>
<slot />
</ButtonFrame>
</template>
@@ -0,0 +1,128 @@
<script setup lang="ts">
import type { Component, CSSProperties } from 'vue'
import { computed, ref } from 'vue'
import type { ButtonSize, ButtonTone, ButtonVariant } from './types'
const baseClasses = [
'relative inline-flex min-w-0 shrink-0 touch-manipulation items-center justify-center',
'whitespace-nowrap border-0 no-underline',
'cursor-pointer select-none transition-[background-color,color,box-shadow,filter,opacity,transform] duration-150 ease-out',
'hover:brightness-[--hover-brightness] focus-visible:brightness-[--hover-brightness]',
'focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-brand-shadow',
'enabled:active:scale-[0.97] disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50',
'[&[aria-disabled=true]]:pointer-events-none [&[aria-disabled=true]]:cursor-not-allowed [&[aria-disabled=true]]:opacity-50',
].join(' ')
const sizeClasses: Record<ButtonSize, string> = {
sm: 'h-6 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',
default:
'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',
md: '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',
lg: '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> = {
sm: 'w-6 px-0',
default: 'w-9 px-0',
md: 'w-10 px-0',
lg: 'w-12 px-0',
}
const variantClasses: Record<ButtonVariant, string> = {
base: 'button-frame--base bg-surface-4 text-contrast [&>svg]:text-primary',
colored: 'button-frame--colored bg-[--button-tone] text-[rgba(0,0,0,0.9)] [&>svg]:text-inherit',
outlined: 'button-frame--outlined bg-transparent text-contrast [&>svg]:text-primary',
quiet:
'button-frame--quiet bg-transparent hover:bg-surface-4 focus-visible:bg-surface-4 [&>svg]:text-inherit',
}
const toneVariables: Record<ButtonTone, 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)',
promotion: 'var(--medal-promotion-text-orange, var(--color-orange))',
}
const props = withDefaults(
defineProps<{
as: string | Component
variant?: ButtonVariant
tone?: ButtonTone
size?: ButtonSize
iconOnly?: boolean
}>(),
{
variant: 'base',
size: 'default',
iconOnly: false,
},
)
const element = ref<HTMLElement | null>(null)
const classes = computed(() => [
baseClasses,
variantClasses[props.variant],
sizeClasses[props.size],
props.iconOnly ? iconOnlySizeClasses[props.size] : '',
])
const style = computed((): CSSProperties | undefined => {
if (props.variant === 'quiet' && !props.tone) return undefined
if (props.variant !== 'colored' && props.variant !== 'quiet') return undefined
return {
'--button-tone': toneVariables[props.tone ?? 'brand'],
} as CSSProperties
})
defineExpose({ element })
</script>
<template>
<component :is="as" ref="element" data-button :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-tone) 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(--surface-5);
}
.button-frame--quiet {
color: var(--button-tone, var(--color-base));
}
</style>
@@ -0,0 +1,20 @@
<script setup lang="ts">
withDefaults(
defineProps<{
label?: string
}>(),
{
label: undefined,
},
)
</script>
<template>
<div
role="group"
:aria-label="label"
class="inline-flex [&>[data-button]:focus-visible]:z-10 [&>[data-button]:not(:first-child)]:rounded-l-none [&>[data-button]:not(:last-child)]:rounded-r-none"
>
<slot />
</div>
</template>
@@ -0,0 +1,63 @@
<script setup lang="ts">
import { computed } from 'vue'
import { RouterLink, type RouteLocationRaw } from 'vue-router'
import ButtonFrame from './ButtonFrame.vue'
import type { ButtonSize, ButtonTone, ButtonVariant } from './types'
const props = withDefaults(
defineProps<{
to?: RouteLocationRaw
href?: string
variant?: ButtonVariant
tone?: ButtonTone
size?: ButtonSize
target?: string
rel?: string
download?: string | boolean
disabled?: boolean
}>(),
{
to: undefined,
href: undefined,
variant: 'base',
size: 'default',
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"
:variant="props.variant"
:tone="props.tone"
:size="props.size"
: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"
:tabindex="props.disabled ? -1 : undefined"
@click="handleClick"
>
<slot />
</ButtonFrame>
</template>
@@ -0,0 +1,83 @@
<script setup lang="ts">
import { fileIsValid } from '@modrinth/utils'
import { useFormatBytes } from '../../../composables'
import ButtonFrame from './ButtonFrame.vue'
import type { ButtonSize, ButtonTone, ButtonVariant } from './types'
const props = withDefaults(
defineProps<{
prompt?: string
multiple?: boolean
accept?: string
maxSize?: number | null
disabled?: boolean
allowDrop?: boolean
variant?: ButtonVariant
tone?: ButtonTone
size?: ButtonSize
}>(),
{
prompt: 'Select file',
multiple: false,
accept: undefined,
maxSize: undefined,
disabled: false,
allowDrop: true,
variant: 'base',
size: 'default',
},
)
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"
:variant="props.variant"
:tone="props.tone"
:size="props.size"
: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,47 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import ButtonFrame from './ButtonFrame.vue'
import type { ButtonNativeType, ButtonSize, ButtonTone, ButtonVariant } from './types'
const props = withDefaults(
defineProps<{
label: string
variant?: ButtonVariant
tone?: ButtonTone
size?: ButtonSize
type?: ButtonNativeType
disabled?: boolean
loading?: boolean
}>(),
{
variant: 'base',
size: 'default',
type: '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"
icon-only
:variant="props.variant"
:tone="props.tone"
:size="props.size"
:type="props.type"
:disabled="props.disabled || props.loading"
:aria-label="props.label"
:aria-busy="props.loading || undefined"
>
<slot />
</ButtonFrame>
</template>
@@ -0,0 +1,83 @@
<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 {
ButtonNativeType,
ButtonSize,
ButtonTone,
ButtonVariant,
OverflowMenuAction,
OverflowMenuLink,
OverflowMenuOption,
TeleportPlacement,
} from './types'
const props = withDefaults(
defineProps<{
menuLabel: string
options: OverflowMenuOption[]
groupLabel?: string
variant?: ButtonVariant
tone?: ButtonTone
size?: ButtonSize
type?: ButtonNativeType
disabled?: boolean
primaryDisabled?: boolean
menuDisabled?: boolean
placement?: TeleportPlacement
}>(),
{
groupLabel: undefined,
variant: 'base',
size: 'default',
type: '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
:variant="props.variant"
:tone="props.tone"
:size="props.size"
:type="props.type"
:disabled="props.disabled || props.primaryDisabled"
@click="emit('click', $event)"
>
<slot />
</Button>
<TeleportOverflowMenu
:label="props.menuLabel"
:options="props.options"
:variant="props.variant"
:tone="props.tone"
:size="props.size"
: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,288 @@
<script setup lang="ts">
import { computed, nextTick, ref, useId, watch } from 'vue'
import { RouterLink } from 'vue-router'
import IconButton from './IconButton.vue'
import type {
ButtonSize,
ButtonTone,
ButtonVariant,
OverflowMenuAction,
OverflowMenuLink,
OverflowMenuOption,
TeleportPlacement,
} from './types'
import { useAnchoredTeleport } from './useAnchoredTeleport'
defineOptions({ inheritAttrs: false })
const props = withDefaults(
defineProps<{
label: string
options: OverflowMenuOption[]
variant?: ButtonVariant
tone?: ButtonTone
size?: ButtonSize
disabled?: boolean
placement?: TeleportPlacement
}>(),
{
variant: 'base',
size: 'default',
disabled: false,
placement: 'bottom-end',
},
)
const emit = defineEmits<{
select: [option: OverflowMenuAction | OverflowMenuLink]
open: []
close: []
}>()
const triggerButton = ref<InstanceType<typeof IconButton> | null>(null)
const triggerElement = computed(() => triggerButton.value?.element ?? null)
const panelElement = ref<HTMLElement | null>(null)
const resolvedPlacement = computed(() => props.placement)
const menuId = `button-overflow-${useId()}`
const selectedIndex = ref(-1)
const typeahead = ref('')
let typeaheadTimer: ReturnType<typeof setTimeout> | undefined
const visibleOptions = computed(() => props.options.filter((option) => option.shown !== false))
const interactiveOptions = computed(() =>
visibleOptions.value.filter(
(option): option is OverflowMenuAction | OverflowMenuLink =>
option.type !== 'divider' && !option.disabled,
),
)
const { isOpen, panelStyle, open, close } = useAnchoredTeleport(
triggerElement,
panelElement,
resolvedPlacement,
)
const menuItemClasses =
'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:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 [&[aria-disabled=true]]:pointer-events-none [&[aria-disabled=true]]:opacity-50 ' +
'[&>svg]:size-5 [&>svg]:shrink-0 [&>svg]:text-primary'
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"]:not([disabled]):not([aria-disabled="true"])',
),
)
}
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') {
if (props.disabled || isOpen.value) return
await open()
emit('open')
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() {
if (isOpen.value) closeMenu()
else await openMenu()
}
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()
}
function handleLink(option: OverflowMenuLink, event: MouseEvent) {
if (option.disabled) {
event.preventDefault()
return
}
emit('select', option)
if (!option.remainOpen) closeMenu()
}
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':
closeMenu()
break
default: {
if (event.key.length !== 1 || event.ctrlKey || event.metaKey || event.altKey) return
typeahead.value += event.key.toLocaleLowerCase()
const match = interactiveOptions.value.findIndex((option) =>
option.label.toLocaleLowerCase().startsWith(typeahead.value),
)
if (match >= 0) focusItem(match)
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 = ''
}
})
defineExpose({ open: openMenu, close: closeMenu })
</script>
<template>
<IconButton
ref="triggerButton"
v-bind="$attrs"
:label="props.label"
:variant="props.variant"
:tone="props.tone"
:size="props.size"
:disabled="props.disabled"
:aria-expanded="isOpen"
:aria-controls="menuId"
aria-haspopup="menu"
@click="toggleMenu"
@keydown="handleTriggerKeydown"
>
<slot />
</IconButton>
<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"
>
<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, option.tone === 'red' ? 'text-red [&>svg]:text-red' : '']"
role="menuitem"
@click="handleLink(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"
:tabindex="option.disabled ? -1 : undefined"
:class="[menuItemClasses, option.tone === 'red' ? 'text-red [&>svg]:text-red' : '']"
role="menuitem"
@click="handleLink(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"
:disabled="option.disabled"
:class="[menuItemClasses, option.tone === 'red' ? 'text-red [&>svg]:text-red' : '']"
role="menuitem"
@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>
@@ -0,0 +1,138 @@
<script setup lang="ts">
import { computed, nextTick, ref, useId, watch } from 'vue'
import Button from './Button.vue'
import IconButton from './IconButton.vue'
import type {
ButtonElementHandle,
ButtonSize,
ButtonTone,
ButtonVariant,
TeleportPlacement,
} from './types'
import { useAnchoredTeleport } from './useAnchoredTeleport'
defineOptions({ inheritAttrs: false })
const props = withDefaults(
defineProps<{
label: string
variant?: ButtonVariant
tone?: ButtonTone
size?: ButtonSize
disabled?: boolean
iconOnly?: boolean
placement?: TeleportPlacement
panelRole?: 'dialog' | 'region'
focusOnOpen?: boolean
}>(),
{
variant: 'base',
size: 'default',
disabled: false,
iconOnly: false,
placement: 'bottom-end',
panelRole: 'dialog',
focusOnOpen: true,
},
)
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.focusOnOpen) await nextTick(focusPanel)
}
function closeMenu(restoreFocus = false) {
if (!isOpen.value) return
close(restoreFocus)
}
async function toggleMenu() {
if (isOpen.value) closeMenu()
else await openMenu()
}
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"
:label="props.iconOnly ? props.label : undefined"
:aria-label="props.iconOnly ? undefined : props.label"
:variant="props.variant"
:tone="props.tone"
:size="props.size"
:disabled="props.disabled"
:aria-expanded="isOpen"
:aria-controls="panelId"
aria-haspopup="dialog"
@click="toggleMenu"
>
<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 ButtonFrame } from './ButtonFrame.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 {
ButtonElementHandle,
ButtonLinkDestination,
ButtonNativeType,
ButtonSize,
ButtonTone,
ButtonVariant,
ButtonVisualProps,
OverflowMenuAction,
OverflowMenuDivider,
OverflowMenuLink,
OverflowMenuOption,
TeleportPlacement,
} from './types'
@@ -0,0 +1,80 @@
import type { Component } from 'vue'
import type { RouteLocationRaw } from 'vue-router'
export type ButtonVariant = 'base' | 'colored' | 'outlined' | 'quiet'
export type ButtonSize = 'sm' | 'default' | 'md' | 'lg'
export type ButtonTone = 'brand' | 'red' | 'orange' | 'green' | 'blue' | 'purple' | 'promotion'
export type ButtonVisualProps = {
size?: ButtonSize
} & (
| {
variant?: 'base'
tone?: never
}
| {
variant: 'outlined'
tone?: never
}
| {
variant: 'colored'
tone?: ButtonTone
}
| {
variant: 'quiet'
tone?: ButtonTone
}
)
export type ButtonNativeType = 'button' | 'submit' | 'reset'
export type ButtonLinkDestination =
| {
to: RouteLocationRaw
href?: never
}
| {
href: string
to?: never
}
export type TeleportPlacement = 'bottom-start' | 'bottom-end' | 'top-start' | 'top-end'
export interface OverflowMenuItemBase {
id: string
label: string
icon?: Component
shown?: boolean
disabled?: boolean
tooltip?: string
remainOpen?: boolean
tone?: 'default' | 'red'
}
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
}
@@ -0,0 +1,107 @@
import type { CSSProperties, Ref } from 'vue'
import { nextTick, onUnmounted, ref, watch } from 'vue'
import type { TeleportPlacement } from './types'
const viewportPadding = 8
export function useAnchoredTeleport(
trigger: Readonly<Ref<HTMLElement | null>>,
panel: Readonly<Ref<HTMLElement | null>>,
placement: Readonly<Ref<TeleportPlacement>>,
) {
const isOpen = ref(false)
const panelStyle = ref<CSSProperties>({
top: '0px',
left: '0px',
visibility: 'hidden',
})
let resizeObserver: ResizeObserver | undefined
function updatePosition() {
if (!isOpen.value || !trigger.value || !panel.value) return
const triggerRect = trigger.value.getBoundingClientRect()
const panelRect = panel.value.getBoundingClientRect()
const offset = 8
const prefersTop = placement.value.startsWith('top')
const alignsEnd = placement.value.endsWith('end')
const spaceBelow = window.innerHeight - triggerRect.bottom - viewportPadding
const spaceAbove = triggerRect.top - viewportPadding
const opensAbove = prefersTop
? panelRect.height + offset <= spaceAbove || spaceAbove > spaceBelow
: panelRect.height + offset > spaceBelow && spaceAbove > spaceBelow
const idealTop = opensAbove
? triggerRect.top - panelRect.height - offset
: triggerRect.bottom + offset
const idealLeft = alignsEnd ? triggerRect.right - panelRect.width : triggerRect.left
const maxTop = Math.max(viewportPadding, window.innerHeight - panelRect.height - viewportPadding)
const maxLeft = Math.max(viewportPadding, window.innerWidth - panelRect.width - viewportPadding)
panelStyle.value = {
top: `${Math.min(Math.max(idealTop, viewportPadding), maxTop)}px`,
left: `${Math.min(Math.max(idealLeft, viewportPadding), maxLeft)}px`,
visibility: 'visible',
}
}
function handlePointerDown(event: PointerEvent) {
const target = event.target as Node | null
if (!target || trigger.value?.contains(target) || panel.value?.contains(target)) return
close()
}
function addListeners() {
document.addEventListener('pointerdown', handlePointerDown)
window.addEventListener('resize', updatePosition)
window.addEventListener('scroll', updatePosition, true)
resizeObserver = new ResizeObserver(updatePosition)
if (trigger.value) resizeObserver.observe(trigger.value)
if (panel.value) resizeObserver.observe(panel.value)
}
function removeListeners() {
document.removeEventListener('pointerdown', handlePointerDown)
window.removeEventListener('resize', updatePosition)
window.removeEventListener('scroll', updatePosition, true)
resizeObserver?.disconnect()
resizeObserver = undefined
}
async function open() {
if (isOpen.value) return
panelStyle.value = { top: '0px', left: '0px', visibility: 'hidden' }
isOpen.value = true
await nextTick()
updatePosition()
addListeners()
}
function close(restoreFocus = false) {
if (!isOpen.value) return
isOpen.value = false
removeListeners()
if (restoreFocus) nextTick(() => trigger.value?.focus())
}
watch(placement, updatePosition)
watch(panel, () => {
if (!isOpen.value) return
resizeObserver?.disconnect()
if (trigger.value) resizeObserver?.observe(trigger.value)
if (panel.value) resizeObserver?.observe(panel.value)
updatePosition()
})
onUnmounted(removeListeners)
return {
isOpen,
panelStyle,
open,
close,
}
}
+19
View File
@@ -9,6 +9,20 @@ export { default as BaseTerminal } from './BaseTerminal.vue'
export { default as BigOptionButton } from './BigOptionButton.vue'
export { default as BulletDivider } from './BulletDivider.vue'
export { default as Button } from './Button.vue'
export { default as ButtonGroup } from './buttons/ButtonGroup.vue'
export { default as ButtonLink } from './buttons/ButtonLink.vue'
export type {
ButtonNativeType,
ButtonSize,
ButtonTone,
ButtonVariant,
ButtonVisualProps,
OverflowMenuAction,
OverflowMenuDivider,
OverflowMenuLink,
OverflowMenuOption,
TeleportPlacement,
} from './buttons/types'
export { default as ButtonStyled } from './ButtonStyled.vue'
export { default as Card } from './Card.vue'
export { default as Checkbox } from './Checkbox.vue'
@@ -31,6 +45,7 @@ export { default as DropzoneFileInput } from './DropzoneFileInput.vue'
export { default as EmptyState } from './EmptyState.vue'
export { default as EnvironmentIndicator } from './EnvironmentIndicator.vue'
export { default as ErrorInformationCard } from './ErrorInformationCard.vue'
export { default as FileButton } from './buttons/FileButton.vue'
export { default as FileInput } from './FileInput.vue'
export type { FileTreeSelectItem } from './FileTreeSelect.vue'
export { default as FileTreeSelect } from './FileTreeSelect.vue'
@@ -44,6 +59,7 @@ export { default as FormattedTag } from './FormattedTag.vue'
export { default as HeadingLink } from './HeadingLink.vue'
export { default as HorizontalRule } from './HorizontalRule.vue'
export { default as I18nDebugPanel } from './I18nDebugPanel.vue'
export { default as IconButton } from './buttons/IconButton.vue'
export { default as IconSelect } from './IconSelect.vue'
export { default as IntlFormatted } from './IntlFormatted.vue'
export type { JoinedButtonAction } from './JoinedButtons.vue'
@@ -79,6 +95,7 @@ export { default as SettingsLabel } from './SettingsLabel.vue'
export { default as SimpleBadge } from './SimpleBadge.vue'
export { default as Slider } from './Slider.vue'
export { default as SmartClickable } from './SmartClickable.vue'
export { default as SplitButton } from './buttons/SplitButton.vue'
export type { StackedAdmonitionItem, StackedAdmonitionType } from './StackedAdmonitions.vue'
export { default as StackedAdmonitions } from './StackedAdmonitions.vue'
export { default as StatItem } from './StatItem.vue'
@@ -89,6 +106,8 @@ export type { TabsTab, TabsValue } from './Tabs.vue'
export { default as Tabs } from './Tabs.vue'
export { default as TagItem } from './TagItem.vue'
export { default as TagTagItem } from './TagTagItem.vue'
export { default as TeleportOverflowMenu } from './buttons/TeleportOverflowMenu.vue'
export { default as TeleportPopoutMenu } from './buttons/TeleportPopoutMenu.vue'
export type {
TimeFrameLastUnit,
TimeFrameLastUnitOption,
@@ -0,0 +1,208 @@
import { DownloadIcon, ExternalIcon, HeartIcon, SettingsIcon } from '@modrinth/assets'
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import Button from '../../components/base/buttons/Button.vue'
import ButtonLink from '../../components/base/buttons/ButtonLink.vue'
import IconButton from '../../components/base/buttons/IconButton.vue'
const variants = ['base', 'colored', 'outlined', 'quiet'] as const
const sizes = ['sm', 'default', 'md', 'lg'] as const
const tones = ['brand', 'red', 'orange', 'green', 'blue', 'purple', 'promotion'] as const
const sizeColumns = [
{ value: 'sm', label: 'Small' },
{ value: 'default', label: 'Default' },
{ value: 'md', label: 'Medium' },
{ value: 'lg', label: 'Large' },
] as const
const variantRows = [
{ label: 'Base', variant: 'base' },
{ label: 'Outlined', variant: 'outlined' },
{ label: 'Quiet', variant: 'quiet' },
...tones.map((tone) => ({
label: `Colored / ${tone.charAt(0).toUpperCase()}${tone.slice(1)}`,
variant: 'colored' as const,
tone,
})),
...tones.map((tone) => ({
label: `Quiet / ${tone.charAt(0).toUpperCase()}${tone.slice(1)}`,
variant: 'quiet' as const,
tone,
})),
]
const meta = {
title: 'Buttons/Button',
component: Button,
argTypes: {
variant: {
control: 'select',
options: variants,
},
size: {
control: 'select',
options: sizes,
},
tone: {
control: 'select',
options: tones,
},
type: {
control: 'select',
options: ['button', 'submit', 'reset'],
},
disabled: { control: 'boolean' },
loading: { control: 'boolean' },
},
args: {
variant: 'base',
size: 'default',
tone: 'brand',
type: 'button',
disabled: false,
loading: false,
},
render: (args) => ({
components: { Button, DownloadIcon },
setup() {
return { args }
},
template: /*html*/ `
<Button v-bind="args">
<DownloadIcon />
Download
</Button>
`,
}),
} satisfies Meta<typeof Button>
export default meta
type Story = StoryObj<typeof meta>
export const Playground: Story = {}
export const AllVariants: Story = {
render: () => ({
components: { Button, DownloadIcon },
setup() {
return { sizeColumns, variantRows }
},
template: /*html*/ `
<div class="grid grid-cols-[max-content_repeat(4,max-content)] items-center gap-4 overflow-x-auto p-1">
<div />
<div v-for="size in sizeColumns" :key="size.value" class="font-semibold text-contrast">
{{ size.label }}
</div>
<template v-for="row in variantRows" :key="row.label">
<div class="whitespace-nowrap font-semibold text-secondary">{{ row.label }}</div>
<Button
v-for="size in sizeColumns"
:key="size.value"
:variant="row.variant"
:tone="row.tone"
:size="size.value"
>
<DownloadIcon />Button
</Button>
</template>
</div>
`,
}),
}
export const Quiet: Story = {
render: () => ({
components: { Button, DownloadIcon, IconButton, SettingsIcon },
template: /*html*/ `
<div class="flex flex-wrap items-center gap-4">
<Button variant="quiet"><DownloadIcon />Quiet</Button>
<Button variant="quiet" tone="red"><DownloadIcon />Quiet destructive</Button>
<IconButton label="Settings" variant="quiet"><SettingsIcon /></IconButton>
</div>
`,
}),
}
export const Sizes: Story = {
render: () => ({
components: { Button, DownloadIcon, IconButton },
setup() {
return { sizes }
},
template: /*html*/ `
<div class="flex flex-wrap items-center gap-4">
<template v-for="size in sizes" :key="size">
<Button :size="size"><DownloadIcon />{{ size }}</Button>
<IconButton :label="size" :size="size"><DownloadIcon /></IconButton>
</template>
</div>
`,
}),
}
export const ColoredTones: Story = {
render: () => ({
components: { Button },
setup() {
return { tones }
},
template: /*html*/ `
<div class="flex flex-wrap items-center gap-4">
<Button v-for="tone in tones" :key="tone" variant="colored" :tone="tone">
{{ tone }}
</Button>
</div>
`,
}),
}
export const Content: Story = {
render: () => ({
components: { Button, DownloadIcon, SettingsIcon },
template: /*html*/ `
<div class="flex max-w-2xl flex-col items-start gap-4">
<Button>Text only</Button>
<Button><DownloadIcon />Leading icon</Button>
<Button>Trailing icon<SettingsIcon /></Button>
<Button class="w-full">Full width</Button>
<Button>Continue with a deliberately long translated action label</Button>
</div>
`,
}),
}
export const InteractionStates: Story = {
render: () => ({
components: { Button },
template: /*html*/ `
<div class="flex flex-wrap items-center gap-4">
<Button>Enabled</Button>
<Button disabled>Disabled</Button>
<Button loading>Loading</Button>
<Button variant="colored">Colored</Button>
<Button variant="colored" disabled>Colored disabled</Button>
<Button variant="outlined">Outlined</Button>
<Button variant="quiet">Quiet</Button>
</div>
`,
}),
}
export const LinksAndIconButton: Story = {
render: () => ({
components: { ButtonLink, ExternalIcon, HeartIcon, IconButton },
template: /*html*/ `
<div class="flex flex-wrap items-center gap-4">
<ButtonLink to="/library">Internal link</ButtonLink>
<ButtonLink href="https://modrinth.com" target="_blank" variant="outlined">
Modrinth<ExternalIcon />
</ButtonLink>
<ButtonLink href="https://modrinth.com" disabled>Disabled link</ButtonLink>
<IconButton label="Favorite"><HeartIcon /></IconButton>
<IconButton label="Favorite" variant="colored"><HeartIcon /></IconButton>
<IconButton label="Favorite" variant="outlined"><HeartIcon /></IconButton>
<IconButton label="Favorite" variant="quiet"><HeartIcon /></IconButton>
</div>
`,
}),
}
@@ -0,0 +1,82 @@
import { PlayIcon, SettingsIcon, StopCircleIcon, TrashIcon } from '@modrinth/assets'
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import Button from '../../components/base/buttons/Button.vue'
import ButtonGroup from '../../components/base/buttons/ButtonGroup.vue'
import SplitButton from '../../components/base/buttons/SplitButton.vue'
import type { OverflowMenuOption } from '../../components/base/buttons/types'
const splitOptions: OverflowMenuOption[] = [
{
id: 'settings',
label: 'Server settings',
icon: SettingsIcon,
action: () => undefined,
},
{ type: 'divider' },
{
id: 'delete',
label: 'Delete server',
icon: TrashIcon,
tone: 'red',
action: () => undefined,
},
]
const meta = {
title: 'Buttons/Button Group',
component: ButtonGroup,
} satisfies Meta<typeof ButtonGroup>
export default meta
type Story = StoryObj<typeof meta>
export const Joined: Story = {
render: () => ({
components: { Button, ButtonGroup },
template: /*html*/ `
<ButtonGroup label="Pagination">
<Button variant="outlined">Previous</Button>
<Button variant="outlined">Next</Button>
</ButtonGroup>
`,
}),
}
export const Split: Story = {
render: () => ({
components: { PlayIcon, SplitButton },
setup() {
return { splitOptions }
},
template: /*html*/ `
<SplitButton
menu-label="More server actions"
group-label="Server actions"
variant="colored"
:options="splitOptions"
>
<PlayIcon />Start server
</SplitButton>
`,
}),
}
export const IndependentDisabledStates: Story = {
render: () => ({
components: { SplitButton, StopCircleIcon },
setup() {
return { splitOptions }
},
template: /*html*/ `
<div class="flex flex-wrap gap-4">
<SplitButton menu-label="More actions" :options="splitOptions" primary-disabled>
<StopCircleIcon />Primary disabled
</SplitButton>
<SplitButton menu-label="More actions" :options="splitOptions" menu-disabled>
<StopCircleIcon />Menu disabled
</SplitButton>
</div>
`,
}),
}
@@ -0,0 +1,61 @@
import { UploadIcon } from '@modrinth/assets'
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import FileButton from '../../components/base/buttons/FileButton.vue'
const meta = {
title: 'Buttons/File Button',
component: FileButton,
argTypes: {
variant: {
control: 'select',
options: ['base', 'colored', 'outlined', 'quiet'],
},
size: {
control: 'select',
options: ['sm', 'default', 'md', 'lg'],
},
tone: {
control: 'select',
options: ['brand', 'red', 'orange', 'green', 'blue', 'purple', 'promotion'],
},
},
args: {
prompt: 'Select file',
variant: 'base',
size: 'default',
multiple: false,
disabled: false,
},
render: (args) => ({
components: { FileButton, UploadIcon },
setup() {
return { args }
},
template: /*html*/ `
<FileButton v-bind="args">
<UploadIcon />
</FileButton>
`,
}),
} satisfies Meta<typeof FileButton>
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {}
export const MultipleImages: Story = {
args: {
prompt: 'Select images',
accept: 'image/*',
multiple: true,
variant: 'colored',
},
}
export const Disabled: Story = {
args: {
disabled: true,
},
}
@@ -0,0 +1,97 @@
import {
DownloadIcon,
ExternalIcon,
MoreVerticalIcon,
SettingsIcon,
TrashIcon,
} from '@modrinth/assets'
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import TeleportOverflowMenu from '../../components/base/buttons/TeleportOverflowMenu.vue'
import type { OverflowMenuOption } from '../../components/base/buttons/types'
const options: OverflowMenuOption[] = [
{
id: 'download',
label: 'Download',
icon: DownloadIcon,
action: () => undefined,
},
{
id: 'settings',
label: 'Project settings',
icon: SettingsIcon,
type: 'link',
to: '/settings',
},
{
id: 'website',
label: 'Open website',
icon: ExternalIcon,
type: 'link',
href: 'https://modrinth.com',
target: '_blank',
},
{
id: 'unavailable',
label: 'Unavailable action',
disabled: true,
tooltip: 'This action is currently unavailable',
action: () => undefined,
},
{ type: 'divider' },
{
id: 'delete',
label: 'Delete project',
icon: TrashIcon,
tone: 'red',
action: () => undefined,
},
]
const meta = {
title: 'Buttons/Teleport Overflow Menu',
component: TeleportOverflowMenu,
args: {
label: 'More actions',
options,
variant: 'base',
size: 'default',
placement: 'bottom-end',
disabled: false,
},
render: (args) => ({
components: { MoreVerticalIcon, TeleportOverflowMenu },
setup() {
return { args }
},
template: /*html*/ `
<TeleportOverflowMenu v-bind="args">
<MoreVerticalIcon />
</TeleportOverflowMenu>
`,
}),
} satisfies Meta<typeof TeleportOverflowMenu>
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {}
export const ColoredTrigger: Story = {
args: {
variant: 'colored',
},
}
export const OutlinedTrigger: Story = {
args: {
variant: 'outlined',
},
}
export const QuietTrigger: Story = {
args: {
variant: 'quiet',
},
}
@@ -0,0 +1,50 @@
import { SettingsIcon } from '@modrinth/assets'
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import Button from '../../components/base/buttons/Button.vue'
import TeleportPopoutMenu from '../../components/base/buttons/TeleportPopoutMenu.vue'
const meta = {
title: 'Buttons/Teleport Popout Menu',
component: TeleportPopoutMenu,
} satisfies Meta<typeof TeleportPopoutMenu>
export default meta
type Story = StoryObj<typeof meta>
export const ArbitraryContent: Story = {
render: () => ({
components: { Button, SettingsIcon, TeleportPopoutMenu },
template: /*html*/ `
<TeleportPopoutMenu label="Configure versions" placement="bottom-start">
<template #trigger>
<SettingsIcon />Configure
</template>
<template #panel="{ close }">
<div class="flex w-72 flex-col gap-4">
<label class="flex flex-col gap-2 font-semibold text-contrast">
Version name
<input class="rounded-lg bg-surface-4 px-3 py-2 text-primary ring-1 ring-surface-5" value="1.21.8" />
</label>
<Button variant="colored" @click="close()">Apply</Button>
</div>
</template>
</TeleportPopoutMenu>
`,
}),
}
export const IconTrigger: Story = {
render: () => ({
components: { SettingsIcon, TeleportPopoutMenu },
template: /*html*/ `
<TeleportPopoutMenu label="Open settings" variant="quiet" icon-only>
<template #trigger><SettingsIcon /></template>
<template #panel>
<p class="m-0 w-64">Arbitrary teleported content can live here.</p>
</template>
</TeleportPopoutMenu>
`,
}),
}