@bearmenu/ui 0.8.33 → 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.
Files changed (84) hide show
  1. package/dist/{chunk-GESB7GCF.js → chunk-6W25S5CF.js} +1 -1
  2. package/dist/chunk-LY67SOFC.js +142 -0
  3. package/dist/{chunk-ZU4NAYN7.js → chunk-NIAL4ZU2.js} +13 -1
  4. package/dist/{chunk-JWTN7MNK.js → chunk-TVEIUTBQ.js} +2 -2
  5. package/dist/{chunk-ZJMCU7MI.js → chunk-VCRXL7H5.js} +1 -1
  6. package/dist/chunk-XLHMF6F5.js +127 -0
  7. package/dist/{chunk-VSRGEB36.js → chunk-XNBDACL7.js} +1 -1
  8. package/dist/components/action-bar.d.ts +67 -0
  9. package/dist/components/action-bar.js +43 -0
  10. package/dist/components/activity-row.d.ts +38 -0
  11. package/dist/components/activity-row.js +43 -0
  12. package/dist/components/alert.d.ts +2 -2
  13. package/dist/components/avatar.d.ts +4 -1
  14. package/dist/components/avatar.js +39 -5
  15. package/dist/components/badge.d.ts +1 -1
  16. package/dist/components/badge.js +1 -1
  17. package/dist/components/booking-row.js +1 -1
  18. package/dist/components/button.d.ts +2 -2
  19. package/dist/components/combobox.js +2 -2
  20. package/dist/components/command.d.ts +15 -15
  21. package/dist/components/command.js +2 -2
  22. package/dist/components/concept-convergence.js +2 -2
  23. package/dist/components/currency-input.js +4 -142
  24. package/dist/components/dialog.d.ts +6 -0
  25. package/dist/components/dialog.js +1 -1
  26. package/dist/components/drawer.d.ts +6 -0
  27. package/dist/components/drawer.js +3 -127
  28. package/dist/components/empty-state.d.ts +2 -2
  29. package/dist/components/filter-category-row.js +1 -1
  30. package/dist/components/menu-item.js +3 -3
  31. package/dist/components/menu-pick-row.d.ts +47 -0
  32. package/dist/components/menu-pick-row.js +49 -0
  33. package/dist/components/multi-select-combobox.js +3 -3
  34. package/dist/components/place-card.js +1 -1
  35. package/dist/components/place-details-mobile.js +1 -1
  36. package/dist/components/qr-panel.d.ts +31 -0
  37. package/dist/components/qr-panel.js +33 -0
  38. package/dist/components/reconcile-bar.d.ts +47 -0
  39. package/dist/components/reconcile-bar.js +60 -0
  40. package/dist/components/responsive-overlay.d.ts +59 -0
  41. package/dist/components/responsive-overlay.js +132 -0
  42. package/dist/components/searchable-select.js +2 -2
  43. package/dist/components/signature-chip.js +2 -2
  44. package/dist/components/spinner.d.ts +2 -2
  45. package/dist/components/split-share-row.d.ts +77 -0
  46. package/dist/components/split-share-row.js +101 -0
  47. package/dist/components/table-ledger-card.d.ts +80 -0
  48. package/dist/components/table-ledger-card.js +84 -0
  49. package/dist/components/table-trigger-band.d.ts +56 -0
  50. package/dist/components/table-trigger-band.js +43 -0
  51. package/dist/components/tag.d.ts +2 -2
  52. package/dist/components/text.d.ts +2 -2
  53. package/dist/components/toggle.d.ts +1 -1
  54. package/dist/components/top-ranking-entry.js +3 -3
  55. package/dist/components/venue-events-block.js +1 -1
  56. package/package.json +10 -1
  57. package/src/components/action-bar.stories.tsx +78 -0
  58. package/src/components/action-bar.tsx +80 -0
  59. package/src/components/activity-row.stories.tsx +78 -0
  60. package/src/components/activity-row.tsx +65 -0
  61. package/src/components/avatar.stories.tsx +55 -0
  62. package/src/components/avatar.tsx +63 -19
  63. package/src/components/badge.stories.tsx +41 -0
  64. package/src/components/badge.tsx +13 -0
  65. package/src/components/dialog.tsx +8 -2
  66. package/src/components/drawer.tsx +8 -2
  67. package/src/components/menu-pick-row.stories.tsx +71 -0
  68. package/src/components/menu-pick-row.tsx +76 -0
  69. package/src/components/qr-panel.stories.tsx +39 -0
  70. package/src/components/qr-panel.tsx +52 -0
  71. package/src/components/reconcile-bar.stories.tsx +57 -0
  72. package/src/components/reconcile-bar.tsx +80 -0
  73. package/src/components/responsive-overlay.stories.tsx +91 -0
  74. package/src/components/responsive-overlay.test.tsx +162 -0
  75. package/src/components/responsive-overlay.tsx +232 -0
  76. package/src/components/shared-table-primitives.test.tsx +482 -0
  77. package/src/components/split-share-row.stories.tsx +114 -0
  78. package/src/components/split-share-row.tsx +150 -0
  79. package/src/components/table-ledger-card.stories.tsx +120 -0
  80. package/src/components/table-ledger-card.tsx +138 -0
  81. package/src/components/table-trigger-band.stories.tsx +76 -0
  82. package/src/components/table-trigger-band.tsx +75 -0
  83. package/src/globals.css +44 -0
  84. package/src/test/setup.ts +11 -0
@@ -16,6 +16,34 @@ const avatarVariants = cva(
16
16
  lg: "size-12 text-base",
17
17
  xl: "size-16 text-lg",
18
18
  },
19
+ /**
20
+ * A person's fixed hue at a shared table: the same face is the same
21
+ * colour on the ledger card, the organise chip, the split row and the
22
+ * activity feed, so a reader tracks people by colour and never has to
23
+ * re-read a name (16-shared-table.html, T2/T4/T6/T9).
24
+ *
25
+ * The caller assigns it — from join order, from the wire, from wherever
26
+ * it keeps its participants. The design system never hashes a name into
27
+ * a colour: a hash re-colours everyone when one person joins, and gives
28
+ * two people the same hue often enough to matter at a table of four.
29
+ *
30
+ * `muted` is the non-person disc — the "Shared between everyone" card's
31
+ * people glyph, an activity row with no actor.
32
+ *
33
+ * Six hues, assigned in the order listed. The board draws the first
34
+ * three; the rest space across the arc they leave free. Past six, cycle —
35
+ * at that size the initial and the name are doing the identifying, and
36
+ * colour is a support cue, never the only one.
37
+ */
38
+ tone: {
39
+ brown: "bg-avatar-brown",
40
+ teal: "bg-avatar-teal",
41
+ green: "bg-avatar-green",
42
+ blue: "bg-avatar-blue",
43
+ violet: "bg-avatar-violet",
44
+ rose: "bg-avatar-rose",
45
+ muted: "bg-muted",
46
+ },
19
47
  },
20
48
  defaultVariants: {
21
49
  size: "default",
@@ -24,14 +52,17 @@ const avatarVariants = cva(
24
52
  );
25
53
 
26
54
  type AvatarSize = NonNullable<VariantProps<typeof avatarVariants>["size"]>;
55
+ type AvatarTone = NonNullable<VariantProps<typeof avatarVariants>["tone"]>;
27
56
 
28
57
  const AvatarContext = React.createContext<AvatarSize>("default");
58
+ /** `null` = no tone set, so the fallback keeps its own muted ground. */
59
+ const AvatarToneContext = React.createContext<AvatarTone | null>(null);
29
60
 
30
61
  const Avatar = React.forwardRef<
31
62
  React.ElementRef<typeof AvatarPrimitive.Root>,
32
63
  React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Root> &
33
64
  VariantProps<typeof avatarVariants>
34
- >(({ className, size, ...props }, ref) => {
65
+ >(({ className, size, tone, ...props }, ref) => {
35
66
  // An explicit `size` always wins; otherwise inherit from the nearest
36
67
  // AvatarGroup (context defaults to "default", so a standalone Avatar with no
37
68
  // size behaves exactly as before).
@@ -40,12 +71,15 @@ const Avatar = React.forwardRef<
40
71
 
41
72
  return (
42
73
  <AvatarContext.Provider value={resolvedSize}>
43
- <AvatarPrimitive.Root
44
- ref={ref}
45
- data-slot="avatar"
46
- className={cn(avatarVariants({ size: resolvedSize }), className)}
47
- {...props}
48
- />
74
+ <AvatarToneContext.Provider value={tone ?? null}>
75
+ <AvatarPrimitive.Root
76
+ ref={ref}
77
+ data-slot="avatar"
78
+ data-tone={tone ?? undefined}
79
+ className={cn(avatarVariants({ size: resolvedSize, tone }), className)}
80
+ {...props}
81
+ />
82
+ </AvatarToneContext.Provider>
49
83
  </AvatarContext.Provider>
50
84
  );
51
85
  });
@@ -67,17 +101,27 @@ AvatarImage.displayName = AvatarPrimitive.Image.displayName;
67
101
  const AvatarFallback = React.forwardRef<
68
102
  React.ElementRef<typeof AvatarPrimitive.Fallback>,
69
103
  React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Fallback>
70
- >(({ className, ...props }, ref) => (
71
- <AvatarPrimitive.Fallback
72
- ref={ref}
73
- data-slot="avatar-fallback"
74
- className={cn(
75
- "flex size-full items-center justify-center rounded-full bg-muted font-semibold text-muted-foreground",
76
- className
77
- )}
78
- {...props}
79
- />
80
- ));
104
+ >(({ className, ...props }, ref) => {
105
+ // The fallback paints its own ground, which would cover the Root's tone.
106
+ // With a tone set it steps aside and takes the ink that tone was measured
107
+ // for: white on the three hues (6.2–7.0:1 light, 4.8–5.4:1 dark), the muted
108
+ // ink on `muted`, which is not a person and stays quiet.
109
+ const tone = React.useContext(AvatarToneContext);
110
+ return (
111
+ <AvatarPrimitive.Fallback
112
+ ref={ref}
113
+ data-slot="avatar-fallback"
114
+ className={cn(
115
+ "flex size-full items-center justify-center rounded-full font-semibold",
116
+ tone === null && "bg-muted text-muted-foreground",
117
+ tone === "muted" && "text-muted-foreground",
118
+ tone !== null && tone !== "muted" && "text-white",
119
+ className
120
+ )}
121
+ {...props}
122
+ />
123
+ );
124
+ });
81
125
  AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName;
82
126
 
83
127
  const badgeOffset = {
@@ -195,4 +239,4 @@ export {
195
239
  avatarVariants,
196
240
  avatarGroupOverflowVariants,
197
241
  };
198
- export type { AvatarSize };
242
+ export type { AvatarSize, AvatarTone };
@@ -1,4 +1,6 @@
1
1
  import type { Meta, StoryObj } from "@storybook/react";
2
+ import { ChevronDown, Users } from "lucide-react";
3
+ import { Avatar, AvatarFallback, AvatarGroup } from "./avatar";
2
4
  import { Badge } from "./badge";
3
5
  import { Button } from "./button";
4
6
 
@@ -85,6 +87,45 @@ export const BookingStatus: Story = {
85
87
  ),
86
88
  };
87
89
 
90
+ /**
91
+ * `assignee` — who an item belongs to, in the shared table's organise view. It
92
+ * is a control, not a status: the whole pill opens "Who had this?", so it keeps
93
+ * room either side for the avatar and the chevron the caller renders into it.
94
+ */
95
+ export const Assignee: Story = {
96
+ render: () => (
97
+ <div className="flex flex-wrap items-center gap-3">
98
+ <Badge variant="assignee" as="span">
99
+ <Avatar size="xs" tone="brown">
100
+ <AvatarFallback>A</AvatarFallback>
101
+ </Avatar>
102
+ Adrian
103
+ <ChevronDown className="size-3.5 opacity-60" aria-hidden />
104
+ </Badge>
105
+ <Badge variant="assignee" as="span">
106
+ <AvatarGroup size="xs" spacing="tight">
107
+ <Avatar tone="brown">
108
+ <AvatarFallback>A</AvatarFallback>
109
+ </Avatar>
110
+ <Avatar tone="teal">
111
+ <AvatarFallback>B</AvatarFallback>
112
+ </Avatar>
113
+ <Avatar tone="green">
114
+ <AvatarFallback>A</AvatarFallback>
115
+ </Avatar>
116
+ </AvatarGroup>
117
+ Shared
118
+ </Badge>
119
+ <Badge variant="assignee" as="span">
120
+ <span className="grid size-6 place-items-center rounded-full bg-muted text-muted-foreground">
121
+ <Users className="size-3" aria-hidden />
122
+ </span>
123
+ Shared
124
+ </Badge>
125
+ </div>
126
+ ),
127
+ };
128
+
88
129
  export const AllVariants: Story = {
89
130
  render: () => (
90
131
  <div className="flex flex-wrap items-center gap-3">
@@ -45,6 +45,19 @@ const badgeVariants = cva(
45
45
  */
46
46
  completed:
47
47
  "rounded-full border-0 bg-muted px-[9px] py-[3px] text-[10.5px] leading-[1.2] font-bold text-muted-foreground",
48
+ /**
49
+ * Who an item belongs to, in the organise table (T4 desktop): a soft
50
+ * pill carrying an avatar (or an `AvatarGroup` for "Shared") and a
51
+ * chevron, because the whole pill opens "Who had this?".
52
+ *
53
+ * It is a control, not a status, so it keeps 6px of inset either side
54
+ * of its slots and `gap-1.5` between them; the caller renders the
55
+ * avatar and the chevron as children. Sized to clear 44px of tap
56
+ * target when it is the row's own control — see `MenuPickRow` for the
57
+ * same rule on the add toggle.
58
+ */
59
+ assignee:
60
+ "rounded-full border-0 bg-surface-2 py-1 pl-1 pr-2 gap-1.5 text-[12px] leading-[1.2] font-semibold text-foreground",
48
61
  },
49
62
  /**
50
63
  * `status` is the taller pill the booking design draws beside a
@@ -53,6 +53,12 @@ const dialogContentVariants = cva(
53
53
  type DialogContentProps = React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> &
54
54
  VariantProps<typeof dialogContentVariants> & {
55
55
  showCloseButton?: boolean;
56
+ /**
57
+ * Classes for the scrim behind the dialog. The default `bg-black/50` is
58
+ * kept for every existing caller; pass `bg-scrim-overlay` for the themed
59
+ * dim (see `--scrim-overlay` in globals.css, and why it is not `--scrim`).
60
+ */
61
+ overlayClassName?: string;
56
62
  };
57
63
 
58
64
  const DialogContent = React.forwardRef<
@@ -60,11 +66,11 @@ const DialogContent = React.forwardRef<
60
66
  DialogContentProps
61
67
  >(
62
68
  (
63
- { className, children, size, scrollBehavior, showCloseButton = true, ...props },
69
+ { className, children, size, scrollBehavior, showCloseButton = true, overlayClassName, ...props },
64
70
  ref
65
71
  ) => (
66
72
  <DialogPortal>
67
- <DialogOverlay />
73
+ <DialogOverlay className={overlayClassName} />
68
74
  <DialogPrimitive.Content
69
75
  ref={ref}
70
76
  data-slot="dialog-content"
@@ -43,12 +43,18 @@ DrawerOverlay.displayName = DrawerPrimitive.Overlay.displayName;
43
43
  type DrawerContentProps = React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Content> & {
44
44
  variant?: "flush" | "floating";
45
45
  showHandle?: boolean;
46
+ /**
47
+ * Classes for the scrim behind the sheet. The default `bg-black/60` is kept
48
+ * for every existing caller; pass `bg-scrim-overlay` for the themed dim (see
49
+ * `--scrim-overlay` in globals.css, and why it is not `--scrim`).
50
+ */
51
+ overlayClassName?: string;
46
52
  };
47
53
 
48
54
  const DrawerContent = React.forwardRef<
49
55
  React.ElementRef<typeof DrawerPrimitive.Content>,
50
56
  DrawerContentProps
51
- >(({ className, variant = "flush", showHandle, children, ...props }, ref) => {
57
+ >(({ className, variant = "flush", showHandle, overlayClassName, children, ...props }, ref) => {
52
58
  const direction = React.useContext(DrawerDirectionContext);
53
59
  const isVertical = direction === "top" || direction === "bottom";
54
60
  const handleEnabled = showHandle ?? (direction === "bottom");
@@ -64,7 +70,7 @@ const DrawerContent = React.forwardRef<
64
70
 
65
71
  return (
66
72
  <DrawerPortal>
67
- <DrawerOverlay />
73
+ <DrawerOverlay className={overlayClassName} />
68
74
  <DrawerPrimitive.Content
69
75
  ref={ref}
70
76
  data-slot="drawer-content"
@@ -0,0 +1,71 @@
1
+ import * as React from "react";
2
+ import type { Meta, StoryObj } from "@storybook/react";
3
+ import { MenuPickRow } from "./menu-pick-row";
4
+
5
+ const meta: Meta<typeof MenuPickRow> = {
6
+ title: "Shared table/MenuPickRow",
7
+ component: MenuPickRow,
8
+ tags: ["autodocs"],
9
+ parameters: {
10
+ docs: {
11
+ description: {
12
+ component:
13
+ "A dish in the add-an-item overlay. The disc is the control, not the row: a whole-row target would make reading the menu and adding to the table the same gesture.",
14
+ },
15
+ },
16
+ },
17
+ };
18
+
19
+ export default meta;
20
+ type Story = StoryObj<typeof MenuPickRow>;
21
+
22
+ export const Unselected: Story = {
23
+ args: { label: "Mușchi de vită la grătar", price: "96 lei", addLabel: "Add Mușchi de vită la grătar" },
24
+ };
25
+
26
+ export const Selected: Story = {
27
+ args: {
28
+ label: "Tochitură ardelenească",
29
+ price: "54 lei",
30
+ selected: true,
31
+ removeLabel: "Remove Tochitură ardelenească",
32
+ },
33
+ };
34
+
35
+ /** T3 — a category of the venue's menu. */
36
+ export const Category: Story = {
37
+ render: () => {
38
+ const [picked, setPicked] = React.useState<string[]>(["Tochitură ardelenească"]);
39
+ const dishes = [
40
+ { label: "Mușchi de vită la grătar", price: "96 lei" },
41
+ { label: "Tochitură ardelenească", price: "54 lei" },
42
+ { label: "Păstrăv la cuptor", price: "62 lei" },
43
+ ];
44
+ return (
45
+ <div>
46
+ <p className="mb-1 font-mono text-[11px] font-bold uppercase tracking-wider text-muted-foreground">
47
+ Main courses
48
+ </p>
49
+ <div className="divide-y divide-border">
50
+ {dishes.map((dish) => (
51
+ <MenuPickRow
52
+ key={dish.label}
53
+ label={dish.label}
54
+ price={dish.price}
55
+ selected={picked.includes(dish.label)}
56
+ onToggle={() =>
57
+ setPicked((prev) =>
58
+ prev.includes(dish.label)
59
+ ? prev.filter((d) => d !== dish.label)
60
+ : [...prev, dish.label]
61
+ )
62
+ }
63
+ addLabel={`Add ${dish.label}`}
64
+ removeLabel={`Remove ${dish.label}`}
65
+ />
66
+ ))}
67
+ </div>
68
+ </div>
69
+ );
70
+ },
71
+ };
@@ -0,0 +1,76 @@
1
+ "use client";
2
+
3
+ import * as React from "react";
4
+ import { Plus } from "lucide-react";
5
+ import { cn } from "../lib/utils";
6
+
7
+ /**
8
+ * MenuPickRow — a dish in the add-an-item overlay (16-shared-table.html T3):
9
+ * its name, its price, and a disc that adds it.
10
+ *
11
+ * The disc is the control and the row is not: a whole-row target would make
12
+ * "read the menu" and "add this" the same gesture, and a reader scrolling a
13
+ * long menu would add things by accident. It is 44×44 as the accessibility
14
+ * floor asks, with the drawn 28px disc inside it.
15
+ *
16
+ * A tap adds one unit. There is no stepper and no quantity anywhere in the
17
+ * shared table — one row per unit, so removing one is removing one thing and
18
+ * never an arithmetic edit (the board's ruling 3). `selected` is therefore
19
+ * "this dish is on the table", not a count; a caller showing a second unit
20
+ * shows a second row.
21
+ */
22
+ export type MenuPickRowProps = Omit<React.HTMLAttributes<HTMLDivElement>, "onToggle"> & {
23
+ /** The dish. */
24
+ label: React.ReactNode;
25
+ /** Its price, formatted by the caller. */
26
+ price: React.ReactNode;
27
+ /** Is it on the table already? Fills the disc. */
28
+ selected?: boolean;
29
+ onToggle?: () => void;
30
+ /** The disc's accessible name when it would add — "Add Papanași". Injected. */
31
+ addLabel?: string;
32
+ /** Its name when it would remove — "Remove Papanași". Injected. */
33
+ removeLabel?: string;
34
+ disabled?: boolean;
35
+ };
36
+
37
+ const MenuPickRow = React.forwardRef<HTMLDivElement, MenuPickRowProps>(
38
+ (
39
+ { className, label, price, selected = false, onToggle, addLabel, removeLabel, disabled = false, ...props },
40
+ ref
41
+ ) => (
42
+ <div
43
+ ref={ref}
44
+ data-slot="menu-pick-row"
45
+ data-selected={selected ? "" : undefined}
46
+ className={cn("flex items-center gap-3 py-2.5", className)}
47
+ {...props}
48
+ >
49
+ <span className="min-w-0 flex-1 truncate text-[14px] font-bold">{label}</span>
50
+ {/* The numeral is the bold sans; only the caller's currency suffix is mono. */}
51
+ <span className="shrink-0 text-[14px] font-bold tabular-nums">{price}</span>
52
+ <button
53
+ type="button"
54
+ onClick={onToggle}
55
+ disabled={disabled}
56
+ aria-pressed={selected}
57
+ aria-label={selected ? removeLabel : addLabel}
58
+ className="grid size-11 shrink-0 place-items-center rounded-full focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
59
+ >
60
+ <span
61
+ className={cn(
62
+ "grid size-7 place-items-center rounded-full border transition-colors",
63
+ selected
64
+ ? "border-transparent bg-primary text-primary-foreground"
65
+ : "border-border bg-background text-muted-foreground"
66
+ )}
67
+ >
68
+ <Plus className="size-4" aria-hidden strokeWidth={2.5} />
69
+ </span>
70
+ </button>
71
+ </div>
72
+ )
73
+ );
74
+ MenuPickRow.displayName = "MenuPickRow";
75
+
76
+ export { MenuPickRow };
@@ -0,0 +1,39 @@
1
+ import type { Meta, StoryObj } from "@storybook/react";
2
+ import { QrPanel } from "./qr-panel";
3
+
4
+ const meta: Meta<typeof QrPanel> = {
5
+ title: "Shared table/QrPanel",
6
+ component: QrPanel,
7
+ tags: ["autodocs"],
8
+ parameters: {
9
+ docs: {
10
+ description: {
11
+ component:
12
+ "The frame around the code that gets a phone onto a table. The code arrives as children — the design system imports no QR library. The plate stays white in dark mode because a reader needs the light module ground and the quiet zone to decode.",
13
+ },
14
+ },
15
+ },
16
+ };
17
+
18
+ export default meta;
19
+ type Story = StoryObj<typeof QrPanel>;
20
+
21
+ /** Stand-in for whatever the app's QR library renders. */
22
+ const code = (
23
+ <svg viewBox="0 0 21 21" shapeRendering="crispEdges" aria-hidden>
24
+ <rect width="21" height="21" fill="#fff" />
25
+ {Array.from({ length: 21 }, (_, y) =>
26
+ Array.from({ length: 21 }, (_, x) =>
27
+ (x * 7 + y * 3) % 5 < 2 ? <rect key={`${x}-${y}`} x={x} y={y} width="1" height="1" /> : null
28
+ )
29
+ )}
30
+ </svg>
31
+ );
32
+
33
+ export const Default: Story = {
34
+ args: { children: code, caption: "Scan with a phone camera to join" },
35
+ };
36
+
37
+ export const WithoutCaption: Story = {
38
+ args: { children: code },
39
+ };
@@ -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 };