@bearmenu/ui 0.8.8 → 0.8.10
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-24RFI4RQ.js +167 -0
- package/dist/chunk-2XCZJWTV.js +121 -0
- package/dist/chunk-5RWTOT5Y.js +72 -0
- package/dist/chunk-62ES6N32.js +27 -0
- package/dist/chunk-AKQ57NXY.js +58 -0
- package/dist/chunk-CLLY6XB3.js +41 -0
- package/dist/chunk-DTXNDLSE.js +125 -0
- package/dist/chunk-F6FMZJDG.js +113 -0
- package/dist/{chunk-2WDJR6K7.js → chunk-G7IFL4Z4.js} +4 -1
- package/dist/chunk-MZL7CGCL.js +109 -0
- package/dist/chunk-Q6QH52LJ.js +38 -0
- package/dist/chunk-SY6RV476.js +48 -0
- package/dist/chunk-TH6W2PWG.js +213 -0
- package/dist/chunk-UDN7UHJW.js +60 -0
- package/dist/{chunk-TT3WFS6S.js → chunk-YOKHBAIH.js} +11 -11
- package/dist/components/alert.d.ts +2 -2
- package/dist/components/badge.d.ts +1 -1
- package/dist/components/concept-compare-rail.d.ts +50 -0
- package/dist/components/concept-compare-rail.js +5 -0
- package/dist/components/concept-compare.d.ts +110 -0
- package/dist/components/concept-compare.js +238 -0
- package/dist/components/concept-convergence.d.ts +41 -0
- package/dist/components/concept-convergence.js +53 -0
- package/dist/components/convergence.d.ts +25 -0
- package/dist/components/convergence.js +4 -0
- package/dist/components/date-input.d.ts +1 -1
- package/dist/components/event-card.js +1 -1
- package/dist/components/event-compact-row.d.ts +1 -1
- package/dist/components/event-compact-row.js +1 -1
- package/dist/components/event-detail-panel.d.ts +171 -25
- package/dist/components/event-detail-panel.js +472 -175
- package/dist/components/event-index-row.js +1 -1
- package/dist/components/event-list-row-wide.js +2 -2
- package/dist/components/event-list-row.js +2 -2
- package/dist/components/explainer-steps.d.ts +34 -0
- package/dist/components/explainer-steps.js +36 -0
- package/dist/components/glass-ground.d.ts +66 -0
- package/dist/components/glass-ground.js +3 -0
- package/dist/components/hub-surface.d.ts +27 -0
- package/dist/components/hub-surface.js +30 -0
- package/dist/components/hue-chip.d.ts +70 -0
- package/dist/components/hue-chip.js +3 -0
- package/dist/components/kitchen-dossier.d.ts +88 -0
- package/dist/components/kitchen-dossier.js +187 -0
- package/dist/components/menu-dead-end.d.ts +56 -0
- package/dist/components/menu-dead-end.js +49 -0
- package/dist/components/menu-family-block.d.ts +99 -0
- package/dist/components/menu-family-block.js +155 -0
- package/dist/components/menu-family-card.d.ts +65 -0
- package/dist/components/menu-family-card.js +79 -0
- package/dist/components/menu-field.d.ts +57 -0
- package/dist/components/menu-field.js +93 -0
- package/dist/components/menu-filter-chips.d.ts +86 -0
- package/dist/components/menu-filter-chips.js +122 -0
- package/dist/components/menu-group-rows.d.ts +53 -0
- package/dist/components/menu-group-rows.js +67 -0
- package/dist/components/menu-hub-hero.d.ts +65 -0
- package/dist/components/menu-hub-hero.js +103 -0
- package/dist/components/menu-index-header.d.ts +37 -0
- package/dist/components/menu-index-header.js +68 -0
- package/dist/components/menu-index-list.d.ts +48 -0
- package/dist/components/menu-index-list.js +96 -0
- package/dist/components/menu-intent-hero.d.ts +69 -0
- package/dist/components/menu-intent-hero.js +87 -0
- package/dist/components/menu-intent-tiles.d.ts +31 -0
- package/dist/components/menu-intent-tiles.js +50 -0
- package/dist/components/menu-item.d.ts +127 -0
- package/dist/components/menu-item.js +5 -0
- package/dist/components/menu-kitchen-list.d.ts +82 -0
- package/dist/components/menu-kitchen-list.js +5 -0
- package/dist/components/menu-mocks.d.ts +90 -0
- package/dist/components/menu-mocks.js +504 -0
- package/dist/components/menu-types.d.ts +421 -0
- package/dist/components/menu-types.js +1 -0
- package/dist/components/motion-icon-controls.js +2 -2
- package/dist/components/motion-icon-set.js +21 -9
- package/dist/components/motion-icon.d.ts +22 -1
- package/dist/components/motion-icon.js +1 -1
- package/dist/components/open-now-list.d.ts +40 -0
- package/dist/components/open-now-list.js +69 -0
- package/dist/components/photo-ground.d.ts +64 -0
- package/dist/components/photo-ground.js +3 -0
- package/dist/components/place-about-tab.js +2 -2
- package/dist/components/place-details-mobile.js +2 -2
- package/dist/components/place-types.d.ts +112 -1
- package/dist/components/qa-block.d.ts +36 -0
- package/dist/components/qa-block.js +24 -0
- package/dist/components/scroll-fade.js +3 -121
- package/dist/components/signature-chip.d.ts +37 -0
- package/dist/components/signature-chip.js +4 -0
- package/dist/components/snap-rail.d.ts +48 -5
- package/dist/components/snap-rail.js +3 -51
- package/dist/components/stat-banner.d.ts +47 -0
- package/dist/components/stat-banner.js +61 -0
- package/dist/components/story.d.ts +34 -0
- package/dist/components/story.js +3 -0
- package/dist/components/tag.d.ts +2 -2
- package/dist/components/text.d.ts +1 -1
- package/dist/components/top-comparison.d.ts +28 -0
- package/dist/components/top-comparison.js +76 -0
- package/dist/components/top-cross-link.d.ts +34 -0
- package/dist/components/top-cross-link.js +101 -0
- package/dist/components/top-ranking-entry.d.ts +53 -0
- package/dist/components/top-ranking-entry.js +217 -0
- package/dist/components/top-ranking-hero.d.ts +61 -0
- package/dist/components/top-ranking-hero.js +99 -0
- package/dist/components/top-ranking-legend.d.ts +69 -0
- package/dist/components/top-ranking-legend.js +124 -0
- package/dist/components/top-types.d.ts +109 -0
- package/dist/components/top-types.js +1 -0
- package/dist/components/venue-grade-dimensions.d.ts +38 -0
- package/dist/components/venue-grade-dimensions.js +3 -0
- package/dist/components/venue-quotes.d.ts +32 -0
- package/dist/components/venue-quotes.js +3 -0
- package/dist/components/venue-wall.d.ts +41 -0
- package/dist/components/venue-wall.js +118 -0
- package/dist/components/weekday-theme-banner.d.ts +70 -0
- package/dist/components/weekday-theme-banner.js +127 -0
- package/dist/components/weekend-panel.js +2 -2
- package/package.json +1 -1
- package/src/components/colour-dictionary.stories.tsx +227 -0
- package/src/components/concept-compare-rail.tsx +131 -0
- package/src/components/concept-compare.stories.tsx +172 -0
- package/src/components/concept-compare.tsx +443 -0
- package/src/components/concept-convergence.stories.tsx +53 -0
- package/src/components/concept-convergence.tsx +90 -0
- package/src/components/convergence.stories.tsx +54 -0
- package/src/components/convergence.tsx +177 -0
- package/src/components/event-compact-row.tsx +1 -1
- package/src/components/event-detail-panel.stories.tsx +205 -97
- package/src/components/event-detail-panel.test.tsx +181 -0
- package/src/components/event-detail-panel.tsx +703 -185
- package/src/components/explainer-steps.stories.tsx +46 -0
- package/src/components/explainer-steps.tsx +74 -0
- package/src/components/glass-ground.tsx +129 -0
- package/src/components/hub-surface.stories.tsx +68 -0
- package/src/components/hub-surface.tsx +50 -0
- package/src/components/hue-chip.stories.tsx +76 -0
- package/src/components/hue-chip.test.tsx +62 -0
- package/src/components/hue-chip.tsx +99 -0
- package/src/components/kitchen-dossier.stories.tsx +108 -0
- package/src/components/kitchen-dossier.tsx +322 -0
- package/src/components/menu-dead-end.stories.tsx +67 -0
- package/src/components/menu-dead-end.tsx +127 -0
- package/src/components/menu-family-block.stories.tsx +91 -0
- package/src/components/menu-family-block.tsx +314 -0
- package/src/components/menu-family-card.stories.tsx +120 -0
- package/src/components/menu-family-card.tsx +191 -0
- package/src/components/menu-field.stories.tsx +74 -0
- package/src/components/menu-field.tsx +199 -0
- package/src/components/menu-filter-chips.stories.tsx +107 -0
- package/src/components/menu-filter-chips.tsx +238 -0
- package/src/components/menu-group-rows.stories.tsx +54 -0
- package/src/components/menu-group-rows.tsx +133 -0
- package/src/components/menu-hub-hero.stories.tsx +92 -0
- package/src/components/menu-hub-hero.tsx +187 -0
- package/src/components/menu-index-header.stories.tsx +72 -0
- package/src/components/menu-index-header.tsx +125 -0
- package/src/components/menu-index-list.stories.tsx +50 -0
- package/src/components/menu-index-list.tsx +149 -0
- package/src/components/menu-intent-hero.stories.tsx +67 -0
- package/src/components/menu-intent-hero.tsx +189 -0
- package/src/components/menu-intent-tiles.stories.tsx +54 -0
- package/src/components/menu-intent-tiles.tsx +76 -0
- package/src/components/menu-item.tsx +416 -0
- package/src/components/menu-kitchen-list.stories.tsx +67 -0
- package/src/components/menu-kitchen-list.test.tsx +78 -0
- package/src/components/menu-kitchen-list.tsx +284 -0
- package/src/components/menu-mocks.ts +592 -0
- package/src/components/menu-types.ts +442 -0
- package/src/components/motion-icon-controls.tsx +11 -11
- package/src/components/motion-icon-set.tsx +36 -8
- package/src/components/motion-icon.test.tsx +23 -1
- package/src/components/motion-icon.tsx +20 -0
- package/src/components/open-now-list.stories.tsx +76 -0
- package/src/components/open-now-list.tsx +127 -0
- package/src/components/photo-ground.tsx +147 -0
- package/src/components/place-types.ts +112 -0
- package/src/components/qa-block.stories.tsx +43 -0
- package/src/components/qa-block.tsx +60 -0
- package/src/components/signature-chip.tsx +77 -0
- package/src/components/snap-rail.tsx +133 -9
- package/src/components/stat-banner.stories.tsx +100 -0
- package/src/components/stat-banner.tsx +117 -0
- package/src/components/story.tsx +225 -0
- package/src/components/top-comparison.stories.tsx +78 -0
- package/src/components/top-comparison.tsx +151 -0
- package/src/components/top-cross-link.stories.tsx +84 -0
- package/src/components/top-cross-link.tsx +171 -0
- package/src/components/top-ranking-entry.stories.tsx +171 -0
- package/src/components/top-ranking-entry.test.tsx +105 -0
- package/src/components/top-ranking-entry.tsx +425 -0
- package/src/components/top-ranking-hero.stories.tsx +76 -0
- package/src/components/top-ranking-hero.tsx +218 -0
- package/src/components/top-ranking-legend.stories.tsx +78 -0
- package/src/components/top-ranking-legend.tsx +239 -0
- package/src/components/top-types.ts +110 -0
- package/src/components/venue-grade-dimensions.stories.tsx +83 -0
- package/src/components/venue-grade-dimensions.tsx +91 -0
- package/src/components/venue-quotes.stories.tsx +59 -0
- package/src/components/venue-quotes.tsx +64 -0
- package/src/components/venue-wall.stories.tsx +78 -0
- package/src/components/venue-wall.tsx +185 -0
- package/src/components/weekday-theme-banner.stories.tsx +113 -0
- package/src/components/weekday-theme-banner.tsx +231 -0
- package/dist/{chunk-XWMRABZW.js → chunk-HSIYYA2E.js} +1 -1
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import { Moon, Sun } from "lucide-react";
|
|
2
|
+
import { cn } from "../lib/utils";
|
|
3
|
+
import type { MenuLinkComponent, MenuLinkProps } from "./menu-types";
|
|
4
|
+
import type { TopComparisonRow } from "./top-types";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* TopComparison — the ranking side by side.
|
|
8
|
+
*
|
|
9
|
+
* The only place in the product where you can compare the whole shortlist at
|
|
10
|
+
* once on the same axes, and it costs nothing because it is static. It is not
|
|
11
|
+
* a filter: narrowing links out to the browsing tool.
|
|
12
|
+
*
|
|
13
|
+
* ## A table on desktop, a ranked strip on the phone
|
|
14
|
+
*
|
|
15
|
+
* Seven columns at 358px is unreadable, and a horizontally scrolling table
|
|
16
|
+
* hides the column you are comparing — which is the one thing the control
|
|
17
|
+
* exists to do. The strip keeps the side-by-side scan, which is the table's
|
|
18
|
+
* actual job, by dropping to the four facts that fit: rank, name, signature
|
|
19
|
+
* with its price, and the grade, with late and terrace as glyphs.
|
|
20
|
+
*
|
|
21
|
+
* Two renderings of one dataset, so they cannot disagree.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
function DefaultLink({ href, ...props }: MenuLinkProps) {
|
|
25
|
+
return <a href={href} {...props} />;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export type TopComparisonLabels = {
|
|
29
|
+
venue: string;
|
|
30
|
+
grade: string;
|
|
31
|
+
signature: string;
|
|
32
|
+
price: string;
|
|
33
|
+
late: string;
|
|
34
|
+
terrace: string;
|
|
35
|
+
/** Screen-reader text for a "no" cell — never an empty cell. */
|
|
36
|
+
no: string;
|
|
37
|
+
yes: string;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
export type TopComparisonProps = {
|
|
41
|
+
rows: TopComparisonRow[];
|
|
42
|
+
labels: TopComparisonLabels;
|
|
43
|
+
/** Accessible name for the table. */
|
|
44
|
+
caption: string;
|
|
45
|
+
linkComponent?: MenuLinkComponent;
|
|
46
|
+
className?: string;
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
export function TopComparison({
|
|
50
|
+
rows,
|
|
51
|
+
labels,
|
|
52
|
+
caption,
|
|
53
|
+
linkComponent: LinkComponent = DefaultLink,
|
|
54
|
+
className,
|
|
55
|
+
}: TopComparisonProps) {
|
|
56
|
+
return (
|
|
57
|
+
<div className={className}>
|
|
58
|
+
{/* Desktop: the full table. `overflow-x-auto` on the wrapper so a narrow
|
|
59
|
+
desktop window scrolls the table rather than the page. */}
|
|
60
|
+
<div className="hidden overflow-x-auto lg:block">
|
|
61
|
+
<table className="w-full border-collapse text-[13px]">
|
|
62
|
+
<caption className="sr-only">{caption}</caption>
|
|
63
|
+
<thead>
|
|
64
|
+
<tr className="border-b border-border text-left text-[11px] font-bold uppercase tracking-[0.07em] text-muted-foreground">
|
|
65
|
+
<th scope="col" className="w-10 py-2 font-bold">
|
|
66
|
+
<span className="sr-only">#</span>
|
|
67
|
+
</th>
|
|
68
|
+
<th scope="col" className="py-2 font-bold">{labels.venue}</th>
|
|
69
|
+
<th scope="col" className="py-2 font-bold">{labels.grade}</th>
|
|
70
|
+
<th scope="col" className="py-2 font-bold">{labels.signature}</th>
|
|
71
|
+
<th scope="col" className="py-2 font-bold">{labels.price}</th>
|
|
72
|
+
<th scope="col" className="py-2 font-bold">{labels.late}</th>
|
|
73
|
+
<th scope="col" className="py-2 font-bold">{labels.terrace}</th>
|
|
74
|
+
</tr>
|
|
75
|
+
</thead>
|
|
76
|
+
<tbody>
|
|
77
|
+
{rows.map((row) => (
|
|
78
|
+
<tr key={row.name} className="border-b border-border/60">
|
|
79
|
+
<td className="py-2.5 tabular-nums text-muted-foreground">
|
|
80
|
+
{String(row.rank).padStart(2, "0")}
|
|
81
|
+
</td>
|
|
82
|
+
<td className="py-2.5 font-semibold">
|
|
83
|
+
<LinkComponent
|
|
84
|
+
href={row.href}
|
|
85
|
+
className="hover:text-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
86
|
+
>
|
|
87
|
+
{row.name}
|
|
88
|
+
</LinkComponent>
|
|
89
|
+
</td>
|
|
90
|
+
<td className="py-2.5 font-bold tabular-nums text-accent">{row.gradeLabel ?? "—"}</td>
|
|
91
|
+
<td className="py-2.5 text-muted-foreground">{row.signature ?? "—"}</td>
|
|
92
|
+
<td className="py-2.5 tabular-nums">{row.priceLabel ?? "—"}</td>
|
|
93
|
+
<td className="py-2.5">
|
|
94
|
+
<Mark on={row.late} yes={labels.yes} no={labels.no} />
|
|
95
|
+
</td>
|
|
96
|
+
<td className="py-2.5">
|
|
97
|
+
<Mark on={row.terrace} yes={labels.yes} no={labels.no} />
|
|
98
|
+
</td>
|
|
99
|
+
</tr>
|
|
100
|
+
))}
|
|
101
|
+
</tbody>
|
|
102
|
+
</table>
|
|
103
|
+
</div>
|
|
104
|
+
|
|
105
|
+
{/* Phone: one row per venue. */}
|
|
106
|
+
<ul className="lg:hidden">
|
|
107
|
+
{rows.map((row) => (
|
|
108
|
+
<li key={row.name} className="border-t border-border first:border-t-0">
|
|
109
|
+
<LinkComponent
|
|
110
|
+
href={row.href}
|
|
111
|
+
className="flex items-center gap-3 py-2.5 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring"
|
|
112
|
+
>
|
|
113
|
+
<span className="w-6 shrink-0 text-[13px] font-bold tabular-nums text-muted-foreground">
|
|
114
|
+
{String(row.rank).padStart(2, "0")}
|
|
115
|
+
</span>
|
|
116
|
+
<span className="min-w-0 flex-1">
|
|
117
|
+
<span className="block truncate text-[13.5px] font-bold">{row.name}</span>
|
|
118
|
+
{(row.signature || row.priceLabel) && (
|
|
119
|
+
<span className="block truncate text-[11.5px] text-muted-foreground">
|
|
120
|
+
{[row.signature, row.priceLabel].filter(Boolean).join(" · ")}
|
|
121
|
+
</span>
|
|
122
|
+
)}
|
|
123
|
+
</span>
|
|
124
|
+
<span className="flex shrink-0 items-center gap-1 text-muted-foreground">
|
|
125
|
+
{row.late && <Moon className="h-3.5 w-3.5" aria-label={labels.late} />}
|
|
126
|
+
{row.terrace && <Sun className="h-3.5 w-3.5" aria-label={labels.terrace} />}
|
|
127
|
+
</span>
|
|
128
|
+
{row.gradeLabel && (
|
|
129
|
+
<span className="shrink-0 text-[13px] font-extrabold tabular-nums text-accent">
|
|
130
|
+
{row.gradeLabel}
|
|
131
|
+
</span>
|
|
132
|
+
)}
|
|
133
|
+
</LinkComponent>
|
|
134
|
+
</li>
|
|
135
|
+
))}
|
|
136
|
+
</ul>
|
|
137
|
+
</div>
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** A yes/no cell that a screen reader can read — never a bare dash. */
|
|
142
|
+
function Mark({ on, yes, no }: { on?: boolean; yes: string; no: string }) {
|
|
143
|
+
return on ? (
|
|
144
|
+
<span className="font-semibold text-[color:var(--free,oklch(0.45_0.13_155))]">{yes}</span>
|
|
145
|
+
) : (
|
|
146
|
+
<span className="text-muted-foreground">
|
|
147
|
+
<span aria-hidden>—</span>
|
|
148
|
+
<span className="sr-only">{no}</span>
|
|
149
|
+
</span>
|
|
150
|
+
);
|
|
151
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/react";
|
|
2
|
+
import { TopCrossLink } from "./top-cross-link";
|
|
3
|
+
import { mockGradients } from "./menu-mocks";
|
|
4
|
+
|
|
5
|
+
const meta: Meta<typeof TopCrossLink> = {
|
|
6
|
+
title: "Top/TopCrossLink",
|
|
7
|
+
component: TopCrossLink,
|
|
8
|
+
tags: ["autodocs"],
|
|
9
|
+
parameters: {
|
|
10
|
+
docs: {
|
|
11
|
+
description: {
|
|
12
|
+
component:
|
|
13
|
+
"The link between the two answers to “where should I eat”. `row` is the way out of the ranking, full width under the list; `panel` is the way in, for the narrow column of a browse page. One component in both directions so the pair cannot drift — and never a sticky bar, because a sticky CTA on an article is an advert.",
|
|
14
|
+
},
|
|
15
|
+
},
|
|
16
|
+
},
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export default meta;
|
|
20
|
+
type Story = StoryObj<typeof TopCrossLink>;
|
|
21
|
+
|
|
22
|
+
/** Under the ranking: the editorial pick is done, the directory is over here. */
|
|
23
|
+
export const Row: Story = {
|
|
24
|
+
args: {
|
|
25
|
+
title: "This is the editorial pick — the rest is browsable",
|
|
26
|
+
description:
|
|
27
|
+
"Two hundred and forty-seven Italian kitchens, filterable by open now, price, district and what they have on.",
|
|
28
|
+
cta: "Browse all 247",
|
|
29
|
+
href: "/menu/cuisines/italian",
|
|
30
|
+
},
|
|
31
|
+
decorators: [
|
|
32
|
+
(Story) => (
|
|
33
|
+
<div className="w-[900px] bg-background p-4">
|
|
34
|
+
<Story />
|
|
35
|
+
</div>
|
|
36
|
+
),
|
|
37
|
+
],
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* The long CTA that `h-11` used to clip. The categories registry hands this
|
|
42
|
+
* component whole headlines, which is the point — the reader lands on a page
|
|
43
|
+
* whose `h1` is the string they tapped.
|
|
44
|
+
*/
|
|
45
|
+
export const RowLongCta: Story = {
|
|
46
|
+
args: {
|
|
47
|
+
...Row.args,
|
|
48
|
+
cta: "Vezi toate localurile cu shaorma din Cluj-Napoca",
|
|
49
|
+
},
|
|
50
|
+
decorators: [
|
|
51
|
+
(Story) => (
|
|
52
|
+
<div className="w-[375px] bg-background p-4">
|
|
53
|
+
<Story />
|
|
54
|
+
</div>
|
|
55
|
+
),
|
|
56
|
+
],
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
/** Beside a browse list, in a 380px column. */
|
|
60
|
+
export const Panel: Story = {
|
|
61
|
+
args: {
|
|
62
|
+
variant: "panel",
|
|
63
|
+
kicker: "RANKED",
|
|
64
|
+
title: "299 kitchens. One answer.",
|
|
65
|
+
description:
|
|
66
|
+
"Every one graded and put in order, with the reviews that decided the ranking quoted. Updated September 2026.",
|
|
67
|
+
cta: "The best Romanian restaurants in Cluj-Napoca",
|
|
68
|
+
href: "/top/romanesc",
|
|
69
|
+
gradient: mockGradients.pasta,
|
|
70
|
+
},
|
|
71
|
+
decorators: [
|
|
72
|
+
(Story) => (
|
|
73
|
+
<div className="w-[380px] bg-background p-4">
|
|
74
|
+
<Story />
|
|
75
|
+
</div>
|
|
76
|
+
),
|
|
77
|
+
],
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
/** No ground — the panel still reads as a card. */
|
|
81
|
+
export const PanelPlain: Story = {
|
|
82
|
+
args: { ...Panel.args, gradient: undefined },
|
|
83
|
+
decorators: Panel.decorators,
|
|
84
|
+
};
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import { ArrowRight, LayoutList, Trophy } from "lucide-react";
|
|
2
|
+
import { cn } from "../lib/utils";
|
|
3
|
+
import type { MenuLinkComponent, MenuLinkProps } from "./menu-types";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* TopCrossLink — the link between the ranking and the browsing tool.
|
|
7
|
+
*
|
|
8
|
+
* The product has two answers to "where should I eat", and they are different
|
|
9
|
+
* questions: the intent page answers *which venues, right now*, and `/top`
|
|
10
|
+
* answers *which are the best*. A reader who wants the other one should be told
|
|
11
|
+
* it exists rather than left to bounce, and told in the same object in both
|
|
12
|
+
* directions — so this is one component with two proportions, not two
|
|
13
|
+
* components that will drift.
|
|
14
|
+
*
|
|
15
|
+
* It was `TopExitRow`, which named only the outbound half. A menu page
|
|
16
|
+
* importing "exit row" to point INTO the ranking is a lie in the import list,
|
|
17
|
+
* and four projects read these names.
|
|
18
|
+
*
|
|
19
|
+
* ## `row` under a list, `panel` beside one
|
|
20
|
+
*
|
|
21
|
+
* `row` is the way out, full width under the ranking: icon, title, prose, and a
|
|
22
|
+
* button on the right at `lg`. `panel` is the way in, stacked for the ~380px
|
|
23
|
+
* column of a browse page, and it is the one that carries a ground — a card
|
|
24
|
+
* competing for attention with the list next to it needs to be a place, not a
|
|
25
|
+
* paragraph.
|
|
26
|
+
*
|
|
27
|
+
* ## Never a sticky bar
|
|
28
|
+
*
|
|
29
|
+
* Stated once, where the reader has finished reading. A sticky call to action
|
|
30
|
+
* on an article is an advert, and both of these pages exist to be read to the
|
|
31
|
+
* end.
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
function DefaultLink({ href, ...props }: MenuLinkProps) {
|
|
35
|
+
return <a href={href} {...props} />;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export type TopCrossLinkProps = {
|
|
39
|
+
/**
|
|
40
|
+
* `row` (default) — the way OUT of the ranking, full width under the list.
|
|
41
|
+
* `panel` — the way IN, stacked, for a narrow column beside a browse list.
|
|
42
|
+
*/
|
|
43
|
+
variant?: "row" | "panel";
|
|
44
|
+
/** e.g. "RANKED". `panel` only; drawn over the title with `icon` inline. */
|
|
45
|
+
kicker?: string;
|
|
46
|
+
title: string;
|
|
47
|
+
description: string;
|
|
48
|
+
/**
|
|
49
|
+
* The link's text. Prefer the destination's own headline over a generic
|
|
50
|
+
* "see more": the reader lands on a page whose `<h1>` is the string they
|
|
51
|
+
* tapped, and it is the anchor text the destination actually wants.
|
|
52
|
+
*/
|
|
53
|
+
cta: string;
|
|
54
|
+
href: string;
|
|
55
|
+
/** Finished CSS gradient for the ground. `panel` only. */
|
|
56
|
+
gradient?: string;
|
|
57
|
+
/** Names the DESTINATION. Defaults to `LayoutList` on `row`, `Trophy` on `panel`. */
|
|
58
|
+
icon?: React.ReactNode;
|
|
59
|
+
/** The title's element. `b` by default on `row`, `h2` on `panel`. */
|
|
60
|
+
as?: "h2" | "h3" | "b";
|
|
61
|
+
linkComponent?: MenuLinkComponent;
|
|
62
|
+
className?: string;
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* `min-h-11`, never `h-11`.
|
|
67
|
+
*
|
|
68
|
+
* A fixed 44px box silently clips: the categories registry hands this component
|
|
69
|
+
* strings like "Vezi toate localurile cu shaorma din Cluj-Napoca" (48 characters
|
|
70
|
+
* against ~271px of inner width at 375px), which wraps to two lines and
|
|
71
|
+
* overflows a fixed height. The floor plus real padding is the same 44px target
|
|
72
|
+
* when the label is short and grows when it is not.
|
|
73
|
+
*/
|
|
74
|
+
const CTA = cn(
|
|
75
|
+
"rounded-xl bg-accent font-bold text-accent-foreground",
|
|
76
|
+
"transition-opacity duration-150 hover:opacity-90",
|
|
77
|
+
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
|
78
|
+
"motion-reduce:transition-none"
|
|
79
|
+
);
|
|
80
|
+
|
|
81
|
+
export function TopCrossLink({
|
|
82
|
+
variant = "row",
|
|
83
|
+
kicker,
|
|
84
|
+
title,
|
|
85
|
+
description,
|
|
86
|
+
cta,
|
|
87
|
+
href,
|
|
88
|
+
gradient,
|
|
89
|
+
icon,
|
|
90
|
+
as,
|
|
91
|
+
linkComponent: LinkComponent = DefaultLink,
|
|
92
|
+
className,
|
|
93
|
+
}: TopCrossLinkProps) {
|
|
94
|
+
const isPanel = variant === "panel";
|
|
95
|
+
const Heading = as ?? (isPanel ? "h2" : "b");
|
|
96
|
+
const glyph = icon ?? (isPanel ? <Trophy /> : <LayoutList />);
|
|
97
|
+
|
|
98
|
+
if (!isPanel) {
|
|
99
|
+
return (
|
|
100
|
+
<div
|
|
101
|
+
className={cn(
|
|
102
|
+
"flex flex-col gap-3 rounded-[18px] border border-border bg-card p-4",
|
|
103
|
+
"lg:flex-row lg:items-center lg:gap-5 lg:p-5",
|
|
104
|
+
className
|
|
105
|
+
)}
|
|
106
|
+
>
|
|
107
|
+
<span aria-hidden className="shrink-0 text-muted-foreground [&>svg]:h-5 [&>svg]:w-5">
|
|
108
|
+
{glyph}
|
|
109
|
+
</span>
|
|
110
|
+
<div className="min-w-0 flex-1">
|
|
111
|
+
<Heading className="block text-[15px] font-bold tracking-[-0.01em]">{title}</Heading>
|
|
112
|
+
<p className="mt-1 text-[13px] leading-relaxed text-muted-foreground">{description}</p>
|
|
113
|
+
</div>
|
|
114
|
+
<LinkComponent
|
|
115
|
+
href={href}
|
|
116
|
+
className={cn(
|
|
117
|
+
CTA,
|
|
118
|
+
"inline-flex min-h-11 shrink-0 items-center justify-center gap-2 px-5 py-2.5 text-sm"
|
|
119
|
+
)}
|
|
120
|
+
>
|
|
121
|
+
{cta}
|
|
122
|
+
<ArrowRight className="h-4 w-4 shrink-0" aria-hidden />
|
|
123
|
+
</LinkComponent>
|
|
124
|
+
</div>
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
return (
|
|
129
|
+
<div
|
|
130
|
+
className={cn(
|
|
131
|
+
"relative overflow-hidden rounded-[18px] border border-border bg-card shadow-[var(--shadow-card)]",
|
|
132
|
+
className
|
|
133
|
+
)}
|
|
134
|
+
>
|
|
135
|
+
{/* An inset span rather than a background on the card, so the ground can
|
|
136
|
+
be a finished gradient string without fighting `bg-card` for the
|
|
137
|
+
shorthand — the same construction `TopRankingHero` uses. */}
|
|
138
|
+
{gradient && (
|
|
139
|
+
<span aria-hidden className="absolute inset-0" style={{ backgroundImage: gradient }} />
|
|
140
|
+
)}
|
|
141
|
+
|
|
142
|
+
<div className="relative z-[1] p-4 lg:p-5">
|
|
143
|
+
{kicker && (
|
|
144
|
+
<span className="flex items-center gap-1.5 text-[10.5px] font-extrabold uppercase tracking-[0.13em] text-muted-foreground">
|
|
145
|
+
<span aria-hidden className="[&>svg]:h-3 [&>svg]:w-3 [&>svg]:shrink-0">{glyph}</span>
|
|
146
|
+
{kicker}
|
|
147
|
+
</span>
|
|
148
|
+
)}
|
|
149
|
+
|
|
150
|
+
{/* Serif, and a tier under the page's own h1. The card is a window onto
|
|
151
|
+
the ranking, so it speaks in the destination's voice. */}
|
|
152
|
+
<Heading className="mt-2 block font-serif text-[20px] font-bold leading-[1.12] tracking-[-0.02em] text-foreground lg:text-[21px]">
|
|
153
|
+
{title}
|
|
154
|
+
</Heading>
|
|
155
|
+
|
|
156
|
+
<p className="mt-1.5 text-[13px] leading-relaxed text-muted-foreground">{description}</p>
|
|
157
|
+
|
|
158
|
+
<LinkComponent
|
|
159
|
+
href={href}
|
|
160
|
+
className={cn(
|
|
161
|
+
CTA,
|
|
162
|
+
"mt-4 flex min-h-11 w-full items-center justify-between gap-3 px-4 py-3 text-left text-[13.5px] leading-snug"
|
|
163
|
+
)}
|
|
164
|
+
>
|
|
165
|
+
<span>{cta}</span>
|
|
166
|
+
<ArrowRight className="h-4 w-4 shrink-0" aria-hidden />
|
|
167
|
+
</LinkComponent>
|
|
168
|
+
</div>
|
|
169
|
+
</div>
|
|
170
|
+
);
|
|
171
|
+
}
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/react";
|
|
2
|
+
import { TopRankingEntry } from "./top-ranking-entry";
|
|
3
|
+
import type { TopRankEntry } from "./top-types";
|
|
4
|
+
import type { MenuItemPreview } from "./menu-types";
|
|
5
|
+
|
|
6
|
+
const labels = {
|
|
7
|
+
signature: "Signature",
|
|
8
|
+
openDish: (name: string) => `See ${name}`,
|
|
9
|
+
itemHref: (item: MenuItemPreview) =>
|
|
10
|
+
item.slug ? `/menu/${item.kind === "drink" ? "drinks" : "dishes"}/${item.slug}` : undefined,
|
|
11
|
+
item: {
|
|
12
|
+
currency: "RON",
|
|
13
|
+
mentions: (count: number) => `${count} mentions`,
|
|
14
|
+
signature: "SIGNATURE",
|
|
15
|
+
mentioned: "MENTIONED",
|
|
16
|
+
},
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
const entry: TopRankEntry = {
|
|
20
|
+
rank: 1,
|
|
21
|
+
slug: "salt-and-stone",
|
|
22
|
+
name: "Salt & Stone",
|
|
23
|
+
href: "/places/salt-and-stone",
|
|
24
|
+
district: "Centru",
|
|
25
|
+
gradeLabel: "9.4",
|
|
26
|
+
tierLabel: "Exceptional",
|
|
27
|
+
editorial:
|
|
28
|
+
"The tagliatelle is the reason to book a week ahead — the truffle is shaved at the table and the pasta is made that morning.",
|
|
29
|
+
menuGroupLabel: "Pasta",
|
|
30
|
+
menuItems: [
|
|
31
|
+
{
|
|
32
|
+
id: "m1",
|
|
33
|
+
slug: "truffle-tagliatelle",
|
|
34
|
+
name: "Truffle Tagliatelle",
|
|
35
|
+
kind: "dish" as const,
|
|
36
|
+
description: "Fresh pasta, black truffle, parmesan.",
|
|
37
|
+
mentions: 17,
|
|
38
|
+
sizes_and_prices: [{ size: 320, price: 62 }],
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
id: "m2",
|
|
42
|
+
slug: "classic-tiramisu",
|
|
43
|
+
name: "Classic Tiramisù",
|
|
44
|
+
kind: "dish" as const,
|
|
45
|
+
mentions: 9,
|
|
46
|
+
sizes_and_prices: [{ size: 180, price: 32 }],
|
|
47
|
+
},
|
|
48
|
+
],
|
|
49
|
+
evidence: {
|
|
50
|
+
quote: "Best carbonara in the city and they will tell you off, politely, if you ask for cream.",
|
|
51
|
+
sourceLabel: "17 from reviews",
|
|
52
|
+
},
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
const meta: Meta<typeof TopRankingEntry> = {
|
|
56
|
+
title: "Top/TopRankingEntry",
|
|
57
|
+
component: TopRankingEntry,
|
|
58
|
+
tags: ["autodocs"],
|
|
59
|
+
args: {
|
|
60
|
+
entry,
|
|
61
|
+
labels,
|
|
62
|
+
formatPrice: (amount: number) => `${amount} RON`,
|
|
63
|
+
markGradient: "linear-gradient(150deg, oklch(0.56 0.15 38), oklch(0.46 0.14 34))",
|
|
64
|
+
},
|
|
65
|
+
decorators: [
|
|
66
|
+
(Story) => (
|
|
67
|
+
<div className="w-[1000px] bg-background p-4">
|
|
68
|
+
<Story />
|
|
69
|
+
</div>
|
|
70
|
+
),
|
|
71
|
+
],
|
|
72
|
+
parameters: {
|
|
73
|
+
docs: {
|
|
74
|
+
description: {
|
|
75
|
+
component:
|
|
76
|
+
"A numbered editorial entry, not a card. Cards imply a grid you filter and force every entry into the same height — wrong when one kitchen has a three-line quote and another has a sentence. **Evidence leads**: the cited review is the entry's first content, because a rank is a claim and the page has to show why before it asks you to accept the position. The entry goes to the venue; the signature dish opens the drawer.",
|
|
77
|
+
},
|
|
78
|
+
},
|
|
79
|
+
},
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
export default meta;
|
|
83
|
+
type Story = StoryObj<typeof TopRankingEntry>;
|
|
84
|
+
|
|
85
|
+
export const Default: Story = {};
|
|
86
|
+
|
|
87
|
+
/** No grade yet — the third-party star is the fallback, never a blank. */
|
|
88
|
+
export const Ungraded: Story = {
|
|
89
|
+
args: {
|
|
90
|
+
entry: { ...entry, gradeLabel: undefined, tierLabel: undefined, rating: 4.6 },
|
|
91
|
+
},
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
/** A must-try with no live menu row: `MenuItem` renders it inert, not linked. */
|
|
95
|
+
export const InertDish: Story = {
|
|
96
|
+
args: {
|
|
97
|
+
entry: {
|
|
98
|
+
...entry,
|
|
99
|
+
menuItems: [{ ...entry.menuItems![0], slug: "", sizes_and_prices: undefined }],
|
|
100
|
+
},
|
|
101
|
+
},
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
/** No evidence, no editorial, no menu — the entry is still a legible rank. */
|
|
105
|
+
export const Bare: Story = {
|
|
106
|
+
args: {
|
|
107
|
+
entry: { ...entry, editorial: undefined, evidence: undefined, menuItems: undefined },
|
|
108
|
+
},
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* On the phone the rank, name, district and grade sit INSIDE a 3:2 cover under
|
|
113
|
+
* a scrim, and the cover halves. It is one DOM copy placed in the same grid
|
|
114
|
+
* cell, not a `hidden`/`lg:hidden` twin — 25 duplicated headings and venue
|
|
115
|
+
* links is not something to ship on the page whose value is search.
|
|
116
|
+
*/
|
|
117
|
+
export const Mobile: Story = {
|
|
118
|
+
decorators: [
|
|
119
|
+
(Story) => (
|
|
120
|
+
<div className="w-[390px] bg-background p-3">
|
|
121
|
+
<Story />
|
|
122
|
+
</div>
|
|
123
|
+
),
|
|
124
|
+
],
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* The contrast worst case: a blown-out cover. The scrim's ramp is anchored in
|
|
129
|
+
* PIXELS to the block's `pt-12` runway, so the first glyph always lands at
|
|
130
|
+
* alpha 0.72 — 7.3:1 on the name against pure white — whatever the handset
|
|
131
|
+
* width or how the name wraps.
|
|
132
|
+
*/
|
|
133
|
+
export const MobileBrightCover: Story = {
|
|
134
|
+
...Mobile,
|
|
135
|
+
args: {
|
|
136
|
+
entry: {
|
|
137
|
+
...entry,
|
|
138
|
+
coverUrl:
|
|
139
|
+
"data:image/svg+xml;utf8," +
|
|
140
|
+
encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" width="8" height="8"><rect width="8" height="8" fill="#ffffff"/></svg>'),
|
|
141
|
+
},
|
|
142
|
+
},
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
/** A name that wraps. It clamps at two lines and the row stays baseline-aligned. */
|
|
146
|
+
export const MobileLongName: Story = {
|
|
147
|
+
...Mobile,
|
|
148
|
+
args: {
|
|
149
|
+
entry: { ...entry, name: "PASQUALE | brunch & dinner, Piața Unirii" },
|
|
150
|
+
},
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* No photograph and no grade at once: the warm ground with the venue's initial
|
|
155
|
+
* — never a grey box — and the filled star fallback. The desktop keeps its
|
|
156
|
+
* diagonal stripes, which are a LIGHT surface and would put white type on a
|
|
157
|
+
* pale rectangle at this size.
|
|
158
|
+
*/
|
|
159
|
+
export const MobileNoPhotoNoGrade: Story = {
|
|
160
|
+
...Mobile,
|
|
161
|
+
args: {
|
|
162
|
+
entry: {
|
|
163
|
+
...entry,
|
|
164
|
+
coverUrl: undefined,
|
|
165
|
+
gradeLabel: undefined,
|
|
166
|
+
tierLabel: undefined,
|
|
167
|
+
rating: 4.6,
|
|
168
|
+
},
|
|
169
|
+
},
|
|
170
|
+
};
|
|
171
|
+
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { render, screen } from "@testing-library/react";
|
|
2
|
+
import { describe, expect, it } from "vitest";
|
|
3
|
+
import { TopRankingEntry } from "./top-ranking-entry";
|
|
4
|
+
import type { TopRankEntry } from "./top-types";
|
|
5
|
+
|
|
6
|
+
const labels = {
|
|
7
|
+
signature: "Signature",
|
|
8
|
+
openDish: (name: string) => `See ${name}`,
|
|
9
|
+
itemHref: () => undefined,
|
|
10
|
+
item: {
|
|
11
|
+
currency: "RON",
|
|
12
|
+
mentions: (count: number) => `${count} mentions`,
|
|
13
|
+
signature: "SIGNATURE",
|
|
14
|
+
mentioned: "MENTIONED",
|
|
15
|
+
},
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
const entry: TopRankEntry = {
|
|
19
|
+
rank: 1,
|
|
20
|
+
slug: "pasquale",
|
|
21
|
+
name: "PASQUALE | brunch & dinner",
|
|
22
|
+
href: "/places/pasquale",
|
|
23
|
+
district: "Centru",
|
|
24
|
+
gradeLabel: "9.4",
|
|
25
|
+
tierLabel: "Exceptional",
|
|
26
|
+
coverUrl: "https://example.test/cover.jpg",
|
|
27
|
+
editorial: "Locals keep coming back.",
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
function renderEntry(override: Partial<TopRankEntry> = {}) {
|
|
31
|
+
return render(
|
|
32
|
+
<TopRankingEntry
|
|
33
|
+
entry={{ ...entry, ...override }}
|
|
34
|
+
labels={labels}
|
|
35
|
+
formatPrice={(amount) => `${amount} RON`}
|
|
36
|
+
/>
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* The phone overlay puts white type over a photograph, so its legibility rests
|
|
42
|
+
* entirely on the scrim behind it. That scrim is a long arbitrary-value class
|
|
43
|
+
* passed through `cn()` — i.e. through `tailwind-merge`, which is allowed to
|
|
44
|
+
* DROP a class it believes a later one supersedes. If it ever does, nothing
|
|
45
|
+
* errors: the type just goes white-on-white, which is precisely how this
|
|
46
|
+
* shipped twice.
|
|
47
|
+
*/
|
|
48
|
+
describe("the phone overlay", () => {
|
|
49
|
+
it("keeps the scrim on the identity block after class merging", () => {
|
|
50
|
+
renderEntry();
|
|
51
|
+
const block = screen.getByRole("heading", { level: 3 }).closest("div")?.parentElement;
|
|
52
|
+
expect(block?.className).toContain("linear-gradient(180deg,oklch(0.16_0.012_55_/_0)");
|
|
53
|
+
// …and clears it again at desktop, where the block is on paper.
|
|
54
|
+
expect(block?.className).toContain("lg:bg-none");
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("places the identity block in the cover's grid cell", () => {
|
|
58
|
+
renderEntry();
|
|
59
|
+
const block = screen.getByRole("heading", { level: 3 }).closest("div")?.parentElement;
|
|
60
|
+
expect(block?.className).toContain("col-start-1");
|
|
61
|
+
expect(block?.className).toContain("row-start-1");
|
|
62
|
+
expect(block?.className).toContain("self-end");
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
/*
|
|
66
|
+
* A non-positioned box's background paints in an earlier phase than replaced
|
|
67
|
+
* inline content, so an unpositioned overlay puts its scrim UNDER the
|
|
68
|
+
* photograph — the scrim vanishes, the white type lands on bare image, and
|
|
69
|
+
* nothing anywhere reports an error.
|
|
70
|
+
*/
|
|
71
|
+
it("positions the identity block so its scrim paints above the photograph", () => {
|
|
72
|
+
renderEntry();
|
|
73
|
+
const block = screen.getByRole("heading", { level: 3 }).closest("div")?.parentElement;
|
|
74
|
+
expect(block?.className).toContain("relative");
|
|
75
|
+
expect(block?.className).toContain("z-[1]");
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
/*
|
|
79
|
+
* Every mobile-only style here is written base-first and undone with `lg:`.
|
|
80
|
+
* `max-lg:` is unusable in this project — the consuming app's Tailwind emits
|
|
81
|
+
* no `max-width` media queries at all, so those classes are dropped silently
|
|
82
|
+
* and the style simply never exists.
|
|
83
|
+
*/
|
|
84
|
+
it("uses no max-lg variants, which this project cannot compile", () => {
|
|
85
|
+
const { container } = renderEntry();
|
|
86
|
+
expect(container.innerHTML).not.toContain("max-lg:");
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("renders one heading and one venue link per entry, not a mobile twin", () => {
|
|
90
|
+
renderEntry();
|
|
91
|
+
expect(screen.getAllByRole("heading", { level: 3 })).toHaveLength(1);
|
|
92
|
+
expect(screen.getAllByRole("link", { name: entry.name })).toHaveLength(1);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("keeps the band word beside the grade rather than hiding it", () => {
|
|
96
|
+
renderEntry();
|
|
97
|
+
expect(screen.getByText("Exceptional")).toBeInTheDocument();
|
|
98
|
+
expect(screen.getByText("9.4")).toBeInTheDocument();
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("falls back to the venue's initial when there is no photograph", () => {
|
|
102
|
+
renderEntry({ coverUrl: undefined });
|
|
103
|
+
expect(screen.getByText("P")).toBeInTheDocument();
|
|
104
|
+
});
|
|
105
|
+
});
|