@bearmenu/ui 0.6.0 → 0.7.0

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 (80) hide show
  1. package/dist/chunk-A5ONAJCE.js +102 -0
  2. package/dist/chunk-AV777R4G.js +43 -0
  3. package/dist/{chunk-WKMLZDUT.js → chunk-FRPPIXMY.js} +1 -1
  4. package/dist/{chunk-PSR7N36A.js → chunk-HCM6LKM5.js} +1 -1
  5. package/dist/{chunk-YJ6OBKZJ.js → chunk-TL4YU2O5.js} +10 -3
  6. package/dist/chunk-UX6SWAKH.js +320 -0
  7. package/dist/{chunk-7PPCH42H.js → chunk-VQNDZVI7.js} +1 -1
  8. package/dist/chunk-WSRWFA6K.js +26 -0
  9. package/dist/chunk-YLNYXPIH.js +61 -0
  10. package/dist/components/accordion.js +1 -1
  11. package/dist/components/alert.d.ts +2 -2
  12. package/dist/components/badge.d.ts +3 -2
  13. package/dist/components/badge.js +1 -1
  14. package/dist/components/button.d.ts +1 -1
  15. package/dist/components/card.d.ts +2 -2
  16. package/dist/components/card.js +127 -4
  17. package/dist/components/combobox.js +2 -2
  18. package/dist/components/command.d.ts +15 -15
  19. package/dist/components/command.js +2 -2
  20. package/dist/components/currency-input.js +1 -1
  21. package/dist/components/date-input.d.ts +1 -1
  22. package/dist/components/date-input.js +1 -1
  23. package/dist/components/dialog.js +1 -1
  24. package/dist/components/filter-category-row.d.ts +13 -0
  25. package/dist/components/filter-category-row.js +44 -0
  26. package/dist/components/input-group.d.ts +2 -0
  27. package/dist/components/input-group.js +10 -5
  28. package/dist/components/input.d.ts +1 -0
  29. package/dist/components/input.js +1 -1
  30. package/dist/components/item.d.ts +2 -2
  31. package/dist/components/multi-select-combobox.js +3 -3
  32. package/dist/components/phone-frame.js +1 -1
  33. package/dist/components/place-about-tab.d.ts +2 -2
  34. package/dist/components/place-about-tab.js +4 -3
  35. package/dist/components/place-details-mobile.js +5 -3
  36. package/dist/components/place-schedule-week.d.ts +19 -0
  37. package/dist/components/place-schedule-week.js +4 -0
  38. package/dist/components/place-types.d.ts +18 -9
  39. package/dist/components/scroll-fade.d.ts +15 -0
  40. package/dist/components/scroll-fade.js +121 -0
  41. package/dist/components/searchable-select.js +2 -2
  42. package/dist/components/separator.js +3 -26
  43. package/dist/components/sheet.js +1 -1
  44. package/dist/components/sliding-panels.d.ts +1 -0
  45. package/dist/components/sliding-panels.js +22 -6
  46. package/dist/components/sort-option-list.d.ts +16 -0
  47. package/dist/components/sort-option-list.js +46 -0
  48. package/dist/components/spinner.d.ts +2 -2
  49. package/dist/components/tabs.js +98 -17
  50. package/dist/components/tag.d.ts +2 -2
  51. package/dist/components/tag.js +3 -61
  52. package/dist/components/text.d.ts +3 -3
  53. package/dist/components/toast.js +1 -1
  54. package/dist/components/toaster.js +1 -1
  55. package/package.json +23 -112
  56. package/src/components/accordion.tsx +1 -1
  57. package/src/components/badge.tsx +9 -2
  58. package/src/components/card.tsx +22 -18
  59. package/src/components/dialog.tsx +1 -1
  60. package/src/components/filter-category-row.tsx +52 -0
  61. package/src/components/input-group.tsx +9 -4
  62. package/src/components/input.tsx +8 -3
  63. package/src/components/phone-frame.tsx +1 -1
  64. package/src/components/place-about-tab.stories.tsx +24 -13
  65. package/src/components/place-about-tab.test.tsx +92 -38
  66. package/src/components/place-about-tab.tsx +310 -344
  67. package/src/components/place-schedule-week.tsx +128 -0
  68. package/src/components/place-types.ts +27 -8
  69. package/src/components/scroll-fade.stories.tsx +98 -0
  70. package/src/components/scroll-fade.tsx +132 -0
  71. package/src/components/sheet.tsx +1 -1
  72. package/src/components/sliding-panels.tsx +33 -6
  73. package/src/components/sort-option-list.tsx +57 -0
  74. package/src/components/tabs.tsx +76 -11
  75. package/src/components/toast.tsx +1 -1
  76. package/src/globals.css +77 -19
  77. package/src/lib/motion.ts +137 -0
  78. package/dist/chunk-BAZFVSSG.js +0 -279
  79. package/dist/chunk-RIDXFY6M.js +0 -38
  80. package/dist/chunk-XK2ZDQVE.js +0 -122
@@ -0,0 +1,128 @@
1
+ "use client";
2
+
3
+ import { useEffect, useRef } from "react";
4
+ import { cn } from "../lib/utils";
5
+ import { Text } from "./text";
6
+ import type {
7
+ PlaceSchedule,
8
+ PlaceScheduleWeekLabels,
9
+ WeekdayKey,
10
+ } from "./place-types";
11
+
12
+ const WEEKDAY_KEYS: readonly WeekdayKey[] = [
13
+ "monday",
14
+ "tuesday",
15
+ "wednesday",
16
+ "thursday",
17
+ "friday",
18
+ "saturday",
19
+ "sunday",
20
+ ] as const;
21
+
22
+ function formatHour(h: number): string {
23
+ return `${h.toString().padStart(2, "0")}:00`;
24
+ }
25
+
26
+ /** JS Date.getDay(): Sunday=0 ... Saturday=6 → WEEKDAY_KEYS index where Monday=0 */
27
+ function todayWeekdayIndex(): number {
28
+ const d = new Date().getDay();
29
+ return (d + 6) % 7;
30
+ }
31
+
32
+ export interface PlaceScheduleWeekProps {
33
+ schedule?: PlaceSchedule;
34
+ labels: PlaceScheduleWeekLabels;
35
+ /**
36
+ * When true, scrolls the today row into view on mount. Use inside a drawer/dialog
37
+ * where the schedule is the only content — auto-scrolling avoids weekend rows
38
+ * being clipped at the bottom of small viewports. Default false: do not auto-scroll
39
+ * when the schedule sits inline on a longer page, where the behaviour disorients
40
+ * the reader.
41
+ */
42
+ autoScrollToToday?: boolean;
43
+ className?: string;
44
+ }
45
+
46
+ export function PlaceScheduleWeek({
47
+ schedule,
48
+ labels,
49
+ autoScrollToToday = false,
50
+ className,
51
+ }: PlaceScheduleWeekProps) {
52
+ const todayIdx = todayWeekdayIndex();
53
+ const todayRowRef = useRef<HTMLDivElement | null>(null);
54
+
55
+ useEffect(() => {
56
+ if (autoScrollToToday) {
57
+ todayRowRef.current?.scrollIntoView({ block: "nearest" });
58
+ }
59
+ }, [autoScrollToToday]);
60
+
61
+ if (!schedule) {
62
+ return (
63
+ <Text as="p" variant="body-sm" color="muted">
64
+ {labels.noHoursAvailable}
65
+ </Text>
66
+ );
67
+ }
68
+
69
+ return (
70
+ <div className={cn(className)}>
71
+ {WEEKDAY_KEYS.map((dayKey, idx) => {
72
+ const slots = schedule[dayKey] ?? [];
73
+ const isToday = idx === todayIdx;
74
+ const isClosed = slots.length === 0;
75
+ return (
76
+ <div
77
+ key={dayKey}
78
+ ref={isToday ? todayRowRef : null}
79
+ className={cn(
80
+ "relative flex items-start justify-between gap-3 rounded-lg px-3 py-1.5 overflow-hidden",
81
+ isToday && "bg-primary/8"
82
+ )}
83
+ >
84
+ {isToday && (
85
+ <span
86
+ aria-hidden
87
+ className="absolute left-0 top-0 bottom-0 w-1 bg-primary"
88
+ />
89
+ )}
90
+ <Text
91
+ as="span"
92
+ variant="body-sm"
93
+ weight={isToday ? "semibold" : "medium"}
94
+ className={isToday ? "text-foreground" : "text-muted-foreground"}
95
+ >
96
+ {labels.days[dayKey]}
97
+ </Text>
98
+ <div className="flex flex-col items-end gap-0.5">
99
+ {isClosed ? (
100
+ <Text
101
+ as="span"
102
+ variant="body-sm"
103
+ className="line-through text-muted-foreground/50"
104
+ >
105
+ {labels.closed}
106
+ </Text>
107
+ ) : (
108
+ slots.map((slot, i) => (
109
+ <Text
110
+ key={i}
111
+ as="span"
112
+ variant="body-sm"
113
+ className={cn(
114
+ "tabular-nums",
115
+ isToday ? "text-foreground" : "text-muted-foreground"
116
+ )}
117
+ >
118
+ {formatHour(slot.opens_at)} – {formatHour(slot.closes_at)}
119
+ </Text>
120
+ ))
121
+ )}
122
+ </div>
123
+ </div>
124
+ );
125
+ })}
126
+ </div>
127
+ );
128
+ }
@@ -15,6 +15,7 @@ export type PlacePreview = {
15
15
  review_count?: number
16
16
  hours?: PlaceHourBlock[]
17
17
  today_schedule?: PlaceHourBlock[]
18
+ schedule?: PlaceSchedule
18
19
  location?: {
19
20
  address?: string
20
21
  geolocation?: { lat: number | string; lon: number | string }
@@ -45,6 +46,17 @@ export type PlacePreview = {
45
46
 
46
47
  export type PlaceHourBlock = { opens_at: number; closes_at: number }
47
48
 
49
+ export type WeekdayKey =
50
+ | 'monday'
51
+ | 'tuesday'
52
+ | 'wednesday'
53
+ | 'thursday'
54
+ | 'friday'
55
+ | 'saturday'
56
+ | 'sunday'
57
+
58
+ export type PlaceSchedule = Record<WeekdayKey, PlaceHourBlock[]>
59
+
48
60
  export type PlacePriceRange = {
49
61
  price_range_low?: number
50
62
  price_range_high?: number
@@ -67,15 +79,14 @@ export type PlaceCardLabels = {
67
79
  * to the raw enum value (with underscores → spaces).
68
80
  */
69
81
  export type PlaceAboutTabLabels = {
70
- overview: string
82
+ /** Section heading for the editorial prose block (generative summary + review summary + description). */
71
83
  description: string
72
- whatPeopleSay: string
73
84
  contactAndLocation: string
74
- address: string
75
85
  instagram: string
76
86
  facebook: string
87
+ /** Section heading for the inline weekly schedule. */
77
88
  todayHours: string
78
- statusOpen: string
89
+ /** Used by the inline schedule's closed-day line-through. */
79
90
  statusClosed: string
80
91
  typicalBusyness: string
81
92
  cuisines: string
@@ -84,10 +95,10 @@ export type PlaceAboutTabLabels = {
84
95
  accessibility: string
85
96
  parking: string
86
97
  nearbyLandmarks: string
87
- neighborhood: string
88
- businessInformation: string
89
- type: string
90
- status: string
98
+ /** Weekday key → translated day name, consumed by the inline `<PlaceScheduleWeek>`. */
99
+ scheduleDays: Record<WeekdayKey, string>
100
+ /** Fallback shown by `<PlaceScheduleWeek>` when no schedule data is present. */
101
+ noHoursAvailable: string
91
102
  amenityLabels: Record<string, string>
92
103
  paymentLabels: Record<string, string>
93
104
  accessibilityLabels: Record<string, string>
@@ -99,6 +110,14 @@ export type PlaceAboutTabLabels = {
99
110
  /** Discriminator for `<PlaceAboutTab>`'s `onLinkClick` callback. */
100
111
  export type PlaceLinkKind = 'phone' | 'website' | 'instagram' | 'facebook' | 'address'
101
112
 
113
+ /** Labels passed to `<PlaceScheduleWeek>`. */
114
+ export type PlaceScheduleWeekLabels = {
115
+ days: Record<WeekdayKey, string>
116
+ closed: string
117
+ /** Shown when the `schedule` prop is missing or empty. */
118
+ noHoursAvailable: string
119
+ }
120
+
102
121
  /**
103
122
  * Labels passed to `<PlaceDetailsMobile>`. Extends `PlaceAboutTabLabels` with
104
123
  * the header/tab-strip strings unique to the mobile place-details surface.
@@ -0,0 +1,98 @@
1
+ import type { Meta, StoryObj } from "@storybook/react";
2
+ import { ScrollFade } from "./scroll-fade";
3
+
4
+ const meta = {
5
+ title: "Components/ScrollFade",
6
+ component: ScrollFade,
7
+ tags: ["autodocs"],
8
+ parameters: {
9
+ docs: {
10
+ description: {
11
+ component:
12
+ "Scroll-position-aware fade overlay. Hides when at the corresponding edge, fades in when there's more content to scroll. Works vertically or horizontally and matches a chosen surface color.",
13
+ },
14
+ },
15
+ },
16
+ } satisfies Meta<typeof ScrollFade>;
17
+
18
+ export default meta;
19
+ type Story = StoryObj<typeof meta>;
20
+
21
+ const longList = Array.from({ length: 30 });
22
+ const wideList = Array.from({ length: 20 });
23
+
24
+ export const Vertical: Story = {
25
+ args: {
26
+ orientation: "vertical",
27
+ fadeColor: "background",
28
+ size: "md",
29
+ },
30
+ render: (args) => (
31
+ <ScrollFade {...args} className="h-[320px] w-[360px] rounded-lg border" scrollClassName="p-4">
32
+ <ul className="space-y-3">
33
+ {longList.map((_, i) => (
34
+ <li key={i} className="text-sm text-muted-foreground">
35
+ Item #{i + 1} — scroll to reveal the fades at the edges.
36
+ </li>
37
+ ))}
38
+ </ul>
39
+ </ScrollFade>
40
+ ),
41
+ };
42
+
43
+ export const Horizontal: Story = {
44
+ args: {
45
+ orientation: "horizontal",
46
+ fadeColor: "background",
47
+ size: "md",
48
+ },
49
+ render: (args) => (
50
+ <ScrollFade {...args} className="w-[480px] rounded-lg border" scrollClassName="flex gap-3 p-4">
51
+ {wideList.map((_, i) => (
52
+ <div
53
+ key={i}
54
+ className="flex h-24 w-32 shrink-0 items-center justify-center rounded-md bg-muted text-sm"
55
+ >
56
+ Card {i + 1}
57
+ </div>
58
+ ))}
59
+ </ScrollFade>
60
+ ),
61
+ };
62
+
63
+ export const InsideCard: Story = {
64
+ args: {
65
+ orientation: "vertical",
66
+ fadeColor: "card",
67
+ size: "md",
68
+ },
69
+ render: (args) => (
70
+ <div className="bg-card text-card-foreground w-[360px] rounded-2xl border shadow-md">
71
+ <div className="border-border border-b p-4 font-semibold">Drawer header</div>
72
+ <ScrollFade {...args} className="h-[280px]" scrollClassName="p-4">
73
+ <ul className="space-y-3">
74
+ {longList.map((_, i) => (
75
+ <li key={i} className="text-sm text-muted-foreground">
76
+ Row #{i + 1} — fades use <code>fadeColor=&quot;card&quot;</code> to match the surface.
77
+ </li>
78
+ ))}
79
+ </ul>
80
+ </ScrollFade>
81
+ </div>
82
+ ),
83
+ };
84
+
85
+ export const NoOverflow: Story = {
86
+ args: {
87
+ orientation: "vertical",
88
+ fadeColor: "background",
89
+ size: "md",
90
+ },
91
+ render: (args) => (
92
+ <ScrollFade {...args} className="h-[200px] w-[320px] rounded-lg border" scrollClassName="p-4">
93
+ <p className="text-sm text-muted-foreground">
94
+ Content fits inside the box — both fades stay hidden.
95
+ </p>
96
+ </ScrollFade>
97
+ ),
98
+ };
@@ -0,0 +1,132 @@
1
+ "use client";
2
+
3
+ import * as React from "react";
4
+ import { cn } from "../lib/utils";
5
+
6
+ type Orientation = "vertical" | "horizontal";
7
+ type FadeColor = "background" | "card" | "muted";
8
+ type Size = "sm" | "md" | "lg";
9
+
10
+ interface ScrollFadeProps extends React.HTMLAttributes<HTMLDivElement> {
11
+ orientation?: Orientation;
12
+ fadeColor?: FadeColor;
13
+ size?: Size;
14
+ /** Class names applied to the inner scrollable element (where overflow lives). */
15
+ scrollClassName?: string;
16
+ }
17
+
18
+ // Static maps so Tailwind can statically analyze the class names.
19
+ const fromMap: Record<FadeColor, string> = {
20
+ background: "from-background",
21
+ card: "from-card",
22
+ muted: "from-muted",
23
+ };
24
+
25
+ const sizeMap: Record<Orientation, Record<Size, string>> = {
26
+ vertical: { sm: "h-6", md: "h-12", lg: "h-20" },
27
+ horizontal: { sm: "w-8", md: "w-16", lg: "w-28" },
28
+ };
29
+
30
+ const ScrollFade = React.forwardRef<HTMLDivElement, ScrollFadeProps>(
31
+ (
32
+ {
33
+ orientation = "vertical",
34
+ fadeColor = "background",
35
+ size = "md",
36
+ className,
37
+ scrollClassName,
38
+ children,
39
+ ...props
40
+ },
41
+ ref
42
+ ) => {
43
+ const innerRef = React.useRef<HTMLDivElement>(null);
44
+ React.useImperativeHandle(ref, () => innerRef.current as HTMLDivElement);
45
+
46
+ const [atStart, setAtStart] = React.useState(true);
47
+ const [atEnd, setAtEnd] = React.useState(true);
48
+
49
+ const update = React.useCallback(() => {
50
+ const el = innerRef.current;
51
+ if (!el) return;
52
+ if (orientation === "vertical") {
53
+ const overflow = el.scrollHeight - el.clientHeight;
54
+ setAtStart(el.scrollTop <= 1);
55
+ setAtEnd(overflow <= 1 || el.scrollTop >= overflow - 1);
56
+ } else {
57
+ const overflow = el.scrollWidth - el.clientWidth;
58
+ setAtStart(el.scrollLeft <= 1);
59
+ setAtEnd(overflow <= 1 || el.scrollLeft >= overflow - 1);
60
+ }
61
+ }, [orientation]);
62
+
63
+ React.useEffect(() => {
64
+ const el = innerRef.current;
65
+ if (!el) return;
66
+ update();
67
+ el.addEventListener("scroll", update, { passive: true });
68
+ const ro = new ResizeObserver(update);
69
+ ro.observe(el);
70
+ return () => {
71
+ el.removeEventListener("scroll", update);
72
+ ro.disconnect();
73
+ };
74
+ }, [update]);
75
+
76
+ const fromClass = fromMap[fadeColor];
77
+ const fadeSize = sizeMap[orientation][size];
78
+
79
+ return (
80
+ <div
81
+ className={cn(
82
+ "relative isolate flex",
83
+ orientation === "vertical" ? "flex-col" : "flex-row",
84
+ className
85
+ )}
86
+ >
87
+ <div
88
+ ref={innerRef}
89
+ className={cn(
90
+ "min-h-0 min-w-0 flex-1",
91
+ orientation === "vertical" ? "overflow-y-auto" : "overflow-x-auto",
92
+ scrollClassName
93
+ )}
94
+ {...props}
95
+ >
96
+ {children}
97
+ </div>
98
+ <div
99
+ aria-hidden
100
+ className={cn(
101
+ "pointer-events-none absolute z-10 transition-opacity duration-300",
102
+ // `from-30%` adds a solid plateau before the gradient starts
103
+ // fading — without it the gradient is a pure linear ramp and only
104
+ // the very top pixel reads as solid, leaving the cut edge visible.
105
+ // -4px inset pulls the fade past the container edge so the very
106
+ // edge of clipped content is covered by the solid plateau.
107
+ orientation === "vertical"
108
+ ? `inset-x-0 -top-1 ${fadeSize} bg-gradient-to-b from-30%`
109
+ : `inset-y-0 -left-1 ${fadeSize} bg-gradient-to-r from-30%`,
110
+ fromClass,
111
+ atStart ? "opacity-0" : "opacity-100"
112
+ )}
113
+ />
114
+ <div
115
+ aria-hidden
116
+ className={cn(
117
+ "pointer-events-none absolute z-10 transition-opacity duration-300",
118
+ orientation === "vertical"
119
+ ? `inset-x-0 -bottom-1 ${fadeSize} bg-gradient-to-t from-30%`
120
+ : `inset-y-0 -right-1 ${fadeSize} bg-gradient-to-l from-30%`,
121
+ fromClass,
122
+ atEnd ? "opacity-0" : "opacity-100"
123
+ )}
124
+ />
125
+ </div>
126
+ );
127
+ }
128
+ );
129
+ ScrollFade.displayName = "ScrollFade";
130
+
131
+ export { ScrollFade };
132
+ export type { ScrollFadeProps };
@@ -30,7 +30,7 @@ const SheetOverlay = React.forwardRef<
30
30
  SheetOverlay.displayName = SheetPrimitive.Overlay.displayName;
31
31
 
32
32
  const sheetVariants = cva(
33
- "fixed z-[1000] gap-4 bg-background border-border p-6 shadow-2xl transition ease-in-out data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:duration-500",
33
+ "fixed z-[1000] gap-4 bg-background border-border p-6 shadow-2xl data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=open]:duration-[var(--duration-deliberate)] data-[state=closed]:duration-[var(--duration-exit-standard)] data-[state=open]:ease-[var(--ease-enter)] data-[state=closed]:ease-[var(--ease-exit)]",
34
34
  {
35
35
  variants: {
36
36
  side: {
@@ -12,6 +12,11 @@ export interface Panel {
12
12
  showBackButton?: boolean;
13
13
  backButtonLabel?: string;
14
14
  onBack?: () => void;
15
+ // When set, the panel is treated as a child of `parentId`. Siblings (panels
16
+ // sharing the same parent) stay off-screen on the right when not active —
17
+ // they no longer slide through during non-adjacent transitions. The default
18
+ // back button navigates to `parentId` instead of the previous array index.
19
+ parentId?: string;
15
20
  }
16
21
 
17
22
  export interface SlidingPanelsProps {
@@ -32,6 +37,23 @@ export const SlidingPanels = ({
32
37
  const panelRefs = useRef<(HTMLDivElement | null)[]>([]);
33
38
  const [containerHeight, setContainerHeight] = useState<number | undefined>();
34
39
 
40
+ // Tree mode: if any panel declares parentId, position based on hierarchy
41
+ // (ancestors slide left, everything else stays right). Otherwise fall back
42
+ // to the legacy index-based stack.
43
+ const usesTreeModel = panels.some((p) => p.parentId);
44
+
45
+ const activeAncestors = React.useMemo(() => {
46
+ if (!usesTreeModel) return new Set<string>();
47
+ const ancestors = new Set<string>();
48
+ let cursor = panels.find((p) => p.id === currentPanel);
49
+ while (cursor?.parentId) {
50
+ ancestors.add(cursor.parentId);
51
+ const nextId: string = cursor.parentId;
52
+ cursor = panels.find((p) => p.id === nextId);
53
+ }
54
+ return ancestors;
55
+ }, [panels, currentPanel, usesTreeModel]);
56
+
35
57
  // Measure active panel height on panel change and whenever content resizes
36
58
  useLayoutEffect(() => {
37
59
  const activeEl = panelRefs.current[currentIndex];
@@ -46,13 +68,16 @@ export const SlidingPanels = ({
46
68
  }, [currentIndex]);
47
69
 
48
70
  const handleBack = () => {
49
- const previousPanel = panels[currentIndex - 1];
50
- if (!previousPanel) return;
51
71
  if (currentPanelData?.onBack) {
52
72
  currentPanelData.onBack();
53
- } else {
54
- onPanelChange(previousPanel.id);
73
+ return;
74
+ }
75
+ if (currentPanelData?.parentId) {
76
+ onPanelChange(currentPanelData.parentId);
77
+ return;
55
78
  }
79
+ const previousPanel = panels[currentIndex - 1];
80
+ if (previousPanel) onPanelChange(previousPanel.id);
56
81
  };
57
82
 
58
83
  return (
@@ -65,8 +90,10 @@ export const SlidingPanels = ({
65
90
  >
66
91
  {panels.map((panel, index) => {
67
92
  const isActive = panel.id === currentPanel;
68
- const isLeft = index < currentIndex;
69
- const isRight = index > currentIndex;
93
+ const isLeft = usesTreeModel
94
+ ? activeAncestors.has(panel.id)
95
+ : index < currentIndex;
96
+ const isRight = !isActive && !isLeft;
70
97
 
71
98
  return (
72
99
  <div
@@ -0,0 +1,57 @@
1
+ "use client";
2
+
3
+ import { Check } from "lucide-react";
4
+ import { cn } from "../lib/utils";
5
+
6
+ export type SortOptionListItem<T extends string> = {
7
+ value: T;
8
+ label: string;
9
+ };
10
+
11
+ interface SortOptionListProps<T extends string> {
12
+ options: SortOptionListItem<T>[];
13
+ value: T;
14
+ onChange: (value: T) => void;
15
+ ariaLabel: string;
16
+ className?: string;
17
+ }
18
+
19
+ export function SortOptionList<T extends string>({
20
+ options,
21
+ value,
22
+ onChange,
23
+ ariaLabel,
24
+ className,
25
+ }: SortOptionListProps<T>) {
26
+ return (
27
+ <div
28
+ role="radiogroup"
29
+ aria-label={ariaLabel}
30
+ className={cn("px-2 pb-6", className)}
31
+ >
32
+ {options.map((option) => {
33
+ const isActive = option.value === value;
34
+ return (
35
+ <button
36
+ key={option.value}
37
+ type="button"
38
+ role="radio"
39
+ aria-checked={isActive}
40
+ onClick={() => onChange(option.value)}
41
+ className={cn(
42
+ "flex w-full items-center justify-between rounded-lg px-3 py-3 text-left text-sm cursor-pointer",
43
+ "transition-colors duration-150 motion-reduce:transition-none",
44
+ "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/30",
45
+ isActive
46
+ ? "bg-accent/10 text-accent font-semibold"
47
+ : "text-foreground hover:bg-muted/50"
48
+ )}
49
+ >
50
+ <span>{option.label}</span>
51
+ {isActive && <Check className="h-4 w-4" aria-hidden />}
52
+ </button>
53
+ );
54
+ })}
55
+ </div>
56
+ );
57
+ }