@juspay/svelte-ui-components 4.27.6 → 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
@@ -0,0 +1,127 @@
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
+ const registry = new Set();
25
+ /** Members of `member`'s group, in DOM order. */
26
+ function groupOf(member) {
27
+ const root = member.element.getRootNode();
28
+ return [...registry]
29
+ .filter((other) => other.name === member.name && other.element.getRootNode() === root)
30
+ .sort((a, b) => (a.element.compareDocumentPosition(b.element) & Node.DOCUMENT_POSITION_FOLLOWING) !== 0
31
+ ? -1
32
+ : 1);
33
+ }
34
+ /**
35
+ * One tab stop per group, on the selected card -- or on the first enabled card
36
+ * while nothing is selected, which is what keeps an untouched group reachable
37
+ * by Tab at all.
38
+ */
39
+ function refreshRoving(group) {
40
+ const enabled = group.filter((candidate) => !candidate.isDisabled());
41
+ const tabbable = enabled.find((candidate) => candidate.isSelected()) ?? enabled.at(0) ?? null;
42
+ for (const member of group) {
43
+ member.element.tabIndex = member.isDisabled() ? -1 : member === tabbable ? 0 : -1;
44
+ }
45
+ }
46
+ /**
47
+ * Adds a card to the group named `name`. Returns the function that removes it
48
+ * again; both ends recalculate the group's tab stop, so a card unmounting while
49
+ * it held the tab stop hands it on rather than leaving the group unreachable.
50
+ */
51
+ export function joinChoiceboxGroup(name, member) {
52
+ const registration = { ...member, name };
53
+ registry.add(registration);
54
+ refreshRoving(groupOf(registration));
55
+ return () => {
56
+ registry.delete(registration);
57
+ refreshRoving(groupOf(registration));
58
+ };
59
+ }
60
+ /** The registration this element was added under, or null when ungrouped. */
61
+ function registrationFor(element) {
62
+ for (const candidate of registry) {
63
+ if (candidate.element === element) {
64
+ return candidate;
65
+ }
66
+ }
67
+ return null;
68
+ }
69
+ /**
70
+ * Selects one card and deselects the rest, the way checking a native radio
71
+ * unchecks its group. Siblings are told through `setSelected` so a consumer
72
+ * binding `selected` on each card sees the change, and through `notify` so a
73
+ * consumer listening on `onclick` is not left believing a deselected card is
74
+ * still selected.
75
+ */
76
+ export function selectExclusively(element) {
77
+ const registration = registrationFor(element);
78
+ if (registration === null) {
79
+ return;
80
+ }
81
+ const group = groupOf(registration);
82
+ for (const member of group) {
83
+ if (member === registration || !member.isSelected()) {
84
+ continue;
85
+ }
86
+ member.setSelected(false);
87
+ member.notify(false);
88
+ }
89
+ registration.setSelected(true);
90
+ refreshRoving(group);
91
+ }
92
+ /**
93
+ * Arrow-key movement inside a radio group. Moving selects, which is what a
94
+ * native radio group does and what makes the group usable without a mouse: the
95
+ * arrow keys are the only way to change the choice once Tab has entered it.
96
+ *
97
+ * Returns false when the element is not in a group, so the caller can leave the
98
+ * key alone rather than swallowing it.
99
+ */
100
+ export function moveWithinChoiceboxGroup(element, step) {
101
+ const registration = registrationFor(element);
102
+ if (registration === null) {
103
+ return false;
104
+ }
105
+ const enabled = groupOf(registration).filter((candidate) => !candidate.isDisabled());
106
+ if (enabled.length === 0) {
107
+ return false;
108
+ }
109
+ const current = enabled.indexOf(registration);
110
+ if (current === -1) {
111
+ return false;
112
+ }
113
+ const target = step === 'first'
114
+ ? enabled.at(0)
115
+ : step === 'last'
116
+ ? enabled.at(-1)
117
+ : // Wraps, like a native radio group: arrowing past the last member
118
+ // returns to the first rather than stopping dead.
119
+ enabled.at((current + (step === 'next' ? 1 : -1) + enabled.length) % enabled.length);
120
+ if (typeof target === 'undefined' || target === registration) {
121
+ return true;
122
+ }
123
+ selectExclusively(target.element);
124
+ target.notify(true);
125
+ target.element.focus();
126
+ return true;
127
+ }
@@ -9,6 +9,52 @@ export type OptionalChoiceboxProperties = {
9
9
  showIndicator?: boolean;
10
10
  testId?: string;
11
11
  classes?: string;
12
+ /**
13
+ * Text shown, and announced, when the card is in error. Linked to the card
14
+ * through `aria-describedby`, and sets `aria-invalid` while present.
15
+ */
16
+ errorMessage?: string | null;
17
+ /**
18
+ * Persistent helper text describing the card. Linked through
19
+ * `aria-describedby` too, so it is read before the user trips an error rather
20
+ * than only after.
21
+ */
22
+ infoMessage?: string | null;
23
+ /**
24
+ * Marks the card invalid without supplying a message, for a consumer driving
25
+ * validity from a server or its own rules.
26
+ */
27
+ invalid?: boolean;
28
+ /**
29
+ * Submits the card under this name when it sits inside (or is associated with)
30
+ * a form. Omitted entirely when absent, unselected or disabled, which is what
31
+ * a native checkbox or radio does.
32
+ *
33
+ * In `radio` mode a name also GROUPS the cards that share it: selecting one
34
+ * deselects the rest, and Arrow/Home/End move the selection between them under
35
+ * a single tab stop, the way a native radio group behaves. Grouping is scoped
36
+ * to cards sharing a root node, so two forms on one page can both use
37
+ * `name="plan"` without interfering. Leave `name` off and each card stays the
38
+ * independent toggle it was before this prop existed.
39
+ */
40
+ name?: string;
41
+ /** Value submitted while selected. Defaults to `'on'`, the native default. */
42
+ value?: string;
43
+ /**
44
+ * Makes an unselected card block submission. In a named `radio` group this is
45
+ * the native "pick one" rule over the whole group. Invalid validation moves
46
+ * focus to the visible card, since the form control behind it is deliberately
47
+ * not a tab stop.
48
+ */
49
+ required?: boolean;
50
+ /**
51
+ * `id` of a form elsewhere in the same document, for a card rendered outside
52
+ * it. Custom elements have no form association through this prop: inside
53
+ * `<sui-choicebox>` the control lives in a shadow root and cannot reach a form
54
+ * in the host document, which is what `ElementInternals` handles there
55
+ * instead.
56
+ */
57
+ form?: string;
12
58
  };
13
59
  export type ChoiceboxEventProperties = {
14
60
  onclick?: (selected: boolean) => void;
@@ -1,4 +1,6 @@
1
1
  <script lang="ts">
2
+ import { registerDismissible } from '../_interaction/dismissal';
3
+ import { describeField } from '../_field/description';
2
4
  import Button from '../Button/Button.svelte';
3
5
  import Input from '../Input/Input.svelte';
4
6
  import Slider from '../Slider/Slider.svelte';
@@ -24,13 +26,35 @@
24
26
  testId,
25
27
  onchange,
26
28
  oninput,
27
- classes
29
+ classes,
30
+ errorMessage,
31
+ infoMessage,
32
+ invalid = false
28
33
  }: ColorPickerProperties = $props();
29
34
 
35
+ /* The control and the text that explains it were never linked: a screen-reader
36
+ user reaching the swatch heard "Pick a color" and nothing about why the last
37
+ one was rejected. `describeField` composes the reference from the messages
38
+ that are actually rendered, so aria-describedby never points at an id that is
39
+ not in the DOM -- which passes an attribute assertion and resolves to nothing
40
+ in a real reader.
41
+
42
+ It describes the group holding the swatch button and the hex field, because
43
+ the message is about the colour rather than about either control, and because
44
+ neither `Button` nor `Input` exposes an `aria-describedby` seam to bind it to
45
+ one of them. The role is conditional so a ColorPicker with no messages keeps
46
+ the accessibility tree it had before these props existed. */
47
+ const fieldUid = $props.id();
48
+ const field = $derived(
49
+ describeField(fieldUid, { error: errorMessage, info: infoMessage, invalid })
50
+ );
51
+ const describedGroup = $derived(field.describedBy !== null || field.ariaInvalid !== null);
52
+
30
53
  // ── Internal state ──────────────────────────────────────────────
31
54
 
32
55
  let open = $state(false);
33
56
  let containerEl: HTMLDivElement | null = $state(null);
57
+ let triggerWrapEl: HTMLDivElement | null = $state(null);
34
58
 
35
59
  let hue = $state(0);
36
60
  let sat = $state(1);
@@ -46,21 +70,43 @@
46
70
  let currentRgb = $derived(hsvToRgb(hue, sat, val));
47
71
  let currentHsl = $derived(hsvToHsl(hue, sat, val));
48
72
 
49
- let _syncTrigger = $derived.by(() => {
73
+ // The hex string this component itself last wrote to `value` via commitColor.
74
+ // Plain (non-`$state`) bookkeeping -- like Tabs' `lastSelectionKey` -- so reading
75
+ // it never creates a reactive dependency; only `value` needs to.
76
+ let lastCommittedHex: string | null = null;
77
+
78
+ // Keeps hue/sat/val honest against `value` for every route that is NOT
79
+ // commitColor -- an externally-changed `value` prop, or the popover's own hex
80
+ // field/trigger input, which write `value` directly. `{@attach}` runs its
81
+ // callback inside a real reactive effect (see attachments.js), so reading
82
+ // `value` here re-runs it on change without reaching for the banned $effect.
83
+ // Skipped entirely when `value` merely echoes our own last commit, so the
84
+ // hue we just set from a drag never gets round-tripped back through hex.
85
+ function syncFromValue(_node: HTMLDivElement): void {
86
+ if (value === lastCommittedHex) {
87
+ return;
88
+ }
50
89
  const hsv = hexToHsv(value);
51
- if (hsv !== null) {
90
+ if (hsv === null) {
91
+ return;
92
+ }
93
+ sat = hsv.s;
94
+ val = hsv.v;
95
+ // A grey hex (sat 0, from d === 0) has no recoverable hue -- rgbToHsv defaults
96
+ // it to 0, which is not "the" hue, just an artifact of the formula. Leaving
97
+ // `hue` alone here is what keeps a drag down to grey from snapping the hue
98
+ // slider to red.
99
+ if (hsv.s > 0) {
52
100
  hue = hsv.h;
53
- sat = hsv.s;
54
- val = hsv.v;
55
101
  }
56
- return value;
57
- });
102
+ }
58
103
 
59
104
  // ── Color commit ────────────────────────────────────────────────
60
105
 
61
106
  function commitColor() {
62
107
  const hex = hsvToHex(hue, sat, val);
63
108
  if (hex !== value) {
109
+ lastCommittedHex = hex;
64
110
  value = hex;
65
111
  oninput?.(value);
66
112
  onchange?.(value);
@@ -75,15 +121,29 @@
75
121
  }
76
122
  }
77
123
 
78
- function handleWindowClick(e: MouseEvent) {
79
- if (
80
- open &&
81
- containerEl !== null &&
82
- e.target instanceof Node &&
83
- containerEl.contains(e.target) === false
84
- ) {
85
- open = false;
86
- }
124
+ // Registered for exactly the popover's open span (the {#if open} block below),
125
+ // same as Menu's dismissalAction, so this popover only answers Escape/outside-press
126
+ // while it is the topmost dismissible surface -- a picker opened inside a Modal or
127
+ // Sheet no longer closes the layer behind it. `element` is containerEl (the whole
128
+ // component, trigger included) rather than just the popover: a press on the swatch
129
+ // button that reopens/toggles the popover must not also be read as an outside press
130
+ // by the module, matching the old handleWindowClick's boundary exactly.
131
+ function dismissalAction(_node: HTMLDivElement) {
132
+ const release = registerDismissible({
133
+ element: () => containerEl,
134
+ // Wherever focus happens to be inside the popover -- the sat panel, a field,
135
+ // the mode-toggle button -- matching ContextMenu's "wherever focus happens to
136
+ // be" rule, then hands focus back to the trigger so keyboard users don't lose
137
+ // their place.
138
+ onEscape: () => {
139
+ open = false;
140
+ triggerWrapEl?.querySelector('button')?.focus();
141
+ },
142
+ onOutside: () => {
143
+ open = false;
144
+ }
145
+ });
146
+ return { destroy: release };
87
147
  }
88
148
 
89
149
  // ── Saturation panel drag ───────────────────────────────────────
@@ -123,6 +183,53 @@
123
183
  commitColor();
124
184
  }
125
185
 
186
+ // Small step for the arrow keys, larger step for Page Up/Down -- mirrors the
187
+ // native <input type="range"> convention Slider relies on.
188
+ const SAT_STEP = 0.01;
189
+ const SAT_PAGE_STEP = 0.1;
190
+
191
+ function handleSatKeydown(e: KeyboardEvent) {
192
+ if (disabled) {
193
+ return;
194
+ }
195
+ switch (e.key) {
196
+ case 'ArrowLeft':
197
+ sat = Math.max(0, sat - SAT_STEP);
198
+ break;
199
+ case 'ArrowRight':
200
+ sat = Math.min(1, sat + SAT_STEP);
201
+ break;
202
+ case 'ArrowUp':
203
+ val = Math.min(1, val + SAT_STEP);
204
+ break;
205
+ case 'ArrowDown':
206
+ val = Math.max(0, val - SAT_STEP);
207
+ break;
208
+ case 'PageUp':
209
+ // Larger step on the same axis Home/End jump on -- see below.
210
+ sat = Math.min(1, sat + SAT_PAGE_STEP);
211
+ break;
212
+ case 'PageDown':
213
+ sat = Math.max(0, sat - SAT_PAGE_STEP);
214
+ break;
215
+ case 'Home':
216
+ // Home/End follow the ARIA slider contract for "the" value this role
217
+ // formally exposes -- aria-valuenow/min/max track saturation, so that
218
+ // is the axis being adjusted here. Brightness is left untouched, same
219
+ // as arrow keys never touch the axis they aren't bound to.
220
+ sat = 0;
221
+ break;
222
+ case 'End':
223
+ sat = 1;
224
+ break;
225
+ default:
226
+ // Not one of ours -- leave it unconsumed (Tab, Enter, etc.).
227
+ return;
228
+ }
229
+ e.preventDefault();
230
+ commitColor();
231
+ }
232
+
126
233
  // ── Hue slider ─────────────────────────────────────────────────
127
234
 
128
235
  let hueDegrees = $derived(Math.round(hue * 360));
@@ -182,14 +289,17 @@
182
289
  }
183
290
  </script>
184
291
 
185
- <svelte:window onclick={handleWindowClick} />
186
-
187
292
  <div
188
293
  class="color-picker-container {classes ?? ''}"
189
294
  class:disabled
295
+ role={describedGroup ? 'group' : null}
296
+ aria-label={describedGroup && typeof label === 'string' ? label : null}
297
+ aria-describedby={field.describedBy}
298
+ aria-invalid={field.ariaInvalid}
190
299
  data-pw={testId}
191
300
  testID={testId}
192
301
  bind:this={containerEl}
302
+ {@attach syncFromValue}
193
303
  >
194
304
  {#if typeof label === 'string'}
195
305
  <span class="color-picker-label">{label}</span>
@@ -197,7 +307,11 @@
197
307
 
198
308
  <!-- Trigger: swatch button + optional hex input -->
199
309
  <div class="color-picker-row">
200
- <div class="color-picker-swatch-btn" class:standalone={showValue === false}>
310
+ <div
311
+ class="color-picker-swatch-btn"
312
+ class:standalone={showValue === false}
313
+ bind:this={triggerWrapEl}
314
+ >
201
315
  <Button onclick={togglePicker} {disabled} ariaLabel="Pick a color" ariaExpanded={open}>
202
316
  <span class="color-picker-checkerboard">
203
317
  <span class="color-picker-swatch" style="background-color: {value};"></span>
@@ -219,7 +333,7 @@
219
333
 
220
334
  <!-- Popover panel -->
221
335
  {#if open}
222
- <div class="color-picker-popover" role="dialog" aria-label="Color picker">
336
+ <div class="color-picker-popover" role="dialog" aria-label="Color picker" use:dismissalAction>
223
337
  <div
224
338
  class="cp-sat-panel"
225
339
  style="background-color: {hsvToHex(hue, 1, 1)};"
@@ -227,6 +341,7 @@
227
341
  onpointerdown={handleSatDown}
228
342
  onpointermove={handleSatMove}
229
343
  onpointerup={handleSatUp}
344
+ onkeydown={handleSatKeydown}
230
345
  role="slider"
231
346
  aria-label="Saturation and brightness"
232
347
  aria-valuenow={Math.round(sat * 100)}
@@ -303,7 +418,41 @@
303
418
  {/if}
304
419
  </div>
305
420
 
421
+ {#if field.showsError}
422
+ <div
423
+ id={field.errorId}
424
+ role="alert"
425
+ class="field-error"
426
+ data-pw={typeof testId === 'string' ? `${testId}-error-message` : null}
427
+ testID={typeof testId === 'string' ? `${testId}-error-message` : null}
428
+ >
429
+ {errorMessage}
430
+ </div>
431
+ {/if}
432
+ {#if field.showsInfo}
433
+ <div
434
+ id={field.infoId}
435
+ class="field-info"
436
+ data-pw={typeof testId === 'string' ? `${testId}-info-message` : null}
437
+ testID={typeof testId === 'string' ? `${testId}-info-message` : null}
438
+ >
439
+ {infoMessage}
440
+ </div>
441
+ {/if}
442
+
306
443
  <style>
444
+ .field-error {
445
+ color: var(--field-error-color, #c5120a);
446
+ font-size: var(--field-error-font-size, 12px);
447
+ margin: var(--field-error-margin, 4px 0 0 0);
448
+ }
449
+
450
+ .field-info {
451
+ color: var(--field-info-color, #6b7280);
452
+ font-size: var(--field-info-font-size, 12px);
453
+ margin: var(--field-info-margin, 4px 0 0 0);
454
+ }
455
+
307
456
  /* ── Container ─────────────────────────────────────────────── */
308
457
 
309
458
  .color-picker-container {
@@ -8,6 +8,22 @@ export type OptionalColorPickerProperties = {
8
8
  showValue?: boolean;
9
9
  testId?: string;
10
10
  classes?: string;
11
+ /**
12
+ * Text shown, and announced, when the chosen colour is in error ("not enough
13
+ * contrast on white"). Referenced by `aria-describedby` on the group holding
14
+ * the swatch and the hex field, and sets `aria-invalid` while present.
15
+ */
16
+ errorMessage?: string | null;
17
+ /**
18
+ * Persistent helper text describing the control. Referenced the same way, so
19
+ * it is read before the user trips an error rather than only after.
20
+ */
21
+ infoMessage?: string | null;
22
+ /**
23
+ * Marks the control invalid without supplying a message, for a consumer
24
+ * driving validity from a server or its own rules.
25
+ */
26
+ invalid?: boolean;
11
27
  };
12
28
  export type ColorPickerEventProperties = {
13
29
  onchange?: (value: string) => void;
@@ -1,5 +1,7 @@
1
1
  <script lang="ts">
2
- import { onMount, tick } from 'svelte';
2
+ import { registerDismissible } from '../_interaction/dismissal';
3
+ import { describeField } from '../_field/description';
4
+ import { tick } from 'svelte';
3
5
  import Input from '../Input/Input.svelte';
4
6
  import Pill from '../Pill/Pill.svelte';
5
7
  import type { ComboboxItem, ComboboxProperties } from './properties';
@@ -50,9 +52,32 @@
50
52
  onchange,
51
53
  onadd,
52
54
  onremove,
53
- oncreate
55
+ oncreate,
56
+ errorMessage,
57
+ infoMessage,
58
+ invalid = false
54
59
  }: ComboboxProperties = $props();
55
60
 
61
+ /* The control and the text that explains it were never linked: a screen-reader
62
+ user reaching this combobox heard its name and nothing about why the value
63
+ was rejected. `describeField` composes the reference from the messages that
64
+ are actually rendered, so aria-describedby never points at an id that is not
65
+ in the DOM -- which passes an attribute assertion and resolves to nothing in
66
+ a real reader.
67
+
68
+ It goes on a group around the control rather than on the `role="combobox"`
69
+ element itself, because that element is inside `Input` and `Input` exposes
70
+ no `aria-describedby` seam -- it derives its own from the messages IT is
71
+ given, which are a different pair of props. Per-control is the stronger
72
+ placement and needs that seam; see docs/Combobox.md. The role is conditional
73
+ so a Combobox with no messages keeps the accessibility tree it had before
74
+ these props existed. */
75
+ const fieldUid = $props.id();
76
+ const field = $derived(
77
+ describeField(fieldUid, { error: errorMessage, info: infoMessage, invalid })
78
+ );
79
+ const describedGroup = $derived(field.describedBy !== null || field.ariaInvalid !== null);
80
+
56
81
  let containerEl: HTMLDivElement | null = $state(null);
57
82
  let inputRef: ReturnType<typeof Input> | null = $state(null);
58
83
 
@@ -295,12 +320,6 @@
295
320
  removeValue(selected[selected.length - 1]);
296
321
  }
297
322
  break;
298
- case 'Escape':
299
- if (open) {
300
- event.preventDefault();
301
- closeDropdown();
302
- }
303
- break;
304
323
  case 'Tab':
305
324
  if (open) {
306
325
  closeDropdown();
@@ -327,28 +346,30 @@
327
346
  }
328
347
  }
329
348
 
330
- function handleClickOutside(event: Event) {
331
- if (
332
- event.target instanceof Node &&
333
- containerEl !== null &&
334
- !containerEl.contains(event.target)
335
- ) {
336
- closeDropdown();
337
- }
349
+ // Registered for exactly the dropdown's open span (the `{#if open && !disabled}`
350
+ // block below), same as Menu's dismissalAction, so this dropdown only answers
351
+ // Escape/outside-press while it is the topmost dismissible surface -- a combobox
352
+ // opened inside a Modal, Sheet, Menu, ContextMenu or CommandMenu no longer closes
353
+ // the layer behind it. Unlike Select's dropdown, this one is never portaled --
354
+ // it is always a DOM descendant of containerEl -- so containerEl alone is enough
355
+ // for the module's own inside/outside test; no second hit-test is needed here.
356
+ function dismissalAction(_node: HTMLDivElement) {
357
+ const release = registerDismissible({
358
+ element: () => containerEl,
359
+ onOutside: closeDropdown,
360
+ onEscape: closeDropdown
361
+ });
362
+ return { destroy: release };
338
363
  }
339
-
340
- onMount(() => {
341
- document.addEventListener('click', handleClickOutside);
342
- return () => {
343
- document.removeEventListener('click', handleClickOutside);
344
- };
345
- });
346
364
  </script>
347
365
 
348
366
  <div
349
367
  class="combobox {classes ?? ''}"
350
368
  class:disabled
351
369
  bind:this={containerEl}
370
+ role={describedGroup ? 'group' : null}
371
+ aria-describedby={field.describedBy}
372
+ aria-invalid={field.ariaInvalid}
352
373
  data-pw={testId}
353
374
  testID={testId}
354
375
  >
@@ -394,7 +415,13 @@
394
415
  </div>
395
416
 
396
417
  {#if open && !disabled}
397
- <div class="combobox-dropdown" role="listbox" id={listboxId} aria-label={ariaLabel}>
418
+ <div
419
+ class="combobox-dropdown"
420
+ role="listbox"
421
+ id={listboxId}
422
+ aria-label={ariaLabel}
423
+ use:dismissalAction
424
+ >
398
425
  {#if typeof dropdownHeader === 'function'}
399
426
  <div class="combobox-dropdown-header">{@render dropdownHeader()}</div>
400
427
  {/if}
@@ -498,7 +525,41 @@
498
525
  {/if}
499
526
  </div>
500
527
 
528
+ {#if field.showsError}
529
+ <div
530
+ id={field.errorId}
531
+ role="alert"
532
+ class="field-error"
533
+ data-pw={typeof testId === 'string' ? `${testId}-error-message` : null}
534
+ testID={typeof testId === 'string' ? `${testId}-error-message` : null}
535
+ >
536
+ {errorMessage}
537
+ </div>
538
+ {/if}
539
+ {#if field.showsInfo}
540
+ <div
541
+ id={field.infoId}
542
+ class="field-info"
543
+ data-pw={typeof testId === 'string' ? `${testId}-info-message` : null}
544
+ testID={typeof testId === 'string' ? `${testId}-info-message` : null}
545
+ >
546
+ {infoMessage}
547
+ </div>
548
+ {/if}
549
+
501
550
  <style>
551
+ .field-error {
552
+ color: var(--field-error-color, #c5120a);
553
+ font-size: var(--field-error-font-size, 12px);
554
+ margin: var(--field-error-margin, 4px 0 0 0);
555
+ }
556
+
557
+ .field-info {
558
+ color: var(--field-info-color, #6b7280);
559
+ font-size: var(--field-info-font-size, 12px);
560
+ margin: var(--field-info-margin, 4px 0 0 0);
561
+ }
562
+
502
563
  .combobox {
503
564
  position: relative;
504
565
  width: var(--combobox-width, 100%);
@@ -605,7 +666,8 @@
605
666
  font-size: var(--combobox-option-font-size, inherit);
606
667
  font-weight: var(--combobox-option-font-weight, inherit);
607
668
  cursor: pointer;
608
- transition: background 0.1s;
669
+ transition: background var(--combobox-option-transition-duration, var(--motion-duration, 0.1s))
670
+ var(--combobox-option-transition-easing, var(--motion-easing, ease));
609
671
  }
610
672
 
611
673
  .combobox-option:hover,
@@ -58,6 +58,29 @@ export type OptionalComboboxProperties = {
58
58
  action?: ComboboxAction;
59
59
  /** Custom leading icon for the persistent action row. */
60
60
  actionIcon?: Snippet;
61
+ /**
62
+ * Text shown, and announced, when the control is in error. Referenced by
63
+ * `aria-describedby` on a group wrapping the control, and sets `aria-invalid`
64
+ * while present.
65
+ *
66
+ * Placed on the group rather than on the `role="combobox"` element itself:
67
+ * that element is rendered by `Input`, which derives its own description from
68
+ * its own `onErrorMessage` / `infoMessage` props and exposes no seam for an
69
+ * outside one. A group description is announced on entering the group rather
70
+ * than on the control, which is weaker; pass `inputProperties.onErrorMessage`
71
+ * instead when you want the message bound to the text field itself.
72
+ */
73
+ errorMessage?: string | null;
74
+ /**
75
+ * Persistent helper text describing the control. Referenced the same way, so
76
+ * it is read before the user trips an error rather than only after.
77
+ */
78
+ infoMessage?: string | null;
79
+ /**
80
+ * Marks the control invalid without supplying a message, for a consumer
81
+ * driving validity from a server or its own rules.
82
+ */
83
+ invalid?: boolean;
61
84
  };
62
85
  export type ComboboxEventProperties = {
63
86
  onselect?: (item: ComboboxItem) => void;