@bearmenu/ui 0.8.20 → 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({
@@ -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.20",
3
+ "version": "0.8.21",
4
4
  "description": "BearMenu design system — shared UI tokens, primitives, and components",
5
5
  "license": "UNLICENSED",
6
6
  "repository": {
@@ -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
  );