@bearmenu/ui 0.8.44 → 0.8.46
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/{chunk-VXGIDKXV.js → chunk-4WW3HPIW.js} +39 -13
- package/dist/{chunk-AHL45DD2.js → chunk-HJTENOB2.js} +31 -16
- package/dist/components/badge.d.ts +1 -1
- package/dist/components/button.d.ts +2 -2
- package/dist/components/command.d.ts +3 -3
- package/dist/components/drift-rail.js +1 -1
- package/dist/components/event-index-row.js +3 -3
- package/dist/components/menu-index-list.js +2 -2
- package/dist/components/menu-item.d.ts +28 -1
- package/dist/components/menu-item.js +1 -1
- package/dist/components/poster-stack.d.ts +3 -0
- package/dist/components/poster-stack.js +2 -1
- package/dist/components/poster-tape.js +1 -1
- package/dist/components/section-header.js +3 -3
- package/dist/components/spinner.d.ts +2 -2
- package/dist/components/tag.d.ts +2 -2
- package/dist/components/text.d.ts +2 -2
- package/dist/components/top-ranking-entry.js +1 -1
- package/package.json +1 -1
- package/src/components/drift-rail.test.tsx +101 -0
- package/src/components/drift-rail.tsx +45 -23
- package/src/components/event-index-row.tsx +3 -3
- package/src/components/menu-index-list.tsx +2 -2
- package/src/components/menu-item.test.tsx +57 -0
- package/src/components/menu-item.tsx +61 -10
- package/src/components/poster-stack.test.tsx +47 -0
- package/src/components/poster-stack.tsx +5 -1
- package/src/components/section-header.tsx +3 -3
|
@@ -2,7 +2,7 @@ import { SignatureChip } from './chunk-6PQINSAA.js';
|
|
|
2
2
|
import { cn } from './chunk-FEK5XGZW.js';
|
|
3
3
|
import { __objRest, __spreadValues } from './chunk-MMUBH76A.js';
|
|
4
4
|
import { memo } from 'react';
|
|
5
|
-
import { MessageSquareQuote } from 'lucide-react';
|
|
5
|
+
import { Star, MessageSquareQuote } from 'lucide-react';
|
|
6
6
|
import { jsxs, jsx } from 'react/jsx-runtime';
|
|
7
7
|
|
|
8
8
|
function DefaultLink(_a) {
|
|
@@ -51,6 +51,20 @@ function MentionSignal({
|
|
|
51
51
|
}
|
|
52
52
|
);
|
|
53
53
|
}
|
|
54
|
+
function ExternalRatingSignal({ label, value }) {
|
|
55
|
+
return /* @__PURE__ */ jsxs(
|
|
56
|
+
"span",
|
|
57
|
+
{
|
|
58
|
+
role: "img",
|
|
59
|
+
"aria-label": label,
|
|
60
|
+
className: "inline-flex shrink-0 items-center gap-0.5 text-[13px] font-semibold text-muted-foreground",
|
|
61
|
+
children: [
|
|
62
|
+
/* @__PURE__ */ jsx(Star, { className: "h-3 w-3 fill-current opacity-70", "aria-hidden": true }),
|
|
63
|
+
/* @__PURE__ */ jsx("span", { className: "tabular-nums", children: value })
|
|
64
|
+
]
|
|
65
|
+
}
|
|
66
|
+
);
|
|
67
|
+
}
|
|
54
68
|
var MenuItem = memo(function MenuItem2({
|
|
55
69
|
item,
|
|
56
70
|
labels,
|
|
@@ -61,6 +75,8 @@ var MenuItem = memo(function MenuItem2({
|
|
|
61
75
|
signatureTier = "strong",
|
|
62
76
|
onSignatureEvidence,
|
|
63
77
|
onMentions,
|
|
78
|
+
externalRating,
|
|
79
|
+
formatRating = (score) => score.toFixed(1),
|
|
64
80
|
markGradient,
|
|
65
81
|
venueName,
|
|
66
82
|
subtitle,
|
|
@@ -70,7 +86,7 @@ var MenuItem = memo(function MenuItem2({
|
|
|
70
86
|
disabled = false,
|
|
71
87
|
className
|
|
72
88
|
}) {
|
|
73
|
-
var _a, _b, _c, _d, _e;
|
|
89
|
+
var _a, _b, _c, _d, _e, _f, _g;
|
|
74
90
|
const firstEntry = (_a = item.sizes_and_prices) == null ? void 0 : _a[0];
|
|
75
91
|
const quantity = firstEntry == null ? void 0 : firstEntry.size;
|
|
76
92
|
const hasQuantity = quantity != null && quantity > 0;
|
|
@@ -79,6 +95,7 @@ var MenuItem = memo(function MenuItem2({
|
|
|
79
95
|
const maxPrice = allPrices.length > 0 ? Math.max(...allPrices) : null;
|
|
80
96
|
const isPriceRange = minPrice != null && maxPrice != null && minPrice !== maxPrice;
|
|
81
97
|
const mentionCount = (_e = (_d = item.mentions) != null ? _d : item.review_count) != null ? _e : 0;
|
|
98
|
+
const ratingText = externalRating != null ? formatRating(externalRating) : null;
|
|
82
99
|
const isInert = !href && !onOpen;
|
|
83
100
|
const isActionable = !disabled && !isInert;
|
|
84
101
|
const handleRowClick = () => {
|
|
@@ -183,7 +200,7 @@ var MenuItem = memo(function MenuItem2({
|
|
|
183
200
|
children: item.description
|
|
184
201
|
}
|
|
185
202
|
),
|
|
186
|
-
(minPrice != null || hasQuantity || mentionCount > 0) && /* @__PURE__ */ jsxs("div", { className: "mt-1.5 flex items-baseline justify-between gap-x-2 gap-y-1", children: [
|
|
203
|
+
(minPrice != null || hasQuantity || mentionCount > 0 || ratingText != null) && /* @__PURE__ */ jsxs("div", { className: "mt-1.5 flex items-baseline justify-between gap-x-2 gap-y-1", children: [
|
|
187
204
|
/* @__PURE__ */ jsxs("div", { className: "flex min-w-0 items-baseline gap-x-2", children: [
|
|
188
205
|
minPrice != null && /* @__PURE__ */ jsxs("span", { className: "flex items-baseline gap-1", children: [
|
|
189
206
|
/* @__PURE__ */ jsx("span", { className: "text-[15px] font-bold tabular-nums text-foreground", children: isPriceRange ? `${formatPrice(minPrice)}\u2013${formatPrice(maxPrice)}` : formatPrice(minPrice) }),
|
|
@@ -194,15 +211,24 @@ var MenuItem = memo(function MenuItem2({
|
|
|
194
211
|
item.kind === "dish" ? "g" : "ml"
|
|
195
212
|
] })
|
|
196
213
|
] }),
|
|
197
|
-
mentionCount > 0 && /* @__PURE__ */
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
214
|
+
(ratingText != null || mentionCount > 0) && /* @__PURE__ */ jsxs("div", { className: "flex shrink-0 items-center gap-x-3", children: [
|
|
215
|
+
ratingText != null && /* @__PURE__ */ jsx(
|
|
216
|
+
ExternalRatingSignal,
|
|
217
|
+
{
|
|
218
|
+
label: (_g = (_f = labels.externalRating) == null ? void 0 : _f.call(labels, ratingText)) != null ? _g : ratingText,
|
|
219
|
+
value: ratingText
|
|
220
|
+
}
|
|
221
|
+
),
|
|
222
|
+
mentionCount > 0 && /* @__PURE__ */ jsx(
|
|
223
|
+
MentionSignal,
|
|
224
|
+
{
|
|
225
|
+
label: labels.mentions(mentionCount),
|
|
226
|
+
count: mentionCount,
|
|
227
|
+
onMentions,
|
|
228
|
+
formatCount
|
|
229
|
+
}
|
|
230
|
+
)
|
|
231
|
+
] })
|
|
206
232
|
] })
|
|
207
233
|
] })
|
|
208
234
|
]
|
|
@@ -210,4 +236,4 @@ var MenuItem = memo(function MenuItem2({
|
|
|
210
236
|
);
|
|
211
237
|
});
|
|
212
238
|
|
|
213
|
-
export { MentionSignal, MenuItem };
|
|
239
|
+
export { ExternalRatingSignal, MentionSignal, MenuItem };
|
|
@@ -36,35 +36,50 @@ function DriftRail({
|
|
|
36
36
|
observer.observe(node);
|
|
37
37
|
return () => observer.disconnect();
|
|
38
38
|
}, []);
|
|
39
|
+
const half = useRef(0);
|
|
40
|
+
useEffect(() => {
|
|
41
|
+
const track = trackRef.current;
|
|
42
|
+
if (!track || typeof ResizeObserver === "undefined") return void 0;
|
|
43
|
+
const observer = new ResizeObserver(() => {
|
|
44
|
+
half.current = track.scrollWidth / 2;
|
|
45
|
+
});
|
|
46
|
+
observer.observe(track);
|
|
47
|
+
return () => observer.disconnect();
|
|
48
|
+
}, []);
|
|
49
|
+
useEffect(() => {
|
|
50
|
+
const scroller = scrollerRef.current;
|
|
51
|
+
if (!scroller) return void 0;
|
|
52
|
+
const onScroll = () => {
|
|
53
|
+
if (half.current > 0 && scroller.scrollLeft >= half.current) {
|
|
54
|
+
scroller.scrollLeft -= half.current;
|
|
55
|
+
}
|
|
56
|
+
};
|
|
57
|
+
scroller.addEventListener("scroll", onScroll, { passive: true });
|
|
58
|
+
return () => scroller.removeEventListener("scroll", onScroll);
|
|
59
|
+
}, []);
|
|
39
60
|
useEffect(() => {
|
|
40
61
|
const scroller = scrollerRef.current;
|
|
41
62
|
const track = trackRef.current;
|
|
42
63
|
if (!scroller || !track) return void 0;
|
|
64
|
+
if (reverse && scroller.scrollLeft === 0) scroller.scrollLeft = track.scrollWidth / 2;
|
|
65
|
+
const drifting = !reducedMotion && inView && !(pauseOnHover && isHovered);
|
|
66
|
+
if (!drifting) return void 0;
|
|
43
67
|
let frame = 0;
|
|
44
68
|
let last = performance.now();
|
|
45
|
-
const drifting = !reducedMotion && inView && !(pauseOnHover && isHovered);
|
|
46
|
-
if (reverse && scroller.scrollLeft === 0) scroller.scrollLeft = track.scrollWidth / 2;
|
|
47
69
|
position.current = scroller.scrollLeft;
|
|
48
70
|
const tick = (now) => {
|
|
49
|
-
const
|
|
50
|
-
if (
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
const perSecond = half / durationSeconds;
|
|
71
|
+
const width = half.current;
|
|
72
|
+
if (width > 0) {
|
|
73
|
+
if (now >= heldUntil.current) {
|
|
74
|
+
const perSecond = width / durationSeconds;
|
|
54
75
|
const delta = perSecond * (now - last) / 1e3;
|
|
55
76
|
position.current += reverse ? -delta : delta;
|
|
77
|
+
if (position.current >= width) position.current -= width;
|
|
78
|
+
else if (position.current < 0) position.current += width;
|
|
79
|
+
scroller.scrollLeft = position.current;
|
|
56
80
|
} else {
|
|
57
81
|
position.current = scroller.scrollLeft;
|
|
58
82
|
}
|
|
59
|
-
let wrapped = false;
|
|
60
|
-
if (position.current >= half) {
|
|
61
|
-
position.current -= half;
|
|
62
|
-
wrapped = true;
|
|
63
|
-
} else if (position.current < 0) {
|
|
64
|
-
position.current += half;
|
|
65
|
-
wrapped = true;
|
|
66
|
-
}
|
|
67
|
-
if (driftingNow || wrapped) scroller.scrollLeft = position.current;
|
|
68
83
|
}
|
|
69
84
|
last = now;
|
|
70
85
|
frame = requestAnimationFrame(tick);
|
|
@@ -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?: "
|
|
7
|
+
variant?: "secondary" | "default" | "success" | "warning" | "destructive" | "outline" | "glass" | "overlay" | "brown" | "code" | "onPrimary" | "confirmed" | "pending" | "completed" | "assignee" | "softSuccess" | "softWarning" | "softDestructive" | "softNeutral" | null | undefined;
|
|
8
8
|
size?: "status" | "chip" | null | undefined;
|
|
9
9
|
live?: boolean | null | undefined;
|
|
10
10
|
} & class_variance_authority_types.ClassProp) | undefined) => string;
|
|
@@ -3,8 +3,8 @@ import * as React from 'react';
|
|
|
3
3
|
import { VariantProps } from 'class-variance-authority';
|
|
4
4
|
|
|
5
5
|
declare const buttonVariants: (props?: ({
|
|
6
|
-
variant?: "
|
|
7
|
-
size?: "
|
|
6
|
+
variant?: "secondary" | "default" | "link" | "warning" | "destructive" | "outline" | "ghost" | "glass" | "overlay" | "ink" | "brown" | "cta" | null | undefined;
|
|
7
|
+
size?: "default" | "icon" | "cta" | "xs" | "sm" | "lg" | "pill" | "cta-sm" | "icon-xs" | "icon-sm" | "icon-lg" | null | undefined;
|
|
8
8
|
fullWidth?: boolean | null | undefined;
|
|
9
9
|
} & class_variance_authority_types.ClassProp) | undefined) => string;
|
|
10
10
|
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement>, VariantProps<typeof buttonVariants> {
|
|
@@ -28,7 +28,7 @@ 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>>, "
|
|
31
|
+
}, "asChild" | "key" | keyof React.InputHTMLAttributes<HTMLInputElement>>, "onChange" | "value" | "type"> & {
|
|
32
32
|
value?: string;
|
|
33
33
|
onValueChange?: (search: string) => void;
|
|
34
34
|
} & React.RefAttributes<HTMLInputElement>, "ref"> & React.RefAttributes<HTMLInputElement>>;
|
|
@@ -64,7 +64,7 @@ declare const CommandGroup: React.ForwardRefExoticComponent<Omit<{
|
|
|
64
64
|
ref?: React.Ref<HTMLDivElement>;
|
|
65
65
|
} & {
|
|
66
66
|
asChild?: boolean;
|
|
67
|
-
}, keyof React.HTMLAttributes<HTMLDivElement> | "asChild" | "key">, "
|
|
67
|
+
}, keyof React.HTMLAttributes<HTMLDivElement> | "asChild" | "key">, "heading" | "value"> & {
|
|
68
68
|
heading?: React.ReactNode;
|
|
69
69
|
value?: string;
|
|
70
70
|
forceMount?: boolean;
|
|
@@ -82,7 +82,7 @@ declare const CommandItem: React.ForwardRefExoticComponent<Omit<{
|
|
|
82
82
|
ref?: React.Ref<HTMLDivElement>;
|
|
83
83
|
} & {
|
|
84
84
|
asChild?: boolean;
|
|
85
|
-
}, keyof React.HTMLAttributes<HTMLDivElement> | "asChild" | "key">, "
|
|
85
|
+
}, keyof React.HTMLAttributes<HTMLDivElement> | "asChild" | "key">, "onSelect" | "value" | "disabled"> & {
|
|
86
86
|
disabled?: boolean;
|
|
87
87
|
onSelect?: (value: string) => void;
|
|
88
88
|
value?: string;
|
|
@@ -29,7 +29,7 @@ function EventIndexRow({
|
|
|
29
29
|
const dot = /* @__PURE__ */ jsx("span", { className: "px-1 text-border", "aria-hidden": true, children: "\xB7" });
|
|
30
30
|
return /* @__PURE__ */ jsxs("div", { className: cn("relative", className), children: [
|
|
31
31
|
!hideRule && /* @__PURE__ */ jsx(Separator, { className: "absolute inset-x-0 top-0" }),
|
|
32
|
-
/* @__PURE__ */ jsxs("div", { className: "relative -mx-3 flex gap-3 rounded-lg px-3 py-4 transition-colors duration-150 hover:bg-card/60 motion-reduce:transition-none md:gap-4 md:py-5", children: [
|
|
32
|
+
/* @__PURE__ */ jsxs("div", { className: "relative -mx-3 flex gap-3 rounded-lg px-3 py-4 transition-colors duration-150 hover:bg-card/60 active:bg-card/60 motion-reduce:transition-none md:gap-4 md:py-5", children: [
|
|
33
33
|
/* @__PURE__ */ jsxs("div", { className: "relative aspect-square w-[72px] shrink-0 overflow-hidden rounded-[9px] bg-muted md:w-[104px]", children: [
|
|
34
34
|
/* @__PURE__ */ jsx("div", { className: "absolute inset-0", children: fallback }),
|
|
35
35
|
posterUrl && // eslint-disable-next-line @next/next/no-img-element
|
|
@@ -49,7 +49,7 @@ function EventIndexRow({
|
|
|
49
49
|
LinkComponent,
|
|
50
50
|
{
|
|
51
51
|
href,
|
|
52
|
-
className: "rounded-sm transition-colors duration-150 after:absolute after:inset-0 hover:text-primary motion-reduce:transition-none focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
|
52
|
+
className: "rounded-sm transition-colors duration-150 after:absolute after:inset-0 hover:text-primary active:text-primary motion-reduce:transition-none focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
|
53
53
|
children: title
|
|
54
54
|
}
|
|
55
55
|
) }),
|
|
@@ -61,7 +61,7 @@ function EventIndexRow({
|
|
|
61
61
|
LinkComponent,
|
|
62
62
|
{
|
|
63
63
|
href: venueHref,
|
|
64
|
-
className: "relative z-10 rounded-sm font-semibold text-foreground/85 underline decoration-border underline-offset-2 transition-colors duration-150 hover:text-primary hover:decoration-primary motion-reduce:transition-none focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
|
64
|
+
className: "relative z-10 rounded-sm font-semibold text-foreground/85 underline decoration-border underline-offset-2 transition-colors duration-150 hover:text-primary hover:decoration-primary active:text-primary active:decoration-primary motion-reduce:transition-none focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
|
65
65
|
children: venue
|
|
66
66
|
}
|
|
67
67
|
) : /* @__PURE__ */ jsx("span", { className: "font-semibold text-foreground/85", children: venue })
|
|
@@ -52,7 +52,7 @@ function MenuIndexList({
|
|
|
52
52
|
LinkComponent,
|
|
53
53
|
{
|
|
54
54
|
href: entry.href,
|
|
55
|
-
className: "group flex items-center gap-2.5 py-2.5 transition-colors hover:text-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring motion-reduce:transition-none",
|
|
55
|
+
className: "group flex items-center gap-2.5 py-2.5 transition-colors hover:text-accent active:text-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring motion-reduce:transition-none",
|
|
56
56
|
children: [
|
|
57
57
|
/* @__PURE__ */ jsx(
|
|
58
58
|
"span",
|
|
@@ -67,7 +67,7 @@ function MenuIndexList({
|
|
|
67
67
|
/* @__PURE__ */ jsx(
|
|
68
68
|
ChevronRight,
|
|
69
69
|
{
|
|
70
|
-
className: "h-3.5 w-3.5 shrink-0 text-muted-foreground transition-colors group-hover:text-accent motion-reduce:transition-none",
|
|
70
|
+
className: "h-3.5 w-3.5 shrink-0 text-muted-foreground transition-colors group-hover:text-accent group-active:text-accent motion-reduce:transition-none",
|
|
71
71
|
"aria-hidden": true
|
|
72
72
|
}
|
|
73
73
|
)
|
|
@@ -57,6 +57,12 @@ type MenuItemLabels = {
|
|
|
57
57
|
signature: string;
|
|
58
58
|
/** Chip text for a `weak` (merely mentioned) item. */
|
|
59
59
|
mentioned: string;
|
|
60
|
+
/**
|
|
61
|
+
* Fully-worded external rating, e.g. "Rated 4.1 on Vivino" — the accessible
|
|
62
|
+
* name for the star-and-score pair. Receives the FORMATTED score. Falls back
|
|
63
|
+
* to the bare score when omitted.
|
|
64
|
+
*/
|
|
65
|
+
externalRating?: (score: string) => string;
|
|
60
66
|
};
|
|
61
67
|
type MenuItemProps = {
|
|
62
68
|
item: MenuItemPreview;
|
|
@@ -79,6 +85,17 @@ type MenuItemProps = {
|
|
|
79
85
|
onSignatureEvidence?: () => void;
|
|
80
86
|
/** Opens the reviews behind the mention count. */
|
|
81
87
|
onMentions?: () => void;
|
|
88
|
+
/**
|
|
89
|
+
* An outside aggregate score for the item (a wine's Vivino rating, a beer's
|
|
90
|
+
* Untappd one), shown as a star and the score beside the mention count.
|
|
91
|
+
*
|
|
92
|
+
* The caller GATES it: those scales run low and a thin vote count is noise,
|
|
93
|
+
* so the app decides whether a score is fit to show and passes nothing when
|
|
94
|
+
* it is not. The DS renders what it is given and applies no floor of its own.
|
|
95
|
+
*/
|
|
96
|
+
externalRating?: number;
|
|
97
|
+
/** Formats `externalRating`. Defaults to one decimal place. */
|
|
98
|
+
formatRating?: (score: number) => string;
|
|
82
99
|
/** The mark's colour, as a CSS `background-image` value. */
|
|
83
100
|
markGradient?: string;
|
|
84
101
|
/**
|
|
@@ -123,6 +140,16 @@ declare function MentionSignal({ label, count, onMentions, formatCount, }: {
|
|
|
123
140
|
onMentions?: () => void;
|
|
124
141
|
formatCount?: (value: number) => string;
|
|
125
142
|
}): react_jsx_runtime.JSX.Element;
|
|
143
|
+
/**
|
|
144
|
+
* An external aggregate score: a star and the numeral, in the neutral muted
|
|
145
|
+
* tone. Deliberately quieter than the mention count beside it (lighter weight,
|
|
146
|
+
* a step smaller, no accent) — the census count is this app's own evidence;
|
|
147
|
+
* the star is someone else's, and it must not outrank or be mistaken for it.
|
|
148
|
+
*/
|
|
149
|
+
declare function ExternalRatingSignal({ label, value }: {
|
|
150
|
+
label: string;
|
|
151
|
+
value: string;
|
|
152
|
+
}): react_jsx_runtime.JSX.Element;
|
|
126
153
|
declare const MenuItem: React.NamedExoticComponent<MenuItemProps>;
|
|
127
154
|
|
|
128
|
-
export { MentionSignal, MenuItem, type MenuItemLabels, type MenuItemLinkProps, type MenuItemProps };
|
|
155
|
+
export { ExternalRatingSignal, MentionSignal, MenuItem, type MenuItemLabels, type MenuItemLinkProps, type MenuItemProps };
|
|
@@ -50,6 +50,9 @@ type PosterStackPoster = {
|
|
|
50
50
|
caption?: string;
|
|
51
51
|
/** Rendered when `src` is absent or fails. Defaults to `<EventPosterFallback />`. */
|
|
52
52
|
fallback?: React.ReactNode;
|
|
53
|
+
/** Load now at high priority instead of lazily. For the poster that is the
|
|
54
|
+
* page's LCP element; every other poster stays lazy. */
|
|
55
|
+
eager?: boolean;
|
|
53
56
|
};
|
|
54
57
|
type PosterStackProps = {
|
|
55
58
|
/** Candidates in order. The first three whose image loads are shown, so
|
|
@@ -85,7 +85,8 @@ function PosterStack({
|
|
|
85
85
|
{
|
|
86
86
|
src: poster.src,
|
|
87
87
|
alt: poster.caption ? "" : poster.alt,
|
|
88
|
-
loading: "lazy",
|
|
88
|
+
loading: poster.eager ? "eager" : "lazy",
|
|
89
|
+
fetchPriority: poster.eager ? "high" : void 0,
|
|
89
90
|
decoding: "async",
|
|
90
91
|
className: cn(
|
|
91
92
|
"h-full w-full object-cover",
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { DriftRail } from '../chunk-
|
|
1
|
+
import { DriftRail } from '../chunk-HJTENOB2.js';
|
|
2
2
|
import { EventPosterFallback } from '../chunk-FDITZ2VM.js';
|
|
3
3
|
import { cn } from '../chunk-FEK5XGZW.js';
|
|
4
4
|
import { __objRest, __spreadValues } from '../chunk-MMUBH76A.js';
|
|
@@ -14,10 +14,10 @@ var ACTION_BASE_CLASSES = cn(
|
|
|
14
14
|
"cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
|
15
15
|
);
|
|
16
16
|
var ACTION_TONE_CLASSES = {
|
|
17
|
-
muted: "text-[14px] text-muted-foreground hover:text-foreground",
|
|
17
|
+
muted: "text-[14px] text-muted-foreground hover:text-foreground active:text-foreground",
|
|
18
18
|
app: cn(
|
|
19
|
-
"text-[12.5px] font-semibold text-accent hover:text-accent/80",
|
|
20
|
-
"lg:text-[14px] lg:font-normal lg:text-muted-foreground lg:hover:text-foreground"
|
|
19
|
+
"text-[12.5px] font-semibold text-accent hover:text-accent/80 active:text-accent/80",
|
|
20
|
+
"lg:text-[14px] lg:font-normal lg:text-muted-foreground lg:hover:text-foreground lg:active:text-foreground"
|
|
21
21
|
)
|
|
22
22
|
};
|
|
23
23
|
function SectionHeader({
|
|
@@ -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?: "primary" | "default" | "inherit" | "destructive" | 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?: "primary" | "default" | "inherit" | "destructive" | 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?: "
|
|
6
|
+
variant?: "primary" | "default" | "success" | "warning" | "destructive" | "outline" | "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?: "
|
|
10
|
+
variant?: "primary" | "default" | "success" | "warning" | "destructive" | "outline" | "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,8 +22,8 @@ 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?: "
|
|
25
|
+
variant?: "small" | "body" | "display" | "h2" | "h3" | "label" | "caption" | "h1" | "h4" | "body-lg" | "body-sm" | "overline" | null | undefined;
|
|
26
|
+
color?: "primary" | "default" | "inherit" | "destructive" | "muted" | "primary-foreground" | null | undefined;
|
|
27
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;
|
package/package.json
CHANGED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
|
2
|
+
import { act, fireEvent, render } from "@testing-library/react";
|
|
3
|
+
import { DriftRail } from "./drift-rail";
|
|
4
|
+
|
|
5
|
+
let intersect: (isIntersecting: boolean) => void = () => {};
|
|
6
|
+
let resize: () => void = () => {};
|
|
7
|
+
let frames = new Map<number, (now: number) => void>();
|
|
8
|
+
let nextFrame = 0;
|
|
9
|
+
|
|
10
|
+
beforeEach(() => {
|
|
11
|
+
frames = new Map();
|
|
12
|
+
vi.stubGlobal("matchMedia", () => ({
|
|
13
|
+
matches: false,
|
|
14
|
+
addEventListener() {},
|
|
15
|
+
removeEventListener() {},
|
|
16
|
+
}));
|
|
17
|
+
vi.stubGlobal(
|
|
18
|
+
"IntersectionObserver",
|
|
19
|
+
class {
|
|
20
|
+
constructor(callback: (entries: Array<{ isIntersecting: boolean }>) => void) {
|
|
21
|
+
intersect = (isIntersecting) => callback([{ isIntersecting }]);
|
|
22
|
+
}
|
|
23
|
+
observe() {}
|
|
24
|
+
disconnect() {}
|
|
25
|
+
}
|
|
26
|
+
);
|
|
27
|
+
vi.stubGlobal(
|
|
28
|
+
"ResizeObserver",
|
|
29
|
+
class {
|
|
30
|
+
constructor(callback: () => void) {
|
|
31
|
+
resize = callback;
|
|
32
|
+
}
|
|
33
|
+
observe() {}
|
|
34
|
+
disconnect() {}
|
|
35
|
+
}
|
|
36
|
+
);
|
|
37
|
+
vi.stubGlobal("requestAnimationFrame", (callback: (now: number) => void) => {
|
|
38
|
+
nextFrame += 1;
|
|
39
|
+
frames.set(nextFrame, callback);
|
|
40
|
+
return nextFrame;
|
|
41
|
+
});
|
|
42
|
+
vi.stubGlobal("cancelAnimationFrame", (id: number) => frames.delete(id));
|
|
43
|
+
});
|
|
44
|
+
afterEach(() => {
|
|
45
|
+
vi.unstubAllGlobals();
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
/** A rail whose track is 1000px wide (two 500px copies). */
|
|
49
|
+
function renderRail() {
|
|
50
|
+
const { container } = render(
|
|
51
|
+
<DriftRail durationSeconds={10}>
|
|
52
|
+
<span>a</span>
|
|
53
|
+
<span>b</span>
|
|
54
|
+
</DriftRail>
|
|
55
|
+
);
|
|
56
|
+
const scroller = container.firstElementChild as HTMLDivElement;
|
|
57
|
+
const track = scroller.firstElementChild as HTMLDivElement;
|
|
58
|
+
const scrollWidth = vi.fn(() => 1000);
|
|
59
|
+
Object.defineProperty(track, "scrollWidth", { get: scrollWidth });
|
|
60
|
+
act(() => resize());
|
|
61
|
+
return { scroller, scrollWidth };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function runFrame(now: number) {
|
|
65
|
+
const pending = [...frames.values()];
|
|
66
|
+
frames.clear();
|
|
67
|
+
pending.forEach((frame) => frame(now));
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
describe("DriftRail", () => {
|
|
71
|
+
it("drifts at one copy per duration without measuring the track each frame", () => {
|
|
72
|
+
const { scroller, scrollWidth } = renderRail();
|
|
73
|
+
const readsAfterMount = scrollWidth.mock.calls.length;
|
|
74
|
+
const start = performance.now();
|
|
75
|
+
|
|
76
|
+
runFrame(start + 1000);
|
|
77
|
+
runFrame(start + 2000);
|
|
78
|
+
|
|
79
|
+
// 500px per 10s is 50px per second.
|
|
80
|
+
expect(scroller.scrollLeft).toBeGreaterThan(0);
|
|
81
|
+
expect(scrollWidth.mock.calls.length).toBe(readsAfterMount);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("stops the frame loop entirely while off screen", () => {
|
|
85
|
+
renderRail();
|
|
86
|
+
act(() => intersect(false));
|
|
87
|
+
runFrame(performance.now() + 16);
|
|
88
|
+
expect(frames.size).toBe(0);
|
|
89
|
+
|
|
90
|
+
act(() => intersect(true));
|
|
91
|
+
expect(frames.size).toBeGreaterThan(0);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("wraps the reader's own scroll by one copy, with no frame loop running", () => {
|
|
95
|
+
const { scroller } = renderRail();
|
|
96
|
+
act(() => intersect(false));
|
|
97
|
+
scroller.scrollLeft = 620;
|
|
98
|
+
fireEvent.scroll(scroller);
|
|
99
|
+
expect(scroller.scrollLeft).toBe(120);
|
|
100
|
+
});
|
|
101
|
+
});
|
|
@@ -88,44 +88,66 @@ export function DriftRail({
|
|
|
88
88
|
return () => observer.disconnect();
|
|
89
89
|
}, []);
|
|
90
90
|
|
|
91
|
-
//
|
|
92
|
-
//
|
|
93
|
-
//
|
|
91
|
+
// One copy's width, kept by a ResizeObserver. Read off the track on every
|
|
92
|
+
// frame, it forced a style and layout pass per frame (measured on /events at
|
|
93
|
+
// up to 558ms of recalc per second).
|
|
94
|
+
const half = useRef(0);
|
|
95
|
+
useEffect(() => {
|
|
96
|
+
const track = trackRef.current;
|
|
97
|
+
if (!track || typeof ResizeObserver === "undefined") return undefined;
|
|
98
|
+
const observer = new ResizeObserver(() => {
|
|
99
|
+
half.current = track.scrollWidth / 2;
|
|
100
|
+
});
|
|
101
|
+
observer.observe(track);
|
|
102
|
+
return () => observer.disconnect();
|
|
103
|
+
}, []);
|
|
104
|
+
|
|
105
|
+
// The wrap, for the reader's own scrolling: the two copies are identical,
|
|
106
|
+
// so jumping by exactly one copy's width is invisible even mid-swipe. On a
|
|
107
|
+
// scroll event rather than in the frame loop, so the loop can stop.
|
|
108
|
+
useEffect(() => {
|
|
109
|
+
const scroller = scrollerRef.current;
|
|
110
|
+
if (!scroller) return undefined;
|
|
111
|
+
const onScroll = () => {
|
|
112
|
+
if (half.current > 0 && scroller.scrollLeft >= half.current) {
|
|
113
|
+
scroller.scrollLeft -= half.current;
|
|
114
|
+
}
|
|
115
|
+
};
|
|
116
|
+
scroller.addEventListener("scroll", onScroll, { passive: true });
|
|
117
|
+
return () => scroller.removeEventListener("scroll", onScroll);
|
|
118
|
+
}, []);
|
|
119
|
+
|
|
120
|
+
// The drift. The frame loop runs only while the rail can drift: on screen,
|
|
121
|
+
// motion allowed, not hovered. Otherwise it does no work at all.
|
|
94
122
|
useEffect(() => {
|
|
95
123
|
const scroller = scrollerRef.current;
|
|
96
124
|
const track = trackRef.current;
|
|
97
125
|
if (!scroller || !track) return undefined;
|
|
98
126
|
|
|
99
|
-
let frame = 0;
|
|
100
|
-
let last = performance.now();
|
|
101
|
-
const drifting = !reducedMotion && inView && !(pauseOnHover && isHovered);
|
|
102
|
-
|
|
103
127
|
// Start the reverse loop inside the second copy so it has room to travel.
|
|
104
128
|
if (reverse && scroller.scrollLeft === 0) scroller.scrollLeft = track.scrollWidth / 2;
|
|
129
|
+
|
|
130
|
+
const drifting = !reducedMotion && inView && !(pauseOnHover && isHovered);
|
|
131
|
+
if (!drifting) return undefined;
|
|
132
|
+
|
|
133
|
+
let frame = 0;
|
|
134
|
+
let last = performance.now();
|
|
105
135
|
position.current = scroller.scrollLeft;
|
|
106
136
|
|
|
107
137
|
const tick = (now: number) => {
|
|
108
|
-
const
|
|
109
|
-
if (
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
const perSecond = half / durationSeconds;
|
|
138
|
+
const width = half.current;
|
|
139
|
+
if (width > 0) {
|
|
140
|
+
if (now >= heldUntil.current) {
|
|
141
|
+
const perSecond = width / durationSeconds;
|
|
113
142
|
const delta = (perSecond * (now - last)) / 1000;
|
|
114
143
|
position.current += reverse ? -delta : delta;
|
|
144
|
+
if (position.current >= width) position.current -= width;
|
|
145
|
+
else if (position.current < 0) position.current += width;
|
|
146
|
+
scroller.scrollLeft = position.current;
|
|
115
147
|
} else {
|
|
148
|
+
// Held: follow the reader, never fight the finger.
|
|
116
149
|
position.current = scroller.scrollLeft;
|
|
117
150
|
}
|
|
118
|
-
// The wrap applies in both modes: the two copies are identical, so
|
|
119
|
-
// jumping by exactly one copy's width is invisible even mid-swipe.
|
|
120
|
-
let wrapped = false;
|
|
121
|
-
if (position.current >= half) {
|
|
122
|
-
position.current -= half;
|
|
123
|
-
wrapped = true;
|
|
124
|
-
} else if (position.current < 0) {
|
|
125
|
-
position.current += half;
|
|
126
|
-
wrapped = true;
|
|
127
|
-
}
|
|
128
|
-
if (driftingNow || wrapped) scroller.scrollLeft = position.current;
|
|
129
151
|
}
|
|
130
152
|
last = now;
|
|
131
153
|
frame = requestAnimationFrame(tick);
|
|
@@ -100,7 +100,7 @@ export function EventIndexRow({
|
|
|
100
100
|
<div className={cn("relative", className)}>
|
|
101
101
|
{!hideRule && <Separator className="absolute inset-x-0 top-0" />}
|
|
102
102
|
|
|
103
|
-
<div className="relative -mx-3 flex gap-3 rounded-lg px-3 py-4 transition-colors duration-150 hover:bg-card/60 motion-reduce:transition-none md:gap-4 md:py-5">
|
|
103
|
+
<div className="relative -mx-3 flex gap-3 rounded-lg px-3 py-4 transition-colors duration-150 hover:bg-card/60 active:bg-card/60 motion-reduce:transition-none md:gap-4 md:py-5">
|
|
104
104
|
{/* Square poster at the same 72/104 the rest of the row family uses —
|
|
105
105
|
the editorial surfaces above this one are 4:3, but a third ratio
|
|
106
106
|
inside the ROW family would break the sibling-shape rule for one
|
|
@@ -125,7 +125,7 @@ export function EventIndexRow({
|
|
|
125
125
|
<Heading className="text-[15px] font-bold leading-snug tracking-[-0.01em] text-foreground md:text-[16.5px]">
|
|
126
126
|
<LinkComponent
|
|
127
127
|
href={href}
|
|
128
|
-
className="rounded-sm transition-colors duration-150 after:absolute after:inset-0 hover:text-primary motion-reduce:transition-none focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
|
128
|
+
className="rounded-sm transition-colors duration-150 after:absolute after:inset-0 hover:text-primary active:text-primary motion-reduce:transition-none focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
|
129
129
|
>
|
|
130
130
|
{title}
|
|
131
131
|
</LinkComponent>
|
|
@@ -139,7 +139,7 @@ export function EventIndexRow({
|
|
|
139
139
|
{venueHref ? (
|
|
140
140
|
<LinkComponent
|
|
141
141
|
href={venueHref}
|
|
142
|
-
className="relative z-10 rounded-sm font-semibold text-foreground/85 underline decoration-border underline-offset-2 transition-colors duration-150 hover:text-primary hover:decoration-primary motion-reduce:transition-none focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
|
142
|
+
className="relative z-10 rounded-sm font-semibold text-foreground/85 underline decoration-border underline-offset-2 transition-colors duration-150 hover:text-primary hover:decoration-primary active:text-primary active:decoration-primary motion-reduce:transition-none focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
|
143
143
|
>
|
|
144
144
|
{venue}
|
|
145
145
|
</LinkComponent>
|
|
@@ -112,7 +112,7 @@ export function MenuIndexList({
|
|
|
112
112
|
<Separator />
|
|
113
113
|
<LinkComponent
|
|
114
114
|
href={entry.href}
|
|
115
|
-
className="group flex items-center gap-2.5 py-2.5 transition-colors hover:text-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring motion-reduce:transition-none"
|
|
115
|
+
className="group flex items-center gap-2.5 py-2.5 transition-colors hover:text-accent active:text-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring motion-reduce:transition-none"
|
|
116
116
|
>
|
|
117
117
|
{/* The family's mark: a dot in its hue. */}
|
|
118
118
|
<span
|
|
@@ -131,7 +131,7 @@ export function MenuIndexList({
|
|
|
131
131
|
{entry.countLabel ?? String(entry.count)}
|
|
132
132
|
</span>
|
|
133
133
|
<ChevronRight
|
|
134
|
-
className="h-3.5 w-3.5 shrink-0 text-muted-foreground transition-colors group-hover:text-accent motion-reduce:transition-none"
|
|
134
|
+
className="h-3.5 w-3.5 shrink-0 text-muted-foreground transition-colors group-hover:text-accent group-active:text-accent motion-reduce:transition-none"
|
|
135
135
|
aria-hidden
|
|
136
136
|
/>
|
|
137
137
|
</LinkComponent>
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { render, screen } from "@testing-library/react";
|
|
3
|
+
import { MenuItem, type MenuItemLabels } from "./menu-item";
|
|
4
|
+
import type { MenuItemPreview } from "./menu-types";
|
|
5
|
+
|
|
6
|
+
const wine: MenuItemPreview = {
|
|
7
|
+
id: "w1",
|
|
8
|
+
slug: "feteasca-neagra",
|
|
9
|
+
name: "Fetească Neagră",
|
|
10
|
+
kind: "drink",
|
|
11
|
+
sizes_and_prices: [{ size: 750, price: 169 }],
|
|
12
|
+
mentions: 4,
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
const labels: MenuItemLabels = {
|
|
16
|
+
currency: "RON",
|
|
17
|
+
mentions: (count) => `Mentioned in ${count} reviews`,
|
|
18
|
+
signature: "Signature",
|
|
19
|
+
mentioned: "Mentioned",
|
|
20
|
+
externalRating: (score) => `Rated ${score} on Vivino`,
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
describe("MenuItem external rating", () => {
|
|
24
|
+
it("shows the score beside the mention count, named for assistive tech", () => {
|
|
25
|
+
render(
|
|
26
|
+
<MenuItem item={wine} labels={labels} formatPrice={String} externalRating={4.14} onOpen={() => {}} />
|
|
27
|
+
);
|
|
28
|
+
const rating = screen.getByRole("img", { name: "Rated 4.1 on Vivino" });
|
|
29
|
+
expect(rating.textContent).toBe("4.1");
|
|
30
|
+
expect(screen.getByLabelText("Mentioned in 4 reviews")).toBeTruthy();
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("uses the injected formatter and falls back to the bare score as its name", () => {
|
|
34
|
+
const { mentions, currency, signature, mentioned } = labels;
|
|
35
|
+
render(
|
|
36
|
+
<MenuItem
|
|
37
|
+
item={{ ...wine, mentions: 0 }}
|
|
38
|
+
labels={{ mentions, currency, signature, mentioned }}
|
|
39
|
+
formatPrice={String}
|
|
40
|
+
externalRating={4.14}
|
|
41
|
+
formatRating={(score) => score.toFixed(2).replace(".", ",")}
|
|
42
|
+
/>
|
|
43
|
+
);
|
|
44
|
+
expect(screen.getByRole("img", { name: "4,14" }).textContent).toBe("4,14");
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("renders no rating when the caller passes none", () => {
|
|
48
|
+
render(<MenuItem item={wine} labels={labels} formatPrice={String} />);
|
|
49
|
+
expect(screen.queryByRole("img", { name: /Rated/ })).toBeNull();
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("keeps the meta row for a rating alone, with no price, size or mentions", () => {
|
|
53
|
+
const bare: MenuItemPreview = { id: "w2", slug: "x", name: "X", kind: "drink" };
|
|
54
|
+
render(<MenuItem item={bare} labels={labels} formatPrice={String} externalRating={3.9} />);
|
|
55
|
+
expect(screen.getByRole("img", { name: "Rated 3.9 on Vivino" })).toBeTruthy();
|
|
56
|
+
});
|
|
57
|
+
});
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { memo } from "react";
|
|
2
|
-
import { MessageSquareQuote } from "lucide-react";
|
|
2
|
+
import { MessageSquareQuote, Star } from "lucide-react";
|
|
3
3
|
import { cn } from "../lib/utils";
|
|
4
4
|
import { SignatureChip } from "./signature-chip";
|
|
5
5
|
import type { MenuItemPreview, MenuLinkProps, MenuLinkComponent } from "./menu-types";
|
|
@@ -63,6 +63,12 @@ export type MenuItemLabels = {
|
|
|
63
63
|
signature: string;
|
|
64
64
|
/** Chip text for a `weak` (merely mentioned) item. */
|
|
65
65
|
mentioned: string;
|
|
66
|
+
/**
|
|
67
|
+
* Fully-worded external rating, e.g. "Rated 4.1 on Vivino" — the accessible
|
|
68
|
+
* name for the star-and-score pair. Receives the FORMATTED score. Falls back
|
|
69
|
+
* to the bare score when omitted.
|
|
70
|
+
*/
|
|
71
|
+
externalRating?: (score: string) => string;
|
|
66
72
|
};
|
|
67
73
|
|
|
68
74
|
export type MenuItemProps = {
|
|
@@ -86,6 +92,17 @@ export type MenuItemProps = {
|
|
|
86
92
|
onSignatureEvidence?: () => void;
|
|
87
93
|
/** Opens the reviews behind the mention count. */
|
|
88
94
|
onMentions?: () => void;
|
|
95
|
+
/**
|
|
96
|
+
* An outside aggregate score for the item (a wine's Vivino rating, a beer's
|
|
97
|
+
* Untappd one), shown as a star and the score beside the mention count.
|
|
98
|
+
*
|
|
99
|
+
* The caller GATES it: those scales run low and a thin vote count is noise,
|
|
100
|
+
* so the app decides whether a score is fit to show and passes nothing when
|
|
101
|
+
* it is not. The DS renders what it is given and applies no floor of its own.
|
|
102
|
+
*/
|
|
103
|
+
externalRating?: number;
|
|
104
|
+
/** Formats `externalRating`. Defaults to one decimal place. */
|
|
105
|
+
formatRating?: (score: number) => string;
|
|
89
106
|
/** The mark's colour, as a CSS `background-image` value. */
|
|
90
107
|
markGradient?: string;
|
|
91
108
|
/**
|
|
@@ -187,6 +204,25 @@ export function MentionSignal({
|
|
|
187
204
|
);
|
|
188
205
|
}
|
|
189
206
|
|
|
207
|
+
/**
|
|
208
|
+
* An external aggregate score: a star and the numeral, in the neutral muted
|
|
209
|
+
* tone. Deliberately quieter than the mention count beside it (lighter weight,
|
|
210
|
+
* a step smaller, no accent) — the census count is this app's own evidence;
|
|
211
|
+
* the star is someone else's, and it must not outrank or be mistaken for it.
|
|
212
|
+
*/
|
|
213
|
+
export function ExternalRatingSignal({ label, value }: { label: string; value: string }) {
|
|
214
|
+
return (
|
|
215
|
+
<span
|
|
216
|
+
role="img"
|
|
217
|
+
aria-label={label}
|
|
218
|
+
className="inline-flex shrink-0 items-center gap-0.5 text-[13px] font-semibold text-muted-foreground"
|
|
219
|
+
>
|
|
220
|
+
<Star className="h-3 w-3 fill-current opacity-70" aria-hidden />
|
|
221
|
+
<span className="tabular-nums">{value}</span>
|
|
222
|
+
</span>
|
|
223
|
+
);
|
|
224
|
+
}
|
|
225
|
+
|
|
190
226
|
export const MenuItem = memo(function MenuItem({
|
|
191
227
|
item,
|
|
192
228
|
labels,
|
|
@@ -197,6 +233,8 @@ export const MenuItem = memo(function MenuItem({
|
|
|
197
233
|
signatureTier = "strong",
|
|
198
234
|
onSignatureEvidence,
|
|
199
235
|
onMentions,
|
|
236
|
+
externalRating,
|
|
237
|
+
formatRating = (score) => score.toFixed(1),
|
|
200
238
|
markGradient,
|
|
201
239
|
venueName,
|
|
202
240
|
subtitle,
|
|
@@ -222,6 +260,7 @@ export const MenuItem = memo(function MenuItem({
|
|
|
222
260
|
// `review_count` counts full review objects and is sparse. Prefer the former,
|
|
223
261
|
// fall back rather than render nothing.
|
|
224
262
|
const mentionCount = item.mentions ?? item.review_count ?? 0;
|
|
263
|
+
const ratingText = externalRating != null ? formatRating(externalRating) : null;
|
|
225
264
|
|
|
226
265
|
// A row with neither destination is content, not a control.
|
|
227
266
|
const isInert = !href && !onOpen;
|
|
@@ -375,10 +414,10 @@ export const MenuItem = memo(function MenuItem({
|
|
|
375
414
|
to the far edge severs it from the name it belongs to. Do not
|
|
376
415
|
re-litigate without looking at it.
|
|
377
416
|
|
|
378
|
-
Suppressed entirely when the row has none of
|
|
417
|
+
Suppressed entirely when the row has none of these: a mention
|
|
379
418
|
with no menu row behind it has no price and no size, and an empty
|
|
380
419
|
meta line still spent its top margin. */}
|
|
381
|
-
{(minPrice != null || hasQuantity || mentionCount > 0) && (
|
|
420
|
+
{(minPrice != null || hasQuantity || mentionCount > 0 || ratingText != null) && (
|
|
382
421
|
<div className="mt-1.5 flex items-baseline justify-between gap-x-2 gap-y-1">
|
|
383
422
|
<div className="flex min-w-0 items-baseline gap-x-2">
|
|
384
423
|
{minPrice != null && (
|
|
@@ -400,13 +439,25 @@ export const MenuItem = memo(function MenuItem({
|
|
|
400
439
|
)}
|
|
401
440
|
</div>
|
|
402
441
|
|
|
403
|
-
{
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
442
|
+
{/* The external score sits LEFT of the mention count, so the count
|
|
443
|
+
keeps the right edge and still forms a column down the list. */}
|
|
444
|
+
{(ratingText != null || mentionCount > 0) && (
|
|
445
|
+
<div className="flex shrink-0 items-center gap-x-3">
|
|
446
|
+
{ratingText != null && (
|
|
447
|
+
<ExternalRatingSignal
|
|
448
|
+
label={labels.externalRating?.(ratingText) ?? ratingText}
|
|
449
|
+
value={ratingText}
|
|
450
|
+
/>
|
|
451
|
+
)}
|
|
452
|
+
{mentionCount > 0 && (
|
|
453
|
+
<MentionSignal
|
|
454
|
+
label={labels.mentions(mentionCount)}
|
|
455
|
+
count={mentionCount}
|
|
456
|
+
onMentions={onMentions}
|
|
457
|
+
formatCount={formatCount}
|
|
458
|
+
/>
|
|
459
|
+
)}
|
|
460
|
+
</div>
|
|
410
461
|
)}
|
|
411
462
|
</div>
|
|
412
463
|
)}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
|
2
|
+
import { render } from "@testing-library/react";
|
|
3
|
+
import { PosterStack } from "./poster-stack";
|
|
4
|
+
|
|
5
|
+
// jsdom has neither; the stack checks reduced motion, then observes itself.
|
|
6
|
+
beforeEach(() => {
|
|
7
|
+
vi.stubGlobal("matchMedia", () => ({ matches: false }));
|
|
8
|
+
vi.stubGlobal(
|
|
9
|
+
"IntersectionObserver",
|
|
10
|
+
class {
|
|
11
|
+
observe() {}
|
|
12
|
+
disconnect() {}
|
|
13
|
+
}
|
|
14
|
+
);
|
|
15
|
+
});
|
|
16
|
+
afterEach(() => {
|
|
17
|
+
vi.unstubAllGlobals();
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
const posters = [
|
|
21
|
+
{ src: "/a.jpg", alt: "A", eager: true },
|
|
22
|
+
{ src: "/b.jpg", alt: "B" },
|
|
23
|
+
{ src: "/c.jpg", alt: "C" },
|
|
24
|
+
];
|
|
25
|
+
|
|
26
|
+
describe("PosterStack eager", () => {
|
|
27
|
+
it("loads only the eager poster now, at high priority", () => {
|
|
28
|
+
const { container } = render(<PosterStack posters={posters} />);
|
|
29
|
+
const [first, ...rest] = Array.from(container.querySelectorAll("img"));
|
|
30
|
+
|
|
31
|
+
expect(first.getAttribute("loading")).toBe("eager");
|
|
32
|
+
expect(first.getAttribute("fetchpriority")).toBe("high");
|
|
33
|
+
for (const img of rest) {
|
|
34
|
+
expect(img.getAttribute("loading")).toBe("lazy");
|
|
35
|
+
expect(img.hasAttribute("fetchpriority")).toBe(false);
|
|
36
|
+
}
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("stays lazy when no poster opts in", () => {
|
|
40
|
+
const { container } = render(
|
|
41
|
+
<PosterStack posters={posters.map(({ src, alt }) => ({ src, alt }))} />
|
|
42
|
+
);
|
|
43
|
+
for (const img of container.querySelectorAll("img")) {
|
|
44
|
+
expect(img.getAttribute("loading")).toBe("lazy");
|
|
45
|
+
}
|
|
46
|
+
});
|
|
47
|
+
});
|
|
@@ -60,6 +60,9 @@ export type PosterStackPoster = {
|
|
|
60
60
|
caption?: string;
|
|
61
61
|
/** Rendered when `src` is absent or fails. Defaults to `<EventPosterFallback />`. */
|
|
62
62
|
fallback?: React.ReactNode;
|
|
63
|
+
/** Load now at high priority instead of lazily. For the poster that is the
|
|
64
|
+
* page's LCP element; every other poster stays lazy. */
|
|
65
|
+
eager?: boolean;
|
|
63
66
|
};
|
|
64
67
|
|
|
65
68
|
export type PosterStackProps = {
|
|
@@ -182,7 +185,8 @@ export function PosterStack({
|
|
|
182
185
|
<img
|
|
183
186
|
src={poster.src}
|
|
184
187
|
alt={poster.caption ? "" : poster.alt}
|
|
185
|
-
loading="lazy"
|
|
188
|
+
loading={poster.eager ? "eager" : "lazy"}
|
|
189
|
+
fetchPriority={poster.eager ? "high" : undefined}
|
|
186
190
|
decoding="async"
|
|
187
191
|
className={cn(
|
|
188
192
|
"h-full w-full object-cover",
|
|
@@ -79,10 +79,10 @@ const ACTION_BASE_CLASSES = cn(
|
|
|
79
79
|
);
|
|
80
80
|
|
|
81
81
|
const ACTION_TONE_CLASSES = {
|
|
82
|
-
muted: "text-[14px] text-muted-foreground hover:text-foreground",
|
|
82
|
+
muted: "text-[14px] text-muted-foreground hover:text-foreground active:text-foreground",
|
|
83
83
|
app: cn(
|
|
84
|
-
"text-[12.5px] font-semibold text-accent hover:text-accent/80",
|
|
85
|
-
"lg:text-[14px] lg:font-normal lg:text-muted-foreground lg:hover:text-foreground"
|
|
84
|
+
"text-[12.5px] font-semibold text-accent hover:text-accent/80 active:text-accent/80",
|
|
85
|
+
"lg:text-[14px] lg:font-normal lg:text-muted-foreground lg:hover:text-foreground lg:active:text-foreground"
|
|
86
86
|
),
|
|
87
87
|
} as const;
|
|
88
88
|
|