@classic-homes/theme-svelte 0.1.52 → 0.1.54
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/__tests__/a11y/Alert.a11y.test.js +84 -0
- package/dist/__tests__/a11y/AlertDialog.a11y.test.js +126 -0
- package/dist/__tests__/a11y/Button.a11y.test.js +66 -0
- package/dist/__tests__/a11y/Card.a11y.test.js +77 -0
- package/dist/__tests__/a11y/Combobox.a11y.test.js +209 -0
- package/dist/__tests__/a11y/Dialog.a11y.test.js +103 -0
- package/dist/__tests__/a11y/DropdownMenu.a11y.test.js +104 -0
- package/dist/__tests__/a11y/FileUpload.a11y.test.js +158 -0
- package/dist/__tests__/a11y/Icon.a11y.test.js +119 -0
- package/dist/__tests__/a11y/Input.a11y.test.js +96 -0
- package/dist/__tests__/a11y/LiveRegion.a11y.test.js +102 -0
- package/dist/__tests__/a11y/Switch.a11y.test.js +95 -0
- package/dist/__tests__/a11y/Tabs.a11y.test.js +97 -0
- package/dist/__tests__/a11y/Textarea.a11y.test.js +111 -0
- package/dist/__tests__/setup.js +6 -0
- package/dist/lib/components/Alert.svelte +1 -1
- package/dist/lib/components/AlertDialog.svelte +1 -1
- package/dist/lib/components/AlertDialog.svelte.d.ts +2 -2
- package/dist/lib/components/Badge.svelte +49 -12
- package/dist/lib/components/Badge.svelte.d.ts +9 -0
- package/dist/lib/components/Button.svelte +14 -2
- package/dist/lib/components/CardTitle.svelte +9 -3
- package/dist/lib/components/CardTitle.svelte.d.ts +2 -0
- package/dist/lib/components/Combobox.svelte +19 -2
- package/dist/lib/components/DataPanelHeader.svelte +4 -4
- package/dist/lib/components/DataPanelTab.svelte +1 -1
- package/dist/lib/components/DataTable.svelte +7 -7
- package/dist/lib/components/DataTable.svelte.d.ts +5 -5
- package/dist/lib/components/DateTimePicker.svelte +7 -4
- package/dist/lib/components/Dialog.svelte +6 -6
- package/dist/lib/components/DropdownMenu.svelte +14 -10
- package/dist/lib/components/FileUpload.svelte +5 -10
- package/dist/lib/components/FormField.svelte +7 -1
- package/dist/lib/components/HeaderSearch.svelte +9 -9
- package/dist/lib/components/Icon.svelte +5 -5
- package/dist/lib/components/Input.svelte +32 -0
- package/dist/lib/components/Input.svelte.d.ts +8 -0
- package/dist/lib/components/LiveRegion.svelte +61 -0
- package/dist/lib/components/LiveRegion.svelte.d.ts +18 -0
- package/dist/lib/components/LoadingOverlay.svelte +148 -0
- package/dist/lib/components/LoadingOverlay.svelte.d.ts +29 -0
- package/dist/lib/components/LoadingPage.svelte +86 -0
- package/dist/lib/components/LoadingPage.svelte.d.ts +20 -0
- package/dist/lib/components/LoadingSection.svelte +54 -0
- package/dist/lib/components/LoadingSection.svelte.d.ts +25 -0
- package/dist/lib/components/MultiSelect.svelte +23 -2
- package/dist/lib/components/NumberInput.svelte +4 -4
- package/dist/lib/components/OTPInput.svelte +14 -0
- package/dist/lib/components/PasswordInput.svelte +14 -1
- package/dist/lib/components/PasswordInput.svelte.d.ts +4 -0
- package/dist/lib/components/ProgressBar.svelte +91 -0
- package/dist/lib/components/ProgressBar.svelte.d.ts +18 -0
- package/dist/lib/components/RadioGroup.svelte +5 -5
- package/dist/lib/components/Select.svelte +13 -1
- package/dist/lib/components/Signature.svelte +5 -5
- package/dist/lib/components/Skeleton.svelte +13 -3
- package/dist/lib/components/Skeleton.svelte.d.ts +2 -0
- package/dist/lib/components/SkipLink.svelte +67 -0
- package/dist/lib/components/SkipLink.svelte.d.ts +25 -0
- package/dist/lib/components/Slider.svelte +6 -2
- package/dist/lib/components/Switch.svelte +8 -0
- package/dist/lib/components/Switch.svelte.d.ts +4 -0
- package/dist/lib/components/Tabs.svelte +15 -5
- package/dist/lib/components/Tabs.svelte.d.ts +6 -0
- package/dist/lib/components/Textarea.svelte +19 -0
- package/dist/lib/components/Textarea.svelte.d.ts +8 -0
- package/dist/lib/components/Toast.svelte +1 -1
- package/dist/lib/components/layout/AuthLayout.svelte +1 -1
- package/dist/lib/components/layout/DashboardLayout.svelte +1 -1
- package/dist/lib/components/layout/Footer.svelte +1 -1
- package/dist/lib/components/layout/PublicLayout.svelte +2 -2
- package/dist/lib/components/layout/QuickLinks.svelte +3 -3
- package/dist/lib/components/layout/Sidebar.svelte +1 -1
- package/dist/lib/components/layout/sidebar/SidebarFlyout.svelte +1 -2
- package/dist/lib/components/layout/sidebar/SidebarNavItem.svelte +20 -21
- package/dist/lib/components/layout/sidebar/SidebarSection.svelte +2 -2
- package/dist/lib/components/notifications/NotificationFilters.svelte +1 -1
- package/dist/lib/components/notifications/NotificationItem.svelte +2 -1
- package/dist/lib/composables/index.d.ts +8 -0
- package/dist/lib/composables/index.js +9 -0
- package/dist/lib/composables/useAnnounce.svelte.d.ts +65 -0
- package/dist/lib/composables/useAnnounce.svelte.js +76 -0
- package/dist/lib/composables/useArrowNavigation.svelte.d.ts +42 -0
- package/dist/lib/composables/useArrowNavigation.svelte.js +138 -0
- package/dist/lib/composables/useClickOutside.svelte.d.ts +88 -0
- package/dist/lib/composables/useClickOutside.svelte.js +100 -0
- package/dist/lib/composables/useEscapeKey.svelte.d.ts +48 -0
- package/dist/lib/composables/useEscapeKey.svelte.js +50 -0
- package/dist/lib/composables/useFocusRef.svelte.d.ts +46 -0
- package/dist/lib/composables/useFocusRef.svelte.js +92 -0
- package/dist/lib/composables/useFocusTrap.svelte.d.ts +42 -0
- package/dist/lib/composables/useFocusTrap.svelte.js +136 -0
- package/dist/lib/composables/useReducedMotion.svelte.d.ts +47 -0
- package/dist/lib/composables/useReducedMotion.svelte.js +67 -0
- package/dist/lib/composables/useRovingTabindex.svelte.d.ts +54 -0
- package/dist/lib/composables/useRovingTabindex.svelte.js +128 -0
- package/dist/lib/index.d.ts +7 -1
- package/dist/lib/index.js +11 -1
- package/dist/lib/stores/theme.svelte.d.ts +1 -1
- package/dist/lib/utils.d.ts +4 -2
- package/dist/lib/utils.js +5 -11
- package/package.json +11 -5
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { render, screen } from '@testing-library/svelte';
|
|
3
|
+
import userEvent from '@testing-library/user-event';
|
|
4
|
+
import { axe } from 'vitest-axe';
|
|
5
|
+
import Tabs from '../../lib/components/Tabs.svelte';
|
|
6
|
+
describe('Tabs accessibility', () => {
|
|
7
|
+
const defaultTabs = [
|
|
8
|
+
{ id: 'tab1', label: 'Tab 1' },
|
|
9
|
+
{ id: 'tab2', label: 'Tab 2' },
|
|
10
|
+
{ id: 'tab3', label: 'Tab 3' },
|
|
11
|
+
];
|
|
12
|
+
it('should have no axe violations', async () => {
|
|
13
|
+
const { container } = render(Tabs, {
|
|
14
|
+
props: { tabs: defaultTabs, label: 'Test tabs', id: 'test-tabs' },
|
|
15
|
+
});
|
|
16
|
+
// Add tab panels so aria-controls references are valid
|
|
17
|
+
defaultTabs.forEach((tab, i) => {
|
|
18
|
+
const panel = document.createElement('div');
|
|
19
|
+
panel.id = `test-tabs-panel-${tab.id}`;
|
|
20
|
+
panel.setAttribute('role', 'tabpanel');
|
|
21
|
+
panel.setAttribute('aria-labelledby', `test-tabs-tab-${tab.id}`);
|
|
22
|
+
panel.textContent = `Panel ${i + 1}`;
|
|
23
|
+
if (i > 0)
|
|
24
|
+
panel.hidden = true;
|
|
25
|
+
container.appendChild(panel);
|
|
26
|
+
});
|
|
27
|
+
const results = await axe(container);
|
|
28
|
+
expect(results).toHaveNoViolations();
|
|
29
|
+
});
|
|
30
|
+
it('should have proper tablist role', () => {
|
|
31
|
+
render(Tabs, {
|
|
32
|
+
props: { tabs: defaultTabs, label: 'Test tabs' },
|
|
33
|
+
});
|
|
34
|
+
const tablist = screen.getByRole('tablist');
|
|
35
|
+
expect(tablist).toBeInTheDocument();
|
|
36
|
+
});
|
|
37
|
+
it('should have proper tab roles', () => {
|
|
38
|
+
render(Tabs, {
|
|
39
|
+
props: { tabs: defaultTabs, label: 'Test tabs' },
|
|
40
|
+
});
|
|
41
|
+
const tabs = screen.getAllByRole('tab');
|
|
42
|
+
expect(tabs).toHaveLength(3);
|
|
43
|
+
});
|
|
44
|
+
it('should have accessible label on tablist', () => {
|
|
45
|
+
render(Tabs, {
|
|
46
|
+
props: { tabs: defaultTabs, label: 'Account settings' },
|
|
47
|
+
});
|
|
48
|
+
const tablist = screen.getByRole('tablist');
|
|
49
|
+
expect(tablist).toHaveAttribute('aria-label', 'Account settings');
|
|
50
|
+
});
|
|
51
|
+
it('should indicate selected tab with aria-selected', () => {
|
|
52
|
+
render(Tabs, {
|
|
53
|
+
props: { tabs: defaultTabs, value: 'tab1', label: 'Test tabs' },
|
|
54
|
+
});
|
|
55
|
+
const tabs = screen.getAllByRole('tab');
|
|
56
|
+
expect(tabs[0]).toHaveAttribute('aria-selected', 'true');
|
|
57
|
+
expect(tabs[1]).toHaveAttribute('aria-selected', 'false');
|
|
58
|
+
expect(tabs[2]).toHaveAttribute('aria-selected', 'false');
|
|
59
|
+
});
|
|
60
|
+
it('should navigate with arrow keys', async () => {
|
|
61
|
+
render(Tabs, {
|
|
62
|
+
props: { tabs: defaultTabs, label: 'Test tabs' },
|
|
63
|
+
});
|
|
64
|
+
const tabs = screen.getAllByRole('tab');
|
|
65
|
+
// Focus first tab
|
|
66
|
+
tabs[0].focus();
|
|
67
|
+
expect(tabs[0]).toHaveFocus();
|
|
68
|
+
// Navigate right
|
|
69
|
+
await userEvent.keyboard('{ArrowRight}');
|
|
70
|
+
expect(tabs[1]).toHaveFocus();
|
|
71
|
+
// Navigate right again
|
|
72
|
+
await userEvent.keyboard('{ArrowRight}');
|
|
73
|
+
expect(tabs[2]).toHaveFocus();
|
|
74
|
+
// Navigate left
|
|
75
|
+
await userEvent.keyboard('{ArrowLeft}');
|
|
76
|
+
expect(tabs[1]).toHaveFocus();
|
|
77
|
+
});
|
|
78
|
+
it('should activate tab with click', async () => {
|
|
79
|
+
const user = userEvent.setup();
|
|
80
|
+
render(Tabs, {
|
|
81
|
+
props: { tabs: defaultTabs, value: 'tab1', label: 'Test tabs' },
|
|
82
|
+
});
|
|
83
|
+
const tabs = screen.getAllByRole('tab');
|
|
84
|
+
// Click second tab
|
|
85
|
+
await user.click(tabs[1]);
|
|
86
|
+
expect(tabs[1]).toHaveAttribute('aria-selected', 'true');
|
|
87
|
+
});
|
|
88
|
+
it('should have aria-controls linking tab to panel', () => {
|
|
89
|
+
render(Tabs, {
|
|
90
|
+
props: { tabs: defaultTabs, label: 'Test tabs' },
|
|
91
|
+
});
|
|
92
|
+
const tabs = screen.getAllByRole('tab');
|
|
93
|
+
// Selected tab should have aria-controls
|
|
94
|
+
const selectedTab = tabs.find((tab) => tab.getAttribute('aria-selected') === 'true');
|
|
95
|
+
expect(selectedTab).toHaveAttribute('aria-controls');
|
|
96
|
+
});
|
|
97
|
+
});
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { render, screen } from '@testing-library/svelte';
|
|
3
|
+
import userEvent from '@testing-library/user-event';
|
|
4
|
+
import { axe } from 'vitest-axe';
|
|
5
|
+
import Textarea from '../../lib/components/Textarea.svelte';
|
|
6
|
+
describe('Textarea accessibility', () => {
|
|
7
|
+
it('should have no axe violations with aria-label', async () => {
|
|
8
|
+
const { container } = render(Textarea, {
|
|
9
|
+
props: { 'aria-label': 'Description' },
|
|
10
|
+
});
|
|
11
|
+
const results = await axe(container);
|
|
12
|
+
expect(results).toHaveNoViolations();
|
|
13
|
+
});
|
|
14
|
+
it('should have no axe violations with aria-invalid', async () => {
|
|
15
|
+
const { container } = render(Textarea, {
|
|
16
|
+
props: {
|
|
17
|
+
'aria-label': 'Description',
|
|
18
|
+
'aria-invalid': true,
|
|
19
|
+
'aria-describedby': 'error',
|
|
20
|
+
},
|
|
21
|
+
});
|
|
22
|
+
const errorSpan = document.createElement('span');
|
|
23
|
+
errorSpan.id = 'error';
|
|
24
|
+
errorSpan.textContent = 'Description is required';
|
|
25
|
+
container.appendChild(errorSpan);
|
|
26
|
+
const results = await axe(container);
|
|
27
|
+
expect(results).toHaveNoViolations();
|
|
28
|
+
});
|
|
29
|
+
it('should be focusable', async () => {
|
|
30
|
+
render(Textarea, {
|
|
31
|
+
props: { 'aria-label': 'Test textarea' },
|
|
32
|
+
});
|
|
33
|
+
const textarea = screen.getByRole('textbox');
|
|
34
|
+
await userEvent.tab();
|
|
35
|
+
expect(textarea).toHaveFocus();
|
|
36
|
+
});
|
|
37
|
+
it('should support aria-describedby for help text', () => {
|
|
38
|
+
const { container } = render(Textarea, {
|
|
39
|
+
props: { 'aria-label': 'Comments', 'aria-describedby': 'help' },
|
|
40
|
+
});
|
|
41
|
+
const helpSpan = document.createElement('span');
|
|
42
|
+
helpSpan.id = 'help';
|
|
43
|
+
helpSpan.textContent = 'Enter your comments here';
|
|
44
|
+
container.appendChild(helpSpan);
|
|
45
|
+
const textarea = screen.getByRole('textbox');
|
|
46
|
+
expect(textarea).toHaveAttribute('aria-describedby', 'help');
|
|
47
|
+
});
|
|
48
|
+
it('should support aria-labelledby', () => {
|
|
49
|
+
const { container } = render(Textarea, {
|
|
50
|
+
props: { 'aria-labelledby': 'label' },
|
|
51
|
+
});
|
|
52
|
+
const labelSpan = document.createElement('span');
|
|
53
|
+
labelSpan.id = 'label';
|
|
54
|
+
labelSpan.textContent = 'Custom Label';
|
|
55
|
+
container.insertBefore(labelSpan, container.firstChild);
|
|
56
|
+
const textarea = screen.getByRole('textbox');
|
|
57
|
+
expect(textarea).toHaveAttribute('aria-labelledby', 'label');
|
|
58
|
+
});
|
|
59
|
+
it('should apply error styling when aria-invalid is true', () => {
|
|
60
|
+
render(Textarea, {
|
|
61
|
+
props: { 'aria-label': 'Description', 'aria-invalid': true },
|
|
62
|
+
});
|
|
63
|
+
const textarea = screen.getByRole('textbox');
|
|
64
|
+
expect(textarea).toHaveAttribute('aria-invalid', 'true');
|
|
65
|
+
expect(textarea.className).toContain('border-destructive');
|
|
66
|
+
});
|
|
67
|
+
it('should apply error styling when aria-invalid is "true" string', () => {
|
|
68
|
+
render(Textarea, {
|
|
69
|
+
props: { 'aria-label': 'Description', 'aria-invalid': 'true' },
|
|
70
|
+
});
|
|
71
|
+
const textarea = screen.getByRole('textbox');
|
|
72
|
+
expect(textarea.className).toContain('border-destructive');
|
|
73
|
+
});
|
|
74
|
+
it('should not apply error styling when aria-invalid is false', () => {
|
|
75
|
+
render(Textarea, {
|
|
76
|
+
props: { 'aria-label': 'Description', 'aria-invalid': false },
|
|
77
|
+
});
|
|
78
|
+
const textarea = screen.getByRole('textbox');
|
|
79
|
+
expect(textarea.className).not.toContain('border-destructive');
|
|
80
|
+
});
|
|
81
|
+
it('should be disabled when disabled prop is true', () => {
|
|
82
|
+
render(Textarea, {
|
|
83
|
+
props: { 'aria-label': 'Disabled textarea', disabled: true },
|
|
84
|
+
});
|
|
85
|
+
const textarea = screen.getByRole('textbox');
|
|
86
|
+
expect(textarea).toBeDisabled();
|
|
87
|
+
});
|
|
88
|
+
it('should support required attribute', () => {
|
|
89
|
+
render(Textarea, {
|
|
90
|
+
props: { 'aria-label': 'Required field', required: true },
|
|
91
|
+
});
|
|
92
|
+
const textarea = screen.getByRole('textbox');
|
|
93
|
+
expect(textarea).toBeRequired();
|
|
94
|
+
});
|
|
95
|
+
it('should support readonly attribute', () => {
|
|
96
|
+
render(Textarea, {
|
|
97
|
+
props: { 'aria-label': 'Readonly field', readonly: true },
|
|
98
|
+
});
|
|
99
|
+
const textarea = screen.getByRole('textbox');
|
|
100
|
+
expect(textarea).toHaveAttribute('readonly');
|
|
101
|
+
});
|
|
102
|
+
it('should have visible focus indicator', async () => {
|
|
103
|
+
render(Textarea, {
|
|
104
|
+
props: { 'aria-label': 'Test textarea' },
|
|
105
|
+
});
|
|
106
|
+
const textarea = screen.getByRole('textbox');
|
|
107
|
+
await userEvent.tab();
|
|
108
|
+
expect(textarea).toHaveFocus();
|
|
109
|
+
// Focus styles are applied via focus-visible classes
|
|
110
|
+
});
|
|
111
|
+
});
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
import { tv, type VariantProps } from 'tailwind-variants';
|
|
6
6
|
|
|
7
7
|
const buttonVariants = tv({
|
|
8
|
-
base: 'inline-flex items-center justify-center rounded-md text-sm font-medium
|
|
8
|
+
base: 'inline-flex items-center justify-center rounded-md text-sm font-medium focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50',
|
|
9
9
|
variants: {
|
|
10
10
|
variant: {
|
|
11
11
|
default: 'bg-primary text-primary-foreground hover:bg-primary-hover',
|
|
@@ -21,7 +21,7 @@ declare const AlertDialog: import("svelte").Component<{
|
|
|
21
21
|
outline: string;
|
|
22
22
|
ghost: string;
|
|
23
23
|
};
|
|
24
|
-
}, undefined, "inline-flex items-center justify-center rounded-md text-sm font-medium
|
|
24
|
+
}, undefined, "inline-flex items-center justify-center rounded-md text-sm font-medium focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50", import("tailwind-variants/dist/config.js").TVConfig<{
|
|
25
25
|
variant: {
|
|
26
26
|
default: string;
|
|
27
27
|
destructive: string;
|
|
@@ -49,7 +49,7 @@ declare const AlertDialog: import("svelte").Component<{
|
|
|
49
49
|
outline: string;
|
|
50
50
|
ghost: string;
|
|
51
51
|
};
|
|
52
|
-
}, undefined, "inline-flex items-center justify-center rounded-md text-sm font-medium
|
|
52
|
+
}, undefined, "inline-flex items-center justify-center rounded-md text-sm font-medium focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50", import("tailwind-variants/dist/config.js").TVConfig<{
|
|
53
53
|
variant: {
|
|
54
54
|
default: string;
|
|
55
55
|
destructive: string;
|
|
@@ -4,16 +4,16 @@
|
|
|
4
4
|
import { tv, type VariantProps } from 'tailwind-variants';
|
|
5
5
|
|
|
6
6
|
const badgeVariants = tv({
|
|
7
|
-
base: 'inline-flex items-center justify-center rounded-md border font-medium w-fit whitespace-nowrap shrink-0 gap-1
|
|
7
|
+
base: 'inline-flex items-center justify-center rounded-md border font-medium w-fit whitespace-nowrap shrink-0 gap-1',
|
|
8
8
|
variants: {
|
|
9
9
|
variant: {
|
|
10
10
|
default: 'border-transparent bg-primary text-primary-foreground',
|
|
11
11
|
secondary: 'border-transparent bg-secondary text-secondary-foreground',
|
|
12
|
-
destructive: 'border-transparent bg-destructive text-
|
|
12
|
+
destructive: 'border-transparent bg-destructive text-destructive-foreground',
|
|
13
13
|
outline: 'text-foreground',
|
|
14
|
-
success: 'border-transparent bg-
|
|
15
|
-
warning: 'border-transparent bg-
|
|
16
|
-
info: 'border-transparent bg-
|
|
14
|
+
success: 'border-transparent bg-success text-success-foreground',
|
|
15
|
+
warning: 'border-transparent bg-warning text-warning-foreground',
|
|
16
|
+
info: 'border-transparent bg-info text-info-foreground',
|
|
17
17
|
},
|
|
18
18
|
size: {
|
|
19
19
|
default: 'px-2 py-0.5 text-xs',
|
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
dot: 'h-5 w-5 p-0 text-xs',
|
|
22
22
|
},
|
|
23
23
|
clickable: {
|
|
24
|
-
true: 'cursor-pointer',
|
|
24
|
+
true: 'cursor-pointer focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2',
|
|
25
25
|
false: '',
|
|
26
26
|
},
|
|
27
27
|
},
|
|
@@ -34,9 +34,9 @@
|
|
|
34
34
|
clickable: true,
|
|
35
35
|
class: 'hover:bg-accent-hover hover:text-accent-foreground',
|
|
36
36
|
},
|
|
37
|
-
{ variant: 'success', clickable: true, class: 'hover:bg-
|
|
38
|
-
{ variant: 'warning', clickable: true, class: 'hover:bg-
|
|
39
|
-
{ variant: 'info', clickable: true, class: 'hover:bg-
|
|
37
|
+
{ variant: 'success', clickable: true, class: 'hover:bg-success/80' },
|
|
38
|
+
{ variant: 'warning', clickable: true, class: 'hover:bg-warning/80' },
|
|
39
|
+
{ variant: 'info', clickable: true, class: 'hover:bg-info/80' },
|
|
40
40
|
],
|
|
41
41
|
defaultVariants: {
|
|
42
42
|
variant: 'default',
|
|
@@ -51,8 +51,17 @@
|
|
|
51
51
|
variant?: BadgeVariants['variant'];
|
|
52
52
|
size?: BadgeVariants['size'];
|
|
53
53
|
clickable?: boolean;
|
|
54
|
+
/** When true, adds role="status" and aria-live="polite" for dynamic content announcements */
|
|
55
|
+
live?: boolean;
|
|
54
56
|
class?: string;
|
|
55
57
|
children: Snippet;
|
|
58
|
+
onclick?: (e: MouseEvent) => void;
|
|
59
|
+
/**
|
|
60
|
+
* Screen reader-only label providing context for the badge content.
|
|
61
|
+
* Useful for count badges (e.g., "unread messages" for a badge showing "5").
|
|
62
|
+
* @example label="unread notifications"
|
|
63
|
+
*/
|
|
64
|
+
label?: string;
|
|
56
65
|
[key: string]: unknown;
|
|
57
66
|
}
|
|
58
67
|
|
|
@@ -60,14 +69,42 @@
|
|
|
60
69
|
variant = 'default',
|
|
61
70
|
size = 'default',
|
|
62
71
|
clickable = false,
|
|
72
|
+
live = false,
|
|
63
73
|
class: className,
|
|
64
74
|
children,
|
|
75
|
+
onclick,
|
|
76
|
+
label,
|
|
65
77
|
...restProps
|
|
66
78
|
}: Props = $props();
|
|
67
79
|
|
|
68
80
|
const classes = $derived(cn(badgeVariants({ variant, size, clickable }), className));
|
|
81
|
+
|
|
82
|
+
// Determine if badge is interactive (has click handler or clickable prop)
|
|
83
|
+
const isInteractive = $derived(clickable || !!onclick);
|
|
69
84
|
</script>
|
|
70
85
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
86
|
+
{#if isInteractive}
|
|
87
|
+
<button
|
|
88
|
+
type="button"
|
|
89
|
+
class={classes}
|
|
90
|
+
{onclick}
|
|
91
|
+
role={live ? 'status' : undefined}
|
|
92
|
+
aria-live={live ? 'polite' : undefined}
|
|
93
|
+
aria-atomic={live ? 'true' : undefined}
|
|
94
|
+
{...restProps}
|
|
95
|
+
>
|
|
96
|
+
{@render children()}
|
|
97
|
+
{#if label}<span class="sr-only">{label}</span>{/if}
|
|
98
|
+
</button>
|
|
99
|
+
{:else}
|
|
100
|
+
<span
|
|
101
|
+
class={classes}
|
|
102
|
+
role={live ? 'status' : undefined}
|
|
103
|
+
aria-live={live ? 'polite' : undefined}
|
|
104
|
+
aria-atomic={live ? 'true' : undefined}
|
|
105
|
+
{...restProps}
|
|
106
|
+
>
|
|
107
|
+
{@render children()}
|
|
108
|
+
{#if label}<span class="sr-only">{label}</span>{/if}
|
|
109
|
+
</span>
|
|
110
|
+
{/if}
|
|
@@ -4,8 +4,17 @@ declare const Badge: import("svelte").Component<{
|
|
|
4
4
|
variant?: "default" | "secondary" | "destructive" | "outline" | "info" | "warning" | "success" | undefined;
|
|
5
5
|
size?: "default" | "sm" | "dot" | undefined;
|
|
6
6
|
clickable?: boolean;
|
|
7
|
+
/** When true, adds role="status" and aria-live="polite" for dynamic content announcements */
|
|
8
|
+
live?: boolean;
|
|
7
9
|
class?: string;
|
|
8
10
|
children: Snippet;
|
|
11
|
+
onclick?: (e: MouseEvent) => void;
|
|
12
|
+
/**
|
|
13
|
+
* Screen reader-only label providing context for the badge content.
|
|
14
|
+
* Useful for count badges (e.g., "unread messages" for a badge showing "5").
|
|
15
|
+
* @example label="unread notifications"
|
|
16
|
+
*/
|
|
17
|
+
label?: string;
|
|
9
18
|
}, {}, "">;
|
|
10
19
|
type Badge = ReturnType<typeof Badge>;
|
|
11
20
|
export default Badge;
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
import { tv, type VariantProps } from 'tailwind-variants';
|
|
5
5
|
|
|
6
6
|
const buttonVariants = tv({
|
|
7
|
-
base: 'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-lg text-sm font-medium ring-offset-background
|
|
7
|
+
base: 'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-lg text-sm font-medium ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50',
|
|
8
8
|
variants: {
|
|
9
9
|
variant: {
|
|
10
10
|
default: 'bg-primary text-primary-foreground shadow hover:bg-primary-hover',
|
|
@@ -63,6 +63,17 @@
|
|
|
63
63
|
}: Props = $props();
|
|
64
64
|
|
|
65
65
|
const classes = $derived(cn(buttonVariants({ variant, size }), className));
|
|
66
|
+
|
|
67
|
+
// Dev-mode a11y warning for icon buttons without accessible labels
|
|
68
|
+
if (typeof process !== 'undefined' && process.env?.NODE_ENV !== 'production') {
|
|
69
|
+
$effect(() => {
|
|
70
|
+
if (size === 'icon' && !restProps['aria-label'] && !restProps['aria-labelledby']) {
|
|
71
|
+
console.warn(
|
|
72
|
+
'Button: Icon-only buttons (size="icon") should have an aria-label or aria-labelledby for accessibility.'
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
});
|
|
76
|
+
}
|
|
66
77
|
</script>
|
|
67
78
|
|
|
68
79
|
{#snippet loadingSpinner()}
|
|
@@ -88,10 +99,11 @@
|
|
|
88
99
|
{@render loadingSpinner()}
|
|
89
100
|
{#if loadingText}
|
|
90
101
|
<span>{loadingText}</span>
|
|
91
|
-
<span class="sr-only">Loading</span>
|
|
92
102
|
{:else}
|
|
93
103
|
{@render children()}
|
|
94
104
|
{/if}
|
|
105
|
+
<!-- Always announce loading state for screen readers -->
|
|
106
|
+
<span class="sr-only" role="status">Loading</span>
|
|
95
107
|
{:else}
|
|
96
108
|
{@render children()}
|
|
97
109
|
{/if}
|
|
@@ -3,14 +3,20 @@
|
|
|
3
3
|
import { cn } from '../utils.js';
|
|
4
4
|
|
|
5
5
|
interface Props {
|
|
6
|
+
/** Heading level (h2 for page sections, h3 for nested, etc.) */
|
|
7
|
+
as?: 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6';
|
|
6
8
|
class?: string;
|
|
7
9
|
children: Snippet;
|
|
8
10
|
[key: string]: unknown;
|
|
9
11
|
}
|
|
10
12
|
|
|
11
|
-
let { class: className, children, ...restProps }: Props = $props();
|
|
13
|
+
let { as = 'h3', class: className, children, ...restProps }: Props = $props();
|
|
12
14
|
</script>
|
|
13
15
|
|
|
14
|
-
<
|
|
16
|
+
<svelte:element
|
|
17
|
+
this={as}
|
|
18
|
+
class={cn('text-2xl font-semibold leading-none tracking-tight', className)}
|
|
19
|
+
{...restProps}
|
|
20
|
+
>
|
|
15
21
|
{@render children()}
|
|
16
|
-
</
|
|
22
|
+
</svelte:element>
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
import { generateId, computeDescribedBy } from '../utils/form.js';
|
|
5
5
|
import Spinner from './Spinner.svelte';
|
|
6
6
|
import Label from './Label.svelte';
|
|
7
|
+
import LiveRegion from './LiveRegion.svelte';
|
|
7
8
|
|
|
8
9
|
export interface ComboboxOption {
|
|
9
10
|
value: string;
|
|
@@ -124,6 +125,15 @@
|
|
|
124
125
|
|
|
125
126
|
// Determine if we need a container wrapper
|
|
126
127
|
const hasWrapper = $derived(!!label || !!hint || !!error);
|
|
128
|
+
|
|
129
|
+
// Generate announcement message for screen readers
|
|
130
|
+
const resultsAnnouncement = $derived.by(() => {
|
|
131
|
+
if (loading) return 'Loading options...';
|
|
132
|
+
if (!open) return '';
|
|
133
|
+
if (filteredOptions.length === 0) return emptyMessage;
|
|
134
|
+
const count = filteredOptions.length;
|
|
135
|
+
return `${count} ${count === 1 ? 'option' : 'options'} available`;
|
|
136
|
+
});
|
|
127
137
|
</script>
|
|
128
138
|
|
|
129
139
|
{#if hasWrapper}
|
|
@@ -160,6 +170,8 @@
|
|
|
160
170
|
error && 'border-destructive focus:ring-destructive',
|
|
161
171
|
className
|
|
162
172
|
)}
|
|
173
|
+
role="combobox"
|
|
174
|
+
aria-expanded={open}
|
|
163
175
|
aria-invalid={error ? 'true' : undefined}
|
|
164
176
|
aria-describedby={describedBy}
|
|
165
177
|
/>
|
|
@@ -187,7 +199,7 @@
|
|
|
187
199
|
value={option.value}
|
|
188
200
|
label={option.label}
|
|
189
201
|
disabled={option.disabled}
|
|
190
|
-
class="relative flex w-full cursor-pointer select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none
|
|
202
|
+
class="relative flex w-full cursor-pointer select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[disabled]:cursor-default"
|
|
191
203
|
>
|
|
192
204
|
{#if option.value === value}
|
|
193
205
|
<span class="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
|
@@ -250,6 +262,8 @@
|
|
|
250
262
|
error && 'border-destructive focus:ring-destructive',
|
|
251
263
|
className
|
|
252
264
|
)}
|
|
265
|
+
role="combobox"
|
|
266
|
+
aria-expanded={open}
|
|
253
267
|
aria-invalid={error ? 'true' : undefined}
|
|
254
268
|
aria-describedby={describedBy}
|
|
255
269
|
/>
|
|
@@ -277,7 +291,7 @@
|
|
|
277
291
|
value={option.value}
|
|
278
292
|
label={option.label}
|
|
279
293
|
disabled={option.disabled}
|
|
280
|
-
class="relative flex w-full cursor-pointer select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none
|
|
294
|
+
class="relative flex w-full cursor-pointer select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[disabled]:cursor-default"
|
|
281
295
|
>
|
|
282
296
|
{#if option.value === value}
|
|
283
297
|
<span class="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
|
@@ -305,3 +319,6 @@
|
|
|
305
319
|
</ComboboxPrimitive.Content>
|
|
306
320
|
</ComboboxPrimitive.Root>
|
|
307
321
|
{/if}
|
|
322
|
+
|
|
323
|
+
<!-- Live region for screen reader announcements -->
|
|
324
|
+
<LiveRegion message={resultsAnnouncement} politeness="polite" />
|
|
@@ -89,7 +89,7 @@
|
|
|
89
89
|
{#snippet trigger()}
|
|
90
90
|
<button
|
|
91
91
|
type="button"
|
|
92
|
-
class="flex h-8 w-8 items-center justify-center rounded-md bg-transparent text-muted-foreground
|
|
92
|
+
class="flex h-8 w-8 items-center justify-center rounded-md bg-transparent text-muted-foreground hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
93
93
|
aria-label="Panel options"
|
|
94
94
|
>
|
|
95
95
|
<svg
|
|
@@ -141,7 +141,7 @@
|
|
|
141
141
|
<!-- Collapse/Expand button -->
|
|
142
142
|
<button
|
|
143
143
|
type="button"
|
|
144
|
-
class="flex h-8 w-8 items-center justify-center rounded-md bg-transparent text-muted-foreground
|
|
144
|
+
class="flex h-8 w-8 items-center justify-center rounded-md bg-transparent text-muted-foreground hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
145
145
|
onclick={() => onExpandedChange?.(!isExpanded)}
|
|
146
146
|
aria-label={isExpanded ? 'Collapse panel' : 'Expand panel'}
|
|
147
147
|
aria-expanded={isExpanded}
|
|
@@ -168,7 +168,7 @@
|
|
|
168
168
|
{#snippet trigger()}
|
|
169
169
|
<button
|
|
170
170
|
type="button"
|
|
171
|
-
class="flex h-8 w-8 items-center justify-center rounded-md bg-transparent text-muted-foreground
|
|
171
|
+
class="flex h-8 w-8 items-center justify-center rounded-md bg-transparent text-muted-foreground hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
172
172
|
aria-label="Panel options"
|
|
173
173
|
>
|
|
174
174
|
<svg
|
|
@@ -195,7 +195,7 @@
|
|
|
195
195
|
{#if !disableClose}
|
|
196
196
|
<button
|
|
197
197
|
type="button"
|
|
198
|
-
class="flex h-8 w-8 items-center justify-center rounded-md bg-transparent text-muted-foreground
|
|
198
|
+
class="flex h-8 w-8 items-center justify-center rounded-md bg-transparent text-muted-foreground hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
199
199
|
onclick={() => onClose?.()}
|
|
200
200
|
aria-label="Close panel"
|
|
201
201
|
>
|
|
@@ -32,7 +32,7 @@
|
|
|
32
32
|
<button
|
|
33
33
|
type="button"
|
|
34
34
|
class={cn(
|
|
35
|
-
'fixed z-40 flex items-center justify-center gap-2 border border-border bg-background px-3 py-2 text-sm font-medium shadow-md
|
|
35
|
+
'fixed z-40 flex items-center justify-center gap-2 border border-border bg-background px-3 py-2 text-sm font-medium shadow-md hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
|
|
36
36
|
positionClasses,
|
|
37
37
|
isVertical && 'writing-mode-vertical',
|
|
38
38
|
className
|
|
@@ -22,11 +22,11 @@
|
|
|
22
22
|
* ## Basic Usage
|
|
23
23
|
* ```svelte
|
|
24
24
|
* <DataTable
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
25
|
+
* data={users}
|
|
26
|
+
* columns={[
|
|
27
|
+
* { id: 'name', header: 'Name', accessor: 'name', sortable: true },
|
|
28
|
+
* { id: 'email', header: 'Email', accessor: 'email' },
|
|
29
|
+
* ]}
|
|
30
30
|
* />
|
|
31
31
|
* ```
|
|
32
32
|
*/
|
|
@@ -218,7 +218,7 @@
|
|
|
218
218
|
}
|
|
219
219
|
if (duplicates.length > 0) {
|
|
220
220
|
throw new Error(
|
|
221
|
-
`[DataTable] Duplicate column IDs found: ${duplicates.join(',
|
|
221
|
+
`[DataTable] Duplicate column IDs found: ${duplicates.join(',')}. ` +
|
|
222
222
|
'Each column must have a unique ID.'
|
|
223
223
|
);
|
|
224
224
|
}
|
|
@@ -430,7 +430,7 @@
|
|
|
430
430
|
{:else}
|
|
431
431
|
<tr
|
|
432
432
|
class={cn(
|
|
433
|
-
'bg-card
|
|
433
|
+
'bg-card',
|
|
434
434
|
onRowClick && 'cursor-pointer hover:bg-muted-hover',
|
|
435
435
|
row.isSelected && 'bg-primary/5'
|
|
436
436
|
)}
|
|
@@ -21,11 +21,11 @@
|
|
|
21
21
|
* ## Basic Usage
|
|
22
22
|
* ```svelte
|
|
23
23
|
* <DataTable
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
24
|
+
* data={users}
|
|
25
|
+
* columns={[
|
|
26
|
+
* { id: 'name', header: 'Name', accessor: 'name', sortable: true },
|
|
27
|
+
* { id: 'email', header: 'Email', accessor: 'email' },
|
|
28
|
+
* ]}
|
|
29
29
|
* />
|
|
30
30
|
* ```
|
|
31
31
|
*/
|