@forwardreach/saas-ui 0.7.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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,41 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.8.0
4
+
5
+ ### Minor Changes
6
+
7
+ - `Switch` themes its thumb per state via a new optional
8
+ `--ssui-switch-thumb-checked` token (defaulting to `--ssui-switch-thumb`, so
9
+ existing consumers are unchanged). A single thumb color only works when the
10
+ checked track and the unchecked track sit on the same side of the lightness
11
+ scale; a product whose primary is near-white got a white thumb on a near-white
12
+ checked track, leaving the control unable to show on versus off.
13
+
14
+ - Add the multi-select and disclosure primitives the settings kit was missing:
15
+
16
+ - `FieldGroup` — the group analogue of `FormField`: a `fieldset`/`legend`
17
+ wrapper with the same label/description/error typography and
18
+ `aria-describedby`/`aria-invalid` wiring. `FormField` can only label a single
19
+ control through `htmlFor`, so a set of controls answering one question (a
20
+ checkbox group, a radio group, a composite input) previously had no correct
21
+ labelling primitive.
22
+ - `Checkbox` — styled native checkbox that participates in `FormField` wiring
23
+ like the other controls.
24
+ - `CheckboxGroup` / `CheckboxGroupItem` — multi-select group whose options share
25
+ one form field name, so the submission arrives as repeated entries under that
26
+ name. The mirror image of `ChoiceCardGroup`, with the same bordered-target
27
+ presentation and support for controlled or uncontrolled selection.
28
+ - `Collapsible` / `CollapsibleTrigger` / `CollapsibleContent` — disclosure region
29
+ for keeping secondary detail (an edit form, an advanced section) out of the way
30
+ until it is asked for. The trigger carries `aria-expanded`/`aria-controls` and
31
+ supports `asChild` so a `Button` can be the trigger; the region stays mounted
32
+ and hidden with the `hidden` attribute, so `aria-controls` always resolves and
33
+ an open form's in-progress state survives a collapse.
34
+
35
+ The component gallery gains a "Form Fields And Groups" section covering these
36
+ alongside the existing `FormField`/`Select`/`Switch`/`ChoiceCardGroup` kit, plus
37
+ a "Collapsible" section.
38
+
3
39
  ## 0.7.0
4
40
 
5
41
  ### Minor Changes
@@ -0,0 +1,51 @@
1
+ import * as React from "react";
2
+ export interface CheckboxProps extends Omit<React.InputHTMLAttributes<HTMLInputElement>, "type"> {
3
+ }
4
+ /**
5
+ * Styled native checkbox. Native on purpose: form participation, indeterminate
6
+ * support and platform semantics come from the browser, and only the box itself
7
+ * needs theming. Participates in `FormField` wiring like the other controls.
8
+ */
9
+ export declare const Checkbox: React.ForwardRefExoticComponent<CheckboxProps & React.RefAttributes<HTMLInputElement>>;
10
+ export interface CheckboxGroupProps extends Omit<React.FieldsetHTMLAttributes<HTMLFieldSetElement>, "onChange" | "defaultValue" | "children"> {
11
+ /** Group label, rendered as the fieldset's legend. */
12
+ legend: React.ReactNode;
13
+ /** Optional supporting copy shown under the legend. */
14
+ description?: React.ReactNode;
15
+ /** Error message; when set the group is marked invalid. */
16
+ error?: React.ReactNode;
17
+ /** Form field name shared by every option in the group. */
18
+ name?: string;
19
+ /** Controlled selection. */
20
+ value?: readonly string[];
21
+ /** Uncontrolled initial selection. */
22
+ defaultValue?: readonly string[];
23
+ onValueChange?: (value: string[]) => void;
24
+ /** Disables every option in the group. */
25
+ disabled?: boolean;
26
+ /** `CheckboxGroupItem`s. */
27
+ children: React.ReactNode;
28
+ }
29
+ /**
30
+ * Multi-select group: a `FieldGroup` whose options share one form field name, so
31
+ * the submission arrives as repeated entries under that name. The mirror image of
32
+ * `ChoiceCardGroup`, which is the single-select form of the same pattern.
33
+ *
34
+ * Selection is tracked in context rather than left to the DOM so the group can be
35
+ * driven as a controlled component, but the underlying inputs stay native — an
36
+ * uncontrolled group still submits correctly with no JavaScript involvement.
37
+ */
38
+ export declare function CheckboxGroup({ legend, description, error, name, value, defaultValue, onValueChange, disabled, className, children, ...props }: CheckboxGroupProps): import("react/jsx-runtime").JSX.Element;
39
+ export interface CheckboxGroupItemProps extends Omit<React.LabelHTMLAttributes<HTMLLabelElement>, "title"> {
40
+ /** Value submitted when this option is checked. */
41
+ value: string;
42
+ /** Option label. */
43
+ label: React.ReactNode;
44
+ disabled?: boolean;
45
+ }
46
+ /**
47
+ * One option inside a `CheckboxGroup`: a bordered label wrapping a native
48
+ * checkbox. Bordered rather than bare so a row of options reads as a set of
49
+ * targets, and so the whole chip is clickable rather than just the box.
50
+ */
51
+ export declare function CheckboxGroupItem({ value, label, disabled, className, ...props }: CheckboxGroupItemProps): import("react/jsx-runtime").JSX.Element;
@@ -0,0 +1,61 @@
1
+ "use client";
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import * as React from "react";
4
+ import { cn } from "../utils/cn.js";
5
+ import { FieldGroup } from "./field-group.js";
6
+ import { useFormFieldProps } from "./form-field.js";
7
+ /**
8
+ * Styled native checkbox. Native on purpose: form participation, indeterminate
9
+ * support and platform semantics come from the browser, and only the box itself
10
+ * needs theming. Participates in `FormField` wiring like the other controls.
11
+ */
12
+ export const Checkbox = React.forwardRef(({ className, ...props }, ref) => (_jsx("input", { ref: ref, type: "checkbox", className: cn("size-4 shrink-0 cursor-pointer rounded-[calc(var(--ssui-radius)/2)] border border-[color:var(--ssui-border)] accent-[color:var(--ssui-primary)] transition-shadow 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", className), ...useFormFieldProps(props) })));
13
+ Checkbox.displayName = "Checkbox";
14
+ const CheckboxGroupContext = React.createContext(null);
15
+ /**
16
+ * Multi-select group: a `FieldGroup` whose options share one form field name, so
17
+ * the submission arrives as repeated entries under that name. The mirror image of
18
+ * `ChoiceCardGroup`, which is the single-select form of the same pattern.
19
+ *
20
+ * Selection is tracked in context rather than left to the DOM so the group can be
21
+ * driven as a controlled component, but the underlying inputs stay native — an
22
+ * uncontrolled group still submits correctly with no JavaScript involvement.
23
+ */
24
+ export function CheckboxGroup({ legend, description, error, name, value, defaultValue, onValueChange, disabled, className, children, ...props }) {
25
+ const generatedName = React.useId();
26
+ const [uncontrolledValue, setUncontrolledValue] = React.useState(defaultValue ?? []);
27
+ const selected = value ?? uncontrolledValue;
28
+ const contextValue = React.useMemo(() => {
29
+ const selectedSet = new Set(selected);
30
+ return {
31
+ name: name ?? generatedName,
32
+ disabled,
33
+ isSelected: (option) => selectedSet.has(option),
34
+ toggle: (option) => {
35
+ const next = selectedSet.has(option)
36
+ ? selected.filter((entry) => entry !== option)
37
+ : [...selected, option];
38
+ if (value === undefined)
39
+ setUncontrolledValue(next);
40
+ onValueChange?.(next);
41
+ },
42
+ };
43
+ }, [disabled, generatedName, name, onValueChange, selected, value]);
44
+ return (_jsx(FieldGroup, { description: description, error: error, legend: legend, className: className, ...props, children: _jsx("div", { className: "flex flex-wrap gap-2 pt-0.5", children: _jsx(CheckboxGroupContext.Provider, { value: contextValue, children: children }) }) }));
45
+ }
46
+ /**
47
+ * One option inside a `CheckboxGroup`: a bordered label wrapping a native
48
+ * checkbox. Bordered rather than bare so a row of options reads as a set of
49
+ * targets, and so the whole chip is clickable rather than just the box.
50
+ */
51
+ export function CheckboxGroupItem({ value, label, disabled, className, ...props }) {
52
+ const group = React.useContext(CheckboxGroupContext);
53
+ if (!group) {
54
+ throw new Error("CheckboxGroupItem must be rendered inside a CheckboxGroup");
55
+ }
56
+ const checked = group.isSelected(value);
57
+ const isDisabled = disabled ?? group.disabled;
58
+ return (_jsxs("label", { className: cn("inline-flex cursor-pointer items-center gap-2 rounded-[var(--ssui-radius)] border px-2.5 py-1.5 text-sm transition-colors has-[:focus-visible]:ring-2 has-[:focus-visible]:ring-[color:var(--ssui-focus-ring)]", checked
59
+ ? "border-[color:var(--ssui-border-strong)] bg-[color:var(--ssui-surface-muted)] text-[color:var(--ssui-text)]"
60
+ : "border-[color:var(--ssui-border)] text-[color:var(--ssui-text-muted)] hover:bg-[color:var(--ssui-overlay-hover)]", isDisabled && "cursor-not-allowed opacity-50", className), ...props, children: [_jsx(Checkbox, { checked: checked, disabled: isDisabled, name: group.name, onChange: () => group.toggle(value), value: value }), label] }));
61
+ }
@@ -0,0 +1,37 @@
1
+ import * as React from "react";
2
+ export interface CollapsibleProps extends Omit<React.HTMLAttributes<HTMLDivElement>, "onToggle"> {
3
+ /** Controlled open state. */
4
+ open?: boolean;
5
+ /** Uncontrolled initial open state. */
6
+ defaultOpen?: boolean;
7
+ onOpenChange?: (open: boolean) => void;
8
+ /** Prevents the trigger from toggling the region. */
9
+ disabled?: boolean;
10
+ }
11
+ /**
12
+ * Disclosure region: a trigger that shows and hides a block of content, used to
13
+ * keep secondary detail — an edit form, an advanced section — out of the way
14
+ * until it is asked for.
15
+ *
16
+ * The content stays mounted and is hidden with the `hidden` attribute rather than
17
+ * unmounted, so `aria-controls` always resolves to a real element and an open
18
+ * form's in-progress state survives a collapse.
19
+ */
20
+ export declare function Collapsible({ open, defaultOpen, onOpenChange, disabled, className, children, ...props }: CollapsibleProps): import("react/jsx-runtime").JSX.Element;
21
+ export interface CollapsibleTriggerProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
22
+ /** Render the consumer's own element (e.g. a `Button`) as the trigger. */
23
+ asChild?: boolean;
24
+ /** Children rendered while the region is open, if it differs from the closed label. */
25
+ openChildren?: React.ReactNode;
26
+ }
27
+ /**
28
+ * Toggles the enclosing `Collapsible`. Always a real `button` (or the consumer's
29
+ * element via `asChild`) carrying `aria-expanded`/`aria-controls`, so the
30
+ * relationship between control and region is announced rather than implied by
31
+ * position.
32
+ */
33
+ export declare const CollapsibleTrigger: React.ForwardRefExoticComponent<CollapsibleTriggerProps & React.RefAttributes<HTMLButtonElement>>;
34
+ export interface CollapsibleContentProps extends React.HTMLAttributes<HTMLDivElement> {
35
+ }
36
+ /** The region shown and hidden by `CollapsibleTrigger`. */
37
+ export declare function CollapsibleContent({ className, children, ...props }: CollapsibleContentProps): import("react/jsx-runtime").JSX.Element;
@@ -0,0 +1,60 @@
1
+ "use client";
2
+ import { jsx as _jsx } from "react/jsx-runtime";
3
+ import { Slot } from "@radix-ui/react-slot";
4
+ import * as React from "react";
5
+ import { cn } from "../utils/cn.js";
6
+ const CollapsibleContext = React.createContext(null);
7
+ function useCollapsibleContext(component) {
8
+ const context = React.useContext(CollapsibleContext);
9
+ if (!context) {
10
+ throw new Error(`${component} must be rendered inside a Collapsible`);
11
+ }
12
+ return context;
13
+ }
14
+ /**
15
+ * Disclosure region: a trigger that shows and hides a block of content, used to
16
+ * keep secondary detail — an edit form, an advanced section — out of the way
17
+ * until it is asked for.
18
+ *
19
+ * The content stays mounted and is hidden with the `hidden` attribute rather than
20
+ * unmounted, so `aria-controls` always resolves to a real element and an open
21
+ * form's in-progress state survives a collapse.
22
+ */
23
+ export function Collapsible({ open, defaultOpen = false, onOpenChange, disabled, className, children, ...props }) {
24
+ const contentId = React.useId();
25
+ const [uncontrolledOpen, setUncontrolledOpen] = React.useState(defaultOpen);
26
+ const isOpen = open ?? uncontrolledOpen;
27
+ const contextValue = React.useMemo(() => ({
28
+ open: isOpen,
29
+ contentId,
30
+ disabled,
31
+ setOpen: (next) => {
32
+ if (open === undefined)
33
+ setUncontrolledOpen(next);
34
+ onOpenChange?.(next);
35
+ },
36
+ }), [contentId, disabled, isOpen, onOpenChange, open]);
37
+ return (_jsx("div", { className: cn(className), "data-state": isOpen ? "open" : "closed", ...props, children: _jsx(CollapsibleContext.Provider, { value: contextValue, children: children }) }));
38
+ }
39
+ /**
40
+ * Toggles the enclosing `Collapsible`. Always a real `button` (or the consumer's
41
+ * element via `asChild`) carrying `aria-expanded`/`aria-controls`, so the
42
+ * relationship between control and region is announced rather than implied by
43
+ * position.
44
+ */
45
+ export const CollapsibleTrigger = React.forwardRef(({ asChild = false, children, openChildren, onClick, ...props }, ref) => {
46
+ const { open, setOpen, contentId, disabled } = useCollapsibleContext("CollapsibleTrigger");
47
+ const Comp = asChild ? Slot : "button";
48
+ return (_jsx(Comp, { ref: ref, "aria-controls": contentId, "aria-expanded": open, "data-state": open ? "open" : "closed", disabled: disabled, onClick: (event) => {
49
+ onClick?.(event);
50
+ if (event.defaultPrevented || disabled)
51
+ return;
52
+ setOpen(!open);
53
+ }, type: asChild ? undefined : "button", ...props, children: open && openChildren !== undefined ? openChildren : children }));
54
+ });
55
+ CollapsibleTrigger.displayName = "CollapsibleTrigger";
56
+ /** The region shown and hidden by `CollapsibleTrigger`. */
57
+ export function CollapsibleContent({ className, children, ...props }) {
58
+ const { open, contentId } = useCollapsibleContext("CollapsibleContent");
59
+ return (_jsx("div", { className: cn(className), "data-state": open ? "open" : "closed", hidden: !open, id: contentId, ...props, children: children }));
60
+ }
@@ -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
+ }
@@ -4,9 +4,12 @@ export * from "./avatar.js";
4
4
  export * from "./badge.js";
5
5
  export * from "./brand-icons.js";
6
6
  export * from "./button.js";
7
+ export * from "./checkbox.js";
7
8
  export * from "./choice-card.js";
9
+ export * from "./collapsible.js";
8
10
  export * from "./combobox.js";
9
11
  export * from "./confirm-dialog.js";
12
+ export * from "./field-group.js";
10
13
  export * from "./form-field.js";
11
14
  export * from "./copy-field.js";
12
15
  export * from "./dialog.js";
@@ -23,6 +26,7 @@ export * from "./overflow-menu.js";
23
26
  export * from "./page-header.js";
24
27
  export * from "./rail-toggle.js";
25
28
  export * from "./request-access.js";
29
+ export * from "./role-menu.js";
26
30
  export * from "./scroll-area.js";
27
31
  export * from "./search-input.js";
28
32
  export * from "./select.js";
@@ -4,9 +4,12 @@ export * from "./avatar.js";
4
4
  export * from "./badge.js";
5
5
  export * from "./brand-icons.js";
6
6
  export * from "./button.js";
7
+ export * from "./checkbox.js";
7
8
  export * from "./choice-card.js";
9
+ export * from "./collapsible.js";
8
10
  export * from "./combobox.js";
9
11
  export * from "./confirm-dialog.js";
12
+ export * from "./field-group.js";
10
13
  export * from "./form-field.js";
11
14
  export * from "./copy-field.js";
12
15
  export * from "./dialog.js";
@@ -23,6 +26,7 @@ export * from "./overflow-menu.js";
23
26
  export * from "./page-header.js";
24
27
  export * from "./rail-toggle.js";
25
28
  export * from "./request-access.js";
29
+ export * from "./role-menu.js";
26
30
  export * from "./scroll-area.js";
27
31
  export * from "./search-input.js";
28
32
  export * from "./select.js";
@@ -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";
@@ -23,6 +23,13 @@ export const Switch = React.forwardRef(({ checked, defaultChecked = false, onChe
23
23
  }
24
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
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 bg-[color:var(--ssui-switch-thumb,#ffffff)] shadow-sm transition-transform", isChecked ? "translate-x-4" : "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] }));
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] }));
27
34
  });
28
35
  Switch.displayName = "Switch";
@@ -23,6 +23,9 @@
23
23
  --ssui-switch-track: var(--ssui-border);
24
24
  --ssui-switch-track-checked: var(--ssui-primary);
25
25
  --ssui-switch-thumb: #ffffff;
26
+ /* Thumb color while checked. Defaults to the unchecked thumb; override when the
27
+ checked track (--ssui-switch-track-checked) is too light for a white thumb. */
28
+ --ssui-switch-thumb-checked: #ffffff;
26
29
  --ssui-radius-sm: 0.25rem;
27
30
  --ssui-radius: 0.375rem;
28
31
  --ssui-radius-lg: 0.5rem;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@forwardreach/saas-ui",
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
4
4
  "description": "Brand-neutral React UI primitives and SaaS app patterns for ForwardReach-owned business applications.",
5
5
  "type": "module",
6
6
  "private": false,