@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,93 @@
|
|
|
1
|
+
// Reveal text word-by-word or char-by-char with a Motion (motion.dev) stagger:
|
|
2
|
+
// each unit starts translated down and transparent, then fades/slides in with
|
|
3
|
+
// `animate`'s `delay: stagger(...)`, either on mount or the first time the
|
|
4
|
+
// text scrolls into view (`inView`). Whitespace units are rendered as plain
|
|
5
|
+
// text (not wrapped/animated) so word spacing stays exactly what it would be
|
|
6
|
+
// without the effect.
|
|
7
|
+
import { createMemo, onMount, For, type JSX } from 'solid-js'
|
|
8
|
+
|
|
9
|
+
import { cn } from '../lib/cn'
|
|
10
|
+
import { animate, inView, motionReduced, stagger } from '../lib/motion'
|
|
11
|
+
|
|
12
|
+
export interface TextRevealProps {
|
|
13
|
+
text: string
|
|
14
|
+
/** Split unit. @default 'word' */
|
|
15
|
+
by?: 'word' | 'char'
|
|
16
|
+
/** Reveal when scrolled into view instead of on mount. @default false */
|
|
17
|
+
onView?: boolean
|
|
18
|
+
class?: string
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Split `text` into render units: words with their surrounding spaces kept as separate whitespace units, or individual characters. */
|
|
22
|
+
function splitUnits(text: string, by: 'word' | 'char'): string[] {
|
|
23
|
+
if (by === 'char') return text.split('')
|
|
24
|
+
return text.split(/(\s+)/).filter((unit) => unit.length > 0)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const isWhitespace = (unit: string): boolean => /^\s+$/.test(unit)
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Reveals `text`, split into words or characters, with a staggered
|
|
31
|
+
* fade + slide-up (via Motion's `animate` + `stagger`). Runs once on mount by
|
|
32
|
+
* default; pass `onView` to instead reveal the first time the component
|
|
33
|
+
* scrolls into view. Whitespace between words renders as plain text, not an
|
|
34
|
+
* animated unit. Under reduced motion, everything stays visible and no
|
|
35
|
+
* animation runs.
|
|
36
|
+
*
|
|
37
|
+
* @example
|
|
38
|
+
* ```tsx
|
|
39
|
+
* <TextReveal text="Ship it." by="char" onView />
|
|
40
|
+
* ```
|
|
41
|
+
*/
|
|
42
|
+
export function TextReveal(props: TextRevealProps): JSX.Element {
|
|
43
|
+
const units = createMemo(() => splitUnits(props.text, props.by ?? 'word'))
|
|
44
|
+
const spans: (HTMLSpanElement | undefined)[] = []
|
|
45
|
+
let root: HTMLSpanElement | undefined
|
|
46
|
+
|
|
47
|
+
const reveal = (): void => {
|
|
48
|
+
const targets = spans.filter((el): el is HTMLSpanElement => el !== undefined)
|
|
49
|
+
if (targets.length === 0) return
|
|
50
|
+
animate(
|
|
51
|
+
targets,
|
|
52
|
+
{ opacity: [0, 1], y: [12, 0] },
|
|
53
|
+
{ delay: stagger(0.03), duration: 0.4, ease: 'easeOut' },
|
|
54
|
+
)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
onMount(() => {
|
|
58
|
+
if (motionReduced()) return
|
|
59
|
+
|
|
60
|
+
for (const el of spans) {
|
|
61
|
+
if (el) el.style.opacity = '0'
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (props.onView) {
|
|
65
|
+
if (!root) return
|
|
66
|
+
inView(root, () => reveal(), { amount: 0.3 })
|
|
67
|
+
return
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
reveal()
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
return (
|
|
74
|
+
<span class={cn(props.class)} ref={root}>
|
|
75
|
+
<For each={units()}>
|
|
76
|
+
{(unit, index) =>
|
|
77
|
+
isWhitespace(unit) ? (
|
|
78
|
+
unit
|
|
79
|
+
) : (
|
|
80
|
+
<span
|
|
81
|
+
class="inline-block will-change-transform"
|
|
82
|
+
ref={(el) => {
|
|
83
|
+
spans[index()] = el
|
|
84
|
+
}}
|
|
85
|
+
>
|
|
86
|
+
{unit}
|
|
87
|
+
</span>
|
|
88
|
+
)
|
|
89
|
+
}
|
|
90
|
+
</For>
|
|
91
|
+
</span>
|
|
92
|
+
)
|
|
93
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// Controlled multi-line text field — plain <textarea>, mirrors Input.tsx.
|
|
2
|
+
import type { JSX } from 'solid-js'
|
|
3
|
+
import { splitProps } from 'solid-js'
|
|
4
|
+
|
|
5
|
+
import { cn } from '../lib/cn'
|
|
6
|
+
|
|
7
|
+
const TEXTAREA_BASE =
|
|
8
|
+
'w-full min-h-[80px] resize-y rounded-md border border-input bg-background text-foreground placeholder:text-muted-foreground px-3 py-2 text-sm outline-none transition-colors a4-field disabled:cursor-not-allowed disabled:opacity-50'
|
|
9
|
+
|
|
10
|
+
interface TextareaProps extends Omit<JSX.TextareaHTMLAttributes<HTMLTextAreaElement>, 'value' | 'onInput'> {
|
|
11
|
+
value: string
|
|
12
|
+
/** Called with the new string value on every input event (not the raw DOM event). */
|
|
13
|
+
onInput: (value: string) => void
|
|
14
|
+
class?: string
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Controlled multi-line text field — a plain `<textarea>` styled to match
|
|
19
|
+
* {@link Input}. Any other native textarea attribute (e.g. `rows`, `placeholder`,
|
|
20
|
+
* `disabled`) is passed through.
|
|
21
|
+
*
|
|
22
|
+
* @example
|
|
23
|
+
* ```tsx
|
|
24
|
+
* const [bio, setBio] = createSignal('')
|
|
25
|
+
* <Textarea value={bio()} onInput={setBio} placeholder="Tell us about yourself" />
|
|
26
|
+
* ```
|
|
27
|
+
*/
|
|
28
|
+
export function Textarea(props: TextareaProps): JSX.Element {
|
|
29
|
+
const [local, rest] = splitProps(props, ['value', 'onInput', 'class'])
|
|
30
|
+
return (
|
|
31
|
+
<textarea
|
|
32
|
+
class={cn(TEXTAREA_BASE, local.class)}
|
|
33
|
+
value={local.value}
|
|
34
|
+
onInput={(ev) => local.onInput(ev.currentTarget.value)}
|
|
35
|
+
{...rest}
|
|
36
|
+
/>
|
|
37
|
+
)
|
|
38
|
+
}
|
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
// Compact custom time picker — no Kobalte primitive covers this yet, so this is
|
|
2
|
+
// a small hand-rolled hour/minute (/ AM-PM) column picker in plain Solid + theme
|
|
3
|
+
// tokens (works in light/dark). Reuses DateField's popover machinery verbatim:
|
|
4
|
+
// a trigger button opens a portaled popover positioned `fixed` at the trigger's
|
|
5
|
+
// viewport rect, closing on outside mousedown, Escape, scroll, or resize.
|
|
6
|
+
import { Clock } from 'lucide-solid'
|
|
7
|
+
import type { JSX } from 'solid-js'
|
|
8
|
+
import { For, Show, createEffect, createMemo, createSignal, onCleanup } from 'solid-js'
|
|
9
|
+
import { Portal } from 'solid-js/web'
|
|
10
|
+
|
|
11
|
+
import { cn } from '../lib/cn'
|
|
12
|
+
|
|
13
|
+
type AmPm = 'AM' | 'PM'
|
|
14
|
+
|
|
15
|
+
const HOURS24 = Array.from({ length: 24 }, (_, i) => i)
|
|
16
|
+
const HOURS12 = [12, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11]
|
|
17
|
+
|
|
18
|
+
/** Zero-pad a non-negative integer to 2 digits. */
|
|
19
|
+
function pad(n: number): string {
|
|
20
|
+
return `${n}`.padStart(2, '0')
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Parse canonical "HH:MM" (24h) into `{h, m}`; null if malformed or out of range. */
|
|
24
|
+
function parse(value: string): { h: number; m: number } | null {
|
|
25
|
+
const mm = /^(\d{2}):(\d{2})$/.exec(value)
|
|
26
|
+
if (!mm) return null
|
|
27
|
+
const h = Number(mm[1])
|
|
28
|
+
const m = Number(mm[2])
|
|
29
|
+
if (h < 0 || h > 23 || m < 0 || m > 59) return null
|
|
30
|
+
return { h, m }
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Format an `{h, m}` pair as canonical "HH:MM" (24h, zero-padded). */
|
|
34
|
+
function format(h: number, m: number): string {
|
|
35
|
+
return `${pad(h)}:${pad(m)}`
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Convert a 24h hour to `{h12, ampm}` (12 stands for both 12AM and 12PM). */
|
|
39
|
+
function to12(h24: number): { h12: number; ampm: AmPm } {
|
|
40
|
+
const ampm: AmPm = h24 < 12 ? 'AM' : 'PM'
|
|
41
|
+
let h12 = h24 % 12
|
|
42
|
+
if (h12 === 0) h12 = 12
|
|
43
|
+
return { h12, ampm }
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Convert a 12h hour + AM/PM back to a 24h hour. */
|
|
47
|
+
function to24(h12: number, ampm: AmPm): number {
|
|
48
|
+
if (ampm === 'AM') return h12 === 12 ? 0 : h12
|
|
49
|
+
return h12 === 12 ? 12 : h12 + 12
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Human-readable trigger label, e.g. `15:05` or `3:05 PM`. */
|
|
53
|
+
function displayLabel(value: string, hour12: boolean): string | null {
|
|
54
|
+
const p = parse(value)
|
|
55
|
+
if (!p) return null
|
|
56
|
+
if (hour12) {
|
|
57
|
+
const { h12, ampm } = to12(p.h)
|
|
58
|
+
return `${h12}:${pad(p.m)} ${ampm}`
|
|
59
|
+
}
|
|
60
|
+
return format(p.h, p.m)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface TimeFieldProps {
|
|
64
|
+
/** Selected time as canonical 24h "HH:MM" (e.g. "15:05"), or "" for none. */
|
|
65
|
+
value: string
|
|
66
|
+
/** Called with the newly picked time as "HH:MM" (24h). */
|
|
67
|
+
onChange: (value: string) => void
|
|
68
|
+
/** 12-hour display with AM/PM instead of 24-hour. @default false */
|
|
69
|
+
hour12?: boolean
|
|
70
|
+
/** Minute increment for the options list. @default 5 */
|
|
71
|
+
minuteStep?: number
|
|
72
|
+
/** Placeholder shown on the trigger when value is empty. */
|
|
73
|
+
label?: string
|
|
74
|
+
disabled?: boolean
|
|
75
|
+
class?: string
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Compact hand-rolled hour/minute (/ AM-PM) column picker (no Kobalte primitive
|
|
80
|
+
* covers this yet). Trigger button opens a portaled popover; closes on outside
|
|
81
|
+
* click, Escape, scroll, or resize. Speaks plain 24h `HH:MM` strings.
|
|
82
|
+
*
|
|
83
|
+
* @example
|
|
84
|
+
* ```tsx
|
|
85
|
+
* const [time, setTime] = createSignal('15:05')
|
|
86
|
+
* <TimeField value={time()} onChange={setTime} label="Start time" hour12 />
|
|
87
|
+
* ```
|
|
88
|
+
*/
|
|
89
|
+
export function TimeField(props: TimeFieldProps): JSX.Element {
|
|
90
|
+
const [open, setOpen] = createSignal(false)
|
|
91
|
+
// Working selection shown/highlighted in the popover columns — 24h internally
|
|
92
|
+
// regardless of display mode. Seeded on open (see openPopover), not here.
|
|
93
|
+
const [workH, setWorkH] = createSignal(0)
|
|
94
|
+
const [workM, setWorkM] = createSignal(0)
|
|
95
|
+
|
|
96
|
+
let rootRef: HTMLDivElement | undefined
|
|
97
|
+
let btnRef: HTMLButtonElement | undefined
|
|
98
|
+
let popRef: HTMLDivElement | undefined
|
|
99
|
+
// The picker is rendered in a <Portal> (so it escapes the glass card's
|
|
100
|
+
// backdrop-filter stacking context, which otherwise traps it behind later
|
|
101
|
+
// content). Being at <body>, it can't use `absolute` positioning relative to
|
|
102
|
+
// the trigger — position it with `fixed` at the trigger's viewport rect.
|
|
103
|
+
const [pos, setPos] = createSignal({ top: 0, left: 0 })
|
|
104
|
+
const popWidth = () => (props.hour12 ? 224 : 176) // w-56 / w-44
|
|
105
|
+
|
|
106
|
+
const place = () => {
|
|
107
|
+
if (!btnRef) return
|
|
108
|
+
const r = btnRef.getBoundingClientRect()
|
|
109
|
+
const w = popWidth()
|
|
110
|
+
const left = Math.max(8, Math.min(r.left, window.innerWidth - w - 8))
|
|
111
|
+
setPos({ top: r.bottom + 4, left })
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const openPopover = () => {
|
|
115
|
+
if (props.disabled) return
|
|
116
|
+
const seed = parse(props.value)
|
|
117
|
+
if (seed) {
|
|
118
|
+
setWorkH(seed.h)
|
|
119
|
+
setWorkM(seed.m)
|
|
120
|
+
} else {
|
|
121
|
+
const step = props.minuteStep ?? 5
|
|
122
|
+
const now = new Date()
|
|
123
|
+
let h = now.getHours()
|
|
124
|
+
let m = Math.round(now.getMinutes() / step) * step
|
|
125
|
+
if (m >= 60) {
|
|
126
|
+
m -= 60
|
|
127
|
+
h = (h + 1) % 24
|
|
128
|
+
}
|
|
129
|
+
setWorkH(h)
|
|
130
|
+
setWorkM(m)
|
|
131
|
+
}
|
|
132
|
+
place()
|
|
133
|
+
setOpen(true)
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const close = () => setOpen(false)
|
|
137
|
+
|
|
138
|
+
const onDocPointer = (ev: MouseEvent) => {
|
|
139
|
+
const t = ev.target as Node
|
|
140
|
+
// Ignore clicks on the trigger (rootRef) AND the portaled popover (popRef).
|
|
141
|
+
if (rootRef && !rootRef.contains(t) && popRef && !popRef.contains(t)) close()
|
|
142
|
+
}
|
|
143
|
+
const onKey = (ev: KeyboardEvent) => {
|
|
144
|
+
if (ev.key === 'Escape') close()
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Wire/tear-down the global listeners strictly around the open state. Scroll/
|
|
148
|
+
// resize close the popover (its fixed position would otherwise drift).
|
|
149
|
+
const toggleListeners = (isOpen: boolean) => {
|
|
150
|
+
if (isOpen) {
|
|
151
|
+
document.addEventListener('mousedown', onDocPointer)
|
|
152
|
+
document.addEventListener('keydown', onKey)
|
|
153
|
+
window.addEventListener('scroll', close, true)
|
|
154
|
+
window.addEventListener('resize', close)
|
|
155
|
+
} else {
|
|
156
|
+
document.removeEventListener('mousedown', onDocPointer)
|
|
157
|
+
document.removeEventListener('keydown', onKey)
|
|
158
|
+
window.removeEventListener('scroll', close, true)
|
|
159
|
+
window.removeEventListener('resize', close)
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
// Reactively attach when open flips; always clean up on unmount.
|
|
163
|
+
createEffect(() => toggleListeners(open()))
|
|
164
|
+
onCleanup(() => toggleListeners(false))
|
|
165
|
+
|
|
166
|
+
const minuteOptions = createMemo(() => {
|
|
167
|
+
const step = props.minuteStep ?? 5
|
|
168
|
+
const out: number[] = []
|
|
169
|
+
for (let m = 0; m < 60; m += step) out.push(m)
|
|
170
|
+
return out
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
// Pick an hour: `value` is a 24h hour when hour12 is off, else the 12h label
|
|
174
|
+
// (1..12) which is combined with the current AM/PM to resolve the 24h hour.
|
|
175
|
+
const pickHour = (value: number) => {
|
|
176
|
+
const h24 = props.hour12 ? to24(value, to12(workH()).ampm) : value
|
|
177
|
+
setWorkH(h24)
|
|
178
|
+
props.onChange(format(h24, workM()))
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const pickMinute = (m: number) => {
|
|
182
|
+
setWorkM(m)
|
|
183
|
+
props.onChange(format(workH(), m))
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const pickAmPm = (ampm: AmPm) => {
|
|
187
|
+
const h24 = to24(to12(workH()).h12, ampm)
|
|
188
|
+
setWorkH(h24)
|
|
189
|
+
props.onChange(format(h24, workM()))
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const optionClass = (selected: boolean) =>
|
|
193
|
+
cn(
|
|
194
|
+
'rounded-md px-2 py-1 text-left text-sm transition-colors',
|
|
195
|
+
selected ? 'bg-primary text-primary-foreground' : 'text-foreground hover:bg-muted',
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
return (
|
|
199
|
+
<div ref={rootRef} class={cn('relative', props.class)}>
|
|
200
|
+
<button
|
|
201
|
+
ref={btnRef}
|
|
202
|
+
type="button"
|
|
203
|
+
disabled={props.disabled}
|
|
204
|
+
onClick={() => (open() ? close() : openPopover())}
|
|
205
|
+
class="flex w-full items-center justify-between gap-2 rounded-md border border-input bg-background px-3 py-2 text-left text-sm text-foreground outline-none transition-colors a4-field disabled:cursor-not-allowed disabled:opacity-50"
|
|
206
|
+
>
|
|
207
|
+
<span class={displayLabel(props.value, props.hour12 ?? false) ? '' : 'text-muted-foreground'}>
|
|
208
|
+
{displayLabel(props.value, props.hour12 ?? false) ?? props.label ?? 'Select time'}
|
|
209
|
+
</span>
|
|
210
|
+
<Clock class="h-4 w-4 shrink-0 text-muted-foreground" />
|
|
211
|
+
</button>
|
|
212
|
+
|
|
213
|
+
<Show when={open()}>
|
|
214
|
+
<Portal>
|
|
215
|
+
<div
|
|
216
|
+
ref={popRef}
|
|
217
|
+
style={{ position: 'fixed', top: `${pos().top}px`, left: `${pos().left}px` }}
|
|
218
|
+
class={cn(
|
|
219
|
+
'z-50 flex gap-1 divide-x divide-border rounded-lg border border-border bg-card p-2 text-card-foreground shadow-lg',
|
|
220
|
+
props.hour12 ? 'w-56' : 'w-44',
|
|
221
|
+
)}
|
|
222
|
+
>
|
|
223
|
+
<div
|
|
224
|
+
class="flex max-h-56 flex-1 flex-col gap-0.5 overflow-y-auto pr-1"
|
|
225
|
+
role="listbox"
|
|
226
|
+
aria-label="Hour"
|
|
227
|
+
>
|
|
228
|
+
<For each={props.hour12 ? HOURS12 : HOURS24}>
|
|
229
|
+
{(h) => {
|
|
230
|
+
const isSel = () => (props.hour12 ? to12(workH()).h12 === h : workH() === h)
|
|
231
|
+
return (
|
|
232
|
+
<button
|
|
233
|
+
type="button"
|
|
234
|
+
role="option"
|
|
235
|
+
aria-selected={isSel()}
|
|
236
|
+
onClick={() => pickHour(h)}
|
|
237
|
+
class={optionClass(isSel())}
|
|
238
|
+
>
|
|
239
|
+
{props.hour12 ? h : pad(h)}
|
|
240
|
+
</button>
|
|
241
|
+
)
|
|
242
|
+
}}
|
|
243
|
+
</For>
|
|
244
|
+
</div>
|
|
245
|
+
|
|
246
|
+
<div
|
|
247
|
+
class="flex max-h-56 flex-1 flex-col gap-0.5 overflow-y-auto px-1"
|
|
248
|
+
role="listbox"
|
|
249
|
+
aria-label="Minute"
|
|
250
|
+
>
|
|
251
|
+
<For each={minuteOptions()}>
|
|
252
|
+
{(m) => {
|
|
253
|
+
const isSel = () => workM() === m
|
|
254
|
+
return (
|
|
255
|
+
<button
|
|
256
|
+
type="button"
|
|
257
|
+
role="option"
|
|
258
|
+
aria-selected={isSel()}
|
|
259
|
+
onClick={() => pickMinute(m)}
|
|
260
|
+
class={optionClass(isSel())}
|
|
261
|
+
>
|
|
262
|
+
{pad(m)}
|
|
263
|
+
</button>
|
|
264
|
+
)
|
|
265
|
+
}}
|
|
266
|
+
</For>
|
|
267
|
+
</div>
|
|
268
|
+
|
|
269
|
+
<Show when={props.hour12}>
|
|
270
|
+
<div
|
|
271
|
+
class="flex max-h-56 flex-1 flex-col gap-0.5 overflow-y-auto pl-1"
|
|
272
|
+
role="listbox"
|
|
273
|
+
aria-label="AM or PM"
|
|
274
|
+
>
|
|
275
|
+
<For each={['AM', 'PM'] as const}>
|
|
276
|
+
{(ampm) => {
|
|
277
|
+
const isSel = () => to12(workH()).ampm === ampm
|
|
278
|
+
return (
|
|
279
|
+
<button
|
|
280
|
+
type="button"
|
|
281
|
+
role="option"
|
|
282
|
+
aria-selected={isSel()}
|
|
283
|
+
onClick={() => pickAmPm(ampm)}
|
|
284
|
+
class={optionClass(isSel())}
|
|
285
|
+
>
|
|
286
|
+
{ampm}
|
|
287
|
+
</button>
|
|
288
|
+
)
|
|
289
|
+
}}
|
|
290
|
+
</For>
|
|
291
|
+
</div>
|
|
292
|
+
</Show>
|
|
293
|
+
</div>
|
|
294
|
+
</Portal>
|
|
295
|
+
</Show>
|
|
296
|
+
</div>
|
|
297
|
+
)
|
|
298
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
// Vertical timeline: a continuous line with a tone-colored dot per event, plus
|
|
2
|
+
// title / optional time / optional description. Semantic <ol>/<li>, theme-agnostic
|
|
3
|
+
// tokens only (with fixed semantic accents like Badge/Stat use for success).
|
|
4
|
+
import type { JSX } from 'solid-js'
|
|
5
|
+
import { For, Show } from 'solid-js'
|
|
6
|
+
|
|
7
|
+
import { cn } from '../lib/cn'
|
|
8
|
+
|
|
9
|
+
/** Accent applied to a {@link TimelineItem}'s dot on the line. */
|
|
10
|
+
export type TimelineTone = 'default' | 'primary' | 'success' | 'danger'
|
|
11
|
+
|
|
12
|
+
const DOT_CLASSES: Record<TimelineTone, string> = {
|
|
13
|
+
default: 'bg-muted border border-border',
|
|
14
|
+
primary: 'bg-primary',
|
|
15
|
+
success: 'bg-emerald-500',
|
|
16
|
+
danger: 'bg-destructive',
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** A single event rendered by {@link Timeline}. */
|
|
20
|
+
export interface TimelineItem {
|
|
21
|
+
/** Headline for the event (rendered emphasized). */
|
|
22
|
+
title: string
|
|
23
|
+
/** Optional supporting copy shown under the title. */
|
|
24
|
+
description?: string
|
|
25
|
+
/** Optional timestamp / relative time label. */
|
|
26
|
+
time?: string
|
|
27
|
+
/** Accent for the dot on the line. Defaults to `'default'`. */
|
|
28
|
+
tone?: TimelineTone
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface TimelineProps {
|
|
32
|
+
items: TimelineItem[]
|
|
33
|
+
class?: string
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Vertical timeline of events. A continuous line runs down the left with a
|
|
38
|
+
* tone-colored dot marking each entry; to the right sit the title, an optional
|
|
39
|
+
* right-aligned time, and an optional description. The line segment stops at the
|
|
40
|
+
* last item's dot. Renders as a semantic `<ol>`/`<li>` list.
|
|
41
|
+
*
|
|
42
|
+
* @example
|
|
43
|
+
* ```tsx
|
|
44
|
+
* <Timeline
|
|
45
|
+
* items={[
|
|
46
|
+
* { title: 'Order placed', time: '09:24', tone: 'primary' },
|
|
47
|
+
* { title: 'Shipped', description: 'Left the warehouse.', time: '11:02', tone: 'success' },
|
|
48
|
+
* { title: 'Delayed', description: 'Weather hold.', time: '14:30', tone: 'danger' },
|
|
49
|
+
* ]}
|
|
50
|
+
* />
|
|
51
|
+
* ```
|
|
52
|
+
*/
|
|
53
|
+
export function Timeline(props: TimelineProps): JSX.Element {
|
|
54
|
+
return (
|
|
55
|
+
<ol class={cn('relative flex flex-col gap-6', props.class)}>
|
|
56
|
+
<For each={props.items}>
|
|
57
|
+
{(item, index) => (
|
|
58
|
+
<li class="relative flex gap-4 pl-2">
|
|
59
|
+
{/* Line segment: hidden on the last item so it doesn't extend past the dot. */}
|
|
60
|
+
<Show when={index() < props.items.length - 1}>
|
|
61
|
+
<span
|
|
62
|
+
aria-hidden="true"
|
|
63
|
+
class="absolute left-[calc(0.5rem+0.375rem)] top-3 -bottom-6 w-px -translate-x-1/2 bg-border"
|
|
64
|
+
/>
|
|
65
|
+
</Show>
|
|
66
|
+
<span
|
|
67
|
+
aria-hidden="true"
|
|
68
|
+
class={cn(
|
|
69
|
+
'relative z-[1] mt-1 h-3 w-3 shrink-0 rounded-full',
|
|
70
|
+
DOT_CLASSES[item.tone ?? 'default'],
|
|
71
|
+
)}
|
|
72
|
+
/>
|
|
73
|
+
<div class="min-w-0 flex-1">
|
|
74
|
+
<div class="flex items-baseline justify-between gap-3">
|
|
75
|
+
<p class="font-medium text-foreground">{item.title}</p>
|
|
76
|
+
<Show when={item.time}>
|
|
77
|
+
<span class="shrink-0 text-xs text-muted-foreground">{item.time}</span>
|
|
78
|
+
</Show>
|
|
79
|
+
</div>
|
|
80
|
+
<Show when={item.description}>
|
|
81
|
+
<p class="mt-1 text-sm text-muted-foreground">{item.description}</p>
|
|
82
|
+
</Show>
|
|
83
|
+
</div>
|
|
84
|
+
</li>
|
|
85
|
+
)}
|
|
86
|
+
</For>
|
|
87
|
+
</ol>
|
|
88
|
+
)
|
|
89
|
+
}
|
package/src/ui/Toast.tsx
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
// Toast notifications on Kobalte's imperative `toaster` + `Toast.Region`.
|
|
2
|
+
// Mount <Toaster /> once near the app root; call the `toast` helpers from
|
|
3
|
+
// anywhere (pages, mutations' onError/onSuccess).
|
|
4
|
+
import { Toast as KToast, toaster } from '@kobalte/core/toast'
|
|
5
|
+
import type { JSX } from 'solid-js'
|
|
6
|
+
import { Show } from 'solid-js'
|
|
7
|
+
|
|
8
|
+
import { cn } from '../lib/cn'
|
|
9
|
+
|
|
10
|
+
/** Visual/semantic tone of a toast — controls its border/background accent color. */
|
|
11
|
+
export type ToastTone = 'success' | 'error' | 'info'
|
|
12
|
+
|
|
13
|
+
const TONE_CLASSES: Record<ToastTone, string> = {
|
|
14
|
+
success: 'border-emerald-500/30 bg-emerald-500/10',
|
|
15
|
+
error: 'border-destructive/30 bg-destructive/10',
|
|
16
|
+
info: 'border-sky-500/30 bg-sky-500/10',
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function show(tone: ToastTone, title: string, description?: string): void {
|
|
20
|
+
toaster.show((props) => (
|
|
21
|
+
<KToast
|
|
22
|
+
toastId={props.toastId}
|
|
23
|
+
class={cn(
|
|
24
|
+
// toast-item drives the slide+fade keyframes in styles.css, keyed off
|
|
25
|
+
// Kobalte's data-opened/data-closed presence (same animationend-only
|
|
26
|
+
// mechanism as Modal).
|
|
27
|
+
'toast-item flex w-full items-start gap-2 rounded-md border bg-card p-3 text-sm text-card-foreground shadow-sm ring-1 ring-border',
|
|
28
|
+
TONE_CLASSES[tone],
|
|
29
|
+
)}
|
|
30
|
+
>
|
|
31
|
+
<div class="flex-1">
|
|
32
|
+
<KToast.Title class="font-semibold">{title}</KToast.Title>
|
|
33
|
+
<Show when={description}>
|
|
34
|
+
<KToast.Description class="text-muted-foreground">{description}</KToast.Description>
|
|
35
|
+
</Show>
|
|
36
|
+
</div>
|
|
37
|
+
<KToast.CloseButton class="text-muted-foreground hover:text-foreground" aria-label="Close">
|
|
38
|
+
✕
|
|
39
|
+
</KToast.CloseButton>
|
|
40
|
+
</KToast>
|
|
41
|
+
))
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Imperative API to show a toast notification from anywhere in the app (event
|
|
46
|
+
* handlers, mutation `onError`/`onSuccess`, etc.) — no component context
|
|
47
|
+
* needed. Requires a mounted {@link Toaster} to render into.
|
|
48
|
+
*
|
|
49
|
+
* @example
|
|
50
|
+
* ```tsx
|
|
51
|
+
* toast.success('Saved', 'Your changes were saved.')
|
|
52
|
+
* toast.error('Something went wrong')
|
|
53
|
+
* ```
|
|
54
|
+
*/
|
|
55
|
+
export const toast = {
|
|
56
|
+
success: (title: string, description?: string) => show('success', title, description),
|
|
57
|
+
error: (title: string, description?: string) => show('error', title, description),
|
|
58
|
+
info: (title: string, description?: string) => show('info', title, description),
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Mount once near the app root — hosts the toast viewport. */
|
|
62
|
+
export function Toaster(): JSX.Element {
|
|
63
|
+
return (
|
|
64
|
+
<KToast.Region duration={5000} swipeDirection="right">
|
|
65
|
+
<KToast.List class="fixed bottom-4 right-4 z-50 flex w-80 flex-col gap-2 outline-none" />
|
|
66
|
+
</KToast.Region>
|
|
67
|
+
)
|
|
68
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// Two-state toggle button on Kobalte's ToggleButton primitive.
|
|
2
|
+
import { ToggleButton as KToggleButton } from '@kobalte/core/toggle-button'
|
|
3
|
+
import type { JSX, ParentProps } from 'solid-js'
|
|
4
|
+
|
|
5
|
+
import { cn } from '../lib/cn'
|
|
6
|
+
|
|
7
|
+
interface ToggleProps extends ParentProps {
|
|
8
|
+
/** Whether the toggle is pressed/"on". Controlled — pair with `onChange`. */
|
|
9
|
+
pressed: boolean
|
|
10
|
+
onChange: (pressed: boolean) => void
|
|
11
|
+
class?: string
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Two-state toggle button (e.g. bold/italic, mute, favorite), built on
|
|
16
|
+
* Kobalte's ToggleButton primitive. `children` is the button's content
|
|
17
|
+
* (icon and/or text).
|
|
18
|
+
*
|
|
19
|
+
* @example
|
|
20
|
+
* ```tsx
|
|
21
|
+
* const [pressed, setPressed] = createSignal(false)
|
|
22
|
+
* <Toggle pressed={pressed()} onChange={setPressed}><StarIcon /></Toggle>
|
|
23
|
+
* ```
|
|
24
|
+
*/
|
|
25
|
+
export function Toggle(props: ToggleProps): JSX.Element {
|
|
26
|
+
return (
|
|
27
|
+
<KToggleButton
|
|
28
|
+
pressed={props.pressed}
|
|
29
|
+
onChange={props.onChange}
|
|
30
|
+
class={cn(
|
|
31
|
+
// Outlined at rest so it reads as a button; filled primary when pressed
|
|
32
|
+
// so the "on" state is unmistakable (the resting muted look alone made it
|
|
33
|
+
// look like stray text).
|
|
34
|
+
'inline-flex h-9 w-9 items-center justify-center rounded-md border border-border text-muted-foreground transition-colors hover:bg-muted hover:text-foreground data-[pressed]:border-primary data-[pressed]:bg-primary data-[pressed]:text-primary-foreground',
|
|
35
|
+
props.class,
|
|
36
|
+
)}
|
|
37
|
+
>
|
|
38
|
+
{props.children}
|
|
39
|
+
</KToggleButton>
|
|
40
|
+
)
|
|
41
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// Segmented button row on Kobalte's ToggleGroup primitive.
|
|
2
|
+
import { ToggleGroup as KToggleGroup } from '@kobalte/core/toggle-group'
|
|
3
|
+
import type { JSX } from 'solid-js'
|
|
4
|
+
import { For } from 'solid-js'
|
|
5
|
+
|
|
6
|
+
import { cn } from '../lib/cn'
|
|
7
|
+
|
|
8
|
+
/** One option in a {@link ToggleGroup}: its `value` and visible `label`. */
|
|
9
|
+
export interface ToggleGroupOption {
|
|
10
|
+
value: string
|
|
11
|
+
label: string
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
interface ToggleGroupProps {
|
|
15
|
+
/** Currently selected option's `value`, or `null` if none is selected. Controlled — pair with `onChange`. */
|
|
16
|
+
value: string | null
|
|
17
|
+
onChange: (value: string | null) => void
|
|
18
|
+
/** Options to render, in order, as segmented buttons. */
|
|
19
|
+
options: ToggleGroupOption[]
|
|
20
|
+
class?: string
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Single-select segmented button row, built on Kobalte's ToggleGroup
|
|
25
|
+
* primitive — for exclusive-choice controls like view mode or alignment.
|
|
26
|
+
*
|
|
27
|
+
* @example
|
|
28
|
+
* ```tsx
|
|
29
|
+
* const [align, setAlign] = createSignal<string | null>('left')
|
|
30
|
+
* <ToggleGroup
|
|
31
|
+
* value={align()}
|
|
32
|
+
* onChange={setAlign}
|
|
33
|
+
* options={[
|
|
34
|
+
* { value: 'left', label: 'Left' },
|
|
35
|
+
* { value: 'center', label: 'Center' },
|
|
36
|
+
* ]}
|
|
37
|
+
* />
|
|
38
|
+
* ```
|
|
39
|
+
*/
|
|
40
|
+
export function ToggleGroup(props: ToggleGroupProps): JSX.Element {
|
|
41
|
+
return (
|
|
42
|
+
<KToggleGroup
|
|
43
|
+
value={props.value}
|
|
44
|
+
onChange={props.onChange}
|
|
45
|
+
class={cn('inline-flex gap-1 rounded-md border border-border bg-card p-1', props.class)}
|
|
46
|
+
>
|
|
47
|
+
<For each={props.options}>
|
|
48
|
+
{(option) => (
|
|
49
|
+
<KToggleGroup.Item
|
|
50
|
+
value={option.value}
|
|
51
|
+
class="rounded-sm px-3 py-1.5 text-sm font-medium text-muted-foreground transition-colors hover:text-foreground data-[pressed]:bg-primary data-[pressed]:text-primary-foreground"
|
|
52
|
+
>
|
|
53
|
+
{option.label}
|
|
54
|
+
</KToggleGroup.Item>
|
|
55
|
+
)}
|
|
56
|
+
</For>
|
|
57
|
+
</KToggleGroup>
|
|
58
|
+
)
|
|
59
|
+
}
|