@bearmenu/ui 0.8.51 → 0.8.53

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 (67) hide show
  1. package/dist/chunk-GIJGVFGC.js +177 -0
  2. package/dist/chunk-H4ORZT2D.js +14 -0
  3. package/dist/chunk-RKNPPYRU.js +86 -0
  4. package/dist/{chunk-S5GDARLW.js → chunk-WY2FP76J.js} +28 -5
  5. package/dist/components/category-deck.d.ts +6 -1
  6. package/dist/components/category-deck.js +39 -35
  7. package/dist/components/event-card.d.ts +7 -0
  8. package/dist/components/event-card.js +9 -0
  9. package/dist/components/event-compact-row.d.ts +6 -0
  10. package/dist/components/event-compact-row.js +5 -3
  11. package/dist/components/event-hub-hero.d.ts +5 -0
  12. package/dist/components/event-hub-hero.js +14 -3
  13. package/dist/components/event-list-row-wide.d.ts +6 -0
  14. package/dist/components/event-list-row-wide.js +5 -3
  15. package/dist/components/event-tonight-strip.d.ts +3 -1
  16. package/dist/components/event-tonight-strip.js +3 -2
  17. package/dist/components/facet-group.d.ts +9 -2
  18. package/dist/components/facet-group.js +108 -10
  19. package/dist/components/featured-rail-item.d.ts +3 -1
  20. package/dist/components/featured-rail-item.js +3 -1
  21. package/dist/components/filter-option-chip.d.ts +15 -0
  22. package/dist/components/filter-option-chip.js +3 -0
  23. package/dist/components/filter-option-group.d.ts +37 -0
  24. package/dist/components/filter-option-group.js +10 -0
  25. package/dist/components/filter-panel-footer.d.ts +15 -0
  26. package/dist/components/filter-panel-footer.js +63 -0
  27. package/dist/components/kitchen-dossier.d.ts +7 -1
  28. package/dist/components/kitchen-dossier.js +2 -1
  29. package/dist/components/menu-hub-hero.d.ts +7 -1
  30. package/dist/components/menu-hub-hero.js +2 -0
  31. package/dist/components/place-card.d.ts +7 -0
  32. package/dist/components/place-card.js +11 -3
  33. package/dist/components/poster-stack.d.ts +2 -0
  34. package/dist/components/poster-stack.js +2 -1
  35. package/dist/components/section-header.d.ts +6 -1
  36. package/dist/components/section-header.js +4 -2
  37. package/dist/components/timeline-day.js +1 -1
  38. package/dist/components/timeline-hour.d.ts +13 -1
  39. package/dist/components/timeline-hour.js +1 -1
  40. package/package.json +6 -6
  41. package/src/components/category-deck.tsx +11 -0
  42. package/src/components/event-card.test.tsx +6 -0
  43. package/src/components/event-card.tsx +17 -0
  44. package/src/components/event-compact-row.tsx +34 -24
  45. package/src/components/event-hub-hero.tsx +15 -1
  46. package/src/components/event-list-row-wide.tsx +25 -15
  47. package/src/components/event-tonight-strip.test.tsx +12 -0
  48. package/src/components/event-tonight-strip.tsx +8 -1
  49. package/src/components/facet-group.tsx +153 -54
  50. package/src/components/featured-rail-item.tsx +5 -0
  51. package/src/components/filter-option-chip.test.tsx +50 -0
  52. package/src/components/filter-option-chip.tsx +81 -0
  53. package/src/components/filter-option-group.stories.tsx +250 -0
  54. package/src/components/filter-option-group.test.tsx +100 -0
  55. package/src/components/filter-option-group.tsx +231 -0
  56. package/src/components/filter-panel-footer.tsx +63 -0
  57. package/src/components/kitchen-dossier.tsx +14 -1
  58. package/src/components/menu-hub-hero.tsx +13 -0
  59. package/src/components/place-card.stories.tsx +10 -0
  60. package/src/components/place-card.test.tsx +31 -0
  61. package/src/components/place-card.tsx +22 -6
  62. package/src/components/poster-stack.tsx +3 -0
  63. package/src/components/section-header.tsx +8 -1
  64. package/src/components/timeline-day.stories.tsx +30 -0
  65. package/src/components/timeline-day.test.tsx +24 -0
  66. package/src/components/timeline-hour.tsx +40 -6
  67. package/src/lib/poster-overlay-frame.tsx +26 -0
@@ -3,6 +3,8 @@
3
3
  import { type ComponentPropsWithoutRef, useId, useState } from "react";
4
4
  import { Check, ChevronDown, type LucideIcon } from "lucide-react";
5
5
  import { cn } from "../lib/utils";
6
+ import { FilterOptionGroup } from "./filter-option-group";
7
+ import { Button } from "./button";
6
8
  import { Skeleton } from "./skeleton";
7
9
 
8
10
  /**
@@ -101,6 +103,12 @@ export type FacetGroupProps = Omit<
101
103
  */
102
104
  name?: string;
103
105
  size?: FacetGroupSize;
106
+ /** Wrapping native-input chips for filter panels. Rows remain the default. */
107
+ layout?: "rows" | "chips";
108
+ /** Optional bounded chip list; selected options always stay visible. */
109
+ visibleLimit?: number;
110
+ showAllLabel?: string | ((count: number) => string);
111
+ showLessLabel?: string;
104
112
  /**
105
113
  * Turns the legend into a disclosure toggle for this group's options.
106
114
  *
@@ -135,6 +143,10 @@ export function FacetGroup({
135
143
  selection = "multiple",
136
144
  name,
137
145
  size = "comfortable",
146
+ layout = "rows",
147
+ visibleLimit,
148
+ showAllLabel,
149
+ showLessLabel,
138
150
  collapsible = false,
139
151
  defaultOpen = true,
140
152
  onToggle,
@@ -155,6 +167,52 @@ export function FacetGroup({
155
167
  const fallbackName = useId();
156
168
  const groupName = name ?? fallbackName;
157
169
 
170
+ if (layout === "chips") {
171
+ const value = options
172
+ .filter((option) => option.checked)
173
+ .map((option) => option.id);
174
+ return (
175
+ <FilterOptionGroup
176
+ title={title}
177
+ options={options.map((option) => ({ ...option, value: option.id }))}
178
+ value={value}
179
+ selection={selection}
180
+ name={groupName}
181
+ defaultOpen={defaultOpen}
182
+ collapsible={collapsible}
183
+ divider={false}
184
+ visibleLimit={visibleLimit}
185
+ showAllLabel={showAllLabel}
186
+ showLessLabel={showLessLabel}
187
+ disabled={rest.disabled}
188
+ className={className}
189
+ onValueChange={(next) => {
190
+ if (selection === "single") {
191
+ if (next[0] != null) onToggle?.(String(next[0]), true);
192
+ return;
193
+ }
194
+ for (const option of options) {
195
+ const checked = next.includes(option.id);
196
+ if (checked !== !!option.checked) onToggle?.(option.id, checked);
197
+ }
198
+ }}
199
+ footer={
200
+ moreLabel && (
201
+ <Button
202
+ type="button"
203
+ variant="link"
204
+ className="min-h-11 px-0"
205
+ onClick={onMore}
206
+ aria-expanded={moreExpanded}
207
+ >
208
+ {moreLabel}
209
+ </Button>
210
+ )
211
+ }
212
+ />
213
+ );
214
+ }
215
+
158
216
  return (
159
217
  // NO SEPARATOR OF ITS OWN. This drew `<Separator className="absolute
160
218
  // inset-x-0 top-0" />`, which looked like a rule above each group and was
@@ -172,7 +230,7 @@ export function FacetGroup({
172
230
  <legend
173
231
  className={cn(
174
232
  "block w-full p-0 text-[11.5px] font-extrabold tracking-[0.1em] text-muted-foreground uppercase",
175
- collapsible ? "mb-0" : "mb-[11px]"
233
+ collapsible ? "mb-0" : "mb-[11px]",
176
234
  )}
177
235
  >
178
236
  {collapsible ? (
@@ -197,22 +255,34 @@ export function FacetGroup({
197
255
  // groups looked like the rules were mis-centred — they were
198
256
  // centred, the labels were not. Transitioned alongside the panel
199
257
  // so the two move as one gesture rather than the margin snapping.
200
- open ? "mb-[11px]" : "mb-0"
258
+ open ? "mb-[11px]" : "mb-0",
201
259
  )}
202
260
  >
203
- {Icon && <Icon aria-hidden className="size-3.5 shrink-0" strokeWidth={2.25} />}
261
+ {Icon && (
262
+ <Icon
263
+ aria-hidden
264
+ className="size-3.5 shrink-0"
265
+ strokeWidth={2.25}
266
+ />
267
+ )}
204
268
  {title}
205
269
  <ChevronDown
206
270
  aria-hidden
207
271
  className={cn(
208
272
  "ml-auto size-3.5 shrink-0 transition-transform duration-150 motion-reduce:transition-none",
209
- open ? "rotate-180" : "rotate-0"
273
+ open ? "rotate-180" : "rotate-0",
210
274
  )}
211
275
  />
212
276
  </button>
213
277
  ) : (
214
278
  <span className="flex items-center gap-1.5">
215
- {Icon && <Icon aria-hidden className="size-3.5 shrink-0" strokeWidth={2.25} />}
279
+ {Icon && (
280
+ <Icon
281
+ aria-hidden
282
+ className="size-3.5 shrink-0"
283
+ strokeWidth={2.25}
284
+ />
285
+ )}
216
286
  {title}
217
287
  </span>
218
288
  )}
@@ -244,7 +314,7 @@ export function FacetGroup({
244
314
  <div
245
315
  className={cn(
246
316
  "grid transition-[grid-template-rows] duration-200 ease-out motion-reduce:transition-none",
247
- open ? "grid-rows-[1fr]" : "grid-rows-[0fr]"
317
+ open ? "grid-rows-[1fr]" : "grid-rows-[0fr]",
248
318
  )}
249
319
  >
250
320
  <div className="overflow-hidden">
@@ -254,54 +324,63 @@ export function FacetGroup({
254
324
  aria-hidden={!open || undefined}
255
325
  className={cn("flex flex-col", ROW_GAP[size])}
256
326
  >
257
- {options.map((option) => {
258
- const checked = !!option.checked;
327
+ {options.map((option) => {
328
+ const checked = !!option.checked;
259
329
 
260
- return (
261
- <label
262
- key={option.id}
263
- className={cn(
264
- "flex cursor-pointer items-center gap-[9px] text-[13.5px] select-none",
265
- "transition-colors duration-150 hover:bg-muted/60 motion-reduce:transition-none",
266
- ROW_SIZE[size]
267
- )}
268
- >
269
- <input
270
- type={isSingle ? "radio" : "checkbox"}
271
- name={isSingle ? groupName : undefined}
272
- className="peer sr-only"
273
- checked={checked}
274
- onChange={(event) => onToggle?.(option.id, event.target.checked)}
275
- />
276
- <span
277
- aria-hidden
278
- className={cn(
279
- "flex h-4 w-4 shrink-0 items-center justify-center border-[1.5px] transition-colors duration-150 motion-reduce:transition-none",
280
- isSingle ? "rounded-full" : "rounded-[5px]",
281
- "peer-focus-visible:ring-2 peer-focus-visible:ring-ring peer-focus-visible:ring-offset-2",
282
- checked ? "border-primary bg-primary" : "border-border bg-background"
283
- )}
284
- >
285
- {checked &&
286
- (isSingle ? (
287
- <span className="size-[7px] rounded-full bg-primary-foreground" />
288
- ) : (
289
- <Check className="h-[11px] w-[11px] text-primary-foreground" strokeWidth={3} />
290
- ))}
291
- </span>
292
- <span
293
- title={option.label}
294
- className={cn(
295
- "min-w-0 truncate",
296
- checked ? "font-bold text-primary" : "font-normal text-foreground"
297
- )}
298
- >
299
- {option.label}
300
- </span>
301
- <span className="ml-auto shrink-0 font-mono text-[11.5px] tabular-nums text-muted-foreground">
302
- {option.count}
303
- </span>
304
- </label>
330
+ return (
331
+ <label
332
+ key={option.id}
333
+ className={cn(
334
+ "flex cursor-pointer items-center gap-[9px] text-[13.5px] select-none",
335
+ "transition-colors duration-150 hover:bg-muted/60 motion-reduce:transition-none",
336
+ ROW_SIZE[size],
337
+ )}
338
+ >
339
+ <input
340
+ type={isSingle ? "radio" : "checkbox"}
341
+ name={isSingle ? groupName : undefined}
342
+ className="peer sr-only"
343
+ checked={checked}
344
+ onChange={(event) =>
345
+ onToggle?.(option.id, event.target.checked)
346
+ }
347
+ />
348
+ <span
349
+ aria-hidden
350
+ className={cn(
351
+ "flex h-4 w-4 shrink-0 items-center justify-center border-[1.5px] transition-colors duration-150 motion-reduce:transition-none",
352
+ isSingle ? "rounded-full" : "rounded-[5px]",
353
+ "peer-focus-visible:ring-2 peer-focus-visible:ring-ring peer-focus-visible:ring-offset-2",
354
+ checked
355
+ ? "border-primary bg-primary"
356
+ : "border-border bg-background",
357
+ )}
358
+ >
359
+ {checked &&
360
+ (isSingle ? (
361
+ <span className="size-[7px] rounded-full bg-primary-foreground" />
362
+ ) : (
363
+ <Check
364
+ className="h-[11px] w-[11px] text-primary-foreground"
365
+ strokeWidth={3}
366
+ />
367
+ ))}
368
+ </span>
369
+ <span
370
+ title={option.label}
371
+ className={cn(
372
+ "min-w-0 truncate",
373
+ checked
374
+ ? "font-bold text-primary"
375
+ : "font-normal text-foreground",
376
+ )}
377
+ >
378
+ {option.label}
379
+ </span>
380
+ <span className="ml-auto shrink-0 font-mono text-[11.5px] tabular-nums text-muted-foreground">
381
+ {option.count}
382
+ </span>
383
+ </label>
305
384
  );
306
385
  })}
307
386
  </div>
@@ -329,6 +408,7 @@ export type FacetGroupSkeletonProps = {
329
408
  showMore?: boolean;
330
409
  /** Must match the `<FacetGroup>` it stands in for, or rows shift when data lands. */
331
410
  size?: FacetGroupSize;
411
+ layout?: "rows" | "chips";
332
412
  className?: string;
333
413
  };
334
414
 
@@ -338,7 +418,23 @@ export function FacetGroupSkeleton({
338
418
  showMore,
339
419
  size = "comfortable",
340
420
  className,
421
+ layout = "rows",
341
422
  }: FacetGroupSkeletonProps) {
423
+ if (layout === "chips")
424
+ return (
425
+ <div aria-hidden="true" className={cn("py-3", className)}>
426
+ <Skeleton className="mb-4 h-4 w-24" />
427
+ <div className="flex flex-wrap gap-2">
428
+ {Array.from({ length: optionCount }, (_, index) => (
429
+ <Skeleton
430
+ key={index}
431
+ className={cn("h-11 rounded-2xl", index % 2 ? "w-28" : "w-24")}
432
+ />
433
+ ))}
434
+ </div>
435
+ {showMore && <Skeleton className="mt-3 h-4 w-20" />}
436
+ </div>
437
+ );
342
438
  return (
343
439
  // Matches the live group: no rule of its own, `py-3`, and the row gap keyed
344
440
  // to the same density — or the panel shifts on load by the difference.
@@ -347,7 +443,10 @@ export function FacetGroupSkeleton({
347
443
 
348
444
  <div className={cn("flex flex-col", ROW_GAP[size])}>
349
445
  {Array.from({ length: optionCount }).map((_, index) => (
350
- <div key={index} className={cn("flex items-center gap-[9px]", ROW_SIZE[size])}>
446
+ <div
447
+ key={index}
448
+ className={cn("flex items-center gap-[9px]", ROW_SIZE[size])}
449
+ >
351
450
  <Skeleton className="h-4 w-4 rounded-[5px]" />
352
451
  <Skeleton className="h-3.5 w-24" />
353
452
  <Skeleton className="ml-auto h-3 w-5" />
@@ -34,6 +34,8 @@ export type FeaturedRailItemProps = {
34
34
  note: string;
35
35
  /** Top-left overlay pill. Include its icon. */
36
36
  tagLabel?: React.ReactNode;
37
+ /** Decorative overlay inside the poster box (`relative`, clipping): an absolutely positioned, `aria-hidden` ornament. */
38
+ posterOverlay?: React.ReactNode;
37
39
  posterUrl?: string;
38
40
  href?: string;
39
41
  /** Defaults to a plain `<a>`. Pass `next/link` in a Next app. */
@@ -49,6 +51,7 @@ export function FeaturedRailItem({
49
51
  venue,
50
52
  note,
51
53
  tagLabel,
54
+ posterOverlay,
52
55
  posterUrl,
53
56
  href,
54
57
  linkComponent: LinkComponent = DefaultLink,
@@ -84,6 +87,8 @@ export function FeaturedRailItem({
84
87
  </span>
85
88
  </div>
86
89
  )}
90
+
91
+ {posterOverlay}
87
92
  </div>
88
93
 
89
94
  <div className="px-0.5 pt-2.5">
@@ -0,0 +1,50 @@
1
+ import { useState } from "react";
2
+ import { render, screen } from "@testing-library/react";
3
+ import userEvent from "@testing-library/user-event";
4
+ import { describe, expect, it } from "vitest";
5
+ import { FilterOptionChip } from "./filter-option-chip";
6
+
7
+ describe("FilterOptionChip", () => {
8
+ it("keeps zero-count selections operable by keyboard", async () => {
9
+ function Example() {
10
+ const [checked, setChecked] = useState(true);
11
+ return (
12
+ <FilterOptionChip
13
+ label="Italiană"
14
+ count={0}
15
+ checked={checked}
16
+ onChange={(e) => setChecked(e.target.checked)}
17
+ />
18
+ );
19
+ }
20
+ const user = userEvent.setup();
21
+ render(<Example />);
22
+ const checkbox = screen.getByRole("checkbox", { name: "Italiană" });
23
+ expect(checkbox).toBeChecked();
24
+ await user.tab();
25
+ expect(checkbox).toHaveFocus();
26
+ await user.keyboard(" ");
27
+ expect(checkbox).not.toBeChecked();
28
+ });
29
+
30
+ it("keeps native single selection for radio groups", async () => {
31
+ const user = userEvent.setup();
32
+ render(
33
+ <fieldset>
34
+ <legend>Budget</legend>
35
+ <FilterOptionChip
36
+ type="radio"
37
+ name="budget"
38
+ label="Budget friendly"
39
+ defaultChecked
40
+ />
41
+ <FilterOptionChip type="radio" name="budget" label="Premium" />
42
+ </fieldset>,
43
+ );
44
+ await user.click(screen.getByRole("radio", { name: "Premium" }));
45
+ expect(screen.getByRole("radio", { name: "Premium" })).toBeChecked();
46
+ expect(
47
+ screen.getByRole("radio", { name: "Budget friendly" }),
48
+ ).not.toBeChecked();
49
+ });
50
+ });
@@ -0,0 +1,81 @@
1
+ "use client";
2
+
3
+ import { type ComponentPropsWithoutRef, type ReactNode, useId } from "react";
4
+ import { Check } from "lucide-react";
5
+ import { cn } from "../lib/utils";
6
+
7
+ export type FilterOptionChipProps = Omit<
8
+ ComponentPropsWithoutRef<"input">,
9
+ "type" | "children" | "size"
10
+ > & {
11
+ label: string;
12
+ /** Radios in one group must share a name. */
13
+ type?: "checkbox" | "radio";
14
+ /** Omit while counts are unresolved. Zero is a valid, selectable count. */
15
+ count?: number | null;
16
+ icon?: ReactNode;
17
+ };
18
+
19
+ /** A wrapping filter option with native selection and keyboard semantics. */
20
+ export function FilterOptionChip({
21
+ label,
22
+ type = "checkbox",
23
+ count,
24
+ icon,
25
+ className,
26
+ id,
27
+ disabled,
28
+ ...input
29
+ }: FilterOptionChipProps) {
30
+ const generatedId = useId();
31
+ const inputId = id ?? generatedId;
32
+ return (
33
+ <label
34
+ htmlFor={inputId}
35
+ className={cn(
36
+ "relative inline-flex max-w-full min-w-0",
37
+ disabled && "cursor-not-allowed opacity-50",
38
+ className,
39
+ )}
40
+ >
41
+ <input
42
+ {...input}
43
+ id={inputId}
44
+ type={type}
45
+ disabled={disabled}
46
+ aria-label={input["aria-label"] ?? label}
47
+ className="peer sr-only"
48
+ />
49
+ <span
50
+ className={cn(
51
+ "flex min-h-11 min-w-0 max-w-full cursor-pointer items-center gap-2 rounded-2xl border border-border bg-background px-3 py-2 text-sm text-foreground",
52
+ "transition-colors hover:bg-muted motion-reduce:transition-none",
53
+ "peer-checked:border-primary peer-checked:bg-primary/10 peer-checked:shadow-[inset_0_0_0_1px_var(--primary)] peer-checked:text-primary",
54
+ "peer-focus-visible:outline-2 peer-focus-visible:outline-offset-2 peer-focus-visible:outline-ring",
55
+ "peer-disabled:pointer-events-none peer-disabled:cursor-not-allowed",
56
+ "[&_.filter-check]:hidden peer-checked:[&_.filter-check]:block peer-checked:[&_.filter-icon]:hidden",
57
+ )}
58
+ >
59
+ <Check
60
+ aria-hidden
61
+ className="filter-check size-4 shrink-0"
62
+ strokeWidth={2.5}
63
+ />
64
+ {icon && (
65
+ <span aria-hidden className="filter-icon shrink-0 [&>svg]:size-4">
66
+ {icon}
67
+ </span>
68
+ )}
69
+ <span className="min-w-0 [overflow-wrap:anywhere]">{label}</span>
70
+ {count != null && (
71
+ <span
72
+ aria-hidden
73
+ className="shrink-0 text-xs tabular-nums text-muted-foreground"
74
+ >
75
+ {count}
76
+ </span>
77
+ )}
78
+ </span>
79
+ </label>
80
+ );
81
+ }