@godxjp/ui 31.14.0 → 31.16.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 (36) hide show
  1. package/agent/START-HERE.md +5 -5
  2. package/agent/components/TextDiff.json +86 -0
  3. package/agent/components/Timeline.json +4 -3
  4. package/agent/components/Tree.json +22 -1
  5. package/agent/components-index.json +7 -2
  6. package/agent/components.json +112 -4
  7. package/agent/index.json +6 -6
  8. package/agent/llms.txt +6 -6
  9. package/agent/tokens.json +42 -0
  10. package/dist/components/data-display/index.d.ts +3 -1
  11. package/dist/components/data-display/index.js +4 -0
  12. package/dist/components/data-display/text-diff.d.ts +45 -0
  13. package/dist/components/data-display/text-diff.js +240 -0
  14. package/dist/components/data-display/timeline.d.ts +12 -2
  15. package/dist/components/data-display/timeline.js +6 -2
  16. package/dist/components/data-display/tree.d.ts +1 -1
  17. package/dist/components/data-display/tree.js +147 -0
  18. package/dist/contracts/measurement.json +1 -1
  19. package/dist/i18n/messages/en.json +18 -1
  20. package/dist/i18n/messages/ja.json +15 -1
  21. package/dist/i18n/messages/vi.json +15 -1
  22. package/dist/props/components/data-display.prop.d.ts +83 -1
  23. package/dist/props/registry.d.ts +88 -0
  24. package/dist/props/registry.js +109 -0
  25. package/dist/styles/data-display-layout.css +92 -1
  26. package/dist/styles/layers.json +6 -1
  27. package/dist/tokens/components/data-display.css +8 -0
  28. package/dist/tokens/components/tree.css +3 -0
  29. package/docs/FRAME-COVERAGE-REPORT.md +3 -2
  30. package/docs/data-display/text-diff.tsx +89 -0
  31. package/docs/data-display/timeline.tsx +28 -0
  32. package/docs/data-display/tree.tsx +82 -0
  33. package/docs/i18n/messages/en.json +41 -0
  34. package/docs/i18n/messages/ja.json +41 -0
  35. package/docs/i18n/messages/vi.json +41 -0
  36. package/package.json +2 -2
@@ -0,0 +1,45 @@
1
+ import * as React from "react";
2
+ import type { TextDiffProp, TextDiffSegment } from "../../props/components/data-display.prop.js";
3
+ export type { TextDiffProp, TextDiffProp as TextDiffProps, TextDiffSegment, } from "../../props/components/data-display.prop.js";
4
+ /** The unit a change is measured in — see `TextDiffProp["granularity"]`. */
5
+ export type TextDiffGranularity = NonNullable<TextDiffProp["granularity"]>;
6
+ /**
7
+ * Cut a text into diff tokens. `line` keeps each line with its newline; `char` is one grapheme
8
+ * cluster per token (an emoji or a combining mark stays whole); `word` is `Intl.Segmenter` word
9
+ * segmentation in `lang`; `auto` is `word`, except that a CJK run is cut per character, because a
10
+ * dictionary word boundary in Japanese moves when one particle changes. A newline is always its
11
+ * own token so a changed line never swallows the line after it.
12
+ */
13
+ export declare function tokenizeText(text: string, granularity?: TextDiffGranularity, lang?: string): string[];
14
+ /**
15
+ * The diff `TextDiff` renders, for a caller that needs the runs themselves (a count of changes, a
16
+ * plain-text export). A longest-common-subsequence over {@link tokenizeText} tokens, with the
17
+ * common prefix and suffix trimmed first; adjacent runs of one kind are merged, and a removal is
18
+ * always emitted before the addition that replaces it. Past `maxCells` LCS cells the changed
19
+ * middle is one removal plus one addition — a bounded cost instead of a frozen tab.
20
+ */
21
+ export declare function diffText(before: string, after: string, options?: {
22
+ granularity?: TextDiffGranularity;
23
+ lang?: string;
24
+ maxCells?: number;
25
+ }): TextDiffSegment[];
26
+ /**
27
+ * TextDiff — what changed between two versions of a text (gh#1096): removed runs in `<del>`,
28
+ * added runs in `<ins>`, each with a struck-through or underlined mark AND a spoken start/end
29
+ * label, so the change is never carried by colour alone. Inline (one flow, GitHub's "unified")
30
+ * or split (before | after side by side); per line, per word, per character, or `auto` (words,
31
+ * with CJK cut per character). Long unchanged runs fold behind a button that reveals them.
32
+ */
33
+ export declare const TextDiff: React.ForwardRefExoticComponent<Omit<React.HTMLAttributes<HTMLDivElement>, "children"> & {
34
+ before: string;
35
+ after: string;
36
+ mode?: "inline" | "split";
37
+ granularity?: "auto" | "word" | "char" | "line";
38
+ collapseUnchanged?: boolean;
39
+ contextLines?: number;
40
+ maxCells?: number;
41
+ beforeLabel?: React.ReactNode;
42
+ afterLabel?: React.ReactNode;
43
+ lang?: string;
44
+ className?: import("../../props/index.js").ClassNameProp;
45
+ } & React.RefAttributes<HTMLDivElement>>;
@@ -0,0 +1,240 @@
1
+ "use client";
2
+ import { Fragment, jsx, jsxs } from "react/jsx-runtime";
3
+ import * as React from "react";
4
+ import { useTranslation } from "../../i18n/use-translation.js";
5
+ import { cn } from "../../lib/utils.js";
6
+ import { Button } from "../general/button.js";
7
+ import { VisuallyHidden } from "../general/visually-hidden.js";
8
+ const CJK = /[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Hangul}]/u;
9
+ const DEFAULT_MAX_CELLS = 4e6;
10
+ const MIN_HIDDEN_LINES = 2;
11
+ function segmenter(lang, granularity) {
12
+ if (typeof Intl === "undefined" || typeof Intl.Segmenter !== "function") return null;
13
+ try {
14
+ return new Intl.Segmenter(lang, { granularity });
15
+ } catch {
16
+ return new Intl.Segmenter(void 0, { granularity });
17
+ }
18
+ }
19
+ function graphemes(text, lang) {
20
+ const s = segmenter(lang, "grapheme");
21
+ return s ? Array.from(s.segment(text), (part) => part.segment) : Array.from(text);
22
+ }
23
+ function tokenizeText(text, granularity = "auto", lang) {
24
+ if (text === "") return [];
25
+ if (granularity === "line") return text.match(/[^\n]*\n|[^\n]+/g) ?? [];
26
+ if (granularity === "char") return graphemes(text, lang);
27
+ const s = segmenter(lang, "word");
28
+ const words = s ? Array.from(s.segment(text), (part) => part.segment) : text.match(/\s+|[^\s]+/g) ?? [];
29
+ const tokens = [];
30
+ for (const word of words) {
31
+ const pieces = word.length > 1 && word.includes("\n") ? word.split(/(\n)/) : [word];
32
+ for (const piece of pieces) {
33
+ if (piece === "") continue;
34
+ if (granularity === "auto" && CJK.test(piece)) tokens.push(...graphemes(piece, lang));
35
+ else tokens.push(piece);
36
+ }
37
+ }
38
+ return tokens;
39
+ }
40
+ function push(segments, kind, text) {
41
+ if (text === "") return;
42
+ const last = segments[segments.length - 1];
43
+ if (last !== void 0 && last.kind === kind) last.text += text;
44
+ else segments.push({ kind, text });
45
+ }
46
+ function diffText(before, after, options = {}) {
47
+ const { granularity = "auto", lang, maxCells = DEFAULT_MAX_CELLS } = options;
48
+ const a = tokenizeText(before, granularity, lang);
49
+ const b = tokenizeText(after, granularity, lang);
50
+ let start = 0;
51
+ while (start < a.length && start < b.length && a[start] === b[start]) start++;
52
+ let endA = a.length;
53
+ let endB = b.length;
54
+ while (endA > start && endB > start && a[endA - 1] === b[endB - 1]) {
55
+ endA--;
56
+ endB--;
57
+ }
58
+ const segments = [];
59
+ push(segments, "same", a.slice(0, start).join(""));
60
+ const midA = a.slice(start, endA);
61
+ const midB = b.slice(start, endB);
62
+ if ((midA.length + 1) * (midB.length + 1) > maxCells) {
63
+ push(segments, "removed", midA.join(""));
64
+ push(segments, "added", midB.join(""));
65
+ } else {
66
+ const width = midB.length + 1;
67
+ const lengths = new Uint32Array((midA.length + 1) * width);
68
+ for (let i2 = midA.length - 1; i2 >= 0; i2--) {
69
+ for (let j2 = midB.length - 1; j2 >= 0; j2--) {
70
+ lengths[i2 * width + j2] = midA[i2] === midB[j2] ? lengths[(i2 + 1) * width + j2 + 1] + 1 : Math.max(lengths[(i2 + 1) * width + j2], lengths[i2 * width + j2 + 1]);
71
+ }
72
+ }
73
+ let i = 0;
74
+ let j = 0;
75
+ while (i < midA.length || j < midB.length) {
76
+ if (i < midA.length && j < midB.length && midA[i] === midB[j]) {
77
+ push(segments, "same", midA[i]);
78
+ i++;
79
+ j++;
80
+ } else if (i < midA.length && (j === midB.length || lengths[(i + 1) * width + j] >= lengths[i * width + j + 1])) {
81
+ push(segments, "removed", midA[i]);
82
+ i++;
83
+ } else {
84
+ push(segments, "added", midB[j]);
85
+ j++;
86
+ }
87
+ }
88
+ }
89
+ push(segments, "same", a.slice(endA).join(""));
90
+ return segments;
91
+ }
92
+ function toLines(segments) {
93
+ const lines = [];
94
+ let current = [];
95
+ for (const segment of segments) {
96
+ for (const piece of segment.text.split(/(?<=\n)/)) {
97
+ if (piece === "") continue;
98
+ current.push({ kind: segment.kind, text: piece });
99
+ if (piece.endsWith("\n")) {
100
+ lines.push(current);
101
+ current = [];
102
+ }
103
+ }
104
+ }
105
+ if (current.length > 0) lines.push(current);
106
+ return lines;
107
+ }
108
+ function toItems(lines, collapse, context) {
109
+ const runs = [];
110
+ for (const line of lines) {
111
+ const changed = line.some((piece) => piece.kind !== "same");
112
+ const last = runs[runs.length - 1];
113
+ if (last !== void 0 && last.changed === changed) last.lines.push(line);
114
+ else runs.push({ changed, lines: [line] });
115
+ }
116
+ const anyChange = runs.some((run) => run.changed);
117
+ const items = [];
118
+ runs.forEach((run, index) => {
119
+ if (run.changed || !collapse || !anyChange) {
120
+ items.push({ type: "lines", ...run });
121
+ return;
122
+ }
123
+ const head = index === 0 ? 0 : context;
124
+ const tail = index === runs.length - 1 ? 0 : context;
125
+ const hidden = run.lines.length - head - tail;
126
+ if (hidden < MIN_HIDDEN_LINES) {
127
+ items.push({ type: "lines", ...run });
128
+ return;
129
+ }
130
+ if (head > 0) items.push({ type: "lines", changed: false, lines: run.lines.slice(0, head) });
131
+ items.push({ type: "gap", id: index, lines: run.lines.slice(head, head + hidden) });
132
+ if (tail > 0) {
133
+ items.push({ type: "lines", changed: false, lines: run.lines.slice(head + hidden) });
134
+ }
135
+ });
136
+ return items;
137
+ }
138
+ function Runs({ lines, side }) {
139
+ const { t } = useTranslation();
140
+ const runs = [];
141
+ for (const piece of lines.flat()) {
142
+ if (side === "before" && piece.kind === "added") continue;
143
+ if (side === "after" && piece.kind === "removed") continue;
144
+ push(runs, piece.kind, piece.text);
145
+ }
146
+ return /* @__PURE__ */ jsx(Fragment, { children: runs.map(
147
+ (run, index) => run.kind === "same" ? /* @__PURE__ */ jsx(React.Fragment, { children: run.text }, index) : run.kind === "removed" ? /* @__PURE__ */ jsxs("del", { className: "ui-text-diff-removed", children: [
148
+ /* @__PURE__ */ jsx(VisuallyHidden, { children: t("ui.textDiff.removedStart") }),
149
+ run.text,
150
+ /* @__PURE__ */ jsx(VisuallyHidden, { children: t("ui.textDiff.removedEnd") })
151
+ ] }, index) : /* @__PURE__ */ jsxs("ins", { className: "ui-text-diff-added", children: [
152
+ /* @__PURE__ */ jsx(VisuallyHidden, { children: t("ui.textDiff.addedStart") }),
153
+ run.text,
154
+ /* @__PURE__ */ jsx(VisuallyHidden, { children: t("ui.textDiff.addedEnd") })
155
+ ] }, index)
156
+ ) });
157
+ }
158
+ const TextDiff = React.forwardRef(function TextDiff2({
159
+ before,
160
+ after,
161
+ mode = "inline",
162
+ granularity = "auto",
163
+ collapseUnchanged = true,
164
+ contextLines = 2,
165
+ maxCells,
166
+ beforeLabel,
167
+ afterLabel,
168
+ lang,
169
+ className,
170
+ ...props
171
+ }, ref) {
172
+ const { t } = useTranslation();
173
+ const items = React.useMemo(
174
+ () => toItems(
175
+ toLines(diffText(before, after, { granularity, lang, maxCells })),
176
+ collapseUnchanged,
177
+ Math.max(0, contextLines)
178
+ ),
179
+ [before, after, granularity, lang, maxCells, collapseUnchanged, contextLines]
180
+ );
181
+ const [expanded, setExpanded] = React.useState(() => /* @__PURE__ */ new Set());
182
+ React.useEffect(() => setExpanded(/* @__PURE__ */ new Set()), [items]);
183
+ const gap = (item, split) => /* @__PURE__ */ jsx("div", { className: "ui-text-diff-gap", "data-span": split || void 0, children: /* @__PURE__ */ jsx(
184
+ Button,
185
+ {
186
+ type: "button",
187
+ variant: "ghost",
188
+ size: "xs",
189
+ onClick: () => setExpanded((previous) => new Set(previous).add(item.id)),
190
+ children: t("ui.textDiff.showUnchanged", { count: item.lines.length })
191
+ }
192
+ ) }, `gap-${item.id}`);
193
+ const resolved = items.map(
194
+ (item) => item.type === "gap" && expanded.has(item.id) ? { type: "lines", changed: false, lines: item.lines } : item
195
+ );
196
+ return /* @__PURE__ */ jsx(
197
+ "div",
198
+ {
199
+ ref,
200
+ "data-slot": "text-diff",
201
+ "data-mode": mode,
202
+ lang,
203
+ className: cn("ui-text-diff", className),
204
+ ...props,
205
+ children: mode === "split" ? /* @__PURE__ */ jsxs("div", { className: "ui-text-diff-split", children: [
206
+ /* @__PURE__ */ jsx("div", { className: "ui-text-diff-heading", children: beforeLabel ?? t("ui.textDiff.before") }),
207
+ /* @__PURE__ */ jsx("div", { className: "ui-text-diff-heading", children: afterLabel ?? t("ui.textDiff.after") }),
208
+ resolved.map(
209
+ (item, index) => item.type === "gap" ? gap(item, true) : /* @__PURE__ */ jsxs(React.Fragment, { children: [
210
+ /* @__PURE__ */ jsx(
211
+ "div",
212
+ {
213
+ className: "ui-text-diff-cell",
214
+ "data-side": "before",
215
+ "data-changed": item.changed || void 0,
216
+ children: /* @__PURE__ */ jsx(Runs, { lines: item.lines, side: "before" })
217
+ }
218
+ ),
219
+ /* @__PURE__ */ jsx(
220
+ "div",
221
+ {
222
+ className: "ui-text-diff-cell",
223
+ "data-side": "after",
224
+ "data-changed": item.changed || void 0,
225
+ children: /* @__PURE__ */ jsx(Runs, { lines: item.lines, side: "after" })
226
+ }
227
+ )
228
+ ] }, index)
229
+ )
230
+ ] }) : /* @__PURE__ */ jsx("div", { className: "ui-text-diff-body", children: resolved.map(
231
+ (item, index) => item.type === "gap" ? gap(item, false) : /* @__PURE__ */ jsx(Runs, { lines: item.lines, side: "both" }, index)
232
+ ) })
233
+ }
234
+ );
235
+ });
236
+ export {
237
+ TextDiff,
238
+ diffText,
239
+ tokenizeText
240
+ };
@@ -1,6 +1,13 @@
1
1
  import type { ReactNode } from "react";
2
2
  import { type LucideIcon } from "lucide-react";
3
- export type TimelineStatus = "done" | "current" | "pending";
3
+ /**
4
+ * `done` / `current` / `pending` are a PROCESS: steps behind, the one you are on, steps ahead.
5
+ * `log` is not a step at all — it is an entry in an activity log or history (antd's
6
+ * `<Timeline.Item color="gray">`, GitHub's conversation rail): a neutral dot, a hairline rail and
7
+ * NO screen-reader status prefix, because a thing that merely happened is neither "completed" nor
8
+ * "upcoming" (gh#1092).
9
+ */
10
+ export type TimelineStatus = "done" | "current" | "pending" | "log";
4
11
  export type TimelineVariant = "icon" | "ordinal" | "status";
5
12
  /**
6
13
  * A Timeline-local subset of the density vocabulary — it has no `comfortable` step, the same shape
@@ -14,7 +21,10 @@ export type TimelineItem = {
14
21
  note?: ReactNode;
15
22
  /** Shorthand for `status: "current"`. `status` wins when both are set. */
16
23
  current?: boolean;
17
- /** Explicit 3-state. Resolves as `status ?? */
24
+ /**
25
+ * Explicit status. Resolves as `status ?? (current ? "current" : "done")`. Use `log` for an
26
+ * activity-log / history row: neutral look, no "Completed:"/"Upcoming:" prefix.
27
+ */
18
28
  status?: TimelineStatus;
19
29
  /** Per-item glyph override; wins over the variant/status auto-glyph. */
20
30
  icon?: LucideIcon;
@@ -5,7 +5,9 @@ import { Check, CheckCircle2, Plane } from "lucide-react";
5
5
  const SR_PREFIX_KEY = {
6
6
  done: "dataDisplay.timeline.statusDone",
7
7
  current: "dataDisplay.timeline.statusCurrent",
8
- pending: "dataDisplay.timeline.statusPending"
8
+ pending: "dataDisplay.timeline.statusPending",
9
+ // A log entry carries no status semantics (antd's items have no prefix at all), gh#1092.
10
+ log: null
9
11
  };
10
12
  function resolveStatus(item) {
11
13
  if (item.status) {
@@ -27,6 +29,8 @@ function Timeline({ items, variant = "icon", density = "default" }) {
27
29
  if (item.icon) {
28
30
  const Icon = item.icon;
29
31
  glyph = /* @__PURE__ */ jsx(Icon, { "aria-hidden": "true" });
32
+ } else if (status === "log" && variant !== "ordinal") {
33
+ glyph = /* @__PURE__ */ jsx("span", { className: "ui-timeline-pip", "aria-hidden": "true" });
30
34
  } else if (variant === "ordinal") {
31
35
  glyph = /* @__PURE__ */ jsx("span", { className: "ui-timeline-ordinal", children: ordinal });
32
36
  } else if (variant === "status") {
@@ -69,7 +73,7 @@ function Timeline({ items, variant = "icon", density = "default" }) {
69
73
  /* @__PURE__ */ jsxs("div", { className: "ui-timeline-body", children: [
70
74
  /* @__PURE__ */ jsxs("div", { className: "ui-timeline-head", children: [
71
75
  /* @__PURE__ */ jsxs("span", { className: "ui-timeline-title", "data-current": isCurrent ? "true" : void 0, children: [
72
- /* @__PURE__ */ jsx("span", { className: "sr-only", children: t(SR_PREFIX_KEY[status]) }),
76
+ SR_PREFIX_KEY[status] ? /* @__PURE__ */ jsx("span", { className: "sr-only", children: t(SR_PREFIX_KEY[status]) }) : null,
73
77
  item.title
74
78
  ] }),
75
79
  item.time ? /* @__PURE__ */ jsx("span", { className: "ui-timeline-time", children: item.time }) : null
@@ -1,6 +1,6 @@
1
1
  import * as React from "react";
2
2
  import type { TreeProp } from "../../props/components/data-display.prop.js";
3
- export type { TreeNodeProp, TreeProp, TreeProp as TreeProps, } from "../../props/components/data-display.prop.js";
3
+ export type { TreeAllowDropInfoProp, TreeDropInfoProp, TreeDropPositionProp, TreeNodeProp, TreeProp, TreeProp as TreeProps, } from "../../props/components/data-display.prop.js";
4
4
  /**
5
5
  * `ref` reaches the `role="tree"` container — the element a page scrolls to, measures, or
6
6
  * focus-manages. Every unknown prop (`aria-*`, `data-*`) rides `...ariaProps` onto the same node.
@@ -71,6 +71,10 @@ function TreeRoot({
71
71
  showLine = false,
72
72
  showIcon = false,
73
73
  divided = false,
74
+ draggable = false,
75
+ allowDrag,
76
+ allowDrop,
77
+ onDrop,
74
78
  variant = "default",
75
79
  size = "md",
76
80
  disabled = false,
@@ -82,6 +86,7 @@ function TreeRoot({
82
86
  const { t } = useTranslation();
83
87
  const reactId = React.useId();
84
88
  const treeId = id ?? `${reactId}-tree`;
89
+ const dragHintId = `${treeId}-drag-hint`;
85
90
  const options = React.useMemo(
86
91
  () => normalizeTreeOptions(treeData, fieldNames),
87
92
  [treeData, fieldNames]
@@ -166,6 +171,92 @@ function TreeRoot({
166
171
  if (node) requestLoadRef.current(node);
167
172
  }
168
173
  }, [expandedSet, loadData, nodesByValue, requestLoadRef]);
174
+ const parentOf = React.useMemo(() => {
175
+ const map = /* @__PURE__ */ new Map();
176
+ const walk = (list, parent) => {
177
+ for (const node of list) {
178
+ map.set(node.value, parent);
179
+ if (node.children?.length) walk(node.children, node);
180
+ }
181
+ };
182
+ walk(options, null);
183
+ return map;
184
+ }, [options]);
185
+ const isDraggable = draggable && !disabled;
186
+ const canDrag = (node) => isDraggable && !node.disabled && (allowDrag ? allowDrag(node) : true);
187
+ const canDrop = (dragNode, dropNode, dropPosition) => {
188
+ for (let cursor = dropNode; cursor; ) {
189
+ if (cursor.value === dragNode.value) return false;
190
+ cursor = parentOf.get(cursor.value);
191
+ }
192
+ return allowDrop ? allowDrop({
193
+ dragNode,
194
+ dropNode,
195
+ dropPosition
196
+ }) : true;
197
+ };
198
+ const dragRef = React.useRef(null);
199
+ const [dragValue, setDragValue] = React.useState(null);
200
+ const [dropTarget, setDropTarget] = React.useState(null);
201
+ const [moveAnnouncement, setMoveAnnouncement] = React.useState("");
202
+ const commitDrop = (dragNode, dropNode, dropPosition) => {
203
+ onDrop?.({
204
+ dragNode,
205
+ node: dropNode,
206
+ dropPosition,
207
+ dropToGap: dropPosition !== 0
208
+ });
209
+ const key = dropPosition === -1 ? "dataDisplay.tree.movedBefore" : dropPosition === 1 ? "dataDisplay.tree.movedAfter" : "dataDisplay.tree.movedInside";
210
+ setMoveAnnouncement(
211
+ t(key, { node: reactNodeText(dragNode.label), target: reactNodeText(dropNode.label) })
212
+ );
213
+ };
214
+ const dropPositionAt = (event) => {
215
+ const rect = event.currentTarget.getBoundingClientRect();
216
+ if (rect.height <= 0) return 0;
217
+ const offset = (event.clientY - rect.top) / rect.height;
218
+ if (offset < 0.25) return -1;
219
+ if (offset > 0.75) return 1;
220
+ return 0;
221
+ };
222
+ const clearDrag = () => {
223
+ dragRef.current = null;
224
+ setDragValue(null);
225
+ setDropTarget(null);
226
+ };
227
+ const keyboardMove = (node, direction) => {
228
+ const parent = parentOf.get(node.value) ?? null;
229
+ const siblings = parent ? parent.children ?? [] : options;
230
+ const position = siblings.findIndex((entry) => entry.value === node.value);
231
+ let target;
232
+ let dropPosition;
233
+ if (direction === "up") {
234
+ target = siblings[position - 1];
235
+ dropPosition = -1;
236
+ } else if (direction === "down") {
237
+ target = siblings[position + 1];
238
+ dropPosition = 1;
239
+ } else if (direction === "out") {
240
+ target = parent;
241
+ dropPosition = 1;
242
+ } else {
243
+ const previous = siblings[position - 1];
244
+ const lastChild = previous?.children?.[previous.children.length - 1];
245
+ target = lastChild ?? previous;
246
+ dropPosition = lastChild ? 1 : 0;
247
+ }
248
+ if (!target || !canDrag(node) || !canDrop(node, target, dropPosition)) {
249
+ setMoveAnnouncement(t("dataDisplay.tree.moveRefused", { node: reactNodeText(node.label) }));
250
+ return;
251
+ }
252
+ if (direction === "in") {
253
+ const host = dropPosition === 0 ? target : siblings[position - 1];
254
+ if (host && !expandedSet.has(host.value)) commitExpanded([...expanded, host.value]);
255
+ }
256
+ commitDrop(node, target, dropPosition);
257
+ setActiveValue(node.value);
258
+ pendingFocus.current = node.value;
259
+ };
169
260
  const visible = flattenVisibleTree(options, expandedSet);
170
261
  const visibleIndex = new Map(visible.map((entry, index) => [entry.node.value, index]));
171
262
  const [activeValue, setActiveValue] = React.useState(null);
@@ -300,6 +391,14 @@ function TreeRoot({
300
391
  const rtl = isRtl(event.currentTarget);
301
392
  const inward = rtl ? "ArrowLeft" : "ArrowRight";
302
393
  const outward = rtl ? "ArrowRight" : "ArrowLeft";
394
+ if (isDraggable && event.altKey && (event.key === "ArrowUp" || event.key === "ArrowDown" || event.key === inward || event.key === outward)) {
395
+ event.preventDefault();
396
+ keyboardMove(
397
+ node,
398
+ event.key === "ArrowUp" ? "up" : event.key === "ArrowDown" ? "down" : event.key === inward ? "in" : "out"
399
+ );
400
+ return;
401
+ }
303
402
  if (event.key === "ArrowDown") {
304
403
  event.preventDefault();
305
404
  moveTo(index + 1);
@@ -366,6 +465,8 @@ function TreeRoot({
366
465
  const checkState = checkableProp ? checkStateOf(node) : "unchecked";
367
466
  const nodeDisabled = disabled || Boolean(node.disabled);
368
467
  const labelId = `${treeId}-${node.value}-label`;
468
+ const nodeDraggable = canDrag(node);
469
+ const dropHere = dropTarget?.value === node.value ? dropTarget.position : void 0;
369
470
  const glyph = node.icon ?? (variant === "directory" ? hasChildren ? isOpen ? /* @__PURE__ */ jsx(FolderOpen, {}) : /* @__PURE__ */ jsx(Folder, {}) : /* @__PURE__ */ jsx(FileIcon, {}) : null);
370
471
  return /* @__PURE__ */ jsxs(
371
472
  "div",
@@ -395,6 +496,47 @@ function TreeRoot({
395
496
  "data-selected": isSelected ? "true" : void 0,
396
497
  "data-disabled": nodeDisabled ? "" : void 0,
397
498
  "data-filter-node": isFilterMatch ? "true" : void 0,
499
+ draggable: nodeDraggable || void 0,
500
+ "data-dragging": dragValue === node.value ? "true" : void 0,
501
+ "data-drop-position": dropHere === void 0 ? void 0 : dropHere === -1 ? "before" : dropHere === 1 ? "after" : "inside",
502
+ onDragStart: isDraggable ? (event) => {
503
+ if (!nodeDraggable) {
504
+ event.preventDefault();
505
+ return;
506
+ }
507
+ event.dataTransfer.effectAllowed = "move";
508
+ event.dataTransfer.setData("text/plain", node.value);
509
+ dragRef.current = node.value;
510
+ setDragValue(node.value);
511
+ } : void 0,
512
+ onDragOver: isDraggable ? (event) => {
513
+ const dragged = dragRef.current === null ? void 0 : nodesByValue.get(dragRef.current);
514
+ if (!dragged) return;
515
+ const position = dropPositionAt(event);
516
+ if (!canDrop(dragged, node, position)) {
517
+ if (dropTarget) setDropTarget(null);
518
+ return;
519
+ }
520
+ event.preventDefault();
521
+ event.dataTransfer.dropEffect = "move";
522
+ if (dropTarget?.value !== node.value || dropTarget.position !== position) {
523
+ setDropTarget({ value: node.value, position });
524
+ }
525
+ } : void 0,
526
+ onDragLeave: isDraggable ? (event) => {
527
+ if (event.currentTarget.contains(event.relatedTarget)) return;
528
+ if (dropTarget?.value === node.value) setDropTarget(null);
529
+ } : void 0,
530
+ onDrop: isDraggable ? (event) => {
531
+ event.preventDefault();
532
+ const dragged = dragRef.current === null ? void 0 : nodesByValue.get(dragRef.current);
533
+ const position = dropPositionAt(event);
534
+ if (dragged && canDrop(dragged, node, position)) {
535
+ commitDrop(dragged, node, position);
536
+ }
537
+ clearDrag();
538
+ } : void 0,
539
+ onDragEnd: isDraggable ? clearDrag : void 0,
398
540
  className: "ui-tree-node ui-focus-ring",
399
541
  style: { "--tree-node-level": depth },
400
542
  children: [
@@ -549,6 +691,7 @@ function TreeRoot({
549
691
  "div",
550
692
  {
551
693
  ...ariaProps,
694
+ "aria-describedby": isDraggable ? [ariaProps["aria-describedby"], dragHintId].filter(Boolean).join(" ") : ariaProps["aria-describedby"],
552
695
  ref: mergeRefs(forwardedRef, treeRef),
553
696
  id: treeId,
554
697
  role: "tree",
@@ -602,6 +745,10 @@ function TreeRoot({
602
745
  ]
603
746
  }
604
747
  ),
748
+ isDraggable ? /* @__PURE__ */ jsxs(Fragment, { children: [
749
+ /* @__PURE__ */ jsx("span", { id: dragHintId, className: "sr-only", children: t("dataDisplay.tree.dragHint") }),
750
+ /* @__PURE__ */ jsx("span", { role: "status", "aria-live": "polite", className: "sr-only", children: moveAnnouncement })
751
+ ] }) : null,
605
752
  isEmpty ? /* @__PURE__ */ jsx("p", { role: "status", className: "ui-tree-empty", children: t("dataDisplay.tree.empty") }) : null
606
753
  ] });
607
754
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "$comment": "AUTO-GENERATED by scripts/gen-measurement-contract.mjs — do not edit. Read this instead of guessing: docs/MEASUREMENT-CONTRACT.md.",
3
- "version": "31.14.0",
3
+ "version": "31.16.0",
4
4
  "targetSize": {
5
5
  "standard": "WCAG 2.2 SC 2.5.8 Target Size (Minimum), level AA — 24×24 CSS px",
6
6
  "min": 24,
@@ -364,7 +364,12 @@
364
364
  "loading": "Loading child nodes…",
365
365
  "empty": "Nothing to show",
366
366
  "selected": "Selected",
367
- "filterMatch": "Matches the filter"
367
+ "filterMatch": "Matches the filter",
368
+ "dragHint": "Alt plus arrow keys move the focused item: up and down past a sibling, left to outdent, right to indent.",
369
+ "movedBefore": "{node} moved before {target}",
370
+ "movedAfter": "{node} moved after {target}",
371
+ "movedInside": "{node} moved into {target}",
372
+ "moveRefused": "{node} cannot move there"
368
373
  },
369
374
  "thoughtChain": {
370
375
  "label": "Reasoning steps",
@@ -452,6 +457,18 @@
452
457
  "copy": "Copy code",
453
458
  "copied": "Copied",
454
459
  "copiedAnnounce": "Code copied to clipboard"
460
+ },
461
+ "textDiff": {
462
+ "before": "Before",
463
+ "after": "After",
464
+ "removedStart": "[removed: ",
465
+ "removedEnd": "]",
466
+ "addedStart": "[added: ",
467
+ "addedEnd": "]",
468
+ "showUnchanged": {
469
+ "one": "Show {count} unchanged line",
470
+ "other": "Show {count} unchanged lines"
471
+ }
455
472
  }
456
473
  },
457
474
  "navigation": {
@@ -357,7 +357,12 @@
357
357
  "loading": "子ノードを読み込み中…",
358
358
  "empty": "表示する項目がありません",
359
359
  "selected": "選択中",
360
- "filterMatch": "フィルターに一致"
360
+ "filterMatch": "フィルターに一致",
361
+ "dragHint": "Alt キーと矢印キーでフォーカス中の項目を移動します。上下で兄弟の前後へ、左で階層を上げ、右で階層を下げます。",
362
+ "movedBefore": "{node} を {target} の前に移動しました",
363
+ "movedAfter": "{node} を {target} の後に移動しました",
364
+ "movedInside": "{node} を {target} の中に移動しました",
365
+ "moveRefused": "{node} はそこへ移動できません"
361
366
  },
362
367
  "thoughtChain": {
363
368
  "label": "思考の手順",
@@ -445,6 +450,15 @@
445
450
  "copy": "コードをコピー",
446
451
  "copied": "コピーしました",
447
452
  "copiedAnnounce": "コードをクリップボードにコピーしました"
453
+ },
454
+ "textDiff": {
455
+ "before": "変更前",
456
+ "after": "変更後",
457
+ "removedStart": "[削除: ",
458
+ "removedEnd": "]",
459
+ "addedStart": "[追加: ",
460
+ "addedEnd": "]",
461
+ "showUnchanged": "変更のない{count}行を表示"
448
462
  }
449
463
  },
450
464
  "navigation": {
@@ -358,7 +358,12 @@
358
358
  "loading": "Đang tải nút con…",
359
359
  "empty": "Không có gì để hiển thị",
360
360
  "selected": "Đã chọn",
361
- "filterMatch": "Khớp bộ lọc"
361
+ "filterMatch": "Khớp bộ lọc",
362
+ "dragHint": "Alt cùng phím mũi tên di chuyển mục đang chọn: lên và xuống qua mục ngang hàng, trái để lùi cấp, phải để thụt cấp.",
363
+ "movedBefore": "Đã chuyển {node} lên trước {target}",
364
+ "movedAfter": "Đã chuyển {node} xuống sau {target}",
365
+ "movedInside": "Đã chuyển {node} vào trong {target}",
366
+ "moveRefused": "Không thể chuyển {node} tới đó"
362
367
  },
363
368
  "thoughtChain": {
364
369
  "label": "Các bước suy luận",
@@ -446,6 +451,15 @@
446
451
  "copy": "Sao chép mã",
447
452
  "copied": "Đã sao chép",
448
453
  "copiedAnnounce": "Đã sao chép mã vào bộ nhớ tạm"
454
+ },
455
+ "textDiff": {
456
+ "before": "Trước",
457
+ "after": "Sau",
458
+ "removedStart": "[đã xoá: ",
459
+ "removedEnd": "]",
460
+ "addedStart": "[đã thêm: ",
461
+ "addedEnd": "]",
462
+ "showUnchanged": "Hiện {count} dòng không đổi"
449
463
  }
450
464
  },
451
465
  "navigation": {