@juspay/svelte-ui-components 4.50.0 → 4.52.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 (95) 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 +12 -3
  29. package/dist/LineChart/LineChart.svelte +105 -33
  30. package/dist/LineChart/properties.d.ts +12 -0
  31. package/dist/MediaPlayer/MediaPlayer.svelte +172 -2
  32. package/dist/MediaPlayer/MediaPlayer.svelte.d.ts +1 -1
  33. package/dist/MediaPlayer/properties.d.ts +31 -0
  34. package/dist/Menu/Menu.svelte +6 -4
  35. package/dist/Modal/Modal.svelte +15 -12
  36. package/dist/Modal/properties.d.ts +29 -1
  37. package/dist/Pagination/Pagination.svelte +1 -0
  38. package/dist/PieChart/PieChart.svelte +51 -29
  39. package/dist/PieChart/properties.d.ts +4 -0
  40. package/dist/Pill/Pill.svelte +5 -0
  41. package/dist/Radio/Radio.svelte +11 -2
  42. package/dist/Radio/properties.d.ts +7 -2
  43. package/dist/SankeyChart/SankeyChart.svelte +339 -211
  44. package/dist/SankeyChart/properties.d.ts +4 -0
  45. package/dist/Scroller/Scroller.svelte +247 -4
  46. package/dist/Scroller/properties.d.ts +8 -0
  47. package/dist/Select/Select.svelte +43 -5
  48. package/dist/Select/properties.d.ts +17 -1
  49. package/dist/Sheet/Sheet.svelte +31 -4
  50. package/dist/Sheet/properties.d.ts +6 -0
  51. package/dist/SplitInput/SplitInput.svelte +17 -0
  52. package/dist/SplitInput/field-name.d.ts +29 -0
  53. package/dist/SplitInput/field-name.js +50 -0
  54. package/dist/SplitInput/properties.d.ts +18 -4
  55. package/dist/Status/Status.svelte +24 -12
  56. package/dist/Status/properties.d.ts +11 -0
  57. package/dist/Table/BuiltinCell.svelte +13 -5
  58. package/dist/Table/BuiltinCell.svelte.d.ts +1 -0
  59. package/dist/Table/Table.svelte +78 -20
  60. package/dist/Table/a11y.d.ts +7 -0
  61. package/dist/Table/a11y.js +71 -0
  62. package/dist/Table/cellData.js +3 -0
  63. package/dist/Table/properties.d.ts +13 -5
  64. package/dist/ThemeSwitcher/ThemeSwitcher.svelte +4 -0
  65. package/dist/ThinkingIndicator/ThinkingIndicator.svelte +48 -9
  66. package/dist/Toast/Toast.svelte +40 -4
  67. package/dist/Toggle/Toggle.svelte +2 -0
  68. package/dist/Toggle/properties.d.ts +2 -0
  69. package/dist/_chart/Axis.svelte +10 -0
  70. package/dist/_chart/ChartContainer.svelte +32 -1
  71. package/dist/_chart/ChartTooltip.svelte +18 -3
  72. package/dist/_chart/LabelPlate.svelte +114 -0
  73. package/dist/_chart/LabelPlate.svelte.d.ts +7 -0
  74. package/dist/_chart/a11y.d.ts +29 -0
  75. package/dist/_chart/a11y.js +44 -0
  76. package/dist/_chart/measure.d.ts +2 -0
  77. package/dist/_chart/measure.js +4 -0
  78. package/dist/_chart/tooltipPosition.d.ts +6 -0
  79. package/dist/_chart/tooltipPosition.js +30 -2
  80. package/dist/_chart/types.d.ts +22 -0
  81. package/dist/_interaction/focusable.d.ts +47 -0
  82. package/dist/_interaction/focusable.js +139 -0
  83. package/dist/assets/captions-on.svg +3 -0
  84. package/dist/assets/captions.svg +5 -0
  85. package/dist/index.d.ts +1 -1
  86. package/dist/index.js +1 -1
  87. package/dist/styles/theme-dark.css +6 -1
  88. package/dist/types.d.ts +2 -0
  89. package/dist/utils.d.ts +18 -2
  90. package/dist/utils.js +73 -28
  91. package/dist-codemod/codemod/migrate-audit.js +9 -7
  92. package/dist-codemod/codemod/migration-assets/legacy-palette.css +9 -1
  93. package/dist-codemod/codemod/migration-assets-layout.js +61 -0
  94. package/dist-wc/index.js +5653 -4148
  95. 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 {
@@ -833,16 +833,25 @@
833
833
  --input-container-width: 100%;
834
834
  }
835
835
 
836
+ /* Wraps so a narrow card (a phone, a chat column) puts the buttons that do not
837
+ fit on their own line instead of running past the card. Each wrapper is
838
+ `flex: 1`, and its automatic minimum is its button's text width, so a row
839
+ that fits is laid out exactly as before; one that does not breaks between
840
+ buttons and the ones on a short line grow to fill it. `.hitl` is
841
+ `contain: layout`, so an overflowing button here would be invisible to the
842
+ page's own scrollWidth and unreachable by scrolling. The wrapping itself comes from the
843
+ zero-specificity rule below, so --hitl-buttons-wrap: nowrap still turns it off. */
836
844
  .action-buttons {
837
845
  display: flex;
838
846
  gap: var(--hitl-buttons-gap, 0.75rem);
839
847
  width: 100%;
840
848
  }
841
849
 
842
- /* Zero specificity, so a consumer's own flex-wrap rule for the row keeps winning, as it
843
- did before this hook existed. */
850
+ /* Zero specificity, so a consumer's own flex-wrap rule for the row keeps winning. The default
851
+ wraps, because a row that does not fit otherwise runs past the card; a row that fits is laid
852
+ out exactly as before. Set --hitl-buttons-wrap: nowrap to keep the single line. */
844
853
  :where(.action-buttons) {
845
- flex-wrap: var(--hitl-buttons-wrap, nowrap);
854
+ flex-wrap: var(--hitl-buttons-wrap, wrap);
846
855
  }
847
856
 
848
857
  .cancel-button,