@a4ui/core 0.11.1 → 0.12.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 +36 -26
- package/dist/full.css +3042 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- 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 +166 -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/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/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/FileUpload.tsx +198 -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/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/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/NotificationCenter.tsx +103 -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/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/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 +75 -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/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,75 @@
|
|
|
1
|
+
// Circular (radial) progress ring rendered as an SVG with a centered label.
|
|
2
|
+
import type { JSX } from 'solid-js'
|
|
3
|
+
import { Show } from 'solid-js'
|
|
4
|
+
|
|
5
|
+
import { cn } from '../lib/cn'
|
|
6
|
+
|
|
7
|
+
export interface RingProgressProps {
|
|
8
|
+
value: number
|
|
9
|
+
/** Outer diameter of the ring in px. @default 96 */
|
|
10
|
+
size?: number
|
|
11
|
+
/** Stroke width of the track and arc in px. @default 8 */
|
|
12
|
+
thickness?: number
|
|
13
|
+
/** Content centered over the ring. Defaults to the rounded percent. */
|
|
14
|
+
label?: JSX.Element
|
|
15
|
+
class?: string
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Circular progress indicator drawn with two concentric SVG circles: a muted
|
|
20
|
+
* track and a primary arc whose length is derived from `value` (0–100). Use for
|
|
21
|
+
* compact, radial completion feedback where a linear bar would be too wide.
|
|
22
|
+
*
|
|
23
|
+
* @example
|
|
24
|
+
* ```tsx
|
|
25
|
+
* <RingProgress value={72} />
|
|
26
|
+
* <RingProgress value={40} size={128} thickness={12} label={<span>4/10</span>} />
|
|
27
|
+
* ```
|
|
28
|
+
*/
|
|
29
|
+
export function RingProgress(props: RingProgressProps): JSX.Element {
|
|
30
|
+
const size = () => props.size ?? 96
|
|
31
|
+
const thickness = () => props.thickness ?? 8
|
|
32
|
+
const clamped = () => Math.min(100, Math.max(0, props.value))
|
|
33
|
+
const radius = () => (size() - thickness()) / 2
|
|
34
|
+
const circumference = () => 2 * Math.PI * radius()
|
|
35
|
+
const offset = () => circumference() * (1 - clamped() / 100)
|
|
36
|
+
|
|
37
|
+
return (
|
|
38
|
+
<div
|
|
39
|
+
role="progressbar"
|
|
40
|
+
aria-valuenow={clamped()}
|
|
41
|
+
aria-valuemin={0}
|
|
42
|
+
aria-valuemax={100}
|
|
43
|
+
class={cn('relative inline-flex items-center justify-center', props.class)}
|
|
44
|
+
style={{ width: `${size()}px`, height: `${size()}px` }}
|
|
45
|
+
>
|
|
46
|
+
<svg width={size()} height={size()} viewBox={`0 0 ${size()} ${size()}`} class="-rotate-90">
|
|
47
|
+
<circle
|
|
48
|
+
cx={size() / 2}
|
|
49
|
+
cy={size() / 2}
|
|
50
|
+
r={radius()}
|
|
51
|
+
fill="none"
|
|
52
|
+
stroke="hsl(var(--muted))"
|
|
53
|
+
stroke-width={thickness()}
|
|
54
|
+
/>
|
|
55
|
+
<circle
|
|
56
|
+
cx={size() / 2}
|
|
57
|
+
cy={size() / 2}
|
|
58
|
+
r={radius()}
|
|
59
|
+
fill="none"
|
|
60
|
+
stroke="hsl(var(--primary))"
|
|
61
|
+
stroke-width={thickness()}
|
|
62
|
+
stroke-linecap="round"
|
|
63
|
+
stroke-dasharray={String(circumference())}
|
|
64
|
+
stroke-dashoffset={String(offset())}
|
|
65
|
+
class="transition-all"
|
|
66
|
+
/>
|
|
67
|
+
</svg>
|
|
68
|
+
<div class="absolute inset-0 flex items-center justify-center text-sm font-medium text-foreground">
|
|
69
|
+
<Show when={props.label} fallback={`${Math.round(clamped())}%`}>
|
|
70
|
+
{props.label}
|
|
71
|
+
</Show>
|
|
72
|
+
</div>
|
|
73
|
+
</div>
|
|
74
|
+
)
|
|
75
|
+
}
|
|
@@ -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,75 @@
|
|
|
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
|
+
|
|
8
|
+
/** A single action in a {@link SpeedDial} — an icon button with an accessible label. */
|
|
9
|
+
export interface SpeedDialAction {
|
|
10
|
+
icon: JSX.Element
|
|
11
|
+
label: string
|
|
12
|
+
onClick: () => void
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface SpeedDialProps {
|
|
16
|
+
actions: SpeedDialAction[]
|
|
17
|
+
/** Icon for the main FAB. Defaults to a `Plus` that rotates to an ✕ when open. */
|
|
18
|
+
icon?: JSX.Element
|
|
19
|
+
class?: string
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* A fixed floating action button (FAB) pinned to the bottom-right. Clicking the
|
|
24
|
+
* main circular button toggles an open state; while open, `actions` fan out
|
|
25
|
+
* upward as a vertical stack of smaller circular buttons. Selecting an action
|
|
26
|
+
* runs its `onClick` and closes the dial. Theme-agnostic — colors come from
|
|
27
|
+
* semantic tokens only.
|
|
28
|
+
*
|
|
29
|
+
* @example
|
|
30
|
+
* ```tsx
|
|
31
|
+
* <SpeedDial actions={[
|
|
32
|
+
* { icon: <Share class="h-5 w-5" />, label: 'Share', onClick: () => share() },
|
|
33
|
+
* { icon: <Copy class="h-5 w-5" />, label: 'Copy', onClick: () => copy() },
|
|
34
|
+
* ]} />
|
|
35
|
+
* ```
|
|
36
|
+
*/
|
|
37
|
+
export function SpeedDial(props: SpeedDialProps): JSX.Element {
|
|
38
|
+
const [open, setOpen] = createSignal(false)
|
|
39
|
+
|
|
40
|
+
return (
|
|
41
|
+
<div class={cn('fixed bottom-6 right-6 z-40 flex flex-col items-center gap-3', props.class)}>
|
|
42
|
+
<Show when={open()}>
|
|
43
|
+
<div class="flex flex-col items-center gap-3">
|
|
44
|
+
<For each={props.actions}>
|
|
45
|
+
{(action) => (
|
|
46
|
+
<button
|
|
47
|
+
type="button"
|
|
48
|
+
aria-label={action.label}
|
|
49
|
+
title={action.label}
|
|
50
|
+
onClick={() => {
|
|
51
|
+
action.onClick()
|
|
52
|
+
setOpen(false)
|
|
53
|
+
}}
|
|
54
|
+
class="flex h-11 w-11 translate-y-0 items-center justify-center rounded-full border border-border bg-card text-foreground opacity-100 shadow transition-[opacity,transform] duration-150 hover:bg-muted focus:outline-none focus:ring-2 focus:ring-ring"
|
|
55
|
+
>
|
|
56
|
+
{action.icon}
|
|
57
|
+
</button>
|
|
58
|
+
)}
|
|
59
|
+
</For>
|
|
60
|
+
</div>
|
|
61
|
+
</Show>
|
|
62
|
+
<button
|
|
63
|
+
type="button"
|
|
64
|
+
aria-label={open() ? 'Close actions' : 'Open actions'}
|
|
65
|
+
aria-expanded={open()}
|
|
66
|
+
onClick={() => setOpen((v) => !v)}
|
|
67
|
+
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"
|
|
68
|
+
>
|
|
69
|
+
<span class={cn('inline-flex transition-transform duration-150', open() && 'rotate-45')}>
|
|
70
|
+
{props.icon ?? <Plus class="h-6 w-6" />}
|
|
71
|
+
</span>
|
|
72
|
+
</button>
|
|
73
|
+
</div>
|
|
74
|
+
)
|
|
75
|
+
}
|
|
@@ -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
|
+
}
|