@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.
Files changed (101) hide show
  1. package/README.md +1 -1
  2. package/dist/components/AccordionItem.svelte +1 -1
  3. package/dist/components/Autocomplete.svelte +191 -125
  4. package/dist/components/Autocomplete.svelte.d.ts +29 -8
  5. package/dist/components/Button.svelte +26 -4
  6. package/dist/components/Card.svelte +61 -3
  7. package/dist/components/Card.svelte.d.ts +24 -2
  8. package/dist/components/Checkbox.svelte +174 -59
  9. package/dist/components/Checkbox.svelte.d.ts +20 -3
  10. package/dist/components/CheckboxGroup.svelte +162 -0
  11. package/dist/components/CheckboxGroup.svelte.d.ts +51 -0
  12. package/dist/components/Collapsible.svelte +142 -0
  13. package/dist/components/Collapsible.svelte.d.ts +32 -0
  14. package/dist/components/CopyButton.svelte +126 -0
  15. package/dist/components/CopyButton.svelte.d.ts +14 -0
  16. package/dist/components/DatePicker.svelte +48 -6
  17. package/dist/components/DateTimePicker.svelte +337 -0
  18. package/dist/components/DateTimePicker.svelte.d.ts +26 -0
  19. package/dist/components/DescriptionList.svelte +78 -0
  20. package/dist/components/DescriptionList.svelte.d.ts +34 -0
  21. package/dist/components/Drawer.svelte +15 -4
  22. package/dist/components/Field.svelte +104 -0
  23. package/dist/components/Field.svelte.d.ts +46 -0
  24. package/dist/components/FileInput.svelte +5 -2
  25. package/dist/components/FormMessage.svelte +85 -0
  26. package/dist/components/FormMessage.svelte.d.ts +11 -0
  27. package/dist/components/Input.svelte +1 -1
  28. package/dist/components/Label.svelte +7 -1
  29. package/dist/components/Label.svelte.d.ts +6 -0
  30. package/dist/components/Modal.svelte +25 -8
  31. package/dist/components/MultiSelect.svelte +199 -109
  32. package/dist/components/MultiSelect.svelte.d.ts +22 -9
  33. package/dist/components/NumberInput.svelte +8 -4
  34. package/dist/components/PageHeader.svelte +37 -4
  35. package/dist/components/PageHeader.svelte.d.ts +15 -0
  36. package/dist/components/PageShell.svelte +85 -0
  37. package/dist/components/PageShell.svelte.d.ts +38 -0
  38. package/dist/components/Pagination.svelte +58 -17
  39. package/dist/components/Panel.svelte +101 -0
  40. package/dist/components/Panel.svelte.d.ts +39 -0
  41. package/dist/components/PasswordInput.svelte +139 -0
  42. package/dist/components/PasswordInput.svelte.d.ts +29 -0
  43. package/dist/components/Radio.svelte +152 -32
  44. package/dist/components/Radio.svelte.d.ts +16 -1
  45. package/dist/components/RadioGroup.svelte +118 -71
  46. package/dist/components/RadioGroup.svelte.d.ts +39 -9
  47. package/dist/components/SectionHeading.svelte +39 -0
  48. package/dist/components/SectionHeading.svelte.d.ts +21 -0
  49. package/dist/components/SegmentedControl.svelte +194 -0
  50. package/dist/components/SegmentedControl.svelte.d.ts +55 -0
  51. package/dist/components/Select.svelte +471 -46
  52. package/dist/components/Select.svelte.d.ts +95 -6
  53. package/dist/components/SidebarNav.svelte +259 -0
  54. package/dist/components/SidebarNav.svelte.d.ts +17 -0
  55. package/dist/components/Stat.svelte +53 -2
  56. package/dist/components/Stat.svelte.d.ts +31 -0
  57. package/dist/components/Textarea.svelte +1 -1
  58. package/dist/components/TimePicker.svelte +480 -0
  59. package/dist/components/TimePicker.svelte.d.ts +23 -0
  60. package/dist/components/Toaster.svelte +9 -2
  61. package/dist/components/Toggle.svelte +5 -1
  62. package/dist/components/Toggle.svelte.d.ts +2 -0
  63. package/dist/components/Toolbar.svelte +39 -0
  64. package/dist/components/Toolbar.svelte.d.ts +26 -0
  65. package/dist/components/Tooltip.svelte +48 -12
  66. package/dist/components/TreeView.svelte +339 -0
  67. package/dist/components/TreeView.svelte.d.ts +37 -0
  68. package/dist/components/dialog/Dialog.svelte +15 -58
  69. package/dist/components/dialog/dialog-manager.svelte.d.ts +2 -2
  70. package/dist/components/dialog/dialog-manager.svelte.js +21 -21
  71. package/dist/index.d.ts +25 -1
  72. package/dist/index.js +20 -1
  73. package/dist/internal/calendar.d.ts +119 -0
  74. package/dist/internal/calendar.js +225 -0
  75. package/dist/internal/choice.d.ts +136 -0
  76. package/dist/internal/choice.js +179 -0
  77. package/dist/internal/field.d.ts +31 -0
  78. package/dist/internal/field.js +42 -1
  79. package/dist/internal/filter.d.ts +80 -0
  80. package/dist/internal/filter.js +80 -0
  81. package/dist/internal/layout.d.ts +119 -0
  82. package/dist/internal/layout.js +132 -0
  83. package/dist/internal/listbox.svelte.d.ts +77 -0
  84. package/dist/internal/listbox.svelte.js +438 -0
  85. package/dist/internal/nav-expansion.svelte.d.ts +36 -0
  86. package/dist/internal/nav-expansion.svelte.js +144 -0
  87. package/dist/internal/nav-tree.d.ts +68 -0
  88. package/dist/internal/nav-tree.js +102 -0
  89. package/dist/internal/overlay.d.ts +25 -0
  90. package/dist/internal/overlay.js +92 -0
  91. package/dist/internal/panel.d.ts +100 -0
  92. package/dist/internal/panel.js +109 -0
  93. package/dist/internal/rollup.d.ts +52 -0
  94. package/dist/internal/rollup.js +67 -0
  95. package/dist/internal/time.d.ts +103 -0
  96. package/dist/internal/time.js +166 -0
  97. package/dist/internal/tree.d.ts +86 -0
  98. package/dist/internal/tree.js +111 -0
  99. package/dist/styles/theme.css +66 -25
  100. package/package.json +4 -2
  101. 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
- function pageNumbers(current: number, last: number): (number | '…')[] {
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: (number | '…')[] = [1];
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
- {#if totalPages > 1}
41
- <div class="flex items-center gap-3 {cls}">
42
- <span class="text-xs text-faint shrink-0">
43
- {total === 0 ? 'No results' : `${from}–${to} of ${total}`}
44
- </span>
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
- <div class="flex items-center gap-0.5 ml-auto">
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
- disabled:opacity-30 disabled:cursor-not-allowed"
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
- <span class="w-7 text-center text-xs text-faint">…</span>
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
- {safePage === n
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
- </div>
112
- {/if}
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 { FIELD_HINT } from '../internal/field.js';
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
- <label
36
- class="inline-flex cursor-pointer select-none items-start gap-2.5
37
- {disabled ? 'cursor-not-allowed opacity-50' : ''} {cls}"
38
- >
39
- <span class="relative mt-0.5 flex shrink-0 items-center justify-center">
40
- <input
41
- type="radio"
42
- {id}
43
- {name}
44
- {value}
45
- {required}
46
- {disabled}
47
- checked={group === value}
48
- onchange={handleChange}
49
- class="peer absolute inset-0 m-0 h-full w-full cursor-pointer opacity-0 disabled:cursor-not-allowed"
50
- />
51
- <span
52
- class="pointer-events-none flex h-4 w-4 items-center justify-center rounded-full border-2 transition-colors duration-150
53
- {group === value
54
- ? 'border-brand bg-surface-2'
55
- : 'border-line bg-surface-2 peer-focus-visible:border-brand'}"
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
- {#if label || hint}
63
- <span class="flex flex-col gap-0.5">
64
- {#if label}<span class="text-sm text-fg">{label}</span>{/if}
65
- {#if hint}<span class={FIELD_HINT}>{hint}</span>{/if}
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
- </label>
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>