@classic-homes/theme-svelte 1.21.0 → 1.22.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) hide show
  1. package/dist/lib/components/Alert.svelte +8 -10
  2. package/dist/lib/components/DataPanel.svelte +19 -5
  3. package/dist/lib/components/HeaderSearch.svelte +18 -10
  4. package/dist/lib/components/Skeleton.svelte.d.ts +1 -1
  5. package/dist/lib/components/layout/AppShell.svelte +1 -1
  6. package/dist/lib/components/layout/DashboardLayout.svelte +15 -6
  7. package/dist/lib/components/layout/Footer.svelte +6 -6
  8. package/dist/lib/components/layout/FormPageLayout.svelte +4 -3
  9. package/dist/lib/components/layout/Sidebar.svelte +25 -21
  10. package/dist/lib/components/media/Image.svelte.d.ts +1 -1
  11. package/dist/lib/components/media/Lightbox.svelte +13 -9
  12. package/dist/lib/components/media/MediaCarousel.svelte +5 -0
  13. package/dist/lib/components/media/MediaGrid.svelte +4 -1
  14. package/dist/lib/components/notifications/NotificationDialog.svelte +8 -5
  15. package/dist/lib/components/notifications/NotificationItem.svelte +16 -6
  16. package/dist/lib/components/studio/InsertPalette.svelte +4 -1
  17. package/dist/lib/components/studio/StudioCanvas.svelte +23 -2
  18. package/dist/lib/components/studio/StudioCanvas.svelte.d.ts +5 -1
  19. package/dist/lib/components/studio/StudioCanvasItem.svelte +31 -4
  20. package/dist/lib/components/studio/StudioCanvasItem.svelte.d.ts +7 -0
  21. package/dist/lib/composables/useFocusTrap.svelte.js +9 -2
  22. package/dist/lib/notices/sanitize.js +10 -3
  23. package/dist/lib/notices/store.d.ts +3 -3
  24. package/dist/lib/notices/store.js +14 -2
  25. package/dist/lib/notices/types.d.ts +8 -8
  26. package/dist/lib/notices/types.js +5 -1
  27. package/dist/lib/stores/sidebar.svelte.d.ts +6 -0
  28. package/dist/lib/stores/sidebar.svelte.js +61 -26
  29. package/dist/lib/utils/media.js +4 -3
  30. package/dist/lib/utils/url.d.ts +10 -0
  31. package/dist/lib/utils/url.js +21 -0
  32. package/dist/lib/utils.js +14 -1
  33. package/package.json +5 -4
  34. package/dist/__tests__/FileUpload.test.js +0 -45
  35. package/dist/__tests__/a11y/Alert.a11y.test.js +0 -84
  36. package/dist/__tests__/a11y/AlertDialog.a11y.test.js +0 -126
  37. package/dist/__tests__/a11y/Button.a11y.test.js +0 -66
  38. package/dist/__tests__/a11y/Card.a11y.test.js +0 -77
  39. package/dist/__tests__/a11y/Combobox.a11y.test.js +0 -209
  40. package/dist/__tests__/a11y/Dialog.a11y.test.js +0 -103
  41. package/dist/__tests__/a11y/DropdownMenu.a11y.test.js +0 -132
  42. package/dist/__tests__/a11y/FileUpload.a11y.test.js +0 -158
  43. package/dist/__tests__/a11y/FilterToolbar.a11y.test.js +0 -62
  44. package/dist/__tests__/a11y/Icon.a11y.test.js +0 -119
  45. package/dist/__tests__/a11y/Input.a11y.test.js +0 -96
  46. package/dist/__tests__/a11y/LiveRegion.a11y.test.js +0 -102
  47. package/dist/__tests__/a11y/Media.a11y.test.js +0 -145
  48. package/dist/__tests__/a11y/PublicLayout.a11y.test.js +0 -139
  49. package/dist/__tests__/a11y/SegmentedControl.a11y.test.js +0 -50
  50. package/dist/__tests__/a11y/Switch.a11y.test.js +0 -95
  51. package/dist/__tests__/a11y/Tabs.a11y.test.js +0 -116
  52. package/dist/__tests__/a11y/Textarea.a11y.test.js +0 -111
  53. package/dist/__tests__/a11y/Toolbar.a11y.test.js +0 -31
  54. package/dist/__tests__/a11y/ViewToggle.a11y.test.js +0 -40
  55. package/dist/__tests__/action-header.test.js +0 -150
  56. package/dist/__tests__/alert-dialog-dismiss.test.js +0 -54
  57. package/dist/__tests__/code-field.test.js +0 -65
  58. package/dist/__tests__/control-text-color.test.js +0 -29
  59. package/dist/__tests__/data-panel-card-gestures.test.js +0 -138
  60. package/dist/__tests__/data-panel-viewport.test.js +0 -45
  61. package/dist/__tests__/dropdown-trigger.test.js +0 -39
  62. package/dist/__tests__/header-search.test.js +0 -90
  63. package/dist/__tests__/media-components.test.js +0 -206
  64. package/dist/__tests__/media-hls.test.js +0 -273
  65. package/dist/__tests__/media-matterport.test.js +0 -296
  66. package/dist/__tests__/media-players.test.js +0 -244
  67. package/dist/__tests__/media-scrubber.test.js +0 -44
  68. package/dist/__tests__/media-utils.test.js +0 -427
  69. package/dist/__tests__/nav-access.test.js +0 -145
  70. package/dist/__tests__/page-header.test.js +0 -36
  71. package/dist/__tests__/public-layout-drawer.test.js +0 -116
  72. package/dist/__tests__/setup.js +0 -56
  73. package/dist/__tests__/sidebar-collapse.test.js +0 -58
  74. package/dist/__tests__/sidebar-docs-parity.test.js +0 -134
  75. package/dist/__tests__/sidebar-expansion.test.js +0 -59
  76. package/dist/__tests__/sidebar-items.test.js +0 -59
  77. package/dist/__tests__/sidebar-mobile.test.js +0 -86
  78. package/dist/__tests__/studio-canvas-item-reveal.test.js +0 -50
  79. package/dist/__tests__/studio.test.js +0 -240
@@ -7,17 +7,15 @@
7
7
  base: 'relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground',
8
8
  variants: {
9
9
  variant: {
10
- default:
11
- 'bg-gray-50 dark:bg-gray-800 border-gray-200 dark:border-gray-700 text-gray-900 dark:text-gray-100',
10
+ // Status tokens follow the theme into dark mode. Text stays foreground:
11
+ // the status hues are tuned for fills and icons, not body-text contrast.
12
+ default: 'bg-muted/50 border-border text-foreground',
12
13
  destructive:
13
- 'bg-red-50 dark:bg-red-900/20 border-red-200 dark:border-red-800 text-red-700 dark:text-red-400 [&>svg]:text-red-700 dark:[&>svg]:text-red-400',
14
- error:
15
- 'bg-red-50 dark:bg-red-900/20 border-red-200 dark:border-red-800 text-red-700 dark:text-red-400 [&>svg]:text-red-700 dark:[&>svg]:text-red-400',
16
- success:
17
- 'bg-green-50 dark:bg-green-900/20 border-green-200 dark:border-green-800 text-green-700 dark:text-green-400 [&>svg]:text-green-700 dark:[&>svg]:text-green-400',
18
- warning:
19
- 'bg-yellow-50 dark:bg-yellow-900/20 border-yellow-200 dark:border-yellow-800 text-yellow-700 dark:text-yellow-400 [&>svg]:text-yellow-700 dark:[&>svg]:text-yellow-400',
20
- info: 'bg-blue-50 dark:bg-blue-900/20 border-blue-200 dark:border-blue-800 text-blue-700 dark:text-blue-400 [&>svg]:text-blue-700 dark:[&>svg]:text-blue-400',
14
+ 'bg-destructive/10 border-destructive/40 text-foreground [&>svg]:text-destructive',
15
+ error: 'bg-destructive/10 border-destructive/40 text-foreground [&>svg]:text-destructive',
16
+ success: 'bg-success/10 border-success/40 text-foreground [&>svg]:text-success',
17
+ warning: 'bg-warning/10 border-warning/40 text-foreground [&>svg]:text-warning',
18
+ info: 'bg-info/10 border-info/40 text-foreground [&>svg]:text-info',
21
19
  },
22
20
  },
23
21
  defaultVariants: {
@@ -169,6 +169,10 @@
169
169
  class: className,
170
170
  }: Props = $props();
171
171
 
172
+ // Unique per instance: two panels on a page must not share a title id.
173
+ const uid = $props.id();
174
+ const titleId = `data-panel-${uid}-title`;
175
+
172
176
  // Resolve constraints and card config (use $derived to react to prop changes)
173
177
  const resolvedConstraints = $derived({ ...DEFAULT_CONSTRAINTS, ...constraints });
174
178
  const resolvedCardConfig = $derived({ ...DEFAULT_CARD_CONFIG, ...cardConfig });
@@ -440,6 +444,15 @@
440
444
  handleExpandedChange(!isExpanded);
441
445
  }
442
446
 
447
+ // Keyboard activation for the card's role="button" header areas. Space must
448
+ // not also scroll the page, and a held key must not toggle repeatedly.
449
+ function handleCardToggleKeyDown(e: KeyboardEvent) {
450
+ if (e.key !== 'Enter' && e.key !== ' ') return;
451
+ e.preventDefault();
452
+ if (e.repeat) return;
453
+ handleToggleExpand();
454
+ }
455
+
443
456
  // Header collapse button. A detached full panel collapses by re-pinning to its
444
457
  // last edge (edge always holds the previous pin, or the default) and showing the
445
458
  // collapsed tab. Pinned/card just toggle expanded.
@@ -1357,7 +1370,8 @@
1357
1370
  .join(';')}
1358
1371
  role={mode === 'detached' || variant === 'card' ? 'dialog' : 'complementary'}
1359
1372
  aria-modal={isDialog ? 'true' : undefined}
1360
- aria-labelledby={title ? 'data-panel-title' : undefined}
1373
+ aria-labelledby={title && !header ? titleId : undefined}
1374
+ aria-label={title && header ? title : undefined}
1361
1375
  aria-expanded={variant === 'card' ? isExpanded : undefined}
1362
1376
  tabindex="-1"
1363
1377
  onpointerdown={variant === 'full' &&
@@ -1388,7 +1402,7 @@
1388
1402
  {#if variant === 'card' && resolvedCardConfig.showDragHandle}
1389
1403
  <div
1390
1404
  class="absolute top-1.5 left-1/2 -translate-x-1/2 w-10 h-1.5 rounded-full bg-muted-foreground/30 cursor-pointer touch-none"
1391
- onkeydown={(e) => (e.key === 'Enter' || e.key === ' ') && handleToggleExpand()}
1405
+ onkeydown={handleCardToggleKeyDown}
1392
1406
  onpointerdown={handleCardHeaderPointerDown}
1393
1407
  role="button"
1394
1408
  tabindex="0"
@@ -1399,7 +1413,7 @@
1399
1413
  {#if variant === 'card'}
1400
1414
  <div
1401
1415
  class="flex-1 min-w-0"
1402
- onkeydown={(e) => (e.key === 'Enter' || e.key === ' ') && handleToggleExpand()}
1416
+ onkeydown={handleCardToggleKeyDown}
1403
1417
  role="button"
1404
1418
  tabindex="0"
1405
1419
  >
@@ -1410,7 +1424,7 @@
1410
1424
  <p class="text-sm text-muted-foreground truncate">{subtitle}</p>
1411
1425
  {/if}
1412
1426
  {#if title}
1413
- <h3 id="data-panel-title" class="text-lg font-semibold truncate">{title}</h3>
1427
+ <h3 id={titleId} class="text-lg font-semibold truncate">{title}</h3>
1414
1428
  {/if}
1415
1429
  {/if}
1416
1430
  </div>
@@ -1423,7 +1437,7 @@
1423
1437
  <p class="text-sm text-muted-foreground truncate">{subtitle}</p>
1424
1438
  {/if}
1425
1439
  {#if title}
1426
- <h3 id="data-panel-title" class="text-lg font-semibold truncate">{title}</h3>
1440
+ <h3 id={titleId} class="text-lg font-semibold truncate">{title}</h3>
1427
1441
  {/if}
1428
1442
  {/if}
1429
1443
  </div>
@@ -134,14 +134,15 @@
134
134
  document.getElementById(optionId(selectedIndex))?.scrollIntoView?.({ block: 'nearest' });
135
135
  });
136
136
 
137
- // Keyboard shortcut handler
137
+ // Keyboard shortcut handler. Not registered while disabled, so the browser's
138
+ // own Ctrl+K stays usable and no hidden open is queued for later.
138
139
  $effect(() => {
139
- if (!enableShortcut || typeof window === 'undefined') return;
140
+ if (!enabled || !enableShortcut || typeof window === 'undefined') return;
140
141
 
141
142
  const handleKeydown = (e: KeyboardEvent) => {
142
143
  if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === shortcutKey) {
143
144
  e.preventDefault();
144
- open = true;
145
+ setOpen(true);
145
146
  }
146
147
  };
147
148
 
@@ -193,17 +194,24 @@
193
194
 
194
195
  function handleSelect(item: SearchResultItem) {
195
196
  onSelect?.(item);
196
- open = false;
197
- query = '';
197
+ setOpen(false);
198
198
  }
199
199
 
200
- function handleOpenChange(isOpen: boolean) {
200
+ /**
201
+ * Every open/close goes through here. bits-ui only reports its own
202
+ * (Escape, outside click) changes, so the triggers, shortcut and selection
203
+ * must notify `onOpenChange` themselves.
204
+ */
205
+ function setOpen(isOpen: boolean) {
206
+ if (open === isOpen) return;
201
207
  open = isOpen;
202
- onOpenChange?.(isOpen);
203
208
  if (!isOpen) {
204
209
  query = '';
205
210
  selectedIndex = 0;
211
+ if (debounceTimer) clearTimeout(debounceTimer);
212
+ debounceTimer = null;
206
213
  }
214
+ onOpenChange?.(isOpen);
207
215
  }
208
216
  </script>
209
217
 
@@ -213,7 +221,7 @@
213
221
  <!-- Icon-only variant -->
214
222
  <button
215
223
  type="button"
216
- onclick={() => (open = true)}
224
+ onclick={() => setOpen(true)}
217
225
  class={cn(
218
226
  'inline-flex items-center justify-center rounded-md border border-input bg-background text-muted-foreground hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
219
227
  size === 'sm' ? 'h-8 w-8' : 'h-9 w-9',
@@ -235,7 +243,7 @@
235
243
  <!-- Default full search box variant -->
236
244
  <button
237
245
  type="button"
238
- onclick={() => (open = true)}
246
+ onclick={() => setOpen(true)}
239
247
  class={cn(
240
248
  'flex items-center gap-2 rounded-md border border-input bg-background px-3 text-sm text-muted-foreground hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
241
249
  size === 'sm' ? 'h-8' : 'h-9',
@@ -263,7 +271,7 @@
263
271
  {/if}
264
272
 
265
273
  <!-- Search Dialog -->
266
- <DialogPrimitive.Root bind:open onOpenChange={handleOpenChange}>
274
+ <DialogPrimitive.Root {open} onOpenChange={setOpen}>
267
275
  <DialogPrimitive.Portal>
268
276
  <DialogPrimitive.Overlay
269
277
  class="fixed inset-0 z-50 bg-black/50 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0"
@@ -1,6 +1,6 @@
1
1
  import type { HTMLAttributes } from 'svelte/elements';
2
2
  declare const Skeleton: import("svelte").Component<HTMLAttributes<HTMLDivElement> & {
3
- variant?: "default" | "button" | "text" | "title" | "avatar" | "card" | undefined;
3
+ variant?: "text" | "default" | "button" | "title" | "avatar" | "card" | undefined;
4
4
  /** Animation style for the skeleton */
5
5
  animation?: "pulse" | "shimmer" | "none";
6
6
  class?: string;
@@ -64,7 +64,7 @@
64
64
  <!-- Skip Link for Accessibility -->
65
65
  <a
66
66
  href="#{skipToId}"
67
- class="sr-only focus:not-sr-only focus:absolute focus:top-4 focus:left-4 focus:z-[100] focus:rounded-md focus:bg-primary focus:px-4 focus:py-2 focus:text-primary-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2"
67
+ class="sr-only focus:not-sr-only focus:absolute focus:top-4 focus:left-4 focus:z-80 focus:rounded-md focus:bg-primary focus:px-4 focus:py-2 focus:text-primary-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2"
68
68
  >
69
69
  {skipToText}
70
70
  </a>
@@ -244,13 +244,22 @@
244
244
  return;
245
245
  }
246
246
 
247
- // Focus search with / key (when not in input/textarea)
248
- if (e.key === '/' && !['INPUT', 'TEXTAREA'].includes(document.activeElement?.tagName || '')) {
249
- e.preventDefault();
250
- const searchInput = document.querySelector<HTMLInputElement>('[data-sidebar-search]');
251
- if (searchInput) {
252
- searchInput.focus();
247
+ // Focus search with / key - only when a sidebar search exists and the
248
+ // key isn't meant for something else (a text field, rich-text editor,
249
+ // a select, a modified shortcut, or a handler that already claimed it).
250
+ if (e.key === '/') {
251
+ if (e.defaultPrevented || e.ctrlKey || e.metaKey || e.altKey) return;
252
+ const target = e.target as HTMLElement | null;
253
+ if (
254
+ target?.isContentEditable ||
255
+ ['INPUT', 'TEXTAREA', 'SELECT'].includes(target?.tagName ?? '')
256
+ ) {
257
+ return;
253
258
  }
259
+ const searchInput = document.querySelector<HTMLInputElement>('[data-sidebar-search]');
260
+ if (!searchInput) return;
261
+ e.preventDefault();
262
+ searchInput.focus();
254
263
  }
255
264
  };
256
265
 
@@ -52,9 +52,9 @@
52
52
  aria-label="Site footer"
53
53
  class={cn(
54
54
  // Base styles
55
- isDark ? 'bg-gray-600 text-white' : 'bg-muted/30',
55
+ isDark ? 'bg-brand-grey-1 text-white' : 'bg-muted/30',
56
56
  // Border styles - strong accent border or standard
57
- strongBorder ? 'border-t-[10px] border-gray-500' : 'border-t border-border',
57
+ strongBorder ? 'border-t-[10px] border-brand-grey-2' : 'border-t border-border',
58
58
  className
59
59
  )}
60
60
  >
@@ -119,7 +119,7 @@
119
119
  {/if}
120
120
 
121
121
  {#if children}
122
- <div class={cn('mt-8 border-t pt-8', isDark ? 'border-gray-500' : 'border-border')}>
122
+ <div class={cn('mt-8 border-t pt-8', isDark ? 'border-brand-grey-2' : 'border-border')}>
123
123
  {@render children()}
124
124
  </div>
125
125
  {/if}
@@ -127,15 +127,15 @@
127
127
  <div
128
128
  class={cn(
129
129
  'mt-8 flex flex-col items-center justify-between gap-4 border-t pt-8 md:flex-row',
130
- isDark ? 'border-gray-500' : 'border-border'
130
+ isDark ? 'border-brand-grey-2' : 'border-border'
131
131
  )}
132
132
  >
133
133
  {#if showLogo}
134
134
  <LogoMain variant="horizontal" color={isDark ? 'light' : 'dark'} size="sm" />
135
135
  {/if}
136
136
 
137
- <!-- gray-300, not gray-400: #9ca3af is 3.22:1 on the gray-600 footer -->
138
- <p class={cn('text-center text-sm', isDark ? 'text-gray-300' : 'text-muted-foreground')}>
137
+ <!-- white/80 on brand grey 1 is ~6:1; a mid grey (gray-400) fell to 3.22:1 -->
138
+ <p class={cn('text-center text-sm', isDark ? 'text-white/80' : 'text-muted-foreground')}>
139
139
  {displayCopyright}
140
140
  </p>
141
141
  </div>
@@ -73,10 +73,11 @@
73
73
  </aside>
74
74
  {/if}
75
75
 
76
- <!-- Main Content -->
77
- <main id="form-content" class={hasSidebar ? 'order-last lg:order-first' : ''}>
76
+ <!-- Main Content. A div, not <main>: this layout sits inside PublicLayout or
77
+ DashboardLayout, which already render the page's main landmark. -->
78
+ <div id="form-content" class={hasSidebar ? 'order-last lg:order-first' : ''}>
78
79
  {@render children()}
79
- </main>
80
+ </div>
80
81
  </div>
81
82
 
82
83
  <!-- Help Text Footer -->
@@ -273,29 +273,33 @@
273
273
  if (query === prevSearchQuery) return;
274
274
  prevSearchQuery = query;
275
275
 
276
- if (query) {
277
- // Force expand all sections during search
278
- const allSectionIds = roleFilteredNavigation.map((section) => section.id);
279
- sidebarStore.forceExpandSections(allSectionIds);
280
-
281
- // Force expand items with matching children
282
- const itemsToExpand: string[] = [];
283
-
284
- const collectMatchingParents = (items: NavItem[]) => {
285
- items.forEach((item) => {
286
- if (item.children) {
287
- if (hasMatchingDescendant(item, query)) {
288
- itemsToExpand.push(item.id);
289
- }
290
- collectMatchingParents(item.children);
276
+ if (!query) {
277
+ // Search cleared: put back what the user had expanded before it.
278
+ sidebarStore.restoreSearchExpansion();
279
+ return;
280
+ }
281
+
282
+ // Force expand all sections during search
283
+ const allSectionIds = roleFilteredNavigation.map((section) => section.id);
284
+ sidebarStore.forceExpandSections(allSectionIds);
285
+
286
+ // Force expand items with matching children
287
+ const itemsToExpand: string[] = [];
288
+
289
+ const collectMatchingParents = (items: NavItem[]) => {
290
+ items.forEach((item) => {
291
+ if (item.children) {
292
+ if (hasMatchingDescendant(item, query)) {
293
+ itemsToExpand.push(item.id);
291
294
  }
292
- });
293
- };
295
+ collectMatchingParents(item.children);
296
+ }
297
+ });
298
+ };
294
299
 
295
- roleFilteredNavigation.forEach((section) => collectMatchingParents(section.items));
296
- if (itemsToExpand.length > 0) {
297
- sidebarStore.forceExpandItems(itemsToExpand);
298
- }
300
+ roleFilteredNavigation.forEach((section) => collectMatchingParents(section.items));
301
+ if (itemsToExpand.length > 0) {
302
+ sidebarStore.forceExpandItems(itemsToExpand);
299
303
  }
300
304
  });
301
305
 
@@ -25,7 +25,7 @@ declare const Image: import("svelte").Component<Omit<HTMLImgAttributes, "class"
25
25
  */
26
26
  ratio?: MediaAspectRatio | number;
27
27
  /** How the image fills its box */
28
- fit?: "none" | "cover" | "contain" | "fill" | "scale-down" | undefined;
28
+ fit?: "fill" | "none" | "cover" | "contain" | "scale-down" | undefined;
29
29
  /** Corner rounding */
30
30
  rounded?: "sm" | "md" | "lg" | "xl" | "none" | "full" | undefined;
31
31
  /**
@@ -89,6 +89,10 @@
89
89
  const zoom = useMediaZoom();
90
90
 
91
91
  const activeItem = $derived(items[index]);
92
+ // Zoom only applies to images. Gating the keys and the pan mode on this keeps
93
+ // '+' on a video or embed from leaving an invisible zoom that hijacks arrows.
94
+ const canZoom = $derived(zoomable && activeItem?.kind === 'image');
95
+ const panning = $derived(canZoom && zoom.zoomed);
92
96
  const hasMultiple = $derived(items.length > 1);
93
97
  const canGoPrevious = $derived(loop || index > 0);
94
98
  const canGoNext = $derived(loop || index < items.length - 1);
@@ -157,7 +161,7 @@
157
161
  switch (event.key) {
158
162
  case 'ArrowLeft':
159
163
  // While zoomed, arrows pan the image instead of changing items.
160
- if (zoom.zoomed) {
164
+ if (panning) {
161
165
  event.preventDefault();
162
166
  zoom.panBy(40, 0);
163
167
  } else if (hasMultiple) {
@@ -166,7 +170,7 @@
166
170
  }
167
171
  break;
168
172
  case 'ArrowRight':
169
- if (zoom.zoomed) {
173
+ if (panning) {
170
174
  event.preventDefault();
171
175
  zoom.panBy(-40, 0);
172
176
  } else if (hasMultiple) {
@@ -175,13 +179,13 @@
175
179
  }
176
180
  break;
177
181
  case 'ArrowUp':
178
- if (zoom.zoomed) {
182
+ if (panning) {
179
183
  event.preventDefault();
180
184
  zoom.panBy(0, 40);
181
185
  }
182
186
  break;
183
187
  case 'ArrowDown':
184
- if (zoom.zoomed) {
188
+ if (panning) {
185
189
  event.preventDefault();
186
190
  zoom.panBy(0, -40);
187
191
  }
@@ -196,18 +200,18 @@
196
200
  break;
197
201
  case '+':
198
202
  case '=':
199
- if (!zoomable) return;
203
+ if (!canZoom) return;
200
204
  event.preventDefault();
201
205
  zoom.zoomIn();
202
206
  break;
203
207
  case '-':
204
208
  case '_':
205
- if (!zoomable) return;
209
+ if (!canZoom) return;
206
210
  event.preventDefault();
207
211
  zoom.zoomOut();
208
212
  break;
209
213
  case '0':
210
- if (!zoomable) return;
214
+ if (!canZoom) return;
211
215
  event.preventDefault();
212
216
  zoom.reset();
213
217
  break;
@@ -215,7 +219,7 @@
215
219
  }
216
220
 
217
221
  function handleSwipeStart(event: PointerEvent) {
218
- if (zoom.zoomed || !hasMultiple) return;
222
+ if (panning || !hasMultiple) return;
219
223
  swipeActive = true;
220
224
  swipeStartX = event.clientX;
221
225
  swipeStartY = event.clientY;
@@ -280,7 +284,7 @@
280
284
  {/if}
281
285
  </div>
282
286
 
283
- {#if zoomable && activeItem?.kind === 'image'}
287
+ {#if canZoom}
284
288
  <button
285
289
  type="button"
286
290
  class={chromeButtonClass}
@@ -145,6 +145,10 @@
145
145
  }
146
146
 
147
147
  function handleKeyDown(event: KeyboardEvent) {
148
+ // Only keys pressed on the track itself. A video player or seek slider
149
+ // inside a slide handles the same arrows; acting on the bubbled event too
150
+ // would seek and change slides at once.
151
+ if (event.defaultPrevented || event.target !== event.currentTarget) return;
148
152
  switch (event.key) {
149
153
  case 'ArrowLeft':
150
154
  event.preventDefault();
@@ -221,6 +225,7 @@
221
225
  aria-roledescription="slide"
222
226
  aria-label="{slideIndex + 1} of {items.length}"
223
227
  aria-hidden={slideIndex !== index ? 'true' : undefined}
228
+ inert={slideIndex !== index}
224
229
  class="w-full shrink-0 snap-center"
225
230
  >
226
231
  {#if item.kind === 'image'}
@@ -164,6 +164,9 @@
164
164
  let activeIndex = $state(0);
165
165
  let lightboxOpen = $state(false);
166
166
  let lightboxIndex = $state(0);
167
+ // The roving tab stop, clamped so a shrinking `items` list never leaves
168
+ // every tile at tabindex=-1.
169
+ const tabStop = $derived(Math.min(activeIndex, Math.max(items.length - 1, 0)));
167
170
 
168
171
  const gapClass = $derived(
169
172
  ({ none: 'gap-0', sm: 'gap-2', md: 'gap-4', lg: 'gap-6' } as const)[gap]
@@ -315,7 +318,7 @@
315
318
  onselect={handleSelect}
316
319
  class="h-full"
317
320
  data-media-tile
318
- tabindex={!singleTabStop || index === activeIndex ? 0 : -1}
321
+ tabindex={!singleTabStop || index === tabStop ? 0 : -1}
319
322
  />
320
323
  </li>
321
324
  {/each}
@@ -15,6 +15,7 @@
15
15
  getPriorityLabel,
16
16
  } from '@classic-homes/notifications/core';
17
17
  import { cn } from '../../utils.js';
18
+ import { safeHref } from '../../utils/url.js';
18
19
  import Dialog from '../Dialog.svelte';
19
20
  import Button from '../Button.svelte';
20
21
  import Icon, { type IconName } from '../Icon.svelte';
@@ -57,6 +58,8 @@
57
58
  const formattedTime = $derived(notification ? formatFullTimestamp(notification.createdAt) : '');
58
59
  const typeLabel = $derived(notification ? getTypeLabel(notification.type) : '');
59
60
  const priorityLabel = $derived(notification ? getPriorityLabel(notification.priority) : '');
61
+ // actionUrl is API data: drop javascript:/data: and other unsafe schemes
62
+ const actionHref = $derived(safeHref(notification?.actionUrl));
60
63
 
61
64
  function handleOpenChange(newOpen: boolean) {
62
65
  if (!newOpen) {
@@ -94,7 +97,7 @@
94
97
  <div
95
98
  class={cn(
96
99
  'flex h-10 w-10 shrink-0 items-center justify-center rounded-full',
97
- notification.read ? 'bg-muted' : 'bg-blue-100'
100
+ notification.read ? 'bg-muted' : 'bg-info/15'
98
101
  )}
99
102
  >
100
103
  <Icon name={iconName} size={20} class={iconColor} />
@@ -138,10 +141,10 @@
138
141
  </div>
139
142
 
140
143
  <!-- Action link -->
141
- {#if notification.actionUrl && notification.actionLabel}
144
+ {#if actionHref && notification.actionLabel}
142
145
  <div class="pt-2">
143
146
  <a
144
- href={notification.actionUrl}
147
+ href={actionHref}
145
148
  class="inline-flex items-center gap-2 text-sm font-medium text-primary hover:underline"
146
149
  >
147
150
  {notification.actionLabel}
@@ -202,8 +205,8 @@
202
205
  {/snippet}
203
206
  </Button>
204
207
  {/if}
205
- {#if notification.actionUrl}
206
- <Button class={footerButtonClass} href={notification.actionUrl}>
208
+ {#if actionHref}
209
+ <Button class={footerButtonClass} href={actionHref}>
207
210
  {#snippet children()}
208
211
  {notification.actionLabel || 'View Details'}
209
212
  {/snippet}
@@ -15,6 +15,7 @@
15
15
  getTypeLabel,
16
16
  } from '@classic-homes/notifications/core';
17
17
  import { cn } from '../../utils.js';
18
+ import { safeHref } from '../../utils/url.js';
18
19
  import Icon, { type IconName } from '../Icon.svelte';
19
20
  import Badge from '../Badge.svelte';
20
21
  import Checkbox from '../Checkbox.svelte';
@@ -49,6 +50,8 @@
49
50
  const priorityBadgeVariant = $derived(getPriorityBadgeVariant(notification.priority));
50
51
  const relativeTime = $derived(formatRelativeTime(notification.createdAt));
51
52
  const typeLabel = $derived(getTypeLabel(notification.type));
53
+ // actionUrl is API data: drop javascript:/data: and other unsafe schemes
54
+ const actionHref = $derived(safeHref(notification.actionUrl));
52
55
 
53
56
  function handleClick() {
54
57
  onClick?.(notification);
@@ -59,6 +62,9 @@
59
62
  }
60
63
 
61
64
  function handleKeyDown(e: KeyboardEvent) {
65
+ // Only keys pressed on the row itself; Enter on the nested action link
66
+ // must follow the link, not activate the row.
67
+ if (e.target !== e.currentTarget) return;
62
68
  if (e.key === 'Enter' || e.key === ' ') {
63
69
  e.preventDefault();
64
70
  onClick?.(notification);
@@ -70,7 +76,7 @@
70
76
  <div
71
77
  class={cn(
72
78
  'group relative flex gap-3 rounded-lg border p-4',
73
- !notification.read && 'bg-blue-50/50 border-blue-200',
79
+ !notification.read && 'bg-info/5 border-info/30',
74
80
  notification.read && 'bg-card border-border',
75
81
  onClick && 'cursor-pointer hover:bg-muted/50',
76
82
  isSelected && 'ring-2 ring-primary ring-offset-2',
@@ -88,7 +94,11 @@
88
94
  onclick={(e) => e.stopPropagation()}
89
95
  onkeydown={(e) => e.stopPropagation()}
90
96
  >
91
- <Checkbox checked={isSelected} onCheckedChange={handleCheckboxChange} />
97
+ <Checkbox
98
+ checked={isSelected}
99
+ onCheckedChange={handleCheckboxChange}
100
+ aria-label={`Select notification: ${notification.title}`}
101
+ />
92
102
  </div>
93
103
  {/if}
94
104
 
@@ -96,7 +106,7 @@
96
106
  <div
97
107
  class={cn(
98
108
  'flex h-10 w-10 shrink-0 items-center justify-center rounded-full',
99
- notification.read ? 'bg-muted' : 'bg-blue-100'
109
+ notification.read ? 'bg-muted' : 'bg-info/15'
100
110
  )}
101
111
  >
102
112
  <Icon name={iconName} size={20} class={iconColor} />
@@ -132,15 +142,15 @@
132
142
  {relativeTime}
133
143
  </span>
134
144
  {#if !notification.read}
135
- <span class="h-2 w-2 rounded-full bg-blue-500" aria-label="Unread"></span>
145
+ <span class="h-2 w-2 rounded-full bg-info" aria-label="Unread"></span>
136
146
  {/if}
137
147
  </div>
138
148
  </div>
139
149
 
140
- {#if notification.actionUrl && notification.actionLabel}
150
+ {#if actionHref && notification.actionLabel}
141
151
  <div class="mt-2">
142
152
  <a
143
- href={notification.actionUrl}
153
+ href={actionHref}
144
154
  class="text-sm font-medium text-primary hover:underline"
145
155
  onclick={(e) => e.stopPropagation()}
146
156
  >
@@ -93,8 +93,11 @@
93
93
  onSelect(item.id);
94
94
  }
95
95
 
96
+ // Disabled entries can't take focus, so arrow navigation must skip them.
96
97
  function entries() {
97
- return Array.from(listEl?.querySelectorAll<HTMLButtonElement>('[data-palette-item]') ?? []);
98
+ return Array.from(
99
+ listEl?.querySelectorAll<HTMLButtonElement>('[data-palette-item]:not(:disabled)') ?? []
100
+ );
98
101
  }
99
102
 
100
103
  function searchKeydown(event: KeyboardEvent) {
@@ -24,7 +24,11 @@
24
24
  toolbar?: Snippet;
25
25
  /** Accessible name of the canvas region (default: "Canvas") */
26
26
  label?: string;
27
- /** Clicked on the backdrop, outside any item: usually clears the selection */
27
+ /**
28
+ * Clicked on the backdrop, outside any item: usually clears the selection.
29
+ * Also called on Escape pressed inside the canvas (outside a text field),
30
+ * the keyboard path back to "nothing selected".
31
+ */
28
32
  onBackgroundClick?: () => void;
29
33
  /** Classes for the frame that holds the content */
30
34
  frameClass?: string;
@@ -47,6 +51,20 @@
47
51
  // On a phone the canvas already is phone width; the switch would do nothing
48
52
  const showDevices = $derived(deviceToggle && studio?.layout !== 'compact');
49
53
  const width = $derived(device === 'phone' && showDevices ? '390px' : maxWidth);
54
+
55
+ function handleKeydown(e: KeyboardEvent) {
56
+ if (e.key !== 'Escape' || e.defaultPrevented || !onBackgroundClick) return;
57
+ // Escape in a field belongs to the field (clearing, closing its own UI).
58
+ const target = e.target as HTMLElement | null;
59
+ if (
60
+ target?.isContentEditable ||
61
+ ['INPUT', 'TEXTAREA', 'SELECT'].includes(target?.tagName ?? '')
62
+ ) {
63
+ return;
64
+ }
65
+ e.preventDefault();
66
+ onBackgroundClick();
67
+ }
50
68
  </script>
51
69
 
52
70
  <section aria-label={label} class={cn('flex h-full flex-col', className)}>
@@ -67,12 +85,15 @@
67
85
  </div>
68
86
  {/if}
69
87
 
70
- <!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_static_element_interactions -->
88
+ <!-- Backdrop click clears the selection; Escape (bubbling from a focused
89
+ item) is the keyboard equivalent. -->
90
+ <!-- svelte-ignore a11y_no_static_element_interactions -->
71
91
  <div
72
92
  class="min-h-0 flex-1 overflow-y-auto px-3 pb-20 pt-3 md:px-6 md:pb-10"
73
93
  onclick={(e) => {
74
94
  if (e.target === e.currentTarget) onBackgroundClick?.();
75
95
  }}
96
+ onkeydown={handleKeydown}
76
97
  >
77
98
  <div
78
99
  class={cn(