@bearmenu/ui 0.8.19 → 0.8.21

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.
@@ -4,7 +4,7 @@ import * as React from 'react';
4
4
  import { VariantProps } from 'class-variance-authority';
5
5
 
6
6
  declare const badgeVariants: (props?: ({
7
- variant?: "default" | "destructive" | "warning" | "success" | "outline" | "secondary" | "glass" | "overlay" | "brown" | "onPrimary" | null | undefined;
7
+ variant?: "default" | "destructive" | "outline" | "secondary" | "glass" | "overlay" | "brown" | "warning" | "success" | "onPrimary" | null | undefined;
8
8
  live?: boolean | null | undefined;
9
9
  } & class_variance_authority_types.ClassProp) | undefined) => string;
10
10
  interface BadgeProps extends React.HTMLAttributes<HTMLDivElement>, VariantProps<typeof badgeVariants> {
@@ -3,7 +3,7 @@ import * as React from 'react';
3
3
  import { VariantProps } from 'class-variance-authority';
4
4
 
5
5
  declare const buttonVariants: (props?: ({
6
- variant?: "default" | "destructive" | "warning" | "link" | "outline" | "secondary" | "ghost" | "glass" | "overlay" | "ink" | "brown" | null | undefined;
6
+ variant?: "link" | "default" | "destructive" | "outline" | "secondary" | "ghost" | "glass" | "overlay" | "ink" | "brown" | "warning" | null | undefined;
7
7
  size?: "default" | "xs" | "sm" | "lg" | "pill" | "icon" | "icon-xs" | "icon-sm" | "icon-lg" | null | undefined;
8
8
  fullWidth?: boolean | null | undefined;
9
9
  } & class_variance_authority_types.ClassProp) | undefined) => string;
@@ -3,12 +3,12 @@ import * as React from 'react';
3
3
  import { VariantProps } from 'class-variance-authority';
4
4
 
5
5
  declare const cardVariants: (props?: ({
6
- variant?: "muted" | "outline" | "glass" | "surface" | "elevated" | "elevatedPanel" | null | undefined;
6
+ variant?: "outline" | "glass" | "muted" | "surface" | "elevated" | "elevatedPanel" | null | undefined;
7
7
  size?: "default" | "sm" | "lg" | null | undefined;
8
8
  interactive?: boolean | null | undefined;
9
9
  } & class_variance_authority_types.ClassProp) | undefined) => string;
10
10
  declare const Card: React.ForwardRefExoticComponent<React.HTMLAttributes<HTMLDivElement> & VariantProps<(props?: ({
11
- variant?: "muted" | "outline" | "glass" | "surface" | "elevated" | "elevatedPanel" | null | undefined;
11
+ variant?: "outline" | "glass" | "muted" | "surface" | "elevated" | "elevatedPanel" | null | undefined;
12
12
  size?: "default" | "sm" | "lg" | null | undefined;
13
13
  interactive?: boolean | null | undefined;
14
14
  } & class_variance_authority_types.ClassProp) | undefined) => string> & React.RefAttributes<HTMLDivElement>>;
@@ -4,11 +4,11 @@ import { DialogProps } from '@radix-ui/react-dialog';
4
4
 
5
5
  declare const Command: React.ForwardRefExoticComponent<Omit<{
6
6
  children?: React.ReactNode;
7
- } & Pick<Pick<React.DetailedHTMLProps<React.HTMLAttributes<HTMLDivElement>, HTMLDivElement>, keyof React.HTMLAttributes<HTMLDivElement> | "key"> & {
7
+ } & Pick<Pick<React.DetailedHTMLProps<React.HTMLAttributes<HTMLDivElement>, HTMLDivElement>, "key" | keyof React.HTMLAttributes<HTMLDivElement>> & {
8
8
  ref?: React.Ref<HTMLDivElement>;
9
9
  } & {
10
10
  asChild?: boolean;
11
- }, keyof React.HTMLAttributes<HTMLDivElement> | "asChild" | "key"> & {
11
+ }, "key" | keyof React.HTMLAttributes<HTMLDivElement> | "asChild"> & {
12
12
  label?: string;
13
13
  shouldFilter?: boolean;
14
14
  filter?: (value: string, search: string, keywords?: string[]) => number;
@@ -28,61 +28,61 @@ declare const CommandInput: React.ForwardRefExoticComponent<Omit<Omit<Pick<Pick<
28
28
  ref?: React.Ref<HTMLInputElement>;
29
29
  } & {
30
30
  asChild?: boolean;
31
- }, "asChild" | "key" | keyof React.InputHTMLAttributes<HTMLInputElement>>, "onChange" | "type" | "value"> & {
31
+ }, "key" | "asChild" | keyof React.InputHTMLAttributes<HTMLInputElement>>, "onChange" | "type" | "value"> & {
32
32
  value?: string;
33
33
  onValueChange?: (search: string) => void;
34
34
  } & React.RefAttributes<HTMLInputElement>, "ref"> & React.RefAttributes<HTMLInputElement>>;
35
35
  declare const CommandList: React.ForwardRefExoticComponent<Omit<{
36
36
  children?: React.ReactNode;
37
- } & Pick<Pick<React.DetailedHTMLProps<React.HTMLAttributes<HTMLDivElement>, HTMLDivElement>, keyof React.HTMLAttributes<HTMLDivElement> | "key"> & {
37
+ } & Pick<Pick<React.DetailedHTMLProps<React.HTMLAttributes<HTMLDivElement>, HTMLDivElement>, "key" | keyof React.HTMLAttributes<HTMLDivElement>> & {
38
38
  ref?: React.Ref<HTMLDivElement>;
39
39
  } & {
40
40
  asChild?: boolean;
41
- }, keyof React.HTMLAttributes<HTMLDivElement> | "asChild" | "key"> & {
41
+ }, "key" | keyof React.HTMLAttributes<HTMLDivElement> | "asChild"> & {
42
42
  label?: string;
43
43
  } & React.RefAttributes<HTMLDivElement>, "ref"> & React.RefAttributes<HTMLDivElement>>;
44
44
  declare const CommandEmpty: React.ForwardRefExoticComponent<Omit<{
45
45
  children?: React.ReactNode;
46
- } & Pick<Pick<React.DetailedHTMLProps<React.HTMLAttributes<HTMLDivElement>, HTMLDivElement>, keyof React.HTMLAttributes<HTMLDivElement> | "key"> & {
46
+ } & Pick<Pick<React.DetailedHTMLProps<React.HTMLAttributes<HTMLDivElement>, HTMLDivElement>, "key" | keyof React.HTMLAttributes<HTMLDivElement>> & {
47
47
  ref?: React.Ref<HTMLDivElement>;
48
48
  } & {
49
49
  asChild?: boolean;
50
- }, keyof React.HTMLAttributes<HTMLDivElement> | "asChild" | "key"> & React.RefAttributes<HTMLDivElement>, "ref"> & React.RefAttributes<HTMLDivElement>>;
50
+ }, "key" | keyof React.HTMLAttributes<HTMLDivElement> | "asChild"> & React.RefAttributes<HTMLDivElement>, "ref"> & React.RefAttributes<HTMLDivElement>>;
51
51
  declare const CommandLoading: React.ForwardRefExoticComponent<Omit<{
52
52
  children?: React.ReactNode;
53
- } & Pick<Pick<React.DetailedHTMLProps<React.HTMLAttributes<HTMLDivElement>, HTMLDivElement>, keyof React.HTMLAttributes<HTMLDivElement> | "key"> & {
53
+ } & Pick<Pick<React.DetailedHTMLProps<React.HTMLAttributes<HTMLDivElement>, HTMLDivElement>, "key" | keyof React.HTMLAttributes<HTMLDivElement>> & {
54
54
  ref?: React.Ref<HTMLDivElement>;
55
55
  } & {
56
56
  asChild?: boolean;
57
- }, keyof React.HTMLAttributes<HTMLDivElement> | "asChild" | "key"> & {
57
+ }, "key" | keyof React.HTMLAttributes<HTMLDivElement> | "asChild"> & {
58
58
  progress?: number;
59
59
  label?: string;
60
60
  } & React.RefAttributes<HTMLDivElement>, "ref"> & React.RefAttributes<HTMLDivElement>>;
61
61
  declare const CommandGroup: React.ForwardRefExoticComponent<Omit<{
62
62
  children?: React.ReactNode;
63
- } & Omit<Pick<Pick<React.DetailedHTMLProps<React.HTMLAttributes<HTMLDivElement>, HTMLDivElement>, keyof React.HTMLAttributes<HTMLDivElement> | "key"> & {
63
+ } & Omit<Pick<Pick<React.DetailedHTMLProps<React.HTMLAttributes<HTMLDivElement>, HTMLDivElement>, "key" | keyof React.HTMLAttributes<HTMLDivElement>> & {
64
64
  ref?: React.Ref<HTMLDivElement>;
65
65
  } & {
66
66
  asChild?: boolean;
67
- }, keyof React.HTMLAttributes<HTMLDivElement> | "asChild" | "key">, "heading" | "value"> & {
67
+ }, "key" | keyof React.HTMLAttributes<HTMLDivElement> | "asChild">, "heading" | "value"> & {
68
68
  heading?: React.ReactNode;
69
69
  value?: string;
70
70
  forceMount?: boolean;
71
71
  } & React.RefAttributes<HTMLDivElement>, "ref"> & React.RefAttributes<HTMLDivElement>>;
72
- declare const CommandSeparator: React.ForwardRefExoticComponent<Omit<Pick<Pick<React.DetailedHTMLProps<React.HTMLAttributes<HTMLDivElement>, HTMLDivElement>, keyof React.HTMLAttributes<HTMLDivElement> | "key"> & {
72
+ declare const CommandSeparator: React.ForwardRefExoticComponent<Omit<Pick<Pick<React.DetailedHTMLProps<React.HTMLAttributes<HTMLDivElement>, HTMLDivElement>, "key" | keyof React.HTMLAttributes<HTMLDivElement>> & {
73
73
  ref?: React.Ref<HTMLDivElement>;
74
74
  } & {
75
75
  asChild?: boolean;
76
- }, keyof React.HTMLAttributes<HTMLDivElement> | "asChild" | "key"> & {
76
+ }, "key" | keyof React.HTMLAttributes<HTMLDivElement> | "asChild"> & {
77
77
  alwaysRender?: boolean;
78
78
  } & React.RefAttributes<HTMLDivElement>, "ref"> & React.RefAttributes<HTMLDivElement>>;
79
79
  declare const CommandItem: React.ForwardRefExoticComponent<Omit<{
80
80
  children?: React.ReactNode;
81
- } & Omit<Pick<Pick<React.DetailedHTMLProps<React.HTMLAttributes<HTMLDivElement>, HTMLDivElement>, keyof React.HTMLAttributes<HTMLDivElement> | "key"> & {
81
+ } & Omit<Pick<Pick<React.DetailedHTMLProps<React.HTMLAttributes<HTMLDivElement>, HTMLDivElement>, "key" | keyof React.HTMLAttributes<HTMLDivElement>> & {
82
82
  ref?: React.Ref<HTMLDivElement>;
83
83
  } & {
84
84
  asChild?: boolean;
85
- }, keyof React.HTMLAttributes<HTMLDivElement> | "asChild" | "key">, "onSelect" | "disabled" | "value"> & {
85
+ }, "key" | keyof React.HTMLAttributes<HTMLDivElement> | "asChild">, "onSelect" | "disabled" | "value"> & {
86
86
  disabled?: boolean;
87
87
  onSelect?: (value: string) => void;
88
88
  value?: string;
@@ -3,12 +3,12 @@ import * as React from 'react';
3
3
  import { VariantProps } from 'class-variance-authority';
4
4
 
5
5
  declare const itemVariants: (props?: ({
6
- variant?: "default" | "muted" | "outline" | null | undefined;
6
+ variant?: "default" | "outline" | "muted" | null | undefined;
7
7
  size?: "default" | "sm" | "lg" | null | undefined;
8
8
  interactive?: boolean | null | undefined;
9
9
  } & class_variance_authority_types.ClassProp) | undefined) => string;
10
10
  declare const Item: React.ForwardRefExoticComponent<React.HTMLAttributes<HTMLDivElement> & VariantProps<(props?: ({
11
- variant?: "default" | "muted" | "outline" | null | undefined;
11
+ variant?: "default" | "outline" | "muted" | null | undefined;
12
12
  size?: "default" | "sm" | "lg" | null | undefined;
13
13
  interactive?: boolean | null | undefined;
14
14
  } & class_variance_authority_types.ClassProp) | undefined) => string> & {
@@ -39,22 +39,25 @@ type MenuFilterChipsProps = {
39
39
  onToggle: (key: string) => void;
40
40
  /** The unit and the current total, composed by the caller, e.g. "KITCHENS · 247". */
41
41
  unitLabel?: string;
42
- /** Opens the full filter sheet. Omit for a row where every facet is a chip. */
42
+ /** Opens the full filter panel. Omit for a row where every facet is a chip. */
43
43
  onMore?: () => void;
44
44
  moreLabel?: string;
45
45
  /**
46
- * A glyph for the phone's icon-only "more filters" button.
46
+ * The glyph on the "more filters" pill.
47
47
  *
48
- * The label is the widest fixed element in the row — "More filters" is ~80px
49
- * of a 358px viewport, and it is the one thing that never scrolls, so it eats
50
- * the chips' runway on every phone. Given an icon, the button collapses to a
51
- * square below `lg` and keeps `moreLabel` as its accessible name.
48
+ * The pill is a chip like the others in the row — same border, same height —
49
+ * so the way out of the row reads as part of the row, not as a link beside
50
+ * it. The label is the widest fixed element in the row ("More filters" is
51
+ * ~80px of a 358px viewport) and the one thing that never scrolls, so below
52
+ * `lg` the pill collapses to a square carrying only the glyph, with
53
+ * `moreLabel` as its accessible name; from `lg` it shows both.
52
54
  *
53
55
  * A node rather than a name because the app owns its iconography, and this
54
56
  * one is animated on press — the DS neither knows the glyph nor drives it.
57
+ * Without a glyph the pill shows the label at every width.
55
58
  */
56
59
  moreIcon?: React.ReactNode;
57
- /** Press handler for the icon button, e.g. to replay the glyph's animation. */
60
+ /** Press handler for the pill, e.g. to replay the glyph's animation. */
58
61
  onMorePointerDown?: () => void;
59
62
  /** Locale-aware integer formatter for the counts. Defaults to `String`. */
60
63
  formatCount?: (value: number) => string;
@@ -3,7 +3,7 @@ import { Skeleton } from '../chunk-XBBEX4SF.js';
3
3
  import { cn } from '../chunk-FEK5XGZW.js';
4
4
  import '../chunk-MMUBH76A.js';
5
5
  import { X } from 'lucide-react';
6
- import { jsxs, jsx, Fragment } from 'react/jsx-runtime';
6
+ import { jsxs, jsx } from 'react/jsx-runtime';
7
7
 
8
8
  function MenuFilterChips({
9
9
  chips,
@@ -39,36 +39,28 @@ function MenuFilterChips({
39
39
  )) })
40
40
  }
41
41
  ),
42
- onMore && /* @__PURE__ */ jsxs(Fragment, { children: [
43
- moreIcon && /* @__PURE__ */ jsx(
44
- "button",
45
- {
46
- type: "button",
47
- onClick: onMore,
48
- onPointerDown: onMorePointerDown,
49
- "aria-label": moreLabel,
50
- "aria-haspopup": "dialog",
51
- className: cn(
52
- "inline-flex shrink-0 items-center justify-center rounded-full border border-border text-accent transition-colors hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring motion-reduce:transition-none lg:hidden",
53
- // The one control that never scrolls keeps the chips' height.
54
- chipSize === "lg" ? "size-11" : "size-10"
55
- ),
56
- children: moreIcon
57
- }
58
- ),
59
- /* @__PURE__ */ jsx(
60
- "button",
61
- {
62
- type: "button",
63
- onClick: onMore,
64
- className: cn(
65
- "shrink-0 rounded-md pl-2 text-[12.5px] font-bold text-accent transition-colors hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring motion-reduce:transition-none",
66
- moreIcon && "hidden lg:inline"
67
- ),
68
- children: moreLabel
69
- }
70
- )
71
- ] })
42
+ onMore && /* @__PURE__ */ jsxs(
43
+ "button",
44
+ {
45
+ type: "button",
46
+ onClick: onMore,
47
+ onPointerDown: onMorePointerDown,
48
+ "aria-label": moreLabel,
49
+ "aria-haspopup": "dialog",
50
+ className: cn(
51
+ // The chip's own chrome, so the pinned control and the scrolling
52
+ // chips read as one row. `shrink-0` against the scrolling chips.
53
+ "inline-flex shrink-0 items-center justify-center gap-1.5 whitespace-nowrap rounded-full border border-border bg-card text-sm text-foreground transition-colors hover:border-accent/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring motion-reduce:transition-none",
54
+ chipSize === "lg" ? "h-11" : "h-9",
55
+ // With a glyph: a square below `lg`, the glyph and the label from it.
56
+ moreIcon ? chipSize === "lg" ? "w-11 lg:w-auto lg:px-4" : "w-9 lg:w-auto lg:px-4" : "px-4"
57
+ ),
58
+ children: [
59
+ moreIcon,
60
+ /* @__PURE__ */ jsx("span", { className: cn(moreIcon && "hidden lg:inline"), children: moreLabel })
61
+ ]
62
+ }
63
+ )
72
64
  ] });
73
65
  }
74
66
  function Chip({
@@ -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
  ]
@@ -4,11 +4,11 @@ import { VariantProps } from 'class-variance-authority';
4
4
 
5
5
  declare const spinnerVariants: (props?: ({
6
6
  size?: "default" | "xs" | "sm" | "lg" | "xl" | null | undefined;
7
- tone?: "default" | "destructive" | "inherit" | "primary" | null | undefined;
7
+ tone?: "inherit" | "default" | "destructive" | "primary" | null | undefined;
8
8
  } & class_variance_authority_types.ClassProp) | undefined) => string;
9
9
  declare const Spinner: React.ForwardRefExoticComponent<React.HTMLAttributes<HTMLSpanElement> & VariantProps<(props?: ({
10
10
  size?: "default" | "xs" | "sm" | "lg" | "xl" | null | undefined;
11
- tone?: "default" | "destructive" | "inherit" | "primary" | null | undefined;
11
+ tone?: "inherit" | "default" | "destructive" | "primary" | null | undefined;
12
12
  } & class_variance_authority_types.ClassProp) | undefined) => string> & {
13
13
  /** Visually-hidden label for screen readers. Defaults to "Loading". */
14
14
  label?: string;
@@ -3,11 +3,11 @@ import * as React from 'react';
3
3
  import { VariantProps } from 'class-variance-authority';
4
4
 
5
5
  declare const tagVariants: (props?: ({
6
- variant?: "default" | "destructive" | "warning" | "success" | "primary" | "outline" | "quiet" | null | undefined;
6
+ variant?: "default" | "destructive" | "outline" | "warning" | "success" | "primary" | "quiet" | null | undefined;
7
7
  size?: "default" | "sm" | "lg" | null | undefined;
8
8
  } & class_variance_authority_types.ClassProp) | undefined) => string;
9
9
  declare const Tag: React.ForwardRefExoticComponent<React.HTMLAttributes<HTMLSpanElement> & VariantProps<(props?: ({
10
- variant?: "default" | "destructive" | "warning" | "success" | "primary" | "outline" | "quiet" | null | undefined;
10
+ variant?: "default" | "destructive" | "outline" | "warning" | "success" | "primary" | "quiet" | null | undefined;
11
11
  size?: "default" | "sm" | "lg" | null | undefined;
12
12
  } & class_variance_authority_types.ClassProp) | undefined) => string> & {
13
13
  onRemove?: () => void;
@@ -22,9 +22,9 @@ import { VariantProps } from 'class-variance-authority';
22
22
  * Weights are 400 / 600 / 700. 500 is not in the system.
23
23
  */
24
24
  declare const textVariants: (props?: ({
25
- variant?: "small" | "display" | "h1" | "h2" | "h3" | "h4" | "body-lg" | "body" | "body-sm" | "caption" | "label" | "overline" | null | undefined;
26
- color?: "default" | "destructive" | "inherit" | "muted" | "primary" | "primary-foreground" | null | undefined;
27
- weight?: "bold" | "normal" | "medium" | "semibold" | null | undefined;
25
+ variant?: "h2" | "h3" | "label" | "body" | "caption" | "h1" | "h4" | "small" | "display" | "body-lg" | "body-sm" | "overline" | null | undefined;
26
+ color?: "inherit" | "default" | "destructive" | "muted" | "primary" | "primary-foreground" | null | undefined;
27
+ weight?: "bold" | "medium" | "normal" | "semibold" | null | undefined;
28
28
  leading?: "none" | "normal" | "tight" | "snug" | "relaxed" | "loose" | null | undefined;
29
29
  tracking?: "normal" | "tight" | "wide" | "wider" | null | undefined;
30
30
  wrap?: "balance" | "pretty" | "nowrap" | null | undefined;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bearmenu/ui",
3
- "version": "0.8.19",
3
+ "version": "0.8.21",
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
  });
@@ -2,6 +2,7 @@ import { useState } from "react";
2
2
  import type { Meta, StoryObj } from "@storybook/react";
3
3
  import { Clock, Leaf, MapPin, Moon, Sun, Wallet } from "lucide-react";
4
4
  import { MenuFilterChips, MenuFilterSummary } from "./menu-filter-chips";
5
+ import { MotionSliders } from "./motion-icon-controls";
5
6
  import { mockFilterChips } from "./menu-mocks";
6
7
 
7
8
  const icons: Record<string, React.ReactNode> = {
@@ -30,7 +31,7 @@ const meta: Meta<typeof MenuFilterChips> = {
30
31
  docs: {
31
32
  description: {
32
33
  component:
33
- "Every chip counts KITCHENS, whatever facet is behind it — a dietary chip reads *has a vegetarian dish* and counts venues, not dishes. The unit is stated once at the head of the row and never again; the earlier version mixed venue counts and item counts and you could not tell what a number meant without re-reading the label that produced it. One line in every state: the chips scroll under a `ScrollFade`, and *More filters* is pinned opposite the unit label rather than wrapping.",
34
+ "Every chip counts KITCHENS, whatever facet is behind it — a dietary chip reads *has a vegetarian dish* and counts venues, not dishes. The unit is stated once at the head of the row and never again; the earlier version mixed venue counts and item counts and you could not tell what a number meant without re-reading the label that produced it. One line in every state: the chips scroll under a `ScrollFade`, and *More filters* is a chip-shaped pill pinned opposite the unit label rather than wrapping — a square carrying only its glyph below `lg`, glyph and label from it.",
34
35
  },
35
36
  },
36
37
  },
@@ -41,6 +42,7 @@ type Story = StoryObj<typeof MenuFilterChips>;
41
42
 
42
43
  function Interactive({ initial }: { initial: typeof chips }) {
43
44
  const [state, setState] = useState(initial);
45
+ const [replayToken, setReplayToken] = useState(0);
44
46
  const active = state.filter((chip) => chip.active).length;
45
47
 
46
48
  return (
@@ -49,6 +51,8 @@ function Interactive({ initial }: { initial: typeof chips }) {
49
51
  chips={state}
50
52
  unitLabel="KITCHENS · 247"
51
53
  moreLabel="More filters"
54
+ moreIcon={<MotionSliders replayToken={replayToken} className="size-4" />}
55
+ onMorePointerDown={() => setReplayToken((n) => n + 1)}
52
56
  onMore={() => {}}
53
57
  ariaLabel="Filter kitchens"
54
58
  onToggle={(key) =>
@@ -0,0 +1,63 @@
1
+ import { describe, it, expect, vi } from "vitest";
2
+ import { fireEvent, render, screen } from "@testing-library/react";
3
+ import { MenuFilterChips } from "./menu-filter-chips";
4
+ import { mockFilterChips } from "./menu-mocks";
5
+
6
+ describe("MenuFilterChips", () => {
7
+ /**
8
+ * The "more filters" control is ONE button at every width, shaped like the
9
+ * chips beside it. It used to be two elements — an icon square below `lg`
10
+ * and a bare text link from it — and the link read as a footnote rather
11
+ * than as the row's last chip.
12
+ */
13
+ it("renders the more control as a single chip-shaped pill carrying the glyph and the label", () => {
14
+ const onMore = vi.fn();
15
+ const onMorePointerDown = vi.fn();
16
+ render(
17
+ <MenuFilterChips
18
+ chips={mockFilterChips}
19
+ onToggle={() => {}}
20
+ moreLabel="More filters"
21
+ moreIcon={<svg data-testid="glyph" />}
22
+ onMore={onMore}
23
+ onMorePointerDown={onMorePointerDown}
24
+ />
25
+ );
26
+
27
+ const pills = screen.getAllByRole("button", { name: "More filters" });
28
+ expect(pills).toHaveLength(1);
29
+ const [pill] = pills;
30
+ expect(pill).toHaveAttribute("aria-haspopup", "dialog");
31
+ expect(pill.className).toContain("rounded-full");
32
+ expect(pill.className).toContain("border-border");
33
+ expect(pill.className).toContain("h-9");
34
+ expect(pill.className).toContain("lg:px-4");
35
+ expect(pill.querySelector("[data-testid='glyph']")).not.toBeNull();
36
+ expect(pill.querySelector("span")?.className).toContain("hidden lg:inline");
37
+
38
+ fireEvent.pointerDown(pill);
39
+ fireEvent.click(pill);
40
+ expect(onMorePointerDown).toHaveBeenCalledTimes(1);
41
+ expect(onMore).toHaveBeenCalledTimes(1);
42
+ });
43
+
44
+ it("shows the label at every width when no glyph is given", () => {
45
+ render(
46
+ <MenuFilterChips
47
+ chips={mockFilterChips}
48
+ onToggle={() => {}}
49
+ moreLabel="More filters"
50
+ onMore={() => {}}
51
+ />
52
+ );
53
+
54
+ const pill = screen.getByRole("button", { name: "More filters" });
55
+ expect(pill.className).toContain("px-4");
56
+ expect(pill.querySelector("span")?.className ?? "").not.toContain("hidden");
57
+ });
58
+
59
+ it("renders no more control without a handler", () => {
60
+ render(<MenuFilterChips chips={mockFilterChips} onToggle={() => {}} moreLabel="More filters" />);
61
+ expect(screen.queryByRole("button", { name: "More filters" })).toBeNull();
62
+ });
63
+ });
@@ -40,22 +40,25 @@ export type MenuFilterChipsProps = {
40
40
  onToggle: (key: string) => void;
41
41
  /** The unit and the current total, composed by the caller, e.g. "KITCHENS · 247". */
42
42
  unitLabel?: string;
43
- /** Opens the full filter sheet. Omit for a row where every facet is a chip. */
43
+ /** Opens the full filter panel. Omit for a row where every facet is a chip. */
44
44
  onMore?: () => void;
45
45
  moreLabel?: string;
46
46
  /**
47
- * A glyph for the phone's icon-only "more filters" button.
47
+ * The glyph on the "more filters" pill.
48
48
  *
49
- * The label is the widest fixed element in the row — "More filters" is ~80px
50
- * of a 358px viewport, and it is the one thing that never scrolls, so it eats
51
- * the chips' runway on every phone. Given an icon, the button collapses to a
52
- * square below `lg` and keeps `moreLabel` as its accessible name.
49
+ * The pill is a chip like the others in the row — same border, same height —
50
+ * so the way out of the row reads as part of the row, not as a link beside
51
+ * it. The label is the widest fixed element in the row ("More filters" is
52
+ * ~80px of a 358px viewport) and the one thing that never scrolls, so below
53
+ * `lg` the pill collapses to a square carrying only the glyph, with
54
+ * `moreLabel` as its accessible name; from `lg` it shows both.
53
55
  *
54
56
  * A node rather than a name because the app owns its iconography, and this
55
57
  * one is animated on press — the DS neither knows the glyph nor drives it.
58
+ * Without a glyph the pill shows the label at every width.
56
59
  */
57
60
  moreIcon?: React.ReactNode;
58
- /** Press handler for the icon button, e.g. to replay the glyph's animation. */
61
+ /** Press handler for the pill, e.g. to replay the glyph's animation. */
59
62
  onMorePointerDown?: () => void;
60
63
  /** Locale-aware integer formatter for the counts. Defaults to `String`. */
61
64
  formatCount?: (value: number) => string;
@@ -117,39 +120,24 @@ export function MenuFilterChips({
117
120
  </ScrollFade>
118
121
 
119
122
  {onMore && (
120
- <>
121
- {/* One control, two shapes. On a phone it is a 40px square carrying
122
- the caller's glyph, with `moreLabel` as its accessible name; from
123
- `lg` it is the text link it has always been. Two elements rather
124
- than one so neither has to carry the other's padding, and both
125
- stay `shrink-0` against the scrolling chips. */}
126
- {moreIcon && (
127
- <button
128
- type="button"
129
- onClick={onMore}
130
- onPointerDown={onMorePointerDown}
131
- aria-label={moreLabel}
132
- aria-haspopup="dialog"
133
- className={cn(
134
- "inline-flex shrink-0 items-center justify-center rounded-full border border-border text-accent transition-colors hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring motion-reduce:transition-none lg:hidden",
135
- // The one control that never scrolls keeps the chips' height.
136
- chipSize === "lg" ? "size-11" : "size-10"
137
- )}
138
- >
139
- {moreIcon}
140
- </button>
123
+ <button
124
+ type="button"
125
+ onClick={onMore}
126
+ onPointerDown={onMorePointerDown}
127
+ aria-label={moreLabel}
128
+ aria-haspopup="dialog"
129
+ className={cn(
130
+ // The chip's own chrome, so the pinned control and the scrolling
131
+ // chips read as one row. `shrink-0` against the scrolling chips.
132
+ "inline-flex shrink-0 items-center justify-center gap-1.5 whitespace-nowrap rounded-full border border-border bg-card text-sm text-foreground transition-colors hover:border-accent/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring motion-reduce:transition-none",
133
+ chipSize === "lg" ? "h-11" : "h-9",
134
+ // With a glyph: a square below `lg`, the glyph and the label from it.
135
+ moreIcon ? (chipSize === "lg" ? "w-11 lg:w-auto lg:px-4" : "w-9 lg:w-auto lg:px-4") : "px-4"
141
136
  )}
142
- <button
143
- type="button"
144
- onClick={onMore}
145
- className={cn(
146
- "shrink-0 rounded-md pl-2 text-[12.5px] font-bold text-accent transition-colors hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring motion-reduce:transition-none",
147
- moreIcon && "hidden lg:inline"
148
- )}
149
- >
150
- {moreLabel}
151
- </button>
152
- </>
137
+ >
138
+ {moreIcon}
139
+ <span className={cn(moreIcon && "hidden lg:inline")}>{moreLabel}</span>
140
+ </button>
153
141
  )}
154
142
  </div>
155
143
  );
@@ -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
  }}