@happyvertical/smrt-ui 0.30.0 → 0.31.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/dist/actions/__tests__/ripple.test.js +55 -1
  2. package/dist/actions/ripple.d.ts +9 -1
  3. package/dist/actions/ripple.d.ts.map +1 -1
  4. package/dist/actions/ripple.js +56 -7
  5. package/dist/components/display/ConfidenceBadge.svelte +14 -10
  6. package/dist/components/display/ConfidenceBadge.svelte.d.ts.map +1 -1
  7. package/dist/components/display/StatusBadge.svelte +81 -75
  8. package/dist/components/display/StatusBadge.svelte.d.ts.map +1 -1
  9. package/dist/components/display/__tests__/ConfidenceBadge.test.js +13 -0
  10. package/dist/components/display/__tests__/StatusBadge.test.js +17 -2
  11. package/dist/components/feedback/ConfirmDialog.svelte +78 -2
  12. package/dist/components/feedback/ConfirmDialog.svelte.d.ts.map +1 -1
  13. package/dist/components/feedback/LoadingOverlay.svelte +6 -1
  14. package/dist/components/feedback/LoadingOverlay.svelte.d.ts.map +1 -1
  15. package/dist/components/feedback/__tests__/ConfirmDialog.test.js +55 -3
  16. package/dist/components/feedback/__tests__/LoadingOverlay.test.js +14 -0
  17. package/dist/components/roles/RoleSelector.svelte +106 -13
  18. package/dist/components/roles/RoleSelector.svelte.d.ts +10 -0
  19. package/dist/components/roles/RoleSelector.svelte.d.ts.map +1 -1
  20. package/dist/components/roles/__tests__/RoleSelector.test.js +134 -0
  21. package/dist/components/ui/Button.svelte +12 -2
  22. package/dist/components/ui/Pagination.svelte +3 -1
  23. package/dist/components/ui/Pagination.svelte.d.ts.map +1 -1
  24. package/dist/components/ui/__tests__/Pagination.test.js +14 -0
  25. package/dist/themes/__tests__/create-theme.test.js +79 -0
  26. package/dist/themes/__tests__/css-generator.test.js +55 -0
  27. package/dist/themes/create-theme.d.ts.map +1 -1
  28. package/dist/themes/create-theme.js +29 -22
  29. package/dist/themes/css-generator.d.ts.map +1 -1
  30. package/dist/themes/css-generator.js +4 -1
  31. package/dist/themes/studio/index.d.ts.map +1 -1
  32. package/dist/themes/studio/index.js +19 -1
  33. package/dist/themes/types.d.ts +8 -1
  34. package/dist/themes/types.d.ts.map +1 -1
  35. package/dist/utils/theme/__tests__/color.test.js +17 -0
  36. package/dist/utils/theme/color.d.ts +11 -0
  37. package/dist/utils/theme/color.d.ts.map +1 -1
  38. package/dist/utils/theme/color.js +39 -4
  39. package/package.json +2 -2
@@ -5,7 +5,14 @@
5
5
  *
6
6
  * Provides a consistent confirmation dialog for destructive actions
7
7
  * or important decisions.
8
+ *
9
+ * Accessibility (#1586): the dialog is a `role="dialog" aria-modal="true"`
10
+ * surface with managed focus — on open it records the previously-focused element
11
+ * and moves focus to the confirm button; Tab/Shift+Tab are trapped within the
12
+ * dialog; Escape (caught at the document level while open) cancels; and focus is
13
+ * restored to the opener when the dialog closes.
8
14
  */
15
+ import { tick } from 'svelte';
9
16
  import { ripple } from '../../actions/ripple.js';
10
17
 
11
18
  /** Props for ConfirmDialog component */
@@ -42,33 +49,101 @@ const {
42
49
  oncancel,
43
50
  }: Props = $props();
44
51
 
52
+ let backdropEl = $state<HTMLElement | null>(null);
53
+ let confirmBtnEl = $state<HTMLButtonElement | null>(null);
54
+ // The element focused before the dialog opened, restored on close.
55
+ let previouslyFocused: HTMLElement | null = null;
56
+
57
+ function focusableEls(): HTMLElement[] {
58
+ if (!backdropEl) return [];
59
+ return Array.from(
60
+ backdropEl.querySelectorAll<HTMLElement>(
61
+ 'button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])',
62
+ ),
63
+ );
64
+ }
65
+
66
+ // Move focus into the dialog on open; restore it to the opener on close.
67
+ $effect(() => {
68
+ if (typeof document === 'undefined') return;
69
+
70
+ if (open) {
71
+ previouslyFocused = document.activeElement as HTMLElement | null;
72
+ // Wait for the dialog DOM to mount, then move focus into the dialog: the
73
+ // confirm action by default, the first focusable if confirm is disabled, or
74
+ // the dialog container itself when every control is disabled (loading) so
75
+ // focus is still contained and Escape stays reachable.
76
+ void tick().then(() => {
77
+ if (!open) return;
78
+ const target = confirmBtnEl?.disabled
79
+ ? (focusableEls()[0] ?? backdropEl)
80
+ : confirmBtnEl;
81
+ target?.focus();
82
+ });
83
+ } else if (previouslyFocused) {
84
+ previouslyFocused.focus();
85
+ previouslyFocused = null;
86
+ }
87
+ });
88
+
45
89
  function handleBackdropClick(e: MouseEvent) {
46
90
  if (e.target === e.currentTarget) {
47
91
  oncancel?.();
48
92
  }
49
93
  }
50
94
 
95
+ // Escape handling lives at the document level (below) so it fires regardless of
96
+ // where focus currently is. This handler, bound to the dialog, only traps Tab.
51
97
  function handleKeydown(e: KeyboardEvent) {
52
- if (e.key === 'Escape') {
98
+ if (e.key !== 'Tab') return;
99
+
100
+ // Trap focus within the dialog so Tab can't escape to the page behind.
101
+ const focusables = focusableEls();
102
+ if (focusables.length === 0) {
103
+ e.preventDefault();
104
+ return;
105
+ }
106
+ const first = focusables[0];
107
+ const last = focusables[focusables.length - 1];
108
+ const active = document.activeElement;
109
+
110
+ if (e.shiftKey) {
111
+ if (active === first || !backdropEl?.contains(active)) {
112
+ e.preventDefault();
113
+ last.focus();
114
+ }
115
+ } else if (active === last || !backdropEl?.contains(active)) {
116
+ e.preventDefault();
117
+ first.focus();
118
+ }
119
+ }
120
+
121
+ function handleWindowKeydown(e: KeyboardEvent) {
122
+ if (open && e.key === 'Escape') {
123
+ e.preventDefault();
53
124
  oncancel?.();
54
125
  }
55
126
  }
56
127
  </script>
57
128
 
129
+ <svelte:window onkeydown={handleWindowKeydown} />
130
+
58
131
  {#if open}
59
132
  <!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
60
133
  <div
134
+ bind:this={backdropEl}
61
135
  class="dialog-backdrop"
62
136
  role="dialog"
63
137
  aria-modal="true"
64
138
  aria-labelledby="dialog-title"
139
+ aria-describedby="dialog-message"
65
140
  tabindex="-1"
66
141
  onclick={handleBackdropClick}
67
142
  onkeydown={handleKeydown}
68
143
  >
69
144
  <div class="dialog-content">
70
145
  <h2 id="dialog-title" class="dialog-title">{title}</h2>
71
- <p class="dialog-message">{message}</p>
146
+ <p id="dialog-message" class="dialog-message">{message}</p>
72
147
 
73
148
  <div class="dialog-actions">
74
149
  <button
@@ -81,6 +156,7 @@ function handleKeydown(e: KeyboardEvent) {
81
156
  {cancelLabel}
82
157
  </button>
83
158
  <button
159
+ bind:this={confirmBtnEl}
84
160
  type="button"
85
161
  class="btn btn-filled"
86
162
  class:destructive
@@ -1 +1 @@
1
- {"version":3,"file":"ConfirmDialog.svelte.d.ts","sourceRoot":"","sources":["../../../src/components/feedback/ConfirmDialog.svelte.ts"],"names":[],"mappings":"AAaA,wCAAwC;AACxC,MAAM,WAAW,KAAK;IACpB,iCAAiC;IACjC,IAAI,EAAE,OAAO,CAAC;IACd,mBAAmB;IACnB,KAAK,EAAE,MAAM,CAAC;IACd,qBAAqB;IACrB,OAAO,EAAE,MAAM,CAAC;IAChB,2BAA2B;IAC3B,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,0BAA0B;IAC1B,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,gDAAgD;IAChD,WAAW,CAAC,EAAE,OAAO,CAAC;IACtB,oCAAoC;IACpC,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB,qCAAqC;IACrC,SAAS,CAAC,EAAE,MAAM,IAAI,CAAC;IACvB,qDAAqD;IACrD,QAAQ,CAAC,EAAE,MAAM,IAAI,CAAC;CACvB;AAwDD,QAAA,MAAM,aAAa,2CAAwC,CAAC;AAC5D,KAAK,aAAa,GAAG,UAAU,CAAC,OAAO,aAAa,CAAC,CAAC;AACtD,eAAe,aAAa,CAAC"}
1
+ {"version":3,"file":"ConfirmDialog.svelte.d.ts","sourceRoot":"","sources":["../../../src/components/feedback/ConfirmDialog.svelte.ts"],"names":[],"mappings":"AAoBA,wCAAwC;AACxC,MAAM,WAAW,KAAK;IACpB,iCAAiC;IACjC,IAAI,EAAE,OAAO,CAAC;IACd,mBAAmB;IACnB,KAAK,EAAE,MAAM,CAAC;IACd,qBAAqB;IACrB,OAAO,EAAE,MAAM,CAAC;IAChB,2BAA2B;IAC3B,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,0BAA0B;IAC1B,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,gDAAgD;IAChD,WAAW,CAAC,EAAE,OAAO,CAAC;IACtB,oCAAoC;IACpC,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB,qCAAqC;IACrC,SAAS,CAAC,EAAE,MAAM,IAAI,CAAC;IACvB,qDAAqD;IACrD,QAAQ,CAAC,EAAE,MAAM,IAAI,CAAC;CACvB;AA2HD,QAAA,MAAM,aAAa,2CAAwC,CAAC;AAC5D,KAAK,aAAa,GAAG,UAAU,CAAC,OAAO,aAAa,CAAC,CAAC;AACtD,eAAe,aAAa,CAAC"}
@@ -114,7 +114,12 @@ function handleKeydown(e: KeyboardEvent) {
114
114
  {/if}
115
115
 
116
116
  {#if error}
117
- <p class="error-message">{error.message}</p>
117
+ <!-- role="alert" (assertive live region) so the failure is announced
118
+ to screen readers when it appears (#1586). aria-busy flips to
119
+ false on error, so without this the overlay changed visually only. -->
120
+ <p class="error-message" role="alert" aria-live="assertive">
121
+ {error.message}
122
+ </p>
118
123
  {/if}
119
124
 
120
125
  {#if dismissible}
@@ -1 +1 @@
1
- {"version":3,"file":"LoadingOverlay.svelte.d.ts","sourceRoot":"","sources":["../../../src/components/feedback/LoadingOverlay.svelte.ts"],"names":[],"mappings":"AAGA;;;;;GAKG;AAEH,yCAAyC;AACzC,MAAM,WAAW,KAAK;IACpB,kCAAkC;IAClC,IAAI,CAAC,EAAE,OAAO,CAAC;IACf,iCAAiC;IACjC,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,kCAAkC;IAClC,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,8BAA8B;IAC9B,KAAK,CAAC,EAAE,MAAM,EAAE,CAAC;IACjB,gCAAgC;IAChC,KAAK,CAAC,EAAE;QAAE,OAAO,EAAE,MAAM,CAAA;KAAE,GAAG,IAAI,CAAC;IACnC,mCAAmC;IACnC,WAAW,CAAC,EAAE,OAAO,CAAC;IACtB,uBAAuB;IACvB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,8BAA8B;IAC9B,SAAS,CAAC,EAAE,MAAM,IAAI,CAAC;CACxB;AAiGD,QAAA,MAAM,cAAc,2CAAwC,CAAC;AAC7D,KAAK,cAAc,GAAG,UAAU,CAAC,OAAO,cAAc,CAAC,CAAC;AACxD,eAAe,cAAc,CAAC"}
1
+ {"version":3,"file":"LoadingOverlay.svelte.d.ts","sourceRoot":"","sources":["../../../src/components/feedback/LoadingOverlay.svelte.ts"],"names":[],"mappings":"AAGA;;;;;GAKG;AAEH,yCAAyC;AACzC,MAAM,WAAW,KAAK;IACpB,kCAAkC;IAClC,IAAI,CAAC,EAAE,OAAO,CAAC;IACf,iCAAiC;IACjC,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,kCAAkC;IAClC,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,8BAA8B;IAC9B,KAAK,CAAC,EAAE,MAAM,EAAE,CAAC;IACjB,gCAAgC;IAChC,KAAK,CAAC,EAAE;QAAE,OAAO,EAAE,MAAM,CAAA;KAAE,GAAG,IAAI,CAAC;IACnC,mCAAmC;IACnC,WAAW,CAAC,EAAE,OAAO,CAAC;IACtB,uBAAuB;IACvB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,8BAA8B;IAC9B,SAAS,CAAC,EAAE,MAAM,IAAI,CAAC;CACxB;AAoGD,QAAA,MAAM,cAAc,2CAAwC,CAAC;AAC7D,KAAK,cAAc,GAAG,UAAU,CAAC,OAAO,cAAc,CAAC,CAAC;AACxD,eAAe,cAAc,CAAC"}
@@ -3,10 +3,12 @@
3
3
  *
4
4
  * ConfirmDialog renders a backdrop `<div role="dialog" aria-modal="true">` only
5
5
  * while `open` is true, with a title, message, and cancel/confirm buttons. It
6
- * exposes `onconfirm`/`oncancel` callbacks; Escape and backdrop clicks both
7
- * route to `oncancel`.
6
+ * exposes `onconfirm`/`oncancel` callbacks; Escape (handled at the document
7
+ * level) and backdrop clicks both route to `oncancel`. On open it manages focus:
8
+ * focus moves to the confirm button, Tab is trapped within the dialog, and focus
9
+ * is restored to the opener on close (#1586).
8
10
  */
9
- import { render, screen } from '@testing-library/svelte';
11
+ import { render, screen, waitFor } from '@testing-library/svelte';
10
12
  import userEvent from '@testing-library/user-event';
11
13
  import { describe, expect, it, vi } from 'vitest';
12
14
  import { expectNoA11yViolations } from '../../../test-support/a11y';
@@ -72,6 +74,56 @@ describe('ConfirmDialog', () => {
72
74
  await userEvent.click(screen.getByRole('heading', { name: 'Delete item' }));
73
75
  expect(oncancel).not.toHaveBeenCalled();
74
76
  });
77
+ describe('focus management (#1586)', () => {
78
+ it('moves focus to the confirm button when opened', async () => {
79
+ render(ConfirmDialog, { props: baseProps });
80
+ await waitFor(() => expect(screen.getByRole('button', { name: 'Confirm' })).toHaveFocus());
81
+ });
82
+ it('traps Tab focus within the dialog', async () => {
83
+ render(ConfirmDialog, { props: baseProps });
84
+ const confirm = screen.getByRole('button', { name: 'Confirm' });
85
+ const cancel = screen.getByRole('button', { name: 'Cancel' });
86
+ await waitFor(() => expect(confirm).toHaveFocus());
87
+ // Tab from the last focusable (confirm) wraps to the first (cancel).
88
+ await userEvent.tab();
89
+ expect(cancel).toHaveFocus();
90
+ // Shift+Tab from the first focusable (cancel) wraps back to confirm.
91
+ await userEvent.tab({ shift: true });
92
+ expect(confirm).toHaveFocus();
93
+ });
94
+ it('restores focus to the opener when closed', async () => {
95
+ // An external trigger holds focus before the dialog opens.
96
+ const opener = document.createElement('button');
97
+ opener.textContent = 'Open';
98
+ document.body.appendChild(opener);
99
+ opener.focus();
100
+ expect(opener).toHaveFocus();
101
+ const { rerender } = render(ConfirmDialog, { props: baseProps });
102
+ await waitFor(() => expect(screen.getByRole('button', { name: 'Confirm' })).toHaveFocus());
103
+ await rerender({ ...baseProps, open: false });
104
+ expect(opener).toHaveFocus();
105
+ opener.remove();
106
+ });
107
+ it('cancels via Escape even when focus is outside the dialog', async () => {
108
+ const oncancel = vi.fn();
109
+ const outside = document.createElement('input');
110
+ document.body.appendChild(outside);
111
+ render(ConfirmDialog, { props: { ...baseProps, oncancel } });
112
+ // Move focus out of the dialog, then press Escape: the document-level
113
+ // handler still fires because the dialog is open.
114
+ outside.focus();
115
+ await userEvent.keyboard('{Escape}');
116
+ expect(oncancel).toHaveBeenCalledTimes(1);
117
+ outside.remove();
118
+ });
119
+ it('focuses the dialog container when all controls are disabled (loading)', async () => {
120
+ // Both buttons are disabled while loading, so there is no focusable
121
+ // control; focus falls back to the dialog container (tabindex=-1) to stay
122
+ // contained and keep Escape reachable.
123
+ render(ConfirmDialog, { props: { ...baseProps, loading: true } });
124
+ await waitFor(() => expect(screen.getByRole('dialog')).toHaveFocus());
125
+ });
126
+ });
75
127
  it('disables both buttons while loading', () => {
76
128
  render(ConfirmDialog, { props: { ...baseProps, loading: true } });
77
129
  expect(screen.getByRole('button', { name: 'Confirm' })).toBeDisabled();
@@ -52,6 +52,20 @@ describe('LoadingOverlay', () => {
52
52
  expect(dialog).toHaveAttribute('aria-busy', 'false');
53
53
  expect(screen.getByText('Import failed')).toBeInTheDocument();
54
54
  });
55
+ it('announces the error via an assertive alert region (#1586)', () => {
56
+ // aria-busy flips to false on error, so without a live region the failure
57
+ // would change visually only and never reach a screen reader.
58
+ render(LoadingOverlay, {
59
+ props: { show: true, error: { message: 'Import failed' } },
60
+ });
61
+ const alert = screen.getByRole('alert');
62
+ expect(alert).toHaveTextContent('Import failed');
63
+ expect(alert).toHaveAttribute('aria-live', 'assertive');
64
+ });
65
+ it('exposes no alert region while loading without an error (#1586)', () => {
66
+ render(LoadingOverlay, { props: { show: true, message: 'Working' } });
67
+ expect(screen.queryByRole('alert')).toBeNull();
68
+ });
55
69
  it('omits the dismiss button unless dismissible', () => {
56
70
  render(LoadingOverlay, { props: { show: true } });
57
71
  expect(screen.queryByRole('button', { name: 'Continue' })).toBeNull();
@@ -1,5 +1,17 @@
1
1
  <script lang="ts">
2
+ /**
3
+ * RoleSelector — a trigger button and a single-select listbox of roles.
4
+ *
5
+ * Implements keyboard support per the WAI-ARIA listbox pattern, mirroring the
6
+ * roving-focus approach in `ui/Dropdown.svelte`: the trigger opens on
7
+ * ArrowDown/ArrowUp/Enter/Space; the open list moves focus onto the selected
8
+ * (or first) option; ArrowUp/Down/Home/End move roving focus; Enter/Space pick
9
+ * the focused option; Escape closes and refocuses the trigger; Tab closes.
10
+ * Click-outside also dismisses.
11
+ */
12
+
2
13
  import type { Role } from '@happyvertical/smrt-types';
14
+ import { tick } from 'svelte';
3
15
 
4
16
  export interface Props {
5
17
  roles: Role[];
@@ -20,28 +32,98 @@ const {
20
32
  }: Props = $props();
21
33
 
22
34
  let open = $state(false);
35
+ let wrapEl = $state<HTMLElement | null>(null);
36
+ let triggerEl = $state<HTMLButtonElement | null>(null);
37
+ let optionEls = $state<Array<HTMLButtonElement | null>>([]);
23
38
  const selectedRole = $derived(roles.find((r) => r.id === value));
24
39
 
40
+ async function openList() {
41
+ if (disabled || roles.length === 0) return;
42
+ open = true;
43
+ await tick();
44
+ // Focus the selected option if there is one, otherwise the first.
45
+ const selectedIndex = roles.findIndex((r) => r.id === value);
46
+ const target = selectedIndex >= 0 ? selectedIndex : 0;
47
+ optionEls[target]?.focus();
48
+ }
49
+
50
+ function closeList(refocus = true) {
51
+ open = false;
52
+ if (refocus) triggerEl?.focus();
53
+ }
54
+
25
55
  function handleSelect(roleId: string) {
26
56
  onchange(roleId);
27
- open = false;
57
+ closeList();
28
58
  }
29
59
 
30
- function handleKeydown(e: KeyboardEvent) {
31
- if (e.key === 'Escape') {
32
- open = false;
60
+ function focusByOffset(offset: number) {
61
+ if (roles.length === 0) return;
62
+ const current = optionEls.indexOf(
63
+ document.activeElement as HTMLButtonElement | null,
64
+ );
65
+ const from = current < 0 ? 0 : current;
66
+ const next = (from + offset + roles.length) % roles.length;
67
+ optionEls[next]?.focus();
68
+ }
69
+
70
+ function onTriggerKeydown(e: KeyboardEvent) {
71
+ if (e.key === 'ArrowDown' || e.key === 'Enter' || e.key === ' ') {
72
+ e.preventDefault();
73
+ openList();
74
+ } else if (e.key === 'ArrowUp') {
75
+ e.preventDefault();
76
+ openList();
33
77
  }
34
78
  }
35
- </script>
36
79
 
37
- <svelte:window onkeydown={handleKeydown} />
80
+ function onListKeydown(e: KeyboardEvent) {
81
+ switch (e.key) {
82
+ case 'ArrowDown':
83
+ e.preventDefault();
84
+ focusByOffset(1);
85
+ break;
86
+ case 'ArrowUp':
87
+ e.preventDefault();
88
+ focusByOffset(-1);
89
+ break;
90
+ case 'Home':
91
+ e.preventDefault();
92
+ optionEls[0]?.focus();
93
+ break;
94
+ case 'End':
95
+ e.preventDefault();
96
+ optionEls[roles.length - 1]?.focus();
97
+ break;
98
+ case 'Escape':
99
+ e.preventDefault();
100
+ closeList();
101
+ break;
102
+ case 'Tab':
103
+ closeList(false);
104
+ break;
105
+ }
106
+ }
38
107
 
39
- <div class="role-selector" class:disabled>
108
+ // Dismiss on outside click while open.
109
+ $effect(() => {
110
+ if (!open) return;
111
+ const onDocPointer = (event: MouseEvent) => {
112
+ if (wrapEl && !wrapEl.contains(event.target as Node)) closeList(false);
113
+ };
114
+ document.addEventListener('click', onDocPointer, true);
115
+ return () => document.removeEventListener('click', onDocPointer, true);
116
+ });
117
+ </script>
118
+
119
+ <div class="role-selector" class:disabled bind:this={wrapEl}>
40
120
  <button
121
+ bind:this={triggerEl}
41
122
  type="button"
42
123
  class="trigger"
43
124
  class:open
44
- onclick={() => (open = !open)}
125
+ onclick={() => (open ? closeList(false) : openList())}
126
+ onkeydown={onTriggerKeydown}
45
127
  {disabled}
46
128
  aria-haspopup="listbox"
47
129
  aria-expanded={open}
@@ -56,7 +138,7 @@ function handleKeydown(e: KeyboardEvent) {
56
138
  {:else}
57
139
  <span class="placeholder">{placeholder}</span>
58
140
  {/if}
59
- <svg class="chevron" viewBox="0 0 20 20" fill="currentColor">
141
+ <svg class="chevron" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
60
142
  <path
61
143
  fill-rule="evenodd"
62
144
  d="M5.23 7.21a.75.75 0 011.06.02L10 11.168l3.71-3.938a.75.75 0 111.08 1.04l-4.25 4.5a.75.75 0 01-1.08 0l-4.25-4.5a.75.75 0 01.02-1.06z"
@@ -66,14 +148,23 @@ function handleKeydown(e: KeyboardEvent) {
66
148
  </button>
67
149
 
68
150
  {#if open}
69
- <div class="dropdown" role="listbox">
70
- {#each roles as role (role.id)}
151
+ <!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
152
+ <div
153
+ class="dropdown"
154
+ role="listbox"
155
+ tabindex={-1}
156
+ aria-label={placeholder}
157
+ onkeydown={onListKeydown}
158
+ >
159
+ {#each roles as role, i (role.id)}
71
160
  <button
161
+ bind:this={optionEls[i]}
72
162
  type="button"
73
163
  class="option"
74
164
  class:selected={role.id === value}
75
165
  onclick={() => handleSelect(role.id!)}
76
166
  role="option"
167
+ tabindex={-1}
77
168
  aria-selected={role.id === value}
78
169
  >
79
170
  <div class="option-content">
@@ -115,7 +206,7 @@ function handleKeydown(e: KeyboardEvent) {
115
206
  text-align: left;
116
207
  }
117
208
 
118
- .trigger:focus {
209
+ .trigger:focus-visible {
119
210
  outline: none;
120
211
  border-color: var(--smrt-color-primary, #005ac1);
121
212
  box-shadow: 0 0 0 3px color-mix(in srgb, var(--smrt-color-primary, #005ac1) 10%, transparent);
@@ -179,8 +270,10 @@ function handleKeydown(e: KeyboardEvent) {
179
270
  text-align: left;
180
271
  }
181
272
 
182
- .option:hover {
273
+ .option:hover,
274
+ .option:focus-visible {
183
275
  background: var(--smrt-color-surface-container-high, #f3f4f6);
276
+ outline: none;
184
277
  }
185
278
 
186
279
  .option.selected {
@@ -1,3 +1,13 @@
1
+ /**
2
+ * RoleSelector — a trigger button and a single-select listbox of roles.
3
+ *
4
+ * Implements keyboard support per the WAI-ARIA listbox pattern, mirroring the
5
+ * roving-focus approach in `ui/Dropdown.svelte`: the trigger opens on
6
+ * ArrowDown/ArrowUp/Enter/Space; the open list moves focus onto the selected
7
+ * (or first) option; ArrowUp/Down/Home/End move roving focus; Enter/Space pick
8
+ * the focused option; Escape closes and refocuses the trigger; Tab closes.
9
+ * Click-outside also dismisses.
10
+ */
1
11
  import type { Role } from '@happyvertical/smrt-types';
2
12
  export interface Props {
3
13
  roles: Role[];
@@ -1 +1 @@
1
- {"version":3,"file":"RoleSelector.svelte.d.ts","sourceRoot":"","sources":["../../../src/components/roles/RoleSelector.svelte.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,IAAI,EAAE,MAAM,2BAA2B,CAAC;AAGtD,MAAM,WAAW,KAAK;IACpB,KAAK,EAAE,IAAI,EAAE,CAAC;IACd,KAAK,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IACtB,QAAQ,EAAE,CAAC,MAAM,EAAE,MAAM,KAAK,IAAI,CAAC;IACnC,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,eAAe,CAAC,EAAE,OAAO,CAAC;CAC3B;AAuED,QAAA,MAAM,YAAY,2CAAwC,CAAC;AAC3D,KAAK,YAAY,GAAG,UAAU,CAAC,OAAO,YAAY,CAAC,CAAC;AACpD,eAAe,YAAY,CAAC"}
1
+ {"version":3,"file":"RoleSelector.svelte.d.ts","sourceRoot":"","sources":["../../../src/components/roles/RoleSelector.svelte.ts"],"names":[],"mappings":"AAGA;;;;;;;;;GASG;AACH,OAAO,KAAK,EAAE,IAAI,EAAE,MAAM,2BAA2B,CAAC;AAItD,MAAM,WAAW,KAAK;IACpB,KAAK,EAAE,IAAI,EAAE,CAAC;IACd,KAAK,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IACtB,QAAQ,EAAE,CAAC,MAAM,EAAE,MAAM,KAAK,IAAI,CAAC;IACnC,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,eAAe,CAAC,EAAE,OAAO,CAAC;CAC3B;AA+ID,QAAA,MAAM,YAAY,2CAAwC,CAAC;AAC3D,KAAK,YAAY,GAAG,UAAU,CAAC,OAAO,YAAY,CAAC,CAAC;AACpD,eAAe,YAAY,CAAC"}
@@ -0,0 +1,134 @@
1
+ /**
2
+ * Component test for RoleSelector (#1586 remediation, epic #1354).
3
+ *
4
+ * RoleSelector is a single-select listbox: a trigger button (`aria-haspopup`/
5
+ * `aria-expanded`) opens a `role="listbox"` of `role="option"` buttons. This
6
+ * suite covers the keyboard support added to match the WAI-ARIA listbox pattern
7
+ * (mirroring `ui/Dropdown.svelte`): open on Enter/ArrowDown, focus-into-list,
8
+ * Arrow/Home/End roving focus, Enter to select, and Escape to close + refocus
9
+ * the trigger. Axe-cleanliness is asserted for both open and closed states.
10
+ */
11
+ import { render, screen } from '@testing-library/svelte';
12
+ import userEvent from '@testing-library/user-event';
13
+ import { describe, expect, it, vi } from 'vitest';
14
+ import { expectNoA11yViolations } from '../../../test-support/a11y';
15
+ import RoleSelector from '../RoleSelector.svelte';
16
+ function role(id, name, isSystem = false) {
17
+ return {
18
+ id,
19
+ slug: id,
20
+ created_at: null,
21
+ updated_at: null,
22
+ tenantId: null,
23
+ name,
24
+ description: `${name} role`,
25
+ isSystem,
26
+ };
27
+ }
28
+ const ROLES = [
29
+ role('admin', 'Admin', true),
30
+ role('editor', 'Editor'),
31
+ role('viewer', 'Viewer'),
32
+ ];
33
+ describe('RoleSelector', () => {
34
+ it('exposes a collapsed listbox trigger by default', () => {
35
+ render(RoleSelector, { props: { roles: ROLES, onchange: vi.fn() } });
36
+ const trigger = screen.getByRole('button');
37
+ expect(trigger).toHaveAttribute('aria-haspopup', 'listbox');
38
+ expect(trigger).toHaveAttribute('aria-expanded', 'false');
39
+ expect(screen.queryByRole('listbox')).toBeNull();
40
+ });
41
+ it('shows the placeholder when no role is selected', () => {
42
+ render(RoleSelector, {
43
+ props: { roles: ROLES, onchange: vi.fn(), placeholder: 'Pick one' },
44
+ });
45
+ expect(screen.getByText('Pick one')).toBeInTheDocument();
46
+ });
47
+ it('opens via click and exposes the options as a listbox', async () => {
48
+ render(RoleSelector, { props: { roles: ROLES, onchange: vi.fn() } });
49
+ await userEvent.click(screen.getByRole('button'));
50
+ expect(screen.getByRole('listbox')).toBeInTheDocument();
51
+ expect(screen.getAllByRole('option')).toHaveLength(3);
52
+ });
53
+ it('opens on Enter and moves focus onto the first option', async () => {
54
+ render(RoleSelector, { props: { roles: ROLES, onchange: vi.fn() } });
55
+ screen.getByRole('button').focus();
56
+ await userEvent.keyboard('{Enter}');
57
+ const options = screen.getAllByRole('option');
58
+ expect(options[0]).toHaveFocus();
59
+ });
60
+ it('opens on ArrowDown and focuses the already-selected option', async () => {
61
+ render(RoleSelector, {
62
+ props: { roles: ROLES, value: 'viewer', onchange: vi.fn() },
63
+ });
64
+ screen.getByRole('button').focus();
65
+ await userEvent.keyboard('{ArrowDown}');
66
+ const viewer = screen.getByRole('option', { name: /Viewer/ });
67
+ expect(viewer).toHaveFocus();
68
+ });
69
+ it('roves focus with ArrowDown/ArrowUp and wraps at the ends', async () => {
70
+ render(RoleSelector, { props: { roles: ROLES, onchange: vi.fn() } });
71
+ screen.getByRole('button').focus();
72
+ await userEvent.keyboard('{Enter}'); // focuses option 0 (Admin)
73
+ const options = screen.getAllByRole('option');
74
+ await userEvent.keyboard('{ArrowDown}');
75
+ expect(options[1]).toHaveFocus();
76
+ await userEvent.keyboard('{ArrowDown}');
77
+ expect(options[2]).toHaveFocus();
78
+ await userEvent.keyboard('{ArrowDown}'); // wraps to first
79
+ expect(options[0]).toHaveFocus();
80
+ await userEvent.keyboard('{ArrowUp}'); // wraps to last
81
+ expect(options[2]).toHaveFocus();
82
+ });
83
+ it('jumps to the first/last option with Home/End', async () => {
84
+ render(RoleSelector, { props: { roles: ROLES, onchange: vi.fn() } });
85
+ screen.getByRole('button').focus();
86
+ await userEvent.keyboard('{Enter}');
87
+ const options = screen.getAllByRole('option');
88
+ await userEvent.keyboard('{End}');
89
+ expect(options[2]).toHaveFocus();
90
+ await userEvent.keyboard('{Home}');
91
+ expect(options[0]).toHaveFocus();
92
+ });
93
+ it('selects the focused option with Enter and fires onchange', async () => {
94
+ const onchange = vi.fn();
95
+ render(RoleSelector, { props: { roles: ROLES, onchange } });
96
+ screen.getByRole('button').focus();
97
+ await userEvent.keyboard('{Enter}'); // open, focus Admin
98
+ await userEvent.keyboard('{ArrowDown}'); // focus Editor
99
+ await userEvent.keyboard('{Enter}'); // select Editor
100
+ expect(onchange).toHaveBeenCalledWith('editor');
101
+ expect(screen.queryByRole('listbox')).toBeNull();
102
+ });
103
+ it('closes on Escape and restores focus to the trigger', async () => {
104
+ render(RoleSelector, { props: { roles: ROLES, onchange: vi.fn() } });
105
+ const trigger = screen.getByRole('button');
106
+ trigger.focus();
107
+ await userEvent.keyboard('{Enter}');
108
+ expect(screen.getByRole('listbox')).toBeInTheDocument();
109
+ await userEvent.keyboard('{Escape}');
110
+ expect(screen.queryByRole('listbox')).toBeNull();
111
+ expect(trigger).toHaveFocus();
112
+ });
113
+ it('does not open when disabled', async () => {
114
+ render(RoleSelector, {
115
+ props: { roles: ROLES, onchange: vi.fn(), disabled: true },
116
+ });
117
+ const trigger = screen.getByRole('button');
118
+ await userEvent.click(trigger);
119
+ expect(screen.queryByRole('listbox')).toBeNull();
120
+ });
121
+ it('is axe-clean when closed', async () => {
122
+ const { container } = render(RoleSelector, {
123
+ props: { roles: ROLES, value: 'admin', onchange: vi.fn() },
124
+ });
125
+ await expectNoA11yViolations(container);
126
+ });
127
+ it('is axe-clean when open', async () => {
128
+ const { container } = render(RoleSelector, {
129
+ props: { roles: ROLES, onchange: vi.fn(), showDescription: true },
130
+ });
131
+ await userEvent.click(screen.getByRole('button'));
132
+ await expectNoA11yViolations(container);
133
+ });
134
+ });
@@ -139,8 +139,14 @@ const linkProps = $derived(() => {
139
139
  color: var(--smrt-color-on-primary);
140
140
  }
141
141
 
142
+ /* M3 hover state layer: 8% of the on-color blended over the base, so the
143
+ button visibly darkens on hover instead of repeating its base color. */
142
144
  .primary:hover:not(:disabled):not(.disabled) {
143
- background: var(--smrt-color-primary);
145
+ background: color-mix(
146
+ in srgb,
147
+ var(--smrt-color-on-primary) 8%,
148
+ var(--smrt-color-primary)
149
+ );
144
150
  }
145
151
 
146
152
  .secondary {
@@ -168,7 +174,11 @@ const linkProps = $derived(() => {
168
174
  }
169
175
 
170
176
  .danger:hover:not(:disabled):not(.disabled) {
171
- background: var(--smrt-color-error);
177
+ background: color-mix(
178
+ in srgb,
179
+ var(--smrt-color-on-error) 8%,
180
+ var(--smrt-color-error)
181
+ );
172
182
  }
173
183
 
174
184
  /* Full width */
@@ -136,7 +136,9 @@ const isLastPage = $derived(currentPage === totalPages);
136
136
  {/if}
137
137
 
138
138
  <ul class="page-numbers">
139
- {#each pageNumbers as page}
139
+ <!-- Key by index: getPageNumbers can emit two 'ellipsis' sentinels, whose
140
+ value-identity would collide under a value-based key (#1586). -->
141
+ {#each pageNumbers as page, i (i)}
140
142
  {#if page === 'ellipsis'}
141
143
  <li class="page-numbers__item">
142
144
  <span class="ellipsis" aria-hidden="true">&hellip;</span>
@@ -1 +1 @@
1
- {"version":3,"file":"Pagination.svelte.d.ts","sourceRoot":"","sources":["../../../src/components/ui/Pagination.svelte.ts"],"names":[],"mappings":"AAkBA,qCAAqC;AACrC,MAAM,WAAW,KAAK;IACpB,oCAAoC;IACpC,WAAW,EAAE,MAAM,CAAC;IACpB,4BAA4B;IAC5B,UAAU,EAAE,MAAM,CAAC;IACnB,iDAAiD;IACjD,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,oFAAoF;IACpF,YAAY,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAC;IACtC,0CAA0C;IAC1C,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,mCAAmC;IACnC,aAAa,CAAC,EAAE,OAAO,CAAC;IACxB,6CAA6C;IAC7C,UAAU,CAAC,EAAE,MAAM,CAAC;CACrB;AAuKD,QAAA,MAAM,UAAU,2CAAwC,CAAC;AACzD,KAAK,UAAU,GAAG,UAAU,CAAC,OAAO,UAAU,CAAC,CAAC;AAChD,eAAe,UAAU,CAAC"}
1
+ {"version":3,"file":"Pagination.svelte.d.ts","sourceRoot":"","sources":["../../../src/components/ui/Pagination.svelte.ts"],"names":[],"mappings":"AAkBA,qCAAqC;AACrC,MAAM,WAAW,KAAK;IACpB,oCAAoC;IACpC,WAAW,EAAE,MAAM,CAAC;IACpB,4BAA4B;IAC5B,UAAU,EAAE,MAAM,CAAC;IACnB,iDAAiD;IACjD,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,oFAAoF;IACpF,YAAY,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAC;IACtC,0CAA0C;IAC1C,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,mCAAmC;IACnC,aAAa,CAAC,EAAE,OAAO,CAAC;IACxB,6CAA6C;IAC7C,UAAU,CAAC,EAAE,MAAM,CAAC;CACrB;AAwKD,QAAA,MAAM,UAAU,2CAAwC,CAAC;AACzD,KAAK,UAAU,GAAG,UAAU,CAAC,OAAO,UAAU,CAAC,CAAC;AAChD,eAAe,UAAU,CAAC"}
@@ -84,6 +84,20 @@ describe('Pagination', () => {
84
84
  const page2 = screen.getByRole('link', { name: 'Go to page 2' });
85
85
  expect(page2).toHaveAttribute('href', '/articles/page/2');
86
86
  });
87
+ it('renders both ellipses on a deep middle page without key collision (#1586)', async () => {
88
+ // currentPage in the middle of many pages emits two 'ellipsis' sentinels.
89
+ // Keying the {#each} by value would collide; keying by index keeps both.
90
+ const onPageChange = vi.fn();
91
+ const { container, rerender } = render(Pagination, {
92
+ props: { currentPage: 10, totalPages: 20, onPageChange },
93
+ });
94
+ expect(container.querySelectorAll('.ellipsis')).toHaveLength(2);
95
+ // Re-render to an adjacent deep page: still two ellipses, still one current.
96
+ await rerender({ currentPage: 11, totalPages: 20, onPageChange });
97
+ expect(container.querySelectorAll('.ellipsis')).toHaveLength(2);
98
+ expect(container.querySelectorAll('[aria-current="page"]')).toHaveLength(1);
99
+ expect(screen.getByText('11')).toHaveAttribute('aria-current', 'page');
100
+ });
87
101
  it('is axe-clean in callback mode on a middle page', async () => {
88
102
  const { container } = render(Pagination, {
89
103
  props: { currentPage: 3, totalPages: 10, onPageChange: vi.fn() },