@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.
@@ -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 h-10 w-full cursor-pointer appearance-none rounded-[var(--ssui-radius)] border border-[color:var(--ssui-border)] bg-[color:var(--ssui-surface)] py-2 pl-3 pr-9 text-sm text-[color:var(--ssui-text)] shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[color:var(--ssui-focus-ring)] focus-visible:ring-offset-2 focus-visible:ring-offset-[color:var(--ssui-bg)] disabled:cursor-not-allowed disabled:bg-[color:var(--ssui-surface-muted)] disabled:opacity-70", ...useFormFieldProps(props), children: children }), _jsx(ChevronDown, { "aria-hidden": "true", className: "pointer-events-none absolute right-3 top-1/2 size-4 -translate-y-1/2 text-[color:var(--ssui-text-subtle)]" })] })));
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 `ChoiceCard`s (auto shows the detected browser zone inline) plus a
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 `ChoiceCard`s (auto shows the detected browser zone inline) plus a
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((zone) => ({
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((zone) => ({
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.10.4",
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 --pack-destination dist-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
  }