@juspay/svelte-ui-components 4.52.1 → 4.53.1

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 (39) hide show
  1. package/README.md +14 -0
  2. package/dist/AttachmentChipRow/AttachmentChipRow.svelte +3 -2
  3. package/dist/AttachmentChipRow/properties.d.ts +2 -0
  4. package/dist/Book/Book.svelte +2 -0
  5. package/dist/Book/properties.d.ts +2 -0
  6. package/dist/Chat/Chat.svelte +2 -0
  7. package/dist/Chat/properties.d.ts +2 -0
  8. package/dist/Chat/types.d.ts +2 -0
  9. package/dist/ChatMessage/ChatMessage.svelte +18 -3
  10. package/dist/ChatMessage/properties.d.ts +6 -0
  11. package/dist/ChatMessageList/ChatMessageList.svelte +31 -7
  12. package/dist/ChatMessageList/properties.d.ts +2 -0
  13. package/dist/Choicebox/Choicebox.svelte +33 -23
  14. package/dist/DateRangePicker/DateRangePicker.svelte +82 -33
  15. package/dist/DateRangePicker/properties.d.ts +18 -2
  16. package/dist/Draggable/Draggable.svelte +4 -2
  17. package/dist/MediaPlayer/MediaPlayer.svelte +28 -13
  18. package/dist/MediaPlayer/properties.d.ts +2 -0
  19. package/dist/Modal/Modal.svelte +30 -43
  20. package/dist/Modal/properties.d.ts +5 -4
  21. package/dist/Pagination/Pagination.svelte +2 -0
  22. package/dist/Pagination/properties.d.ts +2 -0
  23. package/dist/Pill/Pill.svelte +61 -4
  24. package/dist/Pill/properties.d.ts +8 -16
  25. package/dist/Radio/Radio.svelte +6 -2
  26. package/dist/RatingGroup/RatingGroup.svelte +38 -26
  27. package/dist/Select/Select.svelte +8 -0
  28. package/dist/StatCard/StatCard.svelte +68 -17
  29. package/dist/StatCard/properties.d.ts +1 -1
  30. package/dist/Stepper/Step.svelte +2 -2
  31. package/dist/Table/Table.svelte +32 -2
  32. package/dist/Table/properties.d.ts +11 -5
  33. package/dist/Tabs/Tabs.svelte +187 -12
  34. package/dist/ToolCallLog/ToolCallLog.svelte +4 -4
  35. package/dist/utils.js +20 -2
  36. package/dist-codemod/codemod/cli.js +4 -4
  37. package/dist-wc/index.js +3134 -2717
  38. package/package.json +3 -2
  39. package/scripts/postinstall.mjs +13 -15
package/README.md CHANGED
@@ -154,6 +154,20 @@ fix to something that did not work.
154
154
  | **RelativeTime** | Auto-updating relative time display ("5 minutes ago") with locale support and optional tooltip. | [docs](docs/RelativeTime.md) |
155
155
  | **AspectRatio** | Constrains content to a fixed width-to-height ratio using CSS `aspect-ratio`, reserving layout space before an image, video or iframe loads. | [docs](docs/AspectRatio.md) |
156
156
 
157
+ ### Charts
158
+
159
+ Pure-SVG charts with no charting-library dependency; each takes plain data arrays.
160
+
161
+ | Component | Description | Docs |
162
+ | --- | --- | --- |
163
+ | **AreaChart** | Responsive area chart for volume under a trend: multiple series, regular or 100% stacking, gradient fill, data labels and a hover overlay. | [docs](docs/AreaChart.md) |
164
+ | **BarChart** | Responsive bar chart for comparing categories: vertical or horizontal, single or multi-series (grouped or stacked), value labels, per-point colours and hover tooltips. | [docs](docs/BarChart.md) |
165
+ | **DualAxisBarChart** | Two independent Y-axes over one categorical X-axis; each series declares its own axis and render type. | [docs](docs/DualAxisBarChart.md) |
166
+ | **FunnelChart** | Horizontal funnel: one bar per stage, proportional to its value, with connectors between stages. | [docs](docs/FunnelChart.md) |
167
+ | **LineChart** | Responsive line chart for trends: multiple series, five curve interpolations, gradient fill and an optional area mode. | [docs](docs/LineChart.md) |
168
+ | **PieChart** | Responsive pie or donut chart with slice hover highlighting, a delta badge, custom labels, a legend and donut centre content. | [docs](docs/PieChart.md) |
169
+ | **SankeyChart** | Sankey diagram for flows between nodes, positioned into columns automatically. | [docs](docs/SankeyChart.md) |
170
+
157
171
  ### Feedback & Loading
158
172
 
159
173
  | Component | Description | Docs |
@@ -26,14 +26,15 @@
26
26
  removeIcon,
27
27
  fileIcon,
28
28
  testId,
29
- classes
29
+ classes,
30
+ ariaLabel = 'Pending attachments'
30
31
  }: AttachmentChipRowProperties = $props();
31
32
  </script>
32
33
 
33
34
  {#if images.length > 0 || videos.length > 0 || files.length > 0}
34
35
  <Scroller
35
36
  direction="horizontal"
36
- ariaLabel="Pending attachments"
37
+ {ariaLabel}
37
38
  showArrows={false}
38
39
  hideScrollbar={false}
39
40
  showGradient={false}
@@ -44,4 +44,6 @@ export type OptionalAttachmentChipRowProperties = {
44
44
  fileIcon?: Snippet;
45
45
  testId?: string;
46
46
  classes?: string;
47
+ /** Name of this attachment strip. Default: `Pending attachments`. */
48
+ ariaLabel?: string;
47
49
  };
@@ -11,6 +11,7 @@
11
11
  showNavigation = true,
12
12
  showPageIndicator = true,
13
13
  enableSwipe = false,
14
+ ariaLabel = 'Book',
14
15
  testId,
15
16
  previousIcon,
16
17
  nextIcon,
@@ -108,6 +109,7 @@
108
109
  onmousedown={handleMouseDown}
109
110
  onmouseup={handleMouseUp}
110
111
  role="region"
112
+ aria-label={ariaLabel}
111
113
  tabindex="0"
112
114
  >
113
115
  <div class="book-viewport">
@@ -14,6 +14,8 @@ export type OptionalBookProperties = {
14
14
  showNavigation?: boolean;
15
15
  showPageIndicator?: boolean;
16
16
  enableSwipe?: boolean;
17
+ /** Accessible name for the keyboard-operated book region. Defaults to "Book". */
18
+ ariaLabel?: string;
17
19
  testId?: string;
18
20
  previousIcon?: Snippet;
19
21
  nextIcon?: Snippet;
@@ -23,6 +23,7 @@
23
23
  hideScrollbar = false,
24
24
  jump = true,
25
25
  jumpLabel = 'Jump to latest',
26
+ messageListLabel,
26
27
  jumpIcon,
27
28
  toolStatus = null,
28
29
  suggestions = [],
@@ -97,6 +98,7 @@
97
98
  {/if}
98
99
 
99
100
  <ChatMessageList
101
+ ariaLabel={messageListLabel ?? (title.trim() ? `${title} messages` : 'Conversation messages')}
100
102
  {messages}
101
103
  {autoscroll}
102
104
  {scrollPolicy}
@@ -23,6 +23,8 @@ export type OptionalChatProperties = {
23
23
  hideScrollbar?: ChatMessageListProperties['hideScrollbar'];
24
24
  jump?: ChatMessageListProperties['jump'];
25
25
  jumpLabel?: ChatMessageListProperties['jumpLabel'];
26
+ /** Transcript name; defaults to the chat title plus `messages`, or `Conversation messages`. */
27
+ messageListLabel?: ChatMessageListProperties['ariaLabel'];
26
28
  jumpIcon?: ChatMessageListProperties['jumpIcon'];
27
29
  toolStatus?: ChatToolStatus | null;
28
30
  suggestions?: ChatSuggestion[];
@@ -11,6 +11,8 @@ export type ChatMessageData = {
11
11
  * MarkdownText). Wins over `html` and `content` in the bubble.
12
12
  */
13
13
  markdown?: string;
14
+ /** Accessible table scroll-region name for static and streamed markdown bubbles. */
15
+ markdownTableLabel?: string;
14
16
  streaming?: boolean;
15
17
  /**
16
18
  * Reveal this message progressively as it grows. Opt-in per message so it cannot stack with
@@ -10,12 +10,14 @@
10
10
  import { onDestroy, onMount } from 'svelte';
11
11
  import { partyOf } from '../Chat/roles';
12
12
  import type { ChatMessageProperties } from './properties';
13
+ import type { RenderMarkdownOptions } from '../MarkdownText/properties';
13
14
 
14
15
  let {
15
16
  role,
16
17
  content = '',
17
18
  html,
18
19
  markdown,
20
+ markdownTableLabel,
19
21
  body,
20
22
  streaming = false,
21
23
  typewriter = false,
@@ -90,7 +92,9 @@
90
92
  `marked` — a peer dependency — is only pulled in at runtime, never during
91
93
  SSR, and rendering `markdown` degrades to the `html`/`content` fallback
92
94
  until the module resolves or when the peer is absent. */
93
- let renderMarkdownFn = $state<((source: string) => string) | null>(null);
95
+ let renderMarkdownFn = $state<
96
+ ((source: string, options?: RenderMarkdownOptions) => string) | null
97
+ >(null);
94
98
 
95
99
  onMount(() => {
96
100
  void import('../MarkdownText/markdown')
@@ -102,8 +106,19 @@
102
106
  });
103
107
  });
104
108
 
109
+ // Both rendering branches share the same options-aware renderer. Capture the label
110
+ // reactively so a locale/name update also refreshes already-revealed typewriter HTML.
111
+ let messageMarkdownRenderer = $derived.by(() => {
112
+ const renderer = renderMarkdownFn;
113
+ if (renderer === null) {
114
+ return null;
115
+ }
116
+ const options: RenderMarkdownOptions =
117
+ typeof markdownTableLabel === 'string' ? { tableLabel: markdownTableLabel } : {};
118
+ return (source: string) => renderer(source, options);
119
+ });
105
120
  let effectiveHtml = $derived(
106
- hasMarkdown && renderMarkdownFn !== null ? renderMarkdownFn(markdown ?? '') : html
121
+ hasMarkdown && messageMarkdownRenderer !== null ? messageMarkdownRenderer(markdown ?? '') : html
107
122
  );
108
123
  let hasHtml = $derived(typeof effectiveHtml === 'string' && effectiveHtml.length > 0);
109
124
 
@@ -128,7 +143,7 @@
128
143
  (!hasMarkdown || renderMarkdownFn !== null)
129
144
  );
130
145
  let typewriterRenderText = $derived(
131
- hasMarkdown && renderMarkdownFn !== null ? { renderText: renderMarkdownFn } : {}
146
+ hasMarkdown && messageMarkdownRenderer !== null ? { renderText: messageMarkdownRenderer } : {}
132
147
  );
133
148
  // Clamped at the boundary the way LoadingDots clamps `dots`, so the prop's floor is part
134
149
  // of this component's contract rather than something a consumer discovers. The finite
@@ -20,6 +20,12 @@ export type OptionalChatMessageProperties = {
20
20
  * (for server-rendered markdown use `MarkdownText` or `renderMarkdown`).
21
21
  */
22
22
  markdown?: string;
23
+ /**
24
+ * Accessible name for each table scroll region rendered from `markdown`, including
25
+ * progressive/typewriter rendering. Omitted or empty leaves the shared renderer's
26
+ * unnamed, keyboard-scrollable wrapper unchanged. Does not alter `html` or `body`.
27
+ */
28
+ markdownTableLabel?: string;
23
29
  /**
24
30
  * Replaces the rendered bubble body with arbitrary markup while keeping the
25
31
  * message chrome (role styling, avatar, header, attachments, copy/retry/
@@ -28,13 +28,15 @@
28
28
  onretry,
29
29
  onfeedback,
30
30
  testId,
31
- classes
31
+ classes,
32
+ ariaLabel = 'Conversation messages'
32
33
  }: ChatMessageListProperties = $props();
33
34
 
34
35
  let listEl: HTMLElement | null = $state(null);
35
36
  let innerEl: HTMLElement | null = $state(null);
36
37
  let atBottom = $state(true);
37
38
  let scrollable = $state(false);
39
+ let keyboardScrollable = $state(false);
38
40
  let pinActive = false;
39
41
  // Set when a turn without pinHold has asked for its reservation back, but the reply is not yet
40
42
  // tall enough to hold the pinned position without it. Cleared by tryReleasePin.
@@ -78,6 +80,7 @@
78
80
 
79
81
  function reportScrollState(node: HTMLElement): void {
80
82
  atBottom = isNearBottom(node);
83
+ keyboardScrollable = node.scrollHeight - node.clientHeight > 1;
81
84
  scrollable = node.scrollHeight - node.clientHeight > NEAR_BOTTOM_THRESHOLD;
82
85
  onscrollstate?.({ atBottom, scrollable });
83
86
  }
@@ -260,19 +263,29 @@
260
263
  if (typeof ResizeObserver === 'undefined') {
261
264
  return;
262
265
  }
266
+ let pendingFrame: number | null = null;
263
267
  const observer = new ResizeObserver(() => {
264
- if (listEl === null) {
268
+ if (pendingFrame !== null) {
265
269
  return;
266
270
  }
267
- if (scrollPolicy === 'near-bottom' && autoscroll && atBottom) {
268
- listEl.scrollTop = listEl.scrollHeight;
269
- }
270
- tryReleasePin();
271
- reportScrollState(listEl);
271
+ pendingFrame = requestAnimationFrame(() => {
272
+ pendingFrame = null;
273
+ if (listEl === null || !node.isConnected) {
274
+ return;
275
+ }
276
+ if (scrollPolicy === 'near-bottom' && autoscroll && atBottom) {
277
+ listEl.scrollTop = listEl.scrollHeight;
278
+ }
279
+ tryReleasePin();
280
+ reportScrollState(listEl);
281
+ });
272
282
  });
273
283
  observer.observe(node);
274
284
  return {
275
285
  destroy(): void {
286
+ if (pendingFrame !== null) {
287
+ cancelAnimationFrame(pendingFrame);
288
+ }
276
289
  observer.disconnect();
277
290
  }
278
291
  };
@@ -291,9 +304,14 @@
291
304
  };
292
305
  </script>
293
306
 
307
+ <!-- The transcript is an independent scrolling surface. Native keyboard focus
308
+ enables the browser's scroll keys while it overflows, even without actions. -->
309
+ <!-- svelte-ignore a11y_no_noninteractive_tabindex -->
294
310
  <div
295
311
  class="chat-message-list {classes ?? ''}"
296
312
  role="log"
313
+ aria-label={ariaLabel}
314
+ tabindex={keyboardScrollable ? 0 : null}
297
315
  aria-live="polite"
298
316
  data-pw={typeof testId === 'string' ? testId : null}
299
317
  testID={typeof testId === 'string' ? testId : null}
@@ -323,6 +341,7 @@
323
341
  content={msg.content}
324
342
  html={msg.html}
325
343
  markdown={msg.markdown}
344
+ markdownTableLabel={msg.markdownTableLabel}
326
345
  body={typeof messageBody === 'function' ? bodyFor : null}
327
346
  streaming={msg.streaming}
328
347
  typewriter={msg.typewriter}
@@ -352,6 +371,11 @@
352
371
  </div>
353
372
 
354
373
  <style>
374
+ .chat-message-list:focus-visible {
375
+ outline: var(--chat-message-list-focus-outline, 2px solid currentColor);
376
+ outline-offset: -2px;
377
+ }
378
+
355
379
  .chat-message-list {
356
380
  box-sizing: border-box;
357
381
  display: flex;
@@ -44,6 +44,8 @@ export type OptionalChatMessageListProperties = {
44
44
  allowCopy?: boolean;
45
45
  testId?: string;
46
46
  classes?: string;
47
+ /** Accessible transcript name. Default: `Conversation messages`. */
48
+ ariaLabel?: string;
47
49
  };
48
50
  export type ChatMessageListEventProperties = {
49
51
  /**
@@ -82,12 +82,20 @@
82
82
  onclick?.(selected);
83
83
  }
84
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();
85
+ // Let native validation choose the first invalid control. Redirect only its
86
+ // actual focus to the visible card, so checkValidity() stays a silent query
87
+ // and later invalid fields cannot replace the first field's focus.
88
+ function handleNativeFocus(event: FocusEvent & { currentTarget: HTMLInputElement }): void {
89
+ const carrier = event.currentTarget;
90
+ // Finish native interactive validation before moving focus. Firefox can
91
+ // open its validation UI after a synchronous handoff and swallow the next
92
+ // pointer click. A later owner or an unmounted card must keep its focus.
93
+ queueMicrotask(() => {
94
+ const root = carrier.getRootNode();
95
+ if (card?.isConnected && 'activeElement' in root && root.activeElement === carrier) {
96
+ card.focus();
97
+ }
98
+ });
91
99
  }
92
100
 
93
101
  function handleKeyDown(e: KeyboardEvent): void {
@@ -143,21 +151,6 @@
143
151
  testID={testId}
144
152
  {@attach groupMembership}
145
153
  >
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
- />
161
154
  {#if typeof children === 'function'}
162
155
  {@render children()}
163
156
  {/if}
@@ -171,6 +164,23 @@
171
164
  {/if}
172
165
  </div>
173
166
 
167
+ <!-- Native form participation remains outside the interactive accessibility owner. -->
168
+ <input
169
+ type={mode === 'radio' ? 'radio' : 'checkbox'}
170
+ class="native-control"
171
+ checked={selected}
172
+ {disabled}
173
+ {value}
174
+ {required}
175
+ name={typeof name === 'string' ? name : null}
176
+ form={typeof form === 'string' ? form : null}
177
+ onfocus={handleNativeFocus}
178
+ tabindex={-1}
179
+ aria-hidden="true"
180
+ data-pw={typeof testId === 'string' ? `${testId}-native-input` : null}
181
+ testID={typeof testId === 'string' ? `${testId}-native-input` : null}
182
+ />
183
+
174
184
  {#if field.showsError}
175
185
  <div
176
186
  id={field.errorId}
@@ -197,8 +207,8 @@
197
207
  .native-control {
198
208
  position: absolute;
199
209
  opacity: 0;
200
- width: 0;
201
- height: 0;
210
+ width: 1px;
211
+ height: 1px;
202
212
  margin: 0;
203
213
  pointer-events: none;
204
214
  }
@@ -1,5 +1,6 @@
1
1
  <script lang="ts">
2
2
  import type {
3
+ DateRangeCompareDraft,
3
4
  DateRangePickerProperties,
4
5
  DateRangePreset,
5
6
  TimeDisplayBoundary
@@ -95,8 +96,11 @@
95
96
  let draftStart: Date | null = $state(null);
96
97
  let draftEnd: Date | null = $state(null);
97
98
  let draftValue: Date | null = $state(null);
98
- let draftCompareStart: Date | null = $state(null);
99
- let draftCompareEnd: Date | null = $state(null);
99
+ // The compare range is a draft too. It is handed to `compareCalendar` so a consumer's calendar
100
+ // has somewhere to write that Apply commits and Cancel discards; `compareSnapshot` is what the
101
+ // committed range was when the session opened, which is what Cancel restores.
102
+ const compareDraft: DateRangeCompareDraft = $state({ start: null, end: null });
103
+ let compareSnapshot: DateRangeCompareDraft = $state({ start: null, end: null });
100
104
 
101
105
  // Tracks the label of the currently active preset, or null when the user
102
106
  // made a custom calendar selection (or before any selection is made).
@@ -339,6 +343,67 @@
339
343
  }
340
344
  }
341
345
 
346
+ const sameInstant = (a: Date | null, b: Date | null): boolean =>
347
+ (a === null ? null : a.getTime()) === (b === null ? null : b.getTime());
348
+
349
+ // The compare calendar lives in the main panel unless a standalone `compareTrigger` gives it
350
+ // its own panel and its own Apply/Cancel.
351
+ const compareInMain: boolean = $derived(
352
+ typeof compareCalendar === 'function' && typeof compareTrigger !== 'function'
353
+ );
354
+
355
+ function seedCompareSession(): void {
356
+ compareSnapshot = { start: compareStart, end: compareEnd };
357
+ compareDraft.start = compareStart;
358
+ compareDraft.end = compareEnd;
359
+ }
360
+
361
+ // What Apply commits. A calendar bound to `compareDraft` edits the draft. A calendar bound
362
+ // straight to `compareStart`/`compareEnd` (how this component's docs wired it before the draft
363
+ // was exposed) edits the committed values in place and leaves the draft at its seed; Apply used
364
+ // to write that stale seed back over the pick, and could not be pressed at all on first use.
365
+ const pendingCompare = $derived.by(() => {
366
+ const edited =
367
+ !sameInstant(compareDraft.start, compareSnapshot.start) ||
368
+ !sameInstant(compareDraft.end, compareSnapshot.end);
369
+ return edited
370
+ ? { start: compareDraft.start, end: compareDraft.end }
371
+ : { start: compareStart, end: compareEnd };
372
+ });
373
+ const canApplyCompare: boolean = $derived(
374
+ pendingCompare.start !== null && pendingCompare.end !== null
375
+ );
376
+
377
+ function commitCompare(): void {
378
+ const { start, end } = pendingCompare;
379
+ if (start === null || end === null) {
380
+ return;
381
+ }
382
+ compareStart = start;
383
+ compareEnd = end;
384
+ onapplycompare?.({ compareStart: start, compareEnd: end, presetLabel: selectedPresetLabel });
385
+ }
386
+
387
+ // Cancel, Escape and an outside press all land here: whatever the session did to the draft or
388
+ // to the committed range goes back to what it was when the session opened.
389
+ function discardCompare(): void {
390
+ if (!sameInstant(compareStart, compareSnapshot.start)) {
391
+ compareStart = compareSnapshot.start;
392
+ }
393
+ if (!sameInstant(compareEnd, compareSnapshot.end)) {
394
+ compareEnd = compareSnapshot.end;
395
+ }
396
+ compareDraft.start = compareSnapshot.start;
397
+ compareDraft.end = compareSnapshot.end;
398
+ }
399
+
400
+ function abandonPicker(): void {
401
+ if (compareInMain) {
402
+ discardCompare();
403
+ }
404
+ closePicker();
405
+ }
406
+
342
407
  function openPicker(): void {
343
408
  // Pointer activation need not focus a button in WebKit. Return to the
344
409
  // invoking control, not an unrelated previously focused field or body.
@@ -347,8 +412,9 @@
347
412
  draftStart = rangeStart;
348
413
  draftEnd = rangeEnd;
349
414
  draftValue = value;
350
- draftCompareStart = compareStart;
351
- draftCompareEnd = compareEnd;
415
+ if (compareInMain) {
416
+ seedCompareSession();
417
+ }
352
418
 
353
419
  // Re-seed the session from the committed preset so the sidebar re-highlights it
354
420
  // by label. A direct calendar click later clears this, reverting to date matching.
@@ -500,7 +566,7 @@
500
566
  // a second click feeling dead — the panel could only be closed by clicking away.
501
567
  function togglePicker(): void {
502
568
  if (isOpen) {
503
- closePicker();
569
+ abandonPicker();
504
570
  } else {
505
571
  openPicker();
506
572
  }
@@ -508,8 +574,7 @@
508
574
 
509
575
  function openComparePicker(): void {
510
576
  compareFocusReturnEl = compareTriggerRef ? findInteractive(compareTriggerRef) : null;
511
- draftCompareStart = compareStart ?? null;
512
- draftCompareEnd = compareEnd ?? null;
577
+ seedCompareSession();
513
578
  openCompare = true;
514
579
  tick().then(() => focusFirstElement(comparePanelRef));
515
580
  }
@@ -524,19 +589,12 @@
524
589
  }
525
590
 
526
591
  function handleApplyCompare(): void {
527
- if (draftCompareStart !== null && draftCompareEnd !== null) {
528
- compareStart = draftCompareStart;
529
- compareEnd = draftCompareEnd;
530
- onapplycompare?.({
531
- compareStart: draftCompareStart,
532
- compareEnd: draftCompareEnd,
533
- presetLabel: selectedPresetLabel
534
- });
535
- }
592
+ commitCompare();
536
593
  closeComparePicker();
537
594
  }
538
595
 
539
596
  function handleCancelCompare(): void {
597
+ discardCompare();
540
598
  closeComparePicker();
541
599
  }
542
600
 
@@ -568,18 +626,8 @@
568
626
  presetLabel: selectedPresetLabel
569
627
  });
570
628
  }
571
- if (
572
- typeof compareCalendar === 'function' &&
573
- draftCompareStart !== null &&
574
- draftCompareEnd !== null
575
- ) {
576
- compareStart = draftCompareStart;
577
- compareEnd = draftCompareEnd;
578
- onapplycompare?.({
579
- compareStart: draftCompareStart,
580
- compareEnd: draftCompareEnd,
581
- presetLabel: selectedPresetLabel
582
- });
629
+ if (typeof compareCalendar === 'function') {
630
+ commitCompare();
583
631
  }
584
632
  } else {
585
633
  if (draftValue !== null) {
@@ -601,7 +649,7 @@
601
649
 
602
650
  function handleCancel(): void {
603
651
  oncancel?.();
604
- closePicker();
652
+ abandonPicker();
605
653
  }
606
654
 
607
655
  function handlePreset(preset: DateRangePreset): void {
@@ -893,9 +941,10 @@
893
941
  outside both at once. */
894
942
  function handleDismissOutside(): void {
895
943
  if (isOpen) {
896
- closePicker();
944
+ abandonPicker();
897
945
  }
898
946
  if (openCompare) {
947
+ discardCompare();
899
948
  closeComparePicker();
900
949
  }
901
950
  }
@@ -1053,7 +1102,7 @@
1053
1102
  >
1054
1103
  {#if typeof compareCalendar === 'function'}
1055
1104
  <div class="drp-compare-panel-body">
1056
- {@render compareCalendar()}
1105
+ {@render compareCalendar(compareDraft)}
1057
1106
  </div>
1058
1107
  {/if}
1059
1108
  <div class="drp-footer">
@@ -1066,7 +1115,7 @@
1066
1115
  onclick={handleApplyCompare}
1067
1116
  ariaLabel="Apply compare selection"
1068
1117
  classes="drp-btn-apply"
1069
- disabled={draftCompareStart === null || draftCompareEnd === null}>Apply</Button
1118
+ disabled={!canApplyCompare}>Apply</Button
1070
1119
  >
1071
1120
  </div>
1072
1121
  </div>
@@ -1442,7 +1491,7 @@
1442
1491
  causes a Svelte 5 runtime error. -->
1443
1492
  {#if typeof compareCalendar === 'function' && typeof compareTrigger !== 'function'}
1444
1493
  <div class="drp-compare-section">
1445
- {@render compareCalendar()}
1494
+ {@render compareCalendar(compareDraft)}
1446
1495
  </div>
1447
1496
  {/if}
1448
1497
  </div>
@@ -1,4 +1,12 @@
1
1
  import type { Snippet } from 'svelte';
2
+ /**
3
+ * The compare range a `compareCalendar` snippet edits before Apply commits it. A plain
4
+ * reactive object, so a Calendar can bind to its fields.
5
+ */
6
+ export type DateRangeCompareDraft = {
7
+ start: Date | null;
8
+ end: Date | null;
9
+ };
2
10
  export type DateRangePreset = {
3
11
  label: string;
4
12
  getValue: () => {
@@ -102,8 +110,16 @@ export type OptionalDateRangePickerProperties = {
102
110
  compareStart?: Date | null;
103
111
  /** End of compare range (bindable). Used when compareCalendar snippet is provided. */
104
112
  compareEnd?: Date | null;
105
- /** Snippet rendered in the compare-calendar slot. Consumer controls all compare UI. */
106
- compareCalendar?: Snippet;
113
+ /**
114
+ * Snippet rendered in the compare-calendar slot. Consumer controls all compare UI.
115
+ *
116
+ * It receives the compare draft, a reactive `{ start, end }` that Apply commits and that
117
+ * Cancel, Escape and an outside press discard. Bind a Calendar to it:
118
+ * `bind:rangeStart={draft.start} bind:rangeEnd={draft.end}`. A snippet that takes no
119
+ * argument and edits `compareStart`/`compareEnd` directly still works: Apply keeps what
120
+ * it wrote, and Cancel restores the range the session opened with.
121
+ */
122
+ compareCalendar?: Snippet<[DateRangeCompareDraft]>;
107
123
  /** Which day of the week starts the calendar. 0=Sunday, 1=Monday. */
108
124
  weekStartsOn?: 0 | 1;
109
125
  /** BCP-47 locale string for date formatting. */
@@ -134,13 +134,15 @@
134
134
  }
135
135
  </script>
136
136
 
137
- <!-- svelte-ignore a11y_no_noninteractive_tabindex -->
138
- <!-- svelte-ignore a11y_no_static_element_interactions -->
137
+ <!-- This named content group moves with arrow keys; it can contain independent
138
+ controls, so button semantics would hide their accessibility semantics. -->
139
+ <!-- svelte-ignore a11y_no_noninteractive_tabindex, a11y_no_noninteractive_element_interactions -->
139
140
  <div
140
141
  class="draggable {classes ?? ''}"
141
142
  class:dragging={active !== null}
142
143
  data-pw={typeof testId === 'string' ? testId : null}
143
144
  tabindex={disabled ? -1 : 0}
145
+ role="group"
144
146
  aria-label={dragLabel}
145
147
  style:transform={`translate(${x}px, ${y}px)`}
146
148
  onpointerdown={startDrag}