@bearmenu/ui 0.8.38 → 0.8.40
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-FVSKGCK3.js → chunk-DZHKUUYV.js} +15 -1
- package/dist/{chunk-XLHMF6F5.js → chunk-NF5UMLSL.js} +17 -6
- package/dist/components/badge.d.ts +1 -1
- package/dist/components/button.d.ts +2 -2
- package/dist/components/card.d.ts +2 -2
- package/dist/components/card.js +1 -1
- package/dist/components/command.d.ts +3 -3
- package/dist/components/drawer.d.ts +8 -1
- package/dist/components/drawer.js +1 -1
- package/dist/components/empty-state.d.ts +38 -1
- package/dist/components/empty-state.js +48 -18
- package/dist/components/responsive-overlay.js +16 -20
- package/dist/components/spinner.d.ts +2 -2
- package/dist/components/tag.d.ts +2 -2
- package/dist/components/text.d.ts +2 -2
- package/dist/components/venue-events-block.js +1 -1
- package/package.json +1 -1
- package/src/components/card.stories.tsx +20 -1
- package/src/components/card.test.tsx +27 -0
- package/src/components/card.tsx +14 -0
- package/src/components/drawer.test.tsx +55 -0
- package/src/components/drawer.tsx +33 -11
- package/src/components/empty-state.stories.tsx +31 -0
- package/src/components/empty-state.test.tsx +78 -0
- package/src/components/empty-state.tsx +80 -21
- package/src/components/floor-tokens.stories.tsx +223 -0
- package/src/components/responsive-overlay.test.tsx +40 -0
- package/src/components/responsive-overlay.tsx +13 -10
- package/src/globals.css +161 -0
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { render, screen, waitFor } from "@testing-library/react";
|
|
3
|
+
import userEvent from "@testing-library/user-event";
|
|
4
|
+
import { Drawer, DrawerContent, DrawerDescription, DrawerTitle } from "./drawer";
|
|
5
|
+
|
|
6
|
+
function Harness(props: { modal?: boolean; autoFocus?: boolean }) {
|
|
7
|
+
return (
|
|
8
|
+
<>
|
|
9
|
+
<button type="button">Behind</button>
|
|
10
|
+
<Drawer open onOpenChange={() => {}} {...props}>
|
|
11
|
+
<DrawerContent>
|
|
12
|
+
<DrawerTitle>Pick a range</DrawerTitle>
|
|
13
|
+
<DrawerDescription>Reports</DrawerDescription>
|
|
14
|
+
<button type="button">Inside</button>
|
|
15
|
+
</DrawerContent>
|
|
16
|
+
</Drawer>
|
|
17
|
+
</>
|
|
18
|
+
);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
describe("Drawer", () => {
|
|
22
|
+
it("moves focus into the sheet on open", async () => {
|
|
23
|
+
render(<Harness />);
|
|
24
|
+
const dialog = screen.getByRole("dialog");
|
|
25
|
+
await waitFor(() => expect(dialog.contains(document.activeElement)).toBe(true));
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("keeps Tab inside a modal sheet", async () => {
|
|
29
|
+
render(<Harness />);
|
|
30
|
+
const dialog = screen.getByRole("dialog");
|
|
31
|
+
await waitFor(() => expect(dialog.contains(document.activeElement)).toBe(true));
|
|
32
|
+
for (let i = 0; i < 4; i += 1) {
|
|
33
|
+
await userEvent.tab();
|
|
34
|
+
expect(dialog.contains(document.activeElement)).toBe(true);
|
|
35
|
+
}
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("marks a modal sheet aria-modal", () => {
|
|
39
|
+
render(<Harness />);
|
|
40
|
+
expect(screen.getByRole("dialog")).toHaveAttribute("aria-modal", "true");
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("does not claim aria-modal when the sheet is not modal", () => {
|
|
44
|
+
render(<Harness modal={false} />);
|
|
45
|
+
expect(screen.getByRole("dialog")).not.toHaveAttribute("aria-modal");
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("leaves focus where it was with autoFocus={false}", async () => {
|
|
49
|
+
render(<Harness autoFocus={false} />);
|
|
50
|
+
const dialog = screen.getByRole("dialog");
|
|
51
|
+
// Give an open auto-focus the frame it would have taken.
|
|
52
|
+
await new Promise((resolve) => requestAnimationFrame(resolve));
|
|
53
|
+
expect(dialog.contains(document.activeElement)).toBe(false);
|
|
54
|
+
});
|
|
55
|
+
});
|
|
@@ -6,21 +6,40 @@ import { cn } from "../lib/utils";
|
|
|
6
6
|
|
|
7
7
|
type DrawerDirection = "top" | "bottom" | "left" | "right";
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
type DrawerContextValue = { direction: DrawerDirection; modal: boolean };
|
|
10
10
|
|
|
11
|
+
const DrawerContext = React.createContext<DrawerContextValue>({
|
|
12
|
+
direction: "bottom",
|
|
13
|
+
modal: true,
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* `autoFocus` defaults to `true` here, where vaul defaults it to `false`.
|
|
18
|
+
* vaul's default cancels Radix's open auto-focus, so focus never enters the
|
|
19
|
+
* sheet, Radix's FocusScope never engages, and Tab walks the page behind a
|
|
20
|
+
* modal drawer. A caller that must not move focus on open passes
|
|
21
|
+
* `autoFocus={false}`.
|
|
22
|
+
*/
|
|
11
23
|
const Drawer = ({
|
|
12
24
|
shouldScaleBackground = false,
|
|
13
25
|
direction = "bottom",
|
|
26
|
+
modal = true,
|
|
27
|
+
autoFocus = true,
|
|
14
28
|
...props
|
|
15
|
-
}: React.ComponentProps<typeof DrawerPrimitive.Root>) =>
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
29
|
+
}: React.ComponentProps<typeof DrawerPrimitive.Root>) => {
|
|
30
|
+
const context = React.useMemo(() => ({ direction, modal }), [direction, modal]);
|
|
31
|
+
return (
|
|
32
|
+
<DrawerContext.Provider value={context}>
|
|
33
|
+
<DrawerPrimitive.Root
|
|
34
|
+
shouldScaleBackground={shouldScaleBackground}
|
|
35
|
+
direction={direction}
|
|
36
|
+
modal={modal}
|
|
37
|
+
autoFocus={autoFocus}
|
|
38
|
+
{...props}
|
|
39
|
+
/>
|
|
40
|
+
</DrawerContext.Provider>
|
|
41
|
+
);
|
|
42
|
+
};
|
|
24
43
|
Drawer.displayName = "Drawer";
|
|
25
44
|
|
|
26
45
|
const DrawerTrigger = DrawerPrimitive.Trigger;
|
|
@@ -55,7 +74,7 @@ const DrawerContent = React.forwardRef<
|
|
|
55
74
|
React.ElementRef<typeof DrawerPrimitive.Content>,
|
|
56
75
|
DrawerContentProps
|
|
57
76
|
>(({ className, variant = "flush", showHandle, overlayClassName, children, ...props }, ref) => {
|
|
58
|
-
const direction = React.useContext(
|
|
77
|
+
const { direction, modal } = React.useContext(DrawerContext);
|
|
59
78
|
const isVertical = direction === "top" || direction === "bottom";
|
|
60
79
|
const handleEnabled = showHandle ?? (direction === "bottom");
|
|
61
80
|
|
|
@@ -74,6 +93,9 @@ const DrawerContent = React.forwardRef<
|
|
|
74
93
|
<DrawerPrimitive.Content
|
|
75
94
|
ref={ref}
|
|
76
95
|
data-slot="drawer-content"
|
|
96
|
+
// Radix hides the page from assistive tech but never says so on the
|
|
97
|
+
// dialog itself; `aria-modal` is what tells a screen reader to stay in.
|
|
98
|
+
aria-modal={modal ? true : undefined}
|
|
77
99
|
className={cn(
|
|
78
100
|
"fixed z-[1000] flex border-border bg-card shadow-[var(--shadow-overlay)]",
|
|
79
101
|
positionClasses,
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { Meta, StoryObj } from "@storybook/react";
|
|
2
|
+
import { RefreshCw, Plus } from "lucide-react";
|
|
2
3
|
import { EmptyState, SearchEmptyState, ErrorEmptyState } from "./empty-state";
|
|
3
4
|
|
|
4
5
|
const meta: Meta<typeof EmptyState> = {
|
|
@@ -49,6 +50,36 @@ export const Error: Story = {
|
|
|
49
50
|
),
|
|
50
51
|
};
|
|
51
52
|
|
|
53
|
+
/**
|
|
54
|
+
* The booking pages' whole-body failure (reports/02-p4, calendar/02-c2): a
|
|
55
|
+
* 14px/16px-radius borderless card and the recovery action's refresh glyph.
|
|
56
|
+
*/
|
|
57
|
+
export const ErrorCard: Story = {
|
|
58
|
+
render: () => (
|
|
59
|
+
<div className="bg-background p-4">
|
|
60
|
+
<ErrorEmptyState
|
|
61
|
+
surface="card"
|
|
62
|
+
title="Couldn't load this report"
|
|
63
|
+
description="This is on our side, not an empty week. Try again in a moment."
|
|
64
|
+
action={{ label: "Try again", onClick: () => {}, icon: <RefreshCw /> }}
|
|
65
|
+
/>
|
|
66
|
+
</div>
|
|
67
|
+
),
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
/** The calendar day's failure, with the pinned footer's "New reservation" action alongside — both actions carry a leading glyph. */
|
|
71
|
+
export const ActionWithIcon: Story = {
|
|
72
|
+
render: () => (
|
|
73
|
+
<div className="bg-background p-4">
|
|
74
|
+
<EmptyState
|
|
75
|
+
title="No reservations yet"
|
|
76
|
+
description="This day is open. Start the first booking."
|
|
77
|
+
action={{ label: "New reservation", onClick: () => {}, icon: <Plus /> }}
|
|
78
|
+
/>
|
|
79
|
+
</div>
|
|
80
|
+
),
|
|
81
|
+
};
|
|
82
|
+
|
|
52
83
|
export const Small: Story = {
|
|
53
84
|
args: {
|
|
54
85
|
title: "No data",
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from "vitest";
|
|
2
|
+
import { render, screen } from "@testing-library/react";
|
|
3
|
+
import userEvent from "@testing-library/user-event";
|
|
4
|
+
import { RefreshCw } from "lucide-react";
|
|
5
|
+
import { EmptyState, ErrorEmptyState, SearchEmptyState } from "./empty-state";
|
|
6
|
+
|
|
7
|
+
const retry = { label: "Try again", onClick: () => {} };
|
|
8
|
+
|
|
9
|
+
describe("EmptyState", () => {
|
|
10
|
+
it("draws a retry on an error as an outline button", () => {
|
|
11
|
+
render(<ErrorEmptyState title="Couldn't load this report" description="On our side." action={retry} />);
|
|
12
|
+
expect(screen.getByRole("button", { name: "Try again" })).toHaveClass("border-border", "bg-background");
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it("draws a clear-filters on a search as an outline button", () => {
|
|
16
|
+
render(<SearchEmptyState title="No results" description="Try another name." action={retry} />);
|
|
17
|
+
expect(screen.getByRole("button", { name: "Try again" })).toHaveClass("border-border", "bg-background");
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it("keeps the plain empty state's create action filled", () => {
|
|
21
|
+
render(<EmptyState title="No items yet" description="Add the first one." action={{ label: "Add item", onClick: () => {} }} />);
|
|
22
|
+
expect(screen.getByRole("button", { name: "Add item" })).toHaveClass("bg-primary");
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it("lets the caller choose the action style", () => {
|
|
26
|
+
render(<EmptyState title="Nothing" description="Here." action={retry} actionVariant="outline" />);
|
|
27
|
+
expect(screen.getByRole("button", { name: "Try again" })).toHaveClass("border-border", "bg-background");
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("calls the action", async () => {
|
|
31
|
+
const onClick = vi.fn();
|
|
32
|
+
render(<ErrorEmptyState title="Failed" description="Retry." action={{ label: "Try again", onClick }} />);
|
|
33
|
+
await userEvent.click(screen.getByRole("button", { name: "Try again" }));
|
|
34
|
+
expect(onClick).toHaveBeenCalledOnce();
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("does not paint an error's title and body red", () => {
|
|
38
|
+
render(<ErrorEmptyState title="Failed" description="On our side." />);
|
|
39
|
+
expect(screen.getByRole("heading", { name: "Failed" }).className).not.toMatch(/destructive/);
|
|
40
|
+
expect(screen.getByText("On our side.").className).not.toMatch(/destructive/);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("sets the icon in a tile, whatever the icon", () => {
|
|
44
|
+
render(<EmptyState title="Empty" description="" icon={<svg data-testid="glyph" className="h-10 w-10" />} />);
|
|
45
|
+
const tile = document.querySelector('[data-slot="empty-state-icon"]');
|
|
46
|
+
expect(tile).toContainElement(screen.getByTestId("glyph"));
|
|
47
|
+
expect(tile).toHaveClass("size-16", "sm:size-18", "[&_svg]:size-7");
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("renders no empty paragraph when there is no body", () => {
|
|
51
|
+
render(<EmptyState title="Empty" description="" />);
|
|
52
|
+
expect(screen.getByRole("status").querySelector("p")).toBeNull();
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("draws the card surface on request only, borderless with the frames' radius", () => {
|
|
56
|
+
const { rerender } = render(<EmptyState title="Empty" description="Nothing." />);
|
|
57
|
+
expect(screen.getByRole("status")).not.toHaveClass("bg-card");
|
|
58
|
+
rerender(<EmptyState title="Empty" description="Nothing." surface="card" />);
|
|
59
|
+
const card = screen.getByRole("status");
|
|
60
|
+
expect(card).toHaveClass("rounded-[14px]", "sm:rounded-[16px]", "bg-card", "px-5");
|
|
61
|
+
expect(card).not.toHaveClass("border", "border-border");
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("renders the action's icon before its label", () => {
|
|
65
|
+
render(
|
|
66
|
+
<ErrorEmptyState
|
|
67
|
+
title="Couldn't load this report"
|
|
68
|
+
description="On our side."
|
|
69
|
+
action={{ label: "Try again", onClick: () => {}, icon: <RefreshCw data-testid="retry-icon" /> }}
|
|
70
|
+
/>
|
|
71
|
+
);
|
|
72
|
+
const button = screen.getByRole("button", { name: "Try again" });
|
|
73
|
+
const icon = screen.getByTestId("retry-icon");
|
|
74
|
+
expect(button).toContainElement(icon);
|
|
75
|
+
expect(button.firstElementChild).toBe(icon);
|
|
76
|
+
expect(button.textContent).toBe("Try again");
|
|
77
|
+
});
|
|
78
|
+
});
|
|
@@ -9,8 +9,16 @@ import { Search, AlertCircle, FileX } from "lucide-react";
|
|
|
9
9
|
type EmptyStateAction = {
|
|
10
10
|
label: string;
|
|
11
11
|
onClick: () => void;
|
|
12
|
+
/**
|
|
13
|
+
* Rendered before the label, sized like the DS button's own icons (the
|
|
14
|
+
* `Button` base class forces every child `<svg>` to 16px) — "Try again"
|
|
15
|
+
* carries a refresh glyph, "New reservation" a plus.
|
|
16
|
+
*/
|
|
17
|
+
icon?: React.ReactNode;
|
|
12
18
|
};
|
|
13
19
|
|
|
20
|
+
type EmptyStateVariant = "default" | "search" | "error";
|
|
21
|
+
|
|
14
22
|
interface EmptyStateProps {
|
|
15
23
|
title: string;
|
|
16
24
|
description: string;
|
|
@@ -18,8 +26,22 @@ interface EmptyStateProps {
|
|
|
18
26
|
illustration?: React.ReactNode;
|
|
19
27
|
action?: EmptyStateAction;
|
|
20
28
|
secondaryAction?: EmptyStateAction;
|
|
21
|
-
|
|
29
|
+
/**
|
|
30
|
+
* The primary action's style. Defaults by variant: `outline` for `error`
|
|
31
|
+
* (try again) and `search` (clear filters) — a recovery step, not the page's
|
|
32
|
+
* main call to action — and a filled `default` for the plain empty state,
|
|
33
|
+
* where the action is usually "create the first one".
|
|
34
|
+
*/
|
|
35
|
+
actionVariant?: "default" | "outline";
|
|
36
|
+
variant?: EmptyStateVariant;
|
|
22
37
|
size?: "sm" | "md" | "lg";
|
|
38
|
+
/**
|
|
39
|
+
* `card` draws the booking pages' whole-body state card (reports/02-p4,
|
|
40
|
+
* calendar/02-c2): a 14px-radius card surface (16px from `sm`) with the
|
|
41
|
+
* resting card shadow and no border. `none` (the default) leaves the
|
|
42
|
+
* surface to the container it sits in.
|
|
43
|
+
*/
|
|
44
|
+
surface?: "none" | "card";
|
|
23
45
|
className?: string;
|
|
24
46
|
}
|
|
25
47
|
|
|
@@ -29,6 +51,34 @@ const sizeStyles = {
|
|
|
29
51
|
lg: "py-16 px-8",
|
|
30
52
|
} as const;
|
|
31
53
|
|
|
54
|
+
const DEFAULT_ACTION_VARIANT: Record<EmptyStateVariant, "default" | "outline"> = {
|
|
55
|
+
default: "default",
|
|
56
|
+
search: "outline",
|
|
57
|
+
error: "outline",
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
const DEFAULT_ICON: Record<EmptyStateVariant, React.ReactNode> = {
|
|
61
|
+
default: <FileX className="text-muted-foreground" aria-hidden />,
|
|
62
|
+
search: <Search className="text-muted-foreground" aria-hidden />,
|
|
63
|
+
error: <AlertCircle className="text-destructive" aria-hidden />,
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* EmptyState — an icon tile, a title, one line of body and an optional action,
|
|
68
|
+
* spaced as the booking frames draw them (reports/02-p4, calendar/02-c2): a
|
|
69
|
+
* 64px tile (72px from `sm`) with a 28px glyph, 16px to the title, 12px from
|
|
70
|
+
* title to body, 16px from body to the action. The action is full-width on a
|
|
71
|
+
* phone and hugs its label from `sm`.
|
|
72
|
+
*
|
|
73
|
+
* The tile is 20px-radius (22px from `sm`); the title is 16px on a phone,
|
|
74
|
+
* 18px from `sm`. On `surface="card"` the card itself is 14px-radius (16px
|
|
75
|
+
* from `sm`), no border, with 20px side padding to the action row — measured
|
|
76
|
+
* off the frames' DOM (reports/02-p4, calendar/02-c2), not eyeballed.
|
|
77
|
+
*
|
|
78
|
+
* The error variant says "error" with its glyph and its tile's wash, not by
|
|
79
|
+
* painting the title and body red: red body copy is both harder to read and
|
|
80
|
+
* louder than a load failure the user can retry.
|
|
81
|
+
*/
|
|
32
82
|
const EmptyState = React.forwardRef<HTMLDivElement, EmptyStateProps>(
|
|
33
83
|
(
|
|
34
84
|
{
|
|
@@ -38,26 +88,15 @@ const EmptyState = React.forwardRef<HTMLDivElement, EmptyStateProps>(
|
|
|
38
88
|
illustration,
|
|
39
89
|
action,
|
|
40
90
|
secondaryAction,
|
|
91
|
+
actionVariant,
|
|
41
92
|
variant = "default",
|
|
42
93
|
size = "md",
|
|
94
|
+
surface = "none",
|
|
43
95
|
className,
|
|
44
96
|
...props
|
|
45
97
|
},
|
|
46
98
|
ref
|
|
47
99
|
) => {
|
|
48
|
-
const getDefaultIcon = () => {
|
|
49
|
-
switch (variant) {
|
|
50
|
-
case "search":
|
|
51
|
-
return <Search className="h-12 w-12 text-muted-foreground/50" />;
|
|
52
|
-
case "error":
|
|
53
|
-
return <AlertCircle className="h-12 w-12 text-destructive/50" />;
|
|
54
|
-
default:
|
|
55
|
-
return <FileX className="h-12 w-12 text-muted-foreground/50" />;
|
|
56
|
-
}
|
|
57
|
-
};
|
|
58
|
-
|
|
59
|
-
const textColor = variant === "error" ? "destructive" : "muted";
|
|
60
|
-
|
|
61
100
|
return (
|
|
62
101
|
<div
|
|
63
102
|
ref={ref}
|
|
@@ -65,6 +104,8 @@ const EmptyState = React.forwardRef<HTMLDivElement, EmptyStateProps>(
|
|
|
65
104
|
className={cn(
|
|
66
105
|
"flex flex-col items-center justify-center text-center",
|
|
67
106
|
sizeStyles[size],
|
|
107
|
+
surface === "card" &&
|
|
108
|
+
"rounded-[14px] bg-card px-5 shadow-[var(--shadow-card)] sm:rounded-[16px]",
|
|
68
109
|
className
|
|
69
110
|
)}
|
|
70
111
|
{...props}
|
|
@@ -72,26 +113,44 @@ const EmptyState = React.forwardRef<HTMLDivElement, EmptyStateProps>(
|
|
|
72
113
|
{illustration ? (
|
|
73
114
|
<div className="mb-6">{illustration}</div>
|
|
74
115
|
) : (
|
|
75
|
-
<
|
|
116
|
+
<span
|
|
117
|
+
data-slot="empty-state-icon"
|
|
118
|
+
className={cn(
|
|
119
|
+
// The glyph size is the tile's, whatever size the caller's icon
|
|
120
|
+
// node asked for — a 40px icon in a 64px tile has no margin.
|
|
121
|
+
"mb-4 flex size-16 shrink-0 items-center justify-center rounded-[20px] border border-border shadow-sm sm:size-18 sm:rounded-[22px] [&_svg]:size-7",
|
|
122
|
+
variant === "error" ? "bg-destructive/5" : "bg-card"
|
|
123
|
+
)}
|
|
124
|
+
>
|
|
125
|
+
{icon ?? DEFAULT_ICON[variant]}
|
|
126
|
+
</span>
|
|
76
127
|
)}
|
|
77
128
|
|
|
78
|
-
<Text as="h3" weight="semibold"
|
|
129
|
+
<Text as="h3" weight="semibold" className="text-base sm:text-lg">
|
|
79
130
|
{title}
|
|
80
131
|
</Text>
|
|
81
132
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
133
|
+
{description ? (
|
|
134
|
+
<Text as="p" color="muted" className="mt-3 max-w-md text-sm">
|
|
135
|
+
{description}
|
|
136
|
+
</Text>
|
|
137
|
+
) : null}
|
|
85
138
|
|
|
86
139
|
{(action || secondaryAction) && (
|
|
87
|
-
<div className="flex
|
|
140
|
+
<div className="mt-4 flex w-full flex-col gap-3 sm:w-auto sm:flex-row sm:items-center">
|
|
88
141
|
{action && (
|
|
89
|
-
<Button
|
|
142
|
+
<Button
|
|
143
|
+
onClick={action.onClick}
|
|
144
|
+
variant={actionVariant ?? DEFAULT_ACTION_VARIANT[variant]}
|
|
145
|
+
className="glass-hover"
|
|
146
|
+
>
|
|
147
|
+
{action.icon}
|
|
90
148
|
{action.label}
|
|
91
149
|
</Button>
|
|
92
150
|
)}
|
|
93
151
|
{secondaryAction && (
|
|
94
152
|
<Button onClick={secondaryAction.onClick} variant="outline" className="glass-hover">
|
|
153
|
+
{secondaryAction.icon}
|
|
95
154
|
{secondaryAction.label}
|
|
96
155
|
</Button>
|
|
97
156
|
)}
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/react";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Tokens / Floor — the floor plan's `--floor-*` palette (5a "Jewel",
|
|
5
|
+
* contrast-corrected). Every status fill gradient, its chair-ring colour and
|
|
6
|
+
* its foreground, in light and dark side by side. The dark column is a `.dark`
|
|
7
|
+
* scope, so keep the toolbar on Light to compare the two.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
type Status = {
|
|
11
|
+
key: string;
|
|
12
|
+
label: string;
|
|
13
|
+
time?: string;
|
|
14
|
+
/** Token stem, e.g. `booked` → `--floor-booked-from`. */
|
|
15
|
+
stem: string;
|
|
16
|
+
/** The status overlay on the chair ring; statuses without one show the neutral track only. */
|
|
17
|
+
seat?: string;
|
|
18
|
+
foreground: string;
|
|
19
|
+
halo?: string;
|
|
20
|
+
ring?: { color: string; dashed: boolean };
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
const STATUSES: Status[] = [
|
|
24
|
+
{ key: "free", label: "T1", time: "Free", stem: "free", foreground: "--floor-free-foreground" },
|
|
25
|
+
{ key: "booked", label: "T2", time: "19:30", stem: "booked", seat: "--floor-booked-seat", foreground: "--floor-booked-foreground" },
|
|
26
|
+
{ key: "seated", label: "T3", time: "45m", stem: "seated", seat: "--floor-seated-seat", foreground: "--floor-seated-foreground" },
|
|
27
|
+
{ key: "over", label: "T4", time: "+12m", stem: "over", seat: "--floor-over-seat", foreground: "--floor-over-foreground", halo: "--floor-over-halo" },
|
|
28
|
+
{ key: "late", label: "T5", time: "Late 8m", stem: "late", seat: "--floor-late-seat", foreground: "--floor-late-foreground", halo: "--floor-late-halo" },
|
|
29
|
+
{ key: "blocked", label: "T6", time: "Held", stem: "blocked", seat: "--floor-blocked-seat", foreground: "--floor-blocked-foreground" },
|
|
30
|
+
{ key: "out-of-service", label: "T7", time: "Out", stem: "out-of-service", foreground: "--floor-out-of-service-foreground" },
|
|
31
|
+
{ key: "offline", label: "T8", time: "Offline", stem: "offline", seat: "--floor-offline-seat", foreground: "--floor-offline-foreground" },
|
|
32
|
+
{ key: "offer", label: "T9", time: "Offer", stem: "free", foreground: "--floor-free-foreground", ring: { color: "--floor-offer", dashed: true } },
|
|
33
|
+
{ key: "selected", label: "T10", time: "19:30", stem: "booked", seat: "--floor-booked-seat", foreground: "--floor-booked-foreground", ring: { color: "--floor-selected", dashed: false } },
|
|
34
|
+
];
|
|
35
|
+
|
|
36
|
+
const SINGLES = [
|
|
37
|
+
"--floor-seat",
|
|
38
|
+
"--floor-muted-foreground",
|
|
39
|
+
"--floor-on-fill-foreground",
|
|
40
|
+
"--floor-late-halo",
|
|
41
|
+
"--floor-over-halo",
|
|
42
|
+
"--floor-busy",
|
|
43
|
+
"--floor-error",
|
|
44
|
+
"--floor-error-foreground",
|
|
45
|
+
"--floor-offer",
|
|
46
|
+
"--floor-selected",
|
|
47
|
+
"--floor-room",
|
|
48
|
+
"--floor-wall",
|
|
49
|
+
"--floor-fixture",
|
|
50
|
+
"--floor-shadow",
|
|
51
|
+
];
|
|
52
|
+
|
|
53
|
+
/** Short side 60: the geometry in the token notes is in units of it. */
|
|
54
|
+
const S = 60;
|
|
55
|
+
const W = 96;
|
|
56
|
+
const RX = 0.1 * S;
|
|
57
|
+
const OFFSET = 0.0867 * S;
|
|
58
|
+
const HALO = 0.1756 * S;
|
|
59
|
+
|
|
60
|
+
function Table({ status, theme }: { status: Status; theme: string }) {
|
|
61
|
+
const id = `floor-${status.key}-${theme}`;
|
|
62
|
+
const pad = 14;
|
|
63
|
+
const ring = { x: pad - OFFSET, y: pad - OFFSET, w: W + 2 * OFFSET, h: S + 2 * OFFSET };
|
|
64
|
+
return (
|
|
65
|
+
<svg
|
|
66
|
+
width={W + 2 * pad}
|
|
67
|
+
height={S + 2 * pad}
|
|
68
|
+
viewBox={`0 0 ${W + 2 * pad} ${S + 2 * pad}`}
|
|
69
|
+
role="img"
|
|
70
|
+
aria-label={`${status.key} table`}
|
|
71
|
+
>
|
|
72
|
+
<defs>
|
|
73
|
+
<linearGradient id={id} x1="0" x2="1" y1="0" y2="0">
|
|
74
|
+
<stop offset="0" style={{ stopColor: `var(--floor-${status.stem}-from)` }} />
|
|
75
|
+
<stop offset="1" style={{ stopColor: `var(--floor-${status.stem}-to)` }} />
|
|
76
|
+
</linearGradient>
|
|
77
|
+
<filter id={`${id}-shadow`} x="-30%" y="-30%" width="160%" height="180%">
|
|
78
|
+
<feDropShadow dx="0" dy={0.047 * S} stdDeviation={0.053 * S} floodColor="var(--floor-shadow)" floodOpacity="0.16" />
|
|
79
|
+
</filter>
|
|
80
|
+
</defs>
|
|
81
|
+
{status.halo ? (
|
|
82
|
+
<rect
|
|
83
|
+
x={pad - HALO}
|
|
84
|
+
y={pad - HALO}
|
|
85
|
+
width={W + 2 * HALO}
|
|
86
|
+
height={S + 2 * HALO}
|
|
87
|
+
rx={RX + HALO}
|
|
88
|
+
fill="none"
|
|
89
|
+
strokeWidth={0.037 * S}
|
|
90
|
+
style={{ stroke: `var(${status.halo})` }}
|
|
91
|
+
/>
|
|
92
|
+
) : null}
|
|
93
|
+
<rect
|
|
94
|
+
x={ring.x}
|
|
95
|
+
y={ring.y}
|
|
96
|
+
width={ring.w}
|
|
97
|
+
height={ring.h}
|
|
98
|
+
rx={RX + OFFSET}
|
|
99
|
+
fill="none"
|
|
100
|
+
strokeWidth={0.0667 * S}
|
|
101
|
+
strokeLinecap="round"
|
|
102
|
+
style={{ stroke: "var(--floor-seat)" }}
|
|
103
|
+
/>
|
|
104
|
+
{status.seat ? (
|
|
105
|
+
<rect
|
|
106
|
+
x={ring.x}
|
|
107
|
+
y={ring.y}
|
|
108
|
+
width={ring.w}
|
|
109
|
+
height={ring.h}
|
|
110
|
+
rx={RX + OFFSET}
|
|
111
|
+
fill="none"
|
|
112
|
+
pathLength={100}
|
|
113
|
+
strokeDasharray="60 40"
|
|
114
|
+
strokeWidth={0.0667 * S}
|
|
115
|
+
strokeLinecap="round"
|
|
116
|
+
style={{ stroke: `var(${status.seat})` }}
|
|
117
|
+
/>
|
|
118
|
+
) : null}
|
|
119
|
+
{status.ring ? (
|
|
120
|
+
<rect
|
|
121
|
+
x={pad - HALO}
|
|
122
|
+
y={pad - HALO}
|
|
123
|
+
width={W + 2 * HALO}
|
|
124
|
+
height={S + 2 * HALO}
|
|
125
|
+
rx={RX + HALO}
|
|
126
|
+
fill="none"
|
|
127
|
+
strokeWidth={0.037 * S}
|
|
128
|
+
strokeDasharray={status.ring.dashed ? "4 3" : undefined}
|
|
129
|
+
style={{ stroke: `var(${status.ring.color})` }}
|
|
130
|
+
/>
|
|
131
|
+
) : null}
|
|
132
|
+
<rect x={pad} y={pad} width={W} height={S} rx={RX} fill={`url(#${id})`} filter={`url(#${id}-shadow)`} />
|
|
133
|
+
<text
|
|
134
|
+
x={pad + W / 2}
|
|
135
|
+
y={pad + S / 2 - 2}
|
|
136
|
+
textAnchor="middle"
|
|
137
|
+
fontSize={0.2067 * S}
|
|
138
|
+
fontWeight={700}
|
|
139
|
+
style={{ fill: `var(${status.foreground})` }}
|
|
140
|
+
>
|
|
141
|
+
{status.label}
|
|
142
|
+
</text>
|
|
143
|
+
{status.time ? (
|
|
144
|
+
<text
|
|
145
|
+
x={pad + W / 2}
|
|
146
|
+
y={pad + S / 2 + 0.1333 * S + 2}
|
|
147
|
+
textAnchor="middle"
|
|
148
|
+
fontSize={Math.max(12, 0.1333 * S)}
|
|
149
|
+
fontWeight={600}
|
|
150
|
+
style={{ fill: `var(${status.foreground})`, fontVariantNumeric: "tabular-nums" }}
|
|
151
|
+
>
|
|
152
|
+
{status.time}
|
|
153
|
+
</text>
|
|
154
|
+
) : null}
|
|
155
|
+
</svg>
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function Chip({ token }: { token: string }) {
|
|
160
|
+
return (
|
|
161
|
+
<div className="flex items-center gap-2">
|
|
162
|
+
<span
|
|
163
|
+
className="size-5 shrink-0 rounded-md border border-border"
|
|
164
|
+
style={{ background: `var(${token})` }}
|
|
165
|
+
/>
|
|
166
|
+
<code className="text-2xs text-muted-foreground">{token}</code>
|
|
167
|
+
</div>
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function Panel({ theme }: { theme: "light" | "dark" }) {
|
|
172
|
+
return (
|
|
173
|
+
<section
|
|
174
|
+
className={`${theme === "dark" ? "dark" : ""} flex flex-col gap-6 rounded-2xl p-6 text-foreground`}
|
|
175
|
+
style={{ background: "var(--floor-room)" }}
|
|
176
|
+
>
|
|
177
|
+
<h2 className="text-lg font-semibold">{theme === "dark" ? "Dark" : "Light"}</h2>
|
|
178
|
+
<div className="flex flex-col gap-4">
|
|
179
|
+
{STATUSES.map((status) => (
|
|
180
|
+
<div key={status.key} className="flex items-center gap-4">
|
|
181
|
+
<Table status={status} theme={theme} />
|
|
182
|
+
<div className="flex flex-col gap-1">
|
|
183
|
+
<span className="text-sm font-semibold">{status.key}</span>
|
|
184
|
+
{status.ring ? (
|
|
185
|
+
<Chip token={status.ring.color} />
|
|
186
|
+
) : (
|
|
187
|
+
<>
|
|
188
|
+
<Chip token={`--floor-${status.stem}-from`} />
|
|
189
|
+
<Chip token={`--floor-${status.stem}-to`} />
|
|
190
|
+
{status.seat ? <Chip token={status.seat} /> : null}
|
|
191
|
+
<Chip token={status.foreground} />
|
|
192
|
+
{status.halo ? <Chip token={status.halo} /> : null}
|
|
193
|
+
</>
|
|
194
|
+
)}
|
|
195
|
+
</div>
|
|
196
|
+
</div>
|
|
197
|
+
))}
|
|
198
|
+
</div>
|
|
199
|
+
<div className="grid grid-cols-2 gap-2 border-t border-border pt-4">
|
|
200
|
+
{SINGLES.map((token) => (
|
|
201
|
+
<Chip key={token} token={token} />
|
|
202
|
+
))}
|
|
203
|
+
</div>
|
|
204
|
+
</section>
|
|
205
|
+
);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const meta = {
|
|
209
|
+
title: "Tokens/Floor",
|
|
210
|
+
parameters: { layout: "padded" },
|
|
211
|
+
} satisfies Meta;
|
|
212
|
+
|
|
213
|
+
export default meta;
|
|
214
|
+
type Story = StoryObj<typeof meta>;
|
|
215
|
+
|
|
216
|
+
export const BothThemes: Story = {
|
|
217
|
+
render: () => (
|
|
218
|
+
<div className="grid gap-6 md:grid-cols-2">
|
|
219
|
+
<Panel theme="light" />
|
|
220
|
+
<Panel theme="dark" />
|
|
221
|
+
</div>
|
|
222
|
+
),
|
|
223
|
+
};
|
|
@@ -78,6 +78,46 @@ describe("ResponsiveOverlay", () => {
|
|
|
78
78
|
expect(dialog).toHaveAccessibleDescription("Table 18 · from the menu");
|
|
79
79
|
});
|
|
80
80
|
|
|
81
|
+
describe("wires its title as the Radix title in every mode", () => {
|
|
82
|
+
const MODES: Array<{
|
|
83
|
+
name: string;
|
|
84
|
+
wide: boolean;
|
|
85
|
+
props: Partial<React.ComponentProps<typeof ResponsiveOverlay>>;
|
|
86
|
+
}> = [
|
|
87
|
+
{ name: "drawer", wide: false, props: {} },
|
|
88
|
+
{ name: "dialog", wide: true, props: {} },
|
|
89
|
+
{ name: "modal sheet", wide: true, props: { desktop: "sheet" } },
|
|
90
|
+
{ name: "live sheet", wide: true, props: { desktop: "sheet", modal: false } },
|
|
91
|
+
];
|
|
92
|
+
|
|
93
|
+
const logged = (...spies: Array<ReturnType<typeof vi.spyOn>>) =>
|
|
94
|
+
spies.flatMap((spy) => spy.mock.calls.flat()).join(" ");
|
|
95
|
+
|
|
96
|
+
for (const mode of MODES) {
|
|
97
|
+
it(`${mode.name}: named, described, and no Radix title warning`, () => {
|
|
98
|
+
const error = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
99
|
+
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
100
|
+
setWidth(mode.wide);
|
|
101
|
+
open(mode.props);
|
|
102
|
+
const dialog = screen.getByRole("dialog");
|
|
103
|
+
expect(dialog).toHaveAccessibleName("Add an item");
|
|
104
|
+
expect(dialog).toHaveAccessibleDescription("Table 18 · from the menu");
|
|
105
|
+
expect(logged(error, warn)).not.toMatch(/DialogTitle|Description/);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it(`${mode.name}: no description warning without a subtitle`, () => {
|
|
109
|
+
const error = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
110
|
+
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
111
|
+
setWidth(mode.wide);
|
|
112
|
+
open({ ...mode.props, subtitle: undefined });
|
|
113
|
+
const dialog = screen.getByRole("dialog");
|
|
114
|
+
expect(dialog).toHaveAccessibleName("Add an item");
|
|
115
|
+
expect(dialog).not.toHaveAttribute("aria-describedby");
|
|
116
|
+
expect(logged(error, warn)).not.toMatch(/DialogTitle|Description/);
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
});
|
|
120
|
+
|
|
81
121
|
it("closes through the round button", async () => {
|
|
82
122
|
const onOpenChange = vi.fn();
|
|
83
123
|
open({ onOpenChange });
|