@lyeve-labs/ui-kit 0.42.1 → 0.43.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 CHANGED
@@ -28,7 +28,7 @@ No config file, no theme provider, no setup ceremony.
28
28
 
29
29
  ## What's in the box
30
30
 
31
- - **75 components:** buttons, inputs, modals, drawers, tabs, tables, toasts, the works.
31
+ - **78 components:** buttons, inputs, modals, drawers, tabs, tables, toasts, the works.
32
32
  - **Two themes:** Soft Dark (default) and Soft Light, switched by a single `data-theme` attribute on `<html>`.
33
33
  - **One CSS file:** `@lyeve-labs/ui-kit/styles.css` declares every token. The rest is just Tailwind.
34
34
  - **Svelte 5 native:** built on runes and snippets, fully typed end-to-end.
@@ -37,13 +37,13 @@ No config file, no theme provider, no setup ceremony.
37
37
  ## Component list
38
38
 
39
39
  <details>
40
- <summary>75 components, organized by purpose</summary>
40
+ <summary>78 components, organized by purpose</summary>
41
41
 
42
42
  **Layout and structure**
43
- Card, Panel, AppShell, AuthShell, PageShell, PageHeader, SectionHeading, Divider, Accordion, AccordionItem, Collapsible, Table, DescriptionList, Toolbar, TreeView, SplitPane
43
+ Card, Panel, AppShell, AuthShell, PageShell, PageHeader, SectionHeading, Divider, Accordion, AccordionItem, Collapsible, Table, DescriptionList, DiffView, Toolbar, TreeView, SortableList, SplitPane
44
44
 
45
45
  **Forms and inputs**
46
- Button, ButtonGroup, Input, PasswordInput, CopyField, Textarea, NumberInput, SearchInput, FileInput, Label, Field, FormMessage, SegmentedControl, FilterChips, Select, MultiSelect, Autocomplete, DatePicker, TimePicker, DateTimePicker, Checkbox, CheckboxGroup, Radio, RadioGroup, Toggle
46
+ Button, ButtonGroup, Input, PasswordInput, CopyField, Textarea, NumberInput, SearchInput, FileInput, ColorField, Label, Field, FormMessage, SegmentedControl, FilterChips, Select, MultiSelect, Autocomplete, DatePicker, TimePicker, DateTimePicker, Checkbox, CheckboxGroup, Radio, RadioGroup, Toggle
47
47
 
48
48
  **Navigation**
49
49
  Breadcrumb, Tabs, Pagination, StepIndicator, Dropdown, SidebarNav, AccountMenu
@@ -403,7 +403,7 @@ This repo is a single-purpose component library. Nothing but `src/lib/`.
403
403
  ```
404
404
  src/
405
405
  └── lib/ # → published as @lyeve-labs/ui-kit
406
- ├── components/ # 75 .svelte files
406
+ ├── components/ # 78 .svelte files
407
407
  ├── stores/ # toast.svelte.ts
408
408
  ├── styles/ # theme.css (the one stylesheet)
409
409
  ├── utils/ # cn.ts, theme.ts
@@ -0,0 +1,221 @@
1
+ <script lang="ts" module>
2
+ /** One color a ColorField offers as a swatch. */
3
+ export interface ColorSwatch {
4
+ /** What the field stores when this swatch is picked, such as a theme token's name. */
5
+ value: string;
6
+ /** Names the swatch to a screen reader and on hover. */
7
+ label: string;
8
+ /** What the swatch paints: any CSS color, such as `#2F6FEB` or `var(--brand)`. */
9
+ color: string;
10
+ }
11
+
12
+ /**
13
+ * Six upper-case hex digits after a #, from "#2f6feb", "2F6FEB", the short
14
+ * "#26E" or any of them with spaces around. Null for anything else.
15
+ */
16
+ export function normalizeHex(input: string): string | null {
17
+ const digits = /^#?([0-9a-f]{3}|[0-9a-f]{6})$/i.exec(input.trim())?.[1];
18
+ if (digits === undefined) return null;
19
+ const six = digits.length === 3 ? digits.replace(/./g, (d) => d + d) : digits;
20
+ return `#${six.toUpperCase()}`;
21
+ }
22
+ </script>
23
+
24
+ <script lang="ts">
25
+ /**
26
+ * A color, typed as hex or picked from a set of swatches.
27
+ *
28
+ * Without swatches it is a text field with the color drawn inside its
29
+ * start. It takes #2F6FEB, 2f6feb or the short #26E and stores six
30
+ * upper-case digits after a #. Text that reads as no color is refused when
31
+ * the reader leaves the field, and from then on the message follows what
32
+ * they type. The browser's own color input opens a picker no stylesheet
33
+ * reaches, so the kit never draws one.
34
+ *
35
+ * With swatches it is a radio group, and the value is the picked swatch's
36
+ * value, which can be a theme token's name rather than a hex. A stored
37
+ * value no swatch carries any longer stays until the reader picks another,
38
+ * and the field says so.
39
+ */
40
+ import { CHOICE_FOCUS, CHOICE_GROUP, CHOICE_LEGEND } from '../internal/choice.js';
41
+ import {
42
+ CONTROL_BASE,
43
+ FIELD_ERROR,
44
+ FIELD_HINT,
45
+ FIELD_LABEL,
46
+ FIELD_WRAP,
47
+ controlBorder,
48
+ describedBy,
49
+ joinIds,
50
+ } from '../internal/field.js';
51
+ import { TOUCH_GROW } from '../internal/touch.js';
52
+
53
+ interface Props {
54
+ /** Six upper-case hex digits after a #, a swatch's value, or empty for none. */
55
+ value?: string;
56
+ label?: string;
57
+ hint?: string;
58
+ /** The caller's own refusal, shown in place of the field's. */
59
+ error?: string;
60
+ /**
61
+ * Ids of other elements that describe the field, separated by spaces, such
62
+ * as the message a wrapping Field renders. They are read after the field's
63
+ * own hint or error.
64
+ */
65
+ describedBy?: string;
66
+ /** Offers these colors as a radio group in place of the text field. */
67
+ swatches?: ColorSwatch[];
68
+ disabled?: boolean;
69
+ required?: boolean;
70
+ id?: string;
71
+ name?: string;
72
+ class?: string;
73
+ }
74
+
75
+ let {
76
+ value = $bindable(''),
77
+ label = undefined,
78
+ hint = undefined,
79
+ error = undefined,
80
+ describedBy: callerIds = undefined,
81
+ swatches = undefined,
82
+ disabled = false,
83
+ required = false,
84
+ id = undefined,
85
+ name = undefined,
86
+ class: klass = '',
87
+ }: Props = $props();
88
+
89
+ const uid = $props.id();
90
+ const fieldId = $derived(id ?? uid);
91
+
92
+ /**
93
+ * What the reader typed over a value. A new value from the caller replaces
94
+ * it, because the draft then belongs to a value no longer shown.
95
+ */
96
+ let draft = $state<{ over: string; text: string } | null>(null);
97
+ const text = $derived(draft !== null && draft.over === value ? draft.text : value);
98
+ // Dropped once the value moves, or the draft would come back with an undo
99
+ // that returns to the value it was typed over.
100
+ $effect.pre(() => {
101
+ if (draft !== null && draft.over !== value) draft = null;
102
+ });
103
+ let left = $state(false);
104
+ const refusal = $derived(
105
+ left && text.trim() !== '' && normalizeHex(text) === null
106
+ ? 'Enter a color as # and six hex digits, such as #2F6FEB.'
107
+ : undefined,
108
+ );
109
+ const message = $derived(error ?? refusal);
110
+
111
+ function settle() {
112
+ left = true;
113
+ const hex = normalizeHex(text);
114
+ if (hex !== null) {
115
+ value = hex;
116
+ draft = null;
117
+ } else if (text.trim() === '') {
118
+ value = '';
119
+ draft = null;
120
+ }
121
+ }
122
+
123
+ /** A stored value that no swatch offers any longer. */
124
+ const missing = $derived(
125
+ swatches !== undefined && value !== '' && !swatches.some((s) => s.value === value),
126
+ );
127
+ </script>
128
+
129
+ {#if swatches}
130
+ <!-- role="radiogroup" over the fieldset's implicit "group", as RadioGroup
131
+ does, so the group can carry its own validity. -->
132
+ <fieldset
133
+ data-field
134
+ {id}
135
+ class="{CHOICE_GROUP} {klass}"
136
+ role="radiogroup"
137
+ {disabled}
138
+ aria-invalid={error ? 'true' : undefined}
139
+ aria-required={required ? 'true' : undefined}
140
+ aria-describedby={joinIds(describedBy(fieldId, error, missing ? value : hint), callerIds)}
141
+ >
142
+ {#if label}
143
+ <legend class={CHOICE_LEGEND}>
144
+ {label}{#if required}<span class="text-danger ms-0.5" aria-hidden="true">*</span>{/if}
145
+ </legend>
146
+ {/if}
147
+ <div class="flex flex-wrap gap-2">
148
+ {#each swatches as swatch (swatch.value)}
149
+ <label class="relative" title={swatch.label}>
150
+ <input
151
+ type="radio"
152
+ class="peer sr-only"
153
+ name={name ?? uid}
154
+ value={swatch.value}
155
+ aria-label={swatch.label}
156
+ bind:group={value}
157
+ />
158
+ <!-- The ring keeps a swatch the color of the page in sight, and a
159
+ checked one draws a thicker ring in the text color, so the pick
160
+ never rests on the swatch's own color. -->
161
+ <span
162
+ aria-hidden="true"
163
+ class="block size-7 cursor-pointer rounded-full ring-1 ring-line-strong peer-checked:ring-2 peer-checked:ring-fg peer-disabled:cursor-not-allowed peer-disabled:opacity-50 {TOUCH_GROW} {CHOICE_FOCUS}"
164
+ style:background-color={swatch.color}
165
+ ></span>
166
+ </label>
167
+ {/each}
168
+ </div>
169
+ {#if missing && name}
170
+ <!-- No radio is checked, so a form would send nothing for the field and
171
+ a save would wipe the color it says it keeps. -->
172
+ <input type="hidden" {name} {value} />
173
+ {/if}
174
+ {#if error}
175
+ <p id="{fieldId}-error" class={FIELD_ERROR}>{error}</p>
176
+ {:else if missing}
177
+ <p id="{fieldId}-hint" class={FIELD_HINT}>
178
+ The stored color <span class="font-mono text-fg">{value}</span> is no longer in the theme.
179
+ </p>
180
+ {:else if hint}
181
+ <p id="{fieldId}-hint" class={FIELD_HINT}>{hint}</p>
182
+ {/if}
183
+ </fieldset>
184
+ {:else}
185
+ <div data-field class="{FIELD_WRAP} {klass}">
186
+ {#if label}
187
+ <label for={fieldId} class={FIELD_LABEL}>
188
+ {label}{#if required}<span class="text-danger ms-0.5" aria-hidden="true">*</span>{/if}
189
+ </label>
190
+ {/if}
191
+ <div class="relative">
192
+ <span
193
+ aria-hidden="true"
194
+ class="pointer-events-none absolute start-3 top-1/2 size-4 -translate-y-1/2 rounded-sm ring-1 ring-line-strong"
195
+ style:background-color={value || 'transparent'}
196
+ ></span>
197
+ <input
198
+ id={fieldId}
199
+ type="text"
200
+ {name}
201
+ {disabled}
202
+ {required}
203
+ value={text}
204
+ placeholder="#2F6FEB"
205
+ pattern={'\\s*#?([0-9a-fA-F]{3}|[0-9a-fA-F]{6})\\s*'}
206
+ autocomplete="off"
207
+ spellcheck="false"
208
+ aria-invalid={message ? 'true' : undefined}
209
+ aria-describedby={joinIds(describedBy(fieldId, message, hint), callerIds)}
210
+ class="{CONTROL_BASE} {controlBorder(!!message)} ps-9 font-mono"
211
+ oninput={(e) => (draft = { over: value, text: e.currentTarget.value })}
212
+ onchange={settle}
213
+ />
214
+ </div>
215
+ {#if message}
216
+ <p id="{fieldId}-error" class={FIELD_ERROR}>{message}</p>
217
+ {:else if hint}
218
+ <p id="{fieldId}-hint" class={FIELD_HINT}>{hint}</p>
219
+ {/if}
220
+ </div>
221
+ {/if}
@@ -0,0 +1,38 @@
1
+ /** One color a ColorField offers as a swatch. */
2
+ export interface ColorSwatch {
3
+ /** What the field stores when this swatch is picked, such as a theme token's name. */
4
+ value: string;
5
+ /** Names the swatch to a screen reader and on hover. */
6
+ label: string;
7
+ /** What the swatch paints: any CSS color, such as `#2F6FEB` or `var(--brand)`. */
8
+ color: string;
9
+ }
10
+ /**
11
+ * Six upper-case hex digits after a #, from "#2f6feb", "2F6FEB", the short
12
+ * "#26E" or any of them with spaces around. Null for anything else.
13
+ */
14
+ export declare function normalizeHex(input: string): string | null;
15
+ interface Props {
16
+ /** Six upper-case hex digits after a #, a swatch's value, or empty for none. */
17
+ value?: string;
18
+ label?: string;
19
+ hint?: string;
20
+ /** The caller's own refusal, shown in place of the field's. */
21
+ error?: string;
22
+ /**
23
+ * Ids of other elements that describe the field, separated by spaces, such
24
+ * as the message a wrapping Field renders. They are read after the field's
25
+ * own hint or error.
26
+ */
27
+ describedBy?: string;
28
+ /** Offers these colors as a radio group in place of the text field. */
29
+ swatches?: ColorSwatch[];
30
+ disabled?: boolean;
31
+ required?: boolean;
32
+ id?: string;
33
+ name?: string;
34
+ class?: string;
35
+ }
36
+ declare const ColorField: import("svelte").Component<Props, {}, "value">;
37
+ type ColorField = ReturnType<typeof ColorField>;
38
+ export default ColorField;
@@ -0,0 +1,152 @@
1
+ <script lang="ts" module>
2
+ /** One difference between two versions, named by the field it is in. */
3
+ export type DiffChange =
4
+ | { kind: 'added'; label: string; value: string }
5
+ | { kind: 'removed'; label: string; value: string }
6
+ | { kind: 'changed'; label: string; before: string; after: string }
7
+ | {
8
+ kind: 'moved';
9
+ label: string;
10
+ /** Where it moved, in the caller's words, such as "From position 2 to 4". */
11
+ value: string;
12
+ };
13
+ </script>
14
+
15
+ <script lang="ts">
16
+ /**
17
+ * What changed between two versions, one row per field.
18
+ *
19
+ * Each row names its field and says what happened to it in a word: Added,
20
+ * Removed, Changed or Moved, so the kind of change never rests on color. A
21
+ * changed field shows the old value marked Removed above the new one marked
22
+ * Added. Every value takes its direction from its own text, and a value
23
+ * past 400 characters shows its start with a Show all button, cut between
24
+ * two characters a reader sees as whole, so an emoji or an accent is never
25
+ * split.
26
+ *
27
+ * The kit draws the list. Working out what changed is the caller's.
28
+ */
29
+ import { cn } from '../utils/cn.js';
30
+ import type { AccentTone } from '../internal/tone.js';
31
+ import Badge from './Badge.svelte';
32
+ import Button from './Button.svelte';
33
+
34
+ interface Props {
35
+ changes: DiffChange[];
36
+ class?: string;
37
+ }
38
+
39
+ let { changes, class: klass = '' }: Props = $props();
40
+
41
+ /** How many characters of a value show before Show all. */
42
+ const CLAMP = 400;
43
+
44
+ const KIND: Record<DiffChange['kind'], { word: string; tone: AccentTone }> = {
45
+ added: { word: 'Added', tone: 'success' },
46
+ removed: { word: 'Removed', tone: 'danger' },
47
+ changed: { word: 'Changed', tone: 'warn' },
48
+ moved: { word: 'Moved', tone: 'brand' },
49
+ };
50
+
51
+ /**
52
+ * The values a reader opened past the clamp, by row and side, and the
53
+ * changes they were opened in. Other changes start folded, because a row's
54
+ * position there names another value.
55
+ */
56
+ let opened = $state.raw<{ changes: DiffChange[]; keys: Set<string> } | null>(null);
57
+
58
+ function isOpen(key: string): boolean {
59
+ return opened?.changes === changes && opened.keys.has(key);
60
+ }
61
+
62
+ function toggle(key: string) {
63
+ const keys = new Set(opened?.changes === changes ? opened.keys : []);
64
+ if (!keys.delete(key)) keys.add(key);
65
+ opened = { changes, keys };
66
+ }
67
+
68
+ /**
69
+ * The first CLAMP characters a reader sees as whole, or null when the text
70
+ * is no longer than that. A string no longer in code units than the clamp
71
+ * cannot hold more characters, so only a long one is segmented.
72
+ */
73
+ function clip(text: string): string | null {
74
+ if (text.length <= CLAMP) return null;
75
+ let seen = 0;
76
+ for (const { index } of new Intl.Segmenter(undefined, { granularity: 'grapheme' }).segment(
77
+ text,
78
+ )) {
79
+ if (seen === CLAMP) return text.slice(0, index);
80
+ seen++;
81
+ }
82
+ return null;
83
+ }
84
+ </script>
85
+
86
+ <!-- `what` names the value in its button, so a list of buttons tells each
87
+ Show all apart. -->
88
+ {#snippet shown(key: string, text: string, tint: string, what: string)}
89
+ {@const head = clip(text)}
90
+ {@const full = isOpen(key)}
91
+ <div class="min-w-0">
92
+ {#if text === ''}
93
+ <p class="text-faint italic">Empty</p>
94
+ {:else}
95
+ <p
96
+ dir="auto"
97
+ class={cn(
98
+ 'rounded-md whitespace-pre-wrap text-fg [overflow-wrap:anywhere]',
99
+ tint && `px-2 py-1 ${tint}`,
100
+ )}
101
+ >
102
+ {head === null || full ? text : head}
103
+ </p>
104
+ {#if head !== null}
105
+ <Button
106
+ variant="ghost"
107
+ size="sm"
108
+ class="mt-1"
109
+ aria-label="{full ? 'Show less' : 'Show all'} of {what}"
110
+ aria-expanded={full}
111
+ onclick={() => toggle(key)}
112
+ >
113
+ {full ? 'Show less' : 'Show all'}
114
+ </Button>
115
+ {/if}
116
+ {/if}
117
+ </div>
118
+ {/snippet}
119
+
120
+ <div class={cn('text-sm', klass)}>
121
+ {#if changes.length === 0}
122
+ <p class="text-muted">These versions are the same.</p>
123
+ {:else}
124
+ <ul class="divide-y divide-line">
125
+ {#each changes as change, i (i)}
126
+ <li class="flex flex-col gap-2 py-3 first:pt-0 last:pb-0">
127
+ <div class="flex flex-wrap items-center gap-2">
128
+ <span dir="auto" class="min-w-0 font-medium text-fg [overflow-wrap:anywhere]">
129
+ {change.label}
130
+ </span>
131
+ <Badge tone={KIND[change.kind].tone}>{KIND[change.kind].word}</Badge>
132
+ </div>
133
+ {#if change.kind === 'changed'}
134
+ <div class="grid grid-cols-[auto_minmax(0,1fr)] items-baseline gap-x-3 gap-y-1.5">
135
+ <span class="text-xs font-medium text-muted">Removed</span>
136
+ {@render shown(
137
+ `${i}-before`,
138
+ change.before,
139
+ 'bg-danger/10',
140
+ `${change.label}, removed`,
141
+ )}
142
+ <span class="text-xs font-medium text-muted">Added</span>
143
+ {@render shown(`${i}-after`, change.after, 'bg-success/10', `${change.label}, added`)}
144
+ </div>
145
+ {:else}
146
+ {@render shown(`${i}`, change.value, '', change.label)}
147
+ {/if}
148
+ </li>
149
+ {/each}
150
+ </ul>
151
+ {/if}
152
+ </div>
@@ -0,0 +1,27 @@
1
+ /** One difference between two versions, named by the field it is in. */
2
+ export type DiffChange = {
3
+ kind: 'added';
4
+ label: string;
5
+ value: string;
6
+ } | {
7
+ kind: 'removed';
8
+ label: string;
9
+ value: string;
10
+ } | {
11
+ kind: 'changed';
12
+ label: string;
13
+ before: string;
14
+ after: string;
15
+ } | {
16
+ kind: 'moved';
17
+ label: string;
18
+ /** Where it moved, in the caller's words, such as "From position 2 to 4". */
19
+ value: string;
20
+ };
21
+ interface Props {
22
+ changes: DiffChange[];
23
+ class?: string;
24
+ }
25
+ declare const DiffView: import("svelte").Component<Props, {}, "">;
26
+ type DiffView = ReturnType<typeof DiffView>;
27
+ export default DiffView;
@@ -204,6 +204,9 @@
204
204
  const charged = per ?? period;
205
205
  return charged === 'once' ? ' once' : charged === 'annual' ? '/yr' : '/mo';
206
206
  });
207
+ // A card shows the monthly list price. A one-time payment charges it once,
208
+ // so "/mo" there would promise a renewal.
209
+ const cardPerLabel = $derived(per === 'once' ? ' once' : '/mo');
207
210
 
208
211
  // The volume ladder drawn as a meter: the rungs that take something off,
209
212
  // placed along the count of the last one.
@@ -330,7 +333,7 @@
330
333
  </span>
331
334
  <span id="{id}-price" class="shrink-0 tabular-nums text-sm font-semibold text-fg">
332
335
  {formatCents(plugin.price_cents, currency)}<span
333
- class="text-xs font-normal text-muted">/mo</span
336
+ class="text-xs font-normal text-muted">{cardPerLabel}</span
334
337
  >
335
338
  </span>
336
339
  </span>
@@ -0,0 +1,511 @@
1
+ <script lang="ts" module>
2
+ /** One row a person can move. */
3
+ export interface SortableItem {
4
+ /**
5
+ * Stable, and unique in the list. The rows are keyed by it, so a repeated
6
+ * id throws in development, and an id that changes between renders
7
+ * remounts its row and loses the focus inside it.
8
+ */
9
+ id: string;
10
+ /** What the row is called. Its handle reads "Move <label>", and each move is announced by it. */
11
+ label: string;
12
+ }
13
+
14
+ /** A move that landed. */
15
+ export interface SortableMove<T extends SortableItem> {
16
+ item: T;
17
+ from: number;
18
+ to: number;
19
+ /** The list in its new order, which `items` holds as well. */
20
+ items: T[];
21
+ }
22
+ </script>
23
+
24
+ <script lang="ts" generics="T extends SortableItem">
25
+ /**
26
+ * A list a person puts in order, by dragging a row's handle, from the
27
+ * handle's menu, or by pressing Alt with an arrow key.
28
+ *
29
+ * A mouse lifts a row once it moves a few pixels with the button down. A
30
+ * finger lifts it after resting a quarter of a second on the handle, so a
31
+ * swipe that starts there still scrolls the page. A lifted row follows the
32
+ * pointer, a line marks where it will land, the box that scrolls the list
33
+ * scrolls when the pointer nears its edge, and Escape or a second finger
34
+ * puts the row back. A click or tap on the handle opens a menu that moves
35
+ * the row up or down, which is the way in for a pointer that cannot drag
36
+ * and for a screen reader on a phone. Alt+ArrowUp and Alt+ArrowDown move the
37
+ * row that holds the focus, unless the focus is in a text field, where
38
+ * Option and an arrow already move the caret on macOS, or a control in the
39
+ * row has already taken the key.
40
+ *
41
+ * Every move, and every move that cannot happen, is said in a polite live
42
+ * region in the row's own words. `canMove` refuses a move by returning the
43
+ * sentence to say instead, and the line turns to the danger tone while the
44
+ * pointer is over a refused place. A gesture follows its row by id, so a
45
+ * caller that changes `items` mid-drag still moves the row that was lifted,
46
+ * and removing that row ends the drag.
47
+ */
48
+ import { GripVertical } from '@lucide/svelte';
49
+ import { tick, type Snippet } from 'svelte';
50
+ import * as motion from '../motion.js';
51
+ import { cn } from '../utils/cn.js';
52
+ import {
53
+ DRAG_SLOP_PX,
54
+ EDGE_SCROLL_PX,
55
+ HOLD_SLOP_PX,
56
+ TOUCH_HOLD_MS,
57
+ attempt,
58
+ landing,
59
+ type MoveCheck,
60
+ } from '../internal/reorder.js';
61
+ import Button from './Button.svelte';
62
+ import Dropdown from './Dropdown.svelte';
63
+
64
+ interface Props {
65
+ /** The rows, in order. Bind it and a move writes the new order back. */
66
+ items: T[];
67
+ /** Names the list for a screen reader. */
68
+ label: string;
69
+ /** Allows a move with true, or refuses it with the sentence to announce instead. */
70
+ canMove?: (item: T, from: number, to: number) => MoveCheck;
71
+ /** Called once a move has landed, when `items` already holds the new order. */
72
+ onmove?: (move: SortableMove<T>) => void;
73
+ /** Draws a row's content beside its handle. */
74
+ row: Snippet<[T, number]>;
75
+ class?: string;
76
+ }
77
+
78
+ let { items = $bindable(), label, canMove, onmove, row, class: klass = '' }: Props = $props();
79
+
80
+ const uid = $props.id();
81
+
82
+ /** A press on a handle that has not lifted its row yet. */
83
+ interface Press {
84
+ pointerId: number;
85
+ /** The row's id. A gesture never holds an index, because `items` can change under it. */
86
+ id: string;
87
+ x: number;
88
+ y: number;
89
+ handle: HTMLElement;
90
+ timer?: ReturnType<typeof setTimeout>;
91
+ }
92
+
93
+ /** A lifted row, following the pointer. */
94
+ interface Drag {
95
+ pointerId: number;
96
+ id: string;
97
+ /** Where the pointer holds the row, measured from the row's top. */
98
+ grab: number;
99
+ /** How far the row has traveled from its place. */
100
+ dy: number;
101
+ /** The pointer's latest height, which the edge scroll reads between moves. */
102
+ y: number;
103
+ /** Where the row would land, and whether the check refuses that place. */
104
+ to: number;
105
+ refused: boolean;
106
+ }
107
+
108
+ let press: Press | null = null;
109
+ let drag = $state<Drag | null>(null);
110
+ /**
111
+ * Whether the handle's latest press lifted its row, so the click that ends
112
+ * that press is a drop and opens no menu. The next press clears it, not a
113
+ * timer, because a phone can send a tap's click well after the release. A
114
+ * click from Enter or Space has no press behind it (detail 0) and always
115
+ * opens the menu.
116
+ */
117
+ let dragged = false;
118
+ /** The row easing back to its place after a drop that moved nothing. */
119
+ let settling = $state<string | null>(null);
120
+ let spoken = $state({ n: 0, text: '' });
121
+ let list: HTMLUListElement | undefined = $state();
122
+ let scroller: Element | null = null;
123
+ let frame = 0;
124
+
125
+ /**
126
+ * Each announcement gets a node of its own, so a screen reader speaks a
127
+ * sentence again when it repeats, such as a second press at the top.
128
+ */
129
+ function say(text: string) {
130
+ if (text) spoken = { n: spoken.n + 1, text };
131
+ }
132
+
133
+ function rowsNow(): HTMLElement[] {
134
+ return list ? (Array.from(list.children) as HTMLElement[]) : [];
135
+ }
136
+
137
+ /** Where the row with this id is now, or -1 once the caller has removed it. */
138
+ function at(id: string): number {
139
+ return items.findIndex((item) => item.id === id);
140
+ }
141
+
142
+ /** Lands a move, or says why it cannot. True when the order changed. */
143
+ function land(from: number, to: number): boolean {
144
+ const result = attempt(items, from, to, canMove);
145
+ say(result.text);
146
+ if (!result.moved) return false;
147
+ const item = items[from];
148
+ items = result.items;
149
+ onmove?.({ item, from, to: result.to, items: result.items });
150
+ return true;
151
+ }
152
+
153
+ /**
154
+ * Lands a move and keeps the focus where it was. The keyed rows move as
155
+ * nodes, and a browser drops the focus of a node it takes out of the
156
+ * document, even to put it straight back.
157
+ */
158
+ async function move(from: number, to: number) {
159
+ const focused = document.activeElement;
160
+ if (!land(from, to)) return;
161
+ await tick();
162
+ if (focused instanceof HTMLElement && document.activeElement !== focused) focused.focus();
163
+ }
164
+
165
+ function typing(target: EventTarget | null): boolean {
166
+ return (
167
+ target instanceof HTMLElement &&
168
+ (target.isContentEditable || target.matches('input, textarea, select, [role="textbox"]'))
169
+ );
170
+ }
171
+
172
+ /** Whether the key came from this row's own handle, and not from a list inside the row. */
173
+ function fromHandle(e: KeyboardEvent): boolean {
174
+ return (
175
+ e.target instanceof HTMLElement &&
176
+ e.target.hasAttribute('data-handle') &&
177
+ e.target.closest('li') === e.currentTarget
178
+ );
179
+ }
180
+
181
+ /**
182
+ * Moves the row on Alt and an arrow, and says whether it took the key. The
183
+ * caller reads the answer rather than defaultPrevented, which an event that
184
+ * cannot be canceled never sets.
185
+ */
186
+ function keyMove(e: KeyboardEvent, index: number): boolean {
187
+ if (e.defaultPrevented || !e.altKey || e.ctrlKey || e.metaKey || e.shiftKey) return false;
188
+ if (e.key !== 'ArrowUp' && e.key !== 'ArrowDown') return false;
189
+ if (typing(e.target)) return false;
190
+ e.preventDefault();
191
+ if (!drag) void move(index, index + (e.key === 'ArrowUp' ? -1 : 1));
192
+ return true;
193
+ }
194
+
195
+ /**
196
+ * The handle's menu, built once per row. It reads the row's place only when
197
+ * it opens, so a move elsewhere in a long list rebuilds no menu.
198
+ */
199
+ function menu(id: string) {
200
+ const step = (by: number) => {
201
+ const from = at(id);
202
+ if (from >= 0) void move(from, from + by);
203
+ };
204
+ return [
205
+ {
206
+ label: 'Move up',
207
+ get disabled() {
208
+ return at(id) === 0;
209
+ },
210
+ onclick: () => step(-1),
211
+ },
212
+ {
213
+ label: 'Move down',
214
+ get disabled() {
215
+ return at(id) === items.length - 1;
216
+ },
217
+ onclick: () => step(1),
218
+ },
219
+ ];
220
+ }
221
+
222
+ function pressHandle(e: PointerEvent, id: string) {
223
+ if (e.button !== 0 || press || drag) return;
224
+ dragged = false;
225
+ const handle = e.currentTarget as HTMLElement;
226
+ const held: Press = { pointerId: e.pointerId, id, x: e.clientX, y: e.clientY, handle };
227
+ press = held;
228
+ if (e.pointerType === 'touch') held.timer = setTimeout(() => lift(held), TOUCH_HOLD_MS);
229
+ else handle.setPointerCapture?.(e.pointerId);
230
+ }
231
+
232
+ function lift(p: Press) {
233
+ quit();
234
+ const from = at(p.id);
235
+ const lifted = rowsNow()[from];
236
+ if (!lifted || !list || !p.handle.isConnected) return;
237
+ p.handle.setPointerCapture?.(p.pointerId);
238
+ settling = null;
239
+ dragged = true;
240
+ drag = {
241
+ pointerId: p.pointerId,
242
+ id: p.id,
243
+ grab: p.y - lifted.getBoundingClientRect().top,
244
+ dy: 0,
245
+ y: p.y,
246
+ to: from,
247
+ refused: false,
248
+ };
249
+ scroller = scrollParent(list);
250
+ frame = requestAnimationFrame(edgeScroll);
251
+ }
252
+
253
+ function moveHandle(e: PointerEvent) {
254
+ if (press?.pointerId === e.pointerId) {
255
+ const wandered = Math.hypot(e.clientX - press.x, e.clientY - press.y);
256
+ // A finger that travels before the hold is up is a swipe, and the page
257
+ // scrolls under it.
258
+ if (e.pointerType === 'touch') {
259
+ if (wandered > HOLD_SLOP_PX) quit();
260
+ } else if (wandered > DRAG_SLOP_PX) {
261
+ lift(press);
262
+ follow(e.clientY);
263
+ }
264
+ return;
265
+ }
266
+ if (!drag || drag.pointerId !== e.pointerId) return;
267
+ drag.y = e.clientY;
268
+ follow(e.clientY);
269
+ }
270
+
271
+ /** Moves the lifted row to the pointer and works out where it would land. */
272
+ function follow(y: number) {
273
+ const d = drag;
274
+ const from = d ? at(d.id) : -1;
275
+ const rows = rowsNow();
276
+ const lifted = rows[from];
277
+ if (!d || !list || !lifted) return;
278
+ // Where the row sits without its travel. Reading it on every move keeps
279
+ // the row under the pointer while the list scrolls beneath it.
280
+ const box = lifted.getBoundingClientRect();
281
+ const top = box.top - d.dy;
282
+ const bounds = list.getBoundingClientRect();
283
+ d.dy = Math.min(bounds.bottom - top - box.height, Math.max(bounds.top - top, y - d.grab - top));
284
+ let gap = rows.length;
285
+ for (let i = 0; i < rows.length; i++) {
286
+ if (i === from) continue;
287
+ const r = rows[i].getBoundingClientRect();
288
+ if (y < r.top + r.height / 2) {
289
+ gap = i;
290
+ break;
291
+ }
292
+ }
293
+ const to = landing(from, gap);
294
+ if (to === d.to) return;
295
+ d.to = to;
296
+ d.refused = to !== from && (canMove?.(items[from], from, to) ?? true) !== true;
297
+ }
298
+
299
+ function releaseHandle(e: PointerEvent) {
300
+ if (press?.pointerId === e.pointerId) return quit();
301
+ if (!drag || drag.pointerId !== e.pointerId) return;
302
+ // The caller may have changed the rows since the pointer last moved.
303
+ follow(e.clientY);
304
+ const { id, to } = drag;
305
+ const from = at(id);
306
+ stop();
307
+ if (from < 0) return;
308
+ // A move that lands animates from where the row was let go, because the
309
+ // rows are measured before its travel is cleared. A drop that moves
310
+ // nothing eases the row back to its place instead.
311
+ if (to === from || !land(from, to)) settling = id;
312
+ }
313
+
314
+ /** Puts a lifted row back where it was, and forgets a press. */
315
+ function cancel() {
316
+ quit();
317
+ if (!drag) return;
318
+ const { id } = drag;
319
+ stop();
320
+ if (at(id) >= 0) settling = id;
321
+ }
322
+
323
+ function cancelPointer(e: PointerEvent) {
324
+ if (press?.pointerId === e.pointerId || drag?.pointerId === e.pointerId) cancel();
325
+ }
326
+
327
+ function quit() {
328
+ if (press) clearTimeout(press.timer);
329
+ press = null;
330
+ }
331
+
332
+ function stop() {
333
+ drag = null;
334
+ scroller = null;
335
+ cancelAnimationFrame(frame);
336
+ }
337
+
338
+ /** The nearest box that scrolls the list, or the page. */
339
+ function scrollParent(node: HTMLElement): Element {
340
+ for (let el = node.parentElement; el; el = el.parentElement) {
341
+ const { overflowY } = getComputedStyle(el);
342
+ if ((overflowY === 'auto' || overflowY === 'scroll') && el.scrollHeight > el.clientHeight) {
343
+ return el;
344
+ }
345
+ }
346
+ return document.scrollingElement ?? document.documentElement;
347
+ }
348
+
349
+ /** Scrolls toward the edge the pointer is near, faster the nearer it is. */
350
+ function edgeScroll() {
351
+ const d = drag;
352
+ if (!d || !scroller) return;
353
+ const page = scroller === document.scrollingElement;
354
+ const box = page ? { top: 0, bottom: window.innerHeight } : scroller.getBoundingClientRect();
355
+ const past =
356
+ d.y < box.top + EDGE_SCROLL_PX
357
+ ? d.y - box.top - EDGE_SCROLL_PX
358
+ : d.y > box.bottom - EDGE_SCROLL_PX
359
+ ? d.y - box.bottom + EDGE_SCROLL_PX
360
+ : 0;
361
+ if (past !== 0) {
362
+ scroller.scrollTop += Math.round(
363
+ Math.max(-EDGE_SCROLL_PX, Math.min(EDGE_SCROLL_PX, past)) / 3,
364
+ );
365
+ follow(d.y);
366
+ }
367
+ frame = requestAnimationFrame(edgeScroll);
368
+ }
369
+
370
+ $effect(() => {
371
+ // The window hears how a gesture ends even once the handle that began it
372
+ // has left the document, as when the caller removes the row mid-drag.
373
+ window.addEventListener('pointerup', releaseHandle, true);
374
+ window.addEventListener('pointercancel', cancelPointer, true);
375
+ window.addEventListener('lostpointercapture', cancelPointer, true);
376
+ return () => {
377
+ window.removeEventListener('pointerup', releaseHandle, true);
378
+ window.removeEventListener('pointercancel', cancelPointer, true);
379
+ window.removeEventListener('lostpointercapture', cancelPointer, true);
380
+ };
381
+ });
382
+
383
+ $effect(() => {
384
+ if (!drag) return;
385
+ const escape = (e: KeyboardEvent) => {
386
+ if (e.key !== 'Escape') return;
387
+ // Stopped here so a dialog around the list stays open.
388
+ e.preventDefault();
389
+ e.stopPropagation();
390
+ cancel();
391
+ };
392
+ const second = (e: PointerEvent) => {
393
+ if (drag && e.pointerId !== drag.pointerId) cancel();
394
+ };
395
+ window.addEventListener('keydown', escape, true);
396
+ window.addEventListener('pointerdown', second, true);
397
+ return () => {
398
+ window.removeEventListener('keydown', escape, true);
399
+ window.removeEventListener('pointerdown', second, true);
400
+ };
401
+ });
402
+
403
+ $effect(() => {
404
+ const node = list;
405
+ if (!node) return;
406
+ // Svelte listens to touchmove passively, and only a listener that is not
407
+ // passive can keep a lifted row's finger from scrolling the page.
408
+ const hold = (e: TouchEvent) => {
409
+ if (drag) e.preventDefault();
410
+ };
411
+ node.addEventListener('touchmove', hold, { passive: false });
412
+ return () => {
413
+ node.removeEventListener('touchmove', hold);
414
+ quit();
415
+ cancelAnimationFrame(frame);
416
+ };
417
+ });
418
+
419
+ /** The row that draws the landing line, and on which of its edges. */
420
+ const line = $derived.by(() => {
421
+ const from = drag ? at(drag.id) : -1;
422
+ if (!drag || from < 0 || drag.to === from) return null;
423
+ return { index: drag.to, edge: drag.to > from ? 'after' : 'before', refused: drag.refused };
424
+ });
425
+
426
+ // Tailwind reads class names from source, so each edge spells its line out.
427
+ const LINE = {
428
+ before: {
429
+ shape: 'before:absolute before:inset-x-0 before:-top-px before:h-0.5 before:rounded-full',
430
+ ok: 'before:bg-brand',
431
+ refused: 'before:bg-danger',
432
+ },
433
+ after: {
434
+ shape: 'after:absolute after:inset-x-0 after:-bottom-px after:h-0.5 after:rounded-full',
435
+ ok: 'after:bg-brand',
436
+ refused: 'after:bg-danger',
437
+ },
438
+ } as const;
439
+
440
+ function rowClass(index: number, id: string): string {
441
+ const mark = line?.index === index ? LINE[line.edge as 'before' | 'after'] : null;
442
+ return cn(
443
+ 'relative flex items-center gap-1 rounded-lg',
444
+ drag?.id === id && 'z-dropdown bg-surface shadow-lg',
445
+ settling === id && 'transition-[translate] duration-base ease-move',
446
+ mark && `${mark.shape} ${line?.refused ? mark.refused : mark.ok}`,
447
+ );
448
+ }
449
+
450
+ function gripClass(id: string): string {
451
+ if (drag?.id !== id) return 'cursor-grab [-webkit-touch-callout:none]';
452
+ return `[-webkit-touch-callout:none] ${drag.refused ? 'cursor-not-allowed' : 'cursor-grabbing'}`;
453
+ }
454
+ </script>
455
+
456
+ <div class={cn('relative', klass)}>
457
+ <ul bind:this={list} aria-label={label} class="isolate flex flex-col">
458
+ {#each items as item, index (item.id)}
459
+ <!-- The row hears Alt and an arrow from whatever holds the focus inside
460
+ it, the handle or a control the row draws, so the keys work without
461
+ a tab stop of their own. The handle's keys are taken on the way
462
+ down, before its menu opens on an arrow, and the rest on the way
463
+ up, after a control in the row has had its chance to take them. -->
464
+ <!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
465
+ <li
466
+ animate:motion.reorder
467
+ class={rowClass(index, item.id)}
468
+ style:translate={drag?.id === item.id ? `0 ${drag.dy}px` : undefined}
469
+ onkeydowncapture={(e) => {
470
+ if (fromHandle(e) && keyMove(e, index)) e.stopPropagation();
471
+ }}
472
+ onkeydown={(e) => {
473
+ if (!fromHandle(e)) keyMove(e, index);
474
+ }}
475
+ >
476
+ <Dropdown items={menu(item.id)} class="shrink-0">
477
+ {#snippet trigger({ open, toggle })}
478
+ <Button
479
+ variant="ghost"
480
+ size="sm"
481
+ data-handle
482
+ aria-label="Move {item.label}"
483
+ aria-describedby="{uid}-keys"
484
+ aria-haspopup="menu"
485
+ aria-expanded={open}
486
+ hint={false}
487
+ class={gripClass(item.id)}
488
+ onclick={(e: MouseEvent) => {
489
+ if (e.detail === 0 || !dragged) toggle();
490
+ }}
491
+ onpointerdown={(e: PointerEvent) => pressHandle(e, item.id)}
492
+ onpointermove={moveHandle}
493
+ oncontextmenu={(e: MouseEvent) => {
494
+ if (press || drag) e.preventDefault();
495
+ }}
496
+ >
497
+ <GripVertical size={16} />
498
+ </Button>
499
+ {/snippet}
500
+ </Dropdown>
501
+ <div class="min-w-0 flex-1">{@render row(item, index)}</div>
502
+ </li>
503
+ {/each}
504
+ </ul>
505
+ <p id="{uid}-keys" class="sr-only">
506
+ Press Alt with the up or down arrow to move a row, or Enter for a menu.
507
+ </p>
508
+ <p role="status" class="sr-only">
509
+ {#key spoken.n}<span>{spoken.text}</span>{/key}
510
+ </p>
511
+ </div>
@@ -0,0 +1,57 @@
1
+ /** One row a person can move. */
2
+ export interface SortableItem {
3
+ /**
4
+ * Stable, and unique in the list. The rows are keyed by it, so a repeated
5
+ * id throws in development, and an id that changes between renders
6
+ * remounts its row and loses the focus inside it.
7
+ */
8
+ id: string;
9
+ /** What the row is called. Its handle reads "Move <label>", and each move is announced by it. */
10
+ label: string;
11
+ }
12
+ /** A move that landed. */
13
+ export interface SortableMove<T extends SortableItem> {
14
+ item: T;
15
+ from: number;
16
+ to: number;
17
+ /** The list in its new order, which `items` holds as well. */
18
+ items: T[];
19
+ }
20
+ import { type Snippet } from 'svelte';
21
+ import { type MoveCheck } from '../internal/reorder.js';
22
+ declare function $$render<T extends SortableItem>(): {
23
+ props: {
24
+ /** The rows, in order. Bind it and a move writes the new order back. */
25
+ items: T[];
26
+ /** Names the list for a screen reader. */
27
+ label: string;
28
+ /** Allows a move with true, or refuses it with the sentence to announce instead. */
29
+ canMove?: (item: T, from: number, to: number) => MoveCheck;
30
+ /** Called once a move has landed, when `items` already holds the new order. */
31
+ onmove?: (move: SortableMove<T>) => void;
32
+ /** Draws a row's content beside its handle. */
33
+ row: Snippet<[T, number]>;
34
+ class?: string;
35
+ };
36
+ exports: {};
37
+ bindings: "items";
38
+ slots: {};
39
+ events: {};
40
+ };
41
+ declare class __sveltets_Render<T extends SortableItem> {
42
+ props(): ReturnType<typeof $$render<T>>['props'];
43
+ events(): ReturnType<typeof $$render<T>>['events'];
44
+ slots(): ReturnType<typeof $$render<T>>['slots'];
45
+ bindings(): "items";
46
+ exports(): {};
47
+ }
48
+ interface $$IsomorphicComponent {
49
+ new <T extends SortableItem>(options: import('svelte').ComponentConstructorOptions<ReturnType<__sveltets_Render<T>['props']>>): import('svelte').SvelteComponent<ReturnType<__sveltets_Render<T>['props']>, ReturnType<__sveltets_Render<T>['events']>, ReturnType<__sveltets_Render<T>['slots']>> & {
50
+ $$bindings?: ReturnType<__sveltets_Render<T>['bindings']>;
51
+ } & ReturnType<__sveltets_Render<T>['exports']>;
52
+ <T extends SortableItem>(internal: unknown, props: ReturnType<__sveltets_Render<T>['props']> & {}): ReturnType<__sveltets_Render<T>['exports']>;
53
+ z_$$bindings?: ReturnType<__sveltets_Render<any>['bindings']>;
54
+ }
55
+ declare const SortableList: $$IsomorphicComponent;
56
+ type SortableList<T extends SortableItem> = InstanceType<typeof SortableList<T>>;
57
+ export default SortableList;
package/dist/index.d.ts CHANGED
@@ -19,8 +19,12 @@ export { default as AccordionItem } from './components/AccordionItem.svelte';
19
19
  export { default as Collapsible } from './components/Collapsible.svelte';
20
20
  export { default as Table } from './components/Table.svelte';
21
21
  export { default as DescriptionList } from './components/DescriptionList.svelte';
22
+ export { default as DiffView } from './components/DiffView.svelte';
23
+ export type { DiffChange } from './components/DiffView.svelte';
22
24
  export { default as Toolbar } from './components/Toolbar.svelte';
23
25
  export { default as TreeView } from './components/TreeView.svelte';
26
+ export { default as SortableList } from './components/SortableList.svelte';
27
+ export type { SortableItem, SortableMove } from './components/SortableList.svelte';
24
28
  export { default as SplitPane } from './components/SplitPane.svelte';
25
29
  export type { CellFit } from './components/Table.svelte';
26
30
  export type { SidebarState } from './components/AppShell.svelte';
@@ -35,6 +39,8 @@ export { default as Textarea } from './components/Textarea.svelte';
35
39
  export { default as NumberInput } from './components/NumberInput.svelte';
36
40
  export { default as SearchInput } from './components/SearchInput.svelte';
37
41
  export { default as FileInput } from './components/FileInput.svelte';
42
+ export { default as ColorField } from './components/ColorField.svelte';
43
+ export type { ColorSwatch } from './components/ColorField.svelte';
38
44
  export { default as Label } from './components/Label.svelte';
39
45
  export { default as Field } from './components/Field.svelte';
40
46
  export { default as FormMessage } from './components/FormMessage.svelte';
@@ -107,4 +113,4 @@ export { cn, type ClassValue } from './utils/cn.js';
107
113
  export { getTheme, getThemePreference, nextThemePreference, resolveTheme, setTheme, setThemePreference, systemTheme, themeBootScript, watchThemePreference, toggleTheme, watchSystemTheme, THEME_PREFERENCES, type Theme, type ThemePreference, } from './utils/theme.js';
108
114
  export * as motion from './motion.js';
109
115
  export type { Rung as MotionRung, Curve as MotionCurve } from './motion.js';
110
- export declare const VERSION = "0.42.1";
116
+ export declare const VERSION = "0.43.0";
package/dist/index.js CHANGED
@@ -20,8 +20,10 @@ export { default as AccordionItem } from './components/AccordionItem.svelte';
20
20
  export { default as Collapsible } from './components/Collapsible.svelte';
21
21
  export { default as Table } from './components/Table.svelte';
22
22
  export { default as DescriptionList } from './components/DescriptionList.svelte';
23
+ export { default as DiffView } from './components/DiffView.svelte';
23
24
  export { default as Toolbar } from './components/Toolbar.svelte';
24
25
  export { default as TreeView } from './components/TreeView.svelte';
26
+ export { default as SortableList } from './components/SortableList.svelte';
25
27
  export { default as SplitPane } from './components/SplitPane.svelte';
26
28
  // Stated once in an app's root layout, the format turns every PageShell's
27
29
  // title into the browser tab's. An app that never states one gets no title
@@ -36,6 +38,7 @@ export { default as Textarea } from './components/Textarea.svelte';
36
38
  export { default as NumberInput } from './components/NumberInput.svelte';
37
39
  export { default as SearchInput } from './components/SearchInput.svelte';
38
40
  export { default as FileInput } from './components/FileInput.svelte';
41
+ export { default as ColorField } from './components/ColorField.svelte';
39
42
  export { default as Label } from './components/Label.svelte';
40
43
  // The field furniture as a component, for a consumer composing a control the
41
44
  // kit does not ship. internal/field.ts stays private, and a page that could
@@ -119,4 +122,4 @@ export * as motion from './motion.js';
119
122
  // ── Version ────────────────────────────────────────────────────────────────
120
123
  // Generated from package.json by `pnpm version:sync`. Bump package.json, never
121
124
  // this line. The build and the test suite fail when the two disagree.
122
- export const VERSION = '0.42.1';
125
+ export const VERSION = '0.43.0';
@@ -0,0 +1,50 @@
1
+ /**
2
+ * The order arithmetic and the sentences of a list a person reorders.
3
+ *
4
+ * SortableList moves a row by pointer and by key, and both paths land here,
5
+ * so a drop and an Alt+Arrow press agree on where a row ends up and on what
6
+ * the live region says about it. Each sentence is the one a screen reader
7
+ * hears, so a table-driven test pins the words.
8
+ *
9
+ * Not exported from the package entry point: this is an implementation detail.
10
+ */
11
+ /**
12
+ * How long a finger rests on a handle before its row lifts. A shorter press
13
+ * is a swipe, and the page scrolls under it as it always did.
14
+ */
15
+ export declare const TOUCH_HOLD_MS = 250;
16
+ /** How far a mouse moves with the button down before a press becomes a drag. */
17
+ export declare const DRAG_SLOP_PX = 4;
18
+ /** How far a resting finger may wander before the press counts as a swipe. */
19
+ export declare const HOLD_SLOP_PX = 10;
20
+ /**
21
+ * How close a lifted row's pointer comes to the edge of the box that scrolls
22
+ * the list before that box scrolls toward it.
23
+ */
24
+ export declare const EDGE_SCROLL_PX = 40;
25
+ /** A consumer's answer to a move: true allows it, and a sentence refuses it and says why. */
26
+ export type MoveCheck = true | string;
27
+ /** What one attempt at a move comes to, with the sentence to announce. */
28
+ export type MoveResult<T> = {
29
+ moved: true;
30
+ items: T[];
31
+ to: number;
32
+ text: string;
33
+ } | {
34
+ moved: false;
35
+ text: string;
36
+ };
37
+ /**
38
+ * Where a row taken from `from` lands when it is dropped in `gap`, the space
39
+ * before row `gap`. The space after the last row is the gap numbered by the
40
+ * list's length. The two gaps beside the row leave it where it was.
41
+ */
42
+ export declare function landing(from: number, gap: number): number;
43
+ /**
44
+ * Moves the row at `from` to `to` in a copy of the list, once the consumer's
45
+ * check allows it. A move off either end changes nothing and says so, and a
46
+ * move to where the row already is changes nothing and says nothing.
47
+ */
48
+ export declare function attempt<T extends {
49
+ label: string;
50
+ }>(items: readonly T[], from: number, to: number, canMove?: (item: T, from: number, to: number) => MoveCheck): MoveResult<T>;
@@ -0,0 +1,58 @@
1
+ /**
2
+ * The order arithmetic and the sentences of a list a person reorders.
3
+ *
4
+ * SortableList moves a row by pointer and by key, and both paths land here,
5
+ * so a drop and an Alt+Arrow press agree on where a row ends up and on what
6
+ * the live region says about it. Each sentence is the one a screen reader
7
+ * hears, so a table-driven test pins the words.
8
+ *
9
+ * Not exported from the package entry point: this is an implementation detail.
10
+ */
11
+ /**
12
+ * How long a finger rests on a handle before its row lifts. A shorter press
13
+ * is a swipe, and the page scrolls under it as it always did.
14
+ */
15
+ export const TOUCH_HOLD_MS = 250;
16
+ /** How far a mouse moves with the button down before a press becomes a drag. */
17
+ export const DRAG_SLOP_PX = 4;
18
+ /** How far a resting finger may wander before the press counts as a swipe. */
19
+ export const HOLD_SLOP_PX = 10;
20
+ /**
21
+ * How close a lifted row's pointer comes to the edge of the box that scrolls
22
+ * the list before that box scrolls toward it.
23
+ */
24
+ export const EDGE_SCROLL_PX = 40;
25
+ /**
26
+ * Where a row taken from `from` lands when it is dropped in `gap`, the space
27
+ * before row `gap`. The space after the last row is the gap numbered by the
28
+ * list's length. The two gaps beside the row leave it where it was.
29
+ */
30
+ export function landing(from, gap) {
31
+ return gap > from ? gap - 1 : gap;
32
+ }
33
+ /**
34
+ * Moves the row at `from` to `to` in a copy of the list, once the consumer's
35
+ * check allows it. A move off either end changes nothing and says so, and a
36
+ * move to where the row already is changes nothing and says nothing.
37
+ */
38
+ export function attempt(items, from, to, canMove) {
39
+ const item = items[from];
40
+ if (to < 0)
41
+ return { moved: false, text: `${item.label} is already first.` };
42
+ if (to >= items.length)
43
+ return { moved: false, text: `${item.label} is already last.` };
44
+ if (to === from)
45
+ return { moved: false, text: '' };
46
+ const check = canMove?.(item, from, to) ?? true;
47
+ if (check !== true)
48
+ return { moved: false, text: check };
49
+ const next = items.slice();
50
+ next.splice(from, 1);
51
+ next.splice(to, 0, item);
52
+ return {
53
+ moved: true,
54
+ items: next,
55
+ to,
56
+ text: `${item.label} moved to position ${to + 1} of ${items.length}.`,
57
+ };
58
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lyeve-labs/ui-kit",
3
- "version": "0.42.1",
3
+ "version": "0.43.0",
4
4
  "description": "A clean, accessible, palette-aware Svelte 5 component library. The design system behind LyEve.",
5
5
  "license": "MIT",
6
6
  "author": "LyEve Labs <hello@lyeve.com>",