@leavepulse/ui 0.32.0 → 0.33.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/chunks/{LpAddressInput-C35TsJXt.js → LpAddressInput-FZXfljWV.js} +1 -1
- package/dist/chunks/{LpConfirmDialog-CjOGcj1e.js → LpConfirmDialog-CQBVaLpo.js} +1 -1
- package/dist/chunks/{LpDisclosure-y7wDBgLa.js → LpDisclosure-ChQYmjlR.js} +1 -1
- package/dist/chunks/{LpLightbox-Cixfxezw.js → LpLightbox-oLqyjOln.js} +1 -1
- package/dist/chunks/{LpMediaGallery-t1z3bQcY.js → LpMediaGallery-Cid7PGYx.js} +41 -14
- package/dist/chunks/{LpModal-D_o-56To.js → LpModal-DAqXzsm-.js} +20 -11
- package/dist/components/LpAddressInput.vue.js +1 -1
- package/dist/components/LpConfirmDialog.vue.js +1 -1
- package/dist/components/LpDisclosure.vue.js +1 -1
- package/dist/components/LpLightbox.vue.js +1 -1
- package/dist/components/LpMediaGallery.vue.d.ts +4 -0
- package/dist/components/LpMediaGallery.vue.js +1 -1
- package/dist/components/LpModal.vue.js +1 -1
- package/dist/components/LpShortcutsDialog.vue.js +1 -1
- package/dist/components/lightbox.d.ts +10 -0
- package/dist/components/modalDepth.d.ts +13 -0
- package/dist/components/modalDepth.js +29 -0
- package/dist/composables/useShift.js +9 -1
- package/dist/index.js +6 -6
- package/package.json +1 -1
- package/src/components/LpDisclosure.vue +16 -1
- package/src/components/LpLightbox.vue +14 -1
- package/src/components/LpMediaGallery.vue +97 -12
- package/src/components/LpModal.vue +38 -3
- package/src/components/lightbox.ts +10 -0
- package/src/components/modalDepth.ts +72 -0
- package/src/composables/useShift.ts +34 -3
|
@@ -6,7 +6,7 @@ import { _ as _sfc_main$3 } from "./LpButton-5VXXCjwF.js";
|
|
|
6
6
|
import { _ as _sfc_main$1 } from "./LpFormField-CazBc2-a.js";
|
|
7
7
|
import { _ as _sfc_main$4 } from "./LpIcon-CCnX5_2j.js";
|
|
8
8
|
import { _ as _sfc_main$5 } from "./LpInput-9EXWUM9B.js";
|
|
9
|
-
import { _ as _sfc_main$7 } from "./LpModal-
|
|
9
|
+
import { _ as _sfc_main$7 } from "./LpModal-DAqXzsm-.js";
|
|
10
10
|
import { _ as _sfc_main$6 } from "./LpSelect-ANIVtrpM.js";
|
|
11
11
|
import { useMergedAttrs } from "../composables/useMergedClass.js";
|
|
12
12
|
const _hoisted_1 = { class: "flex items-stretch gap-2" };
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { defineComponent, computed, openBlock, createBlock, createSlots, withCtx, renderSlot, createVNode, createTextVNode, toDisplayString, createElementVNode, normalizeClass } from "vue";
|
|
2
2
|
import { _ as _sfc_main$2 } from "./LpButton-5VXXCjwF.js";
|
|
3
3
|
import { _ as _sfc_main$3 } from "./LpIcon-CCnX5_2j.js";
|
|
4
|
-
import { _ as _sfc_main$1 } from "./LpModal-
|
|
4
|
+
import { _ as _sfc_main$1 } from "./LpModal-DAqXzsm-.js";
|
|
5
5
|
const _hoisted_1 = { class: "inline-flex items-center gap-2.5" };
|
|
6
6
|
const _sfc_main = /* @__PURE__ */ defineComponent({
|
|
7
7
|
__name: "LpConfirmDialog",
|
|
@@ -36,7 +36,7 @@ const _sfc_main = /* @__PURE__ */ defineComponent({
|
|
|
36
36
|
}
|
|
37
37
|
}
|
|
38
38
|
const { class: rootClass, attrs: rest } = useMergedAttrs(
|
|
39
|
-
"overflow-hidden rounded-control border border-line bg-surface-soft"
|
|
39
|
+
"shrink-0 overflow-hidden rounded-control border border-line bg-surface-soft"
|
|
40
40
|
);
|
|
41
41
|
return (_ctx, _cache) => {
|
|
42
42
|
return openBlock(), createBlock(unref(CollapsibleRoot), mergeProps({
|
|
@@ -37,7 +37,7 @@ const _hoisted_13 = ["data-index", "aria-label", "onClick"];
|
|
|
37
37
|
const _hoisted_14 = ["src", "alt"];
|
|
38
38
|
const WHEEL_STEP = 60;
|
|
39
39
|
const BTN = "flex size-9 items-center justify-center rounded-control text-white/80 outline-none transition-colors duration-[var(--duration-fast)] hover:bg-white/15 hover:text-white focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-40";
|
|
40
|
-
const ARROW = "absolute top-1/2 z-10 flex size-11 -translate-y-1/2 items-center justify-center rounded-pill bg-black/45 text-white/
|
|
40
|
+
const ARROW = "absolute top-1/2 z-10 flex size-11 -translate-y-1/2 items-center justify-center rounded-pill bg-black/45 text-white/75 ring-1 ring-white/0 outline-none backdrop-blur-sm transition-[background-color,scale,color,box-shadow] duration-[var(--duration-fast)] ease-[var(--ease-emphasized)] hover:bg-white/20 hover:text-white hover:ring-white/30 hover:scale-110 active:scale-95 focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-0 motion-reduce:transition-none motion-reduce:hover:scale-100 motion-reduce:active:scale-100";
|
|
41
41
|
const _sfc_main = /* @__PURE__ */ defineComponent({
|
|
42
42
|
__name: "LpLightbox",
|
|
43
43
|
props: {
|
|
@@ -1,17 +1,21 @@
|
|
|
1
1
|
import { defineComponent, ref, computed, openBlock, createElementBlock, renderSlot, mergeProps, unref, Fragment, renderList, normalizeClass, createElementVNode, toDisplayString, createVNode, createCommentVNode, createBlock } from "vue";
|
|
2
2
|
import { useMergedAttrs } from "../composables/useMergedClass.js";
|
|
3
3
|
import { _ as _sfc_main$1 } from "./LpIcon-CCnX5_2j.js";
|
|
4
|
-
import { _ as _sfc_main$2 } from "./LpLightbox-
|
|
4
|
+
import { _ as _sfc_main$2 } from "./LpLightbox-oLqyjOln.js";
|
|
5
5
|
const _hoisted_1 = { key: 0 };
|
|
6
6
|
const _hoisted_2 = ["aria-label"];
|
|
7
7
|
const _hoisted_3 = ["aria-label", "onClick"];
|
|
8
8
|
const _hoisted_4 = ["src", "alt"];
|
|
9
9
|
const _hoisted_5 = {
|
|
10
10
|
key: 0,
|
|
11
|
-
class: "pointer-events-none absolute inset-0 flex items-center justify-center
|
|
11
|
+
class: "pointer-events-none absolute inset-0 flex items-center justify-center"
|
|
12
12
|
};
|
|
13
13
|
const _hoisted_6 = {
|
|
14
14
|
key: 1,
|
|
15
|
+
class: "pointer-events-none absolute inset-0 flex items-center justify-center text-lg font-medium text-white"
|
|
16
|
+
};
|
|
17
|
+
const _hoisted_7 = {
|
|
18
|
+
key: 2,
|
|
15
19
|
class: "pointer-events-none absolute inset-0 flex items-center justify-center bg-black/0 opacity-0 transition-[background-color,opacity] duration-[var(--duration-fast)] group-hover:bg-black/25 group-hover:opacity-100 motion-reduce:transition-none",
|
|
16
20
|
"aria-hidden": "true"
|
|
17
21
|
};
|
|
@@ -28,7 +32,7 @@ const _sfc_main = /* @__PURE__ */ defineComponent({
|
|
|
28
32
|
rotatable: { type: Boolean, default: true },
|
|
29
33
|
ariaLabel: { default: "Media" }
|
|
30
34
|
},
|
|
31
|
-
emits: ["select", "update:open"],
|
|
35
|
+
emits: ["select", "reveal", "update:open"],
|
|
32
36
|
setup(__props, { expose: __expose, emit: __emit }) {
|
|
33
37
|
const props = __props;
|
|
34
38
|
const emit = __emit;
|
|
@@ -111,16 +115,37 @@ const _sfc_main = /* @__PURE__ */ defineComponent({
|
|
|
111
115
|
if (!item) return;
|
|
112
116
|
emit("select", item, i);
|
|
113
117
|
if (!props.openable) return;
|
|
114
|
-
|
|
118
|
+
const at = openIndexOf(i);
|
|
119
|
+
if (at === -1) return;
|
|
120
|
+
index.value = at;
|
|
115
121
|
open.value = true;
|
|
116
122
|
emit("update:open", true);
|
|
117
123
|
}
|
|
118
124
|
function openOverflow() {
|
|
119
125
|
activate(visible.value.length);
|
|
120
126
|
}
|
|
127
|
+
const revealed = ref(/* @__PURE__ */ new Set());
|
|
128
|
+
function isHidden(item) {
|
|
129
|
+
return Boolean(item.spoiler) && !revealed.value.has(item.src);
|
|
130
|
+
}
|
|
131
|
+
function reveal(item) {
|
|
132
|
+
revealed.value = new Set(revealed.value).add(item.src);
|
|
133
|
+
emit("reveal", item);
|
|
134
|
+
}
|
|
135
|
+
const openItems = computed(() => props.items.filter((item) => !isHidden(item)));
|
|
136
|
+
function openIndexOf(i) {
|
|
137
|
+
const item = props.items[i];
|
|
138
|
+
return item ? openItems.value.indexOf(item) : -1;
|
|
139
|
+
}
|
|
121
140
|
__expose({
|
|
122
141
|
/** Open the lightbox at an index — for a "view all" button outside the grid. */
|
|
123
|
-
openAt: (i) => activate(i)
|
|
142
|
+
openAt: (i) => activate(i),
|
|
143
|
+
/** Uncover every spoiler in the set. */
|
|
144
|
+
revealAll: () => {
|
|
145
|
+
const next = new Set(revealed.value);
|
|
146
|
+
for (const item of props.items) if (item.spoiler) next.add(item.src);
|
|
147
|
+
revealed.value = next;
|
|
148
|
+
}
|
|
124
149
|
});
|
|
125
150
|
return (_ctx, _cache) => {
|
|
126
151
|
return !__props.items.length ? (openBlock(), createElementBlock("div", _hoisted_1, [
|
|
@@ -141,8 +166,8 @@ const _sfc_main = /* @__PURE__ */ defineComponent({
|
|
|
141
166
|
__props.openable ? "cursor-pointer" : "cursor-default",
|
|
142
167
|
"focus-visible:ring-2 focus-visible:ring-ring"
|
|
143
168
|
]),
|
|
144
|
-
"aria-label": hiddenCount.value && i === visible.value.length - 1 ? `Show ${hiddenCount.value} more` : item.title || `Image ${i + 1} of ${__props.items.length}`,
|
|
145
|
-
onClick: ($event) => hiddenCount.value && i === visible.value.length - 1 ? openOverflow() : activate(i)
|
|
169
|
+
"aria-label": isHidden(item) ? "Spoiler — show image" : hiddenCount.value && i === visible.value.length - 1 ? `Show ${hiddenCount.value} more` : item.title || `Image ${i + 1} of ${__props.items.length}`,
|
|
170
|
+
onClick: ($event) => isHidden(item) ? reveal(item) : hiddenCount.value && i === visible.value.length - 1 ? openOverflow() : activate(i)
|
|
146
171
|
}, [
|
|
147
172
|
renderSlot(_ctx.$slots, "item", {
|
|
148
173
|
item,
|
|
@@ -150,16 +175,18 @@ const _sfc_main = /* @__PURE__ */ defineComponent({
|
|
|
150
175
|
}, () => [
|
|
151
176
|
createElementVNode("img", {
|
|
152
177
|
src: item.thumb || item.src,
|
|
153
|
-
alt: item.title || "",
|
|
178
|
+
alt: isHidden(item) ? "" : item.title || "",
|
|
154
179
|
loading: "lazy",
|
|
155
180
|
decoding: "async",
|
|
156
|
-
class: normalizeClass([
|
|
157
|
-
"
|
|
158
|
-
hiddenCount.value && i === visible.value.length - 1 ? "scale-[1.06] blur-[2px] brightness-[0.45] group-hover:scale-[1.06]" : ""
|
|
159
|
-
])
|
|
181
|
+
class: normalizeClass(["size-full object-cover transition-transform duration-[var(--duration-medium)] ease-[var(--ease-emphasized)] group-hover:scale-[1.03] motion-reduce:transition-none motion-reduce:group-hover:scale-100", [
|
|
182
|
+
isHidden(item) ? "scale-[1.12] blur-[18px] brightness-[0.6] saturate-[1.1] group-hover:scale-[1.12]" : "",
|
|
183
|
+
!isHidden(item) && hiddenCount.value && i === visible.value.length - 1 ? "scale-[1.06] blur-[2px] brightness-[0.45] group-hover:scale-[1.06]" : ""
|
|
184
|
+
]])
|
|
160
185
|
}, null, 10, _hoisted_4)
|
|
161
186
|
]),
|
|
162
|
-
|
|
187
|
+
isHidden(item) ? (openBlock(), createElementBlock("span", _hoisted_5, [..._cache[2] || (_cache[2] = [
|
|
188
|
+
createElementVNode("span", { class: "rounded-pill bg-black/55 px-3 py-1 text-xs font-semibold uppercase tracking-wide text-white" }, " Spoiler ", -1)
|
|
189
|
+
])])) : hiddenCount.value && i === visible.value.length - 1 ? (openBlock(), createElementBlock("span", _hoisted_6, " +" + toDisplayString(hiddenCount.value), 1)) : __props.openable ? (openBlock(), createElementBlock("span", _hoisted_7, [
|
|
163
190
|
createVNode(_sfc_main$1, {
|
|
164
191
|
name: "lucide:maximize-2",
|
|
165
192
|
size: 18,
|
|
@@ -174,7 +201,7 @@ const _sfc_main = /* @__PURE__ */ defineComponent({
|
|
|
174
201
|
"onUpdate:open": _cache[0] || (_cache[0] = ($event) => open.value = $event),
|
|
175
202
|
index: index.value,
|
|
176
203
|
"onUpdate:index": _cache[1] || (_cache[1] = ($event) => index.value = $event),
|
|
177
|
-
items:
|
|
204
|
+
items: openItems.value,
|
|
178
205
|
downloadable: __props.downloadable,
|
|
179
206
|
rotatable: __props.rotatable
|
|
180
207
|
}, null, 8, ["open", "index", "items", "downloadable", "rotatable"])) : createCommentVNode("", true)
|
|
@@ -1,16 +1,16 @@
|
|
|
1
|
-
import { defineComponent, computed, useSlots,
|
|
1
|
+
import { defineComponent, computed, useSlots, watch, onBeforeUnmount, ref, openBlock, createBlock, unref, withCtx, createVNode, normalizeStyle, createElementVNode, mergeProps, createElementBlock, renderSlot, createTextVNode, toDisplayString, createCommentVNode, normalizeClass } from "vue";
|
|
2
2
|
import { DialogRoot, DialogPortal, DialogOverlay, DialogContent, DialogTitle, DialogDescription, DialogClose } from "reka-ui";
|
|
3
3
|
import { useShift } from "../composables/useShift.js";
|
|
4
|
+
import { useModalLayer } from "../components/modalDepth.js";
|
|
4
5
|
import { CLOSE_ICON } from "../components/dropdown.js";
|
|
5
6
|
import { _ as _sfc_main$1 } from "./LpIcon-CCnX5_2j.js";
|
|
6
7
|
import { _ as _sfc_main$2 } from "./LpScrollArea-D8xyKXcF.js";
|
|
7
|
-
const _hoisted_1 = {
|
|
8
|
-
const _hoisted_2 = {
|
|
8
|
+
const _hoisted_1 = {
|
|
9
9
|
key: 0,
|
|
10
10
|
class: "flex shrink-0 items-start justify-between gap-4 p-5 pb-3"
|
|
11
11
|
};
|
|
12
|
-
const
|
|
13
|
-
const
|
|
12
|
+
const _hoisted_2 = { class: "flex flex-col gap-1" };
|
|
13
|
+
const _hoisted_3 = {
|
|
14
14
|
key: 3,
|
|
15
15
|
class: "flex shrink-0 justify-end gap-2 p-5 pt-4"
|
|
16
16
|
};
|
|
@@ -27,6 +27,7 @@ const _sfc_main = /* @__PURE__ */ defineComponent({
|
|
|
27
27
|
},
|
|
28
28
|
emits: ["update:open"],
|
|
29
29
|
setup(__props) {
|
|
30
|
+
const { claim, release, layer } = useModalLayer();
|
|
30
31
|
const props = __props;
|
|
31
32
|
const describedByAttrs = computed(
|
|
32
33
|
() => props.description ? {} : { "aria-describedby": void 0 }
|
|
@@ -44,6 +45,8 @@ const _sfc_main = /* @__PURE__ */ defineComponent({
|
|
|
44
45
|
}[props.size];
|
|
45
46
|
});
|
|
46
47
|
const slots = useSlots();
|
|
48
|
+
watch(() => props.open, (open) => open ? claim() : release(), { immediate: true });
|
|
49
|
+
onBeforeUnmount(release);
|
|
47
50
|
const bodyPad = computed(
|
|
48
51
|
() => [
|
|
49
52
|
"px-5",
|
|
@@ -103,8 +106,14 @@ const _sfc_main = /* @__PURE__ */ defineComponent({
|
|
|
103
106
|
default: withCtx(() => [
|
|
104
107
|
createVNode(unref(DialogPortal), null, {
|
|
105
108
|
default: withCtx(() => [
|
|
106
|
-
createVNode(unref(DialogOverlay), {
|
|
107
|
-
|
|
109
|
+
createVNode(unref(DialogOverlay), {
|
|
110
|
+
class: "lp-scrim fixed inset-0 data-[state=open]:animate-[fade-in_var(--duration-medium)_var(--ease-emphasized)] data-[state=closed]:animate-[fade-out_120ms_ease]",
|
|
111
|
+
style: normalizeStyle({ zIndex: unref(layer).scrim })
|
|
112
|
+
}, null, 8, ["style"]),
|
|
113
|
+
createElementVNode("div", {
|
|
114
|
+
class: "fixed inset-0 flex items-center justify-center pointer-events-none",
|
|
115
|
+
style: normalizeStyle({ zIndex: unref(layer).panel })
|
|
116
|
+
}, [
|
|
108
117
|
createVNode(unref(DialogContent), mergeProps({
|
|
109
118
|
ref_key: "panelRef",
|
|
110
119
|
ref: panelRef,
|
|
@@ -112,8 +121,8 @@ const _sfc_main = /* @__PURE__ */ defineComponent({
|
|
|
112
121
|
style: __props.width ? { width: __props.width } : void 0
|
|
113
122
|
}, describedByAttrs.value, { onOpenAutoFocus }), {
|
|
114
123
|
default: withCtx(() => [
|
|
115
|
-
__props.title || _ctx.$slots.title ? (openBlock(), createElementBlock("header",
|
|
116
|
-
createElementVNode("div",
|
|
124
|
+
__props.title || _ctx.$slots.title ? (openBlock(), createElementBlock("header", _hoisted_1, [
|
|
125
|
+
createElementVNode("div", _hoisted_2, [
|
|
117
126
|
createVNode(unref(DialogTitle), { class: "text-base font-semibold text-ink" }, {
|
|
118
127
|
default: withCtx(() => [
|
|
119
128
|
renderSlot(_ctx.$slots, "title", {}, () => [
|
|
@@ -162,13 +171,13 @@ const _sfc_main = /* @__PURE__ */ defineComponent({
|
|
|
162
171
|
]),
|
|
163
172
|
_: 3
|
|
164
173
|
}, 8, ["class", "content-class"])),
|
|
165
|
-
_ctx.$slots.footer ? (openBlock(), createElementBlock("footer",
|
|
174
|
+
_ctx.$slots.footer ? (openBlock(), createElementBlock("footer", _hoisted_3, [
|
|
166
175
|
renderSlot(_ctx.$slots, "footer")
|
|
167
176
|
])) : createCommentVNode("", true)
|
|
168
177
|
]),
|
|
169
178
|
_: 3
|
|
170
179
|
}, 16, ["class", "style"])
|
|
171
|
-
])
|
|
180
|
+
], 4)
|
|
172
181
|
]),
|
|
173
182
|
_: 3
|
|
174
183
|
})
|
|
@@ -45,12 +45,16 @@ type __VLS_Slots = {
|
|
|
45
45
|
declare const __VLS_base: import('vue').DefineComponent<__VLS_Props, {
|
|
46
46
|
/** Open the lightbox at an index — for a "view all" button outside the grid. */
|
|
47
47
|
openAt: (i: number) => void;
|
|
48
|
+
/** Uncover every spoiler in the set. */
|
|
49
|
+
revealAll: () => void;
|
|
48
50
|
}, {}, {}, {}, import('vue').ComponentOptionsMixin, import('vue').ComponentOptionsMixin, {} & {
|
|
49
51
|
select: (item: LightboxItem, index: number) => any;
|
|
50
52
|
"update:open": (value: boolean) => any;
|
|
53
|
+
reveal: (item: LightboxItem) => any;
|
|
51
54
|
}, string, import('vue').PublicProps, Readonly<__VLS_Props> & Readonly<{
|
|
52
55
|
onSelect?: ((item: LightboxItem, index: number) => any) | undefined;
|
|
53
56
|
"onUpdate:open"?: ((value: boolean) => any) | undefined;
|
|
57
|
+
onReveal?: ((item: LightboxItem) => any) | undefined;
|
|
54
58
|
}>, {
|
|
55
59
|
columns: number | {
|
|
56
60
|
base?: number;
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { defineComponent, openBlock, createBlock, withCtx, createElementVNode, createElementBlock, Fragment, renderList, toDisplayString, unref } from "vue";
|
|
2
|
-
import { _ as _sfc_main$1 } from "../chunks/LpModal-
|
|
2
|
+
import { _ as _sfc_main$1 } from "../chunks/LpModal-DAqXzsm-.js";
|
|
3
3
|
import { appleModifierLabel } from "../composables/useHotkeys.js";
|
|
4
4
|
import { _ as _export_sfc } from "../chunks/_plugin-vue_export-helper-1tPrXgE0.js";
|
|
5
5
|
import '../assets/LpShortcutsDialog-B7H-RZLQ.css';const _hoisted_1 = { class: "shortcut-groups" };
|
|
@@ -9,6 +9,16 @@ export interface LightboxItem {
|
|
|
9
9
|
description?: string;
|
|
10
10
|
/** Filename used when downloading; derived from `src` when absent. */
|
|
11
11
|
filename?: string;
|
|
12
|
+
/**
|
|
13
|
+
* Hide this image behind a cover until someone asks for it — a spoiler, a
|
|
14
|
+
* screenshot of something not everyone in the channel has reached yet, a
|
|
15
|
+
* picture that should not appear in a scroll past.
|
|
16
|
+
*
|
|
17
|
+
* A property of the ITEM rather than of the grid showing it: which images are
|
|
18
|
+
* spoilers is a fact about the images, and the usual set is a mixed one, with
|
|
19
|
+
* a marked picture sitting beside plain ones.
|
|
20
|
+
*/
|
|
21
|
+
spoiler?: boolean;
|
|
12
22
|
}
|
|
13
23
|
/** Last path segment of a URL, or a fallback — used to name a download. */
|
|
14
24
|
export declare function fileNameOf(item: LightboxItem, fallback?: string): string;
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { ComputedRef } from 'vue';
|
|
2
|
+
/**
|
|
3
|
+
* Register a dialog as open and get back the z-indexes it should paint with.
|
|
4
|
+
* The result is reactive: opening another dialog moves this one down a step.
|
|
5
|
+
*/
|
|
6
|
+
export declare function useModalLayer(): {
|
|
7
|
+
claim: () => void;
|
|
8
|
+
release: () => void;
|
|
9
|
+
layer: ComputedRef<{
|
|
10
|
+
scrim: string;
|
|
11
|
+
panel: string;
|
|
12
|
+
}>;
|
|
13
|
+
};
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { ref, computed } from "vue";
|
|
2
|
+
const STEP = 10;
|
|
3
|
+
const stack = ref([]);
|
|
4
|
+
let nextId = 0;
|
|
5
|
+
function useModalLayer() {
|
|
6
|
+
const id = nextId++;
|
|
7
|
+
const fromTop = computed(() => {
|
|
8
|
+
const at = stack.value.indexOf(id);
|
|
9
|
+
return at === -1 ? 0 : stack.value.length - 1 - at;
|
|
10
|
+
});
|
|
11
|
+
return {
|
|
12
|
+
claim: () => {
|
|
13
|
+
if (!stack.value.includes(id)) stack.value = [...stack.value, id];
|
|
14
|
+
},
|
|
15
|
+
release: () => {
|
|
16
|
+
stack.value = stack.value.filter((x) => x !== id);
|
|
17
|
+
},
|
|
18
|
+
layer: computed(() => {
|
|
19
|
+
const drop = fromTop.value * STEP;
|
|
20
|
+
return {
|
|
21
|
+
scrim: `calc(var(--z-overlay) - ${drop})`,
|
|
22
|
+
panel: `calc(var(--z-modal) - ${drop})`
|
|
23
|
+
};
|
|
24
|
+
})
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
export {
|
|
28
|
+
useModalLayer
|
|
29
|
+
};
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { ref, watch, toValue, onBeforeUnmount } from "vue";
|
|
2
2
|
import { prefersReducedMotion } from "./easing.js";
|
|
3
3
|
const TWEEN_MS = 160;
|
|
4
|
+
const RETARGET_AFTER_MS = 40;
|
|
4
5
|
function useShift(options = {}) {
|
|
5
6
|
const el = ref(null);
|
|
6
7
|
const tweening = ref(false);
|
|
@@ -12,13 +13,19 @@ function useShift(options = {}) {
|
|
|
12
13
|
let lastHeight = 0;
|
|
13
14
|
let lastWidth = 0;
|
|
14
15
|
let inFlight = false;
|
|
16
|
+
let tweenStart = 0;
|
|
15
17
|
const axisOf = () => toValue(options.axis) ?? "height";
|
|
16
18
|
const wantsHeight = () => axisOf() !== "width";
|
|
17
19
|
const wantsWidth = () => axisOf() !== "height";
|
|
18
20
|
function retune() {
|
|
19
21
|
const node = el.value;
|
|
20
22
|
if (!node) return;
|
|
21
|
-
if (inFlight)
|
|
23
|
+
if (inFlight) {
|
|
24
|
+
if (performance.now() - tweenStart < RETARGET_AFTER_MS) return;
|
|
25
|
+
const live = node.getBoundingClientRect();
|
|
26
|
+
lastHeight = Math.round(live.height);
|
|
27
|
+
lastWidth = Math.round(live.width);
|
|
28
|
+
}
|
|
22
29
|
const fromHeight = lastHeight;
|
|
23
30
|
const fromWidth = lastWidth;
|
|
24
31
|
if (wantsHeight()) {
|
|
@@ -44,6 +51,7 @@ function useShift(options = {}) {
|
|
|
44
51
|
if (movesHeight) node.style.height = `${toHeight}px`;
|
|
45
52
|
if (movesWidth) node.style.width = `${toWidth}px`;
|
|
46
53
|
inFlight = true;
|
|
54
|
+
tweenStart = performance.now();
|
|
47
55
|
resizing.value = true;
|
|
48
56
|
clearTimeout(settleTimer);
|
|
49
57
|
const wait = prefersReducedMotion() ? 0 : toValue(options.duration) ?? TWEEN_MS;
|
package/dist/index.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { _ } from "./chunks/LayoutCanvas-Dd20boUG.js";
|
|
2
|
-
import { _ as _2 } from "./chunks/LpAddressInput-
|
|
2
|
+
import { _ as _2 } from "./chunks/LpAddressInput-FZXfljWV.js";
|
|
3
3
|
import { _ as _3 } from "./chunks/LpMapPicker-ul4oYno6.js";
|
|
4
4
|
import { OSM_TILES, createNominatimProvider, emptyAddress, formatAddress, project, tileUrl, unproject } from "./components/address.js";
|
|
5
5
|
import { _ as _4 } from "./chunks/LpActionBar-BlvtGB2P.js";
|
|
@@ -19,10 +19,10 @@ import { _ as _15 } from "./chunks/LpCodeBlock-Bo0B4TFR.js";
|
|
|
19
19
|
import { _ as _16 } from "./chunks/LpColorPicker-CFC6iaNK.js";
|
|
20
20
|
import { DEFAULT_SWATCHES, clampHsv, formatColor, hsvToHsl, hsvToRgb, isLight, normalizeHue, opaqueCss, parseColor, parseHex, rgbToHex, rgbToHsv } from "./components/color.js";
|
|
21
21
|
import { default as default3 } from "./components/LpCommandPalette.vue.js";
|
|
22
|
-
import { _ as _17 } from "./chunks/LpConfirmDialog-
|
|
22
|
+
import { _ as _17 } from "./chunks/LpConfirmDialog-CQBVaLpo.js";
|
|
23
23
|
import { _ as _18 } from "./chunks/LpContextMenu-B6WBLleS.js";
|
|
24
24
|
import { _ as _19 } from "./chunks/LpDatePicker-6JFXllj5.js";
|
|
25
|
-
import { _ as _20 } from "./chunks/LpDisclosure-
|
|
25
|
+
import { _ as _20 } from "./chunks/LpDisclosure-ChQYmjlR.js";
|
|
26
26
|
import { _ as _21 } from "./chunks/LpDivider-CUX46KR0.js";
|
|
27
27
|
import { _ as _22 } from "./chunks/LpDrawer-DAJN5rGK.js";
|
|
28
28
|
import { _ as _23 } from "./chunks/LpDropdownMenu-DJpoqRCb.js";
|
|
@@ -35,12 +35,12 @@ import { _ as _27 } from "./chunks/LpIcon-CCnX5_2j.js";
|
|
|
35
35
|
import { default as default5 } from "./components/LpImageEditor.vue.js";
|
|
36
36
|
import { _ as _28 } from "./chunks/LpInfraNode-Ds_8SZgN.js";
|
|
37
37
|
import { _ as _29 } from "./chunks/LpInput-9EXWUM9B.js";
|
|
38
|
-
import { _ as _30 } from "./chunks/LpLightbox-
|
|
38
|
+
import { _ as _30 } from "./chunks/LpLightbox-oLqyjOln.js";
|
|
39
39
|
import { fileNameOf } from "./components/lightbox.js";
|
|
40
40
|
import { _ as _31 } from "./chunks/LpLink-DCND-zyu.js";
|
|
41
41
|
import { _ as _32 } from "./chunks/LpLogViewer-y53ZqSVq.js";
|
|
42
|
-
import { _ as _33 } from "./chunks/LpMediaGallery-
|
|
43
|
-
import { _ as _34 } from "./chunks/LpModal-
|
|
42
|
+
import { _ as _33 } from "./chunks/LpMediaGallery-Cid7PGYx.js";
|
|
43
|
+
import { _ as _34 } from "./chunks/LpModal-DAqXzsm-.js";
|
|
44
44
|
import { _ as _35 } from "./chunks/LpNotificationBell-lHePf89W.js";
|
|
45
45
|
import { _ as _36 } from "./chunks/LpNumberField-CWuX08iX.js";
|
|
46
46
|
import { _ as _37 } from "./chunks/LpNumberFlow-amWYaeuA.js";
|
package/package.json
CHANGED
|
@@ -75,8 +75,23 @@ function onToggle(value: boolean) {
|
|
|
75
75
|
}
|
|
76
76
|
|
|
77
77
|
// The disclosure carries a bordered control frame; stacking several into one list means removing the per-item border from the outside.
|
|
78
|
+
/*
|
|
79
|
+
* shrink-0 is load-bearing inside a flex column.
|
|
80
|
+
*
|
|
81
|
+
* A disclosure is nearly always in one — the body of a dialog, a settings
|
|
82
|
+
* column, a form. A flex item defaults to `flex-shrink: 1`, so when that column
|
|
83
|
+
* runs out of room every child is squeezed, and this one hides the evidence
|
|
84
|
+
* with its own `overflow-hidden`: measured in a dialog whose content overflows,
|
|
85
|
+
* the root sat at 22px while the trigger inside it was 40px — a header cut in
|
|
86
|
+
* half, spilling past the panel, and snapping back and forth by 20px as the
|
|
87
|
+
* surrounding layout settled.
|
|
88
|
+
*
|
|
89
|
+
* A collapsible owns its height: closed it is exactly its header, open it is
|
|
90
|
+
* the header plus the animating body. Nothing outside gets to shorten that —
|
|
91
|
+
* the column it sits in is the thing that should scroll instead.
|
|
92
|
+
*/
|
|
78
93
|
const { class: rootClass, attrs: rest } = useMergedAttrs(
|
|
79
|
-
"overflow-hidden rounded-control border border-line bg-surface-soft",
|
|
94
|
+
"shrink-0 overflow-hidden rounded-control border border-line bg-surface-soft",
|
|
80
95
|
)
|
|
81
96
|
</script>
|
|
82
97
|
|
|
@@ -315,8 +315,21 @@ const imageStyle = computed(() => ({
|
|
|
315
315
|
|
|
316
316
|
const BTN =
|
|
317
317
|
"flex size-9 items-center justify-center rounded-control text-white/80 outline-none transition-colors duration-[var(--duration-fast)] hover:bg-white/15 hover:text-white focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-40"
|
|
318
|
+
/*
|
|
319
|
+
* The arrows sit on a photograph, which is the hard surface to answer on: the
|
|
320
|
+
* resting chrome is a dark translucent disc, and a hover that only deepens that
|
|
321
|
+
* dark is a change from "nearly black" to "slightly more nearly black" over an
|
|
322
|
+
* image that may itself be dark. Measured, the old step moved the fill from
|
|
323
|
+
* 0.45 to 0.65 alpha and the disc by 5% of 44px — two pixels. It was doing
|
|
324
|
+
* something, and it read as doing nothing.
|
|
325
|
+
*
|
|
326
|
+
* So the hover changes the QUALITY of the surface, not just its depth: the fill
|
|
327
|
+
* goes lighter rather than darker, a hairline ring appears, the icon reaches
|
|
328
|
+
* full white and the disc grows by a tenth. Pressing it dips back below resting
|
|
329
|
+
* size, which is the part that makes a button feel connected to the click.
|
|
330
|
+
*/
|
|
318
331
|
const ARROW =
|
|
319
|
-
"absolute top-1/2 z-10 flex size-11 -translate-y-1/2 items-center justify-center rounded-pill bg-black/45 text-white/
|
|
332
|
+
"absolute top-1/2 z-10 flex size-11 -translate-y-1/2 items-center justify-center rounded-pill bg-black/45 text-white/75 ring-1 ring-white/0 outline-none backdrop-blur-sm transition-[background-color,scale,color,box-shadow] duration-[var(--duration-fast)] ease-[var(--ease-emphasized)] hover:bg-white/20 hover:text-white hover:ring-white/30 hover:scale-110 active:scale-95 focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-0 motion-reduce:transition-none motion-reduce:hover:scale-100 motion-reduce:active:scale-100"
|
|
320
333
|
</script>
|
|
321
334
|
|
|
322
335
|
<template>
|
|
@@ -19,6 +19,10 @@
|
|
|
19
19
|
* straight from here into the lightbox with nothing in between. `thumb` is the
|
|
20
20
|
* small source when one exists; without it the grid shows `src`, which is
|
|
21
21
|
* correct but heavier, and that is the caller's call to make.
|
|
22
|
+
*
|
|
23
|
+
* An item marked `spoiler` arrives covered and takes a click to uncover. The
|
|
24
|
+
* cover is per image and the reveal is remembered per image, because a set is
|
|
25
|
+
* usually mixed — one marked screenshot among plain ones.
|
|
22
26
|
*/
|
|
23
27
|
import { computed, ref } from "vue"
|
|
24
28
|
import type { LightboxItem } from "./lightbox"
|
|
@@ -69,6 +73,8 @@ const props = withDefaults(
|
|
|
69
73
|
const emit = defineEmits<{
|
|
70
74
|
/** A thumbnail was activated, before the lightbox opens. */
|
|
71
75
|
(e: "select", item: LightboxItem, index: number): void
|
|
76
|
+
/** A spoiler was uncovered. */
|
|
77
|
+
(e: "reveal", item: LightboxItem): void
|
|
72
78
|
(e: "update:open", value: boolean): void
|
|
73
79
|
}>()
|
|
74
80
|
|
|
@@ -173,9 +179,15 @@ const { class: rootClass, attrs: rest } = useMergedAttrs(() =>
|
|
|
173
179
|
function activate(i: number) {
|
|
174
180
|
const item = props.items[i]
|
|
175
181
|
if (!item) return
|
|
182
|
+
// `select` reports the position in the caller's own array, which is the one
|
|
183
|
+
// they can do anything with — the lightbox's filtered index is internal.
|
|
176
184
|
emit("select", item, i)
|
|
177
185
|
if (!props.openable) return
|
|
178
|
-
index
|
|
186
|
+
// Covered images are not in the lightbox's list, so the index has to be
|
|
187
|
+
// translated into it.
|
|
188
|
+
const at = openIndexOf(i)
|
|
189
|
+
if (at === -1) return
|
|
190
|
+
index.value = at
|
|
179
191
|
open.value = true
|
|
180
192
|
emit("update:open", true)
|
|
181
193
|
}
|
|
@@ -185,9 +197,57 @@ function openOverflow() {
|
|
|
185
197
|
activate(visible.value.length)
|
|
186
198
|
}
|
|
187
199
|
|
|
200
|
+
/*
|
|
201
|
+
* Which spoilers have been opened.
|
|
202
|
+
*
|
|
203
|
+
* Keyed by `src` rather than by index, because the index is not a name: a set
|
|
204
|
+
* that gains an image at the front, or is filtered, shifts every position under
|
|
205
|
+
* the cursor, and a grid keyed by index would then show the wrong picture as
|
|
206
|
+
* already-revealed — which for a spoiler is the one failure that matters.
|
|
207
|
+
*/
|
|
208
|
+
const revealed = ref(new Set<string>())
|
|
209
|
+
|
|
210
|
+
function isHidden(item: LightboxItem): boolean {
|
|
211
|
+
return Boolean(item.spoiler) && !revealed.value.has(item.src)
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/*
|
|
215
|
+
* A covered tile takes its click to uncover itself and STOPS there — it does
|
|
216
|
+
* not also open the lightbox. Revealing and viewing are two separate requests,
|
|
217
|
+
* and running them together puts a picture nobody has agreed to see yet
|
|
218
|
+
* full-screen on the first click. The second click opens it like any other.
|
|
219
|
+
*/
|
|
220
|
+
function reveal(item: LightboxItem) {
|
|
221
|
+
// A new Set, not .add(): a Set mutated in place is the same object, so a
|
|
222
|
+
// computed reading it never sees a change.
|
|
223
|
+
revealed.value = new Set(revealed.value).add(item.src)
|
|
224
|
+
emit("reveal", item)
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/*
|
|
228
|
+
* The lightbox never receives a covered image. Paging through the set with the
|
|
229
|
+
* arrow keys would otherwise walk straight into one full-screen, having asked
|
|
230
|
+
* nobody — the grid's cover would have been for nothing. Items already revealed
|
|
231
|
+
* pass through untouched, so opening the one you just uncovered still lands on
|
|
232
|
+
* it.
|
|
233
|
+
*/
|
|
234
|
+
const openItems = computed(() => props.items.filter((item) => !isHidden(item)))
|
|
235
|
+
|
|
236
|
+
/** Index within `openItems` of the grid item at `i`, or -1 when it is covered. */
|
|
237
|
+
function openIndexOf(i: number): number {
|
|
238
|
+
const item = props.items[i]
|
|
239
|
+
return item ? openItems.value.indexOf(item) : -1
|
|
240
|
+
}
|
|
241
|
+
|
|
188
242
|
defineExpose({
|
|
189
243
|
/** Open the lightbox at an index — for a "view all" button outside the grid. */
|
|
190
244
|
openAt: (i: number) => activate(i),
|
|
245
|
+
/** Uncover every spoiler in the set. */
|
|
246
|
+
revealAll: () => {
|
|
247
|
+
const next = new Set(revealed.value)
|
|
248
|
+
for (const item of props.items) if (item.spoiler) next.add(item.src)
|
|
249
|
+
revealed.value = next
|
|
250
|
+
},
|
|
191
251
|
})
|
|
192
252
|
</script>
|
|
193
253
|
|
|
@@ -208,11 +268,19 @@ defineExpose({
|
|
|
208
268
|
'focus-visible:ring-2 focus-visible:ring-ring',
|
|
209
269
|
]"
|
|
210
270
|
:aria-label="
|
|
211
|
-
|
|
212
|
-
?
|
|
213
|
-
:
|
|
271
|
+
isHidden(item)
|
|
272
|
+
? 'Spoiler — show image'
|
|
273
|
+
: hiddenCount && i === visible.length - 1
|
|
274
|
+
? `Show ${hiddenCount} more`
|
|
275
|
+
: item.title || `Image ${i + 1} of ${items.length}`
|
|
276
|
+
"
|
|
277
|
+
@click="
|
|
278
|
+
isHidden(item)
|
|
279
|
+
? reveal(item)
|
|
280
|
+
: hiddenCount && i === visible.length - 1
|
|
281
|
+
? openOverflow()
|
|
282
|
+
: activate(i)
|
|
214
283
|
"
|
|
215
|
-
@click="hiddenCount && i === visible.length - 1 ? openOverflow() : activate(i)"
|
|
216
284
|
>
|
|
217
285
|
<slot name="item" :item="item" :index="i">
|
|
218
286
|
<!-- decoding=async + loading=lazy: a gallery is often far down a page,
|
|
@@ -227,15 +295,18 @@ defineExpose({
|
|
|
227
295
|
little pushes that feathered edge outside the clip. -->
|
|
228
296
|
<img
|
|
229
297
|
:src="item.thumb || item.src"
|
|
230
|
-
:alt="item.title || ''"
|
|
298
|
+
:alt="isHidden(item) ? '' : item.title || ''"
|
|
231
299
|
loading="lazy"
|
|
232
300
|
decoding="async"
|
|
233
301
|
class="size-full object-cover transition-transform duration-[var(--duration-medium)] ease-[var(--ease-emphasized)] group-hover:scale-[1.03] motion-reduce:transition-none motion-reduce:group-hover:scale-100"
|
|
234
|
-
:class="
|
|
235
|
-
|
|
302
|
+
:class="[
|
|
303
|
+
isHidden(item)
|
|
304
|
+
? 'scale-[1.12] blur-[18px] brightness-[0.6] saturate-[1.1] group-hover:scale-[1.12]'
|
|
305
|
+
: '',
|
|
306
|
+
!isHidden(item) && hiddenCount && i === visible.length - 1
|
|
236
307
|
? 'scale-[1.06] blur-[2px] brightness-[0.45] group-hover:scale-[1.06]'
|
|
237
|
-
: ''
|
|
238
|
-
"
|
|
308
|
+
: '',
|
|
309
|
+
]"
|
|
239
310
|
>
|
|
240
311
|
</slot>
|
|
241
312
|
|
|
@@ -252,8 +323,22 @@ defineExpose({
|
|
|
252
323
|
and obvious zoomed. Dimming the image itself means the corner clips a
|
|
253
324
|
finished pixel, which fades toward transparent instead of toward
|
|
254
325
|
something that should never be on show. -->
|
|
326
|
+
<!-- A covered tile says so in words. The blur alone is ambiguous — a
|
|
327
|
+
heavily blurred photograph reads as one that failed to load, and the
|
|
328
|
+
label is the difference between "broken" and "click to see". -->
|
|
329
|
+
<span
|
|
330
|
+
v-if="isHidden(item)"
|
|
331
|
+
class="pointer-events-none absolute inset-0 flex items-center justify-center"
|
|
332
|
+
>
|
|
333
|
+
<span
|
|
334
|
+
class="rounded-pill bg-black/55 px-3 py-1 text-xs font-semibold uppercase tracking-wide text-white"
|
|
335
|
+
>
|
|
336
|
+
Spoiler
|
|
337
|
+
</span>
|
|
338
|
+
</span>
|
|
339
|
+
|
|
255
340
|
<span
|
|
256
|
-
v-if="hiddenCount && i === visible.length - 1"
|
|
341
|
+
v-else-if="hiddenCount && i === visible.length - 1"
|
|
257
342
|
class="pointer-events-none absolute inset-0 flex items-center justify-center text-lg font-medium text-white"
|
|
258
343
|
>
|
|
259
344
|
+{{ hiddenCount }}
|
|
@@ -272,7 +357,7 @@ defineExpose({
|
|
|
272
357
|
v-if="openable"
|
|
273
358
|
v-model:open="open"
|
|
274
359
|
v-model:index="index"
|
|
275
|
-
:items="
|
|
360
|
+
:items="openItems"
|
|
276
361
|
:downloadable="downloadable"
|
|
277
362
|
:rotatable="rotatable"
|
|
278
363
|
/>
|
|
@@ -9,12 +9,32 @@ import {
|
|
|
9
9
|
DialogTitle,
|
|
10
10
|
} from "reka-ui"
|
|
11
11
|
import type { ComponentPublicInstance } from "vue"
|
|
12
|
-
import { computed, ref, useSlots, watch } from "vue"
|
|
12
|
+
import { computed, onBeforeUnmount, ref, useSlots, watch } from "vue"
|
|
13
13
|
import { useShift } from "../composables/useShift"
|
|
14
|
+
import { useModalLayer } from "./modalDepth"
|
|
14
15
|
import { CLOSE_ICON } from "./dropdown"
|
|
15
16
|
import LpIcon from "./LpIcon.vue"
|
|
16
17
|
import LpScrollArea from "./LpScrollArea.vue"
|
|
17
18
|
|
|
19
|
+
/*
|
|
20
|
+
* Stacking depth, so a dialog opened FROM a dialog sits above the one that
|
|
21
|
+
* opened it — scrim included.
|
|
22
|
+
*
|
|
23
|
+
* The fixed rungs cannot express this on their own. Every modal took
|
|
24
|
+
* --z-overlay for its scrim and --z-modal for its panel, so a nested pair
|
|
25
|
+
* landed at exactly the heights of the outer pair: the second scrim, at 100,
|
|
26
|
+
* sat UNDER the first panel at 110. It still darkened the page, which is why
|
|
27
|
+
* this reads as "the blur is missing" rather than as a stacking bug — a
|
|
28
|
+
* backdrop-filter only blurs what is painted beneath it, and the panel it was
|
|
29
|
+
* supposed to blur was above.
|
|
30
|
+
*
|
|
31
|
+
* Each level adds a step, keeping the scrim one below its own panel and both
|
|
32
|
+
* above everything the level beneath put on screen. The same problem, and the
|
|
33
|
+
* same fix, as the lightbox's own rungs in tokens.css — only the depth here is
|
|
34
|
+
* not known until a dialog finds itself inside another.
|
|
35
|
+
*/
|
|
36
|
+
const { claim, release, layer } = useModalLayer()
|
|
37
|
+
|
|
18
38
|
const props = withDefaults(
|
|
19
39
|
defineProps<{
|
|
20
40
|
open?: boolean
|
|
@@ -75,6 +95,17 @@ const widthClass = computed(() => {
|
|
|
75
95
|
const slots = useSlots()
|
|
76
96
|
// The body clips overflow and focus rings are drawn outside their control, so a
|
|
77
97
|
// field flush against the top edge lost its ring; pt-1 clears the ring's width.
|
|
98
|
+
/*
|
|
99
|
+
* Claim a level as this dialog opens, give it back as it closes. `immediate`
|
|
100
|
+
* matters: a dialog that starts out open has to have its rung before the first
|
|
101
|
+
* paint, or its scrim lands on the default one for a frame.
|
|
102
|
+
*/
|
|
103
|
+
watch(() => props.open, (open) => (open ? claim() : release()), { immediate: true })
|
|
104
|
+
|
|
105
|
+
// A dialog torn down while still open would otherwise stay on the stack, holding
|
|
106
|
+
// every dialog under it a step lower than it should be.
|
|
107
|
+
onBeforeUnmount(release)
|
|
108
|
+
|
|
78
109
|
const bodyPad = computed(() =>
|
|
79
110
|
[
|
|
80
111
|
"px-5",
|
|
@@ -178,7 +209,8 @@ watch(
|
|
|
178
209
|
<DialogRoot :open="open" @update:open="(v) => $emit('update:open', v)">
|
|
179
210
|
<DialogPortal>
|
|
180
211
|
<DialogOverlay
|
|
181
|
-
class="lp-scrim fixed inset-0
|
|
212
|
+
class="lp-scrim fixed inset-0 data-[state=open]:animate-[fade-in_var(--duration-medium)_var(--ease-emphasized)] data-[state=closed]:animate-[fade-out_120ms_ease]"
|
|
213
|
+
:style="{ zIndex: layer.scrim }"
|
|
182
214
|
/>
|
|
183
215
|
<!-- Centred by a full-screen flex wrapper rather than by translating the
|
|
184
216
|
panel off its own centre. With `top:50% / -translate-y-1/2` the panel
|
|
@@ -193,7 +225,10 @@ watch(
|
|
|
193
225
|
this wrapper: `max-h-full` on a centred flex child resolves against a
|
|
194
226
|
box it is free to overflow, so a tall body stopped handing its
|
|
195
227
|
overflow to the scroll area below and simply ran off-screen. -->
|
|
196
|
-
<div
|
|
228
|
+
<div
|
|
229
|
+
class="fixed inset-0 flex items-center justify-center pointer-events-none"
|
|
230
|
+
:style="{ zIndex: layer.panel }"
|
|
231
|
+
>
|
|
197
232
|
<!-- Without a description reka-ui warns on every open, and the opt-out it
|
|
198
233
|
checks for is an ABSENT `aria-describedby` — despite the message
|
|
199
234
|
naming the string "undefined" (a leftover from Radix, where
|
|
@@ -14,6 +14,16 @@ export interface LightboxItem {
|
|
|
14
14
|
description?: string
|
|
15
15
|
/** Filename used when downloading; derived from `src` when absent. */
|
|
16
16
|
filename?: string
|
|
17
|
+
/**
|
|
18
|
+
* Hide this image behind a cover until someone asks for it — a spoiler, a
|
|
19
|
+
* screenshot of something not everyone in the channel has reached yet, a
|
|
20
|
+
* picture that should not appear in a scroll past.
|
|
21
|
+
*
|
|
22
|
+
* A property of the ITEM rather than of the grid showing it: which images are
|
|
23
|
+
* spoilers is a fact about the images, and the usual set is a mixed one, with
|
|
24
|
+
* a marked picture sitting beside plain ones.
|
|
25
|
+
*/
|
|
26
|
+
spoiler?: boolean
|
|
17
27
|
}
|
|
18
28
|
|
|
19
29
|
/** Last path segment of a URL, or a fallback — used to name a download. */
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Stacking order for dialogs that are open at the same time.
|
|
3
|
+
*
|
|
4
|
+
* Not provide/inject, which was the first shape tried: it only works when the
|
|
5
|
+
* second dialog is rendered INSIDE the first one's subtree, and the ordinary
|
|
6
|
+
* way to write a dialog opened from a dialog is to put both at the top level of
|
|
7
|
+
* the same component and toggle them with separate flags. Those two are
|
|
8
|
+
* siblings, so the inner one injects nothing and lands on the same rungs as the
|
|
9
|
+
* outer — which is the bug.
|
|
10
|
+
*
|
|
11
|
+
* What decides the order is the sequence of OPENING: a dialog opened while
|
|
12
|
+
* another is up belongs above it, wherever it sits in the markup.
|
|
13
|
+
*
|
|
14
|
+
* The newest dialog keeps the scale's own --z-overlay/--z-modal and the ones
|
|
15
|
+
* beneath are pushed DOWN, rather than each new dialog climbing higher. Climbing
|
|
16
|
+
* runs out of room immediately: --z-popover is only 10 above --z-modal, so the
|
|
17
|
+
* second nested dialog would tie with every popover and the third with every
|
|
18
|
+
* tooltip — and a select inside a dialog is ordinary. Descending has no such
|
|
19
|
+
* ceiling, and it keeps the frontmost dialog exactly where a single one has
|
|
20
|
+
* always been.
|
|
21
|
+
*/
|
|
22
|
+
import { computed, ref, type ComputedRef } from "vue"
|
|
23
|
+
|
|
24
|
+
/*
|
|
25
|
+
* Rungs per level. Has to clear a whole level, not just its scrim: --z-overlay
|
|
26
|
+
* is 100 and --z-modal 110, so a gap of 2 left the upper scrim under the lower
|
|
27
|
+
* PANEL — it darkened that panel and could not blur it, a backdrop-filter
|
|
28
|
+
* having nothing of it underneath to work on.
|
|
29
|
+
*/
|
|
30
|
+
const STEP = 10
|
|
31
|
+
|
|
32
|
+
/** Levels currently claimed, oldest first. */
|
|
33
|
+
const stack = ref<number[]>([])
|
|
34
|
+
let nextId = 0
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Register a dialog as open and get back the z-indexes it should paint with.
|
|
38
|
+
* The result is reactive: opening another dialog moves this one down a step.
|
|
39
|
+
*/
|
|
40
|
+
export function useModalLayer(): {
|
|
41
|
+
claim: () => void
|
|
42
|
+
release: () => void
|
|
43
|
+
layer: ComputedRef<{ scrim: string; panel: string }>
|
|
44
|
+
} {
|
|
45
|
+
const id = nextId++
|
|
46
|
+
|
|
47
|
+
/*
|
|
48
|
+
* How far from the TOP of the stack, so the newest dialog is 0 and keeps the
|
|
49
|
+
* unmodified rungs. A dialog that is not in the stack reads 0 too: it is
|
|
50
|
+
* either closed, or opening this very frame and about to claim.
|
|
51
|
+
*/
|
|
52
|
+
const fromTop = computed(() => {
|
|
53
|
+
const at = stack.value.indexOf(id)
|
|
54
|
+
return at === -1 ? 0 : stack.value.length - 1 - at
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
return {
|
|
58
|
+
claim: () => {
|
|
59
|
+
if (!stack.value.includes(id)) stack.value = [...stack.value, id]
|
|
60
|
+
},
|
|
61
|
+
release: () => {
|
|
62
|
+
stack.value = stack.value.filter((x) => x !== id)
|
|
63
|
+
},
|
|
64
|
+
layer: computed(() => {
|
|
65
|
+
const drop = fromTop.value * STEP
|
|
66
|
+
return {
|
|
67
|
+
scrim: `calc(var(--z-overlay) - ${drop})`,
|
|
68
|
+
panel: `calc(var(--z-modal) - ${drop})`,
|
|
69
|
+
}
|
|
70
|
+
}),
|
|
71
|
+
}
|
|
72
|
+
}
|
|
@@ -65,6 +65,14 @@ export interface UseShift {
|
|
|
65
65
|
/** Kept in step with the `duration-fast` token the transition utilities use. */
|
|
66
66
|
const TWEEN_MS = 160
|
|
67
67
|
|
|
68
|
+
/*
|
|
69
|
+
* How long after a tween starts a second observer call still counts as the same
|
|
70
|
+
* move rather than a new one. Both observers report one swap within a frame or
|
|
71
|
+
* two; anything later is content that is still changing size and has to be
|
|
72
|
+
* followed. Two frames at 60Hz, with room to spare for a slow one.
|
|
73
|
+
*/
|
|
74
|
+
const RETARGET_AFTER_MS = 40
|
|
75
|
+
|
|
68
76
|
export function useShift(
|
|
69
77
|
options: UseShiftOptions = {},
|
|
70
78
|
): UseShift {
|
|
@@ -81,6 +89,8 @@ export function useShift(
|
|
|
81
89
|
let lastWidth = 0
|
|
82
90
|
/** True while a tween owns the size, so observers don't cancel it midway. */
|
|
83
91
|
let inFlight = false
|
|
92
|
+
/** When the in-flight tween started, to tell a duplicate from a follow-up. */
|
|
93
|
+
let tweenStart = 0
|
|
84
94
|
|
|
85
95
|
const axisOf = () => toValue(options.axis) ?? "height"
|
|
86
96
|
const wantsHeight = () => axisOf() !== "width"
|
|
@@ -96,9 +106,29 @@ export function useShift(
|
|
|
96
106
|
function retune() {
|
|
97
107
|
const node = el.value
|
|
98
108
|
if (!node) return
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
109
|
+
|
|
110
|
+
/*
|
|
111
|
+
* A tween already owns the size. Where it came from decides what to do:
|
|
112
|
+
*
|
|
113
|
+
* - One swap wakes both observers — the mutation as content lands, the
|
|
114
|
+
* resize as it reflows — and the second call arrives within a frame or
|
|
115
|
+
* two of the first, describing the same move. Re-running it would clear
|
|
116
|
+
* the pin the first one wrote, so it is dropped.
|
|
117
|
+
* - Content that animates its OWN height keeps reporting new sizes for as
|
|
118
|
+
* long as it runs. A collapsible body opening inside a dialog fires the
|
|
119
|
+
* observer every frame for 260ms, and dropping all of those pinned the
|
|
120
|
+
* panel at the size the body happened to have one frame in — measured,
|
|
121
|
+
* 212px held for 170ms and then 135px arriving in a single frame, which
|
|
122
|
+
* is the "modal snaps open" report. Those retunes must RETARGET the
|
|
123
|
+
* tween instead: start from where the box is painted right now and ease
|
|
124
|
+
* on to the new figure.
|
|
125
|
+
*/
|
|
126
|
+
if (inFlight) {
|
|
127
|
+
if (performance.now() - tweenStart < RETARGET_AFTER_MS) return
|
|
128
|
+
const live = node.getBoundingClientRect()
|
|
129
|
+
lastHeight = Math.round(live.height)
|
|
130
|
+
lastWidth = Math.round(live.width)
|
|
131
|
+
}
|
|
102
132
|
|
|
103
133
|
// Remembered, not measured: by the time either observer fires the box has
|
|
104
134
|
// already reflowed, so the element can only report where it is going.
|
|
@@ -145,6 +175,7 @@ export function useShift(
|
|
|
145
175
|
if (movesWidth) node.style.width = `${toWidth}px`
|
|
146
176
|
|
|
147
177
|
inFlight = true
|
|
178
|
+
tweenStart = performance.now()
|
|
148
179
|
resizing.value = true
|
|
149
180
|
clearTimeout(settleTimer)
|
|
150
181
|
const wait = prefersReducedMotion() ? 0 : (toValue(options.duration) ?? TWEEN_MS)
|