@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,8 +1,23 @@
1
+ import type { Component } from 'svelte';
2
+ import { type ChoiceSize, type ChoiceVariant } from '../internal/choice.js';
1
3
  interface Props {
2
4
  value: string;
3
5
  group?: string;
4
6
  label?: string;
7
+ /** Screen-reader-only label, for a radio in a table cell whose column header is the visible name. */
8
+ labelHidden?: boolean;
9
+ /** Secondary line under the label. */
10
+ description?: string;
5
11
  hint?: string;
12
+ error?: string;
13
+ /** Drawn before the label. */
14
+ icon?: Component<{
15
+ size?: number;
16
+ class?: string;
17
+ }>;
18
+ size?: ChoiceSize;
19
+ /** 'card' draws a bordered option whose whole surface is the target. */
20
+ variant?: ChoiceVariant;
6
21
  required?: boolean;
7
22
  disabled?: boolean;
8
23
  id?: string;
@@ -10,6 +25,6 @@ interface Props {
10
25
  class?: string;
11
26
  onchange?: (value: string) => void;
12
27
  }
13
- declare const Radio: import("svelte").Component<Props, {}, "group">;
28
+ declare const Radio: Component<Props, {}, "group">;
14
29
  type Radio = ReturnType<typeof Radio>;
15
30
  export default Radio;
@@ -1,97 +1,144 @@
1
- <script lang="ts">
2
- import { FIELD_ERROR, FIELD_HINT, FIELD_LABEL } from '../internal/field.js';
3
- // RadioGroup renders a managed set of radio options bound to a single value.
4
- // Matches the visual of Radio.svelte; use this when you have a fixed option list.
5
- interface Option {
1
+ <script lang="ts" module>
2
+ import type { Component } from 'svelte';
3
+
4
+ /**
5
+ * One option in a choice group.
6
+ *
7
+ * CheckboxGroup declares this interface in the same words, so a consumer who
8
+ * has written one option list can write the other without rereading the
9
+ * types. A colocated test compares the two declarations character for
10
+ * character, because the group and the single control had already drifted
11
+ * apart once.
12
+ */
13
+ export interface ChoiceOption {
14
+ /** The submitted value, and the key the option is rendered under. */
6
15
  value: string;
16
+ /** Names this one choice. The group is named by the legend instead. */
7
17
  label: string;
18
+ /** A second line under the label, carrying what the label leaves out. */
19
+ description?: string;
20
+ /** A lucide component. The group sizes it, so the caller does not guess. */
21
+ icon?: Component<{ size?: number; class?: string }>;
22
+ /** Blocks this option alone. A disabled group blocks every option. */
23
+ disabled?: boolean;
24
+ }
25
+ </script>
26
+
27
+ <script lang="ts">
28
+ import {
29
+ CHOICE_GROUP,
30
+ choiceGroupList,
31
+ type ChoiceOrientation,
32
+ type ChoiceSize,
33
+ type ChoiceVariant,
34
+ } from '../internal/choice.js';
35
+ import { FIELD_ERROR, FIELD_HINT, FIELD_LABEL, describedBy } from '../internal/field.js';
36
+ import Radio from './Radio.svelte';
37
+
38
+ interface Props {
39
+ /** The selected value. One of the option values, or empty for none. */
40
+ value?: string;
41
+ options: ChoiceOption[];
42
+ /**
43
+ * The group's accessible name, rendered as a legend. Required because an
44
+ * unnamed group announces as a bare set of controls, which leaves a reader
45
+ * to infer what the set is for from the options alone.
46
+ */
47
+ label: string;
48
+ /** Keeps the legend for a screen reader and takes it off the screen. */
49
+ labelHidden?: boolean;
8
50
  hint?: string;
51
+ error?: string;
52
+ name?: string;
53
+ orientation?: ChoiceOrientation;
54
+ size?: ChoiceSize;
55
+ variant?: ChoiceVariant;
56
+ required?: boolean;
9
57
  disabled?: boolean;
58
+ class?: string;
59
+ onchange?: (value: string) => void;
10
60
  }
11
61
 
12
62
  let {
13
63
  value = $bindable(''),
14
64
  options,
15
- name = undefined,
16
- label = undefined,
65
+ label,
66
+ labelHidden = false,
17
67
  hint = undefined,
18
68
  error = undefined,
69
+ name = undefined,
70
+ orientation = 'vertical',
71
+ size = 'md',
72
+ variant = 'inline',
19
73
  required = false,
20
74
  disabled = false,
21
- orientation = 'vertical',
22
75
  class: cls = '',
23
76
  onchange = undefined,
24
- }: {
25
- value?: string;
26
- options: Option[];
27
- name?: string;
28
- label?: string;
29
- hint?: string;
30
- error?: string;
31
- required?: boolean;
32
- disabled?: boolean;
33
- orientation?: 'vertical' | 'horizontal';
34
- class?: string;
35
- onchange?: (value: string) => void;
36
- } = $props();
77
+ }: Props = $props();
37
78
 
38
- const fallbackName = `rg-${Math.random().toString(36).slice(2, 9)}`;
39
- const groupName = $derived(name ?? fallbackName);
40
-
41
- function select(opt: Option) {
42
- if (disabled || opt.disabled) return;
43
- value = opt.value;
44
- onchange?.(opt.value);
45
- }
79
+ /*
80
+ * The instance id, and through it the shared name, comes from $props.id().
81
+ * This group built its name from `rg-${Math.random()}`, which produces one
82
+ * value on the server and a different one on hydration, so every id and every
83
+ * name derived from it changed under the client on first paint. Two radios
84
+ * that disagree about their name are two groups, and selecting one no longer
85
+ * clears the other.
86
+ */
87
+ const uid = $props.id();
88
+ const groupName = $derived(name ?? uid);
46
89
  </script>
47
90
 
48
- <fieldset class="flex flex-col gap-2 {cls}" {disabled}>
49
- {#if label}
50
- <legend class="{FIELD_LABEL} mb-0.5">
51
- {label}{#if required}<span class="text-danger ml-0.5">*</span>{/if}
52
- </legend>
53
- {/if}
91
+ <!--
92
+ role="radiogroup" over the fieldset's implicit "group". A set of radios is a
93
+ radiogroup, and that is the role ARIA lets carry aria-invalid and
94
+ aria-required, so the group can report its own validity rather than leaving a
95
+ reader to find the message paragraph on their own.
96
+ -->
97
+ <fieldset
98
+ class="{CHOICE_GROUP} {cls}"
99
+ role="radiogroup"
100
+ {disabled}
101
+ aria-invalid={error ? 'true' : undefined}
102
+ aria-required={required ? 'true' : undefined}
103
+ aria-describedby={describedBy(uid, error, hint)}
104
+ >
105
+ <!--
106
+ The legend stays a legend when it is hidden. Swapping it for an aria-label
107
+ on the fieldset would name the group and drop it out of the reading order,
108
+ so a reader moving through the page would meet the options with nothing
109
+ ahead of them saying what the set is for.
110
+ -->
111
+ <legend class="{FIELD_LABEL} {labelHidden ? 'sr-only' : ''}">
112
+ {label}{#if required}<span class="text-danger ml-0.5" aria-hidden="true">*</span>{/if}
113
+ </legend>
54
114
 
55
- <div
56
- class="flex gap-x-5 gap-y-2 {orientation === 'horizontal' ? 'flex-row flex-wrap' : 'flex-col'}"
57
- >
58
- {#each options as opt (opt.value)}
59
- <label
60
- class="inline-flex cursor-pointer select-none items-start gap-2.5
61
- {disabled || opt.disabled ? 'cursor-not-allowed opacity-50' : ''}"
62
- >
63
- <span class="relative mt-0.5 flex shrink-0 items-center justify-center">
64
- <input
65
- type="radio"
66
- name={groupName}
67
- value={opt.value}
68
- disabled={disabled || opt.disabled}
69
- checked={value === opt.value}
70
- onchange={() => select(opt)}
71
- class="peer absolute inset-0 m-0 h-full w-full cursor-pointer opacity-0 disabled:cursor-not-allowed"
72
- />
73
- <span
74
- class="pointer-events-none flex h-4 w-4 items-center justify-center rounded-full border-2 transition-colors duration-150
75
- {value === opt.value
76
- ? 'border-brand bg-surface-2'
77
- : 'border-line bg-surface-2 peer-focus-visible:border-brand'}"
78
- >
79
- {#if value === opt.value}
80
- <span class="h-2 w-2 rounded-full bg-brand"></span>
81
- {/if}
82
- </span>
83
- </span>
84
- <span class="flex flex-col gap-0.5">
85
- <span class="text-sm text-fg">{opt.label}</span>
86
- {#if opt.hint}<span class={FIELD_HINT}>{opt.hint}</span>{/if}
87
- </span>
88
- </label>
115
+ <div class={choiceGroupList(orientation)}>
116
+ {#each options as option (option.value)}
117
+ <!--
118
+ The option renders through Radio rather than through a copy of it. The
119
+ copy is what let this group rest its circle on border-line, at 1.25:1,
120
+ and keep its focus ring inside the selected branch, so choosing an
121
+ option deleted the only indicator a keyboard user had. Radio had been
122
+ fixed for both defects while the group still carried them.
123
+ -->
124
+ <Radio
125
+ bind:group={value}
126
+ value={option.value}
127
+ label={option.label}
128
+ description={option.description}
129
+ icon={option.icon}
130
+ name={groupName}
131
+ {size}
132
+ {variant}
133
+ disabled={disabled || option.disabled === true}
134
+ {onchange}
135
+ />
89
136
  {/each}
90
137
  </div>
91
138
 
92
139
  {#if error}
93
- <p class={FIELD_ERROR}>{error}</p>
140
+ <p id="{uid}-error" class={FIELD_ERROR}>{error}</p>
94
141
  {:else if hint}
95
- <p class={FIELD_HINT}>{hint}</p>
142
+ <p id="{uid}-hint" class={FIELD_HINT}>{hint}</p>
96
143
  {/if}
97
144
  </fieldset>
@@ -1,22 +1,52 @@
1
- interface Option {
1
+ import type { Component } from 'svelte';
2
+ /**
3
+ * One option in a choice group.
4
+ *
5
+ * CheckboxGroup declares this interface in the same words, so a consumer who
6
+ * has written one option list can write the other without rereading the
7
+ * types. A colocated test compares the two declarations character for
8
+ * character, because the group and the single control had already drifted
9
+ * apart once.
10
+ */
11
+ export interface ChoiceOption {
12
+ /** The submitted value, and the key the option is rendered under. */
2
13
  value: string;
14
+ /** Names this one choice. The group is named by the legend instead. */
3
15
  label: string;
4
- hint?: string;
16
+ /** A second line under the label, carrying what the label leaves out. */
17
+ description?: string;
18
+ /** A lucide component. The group sizes it, so the caller does not guess. */
19
+ icon?: Component<{
20
+ size?: number;
21
+ class?: string;
22
+ }>;
23
+ /** Blocks this option alone. A disabled group blocks every option. */
5
24
  disabled?: boolean;
6
25
  }
7
- type $$ComponentProps = {
26
+ import { type ChoiceOrientation, type ChoiceSize, type ChoiceVariant } from '../internal/choice.js';
27
+ interface Props {
28
+ /** The selected value. One of the option values, or empty for none. */
8
29
  value?: string;
9
- options: Option[];
10
- name?: string;
11
- label?: string;
30
+ options: ChoiceOption[];
31
+ /**
32
+ * The group's accessible name, rendered as a legend. Required because an
33
+ * unnamed group announces as a bare set of controls, which leaves a reader
34
+ * to infer what the set is for from the options alone.
35
+ */
36
+ label: string;
37
+ /** Keeps the legend for a screen reader and takes it off the screen. */
38
+ labelHidden?: boolean;
12
39
  hint?: string;
13
40
  error?: string;
41
+ name?: string;
42
+ orientation?: ChoiceOrientation;
43
+ size?: ChoiceSize;
44
+ variant?: ChoiceVariant;
14
45
  required?: boolean;
15
46
  disabled?: boolean;
16
- orientation?: 'vertical' | 'horizontal';
17
47
  class?: string;
18
48
  onchange?: (value: string) => void;
19
- };
20
- declare const RadioGroup: import("svelte").Component<$$ComponentProps, {}, "value">;
49
+ }
50
+ declare const RadioGroup: Component<Props, {}, "value">;
21
51
  type RadioGroup = ReturnType<typeof RadioGroup>;
22
52
  export default RadioGroup;
@@ -0,0 +1,39 @@
1
+ <script lang="ts">
2
+ /**
3
+ * A heading for a section of a page, with an optional row of controls beside
4
+ * it.
5
+ *
6
+ * Fourteen distinct class strings serve this role across one app, so two
7
+ * sections on the same page render at different sizes and weights, and some
8
+ * of them are a styled `div` that no heading query can find. Taking the level
9
+ * rather than a free-form class means the type treatment cannot disagree with
10
+ * the element, and the element is always a real heading.
11
+ */
12
+ import type { Snippet } from 'svelte';
13
+ import { sectionHeading } from '../internal/layout.js';
14
+
15
+ interface Props {
16
+ /** 2 under a page title, 3 inside a card. */
17
+ level?: 2 | 3;
18
+ actions?: Snippet;
19
+ class?: string;
20
+ children: Snippet;
21
+ }
22
+
23
+ let { level = 2, actions, class: klass = '', children }: Props = $props();
24
+ </script>
25
+
26
+ <div class="flex flex-wrap items-center justify-between gap-4 {klass}">
27
+ <!-- Two branches rather than one dynamic element: the level is a document
28
+ structure decision, and a reader of this file should be able to see both
29
+ headings it can produce. -->
30
+ {#if level === 2}
31
+ <h2 class={sectionHeading(2)}>{@render children()}</h2>
32
+ {:else}
33
+ <h3 class={sectionHeading(3)}>{@render children()}</h3>
34
+ {/if}
35
+
36
+ {#if actions}
37
+ <div class="flex shrink-0 items-center gap-2">{@render actions()}</div>
38
+ {/if}
39
+ </div>
@@ -0,0 +1,21 @@
1
+ /**
2
+ * A heading for a section of a page, with an optional row of controls beside
3
+ * it.
4
+ *
5
+ * Fourteen distinct class strings serve this role across one app, so two
6
+ * sections on the same page render at different sizes and weights, and some
7
+ * of them are a styled `div` that no heading query can find. Taking the level
8
+ * rather than a free-form class means the type treatment cannot disagree with
9
+ * the element, and the element is always a real heading.
10
+ */
11
+ import type { Snippet } from 'svelte';
12
+ interface Props {
13
+ /** 2 under a page title, 3 inside a card. */
14
+ level?: 2 | 3;
15
+ actions?: Snippet;
16
+ class?: string;
17
+ children: Snippet;
18
+ }
19
+ declare const SectionHeading: import("svelte").Component<Props, {}, "">;
20
+ type SectionHeading = ReturnType<typeof SectionHeading>;
21
+ export default SectionHeading;
@@ -0,0 +1,194 @@
1
+ <script lang="ts" module>
2
+ import type { Component } from 'svelte';
3
+
4
+ /** One segment of the row. */
5
+ export interface SegmentOption<T extends string = string> {
6
+ /** The chosen value, and the key the segment is rendered under. */
7
+ value: T;
8
+ /** Names this one segment. The group is named by `label`. */
9
+ label: string;
10
+ /** A lucide component. The control sizes it, so the caller does not guess. */
11
+ icon?: Component<{ size?: number; class?: string }>;
12
+ }
13
+ </script>
14
+
15
+ <script lang="ts" generics="T extends string">
16
+ import { FIELD_LABEL, FIELD_WRAP } from '../internal/field.js';
17
+
18
+ type Size = 'sm' | 'md';
19
+
20
+ interface Props {
21
+ /** The chosen value. One of the option values. */
22
+ value: T;
23
+ options: SegmentOption<T>[];
24
+ /**
25
+ * The group's accessible name. Required because an unnamed group announces
26
+ * as a bare set of controls, which leaves a reader to work out what the set
27
+ * is for from the segments alone.
28
+ */
29
+ label: string;
30
+ /** Drops the caption above the row. The group keeps its accessible name. */
31
+ labelHidden?: boolean;
32
+ /** Submits the value with the surrounding form, through a hidden input. */
33
+ name?: string;
34
+ size?: Size;
35
+ disabled?: boolean;
36
+ class?: string;
37
+ onchange?: (value: T) => void;
38
+ }
39
+
40
+ let {
41
+ value = $bindable(),
42
+ options,
43
+ label,
44
+ labelHidden = false,
45
+ name = undefined,
46
+ size = 'md',
47
+ disabled = false,
48
+ class: klass = '',
49
+ onchange = undefined,
50
+ }: Props = $props();
51
+
52
+ /**
53
+ * The row height comes from the control token, so a segmented control and an
54
+ * Input in the same row line up. The segments stretch inside it rather than
55
+ * setting their own height, which would add the row's padding and border on
56
+ * top and stand the control two pixels proud of everything beside it.
57
+ */
58
+ const SIZES: Record<Size, { row: string; segment: string; icon: number }> = {
59
+ sm: { row: 'h-8', segment: 'gap-1.5 px-2.5 text-xs', icon: 14 },
60
+ md: { row: 'h-control', segment: 'gap-2 px-3 text-sm', icon: 16 },
61
+ };
62
+
63
+ let rootEl: HTMLDivElement | undefined = $state();
64
+
65
+ const selectedIndex = $derived(options.findIndex((o) => o.value === value));
66
+
67
+ /*
68
+ * The group holds ONE tab stop, which is the whole reason this is not a row
69
+ * of buttons: a picker of five themes cost five presses of Tab to step over.
70
+ *
71
+ * A value matching no option would leave every segment at tabindex -1 and
72
+ * drop the control out of the tab order entirely, so the first segment holds
73
+ * the stop until something is chosen.
74
+ */
75
+ const tabStop = $derived(selectedIndex === -1 ? 0 : selectedIndex);
76
+
77
+ function segmentAt(index: number): HTMLElement | undefined {
78
+ return rootEl?.querySelectorAll<HTMLElement>('[role="radio"]')[index];
79
+ }
80
+
81
+ function choose(index: number) {
82
+ // Disabled is enforced here as well as on each button. The native attribute
83
+ // is what stops a real press, and it is the only thing that does, so a
84
+ // change arriving any other way would still move the bound value with
85
+ // nothing on screen to say so.
86
+ if (disabled) return;
87
+ const next = options[index];
88
+ if (!next) return;
89
+ if (next.value !== value) {
90
+ value = next.value;
91
+ onchange?.(next.value);
92
+ }
93
+ // The roving stop follows the pointer too. A press does not focus a button
94
+ // in every browser, so without this a later Tab re-enters the group on
95
+ // whichever segment the keyboard last left.
96
+ segmentAt(index)?.focus();
97
+ }
98
+
99
+ /**
100
+ * The radio keyboard pattern: the arrows move the selection and wrap, Home
101
+ * and End go to the ends, and focus travels with the choice.
102
+ */
103
+ function onSegmentKeydown(e: KeyboardEvent, index: number) {
104
+ const last = options.length - 1;
105
+ let next: number;
106
+ switch (e.key) {
107
+ case 'ArrowRight':
108
+ case 'ArrowDown':
109
+ next = index === last ? 0 : index + 1;
110
+ break;
111
+ case 'ArrowLeft':
112
+ case 'ArrowUp':
113
+ next = index === 0 ? last : index - 1;
114
+ break;
115
+ case 'Home':
116
+ next = 0;
117
+ break;
118
+ case 'End':
119
+ next = last;
120
+ break;
121
+ default:
122
+ return;
123
+ }
124
+ // The arrows scroll the page, and Home and End jump it to the ends.
125
+ e.preventDefault();
126
+ choose(next);
127
+ }
128
+ </script>
129
+
130
+ <div class="{FIELD_WRAP} items-start {klass}">
131
+ {#if !labelHidden}
132
+ <!-- A caption, not a label element. The group is named by aria-label, and a
133
+ label has nothing to point at here: role="radiogroup" is not a form
134
+ control that `for` can name. -->
135
+ <span class={FIELD_LABEL}>{label}</span>
136
+ {/if}
137
+
138
+ <div
139
+ bind:this={rootEl}
140
+ role="radiogroup"
141
+ aria-label={label}
142
+ class="inline-flex items-stretch rounded-lg border border-line-strong bg-surface-2 p-0.5 {SIZES[
143
+ size
144
+ ].row} {disabled ? 'opacity-50' : ''}"
145
+ >
146
+ {#each options as option, i (option.value)}
147
+ {@const selected = option.value === value}
148
+ {@const Icon = option.icon}
149
+ <!--
150
+ Each segment is a radio, and aria-checked is what a screen reader reads.
151
+ The theme picker this replaces was a row of plain buttons whose choice
152
+ was carried by a background colour alone, so a screen reader user was
153
+ told nothing and a colour-blind user saw nothing. The selected segment
154
+ also sits at a heavier weight, so the state survives a palette a reader
155
+ cannot separate.
156
+ -->
157
+ <button
158
+ type="button"
159
+ role="radio"
160
+ aria-checked={selected}
161
+ tabindex={i === tabStop ? 0 : -1}
162
+ {disabled}
163
+ onclick={() => choose(i)}
164
+ onkeydown={(e) => onSegmentKeydown(e, i)}
165
+ class="inline-flex items-center justify-center rounded-md outline-none transition-colors duration-150 focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-brand disabled:cursor-not-allowed {SIZES[
166
+ size
167
+ ].segment} {selected ? 'bg-surface text-fg shadow-sm' : 'text-muted hover:text-fg'}"
168
+ >
169
+ {#if Icon}
170
+ <Icon size={SIZES[size].icon} class="shrink-0" />
171
+ {/if}
172
+ <span class="grid text-center">
173
+ <!-- The bold copy holds the width of the heaviest state in every
174
+ segment. Weight alone would resize the segment the selection
175
+ lands on, and the whole row would shuffle under the pointer as
176
+ the user moved along it. -->
177
+ <span class="invisible col-start-1 row-start-1 font-semibold" aria-hidden="true"
178
+ >{option.label}</span
179
+ >
180
+ <span class="col-start-1 row-start-1 {selected ? 'font-semibold' : 'font-normal'}"
181
+ >{option.label}</span
182
+ >
183
+ </span>
184
+ </button>
185
+ {/each}
186
+ </div>
187
+
188
+ {#if name}
189
+ <!-- A div with role="radiogroup" submits nothing. The value reaches a form
190
+ post through this and not through the segments, which are buttons and
191
+ would each post their own. -->
192
+ <input type="hidden" {name} {value} />
193
+ {/if}
194
+ </div>
@@ -0,0 +1,55 @@
1
+ import type { Component } from 'svelte';
2
+ /** One segment of the row. */
3
+ export interface SegmentOption<T extends string = string> {
4
+ /** The chosen value, and the key the segment is rendered under. */
5
+ value: T;
6
+ /** Names this one segment. The group is named by `label`. */
7
+ label: string;
8
+ /** A lucide component. The control sizes it, so the caller does not guess. */
9
+ icon?: Component<{
10
+ size?: number;
11
+ class?: string;
12
+ }>;
13
+ }
14
+ declare function $$render<T extends string>(): {
15
+ props: {
16
+ /** The chosen value. One of the option values. */
17
+ value: T;
18
+ options: SegmentOption<T>[];
19
+ /**
20
+ * The group's accessible name. Required because an unnamed group announces
21
+ * as a bare set of controls, which leaves a reader to work out what the set
22
+ * is for from the segments alone.
23
+ */
24
+ label: string;
25
+ /** Drops the caption above the row. The group keeps its accessible name. */
26
+ labelHidden?: boolean;
27
+ /** Submits the value with the surrounding form, through a hidden input. */
28
+ name?: string;
29
+ size?: "sm" | "md";
30
+ disabled?: boolean;
31
+ class?: string;
32
+ onchange?: (value: T) => void;
33
+ };
34
+ exports: {};
35
+ bindings: "value";
36
+ slots: {};
37
+ events: {};
38
+ };
39
+ declare class __sveltets_Render<T extends string> {
40
+ props(): ReturnType<typeof $$render<T>>['props'];
41
+ events(): ReturnType<typeof $$render<T>>['events'];
42
+ slots(): ReturnType<typeof $$render<T>>['slots'];
43
+ bindings(): "value";
44
+ exports(): {};
45
+ }
46
+ interface $$IsomorphicComponent {
47
+ new <T extends string>(options: import('svelte').ComponentConstructorOptions<ReturnType<__sveltets_Render<T>['props']>>): import('svelte').SvelteComponent<ReturnType<__sveltets_Render<T>['props']>, ReturnType<__sveltets_Render<T>['events']>, ReturnType<__sveltets_Render<T>['slots']>> & {
48
+ $$bindings?: ReturnType<__sveltets_Render<T>['bindings']>;
49
+ } & ReturnType<__sveltets_Render<T>['exports']>;
50
+ <T extends string>(internal: unknown, props: ReturnType<__sveltets_Render<T>['props']> & {}): ReturnType<__sveltets_Render<T>['exports']>;
51
+ z_$$bindings?: ReturnType<__sveltets_Render<any>['bindings']>;
52
+ }
53
+ declare const SegmentedControl: $$IsomorphicComponent;
54
+ type SegmentedControl<T extends string> = InstanceType<typeof SegmentedControl<T>>;
55
+ export default SegmentedControl;