@a4ui/core 0.11.1 → 0.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +80 -37
- package/dist/elements.css +79 -9
- package/dist/full.css +3112 -0
- package/dist/index.d.ts +13 -1
- package/dist/index.js +3392 -2537
- package/dist/lib/flyToCart.d.ts +25 -0
- package/dist/ui/Curtain.d.ts +44 -0
- package/dist/ui/Expandable.d.ts +34 -0
- package/dist/ui/FillText.d.ts +18 -0
- package/dist/ui/HoldToConfirm.d.ts +24 -0
- package/dist/ui/LoadingDots.d.ts +20 -0
- package/dist/ui/MultiStateBadge.d.ts +23 -0
- package/dist/ui/NotificationStack.d.ts +32 -0
- package/dist/ui/NowPlaying.d.ts +21 -0
- package/dist/ui/Parallax.d.ts +21 -0
- package/dist/ui/ScrambleText.d.ts +22 -0
- package/dist/ui/TextReveal.d.ts +23 -0
- package/package.json +8 -1
- package/src/charts/BarChart.tsx +55 -0
- package/src/charts/DonutChart.tsx +127 -0
- package/src/charts/Sparkline.tsx +101 -0
- package/src/charts/index.ts +5 -0
- package/src/commerce/CartLine.tsx +84 -0
- package/src/commerce/CartSummary.tsx +70 -0
- package/src/commerce/FilterGroup.tsx +70 -0
- package/src/commerce/PriceTag.tsx +62 -0
- package/src/commerce/ProductCard.tsx +121 -0
- package/src/commerce/ProductGrid.tsx +27 -0
- package/src/commerce/QuantityStepper.tsx +59 -0
- package/src/commerce/index.ts +10 -0
- package/src/elements.tsx +167 -0
- package/src/index.ts +186 -0
- package/src/layout/AppShell.tsx +102 -0
- package/src/layout/ChristmasBackground.tsx +177 -0
- package/src/layout/EffectsToggle.tsx +41 -0
- package/src/layout/NavGroup.tsx +45 -0
- package/src/layout/SnowScenery.tsx +76 -0
- package/src/layout/SpaceBackground.tsx +459 -0
- package/src/layout/ThemeToggle.tsx +40 -0
- package/src/layout/ThemedScenery.tsx +179 -0
- package/src/layout/sceneEffects.ts +49 -0
- package/src/lib/cn.ts +17 -0
- package/src/lib/effects.ts +71 -0
- package/src/lib/flyToCart.ts +103 -0
- package/src/lib/media.ts +27 -0
- package/src/lib/motion.ts +191 -0
- package/src/lib/theme.ts +113 -0
- package/src/lib/virtual.ts +33 -0
- package/src/styles/space.css +510 -0
- package/src/styles/tokens.css +213 -0
- package/src/themes/index.ts +102 -0
- package/src/themes/palettes.ts +368 -0
- package/src/ui/Accordion.tsx +65 -0
- package/src/ui/Affix.tsx +72 -0
- package/src/ui/Alert.tsx +55 -0
- package/src/ui/AlertDialog.tsx +58 -0
- package/src/ui/Anchor.tsx +100 -0
- package/src/ui/Avatar.tsx +34 -0
- package/src/ui/AvatarGroup.tsx +55 -0
- package/src/ui/BackToTop.tsx +51 -0
- package/src/ui/Badge.tsx +46 -0
- package/src/ui/BottomNavigation.tsx +65 -0
- package/src/ui/Breadcrumb.tsx +64 -0
- package/src/ui/Button.tsx +49 -0
- package/src/ui/Calendar.tsx +40 -0
- package/src/ui/CalendarHeatmap.tsx +180 -0
- package/src/ui/Card.tsx +84 -0
- package/src/ui/Carousel.tsx +121 -0
- package/src/ui/Cascader.tsx +158 -0
- package/src/ui/Checkbox.tsx +35 -0
- package/src/ui/Clock.tsx +221 -0
- package/src/ui/Collapse.tsx +48 -0
- package/src/ui/ColorPicker.tsx +89 -0
- package/src/ui/Combobox.tsx +67 -0
- package/src/ui/Command.tsx +158 -0
- package/src/ui/Comment.tsx +91 -0
- package/src/ui/ContextMenu.tsx +58 -0
- package/src/ui/Countdown.tsx +123 -0
- package/src/ui/Curtain.tsx +348 -0
- package/src/ui/DataGrid.tsx +177 -0
- package/src/ui/DateField.tsx +177 -0
- package/src/ui/DateRangePicker.tsx +204 -0
- package/src/ui/DateTimeField.tsx +68 -0
- package/src/ui/Descriptions.tsx +56 -0
- package/src/ui/Drawer.tsx +72 -0
- package/src/ui/Dropdown.tsx +71 -0
- package/src/ui/Dropzone.tsx +88 -0
- package/src/ui/Empty.tsx +53 -0
- package/src/ui/Expandable.tsx +229 -0
- package/src/ui/FileUpload.tsx +198 -0
- package/src/ui/FillText.tsx +63 -0
- package/src/ui/FloatingActionButton.tsx +48 -0
- package/src/ui/Form.tsx +108 -0
- package/src/ui/Highlight.tsx +53 -0
- package/src/ui/HoldToConfirm.tsx +141 -0
- package/src/ui/HoverCard.tsx +42 -0
- package/src/ui/Image.tsx +66 -0
- package/src/ui/Input.tsx +38 -0
- package/src/ui/Kbd.tsx +25 -0
- package/src/ui/List.tsx +71 -0
- package/src/ui/LoadingDots.tsx +75 -0
- package/src/ui/Marquee.tsx +48 -0
- package/src/ui/Mentions.tsx +170 -0
- package/src/ui/Meter.tsx +57 -0
- package/src/ui/Modal.tsx +106 -0
- package/src/ui/MultiSelect.tsx +236 -0
- package/src/ui/MultiStateBadge.tsx +93 -0
- package/src/ui/NotificationCenter.tsx +103 -0
- package/src/ui/NotificationStack.tsx +104 -0
- package/src/ui/NowPlaying.tsx +100 -0
- package/src/ui/NumberInput.tsx +48 -0
- package/src/ui/PageHeader.tsx +60 -0
- package/src/ui/Pagination.tsx +62 -0
- package/src/ui/Parallax.tsx +51 -0
- package/src/ui/Popover.tsx +42 -0
- package/src/ui/Portal.tsx +34 -0
- package/src/ui/Progress.tsx +55 -0
- package/src/ui/RadioGroup.tsx +65 -0
- package/src/ui/Rating.tsx +98 -0
- package/src/ui/Result.tsx +75 -0
- package/src/ui/RingProgress.tsx +75 -0
- package/src/ui/ScrambleText.tsx +95 -0
- package/src/ui/SegmentedControl.tsx +61 -0
- package/src/ui/Select.tsx +55 -0
- package/src/ui/Separator.tsx +39 -0
- package/src/ui/Skeleton.tsx +23 -0
- package/src/ui/Slider.tsx +57 -0
- package/src/ui/Sortable.tsx +174 -0
- package/src/ui/SpeedDial.tsx +85 -0
- package/src/ui/Spinner.tsx +33 -0
- package/src/ui/Splitter.tsx +115 -0
- package/src/ui/Stat.tsx +74 -0
- package/src/ui/Stepper.tsx +134 -0
- package/src/ui/Switch.tsx +44 -0
- package/src/ui/Table.tsx +128 -0
- package/src/ui/Tabs.tsx +72 -0
- package/src/ui/TagInput.tsx +85 -0
- package/src/ui/TextReveal.tsx +93 -0
- package/src/ui/Textarea.tsx +38 -0
- package/src/ui/TimeField.tsx +298 -0
- package/src/ui/Timeline.tsx +89 -0
- package/src/ui/Toast.tsx +68 -0
- package/src/ui/Toggle.tsx +41 -0
- package/src/ui/ToggleGroup.tsx +59 -0
- package/src/ui/Tooltip.tsx +42 -0
- package/src/ui/Tour.tsx +197 -0
- package/src/ui/Transfer.tsx +112 -0
- package/src/ui/Tree.tsx +100 -0
- package/src/ui/TreeSelect.tsx +151 -0
- package/src/ui/VirtualList.tsx +81 -0
- package/src/ui/internal/CalendarCore.tsx +276 -0
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
// Right-anchored slide-over on Kobalte's Dialog primitive (focus trap +
|
|
2
|
+
// presence for free, like Modal). The drawer-content class drives the
|
|
3
|
+
// slide-in/out keyframes in styles.css off Kobalte's data-expanded/data-closed.
|
|
4
|
+
// Pass a `title` for the sticky glass header, or omit it and render your own
|
|
5
|
+
// inside `children`.
|
|
6
|
+
import { Dialog } from '@kobalte/core/dialog'
|
|
7
|
+
import { X } from 'lucide-solid'
|
|
8
|
+
import type { JSX, ParentProps } from 'solid-js'
|
|
9
|
+
import { Show } from 'solid-js'
|
|
10
|
+
|
|
11
|
+
import { cn } from '../lib/cn'
|
|
12
|
+
|
|
13
|
+
interface DrawerProps extends ParentProps {
|
|
14
|
+
open: boolean
|
|
15
|
+
onOpenChange: (open: boolean) => void
|
|
16
|
+
/** Sticky glass header title. Omit to render fully custom header content in `children`. */
|
|
17
|
+
title?: string
|
|
18
|
+
/** Small muted line under `title`; only shown when `title` is also set. */
|
|
19
|
+
subtitle?: string
|
|
20
|
+
class?: string
|
|
21
|
+
/** Accessible label for the close button. @default 'Close' */
|
|
22
|
+
closeLabel?: string
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Right-anchored slide-over panel built on Kobalte's `Dialog` (focus trap, portal,
|
|
27
|
+
* and open/close presence handled for you). Use for create/edit forms or detail
|
|
28
|
+
* panels that need more room than a centered `Modal`.
|
|
29
|
+
*
|
|
30
|
+
* @example
|
|
31
|
+
* ```tsx
|
|
32
|
+
* const [open, setOpen] = createSignal(false)
|
|
33
|
+
* <Drawer open={open()} onOpenChange={setOpen} title="Edit invoice">
|
|
34
|
+
* <p>Drawer content…</p>
|
|
35
|
+
* </Drawer>
|
|
36
|
+
* ```
|
|
37
|
+
*/
|
|
38
|
+
export function Drawer(props: DrawerProps): JSX.Element {
|
|
39
|
+
return (
|
|
40
|
+
<Dialog open={props.open} onOpenChange={props.onOpenChange}>
|
|
41
|
+
<Dialog.Portal>
|
|
42
|
+
<Dialog.Overlay class="modal-overlay fixed inset-0 z-40 bg-black/50 backdrop-blur-[2px]" />
|
|
43
|
+
<div class="fixed inset-0 z-50 flex justify-end">
|
|
44
|
+
<Dialog.Content
|
|
45
|
+
class={cn(
|
|
46
|
+
'drawer-content flex h-full w-full max-w-[520px] flex-col overflow-y-auto border-l border-border bg-glass shadow-2xl',
|
|
47
|
+
props.class,
|
|
48
|
+
)}
|
|
49
|
+
>
|
|
50
|
+
<Show when={props.title}>
|
|
51
|
+
<div class="sticky top-0 z-10 flex items-center justify-between border-b border-border bg-background/90 px-5 py-4 backdrop-blur">
|
|
52
|
+
<div>
|
|
53
|
+
<Dialog.Title class="text-[16px] font-bold leading-tight">{props.title}</Dialog.Title>
|
|
54
|
+
<Show when={props.subtitle}>
|
|
55
|
+
<p class="text-[12px] text-muted-foreground">{props.subtitle}</p>
|
|
56
|
+
</Show>
|
|
57
|
+
</div>
|
|
58
|
+
<Dialog.CloseButton
|
|
59
|
+
class="grid h-9 w-9 place-items-center rounded-lg text-muted-foreground transition hover:bg-muted hover:text-foreground"
|
|
60
|
+
aria-label={props.closeLabel ?? 'Close'}
|
|
61
|
+
>
|
|
62
|
+
<X class="h-5 w-5" />
|
|
63
|
+
</Dialog.CloseButton>
|
|
64
|
+
</div>
|
|
65
|
+
</Show>
|
|
66
|
+
{props.children}
|
|
67
|
+
</Dialog.Content>
|
|
68
|
+
</div>
|
|
69
|
+
</Dialog.Portal>
|
|
70
|
+
</Dialog>
|
|
71
|
+
)
|
|
72
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
// Accessible dropdown menu on Kobalte's DropdownMenu primitive.
|
|
2
|
+
import { DropdownMenu } from '@kobalte/core/dropdown-menu'
|
|
3
|
+
import type { JSX, ParentProps } from 'solid-js'
|
|
4
|
+
import { For } from 'solid-js'
|
|
5
|
+
|
|
6
|
+
import { cn } from '../lib/cn'
|
|
7
|
+
|
|
8
|
+
/** A single selectable row in a {@link Dropdown} menu. */
|
|
9
|
+
export interface DropdownItem {
|
|
10
|
+
label: string
|
|
11
|
+
onSelect: () => void
|
|
12
|
+
/** Styles the label in the destructive color (e.g. "Delete"). */
|
|
13
|
+
destructive?: boolean
|
|
14
|
+
disabled?: boolean
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
interface DropdownProps extends ParentProps {
|
|
18
|
+
items: DropdownItem[]
|
|
19
|
+
class?: string
|
|
20
|
+
/** Accessible name for the trigger. Pass this instead of nesting a `<button>`
|
|
21
|
+
inside — the Trigger IS the button, so its children must be non-interactive. */
|
|
22
|
+
label?: string
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Accessible dropdown/context menu on Kobalte's `DropdownMenu` primitive.
|
|
27
|
+
* `props.children` is the trigger's (non-interactive) content — the Trigger
|
|
28
|
+
* itself renders as the button, so don't nest another `<button>` inside it.
|
|
29
|
+
* Unlike a Dialog, it does not lock page scroll while open.
|
|
30
|
+
*
|
|
31
|
+
* @example
|
|
32
|
+
* ```tsx
|
|
33
|
+
* <Dropdown label="Row actions" items={[
|
|
34
|
+
* { label: 'Edit', onSelect: () => edit(row) },
|
|
35
|
+
* { label: 'Delete', destructive: true, onSelect: () => remove(row) },
|
|
36
|
+
* ]}>
|
|
37
|
+
* <MoreVertical class="h-4 w-4" />
|
|
38
|
+
* </Dropdown>
|
|
39
|
+
* ```
|
|
40
|
+
*/
|
|
41
|
+
export function Dropdown(props: DropdownProps): JSX.Element {
|
|
42
|
+
return (
|
|
43
|
+
// preventScroll defaults to true in Kobalte's menu — that locks page scroll
|
|
44
|
+
// (body overflow:hidden + scrollbar-width compensation) while the menu is
|
|
45
|
+
// open, which is wrong for a small contextual menu (it closes on scroll
|
|
46
|
+
// anyway). Dialogs (Modal/Drawer) keep the lock; a dropdown shouldn't.
|
|
47
|
+
<DropdownMenu preventScroll={false}>
|
|
48
|
+
<DropdownMenu.Trigger class={cn('inline-flex', props.class)} aria-label={props.label}>
|
|
49
|
+
{props.children}
|
|
50
|
+
</DropdownMenu.Trigger>
|
|
51
|
+
<DropdownMenu.Portal>
|
|
52
|
+
<DropdownMenu.Content class="z-50 min-w-[10rem] overflow-hidden rounded-md border border-border bg-card p-1 text-card-foreground shadow-sm">
|
|
53
|
+
<For each={props.items}>
|
|
54
|
+
{(item) => (
|
|
55
|
+
<DropdownMenu.Item
|
|
56
|
+
class={cn(
|
|
57
|
+
'cursor-pointer select-none rounded-sm px-2 py-1.5 text-sm outline-none hover:bg-muted data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
|
58
|
+
item.destructive && 'text-destructive',
|
|
59
|
+
)}
|
|
60
|
+
disabled={item.disabled}
|
|
61
|
+
onSelect={item.onSelect}
|
|
62
|
+
>
|
|
63
|
+
{item.label}
|
|
64
|
+
</DropdownMenu.Item>
|
|
65
|
+
)}
|
|
66
|
+
</For>
|
|
67
|
+
</DropdownMenu.Content>
|
|
68
|
+
</DropdownMenu.Portal>
|
|
69
|
+
</DropdownMenu>
|
|
70
|
+
)
|
|
71
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
// Drag-and-drop (or click-to-browse) file dropzone. Purely presentational: it
|
|
2
|
+
// collects File[] and hands them to `onFiles` — the caller owns upload/mutation.
|
|
3
|
+
import { UploadCloud } from 'lucide-solid'
|
|
4
|
+
import { type JSX, Show, createSignal } from 'solid-js'
|
|
5
|
+
|
|
6
|
+
interface DropzoneProps {
|
|
7
|
+
/** Called with the dropped or browsed files; the caller owns upload/validation. */
|
|
8
|
+
onFiles: (files: File[]) => void
|
|
9
|
+
/** `accept` attribute for the hidden input, e.g. ".xml,.zip". */
|
|
10
|
+
accept?: string
|
|
11
|
+
multiple?: boolean
|
|
12
|
+
disabled?: boolean
|
|
13
|
+
/** Optional hint line under the primary label. */
|
|
14
|
+
hint?: string
|
|
15
|
+
label?: string
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Presentational drag-and-drop (or click-to-browse) file dropzone. Not a Kobalte
|
|
20
|
+
* wrapper — just a styled `role="button"` region plus a hidden `<input type="file">`.
|
|
21
|
+
* It only collects `File[]` via `onFiles`; it does not upload or store anything.
|
|
22
|
+
*
|
|
23
|
+
* @example
|
|
24
|
+
* ```tsx
|
|
25
|
+
* <Dropzone accept=".xml,.zip" multiple onFiles={(files) => upload(files)} />
|
|
26
|
+
* ```
|
|
27
|
+
*/
|
|
28
|
+
export function Dropzone(props: DropzoneProps): JSX.Element {
|
|
29
|
+
const [dragOver, setDragOver] = createSignal(false)
|
|
30
|
+
let input: HTMLInputElement | undefined
|
|
31
|
+
|
|
32
|
+
const emit = (list: FileList | null | undefined) => {
|
|
33
|
+
if (props.disabled || !list) return
|
|
34
|
+
const files = Array.from(list)
|
|
35
|
+
if (files.length) props.onFiles(files)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return (
|
|
39
|
+
<div
|
|
40
|
+
role="button"
|
|
41
|
+
tabindex={props.disabled ? -1 : 0}
|
|
42
|
+
aria-disabled={props.disabled}
|
|
43
|
+
onClick={() => !props.disabled && input?.click()}
|
|
44
|
+
onKeyDown={(e) => {
|
|
45
|
+
if (props.disabled) return
|
|
46
|
+
if (e.key === 'Enter' || e.key === ' ') {
|
|
47
|
+
e.preventDefault()
|
|
48
|
+
input?.click()
|
|
49
|
+
}
|
|
50
|
+
}}
|
|
51
|
+
onDragOver={(e) => {
|
|
52
|
+
if (props.disabled) return
|
|
53
|
+
e.preventDefault()
|
|
54
|
+
setDragOver(true)
|
|
55
|
+
}}
|
|
56
|
+
onDragLeave={() => setDragOver(false)}
|
|
57
|
+
onDrop={(e) => {
|
|
58
|
+
e.preventDefault()
|
|
59
|
+
setDragOver(false)
|
|
60
|
+
emit(e.dataTransfer?.files)
|
|
61
|
+
}}
|
|
62
|
+
class="flex cursor-pointer flex-col items-center justify-center gap-2 rounded-xl border-2 border-dashed px-6 py-10 text-center transition"
|
|
63
|
+
classList={{
|
|
64
|
+
'border-ring bg-primary/5': dragOver(),
|
|
65
|
+
'border-border bg-glass hover:border-ring/60': !dragOver() && !props.disabled,
|
|
66
|
+
'cursor-not-allowed opacity-60': props.disabled,
|
|
67
|
+
}}
|
|
68
|
+
>
|
|
69
|
+
<UploadCloud class="h-8 w-8 text-muted-foreground" />
|
|
70
|
+
<p class="text-sm font-medium text-foreground">{props.label ?? 'Drag files here or click to browse'}</p>
|
|
71
|
+
<Show when={props.hint}>
|
|
72
|
+
<p class="text-xs text-muted-foreground">{props.hint}</p>
|
|
73
|
+
</Show>
|
|
74
|
+
<input
|
|
75
|
+
ref={input}
|
|
76
|
+
type="file"
|
|
77
|
+
class="hidden"
|
|
78
|
+
accept={props.accept}
|
|
79
|
+
multiple={props.multiple}
|
|
80
|
+
disabled={props.disabled}
|
|
81
|
+
onChange={(e) => {
|
|
82
|
+
emit(e.currentTarget.files)
|
|
83
|
+
e.currentTarget.value = '' // allow re-selecting the same file
|
|
84
|
+
}}
|
|
85
|
+
/>
|
|
86
|
+
</div>
|
|
87
|
+
)
|
|
88
|
+
}
|
package/src/ui/Empty.tsx
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// Empty-state placeholder: a centered vertical stack for "nothing here yet"
|
|
2
|
+
// surfaces (empty lists, no search results, first-run views). The icon sits in a
|
|
3
|
+
// muted circle; everything uses semantic tokens so it stays theme-agnostic in
|
|
4
|
+
// both dark and light themes.
|
|
5
|
+
import { Inbox } from 'lucide-solid'
|
|
6
|
+
import { Show, type JSX } from 'solid-js'
|
|
7
|
+
|
|
8
|
+
import { cn } from '../lib/cn'
|
|
9
|
+
|
|
10
|
+
export interface EmptyProps {
|
|
11
|
+
/** Bold headline describing the empty state. */
|
|
12
|
+
title: string
|
|
13
|
+
/** Optional supporting line under the title. */
|
|
14
|
+
description?: string
|
|
15
|
+
/** Optional custom icon; falls back to a lucide-solid `Inbox`. */
|
|
16
|
+
icon?: JSX.Element
|
|
17
|
+
/** Optional action slot (e.g. a Button) rendered below the text. */
|
|
18
|
+
action?: JSX.Element
|
|
19
|
+
class?: string
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Empty-state placeholder — a centered icon, title, optional description, and an
|
|
24
|
+
* optional action slot — for lists, search results, or first-run views that have
|
|
25
|
+
* nothing to show yet. Falls back to an `Inbox` icon when none is provided.
|
|
26
|
+
*
|
|
27
|
+
* @example
|
|
28
|
+
* ```tsx
|
|
29
|
+
* <Empty
|
|
30
|
+
* title="No projects yet"
|
|
31
|
+
* description="Create your first project to get started."
|
|
32
|
+
* action={<Button>New project</Button>}
|
|
33
|
+
* />
|
|
34
|
+
* ```
|
|
35
|
+
*/
|
|
36
|
+
export function Empty(props: EmptyProps): JSX.Element {
|
|
37
|
+
return (
|
|
38
|
+
<div class={cn('flex flex-col items-center py-12 text-center', props.class)}>
|
|
39
|
+
<div class="grid h-12 w-12 place-items-center rounded-full bg-muted text-muted-foreground">
|
|
40
|
+
<Show when={props.icon} fallback={<Inbox class="h-6 w-6" />}>
|
|
41
|
+
{props.icon}
|
|
42
|
+
</Show>
|
|
43
|
+
</div>
|
|
44
|
+
<p class="mt-4 font-semibold text-foreground">{props.title}</p>
|
|
45
|
+
<Show when={props.description}>
|
|
46
|
+
<p class="mt-1 max-w-sm text-sm text-muted-foreground">{props.description}</p>
|
|
47
|
+
</Show>
|
|
48
|
+
<Show when={props.action}>
|
|
49
|
+
<div class="mt-4">{props.action}</div>
|
|
50
|
+
</Show>
|
|
51
|
+
</div>
|
|
52
|
+
)
|
|
53
|
+
}
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
// Shared-element transition: a compact `trigger` (a card) morphs into an
|
|
2
|
+
// expanded overlay panel and back, using a FLIP animation. We animate the
|
|
3
|
+
// panel's position and SIZE (top/left/width/height) rather than a transform
|
|
4
|
+
// scale, so its content never stretches. Covers the motion.dev "family dialog"
|
|
5
|
+
// (size="dialog") and "app store layout" (size="full") patterns with one API.
|
|
6
|
+
import { createSignal, Show, onCleanup, onMount, type JSX } from 'solid-js'
|
|
7
|
+
import { Portal } from 'solid-js/web'
|
|
8
|
+
|
|
9
|
+
import { cn } from '../lib/cn'
|
|
10
|
+
import { animate, motionReduced } from '../lib/motion'
|
|
11
|
+
|
|
12
|
+
type Rect = { top: number; left: number; width: number; height: number }
|
|
13
|
+
|
|
14
|
+
export interface ExpandableProps {
|
|
15
|
+
/** Collapsed content — the card users click to expand. */
|
|
16
|
+
trigger: JSX.Element
|
|
17
|
+
/** Expanded panel content. */
|
|
18
|
+
children: JSX.Element
|
|
19
|
+
/** `dialog` = centered panel capped at `maxWidth`; `full` = near-fullscreen. @default 'dialog' */
|
|
20
|
+
size?: 'dialog' | 'full'
|
|
21
|
+
/** Max width (px) of the expanded panel when `size='dialog'`. @default 640 */
|
|
22
|
+
maxWidth?: number
|
|
23
|
+
/** Notified when the panel opens/closes. */
|
|
24
|
+
onOpenChange?: (open: boolean) => void
|
|
25
|
+
/** Class for the inline trigger wrapper. */
|
|
26
|
+
class?: string
|
|
27
|
+
/** Class for the expanded panel surface. */
|
|
28
|
+
panelClass?: string
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const PAD = 24
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Expands a card into an overlay panel with a shared-element (FLIP) transition —
|
|
35
|
+
* the card appears to grow into the dialog and shrink back on close. Animates
|
|
36
|
+
* position + size (never scale) so content stays crisp. Falls back to a plain
|
|
37
|
+
* show/hide under reduced motion. Close with the ✕, a backdrop click, or Escape.
|
|
38
|
+
*
|
|
39
|
+
* @example
|
|
40
|
+
* ```tsx
|
|
41
|
+
* <Expandable
|
|
42
|
+
* trigger={<Card class="cursor-pointer">Tap to expand</Card>}
|
|
43
|
+
* size="dialog"
|
|
44
|
+
* >
|
|
45
|
+
* <div class="p-6">Full details here…</div>
|
|
46
|
+
* </Expandable>
|
|
47
|
+
* ```
|
|
48
|
+
*/
|
|
49
|
+
export function Expandable(props: ExpandableProps): JSX.Element {
|
|
50
|
+
const [open, setOpen] = createSignal(false)
|
|
51
|
+
let triggerEl: HTMLDivElement | undefined
|
|
52
|
+
let panelEl: HTMLDivElement | undefined
|
|
53
|
+
let backdropEl: HTMLDivElement | undefined
|
|
54
|
+
let originRect: Rect | undefined
|
|
55
|
+
let controls: ReturnType<typeof animate> | undefined
|
|
56
|
+
|
|
57
|
+
const rectOf = (el: Element): Rect => {
|
|
58
|
+
const r = el.getBoundingClientRect()
|
|
59
|
+
return { top: r.top, left: r.left, width: r.width, height: r.height }
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// The panel's resting position/size, computed (not scaled) so text stays sharp.
|
|
63
|
+
const finalRect = (el: HTMLDivElement): Rect => {
|
|
64
|
+
const vw = window.innerWidth
|
|
65
|
+
const vh = window.innerHeight
|
|
66
|
+
if (props.size === 'full') {
|
|
67
|
+
const width = vw - PAD * 2
|
|
68
|
+
const height = vh - PAD * 2
|
|
69
|
+
return { left: PAD, top: PAD, width, height }
|
|
70
|
+
}
|
|
71
|
+
const width = Math.min(props.maxWidth ?? 640, vw - PAD * 2)
|
|
72
|
+
// Measure the natural height at the target width.
|
|
73
|
+
el.style.width = `${width}px`
|
|
74
|
+
el.style.height = 'auto'
|
|
75
|
+
el.style.maxHeight = `${vh - PAD * 2}px`
|
|
76
|
+
const natH = el.getBoundingClientRect().height
|
|
77
|
+
const height = Math.min(natH, vh - PAD * 2)
|
|
78
|
+
return { left: (vw - width) / 2, top: (vh - height) / 2, width, height }
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const place = (el: HTMLDivElement, r: Rect) => {
|
|
82
|
+
el.style.top = `${r.top}px`
|
|
83
|
+
el.style.left = `${r.left}px`
|
|
84
|
+
el.style.width = `${r.width}px`
|
|
85
|
+
el.style.height = `${r.height}px`
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const doOpen = () => {
|
|
89
|
+
if (open() || !triggerEl) return
|
|
90
|
+
originRect = rectOf(triggerEl)
|
|
91
|
+
setOpen(true)
|
|
92
|
+
props.onOpenChange?.(true)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Runs once the portalled panel is in the DOM: FLIP from the card to the panel.
|
|
96
|
+
const runOpenFlip = () => {
|
|
97
|
+
const el = panelEl
|
|
98
|
+
if (!el) return
|
|
99
|
+
const final = finalRect(el)
|
|
100
|
+
if (motionReduced() || !originRect) {
|
|
101
|
+
place(el, final)
|
|
102
|
+
el.style.overflow = 'auto'
|
|
103
|
+
el.style.opacity = '1'
|
|
104
|
+
return
|
|
105
|
+
}
|
|
106
|
+
place(el, originRect) // first
|
|
107
|
+
el.style.opacity = '1'
|
|
108
|
+
void el.offsetWidth // force reflow
|
|
109
|
+
controls?.stop()
|
|
110
|
+
controls = animate(
|
|
111
|
+
el,
|
|
112
|
+
{
|
|
113
|
+
top: [originRect.top, final.top],
|
|
114
|
+
left: [originRect.left, final.left],
|
|
115
|
+
width: [originRect.width, final.width],
|
|
116
|
+
height: [originRect.height, final.height],
|
|
117
|
+
},
|
|
118
|
+
{ type: 'spring', stiffness: 280, damping: 32 },
|
|
119
|
+
)
|
|
120
|
+
if (backdropEl) animate(backdropEl, { opacity: [0, 1] }, { duration: 0.25, ease: 'easeOut' })
|
|
121
|
+
controls.finished
|
|
122
|
+
.then(() => {
|
|
123
|
+
el.style.overflow = 'auto'
|
|
124
|
+
})
|
|
125
|
+
.catch(() => {})
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const doClose = () => {
|
|
129
|
+
if (!open()) return
|
|
130
|
+
const el = panelEl
|
|
131
|
+
const back = triggerEl ? rectOf(triggerEl) : originRect
|
|
132
|
+
if (!el || !back || motionReduced()) {
|
|
133
|
+
finishClose()
|
|
134
|
+
return
|
|
135
|
+
}
|
|
136
|
+
controls?.stop()
|
|
137
|
+
el.style.overflow = 'hidden'
|
|
138
|
+
place(el, rectOf(el)) // pin current
|
|
139
|
+
void el.offsetWidth
|
|
140
|
+
controls = animate(
|
|
141
|
+
el,
|
|
142
|
+
{ top: back.top, left: back.left, width: back.width, height: back.height, opacity: [1, 0.35] },
|
|
143
|
+
{ duration: 0.3, ease: 'easeInOut' },
|
|
144
|
+
)
|
|
145
|
+
if (backdropEl) animate(backdropEl, { opacity: [1, 0] }, { duration: 0.25, ease: 'easeIn' })
|
|
146
|
+
controls.finished.then(finishClose).catch(finishClose)
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const finishClose = () => {
|
|
150
|
+
setOpen(false)
|
|
151
|
+
props.onOpenChange?.(false)
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const onKeyDown = (e: KeyboardEvent) => {
|
|
155
|
+
if (e.key === 'Escape') doClose()
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
onMount(() => document.addEventListener('keydown', onKeyDown))
|
|
159
|
+
onCleanup(() => {
|
|
160
|
+
document.removeEventListener('keydown', onKeyDown)
|
|
161
|
+
controls?.stop()
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
return (
|
|
165
|
+
<>
|
|
166
|
+
<div
|
|
167
|
+
ref={triggerEl}
|
|
168
|
+
role="button"
|
|
169
|
+
tabindex={0}
|
|
170
|
+
class={cn('cursor-pointer', props.class)}
|
|
171
|
+
onClick={doOpen}
|
|
172
|
+
onKeyDown={(e) => {
|
|
173
|
+
if (e.key === 'Enter' || e.key === ' ') {
|
|
174
|
+
e.preventDefault()
|
|
175
|
+
doOpen()
|
|
176
|
+
}
|
|
177
|
+
}}
|
|
178
|
+
>
|
|
179
|
+
{props.trigger}
|
|
180
|
+
</div>
|
|
181
|
+
|
|
182
|
+
<Show when={open()}>
|
|
183
|
+
<Portal>
|
|
184
|
+
<div
|
|
185
|
+
ref={backdropEl}
|
|
186
|
+
aria-hidden="true"
|
|
187
|
+
onClick={doClose}
|
|
188
|
+
class="fixed inset-0 z-[9998] bg-black/50 backdrop-blur-sm"
|
|
189
|
+
style={{ opacity: motionReduced() ? '1' : '0' }}
|
|
190
|
+
/>
|
|
191
|
+
<div
|
|
192
|
+
ref={(el) => {
|
|
193
|
+
panelEl = el
|
|
194
|
+
// Measure/FLIP after layout settles.
|
|
195
|
+
requestAnimationFrame(runOpenFlip)
|
|
196
|
+
}}
|
|
197
|
+
role="dialog"
|
|
198
|
+
aria-modal="true"
|
|
199
|
+
class={cn(
|
|
200
|
+
'card fixed z-[9999] overflow-hidden rounded-2xl border border-border bg-card shadow-2xl',
|
|
201
|
+
props.panelClass,
|
|
202
|
+
)}
|
|
203
|
+
style={{ opacity: '0', margin: '0' }}
|
|
204
|
+
>
|
|
205
|
+
<button
|
|
206
|
+
type="button"
|
|
207
|
+
aria-label="Close"
|
|
208
|
+
onClick={doClose}
|
|
209
|
+
class="absolute right-3 top-3 z-10 grid h-8 w-8 place-items-center rounded-full bg-muted/80 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
|
210
|
+
>
|
|
211
|
+
<svg
|
|
212
|
+
viewBox="0 0 24 24"
|
|
213
|
+
class="h-4 w-4"
|
|
214
|
+
fill="none"
|
|
215
|
+
stroke="currentColor"
|
|
216
|
+
stroke-width="2.5"
|
|
217
|
+
stroke-linecap="round"
|
|
218
|
+
aria-hidden="true"
|
|
219
|
+
>
|
|
220
|
+
<path d="M6 6l12 12M18 6L6 18" />
|
|
221
|
+
</svg>
|
|
222
|
+
</button>
|
|
223
|
+
{props.children}
|
|
224
|
+
</div>
|
|
225
|
+
</Portal>
|
|
226
|
+
</Show>
|
|
227
|
+
</>
|
|
228
|
+
)
|
|
229
|
+
}
|