@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
@@ -2,6 +2,8 @@
2
2
  import type { CommandMenuProperties, CommandItem } from './properties';
3
3
  import { tick, onMount, onDestroy } from 'svelte';
4
4
  import { lockBodyScroll, unlockBodyScroll } from '../utils';
5
+ import { registerDismissible } from '../_interaction/dismissal';
6
+ import { getActiveElement } from '../_interaction/focus';
5
7
  import { SvelteMap } from 'svelte/reactivity';
6
8
  import Img from '../Img/Img.svelte';
7
9
  import searchSvg from '../assets/search.svg?raw';
@@ -143,11 +145,6 @@
143
145
  }
144
146
  break;
145
147
  }
146
- case 'Escape': {
147
- event.preventDefault();
148
- close();
149
- break;
150
- }
151
148
  case 'Tab': {
152
149
  // aria-modal="true" promises focus stays inside, so Tab cycles rather
153
150
  // than escaping. Cycle over whatever is actually tabbable instead of
@@ -160,7 +157,7 @@
160
157
  break;
161
158
  }
162
159
  event.preventDefault();
163
- const active = document.activeElement;
160
+ const active = getActiveElement(tabbable[0] ?? null);
164
161
  const current = active instanceof HTMLElement ? tabbable.indexOf(active) : -1;
165
162
  const step = event.shiftKey ? -1 : 1;
166
163
  const next = (current + step + tabbable.length) % tabbable.length;
@@ -185,7 +182,7 @@
185
182
  // tabindex="-1"; they are arrow-key targets. Excluding -1 only in the
186
183
  // `[tabindex]` selector would still match them as buttons.
187
184
  el.getAttribute('tabindex') !== '-1' &&
188
- (el.offsetParent !== null || el === document.activeElement)
185
+ (el.offsetParent !== null || el === getActiveElement(el))
189
186
  );
190
187
  }
191
188
 
@@ -220,11 +217,27 @@
220
217
  // the action when the node goes away, including on component teardown, so the
221
218
  // onDestroy below must NOT unlock as well: with a shared reference count a second
222
219
  // decrement would release a lock another open surface still needs.
223
- function scrollLockAction(_node: HTMLElement) {
224
- if (openerElement === null && document.activeElement instanceof HTMLElement) {
225
- openerElement = document.activeElement;
220
+ //
221
+ // The dismissible layer is registered here too, for the same reason: this
222
+ // action's mount/destroy already brackets exactly the open span (the overlay
223
+ // only exists while `open` is true, per the `{#if open}` below). Escape used
224
+ // to be answered directly in handleKeyDown above, with nothing coordinating
225
+ // it against a Modal/Sheet/Menu opened on top — a keydown from a nested
226
+ // overlay bubbling up through this one's own onkeydown would close both.
227
+ // `element` reads `dialogElement` lazily, since it is not bound yet at this
228
+ // point in a fresh open. Ctrl+K stays on `handleGlobalKeyDown`/`window`
229
+ // below rather than moving here: it is an OPEN shortcut that must keep
230
+ // working while the menu is closed, and this layer only exists while open.
231
+ function scrollLockAction(node: HTMLElement) {
232
+ const opener = getActiveElement(node);
233
+ if (openerElement === null && opener instanceof HTMLElement) {
234
+ openerElement = opener;
226
235
  }
227
236
  lockBodyScroll();
237
+ const releaseDismissible = registerDismissible({
238
+ element: () => dialogElement,
239
+ onEscape: close
240
+ });
228
241
  tick().then(() => {
229
242
  if (inputElement !== null) {
230
243
  inputElement.focus();
@@ -232,6 +245,7 @@
232
245
  });
233
246
  return {
234
247
  destroy() {
248
+ releaseDismissible();
235
249
  unlockBodyScroll();
236
250
  if (openerElement !== null) {
237
251
  openerElement.focus();
@@ -459,7 +473,9 @@
459
473
  gap: var(--command-menu-item-gap, 10px);
460
474
  font-size: var(--command-menu-item-font-size, 14px);
461
475
  color: var(--command-menu-item-color, #334155);
462
- transition: background-color 0.1s;
476
+ transition: background-color
477
+ var(--command-menu-item-transition-duration, var(--motion-duration, 0.1s))
478
+ var(--command-menu-item-transition-easing, var(--motion-easing, ease));
463
479
  background: none;
464
480
  border: none;
465
481
  width: 100%;
@@ -1,5 +1,6 @@
1
1
  <script lang="ts">
2
- import { onMount, onDestroy } from 'svelte';
2
+ import { registerDismissible } from '../_interaction/dismissal';
3
+ import { getActiveElement } from '../_interaction/focus';
3
4
  import type { ContextMenuProperties, ContextMenuItem } from './properties';
4
5
 
5
6
  let {
@@ -73,7 +74,7 @@
73
74
  // and pulling it back to the opener would fight the user and lose their
74
75
  // place. Focus still inside the dying menu, or fallen to <body>, is loose and
75
76
  // does belong to the opener.
76
- const active = document.activeElement;
77
+ const active = getActiveElement(opener);
77
78
  const focusIsLoose =
78
79
  active === null || active === document.body || (menuEl?.contains(active) ?? false);
79
80
  if (focusIsLoose) {
@@ -133,7 +134,7 @@
133
134
  // focus to <body>" is not a restoration — it is the same as doing nothing,
134
135
  // but it also discards whatever the browser would have done. Capture only a
135
136
  // real element.
136
- const active = document.activeElement;
137
+ const active = event.target instanceof Node ? getActiveElement(event.target) : null;
137
138
  openerElement = active instanceof HTMLElement && active !== document.body ? active : null;
138
139
  openMenu(event.clientX, event.clientY);
139
140
  }
@@ -174,11 +175,6 @@
174
175
  }
175
176
  break;
176
177
  }
177
- case 'Escape': {
178
- event.preventDefault();
179
- close();
180
- break;
181
- }
182
178
  case 'Tab': {
183
179
  close();
184
180
  break;
@@ -186,36 +182,33 @@
186
182
  }
187
183
  }
188
184
 
189
- function handleClickOutside(event: Event) {
190
- if (event.target instanceof Node && menuEl && !menuEl.contains(event.target)) {
191
- close();
192
- }
193
- }
194
-
195
- // Escape is handled on the document as well as on the menu, because for one
196
- // frame after the right-click the menu is open but focus has not moved into it
197
- // yet. A menu-only handler does nothing during that window: the keystroke is
198
- // swallowed, the menu stays open, and the pending frame then pulls focus into
199
- // it — so a quick Escape appeared to do the opposite of what it asked for. An
200
- // open menu should close on Escape wherever focus happens to be.
201
- function handleDocumentKeydown(event: KeyboardEvent) {
202
- if (open && event.key === 'Escape') {
203
- event.preventDefault();
204
- close();
205
- }
185
+ /**
186
+ * Registers this dropdown as the topmost dismissible layer for exactly the
187
+ * span it is open (the `{#if open}` block below), and releases it again on
188
+ * teardown. Escape and outside-click used to be answered here directly (a
189
+ * document click listener and a document keydown listener, both added in
190
+ * onMount) — routed through the shared module instead so only the topmost
191
+ * open surface, not every one of them, answers a given press.
192
+ *
193
+ * The module still listens for Escape on the document rather than only on
194
+ * the menu, preserving the reason this component used to as well: for one
195
+ * frame after the right-click the menu is open but focus has not moved into
196
+ * it yet (see openMenu's requestAnimationFrame above). A menu-only handler
197
+ * would do nothing during that window — the keystroke swallowed, the menu
198
+ * still open, and the pending frame then pulling focus into it — so a quick
199
+ * Escape appeared to do the opposite of what it asked for. An open menu has
200
+ * to close on Escape wherever focus happens to be.
201
+ */
202
+ function dismissalAction(_node: HTMLDivElement) {
203
+ const release = registerDismissible({
204
+ element: () => menuEl,
205
+ onEscape: close,
206
+ onOutside: close
207
+ });
208
+ return {
209
+ destroy: release
210
+ };
206
211
  }
207
-
208
- onMount(() => {
209
- document.addEventListener('click', handleClickOutside);
210
- document.addEventListener('keydown', handleDocumentKeydown);
211
- });
212
-
213
- onDestroy(() => {
214
- if (typeof window !== 'undefined') {
215
- document.removeEventListener('click', handleClickOutside);
216
- document.removeEventListener('keydown', handleDocumentKeydown);
217
- }
218
- });
219
212
  </script>
220
213
 
221
214
  <div
@@ -239,6 +232,7 @@
239
232
  role="menu"
240
233
  tabindex="-1"
241
234
  onkeydown={handleMenuKeydown}
235
+ use:dismissalAction
242
236
  >
243
237
  {#each items as item (item.value)}
244
238
  {#if item.separator}
@@ -4,10 +4,13 @@
4
4
  DateRangePreset,
5
5
  TimeDisplayBoundary
6
6
  } from './properties';
7
- import { tick, untrack } from 'svelte';
7
+ import { onDestroy, tick, untrack } from 'svelte';
8
8
  import { SvelteDate } from 'svelte/reactivity';
9
9
  import Calendar from '../Calendar/Calendar.svelte';
10
10
  import Button from '../Button/Button.svelte';
11
+ import { focusTrapTabTarget, getFocusTrapBoundary } from '../_interaction/focus';
12
+ import { getActiveElement } from '../_interaction/focus';
13
+ import { registerDismissible } from '../_interaction/dismissal';
11
14
  import chevronDownSvg from '../assets/chevron-down.svg?raw';
12
15
  import checkmarkSvg from '../assets/checkmark.svg?raw';
13
16
  import chevronRightSvg from '../assets/chevron-right.svg?raw';
@@ -147,6 +150,10 @@
147
150
  let isOpen: boolean = $state(false);
148
151
  let panelRef: HTMLDivElement | null = $state(null);
149
152
  let triggerRef: HTMLDivElement | null = $state(null);
153
+ // The trigger, compare trigger, and both panels are all un-portaled descendants
154
+ // of this one root, so it alone is the dismissible layer's "inside" boundary --
155
+ // see the registration effect below.
156
+ let drpRootEl: HTMLDivElement | null = $state(null);
150
157
  // The panel anchors below the trigger by default. When the trigger sits low in
151
158
  // the viewport there is no room for it there, and because the panel is
152
159
  // position:absolute (not portaled, not fixed) it simply extends past the fold
@@ -157,6 +164,9 @@
157
164
  let compareTriggerRef: HTMLDivElement | null = $state(null);
158
165
  // Stores the element that opened the compare panel so focus can be restored on close.
159
166
  let compareFocusReturnEl: HTMLElement | null = null;
167
+ // Same bookkeeping for the main panel — both panels declare aria-modal="true", so both
168
+ // need to return focus to whatever opened them once they close.
169
+ let panelFocusReturnEl: HTMLElement | null = null;
160
170
 
161
171
  // Tracks the active preset label for the trigger display (seeded from initialPresetLabel on mount)
162
172
  const resolvedInitialPresetLabel: string | null = untrack(() => {
@@ -274,7 +284,43 @@
274
284
  return placeholder;
275
285
  });
276
286
 
287
+ // Shared focus-trap plumbing for both aria-modal="true" panels (main and compare).
288
+ // Originally written once, inline, for the compare panel only, then reimplemented
289
+ // a third time for the main panel; both now delegate to the same module Modal and
290
+ // Sheet use (src/lib/_interaction/focus.ts) instead of carrying their own copy.
291
+
292
+ // Moves focus to the first focusable descendant of an open panel, so a keyboard user
293
+ // lands inside the modal content instead of on whatever was focused before it opened.
294
+ // Uses the boundary directly rather than focusEntryPoint's container fallback: this
295
+ // panel has never focused itself when empty, and preserving that (unlikely, since a
296
+ // dialog with no focusable content isn't a case this component has) is simpler than
297
+ // auditing whether the fallback is safe everywhere this is called.
298
+ function focusFirstElement(panelNode: HTMLElement | null): void {
299
+ const { first } = getFocusTrapBoundary(panelNode);
300
+ first?.focus();
301
+ }
302
+
303
+ // Keeps Tab/Shift+Tab cycling within an open panel — required by aria-modal="true":
304
+ // focus must never leave the panel via keyboard until it closes.
305
+ function trapFocus(panelNode: HTMLElement | null, event: KeyboardEvent): void {
306
+ if (event.key !== 'Tab') {
307
+ return;
308
+ }
309
+ const target = focusTrapTabTarget({
310
+ container: panelNode,
311
+ activeElement: getActiveElement(panelNode),
312
+ shiftKey: event.shiftKey
313
+ });
314
+ if (target !== null) {
315
+ event.preventDefault();
316
+ target.focus();
317
+ }
318
+ }
319
+
277
320
  function openPicker(): void {
321
+ // Save whatever had focus (the trigger button) so it can be restored on close.
322
+ const panelOpener = getActiveElement(triggerRef);
323
+ panelFocusReturnEl = panelOpener instanceof HTMLElement ? panelOpener : null;
278
324
  // Seed draft from current committed values
279
325
  draftStart = rangeStart;
280
326
  draftEnd = rangeEnd;
@@ -309,12 +355,18 @@
309
355
 
310
356
  isOpen = true;
311
357
  onopentoggle?.({ open: true });
358
+ tick().then(() => focusFirstElement(panelRef));
312
359
  }
313
360
 
314
361
  function closePicker(): void {
315
362
  isOpen = false;
316
363
  opensUpward = false;
317
364
  onopentoggle?.({ open: false });
365
+ const returnEl = panelFocusReturnEl;
366
+ panelFocusReturnEl = null;
367
+ tick().then(() => {
368
+ returnEl?.focus();
369
+ });
318
370
  }
319
371
 
320
372
  /**
@@ -363,17 +415,12 @@
363
415
  }
364
416
 
365
417
  function openComparePicker(): void {
366
- compareFocusReturnEl =
367
- document.activeElement instanceof HTMLElement ? document.activeElement : null;
418
+ const compareOpener = getActiveElement(compareTriggerRef);
419
+ compareFocusReturnEl = compareOpener instanceof HTMLElement ? compareOpener : null;
368
420
  draftCompareStart = compareStart ?? null;
369
421
  draftCompareEnd = compareEnd ?? null;
370
422
  openCompare = true;
371
- tick().then(() => {
372
- const firstFocusable = comparePanelRef?.querySelector<HTMLElement>(
373
- 'button:not([disabled]), [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
374
- );
375
- firstFocusable?.focus();
376
- });
423
+ tick().then(() => focusFirstElement(comparePanelRef));
377
424
  }
378
425
 
379
426
  function closeComparePicker(): void {
@@ -623,6 +670,22 @@
623
670
  return parsedEndDate !== null && isTypedDateAcceptable('end', parsedEndDate);
624
671
  });
625
672
 
673
+ /* These four flags already drove `aria-invalid`, and nothing else. A
674
+ control that announces it is invalid while linking nothing that says why
675
+ tells a screen-reader user only that they are stuck. Each field now names
676
+ the message that explains it, and the message is rendered only while the
677
+ field is actually invalid so the reference never dangles.
678
+
679
+ The two layout branches below (`isInlineTime` and its `{:else}`) are
680
+ mutually exclusive, so the same four ids are never in the document twice. */
681
+ const drpFieldUid = $props.id();
682
+ const startDateErrorId = `${drpFieldUid}-start-date-error`;
683
+ const startTimeErrorId = `${drpFieldUid}-start-time-error`;
684
+ const endDateErrorId = `${drpFieldUid}-end-date-error`;
685
+ const endTimeErrorId = `${drpFieldUid}-end-time-error`;
686
+ const DATE_HINT = 'Enter a date this picker accepts, like 12 Mar 2025.';
687
+ const TIME_HINT = 'Enter a time like 12:00 AM.';
688
+
626
689
  // True when date's month is already one of the two visible calendar months (or the
627
690
  // single visible month outside dual-month mode) — used to avoid an unnecessary
628
691
  // re-navigation/remount when a typed date is already on screen.
@@ -704,48 +767,73 @@
704
767
  }
705
768
  }
706
769
 
707
- // Close on outside-click
708
- function handleDocumentClick(event: MouseEvent): void {
709
- if (!isOpen && !openCompare) {
710
- return;
711
- }
712
- const target = event.target;
713
- if (!(target instanceof Node)) {
714
- return;
770
+ /* Escape closes the compare panel first, matching the original priority: with
771
+ both surfaces open, compare is the one the user opened most recently (it can
772
+ only ever open while the main panel already is), so it is the one Escape
773
+ should answer. Unlike outside-press below, this goes through handleCancel /
774
+ handleCancelCompare rather than closePicker / closeComparePicker directly --
775
+ preserving the original's asymmetry where Escape fires `oncancel` and an
776
+ outside click does not. */
777
+ function handleDismissEscape(): void {
778
+ if (openCompare) {
779
+ handleCancelCompare();
780
+ } else if (isOpen) {
781
+ handleCancel();
715
782
  }
716
- const clickedInsidePanel = panelRef !== null && panelRef.contains(target);
717
- const clickedTrigger = triggerRef !== null && triggerRef.contains(target);
718
- const clickedInsideComparePanel = comparePanelRef !== null && comparePanelRef.contains(target);
719
- const clickedCompareTrigger = compareTriggerRef !== null && compareTriggerRef.contains(target);
720
- if (
721
- isOpen &&
722
- !clickedInsidePanel &&
723
- !clickedTrigger &&
724
- !clickedInsideComparePanel &&
725
- !clickedCompareTrigger
726
- ) {
783
+ }
784
+
785
+ /* Two independent ifs, not else-if: the original closed whichever of the two
786
+ surfaces the click landed outside of, and a press outside drp-root is
787
+ outside both at once. */
788
+ function handleDismissOutside(): void {
789
+ if (isOpen) {
727
790
  closePicker();
728
791
  }
729
- if (
730
- openCompare &&
731
- !clickedInsideComparePanel &&
732
- !clickedCompareTrigger &&
733
- !clickedInsidePanel &&
734
- !clickedTrigger
735
- ) {
792
+ if (openCompare) {
736
793
  closeComparePicker();
737
794
  }
738
795
  }
739
796
 
740
- function handleDocumentKeyDown(event: KeyboardEvent): void {
741
- if (event.key === 'Escape') {
742
- if (openCompare) {
743
- handleCancelCompare();
744
- } else if (isOpen) {
745
- handleCancel();
746
- }
797
+ let releaseDismissible: (() => void) | null = null;
798
+
799
+ /* Main panel and compare panel are two separately-toggleable surfaces sharing
800
+ one un-portaled root (drp-root), so a single registration -- rather than one
801
+ per panel -- reproduces the original four-way "clicked inside any of the two
802
+ triggers or two panels" boundary check in one go, while handleDismissEscape /
803
+ handleDismissOutside above keep their original mutual precedence.
804
+ registerDismissible has no notion of "this layer's boundary changed shape,
805
+ keep the same slot" -- release+register is the only way to update it, and
806
+ that always re-adds at the top of the stack. Guarding on an actual
807
+ unregistered->registered (or reverse) transition, rather than registering
808
+ fresh on every read of isOpen/openCompare, matters because opening the
809
+ compare panel while the main panel is already open reads openCompare without
810
+ isOpen changing -- naively re-registering there would pop this layer and
811
+ push it back on top, wrongly promoting it above an unrelated layer (e.g. a
812
+ Modal) that opened in between and should stay topmost. $effect is the
813
+ sanctioned reactive escape hatch here, as elsewhere in this library, for
814
+ reacting to a boolean that two independent, bindable pieces of state
815
+ jointly drive. */
816
+ // eslint-disable-next-line no-restricted-syntax
817
+ $effect(() => {
818
+ const shouldBeRegistered = isOpen || openCompare;
819
+ if (shouldBeRegistered && releaseDismissible === null) {
820
+ releaseDismissible = registerDismissible({
821
+ element: () => drpRootEl,
822
+ onEscape: handleDismissEscape,
823
+ onOutside: handleDismissOutside
824
+ });
825
+ } else if (!shouldBeRegistered && releaseDismissible !== null) {
826
+ releaseDismissible();
827
+ releaseDismissible = null;
747
828
  }
748
- }
829
+ });
830
+
831
+ // Safety net for unmounting while still open — the effect above only reacts to
832
+ // isOpen/openCompare transitions, not to the component going away mid-open.
833
+ onDestroy(() => {
834
+ releaseDismissible?.();
835
+ releaseDismissible = null;
836
+ });
749
837
 
750
838
  const canApply: boolean = $derived.by(() => {
751
839
  if (mode === 'range') {
@@ -796,10 +884,14 @@
796
884
  }
797
885
  </script>
798
886
 
799
- <svelte:document onclick={handleDocumentClick} onkeydown={handleDocumentKeyDown} />
887
+ {#snippet drpFieldError(id: string, show: boolean, message: string)}
888
+ {#if show}
889
+ <span {id} class="drp-field-error" role="alert">{message}</span>
890
+ {/if}
891
+ {/snippet}
800
892
 
801
- <div class="drp-root {classes ?? ''}" data-pw={testId} testID={testId}>
802
- <!-- Trigger wrapper — bind:this here so outside-click detection works -->
893
+ <div class="drp-root {classes ?? ''}" bind:this={drpRootEl} data-pw={testId} testID={testId}>
894
+ <!-- Trigger wrapper — bind:this here for panel positioning and focus-return -->
803
895
  <div bind:this={triggerRef} class="drp-trigger-wrapper">
804
896
  <Button
805
897
  onclick={togglePicker}
@@ -845,29 +937,7 @@
845
937
  aria-label="Compare period picker"
846
938
  aria-modal="true"
847
939
  tabindex="-1"
848
- onkeydown={(event) => {
849
- if (event.key === 'Tab') {
850
- const focusable = comparePanelRef?.querySelectorAll<HTMLElement>(
851
- 'button:not([disabled]), [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
852
- );
853
- if (!focusable || focusable.length === 0) {
854
- return;
855
- }
856
- const first = focusable[0];
857
- const last = focusable[focusable.length - 1];
858
- if (event.shiftKey) {
859
- if (document.activeElement === first) {
860
- event.preventDefault();
861
- last.focus();
862
- }
863
- } else {
864
- if (document.activeElement === last) {
865
- event.preventDefault();
866
- first.focus();
867
- }
868
- }
869
- }
870
- }}
940
+ onkeydown={(event) => trapFocus(comparePanelRef, event)}
871
941
  >
872
942
  {#if typeof compareCalendar === 'function'}
873
943
  <div class="drp-compare-panel-body">
@@ -904,6 +974,8 @@
904
974
  role="dialog"
905
975
  aria-label="Date range picker"
906
976
  aria-modal="true"
977
+ tabindex="-1"
978
+ onkeydown={(event) => trapFocus(panelRef, event)}
907
979
  data-pw={typeof testId === 'string' ? `${testId}-panel` : null}
908
980
  testID={typeof testId === 'string' ? `${testId}-panel` : null}
909
981
  >
@@ -966,6 +1038,7 @@
966
1038
  placeholder="Start date"
967
1039
  aria-label="Start date"
968
1040
  aria-invalid={!isStartDateValid}
1041
+ aria-describedby={isStartDateValid ? null : startDateErrorId}
969
1042
  onblur={() => commitTypedDate('start')}
970
1043
  onkeydown={(event) => handleDateInputKeyDown(event, 'start')}
971
1044
  data-pw={testId ? `${testId}-start-date` : null}
@@ -986,11 +1059,14 @@
986
1059
  placeholder="12:00 AM"
987
1060
  aria-label="Start time"
988
1061
  aria-invalid={!isStartTimeValid}
1062
+ aria-describedby={isStartTimeValid ? null : startTimeErrorId}
989
1063
  data-pw={testId ? `${testId}-start-time` : null}
990
1064
  testID={testId ? `${testId}-start-time` : null}
991
1065
  />
992
1066
  </div>
1067
+ {@render drpFieldError(startDateErrorId, !isStartDateValid, DATE_HINT)}
993
1068
  </div>
1069
+ {@render drpFieldError(startTimeErrorId, !isStartTimeValid, TIME_HINT)}
994
1070
  <!-- eslint-disable-next-line svelte/no-at-html-tags -->
995
1071
  <span class="drp-datetime-arrow" aria-hidden="true">{@html chevronRightSvg}</span>
996
1072
  <div class="drp-date-time-group">
@@ -1004,6 +1080,7 @@
1004
1080
  placeholder="End date"
1005
1081
  aria-label="End date"
1006
1082
  aria-invalid={!isEndDateValid}
1083
+ aria-describedby={isEndDateValid ? null : endDateErrorId}
1007
1084
  onblur={() => commitTypedDate('end')}
1008
1085
  onkeydown={(event) => handleDateInputKeyDown(event, 'end')}
1009
1086
  data-pw={testId ? `${testId}-end-date` : null}
@@ -1024,11 +1101,14 @@
1024
1101
  placeholder="11:59 PM"
1025
1102
  aria-label="End time"
1026
1103
  aria-invalid={!isEndTimeValid}
1104
+ aria-describedby={isEndTimeValid ? null : endTimeErrorId}
1027
1105
  data-pw={testId ? `${testId}-end-time` : null}
1028
1106
  testID={testId ? `${testId}-end-time` : null}
1029
1107
  />
1030
1108
  </div>
1109
+ {@render drpFieldError(endDateErrorId, !isEndDateValid, DATE_HINT)}
1031
1110
  </div>
1111
+ {@render drpFieldError(endTimeErrorId, !isEndTimeValid, TIME_HINT)}
1032
1112
  {:else}
1033
1113
  <div class="drp-date-input" class:drp-date-input-invalid={!isStartDateValid}>
1034
1114
  <input
@@ -1040,11 +1120,13 @@
1040
1120
  placeholder="Start date"
1041
1121
  aria-label="Start date"
1042
1122
  aria-invalid={!isStartDateValid}
1123
+ aria-describedby={isStartDateValid ? null : startDateErrorId}
1043
1124
  onblur={() => commitTypedDate('start')}
1044
1125
  onkeydown={(event) => handleDateInputKeyDown(event, 'start')}
1045
1126
  data-pw={testId ? `${testId}-start-date` : null}
1046
1127
  testID={testId ? `${testId}-start-date` : null}
1047
1128
  />
1129
+ {@render drpFieldError(startDateErrorId, !isStartDateValid, DATE_HINT)}
1048
1130
  </div>
1049
1131
  <!-- eslint-disable-next-line svelte/no-at-html-tags -->
1050
1132
  <span class="drp-datetime-arrow" aria-hidden="true">{@html chevronRightSvg}</span>
@@ -1058,11 +1140,13 @@
1058
1140
  placeholder="End date"
1059
1141
  aria-label="End date"
1060
1142
  aria-invalid={!isEndDateValid}
1143
+ aria-describedby={isEndDateValid ? null : endDateErrorId}
1061
1144
  onblur={() => commitTypedDate('end')}
1062
1145
  onkeydown={(event) => handleDateInputKeyDown(event, 'end')}
1063
1146
  data-pw={testId ? `${testId}-end-date` : null}
1064
1147
  testID={testId ? `${testId}-end-date` : null}
1065
1148
  />
1149
+ {@render drpFieldError(endDateErrorId, !isEndDateValid, DATE_HINT)}
1066
1150
  </div>
1067
1151
  {#if showTimeSelection}
1068
1152
  <button
@@ -1094,9 +1178,11 @@
1094
1178
  placeholder="12:00 AM"
1095
1179
  aria-label="Start time"
1096
1180
  aria-invalid={!isStartTimeValid}
1181
+ aria-describedby={isStartTimeValid ? null : startTimeErrorId}
1097
1182
  data-pw={testId ? `${testId}-start-time` : null}
1098
1183
  testID={testId ? `${testId}-start-time` : null}
1099
1184
  />
1185
+ {@render drpFieldError(startTimeErrorId, !isStartTimeValid, TIME_HINT)}
1100
1186
  </div>
1101
1187
  <!-- eslint-disable-next-line svelte/no-at-html-tags -->
1102
1188
  <span class="drp-datetime-arrow" aria-hidden="true">{@html chevronRightSvg}</span>
@@ -1111,9 +1197,11 @@
1111
1197
  placeholder="11:59 PM"
1112
1198
  aria-label="End time"
1113
1199
  aria-invalid={!isEndTimeValid}
1200
+ aria-describedby={isEndTimeValid ? null : endTimeErrorId}
1114
1201
  data-pw={testId ? `${testId}-end-time` : null}
1115
1202
  testID={testId ? `${testId}-end-time` : null}
1116
1203
  />
1204
+ {@render drpFieldError(endTimeErrorId, !isEndTimeValid, TIME_HINT)}
1117
1205
  </div>
1118
1206
  </div>
1119
1207
  {/if}
@@ -1237,6 +1325,14 @@
1237
1325
  </div>
1238
1326
 
1239
1327
  <style>
1328
+ .drp-field-error {
1329
+ display: block;
1330
+ color: var(--drp-field-error-color, #b3261e);
1331
+ font-size: var(--drp-field-error-font-size, 11px);
1332
+ line-height: var(--drp-field-error-line-height, 1.4);
1333
+ margin-top: var(--drp-field-error-margin-top, 2px);
1334
+ }
1335
+
1240
1336
  .drp-root {
1241
1337
  position: relative;
1242
1338
  display: inline-block;
@@ -1292,7 +1388,7 @@
1292
1388
  position: absolute;
1293
1389
  top: calc(100% + var(--drp-panel-offset, 6px));
1294
1390
  z-index: var(--drp-panel-z-index, 1000);
1295
- background: var(--drp-panel-background, inherit);
1391
+ background: var(--drp-panel-background, #ffffff);
1296
1392
  border: var(--drp-panel-border, 1px solid #e0e0e0);
1297
1393
  border-radius: var(--drp-panel-border-radius, var(--radius, 4px));
1298
1394
  box-shadow: var(--drp-panel-shadow, 0 8px 24px rgba(0, 0, 0, 0.12));
@@ -1351,7 +1447,8 @@
1351
1447
  color: var(--drp-preset-color, inherit);
1352
1448
  cursor: pointer;
1353
1449
  white-space: nowrap;
1354
- transition: background 0.12s ease;
1450
+ transition: background var(--drp-preset-item-transition-duration, var(--motion-duration, 0.12s))
1451
+ var(--drp-preset-item-transition-easing, var(--motion-easing, ease));
1355
1452
  }
1356
1453
 
1357
1454
  .drp-preset-item:hover {
@@ -1429,7 +1526,8 @@
1429
1526
  border-radius: var(--drp-nav-btn-border-radius, var(--radius, 4px));
1430
1527
  cursor: pointer;
1431
1528
  color: var(--drp-nav-btn-color, inherit);
1432
- transition: background 0.12s ease;
1529
+ transition: background var(--drp-nav-btn-transition-duration, var(--motion-duration, 0.12s))
1530
+ var(--drp-nav-btn-transition-easing, var(--motion-easing, ease));
1433
1531
  padding: 0;
1434
1532
  }
1435
1533
 
@@ -1751,7 +1849,7 @@
1751
1849
  top: calc(100% + var(--drp-panel-offset, 6px));
1752
1850
  left: var(--drp-compare-panel-left, 0);
1753
1851
  z-index: var(--drp-panel-z-index, 1000);
1754
- background: var(--drp-panel-background, inherit);
1852
+ background: var(--drp-panel-background, #ffffff);
1755
1853
  border: var(--drp-panel-border, 1px solid #e0e0e0);
1756
1854
  border-radius: var(--drp-panel-border-radius, var(--radius, 4px));
1757
1855
  box-shadow: var(--drp-panel-shadow, 0 8px 24px rgba(0, 0, 0, 0.12));
@@ -64,15 +64,15 @@
64
64
  }
65
65
 
66
66
  .delta-positive {
67
- color: var(--delta-indicator-positive-color, #1a9d6f);
67
+ color: var(--delta-indicator-positive-color, #007846);
68
68
  }
69
69
 
70
70
  .delta-negative {
71
- color: var(--delta-indicator-negative-color, #e5484d);
71
+ color: var(--delta-indicator-negative-color, light-dark(#c81e26, #f87171));
72
72
  }
73
73
 
74
74
  .delta-neutral {
75
- color: var(--delta-indicator-neutral-color, #8a8a8a);
75
+ color: var(--delta-indicator-neutral-color, #646464);
76
76
  }
77
77
 
78
78
  /* The tone color is set on the wrapper; the text span must inherit it explicitly.