@godxjp/ui 31.5.0 → 31.6.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.
Files changed (62) hide show
  1. package/agent/START-HERE.md +5 -5
  2. package/agent/components/BranchScopePicker.json +5 -0
  3. package/agent/components/Cascader.json +1 -1
  4. package/agent/components/DataTable.json +2 -2
  5. package/agent/components/InfiniteQueryState.json +6 -1
  6. package/agent/components/RangeTimeline.json +6 -0
  7. package/agent/components/RecordPicker.json +10 -0
  8. package/agent/components/Transfer.json +5 -0
  9. package/agent/components/Tree.json +21 -4
  10. package/agent/components/TreeSelect.json +1 -1
  11. package/agent/components.json +57 -9
  12. package/agent/index.json +5 -5
  13. package/agent/llms.txt +6 -6
  14. package/agent/tokens.json +6 -0
  15. package/dist/components/data-display/data-table.d.ts +3 -0
  16. package/dist/components/data-display/data-table.js +415 -370
  17. package/dist/components/data-display/range-timeline.d.ts +20 -0
  18. package/dist/components/data-display/range-timeline.js +217 -197
  19. package/dist/components/data-display/tree.js +179 -23
  20. package/dist/components/data-entry/branch-scope-picker.d.ts +1 -0
  21. package/dist/components/data-entry/branch-scope-picker.js +2 -1
  22. package/dist/components/data-entry/cascader.js +4 -5
  23. package/dist/components/data-entry/record-picker.d.ts +2 -0
  24. package/dist/components/data-entry/record-picker.js +22 -3
  25. package/dist/components/data-entry/transfer.d.ts +1 -1
  26. package/dist/components/data-entry/transfer.js +5 -2
  27. package/dist/components/data-entry/tree-select.js +14 -5
  28. package/dist/components/query/infinite-query-state.d.ts +1 -1
  29. package/dist/components/query/infinite-query-state.js +2 -1
  30. package/dist/contracts/measurement.json +1 -1
  31. package/dist/i18n/messages/en.json +16 -2
  32. package/dist/i18n/messages/ja.json +7 -2
  33. package/dist/i18n/messages/vi.json +7 -2
  34. package/dist/lib/hooks.d.ts +8 -4
  35. package/dist/lib/hooks.js +5 -2
  36. package/dist/lib/tree.d.ts +23 -0
  37. package/dist/lib/tree.js +29 -0
  38. package/dist/props/components/data-display.prop.d.ts +21 -2
  39. package/dist/props/components/data-entry.prop.d.ts +31 -4
  40. package/dist/props/components/query.prop.d.ts +8 -1
  41. package/dist/props/registry.d.ts +24 -6
  42. package/dist/props/registry.js +41 -4
  43. package/dist/props/vocabulary/data.prop.d.ts +32 -2
  44. package/dist/props/vocabulary/index.d.ts +1 -1
  45. package/dist/styles/data-display-layout.css +52 -0
  46. package/dist/styles/layers.json +1 -1
  47. package/dist/styles/table-layout.css +16 -1
  48. package/dist/tokens/components/tree.css +2 -0
  49. package/docs/data-display/data-table/examples/server-paged.tsx +23 -0
  50. package/docs/data-display/data-table/index.md +2 -0
  51. package/docs/data-display/timeline.tsx +33 -0
  52. package/docs/data-display/tree.tsx +154 -0
  53. package/docs/data-entry/branch-scope-picker.tsx +6 -1
  54. package/docs/data-entry/record-picker.tsx +1 -0
  55. package/docs/data-entry/transfer.tsx +1 -0
  56. package/docs/i18n/messages/en.json +19 -0
  57. package/docs/i18n/messages/ja.json +18 -0
  58. package/docs/i18n/messages/vi.json +18 -0
  59. package/docs/query/infinite-query-state.tsx +5 -1
  60. package/docs/roadmap/tree-components.md +2 -1
  61. package/package.json +2 -2
  62. package/scripts/ui-audit.mjs +15 -5
@@ -26,6 +26,10 @@ import { Download, FileText, LayoutDashboard, Plus, ReceiptText } from "lucide-r
26
26
  * showTotal, onChange }}` — the real Pagination, total beside the page numbers, bottom-end. The
27
27
  * table is compact, so the pager is `size="sm"` and sits on the same control step as the toolbar
28
28
  * buttons above it. `data` is only the current page; the footer offers ceil(total / pageSize).
29
+ *
30
+ * Selection on a server-paged list (gh#1036): the header checkbox ticks THIS PAGE, and says so.
31
+ * Once the page is fully ticked, `rowSelection.matching` offers every matching row; while that is
32
+ * chosen the bulk action sends the query's filter to the server instead of a list of ids.
29
33
  */
30
34
  const sections: SidebarSectionProp[] = [
31
35
  {
@@ -86,6 +90,8 @@ export default function Demo() {
86
90
  const [page, setPage] = useState(1);
87
91
  const [pageSize, setPageSize] = useState(10);
88
92
  const rows = useMemo(() => fetchPage(page, pageSize), [page, pageSize]);
93
+ const [selectedKeys, setSelectedKeys] = useState<string[]>([]);
94
+ const [allMatching, setAllMatching] = useState(false);
89
95
 
90
96
  return (
91
97
  <AppShell
@@ -126,6 +132,17 @@ export default function Demo() {
126
132
  data={rows}
127
133
  columns={columns}
128
134
  getRowId={(row) => row.id}
135
+ rowSelection={{
136
+ selectedRowKeys: selectedKeys,
137
+ onChange: (keys) => {
138
+ setSelectedKeys(keys);
139
+ },
140
+ matching: {
141
+ total: SERVER_TOTAL,
142
+ selected: allMatching,
143
+ onSelectedChange: setAllMatching,
144
+ },
145
+ }}
129
146
  pagination={{
130
147
  total: SERVER_TOTAL,
131
148
  current: page,
@@ -144,6 +161,12 @@ export default function Demo() {
144
161
  <Download />
145
162
  エクスポート
146
163
  </Button>
164
+ {/* While "all matching" is chosen the count is the server's, not the page's. */}
165
+ <DataTable.BulkActions count={allMatching ? SERVER_TOTAL : undefined}>
166
+ <Button size="sm" variant="outline">
167
+ Mark as paid
168
+ </Button>
169
+ </DataTable.BulkActions>
147
170
  </Flex>
148
171
  <DataTable.ViewOptions />
149
172
  </DataTable.Toolbar>
@@ -5,3 +5,5 @@ The admin list primitive — sticky header, sorting, bulk selection, density tog
5
5
  See the "Approval queue" example.
6
6
 
7
7
  See the "Selectable inbox" example for a `ListRow` hosted in a selectable DataTable: mark that column `flush: true` so the cell drops its own padding and the ListRow owns the inset (gh#1016).
8
+
9
+ See the "Server paged" example for selection on a server-paged list (gh#1036). `rowSelection.selectAllLabel` names the header checkbox; `rowSelection.matching={{ total, selected, onSelectedChange }}` adds the "Select all N matching rows" banner once the page is fully ticked. antd has no equivalent of `matching` — it is the Gmail / Jira / GitHub pattern, and the one deliberate deviation from antd's `rowSelection` here. While `selected` is true every row of any page shows ticked; send the query's filter to the server instead of the ids. Unticking a row or "Clear selection" reports `false`. `selections` also takes antd's `DataTable.SELECTION_ALL` / `SELECTION_INVERT` / `SELECTION_NONE` mixed with custom entries.
@@ -230,6 +230,39 @@ export default function Demo() {
230
230
  </CardContent>
231
231
  </Card>
232
232
 
233
+ {/* sticky sits OUTSIDE a Card on purpose: Card clips (`overflow: hidden`), and a clipping
234
+ ancestor becomes the box the header sticks to — the same limit as antd Table sticky. */}
235
+ <Card>
236
+ <CardHeader>
237
+ <CardTitle level={2}>sticky · the axis stays on screen (gh#1044)</CardTitle>
238
+ <CardDescription>
239
+ A long schedule scrolled by the page, placed as a page section rather than inside a
240
+ Card (a Card clips, which would pin the header to the card instead of the viewport).
241
+ With sticky the band and tick rows stay at the top of the viewport — offsetHeader
242
+ clears a fixed app topbar — and still scroll sideways with the body.
243
+ </CardDescription>
244
+ </CardHeader>
245
+ </Card>
246
+ <RangeTimeline
247
+ data-testid="range-timeline-sticky"
248
+ label={t("rangeTimeline.schedule")}
249
+ sticky={{ offsetHeader: 0 }}
250
+ today={8}
251
+ bands={[{ label: monthLabel(8), units: 30 }]}
252
+ columns={Array.from({ length: 30 }, (_, index) => ({
253
+ label: new Intl.NumberFormat(locale).format(index + 1),
254
+ units: 1,
255
+ }))}
256
+ rows={Array.from({ length: 40 }, (_, index) => ({
257
+ id: `task-${index + 1}`,
258
+ label: `Task ${index + 1}`,
259
+ start: index % 20,
260
+ end: (index % 20) + 5,
261
+ startLabel: dayLabel((index % 20) + 1),
262
+ endLabel: dayLabel((index % 20) + 6),
263
+ }))}
264
+ />
265
+
233
266
  <Card>
234
267
  <CardHeader>
235
268
  <CardTitle level={2}>月をまたぐ日単位の軸(bands)</CardTitle>
@@ -12,7 +12,9 @@ import {
12
12
  Tree,
13
13
  type TreeNodeProp,
14
14
  } from "@godxjp/ui/data-display";
15
+ import { SearchInput } from "@godxjp/ui/data-entry";
15
16
  import { Text } from "@godxjp/ui/general";
17
+ import { useTranslation } from "@godxjp/ui/i18n";
16
18
  import { Flex, PageContainer, Separator } from "@godxjp/ui/layout";
17
19
  import { Building2, FolderTree, ShieldCheck } from "lucide-react";
18
20
 
@@ -167,7 +169,85 @@ function attachChildren(nodes: Department[], id: string, children: Department[])
167
169
  );
168
170
  }
169
171
 
172
+ // ── card 4 · a long lazy level (height + virtual · filterTreeNode · retry) ────────────────────
173
+ // The godx-task shape (gh#1041-1043): 2 suites → 28 sections → up to 421 cases per section,
174
+ // fetched when a section opens. antd's Tree has no "load more" node — `height` keeps a 421-row
175
+ // level to a few dozen DOM rows — so the one paged section below composes its own: a leaf whose
176
+ // selection asks for the next page. It is a real treeitem, so the keyboard reaches it.
177
+ const CASE_PAGE = 100;
178
+ // The data carries VALUES only; every visible label is derived from the value through `t()` at
179
+ // render (`labelSuiteTree`), so the demo follows the active locale like the library's chrome does.
180
+ const suites: TreeNodeProp[] = [1, 2].map((suiteNumber) => ({
181
+ value: `suite-${suiteNumber}`,
182
+ label: "",
183
+ children: Array.from({ length: 14 }, (_, sectionIndex) => ({
184
+ value: `suite-${suiteNumber}/section-${sectionIndex + 1}`,
185
+ label: "",
186
+ isLeaf: false,
187
+ })),
188
+ }));
189
+
190
+ function caseNodes(section: string, from: number, to: number): TreeNodeProp[] {
191
+ return Array.from({ length: to - from }, (_, index) => ({
192
+ value: `${section}/case-${from + index + 1}`,
193
+ label: "",
194
+ isLeaf: true,
195
+ }));
196
+ }
197
+
198
+ function withChildren(
199
+ nodes: TreeNodeProp[],
200
+ value: string,
201
+ children: TreeNodeProp[],
202
+ ): TreeNodeProp[] {
203
+ return nodes.map((node) =>
204
+ node.value === value
205
+ ? { ...node, children }
206
+ : node.children
207
+ ? { ...node, children: withChildren(node.children, value, children) }
208
+ : node,
209
+ );
210
+ }
211
+
212
+ function labelSuiteTree(
213
+ nodes: TreeNodeProp[],
214
+ t: ReturnType<typeof useTranslation>["t"],
215
+ ): TreeNodeProp[] {
216
+ return nodes.map((node) => {
217
+ const value = node.value;
218
+ let label: string;
219
+ if (value.endsWith("::more")) {
220
+ label = t("treeDocs.longList.loadMore", { count: CASE_PAGE });
221
+ } else if (/\/case-\d+$/.test(value)) {
222
+ label = t("treeDocs.longList.case", {
223
+ number: String(Number(value.split("case-").pop())).padStart(3, "0"),
224
+ });
225
+ } else if (/\/section-\d+$/.test(value)) {
226
+ const number = Number(value.split("section-").pop());
227
+ const key =
228
+ value === "suite-1/section-2"
229
+ ? "treeDocs.longList.sectionFailing"
230
+ : value === "suite-1/section-3"
231
+ ? "treeDocs.longList.sectionPaged"
232
+ : "treeDocs.longList.section";
233
+ label = t(key, { number, count: CASE_PAGE });
234
+ } else {
235
+ label = t(
236
+ value === "suite-1"
237
+ ? "treeDocs.longList.suiteIntegration"
238
+ : "treeDocs.longList.suiteAcceptance",
239
+ );
240
+ }
241
+ return {
242
+ ...node,
243
+ label,
244
+ children: node.children ? labelSuiteTree(node.children, t) : undefined,
245
+ };
246
+ });
247
+ }
248
+
170
249
  export default function Demo() {
250
+ const { t } = useTranslation();
171
251
  // Card 1 — checks are controlled so the readout beside the tree can never disagree with it.
172
252
  const [granted, setGranted] = React.useState<string[]>(["billing.invoice.read"]);
173
253
  // Card 2 — selection is controlled and drives the detail pane.
@@ -179,6 +259,45 @@ export default function Demo() {
179
259
  // Card 5 — independent (strict) checks, and a multi-select tree.
180
260
  const [strictChecks, setStrictChecks] = React.useState<string[]>(["people", "people.read"]);
181
261
  const [picked, setPicked] = React.useState<string[]>(["billing", "audit"]);
262
+ // Card 4 — the long lazy level.
263
+ const [suiteTree, setSuiteTree] = React.useState<TreeNodeProp[]>(suites);
264
+ const [caseQuery, setCaseQuery] = React.useState("");
265
+ // "" (not undefined) keeps the tree CONTROLLED while nothing is open.
266
+ const [openCase, setOpenCase] = React.useState("");
267
+ const failedOnce = React.useRef(false);
268
+
269
+ const loadSection = async (node: TreeNodeProp) => {
270
+ await new Promise((resolve) => setTimeout(resolve, 600));
271
+ if (node.value === "suite-1/section-2" && !failedOnce.current) {
272
+ failedOnce.current = true;
273
+ // Rejected: the branch folds back shut, and opening it again asks again (gh#1041).
274
+ throw new Error("network");
275
+ }
276
+ const paged = node.value === "suite-1/section-3";
277
+ const children = caseNodes(node.value, 0, paged ? CASE_PAGE : 421);
278
+ if (paged) {
279
+ children.push({
280
+ value: `${node.value}::more`,
281
+ label: "",
282
+ isLeaf: true,
283
+ });
284
+ }
285
+ setSuiteTree((current) => withChildren(current, node.value, children));
286
+ };
287
+
288
+ const loadMoreCases = (moreValue: string) => {
289
+ const section = moreValue.replace(/::more$/, "");
290
+ setSuiteTree((current) => {
291
+ const sectionNode = current
292
+ .flatMap((suite) => suite.children ?? [])
293
+ .find((node) => node.value === section);
294
+ const loaded = (sectionNode?.children ?? []).filter((node) => !node.value.endsWith("::more"));
295
+ const next = caseNodes(section, loaded.length, Math.min(421, loaded.length + CASE_PAGE));
296
+ const more =
297
+ loaded.length + next.length < 421 ? [{ value: moreValue, label: "", isLeaf: true }] : [];
298
+ return withChildren(current, section, [...loaded, ...next, ...more]);
299
+ });
300
+ };
182
301
 
183
302
  const loadDepartment = async (node: TreeNodeProp) => {
184
303
  // A real delay, so the Skeleton row and aria-busy are OBSERVABLE rather than theoretical.
@@ -365,6 +484,41 @@ export default function Demo() {
365
484
  </CardContent>
366
485
  </Card>
367
486
 
487
+ <Card>
488
+ <CardHeader>
489
+ <CardTitle level={2}>{t("treeDocs.longList.title")}</CardTitle>
490
+ <CardDescription>{t("treeDocs.longList.description")}</CardDescription>
491
+ </CardHeader>
492
+ <CardContent>
493
+ <Flex direction="col" gap="sm">
494
+ <SearchInput
495
+ ariaLabel={t("treeDocs.longList.searchLabel")}
496
+ placeholder={t("treeDocs.longList.searchPlaceholder")}
497
+ value={caseQuery}
498
+ onValueChange={setCaseQuery}
499
+ onSearch={() => {}}
500
+ />
501
+ <Tree
502
+ aria-label={t("treeDocs.longList.treeLabel")}
503
+ treeData={labelSuiteTree(suiteTree, t)}
504
+ height={360}
505
+ defaultExpandedValues={["suite-1"]}
506
+ loadData={loadSection}
507
+ filterTreeNode={(node) =>
508
+ caseQuery.trim() !== "" && String(node.label).includes(caseQuery.trim())
509
+ }
510
+ value={openCase}
511
+ onValueChange={(next) => {
512
+ // The "load more" leaf is an action, not a selection — it never stays selected,
513
+ // so choosing it again always asks for the next page.
514
+ if (typeof next === "string" && next.endsWith("::more")) loadMoreCases(next);
515
+ else setOpenCase((next as string | undefined) ?? "");
516
+ }}
517
+ />
518
+ </Flex>
519
+ </CardContent>
520
+ </Card>
521
+
368
522
  <Card>
369
523
  <CardHeader>
370
524
  <CardTitle level={2}>checkStrictly と multiple(チェックと選択は別の軸)</CardTitle>
@@ -54,7 +54,12 @@ export default function Demo() {
54
54
  </CardHeader>
55
55
  <CardContent>
56
56
  <FormField label="適用範囲" required>
57
- <BranchScopePicker branches={BRANCHES} value={scope} onValueChange={setScope} />
57
+ <BranchScopePicker
58
+ branches={BRANCHES}
59
+ value={scope}
60
+ onValueChange={setScope}
61
+ notFoundContent="No branch matches that search"
62
+ />
58
63
  </FormField>
59
64
  </CardContent>
60
65
  </Card>
@@ -109,6 +109,7 @@ export default function Demo() {
109
109
  value={owner}
110
110
  onValueChange={(v) => setOwner(v as string)}
111
111
  dialogTitle="Choose an owner"
112
+ notFoundContent="No one matches that name"
112
113
  />
113
114
  <Text as="p" size="2xs" tone="muted" tabular>
114
115
  value: {JSON.stringify(owner)}
@@ -104,6 +104,7 @@ export default function Demo() {
104
104
  defaultValue={[]}
105
105
  showSearch
106
106
  pagination={{ pageSize: 3 }}
107
+ locale={{ notFoundContent: ["No matching accounts", "Nothing selected yet"] }}
107
108
  name="accounts[]"
108
109
  />
109
110
  </CardContent>
@@ -1450,5 +1450,24 @@
1450
1450
  "nameColumn": "Name",
1451
1451
  "inviteTitle": "Send invitation",
1452
1452
  "send": "Send"
1453
+ },
1454
+ "treeDocs": {
1455
+ "longList": {
1456
+ "title": "Long child lists (height · virtual · filterTreeNode)",
1457
+ "description": "2 suites → 28 sections → up to 421 cases. With height the tree scrolls inside that height and renders only the rows in view (antd height + virtual). Nodes matching the search are highlighted by filterTreeNode, never hidden. Section 2 fails its first loadData and fetches again when reopened. Section 3 shows how to compose a paged API: selecting the last leaf loads the next 100.",
1458
+ "searchLabel": "Search cases",
1459
+ "searchPlaceholder": "Highlight by case name (e.g. 042)",
1460
+ "treeLabel": "Test suites",
1461
+ "suiteIntegration": "Integration tests",
1462
+ "suiteAcceptance": "Acceptance tests",
1463
+ "section": "Section {number}",
1464
+ "sectionFailing": "Section {number} (first load fails)",
1465
+ "sectionPaged": "Section {number} ({count} at a time)",
1466
+ "case": "Case {number}",
1467
+ "loadMore": {
1468
+ "one": "Load {count} more",
1469
+ "other": "Load {count} more"
1470
+ }
1471
+ }
1453
1472
  }
1454
1473
  }
@@ -1444,5 +1444,23 @@
1444
1444
  "nameColumn": "氏名",
1445
1445
  "inviteTitle": "招待を送信",
1446
1446
  "send": "送信"
1447
+ },
1448
+ "treeDocs": {
1449
+ "longList": {
1450
+ "title": "長い子リスト(height · virtual · filterTreeNode)",
1451
+ "description": "2 スイート → 28 セクション → 最大 421 ケース。height を渡すとツリー自身がその高さでスクロールし、見えている行だけを描画します(antd の height + virtual)。検索語に一致するノードは filterTreeNode で強調されるだけで、隠れません。セクション 2 は初回の loadData が失敗し、もう一度開くと再取得します。セクション 3 は API がページングされている場合の組み立て例で、末尾の葉を選ぶと次の 100 件を読み込みます。",
1452
+ "searchLabel": "ケースを検索",
1453
+ "searchPlaceholder": "ケース名で強調(例: 042)",
1454
+ "treeLabel": "テストスイート",
1455
+ "suiteIntegration": "結合テスト",
1456
+ "suiteAcceptance": "受入テスト",
1457
+ "section": "セクション {number}",
1458
+ "sectionFailing": "セクション {number}(初回の読み込みが失敗)",
1459
+ "sectionPaged": "セクション {number}({count} 件ずつ)",
1460
+ "case": "ケース {number}",
1461
+ "loadMore": {
1462
+ "other": "さらに {count} 件を読み込む"
1463
+ }
1464
+ }
1447
1465
  }
1448
1466
  }
@@ -1444,5 +1444,23 @@
1444
1444
  "nameColumn": "Họ tên",
1445
1445
  "inviteTitle": "Gửi lời mời",
1446
1446
  "send": "Gửi"
1447
+ },
1448
+ "treeDocs": {
1449
+ "longList": {
1450
+ "title": "Danh sách con dài (height · virtual · filterTreeNode)",
1451
+ "description": "2 bộ → 28 mục → tối đa 421 ca. Với height, cây cuộn trong chiều cao đó và chỉ vẽ các hàng đang thấy (antd height + virtual). Nút khớp từ tìm kiếm được filterTreeNode làm nổi bật, không bị ẩn. Mục 2 thất bại ở lần loadData đầu và tải lại khi mở lần nữa. Mục 3 là ví dụ ghép cho API phân trang: chọn lá cuối để tải thêm 100 ca.",
1452
+ "searchLabel": "Tìm ca kiểm thử",
1453
+ "searchPlaceholder": "Làm nổi bật theo tên ca (vd: 042)",
1454
+ "treeLabel": "Bộ kiểm thử",
1455
+ "suiteIntegration": "Kiểm thử tích hợp",
1456
+ "suiteAcceptance": "Kiểm thử chấp nhận",
1457
+ "section": "Mục {number}",
1458
+ "sectionFailing": "Mục {number} (lần tải đầu thất bại)",
1459
+ "sectionPaged": "Mục {number} (mỗi lần {count} ca)",
1460
+ "case": "Ca {number}",
1461
+ "loadMore": {
1462
+ "other": "Tải thêm {count} ca"
1463
+ }
1464
+ }
1447
1465
  }
1448
1466
  }
@@ -54,6 +54,7 @@ function Block() {
54
54
  flatten={flattenItemPages}
55
55
  isEmpty={(items) => items.length === 0}
56
56
  empty={<EmptyState title="アクティビティがありません" />}
57
+ loadMoreLabel="Show older activity"
57
58
  >
58
59
  {(items) => (
59
60
  <Timeline
@@ -80,7 +81,10 @@ export default function Demo() {
80
81
  <Card>
81
82
  <CardHeader>
82
83
  <CardTitle level={2}>アクティビティ</CardTitle>
83
- <CardDescription>Click 「もっと見る」 to append the next page.</CardDescription>
84
+ <CardDescription>
85
+ Click “Show older activity” to append the next page. loadMoreLabel renames the
86
+ built-in button; its pending state stays the component&apos;s.
87
+ </CardDescription>
84
88
  </CardHeader>
85
89
  <CardContent>
86
90
  <Block />
@@ -79,7 +79,8 @@ that every Ant-shaped app reaches for was never built.
79
79
  | Density | — | `size: xs \| sm \| lg` (`md` is the default, never `"default"`) |
80
80
  | Directory variant | `<DirectoryTree>` | `variant="directory"` |
81
81
  | Drag & drop | `draggable` | **out of scope for v1** — file a follow-up issue |
82
- | Virtual scroll | `virtual` / `height` | **out of scope for v1** — cap with `ScrollArea`; file a follow-up issue |
82
+ | Virtual scroll | `virtual` / `height` | `height` + `virtual` (gh#1042) — windowed rows are a flat run of treeitems; antd has no "load more" node, so none is shipped |
83
+ | Match highlight | `filterTreeNode` | `filterTreeNode` (gh#1043) — `data-filter-node="true"` for antd's `filter-node` class, plus an sr-only "matches" |
83
84
 
84
85
  Plus the house rules: forward `ref`, spread `...props`, accept `className` + `id`, export
85
86
  `TreeProp` and `TreeProp as TreeProps` from `src/props/components/data-display.prop.ts`, and
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@godxjp/ui",
3
- "version": "31.5.0",
4
- "godxUiMcp": "31.5.0",
3
+ "version": "31.6.0",
4
+ "godxUiMcp": "31.6.0",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
7
7
  "type": "git",
@@ -1916,6 +1916,16 @@ for (const dir of SCAN_DIRS) {
1916
1916
  */
1917
1917
  const stack = [];
1918
1918
  const bareByParent = new Map();
1919
+ /*
1920
+ * Every exported tag that renders @godxjp/ui's <Form> around its children (gh#1039). A field
1921
+ * under any of them has the Form layout context, so it must count like a literal <Form>.
1922
+ * `FormRoot` (@godxjp/ui/form, src/form/form-root.tsx) wraps its <form> in <Form asChild>
1923
+ * whenever a layout prop is set, and is the form shell with one stacked layout otherwise.
1924
+ * Pagination also renders <Form>, but around its own controls, not a consumer's fields. Add a
1925
+ * tag here when another exported wrapper starts rendering <Form> around children.
1926
+ */
1927
+ const FORM_TAGS = new Set(["Form", "FormRoot"]);
1928
+ const formOpenTag = new RegExp(`<(${[...FORM_TAGS].join("|")})(?=[\\s>])([^>]*)>`, "g");
1919
1929
  let formDepth = 0;
1920
1930
  for (const m of scanContent.matchAll(
1921
1931
  /(?<![\w.$])<(\/?)([A-Za-z][\w.]*)(?=[\s>/])([^<>]*?(?:\{[^{}]*\}[^<>]*?)*)(\/?)>/g,
@@ -1928,7 +1938,7 @@ for (const dir of SCAN_DIRS) {
1928
1938
  break;
1929
1939
  }
1930
1940
  }
1931
- if (tag === "Form") formDepth = Math.max(0, formDepth - 1);
1941
+ if (FORM_TAGS.has(tag)) formDepth = Math.max(0, formDepth - 1);
1932
1942
  continue;
1933
1943
  }
1934
1944
  if (tag === "FormField" && formDepth === 0) {
@@ -1937,7 +1947,7 @@ for (const dir of SCAN_DIRS) {
1937
1947
  }
1938
1948
  if (selfClose) continue;
1939
1949
  stack.push({ tag, index: m.index });
1940
- if (tag === "Form") formDepth += 1;
1950
+ if (FORM_TAGS.has(tag)) formDepth += 1;
1941
1951
  }
1942
1952
  for (const group of bareByParent.values()) {
1943
1953
  if (group.length < 2) continue;
@@ -1975,9 +1985,9 @@ for (const dir of SCAN_DIRS) {
1975
1985
  for (const m of scanContent.matchAll(/<FormField(?=[\s>])([^>]*)>([\s\S]*?)<\/FormField>/g)) {
1976
1986
  if (!/<Select(?=[\s>/])/.test(m[2]) || /controlWidth=/.test(m[1])) continue;
1977
1987
  const before = scanContent.slice(0, m.index);
1978
- const lastForm = [...before.matchAll(/<Form(?=[\s>])([^>]*)>/g)].at(-1);
1979
- const insideForm = lastForm && before.lastIndexOf("</Form>") < lastForm.index;
1980
- if (insideForm && /controlWidth=/.test(lastForm[1])) continue;
1988
+ const lastForm = [...before.matchAll(formOpenTag)].at(-1);
1989
+ const insideForm = lastForm && before.lastIndexOf(`</${lastForm[1]}>`) < lastForm.index;
1990
+ if (insideForm && /controlWidth=/.test(lastForm[2])) continue;
1981
1991
  const lineNo = lineAt(m.index);
1982
1992
  if (suppressed("select-width-hint", lineNo - 1)) continue;
1983
1993
  findings.push({