@bearmenu/ui 0.8.16 → 0.8.17
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-ZLPWEHZO.js → chunk-54FQQZZJ.js} +1 -1
- package/dist/{chunk-36KEHCET.js → chunk-5LTILG3L.js} +15 -4
- package/dist/{chunk-P43B3ONY.js → chunk-5XREWOBB.js} +3 -1
- package/dist/{chunk-CLLY6XB3.js → chunk-6VOWT4YR.js} +6 -3
- package/dist/{chunk-S33K4RC5.js → chunk-BKKQ7GCZ.js} +1 -1
- package/dist/chunk-QY66ME5B.js +207 -0
- package/dist/{chunk-T6S2ZBV6.js → chunk-SDWN6L4O.js} +1 -1
- package/dist/{chunk-CNJQUHCU.js → chunk-SWD6CJ35.js} +2 -1
- package/dist/{chunk-HDR7V6IZ.js → chunk-TIQNM4XD.js} +8 -1
- 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/button.d.ts +1 -1
- 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.d.ts +63 -0
- package/dist/components/coach-marks.js +162 -0
- package/dist/components/combobox.js +1 -1
- package/dist/components/concept-compare-rail.js +2 -2
- package/dist/components/concept-compare.js +2 -2
- package/dist/components/concept-convergence.js +2 -2
- package/dist/components/doorway-tiles.d.ts +11 -0
- package/dist/components/doorway-tiles.js +5 -3
- package/dist/components/empty-state.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 +2 -2
- package/dist/components/event-list-row.js +1 -1
- package/dist/components/facet-group.d.ts +8 -1
- package/dist/components/facet-group.js +3 -0
- package/dist/components/filter-category-row.js +1 -1
- package/dist/components/hue-chip.js +1 -1
- package/dist/components/item.js +4 -1
- package/dist/components/kitchen-dossier.js +1 -1
- package/dist/components/link-button.js +1 -1
- package/dist/components/menu-family-block.d.ts +8 -1
- package/dist/components/menu-family-block.js +101 -68
- package/dist/components/menu-family-card.js +2 -2
- package/dist/components/menu-filter-chips.d.ts +12 -3
- package/dist/components/menu-filter-chips.js +22 -4
- package/dist/components/menu-index-list.js +1 -1
- package/dist/components/menu-item.js +3 -3
- package/dist/components/menu-kitchen-list.d.ts +14 -2
- package/dist/components/menu-kitchen-list.js +1 -1
- package/dist/components/multi-select-combobox.js +2 -2
- package/dist/components/pagination.js +1 -1
- package/dist/components/photo-ground.d.ts +7 -1
- package/dist/components/photo-ground.js +1 -1
- package/dist/components/place-card.js +1 -1
- package/dist/components/place-details-mobile.js +1 -1
- package/dist/components/searchable-select.js +1 -1
- package/dist/components/section-header.d.ts +7 -1
- package/dist/components/section-header.js +11 -7
- package/dist/components/signature-chip.js +2 -2
- package/dist/components/sliding-panels.js +1 -1
- package/dist/components/toggle-group.js +9 -4
- 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/package.json +1 -1
- package/src/components/badge.stories.tsx +31 -1
- package/src/components/badge.tsx +2 -0
- package/src/components/button.stories.tsx +5 -4
- package/src/components/button.test.tsx +11 -1
- package/src/components/button.tsx +16 -5
- package/src/components/coach-marks.stories.tsx +79 -0
- package/src/components/coach-marks.test.tsx +140 -0
- package/src/components/coach-marks.tsx +252 -0
- package/src/components/doorway-tiles.stories.tsx +34 -0
- package/src/components/doorway-tiles.test.tsx +27 -0
- package/src/components/doorway-tiles.tsx +30 -2
- package/src/components/facet-group.tsx +9 -0
- package/src/components/hue-chip.test.tsx +6 -3
- package/src/components/hue-chip.tsx +5 -2
- package/src/components/item.tsx +4 -1
- package/src/components/menu-family-block.stories.tsx +12 -0
- package/src/components/menu-family-block.tsx +53 -20
- package/src/components/menu-filter-chips.tsx +32 -4
- package/src/components/menu-kitchen-list.tsx +45 -6
- package/src/components/photo-ground.tsx +7 -0
- package/src/components/section-header.test.tsx +15 -0
- package/src/components/section-header.tsx +26 -12
- package/src/components/toggle-group.stories.tsx +37 -1
- package/src/components/toggle-group.tsx +11 -6
- package/src/components/toggle.tsx +8 -0
- package/dist/chunk-SXQZ7DLH.js +0 -167
|
@@ -36,6 +36,12 @@ import * as react_jsx_runtime from 'react/jsx-runtime';
|
|
|
36
36
|
*/
|
|
37
37
|
/** The ground under the photography. Fixed, and deliberately not a token. */
|
|
38
38
|
declare const PHOTO_GROUND = "bg-[oklch(0.21_0.012_55)]";
|
|
39
|
+
/**
|
|
40
|
+
* The shadow a glyph wears when it stands on `PHOTO_GROUND` in a photograph's
|
|
41
|
+
* place — the card family's fallback treatment: a ground and a lifted glyph,
|
|
42
|
+
* never a bare icon on a flat fill. Same literal family as the ground.
|
|
43
|
+
*/
|
|
44
|
+
declare const PHOTO_GLYPH_SHADOW = "drop-shadow-[0_2px_6px_oklch(0.16_0.012_55_/_0.6)]";
|
|
39
45
|
type PhotoGroundProps = {
|
|
40
46
|
/** One per slot. An absent one draws the slot's initial on the ground. */
|
|
41
47
|
photos: (string | undefined)[];
|
|
@@ -67,4 +73,4 @@ type PhotoGroundProps = {
|
|
|
67
73
|
};
|
|
68
74
|
declare function PhotoGround({ photos, initials, mode, active, mosaicClassName, phoneTiles, gradient, overlay, overlayClassName, loading, glyphClassName, className, }: PhotoGroundProps): react_jsx_runtime.JSX.Element;
|
|
69
75
|
|
|
70
|
-
export { PHOTO_GROUND, PhotoGround, type PhotoGroundProps };
|
|
76
|
+
export { PHOTO_GLYPH_SHADOW, PHOTO_GROUND, PhotoGround, type PhotoGroundProps };
|
|
@@ -6,7 +6,7 @@ import { showsGradeAtom } from '../chunk-I5V446BX.js';
|
|
|
6
6
|
import { Separator } from '../chunk-NWZGSLPU.js';
|
|
7
7
|
import { springSnappy } from '../chunk-OIV7B44M.js';
|
|
8
8
|
import { Skeleton } from '../chunk-XBBEX4SF.js';
|
|
9
|
-
import { Badge } from '../chunk-
|
|
9
|
+
import { Badge } from '../chunk-5XREWOBB.js';
|
|
10
10
|
import { cn } from '../chunk-FEK5XGZW.js';
|
|
11
11
|
import { __objRest, __spreadValues } from '../chunk-MMUBH76A.js';
|
|
12
12
|
import { forwardRef, memo, useState, useMemo } from 'react';
|
|
@@ -3,7 +3,7 @@ import '../chunk-FMTJGO76.js';
|
|
|
3
3
|
import '../chunk-MWB7S54O.js';
|
|
4
4
|
import '../chunk-NWZGSLPU.js';
|
|
5
5
|
import { Text } from '../chunk-ZLMRVBD7.js';
|
|
6
|
-
import { Badge } from '../chunk-
|
|
6
|
+
import { Badge } from '../chunk-5XREWOBB.js';
|
|
7
7
|
import { cn } from '../chunk-FEK5XGZW.js';
|
|
8
8
|
import '../chunk-MMUBH76A.js';
|
|
9
9
|
import { useState } from 'react';
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { Popover, PopoverTrigger, PopoverContent } from '../chunk-2ADCZXZJ.js';
|
|
2
2
|
import { Command, CommandInput, CommandList, CommandEmpty, CommandGroup, CommandItem } from '../chunk-MCOHLWRS.js';
|
|
3
3
|
import '../chunk-PDTPO6NG.js';
|
|
4
|
-
import { Button } from '../chunk-
|
|
4
|
+
import { Button } from '../chunk-5LTILG3L.js';
|
|
5
5
|
import '../chunk-RIH2IS7X.js';
|
|
6
6
|
import '../chunk-5YNIQ2BL.js';
|
|
7
7
|
import { cn } from '../chunk-FEK5XGZW.js';
|
|
@@ -50,9 +50,15 @@ type SectionHeaderProps = {
|
|
|
50
50
|
* would dilute the headings that matter. Defaults to `"h2"`.
|
|
51
51
|
*/
|
|
52
52
|
as?: "h1" | "h2" | "h3" | "h4" | "h5" | "h6" | "p" | "div";
|
|
53
|
+
/**
|
|
54
|
+
* Controls at the end of the title row, after `action` — a rail's
|
|
55
|
+
* previous / next pair on desktop. The header owns none of their
|
|
56
|
+
* behaviour; it only holds the slot.
|
|
57
|
+
*/
|
|
58
|
+
trailing?: React.ReactNode;
|
|
53
59
|
className?: string;
|
|
54
60
|
};
|
|
55
|
-
declare function SectionHeader({ title, count, subtitle, action, actionHref, actionTone, subtitleFrom, linkComponent: LinkComponent, onAction, as: Heading, className, }: SectionHeaderProps): react_jsx_runtime.JSX.Element;
|
|
61
|
+
declare function SectionHeader({ title, count, subtitle, action, actionHref, actionTone, subtitleFrom, linkComponent: LinkComponent, onAction, as: Heading, trailing, className, }: SectionHeaderProps): react_jsx_runtime.JSX.Element;
|
|
56
62
|
type SectionHeaderSkeletonProps = {
|
|
57
63
|
/** Shows the inline count placeholder, matching `SectionHeaderProps["count"]`. */
|
|
58
64
|
showCount?: boolean;
|
|
@@ -31,6 +31,7 @@ function SectionHeader({
|
|
|
31
31
|
linkComponent: LinkComponent = DefaultLink,
|
|
32
32
|
onAction,
|
|
33
33
|
as: Heading = "h2",
|
|
34
|
+
trailing,
|
|
34
35
|
className
|
|
35
36
|
}) {
|
|
36
37
|
const actionIcon = /* @__PURE__ */ jsx(ChevronRight, { className: "h-3.5 w-3.5", "aria-hidden": true });
|
|
@@ -41,13 +42,16 @@ function SectionHeader({
|
|
|
41
42
|
/* @__PURE__ */ jsx(Heading, { className: "truncate text-[17px] font-bold tracking-[-0.01em] text-foreground md:text-[18px]", children: title }),
|
|
42
43
|
count !== void 0 && /* @__PURE__ */ jsx("span", { className: "ml-1.5 shrink-0 text-[12px] tabular-nums text-muted-foreground lg:ml-0.5", children: count })
|
|
43
44
|
] }),
|
|
44
|
-
action
|
|
45
|
-
action,
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
45
|
+
(action || trailing) && /* @__PURE__ */ jsxs("div", { className: "flex shrink-0 items-center gap-3", children: [
|
|
46
|
+
action && (actionHref ? /* @__PURE__ */ jsxs(LinkComponent, { href: actionHref, className: actionClasses, onClick: onAction, children: [
|
|
47
|
+
action,
|
|
48
|
+
actionIcon
|
|
49
|
+
] }) : onAction ? /* @__PURE__ */ jsxs("button", { type: "button", className: actionClasses, onClick: onAction, children: [
|
|
50
|
+
action,
|
|
51
|
+
actionIcon
|
|
52
|
+
] }) : null),
|
|
53
|
+
trailing
|
|
54
|
+
] })
|
|
51
55
|
] }),
|
|
52
56
|
subtitle && /* @__PURE__ */ jsx(
|
|
53
57
|
"p",
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { toggleVariants } from '../chunk-
|
|
1
|
+
import { toggleVariants } from '../chunk-TIQNM4XD.js';
|
|
2
2
|
import { cn } from '../chunk-FEK5XGZW.js';
|
|
3
3
|
import { __objRest, __spreadProps, __spreadValues } from '../chunk-MMUBH76A.js';
|
|
4
4
|
import * as React from 'react';
|
|
@@ -29,6 +29,9 @@ function ToggleGroup(_a) {
|
|
|
29
29
|
"data-size": size,
|
|
30
30
|
className: cn(
|
|
31
31
|
"group/toggle-group flex w-fit items-center rounded-md data-[variant=outline]:shadow-xs",
|
|
32
|
+
// The segmented control's capsule: muted ground, the on cell a raised
|
|
33
|
+
// card inside it (see `toggleVariants.segmented`). 44px tall.
|
|
34
|
+
"data-[variant=segmented]:gap-0.5 data-[variant=segmented]:rounded-full data-[variant=segmented]:bg-muted data-[variant=segmented]:p-0.5",
|
|
32
35
|
className
|
|
33
36
|
)
|
|
34
37
|
}, props), {
|
|
@@ -49,18 +52,20 @@ function ToggleGroupItem(_a) {
|
|
|
49
52
|
"size"
|
|
50
53
|
]);
|
|
51
54
|
const context = React.useContext(ToggleGroupContext);
|
|
55
|
+
const resolved = context.variant || variant;
|
|
52
56
|
return /* @__PURE__ */ jsx(
|
|
53
57
|
ToggleGroupPrimitive.Item,
|
|
54
58
|
__spreadProps(__spreadValues({
|
|
55
59
|
"data-slot": "toggle-group-item",
|
|
56
|
-
"data-variant":
|
|
60
|
+
"data-variant": resolved,
|
|
57
61
|
"data-size": context.size || size,
|
|
58
62
|
className: cn(
|
|
59
63
|
toggleVariants({
|
|
60
|
-
variant:
|
|
64
|
+
variant: resolved,
|
|
61
65
|
size: context.size || size
|
|
62
66
|
}),
|
|
63
|
-
"min-w-0 flex-1 shrink-0
|
|
67
|
+
"min-w-0 flex-1 shrink-0 shadow-none focus:z-10 focus-visible:z-10 data-[variant=outline]:border-l-0 data-[variant=outline]:first:border-l",
|
|
68
|
+
resolved === "segmented" ? "rounded-full px-0 data-[state=on]:shadow-[var(--shadow-card)]" : "rounded-none first:rounded-l-md last:rounded-r-md",
|
|
64
69
|
className
|
|
65
70
|
)
|
|
66
71
|
}, props), {
|
|
@@ -5,7 +5,7 @@ import * as TogglePrimitive from '@radix-ui/react-toggle';
|
|
|
5
5
|
import { VariantProps } from 'class-variance-authority';
|
|
6
6
|
|
|
7
7
|
declare const toggleVariants: (props?: ({
|
|
8
|
-
variant?: "default" | "outline" | null | undefined;
|
|
8
|
+
variant?: "default" | "outline" | "segmented" | null | undefined;
|
|
9
9
|
size?: "default" | "sm" | "lg" | null | undefined;
|
|
10
10
|
} & class_variance_authority_types.ClassProp) | undefined) => string;
|
|
11
11
|
declare function Toggle({ className, variant, size, ...props }: React.ComponentProps<typeof TogglePrimitive.Root> & VariantProps<typeof toggleVariants>): react_jsx_runtime.JSX.Element;
|
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
import { MenuItem } from '../chunk-
|
|
2
|
-
import '../chunk-
|
|
1
|
+
import { MenuItem } from '../chunk-SDWN6L4O.js';
|
|
2
|
+
import '../chunk-BKKQ7GCZ.js';
|
|
3
3
|
import { Separator } from '../chunk-NWZGSLPU.js';
|
|
4
4
|
import { Skeleton } from '../chunk-XBBEX4SF.js';
|
|
5
|
-
import '../chunk-
|
|
5
|
+
import '../chunk-5XREWOBB.js';
|
|
6
6
|
import { cn } from '../chunk-FEK5XGZW.js';
|
|
7
7
|
import { __objRest, __spreadValues } from '../chunk-MMUBH76A.js';
|
|
8
8
|
import { MapPin, Star, MessageSquareQuote } from 'lucide-react';
|
package/package.json
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { Meta, StoryObj } from "@storybook/react";
|
|
2
2
|
import { Badge } from "./badge";
|
|
3
|
+
import { Button } from "./button";
|
|
3
4
|
|
|
4
5
|
const meta = {
|
|
5
6
|
title: "Components/Badge",
|
|
@@ -8,7 +9,18 @@ const meta = {
|
|
|
8
9
|
argTypes: {
|
|
9
10
|
variant: {
|
|
10
11
|
control: "select",
|
|
11
|
-
options: [
|
|
12
|
+
options: [
|
|
13
|
+
"default",
|
|
14
|
+
"secondary",
|
|
15
|
+
"destructive",
|
|
16
|
+
"outline",
|
|
17
|
+
"overlay",
|
|
18
|
+
"glass",
|
|
19
|
+
"success",
|
|
20
|
+
"warning",
|
|
21
|
+
"brown",
|
|
22
|
+
"onPrimary",
|
|
23
|
+
],
|
|
12
24
|
},
|
|
13
25
|
},
|
|
14
26
|
} satisfies Meta<typeof Badge>;
|
|
@@ -44,6 +56,18 @@ export const Warning: Story = {
|
|
|
44
56
|
args: { children: "Warning", variant: "warning" },
|
|
45
57
|
};
|
|
46
58
|
|
|
59
|
+
/** The count inside a filled button: a translucent step on the primary fill. */
|
|
60
|
+
export const OnPrimary: Story = {
|
|
61
|
+
render: () => (
|
|
62
|
+
<Button size="pill">
|
|
63
|
+
Filters
|
|
64
|
+
<Badge as="span" variant="onPrimary">
|
|
65
|
+
3
|
|
66
|
+
</Badge>
|
|
67
|
+
</Button>
|
|
68
|
+
),
|
|
69
|
+
};
|
|
70
|
+
|
|
47
71
|
export const AllVariants: Story = {
|
|
48
72
|
render: () => (
|
|
49
73
|
<div className="flex flex-wrap items-center gap-3">
|
|
@@ -55,6 +79,12 @@ export const AllVariants: Story = {
|
|
|
55
79
|
<Badge variant="glass">Glass</Badge>
|
|
56
80
|
<Badge variant="success">Success</Badge>
|
|
57
81
|
<Badge variant="warning">Warning</Badge>
|
|
82
|
+
<Badge variant="brown">Brown</Badge>
|
|
83
|
+
<span className="rounded-lg bg-primary p-1.5">
|
|
84
|
+
<Badge as="span" variant="onPrimary">
|
|
85
|
+
On primary
|
|
86
|
+
</Badge>
|
|
87
|
+
</span>
|
|
58
88
|
</div>
|
|
59
89
|
),
|
|
60
90
|
};
|
package/src/components/badge.tsx
CHANGED
|
@@ -21,6 +21,8 @@ const badgeVariants = cva(
|
|
|
21
21
|
warning:
|
|
22
22
|
"border-transparent bg-warning text-warning-foreground",
|
|
23
23
|
brown: "border-transparent bg-brown text-brown-foreground",
|
|
24
|
+
/** A count sitting INSIDE a primary-filled control: its own ink, lifted a notch. */
|
|
25
|
+
onPrimary: "border-transparent bg-primary-foreground/20 text-primary-foreground",
|
|
24
26
|
},
|
|
25
27
|
// v1.0 motion: "live" surface signal — applies `live-pulse` archetype.
|
|
26
28
|
// Pair with a colored variant (e.g. variant="destructive" live).
|
|
@@ -18,9 +18,9 @@ const meta = {
|
|
|
18
18
|
"link",
|
|
19
19
|
"glass",
|
|
20
20
|
"brown",
|
|
21
|
-
"antagonist",
|
|
22
21
|
"overlay",
|
|
23
22
|
"ink",
|
|
23
|
+
"warning",
|
|
24
24
|
],
|
|
25
25
|
},
|
|
26
26
|
size: {
|
|
@@ -86,8 +86,9 @@ export const Brown: Story = {
|
|
|
86
86
|
args: { children: "Brown", variant: "brown" },
|
|
87
87
|
};
|
|
88
88
|
|
|
89
|
-
|
|
90
|
-
|
|
89
|
+
/** A control that is ON in a mode the reader should notice — a guide while it runs. */
|
|
90
|
+
export const Warning: Story = {
|
|
91
|
+
args: { children: "Guide · 2 of 6", variant: "warning", size: "pill" },
|
|
91
92
|
};
|
|
92
93
|
|
|
93
94
|
export const Small: Story = {
|
|
@@ -121,7 +122,7 @@ export const AllVariants: Story = {
|
|
|
121
122
|
<Button variant="link">Link</Button>
|
|
122
123
|
<Button variant="glass">Glass</Button>
|
|
123
124
|
<Button variant="brown">Brown</Button>
|
|
124
|
-
<Button variant="
|
|
125
|
+
<Button variant="warning">Warning</Button>
|
|
125
126
|
</div>
|
|
126
127
|
),
|
|
127
128
|
};
|
|
@@ -22,7 +22,17 @@ describe("buttonVariants", () => {
|
|
|
22
22
|
expect(buttonVariants({ variant: "link" })).toContain("text-primary");
|
|
23
23
|
expect(buttonVariants({ variant: "glass" })).toContain("glass");
|
|
24
24
|
expect(buttonVariants({ variant: "brown" })).toContain("bg-brown");
|
|
25
|
-
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
// The quiet variants step the surface on hover; none of them inverts to
|
|
28
|
+
// the brand fill, which would read as a second primary on every row.
|
|
29
|
+
it("outline, secondary and ghost tint on hover and never fill with the accent", () => {
|
|
30
|
+
for (const variant of ["outline", "secondary", "ghost"] as const) {
|
|
31
|
+
const classes = buttonVariants({ variant });
|
|
32
|
+
expect(classes).toContain("hover:bg-accent-subtle");
|
|
33
|
+
expect(classes).not.toContain("hover:bg-accent ");
|
|
34
|
+
expect(classes).not.toContain("hover:text-accent-foreground");
|
|
35
|
+
}
|
|
26
36
|
});
|
|
27
37
|
|
|
28
38
|
it("maps size prop to correct classes", () => {
|
|
@@ -15,11 +15,21 @@ const buttonVariants = cva(
|
|
|
15
15
|
"bg-primary text-primary-foreground shadow-sm hover:bg-primary/90 hover:shadow",
|
|
16
16
|
destructive:
|
|
17
17
|
"bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/85 hover:shadow",
|
|
18
|
+
/**
|
|
19
|
+
* The quiet variants tint on hover, they do not invert. `--accent`
|
|
20
|
+
* here is the brand brown, so the shadcn default (`hover:bg-accent`,
|
|
21
|
+
* written for an accent that is a pale grey) turned every outline,
|
|
22
|
+
* ghost and secondary button into a filled brown block the moment the
|
|
23
|
+
* pointer touched it — a second primary on every row. The standard
|
|
24
|
+
* (shadcn on its own tokens, Radix, Polaris, Material) is a step of the
|
|
25
|
+
* surface: the fill darkens a notch, the border firms up, the type
|
|
26
|
+
* stays put. `--accent-subtle` is that step; `active:` goes one more.
|
|
27
|
+
*/
|
|
18
28
|
outline:
|
|
19
|
-
"border border-border bg-background hover:bg-accent hover:
|
|
29
|
+
"border border-border bg-background hover:bg-accent-subtle hover:border-accent/40 active:bg-muted",
|
|
20
30
|
secondary:
|
|
21
|
-
"bg-secondary text-secondary-foreground shadow-sm hover:bg-accent hover:
|
|
22
|
-
ghost: "hover:bg-accent
|
|
31
|
+
"bg-secondary text-secondary-foreground shadow-sm border border-border hover:bg-accent-subtle hover:border-accent/40 active:bg-muted",
|
|
32
|
+
ghost: "hover:bg-accent-subtle active:bg-muted",
|
|
23
33
|
link: "text-primary underline-offset-4 hover:underline",
|
|
24
34
|
glass: "glass glass-hover text-foreground",
|
|
25
35
|
/**
|
|
@@ -49,8 +59,9 @@ const buttonVariants = cva(
|
|
|
49
59
|
ink: "border border-white/20 bg-white/[0.16] text-white hover:bg-white/25 focus-visible:ring-white/70 focus-visible:ring-offset-0",
|
|
50
60
|
brown:
|
|
51
61
|
"bg-brown text-brown-foreground shadow-sm hover:bg-brown/85 hover:shadow",
|
|
52
|
-
|
|
53
|
-
|
|
62
|
+
/** An ON state that is not the primary — a toggled mode, a guide running. */
|
|
63
|
+
warning:
|
|
64
|
+
"bg-warning text-warning-foreground shadow-sm hover:bg-warning/90 hover:shadow",
|
|
54
65
|
},
|
|
55
66
|
size: {
|
|
56
67
|
xs: "h-8 rounded-lg px-3 text-xs [&_svg]:size-3.5",
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { useState } from "react";
|
|
2
|
+
import type { Meta, StoryObj } from "@storybook/react";
|
|
3
|
+
import { CoachMarks, type CoachMarkStop } from "./coach-marks";
|
|
4
|
+
import { Button } from "./button";
|
|
5
|
+
|
|
6
|
+
const meta: Meta<typeof CoachMarks> = {
|
|
7
|
+
title: "Overlays/CoachMarks",
|
|
8
|
+
component: CoachMarks,
|
|
9
|
+
tags: ["autodocs"],
|
|
10
|
+
parameters: {
|
|
11
|
+
layout: "fullscreen",
|
|
12
|
+
docs: {
|
|
13
|
+
description: {
|
|
14
|
+
component:
|
|
15
|
+
"A guided tour drawn over the real page: the page dims, the current step's target is cut out in a spotlight with its step number on the corner, one callout says what the target is. Targets are `data-coach-mark` attributes on the page's own elements.",
|
|
16
|
+
},
|
|
17
|
+
},
|
|
18
|
+
},
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export default meta;
|
|
22
|
+
type Story = StoryObj<typeof CoachMarks>;
|
|
23
|
+
|
|
24
|
+
const STOPS: CoachMarkStop[] = [
|
|
25
|
+
{
|
|
26
|
+
id: "search",
|
|
27
|
+
title: "One place to search",
|
|
28
|
+
body: "Type and tick in the same sheet. Nothing filters from the page itself, so there is only ever one thing open.",
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
id: "receipt",
|
|
32
|
+
title: "Your search, as a receipt",
|
|
33
|
+
body: "Whatever is applied prints here in the order it was added. Each chip's X takes it off again.",
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
id: "view",
|
|
37
|
+
title: "Cards, or rows",
|
|
38
|
+
body: "The same menu items either way: rows fit three times as many.",
|
|
39
|
+
},
|
|
40
|
+
];
|
|
41
|
+
|
|
42
|
+
function Demo() {
|
|
43
|
+
const [open, setOpen] = useState(true);
|
|
44
|
+
return (
|
|
45
|
+
<div className="min-h-[720px] bg-background p-8 text-foreground">
|
|
46
|
+
<div className="flex items-center gap-3">
|
|
47
|
+
<Button data-coach-mark="search">Search and filter</Button>
|
|
48
|
+
<Button variant="outline" data-coach-mark="view">
|
|
49
|
+
Rows
|
|
50
|
+
</Button>
|
|
51
|
+
<Button variant="outline" onClick={() => setOpen(true)}>
|
|
52
|
+
Guide
|
|
53
|
+
</Button>
|
|
54
|
+
</div>
|
|
55
|
+
<div data-coach-mark="receipt" className="mt-6 inline-flex gap-2 rounded-full border border-border px-3 py-2 text-sm">
|
|
56
|
+
<span>20–40 lei ×</span>
|
|
57
|
+
<span>Centru ×</span>
|
|
58
|
+
</div>
|
|
59
|
+
<p className="mt-10 max-w-[60ch] text-sm text-muted-foreground">
|
|
60
|
+
The page beneath the tour. Nothing here re-renders; the overlay measures the three targets
|
|
61
|
+
and draws over them.
|
|
62
|
+
</p>
|
|
63
|
+
<CoachMarks
|
|
64
|
+
open={open}
|
|
65
|
+
stops={STOPS}
|
|
66
|
+
onClose={() => setOpen(false)}
|
|
67
|
+
labels={{
|
|
68
|
+
progress: (n, total) => `Step ${n} of ${total}`,
|
|
69
|
+
back: "Back",
|
|
70
|
+
next: "Next",
|
|
71
|
+
done: "Done",
|
|
72
|
+
skip: "Skip the tour",
|
|
73
|
+
}}
|
|
74
|
+
/>
|
|
75
|
+
</div>
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export const Default: Story = { render: () => <Demo /> };
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { fireEvent, render, screen } from "@testing-library/react";
|
|
3
|
+
import { CoachMarks, type CoachMarkStop } from "./coach-marks";
|
|
4
|
+
|
|
5
|
+
const STOPS: CoachMarkStop[] = [
|
|
6
|
+
{ id: "a", title: "First", body: "The first thing." },
|
|
7
|
+
{ id: "b", title: "Second", body: "The second thing." },
|
|
8
|
+
{ id: "c", title: "Third", body: "The third thing." },
|
|
9
|
+
];
|
|
10
|
+
|
|
11
|
+
const labels = {
|
|
12
|
+
progress: (n: number, total: number) => `Step ${n} of ${total}`,
|
|
13
|
+
back: "Back",
|
|
14
|
+
next: "Next",
|
|
15
|
+
done: "Done",
|
|
16
|
+
skip: "Skip",
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
function Page({
|
|
20
|
+
onClose,
|
|
21
|
+
onProgress,
|
|
22
|
+
open = true,
|
|
23
|
+
targets = ["a", "b", "c"],
|
|
24
|
+
stops = STOPS,
|
|
25
|
+
}: {
|
|
26
|
+
onClose: () => void;
|
|
27
|
+
onProgress?: (step: number, total: number, stop: CoachMarkStop) => void;
|
|
28
|
+
open?: boolean;
|
|
29
|
+
targets?: string[];
|
|
30
|
+
stops?: CoachMarkStop[];
|
|
31
|
+
}) {
|
|
32
|
+
return (
|
|
33
|
+
<>
|
|
34
|
+
{targets.map((id) => (
|
|
35
|
+
<button key={id} type="button" data-coach-mark={id}>
|
|
36
|
+
{id}
|
|
37
|
+
</button>
|
|
38
|
+
))}
|
|
39
|
+
<CoachMarks
|
|
40
|
+
open={open}
|
|
41
|
+
stops={stops}
|
|
42
|
+
onClose={onClose}
|
|
43
|
+
onProgress={onProgress}
|
|
44
|
+
labels={labels}
|
|
45
|
+
/>
|
|
46
|
+
</>
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** The numbered discs on the page, in order — only the current stop wears one. */
|
|
51
|
+
function pins(): string[] {
|
|
52
|
+
return Array.from(document.querySelectorAll<HTMLElement>('[role="dialog"] > :is(span, button)'))
|
|
53
|
+
.map((pin) => pin.textContent?.trim() ?? "")
|
|
54
|
+
.filter((text) => /^\d+$/.test(text));
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// jsdom has no layout: every rect is zero, which is enough to render. A
|
|
58
|
+
// target has boxes when it is in the document, none when it is not.
|
|
59
|
+
Element.prototype.scrollIntoView = vi.fn();
|
|
60
|
+
Element.prototype.getClientRects = function () {
|
|
61
|
+
return (this.isConnected ? [{}] : []) as unknown as DOMRectList;
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
describe("CoachMarks", () => {
|
|
65
|
+
it("opens on the first stop, numbering only the stop the reader is at", () => {
|
|
66
|
+
render(<Page onClose={vi.fn()} />);
|
|
67
|
+
expect(screen.getByRole("dialog", { name: "First" })).toBeInTheDocument();
|
|
68
|
+
expect(screen.getByText("Step 1 of 3")).toBeInTheDocument();
|
|
69
|
+
expect(pins()).toEqual(["1"]);
|
|
70
|
+
fireEvent.click(screen.getByText("Next"));
|
|
71
|
+
expect(pins()).toEqual(["2"]);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("renders above the page, not inside the consumer's wrapper", () => {
|
|
75
|
+
render(
|
|
76
|
+
<div data-testid="wrapper">
|
|
77
|
+
<Page onClose={vi.fn()} />
|
|
78
|
+
</div>
|
|
79
|
+
);
|
|
80
|
+
const dialog = screen.getByRole("dialog", { name: "First" });
|
|
81
|
+
expect(screen.getByTestId("wrapper")).not.toContainElement(dialog);
|
|
82
|
+
expect(dialog.parentElement).toBe(document.body);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("rebuilds the tour when the itinerary changes, not when only its words do", () => {
|
|
86
|
+
const { rerender } = render(<Page onClose={vi.fn()} />);
|
|
87
|
+
fireEvent.click(screen.getByText("Next"));
|
|
88
|
+
expect(screen.getByText("Step 2 of 3")).toBeInTheDocument();
|
|
89
|
+
rerender(
|
|
90
|
+
<Page onClose={vi.fn()} stops={STOPS.map((stop) => ({ ...stop, title: `${stop.title}!` }))} />
|
|
91
|
+
);
|
|
92
|
+
expect(screen.getByText("Step 2 of 3")).toBeInTheDocument();
|
|
93
|
+
rerender(<Page onClose={vi.fn()} stops={STOPS.slice(0, 2)} />);
|
|
94
|
+
expect(screen.getByText("Step 1 of 2")).toBeInTheDocument();
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it("Next and Back walk the stops; the last stop's forward action closes", () => {
|
|
98
|
+
const onClose = vi.fn();
|
|
99
|
+
const onProgress = vi.fn();
|
|
100
|
+
render(<Page onClose={onClose} onProgress={onProgress} />);
|
|
101
|
+
fireEvent.click(screen.getByText("Next"));
|
|
102
|
+
expect(screen.getByText("Step 2 of 3")).toBeInTheDocument();
|
|
103
|
+
expect(onProgress).toHaveBeenLastCalledWith(2, 3, STOPS[1]);
|
|
104
|
+
fireEvent.click(screen.getByText("Back"));
|
|
105
|
+
expect(screen.getByText("Step 1 of 3")).toBeInTheDocument();
|
|
106
|
+
fireEvent.click(screen.getByText("Next"));
|
|
107
|
+
fireEvent.click(screen.getByText("Next"));
|
|
108
|
+
expect(screen.getByText("Step 3 of 3")).toBeInTheDocument();
|
|
109
|
+
fireEvent.click(screen.getByText("Done"));
|
|
110
|
+
expect(onClose).toHaveBeenCalledTimes(1);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it("counts only the stops whose target is on the page, so every step is reachable", () => {
|
|
114
|
+
const onClose = vi.fn();
|
|
115
|
+
render(<Page onClose={onClose} targets={["a", "c"]} />);
|
|
116
|
+
expect(screen.getByText("Step 1 of 2")).toBeInTheDocument();
|
|
117
|
+
fireEvent.click(screen.getByText("Next"));
|
|
118
|
+
expect(screen.getByRole("dialog", { name: "Third" })).toBeInTheDocument();
|
|
119
|
+
expect(screen.getByText("Step 2 of 2")).toBeInTheDocument();
|
|
120
|
+
expect(screen.getByText("Done")).toBeInTheDocument();
|
|
121
|
+
expect(onClose).not.toHaveBeenCalled();
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it("Skip, Escape and the dimmed page all close", () => {
|
|
125
|
+
const onClose = vi.fn();
|
|
126
|
+
render(<Page onClose={onClose} />);
|
|
127
|
+
fireEvent.click(screen.getByText("Skip"));
|
|
128
|
+
fireEvent.keyDown(window, { key: "Escape" });
|
|
129
|
+
fireEvent.click(screen.getAllByRole("button", { name: "Skip" })[0]);
|
|
130
|
+
expect(onClose).toHaveBeenCalledTimes(3);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it("closes at once when no stop has a target, and renders nothing while closed", () => {
|
|
134
|
+
const onClose = vi.fn();
|
|
135
|
+
render(<Page onClose={onClose} targets={[]} />);
|
|
136
|
+
expect(onClose).toHaveBeenCalled();
|
|
137
|
+
render(<Page onClose={vi.fn()} open={false} />);
|
|
138
|
+
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
|
|
139
|
+
});
|
|
140
|
+
});
|