@instruments/materia-ui 0.1.0 → 0.3.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/agent-docs.md +4 -3
- package/dist/components/animated-number/number-digit.d.ts +5 -3
- package/dist/components/animated-number/number-digit.js +39 -16
- package/dist/components/animated-number/number-section.d.ts +7 -5
- package/dist/components/animated-number/number-section.js +81 -65
- package/dist/components/animated-number/number-symbol.js +2 -0
- package/dist/components/animated-number/target-widths.d.ts +7 -0
- package/dist/components/animated-number/target-widths.js +13 -1
- package/dist/components/button/button-variants.js +53 -8
- package/dist/components/button-group/button-group-root.js +8 -6
- package/dist/components/calendar/calendar-root.js +39 -13
- package/dist/components/chart/chart-tooltip.js +7 -5
- package/dist/components/empty/empty-media.js +5 -5
- package/dist/components/input-group/input-group-textarea.d.ts +3 -2
- package/dist/components/input-group/input-group-textarea.js +8 -4
- package/dist/components/pill/pill-variants.js +21 -10
- package/dist/components/progress/progress-root.d.ts +7 -5
- package/dist/components/progress/progress-root.js +26 -5
- package/dist/components/radio-group/radio-group-root.d.ts +7 -3
- package/dist/components/radio-group/radio-group-root.js +14 -4
- package/dist/components/segmented-control/segmented-control-indicator.js +1 -1
- package/dist/components/segmented-control/segmented-control-item.js +1 -1
- package/dist/components/segmented-control/segmented-control-root.js +1 -1
- package/dist/components/segmented-control/segmented-control-variants.js +1 -1
- package/dist/components/stepper/stepper-item.js +6 -2
- package/dist/components/stepper/stepper-variants.d.ts +1 -0
- package/dist/components/stepper/stepper-variants.js +11 -1
- package/dist/components/tab-navigation/tab-navigation-link.js +1 -1
- package/dist/components/tabs/tabs-indicator.js +1 -1
- package/dist/components/tabs/tabs-trigger.js +1 -1
- package/dist/components/tabs/tabs-variants.js +1 -1
- package/dist/components/textarea/textarea-root.d.ts +1 -1
- package/dist/components/textarea/textarea-root.js +6 -9
- package/dist/components/textarea/use-textarea-rows.d.ts +22 -0
- package/dist/components/textarea/use-textarea-rows.js +43 -0
- package/dist/compositions/data-grid-pagination/data-grid-pagination-root.d.ts +8 -6
- package/dist/compositions/data-grid-pagination/data-grid-pagination-root.js +11 -20
- package/dist/compositions/product-card/product-card-color-count.js +26 -26
- package/dist/compositions/product-card/product-card-compact-arm.js +2 -2
- package/dist/compositions/product-card/product-card-content-types.d.ts +5 -0
- package/dist/compositions/product-card/product-card-full-arm.js +2 -2
- package/dist/compositions/product-card/product-card-provider-types.d.ts +10 -0
- package/dist/compositions/product-card/product-card-provider.js +1 -1
- package/dist/compositions/product-card/product-card-swatch-selector.js +4 -3
- package/dist/data/docs-manifest.d.ts +1 -1
- package/dist/data/docs-manifest.js +29 -74
- package/dist/styles/index.css +17 -6
- package/dist/{components/segmented/segmented-variants.d.ts → tokens/segmented.d.ts} +12 -1
- package/dist/{components/segmented/segmented-variants.js → tokens/segmented.js} +11 -0
- package/lint/tokens.mjs +1 -0
- package/package.json +5 -5
- package/dist/components/segmented/segmented-root.d.ts +0 -12
- package/dist/components/segmented/segmented-root.js +0 -12
- package/dist/components/segmented.d.ts +0 -1
- package/dist/components/segmented.js +0 -1
|
@@ -4,14 +4,13 @@ import { cn } from "@instruments/materia-ui/utils/cn";
|
|
|
4
4
|
import { isNumber, isString } from "../../utils/type-guards.js";
|
|
5
5
|
import { Flex } from "../flex.js";
|
|
6
6
|
import { Grid } from "../grid.js";
|
|
7
|
-
import { getPayloadConfigFromPayload, readDatumStringField, resolveConfigKey, useChart, } from "./chart-context.js";
|
|
7
|
+
import { getPayloadConfigFromPayload, readDatumStringField, resolveConfigKey, resolveEntryKey, useChart, } from "./chart-context.js";
|
|
8
8
|
/** recharts `Tooltip` - pair it with {@link ChartTooltipContent} via `content`. */
|
|
9
9
|
export { Tooltip as ChartTooltip } from "recharts";
|
|
10
10
|
/** One value cell inside the tooltip: swatch (or config icon) + label + value. */
|
|
11
|
-
const TooltipRow = ({ item, indicator, hideIndicator, nestLabel, nestedLabel, color, }) => {
|
|
11
|
+
const TooltipRow = ({ item, indicator, hideIndicator, nestLabel, nestedLabel, color, configKey, }) => {
|
|
12
12
|
const { config } = useChart();
|
|
13
|
-
const
|
|
14
|
-
const itemConfig = getPayloadConfigFromPayload(config, item, key);
|
|
13
|
+
const itemConfig = getPayloadConfigFromPayload(config, item, configKey);
|
|
15
14
|
const swatchColor = color ?? readDatumStringField(item, "fill") ?? item.color;
|
|
16
15
|
const swatchStyle = {
|
|
17
16
|
"--color-bg": swatchColor,
|
|
@@ -58,5 +57,8 @@ export const ChartTooltipContent = ({ active, payload, className, indicator = "d
|
|
|
58
57
|
const items = payload.filter((item) => item.type !== "none");
|
|
59
58
|
const tooltipLabel = hideLabel ? null : (_jsx(TooltipLabel, { config: config, label: label, labelClassName: labelClassName, labelFormatter: labelFormatter, labelKey: labelKey, payload: payload }));
|
|
60
59
|
const nestLabel = items.length === 1 && indicator !== "dot";
|
|
61
|
-
return (_jsxs(Grid, { className: cn("bg-popover text-popover-foreground text-caption min-w-32 items-start gap-1.5 rounded-xl border px-2.5 py-1.5 shadow-md dark:shadow-none", className), columns: "none", "data-component": "chart-tooltip-content", "data-slot": "chart-tooltip-content", gap: "none", children: [nestLabel ? null : tooltipLabel, _jsx(Grid, { className: "gap-1.5", columns: "none", gap: "none", children: items.map((item) =>
|
|
60
|
+
return (_jsxs(Grid, { className: cn("bg-popover text-popover-foreground text-caption min-w-32 items-start gap-1.5 rounded-xl border px-2.5 py-1.5 shadow-md dark:shadow-none", className), columns: "none", "data-component": "chart-tooltip-content", "data-slot": "chart-tooltip-content", gap: "none", children: [nestLabel ? null : tooltipLabel, _jsx(Grid, { className: "gap-1.5", columns: "none", gap: "none", children: items.map((item) => {
|
|
61
|
+
const key = resolveConfigKey(nameKey, item.name, item.dataKey);
|
|
62
|
+
return (_jsx(TooltipRow, { color: color, configKey: key, hideIndicator: hideIndicator, indicator: indicator, item: item, nestedLabel: tooltipLabel, nestLabel: nestLabel }, resolveEntryKey(item, key)));
|
|
63
|
+
}) })] }));
|
|
62
64
|
};
|
|
@@ -16,17 +16,17 @@ const SIZE_MEDIA_MB = {
|
|
|
16
16
|
sm: "mb-1",
|
|
17
17
|
};
|
|
18
18
|
/**
|
|
19
|
-
* Tone → text colour for the bare (plain) icon
|
|
20
|
-
*
|
|
21
|
-
*
|
|
19
|
+
* Tone → text colour for the bare (plain) icon, each at least 3:1 on the page.
|
|
20
|
+
* Brand and warning hues are too light on a light page, so they take
|
|
21
|
+
* `brand-strong` and the warning foreground.
|
|
22
22
|
*/
|
|
23
23
|
const PLAIN_ICON_TONE = {
|
|
24
|
-
brand: "text-brand-
|
|
24
|
+
brand: "text-brand-strong",
|
|
25
25
|
danger: "text-destructive",
|
|
26
26
|
info: "text-info-accent",
|
|
27
27
|
neutral: "text-muted-foreground",
|
|
28
28
|
success: "text-affirmative-accent",
|
|
29
|
-
warning: "text-
|
|
29
|
+
warning: "text-warning-foreground",
|
|
30
30
|
};
|
|
31
31
|
/** Bare-icon size (one step up from the boxed icon so it reads on its own). */
|
|
32
32
|
const PLAIN_ICON_SIZE = {
|
|
@@ -7,7 +7,8 @@ export type InputGroupTextareaProps = Omit<React.ComponentProps<"textarea">, "si
|
|
|
7
7
|
* Textarea element for use inside InputGroup (Base UI `Field.Control`
|
|
8
8
|
* rendering a `<textarea>`). Inherits disabled state from InputGroup context;
|
|
9
9
|
* inside a Field it is the field's control, and it carries `aria-invalid`
|
|
10
|
-
* when the group has an error.
|
|
10
|
+
* when the group has an error. It grows with its text, and `rows` sets its
|
|
11
|
+
* minimum height in lines.
|
|
11
12
|
*
|
|
12
13
|
* @example
|
|
13
14
|
* ```tsx
|
|
@@ -19,4 +20,4 @@ export type InputGroupTextareaProps = Omit<React.ComponentProps<"textarea">, "si
|
|
|
19
20
|
* </InputGroup>
|
|
20
21
|
* ```
|
|
21
22
|
*/
|
|
22
|
-
export declare const InputGroupTextarea: ({ className, testId, disabled: disabledProp, id, name, ...props }: InputGroupTextareaProps) => React.JSX.Element;
|
|
23
|
+
export declare const InputGroupTextarea: ({ className, testId, disabled: disabledProp, id, name, rows, style, ...props }: InputGroupTextareaProps) => React.JSX.Element;
|
|
@@ -4,11 +4,13 @@ import { Field as FieldPrimitive } from "@base-ui/react/field";
|
|
|
4
4
|
import { cn } from "@instruments/materia-ui/utils/cn";
|
|
5
5
|
import { INPUT_CONTROL_RESET } from "../../tokens/input-shell.js";
|
|
6
6
|
import { useInputGroup } from "../input-group/input-group-context.js";
|
|
7
|
+
import { useTextareaRows } from "../textarea/use-textarea-rows.js";
|
|
7
8
|
/**
|
|
8
9
|
* Textarea element for use inside InputGroup (Base UI `Field.Control`
|
|
9
10
|
* rendering a `<textarea>`). Inherits disabled state from InputGroup context;
|
|
10
11
|
* inside a Field it is the field's control, and it carries `aria-invalid`
|
|
11
|
-
* when the group has an error.
|
|
12
|
+
* when the group has an error. It grows with its text, and `rows` sets its
|
|
13
|
+
* minimum height in lines.
|
|
12
14
|
*
|
|
13
15
|
* @example
|
|
14
16
|
* ```tsx
|
|
@@ -20,10 +22,12 @@ import { useInputGroup } from "../input-group/input-group-context.js";
|
|
|
20
22
|
* </InputGroup>
|
|
21
23
|
* ```
|
|
22
24
|
*/
|
|
23
|
-
export const InputGroupTextarea = ({ className, testId, disabled: disabledProp, id, name, ...props }) => {
|
|
25
|
+
export const InputGroupTextarea = ({ className, testId, disabled: disabledProp, id, name, rows, style, ...props }) => {
|
|
24
26
|
const { disabled: contextDisabled, hasError } = useInputGroup();
|
|
25
27
|
const disabled = disabledProp ?? contextDisabled;
|
|
28
|
+
const { className: sizingClassName, ref: sizingRef, style: sizingStyle, } = useTextareaRows(rows);
|
|
26
29
|
// The control owns id/name/disabled so Field can label and validate it;
|
|
27
|
-
// textarea-only props ride on the rendered element.
|
|
28
|
-
|
|
30
|
+
// textarea-only props ride on the rendered element. The sizing ref sits on
|
|
31
|
+
// the control so a caller's own `ref` on the textarea still lands.
|
|
32
|
+
return (_jsx(FieldPrimitive.Control, { disabled: disabled, ref: sizingRef, render: _jsx("textarea", { className: cn(INPUT_CONTROL_RESET, "text-paragraph flex resize-none px-0 py-2", sizingClassName, className), "data-component": "input-group-textarea", "data-slot": "input-group-control", rows: rows, style: { ...sizingStyle, ...style }, ...(hasError ? { "aria-invalid": true } : {}), ...(testId === undefined ? {} : { "data-testid": testId }), ...props }), ...(id === undefined ? {} : { id }), ...(name === undefined ? {} : { name }) }));
|
|
29
33
|
};
|
|
@@ -60,15 +60,19 @@ export const pillVariants = cva(cn("inline-flex items-center justify-center bord
|
|
|
60
60
|
emphasis: "solid",
|
|
61
61
|
tone: "success",
|
|
62
62
|
},
|
|
63
|
+
// The soft token is pale in light and graphite in dark, the reverse of
|
|
64
|
+
// the accent fill, so one class reads on it in both themes.
|
|
63
65
|
{
|
|
64
|
-
className: "text-
|
|
66
|
+
className: "text-info-soft",
|
|
65
67
|
emphasis: "solid",
|
|
66
68
|
tone: "info",
|
|
67
69
|
},
|
|
70
|
+
// Dark mode gives the warning foreground the accent's own colour, as text
|
|
71
|
+
// on graphite, so the solid takes the graphite soft token instead.
|
|
68
72
|
{
|
|
69
|
-
className: "text-
|
|
73
|
+
className: "dark:text-warning-soft",
|
|
70
74
|
emphasis: "solid",
|
|
71
|
-
tone: "
|
|
75
|
+
tone: "warning",
|
|
72
76
|
},
|
|
73
77
|
/* ── Soft (was `light`) ───────────────────────────── */
|
|
74
78
|
/* neutral+soft reproduces the old secondary/solid render */
|
|
@@ -152,8 +156,10 @@ export const pillVariants = cva(cn("inline-flex items-center justify-center bord
|
|
|
152
156
|
emphasis: "ghost",
|
|
153
157
|
tone: "success",
|
|
154
158
|
},
|
|
159
|
+
// Amber is too light to read on a light page, so light mode takes the
|
|
160
|
+
// warning foreground.
|
|
155
161
|
{
|
|
156
|
-
className: "text-warning-
|
|
162
|
+
className: "text-warning-foreground dark:text-warning-accent",
|
|
157
163
|
emphasis: "ghost",
|
|
158
164
|
tone: "warning",
|
|
159
165
|
},
|
|
@@ -167,8 +173,9 @@ export const pillVariants = cva(cn("inline-flex items-center justify-center bord
|
|
|
167
173
|
emphasis: "ghost",
|
|
168
174
|
tone: "danger",
|
|
169
175
|
},
|
|
176
|
+
// Brand accent fails on a light page; graphite keeps the accent.
|
|
170
177
|
{
|
|
171
|
-
className: "text-brand-
|
|
178
|
+
className: "text-brand-strong dark:text-brand-accent",
|
|
172
179
|
emphasis: "ghost",
|
|
173
180
|
tone: "brand",
|
|
174
181
|
},
|
|
@@ -178,28 +185,32 @@ export const pillVariants = cva(cn("inline-flex items-center justify-center bord
|
|
|
178
185
|
emphasis: "transparent",
|
|
179
186
|
tone: "neutral",
|
|
180
187
|
},
|
|
188
|
+
// A half status fill is pale over a light page and mid-toned over
|
|
189
|
+
// graphite, so status tones take dark text in light and white in dark.
|
|
181
190
|
{
|
|
182
|
-
className: "bg-affirmative-accent/50 transform-gpu border-transparent
|
|
191
|
+
className: "bg-affirmative-accent/50 text-foreground transform-gpu border-transparent backdrop-blur-md will-change-[filter,background-color] dark:text-white/90",
|
|
183
192
|
emphasis: "transparent",
|
|
184
193
|
tone: "success",
|
|
185
194
|
},
|
|
186
195
|
{
|
|
187
|
-
className: "bg-warning-accent/50 transform-gpu border-transparent backdrop-blur-md will-change-[filter,background-color]",
|
|
196
|
+
className: "bg-warning-accent/50 transform-gpu border-transparent backdrop-blur-md will-change-[filter,background-color] dark:text-white/90",
|
|
188
197
|
emphasis: "transparent",
|
|
189
198
|
tone: "warning",
|
|
190
199
|
},
|
|
191
200
|
{
|
|
192
|
-
className: "bg-info-accent/50 transform-gpu border-transparent
|
|
201
|
+
className: "bg-info-accent/50 text-foreground transform-gpu border-transparent backdrop-blur-md will-change-[filter,background-color] dark:text-white/90",
|
|
193
202
|
emphasis: "transparent",
|
|
194
203
|
tone: "info",
|
|
195
204
|
},
|
|
196
205
|
{
|
|
197
|
-
className: "bg-destructive/50 transform-gpu border-transparent
|
|
206
|
+
className: "bg-destructive/50 text-foreground transform-gpu border-transparent backdrop-blur-md will-change-[filter,background-color] dark:text-white/90",
|
|
198
207
|
emphasis: "transparent",
|
|
199
208
|
tone: "danger",
|
|
200
209
|
},
|
|
210
|
+
// Over graphite a half brand fill turns olive, so dark mode thickens it
|
|
211
|
+
// and keeps the brand foreground.
|
|
201
212
|
{
|
|
202
|
-
className: "bg-brand/50 transform-gpu border-transparent backdrop-blur-md will-change-[filter,background-color]",
|
|
213
|
+
className: "bg-brand/50 dark:bg-brand/70 transform-gpu border-transparent backdrop-blur-md will-change-[filter,background-color]",
|
|
203
214
|
emphasis: "transparent",
|
|
204
215
|
tone: "brand",
|
|
205
216
|
},
|
|
@@ -21,15 +21,17 @@ interface ProgressCircleProps {
|
|
|
21
21
|
valueFormatter?: (value: number, max: number) => string;
|
|
22
22
|
}
|
|
23
23
|
/**
|
|
24
|
-
* Linear progress bar showing completion from 0 to 100
|
|
25
|
-
*
|
|
26
|
-
*
|
|
24
|
+
* Linear progress bar showing completion from 0 to 100. Pass `value={null}`
|
|
25
|
+
* when the amount of work is unknown: a short bar slides across the track on a
|
|
26
|
+
* loop, and under reduced motion it holds still in the middle. Built on Base
|
|
27
|
+
* UI Progress for accessible semantics.
|
|
27
28
|
*
|
|
28
|
-
* @bestPractice Use `Progress` for determinate 0–100 completion
|
|
29
|
+
* @bestPractice Use `Progress` for determinate 0–100 completion and `value={null}` when the total is unknown; position in a stepped flow is `ProgressDots`.
|
|
29
30
|
* @bestPractice For a ring instead of a bar use `ProgressCircle` - it needs an explicit `label` for its native `<progress>` accessible name.
|
|
31
|
+
* @bestPractice Mount `ReducedMotionProvider` at the app root - the indeterminate bar slides via `m.*` (LazyMotion).
|
|
30
32
|
* @category feedback
|
|
31
33
|
*/
|
|
32
|
-
declare const Progress: ({ className, ...props }: ProgressProps) => React.JSX.Element;
|
|
34
|
+
declare const Progress: ({ className, value, ...props }: ProgressProps) => React.JSX.Element;
|
|
33
35
|
/**
|
|
34
36
|
* Circular progress ring with an optional centred content slot; the value is
|
|
35
37
|
* clamped to `0..max` and exposed through a visually hidden native `<progress>`.
|
|
@@ -2,17 +2,38 @@
|
|
|
2
2
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
3
|
import { Progress as ProgressPrimitive } from "@base-ui/react/progress";
|
|
4
4
|
import { Center } from "@instruments/materia-ui/components/center";
|
|
5
|
+
import { durations } from "@instruments/materia-ui/tokens/motion";
|
|
5
6
|
import { cn } from "@instruments/materia-ui/utils/cn";
|
|
7
|
+
import { useReducedMotion } from "motion/react";
|
|
8
|
+
import * as m from "motion/react-m";
|
|
9
|
+
// The indeterminate bar is a third of the track, so translating it by its own
|
|
10
|
+
// width runs it from just off the left edge (-100%) to just off the right
|
|
11
|
+
// (300%).
|
|
12
|
+
const SLIDE = { x: ["-100%", "300%"] };
|
|
13
|
+
const SLIDE_TRANSITION = {
|
|
14
|
+
duration: durations.slow,
|
|
15
|
+
ease: "linear",
|
|
16
|
+
repeat: Number.POSITIVE_INFINITY,
|
|
17
|
+
};
|
|
6
18
|
/**
|
|
7
|
-
* Linear progress bar showing completion from 0 to 100
|
|
8
|
-
*
|
|
9
|
-
*
|
|
19
|
+
* Linear progress bar showing completion from 0 to 100. Pass `value={null}`
|
|
20
|
+
* when the amount of work is unknown: a short bar slides across the track on a
|
|
21
|
+
* loop, and under reduced motion it holds still in the middle. Built on Base
|
|
22
|
+
* UI Progress for accessible semantics.
|
|
10
23
|
*
|
|
11
|
-
* @bestPractice Use `Progress` for determinate 0–100 completion
|
|
24
|
+
* @bestPractice Use `Progress` for determinate 0–100 completion and `value={null}` when the total is unknown; position in a stepped flow is `ProgressDots`.
|
|
12
25
|
* @bestPractice For a ring instead of a bar use `ProgressCircle` - it needs an explicit `label` for its native `<progress>` accessible name.
|
|
26
|
+
* @bestPractice Mount `ReducedMotionProvider` at the app root - the indeterminate bar slides via `m.*` (LazyMotion).
|
|
13
27
|
* @category feedback
|
|
14
28
|
*/
|
|
15
|
-
const Progress = ({ className, ...props }) =>
|
|
29
|
+
const Progress = ({ className, value, ...props }) => {
|
|
30
|
+
// Gate on the preference directly: MotionConfig's reduced mode would jump
|
|
31
|
+
// the slide to its last keyframe, which leaves the bar off the track.
|
|
32
|
+
const reducedMotion = useReducedMotion() === true;
|
|
33
|
+
const indeterminate = value === null;
|
|
34
|
+
const sliding = indeterminate && !reducedMotion;
|
|
35
|
+
return (_jsx(ProgressPrimitive.Root, { className: cn("bg-muted relative block h-2 w-full overflow-hidden rounded-full", className), "data-component": "progress", "data-slot": "progress", value: value, ...props, children: _jsx(ProgressPrimitive.Track, { className: "block size-full", "data-component": "progress-track", "data-slot": "progress-track", children: _jsx(ProgressPrimitive.Indicator, { className: cn("bg-primary transition-width block h-full", indeterminate && "w-1/3 rounded-full", indeterminate && !sliding && "translate-x-full"), "data-component": "progress-indicator", "data-slot": "progress-indicator", render: sliding ? (_jsx(m.div, { animate: SLIDE, transition: SLIDE_TRANSITION })) : (_jsx("div", {})) }) }) }));
|
|
36
|
+
};
|
|
16
37
|
const clamp = (value, min, max) => {
|
|
17
38
|
if (Number.isNaN(value)) {
|
|
18
39
|
return min;
|
|
@@ -30,13 +30,17 @@ declare const RadioGroup: ({ className, ...props }: RadioGroupPrimitive.Props) =
|
|
|
30
30
|
export type RadioGroupItemProps = RadioProps & {
|
|
31
31
|
/** Label text for the radio option */
|
|
32
32
|
children: React.ReactNode;
|
|
33
|
+
/** Optional helper text below the label; wired to the radio via `aria-describedby`. */
|
|
34
|
+
description?: React.ReactNode;
|
|
33
35
|
};
|
|
34
36
|
/**
|
|
35
|
-
* A radio option with an associated label
|
|
37
|
+
* A radio option with an associated label and optional description. Combines
|
|
38
|
+
* Radio, Label and muted helper text, laid out like `CheckboxField`.
|
|
36
39
|
*
|
|
37
40
|
* @param props - The radio group item props
|
|
38
41
|
* @param props.value - Unique value for this radio option (required).
|
|
39
42
|
* @param props.children - Label text content.
|
|
43
|
+
* @param props.description - Helper text below the label, linked to the radio via `aria-describedby`.
|
|
40
44
|
* @param props.size - Size using shared ComponentSize scale. Defaults to "sm".
|
|
41
45
|
* @param props.disabled - Disable this radio option. Defaults to false.
|
|
42
46
|
* @param props.className - Additional CSS classes for the radio input.
|
|
@@ -46,9 +50,9 @@ export type RadioGroupItemProps = RadioProps & {
|
|
|
46
50
|
* ```tsx
|
|
47
51
|
* <RadioGroup>
|
|
48
52
|
* <RadioGroupItem value="a">Option A</RadioGroupItem>
|
|
49
|
-
* <RadioGroupItem value="b" disabled>Option B</RadioGroupItem>
|
|
53
|
+
* <RadioGroupItem value="b" description="Arrives tomorrow" disabled>Option B</RadioGroupItem>
|
|
50
54
|
* </RadioGroup>
|
|
51
55
|
* ```
|
|
52
56
|
*/
|
|
53
|
-
declare const RadioGroupItem: ({ children, className, id: providedId, disabled, ...props }: RadioGroupItemProps) => React.JSX.Element;
|
|
57
|
+
declare const RadioGroupItem: ({ children, description, className, id: providedId, disabled, "aria-describedby": ariaDescribedBy, ...props }: RadioGroupItemProps) => React.JSX.Element;
|
|
54
58
|
export { RadioGroup, RadioGroupItem };
|
|
@@ -5,8 +5,10 @@ import { cn } from "@instruments/materia-ui/utils/cn";
|
|
|
5
5
|
import { useId } from "react";
|
|
6
6
|
import { CONTROL_LINE_CLASSES } from "../../tokens/control-line.js";
|
|
7
7
|
import { Flex } from "../flex.js";
|
|
8
|
+
import { Grid } from "../grid.js";
|
|
8
9
|
import { Label } from "../label.js";
|
|
9
10
|
import { Radio } from "../radio.js";
|
|
11
|
+
import { Text } from "../text.js";
|
|
10
12
|
/**
|
|
11
13
|
* Container for a group of radio buttons. Manages single selection state.
|
|
12
14
|
*
|
|
@@ -34,11 +36,13 @@ import { Radio } from "../radio.js";
|
|
|
34
36
|
*/
|
|
35
37
|
const RadioGroup = ({ className, ...props }) => (_jsx(RadioGroupPrimitive, { className: cn("grid gap-3", className), "data-component": "radio-group", "data-slot": "radio-group", ...props }));
|
|
36
38
|
/**
|
|
37
|
-
* A radio option with an associated label
|
|
39
|
+
* A radio option with an associated label and optional description. Combines
|
|
40
|
+
* Radio, Label and muted helper text, laid out like `CheckboxField`.
|
|
38
41
|
*
|
|
39
42
|
* @param props - The radio group item props
|
|
40
43
|
* @param props.value - Unique value for this radio option (required).
|
|
41
44
|
* @param props.children - Label text content.
|
|
45
|
+
* @param props.description - Helper text below the label, linked to the radio via `aria-describedby`.
|
|
42
46
|
* @param props.size - Size using shared ComponentSize scale. Defaults to "sm".
|
|
43
47
|
* @param props.disabled - Disable this radio option. Defaults to false.
|
|
44
48
|
* @param props.className - Additional CSS classes for the radio input.
|
|
@@ -48,13 +52,19 @@ const RadioGroup = ({ className, ...props }) => (_jsx(RadioGroupPrimitive, { cla
|
|
|
48
52
|
* ```tsx
|
|
49
53
|
* <RadioGroup>
|
|
50
54
|
* <RadioGroupItem value="a">Option A</RadioGroupItem>
|
|
51
|
-
* <RadioGroupItem value="b" disabled>Option B</RadioGroupItem>
|
|
55
|
+
* <RadioGroupItem value="b" description="Arrives tomorrow" disabled>Option B</RadioGroupItem>
|
|
52
56
|
* </RadioGroup>
|
|
53
57
|
* ```
|
|
54
58
|
*/
|
|
55
|
-
const RadioGroupItem = ({ children, className, id: providedId, disabled, ...props }) => {
|
|
59
|
+
const RadioGroupItem = ({ children, description, className, id: providedId, disabled, "aria-describedby": ariaDescribedBy, ...props }) => {
|
|
56
60
|
const generatedId = useId();
|
|
61
|
+
const descriptionId = useId();
|
|
57
62
|
const id = providedId ?? generatedId;
|
|
58
|
-
|
|
63
|
+
const hasDescription = description !== undefined && description !== null;
|
|
64
|
+
// Keep any ids the caller passed, and add the description's own.
|
|
65
|
+
const describedBy = hasDescription
|
|
66
|
+
? [ariaDescribedBy, descriptionId].join(" ").trim()
|
|
67
|
+
: ariaDescribedBy;
|
|
68
|
+
return (_jsxs(Flex, { align: "flex-start", "data-component": "radio-group-item", "data-disabled": disabled, "data-slot": "radio-group-item", gap: "sm", children: [_jsx("span", { className: cn(CONTROL_LINE_CLASSES, "text-paragraph-sm"), children: _jsx(Radio, { "aria-describedby": describedBy, className: className, disabled: disabled, id: id, ...props }) }), _jsxs(Grid, { columns: "none", gap: "2xs", children: [_jsx(Label, { className: cn("cursor-pointer", disabled === true ? "cursor-not-allowed opacity-60" : null), htmlFor: id, children: children }), hasDescription ? (_jsx(Text, { id: descriptionId, size: "paragraph-sm", variant: "muted", children: description })) : null] })] }));
|
|
59
69
|
};
|
|
60
70
|
export { RadioGroup, RadioGroupItem };
|
|
@@ -3,7 +3,7 @@ import { jsx as _jsx } from "react/jsx-runtime";
|
|
|
3
3
|
import { springs } from "@instruments/materia-ui/tokens/motion";
|
|
4
4
|
import { cn } from "@instruments/materia-ui/utils/cn";
|
|
5
5
|
import * as m from "motion/react-m";
|
|
6
|
-
import { SEGMENTED_INDICATOR_CLASS } from "
|
|
6
|
+
import { SEGMENTED_INDICATOR_CLASS } from "../../tokens/segmented.js";
|
|
7
7
|
import { useSegmentedControlContext } from "./segmented-control-context.js";
|
|
8
8
|
/**
|
|
9
9
|
* Animated active-segment indicator. A single instance is mounted under the
|
|
@@ -5,9 +5,9 @@ import { Dot } from "@instruments/materia-ui/components/dot";
|
|
|
5
5
|
import { Icon } from "@instruments/materia-ui/components/icon";
|
|
6
6
|
import { cn } from "@instruments/materia-ui/utils/cn";
|
|
7
7
|
import { getResponsiveSizeOverrides } from "../../tokens/responsive.js";
|
|
8
|
+
import { SEGMENTED_TRIGGER_PADDING } from "../../tokens/segmented.js";
|
|
8
9
|
import { warnMissingAriaLabel } from "../../utils/warn-missing-aria-label.js";
|
|
9
10
|
import { Flex } from "../flex.js";
|
|
10
|
-
import { SEGMENTED_TRIGGER_PADDING } from "../segmented/segmented-root.js";
|
|
11
11
|
import { useSegmentedControlContext } from "./segmented-control-context.js";
|
|
12
12
|
import { SegmentedControlIndicator } from "./segmented-control-indicator.js";
|
|
13
13
|
import { segmentedControlItemVariants } from "./segmented-control-variants.js";
|
|
@@ -6,8 +6,8 @@ import { ScrollFade } from "@instruments/materia-ui/components/scroll-fade";
|
|
|
6
6
|
import { cn } from "@instruments/materia-ui/utils/cn";
|
|
7
7
|
import { useEffect, useId, useMemo, useRef, useState } from "react";
|
|
8
8
|
import { getResponsiveSizeOverrides, resolveResponsiveBase, } from "../../tokens/responsive.js";
|
|
9
|
+
import { SEGMENTED_GAP, SEGMENTED_SHELL_BASE, SEGMENTED_SHELL_PADDING, } from "../../tokens/segmented.js";
|
|
9
10
|
import { Flex } from "../flex.js";
|
|
10
|
-
import { SEGMENTED_GAP, SEGMENTED_SHELL_BASE, SEGMENTED_SHELL_PADDING, } from "../segmented/segmented-root.js";
|
|
11
11
|
import { SegmentedControlContext } from "./segmented-control-context.js";
|
|
12
12
|
import { useSingleSelection } from "./use-single-selection.js";
|
|
13
13
|
/**
|
|
@@ -2,8 +2,8 @@ import { focusRing } from "@instruments/materia-ui/tokens/focus-ring";
|
|
|
2
2
|
import { cn } from "@instruments/materia-ui/utils/cn";
|
|
3
3
|
import { cva } from "class-variance-authority";
|
|
4
4
|
import { DISABLED } from "../../tokens/input-shell.js";
|
|
5
|
+
import { SEGMENTED_TRIGGER_PADDING, SEGMENTED_TRIGGER_TEXT, } from "../../tokens/segmented.js";
|
|
5
6
|
import { typedEntries } from "../../utils/typed-entries.js";
|
|
6
|
-
import { SEGMENTED_TRIGGER_PADDING, SEGMENTED_TRIGGER_TEXT, } from "../segmented/segmented-root.js";
|
|
7
7
|
/**
|
|
8
8
|
* Item padding lives in compound variants (mirroring `tabsTriggerVariants`) so a
|
|
9
9
|
* single size dimension drives text (base `size` variant) and padding
|
|
@@ -58,7 +58,11 @@ export const StepperItem = ({ value, label, disabled = false, icon = Check, clas
|
|
|
58
58
|
const circleSize = state === "completed"
|
|
59
59
|
? CIRCLE_SIZES[baseSize].completed
|
|
60
60
|
: CIRCLE_SIZES[baseSize].default;
|
|
61
|
-
return (_jsxs("div", { className: cn("relative", className), "data-slot": "stepper-item", "data-state": state, ...rest, children: [_jsx("div", { className: cn(stepperCircleContainerVariants({ size: baseSize }), getResponsiveSizeOverrides(size, STEPPER_CIRCLE_CONTAINER_SIZE_CLASSES)), children: _jsx(m.button, { "aria-current": state === "current" ? "step" : undefined, "aria-label": label !== undefined && label !== null && label !== ""
|
|
61
|
+
return (_jsxs("div", { className: cn("group/step relative", className), "data-slot": "stepper-item", "data-state": state, ...rest, children: [_jsx("div", { className: cn(stepperCircleContainerVariants({ size: baseSize }), getResponsiveSizeOverrides(size, STEPPER_CIRCLE_CONTAINER_SIZE_CLASSES)), children: _jsx(m.button, { "aria-current": state === "current" ? "step" : undefined, "aria-label": label !== undefined && label !== null && label !== ""
|
|
62
62
|
? `Step: ${label}`
|
|
63
|
-
: `Step ${value + 1}`, className: cn(stepperCircleVariants({ size: baseSize, state }), isInteractive && "cursor-pointer", !isInteractive && "cursor-default"), disabled: disabled, layout: true, onClick: handleClick, onKeyDown: handleKeyDown, style: { borderRadius: 9999, height: circleSize, width: circleSize }, transition: springs.standard, type: "button", children: _jsx(m.span, { className: "flex", layout: true, children: _jsx(Icon, { className: stepperIconVariants({ size: baseSize, state }), icon: icon, size: iconSize }) }) }) }), label !== undefined && label !== null && label !== "" && (_jsx("span", { className: cn(stepperLabelVariants({
|
|
63
|
+
: `Step ${value + 1}`, className: cn(stepperCircleVariants({ size: baseSize, state }), isInteractive && "cursor-pointer", !isInteractive && "cursor-default"), disabled: disabled, layout: true, onClick: handleClick, onKeyDown: handleKeyDown, style: { borderRadius: 9999, height: circleSize, width: circleSize }, transition: springs.standard, type: "button", children: _jsx(m.span, { className: "flex", layout: true, children: _jsx(Icon, { className: stepperIconVariants({ size: baseSize, state }), icon: icon, size: iconSize }) }) }) }), label !== undefined && label !== null && label !== "" && (_jsx("span", { className: cn(stepperLabelVariants({
|
|
64
|
+
edge: value === 0 ? "start" : "center",
|
|
65
|
+
size: baseSize,
|
|
66
|
+
state,
|
|
67
|
+
}), getResponsiveSizeOverrides(size, STEPPER_LABEL_SIZE_CLASSES)), children: label }))] }));
|
|
64
68
|
};
|
|
@@ -36,6 +36,7 @@ export declare const stepperIconVariants: (props?: ({
|
|
|
36
36
|
} & import("class-variance-authority/types").ClassProp) | undefined) => string;
|
|
37
37
|
/** Step label variants - absolutely positioned to not affect flex layout */
|
|
38
38
|
export declare const stepperLabelVariants: (props?: ({
|
|
39
|
+
edge?: "center" | "start" | null | undefined;
|
|
39
40
|
size?: StepperSize | null | undefined;
|
|
40
41
|
state?: "completed" | "current" | "disabled" | "upcoming" | null | undefined;
|
|
41
42
|
} & import("class-variance-authority/types").ClassProp) | undefined) => string;
|
|
@@ -105,12 +105,22 @@ export const stepperIconVariants = cva("transition-colors", {
|
|
|
105
105
|
},
|
|
106
106
|
});
|
|
107
107
|
/** Step label variants - absolutely positioned to not affect flex layout */
|
|
108
|
-
|
|
108
|
+
// Labels centre under their circles, except at the ends: the first starts at
|
|
109
|
+
// its circle's left edge and the last ends at its circle's right edge, so no
|
|
110
|
+
// label hangs outside the stepper's box. The last step is found with CSS
|
|
111
|
+
// (`last-child` of the root, via the item's `group/step`) so it holds on the
|
|
112
|
+
// server render; the first is passed in as `edge`.
|
|
113
|
+
export const stepperLabelVariants = cva("text-caption absolute whitespace-nowrap group-last/step:right-0 group-last/step:left-auto group-last/step:translate-x-0 group-last/step:text-right", {
|
|
109
114
|
defaultVariants: {
|
|
115
|
+
edge: "center",
|
|
110
116
|
size: "base",
|
|
111
117
|
state: "upcoming",
|
|
112
118
|
},
|
|
113
119
|
variants: {
|
|
120
|
+
edge: {
|
|
121
|
+
center: "left-1/2 -translate-x-1/2 text-center",
|
|
122
|
+
start: "left-0 text-left",
|
|
123
|
+
},
|
|
114
124
|
size: STEPPER_LABEL_SIZE_CLASSES,
|
|
115
125
|
state: {
|
|
116
126
|
completed: "text-foreground",
|
|
@@ -4,9 +4,9 @@ import { mergeProps } from "@base-ui/react/merge-props";
|
|
|
4
4
|
import { useRender } from "@base-ui/react/use-render";
|
|
5
5
|
import { cn } from "@instruments/materia-ui/utils/cn";
|
|
6
6
|
import { getResponsiveSizeOverrides, resolveResponsiveBase, } from "../../tokens/responsive.js";
|
|
7
|
+
import { SEGMENTED_ICON_SIZE, SEGMENTED_TRIGGER_TEXT, } from "../../tokens/segmented.js";
|
|
7
8
|
import { Flex } from "../flex.js";
|
|
8
9
|
import { Icon } from "../icon.js";
|
|
9
|
-
import { SEGMENTED_ICON_SIZE, SEGMENTED_TRIGGER_TEXT, } from "../segmented/segmented-variants.js";
|
|
10
10
|
import { useTabsContext } from "../tabs/tabs-context.js";
|
|
11
11
|
import { TabsIndicator } from "../tabs/tabs-indicator.js";
|
|
12
12
|
import { DEFAULT_TABS_SIZE, tabsTriggerVariants } from "../tabs/tabs-variants.js";
|
|
@@ -3,7 +3,7 @@ import { jsx as _jsx } from "react/jsx-runtime";
|
|
|
3
3
|
import { springs } from "@instruments/materia-ui/tokens/motion";
|
|
4
4
|
import { cn } from "@instruments/materia-ui/utils/cn";
|
|
5
5
|
import * as m from "motion/react-m";
|
|
6
|
-
import { SEGMENTED_INDICATOR_CLASS } from "
|
|
6
|
+
import { SEGMENTED_INDICATOR_CLASS } from "../../tokens/segmented.js";
|
|
7
7
|
import { useTabsContext } from "../tabs/tabs-context.js";
|
|
8
8
|
const indicatorStyles = {
|
|
9
9
|
line: "absolute inset-x-0 bottom-0 h-0.5 bg-primary dark:bg-gray-100",
|
|
@@ -3,10 +3,10 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
3
3
|
import { Tabs as TabsPrimitive } from "@base-ui/react/tabs";
|
|
4
4
|
import { cn } from "@instruments/materia-ui/utils/cn";
|
|
5
5
|
import { getResponsiveSizeOverrides, resolveResponsiveBase, } from "../../tokens/responsive.js";
|
|
6
|
+
import { SEGMENTED_DOT_SIZE, SEGMENTED_ICON_SIZE, SEGMENTED_TRIGGER_TEXT, } from "../../tokens/segmented.js";
|
|
6
7
|
import { Dot } from "../dot.js";
|
|
7
8
|
import { Flex } from "../flex.js";
|
|
8
9
|
import { Icon } from "../icon.js";
|
|
9
|
-
import { SEGMENTED_DOT_SIZE, SEGMENTED_ICON_SIZE, SEGMENTED_TRIGGER_TEXT, } from "../segmented/segmented-variants.js";
|
|
10
10
|
import { useTabsContext } from "../tabs/tabs-context.js";
|
|
11
11
|
import { TabsIndicator } from "../tabs/tabs-indicator.js";
|
|
12
12
|
import { DEFAULT_TABS_SIZE, tabsTriggerVariants } from "../tabs/tabs-variants.js";
|
|
@@ -2,8 +2,8 @@ import { focusRing } from "@instruments/materia-ui/tokens/focus-ring";
|
|
|
2
2
|
import { cn } from "@instruments/materia-ui/utils/cn";
|
|
3
3
|
import { cva } from "class-variance-authority";
|
|
4
4
|
import { DISABLED } from "../../tokens/input-shell.js";
|
|
5
|
+
import { SEGMENTED_GAP, SEGMENTED_SHELL_BASE, SEGMENTED_SHELL_PADDING, SEGMENTED_TRIGGER_PADDING, SEGMENTED_TRIGGER_TEXT, } from "../../tokens/segmented.js";
|
|
5
6
|
import { typedEntries } from "../../utils/typed-entries.js";
|
|
6
|
-
import { SEGMENTED_GAP, SEGMENTED_SHELL_BASE, SEGMENTED_SHELL_PADDING, SEGMENTED_TRIGGER_PADDING, SEGMENTED_TRIGGER_TEXT, } from "../segmented/segmented-root.js";
|
|
7
7
|
const segmentedShellPaddingVariants = typedEntries(SEGMENTED_SHELL_PADDING).map(([size, className]) => ({
|
|
8
8
|
className,
|
|
9
9
|
size,
|
|
@@ -24,7 +24,7 @@ interface TextareaProps extends WithTestId<React.ComponentProps<"textarea">> {
|
|
|
24
24
|
* ```
|
|
25
25
|
*
|
|
26
26
|
* @bestPractice Inside a `Field`, let invalid state cascade like `Input` - omit `hasError`; pass it only to force error styling on a standalone `Textarea`.
|
|
27
|
-
* @bestPractice It auto-grows with content (`field-sizing-content`); set `rows` for
|
|
27
|
+
* @bestPractice It auto-grows with content (`field-sizing-content`); set `rows` for a minimum height in lines rather than pinning height via CSS.
|
|
28
28
|
* @bestPractice Give it a label - inside a `Field` a `FieldLabel` targets it with no ids to wire; standalone, point a `Label`'s `htmlFor` at its `id`. A placeholder is not an accessible name.
|
|
29
29
|
* @category forms
|
|
30
30
|
*/
|
|
@@ -6,6 +6,7 @@ import { cn } from "@instruments/materia-ui/utils/cn";
|
|
|
6
6
|
import { hasErrorInput } from "../../tokens/has-error-input.js";
|
|
7
7
|
import { INPUT_SHELL } from "../../tokens/input-shell.js";
|
|
8
8
|
import { useFieldContext } from "../field/field-context.js";
|
|
9
|
+
import { useTextareaRows } from "./use-textarea-rows.js";
|
|
9
10
|
/**
|
|
10
11
|
* Renders a textarea element for multi-line text input (Base UI
|
|
11
12
|
* `Field.Control` rendering a `<textarea>`). Inside a Field it is the field's
|
|
@@ -26,21 +27,17 @@ import { useFieldContext } from "../field/field-context.js";
|
|
|
26
27
|
* ```
|
|
27
28
|
*
|
|
28
29
|
* @bestPractice Inside a `Field`, let invalid state cascade like `Input` - omit `hasError`; pass it only to force error styling on a standalone `Textarea`.
|
|
29
|
-
* @bestPractice It auto-grows with content (`field-sizing-content`); set `rows` for
|
|
30
|
+
* @bestPractice It auto-grows with content (`field-sizing-content`); set `rows` for a minimum height in lines rather than pinning height via CSS.
|
|
30
31
|
* @bestPractice Give it a label - inside a `Field` a `FieldLabel` targets it with no ids to wire; standalone, point a `Label`'s `htmlFor` at its `id`. A placeholder is not an accessible name.
|
|
31
32
|
* @category forms
|
|
32
33
|
*/
|
|
33
34
|
const Textarea = ({ className, testId, hasError, id, name, disabled, rows, style, ...props }) => {
|
|
34
35
|
const field = useFieldContext();
|
|
35
36
|
const resolvedError = hasError ?? field?.invalid ?? false;
|
|
36
|
-
|
|
37
|
-
// `rows` sets the starting height instead: that many lines plus the py-2
|
|
38
|
-
// padding and the 1px border, before the box grows with its content.
|
|
39
|
-
const sizedStyle = rows === undefined
|
|
40
|
-
? style
|
|
41
|
-
: { minHeight: `calc(${rows}lh + 1rem + 2px)`, ...style };
|
|
37
|
+
const { className: sizingClassName, ref: sizingRef, style: sizingStyle, } = useTextareaRows(rows);
|
|
42
38
|
// The control owns id/name/disabled so Field can label and validate it;
|
|
43
|
-
// textarea-only props ride on the rendered element.
|
|
44
|
-
|
|
39
|
+
// textarea-only props ride on the rendered element. The sizing ref sits on
|
|
40
|
+
// the control so a caller's own `ref` on the textarea still lands.
|
|
41
|
+
return (_jsx(FieldPrimitive.Control, { ref: sizingRef, render: _jsx("textarea", { className: cn(focusInput(), `flex w-full ${INPUT_SHELL} placeholder:text-muted-foreground transition-color-shadow text-paragraph md:text-paragraph-sm px-3 py-2 shadow-xs disabled:cursor-not-allowed disabled:opacity-50`, sizingClassName, hasErrorInput, className), "data-component": "textarea", "data-slot": "textarea", rows: rows, style: { ...sizingStyle, ...style }, ...(hasError === true ? { "aria-invalid": true } : {}), ...(resolvedError ? { "data-error": true } : {}), ...(testId === undefined ? {} : { "data-testid": testId }), ...props }), ...(disabled === undefined ? {} : { disabled }), ...(id === undefined ? {} : { id }), ...(name === undefined ? {} : { name }) }));
|
|
45
42
|
};
|
|
46
43
|
export { Textarea };
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { CSSProperties, RefCallback } from "react";
|
|
2
|
+
interface TextareaRowsSizing {
|
|
3
|
+
/** Sizing classes: `field-sizing-content`, plus `min-h-16` when there are no `rows`. */
|
|
4
|
+
className: string;
|
|
5
|
+
/** Goes on the textarea, to read back its padding and border once mounted. */
|
|
6
|
+
ref: RefCallback<HTMLTextAreaElement>;
|
|
7
|
+
/** The `rows`-high minimum, once the edges are known. */
|
|
8
|
+
style: CSSProperties | undefined;
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Lets a textarea grow with its text (`field-sizing: content`) without
|
|
12
|
+
* losing `rows`, which content sizing ignores in every browser.
|
|
13
|
+
*
|
|
14
|
+
* Until it mounts, a textarea with `rows` is left to the browser, which
|
|
15
|
+
* sizes it from `rows` with whatever padding and border it has. Once
|
|
16
|
+
* mounted, those edges are read back from its computed style, the minimum
|
|
17
|
+
* height becomes `rows` lines plus them, and content sizing takes over to
|
|
18
|
+
* grow it beyond that. The `lh` unit keeps the minimum in step when the line
|
|
19
|
+
* height changes at a breakpoint.
|
|
20
|
+
*/
|
|
21
|
+
export declare const useTextareaRows: (rows: number | undefined) => TextareaRowsSizing;
|
|
22
|
+
export {};
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { useCallback, useState } from "react";
|
|
3
|
+
/**
|
|
4
|
+
* Lets a textarea grow with its text (`field-sizing: content`) without
|
|
5
|
+
* losing `rows`, which content sizing ignores in every browser.
|
|
6
|
+
*
|
|
7
|
+
* Until it mounts, a textarea with `rows` is left to the browser, which
|
|
8
|
+
* sizes it from `rows` with whatever padding and border it has. Once
|
|
9
|
+
* mounted, those edges are read back from its computed style, the minimum
|
|
10
|
+
* height becomes `rows` lines plus them, and content sizing takes over to
|
|
11
|
+
* grow it beyond that. The `lh` unit keeps the minimum in step when the line
|
|
12
|
+
* height changes at a breakpoint.
|
|
13
|
+
*/
|
|
14
|
+
export const useTextareaRows = (rows) => {
|
|
15
|
+
const [edges, setEdges] = useState();
|
|
16
|
+
const ref = useCallback((element) => {
|
|
17
|
+
if (element === null || rows === undefined) {
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
const style = getComputedStyle(element);
|
|
21
|
+
setEdges([
|
|
22
|
+
style.paddingTop,
|
|
23
|
+
style.paddingBottom,
|
|
24
|
+
style.borderTopWidth,
|
|
25
|
+
style.borderBottomWidth,
|
|
26
|
+
].join(" + "));
|
|
27
|
+
}, [rows]);
|
|
28
|
+
if (rows === undefined) {
|
|
29
|
+
return {
|
|
30
|
+
className: "field-sizing-content min-h-16",
|
|
31
|
+
ref,
|
|
32
|
+
style: undefined,
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
if (edges === undefined) {
|
|
36
|
+
return { className: "", ref, style: undefined };
|
|
37
|
+
}
|
|
38
|
+
return {
|
|
39
|
+
className: "field-sizing-content",
|
|
40
|
+
ref,
|
|
41
|
+
style: { minHeight: `calc(${rows}lh + ${edges})` },
|
|
42
|
+
};
|
|
43
|
+
};
|