@lyeve-labs/ui-kit 0.11.2 → 0.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/dist/components/AccordionItem.svelte +1 -1
- package/dist/components/Autocomplete.svelte +191 -125
- package/dist/components/Autocomplete.svelte.d.ts +29 -8
- package/dist/components/Button.svelte +26 -4
- package/dist/components/Card.svelte +61 -3
- package/dist/components/Card.svelte.d.ts +24 -2
- package/dist/components/Checkbox.svelte +174 -59
- package/dist/components/Checkbox.svelte.d.ts +20 -3
- package/dist/components/CheckboxGroup.svelte +162 -0
- package/dist/components/CheckboxGroup.svelte.d.ts +51 -0
- package/dist/components/Collapsible.svelte +142 -0
- package/dist/components/Collapsible.svelte.d.ts +32 -0
- package/dist/components/CopyButton.svelte +126 -0
- package/dist/components/CopyButton.svelte.d.ts +14 -0
- package/dist/components/DatePicker.svelte +48 -6
- package/dist/components/DateTimePicker.svelte +337 -0
- package/dist/components/DateTimePicker.svelte.d.ts +26 -0
- package/dist/components/DescriptionList.svelte +78 -0
- package/dist/components/DescriptionList.svelte.d.ts +34 -0
- package/dist/components/Drawer.svelte +15 -4
- package/dist/components/Field.svelte +104 -0
- package/dist/components/Field.svelte.d.ts +46 -0
- package/dist/components/FileInput.svelte +5 -2
- package/dist/components/FormMessage.svelte +85 -0
- package/dist/components/FormMessage.svelte.d.ts +11 -0
- package/dist/components/Input.svelte +1 -1
- package/dist/components/Label.svelte +7 -1
- package/dist/components/Label.svelte.d.ts +6 -0
- package/dist/components/Modal.svelte +25 -8
- package/dist/components/MultiSelect.svelte +199 -109
- package/dist/components/MultiSelect.svelte.d.ts +22 -9
- package/dist/components/NumberInput.svelte +8 -4
- package/dist/components/PageHeader.svelte +37 -4
- package/dist/components/PageHeader.svelte.d.ts +15 -0
- package/dist/components/PageShell.svelte +85 -0
- package/dist/components/PageShell.svelte.d.ts +38 -0
- package/dist/components/Pagination.svelte +58 -17
- package/dist/components/Panel.svelte +101 -0
- package/dist/components/Panel.svelte.d.ts +39 -0
- package/dist/components/PasswordInput.svelte +139 -0
- package/dist/components/PasswordInput.svelte.d.ts +29 -0
- package/dist/components/Radio.svelte +152 -32
- package/dist/components/Radio.svelte.d.ts +16 -1
- package/dist/components/RadioGroup.svelte +118 -71
- package/dist/components/RadioGroup.svelte.d.ts +39 -9
- package/dist/components/SectionHeading.svelte +39 -0
- package/dist/components/SectionHeading.svelte.d.ts +21 -0
- package/dist/components/SegmentedControl.svelte +194 -0
- package/dist/components/SegmentedControl.svelte.d.ts +55 -0
- package/dist/components/Select.svelte +471 -46
- package/dist/components/Select.svelte.d.ts +95 -6
- package/dist/components/SidebarNav.svelte +259 -0
- package/dist/components/SidebarNav.svelte.d.ts +17 -0
- package/dist/components/Stat.svelte +53 -2
- package/dist/components/Stat.svelte.d.ts +31 -0
- package/dist/components/Textarea.svelte +1 -1
- package/dist/components/TimePicker.svelte +480 -0
- package/dist/components/TimePicker.svelte.d.ts +23 -0
- package/dist/components/Toaster.svelte +9 -2
- package/dist/components/Toggle.svelte +5 -1
- package/dist/components/Toggle.svelte.d.ts +2 -0
- package/dist/components/Toolbar.svelte +39 -0
- package/dist/components/Toolbar.svelte.d.ts +26 -0
- package/dist/components/Tooltip.svelte +48 -12
- package/dist/components/TreeView.svelte +339 -0
- package/dist/components/TreeView.svelte.d.ts +37 -0
- package/dist/components/dialog/Dialog.svelte +15 -58
- package/dist/components/dialog/dialog-manager.svelte.d.ts +2 -2
- package/dist/components/dialog/dialog-manager.svelte.js +21 -21
- package/dist/index.d.ts +25 -1
- package/dist/index.js +20 -1
- package/dist/internal/calendar.d.ts +119 -0
- package/dist/internal/calendar.js +225 -0
- package/dist/internal/choice.d.ts +136 -0
- package/dist/internal/choice.js +179 -0
- package/dist/internal/field.d.ts +31 -0
- package/dist/internal/field.js +42 -1
- package/dist/internal/filter.d.ts +80 -0
- package/dist/internal/filter.js +80 -0
- package/dist/internal/layout.d.ts +119 -0
- package/dist/internal/layout.js +132 -0
- package/dist/internal/listbox.svelte.d.ts +77 -0
- package/dist/internal/listbox.svelte.js +438 -0
- package/dist/internal/nav-expansion.svelte.d.ts +36 -0
- package/dist/internal/nav-expansion.svelte.js +144 -0
- package/dist/internal/nav-tree.d.ts +68 -0
- package/dist/internal/nav-tree.js +102 -0
- package/dist/internal/overlay.d.ts +25 -0
- package/dist/internal/overlay.js +92 -0
- package/dist/internal/panel.d.ts +100 -0
- package/dist/internal/panel.js +109 -0
- package/dist/internal/rollup.d.ts +52 -0
- package/dist/internal/rollup.js +67 -0
- package/dist/internal/time.d.ts +103 -0
- package/dist/internal/time.js +166 -0
- package/dist/internal/tree.d.ts +86 -0
- package/dist/internal/tree.js +111 -0
- package/dist/styles/theme.css +66 -25
- package/package.json +4 -2
- package/src/lib/styles/theme.css +66 -25
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The page frame: the gutter, the content cap, the centring and the section
|
|
3
|
+
* rhythm, all owned by the shell so a page cannot pick its own.
|
|
4
|
+
*
|
|
5
|
+
* Nothing in the library owned the frame, so every page built one. Across one
|
|
6
|
+
* app 31 of 35 pages restate the gutter in four spellings, five content caps
|
|
7
|
+
* are in use with no rule for choosing between them, and one page renders
|
|
8
|
+
* against the left edge of the window because it set a cap and forgot
|
|
9
|
+
* `mx-auto`. Centring and `w-full` come with the gutter here, in PAGE_PAD, so
|
|
10
|
+
* there is no order of props that leaves them out.
|
|
11
|
+
*
|
|
12
|
+
* The shell renders no landmark of its own. An app shell already owns `main`,
|
|
13
|
+
* `nav` and the rest, and a second `main` changes what a landmark query
|
|
14
|
+
* matches for every assistive technology reading the page.
|
|
15
|
+
*/
|
|
16
|
+
import type { Snippet } from 'svelte';
|
|
17
|
+
import { type PageWidth } from '../internal/layout.js';
|
|
18
|
+
interface Props {
|
|
19
|
+
/** The page's only h1, rendered through PageHeader. */
|
|
20
|
+
title: string;
|
|
21
|
+
description?: string;
|
|
22
|
+
/** Content cap. narrow, default, wide, full. */
|
|
23
|
+
width?: PageWidth;
|
|
24
|
+
/**
|
|
25
|
+
* A full-height page that manages its own scrolling, for instance a split
|
|
26
|
+
* pane or a canvas. No gutter, no cap, and the title renders compact.
|
|
27
|
+
*/
|
|
28
|
+
fill?: boolean;
|
|
29
|
+
/** Rendered above the title at one fixed distance. */
|
|
30
|
+
breadcrumb?: Snippet;
|
|
31
|
+
/** Right-aligned controls in the title row. */
|
|
32
|
+
actions?: Snippet;
|
|
33
|
+
class?: string;
|
|
34
|
+
children: Snippet;
|
|
35
|
+
}
|
|
36
|
+
declare const PageShell: import("svelte").Component<Props, {}, "">;
|
|
37
|
+
type PageShell = ReturnType<typeof PageShell>;
|
|
38
|
+
export default PageShell;
|
|
@@ -19,38 +19,63 @@
|
|
|
19
19
|
let from = $derived(Math.min((safePage - 1) * perPage + 1, safeTotal));
|
|
20
20
|
let to = $derived(Math.min(safePage * perPage, safeTotal));
|
|
21
21
|
|
|
22
|
-
|
|
22
|
+
/**
|
|
23
|
+
* The elided run is the sentinel string 'gap', never the horizontal-ellipsis
|
|
24
|
+
* character.
|
|
25
|
+
*
|
|
26
|
+
* The glyph used to be both the value here and the text of the span that
|
|
27
|
+
* rendered it, and that span carried no aria-hidden, so a screen reader read
|
|
28
|
+
* "horizontal ellipsis" aloud between two page numbers. The gap is decoration:
|
|
29
|
+
* it says nothing the page numbers either side do not already say.
|
|
30
|
+
*/
|
|
31
|
+
type PageSlot = number | 'gap';
|
|
32
|
+
|
|
33
|
+
function pageNumbers(current: number, last: number): PageSlot[] {
|
|
23
34
|
if (last <= 7) return Array.from({ length: last }, (_, i) => i + 1);
|
|
24
|
-
const pages:
|
|
25
|
-
if (current > 3) pages.push('
|
|
35
|
+
const pages: PageSlot[] = [1];
|
|
36
|
+
if (current > 3) pages.push('gap');
|
|
26
37
|
for (let i = Math.max(2, current - 1); i <= Math.min(last - 1, current + 1); i++) {
|
|
27
38
|
pages.push(i);
|
|
28
39
|
}
|
|
29
|
-
if (current < last - 2) pages.push('
|
|
40
|
+
if (current < last - 2) pages.push('gap');
|
|
30
41
|
pages.push(last);
|
|
31
42
|
return pages;
|
|
32
43
|
}
|
|
33
44
|
|
|
45
|
+
/**
|
|
46
|
+
* A page number is unique in the list, so it is its own key. Both gaps are
|
|
47
|
+
* spelled 'gap', so they take their position as well. Keying on the index
|
|
48
|
+
* alone made every slot change identity when a page was inserted ahead of it.
|
|
49
|
+
*/
|
|
50
|
+
function slotKey(slot: PageSlot, index: number): string {
|
|
51
|
+
return slot === 'gap' ? `gap-${index}` : `page-${slot}`;
|
|
52
|
+
}
|
|
53
|
+
|
|
34
54
|
let nums = $derived(pageNumbers(safePage, totalPages));
|
|
35
55
|
|
|
36
56
|
const btnBase =
|
|
37
57
|
'inline-flex items-center justify-center w-7 h-7 rounded text-xs font-medium transition-colors duration-150';
|
|
38
58
|
</script>
|
|
39
59
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
60
|
+
<!-- The summary renders whenever there is a count to state, and the page
|
|
61
|
+
buttons only when there is more than one page. The whole component used to
|
|
62
|
+
be behind `totalPages > 1`, and an empty list has one page, so the 'No
|
|
63
|
+
results' line below could never appear and a single page of results showed
|
|
64
|
+
no count at all. -->
|
|
65
|
+
<div class="flex flex-wrap items-center gap-x-3 gap-y-2 {cls}">
|
|
66
|
+
<span class="text-xs text-faint shrink-0">
|
|
67
|
+
{safeTotal === 0 ? 'No results' : `${from} to ${to} of ${safeTotal}`}
|
|
68
|
+
</span>
|
|
45
69
|
|
|
46
|
-
|
|
70
|
+
{#if totalPages > 1}
|
|
71
|
+
<div class="flex flex-wrap items-center gap-0.5 ml-auto">
|
|
47
72
|
<button
|
|
48
73
|
type="button"
|
|
49
74
|
disabled={safePage <= 1}
|
|
50
75
|
onclick={() => onchange(safePage - 1)}
|
|
51
76
|
aria-label="Previous page"
|
|
52
77
|
class="{btnBase} text-muted hover:text-fg hover:bg-surface-2
|
|
53
|
-
|
|
78
|
+
disabled:opacity-30 disabled:cursor-not-allowed"
|
|
54
79
|
>
|
|
55
80
|
<svg
|
|
56
81
|
width="14"
|
|
@@ -67,16 +92,32 @@
|
|
|
67
92
|
</svg>
|
|
68
93
|
</button>
|
|
69
94
|
|
|
70
|
-
{#each nums as n, i (i)}
|
|
71
|
-
{#if n === '
|
|
72
|
-
|
|
95
|
+
{#each nums as n, i (slotKey(n, i))}
|
|
96
|
+
{#if n === 'gap'}
|
|
97
|
+
<!-- Three drawn dots, not the ellipsis character: a font glyph lands
|
|
98
|
+
at a different optical weight from every other icon here, and the
|
|
99
|
+
character is what the screen reader was speaking. -->
|
|
100
|
+
<span class="inline-flex w-7 items-center justify-center text-faint" aria-hidden="true">
|
|
101
|
+
<svg
|
|
102
|
+
width="14"
|
|
103
|
+
height="14"
|
|
104
|
+
viewBox="0 0 24 24"
|
|
105
|
+
fill="none"
|
|
106
|
+
stroke="currentColor"
|
|
107
|
+
stroke-width="2"
|
|
108
|
+
stroke-linecap="round"
|
|
109
|
+
stroke-linejoin="round"
|
|
110
|
+
>
|
|
111
|
+
<path d="M5 12h.01M12 12h.01M19 12h.01" />
|
|
112
|
+
</svg>
|
|
113
|
+
</span>
|
|
73
114
|
{:else}
|
|
74
115
|
<button
|
|
75
116
|
type="button"
|
|
76
117
|
onclick={() => onchange(n as number)}
|
|
77
118
|
aria-current={safePage === n ? 'page' : undefined}
|
|
78
119
|
class="{btnBase}
|
|
79
|
-
|
|
120
|
+
{safePage === n
|
|
80
121
|
? 'bg-brand text-ink'
|
|
81
122
|
: 'text-muted hover:text-fg hover:bg-surface-2'}"
|
|
82
123
|
>
|
|
@@ -108,5 +149,5 @@
|
|
|
108
149
|
</svg>
|
|
109
150
|
</button>
|
|
110
151
|
</div>
|
|
111
|
-
|
|
112
|
-
|
|
152
|
+
{/if}
|
|
153
|
+
</div>
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* A grouping inside a page: a heading, an optional description and a body,
|
|
4
|
+
* on a lighter surface than Card.
|
|
5
|
+
*
|
|
6
|
+
* An audit counted 63 hand-rolled card surfaces across 31 files in nine
|
|
7
|
+
* paddings while Card itself was used zero times. Many of them did not want
|
|
8
|
+
* a Card. They wanted a labelled group inside a page, and reaching for a
|
|
9
|
+
* card meant a box on a box, so each one was written out by hand and each
|
|
10
|
+
* one picked its own padding. Panel is that group: it takes Card's surface
|
|
11
|
+
* and Card's padding scale from the shared layout contract, so a panel and a
|
|
12
|
+
* card sitting on the same page agree, and it adds a `plain` variant for the
|
|
13
|
+
* groups that wanted the heading and the rhythm without the box at all.
|
|
14
|
+
*
|
|
15
|
+
* The heading is a real h2 or h3. A page's outline is what a screen reader
|
|
16
|
+
* navigates by, and a styled div appears in no heading list.
|
|
17
|
+
*/
|
|
18
|
+
import type { Component, Snippet } from 'svelte';
|
|
19
|
+
import { CARD_PAD, CARD_SURFACE, sectionHeading } from '../internal/layout.js';
|
|
20
|
+
|
|
21
|
+
type Pad = 'none' | 'sm' | 'md';
|
|
22
|
+
type Variant = 'bordered' | 'plain';
|
|
23
|
+
|
|
24
|
+
interface Props {
|
|
25
|
+
heading?: string;
|
|
26
|
+
/** 2 directly under a page title, 3 inside a card or another panel. */
|
|
27
|
+
headingLevel?: 2 | 3;
|
|
28
|
+
description?: string;
|
|
29
|
+
/** Drawn before the heading. */
|
|
30
|
+
icon?: Component<{ size?: number; class?: string }>;
|
|
31
|
+
pad?: Pad;
|
|
32
|
+
/** A quiet variant with no border, for a grouping that needs separation but not a box. */
|
|
33
|
+
variant?: Variant;
|
|
34
|
+
class?: string;
|
|
35
|
+
actions?: Snippet;
|
|
36
|
+
children: Snippet;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
let {
|
|
40
|
+
heading = undefined,
|
|
41
|
+
headingLevel = 3,
|
|
42
|
+
description = undefined,
|
|
43
|
+
icon = undefined,
|
|
44
|
+
pad = 'md',
|
|
45
|
+
variant = 'bordered',
|
|
46
|
+
class: klass = '',
|
|
47
|
+
actions,
|
|
48
|
+
children,
|
|
49
|
+
}: Props = $props();
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* `plain` states nothing at all rather than turning the border off.
|
|
53
|
+
*
|
|
54
|
+
* A `border-0` after `border border-line` is two utilities for one property,
|
|
55
|
+
* and which of them wins is decided by the order Tailwind emits them and not
|
|
56
|
+
* by the order they were written here.
|
|
57
|
+
*/
|
|
58
|
+
const SURFACE: Record<Variant, string> = {
|
|
59
|
+
bordered: CARD_SURFACE,
|
|
60
|
+
plain: '',
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Padding and variant stay independent, so `plain` is not silently also a
|
|
65
|
+
* padding change. A plain panel usually takes `pad="none"`: with no box to
|
|
66
|
+
* inset from, an inset only pushes its heading out of line with the page.
|
|
67
|
+
*/
|
|
68
|
+
const surface = $derived(`${SURFACE[variant]} ${CARD_PAD[pad]}`.trim());
|
|
69
|
+
</script>
|
|
70
|
+
|
|
71
|
+
<div class="flex flex-col gap-stack {surface} {klass}">
|
|
72
|
+
{#snippet headingText()}
|
|
73
|
+
{#if icon}
|
|
74
|
+
{@const Icon = icon}
|
|
75
|
+
<span class="shrink-0 text-muted" aria-hidden="true"><Icon size={16} /></span>
|
|
76
|
+
{/if}
|
|
77
|
+
{heading}
|
|
78
|
+
{/snippet}
|
|
79
|
+
|
|
80
|
+
{#if heading || description || actions}
|
|
81
|
+
<div class="flex flex-wrap items-start justify-between gap-4">
|
|
82
|
+
<div class="flex min-w-0 flex-col gap-1">
|
|
83
|
+
<!-- Two branches rather than one dynamic element: the level is a
|
|
84
|
+
document structure decision, and both headings this can produce
|
|
85
|
+
should be readable in the file. -->
|
|
86
|
+
{#if heading && headingLevel === 2}
|
|
87
|
+
<h2 class="{sectionHeading(2)} flex items-center gap-2">{@render headingText()}</h2>
|
|
88
|
+
{:else if heading}
|
|
89
|
+
<h3 class="{sectionHeading(3)} flex items-center gap-2">{@render headingText()}</h3>
|
|
90
|
+
{/if}
|
|
91
|
+
{#if description}<p class="text-sm text-muted">{description}</p>{/if}
|
|
92
|
+
</div>
|
|
93
|
+
|
|
94
|
+
{#if actions}
|
|
95
|
+
<div class="flex shrink-0 items-center gap-2">{@render actions()}</div>
|
|
96
|
+
{/if}
|
|
97
|
+
</div>
|
|
98
|
+
{/if}
|
|
99
|
+
|
|
100
|
+
<div>{@render children()}</div>
|
|
101
|
+
</div>
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A grouping inside a page: a heading, an optional description and a body,
|
|
3
|
+
* on a lighter surface than Card.
|
|
4
|
+
*
|
|
5
|
+
* An audit counted 63 hand-rolled card surfaces across 31 files in nine
|
|
6
|
+
* paddings while Card itself was used zero times. Many of them did not want
|
|
7
|
+
* a Card. They wanted a labelled group inside a page, and reaching for a
|
|
8
|
+
* card meant a box on a box, so each one was written out by hand and each
|
|
9
|
+
* one picked its own padding. Panel is that group: it takes Card's surface
|
|
10
|
+
* and Card's padding scale from the shared layout contract, so a panel and a
|
|
11
|
+
* card sitting on the same page agree, and it adds a `plain` variant for the
|
|
12
|
+
* groups that wanted the heading and the rhythm without the box at all.
|
|
13
|
+
*
|
|
14
|
+
* The heading is a real h2 or h3. A page's outline is what a screen reader
|
|
15
|
+
* navigates by, and a styled div appears in no heading list.
|
|
16
|
+
*/
|
|
17
|
+
import type { Component, Snippet } from 'svelte';
|
|
18
|
+
type Pad = 'none' | 'sm' | 'md';
|
|
19
|
+
type Variant = 'bordered' | 'plain';
|
|
20
|
+
interface Props {
|
|
21
|
+
heading?: string;
|
|
22
|
+
/** 2 directly under a page title, 3 inside a card or another panel. */
|
|
23
|
+
headingLevel?: 2 | 3;
|
|
24
|
+
description?: string;
|
|
25
|
+
/** Drawn before the heading. */
|
|
26
|
+
icon?: Component<{
|
|
27
|
+
size?: number;
|
|
28
|
+
class?: string;
|
|
29
|
+
}>;
|
|
30
|
+
pad?: Pad;
|
|
31
|
+
/** A quiet variant with no border, for a grouping that needs separation but not a box. */
|
|
32
|
+
variant?: Variant;
|
|
33
|
+
class?: string;
|
|
34
|
+
actions?: Snippet;
|
|
35
|
+
children: Snippet;
|
|
36
|
+
}
|
|
37
|
+
declare const Panel: Component<Props, {}, "">;
|
|
38
|
+
type Panel = ReturnType<typeof Panel>;
|
|
39
|
+
export default Panel;
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { Eye, EyeOff } from '@lucide/svelte';
|
|
3
|
+
import {
|
|
4
|
+
CONTROL_BASE,
|
|
5
|
+
FIELD_ERROR,
|
|
6
|
+
FIELD_HINT,
|
|
7
|
+
FIELD_LABEL,
|
|
8
|
+
FIELD_WRAP,
|
|
9
|
+
controlBorder,
|
|
10
|
+
describedBy,
|
|
11
|
+
} from '../internal/field.js';
|
|
12
|
+
|
|
13
|
+
interface Props {
|
|
14
|
+
value?: string;
|
|
15
|
+
id?: string;
|
|
16
|
+
name?: string;
|
|
17
|
+
label?: string;
|
|
18
|
+
/** Renders the label to screen readers only. For a row of secrets where the visible name is a table cell. */
|
|
19
|
+
labelHidden?: boolean;
|
|
20
|
+
hint?: string;
|
|
21
|
+
error?: string;
|
|
22
|
+
placeholder?: string;
|
|
23
|
+
required?: boolean;
|
|
24
|
+
disabled?: boolean;
|
|
25
|
+
readonly?: boolean;
|
|
26
|
+
autocomplete?: 'new-password' | 'current-password' | 'off';
|
|
27
|
+
/** False for a field whose value must never be shown, for instance on a shared screen. */
|
|
28
|
+
revealable?: boolean;
|
|
29
|
+
class?: string;
|
|
30
|
+
/** Lands on the control, not on the wrapper, which is where `class` goes. */
|
|
31
|
+
controlClass?: string;
|
|
32
|
+
oninput?: (e: Event & { currentTarget: HTMLInputElement }) => void;
|
|
33
|
+
onchange?: (e: Event & { currentTarget: HTMLInputElement }) => void;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
let {
|
|
37
|
+
value = $bindable(''),
|
|
38
|
+
id = undefined,
|
|
39
|
+
name = undefined,
|
|
40
|
+
label = undefined,
|
|
41
|
+
labelHidden = false,
|
|
42
|
+
hint = undefined,
|
|
43
|
+
error = undefined,
|
|
44
|
+
placeholder = '',
|
|
45
|
+
required = false,
|
|
46
|
+
disabled = false,
|
|
47
|
+
readonly = false,
|
|
48
|
+
autocomplete = 'new-password',
|
|
49
|
+
revealable = true,
|
|
50
|
+
class: klass = '',
|
|
51
|
+
controlClass = '',
|
|
52
|
+
oninput = undefined,
|
|
53
|
+
onchange = undefined,
|
|
54
|
+
}: Props = $props();
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* $props.id() returns the same string on the server and on the client. A
|
|
58
|
+
* random one does not, so `aria-controls` on the reveal button named an
|
|
59
|
+
* element that never existed after hydration and the relationship was
|
|
60
|
+
* silently dropped.
|
|
61
|
+
*/
|
|
62
|
+
const uid = $props.id();
|
|
63
|
+
const fieldId = $derived(id ?? uid);
|
|
64
|
+
|
|
65
|
+
let revealed = $state(false);
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* A form reset, or a save that empties the field, leaves the component
|
|
69
|
+
* mounted. Without this the last secret typed stayed legible on screen under
|
|
70
|
+
* a value that no longer existed.
|
|
71
|
+
*/
|
|
72
|
+
$effect(() => {
|
|
73
|
+
if (value === '') revealed = false;
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
// A disabled field hands over nothing, including a look at what it holds.
|
|
77
|
+
const showToggle = $derived(revealable && !disabled);
|
|
78
|
+
|
|
79
|
+
function handleInput(e: Event & { currentTarget: HTMLInputElement }) {
|
|
80
|
+
value = e.currentTarget.value;
|
|
81
|
+
oninput?.(e);
|
|
82
|
+
}
|
|
83
|
+
</script>
|
|
84
|
+
|
|
85
|
+
<div class="{FIELD_WRAP} {klass}">
|
|
86
|
+
{#if label}
|
|
87
|
+
<label for={fieldId} class="{FIELD_LABEL} {labelHidden ? 'sr-only' : ''}">
|
|
88
|
+
{label}{#if required}<span class="ml-0.5 text-danger" aria-hidden="true">*</span>{/if}
|
|
89
|
+
</label>
|
|
90
|
+
{/if}
|
|
91
|
+
|
|
92
|
+
<div class="relative">
|
|
93
|
+
<!-- pr-10 overrides the px-3 CONTROL_BASE states, because `pr` is emitted
|
|
94
|
+
after `px` and so wins. Without it a long key ran under the button and
|
|
95
|
+
the operator could not read the end of what they had pasted. -->
|
|
96
|
+
<input
|
|
97
|
+
id={fieldId}
|
|
98
|
+
type={revealed ? 'text' : 'password'}
|
|
99
|
+
{name}
|
|
100
|
+
{placeholder}
|
|
101
|
+
{required}
|
|
102
|
+
{disabled}
|
|
103
|
+
{readonly}
|
|
104
|
+
{autocomplete}
|
|
105
|
+
{value}
|
|
106
|
+
oninput={handleInput}
|
|
107
|
+
{onchange}
|
|
108
|
+
aria-invalid={error ? 'true' : undefined}
|
|
109
|
+
aria-describedby={describedBy(fieldId, error, hint)}
|
|
110
|
+
class="{CONTROL_BASE} {controlBorder(!!error)} {showToggle ? 'pr-10' : ''} {controlClass}"
|
|
111
|
+
/>
|
|
112
|
+
|
|
113
|
+
{#if showToggle}
|
|
114
|
+
<!-- type="button" is load bearing. A button inside a form defaults to
|
|
115
|
+
type="submit", so a reveal control on a login form posted the form
|
|
116
|
+
on the first click and the user never saw what they had typed. -->
|
|
117
|
+
<button
|
|
118
|
+
type="button"
|
|
119
|
+
onclick={() => (revealed = !revealed)}
|
|
120
|
+
aria-pressed={revealed}
|
|
121
|
+
aria-controls={fieldId}
|
|
122
|
+
aria-label={revealed ? 'Hide password' : 'Show password'}
|
|
123
|
+
class="absolute top-1/2 right-1 flex -translate-y-1/2 items-center justify-center rounded-md p-1.5 text-faint outline-none transition-colors duration-150 hover:text-fg focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-brand"
|
|
124
|
+
>
|
|
125
|
+
{#if revealed}
|
|
126
|
+
<EyeOff size={15} aria-hidden="true" />
|
|
127
|
+
{:else}
|
|
128
|
+
<Eye size={15} aria-hidden="true" />
|
|
129
|
+
{/if}
|
|
130
|
+
</button>
|
|
131
|
+
{/if}
|
|
132
|
+
</div>
|
|
133
|
+
|
|
134
|
+
{#if error}
|
|
135
|
+
<p id="{fieldId}-error" class={FIELD_ERROR}>{error}</p>
|
|
136
|
+
{:else if hint}
|
|
137
|
+
<p id="{fieldId}-hint" class={FIELD_HINT}>{hint}</p>
|
|
138
|
+
{/if}
|
|
139
|
+
</div>
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
interface Props {
|
|
2
|
+
value?: string;
|
|
3
|
+
id?: string;
|
|
4
|
+
name?: string;
|
|
5
|
+
label?: string;
|
|
6
|
+
/** Renders the label to screen readers only. For a row of secrets where the visible name is a table cell. */
|
|
7
|
+
labelHidden?: boolean;
|
|
8
|
+
hint?: string;
|
|
9
|
+
error?: string;
|
|
10
|
+
placeholder?: string;
|
|
11
|
+
required?: boolean;
|
|
12
|
+
disabled?: boolean;
|
|
13
|
+
readonly?: boolean;
|
|
14
|
+
autocomplete?: 'new-password' | 'current-password' | 'off';
|
|
15
|
+
/** False for a field whose value must never be shown, for instance on a shared screen. */
|
|
16
|
+
revealable?: boolean;
|
|
17
|
+
class?: string;
|
|
18
|
+
/** Lands on the control, not on the wrapper, which is where `class` goes. */
|
|
19
|
+
controlClass?: string;
|
|
20
|
+
oninput?: (e: Event & {
|
|
21
|
+
currentTarget: HTMLInputElement;
|
|
22
|
+
}) => void;
|
|
23
|
+
onchange?: (e: Event & {
|
|
24
|
+
currentTarget: HTMLInputElement;
|
|
25
|
+
}) => void;
|
|
26
|
+
}
|
|
27
|
+
declare const PasswordInput: import("svelte").Component<Props, {}, "value">;
|
|
28
|
+
type PasswordInput = ReturnType<typeof PasswordInput>;
|
|
29
|
+
export default PasswordInput;
|
|
@@ -1,10 +1,35 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
|
-
import {
|
|
2
|
+
import type { Component } from 'svelte';
|
|
3
|
+
import { FIELD_ERROR, FIELD_HINT, FIELD_WRAP } from '../internal/field.js';
|
|
4
|
+
import {
|
|
5
|
+
CHOICE_DESCRIPTION,
|
|
6
|
+
CHOICE_FOCUS,
|
|
7
|
+
CHOICE_ICON_PX,
|
|
8
|
+
CHOICE_INPUT,
|
|
9
|
+
CHOICE_LABEL_STACK,
|
|
10
|
+
choiceBox,
|
|
11
|
+
choiceIcon,
|
|
12
|
+
choiceLabel,
|
|
13
|
+
choiceWrap,
|
|
14
|
+
type ChoiceSize,
|
|
15
|
+
type ChoiceVariant,
|
|
16
|
+
} from '../internal/choice.js';
|
|
17
|
+
|
|
3
18
|
interface Props {
|
|
4
19
|
value: string;
|
|
5
20
|
group?: string;
|
|
6
21
|
label?: string;
|
|
22
|
+
/** Screen-reader-only label, for a radio in a table cell whose column header is the visible name. */
|
|
23
|
+
labelHidden?: boolean;
|
|
24
|
+
/** Secondary line under the label. */
|
|
25
|
+
description?: string;
|
|
7
26
|
hint?: string;
|
|
27
|
+
error?: string;
|
|
28
|
+
/** Drawn before the label. */
|
|
29
|
+
icon?: Component<{ size?: number; class?: string }>;
|
|
30
|
+
size?: ChoiceSize;
|
|
31
|
+
/** 'card' draws a bordered option whose whole surface is the target. */
|
|
32
|
+
variant?: ChoiceVariant;
|
|
8
33
|
required?: boolean;
|
|
9
34
|
disabled?: boolean;
|
|
10
35
|
id?: string;
|
|
@@ -17,7 +42,13 @@
|
|
|
17
42
|
value,
|
|
18
43
|
group = $bindable(''),
|
|
19
44
|
label = undefined,
|
|
45
|
+
labelHidden = false,
|
|
46
|
+
description = undefined,
|
|
20
47
|
hint = undefined,
|
|
48
|
+
error = undefined,
|
|
49
|
+
icon: Icon = undefined,
|
|
50
|
+
size = 'md',
|
|
51
|
+
variant = 'inline',
|
|
21
52
|
required = false,
|
|
22
53
|
disabled = false,
|
|
23
54
|
id = undefined,
|
|
@@ -26,43 +57,132 @@
|
|
|
26
57
|
onchange = undefined,
|
|
27
58
|
}: Props = $props();
|
|
28
59
|
|
|
60
|
+
/**
|
|
61
|
+
* The selected dot. Only a radio draws one, so it is not part of the shared
|
|
62
|
+
* choice contract, but it still steps with the ring: an 8px dot inside a 14px
|
|
63
|
+
* ring leaves a hairline of background and reads as a solid filled circle.
|
|
64
|
+
*/
|
|
65
|
+
const DOT: Record<ChoiceSize, string> = {
|
|
66
|
+
sm: 'h-1.5 w-1.5',
|
|
67
|
+
md: 'h-2 w-2',
|
|
68
|
+
lg: 'h-2.5 w-2.5',
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
// $props.id() and not a random string: a random id differs between the server
|
|
72
|
+
// render and hydration, so every aria-describedby built from it points at an
|
|
73
|
+
// element that does not exist on the client.
|
|
74
|
+
const uid = $props.id();
|
|
75
|
+
const fieldId = $derived(id ?? uid);
|
|
76
|
+
|
|
77
|
+
// Error, then hint, then description. A screen reader announces one message,
|
|
78
|
+
// so the most urgent one has to win rather than the last one rendered. This
|
|
79
|
+
// is the only route an error takes: aria-invalid is not supported on role
|
|
80
|
+
// radio, and one option out of a set is not the thing that is invalid. A
|
|
81
|
+
// group marks itself invalid on the fieldset that holds the options.
|
|
82
|
+
const messageId = $derived(
|
|
83
|
+
error
|
|
84
|
+
? `${fieldId}-error`
|
|
85
|
+
: hint
|
|
86
|
+
? `${fieldId}-hint`
|
|
87
|
+
: description
|
|
88
|
+
? `${fieldId}-description`
|
|
89
|
+
: undefined,
|
|
90
|
+
);
|
|
91
|
+
|
|
92
|
+
const selected = $derived(group === value);
|
|
93
|
+
|
|
94
|
+
// A card fills its column. An inline option must not: stretched to the full
|
|
95
|
+
// width of the field wrapper, the label swallows the empty space beside it
|
|
96
|
+
// and a stray click far from the ring selects the option.
|
|
97
|
+
const rootClass = $derived(variant === 'card' ? FIELD_WRAP : `${FIELD_WRAP} items-start`);
|
|
98
|
+
|
|
29
99
|
function handleChange() {
|
|
100
|
+
// Disabled is enforced here as well as on the input. The native attribute
|
|
101
|
+
// is what stops a real click, and it is the only thing that does, so a
|
|
102
|
+
// change arriving any other way - a synthetic event, a script driving the
|
|
103
|
+
// node - would still move the bound value with nothing on screen to say so.
|
|
104
|
+
if (disabled) return;
|
|
30
105
|
group = value;
|
|
31
106
|
onchange?.(value);
|
|
32
107
|
}
|
|
33
108
|
</script>
|
|
34
109
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
>
|
|
57
|
-
{#if group === value}
|
|
58
|
-
<span class="h-2 w-2 rounded-full bg-brand"></span>
|
|
59
|
-
{/if}
|
|
60
|
-
</span>
|
|
110
|
+
{#snippet control()}
|
|
111
|
+
<input
|
|
112
|
+
type="radio"
|
|
113
|
+
id={fieldId}
|
|
114
|
+
{name}
|
|
115
|
+
{value}
|
|
116
|
+
{required}
|
|
117
|
+
{disabled}
|
|
118
|
+
checked={selected}
|
|
119
|
+
aria-labelledby={label ? `${fieldId}-label` : undefined}
|
|
120
|
+
aria-describedby={messageId}
|
|
121
|
+
onchange={handleChange}
|
|
122
|
+
class={CHOICE_INPUT}
|
|
123
|
+
/>
|
|
124
|
+
{/snippet}
|
|
125
|
+
|
|
126
|
+
{#snippet mark()}
|
|
127
|
+
<span class="{choiceBox('radio', size, selected, false)} mt-0.5">
|
|
128
|
+
{#if selected}
|
|
129
|
+
<span class="{DOT[size]} rounded-full bg-brand"></span>
|
|
130
|
+
{/if}
|
|
61
131
|
</span>
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
132
|
+
{/snippet}
|
|
133
|
+
|
|
134
|
+
{#snippet body()}
|
|
135
|
+
{#if label || description || Icon}
|
|
136
|
+
<span class={CHOICE_LABEL_STACK}>
|
|
137
|
+
{#if label || Icon}
|
|
138
|
+
<span class="flex items-center gap-2">
|
|
139
|
+
{#if Icon}
|
|
140
|
+
<span class={choiceIcon(size)}><Icon size={CHOICE_ICON_PX[size]} /></span>
|
|
141
|
+
{/if}
|
|
142
|
+
{#if label}
|
|
143
|
+
<!-- Named by aria-labelledby rather than by the wrapping label, so
|
|
144
|
+
the description below stays a description. Read as the label's
|
|
145
|
+
own text it became part of the name, and the reader heard the
|
|
146
|
+
whole paragraph before it reached the selected state. -->
|
|
147
|
+
<span id="{fieldId}-label" class="{choiceLabel(size)} {labelHidden ? 'sr-only' : ''}">
|
|
148
|
+
{label}{#if required}<span class="ml-0.5 text-danger" aria-hidden="true">*</span>{/if}
|
|
149
|
+
</span>
|
|
150
|
+
{/if}
|
|
151
|
+
</span>
|
|
152
|
+
{/if}
|
|
153
|
+
{#if description}
|
|
154
|
+
<span id="{fieldId}-description" class={CHOICE_DESCRIPTION}>{description}</span>
|
|
155
|
+
{/if}
|
|
66
156
|
</span>
|
|
67
157
|
{/if}
|
|
68
|
-
|
|
158
|
+
{/snippet}
|
|
159
|
+
|
|
160
|
+
<div class="{rootClass} {cls}">
|
|
161
|
+
{#if variant === 'card'}
|
|
162
|
+
<!-- The input covers the whole card, so the card surface is the element the
|
|
163
|
+
peer ring can reach and the ring inside it is not. That is deliberate:
|
|
164
|
+
one control gets one focus indicator, and a ring on the box drawn
|
|
165
|
+
inside a ring on the card reads as two separate things to focus. -->
|
|
166
|
+
<label class="relative block">
|
|
167
|
+
{@render control()}
|
|
168
|
+
<span class="{choiceWrap('card', selected, disabled)} {CHOICE_FOCUS}">
|
|
169
|
+
{@render mark()}
|
|
170
|
+
{@render body()}
|
|
171
|
+
</span>
|
|
172
|
+
</label>
|
|
173
|
+
{:else}
|
|
174
|
+
<label class={choiceWrap('inline', selected, disabled)}>
|
|
175
|
+
<span class="relative flex shrink-0 items-center justify-center">
|
|
176
|
+
{@render control()}
|
|
177
|
+
{@render mark()}
|
|
178
|
+
</span>
|
|
179
|
+
{@render body()}
|
|
180
|
+
</label>
|
|
181
|
+
{/if}
|
|
182
|
+
|
|
183
|
+
{#if error}
|
|
184
|
+
<p id="{fieldId}-error" class={FIELD_ERROR}>{error}</p>
|
|
185
|
+
{:else if hint}
|
|
186
|
+
<p id="{fieldId}-hint" class={FIELD_HINT}>{hint}</p>
|
|
187
|
+
{/if}
|
|
188
|
+
</div>
|