@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,95 @@
|
|
|
1
|
+
// Text that "decodes" into place: characters scramble through random glyphs
|
|
2
|
+
// before locking, left to right, into the final string — motion.dev's
|
|
3
|
+
// scramble-text effect, reimplemented here with a plain setInterval (locking
|
|
4
|
+
// one more character's worth of the string on each tick) rather than the
|
|
5
|
+
// `motion` package, since the effect is character substitution, not a tween.
|
|
6
|
+
import { createSignal, onCleanup, onMount, type JSX } from 'solid-js'
|
|
7
|
+
|
|
8
|
+
import { cn } from '../lib/cn'
|
|
9
|
+
import { motionReduced } from '../lib/motion'
|
|
10
|
+
|
|
11
|
+
export interface ScrambleTextProps {
|
|
12
|
+
text: string
|
|
13
|
+
/** When to run: on mount, or on hover. @default 'mount' */
|
|
14
|
+
trigger?: 'mount' | 'hover'
|
|
15
|
+
/** Total scramble duration in ms. @default 800 */
|
|
16
|
+
duration?: number
|
|
17
|
+
class?: string
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const GLYPHS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ!@#$%^&*<>-_'
|
|
21
|
+
const TICK_MS = 30
|
|
22
|
+
|
|
23
|
+
function randomGlyph(): string {
|
|
24
|
+
return GLYPHS[Math.floor(Math.random() * GLYPHS.length)] as string
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Text that scrambles through random glyphs before "decoding" into the final
|
|
29
|
+
* string, left to right, over `duration` ms. Runs once on mount by default;
|
|
30
|
+
* pass `trigger="hover"` to (re)run it — cleanly restarting if it's still
|
|
31
|
+
* mid-scramble — on pointer hover instead. Renders the final text as-is, with
|
|
32
|
+
* no scramble, when the user prefers reduced motion.
|
|
33
|
+
*
|
|
34
|
+
* @example
|
|
35
|
+
* ```tsx
|
|
36
|
+
* <ScrambleText text="ACCESS GRANTED" trigger="hover" duration={600} />
|
|
37
|
+
* ```
|
|
38
|
+
*/
|
|
39
|
+
export function ScrambleText(props: ScrambleTextProps): JSX.Element {
|
|
40
|
+
// eslint-disable-next-line solid/reactivity -- one-time seed; scramble() re-reads props.text on each run
|
|
41
|
+
const [display, setDisplay] = createSignal(props.text)
|
|
42
|
+
let intervalId: ReturnType<typeof setInterval> | undefined
|
|
43
|
+
|
|
44
|
+
const stop = (): void => {
|
|
45
|
+
if (intervalId === undefined) return
|
|
46
|
+
clearInterval(intervalId)
|
|
47
|
+
intervalId = undefined
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const scramble = (): void => {
|
|
51
|
+
const text = props.text
|
|
52
|
+
if (motionReduced()) {
|
|
53
|
+
setDisplay(text)
|
|
54
|
+
return
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Restart cleanly if a previous scramble (from a prior trigger) is still running.
|
|
58
|
+
stop()
|
|
59
|
+
const totalTicks = Math.max(1, Math.round((props.duration ?? 800) / TICK_MS))
|
|
60
|
+
let tick = 0
|
|
61
|
+
|
|
62
|
+
intervalId = setInterval(() => {
|
|
63
|
+
tick += 1
|
|
64
|
+
|
|
65
|
+
if (tick >= totalTicks) {
|
|
66
|
+
setDisplay(text)
|
|
67
|
+
stop()
|
|
68
|
+
return
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const lockedCount = Math.floor((tick / totalTicks) * text.length)
|
|
72
|
+
setDisplay(
|
|
73
|
+
text
|
|
74
|
+
.split('')
|
|
75
|
+
.map((char, i) => (char === ' ' || i < lockedCount ? char : randomGlyph()))
|
|
76
|
+
.join(''),
|
|
77
|
+
)
|
|
78
|
+
}, TICK_MS)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
onMount(() => {
|
|
82
|
+
if (props.trigger !== 'hover') scramble()
|
|
83
|
+
})
|
|
84
|
+
onCleanup(stop)
|
|
85
|
+
|
|
86
|
+
const handleMouseEnter = (): void => {
|
|
87
|
+
if (props.trigger === 'hover') scramble()
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return (
|
|
91
|
+
<span class={cn('font-mono', props.class)} onMouseEnter={handleMouseEnter}>
|
|
92
|
+
{display()}
|
|
93
|
+
</span>
|
|
94
|
+
)
|
|
95
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
// Single-choice segmented control on Kobalte's SegmentedControl primitive,
|
|
2
|
+
// with an animated indicator sliding under the selected item.
|
|
3
|
+
import { SegmentedControl as KSegmentedControl } from '@kobalte/core/segmented-control'
|
|
4
|
+
import type { JSX } from 'solid-js'
|
|
5
|
+
import { For } from 'solid-js'
|
|
6
|
+
|
|
7
|
+
import { cn } from '../lib/cn'
|
|
8
|
+
|
|
9
|
+
/** A single selectable choice within a {@link SegmentedControl}. */
|
|
10
|
+
export interface SegmentedOption {
|
|
11
|
+
value: string
|
|
12
|
+
label: string
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
interface SegmentedControlProps {
|
|
16
|
+
value: string
|
|
17
|
+
onChange: (value: string) => void
|
|
18
|
+
options: SegmentedOption[]
|
|
19
|
+
class?: string
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Single-choice segmented control with an animated indicator that slides
|
|
24
|
+
* under the selected item, built on Kobalte's `SegmentedControl` primitive.
|
|
25
|
+
* Use for a small, always-visible set of mutually exclusive options (e.g.
|
|
26
|
+
* view toggles) as a more compact alternative to {@link RadioGroup}.
|
|
27
|
+
*
|
|
28
|
+
* @example
|
|
29
|
+
* ```tsx
|
|
30
|
+
* <SegmentedControl
|
|
31
|
+
* value={view()}
|
|
32
|
+
* onChange={setView}
|
|
33
|
+
* options={[
|
|
34
|
+
* { value: 'list', label: 'List' },
|
|
35
|
+
* { value: 'grid', label: 'Grid' },
|
|
36
|
+
* ]}
|
|
37
|
+
* />
|
|
38
|
+
* ```
|
|
39
|
+
*/
|
|
40
|
+
export function SegmentedControl(props: SegmentedControlProps): JSX.Element {
|
|
41
|
+
return (
|
|
42
|
+
<KSegmentedControl
|
|
43
|
+
value={props.value}
|
|
44
|
+
onChange={props.onChange}
|
|
45
|
+
class={cn('relative inline-flex rounded-md border border-border bg-card p-1', props.class)}
|
|
46
|
+
>
|
|
47
|
+
<KSegmentedControl.Indicator class="absolute rounded-sm bg-primary shadow-sm transition-all" />
|
|
48
|
+
<For each={props.options}>
|
|
49
|
+
{(option) => (
|
|
50
|
+
<KSegmentedControl.Item
|
|
51
|
+
value={option.value}
|
|
52
|
+
class="relative z-10 flex cursor-pointer items-center px-3 py-1.5 text-sm font-medium text-muted-foreground transition-colors data-[selected]:text-primary-foreground"
|
|
53
|
+
>
|
|
54
|
+
<KSegmentedControl.ItemInput />
|
|
55
|
+
<KSegmentedControl.ItemLabel>{option.label}</KSegmentedControl.ItemLabel>
|
|
56
|
+
</KSegmentedControl.Item>
|
|
57
|
+
)}
|
|
58
|
+
</For>
|
|
59
|
+
</KSegmentedControl>
|
|
60
|
+
)
|
|
61
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// Controlled native <select>.
|
|
2
|
+
import type { JSX, ParentProps } from 'solid-js'
|
|
3
|
+
import { createEffect, splitProps } from 'solid-js'
|
|
4
|
+
|
|
5
|
+
import { cn } from '../lib/cn'
|
|
6
|
+
|
|
7
|
+
const SELECT_BASE =
|
|
8
|
+
'w-full rounded-md border border-input bg-background text-foreground px-3 py-2 text-sm outline-none transition-colors a4-field'
|
|
9
|
+
|
|
10
|
+
interface SelectProps
|
|
11
|
+
extends ParentProps, Omit<JSX.SelectHTMLAttributes<HTMLSelectElement>, 'value' | 'onChange'> {
|
|
12
|
+
value: string
|
|
13
|
+
onChange: (value: string) => void
|
|
14
|
+
class?: string
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Controlled native `<select>`, styled to match the design system. Pass
|
|
19
|
+
* `<option>` elements as children. Not a Kobalte primitive — for a
|
|
20
|
+
* non-native, fully custom dropdown consider building on Kobalte's `Select`
|
|
21
|
+
* directly.
|
|
22
|
+
*
|
|
23
|
+
* @example
|
|
24
|
+
* ```tsx
|
|
25
|
+
* <Select value={country()} onChange={setCountry}>
|
|
26
|
+
* <option value="mx">Mexico</option>
|
|
27
|
+
* <option value="us">United States</option>
|
|
28
|
+
* </Select>
|
|
29
|
+
* ```
|
|
30
|
+
*/
|
|
31
|
+
export function Select(props: SelectProps): JSX.Element {
|
|
32
|
+
const [local, rest] = splitProps(props, ['value', 'onChange', 'class', 'children'])
|
|
33
|
+
let el!: HTMLSelectElement
|
|
34
|
+
// A native <select> silently drops its value when the matching <option> isn't
|
|
35
|
+
// in the DOM yet — e.g. options loaded async, or the value set (edit form)
|
|
36
|
+
// before the options mount. The declarative `value` below covers the common
|
|
37
|
+
// case; this effect re-applies it whenever EITHER the value or the option set
|
|
38
|
+
// changes, so edit forms reliably pre-select.
|
|
39
|
+
createEffect(() => {
|
|
40
|
+
const v = local.value ?? ''
|
|
41
|
+
void local.children // track option changes
|
|
42
|
+
if (el && el.value !== v) el.value = v
|
|
43
|
+
})
|
|
44
|
+
return (
|
|
45
|
+
<select
|
|
46
|
+
ref={el}
|
|
47
|
+
class={cn(SELECT_BASE, local.class)}
|
|
48
|
+
value={local.value}
|
|
49
|
+
onChange={(ev) => local.onChange(ev.currentTarget.value)}
|
|
50
|
+
{...rest}
|
|
51
|
+
>
|
|
52
|
+
{local.children}
|
|
53
|
+
</select>
|
|
54
|
+
)
|
|
55
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// Visual/semantic divider on Kobalte's Separator primitive.
|
|
2
|
+
import { Separator as KSeparator } from '@kobalte/core/separator'
|
|
3
|
+
import type { JSX } from 'solid-js'
|
|
4
|
+
|
|
5
|
+
import { cn } from '../lib/cn'
|
|
6
|
+
|
|
7
|
+
interface SeparatorProps {
|
|
8
|
+
/** Default: `'horizontal'`. */
|
|
9
|
+
orientation?: 'horizontal' | 'vertical'
|
|
10
|
+
class?: string
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Visual/semantic divider, built on Kobalte's `Separator` primitive (renders
|
|
15
|
+
* with the correct ARIA role for the given orientation). Use to separate
|
|
16
|
+
* groups of content, e.g. inside a menu or between toolbar sections.
|
|
17
|
+
*
|
|
18
|
+
* @example
|
|
19
|
+
* ```tsx
|
|
20
|
+
* <Separator orientation="vertical" />
|
|
21
|
+
* ```
|
|
22
|
+
*/
|
|
23
|
+
export function Separator(props: SeparatorProps): JSX.Element {
|
|
24
|
+
const orientation = () => props.orientation ?? 'horizontal'
|
|
25
|
+
// Kobalte's Separator renders an <hr>, which carries default block margins and
|
|
26
|
+
// a zero height that defeat `self-stretch` on the vertical variant — reset both
|
|
27
|
+
// (border-0 m-0) and give vertical a fallback min-height so it shows even when
|
|
28
|
+
// its flex parent doesn't stretch it.
|
|
29
|
+
return (
|
|
30
|
+
<KSeparator
|
|
31
|
+
orientation={orientation()}
|
|
32
|
+
class={cn(
|
|
33
|
+
'shrink-0 border-0 bg-border m-0',
|
|
34
|
+
orientation() === 'vertical' ? 'w-px min-h-4 h-full self-stretch' : 'h-px w-full',
|
|
35
|
+
props.class,
|
|
36
|
+
)}
|
|
37
|
+
/>
|
|
38
|
+
)
|
|
39
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// Pulsing placeholder for loading content — plain div, no primitive.
|
|
2
|
+
import type { JSX } from 'solid-js'
|
|
3
|
+
|
|
4
|
+
import { cn } from '../lib/cn'
|
|
5
|
+
|
|
6
|
+
interface SkeletonProps {
|
|
7
|
+
/** Size/shape the placeholder to match the content it stands in for, e.g. `"h-4 w-32 rounded-full"`. */
|
|
8
|
+
class?: string
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Pulsing placeholder block for content that is still loading. Plain `div`,
|
|
13
|
+
* no Kobalte primitive — size it via `class` to match the shape of the real
|
|
14
|
+
* content (text line, avatar, card, etc.).
|
|
15
|
+
*
|
|
16
|
+
* @example
|
|
17
|
+
* ```tsx
|
|
18
|
+
* <Skeleton class="h-4 w-48" />
|
|
19
|
+
* ```
|
|
20
|
+
*/
|
|
21
|
+
export function Skeleton(props: SkeletonProps): JSX.Element {
|
|
22
|
+
return <div class={cn('animate-pulse rounded-md bg-muted', props.class)} />
|
|
23
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
// Single-value range slider on Kobalte's Slider primitive. Kobalte works in
|
|
2
|
+
// arrays (multi-thumb); we wrap/unwrap the single value for a simpler API.
|
|
3
|
+
import { Slider as KSlider } from '@kobalte/core/slider'
|
|
4
|
+
import type { JSX } from 'solid-js'
|
|
5
|
+
import { Show } from 'solid-js'
|
|
6
|
+
|
|
7
|
+
import { cn } from '../lib/cn'
|
|
8
|
+
|
|
9
|
+
interface SliderProps {
|
|
10
|
+
value: number
|
|
11
|
+
onChange: (value: number) => void
|
|
12
|
+
/** Default: 0. */
|
|
13
|
+
min?: number
|
|
14
|
+
/** Default: 100. */
|
|
15
|
+
max?: number
|
|
16
|
+
/** Increment step. Default: 1. */
|
|
17
|
+
step?: number
|
|
18
|
+
/** Label shown above the track, paired with an auto-generated value label. */
|
|
19
|
+
label?: string
|
|
20
|
+
class?: string
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Single-value range slider, built on Kobalte's `Slider` primitive (which
|
|
25
|
+
* natively supports multiple thumbs). This wrapper accepts and emits a plain
|
|
26
|
+
* `number` instead of an array, for a simpler single-thumb API.
|
|
27
|
+
*
|
|
28
|
+
* @example
|
|
29
|
+
* ```tsx
|
|
30
|
+
* <Slider label="Volume" value={volume()} onChange={setVolume} min={0} max={100} />
|
|
31
|
+
* ```
|
|
32
|
+
*/
|
|
33
|
+
export function Slider(props: SliderProps): JSX.Element {
|
|
34
|
+
return (
|
|
35
|
+
<KSlider
|
|
36
|
+
value={[props.value]}
|
|
37
|
+
onChange={(v) => props.onChange(v[0])}
|
|
38
|
+
minValue={props.min ?? 0}
|
|
39
|
+
maxValue={props.max ?? 100}
|
|
40
|
+
step={props.step ?? 1}
|
|
41
|
+
class={cn('flex flex-col gap-2', props.class)}
|
|
42
|
+
>
|
|
43
|
+
<Show when={props.label}>
|
|
44
|
+
<div class="flex items-center justify-between text-sm text-foreground">
|
|
45
|
+
<KSlider.Label>{props.label}</KSlider.Label>
|
|
46
|
+
<KSlider.ValueLabel class="text-muted-foreground" />
|
|
47
|
+
</div>
|
|
48
|
+
</Show>
|
|
49
|
+
<KSlider.Track class="relative h-1.5 w-full rounded-full bg-muted">
|
|
50
|
+
<KSlider.Fill class="absolute h-full rounded-full bg-primary" />
|
|
51
|
+
<KSlider.Thumb class="-top-1.5 block h-4 w-4 rounded-full bg-primary outline-none focus:ring-2 focus:ring-ring/40">
|
|
52
|
+
<KSlider.Input />
|
|
53
|
+
</KSlider.Thumb>
|
|
54
|
+
</KSlider.Track>
|
|
55
|
+
</KSlider>
|
|
56
|
+
)
|
|
57
|
+
}
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
// Generic drag-to-reorder list. Dragging is pointer-events based (mouse +
|
|
2
|
+
// touch, no HTML5 drag/drop, no external lib): a grip handle starts the drag,
|
|
3
|
+
// a floating clone follows the pointer through a Portal, and the grabbed
|
|
4
|
+
// row's slot collapses into a dashed placeholder until it's dropped.
|
|
5
|
+
import { createEffect, createSignal, For, Show, type JSX } from 'solid-js'
|
|
6
|
+
import { Portal } from 'solid-js/web'
|
|
7
|
+
import { GripVertical } from 'lucide-solid'
|
|
8
|
+
|
|
9
|
+
import { cn } from '../lib/cn'
|
|
10
|
+
|
|
11
|
+
export interface SortableProps<T> {
|
|
12
|
+
/** The items to render, in current order. */
|
|
13
|
+
items: T[]
|
|
14
|
+
/** Stable unique key for an item (used for drag tracking + list keying). */
|
|
15
|
+
itemKey: (item: T) => string
|
|
16
|
+
/** Render an item's content (the grip handle is added automatically to its left). */
|
|
17
|
+
children: (item: T) => JSX.Element
|
|
18
|
+
/** Called with the new order after a drop. */
|
|
19
|
+
onReorder: (items: T[]) => void
|
|
20
|
+
/** Disable dragging. */
|
|
21
|
+
disabled?: boolean
|
|
22
|
+
class?: string
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
interface Drag {
|
|
26
|
+
key: string
|
|
27
|
+
y: number
|
|
28
|
+
offset: number
|
|
29
|
+
height: number
|
|
30
|
+
width: number
|
|
31
|
+
left: number
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Vertical list whose rows reorder by dragging a grip handle. Pointer-events
|
|
36
|
+
* driven (works with mouse and touch): the grabbed row leaves behind a dashed
|
|
37
|
+
* placeholder while a floating clone follows the pointer, and other rows shift
|
|
38
|
+
* live as the pointer crosses their midpoint. `onReorder` fires once on drop
|
|
39
|
+
* with the final order.
|
|
40
|
+
*
|
|
41
|
+
* @example
|
|
42
|
+
* ```tsx
|
|
43
|
+
* <Sortable
|
|
44
|
+
* items={fields()}
|
|
45
|
+
* itemKey={(f) => f.id}
|
|
46
|
+
* onReorder={setFields}
|
|
47
|
+
* >
|
|
48
|
+
* {(field) => <span>{field.label}</span>}
|
|
49
|
+
* </Sortable>
|
|
50
|
+
* ```
|
|
51
|
+
*/
|
|
52
|
+
export function Sortable<T>(props: SortableProps<T>): JSX.Element {
|
|
53
|
+
// eslint-disable-next-line solid/reactivity -- seed once; a createEffect below keeps it in sync
|
|
54
|
+
const [local, setLocal] = createSignal<T[]>(props.items)
|
|
55
|
+
const [drag, setDrag] = createSignal<Drag | null>(null)
|
|
56
|
+
let listRef: HTMLDivElement | undefined
|
|
57
|
+
|
|
58
|
+
createEffect(() => {
|
|
59
|
+
const next = props.items
|
|
60
|
+
if (!drag()) setLocal(next)
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
const onDragMove = (e: PointerEvent) => {
|
|
64
|
+
const d = drag()
|
|
65
|
+
if (!d || !listRef) return
|
|
66
|
+
setDrag({ ...d, y: e.clientY })
|
|
67
|
+
const rows = Array.from(listRef.querySelectorAll<HTMLElement>('[data-sortable-item]'))
|
|
68
|
+
let target = rows.length - 1
|
|
69
|
+
for (let i = 0; i < rows.length; i++) {
|
|
70
|
+
const r = rows[i].getBoundingClientRect()
|
|
71
|
+
if (e.clientY < r.top + r.height / 2) {
|
|
72
|
+
target = i
|
|
73
|
+
break
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
const cur = local()
|
|
77
|
+
const from = cur.findIndex((it) => props.itemKey(it) === d.key)
|
|
78
|
+
if (from === -1 || from === target) return
|
|
79
|
+
const next = cur.slice()
|
|
80
|
+
const [moved] = next.splice(from, 1)
|
|
81
|
+
next.splice(target, 0, moved)
|
|
82
|
+
setLocal(next)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const onDragStart = (e: PointerEvent, key: string) => {
|
|
86
|
+
if (props.disabled) return
|
|
87
|
+
e.preventDefault()
|
|
88
|
+
const row = (e.currentTarget as HTMLElement).closest('[data-sortable-item]') as HTMLElement | null
|
|
89
|
+
if (!row) return
|
|
90
|
+
const r = row.getBoundingClientRect()
|
|
91
|
+
setDrag({ key, y: e.clientY, offset: e.clientY - r.top, height: r.height, width: r.width, left: r.left })
|
|
92
|
+
|
|
93
|
+
const move = (ev: PointerEvent) => onDragMove(ev)
|
|
94
|
+
const up = () => {
|
|
95
|
+
window.removeEventListener('pointermove', move)
|
|
96
|
+
window.removeEventListener('pointerup', up)
|
|
97
|
+
const order = local()
|
|
98
|
+
const had = drag() !== null
|
|
99
|
+
setDrag(null)
|
|
100
|
+
if (had) props.onReorder(order)
|
|
101
|
+
}
|
|
102
|
+
window.addEventListener('pointermove', move)
|
|
103
|
+
window.addEventListener('pointerup', up)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const draggedItem = () => {
|
|
107
|
+
const d = drag()
|
|
108
|
+
if (!d) return undefined
|
|
109
|
+
return local().find((it) => props.itemKey(it) === d.key)
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return (
|
|
113
|
+
<div ref={listRef} class={cn('flex flex-col gap-2', drag() && 'select-none', props.class)}>
|
|
114
|
+
<For each={local()} fallback={null}>
|
|
115
|
+
{(item) => {
|
|
116
|
+
const key = props.itemKey(item)
|
|
117
|
+
const isDragged = () => drag()?.key === key
|
|
118
|
+
return (
|
|
119
|
+
<Show
|
|
120
|
+
when={!isDragged()}
|
|
121
|
+
fallback={
|
|
122
|
+
<div
|
|
123
|
+
data-sortable-item
|
|
124
|
+
class="rounded-lg border-2 border-dashed border-primary/40 bg-primary/5"
|
|
125
|
+
style={{ height: `${drag()?.height ?? 0}px` }}
|
|
126
|
+
/>
|
|
127
|
+
}
|
|
128
|
+
>
|
|
129
|
+
<div
|
|
130
|
+
data-sortable-item
|
|
131
|
+
class="flex items-center gap-2 rounded-lg border border-border bg-card p-2"
|
|
132
|
+
>
|
|
133
|
+
<button
|
|
134
|
+
type="button"
|
|
135
|
+
onPointerDown={(e) => onDragStart(e, key)}
|
|
136
|
+
style={{ 'touch-action': 'none' }}
|
|
137
|
+
class="cursor-grab active:cursor-grabbing text-muted-foreground shrink-0"
|
|
138
|
+
aria-label="Drag to reorder"
|
|
139
|
+
disabled={props.disabled}
|
|
140
|
+
>
|
|
141
|
+
<GripVertical class="h-4 w-4" />
|
|
142
|
+
</button>
|
|
143
|
+
<div class="min-w-0 flex-1">{props.children(item)}</div>
|
|
144
|
+
</div>
|
|
145
|
+
</Show>
|
|
146
|
+
)
|
|
147
|
+
}}
|
|
148
|
+
</For>
|
|
149
|
+
<Show when={drag()}>
|
|
150
|
+
{(d) => (
|
|
151
|
+
<Portal>
|
|
152
|
+
<div
|
|
153
|
+
class="fixed shadow-2xl flex items-center gap-2 rounded-lg border border-border bg-card p-2"
|
|
154
|
+
style={{
|
|
155
|
+
left: `${d().left}px`,
|
|
156
|
+
top: `${d().y - d().offset}px`,
|
|
157
|
+
width: `${d().width}px`,
|
|
158
|
+
'pointer-events': 'none',
|
|
159
|
+
'z-index': 70,
|
|
160
|
+
}}
|
|
161
|
+
>
|
|
162
|
+
<span class="text-muted-foreground shrink-0">
|
|
163
|
+
<GripVertical class="h-4 w-4" />
|
|
164
|
+
</span>
|
|
165
|
+
<div class="min-w-0 flex-1">
|
|
166
|
+
<Show when={draggedItem()}>{(item) => props.children(item())}</Show>
|
|
167
|
+
</div>
|
|
168
|
+
</div>
|
|
169
|
+
</Portal>
|
|
170
|
+
)}
|
|
171
|
+
</Show>
|
|
172
|
+
</div>
|
|
173
|
+
)
|
|
174
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
// Floating action button that fans a stack of actions upward when open.
|
|
2
|
+
import { Plus } from 'lucide-solid'
|
|
3
|
+
import type { JSX } from 'solid-js'
|
|
4
|
+
import { createSignal, For, Show } from 'solid-js'
|
|
5
|
+
|
|
6
|
+
import { cn } from '../lib/cn'
|
|
7
|
+
import { animate, motionReduced } from '../lib/motion'
|
|
8
|
+
|
|
9
|
+
/** A single action in a {@link SpeedDial} — an icon button with an accessible label. */
|
|
10
|
+
export interface SpeedDialAction {
|
|
11
|
+
icon: JSX.Element
|
|
12
|
+
label: string
|
|
13
|
+
onClick: () => void
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface SpeedDialProps {
|
|
17
|
+
actions: SpeedDialAction[]
|
|
18
|
+
/** Icon for the main FAB. Defaults to a `Plus` that rotates to an ✕ when open. */
|
|
19
|
+
icon?: JSX.Element
|
|
20
|
+
class?: string
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* A fixed floating action button (FAB) pinned to the bottom-right. Clicking the
|
|
25
|
+
* main circular button toggles an open state; while open, `actions` fan out
|
|
26
|
+
* upward as a vertical stack of smaller circular buttons. Selecting an action
|
|
27
|
+
* runs its `onClick` and closes the dial. Theme-agnostic — colors come from
|
|
28
|
+
* semantic tokens only.
|
|
29
|
+
*
|
|
30
|
+
* @example
|
|
31
|
+
* ```tsx
|
|
32
|
+
* <SpeedDial actions={[
|
|
33
|
+
* { icon: <Share class="h-5 w-5" />, label: 'Share', onClick: () => share() },
|
|
34
|
+
* { icon: <Copy class="h-5 w-5" />, label: 'Copy', onClick: () => copy() },
|
|
35
|
+
* ]} />
|
|
36
|
+
* ```
|
|
37
|
+
*/
|
|
38
|
+
export function SpeedDial(props: SpeedDialProps): JSX.Element {
|
|
39
|
+
const [open, setOpen] = createSignal(false)
|
|
40
|
+
|
|
41
|
+
return (
|
|
42
|
+
<div class={cn('fixed bottom-6 right-6 z-40 flex flex-col items-center gap-3', props.class)}>
|
|
43
|
+
<Show when={open()}>
|
|
44
|
+
<div class="flex flex-col items-center gap-3">
|
|
45
|
+
<For each={props.actions}>
|
|
46
|
+
{(action, i) => (
|
|
47
|
+
<button
|
|
48
|
+
ref={(el) => {
|
|
49
|
+
// Fan out with a staggered spring as the dial opens.
|
|
50
|
+
if (!motionReduced())
|
|
51
|
+
animate(
|
|
52
|
+
el,
|
|
53
|
+
{ opacity: [0, 1], y: [12, 0], scale: [0.8, 1] },
|
|
54
|
+
{ type: 'spring', stiffness: 500, damping: 24, delay: i() * 0.05 },
|
|
55
|
+
)
|
|
56
|
+
}}
|
|
57
|
+
type="button"
|
|
58
|
+
aria-label={action.label}
|
|
59
|
+
title={action.label}
|
|
60
|
+
onClick={() => {
|
|
61
|
+
action.onClick()
|
|
62
|
+
setOpen(false)
|
|
63
|
+
}}
|
|
64
|
+
class="flex h-11 w-11 items-center justify-center rounded-full border border-border bg-card text-foreground shadow hover:bg-muted focus:outline-none focus:ring-2 focus:ring-ring"
|
|
65
|
+
>
|
|
66
|
+
{action.icon}
|
|
67
|
+
</button>
|
|
68
|
+
)}
|
|
69
|
+
</For>
|
|
70
|
+
</div>
|
|
71
|
+
</Show>
|
|
72
|
+
<button
|
|
73
|
+
type="button"
|
|
74
|
+
aria-label={open() ? 'Close actions' : 'Open actions'}
|
|
75
|
+
aria-expanded={open()}
|
|
76
|
+
onClick={() => setOpen((v) => !v)}
|
|
77
|
+
class="flex h-14 w-14 items-center justify-center rounded-full bg-primary text-primary-foreground shadow-lg transition-transform duration-150 active:scale-[0.97] focus:outline-none focus:ring-2 focus:ring-ring"
|
|
78
|
+
>
|
|
79
|
+
<span class={cn('inline-flex transition-transform duration-150', open() && 'rotate-45')}>
|
|
80
|
+
{props.icon ?? <Plus class="h-6 w-6" />}
|
|
81
|
+
</span>
|
|
82
|
+
</button>
|
|
83
|
+
</div>
|
|
84
|
+
)
|
|
85
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { JSX } from 'solid-js'
|
|
2
|
+
|
|
3
|
+
import { cn } from '../lib/cn'
|
|
4
|
+
|
|
5
|
+
interface SpinnerProps {
|
|
6
|
+
class?: string
|
|
7
|
+
/** Accessible label announced to screen readers via `aria-label`. Default: `'Loading'`. */
|
|
8
|
+
label?: string
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Small spinning loading indicator (inline SVG, no primitive). Use for
|
|
13
|
+
* indeterminate loading states such as button/inline busy states.
|
|
14
|
+
*
|
|
15
|
+
* @example
|
|
16
|
+
* ```tsx
|
|
17
|
+
* <Spinner label="Saving" />
|
|
18
|
+
* ```
|
|
19
|
+
*/
|
|
20
|
+
export function Spinner(props: SpinnerProps): JSX.Element {
|
|
21
|
+
return (
|
|
22
|
+
<svg
|
|
23
|
+
class={cn('h-5 w-5 animate-spin text-muted-foreground', props.class)}
|
|
24
|
+
viewBox="0 0 24 24"
|
|
25
|
+
fill="none"
|
|
26
|
+
role="status"
|
|
27
|
+
aria-label={props.label ?? 'Loading'}
|
|
28
|
+
>
|
|
29
|
+
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
|
|
30
|
+
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
|
31
|
+
</svg>
|
|
32
|
+
)
|
|
33
|
+
}
|