@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.
- package/dist/lib/components/Alert.svelte +8 -10
- package/dist/lib/components/DataPanel.svelte +19 -5
- package/dist/lib/components/HeaderSearch.svelte +18 -10
- package/dist/lib/components/Skeleton.svelte.d.ts +1 -1
- 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 +25 -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/components/studio/StudioCanvasItem.svelte +31 -4
- package/dist/lib/components/studio/StudioCanvasItem.svelte.d.ts +7 -0
- package/dist/lib/composables/useFocusTrap.svelte.js +9 -2
- 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 +6 -0
- package/dist/lib/stores/sidebar.svelte.js +61 -26
- 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-reveal.test.js +0 -50
- 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
|
-
|
|
11
|
-
|
|
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-
|
|
14
|
-
error:
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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 ?
|
|
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={
|
|
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={
|
|
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=
|
|
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=
|
|
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
|
-
|
|
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
|
-
|
|
197
|
-
query = '';
|
|
197
|
+
setOpen(false);
|
|
198
198
|
}
|
|
199
199
|
|
|
200
|
-
|
|
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={() => (
|
|
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={() => (
|
|
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
|
|
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?: "
|
|
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-
|
|
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
|
|
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
|
|
|
@@ -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(
|