@bearmenu/ui 0.8.33 → 0.8.35

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 (86) 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.d.ts +11 -1
  18. package/dist/components/booking-row.js +4 -1
  19. package/dist/components/button.d.ts +2 -2
  20. package/dist/components/combobox.js +2 -2
  21. package/dist/components/command.d.ts +15 -15
  22. package/dist/components/command.js +2 -2
  23. package/dist/components/concept-convergence.js +2 -2
  24. package/dist/components/currency-input.js +4 -142
  25. package/dist/components/dialog.d.ts +6 -0
  26. package/dist/components/dialog.js +1 -1
  27. package/dist/components/drawer.d.ts +6 -0
  28. package/dist/components/drawer.js +3 -127
  29. package/dist/components/empty-state.d.ts +2 -2
  30. package/dist/components/filter-category-row.js +1 -1
  31. package/dist/components/menu-item.js +3 -3
  32. package/dist/components/menu-pick-row.d.ts +47 -0
  33. package/dist/components/menu-pick-row.js +49 -0
  34. package/dist/components/multi-select-combobox.js +3 -3
  35. package/dist/components/place-card.js +1 -1
  36. package/dist/components/place-details-mobile.js +1 -1
  37. package/dist/components/qr-panel.d.ts +31 -0
  38. package/dist/components/qr-panel.js +33 -0
  39. package/dist/components/reconcile-bar.d.ts +47 -0
  40. package/dist/components/reconcile-bar.js +60 -0
  41. package/dist/components/responsive-overlay.d.ts +59 -0
  42. package/dist/components/responsive-overlay.js +132 -0
  43. package/dist/components/searchable-select.js +2 -2
  44. package/dist/components/signature-chip.js +2 -2
  45. package/dist/components/spinner.d.ts +2 -2
  46. package/dist/components/split-share-row.d.ts +77 -0
  47. package/dist/components/split-share-row.js +101 -0
  48. package/dist/components/table-ledger-card.d.ts +80 -0
  49. package/dist/components/table-ledger-card.js +84 -0
  50. package/dist/components/table-trigger-band.d.ts +56 -0
  51. package/dist/components/table-trigger-band.js +43 -0
  52. package/dist/components/tag.d.ts +2 -2
  53. package/dist/components/text.d.ts +2 -2
  54. package/dist/components/toggle.d.ts +1 -1
  55. package/dist/components/top-ranking-entry.js +3 -3
  56. package/dist/components/venue-events-block.js +1 -1
  57. package/package.json +23 -207
  58. package/src/components/action-bar.stories.tsx +78 -0
  59. package/src/components/action-bar.tsx +80 -0
  60. package/src/components/activity-row.stories.tsx +78 -0
  61. package/src/components/activity-row.tsx +65 -0
  62. package/src/components/avatar.stories.tsx +55 -0
  63. package/src/components/avatar.tsx +63 -19
  64. package/src/components/badge.stories.tsx +41 -0
  65. package/src/components/badge.tsx +13 -0
  66. package/src/components/booking-row.tsx +13 -0
  67. package/src/components/dialog.tsx +8 -2
  68. package/src/components/drawer.tsx +8 -2
  69. package/src/components/menu-pick-row.stories.tsx +71 -0
  70. package/src/components/menu-pick-row.tsx +76 -0
  71. package/src/components/qr-panel.stories.tsx +39 -0
  72. package/src/components/qr-panel.tsx +52 -0
  73. package/src/components/reconcile-bar.stories.tsx +57 -0
  74. package/src/components/reconcile-bar.tsx +80 -0
  75. package/src/components/responsive-overlay.stories.tsx +91 -0
  76. package/src/components/responsive-overlay.test.tsx +162 -0
  77. package/src/components/responsive-overlay.tsx +232 -0
  78. package/src/components/shared-table-primitives.test.tsx +482 -0
  79. package/src/components/split-share-row.stories.tsx +114 -0
  80. package/src/components/split-share-row.tsx +150 -0
  81. package/src/components/table-ledger-card.stories.tsx +120 -0
  82. package/src/components/table-ledger-card.tsx +138 -0
  83. package/src/components/table-trigger-band.stories.tsx +76 -0
  84. package/src/components/table-trigger-band.tsx +75 -0
  85. package/src/globals.css +44 -0
  86. package/src/test/setup.ts +11 -0
@@ -0,0 +1,150 @@
1
+ "use client";
2
+
3
+ import * as React from "react";
4
+ import { cn } from "../lib/utils";
5
+ import { Checkbox } from "./checkbox";
6
+ import { CurrencyInput } from "./currency-input";
7
+
8
+ /**
9
+ * SplitShareRow — one person's share of a split item (16-shared-table.html,
10
+ * T6 and X10). Include them or leave them out, and in the custom mode set what
11
+ * they had.
12
+ *
13
+ * The board draws one row two ways and they are the same component: under md a
14
+ * full-width row with the check on the trailing edge, where a thumb is; from md
15
+ * a tile in a three-up grid with a square checkbox leading, where an eye scans
16
+ * a column. Both are CSS on one DOM — the order flips with `order`, the check's
17
+ * radius with a breakpoint — so there is no JS measurement and no flash.
18
+ *
19
+ * The amount sits in a soft pill under md whether or not it is editable, as the
20
+ * frames draw it; from md a fixed amount is plain text and only an editable one
21
+ * keeps the pill, plus a hairline and a focus ring, because on a wide screen
22
+ * nothing else would say "you can type here".
23
+ *
24
+ * The numeral is the bold sans and only the caller's currency suffix is mono,
25
+ * as the board draws money everywhere.
26
+ *
27
+ * The parent owns the grid (`grid md:grid-cols-3`) and the arithmetic. This row
28
+ * reports what it was given and never rounds: allocations are integer minor
29
+ * units and must sum to the line total exactly.
30
+ */
31
+ export type SplitShareRowProps = Omit<
32
+ React.HTMLAttributes<HTMLDivElement>,
33
+ "onChange" | "children"
34
+ > & {
35
+ /** Avatar and name, composed by the caller — the design system holds no participant shape. */
36
+ person: React.ReactNode;
37
+ /** The share as the caller formatted it. Shown when `editable` is false. */
38
+ amount: React.ReactNode;
39
+ /** Swaps the amount for a `CurrencyInput`. The custom mode, and only it. */
40
+ editable?: boolean;
41
+ /** The editable value in major units, as `CurrencyInput` takes it. */
42
+ amountValue?: number | null;
43
+ onAmountChange?: (value: number | null) => void;
44
+ /** Passed through to `CurrencyInput` for the editable amount. */
45
+ currency?: string;
46
+ locale?: string;
47
+ /** Is this person in the split? Omit the handler for a row that cannot be excluded. */
48
+ checked?: boolean;
49
+ onCheckedChange?: (checked: boolean) => void;
50
+ /**
51
+ * The checkbox's accessible name — "Include Ana in the split". Injected;
52
+ * the design system holds no copy.
53
+ */
54
+ includeLabel?: string;
55
+ /** The editable amount's accessible name — "Ana's share". */
56
+ amountLabel?: string;
57
+ disabled?: boolean;
58
+ };
59
+
60
+ const SplitShareRow = React.forwardRef<HTMLDivElement, SplitShareRowProps>(
61
+ (
62
+ {
63
+ className,
64
+ person,
65
+ amount,
66
+ editable = false,
67
+ amountValue = null,
68
+ onAmountChange,
69
+ currency = "lei",
70
+ locale,
71
+ checked,
72
+ onCheckedChange,
73
+ includeLabel,
74
+ amountLabel,
75
+ disabled = false,
76
+ ...props
77
+ },
78
+ ref
79
+ ) => {
80
+ const amountId = React.useId();
81
+
82
+ return (
83
+ <div
84
+ ref={ref}
85
+ data-slot="split-share-row"
86
+ data-checked={checked ? "" : undefined}
87
+ className={cn(
88
+ "flex items-center gap-3 rounded-xl border border-border bg-card px-3 py-2.5",
89
+ className
90
+ )}
91
+ {...props}
92
+ >
93
+ <div className="min-w-0 flex-1 order-2">{person}</div>
94
+
95
+ {editable ? (
96
+ <span className="order-3 shrink-0">
97
+ {/* `CurrencyInput` takes a closed prop set with no `aria-label`,
98
+ so the name arrives the HTML way. */}
99
+ <label htmlFor={amountId} className="sr-only">
100
+ {amountLabel}
101
+ </label>
102
+ <CurrencyInput
103
+ id={amountId}
104
+ value={amountValue}
105
+ onChange={(next) => onAmountChange?.(next)}
106
+ currency={currency}
107
+ locale={locale}
108
+ disabled={disabled}
109
+ // An editable amount has to look editable at both widths, which
110
+ // the soft pill alone does not do on a wide screen where the
111
+ // fixed amount is plain text: it takes a hairline and a focus ring.
112
+ className="h-9 w-24 rounded-lg border border-border bg-surface-2 text-right text-[13px] font-bold tabular-nums focus-within:ring-2 focus-within:ring-ring"
113
+ />
114
+ </span>
115
+ ) : (
116
+ <span
117
+ data-slot="split-share-amount"
118
+ className={cn(
119
+ "order-3 shrink-0 rounded-lg bg-surface-2 px-2.5 py-1 text-[13px] font-bold tabular-nums md:bg-transparent md:px-0",
120
+ // Only the amount goes quiet when someone is out of the split.
121
+ // Dimming the whole row takes the name under 4.5:1 with it.
122
+ checked === false && "text-muted-foreground"
123
+ )}
124
+ >
125
+ {amount}
126
+ </span>
127
+ )}
128
+
129
+ {onCheckedChange ? (
130
+ <Checkbox
131
+ checked={checked}
132
+ onCheckedChange={(next) => onCheckedChange(next === true)}
133
+ disabled={disabled}
134
+ aria-label={includeLabel}
135
+ // Last in the DOM because that is where the sheet paints it, so
136
+ // tab order follows the eye; `md:order-first` moves it to the
137
+ // leading edge for the dialog's three-up tiles. 44px either way.
138
+ className={cn(
139
+ "relative order-4 size-6 rounded-full md:order-1 md:size-5 md:rounded-md",
140
+ "before:absolute before:-inset-2.5 before:content-[''] md:before:-inset-3"
141
+ )}
142
+ />
143
+ ) : null}
144
+ </div>
145
+ );
146
+ }
147
+ );
148
+ SplitShareRow.displayName = "SplitShareRow";
149
+
150
+ export { SplitShareRow };
@@ -0,0 +1,120 @@
1
+ import type { Meta, StoryObj } from "@storybook/react";
2
+ import { Users } from "lucide-react";
3
+ import { Avatar, AvatarFallback, AvatarGroup } from "./avatar";
4
+ import { Badge } from "./badge";
5
+ import { TableLedgerCard } from "./table-ledger-card";
6
+
7
+ const meta: Meta<typeof TableLedgerCard> = {
8
+ title: "Shared table/TableLedgerCard",
9
+ component: TableLedgerCard,
10
+ tags: ["autodocs"],
11
+ parameters: {
12
+ docs: {
13
+ description: {
14
+ component:
15
+ "What one party at a shared table has against their name. One component for the person card, the Shared card, the card a remote write just changed, and the drop targets on Organise.",
16
+ },
17
+ },
18
+ },
19
+ };
20
+
21
+ export default meta;
22
+ type Story = StoryObj<typeof TableLedgerCard>;
23
+
24
+ const adrian = (
25
+ <Avatar size="sm" tone="brown">
26
+ <AvatarFallback>A</AvatarFallback>
27
+ </Avatar>
28
+ );
29
+
30
+ const rows = [
31
+ { id: "1", label: "Ciorbă de burtă", amount: "32 lei" },
32
+ { id: "2", label: "Zacuscă de casă", amount: "24 lei" },
33
+ ];
34
+
35
+ /** T2 — a person and what they had. */
36
+ export const Person: Story = {
37
+ args: {
38
+ lead: adrian,
39
+ person: (
40
+ <>
41
+ Adrian <span className="font-normal text-muted-foreground">· host</span>
42
+ </>
43
+ ),
44
+ meta: "2 items + share",
45
+ total: "96 lei",
46
+ rows,
47
+ labels: { rows: "Adrian's items" },
48
+ },
49
+ };
50
+
51
+ /** T2 — the Shared card: a glyph for a lead, and rows priced per head. */
52
+ export const Shared: Story = {
53
+ args: {
54
+ lead: (
55
+ <span className="grid size-8 place-items-center rounded-full bg-muted text-muted-foreground">
56
+ <Users className="size-4" aria-hidden />
57
+ </span>
58
+ ),
59
+ person: "Shared between everyone",
60
+ total: "120 lei",
61
+ rows: [{ id: "w", label: "Fetească Neagră, Recaș", amount: "", trailing: "40 lei each" }],
62
+ },
63
+ };
64
+
65
+ /** X5 — someone else just added to it, marked in place rather than re-sorted. */
66
+ export const Updated: Story = {
67
+ args: {
68
+ state: "updated",
69
+ lead: (
70
+ <Avatar size="sm" tone="teal">
71
+ <AvatarFallback>B</AvatarFallback>
72
+ </Avatar>
73
+ ),
74
+ person: "Bruno",
75
+ total: "150 lei",
76
+ rows: [
77
+ {
78
+ id: "t",
79
+ label: "Tochitură ardelenească",
80
+ amount: "54 lei",
81
+ badge: <Badge variant="pending">just now</Badge>,
82
+ },
83
+ ],
84
+ },
85
+ };
86
+
87
+ /** T4 desktop — the drop targets, header only. */
88
+ export const DropTargets: Story = {
89
+ render: () => (
90
+ <div className="grid gap-2">
91
+ <TableLedgerCard state="drop-idle" lead={adrian} person="Adrian" meta="2 items" total="56 lei" />
92
+ <TableLedgerCard
93
+ state="drop-active"
94
+ lead={
95
+ <Avatar size="sm" tone="teal">
96
+ <AvatarFallback>B</AvatarFallback>
97
+ </Avatar>
98
+ }
99
+ person="Bruno"
100
+ meta="2 items"
101
+ total="150 lei"
102
+ />
103
+ <TableLedgerCard
104
+ state="drop-idle"
105
+ lead={
106
+ <AvatarGroup size="sm" spacing="tight">
107
+ <Avatar tone="brown">
108
+ <AvatarFallback>A</AvatarFallback>
109
+ </Avatar>
110
+ <Avatar tone="teal">
111
+ <AvatarFallback>B</AvatarFallback>
112
+ </Avatar>
113
+ </AvatarGroup>
114
+ }
115
+ person="Shared"
116
+ meta="Split between everyone"
117
+ />
118
+ </div>
119
+ ),
120
+ };
@@ -0,0 +1,138 @@
1
+ import * as React from "react";
2
+ import { cva, type VariantProps } from "class-variance-authority";
3
+ import { cn } from "../lib/utils";
4
+
5
+ /**
6
+ * TableLedgerCard — what one party at a shared table has against their name.
7
+ *
8
+ * The board draws this shape four times and it is one component
9
+ * (16-shared-table.html):
10
+ * - T2, a person's card — avatar, "Adrian · host", "2 items + share", their
11
+ * total, and the items under it.
12
+ * - T2, the Shared card — the same card whose `lead` is a people glyph or an
13
+ * `AvatarGroup` instead of one avatar, and whose rows carry "40 lei each".
14
+ * - X5, the same card the moment someone else changed it: `updated` rings it
15
+ * and tints the header, so a remote write is marked in place and the ledger
16
+ * never silently re-sorts under a reader.
17
+ * - T4 desktop, the drop targets — the header alone, no rows, `drop-idle`
18
+ * until a drag is over it and `drop-active` while it is.
19
+ *
20
+ * Money slots are `font-bold tabular-nums`, so a column of totals lines up and
21
+ * does not shuffle when one changes. The board sets the NUMERAL in the bold
22
+ * sans and only the currency suffix in mono, small and muted — so a caller
23
+ * passes `96` followed by a `font-mono text-[11px] text-muted-foreground`
24
+ * "lei", and never wraps the whole amount in `font-mono`.
25
+ *
26
+ * Drag is an accelerator only. A card in `drop-active` must have a tap and
27
+ * keyboard path to the same outcome — every T4 row opens "Who had this?" —
28
+ * because a pointer that can drag is not a pointer everyone has.
29
+ */
30
+ const tableLedgerCardVariants = cva(
31
+ "overflow-hidden rounded-2xl border bg-card transition-colors",
32
+ {
33
+ variants: {
34
+ state: {
35
+ default: "border-border",
36
+ updated: "border-primary",
37
+ "drop-idle": "border-dashed border-border",
38
+ "drop-active": "border-dashed border-primary bg-accent-subtle",
39
+ },
40
+ },
41
+ defaultVariants: { state: "default" },
42
+ }
43
+ );
44
+
45
+ export type TableLedgerRow = {
46
+ /** React key. Falls back to the index when the caller has no id. */
47
+ id?: string;
48
+ /** The dish or drink. */
49
+ label: React.ReactNode;
50
+ /** Its price, formatted by the caller. */
51
+ amount: React.ReactNode;
52
+ /** The quiet trailing line — "40 lei each" on the Shared card. Replaces `amount` when set. */
53
+ trailing?: React.ReactNode;
54
+ /** A chip on the row — "just now" on the X5 card, "Queued" on X6. */
55
+ badge?: React.ReactNode;
56
+ };
57
+
58
+ export type TableLedgerCardProps = Omit<React.HTMLAttributes<HTMLDivElement>, "children"> &
59
+ VariantProps<typeof tableLedgerCardVariants> & {
60
+ /** The leading slot: an `Avatar`, an `AvatarGroup`, or a glyph for "Shared". */
61
+ lead?: React.ReactNode;
62
+ /** The name — "Adrian · host", "Shared between everyone". */
63
+ person?: React.ReactNode;
64
+ /** The line under the name — "2 items + share". */
65
+ meta?: React.ReactNode;
66
+ /** Their running total, formatted by the caller. */
67
+ total?: React.ReactNode;
68
+ /** The items. Empty or omitted for a T4 drop target. */
69
+ rows?: TableLedgerRow[];
70
+ /** Accessible names. Injected; the design system holds no copy. */
71
+ labels?: { rows?: string };
72
+ };
73
+
74
+ const TableLedgerCard = React.forwardRef<HTMLDivElement, TableLedgerCardProps>(
75
+ ({ className, state, lead, person, meta, total, rows, labels, ...props }, ref) => (
76
+ <div
77
+ ref={ref}
78
+ data-slot="table-ledger-card"
79
+ data-state={state ?? "default"}
80
+ className={cn(tableLedgerCardVariants({ state }), className)}
81
+ {...props}
82
+ >
83
+ <div
84
+ data-slot="table-ledger-header"
85
+ className={cn(
86
+ "flex items-center gap-3 px-3.5 py-3",
87
+ // The header takes its own ground only when there are items under it,
88
+ // so it reads as their heading. A card with no rows — a drop target,
89
+ // a person who has had nothing yet — is one plain block, and tinting
90
+ // it would also paint over `drop-active`'s accent.
91
+ rows?.length
92
+ ? state === "updated"
93
+ ? "bg-accent-subtle"
94
+ : "bg-surface-2/60"
95
+ : undefined
96
+ )}
97
+ >
98
+ {lead ? <div className="shrink-0">{lead}</div> : null}
99
+ <div className="min-w-0 flex-1">
100
+ {person ? <div className="truncate text-[15px] font-bold leading-tight">{person}</div> : null}
101
+ {meta ? <div className="mt-0.5 truncate text-[12.5px] text-muted-foreground">{meta}</div> : null}
102
+ </div>
103
+ {total ? (
104
+ <div
105
+ data-slot="table-ledger-total"
106
+ className="shrink-0 text-[15px] font-bold tabular-nums"
107
+ >
108
+ {total}
109
+ </div>
110
+ ) : null}
111
+ </div>
112
+
113
+ {rows && rows.length > 0 ? (
114
+ <ul data-slot="table-ledger-rows" aria-label={labels?.rows} className="divide-y divide-border">
115
+ {rows.map((row, index) => (
116
+ <li
117
+ key={row.id ?? index}
118
+ className="flex items-center gap-3 px-3.5 py-2.5 text-[14px]"
119
+ >
120
+ <span className="min-w-0 flex-1 truncate">{row.label}</span>
121
+ {row.badge}
122
+ {row.trailing ? (
123
+ <span className="shrink-0 text-[12.5px] text-muted-foreground">{row.trailing}</span>
124
+ ) : (
125
+ <span className="shrink-0 text-[14px] font-bold tabular-nums">
126
+ {row.amount}
127
+ </span>
128
+ )}
129
+ </li>
130
+ ))}
131
+ </ul>
132
+ ) : null}
133
+ </div>
134
+ )
135
+ );
136
+ TableLedgerCard.displayName = "TableLedgerCard";
137
+
138
+ export { TableLedgerCard, tableLedgerCardVariants };
@@ -0,0 +1,76 @@
1
+ import type { Meta, StoryObj } from "@storybook/react";
2
+ import { Avatar, AvatarFallback, AvatarGroup } from "./avatar";
3
+ import { Button } from "./button";
4
+ import { TableTriggerBand } from "./table-trigger-band";
5
+
6
+ const meta: Meta<typeof TableTriggerBand> = {
7
+ title: "Shared table/TableTriggerBand",
8
+ component: TableTriggerBand,
9
+ tags: ["autodocs"],
10
+ parameters: {
11
+ docs: {
12
+ description: {
13
+ component:
14
+ "The way back to a live table from the venue's own pages. One target at both widths: a chevron under md, the action inside the link from md — a real button nested in a link would give a screen reader two controls where a reader sees one.",
15
+ },
16
+ },
17
+ },
18
+ };
19
+
20
+ export default meta;
21
+ type Story = StoryObj<typeof TableTriggerBand>;
22
+
23
+ const stack = (
24
+ <AvatarGroup size="sm" spacing="tight">
25
+ <Avatar tone="brown">
26
+ <AvatarFallback>A</AvatarFallback>
27
+ </Avatar>
28
+ <Avatar tone="teal">
29
+ <AvatarFallback>B</AvatarFallback>
30
+ </Avatar>
31
+ <Avatar tone="green">
32
+ <AvatarFallback>A</AvatarFallback>
33
+ </Avatar>
34
+ </AvatarGroup>
35
+ );
36
+
37
+ /** T0c — a session is live on this device. */
38
+ export const AtTheTable: Story = {
39
+ args: {
40
+ href: "/t/8f3c1a",
41
+ people: stack,
42
+ title: "You're at Table 18",
43
+ meta: "Adrian, Bruno and you · opened 19:42 · 367 lei so far",
44
+ // `asChild`: the band is the link, so its action must not be a real
45
+ // <button> nested inside an <a>.
46
+ action: (
47
+ <Button asChild variant="cta">
48
+ <span>Open the table</span>
49
+ </Button>
50
+ ),
51
+ },
52
+ };
53
+
54
+ /** T0e — none is, so the band offers to start one. No naming step. */
55
+ export const StartATable: Story = {
56
+ args: {
57
+ href: "/t/new",
58
+ title: "Start a table",
59
+ meta: "Track who had what, then invite the others",
60
+ action: (
61
+ <Button asChild variant="cta">
62
+ <span>Start a table</span>
63
+ </Button>
64
+ ),
65
+ },
66
+ };
67
+
68
+ /** Without an action the chevron carries both widths. */
69
+ export const ChevronOnly: Story = {
70
+ args: {
71
+ href: "/t/8f3c1a",
72
+ people: stack,
73
+ title: "You're at Table 18",
74
+ meta: "3 people · 367 lei so far",
75
+ },
76
+ };
@@ -0,0 +1,75 @@
1
+ import * as React from "react";
2
+ import { ChevronRight } from "lucide-react";
3
+ import { cn } from "../lib/utils";
4
+
5
+ /**
6
+ * TableTriggerBand — the way back to a live shared table from the venue's own
7
+ * pages: "You're at Table 18 · 3 people · 367 lei so far" (16-shared-table.html
8
+ * T0c), and the same band offering to start one when none is live (T0e).
9
+ *
10
+ * It is one target, not a card with a button in it. The frames draw a chevron
11
+ * under md and an "Open the table" button from md, but both mean "this band
12
+ * goes to the table", so the band is the link at both widths and `action`
13
+ * renders inside it as the wide screen's affordance — a span that looks like a
14
+ * button, `aria-hidden`, because the link already carries the name. A real
15
+ * button nested in a link is invalid HTML and gives a screen reader two
16
+ * controls where a reader sees one.
17
+ *
18
+ * `linkComponent` takes the app's router link (`next/link`); it falls back to
19
+ * `a`, so the design system imports no router.
20
+ */
21
+ export type TableTriggerBandProps = {
22
+ /** "You're at Table 18", "Start a table". */
23
+ title: React.ReactNode;
24
+ /** "3 people · 367 lei so far"; desktop draws the longer "Adrian, Bruno and you · opened 19:42". */
25
+ meta?: React.ReactNode;
26
+ /** The avatar stack — an `AvatarGroup`, or nothing when no table is live. */
27
+ people?: React.ReactNode;
28
+ /**
29
+ * The wide screen's call to action. Decorative: the band is the control, so
30
+ * this must NOT be a real `button` — a button inside an anchor is invalid
31
+ * HTML. Pass `<Button asChild><span>…</span></Button>`.
32
+ */
33
+ action?: React.ReactNode;
34
+ href: string;
35
+ /** The app's link component. Defaults to `a`. */
36
+ linkComponent?: React.ElementType;
37
+ className?: string;
38
+ } & Omit<React.AnchorHTMLAttributes<HTMLAnchorElement>, "href" | "title" | "action">;
39
+
40
+ const TableTriggerBand = React.forwardRef<HTMLAnchorElement, TableTriggerBandProps>(
41
+ ({ title, meta, people, action, href, linkComponent, className, ...props }, ref) => {
42
+ const Link = linkComponent ?? "a";
43
+
44
+ return (
45
+ <Link
46
+ ref={ref}
47
+ href={href}
48
+ data-slot="table-trigger-band"
49
+ className={cn(
50
+ "flex items-center gap-3 rounded-2xl border border-primary bg-card px-3.5 py-3 transition-colors hover:bg-accent-subtle focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
51
+ className
52
+ )}
53
+ {...props}
54
+ >
55
+ {people ? <div className="shrink-0">{people}</div> : null}
56
+ <div className="min-w-0 flex-1">
57
+ <div className="truncate text-[15px] font-bold leading-tight">{title}</div>
58
+ {meta ? <div className="mt-0.5 truncate text-[12.5px] text-muted-foreground">{meta}</div> : null}
59
+ </div>
60
+ {action ? (
61
+ <span data-slot="table-trigger-action" aria-hidden className="hidden shrink-0 md:block">
62
+ {action}
63
+ </span>
64
+ ) : null}
65
+ <ChevronRight
66
+ aria-hidden
67
+ className={cn("size-5 shrink-0 text-muted-foreground", action && "md:hidden")}
68
+ />
69
+ </Link>
70
+ );
71
+ }
72
+ );
73
+ TableTriggerBand.displayName = "TableTriggerBand";
74
+
75
+ export { TableTriggerBand };
package/src/globals.css CHANGED
@@ -190,6 +190,31 @@ h6 {
190
190
  --brown: oklch(0.31 0.05 45);
191
191
  --brown-foreground: oklch(0.985 0 0);
192
192
 
193
+ /* Avatar tones — one fixed hue per person at a shared table, so the same
194
+ face reads the same on the ledger card, the organise chip, the split row
195
+ and the activity feed. Sampled from the shared-table board
196
+ (16-shared-table.html): #914F2F, #006469, #196632. All three sit near
197
+ L 0.45–0.50, which carries a white initial at 6.2:1, 6.9:1 and 7.0:1 —
198
+ AA with room, because the initial is small and semibold.
199
+
200
+ They are NOT `--primary` / `--success`: those move with the brand and
201
+ with status, and a person's colour must not. `avatar-brown` is deliberately
202
+ lighter than `--brown` so a disc separates from a brown control behind it. */
203
+ --avatar-brown: oklch(0.5 0.1 45.5);
204
+ --avatar-teal: oklch(0.458 0.078 201);
205
+ --avatar-green: oklch(0.451 0.111 150);
206
+ /* The board draws three people; a table can seat more. The drawn hues sit at
207
+ 45.5°, 150° and 201°, leaving the 205°–405° arc free, so the next three are
208
+ spaced across it at the same step the teal/green pair uses. White initials
209
+ measure 6.57 / 7.30 / 7.12:1 here. Assign in this order: rose (357°) then
210
+ lands furthest from brown (45.5°) in join order. Past six, cycle — at a
211
+ table of seven the initial and the name are doing the identifying, and
212
+ colour is a support cue, never the only one, which is also the answer to
213
+ teal and green reading alike under deuteranopia. */
214
+ --avatar-blue: oklch(0.48 0.13 253);
215
+ --avatar-violet: oklch(0.47 0.14 305);
216
+ --avatar-rose: oklch(0.48 0.15 357);
217
+
193
218
  /* Shadow tokens — color + per-step alpha (themed in .dark) */
194
219
  --shadow-color: 0 0% 0%;
195
220
  --shadow-alpha-2xs: 0.05;
@@ -359,6 +384,18 @@ h6 {
359
384
  --brown: oklch(0.31 0.05 45);
360
385
  --brown-foreground: oklch(0.985 0 0);
361
386
 
387
+ /* Avatar tones, lifted one notch. The light values sit at 2.6–2.9:1 against
388
+ a 0.205 card, so a disc has no edge in dark; +0.06 lightness buys 3.3–3.7:1
389
+ (AA non-text) while the white initial still holds 4.8–5.4:1 (AA text).
390
+ Lifting further trades the initial away for the edge — this is the ceiling. */
391
+ --avatar-brown: oklch(0.56 0.1 45.5);
392
+ --avatar-teal: oklch(0.518 0.078 201);
393
+ --avatar-green: oklch(0.511 0.111 150);
394
+ /* Same +0.06 lift: disc-vs-card 3.54 / 3.19 / 3.26:1, white at 5.07 / 5.63 / 5.50:1. */
395
+ --avatar-blue: oklch(0.54 0.13 253);
396
+ --avatar-violet: oklch(0.53 0.14 305);
397
+ --avatar-rose: oklch(0.54 0.15 357);
398
+
362
399
  /* Heavier shadow alphas — default Tailwind tints disappear on dark surfaces */
363
400
  --shadow-alpha-2xs: 0.25;
364
401
  --shadow-alpha-xs: 0.3;
@@ -424,6 +461,13 @@ h6 {
424
461
  --color-chart-5: var(--chart-5);
425
462
  --color-brown: var(--brown);
426
463
  --color-brown-foreground: var(--brown-foreground);
464
+ /* `bg-avatar-brown` … — a person's fixed hue at a shared table. */
465
+ --color-avatar-brown: var(--avatar-brown);
466
+ --color-avatar-teal: var(--avatar-teal);
467
+ --color-avatar-green: var(--avatar-green);
468
+ --color-avatar-blue: var(--avatar-blue);
469
+ --color-avatar-violet: var(--avatar-violet);
470
+ --color-avatar-rose: var(--avatar-rose);
427
471
 
428
472
  /* Radius scale */
429
473
  --radius-sm: calc(var(--radius) - 4px);
package/src/test/setup.ts CHANGED
@@ -11,3 +11,14 @@ globalThis.ResizeObserver = class ResizeObserver {
11
11
  };
12
12
 
13
13
  window.HTMLElement.prototype.scrollIntoView = function () {};
14
+
15
+ // jsdom implements no Pointer Events capture API. vaul calls
16
+ // `setPointerCapture` on the first pointerdown inside a Drawer, so without
17
+ // these any test that clicks inside a sheet fails with an unhandled
18
+ // TypeError — and the failure lands on whichever test ran last, not on the
19
+ // one that caused it.
20
+ window.HTMLElement.prototype.setPointerCapture = function () {};
21
+ window.HTMLElement.prototype.releasePointerCapture = function () {};
22
+ window.HTMLElement.prototype.hasPointerCapture = function () {
23
+ return false;
24
+ };