@tangle-network/ui 11.39.0 → 11.40.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/CHANGELOG.md +27 -0
- package/dist/index.d.ts +4 -2
- package/dist/index.js +3 -1
- package/dist/lib/control-presentation.d.ts +1 -1
- package/dist/lib/control-presentation.js +9 -7
- package/dist/presentation.d.ts +1 -1
- package/dist/presentation.js +9 -8
- package/dist/primitives/avatar.d.ts +9 -1
- package/dist/primitives/avatar.js +6 -2
- package/dist/primitives/button.d.ts +2 -1
- package/dist/primitives/field.d.ts +27 -0
- package/dist/primitives/field.js +54 -0
- package/dist/primitives/info-tip.js +50 -0
- package/dist/primitives/page-shell.d.ts +19 -3
- package/dist/primitives/page-shell.js +80 -22
- package/dist/primitives/segmented-control.js +1 -1
- package/dist/primitives/settings.d.ts +125 -0
- package/dist/primitives/settings.js +316 -0
- package/dist/primitives/switch.js +1 -1
- package/dist/primitives/upload-progress.js +11 -6
- package/dist/primitives.d.ts +4 -2
- package/dist/primitives.js +3 -1
- package/package.json +2 -2
- package/src/lib/control-presentation.ts +9 -7
- package/src/presentation.ts +10 -8
- package/src/primitives/avatar.tsx +10 -5
- package/src/primitives/button.tsx +2 -1
- package/src/primitives/control-presentation.stories.tsx +8 -1
- package/src/primitives/field.tsx +63 -0
- package/src/primitives/index.ts +16 -0
- package/src/primitives/info-tip.tsx +40 -0
- package/src/primitives/page-shell.tsx +70 -8
- package/src/primitives/settings.stories.tsx +267 -0
- package/src/primitives/settings.tsx +404 -0
- package/src/primitives/switch.tsx +3 -0
- package/src/primitives/upload-progress.stories.tsx +36 -1
- package/src/primitives/upload-progress.tsx +13 -8
- package/src/styles/compat.css +5 -5
- package/src/styles/presentation.css +43 -2
|
@@ -2,14 +2,19 @@ import * as AvatarPrimitive from "@radix-ui/react-avatar";
|
|
|
2
2
|
import * as React from "react";
|
|
3
3
|
import { cn } from "../lib/utils";
|
|
4
4
|
|
|
5
|
-
const
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
5
|
+
const AVATAR_SIZES = { sm: "h-8 w-8", md: "h-10 w-10" } as const;
|
|
6
|
+
|
|
7
|
+
export interface AvatarProps extends React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Root> {
|
|
8
|
+
/** sm 32px (a list row beside a two-line name), md 40px (default). */
|
|
9
|
+
size?: keyof typeof AVATAR_SIZES;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const Avatar = React.forwardRef<React.ElementRef<typeof AvatarPrimitive.Root>, AvatarProps>(({ className, size = "md", ...props }, ref) => (
|
|
9
13
|
<AvatarPrimitive.Root
|
|
10
14
|
ref={ref}
|
|
11
15
|
className={cn(
|
|
12
|
-
"relative flex
|
|
16
|
+
"relative flex shrink-0 overflow-hidden rounded-full",
|
|
17
|
+
AVATAR_SIZES[size],
|
|
13
18
|
className,
|
|
14
19
|
)}
|
|
15
20
|
{...props}
|
|
@@ -36,7 +36,8 @@ function buttonClassName(
|
|
|
36
36
|
|
|
37
37
|
export interface ButtonProps
|
|
38
38
|
extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
|
39
|
-
|
|
39
|
+
/** `destructive-outline` is a destructive action at rest, such as "Delete project…", whose confirmation uses `destructive`. */
|
|
40
|
+
variant?: "default" | "destructive" | "destructive-outline" | "outline" | "secondary" | "ghost" | "link" | "sandbox" | "bare" | null;
|
|
40
41
|
/** sm 32px, default/md 36px (`--control-height`), lg 44px; icon sizes are squares on the same heights. */
|
|
41
42
|
size?: "default" | "md" | "sm" | "lg" | "xl" | "display" | "icon" | "icon-sm" | "icon-lg" | "compact" | "touch" | null;
|
|
42
43
|
asChild?: boolean;
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import * as React from "react";
|
|
2
2
|
import type { Meta, StoryObj } from "@storybook/react";
|
|
3
3
|
import { Button } from "./button";
|
|
4
|
+
import { X } from "lucide-react";
|
|
4
5
|
import { HelpText } from "./help-text";
|
|
5
6
|
import { Input, Textarea } from "./input";
|
|
6
7
|
import { Label } from "./label";
|
|
@@ -13,7 +14,7 @@ function Contract() {
|
|
|
13
14
|
return (
|
|
14
15
|
<main className="mx-auto max-w-5xl space-y-6 bg-background p-4 text-foreground" data-presentation-contract>
|
|
15
16
|
<h1 className="text-xl font-semibold">Field and metric presentation</h1>
|
|
16
|
-
{(["sm", "md", "lg", "compact", "touch"] as const).map((size) => (
|
|
17
|
+
{(["default", "sm", "md", "lg", "compact", "touch"] as const).map((size) => (
|
|
17
18
|
<section key={size} data-size-row={size} className="space-y-2">
|
|
18
19
|
<h2 className="font-semibold">{size}</h2>
|
|
19
20
|
<div className="flex flex-wrap items-end gap-3">
|
|
@@ -27,10 +28,16 @@ function Contract() {
|
|
|
27
28
|
</Select>
|
|
28
29
|
<Button size={size} onClick={() => setActions((value) => value + 1)}>Save</Button>
|
|
29
30
|
<Button size={size} variant="outline">Cancel</Button>
|
|
31
|
+
<Button size={size} aria-label={`${size} short action`}>+</Button>
|
|
30
32
|
</div>
|
|
31
33
|
<Textarea size={size} aria-label={`${size} notes`} placeholder="Details" />
|
|
32
34
|
</section>
|
|
33
35
|
))}
|
|
36
|
+
<section className="flex flex-wrap gap-3" aria-label="Icon controls">
|
|
37
|
+
{(["icon-sm", "icon", "icon-lg"] as const).map((size) => (
|
|
38
|
+
<Button key={size} size={size} aria-label={`${size} action`} onClick={() => setActions((value) => value + 1)}><X aria-hidden="true" /></Button>
|
|
39
|
+
))}
|
|
40
|
+
</section>
|
|
34
41
|
<section className="max-w-sm space-y-1.5" data-form-text>
|
|
35
42
|
<Label htmlFor="workspace-name">Workspace name</Label>
|
|
36
43
|
<Input id="workspace-name" aria-describedby="workspace-name-hint" placeholder="Acme" />
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { CircleAlert } from "lucide-react";
|
|
4
|
+
import * as React from "react";
|
|
5
|
+
import { cn } from "../lib/utils";
|
|
6
|
+
import { InfoTip } from "./info-tip";
|
|
7
|
+
import { Label } from "./label";
|
|
8
|
+
|
|
9
|
+
export interface FieldProps {
|
|
10
|
+
label: string;
|
|
11
|
+
/** The control's id; the label targets it. */
|
|
12
|
+
htmlFor: string;
|
|
13
|
+
/** Help that explains what the setting does, shown from an info button. */
|
|
14
|
+
info?: React.ReactNode;
|
|
15
|
+
/** One short sentence the person needs to fill the field in. */
|
|
16
|
+
hint?: React.ReactNode;
|
|
17
|
+
/** A validation error. The control should set `aria-invalid` and reference {@link fieldMessageId}. */
|
|
18
|
+
error?: React.ReactNode;
|
|
19
|
+
/** A non-error status, such as a check that passed. */
|
|
20
|
+
status?: React.ReactNode;
|
|
21
|
+
/** Trailing content in the label row, such as a "Use default" action. */
|
|
22
|
+
aside?: React.ReactNode;
|
|
23
|
+
children: React.ReactNode;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** The id of a field's hint, error, or status line, for the control's `aria-describedby`. */
|
|
27
|
+
export function fieldMessageId(htmlFor: string): string {
|
|
28
|
+
return `${htmlFor}-message`;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* A labelled form field: the label (with an optional info tip and a trailing
|
|
33
|
+
* aside) above the control, and one message line under it. Every line is
|
|
34
|
+
* 14px; an error replaces the status and the hint and is announced.
|
|
35
|
+
*/
|
|
36
|
+
export function Field({ label, htmlFor, info, hint, error, status, aside, children }: FieldProps) {
|
|
37
|
+
const message = error ?? status ?? hint;
|
|
38
|
+
return (
|
|
39
|
+
<div className="min-w-0 space-y-2">
|
|
40
|
+
<div className="flex min-h-6 items-center justify-between gap-2">
|
|
41
|
+
<span className="flex min-w-0 items-center gap-1.5">
|
|
42
|
+
<Label htmlFor={htmlFor} className="leading-6 text-foreground">{label}</Label>
|
|
43
|
+
{info && <InfoTip label={label}>{info}</InfoTip>}
|
|
44
|
+
</span>
|
|
45
|
+
{aside}
|
|
46
|
+
</div>
|
|
47
|
+
{children}
|
|
48
|
+
{message && (
|
|
49
|
+
<div
|
|
50
|
+
id={fieldMessageId(htmlFor)}
|
|
51
|
+
role={error ? "alert" : undefined}
|
|
52
|
+
className={cn(
|
|
53
|
+
"text-sm leading-6 [overflow-wrap:anywhere]",
|
|
54
|
+
error ? "flex items-start gap-1.5 text-[var(--surface-danger-text)]" : "text-muted-foreground",
|
|
55
|
+
)}
|
|
56
|
+
>
|
|
57
|
+
{error && <CircleAlert aria-hidden="true" className="mt-1 size-4 shrink-0" />}
|
|
58
|
+
{error ? <span className="min-w-0">{message}</span> : message}
|
|
59
|
+
</div>
|
|
60
|
+
)}
|
|
61
|
+
</div>
|
|
62
|
+
);
|
|
63
|
+
}
|
package/src/primitives/index.ts
CHANGED
|
@@ -79,6 +79,7 @@ export { Badge, badgeVariants } from "./badge";
|
|
|
79
79
|
export type { BadgeProps } from "./badge";
|
|
80
80
|
|
|
81
81
|
export { Avatar, AvatarImage, AvatarFallback } from "./avatar";
|
|
82
|
+
export type { AvatarProps } from "./avatar";
|
|
82
83
|
|
|
83
84
|
export {
|
|
84
85
|
DropdownMenu,
|
|
@@ -199,6 +200,21 @@ export type { HeadingProps, HeadingVariant } from "./heading";
|
|
|
199
200
|
export { PageShell } from "./page-shell";
|
|
200
201
|
export type { PageShellProps } from "./page-shell";
|
|
201
202
|
|
|
203
|
+
export { Field, fieldMessageId } from "./field";
|
|
204
|
+
export type { FieldProps } from "./field";
|
|
205
|
+
|
|
206
|
+
export { SettingsList, SettingsListItem, SettingsRow, SettingsSection } from "./settings";
|
|
207
|
+
export type {
|
|
208
|
+
SettingsListAction,
|
|
209
|
+
SettingsListColumns,
|
|
210
|
+
SettingsListItemProps,
|
|
211
|
+
SettingsListMenuItem,
|
|
212
|
+
SettingsListProps,
|
|
213
|
+
SettingsRowLayout,
|
|
214
|
+
SettingsRowProps,
|
|
215
|
+
SettingsSectionProps,
|
|
216
|
+
} from "./settings";
|
|
217
|
+
|
|
202
218
|
export { PageHeader } from "./page-header";
|
|
203
219
|
export type { PageHeaderProps } from "./page-header";
|
|
204
220
|
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { Info } from "lucide-react";
|
|
4
|
+
import * as React from "react";
|
|
5
|
+
import { Button } from "./button";
|
|
6
|
+
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "./tooltip";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* An info button beside a label, for help that explains a setting. It opens
|
|
10
|
+
* on hover and keyboard focus and toggles on tap. The glyph stays 20px; on a
|
|
11
|
+
* coarse pointer a transparent 44px hit area surrounds it. It brings its own
|
|
12
|
+
* provider, so a row or field works in an app that never mounted one.
|
|
13
|
+
*/
|
|
14
|
+
export function InfoTip({ label, children }: { label: string; children: React.ReactNode }) {
|
|
15
|
+
const [open, setOpen] = React.useState(false);
|
|
16
|
+
// Radix closes a tooltip on every trigger press and click; a tap or click toggles it instead.
|
|
17
|
+
const openAtPress = React.useRef(false);
|
|
18
|
+
return (
|
|
19
|
+
<TooltipProvider delayDuration={150}>
|
|
20
|
+
<Tooltip open={open} onOpenChange={setOpen}>
|
|
21
|
+
<TooltipTrigger asChild>
|
|
22
|
+
<Button
|
|
23
|
+
type="button"
|
|
24
|
+
variant="bare"
|
|
25
|
+
aria-label={`About ${label}`}
|
|
26
|
+
onPointerDown={() => { openAtPress.current = open; }}
|
|
27
|
+
onClick={(event) => {
|
|
28
|
+
event.preventDefault();
|
|
29
|
+
setOpen(event.detail === 0 ? !open : !openAtPress.current);
|
|
30
|
+
}}
|
|
31
|
+
className="relative inline-flex size-5 shrink-0 items-center justify-center rounded-full text-muted-foreground hover:text-foreground pointer-coarse:before:absolute pointer-coarse:before:-inset-3 pointer-coarse:before:content-['']"
|
|
32
|
+
>
|
|
33
|
+
<Info aria-hidden="true" className="size-4" />
|
|
34
|
+
</Button>
|
|
35
|
+
</TooltipTrigger>
|
|
36
|
+
<TooltipContent side="top" className="max-w-72 text-sm leading-5">{children}</TooltipContent>
|
|
37
|
+
</Tooltip>
|
|
38
|
+
</TooltipProvider>
|
|
39
|
+
);
|
|
40
|
+
}
|
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import * as React from "react";
|
|
1
4
|
import type { ReactNode } from "react";
|
|
2
5
|
import { cn } from "../lib/utils";
|
|
3
6
|
|
|
@@ -7,23 +10,82 @@ export interface PageShellProps {
|
|
|
7
10
|
navigation?: ReactNode;
|
|
8
11
|
article?: ReactNode;
|
|
9
12
|
outline?: ReactNode;
|
|
13
|
+
/** Spans the reading layout above navigation and article, such as the page's `PageHeader`. */
|
|
14
|
+
header?: ReactNode;
|
|
15
|
+
/**
|
|
16
|
+
* The reading layout's measure. `reading` (default) is the 42rem document
|
|
17
|
+
* column. `settings` is a 48rem column of cards whose navigation is a sticky
|
|
18
|
+
* 13rem column of rows when its frame is at least 60rem wide, and a sticky
|
|
19
|
+
* 56px tab strip under the header below that; the current link
|
|
20
|
+
* (`aria-current="page"`) is centred by scrolling the strip only. Put
|
|
21
|
+
* links (or a list of links) in `navigation`; an `svg` in a link shows only
|
|
22
|
+
* in the column, and an element with `data-count` sits at its end.
|
|
23
|
+
*/
|
|
24
|
+
measure?: "reading" | "settings";
|
|
10
25
|
navigationLabel?: string;
|
|
11
26
|
outlineLabel?: string;
|
|
12
27
|
className?: string;
|
|
13
28
|
}
|
|
14
29
|
|
|
30
|
+
/**
|
|
31
|
+
* Centre the current link of a horizontally scrolling nav, so the links on
|
|
32
|
+
* both sides show that the strip scrolls. It moves the strip, never the page.
|
|
33
|
+
*/
|
|
34
|
+
function useCurrentLinkInView(ref: React.RefObject<HTMLElement | null>, enabled: boolean) {
|
|
35
|
+
React.useEffect(() => {
|
|
36
|
+
const nav = ref.current;
|
|
37
|
+
if (!enabled || !nav) return;
|
|
38
|
+
const reveal = () => {
|
|
39
|
+
if (nav.scrollWidth <= nav.clientWidth) return;
|
|
40
|
+
const current = nav.querySelector<HTMLElement>('[aria-current="page"]');
|
|
41
|
+
if (!current) return;
|
|
42
|
+
const strip = nav.getBoundingClientRect();
|
|
43
|
+
const link = current.getBoundingClientRect();
|
|
44
|
+
nav.scrollLeft += link.left + link.width / 2 - (strip.left + strip.width / 2);
|
|
45
|
+
};
|
|
46
|
+
let live = true;
|
|
47
|
+
reveal();
|
|
48
|
+
// A web font swap widens the tabs without resizing the strip, so the
|
|
49
|
+
// resize observer alone would leave the current tab where it was.
|
|
50
|
+
void document.fonts?.ready.then(() => { if (live) reveal(); });
|
|
51
|
+
const resize = typeof ResizeObserver === "undefined" ? null : new ResizeObserver(reveal);
|
|
52
|
+
resize?.observe(nav);
|
|
53
|
+
// Links that a caller replaces (sections loaded later, a count that
|
|
54
|
+
// changes) move the current tab as surely as a new aria-current does.
|
|
55
|
+
const links = new MutationObserver(reveal);
|
|
56
|
+
links.observe(nav, { subtree: true, childList: true, characterData: true, attributes: true, attributeFilter: ["aria-current"] });
|
|
57
|
+
return () => {
|
|
58
|
+
live = false;
|
|
59
|
+
resize?.disconnect();
|
|
60
|
+
links.disconnect();
|
|
61
|
+
};
|
|
62
|
+
}, [ref, enabled]);
|
|
63
|
+
}
|
|
64
|
+
|
|
15
65
|
/**
|
|
16
66
|
* Ported from sandbox-ui/src/primitives/page-shell.tsx. An optional bounded
|
|
17
67
|
* width, gutter and vertical rhythm, not an application shell or a card grid.
|
|
18
|
-
* The caller owns landmarks, routing, auth, navigation and data state.
|
|
19
|
-
* className
|
|
68
|
+
* The caller owns landmarks, routing, auth, navigation and data state.
|
|
69
|
+
* Without slots, className overrides the bound/gutters, or omit it for a
|
|
70
|
+
* workbench layout. With slots it goes on the reading grid. With
|
|
71
|
+
* `measure="settings"` it goes on the frame, the width the layout answers to,
|
|
72
|
+
* so a `max-w-*` there bounds the measure and a narrow frame gets the strip.
|
|
20
73
|
*/
|
|
21
|
-
export function PageShell({ children, className, navigation, article, outline, navigationLabel = "Sections", outlineLabel = "On this page" }: PageShellProps) {
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
74
|
+
export function PageShell({ children, className, navigation, article, outline, header, measure = "reading", navigationLabel = "Sections", outlineLabel = "On this page" }: PageShellProps) {
|
|
75
|
+
const navRef = React.useRef<HTMLElement>(null);
|
|
76
|
+
const settings = measure === "settings";
|
|
77
|
+
useCurrentLinkInView(navRef, settings && Boolean(navigation));
|
|
78
|
+
if (navigation || article || outline || header || settings) {
|
|
79
|
+
const shell = <div className={cn("tangle-reading-shell", !settings && className)} data-measure={settings ? "settings" : undefined}>
|
|
80
|
+
{header && <div className="tangle-reading-header">{header}</div>}
|
|
81
|
+
{navigation && <nav ref={navRef} aria-label={navigationLabel} className="tangle-reading-nav">{navigation}</nav>}
|
|
82
|
+
<article className="tangle-reading-article">{article ?? children}</article>
|
|
83
|
+
{outline && <aside aria-label={outlineLabel} className="tangle-reading-outline">{outline}</aside>}
|
|
84
|
+
</div>;
|
|
85
|
+
// The frame is the width container the settings layout answers to, so the
|
|
86
|
+
// navigation switches on the content area's width, not the viewport's.
|
|
87
|
+
return settings ? <div className={cn("tangle-settings-frame", className)}>{shell}</div> : shell;
|
|
88
|
+
}
|
|
27
89
|
return (
|
|
28
90
|
<div
|
|
29
91
|
className={cn(
|
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
import { Bell, Bot, Check, CreditCard, Inbox, Loader2, Settings, Users, Workflow } from "lucide-react";
|
|
2
|
+
import { useEffect, useState } from "react";
|
|
3
|
+
import type { Meta, StoryObj } from "@storybook/react";
|
|
4
|
+
import { Avatar, AvatarFallback } from "./avatar";
|
|
5
|
+
import { Button } from "./button";
|
|
6
|
+
import { Card } from "./card";
|
|
7
|
+
import { Field, fieldMessageId } from "./field";
|
|
8
|
+
import { Input, Textarea } from "./input";
|
|
9
|
+
import { PageHeader } from "./page-header";
|
|
10
|
+
import { PageShell } from "./page-shell";
|
|
11
|
+
import { SegmentedControl } from "./segmented-control";
|
|
12
|
+
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./select";
|
|
13
|
+
import { SettingsList, SettingsListItem, SettingsRow, SettingsSection } from "./settings";
|
|
14
|
+
import { Switch } from "./switch";
|
|
15
|
+
|
|
16
|
+
/** Example data only: no routing, persistence or fetching. Every state is drawn at once so one screenshot shows them all. */
|
|
17
|
+
|
|
18
|
+
const SECTIONS = [
|
|
19
|
+
{ id: "general", label: "General", icon: Settings },
|
|
20
|
+
{ id: "agent", label: "Agent", icon: Bot },
|
|
21
|
+
{ id: "notifications", label: "Notifications", icon: Bell },
|
|
22
|
+
{ id: "automations", label: "Automations", icon: Workflow },
|
|
23
|
+
{ id: "leads", label: "Inbound leads", icon: Inbox },
|
|
24
|
+
{ id: "team", label: "Team", icon: Users, count: 4 },
|
|
25
|
+
{ id: "billing", label: "Ad spend", icon: CreditCard },
|
|
26
|
+
] as const;
|
|
27
|
+
|
|
28
|
+
function Saving() {
|
|
29
|
+
return <><Loader2 aria-hidden="true" className="size-4 animate-spin motion-reduce:animate-none" />Saving…</>;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function Saved() {
|
|
33
|
+
return <span className="inline-flex items-center gap-1 text-[var(--surface-success-text)]"><Check aria-hidden="true" className="size-4" />Saved</span>;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
interface Automation { name: string; meta: string; state: string; verb: string; ownerOnly?: boolean; error?: string }
|
|
37
|
+
|
|
38
|
+
const automations: Automation[] = [
|
|
39
|
+
{ name: "Weekly competitor scan", meta: "Mondays at 09:00", state: "Active", verb: "Pause" },
|
|
40
|
+
{ name: "Inbound lead triage", meta: "When a signed webhook arrives", state: "Paused", verb: "Resume", ownerOnly: true },
|
|
41
|
+
{ name: "Daily brief", meta: "Every day at 08:00 · Europe/Berlin", state: "Couldn't start", verb: "Try again", error: "Platform didn't respond. Nothing ran; try again." },
|
|
42
|
+
];
|
|
43
|
+
|
|
44
|
+
/** Pause turns an automation off; Resume and Try again turn it back on. */
|
|
45
|
+
function runVerb(job: Automation): Automation {
|
|
46
|
+
return job.state === "Active" ? { ...job, state: "Paused", verb: "Resume" } : { ...job, state: "Active", verb: "Pause", error: undefined };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const members = [
|
|
50
|
+
{ name: "Priya Raman", email: "priya@fieldnote.example", role: "owner" },
|
|
51
|
+
{ name: "Jonas Weber", email: "jonas@fieldnote.example", role: "admin" },
|
|
52
|
+
{ name: "Ana Lima", email: "ana.lima.growth-operations-team@fieldnote-analytics.example", role: "editor" },
|
|
53
|
+
];
|
|
54
|
+
|
|
55
|
+
const roles = [
|
|
56
|
+
{ value: "owner", label: "Owner" },
|
|
57
|
+
{ value: "admin", label: "Can manage settings and people" },
|
|
58
|
+
{ value: "editor", label: "Can edit and chat" },
|
|
59
|
+
{ value: "viewer", label: "Can only view" },
|
|
60
|
+
];
|
|
61
|
+
|
|
62
|
+
interface SettingsPageProps {
|
|
63
|
+
/** Only the first two sections render at first; the rest arrive 300ms later, as role-gated sections do. */
|
|
64
|
+
sectionsLoadLater?: boolean;
|
|
65
|
+
/** Leave out the section navigation. */
|
|
66
|
+
withoutNavigation?: boolean;
|
|
67
|
+
className?: string;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function SettingsPage({ sectionsLoadLater = false, withoutNavigation = false, className }: SettingsPageProps) {
|
|
71
|
+
const [current, setCurrent] = useState("team");
|
|
72
|
+
const [sections, setSections] = useState<readonly (typeof SECTIONS)[number][]>(sectionsLoadLater ? SECTIONS.slice(0, 2) : SECTIONS);
|
|
73
|
+
useEffect(() => {
|
|
74
|
+
if (!sectionsLoadLater) return;
|
|
75
|
+
const loaded = setTimeout(() => setSections(SECTIONS), 300);
|
|
76
|
+
return () => clearTimeout(loaded);
|
|
77
|
+
}, [sectionsLoadLater]);
|
|
78
|
+
const [name, setName] = useState("Fieldnote GTM");
|
|
79
|
+
const [digest, setDigest] = useState(true);
|
|
80
|
+
const [approval, setApproval] = useState("each");
|
|
81
|
+
const [jobs, setJobs] = useState(automations);
|
|
82
|
+
const [roleOf, setRoleOf] = useState<Record<string, string>>(Object.fromEntries(members.map((m) => [m.email, m.role])));
|
|
83
|
+
|
|
84
|
+
const navigation = withoutNavigation ? undefined : sections.map((section) => {
|
|
85
|
+
const Icon = section.icon;
|
|
86
|
+
return (
|
|
87
|
+
<a key={section.id} href={`#${section.id}`} aria-current={current === section.id ? "page" : undefined} onClick={() => setCurrent(section.id)}>
|
|
88
|
+
<Icon aria-hidden="true" />
|
|
89
|
+
<span>{section.label}</span>
|
|
90
|
+
{"count" in section && <span data-count="">{section.count}</span>}
|
|
91
|
+
</a>
|
|
92
|
+
);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
return (
|
|
96
|
+
<PageShell
|
|
97
|
+
measure="settings"
|
|
98
|
+
className={className}
|
|
99
|
+
navigationLabel="Settings sections"
|
|
100
|
+
header={<PageHeader title="Project settings" description="Fieldnote GTM" />}
|
|
101
|
+
navigation={navigation}
|
|
102
|
+
article={<>
|
|
103
|
+
<SettingsSection id="general" title="General">
|
|
104
|
+
<SettingsRow label="Project name" htmlFor="project-name" status={<Saved />}>
|
|
105
|
+
<Input id="project-name" value={name} onChange={(event) => setName(event.target.value)} />
|
|
106
|
+
</SettingsRow>
|
|
107
|
+
<SettingsRow label="Timezone" htmlFor="timezone" description="Moves the digest hour; scheduled posts never move." info="Dates in chats, emails and exports use this zone.">
|
|
108
|
+
<Select defaultValue="berlin">
|
|
109
|
+
<SelectTrigger id="timezone" aria-describedby={fieldMessageId("timezone")}><SelectValue /></SelectTrigger>
|
|
110
|
+
<SelectContent>
|
|
111
|
+
<SelectItem value="berlin">Europe/Berlin (UTC+2)</SelectItem>
|
|
112
|
+
<SelectItem value="new-york">America/New_York (UTC−4)</SelectItem>
|
|
113
|
+
</SelectContent>
|
|
114
|
+
</Select>
|
|
115
|
+
</SettingsRow>
|
|
116
|
+
<SettingsRow label="Business brief" layout="compact">
|
|
117
|
+
<Button type="button" variant="outline">Edit brief</Button>
|
|
118
|
+
</SettingsRow>
|
|
119
|
+
<SettingsRow label="Custom instructions" htmlFor="instructions" layout="stacked" status={<Saving />} description="Applies to new chats and the next automation run.">
|
|
120
|
+
<Textarea id="instructions" defaultValue="Write in plain English. Never promise delivery dates." />
|
|
121
|
+
</SettingsRow>
|
|
122
|
+
</SettingsSection>
|
|
123
|
+
|
|
124
|
+
<SettingsSection id="notifications" title="Notifications" description="Daily digest and approval notices go to the owner.">
|
|
125
|
+
<SettingsRow label="Daily digest" htmlFor="digest" layout="compact">
|
|
126
|
+
<Switch id="digest" checked={digest} onCheckedChange={setDigest} />
|
|
127
|
+
</SettingsRow>
|
|
128
|
+
<SettingsRow label="Approval notices" htmlFor="approval-mode">
|
|
129
|
+
<SegmentedControl
|
|
130
|
+
id="approval-mode"
|
|
131
|
+
aria-label="Approval notices"
|
|
132
|
+
value={approval}
|
|
133
|
+
onValueChange={setApproval}
|
|
134
|
+
options={[{ value: "each", label: "Each request" }, { value: "daily", label: "Once a day" }, { value: "never", label: "Never" }]}
|
|
135
|
+
/>
|
|
136
|
+
</SettingsRow>
|
|
137
|
+
<SettingsRow label="Digest hour" htmlFor="digest-hour" error="Couldn't save the digest hour." onRetry={() => undefined}>
|
|
138
|
+
<Input id="digest-hour" defaultValue="08:00" />
|
|
139
|
+
</SettingsRow>
|
|
140
|
+
<SettingsRow label="Texting phone" htmlFor="texting-phone" disabledReason="Only Priya, the owner, can change the texting phone.">
|
|
141
|
+
<Input id="texting-phone" defaultValue="+1 (555) 010-0199" disabled />
|
|
142
|
+
</SettingsRow>
|
|
143
|
+
<SettingsRow label="Send today's digest" layout="compact">
|
|
144
|
+
<Button type="button" variant="outline">Send now</Button>
|
|
145
|
+
</SettingsRow>
|
|
146
|
+
</SettingsSection>
|
|
147
|
+
|
|
148
|
+
<SettingsSection id="automations" title="Automations" info="Automations run on the project's agent and are billed as compute." actions={<Button type="button" variant="outline">New automation</Button>}>
|
|
149
|
+
<SettingsList aria-label="Automations" columns={{ status: "8rem", action: "9rem" }}>
|
|
150
|
+
{jobs.map((job) => (
|
|
151
|
+
<SettingsListItem
|
|
152
|
+
key={job.name}
|
|
153
|
+
title={<a href={`#${job.name}`}>{job.name}</a>}
|
|
154
|
+
meta={job.meta}
|
|
155
|
+
status={job.error ? <span className="font-medium text-[var(--surface-danger-text)]">{job.state}</span> : job.state}
|
|
156
|
+
error={job.error}
|
|
157
|
+
action={{
|
|
158
|
+
label: job.verb,
|
|
159
|
+
onSelect: () => setJobs((all) => all.map((each) => (each.name === job.name ? runVerb(each) : each))),
|
|
160
|
+
disabledReason: job.ownerOnly ? "Only Priya, the owner, can resume it." : undefined,
|
|
161
|
+
}}
|
|
162
|
+
menu={[
|
|
163
|
+
{ label: "Duplicate", onSelect: () => undefined },
|
|
164
|
+
job.ownerOnly
|
|
165
|
+
? { label: "Delete", tone: "danger", onSelect: () => undefined, disabledReason: "Only Priya, the owner, can delete it." }
|
|
166
|
+
: { label: "Delete", tone: "danger", onSelect: () => undefined },
|
|
167
|
+
]}
|
|
168
|
+
/>
|
|
169
|
+
))}
|
|
170
|
+
</SettingsList>
|
|
171
|
+
</SettingsSection>
|
|
172
|
+
|
|
173
|
+
<SettingsSection id="team" title="Members" actions={<Button type="button">Invite</Button>}>
|
|
174
|
+
<SettingsList aria-label="Members" columns={{ leading: "32px", status: "18rem", menu: "36px" }}>
|
|
175
|
+
{members.map((member) => {
|
|
176
|
+
const roleId = `role-${member.email}`;
|
|
177
|
+
return (
|
|
178
|
+
<SettingsListItem
|
|
179
|
+
key={member.email}
|
|
180
|
+
leading={<Avatar size="sm"><AvatarFallback>{member.name.split(" ").map((part) => part[0]).join("")}</AvatarFallback></Avatar>}
|
|
181
|
+
title={member.name}
|
|
182
|
+
meta={member.email}
|
|
183
|
+
status={member.role === "owner" ? <span className="text-foreground">Owner</span> : (
|
|
184
|
+
<Select value={roleOf[member.email]} onValueChange={(role) => setRoleOf((all) => ({ ...all, [member.email]: role }))}>
|
|
185
|
+
<SelectTrigger id={roleId} aria-label={`Role for ${member.name}`}><SelectValue /></SelectTrigger>
|
|
186
|
+
<SelectContent>
|
|
187
|
+
{roles.filter((role) => role.value !== "owner").map((role) => <SelectItem key={role.value} value={role.value}>{role.label}</SelectItem>)}
|
|
188
|
+
</SelectContent>
|
|
189
|
+
</Select>
|
|
190
|
+
)}
|
|
191
|
+
menu={member.role === "owner" ? undefined : [{ label: "Remove from project", tone: "danger", onSelect: () => undefined }]}
|
|
192
|
+
/>
|
|
193
|
+
);
|
|
194
|
+
})}
|
|
195
|
+
</SettingsList>
|
|
196
|
+
</SettingsSection>
|
|
197
|
+
|
|
198
|
+
<SettingsSection
|
|
199
|
+
id="billing"
|
|
200
|
+
title="Ad spend caps"
|
|
201
|
+
description="Ads pause when a cap is reached."
|
|
202
|
+
footer={<>
|
|
203
|
+
<span className="text-muted-foreground">2 unsaved changes</span>
|
|
204
|
+
<span className="ml-auto flex gap-2">
|
|
205
|
+
<Button type="button" variant="ghost">Discard</Button>
|
|
206
|
+
<Button type="button">Save caps</Button>
|
|
207
|
+
</span>
|
|
208
|
+
</>}
|
|
209
|
+
>
|
|
210
|
+
<SettingsRow label="Daily cap" htmlFor="daily-cap"><Input id="daily-cap" defaultValue="$40" /></SettingsRow>
|
|
211
|
+
<SettingsRow label="Monthly cap" htmlFor="monthly-cap"><Input id="monthly-cap" defaultValue="$900" /></SettingsRow>
|
|
212
|
+
</SettingsSection>
|
|
213
|
+
|
|
214
|
+
<SettingsSection id="delete" tone="danger" title="Delete project" description="Deletes every chat, vault file and automation for everyone." actions={<Button type="button" variant="destructive-outline">Delete project…</Button>} />
|
|
215
|
+
</>}
|
|
216
|
+
/>
|
|
217
|
+
);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function FieldsExample() {
|
|
221
|
+
const [model, setModel] = useState("claude-sonnet");
|
|
222
|
+
return (
|
|
223
|
+
<div className="mx-auto max-w-xl p-6">
|
|
224
|
+
<Card className="space-y-4 p-4">
|
|
225
|
+
<Field label="Display name" htmlFor="display-name" hint="Use 40 characters or fewer.">
|
|
226
|
+
<Input id="display-name" defaultValue="Research agent" aria-describedby={fieldMessageId("display-name")} />
|
|
227
|
+
</Field>
|
|
228
|
+
<Field label="Default model" htmlFor="default-model" info="Replaces the profile's model for new chats." aside={<Button type="button" variant="ghost">Use default</Button>}
|
|
229
|
+
error={model.startsWith("claude") ? "This project's catalog refuses Anthropic models. Pick another." : undefined}>
|
|
230
|
+
<Select value={model} onValueChange={setModel}>
|
|
231
|
+
<SelectTrigger id="default-model" aria-invalid={model.startsWith("claude") || undefined} aria-describedby={fieldMessageId("default-model")}><SelectValue /></SelectTrigger>
|
|
232
|
+
<SelectContent>
|
|
233
|
+
<SelectItem value="claude-sonnet">claude-sonnet</SelectItem>
|
|
234
|
+
<SelectItem value="gpt-6.1-sol">gpt-6.1-sol</SelectItem>
|
|
235
|
+
</SelectContent>
|
|
236
|
+
</Select>
|
|
237
|
+
</Field>
|
|
238
|
+
<Field label="Webhook URL" htmlFor="webhook" status="Reachable: the last check returned 200.">
|
|
239
|
+
<Input id="webhook" defaultValue="https://hooks.fieldnote.example/gtm/leads" aria-describedby={fieldMessageId("webhook")} />
|
|
240
|
+
</Field>
|
|
241
|
+
</Card>
|
|
242
|
+
</div>
|
|
243
|
+
);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
const meta = {
|
|
247
|
+
title: "Primitives/Settings",
|
|
248
|
+
component: SettingsSection,
|
|
249
|
+
parameters: { layout: "fullscreen" },
|
|
250
|
+
} satisfies Meta<typeof SettingsSection>;
|
|
251
|
+
export default meta;
|
|
252
|
+
type Story = StoryObj<typeof SettingsSection>;
|
|
253
|
+
|
|
254
|
+
/** Every settings atom state on one page in PageShell measure="settings"; resize to see the nav switch at 60rem and rows stack at 34rem. */
|
|
255
|
+
export const Page: Story = { render: () => <SettingsPage /> };
|
|
256
|
+
|
|
257
|
+
/** The sections a role gates load after the page renders; on a phone the strip still centres the current tab when it arrives. */
|
|
258
|
+
export const SectionsLoadLater: Story = { render: () => <SettingsPage sectionsLoadLater /> };
|
|
259
|
+
|
|
260
|
+
/** A settings page with no section navigation is the 48rem column alone at every width. */
|
|
261
|
+
export const WithoutNavigation: Story = { render: () => <SettingsPage withoutNavigation /> };
|
|
262
|
+
|
|
263
|
+
/** className bounds the frame the layout answers to, so a 42rem page in a wide window gets the tab strip. */
|
|
264
|
+
export const Bounded: Story = { render: () => <SettingsPage className="mx-auto max-w-2xl" /> };
|
|
265
|
+
|
|
266
|
+
/** The promoted Field: hint, info tip with an aside action, a live error, and a status. */
|
|
267
|
+
export const Fields: Story = { render: () => <FieldsExample /> };
|