@juspay/svelte-ui-components 4.51.0 → 4.52.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 (96) hide show
  1. package/dist/Animations/tokenizedFly.js +9 -2
  2. package/dist/AreaChart/AreaChart.svelte +41 -13
  3. package/dist/AreaChart/properties.d.ts +12 -0
  4. package/dist/AttachmentChipRow/AttachmentChipRow.svelte +7 -6
  5. package/dist/BarChart/BarChart.svelte +54 -6
  6. package/dist/BarChart/properties.d.ts +12 -0
  7. package/dist/Calendar/Calendar.svelte +124 -51
  8. package/dist/Card/Card.svelte +5 -0
  9. package/dist/ChatSuggestions/ChatSuggestions.svelte +1 -0
  10. package/dist/CheckListItem/CheckListItem.svelte +27 -5
  11. package/dist/ColorPicker/ColorPicker.svelte +40 -9
  12. package/dist/ColorPicker/properties.d.ts +8 -0
  13. package/dist/Combobox/Combobox.svelte +34 -2
  14. package/dist/CommandMenu/CommandMenu.svelte +35 -8
  15. package/dist/CommandMenu/properties.d.ts +6 -0
  16. package/dist/DateRangePicker/DateRangePicker.svelte +5 -5
  17. package/dist/DualAxisBarChart/DualAxisBarChart.svelte +41 -7
  18. package/dist/DualAxisBarChart/properties.d.ts +4 -0
  19. package/dist/FileDropzoneTrigger/FileDropzoneTrigger.svelte +104 -10
  20. package/dist/FileDropzoneTrigger/properties.d.ts +14 -4
  21. package/dist/FileInput/FileInput.svelte +38 -9
  22. package/dist/FileInput/context.d.ts +20 -0
  23. package/dist/FileInput/context.js +8 -0
  24. package/dist/FileInput/properties.d.ts +25 -1
  25. package/dist/FunnelChart/FunnelChart.svelte +20 -1
  26. package/dist/FunnelChart/properties.d.ts +4 -0
  27. package/dist/Gallery/Gallery.svelte +72 -27
  28. package/dist/HITL/HITL.svelte +64 -14
  29. package/dist/HITL/properties.d.ts +5 -0
  30. package/dist/LineChart/LineChart.svelte +105 -33
  31. package/dist/LineChart/properties.d.ts +12 -0
  32. package/dist/MediaPlayer/MediaPlayer.svelte +172 -2
  33. package/dist/MediaPlayer/MediaPlayer.svelte.d.ts +1 -1
  34. package/dist/MediaPlayer/properties.d.ts +31 -0
  35. package/dist/Menu/Menu.svelte +6 -4
  36. package/dist/Modal/Modal.svelte +15 -12
  37. package/dist/Modal/properties.d.ts +29 -1
  38. package/dist/Pagination/Pagination.svelte +1 -0
  39. package/dist/PieChart/PieChart.svelte +51 -29
  40. package/dist/PieChart/properties.d.ts +4 -0
  41. package/dist/Pill/Pill.svelte +5 -0
  42. package/dist/Radio/Radio.svelte +11 -2
  43. package/dist/Radio/properties.d.ts +7 -2
  44. package/dist/SankeyChart/SankeyChart.svelte +339 -211
  45. package/dist/SankeyChart/properties.d.ts +4 -0
  46. package/dist/Scroller/Scroller.svelte +236 -4
  47. package/dist/Scroller/properties.d.ts +8 -0
  48. package/dist/Select/Select.svelte +46 -8
  49. package/dist/Select/properties.d.ts +17 -1
  50. package/dist/Sheet/Sheet.svelte +31 -4
  51. package/dist/Sheet/properties.d.ts +6 -0
  52. package/dist/SplitInput/SplitInput.svelte +17 -0
  53. package/dist/SplitInput/field-name.d.ts +29 -0
  54. package/dist/SplitInput/field-name.js +50 -0
  55. package/dist/SplitInput/properties.d.ts +18 -4
  56. package/dist/Status/Status.svelte +24 -12
  57. package/dist/Status/properties.d.ts +11 -0
  58. package/dist/Table/BuiltinCell.svelte +13 -5
  59. package/dist/Table/BuiltinCell.svelte.d.ts +1 -0
  60. package/dist/Table/Table.svelte +78 -20
  61. package/dist/Table/a11y.d.ts +7 -0
  62. package/dist/Table/a11y.js +71 -0
  63. package/dist/Table/cellData.js +3 -0
  64. package/dist/Table/properties.d.ts +13 -5
  65. package/dist/ThemeSwitcher/ThemeSwitcher.svelte +4 -0
  66. package/dist/ThinkingIndicator/ThinkingIndicator.svelte +48 -9
  67. package/dist/Toast/Toast.svelte +70 -4
  68. package/dist/Toggle/Toggle.svelte +2 -0
  69. package/dist/Toggle/properties.d.ts +2 -0
  70. package/dist/_chart/Axis.svelte +10 -0
  71. package/dist/_chart/ChartContainer.svelte +32 -1
  72. package/dist/_chart/ChartTooltip.svelte +18 -3
  73. package/dist/_chart/LabelPlate.svelte +116 -0
  74. package/dist/_chart/LabelPlate.svelte.d.ts +7 -0
  75. package/dist/_chart/a11y.d.ts +29 -0
  76. package/dist/_chart/a11y.js +44 -0
  77. package/dist/_chart/measure.d.ts +2 -0
  78. package/dist/_chart/measure.js +4 -0
  79. package/dist/_chart/tooltipPosition.d.ts +6 -0
  80. package/dist/_chart/tooltipPosition.js +30 -2
  81. package/dist/_chart/types.d.ts +22 -0
  82. package/dist/_interaction/focusable.d.ts +47 -0
  83. package/dist/_interaction/focusable.js +139 -0
  84. package/dist/assets/captions-on.svg +3 -0
  85. package/dist/assets/captions.svg +5 -0
  86. package/dist/index.d.ts +1 -1
  87. package/dist/index.js +1 -1
  88. package/dist/styles/theme-dark.css +9 -1
  89. package/dist/types.d.ts +2 -0
  90. package/dist/utils.d.ts +18 -2
  91. package/dist/utils.js +73 -28
  92. package/dist-codemod/codemod/migrate-audit.js +9 -7
  93. package/dist-codemod/codemod/migration-assets/legacy-palette.css +9 -1
  94. package/dist-codemod/codemod/migration-assets-layout.js +61 -0
  95. package/dist-wc/index.js +5872 -4333
  96. package/package.json +4 -2
@@ -11,14 +11,24 @@ export type OptionalFileDropzoneTriggerProperties = {
11
11
  /** Bare icon + heading with no Button wrapper or caption, for inline/compact trigger placements. */
12
12
  compact?: boolean;
13
13
  /**
14
- * Non-compact: forwarded to the inner `Button`. Compact: applied to the heading `<span>`.
15
- * Emits both `data-pw` and `testID` on that element.
14
+ * Non-compact: forwarded to the inner `Button` when this trigger renders one, otherwise applied to the
15
+ * non-interactive surface. Compact: applied to the heading `<span>`. Emits both `data-pw` and `testID`
16
+ * on that element.
16
17
  */
17
18
  testId?: string;
18
- /** CSS class applied to the root element — the inner `Button` in non-compact, the icon+heading wrapper in compact. */
19
+ /** CSS class applied to the root element — the `Button` (or, inside a `FileInput` region, the non-interactive surface that replaces it) in non-compact, the icon+heading wrapper in compact. */
19
20
  classes?: string;
20
21
  };
21
22
  export type FileDropzoneTriggerEventProperties = {
22
- /** Wire to `FileInput`'s `openFilePicker`. Non-compact only — compact relies on `FileInput`'s own whole-area click/drop handling. */
23
+ /**
24
+ * Makes the non-compact trigger a real `Button` that owns the action. Use it where nothing else does:
25
+ * standalone, or under `<FileInput activation="trigger">` with `onclick={openFilePicker}`.
26
+ *
27
+ * Inside a default `FileInput` the region already owns activation (one Tab stop; Enter, Space and a
28
+ * click open the chooser), so the trigger renders the same surface as plain content instead of a
29
+ * second button and does not call this handler: the region opens the picker. An existing
30
+ * `onclick={openFilePicker}` there therefore still works, without the duplicate control.
31
+ * Compact never renders a button.
32
+ */
23
33
  onclick?: () => void;
24
34
  };
@@ -1,9 +1,11 @@
1
1
  <script lang="ts">
2
2
  import { describeField } from '../_field/description';
3
+ import { provideFileInputOwner } from './context';
3
4
  import type { FileInputProperties } from './properties';
4
5
 
5
6
  let {
6
7
  trigger,
8
+ activation = 'region',
7
9
  accept,
8
10
  multiple = false,
9
11
  maxSizeBytes,
@@ -28,6 +30,18 @@
28
30
  describeField(fieldUid, { error: errorMessage, info: infoMessage, invalid })
29
31
  );
30
32
 
33
+ /* One upload action, one interactive owner. Left alone, the region is that
34
+ owner and `trigger` content is plain content inside it. A consumer who wants
35
+ a real control inside (a Button with its own loading state, say) hands
36
+ ownership to it instead, and the region stops being a second control: no
37
+ role, no tab stop, no click or key handling -- only drag and drop. */
38
+ const regionOwnsActivation = $derived(activation !== 'trigger');
39
+ provideFileInputOwner({
40
+ get regionOwnsActivation() {
41
+ return regionOwnsActivation;
42
+ }
43
+ });
44
+
31
45
  let dragOver = $state(false);
32
46
  let inputEl: HTMLInputElement | null = $state(null);
33
47
  let pickerBusy = false;
@@ -149,21 +163,25 @@
149
163
  }
150
164
  </script>
151
165
 
166
+ <!-- The role is a ternary Svelte cannot resolve, so it reads the element as a plain div. Whenever
167
+ the tabindex renders, the role is "button" on the very same expression. -->
168
+ <!-- svelte-ignore a11y_no_noninteractive_tabindex -->
152
169
  <div
153
170
  class="file-input {classes ?? ''}"
154
171
  class:file-input-dragover={dragOver}
155
172
  class:file-input-disabled={disabled}
173
+ class:file-input-passive={!regionOwnsActivation}
156
174
  data-pw={testId}
157
175
  testID={testId}
158
- role="button"
159
- tabindex={disabled ? -1 : 0}
160
- aria-disabled={disabled}
161
- aria-describedby={field.describedBy}
176
+ role={regionOwnsActivation ? 'button' : null}
177
+ tabindex={regionOwnsActivation ? (disabled ? -1 : 0) : null}
178
+ aria-disabled={regionOwnsActivation ? disabled : null}
179
+ aria-describedby={regionOwnsActivation ? field.describedBy : null}
162
180
  ondragover={handleDragOver}
163
181
  ondragleave={handleDragLeave}
164
182
  ondrop={handleDrop}
165
- onkeydown={handleKeyDown}
166
- onclick={handleClick}
183
+ onkeydown={regionOwnsActivation ? handleKeyDown : null}
184
+ onclick={regionOwnsActivation ? handleClick : null}
167
185
  >
168
186
  <input
169
187
  bind:this={inputEl}
@@ -179,7 +197,7 @@
179
197
  aria-hidden="true"
180
198
  />
181
199
 
182
- {@render trigger({ openFilePicker, dragOver, disabled })}
200
+ {@render trigger({ openFilePicker, dragOver, disabled, describedBy: field.describedBy })}
183
201
  </div>
184
202
 
185
203
  {#if field.showsError}
@@ -231,9 +249,20 @@
231
249
  cursor: pointer;
232
250
  }
233
251
 
252
+ /* The region is not the control here, so it must not look like one. */
253
+ .file-input-passive {
254
+ cursor: auto;
255
+ }
256
+
257
+ /* The region is the one Tab stop of its upload action, so it is also the only thing that can
258
+ show where focus is. With no fallback this resolved to `outline: none` -- invisible to a
259
+ keyboard user, and only masked while a nested button supplied its own ring. `currentColor`
260
+ is the library's convention for a ring that needs no colour literal (Card, Pill, StatCard):
261
+ it follows the surrounding text colour, so it clears 3:1 on the light and the dark ground
262
+ alike and stays visible in forced-colors mode. :focus-visible keeps it off pointer clicks. */
234
263
  .file-input:focus-visible {
235
- outline: var(--file-input-focus-outline);
236
- outline-offset: var(--file-input-focus-outline-offset);
264
+ outline: var(--file-input-focus-outline, 2px solid currentColor);
265
+ outline-offset: var(--file-input-focus-outline-offset, 2px);
237
266
  }
238
267
 
239
268
  .file-input-dragover {
@@ -0,0 +1,20 @@
1
+ /**
2
+ * What a `FileInput` tells the content rendered inside its `trigger` snippet.
3
+ *
4
+ * A drop region is one control. By default the region itself is that control
5
+ * (`role="button"`, one Tab stop, Enter/Space/click open the chooser), so
6
+ * anything interactive placed inside it is a second control for the same
7
+ * action. `FileDropzoneTrigger` reads this to render as plain content when the
8
+ * region owns activation, instead of nesting a native `<button>` in it.
9
+ *
10
+ * A getter, not a copied boolean, so a consumer flipping `activation` on a live
11
+ * region is seen by content that is already mounted.
12
+ */
13
+ export type FileInputOwner = {
14
+ readonly regionOwnsActivation: boolean;
15
+ };
16
+ /** Exported only so a test can stand in for the region without mounting a full `FileInput`. */
17
+ export declare const FILE_INPUT_OWNER: unique symbol;
18
+ export declare const provideFileInputOwner: (owner: FileInputOwner) => void;
19
+ /** Null outside a `FileInput`, where nothing owns the action but the caller. */
20
+ export declare const useFileInputOwner: () => FileInputOwner | null;
@@ -0,0 +1,8 @@
1
+ import { getContext, hasContext, setContext } from 'svelte';
2
+ /** Exported only so a test can stand in for the region without mounting a full `FileInput`. */
3
+ export const FILE_INPUT_OWNER = Symbol('sui-file-input-owner');
4
+ export const provideFileInputOwner = (owner) => {
5
+ setContext(FILE_INPUT_OWNER, owner);
6
+ };
7
+ /** Null outside a `FileInput`, where nothing owns the action but the caller. */
8
+ export const useFileInputOwner = () => hasContext(FILE_INPUT_OWNER) ? getContext(FILE_INPUT_OWNER) : null;
@@ -3,10 +3,34 @@ export type FileInputSnippetProps = {
3
3
  openFilePicker: () => void;
4
4
  dragOver: boolean;
5
5
  disabled: boolean;
6
+ /**
7
+ * The ids of the rendered error/info messages, for `aria-describedby`. The drop region links
8
+ * them itself while it owns activation; a control supplied under `activation="trigger"` is the
9
+ * one that must carry them, so it is handed here. Null when nothing is rendered.
10
+ */
11
+ describedBy: string | null;
6
12
  };
13
+ /**
14
+ * Which element is the single interactive control for the upload action.
15
+ *
16
+ * - `'region'` (default): the drop region is the control. It is the one Tab stop, announces as
17
+ * a button named from the `trigger` content, and opens the chooser on Enter, Space or a click
18
+ * anywhere inside it. `trigger` content must therefore be non-interactive.
19
+ * - `'trigger'`: the control inside `trigger` (a `Button`, say) is the one Tab stop and calls
20
+ * `openFilePicker`. The region becomes a passive drag-and-drop target with no role, no tab
21
+ * stop and no click or key handling of its own.
22
+ */
23
+ export type FileInputActivation = 'region' | 'trigger';
7
24
  export type FileInputProperties = {
8
- /** Content slot — receives { openFilePicker, dragOver, disabled } so consumers build any visual they need. */
25
+ /**
26
+ * Content slot — receives { openFilePicker, dragOver, disabled, describedBy } so consumers build
27
+ * any visual they need. Under the default `activation="region"` it must be non-interactive: the
28
+ * region is already the control, and a button inside it is a second Tab stop and a second
29
+ * announcement for the same action.
30
+ */
9
31
  trigger: Snippet<[FileInputSnippetProps]>;
32
+ /** Who owns the single interactive control. Defaults to `'region'`; see {@link FileInputActivation}. */
33
+ activation?: FileInputActivation;
10
34
  accept?: string;
11
35
  multiple?: boolean;
12
36
  maxSizeBytes?: number;
@@ -2,6 +2,7 @@
2
2
  import { onMount } from 'svelte';
3
3
  import type { FunnelChartProperties, FunnelStage } from './properties';
4
4
  import ChartContainer from '../_chart/ChartContainer.svelte';
5
+ import { defaultChartName, resolveChartName } from '../_chart/a11y';
5
6
  import ChartTooltip from '../_chart/ChartTooltip.svelte';
6
7
  import { getColor, getContrastColor } from '../_chart/colors';
7
8
  import { formatNumber, formatPercent } from '../_chart/format';
@@ -25,6 +26,8 @@
25
26
  minHeight = 0,
26
27
  radius = DEFAULT_CHART_CORNER_RADIUS,
27
28
  tooltipPortal = false,
29
+ ariaLabel,
30
+ ariaDescription,
28
31
  testId,
29
32
  classes,
30
33
  empty,
@@ -92,6 +95,16 @@
92
95
  // The documented contract treats an all-zero dataset as empty (it would otherwise
93
96
  // render meaningless min-height bars), so fold that into the empty check.
94
97
  let isEmpty = $derived(data.length === 0 || data.every((stage) => stage.value === 0));
98
+ const chartName = $derived(
99
+ resolveChartName(
100
+ ariaLabel,
101
+ defaultChartName('funnel', {
102
+ seriesNames: data.map((stage) => stage.category)
103
+ })
104
+ )
105
+ );
106
+ // Zero-valued stages still paint structural bars and retain their callbacks.
107
+ const hasMarks = $derived(data.length > 0);
95
108
 
96
109
  /**
97
110
  * Computes the total horizontal space consumed by slope connectors.
@@ -330,6 +343,9 @@
330
343
  {aspectRatio}
331
344
  {maxHeight}
332
345
  {minHeight}
346
+ ariaLabel={chartName}
347
+ {ariaDescription}
348
+ interactive={hasMarks}
333
349
  >
334
350
  <g transform="translate({MARGIN_LEFT}, {MARGIN_TOP})">
335
351
  <!-- Stage bars and category labels -->
@@ -344,6 +360,7 @@
344
360
  <!-- Category label above the bar -->
345
361
  <text
346
362
  class="funnel-category-label"
363
+ aria-hidden="true"
347
364
  x={labelX}
348
365
  y={labelAreaHeight - 6}
349
366
  text-anchor="middle"
@@ -371,8 +388,9 @@
371
388
  onblur={handleLeave}
372
389
  onkeydown={(e) => handleKeydown(e, index)}
373
390
  onclick={() => handleClick(index)}
374
- tabindex="0"
391
+ tabindex={hasMarks ? 0 : null}
375
392
  role="button"
393
+ aria-hidden={hasMarks ? null : 'true'}
376
394
  />
377
395
 
378
396
  <!-- Value label centred inside the bar -->
@@ -381,6 +399,7 @@
381
399
  {#if vl !== ''}
382
400
  <text
383
401
  class="funnel-value-label"
402
+ aria-hidden="true"
384
403
  x={labelX}
385
404
  y={by + bh / 2}
386
405
  text-anchor="middle"
@@ -72,6 +72,10 @@ export type OptionalFunnelChartProperties = {
72
72
  */
73
73
  tooltipPortal?: boolean;
74
74
  /** Value for the `data-pw` attribute on the chart root element. */
75
+ /** Accessible name of the drawing; omit to derive one from the plotted data. */
76
+ ariaLabel?: string;
77
+ /** Longer chart description, resolved inside the drawing's own DOM/shadow root. */
78
+ ariaDescription?: string;
75
79
  testId?: string;
76
80
  /** CSS class string applied to the chart root element. Useful for CSS-variable theming. */
77
81
  classes?: string;
@@ -283,27 +283,29 @@
283
283
  </div>
284
284
  {/if}
285
285
  {#if showItemActions}
286
- <div class="gallery-item-actions">
287
- {#if showEditButton}
288
- <div class="gallery-item-action">
289
- <Button
290
- ariaLabel={`Edit image ${index + 1}: ${image.alt}`}
291
- onclick={(event) => handleEditClick(index, event)}
292
- >
293
- {@render controlIcon(editSvg, editIcon)}
294
- </Button>
295
- </div>
296
- {/if}
297
- {#if showDeleteButton}
298
- <div class="gallery-item-action">
299
- <Button
300
- ariaLabel={`Delete image ${index + 1}: ${image.alt}`}
301
- onclick={(event) => handleDeleteClick(index, event)}
302
- >
303
- {@render controlIcon(deleteSvg, deleteIcon)}
304
- </Button>
305
- </div>
306
- {/if}
286
+ <div class="gallery-item-actions-frame">
287
+ <div class="gallery-item-actions">
288
+ {#if showEditButton}
289
+ <div class="gallery-item-action">
290
+ <Button
291
+ ariaLabel={`Edit image ${index + 1}: ${image.alt}`}
292
+ onclick={(event) => handleEditClick(index, event)}
293
+ >
294
+ {@render controlIcon(editSvg, editIcon)}
295
+ </Button>
296
+ </div>
297
+ {/if}
298
+ {#if showDeleteButton}
299
+ <div class="gallery-item-action">
300
+ <Button
301
+ ariaLabel={`Delete image ${index + 1}: ${image.alt}`}
302
+ onclick={(event) => handleDeleteClick(index, event)}
303
+ >
304
+ {@render controlIcon(deleteSvg, deleteIcon)}
305
+ </Button>
306
+ </div>
307
+ {/if}
308
+ </div>
307
309
  </div>
308
310
  {/if}
309
311
  </div>
@@ -514,16 +516,43 @@
514
516
  font-family: var(--gallery-list-caption-font-family, inherit);
515
517
  }
516
518
 
519
+ /* The --_actions-* / --_action-* values are internal fallbacks that only the narrow-tile rule at the
520
+ end of this block changes. They sit BELOW the public --gallery-item-action* variables, so any value
521
+ a consumer sets still wins at every tile width. */
517
522
  .gallery-item-actions {
518
523
  display: flex;
519
524
  align-items: center;
520
- gap: var(--gallery-item-actions-gap, 4px);
525
+ gap: var(--gallery-item-actions-gap, var(--_actions-gap, 4px));
526
+ }
527
+
528
+ /* The frame exists so the narrow-tile rule below has a container to ask. It cannot be the tile itself:
529
+ inline-size containment makes a box report no intrinsic width, and a tile that reports none leaves a
530
+ grid inside an ancestor that sizes to its content (inline-block, fit-content, an auto-width absolute
531
+ box) with nothing to measure, so the whole gallery collapsed to its gaps. An out-of-flow box adds
532
+ nothing to its parent's intrinsic size, so the frame can be contained and the tile sizes as before.
533
+ It covers the tile exactly and lets pointer events through to the image underneath; in list view it
534
+ takes no box at all. Named so the rule can only match these frames, never a container in the
535
+ consumer's own page. */
536
+ .gallery-item-actions-frame {
537
+ display: contents;
538
+ }
539
+
540
+ .grid .gallery-item-actions-frame {
541
+ display: block;
542
+ position: absolute;
543
+ top: 0;
544
+ right: 0;
545
+ bottom: 0;
546
+ left: 0;
547
+ pointer-events: none;
548
+ container: gallery-tile / inline-size;
521
549
  }
522
550
 
523
551
  .grid .gallery-item-actions {
524
552
  position: absolute;
525
- top: var(--gallery-item-actions-top, 8px);
526
- right: var(--gallery-item-actions-right, 8px);
553
+ pointer-events: auto;
554
+ top: var(--gallery-item-actions-top, var(--_actions-inset, 8px));
555
+ right: var(--gallery-item-actions-right, var(--_actions-inset, 8px));
527
556
  }
528
557
 
529
558
  .list .gallery-item-actions {
@@ -535,12 +564,28 @@
535
564
  border-radius: var(--gallery-item-action-border-radius, 8px);
536
565
  --button-color: transparent;
537
566
  --button-hover-color: transparent;
538
- --button-padding: var(--gallery-item-action-padding, 6px);
567
+ --button-padding: var(--gallery-item-action-padding, var(--_action-padding, 6px));
539
568
  --button-border-radius: var(--gallery-item-action-border-radius, 8px);
540
569
  --icon-container-padding: 0px;
541
570
  --icon-padding: 0px;
542
- --icon-width: var(--gallery-item-action-icon-size, 16px);
543
- --icon-height: var(--gallery-item-action-icon-size, 16px);
571
+ --icon-width: var(--gallery-item-action-icon-size, var(--_action-icon, 16px));
572
+ --icon-height: var(--gallery-item-action-icon-size, var(--_action-icon, 16px));
573
+ }
574
+
575
+ /* Two default-size actions need 2 x 36px + a 4px gap + both 8px insets = 92px of tile width. A tile
576
+ narrower than that (a three-column grid on a 320px phone is about 79px) pushed the left button past
577
+ the tile's overflow:hidden edge. Below 96px the defaults shrink instead: 26px buttons (still above the
578
+ 24px WCAG 2.5.8 target minimum), 2px gap, 4px insets. */
579
+ @container gallery-tile (max-width: 96px) {
580
+ .gallery-item-actions {
581
+ --_actions-gap: 2px;
582
+ --_actions-inset: 4px;
583
+ }
584
+
585
+ .gallery-item-action {
586
+ --_action-padding: 3px;
587
+ --_action-icon: 12px;
588
+ }
544
589
  }
545
590
 
546
591
  .grid .gallery-item-action {
@@ -1,5 +1,5 @@
1
1
  <script lang="ts">
2
- import { onMount, onDestroy } from 'svelte';
2
+ import { onMount, onDestroy, tick } from 'svelte';
3
3
  import { get } from 'svelte/store';
4
4
  import Button from '../Button/Button.svelte';
5
5
  import Card from '../Card/Card.svelte';
@@ -43,6 +43,7 @@
43
43
  showConfirm = true,
44
44
  showEmptyParameters = true,
45
45
  countdownSeconds = 10,
46
+ expiresAt,
46
47
  autoCancelSeconds = 0,
47
48
  isMicMuted = false,
48
49
  // The resolver this replaced defaulted to null, and the call sites test for
@@ -88,9 +89,14 @@
88
89
  // Seeded from the prop; startCountdown re-seeds it on every (re)start.
89
90
  // svelte-ignore state_referenced_locally
90
91
  let timeRemaining = $state(countdownSeconds);
92
+ // svelte-ignore state_referenced_locally
93
+ let countdownDuration = $state(countdownSeconds);
94
+ let relativeDeadline: number | null = null;
95
+ let countdownDeadline: number | null = null;
91
96
  let countdownInterval: ReturnType<typeof setInterval> | null = null;
92
97
  let autoCancelTimeout: ReturnType<typeof setTimeout> | null = null;
93
98
  let originalMicState: boolean | null = null;
99
+ let initialMicToggle: Promise<void> | null = null;
94
100
 
95
101
  // Expiry from the caller takes precedence, including during a live request.
96
102
  // History mode renders the settled state from props; live mode from local state.
@@ -111,7 +117,7 @@
111
117
  });
112
118
  const isCompleted = $derived(userResponse !== null);
113
119
  const isApproved = $derived(userResponse === 'approved' || userResponse === 'auto-approved');
114
- const elapsedTime = $derived(countdownSeconds - timeRemaining);
120
+ const elapsedTime = $derived(Math.max(0, countdownDuration - timeRemaining));
115
121
  const approvalBlocked = (action: HITLAction): boolean => action !== 'rejected' && confirmDisabled;
116
122
 
117
123
  const completionText = $derived(
@@ -140,6 +146,13 @@
140
146
  };
141
147
 
142
148
  const restoreMicState = async (): Promise<void> => {
149
+ // An elapsed deadline may decide in the same mount that starts auto-mute.
150
+ // Finish that call and flush its controlled prop before restoring it.
151
+ if (initialMicToggle !== null) {
152
+ await initialMicToggle;
153
+ await tick();
154
+ initialMicToggle = null;
155
+ }
143
156
  // Consume restoration before awaiting: a failed decision may be retried
144
157
  // before the caller has updated isMicMuted, but must not toggle twice.
145
158
  const stateToRestore = originalMicState;
@@ -214,19 +227,41 @@
214
227
  return;
215
228
  }
216
229
  countdownActive = true;
217
- timeRemaining = countdownSeconds;
218
- countdownInterval = setInterval(() => {
230
+ const now = Date.now();
231
+ // Relative duration is anchored once; rerenders must not extend it. An
232
+ // explicit absolute expiry remains live while this timer is running.
233
+ relativeDeadline ??= now + countdownSeconds * 1000;
234
+ const updateCountdown = (): void => {
219
235
  // A sibling card's interaction pauses this countdown on its next tick.
220
- if (confirmDisabled || get(pauseAllConfirmationTimers)) {
236
+ if (
237
+ countdownSeconds <= 0 ||
238
+ confirmDisabled ||
239
+ isHistoryMode ||
240
+ isCompleted ||
241
+ get(pauseAllConfirmationTimers)
242
+ ) {
221
243
  stopCountdown();
222
244
  return;
223
245
  }
224
- timeRemaining = timeRemaining - 0.1;
246
+ const currentTime = Date.now();
247
+ const nextDeadline =
248
+ typeof expiresAt === 'number' && Number.isFinite(expiresAt)
249
+ ? expiresAt
250
+ : (relativeDeadline ?? now);
251
+ if (nextDeadline !== countdownDeadline) {
252
+ countdownDeadline = nextDeadline;
253
+ countdownDuration = Math.max(0, (nextDeadline - currentTime) / 1000);
254
+ }
255
+ timeRemaining = Math.max(0, (nextDeadline - currentTime) / 1000);
225
256
  if (timeRemaining <= 0) {
226
257
  stopCountdown();
227
258
  void decide('auto-approved');
228
259
  }
229
- }, 100);
260
+ };
261
+ updateCountdown();
262
+ if (countdownActive) {
263
+ countdownInterval = setInterval(updateCountdown, 100);
264
+ }
230
265
  };
231
266
 
232
267
  // Any interaction pauses this card's own countdown and every sibling's.
@@ -345,9 +380,15 @@
345
380
  if (onmictoggle !== null) {
346
381
  originalMicState = isMicMuted;
347
382
  if (!isMicMuted) {
348
- void Promise.resolve(onmictoggle()).catch(() => {
349
- // Auto-mute is best-effort.
350
- });
383
+ try {
384
+ // Preserve the existing synchronous mount invocation. Only the
385
+ // decision waits for its result and the controlled prop update.
386
+ initialMicToggle = Promise.resolve(onmictoggle()).catch(() => {
387
+ // Auto-mute is best-effort; settlement still follows a failed call.
388
+ });
389
+ } catch {
390
+ initialMicToggle = Promise.resolve();
391
+ }
351
392
  }
352
393
  }
353
394
  pauseAllConfirmationTimers.set(false);
@@ -679,7 +720,7 @@
679
720
  <div class="confirm-button">
680
721
  {#if countdownActive && !confirmDisabled}
681
722
  <div class="progress-anchor">
682
- <Progress value={elapsedTime} max={countdownSeconds} />
723
+ <Progress value={elapsedTime} max={countdownDuration} />
683
724
  </div>
684
725
  {/if}
685
726
  <Button
@@ -833,16 +874,25 @@
833
874
  --input-container-width: 100%;
834
875
  }
835
876
 
877
+ /* Wraps so a narrow card (a phone, a chat column) puts the buttons that do not
878
+ fit on their own line instead of running past the card. Each wrapper is
879
+ `flex: 1`, and its automatic minimum is its button's text width, so a row
880
+ that fits is laid out exactly as before; one that does not breaks between
881
+ buttons and the ones on a short line grow to fill it. `.hitl` is
882
+ `contain: layout`, so an overflowing button here would be invisible to the
883
+ page's own scrollWidth and unreachable by scrolling. The wrapping itself comes from the
884
+ zero-specificity rule below, so --hitl-buttons-wrap: nowrap still turns it off. */
836
885
  .action-buttons {
837
886
  display: flex;
838
887
  gap: var(--hitl-buttons-gap, 0.75rem);
839
888
  width: 100%;
840
889
  }
841
890
 
842
- /* Zero specificity, so a consumer's own flex-wrap rule for the row keeps winning, as it
843
- did before this hook existed. */
891
+ /* Zero specificity, so a consumer's own flex-wrap rule for the row keeps winning. The default
892
+ wraps, because a row that does not fit otherwise runs past the card; a row that fits is laid
893
+ out exactly as before. Set --hitl-buttons-wrap: nowrap to keep the single line. */
844
894
  :where(.action-buttons) {
845
- flex-wrap: var(--hitl-buttons-wrap, nowrap);
895
+ flex-wrap: var(--hitl-buttons-wrap, wrap);
846
896
  }
847
897
 
848
898
  .cancel-button,
@@ -144,6 +144,11 @@ export type OptionalHITLProperties = {
144
144
  * button. `0` disables auto-approval.
145
145
  */
146
146
  countdownSeconds?: number;
147
+ /**
148
+ * Absolute server expiry in Unix milliseconds. Updates affect a running
149
+ * countdown; paused or settled cards never restart. Zero keeps manual-only approval.
150
+ */
151
+ expiresAt?: number;
147
152
  /**
148
153
  * Seconds until an untouched card auto-rejects — for confirmations that must
149
154
  * not auto-approve (e.g. OAuth) but should not block a conversation forever.