gbs-add-block 1.2.13 → 1.2.14

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.
Files changed (53) hide show
  1. package/README.md +4 -12
  2. package/index.cjs +4 -0
  3. package/package.json +1 -1
  4. package/source/beta-components/dialog/README.md +39 -0
  5. package/source/beta-components/dialog/__tests__/core.test.ts +86 -0
  6. package/source/beta-components/dialog/core/api.ts +34 -0
  7. package/source/beta-components/dialog/core/dialog.ts +11 -0
  8. package/source/beta-components/dialog/core/index.ts +7 -0
  9. package/source/beta-components/dialog/core/store.ts +81 -0
  10. package/source/beta-components/dialog/core/types.ts +59 -0
  11. package/source/beta-components/dialog/index.ts +7 -0
  12. package/source/beta-components/dialog/react/Dialog.tsx +279 -0
  13. package/source/beta-components/dialog/react/DialogHost.tsx +45 -0
  14. package/source/beta-components/dialog/react/icons.tsx +51 -0
  15. package/source/beta-components/dialog/react/locale.ts +8 -0
  16. package/source/beta-components/dialog/react/props.ts +13 -0
  17. package/source/beta-components/dialog/styles.css +278 -0
  18. package/source/beta-components/input/README.md +44 -0
  19. package/source/beta-components/input/__tests__/core.test.ts +75 -0
  20. package/source/beta-components/input/core/count.ts +14 -0
  21. package/source/beta-components/input/core/index.ts +11 -0
  22. package/source/beta-components/input/core/otp.ts +64 -0
  23. package/source/beta-components/input/core/types.ts +14 -0
  24. package/source/beta-components/input/index.ts +8 -0
  25. package/source/beta-components/input/react/Input.tsx +219 -0
  26. package/source/beta-components/input/react/OtpInput.tsx +256 -0
  27. package/source/beta-components/input/react/dom.ts +10 -0
  28. package/source/beta-components/input/react/icons.tsx +35 -0
  29. package/source/beta-components/input/react/locale.ts +9 -0
  30. package/source/beta-components/input/react/props.ts +6 -0
  31. package/source/beta-components/input/styles.css +296 -0
  32. package/source/beta-components/modal/README.md +51 -0
  33. package/source/beta-components/modal/__tests__/core.test.ts +55 -0
  34. package/source/beta-components/modal/core/dismiss.ts +29 -0
  35. package/source/beta-components/modal/core/index.ts +3 -0
  36. package/source/beta-components/modal/core/types.ts +27 -0
  37. package/source/beta-components/modal/index.ts +5 -0
  38. package/source/beta-components/modal/react/Modal.tsx +238 -0
  39. package/source/beta-components/modal/react/icons.tsx +19 -0
  40. package/source/beta-components/modal/react/locale.ts +5 -0
  41. package/source/beta-components/modal/react/props.ts +17 -0
  42. package/source/beta-components/modal/styles.css +235 -0
  43. package/source/beta-components/textarea/README.md +39 -0
  44. package/source/beta-components/textarea/__tests__/core.test.ts +38 -0
  45. package/source/beta-components/textarea/core/count.ts +14 -0
  46. package/source/beta-components/textarea/core/index.ts +4 -0
  47. package/source/beta-components/textarea/core/size.ts +23 -0
  48. package/source/beta-components/textarea/core/types.ts +17 -0
  49. package/source/beta-components/textarea/index.ts +5 -0
  50. package/source/beta-components/textarea/react/Textarea.tsx +209 -0
  51. package/source/beta-components/textarea/react/locale.ts +5 -0
  52. package/source/beta-components/textarea/react/props.ts +4 -0
  53. package/source/beta-components/textarea/styles.css +159 -0
@@ -0,0 +1,238 @@
1
+ "use client";
2
+
3
+ import {
4
+ useCallback,
5
+ useEffect,
6
+ useId,
7
+ useImperativeHandle,
8
+ useMemo,
9
+ useRef,
10
+ useState,
11
+ type CSSProperties,
12
+ type MouseEvent,
13
+ type ReactNode,
14
+ type Ref,
15
+ type RefObject,
16
+ } from "react";
17
+ import { canDismiss, confirmClose, isOutside } from "../core/dismiss";
18
+ import type {
19
+ CloseReason,
20
+ ModalLocaleText,
21
+ ModalPlacement,
22
+ ModalSize,
23
+ } from "../core/types";
24
+ import { XIcon } from "./icons";
25
+ import { defaultModalText } from "./locale";
26
+ import { cx, type ModalHandle, type ModalRenderProps, type ModalSlot } from "./props";
27
+
28
+ /** Matches the CSS transition, so content stays mounted while the modal animates out. */
29
+ const EXIT_DURATION = 200;
30
+
31
+ type Renderable = ReactNode | ((props: ModalRenderProps) => ReactNode);
32
+
33
+ export interface ModalProps {
34
+ /** Controlled open state. */
35
+ open?: boolean;
36
+ defaultOpen?: boolean;
37
+ /** `reason` says what closed it; it is undefined when opening. */
38
+ onOpenChange?(open: boolean, reason?: CloseReason): void;
39
+ title?: ReactNode;
40
+ description?: ReactNode;
41
+ /** Accessible name when there is no `title`. */
42
+ "aria-label"?: string;
43
+ children?: Renderable;
44
+ footer?: Renderable;
45
+ /** Width for dialogs; for drawers, the width (left/right) or height (bottom). Default `md`. */
46
+ size?: ModalSize;
47
+ /** Default `center`. */
48
+ placement?: ModalPlacement;
49
+ /** Show the × button. Default true. */
50
+ closeButton?: boolean;
51
+ /** Default true. */
52
+ closeOnEscape?: boolean;
53
+ /** Default true. */
54
+ closeOnBackdrop?: boolean;
55
+ /** Return `false` (or a promise of it) to keep the modal open, e.g. for unsaved changes. */
56
+ onBeforeClose?(reason: CloseReason): boolean | Promise<boolean>;
57
+ /** Focused on open. Otherwise the first `[data-autofocus]` element, then the first focusable one. */
58
+ initialFocus?: RefObject<HTMLElement | null>;
59
+ /** Keep the content mounted while closed, preserving its state. Default false. */
60
+ keepMounted?: boolean;
61
+ id?: string;
62
+ className?: string;
63
+ classNames?: Partial<Record<ModalSlot, string>>;
64
+ style?: CSSProperties;
65
+ localeText?: Partial<ModalLocaleText>;
66
+ ref?: Ref<ModalHandle>;
67
+ }
68
+
69
+ const outside = (event: MouseEvent<HTMLDialogElement>) =>
70
+ event.target === event.currentTarget &&
71
+ isOutside(event.currentTarget.getBoundingClientRect(), event.clientX, event.clientY);
72
+
73
+ /**
74
+ * A modal built on the native `<dialog>` element. The browser provides the top
75
+ * layer, the inert background, the focus trap and focus return; the component
76
+ * adds controlled state, dismiss rules, drawers and animation.
77
+ */
78
+ export function Modal(props: ModalProps) {
79
+ const {
80
+ ref,
81
+ open: openProp,
82
+ defaultOpen = false,
83
+ onOpenChange,
84
+ title,
85
+ description,
86
+ "aria-label": ariaLabel,
87
+ children,
88
+ footer,
89
+ size = "md",
90
+ placement = "center",
91
+ closeButton = true,
92
+ closeOnEscape = true,
93
+ closeOnBackdrop = true,
94
+ onBeforeClose,
95
+ initialFocus,
96
+ keepMounted = false,
97
+ id: idProp,
98
+ className,
99
+ classNames,
100
+ style,
101
+ localeText,
102
+ } = props;
103
+
104
+ const reactId = useId();
105
+ const id = idProp ?? reactId;
106
+ const text = useMemo(() => ({ ...defaultModalText, ...localeText }), [localeText]);
107
+ const dialogRef = useRef<HTMLDialogElement>(null);
108
+ const pressStartedOutside = useRef(false);
109
+
110
+ const [internalOpen, setInternalOpen] = useState(defaultOpen);
111
+ const open = openProp ?? internalOpen;
112
+
113
+ // Keep the content mounted through the exit transition. Adjusting state while
114
+ // rendering avoids an extra render with the content already gone.
115
+ const [previousOpen, setPreviousOpen] = useState(open);
116
+ const [closing, setClosing] = useState(false);
117
+ if (previousOpen !== open) {
118
+ setPreviousOpen(open);
119
+ setClosing(!open);
120
+ }
121
+
122
+ useEffect(() => {
123
+ if (!closing) return;
124
+ const timer = setTimeout(() => setClosing(false), EXIT_DURATION);
125
+ return () => clearTimeout(timer);
126
+ }, [closing]);
127
+
128
+ const setOpen = useCallback(
129
+ (next: boolean, reason?: CloseReason) => {
130
+ if (openProp === undefined) setInternalOpen(next);
131
+ onOpenChange?.(next, reason);
132
+ },
133
+ [openProp, onOpenChange],
134
+ );
135
+
136
+ const requestClose = useCallback(
137
+ (reason: CloseReason) => {
138
+ if (!canDismiss(reason, { closeOnEscape, closeOnBackdrop })) return;
139
+ void confirmClose(onBeforeClose, reason).then((allowed) => {
140
+ if (allowed) setOpen(false, reason);
141
+ });
142
+ },
143
+ [closeOnEscape, closeOnBackdrop, onBeforeClose, setOpen],
144
+ );
145
+
146
+ useEffect(() => {
147
+ const dialog = dialogRef.current;
148
+ if (!dialog) return;
149
+ if (open && !dialog.open) {
150
+ dialog.showModal();
151
+ const target =
152
+ initialFocus?.current ?? dialog.querySelector<HTMLElement>("[data-autofocus]");
153
+ target?.focus();
154
+ } else if (!open && dialog.open) {
155
+ dialog.close();
156
+ }
157
+ }, [open, initialFocus]);
158
+
159
+ useImperativeHandle(
160
+ ref,
161
+ () => ({
162
+ open: () => setOpen(true),
163
+ close: () => requestClose("api"),
164
+ getElement: () => dialogRef.current,
165
+ }),
166
+ [setOpen, requestClose],
167
+ );
168
+
169
+ const renderProps: ModalRenderProps = { close: () => requestClose("api") };
170
+ const render = (node: Renderable) => (typeof node === "function" ? node(renderProps) : node);
171
+ const mounted = open || closing || keepMounted;
172
+
173
+ return (
174
+ <dialog
175
+ ref={dialogRef}
176
+ id={id}
177
+ className={cx("md-root", classNames?.root, className)}
178
+ style={style}
179
+ data-size={size}
180
+ data-placement={placement}
181
+ data-state={open ? "open" : "closed"}
182
+ aria-labelledby={title ? `${id}-title` : undefined}
183
+ aria-describedby={description ? `${id}-description` : undefined}
184
+ aria-label={title ? undefined : ariaLabel}
185
+ // Escape: keep the browser from closing it, and decide ourselves.
186
+ onCancel={(event) => {
187
+ event.preventDefault();
188
+ requestClose("escape");
189
+ }}
190
+ // Closed some other way, e.g. by a `<form method="dialog">` inside.
191
+ onClose={() => {
192
+ if (open) setOpen(false, "api");
193
+ }}
194
+ // A drag that starts inside and ends on the backdrop must not close it.
195
+ onPointerDown={(event) => {
196
+ pressStartedOutside.current = outside(event);
197
+ }}
198
+ onClick={(event) => {
199
+ if (pressStartedOutside.current && outside(event)) requestClose("backdrop");
200
+ pressStartedOutside.current = false;
201
+ }}
202
+ >
203
+ {mounted && (
204
+ <>
205
+ {(title || description || closeButton) && (
206
+ <header className={cx("md-header", classNames?.header)}>
207
+ <div className="md-heading">
208
+ {title && (
209
+ <h2 id={`${id}-title`} className={cx("md-title", classNames?.title)}>
210
+ {title}
211
+ </h2>
212
+ )}
213
+ {description && (
214
+ <p id={`${id}-description`} className={cx("md-description", classNames?.description)}>
215
+ {description}
216
+ </p>
217
+ )}
218
+ </div>
219
+ {closeButton && (
220
+ <button
221
+ type="button"
222
+ className={cx("md-close", classNames?.close)}
223
+ aria-label={text.close}
224
+ title={text.close}
225
+ onClick={() => requestClose("close-button")}
226
+ >
227
+ <XIcon />
228
+ </button>
229
+ )}
230
+ </header>
231
+ )}
232
+ <div className={cx("md-body", classNames?.body)}>{render(children)}</div>
233
+ {footer && <footer className={cx("md-footer", classNames?.footer)}>{render(footer)}</footer>}
234
+ </>
235
+ )}
236
+ </dialog>
237
+ );
238
+ }
@@ -0,0 +1,19 @@
1
+ import type { SVGProps } from "react";
2
+
3
+ export const XIcon = (props: SVGProps<SVGSVGElement>) => (
4
+ <svg
5
+ width="16"
6
+ height="16"
7
+ viewBox="0 0 24 24"
8
+ fill="none"
9
+ stroke="currentColor"
10
+ strokeWidth="2"
11
+ strokeLinecap="round"
12
+ strokeLinejoin="round"
13
+ aria-hidden="true"
14
+ focusable="false"
15
+ {...props}
16
+ >
17
+ <path d="M18 6 6 18M6 6l12 12" />
18
+ </svg>
19
+ );
@@ -0,0 +1,5 @@
1
+ import type { ModalLocaleText } from "../core/types";
2
+
3
+ export const defaultModalText: ModalLocaleText = {
4
+ close: "Close",
5
+ };
@@ -0,0 +1,17 @@
1
+ export type ModalSlot = "root" | "header" | "title" | "description" | "close" | "body" | "footer";
2
+
3
+ export interface ModalHandle {
4
+ open(): void;
5
+ /** Asks to close; `onBeforeClose` still gets a say. */
6
+ close(): void;
7
+ getElement(): HTMLDialogElement | null;
8
+ }
9
+
10
+ /** Passed to `children` and `footer` when they are functions. */
11
+ export interface ModalRenderProps {
12
+ /** Closes through `onBeforeClose`, like the close button. */
13
+ close(): void;
14
+ }
15
+
16
+ export const cx = (...names: (string | false | null | undefined)[]) =>
17
+ names.filter(Boolean).join(" ");
@@ -0,0 +1,235 @@
1
+ /*
2
+ * Modal styles.
3
+ *
4
+ * Tokens read the DataGrid's --dg-* variables when they are set on an ancestor
5
+ * (set them on :root to share one theme) and fall back to the same palette
6
+ * otherwise. Rules live in the `components` layer, so utility classes passed
7
+ * through `className` / `classNames` override them.
8
+ */
9
+ @layer theme, base, components, utilities;
10
+
11
+ @layer components {
12
+ .md-root {
13
+ --md-bg: var(--dg-bg, light-dark(#ffffff, #0b0b0e));
14
+ --md-fg: var(--dg-fg, light-dark(#18181b, #f4f4f5));
15
+ --md-muted: var(--dg-muted, light-dark(#71717a, #a1a1aa));
16
+ --md-border: var(--dg-border, light-dark(#e4e4e7, #27272a));
17
+ --md-hover: var(--dg-hover, light-dark(#f4f4f5, #1f1f23));
18
+ --md-footer-bg: var(--dg-header-bg, light-dark(#fafafa, #111114));
19
+ --md-focus: var(--dg-focus, light-dark(#2563eb, #60a5fa));
20
+ --md-shadow: 0 24px 64px -16px light-dark(rgb(0 0 0 / 0.3), rgb(0 0 0 / 0.8));
21
+ --md-backdrop: light-dark(rgb(9 9 11 / 0.45), rgb(0 0 0 / 0.65));
22
+ --md-radius: var(--dg-radius, 12px);
23
+ --md-font-size: var(--dg-font-size, 13px);
24
+ --md-width: 520px;
25
+ --md-gutter: 16px;
26
+ --md-px: 20px;
27
+ --md-duration: 200ms;
28
+ --md-enter: 0 8px;
29
+
30
+ color-scheme: inherit;
31
+ box-sizing: border-box;
32
+ width: min(var(--md-width), 100vw - 2 * var(--md-gutter));
33
+ max-width: none;
34
+ max-height: calc(100dvh - 2 * var(--md-gutter));
35
+ margin: auto;
36
+ padding: 0;
37
+ overflow: hidden;
38
+ border: 1px solid var(--md-border);
39
+ border-radius: var(--md-radius);
40
+ background: var(--md-bg);
41
+ color: var(--md-fg);
42
+ box-shadow: var(--md-shadow);
43
+ font-size: var(--md-font-size);
44
+ line-height: 1.5;
45
+ opacity: 0;
46
+ translate: var(--md-enter);
47
+ transition:
48
+ opacity var(--md-duration) ease,
49
+ translate var(--md-duration) ease,
50
+ overlay var(--md-duration) allow-discrete,
51
+ display var(--md-duration) allow-discrete;
52
+ }
53
+ .md-root[open] {
54
+ display: flex;
55
+ flex-direction: column;
56
+ opacity: 1;
57
+ translate: 0 0;
58
+ }
59
+ @starting-style {
60
+ .md-root[open] {
61
+ opacity: 0;
62
+ translate: var(--md-enter);
63
+ }
64
+ }
65
+
66
+ .md-root::backdrop {
67
+ background: var(--md-backdrop, rgb(9 9 11 / 0.45));
68
+ opacity: 0;
69
+ transition:
70
+ opacity var(--md-duration) ease,
71
+ overlay var(--md-duration) allow-discrete,
72
+ display var(--md-duration) allow-discrete;
73
+ }
74
+ .md-root[open]::backdrop {
75
+ opacity: 1;
76
+ }
77
+ @starting-style {
78
+ .md-root[open]::backdrop {
79
+ opacity: 0;
80
+ }
81
+ }
82
+
83
+ :where(.dark, [data-theme="dark"]) .md-root {
84
+ color-scheme: dark;
85
+ }
86
+ :where(.light, [data-theme="light"]) .md-root {
87
+ color-scheme: light;
88
+ }
89
+
90
+ :where(.md-root) *,
91
+ :where(.md-root) *::before,
92
+ :where(.md-root) *::after {
93
+ box-sizing: border-box;
94
+ }
95
+
96
+ /* The page behind an open modal doesn't scroll. */
97
+ :root:has(.md-root[open]) {
98
+ overflow: hidden;
99
+ }
100
+
101
+ /* --------------------------------------------------------------- sizes */
102
+
103
+ .md-root[data-size="sm"] {
104
+ --md-width: 400px;
105
+ }
106
+ .md-root[data-size="lg"] {
107
+ --md-width: 720px;
108
+ }
109
+ .md-root[data-size="xl"] {
110
+ --md-width: 960px;
111
+ }
112
+ .md-root[data-size="full"] {
113
+ --md-width: 100vw;
114
+ --md-gutter: 0px;
115
+ height: 100dvh;
116
+ max-height: 100dvh;
117
+ border: 0;
118
+ border-radius: 0;
119
+ }
120
+
121
+ /* ----------------------------------------------------------- placement */
122
+
123
+ .md-root[data-placement="top"] {
124
+ margin-top: min(10vh, 80px);
125
+ }
126
+
127
+ .md-root:is([data-placement="left"], [data-placement="right"]) {
128
+ --md-gutter: 0px;
129
+ height: 100dvh;
130
+ max-height: 100dvh;
131
+ border-block: 0;
132
+ border-radius: 0;
133
+ }
134
+ .md-root[data-placement="left"] {
135
+ --md-enter: -100% 0;
136
+ margin: 0 auto 0 0;
137
+ border-inline-start: 0;
138
+ }
139
+ .md-root[data-placement="right"] {
140
+ --md-enter: 100% 0;
141
+ margin: 0 0 0 auto;
142
+ border-inline-end: 0;
143
+ }
144
+ .md-root[data-placement="bottom"] {
145
+ --md-enter: 0 100%;
146
+ width: 100vw;
147
+ max-height: min(var(--md-width), 90dvh);
148
+ margin: auto 0 0;
149
+ border-bottom: 0;
150
+ border-radius: var(--md-radius) var(--md-radius) 0 0;
151
+ }
152
+
153
+ /* -------------------------------------------------------------- layout */
154
+
155
+ .md-header {
156
+ display: flex;
157
+ flex: none;
158
+ align-items: flex-start;
159
+ gap: 12px;
160
+ padding: 16px 12px 12px var(--md-px);
161
+ }
162
+ .md-heading {
163
+ flex: 1 1 auto;
164
+ min-width: 0;
165
+ }
166
+ .md-title {
167
+ margin: 0;
168
+ font-size: 1.15em;
169
+ font-weight: 600;
170
+ line-height: 1.35;
171
+ }
172
+ .md-description {
173
+ margin: 4px 0 0;
174
+ color: var(--md-muted);
175
+ }
176
+ .md-close {
177
+ display: inline-grid;
178
+ flex: none;
179
+ place-items: center;
180
+ width: 30px;
181
+ height: 30px;
182
+ margin-top: -4px;
183
+ padding: 0;
184
+ border: 0;
185
+ border-radius: 6px;
186
+ background: transparent;
187
+ color: var(--md-muted);
188
+ cursor: pointer;
189
+ }
190
+ .md-close:hover {
191
+ background: var(--md-hover);
192
+ color: var(--md-fg);
193
+ }
194
+ .md-close:focus-visible {
195
+ outline: 2px solid var(--md-focus);
196
+ outline-offset: 1px;
197
+ }
198
+
199
+ .md-body {
200
+ flex: 1 1 auto;
201
+ min-height: 0;
202
+ padding: 0 var(--md-px) 20px;
203
+ overflow: auto;
204
+ overscroll-behavior: contain;
205
+ }
206
+ .md-body:first-child {
207
+ padding-top: 20px;
208
+ }
209
+
210
+ .md-footer {
211
+ display: flex;
212
+ flex: none;
213
+ flex-wrap: wrap;
214
+ align-items: center;
215
+ justify-content: flex-end;
216
+ gap: 8px;
217
+ padding: 12px var(--md-px);
218
+ border-top: 1px solid var(--md-border);
219
+ background: var(--md-footer-bg);
220
+ }
221
+
222
+ @media (prefers-reduced-motion: reduce) {
223
+ .md-root,
224
+ .md-root::backdrop {
225
+ --md-enter: 0 0;
226
+ transition-duration: 0s;
227
+ }
228
+ }
229
+
230
+ @media (forced-colors: active) {
231
+ .md-root {
232
+ border: 1px solid CanvasText;
233
+ }
234
+ }
235
+ }
@@ -0,0 +1,39 @@
1
+ # Textarea
2
+
3
+ A multi-line text field for React 19 with label, hint, error, character counter
4
+ and auto-resize, styled to match the rest of the library. No runtime
5
+ dependencies besides React.
6
+
7
+ ## Setup
8
+
9
+ ```ts
10
+ import { Textarea } from "@/components/textarea";
11
+ import "@/components/textarea/styles.css";
12
+ ```
13
+
14
+ ## Usage
15
+
16
+ ```tsx
17
+ <Textarea
18
+ label="Notes"
19
+ value={notes}
20
+ onValueChange={setNotes}
21
+ autoResize
22
+ minRows={2}
23
+ maxRows={8}
24
+ maxLength={500}
25
+ showCount
26
+ />
27
+ ```
28
+
29
+ ## Props
30
+
31
+ All `<textarea>` attributes plus `value`, `defaultValue`, `onValueChange`,
32
+ `label`, `description`, `error`, `size` (`sm` `md` `lg`), `autoResize`, `minRows`,
33
+ `maxRows`, `resize` (`none` `vertical` `horizontal` `both`), `showCount`,
34
+ `classNames` (`root` `label` `textarea` `description` `error` `count`),
35
+ `localeText`, and `ref` (the `<textarea>`).
36
+
37
+ Auto-resize uses CSS `field-sizing: content` where supported and measures the
38
+ text elsewhere. `countCharacters` and `fitHeight` are exported from the
39
+ framework-free `core`.
@@ -0,0 +1,38 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { countCharacters } from "../core/count";
3
+ import { fitHeight, heightForRows } from "../core/size";
4
+
5
+ const metrics = { lineHeight: 20, paddingBlock: 16, borderBlock: 2 };
6
+
7
+ describe("character count", () => {
8
+ it("counts what a person sees", () => {
9
+ expect(countCharacters("")).toBe(0);
10
+ expect(countCharacters("line one\nline two")).toBe(17);
11
+ expect(countCharacters("👍🏽👍🏽")).toBe(2);
12
+ });
13
+ });
14
+
15
+ describe("auto resize", () => {
16
+ it("measures rows as border-box height", () => {
17
+ expect(heightForRows(1, metrics)).toBe(38);
18
+ expect(heightForRows(3, metrics)).toBe(78);
19
+ });
20
+
21
+ it("never shrinks below minRows", () => {
22
+ // One line of content: scrollHeight = 20 + 16 padding.
23
+ expect(fitHeight(36, metrics, 3)).toEqual({ height: 78, overflow: false });
24
+ });
25
+
26
+ it("grows with the content", () => {
27
+ expect(fitHeight(136, metrics, 3)).toEqual({ height: 138, overflow: false });
28
+ });
29
+
30
+ it("stops at maxRows and reports that it should scroll", () => {
31
+ expect(fitHeight(416, metrics, 3, 5)).toEqual({ height: 118, overflow: true });
32
+ expect(fitHeight(96, metrics, 3, 5)).toEqual({ height: 98, overflow: false });
33
+ });
34
+
35
+ it("keeps maxRows from undercutting minRows", () => {
36
+ expect(fitHeight(36, metrics, 4, 2).height).toBe(98);
37
+ });
38
+ });
@@ -0,0 +1,14 @@
1
+ let segmenter: Intl.Segmenter | null | undefined;
2
+
3
+ /**
4
+ * Characters as a person counts them. `"👍🏽".length` is 4 and `"é"` can be 2,
5
+ * but each is one character here. Falls back to code points where
6
+ * `Intl.Segmenter` is missing.
7
+ */
8
+ export function countCharacters(value: string): number {
9
+ if (!value) return 0;
10
+ if (segmenter === undefined) {
11
+ segmenter = typeof Intl !== "undefined" && "Segmenter" in Intl ? new Intl.Segmenter() : null;
12
+ }
13
+ return segmenter ? Array.from(segmenter.segment(value)).length : Array.from(value).length;
14
+ }
@@ -0,0 +1,4 @@
1
+ // Framework-free: character counting and the auto-resize math.
2
+ export { countCharacters } from "./count";
3
+ export { fitHeight, heightForRows } from "./size";
4
+ export type * from "./types";
@@ -0,0 +1,23 @@
1
+ import type { BoxMetrics } from "./types";
2
+
3
+ /** Border-box height that shows exactly `rows` lines of text. */
4
+ export function heightForRows(rows: number, metrics: BoxMetrics): number {
5
+ return rows * metrics.lineHeight + metrics.paddingBlock + metrics.borderBlock;
6
+ }
7
+
8
+ /**
9
+ * The height that fits the content between `minRows` and `maxRows`, and whether
10
+ * the content is taller than that (so the textarea should scroll).
11
+ * `scrollHeight` is the element's, which includes padding but not borders.
12
+ */
13
+ export function fitHeight(
14
+ scrollHeight: number,
15
+ metrics: BoxMetrics,
16
+ minRows: number,
17
+ maxRows?: number,
18
+ ): { height: number; overflow: boolean } {
19
+ const content = scrollHeight + metrics.borderBlock;
20
+ const min = heightForRows(Math.max(1, minRows), metrics);
21
+ const max = maxRows ? Math.max(min, heightForRows(maxRows, metrics)) : Infinity;
22
+ return { height: Math.min(Math.max(content, min), max), overflow: content > max };
23
+ }
@@ -0,0 +1,17 @@
1
+ export type FieldSize = "sm" | "md" | "lg";
2
+
3
+ export type TextareaResize = "none" | "vertical" | "horizontal" | "both";
4
+
5
+ /** Vertical measurements of a textarea, in pixels. */
6
+ export interface BoxMetrics {
7
+ lineHeight: number;
8
+ /** Top plus bottom padding. */
9
+ paddingBlock: number;
10
+ /** Top plus bottom border. */
11
+ borderBlock: number;
12
+ }
13
+
14
+ export interface TextareaLocaleText {
15
+ /** `max` is set when the field has a `maxLength`. */
16
+ characterCount(count: string, max?: string): string;
17
+ }
@@ -0,0 +1,5 @@
1
+ export { Textarea } from "./react/Textarea";
2
+ export type { TextareaProps } from "./react/Textarea";
3
+ export { defaultTextareaText } from "./react/locale";
4
+ export type { TextareaSlot } from "./react/props";
5
+ export * from "./core";