@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.
- package/dist/lib/components/Alert.svelte +8 -10
- package/dist/lib/components/Combobox.svelte +2 -2
- package/dist/lib/components/DataPanel.svelte +19 -5
- package/dist/lib/components/DataTable.svelte +15 -6
- package/dist/lib/components/DataTable.svelte.d.ts +1 -1
- package/dist/lib/components/DateTimePicker.svelte +31 -34
- package/dist/lib/components/DropdownMenu.svelte +52 -30
- package/dist/lib/components/DropdownMenu.svelte.d.ts +5 -0
- package/dist/lib/components/FileUpload.svelte +5 -19
- package/dist/lib/components/HeaderSearch.svelte +18 -10
- package/dist/lib/components/MultiSelect.svelte +2 -2
- package/dist/lib/components/NumberInput.svelte +15 -2
- package/dist/lib/components/Select.svelte +19 -5
- package/dist/lib/components/Select.svelte.d.ts +4 -0
- package/dist/lib/components/Signature.svelte +15 -4
- package/dist/lib/components/Signature.svelte.d.ts +1 -1
- package/dist/lib/components/Skeleton.svelte.d.ts +1 -1
- package/dist/lib/components/Slider.svelte +8 -6
- package/dist/lib/components/Tabs.svelte +10 -12
- package/dist/lib/components/Toast.svelte +12 -12
- package/dist/lib/components/data-table/DataTableColumnVisibility.svelte +24 -26
- package/dist/lib/components/data-table/DataTableEditableCell.svelte +31 -18
- package/dist/lib/components/data-table/DataTableEditableCell.svelte.d.ts +0 -7
- package/dist/lib/components/data-table/DataTablePagination.svelte +5 -1
- package/dist/lib/components/data-table/DataTableVirtual.svelte +4 -7
- package/dist/lib/components/data-table/DataTableVirtual.svelte.d.ts +3 -0
- package/dist/lib/components/data-table/createDataTable.svelte.d.ts +6 -1
- package/dist/lib/components/data-table/createDataTable.svelte.js +22 -9
- package/dist/lib/components/layout/AppShell.svelte +1 -1
- package/dist/lib/components/layout/DashboardLayout.svelte +15 -6
- package/dist/lib/components/layout/Footer.svelte +6 -6
- package/dist/lib/components/layout/FormPageLayout.svelte +4 -3
- package/dist/lib/components/layout/Sidebar.svelte +53 -21
- package/dist/lib/components/media/Image.svelte.d.ts +1 -1
- package/dist/lib/components/media/Lightbox.svelte +13 -9
- package/dist/lib/components/media/MediaCarousel.svelte +5 -0
- package/dist/lib/components/media/MediaGrid.svelte +4 -1
- package/dist/lib/components/notifications/NotificationDialog.svelte +8 -5
- package/dist/lib/components/notifications/NotificationItem.svelte +16 -6
- package/dist/lib/components/studio/InsertPalette.svelte +4 -1
- package/dist/lib/components/studio/StudioCanvas.svelte +23 -2
- package/dist/lib/components/studio/StudioCanvas.svelte.d.ts +5 -1
- package/dist/lib/composables/useAsync.svelte.d.ts +1 -1
- package/dist/lib/composables/useAsync.svelte.js +33 -14
- package/dist/lib/composables/useFocusTrap.svelte.js +9 -2
- package/dist/lib/composables/useForm.svelte.js +19 -7
- package/dist/lib/composables/usePersistedForm.svelte.js +37 -10
- package/dist/lib/notices/sanitize.js +10 -3
- package/dist/lib/notices/store.d.ts +3 -3
- package/dist/lib/notices/store.js +14 -2
- package/dist/lib/notices/types.d.ts +8 -8
- package/dist/lib/notices/types.js +5 -1
- package/dist/lib/stores/sidebar.svelte.d.ts +12 -0
- package/dist/lib/stores/sidebar.svelte.js +76 -26
- package/dist/lib/stores/toast.svelte.js +12 -1
- package/dist/lib/utils/date.d.ts +3 -1
- package/dist/lib/utils/date.js +54 -34
- package/dist/lib/utils/form.d.ts +2 -1
- package/dist/lib/utils/form.js +3 -2
- package/dist/lib/utils/media.js +4 -3
- package/dist/lib/utils/url.d.ts +10 -0
- package/dist/lib/utils/url.js +21 -0
- package/dist/lib/utils.js +14 -1
- package/package.json +5 -4
- package/dist/__tests__/FileUpload.test.js +0 -45
- package/dist/__tests__/a11y/Alert.a11y.test.js +0 -84
- package/dist/__tests__/a11y/AlertDialog.a11y.test.js +0 -126
- package/dist/__tests__/a11y/Button.a11y.test.js +0 -66
- package/dist/__tests__/a11y/Card.a11y.test.js +0 -77
- package/dist/__tests__/a11y/Combobox.a11y.test.js +0 -209
- package/dist/__tests__/a11y/Dialog.a11y.test.js +0 -103
- package/dist/__tests__/a11y/DropdownMenu.a11y.test.js +0 -132
- package/dist/__tests__/a11y/FileUpload.a11y.test.js +0 -158
- package/dist/__tests__/a11y/FilterToolbar.a11y.test.js +0 -62
- package/dist/__tests__/a11y/Icon.a11y.test.js +0 -119
- package/dist/__tests__/a11y/Input.a11y.test.js +0 -96
- package/dist/__tests__/a11y/LiveRegion.a11y.test.js +0 -102
- package/dist/__tests__/a11y/Media.a11y.test.js +0 -145
- package/dist/__tests__/a11y/PublicLayout.a11y.test.js +0 -139
- package/dist/__tests__/a11y/SegmentedControl.a11y.test.js +0 -50
- package/dist/__tests__/a11y/Switch.a11y.test.js +0 -95
- package/dist/__tests__/a11y/Tabs.a11y.test.js +0 -116
- package/dist/__tests__/a11y/Textarea.a11y.test.js +0 -111
- package/dist/__tests__/a11y/Toolbar.a11y.test.js +0 -31
- package/dist/__tests__/a11y/ViewToggle.a11y.test.js +0 -40
- package/dist/__tests__/action-header.test.js +0 -150
- package/dist/__tests__/alert-dialog-dismiss.test.js +0 -54
- package/dist/__tests__/code-field.test.js +0 -65
- package/dist/__tests__/control-text-color.test.js +0 -29
- package/dist/__tests__/data-panel-card-gestures.test.js +0 -138
- package/dist/__tests__/data-panel-viewport.test.js +0 -45
- package/dist/__tests__/dropdown-trigger.test.js +0 -39
- package/dist/__tests__/header-search.test.js +0 -90
- package/dist/__tests__/media-components.test.js +0 -206
- package/dist/__tests__/media-hls.test.js +0 -273
- package/dist/__tests__/media-matterport.test.js +0 -296
- package/dist/__tests__/media-players.test.js +0 -244
- package/dist/__tests__/media-scrubber.test.js +0 -44
- package/dist/__tests__/media-utils.test.js +0 -427
- package/dist/__tests__/nav-access.test.js +0 -145
- package/dist/__tests__/page-header.test.js +0 -36
- package/dist/__tests__/public-layout-drawer.test.js +0 -116
- package/dist/__tests__/setup.js +0 -56
- package/dist/__tests__/sidebar-collapse.test.js +0 -58
- package/dist/__tests__/sidebar-docs-parity.test.js +0 -134
- package/dist/__tests__/sidebar-expansion.test.js +0 -59
- package/dist/__tests__/sidebar-items.test.js +0 -59
- package/dist/__tests__/sidebar-mobile.test.js +0 -86
- package/dist/__tests__/studio-canvas-item.svelte.test.js +0 -98
- package/dist/__tests__/studio.test.js +0 -240
|
@@ -244,13 +244,22 @@
|
|
|
244
244
|
return;
|
|
245
245
|
}
|
|
246
246
|
|
|
247
|
-
// Focus search with / key
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
if (
|
|
252
|
-
|
|
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-
|
|
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-
|
|
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-
|
|
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-
|
|
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
|
-
<!--
|
|
138
|
-
<p class={cn('text-center text-sm', isDark ? 'text-
|
|
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
|
-
|
|
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
|
-
</
|
|
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
|
-
//
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
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
|
-
|
|
296
|
-
|
|
297
|
-
|
|
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?: "
|
|
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 (
|
|
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 (
|
|
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 (
|
|
182
|
+
if (panning) {
|
|
179
183
|
event.preventDefault();
|
|
180
184
|
zoom.panBy(0, 40);
|
|
181
185
|
}
|
|
182
186
|
break;
|
|
183
187
|
case 'ArrowDown':
|
|
184
|
-
if (
|
|
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 (!
|
|
203
|
+
if (!canZoom) return;
|
|
200
204
|
event.preventDefault();
|
|
201
205
|
zoom.zoomIn();
|
|
202
206
|
break;
|
|
203
207
|
case '-':
|
|
204
208
|
case '_':
|
|
205
|
-
if (!
|
|
209
|
+
if (!canZoom) return;
|
|
206
210
|
event.preventDefault();
|
|
207
211
|
zoom.zoomOut();
|
|
208
212
|
break;
|
|
209
213
|
case '0':
|
|
210
|
-
if (!
|
|
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 (
|
|
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
|
|
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 ===
|
|
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-
|
|
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
|
|
144
|
+
{#if actionHref && notification.actionLabel}
|
|
142
145
|
<div class="pt-2">
|
|
143
146
|
<a
|
|
144
|
-
href={
|
|
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
|
|
206
|
-
<Button class={footerButtonClass} href={
|
|
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-
|
|
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
|
|
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-
|
|
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-
|
|
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
|
|
150
|
+
{#if actionHref && notification.actionLabel}
|
|
141
151
|
<div class="mt-2">
|
|
142
152
|
<a
|
|
143
|
-
href={
|
|
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(
|
|
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
|
-
/**
|
|
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
|
-
<!--
|
|
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
|
-
/**
|
|
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
|
-
* -
|
|
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
|
-
*
|
|
60
|
+
* Cancel a pending debounced call, settling its promise with undefined
|
|
58
61
|
*/
|
|
59
|
-
|
|
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
|
-
|
|
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
|
-
|
|
126
|
-
|
|
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
|
|
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) =>
|
|
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.
|