@motir/design-system 0.4.2 → 0.5.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/dist/components/ui/Segmented.d.ts +16 -1
- package/dist/components/ui/Segmented.js +10 -4
- package/dist/components/ui/Segmented.js.map +1 -1
- package/dist/components/ui/Switch.js +1 -1
- package/dist/components/ui/Switch.js.map +1 -1
- package/dist/components/ui/Textarea.d.ts +20 -1
- package/dist/components/ui/Textarea.js +43 -6
- package/dist/components/ui/Textarea.js.map +1 -1
- package/package.json +1 -1
- package/theme.css +13 -0
|
@@ -17,7 +17,7 @@ interface SegmentedOption<T extends string> {
|
|
|
17
17
|
/** Tooltip for the option (e.g. why a seam option is disabled). */
|
|
18
18
|
title?: string;
|
|
19
19
|
}
|
|
20
|
-
declare function Segmented<T extends string>({ options, value, onChange, label, className, disabled, }: {
|
|
20
|
+
declare function Segmented<T extends string>({ options, value, onChange, label, className, disabled, fill, }: {
|
|
21
21
|
options: SegmentedOption<T>[];
|
|
22
22
|
value: T;
|
|
23
23
|
onChange: (value: T) => void;
|
|
@@ -25,6 +25,21 @@ declare function Segmented<T extends string>({ options, value, onChange, label,
|
|
|
25
25
|
label: string;
|
|
26
26
|
className?: string;
|
|
27
27
|
disabled?: boolean;
|
|
28
|
+
/**
|
|
29
|
+
* FILL the container instead of sizing to the options (MOTIR-6200, for
|
|
30
|
+
* MOTIR-6199): the track becomes `w-full` and the segments divide it evenly.
|
|
31
|
+
*
|
|
32
|
+
* WHY IT IS A VARIANT AND NOT THE DEFAULT. The track is `inline-flex` with no
|
|
33
|
+
* wrap and no shrink, so its width is `max-content` — fine in a toolbar that
|
|
34
|
+
* can grow, and unsurvivable in a FIXED rail. The Difficulty editor sits in an
|
|
35
|
+
* 18rem item-page rail and a 300px quick-view rail, and when its scale gained a
|
|
36
|
+
* fourth member the track overran both. Filling makes the width the
|
|
37
|
+
* CONTAINER's, so it holds at any label set, at any `--spacing-control-x`
|
|
38
|
+
* (10 / 12 / 14px across the shipped styles) and at a fifth member.
|
|
39
|
+
*
|
|
40
|
+
* Off by default: every other call site keeps byte-identical markup.
|
|
41
|
+
*/
|
|
42
|
+
fill?: boolean;
|
|
28
43
|
}): react_jsx_runtime.JSX.Element;
|
|
29
44
|
|
|
30
45
|
export { Segmented, type SegmentedOption };
|
|
@@ -12,7 +12,8 @@ function Segmented({
|
|
|
12
12
|
onChange,
|
|
13
13
|
label,
|
|
14
14
|
className,
|
|
15
|
-
disabled
|
|
15
|
+
disabled,
|
|
16
|
+
fill
|
|
16
17
|
}) {
|
|
17
18
|
return /* @__PURE__ */ jsx(
|
|
18
19
|
"div",
|
|
@@ -20,7 +21,8 @@ function Segmented({
|
|
|
20
21
|
role: "group",
|
|
21
22
|
"aria-label": label,
|
|
22
23
|
className: cn(
|
|
23
|
-
"
|
|
24
|
+
"items-center gap-0.5 rounded-(--radius-btn) border border-(--el-border) bg-(--el-tabnav-track) p-0.5",
|
|
25
|
+
fill ? "flex w-full" : "inline-flex",
|
|
24
26
|
className
|
|
25
27
|
),
|
|
26
28
|
children: options.map((opt) => {
|
|
@@ -42,7 +44,11 @@ function Segmented({
|
|
|
42
44
|
// `--radius-control` breaks when a style makes `--radius-btn` a full
|
|
43
45
|
// pill (soft-playful / retrofuturism: pill track, but a small-radius
|
|
44
46
|
// chip floating inside it). Mirrors AppearancePickers' option radius.
|
|
45
|
-
"inline-flex h-(--height-control) items-center gap-1.5 rounded-[calc(var(--radius-btn)-2px)]
|
|
47
|
+
"inline-flex h-(--height-control) items-center gap-1.5 rounded-[calc(var(--radius-btn)-2px)] text-[13px] font-medium transition-colors",
|
|
48
|
+
// In FILL mode the segment's width comes from the track, so the
|
|
49
|
+
// per-style control padding no longer sizes it — keeping it would
|
|
50
|
+
// re-introduce the token dependency the variant exists to remove.
|
|
51
|
+
fill ? "min-w-0 flex-1 justify-center px-1" : "px-(--spacing-control-x)",
|
|
46
52
|
"focus-visible:ring-2 focus-visible:ring-(--focus-ring-color) focus-visible:outline-none",
|
|
47
53
|
"disabled:cursor-not-allowed disabled:opacity-50",
|
|
48
54
|
active ? "bg-(--el-page-bg) text-(--el-text-strong) shadow-(--shadow-subtle)" : "text-(--el-text-secondary) hover:text-(--el-text)"
|
|
@@ -59,7 +65,7 @@ function Segmented({
|
|
|
59
65
|
children: opt.icon
|
|
60
66
|
}
|
|
61
67
|
) : null,
|
|
62
|
-
opt.label,
|
|
68
|
+
fill ? /* @__PURE__ */ jsx("span", { className: "truncate", children: opt.label }) : opt.label,
|
|
63
69
|
opt.trailing != null ? /* @__PURE__ */ jsx(
|
|
64
70
|
"span",
|
|
65
71
|
{
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../src/utils/cn.ts","../../../src/components/ui/Segmented.tsx"],"names":[],"mappings":";;;;AAcO,SAAS,MAAM,MAAA,EAA8B;AAClD,EAAA,OAAO,OAAA,CAAQ,IAAA,CAAK,MAAM,CAAC,CAAA;AAC7B;ACqBO,SAAS,SAAA,CAA4B;AAAA,EAC1C,OAAA;AAAA,EACA,KAAA;AAAA,EACA,QAAA;AAAA,EACA,KAAA;AAAA,EACA,SAAA;AAAA,EACA;AACF,CAAA,
|
|
1
|
+
{"version":3,"sources":["../../../src/utils/cn.ts","../../../src/components/ui/Segmented.tsx"],"names":[],"mappings":";;;;AAcO,SAAS,MAAM,MAAA,EAA8B;AAClD,EAAA,OAAO,OAAA,CAAQ,IAAA,CAAK,MAAM,CAAC,CAAA;AAC7B;ACqBO,SAAS,SAAA,CAA4B;AAAA,EAC1C,OAAA;AAAA,EACA,KAAA;AAAA,EACA,QAAA;AAAA,EACA,KAAA;AAAA,EACA,SAAA;AAAA,EACA,QAAA;AAAA,EACA;AACF,CAAA,EAuBG;AACD,EAAA,uBACE,GAAA;AAAA,IAAC,KAAA;AAAA,IAAA;AAAA,MACC,IAAA,EAAK,OAAA;AAAA,MACL,YAAA,EAAY,KAAA;AAAA,MACZ,SAAA,EAAW,EAAA;AAAA,QACT,sGAAA;AAAA,QACA,OAAO,aAAA,GAAgB,aAAA;AAAA,QACvB;AAAA,OACF;AAAA,MAEC,QAAA,EAAA,OAAA,CAAQ,GAAA,CAAI,CAAC,GAAA,KAAQ;AACpB,QAAA,MAAM,MAAA,GAAS,IAAI,KAAA,KAAU,KAAA;AAC7B,QAAA,uBACE,IAAA;AAAA,UAAC,QAAA;AAAA,UAAA;AAAA,YAEC,IAAA,EAAK,QAAA;AAAA,YACL,cAAA,EAAc,MAAA;AAAA,YACd,QAAA,EAAU,YAAY,GAAA,CAAI,QAAA;AAAA,YAC1B,OAAO,GAAA,CAAI,KAAA;AAAA,YACX,SAAS,MAAM;AACb,cAAA,IAAI,CAAC,MAAA,EAAQ,QAAA,CAAS,GAAA,CAAI,KAAK,CAAA;AAAA,YACjC,CAAA;AAAA,YACA,SAAA,EAAW,EAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,cAOT,uIAAA;AAAA;AAAA;AAAA;AAAA,cAIA,OAAO,oCAAA,GAAuC,0BAAA;AAAA,cAC9C,yFAAA;AAAA,cACA,iDAAA;AAAA,cACA,SACI,oEAAA,GACA;AAAA,aACN;AAAA,YAQC,QAAA,EAAA;AAAA,cAAA,GAAA,CAAI,IAAA,mBACH,GAAA;AAAA,gBAAC,MAAA;AAAA,gBAAA;AAAA,kBACC,aAAA,EAAW,IAAA;AAAA,kBACX,SAAA,EAAW,EAAA;AAAA,oBACT,qEAAA;AAAA,oBACA,SAAS,2BAAA,GAA8B;AAAA,mBACzC;AAAA,kBAEC,QAAA,EAAA,GAAA,CAAI;AAAA;AAAA,eACP,GACE,IAAA;AAAA,cAMH,IAAA,uBAAQ,MAAA,EAAA,EAAK,SAAA,EAAU,YAAY,QAAA,EAAA,GAAA,CAAI,KAAA,EAAM,IAAU,GAAA,CAAI,KAAA;AAAA,cAC3D,GAAA,CAAI,YAAY,IAAA,mBACf,GAAA;AAAA,gBAAC,MAAA;AAAA,gBAAA;AAAA,kBACC,SAAA,EAAW,EAAA;AAAA,oBACT,kDAAA;AAAA,oBACA,SAAS,2BAAA,GAA8B;AAAA,mBACzC;AAAA,kBAEC,QAAA,EAAA,GAAA,CAAI;AAAA;AAAA,eACP,GACE;AAAA;AAAA,WAAA;AAAA,UA3DC,GAAA,CAAI;AAAA,SA4DX;AAAA,MAEJ,CAAC;AAAA;AAAA,GACH;AAEJ","file":"Segmented.js","sourcesContent":["import { clsx, type ClassValue } from 'clsx';\nimport { twMerge } from 'tailwind-merge';\n\n/**\n * Merge Tailwind class names without conflicts.\n *\n * `clsx` handles conditional inclusion (booleans, undefineds, arrays);\n * `twMerge` resolves conflicting Tailwind classes (later wins, e.g.\n * `px-2 px-4` → `px-4`). Together they are the de facto standard for\n * composing Tailwind classes in React components.\n *\n * @example\n * cn('px-2 py-1', isActive && 'bg-primary', className)\n */\nexport function cn(...inputs: ClassValue[]): string {\n return twMerge(clsx(inputs));\n}\n","'use client';\n\nimport type { ReactNode } from 'react';\nimport { cn } from '../../utils/cn';\n\n// Segmented — a single-select inline control: a row of mutually-exclusive\n// buttons where the active option is raised (`aria-pressed`). The accessible,\n// space-cheap alternative to a `<select>` when the option set is small and worth\n// showing at a glance (the board group-by, Subtask 3.3.5; reusable beyond it).\n//\n// Built FROM the design system per `design/boards/swimlanes-wip.mock.html` (the\n// `.seg` block): an `--el-surface` track (`--radius-btn`) with a 2px inset, each\n// option a `calc(--radius-btn - 2px)` button so it NESTS in the track at any style\n// (a fixed `--radius-control` floats wrong when a style pills `--radius-btn`); the\n// pressed option gets the `--el-page-bg` raised fill + `--shadow-subtle` and its\n// leading glyph takes the `--el-accent` hue.\n// Colour via `--el-*`, shape via element-semantic tokens (the colour + shape\n// swap rules). A11y: a labelled `role=\"group\"`; each option is a real `<button>`\n// carrying `aria-pressed`, so it is keyboard-operable and announced as a toggle.\n\nexport interface SegmentedOption<T extends string> {\n value: T;\n label: string;\n /** Optional leading glyph (decorative — the label carries the accessible name). */\n icon?: ReactNode;\n /** Optional trailing content after the label (e.g. the notification drawer's\n * unread count on the Direct tab, Subtask 5.7.5). Decorative — faint when the\n * option is inactive, the `--el-accent` hue when active (mirrors the leading\n * glyph's active treatment). */\n trailing?: ReactNode;\n /** Disable just this option — a forward-compatible seam (e.g. the Activity\n * card's History filter, Story 5.5's slot) drawn present-but-inert. */\n disabled?: boolean;\n /** Tooltip for the option (e.g. why a seam option is disabled). */\n title?: string;\n}\n\nexport function Segmented<T extends string>({\n options,\n value,\n onChange,\n label,\n className,\n disabled,\n fill,\n}: {\n options: SegmentedOption<T>[];\n value: T;\n onChange: (value: T) => void;\n /** Accessible group name (e.g. \"Swimlane group by\") — not rendered visually. */\n label: string;\n className?: string;\n disabled?: boolean;\n /**\n * FILL the container instead of sizing to the options (MOTIR-6200, for\n * MOTIR-6199): the track becomes `w-full` and the segments divide it evenly.\n *\n * WHY IT IS A VARIANT AND NOT THE DEFAULT. The track is `inline-flex` with no\n * wrap and no shrink, so its width is `max-content` — fine in a toolbar that\n * can grow, and unsurvivable in a FIXED rail. The Difficulty editor sits in an\n * 18rem item-page rail and a 300px quick-view rail, and when its scale gained a\n * fourth member the track overran both. Filling makes the width the\n * CONTAINER's, so it holds at any label set, at any `--spacing-control-x`\n * (10 / 12 / 14px across the shipped styles) and at a fifth member.\n *\n * Off by default: every other call site keeps byte-identical markup.\n */\n fill?: boolean;\n}) {\n return (\n <div\n role=\"group\"\n aria-label={label}\n className={cn(\n 'items-center gap-0.5 rounded-(--radius-btn) border border-(--el-border) bg-(--el-tabnav-track) p-0.5',\n fill ? 'flex w-full' : 'inline-flex',\n className,\n )}\n >\n {options.map((opt) => {\n const active = opt.value === value;\n return (\n <button\n key={opt.value}\n type=\"button\"\n aria-pressed={active}\n disabled={disabled || opt.disabled}\n title={opt.title}\n onClick={() => {\n if (!active) onChange(opt.value);\n }}\n className={cn(\n // The segment radius nests inside the track: the track is\n // `rounded-(--radius-btn)` with a 2px (`p-0.5`) inset, so a segment\n // fits the shell only at `--radius-btn - 2px`. Using a fixed\n // `--radius-control` breaks when a style makes `--radius-btn` a full\n // pill (soft-playful / retrofuturism: pill track, but a small-radius\n // chip floating inside it). Mirrors AppearancePickers' option radius.\n 'inline-flex h-(--height-control) items-center gap-1.5 rounded-[calc(var(--radius-btn)-2px)] text-[13px] font-medium transition-colors',\n // In FILL mode the segment's width comes from the track, so the\n // per-style control padding no longer sizes it — keeping it would\n // re-introduce the token dependency the variant exists to remove.\n fill ? 'min-w-0 flex-1 justify-center px-1' : 'px-(--spacing-control-x)',\n 'focus-visible:ring-2 focus-visible:ring-(--focus-ring-color) focus-visible:outline-none',\n 'disabled:cursor-not-allowed disabled:opacity-50',\n active\n ? 'bg-(--el-page-bg) text-(--el-text-strong) shadow-(--shadow-subtle)'\n : 'text-(--el-text-secondary) hover:text-(--el-text)',\n )}\n >\n {/* The icon and the trailing count take their ink HERE rather than\n through the segment's `[&_.seg-*]:…` descendant variants\n (MOTIR-2475). Two things follow: the glyph says it is\n `aria-hidden`, so its faint ink is one of the token's legitimate\n jobs; and the trailing COUNT — text a reader reads — is no\n longer painted at 2.4:1 on an inactive segment. */}\n {opt.icon ? (\n <span\n aria-hidden\n className={cn(\n 'seg-ic inline-flex h-3.5 w-3.5 shrink-0 items-center justify-center',\n active ? 'text-(--el-tabnav-active)' : 'text-(--el-text-faint)',\n )}\n >\n {opt.icon}\n </span>\n ) : null}\n {/* FILL mode gives each segment a share of the track rather than its\n own content width, so a long label has to yield rather than push.\n Truncating needs an element to truncate, hence the span — added\n ONLY in fill mode, so every other call site's markup is\n unchanged. */}\n {fill ? <span className=\"truncate\">{opt.label}</span> : opt.label}\n {opt.trailing != null ? (\n <span\n className={cn(\n 'seg-trail text-[11px] font-semibold tabular-nums',\n active ? 'text-(--el-tabnav-active)' : 'text-(--el-text-secondary)',\n )}\n >\n {opt.trailing}\n </span>\n ) : null}\n </button>\n );\n })}\n </div>\n );\n}\n"]}
|
|
@@ -28,7 +28,7 @@ function Switch({
|
|
|
28
28
|
onClick: () => onCheckedChange(!checked),
|
|
29
29
|
className: cn(
|
|
30
30
|
"relative inline-flex h-5 w-9 shrink-0 items-center rounded-full border transition-colors focus-visible:ring-2 focus-visible:ring-(--focus-ring-color) focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50",
|
|
31
|
-
checked ? "border-(--el-switch-on-border) bg-(--el-switch-on)" : "border-(--el-border
|
|
31
|
+
checked ? "border-(--el-switch-on-border) bg-(--el-switch-on)" : "border-(--el-switch-off-border) bg-(--el-muted)",
|
|
32
32
|
className
|
|
33
33
|
),
|
|
34
34
|
children: /* @__PURE__ */ jsx(
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../src/utils/cn.ts","../../../src/components/ui/Switch.tsx"],"names":[],"mappings":";;;;;AAcO,SAAS,MAAM,MAAA,EAA8B;AAClD,EAAA,OAAO,OAAA,CAAQ,IAAA,CAAK,MAAM,CAAC,CAAA;AAC7B;ACgBO,SAAS,MAAA,CAAO;AAAA,EACrB,OAAA;AAAA,EACA,eAAA;AAAA,EACA,QAAA;AAAA,EACA,EAAA;AAAA,EACA,SAAA;AAAA,EACA,YAAA,EAAc,SAAA;AAAA,EACd,iBAAA,EAAmB;AACrB,CAAA,EAAgB;AACd,EAAA,uBACE,GAAA;AAAA,IAAC,QAAA;AAAA,IAAA;AAAA,MACC,IAAA,EAAK,QAAA;AAAA,MACL,IAAA,EAAK,QAAA;AAAA,MACL,EAAA;AAAA,MACA,cAAA,EAAc,OAAA;AAAA,MACd,YAAA,EAAY,SAAA;AAAA,MACZ,iBAAA,EAAiB,cAAA;AAAA,MACjB,QAAA;AAAA,MACA,OAAA,EAAS,MAAM,eAAA,CAAgB,CAAC,OAAO,CAAA;AAAA,MACvC,SAAA,EAAW,EAAA;AAAA,QACT,kOAAA;AAAA,QACA,UACI,oDAAA,GACA,
|
|
1
|
+
{"version":3,"sources":["../../../src/utils/cn.ts","../../../src/components/ui/Switch.tsx"],"names":[],"mappings":";;;;;AAcO,SAAS,MAAM,MAAA,EAA8B;AAClD,EAAA,OAAO,OAAA,CAAQ,IAAA,CAAK,MAAM,CAAC,CAAA;AAC7B;ACgBO,SAAS,MAAA,CAAO;AAAA,EACrB,OAAA;AAAA,EACA,eAAA;AAAA,EACA,QAAA;AAAA,EACA,EAAA;AAAA,EACA,SAAA;AAAA,EACA,YAAA,EAAc,SAAA;AAAA,EACd,iBAAA,EAAmB;AACrB,CAAA,EAAgB;AACd,EAAA,uBACE,GAAA;AAAA,IAAC,QAAA;AAAA,IAAA;AAAA,MACC,IAAA,EAAK,QAAA;AAAA,MACL,IAAA,EAAK,QAAA;AAAA,MACL,EAAA;AAAA,MACA,cAAA,EAAc,OAAA;AAAA,MACd,YAAA,EAAY,SAAA;AAAA,MACZ,iBAAA,EAAiB,cAAA;AAAA,MACjB,QAAA;AAAA,MACA,OAAA,EAAS,MAAM,eAAA,CAAgB,CAAC,OAAO,CAAA;AAAA,MACvC,SAAA,EAAW,EAAA;AAAA,QACT,kOAAA;AAAA,QACA,UACI,oDAAA,GACA,iDAAA;AAAA,QACJ;AAAA,OACF;AAAA,MAEA,QAAA,kBAAA,GAAA;AAAA,QAAC,MAAA;AAAA,QAAA;AAAA,UACC,aAAA,EAAW,IAAA;AAAA,UACX,SAAA,EAAW,EAAA;AAAA,YACT,kFAAA;AAAA,YACA,UACI,0CAAA,GACA;AAAA;AACN;AAAA;AACF;AAAA,GACF;AAEJ","file":"Switch.js","sourcesContent":["import { clsx, type ClassValue } from 'clsx';\nimport { twMerge } from 'tailwind-merge';\n\n/**\n * Merge Tailwind class names without conflicts.\n *\n * `clsx` handles conditional inclusion (booleans, undefineds, arrays);\n * `twMerge` resolves conflicting Tailwind classes (later wins, e.g.\n * `px-2 px-4` → `px-4`). Together they are the de facto standard for\n * composing Tailwind classes in React components.\n *\n * @example\n * cn('px-2 py-1', isActive && 'bg-primary', className)\n */\nexport function cn(...inputs: ClassValue[]): string {\n return twMerge(clsx(inputs));\n}\n","import { cn } from '../../utils/cn';\n\n/**\n * Switch — a sliding on/off toggle (`role=\"switch\"`), keyboard-operable.\n *\n * The shared primitive the design system names (notifications preferences\n * matrix, automation rule enable, …). Colour flows through `--el-*`; the knob +\n * track are genuinely-circular (`rounded-full`), the shape-rule carve-out for a\n * pill control. Always pass an `aria-label` (or wire an external `<label>` via\n * `id`) so the toggle is screen-reader-legible — a bare switch announces no\n * purpose.\n *\n * @example\n * <Switch checked={on} onCheckedChange={setOn} aria-label=\"Email for Mentioned\" />\n */\nexport interface SwitchProps {\n checked: boolean;\n onCheckedChange: (next: boolean) => void;\n disabled?: boolean;\n /** Accessible name — required unless an external `<label htmlFor=id>` exists. */\n 'aria-label'?: string;\n /**\n * Accessible name by reference — the id of a visible label element. Use this\n * instead of `aria-label` when the label already renders on screen (so the\n * name stays in sync with the visible text). One of `aria-label` /\n * `aria-labelledby` should be set, else the switch announces no purpose.\n */\n 'aria-labelledby'?: string;\n id?: string;\n className?: string;\n}\n\nexport function Switch({\n checked,\n onCheckedChange,\n disabled,\n id,\n className,\n 'aria-label': ariaLabel,\n 'aria-labelledby': ariaLabelledby,\n}: SwitchProps) {\n return (\n <button\n type=\"button\"\n role=\"switch\"\n id={id}\n aria-checked={checked}\n aria-label={ariaLabel}\n aria-labelledby={ariaLabelledby}\n disabled={disabled}\n onClick={() => onCheckedChange(!checked)}\n className={cn(\n 'relative inline-flex h-5 w-9 shrink-0 items-center rounded-full border transition-colors focus-visible:ring-2 focus-visible:ring-(--focus-ring-color) focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50',\n checked\n ? 'border-(--el-switch-on-border) bg-(--el-switch-on)'\n : 'border-(--el-switch-off-border) bg-(--el-muted)',\n className,\n )}\n >\n <span\n aria-hidden\n className={cn(\n 'inline-block size-3.5 rounded-full shadow-(--shadow-subtle) transition-transform',\n checked\n ? 'translate-x-[18px] bg-(--el-switch-knob)'\n : 'translate-x-0.5 bg-(--el-switch-knob-off)',\n )}\n />\n </button>\n );\n}\n"]}
|
|
@@ -4,15 +4,34 @@ import { TextareaHTMLAttributes } from 'react';
|
|
|
4
4
|
/**
|
|
5
5
|
* Textarea — multi-line text field. Mirrors Input's props (label, error,
|
|
6
6
|
* helperText) but no prefix/suffix slots (uncommon shape for textareas).
|
|
7
|
-
*
|
|
7
|
+
*
|
|
8
|
+
* **Auto-resize is OPT-IN.** Pass `rows` alone and the field is a fixed height,
|
|
9
|
+
* exactly as it has always been. Pass `autoGrow` and `rows` becomes the MINIMUM:
|
|
10
|
+
* the field grows with its content, line by line, up to `maxRows`, then stops
|
|
11
|
+
* and scrolls inside itself. A chat composer passes `rows={1}`.
|
|
8
12
|
*
|
|
9
13
|
* @example
|
|
10
14
|
* <Textarea label="Description" rows={4} helperText="Max 500 chars" />
|
|
15
|
+
* <Textarea autoGrow rows={1} maxRows={8} aria-label="Message" />
|
|
11
16
|
*/
|
|
12
17
|
interface TextareaProps extends TextareaHTMLAttributes<HTMLTextAreaElement> {
|
|
13
18
|
label?: string;
|
|
14
19
|
error?: string;
|
|
15
20
|
helperText?: string;
|
|
21
|
+
/**
|
|
22
|
+
* Grow with the content instead of standing at a fixed `rows`. Default
|
|
23
|
+
* `false`, so every existing caller renders byte-for-byte as before.
|
|
24
|
+
*
|
|
25
|
+
* With it, `rows` is the MINIMUM height and the manual resize handle is
|
|
26
|
+
* removed — a hand-dragged height is overwritten by the next keystroke's
|
|
27
|
+
* measurement, so the two cannot both be offered.
|
|
28
|
+
*/
|
|
29
|
+
autoGrow?: boolean;
|
|
30
|
+
/**
|
|
31
|
+
* The cap, in rows, for an `autoGrow` field. Omitted means no cap: the field
|
|
32
|
+
* grows for as long as its content does. Meaningless without `autoGrow`.
|
|
33
|
+
*/
|
|
34
|
+
maxRows?: number;
|
|
16
35
|
}
|
|
17
36
|
declare const Textarea: react.ForwardRefExoticComponent<TextareaProps & react.RefAttributes<HTMLTextAreaElement>>;
|
|
18
37
|
|
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
|
|
1
|
+
'use client';
|
|
2
|
+
import { forwardRef, useId, useRef, useCallback, useLayoutEffect } from 'react';
|
|
2
3
|
import { clsx } from 'clsx';
|
|
3
4
|
import { twMerge } from 'tailwind-merge';
|
|
4
5
|
import { jsx, jsxs } from 'react/jsx-runtime';
|
|
5
6
|
|
|
6
|
-
// src/components/ui/Textarea.tsx
|
|
7
7
|
function cn(...inputs) {
|
|
8
8
|
return twMerge(clsx(inputs));
|
|
9
9
|
}
|
|
@@ -38,22 +38,55 @@ function FormField({
|
|
|
38
38
|
function describedById(htmlFor, error, helperText) {
|
|
39
39
|
return error ? `${htmlFor}-error` : helperText ? `${htmlFor}-helper` : void 0;
|
|
40
40
|
}
|
|
41
|
-
|
|
41
|
+
function resize(el, rows, maxRows) {
|
|
42
|
+
const cs = getComputedStyle(el);
|
|
43
|
+
const parsedLineHeight = Number.parseFloat(cs.lineHeight);
|
|
44
|
+
const lineHeight = Number.isFinite(parsedLineHeight) ? parsedLineHeight : Number.parseFloat(cs.fontSize) * 1.2;
|
|
45
|
+
const padding = Number.parseFloat(cs.paddingTop) + Number.parseFloat(cs.paddingBottom);
|
|
46
|
+
const border = cs.boxSizing === "border-box" ? Number.parseFloat(cs.borderTopWidth) + Number.parseFloat(cs.borderBottomWidth) : 0;
|
|
47
|
+
const min = rows * lineHeight + padding + border;
|
|
48
|
+
const max = maxRows === void 0 ? Infinity : maxRows * lineHeight + padding + border;
|
|
49
|
+
el.style.height = "auto";
|
|
50
|
+
const needed = el.scrollHeight + border;
|
|
51
|
+
const next = Math.min(Math.max(needed, min), max);
|
|
52
|
+
el.style.height = `${next}px`;
|
|
53
|
+
el.style.overflowY = needed > max ? "auto" : "hidden";
|
|
54
|
+
}
|
|
55
|
+
var Textarea = forwardRef(function Textarea2({ label, error, helperText, id, className, disabled, rows = 3, autoGrow, maxRows, ...rest }, ref) {
|
|
42
56
|
const autoId = useId();
|
|
43
57
|
const textareaId = id ?? autoId;
|
|
44
58
|
const describedBy = describedById(textareaId, error, helperText);
|
|
45
59
|
const hasError = Boolean(error);
|
|
60
|
+
const innerRef = useRef(null);
|
|
61
|
+
const setRefs = useCallback(
|
|
62
|
+
(node) => {
|
|
63
|
+
innerRef.current = node;
|
|
64
|
+
if (typeof ref === "function") ref(node);
|
|
65
|
+
else if (ref) ref.current = node;
|
|
66
|
+
},
|
|
67
|
+
[ref]
|
|
68
|
+
);
|
|
69
|
+
useLayoutEffect(() => {
|
|
70
|
+
const el = innerRef.current;
|
|
71
|
+
if (!el || !autoGrow) return;
|
|
72
|
+
resize(el, rows, maxRows);
|
|
73
|
+
if (typeof ResizeObserver === "undefined") return;
|
|
74
|
+
const observer = new ResizeObserver(() => resize(el, rows, maxRows));
|
|
75
|
+
observer.observe(el);
|
|
76
|
+
return () => observer.disconnect();
|
|
77
|
+
}, [autoGrow, rows, maxRows, rest.value, rest.defaultValue]);
|
|
46
78
|
return /* @__PURE__ */ jsx(FormField, { label, error, helperText, htmlFor: textareaId, children: /* @__PURE__ */ jsx(
|
|
47
79
|
"textarea",
|
|
48
80
|
{
|
|
49
|
-
ref,
|
|
81
|
+
ref: setRefs,
|
|
50
82
|
id: textareaId,
|
|
51
83
|
rows,
|
|
52
84
|
"aria-invalid": hasError || void 0,
|
|
53
85
|
"aria-describedby": describedBy,
|
|
54
86
|
disabled,
|
|
55
87
|
className: cn(
|
|
56
|
-
"w-full
|
|
88
|
+
"w-full rounded-(--radius-input) border bg-(--el-page-bg)",
|
|
89
|
+
autoGrow ? "resize-none" : "resize-y",
|
|
57
90
|
"px-(--spacing-input-x) py-(--spacing-input-y)",
|
|
58
91
|
"font-sans text-sm text-(--el-text) placeholder:text-(--el-text-muted)",
|
|
59
92
|
"transition-colors",
|
|
@@ -62,7 +95,11 @@ var Textarea = forwardRef(function Textarea2({ label, error, helperText, id, cla
|
|
|
62
95
|
"disabled:cursor-not-allowed disabled:opacity-50",
|
|
63
96
|
className
|
|
64
97
|
),
|
|
65
|
-
...rest
|
|
98
|
+
...rest,
|
|
99
|
+
onInput: (event) => {
|
|
100
|
+
if (autoGrow) resize(event.currentTarget, rows, maxRows);
|
|
101
|
+
rest.onInput?.(event);
|
|
102
|
+
}
|
|
66
103
|
}
|
|
67
104
|
) });
|
|
68
105
|
});
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../src/utils/cn.ts","../../../src/components/ui/FormField.tsx","../../../src/components/ui/Textarea.tsx"],"names":["describedById","Textarea","jsx"],"mappings":";;;;;;AAcO,SAAS,MAAM,MAAA,EAA8B;AAClD,EAAA,OAAO,OAAA,CAAQ,IAAA,CAAK,MAAM,CAAC,CAAA;AAC7B;ACWO,SAAS,SAAA,CAAU;AAAA,EACxB,KAAA;AAAA,EACA,KAAA;AAAA,EACA,YAAA,GAAe,MAAA;AAAA,EACf,UAAA;AAAA,EACA,OAAA;AAAA,EACA,SAAA;AAAA,EACA;AACF,CAAA,EAAmB;AACjB,EAAA,MAAMA,cAAAA,GAAgB,QAAQ,CAAA,EAAG,OAAO,WAAW,UAAA,GAAa,CAAA,EAAG,OAAO,CAAA,OAAA,CAAA,GAAY,MAAA;AACtF,EAAA,4BACG,KAAA,EAAA,EAAI,SAAA,EAAW,EAAA,CAAG,uBAAA,EAAyB,SAAS,CAAA,EAClD,QAAA,EAAA;AAAA,IAAA,KAAA,uBACE,OAAA,EAAA,EAAM,OAAA,EAAkB,SAAA,EAAU,gDAAA,EAChC,iBACH,CAAA,GACE,IAAA;AAAA,IACH,QAAA;AAAA,IACA,KAAA,mBACC,GAAA;AAAA,MAAC,GAAA;AAAA,MAAA;AAAA,QACC,EAAA,EAAIA,cAAAA;AAAA,QACJ,IAAA,EAAK,OAAA;AAAA,QACL,SAAA,EAAW,EAAA;AAAA,UACT,mBAAA;AAAA,UACA,iBAAiB,KAAA,IACf;AAAA,SACJ;AAAA,QACA,OAAO,YAAA,KAAiB,KAAA,GAAQ,MAAA,GAAY,EAAE,OAAO,kBAAA,EAAmB;AAAA,QAEvE,QAAA,EAAA;AAAA;AAAA,KACH,GACE,6BACF,GAAA,CAAC,GAAA,EAAA,EAAE,IAAIA,cAAAA,EAAe,SAAA,EAAU,2CAAA,EAC7B,QAAA,EAAA,UAAA,EACH,CAAA,GACE;AAAA,GAAA,EACN,CAAA;AAEJ;AAGO,SAAS,aAAA,CACd,OAAA,EACA,KAAA,EACA,UAAA,EACoB;AACpB,EAAA,OAAO,QAAQ,CAAA,EAAG,OAAO,WAAW,UAAA,GAAa,CAAA,EAAG,OAAO,CAAA,OAAA,CAAA,GAAY,MAAA;AACzE;ACxDO,IAAM,WAAW,UAAA,CAA+C,SAASC,SAAAA,CAC9E,EAAE,OAAO,KAAA,EAAO,UAAA,EAAY,EAAA,EAAI,SAAA,EAAW,UAAU,IAAA,GAAO,CAAA,EAAG,GAAG,IAAA,IAClE,GAAA,EACA;AACA,EAAA,MAAM,SAAS,KAAA,EAAM;AACrB,EAAA,MAAM,aAAa,EAAA,IAAM,MAAA;AACzB,EAAA,MAAM,WAAA,GAAc,aAAA,CAAc,UAAA,EAAY,KAAA,EAAO,UAAU,CAAA;AAC/D,EAAA,MAAM,QAAA,GAAW,QAAQ,KAAK,CAAA;AAC9B,EAAA,uBACEC,IAAC,SAAA,EAAA,EAAU,KAAA,EAAc,OAAc,UAAA,EAAwB,OAAA,EAAS,YACtE,QAAA,kBAAAA,GAAAA;AAAA,IAAC,UAAA;AAAA,IAAA;AAAA,MACC,GAAA;AAAA,MACA,EAAA,EAAI,UAAA;AAAA,MACJ,IAAA;AAAA,MACA,gBAAc,QAAA,IAAY,MAAA;AAAA,MAC1B,kBAAA,EAAkB,WAAA;AAAA,MAClB,QAAA;AAAA,MACA,SAAA,EAAW,EAAA;AAAA,QACT,mEAAA;AAAA,QACA,+CAAA;AAAA,QACA,uEAAA;AAAA,QACA,mBAAA;AAAA,QACA,kHAAA;AAAA,QACA,WAAW,sBAAA,GAAyB,6BAAA;AAAA,QACpC,iDAAA;AAAA,QACA;AAAA,OACF;AAAA,MACC,GAAG;AAAA;AAAA,GACN,EACF,CAAA;AAEJ,CAAC","file":"Textarea.js","sourcesContent":["import { clsx, type ClassValue } from 'clsx';\nimport { twMerge } from 'tailwind-merge';\n\n/**\n * Merge Tailwind class names without conflicts.\n *\n * `clsx` handles conditional inclusion (booleans, undefineds, arrays);\n * `twMerge` resolves conflicting Tailwind classes (later wins, e.g.\n * `px-2 px-4` → `px-4`). Together they are the de facto standard for\n * composing Tailwind classes in React components.\n *\n * @example\n * cn('px-2 py-1', isActive && 'bg-primary', className)\n */\nexport function cn(...inputs: ClassValue[]): string {\n return twMerge(clsx(inputs));\n}\n","import { type ReactNode } from 'react';\nimport { cn } from '../../utils/cn';\n\n/**\n * Internal layout wrapper shared by Input and Textarea — handles the\n * label / control / helper-or-error stack pattern. Not exported from\n * the design system; consumers compose <Input> and <Textarea> directly.\n */\nexport interface FormFieldProps {\n /** Visible label rendered above the control. */\n label?: string;\n /** Error message — overrides helperText when present. Sets aria-invalid. */\n error?: string;\n /**\n * How the error message renders: the default `text` is the quiet danger\n * line; `box` is the rose-tint inline-error box (hue in the background,\n * strong text — finding #35), the 2.4.9 grammar inline editors use.\n */\n errorVariant?: 'text' | 'box';\n /** Helper text rendered below the control. */\n helperText?: string;\n /** id of the control element — used to associate label + describedby. */\n htmlFor: string;\n className?: string;\n children: ReactNode;\n}\n\nexport function FormField({\n label,\n error,\n errorVariant = 'text',\n helperText,\n htmlFor,\n className,\n children,\n}: FormFieldProps) {\n const describedById = error ? `${htmlFor}-error` : helperText ? `${htmlFor}-helper` : undefined;\n return (\n <div className={cn('flex flex-col gap-1.5', className)}>\n {label ? (\n <label htmlFor={htmlFor} className=\"font-sans text-sm font-medium text-(--el-text)\">\n {label}\n </label>\n ) : null}\n {children}\n {error ? (\n <p\n id={describedById}\n role=\"alert\"\n className={cn(\n 'font-sans text-xs',\n errorVariant === 'box' &&\n 'bg-(--el-danger-surface) text-(--el-danger-surface-text) rounded-(--radius-control) px-(--spacing-tooltip-x) py-(--spacing-tooltip-y)',\n )}\n style={errorVariant === 'box' ? undefined : { color: 'var(--el-danger)' }}\n >\n {error}\n </p>\n ) : helperText ? (\n <p id={describedById} className=\"text-(--el-text-helper) font-sans text-xs\">\n {helperText}\n </p>\n ) : null}\n </div>\n );\n}\n\n/** Returns the aria-describedby id that consumers should pass to the control. */\nexport function describedById(\n htmlFor: string,\n error: string | undefined,\n helperText: string | undefined,\n): string | undefined {\n return error ? `${htmlFor}-error` : helperText ? `${htmlFor}-helper` : undefined;\n}\n","import { forwardRef, useId, type TextareaHTMLAttributes } from 'react';\nimport { FormField, describedById } from './FormField';\nimport { cn } from '../../utils/cn';\n\n/**\n * Textarea — multi-line text field. Mirrors Input's props (label, error,\n * helperText) but no prefix/suffix slots (uncommon shape for textareas).\n * Auto-resize is intentionally NOT supported in v1 — pass `rows` to size.\n *\n * @example\n * <Textarea label=\"Description\" rows={4} helperText=\"Max 500 chars\" />\n */\nexport interface TextareaProps extends TextareaHTMLAttributes<HTMLTextAreaElement> {\n label?: string;\n error?: string;\n helperText?: string;\n}\n\nexport const Textarea = forwardRef<HTMLTextAreaElement, TextareaProps>(function Textarea(\n { label, error, helperText, id, className, disabled, rows = 3, ...rest },\n ref,\n) {\n const autoId = useId();\n const textareaId = id ?? autoId;\n const describedBy = describedById(textareaId, error, helperText);\n const hasError = Boolean(error);\n return (\n <FormField label={label} error={error} helperText={helperText} htmlFor={textareaId}>\n <textarea\n ref={ref}\n id={textareaId}\n rows={rows}\n aria-invalid={hasError || undefined}\n aria-describedby={describedBy}\n disabled={disabled}\n className={cn(\n 'w-full resize-y rounded-(--radius-input) border bg-(--el-page-bg)',\n 'px-(--spacing-input-x) py-(--spacing-input-y)',\n 'font-sans text-sm text-(--el-text) placeholder:text-(--el-text-muted)',\n 'transition-colors',\n 'focus:outline-none focus:ring-2 focus:ring-(--focus-ring-color) focus:ring-offset-2 focus:ring-offset-background',\n hasError ? 'border-(--el-danger)' : 'border-(--el-border-strong)',\n 'disabled:cursor-not-allowed disabled:opacity-50',\n className,\n )}\n {...rest}\n />\n </FormField>\n );\n});\n"]}
|
|
1
|
+
{"version":3,"sources":["../../../src/utils/cn.ts","../../../src/components/ui/FormField.tsx","../../../src/components/ui/Textarea.tsx"],"names":["describedById","Textarea","jsx"],"mappings":"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","file":"Textarea.js","sourcesContent":["import { clsx, type ClassValue } from 'clsx';\nimport { twMerge } from 'tailwind-merge';\n\n/**\n * Merge Tailwind class names without conflicts.\n *\n * `clsx` handles conditional inclusion (booleans, undefineds, arrays);\n * `twMerge` resolves conflicting Tailwind classes (later wins, e.g.\n * `px-2 px-4` → `px-4`). Together they are the de facto standard for\n * composing Tailwind classes in React components.\n *\n * @example\n * cn('px-2 py-1', isActive && 'bg-primary', className)\n */\nexport function cn(...inputs: ClassValue[]): string {\n return twMerge(clsx(inputs));\n}\n","import { type ReactNode } from 'react';\nimport { cn } from '../../utils/cn';\n\n/**\n * Internal layout wrapper shared by Input and Textarea — handles the\n * label / control / helper-or-error stack pattern. Not exported from\n * the design system; consumers compose <Input> and <Textarea> directly.\n */\nexport interface FormFieldProps {\n /** Visible label rendered above the control. */\n label?: string;\n /** Error message — overrides helperText when present. Sets aria-invalid. */\n error?: string;\n /**\n * How the error message renders: the default `text` is the quiet danger\n * line; `box` is the rose-tint inline-error box (hue in the background,\n * strong text — finding #35), the 2.4.9 grammar inline editors use.\n */\n errorVariant?: 'text' | 'box';\n /** Helper text rendered below the control. */\n helperText?: string;\n /** id of the control element — used to associate label + describedby. */\n htmlFor: string;\n className?: string;\n children: ReactNode;\n}\n\nexport function FormField({\n label,\n error,\n errorVariant = 'text',\n helperText,\n htmlFor,\n className,\n children,\n}: FormFieldProps) {\n const describedById = error ? `${htmlFor}-error` : helperText ? `${htmlFor}-helper` : undefined;\n return (\n <div className={cn('flex flex-col gap-1.5', className)}>\n {label ? (\n <label htmlFor={htmlFor} className=\"font-sans text-sm font-medium text-(--el-text)\">\n {label}\n </label>\n ) : null}\n {children}\n {error ? (\n <p\n id={describedById}\n role=\"alert\"\n className={cn(\n 'font-sans text-xs',\n errorVariant === 'box' &&\n 'bg-(--el-danger-surface) text-(--el-danger-surface-text) rounded-(--radius-control) px-(--spacing-tooltip-x) py-(--spacing-tooltip-y)',\n )}\n style={errorVariant === 'box' ? undefined : { color: 'var(--el-danger)' }}\n >\n {error}\n </p>\n ) : helperText ? (\n <p id={describedById} className=\"text-(--el-text-helper) font-sans text-xs\">\n {helperText}\n </p>\n ) : null}\n </div>\n );\n}\n\n/** Returns the aria-describedby id that consumers should pass to the control. */\nexport function describedById(\n htmlFor: string,\n error: string | undefined,\n helperText: string | undefined,\n): string | undefined {\n return error ? `${htmlFor}-error` : helperText ? `${htmlFor}-helper` : undefined;\n}\n","'use client';\n\nimport {\n forwardRef,\n useCallback,\n useId,\n useLayoutEffect,\n useRef,\n type TextareaHTMLAttributes,\n} from 'react';\nimport { FormField, describedById } from './FormField';\nimport { cn } from '../../utils/cn';\n\n/**\n * Textarea — multi-line text field. Mirrors Input's props (label, error,\n * helperText) but no prefix/suffix slots (uncommon shape for textareas).\n *\n * **Auto-resize is OPT-IN.** Pass `rows` alone and the field is a fixed height,\n * exactly as it has always been. Pass `autoGrow` and `rows` becomes the MINIMUM:\n * the field grows with its content, line by line, up to `maxRows`, then stops\n * and scrolls inside itself. A chat composer passes `rows={1}`.\n *\n * @example\n * <Textarea label=\"Description\" rows={4} helperText=\"Max 500 chars\" />\n * <Textarea autoGrow rows={1} maxRows={8} aria-label=\"Message\" />\n */\nexport interface TextareaProps extends TextareaHTMLAttributes<HTMLTextAreaElement> {\n label?: string;\n error?: string;\n helperText?: string;\n /**\n * Grow with the content instead of standing at a fixed `rows`. Default\n * `false`, so every existing caller renders byte-for-byte as before.\n *\n * With it, `rows` is the MINIMUM height and the manual resize handle is\n * removed — a hand-dragged height is overwritten by the next keystroke's\n * measurement, so the two cannot both be offered.\n */\n autoGrow?: boolean;\n /**\n * The cap, in rows, for an `autoGrow` field. Omitted means no cap: the field\n * grows for as long as its content does. Meaningless without `autoGrow`.\n */\n maxRows?: number;\n}\n\n/**\n * Measure and clamp — in JS, deliberately, not on `field-sizing: content`\n * alone. That property has not shipped in every engine the product supports;\n * this path behaves the same everywhere and is testable in jsdom by stubbing\n * `scrollHeight`.\n *\n * The line height, padding and border are read from the COMPUTED style rather\n * than assumed, so the clamp stays exact under every palette, type scale and\n * density the design system's axes produce.\n */\nfunction resize(el: HTMLTextAreaElement, rows: number, maxRows: number | undefined): void {\n const cs = getComputedStyle(el);\n // `normal` has no pixel value to clamp against; fall back to the font size's\n // usual ratio rather than guessing a constant.\n const parsedLineHeight = Number.parseFloat(cs.lineHeight);\n const lineHeight = Number.isFinite(parsedLineHeight)\n ? parsedLineHeight\n : Number.parseFloat(cs.fontSize) * 1.2;\n const padding = Number.parseFloat(cs.paddingTop) + Number.parseFloat(cs.paddingBottom);\n // `box-sizing: border-box` is the design system's default, so the border is\n // inside the height we set and has to be added back to the content we measure.\n const border =\n cs.boxSizing === 'border-box'\n ? Number.parseFloat(cs.borderTopWidth) + Number.parseFloat(cs.borderBottomWidth)\n : 0;\n\n const min = rows * lineHeight + padding + border;\n const max = maxRows === undefined ? Infinity : maxRows * lineHeight + padding + border;\n\n // Reset first: `scrollHeight` reports the CURRENT box when the content fits,\n // so a field that has already grown would never shrink back.\n el.style.height = 'auto';\n const needed = el.scrollHeight + border;\n const next = Math.min(Math.max(needed, min), max);\n el.style.height = `${next}px`;\n // Below the cap there is nothing to scroll, and a scrollbar that appears for\n // one frame while growing is the tell that this is done wrong.\n el.style.overflowY = needed > max ? 'auto' : 'hidden';\n}\n\nexport const Textarea = forwardRef<HTMLTextAreaElement, TextareaProps>(function Textarea(\n { label, error, helperText, id, className, disabled, rows = 3, autoGrow, maxRows, ...rest },\n ref,\n) {\n const autoId = useId();\n const textareaId = id ?? autoId;\n const describedBy = describedById(textareaId, error, helperText);\n const hasError = Boolean(error);\n const innerRef = useRef<HTMLTextAreaElement | null>(null);\n\n // The caller's ref still has to reach the <textarea> — the planning composer\n // builds its key handling on it — so the two are merged rather than replaced.\n const setRefs = useCallback(\n (node: HTMLTextAreaElement | null) => {\n innerRef.current = node;\n if (typeof ref === 'function') ref(node);\n else if (ref) ref.current = node;\n },\n [ref],\n );\n\n // LAYOUT effect, not a plain one: a field mounted with a pre-filled value must\n // paint at its final height, and an ordinary effect lets the browser paint one\n // line first — the flash a seeded composer would show on every open.\n useLayoutEffect(() => {\n const el = innerRef.current;\n if (!el || !autoGrow) return;\n resize(el, rows, maxRows);\n // The height follows the VALUE whoever changes it — typing, a paste, a\n // controlled value set by the parent (a prefill, or a clear to '' after\n // send) — and `rest.value` is what re-runs this for the controlled case.\n // It also follows the WIDTH, because wrapping changes with width, which no\n // value change reports.\n if (typeof ResizeObserver === 'undefined') return;\n const observer = new ResizeObserver(() => resize(el, rows, maxRows));\n observer.observe(el);\n return () => observer.disconnect();\n }, [autoGrow, rows, maxRows, rest.value, rest.defaultValue]);\n\n return (\n <FormField label={label} error={error} helperText={helperText} htmlFor={textareaId}>\n <textarea\n ref={setRefs}\n id={textareaId}\n rows={rows}\n aria-invalid={hasError || undefined}\n aria-describedby={describedBy}\n disabled={disabled}\n className={cn(\n 'w-full rounded-(--radius-input) border bg-(--el-page-bg)',\n autoGrow ? 'resize-none' : 'resize-y',\n 'px-(--spacing-input-x) py-(--spacing-input-y)',\n 'font-sans text-sm text-(--el-text) placeholder:text-(--el-text-muted)',\n 'transition-colors',\n 'focus:outline-none focus:ring-2 focus:ring-(--focus-ring-color) focus:ring-offset-2 focus:ring-offset-background',\n hasError ? 'border-(--el-danger)' : 'border-(--el-border-strong)',\n 'disabled:cursor-not-allowed disabled:opacity-50',\n className,\n )}\n {...rest}\n // AFTER the spread, deliberately: it must not be replaceable, because\n // the height is the primitive's own contract. A caller's own `onInput`\n // still runs — it is called through, not dropped.\n onInput={(event) => {\n if (autoGrow) resize(event.currentTarget, rows, maxRows);\n rest.onInput?.(event);\n }}\n />\n </FormField>\n );\n});\n"]}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@motir/design-system",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"description": "Motir's 3-axis design system (colour · style · type): distributable token CSS, the axis registries, the theme-apply API, and framework-agnostic React primitives. Shared by motir-core and planner-scaffolded products.",
|
|
5
5
|
"license": "GPL-3.0-only",
|
|
6
6
|
"repository": {
|
package/theme.css
CHANGED
|
@@ -3553,6 +3553,14 @@
|
|
|
3553
3553
|
twenty palette x theme pairs. tests/theme/switchStateContrast.test.ts
|
|
3554
3554
|
(MOTIR-5715). */
|
|
3555
3555
|
--el-switch-on-border: var(--color-primary); /* the ON track's edge, on --el-page-bg */
|
|
3556
|
+
/* The OFF track's OUTER EDGE, the same measurement for the other state. It was
|
|
3557
|
+
--el-border-strong, a generic surface border, at 1.52-2.41:1 on
|
|
3558
|
+
--el-page-bg in EVERY palette and theme. That token is also the input and
|
|
3559
|
+
secondary-button border, so it keeps its mapping and the Switch gets its own:
|
|
3560
|
+
the muted-foreground grey, which is the OFF knob's ink, so the outline and
|
|
3561
|
+
the knob read as one control. Lowest 4.54:1 across all twenty palette x
|
|
3562
|
+
theme pairs. tests/theme/switchStateContrast.test.ts (MOTIR-5725). */
|
|
3563
|
+
--el-switch-off-border: var(--color-muted-foreground); /* the OFF track's edge, on --el-page-bg */
|
|
3556
3564
|
/* The two knob inks are a PAIR with the track under them, and are measured as
|
|
3557
3565
|
one (WCAG 1.4.11, 3:1 — the knob's position IS the switch's state). The
|
|
3558
3566
|
OFF knob used to share --el-switch-knob and sat at 1.01:1 on --el-muted in
|
|
@@ -3646,6 +3654,11 @@
|
|
|
3646
3654
|
--el-type-verification: var(--color-accent-orange); /* orange — with review (Investigate) */
|
|
3647
3655
|
--el-type-legal: var(--color-charcoal); /* ink — with decision (Govern) */
|
|
3648
3656
|
|
|
3657
|
+
/* ADR Amendment 3 (MOTIR-5886) admits `choice`; MOTIR-5888's design gives it
|
|
3658
|
+
the same precedent — `decision`'s charcoal, its OWN token, and a glyph
|
|
3659
|
+
(`signpost`) that tells the two apart. */
|
|
3660
|
+
--el-type-choice: var(--color-charcoal); /* ink — with decision (Govern) */
|
|
3661
|
+
|
|
3649
3662
|
/* ── Chart series (Story 4.6 · the reusable viz Story 6.3 reuses) ──
|
|
3650
3663
|
Every chart colour routes through one of these, NEVER a raw --color-*
|
|
3651
3664
|
in chart code (the per-component growth pattern). They map to Tier-0
|