@lyeve-labs/ui-kit 0.42.2 → 0.44.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/Alert.svelte +44 -8
- package/dist/components/Alert.svelte.d.ts +8 -0
- package/dist/components/Banner.svelte +41 -6
- package/dist/components/Banner.svelte.d.ts +8 -0
- package/dist/components/ColorField.svelte +221 -0
- package/dist/components/ColorField.svelte.d.ts +38 -0
- package/dist/components/CopyButton.svelte +8 -2
- package/dist/components/CopyField.svelte +4 -4
- package/dist/components/DiffView.svelte +152 -0
- package/dist/components/DiffView.svelte.d.ts +27 -0
- package/dist/components/FormMessage.svelte +11 -5
- package/dist/components/Logo.svelte +1 -1
- package/dist/components/SortableList.svelte +520 -0
- package/dist/components/SortableList.svelte.d.ts +57 -0
- package/dist/components/Tabs.svelte +9 -5
- package/dist/components/Toaster.svelte +2 -2
- 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
|
|
@@ -33,6 +33,14 @@
|
|
|
33
33
|
* being true after five seconds.
|
|
34
34
|
*/
|
|
35
35
|
autoDismiss?: number | boolean;
|
|
36
|
+
/**
|
|
37
|
+
* Announces the message when it appears, and every change to it after,
|
|
38
|
+
* without clearing it. For a result that stays on screen, such as a
|
|
39
|
+
* confirmation that replaces a form or a status that a poll updates.
|
|
40
|
+
* A message without this or `autoDismiss` states a standing condition
|
|
41
|
+
* and is read out by nobody, so a page load says nothing.
|
|
42
|
+
*/
|
|
43
|
+
announce?: boolean;
|
|
36
44
|
ondismiss?: () => void;
|
|
37
45
|
class?: string;
|
|
38
46
|
children?: Snippet;
|
|
@@ -43,6 +51,7 @@
|
|
|
43
51
|
title = undefined,
|
|
44
52
|
dismissible = false,
|
|
45
53
|
autoDismiss = false,
|
|
54
|
+
announce = false,
|
|
46
55
|
ondismiss = undefined,
|
|
47
56
|
class: klass = '',
|
|
48
57
|
children,
|
|
@@ -64,6 +73,8 @@
|
|
|
64
73
|
* announced every tone assertively, so a green confirmation cut across
|
|
65
74
|
* whatever was being read. An alert that also clears itself must not be
|
|
66
75
|
* assertive: it would interrupt to say something and then take it away.
|
|
76
|
+
* The polite branch takes no role: browsers expose role="status" as a status
|
|
77
|
+
* bar, and Orca speaks nothing inserted into one.
|
|
67
78
|
*/
|
|
68
79
|
const delay = $derived(autoDismiss === true ? DISMISS_AFTER : Number(autoDismiss) || 0);
|
|
69
80
|
|
|
@@ -72,6 +83,28 @@
|
|
|
72
83
|
/** An alert that can close itself can also be closed by hand. */
|
|
73
84
|
const closable = $derived(dismissible || delay > 0);
|
|
74
85
|
|
|
86
|
+
/**
|
|
87
|
+
* A live region reports a change to what it holds, and an outcome alert
|
|
88
|
+
* arrives inside `{#if form}` already holding its text, so nothing would be
|
|
89
|
+
* read out. An alert that reports an outcome, one that clears itself or
|
|
90
|
+
* takes `announce`, mounts without its text and takes it two frames later,
|
|
91
|
+
* as FormMessage does. It is atomic, as role="status" made it, so a change
|
|
92
|
+
* reads the whole message. One that states a standing condition is no live
|
|
93
|
+
* region at all: it must not be read out because the page loaded, and
|
|
94
|
+
* Chrome reads a polite region that a client-side navigation inserts. A
|
|
95
|
+
* failure or a warning that stays is the alert branch and is never deferred.
|
|
96
|
+
*/
|
|
97
|
+
const outcome = $derived(!live && (delay > 0 || announce));
|
|
98
|
+
let filled = $state(typeof window === 'undefined');
|
|
99
|
+
|
|
100
|
+
$effect(() => {
|
|
101
|
+
if (!outcome) return;
|
|
102
|
+
let frame = requestAnimationFrame(() => {
|
|
103
|
+
frame = requestAnimationFrame(() => (filled = true));
|
|
104
|
+
});
|
|
105
|
+
return () => cancelAnimationFrame(frame);
|
|
106
|
+
});
|
|
107
|
+
|
|
75
108
|
let closed = $state(false);
|
|
76
109
|
/** The pointer is over the alert, or focus is inside it. */
|
|
77
110
|
let held = $state(false);
|
|
@@ -101,8 +134,9 @@
|
|
|
101
134
|
<div
|
|
102
135
|
transition:motion.toast|global
|
|
103
136
|
class="flex items-start gap-3 rounded-lg border px-4 py-3 {tones[t].wrap} {klass}"
|
|
104
|
-
role={live ? 'alert' :
|
|
105
|
-
aria-live={live ? 'assertive' : 'polite'}
|
|
137
|
+
role={live ? 'alert' : undefined}
|
|
138
|
+
aria-live={live ? 'assertive' : outcome ? 'polite' : undefined}
|
|
139
|
+
aria-atomic={outcome ? 'true' : undefined}
|
|
106
140
|
onmouseenter={closable ? () => (held = true) : undefined}
|
|
107
141
|
onmouseleave={closable ? () => (held = false) : undefined}
|
|
108
142
|
onfocusin={closable ? () => (held = true) : undefined}
|
|
@@ -127,12 +161,14 @@
|
|
|
127
161
|
<path d={TONE_GLYPH[t]} />
|
|
128
162
|
</svg>
|
|
129
163
|
</span>
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
164
|
+
{#if filled || !outcome}
|
|
165
|
+
<div class="flex-1 min-w-0">
|
|
166
|
+
{#if title}<p class="text-sm font-semibold text-fg">{title}</p>{/if}
|
|
167
|
+
{#if children}<div class="text-sm text-muted {title ? 'mt-0.5' : ''}">
|
|
168
|
+
{@render children()}
|
|
169
|
+
</div>{/if}
|
|
170
|
+
</div>
|
|
171
|
+
{/if}
|
|
136
172
|
{#if closable}
|
|
137
173
|
<button
|
|
138
174
|
type="button"
|
|
@@ -14,6 +14,14 @@ interface Props {
|
|
|
14
14
|
* being true after five seconds.
|
|
15
15
|
*/
|
|
16
16
|
autoDismiss?: number | boolean;
|
|
17
|
+
/**
|
|
18
|
+
* Announces the message when it appears, and every change to it after,
|
|
19
|
+
* without clearing it. For a result that stays on screen, such as a
|
|
20
|
+
* confirmation that replaces a form or a status that a poll updates.
|
|
21
|
+
* A message without this or `autoDismiss` states a standing condition
|
|
22
|
+
* and is read out by nobody, so a page load says nothing.
|
|
23
|
+
*/
|
|
24
|
+
announce?: boolean;
|
|
17
25
|
ondismiss?: () => void;
|
|
18
26
|
class?: string;
|
|
19
27
|
children?: Snippet;
|
|
@@ -23,6 +23,14 @@
|
|
|
23
23
|
* condition, which does not stop being true after five seconds.
|
|
24
24
|
*/
|
|
25
25
|
autoDismiss?: number | boolean;
|
|
26
|
+
/**
|
|
27
|
+
* Announces the message when it appears, and every change to it after,
|
|
28
|
+
* without clearing it. For a result that stays on screen, such as a
|
|
29
|
+
* confirmation that replaces a form or a status that a poll updates.
|
|
30
|
+
* A message without this or `autoDismiss` states a standing condition
|
|
31
|
+
* and is read out by nobody, so a page load says nothing.
|
|
32
|
+
*/
|
|
33
|
+
announce?: boolean;
|
|
26
34
|
ondismiss?: () => void;
|
|
27
35
|
class?: string;
|
|
28
36
|
children: Snippet;
|
|
@@ -33,6 +41,7 @@
|
|
|
33
41
|
tone = 'brand',
|
|
34
42
|
dismissible = false,
|
|
35
43
|
autoDismiss = false,
|
|
44
|
+
announce = false,
|
|
36
45
|
ondismiss = undefined,
|
|
37
46
|
class: cls = '',
|
|
38
47
|
children,
|
|
@@ -73,9 +82,32 @@
|
|
|
73
82
|
* A failure and a warning interrupt, because the reader has to act, as
|
|
74
83
|
* Alert does. Anything else waits for a pause. A banner that clears itself
|
|
75
84
|
* waits too: interrupting to say something and then taking it away leaves
|
|
76
|
-
* the reader with nothing to act on.
|
|
85
|
+
* the reader with nothing to act on. The polite branch takes no role, as
|
|
86
|
+
* Alert's does, because Orca speaks nothing inserted into a status role.
|
|
77
87
|
*/
|
|
78
88
|
const live = $derived((t === 'danger' || t === 'warn') && delay <= 0);
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* A live region reports a change to what it holds, and an outcome banner
|
|
92
|
+
* arrives inside `{#if form}` already holding its text, so nothing would be
|
|
93
|
+
* read out. A banner that reports an outcome, one that clears itself or
|
|
94
|
+
* takes `announce`, mounts without its text and takes it two frames later,
|
|
95
|
+
* as FormMessage does. It is atomic, as role="status" made it, so a change
|
|
96
|
+
* reads the whole message. One that states a standing condition is no live
|
|
97
|
+
* region at all: it must not be read out because the page loaded, and
|
|
98
|
+
* Chrome reads a polite region that a client-side navigation inserts. A
|
|
99
|
+
* failure or a warning that stays is the alert branch and is never deferred.
|
|
100
|
+
*/
|
|
101
|
+
const outcome = $derived(!live && (delay > 0 || announce));
|
|
102
|
+
let filled = $state(typeof window === 'undefined');
|
|
103
|
+
|
|
104
|
+
$effect(() => {
|
|
105
|
+
if (!outcome) return;
|
|
106
|
+
let frame = requestAnimationFrame(() => {
|
|
107
|
+
frame = requestAnimationFrame(() => (filled = true));
|
|
108
|
+
});
|
|
109
|
+
return () => cancelAnimationFrame(frame);
|
|
110
|
+
});
|
|
79
111
|
</script>
|
|
80
112
|
|
|
81
113
|
{#if visible}
|
|
@@ -85,17 +117,20 @@
|
|
|
85
117
|
rather than vanishing in one frame. -->
|
|
86
118
|
<div
|
|
87
119
|
transition:motion.toast|global
|
|
88
|
-
role={live ? 'alert' :
|
|
89
|
-
aria-live={live ? 'assertive' : 'polite'}
|
|
120
|
+
role={live ? 'alert' : undefined}
|
|
121
|
+
aria-live={live ? 'assertive' : outcome ? 'polite' : undefined}
|
|
122
|
+
aria-atomic={outcome ? 'true' : undefined}
|
|
90
123
|
class="relative flex items-center gap-3 border-b px-4 py-3 text-sm {tones[t]} {cls}"
|
|
91
124
|
onmouseenter={closable ? () => (held = true) : undefined}
|
|
92
125
|
onmouseleave={closable ? () => (held = false) : undefined}
|
|
93
126
|
onfocusin={closable ? () => (held = true) : undefined}
|
|
94
127
|
onfocusout={closable ? () => (held = false) : undefined}
|
|
95
128
|
>
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
129
|
+
{#if filled || !outcome}
|
|
130
|
+
<div class="flex flex-1 items-center justify-center gap-3 text-fg">
|
|
131
|
+
{@render children()}
|
|
132
|
+
</div>
|
|
133
|
+
{/if}
|
|
99
134
|
|
|
100
135
|
{#if action}
|
|
101
136
|
<div class="shrink-0">{@render action()}</div>
|
|
@@ -12,6 +12,14 @@ interface Props {
|
|
|
12
12
|
* condition, which does not stop being true after five seconds.
|
|
13
13
|
*/
|
|
14
14
|
autoDismiss?: number | boolean;
|
|
15
|
+
/**
|
|
16
|
+
* Announces the message when it appears, and every change to it after,
|
|
17
|
+
* without clearing it. For a result that stays on screen, such as a
|
|
18
|
+
* confirmation that replaces a form or a status that a poll updates.
|
|
19
|
+
* A message without this or `autoDismiss` states a standing condition
|
|
20
|
+
* and is read out by nobody, so a page load says nothing.
|
|
21
|
+
*/
|
|
22
|
+
announce?: boolean;
|
|
15
23
|
ondismiss?: () => void;
|
|
16
24
|
class?: string;
|
|
17
25
|
children: Snippet;
|
|
@@ -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;
|
|
@@ -73,7 +73,13 @@
|
|
|
73
73
|
The live region is mounted for the life of the button and empty at rest.
|
|
74
74
|
Rendering it only after a copy announces nothing: assistive technology watches
|
|
75
75
|
an existing region for a change, and a region that arrives already holding its
|
|
76
|
-
text is not a change. Toaster carries the same rule for the same reason.
|
|
76
|
+
text is not a change. Toaster carries the same rule for the same reason. It
|
|
77
|
+
stays a span, so a CopyButton inside a paragraph keeps that paragraph whole.
|
|
78
|
+
It is never role="status", which browsers expose as a status bar and Orca
|
|
79
|
+
never speaks. It is role="generic": Firefox exposes a bare span as static
|
|
80
|
+
text, created only once it holds the message, and Orca drops a sentence
|
|
81
|
+
that comes within a tenth of a second of that arrival unless its region is
|
|
82
|
+
a section, which generic is.
|
|
77
83
|
|
|
78
84
|
The gap exists only while there is a message, so an idle button is the width
|
|
79
85
|
of its icon and a row of them lines up.
|
|
@@ -86,7 +92,7 @@
|
|
|
86
92
|
{/if}
|
|
87
93
|
|
|
88
94
|
<span
|
|
89
|
-
role="
|
|
95
|
+
role="generic"
|
|
90
96
|
aria-live="polite"
|
|
91
97
|
aria-atomic="true"
|
|
92
98
|
class="text-xs {copier.status === 'failed' ? 'text-danger' : 'text-success'}">{message}</span
|
|
@@ -180,16 +180,16 @@
|
|
|
180
180
|
|
|
181
181
|
<!-- Mounted empty for the life of the field, so a copy is a change a screen
|
|
182
182
|
reader announces rather than a region that arrives already full. The
|
|
183
|
-
check is the sighted confirmation. Only a failure is shown in words.
|
|
184
|
-
|
|
185
|
-
|
|
183
|
+
check is the sighted confirmation. Only a failure is shown in words. A
|
|
184
|
+
div with no role, as Toaster's is, so Orca speaks it. -->
|
|
185
|
+
<div
|
|
186
186
|
aria-live="polite"
|
|
187
187
|
aria-atomic="true"
|
|
188
188
|
id={failed ? `${fieldId}-error` : undefined}
|
|
189
189
|
class={failed ? FIELD_ERROR : 'sr-only'}
|
|
190
190
|
>
|
|
191
191
|
{copier.status === 'copied' ? copiedLabel : failed ? COPY_FAILED_MESSAGE : ''}
|
|
192
|
-
</
|
|
192
|
+
</div>
|
|
193
193
|
{#if hint && !failed}
|
|
194
194
|
<p id="{fieldId}-hint" class={FIELD_HINT}>{hint}</p>
|
|
195
195
|
{/if}
|