mirror of
https://github.com/modrinth/code.git
synced 2026-08-01 05:36:39 +00:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a60c0dc395 | ||
|
|
72fa9dd131 |
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
`,
|
||||
}),
|
||||
}
|
||||
Reference in New Issue
Block a user