@bearmenu/ui 0.8.15 → 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/README.md +1 -0
- package/dist/{chunk-ZLPWEHZO.js → chunk-54FQQZZJ.js} +1 -1
- package/dist/{chunk-5FCC5L5F.js → chunk-5LTILG3L.js} +29 -6
- package/dist/{chunk-P43B3ONY.js → chunk-5XREWOBB.js} +3 -1
- package/dist/{chunk-V4E333QS.js → chunk-5YNIQ2BL.js} +7 -29
- 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-RIH2IS7X.js +29 -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 +3 -2
- package/dist/components/badge.d.ts +1 -1
- package/dist/components/badge.js +1 -1
- package/dist/components/button.d.ts +5 -1
- package/dist/components/button.js +3 -2
- package/dist/components/calendar.js +3 -2
- package/dist/components/carousel.js +3 -2
- package/dist/components/coach-marks.d.ts +63 -0
- package/dist/components/coach-marks.js +162 -0
- package/dist/components/combobox.js +3 -2
- 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/date-input.d.ts +1 -1
- package/dist/components/doorway-tiles.d.ts +11 -0
- package/dist/components/doorway-tiles.js +5 -3
- package/dist/components/empty-state.js +3 -2
- package/dist/components/event-detail-panel.js +4 -3
- package/dist/components/event-index-row.js +1 -1
- package/dist/components/event-list-row-wide.js +4 -3
- 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/haptic-switch.d.ts +35 -0
- package/dist/components/haptic-switch.js +3 -0
- package/dist/components/hue-chip.js +1 -1
- package/dist/components/item.js +4 -1
- package/dist/components/kitchen-dossier.js +2 -2
- package/dist/components/link-button.js +3 -2
- package/dist/components/menu-family-block.d.ts +8 -1
- package/dist/components/menu-family-block.js +103 -69
- 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 +3 -2
- 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 +4 -3
- package/dist/components/pagination.js +3 -2
- package/dist/components/photo-ground.d.ts +7 -1
- package/dist/components/photo-ground.js +1 -1
- package/dist/components/place-card.js +2 -2
- package/dist/components/place-details-mobile.js +1 -1
- package/dist/components/searchable-select.js +3 -2
- 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 +3 -2
- package/dist/components/switch.d.ts +7 -1
- package/dist/components/switch.js +33 -24
- 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/dist/hooks/use-haptic.d.ts +9 -1
- package/dist/hooks/use-haptic.js +1 -1
- 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 +41 -6
- 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/haptic-switch.test.tsx +134 -0
- package/src/components/haptic-switch.tsx +62 -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/switch.tsx +29 -18
- 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/src/hooks/use-haptic.test.tsx +54 -59
- package/src/hooks/use-haptic.ts +18 -44
- package/dist/chunk-SXQZ7DLH.js +0 -167
|
@@ -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';
|
|
@@ -4,6 +4,14 @@ interface HapticAPI {
|
|
|
4
4
|
platform: "ios" | "android" | "desktop" | "unknown";
|
|
5
5
|
trigger: (style: HapticStyle) => void;
|
|
6
6
|
}
|
|
7
|
+
/**
|
|
8
|
+
* iOS Safari, including iPadOS masquerading as a Mac. Haptics there come from
|
|
9
|
+
* `HapticSwitch`, not from `trigger`: WebKit has no Vibration API, and since
|
|
10
|
+
* iOS 26.5 the only haptic it plays is a native switch flipping under the
|
|
11
|
+
* user's own finger — a programmatic click on the switch or its label no
|
|
12
|
+
* longer counts (WebKit #65327).
|
|
13
|
+
*/
|
|
14
|
+
declare function isIos(): boolean;
|
|
7
15
|
declare const useHaptic: () => HapticAPI;
|
|
8
16
|
declare global {
|
|
9
17
|
interface Window {
|
|
@@ -19,4 +27,4 @@ declare global {
|
|
|
19
27
|
}
|
|
20
28
|
}
|
|
21
29
|
|
|
22
|
-
export { useHaptic };
|
|
30
|
+
export { isIos, useHaptic };
|
package/dist/hooks/use-haptic.js
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
export { useHaptic } from '../chunk-
|
|
1
|
+
export { isIos, useHaptic } from '../chunk-5YNIQ2BL.js';
|
|
2
2
|
import '../chunk-MMUBH76A.js';
|
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", () => {
|
|
@@ -4,6 +4,7 @@ import { cva, type VariantProps } from "class-variance-authority";
|
|
|
4
4
|
import { Loader2 } from "lucide-react";
|
|
5
5
|
import { cn } from "../lib/utils";
|
|
6
6
|
import { useHaptic } from "../hooks/use-haptic";
|
|
7
|
+
import { HapticSwitch, isHapticSwitchTarget } from "./haptic-switch";
|
|
7
8
|
|
|
8
9
|
const buttonVariants = cva(
|
|
9
10
|
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-xl text-sm font-semibold transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0 cursor-pointer [@media(pointer:coarse)]:active:scale-95",
|
|
@@ -14,11 +15,21 @@ const buttonVariants = cva(
|
|
|
14
15
|
"bg-primary text-primary-foreground shadow-sm hover:bg-primary/90 hover:shadow",
|
|
15
16
|
destructive:
|
|
16
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
|
+
*/
|
|
17
28
|
outline:
|
|
18
|
-
"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",
|
|
19
30
|
secondary:
|
|
20
|
-
"bg-secondary text-secondary-foreground shadow-sm hover:bg-accent hover:
|
|
21
|
-
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",
|
|
22
33
|
link: "text-primary underline-offset-4 hover:underline",
|
|
23
34
|
glass: "glass glass-hover text-foreground",
|
|
24
35
|
/**
|
|
@@ -48,8 +59,9 @@ const buttonVariants = cva(
|
|
|
48
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",
|
|
49
60
|
brown:
|
|
50
61
|
"bg-brown text-brown-foreground shadow-sm hover:bg-brown/85 hover:shadow",
|
|
51
|
-
|
|
52
|
-
|
|
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",
|
|
53
65
|
},
|
|
54
66
|
size: {
|
|
55
67
|
xs: "h-8 rounded-lg px-3 text-xs [&_svg]:size-3.5",
|
|
@@ -82,6 +94,10 @@ export interface ButtonProps
|
|
|
82
94
|
/**
|
|
83
95
|
* Off by default. Haptics are for actions that change state — a save, a
|
|
84
96
|
* toggle, a destructive confirm — not for every button, so each site opts in.
|
|
97
|
+
*
|
|
98
|
+
* Android vibrates through `useHaptic`; iOS ticks through a `HapticSwitch`
|
|
99
|
+
* laid over the button, which needs a real element, so `asChild` gets the
|
|
100
|
+
* vibration only.
|
|
85
101
|
*/
|
|
86
102
|
haptic?: boolean;
|
|
87
103
|
hapticStyle?: "light" | "medium" | "heavy";
|
|
@@ -100,6 +116,7 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
|
|
100
116
|
hapticStyle = "light",
|
|
101
117
|
isLoading = false,
|
|
102
118
|
disabled,
|
|
119
|
+
type,
|
|
103
120
|
onClick,
|
|
104
121
|
children,
|
|
105
122
|
...props
|
|
@@ -109,6 +126,7 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
|
|
109
126
|
const Comp = asChild ? Slot : "button";
|
|
110
127
|
const { trigger } = useHaptic();
|
|
111
128
|
const [isMounted, setIsMounted] = React.useState(false);
|
|
129
|
+
const withSwitch = haptic && !asChild;
|
|
112
130
|
|
|
113
131
|
React.useEffect(() => {
|
|
114
132
|
setIsMounted(true);
|
|
@@ -119,13 +137,25 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
|
|
119
137
|
trigger(hapticStyle);
|
|
120
138
|
}
|
|
121
139
|
onClick?.(event);
|
|
140
|
+
// On iOS the tap lands on the overlay switch, so the button's own
|
|
141
|
+
// implicit submission never runs; forward it once the handler has had
|
|
142
|
+
// its say.
|
|
143
|
+
if (
|
|
144
|
+
withSwitch &&
|
|
145
|
+
type === "submit" &&
|
|
146
|
+
!event.defaultPrevented &&
|
|
147
|
+
isHapticSwitchTarget(event.target)
|
|
148
|
+
) {
|
|
149
|
+
event.currentTarget.form?.requestSubmit(event.currentTarget);
|
|
150
|
+
}
|
|
122
151
|
};
|
|
123
152
|
|
|
124
153
|
return (
|
|
125
154
|
<Comp
|
|
126
155
|
data-slot="button"
|
|
127
|
-
className={cn(buttonVariants({ variant, size, fullWidth, className }))}
|
|
156
|
+
className={cn(buttonVariants({ variant, size, fullWidth, className }), withSwitch && "relative")}
|
|
128
157
|
ref={ref}
|
|
158
|
+
type={type}
|
|
129
159
|
onClick={handleClick}
|
|
130
160
|
disabled={disabled || isLoading}
|
|
131
161
|
aria-busy={isLoading || undefined}
|
|
@@ -136,6 +166,11 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
|
|
136
166
|
<Loader2 className="animate-spin" aria-hidden="true" />
|
|
137
167
|
{!asChild && children}
|
|
138
168
|
</>
|
|
169
|
+
) : withSwitch ? (
|
|
170
|
+
<>
|
|
171
|
+
{children}
|
|
172
|
+
<HapticSwitch disabled={disabled} />
|
|
173
|
+
</>
|
|
139
174
|
) : (
|
|
140
175
|
children
|
|
141
176
|
)}
|
|
@@ -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
|
+
});
|