@inkra/react 0.6.0 → 0.9.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 +166 -1
- package/dist/Button.js +1 -1
- package/dist/PencilIcon.d.ts +14 -1
- package/dist/PencilIcon.js +88 -52
- 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/guide/index.d.ts +77 -0
- package/dist/guide/index.js +379 -0
- package/dist/i18n/catalog.d.ts +1 -1
- package/dist/i18n/catalog.js +15 -0
- package/dist/image-viewer/index.d.ts +2 -0
- package/dist/image-viewer/index.js +28 -28
- package/dist/index.d.ts +6 -0
- package/dist/index.js +5 -0
- package/dist/infinite-scroll/gesture.d.ts +19 -0
- package/dist/infinite-scroll/gesture.js +218 -0
- package/dist/infinite-scroll/index.d.ts +55 -0
- package/dist/infinite-scroll/index.js +543 -0
- package/dist/infinite-scroll/model.d.ts +33 -0
- package/dist/infinite-scroll/model.js +168 -0
- package/dist/internal/iconFill.d.ts +16 -0
- package/dist/internal/iconFill.js +275 -0
- package/dist/inventory-grid/index.d.ts +35 -0
- package/dist/inventory-grid/index.js +215 -0
- package/dist/inventory-grid/model.d.ts +56 -0
- package/dist/inventory-grid/model.js +93 -0
- package/dist/inventory-grid/outline.d.ts +9 -0
- package/dist/inventory-grid/outline.js +54 -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/loading/index.d.ts +58 -0
- package/dist/loading/index.js +188 -0
- package/dist/loading/locales.d.ts +15 -0
- package/dist/loading/locales.js +34 -0
- package/dist/loading/model.d.ts +6 -0
- package/dist/loading/model.js +29 -0
- package/dist/masonry/index.d.ts +25 -0
- package/dist/masonry/index.js +227 -0
- package/dist/masonry/layout.d.ts +35 -0
- package/dist/masonry/layout.js +67 -0
- package/dist/number-ticker/RollingValue.d.ts +6 -0
- package/dist/number-ticker/RollingValue.js +10 -0
- package/dist/number-ticker/index.d.ts +4 -0
- package/dist/number-ticker/index.js +55 -7
- package/dist/number-ticker/rolling.d.ts +35 -0
- package/dist/number-ticker/rolling.js +87 -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/guide.css +202 -0
- package/dist/styles/image-viewer.css +20 -0
- package/dist/styles/infinite-scroll.css +171 -0
- package/dist/styles/inventory-grid.css +43 -0
- package/dist/styles/layout.css +85 -0
- package/dist/styles/loading.css +348 -0
- package/dist/styles/masonry.css +68 -0
- package/dist/styles/number-ticker.css +46 -0
- package/dist/styles/pagination.css +10 -0
- package/dist/styles.css +1162 -146
- package/dist/toast/manager.d.ts +4 -4
- package/package.json +33 -1
package/README.md
CHANGED
|
@@ -17,6 +17,52 @@ Inkra components require a React client boundary. Next.js and React Server Compo
|
|
|
17
17
|
|
|
18
18
|
## Usage
|
|
19
19
|
|
|
20
|
+
### Guided And Asynchronous Experiences
|
|
21
|
+
|
|
22
|
+
`Guide.Root`, `Guide.Trigger` and `Guide.Step` compose a targeted walkthrough.
|
|
23
|
+
`showSkip` exposes skipping; `onSkip` is separate from `onFinish`, so leaving
|
|
24
|
+
early never reports completion. Controlled `open` and `step`, target resolution,
|
|
25
|
+
focus restoration and localized labels are available.
|
|
26
|
+
|
|
27
|
+
`Loading` supports `inline`, `region` and `fullscreen` carriers with six layout
|
|
28
|
+
presets: `brand-center`, `scene-bottom`, `intel`, `staged`, `edge` and `skeleton`.
|
|
29
|
+
Supply real progress or `null` for indeterminate progress. The application owns
|
|
30
|
+
`status` and the cancel/retry/continue actions. Fullscreen blocking and scroll
|
|
31
|
+
locking can be configured independently; the edge preset is non-blocking by
|
|
32
|
+
default. Backgrounds, logos, stages, six logical content slots and rotating
|
|
33
|
+
ReactNode tips share the same component. Tips support manual navigation, pause,
|
|
34
|
+
controlled indices and sequential or non-repeating random rotation.
|
|
35
|
+
|
|
36
|
+
`InfiniteScroll` loads at `top`, `bottom`, `left` or `right` boundaries. Configure
|
|
37
|
+
automatic boundary loading and touch pull-and-release separately per direction;
|
|
38
|
+
buttons remain available for keyboard use. `onLoad` receives an abort signal,
|
|
39
|
+
direction and source. Stable item identities support anchored prepends, while
|
|
40
|
+
errors, retry, exhausted directions and in-flight requests remain explicit.
|
|
41
|
+
|
|
42
|
+
`Masonry` arranges measured items in shortest-column or sequential order while
|
|
43
|
+
retaining DOM order. It supports adaptive or fixed columns, independent gaps,
|
|
44
|
+
RTL and dynamic item sizes. Supply unique stable keys, image alternatives and
|
|
45
|
+
your own data fetching, optionally through `InfiniteScroll`:
|
|
46
|
+
|
|
47
|
+
```tsx
|
|
48
|
+
<InfiniteScroll
|
|
49
|
+
directions="bottom"
|
|
50
|
+
hasMore={hasMore}
|
|
51
|
+
onLoad={({ signal }) => fetchNextPage(signal)}
|
|
52
|
+
style={{ maxHeight: 640, overflow: 'auto' }}
|
|
53
|
+
>
|
|
54
|
+
<Masonry
|
|
55
|
+
items={items}
|
|
56
|
+
getItemKey={item => item.id}
|
|
57
|
+
minColumnWidth={220}
|
|
58
|
+
renderItem={({ item }) => <article>{item.content}</article>}
|
|
59
|
+
/>
|
|
60
|
+
</InfiniteScroll>
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
These APIs are included in this source candidate. Local browser checks are not
|
|
64
|
+
real-device certification or a claim that this candidate has been published.
|
|
65
|
+
|
|
20
66
|
Import the component styles once near the application entry point:
|
|
21
67
|
|
|
22
68
|
```tsx
|
|
@@ -176,6 +222,39 @@ All standard `button` props, including `disabled`, `name`, `value`, `form`, `ari
|
|
|
176
222
|
|
|
177
223
|
`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
224
|
|
|
225
|
+
`Textarea` respects an explicit native `rows`, including `rows={1}`. Omit both
|
|
226
|
+
`rows` and `autoSize` to retain the existing sm/md/lg minimum heights. Enable
|
|
227
|
+
`autoSize` to grow and shrink with content, including lines wrapped by the actual
|
|
228
|
+
container width:
|
|
229
|
+
|
|
230
|
+
```tsx
|
|
231
|
+
import { Textarea } from '@inkra/react/form';
|
|
232
|
+
import type { TextareaAutoSizeOptions } from '@inkra/react/form';
|
|
233
|
+
|
|
234
|
+
<Textarea rows={1} aria-label="Single-line initial height" />
|
|
235
|
+
<Textarea autoSize={{ minRows: 1, maxRows: 6 }} aria-label="Note" />
|
|
236
|
+
<Textarea autoSize rows={2} aria-label="At least two rows, unlimited growth" />
|
|
237
|
+
```
|
|
238
|
+
|
|
239
|
+
`autoSize` accepts `boolean | TextareaAutoSizeOptions` and defaults to `false`.
|
|
240
|
+
Its `minRows` falls back to `rows`, then 1; omit `maxRows` for unlimited growth.
|
|
241
|
+
Both limits accept positive safe integers; invalid values use these defaults and
|
|
242
|
+
`maxRows` below `minRows` is raised to `minRows`. The limit counts visible text
|
|
243
|
+
rows, including soft wraps, rather than newline characters or input length.
|
|
244
|
+
Above the maximum, the native editor scrolls internally; deleting text shrinks it.
|
|
245
|
+
Controlled value updates, native form reset, width and font changes remeasure
|
|
246
|
+
without emitting change events or replacing the native control. Empty editors
|
|
247
|
+
size from their minimum rows rather than their placeholder text.
|
|
248
|
+
|
|
249
|
+
Automatic sizing owns `height`, `minHeight`, `maxHeight` and vertical overflow;
|
|
250
|
+
it takes precedence over inline height and `resize="vertical"`, hiding the manual
|
|
251
|
+
handle. With automatic sizing disabled, manual resizing keeps its existing
|
|
252
|
+
behavior (an explicit `rows` supplies its minimum). Row limits apply only to the
|
|
253
|
+
editor: the character counter retains its separate footer, even while its
|
|
254
|
+
automatic indicator is hidden. On the server, native `rows` provides the initial
|
|
255
|
+
height; existing multi-line content is measured after hydration. Avoid CSS
|
|
256
|
+
`!important` height constraints when enabling automatic sizing.
|
|
257
|
+
|
|
179
258
|
`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
259
|
|
|
181
260
|
`InputNumber` supports Shift-click and Shift-arrow increments of 10 by default.
|
|
@@ -328,7 +407,7 @@ working examples, live property tabs and per-component API/accessibility notes.
|
|
|
328
407
|
| 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
408
|
| DatePicker | Calendar/Popover composition; single/range local dates, controlled/default values, clear, form fields and localized labels; no UTC conversion |
|
|
330
409
|
| 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;
|
|
410
|
+
| Pagination | Page count/current page, boundary actions and ellipses; optional page-size selection, button variants, logical alignment and wrapping |
|
|
332
411
|
| Empty | Optional `Root`, `Icon`, `Title`, `Description`, `Actions`; no mandatory card or live-region role |
|
|
333
412
|
| ToggleGroup | `Root` and `Item`; array values for single/multiple toggles, roving focus and `aria-pressed`, not tabs |
|
|
334
413
|
| Command | cmdk-backed search, grouped commands, empty/disabled states and optional modal composition |
|
|
@@ -338,6 +417,49 @@ working examples, live property tabs and per-component API/accessibility notes.
|
|
|
338
417
|
| Steps | Ordered `items`, `current`, orientation and optional `onStepChange`; status and disabled steps |
|
|
339
418
|
| Timeline | Ordered `items` with date/title/description/content, semantic `dateTime` and horizontal/vertical orientation |
|
|
340
419
|
|
|
420
|
+
### Pagination page sizes
|
|
421
|
+
|
|
422
|
+
```tsx
|
|
423
|
+
const [page, setPage] = useState(1);
|
|
424
|
+
const [pageSize, setPageSize] = useState(20);
|
|
425
|
+
<Pagination totalPages={Math.ceil(itemCount / pageSize)}
|
|
426
|
+
page={page} onPageChange={setPage}
|
|
427
|
+
pageSize={pageSize} onPageSizeChange={setPageSize}
|
|
428
|
+
pageSizeOptions={[10, 20, 50]} hideOnSinglePage
|
|
429
|
+
variant="outline" align="end" wrap />
|
|
430
|
+
```
|
|
431
|
+
|
|
432
|
+
The caller computes `totalPages` and slices or fetches its data. `pageSize` is
|
|
433
|
+
controlled; `defaultPageSize` initializes an uncontrolled size (default `10`).
|
|
434
|
+
Only positive safe integers are accepted. Invalid sizes fall back to `10`;
|
|
435
|
+
invalid options are ignored, duplicates removed and options sorted numerically.
|
|
436
|
+
The current size is retained in a nonempty option list. Omit `pageSizeOptions`
|
|
437
|
+
or provide no valid options to hide the selector.
|
|
438
|
+
|
|
439
|
+
Selecting a different size first calls `onPageSizeChange(size)`, then requests
|
|
440
|
+
`onPageChange(1)` when the current page exceeds one. Each uncontrolled state
|
|
441
|
+
updates independently; controlled state changes only when the parent accepts
|
|
442
|
+
the request. Rendering, changing totals or options, and clamping do not emit
|
|
443
|
+
user callbacks. `hideOnSinglePage` hides page controls for zero or one page while
|
|
444
|
+
retaining the size selector.
|
|
445
|
+
|
|
446
|
+
`variant` uses the Button variants (default `ghost`). `align` accepts logical
|
|
447
|
+
`start | center | end` (default `start`); `wrap` defaults to `false` and retains
|
|
448
|
+
local horizontal scrolling. The size selector reuses Select, inherits disabled
|
|
449
|
+
state and direction, and uses InkraProvider labels. Override
|
|
450
|
+
`labels.pageSize` (accessible name) and `labels.pageSizeOption(size)` (visible
|
|
451
|
+
option text) for application-specific copy. `simple`, `renderPage` and
|
|
452
|
+
`renderSummary` compose with these options.
|
|
453
|
+
|
|
454
|
+
分页容量由调用方计算总页数并切分或请求数据。`pageSize` 为受控值,
|
|
455
|
+
`defaultPageSize` 为非受控初值,默认 10;只接收正安全整数。非法容量回退为
|
|
456
|
+
10,非法选项忽略,选项去重并排序,有效列表保留当前容量。省略或提供空的有效
|
|
457
|
+
选项列表时不显示选择器。切换容量先通知 `onPageSizeChange`,当前页大于 1 时
|
|
458
|
+
再请求 `onPageChange(1)`;受控值由父组件决定是否接受。渲染、总页数及选项变化
|
|
459
|
+
不触发交互回调。`hideOnSinglePage` 隐藏翻页按钮但保留容量选择器;`align`
|
|
460
|
+
按逻辑方向对齐,`wrap` 允许换行,默认仍为局部横滚。容量选择复用 Select,
|
|
461
|
+
继承禁用、方向和语言设置,可用 `labels.pageSize/pageSizeOption` 覆盖文案。
|
|
462
|
+
|
|
341
463
|
## Utility Components (Unreleased)
|
|
342
464
|
|
|
343
465
|
`Slot` is a fixed square visual container for arbitrary icons or content. Static
|
|
@@ -359,6 +481,22 @@ motion and SSR show deterministic final values. Intermediate frames are visual;
|
|
|
359
481
|
the polite announcement updates only at the final value. Use `getValueLabel`
|
|
360
482
|
when units or custom rendering need a complete accessible phrase.
|
|
361
483
|
|
|
484
|
+
Use `animation="roll"` for vertical, place-aligned digit reels. Integers align
|
|
485
|
+
from the right and fractions from the decimal point; carry, multiplication,
|
|
486
|
+
shrinking values and sign changes expand or collapse the affected columns.
|
|
487
|
+
Grouping, signs, currency and compact units remain formatted text, not digit
|
|
488
|
+
reels. `rollDirection="auto"` rolls up for increasing values and down for
|
|
489
|
+
decreasing values; `up` or `down` fixes the direction. `duration` and `easing`
|
|
490
|
+
apply to both modes. The default `animation="count"` retains value interpolation.
|
|
491
|
+
`renderValue` takes precedence over the built-in reels. Changing the formatting
|
|
492
|
+
or animation mode settles immediately, as do disabled/reduced motion, invalid
|
|
493
|
+
durations, and transitions with more than 128 formatted tokens. Exact strings
|
|
494
|
+
and bigint values retain their original precision in either mode.
|
|
495
|
+
|
|
496
|
+
```tsx
|
|
497
|
+
<NumberTicker value={amount} animation="roll" rollDirection="auto" duration={650} />
|
|
498
|
+
```
|
|
499
|
+
|
|
362
500
|
Set `compact` to follow `locale` (Chinese units for `zh`, English units otherwise),
|
|
363
501
|
or use `compact="en"` / `compact="zh"` to select a unit table explicitly.
|
|
364
502
|
English uses this library's short-scale suffix convention: K, M, B, T, Qa, Qi,
|
|
@@ -470,6 +608,33 @@ The matching inherited tokens are `--inkra-icon-stroke-width`,
|
|
|
470
608
|
and `--inkra-icon-break-count`. Finite explicit props override those tokens.
|
|
471
609
|
See `docs/pencil-contract.md` in the workspace for the material contract.
|
|
472
610
|
|
|
611
|
+
Icons support `variant="outline"` (default) and `variant="filled"`. Filled icons
|
|
612
|
+
keep holes and internal marks transparent, join overlapping silhouettes and do
|
|
613
|
+
not close open arrows or checks into polygons. Set `fillColor` independently of
|
|
614
|
+
the contour's CSS `color`; it defaults to `currentColor` and accepts CSS variables.
|
|
615
|
+
The silhouette stays continuous while stroke controls affect its exterior.
|
|
616
|
+
|
|
617
|
+
```tsx
|
|
618
|
+
<PencilIcon paths={folderPaths} variant="filled" fillColor="var(--folder-color)"
|
|
619
|
+
sourceSize={24} size={32} style={{ color: 'var(--inkra-ink)' }} />
|
|
620
|
+
```
|
|
621
|
+
|
|
622
|
+
Supply `filledPaths` when a shape needs a dedicated filled silhouette. Each path
|
|
623
|
+
can use `mode: 'fill'` with an optional `color` for independently colored regions,
|
|
624
|
+
or `mode: 'cutout'` for transparent details. Closed cutouts remove an area; open
|
|
625
|
+
cutouts remove a stroke without implicitly closing it. Explicit fills accept
|
|
626
|
+
`fillRule: 'nonzero' | 'evenodd'` (default `nonzero`). These authored regions do
|
|
627
|
+
not add their own contour stroke. For lighter colored-pencil regions, use
|
|
628
|
+
`mode: 'wash'` with the same optional `color` and `fillRule`: paper remains visible
|
|
629
|
+
between pigment strokes, with a light pencil edge in the icon's CSS `color`.
|
|
630
|
+
Dense `fill` regions are unchanged.
|
|
631
|
+
Explicit `mode: 'stroke'` keeps a line symbol
|
|
632
|
+
even when its path closes; omitted mode detects filled silhouettes automatically.
|
|
633
|
+
An empty `filledPaths` intentionally hides the
|
|
634
|
+
base shape in filled mode. `badge` accepts the same three props and inherits the
|
|
635
|
+
base variant and fill color unless overridden. Intensity applies once to the
|
|
636
|
+
completed image, including its fills, exterior strokes and badge.
|
|
637
|
+
|
|
473
638
|
`PencilLine` is an aria-hidden canvas decoration using the same pressure, grain, breaks, and repeated-pass renderer as surfaces and icons. It defaults to horizontal and supports a native vertical stroke without rotating a horizontal bitmap:
|
|
474
639
|
|
|
475
640
|
```tsx
|
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/PencilIcon.d.ts
CHANGED
|
@@ -1,10 +1,17 @@
|
|
|
1
1
|
import type { CanvasHTMLAttributes } from 'react';
|
|
2
2
|
export interface PencilIconPath {
|
|
3
3
|
d: string;
|
|
4
|
-
|
|
4
|
+
/** Filled icons: stroke stays a line, wash exposes paper, fill is dense; omitted mode detects closed silhouettes. */
|
|
5
|
+
mode?: 'stroke' | 'wash' | 'scuff' | 'fill' | 'cutout';
|
|
6
|
+
/** Optional pigment for an explicit fill region, including CSS custom properties. */
|
|
7
|
+
color?: string;
|
|
8
|
+
fillRule?: CanvasFillRule;
|
|
5
9
|
}
|
|
6
10
|
export interface PencilIconBadge {
|
|
7
11
|
paths: readonly PencilIconPath[];
|
|
12
|
+
filledPaths?: readonly PencilIconPath[];
|
|
13
|
+
variant?: 'outline' | 'filled';
|
|
14
|
+
fillColor?: string;
|
|
8
15
|
sourceSize?: number;
|
|
9
16
|
position?: 'top-start' | 'top-end' | 'bottom-start' | 'bottom-end';
|
|
10
17
|
/** Relative canvas size, clamped to 0.35-0.65. Defaults to 0.55. */
|
|
@@ -12,6 +19,12 @@ export interface PencilIconBadge {
|
|
|
12
19
|
}
|
|
13
20
|
export interface PencilIconProps extends Omit<CanvasHTMLAttributes<HTMLCanvasElement>, 'children' | 'height' | 'width'> {
|
|
14
21
|
paths: readonly PencilIconPath[];
|
|
22
|
+
/** Filled icons retain open symbols and use negative space for interior details. */
|
|
23
|
+
variant?: 'outline' | 'filled';
|
|
24
|
+
/** Optional authored geometry for shapes whose filled silhouette differs from the outline. */
|
|
25
|
+
filledPaths?: readonly PencilIconPath[];
|
|
26
|
+
/** Body pigment, independent of the CSS color used for the contour. Defaults to currentColor. */
|
|
27
|
+
fillColor?: string;
|
|
15
28
|
badge?: PencilIconBadge;
|
|
16
29
|
seed?: string | number;
|
|
17
30
|
size?: number;
|
package/dist/PencilIcon.js
CHANGED
|
@@ -6,6 +6,7 @@ import { seedToNumber } from './internal/random.js';
|
|
|
6
6
|
import { pencilStroke } from './internal/stroke.js';
|
|
7
7
|
import { maxStrokeRoughness, pencilRenderDefaults, readRenderToken } from './internal/renderTokens.js';
|
|
8
8
|
import { cachedPathParts } from './internal/iconGeometry.js';
|
|
9
|
+
import { paintFilledIcon } from './internal/iconFill.js';
|
|
9
10
|
const imageCache = new BoundedCache(128, 24 * 1024 * 1024);
|
|
10
11
|
function drawFallbackPath(context, data, color, mode, weightScale = 1) {
|
|
11
12
|
try {
|
|
@@ -47,56 +48,62 @@ function renderIcon(paths, options) {
|
|
|
47
48
|
const offset = (40 - sourceSize) / 2;
|
|
48
49
|
context.translate(offset, offset);
|
|
49
50
|
let sampledPoints = 0;
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
51
|
+
if (options.variant === 'filled') {
|
|
52
|
+
paintFilledIcon(context, paths, { color, fillColor: options.fillColor ?? color, sourceSize, seed, weightScale, roughnessScale, breaks, breakCount, pressureVariation, coordinateScale });
|
|
53
|
+
}
|
|
54
|
+
else
|
|
55
|
+
paths.forEach((path, index) => {
|
|
56
|
+
if (path.d.length > 65536)
|
|
57
|
+
return;
|
|
58
|
+
const mode = path.mode ?? 'stroke';
|
|
59
|
+
if (mode === 'cutout')
|
|
60
|
+
return;
|
|
61
|
+
const pathSeed = seed + index * 137;
|
|
62
|
+
if (mode === 'wash' || mode === 'fill') {
|
|
63
|
+
try {
|
|
64
|
+
context.save();
|
|
65
|
+
context.clip(new Path2D(path.d));
|
|
66
|
+
context.globalAlpha = active ? 0.76 : 0.34;
|
|
67
|
+
for (let y = sourceSize * 0.18; y < sourceSize; y += 1.6) {
|
|
68
|
+
pencilStroke(context, [[sourceSize * 0.15, y + 3], [sourceSize * 0.85, y - 3]], color, 1.1 * weightScale / (pencilRenderDefaults.strokeWidth / 1.2), pathSeed + Math.round(y * 17), 0.25, 2, false, { variation: Math.min(1, 0.85 * variationScale), fitPressure: true, breaks, breakCount, breakScale: coordinateScale, continuousBreaks: true, grain: 0.7, gain: 2.2, step: 0.35 });
|
|
69
|
+
}
|
|
70
|
+
context.restore();
|
|
71
|
+
}
|
|
72
|
+
catch {
|
|
73
|
+
drawFallbackPath(context, path.d, color, mode, weightScale);
|
|
74
|
+
}
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
56
77
|
try {
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
78
|
+
if (sampledPoints >= 65536) {
|
|
79
|
+
drawFallbackPath(context, path.d, color, mode, weightScale);
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
const parts = cachedPathParts(path.d);
|
|
83
|
+
sampledPoints += parts.reduce((total, part) => total + part.length, 0);
|
|
84
|
+
if (sampledPoints > 65536) {
|
|
85
|
+
drawFallbackPath(context, path.d, color, mode, weightScale);
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
for (const points of parts) {
|
|
89
|
+
pencilStroke(context, points, color, (mode === 'scuff' ? 0.55 : 1.65) * weightScale, pathSeed, 0.3 * roughnessScale, mode === 'scuff' ? 1 : 3, false, {
|
|
90
|
+
variation: pressureVariation,
|
|
91
|
+
fitPressure: true,
|
|
92
|
+
breaks,
|
|
93
|
+
breakCount,
|
|
94
|
+
breakScale: coordinateScale,
|
|
95
|
+
continuousBreaks: true,
|
|
96
|
+
grain: 0.68,
|
|
97
|
+
gain: mode === 'scuff' ? 1.5 : 3,
|
|
98
|
+
step: 0.35,
|
|
99
|
+
taperLength: 0.65,
|
|
100
|
+
});
|
|
62
101
|
}
|
|
63
|
-
context.restore();
|
|
64
102
|
}
|
|
65
103
|
catch {
|
|
66
104
|
drawFallbackPath(context, path.d, color, mode, weightScale);
|
|
67
105
|
}
|
|
68
|
-
|
|
69
|
-
}
|
|
70
|
-
try {
|
|
71
|
-
if (sampledPoints >= 65536) {
|
|
72
|
-
drawFallbackPath(context, path.d, color, mode, weightScale);
|
|
73
|
-
return;
|
|
74
|
-
}
|
|
75
|
-
const parts = cachedPathParts(path.d);
|
|
76
|
-
sampledPoints += parts.reduce((total, part) => total + part.length, 0);
|
|
77
|
-
if (sampledPoints > 65536) {
|
|
78
|
-
drawFallbackPath(context, path.d, color, mode, weightScale);
|
|
79
|
-
return;
|
|
80
|
-
}
|
|
81
|
-
for (const points of parts) {
|
|
82
|
-
pencilStroke(context, points, color, (mode === 'scuff' ? 0.55 : 1.65) * weightScale, pathSeed, 0.3 * roughnessScale, mode === 'scuff' ? 1 : 3, false, {
|
|
83
|
-
variation: pressureVariation,
|
|
84
|
-
fitPressure: true,
|
|
85
|
-
breaks,
|
|
86
|
-
breakCount,
|
|
87
|
-
breakScale: coordinateScale,
|
|
88
|
-
continuousBreaks: true,
|
|
89
|
-
grain: 0.68,
|
|
90
|
-
gain: mode === 'scuff' ? 1.5 : 3,
|
|
91
|
-
step: 0.35,
|
|
92
|
-
taperLength: 0.65,
|
|
93
|
-
});
|
|
94
|
-
}
|
|
95
|
-
}
|
|
96
|
-
catch {
|
|
97
|
-
drawFallbackPath(context, path.d, color, mode, weightScale);
|
|
98
|
-
}
|
|
99
|
-
});
|
|
106
|
+
});
|
|
100
107
|
if (active) {
|
|
101
108
|
pencilStroke(context, [[sourceSize * 0.2, sourceSize - 2], [sourceSize * 0.5, sourceSize - 1.5], [sourceSize * 0.8, sourceSize - 2.2]], color, 1.1 * weightScale, seed + 977, 0.25 * roughnessScale, 3, false, { variation: 0.8 * variationScale, fitPressure: true, breaks, breakCount, breakScale: coordinateScale, continuousBreaks: true, grain: 0.7, gain: 2.8, step: 0.35 });
|
|
102
109
|
}
|
|
@@ -109,8 +116,10 @@ function renderIcon(paths, options) {
|
|
|
109
116
|
const extent = 40 * scale;
|
|
110
117
|
const x = onLeft ? 0 : 40 - extent;
|
|
111
118
|
const y = position.startsWith('top') ? 0 : 40 - extent;
|
|
112
|
-
const
|
|
119
|
+
const badgeVariant = badge.variant ?? options.variant;
|
|
120
|
+
const badgeImage = renderIcon(badgeVariant === 'filled' ? badge.filledPaths ?? badge.paths : badge.paths, {
|
|
113
121
|
seed: seed + 1543, size: size * scale, sourceSize: badgeSourceSize, active: false, color, dpr,
|
|
122
|
+
variant: badgeVariant, fillColor: badge.fillColor ?? options.fillColor,
|
|
114
123
|
weightScale: weightScale / scale, roughnessScale, breaks, breakCount, pressureVariation,
|
|
115
124
|
});
|
|
116
125
|
const mask = document.createElement('canvas');
|
|
@@ -140,17 +149,18 @@ function renderIcon(paths, options) {
|
|
|
140
149
|
}
|
|
141
150
|
return canvas;
|
|
142
151
|
}
|
|
143
|
-
export const PencilIcon = forwardRef(function PencilIcon({ active = false, badge, className = '', decorative = true, paths, seed, size = 40, sourceSize = 32, strokeWidth, intensity, breaks, breakCount, pressureVariation, ...props }, forwardedRef) {
|
|
152
|
+
export const PencilIcon = forwardRef(function PencilIcon({ active = false, badge, className = '', decorative = true, paths, variant = 'outline', filledPaths, fillColor, seed, size = 40, sourceSize = 32, strokeWidth, intensity, breaks, breakCount, pressureVariation, ...props }, forwardedRef) {
|
|
144
153
|
const canvasRef = useRef(null);
|
|
145
154
|
const lastPaintRef = useRef('');
|
|
146
155
|
useImperativeHandle(forwardedRef, () => canvasRef.current);
|
|
147
156
|
// Compare values, not caller object identity. Serializing each render also
|
|
148
157
|
// detects in-place edits; the effect receives a stable snapshot of that value.
|
|
149
|
-
const
|
|
158
|
+
const originalGeometryKey = JSON.stringify(paths);
|
|
159
|
+
const geometryKey = variant === 'filled' && filledPaths ? JSON.stringify(filledPaths) : originalGeometryKey;
|
|
150
160
|
const badgeKey = badge ? JSON.stringify(badge) : '';
|
|
151
161
|
const stablePaths = useMemo(() => JSON.parse(geometryKey), [geometryKey]);
|
|
152
162
|
const stableBadge = useMemo(() => badgeKey ? JSON.parse(badgeKey) : undefined, [badgeKey]);
|
|
153
|
-
const resolvedSeed = seedToNumber(seed ??
|
|
163
|
+
const resolvedSeed = seedToNumber(seed ?? originalGeometryKey);
|
|
154
164
|
useEffect(() => {
|
|
155
165
|
const canvas = canvasRef.current;
|
|
156
166
|
if (!canvas)
|
|
@@ -162,6 +172,28 @@ export const PencilIcon = forwardRef(function PencilIcon({ active = false, badge
|
|
|
162
172
|
const dpr = effectiveDpr(3);
|
|
163
173
|
const computed = getComputedStyle(canvas);
|
|
164
174
|
const color = computed.color;
|
|
175
|
+
const resolveColor = (value) => {
|
|
176
|
+
if (!value || value === 'currentColor')
|
|
177
|
+
return color;
|
|
178
|
+
const probe = document.createElement('span');
|
|
179
|
+
probe.style.color = value;
|
|
180
|
+
// Canvas fallback children inherit its own color and custom properties, not just its parent's.
|
|
181
|
+
canvas.appendChild(probe);
|
|
182
|
+
try {
|
|
183
|
+
return getComputedStyle(probe).color || color;
|
|
184
|
+
}
|
|
185
|
+
finally {
|
|
186
|
+
probe.remove();
|
|
187
|
+
}
|
|
188
|
+
};
|
|
189
|
+
const resolvedFill = variant === 'filled' ? resolveColor(fillColor) : color;
|
|
190
|
+
const resolvePaths = (items) => items.map(path => path.color ? { ...path, color: resolveColor(path.color) } : path);
|
|
191
|
+
const renderedPaths = variant === 'filled' ? resolvePaths(stablePaths) : stablePaths;
|
|
192
|
+
const renderedBadge = stableBadge ? { ...stableBadge,
|
|
193
|
+
fillColor: resolveColor(stableBadge.fillColor ?? fillColor),
|
|
194
|
+
paths: resolvePaths(stableBadge.paths),
|
|
195
|
+
filledPaths: stableBadge.filledPaths ? resolvePaths(stableBadge.filledPaths) : undefined,
|
|
196
|
+
} : undefined;
|
|
165
197
|
const rtl = computed.direction === 'rtl';
|
|
166
198
|
const iconWeight = readRenderToken(computed, '--inkra-icon-stroke-width', pencilRenderDefaults.iconStrokeWidth, 0.3, 3);
|
|
167
199
|
const weightScale = (typeof strokeWidth === 'number' && Number.isFinite(strokeWidth) ? Math.min(3, Math.max(0.3, strokeWidth)) : iconWeight) / 1.2;
|
|
@@ -180,9 +212,10 @@ export const PencilIcon = forwardRef(function PencilIcon({ active = false, badge
|
|
|
180
212
|
: readRenderToken(computed, '--inkra-icon-break-count', pencilRenderDefaults.iconBreakCount, 0, 12));
|
|
181
213
|
const key = JSON.stringify([
|
|
182
214
|
PENCIL_RENDERER_VERSION,
|
|
183
|
-
'icon-path-
|
|
215
|
+
'icon-path-paper-wash-v8',
|
|
184
216
|
geometryKey,
|
|
185
217
|
badgeKey,
|
|
218
|
+
variant, resolvedFill, renderedPaths, renderedBadge,
|
|
186
219
|
rtl,
|
|
187
220
|
resolvedSeed,
|
|
188
221
|
size,
|
|
@@ -202,7 +235,7 @@ export const PencilIcon = forwardRef(function PencilIcon({ active = false, badge
|
|
|
202
235
|
image = undefined;
|
|
203
236
|
}
|
|
204
237
|
if (!image) {
|
|
205
|
-
image = renderIcon(
|
|
238
|
+
image = renderIcon(renderedPaths, { seed: resolvedSeed, size, sourceSize, active, color, dpr, variant, fillColor: resolvedFill, badge: renderedBadge, rtl, weightScale, roughnessScale, breaks: strokeBreaks, breakCount: strokeBreakCount, pressureVariation: strokeVariation });
|
|
206
239
|
imageCache.set(key, image, canvasByteCost(image));
|
|
207
240
|
}
|
|
208
241
|
canvas.width = image.width;
|
|
@@ -236,7 +269,9 @@ export const PencilIcon = forwardRef(function PencilIcon({ active = false, badge
|
|
|
236
269
|
inheritedColor.observe(element, { attributes: true, attributeFilter: ['style', 'class', 'dir', 'data-theme', 'data-inkra-theme'] });
|
|
237
270
|
}
|
|
238
271
|
const colorScheme = window.matchMedia('(prefers-color-scheme: dark)');
|
|
272
|
+
const forcedColors = window.matchMedia('(forced-colors: active)');
|
|
239
273
|
colorScheme.addEventListener('change', paint);
|
|
274
|
+
forcedColors.addEventListener('change', paint);
|
|
240
275
|
const stopRecovery = observeCanvasRecovery(() => { lastPaintRef.current = ''; paint(); });
|
|
241
276
|
paint();
|
|
242
277
|
return () => {
|
|
@@ -245,10 +280,11 @@ export const PencilIcon = forwardRef(function PencilIcon({ active = false, badge
|
|
|
245
280
|
stopRecovery();
|
|
246
281
|
inheritedColor.disconnect();
|
|
247
282
|
colorScheme.removeEventListener('change', paint);
|
|
283
|
+
forcedColors.removeEventListener('change', paint);
|
|
248
284
|
};
|
|
249
|
-
}, [active, stableBadge, badgeKey, geometryKey, stablePaths, resolvedSeed, size, sourceSize, strokeWidth, intensity, breaks, breakCount, pressureVariation]);
|
|
285
|
+
}, [active, stableBadge, badgeKey, geometryKey, stablePaths, resolvedSeed, size, sourceSize, strokeWidth, intensity, breaks, breakCount, pressureVariation, variant, fillColor]);
|
|
250
286
|
const accessibilityProps = decorative
|
|
251
287
|
? { 'aria-hidden': true }
|
|
252
288
|
: { role: props.role ?? 'img' };
|
|
253
|
-
return (_jsx("canvas", { ...props, ...accessibilityProps, className: ['inkra-pencil-icon', className].filter(Boolean).join(' '), "data-active": active || undefined, "data-composite": Boolean(badge?.paths.length) || undefined, "data-renderer": "inkra-pressure-pencil", ref: canvasRef, style: { width: size, height: size, ...props.style } }));
|
|
289
|
+
return (_jsx("canvas", { ...props, ...accessibilityProps, className: ['inkra-pencil-icon', className].filter(Boolean).join(' '), "data-active": active || undefined, "data-icon-variant": variant, "data-composite": Boolean(badge?.paths.length) || undefined, "data-renderer": "inkra-pressure-pencil", ref: canvasRef, style: { width: size, height: size, ...props.style } }));
|
|
254
290
|
});
|
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;
|