@juspay/svelte-ui-components 4.9.2 → 4.10.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.
@@ -19,6 +19,23 @@
19
19
  attributes
20
20
  }: CheckboxProperties = $props();
21
21
 
22
+ // Visible text wins over `ariaLabel`, which is what `properties.ts` has always
23
+ // promised and what WCAG 2.5.3 requires: a control showing "Accept terms" must
24
+ // carry that text in its accessible name, so letting an explicit label replace
25
+ // it outright would swap the name a speech-input user says for one they cannot
26
+ // see. `ariaLabel` names the box only when there is no visible text to use —
27
+ // the icon-only and label-in-a-table-header cases it exists for.
28
+ //
29
+ // A blank `ariaLabel` is treated as absent rather than as an empty name, since
30
+ // `aria-label=""` leaves the box unnamed, which is the bug this fixes.
31
+ const accessibleName: string | null = $derived(
32
+ text.length > 0
33
+ ? text
34
+ : typeof ariaLabel === 'string' && ariaLabel.trim() !== ''
35
+ ? ariaLabel
36
+ : null
37
+ );
38
+
22
39
  function handleClick(): void {
23
40
  if (disabled) {
24
41
  return;
@@ -82,7 +99,7 @@
82
99
  aria-checked={indeterminate ? 'mixed' : checked}
83
100
  aria-disabled={disabled}
84
101
  aria-controls={ariaControls ?? null}
85
- aria-label={text.length === 0 ? (ariaLabel ?? null) : null}
102
+ aria-label={accessibleName}
86
103
  onkeydown={handleKeyDown}
87
104
  data-pw={typeof testId === 'string' ? `${testId}-box` : null}
88
105
  testID={typeof testId === 'string' ? `${testId}-box` : null}
@@ -19,10 +19,19 @@
19
19
  classes
20
20
  }: CommandMenuProperties = $props();
21
21
 
22
+ // Stable per-instance ids keep ARIA references distinct across menus.
23
+ const uid = $props.id();
24
+ const listboxId = `command-menu-listbox-${uid}`;
25
+
22
26
  let query = $state('');
23
27
  let activeIndex = $state(0);
24
28
  let inputElement: HTMLInputElement | null = $state(null);
25
29
  let listElement: HTMLDivElement | null = $state(null);
30
+ let dialogElement: HTMLDivElement | null = $state(null);
31
+ // Captured when the dialog opens, consumed once it unmounts — mirrors
32
+ // Gallery.svelte's lightboxAction so focus returns to whatever opened the
33
+ // command menu instead of falling back to <body>.
34
+ let openerElement: HTMLElement | null = null;
26
35
 
27
36
  let filteredItems = $derived.by(() => {
28
37
  if (query.trim() === '') {
@@ -68,6 +77,13 @@
68
77
  return map;
69
78
  });
70
79
 
80
+ // aria-activedescendant must point at the id of the highlighted option, which
81
+ // is keyed by its flat index (see the option's own id below) — null once the
82
+ // list is empty, since there is then no option to describe.
83
+ let activeDescendantId = $derived(
84
+ flatItems.length > 0 ? `command-menu-option-${uid}-${activeIndex}` : null
85
+ );
86
+
71
87
  function close() {
72
88
  open = false;
73
89
  query = '';
@@ -132,9 +148,47 @@
132
148
  close();
133
149
  break;
134
150
  }
151
+ case 'Tab': {
152
+ // aria-modal="true" promises focus stays inside, so Tab cycles rather
153
+ // than escaping. Cycle over whatever is actually tabbable instead of
154
+ // pinning to the input: today the input is the only tab stop (options
155
+ // are tabindex="-1" and are reached with arrows), so the two are
156
+ // equivalent — but pinning would silently break the moment the dialog
157
+ // gains a second tab stop, such as a close button.
158
+ const tabbable = tabbableElements();
159
+ if (tabbable.length === 0) {
160
+ break;
161
+ }
162
+ event.preventDefault();
163
+ const active = document.activeElement;
164
+ const current = active instanceof HTMLElement ? tabbable.indexOf(active) : -1;
165
+ const step = event.shiftKey ? -1 : 1;
166
+ const next = (current + step + tabbable.length) % tabbable.length;
167
+ tabbable.at(next)?.focus();
168
+ break;
169
+ }
135
170
  }
136
171
  }
137
172
 
173
+ // Everything inside the dialog that Tab would normally reach. `tabindex="-1"`
174
+ // is excluded because those are arrow-key targets, not tab stops.
175
+ function tabbableElements(): HTMLElement[] {
176
+ if (dialogElement === null) {
177
+ return [];
178
+ }
179
+ const candidates = dialogElement.querySelectorAll<HTMLElement>(
180
+ 'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]'
181
+ );
182
+ return [...candidates].filter(
183
+ (el) =>
184
+ // The options are real <button>s held out of the tab order with
185
+ // tabindex="-1"; they are arrow-key targets. Excluding -1 only in the
186
+ // `[tabindex]` selector would still match them as buttons.
187
+ el.getAttribute('tabindex') !== '-1' &&
188
+ (el.offsetParent !== null || el === document.activeElement)
189
+ );
190
+ }
191
+
138
192
  async function scrollActiveIntoView() {
139
193
  await tick();
140
194
  if (listElement !== null) {
@@ -167,6 +221,9 @@
167
221
  // onDestroy below must NOT unlock as well: with a shared reference count a second
168
222
  // decrement would release a lock another open surface still needs.
169
223
  function scrollLockAction(_node: HTMLElement) {
224
+ if (openerElement === null && document.activeElement instanceof HTMLElement) {
225
+ openerElement = document.activeElement;
226
+ }
170
227
  lockBodyScroll();
171
228
  tick().then(() => {
172
229
  if (inputElement !== null) {
@@ -176,6 +233,10 @@
176
233
  return {
177
234
  destroy() {
178
235
  unlockBodyScroll();
236
+ if (openerElement !== null) {
237
+ openerElement.focus();
238
+ openerElement = null;
239
+ }
179
240
  }
180
241
  };
181
242
  }
@@ -204,7 +265,7 @@
204
265
  data-pw={typeof testId === 'string' ? testId : null}
205
266
  testID={typeof testId === 'string' ? testId : null}
206
267
  >
207
- <div class="command-menu-dialog">
268
+ <div class="command-menu-dialog" bind:this={dialogElement}>
208
269
  <div class="command-menu-input-wrapper">
209
270
  {#if typeof searchIcon === 'function'}
210
271
  {@render searchIcon()}
@@ -223,6 +284,11 @@
223
284
  {placeholder}
224
285
  autocomplete="off"
225
286
  spellcheck="false"
287
+ role="combobox"
288
+ aria-expanded={flatItems.length > 0}
289
+ aria-controls={listboxId}
290
+ aria-autocomplete="list"
291
+ aria-activedescendant={activeDescendantId}
226
292
  data-pw={typeof testId === 'string' ? `${testId}-input` : null}
227
293
  testID={typeof testId === 'string' ? `${testId}-input` : null}
228
294
  />
@@ -230,7 +296,7 @@
230
296
 
231
297
  <div class="command-menu-separator"></div>
232
298
 
233
- <div class="command-menu-list" bind:this={listElement} role="listbox">
299
+ <div class="command-menu-list" bind:this={listElement} id={listboxId} role="listbox">
234
300
  {#if flatItems.length === 0}
235
301
  <div class="command-menu-empty">
236
302
  {emptyText}
@@ -244,6 +310,7 @@
244
310
  {@const index = flatIndexLookup.get(item.value) ?? -1}
245
311
  <button
246
312
  type="button"
313
+ id={`command-menu-option-${uid}-${index}`}
247
314
  class="command-menu-item"
248
315
  class:active={index === activeIndex}
249
316
  class:disabled={item.disabled}
@@ -19,27 +19,66 @@
19
19
  let focusedIndex: number = $state(-1);
20
20
  let posX: number = $state(0);
21
21
  let posY: number = $state(0);
22
+ // Captured when the menu opens, consumed once on close — the element that had
23
+ // focus (or triggered the native contextmenu event) is where focus belongs once
24
+ // the menu goes away, matching Menu.svelte's own restore-on-close contract.
25
+ let openerElement: HTMLElement | null = $state(null);
22
26
 
23
27
  let selectableItems: ContextMenuItem[] = $derived(
24
28
  items.filter((item) => !item.separator && !item.disabled)
25
29
  );
26
30
 
31
+ // The frame that moves focus into the freshly-opened menu, kept so closing can
32
+ // cancel it. Escape pressed between the right-click and that frame used to
33
+ // leave the callback queued: it ran after the menu was gone, moved focus onto
34
+ // an item that no longer existed, and left the page with nothing focused at
35
+ // all — worse than never restoring, because the user's place is simply lost.
36
+ let openFrame: number | null = null;
37
+
38
+ function cancelOpenFrame() {
39
+ if (openFrame !== null) {
40
+ cancelAnimationFrame(openFrame);
41
+ openFrame = null;
42
+ }
43
+ }
44
+
27
45
  function openMenu(x: number, y: number) {
28
46
  posX = x;
29
47
  posY = y;
30
48
  open = true;
31
49
  focusedIndex = 0;
32
50
  onopen?.();
33
- requestAnimationFrame(() => {
51
+ cancelOpenFrame();
52
+ openFrame = requestAnimationFrame(() => {
53
+ openFrame = null;
34
54
  adjustPosition();
35
55
  focusItem(0);
36
56
  });
37
57
  }
38
58
 
39
59
  function close() {
60
+ cancelOpenFrame();
40
61
  open = false;
41
62
  focusedIndex = -1;
42
63
  onclose?.();
64
+
65
+ const opener = openerElement;
66
+ openerElement = null;
67
+ if (opener === null) {
68
+ return;
69
+ }
70
+
71
+ // Only take focus back if nothing else has claimed it. Closing by clicking a
72
+ // focusable element outside means the browser has already moved focus there,
73
+ // and pulling it back to the opener would fight the user and lose their
74
+ // place. Focus still inside the dying menu, or fallen to <body>, is loose and
75
+ // does belong to the opener.
76
+ const active = document.activeElement;
77
+ const focusIsLoose =
78
+ active === null || active === document.body || (menuEl?.contains(active) ?? false);
79
+ if (focusIsLoose) {
80
+ opener.focus();
81
+ }
43
82
  }
44
83
 
45
84
  function selectMenuItem(item: ContextMenuItem) {
@@ -90,6 +129,12 @@
90
129
 
91
130
  function handleContextMenu(event: MouseEvent) {
92
131
  event.preventDefault();
132
+ // A right-click usually leaves <body> as the active element, and "restore
133
+ // focus to <body>" is not a restoration — it is the same as doing nothing,
134
+ // but it also discards whatever the browser would have done. Capture only a
135
+ // real element.
136
+ const active = document.activeElement;
137
+ openerElement = active instanceof HTMLElement && active !== document.body ? active : null;
93
138
  openMenu(event.clientX, event.clientY);
94
139
  }
95
140
 
@@ -147,13 +192,28 @@
147
192
  }
148
193
  }
149
194
 
195
+ // Escape is handled on the document as well as on the menu, because for one
196
+ // frame after the right-click the menu is open but focus has not moved into it
197
+ // yet. A menu-only handler does nothing during that window: the keystroke is
198
+ // swallowed, the menu stays open, and the pending frame then pulls focus into
199
+ // it — so a quick Escape appeared to do the opposite of what it asked for. An
200
+ // open menu should close on Escape wherever focus happens to be.
201
+ function handleDocumentKeydown(event: KeyboardEvent) {
202
+ if (open && event.key === 'Escape') {
203
+ event.preventDefault();
204
+ close();
205
+ }
206
+ }
207
+
150
208
  onMount(() => {
151
209
  document.addEventListener('click', handleClickOutside);
210
+ document.addEventListener('keydown', handleDocumentKeydown);
152
211
  });
153
212
 
154
213
  onDestroy(() => {
155
214
  if (typeof window !== 'undefined') {
156
215
  document.removeEventListener('click', handleClickOutside);
216
+ document.removeEventListener('keydown', handleDocumentKeydown);
157
217
  }
158
218
  });
159
219
  </script>
@@ -162,7 +222,7 @@
162
222
  class="context-menu-container {classes ?? ''}"
163
223
  bind:this={containerEl}
164
224
  oncontextmenu={handleContextMenu}
165
- role="application"
225
+ {...open ? { role: 'application' } : {}}
166
226
  data-pw={testId}
167
227
  testID={testId}
168
228
  >
@@ -10,6 +10,7 @@
10
10
  showValue = false,
11
11
  labelFormatter,
12
12
  ariaLabel,
13
+ ariaLabelledby,
13
14
  testId,
14
15
  onchange,
15
16
  oninput,
@@ -44,6 +45,7 @@
44
45
  {value}
45
46
  {disabled}
46
47
  aria-label={typeof ariaLabel === 'string' ? ariaLabel : null}
48
+ aria-labelledby={typeof ariaLabel === 'string' ? null : (ariaLabelledby ?? null)}
47
49
  data-pw={typeof testId === 'string' ? testId : null}
48
50
  testID={typeof testId === 'string' ? testId : null}
49
51
  oninput={handleInput}
@@ -14,6 +14,13 @@ export type OptionalSliderProperties = {
14
14
  * so a control whose purpose is not carried by adjacent visible text needs this.
15
15
  */
16
16
  ariaLabel?: string;
17
+ /**
18
+ * Ids of the elements that name the range input (aria-labelledby). Use this
19
+ * instead of `ariaLabel` when the name is already on screen, so the two cannot
20
+ * drift apart. `ariaLabel` wins if both are given, matching how the platform
21
+ * resolves them.
22
+ */
23
+ ariaLabelledby?: string;
17
24
  testId?: string;
18
25
  classes?: string;
19
26
  };
@@ -12,12 +12,27 @@
12
12
  testId,
13
13
  dropdownIcon,
14
14
  classes,
15
+ triggerAriaLabel,
15
16
  onclick,
16
17
  onselect
17
18
  }: SplitButtonProperties = $props();
18
19
 
19
20
  let menuOpen = $state(false);
20
21
 
22
+ // The dropdown trigger renders a chevron and nothing else, so without a name it is a
23
+ // focusable control that announces as an unnamed button.
24
+ // A blank `triggerAriaLabel` falls back rather than being honoured: `??` only
25
+ // rejects null and undefined, so an empty string would have been passed
26
+ // through as `aria-label=""` and left the trigger unnamed — the very thing
27
+ // this generated fallback exists to prevent.
28
+ let resolvedTriggerAriaLabel = $derived(
29
+ typeof triggerAriaLabel === 'string' && triggerAriaLabel.trim() !== ''
30
+ ? triggerAriaLabel
31
+ : text.length > 0
32
+ ? `More ${text} options`
33
+ : 'More options'
34
+ );
35
+
21
36
  function handlePrimaryClick(event: MouseEvent): void {
22
37
  if (disabled) {
23
38
  return;
@@ -40,7 +55,12 @@
40
55
  <Button {text} onclick={handlePrimaryClick} {disabled} />
41
56
  </div>
42
57
  <div class="split-button-trigger">
43
- <Menu {items} bind:open={menuOpen} onselect={handleMenuSelect}>
58
+ <Menu
59
+ {items}
60
+ bind:open={menuOpen}
61
+ triggerAriaLabel={resolvedTriggerAriaLabel}
62
+ onselect={handleMenuSelect}
63
+ >
44
64
  {#snippet trigger()}
45
65
  <span class="split-button-arrow">
46
66
  {#if typeof dropdownIcon === 'function'}
@@ -10,6 +10,13 @@ export type OptionalSplitButtonProperties = {
10
10
  testId?: string;
11
11
  dropdownIcon?: Snippet;
12
12
  classes?: string;
13
+ /**
14
+ * Accessible name for the dropdown trigger — the second control, which shows only a
15
+ * chevron and so has no text to be named by. Defaults to `More <text> options`, which
16
+ * distinguishes it from the primary button beside it. Named after Menu's own
17
+ * `triggerAriaLabel`, which is what it forwards to.
18
+ */
19
+ triggerAriaLabel?: string;
13
20
  };
14
21
  export type SplitButtonEventProperties = {
15
22
  onclick?: (event: MouseEvent) => void;
@@ -16,6 +16,12 @@
16
16
  onkeydown
17
17
  }: StepProperties = $props();
18
18
 
19
+ // Per-instance, because `step-label-${stepIndex}` collides across two Steppers
20
+ // on one page and `aria-labelledby` then resolves to whichever matching id
21
+ // comes first in the document — naming a step after a different Stepper's.
22
+ const uid = $props.id();
23
+ const labelId = `step-label-${uid}`;
24
+
19
25
  const handleStepClick = (): void => {
20
26
  onclick?.({ selectedIndex: stepIndex });
21
27
  };
@@ -59,7 +65,7 @@
59
65
  role={suppressRoleAndTabindex ? null : 'button'}
60
66
  tabindex={suppressRoleAndTabindex ? null : 0}
61
67
  aria-label={ariaLabel ?? null}
62
- aria-labelledby={ariaLabel ? null : `step-label-${stepIndex}`}
68
+ aria-labelledby={ariaLabel ? null : labelId}
63
69
  aria-current={status === 'active' ? 'step' : null}
64
70
  >
65
71
  {#if typeof icon === 'string' && icon.length > 0}
@@ -99,7 +105,7 @@
99
105
  </div>
100
106
  {/if}
101
107
 
102
- <div class="step-text" id="step-label-{stepIndex}">
108
+ <div class="step-text" id={labelId}>
103
109
  {label}
104
110
  </div>
105
111
 
@@ -447,11 +447,16 @@
447
447
  }))}
448
448
  testId={generatedTestId('menu', rowIndex)}
449
449
  {usePortal}
450
+ interactiveTrigger
450
451
  onselect={(menuItem) => column.onMenuAction?.(rowIndex, menuItem.value, originalIndex)}
451
452
  >
452
- {#snippet trigger()}
453
+ {#snippet trigger(triggerProps)}
453
454
  <span class="builtin-icon-button">
454
- <Button ariaLabel="More actions" testId={generatedTestId('menuTrigger', rowIndex)}>
455
+ <Button
456
+ {...triggerProps}
457
+ ariaLabel="More actions"
458
+ testId={generatedTestId('menuTrigger', rowIndex)}
459
+ >
455
460
  {#snippet icon()}
456
461
  <!-- eslint-disable svelte/no-at-html-tags -->
457
462
  <span class="builtin-menu-dots">{@html dotsSvg}</span>
@@ -483,11 +488,13 @@
483
488
  }))}
484
489
  testId={generatedTestId('popup', rowIndex)}
485
490
  {usePortal}
491
+ interactiveTrigger
486
492
  onselect={(menuItem) => column.onMenuAction?.(rowIndex, menuItem.value, originalIndex)}
487
493
  >
488
- {#snippet trigger()}
494
+ {#snippet trigger(triggerProps)}
489
495
  <span class="builtin-icon-button">
490
496
  <Button
497
+ {...triggerProps}
491
498
  ariaLabel={popupData.ariaLabel ?? 'More actions'}
492
499
  testId={generatedTestId('popupTrigger', rowIndex)}
493
500
  >
@@ -758,19 +758,21 @@
758
758
  }))}
759
759
  selectedValue={filter.selectedValue ?? null}
760
760
  role="listbox"
761
+ interactiveTrigger
761
762
  testId={headerColumn.testId && `${headerColumn.testId}-filter`}
762
763
  onselect={(menuItem) =>
763
764
  filter.onFilterChange?.(
764
765
  menuItem.value === filter.selectedValue ? null : menuItem.value
765
766
  )}
766
767
  >
767
- {#snippet trigger()}
768
+ {#snippet trigger(triggerProps)}
768
769
  <span
769
770
  class="table-header-filter-trigger"
770
771
  class:table-header-filter-active={typeof filter.selectedValue ===
771
772
  'string'}
772
773
  >
773
774
  <Button
775
+ {...triggerProps}
774
776
  ariaLabel="Filter by {header}"
775
777
  testId={headerColumn.testId &&
776
778
  `${headerColumn.testId}-filter-trigger`}