@happyvertical/smrt-ui 0.30.0 → 0.31.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.
- package/dist/actions/__tests__/ripple.test.js +55 -1
- package/dist/actions/ripple.d.ts +9 -1
- package/dist/actions/ripple.d.ts.map +1 -1
- package/dist/actions/ripple.js +56 -7
- package/dist/components/display/ConfidenceBadge.svelte +14 -10
- package/dist/components/display/ConfidenceBadge.svelte.d.ts.map +1 -1
- package/dist/components/display/StatusBadge.svelte +81 -75
- package/dist/components/display/StatusBadge.svelte.d.ts.map +1 -1
- package/dist/components/display/__tests__/ConfidenceBadge.test.js +13 -0
- package/dist/components/display/__tests__/StatusBadge.test.js +17 -2
- package/dist/components/feedback/ConfirmDialog.svelte +78 -2
- package/dist/components/feedback/ConfirmDialog.svelte.d.ts.map +1 -1
- package/dist/components/feedback/LoadingOverlay.svelte +6 -1
- package/dist/components/feedback/LoadingOverlay.svelte.d.ts.map +1 -1
- package/dist/components/feedback/__tests__/ConfirmDialog.test.js +55 -3
- package/dist/components/feedback/__tests__/LoadingOverlay.test.js +14 -0
- package/dist/components/roles/RoleSelector.svelte +106 -13
- package/dist/components/roles/RoleSelector.svelte.d.ts +10 -0
- package/dist/components/roles/RoleSelector.svelte.d.ts.map +1 -1
- package/dist/components/roles/__tests__/RoleSelector.test.js +134 -0
- package/dist/components/ui/Button.svelte +12 -2
- package/dist/components/ui/Pagination.svelte +3 -1
- package/dist/components/ui/Pagination.svelte.d.ts.map +1 -1
- package/dist/components/ui/__tests__/Pagination.test.js +14 -0
- package/dist/themes/__tests__/create-theme.test.js +79 -0
- package/dist/themes/__tests__/css-generator.test.js +55 -0
- package/dist/themes/create-theme.d.ts.map +1 -1
- package/dist/themes/create-theme.js +29 -22
- package/dist/themes/css-generator.d.ts.map +1 -1
- package/dist/themes/css-generator.js +4 -1
- package/dist/themes/studio/index.d.ts.map +1 -1
- package/dist/themes/studio/index.js +19 -1
- package/dist/themes/types.d.ts +8 -1
- package/dist/themes/types.d.ts.map +1 -1
- package/dist/utils/theme/__tests__/color.test.js +17 -0
- package/dist/utils/theme/color.d.ts +11 -0
- package/dist/utils/theme/color.d.ts.map +1 -1
- package/dist/utils/theme/color.js +39 -4
- 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
|
|
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":"
|
|
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
|
-
|
|
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;
|
|
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
|
|
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
|
-
|
|
57
|
+
closeList();
|
|
28
58
|
}
|
|
29
59
|
|
|
30
|
-
function
|
|
31
|
-
if (
|
|
32
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
70
|
-
|
|
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;
|
|
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:
|
|
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:
|
|
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
|
-
|
|
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">…</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;
|
|
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() },
|