@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 +5 -5
- package/dist/components/ColorField.svelte +221 -0
- package/dist/components/ColorField.svelte.d.ts +38 -0
- package/dist/components/DiffView.svelte +152 -0
- package/dist/components/DiffView.svelte.d.ts +27 -0
- package/dist/components/PluginCart.svelte +4 -1
- package/dist/components/SortableList.svelte +511 -0
- package/dist/components/SortableList.svelte.d.ts +57 -0
- package/dist/index.d.ts +7 -1
- package/dist/index.js +4 -1
- package/dist/internal/reorder.d.ts +50 -0
- package/dist/internal/reorder.js +58 -0
- package/package.json +1 -1
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
|
-
- **
|
|
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>
|
|
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/ #
|
|
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"
|
|
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.
|
|
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.
|
|
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