@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.
- package/dist/components/badge.d.ts +1 -1
- package/dist/components/button.d.ts +1 -1
- package/dist/components/card.d.ts +2 -2
- package/dist/components/command.d.ts +15 -15
- package/dist/components/item.d.ts +2 -2
- package/dist/components/menu-filter-chips.d.ts +10 -7
- package/dist/components/menu-filter-chips.js +23 -31
- package/dist/components/poster-stack.d.ts +3 -1
- package/dist/components/poster-stack.js +6 -6
- package/dist/components/spinner.d.ts +2 -2
- package/dist/components/tag.d.ts +2 -2
- package/dist/components/text.d.ts +3 -3
- package/package.json +1 -1
- package/src/components/event-tonight-strip.test.tsx +21 -1
- package/src/components/menu-filter-chips.stories.tsx +5 -1
- package/src/components/menu-filter-chips.test.tsx +63 -0
- package/src/components/menu-filter-chips.tsx +27 -39
- package/src/components/poster-stack.tsx +19 -6
|
@@ -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" | "
|
|
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?: "
|
|
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?: "
|
|
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?: "
|
|
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
|
|
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"
|
|
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
|
-
}, "
|
|
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
|
|
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"
|
|
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
|
|
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"
|
|
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
|
|
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"
|
|
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
|
|
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"
|
|
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
|
|
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"
|
|
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
|
|
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"
|
|
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" | "
|
|
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" | "
|
|
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
|
|
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
|
-
*
|
|
46
|
+
* The glyph on the "more filters" pill.
|
|
47
47
|
*
|
|
48
|
-
* The
|
|
49
|
-
*
|
|
50
|
-
*
|
|
51
|
-
*
|
|
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
|
|
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
|
|
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(
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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
|
-
/**
|
|
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
|
|
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(
|
|
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.
|
|
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?: "
|
|
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?: "
|
|
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;
|
package/dist/components/tag.d.ts
CHANGED
|
@@ -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" | "
|
|
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" | "
|
|
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?: "
|
|
26
|
-
color?: "
|
|
27
|
-
weight?: "bold" | "
|
|
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,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
|
|
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
|
-
*
|
|
47
|
+
* The glyph on the "more filters" pill.
|
|
48
48
|
*
|
|
49
|
-
* The
|
|
50
|
-
*
|
|
51
|
-
*
|
|
52
|
-
*
|
|
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
|
|
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
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
{
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
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
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
|
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={() =>
|
|
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
|
-
|
|
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
|
}}
|