@juspay/svelte-ui-components 4.27.5 → 4.28.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 (164) hide show
  1. package/README.md +140 -36
  2. package/dist/Animations/ModalAnimation.svelte +15 -1
  3. package/dist/Animations/OverlayAnimation.svelte +7 -1
  4. package/dist/AreaChart/AreaChart.svelte +366 -85
  5. package/dist/AspectRatio/AspectRatio.svelte +51 -0
  6. package/dist/AspectRatio/AspectRatio.svelte.d.ts +3 -0
  7. package/dist/AspectRatio/properties.d.ts +27 -0
  8. package/dist/AspectRatio/properties.js +1 -0
  9. package/dist/Banner/Banner.svelte +10 -4
  10. package/dist/Banner/properties.d.ts +8 -0
  11. package/dist/BarChart/BarChart.svelte +156 -49
  12. package/dist/Book/Book.svelte +14 -1
  13. package/dist/BrandLoader/BrandLoader.svelte +76 -12
  14. package/dist/Button/Button.svelte +56 -11
  15. package/dist/Calendar/Calendar.svelte +88 -61
  16. package/dist/Card/Card.svelte +1 -1
  17. package/dist/Carousel/Carousel.svelte +6 -3
  18. package/dist/ChatComposer/ChatComposer.svelte +54 -4
  19. package/dist/ChatComposer/dictationState.d.ts +15 -0
  20. package/dist/ChatComposer/dictationState.js +9 -0
  21. package/dist/ChatComposer/properties.d.ts +28 -1
  22. package/dist/ChatMessage/ChatMessage.svelte +27 -1
  23. package/dist/CheckListItem/CheckListItem.svelte +3 -3
  24. package/dist/Checkbox/Checkbox.svelte +84 -3
  25. package/dist/Checkbox/properties.d.ts +45 -13
  26. package/dist/ChipInput/ChipInput.svelte +70 -3
  27. package/dist/ChipInput/properties.d.ts +16 -0
  28. package/dist/Choicebox/Choicebox.svelte +156 -1
  29. package/dist/Choicebox/group.d.ts +63 -0
  30. package/dist/Choicebox/group.js +127 -0
  31. package/dist/Choicebox/properties.d.ts +46 -0
  32. package/dist/ColorPicker/ColorPicker.svelte +169 -20
  33. package/dist/ColorPicker/properties.d.ts +16 -0
  34. package/dist/Combobox/Combobox.svelte +87 -25
  35. package/dist/Combobox/properties.d.ts +23 -0
  36. package/dist/CommandMenu/CommandMenu.svelte +27 -11
  37. package/dist/ContextMenu/ContextMenu.svelte +31 -37
  38. package/dist/DateRangePicker/DateRangePicker.svelte +171 -73
  39. package/dist/DeltaIndicator/DeltaIndicator.svelte +3 -3
  40. package/dist/Draggable/Draggable.svelte +12 -7
  41. package/dist/DualAxisBarChart/DualAxisBarChart.svelte +11 -4
  42. package/dist/EmptyState/EmptyState.svelte +1 -1
  43. package/dist/FileDropzoneTrigger/FileDropzoneTrigger.svelte +1 -1
  44. package/dist/FileInput/FileInput.svelte +50 -1
  45. package/dist/FileInput/properties.d.ts +7 -0
  46. package/dist/FunnelChart/FunnelChart.svelte +40 -12
  47. package/dist/FunnelChart/properties.d.ts +7 -1
  48. package/dist/Gallery/Gallery.svelte +20 -13
  49. package/dist/Gallery/properties.d.ts +8 -0
  50. package/dist/GridItem/GridItem.svelte +31 -1
  51. package/dist/HITL/HITL.svelte +35 -9
  52. package/dist/Icon/Icon.svelte +19 -3
  53. package/dist/Input/Input.svelte +24 -10
  54. package/dist/Input/properties.d.ts +37 -1
  55. package/dist/InputButton/InputButton.svelte +86 -11
  56. package/dist/InputButton/properties.d.ts +10 -1
  57. package/dist/Label/Label.svelte +54 -0
  58. package/dist/Label/Label.svelte.d.ts +3 -0
  59. package/dist/Label/properties.d.ts +26 -0
  60. package/dist/Label/properties.js +1 -0
  61. package/dist/LineChart/LineChart.svelte +385 -94
  62. package/dist/ListItem/ListItem.svelte +121 -15
  63. package/dist/Loader/Loader.svelte +22 -2
  64. package/dist/LoadingDots/LoadingDots.svelte +21 -0
  65. package/dist/MediaUpload/MediaUpload.svelte +13 -3
  66. package/dist/Menu/Menu.svelte +56 -28
  67. package/dist/Modal/Modal.svelte +77 -42
  68. package/dist/Pagination/Pagination.svelte +9 -2
  69. package/dist/Phone/Phone.svelte +4 -0
  70. package/dist/PieChart/PieChart.svelte +282 -38
  71. package/dist/PieChart/properties.d.ts +9 -0
  72. package/dist/Pill/Pill.svelte +14 -5
  73. package/dist/Progress/Progress.svelte +20 -0
  74. package/dist/Radio/Radio.svelte +58 -1
  75. package/dist/Radio/properties.d.ts +23 -0
  76. package/dist/RatingGroup/RatingGroup.svelte +283 -0
  77. package/dist/RatingGroup/RatingGroup.svelte.d.ts +4 -0
  78. package/dist/RatingGroup/properties.d.ts +73 -0
  79. package/dist/RatingGroup/properties.js +1 -0
  80. package/dist/RelativeTime/RelativeTime.svelte +1 -1
  81. package/dist/SankeyChart/SankeyChart.svelte +272 -50
  82. package/dist/SankeyChart/properties.d.ts +9 -0
  83. package/dist/Scroller/Scroller.svelte +37 -2
  84. package/dist/Select/Select.svelte +166 -46
  85. package/dist/Select/properties.d.ts +7 -0
  86. package/dist/Separator/Separator.svelte +48 -0
  87. package/dist/Separator/Separator.svelte.d.ts +3 -0
  88. package/dist/Separator/properties.d.ts +22 -0
  89. package/dist/Separator/properties.js +1 -0
  90. package/dist/Sheet/Sheet.svelte +60 -21
  91. package/dist/Shimmer/Shimmer.svelte +15 -0
  92. package/dist/Slider/Slider.svelte +87 -6
  93. package/dist/Slider/properties.d.ts +26 -0
  94. package/dist/Snippet/Snippet.svelte +4 -0
  95. package/dist/Snippet/copyState.svelte.d.ts +13 -1
  96. package/dist/Snippet/copyState.svelte.js +27 -1
  97. package/dist/Snippet/properties.d.ts +7 -0
  98. package/dist/SoundKit/SoundKit.js +9 -8
  99. package/dist/SplitInput/SplitInput.svelte +68 -2
  100. package/dist/SplitInput/properties.d.ts +23 -0
  101. package/dist/StatCard/StatCard.svelte +6 -6
  102. package/dist/Status/Status.svelte +2 -2
  103. package/dist/Stepper/Step.svelte +19 -6
  104. package/dist/Stepper/Stepper.svelte +108 -35
  105. package/dist/Table/BuiltinCell.svelte +2 -2
  106. package/dist/Table/Table.svelte +373 -55
  107. package/dist/Table/properties.d.ts +134 -3
  108. package/dist/Tabs/Tabs.svelte +263 -65
  109. package/dist/Tabs/properties.d.ts +23 -0
  110. package/dist/TaskList/TaskList.svelte +14 -5
  111. package/dist/ThemeSwitcher/ThemeSwitcher.svelte +23 -0
  112. package/dist/ThinkingIndicator/ThinkingIndicator.svelte +13 -5
  113. package/dist/Toast/Toast.svelte +27 -2
  114. package/dist/Toggle/Toggle.svelte +79 -7
  115. package/dist/Toggle/Toggle.svelte.d.ts +1 -1
  116. package/dist/Toggle/properties.d.ts +30 -7
  117. package/dist/ToolCallLog/ToolCallLog.svelte +59 -21
  118. package/dist/ToolCallLog/properties.d.ts +10 -0
  119. package/dist/Toolbar/Toolbar.svelte +1 -1
  120. package/dist/Tooltip/Tooltip.svelte +20 -2
  121. package/dist/Tooltip/tooltip-action.js +39 -4
  122. package/dist/TypewriterText/TypewriterText.svelte +34 -2
  123. package/dist/TypewriterText/properties.d.ts +18 -5
  124. package/dist/_chart/ChartContainer.svelte +12 -0
  125. package/dist/_chart/ChartTooltip.svelte +18 -4
  126. package/dist/_chart/Legend.svelte +21 -0
  127. package/dist/_chart/Legend.svelte.d.ts +15 -0
  128. package/dist/_chart/geometry.d.ts +54 -1
  129. package/dist/_chart/geometry.js +122 -14
  130. package/dist/_chart/interactions.d.ts +5 -1
  131. package/dist/_chart/interactions.js +9 -5
  132. package/dist/_chart/scales.js +16 -0
  133. package/dist/_chart/temporal.d.ts +45 -0
  134. package/dist/_chart/temporal.js +116 -0
  135. package/dist/_field/description.d.ts +50 -0
  136. package/dist/_field/description.js +51 -0
  137. package/dist/_interaction/dismissal.d.ts +67 -0
  138. package/dist/_interaction/dismissal.js +137 -0
  139. package/dist/_interaction/focus.d.ts +73 -0
  140. package/dist/_interaction/focus.js +143 -0
  141. package/dist/assets/star.svg +5 -0
  142. package/dist/index.d.ts +13 -1
  143. package/dist/index.js +21 -1
  144. package/dist/reduced-motion.svelte.d.ts +37 -0
  145. package/dist/reduced-motion.svelte.js +44 -0
  146. package/dist/styles/theme-dark.css +778 -0
  147. package/dist/types.d.ts +4 -2
  148. package/dist/utils.d.ts +62 -1
  149. package/dist/utils.js +80 -21
  150. package/dist-codemod/{bin.js → codemod/bin.js} +13 -2
  151. package/dist-codemod/{cli.js → codemod/cli.js} +48 -3
  152. package/dist-codemod/codemod/migrate-audit.js +395 -0
  153. package/dist-codemod/codemod/migration-assets/legacy-display.css +120 -0
  154. package/dist-codemod/codemod/migration-assets/legacy-palette.css +106 -0
  155. package/dist-codemod/codemod/migration-assets/wc-display.json +492 -0
  156. package/dist-codemod/migrate/analyze.js +804 -0
  157. package/dist-codemod/migrate/chart-tooltip-selector.js +301 -0
  158. package/dist-wc/index.js +13992 -4323
  159. package/package.json +13 -6
  160. package/dist/Modal/focus-trap.d.ts +0 -42
  161. package/dist/Modal/focus-trap.js +0 -57
  162. /package/dist-codemod/{legacy-pairs.js → codemod/legacy-pairs.js} +0 -0
  163. /package/dist-codemod/{transform.js → codemod/transform.js} +0 -0
  164. /package/dist-codemod/{wc-children.js → codemod/wc-children.js} +0 -0
@@ -1,5 +1,6 @@
1
1
  <script lang="ts">
2
2
  import { flushSync } from 'svelte';
3
+ import { describeField } from '../_field/description';
3
4
  import type { CheckboxProperties } from './properties';
4
5
  import checkmarkSvg from '../assets/checkmark.svg?raw';
5
6
  import minusSvg from '../assets/minus.svg?raw';
@@ -19,9 +20,37 @@
19
20
  controlled = false,
20
21
  attributes,
21
22
  name,
22
- value
23
+ value,
24
+ required = false,
25
+ form,
26
+ errorMessage,
27
+ infoMessage,
28
+ invalid = false
23
29
  }: CheckboxProperties = $props();
24
30
 
31
+ /* The control and the text that explains it were never linked: a screen-reader
32
+ user reaching this control heard its name and nothing about why it was
33
+ rejected. `describeField` composes the reference from the messages that are
34
+ actually rendered, so aria-describedby never points at an id that is not in
35
+ the DOM -- which passes an attribute assertion and resolves to nothing in a
36
+ real reader. */
37
+ const fieldUid = $props.id();
38
+ const field = $derived(
39
+ describeField(fieldUid, { error: errorMessage, info: infoMessage, invalid })
40
+ );
41
+
42
+ // A mixed box submits nothing and satisfies nothing, so the control behind it is
43
+ // unchecked while `indeterminate` holds — the native property, not a third state.
44
+ const submitsChecked: boolean = $derived(indeterminate ? false : checked);
45
+ const boxState: 'checked' | 'unchecked' | 'indeterminate' = $derived(
46
+ indeterminate ? 'indeterminate' : checked ? 'checked' : 'unchecked'
47
+ );
48
+
49
+ let box: HTMLSpanElement | null = $state(null);
50
+ // Release's own ref, kept: the native input is aria-hidden, tabindex="-1" and
51
+ // pointer-events: none -- a form-submission mirror that never receives a real
52
+ // click, so the browser never fires `change` on it either. handleClick
53
+ // dispatches input/change manually through this ref.
25
54
  let nativeInputEl: HTMLInputElement | null = $state(null);
26
55
 
27
56
  // Visible text wins over `ariaLabel`, which is what `properties.ts` has always
@@ -90,6 +119,14 @@
90
119
  nativeInputEl?.dispatchEvent(new Event('change', { bubbles: true, composed: true }));
91
120
  }
92
121
 
122
+ // The form control is `tabindex="-1"`, so the browser's own "focus the invalid
123
+ // field" step would land on an element the user cannot see or reach. Focus the
124
+ // box that carries the role instead.
125
+ function handleInvalid(e: Event): void {
126
+ e.preventDefault();
127
+ box?.focus();
128
+ }
129
+
93
130
  function handleKeyDown(e: KeyboardEvent): void {
94
131
  if (e.key === ' ' || e.key === 'Enter') {
95
132
  e.preventDefault();
@@ -117,10 +154,14 @@
117
154
  <input
118
155
  type="checkbox"
119
156
  class="native-checkbox"
120
- {checked}
157
+ checked={submitsChecked}
158
+ {indeterminate}
121
159
  {disabled}
122
- name={name ?? null}
123
160
  value={value ?? null}
161
+ {required}
162
+ name={typeof name === 'string' ? name : null}
163
+ form={typeof form === 'string' ? form : null}
164
+ oninvalid={handleInvalid}
124
165
  tabindex={-1}
125
166
  aria-hidden="true"
126
167
  onclick={(e: MouseEvent) => e.stopPropagation()}
@@ -129,10 +170,16 @@
129
170
  testID={typeof testId === 'string' ? `${testId}-native-input` : null}
130
171
  />
131
172
  <span
173
+ bind:this={box}
132
174
  class="box"
133
175
  class:checked
134
176
  class:indeterminate
135
177
  role="checkbox"
178
+ aria-describedby={field.describedBy}
179
+ aria-invalid={field.ariaInvalid}
180
+ data-state={boxState}
181
+ data-disabled={disabled ? '' : null}
182
+ aria-required={required ? 'true' : null}
136
183
  tabindex={disabled ? -1 : 0}
137
184
  aria-checked={indeterminate ? 'mixed' : checked}
138
185
  aria-disabled={disabled}
@@ -163,6 +210,28 @@
163
210
  <span class="label" hidden={text.length === 0}>{text}</span>
164
211
  </label>
165
212
 
213
+ {#if field.showsError}
214
+ <div
215
+ id={field.errorId}
216
+ role="alert"
217
+ class="field-error"
218
+ data-pw={typeof testId === 'string' ? `${testId}-error-message` : null}
219
+ testID={typeof testId === 'string' ? `${testId}-error-message` : null}
220
+ >
221
+ {errorMessage}
222
+ </div>
223
+ {/if}
224
+ {#if field.showsInfo}
225
+ <div
226
+ id={field.infoId}
227
+ class="field-info"
228
+ data-pw={typeof testId === 'string' ? `${testId}-info-message` : null}
229
+ testID={typeof testId === 'string' ? `${testId}-info-message` : null}
230
+ >
231
+ {infoMessage}
232
+ </div>
233
+ {/if}
234
+
166
235
  <style>
167
236
  .container {
168
237
  display: var(--checkbox-container-display, inline-flex);
@@ -241,4 +310,16 @@
241
310
  font-weight: var(--checkbox-label-font-weight, 400);
242
311
  color: var(--checkbox-label-color, #212121);
243
312
  }
313
+
314
+ .field-error {
315
+ color: var(--field-error-color, #c5120a);
316
+ font-size: var(--field-error-font-size, 12px);
317
+ margin: var(--field-error-margin, 4px 0 0 0);
318
+ }
319
+
320
+ .field-info {
321
+ color: var(--field-info-color, #6b7280);
322
+ font-size: var(--field-info-font-size, 12px);
323
+ margin: var(--field-info-margin, 4px 0 0 0);
324
+ }
244
325
  </style>
@@ -4,12 +4,37 @@ export type MandatoryCheckboxProperties = {
4
4
  text?: string;
5
5
  };
6
6
  export type OptionalCheckboxProperties = {
7
- /** Visible label. Optional, defaulting to `''` (unlabelled) -- matching Toggle's
8
- * own `text` contract (Toggle/properties.ts) -- so a form-only checkbox
9
- * (`<Checkbox name="agree" />`) does not need an empty string just to compile.
10
- * A checkbox with no visible text needs `ariaLabel` for an accessible name;
11
- * see `ariaLabel` below. */
7
+ /**
8
+ * Visible label. Optional, and empty by default -- matching Toggle's own `text`
9
+ * contract (Toggle/properties.ts) -- so a form-only checkbox
10
+ * (`<Checkbox name="agree" />`) does not need an empty string just to compile.
11
+ *
12
+ * It mattered beyond the type. `Checkbox` dereferences `text.length`, and the
13
+ * custom-element build type-checks nothing, so `<sui-checkbox name="terms">`
14
+ * with no `text` attribute threw during render and left an empty shadow root
15
+ * -- the same failure mode as the `attributes` collision, from a different
16
+ * cause. `Toggle` and `Radio` already declared it optional with a default.
17
+ *
18
+ * A checkbox with no visible text needs `ariaLabel` for an accessible name;
19
+ * see `ariaLabel` below.
20
+ */
12
21
  text?: string;
22
+ /**
23
+ * Text shown, and announced, when the control is in error. Linked to the
24
+ * control through `aria-describedby`, and sets `aria-invalid` while present.
25
+ */
26
+ errorMessage?: string | null;
27
+ /**
28
+ * Persistent helper text describing the control. Linked through
29
+ * `aria-describedby` too, so it is read before the user trips an error rather
30
+ * than only after.
31
+ */
32
+ infoMessage?: string | null;
33
+ /**
34
+ * Marks the control invalid without supplying a message, for a consumer
35
+ * driving validity from a server or its own rules.
36
+ */
37
+ invalid?: boolean;
13
38
  checked?: boolean;
14
39
  disabled?: boolean;
15
40
  indeterminate?: boolean;
@@ -33,16 +58,23 @@ export type OptionalCheckboxProperties = {
33
58
  * `aria-controls` to point at, or a consumer's own test attribute. Spread
34
59
  * last, so a value here wins over the component's own `data-pw`. */
35
60
  attributes?: Record<string, string>;
36
- /** Native `name` on the underlying `<input type="checkbox">`, so the control
37
- * participates in a surrounding `<form>`'s submission. Omitted by default:
38
- * the input stays nameless and outside `FormData`, exactly as before this
39
- * prop existed. */
61
+ /** Submits the box under this name when it sits inside (or is associated with) a
62
+ * form. Omitted by default: with no `name` the box stays outside `FormData`,
63
+ * exactly as before this prop existed. Omitted from submission too when unchecked,
64
+ * indeterminate or disabled, which is what a native `<input type="checkbox">` does. */
40
65
  name?: string;
41
- /** Native `value` submitted when checked. Defaults to `"on"` — the native
42
- * checkbox default — when omitted, so setting `name` alone is enough to
43
- * form-associate a plain "checked or not" checkbox. An unchecked checkbox
44
- * never appears in `FormData`, matching native behaviour. */
66
+ /** Value submitted while checked. Left unset, the attribute is omitted and the native
67
+ * checkbox's own `"on"` default is what submits -- so setting `name` alone is enough to
68
+ * form-associate a plain "checked or not" box, and an existing consumer's DOM is
69
+ * unchanged. The default is the platform's, not one this component hardcodes. */
45
70
  value?: string;
71
+ /** Makes an unchecked box block submission. Invalid validation moves focus to the
72
+ * visible box, since the form control behind it is deliberately not a tab stop. */
73
+ required?: boolean;
74
+ /** `id` of a form elsewhere in the same document, for a box rendered outside it.
75
+ * Custom elements have no form association: inside `<sui-checkbox>` the control
76
+ * lives in a shadow root and cannot reach a form in the host document. */
77
+ form?: string;
46
78
  };
47
79
  export type CheckboxEventProperties = {
48
80
  onclick?: (checked: boolean) => void;
@@ -1,4 +1,5 @@
1
1
  <script lang="ts">
2
+ import { describeField } from '../_field/description';
2
3
  import Input from '../Input/Input.svelte';
3
4
  import Pill from '../Pill/Pill.svelte';
4
5
  import type { ChipInputProperties } from './properties';
@@ -11,12 +12,33 @@
11
12
  editable = false,
12
13
  testId,
13
14
  classes,
15
+ errorMessage,
16
+ infoMessage,
17
+ invalid = false,
14
18
  onadd,
15
19
  ondismiss,
16
20
  onedit,
17
21
  onchange
18
22
  }: ChipInputProperties = $props();
19
23
 
24
+ /* The widget and the text that explains it were never linked: a screen-reader
25
+ user heard the draft field's name and nothing about why the last value was
26
+ rejected. `describeField` composes the reference from the messages that are
27
+ actually rendered, so aria-describedby never points at an id that is not in
28
+ the DOM -- which passes an attribute assertion and resolves to nothing in a
29
+ real reader.
30
+
31
+ It describes the GROUP rather than the draft field, because that is what the
32
+ message is about: "at most five tags" is a fact about the collection, not
33
+ about the box the next one is typed into. The role is conditional so a
34
+ ChipInput with no messages keeps the accessibility tree it had before this
35
+ prop existed -- an unnamed, undescribed group is announced noise. */
36
+ const fieldUid = $props.id();
37
+ const field = $derived(
38
+ describeField(fieldUid, { error: errorMessage, info: infoMessage, invalid })
39
+ );
40
+ const describedGroup = $derived(field.describedBy !== null || field.ariaInvalid !== null);
41
+
20
42
  let draft = $state('');
21
43
 
22
44
  // The chip currently swapped for an inline edit field, held by VALUE rather than by slot
@@ -131,7 +153,15 @@
131
153
  }
132
154
  </script>
133
155
 
134
- <div class="chip-input {classes ?? ''}" data-pw={testId} testID={testId}>
156
+ <div
157
+ class="chip-input {classes ?? ''}"
158
+ role={describedGroup ? 'group' : null}
159
+ aria-label={describedGroup ? ariaLabel : null}
160
+ aria-describedby={field.describedBy}
161
+ aria-invalid={field.ariaInvalid}
162
+ data-pw={testId}
163
+ testID={testId}
164
+ >
135
165
  {#each values as chip, index (chip)}
136
166
  {#if editable && editingChip === chip && !disabled}
137
167
  <div class="chip-input-edit-wrap">
@@ -186,7 +216,41 @@
186
216
  </div>
187
217
  </div>
188
218
 
219
+ {#if field.showsError}
220
+ <div
221
+ id={field.errorId}
222
+ role="alert"
223
+ class="field-error"
224
+ data-pw={typeof testId === 'string' ? `${testId}-error-message` : null}
225
+ testID={typeof testId === 'string' ? `${testId}-error-message` : null}
226
+ >
227
+ {errorMessage}
228
+ </div>
229
+ {/if}
230
+ {#if field.showsInfo}
231
+ <div
232
+ id={field.infoId}
233
+ class="field-info"
234
+ data-pw={typeof testId === 'string' ? `${testId}-info-message` : null}
235
+ testID={typeof testId === 'string' ? `${testId}-info-message` : null}
236
+ >
237
+ {infoMessage}
238
+ </div>
239
+ {/if}
240
+
189
241
  <style>
242
+ .field-error {
243
+ color: var(--field-error-color, #c5120a);
244
+ font-size: var(--field-error-font-size, 12px);
245
+ margin: var(--field-error-margin, 4px 0 0 0);
246
+ }
247
+
248
+ .field-info {
249
+ color: var(--field-info-color, #6b7280);
250
+ font-size: var(--field-info-font-size, 12px);
251
+ margin: var(--field-info-margin, 4px 0 0 0);
252
+ }
253
+
190
254
  .chip-input {
191
255
  display: flex;
192
256
  flex-wrap: var(--chip-input-flex-wrap, wrap);
@@ -209,8 +273,11 @@
209
273
  Tokens the component owns STRUCTURALLY — the draft field's inline padding, margin and
210
274
  shadow, and its width/height — deliberately stay fixed, since those position the field among
211
275
  the chips rather than describe how it looks. */
212
- --chip-input-pill-background-default: var(--pill-background, #e0e0e0);
213
- --chip-input-pill-color-default: var(--pill-color, #333333);
276
+ --chip-input-pill-background-default: var(
277
+ --pill-background,
278
+ var(--pill-tone-neutral-background, #e0e0e0)
279
+ );
280
+ --chip-input-pill-color-default: var(--pill-color, var(--pill-tone-neutral-color, #333333));
214
281
  --chip-input-pill-dismiss-color-default: var(--pill-dismiss-color, currentColor);
215
282
  --chip-input-pill-font-size-default: var(--pill-font-size, 13px);
216
283
  --chip-input-pill-font-weight-default: var(--pill-font-weight, 500);
@@ -31,6 +31,22 @@ export type OptionalChipInputProperties = {
31
31
  editable?: boolean;
32
32
  testId?: string;
33
33
  classes?: string;
34
+ /**
35
+ * Text shown, and announced, when the chip collection is in error ("at most
36
+ * five tags"). Referenced by `aria-describedby` on the group wrapping the
37
+ * chips and the draft field, and sets `aria-invalid` while present.
38
+ */
39
+ errorMessage?: string | null;
40
+ /**
41
+ * Persistent helper text describing the whole control. Referenced the same
42
+ * way, so it is read before the user trips an error rather than only after.
43
+ */
44
+ infoMessage?: string | null;
45
+ /**
46
+ * Marks the control invalid without supplying a message, for a consumer
47
+ * driving validity from a server or its own rules.
48
+ */
49
+ invalid?: boolean;
34
50
  };
35
51
  export type ChipInputEventProperties = {
36
52
  onadd?: (value: string) => void;
@@ -1,4 +1,6 @@
1
1
  <script lang="ts">
2
+ import { describeField } from '../_field/description';
3
+ import { joinChoiceboxGroup, moveWithinChoiceboxGroup, selectExclusively } from './group';
2
4
  import type { ChoiceboxProperties } from './properties';
3
5
  import checkmarkSvg from '../assets/checkmark.svg?raw';
4
6
 
@@ -10,9 +12,58 @@
10
12
  showIndicator = true,
11
13
  testId,
12
14
  onclick,
13
- classes
15
+ classes,
16
+ errorMessage,
17
+ infoMessage,
18
+ invalid = false,
19
+ name,
20
+ value = 'on',
21
+ required = false,
22
+ form
14
23
  }: ChoiceboxProperties = $props();
15
24
 
25
+ /* The card and the text that explains it were never linked: a screen-reader
26
+ user reaching this control heard its name and nothing about why it was
27
+ rejected. `describeField` composes the reference from the messages that are
28
+ actually rendered, so aria-describedby never points at an id that is not in
29
+ the DOM -- which passes an attribute assertion and resolves to nothing in a
30
+ real reader. */
31
+ const fieldUid = $props.id();
32
+ const field = $derived(
33
+ describeField(fieldUid, { error: errorMessage, info: infoMessage, invalid })
34
+ );
35
+
36
+ /* Grouping is opt-in through `name`, so every card written before this prop
37
+ existed keeps behaving as an independent toggle. */
38
+ const groupKey = $derived(
39
+ mode === 'radio' && typeof name === 'string' && name !== '' ? name : null
40
+ );
41
+
42
+ let card: HTMLDivElement | null = $state(null);
43
+
44
+ /* Registers the card with its group and re-registers whenever an input the
45
+ group's single tab stop depends on changes. `selected` and `disabled` are
46
+ read HERE rather than only inside the callbacks: a closure reading them
47
+ later creates no dependency, and the tab stop has to move when a SIBLING's
48
+ selection changes, which no per-instance `$derived` can observe. */
49
+ function groupMembership(node: HTMLElement) {
50
+ const key = groupKey;
51
+ if (key === null) {
52
+ return;
53
+ }
54
+ const selectedNow = selected;
55
+ const disabledNow = disabled;
56
+ return joinChoiceboxGroup(key, {
57
+ element: node,
58
+ isSelected: () => selectedNow,
59
+ isDisabled: () => disabledNow,
60
+ setSelected: (next: boolean) => {
61
+ selected = next;
62
+ },
63
+ notify: (next: boolean) => onclick?.(next)
64
+ });
65
+ }
66
+
16
67
  function handleClick(): void {
17
68
  if (disabled) {
18
69
  return;
@@ -20,32 +71,93 @@
20
71
  if (mode === 'radio' && selected) {
21
72
  return;
22
73
  }
74
+ if (groupKey !== null && card !== null) {
75
+ // The group owns the exclusivity a native radio gets from the browser:
76
+ // selecting here deselects the siblings and tells each of them.
77
+ selectExclusively(card);
78
+ onclick?.(true);
79
+ return;
80
+ }
23
81
  selected = !selected;
24
82
  onclick?.(selected);
25
83
  }
26
84
 
85
+ // The form control is `tabindex="-1"`, so the browser's own "focus the invalid
86
+ // field" step would land on an element the user cannot see or reach. Focus the
87
+ // card that carries the role instead.
88
+ function handleInvalid(e: Event): void {
89
+ e.preventDefault();
90
+ card?.focus();
91
+ }
92
+
27
93
  function handleKeyDown(e: KeyboardEvent): void {
28
94
  if (e.key === ' ' || e.key === 'Enter') {
29
95
  e.preventDefault();
30
96
  handleClick();
97
+ return;
98
+ }
99
+ if (groupKey === null || disabled || card === null) {
100
+ return;
101
+ }
102
+ // Both axes, because a group of cards may be laid out as a row or a column
103
+ // and the user cannot tell which the author chose.
104
+ const step =
105
+ e.key === 'ArrowDown' || e.key === 'ArrowRight'
106
+ ? 'next'
107
+ : e.key === 'ArrowUp' || e.key === 'ArrowLeft'
108
+ ? 'previous'
109
+ : e.key === 'Home'
110
+ ? 'first'
111
+ : e.key === 'End'
112
+ ? 'last'
113
+ : null;
114
+ if (step === null) {
115
+ return;
116
+ }
117
+ if (moveWithinChoiceboxGroup(card, step)) {
118
+ e.preventDefault();
31
119
  }
32
120
  }
33
121
  </script>
34
122
 
35
123
  <!-- svelte-ignore a11y_no_noninteractive_tabindex -->
124
+ <!-- `tabindex` is the ungrouped answer and the server-rendered baseline. A named
125
+ radio group has ONE tab stop rather than one per card, which depends on
126
+ every sibling's state and so is applied by `groupMembership` once the group
127
+ exists on the client. -->
36
128
  <div
129
+ bind:this={card}
37
130
  class="choicebox {classes ?? ''}"
38
131
  class:selected
39
132
  class:disabled
40
133
  role={mode === 'radio' ? 'radio' : 'checkbox'}
41
134
  aria-checked={selected}
42
135
  aria-disabled={disabled}
136
+ aria-required={required ? 'true' : null}
137
+ aria-describedby={field.describedBy}
138
+ aria-invalid={field.ariaInvalid}
43
139
  tabindex={disabled ? -1 : 0}
44
140
  onclick={handleClick}
45
141
  onkeydown={handleKeyDown}
46
142
  data-pw={testId}
47
143
  testID={testId}
144
+ {@attach groupMembership}
48
145
  >
146
+ <input
147
+ type={mode === 'radio' ? 'radio' : 'checkbox'}
148
+ class="native-control"
149
+ checked={selected}
150
+ {disabled}
151
+ {value}
152
+ {required}
153
+ name={typeof name === 'string' ? name : null}
154
+ form={typeof form === 'string' ? form : null}
155
+ oninvalid={handleInvalid}
156
+ tabindex={-1}
157
+ aria-hidden="true"
158
+ data-pw={typeof testId === 'string' ? `${testId}-native-input` : null}
159
+ testID={typeof testId === 'string' ? `${testId}-native-input` : null}
160
+ />
49
161
  {#if typeof children === 'function'}
50
162
  {@render children()}
51
163
  {/if}
@@ -59,7 +171,50 @@
59
171
  {/if}
60
172
  </div>
61
173
 
174
+ {#if field.showsError}
175
+ <div
176
+ id={field.errorId}
177
+ role="alert"
178
+ class="field-error"
179
+ data-pw={typeof testId === 'string' ? `${testId}-error-message` : null}
180
+ testID={typeof testId === 'string' ? `${testId}-error-message` : null}
181
+ >
182
+ {errorMessage}
183
+ </div>
184
+ {/if}
185
+ {#if field.showsInfo}
186
+ <div
187
+ id={field.infoId}
188
+ class="field-info"
189
+ data-pw={typeof testId === 'string' ? `${testId}-info-message` : null}
190
+ testID={typeof testId === 'string' ? `${testId}-info-message` : null}
191
+ >
192
+ {infoMessage}
193
+ </div>
194
+ {/if}
195
+
62
196
  <style>
197
+ .native-control {
198
+ position: absolute;
199
+ opacity: 0;
200
+ width: 0;
201
+ height: 0;
202
+ margin: 0;
203
+ pointer-events: none;
204
+ }
205
+
206
+ .field-error {
207
+ color: var(--field-error-color, #c5120a);
208
+ font-size: var(--field-error-font-size, 12px);
209
+ margin: var(--field-error-margin, 4px 0 0 0);
210
+ }
211
+
212
+ .field-info {
213
+ color: var(--field-info-color, #6b7280);
214
+ font-size: var(--field-info-font-size, 12px);
215
+ margin: var(--field-info-margin, 4px 0 0 0);
216
+ }
217
+
63
218
  .choicebox {
64
219
  display: var(--choicebox-display, flex);
65
220
  align-items: var(--choicebox-align-items, center);
@@ -0,0 +1,63 @@
1
+ /**
2
+ * Mutual exclusion and arrow-key roving for `Choicebox`es that share a `name`.
3
+ *
4
+ * A native radio group is a browser behaviour rather than a component one: the
5
+ * browser finds the other `<input type="radio">`s carrying the same name and
6
+ * unchecks them. Choicebox's selection lives in a per-instance `selected` prop,
7
+ * so nothing was doing that job -- two cards a consumer thought of as one group
8
+ * could both be selected, and a keyboard user tabbed through every card instead
9
+ * of arrowing between them, which is not how a radio group behaves.
10
+ *
11
+ * Membership is keyed by name AND by root node, so two forms on one page that
12
+ * both use `name="plan"` stay separate groups and a card inside a shadow root
13
+ * does not join one in the document. That is narrower than the platform, which
14
+ * scopes a radio group to the owning form; form ownership is not readable from
15
+ * a card that may not be inside a form at all, and the root node is the closest
16
+ * boundary that always exists.
17
+ *
18
+ * The registry is imperative on purpose. Roving tabindex is a property of the
19
+ * WHOLE group -- the single tab stop moves when any member's selection or
20
+ * disabled state changes -- and a per-instance `$derived` cannot observe a
21
+ * sibling's props. Each member re-registers whenever its own inputs change,
22
+ * which is what re-runs the group-wide recalculation below.
23
+ */
24
+ export type ChoiceboxGroupMember = {
25
+ readonly element: HTMLElement;
26
+ /**
27
+ * Snapshots taken when the member registered, NOT live reads: Choicebox
28
+ * re-registers whenever either value changes, and that re-registration is what
29
+ * recalculates the group. So a `refreshRoving` running in the same tick as a
30
+ * `setSelected` sees the pre-change answer and is corrected a moment later by
31
+ * the re-registration it caused.
32
+ */
33
+ readonly isSelected: () => boolean;
34
+ readonly isDisabled: () => boolean;
35
+ /** Writes the member's own bindable `selected` prop. */
36
+ readonly setSelected: (next: boolean) => void;
37
+ /** Fires the member's `onclick`, so a group move reports like a click. */
38
+ readonly notify: (next: boolean) => void;
39
+ };
40
+ /**
41
+ * Adds a card to the group named `name`. Returns the function that removes it
42
+ * again; both ends recalculate the group's tab stop, so a card unmounting while
43
+ * it held the tab stop hands it on rather than leaving the group unreachable.
44
+ */
45
+ export declare function joinChoiceboxGroup(name: string, member: ChoiceboxGroupMember): () => void;
46
+ /**
47
+ * Selects one card and deselects the rest, the way checking a native radio
48
+ * unchecks its group. Siblings are told through `setSelected` so a consumer
49
+ * binding `selected` on each card sees the change, and through `notify` so a
50
+ * consumer listening on `onclick` is not left believing a deselected card is
51
+ * still selected.
52
+ */
53
+ export declare function selectExclusively(element: HTMLElement): void;
54
+ export type GroupStep = 'next' | 'previous' | 'first' | 'last';
55
+ /**
56
+ * Arrow-key movement inside a radio group. Moving selects, which is what a
57
+ * native radio group does and what makes the group usable without a mouse: the
58
+ * arrow keys are the only way to change the choice once Tab has entered it.
59
+ *
60
+ * Returns false when the element is not in a group, so the caller can leave the
61
+ * key alone rather than swallowing it.
62
+ */
63
+ export declare function moveWithinChoiceboxGroup(element: HTMLElement, step: GroupStep): boolean;