@elabs-ai/components-ui 4.1.0 → 4.2.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/dist/{chunk-JKPPTL63.js → chunk-FCH4ZN6G.js} +12 -3
- package/dist/chunk-FCH4ZN6G.js.map +1 -0
- package/dist/form.js +3 -3
- package/dist/form.js.map +1 -1
- package/dist/index.d.ts +126 -35
- package/dist/index.js +3346 -2079
- package/dist/index.js.map +1 -1
- package/dist/lib/cn.js +10 -1
- package/dist/lib/cn.js.map +1 -1
- package/package.json +3 -3
- package/src/__contract__/attribution-panel.contract.test.tsx +49 -0
- package/src/__contract__/badge.contract.test.tsx +49 -0
- package/src/__contract__/bounded-number.contract.test.tsx +49 -0
- package/src/__contract__/button.contract.test.tsx +49 -0
- package/src/__contract__/color-picker.contract.test.tsx +49 -0
- package/src/__contract__/combobox.contract.test.tsx +49 -0
- package/src/__contract__/command-trigger.contract.test.tsx +49 -0
- package/src/__contract__/copyable-value.contract.test.tsx +49 -0
- package/src/__contract__/date-picker.contract.test.tsx +49 -0
- package/src/__contract__/date-range-picker.contract.test.tsx +49 -0
- package/src/__contract__/empty-state.contract.test.tsx +49 -0
- package/src/__contract__/error-state.contract.test.tsx +49 -0
- package/src/__contract__/icon-button.contract.test.tsx +49 -0
- package/src/__contract__/input.contract.test.tsx +49 -0
- package/src/__contract__/key-value-editor.contract.test.tsx +49 -0
- package/src/__contract__/list-editor.contract.test.tsx +49 -0
- package/src/__contract__/loading-state.contract.test.tsx +49 -0
- package/src/__contract__/metric-card.contract.test.tsx +49 -0
- package/src/__contract__/model-picker.contract.test.tsx +49 -0
- package/src/__contract__/nav-notifications.contract.test.tsx +49 -0
- package/src/__contract__/number-input.contract.test.tsx +49 -0
- package/src/__contract__/rating.contract.test.tsx +49 -0
- package/src/__contract__/schema-form.contract.test.tsx +49 -0
- package/src/__contract__/section-header.contract.test.tsx +49 -0
- package/src/__contract__/segmented-field.contract.test.tsx +49 -0
- package/src/__contract__/slider-number.contract.test.tsx +49 -0
- package/src/__contract__/status-badge.contract.test.tsx +49 -0
- package/src/__contract__/tag-input.contract.test.tsx +49 -0
- package/src/__contract__/text.contract.test.tsx +49 -0
- package/src/__contract__/textarea.contract.test.tsx +49 -0
- package/src/__contract__/timeline.contract.test.tsx +49 -0
- package/src/__contract__/toggle.contract.test.tsx +49 -0
- package/src/__contract__/transfer.contract.test.tsx +49 -0
- package/src/__contract__/tree-select.contract.test.tsx +49 -0
- package/src/__contract__/tree.contract.test.tsx +49 -0
- package/src/__contract__/virtual-select.contract.test.tsx +49 -0
- package/src/__contract__/workspace-picker.contract.test.tsx +49 -0
- package/src/components/accordion/accordion.tsx +13 -4
- package/src/components/alert/alert.tsx +11 -3
- package/src/components/alert-dialog/alert-dialog.test.tsx +32 -0
- package/src/components/alert-dialog/alert-dialog.tsx +28 -6
- package/src/components/app-shell/app-shell.test.tsx +99 -0
- package/src/components/app-shell/app-shell.tsx +29 -10
- package/src/components/attribution-panel/attribution-panel.tsx +2 -0
- package/src/components/attribution-panel/attributions.generated.ts +2 -2
- package/src/components/avatar/avatar.tsx +4 -1
- package/src/components/badge/badge.test.tsx +8 -0
- package/src/components/badge/badge.tsx +14 -4
- package/src/components/bento-grid/bento-grid.test.tsx +23 -2
- package/src/components/bento-grid/bento-grid.tsx +10 -0
- package/src/components/bounded-number/bounded-number.test.tsx +1 -1
- package/src/components/bounded-number/bounded-number.tsx +78 -75
- package/src/components/breadcrumb/breadcrumb.tsx +3 -2
- package/src/components/button/button.tsx +18 -12
- package/src/components/button-group/button-group.tsx +1 -1
- package/src/components/calendar/calendar.tsx +3 -2
- package/src/components/card/card.test.tsx +24 -2
- package/src/components/card/card.tsx +47 -6
- package/src/components/carousel/carousel.test.tsx +169 -0
- package/src/components/carousel/carousel.tsx +93 -32
- package/src/components/change-review/change-review.tsx +38 -29
- package/src/components/checkbox/checkbox.tsx +1 -0
- package/src/components/collapsible-panel/use-collapsible-panel.ts +0 -3
- package/src/components/color-picker/color-picker.tsx +17 -11
- package/src/components/combobox/combobox.stories.tsx +10 -0
- package/src/components/combobox/combobox.test.tsx +54 -0
- package/src/components/combobox/combobox.tsx +25 -11
- package/src/components/command/command.test.tsx +15 -0
- package/src/components/command/command.tsx +8 -6
- package/src/components/context-menu/context-menu.tsx +16 -7
- package/src/components/context-rail/context-rail.tsx +4 -4
- package/src/components/copyable-value/copyable-value.stories.tsx +10 -0
- package/src/components/copyable-value/copyable-value.test.tsx +28 -1
- package/src/components/copyable-value/copyable-value.tsx +12 -1
- package/src/components/date-picker/date-picker.stories.tsx +9 -0
- package/src/components/date-picker/date-picker.test.tsx +52 -0
- package/src/components/date-picker/date-picker.tsx +13 -15
- package/src/components/date-range-picker/date-range-picker.stories.tsx +9 -0
- package/src/components/date-range-picker/date-range-picker.test.tsx +76 -1
- package/src/components/date-range-picker/date-range-picker.tsx +39 -28
- package/src/components/descriptions/descriptions.tsx +1 -1
- package/src/components/dialog/dialog.test.tsx +8 -3
- package/src/components/dialog/dialog.tsx +12 -4
- package/src/components/drawer/drawer.tsx +23 -6
- package/src/components/dropdown-menu/dropdown-menu.tsx +4 -4
- package/src/components/field/field.tsx +2 -0
- package/src/components/field-row/field-row.tsx +2 -0
- package/src/components/file-upload/file-upload.test.tsx +83 -10
- package/src/components/file-upload/file-upload.tsx +150 -28
- package/src/components/form/form.tsx +4 -2
- package/src/components/hover-card/hover-card.tsx +1 -0
- package/src/components/icon-button/icon-button.tsx +1 -1
- package/src/components/input/input.tsx +7 -3
- package/src/components/input-group/input-group.tsx +6 -6
- package/src/components/input-otp/input-otp.tsx +3 -1
- package/src/components/keyboard-shortcuts/keyboard-shortcuts.tsx +2 -2
- package/src/components/label/label.tsx +1 -1
- package/src/components/list-editor/list-editor.test.tsx +24 -0
- package/src/components/list-editor/list-editor.tsx +126 -52
- package/src/components/locale-provider/locale-keys.test.ts +134 -0
- package/src/components/locale-provider/messages.ts +321 -0
- package/src/components/mention-input/mention-input.stories.tsx +3 -3
- package/src/components/mention-input/mention-input.test.tsx +51 -1
- package/src/components/mention-input/mention-input.tsx +58 -8
- package/src/components/menubar/menubar.tsx +16 -6
- package/src/components/nav-main/nav-main.tsx +7 -3
- package/src/components/nav-notifications/nav-notifications.test.tsx +11 -0
- package/src/components/nav-notifications/nav-notifications.tsx +11 -5
- package/src/components/nav-user/nav-user.tsx +5 -5
- package/src/components/navigation-menu/navigation-menu.tsx +16 -4
- package/src/components/number-input/number-input.stories.tsx +7 -3
- package/src/components/number-input/number-input.test.tsx +47 -0
- package/src/components/number-input/number-input.tsx +65 -24
- package/src/components/pagination/pagination.test.tsx +20 -1
- package/src/components/pagination/pagination.tsx +18 -14
- package/src/components/popover/popover.tsx +1 -0
- package/src/components/progress/progress.tsx +2 -0
- package/src/components/rating/rating.tsx +2 -0
- package/src/components/resizable/resizable.test.tsx +76 -0
- package/src/components/resizable/resizable.tsx +31 -5
- package/src/components/revision-timeline/revision-timeline.tsx +7 -2
- package/src/components/schema-form/schema-form-store.ts +506 -0
- package/src/components/schema-form/schema-form.test.tsx +122 -1
- package/src/components/schema-form/schema-form.tsx +163 -327
- package/src/components/section-header/section-header.tsx +2 -2
- package/src/components/select/select.tsx +7 -5
- package/src/components/separator/separator.tsx +1 -0
- package/src/components/sheet/sheet.test.tsx +39 -0
- package/src/components/sheet/sheet.tsx +40 -9
- package/src/components/side-dock/side-dock.test.tsx +26 -0
- package/src/components/side-dock/side-dock.tsx +15 -2
- package/src/components/sidebar/sidebar.test.tsx +35 -1
- package/src/components/sidebar/sidebar.tsx +20 -6
- package/src/components/skeleton/skeleton.tsx +1 -0
- package/src/components/slider-number/slider-number.stories.tsx +4 -1
- package/src/components/slider-number/slider-number.test.tsx +1 -1
- package/src/components/sonner/sonner.tsx +2 -0
- package/src/components/spinner/spinner.test.tsx +18 -0
- package/src/components/spinner/spinner.tsx +14 -4
- package/src/components/state-panel/state-panel.test.tsx +3 -3
- package/src/components/state-panel/state-panel.tsx +15 -8
- package/src/components/switch/switch.tsx +2 -0
- package/src/components/table/table.stories.tsx +3 -1
- package/src/components/table/table.test.tsx +9 -2
- package/src/components/table/table.tsx +18 -8
- package/src/components/tabs/index.ts +10 -1
- package/src/components/tabs/tabs.stories.tsx +48 -0
- package/src/components/tabs/tabs.test.tsx +67 -0
- package/src/components/tabs/tabs.tsx +175 -77
- package/src/components/tag-input/tag-input.tsx +4 -2
- package/src/components/team-switcher/team-switcher.tsx +3 -3
- package/src/components/textarea/textarea.tsx +1 -1
- package/src/components/theme-switcher/theme-switcher.stories.tsx +76 -0
- package/src/components/theme-switcher/theme-switcher.test.tsx +182 -0
- package/src/components/theme-switcher/theme-switcher.tsx +134 -10
- package/src/components/timeline/timeline.tsx +0 -3
- package/src/components/toggle/toggle.tsx +7 -2
- package/src/components/toggle-group/toggle-group.tsx +2 -0
- package/src/components/tooltip/tooltip.tsx +2 -1
- package/src/components/top-nav/top-nav.tsx +1 -1
- package/src/components/transfer/transfer.tsx +35 -30
- package/src/components/tree/tree.test.tsx +41 -0
- package/src/components/tree/tree.tsx +77 -22
- package/src/components/tree/use-tree-keyboard.ts +37 -14
- package/src/components/tree-select/tree-select.stories.tsx +9 -0
- package/src/components/tree-select/tree-select.test.tsx +5 -0
- package/src/components/tree-select/tree-select.tsx +11 -14
- package/src/components/typography/prose.test.tsx +3 -2
- package/src/components/typography/prose.tsx +3 -3
- package/src/components/virtual-select/virtual-select.stories.tsx +9 -0
- package/src/components/virtual-select/virtual-select.test.tsx +28 -2
- package/src/components/virtual-select/virtual-select.tsx +56 -18
- package/src/components/wizard/wizard.test.tsx +9 -0
- package/src/components/wizard/wizard.tsx +12 -4
- package/src/index.ts +5 -0
- package/src/lib/cn.ts +9 -0
- package/src/lib/csv.ts +48 -0
- package/src/lib/diff-rows.ts +2 -0
- package/src/lib/merge-refs.ts +2 -0
- package/src/lib/use-controllable-state.ts +47 -0
- package/src/lib/use-mobile.test.tsx +47 -0
- package/src/lib/use-mobile.ts +26 -10
- package/dist/chunk-JKPPTL63.js.map +0 -1
|
@@ -3,7 +3,9 @@
|
|
|
3
3
|
import {
|
|
4
4
|
forwardRef,
|
|
5
5
|
useCallback,
|
|
6
|
+
useEffect,
|
|
6
7
|
useMemo,
|
|
8
|
+
useRef,
|
|
7
9
|
useState,
|
|
8
10
|
type HTMLAttributes,
|
|
9
11
|
type ReactNode,
|
|
@@ -77,10 +79,48 @@ export const ListEditor = forwardRef<HTMLDivElement, ListEditorProps>(function L
|
|
|
77
79
|
[isControlled, onValueChange],
|
|
78
80
|
);
|
|
79
81
|
|
|
82
|
+
// `rows` is a bare `string[]` with no identity of its own — keying each
|
|
83
|
+
// row's DOM by its ARRAY INDEX means a reorder swaps which row's data a
|
|
84
|
+
// given DOM node (and its focused "Move up" button) renders, not which
|
|
85
|
+
// node holds the moved row's data. A stable per-row id, generated once and
|
|
86
|
+
// carried through add/remove/move in lockstep with `rows`, lets React
|
|
87
|
+
// (and thus keyboard focus) follow the actual row instead of the slot.
|
|
88
|
+
const idCounterRef = useRef(0);
|
|
89
|
+
const makeId = useCallback(() => `list-editor-row-${idCounterRef.current++}`, []);
|
|
90
|
+
const [rowIds, setRowIds] = useState<string[]>(() => rows.map(() => makeId()));
|
|
91
|
+
const lastRowsRef = useRef(rows);
|
|
92
|
+
const upButtonRefs = useRef<Map<string, HTMLButtonElement>>(new Map());
|
|
93
|
+
const downButtonRefs = useRef<Map<string, HTMLButtonElement>>(new Map());
|
|
94
|
+
const pendingFocusRef = useRef<{ id: string; want: "up" | "down" } | null>(null);
|
|
95
|
+
|
|
96
|
+
// Safety net for a `rows` change that did NOT come from add/remove/move
|
|
97
|
+
// below (e.g. a controlled `value` reset by the app) — keeps `rowIds` the
|
|
98
|
+
// same length as `rows` without guessing at a diff.
|
|
99
|
+
useEffect(() => {
|
|
100
|
+
if (rows === lastRowsRef.current) return;
|
|
101
|
+
lastRowsRef.current = rows;
|
|
102
|
+
setRowIds((prev) => {
|
|
103
|
+
if (prev.length === rows.length) return prev;
|
|
104
|
+
if (rows.length > prev.length) {
|
|
105
|
+
return [...prev, ...Array.from({ length: rows.length - prev.length }, () => makeId())];
|
|
106
|
+
}
|
|
107
|
+
return prev.slice(0, rows.length);
|
|
108
|
+
});
|
|
109
|
+
}, [rows, makeId]);
|
|
110
|
+
|
|
111
|
+
useEffect(() => {
|
|
112
|
+
const pending = pendingFocusRef.current;
|
|
113
|
+
if (!pending) return;
|
|
114
|
+
pendingFocusRef.current = null;
|
|
115
|
+
const map = pending.want === "up" ? upButtonRefs.current : downButtonRefs.current;
|
|
116
|
+
map.get(pending.id)?.focus();
|
|
117
|
+
}, [rowIds]);
|
|
118
|
+
|
|
80
119
|
const addRow = useCallback(() => {
|
|
81
120
|
if (max !== undefined && rows.length >= max) return;
|
|
82
121
|
commit([...rows, ""]);
|
|
83
|
-
|
|
122
|
+
setRowIds((ids) => [...ids, makeId()]);
|
|
123
|
+
}, [rows, max, commit, makeId]);
|
|
84
124
|
|
|
85
125
|
const updateRow = useCallback(
|
|
86
126
|
(index: number, next: string) => {
|
|
@@ -92,6 +132,7 @@ export const ListEditor = forwardRef<HTMLDivElement, ListEditorProps>(function L
|
|
|
92
132
|
const removeRow = useCallback(
|
|
93
133
|
(index: number) => {
|
|
94
134
|
commit(rows.filter((_, i) => i !== index));
|
|
135
|
+
setRowIds((ids) => ids.filter((_, i) => i !== index));
|
|
95
136
|
},
|
|
96
137
|
[rows, commit],
|
|
97
138
|
);
|
|
@@ -103,9 +144,31 @@ export const ListEditor = forwardRef<HTMLDivElement, ListEditorProps>(function L
|
|
|
103
144
|
const next = [...rows];
|
|
104
145
|
const [moved] = next.splice(index, 1);
|
|
105
146
|
next.splice(target, 0, moved!);
|
|
147
|
+
const movedId = rowIds[index]!;
|
|
106
148
|
commit(next);
|
|
149
|
+
setRowIds((ids) => {
|
|
150
|
+
const nextIds = [...ids];
|
|
151
|
+
const [id] = nextIds.splice(index, 1);
|
|
152
|
+
nextIds.splice(target, 0, id!);
|
|
153
|
+
return nextIds;
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
// The button the user just pressed keeps focus by default (same id ⇒
|
|
157
|
+
// same DOM node, just repositioned). The one case that needs help: the
|
|
158
|
+
// row landed at the top/bottom, so THIS direction's button is now
|
|
159
|
+
// `disabled` — a browser blurs a focused element the instant it goes
|
|
160
|
+
// disabled, dropping focus to <body>. Redirect to the row's other
|
|
161
|
+
// reorder button instead, which stays enabled.
|
|
162
|
+
const activated: "up" | "down" = direction === -1 ? "up" : "down";
|
|
163
|
+
const willDisable =
|
|
164
|
+
(activated === "up" && target === 0) ||
|
|
165
|
+
(activated === "down" && target === rows.length - 1);
|
|
166
|
+
pendingFocusRef.current = {
|
|
167
|
+
id: movedId,
|
|
168
|
+
want: willDisable ? (activated === "up" ? "down" : "up") : activated,
|
|
169
|
+
};
|
|
107
170
|
},
|
|
108
|
-
[rows, commit],
|
|
171
|
+
[rows, rowIds, commit],
|
|
109
172
|
);
|
|
110
173
|
|
|
111
174
|
const atMax = max !== undefined && rows.length >= max;
|
|
@@ -123,56 +186,67 @@ export const ListEditor = forwardRef<HTMLDivElement, ListEditorProps>(function L
|
|
|
123
186
|
</p>
|
|
124
187
|
) : (
|
|
125
188
|
<div className="flex flex-col gap-2">
|
|
126
|
-
{rows.map((row, index) =>
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
189
|
+
{rows.map((row, index) => {
|
|
190
|
+
const rowId = rowIds[index] ?? String(index);
|
|
191
|
+
return (
|
|
192
|
+
<div key={rowId} data-slot="list-editor-row" className="flex items-center gap-1">
|
|
193
|
+
<Input
|
|
194
|
+
data-slot="list-editor-item"
|
|
195
|
+
value={row}
|
|
196
|
+
placeholder={placeholder}
|
|
197
|
+
disabled={disabled}
|
|
198
|
+
aria-label={t("ui.listEditor.itemLabel", { n: index + 1 })}
|
|
199
|
+
onChange={(event) => updateRow(index, event.target.value)}
|
|
200
|
+
className="flex-1"
|
|
201
|
+
/>
|
|
202
|
+
{reorderable && (
|
|
203
|
+
<>
|
|
204
|
+
<Button
|
|
205
|
+
ref={(el) => {
|
|
206
|
+
if (el) upButtonRefs.current.set(rowId, el);
|
|
207
|
+
else upButtonRefs.current.delete(rowId);
|
|
208
|
+
}}
|
|
209
|
+
data-slot="list-editor-move-up"
|
|
210
|
+
type="button"
|
|
211
|
+
variant="ghost"
|
|
212
|
+
size="icon-sm"
|
|
213
|
+
disabled={disabled || index === 0}
|
|
214
|
+
aria-label={t("ui.listEditor.moveUp", { n: index + 1 })}
|
|
215
|
+
onClick={() => moveRow(index, -1)}
|
|
216
|
+
>
|
|
217
|
+
<ChevronUp aria-hidden="true" />
|
|
218
|
+
</Button>
|
|
219
|
+
<Button
|
|
220
|
+
ref={(el) => {
|
|
221
|
+
if (el) downButtonRefs.current.set(rowId, el);
|
|
222
|
+
else downButtonRefs.current.delete(rowId);
|
|
223
|
+
}}
|
|
224
|
+
data-slot="list-editor-move-down"
|
|
225
|
+
type="button"
|
|
226
|
+
variant="ghost"
|
|
227
|
+
size="icon-sm"
|
|
228
|
+
disabled={disabled || index === rows.length - 1}
|
|
229
|
+
aria-label={t("ui.listEditor.moveDown", { n: index + 1 })}
|
|
230
|
+
onClick={() => moveRow(index, 1)}
|
|
231
|
+
>
|
|
232
|
+
<ChevronDown aria-hidden="true" />
|
|
233
|
+
</Button>
|
|
234
|
+
</>
|
|
235
|
+
)}
|
|
236
|
+
<Button
|
|
237
|
+
data-slot="list-editor-remove"
|
|
238
|
+
type="button"
|
|
239
|
+
variant="ghost"
|
|
240
|
+
size="icon-sm"
|
|
241
|
+
disabled={disabled}
|
|
242
|
+
aria-label={t("ui.listEditor.removeItem", { n: index + 1 })}
|
|
243
|
+
onClick={() => removeRow(index)}
|
|
244
|
+
>
|
|
245
|
+
<X aria-hidden="true" />
|
|
246
|
+
</Button>
|
|
247
|
+
</div>
|
|
248
|
+
);
|
|
249
|
+
})}
|
|
176
250
|
</div>
|
|
177
251
|
)}
|
|
178
252
|
<Button
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { readFileSync, readdirSync, statSync } from "node:fs";
|
|
3
|
+
import { dirname, join, relative } from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
import { DEFAULT_MESSAGES } from "./messages";
|
|
6
|
+
|
|
7
|
+
// ---------------------------------------------------------------------------
|
|
8
|
+
// Guards `t("some.key")` call sites across every package against the shipped
|
|
9
|
+
// English dictionary — a key used in source but never registered in
|
|
10
|
+
// `DEFAULT_MESSAGES` silently renders the RAW KEY to end users (t()'s last
|
|
11
|
+
// fallback rung), which is exactly what shipped when a i18n sweep added
|
|
12
|
+
// `t()` call sites but the matching messages.ts entries lagged behind
|
|
13
|
+
// (#4 i18n review). Source is the one true list of "keys a component can
|
|
14
|
+
// render" (see docs/I18N.md); this test keeps that list honest without
|
|
15
|
+
// hand-maintaining a duplicate inventory.
|
|
16
|
+
// ---------------------------------------------------------------------------
|
|
17
|
+
|
|
18
|
+
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
19
|
+
// packages/ui/src/components/locale-provider -> repo root
|
|
20
|
+
const REPO_ROOT = join(HERE, "../../../../..");
|
|
21
|
+
|
|
22
|
+
/** Source `.tsx`/`.ts` files under a package's `src/`, excluding tests/stories. */
|
|
23
|
+
function sourceFiles(dir: string, acc: string[] = []): string[] {
|
|
24
|
+
let entries;
|
|
25
|
+
try {
|
|
26
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
27
|
+
} catch {
|
|
28
|
+
return acc;
|
|
29
|
+
}
|
|
30
|
+
for (const entry of entries) {
|
|
31
|
+
const full = join(dir, entry.name);
|
|
32
|
+
if (entry.isDirectory()) {
|
|
33
|
+
if (entry.name === "node_modules" || entry.name === "dist") continue;
|
|
34
|
+
sourceFiles(full, acc);
|
|
35
|
+
} else if (/\.tsx?$/.test(entry.name) && !/\.(test|spec|stories)\.tsx?$/.test(entry.name)) {
|
|
36
|
+
acc.push(full);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return acc;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Strip `//` and `/* */` comments so example code in comments never counts as a call site. */
|
|
43
|
+
function stripComments(source: string): string {
|
|
44
|
+
return source.replace(/\/\*[\s\S]*?\*\//g, "").replace(/\/\/.*$/gm, "");
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Extract every string-literal key passed as (or inside a ternary forming)
|
|
49
|
+
* the FIRST argument of a `t(...)` call in `source`. Handles the shapes this
|
|
50
|
+
* codebase actually uses:
|
|
51
|
+
* t("ui.foo.bar")
|
|
52
|
+
* t("ui.foo.bar", { count })
|
|
53
|
+
* t(cond ? "ui.foo.a" : "ui.foo.b", { name })
|
|
54
|
+
* t(SOME_MAP[status]) — dynamic; no literal to extract, skipped
|
|
55
|
+
*
|
|
56
|
+
* A literal only counts as a candidate KEY when it stands as a whole ternary
|
|
57
|
+
* branch or the whole argument — i.e. bounded (modulo whitespace) by the
|
|
58
|
+
* start of the argument, `?`, `:`, or the argument's end — so a literal used
|
|
59
|
+
* mid-condition (`zoom === "fit-width" ? a : b`) is correctly NOT treated as
|
|
60
|
+
* a translation key.
|
|
61
|
+
*/
|
|
62
|
+
export function extractTranslationKeys(source: string): string[] {
|
|
63
|
+
const clean = stripComments(source);
|
|
64
|
+
const keys: string[] = [];
|
|
65
|
+
const callRe = /(?<![A-Za-z0-9_.])t\(/g;
|
|
66
|
+
let match: RegExpExecArray | null;
|
|
67
|
+
|
|
68
|
+
while ((match = callRe.exec(clean))) {
|
|
69
|
+
const start = match.index + match[0].length;
|
|
70
|
+
let depth = 0;
|
|
71
|
+
let end = start;
|
|
72
|
+
// Walk to the first top-level comma or closing paren — the boundary of
|
|
73
|
+
// the first argument expression.
|
|
74
|
+
for (; end < clean.length; end++) {
|
|
75
|
+
const ch = clean[end];
|
|
76
|
+
if (ch === "(" || ch === "{" || ch === "[") depth++;
|
|
77
|
+
else if (ch === ")" || ch === "}" || ch === "]") {
|
|
78
|
+
if (depth === 0) break;
|
|
79
|
+
depth--;
|
|
80
|
+
} else if (ch === "," && depth === 0) {
|
|
81
|
+
break;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
const firstArg = clean.slice(start, end);
|
|
85
|
+
const literalRe = /(?:^|[?:(,])\s*(["'])((?:(?!\1).)*)\1\s*(?=$|[):,])/g;
|
|
86
|
+
for (const lit of firstArg.matchAll(literalRe)) {
|
|
87
|
+
if (lit[2]) keys.push(lit[2]);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return keys;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
interface Finding {
|
|
94
|
+
file: string;
|
|
95
|
+
key: string;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function collectMissingKeys(): Finding[] {
|
|
99
|
+
const missing: Finding[] = [];
|
|
100
|
+
const pkgsDir = join(REPO_ROOT, "packages");
|
|
101
|
+
for (const pkg of readdirSync(pkgsDir, { withFileTypes: true })) {
|
|
102
|
+
if (!pkg.isDirectory()) continue;
|
|
103
|
+
const src = join(pkgsDir, pkg.name, "src");
|
|
104
|
+
try {
|
|
105
|
+
if (!statSync(src).isDirectory()) continue;
|
|
106
|
+
} catch {
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
for (const file of sourceFiles(src)) {
|
|
110
|
+
const source = readFileSync(file, "utf8");
|
|
111
|
+
for (const key of extractTranslationKeys(source)) {
|
|
112
|
+
if (!(key in DEFAULT_MESSAGES)) {
|
|
113
|
+
missing.push({ file: relative(REPO_ROOT, file), key });
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
return missing;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
describe("t() call sites vs. DEFAULT_MESSAGES", () => {
|
|
122
|
+
it("every literal key passed to t() across every package exists in the shipped dictionary", () => {
|
|
123
|
+
const missing = collectMissingKeys();
|
|
124
|
+
if (missing.length > 0) {
|
|
125
|
+
const report = missing.map((m) => ` ${m.file}: t("${m.key}")`).join("\n");
|
|
126
|
+
throw new Error(
|
|
127
|
+
`Found ${missing.length} t() call(s) whose key has no entry in ` +
|
|
128
|
+
`packages/ui/src/components/locale-provider/messages.ts — this key would ` +
|
|
129
|
+
`render as the raw key string to end users instead of English copy:\n${report}`,
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
expect(missing).toEqual([]);
|
|
133
|
+
});
|
|
134
|
+
});
|