@bearmenu/ui 0.8.53 → 0.8.55

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.
@@ -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 { Star, MessageSquareQuote } from 'lucide-react';
5
+ import { Star, ThumbsUp, MessageSquareQuote } from 'lucide-react';
6
6
  import { jsxs, jsx } from 'react/jsx-runtime';
7
7
 
8
8
  function DefaultLink(_a) {
@@ -13,10 +13,12 @@ function MentionSignal({
13
13
  label,
14
14
  count,
15
15
  onMentions,
16
- formatCount = String
16
+ formatCount = String,
17
+ countKind = "mentions"
17
18
  }) {
19
+ const Glyph = countKind === "praise" ? ThumbsUp : MessageSquareQuote;
18
20
  const content = /* @__PURE__ */ jsxs("span", { className: "inline-flex items-center gap-0.5", children: [
19
- /* @__PURE__ */ jsx(MessageSquareQuote, { className: "h-3.5 w-3.5 opacity-60", "aria-hidden": true }),
21
+ /* @__PURE__ */ jsx(Glyph, { className: "h-3.5 w-3.5 opacity-60", "aria-hidden": true }),
20
22
  /* @__PURE__ */ jsx("span", { className: "tabular-nums", children: formatCount(count) })
21
23
  ] });
22
24
  const shared = cn(
@@ -75,6 +77,8 @@ var MenuItem = memo(function MenuItem2({
75
77
  signatureTier = "strong",
76
78
  onSignatureEvidence,
77
79
  onMentions,
80
+ countKind = "mentions",
81
+ emphasis = "price",
78
82
  externalRating,
79
83
  formatRating = (score) => score.toFixed(1),
80
84
  markGradient,
@@ -203,7 +207,16 @@ var MenuItem = memo(function MenuItem2({
203
207
  (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: [
204
208
  /* @__PURE__ */ jsxs("div", { className: "flex min-w-0 items-baseline gap-x-2", children: [
205
209
  minPrice != null && /* @__PURE__ */ jsxs("span", { className: "flex items-baseline gap-1", children: [
206
- /* @__PURE__ */ jsx("span", { className: "text-[15px] font-bold tabular-nums text-foreground", children: isPriceRange ? `${formatPrice(minPrice)}\u2013${formatPrice(maxPrice)}` : formatPrice(minPrice) }),
210
+ /* @__PURE__ */ jsx(
211
+ "span",
212
+ {
213
+ className: cn(
214
+ "tabular-nums",
215
+ emphasis === "praise" ? "text-[13px] font-medium text-muted-foreground" : "text-[15px] font-bold text-foreground"
216
+ ),
217
+ children: isPriceRange ? `${formatPrice(minPrice)}\u2013${formatPrice(maxPrice)}` : formatPrice(minPrice)
218
+ }
219
+ ),
207
220
  /* @__PURE__ */ jsx("span", { className: "text-[11px] text-muted-foreground", children: labels.currency })
208
221
  ] }),
209
222
  hasQuantity && /* @__PURE__ */ jsxs("span", { className: "text-[12px] tabular-nums text-muted-foreground", children: [
@@ -225,7 +238,8 @@ var MenuItem = memo(function MenuItem2({
225
238
  label: labels.mentions(mentionCount),
226
239
  count: mentionCount,
227
240
  onMentions,
228
- formatCount
241
+ formatCount,
242
+ countKind
229
243
  }
230
244
  )
231
245
  ] })
@@ -19,7 +19,9 @@ var PopoverContent = React.forwardRef((_a, ref) => {
19
19
  sideOffset,
20
20
  collisionPadding,
21
21
  className: cn(
22
- "z-50 w-72 rounded-xl border border-border bg-popover p-4 text-popover-foreground shadow-xl outline-none",
22
+ // Drawers and sheets use layer 1000. A body-portalled popover must
23
+ // clear that layer or its visible actions cannot receive a pointer.
24
+ "z-[1010] w-72 rounded-xl border border-border bg-popover p-4 text-popover-foreground shadow-xl outline-none",
23
25
  "max-h-[var(--radix-popover-content-available-height)] overflow-y-auto overscroll-contain",
24
26
  "data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
25
27
  className
@@ -1,4 +1,4 @@
1
- import { Popover, PopoverTrigger, PopoverContent } from '../chunk-2ADCZXZJ.js';
1
+ import { Popover, PopoverTrigger, PopoverContent } from '../chunk-RXXD3UYP.js';
2
2
  import { Command, CommandInput, CommandList, CommandLoading, CommandEmpty, CommandGroup, CommandItem } from '../chunk-PGQXVJXW.js';
3
3
  import '../chunk-IQIJCMG2.js';
4
4
  import { Button } from '../chunk-GHKTVHQG.js';
@@ -111,8 +111,19 @@ type EventDetailPanelProps = {
111
111
  /** The hype tier, rendered as given (the app's own badge) in the chip row. */
112
112
  hype?: React.ReactNode;
113
113
  venue?: EventDetailVenue;
114
- /** Activity tags, after status, fee and hype. */
115
- tags?: string[];
114
+ /** Activity tags. Pass a destination to make a tag a browse action. */
115
+ tags?: (string | {
116
+ label: string;
117
+ href: string;
118
+ onClick?: () => void;
119
+ })[];
120
+ /** Entry information opened by the price/free-entry control. */
121
+ entryDetails?: {
122
+ label: string;
123
+ description: string;
124
+ action?: React.ReactNode;
125
+ onOpenChange?: (open: boolean) => void;
126
+ };
116
127
  /** Performer names, in the order the API gave them. */
117
128
  performers?: string[];
118
129
  /** How many performers to show before the "show all" chip. Default 5. */
@@ -137,7 +148,7 @@ type EventDetailPanelProps = {
137
148
  /** Rendered instead of the poster when it's absent. Defaults to `<EventPosterFallback />`. */
138
149
  fallback?: React.ReactNode;
139
150
  };
140
- declare function EventDetailPanel({ event, labels, formatters, dateLine, dateTime, status, soonLabel, hype, venue, tags, performers, performersPreview, showAllPerformersLabel, headerActions, footer, externalLink, heroHeight, size, linkComponent: LinkComponent, className, fallback, }: EventDetailPanelProps): react_jsx_runtime.JSX.Element;
151
+ declare function EventDetailPanel({ event, labels, formatters, dateLine, dateTime, status, soonLabel, hype, venue, tags, entryDetails, performers, performersPreview, showAllPerformersLabel, headerActions, footer, externalLink, heroHeight, size, linkComponent: LinkComponent, className, fallback, }: EventDetailPanelProps): react_jsx_runtime.JSX.Element;
141
152
  type EventDetailFooterProps = {
142
153
  /**
143
154
  * The one primary action — a `Button` (reserve, tickets, directions when
@@ -205,17 +216,17 @@ type EventDetailPanelSkeletonProps = {
205
216
  };
206
217
  /** Mirrors `<EventDetailPanel>`'s geometry: capped hero, two title lines, when-line, chips, venue card, line-up, about, ghost row and primary. */
207
218
  declare function EventDetailPanelSkeleton({ size, heroHeight, headerActions, label, className, }: EventDetailPanelSkeletonProps): react_jsx_runtime.JSX.Element;
208
- type EventDetailChipRowProps = Pick<EventDetailPanelProps, "event" | "formatters" | "status" | "soonLabel" | "hype" | "tags"> & {
219
+ type EventDetailChipRowProps = Pick<EventDetailPanelProps, "event" | "formatters" | "status" | "soonLabel" | "hype" | "tags" | "entryDetails" | "linkComponent"> & {
209
220
  /** Only the chip strings — a page rendering the row alone need not name the section headings. */
210
221
  labels: Pick<EventDetailPanelLabels, "free" | "liveNow" | "ended">;
211
222
  className?: string;
212
223
  };
213
224
  /**
214
- * One wrapping row, all 24px: status, fee, hype, then activity tags. Exported
225
+ * One wrapping row: metadata, entry information, then activity links. Exported
215
226
  * so the page renders the same row above its title — it is the panel's row,
216
227
  * not a second one.
217
228
  */
218
- declare function EventDetailChipRow({ event, labels, formatters, status, soonLabel, hype, tags, className, }: EventDetailChipRowProps): react_jsx_runtime.JSX.Element;
229
+ declare function EventDetailChipRow({ event, labels, formatters, status, soonLabel, hype, tags, entryDetails, linkComponent: LinkComponent, className, }: EventDetailChipRowProps): react_jsx_runtime.JSX.Element;
219
230
  type EventDetailVenueCardProps = {
220
231
  venue: EventDetailVenue;
221
232
  labels: Pick<EventDetailPanelLabels, "venueNoPage" | "grade">;
@@ -4,13 +4,14 @@ import { resolveEventEntryFee } from '../chunk-DNSXMHYX.js';
4
4
  import { Separator } from '../chunk-NWZGSLPU.js';
5
5
  import { EventPosterFallback } from '../chunk-FDITZ2VM.js';
6
6
  import { Skeleton } from '../chunk-XBBEX4SF.js';
7
+ import { Popover, PopoverTrigger, PopoverContent } from '../chunk-RXXD3UYP.js';
7
8
  import { Button } from '../chunk-GHKTVHQG.js';
8
9
  import '../chunk-RIH2IS7X.js';
9
10
  import '../chunk-5YNIQ2BL.js';
10
11
  import { cn } from '../chunk-FEK5XGZW.js';
11
12
  import { __spreadProps, __spreadValues, __objRest } from '../chunk-MMUBH76A.js';
12
13
  import * as React from 'react';
13
- import { Calendar, User, CalendarX, Unplug, RefreshCw, Timer, Gift, Ticket, MapPin, Star, ChevronRight } from 'lucide-react';
14
+ import { Calendar, User, CalendarX, Unplug, RefreshCw, Gift, Ticket, Timer, ChevronRight, MapPin, Star } from 'lucide-react';
14
15
  import { jsxs, jsx, Fragment } from 'react/jsx-runtime';
15
16
 
16
17
  function DefaultLink(_a) {
@@ -34,6 +35,7 @@ function EventDetailPanel({
34
35
  hype,
35
36
  venue,
36
37
  tags,
38
+ entryDetails,
37
39
  performers,
38
40
  performersPreview = 5,
39
41
  showAllPerformersLabel,
@@ -122,7 +124,9 @@ function EventDetailPanel({
122
124
  status,
123
125
  soonLabel,
124
126
  hype,
125
- tags
127
+ tags,
128
+ entryDetails,
129
+ linkComponent: LinkComponent
126
130
  }
127
131
  ),
128
132
  venue && /* @__PURE__ */ jsxs("div", { children: [
@@ -396,10 +400,14 @@ function EventDetailChipRow({
396
400
  soonLabel,
397
401
  hype,
398
402
  tags,
403
+ entryDetails,
404
+ linkComponent: LinkComponent = DefaultLink,
399
405
  className
400
406
  }) {
401
407
  const fee = resolveEventEntryFee(event);
402
- return /* @__PURE__ */ jsxs("div", { className: cn("flex flex-wrap gap-[7px]", className), children: [
408
+ const priceLabel = fee.kind === "free" ? labels.free : fee.kind === "paid" ? formatters.price(fee.amountRon) : null;
409
+ const priceIcon = fee.kind === "free" ? /* @__PURE__ */ jsx(Gift, { className: "h-3.5 w-3.5", "aria-hidden": true }) : /* @__PURE__ */ jsx(Ticket, { className: "h-3.5 w-3.5", "aria-hidden": true });
410
+ return /* @__PURE__ */ jsxs("div", { className: cn("flex flex-wrap items-center gap-[7px]", className), children: [
403
411
  status === "live" && /* @__PURE__ */ jsxs(
404
412
  "span",
405
413
  {
@@ -436,7 +444,35 @@ function EventDetailChipRow({
436
444
  children: labels.ended
437
445
  }
438
446
  ),
439
- fee.kind === "free" && /* @__PURE__ */ jsxs(
447
+ priceLabel && entryDetails ? /* @__PURE__ */ jsxs(Popover, { onOpenChange: entryDetails.onOpenChange, children: [
448
+ /* @__PURE__ */ jsx(PopoverTrigger, { asChild: true, children: /* @__PURE__ */ jsxs(
449
+ Button,
450
+ {
451
+ variant: "outline",
452
+ size: "sm",
453
+ className: "min-h-11 rounded-full",
454
+ "aria-label": entryDetails.label,
455
+ children: [
456
+ priceIcon,
457
+ priceLabel,
458
+ /* @__PURE__ */ jsx(ChevronRight, { className: "h-3.5 w-3.5", "aria-hidden": true })
459
+ ]
460
+ }
461
+ ) }),
462
+ /* @__PURE__ */ jsxs(
463
+ PopoverContent,
464
+ {
465
+ align: "start",
466
+ "aria-label": entryDetails.label,
467
+ className: "flex max-w-[calc(100vw-2rem)] flex-col gap-3",
468
+ children: [
469
+ /* @__PURE__ */ jsx("p", { className: "text-sm font-semibold", children: priceLabel }),
470
+ /* @__PURE__ */ jsx("p", { className: "text-sm text-muted-foreground", children: entryDetails.description }),
471
+ entryDetails.action
472
+ ]
473
+ }
474
+ )
475
+ ] }) : fee.kind === "free" ? /* @__PURE__ */ jsxs(
440
476
  "span",
441
477
  {
442
478
  className: cn(
@@ -448,13 +484,27 @@ function EventDetailChipRow({
448
484
  labels.free
449
485
  ]
450
486
  }
451
- ),
452
- fee.kind === "paid" && /* @__PURE__ */ jsxs("span", { className: cn(CHIP, "text-foreground"), children: [
487
+ ) : fee.kind === "paid" ? /* @__PURE__ */ jsxs("span", { className: cn(CHIP, "text-foreground"), children: [
453
488
  /* @__PURE__ */ jsx(Ticket, { className: "h-3 w-3", "aria-hidden": true }),
454
489
  formatters.price(fee.amountRon)
455
- ] }),
490
+ ] }) : null,
456
491
  hype,
457
- tags == null ? void 0 : tags.map((tag) => /* @__PURE__ */ jsx("span", { className: CHIP, children: tag }, tag))
492
+ tags == null ? void 0 : tags.map(
493
+ (tag) => typeof tag === "string" ? /* @__PURE__ */ jsx("span", { className: "text-xs text-muted-foreground", children: tag }, tag) : /* @__PURE__ */ jsx(
494
+ Button,
495
+ {
496
+ asChild: true,
497
+ variant: "outline",
498
+ size: "sm",
499
+ className: "min-h-11 rounded-full",
500
+ children: /* @__PURE__ */ jsxs(LinkComponent, { href: tag.href, onClick: tag.onClick, children: [
501
+ tag.label,
502
+ /* @__PURE__ */ jsx(ChevronRight, { className: "h-3.5 w-3.5", "aria-hidden": true })
503
+ ] })
504
+ },
505
+ tag.href
506
+ )
507
+ )
458
508
  ] });
459
509
  }
460
510
  function EventDetailVenueCard({
@@ -64,6 +64,18 @@ type MenuItemLabels = {
64
64
  */
65
65
  externalRating?: (score: string) => string;
66
66
  };
67
+ /**
68
+ * What the count beside the row counts.
69
+ *
70
+ * - `"mentions"`: every review that named the item, complaints included. Drawn
71
+ * with the quote glyph.
72
+ * - `"praise"`: only the reviews that praised it. Drawn with a thumbs-up.
73
+ *
74
+ * The caller decides, because only the caller knows which number it passed in
75
+ * `item.mentions`. A thumbs-up must never sit on a count that includes
76
+ * complaints, so `"mentions"` is the default.
77
+ */
78
+ type MenuItemCountKind = "praise" | "mentions";
67
79
  type MenuItemProps = {
68
80
  item: MenuItemPreview;
69
81
  labels: MenuItemLabels;
@@ -85,6 +97,20 @@ type MenuItemProps = {
85
97
  onSignatureEvidence?: () => void;
86
98
  /** Opens the reviews behind the mention count. */
87
99
  onMentions?: () => void;
100
+ /**
101
+ * Whether the count is praise or every mention. Sets the glyph before the
102
+ * numeral: a thumbs-up for `"praise"`, the quote glyph for `"mentions"`.
103
+ * Defaults to `"mentions"`. Keep `labels.mentions` in step with it.
104
+ */
105
+ countKind?: MenuItemCountKind;
106
+ /**
107
+ * What the meta row leads with. `"price"` (default): the price is bold, the
108
+ * count's peer at the other end. `"praise"`: on surfaces where reviews'
109
+ * praise is the point (the map's lists), the price steps down to secondary
110
+ * text, a fact to check rather than the row's headline, and the praise count
111
+ * keeps the bold accent.
112
+ */
113
+ emphasis?: "price" | "praise";
88
114
  /**
89
115
  * An outside aggregate score for the item (a wine's Vivino rating, a beer's
90
116
  * Untappd one), shown as a star and the score beside the mention count.
@@ -128,17 +154,20 @@ type MenuItemProps = {
128
154
  className?: string;
129
155
  };
130
156
  /**
131
- * The mention count: a filled quote glyph and the numeral, pinned right.
157
+ * The mention count: a glyph and the numeral, pinned right. The glyph is the
158
+ * quote glyph for every mention, or a thumbs-up when the count is praise only
159
+ * (see `MenuItemCountKind`).
132
160
  *
133
161
  * Deliberately NOT a badge — a chip would add a filled shape to a row that has
134
162
  * none, and would read as a status label the venue was given rather than a
135
163
  * count drawn from the same reviews.
136
164
  */
137
- declare function MentionSignal({ label, count, onMentions, formatCount, }: {
165
+ declare function MentionSignal({ label, count, onMentions, formatCount, countKind, }: {
138
166
  label: string;
139
167
  count: number;
140
168
  onMentions?: () => void;
141
169
  formatCount?: (value: number) => string;
170
+ countKind?: MenuItemCountKind;
142
171
  }): react_jsx_runtime.JSX.Element;
143
172
  /**
144
173
  * An external aggregate score: a star and the numeral, in the neutral muted
@@ -152,4 +181,4 @@ declare function ExternalRatingSignal({ label, value }: {
152
181
  }): react_jsx_runtime.JSX.Element;
153
182
  declare const MenuItem: React.NamedExoticComponent<MenuItemProps>;
154
183
 
155
- export { ExternalRatingSignal, MentionSignal, MenuItem, type MenuItemLabels, type MenuItemLinkProps, type MenuItemProps };
184
+ export { ExternalRatingSignal, MentionSignal, MenuItem, type MenuItemCountKind, type MenuItemLabels, type MenuItemLinkProps, type MenuItemProps };
@@ -1,4 +1,4 @@
1
- export { ExternalRatingSignal, MentionSignal, MenuItem } from '../chunk-4WW3HPIW.js';
1
+ export { ExternalRatingSignal, MentionSignal, MenuItem } from '../chunk-CBBECMBD.js';
2
2
  import '../chunk-6PQINSAA.js';
3
3
  import '../chunk-RYPPN2GZ.js';
4
4
  import '../chunk-FEK5XGZW.js';
@@ -1,5 +1,5 @@
1
1
  import { Checkbox } from '../chunk-L3CDXVEP.js';
2
- import { Popover, PopoverTrigger, PopoverContent } from '../chunk-2ADCZXZJ.js';
2
+ import { Popover, PopoverTrigger, PopoverContent } from '../chunk-RXXD3UYP.js';
3
3
  import { Command, CommandInput, CommandList, CommandEmpty, CommandGroup, CommandItem } from '../chunk-PGQXVJXW.js';
4
4
  import '../chunk-IQIJCMG2.js';
5
5
  import { Button } from '../chunk-GHKTVHQG.js';
@@ -1,3 +1,3 @@
1
- export { Popover, PopoverAnchor, PopoverArrow, PopoverClose, PopoverContent, PopoverDescription, PopoverHeader, PopoverTitle, PopoverTrigger } from '../chunk-2ADCZXZJ.js';
1
+ export { Popover, PopoverAnchor, PopoverArrow, PopoverClose, PopoverContent, PopoverDescription, PopoverHeader, PopoverTitle, PopoverTrigger } from '../chunk-RXXD3UYP.js';
2
2
  import '../chunk-FEK5XGZW.js';
3
3
  import '../chunk-MMUBH76A.js';
@@ -1,4 +1,4 @@
1
- import { Popover, PopoverTrigger, PopoverContent } from '../chunk-2ADCZXZJ.js';
1
+ import { Popover, PopoverTrigger, PopoverContent } from '../chunk-RXXD3UYP.js';
2
2
  import { Command, CommandInput, CommandList, CommandEmpty, CommandGroup, CommandItem } from '../chunk-PGQXVJXW.js';
3
3
  import '../chunk-IQIJCMG2.js';
4
4
  import { Button } from '../chunk-GHKTVHQG.js';
@@ -93,7 +93,9 @@ var TabsTrigger = React.forwardRef((_a, ref) => {
93
93
  transition: archetype.indicatorMorph,
94
94
  className: cn(
95
95
  "absolute inset-0 -z-0 motion-reduce:hidden",
96
- variant === "pill" && "rounded-xl bg-background shadow-sm",
96
+ // Dark: `bg-background` is darker than the `bg-muted` track, so the
97
+ // selection read as a hole; it lifts to the raised surface instead.
98
+ variant === "pill" && "rounded-xl bg-background shadow-sm dark:bg-surface-2 dark:ring-1 dark:ring-border",
97
99
  variant === "underline" && orientation === "horizontal" && "border-b-2 border-primary",
98
100
  variant === "underline" && orientation === "vertical" && "border-r-2 border-primary"
99
101
  )
@@ -105,7 +107,9 @@ var TabsTrigger = React.forwardRef((_a, ref) => {
105
107
  "aria-hidden": true,
106
108
  className: cn(
107
109
  "absolute inset-0 -z-0 hidden motion-reduce:block",
108
- variant === "pill" && "rounded-xl bg-background shadow-sm",
110
+ // Dark: `bg-background` is darker than the `bg-muted` track, so the
111
+ // selection read as a hole; it lifts to the raised surface instead.
112
+ variant === "pill" && "rounded-xl bg-background shadow-sm dark:bg-surface-2 dark:ring-1 dark:ring-border",
109
113
  variant === "underline" && orientation === "horizontal" && "border-b-2 border-primary",
110
114
  variant === "underline" && orientation === "vertical" && "border-r-2 border-primary"
111
115
  )
@@ -1,4 +1,4 @@
1
- import { MenuItem } from '../chunk-4WW3HPIW.js';
1
+ import { MenuItem } from '../chunk-CBBECMBD.js';
2
2
  import '../chunk-6PQINSAA.js';
3
3
  import { Separator } from '../chunk-NWZGSLPU.js';
4
4
  import { Skeleton } from '../chunk-XBBEX4SF.js';
package/package.json CHANGED
@@ -1,7 +1,8 @@
1
1
  {
2
2
  "name": "@bearmenu/ui",
3
- "version": "0.8.53",
4
- "description": "BearMenu design system — shared UI tokens, primitives, and components",
3
+ "version": "0.8.55",
4
+ "packageManager": "pnpm@10.28.1",
5
+ "description": "BearMenu design system \u2014 shared UI tokens, primitives, and components",
5
6
  "license": "UNLICENSED",
6
7
  "repository": {
7
8
  "type": "git",
@@ -16,20 +17,226 @@
16
17
  "src"
17
18
  ],
18
19
  "exports": {
20
+ "./components/accordion": "./src/components/accordion.tsx",
21
+ "./components/action-bar": "./src/components/action-bar.tsx",
22
+ "./components/activity-row": "./src/components/activity-row.tsx",
23
+ "./components/alert": "./src/components/alert.tsx",
24
+ "./components/alert-dialog": "./src/components/alert-dialog.tsx",
25
+ "./components/aspect-ratio": "./src/components/aspect-ratio.tsx",
26
+ "./components/avatar": "./src/components/avatar.tsx",
27
+ "./components/badge": "./src/components/badge.tsx",
28
+ "./components/banner": "./src/components/banner.tsx",
29
+ "./components/booking-row": "./src/components/booking-row.tsx",
30
+ "./components/booking-summary-card": "./src/components/booking-summary-card.tsx",
31
+ "./components/breadcrumb": "./src/components/breadcrumb.tsx",
32
+ "./components/button": "./src/components/button.tsx",
33
+ "./components/button-group": "./src/components/button-group.tsx",
34
+ "./components/calendar": "./src/components/calendar.tsx",
35
+ "./components/card": "./src/components/card.tsx",
36
+ "./components/carousel": "./src/components/carousel.tsx",
37
+ "./components/category-deck": "./src/components/category-deck.tsx",
38
+ "./components/checkbox": "./src/components/checkbox.tsx",
39
+ "./components/circular-progress": "./src/components/circular-progress.tsx",
40
+ "./components/coach-marks": "./src/components/coach-marks.tsx",
41
+ "./components/collapsible": "./src/components/collapsible.tsx",
42
+ "./components/combobox": "./src/components/combobox.tsx",
43
+ "./components/command": "./src/components/command.tsx",
44
+ "./components/concept-card": "./src/components/concept-card.tsx",
45
+ "./components/concept-compare": "./src/components/concept-compare.tsx",
46
+ "./components/concept-compare-rail": "./src/components/concept-compare-rail.tsx",
47
+ "./components/concept-convergence": "./src/components/concept-convergence.tsx",
48
+ "./components/convergence": "./src/components/convergence.tsx",
49
+ "./components/coverflow": "./src/components/coverflow.tsx",
50
+ "./components/currency-input": "./src/components/currency-input.tsx",
51
+ "./components/date-input": "./src/components/date-input.tsx",
52
+ "./components/day-rail-chip": "./src/components/day-rail-chip.tsx",
53
+ "./components/description-list": "./src/components/description-list.tsx",
54
+ "./components/dialog": "./src/components/dialog.tsx",
55
+ "./components/doorway-tiles": "./src/components/doorway-tiles.tsx",
56
+ "./components/drawer": "./src/components/drawer.tsx",
57
+ "./components/drift-rail": "./src/components/drift-rail.tsx",
58
+ "./components/dropdown-menu": "./src/components/dropdown-menu.tsx",
59
+ "./components/empty-state": "./src/components/empty-state.tsx",
60
+ "./components/event-card": "./src/components/event-card.tsx",
61
+ "./components/event-compact-row": "./src/components/event-compact-row.tsx",
62
+ "./components/event-day-header": "./src/components/event-day-header.tsx",
63
+ "./components/event-day-separator": "./src/components/event-day-separator.tsx",
64
+ "./components/event-detail-panel": "./src/components/event-detail-panel.tsx",
65
+ "./components/event-hub-hero": "./src/components/event-hub-hero.tsx",
66
+ "./components/event-index-row": "./src/components/event-index-row.tsx",
67
+ "./components/event-list-row": "./src/components/event-list-row.tsx",
68
+ "./components/event-list-row-wide": "./src/components/event-list-row-wide.tsx",
69
+ "./components/event-poster-fallback": "./src/components/event-poster-fallback.tsx",
70
+ "./components/event-tonight-strip": "./src/components/event-tonight-strip.tsx",
71
+ "./components/event-types": "./src/components/event-types.ts",
72
+ "./components/events-empty-state": "./src/components/events-empty-state.tsx",
73
+ "./components/explainer-steps": "./src/components/explainer-steps.tsx",
74
+ "./components/facet-group": "./src/components/facet-group.tsx",
75
+ "./components/featured-rail-item": "./src/components/featured-rail-item.tsx",
76
+ "./components/field": "./src/components/field.tsx",
77
+ "./components/filter-category-row": "./src/components/filter-category-row.tsx",
78
+ "./components/form": "./src/components/form.tsx",
79
+ "./components/glass-ground": "./src/components/glass-ground.tsx",
80
+ "./components/grade-atom": "./src/components/grade-atom.tsx",
81
+ "./components/haptic-switch": "./src/components/haptic-switch.tsx",
82
+ "./components/hold-countdown": "./src/components/hold-countdown.tsx",
83
+ "./components/hub-surface": "./src/components/hub-surface.tsx",
84
+ "./components/hue-chip": "./src/components/hue-chip.tsx",
85
+ "./components/icon": "./src/components/icon.tsx",
86
+ "./components/input": "./src/components/input.tsx",
87
+ "./components/input-group": "./src/components/input-group.tsx",
88
+ "./components/input-otp": "./src/components/input-otp.tsx",
89
+ "./components/item": "./src/components/item.tsx",
90
+ "./components/kbd": "./src/components/kbd.tsx",
91
+ "./components/label": "./src/components/label.tsx",
92
+ "./components/kitchen-dossier": "./src/components/kitchen-dossier.tsx",
93
+ "./components/lazy-markdown": "./src/components/lazy-markdown.tsx",
94
+ "./components/link-button": "./src/components/link-button.tsx",
95
+ "./components/markdown-renderer": "./src/components/markdown-renderer.tsx",
96
+ "./components/menu-dead-end": "./src/components/menu-dead-end.tsx",
97
+ "./components/menu-family-block": "./src/components/menu-family-block.tsx",
98
+ "./components/menu-family-card": "./src/components/menu-family-card.tsx",
99
+ "./components/menu-field": "./src/components/menu-field.tsx",
100
+ "./components/menu-filter-chips": "./src/components/menu-filter-chips.tsx",
101
+ "./components/menu-group-rows": "./src/components/menu-group-rows.tsx",
102
+ "./components/menu-hub-hero": "./src/components/menu-hub-hero.tsx",
103
+ "./components/menu-index-header": "./src/components/menu-index-header.tsx",
104
+ "./components/menu-index-list": "./src/components/menu-index-list.tsx",
105
+ "./components/menu-intent-hero": "./src/components/menu-intent-hero.tsx",
106
+ "./components/menu-item": "./src/components/menu-item.tsx",
107
+ "./components/menu-pick-row": "./src/components/menu-pick-row.tsx",
108
+ "./components/menu-kitchen-list": "./src/components/menu-kitchen-list.tsx",
109
+ "./components/menu-mocks": "./src/components/menu-mocks.ts",
110
+ "./components/menu-types": "./src/components/menu-types.ts",
111
+ "./components/motion-icon": "./src/components/motion-icon.tsx",
112
+ "./components/motion-icon-actions": "./src/components/motion-icon-actions.tsx",
113
+ "./components/motion-icon-controls": "./src/components/motion-icon-controls.tsx",
114
+ "./components/motion-icon-set": "./src/components/motion-icon-set.tsx",
115
+ "./components/multi-select-combobox": "./src/components/multi-select-combobox.tsx",
116
+ "./components/multi-select-menu": "./src/components/multi-select-menu.tsx",
117
+ "./components/native-select": "./src/components/native-select.tsx",
118
+ "./components/open-now-list": "./src/components/open-now-list.tsx",
119
+ "./components/pagination": "./src/components/pagination.tsx",
120
+ "./components/pan-zoom-viewport": "./src/components/pan-zoom-viewport.tsx",
121
+ "./components/party-counter": "./src/components/party-counter.tsx",
122
+ "./components/phone-frame": "./src/components/phone-frame.tsx",
123
+ "./components/photo-ground": "./src/components/photo-ground.tsx",
124
+ "./components/place-about-tab": "./src/components/place-about-tab.tsx",
125
+ "./components/place-card": "./src/components/place-card.tsx",
126
+ "./components/place-details-mobile": "./src/components/place-details-mobile.tsx",
127
+ "./components/place-schedule-week": "./src/components/place-schedule-week.tsx",
128
+ "./components/place-types": "./src/components/place-types.ts",
129
+ "./components/popover": "./src/components/popover.tsx",
130
+ "./components/poster-stack": "./src/components/poster-stack.tsx",
131
+ "./components/poster-tape": "./src/components/poster-tape.tsx",
132
+ "./components/poster-wall": "./src/components/poster-wall.tsx",
133
+ "./components/progress": "./src/components/progress.tsx",
134
+ "./components/qa-block": "./src/components/qa-block.tsx",
135
+ "./components/qr-panel": "./src/components/qr-panel.tsx",
136
+ "./components/quiet-stretch": "./src/components/quiet-stretch.tsx",
137
+ "./components/radio-group": "./src/components/radio-group.tsx",
138
+ "./components/reconcile-bar": "./src/components/reconcile-bar.tsx",
139
+ "./components/responsive-overlay": "./src/components/responsive-overlay.tsx",
140
+ "./components/rating": "./src/components/rating.tsx",
141
+ "./components/scroll-area": "./src/components/scroll-area.tsx",
142
+ "./components/scroll-fade": "./src/components/scroll-fade.tsx",
143
+ "./components/search-bar": "./src/components/search-bar.tsx",
144
+ "./components/searchable-select": "./src/components/searchable-select.tsx",
145
+ "./components/section-header": "./src/components/section-header.tsx",
146
+ "./components/segmented-nav": "./src/components/segmented-nav.tsx",
147
+ "./components/select": "./src/components/select.tsx",
148
+ "./components/selection-bar": "./src/components/selection-bar.tsx",
149
+ "./components/separator": "./src/components/separator.tsx",
150
+ "./components/sheet": "./src/components/sheet.tsx",
151
+ "./components/signature-chip": "./src/components/signature-chip.tsx",
152
+ "./components/skeleton": "./src/components/skeleton.tsx",
153
+ "./components/skeleton-card": "./src/components/skeleton-card.tsx",
154
+ "./components/slider": "./src/components/slider.tsx",
155
+ "./components/sliding-panels": "./src/components/sliding-panels.tsx",
156
+ "./components/sonner": "./src/components/sonner.tsx",
157
+ "./components/sort-option-list": "./src/components/sort-option-list.tsx",
158
+ "./components/spinner": "./src/components/spinner.tsx",
159
+ "./components/split-share-row": "./src/components/split-share-row.tsx",
160
+ "./components/stat-banner": "./src/components/stat-banner.tsx",
161
+ "./components/stepper": "./src/components/stepper.tsx",
162
+ "./components/sticky-container": "./src/components/sticky-container.tsx",
163
+ "./components/story": "./src/components/story.tsx",
164
+ "./components/switch": "./src/components/switch.tsx",
165
+ "./components/table": "./src/components/table.tsx",
166
+ "./components/table-ledger-card": "./src/components/table-ledger-card.tsx",
167
+ "./components/table-trigger-band": "./src/components/table-trigger-band.tsx",
168
+ "./components/tabs": "./src/components/tabs.tsx",
169
+ "./components/tag": "./src/components/tag.tsx",
170
+ "./components/tag-group": "./src/components/tag-group.tsx",
171
+ "./components/text": "./src/components/text.tsx",
172
+ "./components/textarea": "./src/components/textarea.tsx",
173
+ "./components/time-slot": "./src/components/time-slot.tsx",
174
+ "./components/timeline-day": "./src/components/timeline-day.tsx",
175
+ "./components/timeline-hour": "./src/components/timeline-hour.tsx",
176
+ "./components/timeline-hour-puck": "./src/components/timeline-hour-puck.tsx",
177
+ "./components/toggle": "./src/components/toggle.tsx",
178
+ "./components/toggle-group": "./src/components/toggle-group.tsx",
179
+ "./components/tooltip": "./src/components/tooltip.tsx",
180
+ "./components/top-comparison": "./src/components/top-comparison.tsx",
181
+ "./components/top-cross-link": "./src/components/top-cross-link.tsx",
182
+ "./components/top-ranking-entry": "./src/components/top-ranking-entry.tsx",
183
+ "./components/top-ranking-hero": "./src/components/top-ranking-hero.tsx",
184
+ "./components/top-ranking-legend": "./src/components/top-ranking-legend.tsx",
185
+ "./components/top-types": "./src/components/top-types.ts",
186
+ "./components/use-mobile": "./src/components/use-mobile.tsx",
187
+ "./components/venue-events-block": "./src/components/venue-events-block.tsx",
188
+ "./components/venue-grade-dimensions": "./src/components/venue-grade-dimensions.tsx",
189
+ "./components/venue-quotes": "./src/components/venue-quotes.tsx",
190
+ "./components/venue-wall": "./src/components/venue-wall.tsx",
191
+ "./components/weekday-chip": "./src/components/weekday-chip.tsx",
192
+ "./components/weekday-theme-banner": "./src/components/weekday-theme-banner.tsx",
193
+ "./components/weekend-panel": "./src/components/weekend-panel.tsx",
19
194
  "./globals.css": "./src/globals.css",
20
- "./components/*": {
21
- "types": "./dist/components/*.d.ts",
22
- "import": "./dist/components/*.js"
23
- },
24
- "./hooks/*": {
25
- "types": "./dist/hooks/*.d.ts",
26
- "import": "./dist/hooks/*.js"
27
- },
28
- "./lib/*": {
29
- "types": "./dist/lib/*.d.ts",
30
- "import": "./dist/lib/*.js"
195
+ "./hooks/use-drag-swipe": "./src/hooks/use-drag-swipe.ts",
196
+ "./hooks/use-haptic": "./src/hooks/use-haptic.ts",
197
+ "./hooks/use-media-query": "./src/hooks/use-media-query.ts",
198
+ "./hooks/use-mobile": "./src/hooks/use-mobile.ts",
199
+ "./hooks/use-mounted-theme": "./src/hooks/use-mounted-theme.ts",
200
+ "./hooks/use-sticky": "./src/hooks/use-sticky.ts",
201
+ "./lib/card-hover": "./src/lib/card-hover.ts",
202
+ "./lib/grade": "./src/lib/grade.ts",
203
+ "./lib/icon-depth": "./src/lib/icon-depth.ts",
204
+ "./lib/icon-registry": "./src/lib/icon-registry.ts",
205
+ "./lib/motion": "./src/lib/motion.ts",
206
+ "./lib/overlay-pill": "./src/lib/overlay-pill.ts",
207
+ "./lib/utils": "./src/lib/utils.ts",
208
+ "./components/filter-option-chip": "./src/components/filter-option-chip.tsx",
209
+ "./components/filter-option-group": "./src/components/filter-option-group.tsx",
210
+ "./components/filter-panel-footer": "./src/components/filter-panel-footer.tsx"
211
+ },
212
+ "publishConfig": {
213
+ "exports": {
214
+ "./globals.css": "./src/globals.css",
215
+ "./components/*": {
216
+ "types": "./dist/components/*.d.ts",
217
+ "import": "./dist/components/*.js"
218
+ },
219
+ "./hooks/*": {
220
+ "types": "./dist/hooks/*.d.ts",
221
+ "import": "./dist/hooks/*.js"
222
+ },
223
+ "./lib/*": {
224
+ "types": "./dist/lib/*.d.ts",
225
+ "import": "./dist/lib/*.js"
226
+ }
31
227
  }
32
228
  },
229
+ "scripts": {
230
+ "storybook": "storybook dev -p 6006",
231
+ "build-storybook": "storybook build",
232
+ "build": "tsup",
233
+ "dev": "tsup --watch",
234
+ "typecheck": "tsc --noEmit",
235
+ "test": "vitest run",
236
+ "test:watch": "vitest",
237
+ "clean": "rm -rf dist",
238
+ "prepublishOnly": "pnpm run build"
239
+ },
33
240
  "peerDependencies": {
34
241
  "@hookform/resolvers": "^3.0.0 || ^5.0.0",
35
242
  "@radix-ui/react-accordion": "*",
@@ -138,15 +345,5 @@
138
345
  "vaul": "^1.1.2",
139
346
  "vitest": "^4.1.5",
140
347
  "zod": "^3.25.67"
141
- },
142
- "scripts": {
143
- "storybook": "storybook dev -p 6006",
144
- "build-storybook": "storybook build",
145
- "build": "tsup",
146
- "dev": "tsup --watch",
147
- "typecheck": "tsc --noEmit",
148
- "test": "vitest run",
149
- "test:watch": "vitest",
150
- "clean": "rm -rf dist"
151
348
  }
152
- }
349
+ }
@@ -283,6 +283,22 @@ export const Loading: Story = {
283
283
  decorators: SideSheet.decorators,
284
284
  };
285
285
 
286
+ export const EntryAndBrowseActions: Story = {
287
+ args: {
288
+ tags: [{ label: "Party", href: "/events?tags=party" }],
289
+ entryDetails: {
290
+ label: "Entry and tickets",
291
+ description: "Check availability with the organizer.",
292
+ action: (
293
+ <Button asChild fullWidth>
294
+ <a href="https://tickets.example.com">Get tickets</a>
295
+ </Button>
296
+ ),
297
+ },
298
+ },
299
+ decorators: SideSheet.decorators,
300
+ };
301
+
286
302
  export const Error: Story = {
287
303
  render: () => (
288
304
  <EventDetailPanelError
@@ -37,6 +37,42 @@ const event: EventPreview = {
37
37
  const base = { event, labels, formatters, dateLine: "Tuesday · 17:30" };
38
38
 
39
39
  describe("EventDetailPanel", () => {
40
+ it("opens entry information and exposes a separate booking link", () => {
41
+ render(
42
+ <EventDetailPanel
43
+ {...base}
44
+ entryDetails={{
45
+ label: "Entry and tickets",
46
+ description: "Book with the organizer.",
47
+ action: <a href="https://tickets.example.com">Get tickets</a>,
48
+ }}
49
+ />,
50
+ );
51
+ expect(
52
+ screen.queryByRole("link", { name: "Get tickets" }),
53
+ ).not.toBeInTheDocument();
54
+ fireEvent.click(screen.getByRole("button", { name: "Entry and tickets" }));
55
+ expect(screen.getByText("Book with the organizer.")).toBeVisible();
56
+ expect(screen.getByRole("link", { name: "Get tickets" })).toHaveAttribute(
57
+ "href",
58
+ "https://tickets.example.com",
59
+ );
60
+ });
61
+
62
+ it("gives browse tags destinations while keeping descriptive tags out of the tab order", () => {
63
+ render(
64
+ <EventDetailPanel
65
+ {...base}
66
+ tags={["Descriptive", { label: "Party", href: "/events?tags=party" }]}
67
+ />,
68
+ );
69
+ expect(screen.getByRole("link", { name: "Party" })).toHaveAttribute(
70
+ "href",
71
+ "/events?tags=party",
72
+ );
73
+ expect(screen.getByText("Descriptive").tagName).toBe("SPAN");
74
+ });
75
+
40
76
  it("shows the status chip for live, soon and ended, and none when upcoming", () => {
41
77
  const { rerender } = render(<EventDetailPanel {...base} />);
42
78
  expect(screen.queryByText("Now")).not.toBeInTheDocument();
@@ -19,6 +19,7 @@ import { cn } from "../lib/utils";
19
19
  import { GradeAtom, type GradeAtomLabels } from "./grade-atom";
20
20
  import type { PlaceGrade } from "./place-types";
21
21
  import { Button } from "./button";
22
+ import { Popover, PopoverContent, PopoverTrigger } from "./popover";
22
23
  import { EventPosterFallback } from "./event-poster-fallback";
23
24
  import { Separator } from "./separator";
24
25
  import { Skeleton } from "./skeleton";
@@ -144,8 +145,15 @@ export type EventDetailPanelProps = {
144
145
  /** The hype tier, rendered as given (the app's own badge) in the chip row. */
145
146
  hype?: React.ReactNode;
146
147
  venue?: EventDetailVenue;
147
- /** Activity tags, after status, fee and hype. */
148
- tags?: string[];
148
+ /** Activity tags. Pass a destination to make a tag a browse action. */
149
+ tags?: (string | { label: string; href: string; onClick?: () => void })[];
150
+ /** Entry information opened by the price/free-entry control. */
151
+ entryDetails?: {
152
+ label: string;
153
+ description: string;
154
+ action?: React.ReactNode;
155
+ onOpenChange?: (open: boolean) => void;
156
+ };
149
157
  /** Performer names, in the order the API gave them. */
150
158
  performers?: string[];
151
159
  /** How many performers to show before the "show all" chip. Default 5. */
@@ -197,6 +205,7 @@ export function EventDetailPanel({
197
205
  hype,
198
206
  venue,
199
207
  tags,
208
+ entryDetails,
200
209
  performers,
201
210
  performersPreview = 5,
202
211
  showAllPerformersLabel,
@@ -289,6 +298,8 @@ export function EventDetailPanel({
289
298
  soonLabel={soonLabel}
290
299
  hype={hype}
291
300
  tags={tags}
301
+ entryDetails={entryDetails}
302
+ linkComponent={LinkComponent}
292
303
  />
293
304
 
294
305
  {venue && (
@@ -675,7 +686,14 @@ export function EventDetailPanelSkeleton({
675
686
 
676
687
  export type EventDetailChipRowProps = Pick<
677
688
  EventDetailPanelProps,
678
- "event" | "formatters" | "status" | "soonLabel" | "hype" | "tags"
689
+ | "event"
690
+ | "formatters"
691
+ | "status"
692
+ | "soonLabel"
693
+ | "hype"
694
+ | "tags"
695
+ | "entryDetails"
696
+ | "linkComponent"
679
697
  > & {
680
698
  /** Only the chip strings — a page rendering the row alone need not name the section headings. */
681
699
  labels: Pick<EventDetailPanelLabels, "free" | "liveNow" | "ended">;
@@ -683,7 +701,7 @@ export type EventDetailChipRowProps = Pick<
683
701
  };
684
702
 
685
703
  /**
686
- * One wrapping row, all 24px: status, fee, hype, then activity tags. Exported
704
+ * One wrapping row: metadata, entry information, then activity links. Exported
687
705
  * so the page renders the same row above its title — it is the panel's row,
688
706
  * not a second one.
689
707
  */
@@ -695,11 +713,25 @@ export function EventDetailChipRow({
695
713
  soonLabel,
696
714
  hype,
697
715
  tags,
716
+ entryDetails,
717
+ linkComponent: LinkComponent = DefaultLink,
698
718
  className,
699
719
  }: EventDetailChipRowProps) {
700
720
  const fee = resolveEventEntryFee(event);
721
+ const priceLabel =
722
+ fee.kind === "free"
723
+ ? labels.free
724
+ : fee.kind === "paid"
725
+ ? formatters.price(fee.amountRon)
726
+ : null;
727
+ const priceIcon =
728
+ fee.kind === "free" ? (
729
+ <Gift className="h-3.5 w-3.5" aria-hidden />
730
+ ) : (
731
+ <Ticket className="h-3.5 w-3.5" aria-hidden />
732
+ );
701
733
  return (
702
- <div className={cn("flex flex-wrap gap-[7px]", className)}>
734
+ <div className={cn("flex flex-wrap items-center gap-[7px]", className)}>
703
735
  {status === "live" && (
704
736
  <span
705
737
  className={cn(
@@ -732,7 +764,33 @@ export function EventDetailChipRow({
732
764
  {labels.ended}
733
765
  </span>
734
766
  )}
735
- {fee.kind === "free" && (
767
+ {priceLabel && entryDetails ? (
768
+ <Popover onOpenChange={entryDetails.onOpenChange}>
769
+ <PopoverTrigger asChild>
770
+ <Button
771
+ variant="outline"
772
+ size="sm"
773
+ className="min-h-11 rounded-full"
774
+ aria-label={entryDetails.label}
775
+ >
776
+ {priceIcon}
777
+ {priceLabel}
778
+ <ChevronRight className="h-3.5 w-3.5" aria-hidden />
779
+ </Button>
780
+ </PopoverTrigger>
781
+ <PopoverContent
782
+ align="start"
783
+ aria-label={entryDetails.label}
784
+ className="flex max-w-[calc(100vw-2rem)] flex-col gap-3"
785
+ >
786
+ <p className="text-sm font-semibold">{priceLabel}</p>
787
+ <p className="text-sm text-muted-foreground">
788
+ {entryDetails.description}
789
+ </p>
790
+ {entryDetails.action}
791
+ </PopoverContent>
792
+ </Popover>
793
+ ) : fee.kind === "free" ? (
736
794
  <span
737
795
  className={cn(
738
796
  CHIP,
@@ -742,19 +800,33 @@ export function EventDetailChipRow({
742
800
  <Gift className="h-3 w-3" aria-hidden />
743
801
  {labels.free}
744
802
  </span>
745
- )}
746
- {fee.kind === "paid" && (
803
+ ) : fee.kind === "paid" ? (
747
804
  <span className={cn(CHIP, "text-foreground")}>
748
805
  <Ticket className="h-3 w-3" aria-hidden />
749
806
  {formatters.price(fee.amountRon)}
750
807
  </span>
751
- )}
808
+ ) : null}
752
809
  {hype}
753
- {tags?.map((tag) => (
754
- <span key={tag} className={CHIP}>
755
- {tag}
756
- </span>
757
- ))}
810
+ {tags?.map((tag) =>
811
+ typeof tag === "string" ? (
812
+ <span key={tag} className="text-xs text-muted-foreground">
813
+ {tag}
814
+ </span>
815
+ ) : (
816
+ <Button
817
+ key={tag.href}
818
+ asChild
819
+ variant="outline"
820
+ size="sm"
821
+ className="min-h-11 rounded-full"
822
+ >
823
+ <LinkComponent href={tag.href} onClick={tag.onClick}>
824
+ {tag.label}
825
+ <ChevronRight className="h-3.5 w-3.5" aria-hidden />
826
+ </LinkComponent>
827
+ </Button>
828
+ ),
829
+ )}
758
830
  </div>
759
831
  );
760
832
  }
@@ -0,0 +1,82 @@
1
+ import type { Meta, StoryObj } from "@storybook/react";
2
+ import { MenuItem, type MenuItemLabels } from "./menu-item";
3
+ import type { MenuItemPreview } from "./menu-types";
4
+ import { mockGradients } from "./menu-mocks";
5
+
6
+ const tagliatelle: MenuItemPreview = {
7
+ id: "i1",
8
+ slug: "truffle-tagliatelle",
9
+ name: "Truffle Tagliatelle",
10
+ kind: "dish",
11
+ category_slug: "pasta",
12
+ description: "Fresh pasta, black truffle, parmesan.",
13
+ mentions: 17,
14
+ sizes_and_prices: [{ size: 320, price: 62 }],
15
+ };
16
+
17
+ const mentionLabels: MenuItemLabels = {
18
+ currency: "lei",
19
+ mentions: (count) => `Mentioned in ${count} reviews`,
20
+ signature: "SIGNATURE",
21
+ mentioned: "MENTIONED",
22
+ };
23
+
24
+ // The label and the kind move together: "Praised in" with the thumbs-up,
25
+ // "Mentioned in" with the quote glyph.
26
+ const praiseLabels: MenuItemLabels = {
27
+ ...mentionLabels,
28
+ mentions: (count) => `Praised in ${count} reviews`,
29
+ };
30
+
31
+ const meta: Meta<typeof MenuItem> = {
32
+ title: "Menu/MenuItem",
33
+ component: MenuItem,
34
+ tags: ["autodocs"],
35
+ decorators: [
36
+ (Story) => (
37
+ <div className="w-[390px] bg-background">
38
+ <Story />
39
+ </div>
40
+ ),
41
+ ],
42
+ args: {
43
+ item: tagliatelle,
44
+ labels: mentionLabels,
45
+ formatPrice: String,
46
+ markGradient: mockGradients.pasta,
47
+ onOpen: () => {},
48
+ onMentions: () => {},
49
+ },
50
+ argTypes: {
51
+ countKind: { control: "inline-radio", options: ["mentions", "praise"] },
52
+ },
53
+ parameters: {
54
+ docs: {
55
+ description: {
56
+ component:
57
+ "THE menu row. The count at the right edge is drawn with one of two glyphs, set by `countKind`. `\"mentions\"` (the default) counts every review that named the item, complaints included, and takes the quote glyph. `\"praise\"` counts only the reviews that praised it and takes a thumbs-up. A thumbs-up never sits on a count that includes complaints, so pass `\"praise\"` only when the number is praise.",
58
+ },
59
+ },
60
+ },
61
+ };
62
+
63
+ export default meta;
64
+ type Story = StoryObj<typeof MenuItem>;
65
+
66
+ /** The default: every mention, complaints included. Quote glyph. */
67
+ export const Mentions: Story = {};
68
+
69
+ /** A praise-only count. Thumbs-up, in the same slot as the quote glyph. */
70
+ export const Praise: Story = {
71
+ args: { countKind: "praise", labels: praiseLabels },
72
+ };
73
+
74
+ /** The two kinds stacked, so the glyphs can be compared in place. */
75
+ export const CountKinds: Story = {
76
+ render: (args) => (
77
+ <div className="divide-y divide-border">
78
+ <MenuItem {...args} countKind="mentions" labels={mentionLabels} />
79
+ <MenuItem {...args} countKind="praise" labels={praiseLabels} />
80
+ </div>
81
+ ),
82
+ };
@@ -1,6 +1,6 @@
1
1
  import { describe, it, expect } from "vitest";
2
2
  import { render, screen } from "@testing-library/react";
3
- import { MenuItem, type MenuItemLabels } from "./menu-item";
3
+ import { MentionSignal, MenuItem, type MenuItemLabels } from "./menu-item";
4
4
  import type { MenuItemPreview } from "./menu-types";
5
5
 
6
6
  const wine: MenuItemPreview = {
@@ -55,3 +55,79 @@ describe("MenuItem external rating", () => {
55
55
  expect(screen.getByRole("img", { name: "Rated 3.9 on Vivino" })).toBeTruthy();
56
56
  });
57
57
  });
58
+
59
+ describe("MenuItem count glyph", () => {
60
+ const praiseLabels: MenuItemLabels = {
61
+ ...labels,
62
+ mentions: (count) => `Praised in ${count} reviews`,
63
+ };
64
+
65
+ it("draws the quote glyph when no kind is passed, so existing callers look the same", () => {
66
+ render(<MenuItem item={wine} labels={labels} formatPrice={String} />);
67
+ const count = screen.getByLabelText("Mentioned in 4 reviews");
68
+ const glyph = count.querySelector("svg");
69
+ expect(glyph).toHaveClass("lucide-message-square-quote", "h-3.5", "w-3.5", "opacity-60");
70
+ expect(count.querySelector(".lucide-thumbs-up")).toBeNull();
71
+ });
72
+
73
+ it("draws the quote glyph for raw mentions, which can include complaints", () => {
74
+ render(<MenuItem item={wine} labels={labels} formatPrice={String} countKind="mentions" />);
75
+ const count = screen.getByLabelText("Mentioned in 4 reviews");
76
+ expect(count.querySelector("svg")).toHaveClass("lucide-message-square-quote");
77
+ expect(count.querySelector(".lucide-thumbs-up")).toBeNull();
78
+ });
79
+
80
+ it("draws a thumbs-up in the quote glyph's slot when the count is praise", () => {
81
+ render(
82
+ <MenuItem
83
+ item={wine}
84
+ labels={praiseLabels}
85
+ formatPrice={String}
86
+ countKind="praise"
87
+ onMentions={() => {}}
88
+ />
89
+ );
90
+ const count = screen.getByRole("button", { name: "Praised in 4 reviews" });
91
+ const glyph = count.querySelector("svg");
92
+ expect(glyph).toHaveClass("lucide-thumbs-up", "h-3.5", "w-3.5", "opacity-60");
93
+ expect(glyph).toHaveAttribute("aria-hidden", "true");
94
+ expect(count.querySelector(".lucide-message-square-quote")).toBeNull();
95
+ expect(count.textContent).toBe("4");
96
+ });
97
+ });
98
+
99
+ describe("MentionSignal count glyph", () => {
100
+ it("draws the quote glyph by default and the thumbs-up only for praise", () => {
101
+ const { rerender } = render(<MentionSignal label="Mentioned in 9 reviews" count={9} />);
102
+ expect(screen.getByLabelText("Mentioned in 9 reviews").querySelector("svg")).toHaveClass(
103
+ "lucide-message-square-quote"
104
+ );
105
+
106
+ rerender(<MentionSignal label="Praised in 9 reviews" count={9} countKind="praise" />);
107
+ expect(screen.getByLabelText("Praised in 9 reviews").querySelector("svg")).toHaveClass(
108
+ "lucide-thumbs-up"
109
+ );
110
+ });
111
+ });
112
+
113
+ // Where reviews' praise is the point (the map's lists), the price is a fact a
114
+ // person checks, not the row's headline: it steps down to secondary text and
115
+ // the praise count keeps the bold accent (HIG review, 2026-11-08).
116
+ describe("MenuItem emphasis", () => {
117
+ it("keeps the price bold by default", () => {
118
+ render(<MenuItem item={wine} labels={labels} formatPrice={String} onOpen={() => {}} />);
119
+ const price = screen.getByText("169");
120
+ expect(price.className).toContain("font-bold");
121
+ expect(price.className).toContain("text-foreground");
122
+ });
123
+
124
+ it("sets the price as secondary text when praise leads", () => {
125
+ render(
126
+ <MenuItem item={wine} labels={labels} formatPrice={String} emphasis="praise" onOpen={() => {}} />
127
+ );
128
+ const price = screen.getByText("169");
129
+ expect(price.className).not.toContain("font-bold");
130
+ expect(price.className).toContain("text-muted-foreground");
131
+ expect(price.className).toContain("text-[13px]");
132
+ });
133
+ });
@@ -1,5 +1,5 @@
1
1
  import { memo } from "react";
2
- import { MessageSquareQuote, Star } from "lucide-react";
2
+ import { MessageSquareQuote, Star, ThumbsUp } 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";
@@ -71,6 +71,19 @@ export type MenuItemLabels = {
71
71
  externalRating?: (score: string) => string;
72
72
  };
73
73
 
74
+ /**
75
+ * What the count beside the row counts.
76
+ *
77
+ * - `"mentions"`: every review that named the item, complaints included. Drawn
78
+ * with the quote glyph.
79
+ * - `"praise"`: only the reviews that praised it. Drawn with a thumbs-up.
80
+ *
81
+ * The caller decides, because only the caller knows which number it passed in
82
+ * `item.mentions`. A thumbs-up must never sit on a count that includes
83
+ * complaints, so `"mentions"` is the default.
84
+ */
85
+ export type MenuItemCountKind = "praise" | "mentions";
86
+
74
87
  export type MenuItemProps = {
75
88
  item: MenuItemPreview;
76
89
  labels: MenuItemLabels;
@@ -92,6 +105,20 @@ export type MenuItemProps = {
92
105
  onSignatureEvidence?: () => void;
93
106
  /** Opens the reviews behind the mention count. */
94
107
  onMentions?: () => void;
108
+ /**
109
+ * Whether the count is praise or every mention. Sets the glyph before the
110
+ * numeral: a thumbs-up for `"praise"`, the quote glyph for `"mentions"`.
111
+ * Defaults to `"mentions"`. Keep `labels.mentions` in step with it.
112
+ */
113
+ countKind?: MenuItemCountKind;
114
+ /**
115
+ * What the meta row leads with. `"price"` (default): the price is bold, the
116
+ * count's peer at the other end. `"praise"`: on surfaces where reviews'
117
+ * praise is the point (the map's lists), the price steps down to secondary
118
+ * text, a fact to check rather than the row's headline, and the praise count
119
+ * keeps the bold accent.
120
+ */
121
+ emphasis?: "price" | "praise";
95
122
  /**
96
123
  * An outside aggregate score for the item (a wine's Vivino rating, a beer's
97
124
  * Untappd one), shown as a star and the score beside the mention count.
@@ -136,7 +163,9 @@ export type MenuItemProps = {
136
163
  };
137
164
 
138
165
  /**
139
- * The mention count: a filled quote glyph and the numeral, pinned right.
166
+ * The mention count: a glyph and the numeral, pinned right. The glyph is the
167
+ * quote glyph for every mention, or a thumbs-up when the count is praise only
168
+ * (see `MenuItemCountKind`).
140
169
  *
141
170
  * Deliberately NOT a badge — a chip would add a filled shape to a row that has
142
171
  * none, and would read as a status label the venue was given rather than a
@@ -147,18 +176,22 @@ export function MentionSignal({
147
176
  count,
148
177
  onMentions,
149
178
  formatCount = String,
179
+ countKind = "mentions",
150
180
  }: {
151
181
  label: string;
152
182
  count: number;
153
183
  onMentions?: () => void;
154
184
  formatCount?: (value: number) => string;
185
+ countKind?: MenuItemCountKind;
155
186
  }) {
156
187
  /* The glyph and the numeral have to read as ONE object: `gap-0.5` instead of
157
188
  4, and the glyph lighter and a step smaller than the numeral so it reads as
158
- a prefix mark qualifying the value rather than a peer competing with it. */
189
+ a prefix mark qualifying the value rather than a peer competing with it.
190
+ Both glyphs take the same slot, so switching the kind moves nothing. */
191
+ const Glyph = countKind === "praise" ? ThumbsUp : MessageSquareQuote;
159
192
  const content = (
160
193
  <span className="inline-flex items-center gap-0.5">
161
- <MessageSquareQuote className="h-3.5 w-3.5 opacity-60" aria-hidden />
194
+ <Glyph className="h-3.5 w-3.5 opacity-60" aria-hidden />
162
195
  <span className="tabular-nums">{formatCount(count)}</span>
163
196
  </span>
164
197
  );
@@ -233,6 +266,8 @@ export const MenuItem = memo(function MenuItem({
233
266
  signatureTier = "strong",
234
267
  onSignatureEvidence,
235
268
  onMentions,
269
+ countKind = "mentions",
270
+ emphasis = "price",
236
271
  externalRating,
237
272
  formatRating = (score) => score.toFixed(1),
238
273
  markGradient,
@@ -422,7 +457,14 @@ export const MenuItem = memo(function MenuItem({
422
457
  <div className="flex min-w-0 items-baseline gap-x-2">
423
458
  {minPrice != null && (
424
459
  <span className="flex items-baseline gap-1">
425
- <span className="text-[15px] font-bold tabular-nums text-foreground">
460
+ <span
461
+ className={cn(
462
+ "tabular-nums",
463
+ emphasis === "praise"
464
+ ? "text-[13px] font-medium text-muted-foreground"
465
+ : "text-[15px] font-bold text-foreground"
466
+ )}
467
+ >
426
468
  {isPriceRange
427
469
  ? `${formatPrice(minPrice)}–${formatPrice(maxPrice!)}`
428
470
  : formatPrice(minPrice)}
@@ -455,6 +497,7 @@ export const MenuItem = memo(function MenuItem({
455
497
  count={mentionCount}
456
498
  onMentions={onMentions}
457
499
  formatCount={formatCount}
500
+ countKind={countKind}
458
501
  />
459
502
  )}
460
503
  </div>
@@ -27,7 +27,9 @@ const PopoverContent = React.forwardRef<
27
27
  // than escape.
28
28
  collisionPadding={collisionPadding}
29
29
  className={cn(
30
- "z-50 w-72 rounded-xl border border-border bg-popover p-4 text-popover-foreground shadow-xl outline-none",
30
+ // Drawers and sheets use layer 1000. A body-portalled popover must
31
+ // clear that layer or its visible actions cannot receive a pointer.
32
+ "z-[1010] w-72 rounded-xl border border-border bg-popover p-4 text-popover-foreground shadow-xl outline-none",
31
33
  "max-h-[var(--radix-popover-content-available-height)] overflow-y-auto overscroll-contain",
32
34
  "data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
33
35
  className
@@ -169,3 +169,17 @@ describe("Tabs", () => {
169
169
  expect(screen.getByText("Panel A")).toBeInTheDocument();
170
170
  });
171
171
  });
172
+
173
+ // In dark mode `bg-background` is darker than the pill track (`bg-muted`), so
174
+ // the selected segment read as a hole; it lifts to the raised surface there.
175
+ describe("pill indicator in dark mode", () => {
176
+ it("raises the selected segment above the track", () => {
177
+ const { container } = render(<Triplet />);
178
+ const indicators = container.querySelectorAll('[role="tab"][data-state="active"] > span[aria-hidden]');
179
+ expect(indicators.length).toBeGreaterThan(0);
180
+ indicators.forEach((indicator) => {
181
+ expect(indicator.className).toContain("dark:bg-surface-2");
182
+ expect(indicator.className).toContain("dark:ring-1");
183
+ });
184
+ });
185
+ });
@@ -153,7 +153,10 @@ const TabsTrigger = React.forwardRef<
153
153
  transition={archetype.indicatorMorph}
154
154
  className={cn(
155
155
  "absolute inset-0 -z-0 motion-reduce:hidden",
156
- variant === "pill" && "rounded-xl bg-background shadow-sm",
156
+ // Dark: `bg-background` is darker than the `bg-muted` track, so the
157
+ // selection read as a hole; it lifts to the raised surface instead.
158
+ variant === "pill" &&
159
+ "rounded-xl bg-background shadow-sm dark:bg-surface-2 dark:ring-1 dark:ring-border",
157
160
  variant === "underline" && orientation === "horizontal" && "border-b-2 border-primary",
158
161
  variant === "underline" && orientation === "vertical" && "border-r-2 border-primary"
159
162
  )}
@@ -165,7 +168,10 @@ const TabsTrigger = React.forwardRef<
165
168
  aria-hidden
166
169
  className={cn(
167
170
  "absolute inset-0 -z-0 hidden motion-reduce:block",
168
- variant === "pill" && "rounded-xl bg-background shadow-sm",
171
+ // Dark: `bg-background` is darker than the `bg-muted` track, so the
172
+ // selection read as a hole; it lifts to the raised surface instead.
173
+ variant === "pill" &&
174
+ "rounded-xl bg-background shadow-sm dark:bg-surface-2 dark:ring-1 dark:ring-border",
169
175
  variant === "underline" && orientation === "horizontal" && "border-b-2 border-primary",
170
176
  variant === "underline" && orientation === "vertical" && "border-r-2 border-primary"
171
177
  )}