@bearmenu/ui 0.8.32 → 0.8.34
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-5HHRVJOT.js → chunk-5SGKGFMN.js} +1 -1
- package/dist/{chunk-GESB7GCF.js → chunk-6W25S5CF.js} +1 -1
- package/dist/chunk-LY67SOFC.js +142 -0
- package/dist/{chunk-ZU4NAYN7.js → chunk-NIAL4ZU2.js} +13 -1
- package/dist/{chunk-JWTN7MNK.js → chunk-TVEIUTBQ.js} +2 -2
- package/dist/{chunk-ZJMCU7MI.js → chunk-VCRXL7H5.js} +1 -1
- package/dist/chunk-XLHMF6F5.js +127 -0
- package/dist/{chunk-VSRGEB36.js → chunk-XNBDACL7.js} +1 -1
- package/dist/components/action-bar.d.ts +67 -0
- package/dist/components/action-bar.js +43 -0
- package/dist/components/activity-row.d.ts +38 -0
- package/dist/components/activity-row.js +43 -0
- package/dist/components/alert-dialog.js +1 -1
- package/dist/components/alert.d.ts +2 -2
- package/dist/components/avatar.d.ts +4 -1
- package/dist/components/avatar.js +39 -5
- package/dist/components/badge.d.ts +1 -1
- package/dist/components/badge.js +1 -1
- package/dist/components/booking-row.js +1 -1
- package/dist/components/button.d.ts +2 -2
- package/dist/components/button.js +1 -1
- package/dist/components/calendar.js +1 -1
- package/dist/components/carousel.js +1 -1
- package/dist/components/coach-marks.js +1 -1
- package/dist/components/combobox.js +3 -3
- package/dist/components/command.d.ts +15 -15
- package/dist/components/command.js +2 -2
- package/dist/components/concept-convergence.js +2 -2
- package/dist/components/currency-input.js +4 -142
- package/dist/components/dialog.d.ts +6 -0
- package/dist/components/dialog.js +1 -1
- package/dist/components/drawer.d.ts +6 -0
- package/dist/components/drawer.js +3 -127
- package/dist/components/empty-state.d.ts +2 -2
- package/dist/components/empty-state.js +1 -1
- package/dist/components/event-detail-panel.js +1 -1
- package/dist/components/event-list-row-wide.js +1 -1
- package/dist/components/filter-category-row.js +1 -1
- package/dist/components/hold-countdown.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/menu-pick-row.d.ts +47 -0
- package/dist/components/menu-pick-row.js +49 -0
- package/dist/components/multi-select-combobox.js +4 -4
- package/dist/components/pagination.js +1 -1
- package/dist/components/place-card.js +1 -1
- package/dist/components/place-details-mobile.js +1 -1
- package/dist/components/qr-panel.d.ts +31 -0
- package/dist/components/qr-panel.js +33 -0
- package/dist/components/reconcile-bar.d.ts +47 -0
- package/dist/components/reconcile-bar.js +60 -0
- package/dist/components/responsive-overlay.d.ts +59 -0
- package/dist/components/responsive-overlay.js +132 -0
- package/dist/components/searchable-select.js +3 -3
- package/dist/components/signature-chip.js +2 -2
- package/dist/components/sliding-panels.js +1 -1
- package/dist/components/spinner.d.ts +2 -2
- package/dist/components/split-share-row.d.ts +77 -0
- package/dist/components/split-share-row.js +101 -0
- package/dist/components/table-ledger-card.d.ts +80 -0
- package/dist/components/table-ledger-card.js +84 -0
- package/dist/components/table-trigger-band.d.ts +56 -0
- package/dist/components/table-trigger-band.js +43 -0
- package/dist/components/tag.d.ts +2 -2
- package/dist/components/text.d.ts +2 -2
- package/dist/components/toggle.d.ts +1 -1
- package/dist/components/top-ranking-entry.js +3 -3
- package/dist/components/venue-events-block.js +1 -1
- package/package.json +10 -1
- package/src/components/action-bar.stories.tsx +78 -0
- package/src/components/action-bar.tsx +80 -0
- package/src/components/activity-row.stories.tsx +78 -0
- package/src/components/activity-row.tsx +65 -0
- package/src/components/avatar.stories.tsx +55 -0
- package/src/components/avatar.tsx +63 -19
- package/src/components/badge.stories.tsx +41 -0
- package/src/components/badge.tsx +13 -0
- package/src/components/button.tsx +1 -1
- package/src/components/dialog.tsx +8 -2
- package/src/components/drawer.tsx +8 -2
- package/src/components/hold-countdown.tsx +1 -1
- package/src/components/menu-pick-row.stories.tsx +71 -0
- package/src/components/menu-pick-row.tsx +76 -0
- package/src/components/qr-panel.stories.tsx +39 -0
- package/src/components/qr-panel.tsx +52 -0
- package/src/components/reconcile-bar.stories.tsx +57 -0
- package/src/components/reconcile-bar.tsx +80 -0
- package/src/components/responsive-overlay.stories.tsx +91 -0
- package/src/components/responsive-overlay.test.tsx +162 -0
- package/src/components/responsive-overlay.tsx +232 -0
- package/src/components/shared-table-primitives.test.tsx +482 -0
- package/src/components/split-share-row.stories.tsx +114 -0
- package/src/components/split-share-row.tsx +150 -0
- package/src/components/table-ledger-card.stories.tsx +120 -0
- package/src/components/table-ledger-card.tsx +138 -0
- package/src/components/table-trigger-band.stories.tsx +76 -0
- package/src/components/table-trigger-band.tsx +75 -0
- package/src/globals.css +62 -0
- package/src/test/setup.ts +11 -0
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import { cn } from "../lib/utils";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* QrPanel — the frame around the code that gets a phone onto a shared table
|
|
6
|
+
* (16-shared-table.html T8).
|
|
7
|
+
*
|
|
8
|
+
* The code arrives as `children`: an `img` with a data URL, an inline `svg`,
|
|
9
|
+
* whatever the caller's QR library returns. The design system imports no QR
|
|
10
|
+
* library — a dependency that ships in every consumer's bundle to serve one
|
|
11
|
+
* sheet is the wrong trade, and the app already has `qrcode`.
|
|
12
|
+
*
|
|
13
|
+
* The white plate is not decoration. A QR reader needs the quiet zone and the
|
|
14
|
+
* light module ground to decode, so the plate stays white in dark mode too and
|
|
15
|
+
* the padding is the quiet zone, not padding taste. Its hairline is a neutral
|
|
16
|
+
* alpha rather than `--border`: the plate is always white, so a themed border
|
|
17
|
+
* would put a dark ring on it in dark mode.
|
|
18
|
+
*
|
|
19
|
+
* The caption sits OUTSIDE the plate, on the sheet's own ground (T8), which is
|
|
20
|
+
* also what lets it use `--muted-foreground` instead of an ink picked to work
|
|
21
|
+
* on white.
|
|
22
|
+
*/
|
|
23
|
+
export type QrPanelProps = React.HTMLAttributes<HTMLDivElement> & {
|
|
24
|
+
/** The line under the code — "Scan with a phone camera to join". */
|
|
25
|
+
caption?: React.ReactNode;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
const QrPanel = React.forwardRef<HTMLDivElement, QrPanelProps>(
|
|
29
|
+
({ className, caption, children, ...props }, ref) => (
|
|
30
|
+
<div
|
|
31
|
+
ref={ref}
|
|
32
|
+
data-slot="qr-panel"
|
|
33
|
+
className={cn("flex flex-col items-center gap-3", className)}
|
|
34
|
+
{...props}
|
|
35
|
+
>
|
|
36
|
+
<div
|
|
37
|
+
data-slot="qr-panel-plate"
|
|
38
|
+
className="rounded-2xl border border-black/10 bg-white p-5"
|
|
39
|
+
>
|
|
40
|
+
<div data-slot="qr-panel-code" className="[&>*]:block [&>*]:size-full max-w-[220px]">
|
|
41
|
+
{children}
|
|
42
|
+
</div>
|
|
43
|
+
</div>
|
|
44
|
+
{caption ? (
|
|
45
|
+
<p className="text-center text-[12.5px] leading-normal text-muted-foreground">{caption}</p>
|
|
46
|
+
) : null}
|
|
47
|
+
</div>
|
|
48
|
+
)
|
|
49
|
+
);
|
|
50
|
+
QrPanel.displayName = "QrPanel";
|
|
51
|
+
|
|
52
|
+
export { QrPanel };
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/react";
|
|
2
|
+
import { ReconcileBar } from "./reconcile-bar";
|
|
3
|
+
|
|
4
|
+
const meta: Meta<typeof ReconcileBar> = {
|
|
5
|
+
title: "Shared table/ReconcileBar",
|
|
6
|
+
component: ReconcileBar,
|
|
7
|
+
tags: ["autodocs"],
|
|
8
|
+
parameters: {
|
|
9
|
+
docs: {
|
|
10
|
+
description: {
|
|
11
|
+
component:
|
|
12
|
+
"Does the split add up? The running figure and the target side by side, so the reader never does the subtraction. Figures are tabular so the bar does not twitch while someone types.",
|
|
13
|
+
},
|
|
14
|
+
},
|
|
15
|
+
},
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export default meta;
|
|
19
|
+
type Story = StoryObj<typeof ReconcileBar>;
|
|
20
|
+
|
|
21
|
+
/** T6 — the shares come to the line total exactly. */
|
|
22
|
+
export const Ok: Story = {
|
|
23
|
+
args: {
|
|
24
|
+
tone: "ok",
|
|
25
|
+
message: <b>The amounts add up</b>,
|
|
26
|
+
current: "120",
|
|
27
|
+
target: "120",
|
|
28
|
+
},
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
/** X10 — the shortfall is named in money, and Save is blocked until it balances. */
|
|
32
|
+
export const Short: Story = {
|
|
33
|
+
args: {
|
|
34
|
+
tone: "short",
|
|
35
|
+
message: (
|
|
36
|
+
<>
|
|
37
|
+
<b>10 lei short</b> of the item total
|
|
38
|
+
</>
|
|
39
|
+
),
|
|
40
|
+
current: "110",
|
|
41
|
+
target: "120",
|
|
42
|
+
},
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
/** An overshoot takes the same treatment — the bar says the figure, not the sign. */
|
|
46
|
+
export const Over: Story = {
|
|
47
|
+
args: {
|
|
48
|
+
tone: "short",
|
|
49
|
+
message: (
|
|
50
|
+
<>
|
|
51
|
+
<b>15 lei over</b> the item total
|
|
52
|
+
</>
|
|
53
|
+
),
|
|
54
|
+
current: "135",
|
|
55
|
+
target: "120",
|
|
56
|
+
},
|
|
57
|
+
};
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import { cva, type VariantProps } from "class-variance-authority";
|
|
3
|
+
import { Check, TriangleAlert } from "lucide-react";
|
|
4
|
+
import { cn } from "../lib/utils";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* ReconcileBar — does the split add up? The shared-table board puts it under
|
|
8
|
+
* every split, in both states (16-shared-table.html T6, X10): "The amounts add
|
|
9
|
+
* up · 120 / 120" on a green ground, "10 lei short of the item total ·
|
|
10
|
+
* 110 / 120" on the warning one.
|
|
11
|
+
*
|
|
12
|
+
* It is the whole answer to "can I save this", so it says the running figure
|
|
13
|
+
* and the target side by side and never makes the reader do the subtraction.
|
|
14
|
+
* The numbers are `tabular-nums`: they change on every keystroke in the custom
|
|
15
|
+
* mode, and proportional digits make the bar twitch while someone types.
|
|
16
|
+
*
|
|
17
|
+
* `message` is injected — the shortfall is money and money is formatted in the
|
|
18
|
+
* caller's locale, which the design system does not know.
|
|
19
|
+
*/
|
|
20
|
+
const reconcileBarVariants = cva(
|
|
21
|
+
"flex items-center gap-2.5 rounded-xl px-3.5 py-3 text-[12.5px] leading-normal [&>svg]:size-4 [&>svg]:shrink-0",
|
|
22
|
+
{
|
|
23
|
+
variants: {
|
|
24
|
+
tone: {
|
|
25
|
+
ok: "bg-success/15 text-success-ink",
|
|
26
|
+
// `--warning-deep` and `--warning-ink` are the tokens for exactly this:
|
|
27
|
+
// the solid `--warning` is tuned as a fill and falls short of AA as ink
|
|
28
|
+
// on its own wash. Both have dark twins, so there is no `dark:` branch.
|
|
29
|
+
short: "bg-warning-deep/15 text-warning-ink",
|
|
30
|
+
},
|
|
31
|
+
},
|
|
32
|
+
defaultVariants: { tone: "ok" },
|
|
33
|
+
}
|
|
34
|
+
);
|
|
35
|
+
|
|
36
|
+
export type ReconcileBarProps = Omit<React.HTMLAttributes<HTMLDivElement>, "children"> &
|
|
37
|
+
VariantProps<typeof reconcileBarVariants> & {
|
|
38
|
+
/**
|
|
39
|
+
* "The amounts add up", "**10 lei short** of the item total". The caller
|
|
40
|
+
* carries the weight — the shortfall is bold, the rest is not.
|
|
41
|
+
*/
|
|
42
|
+
message: React.ReactNode;
|
|
43
|
+
/** What the shares come to now, formatted by the caller. */
|
|
44
|
+
current: React.ReactNode;
|
|
45
|
+
/** What they have to come to — the item's line total. */
|
|
46
|
+
target: React.ReactNode;
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
const TONE_ICON = {
|
|
50
|
+
ok: <Check aria-hidden strokeWidth={3} />,
|
|
51
|
+
short: <TriangleAlert aria-hidden />,
|
|
52
|
+
} as const;
|
|
53
|
+
|
|
54
|
+
const ReconcileBar = React.forwardRef<HTMLDivElement, ReconcileBarProps>(
|
|
55
|
+
({ className, tone, message, current, target, ...props }, ref) => (
|
|
56
|
+
<div
|
|
57
|
+
ref={ref}
|
|
58
|
+
data-slot="reconcile-bar"
|
|
59
|
+
data-tone={tone ?? "ok"}
|
|
60
|
+
className={cn(reconcileBarVariants({ tone }), className)}
|
|
61
|
+
{...props}
|
|
62
|
+
>
|
|
63
|
+
{TONE_ICON[tone ?? "ok"]}
|
|
64
|
+
{/* Weight is the caller's: X10 draws "10 lei short" bold and "of the item
|
|
65
|
+
total" regular, which a blanket `font-semibold` here would flatten. */}
|
|
66
|
+
<span className="min-w-0 flex-1">{message}</span>
|
|
67
|
+
<span
|
|
68
|
+
data-slot="reconcile-bar-figures"
|
|
69
|
+
className="shrink-0 text-[12px] font-bold tabular-nums"
|
|
70
|
+
>
|
|
71
|
+
{current}
|
|
72
|
+
<span className="px-1.5 opacity-60">/</span>
|
|
73
|
+
{target}
|
|
74
|
+
</span>
|
|
75
|
+
</div>
|
|
76
|
+
)
|
|
77
|
+
);
|
|
78
|
+
ReconcileBar.displayName = "ReconcileBar";
|
|
79
|
+
|
|
80
|
+
export { ReconcileBar, reconcileBarVariants };
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import type { Meta, StoryObj } from "@storybook/react";
|
|
3
|
+
import { ActionBar } from "./action-bar";
|
|
4
|
+
import { Button } from "./button";
|
|
5
|
+
import { MenuPickRow } from "./menu-pick-row";
|
|
6
|
+
import { ResponsiveOverlay } from "./responsive-overlay";
|
|
7
|
+
|
|
8
|
+
const meta: Meta<typeof ResponsiveOverlay> = {
|
|
9
|
+
title: "Shared table/ResponsiveOverlay",
|
|
10
|
+
component: ResponsiveOverlay,
|
|
11
|
+
tags: ["autodocs"],
|
|
12
|
+
parameters: {
|
|
13
|
+
docs: {
|
|
14
|
+
description: {
|
|
15
|
+
component:
|
|
16
|
+
"One overlay authored once: a Drawer under md, a Dialog from md. The chrome is the same at both widths — title, one-line subtitle, round close, body, footer band. Resize the preview across 768px to see it change container. The scrim is `--scrim-overlay`, never `--scrim`.",
|
|
17
|
+
},
|
|
18
|
+
},
|
|
19
|
+
},
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export default meta;
|
|
23
|
+
type Story = StoryObj<typeof ResponsiveOverlay>;
|
|
24
|
+
|
|
25
|
+
/** T3 — add an item. */
|
|
26
|
+
export const AddAnItem: Story = {
|
|
27
|
+
render: () => {
|
|
28
|
+
const [open, setOpen] = React.useState(false);
|
|
29
|
+
return (
|
|
30
|
+
<>
|
|
31
|
+
<Button variant="cta" onClick={() => setOpen(true)}>
|
|
32
|
+
Add item
|
|
33
|
+
</Button>
|
|
34
|
+
<ResponsiveOverlay
|
|
35
|
+
open={open}
|
|
36
|
+
onOpenChange={setOpen}
|
|
37
|
+
title="Add an item"
|
|
38
|
+
subtitle="Table 18 · from the Sălcii Bistro menu"
|
|
39
|
+
closeLabel="Close"
|
|
40
|
+
footer={
|
|
41
|
+
<ActionBar
|
|
42
|
+
note="Added items can be assigned afterwards."
|
|
43
|
+
primary={<Button variant="cta">Add 1 item</Button>}
|
|
44
|
+
/>
|
|
45
|
+
}
|
|
46
|
+
>
|
|
47
|
+
<p className="mb-1 font-mono text-[11px] font-bold uppercase tracking-wider text-muted-foreground">
|
|
48
|
+
Main courses
|
|
49
|
+
</p>
|
|
50
|
+
<div className="divide-y divide-border">
|
|
51
|
+
<MenuPickRow label="Mușchi de vită la grătar" price="96 lei" addLabel="Add it" />
|
|
52
|
+
<MenuPickRow label="Tochitură ardelenească" price="54 lei" selected removeLabel="Remove it" />
|
|
53
|
+
<MenuPickRow label="Păstrăv la cuptor" price="62 lei" addLabel="Add it" />
|
|
54
|
+
</div>
|
|
55
|
+
</ResponsiveOverlay>
|
|
56
|
+
</>
|
|
57
|
+
);
|
|
58
|
+
},
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
/** T1 — an overlay with no footer note: the two actions divide the band. */
|
|
62
|
+
export const JoinTheTable: Story = {
|
|
63
|
+
render: () => {
|
|
64
|
+
const [open, setOpen] = React.useState(false);
|
|
65
|
+
return (
|
|
66
|
+
<>
|
|
67
|
+
<Button variant="cta" onClick={() => setOpen(true)}>
|
|
68
|
+
Open the join overlay
|
|
69
|
+
</Button>
|
|
70
|
+
<ResponsiveOverlay
|
|
71
|
+
open={open}
|
|
72
|
+
onOpenChange={setOpen}
|
|
73
|
+
title="Join this table"
|
|
74
|
+
subtitle="Table 18 · Sălcii Bistro"
|
|
75
|
+
size="sm"
|
|
76
|
+
closeLabel="Close"
|
|
77
|
+
footer={
|
|
78
|
+
<ActionBar
|
|
79
|
+
secondary={<Button variant="outline">Not now</Button>}
|
|
80
|
+
primary={<Button variant="cta">Join the table</Button>}
|
|
81
|
+
/>
|
|
82
|
+
}
|
|
83
|
+
>
|
|
84
|
+
<p className="text-[14px] text-muted-foreground">
|
|
85
|
+
Only the people at this table see it. No account needed.
|
|
86
|
+
</p>
|
|
87
|
+
</ResponsiveOverlay>
|
|
88
|
+
</>
|
|
89
|
+
);
|
|
90
|
+
},
|
|
91
|
+
};
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
3
|
+
import { render, screen, waitFor } from "@testing-library/react";
|
|
4
|
+
import userEvent from "@testing-library/user-event";
|
|
5
|
+
import { ActionBar } from "./action-bar";
|
|
6
|
+
import { ResponsiveOverlay } from "./responsive-overlay";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* jsdom has no matchMedia, so each test states the width it is testing. The
|
|
10
|
+
* component's own fallback (innerWidth) is covered by its own case below.
|
|
11
|
+
*/
|
|
12
|
+
function setWidth(wide: boolean) {
|
|
13
|
+
window.matchMedia = ((query: string) =>
|
|
14
|
+
({
|
|
15
|
+
matches: wide,
|
|
16
|
+
media: query,
|
|
17
|
+
onchange: null,
|
|
18
|
+
addEventListener: () => {},
|
|
19
|
+
removeEventListener: () => {},
|
|
20
|
+
addListener: () => {},
|
|
21
|
+
removeListener: () => {},
|
|
22
|
+
dispatchEvent: () => false,
|
|
23
|
+
}) as unknown as MediaQueryList) as typeof window.matchMedia;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const original = window.matchMedia;
|
|
27
|
+
beforeEach(() => setWidth(false));
|
|
28
|
+
afterEach(() => {
|
|
29
|
+
window.matchMedia = original;
|
|
30
|
+
vi.restoreAllMocks();
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
function open(props: Partial<React.ComponentProps<typeof ResponsiveOverlay>> = {}) {
|
|
34
|
+
return render(
|
|
35
|
+
<ResponsiveOverlay
|
|
36
|
+
open
|
|
37
|
+
onOpenChange={() => {}}
|
|
38
|
+
title="Add an item"
|
|
39
|
+
subtitle="Table 18 · from the menu"
|
|
40
|
+
closeLabel="Close"
|
|
41
|
+
{...props}
|
|
42
|
+
>
|
|
43
|
+
<p>body</p>
|
|
44
|
+
</ResponsiveOverlay>
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
describe("ResponsiveOverlay", () => {
|
|
49
|
+
it("is a drawer under md", () => {
|
|
50
|
+
open();
|
|
51
|
+
expect(document.querySelector('[data-slot="drawer-content"]')).toBeInTheDocument();
|
|
52
|
+
expect(document.querySelector('[data-slot="dialog-content"]')).toBeNull();
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("is a dialog from md", () => {
|
|
56
|
+
setWidth(true);
|
|
57
|
+
open();
|
|
58
|
+
expect(document.querySelector('[data-slot="dialog-content"]')).toBeInTheDocument();
|
|
59
|
+
expect(document.querySelector('[data-slot="drawer-content"]')).toBeNull();
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("carries the same chrome at both widths", () => {
|
|
63
|
+
for (const wide of [false, true]) {
|
|
64
|
+
setWidth(wide);
|
|
65
|
+
const view = open();
|
|
66
|
+
expect(screen.getByText("Add an item")).toBeInTheDocument();
|
|
67
|
+
expect(screen.getByText("Table 18 · from the menu")).toBeInTheDocument();
|
|
68
|
+
expect(screen.getByRole("button", { name: "Close" })).toBeInTheDocument();
|
|
69
|
+
view.unmount();
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("names the overlay by its title and describes it by its subtitle", () => {
|
|
74
|
+
setWidth(true);
|
|
75
|
+
open();
|
|
76
|
+
const dialog = screen.getByRole("dialog");
|
|
77
|
+
expect(dialog).toHaveAccessibleName("Add an item");
|
|
78
|
+
expect(dialog).toHaveAccessibleDescription("Table 18 · from the menu");
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("closes through the round button", async () => {
|
|
82
|
+
const onOpenChange = vi.fn();
|
|
83
|
+
open({ onOpenChange });
|
|
84
|
+
await userEvent.click(screen.getByRole("button", { name: "Close" }));
|
|
85
|
+
expect(onOpenChange).toHaveBeenCalledWith(false);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("dims with the overlay token, never the app-shadowed --scrim", () => {
|
|
89
|
+
setWidth(true);
|
|
90
|
+
open();
|
|
91
|
+
const scrim = document.querySelector('[data-slot="dialog-overlay"]');
|
|
92
|
+
const classes = (scrim?.className ?? "").split(" ");
|
|
93
|
+
expect(classes).toContain("bg-scrim-overlay");
|
|
94
|
+
// `--scrim` is an opaque photo hue in bearmenu-fe's globals.css, which
|
|
95
|
+
// loads after ours. A bare `bg-scrim` here would paint it over the page.
|
|
96
|
+
expect(classes).not.toContain("bg-scrim");
|
|
97
|
+
expect(classes).not.toContain("bg-black/50");
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("renders the footer band when one is given", () => {
|
|
101
|
+
open({
|
|
102
|
+
footer: <ActionBar primary={<button type="button">Add 1 item</button>} />,
|
|
103
|
+
});
|
|
104
|
+
expect(screen.getByRole("button", { name: "Add 1 item" })).toBeInTheDocument();
|
|
105
|
+
expect(document.querySelector('[data-slot="responsive-overlay-footer"]')).toBeInTheDocument();
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it("falls back to the phone variant when there is no matchMedia at all", () => {
|
|
109
|
+
// `useMediaQuery` is mobile-first: the server, the first client frame and
|
|
110
|
+
// an environment without matchMedia (jsdom) all get the drawer. Deterministic
|
|
111
|
+
// beats clever — a test that wants the dialog says so by mocking the query.
|
|
112
|
+
delete (window as { matchMedia?: unknown }).matchMedia;
|
|
113
|
+
open();
|
|
114
|
+
expect(document.querySelector('[data-slot="drawer-content"]')).toBeInTheDocument();
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it("closes on Escape", async () => {
|
|
118
|
+
setWidth(true);
|
|
119
|
+
const onOpenChange = vi.fn();
|
|
120
|
+
open({ onOpenChange });
|
|
121
|
+
await userEvent.keyboard("{Escape}");
|
|
122
|
+
expect(onOpenChange).toHaveBeenCalledWith(false);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it("returns focus to the control that opened it", async () => {
|
|
126
|
+
setWidth(true);
|
|
127
|
+
|
|
128
|
+
function Harness() {
|
|
129
|
+
const [isOpen, setIsOpen] = React.useState(false);
|
|
130
|
+
return (
|
|
131
|
+
<>
|
|
132
|
+
<button type="button" onClick={() => setIsOpen(true)}>
|
|
133
|
+
Add item
|
|
134
|
+
</button>
|
|
135
|
+
<ResponsiveOverlay
|
|
136
|
+
open={isOpen}
|
|
137
|
+
onOpenChange={setIsOpen}
|
|
138
|
+
title="Add an item"
|
|
139
|
+
closeLabel="Close"
|
|
140
|
+
>
|
|
141
|
+
<p>body</p>
|
|
142
|
+
</ResponsiveOverlay>
|
|
143
|
+
</>
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
render(<Harness />);
|
|
148
|
+
const opener = screen.getByRole("button", { name: "Add item" });
|
|
149
|
+
await userEvent.click(opener);
|
|
150
|
+
await userEvent.click(screen.getByRole("button", { name: "Close" }));
|
|
151
|
+
await waitFor(() => expect(opener).toHaveFocus());
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it("stays out of the DOM while closed", () => {
|
|
155
|
+
render(
|
|
156
|
+
<ResponsiveOverlay open={false} onOpenChange={() => {}} title="Add an item">
|
|
157
|
+
<p>body</p>
|
|
158
|
+
</ResponsiveOverlay>
|
|
159
|
+
);
|
|
160
|
+
expect(screen.queryByText("body")).toBeNull();
|
|
161
|
+
});
|
|
162
|
+
});
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import * as React from "react";
|
|
4
|
+
import { X } from "lucide-react";
|
|
5
|
+
import { useMediaQuery } from "../hooks/use-media-query";
|
|
6
|
+
import { cn } from "../lib/utils";
|
|
7
|
+
import { Dialog, DialogContent, DialogDescription, DialogTitle } from "./dialog";
|
|
8
|
+
import { Drawer, DrawerContent, DrawerDescription, DrawerTitle } from "./drawer";
|
|
9
|
+
|
|
10
|
+
/** The board switches container at Tailwind's `md`, not at the library's `lg`. */
|
|
11
|
+
const MD_MEDIA_QUERY = "(min-width: 768px)";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Sends focus back to whatever opened the overlay once it closes.
|
|
15
|
+
*
|
|
16
|
+
* Radix restores focus to its own `DialogTrigger`, and this overlay is
|
|
17
|
+
* controlled — the opener is an ordinary button somewhere else on the page, or
|
|
18
|
+
* a URL that was already carrying `?add=1` on arrival. Left to the primitives,
|
|
19
|
+
* focus lands on `document.body` and a keyboard user starts again from the top
|
|
20
|
+
* of the page. The shared table's brief asks for the opposite in as many words:
|
|
21
|
+
* "focus returns to the control that opened an overlay or a sheet".
|
|
22
|
+
*
|
|
23
|
+
* The opener is tracked while the overlay is CLOSED rather than captured when
|
|
24
|
+
* it opens: by the time an effect could run on the opening render, the dialog
|
|
25
|
+
* has already mounted and pulled focus into itself.
|
|
26
|
+
*/
|
|
27
|
+
function useFocusReturn(open: boolean) {
|
|
28
|
+
const openerRef = React.useRef<HTMLElement | null>(null);
|
|
29
|
+
const wasOpen = React.useRef(false);
|
|
30
|
+
|
|
31
|
+
React.useEffect(() => {
|
|
32
|
+
if (open) {
|
|
33
|
+
wasOpen.current = true;
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
if (wasOpen.current) {
|
|
38
|
+
wasOpen.current = false;
|
|
39
|
+
const opener = openerRef.current;
|
|
40
|
+
// The overlay is still unmounting on this tick, and it takes focus with
|
|
41
|
+
// it; restoring on the next frame lands after that.
|
|
42
|
+
if (opener && opener.isConnected) {
|
|
43
|
+
requestAnimationFrame(() => opener.focus());
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const track = (event: FocusEvent) => {
|
|
48
|
+
const target = event.target;
|
|
49
|
+
if (target instanceof HTMLElement) openerRef.current = target;
|
|
50
|
+
};
|
|
51
|
+
if (document.activeElement instanceof HTMLElement && document.activeElement !== document.body) {
|
|
52
|
+
openerRef.current = document.activeElement;
|
|
53
|
+
}
|
|
54
|
+
document.addEventListener("focusin", track);
|
|
55
|
+
return () => document.removeEventListener("focusin", track);
|
|
56
|
+
}, [open]);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export type ResponsiveOverlayProps = {
|
|
60
|
+
open: boolean;
|
|
61
|
+
onOpenChange: (open: boolean) => void;
|
|
62
|
+
/** The overlay's name — "Add an item", "Split this item", "Invite people". */
|
|
63
|
+
title: React.ReactNode;
|
|
64
|
+
/** The one line under it — "Table 18 · from the menu". Optional, never two lines. */
|
|
65
|
+
subtitle?: React.ReactNode;
|
|
66
|
+
/** The scrolling body. */
|
|
67
|
+
children: React.ReactNode;
|
|
68
|
+
/** The closing band — an `ActionBar`, usually. */
|
|
69
|
+
footer?: React.ReactNode;
|
|
70
|
+
/**
|
|
71
|
+
* The close button's accessible name. Injected, like every label here: the
|
|
72
|
+
* design system holds no copy and no i18n.
|
|
73
|
+
*/
|
|
74
|
+
closeLabel?: string;
|
|
75
|
+
/** Widths for the desktop dialog. Mobile is always the full-width drawer. */
|
|
76
|
+
size?: React.ComponentPropsWithoutRef<typeof DialogContent>["size"];
|
|
77
|
+
/** Classes for the panel itself — the dialog box or the drawer sheet. */
|
|
78
|
+
className?: string;
|
|
79
|
+
/** Classes for the scrolling body. */
|
|
80
|
+
bodyClassName?: string;
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* ResponsiveOverlay — one overlay authored once: a `Drawer` under md, a
|
|
85
|
+
* `Dialog` from md.
|
|
86
|
+
*
|
|
87
|
+
* The shared-table board draws the same chrome on T1, T3, T5, T6 and T8
|
|
88
|
+
* (16-shared-table.html) and the two widths never disagree about it: the
|
|
89
|
+
* title and its one-line subtitle top left, a round close top right, the body,
|
|
90
|
+
* and a footer band carrying the note and the actions. What differs is the
|
|
91
|
+
* container — a sheet that a thumb drags down, or a box in the middle of the
|
|
92
|
+
* screen — and that is the only thing this component switches.
|
|
93
|
+
*
|
|
94
|
+
* The scrim is `--scrim-overlay`. bearmenu-fe's `globals.css` loads after the
|
|
95
|
+
* design system's and defines a `--scrim` of its own — an opaque photo hue —
|
|
96
|
+
* so an overlay that reached for `bg-scrim` would paint that colour over the
|
|
97
|
+
* whole page at full alpha. The two drawer/dialog primitives still carry their
|
|
98
|
+
* older `bg-black/…`; this component states the token explicitly rather than
|
|
99
|
+
* changing theirs underneath every other caller.
|
|
100
|
+
*
|
|
101
|
+
* It resolves the width with `useMediaQuery`, which is mobile-first: the
|
|
102
|
+
* drawer is what the server and the first client frame render. A wide screen
|
|
103
|
+
* that arrives with the overlay already open — an invite link opened on a
|
|
104
|
+
* laptop — therefore shows the sheet for one frame before the dialog replaces
|
|
105
|
+
* it. That is the library's documented convention for every breakpoint-
|
|
106
|
+
* switching component, and a second breakpoint hook with different semantics
|
|
107
|
+
* would be a worse thing to own than one frame.
|
|
108
|
+
*/
|
|
109
|
+
const ResponsiveOverlay = React.forwardRef<HTMLDivElement, ResponsiveOverlayProps>(
|
|
110
|
+
(
|
|
111
|
+
{
|
|
112
|
+
open,
|
|
113
|
+
onOpenChange,
|
|
114
|
+
title,
|
|
115
|
+
subtitle,
|
|
116
|
+
children,
|
|
117
|
+
footer,
|
|
118
|
+
closeLabel = "Close",
|
|
119
|
+
size = "lg",
|
|
120
|
+
className,
|
|
121
|
+
bodyClassName,
|
|
122
|
+
},
|
|
123
|
+
ref
|
|
124
|
+
) => {
|
|
125
|
+
const isWide = useMediaQuery(MD_MEDIA_QUERY);
|
|
126
|
+
const titleId = React.useId();
|
|
127
|
+
const subtitleId = React.useId();
|
|
128
|
+
useFocusReturn(open);
|
|
129
|
+
|
|
130
|
+
const header = (
|
|
131
|
+
<div data-slot="responsive-overlay-header" className="flex items-start gap-3 px-5 pt-5">
|
|
132
|
+
<div className="min-w-0 flex-1">
|
|
133
|
+
{isWide ? (
|
|
134
|
+
<DialogTitle id={titleId} className="text-[19px] font-bold leading-tight">
|
|
135
|
+
{title}
|
|
136
|
+
</DialogTitle>
|
|
137
|
+
) : (
|
|
138
|
+
<DrawerTitle id={titleId} className="text-[19px] font-bold leading-tight">
|
|
139
|
+
{title}
|
|
140
|
+
</DrawerTitle>
|
|
141
|
+
)}
|
|
142
|
+
{subtitle ? (
|
|
143
|
+
isWide ? (
|
|
144
|
+
<DialogDescription id={subtitleId} className="mt-1 text-[13px] text-muted-foreground">
|
|
145
|
+
{subtitle}
|
|
146
|
+
</DialogDescription>
|
|
147
|
+
) : (
|
|
148
|
+
<DrawerDescription id={subtitleId} className="mt-1 text-[13px] text-muted-foreground">
|
|
149
|
+
{subtitle}
|
|
150
|
+
</DrawerDescription>
|
|
151
|
+
)
|
|
152
|
+
) : null}
|
|
153
|
+
</div>
|
|
154
|
+
<button
|
|
155
|
+
type="button"
|
|
156
|
+
data-slot="responsive-overlay-close"
|
|
157
|
+
onClick={() => onOpenChange(false)}
|
|
158
|
+
// 32px disc, 44px of tap target around it — the frames draw the disc,
|
|
159
|
+
// the accessibility floor asks for the target.
|
|
160
|
+
className="-m-1.5 grid size-11 shrink-0 place-items-center rounded-full text-muted-foreground transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
|
161
|
+
>
|
|
162
|
+
<span className="grid size-8 place-items-center rounded-full bg-surface-2 transition-colors hover:bg-muted">
|
|
163
|
+
<X className="size-4" aria-hidden />
|
|
164
|
+
</span>
|
|
165
|
+
<span className="sr-only">{closeLabel}</span>
|
|
166
|
+
</button>
|
|
167
|
+
</div>
|
|
168
|
+
);
|
|
169
|
+
|
|
170
|
+
const body = (
|
|
171
|
+
<div
|
|
172
|
+
data-slot="responsive-overlay-body"
|
|
173
|
+
className={cn("min-h-0 flex-1 overflow-y-auto px-5 py-4", bodyClassName)}
|
|
174
|
+
>
|
|
175
|
+
{children}
|
|
176
|
+
</div>
|
|
177
|
+
);
|
|
178
|
+
|
|
179
|
+
const foot = footer ? (
|
|
180
|
+
<div
|
|
181
|
+
data-slot="responsive-overlay-footer"
|
|
182
|
+
className="shrink-0 border-t border-border bg-surface-2"
|
|
183
|
+
>
|
|
184
|
+
{footer}
|
|
185
|
+
</div>
|
|
186
|
+
) : null;
|
|
187
|
+
|
|
188
|
+
if (isWide) {
|
|
189
|
+
return (
|
|
190
|
+
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
191
|
+
<DialogContent
|
|
192
|
+
ref={ref}
|
|
193
|
+
size={size}
|
|
194
|
+
showCloseButton={false}
|
|
195
|
+
overlayClassName="bg-scrim-overlay backdrop-blur-none"
|
|
196
|
+
aria-labelledby={titleId}
|
|
197
|
+
aria-describedby={subtitle ? subtitleId : undefined}
|
|
198
|
+
className={cn(
|
|
199
|
+
// The dialog's own `grid gap-4 p-6` would put air between the
|
|
200
|
+
// header, the body and the band the frames draw flush.
|
|
201
|
+
"flex max-h-[85vh] flex-col gap-0 overflow-hidden p-0",
|
|
202
|
+
className
|
|
203
|
+
)}
|
|
204
|
+
>
|
|
205
|
+
{header}
|
|
206
|
+
{body}
|
|
207
|
+
{foot}
|
|
208
|
+
</DialogContent>
|
|
209
|
+
</Dialog>
|
|
210
|
+
);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
return (
|
|
214
|
+
<Drawer open={open} onOpenChange={onOpenChange}>
|
|
215
|
+
<DrawerContent
|
|
216
|
+
ref={ref}
|
|
217
|
+
overlayClassName="bg-scrim-overlay"
|
|
218
|
+
aria-labelledby={titleId}
|
|
219
|
+
aria-describedby={subtitle ? subtitleId : undefined}
|
|
220
|
+
className={cn("flex max-h-[85vh] flex-col p-0", className)}
|
|
221
|
+
>
|
|
222
|
+
{header}
|
|
223
|
+
{body}
|
|
224
|
+
{foot}
|
|
225
|
+
</DrawerContent>
|
|
226
|
+
</Drawer>
|
|
227
|
+
);
|
|
228
|
+
}
|
|
229
|
+
);
|
|
230
|
+
ResponsiveOverlay.displayName = "ResponsiveOverlay";
|
|
231
|
+
|
|
232
|
+
export { ResponsiveOverlay };
|