@bearmenu/ui 0.8.19 → 0.8.20

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.
@@ -52,7 +52,9 @@ type PosterStackPoster = {
52
52
  fallback?: React.ReactNode;
53
53
  };
54
54
  type PosterStackProps = {
55
- /** Rendered in order; only the first three are shown. */
55
+ /** Candidates in order. The first three whose image loads are shown, so
56
+ * pass more than three: a poster whose asset fails yields its slot to
57
+ * the next candidate. */
56
58
  posters: PosterStackPoster[];
57
59
  /** Defaults to a plain `<a>`. Pass `next/link`, or an anchor that opens the app's preview. */
58
60
  linkComponent?: React.ComponentType<PosterStackLinkProps>;
@@ -65,7 +65,7 @@ function PosterStack({
65
65
  observer.observe(container);
66
66
  return () => observer.disconnect();
67
67
  }, []);
68
- const visiblePosters = posters.slice(0, POSTER_SLOTS.length);
68
+ const visiblePosters = posters.map((poster, index) => ({ poster, index })).filter(({ index }) => !failedIndices.has(index)).slice(0, POSTER_SLOTS.length);
69
69
  return /* @__PURE__ */ jsxs(
70
70
  "div",
71
71
  {
@@ -73,10 +73,10 @@ function PosterStack({
73
73
  className: cn("relative h-[196px] md:h-[400px] md:w-auto md:mx-0", PHONE_STAGE_CLASS, className),
74
74
  children: [
75
75
  /* @__PURE__ */ jsx("style", { children: POSTER_STACK_ANIMATION_STYLE }),
76
- visiblePosters.map((poster, index) => {
77
- var _a;
76
+ visiblePosters.map(({ poster, index: candidateIndex }, index) => {
77
+ var _a, _b, _c;
78
78
  const slot = POSTER_SLOTS[index];
79
- const showPoster = !!poster.src && !failedIndices.has(index);
79
+ const showPoster = !!poster.src;
80
80
  const face = /* @__PURE__ */ jsxs(Fragment, { children: [
81
81
  showPoster ? (
82
82
  // eslint-disable-next-line @next/next/no-img-element
@@ -91,7 +91,7 @@ function PosterStack({
91
91
  "h-full w-full object-cover",
92
92
  poster.href && "transition-transform duration-300 group-hover/poster:scale-[1.03] motion-reduce:transition-none"
93
93
  ),
94
- onError: () => setFailedIndices((current) => new Set(current).add(index))
94
+ onError: () => setFailedIndices((current) => new Set(current).add(candidateIndex))
95
95
  }
96
96
  )
97
97
  ) : (_a = poster.fallback) != null ? _a : /* @__PURE__ */ jsx(EventPosterFallback, {}),
@@ -132,7 +132,7 @@ function PosterStack({
132
132
  }
133
133
  ) : /* @__PURE__ */ jsx("div", { className: "relative h-full w-full", children: face })
134
134
  },
135
- poster.alt || index
135
+ (_c = (_b = poster.href) != null ? _b : poster.src) != null ? _c : candidateIndex
136
136
  );
137
137
  })
138
138
  ]
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bearmenu/ui",
3
- "version": "0.8.19",
3
+ "version": "0.8.20",
4
4
  "description": "BearMenu design system — shared UI tokens, primitives, and components",
5
5
  "license": "UNLICENSED",
6
6
  "repository": {
@@ -1,5 +1,5 @@
1
1
  import { describe, it, expect } from "vitest";
2
- import { render, screen } from "@testing-library/react";
2
+ import { fireEvent, render, screen } from "@testing-library/react";
3
3
  import { EventTonightStrip } from "./event-tonight-strip";
4
4
  import { PosterStack } from "./poster-stack";
5
5
 
@@ -46,4 +46,24 @@ describe("PosterStack", () => {
46
46
  expect(screen.getAllByRole("link")).toHaveLength(1);
47
47
  expect(screen.getByAltText("B poster")).toBeInTheDocument();
48
48
  });
49
+
50
+ it("gives a failed poster's slot to the next candidate", () => {
51
+ window.matchMedia = () => ({ matches: true }) as MediaQueryList;
52
+ const poster = (id: string) => ({ src: `/${id}.jpg`, alt: id, caption: id, href: `/e/${id}` });
53
+ render(<PosterStack posters={[poster("a"), poster("b"), poster("c"), poster("d")]} />);
54
+ expect(screen.getAllByRole("link").map((l) => l.getAttribute("href"))).toEqual([
55
+ "/e/a",
56
+ "/e/b",
57
+ "/e/c",
58
+ ]);
59
+
60
+ // The middle poster's asset dies at load time.
61
+ fireEvent.error(screen.getByRole("link", { name: "b" }).querySelector("img")!);
62
+
63
+ expect(screen.getAllByRole("link").map((l) => l.getAttribute("href"))).toEqual([
64
+ "/e/a",
65
+ "/e/c",
66
+ "/e/d",
67
+ ]);
68
+ });
49
69
  });
@@ -63,7 +63,9 @@ export type PosterStackPoster = {
63
63
  };
64
64
 
65
65
  export type PosterStackProps = {
66
- /** Rendered in order; only the first three are shown. */
66
+ /** Candidates in order. The first three whose image loads are shown, so
67
+ * pass more than three: a poster whose asset fails yields its slot to
68
+ * the next candidate. */
67
69
  posters: PosterStackPoster[];
68
70
  /** Defaults to a plain `<a>`. Pass `next/link`, or an anchor that opens the app's preview. */
69
71
  linkComponent?: React.ComponentType<PosterStackLinkProps>;
@@ -154,7 +156,14 @@ export function PosterStack({
154
156
  return () => observer.disconnect();
155
157
  }, []);
156
158
 
157
- const visiblePosters = posters.slice(0, POSTER_SLOTS.length);
159
+ // The first three candidates whose image has not failed. A poster whose
160
+ // asset 404s or 502s at load time gives its slot to the next candidate —
161
+ // the stack is the hub's first screen, and a fallback glyph in the middle
162
+ // of three posters is a hole in it. Pass more candidates than slots.
163
+ const visiblePosters = posters
164
+ .map((poster, index) => ({ poster, index }))
165
+ .filter(({ index }) => !failedIndices.has(index))
166
+ .slice(0, POSTER_SLOTS.length);
158
167
 
159
168
  return (
160
169
  <div
@@ -163,9 +172,9 @@ export function PosterStack({
163
172
  >
164
173
  <style>{POSTER_STACK_ANIMATION_STYLE}</style>
165
174
 
166
- {visiblePosters.map((poster, index) => {
175
+ {visiblePosters.map(({ poster, index: candidateIndex }, index) => {
167
176
  const slot = POSTER_SLOTS[index];
168
- const showPoster = !!poster.src && !failedIndices.has(index);
177
+ const showPoster = !!poster.src;
169
178
  const face = (
170
179
  <>
171
180
  {showPoster ? (
@@ -180,7 +189,9 @@ export function PosterStack({
180
189
  poster.href &&
181
190
  "transition-transform duration-300 group-hover/poster:scale-[1.03] motion-reduce:transition-none"
182
191
  )}
183
- onError={() => setFailedIndices((current) => new Set(current).add(index))}
192
+ onError={() =>
193
+ setFailedIndices((current) => new Set(current).add(candidateIndex))
194
+ }
184
195
  />
185
196
  ) : (
186
197
  (poster.fallback ?? <EventPosterFallback />)
@@ -197,7 +208,9 @@ export function PosterStack({
197
208
 
198
209
  return (
199
210
  <div
200
- key={poster.alt || index}
211
+ // Keyed by candidate, not slot: a replacement mounts fresh in the
212
+ // slot the failed poster vacated instead of inheriting its state.
213
+ key={poster.href ?? poster.src ?? candidateIndex}
201
214
  ref={(node) => {
202
215
  posterRefs.current[index] = node;
203
216
  }}