@inkra/react 0.6.0 → 0.8.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/README.md +77 -1
- package/dist/Button.js +1 -1
- package/dist/PencilText.js +8 -0
- package/dist/form/Textarea.d.ts +4 -0
- package/dist/form/Textarea.js +14 -8
- package/dist/form/index.d.ts +1 -1
- package/dist/form/textareaSizing.d.ts +29 -0
- package/dist/form/textareaSizing.js +153 -0
- package/dist/i18n/catalog.d.ts +1 -1
- package/dist/i18n/catalog.js +9 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +1 -0
- package/dist/inventory-grid/index.d.ts +35 -0
- package/dist/inventory-grid/index.js +205 -0
- package/dist/inventory-grid/model.d.ts +56 -0
- package/dist/inventory-grid/model.js +93 -0
- package/dist/layout/GridBoard.d.ts +22 -0
- package/dist/layout/GridBoard.js +113 -0
- package/dist/layout/GridResizable.d.ts +31 -0
- package/dist/layout/GridResizable.js +35 -0
- package/dist/layout/index.d.ts +19 -4
- package/dist/layout/index.js +45 -9
- package/dist/layout/swipeNavigation.d.ts +15 -0
- package/dist/layout/swipeNavigation.js +121 -0
- package/dist/pagination/index.d.ts +17 -0
- package/dist/pagination/index.js +30 -6
- package/dist/styles/base.css +9 -1
- package/dist/styles/form.css +10 -0
- package/dist/styles/inventory-grid.css +31 -0
- package/dist/styles/layout.css +85 -0
- package/dist/styles/pagination.css +10 -0
- package/dist/styles.css +146 -1
- package/package.json +8 -1
package/README.md
CHANGED
|
@@ -176,6 +176,39 @@ All standard `button` props, including `disabled`, `name`, `value`, `form`, `ari
|
|
|
176
176
|
|
|
177
177
|
`Input` defaults to an underline appearance, with explicit `outline`, `soft` and `ghost` variants. It supports `startIcon`, `endIcon`, `prefix`, `suffix`, `clearable`, `loading`, `startAction`, `endAction`, `fullWidth` and logical `textAlign`. Use `onValueChange` for edits and clearing; native `onChange` does not receive fabricated clear events. `Field` connects labels, descriptions and errors.
|
|
178
178
|
|
|
179
|
+
`Textarea` respects an explicit native `rows`, including `rows={1}`. Omit both
|
|
180
|
+
`rows` and `autoSize` to retain the existing sm/md/lg minimum heights. Enable
|
|
181
|
+
`autoSize` to grow and shrink with content, including lines wrapped by the actual
|
|
182
|
+
container width:
|
|
183
|
+
|
|
184
|
+
```tsx
|
|
185
|
+
import { Textarea } from '@inkra/react/form';
|
|
186
|
+
import type { TextareaAutoSizeOptions } from '@inkra/react/form';
|
|
187
|
+
|
|
188
|
+
<Textarea rows={1} aria-label="Single-line initial height" />
|
|
189
|
+
<Textarea autoSize={{ minRows: 1, maxRows: 6 }} aria-label="Note" />
|
|
190
|
+
<Textarea autoSize rows={2} aria-label="At least two rows, unlimited growth" />
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
`autoSize` accepts `boolean | TextareaAutoSizeOptions` and defaults to `false`.
|
|
194
|
+
Its `minRows` falls back to `rows`, then 1; omit `maxRows` for unlimited growth.
|
|
195
|
+
Both limits accept positive safe integers; invalid values use these defaults and
|
|
196
|
+
`maxRows` below `minRows` is raised to `minRows`. The limit counts visible text
|
|
197
|
+
rows, including soft wraps, rather than newline characters or input length.
|
|
198
|
+
Above the maximum, the native editor scrolls internally; deleting text shrinks it.
|
|
199
|
+
Controlled value updates, native form reset, width and font changes remeasure
|
|
200
|
+
without emitting change events or replacing the native control. Empty editors
|
|
201
|
+
size from their minimum rows rather than their placeholder text.
|
|
202
|
+
|
|
203
|
+
Automatic sizing owns `height`, `minHeight`, `maxHeight` and vertical overflow;
|
|
204
|
+
it takes precedence over inline height and `resize="vertical"`, hiding the manual
|
|
205
|
+
handle. With automatic sizing disabled, manual resizing keeps its existing
|
|
206
|
+
behavior (an explicit `rows` supplies its minimum). Row limits apply only to the
|
|
207
|
+
editor: the character counter retains its separate footer, even while its
|
|
208
|
+
automatic indicator is hidden. On the server, native `rows` provides the initial
|
|
209
|
+
height; existing multi-line content is measured after hydration. Avoid CSS
|
|
210
|
+
`!important` height constraints when enabling automatic sizing.
|
|
211
|
+
|
|
179
212
|
`InputNumber` places decrease and increase buttons on either side of a centered numeric input, with no clear button. Keyboard deletion produces `null`; decimal stepping and out-of-range validation remain native. `Slider` supports single/range values, horizontal/vertical layout and `standard` or `inset` appearance.
|
|
180
213
|
|
|
181
214
|
`InputNumber` supports Shift-click and Shift-arrow increments of 10 by default.
|
|
@@ -328,7 +361,7 @@ working examples, live property tabs and per-component API/accessibility notes.
|
|
|
328
361
|
| Drawer | Dialog parts plus section layout, four `Content.direction` values and token/custom `size`; focus trap, Escape and return focus, no swipe gesture API |
|
|
329
362
|
| DatePicker | Calendar/Popover composition; single/range local dates, controlled/default values, clear, form fields and localized labels; no UTC conversion |
|
|
330
363
|
| TimePicker | Native time input with clear, disabled/readOnly, min/max/step and optional seconds; browser/OS controls the native presentation |
|
|
331
|
-
| Pagination | Page count/current page, boundary actions and ellipses;
|
|
364
|
+
| Pagination | Page count/current page, boundary actions and ellipses; optional page-size selection, button variants, logical alignment and wrapping |
|
|
332
365
|
| Empty | Optional `Root`, `Icon`, `Title`, `Description`, `Actions`; no mandatory card or live-region role |
|
|
333
366
|
| ToggleGroup | `Root` and `Item`; array values for single/multiple toggles, roving focus and `aria-pressed`, not tabs |
|
|
334
367
|
| Command | cmdk-backed search, grouped commands, empty/disabled states and optional modal composition |
|
|
@@ -338,6 +371,49 @@ working examples, live property tabs and per-component API/accessibility notes.
|
|
|
338
371
|
| Steps | Ordered `items`, `current`, orientation and optional `onStepChange`; status and disabled steps |
|
|
339
372
|
| Timeline | Ordered `items` with date/title/description/content, semantic `dateTime` and horizontal/vertical orientation |
|
|
340
373
|
|
|
374
|
+
### Pagination page sizes
|
|
375
|
+
|
|
376
|
+
```tsx
|
|
377
|
+
const [page, setPage] = useState(1);
|
|
378
|
+
const [pageSize, setPageSize] = useState(20);
|
|
379
|
+
<Pagination totalPages={Math.ceil(itemCount / pageSize)}
|
|
380
|
+
page={page} onPageChange={setPage}
|
|
381
|
+
pageSize={pageSize} onPageSizeChange={setPageSize}
|
|
382
|
+
pageSizeOptions={[10, 20, 50]} hideOnSinglePage
|
|
383
|
+
variant="outline" align="end" wrap />
|
|
384
|
+
```
|
|
385
|
+
|
|
386
|
+
The caller computes `totalPages` and slices or fetches its data. `pageSize` is
|
|
387
|
+
controlled; `defaultPageSize` initializes an uncontrolled size (default `10`).
|
|
388
|
+
Only positive safe integers are accepted. Invalid sizes fall back to `10`;
|
|
389
|
+
invalid options are ignored, duplicates removed and options sorted numerically.
|
|
390
|
+
The current size is retained in a nonempty option list. Omit `pageSizeOptions`
|
|
391
|
+
or provide no valid options to hide the selector.
|
|
392
|
+
|
|
393
|
+
Selecting a different size first calls `onPageSizeChange(size)`, then requests
|
|
394
|
+
`onPageChange(1)` when the current page exceeds one. Each uncontrolled state
|
|
395
|
+
updates independently; controlled state changes only when the parent accepts
|
|
396
|
+
the request. Rendering, changing totals or options, and clamping do not emit
|
|
397
|
+
user callbacks. `hideOnSinglePage` hides page controls for zero or one page while
|
|
398
|
+
retaining the size selector.
|
|
399
|
+
|
|
400
|
+
`variant` uses the Button variants (default `ghost`). `align` accepts logical
|
|
401
|
+
`start | center | end` (default `start`); `wrap` defaults to `false` and retains
|
|
402
|
+
local horizontal scrolling. The size selector reuses Select, inherits disabled
|
|
403
|
+
state and direction, and uses InkraProvider labels. Override
|
|
404
|
+
`labels.pageSize` (accessible name) and `labels.pageSizeOption(size)` (visible
|
|
405
|
+
option text) for application-specific copy. `simple`, `renderPage` and
|
|
406
|
+
`renderSummary` compose with these options.
|
|
407
|
+
|
|
408
|
+
分页容量由调用方计算总页数并切分或请求数据。`pageSize` 为受控值,
|
|
409
|
+
`defaultPageSize` 为非受控初值,默认 10;只接收正安全整数。非法容量回退为
|
|
410
|
+
10,非法选项忽略,选项去重并排序,有效列表保留当前容量。省略或提供空的有效
|
|
411
|
+
选项列表时不显示选择器。切换容量先通知 `onPageSizeChange`,当前页大于 1 时
|
|
412
|
+
再请求 `onPageChange(1)`;受控值由父组件决定是否接受。渲染、总页数及选项变化
|
|
413
|
+
不触发交互回调。`hideOnSinglePage` 隐藏翻页按钮但保留容量选择器;`align`
|
|
414
|
+
按逻辑方向对齐,`wrap` 允许换行,默认仍为局部横滚。容量选择复用 Select,
|
|
415
|
+
继承禁用、方向和语言设置,可用 `labels.pageSize/pageSizeOption` 覆盖文案。
|
|
416
|
+
|
|
341
417
|
## Utility Components (Unreleased)
|
|
342
418
|
|
|
343
419
|
`Slot` is a fixed square visual container for arbitrary icons or content. Static
|
package/dist/Button.js
CHANGED
|
@@ -36,7 +36,7 @@ function ButtonVisual({ children, endIcon, loading, loadingPosition, loadingIndi
|
|
|
36
36
|
const label = typeof children === 'string'
|
|
37
37
|
? _jsx(PencilText, { seed: `${seed}-label`, children: children })
|
|
38
38
|
: children == null ? null : _jsx("span", { children: children });
|
|
39
|
-
return (_jsxs(_Fragment, { children: [_jsx(PencilSurface, { as: "span", className: "inkra-button__surface", clipContent: sharedOutline, radius: sharedOutline ? 0 : undefined, seed: seed, variant: inputAction ? 'ghost' : surfaceVariant(variant), children: _jsxs("span", { className: "inkra-button__content", children: [loading && loadingPosition === 'center' ? indicator : null, iconSlot('start', startIcon), label == null ? null : _jsxs("span", { className: "inkra-button__label", children: [label, variant === 'link' ? _jsx(PencilLine, { className: "inkra-button__underline", seed: `${seed}-underline`, weight: 0.9 }) : null] }), iconSlot('end', endIcon)] }) }), _jsx(PencilSurface, { as: "span", "aria-hidden": "true", className: "inkra-button__focus-ring", variant: "outline", seed: `${seed}-focus` }), group === null ? null : _jsx(PencilLine, { className: "inkra-button-group__divider", orientation: group.orientation === 'horizontal' ? 'vertical' : 'horizontal', seed: `${seed}-group-divider` })] }));
|
|
39
|
+
return (_jsxs(_Fragment, { children: [_jsx(PencilSurface, { as: "span", className: "inkra-button__surface", clipContent: sharedOutline || variant === 'ghost', radius: sharedOutline ? 0 : undefined, seed: seed, variant: inputAction ? 'ghost' : surfaceVariant(variant), children: _jsxs("span", { className: "inkra-button__content", children: [loading && loadingPosition === 'center' ? indicator : null, iconSlot('start', startIcon), label == null ? null : _jsxs("span", { className: "inkra-button__label", children: [label, variant === 'link' ? _jsx(PencilLine, { className: "inkra-button__underline", seed: `${seed}-underline`, weight: 0.9 }) : null] }), iconSlot('end', endIcon)] }) }), _jsx(PencilSurface, { as: "span", "aria-hidden": "true", className: "inkra-button__focus-ring", variant: "outline", seed: `${seed}-focus` }), group === null ? null : _jsx(PencilLine, { className: "inkra-button-group__divider", orientation: group.orientation === 'horizontal' ? 'vertical' : 'horizontal', seed: `${seed}-group-divider` })] }));
|
|
40
40
|
}
|
|
41
41
|
export const Button = forwardRef(function Button({ children, 'aria-label': ariaLabel, className, disabled = false, endIcon, loading = false, loadingPosition = 'center', loadingIndicator, fullWidth = false, align = 'center', seed, size: sizeProp, startIcon, type = 'button', variant: variantProp, ...props }, ref) {
|
|
42
42
|
const id = useId();
|
package/dist/PencilText.js
CHANGED
|
@@ -324,6 +324,14 @@ const PencilTextImplementation = forwardRef(function PencilText({ as: Element =
|
|
|
324
324
|
markDirty();
|
|
325
325
|
});
|
|
326
326
|
schedule();
|
|
327
|
+
// Ancestor layout effects can reveal a clipped label during the same commit.
|
|
328
|
+
if (!visible)
|
|
329
|
+
queueMicrotask(() => {
|
|
330
|
+
if (!disposed && dirty && inViewport()) {
|
|
331
|
+
visible = true;
|
|
332
|
+
schedule();
|
|
333
|
+
}
|
|
334
|
+
});
|
|
327
335
|
return () => {
|
|
328
336
|
disposed = true;
|
|
329
337
|
stopRecovery();
|
package/dist/form/Textarea.d.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import type { ReactNode, TextareaHTMLAttributes } from 'react';
|
|
2
2
|
import type { FieldControlStateProps } from './types.js';
|
|
3
|
+
import type { TextareaAutoSizeOptions } from './textareaSizing.js';
|
|
4
|
+
export type { TextareaAutoSizeOptions } from './textareaSizing.js';
|
|
3
5
|
export interface TextareaCountDetails {
|
|
4
6
|
count: number;
|
|
5
7
|
maxLength: number;
|
|
@@ -15,5 +17,7 @@ export interface TextareaProps extends TextareaHTMLAttributes<HTMLTextAreaElemen
|
|
|
15
17
|
getCountLabel?: (details: TextareaCountDetails) => string;
|
|
16
18
|
resize?: 'none' | 'vertical';
|
|
17
19
|
resizeLabel?: string;
|
|
20
|
+
/** Grow and shrink with content. Takes precedence over manual resize and inline height. */
|
|
21
|
+
autoSize?: boolean | TextareaAutoSizeOptions;
|
|
18
22
|
}
|
|
19
23
|
export declare const Textarea: import("react").ForwardRefExoticComponent<TextareaProps & import("react").RefAttributes<HTMLTextAreaElement>>;
|
package/dist/form/Textarea.js
CHANGED
|
@@ -10,16 +10,19 @@ import { Progress } from '../display/index.js';
|
|
|
10
10
|
import { useResolvedControlProps } from './controlProps.js';
|
|
11
11
|
import { EditablePencilText } from './EditablePencilText.js';
|
|
12
12
|
import { InputAction } from './InputAction.js';
|
|
13
|
+
import { textareaResizeHeight, useTextareaSizing } from './textareaSizing.js';
|
|
13
14
|
const resizePaths = [{ d: 'M12 3v18M8 7l4-4 4 4M8 17l4 4 4-4' }];
|
|
14
15
|
export const Textarea = forwardRef(function Textarea(inkraProps, ref) {
|
|
15
16
|
const { t } = useInkraLocale();
|
|
16
|
-
const { 'aria-describedby': ariaDescribedBy, 'aria-errormessage': ariaErrorMessage, 'aria-invalid': ariaInvalid, className, disabled, id, invalid = false, readOnly, required, seed, size = 'md', variant = 'outline', endAction, countMode = 'remaining', countDisplay = 'auto', countThreshold = 0.8, getCountLabel = ({ remaining }) => t(remaining < 0 ? 'textarea.overLimit' : 'textarea.countRemaining', { count: Math.abs(remaining) }), resize = 'none', resizeLabel = t('textarea.resize'), ...props } = inkraProps;
|
|
17
|
+
const { 'aria-describedby': ariaDescribedBy, 'aria-errormessage': ariaErrorMessage, 'aria-invalid': ariaInvalid, className, disabled, id, invalid = false, readOnly, required, seed, size = 'md', variant = 'outline', endAction, countMode = 'remaining', countDisplay = 'auto', countThreshold = 0.8, getCountLabel = ({ remaining }) => t(remaining < 0 ? 'textarea.overLimit' : 'textarea.countRemaining', { count: Math.abs(remaining) }), resize = 'none', resizeLabel = t('textarea.resize'), autoSize = false, ...props } = inkraProps;
|
|
17
18
|
const generatedId = useId();
|
|
18
19
|
const textareaRef = useRef(null);
|
|
19
20
|
const [uncontrolledCount, setUncontrolledCount] = useState(() => String(props.defaultValue ?? '').length);
|
|
20
21
|
const [height, setHeight] = useState();
|
|
21
22
|
const [measuredHeight, setMeasuredHeight] = useState();
|
|
22
23
|
const drag = useRef(null);
|
|
24
|
+
const sizing = useTextareaSizing(textareaRef, autoSize, props.rows, props.form, resize === 'vertical');
|
|
25
|
+
const manualResize = resize === 'vertical' && !sizing.enabled;
|
|
23
26
|
const controlled = props.value != null;
|
|
24
27
|
const count = controlled ? String(props.value).length : uncontrolledCount;
|
|
25
28
|
const maximum = props.maxLength;
|
|
@@ -30,11 +33,12 @@ export const Textarea = forwardRef(function Textarea(inkraProps, ref) {
|
|
|
30
33
|
const controlId = id ?? `inkra-textarea-${generatedId}`;
|
|
31
34
|
const remaining = hasCount ? maximum - count : 0;
|
|
32
35
|
const countLabel = hasCount ? getCountLabel({ count, maxLength: maximum, remaining }) : '';
|
|
33
|
-
const minimumHeight = size === 'sm' ? 80 : size === 'lg' ? 112 : 96;
|
|
34
|
-
const
|
|
36
|
+
const minimumHeight = (sizing.minimumHeight ?? (size === 'sm' ? 80 : size === 'lg' ? 112 : 96)) + sizing.boxAdjustment;
|
|
37
|
+
const maximumHeight = Math.max(1200, minimumHeight);
|
|
38
|
+
const resizeTo = (next) => setHeight(textareaResizeHeight(next, minimumHeight, maximumHeight, sizing.boxAdjustment));
|
|
35
39
|
useEffect(() => {
|
|
36
40
|
const control = textareaRef.current;
|
|
37
|
-
if (
|
|
41
|
+
if (!manualResize || !control)
|
|
38
42
|
return;
|
|
39
43
|
const measure = () => setMeasuredHeight(control.getBoundingClientRect().height);
|
|
40
44
|
measure();
|
|
@@ -43,7 +47,7 @@ export const Textarea = forwardRef(function Textarea(inkraProps, ref) {
|
|
|
43
47
|
const observer = new ResizeObserver(measure);
|
|
44
48
|
observer.observe(control);
|
|
45
49
|
return () => observer.disconnect();
|
|
46
|
-
}, [
|
|
50
|
+
}, [manualResize]);
|
|
47
51
|
useEffect(() => {
|
|
48
52
|
const control = textareaRef.current;
|
|
49
53
|
const form = control?.form;
|
|
@@ -73,11 +77,13 @@ export const Textarea = forwardRef(function Textarea(inkraProps, ref) {
|
|
|
73
77
|
});
|
|
74
78
|
const resolvedSeed = seed ?? `inkra-textarea-${state.id ?? generatedId}`;
|
|
75
79
|
const classes = ['inkra-textarea', className].filter(Boolean).join(' ');
|
|
76
|
-
return (_jsx("div", { className: "inkra-field-control-hit inkra-field-control-hit--textarea", "data-disabled": state.disabled || undefined, "data-invalid": state.invalid || undefined, "data-readonly": state.readOnly || undefined, "data-size": size, "data-variant": variant, "data-end-action": endAction != null || undefined, children: _jsxs(PencilSurface, { className: "inkra-field-control inkra-field-control--textarea", seed: resolvedSeed, variant: variant === 'underline' ? 'ghost' : state.invalid ? 'danger' : 'outline', children: [_jsx("textarea", { ...props, "aria-describedby": [state.describedBy, showCount && countId].filter(Boolean).join(' ') || undefined, "aria-errormessage": state.errorMessage, "aria-invalid": state.ariaInvalid, className: classes, disabled: state.disabled, id: state.id, readOnly: state.readOnly, ref: textareaRef, required: state.required, style: { ...props.style, ...(
|
|
80
|
+
return (_jsx("div", { className: "inkra-field-control-hit inkra-field-control-hit--textarea", "data-disabled": state.disabled || undefined, "data-invalid": state.invalid || undefined, "data-readonly": state.readOnly || undefined, "data-size": size, "data-variant": variant, "data-end-action": endAction != null || undefined, "data-row-sizing": sizing.rowSizing || undefined, children: _jsxs(PencilSurface, { className: "inkra-field-control inkra-field-control--textarea", seed: resolvedSeed, variant: variant === 'underline' ? 'ghost' : state.invalid ? 'danger' : 'outline', children: [_jsx("textarea", { ...props, "aria-describedby": [state.describedBy, showCount && countId].filter(Boolean).join(' ') || undefined, "aria-errormessage": state.errorMessage, "aria-invalid": state.ariaInvalid, className: classes, disabled: state.disabled, id: state.id, readOnly: state.readOnly, ref: textareaRef, required: state.required, rows: sizing.enabled ? sizing.minRows : props.rows, style: { ...props.style, ...(manualResize && height !== undefined ? { height: Math.max(height, sizing.minimumHeight ?? height) } : {}), ...sizing.style, resize: 'none' }, onInput: event => {
|
|
77
81
|
if (!controlled)
|
|
78
82
|
setUncontrolledCount(event.currentTarget.value.length);
|
|
79
83
|
props.onInput?.(event);
|
|
80
|
-
|
|
84
|
+
// Wait for React to accept or restore a controlled value before measuring.
|
|
85
|
+
sizing.schedule();
|
|
86
|
+
} }), _jsx(EditablePencilText, { controlRef: textareaRef, defaultValue: props.defaultValue, multiline: true, placeholder: props.placeholder, seed: `${resolvedSeed}-editable`, value: props.value }), endAction != null && _jsx("div", { className: "inkra-textarea__end-action", children: _jsx(InputAction, { disabled: Boolean(state.disabled), seed: `${resolvedSeed}-action`, children: endAction }) }), (hasCount || manualResize) && _jsxs("div", { className: "inkra-textarea__footer", children: [hasCount && _jsxs("div", { className: "inkra-textarea__count", id: countId, "data-visible": showCount, "data-limit": remaining <= 0 ? 'reached' : 'near', "aria-hidden": !showCount || undefined, children: [countMode === 'progress' && _jsx(Progress, { size: "sm", value: maximum === 0 ? 1 : count, max: maximum === 0 ? 1 : maximum, "aria-label": countLabel, "aria-valuetext": countLabel }), _jsx(PencilText, { children: countMode === 'fraction' ? `${count} / ${maximum}` : countLabel })] }), manualResize && _jsx(Button, { className: "inkra-textarea__resize", variant: "ghost", size: "icon", role: "slider", "aria-label": resizeLabel, title: resizeLabel, "aria-controls": state.id, "aria-orientation": "vertical", "aria-valuemin": minimumHeight, "aria-valuemax": maximumHeight, "aria-valuenow": Math.round(Math.max(measuredHeight ?? minimumHeight, minimumHeight)), disabled: state.disabled, onPointerDown: event => {
|
|
81
87
|
if (event.button !== 0)
|
|
82
88
|
return;
|
|
83
89
|
drag.current = { y: event.clientY, height: textareaRef.current?.getBoundingClientRect().height ?? minimumHeight };
|
|
@@ -98,7 +104,7 @@ export const Textarea = forwardRef(function Textarea(inkraProps, ref) {
|
|
|
98
104
|
else if (event.key === 'Home')
|
|
99
105
|
resizeTo(minimumHeight);
|
|
100
106
|
else if (event.key === 'End')
|
|
101
|
-
resizeTo(
|
|
107
|
+
resizeTo(maximumHeight);
|
|
102
108
|
else
|
|
103
109
|
return;
|
|
104
110
|
event.preventDefault();
|
package/dist/form/index.d.ts
CHANGED
|
@@ -3,5 +3,5 @@ export type { FieldProps } from './Field.js';
|
|
|
3
3
|
export { Input } from './Input.js';
|
|
4
4
|
export type { InputProps, InputVariant, InputTextAlign, InputValueChangeDetails, InputClassNames } from './Input.js';
|
|
5
5
|
export { Textarea } from './Textarea.js';
|
|
6
|
-
export type { TextareaProps, TextareaCountDetails } from './Textarea.js';
|
|
6
|
+
export type { TextareaProps, TextareaCountDetails, TextareaAutoSizeOptions } from './Textarea.js';
|
|
7
7
|
export type { FieldControlSize, FieldControlStateProps } from './types.js';
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { CSSProperties, RefObject } from 'react';
|
|
2
|
+
export interface TextareaAutoSizeOptions {
|
|
3
|
+
/** Minimum visible text rows. Falls back to rows, then 1. */
|
|
4
|
+
minRows?: number;
|
|
5
|
+
/** Maximum visible text rows, including soft wraps. Omit for unlimited growth. */
|
|
6
|
+
maxRows?: number;
|
|
7
|
+
}
|
|
8
|
+
export declare function resolveTextareaRows(autoSize: boolean | TextareaAutoSizeOptions | undefined, rows?: number): {
|
|
9
|
+
minRows: number;
|
|
10
|
+
maxRows: number;
|
|
11
|
+
};
|
|
12
|
+
/** Measure a native textarea without changing its value, selection, scroll position or height. */
|
|
13
|
+
export declare function measureTextareaSize(control: HTMLTextAreaElement, minRows: number, maxRows: number, measureContent?: boolean): {
|
|
14
|
+
minimumHeight: number;
|
|
15
|
+
boxAdjustment: number;
|
|
16
|
+
height: number;
|
|
17
|
+
overflowY: "hidden" | "auto";
|
|
18
|
+
} | undefined;
|
|
19
|
+
export declare function useTextareaSizing(controlRef: RefObject<HTMLTextAreaElement | null>, autoSize: boolean | TextareaAutoSizeOptions | undefined, rows: number | undefined, form: string | undefined, manualResize?: boolean): {
|
|
20
|
+
enabled: boolean;
|
|
21
|
+
rowSizing: boolean;
|
|
22
|
+
minRows: number;
|
|
23
|
+
minimumHeight: number | undefined;
|
|
24
|
+
boxAdjustment: number;
|
|
25
|
+
style: CSSProperties | undefined;
|
|
26
|
+
schedule: () => void;
|
|
27
|
+
};
|
|
28
|
+
/** Manual handles and ARIA use visible border-box pixels; CSS height may use content-box pixels. */
|
|
29
|
+
export declare function textareaResizeHeight(visibleHeight: number, minimum: number, maximum: number, boxAdjustment: number): number;
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import { useCallback, useRef, useState } from 'react';
|
|
2
|
+
import { useBrowserLayoutEffect } from '../internal/useBrowserLayoutEffect.js';
|
|
3
|
+
function positiveRows(value, fallback) {
|
|
4
|
+
return typeof value === 'number' && Number.isSafeInteger(value) && value > 0 ? value : fallback;
|
|
5
|
+
}
|
|
6
|
+
export function resolveTextareaRows(autoSize, rows) {
|
|
7
|
+
const options = typeof autoSize === 'object' ? autoSize : undefined;
|
|
8
|
+
const minRows = positiveRows(options?.minRows, positiveRows(rows, 1));
|
|
9
|
+
return { minRows, maxRows: Math.max(minRows, positiveRows(options?.maxRows, Infinity)) };
|
|
10
|
+
}
|
|
11
|
+
const sizingProperties = [
|
|
12
|
+
'boxSizing', 'width', 'paddingTop', 'paddingRight', 'paddingBottom', 'paddingLeft',
|
|
13
|
+
'borderTopWidth', 'borderRightWidth', 'borderBottomWidth', 'borderLeftWidth', 'borderStyle',
|
|
14
|
+
'fontFamily', 'fontSize', 'fontWeight', 'fontStyle', 'fontStretch', 'fontVariant',
|
|
15
|
+
'fontFeatureSettings', 'fontVariationSettings', 'fontKerning', 'lineHeight', 'letterSpacing',
|
|
16
|
+
'wordSpacing', 'textIndent', 'textTransform', 'textAlign', 'tabSize', 'direction',
|
|
17
|
+
'overflowWrap', 'wordBreak', 'whiteSpace', 'scrollbarGutter',
|
|
18
|
+
];
|
|
19
|
+
/** Measure a native textarea without changing its value, selection, scroll position or height. */
|
|
20
|
+
export function measureTextareaSize(control, minRows, maxRows, measureContent = true) {
|
|
21
|
+
const document = control.ownerDocument;
|
|
22
|
+
const view = document.defaultView;
|
|
23
|
+
if (!view || !document.body || control.getClientRects().length === 0)
|
|
24
|
+
return undefined;
|
|
25
|
+
const computed = view.getComputedStyle(control);
|
|
26
|
+
if (Number.parseFloat(computed.width) <= 0)
|
|
27
|
+
return undefined;
|
|
28
|
+
const number = (value) => Number.parseFloat(value) || 0;
|
|
29
|
+
const padding = number(computed.paddingTop) + number(computed.paddingBottom);
|
|
30
|
+
const border = number(computed.borderTopWidth) + number(computed.borderBottomWidth);
|
|
31
|
+
const mirror = document.createElement('textarea');
|
|
32
|
+
mirror.setAttribute('aria-hidden', 'true');
|
|
33
|
+
mirror.tabIndex = -1;
|
|
34
|
+
mirror.rows = 1;
|
|
35
|
+
mirror.wrap = control.wrap;
|
|
36
|
+
for (const property of sizingProperties)
|
|
37
|
+
mirror.style[property] = computed[property];
|
|
38
|
+
Object.assign(mirror.style, {
|
|
39
|
+
position: 'fixed', left: '-100000px', top: '0', visibility: 'hidden',
|
|
40
|
+
pointerEvents: 'none', height: '0', minHeight: '0', maxHeight: 'none',
|
|
41
|
+
overflow: 'hidden', resize: 'none',
|
|
42
|
+
});
|
|
43
|
+
document.body.appendChild(mirror);
|
|
44
|
+
try {
|
|
45
|
+
mirror.value = 'x';
|
|
46
|
+
let lineHeight = number(computed.lineHeight);
|
|
47
|
+
if (!lineHeight) {
|
|
48
|
+
// scrollHeight rounds to integer pixels. A bounded multi-line probe avoids
|
|
49
|
+
// multiplying that rounding error by maxRows when line-height is normal.
|
|
50
|
+
mirror.value = Array(100).fill('x').join('\n');
|
|
51
|
+
lineHeight = Math.max(1, (mirror.scrollHeight - padding) / 100);
|
|
52
|
+
}
|
|
53
|
+
// Placeholder copy does not change the height of an otherwise empty editor.
|
|
54
|
+
mirror.value = measureContent ? control.value || ' ' : ' ';
|
|
55
|
+
const contentHeight = Math.max(lineHeight, mirror.scrollHeight - padding);
|
|
56
|
+
const extra = computed.boxSizing === 'border-box' ? padding + border : 0;
|
|
57
|
+
const minimumHeight = Math.ceil(minRows * lineHeight + extra);
|
|
58
|
+
const maximumHeight = Math.ceil(maxRows * lineHeight + extra);
|
|
59
|
+
return {
|
|
60
|
+
minimumHeight,
|
|
61
|
+
boxAdjustment: computed.boxSizing === 'border-box' ? 0 : padding + border,
|
|
62
|
+
height: Math.min(maximumHeight, Math.max(minimumHeight, Math.ceil(contentHeight + extra))),
|
|
63
|
+
overflowY: contentHeight > maxRows * lineHeight + 1 ? 'auto' : 'hidden',
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
finally {
|
|
67
|
+
mirror.remove();
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
export function useTextareaSizing(controlRef, autoSize, rows, form, manualResize = false) {
|
|
71
|
+
const enabled = Boolean(autoSize);
|
|
72
|
+
const rowSizing = enabled || rows !== undefined;
|
|
73
|
+
const measureEnabled = enabled || (rowSizing && manualResize);
|
|
74
|
+
const { minRows, maxRows } = resolveTextareaRows(autoSize, rows);
|
|
75
|
+
const [measurement, setMeasurement] = useState();
|
|
76
|
+
const measure = useCallback(() => {
|
|
77
|
+
const control = controlRef.current;
|
|
78
|
+
if (!measureEnabled || !control)
|
|
79
|
+
return;
|
|
80
|
+
const next = measureTextareaSize(control, minRows, maxRows, enabled);
|
|
81
|
+
if (next)
|
|
82
|
+
setMeasurement(previous => previous?.height === next.height && previous.minimumHeight === next.minimumHeight
|
|
83
|
+
&& previous.boxAdjustment === next.boxAdjustment
|
|
84
|
+
&& previous.overflowY === next.overflowY ? previous : next);
|
|
85
|
+
}, [controlRef, measureEnabled, enabled, minRows, maxRows]);
|
|
86
|
+
const latestMeasure = useRef(measure);
|
|
87
|
+
latestMeasure.current = measure;
|
|
88
|
+
const scheduleRef = useRef(() => { });
|
|
89
|
+
const schedule = useCallback(() => scheduleRef.current(), []);
|
|
90
|
+
// Includes controlled/default value, class, style, size and row-option updates.
|
|
91
|
+
useBrowserLayoutEffect(() => { measure(); });
|
|
92
|
+
useBrowserLayoutEffect(() => {
|
|
93
|
+
const control = controlRef.current;
|
|
94
|
+
const view = control?.ownerDocument.defaultView;
|
|
95
|
+
if (!measureEnabled || !control || !view)
|
|
96
|
+
return;
|
|
97
|
+
let frame = 0;
|
|
98
|
+
let disposed = false;
|
|
99
|
+
const schedule = () => {
|
|
100
|
+
if (!frame && !disposed)
|
|
101
|
+
frame = view.requestAnimationFrame(() => {
|
|
102
|
+
frame = 0;
|
|
103
|
+
if (!disposed)
|
|
104
|
+
latestMeasure.current();
|
|
105
|
+
});
|
|
106
|
+
};
|
|
107
|
+
scheduleRef.current = schedule;
|
|
108
|
+
// Height changes are our output; only width changes should schedule another measurement.
|
|
109
|
+
let width = control.getBoundingClientRect().width;
|
|
110
|
+
const observer = typeof ResizeObserver === 'undefined' ? undefined : new ResizeObserver(() => {
|
|
111
|
+
const nextWidth = control.getBoundingClientRect().width;
|
|
112
|
+
if (nextWidth !== width) {
|
|
113
|
+
width = nextWidth;
|
|
114
|
+
schedule();
|
|
115
|
+
}
|
|
116
|
+
});
|
|
117
|
+
observer?.observe(control);
|
|
118
|
+
const inheritedStyle = typeof MutationObserver === 'undefined' ? undefined : new MutationObserver(schedule);
|
|
119
|
+
// Do not observe our own inline height: React updates already measure it.
|
|
120
|
+
for (let element = control.parentElement; element; element = element.parentElement) {
|
|
121
|
+
inheritedStyle?.observe(element, { attributes: true, attributeFilter: ['class', 'style', 'data-theme', 'data-inkra-theme'] });
|
|
122
|
+
}
|
|
123
|
+
const fonts = control.ownerDocument.fonts;
|
|
124
|
+
void fonts?.ready.then(() => { if (!disposed)
|
|
125
|
+
schedule(); });
|
|
126
|
+
fonts?.addEventListener('loadingdone', schedule);
|
|
127
|
+
fonts?.addEventListener('loadingerror', schedule);
|
|
128
|
+
view.addEventListener('resize', schedule);
|
|
129
|
+
// The frame runs after the native reset default action, including cancelled resets.
|
|
130
|
+
const ownerForm = control.form;
|
|
131
|
+
ownerForm?.addEventListener('reset', schedule);
|
|
132
|
+
return () => {
|
|
133
|
+
disposed = true;
|
|
134
|
+
scheduleRef.current = () => { };
|
|
135
|
+
view.cancelAnimationFrame(frame);
|
|
136
|
+
observer?.disconnect();
|
|
137
|
+
inheritedStyle?.disconnect();
|
|
138
|
+
fonts?.removeEventListener('loadingdone', schedule);
|
|
139
|
+
fonts?.removeEventListener('loadingerror', schedule);
|
|
140
|
+
view.removeEventListener('resize', schedule);
|
|
141
|
+
ownerForm?.removeEventListener('reset', schedule);
|
|
142
|
+
};
|
|
143
|
+
}, [controlRef, measureEnabled, form]);
|
|
144
|
+
const style = enabled && measurement ? {
|
|
145
|
+
height: measurement.height, minHeight: measurement.minimumHeight, maxHeight: 'none', overflowY: measurement.overflowY,
|
|
146
|
+
} : undefined;
|
|
147
|
+
return { enabled, rowSizing, minRows, minimumHeight: rowSizing ? measurement?.minimumHeight : undefined,
|
|
148
|
+
boxAdjustment: rowSizing ? measurement?.boxAdjustment ?? 0 : 0, style, schedule };
|
|
149
|
+
}
|
|
150
|
+
/** Manual handles and ARIA use visible border-box pixels; CSS height may use content-box pixels. */
|
|
151
|
+
export function textareaResizeHeight(visibleHeight, minimum, maximum, boxAdjustment) {
|
|
152
|
+
return Math.max(minimum, Math.min(maximum, visibleHeight)) - boxAdjustment;
|
|
153
|
+
}
|