@classic-homes/theme-svelte 1.22.0 → 1.22.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (77) hide show
  1. package/dist/lib/components/Alert.svelte +8 -10
  2. package/dist/lib/components/DataPanel.svelte +19 -5
  3. package/dist/lib/components/HeaderSearch.svelte +18 -10
  4. package/dist/lib/components/Skeleton.svelte.d.ts +1 -1
  5. package/dist/lib/components/layout/AppShell.svelte +1 -1
  6. package/dist/lib/components/layout/DashboardLayout.svelte +15 -6
  7. package/dist/lib/components/layout/Footer.svelte +6 -6
  8. package/dist/lib/components/layout/FormPageLayout.svelte +4 -3
  9. package/dist/lib/components/layout/Sidebar.svelte +25 -21
  10. package/dist/lib/components/media/Image.svelte.d.ts +1 -1
  11. package/dist/lib/components/media/Lightbox.svelte +13 -9
  12. package/dist/lib/components/media/MediaCarousel.svelte +5 -0
  13. package/dist/lib/components/media/MediaGrid.svelte +4 -1
  14. package/dist/lib/components/notifications/NotificationDialog.svelte +8 -5
  15. package/dist/lib/components/notifications/NotificationItem.svelte +16 -6
  16. package/dist/lib/components/studio/InsertPalette.svelte +4 -1
  17. package/dist/lib/components/studio/StudioCanvas.svelte +23 -2
  18. package/dist/lib/components/studio/StudioCanvas.svelte.d.ts +5 -1
  19. package/dist/lib/composables/useFocusTrap.svelte.js +9 -2
  20. package/dist/lib/notices/sanitize.js +10 -3
  21. package/dist/lib/notices/store.d.ts +3 -3
  22. package/dist/lib/notices/store.js +14 -2
  23. package/dist/lib/notices/types.d.ts +8 -8
  24. package/dist/lib/notices/types.js +5 -1
  25. package/dist/lib/stores/sidebar.svelte.d.ts +6 -0
  26. package/dist/lib/stores/sidebar.svelte.js +61 -26
  27. package/dist/lib/utils/media.js +4 -3
  28. package/dist/lib/utils/url.d.ts +10 -0
  29. package/dist/lib/utils/url.js +21 -0
  30. package/dist/lib/utils.js +14 -1
  31. package/package.json +5 -4
  32. package/dist/__tests__/FileUpload.test.js +0 -45
  33. package/dist/__tests__/a11y/Alert.a11y.test.js +0 -84
  34. package/dist/__tests__/a11y/AlertDialog.a11y.test.js +0 -126
  35. package/dist/__tests__/a11y/Button.a11y.test.js +0 -66
  36. package/dist/__tests__/a11y/Card.a11y.test.js +0 -77
  37. package/dist/__tests__/a11y/Combobox.a11y.test.js +0 -209
  38. package/dist/__tests__/a11y/Dialog.a11y.test.js +0 -103
  39. package/dist/__tests__/a11y/DropdownMenu.a11y.test.js +0 -132
  40. package/dist/__tests__/a11y/FileUpload.a11y.test.js +0 -158
  41. package/dist/__tests__/a11y/FilterToolbar.a11y.test.js +0 -62
  42. package/dist/__tests__/a11y/Icon.a11y.test.js +0 -119
  43. package/dist/__tests__/a11y/Input.a11y.test.js +0 -96
  44. package/dist/__tests__/a11y/LiveRegion.a11y.test.js +0 -102
  45. package/dist/__tests__/a11y/Media.a11y.test.js +0 -145
  46. package/dist/__tests__/a11y/PublicLayout.a11y.test.js +0 -139
  47. package/dist/__tests__/a11y/SegmentedControl.a11y.test.js +0 -50
  48. package/dist/__tests__/a11y/Switch.a11y.test.js +0 -95
  49. package/dist/__tests__/a11y/Tabs.a11y.test.js +0 -116
  50. package/dist/__tests__/a11y/Textarea.a11y.test.js +0 -111
  51. package/dist/__tests__/a11y/Toolbar.a11y.test.js +0 -31
  52. package/dist/__tests__/a11y/ViewToggle.a11y.test.js +0 -40
  53. package/dist/__tests__/action-header.test.js +0 -150
  54. package/dist/__tests__/alert-dialog-dismiss.test.js +0 -54
  55. package/dist/__tests__/code-field.test.js +0 -65
  56. package/dist/__tests__/control-text-color.test.js +0 -29
  57. package/dist/__tests__/data-panel-card-gestures.test.js +0 -138
  58. package/dist/__tests__/data-panel-viewport.test.js +0 -45
  59. package/dist/__tests__/dropdown-trigger.test.js +0 -39
  60. package/dist/__tests__/header-search.test.js +0 -90
  61. package/dist/__tests__/media-components.test.js +0 -206
  62. package/dist/__tests__/media-hls.test.js +0 -273
  63. package/dist/__tests__/media-matterport.test.js +0 -296
  64. package/dist/__tests__/media-players.test.js +0 -244
  65. package/dist/__tests__/media-scrubber.test.js +0 -44
  66. package/dist/__tests__/media-utils.test.js +0 -427
  67. package/dist/__tests__/nav-access.test.js +0 -145
  68. package/dist/__tests__/page-header.test.js +0 -36
  69. package/dist/__tests__/public-layout-drawer.test.js +0 -116
  70. package/dist/__tests__/setup.js +0 -56
  71. package/dist/__tests__/sidebar-collapse.test.js +0 -58
  72. package/dist/__tests__/sidebar-docs-parity.test.js +0 -134
  73. package/dist/__tests__/sidebar-expansion.test.js +0 -59
  74. package/dist/__tests__/sidebar-items.test.js +0 -59
  75. package/dist/__tests__/sidebar-mobile.test.js +0 -86
  76. package/dist/__tests__/studio-canvas-item.svelte.test.js +0 -98
  77. 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
- /** Clicked on the backdrop, outside any item: usually clears the selection */
22
+ /**
23
+ * Clicked on the backdrop, outside any item: usually clears the selection.
24
+ * Also called on Escape pressed inside the canvas (outside a text field),
25
+ * the keyboard path back to "nothing selected".
26
+ */
23
27
  onBackgroundClick?: () => void;
24
28
  /** Classes for the frame that holds the content */
25
29
  frameClass?: string;
@@ -8,12 +8,19 @@ const FOCUSABLE_SELECTOR = [
8
8
  'iframe',
9
9
  'object',
10
10
  'embed',
11
- '[contenteditable]',
11
+ '[contenteditable]:not([contenteditable="false"])',
12
12
  '[tabindex]:not([tabindex="-1"])',
13
13
  ].join(',');
14
14
  function getFocusableElements(container) {
15
15
  const elements = container.querySelectorAll(FOCUSABLE_SELECTOR);
16
- return Array.from(elements).filter((el) => el.offsetParent !== null && getComputedStyle(el).visibility !== 'hidden');
16
+ return Array.from(elements).filter((el) =>
17
+ // tabindex="-1" on a natively focusable element (roving-tabindex radios,
18
+ // toolbar buttons) takes it out of the Tab order, so it can't be the
19
+ // trap's first or last stop.
20
+ el.tabIndex >= 0 &&
21
+ !el.closest('[inert]') &&
22
+ el.offsetParent !== null &&
23
+ getComputedStyle(el).visibility !== 'hidden');
17
24
  }
18
25
  /**
19
26
  * Creates a focus trap action for use with Svelte's use: directive.
@@ -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', 'class'];
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
- localStorage.removeItem(DISMISSED_STORAGE_KEY);
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
- localStorage.removeItem(DISMISSED_STORAGE_KEY);
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.string().min(1, 'Link href is required'),
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 newSet = new Set(this.expandedSections);
180
- if (newSet.has(sectionId)) {
181
- newSet.delete(sectionId);
182
- }
183
- else {
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 newSet = new Set(this.expandedItems);
198
- if (newSet.has(itemId)) {
199
- newSet.delete(itemId);
200
- }
201
- else {
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([...this.expandedSections]));
377
- localStorage.setItem(__classPrivateFieldGet(this, _SidebarStore_instances, "m", _SidebarStore_getStorageKey).call(this, 'items'), JSON.stringify([...this.expandedItems]));
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();
@@ -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
- return `https://player.vimeo.com/video/${videoId}${query ? `?${query}` : ''}`;
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 { twMerge } from 'tailwind-merge';
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.22.0",
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
- });
@@ -1,84 +0,0 @@
1
- import { describe, it, expect } from 'vitest';
2
- import { screen } from '@testing-library/svelte';
3
- import { axe } from 'vitest-axe';
4
- describe('Alert accessibility', () => {
5
- it('should have no axe violations', async () => {
6
- const container = document.createElement('div');
7
- document.body.appendChild(container);
8
- container.innerHTML = `
9
- <div role="alert" aria-live="polite" aria-atomic="true" class="relative w-full rounded-lg border p-4">
10
- <h5 class="mb-1 font-medium leading-none tracking-tight">Alert Title</h5>
11
- <p class="text-sm">Alert description text</p>
12
- </div>
13
- `;
14
- const results = await axe(container);
15
- expect(results).toHaveNoViolations();
16
- document.body.removeChild(container);
17
- });
18
- it('should have no axe violations with different variants', async () => {
19
- const container = document.createElement('div');
20
- document.body.appendChild(container);
21
- // Test default variant
22
- container.innerHTML = `
23
- <div role="alert" aria-live="polite" aria-atomic="true" class="relative w-full rounded-lg border p-4 bg-gray-50">
24
- <h5 class="mb-1 font-medium leading-none tracking-tight">Default Alert</h5>
25
- </div>
26
- `;
27
- expect(await axe(container)).toHaveNoViolations();
28
- // Test destructive variant
29
- container.innerHTML = `
30
- <div role="alert" aria-live="assertive" aria-atomic="true" class="relative w-full rounded-lg border p-4 bg-red-50">
31
- <h5 class="mb-1 font-medium leading-none tracking-tight">Destructive Alert</h5>
32
- </div>
33
- `;
34
- expect(await axe(container)).toHaveNoViolations();
35
- document.body.removeChild(container);
36
- });
37
- it('should have role="alert"', () => {
38
- const container = document.createElement('div');
39
- document.body.appendChild(container);
40
- container.innerHTML = `
41
- <div role="alert" aria-live="polite" aria-atomic="true" class="relative w-full rounded-lg border p-4">
42
- <h5 class="mb-1 font-medium leading-none tracking-tight">Alert Title</h5>
43
- </div>
44
- `;
45
- const alert = screen.getByRole('alert');
46
- expect(alert).toBeInTheDocument();
47
- document.body.removeChild(container);
48
- });
49
- it('should have aria-atomic="true"', () => {
50
- const container = document.createElement('div');
51
- document.body.appendChild(container);
52
- container.innerHTML = `
53
- <div role="alert" aria-live="polite" aria-atomic="true" class="relative w-full rounded-lg border p-4">
54
- <h5 class="mb-1 font-medium leading-none tracking-tight">Alert Title</h5>
55
- </div>
56
- `;
57
- const alert = screen.getByRole('alert');
58
- expect(alert).toHaveAttribute('aria-atomic', 'true');
59
- document.body.removeChild(container);
60
- });
61
- it('should display title text', () => {
62
- const container = document.createElement('div');
63
- document.body.appendChild(container);
64
- container.innerHTML = `
65
- <div role="alert" aria-live="polite" aria-atomic="true" class="relative w-full rounded-lg border p-4">
66
- <h5 class="mb-1 font-medium leading-none tracking-tight">Important Alert</h5>
67
- </div>
68
- `;
69
- expect(screen.getByText('Important Alert')).toBeInTheDocument();
70
- document.body.removeChild(container);
71
- });
72
- it('should display description text', () => {
73
- const container = document.createElement('div');
74
- document.body.appendChild(container);
75
- container.innerHTML = `
76
- <div role="alert" aria-live="polite" aria-atomic="true" class="relative w-full rounded-lg border p-4">
77
- <h5 class="mb-1 font-medium leading-none tracking-tight">Alert</h5>
78
- <p class="text-sm">This is the description</p>
79
- </div>
80
- `;
81
- expect(screen.getByText('This is the description')).toBeInTheDocument();
82
- document.body.removeChild(container);
83
- });
84
- });