@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
@@ -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
 
@@ -319,6 +323,34 @@
319
323
  sidebarStore.setDefaultItems(defaultExpanded);
320
324
  });
321
325
 
326
+ // Keep the active item visible: open the section and parent items holding
327
+ // it when it changes. Declared after the defaults effects so it runs after
328
+ // them: while nothing is saved, every new navigation re-applies the
329
+ // defaults, which would close it again. Once the user has toggled (saved),
330
+ // a section they closed stays closed until the active item moves.
331
+ let revealedActiveId: string | null = null;
332
+ $effect(() => {
333
+ type ActivePath = { id: string; parents: string[] };
334
+ const findActive = (items: NavItem[], parents: string[]): ActivePath | null => {
335
+ for (const item of items) {
336
+ if (item.active) return { id: item.id, parents };
337
+ const found = item.children && findActive(item.children, [...parents, item.id]);
338
+ if (found) return found;
339
+ }
340
+ return null;
341
+ };
342
+ for (const section of roleFilteredNavigation) {
343
+ const active = findActive(section.items, []);
344
+ if (!active) continue;
345
+ if (active.id !== revealedActiveId || !sidebarStore.hasPersistedExpansionState) {
346
+ sidebarStore.revealExpansion([section.id], active.parents);
347
+ }
348
+ revealedActiveId = active.id;
349
+ return;
350
+ }
351
+ revealedActiveId = null;
352
+ });
353
+
322
354
  // Flyout state for collapsed sidebar
323
355
  let hoveredFlyoutItem = $state<NavItem | null>(null);
324
356
  let flyoutTop = $state(0);
@@ -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(
@@ -19,7 +19,11 @@ interface Props {
19
19
  toolbar?: Snippet;
20
20
  /** Accessible name of the canvas region (default: "Canvas") */
21
21
  label?: string;
22
- /** Clicked on the backdrop, outside any item: usually clears the selection */
22
+ /**
23
+ * Clicked on the backdrop, outside any item: usually clears the selection.
24
+ * Also called on Escape pressed inside the canvas (outside a text field),
25
+ * the keyboard path back to "nothing selected".
26
+ */
23
27
  onBackgroundClick?: () => void;
24
28
  /** Classes for the frame that holds the content */
25
29
  frameClass?: string;
@@ -6,7 +6,7 @@
6
6
  * - Error handling with toast integration
7
7
  * - Success/error callbacks
8
8
  * - Retry functionality
9
- * - Abort/cancel support
9
+ * - Overlapping calls: only the latest call (and none after reset) updates state
10
10
  * - Debounce support
11
11
  *
12
12
  * @example
@@ -6,7 +6,7 @@
6
6
  * - Error handling with toast integration
7
7
  * - Success/error callbacks
8
8
  * - Retry functionality
9
- * - Abort/cancel support
9
+ * - Overlapping calls: only the latest call (and none after reset) updates state
10
10
  * - Debounce support
11
11
  *
12
12
  * @example
@@ -51,21 +51,35 @@ export function useAsync(options) {
51
51
  // Derived state
52
52
  const isSuccess = $derived(isExecuted && !error && !isLoading);
53
53
  const isError = $derived(isExecuted && !!error);
54
- // Debounce timer
54
+ // Debounce timer, and the resolver of the call it is holding back
55
55
  let debounceTimer;
56
+ let pendingResolve;
57
+ // Id of the latest call; results from older calls are ignored
58
+ let callId = 0;
56
59
  /**
57
- * Execute the async function
60
+ * Cancel a pending debounced call, settling its promise with undefined
58
61
  */
59
- async function execute(...args) {
60
- // Clear any pending debounce
62
+ function cancelPending() {
61
63
  if (debounceTimer) {
62
64
  clearTimeout(debounceTimer);
63
65
  debounceTimer = undefined;
64
66
  }
67
+ pendingResolve?.(undefined);
68
+ pendingResolve = undefined;
69
+ }
70
+ /**
71
+ * Execute the async function
72
+ */
73
+ async function execute(...args) {
74
+ // Clear any pending debounce
75
+ cancelPending();
65
76
  // If debounce is set, delay execution
66
77
  if (debounceMs && debounceMs > 0) {
67
78
  return new Promise((resolve) => {
79
+ pendingResolve = resolve;
68
80
  debounceTimer = setTimeout(async () => {
81
+ debounceTimer = undefined;
82
+ pendingResolve = undefined;
69
83
  const result = await executeImmediate(...args);
70
84
  resolve(result);
71
85
  }, debounceMs);
@@ -77,12 +91,16 @@ export function useAsync(options) {
77
91
  * Execute immediately without debounce
78
92
  */
79
93
  async function executeImmediate(...args) {
94
+ const id = ++callId;
80
95
  lastArgs = args;
81
96
  isLoading = true;
82
97
  error = undefined;
83
98
  isExecuted = true;
84
99
  try {
85
100
  const result = await fn(...args);
101
+ // A newer call (or reset) superseded this one: leave state alone
102
+ if (id !== callId)
103
+ return result;
86
104
  data = result;
87
105
  if (onSuccess) {
88
106
  onSuccess(result);
@@ -93,6 +111,8 @@ export function useAsync(options) {
93
111
  return result;
94
112
  }
95
113
  catch (err) {
114
+ if (id !== callId)
115
+ return undefined;
96
116
  const normalizedError = err instanceof Error ? err : new Error(String(err));
97
117
  error = normalizedError;
98
118
  if (onError) {
@@ -104,7 +124,8 @@ export function useAsync(options) {
104
124
  return undefined;
105
125
  }
106
126
  finally {
107
- isLoading = false;
127
+ if (id === callId)
128
+ isLoading = false;
108
129
  }
109
130
  }
110
131
  /**
@@ -121,11 +142,9 @@ export function useAsync(options) {
121
142
  * Reset state to initial values
122
143
  */
123
144
  function reset() {
124
- // Clear any pending debounce
125
- if (debounceTimer) {
126
- clearTimeout(debounceTimer);
127
- debounceTimer = undefined;
128
- }
145
+ // Clear any pending debounce, and drop the result of any call in flight
146
+ cancelPending();
147
+ callId++;
129
148
  data = initialData;
130
149
  error = undefined;
131
150
  isLoading = false;
@@ -153,11 +172,11 @@ export function useAsync(options) {
153
172
  function clearError() {
154
173
  error = undefined;
155
174
  }
156
- // Execute immediately if requested
157
- if (immediate && immediateArgs) {
175
+ // Execute immediately if requested (zero-argument functions need no immediateArgs)
176
+ if (immediate) {
158
177
  // Use $effect-like behavior by queueing the execution
159
178
  queueMicrotask(() => {
160
- execute(...immediateArgs);
179
+ execute(...(immediateArgs ?? []));
161
180
  });
162
181
  }
163
182
  return {
@@ -8,12 +8,19 @@ const FOCUSABLE_SELECTOR = [
8
8
  'iframe',
9
9
  'object',
10
10
  'embed',
11
- '[contenteditable]',
11
+ '[contenteditable]:not([contenteditable="false"])',
12
12
  '[tabindex]:not([tabindex="-1"])',
13
13
  ].join(',');
14
14
  function getFocusableElements(container) {
15
15
  const elements = container.querySelectorAll(FOCUSABLE_SELECTOR);
16
- return Array.from(elements).filter((el) => el.offsetParent !== null && getComputedStyle(el).visibility !== 'hidden');
16
+ return Array.from(elements).filter((el) =>
17
+ // tabindex="-1" on a natively focusable element (roving-tabindex radios,
18
+ // toolbar buttons) takes it out of the Tab order, so it can't be the
19
+ // trap's first or last stop.
20
+ el.tabIndex >= 0 &&
21
+ !el.closest('[inert]') &&
22
+ el.offsetParent !== null &&
23
+ getComputedStyle(el).visibility !== 'hidden');
17
24
  }
18
25
  /**
19
26
  * Creates a focus trap action for use with Svelte's use: directive.