Compare commits

..

No commits in common. "04062703ff166d08216d4e8202bfdf7998850713" and "f69a3b0c6580504e9c1136cc52ccb386dcbce983" have entirely different histories.

27 changed files with 147 additions and 475 deletions

View file

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

View file

@ -32,7 +32,8 @@
"test:unit:watch": "vitest"
},
"peerDependencies": {
"vue": "^3.5.0"
"vue": "^3.5.0",
"reka-ui": "^2.10.0"
},
"devDependencies": {
"@vitest/coverage-v8": "^2.0.0",

View file

@ -9,8 +9,7 @@ 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, cardVariants } from './ui/card'
export type { CardVariants } from './ui/card'
export { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter, CardAction } from './ui/card'
export { Alert, AlertTitle, AlertDescription, AlertAction, alertVariants } from './ui/alert'
export {
Select,

View file

@ -1,12 +1,10 @@
<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?: CardVariants["size"]
size?: "default" | "sm"
}>(), {
size: "default",
})
@ -16,7 +14,7 @@ const props = withDefaults(defineProps<{
<div
data-slot="card"
:data-size="size"
:class="cn(cardVariants({ size }), props.class)"
: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)"
>
<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 justify-end gap-2', props.class)"
:class="cn('bg-muted/50 rounded-b-xl border-t p-4 group-data-[size=sm]/card:p-3 flex items-center', props.class)"
>
<slot />
</div>

View file

@ -1,6 +1,3 @@
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"
@ -8,19 +5,3 @@ 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,22 +34,17 @@ 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 hover:border-ring', 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', props.class)"
@click="toggle"
>
<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="checked"
data-slot="checkbox-indicator"
class="[&>svg]:size-3.5 grid place-content-center text-current transition-none"
>
<span
v-if="checked"
data-slot="checkbox-indicator"
class="[&>svg]:size-3.5 grid place-content-center text-current"
>
<slot>
<CheckIcon />
</slot>
</span>
</Transition>
<slot>
<CheckIcon />
</slot>
</span>
</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 animation-duration-100"
leave-active-class="animate-out fade-out-0 zoom-out-95 animation-duration-100"
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"
>
<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 text-foreground hover:bg-black/10" size="icon">
<Button variant="secondary" class="absolute top-2 right-2 size-7 bg-transparent 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 animation-duration-100"
leave-active-class="animate-out fade-out-0 animation-duration-100"
enter-active-class="animate-in fade-in-0 duration-100"
leave-active-class="animate-out fade-out-0 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 animation-duration-100"
leave-active-class="animate-out fade-out-0 animation-duration-100"
enter-active-class="animate-in fade-in-0 duration-100"
leave-active-class="animate-out fade-out-0 duration-100"
>
<div
v-if="context.open.value"

View file

@ -46,21 +46,16 @@ 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 transition-colors hover:border-ring',
'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',
props.class,
)
"
@click="onClick"
>
<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>
<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>
</button>
</template>

View file

@ -37,6 +37,7 @@ 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,121 +1,58 @@
<script setup lang="ts">
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'
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 "."
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 },
defineOptions({
inheritAttrs: false,
})
const { handleKeydown: handleRovingKeydown } = useRovingFocus(items, { orientation: 'vertical', loop: false })
const props = withDefaults(
defineProps<SelectContentProps & { class?: HTMLAttributes["class"] }>(),
{
position: "item-aligned",
align: "center",
},
)
const emits = defineEmits<SelectContentEmits>()
let typeaheadBuffer = ''
let typeaheadTimeout: ReturnType<typeof setTimeout> | undefined
const delegatedProps = reactiveOmit(props, "class")
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 })
const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script>
<template>
<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"
<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,
)
"
>
<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"
<SelectScrollUpButton />
<SelectViewport
:data-position="position"
:class="cn(
'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,
'data-[position=popper]:h-(--reka-select-trigger-height) data-[position=popper]:w-full data-[position=popper]:min-w-(--reka-select-trigger-width)',
)"
@keydown="onKeydown"
>
<SelectScrollUpButton :viewport="viewportRef" />
<div ref="viewportRef" class="overflow-y-auto">
<slot />
</div>
<SelectScrollDownButton :viewport="viewportRef" />
</div>
</Transition>
</Teleport>
<slot />
</SelectViewport>
<SelectScrollDownButton />
</SelectContent>
</SelectPortal>
</template>

View file

@ -1,64 +1,45 @@
<script setup lang="ts">
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 { CheckIcon } from '@lucide/vue';
const props = defineProps<{
value: string
disabled?: boolean
class?: HTMLAttributes['class']
}>()
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 context = inject(SelectContextKey)!
const itemRef = ref<HTMLElement | null>(null)
const props = defineProps<SelectItemProps & { class?: HTMLAttributes["class"] }>()
const isSelected = computed(() => context.modelValue.value === props.value)
const delegatedProps = reactiveOmit(props, "class")
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() ?? '')
})
const forwardedProps = useForwardProps(delegatedProps)
</script>
<template>
<div
ref="itemRef"
<SelectItem
data-slot="select-item"
role="option"
:data-value="value"
:aria-selected="isSelected"
:data-disabled="disabled ? '' : undefined"
tabindex="-1"
v-bind="forwardedProps"
: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 transition-colors',
'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',
props.class,
)
"
@click="select"
@pointermove="onPointerMove"
>
<span class="pointer-events-none absolute right-2 flex size-4 items-center justify-center">
<slot v-if="isSelected" name="indicator-icon">
<CheckIcon class="pointer-events-none" />
</slot>
<SelectItemIndicator>
<slot name="indicator-icon">
<CheckIcon class="pointer-events-none" />
</slot>
</SelectItemIndicator>
</span>
<SelectItemText>
<slot />
</SelectItemText>
</div>
</SelectItem>
</template>

View file

@ -1,51 +1,27 @@
<script setup lang="ts">
import { ChevronDownIcon } from '@lucide/vue'
import type { HTMLAttributes } from 'vue'
import { onBeforeUnmount, ref, watchEffect } from 'vue'
import { cn } from '@/lib/utils'
import { ChevronDownIcon } from '@lucide/vue';
const props = defineProps<{ viewport: HTMLElement | null, class?: HTMLAttributes['class'] }>()
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 visible = ref(false)
let scrollInterval: ReturnType<typeof setInterval> | undefined
const props = defineProps<SelectScrollDownButtonProps & { class?: HTMLAttributes["class"] }>()
function updateVisibility() {
const viewport = props.viewport
visible.value = !!viewport && viewport.scrollTop + viewport.clientHeight < viewport.scrollHeight
}
const delegatedProps = reactiveOmit(props, "class")
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)
const forwardedProps = useForwardProps(delegatedProps)
</script>
<template>
<div
v-if="visible"
<SelectScrollDownButton
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>
</div>
</SelectScrollDownButton>
</template>

View file

@ -1,50 +1,27 @@
<script setup lang="ts">
import { ChevronUpIcon } from '@lucide/vue'
import type { HTMLAttributes } from 'vue'
import { onBeforeUnmount, ref, watchEffect } from 'vue'
import { cn } from '@/lib/utils'
import { ChevronUpIcon } from '@lucide/vue';
const props = defineProps<{ viewport: HTMLElement | null, class?: HTMLAttributes['class'] }>()
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 visible = ref(false)
let scrollInterval: ReturnType<typeof setInterval> | undefined
const props = defineProps<SelectScrollUpButtonProps & { class?: HTMLAttributes["class"] }>()
function updateVisibility() {
visible.value = !!props.viewport && props.viewport.scrollTop > 0
}
const delegatedProps = reactiveOmit(props, "class")
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)
const forwardedProps = useForwardProps(delegatedProps)
</script>
<template>
<div
v-if="visible"
<SelectScrollUpButton
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>
</div>
</SelectScrollUpButton>
</template>

View file

@ -1,49 +1,34 @@
<script setup lang="ts">
import { ChevronDownIcon } from '@lucide/vue'
import type { HTMLAttributes } from 'vue'
import { inject } from 'vue'
import { cn } from '@/lib/utils'
import { SelectContextKey } from './context'
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"
const props = withDefaults(
defineProps<{ class?: HTMLAttributes['class'], size?: 'sm' | 'default', disabled?: boolean }>(),
{ size: 'default' },
defineProps<SelectTriggerProps & { class?: HTMLAttributes["class"], size?: "sm" | "default" }>(),
{ size: "default" },
)
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
}
}
const delegatedProps = reactiveOmit(props, "class", "size")
const forwardedProps = useForwardProps(delegatedProps)
</script>
<template>
<button
:ref="(el) => { context.triggerRef.value = el as HTMLElement | null }"
type="button"
<SelectTrigger
data-slot="select-trigger"
:data-size="size"
role="combobox"
:aria-controls="context.contentId"
:aria-expanded="context.open.value"
aria-autocomplete="none"
:disabled="disabled || context.disabled.value"
v-bind="forwardedProps"
: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 />
<ChevronDownIcon class="text-muted-foreground size-4 pointer-events-none" />
</button>
<SelectIcon as-child>
<ChevronDownIcon class="text-muted-foreground size-4 pointer-events-none" />
</SelectIcon>
</SelectTrigger>
</template>

View file

@ -8,6 +8,7 @@ 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,7 +19,6 @@ import {
CardContent,
CardFooter,
CardAction,
cardVariants,
Alert,
AlertTitle,
AlertDescription,
@ -64,7 +63,6 @@ export {
CardContent,
CardFooter,
CardAction,
cardVariants,
Alert,
AlertTitle,
AlertDescription,

View file

@ -1,16 +1,5 @@
import type { VNode } 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
}
import { cloneVNode, defineComponent, mergeProps } from 'vue'
export default defineComponent({
name: 'Slot',
@ -21,7 +10,7 @@ export default defineComponent({
if (children.length !== 1) {
throw new Error('Slot requires exactly one child element')
}
const child = unwrapFragment(children[0] as VNode)
const child = children[0] as VNode
return cloneVNode(child, mergeProps(attrs, (child.props ?? {}) as Record<string, unknown>))
}
},

View file

@ -6,32 +6,18 @@ 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
@ -42,14 +28,11 @@ 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,12 +29,4 @@ 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

@ -1,11 +0,0 @@
<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,7 +2,6 @@ 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', () => {
@ -43,27 +42,4 @@ 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 { nextTick, ref } from 'vue'
import { ref } from 'vue'
import { useDismissableLayer } from '../src/lib/use-dismissable-layer'
function firePointerDown(target: EventTarget) {
@ -79,65 +79,4 @@ 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,10 +20,11 @@ export default defineConfig({
fileName: () => 'index.js',
},
rollupOptions: {
external: ['vue'],
external: ['vue', 'reka-ui'],
output: {
globals: {
vue: 'Vue',
'reka-ui': 'RekaUI',
},
},
},

View file

@ -1,5 +1,4 @@
import type { Preview } from '@storybook/vue3-vite'
import { useGlobals } from 'storybook/preview-api'
import '@nychthemeron/library/theme'
const preview: Preview = {
@ -14,42 +13,20 @@ 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 />',
}