@forwardreach/saas-ui 0.10.4 → 0.12.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 +253 -34
- package/dist/components/audit-log.js +8 -3
- package/dist/components/checkbox.js +1 -1
- package/dist/components/choice-card.d.ts +29 -7
- package/dist/components/choice-card.js +92 -12
- package/dist/components/combobox.d.ts +60 -24
- package/dist/components/combobox.js +123 -71
- package/dist/components/index.d.ts +2 -0
- package/dist/components/index.js +2 -0
- package/dist/components/input.js +1 -1
- package/dist/components/option-list.d.ts +140 -0
- package/dist/components/option-list.js +142 -0
- package/dist/components/popover.d.ts +23 -0
- package/dist/components/popover.js +32 -0
- package/dist/components/role-menu.d.ts +7 -5
- package/dist/components/role-menu.js +7 -5
- package/dist/components/select-menu.d.ts +128 -0
- package/dist/components/select-menu.js +219 -0
- package/dist/components/select.d.ts +14 -1
- package/dist/components/select.js +14 -1
- package/dist/components/textarea.js +1 -1
- package/dist/components/time-zone-field.d.ts +11 -2
- package/dist/components/time-zone-field.js +25 -15
- package/package.json +4 -2
|
@@ -3,10 +3,23 @@ import { ChevronDown } from "lucide-react";
|
|
|
3
3
|
import * as React from "react";
|
|
4
4
|
import { cn } from "../utils/cn.js";
|
|
5
5
|
import { useFormFieldProps } from "./form-field.js";
|
|
6
|
+
// Height, text, padding and chevron inset are held together per size rather
|
|
7
|
+
// than split across rules, because the chevron's `right` has to track the
|
|
8
|
+
// select's right padding — separate them and they drift.
|
|
9
|
+
const SIZE_CLASSES = {
|
|
10
|
+
md: { select: "h-10 py-2 pl-3 pr-9 text-sm", chevron: "right-3 size-4" },
|
|
11
|
+
sm: { select: "h-8 py-1 pl-2.5 pr-7 text-xs", chevron: "right-2 size-3.5" },
|
|
12
|
+
};
|
|
6
13
|
/**
|
|
7
14
|
* Styled native `<select>` matching the shared `Input` look, with a trailing
|
|
8
15
|
* chevron. Native on purpose: correct mobile behavior and form participation
|
|
9
16
|
* with zero positioning code. Participates in `FormField` wiring.
|
|
17
|
+
*
|
|
18
|
+
* The default for a bounded field in an ordinary form. Its popup belongs to the
|
|
19
|
+
* platform and cannot be themed past its background colour; where that popup is
|
|
20
|
+
* the whole of what the reader sees — a choice presented as inline text — reach
|
|
21
|
+
* for `SelectMenu` instead. The four single-choice controls and which case each
|
|
22
|
+
* answers are tabled once, in `docs/packages/saas-ui.md`.
|
|
10
23
|
*/
|
|
11
|
-
export const Select = React.forwardRef(({ className, children, ...props }, ref) => (_jsxs("div", { className: cn("relative", className), children: [_jsx("select", { ref: ref, className: "flex
|
|
24
|
+
export const Select = React.forwardRef(({ className, children, size = "md", ...props }, ref) => (_jsxs("div", { className: cn("relative", className), children: [_jsx("select", { ref: ref, className: cn("flex w-full cursor-pointer appearance-none rounded-[var(--ssui-radius)] border border-[color:var(--ssui-border-control,var(--ssui-border))] bg-[color:var(--ssui-surface)] text-[color:var(--ssui-text)] shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[color:var(--ssui-focus-ring)] focus-visible:ring-offset-2 focus-visible:ring-offset-[color:var(--ssui-bg)] disabled:cursor-not-allowed disabled:bg-[color:var(--ssui-surface-muted)] disabled:opacity-70", SIZE_CLASSES[size].select), ...useFormFieldProps(props), children: children }), _jsx(ChevronDown, { "aria-hidden": "true", className: cn("pointer-events-none absolute top-1/2 -translate-y-1/2 text-[color:var(--ssui-text-subtle)]", SIZE_CLASSES[size].chevron) })] })));
|
|
12
25
|
Select.displayName = "Select";
|
|
@@ -2,5 +2,5 @@ import { jsx as _jsx } from "react/jsx-runtime";
|
|
|
2
2
|
import * as React from "react";
|
|
3
3
|
import { cn } from "../utils/cn.js";
|
|
4
4
|
import { useFormFieldProps } from "./form-field.js";
|
|
5
|
-
export const Textarea = React.forwardRef(({ className, rows = 4, ...props }, ref) => (_jsx("textarea", { ref: ref, rows: rows, className: cn("flex min-h-24 w-full resize-y rounded-[var(--ssui-radius)] border border-[color:var(--ssui-border)] bg-[color:var(--ssui-surface)] px-3 py-2 text-sm text-[color:var(--ssui-text)] shadow-sm transition-colors placeholder:text-[color:var(--ssui-text-subtle)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[color:var(--ssui-focus-ring)] focus-visible:ring-offset-2 focus-visible:ring-offset-[color:var(--ssui-bg)] disabled:cursor-not-allowed disabled:bg-[color:var(--ssui-surface-muted)] disabled:opacity-70", className), ...useFormFieldProps(props) })));
|
|
5
|
+
export const Textarea = React.forwardRef(({ className, rows = 4, ...props }, ref) => (_jsx("textarea", { ref: ref, rows: rows, className: cn("flex min-h-24 w-full resize-y rounded-[var(--ssui-radius)] border border-[color:var(--ssui-border-control,var(--ssui-border))] bg-[color:var(--ssui-surface)] px-3 py-2 text-sm text-[color:var(--ssui-text)] shadow-sm transition-colors placeholder:text-[color:var(--ssui-text-subtle)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[color:var(--ssui-focus-ring)] focus-visible:ring-offset-2 focus-visible:ring-offset-[color:var(--ssui-bg)] disabled:cursor-not-allowed disabled:bg-[color:var(--ssui-surface-muted)] disabled:opacity-70", className), ...useFormFieldProps(props) })));
|
|
6
6
|
Textarea.displayName = "Textarea";
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { TimeZoneOption } from "../utils/time-zones.js";
|
|
2
|
+
import type { ChoiceGroupVariant } from "./choice-card.js";
|
|
2
3
|
export type TimeZoneMode = "auto" | "fixed";
|
|
3
4
|
export interface TimeZoneFieldProps {
|
|
4
5
|
/** Initial mode. Defaults to "auto". */
|
|
@@ -17,14 +18,22 @@ export interface TimeZoneFieldProps {
|
|
|
17
18
|
options?: TimeZoneOption[];
|
|
18
19
|
/** Accessible label for the mode choice. Defaults to "Time zone mode". */
|
|
19
20
|
"aria-label"?: string;
|
|
21
|
+
/**
|
|
22
|
+
* How the mode choice is drawn — passed straight to `ChoiceCardGroup`.
|
|
23
|
+
* Defaults to `"card"`. Use `"list"` where the surrounding form treats a
|
|
24
|
+
* choice group as one bordered container of rows; see `ChoiceCardGroupProps`
|
|
25
|
+
* for why the two are not interchangeable.
|
|
26
|
+
*/
|
|
27
|
+
variant?: ChoiceGroupVariant;
|
|
20
28
|
className?: string;
|
|
21
29
|
}
|
|
22
30
|
/**
|
|
23
31
|
* Composite time-zone preference control: an auto/fixed mode choice rendered
|
|
24
|
-
* as `
|
|
32
|
+
* as a `ChoiceCardGroup` in either variant (auto shows the detected browser
|
|
33
|
+
* zone inline) plus a
|
|
25
34
|
* filterable zone `Combobox` shown only in fixed mode. Search matches zone
|
|
26
35
|
* names with spaces or underscores and offset text ("GMT+09"). Submits
|
|
27
36
|
* through hidden inputs — `timeZoneMode`, `timeZone`, `browserTimeZone` —
|
|
28
37
|
* for server-action form handling.
|
|
29
38
|
*/
|
|
30
|
-
export declare function TimeZoneField({ defaultMode, defaultTimeZone, onChange, options, className, "aria-label": ariaLabel, }: TimeZoneFieldProps): import("react/jsx-runtime").JSX.Element;
|
|
39
|
+
export declare function TimeZoneField({ defaultMode, defaultTimeZone, onChange, options, variant, className, "aria-label": ariaLabel, }: TimeZoneFieldProps): import("react/jsx-runtime").JSX.Element;
|
|
@@ -5,6 +5,24 @@ import { cn } from "../utils/cn.js";
|
|
|
5
5
|
import { detectBrowserTimeZone, formatTimeZoneOffset, timeZoneOptions, } from "../utils/time-zones.js";
|
|
6
6
|
import { ChoiceCard, ChoiceCardGroup } from "./choice-card.js";
|
|
7
7
|
import { Combobox } from "./combobox.js";
|
|
8
|
+
/**
|
|
9
|
+
* One shape for both zone sources — the consumer's `options` and the built IANA
|
|
10
|
+
* list — so a field added to it is added once.
|
|
11
|
+
*
|
|
12
|
+
* `inputLabel` is the reason this is worth a function: the offset is part of
|
|
13
|
+
* which zone this is rather than decoration on the row, so it has to survive
|
|
14
|
+
* into the collapsed field. Without it a chosen zone shows as a bare IANA name
|
|
15
|
+
* and the reader has to reopen the list to check they picked the offset they
|
|
16
|
+
* meant.
|
|
17
|
+
*/
|
|
18
|
+
function toComboboxOption(zone) {
|
|
19
|
+
return {
|
|
20
|
+
value: zone.value,
|
|
21
|
+
label: zone.label,
|
|
22
|
+
trailing: zone.offset,
|
|
23
|
+
inputLabel: `${zone.label} (${zone.offset})`,
|
|
24
|
+
};
|
|
25
|
+
}
|
|
8
26
|
function matchesZoneQuery(option, query) {
|
|
9
27
|
const normalized = query.trim().toLowerCase();
|
|
10
28
|
if (!normalized)
|
|
@@ -19,13 +37,14 @@ function matchesZoneQuery(option, query) {
|
|
|
19
37
|
}
|
|
20
38
|
/**
|
|
21
39
|
* Composite time-zone preference control: an auto/fixed mode choice rendered
|
|
22
|
-
* as `
|
|
40
|
+
* as a `ChoiceCardGroup` in either variant (auto shows the detected browser
|
|
41
|
+
* zone inline) plus a
|
|
23
42
|
* filterable zone `Combobox` shown only in fixed mode. Search matches zone
|
|
24
43
|
* names with spaces or underscores and offset text ("GMT+09"). Submits
|
|
25
44
|
* through hidden inputs — `timeZoneMode`, `timeZone`, `browserTimeZone` —
|
|
26
45
|
* for server-action form handling.
|
|
27
46
|
*/
|
|
28
|
-
export function TimeZoneField({ defaultMode = "auto", defaultTimeZone, onChange, options, className, "aria-label": ariaLabel = "Time zone mode", }) {
|
|
47
|
+
export function TimeZoneField({ defaultMode = "auto", defaultTimeZone, onChange, options, variant = "card", className, "aria-label": ariaLabel = "Time zone mode", }) {
|
|
29
48
|
const [mode, setMode] = React.useState(defaultMode);
|
|
30
49
|
const [browserTimeZone, setBrowserTimeZone] = React.useState("UTC");
|
|
31
50
|
const [fixedTimeZone, setFixedTimeZone] = React.useState(defaultTimeZone ?? "");
|
|
@@ -41,21 +60,12 @@ export function TimeZoneField({ defaultMode = "auto", defaultTimeZone, onChange,
|
|
|
41
60
|
// component's lifetime. Consumer-provided `options` skip the build entirely.
|
|
42
61
|
const builtOptionsRef = React.useRef(null);
|
|
43
62
|
const comboboxOptions = React.useMemo(() => {
|
|
44
|
-
if (options)
|
|
45
|
-
return options.map(
|
|
46
|
-
value: zone.value,
|
|
47
|
-
label: zone.label,
|
|
48
|
-
trailing: zone.offset,
|
|
49
|
-
}));
|
|
50
|
-
}
|
|
63
|
+
if (options)
|
|
64
|
+
return options.map(toComboboxOption);
|
|
51
65
|
if (mode !== "fixed")
|
|
52
66
|
return [];
|
|
53
67
|
if (!builtOptionsRef.current) {
|
|
54
|
-
builtOptionsRef.current = timeZoneOptions().map(
|
|
55
|
-
value: zone.value,
|
|
56
|
-
label: zone.label,
|
|
57
|
-
trailing: zone.offset,
|
|
58
|
-
}));
|
|
68
|
+
builtOptionsRef.current = timeZoneOptions().map(toComboboxOption);
|
|
59
69
|
}
|
|
60
70
|
return builtOptionsRef.current;
|
|
61
71
|
}, [options, mode]);
|
|
@@ -68,5 +78,5 @@ export function TimeZoneField({ defaultMode = "auto", defaultTimeZone, onChange,
|
|
|
68
78
|
setFixedTimeZone(next.timeZone);
|
|
69
79
|
onChange?.({ mode: nextMode, timeZone: nextZone });
|
|
70
80
|
}
|
|
71
|
-
return (_jsxs("div", { className: cn("space-y-3", className), children: [_jsx("input", { name: "timeZoneMode", type: "hidden", value: mode }), _jsx("input", { name: "timeZone", type: "hidden", value: fixedTimeZone }), _jsx("input", { name: "browserTimeZone", type: "hidden", value: browserTimeZone }), _jsxs(ChoiceCardGroup, { "aria-label": ariaLabel, onValueChange: (value) => update({ mode: value }), value: mode, children: [_jsx(ChoiceCard, { description: _jsxs(_Fragment, { children: ["Use the browser time zone", _jsxs("span", { className: "mt-0.5 block font-mono text-[color:var(--ssui-text-muted)]", children: [browserTimeZone, " (", formatTimeZoneOffset(browserTimeZone), ")"] })] }), title: "Auto", value: "auto" }), _jsx(ChoiceCard, { description: "Always use one time zone", title: "Fixed", value: "fixed" })] }), mode === "fixed" ? (_jsx(Combobox, { "aria-label": "Fixed time zone", emptyMessage: "No matching time zones", filter: matchesZoneQuery, onValueChange: (value) => update({ timeZone: value }), options: comboboxOptions, placeholder: "America/New_York", value: fixedTimeZone })) : null] }));
|
|
81
|
+
return (_jsxs("div", { className: cn("space-y-3", className), children: [_jsx("input", { name: "timeZoneMode", type: "hidden", value: mode }), _jsx("input", { name: "timeZone", type: "hidden", value: fixedTimeZone }), _jsx("input", { name: "browserTimeZone", type: "hidden", value: browserTimeZone }), _jsxs(ChoiceCardGroup, { "aria-label": ariaLabel, onValueChange: (value) => update({ mode: value }), value: mode, variant: variant, children: [_jsx(ChoiceCard, { description: _jsxs(_Fragment, { children: ["Use the browser time zone", _jsxs("span", { className: "mt-0.5 block font-mono text-[color:var(--ssui-text-muted)]", children: [browserTimeZone, " (", formatTimeZoneOffset(browserTimeZone), ")"] })] }), title: "Auto", value: "auto" }), _jsx(ChoiceCard, { description: "Always use one time zone", title: "Fixed", value: "fixed" })] }), mode === "fixed" ? (_jsx(Combobox, { "aria-label": "Fixed time zone", emptyMessage: "No matching time zones", filter: matchesZoneQuery, onValueChange: (value) => update({ timeZone: value }), options: comboboxOptions, placeholder: "America/New_York", value: fixedTimeZone })) : null] }));
|
|
72
82
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@forwardreach/saas-ui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.12.0",
|
|
4
4
|
"description": "Brand-neutral React UI primitives and SaaS app patterns for ForwardReach-owned business applications.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"private": false,
|
|
@@ -51,6 +51,7 @@
|
|
|
51
51
|
"@radix-ui/react-avatar": "^1.1.10",
|
|
52
52
|
"@radix-ui/react-dialog": "^1.1.15",
|
|
53
53
|
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
|
54
|
+
"@radix-ui/react-popover": "^1.1.23",
|
|
54
55
|
"@radix-ui/react-scroll-area": "^1.2.10",
|
|
55
56
|
"@radix-ui/react-separator": "^1.1.7",
|
|
56
57
|
"@radix-ui/react-slot": "^1.2.4",
|
|
@@ -72,12 +73,13 @@
|
|
|
72
73
|
"typescript": "^5.8.3",
|
|
73
74
|
"vitest": "^3.2.4"
|
|
74
75
|
},
|
|
76
|
+
"gitHead": "7517a542f48380e40fb8d7632f12ce352151487d",
|
|
75
77
|
"scripts": {
|
|
76
78
|
"build": "pnpm clean && tsc -p tsconfig.build.json && node scripts/copy-styles.mjs",
|
|
77
79
|
"dev": "node scripts/copy-styles.mjs && tsc -p tsconfig.build.json --watch",
|
|
78
80
|
"check:brand": "node scripts/check-brand-neutrality.mjs",
|
|
79
81
|
"clean": "rimraf dist coverage dist-pack *.tgz",
|
|
80
|
-
"pack:local": "pnpm build && mkdir -p dist-pack && pnpm pack --
|
|
82
|
+
"pack:local": "pnpm build && mkdir -p dist-pack && pnpm pack --out dist-pack/forwardreach-saas-ui-local.tgz",
|
|
81
83
|
"test": "vitest run && pnpm check:brand",
|
|
82
84
|
"typecheck": "tsc --noEmit"
|
|
83
85
|
}
|