@bearmenu/ui 0.8.15 → 0.8.17

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.
Files changed (102) hide show
  1. package/README.md +1 -0
  2. package/dist/{chunk-ZLPWEHZO.js → chunk-54FQQZZJ.js} +1 -1
  3. package/dist/{chunk-5FCC5L5F.js → chunk-5LTILG3L.js} +29 -6
  4. package/dist/{chunk-P43B3ONY.js → chunk-5XREWOBB.js} +3 -1
  5. package/dist/{chunk-V4E333QS.js → chunk-5YNIQ2BL.js} +7 -29
  6. package/dist/{chunk-CLLY6XB3.js → chunk-6VOWT4YR.js} +6 -3
  7. package/dist/{chunk-S33K4RC5.js → chunk-BKKQ7GCZ.js} +1 -1
  8. package/dist/chunk-QY66ME5B.js +207 -0
  9. package/dist/chunk-RIH2IS7X.js +29 -0
  10. package/dist/{chunk-T6S2ZBV6.js → chunk-SDWN6L4O.js} +1 -1
  11. package/dist/{chunk-CNJQUHCU.js → chunk-SWD6CJ35.js} +2 -1
  12. package/dist/{chunk-HDR7V6IZ.js → chunk-TIQNM4XD.js} +8 -1
  13. package/dist/components/alert-dialog.js +3 -2
  14. package/dist/components/badge.d.ts +1 -1
  15. package/dist/components/badge.js +1 -1
  16. package/dist/components/button.d.ts +5 -1
  17. package/dist/components/button.js +3 -2
  18. package/dist/components/calendar.js +3 -2
  19. package/dist/components/carousel.js +3 -2
  20. package/dist/components/coach-marks.d.ts +63 -0
  21. package/dist/components/coach-marks.js +162 -0
  22. package/dist/components/combobox.js +3 -2
  23. package/dist/components/concept-compare-rail.js +2 -2
  24. package/dist/components/concept-compare.js +2 -2
  25. package/dist/components/concept-convergence.js +2 -2
  26. package/dist/components/date-input.d.ts +1 -1
  27. package/dist/components/doorway-tiles.d.ts +11 -0
  28. package/dist/components/doorway-tiles.js +5 -3
  29. package/dist/components/empty-state.js +3 -2
  30. package/dist/components/event-detail-panel.js +4 -3
  31. package/dist/components/event-index-row.js +1 -1
  32. package/dist/components/event-list-row-wide.js +4 -3
  33. package/dist/components/event-list-row.js +1 -1
  34. package/dist/components/facet-group.d.ts +8 -1
  35. package/dist/components/facet-group.js +3 -0
  36. package/dist/components/filter-category-row.js +1 -1
  37. package/dist/components/haptic-switch.d.ts +35 -0
  38. package/dist/components/haptic-switch.js +3 -0
  39. package/dist/components/hue-chip.js +1 -1
  40. package/dist/components/item.js +4 -1
  41. package/dist/components/kitchen-dossier.js +2 -2
  42. package/dist/components/link-button.js +3 -2
  43. package/dist/components/menu-family-block.d.ts +8 -1
  44. package/dist/components/menu-family-block.js +103 -69
  45. package/dist/components/menu-family-card.js +2 -2
  46. package/dist/components/menu-filter-chips.d.ts +12 -3
  47. package/dist/components/menu-filter-chips.js +22 -4
  48. package/dist/components/menu-index-list.js +3 -2
  49. package/dist/components/menu-item.js +3 -3
  50. package/dist/components/menu-kitchen-list.d.ts +14 -2
  51. package/dist/components/menu-kitchen-list.js +1 -1
  52. package/dist/components/multi-select-combobox.js +4 -3
  53. package/dist/components/pagination.js +3 -2
  54. package/dist/components/photo-ground.d.ts +7 -1
  55. package/dist/components/photo-ground.js +1 -1
  56. package/dist/components/place-card.js +2 -2
  57. package/dist/components/place-details-mobile.js +1 -1
  58. package/dist/components/searchable-select.js +3 -2
  59. package/dist/components/section-header.d.ts +7 -1
  60. package/dist/components/section-header.js +11 -7
  61. package/dist/components/signature-chip.js +2 -2
  62. package/dist/components/sliding-panels.js +3 -2
  63. package/dist/components/switch.d.ts +7 -1
  64. package/dist/components/switch.js +33 -24
  65. package/dist/components/toggle-group.js +9 -4
  66. package/dist/components/toggle.d.ts +1 -1
  67. package/dist/components/toggle.js +1 -1
  68. package/dist/components/top-ranking-entry.js +3 -3
  69. package/dist/hooks/use-haptic.d.ts +9 -1
  70. package/dist/hooks/use-haptic.js +1 -1
  71. package/package.json +1 -1
  72. package/src/components/badge.stories.tsx +31 -1
  73. package/src/components/badge.tsx +2 -0
  74. package/src/components/button.stories.tsx +5 -4
  75. package/src/components/button.test.tsx +11 -1
  76. package/src/components/button.tsx +41 -6
  77. package/src/components/coach-marks.stories.tsx +79 -0
  78. package/src/components/coach-marks.test.tsx +140 -0
  79. package/src/components/coach-marks.tsx +252 -0
  80. package/src/components/doorway-tiles.stories.tsx +34 -0
  81. package/src/components/doorway-tiles.test.tsx +27 -0
  82. package/src/components/doorway-tiles.tsx +30 -2
  83. package/src/components/facet-group.tsx +9 -0
  84. package/src/components/haptic-switch.test.tsx +134 -0
  85. package/src/components/haptic-switch.tsx +62 -0
  86. package/src/components/hue-chip.test.tsx +6 -3
  87. package/src/components/hue-chip.tsx +5 -2
  88. package/src/components/item.tsx +4 -1
  89. package/src/components/menu-family-block.stories.tsx +12 -0
  90. package/src/components/menu-family-block.tsx +53 -20
  91. package/src/components/menu-filter-chips.tsx +32 -4
  92. package/src/components/menu-kitchen-list.tsx +45 -6
  93. package/src/components/photo-ground.tsx +7 -0
  94. package/src/components/section-header.test.tsx +15 -0
  95. package/src/components/section-header.tsx +26 -12
  96. package/src/components/switch.tsx +29 -18
  97. package/src/components/toggle-group.stories.tsx +37 -1
  98. package/src/components/toggle-group.tsx +11 -6
  99. package/src/components/toggle.tsx +8 -0
  100. package/src/hooks/use-haptic.test.tsx +54 -59
  101. package/src/hooks/use-haptic.ts +18 -44
  102. package/dist/chunk-SXQZ7DLH.js +0 -167
@@ -20,7 +20,10 @@ const itemVariants = cva(
20
20
  lg: "px-5 py-4",
21
21
  },
22
22
  interactive: {
23
- true: "cursor-pointer hover:bg-accent hover:text-accent-foreground active:scale-[0.99]",
23
+ // The surface tint a hover is, not the brand fill: `bg-accent` here is
24
+ // the brown, and a row that inverts under the cursor loses its muted
25
+ // description. Same step the buttons take (`--accent-subtle`).
26
+ true: "cursor-pointer hover:bg-accent-subtle hover:border-accent/40 active:bg-muted active:scale-[0.99]",
24
27
  false: "",
25
28
  },
26
29
  },
@@ -61,6 +61,18 @@ export const Default: Story = {
61
61
  args: { family: { ...mockFamilyBlock, photoUrls: PHOTOS } },
62
62
  };
63
63
 
64
+ /** The phone card at every width — for a desktop rail where the two-column card is too wide to scan. */
65
+ export const Compact: Story = {
66
+ args: { family: { ...mockFamilyBlock, photoUrls: PHOTOS }, variant: "compact" },
67
+ decorators: [
68
+ (Story) => (
69
+ <div className="w-[360px] bg-background p-4">
70
+ <Story />
71
+ </div>
72
+ ),
73
+ ],
74
+ };
75
+
64
76
  /**
65
77
  * No photographs: the mosaic ground with the family's initial, never a grey box.
66
78
  * A missing photograph is not a broken image, and a placeholder makes a real
@@ -99,6 +99,13 @@ export type MenuFamilyBlockProps = {
99
99
  hue?: string;
100
100
  /** Finished CSS gradient — the family's colour over the mosaic. */
101
101
  gradient?: string;
102
+ /**
103
+ * `wide` (default): the phone card below `lg`, the two-column card — photo
104
+ * third, rows beside it — from `lg`. `compact`: the phone card at every
105
+ * width, for a rail on a desktop page where the two-column card is too much
106
+ * to scan across (the browse page's concept rail).
107
+ */
108
+ variant?: "wide" | "compact";
102
109
  linkComponent?: MenuLinkComponent;
103
110
  className?: string;
104
111
  };
@@ -108,17 +115,20 @@ const GROUND = PHOTO_GROUND;
108
115
 
109
116
  /**
110
117
  * The ink. Nearly absent at the top, heavier at the foot on the phone — the
111
- * top 40% is the photograph, the rows sit on 0.92+; a to-right ramp from
112
- * `lg`, where the row column brings its own opaque paper anyway. It is the
113
- * layer the contrast rests on. Literal rather than `var(--scrim)`: that token
114
- * lives in the app's stylesheet, not this package's, so a DS component reading
115
- * it renders nothing in Storybook and the other three consumers.
118
+ * top 40% is the photograph, the rows sit on 0.92+. It is the layer the
119
+ * contrast rests on. Literal rather than `var(--scrim)`: that token lives in
120
+ * the app's stylesheet, not this package's, so a DS component reading it
121
+ * renders nothing in Storybook and the other three consumers.
116
122
  */
117
- const INK =
118
- "bg-[linear-gradient(to_bottom,oklch(0.16_0.012_55_/_0.12)_0%,oklch(0.16_0.012_55_/_0.55)_38%,oklch(0.16_0.012_55_/_0.92)_62%,oklch(0.16_0.012_55_/_0.96)_100%)] lg:bg-[linear-gradient(to_right,oklch(0.16_0.012_55_/_0.38)_0%,oklch(0.16_0.012_55_/_0.6)_30%,oklch(0.16_0.012_55_/_0.92)_46%,oklch(0.16_0.012_55_/_0.96)_100%)]";
119
-
120
- /** The row column's paper from `lg`: the card's dark, opaque. */
121
- const PAPER = "lg:bg-[oklch(0.21_0.012_55)]";
123
+ const INK_PHONE =
124
+ "bg-[linear-gradient(to_bottom,oklch(0.16_0.012_55_/_0.12)_0%,oklch(0.16_0.012_55_/_0.55)_38%,oklch(0.16_0.012_55_/_0.92)_62%,oklch(0.16_0.012_55_/_0.96)_100%)]";
125
+ /**
126
+ * From `lg` the ramp runs to the right: clear over the photo third, dark
127
+ * under the rows — the rows sit on the ink itself, no opaque paper, so the
128
+ * photograph still reads through the ramp's shoulder.
129
+ */
130
+ const INK_WIDE =
131
+ "lg:bg-[linear-gradient(to_right,oklch(0.16_0.012_55_/_0.32)_0%,oklch(0.16_0.012_55_/_0.62)_34%,oklch(0.16_0.012_55_/_0.9)_52%,oklch(0.16_0.012_55_/_0.96)_100%)]";
122
132
 
123
133
  /**
124
134
  * A second, LOCAL scrim under the name, count and action — the photo column's
@@ -144,9 +154,11 @@ export function MenuFamilyBlock({
144
154
  formatCount = String,
145
155
  hue,
146
156
  gradient,
157
+ variant = "wide",
147
158
  linkComponent: LinkComponent = DefaultLink,
148
159
  className,
149
160
  }: MenuFamilyBlockProps) {
161
+ const wide = variant === "wide";
150
162
  const [photo] = family.photoUrls ?? [];
151
163
  // "+N more" counts past the rows shown, which are the venues the reader can
152
164
  // see — the photograph is one of them, not a venue on its own.
@@ -161,7 +173,9 @@ export function MenuFamilyBlock({
161
173
  className={cn(
162
174
  /* `isolate` confines the blend and the backdrop-filter to this card.
163
175
  `dark` scopes every token inside to its dark-theme value. */
164
- "dark relative isolate overflow-hidden rounded-[14px] text-foreground",
176
+ /* A flex column, so a `h-full` card in a rail fills its row: siblings
177
+ with two rows and with three end on the same baseline. */
178
+ "dark relative isolate flex flex-col overflow-hidden rounded-[14px] text-foreground",
165
179
  GROUND,
166
180
  className
167
181
  )}
@@ -171,21 +185,32 @@ export function MenuFamilyBlock({
171
185
  <PhotoGround photos={[photo]} initials={initial} mode="stack" gradient={gradient} />
172
186
 
173
187
  {/* ── Layer 2: the ink ────────────────────────────────────────────── */}
174
- <span aria-hidden className={cn("absolute inset-0", INK)} />
188
+ <span aria-hidden className={cn("absolute inset-0", INK_PHONE, wide && INK_WIDE)} />
175
189
 
176
190
  {/* ── The content, floating ───────────────────────────────────────── */}
177
- <div className="relative grid grid-cols-1 lg:grid-cols-[34%_1fr]">
191
+ <div
192
+ className={cn(
193
+ "relative grid flex-1 grid-cols-1 grid-rows-[auto_1fr]",
194
+ wide && "lg:grid-cols-[34%_1fr] lg:grid-rows-1"
195
+ )}
196
+ >
178
197
  {/* The photo column. On the phone it is the card's head, with the
179
198
  overflow pill pinned to the corner and the photograph clear above
180
199
  the name; on desktop the left third, its stack anchored to the foot. */}
181
200
  <div
182
201
  className={cn(
183
- "relative flex min-h-[220px] flex-col justify-end p-3.5 lg:min-h-[320px] lg:p-5",
202
+ "relative flex min-h-[220px] flex-col justify-end p-3.5",
203
+ wide && "lg:min-h-[320px] lg:p-5",
184
204
  TEXT_SCRIM
185
205
  )}
186
206
  >
187
207
  {pill && (
188
- <span className="absolute right-3.5 top-3.5 rounded-full bg-[oklch(0.16_0.012_55_/_0.78)] px-2.5 py-1 text-[11px] font-semibold text-white lg:static lg:mb-3 lg:self-start">
208
+ <span
209
+ className={cn(
210
+ "absolute right-3.5 top-3.5 rounded-full bg-[oklch(0.16_0.012_55_/_0.78)] px-2.5 py-1 text-[11px] font-semibold text-white",
211
+ wide && "lg:static lg:mb-3 lg:self-start"
212
+ )}
213
+ >
189
214
  {pill}
190
215
  </span>
191
216
  )}
@@ -195,7 +220,12 @@ export function MenuFamilyBlock({
195
220
  {family.kicker}
196
221
  </span>
197
222
  )}
198
- <h3 className="text-balance font-serif text-[26px] font-semibold leading-tight tracking-[-0.02em] text-white lg:text-[30px]">
223
+ <h3
224
+ className={cn(
225
+ "text-balance font-serif text-[26px] font-semibold leading-tight tracking-[-0.02em] text-white",
226
+ wide && "lg:text-[30px]"
227
+ )}
228
+ >
199
229
  {family.name}
200
230
  </h3>
201
231
  {/* The count, the facts and the band on one line: a few facts about
@@ -230,7 +260,7 @@ export function MenuFamilyBlock({
230
260
  aria-label={labels.browse}
231
261
  className={cn(buttonVariants({ variant: "ink", size: "pill" }), "mt-3.5 w-max max-w-full")}
232
262
  >
233
- {labels.browseShort ? (
263
+ {labels.browseShort && wide ? (
234
264
  <>
235
265
  <span className="lg:hidden">{labels.browse}</span>
236
266
  <span className="hidden lg:inline">{labels.browseShort}</span>
@@ -241,10 +271,10 @@ export function MenuFamilyBlock({
241
271
  </LinkComponent>
242
272
  </div>
243
273
 
244
- {/* The row column: chips, then the rows, on paper from `lg`. */}
245
- <div className={cn("flex min-w-0 flex-col px-3.5 pb-3.5 lg:p-5", PAPER)}>
274
+ {/* The row column: chips, then the rows, on the ink's dark shoulder. */}
275
+ <div className={cn("flex min-w-0 flex-col px-3.5 pb-3.5", wide && "lg:p-5")}>
246
276
  {family.chips && family.chips.length > 0 && (
247
- <ul className="flex flex-wrap items-center gap-1.5 lg:justify-end">
277
+ <ul className={cn("flex flex-wrap items-center gap-1.5", wide && "lg:justify-end")}>
248
278
  {family.chips.map((chip) => (
249
279
  /* Label only. The counts are VENUES beside a dish group's name,
250
280
  which every reader takes for dishes. */
@@ -261,6 +291,9 @@ export function MenuFamilyBlock({
261
291
  <MenuKitchenList
262
292
  kitchens={venues}
263
293
  labels={labels.kitchens}
294
+ /* A half-width column: the dish goes under the venue, not
295
+ beside it. The compact card is the phone row throughout. */
296
+ layout={wide ? "stacked" : "phone"}
264
297
  linkComponent={LinkComponent}
265
298
  />
266
299
  </>
@@ -59,11 +59,19 @@ export type MenuFilterChipsProps = {
59
59
  onMorePointerDown?: () => void;
60
60
  /** Locale-aware integer formatter for the counts. Defaults to `String`. */
61
61
  formatCount?: (value: number) => string;
62
+ /**
63
+ * `sm` (36px) is the row under a page header. `lg` (44px) is for a row
64
+ * inside a sticky bar, where the chips are the only thing the thumb can
65
+ * reach and 36px sits under the target floor.
66
+ */
67
+ chipSize?: MenuFilterChipSize;
62
68
  /** Accessible name for the row. */
63
69
  ariaLabel?: string;
64
70
  className?: string;
65
71
  };
66
72
 
73
+ export type MenuFilterChipSize = "sm" | "lg";
74
+
67
75
  export function MenuFilterChips({
68
76
  chips,
69
77
  onToggle,
@@ -73,6 +81,7 @@ export function MenuFilterChips({
73
81
  moreIcon,
74
82
  onMorePointerDown,
75
83
  formatCount = String,
84
+ chipSize = "sm",
76
85
  ariaLabel,
77
86
  className,
78
87
  }: MenuFilterChipsProps) {
@@ -99,6 +108,7 @@ export function MenuFilterChips({
99
108
  <Chip
100
109
  key={chip.key}
101
110
  chip={chip}
111
+ size={chipSize}
102
112
  onToggle={() => onToggle(chip.key)}
103
113
  formatCount={formatCount}
104
114
  />
@@ -120,7 +130,11 @@ export function MenuFilterChips({
120
130
  onPointerDown={onMorePointerDown}
121
131
  aria-label={moreLabel}
122
132
  aria-haspopup="dialog"
123
- className="inline-flex size-10 shrink-0 items-center justify-center rounded-full border border-border text-accent transition-colors hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring motion-reduce:transition-none lg:hidden"
133
+ className={cn(
134
+ "inline-flex shrink-0 items-center justify-center rounded-full border border-border text-accent transition-colors hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring motion-reduce:transition-none lg:hidden",
135
+ // The one control that never scrolls keeps the chips' height.
136
+ chipSize === "lg" ? "size-11" : "size-10"
137
+ )}
124
138
  >
125
139
  {moreIcon}
126
140
  </button>
@@ -143,10 +157,12 @@ export function MenuFilterChips({
143
157
 
144
158
  function Chip({
145
159
  chip,
160
+ size,
146
161
  onToggle,
147
162
  formatCount,
148
163
  }: {
149
164
  chip: MenuFilterChip;
165
+ size: MenuFilterChipSize;
150
166
  onToggle: () => void;
151
167
  formatCount: (value: number) => string;
152
168
  }) {
@@ -156,7 +172,8 @@ function Chip({
156
172
  onClick={onToggle}
157
173
  aria-pressed={chip.active}
158
174
  className={cn(
159
- "inline-flex h-9 shrink-0 items-center gap-1.5 whitespace-nowrap rounded-full border px-4 text-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring motion-reduce:transition-none",
175
+ "inline-flex shrink-0 items-center gap-1.5 whitespace-nowrap rounded-full border px-4 text-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring motion-reduce:transition-none",
176
+ size === "lg" ? "h-11" : "h-9",
160
177
  chip.active
161
178
  ? "border-accent font-semibold text-accent shadow-[0_1px_2px_oklch(0.51_0.095_45_/_0.08)]"
162
179
  : "border-border bg-card text-foreground hover:border-accent/40",
@@ -226,12 +243,23 @@ export function MenuFilterSummary({
226
243
  */
227
244
  const CHIP_WIDTHS = [128, 104, 96, 148, 112, 88];
228
245
 
229
- export function MenuFilterChipsSkeleton({ className }: { className?: string }) {
246
+ export function MenuFilterChipsSkeleton({
247
+ className,
248
+ chipSize = "sm",
249
+ }: {
250
+ className?: string;
251
+ /** Match the row it stands in for, so the chips do not jump 8px on load. */
252
+ chipSize?: MenuFilterChipSize;
253
+ }) {
230
254
  return (
231
255
  <div className={cn("flex items-center gap-2 overflow-hidden", className)} aria-hidden>
232
256
  <Skeleton className="h-3 w-[92px] shrink-0" />
233
257
  {CHIP_WIDTHS.map((width, index) => (
234
- <Skeleton key={index} className="h-9 shrink-0 rounded-full" style={{ width }} />
258
+ <Skeleton
259
+ key={index}
260
+ className={cn("shrink-0 rounded-full", chipSize === "lg" ? "h-11" : "h-9")}
261
+ style={{ width }}
262
+ />
235
263
  ))}
236
264
  </div>
237
265
  );
@@ -61,6 +61,18 @@ export type MenuKitchenListLabels = {
61
61
  showMore?: string;
62
62
  };
63
63
 
64
+ /**
65
+ * `wide` (default): the intent page's full-width list — from `lg` the venue
66
+ * takes a fixed 226px column and the dish stands beside it in a column of its
67
+ * own. `stacked`: the list sits in a narrow column (a concept card's row
68
+ * half), so the venue keeps the row and the dish takes a SECOND LINE under it
69
+ * from `lg`; in a half-width card the dish column had ~100px and its kicker,
70
+ * name and meta wrapped into a stack of single words. `phone`: the phone row
71
+ * at every width — no dish line, the venue and its price only — for a card
72
+ * that keeps its phone form on a desktop rail.
73
+ */
74
+ export type MenuKitchenListLayout = "wide" | "stacked" | "phone";
75
+
64
76
  export type MenuKitchenListProps = {
65
77
  kitchens: MenuKitchenPreview[];
66
78
  labels: MenuKitchenListLabels;
@@ -69,6 +81,7 @@ export type MenuKitchenListProps = {
69
81
  * navigates for the eleventh — the list is the page.
70
82
  */
71
83
  onShowMore?: () => void;
84
+ layout?: MenuKitchenListLayout;
72
85
  linkComponent?: MenuLinkComponent;
73
86
  className?: string;
74
87
  };
@@ -77,6 +90,7 @@ export function MenuKitchenList({
77
90
  kitchens,
78
91
  labels,
79
92
  onShowMore,
93
+ layout = "wide",
80
94
  linkComponent = DefaultLink,
81
95
  className,
82
96
  }: MenuKitchenListProps) {
@@ -90,7 +104,12 @@ export function MenuKitchenList({
90
104
  because `<ol>` admits only list items, so a sibling element
91
105
  between them would be invalid markup. */}
92
106
  {index > 0 && <Separator />}
93
- <KitchenRow kitchen={kitchen} labels={labels} linkComponent={linkComponent} />
107
+ <KitchenRow
108
+ kitchen={kitchen}
109
+ labels={labels}
110
+ layout={layout}
111
+ linkComponent={linkComponent}
112
+ />
94
113
  </li>
95
114
  ))}
96
115
  </ol>
@@ -111,17 +130,25 @@ export function MenuKitchenList({
111
130
  function KitchenRow({
112
131
  kitchen,
113
132
  labels,
133
+ layout,
114
134
  linkComponent: LinkComponent,
115
135
  }: {
116
136
  kitchen: MenuKitchenPreview;
117
137
  labels: MenuKitchenListLabels;
138
+ layout: MenuKitchenListLayout;
118
139
  linkComponent: MenuLinkComponent;
119
140
  }) {
120
141
  const { place, dish } = kitchen;
121
142
  const openNow = place.open_now === true;
143
+ const wide = layout === "wide";
122
144
 
123
145
  return (
124
- <div className="relative flex flex-wrap items-center gap-[11px] py-3 transition-colors hover:bg-muted/30 motion-reduce:transition-none lg:flex-nowrap lg:gap-4 lg:py-3.5">
146
+ <div
147
+ className={cn(
148
+ "relative flex flex-wrap items-center gap-[11px] py-3 transition-colors hover:bg-muted/30 motion-reduce:transition-none lg:gap-4 lg:py-3.5",
149
+ wide && "lg:flex-nowrap"
150
+ )}
151
+ >
125
152
  {/* The venue link, stretched. `z-[1]` — the dish link sits above it. */}
126
153
  <LinkComponent
127
154
  href={kitchen.href}
@@ -149,7 +176,12 @@ function KitchenRow({
149
176
  />
150
177
  </span>
151
178
 
152
- <div className="flex min-w-0 flex-1 flex-col gap-0.5 lg:w-[226px] lg:flex-none">
179
+ <div
180
+ className={cn(
181
+ "flex min-w-0 flex-1 flex-col gap-0.5",
182
+ wide && "lg:w-[226px] lg:flex-none"
183
+ )}
184
+ >
153
185
  {/* One line with an ellipsis on a phone, where the row is the whole
154
186
  width; two lines from `lg`, where the column is 226px and
155
187
  "ADRIATICA GELATERIA ITALIANA" would otherwise lose its half. */}
@@ -190,8 +222,15 @@ function KitchenRow({
190
222
  one tap away on the venue's own page, with room to state it.
191
223
 
192
224
  `order-5` puts it last in the visual order without moving it in the
193
- DOM, where it belongs to the venue. */
194
- <div className="hidden min-w-0 basis-full pl-[57px] lg:order-none lg:block lg:flex-1 lg:basis-auto lg:pl-0">
225
+ DOM, where it belongs to the venue. In the `stacked` layout it is a
226
+ second LINE under the venue, indented past the thumbnail. */
227
+ <div
228
+ className={cn(
229
+ "hidden min-w-0 basis-full pl-[57px]",
230
+ layout !== "phone" && "lg:block",
231
+ wide ? "lg:order-none lg:flex-1 lg:basis-auto lg:pl-0" : "lg:order-5 lg:pl-[74px]"
232
+ )}
233
+ >
195
234
  <span
196
235
  className={cn(
197
236
  "flex items-center gap-1.5 text-[9px] font-extrabold uppercase tracking-[0.1em]",
@@ -230,7 +269,7 @@ function KitchenRow({
230
269
  </div>
231
270
  )}
232
271
 
233
- <div className="ml-auto flex shrink-0 items-center gap-[11px] lg:ml-0">
272
+ <div className={cn("ml-auto flex shrink-0 items-center gap-[11px]", wide && "lg:ml-0")}>
234
273
  {kitchen.priceLabel && (
235
274
  <span className="text-base font-extrabold tabular-nums text-accent">
236
275
  {kitchen.priceLabel}
@@ -38,6 +38,13 @@ import { cn } from "../lib/utils";
38
38
  /** The ground under the photography. Fixed, and deliberately not a token. */
39
39
  export const PHOTO_GROUND = "bg-[oklch(0.21_0.012_55)]";
40
40
 
41
+ /**
42
+ * The shadow a glyph wears when it stands on `PHOTO_GROUND` in a photograph's
43
+ * place — the card family's fallback treatment: a ground and a lifted glyph,
44
+ * never a bare icon on a flat fill. Same literal family as the ground.
45
+ */
46
+ export const PHOTO_GLYPH_SHADOW = "drop-shadow-[0_2px_6px_oklch(0.16_0.012_55_/_0.6)]";
47
+
41
48
  /** The uniform dark overlay a card may put over every photograph. */
42
49
  const OVERLAY = "absolute inset-0 bg-[oklch(0.16_0.012_55_/_0.5)]";
43
50
 
@@ -25,4 +25,19 @@ describe("SectionHeader", () => {
25
25
  expect(screen.getByText("Still to start").className).toContain("hidden");
26
26
  expect(screen.getByText("Still to start").className).toContain("lg:block");
27
27
  });
28
+
29
+ it("holds trailing controls at the row's end, beside the action", () => {
30
+ render(
31
+ <SectionHeader
32
+ title="Concepts"
33
+ action="See all"
34
+ actionHref="/concepts"
35
+ trailing={<button type="button">Next</button>}
36
+ />
37
+ );
38
+ const next = screen.getByRole("button", { name: "Next" });
39
+ const action = screen.getByRole("link", { name: "See all" });
40
+ expect(next.parentElement).toBe(action.parentElement);
41
+ expect(next.parentElement).not.toContainElement(screen.getByText("Concepts"));
42
+ });
28
43
  });
@@ -60,6 +60,12 @@ export type SectionHeaderProps = {
60
60
  * would dilute the headings that matter. Defaults to `"h2"`.
61
61
  */
62
62
  as?: "h1" | "h2" | "h3" | "h4" | "h5" | "h6" | "p" | "div";
63
+ /**
64
+ * Controls at the end of the title row, after `action` — a rail's
65
+ * previous / next pair on desktop. The header owns none of their
66
+ * behaviour; it only holds the slot.
67
+ */
68
+ trailing?: React.ReactNode;
63
69
  className?: string;
64
70
  };
65
71
 
@@ -91,6 +97,7 @@ export function SectionHeader({
91
97
  linkComponent: LinkComponent = DefaultLink,
92
98
  onAction,
93
99
  as: Heading = "h2",
100
+ trailing,
94
101
  className,
95
102
  }: SectionHeaderProps) {
96
103
  const actionIcon = <ChevronRight className="h-3.5 w-3.5" aria-hidden />;
@@ -110,18 +117,25 @@ export function SectionHeader({
110
117
  )}
111
118
  </div>
112
119
 
113
- {action &&
114
- (actionHref ? (
115
- <LinkComponent href={actionHref} className={actionClasses} onClick={onAction}>
116
- {action}
117
- {actionIcon}
118
- </LinkComponent>
119
- ) : onAction ? (
120
- <button type="button" className={actionClasses} onClick={onAction}>
121
- {action}
122
- {actionIcon}
123
- </button>
124
- ) : null)}
120
+ {/* The action and the trailing controls share the row's end, so the
121
+ action never floats to the middle when both are present. */}
122
+ {(action || trailing) && (
123
+ <div className="flex shrink-0 items-center gap-3">
124
+ {action &&
125
+ (actionHref ? (
126
+ <LinkComponent href={actionHref} className={actionClasses} onClick={onAction}>
127
+ {action}
128
+ {actionIcon}
129
+ </LinkComponent>
130
+ ) : onAction ? (
131
+ <button type="button" className={actionClasses} onClick={onAction}>
132
+ {action}
133
+ {actionIcon}
134
+ </button>
135
+ ) : null)}
136
+ {trailing}
137
+ </div>
138
+ )}
125
139
  </div>
126
140
 
127
141
  {subtitle && (
@@ -3,27 +3,38 @@
3
3
  import * as React from "react";
4
4
  import * as SwitchPrimitive from "@radix-ui/react-switch";
5
5
  import { cn } from "../lib/utils";
6
+ import { HapticSwitch } from "./haptic-switch";
6
7
 
7
- const Switch = React.forwardRef<
8
- React.ElementRef<typeof SwitchPrimitive.Root>,
9
- React.ComponentPropsWithoutRef<typeof SwitchPrimitive.Root>
10
- >(({ className, ...props }, ref) => (
11
- <SwitchPrimitive.Root
12
- ref={ref}
13
- data-slot="switch"
14
- className={cn(
15
- "peer inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input aria-invalid:ring-2 aria-invalid:ring-destructive/40",
16
- className
17
- )}
18
- {...props}
19
- >
20
- <SwitchPrimitive.Thumb
8
+ type SwitchProps = React.ComponentPropsWithoutRef<typeof SwitchPrimitive.Root> & {
9
+ /**
10
+ * Tick on iOS through a `HapticSwitch` laid over the control. Off by default;
11
+ * a switch that writes a persisted setting opts in.
12
+ */
13
+ haptic?: boolean;
14
+ };
15
+
16
+ const Switch = React.forwardRef<React.ElementRef<typeof SwitchPrimitive.Root>, SwitchProps>(
17
+ ({ className, haptic = false, disabled, ...props }, ref) => (
18
+ <SwitchPrimitive.Root
19
+ ref={ref}
20
+ data-slot="switch"
21
+ disabled={disabled}
21
22
  className={cn(
22
- "pointer-events-none block size-5 rounded-full bg-background shadow-md ring-0 transition-transform data-[state=checked]:translate-x-5 data-[state=unchecked]:translate-x-0 data-[state=checked]:bg-primary-foreground"
23
+ "peer inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input aria-invalid:ring-2 aria-invalid:ring-destructive/40",
24
+ haptic && "relative",
25
+ className
23
26
  )}
24
- />
25
- </SwitchPrimitive.Root>
26
- ));
27
+ {...props}
28
+ >
29
+ <SwitchPrimitive.Thumb
30
+ className={cn(
31
+ "pointer-events-none block size-5 rounded-full bg-background shadow-md ring-0 transition-transform data-[state=checked]:translate-x-5 data-[state=unchecked]:translate-x-0 data-[state=checked]:bg-primary-foreground"
32
+ )}
33
+ />
34
+ {haptic && <HapticSwitch disabled={disabled} />}
35
+ </SwitchPrimitive.Root>
36
+ )
37
+ );
27
38
  Switch.displayName = SwitchPrimitive.Root.displayName;
28
39
 
29
40
  export { Switch };
@@ -1,6 +1,16 @@
1
1
  import type { Meta, StoryObj } from "@storybook/react";
2
2
  import { ToggleGroup, ToggleGroupItem } from "./toggle-group";
3
- import { Bold, Italic, Underline, AlignLeft, AlignCenter, AlignRight } from "lucide-react";
3
+ import {
4
+ Bold,
5
+ Italic,
6
+ Underline,
7
+ AlignLeft,
8
+ AlignCenter,
9
+ AlignRight,
10
+ LayoutGrid,
11
+ List,
12
+ } from "lucide-react";
13
+ import { Button } from "./button";
4
14
 
5
15
  const meta = {
6
16
  title: "Components/ToggleGroup",
@@ -58,3 +68,29 @@ export const Outline: Story = {
58
68
  </ToggleGroup>
59
69
  ),
60
70
  };
71
+
72
+ /**
73
+ * The segmented control: one 44px muted capsule, the on cell a raised card.
74
+ * Sits beside pill buttons at the same height — the browse toolbar's view
75
+ * switch. Single, always one on.
76
+ */
77
+ export const Segmented: Story = {
78
+ render: () => (
79
+ <div className="flex items-center gap-2">
80
+ <Button variant="outline" size="pill">
81
+ Filters
82
+ </Button>
83
+ <Button variant="outline" size="pill">
84
+ Sort
85
+ </Button>
86
+ <ToggleGroup type="single" variant="segmented" defaultValue="list" aria-label="View">
87
+ <ToggleGroupItem value="list" aria-label="Rows">
88
+ <List />
89
+ </ToggleGroupItem>
90
+ <ToggleGroupItem value="grid" aria-label="Cards">
91
+ <LayoutGrid />
92
+ </ToggleGroupItem>
93
+ </ToggleGroup>
94
+ </div>
95
+ ),
96
+ };
@@ -26,13 +26,14 @@ function ToggleGroup({
26
26
  data-size={size}
27
27
  className={cn(
28
28
  "group/toggle-group flex w-fit items-center rounded-md data-[variant=outline]:shadow-xs",
29
+ // The segmented control's capsule: muted ground, the on cell a raised
30
+ // card inside it (see `toggleVariants.segmented`). 44px tall.
31
+ "data-[variant=segmented]:gap-0.5 data-[variant=segmented]:rounded-full data-[variant=segmented]:bg-muted data-[variant=segmented]:p-0.5",
29
32
  className
30
33
  )}
31
34
  {...props}
32
35
  >
33
- <ToggleGroupContext.Provider value={{ variant, size }}>
34
- {children}
35
- </ToggleGroupContext.Provider>
36
+ <ToggleGroupContext.Provider value={{ variant, size }}>{children}</ToggleGroupContext.Provider>
36
37
  </ToggleGroupPrimitive.Root>
37
38
  );
38
39
  }
@@ -45,18 +46,22 @@ function ToggleGroupItem({
45
46
  ...props
46
47
  }: React.ComponentProps<typeof ToggleGroupPrimitive.Item> & VariantProps<typeof toggleVariants>) {
47
48
  const context = React.useContext(ToggleGroupContext);
49
+ const resolved = context.variant || variant;
48
50
 
49
51
  return (
50
52
  <ToggleGroupPrimitive.Item
51
53
  data-slot="toggle-group-item"
52
- data-variant={context.variant || variant}
54
+ data-variant={resolved}
53
55
  data-size={context.size || size}
54
56
  className={cn(
55
57
  toggleVariants({
56
- variant: context.variant || variant,
58
+ variant: resolved,
57
59
  size: context.size || size,
58
60
  }),
59
- "min-w-0 flex-1 shrink-0 rounded-none shadow-none first:rounded-l-md last:rounded-r-md focus:z-10 focus-visible:z-10 data-[variant=outline]:border-l-0 data-[variant=outline]:first:border-l",
61
+ "min-w-0 flex-1 shrink-0 shadow-none focus:z-10 focus-visible:z-10 data-[variant=outline]:border-l-0 data-[variant=outline]:first:border-l",
62
+ resolved === "segmented"
63
+ ? "rounded-full px-0 data-[state=on]:shadow-[var(--shadow-card)]"
64
+ : "rounded-none first:rounded-l-md last:rounded-r-md",
60
65
  className
61
66
  )}
62
67
  {...props}
@@ -14,6 +14,14 @@ const toggleVariants = cva(
14
14
  default: "bg-transparent",
15
15
  outline:
16
16
  "border border-border bg-transparent hover:bg-accent/50 hover:text-accent-foreground data-[state=on]:border-primary",
17
+ /**
18
+ * One cell of a segmented control: a raised card on the group's
19
+ * muted capsule when on, quiet type when off. The group
20
+ * (`ToggleGroup variant="segmented"`) supplies the capsule; the cell
21
+ * never fills with the accent, which would read as a primary button.
22
+ */
23
+ segmented:
24
+ "h-10 w-11 rounded-full text-muted-foreground hover:bg-transparent hover:text-foreground data-[state=on]:bg-card data-[state=on]:text-foreground data-[state=on]:shadow-[var(--shadow-card)]",
17
25
  },
18
26
  size: {
19
27
  default: "h-9 px-2 min-w-9",