@forwardreach/saas-ui 0.5.0 → 0.7.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 (34) hide show
  1. package/CHANGELOG.md +54 -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/choice-card.d.ts +33 -0
  6. package/dist/components/choice-card.js +40 -0
  7. package/dist/components/combobox.d.ts +37 -0
  8. package/dist/components/combobox.js +103 -0
  9. package/dist/components/form-field.d.ts +48 -0
  10. package/dist/components/form-field.js +51 -0
  11. package/dist/components/index.d.ts +11 -0
  12. package/dist/components/index.js +11 -0
  13. package/dist/components/input.js +2 -1
  14. package/dist/components/rail-toggle.d.ts +19 -0
  15. package/dist/components/rail-toggle.js +17 -0
  16. package/dist/components/select.d.ts +9 -0
  17. package/dist/components/select.js +12 -0
  18. package/dist/components/settings-layout.d.ts +10 -0
  19. package/dist/components/settings-layout.js +10 -0
  20. package/dist/components/settings-nav.d.ts +46 -0
  21. package/dist/components/settings-nav.js +21 -0
  22. package/dist/components/switch.d.ts +20 -0
  23. package/dist/components/switch.js +28 -0
  24. package/dist/components/textarea.js +2 -1
  25. package/dist/components/time-zone-field.d.ts +30 -0
  26. package/dist/components/time-zone-field.js +72 -0
  27. package/dist/components/workspace-switcher.d.ts +33 -0
  28. package/dist/components/workspace-switcher.js +34 -0
  29. package/dist/styles/index.css +3 -0
  30. package/dist/utils/index.d.ts +1 -0
  31. package/dist/utils/index.js +1 -0
  32. package/dist/utils/time-zones.d.ts +26 -0
  33. package/dist/utils/time-zones.js +79 -0
  34. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -1,5 +1,59 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.7.0
4
+
5
+ ### Minor Changes
6
+
7
+ - Add the settings shell and form primitive kit:
8
+
9
+ - `SettingsLayout` / `SettingsNav` — centered max-width settings shell with a
10
+ grouped, icon-capable sidebar nav (horizontal-scroll row on small screens).
11
+ Router-agnostic: active state comes from `activeHref` or an `isActive`
12
+ predicate and links render through an injectable `linkComponent`.
13
+ - `FormField` — label/description/control/error wrapper that generates and
14
+ threads `id`/`aria-describedby`/`aria-invalid` to package controls via
15
+ context; `Input`, `Textarea`, `Select`, `Switch`, and `Combobox` participate.
16
+ - `Select` — native select styled to match `Input`, with chevron.
17
+ - `Switch` — `role="switch"` toggle themed by new optional
18
+ `--ssui-switch-track` / `--ssui-switch-track-checked` / `--ssui-switch-thumb`
19
+ tokens (neutral fallbacks); optional form participation via `name`.
20
+ - `ChoiceCardGroup` / `ChoiceCard` — radio group rendered as selectable
21
+ bordered cards over native radios.
22
+ - `Combobox` — filterable single-select with WAI-ARIA combobox semantics,
23
+ wrap-around active-option keyboard navigation, and Enter-commit of the
24
+ highlighted option.
25
+ - `TimeZoneField` — auto/fixed time-zone composite (detected zone shown in
26
+ the auto card, zone combobox with offset-aware search in fixed mode) that
27
+ submits `timeZoneMode`/`timeZone`/`browserTimeZone` hidden inputs.
28
+ - Time-zone utilities (`timeZoneOptions`, `formatTimeZoneOffset`,
29
+ `detectBrowserTimeZone`, `COMMON_TIME_ZONES`) exported for server-side reuse.
30
+
31
+ ## 0.6.0
32
+
33
+ ### Minor Changes
34
+
35
+ - Add `RailToggle` (icon-only rail collapse/expand toggle with tooltip + shortcut hint, presentational) and polish `WorkspaceSwitcher`/`AccountMenu` popup presentation: 10px side offset, panel min-width tied to the trigger width, and trigger chevrons that rotate while the menu is open.
36
+
37
+ - Add `AccountMenu` and `WorkspaceSwitcher` — the shared app-shell menu pair.
38
+
39
+ `AccountMenu` is the bottom-left identity menu: a full-width account card
40
+ trigger (avatar, name, email, chevron-up; avatar-only with a name tooltip when
41
+ `collapsed`) opening a Radix menu with a fixed anatomy — identity header,
42
+ optional theme selector row, consumer-provided items (`AccountMenuItem`), then
43
+ a separated Sign out. The theme row is a segmented `menuitemradio` control
44
+ (Sun/Moon/Monitor) that supports both two-option (light/dark) and three-option
45
+ (light/dark/system) configurations and keeps the menu open on selection.
46
+
47
+ `WorkspaceSwitcher` is the top-left workspace context menu: a brand badge +
48
+ active workspace name + chevron-down trigger (badge-only with a tooltip when
49
+ `collapsed`) opening a list of workspaces with the active row checked and not
50
+ re-selectable, plus an optional separated "New workspace" action. Selection is
51
+ callback-based (`onSelect(id)`).
52
+
53
+ Both components support the collapsed icon-rail state (menus open to the
54
+ right) and wrap their tooltips in a local `TooltipProvider`, so consumers need
55
+ no extra setup.
56
+
3
57
  ## 0.5.0
4
58
 
5
59
  ### Minor Changes
@@ -0,0 +1,42 @@
1
+ import * as React from "react";
2
+ export type AccountMenuThemeValue = "light" | "dark" | "system";
3
+ export interface AccountMenuTheme {
4
+ /** Currently active theme. */
5
+ value: AccountMenuThemeValue;
6
+ /** Which options to offer. Defaults to all three (light/dark/system). */
7
+ options?: ReadonlyArray<AccountMenuThemeValue>;
8
+ onChange: (value: AccountMenuThemeValue) => void;
9
+ }
10
+ export interface AccountMenuProps {
11
+ name: string;
12
+ email?: string;
13
+ /** Avatar image URL; initials fall back from `name`. */
14
+ image?: string | null;
15
+ /** Icon-only rail state: avatar-only trigger with a name tooltip, menu opens right. */
16
+ collapsed?: boolean;
17
+ /** Omit to hide the theme row entirely. */
18
+ theme?: AccountMenuTheme;
19
+ /** Label for the theme row. Defaults to "Theme". */
20
+ themeLabel?: string;
21
+ /**
22
+ * Consumer menu entries (e.g. a Settings link), rendered between the theme
23
+ * row and the separated Sign out item. Use `AccountMenuItem`s.
24
+ */
25
+ children?: React.ReactNode;
26
+ onSignOut: () => void | Promise<void>;
27
+ /** Defaults to "Sign out". */
28
+ signOutLabel?: string;
29
+ /** Open the menu by default (uncontrolled). Mainly useful in tests. */
30
+ defaultOpen?: boolean;
31
+ /** Extra classes for the trigger card. */
32
+ className?: string;
33
+ }
34
+ /**
35
+ * Bottom-left identity menu for the app shell: a full-width account card
36
+ * (avatar, name, email, chevron-up) opening a menu with a fixed anatomy —
37
+ * identity header, optional theme selector row, consumer items, then a
38
+ * separated Sign out. Workspace switching deliberately does not belong here;
39
+ * that's the top-left `WorkspaceSwitcher`.
40
+ */
41
+ export declare function AccountMenu({ name, email, image, collapsed, theme, themeLabel, children, onSignOut, signOutLabel, defaultOpen, className, }: AccountMenuProps): import("react/jsx-runtime").JSX.Element;
42
+ export { DropdownMenuItem as AccountMenuItem, DropdownMenuSeparator as AccountMenuSeparator, } from "./dropdown-menu.js";
@@ -0,0 +1,41 @@
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 { ChevronUp, LogOut, Monitor, Moon, Sun } from "lucide-react";
5
+ import * as React from "react";
6
+ import { cn } from "../utils/cn.js";
7
+ import { Avatar } from "./avatar.js";
8
+ import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuRadioGroup, DropdownMenuSeparator, DropdownMenuTrigger, } from "./dropdown-menu.js";
9
+ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "./tooltip.js";
10
+ const THEME_OPTION_META = {
11
+ light: { icon: Sun, label: "Light" },
12
+ dark: { icon: Moon, label: "Dark" },
13
+ system: { icon: Monitor, label: "System" },
14
+ };
15
+ const ALL_THEME_OPTIONS = ["light", "dark", "system"];
16
+ /**
17
+ * Bottom-left identity menu for the app shell: a full-width account card
18
+ * (avatar, name, email, chevron-up) opening a menu with a fixed anatomy —
19
+ * identity header, optional theme selector row, consumer items, then a
20
+ * separated Sign out. Workspace switching deliberately does not belong here;
21
+ * that's the top-left `WorkspaceSwitcher`.
22
+ */
23
+ export function AccountMenu({ name, email, image, collapsed = false, theme, themeLabel = "Theme", children, onSignOut, signOutLabel = "Sign out", defaultOpen, className, }) {
24
+ const themeOptions = theme?.options ?? ALL_THEME_OPTIONS;
25
+ // Mirror open state for the chevron cue: the trigger's data-state is
26
+ // unreliable here because TooltipTrigger's own data-state wins the
27
+ // asChild prop merge over DropdownMenuTrigger's.
28
+ const [open, setOpen] = React.useState(defaultOpen ?? false);
29
+ return (_jsxs(DropdownMenu, { open: open, onOpenChange: setOpen, children: [_jsx(TooltipProvider, { delayDuration: 300, children: _jsxs(Tooltip, { children: [_jsx(TooltipTrigger, { asChild: true, children: _jsx(DropdownMenuTrigger, { asChild: true, children: _jsxs("button", { type: "button", "aria-label": collapsed ? name : "Account", className: cn("flex h-10 w-full items-center gap-2 rounded-[var(--ssui-radius)] px-1.5 text-left outline-none transition-colors hover:bg-[color:var(--ssui-overlay-hover)] focus-visible:ring-2 focus-visible:ring-[color:var(--ssui-focus-ring)]", collapsed
30
+ ? // Collapsed = avatar-only: the text column/chevron are omitted
31
+ // from the layout entirely (not just faded) so the trigger
32
+ // truly fits an icon rail instead of relying on clipping.
33
+ "justify-center px-0"
34
+ : "border border-[color:var(--ssui-border)] bg-[color:var(--ssui-surface)]", className), children: [_jsx(Avatar, { className: "shrink-0", name: name, size: 28, src: image }), !collapsed && (_jsxs(_Fragment, { children: [_jsxs("span", { className: "flex min-w-0 flex-1 flex-col", children: [_jsx("span", { className: "truncate text-[13px] font-medium leading-tight text-[color:var(--ssui-text)]", children: name }), email ? (_jsx("span", { className: "truncate text-[11px] leading-tight text-[color:var(--ssui-text-muted)]", children: email })) : null] }), _jsx(ChevronUp, { className: cn("size-4 shrink-0 text-[color:var(--ssui-text-subtle)] transition-transform duration-150", open && "rotate-180") })] }))] }) }) }), collapsed ? _jsx(TooltipContent, { side: "right", children: name }) : null] }) }), _jsxs(DropdownMenuContent, { align: collapsed ? "end" : "start", className: "w-60 min-w-[var(--radix-dropdown-menu-trigger-width)]", side: collapsed ? "right" : "top", sideOffset: 10, children: [_jsx(DropdownMenuLabel, { children: _jsxs("span", { className: "flex min-w-0 flex-col gap-0.5", children: [_jsx("span", { className: "truncate text-sm font-medium text-[color:var(--ssui-text)]", children: name }), email ? (_jsx("span", { className: "truncate text-xs font-normal text-[color:var(--ssui-text-muted)]", children: email })) : null] }) }), _jsx(DropdownMenuSeparator, {}), theme ? (_jsx(DropdownMenuRadioGroup, { value: theme.value, onValueChange: (value) => theme.onChange(value), children: _jsxs("div", { className: "flex items-center justify-between gap-2 px-2 py-1.5", children: [_jsx("span", { className: "text-xs text-[color:var(--ssui-text-muted)]", children: themeLabel }), _jsx("div", { className: "flex gap-0.5 rounded-[var(--ssui-radius-sm)] border border-[color:var(--ssui-border)] p-0.5", children: themeOptions.map((option) => {
35
+ const { icon: Icon, label } = THEME_OPTION_META[option];
36
+ return (_jsx(DropdownMenuPrimitive.RadioItem, { "aria-label": label, className: "flex size-7 cursor-default select-none items-center justify-center rounded-[calc(var(--ssui-radius-sm)-1px)] text-[color:var(--ssui-text-muted)] outline-none transition-colors focus:bg-[color:var(--ssui-overlay-hover)] data-[highlighted]:bg-[color:var(--ssui-overlay-hover)] data-[state=checked]:bg-[color:var(--ssui-overlay-hover)] data-[state=checked]:text-[color:var(--ssui-text)]",
37
+ // Keep the menu open: theme picking is a tweak-and-compare loop.
38
+ onSelect: (event) => event.preventDefault(), title: label, value: option, children: _jsx(Icon, { className: "size-4" }) }, option));
39
+ }) })] }) })) : null, children, theme || children ? _jsx(DropdownMenuSeparator, {}) : null, _jsxs(DropdownMenuItem, { onSelect: () => void onSignOut(), children: [_jsx(LogOut, { className: "size-4 text-[color:var(--ssui-text-muted)]" }), signOutLabel] })] })] }));
40
+ }
41
+ export { DropdownMenuItem as AccountMenuItem, DropdownMenuSeparator as AccountMenuSeparator, } from "./dropdown-menu.js";
@@ -2,7 +2,7 @@ import { type VariantProps } from "class-variance-authority";
2
2
  import * as React from "react";
3
3
  export declare const buttonVariants: (props?: ({
4
4
  variant?: "link" | "primary" | "outline" | "secondary" | "ghost" | "ghost-accent" | "destructive" | null | undefined;
5
- size?: "icon" | "sm" | "md" | "lg" | "icon-sm" | "icon-lg" | null | undefined;
5
+ size?: "sm" | "md" | "lg" | "icon" | "icon-sm" | "icon-lg" | null | undefined;
6
6
  } & import("class-variance-authority/types").ClassProp) | undefined) => string;
7
7
  export interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement>, VariantProps<typeof buttonVariants> {
8
8
  asChild?: boolean;
@@ -0,0 +1,33 @@
1
+ import * as React from "react";
2
+ export interface ChoiceCardGroupProps extends Omit<React.HTMLAttributes<HTMLDivElement>, "onChange"> {
3
+ /** Form field name shared by the underlying radios. */
4
+ name?: string;
5
+ /** Controlled selected value. */
6
+ value?: string;
7
+ /** Uncontrolled initial value. */
8
+ defaultValue?: string;
9
+ onValueChange?: (value: string) => void;
10
+ /** Accessible label for the radio group. */
11
+ "aria-label"?: string;
12
+ }
13
+ /**
14
+ * Radio group rendered as selectable bordered cards. Uses native radio
15
+ * inputs, so form participation and arrow-key navigation between cards come
16
+ * from the browser. Children are `ChoiceCard`s.
17
+ */
18
+ export declare function ChoiceCardGroup({ name, value, defaultValue, onValueChange, className, children, ...props }: ChoiceCardGroupProps): import("react/jsx-runtime").JSX.Element;
19
+ export interface ChoiceCardProps extends Omit<React.LabelHTMLAttributes<HTMLLabelElement>, "title"> {
20
+ /** Value submitted when this card is selected. */
21
+ value: string;
22
+ /** Card title. */
23
+ title: React.ReactNode;
24
+ /** Optional supporting copy under the title. */
25
+ description?: React.ReactNode;
26
+ disabled?: boolean;
27
+ }
28
+ /**
29
+ * One selectable card inside a `ChoiceCardGroup`: a bordered label wrapping a
30
+ * native radio plus title and supporting text. The selected card gets the
31
+ * emphasized border and muted fill.
32
+ */
33
+ export declare function ChoiceCard({ value, title, description, disabled, className, children, ...props }: ChoiceCardProps): import("react/jsx-runtime").JSX.Element;
@@ -0,0 +1,40 @@
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
+ const ChoiceCardGroupContext = React.createContext(null);
6
+ /**
7
+ * Radio group rendered as selectable bordered cards. Uses native radio
8
+ * inputs, so form participation and arrow-key navigation between cards come
9
+ * from the browser. Children are `ChoiceCard`s.
10
+ */
11
+ export function ChoiceCardGroup({ name, value, defaultValue, onValueChange, className, children, ...props }) {
12
+ const generatedName = React.useId();
13
+ const [uncontrolledValue, setUncontrolledValue] = React.useState(defaultValue);
14
+ const selected = value ?? uncontrolledValue;
15
+ const contextValue = React.useMemo(() => ({
16
+ name: name ?? generatedName,
17
+ value: selected,
18
+ setValue: (next) => {
19
+ if (value === undefined)
20
+ setUncontrolledValue(next);
21
+ onValueChange?.(next);
22
+ },
23
+ }), [name, generatedName, selected, value, onValueChange]);
24
+ return (_jsx("div", { role: "radiogroup", className: cn("grid gap-2 md:grid-cols-2", className), ...props, children: _jsx(ChoiceCardGroupContext.Provider, { value: contextValue, children: children }) }));
25
+ }
26
+ /**
27
+ * One selectable card inside a `ChoiceCardGroup`: a bordered label wrapping a
28
+ * native radio plus title and supporting text. The selected card gets the
29
+ * emphasized border and muted fill.
30
+ */
31
+ export function ChoiceCard({ value, title, description, disabled, className, children, ...props }) {
32
+ const group = React.useContext(ChoiceCardGroupContext);
33
+ if (!group) {
34
+ throw new Error("ChoiceCard must be rendered inside a ChoiceCardGroup");
35
+ }
36
+ const selected = group.value === value;
37
+ return (_jsxs("label", { className: cn("flex cursor-pointer gap-2 rounded-[var(--ssui-radius)] border px-3 py-2 transition-colors has-[:focus-visible]:ring-2 has-[:focus-visible]:ring-[color:var(--ssui-focus-ring)]", selected
38
+ ? "border-[color:var(--ssui-border-strong)] bg-[color:var(--ssui-surface-muted)] text-[color:var(--ssui-text)]"
39
+ : "border-[color:var(--ssui-border)] text-[color:var(--ssui-text-muted)] hover:bg-[color:var(--ssui-overlay-hover)]", disabled && "cursor-not-allowed opacity-50", className), ...props, children: [_jsx("input", { checked: selected, className: "mt-0.5 size-4 shrink-0 accent-current focus-visible:outline-none", disabled: disabled, name: group.name, onChange: () => group.setValue(value), type: "radio", value: value }), _jsxs("span", { className: "min-w-0", children: [_jsx("span", { className: "block text-sm font-medium", children: title }), description ? (_jsx("span", { className: "block text-xs text-[color:var(--ssui-text-subtle)]", children: description })) : null, children] })] }));
40
+ }
@@ -0,0 +1,37 @@
1
+ import * as React from "react";
2
+ export interface ComboboxOption {
3
+ value: string;
4
+ label: string;
5
+ /** Optional trailing content (e.g. a GMT offset) shown right-aligned. */
6
+ trailing?: React.ReactNode;
7
+ }
8
+ export interface ComboboxProps {
9
+ /** Full option list; the component filters it against the typed query. */
10
+ options: ComboboxOption[];
11
+ /** Selected option value. */
12
+ value?: string;
13
+ onValueChange?: (value: string) => void;
14
+ /**
15
+ * Filter predicate; defaults to case-insensitive substring match on the
16
+ * option label and value with spaces treated as underscores.
17
+ */
18
+ filter?: (option: ComboboxOption, query: string) => boolean;
19
+ placeholder?: string;
20
+ disabled?: boolean;
21
+ /** Message shown when no option matches. Defaults to "No matches". */
22
+ emptyMessage?: React.ReactNode;
23
+ id?: string;
24
+ name?: string;
25
+ "aria-label"?: string;
26
+ "aria-describedby"?: string;
27
+ "aria-invalid"?: React.AriaAttributes["aria-invalid"];
28
+ className?: string;
29
+ }
30
+ /**
31
+ * Filterable single-select following the WAI-ARIA combobox pattern: a text
32
+ * input filtering a listbox popup. ArrowUp/ArrowDown move the active-option
33
+ * highlight with wrap-around and scroll-into-view, Enter commits the active
34
+ * option, Escape closes, and losing focus closes without committing a
35
+ * partial value. Selection submits via a hidden input when `name` is set.
36
+ */
37
+ export declare const Combobox: React.ForwardRefExoticComponent<ComboboxProps & React.RefAttributes<HTMLInputElement>>;
@@ -0,0 +1,103 @@
1
+ "use client";
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { ChevronDown } from "lucide-react";
4
+ import * as React from "react";
5
+ import { cn } from "../utils/cn.js";
6
+ import { useFormFieldProps } from "./form-field.js";
7
+ function defaultFilter(option, query) {
8
+ const normalized = query.trim().toLowerCase().replaceAll(" ", "_");
9
+ if (!normalized)
10
+ return true;
11
+ return (option.value.toLowerCase().includes(normalized) ||
12
+ option.label.toLowerCase().replaceAll(" ", "_").includes(normalized));
13
+ }
14
+ /**
15
+ * Filterable single-select following the WAI-ARIA combobox pattern: a text
16
+ * input filtering a listbox popup. ArrowUp/ArrowDown move the active-option
17
+ * highlight with wrap-around and scroll-into-view, Enter commits the active
18
+ * option, Escape closes, and losing focus closes without committing a
19
+ * partial value. Selection submits via a hidden input when `name` is set.
20
+ */
21
+ export const Combobox = React.forwardRef(({ options, value, onValueChange, filter = defaultFilter, placeholder, disabled, emptyMessage = "No matches", className, name, ...ariaProps }, ref) => {
22
+ const fieldProps = useFormFieldProps(ariaProps);
23
+ const generatedId = React.useId();
24
+ const id = fieldProps.id ?? generatedId;
25
+ const listboxId = `${id}-listbox`;
26
+ const [open, setOpen] = React.useState(false);
27
+ const [query, setQuery] = React.useState(null);
28
+ const [activeIndex, setActiveIndex] = React.useState(-1);
29
+ const listRef = React.useRef(null);
30
+ const selectedOption = options.find((option) => option.value === value);
31
+ const filtered = query === null
32
+ ? options
33
+ : options.filter((option) => filter(option, query));
34
+ const inputValue = query ?? selectedOption?.label ?? "";
35
+ const activeOption = activeIndex >= 0 ? filtered[activeIndex] : undefined;
36
+ function openList() {
37
+ setOpen(true);
38
+ setActiveIndex(-1);
39
+ }
40
+ function close() {
41
+ setOpen(false);
42
+ setQuery(null);
43
+ setActiveIndex(-1);
44
+ }
45
+ function commit(option) {
46
+ onValueChange?.(option.value);
47
+ close();
48
+ }
49
+ function moveActive(delta) {
50
+ if (filtered.length === 0)
51
+ return;
52
+ const next = activeIndex < 0
53
+ ? delta === 1
54
+ ? 0
55
+ : filtered.length - 1
56
+ : (activeIndex + delta + filtered.length) % filtered.length;
57
+ setActiveIndex(next);
58
+ listRef.current
59
+ ?.querySelector(`[data-index="${next}"]`)
60
+ ?.scrollIntoView({ block: "nearest" });
61
+ }
62
+ function handleKeyDown(event) {
63
+ if (event.key === "ArrowDown" || event.key === "ArrowUp") {
64
+ event.preventDefault();
65
+ if (!open) {
66
+ openList();
67
+ return;
68
+ }
69
+ moveActive(event.key === "ArrowDown" ? 1 : -1);
70
+ return;
71
+ }
72
+ if (event.key === "Enter") {
73
+ if (open && activeOption) {
74
+ event.preventDefault();
75
+ commit(activeOption);
76
+ }
77
+ return;
78
+ }
79
+ if (event.key === "Escape") {
80
+ if (open) {
81
+ event.stopPropagation();
82
+ close();
83
+ }
84
+ }
85
+ }
86
+ return (_jsxs("div", { className: cn("relative", className), onBlur: (event) => {
87
+ if (!event.currentTarget.contains(event.relatedTarget)) {
88
+ close();
89
+ }
90
+ }, children: [_jsx("input", { ...fieldProps, ref: ref, id: id, role: "combobox", type: "text", "aria-autocomplete": "list", "aria-controls": listboxId, "aria-expanded": open, "aria-haspopup": "listbox", "aria-activedescendant": open && activeOption ? `${id}-option-${activeOption.value}` : undefined, autoComplete: "off", className: "flex h-10 w-full 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 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", disabled: disabled, onChange: (event) => {
91
+ setQuery(event.target.value);
92
+ setOpen(true);
93
+ setActiveIndex(-1);
94
+ }, onFocus: openList, onKeyDown: handleKeyDown, placeholder: placeholder, value: inputValue }), _jsx("button", { "aria-label": "Show options", className: "absolute right-1 top-1 grid size-8 place-items-center rounded-[var(--ssui-radius-sm)] text-[color:var(--ssui-text-subtle)] transition-colors hover:bg-[color:var(--ssui-overlay-hover)] hover:text-[color:var(--ssui-text)] disabled:pointer-events-none disabled:opacity-50", disabled: disabled, onClick: () => {
95
+ setQuery(null);
96
+ openList();
97
+ document.getElementById(id)?.focus();
98
+ }, tabIndex: -1, type: "button", children: _jsx(ChevronDown, { "aria-hidden": "true", className: "size-4" }) }), name ? _jsx("input", { name: name, type: "hidden", value: value ?? "" }) : null, open ? (_jsx("div", { className: "absolute z-50 mt-1 max-h-64 w-full overflow-y-auto rounded-[var(--ssui-radius)] border border-[color:var(--ssui-border)] bg-[color:var(--ssui-surface-elevated)] p-1 shadow-[var(--ssui-shadow-md)]", id: listboxId, ref: listRef, role: "listbox", children: filtered.length > 0 ? (filtered.map((option, index) => (_jsxs("button", { "aria-selected": option.value === value, className: cn("flex w-full items-center justify-between gap-3 rounded-[var(--ssui-radius-sm)] px-2 py-1.5 text-left text-sm transition-colors", index === activeIndex &&
99
+ "bg-[color:var(--ssui-overlay-hover)]", option.value === value
100
+ ? "bg-[color:var(--ssui-surface-muted)] text-[color:var(--ssui-text)]"
101
+ : "text-[color:var(--ssui-text-muted)] hover:bg-[color:var(--ssui-overlay-hover)]"), "data-index": index, id: `${id}-option-${option.value}`, onClick: () => commit(option), onMouseDown: (event) => event.preventDefault(), onMouseMove: () => setActiveIndex(index), role: "option", tabIndex: -1, type: "button", children: [_jsx("span", { className: "truncate", children: option.label }), option.trailing !== undefined ? (_jsx("span", { className: "shrink-0 font-mono text-xs text-[color:var(--ssui-text-subtle)]", children: option.trailing })) : null] }, option.value)))) : (_jsx("div", { className: "px-2 py-1.5 text-sm text-[color:var(--ssui-text-subtle)]", children: emptyMessage })) })) : null] }));
102
+ });
103
+ Combobox.displayName = "Combobox";
@@ -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,13 @@
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 "./choice-card.js";
8
+ export * from "./combobox.js";
6
9
  export * from "./confirm-dialog.js";
10
+ export * from "./form-field.js";
7
11
  export * from "./copy-field.js";
8
12
  export * from "./dialog.js";
9
13
  export * from "./dropdown-menu.js";
@@ -17,14 +21,21 @@ export * from "./list-shell.js";
17
21
  export * from "./login.js";
18
22
  export * from "./overflow-menu.js";
19
23
  export * from "./page-header.js";
24
+ export * from "./rail-toggle.js";
20
25
  export * from "./request-access.js";
21
26
  export * from "./scroll-area.js";
22
27
  export * from "./search-input.js";
28
+ export * from "./select.js";
23
29
  export * from "./separator.js";
30
+ export * from "./settings-layout.js";
31
+ export * from "./settings-nav.js";
24
32
  export * from "./settings-section.js";
25
33
  export * from "./skeleton.js";
26
34
  export * from "./status-pill.js";
35
+ export * from "./switch.js";
27
36
  export * from "./tabs.js";
28
37
  export * from "./textarea.js";
38
+ export * from "./time-zone-field.js";
29
39
  export * from "./toolbar.js";
30
40
  export * from "./tooltip.js";
41
+ export * from "./workspace-switcher.js";
@@ -1,9 +1,13 @@
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 "./choice-card.js";
8
+ export * from "./combobox.js";
6
9
  export * from "./confirm-dialog.js";
10
+ export * from "./form-field.js";
7
11
  export * from "./copy-field.js";
8
12
  export * from "./dialog.js";
9
13
  export * from "./dropdown-menu.js";
@@ -17,14 +21,21 @@ export * from "./list-shell.js";
17
21
  export * from "./login.js";
18
22
  export * from "./overflow-menu.js";
19
23
  export * from "./page-header.js";
24
+ export * from "./rail-toggle.js";
20
25
  export * from "./request-access.js";
21
26
  export * from "./scroll-area.js";
22
27
  export * from "./search-input.js";
28
+ export * from "./select.js";
23
29
  export * from "./separator.js";
30
+ export * from "./settings-layout.js";
31
+ export * from "./settings-nav.js";
24
32
  export * from "./settings-section.js";
25
33
  export * from "./skeleton.js";
26
34
  export * from "./status-pill.js";
35
+ export * from "./switch.js";
27
36
  export * from "./tabs.js";
28
37
  export * from "./textarea.js";
38
+ export * from "./time-zone-field.js";
29
39
  export * from "./toolbar.js";
30
40
  export * from "./tooltip.js";
41
+ 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,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,28 @@
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 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] }));
27
+ });
28
+ 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";
@@ -0,0 +1,30 @@
1
+ import type { TimeZoneOption } from "../utils/time-zones.js";
2
+ export type TimeZoneMode = "auto" | "fixed";
3
+ export interface TimeZoneFieldProps {
4
+ /** Initial mode. Defaults to "auto". */
5
+ defaultMode?: TimeZoneMode;
6
+ /** Initial fixed zone. Defaults to the detected browser zone. */
7
+ defaultTimeZone?: string;
8
+ /** Notified whenever mode or zone changes. */
9
+ onChange?: (state: {
10
+ mode: TimeZoneMode;
11
+ timeZone: string;
12
+ }) => void;
13
+ /**
14
+ * Zone options to render; defaults to `timeZoneOptions()` computed
15
+ * client-side. Provide to SSR the list or trim it.
16
+ */
17
+ options?: TimeZoneOption[];
18
+ /** Accessible label for the mode choice. Defaults to "Time zone mode". */
19
+ "aria-label"?: string;
20
+ className?: string;
21
+ }
22
+ /**
23
+ * Composite time-zone preference control: an auto/fixed mode choice rendered
24
+ * as `ChoiceCard`s (auto shows the detected browser zone inline) plus a
25
+ * filterable zone `Combobox` shown only in fixed mode. Search matches zone
26
+ * names with spaces or underscores and offset text ("GMT+09"). Submits
27
+ * through hidden inputs — `timeZoneMode`, `timeZone`, `browserTimeZone` —
28
+ * for server-action form handling.
29
+ */
30
+ export declare function TimeZoneField({ defaultMode, defaultTimeZone, onChange, options, className, "aria-label": ariaLabel, }: TimeZoneFieldProps): import("react/jsx-runtime").JSX.Element;
@@ -0,0 +1,72 @@
1
+ "use client";
2
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
3
+ import * as React from "react";
4
+ import { cn } from "../utils/cn.js";
5
+ import { detectBrowserTimeZone, formatTimeZoneOffset, timeZoneOptions, } from "../utils/time-zones.js";
6
+ import { ChoiceCard, ChoiceCardGroup } from "./choice-card.js";
7
+ import { Combobox } from "./combobox.js";
8
+ function matchesZoneQuery(option, query) {
9
+ const normalized = query.trim().toLowerCase();
10
+ if (!normalized)
11
+ return true;
12
+ const underscored = normalized.replaceAll(" ", "_");
13
+ const haystacks = [
14
+ option.value.toLowerCase(),
15
+ option.label.toLowerCase(),
16
+ typeof option.trailing === "string" ? option.trailing.toLowerCase() : "",
17
+ ];
18
+ return haystacks.some((haystack) => haystack.includes(normalized) || haystack.includes(underscored));
19
+ }
20
+ /**
21
+ * Composite time-zone preference control: an auto/fixed mode choice rendered
22
+ * as `ChoiceCard`s (auto shows the detected browser zone inline) plus a
23
+ * filterable zone `Combobox` shown only in fixed mode. Search matches zone
24
+ * names with spaces or underscores and offset text ("GMT+09"). Submits
25
+ * through hidden inputs — `timeZoneMode`, `timeZone`, `browserTimeZone` —
26
+ * for server-action form handling.
27
+ */
28
+ export function TimeZoneField({ defaultMode = "auto", defaultTimeZone, onChange, options, className, "aria-label": ariaLabel = "Time zone mode", }) {
29
+ const [mode, setMode] = React.useState(defaultMode);
30
+ const [browserTimeZone, setBrowserTimeZone] = React.useState("UTC");
31
+ const [fixedTimeZone, setFixedTimeZone] = React.useState(defaultTimeZone ?? "");
32
+ // Detection runs in an effect so server and first client render agree.
33
+ React.useEffect(() => {
34
+ const detected = detectBrowserTimeZone();
35
+ setBrowserTimeZone(detected);
36
+ setFixedTimeZone((current) => current || detected);
37
+ }, []);
38
+ // Building the full IANA list constructs one `Intl.DateTimeFormat` per zone
39
+ // (~400 of them, tens of ms). The combobox only renders in fixed mode, so
40
+ // defer the build until fixed is first selected, then cache it for the
41
+ // component's lifetime. Consumer-provided `options` skip the build entirely.
42
+ const builtOptionsRef = React.useRef(null);
43
+ const comboboxOptions = React.useMemo(() => {
44
+ if (options) {
45
+ return options.map((zone) => ({
46
+ value: zone.value,
47
+ label: zone.label,
48
+ trailing: zone.offset,
49
+ }));
50
+ }
51
+ if (mode !== "fixed")
52
+ return [];
53
+ if (!builtOptionsRef.current) {
54
+ builtOptionsRef.current = timeZoneOptions().map((zone) => ({
55
+ value: zone.value,
56
+ label: zone.label,
57
+ trailing: zone.offset,
58
+ }));
59
+ }
60
+ return builtOptionsRef.current;
61
+ }, [options, mode]);
62
+ function update(next) {
63
+ const nextMode = next.mode ?? mode;
64
+ const nextZone = next.timeZone ?? fixedTimeZone;
65
+ if (next.mode !== undefined)
66
+ setMode(next.mode);
67
+ if (next.timeZone !== undefined)
68
+ setFixedTimeZone(next.timeZone);
69
+ onChange?.({ mode: nextMode, timeZone: nextZone });
70
+ }
71
+ return (_jsxs("div", { className: cn("space-y-3", className), children: [_jsx("input", { name: "timeZoneMode", type: "hidden", value: mode }), _jsx("input", { name: "timeZone", type: "hidden", value: fixedTimeZone }), _jsx("input", { name: "browserTimeZone", type: "hidden", value: browserTimeZone }), _jsxs(ChoiceCardGroup, { "aria-label": ariaLabel, onValueChange: (value) => update({ mode: value }), value: mode, children: [_jsx(ChoiceCard, { description: _jsxs(_Fragment, { children: ["Use the browser time zone", _jsxs("span", { className: "mt-0.5 block font-mono text-[color:var(--ssui-text-muted)]", children: [browserTimeZone, " (", formatTimeZoneOffset(browserTimeZone), ")"] })] }), title: "Auto", value: "auto" }), _jsx(ChoiceCard, { description: "Always use one time zone", title: "Fixed", value: "fixed" })] }), mode === "fixed" ? (_jsx(Combobox, { "aria-label": "Fixed time zone", emptyMessage: "No matching time zones", filter: matchesZoneQuery, onValueChange: (value) => update({ timeZone: value }), options: comboboxOptions, placeholder: "America/New_York", value: fixedTimeZone })) : null] }));
72
+ }
@@ -0,0 +1,33 @@
1
+ import * as React from "react";
2
+ export interface WorkspaceSwitcherWorkspace {
3
+ id: string;
4
+ name: string;
5
+ }
6
+ export interface WorkspaceSwitcherProps {
7
+ workspaces: ReadonlyArray<WorkspaceSwitcherWorkspace>;
8
+ activeWorkspaceId: string;
9
+ /** Called with the selected workspace id; never fired for the active row. */
10
+ onSelect: (id: string) => void | Promise<void>;
11
+ /**
12
+ * Brand badge rendered at the left of the trigger (e.g. a product tile).
13
+ * Defaults to a primary-colored tile with the active workspace's initials.
14
+ */
15
+ badge?: React.ReactNode;
16
+ /** Icon-only rail state: badge-only trigger with a name tooltip, menu opens right. */
17
+ collapsed?: boolean;
18
+ /** Renders a separated "New workspace" item when provided. */
19
+ onCreateWorkspace?: () => void;
20
+ /** Defaults to "New workspace". */
21
+ createWorkspaceLabel?: string;
22
+ /** Open the menu by default (uncontrolled). Mainly useful in tests. */
23
+ defaultOpen?: boolean;
24
+ /** Extra classes for the trigger. */
25
+ className?: string;
26
+ }
27
+ /**
28
+ * Top-left workspace context menu for the app shell: a trigger showing the
29
+ * brand badge and active workspace name, opening a list of the user's
30
+ * workspaces with the active one checked. Identity/session actions
31
+ * deliberately do not belong here; that's the bottom-left `AccountMenu`.
32
+ */
33
+ export declare function WorkspaceSwitcher({ workspaces, activeWorkspaceId, onSelect, badge, collapsed, onCreateWorkspace, createWorkspaceLabel, defaultOpen, className, }: WorkspaceSwitcherProps): import("react/jsx-runtime").JSX.Element;
@@ -0,0 +1,34 @@
1
+ "use client";
2
+ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
4
+ import { Check, ChevronDown, Plus } from "lucide-react";
5
+ import * as React from "react";
6
+ import { cn } from "../utils/cn.js";
7
+ import { getInitials } from "../utils/initials.js";
8
+ import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger, } from "./dropdown-menu.js";
9
+ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "./tooltip.js";
10
+ /**
11
+ * Top-left workspace context menu for the app shell: a trigger showing the
12
+ * brand badge and active workspace name, opening a list of the user's
13
+ * workspaces with the active one checked. Identity/session actions
14
+ * deliberately do not belong here; that's the bottom-left `AccountMenu`.
15
+ */
16
+ export function WorkspaceSwitcher({ workspaces, activeWorkspaceId, onSelect, badge, collapsed = false, onCreateWorkspace, createWorkspaceLabel = "New workspace", defaultOpen, className, }) {
17
+ const active = workspaces.find((workspace) => workspace.id === activeWorkspaceId);
18
+ const activeName = active?.name ?? "";
19
+ // Mirror open state for the chevron cue: the trigger's data-state is
20
+ // unreliable here because TooltipTrigger's own data-state wins the
21
+ // asChild prop merge over DropdownMenuTrigger's.
22
+ const [open, setOpen] = React.useState(defaultOpen ?? false);
23
+ return (_jsxs(DropdownMenu, { open: open, onOpenChange: setOpen, children: [_jsx(TooltipProvider, { delayDuration: 300, children: _jsxs(Tooltip, { children: [_jsx(TooltipTrigger, { asChild: true, children: _jsx(DropdownMenuTrigger, { asChild: true, children: _jsxs("button", { type: "button", "aria-label": collapsed ? activeName : `Switch workspace (current: ${activeName})`, className: cn("flex h-10 w-full items-center gap-2 rounded-[var(--ssui-radius)] px-1.5 text-left outline-none transition-colors hover:bg-[color:var(--ssui-overlay-hover)] focus-visible:ring-2 focus-visible:ring-[color:var(--ssui-focus-ring)]",
24
+ // Collapsed = badge-only: the label/chevron are omitted from the
25
+ // layout entirely (not just faded) so the trigger truly fits an
26
+ // icon rail instead of relying on horizontal clipping.
27
+ collapsed && "justify-center px-0", className), children: [_jsx("span", { className: "flex shrink-0 items-center justify-center", children: badge ?? (_jsx("span", { className: "flex size-7 items-center justify-center rounded-[var(--ssui-radius-sm)] bg-[color:var(--ssui-primary)] text-xs font-semibold text-[color:var(--ssui-primary-foreground)]", children: getInitials(activeName) })) }), !collapsed && (_jsxs(_Fragment, { children: [_jsx("span", { className: "min-w-0 flex-1 truncate text-[13px] font-medium leading-tight text-[color:var(--ssui-text)]", children: activeName }), _jsx(ChevronDown, { className: cn("size-4 shrink-0 text-[color:var(--ssui-text-subtle)] transition-transform duration-150", open && "rotate-180") })] }))] }) }) }), collapsed ? _jsx(TooltipContent, { side: "right", children: activeName }) : null] }) }), _jsxs(DropdownMenuContent, { align: "start", className: "w-60 min-w-[var(--radix-dropdown-menu-trigger-width)]", side: collapsed ? "right" : "bottom", sideOffset: 10, children: [_jsx(DropdownMenuPrimitive.RadioGroup, { value: activeWorkspaceId, onValueChange: (id) => {
28
+ if (id !== activeWorkspaceId)
29
+ void onSelect(id);
30
+ }, children: workspaces.map((workspace) => {
31
+ const isActive = workspace.id === activeWorkspaceId;
32
+ return (_jsxs(DropdownMenuPrimitive.RadioItem, { value: workspace.id, className: cn("relative flex cursor-default select-none items-center gap-2 rounded-[var(--ssui-radius-sm)] px-2 py-1.5 text-sm text-[color:var(--ssui-text)] outline-none transition-colors focus:bg-[color:var(--ssui-overlay-hover)] data-[highlighted]:bg-[color:var(--ssui-overlay-hover)]", isActive && "font-medium"), children: [_jsx("span", { className: "min-w-0 flex-1 truncate", children: workspace.name }), isActive ? (_jsx(Check, { className: "size-4 shrink-0 text-[color:var(--ssui-primary)]" })) : null] }, workspace.id));
33
+ }) }), onCreateWorkspace ? (_jsxs(_Fragment, { children: [_jsx(DropdownMenuSeparator, {}), _jsxs(DropdownMenuItem, { onSelect: () => onCreateWorkspace(), children: [_jsx(Plus, { className: "size-4 text-[color:var(--ssui-text-muted)]" }), createWorkspaceLabel] })] })) : null] })] }));
34
+ }
@@ -20,6 +20,9 @@
20
20
  --ssui-destructive-hover: #b91c1c;
21
21
  --ssui-destructive-foreground: #ffffff;
22
22
  --ssui-focus-ring: #2563eb;
23
+ --ssui-switch-track: var(--ssui-border);
24
+ --ssui-switch-track-checked: var(--ssui-primary);
25
+ --ssui-switch-thumb: #ffffff;
23
26
  --ssui-radius-sm: 0.25rem;
24
27
  --ssui-radius: 0.375rem;
25
28
  --ssui-radius-lg: 0.5rem;
@@ -1,3 +1,4 @@
1
1
  export * from "./cn.js";
2
2
  export * from "./initials.js";
3
3
  export * from "./time.js";
4
+ export * from "./time-zones.js";
@@ -1,3 +1,4 @@
1
1
  export * from "./cn.js";
2
2
  export * from "./initials.js";
3
3
  export * from "./time.js";
4
+ export * from "./time-zones.js";
@@ -0,0 +1,26 @@
1
+ export interface TimeZoneOption {
2
+ /** IANA zone id, e.g. "America/New_York". */
3
+ value: string;
4
+ /** Display label, e.g. "America/New_York". */
5
+ label: string;
6
+ /** Current offset label, e.g. "GMT-04:00". */
7
+ offset: string;
8
+ /** Offset in minutes, for sorting. */
9
+ offsetMinutes: number;
10
+ }
11
+ /**
12
+ * Zones surfaced ahead of the full IANA list — the major business hubs both
13
+ * consuming products' audiences are most likely to pick.
14
+ */
15
+ export declare const COMMON_TIME_ZONES: string[];
16
+ /** Detect the browser's IANA time zone, falling back to UTC. */
17
+ export declare function detectBrowserTimeZone(): string;
18
+ /** Format a zone's current UTC offset as "GMT±HH:MM". Unknown zones map to GMT+00:00. */
19
+ export declare function formatTimeZoneOffset(timeZone: string, date?: Date): string;
20
+ /**
21
+ * Build the ordered, offset-annotated zone option list `TimeZoneField`
22
+ * renders: common zones first, then every zone the runtime supports, each
23
+ * sorted by current offset then name. Offsets are computed for `date`
24
+ * (defaults to now), so DST is reflected.
25
+ */
26
+ export declare function timeZoneOptions(date?: Date): TimeZoneOption[];
@@ -0,0 +1,79 @@
1
+ /**
2
+ * Zones surfaced ahead of the full IANA list — the major business hubs both
3
+ * consuming products' audiences are most likely to pick.
4
+ */
5
+ export const COMMON_TIME_ZONES = [
6
+ "UTC",
7
+ "America/New_York",
8
+ "America/Chicago",
9
+ "America/Denver",
10
+ "America/Los_Angeles",
11
+ "America/Toronto",
12
+ "America/Vancouver",
13
+ "Europe/London",
14
+ "Europe/Paris",
15
+ "Europe/Berlin",
16
+ "Asia/Kolkata",
17
+ "Asia/Singapore",
18
+ "Asia/Tokyo",
19
+ "Australia/Sydney",
20
+ ];
21
+ /** Detect the browser's IANA time zone, falling back to UTC. */
22
+ export function detectBrowserTimeZone() {
23
+ try {
24
+ return Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC";
25
+ }
26
+ catch {
27
+ return "UTC";
28
+ }
29
+ }
30
+ /** Format a zone's current UTC offset as "GMT±HH:MM". Unknown zones map to GMT+00:00. */
31
+ export function formatTimeZoneOffset(timeZone, date = new Date()) {
32
+ try {
33
+ const parts = new Intl.DateTimeFormat("en-US", {
34
+ hour: "2-digit",
35
+ minute: "2-digit",
36
+ timeZone,
37
+ timeZoneName: "longOffset",
38
+ }).formatToParts(date);
39
+ const offset = parts.find((part) => part.type === "timeZoneName")?.value;
40
+ return offset && offset !== "GMT" ? offset : "GMT+00:00";
41
+ }
42
+ catch {
43
+ return "GMT+00:00";
44
+ }
45
+ }
46
+ function offsetMinutesFromLabel(offset) {
47
+ const match = offset.match(/^GMT([+-])(\d{2}):(\d{2})$/);
48
+ if (!match)
49
+ return 0;
50
+ const minutes = Number(match[2]) * 60 + Number(match[3]);
51
+ return match[1] === "-" ? -minutes : minutes;
52
+ }
53
+ function toOption(value, date) {
54
+ const offset = formatTimeZoneOffset(value, date);
55
+ return {
56
+ value,
57
+ label: value,
58
+ offset,
59
+ offsetMinutes: offsetMinutesFromLabel(offset),
60
+ };
61
+ }
62
+ /**
63
+ * Build the ordered, offset-annotated zone option list `TimeZoneField`
64
+ * renders: common zones first, then every zone the runtime supports, each
65
+ * sorted by current offset then name. Offsets are computed for `date`
66
+ * (defaults to now), so DST is reflected.
67
+ */
68
+ export function timeZoneOptions(date = new Date()) {
69
+ const intl = Intl;
70
+ const allZones = intl.supportedValuesOf?.("timeZone") ?? [];
71
+ const common = new Set(COMMON_TIME_ZONES);
72
+ const sortByOffsetThenName = (a, b) => a.offsetMinutes - b.offsetMinutes || a.value.localeCompare(b.value);
73
+ const commonOptions = COMMON_TIME_ZONES.map((zone) => toOption(zone, date)).sort(sortByOffsetThenName);
74
+ const rest = allZones
75
+ .filter((zone) => !common.has(zone))
76
+ .map((zone) => toOption(zone, date))
77
+ .sort(sortByOffsetThenName);
78
+ return [...commonOptions, ...rest];
79
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@forwardreach/saas-ui",
3
- "version": "0.5.0",
3
+ "version": "0.7.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,