@sveltia/ui 0.72.1 → 0.73.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 (90) hide show
  1. package/dist/components/alert/alert.svelte +29 -5
  2. package/dist/components/alert/alert.svelte.d.ts +8 -4
  3. package/dist/components/alert/infobar.svelte +21 -5
  4. package/dist/components/alert/infobar.svelte.d.ts +10 -4
  5. package/dist/components/button/button.svelte +5 -1
  6. package/dist/components/button/select-button-group.svelte +1 -1
  7. package/dist/components/calendar/calendar.d.ts +1 -4
  8. package/dist/components/calendar/calendar.js +17 -0
  9. package/dist/components/calendar/calendar.svelte +67 -11
  10. package/dist/components/checkbox/checkbox-group.svelte +3 -1
  11. package/dist/components/checkbox/checkbox.svelte +3 -1
  12. package/dist/components/dialog/dialog.svelte +14 -2
  13. package/dist/components/dialog/prompt-dialog.svelte +9 -2
  14. package/dist/components/dialog/prompt-dialog.svelte.d.ts +4 -2
  15. package/dist/components/disclosure/disclosure.svelte +2 -1
  16. package/dist/components/drawer/drawer.svelte +28 -3
  17. package/dist/components/drawer/drawer.svelte.d.ts +16 -2
  18. package/dist/components/grid/grid-body.svelte +3 -1
  19. package/dist/components/grid/grid-foot.svelte +3 -1
  20. package/dist/components/grid/grid-head.svelte +3 -1
  21. package/dist/components/listbox/option-group.svelte +2 -1
  22. package/dist/components/listbox/option.svelte +4 -0
  23. package/dist/components/menu/menu-item-group.svelte +3 -1
  24. package/dist/components/radio/radio.svelte +21 -3
  25. package/dist/components/radio/radio.svelte.d.ts +12 -2
  26. package/dist/components/resizable-pane/resizable-handle.svelte +12 -0
  27. package/dist/components/select/combobox.svelte +69 -1
  28. package/dist/components/select/select-tags.svelte +34 -23
  29. package/dist/components/slider/slider.d.ts +6 -0
  30. package/dist/components/slider/slider.js +40 -0
  31. package/dist/components/slider/slider.svelte +45 -11
  32. package/dist/components/slider/slider.svelte.d.ts +14 -2
  33. package/dist/components/switch/switch.svelte +1 -1
  34. package/dist/components/table/table-body.svelte +3 -1
  35. package/dist/components/table/table-foot.svelte +3 -1
  36. package/dist/components/table/table-head.svelte +3 -1
  37. package/dist/components/text-editor/code-editor.svelte +22 -1
  38. package/dist/components/text-editor/code-editor.svelte.d.ts +28 -0
  39. package/dist/components/text-editor/shiki/version.d.ts +1 -1
  40. package/dist/components/text-editor/shiki/version.js +1 -1
  41. package/dist/components/text-editor/text-editor.svelte +23 -0
  42. package/dist/components/text-editor/text-editor.svelte.d.ts +28 -0
  43. package/dist/components/text-field/number-input.svelte +7 -17
  44. package/dist/components/text-field/search-bar.svelte +1 -0
  45. package/dist/components/text-field/text-input.svelte +1 -1
  46. package/dist/components/toast/toast.svelte +58 -4
  47. package/dist/components/toast/toast.svelte.d.ts +4 -2
  48. package/dist/components/tree/tree-item.svelte +3 -2
  49. package/dist/components/util/app-shell.svelte +290 -5
  50. package/dist/components/util/popup.svelte +1 -1
  51. package/dist/locales/ar.yaml +20 -0
  52. package/dist/locales/bg.yaml +20 -0
  53. package/dist/locales/ca.yaml +20 -0
  54. package/dist/locales/cs.yaml +20 -0
  55. package/dist/locales/da.yaml +20 -0
  56. package/dist/locales/de.yaml +20 -0
  57. package/dist/locales/el.yaml +20 -0
  58. package/dist/locales/en-CA.yaml +20 -0
  59. package/dist/locales/en-GB.yaml +20 -0
  60. package/dist/locales/en-US.yaml +20 -0
  61. package/dist/locales/es-CO.yaml +20 -0
  62. package/dist/locales/fa.yaml +20 -0
  63. package/dist/locales/fi.yaml +20 -0
  64. package/dist/locales/fr.yaml +20 -0
  65. package/dist/locales/hr.yaml +20 -0
  66. package/dist/locales/it.yaml +20 -0
  67. package/dist/locales/ja.yaml +20 -0
  68. package/dist/locales/ko.yaml +20 -0
  69. package/dist/locales/nl.yaml +20 -0
  70. package/dist/locales/pl.yaml +20 -0
  71. package/dist/locales/pt-BR.yaml +20 -0
  72. package/dist/locales/pt-PT.yaml +20 -0
  73. package/dist/locales/ru.yaml +20 -0
  74. package/dist/locales/sv.yaml +20 -0
  75. package/dist/locales/tr.yaml +20 -0
  76. package/dist/locales/uk.yaml +20 -0
  77. package/dist/locales/vi.yaml +20 -0
  78. package/dist/locales/zh-CN.yaml +20 -0
  79. package/dist/locales/zh-TW.yaml +20 -0
  80. package/dist/services/group.svelte.js +60 -0
  81. package/dist/services/popup.svelte.js +24 -1
  82. package/dist/services/select.svelte.d.ts +5 -0
  83. package/dist/services/select.svelte.js +8 -0
  84. package/dist/services/type-ahead.d.ts +26 -0
  85. package/dist/services/type-ahead.js +81 -0
  86. package/dist/styles/core.scss +14 -3
  87. package/dist/styles/variables.scss +119 -1
  88. package/dist/typedefs.d.ts +7 -1
  89. package/dist/typedefs.js +4 -1
  90. package/package.json +7 -7
@@ -5,6 +5,8 @@
5
5
  @see https://w3c.github.io/aria/#rowgroup
6
6
  -->
7
7
  <script>
8
+ import { _ } from '@sveltia/i18n';
9
+
8
10
  /**
9
11
  * @import { Snippet } from 'svelte';
10
12
  */
@@ -31,7 +33,7 @@
31
33
  {...restProps}
32
34
  role="rowgroup"
33
35
  class="sui grid-foot {className}"
34
- aria-roledescription="grid foot"
36
+ aria-roledescription={_('_sui.role_descriptions.grid_foot')}
35
37
  >
36
38
  {@render children?.()}
37
39
  </div>
@@ -5,6 +5,8 @@
5
5
  @see https://w3c.github.io/aria/#rowgroup
6
6
  -->
7
7
  <script>
8
+ import { _ } from '@sveltia/i18n';
9
+
8
10
  /**
9
11
  * @import { Snippet } from 'svelte';
10
12
  */
@@ -31,7 +33,7 @@
31
33
  {...restProps}
32
34
  role="rowgroup"
33
35
  class="sui grid-head {className}"
34
- aria-roledescription="grid head"
36
+ aria-roledescription={_('_sui.role_descriptions.grid_head')}
35
37
  >
36
38
  {@render children?.()}
37
39
  </div>
@@ -7,6 +7,7 @@
7
7
  @see https://www.w3.org/WAI/ARIA/apg/patterns/listbox/examples/listbox-grouped/
8
8
  -->
9
9
  <script>
10
+ import { _ } from '@sveltia/i18n';
10
11
  import TruncatedText from '../typography/truncated-text.svelte';
11
12
 
12
13
  /**
@@ -50,7 +51,7 @@
50
51
  aria-hidden={hidden}
51
52
  aria-disabled={disabled}
52
53
  aria-labelledby="{id}-label"
53
- aria-roledescription="option group"
54
+ aria-roledescription={_('_sui.role_descriptions.option_group')}
54
55
  >
55
56
  <div role="none" id="{id}-label" class="label">
56
57
  <TruncatedText>
@@ -202,6 +202,10 @@
202
202
  color: var(--sui-highlight-foreground-color);
203
203
  background-color: var(--sui-hover-background-color);
204
204
  }
205
+ .option :global(.focused) {
206
+ outline-color: var(--sui-focus-ring-color);
207
+ outline-offset: calc(var(--sui-focus-ring-width) * -1);
208
+ }
205
209
  .option :global(.icon.check) {
206
210
  margin: -2px;
207
211
  }</style>
@@ -3,6 +3,8 @@
3
3
  A menu item group.
4
4
  -->
5
5
  <script>
6
+ import { _ } from '@sveltia/i18n';
7
+
6
8
  /**
7
9
  * @import { CommonEventHandlers } from '../../typedefs';
8
10
  */
@@ -43,7 +45,7 @@
43
45
  aria-hidden={hidden}
44
46
  aria-disabled={disabled}
45
47
  aria-labelledby={title ? `${id}-title` : undefined}
46
- aria-roledescription="menu item group"
48
+ aria-roledescription={_('_sui.role_descriptions.menu_item_group')}
47
49
  >
48
50
  {#if title}
49
51
  <div role="none" class="title" id="{id}-title">{title}</div>
@@ -24,7 +24,9 @@
24
24
  * @property {any} [value] The `data-value` attribute on the `<button>` element.
25
25
  * @property {string} [valueType] Data type of the `value`. Typically `string`, `number` or
26
26
  * `boolean`. Default: auto detect.
27
- * @property {string} [label] Text label displayed next to the checkbox.
27
+ * @property {string} [label] Text label displayed next to the radio button.
28
+ * @property {string} [ariaLabel] `aria-label` attribute on the `<button>` element, for a radio
29
+ * button without a visible label.
28
30
  * @property {string} [group] The two-way bound variable to manage the state of a group of radio
29
31
  * buttons. It works in the same way as the [`<input
30
32
  * bind:group>`](https://svelte.dev/docs/svelte/bind#input-bind:group) of Svelte.
@@ -47,6 +49,7 @@
47
49
  value = undefined,
48
50
  valueType = undefined,
49
51
  label = undefined,
52
+ ariaLabel = undefined,
50
53
  group = $bindable(),
51
54
  children,
52
55
  onChange,
@@ -63,6 +66,14 @@
63
66
  */
64
67
  let buttonElement = $state();
65
68
 
69
+ /**
70
+ * Accessible name given as a prop or as a plain `aria-label` attribute. It takes the place of the
71
+ * visible label, which the button otherwise points at — but only when there is one to point at.
72
+ * @type {string | undefined}
73
+ */
74
+ const accessibleLabel = $derived(ariaLabel ?? restProps['aria-label']);
75
+ const hasVisibleLabel = $derived(!!(children || label));
76
+
66
77
  // Sync `checked` with `group` and `value`
67
78
  $effect(() => {
68
79
  if (typeof group === 'string') {
@@ -77,8 +88,11 @@
77
88
  });
78
89
  </script>
79
90
 
91
+ <!--
92
+ `restProps`, including any `aria-*` attribute, go on the `<button>`: ARIA ignores them on the
93
+ presentational wrapper.
94
+ -->
80
95
  <span
81
- {...restProps}
82
96
  role="none"
83
97
  class="sui radio {className}"
84
98
  class:disabled
@@ -90,6 +104,7 @@
90
104
  }}
91
105
  >
92
106
  <Button
107
+ {...restProps}
93
108
  bind:element={buttonElement}
94
109
  role="radio"
95
110
  {id}
@@ -99,7 +114,8 @@
99
114
  {value}
100
115
  {valueType}
101
116
  aria-checked={checked}
102
- aria-labelledby="{id}-label"
117
+ aria-label={accessibleLabel || undefined}
118
+ aria-labelledby={!accessibleLabel && hasVisibleLabel ? `${id}-label` : undefined}
103
119
  onclick={(event) => {
104
120
  event.preventDefault();
105
121
 
@@ -132,6 +148,8 @@
132
148
  align-items: center;
133
149
  gap: 8px;
134
150
  margin: var(--sui-focus-ring-width);
151
+ min-width: 24px;
152
+ min-height: 24px;
135
153
  color: var(--sui-control-foreground-color);
136
154
  font-family: var(--sui-control-font-family);
137
155
  font-size: var(--sui-control-font-size);
@@ -42,9 +42,14 @@ declare const Radio: import("svelte").Component<{
42
42
  */
43
43
  valueType?: string | undefined;
44
44
  /**
45
- * Text label displayed next to the checkbox.
45
+ * Text label displayed next to the radio button.
46
46
  */
47
47
  label?: string | undefined;
48
+ /**
49
+ * `aria-label` attribute on the `<button>` element, for a radio
50
+ * button without a visible label.
51
+ */
52
+ ariaLabel?: string | undefined;
48
53
  /**
49
54
  * The two-way bound variable to manage the state of a group of radio
50
55
  * buttons. It works in the same way as the [`<input
@@ -101,9 +106,14 @@ type Props = {
101
106
  */
102
107
  valueType?: string | undefined;
103
108
  /**
104
- * Text label displayed next to the checkbox.
109
+ * Text label displayed next to the radio button.
105
110
  */
106
111
  label?: string | undefined;
112
+ /**
113
+ * `aria-label` attribute on the `<button>` element, for a radio
114
+ * button without a visible label.
115
+ */
116
+ ariaLabel?: string | undefined;
107
117
  /**
108
118
  * The two-way bound variable to manage the state of a group of radio
109
119
  * buttons. It works in the same way as the [`<input
@@ -284,11 +284,20 @@
284
284
  pointer-events: none;
285
285
  opacity: 0.4;
286
286
  }
287
+ .resizable-handle::before {
288
+ content: "";
289
+ position: absolute;
290
+ inset: 0;
291
+ z-index: 1;
292
+ }
287
293
  .resizable-handle.horizontal {
288
294
  width: var(--sui-resizable-handle-size, 4px);
289
295
  height: 100%;
290
296
  cursor: col-resize;
291
297
  }
298
+ .resizable-handle.horizontal::before {
299
+ inset-inline: calc((var(--sui-resizable-handle-size, 4px) - 24px) / 2);
300
+ }
292
301
  .resizable-handle.horizontal .handle-bar {
293
302
  width: 2px;
294
303
  height: 40%;
@@ -299,6 +308,9 @@
299
308
  height: var(--sui-resizable-handle-size, 4px);
300
309
  cursor: row-resize;
301
310
  }
311
+ .resizable-handle.vertical::before {
312
+ inset-block: calc((var(--sui-resizable-handle-size, 4px) - 24px) / 2);
313
+ }
302
314
  .resizable-handle.vertical .handle-bar {
303
315
  height: 2px;
304
316
  width: 40%;
@@ -8,7 +8,9 @@
8
8
  <script>
9
9
  import { _ } from '@sveltia/i18n';
10
10
  import { onMount } from 'svelte';
11
+ import { normalize } from '../../services/group.svelte.js';
11
12
  import { createOptionRegistry, getSelectedItemDetail } from '../../services/select.svelte.js';
13
+ import { findTypeAheadMatch, TypeAhead } from '../../services/type-ahead.js';
12
14
  import Button from '../button/button.svelte';
13
15
  import Icon from '../icon/icon.svelte';
14
16
  import Listbox from '../listbox/listbox.svelte';
@@ -124,6 +126,71 @@
124
126
  onChange?.(new CustomEvent('Change', { detail }));
125
127
  };
126
128
 
129
+ /**
130
+ * Keystroke buffer for type-ahead on the collapsed select.
131
+ */
132
+ const typeAhead = new TypeAhead();
133
+
134
+ /**
135
+ * Type-ahead on the collapsed, select-only combobox: a printable character picks the next option
136
+ * whose label starts with what has been typed, without expanding the dropdown — the way a native
137
+ * `<select>` behaves and the Select-Only Combobox pattern expects. There is no option element to
138
+ * report while the dropdown is collapsed, so the `Change` event carries the registry entry.
139
+ * @param {KeyboardEvent} event The `keydown` event.
140
+ */
141
+ const onComboboxKeyDown = (event) => {
142
+ const { key, ctrlKey, metaKey, altKey } = event;
143
+
144
+ if (
145
+ disabled ||
146
+ readonly ||
147
+ isPopupOpen ||
148
+ key.length !== 1 ||
149
+ key === ' ' ||
150
+ ctrlKey ||
151
+ metaKey ||
152
+ altKey
153
+ ) {
154
+ return;
155
+ }
156
+
157
+ // Swallow the key whether or not it matches, as a native `<select>` does. Left to the browser,
158
+ // a printable key on a non-editable element starts Firefox’s find-as-you-type.
159
+ event.preventDefault();
160
+
161
+ const entries = registry.entries.filter((entry) => !entry.disabled);
162
+
163
+ const index = findTypeAheadMatch(
164
+ entries.map((entry) => normalize(entry.label ?? '')),
165
+ typeAhead.push(key),
166
+ entries.findIndex((entry) => entry.value === value),
167
+ );
168
+
169
+ if (index === -1) {
170
+ return;
171
+ }
172
+
173
+ const entry = entries[index];
174
+
175
+ if (entry.value === value) {
176
+ return;
177
+ }
178
+
179
+ value = entry.value;
180
+ _onChange();
181
+ onChange?.(
182
+ new CustomEvent('Change', {
183
+ detail: {
184
+ target: undefined,
185
+ type: entry.type,
186
+ name: entry.name,
187
+ label: entry.label,
188
+ value: entry.value,
189
+ },
190
+ }),
191
+ );
192
+ };
193
+
127
194
  /**
128
195
  * The `<Listbox>` rendered for this combobox. It’s reached through {@link optionHost}, which owns
129
196
  * it wherever the options currently live, rather than through the popup, which only holds it
@@ -254,6 +321,7 @@
254
321
  aria-invalid={invalid}
255
322
  aria-haspopup="listbox"
256
323
  aria-label={ariaLabel}
324
+ onkeydown={onComboboxKeyDown}
257
325
  >
258
326
  <div role="none" class="label">
259
327
  <TruncatedText>
@@ -364,7 +432,7 @@
364
432
  {/if}
365
433
  <div bind:this={listboxSlot} role="none" class="listbox-slot"></div>
366
434
  {#if !hasMatchingOptions}
367
- <div role="alert" class="no-options" aria-live="assertive">
435
+ <div role="status" class="no-options">
368
436
  {_('_sui.combobox.no_matching_options')}
369
437
  </div>
370
438
  {/if}
@@ -130,16 +130,23 @@
130
130
  {hidden}
131
131
  bind:this={wrapperElement}
132
132
  >
133
- <span
134
- role="listbox"
135
- aria-multiselectable="true"
136
- aria-label={_('_sui.select_tags.selected_options')}
137
- >
133
+ <!--
134
+ The tags follow the APG layout grid pattern rather than a listbox: a listbox may only hold
135
+ options, and each tag also carries its Remove button. Each tag is a row whose first cell, the
136
+ label, is the focusable part that the arrow keys reorder.
137
+ @see https://www.w3.org/WAI/ARIA/apg/patterns/grid/examples/layout-grids/
138
+ -->
139
+ <span role="grid" aria-label={_('_sui.select_tags.selected_options')}>
138
140
  {#each values as value, index (value)}
139
141
  {@const option = optionMap.get(value)}
140
142
  {@const label = option?.label || option?.value || value}
143
+ <!--
144
+ The drag handlers are the pointer path only; the keyboard path is the focusable label cell
145
+ below, so the row itself stays out of the tab order
146
+ -->
147
+ <!-- svelte-ignore a11y_interactive_supports_focus -->
141
148
  <span
142
- role="none"
149
+ role="row"
143
150
  draggable={!disabled && !readonly}
144
151
  class:drag-source={dragIndex === index}
145
152
  class:drop-before={dropIndex === index && dragIndex !== index && dragIndex !== index - 1}
@@ -195,8 +202,7 @@
195
202
  >
196
203
  <span
197
204
  class="label"
198
- role="option"
199
- aria-selected="true"
205
+ role="gridcell"
200
206
  tabindex={disabled || readonly ? undefined : 0}
201
207
  onkeydown={async (event) => {
202
208
  const targetIndex = getKeyboardMoveTarget({
@@ -215,20 +221,22 @@
215
221
  {label}
216
222
  </span>
217
223
  {#if option}
218
- <Button
219
- iconic
220
- size="small"
221
- disabled={disabled || readonly}
222
- aria-label={_('_sui.select_tags.remove_x', { values: { name: label } })}
223
- onclick={() => {
224
- values = values.filter((v) => v !== value);
225
- onRemoveValue?.(new CustomEvent('RemoveValue', { detail: { value } }));
226
- }}
227
- >
228
- {#snippet startIcon()}
229
- <Icon name="close" />
230
- {/snippet}
231
- </Button>
224
+ <span role="gridcell">
225
+ <Button
226
+ iconic
227
+ size="small"
228
+ disabled={disabled || readonly}
229
+ aria-label={_('_sui.select_tags.remove_x', { values: { name: label } })}
230
+ onclick={() => {
231
+ values = values.filter((v) => v !== value);
232
+ onRemoveValue?.(new CustomEvent('RemoveValue', { detail: { value } }));
233
+ }}
234
+ >
235
+ {#snippet startIcon()}
236
+ <Icon name="close" />
237
+ {/snippet}
238
+ </Button>
239
+ </span>
232
240
  {/if}
233
241
  </span>
234
242
  {/each}
@@ -272,7 +280,7 @@
272
280
  .select-tags.disabled > * {
273
281
  opacity: 0.5;
274
282
  }
275
- .select-tags span[role=listbox] {
283
+ .select-tags span[role=grid] {
276
284
  display: contents;
277
285
  }
278
286
  .select-tags span[draggable] {
@@ -314,6 +322,9 @@
314
322
  .select-tags span[draggable] .label {
315
323
  outline: none;
316
324
  }
325
+ .select-tags span[draggable] span[role=gridcell]:not(.label) {
326
+ display: contents;
327
+ }
317
328
  .select-tags span[draggable] :global(button) {
318
329
  outline-offset: -2px;
319
330
  }
@@ -10,6 +10,12 @@ export function getSliderSteps({ min, max, step, barWidth }: {
10
10
  export function toLogicalX(physicalX: number, barWidth: number, rtl: boolean): number;
11
11
  export function findNearestStepIndex(positionList: number[], logicalX: number): number;
12
12
  export function getSliderKeyDirection(key: string, rtl: boolean): 1 | -1 | 0;
13
+ export function getSliderKeyTargetIndex({ key, rtl, currentIndex, length }: {
14
+ key: string;
15
+ rtl: boolean;
16
+ currentIndex: number;
17
+ length: number;
18
+ }): number;
13
19
  export function wouldCrossThumbs({ valueIndex, targetPosition, sliderPositions }: {
14
20
  valueIndex: number;
15
21
  targetPosition: number;
@@ -82,6 +82,46 @@ export const getSliderKeyDirection = (key, rtl) => {
82
82
  return 0;
83
83
  };
84
84
 
85
+ /**
86
+ * Get the step index a key moves the thumb to, per the APG Slider pattern: the arrow keys move by
87
+ * one step, Page Up/Down by a tenth of the range, and Home/End jump to either end.
88
+ * @param {object} options Options.
89
+ * @param {string} options.key The key pressed.
90
+ * @param {boolean} options.rtl Whether the layout is right-to-left.
91
+ * @param {number} options.currentIndex Index of the current step.
92
+ * @param {number} options.length Number of steps.
93
+ * @returns {number} Target index, clamped to the range, or `-1` if the key doesn’t move the thumb
94
+ * or the thumb is already where the key would take it.
95
+ */
96
+ export const getSliderKeyTargetIndex = ({ key, rtl, currentIndex, length }) => {
97
+ const lastIndex = length - 1;
98
+ const page = Math.max(1, Math.round(length / 10));
99
+ /** @type {number} */
100
+ let index;
101
+
102
+ if (key === 'Home') {
103
+ index = 0;
104
+ } else if (key === 'End') {
105
+ index = lastIndex;
106
+ } else if (key === 'PageUp') {
107
+ index = currentIndex + page;
108
+ } else if (key === 'PageDown') {
109
+ index = currentIndex - page;
110
+ } else {
111
+ const direction = getSliderKeyDirection(key, rtl);
112
+
113
+ if (!direction) {
114
+ return -1;
115
+ }
116
+
117
+ index = currentIndex + direction;
118
+ }
119
+
120
+ index = Math.min(lastIndex, Math.max(0, index));
121
+
122
+ return index === currentIndex ? -1 : index;
123
+ };
124
+
85
125
  /**
86
126
  * Whether moving one thumb of a multi-thumb slider to the given position would make it cross, or
87
127
  * land on, the other thumb.
@@ -12,7 +12,7 @@
12
12
  import { onMount } from 'svelte';
13
13
  import {
14
14
  findNearestStepIndex,
15
- getSliderKeyDirection,
15
+ getSliderKeyTargetIndex,
16
16
  getSliderSteps,
17
17
  toLogicalX,
18
18
  wouldCrossThumbs,
@@ -28,10 +28,13 @@
28
28
  * @property {number} [min] Minimum allowed value. An alias of the `aria-valuemin` attribute.
29
29
  * @property {number} [max] Maximum allowed value. An alias of the `aria-valuemax` attribute.
30
30
  * @property {string} [sliderLabel] `aria-label` on the slider.
31
+ * @property {string} [ariaLabelledby] `aria-labelledby` on a single-thumb slider, for a visible
32
+ * label elsewhere on the page.
31
33
  * @property {[number, number]} [values] Value list for a multi-thumb slider.
32
34
  * @property {[string, string]} [sliderLabels] `aria-label` on a multi-thumb slider.
33
35
  * @property {number} [step] Step option like `<input type="range">`.
34
- * @property {(string[] | number[])} [optionLabels] Visible labels on the slider.
36
+ * @property {(string[] | number[])} [optionLabels] Visible labels on the slider. When there is
37
+ * one per step, the current one is also read out as the value (`aria-valuetext`).
35
38
  * @property {boolean} [flex] Make the text input container flexible.
36
39
  * @property {string} [class] The `class` attribute on the wrapper element.
37
40
  * @property {boolean} [hidden] Whether to hide the widget.
@@ -55,6 +58,7 @@
55
58
  min = 0,
56
59
  max = 100,
57
60
  sliderLabel = '',
61
+ ariaLabelledby = undefined,
58
62
  values = $bindable(undefined),
59
63
  sliderLabels = undefined,
60
64
  step = 1,
@@ -79,6 +83,21 @@
79
83
  let positionList = $state([]);
80
84
  /** @type {number[]} */
81
85
  let valueList = $state([]);
86
+
87
+ /**
88
+ * Get the text read out for a value, when the visible option labels line up with the steps.
89
+ * @param {number | undefined} _value Value.
90
+ * @returns {string | undefined} Matching label, if any.
91
+ */
92
+ const getValueText = (_value) => {
93
+ if (optionLabels.length !== valueList.length || _value === undefined) {
94
+ return undefined;
95
+ }
96
+
97
+ const index = valueList.indexOf(_value);
98
+
99
+ return index === -1 ? undefined : String(optionLabels[index]);
100
+ };
82
101
  let startX = $state(0);
83
102
  let startScreenX = $state(0);
84
103
  // eslint-disable-next-line prefer-const
@@ -128,20 +147,26 @@
128
147
  }
129
148
 
130
149
  const _value = multiThumb ? /** @type {[number, number]} */ (values)[valueIndex] : value;
131
- const direction = getSliderKeyDirection(key, isRTL());
132
150
 
133
- if (!direction) {
151
+ const index = getSliderKeyTargetIndex({
152
+ key,
153
+ rtl: isRTL(),
154
+ currentIndex: valueList.indexOf(_value),
155
+ length: valueList.length,
156
+ });
157
+
158
+ if (index === -1) {
159
+ // Still swallow the key if it’s one of the slider’s, so Home/End don’t scroll the page
160
+ if (['Home', 'End', 'PageUp', 'PageDown'].includes(key)) {
161
+ event.preventDefault();
162
+ }
163
+
134
164
  return;
135
165
  }
136
166
 
137
167
  event.preventDefault();
138
168
  event.stopPropagation();
139
169
 
140
- const index =
141
- (direction < 0 && _value > min) || (direction > 0 && _value < max)
142
- ? valueList.indexOf(_value) + direction
143
- : -1;
144
-
145
170
  if (index > -1) {
146
171
  if (
147
172
  multiThumb &&
@@ -325,7 +350,8 @@
325
350
  <div
326
351
  role="slider"
327
352
  tabindex={disabled ? -1 : 0}
328
- aria-label={multiThumb ? sliderLabels?.[0] : sliderLabel}
353
+ aria-label={(multiThumb ? sliderLabels?.[0] : sliderLabel) || undefined}
354
+ aria-labelledby={multiThumb ? undefined : ariaLabelledby}
329
355
  aria-hidden={hidden}
330
356
  aria-disabled={disabled}
331
357
  aria-readonly={readonly}
@@ -333,6 +359,7 @@
333
359
  aria-valuemin={min}
334
360
  aria-valuemax={max}
335
361
  aria-valuenow={multiThumb ? values?.[0] : value}
362
+ aria-valuetext={getValueText(multiThumb ? values?.[0] : value)}
336
363
  style:inset-inline-start={`${sliderPositions[0]}px`}
337
364
  onpointerdown={(event) => onPointerDown(event, 0)}
338
365
  onkeydown={(event) => onKeyDown(event, 0)}
@@ -341,7 +368,7 @@
341
368
  <div
342
369
  role="slider"
343
370
  tabindex={disabled ? -1 : 0}
344
- aria-label={sliderLabels?.[1]}
371
+ aria-label={sliderLabels?.[1] || undefined}
345
372
  aria-hidden={hidden}
346
373
  aria-disabled={disabled}
347
374
  aria-readonly={readonly}
@@ -349,6 +376,7 @@
349
376
  aria-valuemin={min}
350
377
  aria-valuemax={max}
351
378
  aria-valuenow={values?.[1]}
379
+ aria-valuetext={getValueText(values?.[1])}
352
380
  style:inset-inline-start={`${sliderPositions[1]}px`}
353
381
  onpointerdown={(event) => onPointerDown(event, 1)}
354
382
  onkeydown={(event) => onKeyDown(event, 1)}
@@ -421,6 +449,12 @@
421
449
  background-color: var(--sui-primary-accent-color-inverted);
422
450
  cursor: pointer;
423
451
  }
452
+ [role=slider]::before {
453
+ content: "";
454
+ position: absolute;
455
+ inset: -4px;
456
+ border-radius: inherit;
457
+ }
424
458
  [role=slider]:dir(ltr) {
425
459
  transform: translate(calc((var(--sui-checkbox-height) / 2 - 1px) * -1), calc((var(--sui-checkbox-height) / 4 - 1px) * -1));
426
460
  }
@@ -28,6 +28,11 @@ declare const Slider: import("svelte").Component<{
28
28
  * `aria-label` on the slider.
29
29
  */
30
30
  sliderLabel?: string | undefined;
31
+ /**
32
+ * `aria-labelledby` on a single-thumb slider, for a visible
33
+ * label elsewhere on the page.
34
+ */
35
+ ariaLabelledby?: string | undefined;
31
36
  /**
32
37
  * Value list for a multi-thumb slider.
33
38
  */
@@ -41,7 +46,8 @@ declare const Slider: import("svelte").Component<{
41
46
  */
42
47
  step?: number | undefined;
43
48
  /**
44
- * Visible labels on the slider.
49
+ * Visible labels on the slider. When there is
50
+ * one per step, the current one is also read out as the value (`aria-valuetext`).
45
51
  */
46
52
  optionLabels?: string[] | number[] | undefined;
47
53
  /**
@@ -101,6 +107,11 @@ type Props = {
101
107
  * `aria-label` on the slider.
102
108
  */
103
109
  sliderLabel?: string | undefined;
110
+ /**
111
+ * `aria-labelledby` on a single-thumb slider, for a visible
112
+ * label elsewhere on the page.
113
+ */
114
+ ariaLabelledby?: string | undefined;
104
115
  /**
105
116
  * Value list for a multi-thumb slider.
106
117
  */
@@ -114,7 +125,8 @@ type Props = {
114
125
  */
115
126
  step?: number | undefined;
116
127
  /**
117
- * Visible labels on the slider.
128
+ * Visible labels on the slider. When there is
129
+ * one per step, the current one is also read out as the value (`aria-valuetext`).
118
130
  */
119
131
  optionLabels?: string[] | number[] | undefined;
120
132
  /**
@@ -32,7 +32,7 @@
32
32
  */
33
33
  let {
34
34
  /* eslint-disable prefer-const */
35
- checked = $bindable(),
35
+ checked = $bindable(false),
36
36
  label = undefined,
37
37
  class: className,
38
38
  hidden = false,
@@ -5,6 +5,8 @@
5
5
  @see https://w3c.github.io/aria/#rowgroup
6
6
  -->
7
7
  <script>
8
+ import { _ } from '@sveltia/i18n';
9
+
8
10
  /**
9
11
  * @import { Snippet } from 'svelte';
10
12
  */
@@ -36,7 +38,7 @@
36
38
  role="rowgroup"
37
39
  class="sui table-body row-group {className}"
38
40
  aria-labelledby={label ? `${id}-label` : undefined}
39
- aria-roledescription="table body"
41
+ aria-roledescription={_('_sui.role_descriptions.table_body')}
40
42
  >
41
43
  {#if label}
42
44
  <div role="row" class="row-group-caption">