@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,58 @@
|
|
|
1
|
+
// Right-click menu on Kobalte's ContextMenu primitive. `children` is the target.
|
|
2
|
+
import { ContextMenu as KContextMenu } from '@kobalte/core/context-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 actionable row rendered by {@link ContextMenu}. */
|
|
9
|
+
export interface ContextMenuItem {
|
|
10
|
+
label: string
|
|
11
|
+
/** Called when the item is chosen (click or keyboard activation). */
|
|
12
|
+
onSelect: () => void
|
|
13
|
+
/** Styles the label as a destructive action (e.g. "Delete"). */
|
|
14
|
+
destructive?: boolean
|
|
15
|
+
disabled?: boolean
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
interface ContextMenuProps extends ParentProps {
|
|
19
|
+
items: ContextMenuItem[]
|
|
20
|
+
class?: string
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Right-click (or long-press) menu built on Kobalte's `ContextMenu`
|
|
25
|
+
* primitive. `children` is the element that acts as the trigger/target area.
|
|
26
|
+
*
|
|
27
|
+
* @example
|
|
28
|
+
* ```tsx
|
|
29
|
+
* <ContextMenu items={[{ label: 'Rename', onSelect: rename }, { label: 'Delete', onSelect: remove, destructive: true }]}>
|
|
30
|
+
* <div class="rounded border p-4">Right-click me</div>
|
|
31
|
+
* </ContextMenu>
|
|
32
|
+
* ```
|
|
33
|
+
*/
|
|
34
|
+
export function ContextMenu(props: ContextMenuProps): JSX.Element {
|
|
35
|
+
return (
|
|
36
|
+
<KContextMenu>
|
|
37
|
+
<KContextMenu.Trigger class={cn('inline-flex', props.class)}>{props.children}</KContextMenu.Trigger>
|
|
38
|
+
<KContextMenu.Portal>
|
|
39
|
+
<KContextMenu.Content class="z-50 min-w-[10rem] overflow-hidden rounded-md border border-border bg-card p-1 text-card-foreground shadow-sm">
|
|
40
|
+
<For each={props.items}>
|
|
41
|
+
{(item) => (
|
|
42
|
+
<KContextMenu.Item
|
|
43
|
+
class={cn(
|
|
44
|
+
'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',
|
|
45
|
+
item.destructive && 'text-destructive',
|
|
46
|
+
)}
|
|
47
|
+
disabled={item.disabled}
|
|
48
|
+
onSelect={item.onSelect}
|
|
49
|
+
>
|
|
50
|
+
{item.label}
|
|
51
|
+
</KContextMenu.Item>
|
|
52
|
+
)}
|
|
53
|
+
</For>
|
|
54
|
+
</KContextMenu.Content>
|
|
55
|
+
</KContextMenu.Portal>
|
|
56
|
+
</KContextMenu>
|
|
57
|
+
)
|
|
58
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
// Live countdown to a target Date, rendered as four Days/Hours/Mins/Secs cells.
|
|
2
|
+
// A single setInterval (started in onMount, cleared in onCleanup) ticks a signal
|
|
3
|
+
// once per second; remaining ms is clamped at 0 and `onComplete` fires exactly
|
|
4
|
+
// once when the target is reached. Each digit is an odometer-style roller: the
|
|
5
|
+
// column of 0–9 slides so the active digit sits in the viewport, so a decreasing
|
|
6
|
+
// countdown rolls the new number in from the top — like an old flip clock.
|
|
7
|
+
//
|
|
8
|
+
// Digit/cell slots use <Index> (keyed by position, not value) so they persist
|
|
9
|
+
// across ticks and the CSS transform transition actually animates instead of the
|
|
10
|
+
// node being torn down and recreated each second.
|
|
11
|
+
//
|
|
12
|
+
// The target instant is captured once (createMemo): a parent JSX expression like
|
|
13
|
+
// `to={new Date(Date.now() + X)}` compiles to a getter Solid re-evaluates on each
|
|
14
|
+
// access, which would otherwise pin "remaining" at X forever. Memoizing reads it
|
|
15
|
+
// a single time (and still updates if `to` is backed by a real signal).
|
|
16
|
+
import { createMemo, createSignal, Index, onCleanup, onMount, type JSX } from 'solid-js'
|
|
17
|
+
|
|
18
|
+
import { cn } from '../lib/cn'
|
|
19
|
+
|
|
20
|
+
export interface CountdownProps {
|
|
21
|
+
/** Target moment the countdown runs toward. */
|
|
22
|
+
to: Date
|
|
23
|
+
/** Called once, the moment the countdown reaches zero. */
|
|
24
|
+
onComplete?: () => void
|
|
25
|
+
class?: string
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const MS_PER_SECOND = 1000
|
|
29
|
+
const MS_PER_MINUTE = 60 * MS_PER_SECOND
|
|
30
|
+
const MS_PER_HOUR = 60 * MS_PER_MINUTE
|
|
31
|
+
const MS_PER_DAY = 24 * MS_PER_HOUR
|
|
32
|
+
const DIGITS = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
|
|
33
|
+
|
|
34
|
+
const pad = (n: number): string => String(n).padStart(2, '0')
|
|
35
|
+
|
|
36
|
+
/** One odometer digit: a 0–9 column shifted so `char`'s row fills the viewport. */
|
|
37
|
+
function Digit(props: { char: string }): JSX.Element {
|
|
38
|
+
const digit = () => (props.char >= '0' && props.char <= '9' ? Number(props.char) : 0)
|
|
39
|
+
return (
|
|
40
|
+
<span class="relative inline-block h-[1em] w-[0.62em] overflow-hidden align-top">
|
|
41
|
+
<span
|
|
42
|
+
class="absolute inset-x-0 top-0 flex flex-col transition-transform duration-500 ease-out"
|
|
43
|
+
style={{ transform: `translateY(${digit() * -10}%)` }}
|
|
44
|
+
>
|
|
45
|
+
<Index each={DIGITS}>
|
|
46
|
+
{(d) => <span class="block h-[1em] text-center leading-none">{d()}</span>}
|
|
47
|
+
</Index>
|
|
48
|
+
</span>
|
|
49
|
+
</span>
|
|
50
|
+
)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Render a value string as a row of rolling digits (stable slots per position). */
|
|
54
|
+
function Rollers(props: { value: string }): JSX.Element {
|
|
55
|
+
const chars = () => props.value.split('')
|
|
56
|
+
return (
|
|
57
|
+
<span class="flex text-2xl font-bold tabular-nums text-foreground">
|
|
58
|
+
<Index each={chars()}>{(char) => <Digit char={char()} />}</Index>
|
|
59
|
+
</span>
|
|
60
|
+
)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Live countdown to `props.to`, shown as four cells (Days / Hours / Mins /
|
|
65
|
+
* Secs). Updates every second and calls `props.onComplete` once when it hits
|
|
66
|
+
* zero. Each digit rolls like an old flip clock as it changes. Hours, minutes,
|
|
67
|
+
* and seconds are zero-padded to two digits.
|
|
68
|
+
*
|
|
69
|
+
* @example
|
|
70
|
+
* ```tsx
|
|
71
|
+
* <Countdown to={new Date(Date.now() + 86_400_000)} onComplete={() => launch()} />
|
|
72
|
+
* ```
|
|
73
|
+
*/
|
|
74
|
+
export function Countdown(props: CountdownProps): JSX.Element {
|
|
75
|
+
// Read the target instant once (see file header on why this must be memoized).
|
|
76
|
+
const target = createMemo(() => props.to.getTime())
|
|
77
|
+
const remainingOf = () => Math.max(0, target() - Date.now())
|
|
78
|
+
const [remaining, setRemaining] = createSignal(remainingOf())
|
|
79
|
+
|
|
80
|
+
let completed = false
|
|
81
|
+
const notifyIfDone = (ms: number) => {
|
|
82
|
+
if (ms <= 0 && !completed) {
|
|
83
|
+
completed = true
|
|
84
|
+
props.onComplete?.()
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
// eslint-disable-next-line solid/reactivity -- one-time check at setup for an already-elapsed target
|
|
88
|
+
notifyIfDone(remaining())
|
|
89
|
+
|
|
90
|
+
onMount(() => {
|
|
91
|
+
const id = setInterval(() => {
|
|
92
|
+
const ms = remainingOf()
|
|
93
|
+
setRemaining(ms)
|
|
94
|
+
notifyIfDone(ms)
|
|
95
|
+
}, MS_PER_SECOND)
|
|
96
|
+
onCleanup(() => clearInterval(id))
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
const days = () => Math.floor(remaining() / MS_PER_DAY)
|
|
100
|
+
const hours = () => Math.floor((remaining() % MS_PER_DAY) / MS_PER_HOUR)
|
|
101
|
+
const minutes = () => Math.floor((remaining() % MS_PER_HOUR) / MS_PER_MINUTE)
|
|
102
|
+
const seconds = () => Math.floor((remaining() % MS_PER_MINUTE) / MS_PER_SECOND)
|
|
103
|
+
|
|
104
|
+
const cells = (): { label: string; value: string }[] => [
|
|
105
|
+
{ label: 'Days', value: String(days()) },
|
|
106
|
+
{ label: 'Hours', value: pad(hours()) },
|
|
107
|
+
{ label: 'Mins', value: pad(minutes()) },
|
|
108
|
+
{ label: 'Secs', value: pad(seconds()) },
|
|
109
|
+
]
|
|
110
|
+
|
|
111
|
+
return (
|
|
112
|
+
<div class={cn('flex gap-4', props.class)}>
|
|
113
|
+
<Index each={cells()}>
|
|
114
|
+
{(cell) => (
|
|
115
|
+
<div class="flex flex-col items-center">
|
|
116
|
+
<Rollers value={cell().value} />
|
|
117
|
+
<span class="text-[10px] uppercase tracking-wide text-muted-foreground">{cell().label}</span>
|
|
118
|
+
</div>
|
|
119
|
+
)}
|
|
120
|
+
</Index>
|
|
121
|
+
</div>
|
|
122
|
+
)
|
|
123
|
+
}
|
|
@@ -0,0 +1,348 @@
|
|
|
1
|
+
// Curtain — a controlled, full-screen cover→uncover transition primitive for
|
|
2
|
+
// page/route transitions. It collapses motion.dev's "curtains" example family
|
|
3
|
+
// (fade, sliding doors, blinds, shutter, iris, clip-wipe, pixels) into one component driven by a
|
|
4
|
+
// single `show` boolean: `true` plays the "cover" animation (the curtain
|
|
5
|
+
// closes over the screen), `false` plays "uncover" (the curtain opens,
|
|
6
|
+
// revealing whatever's behind it). Swap your route content while covered —
|
|
7
|
+
// `onCovered` fires the instant the screen is fully masked, `onRevealed`
|
|
8
|
+
// once it's fully open again.
|
|
9
|
+
import { createEffect, createSignal, For, Index, on, onCleanup, onMount, type JSX } from 'solid-js'
|
|
10
|
+
|
|
11
|
+
import { cn } from '../lib/cn'
|
|
12
|
+
import { animate, motionReduced, stagger } from '../lib/motion'
|
|
13
|
+
|
|
14
|
+
export type CurtainVariant = 'fade' | 'doors' | 'blinds' | 'iris' | 'shutter' | 'clip' | 'pixels'
|
|
15
|
+
|
|
16
|
+
export interface CurtainProps {
|
|
17
|
+
/** true = cover the screen; false = uncover (reveal content). */
|
|
18
|
+
show: boolean
|
|
19
|
+
/** Which wipe style. @default 'fade' */
|
|
20
|
+
variant?: CurtainVariant
|
|
21
|
+
/** Overlay color (CSS color / var). @default 'hsl(var(--background))' */
|
|
22
|
+
color?: string
|
|
23
|
+
/** Seconds for one direction. @default 0.6 */
|
|
24
|
+
duration?: number
|
|
25
|
+
/** Called once the screen is fully covered (show went true→covered). */
|
|
26
|
+
onCovered?: () => void
|
|
27
|
+
/** Called once fully uncovered (show went false→revealed). */
|
|
28
|
+
onRevealed?: () => void
|
|
29
|
+
class?: string
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
interface CurtainControls {
|
|
33
|
+
finished: Promise<unknown>
|
|
34
|
+
stop: () => void
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
type CurtainPhase = 'covered' | 'uncovered' | 'covering' | 'uncovering'
|
|
38
|
+
|
|
39
|
+
const STRIP_COUNT = 8
|
|
40
|
+
const STRIP_INDICES = Array.from({ length: STRIP_COUNT }, (_, i) => i)
|
|
41
|
+
|
|
42
|
+
// `pixels` variant: a grid of tiles that pop in/out in a diagonal cascade.
|
|
43
|
+
const PIXEL_COLS = 10
|
|
44
|
+
const PIXEL_ROWS = 6
|
|
45
|
+
const PIXEL_COUNT = PIXEL_COLS * PIXEL_ROWS
|
|
46
|
+
const PIXEL_INDICES = Array.from({ length: PIXEL_COUNT }, (_, i) => i)
|
|
47
|
+
|
|
48
|
+
function combine(controls: CurtainControls[]): CurtainControls {
|
|
49
|
+
return {
|
|
50
|
+
finished: Promise.all(controls.map((c) => c.finished)),
|
|
51
|
+
stop: () => {
|
|
52
|
+
for (const c of controls) c.stop()
|
|
53
|
+
},
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* A controlled full-screen curtain for page/route transitions. Set `show` to
|
|
59
|
+
* `true` to cover the viewport, `false` to uncover it, and swap your route
|
|
60
|
+
* content while covered. `variant` picks the wipe style — fade, sliding
|
|
61
|
+
* doors, blinds, shutter (vertical blinds), iris/circle wipe, a left-to-right
|
|
62
|
+
* clip wipe, or a pixel-grid cascade —
|
|
63
|
+
* `onCovered` / `onRevealed` fire once each transition finishes so you can
|
|
64
|
+
* swap content at exactly the right moment. Skips the animation (and still
|
|
65
|
+
* fires the callback on the next microtask) under reduced motion.
|
|
66
|
+
*
|
|
67
|
+
* @example
|
|
68
|
+
* ```tsx
|
|
69
|
+
* const [covered, setCovered] = createSignal(false)
|
|
70
|
+
* const goTo = (path: string) => setCovered(true)
|
|
71
|
+
* return (
|
|
72
|
+
* <>
|
|
73
|
+
* <Curtain
|
|
74
|
+
* show={covered()}
|
|
75
|
+
* variant="iris"
|
|
76
|
+
* onCovered={() => { loadRoute(); setCovered(false) }}
|
|
77
|
+
* />
|
|
78
|
+
* <Router />
|
|
79
|
+
* </>
|
|
80
|
+
* )
|
|
81
|
+
* ```
|
|
82
|
+
*/
|
|
83
|
+
export function Curtain(props: CurtainProps): JSX.Element {
|
|
84
|
+
// eslint-disable-next-line solid/reactivity -- initial seed only; reactivity handled in the createEffect below
|
|
85
|
+
const [phase, setPhase] = createSignal<CurtainPhase>(props.show ? 'covered' : 'uncovered')
|
|
86
|
+
|
|
87
|
+
let controls: CurtainControls | undefined
|
|
88
|
+
let panelA: HTMLDivElement | undefined
|
|
89
|
+
let panelB: HTMLDivElement | undefined
|
|
90
|
+
const strips: (HTMLDivElement | undefined)[] = []
|
|
91
|
+
|
|
92
|
+
const color = () => props.color ?? 'hsl(var(--background))'
|
|
93
|
+
|
|
94
|
+
function applyFinalState(variant: CurtainVariant, show: boolean): void {
|
|
95
|
+
switch (variant) {
|
|
96
|
+
case 'fade':
|
|
97
|
+
if (panelA) panelA.style.opacity = show ? '1' : '0'
|
|
98
|
+
break
|
|
99
|
+
case 'doors':
|
|
100
|
+
if (panelA) panelA.style.transform = show ? 'translateX(0%)' : 'translateX(-100%)'
|
|
101
|
+
if (panelB) panelB.style.transform = show ? 'translateX(0%)' : 'translateX(100%)'
|
|
102
|
+
break
|
|
103
|
+
case 'blinds':
|
|
104
|
+
for (const el of strips.slice(0, STRIP_COUNT))
|
|
105
|
+
if (el) el.style.transform = show ? 'scaleY(1)' : 'scaleY(0)'
|
|
106
|
+
break
|
|
107
|
+
case 'shutter':
|
|
108
|
+
for (const el of strips.slice(0, STRIP_COUNT))
|
|
109
|
+
if (el) el.style.transform = show ? 'scaleX(1)' : 'scaleX(0)'
|
|
110
|
+
break
|
|
111
|
+
case 'iris':
|
|
112
|
+
if (panelA) panelA.style.clipPath = show ? 'circle(150% at 50% 50%)' : 'circle(0% at 50% 50%)'
|
|
113
|
+
break
|
|
114
|
+
case 'clip':
|
|
115
|
+
if (panelA) panelA.style.clipPath = show ? 'inset(0 0 0 0)' : 'inset(0 100% 0 0)'
|
|
116
|
+
break
|
|
117
|
+
case 'pixels':
|
|
118
|
+
for (const el of strips.slice(0, PIXEL_COUNT)) if (el) el.style.opacity = show ? '1' : '0'
|
|
119
|
+
break
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function play(variant: CurtainVariant, show: boolean, duration: number): CurtainControls | undefined {
|
|
124
|
+
const ease = 'easeInOut' as const
|
|
125
|
+
|
|
126
|
+
switch (variant) {
|
|
127
|
+
case 'fade': {
|
|
128
|
+
if (!panelA) return undefined
|
|
129
|
+
return animate(panelA, { opacity: show ? [0, 1] : [1, 0] }, { duration, ease })
|
|
130
|
+
}
|
|
131
|
+
case 'doors': {
|
|
132
|
+
if (!panelA || !panelB) return undefined
|
|
133
|
+
const left = animate(panelA, { x: show ? ['-100%', '0%'] : ['0%', '-100%'] }, { duration, ease })
|
|
134
|
+
const right = animate(panelB, { x: show ? ['100%', '0%'] : ['0%', '100%'] }, { duration, ease })
|
|
135
|
+
return combine([left, right])
|
|
136
|
+
}
|
|
137
|
+
case 'blinds': {
|
|
138
|
+
const els = strips.slice(0, STRIP_COUNT).filter((el): el is HTMLDivElement => el !== undefined)
|
|
139
|
+
if (els.length === 0) return undefined
|
|
140
|
+
return animate(els, { scaleY: show ? [0, 1] : [1, 0] }, { duration, delay: stagger(0.05), ease })
|
|
141
|
+
}
|
|
142
|
+
case 'shutter': {
|
|
143
|
+
const els = strips.slice(0, STRIP_COUNT).filter((el): el is HTMLDivElement => el !== undefined)
|
|
144
|
+
if (els.length === 0) return undefined
|
|
145
|
+
return animate(els, { scaleX: show ? [0, 1] : [1, 0] }, { duration, delay: stagger(0.05), ease })
|
|
146
|
+
}
|
|
147
|
+
case 'iris': {
|
|
148
|
+
if (!panelA) return undefined
|
|
149
|
+
return animate(
|
|
150
|
+
panelA,
|
|
151
|
+
{
|
|
152
|
+
clipPath: show
|
|
153
|
+
? ['circle(0% at 50% 50%)', 'circle(150% at 50% 50%)']
|
|
154
|
+
: ['circle(150% at 50% 50%)', 'circle(0% at 50% 50%)'],
|
|
155
|
+
},
|
|
156
|
+
{ duration, ease },
|
|
157
|
+
)
|
|
158
|
+
}
|
|
159
|
+
case 'clip': {
|
|
160
|
+
if (!panelA) return undefined
|
|
161
|
+
return animate(
|
|
162
|
+
panelA,
|
|
163
|
+
{
|
|
164
|
+
clipPath: show
|
|
165
|
+
? ['inset(0 100% 0 0)', 'inset(0 0 0 0)']
|
|
166
|
+
: ['inset(0 0 0 0)', 'inset(0 100% 0 0)'],
|
|
167
|
+
},
|
|
168
|
+
{ duration, ease },
|
|
169
|
+
)
|
|
170
|
+
}
|
|
171
|
+
case 'pixels': {
|
|
172
|
+
const els = strips.slice(0, PIXEL_COUNT).filter((el): el is HTMLDivElement => el !== undefined)
|
|
173
|
+
if (els.length === 0) return undefined
|
|
174
|
+
return animate(
|
|
175
|
+
els,
|
|
176
|
+
{ opacity: show ? [0, 1] : [1, 0] },
|
|
177
|
+
{ duration: duration * 0.5, delay: stagger(0.01, { from: 'first' }), ease },
|
|
178
|
+
)
|
|
179
|
+
}
|
|
180
|
+
default:
|
|
181
|
+
return undefined
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// Initial paint: the panels' inline styles already sit in the "uncovered"
|
|
186
|
+
// pose, so we only need to snap them to "covered" when show starts true. No
|
|
187
|
+
// animation on mount — that would flash the curtain in from its covered pose.
|
|
188
|
+
onMount(() => {
|
|
189
|
+
if (props.show) applyFinalState(props.variant ?? 'fade', true)
|
|
190
|
+
})
|
|
191
|
+
|
|
192
|
+
// Animate ONLY when `show` actually toggles (defer skips the mount run; variant
|
|
193
|
+
// and duration are read untracked so switching variant while idle never plays).
|
|
194
|
+
createEffect(
|
|
195
|
+
on(
|
|
196
|
+
() => props.show,
|
|
197
|
+
(show) => {
|
|
198
|
+
const variant = props.variant ?? 'fade'
|
|
199
|
+
const duration = props.duration ?? 0.6
|
|
200
|
+
|
|
201
|
+
controls?.stop()
|
|
202
|
+
controls = undefined
|
|
203
|
+
setPhase(show ? 'covering' : 'uncovering')
|
|
204
|
+
|
|
205
|
+
const settle = () => {
|
|
206
|
+
setPhase(show ? 'covered' : 'uncovered')
|
|
207
|
+
if (show) props.onCovered?.()
|
|
208
|
+
else props.onRevealed?.()
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
if (motionReduced()) {
|
|
212
|
+
applyFinalState(variant, show)
|
|
213
|
+
queueMicrotask(settle)
|
|
214
|
+
return
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const played = play(variant, show, duration)
|
|
218
|
+
if (!played) {
|
|
219
|
+
queueMicrotask(settle)
|
|
220
|
+
return
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
controls = played
|
|
224
|
+
played.finished.then(settle).catch(() => {})
|
|
225
|
+
},
|
|
226
|
+
{ defer: true },
|
|
227
|
+
),
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
onCleanup(() => controls?.stop())
|
|
231
|
+
|
|
232
|
+
return (
|
|
233
|
+
<div
|
|
234
|
+
class={cn('pointer-events-none fixed inset-0 z-[9999]', props.class)}
|
|
235
|
+
aria-hidden="true"
|
|
236
|
+
data-curtain-variant={props.variant ?? 'fade'}
|
|
237
|
+
data-curtain-state={phase()}
|
|
238
|
+
>
|
|
239
|
+
{(props.variant ?? 'fade') === 'fade' && (
|
|
240
|
+
<div
|
|
241
|
+
class="absolute inset-0"
|
|
242
|
+
style={{ position: 'absolute', opacity: 0, background: color() }}
|
|
243
|
+
ref={(el) => {
|
|
244
|
+
panelA = el
|
|
245
|
+
}}
|
|
246
|
+
/>
|
|
247
|
+
)}
|
|
248
|
+
|
|
249
|
+
{(props.variant ?? 'fade') === 'doors' && (
|
|
250
|
+
<>
|
|
251
|
+
<div
|
|
252
|
+
class="absolute inset-y-0 left-0 w-1/2"
|
|
253
|
+
style={{ position: 'absolute', transform: 'translateX(-100%)', background: color() }}
|
|
254
|
+
ref={(el) => {
|
|
255
|
+
panelA = el
|
|
256
|
+
}}
|
|
257
|
+
/>
|
|
258
|
+
<div
|
|
259
|
+
class="absolute inset-y-0 right-0 w-1/2"
|
|
260
|
+
style={{ position: 'absolute', transform: 'translateX(100%)', background: color() }}
|
|
261
|
+
ref={(el) => {
|
|
262
|
+
panelB = el
|
|
263
|
+
}}
|
|
264
|
+
/>
|
|
265
|
+
</>
|
|
266
|
+
)}
|
|
267
|
+
|
|
268
|
+
{(props.variant ?? 'fade') === 'blinds' && (
|
|
269
|
+
<Index each={STRIP_INDICES}>
|
|
270
|
+
{(_, index) => (
|
|
271
|
+
<div
|
|
272
|
+
class="absolute left-0 h-[12.5%] w-full origin-top"
|
|
273
|
+
style={{
|
|
274
|
+
position: 'absolute',
|
|
275
|
+
top: `${index * 12.5}%`,
|
|
276
|
+
transform: 'scaleY(0)',
|
|
277
|
+
background: color(),
|
|
278
|
+
}}
|
|
279
|
+
ref={(el) => {
|
|
280
|
+
strips[index] = el
|
|
281
|
+
}}
|
|
282
|
+
/>
|
|
283
|
+
)}
|
|
284
|
+
</Index>
|
|
285
|
+
)}
|
|
286
|
+
|
|
287
|
+
{(props.variant ?? 'fade') === 'shutter' && (
|
|
288
|
+
<For each={STRIP_INDICES}>
|
|
289
|
+
{(_, index) => (
|
|
290
|
+
<div
|
|
291
|
+
class="absolute top-0 h-full w-[12.5%] origin-left"
|
|
292
|
+
style={{
|
|
293
|
+
position: 'absolute',
|
|
294
|
+
left: `${index() * 12.5}%`,
|
|
295
|
+
transform: 'scaleX(0)',
|
|
296
|
+
background: color(),
|
|
297
|
+
}}
|
|
298
|
+
ref={(el) => {
|
|
299
|
+
strips[index()] = el
|
|
300
|
+
}}
|
|
301
|
+
/>
|
|
302
|
+
)}
|
|
303
|
+
</For>
|
|
304
|
+
)}
|
|
305
|
+
|
|
306
|
+
{(props.variant ?? 'fade') === 'iris' && (
|
|
307
|
+
<div
|
|
308
|
+
class="absolute inset-0"
|
|
309
|
+
style={{ position: 'absolute', 'clip-path': 'circle(0% at 50% 50%)', background: color() }}
|
|
310
|
+
ref={(el) => {
|
|
311
|
+
panelA = el
|
|
312
|
+
}}
|
|
313
|
+
/>
|
|
314
|
+
)}
|
|
315
|
+
|
|
316
|
+
{(props.variant ?? 'fade') === 'clip' && (
|
|
317
|
+
<div
|
|
318
|
+
class="absolute inset-0"
|
|
319
|
+
style={{ position: 'absolute', 'clip-path': 'inset(0 100% 0 0)', background: color() }}
|
|
320
|
+
ref={(el) => {
|
|
321
|
+
panelA = el
|
|
322
|
+
}}
|
|
323
|
+
/>
|
|
324
|
+
)}
|
|
325
|
+
|
|
326
|
+
{(props.variant ?? 'fade') === 'pixels' && (
|
|
327
|
+
<div
|
|
328
|
+
class="absolute inset-0 grid"
|
|
329
|
+
style={{
|
|
330
|
+
'grid-template-columns': `repeat(${PIXEL_COLS}, 1fr)`,
|
|
331
|
+
'grid-template-rows': `repeat(${PIXEL_ROWS}, 1fr)`,
|
|
332
|
+
}}
|
|
333
|
+
>
|
|
334
|
+
<Index each={PIXEL_INDICES}>
|
|
335
|
+
{(_, index) => (
|
|
336
|
+
<div
|
|
337
|
+
style={{ opacity: 0, background: color() }}
|
|
338
|
+
ref={(el) => {
|
|
339
|
+
strips[index] = el
|
|
340
|
+
}}
|
|
341
|
+
/>
|
|
342
|
+
)}
|
|
343
|
+
</Index>
|
|
344
|
+
</div>
|
|
345
|
+
)}
|
|
346
|
+
</div>
|
|
347
|
+
)
|
|
348
|
+
}
|