@bearmenu/ui 0.8.24 → 0.8.26
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-5LTILG3L.js → chunk-5HHRVJOT.js} +14 -1
- package/dist/{chunk-SDWN6L4O.js → chunk-AHHDC7ML.js} +1 -1
- package/dist/{chunk-I4RUOAU7.js → chunk-AHL45DD2.js} +11 -38
- package/dist/{chunk-TIQNM4XD.js → chunk-F3XK34DV.js} +25 -1
- package/dist/{chunk-5XREWOBB.js → chunk-IEIJP73R.js} +14 -1
- package/dist/chunk-L3CDXVEP.js +56 -0
- package/dist/{chunk-BKKQ7GCZ.js → chunk-QG6I2ILT.js} +1 -1
- package/dist/{chunk-CZK2MRS2.js → chunk-VEFKZIK6.js} +13 -3
- package/dist/components/alert-dialog.js +1 -1
- package/dist/components/badge.d.ts +1 -1
- package/dist/components/badge.js +1 -1
- package/dist/components/banner.d.ts +36 -0
- package/dist/components/banner.js +29 -0
- package/dist/components/booking-row.d.ts +60 -0
- package/dist/components/booking-row.js +103 -0
- package/dist/components/booking-summary-card.d.ts +38 -0
- package/dist/components/booking-summary-card.js +67 -0
- package/dist/components/button.d.ts +2 -2
- package/dist/components/button.js +1 -1
- package/dist/components/calendar.d.ts +17 -3
- package/dist/components/calendar.js +100 -12
- package/dist/components/carousel.js +1 -1
- package/dist/components/checkbox.d.ts +9 -2
- package/dist/components/checkbox.js +1 -1
- package/dist/components/coach-marks.js +1 -1
- package/dist/components/combobox.js +1 -1
- package/dist/components/concept-convergence.js +2 -2
- package/dist/components/currency-input.js +1 -1
- package/dist/components/date-input.js +1 -1
- package/dist/components/drift-rail.d.ts +2 -29
- package/dist/components/drift-rail.js +1 -3
- package/dist/components/empty-state.js +1 -1
- package/dist/components/event-card.js +1 -1
- package/dist/components/event-compact-row.js +1 -1
- package/dist/components/event-detail-panel.js +2 -2
- package/dist/components/event-index-row.js +1 -1
- package/dist/components/event-list-row-wide.js +3 -3
- package/dist/components/event-list-row.js +2 -2
- package/dist/components/field.d.ts +15 -1
- package/dist/components/field.js +62 -21
- package/dist/components/filter-category-row.js +1 -1
- package/dist/components/hold-countdown.d.ts +43 -0
- package/dist/components/hold-countdown.js +90 -0
- package/dist/components/input.d.ts +1 -0
- package/dist/components/input.js +1 -1
- package/dist/components/link-button.js +1 -1
- package/dist/components/menu-family-block.js +1 -1
- package/dist/components/menu-index-list.js +1 -1
- package/dist/components/menu-item.js +3 -3
- package/dist/components/motion-icon-set.d.ts +2 -1
- package/dist/components/motion-icon-set.js +27 -1
- package/dist/components/multi-select-combobox.js +3 -3
- package/dist/components/multi-select-menu.js +1 -1
- package/dist/components/pagination.js +1 -1
- package/dist/components/party-counter.d.ts +36 -0
- package/dist/components/party-counter.js +61 -0
- package/dist/components/place-card.js +1 -1
- package/dist/components/place-details-mobile.js +1 -1
- package/dist/components/poster-tape.d.ts +2 -15
- package/dist/components/poster-tape.js +3 -12
- package/dist/components/searchable-select.js +1 -1
- package/dist/components/signature-chip.js +2 -2
- package/dist/components/sliding-panels.js +1 -1
- package/dist/components/textarea.d.ts +10 -3
- package/dist/components/textarea.js +21 -6
- package/dist/components/time-slot.d.ts +73 -0
- package/dist/components/time-slot.js +106 -0
- package/dist/components/toggle-group.js +15 -2
- package/dist/components/toggle.d.ts +1 -1
- package/dist/components/toggle.js +1 -1
- package/dist/components/top-ranking-entry.js +3 -3
- package/dist/components/weekday-chip.d.ts +33 -0
- package/dist/components/weekday-chip.js +48 -0
- package/package.json +207 -23
- package/src/components/badge.stories.tsx +17 -0
- package/src/components/badge.tsx +15 -0
- package/src/components/banner.stories.tsx +36 -0
- package/src/components/banner.tsx +58 -0
- package/src/components/booking-details-primitives.test.tsx +75 -0
- package/src/components/booking-primitives.test.tsx +180 -0
- package/src/components/booking-row.stories.tsx +66 -0
- package/src/components/booking-row.tsx +168 -0
- package/src/components/booking-summary-card.stories.tsx +41 -0
- package/src/components/booking-summary-card.tsx +109 -0
- package/src/components/button.stories.tsx +23 -0
- package/src/components/button.tsx +13 -0
- package/src/components/calendar.stories.tsx +27 -0
- package/src/components/calendar.tsx +110 -3
- package/src/components/checkbox.tsx +49 -23
- package/src/components/drift-rail.stories.tsx +1 -29
- package/src/components/drift-rail.tsx +4 -71
- package/src/components/field.stories.tsx +51 -1
- package/src/components/field.tsx +68 -9
- package/src/components/hold-countdown.stories.tsx +68 -0
- package/src/components/hold-countdown.tsx +140 -0
- package/src/components/input.tsx +13 -2
- package/src/components/motion-icon-set.stories.tsx +2 -0
- package/src/components/motion-icon-set.tsx +42 -0
- package/src/components/party-counter.stories.tsx +57 -0
- package/src/components/party-counter.tsx +93 -0
- package/src/components/poster-tape.stories.tsx +0 -29
- package/src/components/poster-tape.tsx +5 -21
- package/src/components/textarea.tsx +25 -7
- package/src/components/time-slot.stories.tsx +85 -0
- package/src/components/time-slot.tsx +186 -0
- package/src/components/toggle-group.stories.tsx +27 -0
- package/src/components/toggle-group.tsx +14 -1
- package/src/components/toggle.tsx +24 -0
- package/src/components/weekday-chip.stories.tsx +54 -0
- package/src/components/weekday-chip.tsx +75 -0
- package/dist/chunk-NMEVJT5Q.js +0 -26
- package/src/components/drift-rail.test.tsx +0 -125
- package/src/components/poster-tape.test.tsx +0 -42
package/src/components/field.tsx
CHANGED
|
@@ -5,12 +5,21 @@ import { Slot } from "@radix-ui/react-slot";
|
|
|
5
5
|
import { cn } from "../lib/utils";
|
|
6
6
|
import { Label } from "./label";
|
|
7
7
|
|
|
8
|
+
/**
|
|
9
|
+
* `booking` — the booking flow's fields (booking flow.html, `.fld`): the
|
|
10
|
+
* label a 10.5px uppercase caption in the muted ink that stays muted when
|
|
11
|
+
* the field is invalid (the hairline and the line under it carry the error),
|
|
12
|
+
* the error at 11.5px, 5px between label, control and error.
|
|
13
|
+
*/
|
|
14
|
+
type FieldAppearance = "default" | "booking";
|
|
15
|
+
|
|
8
16
|
const FieldContext = React.createContext<{
|
|
9
17
|
id: string;
|
|
10
18
|
descriptionId: string;
|
|
11
19
|
errorId: string;
|
|
12
20
|
hasError: boolean;
|
|
13
|
-
|
|
21
|
+
appearance: FieldAppearance;
|
|
22
|
+
}>({ id: "", descriptionId: "", errorId: "", hasError: false, appearance: "default" });
|
|
14
23
|
|
|
15
24
|
type FieldProps = React.HTMLAttributes<HTMLDivElement> & {
|
|
16
25
|
/** Field id. Auto-generated if omitted. */
|
|
@@ -19,23 +28,38 @@ type FieldProps = React.HTMLAttributes<HTMLDivElement> & {
|
|
|
19
28
|
invalid?: boolean;
|
|
20
29
|
/** Lay out content horizontally instead of stacked. */
|
|
21
30
|
orientation?: "vertical" | "horizontal";
|
|
31
|
+
appearance?: FieldAppearance;
|
|
22
32
|
};
|
|
23
33
|
|
|
24
34
|
const Field = React.forwardRef<HTMLDivElement, FieldProps>(
|
|
25
|
-
(
|
|
35
|
+
(
|
|
36
|
+
{
|
|
37
|
+
id: providedId,
|
|
38
|
+
invalid,
|
|
39
|
+
orientation = "vertical",
|
|
40
|
+
appearance = "default",
|
|
41
|
+
className,
|
|
42
|
+
children,
|
|
43
|
+
...props
|
|
44
|
+
},
|
|
45
|
+
ref
|
|
46
|
+
) => {
|
|
26
47
|
const generatedId = React.useId();
|
|
27
48
|
const id = providedId ?? generatedId;
|
|
28
49
|
const descriptionId = `${id}-description`;
|
|
29
50
|
const errorId = `${id}-error`;
|
|
30
51
|
|
|
31
52
|
return (
|
|
32
|
-
<FieldContext.Provider
|
|
53
|
+
<FieldContext.Provider
|
|
54
|
+
value={{ id, descriptionId, errorId, hasError: !!invalid, appearance }}
|
|
55
|
+
>
|
|
33
56
|
<div
|
|
34
57
|
ref={ref}
|
|
35
58
|
data-slot="field"
|
|
36
59
|
data-invalid={invalid || undefined}
|
|
37
60
|
className={cn(
|
|
38
|
-
"flex
|
|
61
|
+
"flex",
|
|
62
|
+
appearance === "booking" ? "gap-[5px]" : "gap-2",
|
|
39
63
|
orientation === "vertical" ? "flex-col" : "flex-row items-center",
|
|
40
64
|
className
|
|
41
65
|
)}
|
|
@@ -53,19 +77,38 @@ const FieldLabel = React.forwardRef<
|
|
|
53
77
|
HTMLLabelElement,
|
|
54
78
|
React.ComponentPropsWithoutRef<typeof Label>
|
|
55
79
|
>(({ className, ...props }, ref) => {
|
|
56
|
-
const { id, hasError } = React.useContext(FieldContext);
|
|
80
|
+
const { id, hasError, appearance } = React.useContext(FieldContext);
|
|
57
81
|
return (
|
|
58
82
|
<Label
|
|
59
83
|
ref={ref}
|
|
60
84
|
data-slot="field-label"
|
|
61
85
|
htmlFor={id}
|
|
62
|
-
className={cn(
|
|
86
|
+
className={cn(
|
|
87
|
+
appearance === "booking"
|
|
88
|
+
? "text-[10.5px] leading-[1.2] font-semibold tracking-[0.04em] text-muted-foreground uppercase"
|
|
89
|
+
: hasError && "text-destructive",
|
|
90
|
+
className
|
|
91
|
+
)}
|
|
63
92
|
{...props}
|
|
64
93
|
/>
|
|
65
94
|
);
|
|
66
95
|
});
|
|
67
96
|
FieldLabel.displayName = "FieldLabel";
|
|
68
97
|
|
|
98
|
+
/**
|
|
99
|
+
* "(optional)" after a label's text — sentence case, regular weight, a
|
|
100
|
+
* shade quieter than the label (booking flow.html's EMAIL (optional)).
|
|
101
|
+
*/
|
|
102
|
+
function FieldOptional({ className, ...props }: React.HTMLAttributes<HTMLSpanElement>) {
|
|
103
|
+
return (
|
|
104
|
+
<span
|
|
105
|
+
data-slot="field-optional"
|
|
106
|
+
className={cn("font-normal normal-case opacity-70", className)}
|
|
107
|
+
{...props}
|
|
108
|
+
/>
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
|
|
69
112
|
const FieldControl = React.forwardRef<HTMLElement, React.ComponentPropsWithoutRef<typeof Slot>>(
|
|
70
113
|
({ ...props }, ref) => {
|
|
71
114
|
const { id, descriptionId, errorId, hasError } = React.useContext(FieldContext);
|
|
@@ -101,7 +144,7 @@ FieldDescription.displayName = "FieldDescription";
|
|
|
101
144
|
|
|
102
145
|
const FieldError = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLParagraphElement>>(
|
|
103
146
|
({ className, children, ...props }, ref) => {
|
|
104
|
-
const { errorId, hasError } = React.useContext(FieldContext);
|
|
147
|
+
const { errorId, hasError, appearance } = React.useContext(FieldContext);
|
|
105
148
|
if (!hasError || !children) return null;
|
|
106
149
|
return (
|
|
107
150
|
<p
|
|
@@ -109,7 +152,14 @@ const FieldError = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<H
|
|
|
109
152
|
id={errorId}
|
|
110
153
|
role="alert"
|
|
111
154
|
data-slot="field-error"
|
|
112
|
-
className={cn(
|
|
155
|
+
className={cn(
|
|
156
|
+
appearance === "booking"
|
|
157
|
+
? // The design's error ink: the solid token is too bright for AA
|
|
158
|
+
// at 11.5px on white; dark mode's lifted token already clears it.
|
|
159
|
+
"m-0 text-[11.5px] leading-[1.2] text-[oklch(0.52_0.16_25)] dark:text-destructive"
|
|
160
|
+
: "text-sm text-destructive",
|
|
161
|
+
className
|
|
162
|
+
)}
|
|
113
163
|
{...props}
|
|
114
164
|
>
|
|
115
165
|
{children}
|
|
@@ -143,4 +193,13 @@ const FieldLegend = React.forwardRef<HTMLLegendElement, React.HTMLAttributes<HTM
|
|
|
143
193
|
);
|
|
144
194
|
FieldLegend.displayName = "FieldLegend";
|
|
145
195
|
|
|
146
|
-
export {
|
|
196
|
+
export {
|
|
197
|
+
Field,
|
|
198
|
+
FieldLabel,
|
|
199
|
+
FieldOptional,
|
|
200
|
+
FieldControl,
|
|
201
|
+
FieldDescription,
|
|
202
|
+
FieldError,
|
|
203
|
+
FieldSet,
|
|
204
|
+
FieldLegend,
|
|
205
|
+
};
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/react";
|
|
2
|
+
import { Button } from "./button";
|
|
3
|
+
import { HoldCountdown, type HoldCountdownLabels } from "./hold-countdown";
|
|
4
|
+
|
|
5
|
+
// A fixed clock so the story always reads 09:41 on first paint.
|
|
6
|
+
const NOW = Date.UTC(2026, 7, 14, 16, 0, 0);
|
|
7
|
+
const expiresAt = NOW + (9 * 60 + 41) * 1000;
|
|
8
|
+
|
|
9
|
+
const stackedLabels: HoldCountdownLabels = {
|
|
10
|
+
held: "Held for you",
|
|
11
|
+
explanation: "then it goes to the next person",
|
|
12
|
+
remaining: (m, s) => `${m} minutes ${s} seconds left`,
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
const inlineLabels: HoldCountdownLabels = {
|
|
16
|
+
...stackedLabels,
|
|
17
|
+
explanation: "After the countdown the table goes to the next person on the list.",
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
const meta: Meta<typeof HoldCountdown> = {
|
|
21
|
+
title: "Booking/HoldCountdown",
|
|
22
|
+
component: HoldCountdown,
|
|
23
|
+
tags: ["autodocs"],
|
|
24
|
+
args: { expiresAt, now: () => NOW },
|
|
25
|
+
parameters: {
|
|
26
|
+
docs: {
|
|
27
|
+
description: {
|
|
28
|
+
component:
|
|
29
|
+
"How long a table offered from the waitlist stays held (\"A table opened up\"). Stacked on mobile; inline with its action on desktop. Ticks once a second and calls onExpire once, at zero.",
|
|
30
|
+
},
|
|
31
|
+
},
|
|
32
|
+
},
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
export default meta;
|
|
36
|
+
type Story = StoryObj<typeof HoldCountdown>;
|
|
37
|
+
|
|
38
|
+
/** B5a mobile. */
|
|
39
|
+
export const Stacked: Story = {
|
|
40
|
+
args: { labels: stackedLabels, layout: "stacked" },
|
|
41
|
+
decorators: [
|
|
42
|
+
(Story) => (
|
|
43
|
+
<div className="w-[343px]">
|
|
44
|
+
<Story />
|
|
45
|
+
</div>
|
|
46
|
+
),
|
|
47
|
+
],
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
/** B5a desktop. */
|
|
51
|
+
export const Inline: Story = {
|
|
52
|
+
args: {
|
|
53
|
+
labels: inlineLabels,
|
|
54
|
+
layout: "inline",
|
|
55
|
+
action: (
|
|
56
|
+
<Button variant="cta" size="cta-sm">
|
|
57
|
+
Confirm the table
|
|
58
|
+
</Button>
|
|
59
|
+
),
|
|
60
|
+
},
|
|
61
|
+
decorators: [
|
|
62
|
+
(Story) => (
|
|
63
|
+
<div className="w-[520px]">
|
|
64
|
+
<Story />
|
|
65
|
+
</div>
|
|
66
|
+
),
|
|
67
|
+
],
|
|
68
|
+
};
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import * as React from "react";
|
|
4
|
+
import { cn } from "../lib/utils";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* HoldCountdown — "HELD FOR YOU 09:41": how long a table offered from the
|
|
8
|
+
* waitlist stays reserved for this guest (the booking flow's "A table opened
|
|
9
|
+
* up").
|
|
10
|
+
*
|
|
11
|
+
* Measured from the booking design: a card with a warning-colour hairline,
|
|
12
|
+
* 14px radius, 15px inset.
|
|
13
|
+
* - `stacked` (mobile): centred. The label at 10.5px uppercase with 0.06em
|
|
14
|
+
* tracking, muted; the remaining time in mono at 34px bold; a muted
|
|
15
|
+
* 11.5px line under it ("then it goes to the next person").
|
|
16
|
+
* - `inline` (desktop): one row, 18px gaps. The label over the time at
|
|
17
|
+
* 30px, then the explanation at 12.5px muted taking the free width, then
|
|
18
|
+
* the action.
|
|
19
|
+
*
|
|
20
|
+
* Counts down from `expiresAt` once a second and calls `onExpire` once, at
|
|
21
|
+
* zero. It never shows a negative time.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
export type HoldCountdownLabels = {
|
|
25
|
+
/** "Held for you" */
|
|
26
|
+
held: string;
|
|
27
|
+
/** Stacked: "then it goes to the next person". Inline: "After the countdown the table goes to the next person on the list." */
|
|
28
|
+
explanation: string;
|
|
29
|
+
/** Spoken form for assistive tech — "9 minutes 41 seconds left". */
|
|
30
|
+
remaining: (minutes: number, seconds: number) => string;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
export type HoldCountdownProps = {
|
|
34
|
+
/** When the hold ends — ISO string or epoch milliseconds. */
|
|
35
|
+
expiresAt: string | number;
|
|
36
|
+
labels: HoldCountdownLabels;
|
|
37
|
+
layout?: "stacked" | "inline";
|
|
38
|
+
/** The inline layout's action, typically a `Button variant="cta" size="cta-sm"`. */
|
|
39
|
+
action?: React.ReactNode;
|
|
40
|
+
onExpire?: () => void;
|
|
41
|
+
/** Clock override for stories and tests. */
|
|
42
|
+
now?: () => number;
|
|
43
|
+
className?: string;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* The design's hold amber (`--warnc`, oklch 0.62 0.15 70), deeper than the
|
|
48
|
+
* `--warning` token (0.72): on white the token reads as a yellow alert, the
|
|
49
|
+
* design's as a timer. Dark mode takes the lifted token, which already has
|
|
50
|
+
* the contrast against the dark card.
|
|
51
|
+
*/
|
|
52
|
+
const HOLD_BORDER = "border-[oklch(0.62_0.15_70)] dark:border-warning";
|
|
53
|
+
|
|
54
|
+
function remainingSeconds(expiresAt: string | number, now: number): number {
|
|
55
|
+
const end = typeof expiresAt === "number" ? expiresAt : Date.parse(expiresAt);
|
|
56
|
+
return Math.max(0, Math.ceil((end - now) / 1000));
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function HoldCountdown({
|
|
60
|
+
expiresAt,
|
|
61
|
+
labels,
|
|
62
|
+
layout = "stacked",
|
|
63
|
+
action,
|
|
64
|
+
onExpire,
|
|
65
|
+
now = Date.now,
|
|
66
|
+
className,
|
|
67
|
+
}: HoldCountdownProps) {
|
|
68
|
+
const [left, setLeft] = React.useState(() => remainingSeconds(expiresAt, now()));
|
|
69
|
+
const expired = React.useRef(false);
|
|
70
|
+
const onExpireRef = React.useRef(onExpire);
|
|
71
|
+
onExpireRef.current = onExpire;
|
|
72
|
+
|
|
73
|
+
React.useEffect(() => {
|
|
74
|
+
expired.current = false;
|
|
75
|
+
const tick = () => {
|
|
76
|
+
const next = remainingSeconds(expiresAt, now());
|
|
77
|
+
setLeft(next);
|
|
78
|
+
if (next === 0 && !expired.current) {
|
|
79
|
+
expired.current = true;
|
|
80
|
+
onExpireRef.current?.();
|
|
81
|
+
}
|
|
82
|
+
};
|
|
83
|
+
tick();
|
|
84
|
+
const id = window.setInterval(tick, 1000);
|
|
85
|
+
return () => window.clearInterval(id);
|
|
86
|
+
}, [expiresAt, now]);
|
|
87
|
+
|
|
88
|
+
const minutes = Math.floor(left / 60);
|
|
89
|
+
const seconds = left % 60;
|
|
90
|
+
const display = `${String(minutes).padStart(2, "0")}:${String(seconds).padStart(2, "0")}`;
|
|
91
|
+
|
|
92
|
+
const label = (
|
|
93
|
+
<div className="text-[10.5px] leading-[13px] tracking-[0.06em] text-muted-foreground uppercase">
|
|
94
|
+
{labels.held}
|
|
95
|
+
</div>
|
|
96
|
+
);
|
|
97
|
+
const time = (
|
|
98
|
+
<div
|
|
99
|
+
role="timer"
|
|
100
|
+
aria-label={labels.remaining(minutes, seconds)}
|
|
101
|
+
className={cn(
|
|
102
|
+
"font-mono font-bold tabular-nums text-foreground",
|
|
103
|
+
layout === "stacked" ? "mt-[5px] text-[34px] leading-[40px]" : "text-[30px] leading-[35px]"
|
|
104
|
+
)}
|
|
105
|
+
>
|
|
106
|
+
{display}
|
|
107
|
+
</div>
|
|
108
|
+
);
|
|
109
|
+
|
|
110
|
+
if (layout === "inline") {
|
|
111
|
+
return (
|
|
112
|
+
<div
|
|
113
|
+
data-slot="hold-countdown"
|
|
114
|
+
className={cn(
|
|
115
|
+
"flex items-center gap-[18px] rounded-[14px] border bg-card p-[15px] text-left",
|
|
116
|
+
HOLD_BORDER,
|
|
117
|
+
className
|
|
118
|
+
)}
|
|
119
|
+
>
|
|
120
|
+
<div className="shrink-0">
|
|
121
|
+
{label}
|
|
122
|
+
{time}
|
|
123
|
+
</div>
|
|
124
|
+
<p className="flex-1 text-[12.5px] leading-[15px] text-muted-foreground">{labels.explanation}</p>
|
|
125
|
+
{action}
|
|
126
|
+
</div>
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return (
|
|
131
|
+
<div
|
|
132
|
+
data-slot="hold-countdown"
|
|
133
|
+
className={cn("rounded-[14px] border bg-card p-[15px] text-center", HOLD_BORDER, className)}
|
|
134
|
+
>
|
|
135
|
+
{label}
|
|
136
|
+
{time}
|
|
137
|
+
<p className="mt-1 text-[11.5px] leading-[14px] text-muted-foreground">{labels.explanation}</p>
|
|
138
|
+
</div>
|
|
139
|
+
);
|
|
140
|
+
}
|
package/src/components/input.tsx
CHANGED
|
@@ -15,10 +15,21 @@ const inputVariants = cva(
|
|
|
15
15
|
rounded: "rounded-xl focus-visible:ring-ring",
|
|
16
16
|
pill: "rounded-full bg-background shadow-[var(--shadow-card)] focus-visible:ring-accent/30",
|
|
17
17
|
},
|
|
18
|
+
/**
|
|
19
|
+
* The booking flow's field (booking flow.html, `.in`): 46px on the card
|
|
20
|
+
* ground rather than the input wash, an 11px radius, 13px inset, 14px
|
|
21
|
+
* type. Last, so it wins over the size and shape it restates.
|
|
22
|
+
*/
|
|
23
|
+
appearance: {
|
|
24
|
+
default: "",
|
|
25
|
+
booking:
|
|
26
|
+
"h-[46px] rounded-[11px] bg-card px-[13px] py-0 text-[14px] leading-[1.2] focus-visible:bg-card focus-visible:ring-ring aria-invalid:border-[oklch(0.52_0.16_25)] dark:aria-invalid:border-destructive",
|
|
27
|
+
},
|
|
18
28
|
},
|
|
19
29
|
defaultVariants: {
|
|
20
30
|
size: "default",
|
|
21
31
|
shape: "rounded",
|
|
32
|
+
appearance: "default",
|
|
22
33
|
},
|
|
23
34
|
}
|
|
24
35
|
);
|
|
@@ -28,12 +39,12 @@ export interface InputProps
|
|
|
28
39
|
VariantProps<typeof inputVariants> {}
|
|
29
40
|
|
|
30
41
|
const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
|
31
|
-
({ className, type, size, shape, ...props }, ref) => {
|
|
42
|
+
({ className, type, size, shape, appearance, ...props }, ref) => {
|
|
32
43
|
return (
|
|
33
44
|
<input
|
|
34
45
|
type={type}
|
|
35
46
|
data-slot="input"
|
|
36
|
-
className={cn(inputVariants({ size, shape }), className)}
|
|
47
|
+
className={cn(inputVariants({ size, shape, appearance }), className)}
|
|
37
48
|
ref={ref}
|
|
38
49
|
{...props}
|
|
39
50
|
/>
|
|
@@ -4,6 +4,7 @@ import type { Meta, StoryObj } from "@storybook/react";
|
|
|
4
4
|
import {
|
|
5
5
|
MotionHouse,
|
|
6
6
|
MotionUtensils,
|
|
7
|
+
MotionCompass,
|
|
7
8
|
MotionMapPin,
|
|
8
9
|
MotionCalendar,
|
|
9
10
|
MotionLayoutGrid,
|
|
@@ -62,6 +63,7 @@ type Glyph = { name: string; Icon: React.ComponentType<MotionIconProps>; gesture
|
|
|
62
63
|
const OBJECTS: Glyph[] = [
|
|
63
64
|
{ name: "House", Icon: MotionHouse, gesture: "roof jumps, walls take the impact" },
|
|
64
65
|
{ name: "Utensils", Icon: MotionUtensils, gesture: "uncrosses into a place setting · holds" },
|
|
66
|
+
{ name: "Compass", Icon: MotionCompass, gesture: "needle spins a turn, overshoots, hunts, settles · fills held" },
|
|
65
67
|
{ name: "MapPin", Icon: MotionMapPin, gesture: "jumps, fills on the descent · holds" },
|
|
66
68
|
{ name: "Calendar", Icon: MotionCalendar, gesture: "rolls four pages" },
|
|
67
69
|
{ name: "LayoutGrid", Icon: MotionLayoutGrid, gesture: "tiles shuffle one slot round the board" },
|
|
@@ -149,6 +149,48 @@ export function MotionUtensils(props: MotionIconProps) {
|
|
|
149
149
|
);
|
|
150
150
|
}
|
|
151
151
|
|
|
152
|
+
// ─── Compass — the needle spins hard, overshoots, wobbles, settles (cycle content)
|
|
153
|
+
|
|
154
|
+
const COMPASS_TIMES = [0, 0.14, 0.58, 0.76, 0.9, 1];
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* The dial holds still; the needle is the content that moves through it.
|
|
158
|
+
* It pulls back against the spin first (anticipation), goes round once and
|
|
159
|
+
* 20° past its bearing, swings back short of it, and settles — the way a
|
|
160
|
+
* real needle hunts for north. One full turn is what changes the silhouette
|
|
161
|
+
* at 22px: the diamond reads reversed at the half-way frame. Pivot at the
|
|
162
|
+
* dial's centre.
|
|
163
|
+
*
|
|
164
|
+
* The held poses sit at 360°, not 0°: the performance ends a full turn on
|
|
165
|
+
* from where it started, and a held pose at 0° would make the settle spin
|
|
166
|
+
* the needle backwards. 360° and 0° draw the same needle.
|
|
167
|
+
*/
|
|
168
|
+
const compassNeedle: Variants = {
|
|
169
|
+
rest: { rotate: 360, fillOpacity: 0, transition: returnTransition },
|
|
170
|
+
active: { rotate: 360, fillOpacity: 1, transition: returnTransition },
|
|
171
|
+
play: {
|
|
172
|
+
rotate: [0, -24, 380, 352, 364, 360],
|
|
173
|
+
// The fill lands with the needle: it takes as the swing dies.
|
|
174
|
+
fillOpacity: [0, 0, 0, 0.6, 1, 1],
|
|
175
|
+
transition: beats(COMPASS_TIMES, 0.7),
|
|
176
|
+
},
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
export function MotionCompass(props: MotionIconProps) {
|
|
180
|
+
return (
|
|
181
|
+
<MotionIcon {...props}>
|
|
182
|
+
<circle cx="12" cy="12" r="10" />
|
|
183
|
+
<motion.path
|
|
184
|
+
variants={compassNeedle}
|
|
185
|
+
style={pivot(12, 12)}
|
|
186
|
+
fill="currentColor"
|
|
187
|
+
fillOpacity={0}
|
|
188
|
+
d="m16.24 7.76-1.804 5.411a2 2 0 0 1-1.265 1.265L7.76 16.24l1.804-5.411a2 2 0 0 1 1.265-1.265z"
|
|
189
|
+
/>
|
|
190
|
+
</MotionIcon>
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
|
|
152
194
|
// ─── Map pin — jumps, and fills on the way down (fall and land) ───────────────
|
|
153
195
|
|
|
154
196
|
const PIN_TIMES = [0, 0.16, 0.44, 0.72, 1];
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import type { Meta, StoryObj } from "@storybook/react";
|
|
3
|
+
import { PartyCounter, type PartyCounterLabels } from "./party-counter";
|
|
4
|
+
|
|
5
|
+
const labels: PartyCounterLabels = {
|
|
6
|
+
decrease: "Fewer people",
|
|
7
|
+
increase: "More people",
|
|
8
|
+
unit: (count) => (count === 1 ? "person" : "people"),
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
const meta: Meta<typeof PartyCounter> = {
|
|
12
|
+
title: "Booking/PartyCounter",
|
|
13
|
+
component: PartyCounter,
|
|
14
|
+
tags: ["autodocs"],
|
|
15
|
+
args: { labels, min: 1, max: 8 },
|
|
16
|
+
decorators: [
|
|
17
|
+
(Story) => (
|
|
18
|
+
<div className="w-[335px]">
|
|
19
|
+
<Story />
|
|
20
|
+
</div>
|
|
21
|
+
),
|
|
22
|
+
],
|
|
23
|
+
parameters: {
|
|
24
|
+
docs: {
|
|
25
|
+
description: {
|
|
26
|
+
component:
|
|
27
|
+
"How many people, in \"When are you coming?\" and \"Change party size\". The plus is filled: it is the direction the screen is asking about. At an end the button disables rather than hides, so the value never moves.",
|
|
28
|
+
},
|
|
29
|
+
},
|
|
30
|
+
},
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
export default meta;
|
|
34
|
+
type Story = StoryObj<typeof PartyCounter>;
|
|
35
|
+
|
|
36
|
+
function Controlled(args: React.ComponentProps<typeof PartyCounter>) {
|
|
37
|
+
const [value, setValue] = React.useState(args.value);
|
|
38
|
+
return <PartyCounter {...args} value={value} onValueChange={setValue} />;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** B1: "2 people". */
|
|
42
|
+
export const Default: Story = {
|
|
43
|
+
args: { value: 2 },
|
|
44
|
+
render: (args) => <Controlled {...args} />,
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
/** At the minimum the minus disables; the value stays put. */
|
|
48
|
+
export const AtMinimum: Story = {
|
|
49
|
+
args: { value: 1 },
|
|
50
|
+
render: (args) => <Controlled {...args} />,
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
/** B9: "4 people". */
|
|
54
|
+
export const FourPeople: Story = {
|
|
55
|
+
args: { value: 4 },
|
|
56
|
+
render: (args) => <Controlled {...args} />,
|
|
57
|
+
};
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import * as React from "react";
|
|
4
|
+
import { Minus, Plus } from "lucide-react";
|
|
5
|
+
import { cn } from "../lib/utils";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* PartyCounter — how many people, in the booking flow's "When are you
|
|
9
|
+
* coming?" and "Change party size" screens.
|
|
10
|
+
*
|
|
11
|
+
* Measured from the booking design (`.stepper`): a 52px bordered field on the
|
|
12
|
+
* card ground, 12px radius, 8px side inset. A round 36px minus on the muted
|
|
13
|
+
* surface at the left, a round 36px plus filled with `--gradient-primary` at
|
|
14
|
+
* the right — the plus is the direction the screen is asking about — and the
|
|
15
|
+
* count between them at 19px bold with its unit at 12px muted.
|
|
16
|
+
*
|
|
17
|
+
* At an end the button is disabled, not hidden, so the value never shifts.
|
|
18
|
+
* The value is announced politely on change; the buttons carry their own
|
|
19
|
+
* localised names, because a lone "−" reads as nothing to a screen reader.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
export type PartyCounterLabels = {
|
|
23
|
+
/** "Fewer people" */
|
|
24
|
+
decrease: string;
|
|
25
|
+
/** "More people" */
|
|
26
|
+
increase: string;
|
|
27
|
+
/** The unit after the number: "people" / "person" — already pluralised by the caller. */
|
|
28
|
+
unit: (count: number) => string;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
export type PartyCounterProps = {
|
|
32
|
+
value: number;
|
|
33
|
+
onValueChange: (value: number) => void;
|
|
34
|
+
min?: number;
|
|
35
|
+
max?: number;
|
|
36
|
+
labels: PartyCounterLabels;
|
|
37
|
+
disabled?: boolean;
|
|
38
|
+
className?: string;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
export function PartyCounter({
|
|
42
|
+
value,
|
|
43
|
+
onValueChange,
|
|
44
|
+
min = 1,
|
|
45
|
+
max = 20,
|
|
46
|
+
labels,
|
|
47
|
+
disabled,
|
|
48
|
+
className,
|
|
49
|
+
}: PartyCounterProps) {
|
|
50
|
+
const atMin = value <= min;
|
|
51
|
+
const atMax = value >= max;
|
|
52
|
+
|
|
53
|
+
const roundButton =
|
|
54
|
+
"grid size-9 shrink-0 cursor-pointer place-items-center rounded-full transition-[filter,background-color,opacity] duration-150 motion-reduce:transition-none focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-card disabled:cursor-not-allowed disabled:opacity-40 [&_svg]:size-4";
|
|
55
|
+
|
|
56
|
+
return (
|
|
57
|
+
<div
|
|
58
|
+
data-slot="party-counter"
|
|
59
|
+
className={cn(
|
|
60
|
+
"flex h-[52px] items-center justify-between gap-2.5 rounded-xl border border-border bg-card px-2",
|
|
61
|
+
className
|
|
62
|
+
)}
|
|
63
|
+
>
|
|
64
|
+
<button
|
|
65
|
+
type="button"
|
|
66
|
+
aria-label={labels.decrease}
|
|
67
|
+
disabled={disabled || atMin}
|
|
68
|
+
onClick={() => onValueChange(Math.max(min, value - 1))}
|
|
69
|
+
className={cn(roundButton, "bg-surface-2 text-foreground hover:bg-muted")}
|
|
70
|
+
>
|
|
71
|
+
<Minus aria-hidden />
|
|
72
|
+
</button>
|
|
73
|
+
|
|
74
|
+
<output aria-live="polite" className="flex-1 text-center">
|
|
75
|
+
<b className="text-[19px] font-bold tabular-nums text-foreground">{value}</b>
|
|
76
|
+
<span className="ml-1 text-xs text-muted-foreground">{labels.unit(value)}</span>
|
|
77
|
+
</output>
|
|
78
|
+
|
|
79
|
+
<button
|
|
80
|
+
type="button"
|
|
81
|
+
aria-label={labels.increase}
|
|
82
|
+
disabled={disabled || atMax}
|
|
83
|
+
onClick={() => onValueChange(Math.min(max, value + 1))}
|
|
84
|
+
className={cn(
|
|
85
|
+
roundButton,
|
|
86
|
+
"bg-[image:var(--gradient-primary)] text-white hover:brightness-[0.94]"
|
|
87
|
+
)}
|
|
88
|
+
>
|
|
89
|
+
<Plus aria-hidden />
|
|
90
|
+
</button>
|
|
91
|
+
</div>
|
|
92
|
+
);
|
|
93
|
+
}
|
|
@@ -68,32 +68,3 @@ export const LongTitles: Story = {
|
|
|
68
68
|
],
|
|
69
69
|
},
|
|
70
70
|
};
|
|
71
|
-
|
|
72
|
-
/**
|
|
73
|
-
* A long localised date with the time split out into `time`. The date
|
|
74
|
-
* truncates ("sâm., 5 sept." becomes "sâm., 5 se…" if it must); the time is
|
|
75
|
-
* `shrink-0` outside the truncating span and is always whole. Before this the
|
|
76
|
-
* whole line was one truncating string and clipped to "17:…".
|
|
77
|
-
*/
|
|
78
|
-
export const LocalisedDateAndTime: Story = {
|
|
79
|
-
args: {
|
|
80
|
-
items: [
|
|
81
|
-
{ id: "1", title: "Warehouse Rave", meta: "sâmbătă, 5 septembrie", time: "17:00" },
|
|
82
|
-
{ id: "2", title: "Jazz on the Rooftop", meta: "vineri, 4 septembrie", time: "19:30" },
|
|
83
|
-
{ id: "3", title: "Open Mic Night", meta: "joi, 3 sept.", time: "21:00" },
|
|
84
|
-
{ id: "4", title: "Street Food Market", meta: "duminică, 6 septembrie", time: "12:00" },
|
|
85
|
-
],
|
|
86
|
-
},
|
|
87
|
-
};
|
|
88
|
-
|
|
89
|
-
/**
|
|
90
|
-
* With `labels`, a pause/play toggle renders under the tape's right edge —
|
|
91
|
-
* `<DriftRail>`'s control, forwarded. Pressing it latches the tape still
|
|
92
|
-
* until play is pressed again. It does not render under
|
|
93
|
-
* `prefers-reduced-motion`, where nothing drifts.
|
|
94
|
-
*/
|
|
95
|
-
export const WithPauseControl: Story = {
|
|
96
|
-
args: {
|
|
97
|
-
labels: { pause: "Pause the tape", play: "Play the tape" },
|
|
98
|
-
},
|
|
99
|
-
};
|