@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
@@ -1,20 +1,37 @@
1
1
  <script lang="ts">
2
- import { FIELD_HINT } from '../internal/field.js';
3
- let {
4
- checked = $bindable(false),
5
- label,
6
- hint,
7
- required = false,
8
- disabled = false,
9
- id,
10
- name,
11
- value,
12
- class: cls = '',
13
- onchange,
14
- }: {
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
+ CHOICE_MARK,
11
+ choiceBox,
12
+ choiceIcon,
13
+ choiceLabel,
14
+ choiceWrap,
15
+ type ChoiceSize,
16
+ type ChoiceVariant,
17
+ } from '../internal/choice.js';
18
+
19
+ interface Props {
15
20
  checked?: boolean;
21
+ /** Partly checked. Announced as aria-checked="mixed". */
22
+ indeterminate?: boolean;
16
23
  label?: string;
24
+ /** Screen-reader-only label, for a checkbox in a table cell whose column header is the visible name. */
25
+ labelHidden?: boolean;
26
+ /** Secondary line under the label. */
27
+ description?: string;
17
28
  hint?: string;
29
+ error?: string;
30
+ /** Drawn before the label. */
31
+ icon?: Component<{ size?: number; class?: string }>;
32
+ size?: ChoiceSize;
33
+ /** 'card' draws a bordered option whose whole surface is the target. */
34
+ variant?: ChoiceVariant;
18
35
  required?: boolean;
19
36
  disabled?: boolean;
20
37
  id?: string;
@@ -22,62 +39,160 @@
22
39
  value?: string;
23
40
  class?: string;
24
41
  onchange?: (checked: boolean) => void;
25
- } = $props();
42
+ }
43
+
44
+ let {
45
+ checked = $bindable(false),
46
+ indeterminate = $bindable(false),
47
+ label = undefined,
48
+ labelHidden = false,
49
+ description = undefined,
50
+ hint = undefined,
51
+ error = undefined,
52
+ icon: Icon = undefined,
53
+ size = 'md',
54
+ variant = 'inline',
55
+ required = false,
56
+ disabled = false,
57
+ id = undefined,
58
+ name = undefined,
59
+ value = undefined,
60
+ class: cls = '',
61
+ onchange = undefined,
62
+ }: Props = $props();
63
+
64
+ // $props.id() and not a random string: a random id differs between the server
65
+ // render and hydration, so every aria-describedby built from it points at an
66
+ // element that does not exist on the client.
67
+ const uid = $props.id();
68
+ const fieldId = $derived(id ?? uid);
69
+
70
+ // Error, then hint, then description. A screen reader announces one message,
71
+ // so the most urgent one has to win rather than the last one rendered.
72
+ const messageId = $derived(
73
+ error
74
+ ? `${fieldId}-error`
75
+ : hint
76
+ ? `${fieldId}-hint`
77
+ : description
78
+ ? `${fieldId}-description`
79
+ : undefined,
80
+ );
81
+
82
+ // A part-checked box is on, not a third colour, so it paints the filled box
83
+ // and tints the card exactly as a checked one does.
84
+ const on = $derived(checked || indeterminate);
85
+
86
+ // A card fills its column. An inline option must not: stretched to the full
87
+ // width of the field wrapper, the label swallows the empty space beside it
88
+ // and a stray click far from the box toggles the value.
89
+ const rootClass = $derived(variant === 'card' ? FIELD_WRAP : `${FIELD_WRAP} items-start`);
26
90
 
27
91
  function handleChange(e: Event & { currentTarget: HTMLInputElement }) {
92
+ // Disabled is enforced here as well as on the input. The native attribute
93
+ // is what stops a real click, and it is the only thing that does, so a
94
+ // change arriving any other way - a synthetic event, a script driving the
95
+ // node - would still move the bound value with nothing on screen to say so.
96
+ if (disabled) return;
97
+ // The DOM clears indeterminate on the first click. Leaving the prop set
98
+ // would repaint the mixed bar over a box the user has just ticked.
99
+ indeterminate = false;
28
100
  checked = e.currentTarget.checked;
29
101
  onchange?.(checked);
30
102
  }
31
103
  </script>
32
104
 
33
- <label
34
- class="inline-flex items-start gap-2.5 cursor-pointer select-none {disabled
35
- ? 'opacity-50 cursor-not-allowed'
36
- : ''} {cls}"
37
- >
38
- <span class="relative flex items-center justify-center mt-0.5 shrink-0">
39
- <input
40
- type="checkbox"
41
- {id}
42
- {name}
43
- {value}
44
- {required}
45
- {disabled}
46
- {checked}
47
- onchange={handleChange}
48
- class="peer absolute inset-0 m-0 h-full w-full cursor-pointer opacity-0 disabled:cursor-not-allowed"
49
- />
50
- <span
51
- class="pointer-events-none w-4 h-4 rounded border transition-colors duration-150 flex items-center justify-center
52
- {checked
53
- ? 'bg-brand border-brand'
54
- : 'bg-surface-2 border-line peer-focus-visible:border-brand'}"
55
- >
56
- {#if checked}
57
- <svg width="10" height="8" viewBox="0 0 10 8" fill="none" aria-hidden="true">
58
- <path
59
- d="M1 4l3 3 5-6"
60
- stroke="currentColor"
61
- stroke-width="1.5"
62
- stroke-linecap="round"
63
- stroke-linejoin="round"
64
- class="text-ink"
65
- />
66
- </svg>
67
- {/if}
68
- </span>
105
+ {#snippet control()}
106
+ <input
107
+ type="checkbox"
108
+ id={fieldId}
109
+ {name}
110
+ {value}
111
+ {required}
112
+ {disabled}
113
+ {checked}
114
+ {indeterminate}
115
+ aria-checked={indeterminate ? 'mixed' : undefined}
116
+ aria-invalid={error ? 'true' : undefined}
117
+ aria-labelledby={label ? `${fieldId}-label` : undefined}
118
+ aria-describedby={messageId}
119
+ onchange={handleChange}
120
+ class={CHOICE_INPUT}
121
+ />
122
+ {/snippet}
123
+
124
+ {#snippet mark()}
125
+ <span class="{choiceBox('checkbox', size, checked, indeterminate)} mt-0.5">
126
+ {#if on}
127
+ <!-- Both marks are stroked paths on one 10 by 8 grid. A Unicode check
128
+ lands at whatever weight the reader's font gives it, visibly lighter
129
+ than every other icon in the kit, and the mixed bar has no glyph that
130
+ is not a minus sign standing in for one. -->
131
+ <svg width="10" height="8" viewBox="0 0 10 8" fill="none" aria-hidden="true">
132
+ <path
133
+ d={indeterminate ? CHOICE_MARK.mixed : CHOICE_MARK.check}
134
+ stroke="currentColor"
135
+ stroke-width="1.5"
136
+ stroke-linecap="round"
137
+ stroke-linejoin="round"
138
+ />
139
+ </svg>
140
+ {/if}
69
141
  </span>
70
- {#if label || hint}
71
- <span class="flex flex-col gap-0.5">
72
- {#if label}
73
- <span class="text-sm text-fg">
74
- {label}
75
- {#if required}<span class="text-danger ml-0.5" aria-label="required">*</span>{/if}
142
+ {/snippet}
143
+
144
+ {#snippet body()}
145
+ {#if label || description || Icon}
146
+ <span class={CHOICE_LABEL_STACK}>
147
+ {#if label || Icon}
148
+ <span class="flex items-center gap-2">
149
+ {#if Icon}
150
+ <span class={choiceIcon(size)}><Icon size={CHOICE_ICON_PX[size]} /></span>
151
+ {/if}
152
+ {#if label}
153
+ <!-- Named by aria-labelledby rather than by the wrapping label, so
154
+ the description below stays a description. Read as the label's
155
+ own text it became part of the name, and the reader heard the
156
+ whole paragraph before it reached the checked state. -->
157
+ <span id="{fieldId}-label" class="{choiceLabel(size)} {labelHidden ? 'sr-only' : ''}">
158
+ {label}{#if required}<span class="ml-0.5 text-danger" aria-hidden="true">*</span>{/if}
159
+ </span>
160
+ {/if}
76
161
  </span>
77
162
  {/if}
78
- {#if hint}
79
- <span class={FIELD_HINT}>{hint}</span>
163
+ {#if description}
164
+ <span id="{fieldId}-description" class={CHOICE_DESCRIPTION}>{description}</span>
80
165
  {/if}
81
166
  </span>
82
167
  {/if}
83
- </label>
168
+ {/snippet}
169
+
170
+ <div class="{rootClass} {cls}">
171
+ {#if variant === 'card'}
172
+ <!-- The input covers the whole card, so the card surface is the element the
173
+ peer ring can reach and the box inside it is not. That is deliberate:
174
+ one control gets one focus indicator, and a ring on the box drawn
175
+ inside a ring on the card reads as two separate things to focus. -->
176
+ <label class="relative block">
177
+ {@render control()}
178
+ <span class="{choiceWrap('card', on, disabled)} {CHOICE_FOCUS}">
179
+ {@render mark()}
180
+ {@render body()}
181
+ </span>
182
+ </label>
183
+ {:else}
184
+ <label class={choiceWrap('inline', on, disabled)}>
185
+ <span class="relative flex shrink-0 items-center justify-center">
186
+ {@render control()}
187
+ {@render mark()}
188
+ </span>
189
+ {@render body()}
190
+ </label>
191
+ {/if}
192
+
193
+ {#if error}
194
+ <p id="{fieldId}-error" class={FIELD_ERROR}>{error}</p>
195
+ {:else if hint}
196
+ <p id="{fieldId}-hint" class={FIELD_HINT}>{hint}</p>
197
+ {/if}
198
+ </div>
@@ -1,7 +1,24 @@
1
- type $$ComponentProps = {
1
+ import type { Component } from 'svelte';
2
+ import { type ChoiceSize, type ChoiceVariant } from '../internal/choice.js';
3
+ interface Props {
2
4
  checked?: boolean;
5
+ /** Partly checked. Announced as aria-checked="mixed". */
6
+ indeterminate?: boolean;
3
7
  label?: string;
8
+ /** Screen-reader-only label, for a checkbox in a table cell whose column header is the visible name. */
9
+ labelHidden?: boolean;
10
+ /** Secondary line under the label. */
11
+ description?: string;
4
12
  hint?: string;
13
+ error?: string;
14
+ /** Drawn before the label. */
15
+ icon?: Component<{
16
+ size?: number;
17
+ class?: string;
18
+ }>;
19
+ size?: ChoiceSize;
20
+ /** 'card' draws a bordered option whose whole surface is the target. */
21
+ variant?: ChoiceVariant;
5
22
  required?: boolean;
6
23
  disabled?: boolean;
7
24
  id?: string;
@@ -9,7 +26,7 @@ type $$ComponentProps = {
9
26
  value?: string;
10
27
  class?: string;
11
28
  onchange?: (checked: boolean) => void;
12
- };
13
- declare const Checkbox: import("svelte").Component<$$ComponentProps, {}, "checked">;
29
+ }
30
+ declare const Checkbox: Component<Props, {}, "checked" | "indeterminate">;
14
31
  type Checkbox = ReturnType<typeof Checkbox>;
15
32
  export default Checkbox;
@@ -0,0 +1,162 @@
1
+ <script lang="ts" module>
2
+ import type { Component } from 'svelte';
3
+
4
+ /**
5
+ * One option in a choice group.
6
+ *
7
+ * RadioGroup declares this interface in the same words, so a consumer who has
8
+ * written one option list can write the other without rereading the types. A
9
+ * colocated test compares the two declarations character for character,
10
+ * because the group and the single control had already drifted apart once.
11
+ */
12
+ export interface ChoiceOption {
13
+ /** The submitted value, and the key the option is rendered under. */
14
+ value: string;
15
+ /** Names this one choice. The group is named by the legend instead. */
16
+ label: string;
17
+ /** A second line under the label, carrying what the label leaves out. */
18
+ description?: string;
19
+ /** A lucide component. The group sizes it, so the caller does not guess. */
20
+ icon?: Component<{ size?: number; class?: string }>;
21
+ /** Blocks this option alone. A disabled group blocks every option. */
22
+ disabled?: boolean;
23
+ }
24
+ </script>
25
+
26
+ <script lang="ts">
27
+ import {
28
+ CHOICE_GROUP,
29
+ choiceGroupList,
30
+ type ChoiceOrientation,
31
+ type ChoiceSize,
32
+ type ChoiceVariant,
33
+ } from '../internal/choice.js';
34
+ import { FIELD_ERROR, FIELD_HINT, FIELD_LABEL, describedBy } from '../internal/field.js';
35
+ import Checkbox from './Checkbox.svelte';
36
+
37
+ interface Props {
38
+ /** The chosen values, ordered by `options` rather than by click order. */
39
+ value?: string[];
40
+ options: ChoiceOption[];
41
+ /**
42
+ * The group's accessible name, rendered as a legend. Required because an
43
+ * unnamed group announces as a bare set of controls, which leaves a reader
44
+ * to infer what the set is for from the options alone.
45
+ */
46
+ label: string;
47
+ /** Keeps the legend for a screen reader and takes it off the screen. */
48
+ labelHidden?: boolean;
49
+ hint?: string;
50
+ error?: string;
51
+ name?: string;
52
+ orientation?: ChoiceOrientation;
53
+ size?: ChoiceSize;
54
+ variant?: ChoiceVariant;
55
+ required?: boolean;
56
+ disabled?: boolean;
57
+ class?: string;
58
+ onchange?: (value: string[]) => void;
59
+ }
60
+
61
+ let {
62
+ value = $bindable([]),
63
+ options,
64
+ label,
65
+ labelHidden = false,
66
+ hint = undefined,
67
+ error = undefined,
68
+ name = undefined,
69
+ orientation = 'vertical',
70
+ size = 'md',
71
+ variant = 'inline',
72
+ required = false,
73
+ disabled = false,
74
+ class: cls = '',
75
+ onchange = undefined,
76
+ }: Props = $props();
77
+
78
+ /*
79
+ * The instance id, and through it the shared name, comes from $props.id().
80
+ * RadioGroup built its name from Math.random(), which produces one value on
81
+ * the server and a different one on hydration, so every id and every name
82
+ * derived from it changed under the client on first paint.
83
+ */
84
+ const uid = $props.id();
85
+ const groupName = $derived(name ?? uid);
86
+
87
+ const chosen = $derived(new Set(value));
88
+
89
+ function toggle(option: string, on: boolean) {
90
+ const next = new Set(value);
91
+ if (on) {
92
+ next.add(option);
93
+ } else {
94
+ next.delete(option);
95
+ }
96
+ // Filtered out of `options` rather than appended on click, so ticking C
97
+ // and then A submits the same value as ticking A and then C.
98
+ value = options.filter((o) => next.has(o.value)).map((o) => o.value);
99
+ onchange?.(value);
100
+ }
101
+ </script>
102
+
103
+ <!--
104
+ No aria-invalid here, where RadioGroup carries one. ARIA supports the
105
+ attribute on radiogroup and not on group, which is the role a fieldset of
106
+ checkboxes has and the only role that fits it, so the kit's a11y gate rejects
107
+ it outright. The error still reaches a reader through aria-describedby, which
108
+ is global and points at the message paragraph below.
109
+
110
+ aria-required is gone for the same reason and is not replaced by `required` on
111
+ each box, which would demand every option be ticked instead of one of them.
112
+ The requirement is in the group's name instead: a group's name comes from its
113
+ legend, so the legend says "(required)" and the set announces as "Interests
114
+ (required), group". Naming a group that way is accurate, where naming a single
115
+ control that way puts a word into what a voice user has to speak at it. The
116
+ asterisk stays decoration, and the hint, when the caller writes one, says what
117
+ is required and reaches the reader through aria-describedby.
118
+ -->
119
+ <fieldset class="{CHOICE_GROUP} {cls}" {disabled} aria-describedby={describedBy(uid, error, hint)}>
120
+ <!--
121
+ The legend stays a legend when it is hidden. Swapping it for an aria-label
122
+ on the fieldset would name the group and drop it out of the reading order,
123
+ so a reader moving through the page would meet the options with nothing
124
+ ahead of them saying what the set is for.
125
+ -->
126
+ <legend class="{FIELD_LABEL} {labelHidden ? 'sr-only' : ''}">
127
+ {label}{#if required}<span class="text-danger ml-0.5" aria-hidden="true">*</span><span
128
+ class="sr-only"
129
+ >
130
+ (required)</span
131
+ >{/if}
132
+ </legend>
133
+
134
+ <div class={choiceGroupList(orientation)}>
135
+ {#each options as option (option.value)}
136
+ <!--
137
+ The option renders through Checkbox rather than through a copy of it.
138
+ A group that paints its own box is how RadioGroup came to rest on
139
+ border-line and to keep its focus ring inside the selected branch while
140
+ the single control had been fixed for both.
141
+ -->
142
+ <Checkbox
143
+ checked={chosen.has(option.value)}
144
+ value={option.value}
145
+ label={option.label}
146
+ description={option.description}
147
+ icon={option.icon}
148
+ name={groupName}
149
+ {size}
150
+ {variant}
151
+ disabled={disabled || option.disabled === true}
152
+ onchange={(on) => toggle(option.value, on)}
153
+ />
154
+ {/each}
155
+ </div>
156
+
157
+ {#if error}
158
+ <p id="{uid}-error" class={FIELD_ERROR}>{error}</p>
159
+ {:else if hint}
160
+ <p id="{uid}-hint" class={FIELD_HINT}>{hint}</p>
161
+ {/if}
162
+ </fieldset>
@@ -0,0 +1,51 @@
1
+ import type { Component } from 'svelte';
2
+ /**
3
+ * One option in a choice group.
4
+ *
5
+ * RadioGroup declares this interface in the same words, so a consumer who has
6
+ * written one option list can write the other without rereading the types. A
7
+ * colocated test compares the two declarations character for character,
8
+ * because the group and the single control had already drifted apart once.
9
+ */
10
+ export interface ChoiceOption {
11
+ /** The submitted value, and the key the option is rendered under. */
12
+ value: string;
13
+ /** Names this one choice. The group is named by the legend instead. */
14
+ label: string;
15
+ /** A second line under the label, carrying what the label leaves out. */
16
+ description?: string;
17
+ /** A lucide component. The group sizes it, so the caller does not guess. */
18
+ icon?: Component<{
19
+ size?: number;
20
+ class?: string;
21
+ }>;
22
+ /** Blocks this option alone. A disabled group blocks every option. */
23
+ disabled?: boolean;
24
+ }
25
+ import { type ChoiceOrientation, type ChoiceSize, type ChoiceVariant } from '../internal/choice.js';
26
+ interface Props {
27
+ /** The chosen values, ordered by `options` rather than by click order. */
28
+ value?: string[];
29
+ options: ChoiceOption[];
30
+ /**
31
+ * The group's accessible name, rendered as a legend. Required because an
32
+ * unnamed group announces as a bare set of controls, which leaves a reader
33
+ * to infer what the set is for from the options alone.
34
+ */
35
+ label: string;
36
+ /** Keeps the legend for a screen reader and takes it off the screen. */
37
+ labelHidden?: boolean;
38
+ hint?: string;
39
+ error?: string;
40
+ name?: string;
41
+ orientation?: ChoiceOrientation;
42
+ size?: ChoiceSize;
43
+ variant?: ChoiceVariant;
44
+ required?: boolean;
45
+ disabled?: boolean;
46
+ class?: string;
47
+ onchange?: (value: string[]) => void;
48
+ }
49
+ declare const CheckboxGroup: Component<Props, {}, "value">;
50
+ type CheckboxGroup = ReturnType<typeof CheckboxGroup>;
51
+ export default CheckboxGroup;
@@ -0,0 +1,142 @@
1
+ <script lang="ts">
2
+ /**
3
+ * One disclosure that owns nothing but itself: an advanced-options block, a
4
+ * long log line, a stack trace folded under an error.
5
+ *
6
+ * Not the native `details` element. Its open state cannot be animated, so the
7
+ * content snaps in and out in a single frame, and it cannot be driven from
8
+ * outside: a page that wants to open the block from a deep link or a search
9
+ * hit has no way in. `open` here is bindable, so the page and the trigger
10
+ * share one value.
11
+ *
12
+ * Accordion is the component for a set of these. It decides which of its
13
+ * items is open, and an item asks it; this decides nothing beyond itself.
14
+ */
15
+ import type { Component, Snippet } from 'svelte';
16
+
17
+ interface Props {
18
+ open?: boolean;
19
+ /** The clickable summary. */
20
+ label: string;
21
+ /** Drawn before the label. */
22
+ icon?: Component<{ size?: number; class?: string }>;
23
+ /** A count or status beside the label. */
24
+ badge?: string | number;
25
+ disabled?: boolean;
26
+ class?: string;
27
+ children: Snippet;
28
+ }
29
+
30
+ let {
31
+ open = $bindable(false),
32
+ label,
33
+ icon = undefined,
34
+ badge = undefined,
35
+ disabled = false,
36
+ class: klass = '',
37
+ children,
38
+ }: Props = $props();
39
+
40
+ // $props.id() and not a random string: a random id differs between the server
41
+ // render and hydration, so aria-controls points at nothing for the first
42
+ // paint and the two halves of the disclosure are wired to different names.
43
+ const uid = $props.id();
44
+ const triggerId = `${uid}-trigger`;
45
+ const panelId = `${uid}-panel`;
46
+
47
+ /**
48
+ * The disabled attribute stops a user press and not a dispatched one. A click
49
+ * sent from script reaches the handler on a disabled button, so the state
50
+ * change is guarded here as well as on the element.
51
+ */
52
+ function toggle() {
53
+ if (disabled) return;
54
+ open = !open;
55
+ }
56
+ </script>
57
+
58
+ <div class={klass}>
59
+ <!-- The focus ring is inset. The trigger spans the full width of whatever
60
+ holds it, so the global 2px outset outline is cropped by the first
61
+ ancestor that clips.
62
+ The hover background is dropped rather than overridden when the trigger
63
+ is disabled: :hover still matches a disabled button, and a second
64
+ background utility on the same element would resolve by the order
65
+ Tailwind emits it rather than the order it was written. -->
66
+ <button
67
+ id={triggerId}
68
+ type="button"
69
+ {disabled}
70
+ aria-expanded={open}
71
+ aria-controls={panelId}
72
+ onclick={toggle}
73
+ class="flex w-full items-center gap-2 rounded-lg px-2 py-1.5 text-left text-sm font-medium
74
+ text-fg transition-colors duration-150 outline-none focus-visible:ring-2
75
+ focus-visible:ring-inset focus-visible:ring-brand disabled:cursor-not-allowed
76
+ disabled:opacity-50 {disabled ? '' : 'hover:bg-surface-2'}"
77
+ >
78
+ <!-- The chevron points along the axis the content moves on: right while the
79
+ block is closed, down once it is open. -->
80
+ <svg
81
+ width="16"
82
+ height="16"
83
+ viewBox="0 0 24 24"
84
+ fill="none"
85
+ stroke="currentColor"
86
+ stroke-width="2"
87
+ stroke-linecap="round"
88
+ stroke-linejoin="round"
89
+ class="shrink-0 text-faint transition-transform duration-200 ease-out {open
90
+ ? ''
91
+ : '-rotate-90'}"
92
+ aria-hidden="true"
93
+ >
94
+ <path d="M6 9l6 6 6-6" />
95
+ </svg>
96
+
97
+ {#if icon}
98
+ {@const Icon = icon}
99
+ <Icon size={16} class="shrink-0 text-faint" />
100
+ {/if}
101
+
102
+ <span class="truncate">{label}</span>
103
+
104
+ {#if badge !== undefined}
105
+ <span class="ml-auto shrink-0 rounded-full bg-surface-2 px-2 py-0.5 text-xs text-muted">
106
+ {badge}
107
+ </span>
108
+ {/if}
109
+ </button>
110
+
111
+ <!--
112
+ A block opens to the height of its own content, which no fixed value knows.
113
+ Animating grid-template-rows from 0fr to 1fr resolves to that height in CSS
114
+ alone: no measuring, no reflow per frame, and nothing to correct when the
115
+ content changes. The inner element carries the overflow so the content is
116
+ clipped rather than spilling while the row grows.
117
+ -->
118
+ <div
119
+ class="grid transition-[grid-template-rows] duration-200 ease-out {open
120
+ ? 'grid-rows-[1fr]'
121
+ : 'grid-rows-[0fr]'}"
122
+ >
123
+ <div class="overflow-hidden">
124
+ <!--
125
+ inert, not height alone. The content stays mounted so the row can
126
+ animate to its height, and a subtree that is only zero pixels tall is
127
+ still in the accessibility tree and still in the tab order: Tab lands on
128
+ a control the reader cannot see and the page appears to lose focus.
129
+ inert is what takes the whole subtree out of both.
130
+ -->
131
+ <div
132
+ id={panelId}
133
+ role="region"
134
+ aria-labelledby={triggerId}
135
+ inert={!open}
136
+ class="px-2 pt-2 text-sm"
137
+ >
138
+ {@render children()}
139
+ </div>
140
+ </div>
141
+ </div>
142
+ </div>
@@ -0,0 +1,32 @@
1
+ /**
2
+ * One disclosure that owns nothing but itself: an advanced-options block, a
3
+ * long log line, a stack trace folded under an error.
4
+ *
5
+ * Not the native `details` element. Its open state cannot be animated, so the
6
+ * content snaps in and out in a single frame, and it cannot be driven from
7
+ * outside: a page that wants to open the block from a deep link or a search
8
+ * hit has no way in. `open` here is bindable, so the page and the trigger
9
+ * share one value.
10
+ *
11
+ * Accordion is the component for a set of these. It decides which of its
12
+ * items is open, and an item asks it; this decides nothing beyond itself.
13
+ */
14
+ import type { Component, Snippet } from 'svelte';
15
+ interface Props {
16
+ open?: boolean;
17
+ /** The clickable summary. */
18
+ label: string;
19
+ /** Drawn before the label. */
20
+ icon?: Component<{
21
+ size?: number;
22
+ class?: string;
23
+ }>;
24
+ /** A count or status beside the label. */
25
+ badge?: string | number;
26
+ disabled?: boolean;
27
+ class?: string;
28
+ children: Snippet;
29
+ }
30
+ declare const Collapsible: Component<Props, {}, "open">;
31
+ type Collapsible = ReturnType<typeof Collapsible>;
32
+ export default Collapsible;