raft-ui 0.0.1
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/README.md +24 -0
- package/dist/cn.d.mts +2 -0
- package/dist/cn.mjs +2 -0
- package/dist/index.d.mts +2710 -0
- package/dist/index.mjs +4821 -0
- package/dist/label-DWq4ia5I.mjs +115 -0
- package/dist/styled-props-B3SNekJg.d.mts +16 -0
- package/dist/wip.d.mts +1238 -0
- package/dist/wip.mjs +1416 -0
- package/package.json +70 -0
- package/src/components/avatar/avatar.tsx +375 -0
- package/src/components/avatar/index.ts +17 -0
- package/src/components/badge/badge.tsx +393 -0
- package/src/components/badge/index.ts +2 -0
- package/src/components/banner/banner.tsx +269 -0
- package/src/components/banner/index.ts +8 -0
- package/src/components/bottom-sheet/bottom-sheet.tsx +152 -0
- package/src/components/bottom-sheet/index.ts +22 -0
- package/src/components/button/button.tsx +497 -0
- package/src/components/button/index.ts +2 -0
- package/src/components/checkbox/checkbox-indicator.tsx +128 -0
- package/src/components/checkbox/checkbox.tsx +350 -0
- package/src/components/checkbox/index.ts +2 -0
- package/src/components/combobox/combobox.tsx +603 -0
- package/src/components/combobox/index.ts +52 -0
- package/src/components/context-menu/context-menu.tsx +380 -0
- package/src/components/context-menu/index.ts +34 -0
- package/src/components/conversation-preview-card/conversation-preview-card.tsx +336 -0
- package/src/components/conversation-preview-card/index.ts +32 -0
- package/src/components/description-list/description-list.tsx +116 -0
- package/src/components/description-list/index.ts +14 -0
- package/src/components/dropdown-menu/dropdown-menu.tsx +405 -0
- package/src/components/dropdown-menu/index.ts +38 -0
- package/src/components/empty-state/empty-state.tsx +177 -0
- package/src/components/empty-state/index.ts +16 -0
- package/src/components/field/field.tsx +125 -0
- package/src/components/field/index.ts +18 -0
- package/src/components/inline-code/index.ts +2 -0
- package/src/components/inline-code/inline-code.tsx +33 -0
- package/src/components/input/index.ts +2 -0
- package/src/components/input/input.tsx +76 -0
- package/src/components/input-group/index.ts +7 -0
- package/src/components/input-group/input-group.tsx +215 -0
- package/src/components/kbd/index.ts +2 -0
- package/src/components/kbd/kbd.tsx +43 -0
- package/src/components/label/index.ts +7 -0
- package/src/components/label/label.tsx +99 -0
- package/src/components/lightbox/index.ts +24 -0
- package/src/components/lightbox/lightbox.tsx +245 -0
- package/src/components/list-item/index.ts +20 -0
- package/src/components/list-item/list-item.tsx +263 -0
- package/src/components/media-list-item/index.ts +20 -0
- package/src/components/media-list-item/media-list-item.tsx +169 -0
- package/src/components/notification-center/index.ts +39 -0
- package/src/components/notification-center/notification-center.tsx +558 -0
- package/src/components/panel-header/index.ts +22 -0
- package/src/components/panel-header/panel-header.tsx +199 -0
- package/src/components/popover/index.ts +30 -0
- package/src/components/popover/popover.tsx +248 -0
- package/src/components/preview-shell/index.ts +2 -0
- package/src/components/preview-shell/preview-shell.tsx +51 -0
- package/src/components/quoted-message-card/index.ts +36 -0
- package/src/components/quoted-message-card/quoted-message-card.tsx +391 -0
- package/src/components/radio-group/index.ts +6 -0
- package/src/components/radio-group/radio-group.tsx +119 -0
- package/src/components/section-header/index.ts +16 -0
- package/src/components/section-header/section-header.tsx +117 -0
- package/src/components/section-label/index.ts +2 -0
- package/src/components/section-label/section-label.tsx +38 -0
- package/src/components/segmented-control/index.ts +12 -0
- package/src/components/segmented-control/segmented-control.tsx +129 -0
- package/src/components/select/index.ts +40 -0
- package/src/components/select/select.tsx +382 -0
- package/src/components/spinner/index.ts +2 -0
- package/src/components/spinner/spinner.tsx +228 -0
- package/src/components/status/index.ts +2 -0
- package/src/components/status/status.tsx +152 -0
- package/src/components/tabs/index.ts +24 -0
- package/src/components/tabs/tabs.tsx +417 -0
- package/src/components/text/index.ts +2 -0
- package/src/components/text/text.tsx +130 -0
- package/src/components/textarea/index.ts +2 -0
- package/src/components/textarea/textarea.tsx +136 -0
- package/src/components/toggle-group/index.ts +12 -0
- package/src/components/toggle-group/toggle-group.tsx +111 -0
- package/src/components/tooltip/index.ts +14 -0
- package/src/components/tooltip/tooltip.tsx +186 -0
- package/src/fonts/brutal.css +2 -0
- package/src/index.ts +404 -0
- package/src/legacy/AttentionDot.tsx +28 -0
- package/src/legacy/AvatarListRow.tsx +87 -0
- package/src/legacy/AvatarSlot.tsx +157 -0
- package/src/legacy/Badge.tsx +95 -0
- package/src/legacy/Banner.tsx +74 -0
- package/src/legacy/BottomSheet.tsx +76 -0
- package/src/legacy/CheckMarker.tsx +72 -0
- package/src/legacy/Checkbox.tsx +81 -0
- package/src/legacy/ConversationPreviewCard.tsx +169 -0
- package/src/legacy/DismissBackdrop.tsx +68 -0
- package/src/legacy/EmptyState.tsx +63 -0
- package/src/legacy/ExperimentalBadge.tsx +19 -0
- package/src/legacy/FormField.tsx +106 -0
- package/src/legacy/KeyValueRow.tsx +67 -0
- package/src/legacy/Lightbox.tsx +119 -0
- package/src/legacy/NotificationCenter.tsx +217 -0
- package/src/legacy/PanelHeader.tsx +94 -0
- package/src/legacy/PreviewShell.tsx +40 -0
- package/src/legacy/QuotedMessageCard.tsx +181 -0
- package/src/legacy/ReorderablePanelTabs.tsx +151 -0
- package/src/legacy/SandboxedPreviewFrame.tsx +59 -0
- package/src/legacy/SearchInput.tsx +23 -0
- package/src/legacy/SectionEyebrow.tsx +60 -0
- package/src/legacy/SectionHeader.tsx +68 -0
- package/src/legacy/SegmentedControl.tsx +74 -0
- package/src/legacy/SlugInput.tsx +29 -0
- package/src/legacy/Spinner.tsx +89 -0
- package/src/legacy/StatusDot.tsx +51 -0
- package/src/legacy/SurfaceListItem.tsx +32 -0
- package/src/legacy/Textarea.tsx +137 -0
- package/src/legacy/button.tsx +100 -0
- package/src/legacy/context-menu-divider.tsx +12 -0
- package/src/legacy/menu-item.tsx +66 -0
- package/src/legacy/selection-popover.tsx +212 -0
- package/src/legacy/server-switcher-menu.tsx +203 -0
- package/src/legacy/source.css +240 -0
- package/src/lib/cn.ts +1 -0
- package/src/lib/styled-props.ts +11 -0
- package/src/lib/theme/theme-context.ts +28 -0
- package/src/lib/theme/theme-provider.tsx +181 -0
- package/src/lib/theme/use-theme.ts +12 -0
- package/src/lib/tv.ts +29 -0
- package/src/lib/without-children.ts +6 -0
- package/src/styles.css +467 -0
- package/src/wip.ts +255 -0
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { HTMLAttributes } from "react";
|
|
2
|
+
import { Badge } from "./Badge.tsx";
|
|
3
|
+
|
|
4
|
+
export interface ExperimentalBadgeProps extends HTMLAttributes<HTMLSpanElement> {
|
|
5
|
+
// Callers can add positional tweaks (e.g. `-translate-y-px` inside a
|
|
6
|
+
// PanelHeader title row) — but the visual identity (lime tone +
|
|
7
|
+
// brutal Badge's label typography) is owned by Badge so every
|
|
8
|
+
// Experimental surface stays visually identical.
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function ExperimentalBadge({ className = "", ...props }: ExperimentalBadgeProps) {
|
|
12
|
+
return (
|
|
13
|
+
<Badge tone="lime" className={className} {...props}>
|
|
14
|
+
Experimental
|
|
15
|
+
</Badge>
|
|
16
|
+
);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export default ExperimentalBadge;
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Canonical form-field row — label + control + optional helper / error.
|
|
3
|
+
*
|
|
4
|
+
* Replaces the inline `<div><label className="mb-1 block text-sm font-bold
|
|
5
|
+
* text-black uppercase tracking-wide">…</label><input className="input-brutal
|
|
6
|
+
* …" /></div>` pattern that appears 30+ times across auth pages, dialogs, and
|
|
7
|
+
* settings panels.
|
|
8
|
+
*
|
|
9
|
+
* Background: stdrc 2026-05-12 #wg-theme:a987f888 asked for the strong-signal
|
|
10
|
+
* primitives to be lifted into one PR. FormField is the highest-leverage of
|
|
11
|
+
* the three (FormField / KeyValueRow / SectionHeader) — every multi-field
|
|
12
|
+
* dialog and every auth page is a stack of these.
|
|
13
|
+
*
|
|
14
|
+
* Slots:
|
|
15
|
+
* - `label` — the field label text (rendered as `<label>` and tied to
|
|
16
|
+
* children via the optional `htmlFor`)
|
|
17
|
+
* - `required` — true → render the `*` marker in `text-brutal-pink`
|
|
18
|
+
* - `optional` — true → render `(optional)` in `text-black/40 normal-case`
|
|
19
|
+
* (mutually exclusive with `required`)
|
|
20
|
+
* - `hint` — rendered below the control, `text-xs text-black/50`
|
|
21
|
+
* - `error` — rendered below the control, `text-xs text-brutal-red`,
|
|
22
|
+
* takes precedence over hint
|
|
23
|
+
* - `htmlFor` — forwarded to the `<label htmlFor>` attribute
|
|
24
|
+
* - `labelStyle`— `"uppercase"` (default, matches dialog convention) or
|
|
25
|
+
* `"plain"` (matches auth-page convention — sentence/title case)
|
|
26
|
+
* - `size` — `"default"` (default, `text-sm` + full-opacity black) or
|
|
27
|
+
* `"compact"` (`text-xs` + `text-black/60`). Compact mirrors
|
|
28
|
+
* the dense Settings form-row label that uses
|
|
29
|
+
* `mb-1 block text-xs font-bold text-black/60`. Use compact
|
|
30
|
+
* inside Settings forms to preserve the existing density;
|
|
31
|
+
* use default inside dialogs / auth pages.
|
|
32
|
+
* - `className` — additional classes appended to the outer wrapper (e.g.
|
|
33
|
+
* `mt-2`)
|
|
34
|
+
*
|
|
35
|
+
* The control itself is `children`. FormField does NOT render the `<input>` /
|
|
36
|
+
* `<textarea>` / `<SegmentedControl>` etc. — pass it explicitly. This keeps
|
|
37
|
+
* FormField agnostic of the control library and avoids prop-explosion.
|
|
38
|
+
*/
|
|
39
|
+
|
|
40
|
+
import type { ReactNode, LabelHTMLAttributes } from "react";
|
|
41
|
+
|
|
42
|
+
const LABEL_BASE_DEFAULT = "mb-1 block text-sm font-bold text-black";
|
|
43
|
+
const LABEL_BASE_COMPACT = "mb-1 block text-xs font-bold text-black/60";
|
|
44
|
+
const LABEL_UPPERCASE = "uppercase tracking-wide";
|
|
45
|
+
|
|
46
|
+
export type FormFieldProps = {
|
|
47
|
+
/** Field label text. */
|
|
48
|
+
label: ReactNode;
|
|
49
|
+
/** Show a `*` required marker after the label. */
|
|
50
|
+
required?: boolean;
|
|
51
|
+
/** Show `(optional)` in muted case after the label. Mutually exclusive
|
|
52
|
+
* with `required`. */
|
|
53
|
+
optional?: boolean;
|
|
54
|
+
/** Helper text rendered below the control. */
|
|
55
|
+
hint?: ReactNode;
|
|
56
|
+
/** Error text rendered below the control (takes precedence over `hint`). */
|
|
57
|
+
error?: ReactNode;
|
|
58
|
+
/** `<label htmlFor>` — point at the inner control's `id`. */
|
|
59
|
+
htmlFor?: LabelHTMLAttributes<HTMLLabelElement>["htmlFor"];
|
|
60
|
+
/** Label case style. `"uppercase"` (default) matches the dialog/settings
|
|
61
|
+
* convention. `"plain"` matches the auth-page convention. */
|
|
62
|
+
labelStyle?: "uppercase" | "plain";
|
|
63
|
+
/** Label size. `"default"` (default) is `text-sm` + full-opacity black —
|
|
64
|
+
* matches dialogs / auth pages. `"compact"` is `text-xs` + `text-black/60`
|
|
65
|
+
* — matches the dense Settings form-row labels. */
|
|
66
|
+
size?: "default" | "compact";
|
|
67
|
+
/** Additional classes appended to the outer `<div>`. */
|
|
68
|
+
className?: string;
|
|
69
|
+
/** The actual control — `<input>`, `<textarea>`, `<SegmentedControl>`,
|
|
70
|
+
* `<select>`, custom group, etc. */
|
|
71
|
+
children: ReactNode;
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
export default function FormField({
|
|
75
|
+
label,
|
|
76
|
+
required,
|
|
77
|
+
optional,
|
|
78
|
+
hint,
|
|
79
|
+
error,
|
|
80
|
+
htmlFor,
|
|
81
|
+
labelStyle = "uppercase",
|
|
82
|
+
size = "default",
|
|
83
|
+
className,
|
|
84
|
+
children,
|
|
85
|
+
}: FormFieldProps) {
|
|
86
|
+
const base = size === "compact" ? LABEL_BASE_COMPACT : LABEL_BASE_DEFAULT;
|
|
87
|
+
const labelCls = `${base} ${labelStyle === "uppercase" ? LABEL_UPPERCASE : ""}`.trim();
|
|
88
|
+
const wrapperCls = className ? className : undefined;
|
|
89
|
+
return (
|
|
90
|
+
<div className={wrapperCls}>
|
|
91
|
+
<label className={labelCls} htmlFor={htmlFor}>
|
|
92
|
+
{label}
|
|
93
|
+
{required ? <span className="ml-1 text-brutal-pink">*</span> : null}
|
|
94
|
+
{optional ? <span className="ml-1 text-black/40 normal-case">(optional)</span> : null}
|
|
95
|
+
</label>
|
|
96
|
+
{children}
|
|
97
|
+
{error ? (
|
|
98
|
+
<p className="mt-1 text-xs font-bold text-brutal-red" role="alert">
|
|
99
|
+
{error}
|
|
100
|
+
</p>
|
|
101
|
+
) : hint ? (
|
|
102
|
+
<p className="mt-1 text-xs text-black/50">{hint}</p>
|
|
103
|
+
) : null}
|
|
104
|
+
</div>
|
|
105
|
+
);
|
|
106
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Canonical key/value row — small label above value.
|
|
3
|
+
*
|
|
4
|
+
* Replaces the inline `<div><div className="text-xs text-black/50 mb-1">Foo</div><span className="text-sm text-black font-mono">bar</span></div>` pattern that appears 30+ times across detail panels (HumanDetailPanel, AgentDetailPanel, MachineDetailPanel) and the SettingsPanel body.
|
|
5
|
+
*
|
|
6
|
+
* Background: stdrc 2026-05-12 #wg-theme:a987f888 asked for strong-signal
|
|
7
|
+
* primitives to be lifted in one PR. KeyValueRow is the second of the three.
|
|
8
|
+
*
|
|
9
|
+
* Slots:
|
|
10
|
+
* - `label` — the muted label text (string or node)
|
|
11
|
+
* - `value` — the value content (string or node, often code/mono)
|
|
12
|
+
* - `mono` — true → render the value cell in `font-mono`
|
|
13
|
+
* - `breakAll` — true → render the value cell with `break-all` (long
|
|
14
|
+
* emails, hostnames, etc.)
|
|
15
|
+
* - `action` — optional inline trailing element rendered next to the
|
|
16
|
+
* label (e.g. an edit pencil button)
|
|
17
|
+
* - `className` — additional classes on the outer wrapper
|
|
18
|
+
* - `valueClassName` — additional classes on the value cell
|
|
19
|
+
*
|
|
20
|
+
* Typography defaults match the canonical detail-panel KV convention: label
|
|
21
|
+
* is `text-xs text-black/50 mb-1`, value is `text-sm text-black`. Pass
|
|
22
|
+
* `mono` for fields like email / dates / IDs that are conventionally rendered
|
|
23
|
+
* in monospace.
|
|
24
|
+
*
|
|
25
|
+
* If your row needs an inline-editable value (with Save/Cancel and per-row
|
|
26
|
+
* loading state), this primitive is not the right fit — it intentionally
|
|
27
|
+
* stays a presentational primitive. Inline-edit shells like the role / avatar
|
|
28
|
+
* editors in HumanDetailPanel keep their own structure.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
import type { ReactNode } from "react";
|
|
32
|
+
|
|
33
|
+
export type KeyValueRowProps = {
|
|
34
|
+
label: ReactNode;
|
|
35
|
+
value: ReactNode;
|
|
36
|
+
mono?: boolean;
|
|
37
|
+
breakAll?: boolean;
|
|
38
|
+
action?: ReactNode;
|
|
39
|
+
className?: string;
|
|
40
|
+
valueClassName?: string;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
export default function KeyValueRow({
|
|
44
|
+
label,
|
|
45
|
+
value,
|
|
46
|
+
mono = false,
|
|
47
|
+
breakAll = false,
|
|
48
|
+
action,
|
|
49
|
+
className,
|
|
50
|
+
valueClassName,
|
|
51
|
+
}: KeyValueRowProps) {
|
|
52
|
+
const wrapperCls = className ? className : undefined;
|
|
53
|
+
const valueClsParts = ["text-sm text-black"];
|
|
54
|
+
if (mono) valueClsParts.push("font-mono");
|
|
55
|
+
if (breakAll) valueClsParts.push("break-all");
|
|
56
|
+
if (valueClassName) valueClsParts.push(valueClassName);
|
|
57
|
+
const valueCls = valueClsParts.join(" ");
|
|
58
|
+
return (
|
|
59
|
+
<div className={wrapperCls}>
|
|
60
|
+
<div className="mb-1 flex items-center gap-2">
|
|
61
|
+
<div className="text-xs text-black/50">{label}</div>
|
|
62
|
+
{action ? <div className="shrink-0">{action}</div> : null}
|
|
63
|
+
</div>
|
|
64
|
+
<div className={valueCls}>{value}</div>
|
|
65
|
+
</div>
|
|
66
|
+
);
|
|
67
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { type HTMLAttributes, type ReactNode, useEffect, useRef } from "react";
|
|
2
|
+
import { createPortal } from "react-dom";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Full-screen dark-backdrop overlay for lightboxes and bottom sheets.
|
|
6
|
+
*
|
|
7
|
+
* Replaces the recurring pattern:
|
|
8
|
+
* <div className="fixed inset-0 z-[70] flex flex-col bg-black/75" onClick={...}>
|
|
9
|
+
*
|
|
10
|
+
* Used in: ImageLightbox, SelectShareLightbox, MessageItem mobile reaction sheet.
|
|
11
|
+
*
|
|
12
|
+
* Distinct from <Modal> (centered card) — this primitive is content-agnostic:
|
|
13
|
+
* callers control layout via `className` on children. Modal stays for
|
|
14
|
+
* centered-card dialogs (ConfirmDialog, CreateChannelDialog, etc.).
|
|
15
|
+
*
|
|
16
|
+
* Body scroll lock is applied while mounted.
|
|
17
|
+
*
|
|
18
|
+
* Extra HTML attributes (e.g. `data-testid`) are forwarded to the backdrop div.
|
|
19
|
+
*/
|
|
20
|
+
interface LightboxProps extends Omit<
|
|
21
|
+
HTMLAttributes<HTMLDivElement>,
|
|
22
|
+
"onClick" | "className" | "children"
|
|
23
|
+
> {
|
|
24
|
+
onClose: () => void;
|
|
25
|
+
children: ReactNode;
|
|
26
|
+
/** CSS z-index. Default 70 (above Modal's z-50/z-[60]). */
|
|
27
|
+
zIndex?: number;
|
|
28
|
+
/** Tailwind bg class for the backdrop. Default "bg-black/75". */
|
|
29
|
+
backdropClass?: string;
|
|
30
|
+
/** Click on backdrop closes. Default true. */
|
|
31
|
+
dismissOnBackdrop?: boolean;
|
|
32
|
+
/** Extra classes on the backdrop div (e.g. "flex flex-col"). */
|
|
33
|
+
className?: string;
|
|
34
|
+
/** Positioning class for the backdrop. Default fixed viewport overlay. */
|
|
35
|
+
positionClass?: string;
|
|
36
|
+
/** Lock body scroll while mounted. Default true. */
|
|
37
|
+
lockBodyScroll?: boolean;
|
|
38
|
+
/** Scroll the overlay root into view after mount. Useful for document-flow overlays. */
|
|
39
|
+
scrollIntoViewOnMount?: boolean;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export default function Lightbox({
|
|
43
|
+
onClose,
|
|
44
|
+
children,
|
|
45
|
+
zIndex = 70,
|
|
46
|
+
backdropClass = "bg-black/75",
|
|
47
|
+
dismissOnBackdrop = true,
|
|
48
|
+
className = "",
|
|
49
|
+
positionClass = "fixed inset-0",
|
|
50
|
+
lockBodyScroll = true,
|
|
51
|
+
scrollIntoViewOnMount = false,
|
|
52
|
+
...rest
|
|
53
|
+
}: LightboxProps) {
|
|
54
|
+
const rootRef = useRef<HTMLDivElement | null>(null);
|
|
55
|
+
|
|
56
|
+
// Close the topmost preview before browser-level close shortcuts can close
|
|
57
|
+
// the whole tab/window.
|
|
58
|
+
useEffect(() => {
|
|
59
|
+
const handler = (e: KeyboardEvent) => {
|
|
60
|
+
const closeShortcut =
|
|
61
|
+
e.key.toLowerCase() === "w" && (e.metaKey || e.ctrlKey) && !e.shiftKey && !e.altKey;
|
|
62
|
+
if (e.key !== "Escape" && !closeShortcut) return;
|
|
63
|
+
e.preventDefault();
|
|
64
|
+
e.stopPropagation();
|
|
65
|
+
onClose();
|
|
66
|
+
};
|
|
67
|
+
document.addEventListener("keydown", handler);
|
|
68
|
+
return () => document.removeEventListener("keydown", handler);
|
|
69
|
+
}, [onClose]);
|
|
70
|
+
|
|
71
|
+
// Body scroll lock
|
|
72
|
+
useEffect(() => {
|
|
73
|
+
const html = document.documentElement;
|
|
74
|
+
const prevHtmlOverflow = html.style.overflow;
|
|
75
|
+
const prevHtmlHeight = html.style.height;
|
|
76
|
+
const prevBodyOverflow = document.body.style.overflow;
|
|
77
|
+
const prevBodyHeight = document.body.style.height;
|
|
78
|
+
|
|
79
|
+
if (lockBodyScroll) {
|
|
80
|
+
document.body.style.overflow = "hidden";
|
|
81
|
+
} else {
|
|
82
|
+
html.style.overflow = "auto";
|
|
83
|
+
html.style.height = "auto";
|
|
84
|
+
document.body.style.overflow = "auto";
|
|
85
|
+
document.body.style.height = "auto";
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return () => {
|
|
89
|
+
html.style.overflow = prevHtmlOverflow;
|
|
90
|
+
html.style.height = prevHtmlHeight;
|
|
91
|
+
document.body.style.overflow = prevBodyOverflow;
|
|
92
|
+
document.body.style.height = prevBodyHeight;
|
|
93
|
+
};
|
|
94
|
+
}, [lockBodyScroll]);
|
|
95
|
+
|
|
96
|
+
useEffect(() => {
|
|
97
|
+
if (!scrollIntoViewOnMount) return;
|
|
98
|
+
rootRef.current?.scrollIntoView({ block: "start" });
|
|
99
|
+
}, [scrollIntoViewOnMount]);
|
|
100
|
+
|
|
101
|
+
return createPortal(
|
|
102
|
+
<div
|
|
103
|
+
ref={rootRef}
|
|
104
|
+
{...rest}
|
|
105
|
+
className={`${positionClass} ${backdropClass} ${className}`}
|
|
106
|
+
style={{ zIndex }}
|
|
107
|
+
onClick={
|
|
108
|
+
dismissOnBackdrop
|
|
109
|
+
? (e) => {
|
|
110
|
+
if (e.target === e.currentTarget) onClose();
|
|
111
|
+
}
|
|
112
|
+
: undefined
|
|
113
|
+
}
|
|
114
|
+
>
|
|
115
|
+
{children}
|
|
116
|
+
</div>,
|
|
117
|
+
document.body,
|
|
118
|
+
);
|
|
119
|
+
}
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
import { forwardRef, useEffect, type HTMLAttributes, type ReactNode } from "react";
|
|
2
|
+
import { AlertTriangle, CheckCircle2, Info } from "lucide-react";
|
|
3
|
+
import EmptyState from "./EmptyState.tsx";
|
|
4
|
+
import SectionEyebrow from "./SectionEyebrow.tsx";
|
|
5
|
+
|
|
6
|
+
export type NotificationCenterKind = "error" | "warning" | "info" | "success";
|
|
7
|
+
export type NotificationCenterViewport = "desktop" | "mobile";
|
|
8
|
+
export type NotificationCenterSize = "compact" | "regular" | "large";
|
|
9
|
+
export type NotificationCenterActionVariant = "primary" | "secondary";
|
|
10
|
+
|
|
11
|
+
export interface NotificationCenterAction {
|
|
12
|
+
id?: string;
|
|
13
|
+
label: ReactNode;
|
|
14
|
+
ariaLabel?: string;
|
|
15
|
+
onClick?: () => void;
|
|
16
|
+
disabled?: boolean;
|
|
17
|
+
variant?: NotificationCenterActionVariant;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface NotificationCenterEntry {
|
|
21
|
+
id: string;
|
|
22
|
+
kind: NotificationCenterKind;
|
|
23
|
+
title: ReactNode;
|
|
24
|
+
body?: ReactNode;
|
|
25
|
+
icon?: ReactNode;
|
|
26
|
+
actions?: NotificationCenterAction[];
|
|
27
|
+
read?: boolean;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface NotificationCenterProps extends Omit<HTMLAttributes<HTMLDivElement>, "title"> {
|
|
31
|
+
entries: NotificationCenterEntry[];
|
|
32
|
+
/** Desktop uses a stable fixed-height frame. Mobile grows with content until
|
|
33
|
+
* its max-height token, then scrolls internally. */
|
|
34
|
+
viewport: NotificationCenterViewport;
|
|
35
|
+
size?: NotificationCenterSize;
|
|
36
|
+
title?: ReactNode;
|
|
37
|
+
countLabel?: ReactNode;
|
|
38
|
+
emptyState?: ReactNode;
|
|
39
|
+
closeOnEscape?: boolean;
|
|
40
|
+
onClose?: () => void;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const SIZE_CLASS: Record<NotificationCenterViewport, Record<NotificationCenterSize, string>> = {
|
|
44
|
+
desktop: {
|
|
45
|
+
compact: "h-56",
|
|
46
|
+
regular: "h-72",
|
|
47
|
+
large: "h-96",
|
|
48
|
+
},
|
|
49
|
+
mobile: {
|
|
50
|
+
compact: "max-h-[min(64dvh,224px)]",
|
|
51
|
+
regular: "max-h-[min(72dvh,288px)]",
|
|
52
|
+
large: "max-h-[min(80dvh,384px)]",
|
|
53
|
+
},
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
const WIDTH_CLASS: Record<NotificationCenterViewport, string> = {
|
|
57
|
+
desktop: "w-80",
|
|
58
|
+
mobile: "w-[min(92vw,360px)]",
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
const KIND_DOT_BG: Record<NotificationCenterKind, string> = {
|
|
62
|
+
error: "bg-brutal-orange",
|
|
63
|
+
warning: "bg-brutal-orange",
|
|
64
|
+
info: "bg-brutal-yellow",
|
|
65
|
+
success: "bg-brutal-lime",
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
const KIND_LABEL: Record<NotificationCenterKind, string> = {
|
|
69
|
+
error: "Error",
|
|
70
|
+
warning: "Warning",
|
|
71
|
+
info: "Info",
|
|
72
|
+
success: "Success",
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
const DEFAULT_KIND_ICON: Record<NotificationCenterKind, ReactNode> = {
|
|
76
|
+
error: <AlertTriangle size={16} className="text-black" />,
|
|
77
|
+
warning: <AlertTriangle size={16} className="text-black" />,
|
|
78
|
+
info: <Info size={16} className="text-black" />,
|
|
79
|
+
success: <CheckCircle2 size={16} className="text-black" />,
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Reusable notification-center primitive.
|
|
84
|
+
*
|
|
85
|
+
* Layout contract:
|
|
86
|
+
* - desktop: fixed-height frame, scrollable list body.
|
|
87
|
+
* - mobile: content-height frame capped by a max-height token, then scrollable
|
|
88
|
+
* list body.
|
|
89
|
+
*
|
|
90
|
+
* The component does not know how notifications are sourced or dismissed; apps
|
|
91
|
+
* provide entries and action handlers. This keeps product aggregation separate
|
|
92
|
+
* from the open-source-ready UI primitive.
|
|
93
|
+
*/
|
|
94
|
+
const NotificationCenter = forwardRef<HTMLDivElement, NotificationCenterProps>(
|
|
95
|
+
function NotificationCenter(
|
|
96
|
+
{
|
|
97
|
+
entries,
|
|
98
|
+
viewport,
|
|
99
|
+
size = "regular",
|
|
100
|
+
title = "Notifications",
|
|
101
|
+
countLabel,
|
|
102
|
+
emptyState,
|
|
103
|
+
closeOnEscape = true,
|
|
104
|
+
onClose,
|
|
105
|
+
className = "",
|
|
106
|
+
...props
|
|
107
|
+
},
|
|
108
|
+
ref,
|
|
109
|
+
) {
|
|
110
|
+
useEffect(() => {
|
|
111
|
+
if (!closeOnEscape || !onClose) return;
|
|
112
|
+
const onKey = (e: KeyboardEvent) => {
|
|
113
|
+
if (e.key === "Escape") onClose();
|
|
114
|
+
};
|
|
115
|
+
window.addEventListener("keydown", onKey);
|
|
116
|
+
return () => window.removeEventListener("keydown", onKey);
|
|
117
|
+
}, [closeOnEscape, onClose]);
|
|
118
|
+
|
|
119
|
+
return (
|
|
120
|
+
<div
|
|
121
|
+
{...props}
|
|
122
|
+
ref={ref}
|
|
123
|
+
role={props.role ?? "dialog"}
|
|
124
|
+
aria-label={props["aria-label"] ?? "Notification center"}
|
|
125
|
+
className={[
|
|
126
|
+
"card-brutal flex flex-col overflow-hidden bg-white",
|
|
127
|
+
WIDTH_CLASS[viewport],
|
|
128
|
+
SIZE_CLASS[viewport][size],
|
|
129
|
+
className,
|
|
130
|
+
].join(" ")}
|
|
131
|
+
>
|
|
132
|
+
<div className="flex items-center gap-2 border-b-2 border-black bg-brutal-cream px-3 py-2">
|
|
133
|
+
<SectionEyebrow>{title}</SectionEyebrow>
|
|
134
|
+
<span className="ml-auto text-xs font-mono text-black/40">
|
|
135
|
+
{countLabel ?? defaultCountLabel(entries.length)}
|
|
136
|
+
</span>
|
|
137
|
+
</div>
|
|
138
|
+
|
|
139
|
+
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain">
|
|
140
|
+
{entries.length === 0 ? (
|
|
141
|
+
(emptyState ?? <NotificationCenterEmptyState />)
|
|
142
|
+
) : (
|
|
143
|
+
<ul className="divide-y-2 divide-black" aria-label="Notifications">
|
|
144
|
+
{entries.map((entry) => (
|
|
145
|
+
<NotificationCenterItem key={entry.id} entry={entry} />
|
|
146
|
+
))}
|
|
147
|
+
</ul>
|
|
148
|
+
)}
|
|
149
|
+
</div>
|
|
150
|
+
</div>
|
|
151
|
+
);
|
|
152
|
+
},
|
|
153
|
+
);
|
|
154
|
+
|
|
155
|
+
export default NotificationCenter;
|
|
156
|
+
|
|
157
|
+
export function NotificationCenterEmptyState({
|
|
158
|
+
title = "No notifications right now",
|
|
159
|
+
body = "You'll see things here that need your attention.",
|
|
160
|
+
}: {
|
|
161
|
+
title?: ReactNode;
|
|
162
|
+
body?: ReactNode;
|
|
163
|
+
}) {
|
|
164
|
+
return <EmptyState icon={<CheckCircle2 size={36} />} title={title} description={body} />;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function NotificationCenterItem({ entry }: { entry: NotificationCenterEntry }) {
|
|
168
|
+
return (
|
|
169
|
+
<li className={`px-3 py-3 ${entry.read ? "opacity-70" : ""}`}>
|
|
170
|
+
<div className="flex items-start gap-2">
|
|
171
|
+
<div
|
|
172
|
+
className={`mt-0.5 flex size-6 shrink-0 items-center justify-center border-2 border-black ${
|
|
173
|
+
KIND_DOT_BG[entry.kind]
|
|
174
|
+
}`}
|
|
175
|
+
aria-label={KIND_LABEL[entry.kind]}
|
|
176
|
+
>
|
|
177
|
+
{entry.icon ?? DEFAULT_KIND_ICON[entry.kind]}
|
|
178
|
+
</div>
|
|
179
|
+
<div className="min-w-0 flex-1">
|
|
180
|
+
<div className="text-sm font-bold text-black break-words">{entry.title}</div>
|
|
181
|
+
{entry.body && <div className="mt-0.5 text-sm break-words">{entry.body}</div>}
|
|
182
|
+
{entry.actions && entry.actions.length > 0 && (
|
|
183
|
+
<div className="mt-2 flex flex-wrap items-center gap-2">
|
|
184
|
+
{entry.actions.map((action, index) => (
|
|
185
|
+
<NotificationCenterActionButton
|
|
186
|
+
key={action.id ?? `${entry.id}-action-${index}`}
|
|
187
|
+
action={action}
|
|
188
|
+
/>
|
|
189
|
+
))}
|
|
190
|
+
</div>
|
|
191
|
+
)}
|
|
192
|
+
</div>
|
|
193
|
+
</div>
|
|
194
|
+
</li>
|
|
195
|
+
);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function NotificationCenterActionButton({ action }: { action: NotificationCenterAction }) {
|
|
199
|
+
const isPrimary = action.variant === "primary";
|
|
200
|
+
return (
|
|
201
|
+
<button
|
|
202
|
+
type="button"
|
|
203
|
+
onClick={action.onClick}
|
|
204
|
+
disabled={action.disabled}
|
|
205
|
+
aria-label={action.ariaLabel}
|
|
206
|
+
className={`btn-brutal px-3 py-1.5 text-xs disabled:cursor-not-allowed disabled:opacity-50 ${
|
|
207
|
+
isPrimary ? "bg-brutal-pink" : "bg-white"
|
|
208
|
+
}`}
|
|
209
|
+
>
|
|
210
|
+
{action.label}
|
|
211
|
+
</button>
|
|
212
|
+
);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function defaultCountLabel(count: number): ReactNode {
|
|
216
|
+
return count === 0 ? "all clear" : `${count} ${count === 1 ? "item" : "items"}`;
|
|
217
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { ArrowLeft } from "lucide-react";
|
|
2
|
+
import type { HTMLAttributes, ReactNode } from "react";
|
|
3
|
+
import Button from "./button.tsx";
|
|
4
|
+
|
|
5
|
+
type DivPassthroughProps = HTMLAttributes<HTMLDivElement> &
|
|
6
|
+
Record<`data-${string}`, string | undefined>;
|
|
7
|
+
type ButtonPassthroughProps = HTMLAttributes<HTMLButtonElement> &
|
|
8
|
+
Record<`data-${string}`, string | undefined>;
|
|
9
|
+
|
|
10
|
+
export interface PanelHeaderProps {
|
|
11
|
+
title?: ReactNode;
|
|
12
|
+
titleSlot?: ReactNode;
|
|
13
|
+
titleSuffix?: ReactNode;
|
|
14
|
+
subtitle?: ReactNode;
|
|
15
|
+
subtitleMultiline?: boolean;
|
|
16
|
+
icon?: ReactNode;
|
|
17
|
+
iconBg?: string;
|
|
18
|
+
iconSlot?: ReactNode;
|
|
19
|
+
iconAlwaysVisible?: boolean;
|
|
20
|
+
onMobileBack?: () => void;
|
|
21
|
+
mobileBreakpoint?: "md" | "lg";
|
|
22
|
+
mobileBackProps?: ButtonPassthroughProps;
|
|
23
|
+
titleClickProps?: DivPassthroughProps;
|
|
24
|
+
containerProps?: DivPassthroughProps;
|
|
25
|
+
actions?: ReactNode;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export default function PanelHeader({
|
|
29
|
+
title,
|
|
30
|
+
titleSlot,
|
|
31
|
+
titleSuffix,
|
|
32
|
+
subtitle,
|
|
33
|
+
subtitleMultiline = false,
|
|
34
|
+
icon,
|
|
35
|
+
iconBg = "bg-brutal-yellow",
|
|
36
|
+
iconSlot,
|
|
37
|
+
iconAlwaysVisible = false,
|
|
38
|
+
onMobileBack,
|
|
39
|
+
mobileBreakpoint = "md",
|
|
40
|
+
mobileBackProps,
|
|
41
|
+
titleClickProps,
|
|
42
|
+
containerProps,
|
|
43
|
+
actions,
|
|
44
|
+
}: PanelHeaderProps) {
|
|
45
|
+
const mobileHiddenClass = mobileBreakpoint === "lg" ? "lg:hidden" : "md:hidden";
|
|
46
|
+
const iconVisibilityClass = iconAlwaysVisible
|
|
47
|
+
? "flex"
|
|
48
|
+
: mobileBreakpoint === "lg"
|
|
49
|
+
? "hidden lg:flex"
|
|
50
|
+
: "hidden md:flex";
|
|
51
|
+
const { className: containerExtraClass, ...containerRest } = containerProps ?? {};
|
|
52
|
+
|
|
53
|
+
return (
|
|
54
|
+
<div
|
|
55
|
+
{...containerRest}
|
|
56
|
+
className={`flex h-panel-header items-center gap-3 border-b-2 border-black bg-white px-5 ${containerExtraClass ?? ""}`}
|
|
57
|
+
>
|
|
58
|
+
{onMobileBack && (
|
|
59
|
+
<Button
|
|
60
|
+
{...mobileBackProps}
|
|
61
|
+
onClick={onMobileBack}
|
|
62
|
+
shape="icon"
|
|
63
|
+
className={`${mobileHiddenClass} ${mobileBackProps?.className ?? ""}`}
|
|
64
|
+
>
|
|
65
|
+
<ArrowLeft size={14} />
|
|
66
|
+
</Button>
|
|
67
|
+
)}
|
|
68
|
+
{icon && (
|
|
69
|
+
<div
|
|
70
|
+
className={`${iconVisibilityClass} size-icon-header shrink-0 items-center justify-center border-2 border-black ${iconBg}`}
|
|
71
|
+
>
|
|
72
|
+
{icon}
|
|
73
|
+
</div>
|
|
74
|
+
)}
|
|
75
|
+
{iconSlot && <div className={`${iconVisibilityClass} shrink-0`}>{iconSlot}</div>}
|
|
76
|
+
{titleSlot ? (
|
|
77
|
+
<div className="min-w-0 flex-1">{titleSlot}</div>
|
|
78
|
+
) : (
|
|
79
|
+
<div {...titleClickProps} className={`min-w-0 flex-1 ${titleClickProps?.className ?? ""}`}>
|
|
80
|
+
<div className="flex min-w-0 items-center gap-2">
|
|
81
|
+
<h2 className="truncate text-base font-bold leading-tight text-black">{title}</h2>
|
|
82
|
+
{titleSuffix && <div className="shrink-0">{titleSuffix}</div>}
|
|
83
|
+
</div>
|
|
84
|
+
{subtitle && (
|
|
85
|
+
<p className={`font-mono text-xs text-black/50 ${subtitleMultiline ? "" : "truncate"}`}>
|
|
86
|
+
{subtitle}
|
|
87
|
+
</p>
|
|
88
|
+
)}
|
|
89
|
+
</div>
|
|
90
|
+
)}
|
|
91
|
+
{actions && <div className="flex shrink-0 items-center gap-1.5">{actions}</div>}
|
|
92
|
+
</div>
|
|
93
|
+
);
|
|
94
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
|
|
3
|
+
export const PREVIEW_SHELL_BORDER = "border border-black/15 hover:border-black/30";
|
|
4
|
+
|
|
5
|
+
export const PREVIEW_SHELL_SKIN = `${PREVIEW_SHELL_BORDER} bg-white hover:bg-black/5`;
|
|
6
|
+
|
|
7
|
+
export const PREVIEW_SHELL_SKIN_MUTED =
|
|
8
|
+
"border-2 border-black/30 bg-black/5 italic text-black/40 hover:shadow-brutal-sm";
|
|
9
|
+
|
|
10
|
+
export type PreviewShellVariant = "default" | "muted";
|
|
11
|
+
|
|
12
|
+
export interface PreviewShellProps {
|
|
13
|
+
variant?: PreviewShellVariant;
|
|
14
|
+
onClick?: () => void;
|
|
15
|
+
className?: string;
|
|
16
|
+
"data-testid"?: string;
|
|
17
|
+
title?: string;
|
|
18
|
+
"aria-busy"?: "true" | "false";
|
|
19
|
+
children: ReactNode;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export default function PreviewShell({
|
|
23
|
+
variant = "default",
|
|
24
|
+
onClick,
|
|
25
|
+
className,
|
|
26
|
+
children,
|
|
27
|
+
...rest
|
|
28
|
+
}: PreviewShellProps) {
|
|
29
|
+
const Root = onClick ? "button" : "div";
|
|
30
|
+
const skin = variant === "muted" ? PREVIEW_SHELL_SKIN_MUTED : PREVIEW_SHELL_SKIN;
|
|
31
|
+
return (
|
|
32
|
+
<Root
|
|
33
|
+
{...(onClick ? { type: "button" as const, onClick } : {})}
|
|
34
|
+
{...rest}
|
|
35
|
+
className={`${skin}${className ? ` ${className}` : ""}`}
|
|
36
|
+
>
|
|
37
|
+
{children}
|
|
38
|
+
</Root>
|
|
39
|
+
);
|
|
40
|
+
}
|