@botiverse/raft-ui 0.96.1 → 0.97.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/CHANGELOG.md +21 -0
- package/checks/native-scroll-css.mjs +467 -0
- package/dist/components/dialog/dialog.d.mts +16 -1
- package/dist/components/dialog/dialog.mjs +96 -2
- package/dist/components/markdown/markdown-parts.d.mts +7 -6
- package/dist/components/markdown/markdown-parts.mjs +6 -6
- package/dist/components/table/index.d.mts +2 -1
- package/dist/components/table/table-scroll.d.mts +16 -0
- package/dist/components/table/table-scroll.mjs +23 -0
- package/dist/components/textarea/index.d.mts +2 -2
- package/dist/components/textarea/textarea.d.mts +17 -0
- package/dist/components/textarea/textarea.mjs +38 -4
- package/dist/index-exports.d.mts +2 -1
- package/dist/index-exports.mjs +2 -1
- package/dist/index.d.mts +2 -1
- package/dist/lint/prefer-scroll-area.mjs +1 -1
- package/dist/wip-exports.d.mts +22 -20
- package/dist/wip-exports.mjs +4 -3
- package/dist/wip.d.mts +2 -1
- package/package.json +2 -1
- package/skills/raft-ui-guide/SKILL.md +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,24 @@
|
|
|
1
|
+
## [0.97.0](https://github.com/botiverse/raft-design/compare/v0.96.1...v0.97.0) (2026-11-13)
|
|
2
|
+
|
|
3
|
+
### Added
|
|
4
|
+
|
|
5
|
+
- **A check for native scrollers written in CSS files, which the lint rule can't see** ([Use raft-ui › CSS files](https://raft-design.botiverse.build/about/use-raft-ui/#css-files)). `node node_modules/@botiverse/raft-ui/checks/native-scroll-css.mjs [<dir, file or glob>...] [--json]` reads `.css` files (every one under the current directory by default, without `node_modules`, `dist`, `build` and dot folders) and reports each declaration of `overflow`, `overflow-x`, `overflow-y`, `overflow-inline` or `overflow-block` whose value has `auto`, `scroll` or `overlay` in it (`hidden auto`, `auto !important` and `var(--x, auto)` too), in minified CSS and inside `@media`, `@layer`, `@supports` and nested rules, and each native-scrolling class in an `@apply` (`overflow-y-auto`, `md:overflow-auto`, `overflow-[auto]`, `[overflow:overlay]`). Each report is `file:line:column selector { declaration }`, followed by one message that says what to write instead and ends with the address of ScrollArea's page as Markdown. It exits 1 when it reports anything, 0 when it reports nothing and 2 for a path that doesn't exist or can't be read, a glob that matches no file, or an unknown option. A comment allows one declaration: above a line that holds several, only the first. A plain Node script with no dependencies, shipped in a new `checks` folder of the package.
|
|
6
|
+
- Why: `raft-ui/prefer-scroll-area` reads JS and TSX only, so a scroller made in a stylesheet (a diagram's `<pre>`, a standalone page's own `styles.css`) passed the lint and kept the platform's scrollbar.
|
|
7
|
+
- Affects: nothing until an app runs it. Chain it into the app's lint script, such as `"lint": "oxlint && node node_modules/@botiverse/raft-ui/checks/native-scroll-css.mjs src"`, then make the element each reported rule styles a `ScrollArea` (`useScrollAreaContainer` and `ScrollAreaGutters` for an element another component renders) and remove the declaration, or, for an allowed native case, put `/* raft-ui: native-scroll <reason> */` on a line of its own right above the declaration, or after it at the end of its line. A comment without a reason doesn't count. It doesn't read Sass or Less, a `<style>` block in HTML or CSS in a JS string.
|
|
8
|
+
- Docs: https://raft-design.botiverse.build/about/use-raft-ui/#css-files
|
|
9
|
+
- **`TableScroll`, the area a wide table scrolls sideways in** ([Table › Wide tables](https://raft-design.botiverse.build/components/table/#wide-tables)). A part of Table: a horizontal ScrollArea around a `Table` or a plain `<table>`, with `data-slot="table-scroll"`. It takes ScrollArea's props but `orientation`, and has no frame or size of its own.
|
|
10
|
+
- Why: the only such part was Markdown's `MarkdownTableScroll`, so everywhere else a table wider than its column got a hand-written `overflow-x-auto` wrapper and the platform's scrollbar, or widened the page.
|
|
11
|
+
- Affects: nothing that exists. `MarkdownTableScroll` is now a `TableScroll` under its own slot name and renders the same DOM and classes as before. Give the `Table` a least width (`min-w-*`): without one its columns are first squeezed to their headers' width, long cells wrapping at any character, and it scrolls only once the headers don't fit. The area scrolls sideways only, so a vertical wheel over the table still scrolls the page, and it is a tab stop while the table is wider than it. `raft-ui/prefer-scroll-area` names `TableScroll` in its message for an element that scrolls sideways.
|
|
12
|
+
- Docs: https://raft-design.botiverse.build/components/table/#wide-tables
|
|
13
|
+
- **`textareaScrollAreaVariants`, Textarea's look for a `ScrollAreaTextarea`** ([Textarea › A field that scrolls](https://raft-design.botiverse.build/components/textarea/#a-field-that-scrolls)), from `@botiverse/raft-ui/wip`. `wrapper()` goes to `wrapperClassName` (the border, radius, background, least height, and the focus ring, invalid and disabled looks read from the textarea inside) and `textarea()` to `className` (the type and padding).
|
|
14
|
+
- Why: `Textarea` stays a native `<textarea>`, which shows the platform's scrollbar when it scrolls, and nothing said what to use for a field that is meant to scroll. `ScrollAreaTextarea` draws raft-ui's scrollbar but has no look, so each form re-derived the field's classes for its frame.
|
|
15
|
+
- Affects: nothing that exists: `Textarea` renders the same classes as before. A field whose height is capped or whose `rows` are fixed, with text long enough to scroll, can move to `ScrollAreaTextarea` with these classes and keeps the box of a `Textarea`. Put the cap on the frame (`wrapper({ className: "max-h-40" })`), and set `aria-invalid` beside `data-invalid` yourself.
|
|
16
|
+
- Docs: https://raft-design.botiverse.build/components/textarea/#a-field-that-scrolls
|
|
17
|
+
- **`DialogBody` can scroll in a ScrollArea, with raft-ui's scrollbar in place of the platform's** ([Dialog › Scrolling in a ScrollArea](https://raft-design.botiverse.build/components/dialog/#scrolling-in-a-scrollarea)). `<DialogBody unstable_scrollArea>` renders a frame (`data-slot="dialog-body-frame"`) around the body and binds the body as a ScrollArea with absolute gutters. The body keeps `data-slot="dialog-body"`, `className`, `ref`, the native props and its children, so a layout written on it (`className="grid gap-4"`) is unchanged; `wrapperClassName` is the frame's class, for the classes that size the body in the dialog (`max-h-*`, `h-*`, `flex-*`). The body is a Tab stop only while it overflows and holds nothing that takes focus (a `tabIndex` of your own wins), so a form gains no stop and a long text that is only read can be scrolled with the keyboard, and a scroll stops at its end with the bounce kept (`overscroll="contain"`). It works the same in `DialogSheetContent` below 768px. A `bar` body ignores the prop.
|
|
18
|
+
- Why: `DialogBody` was one of the few regions that still scrolled natively, because callers lay the body out themselves and ScrollArea's sticky gutters would take the grid's first cell. On Windows, and on a Mac set to always show scrollbars, a long dialog drew the platform's scrollbar, which takes room from the form when it appears. Absolute gutters put nothing inside the body, at the price of one element around it.
|
|
19
|
+
- Affects: nothing unless the prop is passed: without it `DialogBody` renders the same element and classes as before. It becomes the default in the next minor release, and the prop can be deleted then. With it, the frame, not the body, is the child of `DialogContent` (or of a `<form className="contents">` in it): a selector such as `[data-slot=dialog-content] > [data-slot=dialog-body]` no longer matches, a sizing class on the body moves to `wrapperClassName`, and a `p-0` in `className` also removes the top inset of a body with no header above it. The frame is a stacking context of its own, so a popup portaled into the body is drawn under the dialog's head; leave popups where raft-ui portals them. `DrawerBody` is unchanged.
|
|
20
|
+
- Docs: https://raft-design.botiverse.build/components/dialog/#scrolling-in-a-scrollarea
|
|
21
|
+
|
|
1
22
|
## [0.96.1](https://github.com/botiverse/raft-design/compare/v0.96.0...v0.96.1) (2026-11-13)
|
|
2
23
|
|
|
3
24
|
### Fixed
|
|
@@ -0,0 +1,467 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Finds native scrollers written in CSS files, which the lint rule
|
|
3
|
+
// `raft-ui/prefer-scroll-area` can't see: it reads JS and TSX only.
|
|
4
|
+
//
|
|
5
|
+
// node node_modules/@botiverse/raft-ui/checks/native-scroll-css.mjs [<dir-file-or-glob>...] [--json]
|
|
6
|
+
//
|
|
7
|
+
// With no path it reads every .css file under the current directory. A
|
|
8
|
+
// directory is read recursively; inside it node_modules, dist, build and dot
|
|
9
|
+
// folders are skipped at any depth (`src/build` too), and so is raft-ui's own
|
|
10
|
+
// CSS. A folder or a file named on the command line is read whatever it is
|
|
11
|
+
// called. Exits 1 when it reports anything, 0
|
|
12
|
+
// when it reports nothing, 2 on a path that doesn't exist or can't be read, a
|
|
13
|
+
// glob that matches no file, or an unknown option, so an app can chain it into
|
|
14
|
+
// its lint script:
|
|
15
|
+
//
|
|
16
|
+
// "lint": "oxlint && node node_modules/@botiverse/raft-ui/checks/native-scroll-css.mjs src"
|
|
17
|
+
//
|
|
18
|
+
// Reported:
|
|
19
|
+
// - a declaration of `overflow`, `overflow-x`, `overflow-y`, `overflow-inline`
|
|
20
|
+
// or `overflow-block` whose value has `auto`, `scroll` or `overlay` in it
|
|
21
|
+
// (`hidden auto` and `auto !important` too), in a rule at any depth
|
|
22
|
+
// (`@media`, `@layer`, `@supports`, nested rules);
|
|
23
|
+
// - a native-scrolling Tailwind class in an `@apply`: `overflow-auto`,
|
|
24
|
+
// `overflow-scroll`, their `-x-` and `-y-` forms, with any variant or `!`,
|
|
25
|
+
// an arbitrary value (`overflow-[auto]`, `overflow-y-[scroll]`) or an
|
|
26
|
+
// arbitrary property (`[overflow:overlay]`).
|
|
27
|
+
//
|
|
28
|
+
// Not reported: `hidden`, `visible` and `clip`, a value that is only a
|
|
29
|
+
// `var(--x)` with no keyword in it, a condition (`@supports (overflow: auto)`,
|
|
30
|
+
// `@media (overflow-block: scroll)`), and anything in a comment or a string.
|
|
31
|
+
//
|
|
32
|
+
// Allowed with a `/* raft-ui: native-scroll <reason> */` comment: on a line of
|
|
33
|
+
// its own right above the declaration (or the `@apply`), or after it at the
|
|
34
|
+
// end of its line. One comment allows one declaration: above a line that holds
|
|
35
|
+
// several, only the first; an `@apply` is one declaration however many lines
|
|
36
|
+
// its classes take. A `//` line is not a comment in CSS and allows nothing. The comment starts with the marker, and one without
|
|
37
|
+
// a reason doesn't count. The wording is
|
|
38
|
+
// the lint rule's (design/about/use-raft-ui.md#lint-rules).
|
|
39
|
+
import { existsSync, readdirSync, readFileSync, realpathSync, statSync } from "node:fs";
|
|
40
|
+
import path from "node:path";
|
|
41
|
+
import { fileURLToPath } from "node:url";
|
|
42
|
+
|
|
43
|
+
const PROPERTIES = new Set([
|
|
44
|
+
"overflow",
|
|
45
|
+
"overflow-x",
|
|
46
|
+
"overflow-y",
|
|
47
|
+
"overflow-inline",
|
|
48
|
+
"overflow-block",
|
|
49
|
+
]);
|
|
50
|
+
const SCROLLING_VALUE = /(?<![\w-])(?:auto|scroll|overlay)(?![\w-])/i;
|
|
51
|
+
// A class (group 1 a keyword, 2 an arbitrary value) or an arbitrary property (group 3 its value).
|
|
52
|
+
const OVERFLOW_CLASS =
|
|
53
|
+
/(?<![\w-])overflow(?:-[xy])?-(?:(auto|scroll)|\[([^\]\s]+)\])(?![\w-])|\[overflow(?:-(?:[xy]|inline|block))?:([^\]\s]+)\]/g;
|
|
54
|
+
// The comment starts with the marker, and a reason follows it: a word, not the
|
|
55
|
+
// `*` of a decorated comment.
|
|
56
|
+
const OPT_OUT = /^[\s*]*raft-ui:\s*native-scroll\s+[^\s*]/;
|
|
57
|
+
|
|
58
|
+
export const DOCS_URL = "https://raft-design.botiverse.build/components/scroll-area.md";
|
|
59
|
+
export const MESSAGE = [
|
|
60
|
+
"A native scroller shows the platform's own scrollbar. The element each of these rules styles",
|
|
61
|
+
"should be a ScrollArea from @botiverse/raft-ui instead: the size stays on its className, grid,",
|
|
62
|
+
'flex, gap and padding go on contentClassName, with orientation="horizontal" for overflow-x and',
|
|
63
|
+
'"both" for overflow, and the overflow declaration is removed. For an element another component',
|
|
64
|
+
"renders, bind it with useScrollAreaContainer and render <ScrollAreaGutters> as its first child.",
|
|
65
|
+
"Where it must scroll natively (one of the allowed cases in the docs), say why in",
|
|
66
|
+
"/* raft-ui: native-scroll <reason> */ on the line above the declaration.",
|
|
67
|
+
`Docs: ${DOCS_URL}`,
|
|
68
|
+
].join("\n");
|
|
69
|
+
|
|
70
|
+
const collapse = (text) => text.replace(/\s+/g, " ").trim();
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* The native scrollers in a CSS source: `{ line, column, selector, declaration,
|
|
74
|
+
* property, value }` for each, 1-based, in source order. `optOut: false` also
|
|
75
|
+
* returns the ones a `raft-ui: native-scroll <reason>` comment allows.
|
|
76
|
+
*/
|
|
77
|
+
export function scanCss(source, { optOut = true } = {}) {
|
|
78
|
+
const text = source.replace(/^/, " ");
|
|
79
|
+
const lineStarts = [0];
|
|
80
|
+
for (let i = 0; i < text.length; i++) if (text[i] === "\n") lineStarts.push(i + 1);
|
|
81
|
+
const lineOf = (offset) => {
|
|
82
|
+
let low = 0;
|
|
83
|
+
let high = lineStarts.length - 1;
|
|
84
|
+
while (low < high) {
|
|
85
|
+
const mid = (low + high + 1) >> 1;
|
|
86
|
+
if (lineStarts[mid] <= offset) low = mid;
|
|
87
|
+
else high = mid - 1;
|
|
88
|
+
}
|
|
89
|
+
return low;
|
|
90
|
+
};
|
|
91
|
+
const lineEnd = (line) => (line + 1 < lineStarts.length ? lineStarts[line + 1] - 1 : text.length);
|
|
92
|
+
|
|
93
|
+
const comments = [];
|
|
94
|
+
const findings = [];
|
|
95
|
+
// The preludes of the open blocks, outermost first.
|
|
96
|
+
const blocks = [];
|
|
97
|
+
// The statement being read: what it holds with comments left out, the offset
|
|
98
|
+
// of each character kept, and the depth of the brackets open in it.
|
|
99
|
+
let buffer = "";
|
|
100
|
+
let tail = "";
|
|
101
|
+
let offsets = [];
|
|
102
|
+
let depth = 0;
|
|
103
|
+
const reset = () => {
|
|
104
|
+
buffer = "";
|
|
105
|
+
offsets = [];
|
|
106
|
+
depth = 0;
|
|
107
|
+
tail = "";
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
function selector() {
|
|
111
|
+
const rules = blocks.filter((prelude) => prelude !== "" && !prelude.startsWith("@"));
|
|
112
|
+
if (rules.length > 0) return rules.join(" ");
|
|
113
|
+
return blocks.findLast((prelude) => prelude !== "") ?? "";
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// The opt-out comments by line: one alone on its lines under its last line
|
|
117
|
+
// (its statement starts on the next), any other under its first (its
|
|
118
|
+
// statement ends on it).
|
|
119
|
+
let byLine;
|
|
120
|
+
function optOuts() {
|
|
121
|
+
if (byLine) return byLine;
|
|
122
|
+
byLine = new Map();
|
|
123
|
+
for (const comment of comments) {
|
|
124
|
+
if (!OPT_OUT.test(comment.value)) continue;
|
|
125
|
+
const first = lineOf(comment.start);
|
|
126
|
+
const last = lineOf(comment.end - 1);
|
|
127
|
+
const alone =
|
|
128
|
+
text.slice(lineStarts[first], comment.start).trim() === "" &&
|
|
129
|
+
text.slice(comment.end, lineEnd(last)).trim() === "";
|
|
130
|
+
const key = alone ? last : first;
|
|
131
|
+
if (!byLine.has(key)) byLine.set(key, []);
|
|
132
|
+
byLine.get(key).push({ comment, first, last, alone });
|
|
133
|
+
}
|
|
134
|
+
return byLine;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function allowed(start, end) {
|
|
138
|
+
if (!optOut) return false;
|
|
139
|
+
const startLine = lineOf(start);
|
|
140
|
+
const endLine = lineOf(end - 1);
|
|
141
|
+
const near = [...(optOuts().get(startLine - 1) ?? []), ...(optOuts().get(endLine) ?? [])];
|
|
142
|
+
return near.some(({ comment, first, last, alone }) => {
|
|
143
|
+
// On the line above, it allows the first statement of the next line
|
|
144
|
+
// only: nothing ends before it there (`.a { overflow: auto }`, not the
|
|
145
|
+
// second rule or declaration of a line, so not a minified file).
|
|
146
|
+
if (alone) {
|
|
147
|
+
if (last !== startLine - 1) return false;
|
|
148
|
+
const before = text.slice(lineStarts[startLine], start).replace(/\/\*[\s\S]*?\*\//g, "");
|
|
149
|
+
return !/[;}]/.test(before);
|
|
150
|
+
}
|
|
151
|
+
// At the end of the statement's own line: nothing but the end of the
|
|
152
|
+
// statement and of its rule between the two, and nothing after it.
|
|
153
|
+
if (comment.start < end || first !== endLine) return false;
|
|
154
|
+
const between = text.slice(end, comment.start).replace(/\/\*[\s\S]*?\*\//g, "");
|
|
155
|
+
return /^[\s;}]*$/.test(between) && /^[\s;}]*$/.test(text.slice(comment.end, lineEnd(last)));
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function report(offset, declaration, property, value, start, end) {
|
|
160
|
+
const line = lineOf(offset);
|
|
161
|
+
findings.push({
|
|
162
|
+
start,
|
|
163
|
+
end,
|
|
164
|
+
line: line + 1,
|
|
165
|
+
column: offset - lineStarts[line] + 1,
|
|
166
|
+
selector: selector(),
|
|
167
|
+
declaration: collapse(declaration),
|
|
168
|
+
property,
|
|
169
|
+
value,
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function statement() {
|
|
174
|
+
const lead = buffer.length - buffer.trimStart().length;
|
|
175
|
+
const body = buffer.trim();
|
|
176
|
+
if (body === "") return;
|
|
177
|
+
const start = offsets[lead];
|
|
178
|
+
const end = offsets[lead + body.length - 1] + 1;
|
|
179
|
+
const apply = /^@apply(?=[\s![]|$)/i.exec(body);
|
|
180
|
+
if (apply) {
|
|
181
|
+
for (const match of body.matchAll(OVERFLOW_CLASS)) {
|
|
182
|
+
const value = match[1] ?? (match[2] ?? match[3]).replaceAll("_", " ");
|
|
183
|
+
if (!SCROLLING_VALUE.test(value)) continue;
|
|
184
|
+
report(offsets[lead + match.index], `@apply ${match[0]}`, "@apply", match[0], start, end);
|
|
185
|
+
}
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
const declaration = /^([-\w]+)\s*:([\s\S]*)$/.exec(body);
|
|
189
|
+
if (!declaration) return;
|
|
190
|
+
const property = declaration[1].toLowerCase();
|
|
191
|
+
if (!PROPERTIES.has(property)) return;
|
|
192
|
+
const value = collapse(declaration[2]);
|
|
193
|
+
if (!SCROLLING_VALUE.test(value)) return;
|
|
194
|
+
report(start, `${property}: ${value}`, property, value, start, end);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// Nothing of a statement has been read yet (a space stands for a comment).
|
|
198
|
+
const blank = () => buffer.length < 64 && buffer.trim() === "";
|
|
199
|
+
// The last characters kept, read apart from the buffer: reading the end of
|
|
200
|
+
// a string built a character at a time copies all of it.
|
|
201
|
+
const push = (char, offset) => {
|
|
202
|
+
tail = (tail + char).slice(-4);
|
|
203
|
+
buffer += char;
|
|
204
|
+
offsets.push(offset);
|
|
205
|
+
};
|
|
206
|
+
|
|
207
|
+
for (let i = 0; i < text.length; i++) {
|
|
208
|
+
const char = text[i];
|
|
209
|
+
if (char === "/" && text[i + 1] === "*") {
|
|
210
|
+
const close = text.indexOf("*/", i + 2);
|
|
211
|
+
const end = close === -1 ? text.length : close + 2;
|
|
212
|
+
comments.push({ start: i, end, value: text.slice(i + 2, close === -1 ? end : close) });
|
|
213
|
+
// A comment separates what is on its two sides, as a space does.
|
|
214
|
+
push(" ", i);
|
|
215
|
+
i = end - 1;
|
|
216
|
+
} else if (char === '"' || char === "'") {
|
|
217
|
+
// A string is kept whole: a brace or a semicolon in it ends nothing.
|
|
218
|
+
// An unclosed one ends with its line, as CSS reads it.
|
|
219
|
+
push(char, i);
|
|
220
|
+
for (i++; i < text.length && text[i] !== char && text[i] !== "\n"; i++) {
|
|
221
|
+
if (text[i] === "\\" && i + 1 < text.length) push(text[i], i++);
|
|
222
|
+
push(text[i], i);
|
|
223
|
+
}
|
|
224
|
+
if (text[i] === char) push(char, i);
|
|
225
|
+
} else if (char === "\\" && i + 1 < text.length) {
|
|
226
|
+
// An escaped character is never a delimiter: `.a\{b`, `.sm\:flex`.
|
|
227
|
+
push(char, i);
|
|
228
|
+
push(text[i + 1], ++i);
|
|
229
|
+
} else if (char === "/" && text[i + 1] === "/" && depth === 0 && blank()) {
|
|
230
|
+
// CSS has no `//` comment, but a build step may take one, and an
|
|
231
|
+
// apostrophe or a brace in such a line would be read as CSS. At the start
|
|
232
|
+
// of a statement the line is skipped whole, so the declaration under it
|
|
233
|
+
// is read on its own, and `//` allows nothing.
|
|
234
|
+
const end = text.indexOf("\n", i);
|
|
235
|
+
i = end === -1 ? text.length : end;
|
|
236
|
+
} else if (
|
|
237
|
+
char === "(" &&
|
|
238
|
+
/(?:^|[^\w-])url$/i.test(tail) &&
|
|
239
|
+
!/^\s*["']/.test(text.slice(i + 1, i + 40))
|
|
240
|
+
) {
|
|
241
|
+
// An unquoted `url(…)` is kept whole to its `)`: `/*`, a brace or a
|
|
242
|
+
// semicolon in it (`url(/img/*.png)`, `url(data:…;base64,…)`) is part
|
|
243
|
+
// of the address.
|
|
244
|
+
push(char, i);
|
|
245
|
+
for (i++; i < text.length && text[i] !== ")"; i++) {
|
|
246
|
+
if (text[i] === "\\" && i + 1 < text.length) push(text[i], i++);
|
|
247
|
+
push(text[i], i);
|
|
248
|
+
}
|
|
249
|
+
if (text[i] === ")") push(")", i);
|
|
250
|
+
} else if (char === "(" || char === "[") {
|
|
251
|
+
depth++;
|
|
252
|
+
push(char, i);
|
|
253
|
+
} else if (char === ")" || char === "]") {
|
|
254
|
+
depth = Math.max(0, depth - 1);
|
|
255
|
+
push(char, i);
|
|
256
|
+
} else if (depth > 0 && char !== "}") {
|
|
257
|
+
// Inside brackets: `url(data:image/png;base64,…)` ends no declaration.
|
|
258
|
+
push(char, i);
|
|
259
|
+
} else if (char === "{") {
|
|
260
|
+
blocks.push(collapse(buffer));
|
|
261
|
+
reset();
|
|
262
|
+
} else if (char === "}") {
|
|
263
|
+
// The last declaration of a rule needs no semicolon.
|
|
264
|
+
statement();
|
|
265
|
+
reset();
|
|
266
|
+
blocks.pop();
|
|
267
|
+
} else if (char === ";") {
|
|
268
|
+
statement();
|
|
269
|
+
reset();
|
|
270
|
+
} else {
|
|
271
|
+
push(char, i);
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
statement();
|
|
275
|
+
// Once every comment is known: one at the end of a line comes after its statement.
|
|
276
|
+
return findings
|
|
277
|
+
.filter(({ start, end }) => !allowed(start, end))
|
|
278
|
+
.map(({ start: _start, end: _end, ...finding }) => finding);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
const SKIP = new Set(["node_modules", "dist", "build"]);
|
|
282
|
+
const byName = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
|
|
283
|
+
const toPosix = (file) => file.split(path.sep).join("/");
|
|
284
|
+
|
|
285
|
+
function* walk(dir, seen = new Set()) {
|
|
286
|
+
// A folder is read once: a symlink back to one already read is a loop.
|
|
287
|
+
const real = realpathSync(dir);
|
|
288
|
+
if (seen.has(real)) return;
|
|
289
|
+
seen.add(real);
|
|
290
|
+
for (const name of readdirSync(dir).sort(byName)) {
|
|
291
|
+
if (SKIP.has(name) || name.startsWith(".")) continue;
|
|
292
|
+
const full = path.join(dir, name);
|
|
293
|
+
let stat;
|
|
294
|
+
try {
|
|
295
|
+
stat = statSync(full);
|
|
296
|
+
} catch {
|
|
297
|
+
continue; // a dangling symlink
|
|
298
|
+
}
|
|
299
|
+
if (stat.isDirectory()) yield* walk(full, seen);
|
|
300
|
+
else if (name.toLowerCase().endsWith(".css")) yield full;
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/** A glob as a RegExp over a posix path: `**`, `*`, `?`, `{a,b}` and `[abc]`. */
|
|
305
|
+
export function globToRegExp(glob) {
|
|
306
|
+
let out = "";
|
|
307
|
+
let braces = 0;
|
|
308
|
+
for (let i = 0; i < glob.length; i++) {
|
|
309
|
+
const char = glob[i];
|
|
310
|
+
if (char === "*") {
|
|
311
|
+
if (glob[i + 1] === "*") {
|
|
312
|
+
i++;
|
|
313
|
+
// `**/` is any number of folders, none included.
|
|
314
|
+
if (glob[i + 1] === "/") {
|
|
315
|
+
i++;
|
|
316
|
+
out += "(?:[^/]+/)*";
|
|
317
|
+
} else out += ".*";
|
|
318
|
+
} else out += "[^/]*";
|
|
319
|
+
} else if (char === "?") out += "[^/]";
|
|
320
|
+
else if (char === "{") {
|
|
321
|
+
braces++;
|
|
322
|
+
out += "(?:";
|
|
323
|
+
} else if (char === "}" && braces > 0) {
|
|
324
|
+
braces--;
|
|
325
|
+
out += ")";
|
|
326
|
+
} else if (char === "," && braces > 0) out += "|";
|
|
327
|
+
else if (char === "[") {
|
|
328
|
+
const close = glob.indexOf("]", i + 2);
|
|
329
|
+
if (close === -1) out += "\\[";
|
|
330
|
+
else {
|
|
331
|
+
const body = glob.slice(i + 1, close).replace(/\\/g, "\\\\");
|
|
332
|
+
out += `[${body.startsWith("!") ? `^${body.slice(1)}` : body}]`;
|
|
333
|
+
i = close;
|
|
334
|
+
}
|
|
335
|
+
} else out += char.replace(/[.+^$()|\\\]}]/g, "\\$&");
|
|
336
|
+
}
|
|
337
|
+
return new RegExp(`^${out}$`);
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
const IS_GLOB = /[*?{[]/;
|
|
341
|
+
|
|
342
|
+
/** The CSS files a path names: the file, every .css file under a directory, or a glob's matches. */
|
|
343
|
+
export function cssFiles(target, cwd = process.cwd()) {
|
|
344
|
+
const full = path.resolve(cwd, target);
|
|
345
|
+
if (existsSync(full)) return statSync(full).isDirectory() ? [...walk(full)] : [full];
|
|
346
|
+
if (!IS_GLOB.test(target)) return undefined;
|
|
347
|
+
// Walk from the folders the glob names before its first wildcard.
|
|
348
|
+
const parts = toPosix(target).split("/");
|
|
349
|
+
const fixed = [];
|
|
350
|
+
while (parts.length > 1 && !IS_GLOB.test(parts[0])) fixed.push(parts.shift());
|
|
351
|
+
const base = path.resolve(cwd, fixed.join("/") || ".");
|
|
352
|
+
if (!existsSync(base) || !statSync(base).isDirectory()) return [];
|
|
353
|
+
const pattern = globToRegExp(parts.join("/"));
|
|
354
|
+
return [...walk(base)].filter((file) => pattern.test(toPosix(path.relative(base, file))));
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
const PACKAGE_DIR = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
|
|
358
|
+
|
|
359
|
+
/** raft-ui's own CSS, which scrolls natively where its docs say: read by mistake, never reported. */
|
|
360
|
+
function isOwnCss(file) {
|
|
361
|
+
if (toPosix(file).includes("/node_modules/@botiverse/raft-ui/")) return true;
|
|
362
|
+
try {
|
|
363
|
+
// A symlinked install (pnpm, a workspace) resolves to the package's folder.
|
|
364
|
+
const real = realpathSync(file);
|
|
365
|
+
const own = realpathSync(PACKAGE_DIR) + path.sep;
|
|
366
|
+
return real.startsWith(own) && toPosix(real.slice(own.length)).startsWith("dist/");
|
|
367
|
+
} catch {
|
|
368
|
+
return false;
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
const USAGE =
|
|
373
|
+
"usage: native-scroll-css.mjs [<dir-file-or-glob>...] [--json]\n" +
|
|
374
|
+
"Reports native scrollers (overflow: auto | scroll | overlay) in CSS files.\n" +
|
|
375
|
+
"With no path, reads every .css file under the current directory.";
|
|
376
|
+
|
|
377
|
+
/** Runs the check: `{ code, stdout, stderr }`, without printing or exiting. */
|
|
378
|
+
export function run(argv, cwd = process.cwd()) {
|
|
379
|
+
const flags = argv.filter((arg) => arg.startsWith("-"));
|
|
380
|
+
const targets = argv.filter((arg) => !arg.startsWith("-"));
|
|
381
|
+
if (flags.includes("--help") || flags.includes("-h"))
|
|
382
|
+
return { code: 0, stdout: `${USAGE}\n`, stderr: "" };
|
|
383
|
+
const unknown = flags.filter((flag) => flag !== "--json");
|
|
384
|
+
if (unknown.length > 0) {
|
|
385
|
+
return { code: 2, stdout: "", stderr: `unknown option ${unknown.join(", ")}\n${USAGE}\n` };
|
|
386
|
+
}
|
|
387
|
+
const json = flags.includes("--json");
|
|
388
|
+
|
|
389
|
+
const files = new Set();
|
|
390
|
+
for (const target of targets.length > 0 ? targets : ["."]) {
|
|
391
|
+
let found;
|
|
392
|
+
try {
|
|
393
|
+
found = cssFiles(target, cwd);
|
|
394
|
+
} catch (error) {
|
|
395
|
+
return {
|
|
396
|
+
code: 2,
|
|
397
|
+
stdout: "",
|
|
398
|
+
stderr: `can't read ${target}: ${error.code ?? error.message}\n`,
|
|
399
|
+
};
|
|
400
|
+
}
|
|
401
|
+
if (found === undefined) {
|
|
402
|
+
return { code: 2, stdout: "", stderr: `no such file or directory: ${target}\n` };
|
|
403
|
+
}
|
|
404
|
+
// A glob that matches nothing is a mistyped path, as a missing one is: a
|
|
405
|
+
// lint script must not stay green on it.
|
|
406
|
+
if (found.length === 0 && !existsSync(path.resolve(cwd, target))) {
|
|
407
|
+
return { code: 2, stdout: "", stderr: `no CSS files match: ${target}\n` };
|
|
408
|
+
}
|
|
409
|
+
for (const file of found) if (!isOwnCss(file)) files.add(file);
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
const findings = [];
|
|
413
|
+
for (const file of [...files].sort(byName)) {
|
|
414
|
+
const shown = toPosix(path.relative(cwd, file)) || toPosix(file);
|
|
415
|
+
let source;
|
|
416
|
+
try {
|
|
417
|
+
source = readFileSync(file, "utf8");
|
|
418
|
+
} catch (error) {
|
|
419
|
+
return {
|
|
420
|
+
code: 2,
|
|
421
|
+
stdout: "",
|
|
422
|
+
stderr: `can't read ${shown}: ${error.code ?? error.message}\n`,
|
|
423
|
+
};
|
|
424
|
+
}
|
|
425
|
+
for (const finding of scanCss(source)) {
|
|
426
|
+
findings.push({ file: shown, ...finding });
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
const code = findings.length > 0 ? 1 : 0;
|
|
431
|
+
if (json) {
|
|
432
|
+
const report = { files: files.size, findings, message: code ? MESSAGE : "", docs: DOCS_URL };
|
|
433
|
+
return { code, stdout: `${JSON.stringify(report, null, 2)}\n`, stderr: "" };
|
|
434
|
+
}
|
|
435
|
+
const plural = (count, word) => `${count} ${word}${count === 1 ? "" : "s"}`;
|
|
436
|
+
if (code === 0) {
|
|
437
|
+
return {
|
|
438
|
+
code,
|
|
439
|
+
stdout: `No native scrollers in ${plural(files.size, "CSS file")}.\n`,
|
|
440
|
+
stderr: "",
|
|
441
|
+
};
|
|
442
|
+
}
|
|
443
|
+
const lines = findings.map(
|
|
444
|
+
({ file, line, column, selector, declaration }) =>
|
|
445
|
+
`${file}:${line}:${column} ${selector ? `${selector} ` : ""}{ ${declaration} }`,
|
|
446
|
+
);
|
|
447
|
+
const reported = new Set(findings.map((finding) => finding.file)).size;
|
|
448
|
+
const summary = `${plural(findings.length, "native scroller")} in ${plural(reported, "CSS file")}.`;
|
|
449
|
+
return { code, stdout: `${lines.join("\n")}\n\n${summary}\n${MESSAGE}\n`, stderr: "" };
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
// Run only when invoked directly. Compare real paths: pnpm installs packages
|
|
453
|
+
// through symlinks, so `process.argv[1]` (the path typed) and this module's
|
|
454
|
+
// URL (the resolved file) differ.
|
|
455
|
+
function isInvokedDirectly() {
|
|
456
|
+
try {
|
|
457
|
+
return realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url));
|
|
458
|
+
} catch {
|
|
459
|
+
return false;
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
if (isInvokedDirectly()) {
|
|
463
|
+
const { code, stdout, stderr } = run(process.argv.slice(2));
|
|
464
|
+
if (stdout) process.stdout.write(stdout);
|
|
465
|
+
if (stderr) process.stderr.write(stderr);
|
|
466
|
+
process.exitCode = code;
|
|
467
|
+
}
|
|
@@ -57,8 +57,23 @@ export declare function DialogHeader({ className, bar, children, ...props }: Dia
|
|
|
57
57
|
export type DialogBodyProps = ComponentPropsWithRef<"div"> & {
|
|
58
58
|
/** A pinned bar of a scrolling (`size="wide"`) dialog. */
|
|
59
59
|
bar?: boolean;
|
|
60
|
+
/**
|
|
61
|
+
* Scroll the body in a ScrollArea, with raft-ui's scrollbar in place of the
|
|
62
|
+
* platform's. A frame (`data-slot="dialog-body-frame"`) is rendered around
|
|
63
|
+
* the body and holds the scrollbars; the body keeps its slot, `className`,
|
|
64
|
+
* `ref`, native props and children, so a layout written on it
|
|
65
|
+
* (`grid gap-4`) is unchanged. It becomes the default in the next minor
|
|
66
|
+
* release: delete the prop then. A `bar` body ignores it.
|
|
67
|
+
*/
|
|
68
|
+
unstable_scrollArea?: boolean;
|
|
69
|
+
/**
|
|
70
|
+
* With `unstable_scrollArea`, the class of the frame around the body: the
|
|
71
|
+
* classes that size the body in the dialog (`max-h-*`, `h-*`, `flex-*`) go
|
|
72
|
+
* here, not in `className`. Ignored without `unstable_scrollArea`.
|
|
73
|
+
*/
|
|
74
|
+
wrapperClassName?: string;
|
|
60
75
|
};
|
|
61
|
-
export declare function DialogBody({ className, bar, children, ...props }: DialogBodyProps): import("react").JSX.Element;
|
|
76
|
+
export declare function DialogBody({ className, bar, unstable_scrollArea: scrollArea, wrapperClassName, children, ...props }: DialogBodyProps): import("react").JSX.Element;
|
|
62
77
|
export type DialogFooterProps = ComponentPropsWithRef<"div"> & {
|
|
63
78
|
/** A pinned bar of a scrolling (`size="wide"`) dialog. */
|
|
64
79
|
bar?: boolean;
|
|
@@ -3,6 +3,8 @@ import { barButtonDesktop, headEdgePull } from "../../lib/head-edges.mjs";
|
|
|
3
3
|
import { decisionWidth } from "../../lib/decision-width.mjs";
|
|
4
4
|
import { PopupSheetReset } from "../../lib/popup-boundary.mjs";
|
|
5
5
|
import { ScrollArea } from "../scroll-area/scroll-area.mjs";
|
|
6
|
+
import { ScrollAreaGutters } from "../scroll-area/scroll-area-gutters.mjs";
|
|
7
|
+
import { useScrollAreaContainer } from "../scroll-area/use-scroll-area-container.mjs";
|
|
6
8
|
import { stackedActions, useStackedActions } from "../../lib/stacked-actions.mjs";
|
|
7
9
|
import { tv } from "../../lib/tv.mjs";
|
|
8
10
|
import { useThemeScopeProps } from "../../lib/theme/theme-scope.mjs";
|
|
@@ -15,7 +17,7 @@ import { PanelAction } from "../panel/panel-action.mjs";
|
|
|
15
17
|
import { DialogSheetHeadContext, DialogSheetReset, DialogSheetSurfaceContext, dialogSheet, useDialogSheet, useInDialogSheetHead } from "./dialog-sheet-context.mjs";
|
|
16
18
|
import { Dialog } from "@base-ui/react/dialog";
|
|
17
19
|
import { ArrowLeft, X } from "lucide-react";
|
|
18
|
-
import { Children, createContext, isValidElement, useContext, useEffect, useState } from "react";
|
|
20
|
+
import { Children, createContext, isValidElement, useCallback, useContext, useEffect, useState } from "react";
|
|
19
21
|
import { Fragment as Fragment$1, jsx, jsxs } from "react/jsx-runtime";
|
|
20
22
|
import { mergeProps } from "@base-ui/react/merge-props";
|
|
21
23
|
import { useRender } from "@base-ui/react/use-render";
|
|
@@ -41,6 +43,12 @@ const dialog = tv({
|
|
|
41
43
|
"px-(--surface-inset) pt-1.5 pb-(--surface-inset) first:pt-(--surface-inset)",
|
|
42
44
|
"font-sans text-subhead text-foreground-muted"
|
|
43
45
|
],
|
|
46
|
+
bodyFrame: ["min-h-0 min-w-0 flex-1", "[--dialog-body-top:--spacing(1.5)] first:[--dialog-body-top:var(--surface-inset)]"],
|
|
47
|
+
bodyScroll: [
|
|
48
|
+
"min-h-0 min-w-0 flex-1",
|
|
49
|
+
"px-(--surface-inset) pt-(--dialog-body-top) pb-(--surface-inset)",
|
|
50
|
+
"font-sans text-subhead text-foreground-muted"
|
|
51
|
+
],
|
|
44
52
|
footer: [
|
|
45
53
|
"flex shrink-0 items-center justify-end",
|
|
46
54
|
...stackedActions,
|
|
@@ -285,10 +293,16 @@ function DialogHeader({ className, bar = false, children, ...props }) {
|
|
|
285
293
|
children
|
|
286
294
|
});
|
|
287
295
|
}
|
|
288
|
-
function DialogBody({ className, bar = false, children, ...props }) {
|
|
296
|
+
function DialogBody({ className, bar = false, unstable_scrollArea: scrollArea = false, wrapperClassName, children, ...props }) {
|
|
289
297
|
const { sheet, setBodyEndSlot } = useDialogSheet();
|
|
290
298
|
const { body } = dialog({ bar });
|
|
291
299
|
const sheetBody = sheet ? cn(bar && dialogSheet().barBody(), dialogSheet().body()) : void 0;
|
|
300
|
+
if (scrollArea && !bar) return /* @__PURE__ */ jsx(DialogBodyScrollArea, {
|
|
301
|
+
className,
|
|
302
|
+
wrapperClassName,
|
|
303
|
+
...props,
|
|
304
|
+
children
|
|
305
|
+
});
|
|
292
306
|
return /* @__PURE__ */ jsxs("div", {
|
|
293
307
|
"data-slot": "dialog-body",
|
|
294
308
|
className: body({ className: sheetBody ? cn(sheetBody, className) : className }),
|
|
@@ -300,6 +314,86 @@ function DialogBody({ className, bar = false, children, ...props }) {
|
|
|
300
314
|
}) : null]
|
|
301
315
|
});
|
|
302
316
|
}
|
|
317
|
+
/**
|
|
318
|
+
* DialogBody as a ScrollArea with absolute gutters: nothing is put inside the
|
|
319
|
+
* body, so its own grid or flex layout holds only the caller's children.
|
|
320
|
+
*/
|
|
321
|
+
function DialogBodyScrollArea({ className, wrapperClassName, children, ref, tabIndex, ...props }) {
|
|
322
|
+
const { sheet, setBodyEndSlot } = useDialogSheet();
|
|
323
|
+
const { bodyFrame, bodyScroll } = dialog();
|
|
324
|
+
const [body, setBody] = useState(null);
|
|
325
|
+
const setRef = useCallback((element) => {
|
|
326
|
+
setBody(element);
|
|
327
|
+
if (typeof ref === "function") ref(element);
|
|
328
|
+
else if (ref) ref.current = element;
|
|
329
|
+
}, [ref]);
|
|
330
|
+
const holdsFocusable = useHoldsFocusable(body);
|
|
331
|
+
const { containerProps, frameProps, guttersProps } = useScrollAreaContainer({
|
|
332
|
+
containerRef: setRef,
|
|
333
|
+
guttersPosition: "absolute",
|
|
334
|
+
overscroll: "contain"
|
|
335
|
+
});
|
|
336
|
+
return /* @__PURE__ */ jsxs("div", {
|
|
337
|
+
...frameProps,
|
|
338
|
+
"data-slot": "dialog-body-frame",
|
|
339
|
+
className: bodyFrame({ className: cn(frameProps.className, wrapperClassName) }),
|
|
340
|
+
children: [/* @__PURE__ */ jsxs("div", {
|
|
341
|
+
"data-slot": "dialog-body",
|
|
342
|
+
...containerProps,
|
|
343
|
+
...props,
|
|
344
|
+
tabIndex: tabIndex ?? (holdsFocusable ? -1 : containerProps.tabIndex),
|
|
345
|
+
className: bodyScroll({ className: cn(containerProps.className, sheet && dialogSheet().body(), className) }),
|
|
346
|
+
children: [children, sheet ? /* @__PURE__ */ jsx("div", {
|
|
347
|
+
ref: setBodyEndSlot,
|
|
348
|
+
"data-slot": "dialog-sheet-body-end",
|
|
349
|
+
className: dialogSheet().bodyEnd()
|
|
350
|
+
}) : null]
|
|
351
|
+
}), /* @__PURE__ */ jsx(ScrollAreaGutters, { ...guttersProps })]
|
|
352
|
+
});
|
|
353
|
+
}
|
|
354
|
+
const FOCUSABLE = "a[href], button, input:not([type=\"hidden\"]), select, textarea, summary, iframe, audio[controls], video[controls], [contenteditable]:not([contenteditable=\"false\"]), [tabindex]";
|
|
355
|
+
/** Whether Tab stops at `candidate`: enabled, not inert, and rendered. */
|
|
356
|
+
function takesTab(candidate) {
|
|
357
|
+
if (!candidate.isContentEditable && candidate.tabIndex < 0) return false;
|
|
358
|
+
if (candidate.matches(":disabled") || candidate.closest("[inert]")) return false;
|
|
359
|
+
if (typeof candidate.checkVisibility !== "function") return true;
|
|
360
|
+
return candidate.checkVisibility({ visibilityProperty: true });
|
|
361
|
+
}
|
|
362
|
+
/** Whether `element` holds something Tab stops at, kept current as its content changes. */
|
|
363
|
+
function useHoldsFocusable(element) {
|
|
364
|
+
const [holds, setHolds] = useState(true);
|
|
365
|
+
useEffect(() => {
|
|
366
|
+
if (!element) return;
|
|
367
|
+
const read = () => {
|
|
368
|
+
for (const candidate of element.querySelectorAll(FOCUSABLE)) if (takesTab(candidate)) {
|
|
369
|
+
setHolds(true);
|
|
370
|
+
return;
|
|
371
|
+
}
|
|
372
|
+
setHolds(false);
|
|
373
|
+
};
|
|
374
|
+
read();
|
|
375
|
+
if (typeof MutationObserver === "undefined") return;
|
|
376
|
+
const observer = new MutationObserver(read);
|
|
377
|
+
observer.observe(element, {
|
|
378
|
+
childList: true,
|
|
379
|
+
subtree: true,
|
|
380
|
+
attributes: true,
|
|
381
|
+
attributeFilter: [
|
|
382
|
+
"disabled",
|
|
383
|
+
"tabindex",
|
|
384
|
+
"href",
|
|
385
|
+
"inert",
|
|
386
|
+
"contenteditable",
|
|
387
|
+
"hidden",
|
|
388
|
+
"class",
|
|
389
|
+
"style",
|
|
390
|
+
"open"
|
|
391
|
+
]
|
|
392
|
+
});
|
|
393
|
+
return () => observer.disconnect();
|
|
394
|
+
}, [element]);
|
|
395
|
+
return holds;
|
|
396
|
+
}
|
|
303
397
|
function DialogFooter({ className, bar = false, hidden, children, ref, ...props }) {
|
|
304
398
|
const { sheet, bodyEndSlot } = useDialogSheet();
|
|
305
399
|
const items = Children.toArray(children);
|