Compare commits
10 commits
f69a3b0c65
...
04062703ff
| Author | SHA1 | Date | |
|---|---|---|---|
| 04062703ff | |||
| da4fd17934 | |||
| a49f216c43 | |||
| 8cee97614d | |||
| a2685c6920 | |||
| b8fb6769c7 | |||
| 8c830438d5 | |||
| 8497ddeed4 | |||
| 81f7e0c2cc | |||
| 1cb1486fbd |
27 changed files with 475 additions and 147 deletions
1
bun.lock
1
bun.lock
|
|
@ -55,7 +55,6 @@
|
|||
"vite-plugin-dts": "^5.0.2",
|
||||
},
|
||||
"peerDependencies": {
|
||||
"reka-ui": "^2.10.0",
|
||||
"vue": "^3.5.0",
|
||||
},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -32,8 +32,7 @@
|
|||
"test:unit:watch": "vitest"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"vue": "^3.5.0",
|
||||
"reka-ui": "^2.10.0"
|
||||
"vue": "^3.5.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitest/coverage-v8": "^2.0.0",
|
||||
|
|
|
|||
|
|
@ -9,7 +9,8 @@ export { Badge, badgeVariants } from './ui/badge'
|
|||
export { Checkbox } from './ui/checkbox'
|
||||
export { CheckboxGroup } from './ui/checkbox-group'
|
||||
export { RadioGroup, RadioGroupItem } from './ui/radio-group'
|
||||
export { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter, CardAction } from './ui/card'
|
||||
export { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter, CardAction, cardVariants } from './ui/card'
|
||||
export type { CardVariants } from './ui/card'
|
||||
export { Alert, AlertTitle, AlertDescription, AlertAction, alertVariants } from './ui/alert'
|
||||
export {
|
||||
Select,
|
||||
|
|
|
|||
|
|
@ -1,10 +1,12 @@
|
|||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import type { CardVariants } from "."
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cardVariants } from "."
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
class?: HTMLAttributes["class"]
|
||||
size?: "default" | "sm"
|
||||
size?: CardVariants["size"]
|
||||
}>(), {
|
||||
size: "default",
|
||||
})
|
||||
|
|
@ -14,7 +16,7 @@ const props = withDefaults(defineProps<{
|
|||
<div
|
||||
data-slot="card"
|
||||
:data-size="size"
|
||||
:class="cn('border border-border bg-card text-card-foreground gap-4 overflow-hidden rounded-xl py-4 text-sm shadow-md has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 data-[size=sm]:gap-3 data-[size=sm]:py-3 data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl group/card flex flex-col', props.class)"
|
||||
:class="cn(cardVariants({ size }), props.class)"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ const props = defineProps<{
|
|||
<template>
|
||||
<div
|
||||
data-slot="card-footer"
|
||||
:class="cn('bg-muted/50 rounded-b-xl border-t p-4 group-data-[size=sm]/card:p-3 flex items-center', props.class)"
|
||||
:class="cn('bg-muted/50 rounded-b-xl border-t p-4 group-data-[size=sm]/card:p-3 flex items-center justify-end gap-2', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,3 +1,6 @@
|
|||
import type { VariantProps } from 'class-variance-authority'
|
||||
import { cva } from 'class-variance-authority'
|
||||
|
||||
export { default as Card } from "./Card.vue"
|
||||
export { default as CardAction } from "./CardAction.vue"
|
||||
export { default as CardContent } from "./CardContent.vue"
|
||||
|
|
@ -5,3 +8,19 @@ export { default as CardDescription } from "./CardDescription.vue"
|
|||
export { default as CardFooter } from "./CardFooter.vue"
|
||||
export { default as CardHeader } from "./CardHeader.vue"
|
||||
export { default as CardTitle } from "./CardTitle.vue"
|
||||
|
||||
export const cardVariants = cva(
|
||||
'group/card flex flex-col gap-4 overflow-hidden rounded-xl border border-border bg-card py-4 text-sm text-card-foreground shadow-md has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl',
|
||||
{
|
||||
variants: {
|
||||
size: {
|
||||
default: '',
|
||||
sm: 'gap-3 py-3',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
size: 'default',
|
||||
},
|
||||
},
|
||||
)
|
||||
export type CardVariants = VariantProps<typeof cardVariants>
|
||||
|
|
|
|||
|
|
@ -34,17 +34,22 @@ function toggle() {
|
|||
:aria-required="required"
|
||||
:data-state="checked ? 'checked' : 'unchecked'"
|
||||
:disabled="disabled"
|
||||
:class="cn('border-input dark:bg-input/30 data-checked:bg-primary data-checked:text-primary-foreground dark:data-checked:bg-primary data-checked:border-primary aria-invalid:aria-checked:border-primary aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 flex size-4 items-center justify-center rounded-[4px] border transition-colors group-has-disabled/field:opacity-50 focus-visible:ring-3 aria-invalid:ring-3 peer relative shrink-0 outline-none after:absolute after:-inset-x-3 after:-inset-y-2 disabled:cursor-not-allowed disabled:opacity-50', props.class)"
|
||||
:class="cn('border-input dark:bg-input/30 data-checked:bg-primary data-checked:text-primary-foreground dark:data-checked:bg-primary data-checked:border-primary aria-invalid:aria-checked:border-primary aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 flex size-4 items-center justify-center rounded-[4px] border transition-colors group-has-disabled/field:opacity-50 focus-visible:ring-3 aria-invalid:ring-3 peer relative shrink-0 outline-none after:absolute after:-inset-x-3 after:-inset-y-2 disabled:cursor-not-allowed disabled:opacity-50 hover:border-ring', props.class)"
|
||||
@click="toggle"
|
||||
>
|
||||
<span
|
||||
v-if="checked"
|
||||
data-slot="checkbox-indicator"
|
||||
class="[&>svg]:size-3.5 grid place-content-center text-current transition-none"
|
||||
<Transition
|
||||
enter-active-class="animate-in fade-in-0 zoom-in-50 animation-duration-100"
|
||||
leave-active-class="animate-out fade-out-0 zoom-out-50 animation-duration-100"
|
||||
>
|
||||
<slot>
|
||||
<CheckIcon />
|
||||
</slot>
|
||||
</span>
|
||||
<span
|
||||
v-if="checked"
|
||||
data-slot="checkbox-indicator"
|
||||
class="[&>svg]:size-3.5 grid place-content-center text-current"
|
||||
>
|
||||
<slot>
|
||||
<CheckIcon />
|
||||
</slot>
|
||||
</span>
|
||||
</Transition>
|
||||
</button>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -34,8 +34,8 @@ watch(context.open, (value) => { isLocked.value = value }, { immediate: true })
|
|||
<Teleport to="body">
|
||||
<DialogOverlay />
|
||||
<Transition
|
||||
enter-active-class="animate-in fade-in-0 zoom-in-95 duration-100"
|
||||
leave-active-class="animate-out fade-out-0 zoom-out-95 duration-100"
|
||||
enter-active-class="animate-in fade-in-0 zoom-in-95 animation-duration-100"
|
||||
leave-active-class="animate-out fade-out-0 zoom-out-95 animation-duration-100"
|
||||
>
|
||||
<div
|
||||
v-if="context.open.value"
|
||||
|
|
@ -52,7 +52,7 @@ watch(context.open, (value) => { isLocked.value = value }, { immediate: true })
|
|||
<slot />
|
||||
|
||||
<DialogClose v-if="showCloseButton" as-child>
|
||||
<Button variant="secondary" class="absolute top-2 right-2 size-7 bg-transparent hover:bg-black/10" size="icon">
|
||||
<Button variant="secondary" class="absolute top-2 right-2 size-7 bg-transparent text-foreground hover:bg-black/10" size="icon">
|
||||
<XIcon class="size-3.5" />
|
||||
<span class="sr-only">Close</span>
|
||||
</Button>
|
||||
|
|
|
|||
|
|
@ -10,8 +10,8 @@ const context = inject(DialogContextKey)!
|
|||
|
||||
<template>
|
||||
<Transition
|
||||
enter-active-class="animate-in fade-in-0 duration-100"
|
||||
leave-active-class="animate-out fade-out-0 duration-100"
|
||||
enter-active-class="animate-in fade-in-0 animation-duration-100"
|
||||
leave-active-class="animate-out fade-out-0 animation-duration-100"
|
||||
>
|
||||
<div
|
||||
v-if="context.open.value"
|
||||
|
|
|
|||
|
|
@ -34,8 +34,8 @@ watch(context.open, (value) => { isLocked.value = value }, { immediate: true })
|
|||
<template>
|
||||
<Teleport to="body">
|
||||
<Transition
|
||||
enter-active-class="animate-in fade-in-0 duration-100"
|
||||
leave-active-class="animate-out fade-out-0 duration-100"
|
||||
enter-active-class="animate-in fade-in-0 animation-duration-100"
|
||||
leave-active-class="animate-out fade-out-0 animation-duration-100"
|
||||
>
|
||||
<div
|
||||
v-if="context.open.value"
|
||||
|
|
|
|||
|
|
@ -46,16 +46,21 @@ onBeforeUnmount(() => {
|
|||
:disabled="disabled || context.disabled.value"
|
||||
:class="
|
||||
cn(
|
||||
'border-input dark:bg-input/30 data-checked:bg-primary data-checked:text-primary-foreground dark:data-checked:bg-primary data-checked:border-primary aria-invalid:aria-checked:border-primary aria-invalid:border-destructive focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 dark:aria-invalid:border-destructive/50 flex size-4 rounded-full focus-visible:ring-3 aria-invalid:ring-3 group/radio-group-item peer relative aspect-square shrink-0 border outline-none after:absolute after:-inset-x-3 after:-inset-y-2 disabled:cursor-not-allowed disabled:opacity-50',
|
||||
'border-input dark:bg-input/30 data-checked:bg-primary data-checked:text-primary-foreground dark:data-checked:bg-primary data-checked:border-primary aria-invalid:aria-checked:border-primary aria-invalid:border-destructive focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 dark:aria-invalid:border-destructive/50 flex size-4 rounded-full focus-visible:ring-3 aria-invalid:ring-3 group/radio-group-item peer relative aspect-square shrink-0 border outline-none after:absolute after:-inset-x-3 after:-inset-y-2 disabled:cursor-not-allowed disabled:opacity-50 transition-colors hover:border-ring',
|
||||
props.class,
|
||||
)
|
||||
"
|
||||
@click="onClick"
|
||||
>
|
||||
<span v-if="isChecked" data-slot="radio-group-indicator" class="flex size-4 items-center justify-center">
|
||||
<slot>
|
||||
<CircleIcon class="bg-primary-foreground absolute top-1/2 left-1/2 size-2 -translate-x-1/2 -translate-y-1/2 rounded-full" />
|
||||
</slot>
|
||||
</span>
|
||||
<Transition
|
||||
enter-active-class="animate-in fade-in-0 zoom-in-50 animation-duration-100"
|
||||
leave-active-class="animate-out fade-out-0 zoom-out-50 animation-duration-100"
|
||||
>
|
||||
<span v-if="isChecked" data-slot="radio-group-indicator" class="flex size-4 items-center justify-center">
|
||||
<slot>
|
||||
<CircleIcon class="fill-primary-foreground text-primary-foreground absolute top-1/2 left-1/2 size-1.5 -translate-x-1/2 -translate-y-1/2" />
|
||||
</slot>
|
||||
</span>
|
||||
</Transition>
|
||||
</button>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -37,7 +37,6 @@ provide(SelectContextKey, {
|
|||
contentId: `select-content-${useId()}`,
|
||||
itemLabels,
|
||||
registerLabel: (value: string, label: string) => { itemLabels.set(value, label) },
|
||||
unregisterLabel: (value: string) => { itemLabels.delete(value) },
|
||||
})
|
||||
</script>
|
||||
|
||||
|
|
|
|||
|
|
@ -1,58 +1,121 @@
|
|||
<script setup lang="ts">
|
||||
import type { SelectContentEmits, SelectContentProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
import {
|
||||
SelectContent,
|
||||
SelectPortal,
|
||||
SelectViewport,
|
||||
useForwardPropsEmits,
|
||||
} from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { SelectScrollDownButton, SelectScrollUpButton } from "."
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { inject, nextTick, ref, watch } from 'vue'
|
||||
import { useScrollLock } from '@vueuse/core'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useFocusTrap } from '@/lib/use-focus-trap'
|
||||
import { useDismissableLayer } from '@/lib/use-dismissable-layer'
|
||||
import { useRovingFocus } from '@/lib/use-roving-focus'
|
||||
import { usePopoverPosition } from '@/lib/use-popover-position'
|
||||
import { SelectContextKey } from './context'
|
||||
import SelectScrollUpButton from './SelectScrollUpButton.vue'
|
||||
import SelectScrollDownButton from './SelectScrollDownButton.vue'
|
||||
|
||||
defineOptions({
|
||||
inheritAttrs: false,
|
||||
defineOptions({ inheritAttrs: false })
|
||||
|
||||
const props = defineProps<{ class?: HTMLAttributes['class'] }>()
|
||||
|
||||
const context = inject(SelectContextKey)!
|
||||
const contentRef = ref<HTMLElement | null>(null)
|
||||
const viewportRef = ref<HTMLElement | null>(null)
|
||||
const items = ref<HTMLElement[]>([])
|
||||
|
||||
const position = usePopoverPosition(context.triggerRef, contentRef, context.open)
|
||||
|
||||
function collectItems() {
|
||||
const viewport = viewportRef.value
|
||||
items.value = viewport
|
||||
? Array.from(viewport.querySelectorAll<HTMLElement>('[role="option"]:not([data-disabled])'))
|
||||
: []
|
||||
}
|
||||
|
||||
function initialFocusTarget() {
|
||||
collectItems()
|
||||
return items.value.find((item) => item.dataset.value === context.modelValue.value) ?? items.value[0] ?? null
|
||||
}
|
||||
|
||||
useFocusTrap(contentRef, context.open, { initialFocus: initialFocusTarget })
|
||||
useDismissableLayer(contentRef, context.open, {
|
||||
onDismiss: () => { context.open.value = false },
|
||||
})
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<SelectContentProps & { class?: HTMLAttributes["class"] }>(),
|
||||
{
|
||||
position: "item-aligned",
|
||||
align: "center",
|
||||
},
|
||||
)
|
||||
const emits = defineEmits<SelectContentEmits>()
|
||||
const { handleKeydown: handleRovingKeydown } = useRovingFocus(items, { orientation: 'vertical', loop: false })
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class")
|
||||
let typeaheadBuffer = ''
|
||||
let typeaheadTimeout: ReturnType<typeof setTimeout> | undefined
|
||||
|
||||
const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||
function handleTypeahead(char: string) {
|
||||
typeaheadBuffer += char.toLowerCase()
|
||||
clearTimeout(typeaheadTimeout)
|
||||
typeaheadTimeout = setTimeout(() => { typeaheadBuffer = '' }, 500)
|
||||
|
||||
const match = items.value.find((item) => (item.textContent ?? '').trim().toLowerCase().startsWith(typeaheadBuffer))
|
||||
match?.focus()
|
||||
}
|
||||
|
||||
function onKeydown(event: KeyboardEvent) {
|
||||
if (['ArrowDown', 'ArrowUp', 'Home', 'End'].includes(event.key)) {
|
||||
handleRovingKeydown(event)
|
||||
return
|
||||
}
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault()
|
||||
const value = (document.activeElement as HTMLElement | null)?.dataset.value
|
||||
if (value !== undefined) {
|
||||
context.modelValue.value = value
|
||||
context.open.value = false
|
||||
}
|
||||
return
|
||||
}
|
||||
if (event.key.length === 1 && !event.altKey && !event.ctrlKey && !event.metaKey) {
|
||||
handleTypeahead(event.key)
|
||||
}
|
||||
}
|
||||
|
||||
watch(context.open, async (isOpen) => {
|
||||
if (!isOpen) return
|
||||
await nextTick()
|
||||
collectItems()
|
||||
})
|
||||
|
||||
const isLocked = useScrollLock(document.body)
|
||||
watch(context.open, (value) => { isLocked.value = value }, { immediate: true })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SelectPortal>
|
||||
<SelectContent
|
||||
data-slot="select-content"
|
||||
:data-align-trigger="position === 'item-aligned'"
|
||||
v-bind="{ ...$attrs, ...forwarded }"
|
||||
:class="cn(
|
||||
'bg-popover text-popover-foreground data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 border border-border min-w-36 rounded-lg shadow-md duration-100 data-[side=inline-start]:slide-in-from-right-2 data-[side=inline-end]:slide-in-from-left-2 relative z-50 max-h-(--reka-select-content-available-height) origin-(--reka-select-content-transform-origin) overflow-x-hidden overflow-y-auto data-[align-trigger=true]:animate-none',
|
||||
position === 'popper'
|
||||
&& 'data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1',
|
||||
props.class,
|
||||
)
|
||||
"
|
||||
<Teleport to="body">
|
||||
<Transition
|
||||
enter-active-class="animate-in fade-in-0 zoom-in-95 animation-duration-100"
|
||||
leave-active-class="animate-out fade-out-0 zoom-out-95 animation-duration-100"
|
||||
>
|
||||
<SelectScrollUpButton />
|
||||
<SelectViewport
|
||||
:data-position="position"
|
||||
<div
|
||||
v-if="context.open.value"
|
||||
:id="context.contentId"
|
||||
ref="contentRef"
|
||||
data-slot="select-content"
|
||||
role="listbox"
|
||||
:data-side="position.side"
|
||||
:style="{
|
||||
position: 'fixed',
|
||||
top: `${position.top}px`,
|
||||
left: `${position.left}px`,
|
||||
minWidth: `${position.minWidth}px`,
|
||||
maxHeight: `${position.maxHeight}px`,
|
||||
}"
|
||||
tabindex="-1"
|
||||
v-bind="$attrs"
|
||||
:class="cn(
|
||||
'data-[position=popper]:h-(--reka-select-trigger-height) data-[position=popper]:w-full data-[position=popper]:min-w-(--reka-select-trigger-width)',
|
||||
'bg-popover text-popover-foreground data-[side=bottom]:slide-in-from-top-2 data-[side=top]:slide-in-from-bottom-2 border border-border min-w-36 rounded-lg shadow-md overflow-x-hidden overflow-y-auto',
|
||||
props.class,
|
||||
)"
|
||||
@keydown="onKeydown"
|
||||
>
|
||||
<slot />
|
||||
</SelectViewport>
|
||||
<SelectScrollDownButton />
|
||||
</SelectContent>
|
||||
</SelectPortal>
|
||||
<SelectScrollUpButton :viewport="viewportRef" />
|
||||
<div ref="viewportRef" class="overflow-y-auto">
|
||||
<slot />
|
||||
</div>
|
||||
<SelectScrollDownButton :viewport="viewportRef" />
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -1,45 +1,64 @@
|
|||
<script setup lang="ts">
|
||||
import { CheckIcon } from '@lucide/vue';
|
||||
import { CheckIcon } from '@lucide/vue'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { computed, inject, onMounted, ref } from 'vue'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { SelectContextKey } from './context'
|
||||
import SelectItemText from './SelectItemText.vue'
|
||||
|
||||
import type { SelectItemProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
import {
|
||||
SelectItem,
|
||||
SelectItemIndicator,
|
||||
SelectItemText,
|
||||
useForwardProps,
|
||||
} from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
const props = defineProps<{
|
||||
value: string
|
||||
disabled?: boolean
|
||||
class?: HTMLAttributes['class']
|
||||
}>()
|
||||
|
||||
const props = defineProps<SelectItemProps & { class?: HTMLAttributes["class"] }>()
|
||||
const context = inject(SelectContextKey)!
|
||||
const itemRef = ref<HTMLElement | null>(null)
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class")
|
||||
const isSelected = computed(() => context.modelValue.value === props.value)
|
||||
|
||||
const forwardedProps = useForwardProps(delegatedProps)
|
||||
function select() {
|
||||
if (props.disabled) return
|
||||
context.modelValue.value = props.value
|
||||
context.open.value = false
|
||||
}
|
||||
|
||||
function onPointerMove() {
|
||||
if (props.disabled) return
|
||||
itemRef.value?.focus()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
context.registerLabel(props.value, itemRef.value?.textContent?.trim() ?? '')
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SelectItem
|
||||
<div
|
||||
ref="itemRef"
|
||||
data-slot="select-item"
|
||||
v-bind="forwardedProps"
|
||||
role="option"
|
||||
:data-value="value"
|
||||
:aria-selected="isSelected"
|
||||
:data-disabled="disabled ? '' : undefined"
|
||||
tabindex="-1"
|
||||
:class="
|
||||
cn(
|
||||
'focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm [&_svg:not([class*=size-])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2 relative flex w-full cursor-default items-center outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0',
|
||||
'focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm [&_svg:not([class*=size-])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2 relative flex w-full cursor-default items-center outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 transition-colors',
|
||||
props.class,
|
||||
)
|
||||
"
|
||||
@click="select"
|
||||
@pointermove="onPointerMove"
|
||||
>
|
||||
<span class="pointer-events-none absolute right-2 flex size-4 items-center justify-center">
|
||||
<SelectItemIndicator>
|
||||
<slot name="indicator-icon">
|
||||
<CheckIcon class="pointer-events-none" />
|
||||
</slot>
|
||||
</SelectItemIndicator>
|
||||
<slot v-if="isSelected" name="indicator-icon">
|
||||
<CheckIcon class="pointer-events-none" />
|
||||
</slot>
|
||||
</span>
|
||||
|
||||
<SelectItemText>
|
||||
<slot />
|
||||
</SelectItemText>
|
||||
</SelectItem>
|
||||
</div>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -1,27 +1,51 @@
|
|||
<script setup lang="ts">
|
||||
import { ChevronDownIcon } from '@lucide/vue';
|
||||
import { ChevronDownIcon } from '@lucide/vue'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { onBeforeUnmount, ref, watchEffect } from 'vue'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
import type { SelectScrollDownButtonProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
import { SelectScrollDownButton, useForwardProps } from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
const props = defineProps<{ viewport: HTMLElement | null, class?: HTMLAttributes['class'] }>()
|
||||
|
||||
const props = defineProps<SelectScrollDownButtonProps & { class?: HTMLAttributes["class"] }>()
|
||||
const visible = ref(false)
|
||||
let scrollInterval: ReturnType<typeof setInterval> | undefined
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class")
|
||||
function updateVisibility() {
|
||||
const viewport = props.viewport
|
||||
visible.value = !!viewport && viewport.scrollTop + viewport.clientHeight < viewport.scrollHeight
|
||||
}
|
||||
|
||||
const forwardedProps = useForwardProps(delegatedProps)
|
||||
watchEffect((onCleanup) => {
|
||||
const viewport = props.viewport
|
||||
if (!viewport) return
|
||||
updateVisibility()
|
||||
viewport.addEventListener('scroll', updateVisibility)
|
||||
onCleanup(() => viewport.removeEventListener('scroll', updateVisibility))
|
||||
})
|
||||
|
||||
function startScroll() {
|
||||
stopScroll()
|
||||
scrollInterval = setInterval(() => {
|
||||
if (props.viewport) props.viewport.scrollTop += 8
|
||||
}, 16)
|
||||
}
|
||||
function stopScroll() {
|
||||
if (scrollInterval) clearInterval(scrollInterval)
|
||||
scrollInterval = undefined
|
||||
}
|
||||
onBeforeUnmount(stopScroll)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SelectScrollDownButton
|
||||
<div
|
||||
v-if="visible"
|
||||
data-slot="select-scroll-down-button"
|
||||
v-bind="forwardedProps"
|
||||
:class="cn('bg-popover z-10 flex cursor-default items-center justify-center py-1 [&_svg:not([class*=size-])]:size-4', props.class)"
|
||||
@pointerdown="startScroll"
|
||||
@pointerup="stopScroll"
|
||||
@pointerleave="stopScroll"
|
||||
>
|
||||
<slot>
|
||||
<ChevronDownIcon />
|
||||
</slot>
|
||||
</SelectScrollDownButton>
|
||||
</div>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -1,27 +1,50 @@
|
|||
<script setup lang="ts">
|
||||
import { ChevronUpIcon } from '@lucide/vue';
|
||||
import { ChevronUpIcon } from '@lucide/vue'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { onBeforeUnmount, ref, watchEffect } from 'vue'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
import type { SelectScrollUpButtonProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
import { SelectScrollUpButton, useForwardProps } from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
const props = defineProps<{ viewport: HTMLElement | null, class?: HTMLAttributes['class'] }>()
|
||||
|
||||
const props = defineProps<SelectScrollUpButtonProps & { class?: HTMLAttributes["class"] }>()
|
||||
const visible = ref(false)
|
||||
let scrollInterval: ReturnType<typeof setInterval> | undefined
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class")
|
||||
function updateVisibility() {
|
||||
visible.value = !!props.viewport && props.viewport.scrollTop > 0
|
||||
}
|
||||
|
||||
const forwardedProps = useForwardProps(delegatedProps)
|
||||
watchEffect((onCleanup) => {
|
||||
const viewport = props.viewport
|
||||
if (!viewport) return
|
||||
updateVisibility()
|
||||
viewport.addEventListener('scroll', updateVisibility)
|
||||
onCleanup(() => viewport.removeEventListener('scroll', updateVisibility))
|
||||
})
|
||||
|
||||
function startScroll() {
|
||||
stopScroll()
|
||||
scrollInterval = setInterval(() => {
|
||||
if (props.viewport) props.viewport.scrollTop -= 8
|
||||
}, 16)
|
||||
}
|
||||
function stopScroll() {
|
||||
if (scrollInterval) clearInterval(scrollInterval)
|
||||
scrollInterval = undefined
|
||||
}
|
||||
onBeforeUnmount(stopScroll)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SelectScrollUpButton
|
||||
<div
|
||||
v-if="visible"
|
||||
data-slot="select-scroll-up-button"
|
||||
v-bind="forwardedProps"
|
||||
:class="cn('bg-popover z-10 flex cursor-default items-center justify-center py-1 [&_svg:not([class*=size-])]:size-4', props.class)"
|
||||
@pointerdown="startScroll"
|
||||
@pointerup="stopScroll"
|
||||
@pointerleave="stopScroll"
|
||||
>
|
||||
<slot>
|
||||
<ChevronUpIcon />
|
||||
</slot>
|
||||
</SelectScrollUpButton>
|
||||
</div>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -1,34 +1,49 @@
|
|||
<script setup lang="ts">
|
||||
import { ChevronDownIcon } from '@lucide/vue';
|
||||
|
||||
import type { SelectTriggerProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
import { SelectIcon, SelectTrigger, useForwardProps } from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { ChevronDownIcon } from '@lucide/vue'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { inject } from 'vue'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { SelectContextKey } from './context'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<SelectTriggerProps & { class?: HTMLAttributes["class"], size?: "sm" | "default" }>(),
|
||||
{ size: "default" },
|
||||
defineProps<{ class?: HTMLAttributes['class'], size?: 'sm' | 'default', disabled?: boolean }>(),
|
||||
{ size: 'default' },
|
||||
)
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class", "size")
|
||||
const forwardedProps = useForwardProps(delegatedProps)
|
||||
const context = inject(SelectContextKey)!
|
||||
|
||||
function onClick() {
|
||||
if (props.disabled || context.disabled.value) return
|
||||
context.open.value = !context.open.value
|
||||
}
|
||||
|
||||
function onKeydown(event: KeyboardEvent) {
|
||||
if (event.key === 'Enter' || event.key === ' ' || event.key === 'ArrowDown' || event.key === 'ArrowUp') {
|
||||
event.preventDefault()
|
||||
context.open.value = true
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SelectTrigger
|
||||
<button
|
||||
:ref="(el) => { context.triggerRef.value = el as HTMLElement | null }"
|
||||
type="button"
|
||||
data-slot="select-trigger"
|
||||
:data-size="size"
|
||||
v-bind="forwardedProps"
|
||||
role="combobox"
|
||||
:aria-controls="context.contentId"
|
||||
:aria-expanded="context.open.value"
|
||||
aria-autocomplete="none"
|
||||
:disabled="disabled || context.disabled.value"
|
||||
:class="cn(
|
||||
'border-input data-placeholder:text-muted-foreground dark:bg-input/30 dark:hover:bg-input/50 focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 gap-1.5 rounded-lg border bg-transparent py-2 pr-2 pl-2.5 text-sm transition-colors select-none focus-visible:ring-3 aria-invalid:ring-3 data-[size=default]:h-8 data-[size=sm]:h-7 data-[size=sm]:rounded-[min(var(--radius-md),10px)] *:data-[slot=select-value]:gap-1.5 [&_svg:not([class*=size-])]:size-4 flex w-fit items-center justify-between whitespace-nowrap outline-none disabled:cursor-not-allowed disabled:opacity-50 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center [&_svg]:pointer-events-none [&_svg]:shrink-0',
|
||||
props.class,
|
||||
)"
|
||||
@click="onClick"
|
||||
@keydown="onKeydown"
|
||||
>
|
||||
<slot />
|
||||
<SelectIcon as-child>
|
||||
<ChevronDownIcon class="text-muted-foreground size-4 pointer-events-none" />
|
||||
</SelectIcon>
|
||||
</SelectTrigger>
|
||||
<ChevronDownIcon class="text-muted-foreground size-4 pointer-events-none" />
|
||||
</button>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -8,7 +8,6 @@ export interface SelectContext {
|
|||
contentId: string
|
||||
itemLabels: Map<string, string>
|
||||
registerLabel: (value: string, label: string) => void
|
||||
unregisterLabel: (value: string) => void
|
||||
}
|
||||
|
||||
export const SelectContextKey: InjectionKey<SelectContext> = Symbol('SelectContext')
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ import {
|
|||
CardContent,
|
||||
CardFooter,
|
||||
CardAction,
|
||||
cardVariants,
|
||||
Alert,
|
||||
AlertTitle,
|
||||
AlertDescription,
|
||||
|
|
@ -63,6 +64,7 @@ export {
|
|||
CardContent,
|
||||
CardFooter,
|
||||
CardAction,
|
||||
cardVariants,
|
||||
Alert,
|
||||
AlertTitle,
|
||||
AlertDescription,
|
||||
|
|
|
|||
|
|
@ -1,5 +1,16 @@
|
|||
import type { VNode } from 'vue'
|
||||
import { cloneVNode, defineComponent, mergeProps } from 'vue'
|
||||
import { Fragment, cloneVNode, defineComponent, mergeProps } from 'vue'
|
||||
|
||||
// `<slot />` used as a passthrough outlet resolves through Vue's `renderSlot()`
|
||||
// helper, which always wraps the forwarded content in a Fragment vnode (for
|
||||
// diffing), even when there's exactly one real child inside. Unwrap it so we
|
||||
// clone the actual element/component vnode instead of the inert wrapper.
|
||||
function unwrapFragment(vnode: VNode): VNode {
|
||||
if (vnode.type === Fragment && Array.isArray(vnode.children) && vnode.children.length === 1) {
|
||||
return unwrapFragment(vnode.children[0] as VNode)
|
||||
}
|
||||
return vnode
|
||||
}
|
||||
|
||||
export default defineComponent({
|
||||
name: 'Slot',
|
||||
|
|
@ -10,7 +21,7 @@ export default defineComponent({
|
|||
if (children.length !== 1) {
|
||||
throw new Error('Slot requires exactly one child element')
|
||||
}
|
||||
const child = children[0] as VNode
|
||||
const child = unwrapFragment(children[0] as VNode)
|
||||
return cloneVNode(child, mergeProps(attrs, (child.props ?? {}) as Record<string, unknown>))
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -6,18 +6,32 @@ export interface DismissableLayerOptions {
|
|||
onPointerDownOutside?: (event: PointerEvent) => void
|
||||
}
|
||||
|
||||
// Stack of currently-active layers (dialogs, popovers, etc.), bottom to top.
|
||||
// Only the topmost layer reacts to Escape/outside-pointerdown, so opening a
|
||||
// nested layer (e.g. a dialog on top of another dialog) can't dismiss layers
|
||||
// beneath it.
|
||||
const layerStack: symbol[] = []
|
||||
|
||||
export function useDismissableLayer(
|
||||
containerRef: Ref<HTMLElement | null>,
|
||||
active: Ref<boolean>,
|
||||
options: DismissableLayerOptions,
|
||||
) {
|
||||
const layerId = Symbol('dismissable-layer')
|
||||
|
||||
function isTopmost() {
|
||||
return layerStack[layerStack.length - 1] === layerId
|
||||
}
|
||||
|
||||
function handleKeydown(event: KeyboardEvent) {
|
||||
if (event.key !== 'Escape') return
|
||||
if (!isTopmost()) return
|
||||
event.preventDefault()
|
||||
options.onDismiss()
|
||||
}
|
||||
|
||||
function handlePointerDown(event: PointerEvent) {
|
||||
if (!isTopmost()) return
|
||||
const container = containerRef.value
|
||||
if (!container) return
|
||||
if (container.contains(event.target as Node)) return
|
||||
|
|
@ -28,11 +42,14 @@ export function useDismissableLayer(
|
|||
}
|
||||
|
||||
function attach() {
|
||||
layerStack.push(layerId)
|
||||
document.addEventListener('keydown', handleKeydown)
|
||||
document.addEventListener('pointerdown', handlePointerDown)
|
||||
}
|
||||
|
||||
function detach() {
|
||||
const index = layerStack.indexOf(layerId)
|
||||
if (index !== -1) layerStack.splice(index, 1)
|
||||
document.removeEventListener('keydown', handleKeydown)
|
||||
document.removeEventListener('pointerdown', handlePointerDown)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -29,4 +29,12 @@ describe('Card', () => {
|
|||
const wrapper = mount(Card)
|
||||
expect(wrapper.attributes('data-slot')).toBe('card')
|
||||
})
|
||||
|
||||
it('resolves size classes through cva instead of data-[size=] selectors', () => {
|
||||
const wrapper = mount(Card, { props: { size: 'sm' } })
|
||||
const classes = wrapper.classes()
|
||||
expect(classes).toContain('gap-3')
|
||||
expect(classes).toContain('py-3')
|
||||
expect(classes.some((c) => c.includes('data-[size='))).toBe(false)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
11
packages/library/tests/fixtures/SlotPassthrough.vue
vendored
Normal file
11
packages/library/tests/fixtures/SlotPassthrough.vue
vendored
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
<script setup lang="ts">
|
||||
import { Primitive } from '@/lib/primitive'
|
||||
|
||||
defineProps<{ asChild?: boolean }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Primitive as="button" :as-child="asChild" type="button" data-slot="passthrough-wrapper">
|
||||
<slot />
|
||||
</Primitive>
|
||||
</template>
|
||||
|
|
@ -2,6 +2,7 @@ import { describe, it, expect } from 'vitest'
|
|||
import { h } from 'vue'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { Primitive } from '../src/lib/primitive'
|
||||
import SlotPassthrough from './fixtures/SlotPassthrough.vue'
|
||||
|
||||
describe('Primitive', () => {
|
||||
it('renders the "as" tag with forwarded attrs when asChild is false', () => {
|
||||
|
|
@ -42,4 +43,27 @@ describe('Primitive', () => {
|
|||
}),
|
||||
).toThrow()
|
||||
})
|
||||
|
||||
it('merges attrs through a real SFC that forwards `<slot />` as asChild content', async () => {
|
||||
// Regression test: `<slot />` used as a passthrough outlet inside an SFC
|
||||
// template resolves through Vue's renderSlot() helper, which wraps the
|
||||
// forwarded content in a Fragment vnode even when there's exactly one
|
||||
// real child. Slot must unwrap that Fragment before cloning, or the
|
||||
// merged attrs silently vanish onto the inert wrapper instead of the
|
||||
// real child element.
|
||||
let clicked = false
|
||||
const wrapper = mount(SlotPassthrough, {
|
||||
props: { asChild: true },
|
||||
attrs: { onClick: () => { clicked = true } },
|
||||
slots: {
|
||||
default: () => h('a', { href: '/somewhere' }, 'Click'),
|
||||
},
|
||||
})
|
||||
const el = wrapper.get('a')
|
||||
expect(el.attributes('data-slot')).toBe('passthrough-wrapper')
|
||||
expect(el.attributes('type')).toBe('button')
|
||||
expect(el.attributes('href')).toBe('/somewhere')
|
||||
await el.trigger('click')
|
||||
expect(clicked).toBe(true)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { describe, it, expect, vi, afterEach } from 'vitest'
|
||||
import { ref } from 'vue'
|
||||
import { nextTick, ref } from 'vue'
|
||||
import { useDismissableLayer } from '../src/lib/use-dismissable-layer'
|
||||
|
||||
function firePointerDown(target: EventTarget) {
|
||||
|
|
@ -79,4 +79,65 @@ describe('useDismissableLayer', () => {
|
|||
|
||||
expect(onDismiss).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('only dismisses the topmost layer on Escape when layers are stacked', () => {
|
||||
const container1 = document.createElement('div')
|
||||
const container2 = document.createElement('div')
|
||||
document.body.append(container1, container2)
|
||||
const active1 = ref(true)
|
||||
const active2 = ref(true)
|
||||
const onDismiss1 = vi.fn()
|
||||
const onDismiss2 = vi.fn()
|
||||
useDismissableLayer(ref(container1), active1, { onDismiss: onDismiss1 })
|
||||
useDismissableLayer(ref(container2), active2, { onDismiss: onDismiss2 })
|
||||
|
||||
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))
|
||||
|
||||
expect(onDismiss2).toHaveBeenCalledOnce()
|
||||
expect(onDismiss1).not.toHaveBeenCalled()
|
||||
|
||||
active1.value = false
|
||||
active2.value = false
|
||||
})
|
||||
|
||||
it('only dismisses the topmost layer on pointerdown outside it, even inside a lower layer', () => {
|
||||
const container1 = document.createElement('div')
|
||||
const container2 = document.createElement('div')
|
||||
document.body.append(container1, container2)
|
||||
const active1 = ref(true)
|
||||
const active2 = ref(true)
|
||||
const onDismiss1 = vi.fn()
|
||||
const onDismiss2 = vi.fn()
|
||||
useDismissableLayer(ref(container1), active1, { onDismiss: onDismiss1 })
|
||||
useDismissableLayer(ref(container2), active2, { onDismiss: onDismiss2 })
|
||||
|
||||
firePointerDown(container1)
|
||||
|
||||
expect(onDismiss2).toHaveBeenCalledOnce()
|
||||
expect(onDismiss1).not.toHaveBeenCalled()
|
||||
|
||||
active1.value = false
|
||||
active2.value = false
|
||||
})
|
||||
|
||||
it('promotes the next layer to topmost once the top layer deactivates', async () => {
|
||||
const container1 = document.createElement('div')
|
||||
const container2 = document.createElement('div')
|
||||
document.body.append(container1, container2)
|
||||
const active1 = ref(true)
|
||||
const active2 = ref(true)
|
||||
const onDismiss1 = vi.fn()
|
||||
const onDismiss2 = vi.fn()
|
||||
useDismissableLayer(ref(container1), active1, { onDismiss: onDismiss1 })
|
||||
useDismissableLayer(ref(container2), active2, { onDismiss: onDismiss2 })
|
||||
|
||||
active2.value = false
|
||||
await nextTick()
|
||||
|
||||
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))
|
||||
|
||||
expect(onDismiss1).toHaveBeenCalledOnce()
|
||||
|
||||
active1.value = false
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -20,11 +20,10 @@ export default defineConfig({
|
|||
fileName: () => 'index.js',
|
||||
},
|
||||
rollupOptions: {
|
||||
external: ['vue', 'reka-ui'],
|
||||
external: ['vue'],
|
||||
output: {
|
||||
globals: {
|
||||
vue: 'Vue',
|
||||
'reka-ui': 'RekaUI',
|
||||
},
|
||||
},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import type { Preview } from '@storybook/vue3-vite'
|
||||
import { useGlobals } from 'storybook/preview-api'
|
||||
import '@nychthemeron/library/theme'
|
||||
|
||||
const preview: Preview = {
|
||||
|
|
@ -13,20 +14,42 @@ const preview: Preview = {
|
|||
dynamicTitle: true,
|
||||
},
|
||||
},
|
||||
// Match Storybook's built-in backgrounds addon (used by the Docs page's
|
||||
// embedded canvas) to the same surface-0 colors as our hades/apollo
|
||||
// theme, instead of its generic white/black defaults.
|
||||
backgrounds: {
|
||||
options: {
|
||||
dark: { name: 'dark', value: '#18160f' },
|
||||
light: { name: 'light', value: '#faf7f2' },
|
||||
},
|
||||
},
|
||||
docs: {
|
||||
description: {
|
||||
component: 'Component documentation',
|
||||
},
|
||||
},
|
||||
},
|
||||
initialGlobals: {
|
||||
backgrounds: { value: 'dark' },
|
||||
},
|
||||
decorators: [
|
||||
(story, context) => {
|
||||
const theme = context.globals.theme || 'hades'
|
||||
|
||||
// Set eagerly (not inside setup()) since Storybook doesn't remount the
|
||||
// story tree on a globals-only change, so setup() wouldn't rerun.
|
||||
document.documentElement.setAttribute('data-theme', theme)
|
||||
|
||||
// Keep the built-in backgrounds addon (used by the Docs page's
|
||||
// embedded canvas) in lockstep with the theme toggle, so switching
|
||||
// Hades/Apollo also switches which background swatch is active.
|
||||
const [globals, updateGlobals] = useGlobals()
|
||||
const wantedBackground = theme === 'apollo' ? 'light' : 'dark'
|
||||
if (globals.backgrounds?.value !== wantedBackground) {
|
||||
updateGlobals({ backgrounds: { value: wantedBackground } })
|
||||
}
|
||||
|
||||
return {
|
||||
setup() {
|
||||
document.documentElement.setAttribute('data-theme', theme)
|
||||
},
|
||||
components: { story },
|
||||
template: '<story />',
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue