refactor: Use ShadCN and toss primevue
This commit is contained in:
parent
da4fd17934
commit
04062703ff
9 changed files with 141 additions and 8 deletions
|
|
@ -9,7 +9,8 @@ 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 } 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 { Alert, AlertTitle, AlertDescription, AlertAction, alertVariants } from './ui/alert'
|
||||||
export {
|
export {
|
||||||
Select,
|
Select,
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,12 @@
|
||||||
<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?: "default" | "sm"
|
size?: CardVariants["size"]
|
||||||
}>(), {
|
}>(), {
|
||||||
size: "default",
|
size: "default",
|
||||||
})
|
})
|
||||||
|
|
@ -14,7 +16,7 @@ const props = withDefaults(defineProps<{
|
||||||
<div
|
<div
|
||||||
data-slot="card"
|
data-slot="card"
|
||||||
:data-size="size"
|
: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 />
|
<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', 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 />
|
<slot />
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,6 @@
|
||||||
|
import type { VariantProps } from 'class-variance-authority'
|
||||||
|
import { cva } from 'class-variance-authority'
|
||||||
|
|
||||||
export { default as Card } from "./Card.vue"
|
export { default as 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"
|
||||||
|
|
@ -5,3 +8,19 @@ 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>
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ import {
|
||||||
CardContent,
|
CardContent,
|
||||||
CardFooter,
|
CardFooter,
|
||||||
CardAction,
|
CardAction,
|
||||||
|
cardVariants,
|
||||||
Alert,
|
Alert,
|
||||||
AlertTitle,
|
AlertTitle,
|
||||||
AlertDescription,
|
AlertDescription,
|
||||||
|
|
@ -63,6 +64,7 @@ export {
|
||||||
CardContent,
|
CardContent,
|
||||||
CardFooter,
|
CardFooter,
|
||||||
CardAction,
|
CardAction,
|
||||||
|
cardVariants,
|
||||||
Alert,
|
Alert,
|
||||||
AlertTitle,
|
AlertTitle,
|
||||||
AlertDescription,
|
AlertDescription,
|
||||||
|
|
|
||||||
|
|
@ -6,18 +6,32 @@ 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
|
||||||
|
|
@ -28,11 +42,14 @@ 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,4 +29,12 @@ 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,5 +1,5 @@
|
||||||
import { describe, it, expect, vi, afterEach } from 'vitest'
|
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'
|
import { useDismissableLayer } from '../src/lib/use-dismissable-layer'
|
||||||
|
|
||||||
function firePointerDown(target: EventTarget) {
|
function firePointerDown(target: EventTarget) {
|
||||||
|
|
@ -79,4 +79,65 @@ 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
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
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 = {
|
||||||
|
|
@ -13,20 +14,42 @@ 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'
|
||||||
|
|
||||||
return {
|
// Set eagerly (not inside setup()) since Storybook doesn't remount the
|
||||||
setup() {
|
// story tree on a globals-only change, so setup() wouldn't rerun.
|
||||||
document.documentElement.setAttribute('data-theme', theme)
|
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 {
|
||||||
components: { story },
|
components: { story },
|
||||||
template: '<story />',
|
template: '<story />',
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue