@classic-homes/theme-svelte 1.22.0 → 1.22.2

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 (110) hide show
  1. package/dist/lib/components/Alert.svelte +8 -10
  2. package/dist/lib/components/Combobox.svelte +2 -2
  3. package/dist/lib/components/DataPanel.svelte +19 -5
  4. package/dist/lib/components/DataTable.svelte +15 -6
  5. package/dist/lib/components/DataTable.svelte.d.ts +1 -1
  6. package/dist/lib/components/DateTimePicker.svelte +31 -34
  7. package/dist/lib/components/DropdownMenu.svelte +52 -30
  8. package/dist/lib/components/DropdownMenu.svelte.d.ts +5 -0
  9. package/dist/lib/components/FileUpload.svelte +5 -19
  10. package/dist/lib/components/HeaderSearch.svelte +18 -10
  11. package/dist/lib/components/MultiSelect.svelte +2 -2
  12. package/dist/lib/components/NumberInput.svelte +15 -2
  13. package/dist/lib/components/Select.svelte +19 -5
  14. package/dist/lib/components/Select.svelte.d.ts +4 -0
  15. package/dist/lib/components/Signature.svelte +15 -4
  16. package/dist/lib/components/Signature.svelte.d.ts +1 -1
  17. package/dist/lib/components/Skeleton.svelte.d.ts +1 -1
  18. package/dist/lib/components/Slider.svelte +8 -6
  19. package/dist/lib/components/Tabs.svelte +10 -12
  20. package/dist/lib/components/Toast.svelte +12 -12
  21. package/dist/lib/components/data-table/DataTableColumnVisibility.svelte +24 -26
  22. package/dist/lib/components/data-table/DataTableEditableCell.svelte +31 -18
  23. package/dist/lib/components/data-table/DataTableEditableCell.svelte.d.ts +0 -7
  24. package/dist/lib/components/data-table/DataTablePagination.svelte +5 -1
  25. package/dist/lib/components/data-table/DataTableVirtual.svelte +4 -7
  26. package/dist/lib/components/data-table/DataTableVirtual.svelte.d.ts +3 -0
  27. package/dist/lib/components/data-table/createDataTable.svelte.d.ts +6 -1
  28. package/dist/lib/components/data-table/createDataTable.svelte.js +22 -9
  29. package/dist/lib/components/layout/AppShell.svelte +1 -1
  30. package/dist/lib/components/layout/DashboardLayout.svelte +15 -6
  31. package/dist/lib/components/layout/Footer.svelte +6 -6
  32. package/dist/lib/components/layout/FormPageLayout.svelte +4 -3
  33. package/dist/lib/components/layout/Sidebar.svelte +53 -21
  34. package/dist/lib/components/media/Image.svelte.d.ts +1 -1
  35. package/dist/lib/components/media/Lightbox.svelte +13 -9
  36. package/dist/lib/components/media/MediaCarousel.svelte +5 -0
  37. package/dist/lib/components/media/MediaGrid.svelte +4 -1
  38. package/dist/lib/components/notifications/NotificationDialog.svelte +8 -5
  39. package/dist/lib/components/notifications/NotificationItem.svelte +16 -6
  40. package/dist/lib/components/studio/InsertPalette.svelte +4 -1
  41. package/dist/lib/components/studio/StudioCanvas.svelte +23 -2
  42. package/dist/lib/components/studio/StudioCanvas.svelte.d.ts +5 -1
  43. package/dist/lib/composables/useAsync.svelte.d.ts +1 -1
  44. package/dist/lib/composables/useAsync.svelte.js +33 -14
  45. package/dist/lib/composables/useFocusTrap.svelte.js +9 -2
  46. package/dist/lib/composables/useForm.svelte.js +19 -7
  47. package/dist/lib/composables/usePersistedForm.svelte.js +37 -10
  48. package/dist/lib/notices/sanitize.js +10 -3
  49. package/dist/lib/notices/store.d.ts +3 -3
  50. package/dist/lib/notices/store.js +14 -2
  51. package/dist/lib/notices/types.d.ts +8 -8
  52. package/dist/lib/notices/types.js +5 -1
  53. package/dist/lib/stores/sidebar.svelte.d.ts +12 -0
  54. package/dist/lib/stores/sidebar.svelte.js +76 -26
  55. package/dist/lib/stores/toast.svelte.js +12 -1
  56. package/dist/lib/utils/date.d.ts +3 -1
  57. package/dist/lib/utils/date.js +54 -34
  58. package/dist/lib/utils/form.d.ts +2 -1
  59. package/dist/lib/utils/form.js +3 -2
  60. package/dist/lib/utils/media.js +4 -3
  61. package/dist/lib/utils/url.d.ts +10 -0
  62. package/dist/lib/utils/url.js +21 -0
  63. package/dist/lib/utils.js +14 -1
  64. package/package.json +5 -4
  65. package/dist/__tests__/FileUpload.test.js +0 -45
  66. package/dist/__tests__/a11y/Alert.a11y.test.js +0 -84
  67. package/dist/__tests__/a11y/AlertDialog.a11y.test.js +0 -126
  68. package/dist/__tests__/a11y/Button.a11y.test.js +0 -66
  69. package/dist/__tests__/a11y/Card.a11y.test.js +0 -77
  70. package/dist/__tests__/a11y/Combobox.a11y.test.js +0 -209
  71. package/dist/__tests__/a11y/Dialog.a11y.test.js +0 -103
  72. package/dist/__tests__/a11y/DropdownMenu.a11y.test.js +0 -132
  73. package/dist/__tests__/a11y/FileUpload.a11y.test.js +0 -158
  74. package/dist/__tests__/a11y/FilterToolbar.a11y.test.js +0 -62
  75. package/dist/__tests__/a11y/Icon.a11y.test.js +0 -119
  76. package/dist/__tests__/a11y/Input.a11y.test.js +0 -96
  77. package/dist/__tests__/a11y/LiveRegion.a11y.test.js +0 -102
  78. package/dist/__tests__/a11y/Media.a11y.test.js +0 -145
  79. package/dist/__tests__/a11y/PublicLayout.a11y.test.js +0 -139
  80. package/dist/__tests__/a11y/SegmentedControl.a11y.test.js +0 -50
  81. package/dist/__tests__/a11y/Switch.a11y.test.js +0 -95
  82. package/dist/__tests__/a11y/Tabs.a11y.test.js +0 -116
  83. package/dist/__tests__/a11y/Textarea.a11y.test.js +0 -111
  84. package/dist/__tests__/a11y/Toolbar.a11y.test.js +0 -31
  85. package/dist/__tests__/a11y/ViewToggle.a11y.test.js +0 -40
  86. package/dist/__tests__/action-header.test.js +0 -150
  87. package/dist/__tests__/alert-dialog-dismiss.test.js +0 -54
  88. package/dist/__tests__/code-field.test.js +0 -65
  89. package/dist/__tests__/control-text-color.test.js +0 -29
  90. package/dist/__tests__/data-panel-card-gestures.test.js +0 -138
  91. package/dist/__tests__/data-panel-viewport.test.js +0 -45
  92. package/dist/__tests__/dropdown-trigger.test.js +0 -39
  93. package/dist/__tests__/header-search.test.js +0 -90
  94. package/dist/__tests__/media-components.test.js +0 -206
  95. package/dist/__tests__/media-hls.test.js +0 -273
  96. package/dist/__tests__/media-matterport.test.js +0 -296
  97. package/dist/__tests__/media-players.test.js +0 -244
  98. package/dist/__tests__/media-scrubber.test.js +0 -44
  99. package/dist/__tests__/media-utils.test.js +0 -427
  100. package/dist/__tests__/nav-access.test.js +0 -145
  101. package/dist/__tests__/page-header.test.js +0 -36
  102. package/dist/__tests__/public-layout-drawer.test.js +0 -116
  103. package/dist/__tests__/setup.js +0 -56
  104. package/dist/__tests__/sidebar-collapse.test.js +0 -58
  105. package/dist/__tests__/sidebar-docs-parity.test.js +0 -134
  106. package/dist/__tests__/sidebar-expansion.test.js +0 -59
  107. package/dist/__tests__/sidebar-items.test.js +0 -59
  108. package/dist/__tests__/sidebar-mobile.test.js +0 -86
  109. package/dist/__tests__/studio-canvas-item.svelte.test.js +0 -98
  110. package/dist/__tests__/studio.test.js +0 -240
@@ -104,6 +104,37 @@ export function isSameDay(date1, date2) {
104
104
  date1.getMonth() === date2.getMonth() &&
105
105
  date1.getDate() === date2.getDate());
106
106
  }
107
+ /**
108
+ * Build a local Date, or null when a part is out of range. `new Date()` rolls
109
+ * overflow into the next unit (Feb 30 -> Mar 1, 25:00 -> next day), so the
110
+ * date parts must round-trip and the time parts must be in range.
111
+ */
112
+ function buildDate(year, monthIndex, day, hour, minute) {
113
+ if (hour > 23 || minute > 59)
114
+ return null;
115
+ const date = new Date(year, monthIndex, day, hour, minute);
116
+ if (date.getFullYear() !== year || date.getMonth() !== monthIndex || date.getDate() !== day) {
117
+ return null;
118
+ }
119
+ return date;
120
+ }
121
+ /**
122
+ * Convert a typed hour (with optional AM/PM) to 0-23, or null when out of
123
+ * range for its clock (e.g. `13 PM`, `0 AM`).
124
+ */
125
+ function toHour24(hour, meridiem) {
126
+ if (!hour)
127
+ return 0;
128
+ const parsed = parseInt(hour);
129
+ if (!meridiem)
130
+ return parsed;
131
+ if (parsed < 1 || parsed > 12)
132
+ return null;
133
+ const isPm = meridiem.toUpperCase() === 'PM';
134
+ if (parsed === 12)
135
+ return isPm ? 12 : 0;
136
+ return isPm ? parsed + 12 : parsed;
137
+ }
107
138
  /**
108
139
  * Parse a user-typed date input string into a Date object.
109
140
  *
@@ -111,7 +142,9 @@ export function isSameDay(date1, date2) {
111
142
  * - ISO: `2024-03-15`, `2024-03-15T14:30`
112
143
  * - US: `03/15/2024`, `3/15/24`, `03-15-2024`
113
144
  * - Natural: `Mar 15, 2024`, `March 15 2024`
114
- * - With time: `03/15/2024 2:30 PM`, `03/15/2024 14:30`
145
+ * - With time: `03/15/2024 2:30 PM`, `03/15/2024 14:30`, `Mar 15, 2024 at 2:30 PM`
146
+ *
147
+ * Out-of-range parts (`02/30/2024`, `25:99`) are rejected, not rolled over.
115
148
  *
116
149
  * @param input - The user-typed date string
117
150
  * @param options - Parsing options
@@ -128,17 +161,17 @@ export function parseDateInput(input, options) {
128
161
  }
129
162
  // Try parsing with various patterns
130
163
  let date = null;
131
- let hasTime = false;
164
+ let matched = false;
132
165
  // Pattern 1: ISO format (2024-03-15 or 2024-03-15T14:30)
133
166
  const isoMatch = trimmed.match(/^(\d{4})-(\d{1,2})-(\d{1,2})(?:[T ](\d{1,2}):(\d{2})(?::(\d{2}))?)?$/);
134
167
  if (isoMatch) {
135
168
  const [, year, month, day, hour, minute] = isoMatch;
136
- date = new Date(parseInt(year), parseInt(month) - 1, parseInt(day), hour ? parseInt(hour) : 0, minute ? parseInt(minute) : 0);
137
- hasTime = !!hour;
169
+ matched = true;
170
+ date = buildDate(parseInt(year), parseInt(month) - 1, parseInt(day), hour ? parseInt(hour) : 0, minute ? parseInt(minute) : 0);
138
171
  }
139
172
  // Pattern 2: US format with slashes or dashes (03/15/2024, 3/15/24, 03-15-2024)
140
- if (!date) {
141
- const usMatch = trimmed.match(/^(\d{1,2})[/-](\d{1,2})[/-](\d{2,4})(?:\s+(\d{1,2}):(\d{2})(?:\s*(AM|PM))?)?$/i);
173
+ if (!matched) {
174
+ const usMatch = trimmed.match(/^(\d{1,2})[/-](\d{1,2})[/-](\d{2,4})(?:\s+(?:at\s+)?(\d{1,2}):(\d{2})(?:\s*(AM|PM))?)?$/i);
142
175
  if (usMatch) {
143
176
  const [, month, day, yearStr, hour, minute, meridiem] = usMatch;
144
177
  let year = parseInt(yearStr);
@@ -146,22 +179,16 @@ export function parseDateInput(input, options) {
146
179
  if (year < 100) {
147
180
  year += year < 50 ? 2000 : 1900;
148
181
  }
149
- let parsedHour = hour ? parseInt(hour) : 0;
150
- if (meridiem) {
151
- const upper = meridiem.toUpperCase();
152
- if (upper === 'PM' && parsedHour !== 12) {
153
- parsedHour += 12;
154
- }
155
- else if (upper === 'AM' && parsedHour === 12) {
156
- parsedHour = 0;
157
- }
158
- }
159
- date = new Date(year, parseInt(month) - 1, parseInt(day), parsedHour, minute ? parseInt(minute) : 0);
160
- hasTime = !!hour;
182
+ matched = true;
183
+ const parsedHour = toHour24(hour, meridiem);
184
+ date =
185
+ parsedHour === null
186
+ ? null
187
+ : buildDate(year, parseInt(month) - 1, parseInt(day), parsedHour, minute ? parseInt(minute) : 0);
161
188
  }
162
189
  }
163
190
  // Pattern 3: Natural format (Mar 15, 2024 or March 15 2024)
164
- if (!date) {
191
+ if (!matched) {
165
192
  const monthNames = {
166
193
  jan: 0,
167
194
  january: 0,
@@ -188,23 +215,16 @@ export function parseDateInput(input, options) {
188
215
  dec: 11,
189
216
  december: 11,
190
217
  };
191
- const naturalMatch = trimmed.match(/^([a-zA-Z]+)\s+(\d{1,2}),?\s+(\d{4})(?:\s+(\d{1,2}):(\d{2})(?:\s*(AM|PM))?)?$/i);
218
+ const naturalMatch = trimmed.match(/^([a-zA-Z]+)\s+(\d{1,2}),?\s+(\d{4})(?:\s+(?:at\s+)?(\d{1,2}):(\d{2})(?:\s*(AM|PM))?)?$/i);
192
219
  if (naturalMatch) {
193
220
  const [, monthStr, day, year, hour, minute, meridiem] = naturalMatch;
194
221
  const monthNum = monthNames[monthStr.toLowerCase()];
195
222
  if (monthNum !== undefined) {
196
- let parsedHour = hour ? parseInt(hour) : 0;
197
- if (meridiem) {
198
- const upper = meridiem.toUpperCase();
199
- if (upper === 'PM' && parsedHour !== 12) {
200
- parsedHour += 12;
201
- }
202
- else if (upper === 'AM' && parsedHour === 12) {
203
- parsedHour = 0;
204
- }
205
- }
206
- date = new Date(parseInt(year), monthNum, parseInt(day), parsedHour, minute ? parseInt(minute) : 0);
207
- hasTime = !!hour;
223
+ const parsedHour = toHour24(hour, meridiem);
224
+ date =
225
+ parsedHour === null
226
+ ? null
227
+ : buildDate(parseInt(year), monthNum, parseInt(day), parsedHour, minute ? parseInt(minute) : 0);
208
228
  }
209
229
  }
210
230
  }
@@ -216,8 +236,8 @@ export function parseDateInput(input, options) {
216
236
  error: 'Invalid date format. Try MM/DD/YYYY or YYYY-MM-DD',
217
237
  };
218
238
  }
219
- // If dateOnly mode and time was provided, still accept it but set time to midnight
220
- if (options?.dateOnly && !hasTime) {
239
+ // In dateOnly mode a typed time is accepted but reset to midnight
240
+ if (options?.dateOnly) {
221
241
  date.setHours(0, 0, 0, 0);
222
242
  }
223
243
  return { date, isValid: true };
@@ -11,7 +11,8 @@
11
11
  export declare function generateId(prefix?: string): string;
12
12
  /**
13
13
  * Computes the aria-describedby attribute value based on hint and error states.
14
- * Returns undefined if neither hint nor error is present.
14
+ * Returns undefined if neither hint nor error is present. The hint is left out
15
+ * while an error shows, since form fields render the error in its place.
15
16
  *
16
17
  * @param hintId - The ID of the hint element
17
18
  * @param errorId - The ID of the error element
@@ -13,7 +13,8 @@ export function generateId(prefix = 'field') {
13
13
  }
14
14
  /**
15
15
  * Computes the aria-describedby attribute value based on hint and error states.
16
- * Returns undefined if neither hint nor error is present.
16
+ * Returns undefined if neither hint nor error is present. The hint is left out
17
+ * while an error shows, since form fields render the error in its place.
17
18
  *
18
19
  * @param hintId - The ID of the hint element
19
20
  * @param errorId - The ID of the error element
@@ -23,7 +24,7 @@ export function generateId(prefix = 'field') {
23
24
  */
24
25
  export function computeDescribedBy(hintId, errorId, hasHint, hasError) {
25
26
  const ids = [];
26
- if (hasHint && hintId)
27
+ if (hasHint && !hasError && hintId)
27
28
  ids.push(hintId);
28
29
  if (hasError && errorId)
29
30
  ids.push(errorId);
@@ -614,10 +614,11 @@ export function buildEmbedSrc(provider, videoId, options = {}) {
614
614
  params.set('autoplay', '1');
615
615
  if (privacy)
616
616
  params.set('dnt', '1');
617
- if (start !== undefined)
618
- params.set('#t', `${Math.floor(start)}s`);
619
617
  const query = params.toString();
620
- return `https://player.vimeo.com/video/${videoId}${query ? `?${query}` : ''}`;
618
+ // Vimeo reads the start time from the fragment (#t=30s), not the query;
619
+ // URLSearchParams would encode it as `%23t`, which Vimeo ignores.
620
+ const fragment = start !== undefined ? `#t=${Math.floor(start)}s` : '';
621
+ return `https://player.vimeo.com/video/${videoId}${query ? `?${query}` : ''}${fragment}`;
621
622
  }
622
623
  /**
623
624
  * Poster image URL for an embedded video, when one can be derived without a
@@ -0,0 +1,10 @@
1
+ /**
2
+ * URL safety helpers for hrefs that come from config or API data.
3
+ */
4
+ /**
5
+ * Return `url` when it is relative or uses a safe scheme (http, https, mailto,
6
+ * tel), otherwise `null`. Rejects `javascript:`, `data:`, `vbscript:` and the
7
+ * like, including obfuscated forms (leading whitespace, mixed case, embedded
8
+ * tabs/newlines), because the URL parser normalises those before the check.
9
+ */
10
+ export declare function safeHref(url: string | null | undefined): string | null;
@@ -0,0 +1,21 @@
1
+ /**
2
+ * URL safety helpers for hrefs that come from config or API data.
3
+ */
4
+ const SAFE_PROTOCOLS = new Set(['http:', 'https:', 'mailto:', 'tel:']);
5
+ /**
6
+ * Return `url` when it is relative or uses a safe scheme (http, https, mailto,
7
+ * tel), otherwise `null`. Rejects `javascript:`, `data:`, `vbscript:` and the
8
+ * like, including obfuscated forms (leading whitespace, mixed case, embedded
9
+ * tabs/newlines), because the URL parser normalises those before the check.
10
+ */
11
+ export function safeHref(url) {
12
+ if (typeof url !== 'string' || url.trim() === '')
13
+ return null;
14
+ try {
15
+ const parsed = new URL(url, 'https://relative.invalid');
16
+ return SAFE_PROTOCOLS.has(parsed.protocol) ? url : null;
17
+ }
18
+ catch {
19
+ return null;
20
+ }
21
+ }
package/dist/lib/utils.js CHANGED
@@ -1,5 +1,18 @@
1
1
  import { clsx } from 'clsx';
2
- import { twMerge } from 'tailwind-merge';
2
+ import { extendTailwindMerge } from 'tailwind-merge';
3
+ /**
4
+ * tailwind-merge only knows Tailwind's default theme, so it reads the preset's
5
+ * custom font sizes (`text-h1`..`text-h6`, from `fontSize` in
6
+ * @classic-homes/theme-tailwind-preset) as text colours and drops a real colour
7
+ * such as `text-foreground` beside them. Register them as font sizes.
8
+ */
9
+ const twMerge = extendTailwindMerge({
10
+ extend: {
11
+ classGroups: {
12
+ 'font-size': [{ text: ['h1', 'h2', 'h3', 'h4', 'h5', 'h6'] }],
13
+ },
14
+ },
15
+ });
3
16
  /**
4
17
  * Utility function to merge Tailwind CSS classes
5
18
  * Uses clsx for conditional classes and tailwind-merge to handle conflicts
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@classic-homes/theme-svelte",
3
- "version": "1.22.0",
3
+ "version": "1.22.2",
4
4
  "description": "Svelte components for the Classic theme system",
5
5
  "type": "module",
6
6
  "svelte": "./dist/lib/index.js",
@@ -15,14 +15,15 @@
15
15
  "types": "./dist/lib/components/notifications/index.d.ts",
16
16
  "svelte": "./dist/lib/components/notifications/index.js",
17
17
  "default": "./dist/lib/components/notifications/index.js"
18
- },
19
- "./styles.css": "./dist/styles.css"
18
+ }
20
19
  },
21
20
  "sideEffects": [
22
21
  "*.css"
23
22
  ],
24
23
  "files": [
25
- "dist"
24
+ "dist",
25
+ "!dist/__tests__",
26
+ "!dist/**/*.test.*"
26
27
  ],
27
28
  "scripts": {
28
29
  "build": "svelte-package -i src",
@@ -1,45 +0,0 @@
1
- import { describe, it, expect } from 'vitest';
2
- import { render, screen } from '@testing-library/svelte';
3
- import FileUpload from '../lib/components/FileUpload.svelte';
4
- describe('FileUpload accepted-types label', () => {
5
- it('derives a friendly label from MIME types instead of showing raw values', () => {
6
- render(FileUpload, {
7
- props: {
8
- label: 'Upload files',
9
- acceptedTypes: [
10
- 'image/jpeg',
11
- 'image/png',
12
- 'application/pdf',
13
- 'video/mp4',
14
- 'video/quicktime',
15
- ],
16
- },
17
- });
18
- expect(screen.getByText(/Images, PDF, Videos \(max/)).toBeInTheDocument();
19
- // Raw MIME types should not be shown
20
- expect(screen.queryByText(/image\/jpeg/)).not.toBeInTheDocument();
21
- });
22
- it('uses the default accepted types (Images, PDF) when none are provided', () => {
23
- render(FileUpload, { props: { label: 'Upload files' } });
24
- expect(screen.getByText(/^Images, PDF \(max/)).toBeInTheDocument();
25
- });
26
- it('respects an explicit typesLabel override', () => {
27
- render(FileUpload, {
28
- props: {
29
- label: 'Upload files',
30
- acceptedTypes: ['image/png', 'video/mp4'],
31
- typesLabel: 'Photos and clips',
32
- },
33
- });
34
- expect(screen.getByText(/^Photos and clips \(max/)).toBeInTheDocument();
35
- });
36
- it('falls back to the uppercased subtype for unmapped types', () => {
37
- render(FileUpload, {
38
- props: {
39
- label: 'Upload files',
40
- acceptedTypes: ['application/zip'],
41
- },
42
- });
43
- expect(screen.getByText(/^ZIP \(max/)).toBeInTheDocument();
44
- });
45
- });
@@ -1,84 +0,0 @@
1
- import { describe, it, expect } from 'vitest';
2
- import { screen } from '@testing-library/svelte';
3
- import { axe } from 'vitest-axe';
4
- describe('Alert accessibility', () => {
5
- it('should have no axe violations', async () => {
6
- const container = document.createElement('div');
7
- document.body.appendChild(container);
8
- container.innerHTML = `
9
- <div role="alert" aria-live="polite" aria-atomic="true" class="relative w-full rounded-lg border p-4">
10
- <h5 class="mb-1 font-medium leading-none tracking-tight">Alert Title</h5>
11
- <p class="text-sm">Alert description text</p>
12
- </div>
13
- `;
14
- const results = await axe(container);
15
- expect(results).toHaveNoViolations();
16
- document.body.removeChild(container);
17
- });
18
- it('should have no axe violations with different variants', async () => {
19
- const container = document.createElement('div');
20
- document.body.appendChild(container);
21
- // Test default variant
22
- container.innerHTML = `
23
- <div role="alert" aria-live="polite" aria-atomic="true" class="relative w-full rounded-lg border p-4 bg-gray-50">
24
- <h5 class="mb-1 font-medium leading-none tracking-tight">Default Alert</h5>
25
- </div>
26
- `;
27
- expect(await axe(container)).toHaveNoViolations();
28
- // Test destructive variant
29
- container.innerHTML = `
30
- <div role="alert" aria-live="assertive" aria-atomic="true" class="relative w-full rounded-lg border p-4 bg-red-50">
31
- <h5 class="mb-1 font-medium leading-none tracking-tight">Destructive Alert</h5>
32
- </div>
33
- `;
34
- expect(await axe(container)).toHaveNoViolations();
35
- document.body.removeChild(container);
36
- });
37
- it('should have role="alert"', () => {
38
- const container = document.createElement('div');
39
- document.body.appendChild(container);
40
- container.innerHTML = `
41
- <div role="alert" aria-live="polite" aria-atomic="true" class="relative w-full rounded-lg border p-4">
42
- <h5 class="mb-1 font-medium leading-none tracking-tight">Alert Title</h5>
43
- </div>
44
- `;
45
- const alert = screen.getByRole('alert');
46
- expect(alert).toBeInTheDocument();
47
- document.body.removeChild(container);
48
- });
49
- it('should have aria-atomic="true"', () => {
50
- const container = document.createElement('div');
51
- document.body.appendChild(container);
52
- container.innerHTML = `
53
- <div role="alert" aria-live="polite" aria-atomic="true" class="relative w-full rounded-lg border p-4">
54
- <h5 class="mb-1 font-medium leading-none tracking-tight">Alert Title</h5>
55
- </div>
56
- `;
57
- const alert = screen.getByRole('alert');
58
- expect(alert).toHaveAttribute('aria-atomic', 'true');
59
- document.body.removeChild(container);
60
- });
61
- it('should display title text', () => {
62
- const container = document.createElement('div');
63
- document.body.appendChild(container);
64
- container.innerHTML = `
65
- <div role="alert" aria-live="polite" aria-atomic="true" class="relative w-full rounded-lg border p-4">
66
- <h5 class="mb-1 font-medium leading-none tracking-tight">Important Alert</h5>
67
- </div>
68
- `;
69
- expect(screen.getByText('Important Alert')).toBeInTheDocument();
70
- document.body.removeChild(container);
71
- });
72
- it('should display description text', () => {
73
- const container = document.createElement('div');
74
- document.body.appendChild(container);
75
- container.innerHTML = `
76
- <div role="alert" aria-live="polite" aria-atomic="true" class="relative w-full rounded-lg border p-4">
77
- <h5 class="mb-1 font-medium leading-none tracking-tight">Alert</h5>
78
- <p class="text-sm">This is the description</p>
79
- </div>
80
- `;
81
- expect(screen.getByText('This is the description')).toBeInTheDocument();
82
- document.body.removeChild(container);
83
- });
84
- });
@@ -1,126 +0,0 @@
1
- import { describe, it, expect } from 'vitest';
2
- import { screen } from '@testing-library/svelte';
3
- import userEvent from '@testing-library/user-event';
4
- import { axe } from 'vitest-axe';
5
- describe('AlertDialog accessibility', () => {
6
- it('should have no axe violations when open', async () => {
7
- const container = document.createElement('div');
8
- document.body.appendChild(container);
9
- container.innerHTML = `
10
- <div role="alertdialog" aria-modal="true" aria-labelledby="alertdialog-title" aria-describedby="alertdialog-desc" class="fixed z-50 bg-background p-6 shadow-lg">
11
- <h2 id="alertdialog-title" class="text-lg font-semibold">Confirm Action</h2>
12
- <p id="alertdialog-desc" class="text-sm text-muted-foreground">Are you sure you want to proceed?</p>
13
- <div>
14
- <button type="button">Cancel</button>
15
- <button type="button">Continue</button>
16
- </div>
17
- </div>
18
- `;
19
- const results = await axe(container);
20
- expect(results).toHaveNoViolations();
21
- document.body.removeChild(container);
22
- });
23
- it('should have role="alertdialog"', () => {
24
- const container = document.createElement('div');
25
- document.body.appendChild(container);
26
- container.innerHTML = `
27
- <div role="alertdialog" aria-modal="true" aria-labelledby="alertdialog-title">
28
- <h2 id="alertdialog-title">Confirm Action</h2>
29
- <button type="button">Cancel</button>
30
- <button type="button">Continue</button>
31
- </div>
32
- `;
33
- const dialog = screen.getByRole('alertdialog');
34
- expect(dialog).toBeInTheDocument();
35
- document.body.removeChild(container);
36
- });
37
- it('should have aria-modal="true"', () => {
38
- const container = document.createElement('div');
39
- document.body.appendChild(container);
40
- container.innerHTML = `
41
- <div role="alertdialog" aria-modal="true" aria-labelledby="alertdialog-title">
42
- <h2 id="alertdialog-title">Confirm Action</h2>
43
- <button type="button">Cancel</button>
44
- <button type="button">Continue</button>
45
- </div>
46
- `;
47
- const dialog = screen.getByRole('alertdialog');
48
- expect(dialog).toHaveAttribute('aria-modal', 'true');
49
- document.body.removeChild(container);
50
- });
51
- it('should have accessible title', () => {
52
- const container = document.createElement('div');
53
- document.body.appendChild(container);
54
- container.innerHTML = `
55
- <div role="alertdialog" aria-modal="true" aria-labelledby="alertdialog-title">
56
- <h2 id="alertdialog-title">Delete Item</h2>
57
- <button type="button">Cancel</button>
58
- <button type="button">Delete</button>
59
- </div>
60
- `;
61
- expect(screen.getByText('Delete Item')).toBeInTheDocument();
62
- document.body.removeChild(container);
63
- });
64
- it('should have accessible description', () => {
65
- const container = document.createElement('div');
66
- document.body.appendChild(container);
67
- container.innerHTML = `
68
- <div role="alertdialog" aria-modal="true" aria-labelledby="alertdialog-title" aria-describedby="alertdialog-desc">
69
- <h2 id="alertdialog-title">Delete Item</h2>
70
- <p id="alertdialog-desc">This action cannot be undone.</p>
71
- <button type="button">Cancel</button>
72
- <button type="button">Delete</button>
73
- </div>
74
- `;
75
- expect(screen.getByText('This action cannot be undone.')).toBeInTheDocument();
76
- document.body.removeChild(container);
77
- });
78
- it('should have cancel and confirm buttons', () => {
79
- const container = document.createElement('div');
80
- document.body.appendChild(container);
81
- container.innerHTML = `
82
- <div role="alertdialog" aria-modal="true" aria-labelledby="alertdialog-title">
83
- <h2 id="alertdialog-title">Confirm Action</h2>
84
- <button type="button">Cancel</button>
85
- <button type="button">Confirm</button>
86
- </div>
87
- `;
88
- expect(screen.getByRole('button', { name: 'Cancel' })).toBeInTheDocument();
89
- expect(screen.getByRole('button', { name: 'Confirm' })).toBeInTheDocument();
90
- document.body.removeChild(container);
91
- });
92
- it('should be keyboard accessible', async () => {
93
- const user = userEvent.setup();
94
- const container = document.createElement('div');
95
- document.body.appendChild(container);
96
- container.innerHTML = `
97
- <div role="alertdialog" aria-modal="true" aria-labelledby="alertdialog-title">
98
- <h2 id="alertdialog-title">Confirm Action</h2>
99
- <button type="button" id="cancel-btn">Cancel</button>
100
- <button type="button" id="confirm-btn">Confirm</button>
101
- </div>
102
- `;
103
- const cancelBtn = container.querySelector('#cancel-btn');
104
- const confirmBtn = container.querySelector('#confirm-btn');
105
- cancelBtn.focus();
106
- expect(cancelBtn).toHaveFocus();
107
- await user.tab();
108
- expect(confirmBtn).toHaveFocus();
109
- document.body.removeChild(container);
110
- });
111
- it('should have labeled buttons that describe the action', () => {
112
- const container = document.createElement('div');
113
- document.body.appendChild(container);
114
- container.innerHTML = `
115
- <div role="alertdialog" aria-modal="true" aria-labelledby="alertdialog-title" aria-describedby="alertdialog-desc">
116
- <h2 id="alertdialog-title">Delete Account</h2>
117
- <p id="alertdialog-desc">This will permanently delete your account and all associated data.</p>
118
- <button type="button">Cancel</button>
119
- <button type="button">Delete Account</button>
120
- </div>
121
- `;
122
- const deleteBtn = screen.getByRole('button', { name: 'Delete Account' });
123
- expect(deleteBtn).toBeInTheDocument();
124
- document.body.removeChild(container);
125
- });
126
- });
@@ -1,66 +0,0 @@
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 { createRawSnippet } from 'svelte';
6
- import Button from '../../lib/components/Button.svelte';
7
- function textSnippet(text) {
8
- return createRawSnippet(() => ({
9
- render: () => `<span>${text}</span>`,
10
- }));
11
- }
12
- describe('Button accessibility', () => {
13
- it('should have no axe violations', async () => {
14
- const { container } = render(Button, {
15
- props: { children: textSnippet('Click me') },
16
- });
17
- const results = await axe(container);
18
- expect(results).toHaveNoViolations();
19
- });
20
- it('should have no axe violations with different variants', async () => {
21
- const { container } = render(Button, {
22
- props: { variant: 'default', children: textSnippet('Default') },
23
- });
24
- expect(await axe(container)).toHaveNoViolations();
25
- const { container: container2 } = render(Button, {
26
- props: { variant: 'secondary', children: textSnippet('Secondary') },
27
- });
28
- expect(await axe(container2)).toHaveNoViolations();
29
- const { container: container3 } = render(Button, {
30
- props: { variant: 'destructive', children: textSnippet('Destructive') },
31
- });
32
- expect(await axe(container3)).toHaveNoViolations();
33
- });
34
- it('should be focusable', async () => {
35
- render(Button, {
36
- props: { children: textSnippet('Click me') },
37
- });
38
- const button = screen.getByRole('button');
39
- await userEvent.tab();
40
- expect(button).toHaveFocus();
41
- });
42
- it('should be activatable with keyboard', async () => {
43
- let clicked = false;
44
- render(Button, {
45
- props: {
46
- children: textSnippet('Click me'),
47
- onclick: () => {
48
- clicked = true;
49
- },
50
- },
51
- });
52
- await userEvent.tab();
53
- await userEvent.keyboard('{Enter}');
54
- expect(clicked).toBe(true);
55
- });
56
- it('should not be focusable when disabled', async () => {
57
- render(Button, {
58
- props: {
59
- disabled: true,
60
- children: textSnippet('Disabled'),
61
- },
62
- });
63
- const button = screen.getByRole('button');
64
- expect(button).toBeDisabled();
65
- });
66
- });