Compare commits

...

10 commits

Author SHA1 Message Date
04062703ff
refactor: Use ShadCN and toss primevue 2026-07-15 19:19:46 -04:00
da4fd17934 feat: smoother hover/select feedback on Checkbox, RadioGroup, Select
- RadioGroupItem and SelectItem had no transition class at all, so
  their checked/highlighted background changes snapped instantly.
  Added transition-colors to both.
- Added hover:border-ring to Checkbox and RadioGroupItem so unchecked
  items get visible mouse-hover feedback, not just focus-visible.
- Wrapped the Checkbox checkmark and RadioGroup dot in a small
  Transition (fade-in/zoom-in on check, fade-out/zoom-out on uncheck)
  instead of popping in/out instantly, using the animation-duration-*
  utility (not duration-*) to avoid the transition:all bug fixed
  earlier in this session.

Verified via headless Chromium: no console errors, hover border colors
resolve to the ring token, Select items show a real transition-colors
(not "all") with the expected 150ms duration.
2026-07-14 10:42:53 -04:00
a49f216c43 fix: fill the RadioGroup indicator's own SVG path instead of faking it with a background
The checked-state dot used bg-primary-foreground behind an unfilled,
stroke-only CircleIcon (fill="none" by default) -- relying on the
stroke and background happening to share the same color to look like
a solid dot. That's fragile and, combined with a size-2 box (50% of
the outer ring), reads as an oversized hole rather than a normal radio
dot. Fill the icon's own path directly (fill-primary-foreground) and
size it down to a more typical proportion (size-1.5, ~40% of the
ring). Verified centered and correctly proportioned in both themes via
headless Chromium (pixel-precise marker overlay + zoomed screenshots).
2026-07-14 10:39:18 -04:00
8cee97614d fix: use text-foreground for the Dialog close icon
The close icon used the Button "secondary" variant's
text-secondary-foreground, a near-white token fixed across both
themes. That reads fine against the dark Hades popover but is almost
invisible against the light Apollo popover since bg-transparent
removes the variant's own background. text-foreground already adapts
per-theme and is what a plain icon-only dismiss button should use.
Pre-existing bug, unrelated to the reka-ui removal (same classes
existed before) -- confirmed via computed-style + screenshot checks in
both themes in headless Chromium.
2026-07-14 09:40:45 -04:00
a2685c6920 fix: stop Select/Dialog enter animations from transitioning position
Root cause: Tailwind's `duration-100` utility sets the literal CSS
`transition-duration` property (it's meant for transitions, not
animations). Since `transition-property`'s initial value is `all` and
nothing overrode it, every element using `duration-100` in its
Transition enter/leave-active-class picked up an unintended
`transition: all 100ms`. For SelectContent, whose `top`/`left` are set
via inline style from usePopoverPosition, this meant the popover's
real computed position (set synchronously on mount) got smoothly
interpolated from the reactive object's initial {top:0,left:0} default
-- visibly flying in from the top-left corner of the viewport before
landing in place.

Fix: use tw-animate-css's dedicated `animation-duration-*` utility
instead, which sets `animation-duration`/`--tw-animation-duration`
without ever touching `transition-property`/`transition-duration`.
Confirmed via computed-style inspection in headless Chromium that
transitionDuration is now 0s while the intended enter/exit keyframe
animation still runs for 100ms. Applied everywhere `duration-100` was
used for this pattern (DialogContent, DialogOverlay,
DialogScrollContent, SelectContent).
2026-07-14 09:39:12 -04:00
b8fb6769c7 fix: stop unregistering Select item labels on unmount
SelectItem removed its entry from the shared itemLabels map on
unmount, which fires for every item when the dropdown closes --
including the item that was just selected. That wiped the trigger's
display label right after selecting (and after Escape/close in
general). Labels only need to be learned once and can safely persist,
matching the documented behavior in tests/Select.spec.ts. Found via
manual Storybook verification.
2026-07-13 18:39:31 -04:00
8c830438d5 fix: unwrap Fragment vnodes in Slot before cloning
<slot /> used as a passthrough outlet inside an SFC template (e.g.
DialogTrigger.vue's <Primitive as-child><slot /></Primitive>) resolves
through Vue's renderSlot() helper, which always wraps the forwarded
content in a Fragment vnode -- even when there's exactly one real
child. Slot was cloning that inert Fragment wrapper instead of the
real child, so attrs merged onto asChild-forwarded elements (data-slot,
aria-*, the click handler) silently vanished. Found via manual
Storybook verification: the Dialog trigger rendered but never opened
the dialog on click.

Unwrap single-child Fragments before merging/cloning, and add a
regression test through a real SFC fixture (a raw h()-based test
doesn't reproduce this, since it never goes through renderSlot()).
2026-07-13 18:37:50 -04:00
8497ddeed4 chore: drop the reka-ui dependency
Every component that used to depend on it has been rewritten on small
local primitives (previous commits). reka-ui is no longer a
peerDependency of @nychthemeron/library, so consumers no longer need
to install it.
2026-07-13 18:18:55 -04:00
81f7e0c2cc refactor: rewrite SelectContent and scroll buttons without reka-ui
Positioning via usePopoverPosition, focus/dismiss via useFocusTrap +
useDismissableLayer, arrow/Home/End navigation via useRovingFocus, plus
inline typeahead. This is the last reka-ui import in the library.
2026-07-13 18:17:53 -04:00
1cb1486fbd refactor: rewrite SelectTrigger/SelectItem without reka-ui
role="combobox" trigger and role="option" items driven by the shared
Select context. SelectContent (next commit) is needed before the
Select family works end-to-end again.
2026-07-13 18:16:55 -04:00
27 changed files with 475 additions and 147 deletions

View file

@ -55,7 +55,6 @@
"vite-plugin-dts": "^5.0.2",
},
"peerDependencies": {
"reka-ui": "^2.10.0",
"vue": "^3.5.0",
},
},

View file

@ -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",

View file

@ -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,

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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"

View file

@ -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"

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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')

View file

@ -19,6 +19,7 @@ import {
CardContent,
CardFooter,
CardAction,
cardVariants,
Alert,
AlertTitle,
AlertDescription,
@ -63,6 +64,7 @@ export {
CardContent,
CardFooter,
CardAction,
cardVariants,
Alert,
AlertTitle,
AlertDescription,

View file

@ -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>))
}
},

View file

@ -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)
}

View file

@ -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)
})
})

View 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>

View file

@ -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)
})
})

View file

@ -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
})
})

View file

@ -20,11 +20,10 @@ export default defineConfig({
fileName: () => 'index.js',
},
rollupOptions: {
external: ['vue', 'reka-ui'],
external: ['vue'],
output: {
globals: {
vue: 'Vue',
'reka-ui': 'RekaUI',
},
},
},

View file

@ -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 />',
}