@classic-homes/theme-svelte 1.40.0 → 1.42.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.
@@ -356,6 +356,23 @@
356
356
  }
357
357
  });
358
358
 
359
+ /**
360
+ * Clamp a detached position so the panel (its header above all) stays inside
361
+ * the current viewport. The panel renders at `min(size, 100%)`, so clamp
362
+ * against that visible size: an oversized panel then pins to 0,0.
363
+ * Mirrors the React DataPanel's restore and window-resize clamping.
364
+ */
365
+ function clampDetachedPosition(position: Position, size: Size): Position {
366
+ const vw = layoutViewportWidth();
367
+ const vh = window.innerHeight;
368
+ return constrainPosition(
369
+ position,
370
+ { width: Math.min(size.width, vw), height: Math.min(size.height, vh) },
371
+ vw,
372
+ vh
373
+ );
374
+ }
375
+
359
376
  // Initialize from persistence on mount
360
377
  onMount(() => {
361
378
  // Update viewport dimensions
@@ -370,6 +387,13 @@
370
387
  viewportWidth = layoutViewportWidth();
371
388
  viewportHeight = window.innerHeight;
372
389
  cardExpandedHeight = viewportHeight * resolvedCardConfig.expandedRatio;
390
+ // Keep a detached panel reachable after the window shrinks
391
+ if (mode === 'detached') {
392
+ const clamped = clampDetachedPosition(detachedPosition, detachedSize);
393
+ if (clamped.x !== detachedPosition.x || clamped.y !== detachedPosition.y) {
394
+ detachedPosition = clamped;
395
+ }
396
+ }
373
397
  };
374
398
  window.addEventListener('resize', handleResize);
375
399
 
@@ -402,6 +426,11 @@
402
426
  }
403
427
  }
404
428
 
429
+ // A position restored from storage may have been saved on a larger screen
430
+ if (persistKey) {
431
+ detachedPosition = clampDetachedPosition(detachedPosition, detachedSize);
432
+ }
433
+
405
434
  // Escape-to-close is handled by useEscapeKey; focus trapping by useFocusTrap.
406
435
 
407
436
  return () => {
@@ -15,6 +15,7 @@
15
15
  */
16
16
  import type { FileMetadata, MediaItem } from '../types/components.js';
17
17
  import { cn } from '../utils.js';
18
+ import { acceptLabel, matchesAccept } from '../utils/file-accept.js';
18
19
  import { formatDuration } from '../utils/media.js';
19
20
  import Button from './Button.svelte';
20
21
  import Lightbox from './media/Lightbox.svelte';
@@ -29,7 +30,12 @@
29
30
  maxFiles?: number;
30
31
  /** Maximum file size in bytes (default: 10MB) */
31
32
  maxSizeBytes?: number;
32
- /** Accepted MIME types */
33
+ /**
34
+ * Accepted file types, as `<input accept>` entries: MIME types
35
+ * (`application/pdf`), wildcards (`image/*`) or extensions (`.dxf`,
36
+ * matched case-insensitively against the file name). A file passes when
37
+ * any entry matches.
38
+ */
33
39
  acceptedTypes?: string[];
34
40
  /** Whether upload is disabled */
35
41
  disabled?: boolean;
@@ -107,29 +113,9 @@
107
113
  /**
108
114
  * Readable label for the accepted types shown in the drop zone.
109
115
  * Uses the explicit `typesLabel` when provided, otherwise groups the
110
- * accepted MIME types into friendly names (Images, PDF, Videos, ...).
116
+ * accepted types into friendly names (Images, PDF, Videos, DXF, ...).
111
117
  */
112
- const acceptedTypesLabel = $derived.by(() => {
113
- if (typesLabel) return typesLabel;
114
-
115
- const groups: string[] = [];
116
- const add = (name: string) => {
117
- if (!groups.includes(name)) groups.push(name);
118
- };
119
-
120
- for (const type of acceptedTypes) {
121
- if (type.startsWith('image/')) add('Images');
122
- else if (type === 'application/pdf') add('PDF');
123
- else if (type.startsWith('video/')) add('Videos');
124
- else if (type.startsWith('audio/')) add('Audio');
125
- else {
126
- const subtype = type.split('/')[1];
127
- add(subtype ? subtype.toUpperCase() : type);
128
- }
129
- }
130
-
131
- return groups.join(', ');
132
- });
118
+ const acceptedTypesLabel = $derived(typesLabel || acceptLabel(acceptedTypes));
133
119
 
134
120
  // Helper to generate unique ID
135
121
  function generateId(): string {
@@ -143,17 +129,11 @@
143
129
  return `File "${file.name}" exceeds maximum size of ${maxMB}MB`;
144
130
  }
145
131
 
146
- // Check if file type is accepted
147
- const isAccepted = acceptedTypes.some((type) => {
148
- if (type.endsWith('/*')) {
149
- const category = type.slice(0, -2);
150
- return file.type.startsWith(category);
151
- }
152
- return file.type === type;
153
- });
154
-
155
- if (!isAccepted) {
156
- return `File type "${file.type}" is not allowed`;
132
+ // Same rules as the native `accept` attribute: extensions, wildcards, MIME types
133
+ if (!matchesAccept(file, acceptedTypes)) {
134
+ return file.type
135
+ ? `File type "${file.type}" is not allowed`
136
+ : `File "${file.name}" is not an allowed type`;
157
137
  }
158
138
 
159
139
  return null;
@@ -22,7 +22,12 @@ interface Props {
22
22
  maxFiles?: number;
23
23
  /** Maximum file size in bytes (default: 10MB) */
24
24
  maxSizeBytes?: number;
25
- /** Accepted MIME types */
25
+ /**
26
+ * Accepted file types, as `<input accept>` entries: MIME types
27
+ * (`application/pdf`), wildcards (`image/*`) or extensions (`.dxf`,
28
+ * matched case-insensitively against the file name). A file passes when
29
+ * any entry matches.
30
+ */
26
31
  acceptedTypes?: string[];
27
32
  /** Whether upload is disabled */
28
33
  disabled?: boolean;
@@ -15,13 +15,19 @@ function getFocusableElements(container) {
15
15
  const elements = container.querySelectorAll(FOCUSABLE_SELECTOR);
16
16
  return Array.from(elements).filter((el) =>
17
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.
18
+ // toolbar buttons) takes it out of the Tab order, as do `inert`, `hidden`
19
+ // and a disabled <fieldset> ancestor, so none can be the trap's first or
20
+ // last stop.
20
21
  el.tabIndex >= 0 &&
21
- !el.closest('[inert]') &&
22
+ !el.matches(':disabled') &&
23
+ !el.closest('[inert],[hidden]') &&
22
24
  el.offsetParent !== null &&
23
25
  getComputedStyle(el).visibility !== 'hidden');
24
26
  }
27
+ /** True when `a` comes before `b` in document order. */
28
+ function precedes(a, b) {
29
+ return Boolean(a.compareDocumentPosition(b) & Node.DOCUMENT_POSITION_FOLLOWING);
30
+ }
25
31
  /**
26
32
  * Creates a focus trap action for use with Svelte's use: directive.
27
33
  *
@@ -53,6 +59,7 @@ export function useFocusTrap(options = {}) {
53
59
  let container = $state(null);
54
60
  let previousActiveElement = null;
55
61
  let trapped = false;
62
+ let addedTabIndexTo = null;
56
63
  function isActive() {
57
64
  const active = currentOptions.active;
58
65
  return typeof active === 'function' ? active() : (active ?? true);
@@ -61,21 +68,39 @@ export function useFocusTrap(options = {}) {
61
68
  if (!trapped || event.key !== 'Tab' || !container)
62
69
  return;
63
70
  const preventScroll = currentOptions.preventScroll ?? false;
71
+ // Focus outside the container (e.g. a portaled nested popover) is not
72
+ // ours to move; the nested overlay manages its own Tab order.
73
+ const current = document.activeElement;
74
+ if (!current || !container.contains(current))
75
+ return;
64
76
  const focusableElements = getFocusableElements(container);
65
- if (focusableElements.length === 0)
77
+ // Nothing tabbable: keep focus on the container instead of letting Tab
78
+ // walk out of the trap.
79
+ if (focusableElements.length === 0) {
80
+ event.preventDefault();
81
+ container.focus({ preventScroll });
66
82
  return;
83
+ }
67
84
  const firstElement = focusableElements[0];
68
85
  const lastElement = focusableElements[focusableElements.length - 1];
86
+ // Focus on the container itself: enter the tab sequence at the matching end.
87
+ if (current === container) {
88
+ event.preventDefault();
89
+ (event.shiftKey ? lastElement : firstElement).focus({ preventScroll });
90
+ return;
91
+ }
69
92
  if (event.shiftKey) {
70
- // Shift + Tab: if on first element, move to last
71
- if (document.activeElement === firstElement) {
93
+ // Shift + Tab from the first stop, or from a non-tabbable element before
94
+ // it (e.g. a clicked tabindex=-1 control), wraps to the last.
95
+ if (current === firstElement || precedes(current, firstElement)) {
72
96
  event.preventDefault();
73
97
  lastElement.focus({ preventScroll });
74
98
  }
75
99
  }
76
100
  else {
77
- // Tab: if on last element, move to first
78
- if (document.activeElement === lastElement) {
101
+ // Tab from the last stop, or from a non-tabbable element after it,
102
+ // wraps to the first.
103
+ if (current === lastElement || precedes(lastElement, current)) {
79
104
  event.preventDefault();
80
105
  firstElement.focus({ preventScroll });
81
106
  }
@@ -87,21 +112,27 @@ export function useFocusTrap(options = {}) {
87
112
  trapped = true;
88
113
  // Save current active element to return focus later
89
114
  previousActiveElement = document.activeElement;
90
- // Focus initial element
115
+ // Focus initial element; an empty trap focuses the container itself,
116
+ // which needs a tabindex for that (removed again on deactivate).
91
117
  const preventScroll = currentOptions.preventScroll ?? false;
92
118
  const focusableElements = getFocusableElements(container);
93
- if (focusableElements.length > 0) {
94
- let elementToFocus = null;
95
- if (currentOptions.initialFocus) {
96
- elementToFocus = container.querySelector(currentOptions.initialFocus);
97
- }
98
- if (!elementToFocus) {
99
- elementToFocus = focusableElements[0];
119
+ let elementToFocus = null;
120
+ if (currentOptions.initialFocus) {
121
+ elementToFocus = container.querySelector(currentOptions.initialFocus);
122
+ }
123
+ if (!elementToFocus) {
124
+ elementToFocus = focusableElements[0] ?? null;
125
+ }
126
+ if (!elementToFocus) {
127
+ if (!container.hasAttribute('tabindex')) {
128
+ container.setAttribute('tabindex', '-1');
129
+ addedTabIndexTo = container;
100
130
  }
101
- requestAnimationFrame(() => {
102
- elementToFocus?.focus({ preventScroll });
103
- });
131
+ elementToFocus = container;
104
132
  }
133
+ requestAnimationFrame(() => {
134
+ elementToFocus?.focus({ preventScroll });
135
+ });
105
136
  document.addEventListener('keydown', handleKeyDown);
106
137
  }
107
138
  function deactivate() {
@@ -109,6 +140,8 @@ export function useFocusTrap(options = {}) {
109
140
  return;
110
141
  trapped = false;
111
142
  document.removeEventListener('keydown', handleKeyDown);
143
+ addedTabIndexTo?.removeAttribute('tabindex');
144
+ addedTabIndexTo = null;
112
145
  // Return focus when trap is deactivated
113
146
  const preventScroll = currentOptions.preventScroll ?? false;
114
147
  const elementToReturn = currentOptions.returnFocusTo ?? previousActiveElement;
@@ -116,6 +116,7 @@ export type { PanelEdge, PanelMode, PanelVariant, SnapPoint, CardConfig, Positio
116
116
  export { cn } from './utils.js';
117
117
  export { parseDate, toISOString, formatDate, formatDateTime, isSameDay } from './utils/date.js';
118
118
  export { generateId, computeDescribedBy } from './utils/form.js';
119
+ export { matchesAccept, acceptLabel, type AcceptableFile } from './utils/file-accept.js';
119
120
  export { formatDuration, formatDurationSpoken, inferMediaKind, parseEmbedUrl, buildEmbedSrc, getEmbedThumbnail, getMediaThumbnail, getMediaLabel, isTimeBasedMedia, isSpatialMedia, isHlsSource, resolveAspectRatio, toMediaSources, HLS_MIME_TYPE, DASH_MIME_TYPE, type ParsedEmbed, type EmbedSrcOptions, } from './utils/media.js';
120
121
  export { cloudflareStream, cloudflareStreamUrls, cloudflareStreamThumbnail, cloudflareStreamHost, type CloudflareStreamItemOptions, type CloudflareStreamUrlOptions, type CloudflareStreamUrls, type CloudflareStreamThumbnailOptions, } from './utils/media.js';
121
122
  export { matterportTour, buildMatterportSrc, fetchMatterportMetadata, type MatterportTourOptions, type MatterportMetadata, } from './utils/media.js';
package/dist/lib/index.js CHANGED
@@ -147,6 +147,7 @@ export { cn } from './utils.js';
147
147
  export { parseDate, toISOString, formatDate, formatDateTime, isSameDay } from './utils/date.js';
148
148
  // Form utilities
149
149
  export { generateId, computeDescribedBy } from './utils/form.js';
150
+ export { matchesAccept, acceptLabel } from './utils/file-accept.js';
150
151
  // Media utilities
151
152
  export { formatDuration, formatDurationSpoken, inferMediaKind, parseEmbedUrl, buildEmbedSrc, getEmbedThumbnail, getMediaThumbnail, getMediaLabel, isTimeBasedMedia, isSpatialMedia, isHlsSource, resolveAspectRatio, toMediaSources, HLS_MIME_TYPE, DASH_MIME_TYPE, } from './utils/media.js';
152
153
  // Cloudflare Stream helpers
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Matching and labelling for a list of HTML `accept` entries, as used by
3
+ * `FileUpload`'s `acceptedTypes`. Mirrors the native `<input accept>` rules:
4
+ *
5
+ * - `.dxf` matches a file whose name ends in `.dxf`, case-insensitively
6
+ * (whatever MIME type the browser guessed, often `application/octet-stream` or '')
7
+ * - `image/*` matches any MIME type in the `image` major type
8
+ * - `application/pdf` matches that MIME type exactly, case-insensitively
9
+ *
10
+ * A file is accepted when any entry matches. An empty list accepts everything,
11
+ * as an absent `accept` attribute does.
12
+ */
13
+ /** The parts of a `File` the matcher reads. */
14
+ export interface AcceptableFile {
15
+ name: string;
16
+ type: string;
17
+ }
18
+ /** Whether `file` matches any of the `accept` entries. */
19
+ export declare function matchesAccept(file: AcceptableFile, accept: readonly string[]): boolean;
20
+ /**
21
+ * Readable label for `accept` entries: MIME groups become friendly names
22
+ * (Images, PDF, Videos, Audio), other MIME types their uppercased subtype and
23
+ * extensions their uppercased name (`.dxf` → DXF). Duplicates collapse, so
24
+ * `['application/pdf', '.pdf']` reads "PDF".
25
+ */
26
+ export declare function acceptLabel(accept: readonly string[]): string;
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Matching and labelling for a list of HTML `accept` entries, as used by
3
+ * `FileUpload`'s `acceptedTypes`. Mirrors the native `<input accept>` rules:
4
+ *
5
+ * - `.dxf` matches a file whose name ends in `.dxf`, case-insensitively
6
+ * (whatever MIME type the browser guessed, often `application/octet-stream` or '')
7
+ * - `image/*` matches any MIME type in the `image` major type
8
+ * - `application/pdf` matches that MIME type exactly, case-insensitively
9
+ *
10
+ * A file is accepted when any entry matches. An empty list accepts everything,
11
+ * as an absent `accept` attribute does.
12
+ */
13
+ function entries(accept) {
14
+ return accept.map((entry) => entry.trim().toLowerCase()).filter(Boolean);
15
+ }
16
+ /** Whether `file` matches any of the `accept` entries. */
17
+ export function matchesAccept(file, accept) {
18
+ const list = entries(accept);
19
+ if (list.length === 0)
20
+ return true;
21
+ const name = file.name.toLowerCase();
22
+ const type = file.type.trim().toLowerCase();
23
+ return list.some((entry) => {
24
+ if (entry.startsWith('.'))
25
+ return name.endsWith(entry) && name.length > entry.length;
26
+ if (entry === '*' || entry === '*/*')
27
+ return true;
28
+ if (entry.endsWith('/*'))
29
+ return type.startsWith(entry.slice(0, -1));
30
+ return type === entry;
31
+ });
32
+ }
33
+ /**
34
+ * Readable label for `accept` entries: MIME groups become friendly names
35
+ * (Images, PDF, Videos, Audio), other MIME types their uppercased subtype and
36
+ * extensions their uppercased name (`.dxf` → DXF). Duplicates collapse, so
37
+ * `['application/pdf', '.pdf']` reads "PDF".
38
+ */
39
+ export function acceptLabel(accept) {
40
+ const groups = [];
41
+ const add = (label) => {
42
+ if (!groups.includes(label))
43
+ groups.push(label);
44
+ };
45
+ for (const raw of accept) {
46
+ const type = raw.trim().toLowerCase();
47
+ if (!type)
48
+ continue;
49
+ if (type.startsWith('.'))
50
+ add(type.slice(1).toUpperCase());
51
+ else if (type === '*' || type === '*/*')
52
+ add('Any file');
53
+ else if (type.startsWith('image/'))
54
+ add('Images');
55
+ else if (type === 'application/pdf')
56
+ add('PDF');
57
+ else if (type.startsWith('video/'))
58
+ add('Videos');
59
+ else if (type.startsWith('audio/'))
60
+ add('Audio');
61
+ else {
62
+ const subtype = type.split('/')[1];
63
+ add(subtype ? subtype.toUpperCase() : raw.trim());
64
+ }
65
+ }
66
+ return groups.join(', ');
67
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@classic-homes/theme-svelte",
3
- "version": "1.40.0",
3
+ "version": "1.42.1",
4
4
  "description": "Svelte components for the Classic theme system",
5
5
  "type": "module",
6
6
  "svelte": "./dist/lib/index.js",