@brdgapp/ui 0.1.2 → 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.
- package/CHANGELOG.md +83 -0
- package/README.md +37 -201
- package/components/action-menu.d.ts +65 -0
- package/components/action-menu.js +48 -0
- package/components/avatar.d.ts +50 -0
- package/components/avatar.js +91 -0
- package/components/badge.d.ts +12 -0
- package/components/badge.js +23 -0
- package/components/bottom-sheet.d.ts +17 -0
- package/components/bottom-sheet.js +12 -0
- package/components/brand-mark.d.ts +20 -0
- package/components/brand-mark.js +44 -0
- package/components/button-group.d.ts +25 -0
- package/components/button-group.js +53 -0
- package/components/button.d.ts +33 -0
- package/components/button.js +82 -0
- package/components/card.d.ts +78 -0
- package/components/card.js +75 -0
- package/components/checkbox.d.ts +22 -0
- package/components/checkbox.js +35 -0
- package/components/collapsible.d.ts +29 -0
- package/components/collapsible.js +38 -0
- package/components/column-filter-menu.d.ts +15 -0
- package/components/column-filter-menu.js +30 -0
- package/components/column-sort-button.d.ts +18 -0
- package/components/column-sort-button.js +14 -0
- package/components/confirm-dialog.d.ts +20 -0
- package/components/confirm-dialog.js +17 -0
- package/components/copy-field.d.ts +17 -0
- package/components/copy-field.js +36 -0
- package/components/data-state.d.ts +44 -0
- package/components/data-state.js +25 -0
- package/components/data-table.d.ts +65 -0
- package/components/data-table.js +84 -0
- package/components/dialog.d.ts +19 -0
- package/components/dialog.js +10 -0
- package/components/drawer.d.ts +21 -0
- package/components/drawer.js +17 -0
- package/components/expandable-bio.d.ts +8 -0
- package/components/expandable-bio.js +26 -0
- package/components/field.d.ts +29 -0
- package/components/field.js +27 -0
- package/components/filter-chip.d.ts +11 -0
- package/components/filter-chip.js +8 -0
- package/components/filter-pill.d.ts +34 -0
- package/components/filter-pill.js +35 -0
- package/components/hover-card.d.ts +21 -0
- package/components/hover-card.js +20 -0
- package/components/icon-button.d.ts +16 -0
- package/components/icon-button.js +32 -0
- package/components/illustration.d.ts +33 -0
- package/components/illustration.js +70 -0
- package/components/info-hint.d.ts +29 -0
- package/components/info-hint.js +42 -0
- package/components/input.d.ts +9 -0
- package/components/input.js +15 -0
- package/components/network-graph.d.ts +48 -0
- package/components/network-graph.js +37 -0
- package/components/page-banner.d.ts +20 -0
- package/components/page-banner.js +29 -0
- package/components/scene.d.ts +12 -0
- package/components/scene.js +7 -0
- package/components/search-input.d.ts +8 -0
- package/components/search-input.js +14 -0
- package/components/segmented-control.d.ts +18 -0
- package/components/segmented-control.js +14 -0
- package/components/skeleton.d.ts +12 -0
- package/components/skeleton.js +21 -0
- package/components/skip-link.d.ts +8 -0
- package/components/skip-link.js +7 -0
- package/components/stat-box.d.ts +23 -0
- package/components/stat-box.js +29 -0
- package/components/switch.d.ts +13 -0
- package/components/switch.js +8 -0
- package/components/tabs.d.ts +6 -0
- package/components/tabs.js +25 -0
- package/components/tag-input.d.ts +15 -0
- package/components/tag-input.js +40 -0
- package/components/tag.d.ts +27 -0
- package/components/tag.js +35 -0
- package/components/textarea.d.ts +12 -0
- package/components/textarea.js +78 -0
- package/components/theme-switch.d.ts +6 -0
- package/components/theme-switch.js +16 -0
- package/components/toast.d.ts +25 -0
- package/components/toast.js +44 -0
- package/hooks/use-is-mobile.d.ts +2 -0
- package/hooks/use-is-mobile.js +16 -0
- package/hooks/use-mock-query.d.ts +23 -0
- package/hooks/use-mock-query.js +36 -0
- package/hooks/use-theme.d.ts +11 -0
- package/hooks/use-theme.js +34 -0
- package/lib/demo-controls.d.ts +24 -0
- package/lib/demo-controls.js +24 -0
- package/lib/format.d.ts +17 -0
- package/lib/format.js +55 -0
- package/lib/layout.d.ts +19 -0
- package/lib/layout.js +24 -0
- package/lib/motion.d.ts +16 -0
- package/lib/motion.js +18 -0
- package/lib/theme.d.ts +21 -0
- package/lib/theme.js +63 -0
- package/lib/utils.d.ts +22 -0
- package/lib/utils.js +27 -0
- package/package.json +32 -16
- package/styles/globals.css +595 -0
- package/src/index.ts +0 -18
- package/src/network-people-helper/README.md +0 -130
- package/src/network-people-helper/api.ts +0 -174
- package/src/network-people-helper/contact-avatar.tsx +0 -63
- package/src/network-people-helper/domain.ts +0 -14
- package/src/network-people-helper/icons.tsx +0 -130
- package/src/network-people-helper/index.ts +0 -6
- package/src/network-people-helper/network-people-helper.tsx +0 -397
- package/src/network-people-helper/network-people-nudge.tsx +0 -98
- package/src/network-people-helper/open-helper.ts +0 -22
- package/src/network-people-helper/person-menu.tsx +0 -211
- package/src/network-people-helper/styles.css +0 -848
- package/src/network-people-helper/types.ts +0 -23
- package/src/network-people-helper/use-network-people-list.ts +0 -122
- package/src/network-people-helper/use-network-preview.ts +0 -43
- package/src/provider.tsx +0 -59
- package/src/styles.css +0 -1
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { type LucideIcon } from "lucide-react";
|
|
2
|
+
import { type FilterPillOption } from "@brdgapp/ui/components/filter-pill";
|
|
3
|
+
import { type ElementProps } from "@brdgapp/ui/lib/utils";
|
|
4
|
+
type ColumnFilterMenuProps<T extends string> = ElementProps<HTMLButtonElement> & {
|
|
5
|
+
icon: LucideIcon;
|
|
6
|
+
label: string;
|
|
7
|
+
/** Explains the column. Hidden while the menu is open. */
|
|
8
|
+
tooltip?: string;
|
|
9
|
+
value: T;
|
|
10
|
+
options: FilterPillOption<T>[];
|
|
11
|
+
onChange: (value: T) => void;
|
|
12
|
+
};
|
|
13
|
+
declare function ColumnFilterMenu<T extends string>({ icon: Icon, label, tooltip, value, options, onChange, ...rest }: ColumnFilterMenuProps<T>): import("react").JSX.Element;
|
|
14
|
+
export { ColumnFilterMenu };
|
|
15
|
+
export type { ColumnFilterMenuProps };
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { useState } from "react";
|
|
4
|
+
import { Menu } from "@base-ui/react/menu";
|
|
5
|
+
import { ChevronDown } from "lucide-react";
|
|
6
|
+
import { FilterMenuPopup, FilterSheet } from "@brdgapp/ui/components/filter-pill";
|
|
7
|
+
import { useIsMobile } from "@brdgapp/ui/hooks/use-is-mobile";
|
|
8
|
+
import { Tip } from "@brdgapp/ui/components/info-hint";
|
|
9
|
+
import { cn } from "@brdgapp/ui/lib/utils";
|
|
10
|
+
function ColumnFilterMenu({ icon: Icon, label, tooltip, value, options, onChange, ...rest }) {
|
|
11
|
+
const [open, setOpen] = useState(false);
|
|
12
|
+
const mobile = useIsMobile();
|
|
13
|
+
const active = options.length > 0 && value !== options[0]?.value;
|
|
14
|
+
const activeLabel = options.find((o) => o.value === value)?.label;
|
|
15
|
+
const name = `Filter by ${label.toLowerCase()}${active && activeLabel ? `: ${activeLabel}` : ""}`;
|
|
16
|
+
const triggerClass = 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)]", active ? "text-[var(--primary)]" : "text-[var(--text-secondary)]");
|
|
17
|
+
const inner = (_jsxs(_Fragment, { children: [_jsx(Icon, { "aria-hidden": "true", className: cn("size-3.5", active ? "text-[var(--primary)]" : "text-[var(--text-muted)]") }), label, active ? _jsx("span", { "aria-hidden": "true", className: "size-1.5 rounded-full bg-[var(--primary)]" }) : null, _jsx(ChevronDown, { "aria-hidden": "true", className: cn("size-3.5 text-[var(--text-muted)] transition-transform", open && "rotate-180") })] }));
|
|
18
|
+
// Phones: a bottom sheet instead of a menu. No tooltip: touch has no hover.
|
|
19
|
+
if (mobile) {
|
|
20
|
+
return (_jsxs(_Fragment, { children: [_jsx("button", { ...rest, type: "button", "data-slot": "column-filter-menu", "aria-haspopup": "dialog", "aria-label": name, className: triggerClass, onClick: (e) => {
|
|
21
|
+
// A header often sits in a clickable row; keep the click here.
|
|
22
|
+
e.stopPropagation();
|
|
23
|
+
setOpen(true);
|
|
24
|
+
}, children: inner }), _jsx(FilterSheet, { open: open, onClose: () => setOpen(false), title: label, value: value, options: options, onChange: onChange })] }));
|
|
25
|
+
}
|
|
26
|
+
return (_jsxs(Menu.Root, { open: open, onOpenChange: setOpen, children: [_jsx(Tip, { text: tooltip, disabled: open, children: _jsx(Menu.Trigger, { ...rest, "data-slot": "column-filter-menu", "aria-label": name,
|
|
27
|
+
// A header often sits in a clickable row; keep the click here.
|
|
28
|
+
onClick: (e) => e.stopPropagation(), className: triggerClass, children: inner }) }), _jsx(FilterMenuPopup, { value: value, options: options, onChange: onChange, width: 220, align: "start" })] }));
|
|
29
|
+
}
|
|
30
|
+
export { ColumnFilterMenu };
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { type LucideIcon } from "lucide-react";
|
|
2
|
+
import { type ElementProps } from "@brdgapp/ui/lib/utils";
|
|
3
|
+
type ColumnSortButtonProps = ElementProps<HTMLButtonElement> & {
|
|
4
|
+
icon: LucideIcon;
|
|
5
|
+
label: string;
|
|
6
|
+
tooltip?: string;
|
|
7
|
+
/** This column's sort, or null when another column is the sort. */
|
|
8
|
+
direction: "asc" | "desc" | null;
|
|
9
|
+
/** Words for each direction, read by screen readers: { asc: "lowest first", desc: "highest first" }. */
|
|
10
|
+
describe?: {
|
|
11
|
+
asc: string;
|
|
12
|
+
desc: string;
|
|
13
|
+
};
|
|
14
|
+
onClick: () => void;
|
|
15
|
+
};
|
|
16
|
+
declare function ColumnSortButton({ icon: Icon, label, tooltip, direction, describe, onClick, ...rest }: ColumnSortButtonProps): import("react").JSX.Element;
|
|
17
|
+
export { ColumnSortButton };
|
|
18
|
+
export type { ColumnSortButtonProps };
|
|
@@ -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,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 };
|