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 { 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,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ import {
|
|||
CardContent,
|
||||
CardFooter,
|
||||
CardAction,
|
||||
cardVariants,
|
||||
Alert,
|
||||
AlertTitle,
|
||||
AlertDescription,
|
||||
|
|
@ -63,6 +64,7 @@ export {
|
|||
CardContent,
|
||||
CardFooter,
|
||||
CardAction,
|
||||
cardVariants,
|
||||
Alert,
|
||||
AlertTitle,
|
||||
AlertDescription,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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 />',
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue