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