@leavepulse/ui 0.93.2 → 0.93.3

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.
@@ -9,7 +9,7 @@ import { _ as _sfc_main$2 } from "./VideoThumb-BfDkIXqE.js";
9
9
  const _hoisted_1 = { key: 0 };
10
10
  const _hoisted_2 = ["aria-label"];
11
11
  const _hoisted_3 = ["aria-label", "onClick"];
12
- const _hoisted_4 = ["src", "alt"];
12
+ const _hoisted_4 = ["src", "alt", "onError"];
13
13
  const _hoisted_5 = {
14
14
  key: 0,
15
15
  class: "pointer-events-none absolute inset-0 flex items-center justify-center"
@@ -39,7 +39,7 @@ const _sfc_main = /* @__PURE__ */ defineComponent({
39
39
  itemMenu: {},
40
40
  labels: {}
41
41
  },
42
- emits: ["select", "reveal", "update:open"],
42
+ emits: ["select", "reveal", "broken", "update:open"],
43
43
  setup(__props, { expose: __expose, emit: __emit }) {
44
44
  const DEFAULT_LABELS = {
45
45
  /** Accessible name of the grid. */
@@ -60,7 +60,7 @@ const _sfc_main = /* @__PURE__ */ defineComponent({
60
60
  }
61
61
  function menuTitle(item, i) {
62
62
  if (isHidden(item)) return text.value.spoiler;
63
- return item.title || fill(text.value.image, i + 1, props.items.length);
63
+ return item.title || fill(text.value.image, i + 1, shown.value.length);
64
64
  }
65
65
  const emit = __emit;
66
66
  const open = ref(false);
@@ -68,10 +68,27 @@ const _sfc_main = /* @__PURE__ */ defineComponent({
68
68
  watch(open, (value, was) => {
69
69
  if (!value && was) emit("update:open", false);
70
70
  });
71
+ const fellBack = ref(/* @__PURE__ */ new Set());
72
+ const broken = ref(/* @__PURE__ */ new Set());
73
+ const shown = computed(
74
+ () => broken.value.size ? props.items.filter((item) => !broken.value.has(item.src)) : props.items
75
+ );
76
+ function thumbOf(item) {
77
+ return item.thumb && !fellBack.value.has(item.src) ? item.thumb : item.src;
78
+ }
79
+ function onImageError(item) {
80
+ if (item.thumb && item.thumb !== item.src && !fellBack.value.has(item.src)) {
81
+ fellBack.value = new Set(fellBack.value).add(item.src);
82
+ return;
83
+ }
84
+ if (broken.value.has(item.src)) return;
85
+ broken.value = new Set(broken.value).add(item.src);
86
+ emit("broken", item);
87
+ }
71
88
  const visible = computed(
72
- () => props.max && props.items.length > props.max ? props.items.slice(0, props.max) : props.items
89
+ () => props.max && shown.value.length > props.max ? shown.value.slice(0, props.max) : shown.value
73
90
  );
74
- const hiddenCount = computed(() => Math.max(props.items.length - visible.value.length, 0));
91
+ const hiddenCount = computed(() => Math.max(shown.value.length - visible.value.length, 0));
75
92
  const COLS = {
76
93
  1: "grid-cols-1",
77
94
  2: "grid-cols-2",
@@ -141,9 +158,9 @@ const _sfc_main = /* @__PURE__ */ defineComponent({
141
158
  () => ["grid", columnClass.value, GAPS[props.gap] ?? GAPS[2]].join(" ")
142
159
  );
143
160
  function activate(i) {
144
- const item = props.items[i];
161
+ const item = shown.value[i];
145
162
  if (!item) return;
146
- emit("select", item, i);
163
+ emit("select", item, props.items.indexOf(item));
147
164
  if (!props.openable) return;
148
165
  const at = openIndexOf(i);
149
166
  if (at === -1) return;
@@ -162,14 +179,17 @@ const _sfc_main = /* @__PURE__ */ defineComponent({
162
179
  revealed.value = new Set(revealed.value).add(item.src);
163
180
  emit("reveal", item);
164
181
  }
165
- const openItems = computed(() => props.items.filter((item) => !isHidden(item)));
182
+ const openItems = computed(() => shown.value.filter((item) => !isHidden(item)));
166
183
  function openIndexOf(i) {
167
- const item = props.items[i];
184
+ const item = shown.value[i];
168
185
  return item ? openItems.value.indexOf(item) : -1;
169
186
  }
170
187
  __expose({
171
188
  /** Open the lightbox at an index — for a "view all" button outside the grid. */
172
- openAt: (i) => activate(i),
189
+ openAt: (i) => {
190
+ const item = props.items[i];
191
+ if (item) activate(shown.value.indexOf(item));
192
+ },
173
193
  /** Uncover every spoiler in the set. */
174
194
  revealAll: () => {
175
195
  const next = new Set(revealed.value);
@@ -178,7 +198,7 @@ const _sfc_main = /* @__PURE__ */ defineComponent({
178
198
  }
179
199
  });
180
200
  return (_ctx, _cache) => {
181
- return !__props.items.length ? (openBlock(), createElementBlock("div", _hoisted_1, [
201
+ return !shown.value.length ? (openBlock(), createElementBlock("div", _hoisted_1, [
182
202
  renderSlot(_ctx.$slots, "empty")
183
203
  ])) : (openBlock(), createElementBlock("div", mergeProps({
184
204
  key: 1,
@@ -191,7 +211,7 @@ const _sfc_main = /* @__PURE__ */ defineComponent({
191
211
  key: item.src + i,
192
212
  items: __props.itemMenu?.(item, i) ?? [],
193
213
  title: menuTitle(item, i),
194
- "title-image": isHidden(item) ? void 0 : unref(isVideoItem)(item) ? item.poster ?? item.thumb : item.thumb || item.src,
214
+ "title-image": isHidden(item) ? void 0 : unref(isVideoItem)(item) ? item.poster ?? item.thumb : thumbOf(item),
195
215
  subtitle: isHidden(item) ? void 0 : item.description
196
216
  }, {
197
217
  default: withCtx(() => [
@@ -221,15 +241,16 @@ const _sfc_main = /* @__PURE__ */ defineComponent({
221
241
  ]
222
242
  }, null, 8, ["item", "alt", "media-class"])) : (openBlock(), createElementBlock("img", {
223
243
  key: 1,
224
- src: item.thumb || item.src,
244
+ src: thumbOf(item),
225
245
  alt: isHidden(item) ? "" : item.title || "",
226
246
  loading: "lazy",
227
247
  decoding: "async",
248
+ onError: ($event) => onImageError(item),
228
249
  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", [
229
250
  isHidden(item) ? "scale-[1.12] blur-[18px] brightness-[0.6] saturate-[1.1] group-hover:scale-[1.12]" : "",
230
251
  !isHidden(item) && hiddenCount.value && i === visible.value.length - 1 ? "scale-[1.06] blur-[2px] brightness-[0.45] group-hover:scale-[1.06]" : ""
231
252
  ]])
232
- }, null, 10, _hoisted_4))
253
+ }, null, 42, _hoisted_4))
233
254
  ]),
234
255
  isHidden(item) ? (openBlock(), createElementBlock("span", _hoisted_5, [
235
256
  createElementVNode("span", _hoisted_6, toDisplayString(unref(text).spoiler), 1)
@@ -67,10 +67,12 @@ declare const __VLS_base: import("vue").DefineComponent<__VLS_Props, {
67
67
  select: (item: LightboxItem, index: number) => any;
68
68
  reveal: (item: LightboxItem) => any;
69
69
  "update:open": (value: boolean) => any;
70
+ broken: (item: LightboxItem) => any;
70
71
  }, string, import("vue").PublicProps, Readonly<__VLS_Props> & Readonly<{
71
72
  onSelect?: ((item: LightboxItem, index: number) => any) | undefined;
72
73
  onReveal?: ((item: LightboxItem) => any) | undefined;
73
74
  "onUpdate:open"?: ((value: boolean) => any) | undefined;
75
+ onBroken?: ((item: LightboxItem) => any) | undefined;
74
76
  }>, {
75
77
  columns: number | {
76
78
  base?: number;
@@ -1,4 +1,4 @@
1
- import { _ as _sfc_main } from "../chunks/LpMediaGallery-B7DOV_aL.js";
1
+ import { _ as _sfc_main } from "../chunks/LpMediaGallery-CtWA5Uab.js";
2
2
  export {
3
3
  _sfc_main as default
4
4
  };
package/dist/index.js CHANGED
@@ -63,7 +63,7 @@ import { _ as _37 } from "./chunks/LpLogViewer-BuIzr_6h.js";
63
63
  import { _ as _38 } from "./chunks/LpLogInspector-BhJTJIst.js";
64
64
  import { compilePattern, findIds, messageShape, structuredPayload, timeMs } from "./components/logTools.js";
65
65
  import { countLevels, matchPositions, matchesTerm, ringWindow, stepMatch } from "./components/logSearch.js";
66
- import { _ as _39 } from "./chunks/LpMediaGallery-B7DOV_aL.js";
66
+ import { _ as _39 } from "./chunks/LpMediaGallery-CtWA5Uab.js";
67
67
  import { _ as _40 } from "./chunks/LpModal-D1E7rQzw.js";
68
68
  import { _ as _41 } from "./chunks/LpModalNav-DivqT1NV.js";
69
69
  import { _ as _42 } from "./chunks/LpNotificationBell-KRgdRZs-.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@leavepulse/ui",
3
- "version": "0.93.2",
3
+ "version": "0.93.3",
4
4
  "type": "module",
5
5
  "description": "LeavePulse token-driven Vue component kit.",
6
6
  "repository": {
@@ -115,7 +115,7 @@ function fill(template: string, n: number, total = 0): string {
115
115
  */
116
116
  function menuTitle(item: LightboxItem, i: number): string {
117
117
  if (isHidden(item)) return text.value.spoiler
118
- return item.title || fill(text.value.image, i + 1, props.items.length)
118
+ return item.title || fill(text.value.image, i + 1, shown.value.length)
119
119
  }
120
120
 
121
121
  const emit = defineEmits<{
@@ -123,6 +123,8 @@ const emit = defineEmits<{
123
123
  (e: "select", item: LightboxItem, index: number): void
124
124
  /** A spoiler was uncovered. */
125
125
  (e: "reveal", item: LightboxItem): void
126
+ /** An image failed to load from both its thumbnail and its source and left the grid. */
127
+ (e: "broken", item: LightboxItem): void
126
128
  (e: "update:open", value: boolean): void
127
129
  }>()
128
130
 
@@ -142,12 +144,39 @@ watch(open, (value, was) => {
142
144
  if (!value && was) emit("update:open", false)
143
145
  })
144
146
 
147
+ /*
148
+ * Images that failed to load. A thumbnail that fails falls back to the full
149
+ * source once; when that fails too the image leaves the grid and the lightbox
150
+ * and `broken` says so. A cell of grey with a broken-picture glyph tells the
151
+ * viewer nothing they can act on, and paging the lightbox onto it is worse.
152
+ * Keyed by `src`, like `revealed`, so a reordered set keeps the right ones out.
153
+ */
154
+ const fellBack = ref(new Set<string>())
155
+ const broken = ref(new Set<string>())
156
+ const shown = computed(() =>
157
+ broken.value.size ? props.items.filter((item) => !broken.value.has(item.src)) : props.items,
158
+ )
159
+
160
+ function thumbOf(item: LightboxItem): string {
161
+ return item.thumb && !fellBack.value.has(item.src) ? item.thumb : item.src
162
+ }
163
+
164
+ function onImageError(item: LightboxItem) {
165
+ if (item.thumb && item.thumb !== item.src && !fellBack.value.has(item.src)) {
166
+ fellBack.value = new Set(fellBack.value).add(item.src)
167
+ return
168
+ }
169
+ if (broken.value.has(item.src)) return
170
+ broken.value = new Set(broken.value).add(item.src)
171
+ emit("broken", item)
172
+ }
173
+
145
174
  // `max` counts what is SHOWN, so the badge reads "+N more" rather than "N
146
175
  // total". A set of 7 capped at 4 shows 4 cells, the last one badged +3.
147
176
  const visible = computed(() =>
148
- props.max && props.items.length > props.max ? props.items.slice(0, props.max) : props.items,
177
+ props.max && shown.value.length > props.max ? shown.value.slice(0, props.max) : shown.value,
149
178
  )
150
- const hiddenCount = computed(() => Math.max(props.items.length - visible.value.length, 0))
179
+ const hiddenCount = computed(() => Math.max(shown.value.length - visible.value.length, 0))
151
180
 
152
181
  /*
153
182
  * Column classes are written out rather than interpolated. Tailwind scans
@@ -231,11 +260,11 @@ const { class: rootClass, attrs: rest } = useMergedAttrs(() =>
231
260
  )
232
261
 
233
262
  function activate(i: number) {
234
- const item = props.items[i]
263
+ const item = shown.value[i]
235
264
  if (!item) return
236
265
  // `select` reports the position in the caller's own array, which is the one
237
266
  // they can do anything with — the lightbox's filtered index is internal.
238
- emit("select", item, i)
267
+ emit("select", item, props.items.indexOf(item))
239
268
  if (!props.openable) return
240
269
  // Covered images are not in the lightbox's list, so the index has to be
241
270
  // translated into it.
@@ -285,17 +314,20 @@ function reveal(item: LightboxItem) {
285
314
  * pass through untouched, so opening the one you just uncovered still lands on
286
315
  * it.
287
316
  */
288
- const openItems = computed(() => props.items.filter((item) => !isHidden(item)))
317
+ const openItems = computed(() => shown.value.filter((item) => !isHidden(item)))
289
318
 
290
319
  /** Index within `openItems` of the grid item at `i`, or -1 when it is covered. */
291
320
  function openIndexOf(i: number): number {
292
- const item = props.items[i]
321
+ const item = shown.value[i]
293
322
  return item ? openItems.value.indexOf(item) : -1
294
323
  }
295
324
 
296
325
  defineExpose({
297
326
  /** Open the lightbox at an index — for a "view all" button outside the grid. */
298
- openAt: (i: number) => activate(i),
327
+ openAt: (i: number) => {
328
+ const item = props.items[i]
329
+ if (item) activate(shown.value.indexOf(item))
330
+ },
299
331
  /** Uncover every spoiler in the set. */
300
332
  revealAll: () => {
301
333
  const next = new Set(revealed.value)
@@ -306,7 +338,7 @@ defineExpose({
306
338
  </script>
307
339
 
308
340
  <template>
309
- <div v-if="!items.length">
341
+ <div v-if="!shown.length">
310
342
  <slot name="empty" />
311
343
  </div>
312
344
 
@@ -316,7 +348,7 @@ defineExpose({
316
348
  :key="item.src + i"
317
349
  :items="itemMenu?.(item, i) ?? []"
318
350
  :title="menuTitle(item, i)"
319
- :title-image="isHidden(item) ? undefined : isVideoItem(item) ? item.poster ?? item.thumb : item.thumb || item.src"
351
+ :title-image="isHidden(item) ? undefined : isVideoItem(item) ? item.poster ?? item.thumb : thumbOf(item)"
320
352
  :subtitle="isHidden(item) ? undefined : item.description"
321
353
  >
322
354
  <button
@@ -370,10 +402,11 @@ defineExpose({
370
402
  />
371
403
  <img
372
404
  v-else
373
- :src="item.thumb || item.src"
405
+ :src="thumbOf(item)"
374
406
  :alt="isHidden(item) ? '' : item.title || ''"
375
407
  loading="lazy"
376
408
  decoding="async"
409
+ @error="onImageError(item)"
377
410
  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"
378
411
  :class="[
379
412
  isHidden(item)