@brdgapp/ui 0.1.1 → 1.0.0

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 (123) hide show
  1. package/CHANGELOG.md +83 -0
  2. package/README.md +39 -85
  3. package/components/action-menu.d.ts +65 -0
  4. package/components/action-menu.js +48 -0
  5. package/components/avatar.d.ts +50 -0
  6. package/components/avatar.js +91 -0
  7. package/components/badge.d.ts +12 -0
  8. package/components/badge.js +23 -0
  9. package/components/bottom-sheet.d.ts +17 -0
  10. package/components/bottom-sheet.js +12 -0
  11. package/components/brand-mark.d.ts +20 -0
  12. package/components/brand-mark.js +44 -0
  13. package/components/button-group.d.ts +25 -0
  14. package/components/button-group.js +53 -0
  15. package/components/button.d.ts +33 -0
  16. package/components/button.js +82 -0
  17. package/components/card.d.ts +78 -0
  18. package/components/card.js +75 -0
  19. package/components/checkbox.d.ts +22 -0
  20. package/components/checkbox.js +35 -0
  21. package/components/collapsible.d.ts +29 -0
  22. package/components/collapsible.js +38 -0
  23. package/components/column-filter-menu.d.ts +15 -0
  24. package/components/column-filter-menu.js +30 -0
  25. package/components/column-sort-button.d.ts +18 -0
  26. package/components/column-sort-button.js +14 -0
  27. package/components/confirm-dialog.d.ts +20 -0
  28. package/components/confirm-dialog.js +17 -0
  29. package/components/copy-field.d.ts +17 -0
  30. package/components/copy-field.js +36 -0
  31. package/components/data-state.d.ts +44 -0
  32. package/components/data-state.js +25 -0
  33. package/components/data-table.d.ts +65 -0
  34. package/components/data-table.js +84 -0
  35. package/components/dialog.d.ts +19 -0
  36. package/components/dialog.js +10 -0
  37. package/components/drawer.d.ts +21 -0
  38. package/components/drawer.js +17 -0
  39. package/components/expandable-bio.d.ts +8 -0
  40. package/components/expandable-bio.js +26 -0
  41. package/components/field.d.ts +29 -0
  42. package/components/field.js +27 -0
  43. package/components/filter-chip.d.ts +11 -0
  44. package/components/filter-chip.js +8 -0
  45. package/components/filter-pill.d.ts +34 -0
  46. package/components/filter-pill.js +35 -0
  47. package/components/hover-card.d.ts +21 -0
  48. package/components/hover-card.js +20 -0
  49. package/components/icon-button.d.ts +16 -0
  50. package/components/icon-button.js +32 -0
  51. package/components/illustration.d.ts +33 -0
  52. package/components/illustration.js +70 -0
  53. package/components/info-hint.d.ts +29 -0
  54. package/components/info-hint.js +42 -0
  55. package/components/input.d.ts +9 -0
  56. package/components/input.js +15 -0
  57. package/components/network-graph.d.ts +48 -0
  58. package/components/network-graph.js +37 -0
  59. package/components/page-banner.d.ts +20 -0
  60. package/components/page-banner.js +29 -0
  61. package/components/scene.d.ts +12 -0
  62. package/components/scene.js +7 -0
  63. package/components/search-input.d.ts +8 -0
  64. package/components/search-input.js +14 -0
  65. package/components/segmented-control.d.ts +18 -0
  66. package/components/segmented-control.js +14 -0
  67. package/components/skeleton.d.ts +12 -0
  68. package/components/skeleton.js +21 -0
  69. package/components/skip-link.d.ts +8 -0
  70. package/components/skip-link.js +7 -0
  71. package/components/stat-box.d.ts +23 -0
  72. package/components/stat-box.js +29 -0
  73. package/components/switch.d.ts +13 -0
  74. package/components/switch.js +8 -0
  75. package/components/tabs.d.ts +6 -0
  76. package/components/tabs.js +25 -0
  77. package/components/tag-input.d.ts +15 -0
  78. package/components/tag-input.js +40 -0
  79. package/components/tag.d.ts +27 -0
  80. package/components/tag.js +35 -0
  81. package/components/textarea.d.ts +12 -0
  82. package/components/textarea.js +78 -0
  83. package/components/theme-switch.d.ts +6 -0
  84. package/components/theme-switch.js +16 -0
  85. package/components/toast.d.ts +25 -0
  86. package/components/toast.js +44 -0
  87. package/hooks/use-is-mobile.d.ts +2 -0
  88. package/hooks/use-is-mobile.js +16 -0
  89. package/hooks/use-mock-query.d.ts +23 -0
  90. package/hooks/use-mock-query.js +36 -0
  91. package/hooks/use-theme.d.ts +11 -0
  92. package/hooks/use-theme.js +34 -0
  93. package/lib/demo-controls.d.ts +24 -0
  94. package/lib/demo-controls.js +24 -0
  95. package/lib/format.d.ts +17 -0
  96. package/lib/format.js +55 -0
  97. package/lib/layout.d.ts +19 -0
  98. package/lib/layout.js +24 -0
  99. package/lib/motion.d.ts +16 -0
  100. package/lib/motion.js +18 -0
  101. package/lib/theme.d.ts +21 -0
  102. package/lib/theme.js +63 -0
  103. package/lib/utils.d.ts +22 -0
  104. package/lib/utils.js +27 -0
  105. package/package.json +32 -16
  106. package/styles/globals.css +595 -0
  107. package/src/index.ts +0 -18
  108. package/src/network-people-helper/README.md +0 -130
  109. package/src/network-people-helper/api.ts +0 -174
  110. package/src/network-people-helper/contact-avatar.tsx +0 -63
  111. package/src/network-people-helper/domain.ts +0 -14
  112. package/src/network-people-helper/icons.tsx +0 -130
  113. package/src/network-people-helper/index.ts +0 -6
  114. package/src/network-people-helper/network-people-helper.tsx +0 -397
  115. package/src/network-people-helper/network-people-nudge.tsx +0 -98
  116. package/src/network-people-helper/open-helper.ts +0 -22
  117. package/src/network-people-helper/person-menu.tsx +0 -211
  118. package/src/network-people-helper/styles.css +0 -848
  119. package/src/network-people-helper/types.ts +0 -23
  120. package/src/network-people-helper/use-network-people-list.ts +0 -122
  121. package/src/network-people-helper/use-network-preview.ts +0 -43
  122. package/src/provider.tsx +0 -59
  123. package/src/styles.css +0 -1
@@ -0,0 +1,14 @@
1
+ "use client";
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { Button as ButtonPrimitive } from "@base-ui/react/button";
4
+ import { ArrowDown, ArrowUp, ArrowUpDown } from "lucide-react";
5
+ import { Tip } from "@brdgapp/ui/components/info-hint";
6
+ import { cn } from "@brdgapp/ui/lib/utils";
7
+ function ColumnSortButton({ icon: Icon, label, tooltip, direction, describe, onClick, ...rest }) {
8
+ const state = direction && describe ? `, sorted ${describe[direction]}` : "";
9
+ return (_jsx(Tip, { text: tooltip, children: _jsxs(ButtonPrimitive, { ...rest, "data-slot": "column-sort-button", onClick: (e) => {
10
+ e.stopPropagation();
11
+ onClick();
12
+ }, "aria-label": `Sort by ${label.toLowerCase()}${state}`, className: cn("-mx-1.5 inline-flex items-center gap-1.5 rounded px-1.5 py-0.5 text-[12px] font-bold whitespace-nowrap outline-none transition-colors", "hover:bg-[var(--surface-tertiary)] focus-visible:ring-2 focus-visible:ring-[var(--primary)]", direction ? "text-[var(--text-primary)]" : "text-[var(--text-secondary)]"), children: [_jsx(Icon, { "aria-hidden": "true", className: "size-3.5 text-[var(--text-muted)]" }), label, direction === "desc" ? (_jsx(ArrowDown, { "aria-hidden": "true", className: "size-3 shrink-0 text-[var(--primary)]" })) : direction === "asc" ? (_jsx(ArrowUp, { "aria-hidden": "true", className: "size-3 shrink-0 text-[var(--primary)]" })) : (_jsx(ArrowUpDown, { "aria-hidden": "true", className: "size-3 shrink-0 opacity-40" }))] }) }));
13
+ }
14
+ export { ColumnSortButton };
@@ -0,0 +1,20 @@
1
+ import type * as React from "react";
2
+ type ConfirmDialogProps = {
3
+ open: boolean;
4
+ onClose: () => void;
5
+ /** A question naming the thing: "Stop sharing with Leah Okonkwo?" */
6
+ title: string;
7
+ /** What happens, in a sentence or two. Can emphasise a name. */
8
+ body: React.ReactNode;
9
+ /** The action, naming the object: "Stop sharing". Never "OK" or "Yes". */
10
+ confirmLabel: string;
11
+ confirmIcon?: React.ReactNode;
12
+ /** Red confirm button and a warning icon, for things that can't be undone. */
13
+ destructive?: boolean;
14
+ /** The action is running: the confirm button spins, and the dialog can't be dismissed. */
15
+ busy?: boolean;
16
+ onConfirm: () => void | Promise<void>;
17
+ };
18
+ declare function ConfirmDialog({ open, onClose, title, body, confirmLabel, confirmIcon, destructive, busy, onConfirm }: ConfirmDialogProps): React.JSX.Element;
19
+ export { ConfirmDialog };
20
+ export type { ConfirmDialogProps };
@@ -0,0 +1,17 @@
1
+ "use client";
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { AlertDialog } from "@base-ui/react/alert-dialog";
4
+ import { TriangleAlert } from "lucide-react";
5
+ import { Button } from "@brdgapp/ui/components/button";
6
+ function ConfirmDialog({ open, onClose, title, body, confirmLabel, confirmIcon, destructive = false, busy = false, onConfirm }) {
7
+ return (_jsx(AlertDialog.Root, { open: open, onOpenChange: (next) => {
8
+ // Escape and Cancel close it — but never mid-request: hiding the dialog then would hide the
9
+ // outcome, and the person can't tell whether the thing happened.
10
+ if (!next && !busy)
11
+ onClose();
12
+ }, children: _jsxs(AlertDialog.Portal, { children: [_jsx(AlertDialog.Backdrop, { className: "fixed inset-0 z-[90] bg-black/40 backdrop-blur-sm transition-opacity duration-300 ease-out data-[ending-style]:duration-200 data-[starting-style]:opacity-0 data-[ending-style]:opacity-0" }), _jsxs(AlertDialog.Popup, { "data-slot": "confirm-dialog", className: "fixed top-1/2 left-1/2 z-[90] w-[88vw] max-w-sm -translate-x-1/2 -translate-y-1/2 rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] p-6 shadow-[var(--shadow-dialog)] outline-none transition-[opacity,scale] duration-300 ease-out data-[ending-style]:duration-200 data-[starting-style]:scale-95 data-[starting-style]:opacity-0 data-[ending-style]:opacity-0", children: [destructive ? (_jsx("span", { "aria-hidden": "true", className: "mb-4 flex size-10 items-center justify-center rounded-full bg-[var(--error-bg)] text-[var(--danger-text)]", children: _jsx(TriangleAlert, { className: "size-5" }) })) : null, _jsx(AlertDialog.Title, { className: "text-18 font-bold tracking-tight text-[var(--text-primary)]", children: title }), _jsx(AlertDialog.Description, { render: _jsx("div", {}), className: "mt-2 text-[13px] leading-relaxed text-[var(--text-body)]", children: body }), _jsxs("div", { className: "mt-5 flex gap-2", children: [_jsx(AlertDialog.Close, { disabled: busy, render: _jsx(Button, { variant: "secondary", size: "md", fullWidth: true, children: "Cancel" }) }), _jsx(Button, { variant: destructive ? "destructive" : "primary", size: "md", fullWidth: true, loading: busy, icon: confirmIcon, onClick: async () => {
13
+ await onConfirm();
14
+ onClose();
15
+ }, children: confirmLabel })] })] })] }) }));
16
+ }
17
+ export { ConfirmDialog };
@@ -0,0 +1,17 @@
1
+ import { type ElementProps } from "@brdgapp/ui/lib/utils";
2
+ type CopyFieldProps = ElementProps<HTMLButtonElement> & {
3
+ value: string;
4
+ kind: "email" | "phone";
5
+ /** `row`: a full-width contact row with a Copy hint on hover. `inline`: a compact meta item. */
6
+ variant?: "row" | "inline";
7
+ /** Inline only: the stronger body colour, for a prominent identity line. */
8
+ strong?: boolean;
9
+ /** Inline only: text size. */
10
+ size?: 12 | 14;
11
+ /** Inline only: show the mail / phone icon. */
12
+ showIcon?: boolean;
13
+ className?: string;
14
+ };
15
+ declare function CopyField({ value, kind, variant, strong, size, showIcon, className, ...rest }: CopyFieldProps): import("react").JSX.Element;
16
+ export { CopyField };
17
+ export type { CopyFieldProps };
@@ -0,0 +1,36 @@
1
+ "use client";
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { useEffect, useState } from "react";
4
+ import { Button as ButtonPrimitive } from "@base-ui/react/button";
5
+ import { Check, Copy, Mail, Phone } from "lucide-react";
6
+ import { cn } from "@brdgapp/ui/lib/utils";
7
+ function CopyField({ value, kind, variant = "row", strong = false, size = 12, showIcon = true, className, ...rest }) {
8
+ const [copied, setCopied] = useState(false);
9
+ useEffect(() => {
10
+ if (!copied)
11
+ return;
12
+ const timer = setTimeout(() => setCopied(false), 1500);
13
+ return () => clearTimeout(timer);
14
+ }, [copied]);
15
+ async function copy() {
16
+ try {
17
+ await navigator.clipboard.writeText(value);
18
+ setCopied(true);
19
+ }
20
+ catch {
21
+ // Clipboard blocked (permissions or an insecure page): nothing to undo.
22
+ }
23
+ }
24
+ const Icon = kind === "email" ? Mail : Phone;
25
+ const label = `Copy ${kind === "email" ? "email" : "phone number"}`;
26
+ const announcement = (_jsx("span", { className: "sr-only", "aria-live": "polite", children: copied ? "Copied" : "" }));
27
+ if (variant === "inline") {
28
+ return (_jsxs(ButtonPrimitive, { ...rest, "data-slot": "copy-field", onClick: copy, "aria-label": `${label}: ${value}`, className: cn("inline-flex min-w-0 items-center gap-1.5 rounded outline-none transition-colors focus-visible:ring-2 focus-visible:ring-[var(--primary)]", size === 14 ? "text-[14px]" : "text-[12px]", strong
29
+ ? "text-[var(--text-body)] hover:text-[var(--text-primary)]"
30
+ : "text-[var(--text-muted)] hover:text-[var(--text-secondary)]", className), children: [copied ? (_jsx(Check, { "aria-hidden": "true", className: "size-3.5 shrink-0 text-[var(--success)]" })) : showIcon ? (_jsx(Icon, { "aria-hidden": "true", className: "size-3.5 shrink-0" })) : null, _jsx("span", { className: "truncate", children: copied ? "Copied" : value }), announcement] }));
31
+ }
32
+ return (_jsxs(ButtonPrimitive, { ...rest, "data-slot": "copy-field", onClick: copy, "aria-label": `${label}: ${value}`, className: cn("group flex w-full items-center gap-3 rounded-lg px-3 py-2.5 text-left outline-none transition-colors", "hover:bg-[var(--surface-secondary)] focus-visible:ring-2 focus-visible:ring-[var(--primary)]", className), children: [_jsx(Icon, { "aria-hidden": "true", className: "size-4 shrink-0 text-[var(--text-disabled)]" }), _jsx("span", { className: "min-w-0 flex-1 truncate text-[13px] text-[var(--text-body)] group-hover:text-[var(--text-primary)]", children: value }), _jsxs("span", { "aria-hidden": "true", className: cn("inline-flex shrink-0 items-center gap-1 text-[12px] font-semibold transition-opacity", copied
33
+ ? "text-[var(--success-text)] opacity-100"
34
+ : "text-[var(--text-muted)] [@media(hover:hover)]:opacity-0 group-hover:opacity-100 group-focus-visible:opacity-100"), children: [copied ? _jsx(Check, { className: "size-3.5" }) : _jsx(Copy, { className: "size-3.5" }), copied ? "Copied" : "Copy"] }), announcement] }));
35
+ }
36
+ export { CopyField };
@@ -0,0 +1,44 @@
1
+ import type * as React from "react";
2
+ import { type IllustrationName } from "@brdgapp/ui/components/illustration";
3
+ type DataStatus = "loading" | "error" | "success";
4
+ type EmptyContent = {
5
+ /** A bare Lucide icon, shown in a soft circle. Use `illustration` instead where a drawing fits. */
6
+ icon?: React.ReactNode;
7
+ /** One of Bridge's drawings, in place of the icon: "people", "intros", "search"… */
8
+ illustration?: IllustrationName;
9
+ /** "sm" (80px) inside a card or side panel; "md" (120px, the default) for a page's main region. */
10
+ illustrationSize?: "sm" | "md";
11
+ /** Say what would be here, not just that it's empty: "No intros yet". */
12
+ title: string;
13
+ /** What the person gets once there's data, or how to get some. */
14
+ description?: string;
15
+ /** One action, usually a primary Button. */
16
+ action?: React.ReactNode;
17
+ };
18
+ type DataStateProps<T> = {
19
+ status: DataStatus;
20
+ data: T | undefined;
21
+ /**
22
+ * The loading body. Make it the same size and shape as the loaded content (rows, cells, tiles), so
23
+ * nothing moves when the data arrives.
24
+ */
25
+ skeleton: React.ReactNode;
26
+ /** Decides whether loaded data counts as empty. Defaults to: an empty array, null or undefined. */
27
+ isEmpty?: (data: T) => boolean;
28
+ empty: EmptyContent;
29
+ /** What failed to load, finishing "Couldn't load …": "introductions", "this profile". */
30
+ errorSubject: string;
31
+ /** Load again. When given, the error shows a Retry action. */
32
+ onRetry?: () => void;
33
+ /** The loaded body. Only called with data that isn't empty. */
34
+ children: (data: T) => React.ReactNode;
35
+ className?: string;
36
+ };
37
+ /**
38
+ * The one way a data region shows loading, empty and error. It goes INSIDE the card or section: the
39
+ * frame (title, icon, hint) always renders, and only this body changes. A region never disappears
40
+ * because its data is missing, and loading never takes over the whole page.
41
+ */
42
+ declare function DataState<T>({ status, data, skeleton, isEmpty, empty, errorSubject, onRetry, children, className, }: DataStateProps<T>): React.JSX.Element;
43
+ export { DataState };
44
+ export type { DataStateProps, DataStatus, EmptyContent };
@@ -0,0 +1,25 @@
1
+ "use client";
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { AlertCircle } from "lucide-react";
4
+ import { Button } from "@brdgapp/ui/components/button";
5
+ import { Illustration } from "@brdgapp/ui/components/illustration";
6
+ import { cn } from "@brdgapp/ui/lib/utils";
7
+ function defaultIsEmpty(data) {
8
+ return data == null || (Array.isArray(data) && data.length === 0);
9
+ }
10
+ /**
11
+ * The one way a data region shows loading, empty and error. It goes INSIDE the card or section: the
12
+ * frame (title, icon, hint) always renders, and only this body changes. A region never disappears
13
+ * because its data is missing, and loading never takes over the whole page.
14
+ */
15
+ function DataState({ status, data, skeleton, isEmpty = defaultIsEmpty, empty, errorSubject, onRetry, children, className, }) {
16
+ const state = status !== "success" ? status : data === undefined || isEmpty(data) ? "empty" : "ready";
17
+ return (_jsxs("div", { "data-slot": "data-state", "data-state": state, "aria-busy": state === "loading" || undefined, "aria-live": "polite", className: cn("min-w-0", className), children: [state === "loading" ? skeleton : null, state === "error" ? _jsx(DataStateError, { subject: errorSubject, onRetry: onRetry }) : null, state === "empty" ? _jsx(DataStateEmpty, { ...empty }) : null, state === "ready" ? children(data) : null] }));
18
+ }
19
+ function DataStateEmpty({ icon, illustration, illustrationSize = "md", title, description, action }) {
20
+ return (_jsxs("div", { "data-slot": "data-state-empty", className: "flex flex-col items-center gap-4 px-6 py-10 text-center", children: [illustration ? (_jsx(Illustration, { name: illustration, size: illustrationSize })) : icon ? (_jsx("span", { "aria-hidden": "true", className: "flex size-12 items-center justify-center rounded-full bg-[var(--surface-secondary)] text-[var(--text-disabled)] [&_svg]:size-6", children: icon })) : null, _jsxs("div", { children: [_jsx("p", { className: "text-16 font-bold tracking-tight text-[var(--text-primary)]", children: title }), description ? (_jsx("p", { className: "mx-auto mt-1 max-w-xs text-[13px] leading-relaxed text-[var(--text-body)]", children: description })) : null] }), action] }));
21
+ }
22
+ function DataStateError({ subject, onRetry }) {
23
+ return (_jsxs("div", { "data-slot": "data-state-error", role: "alert", className: "flex flex-col items-center gap-3 px-6 py-10 text-center", children: [_jsx("span", { "aria-hidden": "true", className: "flex size-12 items-center justify-center rounded-full bg-[var(--error-bg)] text-[var(--danger-text)] [&_svg]:size-6", children: _jsx(AlertCircle, {}) }), _jsxs("p", { className: "text-16 font-bold tracking-tight text-[var(--text-primary)]", children: ["Couldn\u2019t load ", subject] }), onRetry ? (_jsx(Button, { variant: "link", size: "sm", onClick: onRetry, children: "Try again" })) : (_jsx("p", { className: "text-[13px] text-[var(--text-body)]", children: "Try again shortly." }))] }));
24
+ }
25
+ export { DataState };
@@ -0,0 +1,65 @@
1
+ import type * as React from "react";
2
+ import { type LucideIcon } from "lucide-react";
3
+ import { type ElementProps } from "@brdgapp/ui/lib/utils";
4
+ type DataTableColumn<T> = {
5
+ key: string;
6
+ label: string;
7
+ icon: LucideIcon;
8
+ /** Comfortable width in px. The column never gets narrower; below the total, the table scrolls. */
9
+ minWidth: number;
10
+ /** Share of any extra width on wide screens. Default 1; a small badge column might use 0.3. */
11
+ weight?: number;
12
+ /** Explains the column in a tooltip on its header. */
13
+ tooltip?: string;
14
+ /** Replaces the default header, e.g. with ColumnSortButton or ColumnFilterMenu. */
15
+ headerContent?: React.ReactNode;
16
+ render: (row: T) => React.ReactNode;
17
+ };
18
+ type ColumnMeta = Pick<DataTableColumn<unknown>, "key" | "label" | "icon" | "minWidth" | "weight" | "tooltip">;
19
+ /** The default column header: icon + label, with an optional explanation. */
20
+ declare function DataTableHeader({ icon: Icon, label, tooltip }: {
21
+ icon: LucideIcon;
22
+ label: string;
23
+ tooltip?: string;
24
+ }): React.JSX.Element;
25
+ type DataTableProps<T> = {
26
+ columns: DataTableColumn<T>[];
27
+ rows: T[];
28
+ rowKey: (row: T) => string;
29
+ /** Makes rows clickable (and reachable with Tab, opened with Enter). */
30
+ onRowClick?: (row: T) => void;
31
+ /** Called on hover or focus, to warm data a click will need. Keep it cheap and repeatable. */
32
+ onRowPrefetch?: (row: T) => void;
33
+ /** Extra attributes for each row (`data-*`, `aria-*`, `id`). The table's own row props win. */
34
+ rowProps?: (row: T) => ElementProps<HTMLDivElement>;
35
+ /** Names the table for screen readers: "Introductions". */
36
+ "aria-label"?: string;
37
+ };
38
+ declare function DataTable<T>({ columns, rows, rowKey, onRowClick, onRowPrefetch, rowProps, ...props }: DataTableProps<T>): React.JSX.Element;
39
+ /**
40
+ * The loading body for a DataTable. Takes the same column list (without `render`), so its widths and
41
+ * column count can't drift from the real table. To make the swap to real data jump-free, pass the
42
+ * loaded row's `rowHeight` and, if the table has a pager, `pagination`.
43
+ */
44
+ declare function DataTableSkeleton({ columns, rows, rowHeight, pagination, }: {
45
+ columns: ColumnMeta[];
46
+ rows?: number;
47
+ /** A loaded row's height in px, not counting its 1px divider (a row with a 28px avatar is 56). */
48
+ rowHeight?: number;
49
+ /** Reserve the pager's 52px under the rows. */
50
+ pagination?: boolean;
51
+ }): React.JSX.Element;
52
+ /**
53
+ * The pager under a DataTable: "1–15 of 34", previous, page numbers, next. Needs an EXACT total: with
54
+ * an estimated count or varying page sizes, the page numbers would lie — use "load more" there.
55
+ * Renders nothing when there is only one page.
56
+ */
57
+ declare function DataTablePagination({ page, pageSize, totalCount, loading, onPageChange, }: {
58
+ page: number;
59
+ pageSize: number;
60
+ totalCount: number;
61
+ loading?: boolean;
62
+ onPageChange: (page: number) => void;
63
+ }): React.JSX.Element | null;
64
+ export { DataTable, DataTableHeader, DataTableSkeleton, DataTablePagination };
65
+ export type { DataTableColumn, DataTableProps };
@@ -0,0 +1,84 @@
1
+ "use client";
2
+ import { createElement as _createElement } from "react";
3
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
4
+ import { Button as ButtonPrimitive } from "@base-ui/react/button";
5
+ import { ChevronLeft, ChevronRight, Loader2 } from "lucide-react";
6
+ import { Tip } from "@brdgapp/ui/components/info-hint";
7
+ import { cn } from "@brdgapp/ui/lib/utils";
8
+ function gridTemplate(columns) {
9
+ return columns.map((c) => `minmax(${c.minWidth}px, ${c.weight ?? 1}fr)`).join(" ");
10
+ }
11
+ function minTableWidth(columns) {
12
+ return columns.reduce((sum, c) => sum + c.minWidth, 0);
13
+ }
14
+ function cellPadding(index, count) {
15
+ if (index === 0)
16
+ return "pl-6 pr-3";
17
+ if (index === count - 1)
18
+ return "pl-3 pr-6";
19
+ return "px-3";
20
+ }
21
+ /** The default column header: icon + label, with an optional explanation. */
22
+ function DataTableHeader({ icon: Icon, label, tooltip }) {
23
+ return (_jsx(Tip, { text: tooltip, focusable: Boolean(tooltip), children: _jsxs("span", { "data-slot": "data-table-header", className: "inline-flex items-center gap-1.5 text-[12px] font-bold whitespace-nowrap text-[var(--text-secondary)]", children: [_jsx(Icon, { "aria-hidden": "true", className: "size-3.5 text-[var(--text-muted)]" }), label] }) }));
24
+ }
25
+ function HeaderRow({ columns, template }) {
26
+ return (_jsx("div", { role: "row", className: "grid border-b border-[var(--border-divider)] bg-[var(--surface-secondary)]/40", style: { gridTemplateColumns: template }, children: columns.map((c, i) => (
27
+ // Height comes from the content, as in Bridge OS: 42px for a plain header, 46px with a sort or
28
+ // filter button. (Open question for Aman: one fixed height would stop the 4px skeleton jump on
29
+ // sortable tables but make plain headers taller.)
30
+ _jsx("div", { role: "columnheader", className: cn(cellPadding(i, columns.length), "flex items-center py-3"), children: c.headerContent ?? _jsx(DataTableHeader, { icon: c.icon, label: c.label, tooltip: c.tooltip }) }, c.key))) }));
31
+ }
32
+ function DataTable({ columns, rows, rowKey, onRowClick, onRowPrefetch, rowProps, ...props }) {
33
+ const template = gridTemplate(columns);
34
+ return (_jsx("div", { "data-slot": "data-table", className: "overflow-x-auto", children: _jsxs("div", { role: "table", "aria-label": props["aria-label"], style: { minWidth: minTableWidth(columns) }, children: [_jsx(HeaderRow, { columns: columns, template: template }), _jsx("div", { role: "rowgroup", children: rows.map((row) => (_createElement("div", { ...rowProps?.(row), key: rowKey(row), role: "row", "data-slot": "data-table-row", tabIndex: onRowClick ? 0 : undefined, onClick: onRowClick ? () => onRowClick(row) : undefined, onKeyDown: onRowClick
35
+ ? (e) => {
36
+ // Only the row itself: a button inside the row handles its own keys.
37
+ if (e.target === e.currentTarget && (e.key === "Enter" || e.key === " ")) {
38
+ e.preventDefault();
39
+ onRowClick(row);
40
+ }
41
+ }
42
+ : undefined, onMouseEnter: onRowPrefetch ? () => onRowPrefetch(row) : undefined, onFocus: onRowPrefetch ? () => onRowPrefetch(row) : undefined, className: cn("grid border-b border-[var(--border-divider)] transition-colors last:border-0", onRowClick
43
+ ? "cursor-pointer outline-none hover:bg-[var(--surface-tertiary)] focus-visible:bg-[var(--surface-tertiary)] focus-visible:ring-2 focus-visible:ring-[var(--primary)] focus-visible:ring-inset"
44
+ : "hover:bg-[var(--surface-secondary)]/40"), style: { gridTemplateColumns: template } }, columns.map((c, i) => (_jsx("div", { role: "cell", className: cn(cellPadding(i, columns.length), "flex min-w-0 items-center py-3.5"), children: _jsx("div", { className: "w-full min-w-0", children: c.render(row) }) }, c.key)))))) })] }) }));
45
+ }
46
+ /**
47
+ * The loading body for a DataTable. Takes the same column list (without `render`), so its widths and
48
+ * column count can't drift from the real table. To make the swap to real data jump-free, pass the
49
+ * loaded row's `rowHeight` and, if the table has a pager, `pagination`.
50
+ */
51
+ function DataTableSkeleton({ columns, rows = 5, rowHeight, pagination = false, }) {
52
+ const template = gridTemplate(columns);
53
+ return (_jsxs("div", { "data-slot": "data-table-skeleton", "aria-hidden": "true", className: "overflow-x-auto", children: [_jsxs("div", { style: { minWidth: minTableWidth(columns) }, children: [_jsx(HeaderRow, { columns: columns, template: template }), Array.from({ length: rows }, (_, r) => (_jsx("div", { className: "grid border-b border-[var(--border-divider)] last:border-0", style: { gridTemplateColumns: template }, children: columns.map((c, i) => (
54
+ // The height goes on the cells, so the row's divider adds to it exactly as in the real table.
55
+ _jsx("div", { className: cn(cellPadding(i, columns.length), "flex min-w-0 items-center py-3.5"), style: { minHeight: rowHeight }, children: _jsx("div", { className: "h-3.5 w-full max-w-[160px] animate-pulse rounded bg-[var(--surface-tertiary)] motion-reduce:animate-none" }) }, c.key))) }, r)))] }), pagination ? (_jsxs("div", { className: "flex h-[52px] items-center justify-between px-6", children: [_jsx("div", { className: "h-3 w-20 animate-pulse rounded bg-[var(--surface-tertiary)] motion-reduce:animate-none" }), _jsx("div", { className: "h-7 w-32 animate-pulse rounded-lg bg-[var(--surface-tertiary)] motion-reduce:animate-none" })] })) : null] }));
56
+ }
57
+ const pagerButton = "flex size-7 items-center justify-center rounded-lg text-[var(--text-muted)] outline-none transition-colors hover:bg-[var(--surface-secondary)] hover:text-[var(--text-primary)] focus-visible:ring-2 focus-visible:ring-[var(--primary)] disabled:cursor-not-allowed disabled:opacity-50";
58
+ /** 1 … 4 5 6 … 12 — first and last always, the current page ±1, an ellipsis for gaps. */
59
+ function pageList(page, totalPages) {
60
+ if (totalPages <= 7)
61
+ return Array.from({ length: totalPages }, (_, i) => i + 1);
62
+ const pages = [1];
63
+ if (page > 3)
64
+ pages.push("…");
65
+ for (let i = Math.max(2, page - 1); i <= Math.min(totalPages - 1, page + 1); i++)
66
+ pages.push(i);
67
+ if (page < totalPages - 2)
68
+ pages.push("…");
69
+ pages.push(totalPages);
70
+ return pages;
71
+ }
72
+ /**
73
+ * The pager under a DataTable: "1–15 of 34", previous, page numbers, next. Needs an EXACT total: with
74
+ * an estimated count or varying page sizes, the page numbers would lie — use "load more" there.
75
+ * Renders nothing when there is only one page.
76
+ */
77
+ function DataTablePagination({ page, pageSize, totalCount, loading = false, onPageChange, }) {
78
+ const totalPages = Math.max(1, Math.ceil(totalCount / pageSize));
79
+ if (totalPages <= 1)
80
+ return null;
81
+ const n = (v) => v.toLocaleString("en-US");
82
+ return (_jsxs("nav", { "data-slot": "data-table-pagination", "aria-label": "Pages", className: "flex items-center justify-between gap-4 px-6 py-3", children: [_jsxs("p", { className: "shrink-0 text-[12px] tabular-nums text-[var(--text-muted)]", children: [n((page - 1) * pageSize + 1), "\u2013", n(Math.min(page * pageSize, totalCount)), " of ", n(totalCount)] }), _jsxs("div", { className: "flex items-center gap-1", children: [_jsx(ButtonPrimitive, { onClick: () => onPageChange(page - 1), disabled: page === 1 || loading, "aria-label": "Previous page", className: pagerButton, children: _jsx(ChevronLeft, { "aria-hidden": "true", className: "size-3.5" }) }), pageList(page, totalPages).map((p, i) => p === "…" ? (_jsx("span", { "aria-hidden": "true", className: "flex size-7 items-center justify-center text-[12px] text-[var(--text-muted)]", children: "\u2026" }, `gap-${i}`)) : (_jsx(ButtonPrimitive, { onClick: () => onPageChange(p), disabled: loading, "aria-label": `Page ${p}`, "aria-current": p === page ? "page" : undefined, className: cn(pagerButton, "w-auto min-w-7 px-1.5 text-[12px] font-semibold tabular-nums disabled:opacity-100", p === page && "bg-[var(--primary-solid)] text-white hover:bg-[var(--primary-solid)] hover:text-white"), children: loading && p === page ? _jsx(Loader2, { "aria-hidden": "true", className: "mx-auto size-3 animate-spin" }) : p }, p))), _jsx(ButtonPrimitive, { onClick: () => onPageChange(page + 1), disabled: page === totalPages || loading, "aria-label": "Next page", className: pagerButton, children: _jsx(ChevronRight, { "aria-hidden": "true", className: "size-3.5" }) })] })] }));
83
+ }
84
+ export { DataTable, DataTableHeader, DataTableSkeleton, DataTablePagination };
@@ -0,0 +1,19 @@
1
+ import type * as React from "react";
2
+ type DialogProps = {
3
+ open: boolean;
4
+ onClose: () => void;
5
+ /** What the dialog does: "Add a network", "Edit Saltmarsh Partners". */
6
+ title: string;
7
+ /** One optional line under the title. */
8
+ description?: React.ReactNode;
9
+ /** The form. */
10
+ children: React.ReactNode;
11
+ /** The buttons. Put a destructive action first (it sits on the left), then Cancel and the primary action. */
12
+ footer?: React.ReactNode;
13
+ /** A save is running: the dialog can't be dismissed until it finishes. */
14
+ busy?: boolean;
15
+ className?: string;
16
+ };
17
+ declare function Dialog({ open, onClose, title, description, children, footer, busy, className }: DialogProps): React.JSX.Element;
18
+ export { Dialog };
19
+ export type { DialogProps };
@@ -0,0 +1,10 @@
1
+ "use client";
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { Dialog as DialogPrimitive } from "@base-ui/react/dialog";
4
+ import { X } from "lucide-react";
5
+ import { IconButton } from "@brdgapp/ui/components/icon-button";
6
+ import { cn } from "@brdgapp/ui/lib/utils";
7
+ function Dialog({ open, onClose, title, description, children, footer, busy = false, className }) {
8
+ return (_jsx(DialogPrimitive.Root, { open: open, onOpenChange: (next) => !next && !busy && onClose(), children: _jsxs(DialogPrimitive.Portal, { children: [_jsx(DialogPrimitive.Backdrop, { className: "fixed inset-0 z-[90] bg-black/40 backdrop-blur-sm transition-opacity duration-300 ease-out data-[ending-style]:duration-200 data-[starting-style]:opacity-0 data-[ending-style]:opacity-0" }), _jsxs(DialogPrimitive.Popup, { "data-slot": "dialog", className: cn("fixed top-1/2 left-1/2 z-[90] flex max-h-[calc(100dvh-32px)] w-[calc(100vw-32px)] max-w-lg -translate-x-1/2 -translate-y-1/2 flex-col rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] shadow-[var(--shadow-dialog)] outline-none", "transition-[opacity,scale] duration-300 ease-out data-[ending-style]:duration-200 data-[starting-style]:scale-95 data-[starting-style]:opacity-0 data-[ending-style]:opacity-0", className), children: [_jsxs("header", { "data-slot": "dialog-header", className: "flex items-start gap-3 px-6 pt-6 pb-4", children: [_jsxs("div", { className: "min-w-0 flex-1", children: [_jsx(DialogPrimitive.Title, { className: "text-18 font-bold tracking-tight text-[var(--text-primary)]", children: title }), description ? (_jsx(DialogPrimitive.Description, { className: "mt-1 text-[13px] text-[var(--text-body)]", children: description })) : null] }), _jsx(DialogPrimitive.Close, { disabled: busy, render: _jsx(IconButton, { "aria-label": "Close", size: "sm", className: "-mt-1 -mr-2", children: _jsx(X, {}) }) })] }), _jsx("div", { "data-slot": "dialog-body", className: "min-h-0 flex-1 overflow-y-auto px-6 pb-2", children: children }), footer ? (_jsx("footer", { "data-slot": "dialog-footer", className: "flex items-center justify-end gap-2 px-6 pt-4 pb-6", children: footer })) : null] })] }) }));
9
+ }
10
+ export { Dialog };
@@ -0,0 +1,21 @@
1
+ import type * as React from "react";
2
+ declare const WIDTH: {
3
+ readonly item: "max-w-xl";
4
+ readonly list: "max-w-2xl";
5
+ };
6
+ type DrawerProps = {
7
+ open: boolean;
8
+ onClose: () => void;
9
+ title: React.ReactNode;
10
+ /** One line under the title: who, when. */
11
+ subtitle?: React.ReactNode;
12
+ /** `item` 576px for one record; `list` 672px for a list. */
13
+ size?: keyof typeof WIDTH;
14
+ /** Actions beside the close button, such as an ActionMenu. */
15
+ actions?: React.ReactNode;
16
+ children: React.ReactNode;
17
+ className?: string;
18
+ };
19
+ declare function Drawer({ open, onClose, title, subtitle, size, actions, children, className }: DrawerProps): React.JSX.Element;
20
+ export { Drawer };
21
+ export type { DrawerProps };
@@ -0,0 +1,17 @@
1
+ "use client";
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { Dialog as DialogPrimitive } from "@base-ui/react/dialog";
4
+ import { X } from "lucide-react";
5
+ import { IconButton } from "@brdgapp/ui/components/icon-button";
6
+ import { cn } from "@brdgapp/ui/lib/utils";
7
+ // The right-hand panel for one item's details or a short list (approved 2026-11-02, "Panes, rails and
8
+ // sheets"). Shape from Bridge OS's intro / relationship / week drawers: slides in from the right, below
9
+ // the 56px top bar, over a dimmed page; Escape or ✕ closes it. Two widths: `item` (576px, one intro)
10
+ // and `list` (672px, a relationship or a week of contacts). Built on Base UI Dialog: focus is trapped
11
+ // and returns to the opener; a drawer opened from a drawer closes first on Escape. z-[60], Bridge's
12
+ // layer for search and drawers, so menus (80) and confirms (90) opened from it sit above.
13
+ const WIDTH = { item: "max-w-xl", list: "max-w-2xl" };
14
+ function Drawer({ open, onClose, title, subtitle, size = "item", actions, children, className }) {
15
+ return (_jsx(DialogPrimitive.Root, { open: open, onOpenChange: (next) => !next && onClose(), children: _jsxs(DialogPrimitive.Portal, { children: [_jsx(DialogPrimitive.Backdrop, { className: "fixed inset-x-0 top-14 bottom-0 z-[60] bg-black/30 transition-opacity duration-300 ease-out data-[ending-style]:duration-200 data-[starting-style]:opacity-0 data-[ending-style]:opacity-0" }), _jsxs(DialogPrimitive.Popup, { "data-slot": "drawer", "data-size": size, className: cn("fixed top-14 right-0 bottom-0 z-[60] flex w-full flex-col border-l border-[var(--border-default)] bg-[var(--surface-elevated)] shadow-[var(--shadow-drawer)] outline-none", "transition-transform duration-300 ease-out data-[ending-style]:duration-200 data-[starting-style]:translate-x-full data-[ending-style]:translate-x-full", WIDTH[size], className), children: [_jsxs("header", { "data-slot": "drawer-header", className: "flex items-start gap-2 border-b border-[var(--border-divider)] px-6 py-4", children: [_jsxs("div", { className: "min-w-0 flex-1", children: [_jsx(DialogPrimitive.Title, { className: "truncate text-18 font-bold tracking-tight text-[var(--text-primary)]", children: title }), subtitle ? (_jsx(DialogPrimitive.Description, { className: "mt-0.5 truncate text-[12px] text-[var(--text-muted)]", children: subtitle })) : null] }), actions, _jsx(DialogPrimitive.Close, { render: _jsx(IconButton, { "aria-label": "Close", size: "sm", children: _jsx(X, {}) }) })] }), _jsx("div", { "data-slot": "drawer-body", className: "min-h-0 flex-1 overflow-y-auto overscroll-contain px-6 py-5", children: children })] })] }) }));
16
+ }
17
+ export { Drawer };
@@ -0,0 +1,8 @@
1
+ type ExpandableBioProps = {
2
+ text: string;
3
+ size?: 12 | 14;
4
+ className?: string;
5
+ };
6
+ declare function ExpandableBio({ text, size, className }: ExpandableBioProps): import("react").JSX.Element;
7
+ export { ExpandableBio };
8
+ export type { ExpandableBioProps };
@@ -0,0 +1,26 @@
1
+ "use client";
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { useEffect, useId, useRef, useState } from "react";
4
+ import { Button } from "@brdgapp/ui/components/button";
5
+ import { cn } from "@brdgapp/ui/lib/utils";
6
+ function ExpandableBio({ text, size = 12, className }) {
7
+ const trimmed = text.trim();
8
+ const [expanded, setExpanded] = useState(false);
9
+ const [truncatable, setTruncatable] = useState(false);
10
+ const ref = useRef(null);
11
+ const id = useId();
12
+ useEffect(() => {
13
+ const el = ref.current;
14
+ // Measure only while collapsed, so expanding never flips the flag off.
15
+ if (!el || expanded)
16
+ return;
17
+ // ResizeObserver's first callback runs async, so state isn't set synchronously in the effect.
18
+ const observer = new ResizeObserver(() => setTruncatable(el.scrollHeight > el.clientHeight + 1));
19
+ observer.observe(el);
20
+ return () => observer.disconnect();
21
+ }, [trimmed, expanded]);
22
+ return (_jsxs("div", { "data-slot": "expandable-bio", className: className, children: [_jsx("p", { id: id, ref: ref,
23
+ // Size before line height: the class merger lets a font size override an earlier line height.
24
+ className: cn("text-[var(--text-body)]", size === 14 ? "text-[14px]" : "text-[12px]", "leading-relaxed", !expanded && "line-clamp-1"), children: trimmed }), truncatable || expanded ? (_jsx(Button, { variant: "link", size: "xs", className: "mt-1", "aria-expanded": expanded, "aria-controls": id, onClick: () => setExpanded((v) => !v), children: expanded ? "See less" : "See more" })) : null] }));
25
+ }
26
+ export { ExpandableBio };
@@ -0,0 +1,29 @@
1
+ import type * as React from "react";
2
+ import { Field as FieldPrimitive } from "@base-ui/react/field";
3
+ type FieldProps = Omit<FieldPrimitive.Root.Props, "className"> & {
4
+ /** The value has a problem. Turns the control red and lets FieldError show. */
5
+ invalid?: boolean;
6
+ className?: string;
7
+ };
8
+ /** One field. Put a FieldLabel, the control (Input, TagInput…), and optionally FieldDescription and FieldError inside. */
9
+ declare function Field({ className, invalid, ...props }: FieldProps): React.JSX.Element;
10
+ type FieldLabelProps = Omit<FieldPrimitive.Label.Props, "className"> & {
11
+ /** Adds "(optional)" after the label, in muted text. Required fields get no mark. */
12
+ optional?: boolean;
13
+ className?: string;
14
+ };
15
+ declare function FieldLabel({ className, optional, children, ...props }: FieldLabelProps): React.JSX.Element;
16
+ type FieldDescriptionProps = Omit<FieldPrimitive.Description.Props, "className"> & {
17
+ className?: string;
18
+ };
19
+ /** Help text under the control: what the value is for. Read out with the field. */
20
+ declare function FieldDescription({ className, ...props }: FieldDescriptionProps): React.JSX.Element;
21
+ type FieldErrorProps = Omit<FieldPrimitive.Error.Props, "className" | "match"> & {
22
+ className?: string;
23
+ };
24
+ /** What's wrong and how to fix it. Shows whenever it has text; pair it with `invalid` on the Field. */
25
+ declare function FieldError({ className, children, ...props }: FieldErrorProps): React.JSX.Element | null;
26
+ /** Stacks fields 16px apart. */
27
+ declare function FieldGroup({ className, ...props }: React.ComponentProps<"div">): React.JSX.Element;
28
+ export { Field, FieldDescription, FieldError, FieldGroup, FieldLabel };
29
+ export type { FieldDescriptionProps, FieldErrorProps, FieldLabelProps, FieldProps };
@@ -0,0 +1,27 @@
1
+ "use client";
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { Field as FieldPrimitive } from "@base-ui/react/field";
4
+ import { AlertCircle } from "lucide-react";
5
+ import { cn } from "@brdgapp/ui/lib/utils";
6
+ /** One field. Put a FieldLabel, the control (Input, TagInput…), and optionally FieldDescription and FieldError inside. */
7
+ function Field({ className, invalid, ...props }) {
8
+ return _jsx(FieldPrimitive.Root, { "data-slot": "field", invalid: invalid, className: cn("flex flex-col gap-1.5", className), ...props });
9
+ }
10
+ function FieldLabel({ className, optional, children, ...props }) {
11
+ return (_jsxs(FieldPrimitive.Label, { "data-slot": "field-label", className: cn("text-[14px] font-medium text-[var(--text-primary)]", className), ...props, children: [children, optional ? _jsx("span", { className: "font-normal text-[var(--text-muted)]", children: " (optional)" }) : null] }));
12
+ }
13
+ /** Help text under the control: what the value is for. Read out with the field. */
14
+ function FieldDescription({ className, ...props }) {
15
+ return _jsx(FieldPrimitive.Description, { "data-slot": "field-description", className: cn("text-[12px] text-[var(--text-muted)]", className), ...props });
16
+ }
17
+ /** What's wrong and how to fix it. Shows whenever it has text; pair it with `invalid` on the Field. */
18
+ function FieldError({ className, children, ...props }) {
19
+ if (!children)
20
+ return null;
21
+ return (_jsxs(FieldPrimitive.Error, { "data-slot": "field-error", match: true, className: cn("flex items-start gap-1.5 text-[12px] text-[var(--danger-text)]", className), ...props, children: [_jsx(AlertCircle, { "aria-hidden": "true", className: "mt-px size-3.5 shrink-0" }), _jsx("span", { children: children })] }));
22
+ }
23
+ /** Stacks fields 16px apart. */
24
+ function FieldGroup({ className, ...props }) {
25
+ return _jsx("div", { "data-slot": "field-group", className: cn("flex flex-col gap-4", className), ...props });
26
+ }
27
+ export { Field, FieldDescription, FieldError, FieldGroup, FieldLabel };
@@ -0,0 +1,11 @@
1
+ import { Toggle } from "@base-ui/react/toggle";
2
+ type FilterChipProps = Omit<Toggle.Props, "className" | "pressed" | "defaultPressed"> & {
3
+ /** Whether this filter is on. The parent owns it. */
4
+ active?: boolean;
5
+ /** A count shown after the label, such as "All 128". */
6
+ count?: number;
7
+ className?: string;
8
+ };
9
+ declare function FilterChip({ active, count, className, children, ...props }: FilterChipProps): import("react").JSX.Element;
10
+ export { FilterChip };
11
+ export type { FilterChipProps };
@@ -0,0 +1,8 @@
1
+ "use client";
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { Toggle } from "@base-ui/react/toggle";
4
+ import { cn } from "@brdgapp/ui/lib/utils";
5
+ function FilterChip({ active = false, count, className, children, ...props }) {
6
+ return (_jsxs(Toggle, { "data-slot": "filter-chip", pressed: active, className: cn("inline-flex items-center gap-1.5 rounded-full border px-3 py-1.5 text-[13px] font-semibold transition-colors duration-150 outline-none", "focus-visible:ring-2 focus-visible:ring-[var(--primary)]", "border-[var(--text-disabled)] bg-transparent text-[var(--text-secondary)] hover:border-[var(--text-muted)] hover:text-[var(--text-primary)]", "data-[pressed]:border-[var(--text-muted)] data-[pressed]:bg-[var(--surface-tertiary)] data-[pressed]:text-[var(--text-primary)]", "disabled:pointer-events-none disabled:opacity-50", className), ...props, children: [children, count != null ? (_jsx("span", { "data-slot": "filter-chip-count", className: "font-normal tabular-nums text-[var(--text-muted)]", children: count.toLocaleString("en-GB") })) : null] }));
7
+ }
8
+ export { FilterChip };
@@ -0,0 +1,34 @@
1
+ import { type ElementProps } from "@brdgapp/ui/lib/utils";
2
+ type FilterPillOption<T extends string> = {
3
+ value: T;
4
+ label: string;
5
+ };
6
+ /** The menu body shared by FilterPill and ColumnFilterMenu. */
7
+ declare function FilterMenuPopup<T extends string>({ value, options, onChange, width, align, }: {
8
+ value: T;
9
+ options: FilterPillOption<T>[];
10
+ onChange: (value: T) => void;
11
+ width: number;
12
+ align: "start" | "end";
13
+ }): import("react").JSX.Element;
14
+ /** The same choices as FilterMenuPopup, as a bottom sheet: what phones get below 768px. */
15
+ declare function FilterSheet<T extends string>({ open, onClose, title, value, options, onChange, }: {
16
+ open: boolean;
17
+ onClose: () => void;
18
+ title: string;
19
+ value: T;
20
+ options: FilterPillOption<T>[];
21
+ onChange: (value: T) => void;
22
+ }): import("react").JSX.Element;
23
+ type FilterPillProps<T extends string> = ElementProps<HTMLButtonElement> & {
24
+ value: T;
25
+ /** The first option is the resting "All" state. */
26
+ options: FilterPillOption<T>[];
27
+ onChange: (value: T) => void;
28
+ /** What is being filtered: "Degree", "Status". Read as "Degree: 1st". */
29
+ ariaLabel?: string;
30
+ className?: string;
31
+ };
32
+ declare function FilterPill<T extends string>({ value, options, onChange, ariaLabel, className, ...rest }: FilterPillProps<T>): import("react").JSX.Element;
33
+ export { FilterPill, FilterMenuPopup, FilterSheet };
34
+ export type { FilterPillOption, FilterPillProps };
@@ -0,0 +1,35 @@
1
+ "use client";
2
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
3
+ import { useState } from "react";
4
+ import { Menu } from "@base-ui/react/menu";
5
+ import { Check, ChevronDown } from "lucide-react";
6
+ import { BottomSheet, BottomSheetOption } from "@brdgapp/ui/components/bottom-sheet";
7
+ import { useIsMobile } from "@brdgapp/ui/hooks/use-is-mobile";
8
+ import { cn } from "@brdgapp/ui/lib/utils";
9
+ /** The menu body shared by FilterPill and ColumnFilterMenu. */
10
+ function FilterMenuPopup({ value, options, onChange, width, align, }) {
11
+ return (_jsx(Menu.Portal, { children: _jsx(Menu.Positioner, { side: "bottom", align: align, sideOffset: 6, collisionPadding: 8, className: "z-[80]", children: _jsx(Menu.Popup, { "data-slot": "filter-menu", style: { minWidth: width }, className: "max-h-[320px] overflow-y-auto rounded-xl border border-[var(--border-default)] bg-[var(--surface-elevated)] py-1 shadow-[var(--shadow-menu)] outline-none transition-opacity duration-200 ease-out data-[ending-style]:duration-150 data-[starting-style]:opacity-0 data-[ending-style]:opacity-0", children: _jsx(Menu.RadioGroup, { value: value, onValueChange: (v) => onChange(v), children: options.map((option) => (_jsxs(Menu.RadioItem, { value: option.value, closeOnClick: true, "data-slot": "filter-menu-option", className: "group flex w-full cursor-default items-center justify-between gap-3 px-3 py-2 text-left text-[13px] text-[var(--text-body)] outline-none transition-colors data-[checked]:font-semibold data-[checked]:text-[var(--text-primary)] data-[highlighted]:bg-[var(--surface-secondary)] hover:bg-[var(--surface-secondary)]", children: [_jsx("span", { className: "truncate", children: option.label }), _jsx(Menu.RadioItemIndicator, { className: "inline-flex shrink-0", children: _jsx(Check, { "aria-hidden": "true", className: "size-4 text-[var(--primary)]" }) })] }, option.value))) }) }) }) }));
12
+ }
13
+ /** The same choices as FilterMenuPopup, as a bottom sheet: what phones get below 768px. */
14
+ function FilterSheet({ open, onClose, title, value, options, onChange, }) {
15
+ return (_jsx(BottomSheet, { open: open, onClose: onClose, title: title, children: _jsx("div", { role: "radiogroup", "aria-label": title, "data-slot": "filter-sheet", children: options.map((option) => (_jsx(BottomSheetOption, { selected: option.value === value, onSelect: () => {
16
+ onChange(option.value);
17
+ onClose();
18
+ }, children: option.label }, option.value))) }) }));
19
+ }
20
+ function FilterPill({ value, options, onChange, ariaLabel = "Filter", className, ...rest }) {
21
+ const [open, setOpen] = useState(false);
22
+ const mobile = useIsMobile();
23
+ const active = value !== options[0]?.value;
24
+ const label = options.find((o) => o.value === value)?.label ?? options[0]?.label ?? "";
25
+ const triggerClass = cn("inline-flex shrink-0 items-center gap-1.5 rounded-full border px-3 py-1.5 text-[13px] font-semibold outline-none transition-colors", "focus-visible:ring-2 focus-visible:ring-[var(--primary)]", active
26
+ ? "border-[var(--border-hover)] bg-[var(--surface-tertiary)] text-[var(--text-primary)]"
27
+ : "border-[var(--border-default)] bg-transparent text-[var(--text-muted)] hover:text-[var(--text-body)]", className);
28
+ const inner = (_jsxs(_Fragment, { children: [label, _jsx(ChevronDown, { "aria-hidden": "true", className: cn("size-3.5 text-[var(--text-muted)] transition-transform", open && "rotate-180") })] }));
29
+ // Phones: the choices slide up from the bottom, near the thumb, in 44px rows.
30
+ if (mobile) {
31
+ return (_jsxs(_Fragment, { children: [_jsx("button", { ...rest, type: "button", "data-slot": "filter-pill", "aria-haspopup": "dialog", "aria-label": `${ariaLabel}: ${label}`, className: triggerClass, onClick: () => setOpen(true), children: inner }), _jsx(FilterSheet, { open: open, onClose: () => setOpen(false), title: ariaLabel, value: value, options: options, onChange: onChange })] }));
32
+ }
33
+ return (_jsxs(Menu.Root, { open: open, onOpenChange: setOpen, children: [_jsx(Menu.Trigger, { ...rest, "data-slot": "filter-pill", "aria-label": `${ariaLabel}: ${label}`, className: triggerClass, children: inner }), _jsx(FilterMenuPopup, { value: value, options: options, onChange: onChange, width: 200, align: "end" })] }));
34
+ }
35
+ export { FilterPill, FilterMenuPopup, FilterSheet };