@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,49 @@
|
|
|
1
|
+
// Shared, theme-agnostic pointer effects used by every backdrop (SpaceBackground
|
|
2
|
+
// and ThemedScenery) so the whole UI feels alive on every theme, not just space:
|
|
3
|
+
// • cursor glow — the `#cursorGlow` element inside `root` follows the pointer
|
|
4
|
+
// • magnetic buttons — `.magnetic` elements lean toward the cursor
|
|
5
|
+
// • card edge-glow — `.glow-edge` cards track the pointer via `--mx`/`--my`
|
|
6
|
+
// Returns a cleanup that unbinds every listener. Call only when motion is allowed.
|
|
7
|
+
|
|
8
|
+
export function bindPointerFx(root: HTMLElement): () => void {
|
|
9
|
+
const glow = root.querySelector<HTMLElement>('#cursorGlow')
|
|
10
|
+
|
|
11
|
+
const onMove = (e: PointerEvent): void => {
|
|
12
|
+
if (glow) {
|
|
13
|
+
glow.style.opacity = '1'
|
|
14
|
+
glow.style.left = `${e.clientX}px`
|
|
15
|
+
glow.style.top = `${e.clientY}px`
|
|
16
|
+
}
|
|
17
|
+
const el = e.target as Element | null
|
|
18
|
+
const magnetic = el?.closest?.('.magnetic') as HTMLElement | null
|
|
19
|
+
if (magnetic) {
|
|
20
|
+
const r = magnetic.getBoundingClientRect()
|
|
21
|
+
const mx = (e.clientX - (r.left + r.width / 2)) / r.width
|
|
22
|
+
const my = (e.clientY - (r.top + r.height / 2)) / r.height
|
|
23
|
+
magnetic.style.transform = `translate(${(mx * 7).toFixed(1)}px, ${(my * 7).toFixed(1)}px)`
|
|
24
|
+
}
|
|
25
|
+
const card = el?.closest?.('.glow-edge') as HTMLElement | null
|
|
26
|
+
if (card) {
|
|
27
|
+
const r = card.getBoundingClientRect()
|
|
28
|
+
card.style.setProperty('--mx', `${e.clientX - r.left}px`)
|
|
29
|
+
card.style.setProperty('--my', `${e.clientY - r.top}px`)
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
const onLeave = (): void => {
|
|
33
|
+
if (glow) glow.style.opacity = '0'
|
|
34
|
+
}
|
|
35
|
+
const onOut = (e: PointerEvent): void => {
|
|
36
|
+
const magnetic = (e.target as Element | null)?.closest?.('.magnetic') as HTMLElement | null
|
|
37
|
+
if (magnetic) magnetic.style.transform = ''
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
document.addEventListener('pointermove', onMove as EventListener)
|
|
41
|
+
window.addEventListener('pointerleave', onLeave)
|
|
42
|
+
document.addEventListener('pointerout', onOut as EventListener)
|
|
43
|
+
|
|
44
|
+
return () => {
|
|
45
|
+
document.removeEventListener('pointermove', onMove as EventListener)
|
|
46
|
+
window.removeEventListener('pointerleave', onLeave)
|
|
47
|
+
document.removeEventListener('pointerout', onOut as EventListener)
|
|
48
|
+
}
|
|
49
|
+
}
|
package/src/lib/cn.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { type ClassValue, clsx } from 'clsx'
|
|
2
|
+
import { twMerge } from 'tailwind-merge'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Merge Tailwind class lists, resolving conflicting utilities left-to-right
|
|
6
|
+
* (via `tailwind-merge`) after flattening conditionals/arrays/objects (via `clsx`).
|
|
7
|
+
* Use this instead of template-string concatenation whenever a component
|
|
8
|
+
* accepts a `class` override that should win over its own defaults.
|
|
9
|
+
*
|
|
10
|
+
* @example
|
|
11
|
+
* ```ts
|
|
12
|
+
* cn('px-2 py-1 text-sm', isActive && 'bg-primary text-primary-foreground', props.class)
|
|
13
|
+
* ```
|
|
14
|
+
*/
|
|
15
|
+
export function cn(...inputs: ClassValue[]): string {
|
|
16
|
+
return twMerge(clsx(inputs))
|
|
17
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
// Single "visual effects" switch (merges the old motion + axe-preview toggles).
|
|
2
|
+
// ON by default = the full space-glass experience (translucent glass, starfield,
|
|
3
|
+
// animations). Turning it OFF adds `html.calm`, which makes surfaces opaque,
|
|
4
|
+
// hides the starfield and disables motion — a calm / high-contrast / low-power
|
|
5
|
+
// mode. `?calm=1` forces it off (used by contrast checks).
|
|
6
|
+
import { createSignal } from 'solid-js'
|
|
7
|
+
|
|
8
|
+
const STORAGE_KEY = 'a4ui-effects'
|
|
9
|
+
|
|
10
|
+
function lsSafe(): Storage | null {
|
|
11
|
+
try {
|
|
12
|
+
return window.localStorage
|
|
13
|
+
} catch {
|
|
14
|
+
return null
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function initial(): boolean {
|
|
19
|
+
try {
|
|
20
|
+
if (new URLSearchParams(window.location.search).get('calm') === '1') return false
|
|
21
|
+
} catch {
|
|
22
|
+
/* ignore */
|
|
23
|
+
}
|
|
24
|
+
const raw = lsSafe()?.getItem(STORAGE_KEY)
|
|
25
|
+
return raw === null ? true : raw === '1' // default ON
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function apply(on: boolean): void {
|
|
29
|
+
if (typeof document === 'undefined') return // SSR-safe: no-op on the server
|
|
30
|
+
document.documentElement.classList.toggle('calm', !on)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const start = initial()
|
|
34
|
+
apply(start)
|
|
35
|
+
const [effectsOn, setSignal] = createSignal(start)
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Reactive accessor for the "visual effects" switch: `true` = full
|
|
39
|
+
* space-glass experience (glass, starfield, animation), `false` = calm mode.
|
|
40
|
+
* Backing signal shared module-wide, so every consumer (e.g.
|
|
41
|
+
* {@link EffectsToggle}) reads the same source of truth.
|
|
42
|
+
*
|
|
43
|
+
* @example
|
|
44
|
+
* ```ts
|
|
45
|
+
* const effectsOn = useEffects()
|
|
46
|
+
* console.log(effectsOn()) // true | false
|
|
47
|
+
* ```
|
|
48
|
+
*/
|
|
49
|
+
export function useEffects() {
|
|
50
|
+
return effectsOn
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** True when visual effects are OFF (calm mode). Read by motion + charts. */
|
|
54
|
+
export function isCalm(): boolean {
|
|
55
|
+
return !effectsOn()
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Persist and apply the visual-effects switch: toggles `html.calm` (opaque
|
|
60
|
+
* surfaces, no starfield, no motion when off) and updates {@link useEffects}.
|
|
61
|
+
*
|
|
62
|
+
* @example
|
|
63
|
+
* ```ts
|
|
64
|
+
* setEffects(false) // enter calm mode
|
|
65
|
+
* ```
|
|
66
|
+
*/
|
|
67
|
+
export function setEffects(on: boolean): void {
|
|
68
|
+
lsSafe()?.setItem(STORAGE_KEY, on ? '1' : '0')
|
|
69
|
+
apply(on)
|
|
70
|
+
setSignal(on)
|
|
71
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
// The classic "add to basket" fly animation: a ghost of the product image
|
|
2
|
+
// arcs from a source element (button/card) to a target element (the cart
|
|
3
|
+
// icon), then the target bumps to acknowledge the drop. Pure JS/DOM (a
|
|
4
|
+
// throwaway ghost node driven by Motion's `animate`), so it works from any
|
|
5
|
+
// click handler without touching component state. Reduced motion skips the
|
|
6
|
+
// flight entirely but still fires `onArrive` so cart counts stay correct.
|
|
7
|
+
import { animate, motionReduced } from './motion'
|
|
8
|
+
|
|
9
|
+
export interface FlyToCartOptions {
|
|
10
|
+
/** Image URL to fly; if omitted, the source element is cloned. */
|
|
11
|
+
image?: string
|
|
12
|
+
/** Flight duration in seconds. @default 0.6 */
|
|
13
|
+
duration?: number
|
|
14
|
+
/** Called when the flight finishes (e.g. to increment the cart count). */
|
|
15
|
+
onArrive?: () => void
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Animate a small ghost from `source` to `target` (e.g. product → cart icon),
|
|
20
|
+
* then a bump on the target. No-op under reduced motion (still calls onArrive).
|
|
21
|
+
*
|
|
22
|
+
* @example
|
|
23
|
+
* ```ts
|
|
24
|
+
* function onAddToCart(e: MouseEvent) {
|
|
25
|
+
* const button = e.currentTarget as HTMLElement
|
|
26
|
+
* const cartIcon = document.getElementById('cart-icon')!
|
|
27
|
+
* flyToCart(button, cartIcon, {
|
|
28
|
+
* image: product.thumbnailUrl,
|
|
29
|
+
* onArrive: () => setCartCount((n) => n + 1),
|
|
30
|
+
* })
|
|
31
|
+
* }
|
|
32
|
+
* ```
|
|
33
|
+
*/
|
|
34
|
+
export function flyToCart(source: Element, target: Element, opts?: FlyToCartOptions): void {
|
|
35
|
+
if (typeof document === 'undefined') {
|
|
36
|
+
opts?.onArrive?.()
|
|
37
|
+
return
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
if (motionReduced()) {
|
|
41
|
+
opts?.onArrive?.()
|
|
42
|
+
return
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
try {
|
|
46
|
+
const sourceRect = source.getBoundingClientRect()
|
|
47
|
+
const targetRect = target.getBoundingClientRect()
|
|
48
|
+
|
|
49
|
+
const size = 48
|
|
50
|
+
const sourceCenterX = sourceRect.left + sourceRect.width / 2
|
|
51
|
+
const sourceCenterY = sourceRect.top + sourceRect.height / 2
|
|
52
|
+
const targetCenterX = targetRect.left + targetRect.width / 2
|
|
53
|
+
const targetCenterY = targetRect.top + targetRect.height / 2
|
|
54
|
+
|
|
55
|
+
const dx = targetCenterX - sourceCenterX
|
|
56
|
+
const dy = targetCenterY - sourceCenterY
|
|
57
|
+
|
|
58
|
+
const ghost: HTMLElement = opts?.image ? document.createElement('img') : document.createElement('div')
|
|
59
|
+
if (opts?.image) (ghost as HTMLImageElement).src = opts.image
|
|
60
|
+
|
|
61
|
+
ghost.style.position = 'fixed'
|
|
62
|
+
ghost.style.left = `${sourceCenterX - size / 2}px`
|
|
63
|
+
ghost.style.top = `${sourceCenterY - size / 2}px`
|
|
64
|
+
ghost.style.width = `${size}px`
|
|
65
|
+
ghost.style.height = `${size}px`
|
|
66
|
+
ghost.style.borderRadius = '9999px'
|
|
67
|
+
ghost.style.objectFit = 'cover'
|
|
68
|
+
ghost.style.pointerEvents = 'none'
|
|
69
|
+
ghost.style.zIndex = '9999'
|
|
70
|
+
if (!opts?.image) ghost.style.background = 'var(--a4-primary, #6366f1)'
|
|
71
|
+
|
|
72
|
+
document.body.appendChild(ghost)
|
|
73
|
+
|
|
74
|
+
const duration = opts?.duration ?? 0.6
|
|
75
|
+
|
|
76
|
+
const controls = animate(
|
|
77
|
+
ghost,
|
|
78
|
+
{ x: [0, dx], y: [0, dy], scale: [1, 0.3], opacity: [1, 0.5] },
|
|
79
|
+
{ duration, ease: 'easeInOut' },
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
controls.finished
|
|
83
|
+
.catch(() => {
|
|
84
|
+
/* stopped early — still clean up and settle below */
|
|
85
|
+
})
|
|
86
|
+
.then(() => {
|
|
87
|
+
ghost.remove()
|
|
88
|
+
opts?.onArrive?.()
|
|
89
|
+
try {
|
|
90
|
+
animate(target, { scale: [1, 1.25, 1] }, { duration: 0.3, ease: 'easeOut' })
|
|
91
|
+
} catch {
|
|
92
|
+
/* target no longer animatable — ignore */
|
|
93
|
+
}
|
|
94
|
+
})
|
|
95
|
+
.catch(() => {
|
|
96
|
+
/* onArrive threw — nothing more we can do here */
|
|
97
|
+
})
|
|
98
|
+
} catch {
|
|
99
|
+
// Rect reads / DOM writes can fail (detached nodes, hostile environments) —
|
|
100
|
+
// bail gracefully rather than throwing out of an event handler.
|
|
101
|
+
opts?.onArrive?.()
|
|
102
|
+
}
|
|
103
|
+
}
|
package/src/lib/media.ts
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
// Reactive CSS media-query matcher. Used to render EITHER the desktop table or
|
|
2
|
+
// the mobile cards (not both) so a virtualized long list keeps only one
|
|
3
|
+
// structure in the DOM.
|
|
4
|
+
import { createSignal, onCleanup } from 'solid-js'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Reactive `matchMedia` matcher: returns a signal accessor that updates when
|
|
8
|
+
* the query's match state changes, cleaning up its listener on unmount.
|
|
9
|
+
* Handy for rendering either a desktop or mobile layout instead of hiding one
|
|
10
|
+
* with CSS.
|
|
11
|
+
*
|
|
12
|
+
* @example
|
|
13
|
+
* ```tsx
|
|
14
|
+
* const isDesktop = useMediaQuery('(min-width: 768px)')
|
|
15
|
+
* return isDesktop() ? <DesktopTable /> : <MobileCards />
|
|
16
|
+
* ```
|
|
17
|
+
*/
|
|
18
|
+
export function useMediaQuery(query: string): () => boolean {
|
|
19
|
+
const mql = typeof window !== 'undefined' ? window.matchMedia(query) : null
|
|
20
|
+
const [matches, setMatches] = createSignal(mql ? mql.matches : false)
|
|
21
|
+
if (mql) {
|
|
22
|
+
const on = (e: MediaQueryListEvent) => setMatches(e.matches)
|
|
23
|
+
mql.addEventListener('change', on)
|
|
24
|
+
onCleanup(() => mql.removeEventListener('change', on))
|
|
25
|
+
}
|
|
26
|
+
return matches
|
|
27
|
+
}
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
// Shared motion helpers. CSS-driven transitions (Tailwind classes, Kobalte's
|
|
2
|
+
// data-expanded/data-closed keyframes) get their reduced-motion guard for free
|
|
3
|
+
// from the global `@media (prefers-reduced-motion: reduce)` block in styles.css —
|
|
4
|
+
// this module is for the JS-driven paths (Motion's `animate`, the KPI count-up)
|
|
5
|
+
// that can't rely on a CSS media query alone. JS animation uses **Motion**
|
|
6
|
+
// (motion.dev, the `motion` package) — `animate`/`scroll`/`inView`/`stagger` are
|
|
7
|
+
// re-exported below so components (and consumers) share one motion engine.
|
|
8
|
+
import { animate, inView, scroll, stagger, spring } from 'motion'
|
|
9
|
+
import { createEffect, createSignal, onCleanup, untrack, type Accessor } from 'solid-js'
|
|
10
|
+
|
|
11
|
+
import { isCalm } from './effects'
|
|
12
|
+
|
|
13
|
+
// Re-export Motion's imperative API so the whole library shares one engine and
|
|
14
|
+
// consumers can reach it without a second dependency.
|
|
15
|
+
export { animate, inView, scroll, stagger, spring }
|
|
16
|
+
|
|
17
|
+
const REDUCED_MOTION_QUERY = '(prefers-reduced-motion: reduce)'
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Snapshot of the user's reduced-motion preference at call time. Not
|
|
21
|
+
* reactive to preference changes mid-session (matches theme.ts's one-shot
|
|
22
|
+
* environment read) — reload picks up changes.
|
|
23
|
+
*
|
|
24
|
+
* @example
|
|
25
|
+
* ```ts
|
|
26
|
+
* if (prefersReducedMotion()) skipEntranceAnimation()
|
|
27
|
+
* ```
|
|
28
|
+
*/
|
|
29
|
+
export function prefersReducedMotion(): boolean {
|
|
30
|
+
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return false
|
|
31
|
+
return window.matchMedia(REDUCED_MOTION_QUERY).matches
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// --- "force full motion" override (the ⚡ MotionToggle) --------------------
|
|
35
|
+
// Persisted opt-out from the OS "reduce motion" preference; toggling it also
|
|
36
|
+
// adds/removes `.force-motion` on <html>, which the reduced-motion @media block
|
|
37
|
+
// in styles.css keys off to re-enable CSS animation.
|
|
38
|
+
const FORCE_MOTION_KEY = 'a4ui-motion-forced'
|
|
39
|
+
|
|
40
|
+
function motionStore(): Storage | null {
|
|
41
|
+
try {
|
|
42
|
+
return window.localStorage
|
|
43
|
+
} catch {
|
|
44
|
+
return null
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const [motionForced, setMotionForcedSignal] = createSignal(motionStore()?.getItem(FORCE_MOTION_KEY) === '1')
|
|
49
|
+
|
|
50
|
+
if (typeof document !== 'undefined') {
|
|
51
|
+
document.documentElement.classList.toggle('force-motion', untrack(motionForced))
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Reactive accessor for the persisted "force full motion" opt-out (the ⚡
|
|
56
|
+
* MotionToggle) — `true` re-enables animation even when the OS requests
|
|
57
|
+
* reduced motion.
|
|
58
|
+
*
|
|
59
|
+
* @example
|
|
60
|
+
* ```ts
|
|
61
|
+
* const forced = useMotionForced()
|
|
62
|
+
* console.log(forced()) // true | false
|
|
63
|
+
* ```
|
|
64
|
+
*/
|
|
65
|
+
export function useMotionForced(): Accessor<boolean> {
|
|
66
|
+
return motionForced
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Persist the "force full motion" choice, toggle `html.force-motion`
|
|
71
|
+
* (which the reduced-motion `@media` block in styles.css keys off), and
|
|
72
|
+
* update {@link useMotionForced}.
|
|
73
|
+
*
|
|
74
|
+
* @example
|
|
75
|
+
* ```ts
|
|
76
|
+
* setMotionForced(true) // opt back into full motion despite OS setting
|
|
77
|
+
* ```
|
|
78
|
+
*/
|
|
79
|
+
export function setMotionForced(on: boolean): void {
|
|
80
|
+
motionStore()?.setItem(FORCE_MOTION_KEY, on ? '1' : '0')
|
|
81
|
+
if (typeof document !== 'undefined') document.documentElement.classList.toggle('force-motion', on)
|
|
82
|
+
setMotionForcedSignal(on)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Effective reduced-motion: reduced when calm mode (visual effects OFF) is on,
|
|
87
|
+
* or when the OS asks for reduced motion and the user hasn't forced it. JS-driven
|
|
88
|
+
* motion (SpaceBackground, count-up) gates on this rather than
|
|
89
|
+
* prefersReducedMotion() directly.
|
|
90
|
+
*
|
|
91
|
+
* @example
|
|
92
|
+
* ```ts
|
|
93
|
+
* if (!motionReduced()) startParallaxLoop()
|
|
94
|
+
* ```
|
|
95
|
+
*/
|
|
96
|
+
export function motionReduced(): boolean {
|
|
97
|
+
return isCalm() || (prefersReducedMotion() && !motionForced())
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Tweens a numeric signal toward `target()` with Motion's `animate` (ease-out),
|
|
102
|
+
* for KPI count-ups. Jumps straight to the target under reduced motion.
|
|
103
|
+
*
|
|
104
|
+
* @example
|
|
105
|
+
* ```tsx
|
|
106
|
+
* const animated = createCountUp(() => props.value, 800)
|
|
107
|
+
* return <span>{Math.round(animated())}</span>
|
|
108
|
+
* ```
|
|
109
|
+
*/
|
|
110
|
+
export function createCountUp(target: Accessor<number>, durationMs = 600): Accessor<number> {
|
|
111
|
+
const [value, setValue] = createSignal(0)
|
|
112
|
+
let controls: { stop: () => void } | undefined
|
|
113
|
+
|
|
114
|
+
createEffect(() => {
|
|
115
|
+
const to = target()
|
|
116
|
+
controls?.stop()
|
|
117
|
+
|
|
118
|
+
if (motionReduced()) {
|
|
119
|
+
setValue(to)
|
|
120
|
+
return
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const from = untrack(value)
|
|
124
|
+
if (from === to) return
|
|
125
|
+
|
|
126
|
+
controls = animate(from, to, {
|
|
127
|
+
duration: durationMs / 1000,
|
|
128
|
+
ease: 'easeOut',
|
|
129
|
+
onUpdate: (latest: number) => setValue(latest),
|
|
130
|
+
})
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
onCleanup(() => controls?.stop())
|
|
134
|
+
|
|
135
|
+
return value
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Fade + slide an element in on mount with Motion (ease-out), respecting
|
|
140
|
+
* reduced motion (a no-op then). Call it from a ref/onMount; use `delay` to
|
|
141
|
+
* stagger a row.
|
|
142
|
+
*
|
|
143
|
+
* @example
|
|
144
|
+
* ```tsx
|
|
145
|
+
* let el!: HTMLDivElement
|
|
146
|
+
* onMount(() => animateIn(el, { delay: 0.1 }))
|
|
147
|
+
* return <div ref={el}>…</div>
|
|
148
|
+
* ```
|
|
149
|
+
*/
|
|
150
|
+
export function animateIn(el: Element, opts: { y?: number; duration?: number; delay?: number } = {}): void {
|
|
151
|
+
if (motionReduced()) return
|
|
152
|
+
animate(
|
|
153
|
+
el,
|
|
154
|
+
{ opacity: [0, 1], y: [opts.y ?? 8, 0] },
|
|
155
|
+
{ duration: opts.duration ?? 0.32, delay: opts.delay ?? 0, ease: 'easeOut' },
|
|
156
|
+
)
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Reveal an element the first time it scrolls into view (fade + slide up), using
|
|
161
|
+
* Motion's `inView`. Reveals once (won't re-flash on re-entry) and is a no-op
|
|
162
|
+
* under reduced motion (the element stays visible). Returns a stop function.
|
|
163
|
+
*
|
|
164
|
+
* @example
|
|
165
|
+
* ```tsx
|
|
166
|
+
* let el!: HTMLDivElement
|
|
167
|
+
* onMount(() => revealOnScroll(el, { amount: 0.3 }))
|
|
168
|
+
* return <section ref={el}>…</section>
|
|
169
|
+
* ```
|
|
170
|
+
*/
|
|
171
|
+
export function revealOnScroll(
|
|
172
|
+
el: Element,
|
|
173
|
+
opts: { y?: number; duration?: number; amount?: number } = {},
|
|
174
|
+
): () => void {
|
|
175
|
+
if (motionReduced()) return () => {}
|
|
176
|
+
;(el as HTMLElement).style.opacity = '0'
|
|
177
|
+
let done = false
|
|
178
|
+
return inView(
|
|
179
|
+
el,
|
|
180
|
+
() => {
|
|
181
|
+
if (done) return
|
|
182
|
+
done = true
|
|
183
|
+
animate(
|
|
184
|
+
el,
|
|
185
|
+
{ opacity: [0, 1], y: [opts.y ?? 16, 0] },
|
|
186
|
+
{ duration: opts.duration ?? 0.5, ease: 'easeOut' },
|
|
187
|
+
)
|
|
188
|
+
},
|
|
189
|
+
{ amount: opts.amount ?? 0.2 },
|
|
190
|
+
)
|
|
191
|
+
}
|
package/src/lib/theme.ts
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
// Light/dark theme handling. The dark "night" palette is the default (see
|
|
2
|
+
// styles.css / tokens.css); choosing light sets `data-theme="light"` on the
|
|
3
|
+
// root <html> element. The choice is persisted in localStorage so state
|
|
4
|
+
// survives reloads.
|
|
5
|
+
import { createSignal } from 'solid-js'
|
|
6
|
+
|
|
7
|
+
const STORAGE_KEY = 'a4ui-theme'
|
|
8
|
+
|
|
9
|
+
/** The two supported color schemes. `'dark'` (the "night" palette) is the default. */
|
|
10
|
+
export type Theme = 'dark' | 'light'
|
|
11
|
+
|
|
12
|
+
function isTheme(value: string | null): value is Theme {
|
|
13
|
+
return value === 'dark' || value === 'light'
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function localStorageSafe(): Storage | null {
|
|
17
|
+
try {
|
|
18
|
+
return window.localStorage
|
|
19
|
+
} catch {
|
|
20
|
+
return null
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Read the persisted theme choice from `localStorage`, defaulting to `'dark'`
|
|
26
|
+
* when nothing is stored (or storage is unavailable).
|
|
27
|
+
*
|
|
28
|
+
* @example
|
|
29
|
+
* ```ts
|
|
30
|
+
* const initial = storedTheme() // 'dark' | 'light'
|
|
31
|
+
* ```
|
|
32
|
+
*/
|
|
33
|
+
export function storedTheme(): Theme {
|
|
34
|
+
const raw = localStorageSafe()?.getItem(STORAGE_KEY) ?? null
|
|
35
|
+
return isTheme(raw) ? raw : 'dark'
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Reflect a theme onto `<html data-theme>` without touching storage or the
|
|
40
|
+
* shared signal. Dark is the default (no attribute); light sets
|
|
41
|
+
* `data-theme="light"`. Prefer {@link setTheme} unless you specifically need
|
|
42
|
+
* to apply without persisting.
|
|
43
|
+
*
|
|
44
|
+
* @example
|
|
45
|
+
* ```ts
|
|
46
|
+
* applyTheme('light')
|
|
47
|
+
* ```
|
|
48
|
+
*/
|
|
49
|
+
export function applyTheme(theme: Theme): void {
|
|
50
|
+
if (typeof document === 'undefined') return // SSR-safe: no-op on the server
|
|
51
|
+
const root = document.documentElement
|
|
52
|
+
if (theme === 'light') {
|
|
53
|
+
root.setAttribute('data-theme', 'light')
|
|
54
|
+
} else {
|
|
55
|
+
root.removeAttribute('data-theme')
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Persist a theme choice to `localStorage` and apply it to `<html>`. Does not
|
|
61
|
+
* update the shared `useTheme()` signal directly — {@link toggleTheme} does
|
|
62
|
+
* that after calling this.
|
|
63
|
+
*
|
|
64
|
+
* @example
|
|
65
|
+
* ```ts
|
|
66
|
+
* setTheme('light')
|
|
67
|
+
* ```
|
|
68
|
+
*/
|
|
69
|
+
export function setTheme(theme: Theme): void {
|
|
70
|
+
localStorageSafe()?.setItem(STORAGE_KEY, theme)
|
|
71
|
+
applyTheme(theme)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* The opposite of the given theme. Pure helper (no signal/storage side effects)
|
|
76
|
+
* used by {@link toggleTheme}.
|
|
77
|
+
*
|
|
78
|
+
* @example
|
|
79
|
+
* ```ts
|
|
80
|
+
* toggled('dark') // 'light'
|
|
81
|
+
* ```
|
|
82
|
+
*/
|
|
83
|
+
export function toggled(theme: Theme): Theme {
|
|
84
|
+
return theme === 'dark' ? 'light' : 'dark'
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Module-level signal so every consumer (ThemeToggle, anything reading the
|
|
88
|
+
// current theme) shares one source of truth, applied once at import time.
|
|
89
|
+
const initial = storedTheme()
|
|
90
|
+
applyTheme(initial)
|
|
91
|
+
const [theme, setThemeSignal] = createSignal<Theme>(initial)
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Reactive accessor for the current theme, shared module-wide so every
|
|
95
|
+
* consumer (e.g. {@link ThemeToggle}, `SpaceBackground`) reads the same
|
|
96
|
+
* source of truth.
|
|
97
|
+
*
|
|
98
|
+
* @example
|
|
99
|
+
* ```ts
|
|
100
|
+
* const theme = useTheme()
|
|
101
|
+
* console.log(theme()) // 'dark' | 'light'
|
|
102
|
+
* ```
|
|
103
|
+
*/
|
|
104
|
+
export function useTheme() {
|
|
105
|
+
return theme
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Flip the current theme (dark↔light), persisting and applying the new value. */
|
|
109
|
+
export function toggleTheme(): void {
|
|
110
|
+
const next = toggled(theme())
|
|
111
|
+
setTheme(next)
|
|
112
|
+
setThemeSignal(next)
|
|
113
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
// Helpers for @tanstack/solid-virtual lists.
|
|
2
|
+
import { onMount, type Accessor } from 'solid-js'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Works around a `@tanstack/solid-virtual` bug where the virtualizer measures
|
|
6
|
+
* a 0×0 viewport: it attaches its `ResizeObserver` during the render pass
|
|
7
|
+
* (when `getScrollElement` first returns the scroll container), before the
|
|
8
|
+
* container has been laid out, so it caches a 0×0 size and never renders any
|
|
9
|
+
* rows. Once layout settles, this detaches+reattaches the scroll element so
|
|
10
|
+
* the observer re-measures the real height. Call it right after wiring up
|
|
11
|
+
* the virtualizer, passing the scroll-element signal's getter and setter.
|
|
12
|
+
*
|
|
13
|
+
* @example
|
|
14
|
+
* ```ts
|
|
15
|
+
* const [scrollEl, setScrollEl] = createSignal<HTMLElement>()
|
|
16
|
+
* const virtualizer = createVirtualizer({ getScrollElement: scrollEl, ... })
|
|
17
|
+
* remeasureAfterLayout(scrollEl, setScrollEl)
|
|
18
|
+
* ```
|
|
19
|
+
*/
|
|
20
|
+
export function remeasureAfterLayout(
|
|
21
|
+
scrollEl: Accessor<HTMLElement | undefined>,
|
|
22
|
+
setScrollEl: (v: HTMLElement | undefined) => void,
|
|
23
|
+
): void {
|
|
24
|
+
onMount(() => {
|
|
25
|
+
requestAnimationFrame(() => {
|
|
26
|
+
const el = scrollEl()
|
|
27
|
+
if (el) {
|
|
28
|
+
setScrollEl(undefined)
|
|
29
|
+
setScrollEl(el)
|
|
30
|
+
}
|
|
31
|
+
})
|
|
32
|
+
})
|
|
33
|
+
}
|