Compare commits
No commits in common. "04062703ff166d08216d4e8202bfdf7998850713" and "f69a3b0c6580504e9c1136cc52ccb386dcbce983" have entirely different histories.
04062703ff
...
f69a3b0c65
27 changed files with 147 additions and 475 deletions
1
bun.lock
1
bun.lock
|
|
@ -55,6 +55,7 @@
|
||||||
"vite-plugin-dts": "^5.0.2",
|
"vite-plugin-dts": "^5.0.2",
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
|
"reka-ui": "^2.10.0",
|
||||||
"vue": "^3.5.0",
|
"vue": "^3.5.0",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -32,7 +32,8 @@
|
||||||
"test:unit:watch": "vitest"
|
"test:unit:watch": "vitest"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"vue": "^3.5.0"
|
"vue": "^3.5.0",
|
||||||
|
"reka-ui": "^2.10.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@vitest/coverage-v8": "^2.0.0",
|
"@vitest/coverage-v8": "^2.0.0",
|
||||||
|
|
|
||||||
|
|
@ -9,8 +9,7 @@ export { Badge, badgeVariants } from './ui/badge'
|
||||||
export { Checkbox } from './ui/checkbox'
|
export { Checkbox } from './ui/checkbox'
|
||||||
export { CheckboxGroup } from './ui/checkbox-group'
|
export { CheckboxGroup } from './ui/checkbox-group'
|
||||||
export { RadioGroup, RadioGroupItem } from './ui/radio-group'
|
export { RadioGroup, RadioGroupItem } from './ui/radio-group'
|
||||||
export { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter, CardAction, cardVariants } from './ui/card'
|
export { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter, CardAction } from './ui/card'
|
||||||
export type { CardVariants } from './ui/card'
|
|
||||||
export { Alert, AlertTitle, AlertDescription, AlertAction, alertVariants } from './ui/alert'
|
export { Alert, AlertTitle, AlertDescription, AlertAction, alertVariants } from './ui/alert'
|
||||||
export {
|
export {
|
||||||
Select,
|
Select,
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,10 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { HTMLAttributes } from "vue"
|
import type { HTMLAttributes } from "vue"
|
||||||
import type { CardVariants } from "."
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
import { cardVariants } from "."
|
|
||||||
|
|
||||||
const props = withDefaults(defineProps<{
|
const props = withDefaults(defineProps<{
|
||||||
class?: HTMLAttributes["class"]
|
class?: HTMLAttributes["class"]
|
||||||
size?: CardVariants["size"]
|
size?: "default" | "sm"
|
||||||
}>(), {
|
}>(), {
|
||||||
size: "default",
|
size: "default",
|
||||||
})
|
})
|
||||||
|
|
@ -16,7 +14,7 @@ const props = withDefaults(defineProps<{
|
||||||
<div
|
<div
|
||||||
data-slot="card"
|
data-slot="card"
|
||||||
:data-size="size"
|
: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 />
|
<slot />
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ const props = defineProps<{
|
||||||
<template>
|
<template>
|
||||||
<div
|
<div
|
||||||
data-slot="card-footer"
|
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 />
|
<slot />
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -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 Card } from "./Card.vue"
|
||||||
export { default as CardAction } from "./CardAction.vue"
|
export { default as CardAction } from "./CardAction.vue"
|
||||||
export { default as CardContent } from "./CardContent.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 CardFooter } from "./CardFooter.vue"
|
||||||
export { default as CardHeader } from "./CardHeader.vue"
|
export { default as CardHeader } from "./CardHeader.vue"
|
||||||
export { default as CardTitle } from "./CardTitle.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,22 +34,17 @@ function toggle() {
|
||||||
:aria-required="required"
|
:aria-required="required"
|
||||||
:data-state="checked ? 'checked' : 'unchecked'"
|
:data-state="checked ? 'checked' : 'unchecked'"
|
||||||
:disabled="disabled"
|
: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"
|
@click="toggle"
|
||||||
>
|
>
|
||||||
<Transition
|
<span
|
||||||
enter-active-class="animate-in fade-in-0 zoom-in-50 animation-duration-100"
|
v-if="checked"
|
||||||
leave-active-class="animate-out fade-out-0 zoom-out-50 animation-duration-100"
|
data-slot="checkbox-indicator"
|
||||||
|
class="[&>svg]:size-3.5 grid place-content-center text-current transition-none"
|
||||||
>
|
>
|
||||||
<span
|
<slot>
|
||||||
v-if="checked"
|
<CheckIcon />
|
||||||
data-slot="checkbox-indicator"
|
</slot>
|
||||||
class="[&>svg]:size-3.5 grid place-content-center text-current"
|
</span>
|
||||||
>
|
|
||||||
<slot>
|
|
||||||
<CheckIcon />
|
|
||||||
</slot>
|
|
||||||
</span>
|
|
||||||
</Transition>
|
|
||||||
</button>
|
</button>
|
||||||
</template>
|
</template>
|
||||||
|
|
|
||||||
|
|
@ -34,8 +34,8 @@ watch(context.open, (value) => { isLocked.value = value }, { immediate: true })
|
||||||
<Teleport to="body">
|
<Teleport to="body">
|
||||||
<DialogOverlay />
|
<DialogOverlay />
|
||||||
<Transition
|
<Transition
|
||||||
enter-active-class="animate-in fade-in-0 zoom-in-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 animation-duration-100"
|
leave-active-class="animate-out fade-out-0 zoom-out-95 duration-100"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
v-if="context.open.value"
|
v-if="context.open.value"
|
||||||
|
|
@ -52,7 +52,7 @@ watch(context.open, (value) => { isLocked.value = value }, { immediate: true })
|
||||||
<slot />
|
<slot />
|
||||||
|
|
||||||
<DialogClose v-if="showCloseButton" as-child>
|
<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" />
|
<XIcon class="size-3.5" />
|
||||||
<span class="sr-only">Close</span>
|
<span class="sr-only">Close</span>
|
||||||
</Button>
|
</Button>
|
||||||
|
|
|
||||||
|
|
@ -10,8 +10,8 @@ const context = inject(DialogContextKey)!
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<Transition
|
<Transition
|
||||||
enter-active-class="animate-in fade-in-0 animation-duration-100"
|
enter-active-class="animate-in fade-in-0 duration-100"
|
||||||
leave-active-class="animate-out fade-out-0 animation-duration-100"
|
leave-active-class="animate-out fade-out-0 duration-100"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
v-if="context.open.value"
|
v-if="context.open.value"
|
||||||
|
|
|
||||||
|
|
@ -34,8 +34,8 @@ watch(context.open, (value) => { isLocked.value = value }, { immediate: true })
|
||||||
<template>
|
<template>
|
||||||
<Teleport to="body">
|
<Teleport to="body">
|
||||||
<Transition
|
<Transition
|
||||||
enter-active-class="animate-in fade-in-0 animation-duration-100"
|
enter-active-class="animate-in fade-in-0 duration-100"
|
||||||
leave-active-class="animate-out fade-out-0 animation-duration-100"
|
leave-active-class="animate-out fade-out-0 duration-100"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
v-if="context.open.value"
|
v-if="context.open.value"
|
||||||
|
|
|
||||||
|
|
@ -46,21 +46,16 @@ onBeforeUnmount(() => {
|
||||||
:disabled="disabled || context.disabled.value"
|
:disabled="disabled || context.disabled.value"
|
||||||
:class="
|
:class="
|
||||||
cn(
|
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,
|
props.class,
|
||||||
)
|
)
|
||||||
"
|
"
|
||||||
@click="onClick"
|
@click="onClick"
|
||||||
>
|
>
|
||||||
<Transition
|
<span v-if="isChecked" data-slot="radio-group-indicator" class="flex size-4 items-center justify-center">
|
||||||
enter-active-class="animate-in fade-in-0 zoom-in-50 animation-duration-100"
|
<slot>
|
||||||
leave-active-class="animate-out fade-out-0 zoom-out-50 animation-duration-100"
|
<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 v-if="isChecked" data-slot="radio-group-indicator" class="flex size-4 items-center justify-center">
|
</span>
|
||||||
<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>
|
</button>
|
||||||
</template>
|
</template>
|
||||||
|
|
|
||||||
|
|
@ -37,6 +37,7 @@ provide(SelectContextKey, {
|
||||||
contentId: `select-content-${useId()}`,
|
contentId: `select-content-${useId()}`,
|
||||||
itemLabels,
|
itemLabels,
|
||||||
registerLabel: (value: string, label: string) => { itemLabels.set(value, label) },
|
registerLabel: (value: string, label: string) => { itemLabels.set(value, label) },
|
||||||
|
unregisterLabel: (value: string) => { itemLabels.delete(value) },
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,121 +1,58 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { HTMLAttributes } from 'vue'
|
import type { SelectContentEmits, SelectContentProps } from "reka-ui"
|
||||||
import { inject, nextTick, ref, watch } from 'vue'
|
import type { HTMLAttributes } from "vue"
|
||||||
import { useScrollLock } from '@vueuse/core'
|
import { reactiveOmit } from "@vueuse/core"
|
||||||
import { cn } from '@/lib/utils'
|
import {
|
||||||
import { useFocusTrap } from '@/lib/use-focus-trap'
|
SelectContent,
|
||||||
import { useDismissableLayer } from '@/lib/use-dismissable-layer'
|
SelectPortal,
|
||||||
import { useRovingFocus } from '@/lib/use-roving-focus'
|
SelectViewport,
|
||||||
import { usePopoverPosition } from '@/lib/use-popover-position'
|
useForwardPropsEmits,
|
||||||
import { SelectContextKey } from './context'
|
} from "reka-ui"
|
||||||
import SelectScrollUpButton from './SelectScrollUpButton.vue'
|
import { cn } from "@/lib/utils"
|
||||||
import SelectScrollDownButton from './SelectScrollDownButton.vue'
|
import { SelectScrollDownButton, SelectScrollUpButton } from "."
|
||||||
|
|
||||||
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 { 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 = ''
|
const delegatedProps = reactiveOmit(props, "class")
|
||||||
let typeaheadTimeout: ReturnType<typeof setTimeout> | undefined
|
|
||||||
|
|
||||||
function handleTypeahead(char: string) {
|
const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||||
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<Teleport to="body">
|
<SelectPortal>
|
||||||
<Transition
|
<SelectContent
|
||||||
enter-active-class="animate-in fade-in-0 zoom-in-95 animation-duration-100"
|
data-slot="select-content"
|
||||||
leave-active-class="animate-out fade-out-0 zoom-out-95 animation-duration-100"
|
: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
|
<SelectScrollUpButton />
|
||||||
v-if="context.open.value"
|
<SelectViewport
|
||||||
:id="context.contentId"
|
:data-position="position"
|
||||||
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(
|
: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',
|
'data-[position=popper]:h-(--reka-select-trigger-height) data-[position=popper]:w-full data-[position=popper]:min-w-(--reka-select-trigger-width)',
|
||||||
props.class,
|
|
||||||
)"
|
)"
|
||||||
@keydown="onKeydown"
|
|
||||||
>
|
>
|
||||||
<SelectScrollUpButton :viewport="viewportRef" />
|
<slot />
|
||||||
<div ref="viewportRef" class="overflow-y-auto">
|
</SelectViewport>
|
||||||
<slot />
|
<SelectScrollDownButton />
|
||||||
</div>
|
</SelectContent>
|
||||||
<SelectScrollDownButton :viewport="viewportRef" />
|
</SelectPortal>
|
||||||
</div>
|
|
||||||
</Transition>
|
|
||||||
</Teleport>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
|
||||||
|
|
@ -1,64 +1,45 @@
|
||||||
<script setup lang="ts">
|
<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'
|
|
||||||
|
|
||||||
const props = defineProps<{
|
import type { SelectItemProps } from "reka-ui"
|
||||||
value: string
|
import type { HTMLAttributes } from "vue"
|
||||||
disabled?: boolean
|
import { reactiveOmit } from "@vueuse/core"
|
||||||
class?: HTMLAttributes['class']
|
import {
|
||||||
}>()
|
SelectItem,
|
||||||
|
SelectItemIndicator,
|
||||||
|
SelectItemText,
|
||||||
|
useForwardProps,
|
||||||
|
} from "reka-ui"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
const context = inject(SelectContextKey)!
|
const props = defineProps<SelectItemProps & { class?: HTMLAttributes["class"] }>()
|
||||||
const itemRef = ref<HTMLElement | null>(null)
|
|
||||||
|
|
||||||
const isSelected = computed(() => context.modelValue.value === props.value)
|
const delegatedProps = reactiveOmit(props, "class")
|
||||||
|
|
||||||
function select() {
|
const forwardedProps = useForwardProps(delegatedProps)
|
||||||
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div
|
<SelectItem
|
||||||
ref="itemRef"
|
|
||||||
data-slot="select-item"
|
data-slot="select-item"
|
||||||
role="option"
|
v-bind="forwardedProps"
|
||||||
:data-value="value"
|
|
||||||
:aria-selected="isSelected"
|
|
||||||
:data-disabled="disabled ? '' : undefined"
|
|
||||||
tabindex="-1"
|
|
||||||
:class="
|
:class="
|
||||||
cn(
|
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,
|
props.class,
|
||||||
)
|
)
|
||||||
"
|
"
|
||||||
@click="select"
|
|
||||||
@pointermove="onPointerMove"
|
|
||||||
>
|
>
|
||||||
<span class="pointer-events-none absolute right-2 flex size-4 items-center justify-center">
|
<span class="pointer-events-none absolute right-2 flex size-4 items-center justify-center">
|
||||||
<slot v-if="isSelected" name="indicator-icon">
|
<SelectItemIndicator>
|
||||||
<CheckIcon class="pointer-events-none" />
|
<slot name="indicator-icon">
|
||||||
</slot>
|
<CheckIcon class="pointer-events-none" />
|
||||||
|
</slot>
|
||||||
|
</SelectItemIndicator>
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
<SelectItemText>
|
<SelectItemText>
|
||||||
<slot />
|
<slot />
|
||||||
</SelectItemText>
|
</SelectItemText>
|
||||||
</div>
|
</SelectItem>
|
||||||
</template>
|
</template>
|
||||||
|
|
|
||||||
|
|
@ -1,51 +1,27 @@
|
||||||
<script setup lang="ts">
|
<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'
|
|
||||||
|
|
||||||
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)
|
const props = defineProps<SelectScrollDownButtonProps & { class?: HTMLAttributes["class"] }>()
|
||||||
let scrollInterval: ReturnType<typeof setInterval> | undefined
|
|
||||||
|
|
||||||
function updateVisibility() {
|
const delegatedProps = reactiveOmit(props, "class")
|
||||||
const viewport = props.viewport
|
|
||||||
visible.value = !!viewport && viewport.scrollTop + viewport.clientHeight < viewport.scrollHeight
|
|
||||||
}
|
|
||||||
|
|
||||||
watchEffect((onCleanup) => {
|
const forwardedProps = useForwardProps(delegatedProps)
|
||||||
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div
|
<SelectScrollDownButton
|
||||||
v-if="visible"
|
|
||||||
data-slot="select-scroll-down-button"
|
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)"
|
: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>
|
<slot>
|
||||||
<ChevronDownIcon />
|
<ChevronDownIcon />
|
||||||
</slot>
|
</slot>
|
||||||
</div>
|
</SelectScrollDownButton>
|
||||||
</template>
|
</template>
|
||||||
|
|
|
||||||
|
|
@ -1,50 +1,27 @@
|
||||||
<script setup lang="ts">
|
<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'
|
|
||||||
|
|
||||||
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)
|
const props = defineProps<SelectScrollUpButtonProps & { class?: HTMLAttributes["class"] }>()
|
||||||
let scrollInterval: ReturnType<typeof setInterval> | undefined
|
|
||||||
|
|
||||||
function updateVisibility() {
|
const delegatedProps = reactiveOmit(props, "class")
|
||||||
visible.value = !!props.viewport && props.viewport.scrollTop > 0
|
|
||||||
}
|
|
||||||
|
|
||||||
watchEffect((onCleanup) => {
|
const forwardedProps = useForwardProps(delegatedProps)
|
||||||
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div
|
<SelectScrollUpButton
|
||||||
v-if="visible"
|
|
||||||
data-slot="select-scroll-up-button"
|
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)"
|
: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>
|
<slot>
|
||||||
<ChevronUpIcon />
|
<ChevronUpIcon />
|
||||||
</slot>
|
</slot>
|
||||||
</div>
|
</SelectScrollUpButton>
|
||||||
</template>
|
</template>
|
||||||
|
|
|
||||||
|
|
@ -1,49 +1,34 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ChevronDownIcon } from '@lucide/vue'
|
import { ChevronDownIcon } from '@lucide/vue';
|
||||||
import type { HTMLAttributes } from 'vue'
|
|
||||||
import { inject } from 'vue'
|
import type { SelectTriggerProps } from "reka-ui"
|
||||||
import { cn } from '@/lib/utils'
|
import type { HTMLAttributes } from "vue"
|
||||||
import { SelectContextKey } from './context'
|
import { reactiveOmit } from "@vueuse/core"
|
||||||
|
import { SelectIcon, SelectTrigger, useForwardProps } from "reka-ui"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
const props = withDefaults(
|
const props = withDefaults(
|
||||||
defineProps<{ class?: HTMLAttributes['class'], size?: 'sm' | 'default', disabled?: boolean }>(),
|
defineProps<SelectTriggerProps & { class?: HTMLAttributes["class"], size?: "sm" | "default" }>(),
|
||||||
{ size: 'default' },
|
{ size: "default" },
|
||||||
)
|
)
|
||||||
|
|
||||||
const context = inject(SelectContextKey)!
|
const delegatedProps = reactiveOmit(props, "class", "size")
|
||||||
|
const forwardedProps = useForwardProps(delegatedProps)
|
||||||
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<button
|
<SelectTrigger
|
||||||
:ref="(el) => { context.triggerRef.value = el as HTMLElement | null }"
|
|
||||||
type="button"
|
|
||||||
data-slot="select-trigger"
|
data-slot="select-trigger"
|
||||||
:data-size="size"
|
:data-size="size"
|
||||||
role="combobox"
|
v-bind="forwardedProps"
|
||||||
:aria-controls="context.contentId"
|
|
||||||
:aria-expanded="context.open.value"
|
|
||||||
aria-autocomplete="none"
|
|
||||||
:disabled="disabled || context.disabled.value"
|
|
||||||
:class="cn(
|
: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',
|
'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,
|
props.class,
|
||||||
)"
|
)"
|
||||||
@click="onClick"
|
|
||||||
@keydown="onKeydown"
|
|
||||||
>
|
>
|
||||||
<slot />
|
<slot />
|
||||||
<ChevronDownIcon class="text-muted-foreground size-4 pointer-events-none" />
|
<SelectIcon as-child>
|
||||||
</button>
|
<ChevronDownIcon class="text-muted-foreground size-4 pointer-events-none" />
|
||||||
|
</SelectIcon>
|
||||||
|
</SelectTrigger>
|
||||||
</template>
|
</template>
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ export interface SelectContext {
|
||||||
contentId: string
|
contentId: string
|
||||||
itemLabels: Map<string, string>
|
itemLabels: Map<string, string>
|
||||||
registerLabel: (value: string, label: string) => void
|
registerLabel: (value: string, label: string) => void
|
||||||
|
unregisterLabel: (value: string) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export const SelectContextKey: InjectionKey<SelectContext> = Symbol('SelectContext')
|
export const SelectContextKey: InjectionKey<SelectContext> = Symbol('SelectContext')
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,6 @@ import {
|
||||||
CardContent,
|
CardContent,
|
||||||
CardFooter,
|
CardFooter,
|
||||||
CardAction,
|
CardAction,
|
||||||
cardVariants,
|
|
||||||
Alert,
|
Alert,
|
||||||
AlertTitle,
|
AlertTitle,
|
||||||
AlertDescription,
|
AlertDescription,
|
||||||
|
|
@ -64,7 +63,6 @@ export {
|
||||||
CardContent,
|
CardContent,
|
||||||
CardFooter,
|
CardFooter,
|
||||||
CardAction,
|
CardAction,
|
||||||
cardVariants,
|
|
||||||
Alert,
|
Alert,
|
||||||
AlertTitle,
|
AlertTitle,
|
||||||
AlertDescription,
|
AlertDescription,
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,5 @@
|
||||||
import type { VNode } from 'vue'
|
import type { VNode } from 'vue'
|
||||||
import { Fragment, cloneVNode, defineComponent, mergeProps } from 'vue'
|
import { 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({
|
export default defineComponent({
|
||||||
name: 'Slot',
|
name: 'Slot',
|
||||||
|
|
@ -21,7 +10,7 @@ export default defineComponent({
|
||||||
if (children.length !== 1) {
|
if (children.length !== 1) {
|
||||||
throw new Error('Slot requires exactly one child element')
|
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>))
|
return cloneVNode(child, mergeProps(attrs, (child.props ?? {}) as Record<string, unknown>))
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -6,32 +6,18 @@ export interface DismissableLayerOptions {
|
||||||
onPointerDownOutside?: (event: PointerEvent) => void
|
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(
|
export function useDismissableLayer(
|
||||||
containerRef: Ref<HTMLElement | null>,
|
containerRef: Ref<HTMLElement | null>,
|
||||||
active: Ref<boolean>,
|
active: Ref<boolean>,
|
||||||
options: DismissableLayerOptions,
|
options: DismissableLayerOptions,
|
||||||
) {
|
) {
|
||||||
const layerId = Symbol('dismissable-layer')
|
|
||||||
|
|
||||||
function isTopmost() {
|
|
||||||
return layerStack[layerStack.length - 1] === layerId
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleKeydown(event: KeyboardEvent) {
|
function handleKeydown(event: KeyboardEvent) {
|
||||||
if (event.key !== 'Escape') return
|
if (event.key !== 'Escape') return
|
||||||
if (!isTopmost()) return
|
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
options.onDismiss()
|
options.onDismiss()
|
||||||
}
|
}
|
||||||
|
|
||||||
function handlePointerDown(event: PointerEvent) {
|
function handlePointerDown(event: PointerEvent) {
|
||||||
if (!isTopmost()) return
|
|
||||||
const container = containerRef.value
|
const container = containerRef.value
|
||||||
if (!container) return
|
if (!container) return
|
||||||
if (container.contains(event.target as Node)) return
|
if (container.contains(event.target as Node)) return
|
||||||
|
|
@ -42,14 +28,11 @@ export function useDismissableLayer(
|
||||||
}
|
}
|
||||||
|
|
||||||
function attach() {
|
function attach() {
|
||||||
layerStack.push(layerId)
|
|
||||||
document.addEventListener('keydown', handleKeydown)
|
document.addEventListener('keydown', handleKeydown)
|
||||||
document.addEventListener('pointerdown', handlePointerDown)
|
document.addEventListener('pointerdown', handlePointerDown)
|
||||||
}
|
}
|
||||||
|
|
||||||
function detach() {
|
function detach() {
|
||||||
const index = layerStack.indexOf(layerId)
|
|
||||||
if (index !== -1) layerStack.splice(index, 1)
|
|
||||||
document.removeEventListener('keydown', handleKeydown)
|
document.removeEventListener('keydown', handleKeydown)
|
||||||
document.removeEventListener('pointerdown', handlePointerDown)
|
document.removeEventListener('pointerdown', handlePointerDown)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -29,12 +29,4 @@ describe('Card', () => {
|
||||||
const wrapper = mount(Card)
|
const wrapper = mount(Card)
|
||||||
expect(wrapper.attributes('data-slot')).toBe('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)
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -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>
|
|
||||||
|
|
@ -2,7 +2,6 @@ import { describe, it, expect } from 'vitest'
|
||||||
import { h } from 'vue'
|
import { h } from 'vue'
|
||||||
import { mount } from '@vue/test-utils'
|
import { mount } from '@vue/test-utils'
|
||||||
import { Primitive } from '../src/lib/primitive'
|
import { Primitive } from '../src/lib/primitive'
|
||||||
import SlotPassthrough from './fixtures/SlotPassthrough.vue'
|
|
||||||
|
|
||||||
describe('Primitive', () => {
|
describe('Primitive', () => {
|
||||||
it('renders the "as" tag with forwarded attrs when asChild is false', () => {
|
it('renders the "as" tag with forwarded attrs when asChild is false', () => {
|
||||||
|
|
@ -43,27 +42,4 @@ describe('Primitive', () => {
|
||||||
}),
|
}),
|
||||||
).toThrow()
|
).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 { describe, it, expect, vi, afterEach } from 'vitest'
|
||||||
import { nextTick, ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
import { useDismissableLayer } from '../src/lib/use-dismissable-layer'
|
import { useDismissableLayer } from '../src/lib/use-dismissable-layer'
|
||||||
|
|
||||||
function firePointerDown(target: EventTarget) {
|
function firePointerDown(target: EventTarget) {
|
||||||
|
|
@ -79,65 +79,4 @@ describe('useDismissableLayer', () => {
|
||||||
|
|
||||||
expect(onDismiss).not.toHaveBeenCalled()
|
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,10 +20,11 @@ export default defineConfig({
|
||||||
fileName: () => 'index.js',
|
fileName: () => 'index.js',
|
||||||
},
|
},
|
||||||
rollupOptions: {
|
rollupOptions: {
|
||||||
external: ['vue'],
|
external: ['vue', 'reka-ui'],
|
||||||
output: {
|
output: {
|
||||||
globals: {
|
globals: {
|
||||||
vue: 'Vue',
|
vue: 'Vue',
|
||||||
|
'reka-ui': 'RekaUI',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
import type { Preview } from '@storybook/vue3-vite'
|
import type { Preview } from '@storybook/vue3-vite'
|
||||||
import { useGlobals } from 'storybook/preview-api'
|
|
||||||
import '@nychthemeron/library/theme'
|
import '@nychthemeron/library/theme'
|
||||||
|
|
||||||
const preview: Preview = {
|
const preview: Preview = {
|
||||||
|
|
@ -14,42 +13,20 @@ const preview: Preview = {
|
||||||
dynamicTitle: true,
|
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: {
|
docs: {
|
||||||
description: {
|
description: {
|
||||||
component: 'Component documentation',
|
component: 'Component documentation',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
initialGlobals: {
|
|
||||||
backgrounds: { value: 'dark' },
|
|
||||||
},
|
|
||||||
decorators: [
|
decorators: [
|
||||||
(story, context) => {
|
(story, context) => {
|
||||||
const theme = context.globals.theme || 'hades'
|
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 {
|
return {
|
||||||
|
setup() {
|
||||||
|
document.documentElement.setAttribute('data-theme', theme)
|
||||||
|
},
|
||||||
components: { story },
|
components: { story },
|
||||||
template: '<story />',
|
template: '<story />',
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue