@forwardreach/saas-ui 0.5.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/CHANGELOG.md +90 -0
  2. package/dist/components/account-menu.d.ts +42 -0
  3. package/dist/components/account-menu.js +41 -0
  4. package/dist/components/button.d.ts +1 -1
  5. package/dist/components/checkbox.d.ts +51 -0
  6. package/dist/components/checkbox.js +61 -0
  7. package/dist/components/choice-card.d.ts +33 -0
  8. package/dist/components/choice-card.js +40 -0
  9. package/dist/components/collapsible.d.ts +37 -0
  10. package/dist/components/collapsible.js +60 -0
  11. package/dist/components/combobox.d.ts +37 -0
  12. package/dist/components/combobox.js +103 -0
  13. package/dist/components/field-group.d.ts +22 -0
  14. package/dist/components/field-group.js +22 -0
  15. package/dist/components/form-field.d.ts +48 -0
  16. package/dist/components/form-field.js +51 -0
  17. package/dist/components/index.d.ts +15 -0
  18. package/dist/components/index.js +15 -0
  19. package/dist/components/input.js +2 -1
  20. package/dist/components/rail-toggle.d.ts +19 -0
  21. package/dist/components/rail-toggle.js +17 -0
  22. package/dist/components/role-menu.d.ts +95 -0
  23. package/dist/components/role-menu.js +52 -0
  24. package/dist/components/select.d.ts +9 -0
  25. package/dist/components/select.js +12 -0
  26. package/dist/components/settings-layout.d.ts +10 -0
  27. package/dist/components/settings-layout.js +10 -0
  28. package/dist/components/settings-nav.d.ts +46 -0
  29. package/dist/components/settings-nav.js +21 -0
  30. package/dist/components/switch.d.ts +20 -0
  31. package/dist/components/switch.js +35 -0
  32. package/dist/components/textarea.js +2 -1
  33. package/dist/components/time-zone-field.d.ts +30 -0
  34. package/dist/components/time-zone-field.js +72 -0
  35. package/dist/components/workspace-switcher.d.ts +33 -0
  36. package/dist/components/workspace-switcher.js +34 -0
  37. package/dist/styles/index.css +6 -0
  38. package/dist/utils/index.d.ts +1 -0
  39. package/dist/utils/index.js +1 -0
  40. package/dist/utils/time-zones.d.ts +26 -0
  41. package/dist/utils/time-zones.js +79 -0
  42. package/package.json +1 -1
@@ -0,0 +1,22 @@
1
+ import * as React from "react";
2
+ export interface FieldGroupProps extends Omit<React.FieldsetHTMLAttributes<HTMLFieldSetElement>, "children"> {
3
+ /** Group label, rendered as the fieldset's legend. */
4
+ legend: React.ReactNode;
5
+ /** Optional supporting copy shown under the legend. */
6
+ description?: React.ReactNode;
7
+ /** Error message; when set the group is marked invalid. */
8
+ error?: React.ReactNode;
9
+ /** The controls belonging to this group. */
10
+ children: React.ReactNode;
11
+ }
12
+ /**
13
+ * The group analogue of `FormField`: a `fieldset`/`legend` wrapper with the same
14
+ * label/description/error typography, for a set of controls that answer one
15
+ * question together (checkbox groups, radio groups, composite inputs).
16
+ *
17
+ * `FormField` is wrong for these — its `label`/`htmlFor` pair can only point at a
18
+ * single control, so with several controls the label either names just one of them
19
+ * or names nothing. `legend` labels the whole set, which is what a screen reader
20
+ * needs to announce before reading the options.
21
+ */
22
+ export declare function FieldGroup({ legend, description, error, className, children, ...props }: FieldGroupProps): import("react/jsx-runtime").JSX.Element;
@@ -0,0 +1,22 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import * as React from "react";
3
+ import { cn } from "../utils/cn.js";
4
+ /**
5
+ * The group analogue of `FormField`: a `fieldset`/`legend` wrapper with the same
6
+ * label/description/error typography, for a set of controls that answer one
7
+ * question together (checkbox groups, radio groups, composite inputs).
8
+ *
9
+ * `FormField` is wrong for these — its `label`/`htmlFor` pair can only point at a
10
+ * single control, so with several controls the label either names just one of them
11
+ * or names nothing. `legend` labels the whole set, which is what a screen reader
12
+ * needs to announce before reading the options.
13
+ */
14
+ export function FieldGroup({ legend, description, error, className, children, ...props }) {
15
+ const id = React.useId();
16
+ const descriptionId = `${id}-description`;
17
+ const errorId = `${id}-error`;
18
+ const describedBy = [description ? descriptionId : null, error ? errorId : null]
19
+ .filter(Boolean)
20
+ .join(" ") || undefined;
21
+ return (_jsxs("fieldset", { "aria-describedby": describedBy, "aria-invalid": error ? true : undefined, className: cn("min-w-0 space-y-1.5", className), ...props, children: [_jsx("legend", { className: "text-sm font-medium text-[color:var(--ssui-text)]", children: legend }), description ? (_jsx("p", { className: "text-xs text-[color:var(--ssui-text-muted)]", id: descriptionId, children: description })) : null, children, error ? (_jsx("p", { className: "text-xs text-[color:var(--ssui-destructive)]", id: errorId, children: error })) : null] }));
22
+ }
@@ -0,0 +1,48 @@
1
+ import * as React from "react";
2
+ export interface FormFieldContextValue {
3
+ /** Id the participating control should adopt. */
4
+ id: string;
5
+ /** Space-separated ids of the description/error elements, if any. */
6
+ describedBy?: string;
7
+ /** True when the field is in an error state. */
8
+ invalid: boolean;
9
+ }
10
+ /**
11
+ * Read the enclosing `FormField` context, if any. Package controls use this
12
+ * to adopt the generated `id`/`aria-describedby`/`aria-invalid` wiring;
13
+ * standalone controls (no enclosing field) get `null` and render unchanged.
14
+ */
15
+ export declare function useFormField(): FormFieldContextValue | null;
16
+ /**
17
+ * Merge `FormField` context wiring into a control's own props. The field owns
18
+ * the control's `id` so the label and control stay associated (customize it via
19
+ * `FormField`'s `htmlFor`, not the control). An explicit `aria-describedby` on
20
+ * the control is concatenated with — not replaced by — the field's
21
+ * description/error ids so error announcement is never dropped. An explicit
22
+ * `aria-invalid` still wins.
23
+ */
24
+ export declare function useFormFieldProps<T extends {
25
+ id?: string;
26
+ "aria-describedby"?: string;
27
+ "aria-invalid"?: React.AriaAttributes["aria-invalid"];
28
+ }>(props: T): T;
29
+ export interface FormFieldProps extends Omit<React.HTMLAttributes<HTMLDivElement>, "children"> {
30
+ /** Field label. */
31
+ label: React.ReactNode;
32
+ /** Optional supporting copy shown under the label. */
33
+ description?: React.ReactNode;
34
+ /** Error message; when set the field is marked invalid. */
35
+ error?: React.ReactNode;
36
+ /** Explicit control id; defaults to a generated id. */
37
+ htmlFor?: string;
38
+ /** The control. Package controls pick up the field wiring automatically. */
39
+ children: React.ReactNode;
40
+ }
41
+ /**
42
+ * Label + description + control + error wrapper with standardized typography
43
+ * and accessibility wiring. Generates an id shared between the label's
44
+ * `htmlFor` and the control, and threads `aria-describedby`/`aria-invalid`
45
+ * to package controls (`Input`, `Textarea`, `Select`, `Combobox`) via
46
+ * context. Non-package controls can adopt the same id manually via `htmlFor`.
47
+ */
48
+ export declare function FormField({ label, description, error, htmlFor, className, children, ...props }: FormFieldProps): import("react/jsx-runtime").JSX.Element;
@@ -0,0 +1,51 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import * as React from "react";
3
+ import { cn } from "../utils/cn.js";
4
+ const FormFieldContext = React.createContext(null);
5
+ /**
6
+ * Read the enclosing `FormField` context, if any. Package controls use this
7
+ * to adopt the generated `id`/`aria-describedby`/`aria-invalid` wiring;
8
+ * standalone controls (no enclosing field) get `null` and render unchanged.
9
+ */
10
+ export function useFormField() {
11
+ return React.useContext(FormFieldContext);
12
+ }
13
+ /**
14
+ * Merge `FormField` context wiring into a control's own props. The field owns
15
+ * the control's `id` so the label and control stay associated (customize it via
16
+ * `FormField`'s `htmlFor`, not the control). An explicit `aria-describedby` on
17
+ * the control is concatenated with — not replaced by — the field's
18
+ * description/error ids so error announcement is never dropped. An explicit
19
+ * `aria-invalid` still wins.
20
+ */
21
+ export function useFormFieldProps(props) {
22
+ const field = useFormField();
23
+ if (!field)
24
+ return props;
25
+ const describedBy = [props["aria-describedby"], field.describedBy].filter(Boolean).join(" ") ||
26
+ undefined;
27
+ return {
28
+ ...props,
29
+ id: field.id,
30
+ "aria-describedby": describedBy,
31
+ "aria-invalid": props["aria-invalid"] ?? (field.invalid || undefined),
32
+ };
33
+ }
34
+ /**
35
+ * Label + description + control + error wrapper with standardized typography
36
+ * and accessibility wiring. Generates an id shared between the label's
37
+ * `htmlFor` and the control, and threads `aria-describedby`/`aria-invalid`
38
+ * to package controls (`Input`, `Textarea`, `Select`, `Combobox`) via
39
+ * context. Non-package controls can adopt the same id manually via `htmlFor`.
40
+ */
41
+ export function FormField({ label, description, error, htmlFor, className, children, ...props }) {
42
+ const generatedId = React.useId();
43
+ const id = htmlFor ?? generatedId;
44
+ const descriptionId = `${id}-description`;
45
+ const errorId = `${id}-error`;
46
+ const describedBy = [description ? descriptionId : null, error ? errorId : null]
47
+ .filter(Boolean)
48
+ .join(" ") || undefined;
49
+ const contextValue = React.useMemo(() => ({ id, describedBy, invalid: Boolean(error) }), [id, describedBy, error]);
50
+ return (_jsxs("div", { className: cn("space-y-1.5", className), ...props, children: [_jsx("label", { className: "block text-sm font-medium text-[color:var(--ssui-text)]", htmlFor: id, children: label }), description ? (_jsx("p", { className: "text-xs text-[color:var(--ssui-text-muted)]", id: descriptionId, children: description })) : null, _jsx(FormFieldContext.Provider, { value: contextValue, children: children }), error ? (_jsx("p", { className: "text-xs text-[color:var(--ssui-destructive)]", id: errorId, children: error })) : null] }));
51
+ }
@@ -1,9 +1,16 @@
1
+ export * from "./account-menu.js";
1
2
  export * from "./audit-log.js";
2
3
  export * from "./avatar.js";
3
4
  export * from "./badge.js";
4
5
  export * from "./brand-icons.js";
5
6
  export * from "./button.js";
7
+ export * from "./checkbox.js";
8
+ export * from "./choice-card.js";
9
+ export * from "./collapsible.js";
10
+ export * from "./combobox.js";
6
11
  export * from "./confirm-dialog.js";
12
+ export * from "./field-group.js";
13
+ export * from "./form-field.js";
7
14
  export * from "./copy-field.js";
8
15
  export * from "./dialog.js";
9
16
  export * from "./dropdown-menu.js";
@@ -17,14 +24,22 @@ export * from "./list-shell.js";
17
24
  export * from "./login.js";
18
25
  export * from "./overflow-menu.js";
19
26
  export * from "./page-header.js";
27
+ export * from "./rail-toggle.js";
20
28
  export * from "./request-access.js";
29
+ export * from "./role-menu.js";
21
30
  export * from "./scroll-area.js";
22
31
  export * from "./search-input.js";
32
+ export * from "./select.js";
23
33
  export * from "./separator.js";
34
+ export * from "./settings-layout.js";
35
+ export * from "./settings-nav.js";
24
36
  export * from "./settings-section.js";
25
37
  export * from "./skeleton.js";
26
38
  export * from "./status-pill.js";
39
+ export * from "./switch.js";
27
40
  export * from "./tabs.js";
28
41
  export * from "./textarea.js";
42
+ export * from "./time-zone-field.js";
29
43
  export * from "./toolbar.js";
30
44
  export * from "./tooltip.js";
45
+ export * from "./workspace-switcher.js";
@@ -1,9 +1,16 @@
1
+ export * from "./account-menu.js";
1
2
  export * from "./audit-log.js";
2
3
  export * from "./avatar.js";
3
4
  export * from "./badge.js";
4
5
  export * from "./brand-icons.js";
5
6
  export * from "./button.js";
7
+ export * from "./checkbox.js";
8
+ export * from "./choice-card.js";
9
+ export * from "./collapsible.js";
10
+ export * from "./combobox.js";
6
11
  export * from "./confirm-dialog.js";
12
+ export * from "./field-group.js";
13
+ export * from "./form-field.js";
7
14
  export * from "./copy-field.js";
8
15
  export * from "./dialog.js";
9
16
  export * from "./dropdown-menu.js";
@@ -17,14 +24,22 @@ export * from "./list-shell.js";
17
24
  export * from "./login.js";
18
25
  export * from "./overflow-menu.js";
19
26
  export * from "./page-header.js";
27
+ export * from "./rail-toggle.js";
20
28
  export * from "./request-access.js";
29
+ export * from "./role-menu.js";
21
30
  export * from "./scroll-area.js";
22
31
  export * from "./search-input.js";
32
+ export * from "./select.js";
23
33
  export * from "./separator.js";
34
+ export * from "./settings-layout.js";
35
+ export * from "./settings-nav.js";
24
36
  export * from "./settings-section.js";
25
37
  export * from "./skeleton.js";
26
38
  export * from "./status-pill.js";
39
+ export * from "./switch.js";
27
40
  export * from "./tabs.js";
28
41
  export * from "./textarea.js";
42
+ export * from "./time-zone-field.js";
29
43
  export * from "./toolbar.js";
30
44
  export * from "./tooltip.js";
45
+ export * from "./workspace-switcher.js";
@@ -1,5 +1,6 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
2
  import * as React from "react";
3
3
  import { cn } from "../utils/cn.js";
4
- export const Input = React.forwardRef(({ className, type = "text", ...props }, ref) => (_jsx("input", { ref: ref, type: type, className: cn("flex h-10 w-full rounded-[var(--ssui-radius)] border border-[color:var(--ssui-border)] bg-[color:var(--ssui-surface)] px-3 py-2 text-sm text-[color:var(--ssui-text)] shadow-sm transition-colors placeholder:text-[color:var(--ssui-text-subtle)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[color:var(--ssui-focus-ring)] focus-visible:ring-offset-2 focus-visible:ring-offset-[color:var(--ssui-bg)] disabled:cursor-not-allowed disabled:bg-[color:var(--ssui-surface-muted)] disabled:opacity-70", className), ...props })));
4
+ import { useFormFieldProps } from "./form-field.js";
5
+ export const Input = React.forwardRef(({ className, type = "text", ...props }, ref) => (_jsx("input", { ref: ref, type: type, className: cn("flex h-10 w-full rounded-[var(--ssui-radius)] border border-[color:var(--ssui-border)] bg-[color:var(--ssui-surface)] px-3 py-2 text-sm text-[color:var(--ssui-text)] shadow-sm transition-colors placeholder:text-[color:var(--ssui-text-subtle)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[color:var(--ssui-focus-ring)] focus-visible:ring-offset-2 focus-visible:ring-offset-[color:var(--ssui-bg)] disabled:cursor-not-allowed disabled:bg-[color:var(--ssui-surface-muted)] disabled:opacity-70", className), ...useFormFieldProps(props) })));
5
6
  Input.displayName = "Input";
@@ -0,0 +1,19 @@
1
+ export interface RailToggleProps {
2
+ /** Current rail state; the toggle renders the action that would change it. */
3
+ collapsed: boolean;
4
+ onToggle: () => void;
5
+ /** Accessible label + tooltip when the rail is expanded. Defaults to "Collapse sidebar". */
6
+ collapseLabel?: string;
7
+ /** Accessible label + tooltip when the rail is collapsed. Defaults to "Expand sidebar". */
8
+ expandLabel?: string;
9
+ /** Optional shortcut rendered muted next to the tooltip label (e.g. "⌘\\"). */
10
+ shortcutHint?: string;
11
+ className?: string;
12
+ }
13
+ /**
14
+ * Icon-only ghost toggle for collapsing/expanding the app-shell rail, meant to
15
+ * sit in the rail header beside the `WorkspaceSwitcher`. Presentational on
16
+ * purpose: collapse state, persistence, and any keyboard shortcut binding are
17
+ * consumer-owned — this only reflects state and fires `onToggle`.
18
+ */
19
+ export declare function RailToggle({ collapsed, onToggle, collapseLabel, expandLabel, shortcutHint, className, }: RailToggleProps): import("react/jsx-runtime").JSX.Element;
@@ -0,0 +1,17 @@
1
+ "use client";
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { PanelLeftClose, PanelLeftOpen } from "lucide-react";
4
+ import * as React from "react";
5
+ import { cn } from "../utils/cn.js";
6
+ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "./tooltip.js";
7
+ /**
8
+ * Icon-only ghost toggle for collapsing/expanding the app-shell rail, meant to
9
+ * sit in the rail header beside the `WorkspaceSwitcher`. Presentational on
10
+ * purpose: collapse state, persistence, and any keyboard shortcut binding are
11
+ * consumer-owned — this only reflects state and fires `onToggle`.
12
+ */
13
+ export function RailToggle({ collapsed, onToggle, collapseLabel = "Collapse sidebar", expandLabel = "Expand sidebar", shortcutHint, className, }) {
14
+ const label = collapsed ? expandLabel : collapseLabel;
15
+ const Icon = collapsed ? PanelLeftOpen : PanelLeftClose;
16
+ return (_jsx(TooltipProvider, { delayDuration: 300, children: _jsxs(Tooltip, { children: [_jsx(TooltipTrigger, { asChild: true, children: _jsx("button", { type: "button", "aria-expanded": !collapsed, "aria-label": label, onClick: onToggle, className: cn("flex size-8 shrink-0 items-center justify-center rounded-[var(--ssui-radius-sm)] text-[color:var(--ssui-text-subtle)] outline-none transition-colors hover:bg-[color:var(--ssui-overlay-hover)] hover:text-[color:var(--ssui-text-muted)] focus-visible:ring-2 focus-visible:ring-[color:var(--ssui-focus-ring)]", className), children: _jsx(Icon, { className: "size-4" }) }) }), _jsxs(TooltipContent, { side: collapsed ? "right" : "bottom", children: [label, shortcutHint ? (_jsx("span", { className: "ms-1.5 text-[color:var(--ssui-text-subtle)]", children: shortcutHint })) : null] })] }) }));
17
+ }
@@ -0,0 +1,95 @@
1
+ import * as React from "react";
2
+ export interface RoleMenuOption {
3
+ value: string;
4
+ label: string;
5
+ /** One line on what the role grants. Shown in the popup, never on the trigger. */
6
+ description: string;
7
+ /** Optional leading icon, sized by the caller (16px reads correctly here). */
8
+ icon?: React.ReactNode;
9
+ /**
10
+ * Withholds this one option while leaving the rest selectable, and says why —
11
+ * a workspace's last admin, a plan the account cannot downgrade to. The reason
12
+ * is shown in place of the description.
13
+ *
14
+ * Presence is what withholds: there is no separate `disabled` flag, because an
15
+ * option withheld without an explanation is a dead row that answers none of
16
+ * the question the reader now has. Pass `undefined` to leave it selectable, so
17
+ * a computed case stays one expression:
18
+ * `unavailableReason: isLastAdmin ? "…" : undefined`.
19
+ */
20
+ unavailableReason?: string;
21
+ }
22
+ export interface RoleMenuDestructiveItem {
23
+ label: string;
24
+ onSelect: () => void | Promise<void>;
25
+ icon?: React.ReactNode;
26
+ disabled?: boolean;
27
+ }
28
+ export interface RoleMenuProps {
29
+ options: ReadonlyArray<RoleMenuOption>;
30
+ /** Selected option value. The component never changes this itself. */
31
+ value: string;
32
+ /** Called with the chosen value; never fired for the option already selected. */
33
+ onValueChange: (value: string) => void | Promise<void>;
34
+ /** Heading above the options, e.g. "Select role". Omitted when absent. */
35
+ heading?: React.ReactNode;
36
+ /** Rendered below the options, separated — typically a documentation link. */
37
+ footer?: React.ReactNode;
38
+ /** Rendered last, separated, styled as destructive. Typically "Remove from workspace". */
39
+ destructiveItem?: RoleMenuDestructiveItem;
40
+ /**
41
+ * Submits the selected value with the surrounding form through a hidden input.
42
+ * Omit it in a list that applies on selection: a Radix menu is not a form
43
+ * control, and an unconditional hidden input would litter every such row.
44
+ */
45
+ name?: string;
46
+ /** Disables the whole control. To withhold one option, use its `unavailableReason`. */
47
+ disabled?: boolean;
48
+ /**
49
+ * Which edge of the trigger the popup aligns to. Defaults to `"start"`; a
50
+ * right-aligned trigger (a table's last column) wants `"end"` so the popup
51
+ * opens inward rather than relying on collision detection to rescue it.
52
+ */
53
+ align?: "start" | "center" | "end";
54
+ /** Trigger id. Supplied by an enclosing `FormField` when there is one. */
55
+ id?: string;
56
+ /**
57
+ * Names the trigger's purpose, e.g. `"Role for Ada Lovelace"` in a member
58
+ * list. The selected label is appended to whatever is passed — the accessible
59
+ * name has to contain the trigger's visible text for voice control (WCAG
60
+ * 2.5.3), and a row's own name has to say which row it belongs to. Defaults to
61
+ * `"Change role (current: …)"`, and is dropped entirely inside a `FormField`,
62
+ * whose label already names the control.
63
+ */
64
+ "aria-label"?: string;
65
+ "aria-describedby"?: string;
66
+ "aria-invalid"?: React.AriaAttributes["aria-invalid"];
67
+ /** Open the menu by default (uncontrolled). Useful for tests and controlled-open consumers. */
68
+ defaultOpen?: boolean;
69
+ /** Extra classes for the trigger. */
70
+ className?: string;
71
+ }
72
+ /**
73
+ * Single choice from a small closed set where each option needs a sentence.
74
+ *
75
+ * The third answer to "pick one of a few" in this package, and the one to reach
76
+ * for when the options need explaining: `Select` is a native `<select>` whose
77
+ * options may hold only text, and `Combobox` is a filterable input for lists long
78
+ * enough to search. A role set is neither — two or three options, each carrying a
79
+ * meaning the reader cannot infer from its name.
80
+ *
81
+ * Options are radio items rather than plain menu items, so assistive technology
82
+ * announces the option set and which member of it is current instead of leaving
83
+ * the checkmark as a purely visual cue. The check is drawn here rather than by
84
+ * Radix's `ItemIndicator` because it sits opposite a two-line label, where the
85
+ * indicator's leading placement fights the description.
86
+ *
87
+ * The component owns no invariant. Which option is unavailable and why is the
88
+ * consumer's to say, and a consumer that renders a stale `unavailableReason` has
89
+ * a display bug rather than a hole — the server it submits to is expected to
90
+ * refuse the change on its own.
91
+ */
92
+ export declare function RoleMenu({ options, value, onValueChange, heading, footer, destructiveItem, name, disabled, align, defaultOpen, className, ...ariaProps }: RoleMenuProps): import("react/jsx-runtime").JSX.Element;
93
+ export declare namespace RoleMenu {
94
+ var displayName: string;
95
+ }
@@ -0,0 +1,52 @@
1
+ "use client";
2
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
3
+ import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
4
+ import { Check, ChevronDown } from "lucide-react";
5
+ import * as React from "react";
6
+ import { cn } from "../utils/cn.js";
7
+ import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuRadioGroup, DropdownMenuSeparator, DropdownMenuTrigger, } from "./dropdown-menu.js";
8
+ import { useFormField, useFormFieldProps } from "./form-field.js";
9
+ /**
10
+ * Single choice from a small closed set where each option needs a sentence.
11
+ *
12
+ * The third answer to "pick one of a few" in this package, and the one to reach
13
+ * for when the options need explaining: `Select` is a native `<select>` whose
14
+ * options may hold only text, and `Combobox` is a filterable input for lists long
15
+ * enough to search. A role set is neither — two or three options, each carrying a
16
+ * meaning the reader cannot infer from its name.
17
+ *
18
+ * Options are radio items rather than plain menu items, so assistive technology
19
+ * announces the option set and which member of it is current instead of leaving
20
+ * the checkmark as a purely visual cue. The check is drawn here rather than by
21
+ * Radix's `ItemIndicator` because it sits opposite a two-line label, where the
22
+ * indicator's leading placement fights the description.
23
+ *
24
+ * The component owns no invariant. Which option is unavailable and why is the
25
+ * consumer's to say, and a consumer that renders a stale `unavailableReason` has
26
+ * a display bug rather than a hole — the server it submits to is expected to
27
+ * refuse the change on its own.
28
+ */
29
+ export function RoleMenu({ options, value, onValueChange, heading, footer, destructiveItem, name, disabled, align = "start", defaultOpen, className, ...ariaProps }) {
30
+ const selected = options.find((option) => option.value === value);
31
+ const selectedLabel = selected?.label ?? value;
32
+ const headingId = React.useId();
33
+ // An enclosing `FormField` owns the trigger's id and error wiring, the same
34
+ // way it owns `Input`'s and `Combobox`'s — without this the field's label
35
+ // points at an id no element carries and its error is never announced.
36
+ const field = useFormField();
37
+ const { "aria-label": ariaLabel, ...fieldProps } = useFormFieldProps(ariaProps);
38
+ // A row of ten triggers all named "Member" says nothing about what they do or
39
+ // whose role they change, so the name carries purpose and current value —
40
+ // `WorkspaceSwitcher`'s reasoning. A `FormField`'s visible label does that job
41
+ // better, and an `aria-label` would override it, so inside one there is none.
42
+ const triggerLabel = ariaLabel
43
+ ? `${ariaLabel} (current: ${selectedLabel})`
44
+ : field
45
+ ? undefined
46
+ : `Change role (current: ${selectedLabel})`;
47
+ return (_jsxs(_Fragment, { children: [name ? _jsx("input", { disabled: disabled, name: name, type: "hidden", value: value }) : null, _jsxs(DropdownMenu, { defaultOpen: defaultOpen, children: [_jsx(DropdownMenuTrigger, { asChild: true, children: _jsxs("button", { ...fieldProps, "aria-label": triggerLabel, className: cn("inline-flex h-8 items-center gap-1.5 rounded-[var(--ssui-radius-sm)] px-2 text-sm text-[color:var(--ssui-text)] transition-colors", "hover:bg-[color:var(--ssui-overlay-hover)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[color:var(--ssui-focus-ring)] focus-visible:ring-offset-2 focus-visible:ring-offset-[color:var(--ssui-bg)]", "disabled:pointer-events-none disabled:opacity-70", className), disabled: disabled, type: "button", children: [selectedLabel, _jsx(ChevronDown, { "aria-hidden": "true", className: "size-4 text-[color:var(--ssui-text-subtle)]" })] }) }), _jsxs(DropdownMenuContent, { align: align, className: "w-72 p-1.5", collisionPadding: 8, children: [heading ? (_jsx(DropdownMenuLabel, { className: "px-2 py-1", id: headingId, children: heading })) : null, _jsx(DropdownMenuRadioGroup, { "aria-labelledby": heading ? headingId : undefined, onValueChange: (next) => {
48
+ if (next !== value)
49
+ void onValueChange(next);
50
+ }, value: value, children: options.map((option) => (_jsxs(DropdownMenuPrimitive.RadioItem, { className: cn("relative flex cursor-default select-none items-start gap-2.5 rounded-[var(--ssui-radius-sm)] px-2 py-2 text-sm outline-none transition-colors", "focus:bg-[color:var(--ssui-overlay-hover)] data-[highlighted]:bg-[color:var(--ssui-overlay-hover)]", "data-[disabled]:pointer-events-none data-[disabled]:opacity-50"), disabled: option.unavailableReason !== undefined, value: option.value, children: [option.icon ? (_jsx("span", { "aria-hidden": "true", className: "mt-0.5 shrink-0 text-[color:var(--ssui-text-subtle)]", children: option.icon })) : null, _jsxs("span", { className: "min-w-0 flex-1", children: [_jsx("span", { className: "block font-medium", children: option.label }), _jsx("span", { className: "mt-0.5 block text-xs leading-snug text-[color:var(--ssui-text-muted)]", children: option.unavailableReason ?? option.description })] }), option.value === value ? (_jsx(Check, { "aria-hidden": "true", className: "mt-0.5 size-4 shrink-0 text-[color:var(--ssui-text)]" })) : null] }, option.value))) }), destructiveItem ? (_jsxs(_Fragment, { children: [_jsx(DropdownMenuSeparator, {}), _jsxs(DropdownMenuItem, { className: "gap-2.5 px-2 py-2 text-[color:var(--ssui-destructive)] focus:text-[color:var(--ssui-destructive)] data-[highlighted]:text-[color:var(--ssui-destructive)]", disabled: destructiveItem.disabled, onSelect: () => void destructiveItem.onSelect(), children: [destructiveItem.icon ? (_jsx("span", { "aria-hidden": "true", className: "shrink-0", children: destructiveItem.icon })) : null, destructiveItem.label] })] })) : null, footer ? (_jsxs(_Fragment, { children: [_jsx(DropdownMenuSeparator, {}), _jsx("div", { className: "px-2 py-1.5 text-xs text-[color:var(--ssui-text-muted)]", children: footer })] })) : null] })] })] }));
51
+ }
52
+ RoleMenu.displayName = "RoleMenu";
@@ -0,0 +1,9 @@
1
+ import * as React from "react";
2
+ export interface SelectProps extends React.SelectHTMLAttributes<HTMLSelectElement> {
3
+ }
4
+ /**
5
+ * Styled native `<select>` matching the shared `Input` look, with a trailing
6
+ * chevron. Native on purpose: correct mobile behavior and form participation
7
+ * with zero positioning code. Participates in `FormField` wiring.
8
+ */
9
+ export declare const Select: React.ForwardRefExoticComponent<SelectProps & React.RefAttributes<HTMLSelectElement>>;
@@ -0,0 +1,12 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { ChevronDown } from "lucide-react";
3
+ import * as React from "react";
4
+ import { cn } from "../utils/cn.js";
5
+ import { useFormFieldProps } from "./form-field.js";
6
+ /**
7
+ * Styled native `<select>` matching the shared `Input` look, with a trailing
8
+ * chevron. Native on purpose: correct mobile behavior and form participation
9
+ * with zero positioning code. Participates in `FormField` wiring.
10
+ */
11
+ export const Select = React.forwardRef(({ className, children, ...props }, ref) => (_jsxs("div", { className: cn("relative", className), children: [_jsx("select", { ref: ref, className: "flex h-10 w-full cursor-pointer appearance-none rounded-[var(--ssui-radius)] border border-[color:var(--ssui-border)] bg-[color:var(--ssui-surface)] py-2 pl-3 pr-9 text-sm text-[color:var(--ssui-text)] shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[color:var(--ssui-focus-ring)] focus-visible:ring-offset-2 focus-visible:ring-offset-[color:var(--ssui-bg)] disabled:cursor-not-allowed disabled:bg-[color:var(--ssui-surface-muted)] disabled:opacity-70", ...useFormFieldProps(props), children: children }), _jsx(ChevronDown, { "aria-hidden": "true", className: "pointer-events-none absolute right-3 top-1/2 size-4 -translate-y-1/2 text-[color:var(--ssui-text-subtle)]" })] })));
12
+ Select.displayName = "Select";
@@ -0,0 +1,10 @@
1
+ import * as React from "react";
2
+ export interface SettingsLayoutProps extends React.HTMLAttributes<HTMLDivElement> {
3
+ /** Settings navigation, typically a `SettingsNav`. */
4
+ nav: React.ReactNode;
5
+ }
6
+ /**
7
+ * Centered, max-width settings shell: nav as a left sidebar beside the
8
+ * content on `md+`, stacked above full-width content on small screens.
9
+ */
10
+ export declare function SettingsLayout({ nav, className, children, ...props }: SettingsLayoutProps): import("react/jsx-runtime").JSX.Element;
@@ -0,0 +1,10 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import * as React from "react";
3
+ import { cn } from "../utils/cn.js";
4
+ /**
5
+ * Centered, max-width settings shell: nav as a left sidebar beside the
6
+ * content on `md+`, stacked above full-width content on small screens.
7
+ */
8
+ export function SettingsLayout({ nav, className, children, ...props }) {
9
+ return (_jsxs("div", { className: cn("mx-auto flex max-w-6xl flex-col gap-6 px-4 py-5 md:flex-row md:px-8 md:py-8", className), ...props, children: [nav, _jsx("div", { className: "min-w-0 flex-1", children: children })] }));
10
+ }
@@ -0,0 +1,46 @@
1
+ import * as React from "react";
2
+ export interface SettingsNavItem {
3
+ href: string;
4
+ label: React.ReactNode;
5
+ /** Optional leading icon (e.g. a lucide icon element). */
6
+ icon?: React.ReactNode;
7
+ }
8
+ export interface SettingsNavGroup {
9
+ label: React.ReactNode;
10
+ items: SettingsNavItem[];
11
+ }
12
+ /**
13
+ * Minimal contract for an injected link component: it must accept `href`,
14
+ * `className`, `aria-current`, and children. Next.js `Link` satisfies this.
15
+ */
16
+ export type SettingsNavLinkComponent = React.ElementType<{
17
+ href: string;
18
+ className?: string;
19
+ "aria-current"?: "page";
20
+ children?: React.ReactNode;
21
+ }>;
22
+ export interface SettingsNavProps extends Omit<React.HTMLAttributes<HTMLElement>, "children"> {
23
+ /** Navigation groups, each a labeled set of items. */
24
+ groups: SettingsNavGroup[];
25
+ /** Href of the active item. Ignored when `isActive` is provided. */
26
+ activeHref?: string;
27
+ /** Predicate for active state; use for nested-route or alias matching. */
28
+ isActive?: (href: string) => boolean;
29
+ /**
30
+ * Component used to render item links (e.g. Next.js `Link`).
31
+ * Defaults to a plain anchor.
32
+ */
33
+ linkComponent?: SettingsNavLinkComponent;
34
+ /** Heading shown above the groups. Defaults to "Settings". */
35
+ heading?: React.ReactNode;
36
+ /** Accessible label for the nav landmark. Defaults to "Settings". */
37
+ "aria-label"?: string;
38
+ }
39
+ /**
40
+ * Grouped settings navigation: section-labeled link groups with icons and
41
+ * active highlighting. Renders as a sidebar column on `md+` and collapses to
42
+ * a horizontally scrollable row on small screens (pair with `SettingsLayout`).
43
+ * Router-agnostic: active state comes from `activeHref`/`isActive` and links
44
+ * render through `linkComponent`.
45
+ */
46
+ export declare function SettingsNav({ groups, activeHref, isActive, linkComponent, heading, className, "aria-label": ariaLabel, ...props }: SettingsNavProps): import("react/jsx-runtime").JSX.Element;
@@ -0,0 +1,21 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { SlidersHorizontal } from "lucide-react";
3
+ import * as React from "react";
4
+ import { cn } from "../utils/cn.js";
5
+ /**
6
+ * Grouped settings navigation: section-labeled link groups with icons and
7
+ * active highlighting. Renders as a sidebar column on `md+` and collapses to
8
+ * a horizontally scrollable row on small screens (pair with `SettingsLayout`).
9
+ * Router-agnostic: active state comes from `activeHref`/`isActive` and links
10
+ * render through `linkComponent`.
11
+ */
12
+ export function SettingsNav({ groups, activeHref, isActive, linkComponent, heading = "Settings", className, "aria-label": ariaLabel = "Settings", ...props }) {
13
+ const LinkComponent = linkComponent ?? "a";
14
+ const activeFor = isActive ?? ((href) => href === activeHref);
15
+ return (_jsxs("aside", { className: cn("space-y-3 md:w-56 md:shrink-0", className), ...props, children: [heading ? (_jsxs("div", { className: "flex items-center gap-2 text-xs font-semibold uppercase text-[color:var(--ssui-text-subtle)]", children: [_jsx(SlidersHorizontal, { "aria-hidden": "true", className: "size-3.5" }), heading] })) : null, _jsx("nav", { "aria-label": ariaLabel, className: "flex gap-4 overflow-x-auto pb-1 md:flex-col md:gap-5 md:overflow-visible md:pb-0", children: groups.map((group, groupIndex) => (_jsxs("div", { className: "min-w-max md:min-w-0", children: [_jsx("div", { className: "mb-1.5 px-2.5 text-[0.68rem] font-semibold uppercase text-[color:var(--ssui-text-subtle)]", children: group.label }), _jsx("div", { className: "flex gap-1 md:flex-col", children: group.items.map((item) => {
16
+ const active = activeFor(item.href);
17
+ return (_jsxs(LinkComponent, { href: item.href, "aria-current": active ? "page" : undefined, className: cn("flex shrink-0 items-center gap-2 rounded-[var(--ssui-radius)] px-2.5 py-1.5 text-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[color:var(--ssui-focus-ring)]", active
18
+ ? "bg-[color:var(--ssui-surface-muted)] text-[color:var(--ssui-text)]"
19
+ : "text-[color:var(--ssui-text-muted)] hover:bg-[color:var(--ssui-overlay-hover)] hover:text-[color:var(--ssui-text)]"), children: [item.icon ? (_jsx("span", { "aria-hidden": "true", className: "shrink-0 [&_svg]:size-4", children: item.icon })) : null, item.label] }, item.href));
20
+ }) })] }, groupIndex))) })] }));
21
+ }
@@ -0,0 +1,20 @@
1
+ import * as React from "react";
2
+ export interface SwitchProps extends Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, "onChange" | "value" | "type" | "role" | "aria-checked"> {
3
+ /** Controlled checked state. */
4
+ checked?: boolean;
5
+ /** Uncontrolled initial state. */
6
+ defaultChecked?: boolean;
7
+ onCheckedChange?: (checked: boolean) => void;
8
+ /** When set, submits the value under this name via a hidden checkbox. */
9
+ name?: string;
10
+ /** Submitted value when checked. Defaults to "on". */
11
+ value?: string;
12
+ }
13
+ /**
14
+ * Accessible boolean toggle: a `role="switch"` button with a track and thumb.
15
+ * Space/Enter toggle (native button activation), `aria-checked` reflects
16
+ * state. Track colors come from `--ssui-switch-track` /
17
+ * `--ssui-switch-track-checked` tokens with neutral fallbacks. Optional form
18
+ * participation via a hidden checkbox when `name` is provided.
19
+ */
20
+ export declare const Switch: React.ForwardRefExoticComponent<SwitchProps & React.RefAttributes<HTMLButtonElement>>;
@@ -0,0 +1,35 @@
1
+ "use client";
2
+ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import * as React from "react";
4
+ import { cn } from "../utils/cn.js";
5
+ import { useFormFieldProps } from "./form-field.js";
6
+ /**
7
+ * Accessible boolean toggle: a `role="switch"` button with a track and thumb.
8
+ * Space/Enter toggle (native button activation), `aria-checked` reflects
9
+ * state. Track colors come from `--ssui-switch-track` /
10
+ * `--ssui-switch-track-checked` tokens with neutral fallbacks. Optional form
11
+ * participation via a hidden checkbox when `name` is provided.
12
+ */
13
+ export const Switch = React.forwardRef(({ checked, defaultChecked = false, onCheckedChange, name, value = "on", className, disabled, onClick, ...props }, ref) => {
14
+ const [uncontrolledChecked, setUncontrolledChecked] = React.useState(defaultChecked);
15
+ const isChecked = checked ?? uncontrolledChecked;
16
+ function handleClick(event) {
17
+ onClick?.(event);
18
+ if (event.defaultPrevented)
19
+ return;
20
+ if (checked === undefined)
21
+ setUncontrolledChecked(!isChecked);
22
+ onCheckedChange?.(!isChecked);
23
+ }
24
+ return (_jsxs(_Fragment, { children: [_jsx("button", { ref: ref, type: "button", role: "switch", "aria-checked": isChecked, "data-state": isChecked ? "checked" : "unchecked", disabled: disabled, onClick: handleClick, className: cn("inline-flex h-6 w-10 shrink-0 items-center rounded-full border border-transparent p-0.5 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[color:var(--ssui-focus-ring)] focus-visible:ring-offset-2 focus-visible:ring-offset-[color:var(--ssui-bg)] disabled:cursor-not-allowed disabled:opacity-50", isChecked
25
+ ? "bg-[color:var(--ssui-switch-track-checked,var(--ssui-primary))]"
26
+ : "bg-[color:var(--ssui-switch-track,var(--ssui-border))]", className), ...useFormFieldProps(props), children: _jsx("span", { "aria-hidden": "true", className: cn("block size-5 rounded-full shadow-sm transition-transform",
27
+ // The thumb is themed per state. A single thumb color only works when the
28
+ // checked track (the primary) and the unchecked track sit on the same side
29
+ // of the lightness scale; a product whose primary is near-white gets a white
30
+ // thumb on a near-white track and the control stops reading as on or off.
31
+ isChecked
32
+ ? "bg-[color:var(--ssui-switch-thumb-checked,var(--ssui-switch-thumb,#ffffff))] translate-x-4"
33
+ : "bg-[color:var(--ssui-switch-thumb,#ffffff)] translate-x-0") }) }), name ? (_jsx("input", { type: "checkbox", "aria-hidden": "true", tabIndex: -1, className: "sr-only", checked: isChecked, disabled: disabled, name: name, value: value, readOnly: true })) : null] }));
34
+ });
35
+ Switch.displayName = "Switch";
@@ -1,5 +1,6 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
2
  import * as React from "react";
3
3
  import { cn } from "../utils/cn.js";
4
- export const Textarea = React.forwardRef(({ className, rows = 4, ...props }, ref) => (_jsx("textarea", { ref: ref, rows: rows, className: cn("flex min-h-24 w-full resize-y rounded-[var(--ssui-radius)] border border-[color:var(--ssui-border)] bg-[color:var(--ssui-surface)] px-3 py-2 text-sm text-[color:var(--ssui-text)] shadow-sm transition-colors placeholder:text-[color:var(--ssui-text-subtle)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[color:var(--ssui-focus-ring)] focus-visible:ring-offset-2 focus-visible:ring-offset-[color:var(--ssui-bg)] disabled:cursor-not-allowed disabled:bg-[color:var(--ssui-surface-muted)] disabled:opacity-70", className), ...props })));
4
+ import { useFormFieldProps } from "./form-field.js";
5
+ export const Textarea = React.forwardRef(({ className, rows = 4, ...props }, ref) => (_jsx("textarea", { ref: ref, rows: rows, className: cn("flex min-h-24 w-full resize-y rounded-[var(--ssui-radius)] border border-[color:var(--ssui-border)] bg-[color:var(--ssui-surface)] px-3 py-2 text-sm text-[color:var(--ssui-text)] shadow-sm transition-colors placeholder:text-[color:var(--ssui-text-subtle)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[color:var(--ssui-focus-ring)] focus-visible:ring-offset-2 focus-visible:ring-offset-[color:var(--ssui-bg)] disabled:cursor-not-allowed disabled:bg-[color:var(--ssui-surface-muted)] disabled:opacity-70", className), ...useFormFieldProps(props) })));
5
6
  Textarea.displayName = "Textarea";