@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.
Files changed (102) hide show
  1. package/dist/__tests__/a11y/Alert.a11y.test.js +84 -0
  2. package/dist/__tests__/a11y/AlertDialog.a11y.test.js +126 -0
  3. package/dist/__tests__/a11y/Button.a11y.test.js +66 -0
  4. package/dist/__tests__/a11y/Card.a11y.test.js +77 -0
  5. package/dist/__tests__/a11y/Combobox.a11y.test.js +209 -0
  6. package/dist/__tests__/a11y/Dialog.a11y.test.js +103 -0
  7. package/dist/__tests__/a11y/DropdownMenu.a11y.test.js +104 -0
  8. package/dist/__tests__/a11y/FileUpload.a11y.test.js +158 -0
  9. package/dist/__tests__/a11y/Icon.a11y.test.js +119 -0
  10. package/dist/__tests__/a11y/Input.a11y.test.js +96 -0
  11. package/dist/__tests__/a11y/LiveRegion.a11y.test.js +102 -0
  12. package/dist/__tests__/a11y/Switch.a11y.test.js +95 -0
  13. package/dist/__tests__/a11y/Tabs.a11y.test.js +97 -0
  14. package/dist/__tests__/a11y/Textarea.a11y.test.js +111 -0
  15. package/dist/__tests__/setup.js +6 -0
  16. package/dist/lib/components/Alert.svelte +1 -1
  17. package/dist/lib/components/AlertDialog.svelte +1 -1
  18. package/dist/lib/components/AlertDialog.svelte.d.ts +2 -2
  19. package/dist/lib/components/Badge.svelte +49 -12
  20. package/dist/lib/components/Badge.svelte.d.ts +9 -0
  21. package/dist/lib/components/Button.svelte +14 -2
  22. package/dist/lib/components/CardTitle.svelte +9 -3
  23. package/dist/lib/components/CardTitle.svelte.d.ts +2 -0
  24. package/dist/lib/components/Combobox.svelte +19 -2
  25. package/dist/lib/components/DataPanelHeader.svelte +4 -4
  26. package/dist/lib/components/DataPanelTab.svelte +1 -1
  27. package/dist/lib/components/DataTable.svelte +7 -7
  28. package/dist/lib/components/DataTable.svelte.d.ts +5 -5
  29. package/dist/lib/components/DateTimePicker.svelte +7 -4
  30. package/dist/lib/components/Dialog.svelte +6 -6
  31. package/dist/lib/components/DropdownMenu.svelte +14 -10
  32. package/dist/lib/components/FileUpload.svelte +5 -10
  33. package/dist/lib/components/FormField.svelte +7 -1
  34. package/dist/lib/components/HeaderSearch.svelte +9 -9
  35. package/dist/lib/components/Icon.svelte +5 -5
  36. package/dist/lib/components/Input.svelte +32 -0
  37. package/dist/lib/components/Input.svelte.d.ts +8 -0
  38. package/dist/lib/components/LiveRegion.svelte +61 -0
  39. package/dist/lib/components/LiveRegion.svelte.d.ts +18 -0
  40. package/dist/lib/components/LoadingOverlay.svelte +148 -0
  41. package/dist/lib/components/LoadingOverlay.svelte.d.ts +29 -0
  42. package/dist/lib/components/LoadingPage.svelte +86 -0
  43. package/dist/lib/components/LoadingPage.svelte.d.ts +20 -0
  44. package/dist/lib/components/LoadingSection.svelte +54 -0
  45. package/dist/lib/components/LoadingSection.svelte.d.ts +25 -0
  46. package/dist/lib/components/MultiSelect.svelte +23 -2
  47. package/dist/lib/components/NumberInput.svelte +4 -4
  48. package/dist/lib/components/OTPInput.svelte +14 -0
  49. package/dist/lib/components/PasswordInput.svelte +14 -1
  50. package/dist/lib/components/PasswordInput.svelte.d.ts +4 -0
  51. package/dist/lib/components/ProgressBar.svelte +91 -0
  52. package/dist/lib/components/ProgressBar.svelte.d.ts +18 -0
  53. package/dist/lib/components/RadioGroup.svelte +5 -5
  54. package/dist/lib/components/Select.svelte +13 -1
  55. package/dist/lib/components/Signature.svelte +5 -5
  56. package/dist/lib/components/Skeleton.svelte +13 -3
  57. package/dist/lib/components/Skeleton.svelte.d.ts +2 -0
  58. package/dist/lib/components/SkipLink.svelte +67 -0
  59. package/dist/lib/components/SkipLink.svelte.d.ts +25 -0
  60. package/dist/lib/components/Slider.svelte +6 -2
  61. package/dist/lib/components/Switch.svelte +8 -0
  62. package/dist/lib/components/Switch.svelte.d.ts +4 -0
  63. package/dist/lib/components/Tabs.svelte +15 -5
  64. package/dist/lib/components/Tabs.svelte.d.ts +6 -0
  65. package/dist/lib/components/Textarea.svelte +19 -0
  66. package/dist/lib/components/Textarea.svelte.d.ts +8 -0
  67. package/dist/lib/components/Toast.svelte +1 -1
  68. package/dist/lib/components/layout/AuthLayout.svelte +1 -1
  69. package/dist/lib/components/layout/DashboardLayout.svelte +1 -1
  70. package/dist/lib/components/layout/Footer.svelte +1 -1
  71. package/dist/lib/components/layout/PublicLayout.svelte +2 -2
  72. package/dist/lib/components/layout/QuickLinks.svelte +3 -3
  73. package/dist/lib/components/layout/Sidebar.svelte +1 -1
  74. package/dist/lib/components/layout/sidebar/SidebarFlyout.svelte +1 -2
  75. package/dist/lib/components/layout/sidebar/SidebarNavItem.svelte +20 -21
  76. package/dist/lib/components/layout/sidebar/SidebarSection.svelte +2 -2
  77. package/dist/lib/components/notifications/NotificationFilters.svelte +1 -1
  78. package/dist/lib/components/notifications/NotificationItem.svelte +2 -1
  79. package/dist/lib/composables/index.d.ts +8 -0
  80. package/dist/lib/composables/index.js +9 -0
  81. package/dist/lib/composables/useAnnounce.svelte.d.ts +65 -0
  82. package/dist/lib/composables/useAnnounce.svelte.js +76 -0
  83. package/dist/lib/composables/useArrowNavigation.svelte.d.ts +42 -0
  84. package/dist/lib/composables/useArrowNavigation.svelte.js +138 -0
  85. package/dist/lib/composables/useClickOutside.svelte.d.ts +88 -0
  86. package/dist/lib/composables/useClickOutside.svelte.js +100 -0
  87. package/dist/lib/composables/useEscapeKey.svelte.d.ts +48 -0
  88. package/dist/lib/composables/useEscapeKey.svelte.js +50 -0
  89. package/dist/lib/composables/useFocusRef.svelte.d.ts +46 -0
  90. package/dist/lib/composables/useFocusRef.svelte.js +92 -0
  91. package/dist/lib/composables/useFocusTrap.svelte.d.ts +42 -0
  92. package/dist/lib/composables/useFocusTrap.svelte.js +136 -0
  93. package/dist/lib/composables/useReducedMotion.svelte.d.ts +47 -0
  94. package/dist/lib/composables/useReducedMotion.svelte.js +67 -0
  95. package/dist/lib/composables/useRovingTabindex.svelte.d.ts +54 -0
  96. package/dist/lib/composables/useRovingTabindex.svelte.js +128 -0
  97. package/dist/lib/index.d.ts +7 -1
  98. package/dist/lib/index.js +11 -1
  99. package/dist/lib/stores/theme.svelte.d.ts +1 -1
  100. package/dist/lib/utils.d.ts +4 -2
  101. package/dist/lib/utils.js +5 -11
  102. 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
+ });
@@ -0,0 +1,6 @@
1
+ import '@testing-library/jest-dom/vitest';
2
+ import { expect, vi } from 'vitest';
3
+ import * as matchers from 'vitest-axe/matchers';
4
+ expect.extend(matchers);
5
+ // Mock scrollIntoView since jsdom doesn't support it
6
+ Element.prototype.scrollIntoView = vi.fn();
@@ -46,6 +46,6 @@
46
46
  );
47
47
  </script>
48
48
 
49
- <div role="alert" aria-live={ariaLive} class={classes} {...restProps}>
49
+ <div role="alert" aria-live={ariaLive} aria-atomic="true" class={classes} {...restProps}>
50
50
  {@render children()}
51
51
  </div>
@@ -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 transition-colors 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
+ 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 transition-colors 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<{
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 transition-colors 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<{
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 transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2',
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-white',
12
+ destructive: 'border-transparent bg-destructive text-destructive-foreground',
13
13
  outline: 'text-foreground',
14
- success: 'border-transparent bg-green-500 text-white',
15
- warning: 'border-transparent bg-yellow-500 text-white',
16
- info: 'border-transparent bg-blue-500 text-white',
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-green-600' },
38
- { variant: 'warning', clickable: true, class: 'hover:bg-yellow-600' },
39
- { variant: 'info', clickable: true, class: 'hover:bg-blue-600' },
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
- <span class={classes} {...restProps}>
72
- {@render children()}
73
- </span>
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 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50',
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
- <h3 class={cn('text-2xl font-semibold leading-none tracking-tight', className)} {...restProps}>
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
- </h3>
22
+ </svelte:element>
@@ -1,5 +1,7 @@
1
1
  import type { Snippet } from 'svelte';
2
2
  interface Props {
3
+ /** Heading level (h2 for page sections, h3 for nested, etc.) */
4
+ as?: 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6';
3
5
  class?: string;
4
6
  children: Snippet;
5
7
  [key: string]: unknown;
@@ -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 transition-colors data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[disabled]:cursor-default"
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 transition-colors data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[disabled]:cursor-default"
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 transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
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 transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
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 transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
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 transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
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 transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
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
- * data={users}
26
- * columns={[
27
- * { id: 'name', header: 'Name', accessor: 'name', sortable: true },
28
- * { id: 'email', header: 'Email', accessor: 'email' },
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 transition-colors',
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
- * data={users}
25
- * columns={[
26
- * { id: 'name', header: 'Name', accessor: 'name', sortable: true },
27
- * { id: 'email', header: 'Email', accessor: 'email' },
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
  */