@rainbow-oh/yee-c 0.6.4 → 0.8.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 (145) hide show
  1. package/dist/cjs/Button/style/index.less +5 -4
  2. package/dist/cjs/Drawer/drawer.js +1 -1
  3. package/dist/cjs/Drawer/interface.d.ts +1 -1
  4. package/dist/cjs/Drawer/style/index.less +1 -0
  5. package/dist/cjs/Form/form-store.d.ts +2 -1
  6. package/dist/cjs/Form/form-store.js +32 -17
  7. package/dist/cjs/List/demo/virtual.d.ts +3 -0
  8. package/dist/cjs/List/demo/virtual.js +53 -0
  9. package/dist/cjs/List/hooks/useListKeyboard.d.ts +28 -0
  10. package/dist/cjs/List/hooks/useListKeyboard.js +188 -0
  11. package/dist/cjs/List/interface.d.ts +29 -2
  12. package/dist/cjs/List/list.js +141 -21
  13. package/dist/cjs/List/style/index.less +24 -0
  14. package/dist/cjs/Select/demo/columns.d.ts +3 -0
  15. package/dist/cjs/Select/demo/columns.js +62 -0
  16. package/dist/cjs/Select/hooks/useSelectKeyboard.d.ts +13 -1
  17. package/dist/cjs/Select/hooks/useSelectKeyboard.js +81 -10
  18. package/dist/cjs/Select/interface.d.ts +30 -3
  19. package/dist/cjs/Select/option.js +4 -3
  20. package/dist/cjs/Select/options.js +90 -8
  21. package/dist/cjs/Select/select.js +32 -8
  22. package/dist/cjs/Select/style/index.less +14 -0
  23. package/dist/cjs/Selector/interface.d.ts +4 -0
  24. package/dist/cjs/Selector/selector.js +5 -3
  25. package/dist/cjs/Table/hooks/useColumns.js +1 -1
  26. package/dist/cjs/Table/interface.d.ts +1 -1
  27. package/dist/cjs/Table/style/index.less +35 -11
  28. package/dist/cjs/Table/table.js +57 -7
  29. package/dist/cjs/Table/tbody/cell.js +1 -1
  30. package/dist/cjs/Table/util/header.d.ts +3 -1
  31. package/dist/cjs/Table/util/header.js +30 -3
  32. package/dist/cjs/Tree/demo/draggable.js +2 -0
  33. package/dist/cjs/Tree/interface.d.ts +46 -23
  34. package/dist/cjs/Tree/tree-node.d.ts +1 -1
  35. package/dist/cjs/Tree/tree-node.js +30 -23
  36. package/dist/cjs/Tree/tree.d.ts +13 -12
  37. package/dist/cjs/Tree/tree.js +452 -227
  38. package/dist/cjs/Tree/utils/tree-tools.d.ts +43 -10
  39. package/dist/cjs/Tree/utils/tree-tools.js +129 -91
  40. package/dist/cjs/locale/context.d.ts +5 -4
  41. package/dist/cjs/locale/context.js +15 -8
  42. package/dist/cjs/locale/interface.d.ts +20 -0
  43. package/dist/cjs/locale/util.d.ts +3 -3
  44. package/dist/cjs/style/color.css +3 -0
  45. package/dist/cjs/style/size.css +2 -1
  46. package/dist/cjs/style/themes.css +1 -1
  47. package/dist/cjs/style/variables.css +6 -2
  48. package/dist/cjs/utils/isEqual.js +5 -7
  49. package/dist/cjs/version.d.ts +1 -1
  50. package/dist/cjs/version.js +1 -1
  51. package/dist/esm/Button/style/index.less +5 -4
  52. package/dist/esm/Drawer/drawer.js +2 -1
  53. package/dist/esm/Drawer/interface.d.ts +1 -1
  54. package/dist/esm/Drawer/style/index.less +1 -0
  55. package/dist/esm/Form/form-store.d.ts +2 -1
  56. package/dist/esm/Form/form-store.js +40 -19
  57. package/dist/esm/List/demo/virtual.d.ts +3 -0
  58. package/dist/esm/List/demo/virtual.js +22 -0
  59. package/dist/esm/List/hooks/useListKeyboard.d.ts +28 -0
  60. package/dist/esm/List/hooks/useListKeyboard.js +173 -0
  61. package/dist/esm/List/interface.d.ts +29 -2
  62. package/dist/esm/List/list.js +171 -14
  63. package/dist/esm/List/style/index.less +24 -0
  64. package/dist/esm/Select/demo/columns.d.ts +3 -0
  65. package/dist/esm/Select/demo/columns.js +36 -0
  66. package/dist/esm/Select/hooks/useSelectKeyboard.d.ts +13 -1
  67. package/dist/esm/Select/hooks/useSelectKeyboard.js +91 -14
  68. package/dist/esm/Select/interface.d.ts +30 -3
  69. package/dist/esm/Select/option.js +6 -2
  70. package/dist/esm/Select/options.js +114 -32
  71. package/dist/esm/Select/select.js +40 -11
  72. package/dist/esm/Select/style/index.less +14 -0
  73. package/dist/esm/Selector/interface.d.ts +4 -0
  74. package/dist/esm/Selector/selector.js +10 -4
  75. package/dist/esm/Table/hooks/useColumns.js +1 -1
  76. package/dist/esm/Table/interface.d.ts +1 -1
  77. package/dist/esm/Table/style/index.less +35 -11
  78. package/dist/esm/Table/table.js +54 -2
  79. package/dist/esm/Table/tbody/cell.js +1 -1
  80. package/dist/esm/Table/util/header.d.ts +3 -1
  81. package/dist/esm/Table/util/header.js +30 -2
  82. package/dist/esm/Tree/demo/draggable.js +2 -0
  83. package/dist/esm/Tree/interface.d.ts +46 -23
  84. package/dist/esm/Tree/tree-node.d.ts +1 -1
  85. package/dist/esm/Tree/tree-node.js +26 -31
  86. package/dist/esm/Tree/tree.d.ts +13 -12
  87. package/dist/esm/Tree/tree.js +572 -163
  88. package/dist/esm/Tree/utils/tree-tools.d.ts +43 -10
  89. package/dist/esm/Tree/utils/tree-tools.js +219 -104
  90. package/dist/esm/locale/context.d.ts +5 -4
  91. package/dist/esm/locale/context.js +25 -9
  92. package/dist/esm/locale/interface.d.ts +20 -0
  93. package/dist/esm/locale/util.d.ts +3 -3
  94. package/dist/esm/style/color.css +3 -0
  95. package/dist/esm/style/size.css +2 -1
  96. package/dist/esm/style/themes.css +1 -1
  97. package/dist/esm/style/variables.css +6 -2
  98. package/dist/esm/utils/isEqual.js +5 -7
  99. package/dist/esm/version.d.ts +1 -1
  100. package/dist/esm/version.js +1 -1
  101. package/dist/umd/yee-c.min.css +4 -4
  102. package/dist/umd/yee-c.min.js +1 -1
  103. package/package.json +1 -1
  104. package/src/Button/style/index.less +5 -4
  105. package/src/Drawer/drawer.tsx +1 -1
  106. package/src/Drawer/interface.ts +1 -0
  107. package/src/Drawer/style/index.less +1 -0
  108. package/src/Form/form-store.ts +42 -15
  109. package/src/List/demo/virtual.tsx +20 -0
  110. package/src/List/hooks/useListKeyboard.ts +235 -0
  111. package/src/List/index.en-US.md +6 -0
  112. package/src/List/index.zh-CN.md +6 -0
  113. package/src/List/interface.ts +29 -2
  114. package/src/List/list.tsx +157 -19
  115. package/src/List/style/index.less +24 -0
  116. package/src/Select/demo/columns.tsx +35 -0
  117. package/src/Select/hooks/useSelectKeyboard.ts +112 -15
  118. package/src/Select/index.en-US.md +4 -0
  119. package/src/Select/index.zh-CN.md +4 -0
  120. package/src/Select/interface.ts +31 -4
  121. package/src/Select/option.tsx +9 -3
  122. package/src/Select/options.tsx +119 -26
  123. package/src/Select/select.tsx +41 -8
  124. package/src/Select/style/index.less +14 -0
  125. package/src/Selector/interface.ts +4 -0
  126. package/src/Selector/selector.tsx +8 -3
  127. package/src/Table/hooks/useColumns.ts +1 -1
  128. package/src/Table/interface.ts +1 -1
  129. package/src/Table/style/index.less +35 -11
  130. package/src/Table/table.tsx +60 -1
  131. package/src/Table/tbody/cell.tsx +1 -1
  132. package/src/Table/util/header.ts +48 -2
  133. package/src/Tree/demo/draggable.tsx +2 -0
  134. package/src/Tree/interface.ts +46 -23
  135. package/src/Tree/tree-node.tsx +26 -29
  136. package/src/Tree/tree.tsx +573 -274
  137. package/src/Tree/utils/tree-tools.ts +231 -125
  138. package/src/locale/_README.md +22 -13
  139. package/src/locale/context.tsx +33 -16
  140. package/src/locale/interface.ts +24 -0
  141. package/src/locale/util.ts +6 -6
  142. package/src/style/color.css +3 -0
  143. package/src/style/size.css +2 -1
  144. package/src/utils/isEqual.ts +5 -7
  145. package/src/version.ts +1 -1
@@ -6,6 +6,17 @@ export interface MoveInfo {
6
6
  dragKey: string | number;
7
7
  dropKey: string | number;
8
8
  position: DragPosition;
9
+ /**
10
+ * Full key path from root to the dragged node (inclusive).
11
+ * When provided, enables precise matching even with non-unique keys.
12
+ * Example: ['root', 'parent', 'child']
13
+ */
14
+ dragPath?: Array<string | number>;
15
+ /**
16
+ * Full key path from root to the drop target node (inclusive).
17
+ * When provided, enables precise matching even with non-unique keys.
18
+ */
19
+ dropPath?: Array<string | number>;
9
20
  }
10
21
 
11
22
  /**
@@ -16,6 +27,10 @@ export interface MoveInfo {
16
27
  * nodes are reused by reference (data may carry non-cloneable values like
17
28
  * React elements / functions in `icon`).
18
29
  *
30
+ * When `dragPath` / `dropPath` are provided in `info`, matching is done by
31
+ * walking the exact path (supports non-unique keys). Otherwise falls back to
32
+ * first-match by key (backward compatible).
33
+ *
19
34
  * Returns a brand-new root array; returns the input unchanged if dragKey is
20
35
  * not found or drag and drop resolve to the same location.
21
36
  */
@@ -27,29 +42,66 @@ export function moveTreeNode<T extends Record<string, unknown>>(
27
42
  if (!tree || (Array.isArray(tree) && tree.length === 0)) return [];
28
43
 
29
44
  const { key: K, children: C } = fieldNames;
30
- const { dragKey, dropKey, position } = info;
45
+ const { dragKey, dropKey, position, dragPath, dropPath } = info;
31
46
  const root = Array.isArray(tree) ? tree : [tree];
32
47
 
48
+ /**
49
+ * Match a node: when a path is provided, match by comparing node key against
50
+ * the expected key at the current depth; otherwise match by key equality.
51
+ */
52
+ const matchNode = (
53
+ node: T,
54
+ targetKey: string | number,
55
+ path: Array<string | number> | undefined,
56
+ depth: number,
57
+ ): boolean => {
58
+ if (path) {
59
+ return String(node[K]) === String(path[depth]);
60
+ }
61
+ return node[K] === targetKey;
62
+ };
63
+
64
+ const isTargetDepth = (
65
+ path: Array<string | number> | undefined,
66
+ depth: number,
67
+ ): boolean => {
68
+ if (!path) return true; // no path constraint, match at any depth
69
+ return depth === path.length - 1;
70
+ };
71
+
33
72
  // Phase 1: detach the drag node subtree (immutably).
34
- // Returns the rebuilt array (without the drag node) plus the detached node.
35
73
  const detach = (
36
74
  nodes: T[],
75
+ depth: number,
37
76
  ): { found: T | null; nodes: T[] } => {
38
77
  let found: T | null = null;
39
78
  const next: T[] = [];
40
79
  for (const n of nodes) {
41
- if (n[K] === dragKey) {
42
- found = n; // keep the original subtree reference intact
80
+ if (
81
+ !found &&
82
+ matchNode(n, dragKey, dragPath, depth) &&
83
+ isTargetDepth(dragPath, depth)
84
+ ) {
85
+ found = n;
43
86
  continue;
44
87
  }
45
88
  const kids = n[C] as T[] | undefined;
46
- if (Array.isArray(kids)) {
47
- const r = detach(kids);
89
+ if (
90
+ !found &&
91
+ dragPath &&
92
+ Array.isArray(kids) &&
93
+ !matchNode(n, dragKey, dragPath, depth)
94
+ ) {
95
+ // A path lets us skip branches that cannot contain the drag node.
96
+ next.push(n);
97
+ } else if (!found && Array.isArray(kids)) {
98
+ // On the drag path (or no path constraint), recurse
99
+ const r = detach(kids, depth + 1);
48
100
  if (r.found) {
49
101
  found = r.found;
50
102
  next.push({ ...n, [C]: r.nodes });
51
103
  } else {
52
- next.push(n); // unchanged branch: reuse original ref
104
+ next.push(n);
53
105
  }
54
106
  } else {
55
107
  next.push(n);
@@ -58,38 +110,43 @@ export function moveTreeNode<T extends Record<string, unknown>>(
58
110
  return { found, nodes: next };
59
111
  };
60
112
 
61
- const { found: dragNode, nodes: withoutDrag } = detach(root);
62
- if (!dragNode) return root; // dragKey not found: no-op
113
+ const { found: dragNode, nodes: withoutDrag } = detach(root, 0);
114
+ if (!dragNode) return root;
63
115
 
64
- // Phase 2: insert per position into the tree that no longer contains the drag node.
65
- // Note: do NOT short-circuit with an early return once inserted — siblings that
66
- // follow the matched subtree must still be copied (by reference) or they'd be lost.
67
- const insert = (nodes: T[]): { inserted: boolean; nodes: T[] } => {
116
+ // Phase 2: insert per position.
117
+ const insert = (nodes: T[], depth: number): { inserted: boolean; nodes: T[] } => {
68
118
  const next: T[] = [];
69
119
  let inserted = false;
70
120
  for (const n of nodes) {
71
- if (!inserted && position === 'inside' && n[K] === dropKey) {
121
+ const isTarget =
122
+ !inserted &&
123
+ matchNode(n, dropKey, dropPath, depth) &&
124
+ isTargetDepth(dropPath, depth);
125
+
126
+ if (isTarget && position === 'inside') {
72
127
  const oldKids = (Array.isArray(n[C]) ? n[C] : []) as T[];
73
128
  next.push({ ...n, [C]: [...oldKids, dragNode] });
74
129
  inserted = true;
75
130
  continue;
76
131
  }
77
- if (!inserted && position === 'before' && n[K] === dropKey) {
132
+ if (isTarget && position === 'before') {
78
133
  next.push(dragNode, n);
79
134
  inserted = true;
80
135
  continue;
81
136
  }
82
- if (!inserted && position === 'after' && n[K] === dropKey) {
137
+ if (isTarget && position === 'after') {
83
138
  next.push(n, dragNode);
84
139
  inserted = true;
85
140
  continue;
86
141
  }
87
142
  const kids = n[C] as T[] | undefined;
88
143
  if (inserted || !Array.isArray(kids)) {
89
- // Already inserted, or a leaf with no subtree to search: keep by ref.
144
+ next.push(n);
145
+ } else if (dropPath && !matchNode(n, dropKey, dropPath, depth)) {
146
+ // Not on the drop path at this level, skip recursion
90
147
  next.push(n);
91
148
  } else {
92
- const r = insert(kids);
149
+ const r = insert(kids, depth + 1);
93
150
  if (r.inserted) {
94
151
  next.push({ ...n, [C]: r.nodes });
95
152
  inserted = true;
@@ -101,34 +158,20 @@ export function moveTreeNode<T extends Record<string, unknown>>(
101
158
  return { inserted, nodes: next };
102
159
  };
103
160
 
104
- const { inserted, nodes: result } = insert(withoutDrag);
105
- // If the drop target wasn't found, nothing should change — return the
106
- // original (drag node stays put) rather than silently dropping it.
161
+ const { inserted, nodes: result } = insert(withoutDrag, 0);
107
162
  return inserted ? result : root;
108
163
  }
109
164
 
110
- export function getChildKeys<T extends Record<string, unknown>>(
111
- data: T,
112
- fieldNames: {
113
- key: keyof T;
114
- children: keyof T;
115
- },
116
- ): Array<string | number> {
117
- const { key, children } = fieldNames;
118
- const _children = data[children];
119
- const keys: Array<string | number> = [];
120
- if (Array.isArray(_children) && _children.length) {
121
- _children.forEach((node) => {
122
- keys.push(node[key] as string | number);
123
-
124
- if (Array.isArray(node[children])) {
125
- keys.push(...getChildKeys(node, fieldNames));
126
- }
127
- });
128
- }
129
- return keys;
130
- }
131
-
165
+ /**
166
+ * Flatten tree into a flat array with rich metadata.
167
+ *
168
+ * Key design decisions:
169
+ * - Uses `uid` (built from the key path) as the internal identifier, because
170
+ * user-provided `key` values may NOT be globally unique across the tree.
171
+ * - The returned Map is keyed by `uid` for O(1) lookups.
172
+ * - `childUids` is collected bottom-up in a single pass (O(n) total) instead
173
+ * of per-node recursive enumeration (which was O(n²)).
174
+ */
132
175
  export function tree2array<T extends Record<string, unknown>>(
133
176
  tree: T | T[],
134
177
  fieldNames: {
@@ -142,140 +185,203 @@ export function tree2array<T extends Record<string, unknown>>(
142
185
 
143
186
  const data = Array.isArray(tree) ? tree : [tree];
144
187
  const res: TreeNode<T>[] = [];
145
- const map = new Map();
188
+ const map = new Map<string, TreeNode<T>>();
146
189
 
147
190
  const { key, label, children } = fieldNames;
148
191
 
192
+ /**
193
+ * Walk returns the list of descendant uids so the parent can collect them
194
+ * without a second traversal.
195
+ * Pushes nodes in PRE-ORDER (parent before children) so that visibleItems
196
+ * filtering can work in a single forward pass.
197
+ */
149
198
  function walk({
150
- data,
151
- pKey,
199
+ data: node,
200
+ pUid,
152
201
  depth,
153
202
  path,
154
203
  isLast,
155
204
  lines,
156
205
  }: {
157
206
  data: T;
158
- pKey: string | null;
207
+ pUid: string | null;
159
208
  depth: number;
160
- path: string[];
209
+ path: Array<string | number>;
161
210
  isLast: boolean;
162
211
  lines: number[];
163
- }) {
164
- const _key = data[key] as string;
165
- const _label = data[label];
166
- const _children = data[children] as T[] | undefined;
167
- const nextPath = [...path, _key];
168
- const uid = nextPath.join('_');
169
-
170
- const isParent = !!(
171
- Array.isArray(_children) &&
172
- (_children as Array<Record<string, unknown>>).length
173
- );
174
-
175
- const wrapperData = {
212
+ }): string[] {
213
+ const rawKey = node[key] as string | number;
214
+ const _label = node[label];
215
+ const _children = node[children] as T[] | undefined;
216
+ const nextPath = [...path, rawKey];
217
+ const uid = pUid ? `${pUid}\x00${String(rawKey)}` : String(rawKey);
218
+
219
+ const isParent = Array.isArray(_children) && _children.length > 0;
220
+
221
+ // Push parent FIRST (pre-order) so visibleItems can rely on forward scan
222
+ const wrapperData: TreeNode<T> = {
176
223
  uid,
177
- key: _key,
178
- label: _label,
224
+ key: rawKey,
225
+ label: _label as string,
226
+ title: node.title as string | undefined,
179
227
  path,
180
- pKey,
228
+ pUid,
181
229
  depth,
182
230
  isLeaf: !isParent,
183
- isLast: isLast,
184
- lines: lines,
185
- disabled: data.disabled,
186
- icon: data.icon,
187
- childKeys: getChildKeys<T>(data, fieldNames),
188
- original: data,
189
- } as TreeNode<T>;
231
+ isLast,
232
+ lines,
233
+ disabled: node.disabled as boolean | undefined,
234
+ icon: node.icon as React.ReactNode | (() => React.ReactNode) | undefined,
235
+ childUids: undefined, // will be filled after recursion
236
+ original: node,
237
+ };
190
238
 
191
239
  res.push(wrapperData);
192
- map.set(_key, wrapperData);
193
- const total = Array.isArray(_children) ? _children.length : 0;
194
- _children?.forEach((node, index) =>
195
- walk({
196
- data: node,
197
- pKey: _key,
198
- depth: depth + 1,
199
- path: nextPath,
200
- isLast: index + 1 === total,
201
- lines: isLast ? lines : [...lines, depth],
202
- }),
203
- );
240
+ map.set(uid, wrapperData);
241
+
242
+ // Recurse children to collect their uids bottom-up
243
+ const childTotal = isParent ? _children!.length : 0;
244
+ const allDescendantUids: string[] = [];
245
+
246
+ if (isParent) {
247
+ _children!.forEach((child, index) => {
248
+ const childIsLast = index + 1 === childTotal;
249
+ const descendantUids = walk({
250
+ data: child,
251
+ pUid: uid,
252
+ depth: depth + 1,
253
+ path: nextPath,
254
+ isLast: childIsLast,
255
+ lines: isLast ? lines : [...lines, depth],
256
+ });
257
+ allDescendantUids.push(...descendantUids);
258
+ });
259
+ }
260
+
261
+ // Now fill in childUids
262
+ if (allDescendantUids.length > 0) {
263
+ wrapperData.childUids = allDescendantUids;
264
+ }
265
+
266
+ // Return this uid + all descendant uids to the caller
267
+ return [uid, ...allDescendantUids];
204
268
  }
205
269
 
206
- const total = Array.isArray(data) ? data.length : 0;
207
- data.forEach((node, index) =>
270
+ const rootTotal = data.length;
271
+ data.forEach((node, index) => {
208
272
  walk({
209
273
  data: node,
210
- pKey: null,
274
+ pUid: null,
211
275
  depth: 0,
212
276
  path: [],
213
- isLast: index + 1 === total,
214
- lines: index + 1 === total ? [] : [0],
215
- }),
216
- );
277
+ isLast: index + 1 === rootTotal,
278
+ lines: index + 1 === rootTotal ? [] : [0],
279
+ });
280
+ });
281
+
217
282
  return [res, map];
218
283
  }
219
284
 
220
- export function containsAll<T>(a: Array<T>, b: Array<T>) {
221
- if (b.length > a.length) {
222
- return false;
223
- }
224
- return b.every((i) => a.includes(i));
285
+ /**
286
+ * Check whether array `a` contains every element in `b`.
287
+ */
288
+ export function containsAll<T>(a: Set<T>, b: Array<T>): boolean {
289
+ if (!b || b.length === 0) return true;
290
+ return b.every((i) => a.has(i));
225
291
  }
226
292
 
227
- export function removeContainsAll<T>(a: Array<T>, b: Array<T>) {
228
- if (!b || b.length === 0) return a;
229
- const toRemove = new Set(b);
230
- return a.filter((i) => !toRemove.has(i));
293
+ /**
294
+ * Remove all elements in `b` from `a`.
295
+ */
296
+ export function removeAll<T>(a: Set<T>, b: Array<T>): Set<T> {
297
+ const result = new Set<T>();
298
+ a.forEach((item) => result.add(item));
299
+ for (const item of b) {
300
+ result.delete(item);
301
+ }
302
+ return result;
231
303
  }
232
304
 
305
+ /**
306
+ * Compute new checked uids when a node is toggled.
307
+ * Uses Set-based operations for O(1) membership checks.
308
+ * Disabled nodes are NOT affected by parent/child propagation — their checked
309
+ * state remains unchanged unless they are the directly toggled node.
310
+ */
233
311
  export function getCheckedKeys({
234
312
  checked,
235
313
  keys,
236
- key,
314
+ uid,
237
315
  map,
238
316
  }: {
239
317
  checked: boolean;
240
- keys: Array<string | number>;
241
- key: string | number;
318
+ keys: Set<string>;
319
+ uid: string;
242
320
  map: Map<string, TreeNode<unknown>>;
243
- }) {
244
- let newKeys = [...keys];
245
- const item = map.get(key as string);
246
- const childKeys = item?.childKeys || [];
247
- let pKey = item?.pKey;
321
+ }): Set<string> {
322
+ const item = map.get(uid);
323
+ if (!item) return keys;
324
+
325
+ const childUids = item.childUids || [];
326
+ const newKeys = new Set<string>();
327
+ keys.forEach((k) => newKeys.add(k));
248
328
 
249
329
  if (checked) {
250
- newKeys = [...newKeys, ...childKeys, key];
330
+ // Add this node + non-disabled descendants
331
+ newKeys.add(uid);
332
+ for (const cuid of childUids) {
333
+ const child = map.get(cuid);
334
+ if (child && !child.disabled) {
335
+ newKeys.add(cuid);
336
+ }
337
+ }
251
338
 
252
- while (pKey) {
253
- const parent = map.get(pKey);
339
+ // Walk up: if all non-disabled children of an ancestor are checked, check the ancestor
340
+ let pUid = item.pUid;
341
+ while (pUid) {
342
+ const parent = map.get(pUid);
254
343
  if (parent) {
255
- if (
256
- !newKeys.includes(parent.key) &&
257
- containsAll(newKeys, parent.childKeys as Array<unknown>)
258
- ) {
259
- newKeys.push(parent.key);
344
+ if (parent.disabled) {
345
+ // Stop propagation at disabled ancestors
346
+ break;
347
+ }
348
+ if (!newKeys.has(parent.uid) && parent.childUids) {
349
+ // Check if all non-disabled children are checked
350
+ const allNonDisabledChecked = parent.childUids.every((cuid) => {
351
+ const child = map.get(cuid);
352
+ return (child && child.disabled) || newKeys.has(cuid);
353
+ });
354
+ if (allNonDisabledChecked) {
355
+ newKeys.add(parent.uid);
356
+ }
260
357
  }
261
- pKey = parent.pKey;
358
+ pUid = parent.pUid;
262
359
  } else {
263
- pKey = null;
360
+ pUid = null;
264
361
  }
265
362
  }
266
363
  } else {
267
- newKeys = newKeys.filter((k) => k !== key);
268
- newKeys = removeContainsAll(newKeys, childKeys);
364
+ // Remove this node + non-disabled descendants
365
+ newKeys.delete(uid);
366
+ for (const cuid of childUids) {
367
+ const child = map.get(cuid);
368
+ if (child && !child.disabled) {
369
+ newKeys.delete(cuid);
370
+ }
371
+ }
269
372
 
270
- while (pKey) {
271
- const parent = map.get(pKey);
373
+ // Walk up: uncheck non-disabled ancestors
374
+ let pUid = item.pUid;
375
+ while (pUid) {
376
+ const parent = map.get(pUid);
272
377
  if (parent) {
273
- if (newKeys.includes(parent.key)) {
274
- newKeys = newKeys.filter((k) => k !== parent.key);
378
+ if (parent.disabled) {
379
+ break;
275
380
  }
276
- pKey = parent.pKey;
381
+ newKeys.delete(parent.uid);
382
+ pUid = parent.pUid;
277
383
  } else {
278
- pKey = null;
384
+ pUid = null;
279
385
  }
280
386
  }
281
387
  }
@@ -73,31 +73,40 @@ function LanguageSelector() {
73
73
  }
74
74
  ```
75
75
 
76
- ### 自定义语言包
76
+ ### 自定义 / 部分覆盖语言包
77
+
78
+ 传入一个带 `locale` 字段的(可以是**部分的**)对象,只需写要覆盖的 key,其余从对应内置语言包继承。对象会基于 `locale` 字段深度合并到内置包上,无需提供完整包。
77
79
 
78
80
  ```tsx
79
81
  import { LocaleProvider } from '@rainbow-oh/yee-c/locale';
80
82
 
81
- const customLocale = {
82
- locale: 'zh_CN',
83
- datepicker: {
84
- now: '当前时间',
85
- confirm: '好的',
86
- },
87
- select: {
88
- noData: '空空如也',
89
- },
90
- };
91
-
83
+ // 只覆盖 datepicker.now/confirm 和 select.noData,其余沿用 zh_CN 内置包
92
84
  function App() {
93
85
  return (
94
- <LocaleProvider defaultLocale={customLocale}>
86
+ <LocaleProvider
87
+ defaultLocale={{
88
+ locale: 'zh_CN',
89
+ datepicker: { now: '当前时间', confirm: '好的' },
90
+ select: { noData: '空空如也' },
91
+ }}
92
+ >
95
93
  <MyComponent />
96
94
  </LocaleProvider>
97
95
  );
98
96
  }
99
97
  ```
100
98
 
99
+ 运行时切换语言也可传部分覆盖,同样合并到对应基础包:
100
+
101
+ ```tsx
102
+ const { setLocale } = useLocale();
103
+
104
+ // 切到英文,同时把弹窗的 OK 文案改成 "Sure"
105
+ setLocale({ locale: 'en_US', modal: { okText: 'Sure' } });
106
+ ```
107
+
108
+ > 注意:对象形式必须带 `locale` 字段,用来确定基于哪个内置包合并。也可以用 `deepMerge(LOCALE_CONFIGS.zh_CN, { ... })` 自行预先生成完整包再传入。
109
+
101
110
  ### 异步加载语言包
102
111
 
103
112
  ```tsx
@@ -7,10 +7,10 @@ import React, {
7
7
  useMemo,
8
8
  useState,
9
9
  } from 'react';
10
- import { DEFAULT_LOCALE, LOCALE_CONFIGS } from './constants';
11
- import type { Locale, TFunction } from './interface';
10
+ import { DEFAULT_LOCALE, DEFAULT_LOCALE_CONFIG, LOCALE_CONFIGS } from './constants';
11
+ import type { Locale, LocaleConfigInput, TFunction } from './interface';
12
12
  import defaultLocaleData from './lang/en_US';
13
- import { isValidLocale, translate } from './util';
13
+ import { deepMerge, isValidLocale, translate } from './util';
14
14
 
15
15
  // ============ Default value (fallback when no Provider) ============
16
16
 
@@ -30,12 +30,31 @@ const createDefaultValue = (): LocaleContextType => {
30
30
 
31
31
  const defaultValue = createDefaultValue();
32
32
 
33
+ // ============ Locale input resolution ============
34
+
35
+ /**
36
+ * Resolve a locale input (a language code, or a possibly-partial locale
37
+ * object) into a full `Locale` pack. Partial objects are deep-merged onto the
38
+ * built-in pack matching their `locale` field, so only the overridden keys
39
+ * change and everything else is inherited.
40
+ */
41
+ function resolveLocaleConfigInput(input: LocaleConfigInput): Locale {
42
+ if (typeof input === 'string') {
43
+ return LOCALE_CONFIGS[input] || defaultLocaleData;
44
+ }
45
+ if (input && typeof input.locale === 'string') {
46
+ const base = LOCALE_CONFIGS[input.locale] || DEFAULT_LOCALE_CONFIG;
47
+ return deepMerge(base, input);
48
+ }
49
+ return defaultLocaleData;
50
+ }
51
+
33
52
  // ============ Context definition ============
34
53
 
35
54
  interface LocaleContextType {
36
55
  locale: Locale;
37
56
  lang: string; // Current language code 'zh_CN'
38
- setLocale: (locale: Locale | string) => void; // Supports object or language code
57
+ setLocale: (locale: LocaleConfigInput) => void; // Supports object (full or partial) or language code
39
58
  t: TFunction;
40
59
  }
41
60
 
@@ -45,8 +64,9 @@ export const LocaleContext = createContext<LocaleContextType>(defaultValue);
45
64
 
46
65
  interface LocaleProviderProps {
47
66
  children: React.ReactNode;
48
- /** Initial locale pack or language code */
49
- defaultLocale?: Locale | string;
67
+ /** Initial locale: a language code, or a full/partial locale object.
68
+ * Partial objects are merged onto the matching built-in pack. */
69
+ defaultLocale?: LocaleConfigInput;
50
70
  /** Async locale loader function */
51
71
  loadLocale?: (lang: string) => Promise<Locale>;
52
72
  /** Whether to auto-fallback to default for missing fields */
@@ -60,13 +80,9 @@ export function LocaleProvider({
60
80
  fallbackToDefault = true,
61
81
  }: LocaleProviderProps) {
62
82
  // State management
63
- const [locale, setLocaleState] = useState<Locale>(() => {
64
- if (typeof initialLocale === 'string') {
65
- // Prefer sync lookup from static locale packs
66
- return LOCALE_CONFIGS[initialLocale] || defaultLocaleData;
67
- }
68
- return isValidLocale(initialLocale) ? initialLocale : defaultLocaleData;
69
- });
83
+ const [locale, setLocaleState] = useState<Locale>(() =>
84
+ resolveLocaleConfigInput(initialLocale),
85
+ );
70
86
 
71
87
  const [lang, setLang] = useState<string>(
72
88
  typeof initialLocale === 'string' ? initialLocale : initialLocale.locale,
@@ -131,7 +147,7 @@ export function LocaleProvider({
131
147
 
132
148
  // Set locale method
133
149
  const setLocale = useCallback(
134
- (target: Locale | string) => {
150
+ (target: LocaleConfigInput) => {
135
151
  if (typeof target === 'string') {
136
152
  // Prefer sync lookup from static locale packs
137
153
  const staticLocale = LOCALE_CONFIGS[target];
@@ -142,8 +158,9 @@ export function LocaleProvider({
142
158
  }
143
159
  // Otherwise use async loading
144
160
  loadAndSetLocale(target);
145
- } else if (isValidLocale(target)) {
146
- setLocaleState(target);
161
+ } else if (target && typeof target.locale === 'string') {
162
+ // Full or partial object: merge onto the matching built-in pack
163
+ setLocaleState(resolveLocaleConfigInput(target));
147
164
  setLang(target.locale);
148
165
  }
149
166
  },
@@ -197,3 +197,27 @@ export type TFunction = <K extends LocaleKey>(
197
197
  key: K,
198
198
  params?: LocaleParams<K>,
199
199
  ) => string;
200
+
201
+ /**
202
+ * Recursively-optional version of T. Every level is optional; arrays are kept
203
+ * as-is (replaced wholesale rather than element-merged).
204
+ *
205
+ * Used so a locale override only needs to specify the keys it changes:
206
+ * `{ datepicker: { now: 'Now' } }` is a valid `DeepPartial<Locale>`.
207
+ */
208
+ export type DeepPartial<T> = T extends (...args: any[]) => any
209
+ ? T
210
+ : T extends object
211
+ ? T extends readonly any[]
212
+ ? T
213
+ : { [K in keyof T]?: DeepPartial<T[K]> }
214
+ : T;
215
+
216
+ /**
217
+ * Locale input accepted by `LocaleProvider` / `useLocale().setLocale`:
218
+ * - a language code string (`'zh_CN'`), resolved from the built-in packs, or
219
+ * - a (possibly partial) locale object. A partial object MUST carry `locale`
220
+ * so the provider knows which built-in pack to merge the overrides onto;
221
+ * only the keys you specify are overridden, everything else is inherited.
222
+ */
223
+ export type LocaleConfigInput = string | (DeepPartial<Locale> & { locale: string });