@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
|
@@ -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;
|
|
@@ -12,6 +12,11 @@
|
|
|
12
12
|
* gets a ring and its `toolbar` (duplicate, delete...). With `onInsert`, a
|
|
13
13
|
* "+" between this item and the next adds a new one right there. On phones
|
|
14
14
|
* picking an item opens the inspector sheet.
|
|
15
|
+
*
|
|
16
|
+
* Picking an item from elsewhere (an outline, an issue list) scrolls it into
|
|
17
|
+
* view; picking it on the canvas does not, so it never moves under the
|
|
18
|
+
* pointer. `insertPlacement="gutter"` puts the "+" in the left margin, for
|
|
19
|
+
* items edited in place, where a centred "+" would sit on the text.
|
|
15
20
|
*/
|
|
16
21
|
interface Props {
|
|
17
22
|
children: Snippet;
|
|
@@ -32,6 +37,8 @@
|
|
|
32
37
|
* Turn off for items edited in place, where the sheet would cover them.
|
|
33
38
|
*/
|
|
34
39
|
revealOnSelect?: boolean;
|
|
40
|
+
/** Where the "+" sits: centred on the item's bottom edge, or in the left gutter beside it */
|
|
41
|
+
insertPlacement?: 'center' | 'gutter';
|
|
35
42
|
class?: string;
|
|
36
43
|
}
|
|
37
44
|
|
|
@@ -45,11 +52,14 @@
|
|
|
45
52
|
insertLabel,
|
|
46
53
|
invalid = false,
|
|
47
54
|
revealOnSelect = true,
|
|
55
|
+
insertPlacement = 'center',
|
|
48
56
|
class: className,
|
|
49
57
|
}: Props = $props();
|
|
50
58
|
|
|
51
59
|
const studio = getStudioContext();
|
|
52
60
|
let element: HTMLDivElement | undefined = $state();
|
|
61
|
+
/** The item was picked on the canvas (pointer or focus), so it is already in view. */
|
|
62
|
+
let pickedHere = false;
|
|
53
63
|
|
|
54
64
|
// Quick actions (toolbar, "+") act on the item without selecting it. A
|
|
55
65
|
// dialog they open (the insert palette) returns focus to them on close;
|
|
@@ -63,7 +73,14 @@
|
|
|
63
73
|
}
|
|
64
74
|
|
|
65
75
|
$effect(() => {
|
|
66
|
-
if (!selected || !element)
|
|
76
|
+
if (!selected || !element) {
|
|
77
|
+
pickedHere = false;
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
if (pickedHere) {
|
|
81
|
+
pickedHere = false;
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
67
84
|
const el = element;
|
|
68
85
|
void tick().then(() => {
|
|
69
86
|
// Don't yank the canvas while the user is working inside the item
|
|
@@ -88,9 +105,14 @@
|
|
|
88
105
|
invalid && 'outline-dashed outline-2 -outline-offset-2 outline-warning/70',
|
|
89
106
|
className
|
|
90
107
|
)}
|
|
108
|
+
onpointerdowncapture={(e) => {
|
|
109
|
+
if (!selected && !inQuickAction(e.target)) pickedHere = true;
|
|
110
|
+
}}
|
|
91
111
|
onclickcapture={(e) => select(!inQuickAction(e.target))}
|
|
92
112
|
onfocusin={(e) => {
|
|
93
|
-
if (
|
|
113
|
+
if (inQuickAction(e.target)) return;
|
|
114
|
+
if (!selected) pickedHere = true;
|
|
115
|
+
select(false);
|
|
94
116
|
}}
|
|
95
117
|
>
|
|
96
118
|
{#if !selected}
|
|
@@ -121,8 +143,13 @@
|
|
|
121
143
|
type="button"
|
|
122
144
|
aria-label={insertLabel ?? `Add below ${label}`}
|
|
123
145
|
title={insertLabel ?? 'Add below'}
|
|
124
|
-
class=
|
|
125
|
-
|
|
146
|
+
class={cn(
|
|
147
|
+
'absolute top-0 z-10 flex h-7 w-7 -translate-y-1/2 items-center justify-center rounded-full border border-border bg-background text-muted-foreground opacity-0 shadow-sm hover:border-primary hover:text-primary focus-visible:opacity-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring group-hover/item:opacity-100 [@media(hover:none)]:opacity-100',
|
|
148
|
+
insertPlacement === 'gutter'
|
|
149
|
+
? 'right-full mr-1.5 group-focus-within/item:opacity-100'
|
|
150
|
+
: 'left-1/2 -translate-x-1/2',
|
|
151
|
+
selected && insertPlacement === 'center' && 'opacity-100'
|
|
152
|
+
)}
|
|
126
153
|
onclick={(e) => {
|
|
127
154
|
e.stopPropagation();
|
|
128
155
|
onInsert?.();
|
|
@@ -7,6 +7,11 @@ import { type Snippet } from 'svelte';
|
|
|
7
7
|
* gets a ring and its `toolbar` (duplicate, delete...). With `onInsert`, a
|
|
8
8
|
* "+" between this item and the next adds a new one right there. On phones
|
|
9
9
|
* picking an item opens the inspector sheet.
|
|
10
|
+
*
|
|
11
|
+
* Picking an item from elsewhere (an outline, an issue list) scrolls it into
|
|
12
|
+
* view; picking it on the canvas does not, so it never moves under the
|
|
13
|
+
* pointer. `insertPlacement="gutter"` puts the "+" in the left margin, for
|
|
14
|
+
* items edited in place, where a centred "+" would sit on the text.
|
|
10
15
|
*/
|
|
11
16
|
interface Props {
|
|
12
17
|
children: Snippet;
|
|
@@ -27,6 +32,8 @@ interface Props {
|
|
|
27
32
|
* Turn off for items edited in place, where the sheet would cover them.
|
|
28
33
|
*/
|
|
29
34
|
revealOnSelect?: boolean;
|
|
35
|
+
/** Where the "+" sits: centred on the item's bottom edge, or in the left gutter beside it */
|
|
36
|
+
insertPlacement?: 'center' | 'gutter';
|
|
30
37
|
class?: string;
|
|
31
38
|
}
|
|
32
39
|
declare const StudioCanvasItem: import("svelte").Component<Props, {}, "">;
|
|
@@ -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.
|
|
@@ -15,8 +15,10 @@ import { BROWSER } from 'esm-env';
|
|
|
15
15
|
const ALLOWED_TAGS = ['a', 'b', 'br', 'em', 'i', 'p', 'span', 'strong', 'u', 'ul', 'ol', 'li'];
|
|
16
16
|
/**
|
|
17
17
|
* Allowed HTML attributes for notice messages.
|
|
18
|
+
* `class` is deliberately excluded: with Tailwind on the page, arbitrary
|
|
19
|
+
* utilities (`fixed inset-0 z-[9999]`) would let a message overlay the app.
|
|
18
20
|
*/
|
|
19
|
-
const ALLOWED_ATTR = ['href', 'target', 'rel'
|
|
21
|
+
const ALLOWED_ATTR = ['href', 'target', 'rel'];
|
|
20
22
|
/**
|
|
21
23
|
* Escape HTML entities for safe rendering when DOMPurify isn't available.
|
|
22
24
|
* This is a fallback for server-side rendering.
|
|
@@ -31,6 +33,12 @@ function escapeHtml(html) {
|
|
|
31
33
|
}
|
|
32
34
|
// Cached DOMPurify instance (only loaded on client)
|
|
33
35
|
let purify = null;
|
|
36
|
+
/** Force rel="noopener noreferrer" on any element that kept a target. */
|
|
37
|
+
function enforceSafeRel(node) {
|
|
38
|
+
if (node.hasAttribute?.('target')) {
|
|
39
|
+
node.setAttribute('rel', 'noopener noreferrer');
|
|
40
|
+
}
|
|
41
|
+
}
|
|
34
42
|
/**
|
|
35
43
|
* Initialize DOMPurify on the client.
|
|
36
44
|
* Must be called before sanitizeHtml on the client side.
|
|
@@ -38,6 +46,7 @@ let purify = null;
|
|
|
38
46
|
export async function initSanitizer() {
|
|
39
47
|
if (BROWSER && !purify) {
|
|
40
48
|
const DOMPurify = await import('isomorphic-dompurify');
|
|
49
|
+
DOMPurify.default.addHook('afterSanitizeAttributes', enforceSafeRel);
|
|
41
50
|
purify = DOMPurify.default;
|
|
42
51
|
}
|
|
43
52
|
}
|
|
@@ -71,7 +80,5 @@ export function sanitizeHtml(html) {
|
|
|
71
80
|
return purify.sanitize(html, {
|
|
72
81
|
ALLOWED_TAGS,
|
|
73
82
|
ALLOWED_ATTR,
|
|
74
|
-
// Ensure links open safely
|
|
75
|
-
ADD_ATTR: ['target'],
|
|
76
83
|
});
|
|
77
84
|
}
|
|
@@ -77,8 +77,8 @@ export declare const activeNotices: import("svelte/store").Readable<{
|
|
|
77
77
|
location: "banner" | "both" | "card";
|
|
78
78
|
pages: string[];
|
|
79
79
|
link?: {
|
|
80
|
-
href: string;
|
|
81
80
|
text: string;
|
|
81
|
+
href: string;
|
|
82
82
|
external?: boolean | undefined;
|
|
83
83
|
} | undefined;
|
|
84
84
|
title?: string | undefined;
|
|
@@ -98,8 +98,8 @@ export declare const bannerNotices: import("svelte/store").Readable<{
|
|
|
98
98
|
location: "banner" | "both" | "card";
|
|
99
99
|
pages: string[];
|
|
100
100
|
link?: {
|
|
101
|
-
href: string;
|
|
102
101
|
text: string;
|
|
102
|
+
href: string;
|
|
103
103
|
external?: boolean | undefined;
|
|
104
104
|
} | undefined;
|
|
105
105
|
title?: string | undefined;
|
|
@@ -119,8 +119,8 @@ export declare const cardNotices: import("svelte/store").Readable<{
|
|
|
119
119
|
location: "banner" | "both" | "card";
|
|
120
120
|
pages: string[];
|
|
121
121
|
link?: {
|
|
122
|
-
href: string;
|
|
123
122
|
text: string;
|
|
123
|
+
href: string;
|
|
124
124
|
external?: boolean | undefined;
|
|
125
125
|
} | undefined;
|
|
126
126
|
title?: string | undefined;
|
|
@@ -36,10 +36,22 @@ function getDismissedNotices() {
|
|
|
36
36
|
}
|
|
37
37
|
catch (error) {
|
|
38
38
|
console.warn('[notices] Failed to parse dismissed notices from localStorage', { error });
|
|
39
|
-
|
|
39
|
+
removeDismissedStorage();
|
|
40
40
|
}
|
|
41
41
|
return new Set();
|
|
42
42
|
}
|
|
43
|
+
/**
|
|
44
|
+
* Remove the dismissed-notices key. Storage itself can throw (blocked cookies,
|
|
45
|
+
* sandboxed iframes), so this never propagates.
|
|
46
|
+
*/
|
|
47
|
+
function removeDismissedStorage() {
|
|
48
|
+
try {
|
|
49
|
+
localStorage.removeItem(DISMISSED_STORAGE_KEY);
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
// Storage unavailable - nothing to clear
|
|
53
|
+
}
|
|
54
|
+
}
|
|
43
55
|
/**
|
|
44
56
|
* Save dismissed notice IDs to localStorage
|
|
45
57
|
*/
|
|
@@ -166,7 +178,7 @@ export function dismissNotice(noticeId) {
|
|
|
166
178
|
export function clearDismissedNotices() {
|
|
167
179
|
dismissedIds.set(new Set());
|
|
168
180
|
if (BROWSER) {
|
|
169
|
-
|
|
181
|
+
removeDismissedStorage();
|
|
170
182
|
}
|
|
171
183
|
}
|
|
172
184
|
/**
|
|
@@ -23,15 +23,15 @@ export type NoticePage = '*' | string;
|
|
|
23
23
|
*/
|
|
24
24
|
export declare const NoticeLinkSchema: z.ZodObject<{
|
|
25
25
|
text: z.ZodString;
|
|
26
|
-
href: z.ZodString
|
|
26
|
+
href: z.ZodEffects<z.ZodString, string, string>;
|
|
27
27
|
external: z.ZodOptional<z.ZodBoolean>;
|
|
28
28
|
}, "strip", z.ZodTypeAny, {
|
|
29
|
-
href: string;
|
|
30
29
|
text: string;
|
|
30
|
+
href: string;
|
|
31
31
|
external?: boolean | undefined;
|
|
32
32
|
}, {
|
|
33
|
-
href: string;
|
|
34
33
|
text: string;
|
|
34
|
+
href: string;
|
|
35
35
|
external?: boolean | undefined;
|
|
36
36
|
}>;
|
|
37
37
|
/**
|
|
@@ -61,15 +61,15 @@ export declare const NoticeSchema: z.ZodObject<{
|
|
|
61
61
|
/** Optional link for "Learn more" or CTA */
|
|
62
62
|
link: z.ZodOptional<z.ZodObject<{
|
|
63
63
|
text: z.ZodString;
|
|
64
|
-
href: z.ZodString
|
|
64
|
+
href: z.ZodEffects<z.ZodString, string, string>;
|
|
65
65
|
external: z.ZodOptional<z.ZodBoolean>;
|
|
66
66
|
}, "strip", z.ZodTypeAny, {
|
|
67
|
-
href: string;
|
|
68
67
|
text: string;
|
|
68
|
+
href: string;
|
|
69
69
|
external?: boolean | undefined;
|
|
70
70
|
}, {
|
|
71
|
-
href: string;
|
|
72
71
|
text: string;
|
|
72
|
+
href: string;
|
|
73
73
|
external?: boolean | undefined;
|
|
74
74
|
}>>;
|
|
75
75
|
/** Whether the notice is enabled (quick toggle without removing) */
|
|
@@ -81,8 +81,8 @@ export declare const NoticeSchema: z.ZodObject<{
|
|
|
81
81
|
location: "banner" | "both" | "card";
|
|
82
82
|
pages: string[];
|
|
83
83
|
link?: {
|
|
84
|
-
href: string;
|
|
85
84
|
text: string;
|
|
85
|
+
href: string;
|
|
86
86
|
external?: boolean | undefined;
|
|
87
87
|
} | undefined;
|
|
88
88
|
title?: string | undefined;
|
|
@@ -98,8 +98,8 @@ export declare const NoticeSchema: z.ZodObject<{
|
|
|
98
98
|
location: "banner" | "both" | "card";
|
|
99
99
|
pages: string[];
|
|
100
100
|
link?: {
|
|
101
|
-
href: string;
|
|
102
101
|
text: string;
|
|
102
|
+
href: string;
|
|
103
103
|
external?: boolean | undefined;
|
|
104
104
|
} | undefined;
|
|
105
105
|
title?: string | undefined;
|
|
@@ -5,12 +5,16 @@
|
|
|
5
5
|
* as banners at the top of pages or as cards within form pages.
|
|
6
6
|
*/
|
|
7
7
|
import { z } from 'zod';
|
|
8
|
+
import { safeHref } from '../utils/url.js';
|
|
8
9
|
/**
|
|
9
10
|
* Zod schema for notice link
|
|
10
11
|
*/
|
|
11
12
|
export const NoticeLinkSchema = z.object({
|
|
12
13
|
text: z.string().min(1, 'Link text is required'),
|
|
13
|
-
href: z
|
|
14
|
+
href: z
|
|
15
|
+
.string()
|
|
16
|
+
.min(1, 'Link href is required')
|
|
17
|
+
.refine((href) => safeHref(href) !== null, 'Link href must be relative or http(s)/mailto/tel'),
|
|
14
18
|
external: z.boolean().optional(),
|
|
15
19
|
});
|
|
16
20
|
/**
|
|
@@ -98,6 +98,12 @@ declare class SidebarStore {
|
|
|
98
98
|
* This bypasses the persisted state check but doesn't persist the changes
|
|
99
99
|
*/
|
|
100
100
|
forceExpandSections(sectionIds: string[]): void;
|
|
101
|
+
/**
|
|
102
|
+
* Undo search force-expansions, restoring the expansion state from before the
|
|
103
|
+
* search (including any toggles made while it was active). Call when the
|
|
104
|
+
* search query is cleared; a no-op when no search expanded anything.
|
|
105
|
+
*/
|
|
106
|
+
restoreSearchExpansion(): void;
|
|
101
107
|
/**
|
|
102
108
|
* Set callback for when pinned items change (for external persistence)
|
|
103
109
|
*/
|
|
@@ -16,7 +16,7 @@ var __classPrivateFieldSet = (this && this.__classPrivateFieldSet) || function (
|
|
|
16
16
|
if (typeof state === "function" ? receiver !== state || !f : !state.has(receiver)) throw new TypeError("Cannot write private member to an object whose class did not declare it");
|
|
17
17
|
return (kind === "a" ? f.call(receiver, value) : f ? f.value = value : state.set(receiver, value)), value;
|
|
18
18
|
};
|
|
19
|
-
var _SidebarStore_instances, _SidebarStore_initialized, _SidebarStore_loadedFromStorage, _SidebarStore_loadedExpansionFromStorage, _SidebarStore_loadedPinnedFromStorage, _SidebarStore_onPinnedChange, _SidebarStore_appId, _SidebarStore_desktopOpen, _SidebarStore_getStorageKey, _SidebarStore_persist, _SidebarStore_persistExpansion, _SidebarStore_persistPinned, _SidebarStore_persistDesktopState;
|
|
19
|
+
var _SidebarStore_instances, _SidebarStore_initialized, _SidebarStore_loadedFromStorage, _SidebarStore_loadedExpansionFromStorage, _SidebarStore_loadedPinnedFromStorage, _SidebarStore_onPinnedChange, _SidebarStore_appId, _SidebarStore_desktopOpen, _SidebarStore_searchSnapshot, _SidebarStore_getStorageKey, _SidebarStore_persist, _SidebarStore_persistExpansion, _SidebarStore_persistPinned, _SidebarStore_persistDesktopState, _SidebarStore_takeSearchSnapshot;
|
|
20
20
|
import { matchesAccess } from '../utils/nav-access.js';
|
|
21
21
|
const STORAGE_PREFIX = 'classic-theme-sidebar';
|
|
22
22
|
class SidebarStore {
|
|
@@ -43,6 +43,9 @@ class SidebarStore {
|
|
|
43
43
|
// mobile drawer (which must always start closed and never persists) can't
|
|
44
44
|
// clobber it. Restored when leaving mobile mode.
|
|
45
45
|
_SidebarStore_desktopOpen.set(this, null);
|
|
46
|
+
// Expansion state from before a search force-expanded everything; restored
|
|
47
|
+
// when the search clears. Null while no search is active.
|
|
48
|
+
_SidebarStore_searchSnapshot.set(this, null);
|
|
46
49
|
}
|
|
47
50
|
/**
|
|
48
51
|
* Whether the store has been initialized
|
|
@@ -87,6 +90,7 @@ class SidebarStore {
|
|
|
87
90
|
__classPrivateFieldSet(this, _SidebarStore_loadedFromStorage, false, "f");
|
|
88
91
|
__classPrivateFieldSet(this, _SidebarStore_loadedExpansionFromStorage, false, "f");
|
|
89
92
|
__classPrivateFieldSet(this, _SidebarStore_loadedPinnedFromStorage, false, "f");
|
|
93
|
+
__classPrivateFieldSet(this, _SidebarStore_searchSnapshot, null, "f");
|
|
90
94
|
try {
|
|
91
95
|
// Load sidebar open state. On mobile only the desktop preference is
|
|
92
96
|
// recorded — the drawer must never open on first paint.
|
|
@@ -176,36 +180,24 @@ class SidebarStore {
|
|
|
176
180
|
* Toggle section expansion state. When expanding, sections in `collapseOnExpand` close.
|
|
177
181
|
*/
|
|
178
182
|
toggleSection(sectionId, collapseOnExpand = []) {
|
|
179
|
-
const
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
newSet.add(sectionId);
|
|
185
|
-
// Accordion behaviour: opening one closes these (its siblings)
|
|
186
|
-
for (const id of collapseOnExpand)
|
|
187
|
-
if (id !== sectionId)
|
|
188
|
-
newSet.delete(id);
|
|
183
|
+
const expand = !this.expandedSections.has(sectionId);
|
|
184
|
+
this.expandedSections = applyToggle(this.expandedSections, sectionId, expand, collapseOnExpand);
|
|
185
|
+
if (__classPrivateFieldGet(this, _SidebarStore_searchSnapshot, "f")) {
|
|
186
|
+
const snap = __classPrivateFieldGet(this, _SidebarStore_searchSnapshot, "f");
|
|
187
|
+
snap.sections = applyToggle(snap.sections, sectionId, expand, collapseOnExpand);
|
|
189
188
|
}
|
|
190
|
-
this.expandedSections = newSet;
|
|
191
189
|
__classPrivateFieldGet(this, _SidebarStore_instances, "m", _SidebarStore_persistExpansion).call(this);
|
|
192
190
|
}
|
|
193
191
|
/**
|
|
194
192
|
* Toggle item expansion state. When expanding, items in `collapseOnExpand` close.
|
|
195
193
|
*/
|
|
196
194
|
toggleItem(itemId, collapseOnExpand = []) {
|
|
197
|
-
const
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
newSet.add(itemId);
|
|
203
|
-
// Accordion behaviour: opening one closes these (its siblings)
|
|
204
|
-
for (const id of collapseOnExpand)
|
|
205
|
-
if (id !== itemId)
|
|
206
|
-
newSet.delete(id);
|
|
195
|
+
const expand = !this.expandedItems.has(itemId);
|
|
196
|
+
this.expandedItems = applyToggle(this.expandedItems, itemId, expand, collapseOnExpand);
|
|
197
|
+
if (__classPrivateFieldGet(this, _SidebarStore_searchSnapshot, "f")) {
|
|
198
|
+
const snap = __classPrivateFieldGet(this, _SidebarStore_searchSnapshot, "f");
|
|
199
|
+
snap.items = applyToggle(snap.items, itemId, expand, collapseOnExpand);
|
|
207
200
|
}
|
|
208
|
-
this.expandedItems = newSet;
|
|
209
201
|
__classPrivateFieldGet(this, _SidebarStore_instances, "m", _SidebarStore_persistExpansion).call(this);
|
|
210
202
|
}
|
|
211
203
|
/**
|
|
@@ -241,6 +233,7 @@ class SidebarStore {
|
|
|
241
233
|
* This bypasses the persisted state check but doesn't persist the changes
|
|
242
234
|
*/
|
|
243
235
|
forceExpandItems(itemIds) {
|
|
236
|
+
__classPrivateFieldGet(this, _SidebarStore_instances, "m", _SidebarStore_takeSearchSnapshot).call(this);
|
|
244
237
|
const newSet = new Set(this.expandedItems);
|
|
245
238
|
itemIds.forEach((id) => newSet.add(id));
|
|
246
239
|
this.expandedItems = newSet;
|
|
@@ -251,11 +244,25 @@ class SidebarStore {
|
|
|
251
244
|
* This bypasses the persisted state check but doesn't persist the changes
|
|
252
245
|
*/
|
|
253
246
|
forceExpandSections(sectionIds) {
|
|
247
|
+
__classPrivateFieldGet(this, _SidebarStore_instances, "m", _SidebarStore_takeSearchSnapshot).call(this);
|
|
254
248
|
const newSet = new Set(this.expandedSections);
|
|
255
249
|
sectionIds.forEach((id) => newSet.add(id));
|
|
256
250
|
this.expandedSections = newSet;
|
|
257
251
|
// Don't persist - this is temporary for search
|
|
258
252
|
}
|
|
253
|
+
/**
|
|
254
|
+
* Undo search force-expansions, restoring the expansion state from before the
|
|
255
|
+
* search (including any toggles made while it was active). Call when the
|
|
256
|
+
* search query is cleared; a no-op when no search expanded anything.
|
|
257
|
+
*/
|
|
258
|
+
restoreSearchExpansion() {
|
|
259
|
+
const snap = __classPrivateFieldGet(this, _SidebarStore_searchSnapshot, "f");
|
|
260
|
+
if (!snap)
|
|
261
|
+
return;
|
|
262
|
+
__classPrivateFieldSet(this, _SidebarStore_searchSnapshot, null, "f");
|
|
263
|
+
this.expandedSections = snap.sections;
|
|
264
|
+
this.expandedItems = snap.items;
|
|
265
|
+
}
|
|
259
266
|
// ============================================
|
|
260
267
|
// Pinning Methods
|
|
261
268
|
// ============================================
|
|
@@ -353,7 +360,7 @@ class SidebarStore {
|
|
|
353
360
|
};
|
|
354
361
|
}
|
|
355
362
|
}
|
|
356
|
-
_SidebarStore_initialized = new WeakMap(), _SidebarStore_loadedFromStorage = new WeakMap(), _SidebarStore_loadedExpansionFromStorage = new WeakMap(), _SidebarStore_loadedPinnedFromStorage = new WeakMap(), _SidebarStore_onPinnedChange = new WeakMap(), _SidebarStore_appId = new WeakMap(), _SidebarStore_desktopOpen = new WeakMap(), _SidebarStore_instances = new WeakSet(), _SidebarStore_getStorageKey = function _SidebarStore_getStorageKey(suffix) {
|
|
363
|
+
_SidebarStore_initialized = new WeakMap(), _SidebarStore_loadedFromStorage = new WeakMap(), _SidebarStore_loadedExpansionFromStorage = new WeakMap(), _SidebarStore_loadedPinnedFromStorage = new WeakMap(), _SidebarStore_onPinnedChange = new WeakMap(), _SidebarStore_appId = new WeakMap(), _SidebarStore_desktopOpen = new WeakMap(), _SidebarStore_searchSnapshot = new WeakMap(), _SidebarStore_instances = new WeakSet(), _SidebarStore_getStorageKey = function _SidebarStore_getStorageKey(suffix) {
|
|
357
364
|
return __classPrivateFieldGet(this, _SidebarStore_appId, "f")
|
|
358
365
|
? `${STORAGE_PREFIX}-${__classPrivateFieldGet(this, _SidebarStore_appId, "f")}-${suffix}`
|
|
359
366
|
: `${STORAGE_PREFIX}-${suffix}`;
|
|
@@ -372,9 +379,16 @@ _SidebarStore_initialized = new WeakMap(), _SidebarStore_loadedFromStorage = new
|
|
|
372
379
|
}, _SidebarStore_persistExpansion = function _SidebarStore_persistExpansion() {
|
|
373
380
|
if (typeof window === 'undefined')
|
|
374
381
|
return;
|
|
382
|
+
// During a search the live sets hold temporary force-expansions; persist
|
|
383
|
+
// the user's real state instead.
|
|
384
|
+
const sections = __classPrivateFieldGet(this, _SidebarStore_searchSnapshot, "f")?.sections ?? this.expandedSections;
|
|
385
|
+
const items = __classPrivateFieldGet(this, _SidebarStore_searchSnapshot, "f")?.items ?? this.expandedItems;
|
|
375
386
|
try {
|
|
376
|
-
localStorage.setItem(__classPrivateFieldGet(this, _SidebarStore_instances, "m", _SidebarStore_getStorageKey).call(this, 'sections'), JSON.stringify([...
|
|
377
|
-
localStorage.setItem(__classPrivateFieldGet(this, _SidebarStore_instances, "m", _SidebarStore_getStorageKey).call(this, 'items'), JSON.stringify([...
|
|
387
|
+
localStorage.setItem(__classPrivateFieldGet(this, _SidebarStore_instances, "m", _SidebarStore_getStorageKey).call(this, 'sections'), JSON.stringify([...sections]));
|
|
388
|
+
localStorage.setItem(__classPrivateFieldGet(this, _SidebarStore_instances, "m", _SidebarStore_getStorageKey).call(this, 'items'), JSON.stringify([...items]));
|
|
389
|
+
// The keys now exist: defaults applied "only when nothing is persisted"
|
|
390
|
+
// must stop, or they undo the user's toggle (same as #persist).
|
|
391
|
+
__classPrivateFieldSet(this, _SidebarStore_loadedExpansionFromStorage, true, "f");
|
|
378
392
|
}
|
|
379
393
|
catch {
|
|
380
394
|
// localStorage not available
|
|
@@ -395,5 +409,26 @@ _SidebarStore_initialized = new WeakMap(), _SidebarStore_loadedFromStorage = new
|
|
|
395
409
|
return;
|
|
396
410
|
__classPrivateFieldSet(this, _SidebarStore_desktopOpen, this.isOpen, "f");
|
|
397
411
|
__classPrivateFieldGet(this, _SidebarStore_instances, "m", _SidebarStore_persist).call(this);
|
|
412
|
+
}, _SidebarStore_takeSearchSnapshot = function _SidebarStore_takeSearchSnapshot() {
|
|
413
|
+
if (__classPrivateFieldGet(this, _SidebarStore_searchSnapshot, "f"))
|
|
414
|
+
return;
|
|
415
|
+
__classPrivateFieldSet(this, _SidebarStore_searchSnapshot, {
|
|
416
|
+
sections: new Set(this.expandedSections),
|
|
417
|
+
items: new Set(this.expandedItems),
|
|
418
|
+
}, "f");
|
|
398
419
|
};
|
|
420
|
+
/** Copy of `set` with `id` expanded or collapsed; expanding closes `collapseOnExpand` (accordion). */
|
|
421
|
+
function applyToggle(set, id, expand, collapseOnExpand) {
|
|
422
|
+
const next = new Set(set);
|
|
423
|
+
if (expand) {
|
|
424
|
+
next.add(id);
|
|
425
|
+
for (const other of collapseOnExpand)
|
|
426
|
+
if (other !== id)
|
|
427
|
+
next.delete(other);
|
|
428
|
+
}
|
|
429
|
+
else {
|
|
430
|
+
next.delete(id);
|
|
431
|
+
}
|
|
432
|
+
return next;
|
|
433
|
+
}
|
|
399
434
|
export const sidebarStore = new SidebarStore();
|
package/dist/lib/utils/media.js
CHANGED
|
@@ -614,10 +614,11 @@ export function buildEmbedSrc(provider, videoId, options = {}) {
|
|
|
614
614
|
params.set('autoplay', '1');
|
|
615
615
|
if (privacy)
|
|
616
616
|
params.set('dnt', '1');
|
|
617
|
-
if (start !== undefined)
|
|
618
|
-
params.set('#t', `${Math.floor(start)}s`);
|
|
619
617
|
const query = params.toString();
|
|
620
|
-
|
|
618
|
+
// Vimeo reads the start time from the fragment (#t=30s), not the query;
|
|
619
|
+
// URLSearchParams would encode it as `%23t`, which Vimeo ignores.
|
|
620
|
+
const fragment = start !== undefined ? `#t=${Math.floor(start)}s` : '';
|
|
621
|
+
return `https://player.vimeo.com/video/${videoId}${query ? `?${query}` : ''}${fragment}`;
|
|
621
622
|
}
|
|
622
623
|
/**
|
|
623
624
|
* Poster image URL for an embedded video, when one can be derived without a
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* URL safety helpers for hrefs that come from config or API data.
|
|
3
|
+
*/
|
|
4
|
+
/**
|
|
5
|
+
* Return `url` when it is relative or uses a safe scheme (http, https, mailto,
|
|
6
|
+
* tel), otherwise `null`. Rejects `javascript:`, `data:`, `vbscript:` and the
|
|
7
|
+
* like, including obfuscated forms (leading whitespace, mixed case, embedded
|
|
8
|
+
* tabs/newlines), because the URL parser normalises those before the check.
|
|
9
|
+
*/
|
|
10
|
+
export declare function safeHref(url: string | null | undefined): string | null;
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* URL safety helpers for hrefs that come from config or API data.
|
|
3
|
+
*/
|
|
4
|
+
const SAFE_PROTOCOLS = new Set(['http:', 'https:', 'mailto:', 'tel:']);
|
|
5
|
+
/**
|
|
6
|
+
* Return `url` when it is relative or uses a safe scheme (http, https, mailto,
|
|
7
|
+
* tel), otherwise `null`. Rejects `javascript:`, `data:`, `vbscript:` and the
|
|
8
|
+
* like, including obfuscated forms (leading whitespace, mixed case, embedded
|
|
9
|
+
* tabs/newlines), because the URL parser normalises those before the check.
|
|
10
|
+
*/
|
|
11
|
+
export function safeHref(url) {
|
|
12
|
+
if (typeof url !== 'string' || url.trim() === '')
|
|
13
|
+
return null;
|
|
14
|
+
try {
|
|
15
|
+
const parsed = new URL(url, 'https://relative.invalid');
|
|
16
|
+
return SAFE_PROTOCOLS.has(parsed.protocol) ? url : null;
|
|
17
|
+
}
|
|
18
|
+
catch {
|
|
19
|
+
return null;
|
|
20
|
+
}
|
|
21
|
+
}
|
package/dist/lib/utils.js
CHANGED
|
@@ -1,5 +1,18 @@
|
|
|
1
1
|
import { clsx } from 'clsx';
|
|
2
|
-
import {
|
|
2
|
+
import { extendTailwindMerge } from 'tailwind-merge';
|
|
3
|
+
/**
|
|
4
|
+
* tailwind-merge only knows Tailwind's default theme, so it reads the preset's
|
|
5
|
+
* custom font sizes (`text-h1`..`text-h6`, from `fontSize` in
|
|
6
|
+
* @classic-homes/theme-tailwind-preset) as text colours and drops a real colour
|
|
7
|
+
* such as `text-foreground` beside them. Register them as font sizes.
|
|
8
|
+
*/
|
|
9
|
+
const twMerge = extendTailwindMerge({
|
|
10
|
+
extend: {
|
|
11
|
+
classGroups: {
|
|
12
|
+
'font-size': [{ text: ['h1', 'h2', 'h3', 'h4', 'h5', 'h6'] }],
|
|
13
|
+
},
|
|
14
|
+
},
|
|
15
|
+
});
|
|
3
16
|
/**
|
|
4
17
|
* Utility function to merge Tailwind CSS classes
|
|
5
18
|
* Uses clsx for conditional classes and tailwind-merge to handle conflicts
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@classic-homes/theme-svelte",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.22.1",
|
|
4
4
|
"description": "Svelte components for the Classic theme system",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"svelte": "./dist/lib/index.js",
|
|
@@ -15,14 +15,15 @@
|
|
|
15
15
|
"types": "./dist/lib/components/notifications/index.d.ts",
|
|
16
16
|
"svelte": "./dist/lib/components/notifications/index.js",
|
|
17
17
|
"default": "./dist/lib/components/notifications/index.js"
|
|
18
|
-
}
|
|
19
|
-
"./styles.css": "./dist/styles.css"
|
|
18
|
+
}
|
|
20
19
|
},
|
|
21
20
|
"sideEffects": [
|
|
22
21
|
"*.css"
|
|
23
22
|
],
|
|
24
23
|
"files": [
|
|
25
|
-
"dist"
|
|
24
|
+
"dist",
|
|
25
|
+
"!dist/__tests__",
|
|
26
|
+
"!dist/**/*.test.*"
|
|
26
27
|
],
|
|
27
28
|
"scripts": {
|
|
28
29
|
"build": "svelte-package -i src",
|
|
@@ -1,45 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect } from 'vitest';
|
|
2
|
-
import { render, screen } from '@testing-library/svelte';
|
|
3
|
-
import FileUpload from '../lib/components/FileUpload.svelte';
|
|
4
|
-
describe('FileUpload accepted-types label', () => {
|
|
5
|
-
it('derives a friendly label from MIME types instead of showing raw values', () => {
|
|
6
|
-
render(FileUpload, {
|
|
7
|
-
props: {
|
|
8
|
-
label: 'Upload files',
|
|
9
|
-
acceptedTypes: [
|
|
10
|
-
'image/jpeg',
|
|
11
|
-
'image/png',
|
|
12
|
-
'application/pdf',
|
|
13
|
-
'video/mp4',
|
|
14
|
-
'video/quicktime',
|
|
15
|
-
],
|
|
16
|
-
},
|
|
17
|
-
});
|
|
18
|
-
expect(screen.getByText(/Images, PDF, Videos \(max/)).toBeInTheDocument();
|
|
19
|
-
// Raw MIME types should not be shown
|
|
20
|
-
expect(screen.queryByText(/image\/jpeg/)).not.toBeInTheDocument();
|
|
21
|
-
});
|
|
22
|
-
it('uses the default accepted types (Images, PDF) when none are provided', () => {
|
|
23
|
-
render(FileUpload, { props: { label: 'Upload files' } });
|
|
24
|
-
expect(screen.getByText(/^Images, PDF \(max/)).toBeInTheDocument();
|
|
25
|
-
});
|
|
26
|
-
it('respects an explicit typesLabel override', () => {
|
|
27
|
-
render(FileUpload, {
|
|
28
|
-
props: {
|
|
29
|
-
label: 'Upload files',
|
|
30
|
-
acceptedTypes: ['image/png', 'video/mp4'],
|
|
31
|
-
typesLabel: 'Photos and clips',
|
|
32
|
-
},
|
|
33
|
-
});
|
|
34
|
-
expect(screen.getByText(/^Photos and clips \(max/)).toBeInTheDocument();
|
|
35
|
-
});
|
|
36
|
-
it('falls back to the uppercased subtype for unmapped types', () => {
|
|
37
|
-
render(FileUpload, {
|
|
38
|
-
props: {
|
|
39
|
-
label: 'Upload files',
|
|
40
|
-
acceptedTypes: ['application/zip'],
|
|
41
|
-
},
|
|
42
|
-
});
|
|
43
|
-
expect(screen.getByText(/^ZIP \(max/)).toBeInTheDocument();
|
|
44
|
-
});
|
|
45
|
-
});
|