@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
@@ -0,0 +1,250 @@
1
+ import { useState } from "react";
2
+ import type { Meta, StoryObj } from "@storybook/react";
3
+ import { ArrowLeft, X } from "lucide-react";
4
+ import { FilterOptionGroup } from "./filter-option-group";
5
+ import { Button } from "./button";
6
+ import { FilterPanelFooter } from "./filter-panel-footer";
7
+
8
+ const meta: Meta<typeof FilterOptionGroup> = {
9
+ title: "Places/Filters redesign",
10
+ component: FilterOptionGroup,
11
+ parameters: {
12
+ layout: "fullscreen",
13
+ docs: {
14
+ description: {
15
+ component:
16
+ "Issue #255 design review. Native wrapping options with immediate selection, persistent reset/results actions and selected summaries. Demo counts are fixtures, not live results. App URL, search flush and map navigation are verified during integration.",
17
+ },
18
+ },
19
+ },
20
+ };
21
+ export default meta;
22
+ type Story = StoryObj<typeof meta>;
23
+
24
+ function FiltersPreview({
25
+ width = 390,
26
+ english = false,
27
+ pending = false,
28
+ zero = false,
29
+ largeText = false,
30
+ initiallyOpenNow = false,
31
+ }: {
32
+ width?: number;
33
+ english?: boolean;
34
+ pending?: boolean;
35
+ zero?: boolean;
36
+ largeText?: boolean;
37
+ initiallyOpenNow?: boolean;
38
+ }) {
39
+ const [values, setValues] = useState<Record<string, string[]>>({
40
+ type: ["Restaurant"],
41
+ service: [english ? "Table service" : "La masă"],
42
+ openNow: initiallyOpenNow ? [english ? "Open now" : "Deschis acum"] : [],
43
+ });
44
+ const [results, setResults] = useState(false);
45
+ const groups = [
46
+ {
47
+ key: "type",
48
+ title: english ? "Place type" : "Tipul localului",
49
+ open: true,
50
+ options: english
51
+ ? ["Restaurant", "Bistro", "Café", "Pub", "Bar", "Bakery"]
52
+ : ["Restaurant", "Bistro", "Cafenea", "Pub", "Bar", "Brutărie"],
53
+ },
54
+ {
55
+ key: "food",
56
+ title: english ? "Cuisine" : "Bucătărie",
57
+ open: true,
58
+ options: english
59
+ ? [
60
+ "Romanian",
61
+ "Italian",
62
+ "Mediterranean",
63
+ "Japanese",
64
+ "International",
65
+ "French",
66
+ "Greek",
67
+ "Turkish",
68
+ "Mexican",
69
+ ]
70
+ : [
71
+ "Românească",
72
+ "Italiană",
73
+ "Mediteraneană",
74
+ "Japoneză",
75
+ "Internațională",
76
+ "Franceză",
77
+ "Grecească",
78
+ "Turcească",
79
+ "Mexicană",
80
+ ],
81
+ },
82
+ {
83
+ key: "service",
84
+ title: english ? "Service" : "Servire",
85
+ options: english
86
+ ? ["Table service", "Self service", "Takeaway"]
87
+ : ["La masă", "Autoservire", "La pachet"],
88
+ },
89
+ {
90
+ key: "openNow",
91
+ title: english ? "Open now" : "Deschis acum",
92
+ open: true,
93
+ options: [english ? "Open now" : "Deschis acum"],
94
+ },
95
+ {
96
+ key: "dietary",
97
+ title: english ? "Dietary preferences" : "Preferințe alimentare",
98
+ options: english
99
+ ? [
100
+ "Vegetarian options",
101
+ "Vegan options",
102
+ "Gluten-free options available on request",
103
+ ]
104
+ : [
105
+ "Opțiuni vegetariene",
106
+ "Opțiuni vegane",
107
+ "Opțiuni fără gluten disponibile la cerere",
108
+ ],
109
+ },
110
+ {
111
+ key: "vibe",
112
+ title: english ? "Atmosphere" : "Atmosferă",
113
+ options: english
114
+ ? ["Cozy", "Romantic", "Family friendly", "Lively", "Quiet"]
115
+ : [
116
+ "Primitoare",
117
+ "Romantică",
118
+ "Potrivit pentru familii",
119
+ "Animată",
120
+ "Liniștită",
121
+ ],
122
+ },
123
+ {
124
+ key: "amenities",
125
+ title: english ? "Amenities" : "Facilități",
126
+ options: english
127
+ ? ["Terrace", "Wi-Fi", "Parking", "Step-free wheelchair access"]
128
+ : [
129
+ "Terasă",
130
+ "Wi-Fi",
131
+ "Parcare",
132
+ "Acces fără trepte pentru scaun rulant",
133
+ ],
134
+ },
135
+ ];
136
+ const canReset = Object.values(values).some((value) => value.length > 0);
137
+ return (
138
+ <div
139
+ style={{ width, maxWidth: "100%" }}
140
+ className="mx-auto flex h-[760px] max-h-dvh flex-col overflow-hidden rounded-t-3xl border border-border bg-background text-foreground shadow-lg"
141
+ >
142
+ {largeText && <style>{"html { font-size: 20px; }"}</style>}
143
+ <div
144
+ aria-hidden
145
+ className="mx-auto mt-3 h-1 w-10 shrink-0 rounded-full bg-muted-foreground/30"
146
+ />
147
+ <header className="flex shrink-0 items-center justify-between gap-3 border-b border-border/60 px-4 py-2">
148
+ <Button
149
+ variant="ghost"
150
+ size="icon"
151
+ aria-label={english ? "Back to results" : "Înapoi la rezultate"}
152
+ onClick={() => setResults(true)}
153
+ >
154
+ <ArrowLeft aria-hidden />
155
+ </Button>
156
+ <h1 className="text-lg font-semibold">
157
+ {english ? "Filters" : "Filtre"}
158
+ </h1>
159
+ <Button
160
+ variant="ghost"
161
+ size="icon"
162
+ aria-label={english ? "Close filters" : "Închide filtrele"}
163
+ onClick={() => setResults(true)}
164
+ >
165
+ <X aria-hidden />
166
+ </Button>
167
+ </header>
168
+ {results ? (
169
+ <div className="flex flex-1 flex-col items-center justify-center gap-4 p-5">
170
+ <p>
171
+ {english
172
+ ? "Your selections are kept."
173
+ : "Selecțiile tale sunt păstrate."}
174
+ </p>
175
+ <Button onClick={() => setResults(false)}>
176
+ {english ? "Reopen filters" : "Redeschide filtrele"}
177
+ </Button>
178
+ </div>
179
+ ) : (
180
+ <>
181
+ <div className="min-h-0 flex-1 overflow-y-auto overscroll-contain px-4 pb-4">
182
+ {groups.map((group) => (
183
+ <FilterOptionGroup
184
+ key={group.key}
185
+ title={group.title}
186
+ defaultOpen={group.open}
187
+ options={group.options.map((label, index) => ({
188
+ value: label,
189
+ label,
190
+ count:
191
+ pending || group.key === "openNow"
192
+ ? null
193
+ : zero
194
+ ? 0
195
+ : [159, 42, 26, 18, 12, 7, 6, 4, 2][index],
196
+ }))}
197
+ value={values[group.key] ?? []}
198
+ onValueChange={(value) =>
199
+ setValues((previous) => ({
200
+ ...previous,
201
+ [group.key]: value.map(String),
202
+ }))
203
+ }
204
+ showAllLabel={
205
+ english
206
+ ? `Show all (${group.options.length})`
207
+ : `Arată toate (${group.options.length})`
208
+ }
209
+ showLessLabel={english ? "Show fewer" : "Arată mai puține"}
210
+ />
211
+ ))}
212
+ </div>
213
+ <FilterPanelFooter
214
+ resetLabel={english ? "Reset" : "Resetează"}
215
+ resultsLabel={`${english ? "View results" : "Vezi rezultatele"}${pending ? "" : ` (${zero ? 0 : 159})`}`}
216
+ canReset={canReset}
217
+ onReset={() => setValues({})}
218
+ onResults={() => setResults(true)}
219
+ resetHint={
220
+ english
221
+ ? "Reset clears all filters, including place type and service."
222
+ : "Resetarea șterge toate filtrele, inclusiv tipul localului și servirea."
223
+ }
224
+ />
225
+ </>
226
+ )}
227
+ </div>
228
+ );
229
+ }
230
+
231
+ export const Mobile390: Story = { render: () => <FiltersPreview /> };
232
+ export const OpenNowSelected: Story = {
233
+ render: () => <FiltersPreview initiallyOpenNow />,
234
+ };
235
+ export const Narrow360: Story = {
236
+ render: () => <FiltersPreview width={360} />,
237
+ };
238
+ export const Wide430: Story = { render: () => <FiltersPreview width={430} /> };
239
+ export const English: Story = { render: () => <FiltersPreview english /> };
240
+ export const PendingCounts: Story = {
241
+ render: () => <FiltersPreview pending />,
242
+ };
243
+ export const ZeroResults: Story = { render: () => <FiltersPreview zero /> };
244
+ export const LargerText: Story = {
245
+ render: () => <FiltersPreview width={360} largeText />,
246
+ };
247
+ export const Dark: Story = {
248
+ globals: { theme: "dark" },
249
+ render: () => <FiltersPreview />,
250
+ };
@@ -0,0 +1,100 @@
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, vi } from "vitest";
5
+ import { FilterOptionGroup } from "./filter-option-group";
6
+ import { FacetGroup } from "./facet-group";
7
+
8
+ describe("FilterOptionGroup", () => {
9
+ it("keeps selected options outside the cap reachable and summarizes them on collapse", async () => {
10
+ const user = userEvent.setup();
11
+ const options = Array.from({ length: 9 }, (_, i) => ({
12
+ value: String(i),
13
+ label: `Cuisine ${i}`,
14
+ }));
15
+ render(
16
+ <FilterOptionGroup
17
+ title="Cuisine"
18
+ options={options}
19
+ value={["8"]}
20
+ onValueChange={() => {}}
21
+ defaultOpen
22
+ showAllLabel="Show all"
23
+ showLessLabel="Show less"
24
+ />,
25
+ );
26
+ expect(screen.getByRole("checkbox", { name: "Cuisine 8" })).toBeChecked();
27
+ expect(
28
+ screen.queryByRole("checkbox", { name: "Cuisine 7" }),
29
+ ).not.toBeInTheDocument();
30
+ await user.click(screen.getByRole("button", { name: "Show all" }));
31
+ expect(screen.getByRole("checkbox", { name: "Cuisine 7" })).toBeVisible();
32
+ await user.click(screen.getByRole("button", { name: /^Cuisine/ }));
33
+ expect(screen.getByRole("button", { name: /Cuisine 8/ })).toBeVisible();
34
+ expect(screen.queryByRole("checkbox")).not.toBeInTheDocument();
35
+ });
36
+
37
+ it("leaves the group open after the last selection is removed", async () => {
38
+ function Example() {
39
+ const [value, setValue] = useState<(string | number)[]>(["open"]);
40
+ return (
41
+ <FilterOptionGroup
42
+ title="Opening hours"
43
+ options={[{ value: "open", label: "Open now" }]}
44
+ value={value}
45
+ onValueChange={setValue}
46
+ defaultOpen
47
+ />
48
+ );
49
+ }
50
+ render(<Example />);
51
+ await userEvent.click(screen.getByRole("checkbox", { name: "Open now" }));
52
+ expect(
53
+ screen.getByRole("checkbox", { name: "Open now" }),
54
+ ).not.toBeChecked();
55
+ expect(
56
+ screen.getByRole("button", { name: "Opening hours" }),
57
+ ).toHaveAttribute("aria-expanded", "true");
58
+ });
59
+
60
+ it("caps each vocabulary independently", () => {
61
+ const options = ["Dishes", "Cuisines"].flatMap((section) =>
62
+ Array.from({ length: 8 }, (_, i) => ({
63
+ value: `${section}-${i}`,
64
+ label: `${section} ${i}`,
65
+ section,
66
+ })),
67
+ );
68
+ render(
69
+ <FilterOptionGroup
70
+ title="Food"
71
+ options={options}
72
+ value={[]}
73
+ onValueChange={() => {}}
74
+ defaultOpen
75
+ showAllLabel="All"
76
+ showLessLabel="Less"
77
+ />,
78
+ );
79
+ expect(screen.getAllByRole("checkbox")).toHaveLength(12);
80
+ expect(screen.getAllByRole("button", { name: "All" })).toHaveLength(2);
81
+ });
82
+
83
+ it("adapts the existing FacetGroup single selection contract", async () => {
84
+ const onToggle = vi.fn();
85
+ render(
86
+ <FacetGroup
87
+ layout="chips"
88
+ title="When"
89
+ selection="single"
90
+ options={[
91
+ { id: "today", label: "Today", count: 1, checked: true },
92
+ { id: "tomorrow", label: "Tomorrow", count: 2 },
93
+ ]}
94
+ onToggle={onToggle}
95
+ />,
96
+ );
97
+ await userEvent.click(screen.getByRole("radio", { name: "Tomorrow" }));
98
+ expect(onToggle).toHaveBeenCalledExactlyOnceWith("tomorrow", true);
99
+ });
100
+ });
@@ -0,0 +1,231 @@
1
+ "use client";
2
+
3
+ import { type ReactNode, useId, useState } from "react";
4
+ import { ChevronDown } from "lucide-react";
5
+ import { FilterOptionChip } from "./filter-option-chip";
6
+ import { Badge } from "./badge";
7
+ import { Button } from "./button";
8
+ import { Separator } from "./separator";
9
+ import { Text } from "./text";
10
+ import { cn } from "../lib/utils";
11
+
12
+ export type FilterOptionValue = string | number;
13
+ export type FilterOptionGroupProps = {
14
+ title: string;
15
+ options: {
16
+ value: FilterOptionValue;
17
+ label: string;
18
+ count?: number | null;
19
+ icon?: ReactNode;
20
+ section?: string;
21
+ }[];
22
+ value: FilterOptionValue[];
23
+ onValueChange: (value: FilterOptionValue[]) => void;
24
+ selection?: "multiple" | "single";
25
+ name?: string;
26
+ defaultOpen?: boolean;
27
+ collapsible?: boolean;
28
+ visibleLimit?: number;
29
+ showAllLabel?: string | ((count: number) => string);
30
+ showLessLabel?: string;
31
+ /** Extra controls or a selection explanation, before the options. */
32
+ children?: ReactNode;
33
+ footer?: ReactNode;
34
+ clearLabel?: string;
35
+ onClear?: () => void;
36
+ /** Omit neutral choices from the active summary without changing radio selection. */
37
+ summaryValues?: FilterOptionValue[];
38
+ className?: string;
39
+ divider?: boolean;
40
+ disabled?: boolean;
41
+ };
42
+
43
+ /** Presentation only: callers own filter rules, URL state, counts and translations. */
44
+ export function FilterOptionGroup({
45
+ title,
46
+ options,
47
+ value,
48
+ onValueChange,
49
+ selection = "multiple",
50
+ name,
51
+ defaultOpen = false,
52
+ collapsible = true,
53
+ visibleLimit = 6,
54
+ showAllLabel,
55
+ showLessLabel,
56
+ children,
57
+ footer,
58
+ clearLabel,
59
+ onClear,
60
+ summaryValues = value,
61
+ className,
62
+ divider = true,
63
+ disabled,
64
+ }: FilterOptionGroupProps) {
65
+ const id = useId();
66
+ const [isOpen, setOpen] = useState(defaultOpen);
67
+ const open = !collapsible || isOpen;
68
+ const [expanded, setExpanded] = useState<string[]>([]);
69
+ const selected = options.filter((option) =>
70
+ summaryValues.includes(option.value),
71
+ );
72
+ const sections: { label?: string; options: typeof options }[] = [];
73
+ for (const option of options) {
74
+ const last = sections[sections.length - 1];
75
+ if (!last || last.label !== option.section)
76
+ sections.push({ label: option.section, options: [option] });
77
+ else last.options.push(option);
78
+ }
79
+ const heading = (
80
+ <>
81
+ <span className="min-w-0 flex-1">
82
+ <Text
83
+ as="span"
84
+ id={`${id}-title`}
85
+ className="block"
86
+ variant="body-sm"
87
+ weight="semibold"
88
+ >
89
+ {title}
90
+ </Text>
91
+ {!open && selected.length > 0 && (
92
+ <Text
93
+ as="span"
94
+ variant="caption"
95
+ color="muted"
96
+ className="mt-1 block leading-relaxed [overflow-wrap:anywhere]"
97
+ >
98
+ {selected.map((option) => option.label).join(" · ")}
99
+ </Text>
100
+ )}
101
+ </span>
102
+ {selected.length > 0 && (
103
+ <Badge variant="secondary" className="tabular-nums">
104
+ {selected.length}
105
+ </Badge>
106
+ )}
107
+ {collapsible && (
108
+ <ChevronDown
109
+ aria-hidden
110
+ className={cn(
111
+ "size-4 shrink-0 text-muted-foreground transition-transform motion-reduce:transition-none",
112
+ open && "rotate-180",
113
+ )}
114
+ />
115
+ )}
116
+ </>
117
+ );
118
+ return (
119
+ <section className={cn("min-w-0", className)}>
120
+ <div className="py-2">
121
+ <div className="flex items-center gap-2">
122
+ {collapsible ? (
123
+ <button
124
+ type="button"
125
+ onClick={() => setOpen((previous) => !previous)}
126
+ aria-expanded={open}
127
+ aria-controls={id}
128
+ className="flex min-h-11 min-w-0 flex-1 items-center gap-3 rounded-lg py-2 text-left focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring"
129
+ >
130
+ {heading}
131
+ </button>
132
+ ) : (
133
+ <div className="flex min-h-11 min-w-0 flex-1 items-center gap-3 py-2">
134
+ {heading}
135
+ </div>
136
+ )}
137
+ {onClear && clearLabel && summaryValues.length > 0 && (
138
+ <Button
139
+ type="button"
140
+ variant="ghost"
141
+ className="min-h-11 shrink-0 px-2 text-xs"
142
+ onClick={onClear}
143
+ >
144
+ {clearLabel}
145
+ </Button>
146
+ )}
147
+ </div>
148
+ <div id={id} hidden={!open}>
149
+ {children}
150
+ {sections.map((section, index) => {
151
+ const sectionId = `${id}-section-${index}`;
152
+ const key = section.label ?? "";
153
+ const isExpanded = expanded.includes(key);
154
+ const limit = showAllLabel ? visibleLimit : Infinity;
155
+ const visible = isExpanded
156
+ ? section.options
157
+ : section.options.filter(
158
+ (option, i) => i < limit || value.includes(option.value),
159
+ );
160
+ return (
161
+ <div key={key}>
162
+ {section.label && (
163
+ <Text
164
+ as="h4"
165
+ id={sectionId}
166
+ variant="caption"
167
+ weight="semibold"
168
+ color="muted"
169
+ className="mt-3 mb-1"
170
+ >
171
+ {section.label}
172
+ </Text>
173
+ )}
174
+ <fieldset
175
+ aria-labelledby={`${id}-title${section.label ? ` ${sectionId}` : ""}`}
176
+ disabled={disabled}
177
+ className="m-0 flex min-w-0 flex-wrap gap-2 border-0 px-0 pt-2 pb-3"
178
+ >
179
+ {visible.map((option) => (
180
+ <FilterOptionChip
181
+ key={option.value}
182
+ label={option.label}
183
+ count={option.count}
184
+ icon={option.icon}
185
+ type={selection === "single" ? "radio" : "checkbox"}
186
+ name={selection === "single" ? (name ?? id) : undefined}
187
+ value={option.value}
188
+ checked={value.includes(option.value)}
189
+ onChange={(event) =>
190
+ onValueChange(
191
+ selection === "single"
192
+ ? [option.value]
193
+ : event.target.checked
194
+ ? [...value, option.value]
195
+ : value.filter((item) => item !== option.value),
196
+ )
197
+ }
198
+ />
199
+ ))}
200
+ </fieldset>
201
+ {section.options.length > limit && (
202
+ <Button
203
+ type="button"
204
+ variant="link"
205
+ aria-expanded={isExpanded}
206
+ onClick={() =>
207
+ setExpanded((previous) =>
208
+ isExpanded
209
+ ? previous.filter((item) => item !== key)
210
+ : [...previous, key],
211
+ )
212
+ }
213
+ className="mb-1 min-h-11 px-0"
214
+ >
215
+ {isExpanded
216
+ ? showLessLabel
217
+ : typeof showAllLabel === "function"
218
+ ? showAllLabel(section.options.length)
219
+ : showAllLabel}
220
+ </Button>
221
+ )}
222
+ </div>
223
+ );
224
+ })}
225
+ {footer}
226
+ </div>
227
+ </div>
228
+ {divider && <Separator />}
229
+ </section>
230
+ );
231
+ }
@@ -0,0 +1,63 @@
1
+ "use client";
2
+
3
+ import { Button } from "./button";
4
+ import { Text } from "./text";
5
+
6
+ export type FilterPanelFooterProps = {
7
+ resetLabel: string;
8
+ resultsLabel: string;
9
+ onReset: () => void;
10
+ onResults: () => void;
11
+ canReset: boolean;
12
+ pending?: boolean;
13
+ resetHint?: string;
14
+ };
15
+
16
+ /** Persistent actions belong outside the panel's scroll container. */
17
+ export function FilterPanelFooter({
18
+ resetLabel,
19
+ resultsLabel,
20
+ onReset,
21
+ onResults,
22
+ canReset,
23
+ pending,
24
+ resetHint,
25
+ }: FilterPanelFooterProps) {
26
+ return (
27
+ <footer
28
+ className="shrink-0 border-t border-border bg-card px-4 pt-3 pb-[max(1rem,env(safe-area-inset-bottom))]"
29
+ data-slot="filter-panel-footer"
30
+ >
31
+ <div className="flex flex-wrap gap-2">
32
+ <Button
33
+ type="button"
34
+ variant="outline"
35
+ className="min-h-11 h-auto flex-1 whitespace-normal py-2"
36
+ disabled={!canReset}
37
+ onClick={onReset}
38
+ >
39
+ {resetLabel}
40
+ </Button>
41
+ <Button
42
+ type="button"
43
+ className="min-h-11 h-auto flex-[2] whitespace-normal py-2"
44
+ disabled={pending}
45
+ aria-busy={pending || undefined}
46
+ onClick={onResults}
47
+ >
48
+ {resultsLabel}
49
+ </Button>
50
+ </div>
51
+ {resetHint && (
52
+ <Text
53
+ as="p"
54
+ variant="caption"
55
+ color="muted"
56
+ className="mt-2 leading-relaxed"
57
+ >
58
+ {resetHint}
59
+ </Text>
60
+ )}
61
+ </footer>
62
+ );
63
+ }
@@ -96,6 +96,12 @@ export type KitchenDossierProps = {
96
96
  partDurationMs?: number;
97
97
  /** The foot's action — pass a rendered link or button. */
98
98
  action?: ReactNode;
99
+ /**
100
+ * Drawn in place of the open-until pill's dot, in the dot's slot (roughly
101
+ * 16-22px square). Decorative and `aria-hidden`; only rendered with
102
+ * `labels.openUntil`.
103
+ */
104
+ openIndicator?: ReactNode;
99
105
  linkComponent?: MenuLinkComponent;
100
106
  className?: string;
101
107
  };
@@ -128,6 +134,7 @@ export function KitchenDossier({
128
134
  formatScore,
129
135
  partDurationMs,
130
136
  action,
137
+ openIndicator,
131
138
  linkComponent: LinkComponent = DefaultLink,
132
139
  className,
133
140
  }: KitchenDossierProps) {
@@ -249,7 +256,13 @@ export function KitchenDossier({
249
256
  <div className="relative min-h-[184px] flex-1">
250
257
  {labels.openUntil && (
251
258
  <span className="absolute left-0 top-3 inline-flex h-[30px] items-center gap-[7px] rounded-full bg-[oklch(0.16_0.012_55_/_0.78)] px-3 text-[11.5px] font-bold text-white">
252
- <i className="h-[7px] w-[7px] rounded-full bg-success" aria-hidden />
259
+ {openIndicator ? (
260
+ <span className="flex shrink-0 items-center justify-center" aria-hidden>
261
+ {openIndicator}
262
+ </span>
263
+ ) : (
264
+ <i className="h-[7px] w-[7px] rounded-full bg-success" aria-hidden />
265
+ )}
253
266
  {labels.openUntil}
254
267
  </span>
255
268
  )}