gbs-add-block 1.2.13 → 1.2.15
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 +4 -12
- package/index.cjs +9 -0
- package/package.json +1 -1
- package/source/beta-components/breadcrumb/README.md +34 -0
- package/source/beta-components/breadcrumb/__tests__/core.test.ts +58 -0
- package/source/beta-components/breadcrumb/core/index.ts +4 -0
- package/source/beta-components/breadcrumb/core/trail.ts +42 -0
- package/source/beta-components/breadcrumb/core/types.ts +20 -0
- package/source/beta-components/breadcrumb/index.ts +5 -0
- package/source/beta-components/breadcrumb/react/Breadcrumb.tsx +177 -0
- package/source/beta-components/breadcrumb/react/locale.ts +6 -0
- package/source/beta-components/breadcrumb/react/props.ts +14 -0
- package/source/beta-components/breadcrumb/styles.css +139 -0
- package/source/beta-components/button/README.md +32 -0
- package/source/beta-components/button/__tests__/core.test.ts +31 -0
- package/source/beta-components/button/core/index.ts +3 -0
- package/source/beta-components/button/core/state.ts +16 -0
- package/source/beta-components/button/core/types.ts +28 -0
- package/source/beta-components/button/index.ts +5 -0
- package/source/beta-components/button/react/Button.tsx +173 -0
- package/source/beta-components/button/react/locale.ts +5 -0
- package/source/beta-components/button/react/props.ts +23 -0
- package/source/beta-components/button/styles.css +233 -0
- package/source/beta-components/checkbox/README.md +36 -0
- package/source/beta-components/checkbox/__tests__/core.test.ts +40 -0
- package/source/beta-components/checkbox/core/group.ts +30 -0
- package/source/beta-components/checkbox/core/index.ts +3 -0
- package/source/beta-components/checkbox/core/types.ts +17 -0
- package/source/beta-components/checkbox/index.ts +9 -0
- package/source/beta-components/checkbox/react/Checkbox.tsx +146 -0
- package/source/beta-components/checkbox/react/CheckboxGroup.tsx +149 -0
- package/source/beta-components/checkbox/react/context.ts +16 -0
- package/source/beta-components/checkbox/react/locale.ts +5 -0
- package/source/beta-components/checkbox/react/props.ts +6 -0
- package/source/beta-components/checkbox/styles.css +212 -0
- package/source/beta-components/dialog/README.md +39 -0
- package/source/beta-components/dialog/__tests__/core.test.ts +86 -0
- package/source/beta-components/dialog/core/api.ts +34 -0
- package/source/beta-components/dialog/core/dialog.ts +11 -0
- package/source/beta-components/dialog/core/index.ts +7 -0
- package/source/beta-components/dialog/core/store.ts +81 -0
- package/source/beta-components/dialog/core/types.ts +59 -0
- package/source/beta-components/dialog/index.ts +7 -0
- package/source/beta-components/dialog/react/Dialog.tsx +279 -0
- package/source/beta-components/dialog/react/DialogHost.tsx +45 -0
- package/source/beta-components/dialog/react/icons.tsx +51 -0
- package/source/beta-components/dialog/react/locale.ts +8 -0
- package/source/beta-components/dialog/react/props.ts +13 -0
- package/source/beta-components/dialog/styles.css +278 -0
- package/source/beta-components/input/README.md +44 -0
- package/source/beta-components/input/__tests__/core.test.ts +75 -0
- package/source/beta-components/input/core/count.ts +14 -0
- package/source/beta-components/input/core/index.ts +11 -0
- package/source/beta-components/input/core/otp.ts +64 -0
- package/source/beta-components/input/core/types.ts +14 -0
- package/source/beta-components/input/index.ts +8 -0
- package/source/beta-components/input/react/Input.tsx +219 -0
- package/source/beta-components/input/react/OtpInput.tsx +256 -0
- package/source/beta-components/input/react/dom.ts +10 -0
- package/source/beta-components/input/react/icons.tsx +35 -0
- package/source/beta-components/input/react/locale.ts +9 -0
- package/source/beta-components/input/react/props.ts +6 -0
- package/source/beta-components/input/styles.css +296 -0
- package/source/beta-components/modal/README.md +51 -0
- package/source/beta-components/modal/__tests__/core.test.ts +55 -0
- package/source/beta-components/modal/core/dismiss.ts +29 -0
- package/source/beta-components/modal/core/index.ts +3 -0
- package/source/beta-components/modal/core/types.ts +27 -0
- package/source/beta-components/modal/index.ts +5 -0
- package/source/beta-components/modal/react/Modal.tsx +238 -0
- package/source/beta-components/modal/react/icons.tsx +19 -0
- package/source/beta-components/modal/react/locale.ts +5 -0
- package/source/beta-components/modal/react/props.ts +17 -0
- package/source/beta-components/modal/styles.css +235 -0
- package/source/beta-components/spinner/README.md +29 -0
- package/source/beta-components/spinner/__tests__/core.test.ts +48 -0
- package/source/beta-components/spinner/core/index.ts +3 -0
- package/source/beta-components/spinner/core/types.ts +28 -0
- package/source/beta-components/spinner/core/visibility.ts +34 -0
- package/source/beta-components/spinner/index.ts +6 -0
- package/source/beta-components/spinner/react/Spinner.tsx +96 -0
- package/source/beta-components/spinner/react/locale.ts +5 -0
- package/source/beta-components/spinner/react/props.ts +4 -0
- package/source/beta-components/spinner/react/useDelayedLoading.ts +39 -0
- package/source/beta-components/spinner/styles.css +167 -0
- package/source/beta-components/tabs/README.md +32 -0
- package/source/beta-components/tabs/__tests__/core.test.ts +51 -0
- package/source/beta-components/tabs/core/index.ts +3 -0
- package/source/beta-components/tabs/core/navigation.ts +54 -0
- package/source/beta-components/tabs/core/types.ts +16 -0
- package/source/beta-components/tabs/index.ts +5 -0
- package/source/beta-components/tabs/react/Tabs.tsx +260 -0
- package/source/beta-components/tabs/react/context.ts +21 -0
- package/source/beta-components/tabs/styles.css +257 -0
- package/source/beta-components/textarea/README.md +39 -0
- package/source/beta-components/textarea/__tests__/core.test.ts +38 -0
- package/source/beta-components/textarea/core/count.ts +14 -0
- package/source/beta-components/textarea/core/index.ts +4 -0
- package/source/beta-components/textarea/core/size.ts +23 -0
- package/source/beta-components/textarea/core/types.ts +17 -0
- package/source/beta-components/textarea/index.ts +5 -0
- package/source/beta-components/textarea/react/Textarea.tsx +209 -0
- package/source/beta-components/textarea/react/locale.ts +5 -0
- package/source/beta-components/textarea/react/props.ts +4 -0
- package/source/beta-components/textarea/styles.css +159 -0
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
Activity,
|
|
5
|
+
useCallback,
|
|
6
|
+
useId,
|
|
7
|
+
useLayoutEffect,
|
|
8
|
+
useMemo,
|
|
9
|
+
useRef,
|
|
10
|
+
useState,
|
|
11
|
+
type ButtonHTMLAttributes,
|
|
12
|
+
type HTMLAttributes,
|
|
13
|
+
type KeyboardEvent,
|
|
14
|
+
type ReactNode,
|
|
15
|
+
} from "react";
|
|
16
|
+
import { nextTab, tabDomId } from "../core/navigation";
|
|
17
|
+
import type { TabsActivation, TabsOrientation, TabsSize, TabsVariant } from "../core/types";
|
|
18
|
+
import { TabsContext, useTabsContext } from "./context";
|
|
19
|
+
|
|
20
|
+
const cx = (...names: (string | false | null | undefined)[]) => names.filter(Boolean).join(" ");
|
|
21
|
+
|
|
22
|
+
/* ------------------------------------------------------------------- Tabs */
|
|
23
|
+
|
|
24
|
+
export interface TabsProps extends Omit<HTMLAttributes<HTMLDivElement>, "defaultValue" | "onChange"> {
|
|
25
|
+
/** Selected tab (controlled). */
|
|
26
|
+
value?: string;
|
|
27
|
+
/** Selected tab at first. Without either, the first enabled tab is selected. */
|
|
28
|
+
defaultValue?: string;
|
|
29
|
+
onValueChange?(value: string): void;
|
|
30
|
+
/** Default `horizontal`. */
|
|
31
|
+
orientation?: TabsOrientation;
|
|
32
|
+
/** Default `automatic`: arrow keys select as they move. */
|
|
33
|
+
activation?: TabsActivation;
|
|
34
|
+
/** Default `line`. */
|
|
35
|
+
variant?: TabsVariant;
|
|
36
|
+
/** Default `md`. */
|
|
37
|
+
size?: TabsSize;
|
|
38
|
+
/**
|
|
39
|
+
* Keep hidden panels mounted, preserving their state (form input, scroll) at
|
|
40
|
+
* lower rendering priority through React's `<Activity>`. Default false.
|
|
41
|
+
*/
|
|
42
|
+
keepMounted?: boolean;
|
|
43
|
+
children?: ReactNode;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Tabs following the WAI-ARIA pattern. Compose with `TabList`, `Tab` and `TabPanel`. */
|
|
47
|
+
export function Tabs(props: TabsProps) {
|
|
48
|
+
const {
|
|
49
|
+
value: valueProp,
|
|
50
|
+
defaultValue,
|
|
51
|
+
onValueChange,
|
|
52
|
+
orientation = "horizontal",
|
|
53
|
+
activation = "automatic",
|
|
54
|
+
variant = "line",
|
|
55
|
+
size = "md",
|
|
56
|
+
keepMounted = false,
|
|
57
|
+
className,
|
|
58
|
+
children,
|
|
59
|
+
...rest
|
|
60
|
+
} = props;
|
|
61
|
+
|
|
62
|
+
const id = useId();
|
|
63
|
+
const [internal, setInternal] = useState(defaultValue);
|
|
64
|
+
const value = valueProp ?? internal;
|
|
65
|
+
|
|
66
|
+
const select = useCallback(
|
|
67
|
+
(next: string) => {
|
|
68
|
+
if (valueProp === undefined) setInternal(next);
|
|
69
|
+
if (next !== value) onValueChange?.(next);
|
|
70
|
+
},
|
|
71
|
+
[valueProp, value, onValueChange],
|
|
72
|
+
);
|
|
73
|
+
|
|
74
|
+
const context = useMemo(
|
|
75
|
+
() => ({ id, value, select, orientation, activation, keepMounted }),
|
|
76
|
+
[id, value, select, orientation, activation, keepMounted],
|
|
77
|
+
);
|
|
78
|
+
|
|
79
|
+
return (
|
|
80
|
+
<TabsContext value={context}>
|
|
81
|
+
<div
|
|
82
|
+
{...rest}
|
|
83
|
+
className={cx("tb-root", className)}
|
|
84
|
+
data-orientation={orientation}
|
|
85
|
+
data-variant={variant}
|
|
86
|
+
data-size={size}
|
|
87
|
+
>
|
|
88
|
+
{children}
|
|
89
|
+
</div>
|
|
90
|
+
</TabsContext>
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/* ---------------------------------------------------------------- TabList */
|
|
95
|
+
|
|
96
|
+
export type TabListProps = HTMLAttributes<HTMLDivElement>;
|
|
97
|
+
|
|
98
|
+
export function TabList(props: TabListProps) {
|
|
99
|
+
const { className, children, onKeyDown, ...rest } = props;
|
|
100
|
+
const context = useTabsContext();
|
|
101
|
+
const listRef = useRef<HTMLDivElement>(null);
|
|
102
|
+
|
|
103
|
+
/** Tabs belonging to this list, in document order (not nested tab lists). */
|
|
104
|
+
const getTabs = useCallback(() => {
|
|
105
|
+
const list = listRef.current;
|
|
106
|
+
if (!list) return [];
|
|
107
|
+
return Array.from(list.querySelectorAll<HTMLElement>('[role="tab"]')).filter(
|
|
108
|
+
(tab) => tab.closest('[role="tablist"]') === list,
|
|
109
|
+
);
|
|
110
|
+
}, []);
|
|
111
|
+
|
|
112
|
+
// Without a matching value, select the first enabled tab so the list stays reachable by Tab.
|
|
113
|
+
useLayoutEffect(() => {
|
|
114
|
+
const tabs = getTabs();
|
|
115
|
+
if (tabs.some((tab) => tab.dataset.value === context.value)) return;
|
|
116
|
+
const first = tabs.find((tab) => tab.getAttribute("aria-disabled") !== "true");
|
|
117
|
+
if (first?.dataset.value !== undefined) context.select(first.dataset.value);
|
|
118
|
+
}, [context, getTabs]);
|
|
119
|
+
|
|
120
|
+
// Slide the indicator under the selected tab, and follow size changes.
|
|
121
|
+
useLayoutEffect(() => {
|
|
122
|
+
const list = listRef.current;
|
|
123
|
+
if (!list) return;
|
|
124
|
+
const horizontal = context.orientation === "horizontal";
|
|
125
|
+
const place = () => {
|
|
126
|
+
const active = getTabs().find((tab) => tab.getAttribute("aria-selected") === "true");
|
|
127
|
+
if (!active) {
|
|
128
|
+
list.style.removeProperty("--tb-indicator-size");
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
list.style.setProperty("--tb-indicator-start", `${horizontal ? active.offsetLeft : active.offsetTop}px`);
|
|
132
|
+
list.style.setProperty("--tb-indicator-size", `${horizontal ? active.offsetWidth : active.offsetHeight}px`);
|
|
133
|
+
};
|
|
134
|
+
place();
|
|
135
|
+
const observer = new ResizeObserver(place);
|
|
136
|
+
observer.observe(list);
|
|
137
|
+
for (const tab of getTabs()) observer.observe(tab);
|
|
138
|
+
return () => observer.disconnect();
|
|
139
|
+
}, [context.value, context.orientation, getTabs]);
|
|
140
|
+
|
|
141
|
+
const handleKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
|
|
142
|
+
onKeyDown?.(event);
|
|
143
|
+
if (event.defaultPrevented) return;
|
|
144
|
+
const tabs = getTabs();
|
|
145
|
+
const current = tabs.indexOf(document.activeElement as HTMLElement);
|
|
146
|
+
if (current === -1) return;
|
|
147
|
+
|
|
148
|
+
if (context.activation === "manual" && (event.key === "Enter" || event.key === " ")) {
|
|
149
|
+
event.preventDefault();
|
|
150
|
+
const value = tabs[current].dataset.value;
|
|
151
|
+
if (value !== undefined && tabs[current].getAttribute("aria-disabled") !== "true") context.select(value);
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const next = nextTab(
|
|
156
|
+
tabs.map((tab) => ({ disabled: tab.getAttribute("aria-disabled") === "true" })),
|
|
157
|
+
current,
|
|
158
|
+
event.key,
|
|
159
|
+
{
|
|
160
|
+
orientation: context.orientation,
|
|
161
|
+
rtl: getComputedStyle(event.currentTarget).direction === "rtl",
|
|
162
|
+
},
|
|
163
|
+
);
|
|
164
|
+
if (next === null) return;
|
|
165
|
+
event.preventDefault();
|
|
166
|
+
const target = tabs[next];
|
|
167
|
+
target.focus();
|
|
168
|
+
if (context.activation === "automatic" && target.dataset.value !== undefined) {
|
|
169
|
+
context.select(target.dataset.value);
|
|
170
|
+
}
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
return (
|
|
174
|
+
<div
|
|
175
|
+
{...rest}
|
|
176
|
+
ref={listRef}
|
|
177
|
+
role="tablist"
|
|
178
|
+
aria-orientation={context.orientation}
|
|
179
|
+
className={cx("tb-list", className)}
|
|
180
|
+
onKeyDown={handleKeyDown}
|
|
181
|
+
>
|
|
182
|
+
{children}
|
|
183
|
+
<span className="tb-indicator" aria-hidden="true" />
|
|
184
|
+
</div>
|
|
185
|
+
);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/* -------------------------------------------------------------------- Tab */
|
|
189
|
+
|
|
190
|
+
export interface TabProps extends Omit<ButtonHTMLAttributes<HTMLButtonElement>, "value" | "type"> {
|
|
191
|
+
/** Identifies the tab and its panel. */
|
|
192
|
+
value: string;
|
|
193
|
+
icon?: ReactNode;
|
|
194
|
+
/** A small count or label after the text, e.g. unread items. */
|
|
195
|
+
badge?: ReactNode;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
export function Tab(props: TabProps) {
|
|
199
|
+
const { value, icon, badge, disabled = false, className, children, onClick, ...rest } = props;
|
|
200
|
+
const context = useTabsContext();
|
|
201
|
+
const selected = context.value === value;
|
|
202
|
+
|
|
203
|
+
return (
|
|
204
|
+
<button
|
|
205
|
+
{...rest}
|
|
206
|
+
type="button"
|
|
207
|
+
role="tab"
|
|
208
|
+
id={tabDomId(context.id, "tab", value)}
|
|
209
|
+
aria-controls={tabDomId(context.id, "panel", value)}
|
|
210
|
+
aria-selected={selected}
|
|
211
|
+
// aria-disabled keeps the tab discoverable; arrow keys skip it.
|
|
212
|
+
aria-disabled={disabled || undefined}
|
|
213
|
+
tabIndex={selected ? 0 : -1}
|
|
214
|
+
data-value={value}
|
|
215
|
+
className={cx("tb-tab", className)}
|
|
216
|
+
onClick={(event) => {
|
|
217
|
+
onClick?.(event);
|
|
218
|
+
if (!disabled && !event.defaultPrevented) context.select(value);
|
|
219
|
+
}}
|
|
220
|
+
>
|
|
221
|
+
{icon && (
|
|
222
|
+
<span className="tb-icon" aria-hidden="true">
|
|
223
|
+
{icon}
|
|
224
|
+
</span>
|
|
225
|
+
)}
|
|
226
|
+
<span className="tb-label">{children}</span>
|
|
227
|
+
{badge !== undefined && badge !== null && <span className="tb-badge">{badge}</span>}
|
|
228
|
+
</button>
|
|
229
|
+
);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/* --------------------------------------------------------------- TabPanel */
|
|
233
|
+
|
|
234
|
+
export interface TabPanelProps extends HTMLAttributes<HTMLDivElement> {
|
|
235
|
+
value: string;
|
|
236
|
+
/** Overrides `keepMounted` on `Tabs` for this panel. */
|
|
237
|
+
keepMounted?: boolean;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
export function TabPanel(props: TabPanelProps) {
|
|
241
|
+
const { value, keepMounted, className, children, ...rest } = props;
|
|
242
|
+
const context = useTabsContext();
|
|
243
|
+
const selected = context.value === value;
|
|
244
|
+
const keep = keepMounted ?? context.keepMounted;
|
|
245
|
+
|
|
246
|
+
return (
|
|
247
|
+
<div
|
|
248
|
+
{...rest}
|
|
249
|
+
role="tabpanel"
|
|
250
|
+
id={tabDomId(context.id, "panel", value)}
|
|
251
|
+
aria-labelledby={tabDomId(context.id, "tab", value)}
|
|
252
|
+
// Focusable so keyboard users reach panels without focusable content.
|
|
253
|
+
tabIndex={0}
|
|
254
|
+
hidden={!selected}
|
|
255
|
+
className={cx("tb-panel", className)}
|
|
256
|
+
>
|
|
257
|
+
{keep ? <Activity mode={selected ? "visible" : "hidden"}>{children}</Activity> : selected ? children : null}
|
|
258
|
+
</div>
|
|
259
|
+
);
|
|
260
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { createContext, use } from "react";
|
|
4
|
+
import type { TabsActivation, TabsOrientation } from "../core/types";
|
|
5
|
+
|
|
6
|
+
export interface TabsContextValue {
|
|
7
|
+
id: string;
|
|
8
|
+
value: string | undefined;
|
|
9
|
+
select(value: string): void;
|
|
10
|
+
orientation: TabsOrientation;
|
|
11
|
+
activation: TabsActivation;
|
|
12
|
+
keepMounted: boolean;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export const TabsContext = createContext<TabsContextValue | null>(null);
|
|
16
|
+
|
|
17
|
+
export function useTabsContext(): TabsContextValue {
|
|
18
|
+
const context = use(TabsContext);
|
|
19
|
+
if (!context) throw new Error("TabList, Tab and TabPanel must be used inside <Tabs>.");
|
|
20
|
+
return context;
|
|
21
|
+
}
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Tabs 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` override them.
|
|
8
|
+
*/
|
|
9
|
+
@layer theme, base, components, utilities;
|
|
10
|
+
|
|
11
|
+
@layer components {
|
|
12
|
+
.tb-root {
|
|
13
|
+
--tb-bg: var(--dg-bg, light-dark(#ffffff, #0b0b0e));
|
|
14
|
+
--tb-fg: var(--dg-fg, light-dark(#18181b, #f4f4f5));
|
|
15
|
+
--tb-muted: var(--dg-muted, light-dark(#71717a, #a1a1aa));
|
|
16
|
+
--tb-border: var(--dg-border, light-dark(#e4e4e7, #27272a));
|
|
17
|
+
--tb-hover: var(--dg-hover, light-dark(#f4f4f5, #1f1f23));
|
|
18
|
+
--tb-track: light-dark(#f4f4f5, #18181b);
|
|
19
|
+
--tb-accent: var(--dg-accent, light-dark(#2563eb, #60a5fa));
|
|
20
|
+
--tb-accent-soft: var(--dg-accent-soft, light-dark(#eff6ff, #172554));
|
|
21
|
+
--tb-focus: var(--dg-focus, light-dark(#2563eb, #60a5fa));
|
|
22
|
+
--tb-radius: var(--dg-radius, 8px);
|
|
23
|
+
--tb-font-size: var(--dg-font-size, 13px);
|
|
24
|
+
--tb-height: 36px;
|
|
25
|
+
--tb-px: 12px;
|
|
26
|
+
--tb-duration: 200ms;
|
|
27
|
+
|
|
28
|
+
color-scheme: inherit;
|
|
29
|
+
display: flex;
|
|
30
|
+
flex-direction: column;
|
|
31
|
+
gap: 12px;
|
|
32
|
+
min-width: 0;
|
|
33
|
+
font-size: var(--tb-font-size);
|
|
34
|
+
line-height: 1.4;
|
|
35
|
+
color: var(--tb-fg);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
:where(.dark, [data-theme="dark"]) .tb-root {
|
|
39
|
+
color-scheme: dark;
|
|
40
|
+
}
|
|
41
|
+
:where(.light, [data-theme="light"]) .tb-root {
|
|
42
|
+
color-scheme: light;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
:where(.tb-root) *,
|
|
46
|
+
:where(.tb-root) *::before,
|
|
47
|
+
:where(.tb-root) *::after {
|
|
48
|
+
box-sizing: border-box;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
.tb-root[data-size="sm"] {
|
|
52
|
+
--tb-height: 30px;
|
|
53
|
+
--tb-px: 10px;
|
|
54
|
+
--tb-font-size: 12px;
|
|
55
|
+
}
|
|
56
|
+
.tb-root[data-size="lg"] {
|
|
57
|
+
--tb-height: 44px;
|
|
58
|
+
--tb-px: 16px;
|
|
59
|
+
--tb-font-size: 14px;
|
|
60
|
+
}
|
|
61
|
+
.tb-root[data-orientation="vertical"] {
|
|
62
|
+
flex-direction: row;
|
|
63
|
+
gap: 20px;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/* ---------------------------------------------------------------- list */
|
|
67
|
+
|
|
68
|
+
.tb-list {
|
|
69
|
+
position: relative;
|
|
70
|
+
display: flex;
|
|
71
|
+
flex: none;
|
|
72
|
+
gap: 2px;
|
|
73
|
+
overflow-x: auto;
|
|
74
|
+
scrollbar-width: none;
|
|
75
|
+
}
|
|
76
|
+
.tb-root[data-orientation="vertical"] .tb-list {
|
|
77
|
+
flex-direction: column;
|
|
78
|
+
overflow: visible;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
.tb-tab {
|
|
82
|
+
position: relative;
|
|
83
|
+
z-index: 1;
|
|
84
|
+
display: inline-flex;
|
|
85
|
+
flex: none;
|
|
86
|
+
align-items: center;
|
|
87
|
+
justify-content: center;
|
|
88
|
+
gap: 6px;
|
|
89
|
+
height: var(--tb-height);
|
|
90
|
+
padding: 0 var(--tb-px);
|
|
91
|
+
border: 0;
|
|
92
|
+
border-radius: calc(var(--tb-radius) - 2px);
|
|
93
|
+
background: transparent;
|
|
94
|
+
color: var(--tb-muted);
|
|
95
|
+
font: inherit;
|
|
96
|
+
font-weight: 500;
|
|
97
|
+
white-space: nowrap;
|
|
98
|
+
cursor: pointer;
|
|
99
|
+
transition: color 120ms ease;
|
|
100
|
+
}
|
|
101
|
+
.tb-root[data-orientation="vertical"] .tb-tab {
|
|
102
|
+
justify-content: flex-start;
|
|
103
|
+
}
|
|
104
|
+
.tb-tab:hover:not([aria-disabled="true"]),
|
|
105
|
+
.tb-tab[aria-selected="true"] {
|
|
106
|
+
color: var(--tb-fg);
|
|
107
|
+
}
|
|
108
|
+
.tb-tab:focus-visible {
|
|
109
|
+
outline: 2px solid var(--tb-focus);
|
|
110
|
+
outline-offset: -2px;
|
|
111
|
+
}
|
|
112
|
+
.tb-tab[aria-disabled="true"] {
|
|
113
|
+
opacity: 0.45;
|
|
114
|
+
cursor: not-allowed;
|
|
115
|
+
}
|
|
116
|
+
.tb-icon {
|
|
117
|
+
display: inline-flex;
|
|
118
|
+
}
|
|
119
|
+
.tb-icon > svg {
|
|
120
|
+
width: 1.1em;
|
|
121
|
+
height: 1.1em;
|
|
122
|
+
}
|
|
123
|
+
.tb-badge {
|
|
124
|
+
min-width: 18px;
|
|
125
|
+
padding: 1px 6px;
|
|
126
|
+
border-radius: 999px;
|
|
127
|
+
background: var(--tb-hover);
|
|
128
|
+
color: var(--tb-muted);
|
|
129
|
+
font-size: 0.85em;
|
|
130
|
+
font-variant-numeric: tabular-nums;
|
|
131
|
+
line-height: 1.4;
|
|
132
|
+
text-align: center;
|
|
133
|
+
}
|
|
134
|
+
.tb-tab[aria-selected="true"] .tb-badge {
|
|
135
|
+
background: var(--tb-accent-soft);
|
|
136
|
+
color: var(--tb-accent);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/* The indicator is measured in JS; it slides between tabs. */
|
|
140
|
+
.tb-indicator {
|
|
141
|
+
position: absolute;
|
|
142
|
+
z-index: 0;
|
|
143
|
+
pointer-events: none;
|
|
144
|
+
opacity: 0;
|
|
145
|
+
transition:
|
|
146
|
+
left var(--tb-duration) ease,
|
|
147
|
+
top var(--tb-duration) ease,
|
|
148
|
+
width var(--tb-duration) ease,
|
|
149
|
+
height var(--tb-duration) ease;
|
|
150
|
+
}
|
|
151
|
+
.tb-list[style*="--tb-indicator-size"] .tb-indicator {
|
|
152
|
+
opacity: 1;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/* ---------------------------------------------------------------- line */
|
|
156
|
+
|
|
157
|
+
.tb-root[data-variant="line"] .tb-list {
|
|
158
|
+
border-bottom: 1px solid var(--tb-border);
|
|
159
|
+
}
|
|
160
|
+
.tb-root[data-variant="line"] .tb-indicator {
|
|
161
|
+
bottom: -1px;
|
|
162
|
+
left: var(--tb-indicator-start);
|
|
163
|
+
width: var(--tb-indicator-size);
|
|
164
|
+
height: 2px;
|
|
165
|
+
border-radius: 2px;
|
|
166
|
+
background: var(--tb-accent);
|
|
167
|
+
}
|
|
168
|
+
.tb-root[data-variant="line"][data-orientation="vertical"] .tb-list {
|
|
169
|
+
border-bottom: 0;
|
|
170
|
+
border-inline-end: 1px solid var(--tb-border);
|
|
171
|
+
}
|
|
172
|
+
.tb-root[data-variant="line"][data-orientation="vertical"] .tb-indicator {
|
|
173
|
+
top: var(--tb-indicator-start);
|
|
174
|
+
bottom: auto;
|
|
175
|
+
left: auto;
|
|
176
|
+
inset-inline-end: -1px;
|
|
177
|
+
width: 2px;
|
|
178
|
+
height: var(--tb-indicator-size);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/* --------------------------------------------------------------- pills */
|
|
182
|
+
|
|
183
|
+
.tb-root[data-variant="pills"] .tb-indicator {
|
|
184
|
+
top: 0;
|
|
185
|
+
left: var(--tb-indicator-start);
|
|
186
|
+
width: var(--tb-indicator-size);
|
|
187
|
+
height: 100%;
|
|
188
|
+
border-radius: calc(var(--tb-radius) - 2px);
|
|
189
|
+
background: var(--tb-accent-soft);
|
|
190
|
+
}
|
|
191
|
+
.tb-root[data-variant="pills"] .tb-tab[aria-selected="true"] {
|
|
192
|
+
color: var(--tb-accent);
|
|
193
|
+
}
|
|
194
|
+
.tb-root[data-variant="pills"][data-orientation="vertical"] .tb-indicator {
|
|
195
|
+
top: var(--tb-indicator-start);
|
|
196
|
+
left: 0;
|
|
197
|
+
width: 100%;
|
|
198
|
+
height: var(--tb-indicator-size);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/* ------------------------------------------------------------ enclosed */
|
|
202
|
+
|
|
203
|
+
.tb-root[data-variant="enclosed"] .tb-list {
|
|
204
|
+
padding: 3px;
|
|
205
|
+
border-radius: var(--tb-radius);
|
|
206
|
+
background: var(--tb-track);
|
|
207
|
+
}
|
|
208
|
+
.tb-root[data-variant="enclosed"] .tb-indicator {
|
|
209
|
+
top: 3px;
|
|
210
|
+
bottom: 3px;
|
|
211
|
+
left: var(--tb-indicator-start);
|
|
212
|
+
width: var(--tb-indicator-size);
|
|
213
|
+
border-radius: calc(var(--tb-radius) - 2px);
|
|
214
|
+
background: var(--tb-bg);
|
|
215
|
+
box-shadow: 0 1px 3px light-dark(rgb(0 0 0 / 0.12), rgb(0 0 0 / 0.5));
|
|
216
|
+
}
|
|
217
|
+
.tb-root[data-variant="enclosed"] .tb-tab {
|
|
218
|
+
height: calc(var(--tb-height) - 6px);
|
|
219
|
+
}
|
|
220
|
+
.tb-root[data-variant="enclosed"][data-orientation="vertical"] .tb-indicator {
|
|
221
|
+
top: var(--tb-indicator-start);
|
|
222
|
+
bottom: auto;
|
|
223
|
+
left: 3px;
|
|
224
|
+
right: 3px;
|
|
225
|
+
width: auto;
|
|
226
|
+
height: var(--tb-indicator-size);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/* Before the indicator is measured (server render), mark the selected tab statically. */
|
|
230
|
+
.tb-root[data-variant="line"] .tb-list:not([style*="--tb-indicator-size"]) .tb-tab[aria-selected="true"] {
|
|
231
|
+
box-shadow: inset 0 -2px 0 var(--tb-accent);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/* --------------------------------------------------------------- panel */
|
|
235
|
+
|
|
236
|
+
.tb-panel {
|
|
237
|
+
flex: 1 1 auto;
|
|
238
|
+
min-width: 0;
|
|
239
|
+
border-radius: 4px;
|
|
240
|
+
}
|
|
241
|
+
.tb-panel:focus-visible {
|
|
242
|
+
outline: 2px solid var(--tb-focus);
|
|
243
|
+
outline-offset: 4px;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
@media (prefers-reduced-motion: reduce) {
|
|
247
|
+
.tb-root {
|
|
248
|
+
--tb-duration: 0s;
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
@media (forced-colors: active) {
|
|
253
|
+
.tb-indicator {
|
|
254
|
+
background: Highlight;
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
}
|
|
@@ -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,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
|
+
}
|