@bearmenu/ui 0.6.1 → 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.
- package/dist/chunk-A5ONAJCE.js +102 -0
- package/dist/chunk-AV777R4G.js +43 -0
- package/dist/{chunk-WKMLZDUT.js → chunk-FRPPIXMY.js} +1 -1
- package/dist/{chunk-PSR7N36A.js → chunk-HCM6LKM5.js} +1 -1
- package/dist/{chunk-YJ6OBKZJ.js → chunk-TL4YU2O5.js} +10 -3
- package/dist/chunk-UX6SWAKH.js +320 -0
- package/dist/{chunk-7PPCH42H.js → chunk-VQNDZVI7.js} +1 -1
- package/dist/chunk-WSRWFA6K.js +26 -0
- package/dist/chunk-YLNYXPIH.js +61 -0
- package/dist/components/accordion.js +1 -1
- package/dist/components/badge.d.ts +2 -1
- package/dist/components/badge.js +1 -1
- package/dist/components/card.js +127 -4
- package/dist/components/combobox.js +2 -2
- package/dist/components/command.js +2 -2
- package/dist/components/currency-input.js +1 -1
- package/dist/components/date-input.js +1 -1
- package/dist/components/dialog.js +1 -1
- package/dist/components/filter-category-row.d.ts +13 -0
- package/dist/components/filter-category-row.js +44 -0
- package/dist/components/input-group.d.ts +2 -0
- package/dist/components/input-group.js +10 -5
- package/dist/components/input.d.ts +1 -0
- package/dist/components/input.js +1 -1
- package/dist/components/multi-select-combobox.js +3 -3
- package/dist/components/phone-frame.js +1 -1
- package/dist/components/place-about-tab.d.ts +2 -2
- package/dist/components/place-about-tab.js +4 -3
- package/dist/components/place-details-mobile.js +5 -3
- package/dist/components/place-schedule-week.d.ts +19 -0
- package/dist/components/place-schedule-week.js +4 -0
- package/dist/components/place-types.d.ts +18 -9
- package/dist/components/scroll-fade.js +7 -2
- package/dist/components/searchable-select.js +2 -2
- package/dist/components/separator.js +3 -26
- package/dist/components/sheet.js +1 -1
- package/dist/components/sliding-panels.d.ts +1 -0
- package/dist/components/sliding-panels.js +22 -6
- package/dist/components/sort-option-list.d.ts +16 -0
- package/dist/components/sort-option-list.js +46 -0
- package/dist/components/tabs.js +98 -17
- package/dist/components/tag.js +3 -61
- package/dist/components/toast.js +1 -1
- package/dist/components/toaster.js +1 -1
- package/package.json +23 -113
- package/src/components/accordion.tsx +1 -1
- package/src/components/badge.tsx +9 -2
- package/src/components/card.tsx +22 -18
- package/src/components/dialog.tsx +1 -1
- package/src/components/filter-category-row.tsx +52 -0
- package/src/components/input-group.tsx +9 -4
- package/src/components/input.tsx +8 -3
- package/src/components/phone-frame.tsx +1 -1
- package/src/components/place-about-tab.stories.tsx +24 -13
- package/src/components/place-about-tab.test.tsx +92 -38
- package/src/components/place-about-tab.tsx +310 -344
- package/src/components/place-schedule-week.tsx +128 -0
- package/src/components/place-types.ts +27 -8
- package/src/components/scroll-fade.tsx +9 -4
- package/src/components/sheet.tsx +1 -1
- package/src/components/sliding-panels.tsx +33 -6
- package/src/components/sort-option-list.tsx +57 -0
- package/src/components/tabs.tsx +76 -11
- package/src/components/toast.tsx +1 -1
- package/src/globals.css +77 -19
- package/src/lib/motion.ts +137 -0
- package/dist/chunk-BAZFVSSG.js +0 -279
- package/dist/chunk-RIDXFY6M.js +0 -38
- 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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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.
|
|
@@ -99,9 +99,14 @@ const ScrollFade = React.forwardRef<HTMLDivElement, ScrollFadeProps>(
|
|
|
99
99
|
aria-hidden
|
|
100
100
|
className={cn(
|
|
101
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.
|
|
102
107
|
orientation === "vertical"
|
|
103
|
-
? `inset-x-0 top-
|
|
104
|
-
: `inset-y-0 left-
|
|
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%`,
|
|
105
110
|
fromClass,
|
|
106
111
|
atStart ? "opacity-0" : "opacity-100"
|
|
107
112
|
)}
|
|
@@ -111,8 +116,8 @@ const ScrollFade = React.forwardRef<HTMLDivElement, ScrollFadeProps>(
|
|
|
111
116
|
className={cn(
|
|
112
117
|
"pointer-events-none absolute z-10 transition-opacity duration-300",
|
|
113
118
|
orientation === "vertical"
|
|
114
|
-
? `inset-x-0 bottom-
|
|
115
|
-
: `inset-y-0 right-
|
|
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%`,
|
|
116
121
|
fromClass,
|
|
117
122
|
atEnd ? "opacity-0" : "opacity-100"
|
|
118
123
|
)}
|
package/src/components/sheet.tsx
CHANGED
|
@@ -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
|
|
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
|
-
|
|
54
|
-
|
|
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 =
|
|
69
|
-
|
|
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
|
+
}
|
package/src/components/tabs.tsx
CHANGED
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
import * as React from "react";
|
|
4
4
|
import * as TabsPrimitive from "@radix-ui/react-tabs";
|
|
5
|
+
import { motion } from "framer-motion";
|
|
6
|
+
import { archetype } from "../lib/motion";
|
|
5
7
|
import { cn } from "../lib/utils";
|
|
6
8
|
|
|
7
9
|
type TabsVariant = "pill" | "underline";
|
|
@@ -10,11 +12,15 @@ type TabsOrientation = "horizontal" | "vertical";
|
|
|
10
12
|
type TabsContextValue = {
|
|
11
13
|
variant: TabsVariant;
|
|
12
14
|
orientation: TabsOrientation;
|
|
15
|
+
/** Unique id used by Framer Motion `layoutId` to morph the active indicator
|
|
16
|
+
* between triggers. Generated once per <TabsList>. */
|
|
17
|
+
indicatorId: string;
|
|
13
18
|
};
|
|
14
19
|
|
|
15
20
|
const TabsContext = React.createContext<TabsContextValue>({
|
|
16
21
|
variant: "pill",
|
|
17
22
|
orientation: "horizontal",
|
|
23
|
+
indicatorId: "tabs-indicator",
|
|
18
24
|
});
|
|
19
25
|
|
|
20
26
|
const Tabs = React.forwardRef<
|
|
@@ -45,9 +51,10 @@ const TabsList = React.forwardRef<
|
|
|
45
51
|
>(({ className, variant = "pill", scrollable, children, ...props }, ref) => {
|
|
46
52
|
const orientation: TabsOrientation = props["aria-orientation"] === "vertical" ? "vertical" : "horizontal";
|
|
47
53
|
const allowScroll = scrollable ?? orientation === "horizontal";
|
|
54
|
+
const indicatorId = React.useId();
|
|
48
55
|
|
|
49
56
|
return (
|
|
50
|
-
<TabsContext.Provider value={{ variant, orientation }}>
|
|
57
|
+
<TabsContext.Provider value={{ variant, orientation, indicatorId }}>
|
|
51
58
|
<TabsPrimitive.List
|
|
52
59
|
ref={ref}
|
|
53
60
|
data-slot="tabs-list"
|
|
@@ -75,27 +82,80 @@ TabsList.displayName = TabsPrimitive.List.displayName;
|
|
|
75
82
|
const TabsTrigger = React.forwardRef<
|
|
76
83
|
React.ElementRef<typeof TabsPrimitive.Trigger>,
|
|
77
84
|
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
|
|
78
|
-
>(({ className, ...props }, ref) => {
|
|
79
|
-
const { variant, orientation } = React.useContext(TabsContext);
|
|
85
|
+
>(({ className, children, ...props }, ref) => {
|
|
86
|
+
const { variant, orientation, indicatorId } = React.useContext(TabsContext);
|
|
87
|
+
// Inspect data-state at render to know whether to render the indicator.
|
|
88
|
+
// Radix sets data-state="active" | "inactive" on the trigger via its primitive.
|
|
89
|
+
const triggerRef = React.useRef<HTMLButtonElement | null>(null);
|
|
90
|
+
const [isActive, setIsActive] = React.useState(false);
|
|
91
|
+
|
|
92
|
+
// Sync isActive from Radix's data-state. Tracking via mutation observer
|
|
93
|
+
// keeps the indicator in sync without needing controlled state from consumers.
|
|
94
|
+
React.useEffect(() => {
|
|
95
|
+
const node = triggerRef.current;
|
|
96
|
+
if (!node) return;
|
|
97
|
+
const sync = () => setIsActive(node.getAttribute("data-state") === "active");
|
|
98
|
+
sync();
|
|
99
|
+
const observer = new MutationObserver(sync);
|
|
100
|
+
observer.observe(node, { attributes: true, attributeFilter: ["data-state"] });
|
|
101
|
+
return () => observer.disconnect();
|
|
102
|
+
}, []);
|
|
103
|
+
|
|
104
|
+
const composedRef = React.useCallback(
|
|
105
|
+
(node: HTMLButtonElement | null) => {
|
|
106
|
+
triggerRef.current = node;
|
|
107
|
+
if (typeof ref === "function") ref(node);
|
|
108
|
+
else if (ref) (ref as React.MutableRefObject<HTMLButtonElement | null>).current = node;
|
|
109
|
+
},
|
|
110
|
+
[ref]
|
|
111
|
+
);
|
|
80
112
|
|
|
81
113
|
return (
|
|
82
114
|
<TabsPrimitive.Trigger
|
|
83
|
-
ref={
|
|
115
|
+
ref={composedRef}
|
|
84
116
|
data-slot="tabs-trigger"
|
|
85
117
|
className={cn(
|
|
86
|
-
"inline-flex items-center justify-center whitespace-nowrap text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
|
|
118
|
+
"relative inline-flex items-center justify-center whitespace-nowrap text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
|
|
87
119
|
variant === "pill" &&
|
|
88
|
-
"rounded-xl px-4 py-1.5 text-muted-foreground hover:text-foreground data-[state=active]:
|
|
120
|
+
"rounded-xl px-4 py-1.5 text-muted-foreground hover:text-foreground data-[state=active]:text-foreground",
|
|
89
121
|
variant === "underline" &&
|
|
90
122
|
orientation === "horizontal" &&
|
|
91
|
-
"
|
|
123
|
+
"h-11 rounded-none px-4 text-muted-foreground hover:text-foreground data-[state=active]:text-foreground",
|
|
92
124
|
variant === "underline" &&
|
|
93
125
|
orientation === "vertical" &&
|
|
94
|
-
"
|
|
126
|
+
"rounded-none px-4 py-2 -mr-px text-muted-foreground hover:text-foreground data-[state=active]:text-foreground justify-start",
|
|
95
127
|
className
|
|
96
128
|
)}
|
|
97
129
|
{...props}
|
|
98
|
-
|
|
130
|
+
>
|
|
131
|
+
{/* Active indicator: shared-layout element morphs between triggers */}
|
|
132
|
+
{isActive && (
|
|
133
|
+
<motion.span
|
|
134
|
+
aria-hidden
|
|
135
|
+
layoutId={indicatorId}
|
|
136
|
+
transition={archetype.indicatorMorph}
|
|
137
|
+
className={cn(
|
|
138
|
+
"absolute inset-0 -z-0 motion-reduce:hidden",
|
|
139
|
+
variant === "pill" && "rounded-xl bg-background shadow-sm",
|
|
140
|
+
variant === "underline" && orientation === "horizontal" && "border-b-2 border-primary",
|
|
141
|
+
variant === "underline" && orientation === "vertical" && "border-r-2 border-primary"
|
|
142
|
+
)}
|
|
143
|
+
/>
|
|
144
|
+
)}
|
|
145
|
+
{/* Static fallback for reduced-motion: keep the legacy active styling */}
|
|
146
|
+
{isActive && (
|
|
147
|
+
<span
|
|
148
|
+
aria-hidden
|
|
149
|
+
className={cn(
|
|
150
|
+
"absolute inset-0 -z-0 hidden motion-reduce:block",
|
|
151
|
+
variant === "pill" && "rounded-xl bg-background shadow-sm",
|
|
152
|
+
variant === "underline" && orientation === "horizontal" && "border-b-2 border-primary",
|
|
153
|
+
variant === "underline" && orientation === "vertical" && "border-r-2 border-primary"
|
|
154
|
+
)}
|
|
155
|
+
/>
|
|
156
|
+
)}
|
|
157
|
+
<span className="relative z-10 flex items-center gap-2">{children}</span>
|
|
158
|
+
</TabsPrimitive.Trigger>
|
|
99
159
|
);
|
|
100
160
|
});
|
|
101
161
|
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName;
|
|
@@ -103,16 +163,21 @@ TabsTrigger.displayName = TabsPrimitive.Trigger.displayName;
|
|
|
103
163
|
const TabsContent = React.forwardRef<
|
|
104
164
|
React.ElementRef<typeof TabsPrimitive.Content>,
|
|
105
165
|
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
|
|
106
|
-
>(({ className, ...props }, ref) => (
|
|
166
|
+
>(({ className, children, ...props }, ref) => (
|
|
107
167
|
<TabsPrimitive.Content
|
|
108
168
|
ref={ref}
|
|
109
169
|
data-slot="tabs-content"
|
|
110
170
|
className={cn(
|
|
111
171
|
"flex-1 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 rounded-md",
|
|
172
|
+
// v1.0 motion: content-swap on activation. CSS animation so this works
|
|
173
|
+
// even outside a Framer-Motion-wrapped layout. Disabled for reduced-motion.
|
|
174
|
+
"data-[state=active]:animate-in data-[state=active]:fade-in-0 data-[state=active]:slide-in-from-bottom-1 data-[state=active]:duration-[var(--duration-quick)] data-[state=active]:ease-[var(--ease-standard)] motion-reduce:data-[state=active]:animate-none",
|
|
112
175
|
className
|
|
113
176
|
)}
|
|
114
177
|
{...props}
|
|
115
|
-
|
|
178
|
+
>
|
|
179
|
+
{children}
|
|
180
|
+
</TabsPrimitive.Content>
|
|
116
181
|
));
|
|
117
182
|
TabsContent.displayName = TabsPrimitive.Content.displayName;
|
|
118
183
|
|
package/src/components/toast.tsx
CHANGED
|
@@ -24,7 +24,7 @@ const ToastViewport = React.forwardRef<
|
|
|
24
24
|
ToastViewport.displayName = ToastPrimitives.Viewport.displayName;
|
|
25
25
|
|
|
26
26
|
const toastVariants = cva(
|
|
27
|
-
"group pointer-events-auto relative flex w-full items-center justify-between space-x-4 overflow-hidden rounded-xl p-6 pr-8 shadow-2xl transition-
|
|
27
|
+
"group pointer-events-auto relative flex w-full items-center justify-between space-x-4 overflow-hidden rounded-xl p-6 pr-8 shadow-2xl transition-[transform,opacity] duration-[var(--duration-standard)] ease-[var(--ease-standard)] data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=open]:duration-[var(--duration-standard)] data-[state=closed]:duration-[var(--duration-exit-standard)] data-[state=open]:ease-[var(--ease-enter)] data-[state=closed]:ease-[var(--ease-exit)] data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full",
|
|
28
28
|
{
|
|
29
29
|
variants: {
|
|
30
30
|
variant: {
|
package/src/globals.css
CHANGED
|
@@ -89,18 +89,30 @@ h6 {
|
|
|
89
89
|
--shadow-alpha-xl: 0.18;
|
|
90
90
|
--shadow-alpha-2xl: 0.25;
|
|
91
91
|
|
|
92
|
-
/* Motion
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
--
|
|
100
|
-
--
|
|
101
|
-
--
|
|
102
|
-
--
|
|
103
|
-
--
|
|
92
|
+
/* ─── Motion System v1.0 ─────────────────────────────────────────────
|
|
93
|
+
Three layers of tokens. Feature code reads only `--duration-*` and
|
|
94
|
+
`--ease-*` semantic names; raw values live here.
|
|
95
|
+
|
|
96
|
+
JS twin: @bearmenu/ui/lib/motion (duration / ease / archetype). */
|
|
97
|
+
|
|
98
|
+
/* Durations */
|
|
99
|
+
--duration-none: 0ms; /* literal "no animation" */
|
|
100
|
+
--duration-instant: 120ms; /* tap feedback, icon swap */
|
|
101
|
+
--duration-quick: 180ms; /* chip toggle, tab indicator */
|
|
102
|
+
--duration-standard: 260ms; /* component entrance — workhorse */
|
|
103
|
+
--duration-deliberate: 360ms; /* drawer / sheet open */
|
|
104
|
+
--duration-narrative: 500ms; /* editorial hero (sparingly) */
|
|
105
|
+
--duration-exit-quick: 110ms; /* content exit inside a surface */
|
|
106
|
+
--duration-exit-standard: 210ms; /* surface / overlay exit */
|
|
107
|
+
|
|
108
|
+
/* Easings */
|
|
109
|
+
--ease-enter: cubic-bezier(0, 0, 0.2, 1);
|
|
110
|
+
--ease-standard: cubic-bezier(0.4, 0, 0.2, 1);
|
|
111
|
+
--ease-exit: cubic-bezier(0.55, 0, 1, 0.45);
|
|
112
|
+
/* CSS equivalents for the JS spring presets — for keyframe / transition use */
|
|
113
|
+
--ease-spring-snappy: cubic-bezier(0.34, 1.56, 0.64, 1);
|
|
114
|
+
--ease-spring-smooth: cubic-bezier(0.22, 1, 0.36, 1);
|
|
115
|
+
--ease-spring-soft: cubic-bezier(0.16, 1, 0.3, 1);
|
|
104
116
|
|
|
105
117
|
/* Z-index scale */
|
|
106
118
|
--z-base: 0;
|
|
@@ -246,8 +258,8 @@ h6 {
|
|
|
246
258
|
--shadow-2xl: 0 25px 50px -12px hsl(var(--shadow-color) / var(--shadow-alpha-2xl));
|
|
247
259
|
|
|
248
260
|
/* Default transition tokens — consumed by Tailwind's `transition-*` utilities */
|
|
249
|
-
--default-transition-duration: var(--duration-
|
|
250
|
-
--default-transition-timing-function: var(--ease-
|
|
261
|
+
--default-transition-duration: var(--duration-standard);
|
|
262
|
+
--default-transition-timing-function: var(--ease-standard);
|
|
251
263
|
|
|
252
264
|
--animate-highlight-ring: highlight-ring 5s ease-out forwards;
|
|
253
265
|
}
|
|
@@ -296,8 +308,8 @@ h6 {
|
|
|
296
308
|
|
|
297
309
|
.glass-hover {
|
|
298
310
|
transition:
|
|
299
|
-
background-color var(--duration-
|
|
300
|
-
border-color var(--duration-
|
|
311
|
+
background-color var(--duration-standard) var(--ease-standard),
|
|
312
|
+
border-color var(--duration-standard) var(--ease-standard);
|
|
301
313
|
}
|
|
302
314
|
|
|
303
315
|
.glass-hover:hover {
|
|
@@ -407,11 +419,11 @@ h6 {
|
|
|
407
419
|
}
|
|
408
420
|
|
|
409
421
|
.animate-collapsible-down {
|
|
410
|
-
animation: collapsible-down var(--duration-
|
|
422
|
+
animation: collapsible-down var(--duration-deliberate) var(--ease-enter);
|
|
411
423
|
}
|
|
412
424
|
|
|
413
425
|
.animate-collapsible-up {
|
|
414
|
-
animation: collapsible-up var(--duration-
|
|
426
|
+
animation: collapsible-up var(--duration-exit-standard) var(--ease-exit);
|
|
415
427
|
}
|
|
416
428
|
|
|
417
429
|
/* Shimmer — referenced by Skeleton (was undefined → animation was dead) */
|
|
@@ -421,13 +433,59 @@ h6 {
|
|
|
421
433
|
}
|
|
422
434
|
}
|
|
423
435
|
|
|
436
|
+
/* Live-pulse — referenced by Badge `live` variant, EventBanner live indicator,
|
|
437
|
+
and any "this is happening right now" surface. Subtle to avoid distraction. */
|
|
438
|
+
@keyframes live-pulse {
|
|
439
|
+
0%, 100% {
|
|
440
|
+
transform: scale(1);
|
|
441
|
+
opacity: 1;
|
|
442
|
+
}
|
|
443
|
+
50% {
|
|
444
|
+
transform: scale(1.08);
|
|
445
|
+
opacity: 0.7;
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
.motion-live-pulse {
|
|
450
|
+
animation: live-pulse 1.8s ease-in-out infinite;
|
|
451
|
+
transform-origin: center;
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
/* Image-enter — gallery / hero photographs. Slight scale-down + fade so images
|
|
455
|
+
read as "arriving" rather than popping. Standalone CSS so consumers don't
|
|
456
|
+
need framer-motion on every photo. */
|
|
457
|
+
@keyframes image-enter {
|
|
458
|
+
from {
|
|
459
|
+
opacity: 0;
|
|
460
|
+
transform: scale(1.04);
|
|
461
|
+
}
|
|
462
|
+
to {
|
|
463
|
+
opacity: 1;
|
|
464
|
+
transform: scale(1);
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
.motion-image-enter {
|
|
469
|
+
animation: image-enter var(--duration-standard) var(--ease-standard) both;
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
/* Reduced-motion: instant — no scale, no fade. */
|
|
473
|
+
@media (prefers-reduced-motion: reduce) {
|
|
474
|
+
.motion-image-enter {
|
|
475
|
+
animation: none;
|
|
476
|
+
}
|
|
477
|
+
.motion-live-pulse {
|
|
478
|
+
animation: none;
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
|
|
424
482
|
/* Swiper pagination styling for experience cards */
|
|
425
483
|
.swiper-pagination-bullet {
|
|
426
484
|
background: color-mix(in oklch, var(--foreground) 30%, transparent) !important;
|
|
427
485
|
opacity: 1 !important;
|
|
428
486
|
width: 6px !important;
|
|
429
487
|
height: 6px !important;
|
|
430
|
-
transition:
|
|
488
|
+
transition: width var(--duration-standard) var(--ease-standard), background var(--duration-standard) var(--ease-standard) !important;
|
|
431
489
|
}
|
|
432
490
|
|
|
433
491
|
.swiper-pagination-bullet-active {
|