@forwardreach/saas-ui 0.7.0 → 0.10.2
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 +121 -0
- package/dist/components/checkbox.d.ts +51 -0
- package/dist/components/checkbox.js +61 -0
- package/dist/components/collapsible.d.ts +37 -0
- package/dist/components/collapsible.js +60 -0
- package/dist/components/combobox.d.ts +17 -0
- package/dist/components/combobox.js +59 -10
- package/dist/components/field-group.d.ts +22 -0
- package/dist/components/field-group.js +22 -0
- package/dist/components/index.d.ts +4 -0
- package/dist/components/index.js +4 -0
- package/dist/components/role-menu.d.ts +95 -0
- package/dist/components/role-menu.js +52 -0
- package/dist/components/switch.js +8 -1
- package/dist/styles/index.css +3 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,126 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.10.2
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- `Combobox` keyboard scrolling brings a group's heading into view *alongside*
|
|
8
|
+
its first option rather than instead of it. `0.10.1` scrolled the heading and
|
|
9
|
+
not the option, which fixed entering a group from above and broke entering
|
|
10
|
+
one from below: `scrollIntoView({block: "nearest"})` on a heading below the
|
|
11
|
+
fold aligns its bottom edge with the scrollport's, leaving the newly
|
|
12
|
+
highlighted option out of view. Two `nearest` calls — heading first, then the
|
|
13
|
+
option — settle both directions, and losing the highlight is now impossible
|
|
14
|
+
in either.
|
|
15
|
+
|
|
16
|
+
Packed under a new number rather than re-packed under `0.10.1`, which is the
|
|
17
|
+
practice the `0.10.0` double-pack cost us.
|
|
18
|
+
|
|
19
|
+
## 0.10.1
|
|
20
|
+
|
|
21
|
+
### Patch Changes
|
|
22
|
+
|
|
23
|
+
- `Combobox` grouping now reaches assistive technology. Each run of options
|
|
24
|
+
sharing a `group` is wrapped in a `role="group"` labelled by its heading (the
|
|
25
|
+
APG grouped-listbox shape), instead of the heading being a roleless sibling
|
|
26
|
+
of the options. A roleless element is not in the content model of
|
|
27
|
+
`role="listbox"`, so the previous markup conveyed the grouping to sighted
|
|
28
|
+
users only — a regression against the native `<select>` with `<optgroup
|
|
29
|
+
label>` that consumers replace with this. The options array stays flat and
|
|
30
|
+
every index the keyboard model uses is unchanged; only the DOM the rows are
|
|
31
|
+
emitted into differs.
|
|
32
|
+
|
|
33
|
+
A consequence, and the behavior the `group` doc comment already promised: an
|
|
34
|
+
option declaring no `group` after a grouped run now stands outside that
|
|
35
|
+
group rather than rendering under its heading, the way an `<option>` after an
|
|
36
|
+
`</optgroup>` does.
|
|
37
|
+
|
|
38
|
+
- **Behavior change.** `Combobox` now consumes Enter whenever its popup is
|
|
39
|
+
open, committing the active option where there is one and swallowing the key
|
|
40
|
+
where there is none. Enter previously fell through to the surrounding form
|
|
41
|
+
when nothing was highlighted, which was harmless only while every consumer
|
|
42
|
+
had a submit button to absorb it — and `0.10.0` exists to let a consumer drop
|
|
43
|
+
theirs. A form whose only field is a `Combobox` is implicitly submitted by
|
|
44
|
+
Enter under HTML's rules, so an uncommitted query submitted an empty value. A
|
|
45
|
+
consumer that relied on Enter in a closed popup reaching its form is
|
|
46
|
+
unaffected; one that relied on it with the popup open must now press Escape
|
|
47
|
+
first.
|
|
48
|
+
|
|
49
|
+
- Arrowing to the first option of a group scrolls that group's heading into
|
|
50
|
+
view rather than the option, so the heading is not left clipped above the
|
|
51
|
+
scrollport at the moment it is most needed.
|
|
52
|
+
|
|
53
|
+
- `0.10.0` was packed twice under one number: the group heading's padding was
|
|
54
|
+
retuned after the tarball had already been handed to a consumer, so two
|
|
55
|
+
distinct `0.10.0` tarballs existed and the one that was verified is gone.
|
|
56
|
+
This release supersedes both. See `future.md` for the packing-provenance
|
|
57
|
+
remedy.
|
|
58
|
+
|
|
59
|
+
## 0.10.0
|
|
60
|
+
|
|
61
|
+
### Minor Changes
|
|
62
|
+
|
|
63
|
+
- `Combobox` options may declare a `group` and a `description`, both optional
|
|
64
|
+
and both additive — every existing call site renders exactly as before, down
|
|
65
|
+
to the markup.
|
|
66
|
+
|
|
67
|
+
- `group` puts a run of consecutive options under a heading. The options prop
|
|
68
|
+
stays one flat ordered list, which is what keeps the keyboard model intact:
|
|
69
|
+
filtering, the active-option index, wrap-around, and scroll-into-view all
|
|
70
|
+
still work on that single list, and headings are siblings inside the
|
|
71
|
+
listbox rather than wrappers around the options. A heading is presentation,
|
|
72
|
+
not a choice — it carries no `role="option"`, arrow keys pass over it, and
|
|
73
|
+
a heading whose options are all filtered away is not rendered. The
|
|
74
|
+
component does not reorder, so interleaved groups repeat their heading, as
|
|
75
|
+
`<optgroup>` does; sort before passing if that is not wanted. A heading is
|
|
76
|
+
spaced asymmetrically — much more room above it than below — so that each
|
|
77
|
+
run reads as one block instead of the heading floating equidistant between
|
|
78
|
+
the group above and the group it names.
|
|
79
|
+
- `description` is secondary text beside the label, for the kind of
|
|
80
|
+
qualifier that has to read next to a name — a record's type, say. It is
|
|
81
|
+
distinct from `trailing`, which is right-aligned and monospaced because it
|
|
82
|
+
was built for GMT offsets, and an option may carry both.
|
|
83
|
+
|
|
84
|
+
Between `0.8.0` and this release, a `0.9.1` tarball circulated to a consumer
|
|
85
|
+
without a release note or a published version behind it. This version is
|
|
86
|
+
numbered past it so no consumer moves backwards.
|
|
87
|
+
|
|
88
|
+
## 0.8.0
|
|
89
|
+
|
|
90
|
+
### Minor Changes
|
|
91
|
+
|
|
92
|
+
- `Switch` themes its thumb per state via a new optional
|
|
93
|
+
`--ssui-switch-thumb-checked` token (defaulting to `--ssui-switch-thumb`, so
|
|
94
|
+
existing consumers are unchanged). A single thumb color only works when the
|
|
95
|
+
checked track and the unchecked track sit on the same side of the lightness
|
|
96
|
+
scale; a product whose primary is near-white got a white thumb on a near-white
|
|
97
|
+
checked track, leaving the control unable to show on versus off.
|
|
98
|
+
|
|
99
|
+
- Add the multi-select and disclosure primitives the settings kit was missing:
|
|
100
|
+
|
|
101
|
+
- `FieldGroup` — the group analogue of `FormField`: a `fieldset`/`legend`
|
|
102
|
+
wrapper with the same label/description/error typography and
|
|
103
|
+
`aria-describedby`/`aria-invalid` wiring. `FormField` can only label a single
|
|
104
|
+
control through `htmlFor`, so a set of controls answering one question (a
|
|
105
|
+
checkbox group, a radio group, a composite input) previously had no correct
|
|
106
|
+
labelling primitive.
|
|
107
|
+
- `Checkbox` — styled native checkbox that participates in `FormField` wiring
|
|
108
|
+
like the other controls.
|
|
109
|
+
- `CheckboxGroup` / `CheckboxGroupItem` — multi-select group whose options share
|
|
110
|
+
one form field name, so the submission arrives as repeated entries under that
|
|
111
|
+
name. The mirror image of `ChoiceCardGroup`, with the same bordered-target
|
|
112
|
+
presentation and support for controlled or uncontrolled selection.
|
|
113
|
+
- `Collapsible` / `CollapsibleTrigger` / `CollapsibleContent` — disclosure region
|
|
114
|
+
for keeping secondary detail (an edit form, an advanced section) out of the way
|
|
115
|
+
until it is asked for. The trigger carries `aria-expanded`/`aria-controls` and
|
|
116
|
+
supports `asChild` so a `Button` can be the trigger; the region stays mounted
|
|
117
|
+
and hidden with the `hidden` attribute, so `aria-controls` always resolves and
|
|
118
|
+
an open form's in-progress state survives a collapse.
|
|
119
|
+
|
|
120
|
+
The component gallery gains a "Form Fields And Groups" section covering these
|
|
121
|
+
alongside the existing `FormField`/`Select`/`Switch`/`ChoiceCardGroup` kit, plus
|
|
122
|
+
a "Collapsible" section.
|
|
123
|
+
|
|
3
124
|
## 0.7.0
|
|
4
125
|
|
|
5
126
|
### Minor Changes
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
export interface CheckboxProps extends Omit<React.InputHTMLAttributes<HTMLInputElement>, "type"> {
|
|
3
|
+
}
|
|
4
|
+
/**
|
|
5
|
+
* Styled native checkbox. Native on purpose: form participation, indeterminate
|
|
6
|
+
* support and platform semantics come from the browser, and only the box itself
|
|
7
|
+
* needs theming. Participates in `FormField` wiring like the other controls.
|
|
8
|
+
*/
|
|
9
|
+
export declare const Checkbox: React.ForwardRefExoticComponent<CheckboxProps & React.RefAttributes<HTMLInputElement>>;
|
|
10
|
+
export interface CheckboxGroupProps extends Omit<React.FieldsetHTMLAttributes<HTMLFieldSetElement>, "onChange" | "defaultValue" | "children"> {
|
|
11
|
+
/** Group label, rendered as the fieldset's legend. */
|
|
12
|
+
legend: React.ReactNode;
|
|
13
|
+
/** Optional supporting copy shown under the legend. */
|
|
14
|
+
description?: React.ReactNode;
|
|
15
|
+
/** Error message; when set the group is marked invalid. */
|
|
16
|
+
error?: React.ReactNode;
|
|
17
|
+
/** Form field name shared by every option in the group. */
|
|
18
|
+
name?: string;
|
|
19
|
+
/** Controlled selection. */
|
|
20
|
+
value?: readonly string[];
|
|
21
|
+
/** Uncontrolled initial selection. */
|
|
22
|
+
defaultValue?: readonly string[];
|
|
23
|
+
onValueChange?: (value: string[]) => void;
|
|
24
|
+
/** Disables every option in the group. */
|
|
25
|
+
disabled?: boolean;
|
|
26
|
+
/** `CheckboxGroupItem`s. */
|
|
27
|
+
children: React.ReactNode;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Multi-select group: a `FieldGroup` whose options share one form field name, so
|
|
31
|
+
* the submission arrives as repeated entries under that name. The mirror image of
|
|
32
|
+
* `ChoiceCardGroup`, which is the single-select form of the same pattern.
|
|
33
|
+
*
|
|
34
|
+
* Selection is tracked in context rather than left to the DOM so the group can be
|
|
35
|
+
* driven as a controlled component, but the underlying inputs stay native — an
|
|
36
|
+
* uncontrolled group still submits correctly with no JavaScript involvement.
|
|
37
|
+
*/
|
|
38
|
+
export declare function CheckboxGroup({ legend, description, error, name, value, defaultValue, onValueChange, disabled, className, children, ...props }: CheckboxGroupProps): import("react/jsx-runtime").JSX.Element;
|
|
39
|
+
export interface CheckboxGroupItemProps extends Omit<React.LabelHTMLAttributes<HTMLLabelElement>, "title"> {
|
|
40
|
+
/** Value submitted when this option is checked. */
|
|
41
|
+
value: string;
|
|
42
|
+
/** Option label. */
|
|
43
|
+
label: React.ReactNode;
|
|
44
|
+
disabled?: boolean;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* One option inside a `CheckboxGroup`: a bordered label wrapping a native
|
|
48
|
+
* checkbox. Bordered rather than bare so a row of options reads as a set of
|
|
49
|
+
* targets, and so the whole chip is clickable rather than just the box.
|
|
50
|
+
*/
|
|
51
|
+
export declare function CheckboxGroupItem({ value, label, disabled, className, ...props }: CheckboxGroupItemProps): import("react/jsx-runtime").JSX.Element;
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
import * as React from "react";
|
|
4
|
+
import { cn } from "../utils/cn.js";
|
|
5
|
+
import { FieldGroup } from "./field-group.js";
|
|
6
|
+
import { useFormFieldProps } from "./form-field.js";
|
|
7
|
+
/**
|
|
8
|
+
* Styled native checkbox. Native on purpose: form participation, indeterminate
|
|
9
|
+
* support and platform semantics come from the browser, and only the box itself
|
|
10
|
+
* needs theming. Participates in `FormField` wiring like the other controls.
|
|
11
|
+
*/
|
|
12
|
+
export const Checkbox = React.forwardRef(({ className, ...props }, ref) => (_jsx("input", { ref: ref, type: "checkbox", className: cn("size-4 shrink-0 cursor-pointer rounded-[calc(var(--ssui-radius)/2)] border border-[color:var(--ssui-border)] accent-[color:var(--ssui-primary)] transition-shadow focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[color:var(--ssui-focus-ring)] focus-visible:ring-offset-2 focus-visible:ring-offset-[color:var(--ssui-bg)] disabled:cursor-not-allowed disabled:opacity-50", className), ...useFormFieldProps(props) })));
|
|
13
|
+
Checkbox.displayName = "Checkbox";
|
|
14
|
+
const CheckboxGroupContext = React.createContext(null);
|
|
15
|
+
/**
|
|
16
|
+
* Multi-select group: a `FieldGroup` whose options share one form field name, so
|
|
17
|
+
* the submission arrives as repeated entries under that name. The mirror image of
|
|
18
|
+
* `ChoiceCardGroup`, which is the single-select form of the same pattern.
|
|
19
|
+
*
|
|
20
|
+
* Selection is tracked in context rather than left to the DOM so the group can be
|
|
21
|
+
* driven as a controlled component, but the underlying inputs stay native — an
|
|
22
|
+
* uncontrolled group still submits correctly with no JavaScript involvement.
|
|
23
|
+
*/
|
|
24
|
+
export function CheckboxGroup({ legend, description, error, name, value, defaultValue, onValueChange, disabled, className, children, ...props }) {
|
|
25
|
+
const generatedName = React.useId();
|
|
26
|
+
const [uncontrolledValue, setUncontrolledValue] = React.useState(defaultValue ?? []);
|
|
27
|
+
const selected = value ?? uncontrolledValue;
|
|
28
|
+
const contextValue = React.useMemo(() => {
|
|
29
|
+
const selectedSet = new Set(selected);
|
|
30
|
+
return {
|
|
31
|
+
name: name ?? generatedName,
|
|
32
|
+
disabled,
|
|
33
|
+
isSelected: (option) => selectedSet.has(option),
|
|
34
|
+
toggle: (option) => {
|
|
35
|
+
const next = selectedSet.has(option)
|
|
36
|
+
? selected.filter((entry) => entry !== option)
|
|
37
|
+
: [...selected, option];
|
|
38
|
+
if (value === undefined)
|
|
39
|
+
setUncontrolledValue(next);
|
|
40
|
+
onValueChange?.(next);
|
|
41
|
+
},
|
|
42
|
+
};
|
|
43
|
+
}, [disabled, generatedName, name, onValueChange, selected, value]);
|
|
44
|
+
return (_jsx(FieldGroup, { description: description, error: error, legend: legend, className: className, ...props, children: _jsx("div", { className: "flex flex-wrap gap-2 pt-0.5", children: _jsx(CheckboxGroupContext.Provider, { value: contextValue, children: children }) }) }));
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* One option inside a `CheckboxGroup`: a bordered label wrapping a native
|
|
48
|
+
* checkbox. Bordered rather than bare so a row of options reads as a set of
|
|
49
|
+
* targets, and so the whole chip is clickable rather than just the box.
|
|
50
|
+
*/
|
|
51
|
+
export function CheckboxGroupItem({ value, label, disabled, className, ...props }) {
|
|
52
|
+
const group = React.useContext(CheckboxGroupContext);
|
|
53
|
+
if (!group) {
|
|
54
|
+
throw new Error("CheckboxGroupItem must be rendered inside a CheckboxGroup");
|
|
55
|
+
}
|
|
56
|
+
const checked = group.isSelected(value);
|
|
57
|
+
const isDisabled = disabled ?? group.disabled;
|
|
58
|
+
return (_jsxs("label", { className: cn("inline-flex cursor-pointer items-center gap-2 rounded-[var(--ssui-radius)] border px-2.5 py-1.5 text-sm transition-colors has-[:focus-visible]:ring-2 has-[:focus-visible]:ring-[color:var(--ssui-focus-ring)]", checked
|
|
59
|
+
? "border-[color:var(--ssui-border-strong)] bg-[color:var(--ssui-surface-muted)] text-[color:var(--ssui-text)]"
|
|
60
|
+
: "border-[color:var(--ssui-border)] text-[color:var(--ssui-text-muted)] hover:bg-[color:var(--ssui-overlay-hover)]", isDisabled && "cursor-not-allowed opacity-50", className), ...props, children: [_jsx(Checkbox, { checked: checked, disabled: isDisabled, name: group.name, onChange: () => group.toggle(value), value: value }), label] }));
|
|
61
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
export interface CollapsibleProps extends Omit<React.HTMLAttributes<HTMLDivElement>, "onToggle"> {
|
|
3
|
+
/** Controlled open state. */
|
|
4
|
+
open?: boolean;
|
|
5
|
+
/** Uncontrolled initial open state. */
|
|
6
|
+
defaultOpen?: boolean;
|
|
7
|
+
onOpenChange?: (open: boolean) => void;
|
|
8
|
+
/** Prevents the trigger from toggling the region. */
|
|
9
|
+
disabled?: boolean;
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Disclosure region: a trigger that shows and hides a block of content, used to
|
|
13
|
+
* keep secondary detail — an edit form, an advanced section — out of the way
|
|
14
|
+
* until it is asked for.
|
|
15
|
+
*
|
|
16
|
+
* The content stays mounted and is hidden with the `hidden` attribute rather than
|
|
17
|
+
* unmounted, so `aria-controls` always resolves to a real element and an open
|
|
18
|
+
* form's in-progress state survives a collapse.
|
|
19
|
+
*/
|
|
20
|
+
export declare function Collapsible({ open, defaultOpen, onOpenChange, disabled, className, children, ...props }: CollapsibleProps): import("react/jsx-runtime").JSX.Element;
|
|
21
|
+
export interface CollapsibleTriggerProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
|
22
|
+
/** Render the consumer's own element (e.g. a `Button`) as the trigger. */
|
|
23
|
+
asChild?: boolean;
|
|
24
|
+
/** Children rendered while the region is open, if it differs from the closed label. */
|
|
25
|
+
openChildren?: React.ReactNode;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Toggles the enclosing `Collapsible`. Always a real `button` (or the consumer's
|
|
29
|
+
* element via `asChild`) carrying `aria-expanded`/`aria-controls`, so the
|
|
30
|
+
* relationship between control and region is announced rather than implied by
|
|
31
|
+
* position.
|
|
32
|
+
*/
|
|
33
|
+
export declare const CollapsibleTrigger: React.ForwardRefExoticComponent<CollapsibleTriggerProps & React.RefAttributes<HTMLButtonElement>>;
|
|
34
|
+
export interface CollapsibleContentProps extends React.HTMLAttributes<HTMLDivElement> {
|
|
35
|
+
}
|
|
36
|
+
/** The region shown and hidden by `CollapsibleTrigger`. */
|
|
37
|
+
export declare function CollapsibleContent({ className, children, ...props }: CollapsibleContentProps): import("react/jsx-runtime").JSX.Element;
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
3
|
+
import { Slot } from "@radix-ui/react-slot";
|
|
4
|
+
import * as React from "react";
|
|
5
|
+
import { cn } from "../utils/cn.js";
|
|
6
|
+
const CollapsibleContext = React.createContext(null);
|
|
7
|
+
function useCollapsibleContext(component) {
|
|
8
|
+
const context = React.useContext(CollapsibleContext);
|
|
9
|
+
if (!context) {
|
|
10
|
+
throw new Error(`${component} must be rendered inside a Collapsible`);
|
|
11
|
+
}
|
|
12
|
+
return context;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Disclosure region: a trigger that shows and hides a block of content, used to
|
|
16
|
+
* keep secondary detail — an edit form, an advanced section — out of the way
|
|
17
|
+
* until it is asked for.
|
|
18
|
+
*
|
|
19
|
+
* The content stays mounted and is hidden with the `hidden` attribute rather than
|
|
20
|
+
* unmounted, so `aria-controls` always resolves to a real element and an open
|
|
21
|
+
* form's in-progress state survives a collapse.
|
|
22
|
+
*/
|
|
23
|
+
export function Collapsible({ open, defaultOpen = false, onOpenChange, disabled, className, children, ...props }) {
|
|
24
|
+
const contentId = React.useId();
|
|
25
|
+
const [uncontrolledOpen, setUncontrolledOpen] = React.useState(defaultOpen);
|
|
26
|
+
const isOpen = open ?? uncontrolledOpen;
|
|
27
|
+
const contextValue = React.useMemo(() => ({
|
|
28
|
+
open: isOpen,
|
|
29
|
+
contentId,
|
|
30
|
+
disabled,
|
|
31
|
+
setOpen: (next) => {
|
|
32
|
+
if (open === undefined)
|
|
33
|
+
setUncontrolledOpen(next);
|
|
34
|
+
onOpenChange?.(next);
|
|
35
|
+
},
|
|
36
|
+
}), [contentId, disabled, isOpen, onOpenChange, open]);
|
|
37
|
+
return (_jsx("div", { className: cn(className), "data-state": isOpen ? "open" : "closed", ...props, children: _jsx(CollapsibleContext.Provider, { value: contextValue, children: children }) }));
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Toggles the enclosing `Collapsible`. Always a real `button` (or the consumer's
|
|
41
|
+
* element via `asChild`) carrying `aria-expanded`/`aria-controls`, so the
|
|
42
|
+
* relationship between control and region is announced rather than implied by
|
|
43
|
+
* position.
|
|
44
|
+
*/
|
|
45
|
+
export const CollapsibleTrigger = React.forwardRef(({ asChild = false, children, openChildren, onClick, ...props }, ref) => {
|
|
46
|
+
const { open, setOpen, contentId, disabled } = useCollapsibleContext("CollapsibleTrigger");
|
|
47
|
+
const Comp = asChild ? Slot : "button";
|
|
48
|
+
return (_jsx(Comp, { ref: ref, "aria-controls": contentId, "aria-expanded": open, "data-state": open ? "open" : "closed", disabled: disabled, onClick: (event) => {
|
|
49
|
+
onClick?.(event);
|
|
50
|
+
if (event.defaultPrevented || disabled)
|
|
51
|
+
return;
|
|
52
|
+
setOpen(!open);
|
|
53
|
+
}, type: asChild ? undefined : "button", ...props, children: open && openChildren !== undefined ? openChildren : children }));
|
|
54
|
+
});
|
|
55
|
+
CollapsibleTrigger.displayName = "CollapsibleTrigger";
|
|
56
|
+
/** The region shown and hidden by `CollapsibleTrigger`. */
|
|
57
|
+
export function CollapsibleContent({ className, children, ...props }) {
|
|
58
|
+
const { open, contentId } = useCollapsibleContext("CollapsibleContent");
|
|
59
|
+
return (_jsx("div", { className: cn(className), "data-state": open ? "open" : "closed", hidden: !open, id: contentId, ...props, children: children }));
|
|
60
|
+
}
|
|
@@ -4,6 +4,23 @@ export interface ComboboxOption {
|
|
|
4
4
|
label: string;
|
|
5
5
|
/** Optional trailing content (e.g. a GMT offset) shown right-aligned. */
|
|
6
6
|
trailing?: React.ReactNode;
|
|
7
|
+
/**
|
|
8
|
+
* Optional secondary text shown next to the label, in the reading order a
|
|
9
|
+
* subtitle would take. Distinct from `trailing`, which is right-aligned and
|
|
10
|
+
* monospaced; an option may carry both.
|
|
11
|
+
*/
|
|
12
|
+
description?: React.ReactNode;
|
|
13
|
+
/**
|
|
14
|
+
* Optional heading this option sits under. Consecutive options declaring the
|
|
15
|
+
* same group collapse into one heading, exactly as `<optgroup>` does — the
|
|
16
|
+
* component never reorders, so interleaved groups (`A, B, A`) render three
|
|
17
|
+
* headings, not two. Sort the list before passing it if that is not wanted.
|
|
18
|
+
*
|
|
19
|
+
* Grouped and ungrouped options may be mixed: an option declaring no group
|
|
20
|
+
* ends the run above it and renders outside every group, the way an
|
|
21
|
+
* `<option>` following an `</optgroup>` sits outside that group.
|
|
22
|
+
*/
|
|
23
|
+
group?: string;
|
|
7
24
|
}
|
|
8
25
|
export interface ComboboxProps {
|
|
9
26
|
/** Full option list; the component filters it against the typed query. */
|
|
@@ -55,9 +55,26 @@ export const Combobox = React.forwardRef(({ options, value, onValueChange, filte
|
|
|
55
55
|
: filtered.length - 1
|
|
56
56
|
: (activeIndex + delta + filtered.length) % filtered.length;
|
|
57
57
|
setActiveIndex(next);
|
|
58
|
-
listRef.current
|
|
59
|
-
|
|
60
|
-
|
|
58
|
+
const element = listRef.current?.querySelector(`[data-index="${next}"]`);
|
|
59
|
+
// Arriving at the first option of a group brings that group's heading into
|
|
60
|
+
// view as well as the option, so a keyboard user entering a group can see
|
|
61
|
+
// which one they are in. Both, and in this order — scrolling only the
|
|
62
|
+
// option leaves the 16px heading clipped above the scrollport, and
|
|
63
|
+
// scrolling only the heading is worse, because entering a group from below
|
|
64
|
+
// aligns the heading's bottom edge with the scrollport's and leaves the
|
|
65
|
+
// newly highlighted option out of view entirely.
|
|
66
|
+
//
|
|
67
|
+
// Two `nearest` calls settle both directions. Downward: the first brings
|
|
68
|
+
// the heading to the bottom edge, the second scrolls one option further,
|
|
69
|
+
// leaving heading and option both visible. Upward: the first aligns the
|
|
70
|
+
// heading to the top and the second is a no-op, the option having come
|
|
71
|
+
// with it. Never losing the highlight is the constraint; showing the
|
|
72
|
+
// heading is the preference.
|
|
73
|
+
const previous = element?.previousElementSibling ?? null;
|
|
74
|
+
if (previous?.getAttribute("role") === "presentation") {
|
|
75
|
+
previous.scrollIntoView({ block: "nearest" });
|
|
76
|
+
}
|
|
77
|
+
element?.scrollIntoView({ block: "nearest" });
|
|
61
78
|
}
|
|
62
79
|
function handleKeyDown(event) {
|
|
63
80
|
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
|
|
@@ -70,10 +87,18 @@ export const Combobox = React.forwardRef(({ options, value, onValueChange, filte
|
|
|
70
87
|
return;
|
|
71
88
|
}
|
|
72
89
|
if (event.key === "Enter") {
|
|
73
|
-
|
|
74
|
-
|
|
90
|
+
// An open popup owns Enter, whether or not anything is highlighted.
|
|
91
|
+
// Committing when there is an active option is the obvious half; the
|
|
92
|
+
// other half is swallowing the key when there is not, so that Enter on
|
|
93
|
+
// an uncommitted query cannot reach the form behind the control. A
|
|
94
|
+
// consumer whose only field is this combobox has no submit button to
|
|
95
|
+
// absorb it, and HTML would otherwise implicitly submit the form with
|
|
96
|
+
// whatever the hidden input last held.
|
|
97
|
+
if (!open)
|
|
98
|
+
return;
|
|
99
|
+
event.preventDefault();
|
|
100
|
+
if (activeOption)
|
|
75
101
|
commit(activeOption);
|
|
76
|
-
}
|
|
77
102
|
return;
|
|
78
103
|
}
|
|
79
104
|
if (event.key === "Escape") {
|
|
@@ -83,6 +108,24 @@ export const Combobox = React.forwardRef(({ options, value, onValueChange, filte
|
|
|
83
108
|
}
|
|
84
109
|
}
|
|
85
110
|
}
|
|
111
|
+
const rows = [];
|
|
112
|
+
filtered.forEach((option, index) => {
|
|
113
|
+
const last = rows[rows.length - 1];
|
|
114
|
+
if (option.group === undefined) {
|
|
115
|
+
rows.push({ kind: "option", entry: { option, index } });
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
if (last?.kind === "group" && last.group === option.group) {
|
|
119
|
+
last.entries.push({ option, index });
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
rows.push({ kind: "group", group: option.group, entries: [{ option, index }] });
|
|
123
|
+
});
|
|
124
|
+
function renderOption({ option, index }) {
|
|
125
|
+
return (_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 && "bg-[color:var(--ssui-overlay-hover)]", option.value === value
|
|
126
|
+
? "bg-[color:var(--ssui-surface-muted)] text-[color:var(--ssui-text)]"
|
|
127
|
+
: "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: [option.description !== undefined ? (_jsxs("span", { className: "flex min-w-0 items-baseline gap-1.5", children: [_jsx("span", { className: "truncate", children: option.label }), _jsx("span", { className: "truncate text-xs text-[color:var(--ssui-text-subtle)]", children: option.description })] })) : (_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));
|
|
128
|
+
}
|
|
86
129
|
return (_jsxs("div", { className: cn("relative", className), onBlur: (event) => {
|
|
87
130
|
if (!event.currentTarget.contains(event.relatedTarget)) {
|
|
88
131
|
close();
|
|
@@ -95,9 +138,15 @@ export const Combobox = React.forwardRef(({ options, value, onValueChange, filte
|
|
|
95
138
|
setQuery(null);
|
|
96
139
|
openList();
|
|
97
140
|
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:
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
141
|
+
}, 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: rows.length > 0 ? (rows.map((row, rowIndex) => row.kind === "option" ? (renderOption(row.entry)) : (
|
|
142
|
+
// A run is wrapped in `role="group"` labelled by its heading —
|
|
143
|
+
// the APG grouped-listbox shape. Without it the grouping is
|
|
144
|
+
// conveyed to sighted users only: a roleless heading is not in
|
|
145
|
+
// the listbox's content model, so a screen-reader user hears a
|
|
146
|
+
// flat list of options and never learns which kind each is.
|
|
147
|
+
// The heading takes `role="presentation"` so it stays out of
|
|
148
|
+
// that content model while `aria-labelledby` still names the
|
|
149
|
+
// group from its text.
|
|
150
|
+
_jsxs("div", { "aria-labelledby": `${id}-group-${rowIndex}`, role: "group", children: [_jsx("div", { className: cn("px-2 pb-0.5 pt-4 text-xs font-medium uppercase tracking-wide text-[color:var(--ssui-text-subtle)]", rowIndex === 0 && "pt-1"), id: `${id}-group-${rowIndex}`, role: "presentation", children: row.group }), row.entries.map(renderOption)] }, `group-${rowIndex}`)))) : (_jsx("div", { className: "px-2 py-1.5 text-sm text-[color:var(--ssui-text-subtle)]", children: emptyMessage })) })) : null] }));
|
|
102
151
|
});
|
|
103
152
|
Combobox.displayName = "Combobox";
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
export interface FieldGroupProps extends Omit<React.FieldsetHTMLAttributes<HTMLFieldSetElement>, "children"> {
|
|
3
|
+
/** Group label, rendered as the fieldset's legend. */
|
|
4
|
+
legend: React.ReactNode;
|
|
5
|
+
/** Optional supporting copy shown under the legend. */
|
|
6
|
+
description?: React.ReactNode;
|
|
7
|
+
/** Error message; when set the group is marked invalid. */
|
|
8
|
+
error?: React.ReactNode;
|
|
9
|
+
/** The controls belonging to this group. */
|
|
10
|
+
children: React.ReactNode;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* The group analogue of `FormField`: a `fieldset`/`legend` wrapper with the same
|
|
14
|
+
* label/description/error typography, for a set of controls that answer one
|
|
15
|
+
* question together (checkbox groups, radio groups, composite inputs).
|
|
16
|
+
*
|
|
17
|
+
* `FormField` is wrong for these — its `label`/`htmlFor` pair can only point at a
|
|
18
|
+
* single control, so with several controls the label either names just one of them
|
|
19
|
+
* or names nothing. `legend` labels the whole set, which is what a screen reader
|
|
20
|
+
* needs to announce before reading the options.
|
|
21
|
+
*/
|
|
22
|
+
export declare function FieldGroup({ legend, description, error, className, children, ...props }: FieldGroupProps): import("react/jsx-runtime").JSX.Element;
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import * as React from "react";
|
|
3
|
+
import { cn } from "../utils/cn.js";
|
|
4
|
+
/**
|
|
5
|
+
* The group analogue of `FormField`: a `fieldset`/`legend` wrapper with the same
|
|
6
|
+
* label/description/error typography, for a set of controls that answer one
|
|
7
|
+
* question together (checkbox groups, radio groups, composite inputs).
|
|
8
|
+
*
|
|
9
|
+
* `FormField` is wrong for these — its `label`/`htmlFor` pair can only point at a
|
|
10
|
+
* single control, so with several controls the label either names just one of them
|
|
11
|
+
* or names nothing. `legend` labels the whole set, which is what a screen reader
|
|
12
|
+
* needs to announce before reading the options.
|
|
13
|
+
*/
|
|
14
|
+
export function FieldGroup({ legend, description, error, className, children, ...props }) {
|
|
15
|
+
const id = React.useId();
|
|
16
|
+
const descriptionId = `${id}-description`;
|
|
17
|
+
const errorId = `${id}-error`;
|
|
18
|
+
const describedBy = [description ? descriptionId : null, error ? errorId : null]
|
|
19
|
+
.filter(Boolean)
|
|
20
|
+
.join(" ") || undefined;
|
|
21
|
+
return (_jsxs("fieldset", { "aria-describedby": describedBy, "aria-invalid": error ? true : undefined, className: cn("min-w-0 space-y-1.5", className), ...props, children: [_jsx("legend", { className: "text-sm font-medium text-[color:var(--ssui-text)]", children: legend }), description ? (_jsx("p", { className: "text-xs text-[color:var(--ssui-text-muted)]", id: descriptionId, children: description })) : null, children, error ? (_jsx("p", { className: "text-xs text-[color:var(--ssui-destructive)]", id: errorId, children: error })) : null] }));
|
|
22
|
+
}
|
|
@@ -4,9 +4,12 @@ export * from "./avatar.js";
|
|
|
4
4
|
export * from "./badge.js";
|
|
5
5
|
export * from "./brand-icons.js";
|
|
6
6
|
export * from "./button.js";
|
|
7
|
+
export * from "./checkbox.js";
|
|
7
8
|
export * from "./choice-card.js";
|
|
9
|
+
export * from "./collapsible.js";
|
|
8
10
|
export * from "./combobox.js";
|
|
9
11
|
export * from "./confirm-dialog.js";
|
|
12
|
+
export * from "./field-group.js";
|
|
10
13
|
export * from "./form-field.js";
|
|
11
14
|
export * from "./copy-field.js";
|
|
12
15
|
export * from "./dialog.js";
|
|
@@ -23,6 +26,7 @@ export * from "./overflow-menu.js";
|
|
|
23
26
|
export * from "./page-header.js";
|
|
24
27
|
export * from "./rail-toggle.js";
|
|
25
28
|
export * from "./request-access.js";
|
|
29
|
+
export * from "./role-menu.js";
|
|
26
30
|
export * from "./scroll-area.js";
|
|
27
31
|
export * from "./search-input.js";
|
|
28
32
|
export * from "./select.js";
|
package/dist/components/index.js
CHANGED
|
@@ -4,9 +4,12 @@ export * from "./avatar.js";
|
|
|
4
4
|
export * from "./badge.js";
|
|
5
5
|
export * from "./brand-icons.js";
|
|
6
6
|
export * from "./button.js";
|
|
7
|
+
export * from "./checkbox.js";
|
|
7
8
|
export * from "./choice-card.js";
|
|
9
|
+
export * from "./collapsible.js";
|
|
8
10
|
export * from "./combobox.js";
|
|
9
11
|
export * from "./confirm-dialog.js";
|
|
12
|
+
export * from "./field-group.js";
|
|
10
13
|
export * from "./form-field.js";
|
|
11
14
|
export * from "./copy-field.js";
|
|
12
15
|
export * from "./dialog.js";
|
|
@@ -23,6 +26,7 @@ export * from "./overflow-menu.js";
|
|
|
23
26
|
export * from "./page-header.js";
|
|
24
27
|
export * from "./rail-toggle.js";
|
|
25
28
|
export * from "./request-access.js";
|
|
29
|
+
export * from "./role-menu.js";
|
|
26
30
|
export * from "./scroll-area.js";
|
|
27
31
|
export * from "./search-input.js";
|
|
28
32
|
export * from "./select.js";
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
export interface RoleMenuOption {
|
|
3
|
+
value: string;
|
|
4
|
+
label: string;
|
|
5
|
+
/** One line on what the role grants. Shown in the popup, never on the trigger. */
|
|
6
|
+
description: string;
|
|
7
|
+
/** Optional leading icon, sized by the caller (16px reads correctly here). */
|
|
8
|
+
icon?: React.ReactNode;
|
|
9
|
+
/**
|
|
10
|
+
* Withholds this one option while leaving the rest selectable, and says why —
|
|
11
|
+
* a workspace's last admin, a plan the account cannot downgrade to. The reason
|
|
12
|
+
* is shown in place of the description.
|
|
13
|
+
*
|
|
14
|
+
* Presence is what withholds: there is no separate `disabled` flag, because an
|
|
15
|
+
* option withheld without an explanation is a dead row that answers none of
|
|
16
|
+
* the question the reader now has. Pass `undefined` to leave it selectable, so
|
|
17
|
+
* a computed case stays one expression:
|
|
18
|
+
* `unavailableReason: isLastAdmin ? "…" : undefined`.
|
|
19
|
+
*/
|
|
20
|
+
unavailableReason?: string;
|
|
21
|
+
}
|
|
22
|
+
export interface RoleMenuDestructiveItem {
|
|
23
|
+
label: string;
|
|
24
|
+
onSelect: () => void | Promise<void>;
|
|
25
|
+
icon?: React.ReactNode;
|
|
26
|
+
disabled?: boolean;
|
|
27
|
+
}
|
|
28
|
+
export interface RoleMenuProps {
|
|
29
|
+
options: ReadonlyArray<RoleMenuOption>;
|
|
30
|
+
/** Selected option value. The component never changes this itself. */
|
|
31
|
+
value: string;
|
|
32
|
+
/** Called with the chosen value; never fired for the option already selected. */
|
|
33
|
+
onValueChange: (value: string) => void | Promise<void>;
|
|
34
|
+
/** Heading above the options, e.g. "Select role". Omitted when absent. */
|
|
35
|
+
heading?: React.ReactNode;
|
|
36
|
+
/** Rendered below the options, separated — typically a documentation link. */
|
|
37
|
+
footer?: React.ReactNode;
|
|
38
|
+
/** Rendered last, separated, styled as destructive. Typically "Remove from workspace". */
|
|
39
|
+
destructiveItem?: RoleMenuDestructiveItem;
|
|
40
|
+
/**
|
|
41
|
+
* Submits the selected value with the surrounding form through a hidden input.
|
|
42
|
+
* Omit it in a list that applies on selection: a Radix menu is not a form
|
|
43
|
+
* control, and an unconditional hidden input would litter every such row.
|
|
44
|
+
*/
|
|
45
|
+
name?: string;
|
|
46
|
+
/** Disables the whole control. To withhold one option, use its `unavailableReason`. */
|
|
47
|
+
disabled?: boolean;
|
|
48
|
+
/**
|
|
49
|
+
* Which edge of the trigger the popup aligns to. Defaults to `"start"`; a
|
|
50
|
+
* right-aligned trigger (a table's last column) wants `"end"` so the popup
|
|
51
|
+
* opens inward rather than relying on collision detection to rescue it.
|
|
52
|
+
*/
|
|
53
|
+
align?: "start" | "center" | "end";
|
|
54
|
+
/** Trigger id. Supplied by an enclosing `FormField` when there is one. */
|
|
55
|
+
id?: string;
|
|
56
|
+
/**
|
|
57
|
+
* Names the trigger's purpose, e.g. `"Role for Ada Lovelace"` in a member
|
|
58
|
+
* list. The selected label is appended to whatever is passed — the accessible
|
|
59
|
+
* name has to contain the trigger's visible text for voice control (WCAG
|
|
60
|
+
* 2.5.3), and a row's own name has to say which row it belongs to. Defaults to
|
|
61
|
+
* `"Change role (current: …)"`, and is dropped entirely inside a `FormField`,
|
|
62
|
+
* whose label already names the control.
|
|
63
|
+
*/
|
|
64
|
+
"aria-label"?: string;
|
|
65
|
+
"aria-describedby"?: string;
|
|
66
|
+
"aria-invalid"?: React.AriaAttributes["aria-invalid"];
|
|
67
|
+
/** Open the menu by default (uncontrolled). Useful for tests and controlled-open consumers. */
|
|
68
|
+
defaultOpen?: boolean;
|
|
69
|
+
/** Extra classes for the trigger. */
|
|
70
|
+
className?: string;
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Single choice from a small closed set where each option needs a sentence.
|
|
74
|
+
*
|
|
75
|
+
* The third answer to "pick one of a few" in this package, and the one to reach
|
|
76
|
+
* for when the options need explaining: `Select` is a native `<select>` whose
|
|
77
|
+
* options may hold only text, and `Combobox` is a filterable input for lists long
|
|
78
|
+
* enough to search. A role set is neither — two or three options, each carrying a
|
|
79
|
+
* meaning the reader cannot infer from its name.
|
|
80
|
+
*
|
|
81
|
+
* Options are radio items rather than plain menu items, so assistive technology
|
|
82
|
+
* announces the option set and which member of it is current instead of leaving
|
|
83
|
+
* the checkmark as a purely visual cue. The check is drawn here rather than by
|
|
84
|
+
* Radix's `ItemIndicator` because it sits opposite a two-line label, where the
|
|
85
|
+
* indicator's leading placement fights the description.
|
|
86
|
+
*
|
|
87
|
+
* The component owns no invariant. Which option is unavailable and why is the
|
|
88
|
+
* consumer's to say, and a consumer that renders a stale `unavailableReason` has
|
|
89
|
+
* a display bug rather than a hole — the server it submits to is expected to
|
|
90
|
+
* refuse the change on its own.
|
|
91
|
+
*/
|
|
92
|
+
export declare function RoleMenu({ options, value, onValueChange, heading, footer, destructiveItem, name, disabled, align, defaultOpen, className, ...ariaProps }: RoleMenuProps): import("react/jsx-runtime").JSX.Element;
|
|
93
|
+
export declare namespace RoleMenu {
|
|
94
|
+
var displayName: string;
|
|
95
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
3
|
+
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
|
|
4
|
+
import { Check, ChevronDown } from "lucide-react";
|
|
5
|
+
import * as React from "react";
|
|
6
|
+
import { cn } from "../utils/cn.js";
|
|
7
|
+
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuRadioGroup, DropdownMenuSeparator, DropdownMenuTrigger, } from "./dropdown-menu.js";
|
|
8
|
+
import { useFormField, useFormFieldProps } from "./form-field.js";
|
|
9
|
+
/**
|
|
10
|
+
* Single choice from a small closed set where each option needs a sentence.
|
|
11
|
+
*
|
|
12
|
+
* The third answer to "pick one of a few" in this package, and the one to reach
|
|
13
|
+
* for when the options need explaining: `Select` is a native `<select>` whose
|
|
14
|
+
* options may hold only text, and `Combobox` is a filterable input for lists long
|
|
15
|
+
* enough to search. A role set is neither — two or three options, each carrying a
|
|
16
|
+
* meaning the reader cannot infer from its name.
|
|
17
|
+
*
|
|
18
|
+
* Options are radio items rather than plain menu items, so assistive technology
|
|
19
|
+
* announces the option set and which member of it is current instead of leaving
|
|
20
|
+
* the checkmark as a purely visual cue. The check is drawn here rather than by
|
|
21
|
+
* Radix's `ItemIndicator` because it sits opposite a two-line label, where the
|
|
22
|
+
* indicator's leading placement fights the description.
|
|
23
|
+
*
|
|
24
|
+
* The component owns no invariant. Which option is unavailable and why is the
|
|
25
|
+
* consumer's to say, and a consumer that renders a stale `unavailableReason` has
|
|
26
|
+
* a display bug rather than a hole — the server it submits to is expected to
|
|
27
|
+
* refuse the change on its own.
|
|
28
|
+
*/
|
|
29
|
+
export function RoleMenu({ options, value, onValueChange, heading, footer, destructiveItem, name, disabled, align = "start", defaultOpen, className, ...ariaProps }) {
|
|
30
|
+
const selected = options.find((option) => option.value === value);
|
|
31
|
+
const selectedLabel = selected?.label ?? value;
|
|
32
|
+
const headingId = React.useId();
|
|
33
|
+
// An enclosing `FormField` owns the trigger's id and error wiring, the same
|
|
34
|
+
// way it owns `Input`'s and `Combobox`'s — without this the field's label
|
|
35
|
+
// points at an id no element carries and its error is never announced.
|
|
36
|
+
const field = useFormField();
|
|
37
|
+
const { "aria-label": ariaLabel, ...fieldProps } = useFormFieldProps(ariaProps);
|
|
38
|
+
// A row of ten triggers all named "Member" says nothing about what they do or
|
|
39
|
+
// whose role they change, so the name carries purpose and current value —
|
|
40
|
+
// `WorkspaceSwitcher`'s reasoning. A `FormField`'s visible label does that job
|
|
41
|
+
// better, and an `aria-label` would override it, so inside one there is none.
|
|
42
|
+
const triggerLabel = ariaLabel
|
|
43
|
+
? `${ariaLabel} (current: ${selectedLabel})`
|
|
44
|
+
: field
|
|
45
|
+
? undefined
|
|
46
|
+
: `Change role (current: ${selectedLabel})`;
|
|
47
|
+
return (_jsxs(_Fragment, { children: [name ? _jsx("input", { disabled: disabled, name: name, type: "hidden", value: value }) : null, _jsxs(DropdownMenu, { defaultOpen: defaultOpen, children: [_jsx(DropdownMenuTrigger, { asChild: true, children: _jsxs("button", { ...fieldProps, "aria-label": triggerLabel, className: cn("inline-flex h-8 items-center gap-1.5 rounded-[var(--ssui-radius-sm)] px-2 text-sm text-[color:var(--ssui-text)] transition-colors", "hover:bg-[color:var(--ssui-overlay-hover)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[color:var(--ssui-focus-ring)] focus-visible:ring-offset-2 focus-visible:ring-offset-[color:var(--ssui-bg)]", "disabled:pointer-events-none disabled:opacity-70", className), disabled: disabled, type: "button", children: [selectedLabel, _jsx(ChevronDown, { "aria-hidden": "true", className: "size-4 text-[color:var(--ssui-text-subtle)]" })] }) }), _jsxs(DropdownMenuContent, { align: align, className: "w-72 p-1.5", collisionPadding: 8, children: [heading ? (_jsx(DropdownMenuLabel, { className: "px-2 py-1", id: headingId, children: heading })) : null, _jsx(DropdownMenuRadioGroup, { "aria-labelledby": heading ? headingId : undefined, onValueChange: (next) => {
|
|
48
|
+
if (next !== value)
|
|
49
|
+
void onValueChange(next);
|
|
50
|
+
}, value: value, children: options.map((option) => (_jsxs(DropdownMenuPrimitive.RadioItem, { className: cn("relative flex cursor-default select-none items-start gap-2.5 rounded-[var(--ssui-radius-sm)] px-2 py-2 text-sm outline-none transition-colors", "focus:bg-[color:var(--ssui-overlay-hover)] data-[highlighted]:bg-[color:var(--ssui-overlay-hover)]", "data-[disabled]:pointer-events-none data-[disabled]:opacity-50"), disabled: option.unavailableReason !== undefined, value: option.value, children: [option.icon ? (_jsx("span", { "aria-hidden": "true", className: "mt-0.5 shrink-0 text-[color:var(--ssui-text-subtle)]", children: option.icon })) : null, _jsxs("span", { className: "min-w-0 flex-1", children: [_jsx("span", { className: "block font-medium", children: option.label }), _jsx("span", { className: "mt-0.5 block text-xs leading-snug text-[color:var(--ssui-text-muted)]", children: option.unavailableReason ?? option.description })] }), option.value === value ? (_jsx(Check, { "aria-hidden": "true", className: "mt-0.5 size-4 shrink-0 text-[color:var(--ssui-text)]" })) : null] }, option.value))) }), destructiveItem ? (_jsxs(_Fragment, { children: [_jsx(DropdownMenuSeparator, {}), _jsxs(DropdownMenuItem, { className: "gap-2.5 px-2 py-2 text-[color:var(--ssui-destructive)] focus:text-[color:var(--ssui-destructive)] data-[highlighted]:text-[color:var(--ssui-destructive)]", disabled: destructiveItem.disabled, onSelect: () => void destructiveItem.onSelect(), children: [destructiveItem.icon ? (_jsx("span", { "aria-hidden": "true", className: "shrink-0", children: destructiveItem.icon })) : null, destructiveItem.label] })] })) : null, footer ? (_jsxs(_Fragment, { children: [_jsx(DropdownMenuSeparator, {}), _jsx("div", { className: "px-2 py-1.5 text-xs text-[color:var(--ssui-text-muted)]", children: footer })] })) : null] })] })] }));
|
|
51
|
+
}
|
|
52
|
+
RoleMenu.displayName = "RoleMenu";
|
|
@@ -23,6 +23,13 @@ export const Switch = React.forwardRef(({ checked, defaultChecked = false, onChe
|
|
|
23
23
|
}
|
|
24
24
|
return (_jsxs(_Fragment, { children: [_jsx("button", { ref: ref, type: "button", role: "switch", "aria-checked": isChecked, "data-state": isChecked ? "checked" : "unchecked", disabled: disabled, onClick: handleClick, className: cn("inline-flex h-6 w-10 shrink-0 items-center rounded-full border border-transparent p-0.5 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[color:var(--ssui-focus-ring)] focus-visible:ring-offset-2 focus-visible:ring-offset-[color:var(--ssui-bg)] disabled:cursor-not-allowed disabled:opacity-50", isChecked
|
|
25
25
|
? "bg-[color:var(--ssui-switch-track-checked,var(--ssui-primary))]"
|
|
26
|
-
: "bg-[color:var(--ssui-switch-track,var(--ssui-border))]", className), ...useFormFieldProps(props), children: _jsx("span", { "aria-hidden": "true", className: cn("block size-5 rounded-full
|
|
26
|
+
: "bg-[color:var(--ssui-switch-track,var(--ssui-border))]", className), ...useFormFieldProps(props), children: _jsx("span", { "aria-hidden": "true", className: cn("block size-5 rounded-full shadow-sm transition-transform",
|
|
27
|
+
// The thumb is themed per state. A single thumb color only works when the
|
|
28
|
+
// checked track (the primary) and the unchecked track sit on the same side
|
|
29
|
+
// of the lightness scale; a product whose primary is near-white gets a white
|
|
30
|
+
// thumb on a near-white track and the control stops reading as on or off.
|
|
31
|
+
isChecked
|
|
32
|
+
? "bg-[color:var(--ssui-switch-thumb-checked,var(--ssui-switch-thumb,#ffffff))] translate-x-4"
|
|
33
|
+
: "bg-[color:var(--ssui-switch-thumb,#ffffff)] translate-x-0") }) }), name ? (_jsx("input", { type: "checkbox", "aria-hidden": "true", tabIndex: -1, className: "sr-only", checked: isChecked, disabled: disabled, name: name, value: value, readOnly: true })) : null] }));
|
|
27
34
|
});
|
|
28
35
|
Switch.displayName = "Switch";
|
package/dist/styles/index.css
CHANGED
|
@@ -23,6 +23,9 @@
|
|
|
23
23
|
--ssui-switch-track: var(--ssui-border);
|
|
24
24
|
--ssui-switch-track-checked: var(--ssui-primary);
|
|
25
25
|
--ssui-switch-thumb: #ffffff;
|
|
26
|
+
/* Thumb color while checked. Defaults to the unchecked thumb; override when the
|
|
27
|
+
checked track (--ssui-switch-track-checked) is too light for a white thumb. */
|
|
28
|
+
--ssui-switch-thumb-checked: #ffffff;
|
|
26
29
|
--ssui-radius-sm: 0.25rem;
|
|
27
30
|
--ssui-radius: 0.375rem;
|
|
28
31
|
--ssui-radius-lg: 0.5rem;
|
package/package.json
CHANGED