@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.
Files changed (87) hide show
  1. package/dist/{chunk-ZLPWEHZO.js → chunk-54FQQZZJ.js} +1 -1
  2. package/dist/{chunk-36KEHCET.js → chunk-5LTILG3L.js} +15 -4
  3. package/dist/{chunk-P43B3ONY.js → chunk-5XREWOBB.js} +3 -1
  4. package/dist/{chunk-CLLY6XB3.js → chunk-6VOWT4YR.js} +6 -3
  5. package/dist/{chunk-S33K4RC5.js → chunk-BKKQ7GCZ.js} +1 -1
  6. package/dist/chunk-QY66ME5B.js +207 -0
  7. package/dist/{chunk-T6S2ZBV6.js → chunk-SDWN6L4O.js} +1 -1
  8. package/dist/{chunk-CNJQUHCU.js → chunk-SWD6CJ35.js} +2 -1
  9. package/dist/{chunk-HDR7V6IZ.js → chunk-TIQNM4XD.js} +8 -1
  10. package/dist/components/alert-dialog.js +1 -1
  11. package/dist/components/badge.d.ts +1 -1
  12. package/dist/components/badge.js +1 -1
  13. package/dist/components/button.d.ts +1 -1
  14. package/dist/components/button.js +1 -1
  15. package/dist/components/calendar.js +1 -1
  16. package/dist/components/carousel.js +1 -1
  17. package/dist/components/coach-marks.d.ts +63 -0
  18. package/dist/components/coach-marks.js +162 -0
  19. package/dist/components/combobox.js +1 -1
  20. package/dist/components/concept-compare-rail.js +2 -2
  21. package/dist/components/concept-compare.js +2 -2
  22. package/dist/components/concept-convergence.js +2 -2
  23. package/dist/components/doorway-tiles.d.ts +11 -0
  24. package/dist/components/doorway-tiles.js +5 -3
  25. package/dist/components/empty-state.js +1 -1
  26. package/dist/components/event-detail-panel.js +2 -2
  27. package/dist/components/event-index-row.js +1 -1
  28. package/dist/components/event-list-row-wide.js +2 -2
  29. package/dist/components/event-list-row.js +1 -1
  30. package/dist/components/facet-group.d.ts +8 -1
  31. package/dist/components/facet-group.js +3 -0
  32. package/dist/components/filter-category-row.js +1 -1
  33. package/dist/components/hue-chip.js +1 -1
  34. package/dist/components/item.js +4 -1
  35. package/dist/components/kitchen-dossier.js +1 -1
  36. package/dist/components/link-button.js +1 -1
  37. package/dist/components/menu-family-block.d.ts +8 -1
  38. package/dist/components/menu-family-block.js +101 -68
  39. package/dist/components/menu-family-card.js +2 -2
  40. package/dist/components/menu-filter-chips.d.ts +12 -3
  41. package/dist/components/menu-filter-chips.js +22 -4
  42. package/dist/components/menu-index-list.js +1 -1
  43. package/dist/components/menu-item.js +3 -3
  44. package/dist/components/menu-kitchen-list.d.ts +14 -2
  45. package/dist/components/menu-kitchen-list.js +1 -1
  46. package/dist/components/multi-select-combobox.js +2 -2
  47. package/dist/components/pagination.js +1 -1
  48. package/dist/components/photo-ground.d.ts +7 -1
  49. package/dist/components/photo-ground.js +1 -1
  50. package/dist/components/place-card.js +1 -1
  51. package/dist/components/place-details-mobile.js +1 -1
  52. package/dist/components/searchable-select.js +1 -1
  53. package/dist/components/section-header.d.ts +7 -1
  54. package/dist/components/section-header.js +11 -7
  55. package/dist/components/signature-chip.js +2 -2
  56. package/dist/components/sliding-panels.js +1 -1
  57. package/dist/components/toggle-group.js +9 -4
  58. package/dist/components/toggle.d.ts +1 -1
  59. package/dist/components/toggle.js +1 -1
  60. package/dist/components/top-ranking-entry.js +3 -3
  61. package/package.json +1 -1
  62. package/src/components/badge.stories.tsx +31 -1
  63. package/src/components/badge.tsx +2 -0
  64. package/src/components/button.stories.tsx +5 -4
  65. package/src/components/button.test.tsx +11 -1
  66. package/src/components/button.tsx +16 -5
  67. package/src/components/coach-marks.stories.tsx +79 -0
  68. package/src/components/coach-marks.test.tsx +140 -0
  69. package/src/components/coach-marks.tsx +252 -0
  70. package/src/components/doorway-tiles.stories.tsx +34 -0
  71. package/src/components/doorway-tiles.test.tsx +27 -0
  72. package/src/components/doorway-tiles.tsx +30 -2
  73. package/src/components/facet-group.tsx +9 -0
  74. package/src/components/hue-chip.test.tsx +6 -3
  75. package/src/components/hue-chip.tsx +5 -2
  76. package/src/components/item.tsx +4 -1
  77. package/src/components/menu-family-block.stories.tsx +12 -0
  78. package/src/components/menu-family-block.tsx +53 -20
  79. package/src/components/menu-filter-chips.tsx +32 -4
  80. package/src/components/menu-kitchen-list.tsx +45 -6
  81. package/src/components/photo-ground.tsx +7 -0
  82. package/src/components/section-header.test.tsx +15 -0
  83. package/src/components/section-header.tsx +26 -12
  84. package/src/components/toggle-group.stories.tsx +37 -1
  85. package/src/components/toggle-group.tsx +11 -6
  86. package/src/components/toggle.tsx +8 -0
  87. 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 };
@@ -1,3 +1,3 @@
1
- export { PHOTO_GROUND, PhotoGround } from '../chunk-CNJQUHCU.js';
1
+ export { PHOTO_GLYPH_SHADOW, PHOTO_GROUND, PhotoGround } from '../chunk-SWD6CJ35.js';
2
2
  import '../chunk-FEK5XGZW.js';
3
3
  import '../chunk-MMUBH76A.js';
@@ -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-P43B3ONY.js';
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-P43B3ONY.js';
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-36KEHCET.js';
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 && (actionHref ? /* @__PURE__ */ jsxs(LinkComponent, { href: actionHref, className: actionClasses, onClick: onAction, children: [
45
- action,
46
- actionIcon
47
- ] }) : onAction ? /* @__PURE__ */ jsxs("button", { type: "button", className: actionClasses, onClick: onAction, children: [
48
- action,
49
- actionIcon
50
- ] }) : null)
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
- export { SignatureChip } from '../chunk-S33K4RC5.js';
2
- import '../chunk-P43B3ONY.js';
1
+ export { SignatureChip } from '../chunk-BKKQ7GCZ.js';
2
+ import '../chunk-5XREWOBB.js';
3
3
  import '../chunk-FEK5XGZW.js';
4
4
  import '../chunk-MMUBH76A.js';
@@ -1,4 +1,4 @@
1
- import { Button } from '../chunk-36KEHCET.js';
1
+ import { Button } from '../chunk-5LTILG3L.js';
2
2
  import '../chunk-RIH2IS7X.js';
3
3
  import '../chunk-5YNIQ2BL.js';
4
4
  import { cn } from '../chunk-FEK5XGZW.js';
@@ -1,4 +1,4 @@
1
- import { toggleVariants } from '../chunk-HDR7V6IZ.js';
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": context.variant || variant,
60
+ "data-variant": resolved,
57
61
  "data-size": context.size || size,
58
62
  className: cn(
59
63
  toggleVariants({
60
- variant: context.variant || variant,
64
+ variant: resolved,
61
65
  size: context.size || size
62
66
  }),
63
- "min-w-0 flex-1 shrink-0 rounded-none shadow-none first:rounded-l-md last:rounded-r-md focus:z-10 focus-visible:z-10 data-[variant=outline]:border-l-0 data-[variant=outline]:first:border-l",
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,3 +1,3 @@
1
- export { Toggle, toggleVariants } from '../chunk-HDR7V6IZ.js';
1
+ export { Toggle, toggleVariants } from '../chunk-TIQNM4XD.js';
2
2
  import '../chunk-FEK5XGZW.js';
3
3
  import '../chunk-MMUBH76A.js';
@@ -1,8 +1,8 @@
1
- import { MenuItem } from '../chunk-T6S2ZBV6.js';
2
- import '../chunk-S33K4RC5.js';
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-P43B3ONY.js';
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,6 +1,6 @@
1
1
  {
2
2
  "name": "@bearmenu/ui",
3
- "version": "0.8.16",
3
+ "version": "0.8.17",
4
4
  "description": "BearMenu design system — shared UI tokens, primitives, and components",
5
5
  "license": "UNLICENSED",
6
6
  "repository": {
@@ -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: ["default", "secondary", "destructive", "outline", "overlay", "glass", "success", "warning"],
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
  };
@@ -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
- export const Antagonist: Story = {
90
- args: { children: "Antagonist", variant: "antagonist" },
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="antagonist">Antagonist</Button>
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
- expect(buttonVariants({ variant: "antagonist" })).toContain("bg-primary-foreground");
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:text-accent-foreground hover:border-accent",
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:text-accent-foreground border border-border hover:border-accent",
22
- ghost: "hover:bg-accent hover:text-accent-foreground",
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
- antagonist:
53
- "bg-primary-foreground text-primary shadow-sm hover:bg-accent hover:text-accent-foreground border border-border hover:border-accent",
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
+ });