@motion-proto/live-tokens 0.30.0 → 0.31.0
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/.claude/skills/live-tokens-create-component/SKILL.md +1 -0
- package/dist-plugin/{chunk-2TW77U3O.js → chunk-Y3K6NR6V.js} +11 -1
- package/dist-plugin/index.cjs +11 -1
- package/dist-plugin/index.js +1 -1
- package/dist-plugin/tokensCssMigrations/index.cjs +11 -1
- package/dist-plugin/tokensCssMigrations/index.js +1 -1
- package/package.json +3 -2
- package/src/editor/component-editor/ImageLightboxEditor.svelte +27 -3
- package/src/system/components/Dialog.svelte +4 -1
- package/src/system/components/ImageLightbox.svelte +500 -153
- package/src/system/internal/portal.ts +18 -0
- package/src/system/styles/tokens.css +0 -9
|
@@ -106,6 +106,7 @@ The authoritative recognised list lives in `bin/check-component.mjs` (`KNOWN_SUF
|
|
|
106
106
|
|
|
107
107
|
### Rules that bite
|
|
108
108
|
|
|
109
|
+
- **Fixed overlays must portal to `<body>`.** Any `position: fixed` layer (modal, lightbox, full-screen backdrop) is trapped — clipped or painted under other chrome — by a transformed / `isolation` / `contain` / `will-change` ancestor, which real consumer pages (and the editor's own preview pane) commonly have. Render the fixed layer with `use:portal` from `src/system/internal/portal.ts` so it escapes to `<body>`; pass `use:portal={enabled}` to keep an in-flow preview variant in place (see `Dialog`). `check:overlay-portal` fails the build if a component has `position: fixed` without it. Anchored popovers (`position: absolute` relative to a trigger, like `Tooltip`) are exempt. Two consequences of moving to `<body>`: DOM events from the layer no longer bubble to a consumer ancestor (use component callbacks, as `Dialog` does), and a subtree-scoped CSS-variable theme no longer reaches it (this library themes via `:root`, so fine here). If the layer is a modal, also give it `role="dialog"` + `aria-modal`, move focus in on open and restore it on close, and trap `Tab` (see `ImageLightbox`).
|
|
109
110
|
- **State before property.** `--mywidget-button-hover-surface` ✓ — `--mywidget-button-surface-hover` ✗ (breaks sibling matching).
|
|
110
111
|
- **Defaults reference theme tokens, never raw values.** `var(--surface-primary)` ✓ — `#6a4ce8` ✗.
|
|
111
112
|
- **No abbreviations.** `bg` → `surface`; `fg` → `text`; component ids are never abbreviated.
|
|
@@ -173,6 +173,15 @@ var tokensCssMigration_2026_06_03_transformScaleAdditions = {
|
|
|
173
173
|
}
|
|
174
174
|
};
|
|
175
175
|
|
|
176
|
+
// vite-plugin/tokensCssMigrations/migrations/2026-06-04-remove-dead-size-icon-scale.ts
|
|
177
|
+
var tokensCssMigration_2026_06_04_removeDeadSizeIconScale = {
|
|
178
|
+
id: "2026-06-04-remove-dead-size-icon-scale",
|
|
179
|
+
description: "Remove the unused --size-icon-* scale (live scale is --icon-size-*)",
|
|
180
|
+
apply(css) {
|
|
181
|
+
return removeTokensMatching(css, (name) => name.startsWith("--size-icon-"));
|
|
182
|
+
}
|
|
183
|
+
};
|
|
184
|
+
|
|
176
185
|
// vite-plugin/files/dataPaths.ts
|
|
177
186
|
import fs from "fs";
|
|
178
187
|
import path from "path";
|
|
@@ -223,7 +232,8 @@ function resolveDataDirs(opts = {}) {
|
|
|
223
232
|
var TOKENS_CSS_MIGRATIONS = [
|
|
224
233
|
tokensCssMigration_2026_05_29_typographyScaleAdditions,
|
|
225
234
|
tokensCssMigration_2026_05_29_sectiondividerLegacyAxisCleanup,
|
|
226
|
-
tokensCssMigration_2026_06_03_transformScaleAdditions
|
|
235
|
+
tokensCssMigration_2026_06_03_transformScaleAdditions,
|
|
236
|
+
tokensCssMigration_2026_06_04_removeDeadSizeIconScale
|
|
227
237
|
];
|
|
228
238
|
function runTokensCssMigrations(css) {
|
|
229
239
|
let out = css;
|
package/dist-plugin/index.cjs
CHANGED
|
@@ -797,11 +797,21 @@ var tokensCssMigration_2026_06_03_transformScaleAdditions = {
|
|
|
797
797
|
}
|
|
798
798
|
};
|
|
799
799
|
|
|
800
|
+
// vite-plugin/tokensCssMigrations/migrations/2026-06-04-remove-dead-size-icon-scale.ts
|
|
801
|
+
var tokensCssMigration_2026_06_04_removeDeadSizeIconScale = {
|
|
802
|
+
id: "2026-06-04-remove-dead-size-icon-scale",
|
|
803
|
+
description: "Remove the unused --size-icon-* scale (live scale is --icon-size-*)",
|
|
804
|
+
apply(css) {
|
|
805
|
+
return removeTokensMatching(css, (name) => name.startsWith("--size-icon-"));
|
|
806
|
+
}
|
|
807
|
+
};
|
|
808
|
+
|
|
800
809
|
// vite-plugin/tokensCssMigrations/index.ts
|
|
801
810
|
var TOKENS_CSS_MIGRATIONS = [
|
|
802
811
|
tokensCssMigration_2026_05_29_typographyScaleAdditions,
|
|
803
812
|
tokensCssMigration_2026_05_29_sectiondividerLegacyAxisCleanup,
|
|
804
|
-
tokensCssMigration_2026_06_03_transformScaleAdditions
|
|
813
|
+
tokensCssMigration_2026_06_03_transformScaleAdditions,
|
|
814
|
+
tokensCssMigration_2026_06_04_removeDeadSizeIconScale
|
|
805
815
|
];
|
|
806
816
|
function runTokensCssMigrations(css) {
|
|
807
817
|
let out = css;
|
package/dist-plugin/index.js
CHANGED
|
@@ -218,6 +218,15 @@ var tokensCssMigration_2026_06_03_transformScaleAdditions = {
|
|
|
218
218
|
}
|
|
219
219
|
};
|
|
220
220
|
|
|
221
|
+
// vite-plugin/tokensCssMigrations/migrations/2026-06-04-remove-dead-size-icon-scale.ts
|
|
222
|
+
var tokensCssMigration_2026_06_04_removeDeadSizeIconScale = {
|
|
223
|
+
id: "2026-06-04-remove-dead-size-icon-scale",
|
|
224
|
+
description: "Remove the unused --size-icon-* scale (live scale is --icon-size-*)",
|
|
225
|
+
apply(css) {
|
|
226
|
+
return removeTokensMatching(css, (name) => name.startsWith("--size-icon-"));
|
|
227
|
+
}
|
|
228
|
+
};
|
|
229
|
+
|
|
221
230
|
// vite-plugin/files/dataPaths.ts
|
|
222
231
|
var import_fs = __toESM(require("fs"), 1);
|
|
223
232
|
var import_path = __toESM(require("path"), 1);
|
|
@@ -255,7 +264,8 @@ function readLiveTokensConfig() {
|
|
|
255
264
|
var TOKENS_CSS_MIGRATIONS = [
|
|
256
265
|
tokensCssMigration_2026_05_29_typographyScaleAdditions,
|
|
257
266
|
tokensCssMigration_2026_05_29_sectiondividerLegacyAxisCleanup,
|
|
258
|
-
tokensCssMigration_2026_06_03_transformScaleAdditions
|
|
267
|
+
tokensCssMigration_2026_06_03_transformScaleAdditions,
|
|
268
|
+
tokensCssMigration_2026_06_04_removeDeadSizeIconScale
|
|
259
269
|
];
|
|
260
270
|
function runTokensCssMigrations(css) {
|
|
261
271
|
let out = css;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@motion-proto/live-tokens",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.31.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Design token editor with live CSS variable editing. Svelte 5 + Vite 8.",
|
|
6
6
|
"keywords": [
|
|
@@ -95,6 +95,7 @@
|
|
|
95
95
|
"deploy:local": "bash scripts/deploy-local.sh",
|
|
96
96
|
"check:no-style-imports": "node scripts/check-no-style-imports.mjs",
|
|
97
97
|
"check:slot-prose": "node scripts/check-slot-prose.mjs",
|
|
98
|
+
"check:overlay-portal": "node scripts/check-overlay-portal.mjs",
|
|
98
99
|
"check:editor-font-isolation": "node scripts/check-editor-font-isolation.mjs",
|
|
99
100
|
"check:component-defaults": "node scripts/sync-component-defaults.mjs --check",
|
|
100
101
|
"check:production-is-default": "node scripts/check-production-is-default.mjs",
|
|
@@ -104,7 +105,7 @@
|
|
|
104
105
|
"collapse:manifest": "node scripts/collapse-manifest-to-default.mjs",
|
|
105
106
|
"check:smoke-install": "bash scripts/smoke-install.sh",
|
|
106
107
|
"check:smoke-create": "bash scripts/smoke-create.sh",
|
|
107
|
-
"prepublishOnly": "npm run check:no-style-imports && npm run check:slot-prose && npm run check:editor-font-isolation && npm run check:component-defaults && npm run check:production-is-default && npm run check:docs-content && npm run build:lib && npm run check:smoke-install && npm run check:smoke-create"
|
|
108
|
+
"prepublishOnly": "npm run check:no-style-imports && npm run check:slot-prose && npm run check:overlay-portal && npm run check:editor-font-isolation && npm run check:component-defaults && npm run check:production-is-default && npm run check:docs-content && npm run build:lib && npm run check:smoke-install && npm run check:smoke-create"
|
|
108
109
|
},
|
|
109
110
|
"peerDependencies": {
|
|
110
111
|
"@sveltejs/vite-plugin-svelte": "^7.0",
|
|
@@ -33,18 +33,34 @@
|
|
|
33
33
|
import ImageLightbox from '../../system/components/ImageLightbox.svelte';
|
|
34
34
|
import VariantGroup from './scaffolding/VariantGroup.svelte';
|
|
35
35
|
import ComponentEditorBase from './scaffolding/ComponentEditorBase.svelte';
|
|
36
|
-
import
|
|
36
|
+
import offeringUrl from '../../system/assets/offering.webp';
|
|
37
|
+
import newspaperUrl from '../../system/assets/newspaper.webp';
|
|
38
|
+
|
|
39
|
+
const demoImages = [
|
|
40
|
+
// Offering carries explicit dimensions (the no-reflow path); Newspaper omits
|
|
41
|
+
// them to exercise self-measure from the loaded image.
|
|
42
|
+
{ src: offeringUrl, alt: 'Offering', width: 1455, height: 970 },
|
|
43
|
+
{ src: newspaperUrl, alt: 'Newspaper' },
|
|
44
|
+
];
|
|
45
|
+
|
|
46
|
+
let multiple = $state(true);
|
|
37
47
|
</script>
|
|
38
48
|
|
|
39
49
|
<ComponentEditorBase
|
|
40
50
|
{component}
|
|
41
51
|
title="Image Lightbox"
|
|
42
|
-
description="Click an inline image to expand it into a centered modal with a backdrop. Extended mode adds zoom controls and drag panning."
|
|
52
|
+
description="Click an inline image to expand it into a centered modal with a backdrop. Pass multiple images for a gallery (chevrons + counter). Extended mode adds zoom controls and drag panning."
|
|
43
53
|
tokens={allTokens}
|
|
44
54
|
>
|
|
45
55
|
<VariantGroup name="imagelightbox" title="Image Lightbox" {states} {component}>
|
|
56
|
+
{#snippet stateActions()}
|
|
57
|
+
<label class="preview-toggle">
|
|
58
|
+
<input type="checkbox" checked={multiple} onchange={(e) => (multiple = e.currentTarget.checked)} />
|
|
59
|
+
<span>Multiple images</span>
|
|
60
|
+
</label>
|
|
61
|
+
{/snippet}
|
|
46
62
|
<div class="preview-frame">
|
|
47
|
-
<ImageLightbox
|
|
63
|
+
<ImageLightbox images={multiple ? demoImages : [demoImages[0]]} extended />
|
|
48
64
|
</div>
|
|
49
65
|
</VariantGroup>
|
|
50
66
|
</ComponentEditorBase>
|
|
@@ -55,4 +71,12 @@
|
|
|
55
71
|
max-width: 28rem;
|
|
56
72
|
margin: 0 auto;
|
|
57
73
|
}
|
|
74
|
+
.preview-toggle {
|
|
75
|
+
display: inline-flex;
|
|
76
|
+
align-items: center;
|
|
77
|
+
gap: var(--ui-space-4);
|
|
78
|
+
font-size: var(--ui-font-size-sm);
|
|
79
|
+
color: var(--ui-text-secondary);
|
|
80
|
+
cursor: pointer;
|
|
81
|
+
}
|
|
58
82
|
</style>
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
2
|
import { createEventDispatcher, tick } from 'svelte';
|
|
3
3
|
import type { Snippet } from 'svelte';
|
|
4
|
+
import { portal } from '../internal/portal';
|
|
4
5
|
import Button from './Button.svelte';
|
|
5
6
|
import type { ButtonVariant, DialogButtonSpec } from './types';
|
|
6
7
|
|
|
@@ -105,7 +106,9 @@
|
|
|
105
106
|
</script>
|
|
106
107
|
|
|
107
108
|
{#if show}
|
|
108
|
-
|
|
109
|
+
<!-- The fixed backdrop portals to <body> to escape transformed/isolated
|
|
110
|
+
ancestors; the inline preview variant stays in flow. -->
|
|
111
|
+
<div class="dialog-backdrop" class:inline use:portal={!inline}>
|
|
109
112
|
<div class="dialog" style="width: {width}; max-width: {width};">
|
|
110
113
|
<div class="dialog-content">
|
|
111
114
|
{#if title}
|
|
@@ -1,47 +1,118 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
2
|
import { onMount, tick } from 'svelte';
|
|
3
|
+
import { portal } from '../internal/portal';
|
|
3
4
|
|
|
4
|
-
interface
|
|
5
|
+
interface GalleryImage {
|
|
5
6
|
src: string;
|
|
6
7
|
alt: string;
|
|
8
|
+
width?: number;
|
|
9
|
+
height?: number;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
interface Props {
|
|
13
|
+
src?: string;
|
|
14
|
+
alt?: string;
|
|
7
15
|
width?: number | undefined;
|
|
8
16
|
height?: number | undefined;
|
|
17
|
+
/** Pass two or more to turn the lightbox into a gallery: left/right chevrons
|
|
18
|
+
and an `i / n` counter appear once open, plus arrow-key navigation. A
|
|
19
|
+
single-entry array behaves exactly like a lone `src`. */
|
|
20
|
+
images?: GalleryImage[];
|
|
9
21
|
maxWidth?: number | string | undefined;
|
|
22
|
+
/** Closed-thumbnail object-fit. `cover` crops the thumbnail to fill its box;
|
|
23
|
+
the expanded modal always uses `contain` so the whole image stays visible.
|
|
24
|
+
`cover` only crops when the thumbnail has its own box (an aspect from
|
|
25
|
+
`width`/`height`, or a CSS-constrained container). */
|
|
26
|
+
fit?: 'contain' | 'cover';
|
|
10
27
|
/** When true, shows a bottom toolbar (zoom in/out + percent) and a top-right close button, and enables wheel/drag zoom inside the open modal. When false, click anywhere closes. */
|
|
11
28
|
extended?: boolean;
|
|
12
29
|
}
|
|
13
30
|
|
|
14
31
|
let {
|
|
15
|
-
src,
|
|
16
|
-
alt,
|
|
32
|
+
src = undefined,
|
|
33
|
+
alt = undefined,
|
|
17
34
|
width = undefined,
|
|
18
35
|
height = undefined,
|
|
36
|
+
images = undefined,
|
|
19
37
|
maxWidth = undefined,
|
|
38
|
+
fit = 'contain',
|
|
20
39
|
extended = false,
|
|
21
40
|
}: Props = $props();
|
|
22
41
|
|
|
42
|
+
const items = $derived(
|
|
43
|
+
images && images.length
|
|
44
|
+
? images
|
|
45
|
+
: src != null
|
|
46
|
+
? [{ src, alt: alt ?? '', width, height }]
|
|
47
|
+
: [],
|
|
48
|
+
);
|
|
49
|
+
|
|
50
|
+
let index = $state(0);
|
|
51
|
+
const current = $derived(items[index]);
|
|
52
|
+
const cover = $derived(items[0]);
|
|
53
|
+
const isGallery = $derived(items.length > 1);
|
|
54
|
+
|
|
55
|
+
const dialogLabel = $derived(
|
|
56
|
+
isGallery
|
|
57
|
+
? `Image ${index + 1} of ${items.length}${current?.alt ? `: ${current.alt}` : ''}`
|
|
58
|
+
: current?.alt || 'Image',
|
|
59
|
+
);
|
|
60
|
+
|
|
23
61
|
const MIN_SCALE = 1;
|
|
24
62
|
const MAX_SCALE = 5;
|
|
25
63
|
const ZOOM_STEP = 1.5;
|
|
26
64
|
const TRANSITION_MS = 350;
|
|
27
65
|
const TRANSITION_EASE = 'cubic-bezier(0.65, 0, 0.35, 1)';
|
|
28
66
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
67
|
+
// Gallery navigation: outgoing slides out + shrinks + fades; incoming slides
|
|
68
|
+
// in from the opposite side, staggered. Easings mirror --ease-in/out-cubic.
|
|
69
|
+
const NAV_MS = 250;
|
|
70
|
+
const NAV_STAGGER_MS = 125;
|
|
71
|
+
const NAV_SHIFT_PX = 32;
|
|
72
|
+
const NAV_SCALE = 0.95;
|
|
73
|
+
const EASE_IN = 'cubic-bezier(0.32, 0, 0.67, 0)';
|
|
74
|
+
const EASE_OUT = 'cubic-bezier(0.33, 1, 0.68, 1)';
|
|
75
|
+
|
|
76
|
+
let thumbEl: HTMLButtonElement | undefined = $state();
|
|
77
|
+
let modalEl: HTMLDivElement | undefined = $state();
|
|
78
|
+
let stageEl: HTMLDivElement | undefined = $state();
|
|
79
|
+
let transformEl: HTMLDivElement | undefined = $state();
|
|
80
|
+
let overlayEl: HTMLDivElement | undefined = $state();
|
|
33
81
|
let toolbarEl: HTMLDivElement | undefined = $state();
|
|
34
82
|
let closeBtnEl: HTMLButtonElement | undefined = $state();
|
|
35
|
-
|
|
83
|
+
let prevBtnEl: HTMLButtonElement | undefined = $state();
|
|
84
|
+
let nextBtnEl: HTMLButtonElement | undefined = $state();
|
|
85
|
+
let counterEl: HTMLDivElement | undefined = $state();
|
|
86
|
+
let incomingEl: HTMLImageElement | undefined = $state();
|
|
87
|
+
let outgoingEl: HTMLImageElement | undefined = $state();
|
|
88
|
+
|
|
89
|
+
// `mounted` keeps the portaled modal in the DOM through the close animation;
|
|
90
|
+
// `open` is the visual state the chrome reacts to.
|
|
91
|
+
let mounted = $state(false);
|
|
36
92
|
let open = $state(false);
|
|
37
93
|
let scale = $state(1);
|
|
38
94
|
let offset = { x: 0, y: 0 };
|
|
39
95
|
|
|
96
|
+
// The image leaving during a gallery transition; rendered as a second layer
|
|
97
|
+
// until its slide-out finishes, then dropped.
|
|
98
|
+
let outgoing = $state<GalleryImage | null>(null);
|
|
99
|
+
let navigating = false;
|
|
100
|
+
|
|
101
|
+
// Aspect ratio per image, keyed by src: explicit width/height when given, else
|
|
102
|
+
// measured from the loaded <img>. The thumbnail reads the cover's entry and the
|
|
103
|
+
// modal reads the current image's, so they are never confused for each other.
|
|
104
|
+
let measured = $state<Record<string, number>>({});
|
|
105
|
+
let reducedMotion = $state(false);
|
|
106
|
+
const dur = (ms = TRANSITION_MS) => (reducedMotion ? 0 : ms);
|
|
107
|
+
|
|
40
108
|
// Pointer drag for pan (extended + zoomed only).
|
|
41
109
|
let dragState: { startX: number; startY: number; baseX: number; baseY: number; pointerId: number } | null = null;
|
|
42
110
|
let didDrag = false;
|
|
43
111
|
|
|
44
|
-
const
|
|
112
|
+
const aspectOf = (it?: GalleryImage) =>
|
|
113
|
+
it ? (it.width && it.height ? it.width / it.height : measured[it.src]) : undefined;
|
|
114
|
+
const coverAspect = $derived(aspectOf(cover)); // inline thumbnail box
|
|
115
|
+
const aspect = $derived(aspectOf(current)); // open modal box
|
|
45
116
|
|
|
46
117
|
function viewportTarget() {
|
|
47
118
|
const vw = window.innerWidth;
|
|
@@ -62,8 +133,8 @@
|
|
|
62
133
|
}
|
|
63
134
|
|
|
64
135
|
function clampOffset(x: number, y: number, s: number) {
|
|
65
|
-
if (!
|
|
66
|
-
const r =
|
|
136
|
+
if (!stageEl) return { x: 0, y: 0 };
|
|
137
|
+
const r = stageEl.getBoundingClientRect();
|
|
67
138
|
const maxX = Math.max(0, (r.width * s - r.width) / 2);
|
|
68
139
|
const maxY = Math.max(0, (r.height * s - r.height) / 2);
|
|
69
140
|
return {
|
|
@@ -101,10 +172,8 @@
|
|
|
101
172
|
function cancelAnimations() {
|
|
102
173
|
// Commit each animation's current value to inline styles before cancelling,
|
|
103
174
|
// so a mid-flight cancel doesn't visually snap the element back to its
|
|
104
|
-
// pre-animation state.
|
|
105
|
-
|
|
106
|
-
// clear via cssText = ''.
|
|
107
|
-
for (const el of [tileEl, overlayEl, toolbarEl, closeBtnEl]) {
|
|
175
|
+
// pre-animation state.
|
|
176
|
+
for (const el of [stageEl, overlayEl, toolbarEl, closeBtnEl, prevBtnEl, nextBtnEl, counterEl]) {
|
|
108
177
|
if (!el) continue;
|
|
109
178
|
for (const a of el.getAnimations()) {
|
|
110
179
|
try { a.commitStyles(); } catch {}
|
|
@@ -114,100 +183,218 @@
|
|
|
114
183
|
}
|
|
115
184
|
|
|
116
185
|
async function openLightbox() {
|
|
117
|
-
if (open || !
|
|
118
|
-
|
|
119
|
-
const start = tileEl.getBoundingClientRect();
|
|
120
|
-
const target = viewportTarget();
|
|
186
|
+
if (open || !thumbEl) return;
|
|
187
|
+
const start = thumbEl.getBoundingClientRect();
|
|
121
188
|
|
|
189
|
+
mounted = true;
|
|
122
190
|
open = true;
|
|
123
191
|
document.body.style.overflow = 'hidden';
|
|
124
192
|
hideEditorOverlay();
|
|
125
193
|
await tick();
|
|
194
|
+
if (!stageEl || !overlayEl) return;
|
|
126
195
|
|
|
127
|
-
|
|
128
|
-
|
|
196
|
+
const target = viewportTarget();
|
|
197
|
+
Object.assign(stageEl.style, {
|
|
129
198
|
top: `${start.top}px`,
|
|
130
199
|
left: `${start.left}px`,
|
|
131
200
|
width: `${start.width}px`,
|
|
132
201
|
height: `${start.height}px`,
|
|
133
|
-
zIndex: 'var(--z-modal)',
|
|
134
202
|
});
|
|
135
203
|
|
|
136
|
-
|
|
204
|
+
stageEl.animate(
|
|
137
205
|
[
|
|
138
206
|
{ top: `${start.top}px`, left: `${start.left}px`, width: `${start.width}px`, height: `${start.height}px` },
|
|
139
207
|
{ top: `${target.top}px`, left: `${target.left}px`, width: `${target.width}px`, height: `${target.height}px` },
|
|
140
208
|
],
|
|
141
|
-
{ duration:
|
|
209
|
+
{ duration: dur(), easing: TRANSITION_EASE, fill: 'forwards' },
|
|
142
210
|
);
|
|
143
211
|
|
|
144
212
|
overlayEl.animate([{ opacity: 0 }, { opacity: 1 }], {
|
|
145
|
-
duration:
|
|
213
|
+
duration: dur(),
|
|
146
214
|
easing: TRANSITION_EASE,
|
|
147
215
|
fill: 'forwards',
|
|
148
216
|
});
|
|
149
217
|
|
|
218
|
+
fadeChrome(true);
|
|
150
219
|
if (extended) {
|
|
151
220
|
toolbarEl?.animate([{ opacity: 0, transform: 'translate(-50%, 16px)' }, { opacity: 1, transform: 'translate(-50%, 0)' }], {
|
|
152
|
-
duration:
|
|
221
|
+
duration: dur(),
|
|
153
222
|
easing: TRANSITION_EASE,
|
|
154
223
|
fill: 'forwards',
|
|
155
224
|
delay: 80,
|
|
156
225
|
});
|
|
157
|
-
|
|
158
|
-
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// Move focus into the modal so keyboard users land inside the dialog; the
|
|
229
|
+
// stage (tabindex -1) is the fallback when there is no chrome to focus.
|
|
230
|
+
(closeBtnEl ?? stageEl)?.focus();
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// Close button, gallery chevrons, and counter all share a plain opacity fade;
|
|
234
|
+
// the zoom toolbar keeps its bespoke slide so it's handled separately.
|
|
235
|
+
function chromeFadeEls(): HTMLElement[] {
|
|
236
|
+
const els: (HTMLElement | undefined)[] = [];
|
|
237
|
+
if (extended || isGallery) els.push(closeBtnEl);
|
|
238
|
+
if (isGallery) els.push(prevBtnEl, nextBtnEl, counterEl);
|
|
239
|
+
return els.filter((el): el is HTMLElement => !!el);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function fadeChrome(showing: boolean) {
|
|
243
|
+
for (const el of chromeFadeEls()) {
|
|
244
|
+
el.animate([{ opacity: showing ? 0 : 1 }, { opacity: showing ? 1 : 0 }], {
|
|
245
|
+
duration: dur(),
|
|
159
246
|
easing: TRANSITION_EASE,
|
|
160
247
|
fill: 'forwards',
|
|
161
|
-
delay: 80,
|
|
248
|
+
delay: showing ? 80 : 0,
|
|
162
249
|
});
|
|
163
250
|
}
|
|
164
251
|
}
|
|
165
252
|
|
|
166
253
|
function closeLightbox() {
|
|
167
|
-
if (!open || !
|
|
254
|
+
if (!open || !stageEl || !thumbEl || !overlayEl) return;
|
|
168
255
|
cancelAnimations();
|
|
169
|
-
const target =
|
|
170
|
-
const
|
|
256
|
+
const target = thumbEl.getBoundingClientRect();
|
|
257
|
+
const from = stageEl.getBoundingClientRect();
|
|
171
258
|
|
|
172
|
-
const anim =
|
|
259
|
+
const anim = stageEl.animate(
|
|
173
260
|
[
|
|
174
|
-
{ top: `${
|
|
261
|
+
{ top: `${from.top}px`, left: `${from.left}px`, width: `${from.width}px`, height: `${from.height}px` },
|
|
175
262
|
{ top: `${target.top}px`, left: `${target.left}px`, width: `${target.width}px`, height: `${target.height}px` },
|
|
176
263
|
],
|
|
177
|
-
{ duration:
|
|
264
|
+
{ duration: dur(), easing: TRANSITION_EASE, fill: 'forwards' },
|
|
178
265
|
);
|
|
179
266
|
|
|
180
267
|
overlayEl.animate([{ opacity: 1 }, { opacity: 0 }], {
|
|
181
|
-
duration:
|
|
268
|
+
duration: dur(),
|
|
182
269
|
easing: TRANSITION_EASE,
|
|
183
270
|
fill: 'forwards',
|
|
184
271
|
});
|
|
185
272
|
|
|
273
|
+
fadeChrome(false);
|
|
186
274
|
if (extended) {
|
|
187
275
|
toolbarEl?.animate([{ opacity: 1 }, { opacity: 0 }], {
|
|
188
|
-
duration:
|
|
189
|
-
easing: TRANSITION_EASE,
|
|
190
|
-
fill: 'forwards',
|
|
191
|
-
});
|
|
192
|
-
closeBtnEl?.animate([{ opacity: 1 }, { opacity: 0 }], {
|
|
193
|
-
duration: TRANSITION_MS,
|
|
276
|
+
duration: dur(),
|
|
194
277
|
easing: TRANSITION_EASE,
|
|
195
278
|
fill: 'forwards',
|
|
196
279
|
});
|
|
197
280
|
}
|
|
198
281
|
|
|
199
282
|
anim.onfinish = () => {
|
|
200
|
-
cancelAnimations();
|
|
201
|
-
tileEl.style.cssText = '';
|
|
202
|
-
transformEl.style.transform = '';
|
|
203
283
|
scale = 1;
|
|
204
284
|
offset = { x: 0, y: 0 };
|
|
285
|
+
index = 0;
|
|
205
286
|
open = false;
|
|
287
|
+
mounted = false;
|
|
206
288
|
document.body.style.overflow = '';
|
|
207
289
|
restoreEditorOverlay();
|
|
290
|
+
thumbEl?.focus();
|
|
208
291
|
};
|
|
209
292
|
}
|
|
210
293
|
|
|
294
|
+
// Re-fit the open modal to the viewport — used on resize (snap) and when the
|
|
295
|
+
// gallery image or its measured aspect changes (animated morph).
|
|
296
|
+
function fitToViewport(animate: boolean) {
|
|
297
|
+
if (!open || !stageEl) return;
|
|
298
|
+
const target = viewportTarget();
|
|
299
|
+
if (animate) {
|
|
300
|
+
const from = stageEl.getBoundingClientRect();
|
|
301
|
+
for (const a of stageEl.getAnimations()) {
|
|
302
|
+
try { a.commitStyles(); } catch {}
|
|
303
|
+
a.cancel();
|
|
304
|
+
}
|
|
305
|
+
stageEl.animate(
|
|
306
|
+
[
|
|
307
|
+
{ top: `${from.top}px`, left: `${from.left}px`, width: `${from.width}px`, height: `${from.height}px` },
|
|
308
|
+
{ top: `${target.top}px`, left: `${target.left}px`, width: `${target.width}px`, height: `${target.height}px` },
|
|
309
|
+
],
|
|
310
|
+
{ duration: dur(), easing: TRANSITION_EASE, fill: 'forwards' },
|
|
311
|
+
);
|
|
312
|
+
} else {
|
|
313
|
+
Object.assign(stageEl.style, {
|
|
314
|
+
top: `${target.top}px`,
|
|
315
|
+
left: `${target.left}px`,
|
|
316
|
+
width: `${target.width}px`,
|
|
317
|
+
height: `${target.height}px`,
|
|
318
|
+
});
|
|
319
|
+
}
|
|
320
|
+
offset = clampOffset(offset.x, offset.y, scale);
|
|
321
|
+
applyTransform(scale, offset);
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
function settleNav() {
|
|
325
|
+
incomingEl?.getAnimations().forEach((a) => a.cancel());
|
|
326
|
+
outgoingEl?.getAnimations().forEach((a) => a.cancel());
|
|
327
|
+
outgoing = null;
|
|
328
|
+
navigating = false;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// dir: +1 when paging via the right chevron (content exits left, the next
|
|
332
|
+
// image enters from the right), -1 for the left chevron.
|
|
333
|
+
async function goTo(target: number, dir: number) {
|
|
334
|
+
if (!isGallery) return;
|
|
335
|
+
const n = ((target % items.length) + items.length) % items.length;
|
|
336
|
+
if (n === index) return;
|
|
337
|
+
if (navigating) settleNav();
|
|
338
|
+
|
|
339
|
+
scale = 1;
|
|
340
|
+
offset = { x: 0, y: 0 };
|
|
341
|
+
applyTransform(scale, offset);
|
|
342
|
+
|
|
343
|
+
outgoing = current;
|
|
344
|
+
index = n;
|
|
345
|
+
navigating = true;
|
|
346
|
+
await tick();
|
|
347
|
+
|
|
348
|
+
const exit = -NAV_SHIFT_PX * dir;
|
|
349
|
+
outgoingEl?.animate(
|
|
350
|
+
[
|
|
351
|
+
{ transform: 'translateX(0) scale(1)', opacity: 1 },
|
|
352
|
+
{ transform: `translateX(${exit}px) scale(${NAV_SCALE})`, opacity: 0 },
|
|
353
|
+
],
|
|
354
|
+
{ duration: dur(NAV_MS), easing: EASE_IN, fill: 'forwards' },
|
|
355
|
+
);
|
|
356
|
+
|
|
357
|
+
const enter = incomingEl?.animate(
|
|
358
|
+
[
|
|
359
|
+
{ transform: `translateX(${-exit}px) scale(${NAV_SCALE})`, opacity: 0 },
|
|
360
|
+
{ transform: 'translateX(0) scale(1)', opacity: 1 },
|
|
361
|
+
],
|
|
362
|
+
// `fill: both` holds the opacity-0 start state through the stagger delay,
|
|
363
|
+
// otherwise the incoming image flashes at full opacity before sliding in.
|
|
364
|
+
{ duration: dur(NAV_MS), easing: EASE_OUT, fill: 'both', delay: dur(NAV_STAGGER_MS) },
|
|
365
|
+
);
|
|
366
|
+
|
|
367
|
+
fitToViewport(true); // resize the stage if the incoming aspect differs; a no-op when it matches
|
|
368
|
+
|
|
369
|
+
if (enter) enter.onfinish = settleNav;
|
|
370
|
+
else settleNav();
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
const next = () => goTo(index + 1, 1);
|
|
374
|
+
const prev = () => goTo(index - 1, -1);
|
|
375
|
+
|
|
376
|
+
function record(src: string, e: Event) {
|
|
377
|
+
const img = e.currentTarget as HTMLImageElement;
|
|
378
|
+
if (img.naturalWidth && img.naturalHeight) measured[src] = img.naturalWidth / img.naturalHeight;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
function onCoverLoad(e: Event) {
|
|
382
|
+
if (cover) record(cover.src, e);
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
function onImgLoad(e: Event) {
|
|
386
|
+
if (current) record(current.src, e);
|
|
387
|
+
if (open) fitToViewport(true);
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
$effect(() => {
|
|
391
|
+
if (!isGallery || typeof Image === 'undefined') return;
|
|
392
|
+
for (const d of [index + 1, index - 1]) {
|
|
393
|
+
const it = items[((d % items.length) + items.length) % items.length];
|
|
394
|
+
if (it) new Image().src = it.src;
|
|
395
|
+
}
|
|
396
|
+
});
|
|
397
|
+
|
|
211
398
|
function zoomTo(nextScale: number, anchor?: { x: number; y: number }) {
|
|
212
399
|
const s = Math.max(MIN_SCALE, Math.min(MAX_SCALE, nextScale));
|
|
213
400
|
if (s <= MIN_SCALE) {
|
|
@@ -216,18 +403,18 @@
|
|
|
216
403
|
applyTransform(scale, offset);
|
|
217
404
|
return;
|
|
218
405
|
}
|
|
219
|
-
let
|
|
220
|
-
if (anchor &&
|
|
221
|
-
const r =
|
|
406
|
+
let nextOffset: { x: number; y: number };
|
|
407
|
+
if (anchor && stageEl) {
|
|
408
|
+
const r = stageEl.getBoundingClientRect();
|
|
222
409
|
const dx = anchor.x - (r.left + r.width / 2);
|
|
223
410
|
const dy = anchor.y - (r.top + r.height / 2);
|
|
224
411
|
const ratio = scale > 0 ? s / scale : 1;
|
|
225
|
-
|
|
412
|
+
nextOffset = clampOffset(dx * (1 - ratio) + offset.x * ratio, dy * (1 - ratio) + offset.y * ratio, s);
|
|
226
413
|
} else {
|
|
227
|
-
|
|
414
|
+
nextOffset = clampOffset(offset.x, offset.y, s);
|
|
228
415
|
}
|
|
229
416
|
scale = s;
|
|
230
|
-
offset =
|
|
417
|
+
offset = nextOffset;
|
|
231
418
|
applyTransform(scale, offset);
|
|
232
419
|
}
|
|
233
420
|
|
|
@@ -267,44 +454,63 @@
|
|
|
267
454
|
}
|
|
268
455
|
}
|
|
269
456
|
|
|
270
|
-
function
|
|
457
|
+
function onStageClick() {
|
|
271
458
|
if (didDrag) {
|
|
272
459
|
didDrag = false;
|
|
273
460
|
return;
|
|
274
461
|
}
|
|
275
|
-
if (!
|
|
276
|
-
openLightbox();
|
|
277
|
-
} else if (!extended) {
|
|
278
|
-
closeLightbox();
|
|
279
|
-
}
|
|
462
|
+
if (!extended && !isGallery) closeLightbox();
|
|
280
463
|
}
|
|
281
464
|
|
|
282
|
-
|
|
283
|
-
|
|
465
|
+
// Keep Tab inside the open dialog. The stage (tabindex -1) is excluded, so an
|
|
466
|
+
// image-only lightbox with no chrome simply holds focus on the stage.
|
|
467
|
+
function trapTab(e: KeyboardEvent) {
|
|
468
|
+
if (!modalEl) return;
|
|
469
|
+
const f = [
|
|
470
|
+
...modalEl.querySelectorAll<HTMLElement>('button:not([disabled]), [href], [tabindex]:not([tabindex="-1"])'),
|
|
471
|
+
];
|
|
472
|
+
if (f.length === 0) {
|
|
473
|
+
e.preventDefault();
|
|
474
|
+
stageEl?.focus();
|
|
475
|
+
return;
|
|
476
|
+
}
|
|
477
|
+
const first = f[0];
|
|
478
|
+
const last = f[f.length - 1];
|
|
479
|
+
const active = document.activeElement as HTMLElement | null;
|
|
480
|
+
const inside = active && modalEl.contains(active);
|
|
481
|
+
if (e.shiftKey && (!inside || active === first)) {
|
|
482
|
+
e.preventDefault();
|
|
483
|
+
last.focus();
|
|
484
|
+
} else if (!e.shiftKey && (!inside || active === last)) {
|
|
284
485
|
e.preventDefault();
|
|
285
|
-
|
|
486
|
+
first.focus();
|
|
286
487
|
}
|
|
287
488
|
}
|
|
288
489
|
|
|
289
490
|
onMount(() => {
|
|
290
491
|
const onKey = (e: KeyboardEvent) => {
|
|
291
|
-
if (
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
if (
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
applyTransform(scale, offset);
|
|
492
|
+
if (!open) return;
|
|
493
|
+
if (e.key === 'Escape') {
|
|
494
|
+
closeLightbox();
|
|
495
|
+
} else if (e.key === 'Tab') {
|
|
496
|
+
trapTab(e);
|
|
497
|
+
} else if (isGallery && e.key === 'ArrowRight') {
|
|
498
|
+
e.preventDefault();
|
|
499
|
+
next();
|
|
500
|
+
} else if (isGallery && e.key === 'ArrowLeft') {
|
|
501
|
+
e.preventDefault();
|
|
502
|
+
prev();
|
|
503
|
+
}
|
|
304
504
|
};
|
|
505
|
+
const onResize = () => fitToViewport(false);
|
|
506
|
+
const motionQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
|
|
507
|
+
reducedMotion = motionQuery.matches;
|
|
508
|
+
const onMotionChange = (e: MediaQueryListEvent) => { reducedMotion = e.matches; };
|
|
509
|
+
motionQuery.addEventListener('change', onMotionChange);
|
|
305
510
|
document.addEventListener('keydown', onKey);
|
|
306
511
|
window.addEventListener('resize', onResize);
|
|
307
512
|
return () => {
|
|
513
|
+
motionQuery.removeEventListener('change', onMotionChange);
|
|
308
514
|
document.removeEventListener('keydown', onKey);
|
|
309
515
|
window.removeEventListener('resize', onResize);
|
|
310
516
|
document.body.style.overflow = '';
|
|
@@ -316,97 +522,151 @@
|
|
|
316
522
|
</script>
|
|
317
523
|
|
|
318
524
|
<div
|
|
319
|
-
bind:this={wrapperEl}
|
|
320
525
|
class="image-lightbox-wrapper"
|
|
321
|
-
style:aspect-ratio={
|
|
526
|
+
style:aspect-ratio={coverAspect ? `${coverAspect}` : undefined}
|
|
322
527
|
style:max-width={typeof maxWidth === 'number' ? `${maxWidth}px` : maxWidth}
|
|
323
528
|
>
|
|
324
|
-
<div
|
|
325
|
-
bind:this={tileEl}
|
|
326
|
-
class="image-lightbox-tile"
|
|
327
|
-
class:open
|
|
328
|
-
role="button"
|
|
329
|
-
tabindex="0"
|
|
330
|
-
aria-label={open ? `Close image: ${alt}` : `Expand image: ${alt}`}
|
|
331
|
-
onclick={onTileClick}
|
|
332
|
-
onkeydown={onTileKeyDown}
|
|
333
|
-
onpointerdown={onPointerDown}
|
|
334
|
-
onpointermove={onPointerMove}
|
|
335
|
-
onpointerup={onPointerUp}
|
|
336
|
-
onpointercancel={onPointerUp}
|
|
337
|
-
onwheel={onWheel}
|
|
338
|
-
>
|
|
339
|
-
<div class="image-lightbox-clip">
|
|
340
|
-
<div bind:this={transformEl} class="image-lightbox-transform">
|
|
341
|
-
<img {src} {alt} draggable="false" />
|
|
342
|
-
</div>
|
|
343
|
-
</div>
|
|
344
|
-
</div>
|
|
345
|
-
</div>
|
|
346
|
-
|
|
347
|
-
<div
|
|
348
|
-
bind:this={overlayEl}
|
|
349
|
-
class="image-lightbox-overlay"
|
|
350
|
-
class:active={open}
|
|
351
|
-
aria-hidden="true"
|
|
352
|
-
onclick={closeLightbox}
|
|
353
|
-
role="presentation"
|
|
354
|
-
></div>
|
|
355
|
-
|
|
356
|
-
{#if extended}
|
|
357
529
|
<button
|
|
358
|
-
bind:this={
|
|
359
|
-
class="image-lightbox-
|
|
360
|
-
|
|
530
|
+
bind:this={thumbEl}
|
|
531
|
+
class="image-lightbox-thumb"
|
|
532
|
+
style:--imagelightbox-tile-object-fit={fit}
|
|
361
533
|
type="button"
|
|
362
|
-
aria-label=
|
|
363
|
-
onclick={
|
|
534
|
+
aria-label={cover?.alt ? `Expand image: ${cover.alt}` : 'Expand image'}
|
|
535
|
+
onclick={openLightbox}
|
|
364
536
|
>
|
|
365
|
-
<
|
|
366
|
-
<path d="M18 6L6 18" />
|
|
367
|
-
<path d="M6 6l12 12" />
|
|
368
|
-
</svg>
|
|
537
|
+
<img src={cover?.src} alt={cover?.alt} draggable="false" onload={onCoverLoad} />
|
|
369
538
|
</button>
|
|
539
|
+
</div>
|
|
370
540
|
|
|
541
|
+
{#if mounted}
|
|
371
542
|
<div
|
|
372
|
-
bind:this={
|
|
373
|
-
class="image-lightbox-
|
|
374
|
-
|
|
543
|
+
bind:this={modalEl}
|
|
544
|
+
class="image-lightbox-modal"
|
|
545
|
+
role="dialog"
|
|
546
|
+
aria-modal="true"
|
|
547
|
+
aria-label={dialogLabel}
|
|
548
|
+
use:portal
|
|
375
549
|
>
|
|
376
|
-
<
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
onclick={
|
|
550
|
+
<div
|
|
551
|
+
bind:this={overlayEl}
|
|
552
|
+
class="image-lightbox-overlay"
|
|
553
|
+
class:active={open}
|
|
554
|
+
aria-hidden="true"
|
|
555
|
+
onclick={closeLightbox}
|
|
556
|
+
role="presentation"
|
|
557
|
+
></div>
|
|
558
|
+
|
|
559
|
+
{#if isGallery}
|
|
560
|
+
<div class="image-lightbox-sr" aria-live="polite">{`Image ${index + 1} of ${items.length}`}</div>
|
|
561
|
+
{/if}
|
|
562
|
+
|
|
563
|
+
<div
|
|
564
|
+
bind:this={stageEl}
|
|
565
|
+
class="image-lightbox-stage"
|
|
566
|
+
role="presentation"
|
|
567
|
+
tabindex="-1"
|
|
568
|
+
onclick={onStageClick}
|
|
569
|
+
onpointerdown={onPointerDown}
|
|
570
|
+
onpointermove={onPointerMove}
|
|
571
|
+
onpointerup={onPointerUp}
|
|
572
|
+
onpointercancel={onPointerUp}
|
|
573
|
+
onwheel={onWheel}
|
|
382
574
|
>
|
|
383
|
-
<
|
|
384
|
-
<
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
575
|
+
<div class="image-lightbox-clip">
|
|
576
|
+
<div bind:this={transformEl} class="image-lightbox-transform">
|
|
577
|
+
{#if outgoing}
|
|
578
|
+
<img bind:this={outgoingEl} class="image-lightbox-layer image-lightbox-layer-exit" src={outgoing.src} alt="" draggable="false" />
|
|
579
|
+
{/if}
|
|
580
|
+
<img bind:this={incomingEl} class="image-lightbox-layer" src={current?.src} alt={current?.alt} draggable="false" onload={onImgLoad} />
|
|
581
|
+
</div>
|
|
582
|
+
</div>
|
|
583
|
+
</div>
|
|
584
|
+
|
|
585
|
+
{#if extended || isGallery}
|
|
586
|
+
<button
|
|
587
|
+
bind:this={closeBtnEl}
|
|
588
|
+
class="image-lightbox-chrome image-lightbox-close"
|
|
589
|
+
class:active={open}
|
|
590
|
+
type="button"
|
|
591
|
+
aria-label="Close"
|
|
592
|
+
onclick={closeLightbox}
|
|
593
|
+
>
|
|
594
|
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.25" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
|
595
|
+
<path d="M18 6L6 18" />
|
|
596
|
+
<path d="M6 6l12 12" />
|
|
597
|
+
</svg>
|
|
598
|
+
</button>
|
|
599
|
+
{/if}
|
|
600
|
+
|
|
601
|
+
{#if isGallery}
|
|
602
|
+
<button
|
|
603
|
+
bind:this={prevBtnEl}
|
|
604
|
+
class="image-lightbox-chrome image-lightbox-nav image-lightbox-nav-prev"
|
|
605
|
+
class:active={open}
|
|
606
|
+
type="button"
|
|
607
|
+
aria-label="Previous image"
|
|
608
|
+
onclick={prev}
|
|
609
|
+
>
|
|
610
|
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.25" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
|
611
|
+
<path d="M15 18l-6-6 6-6" />
|
|
612
|
+
</svg>
|
|
613
|
+
</button>
|
|
614
|
+
<button
|
|
615
|
+
bind:this={nextBtnEl}
|
|
616
|
+
class="image-lightbox-chrome image-lightbox-nav image-lightbox-nav-next"
|
|
617
|
+
class:active={open}
|
|
618
|
+
type="button"
|
|
619
|
+
aria-label="Next image"
|
|
620
|
+
onclick={next}
|
|
621
|
+
>
|
|
622
|
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.25" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
|
623
|
+
<path d="M9 6l6 6-6 6" />
|
|
624
|
+
</svg>
|
|
625
|
+
</button>
|
|
626
|
+
<div bind:this={counterEl} class="image-lightbox-counter" aria-hidden="true">
|
|
627
|
+
{index + 1} / {items.length}
|
|
628
|
+
</div>
|
|
629
|
+
{/if}
|
|
630
|
+
|
|
631
|
+
{#if extended}
|
|
632
|
+
<div bind:this={toolbarEl} class="image-lightbox-toolbar" class:active={open}>
|
|
633
|
+
<button
|
|
634
|
+
class="image-lightbox-chrome-button"
|
|
635
|
+
type="button"
|
|
636
|
+
aria-label="Zoom out"
|
|
637
|
+
disabled={scale <= MIN_SCALE + 0.001}
|
|
638
|
+
onclick={() => zoomTo(scale / ZOOM_STEP)}
|
|
639
|
+
>
|
|
640
|
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.25" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
|
641
|
+
<path d="M5 12h14" />
|
|
642
|
+
</svg>
|
|
643
|
+
</button>
|
|
644
|
+
<span class="image-lightbox-toolbar-label">{percentLabel}</span>
|
|
645
|
+
<button
|
|
646
|
+
class="image-lightbox-chrome-button"
|
|
647
|
+
type="button"
|
|
648
|
+
aria-label="Zoom in"
|
|
649
|
+
disabled={scale >= MAX_SCALE - 0.001}
|
|
650
|
+
onclick={() => zoomTo(scale * ZOOM_STEP)}
|
|
651
|
+
>
|
|
652
|
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.25" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
|
653
|
+
<path d="M12 5v14" />
|
|
654
|
+
<path d="M5 12h14" />
|
|
655
|
+
</svg>
|
|
656
|
+
</button>
|
|
657
|
+
</div>
|
|
658
|
+
{/if}
|
|
400
659
|
</div>
|
|
401
660
|
{/if}
|
|
402
661
|
|
|
403
662
|
<style>
|
|
404
663
|
:global(:root) {
|
|
405
|
-
/*
|
|
664
|
+
/* thumbnail (closed inline) + animated modal stage */
|
|
406
665
|
--imagelightbox-tile-radius: var(--radius-2xl);
|
|
407
666
|
--imagelightbox-tile-border: var(--color-transparent);
|
|
408
667
|
--imagelightbox-tile-border-width: var(--border-width-0);
|
|
409
668
|
--imagelightbox-tile-shadow: var(--shadow-md);
|
|
669
|
+
--imagelightbox-tile-object-fit: contain;
|
|
410
670
|
|
|
411
671
|
/* overlay */
|
|
412
672
|
--imagelightbox-overlay-surface: color-mix(in srgb, var(--color-neutral-950) 76%, transparent);
|
|
@@ -425,31 +685,63 @@
|
|
|
425
685
|
width: 100%;
|
|
426
686
|
}
|
|
427
687
|
|
|
428
|
-
.image-lightbox-
|
|
688
|
+
.image-lightbox-thumb {
|
|
429
689
|
position: absolute;
|
|
430
690
|
inset: 0;
|
|
691
|
+
padding: 0;
|
|
431
692
|
cursor: zoom-in;
|
|
432
693
|
border: var(--imagelightbox-tile-border-width) solid var(--imagelightbox-tile-border);
|
|
433
694
|
border-radius: var(--imagelightbox-tile-radius);
|
|
434
695
|
box-shadow: var(--imagelightbox-tile-shadow);
|
|
435
696
|
background: transparent;
|
|
436
|
-
overflow:
|
|
697
|
+
overflow: hidden;
|
|
437
698
|
transition: transform 250ms ease;
|
|
438
699
|
}
|
|
439
700
|
|
|
440
|
-
.image-lightbox-
|
|
701
|
+
.image-lightbox-thumb:hover {
|
|
441
702
|
transform: scale(1.02);
|
|
442
703
|
}
|
|
443
704
|
|
|
444
|
-
.image-lightbox-
|
|
445
|
-
cursor: zoom-out;
|
|
446
|
-
}
|
|
447
|
-
|
|
448
|
-
.image-lightbox-tile:focus-visible {
|
|
705
|
+
.image-lightbox-thumb:focus-visible {
|
|
449
706
|
outline: 2px solid var(--border-brand-medium);
|
|
450
707
|
outline-offset: 2px;
|
|
451
708
|
}
|
|
452
709
|
|
|
710
|
+
.image-lightbox-thumb img {
|
|
711
|
+
width: 100%;
|
|
712
|
+
height: 100%;
|
|
713
|
+
object-fit: var(--imagelightbox-tile-object-fit);
|
|
714
|
+
object-position: center;
|
|
715
|
+
user-select: none;
|
|
716
|
+
display: block;
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
.image-lightbox-modal {
|
|
720
|
+
display: contents;
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
.image-lightbox-sr {
|
|
724
|
+
position: fixed;
|
|
725
|
+
width: 1px;
|
|
726
|
+
height: 1px;
|
|
727
|
+
padding: 0;
|
|
728
|
+
margin: -1px;
|
|
729
|
+
overflow: hidden;
|
|
730
|
+
clip: rect(0 0 0 0);
|
|
731
|
+
white-space: nowrap;
|
|
732
|
+
border: 0;
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
.image-lightbox-stage {
|
|
736
|
+
position: fixed;
|
|
737
|
+
z-index: var(--z-modal);
|
|
738
|
+
cursor: zoom-out;
|
|
739
|
+
border: var(--imagelightbox-tile-border-width) solid var(--imagelightbox-tile-border);
|
|
740
|
+
border-radius: var(--imagelightbox-tile-radius);
|
|
741
|
+
box-shadow: var(--imagelightbox-tile-shadow);
|
|
742
|
+
background: transparent;
|
|
743
|
+
}
|
|
744
|
+
|
|
453
745
|
.image-lightbox-clip {
|
|
454
746
|
position: absolute;
|
|
455
747
|
inset: 0;
|
|
@@ -464,14 +756,26 @@
|
|
|
464
756
|
transform-origin: center center;
|
|
465
757
|
}
|
|
466
758
|
|
|
467
|
-
.image-lightbox-
|
|
759
|
+
.image-lightbox-layer {
|
|
760
|
+
position: absolute;
|
|
761
|
+
inset: 0;
|
|
468
762
|
width: 100%;
|
|
469
763
|
height: 100%;
|
|
470
764
|
object-fit: contain;
|
|
471
765
|
object-position: center;
|
|
766
|
+
transform-origin: center center;
|
|
472
767
|
user-select: none;
|
|
473
768
|
pointer-events: none;
|
|
474
769
|
display: block;
|
|
770
|
+
/* Incoming layer sits above the exiting one so the new image fades in over
|
|
771
|
+
the old; own compositor layer keeps the crossfade from flickering. */
|
|
772
|
+
z-index: 1;
|
|
773
|
+
will-change: transform, opacity;
|
|
774
|
+
backface-visibility: hidden;
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
.image-lightbox-layer-exit {
|
|
778
|
+
z-index: 0;
|
|
475
779
|
}
|
|
476
780
|
|
|
477
781
|
.image-lightbox-overlay {
|
|
@@ -509,6 +813,7 @@
|
|
|
509
813
|
.image-lightbox-close {
|
|
510
814
|
top: var(--space-24);
|
|
511
815
|
right: var(--space-24);
|
|
816
|
+
/* 2.75rem = 44px, the min touch-target floor for primary nav. */
|
|
512
817
|
width: 2.75rem;
|
|
513
818
|
height: 2.75rem;
|
|
514
819
|
display: flex;
|
|
@@ -521,6 +826,48 @@
|
|
|
521
826
|
background: var(--imagelightbox-chrome-hover-surface);
|
|
522
827
|
}
|
|
523
828
|
|
|
829
|
+
.image-lightbox-nav {
|
|
830
|
+
top: 50%;
|
|
831
|
+
transform: translateY(-50%);
|
|
832
|
+
/* 2.75rem = 44px, the min touch-target floor for primary nav. */
|
|
833
|
+
width: 2.75rem;
|
|
834
|
+
height: 2.75rem;
|
|
835
|
+
display: flex;
|
|
836
|
+
align-items: center;
|
|
837
|
+
justify-content: center;
|
|
838
|
+
cursor: pointer;
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
.image-lightbox-nav-prev {
|
|
842
|
+
left: var(--space-24);
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
.image-lightbox-nav-next {
|
|
846
|
+
right: var(--space-24);
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
.image-lightbox-nav:hover {
|
|
850
|
+
background: var(--imagelightbox-chrome-hover-surface);
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
.image-lightbox-counter {
|
|
854
|
+
position: fixed;
|
|
855
|
+
right: var(--space-24);
|
|
856
|
+
bottom: var(--space-24);
|
|
857
|
+
z-index: var(--z-modal);
|
|
858
|
+
padding: var(--space-8) var(--space-16);
|
|
859
|
+
background: var(--imagelightbox-chrome-surface);
|
|
860
|
+
border: var(--imagelightbox-chrome-border-width) solid var(--imagelightbox-chrome-border);
|
|
861
|
+
border-radius: var(--imagelightbox-chrome-radius);
|
|
862
|
+
color: var(--imagelightbox-chrome-icon);
|
|
863
|
+
backdrop-filter: blur(var(--blur-md));
|
|
864
|
+
font-family: var(--font-mono);
|
|
865
|
+
font-size: var(--font-size-xs);
|
|
866
|
+
letter-spacing: var(--letter-spacing-wider);
|
|
867
|
+
opacity: 0;
|
|
868
|
+
pointer-events: none;
|
|
869
|
+
}
|
|
870
|
+
|
|
524
871
|
.image-lightbox-toolbar {
|
|
525
872
|
position: fixed;
|
|
526
873
|
left: 50%;
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
// Move a `position: fixed` overlay to <body> so it escapes any transformed /
|
|
2
|
+
// `isolation` / `contain` / scrolling ancestor that would otherwise clip it or
|
|
3
|
+
// trap its stacking order (the editor's `.content` pane is one such ancestor;
|
|
4
|
+
// consumer pages have countless others). Every shipped component that renders a
|
|
5
|
+
// fixed overlay uses this — see check-overlay-portal.
|
|
6
|
+
//
|
|
7
|
+
// `enabled` is read once, at mount: pass `false` to leave the node in flow
|
|
8
|
+
// (Dialog's `inline` preview). It is intentionally not reactive — every consumer
|
|
9
|
+
// sets it statically, so there is no move-it-back path to get wrong.
|
|
10
|
+
|
|
11
|
+
export function portal(node: HTMLElement, enabled = true) {
|
|
12
|
+
if (enabled) document.body.appendChild(node);
|
|
13
|
+
return {
|
|
14
|
+
destroy() {
|
|
15
|
+
if (enabled) node.remove();
|
|
16
|
+
},
|
|
17
|
+
};
|
|
18
|
+
}
|
|
@@ -415,15 +415,6 @@
|
|
|
415
415
|
--border-width-20: 20px;
|
|
416
416
|
--border-width-24: 24px;
|
|
417
417
|
|
|
418
|
-
/* Icon sizing (square) */
|
|
419
|
-
--size-icon-sm: 1rem; /* 16px */
|
|
420
|
-
--size-icon-md: 1.25rem; /* 20px */
|
|
421
|
-
--size-icon-lg: 1.5rem; /* 24px */
|
|
422
|
-
--size-icon-xl: 2rem; /* 32px */
|
|
423
|
-
--size-icon-2xl: 4rem; /* 64px */
|
|
424
|
-
--size-icon-3xl: 6rem; /* 96px */
|
|
425
|
-
--size-icon-4xl: 8rem; /* 128px */
|
|
426
|
-
|
|
427
418
|
/* Spacing */
|
|
428
419
|
--space-0: 0;
|
|
429
420
|
--space-2: 0.125rem; /* 2px */
|