refactor: Use ShadCN and toss primevue

This commit is contained in:
Matthew McPeak 2026-07-15 19:19:46 -04:00
parent da4fd17934
commit 04062703ff
Signed by: McPeakML
GPG key ID: 3D64A2E70F58D07C
9 changed files with 141 additions and 8 deletions

View file

@ -9,7 +9,8 @@ export { Badge, badgeVariants } from './ui/badge'
export { Checkbox } from './ui/checkbox'
export { CheckboxGroup } from './ui/checkbox-group'
export { RadioGroup, RadioGroupItem } from './ui/radio-group'
export { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter, CardAction } from './ui/card'
export { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter, CardAction, cardVariants } from './ui/card'
export type { CardVariants } from './ui/card'
export { Alert, AlertTitle, AlertDescription, AlertAction, alertVariants } from './ui/alert'
export {
Select,

View file

@ -1,10 +1,12 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import type { CardVariants } from "."
import { cn } from "@/lib/utils"
import { cardVariants } from "."
const props = withDefaults(defineProps<{
class?: HTMLAttributes["class"]
size?: "default" | "sm"
size?: CardVariants["size"]
}>(), {
size: "default",
})
@ -14,7 +16,7 @@ const props = withDefaults(defineProps<{
<div
data-slot="card"
:data-size="size"
:class="cn('border border-border bg-card text-card-foreground gap-4 overflow-hidden rounded-xl py-4 text-sm shadow-md has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 data-[size=sm]:gap-3 data-[size=sm]:py-3 data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl group/card flex flex-col', props.class)"
:class="cn(cardVariants({ size }), props.class)"
>
<slot />
</div>

View file

@ -10,7 +10,7 @@ const props = defineProps<{
<template>
<div
data-slot="card-footer"
:class="cn('bg-muted/50 rounded-b-xl border-t p-4 group-data-[size=sm]/card:p-3 flex items-center', props.class)"
:class="cn('bg-muted/50 rounded-b-xl border-t p-4 group-data-[size=sm]/card:p-3 flex items-center justify-end gap-2', props.class)"
>
<slot />
</div>

View file

@ -1,3 +1,6 @@
import type { VariantProps } from 'class-variance-authority'
import { cva } from 'class-variance-authority'
export { default as Card } from "./Card.vue"
export { default as CardAction } from "./CardAction.vue"
export { default as CardContent } from "./CardContent.vue"
@ -5,3 +8,19 @@ export { default as CardDescription } from "./CardDescription.vue"
export { default as CardFooter } from "./CardFooter.vue"
export { default as CardHeader } from "./CardHeader.vue"
export { default as CardTitle } from "./CardTitle.vue"
export const cardVariants = cva(
'group/card flex flex-col gap-4 overflow-hidden rounded-xl border border-border bg-card py-4 text-sm text-card-foreground shadow-md has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl',
{
variants: {
size: {
default: '',
sm: 'gap-3 py-3',
},
},
defaultVariants: {
size: 'default',
},
},
)
export type CardVariants = VariantProps<typeof cardVariants>

View file

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

View file

@ -6,18 +6,32 @@ export interface DismissableLayerOptions {
onPointerDownOutside?: (event: PointerEvent) => void
}
// Stack of currently-active layers (dialogs, popovers, etc.), bottom to top.
// Only the topmost layer reacts to Escape/outside-pointerdown, so opening a
// nested layer (e.g. a dialog on top of another dialog) can't dismiss layers
// beneath it.
const layerStack: symbol[] = []
export function useDismissableLayer(
containerRef: Ref<HTMLElement | null>,
active: Ref<boolean>,
options: DismissableLayerOptions,
) {
const layerId = Symbol('dismissable-layer')
function isTopmost() {
return layerStack[layerStack.length - 1] === layerId
}
function handleKeydown(event: KeyboardEvent) {
if (event.key !== 'Escape') return
if (!isTopmost()) return
event.preventDefault()
options.onDismiss()
}
function handlePointerDown(event: PointerEvent) {
if (!isTopmost()) return
const container = containerRef.value
if (!container) return
if (container.contains(event.target as Node)) return
@ -28,11 +42,14 @@ export function useDismissableLayer(
}
function attach() {
layerStack.push(layerId)
document.addEventListener('keydown', handleKeydown)
document.addEventListener('pointerdown', handlePointerDown)
}
function detach() {
const index = layerStack.indexOf(layerId)
if (index !== -1) layerStack.splice(index, 1)
document.removeEventListener('keydown', handleKeydown)
document.removeEventListener('pointerdown', handlePointerDown)
}

View file

@ -29,4 +29,12 @@ describe('Card', () => {
const wrapper = mount(Card)
expect(wrapper.attributes('data-slot')).toBe('card')
})
it('resolves size classes through cva instead of data-[size=] selectors', () => {
const wrapper = mount(Card, { props: { size: 'sm' } })
const classes = wrapper.classes()
expect(classes).toContain('gap-3')
expect(classes).toContain('py-3')
expect(classes.some((c) => c.includes('data-[size='))).toBe(false)
})
})

View file

@ -1,5 +1,5 @@
import { describe, it, expect, vi, afterEach } from 'vitest'
import { ref } from 'vue'
import { nextTick, ref } from 'vue'
import { useDismissableLayer } from '../src/lib/use-dismissable-layer'
function firePointerDown(target: EventTarget) {
@ -79,4 +79,65 @@ describe('useDismissableLayer', () => {
expect(onDismiss).not.toHaveBeenCalled()
})
it('only dismisses the topmost layer on Escape when layers are stacked', () => {
const container1 = document.createElement('div')
const container2 = document.createElement('div')
document.body.append(container1, container2)
const active1 = ref(true)
const active2 = ref(true)
const onDismiss1 = vi.fn()
const onDismiss2 = vi.fn()
useDismissableLayer(ref(container1), active1, { onDismiss: onDismiss1 })
useDismissableLayer(ref(container2), active2, { onDismiss: onDismiss2 })
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))
expect(onDismiss2).toHaveBeenCalledOnce()
expect(onDismiss1).not.toHaveBeenCalled()
active1.value = false
active2.value = false
})
it('only dismisses the topmost layer on pointerdown outside it, even inside a lower layer', () => {
const container1 = document.createElement('div')
const container2 = document.createElement('div')
document.body.append(container1, container2)
const active1 = ref(true)
const active2 = ref(true)
const onDismiss1 = vi.fn()
const onDismiss2 = vi.fn()
useDismissableLayer(ref(container1), active1, { onDismiss: onDismiss1 })
useDismissableLayer(ref(container2), active2, { onDismiss: onDismiss2 })
firePointerDown(container1)
expect(onDismiss2).toHaveBeenCalledOnce()
expect(onDismiss1).not.toHaveBeenCalled()
active1.value = false
active2.value = false
})
it('promotes the next layer to topmost once the top layer deactivates', async () => {
const container1 = document.createElement('div')
const container2 = document.createElement('div')
document.body.append(container1, container2)
const active1 = ref(true)
const active2 = ref(true)
const onDismiss1 = vi.fn()
const onDismiss2 = vi.fn()
useDismissableLayer(ref(container1), active1, { onDismiss: onDismiss1 })
useDismissableLayer(ref(container2), active2, { onDismiss: onDismiss2 })
active2.value = false
await nextTick()
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))
expect(onDismiss1).toHaveBeenCalledOnce()
active1.value = false
})
})

View file

@ -1,4 +1,5 @@
import type { Preview } from '@storybook/vue3-vite'
import { useGlobals } from 'storybook/preview-api'
import '@nychthemeron/library/theme'
const preview: Preview = {
@ -13,20 +14,42 @@ const preview: Preview = {
dynamicTitle: true,
},
},
// Match Storybook's built-in backgrounds addon (used by the Docs page's
// embedded canvas) to the same surface-0 colors as our hades/apollo
// theme, instead of its generic white/black defaults.
backgrounds: {
options: {
dark: { name: 'dark', value: '#18160f' },
light: { name: 'light', value: '#faf7f2' },
},
},
docs: {
description: {
component: 'Component documentation',
},
},
},
initialGlobals: {
backgrounds: { value: 'dark' },
},
decorators: [
(story, context) => {
const theme = context.globals.theme || 'hades'
// Set eagerly (not inside setup()) since Storybook doesn't remount the
// story tree on a globals-only change, so setup() wouldn't rerun.
document.documentElement.setAttribute('data-theme', theme)
// Keep the built-in backgrounds addon (used by the Docs page's
// embedded canvas) in lockstep with the theme toggle, so switching
// Hades/Apollo also switches which background swatch is active.
const [globals, updateGlobals] = useGlobals()
const wantedBackground = theme === 'apollo' ? 'light' : 'dark'
if (globals.backgrounds?.value !== wantedBackground) {
updateGlobals({ backgrounds: { value: wantedBackground } })
}
return {
setup() {
document.documentElement.setAttribute('data-theme', theme)
},
components: { story },
template: '<story />',
}