@forwardreach/saas-ui 0.5.0 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +90 -0
- package/dist/components/account-menu.d.ts +42 -0
- package/dist/components/account-menu.js +41 -0
- package/dist/components/button.d.ts +1 -1
- package/dist/components/checkbox.d.ts +51 -0
- package/dist/components/checkbox.js +61 -0
- package/dist/components/choice-card.d.ts +33 -0
- package/dist/components/choice-card.js +40 -0
- package/dist/components/collapsible.d.ts +37 -0
- package/dist/components/collapsible.js +60 -0
- package/dist/components/combobox.d.ts +37 -0
- package/dist/components/combobox.js +103 -0
- package/dist/components/field-group.d.ts +22 -0
- package/dist/components/field-group.js +22 -0
- package/dist/components/form-field.d.ts +48 -0
- package/dist/components/form-field.js +51 -0
- package/dist/components/index.d.ts +15 -0
- package/dist/components/index.js +15 -0
- package/dist/components/input.js +2 -1
- package/dist/components/rail-toggle.d.ts +19 -0
- package/dist/components/rail-toggle.js +17 -0
- package/dist/components/role-menu.d.ts +95 -0
- package/dist/components/role-menu.js +52 -0
- package/dist/components/select.d.ts +9 -0
- package/dist/components/select.js +12 -0
- package/dist/components/settings-layout.d.ts +10 -0
- package/dist/components/settings-layout.js +10 -0
- package/dist/components/settings-nav.d.ts +46 -0
- package/dist/components/settings-nav.js +21 -0
- package/dist/components/switch.d.ts +20 -0
- package/dist/components/switch.js +35 -0
- package/dist/components/textarea.js +2 -1
- package/dist/components/time-zone-field.d.ts +30 -0
- package/dist/components/time-zone-field.js +72 -0
- package/dist/components/workspace-switcher.d.ts +33 -0
- package/dist/components/workspace-switcher.js +34 -0
- package/dist/styles/index.css +6 -0
- package/dist/utils/index.d.ts +1 -0
- package/dist/utils/index.js +1 -0
- package/dist/utils/time-zones.d.ts +26 -0
- package/dist/utils/time-zones.js +79 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,95 @@
|
|
|
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
|
+
|
|
39
|
+
## 0.7.0
|
|
40
|
+
|
|
41
|
+
### Minor Changes
|
|
42
|
+
|
|
43
|
+
- Add the settings shell and form primitive kit:
|
|
44
|
+
|
|
45
|
+
- `SettingsLayout` / `SettingsNav` — centered max-width settings shell with a
|
|
46
|
+
grouped, icon-capable sidebar nav (horizontal-scroll row on small screens).
|
|
47
|
+
Router-agnostic: active state comes from `activeHref` or an `isActive`
|
|
48
|
+
predicate and links render through an injectable `linkComponent`.
|
|
49
|
+
- `FormField` — label/description/control/error wrapper that generates and
|
|
50
|
+
threads `id`/`aria-describedby`/`aria-invalid` to package controls via
|
|
51
|
+
context; `Input`, `Textarea`, `Select`, `Switch`, and `Combobox` participate.
|
|
52
|
+
- `Select` — native select styled to match `Input`, with chevron.
|
|
53
|
+
- `Switch` — `role="switch"` toggle themed by new optional
|
|
54
|
+
`--ssui-switch-track` / `--ssui-switch-track-checked` / `--ssui-switch-thumb`
|
|
55
|
+
tokens (neutral fallbacks); optional form participation via `name`.
|
|
56
|
+
- `ChoiceCardGroup` / `ChoiceCard` — radio group rendered as selectable
|
|
57
|
+
bordered cards over native radios.
|
|
58
|
+
- `Combobox` — filterable single-select with WAI-ARIA combobox semantics,
|
|
59
|
+
wrap-around active-option keyboard navigation, and Enter-commit of the
|
|
60
|
+
highlighted option.
|
|
61
|
+
- `TimeZoneField` — auto/fixed time-zone composite (detected zone shown in
|
|
62
|
+
the auto card, zone combobox with offset-aware search in fixed mode) that
|
|
63
|
+
submits `timeZoneMode`/`timeZone`/`browserTimeZone` hidden inputs.
|
|
64
|
+
- Time-zone utilities (`timeZoneOptions`, `formatTimeZoneOffset`,
|
|
65
|
+
`detectBrowserTimeZone`, `COMMON_TIME_ZONES`) exported for server-side reuse.
|
|
66
|
+
|
|
67
|
+
## 0.6.0
|
|
68
|
+
|
|
69
|
+
### Minor Changes
|
|
70
|
+
|
|
71
|
+
- 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.
|
|
72
|
+
|
|
73
|
+
- Add `AccountMenu` and `WorkspaceSwitcher` — the shared app-shell menu pair.
|
|
74
|
+
|
|
75
|
+
`AccountMenu` is the bottom-left identity menu: a full-width account card
|
|
76
|
+
trigger (avatar, name, email, chevron-up; avatar-only with a name tooltip when
|
|
77
|
+
`collapsed`) opening a Radix menu with a fixed anatomy — identity header,
|
|
78
|
+
optional theme selector row, consumer-provided items (`AccountMenuItem`), then
|
|
79
|
+
a separated Sign out. The theme row is a segmented `menuitemradio` control
|
|
80
|
+
(Sun/Moon/Monitor) that supports both two-option (light/dark) and three-option
|
|
81
|
+
(light/dark/system) configurations and keeps the menu open on selection.
|
|
82
|
+
|
|
83
|
+
`WorkspaceSwitcher` is the top-left workspace context menu: a brand badge +
|
|
84
|
+
active workspace name + chevron-down trigger (badge-only with a tooltip when
|
|
85
|
+
`collapsed`) opening a list of workspaces with the active row checked and not
|
|
86
|
+
re-selectable, plus an optional separated "New workspace" action. Selection is
|
|
87
|
+
callback-based (`onSelect(id)`).
|
|
88
|
+
|
|
89
|
+
Both components support the collapsed icon-rail state (menus open to the
|
|
90
|
+
right) and wrap their tooltips in a local `TooltipProvider`, so consumers need
|
|
91
|
+
no extra setup.
|
|
92
|
+
|
|
3
93
|
## 0.5.0
|
|
4
94
|
|
|
5
95
|
### 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?: "
|
|
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,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,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 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,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";
|