@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.
- package/dist/lib/components/DataPanel.svelte +29 -0
- package/dist/lib/components/FileUpload.svelte +14 -34
- package/dist/lib/components/FileUpload.svelte.d.ts +6 -1
- package/dist/lib/composables/useFocusTrap.svelte.js +52 -19
- package/dist/lib/index.d.ts +1 -0
- package/dist/lib/index.js +1 -0
- package/dist/lib/utils/file-accept.d.ts +26 -0
- package/dist/lib/utils/file-accept.js +67 -0
- package/package.json +1 -1
|
@@ -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
|
-
/**
|
|
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
|
|
116
|
+
* accepted types into friendly names (Images, PDF, Videos, DXF, ...).
|
|
111
117
|
*/
|
|
112
|
-
const acceptedTypesLabel = $derived
|
|
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
|
-
//
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
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
|
-
/**
|
|
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,
|
|
19
|
-
// trap's first or
|
|
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.
|
|
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
|
-
|
|
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
|
|
71
|
-
|
|
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
|
|
78
|
-
|
|
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
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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
|
-
|
|
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;
|
package/dist/lib/index.d.ts
CHANGED
|
@@ -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
|
+
}
|