@elabs-ai/components-ui 4.0.0 → 4.1.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/README.md +10 -8
- package/dist/chunk-JKPPTL63.js +48 -0
- package/dist/chunk-JKPPTL63.js.map +1 -0
- package/dist/form.d.ts +28 -0
- package/dist/form.js +123 -0
- package/dist/form.js.map +1 -0
- package/dist/index.d.ts +2485 -167
- package/dist/index.js +10716 -2706
- package/dist/index.js.map +1 -1
- package/package.json +21 -7
- package/src/_peer-deps.test.ts +59 -0
- package/src/blocks-comparison-table.stories.tsx +2 -2
- package/src/components/accordion/accordion.tsx +1 -1
- package/src/components/advanced-group/advanced-group.tsx +1 -1
- package/src/components/alert/alert.tsx +4 -1
- package/src/components/app-shell/app-shell.stories.tsx +44 -2
- package/src/components/app-shell/app-shell.tsx +35 -2
- package/src/components/app-sidebar/app-sidebar.stories.tsx +9 -1
- package/src/components/attribution-panel/attribution-panel.tsx +1 -1
- package/src/components/attribution-panel/attributions.generated.ts +81 -77
- package/src/components/badge/badge.stories.tsx +8 -0
- package/src/components/badge/badge.tsx +1 -1
- package/src/components/bento-grid/bento-grid.test.tsx +1 -1
- package/src/components/bento-grid/bento-grid.tsx +1 -1
- package/src/components/breadcrumb/breadcrumb.stories.tsx +38 -0
- package/src/components/breadcrumb/breadcrumb.test.tsx +24 -1
- package/src/components/breadcrumb/breadcrumb.tsx +7 -2
- package/src/components/button/button.stories.tsx +116 -8
- package/src/components/button/button.tsx +1 -1
- package/src/components/card/card.stories.tsx +11 -6
- package/src/components/card/card.tsx +1 -1
- package/src/components/change-review/change-review.stories.tsx +145 -1
- package/src/components/change-review/change-review.test.tsx +143 -0
- package/src/components/change-review/change-review.tsx +185 -4
- package/src/components/checkbox/checkbox.tsx +1 -1
- package/src/components/color-picker/color-picker.tsx +6 -5
- package/src/components/command/command.stories.tsx +16 -6
- package/src/components/command/command.test.tsx +73 -0
- package/src/components/command/command.tsx +30 -7
- package/src/components/command/index.ts +1 -0
- package/src/components/command-trigger/command-trigger.stories.tsx +39 -0
- package/src/components/command-trigger/command-trigger.test.tsx +18 -0
- package/src/components/command-trigger/command-trigger.tsx +70 -0
- package/src/components/command-trigger/index.ts +1 -0
- package/src/components/context-rail/context-rail.stories.tsx +285 -0
- package/src/components/context-rail/context-rail.test.tsx +263 -0
- package/src/components/context-rail/context-rail.tsx +678 -0
- package/src/components/context-rail/index.ts +1 -0
- package/src/components/copyable-value/copyable-value.tsx +1 -1
- package/src/components/dialog/dialog.tsx +2 -2
- package/src/components/dropdown-menu/dropdown-menu.stories.tsx +1 -1
- package/src/components/empty-state/empty-state.test.tsx +26 -0
- package/src/components/empty-state/empty-state.tsx +6 -1
- package/src/components/expand-dialog/expand-dialog.tsx +1 -2
- package/src/components/field/field-context.ts +59 -0
- package/src/components/field/field.stories.tsx +142 -0
- package/src/components/field/field.test.tsx +415 -0
- package/src/components/field/field.tsx +340 -0
- package/src/components/field/index.ts +9 -0
- package/src/components/field-row/field-row.stories.tsx +85 -1
- package/src/components/field-row/field-row.test.tsx +145 -0
- package/src/components/field-row/field-row.tsx +48 -6
- package/src/components/file-upload/file-upload.stories.tsx +16 -0
- package/src/components/file-upload/file-upload.test.tsx +7 -2
- package/src/components/file-upload/file-upload.tsx +15 -4
- package/src/components/form/form.tsx +5 -2
- package/src/components/icon-button/icon-button.test.tsx +1 -1
- package/src/components/icon-button/icon-button.tsx +2 -2
- package/src/components/input/input.stories.tsx +121 -0
- package/src/components/input/input.test.tsx +13 -0
- package/src/components/input/input.tsx +2 -2
- package/src/components/input-group/input-group.stories.tsx +74 -0
- package/src/components/input-group/input-group.tsx +13 -4
- package/src/components/input-otp/input-otp.tsx +5 -1
- package/src/components/keyboard-shortcuts/index.ts +6 -0
- package/src/components/keyboard-shortcuts/keyboard-shortcuts.stories.tsx +177 -0
- package/src/components/keyboard-shortcuts/keyboard-shortcuts.test.tsx +73 -0
- package/src/components/keyboard-shortcuts/keyboard-shortcuts.tsx +203 -0
- package/src/components/link-preview/link-preview.tsx +1 -1
- package/src/components/locale-provider/index.ts +6 -1
- package/src/components/locale-provider/locale-provider.stories.tsx +89 -1
- package/src/components/locale-provider/locale-provider.test.tsx +257 -1
- package/src/components/locale-provider/locale-provider.tsx +63 -8
- package/src/components/locale-provider/messages.ts +501 -2
- package/src/components/match-highlight/match-highlight.stories.tsx +1 -1
- package/src/components/mention-input/mention-input-mirror.stories.tsx +9 -1
- package/src/components/mention-input/mention-input.tsx +8 -38
- package/src/components/mention-input/mention-value.ts +8 -2
- package/src/components/metric-card/metric-card.stories.tsx +5 -3
- package/src/components/metric-card/metric-card.tsx +21 -2
- package/src/components/model-picker/model-picker.stories.tsx +63 -3
- package/src/components/model-picker/model-picker.test.tsx +63 -0
- package/src/components/model-picker/model-picker.tsx +62 -36
- package/src/components/nav-main/nav-main.tsx +1 -1
- package/src/components/nav-notifications/nav-notifications.stories.tsx +110 -0
- package/src/components/nav-notifications/nav-notifications.tsx +57 -5
- package/src/components/navigation-menu/navigation-menu.stories.tsx +22 -0
- package/src/components/navigation-menu/navigation-menu.test.tsx +174 -0
- package/src/components/navigation-menu/navigation-menu.tsx +189 -12
- package/src/components/page-shell/page-shell.stories.tsx +163 -0
- package/src/components/page-shell/page-shell.test.tsx +229 -0
- package/src/components/page-shell/page-shell.tsx +108 -25
- package/src/components/pagination/pagination.test.tsx +90 -0
- package/src/components/pagination/pagination.tsx +9 -5
- package/src/components/radio-group/radio-group.tsx +1 -1
- package/src/components/rating/rating.tsx +1 -1
- package/src/components/resizable/resizable.stories.tsx +114 -2
- package/src/components/resizable/resizable.tsx +1 -1
- package/src/components/revision-timeline/revision-timeline.stories.tsx +8 -1
- package/src/components/revision-timeline/revision-timeline.test.tsx +26 -0
- package/src/components/revision-timeline/revision-timeline.tsx +46 -3
- package/src/components/schema-form/from-json-schema.test.ts +390 -0
- package/src/components/schema-form/from-json-schema.ts +375 -0
- package/src/components/schema-form/index.ts +81 -0
- package/src/components/schema-form/schema-form-spec.ts +698 -0
- package/src/components/schema-form/schema-form.stories.tsx +566 -0
- package/src/components/schema-form/schema-form.test.tsx +900 -0
- package/src/components/schema-form/schema-form.tsx +1545 -0
- package/src/components/section-header/section-header.stories.tsx +37 -0
- package/src/components/section-header/section-header.tsx +12 -1
- package/src/components/select/select.tsx +6 -2
- package/src/components/sheet/sheet.tsx +1 -1
- package/src/components/side-dock/index.ts +1 -0
- package/src/components/side-dock/side-dock.stories.tsx +137 -0
- package/src/components/side-dock/side-dock.test.tsx +273 -0
- package/src/components/side-dock/side-dock.tsx +502 -0
- package/src/components/sidebar/index.ts +1 -0
- package/src/components/sidebar/sidebar-frame.test.tsx +162 -0
- package/src/components/sidebar/sidebar.stories.tsx +421 -1
- package/src/components/sidebar/sidebar.test.tsx +36 -1
- package/src/components/sidebar/sidebar.tsx +245 -33
- package/src/components/skip-link/index.ts +1 -0
- package/src/components/skip-link/skip-link.stories.tsx +106 -0
- package/src/components/skip-link/skip-link.test.tsx +20 -0
- package/src/components/skip-link/skip-link.tsx +34 -0
- package/src/components/slider/slider.tsx +1 -1
- package/src/components/split-panel/index.ts +6 -1
- package/src/components/split-panel/split-panel.stories.tsx +18 -2
- package/src/components/split-panel/split-panel.test.tsx +20 -0
- package/src/components/split-panel/split-panel.tsx +28 -8
- package/src/components/state-panel/state-panel.stories.tsx +52 -0
- package/src/components/state-panel/state-panel.test.tsx +125 -0
- package/src/components/state-panel/state-panel.tsx +96 -17
- package/src/components/status-badge/status-badge.stories.tsx +8 -0
- package/src/components/switch/switch.tsx +1 -1
- package/src/components/table/table.stories.tsx +62 -0
- package/src/components/table/table.test.tsx +142 -1
- package/src/components/table/table.tsx +60 -1
- package/src/components/tabs/tabs.tsx +8 -2
- package/src/components/tag-input/tag-input.tsx +3 -2
- package/src/components/team-switcher/team-switcher.tsx +34 -5
- package/src/components/textarea/textarea.tsx +1 -1
- package/src/components/theme-switcher/theme-switcher.tsx +6 -2
- package/src/components/theme-switcher/use-theme-transition.ts +2 -2
- package/src/components/timeline/timeline.stories.tsx +27 -0
- package/src/components/timeline/timeline.tsx +11 -1
- package/src/components/toggle/toggle.tsx +1 -1
- package/src/components/toolbar/toolbar.stories.tsx +10 -2
- package/src/components/tree/tree.stories.tsx +2 -2
- package/src/components/tree/tree.tsx +14 -9
- package/src/components/typography/prose.tsx +1 -1
- package/src/components/typography/typography.stories.tsx +228 -0
- package/src/components/view-toolbar/view-toolbar.stories.tsx +41 -1
- package/src/components/view-toolbar/view-toolbar.test.tsx +33 -0
- package/src/components/view-toolbar/view-toolbar.tsx +37 -4
- package/src/components/virtual-select/virtual-select.test.tsx +1 -1
- package/src/components/wizard/wizard.tsx +1 -1
- package/src/components/workspace-picker/index.ts +3 -0
- package/src/components/workspace-picker/workspace-picker-state.ts +50 -0
- package/src/components/workspace-picker/workspace-picker.stories.tsx +126 -0
- package/src/components/workspace-picker/workspace-picker.test.tsx +207 -0
- package/src/components/workspace-picker/workspace-picker.tsx +172 -0
- package/src/illustrations/empty-list-illustration.tsx +26 -0
- package/src/illustrations/error-illustration.tsx +60 -0
- package/src/illustrations/first-run-illustration.tsx +27 -0
- package/src/illustrations/illustration-base.tsx +108 -0
- package/src/illustrations/illustrations.stories.tsx +94 -0
- package/src/illustrations/illustrations.test.tsx +98 -0
- package/src/illustrations/index.ts +16 -0
- package/src/illustrations/no-access-illustration.tsx +45 -0
- package/src/illustrations/no-results-illustration.tsx +46 -0
- package/src/illustrations/offline-illustration.tsx +37 -0
- package/src/illustrations/success-illustration.tsx +40 -0
- package/src/index.ts +75 -2
- package/src/lib/agent-event-model.test.ts +12 -0
- package/src/lib/agent-event-model.ts +42 -0
- package/src/lib/approval-option.test.ts +17 -0
- package/src/lib/approval-option.ts +53 -0
- package/src/lib/check-result.ts +33 -0
- package/src/lib/diff-line.test.ts +36 -0
- package/src/lib/diff-line.ts +63 -0
- package/src/lib/diff-rows.test.ts +76 -0
- package/src/lib/diff-rows.ts +92 -0
- package/src/lib/format-duration.test.ts +22 -0
- package/src/lib/format-duration.ts +30 -0
- package/src/lib/has-renderable-content.test.ts +49 -0
- package/src/lib/has-renderable-content.ts +32 -0
- package/src/lib/operating-mode.test.ts +36 -0
- package/src/lib/operating-mode.ts +81 -0
- package/src/lib/optional-peer.ts +59 -0
- package/src/lib/session-launch.ts +37 -0
- package/src/lib/slash-command.test.ts +44 -0
- package/src/lib/slash-command.ts +45 -0
- package/src/lib/trigger-query.test.ts +94 -0
- package/src/lib/trigger-query.ts +88 -0
- package/src/lib/use-mobile.ts +12 -6
- package/src/motion.stories.tsx +6 -0
- package/src/templates-object-detail-hub.stories.tsx +2 -2
- package/src/templates-screen-states.stories.tsx +5 -5
- package/src/templates-settings.stories.tsx +1 -1
- package/src/blocks/sidebar-02/app-sidebar.tsx +0 -160
- package/src/blocks/sidebar-02/logo.tsx +0 -18
- package/src/blocks/sidebar-02/nav-main.tsx +0 -13
- package/src/blocks/sidebar-02/nav-notifications.tsx +0 -7
- package/src/blocks/sidebar-02/sidebar-02.stories.tsx +0 -23
- package/src/blocks/sidebar-02/team-switcher.tsx +0 -7
- package/src/blocks/sidebar-04/app-sidebar.tsx +0 -236
- package/src/blocks/sidebar-04/mail-context.tsx +0 -29
- package/src/blocks/sidebar-04/nav-user.tsx +0 -6
- package/src/blocks/sidebar-04/sidebar-04.stories.tsx +0 -150
- package/src/blocks/sidebar-05/app-sidebar.tsx +0 -375
- package/src/blocks/sidebar-05/sidebar-05.stories.tsx +0 -18
- package/src/blocks/sidebar-05/team-switcher.tsx +0 -6
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { describe, expect, it } from "vitest";
|
|
1
|
+
import { describe, expect, it, vi } from "vitest";
|
|
2
2
|
import { render, screen } from "@testing-library/react";
|
|
3
3
|
import { createPortal } from "react-dom";
|
|
4
4
|
import { useDirection } from "@radix-ui/react-direction";
|
|
@@ -236,3 +236,259 @@ describe("t() — variable interpolation", () => {
|
|
|
236
236
|
expect(screen.getByTestId("result").textContent).toBe("greeting");
|
|
237
237
|
});
|
|
238
238
|
});
|
|
239
|
+
|
|
240
|
+
// ---------------------------------------------------------------------------
|
|
241
|
+
// t() — pluggable `translate` resolver bridge (#19)
|
|
242
|
+
// ---------------------------------------------------------------------------
|
|
243
|
+
|
|
244
|
+
describe("LocaleProvider — translate resolver bridge", () => {
|
|
245
|
+
function TranslateReader({ vars }: { vars?: Record<string, string | number> }) {
|
|
246
|
+
const { t } = useLocale();
|
|
247
|
+
return <span data-testid="result">{t("close", vars)}</span>;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
it("a translate() result overrides both a messages override and the English default", () => {
|
|
251
|
+
render(
|
|
252
|
+
<LocaleProvider
|
|
253
|
+
messages={{ close: "Fermer" }}
|
|
254
|
+
translate={(key) => (key === "close" ? "Schließen" : undefined)}
|
|
255
|
+
>
|
|
256
|
+
<TranslateReader />
|
|
257
|
+
</LocaleProvider>,
|
|
258
|
+
);
|
|
259
|
+
expect(screen.getByTestId("result").textContent).toBe("Schließen");
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
it("translate() returning undefined falls through to messages, then DEFAULT_MESSAGES, then the key", () => {
|
|
263
|
+
// Rung 1: messages override present -> used.
|
|
264
|
+
const { rerender } = render(
|
|
265
|
+
<LocaleProvider messages={{ close: "Fermer" }} translate={() => undefined}>
|
|
266
|
+
<TranslateReader />
|
|
267
|
+
</LocaleProvider>,
|
|
268
|
+
);
|
|
269
|
+
expect(screen.getByTestId("result").textContent).toBe("Fermer");
|
|
270
|
+
|
|
271
|
+
// Rung 2: no messages override -> DEFAULT_MESSAGES English default.
|
|
272
|
+
rerender(
|
|
273
|
+
<LocaleProvider translate={() => undefined}>
|
|
274
|
+
<TranslateReader />
|
|
275
|
+
</LocaleProvider>,
|
|
276
|
+
);
|
|
277
|
+
expect(screen.getByTestId("result").textContent).toBe("Close");
|
|
278
|
+
|
|
279
|
+
// Rung 3: a key with no default and no override -> the raw key itself.
|
|
280
|
+
function UnknownKeyReader() {
|
|
281
|
+
const { t } = useLocale();
|
|
282
|
+
return <span data-testid="unknown">{t("totallyUnknownKey")}</span>;
|
|
283
|
+
}
|
|
284
|
+
rerender(
|
|
285
|
+
<LocaleProvider translate={() => undefined}>
|
|
286
|
+
<UnknownKeyReader />
|
|
287
|
+
</LocaleProvider>,
|
|
288
|
+
);
|
|
289
|
+
expect(screen.getByTestId("unknown").textContent).toBe("totallyUnknownKey");
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
it("passes the raw key and vars to translate(), not a pre-interpolated string", () => {
|
|
293
|
+
const translate = vi.fn((_key: string, _vars?: Record<string, string | number>) => undefined);
|
|
294
|
+
function CountReader() {
|
|
295
|
+
const { t } = useLocale();
|
|
296
|
+
return (
|
|
297
|
+
<span data-testid="result">
|
|
298
|
+
{t("ui.viewToolbar.countOfTotal", { count: 3, total: 10 })}
|
|
299
|
+
</span>
|
|
300
|
+
);
|
|
301
|
+
}
|
|
302
|
+
render(
|
|
303
|
+
<LocaleProvider translate={translate}>
|
|
304
|
+
<CountReader />
|
|
305
|
+
</LocaleProvider>,
|
|
306
|
+
);
|
|
307
|
+
expect(translate).toHaveBeenCalledWith("ui.viewToolbar.countOfTotal", { count: 3, total: 10 });
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
it("does not double-interpolate a translate() return value that itself contains a literal '{'", () => {
|
|
311
|
+
render(
|
|
312
|
+
<LocaleProvider translate={(key) => (key === "close" ? "raw {not-a-var} text" : undefined)}>
|
|
313
|
+
<TranslateReader />
|
|
314
|
+
</LocaleProvider>,
|
|
315
|
+
);
|
|
316
|
+
// brand-ui's own {name}-replacement must NOT run a second time over the
|
|
317
|
+
// resolver's own output — a real ICU formatter already interpolated it.
|
|
318
|
+
expect(screen.getByTestId("result").textContent).toBe("raw {not-a-var} text");
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
it("with no resolver and no message, t() still falls back to the key exactly as today", () => {
|
|
322
|
+
function UnknownKeyReader() {
|
|
323
|
+
const { t } = useLocale();
|
|
324
|
+
return <span data-testid="result">{t("anotherUnknownKey")}</span>;
|
|
325
|
+
}
|
|
326
|
+
render(
|
|
327
|
+
<LocaleProvider>
|
|
328
|
+
<UnknownKeyReader />
|
|
329
|
+
</LocaleProvider>,
|
|
330
|
+
);
|
|
331
|
+
expect(screen.getByTestId("result").textContent).toBe("anotherUnknownKey");
|
|
332
|
+
});
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
// ---------------------------------------------------------------------------
|
|
336
|
+
// t() — ICU cardinal-plural forms via `Intl.PluralRules` (#19)
|
|
337
|
+
// ---------------------------------------------------------------------------
|
|
338
|
+
|
|
339
|
+
describe("LocaleProvider — plural forms (Intl.PluralRules)", () => {
|
|
340
|
+
const ITEM_COUNT_MESSAGE = {
|
|
341
|
+
one: "{count} item selected",
|
|
342
|
+
other: "{count} items selected",
|
|
343
|
+
};
|
|
344
|
+
|
|
345
|
+
function PluralReader({ count }: { count: number }) {
|
|
346
|
+
const { t } = useLocale();
|
|
347
|
+
return <span data-testid="result">{t("itemCount", { count })}</span>;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
it("selects the English 'one' form for count=1 and 'other' for 0 and many", () => {
|
|
351
|
+
const { rerender } = render(
|
|
352
|
+
<LocaleProvider locale="en-US" messages={{ itemCount: ITEM_COUNT_MESSAGE }}>
|
|
353
|
+
<PluralReader count={1} />
|
|
354
|
+
</LocaleProvider>,
|
|
355
|
+
);
|
|
356
|
+
expect(screen.getByTestId("result").textContent).toBe("1 item selected");
|
|
357
|
+
|
|
358
|
+
rerender(
|
|
359
|
+
<LocaleProvider locale="en-US" messages={{ itemCount: ITEM_COUNT_MESSAGE }}>
|
|
360
|
+
<PluralReader count={0} />
|
|
361
|
+
</LocaleProvider>,
|
|
362
|
+
);
|
|
363
|
+
expect(screen.getByTestId("result").textContent).toBe("0 items selected");
|
|
364
|
+
|
|
365
|
+
rerender(
|
|
366
|
+
<LocaleProvider locale="en-US" messages={{ itemCount: ITEM_COUNT_MESSAGE }}>
|
|
367
|
+
<PluralReader count={5} />
|
|
368
|
+
</LocaleProvider>,
|
|
369
|
+
);
|
|
370
|
+
expect(screen.getByTestId("result").textContent).toBe("5 items selected");
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
// Polish has FOUR cardinal categories (one/few/many/other) with different
|
|
374
|
+
// boundaries than English's two (one/other) — this is the case a
|
|
375
|
+
// design that only handles English silently gets wrong.
|
|
376
|
+
const PL_FILE_MESSAGE = {
|
|
377
|
+
one: "{count} plik",
|
|
378
|
+
few: "{count} pliki",
|
|
379
|
+
many: "{count} plików",
|
|
380
|
+
other: "{count} pliku",
|
|
381
|
+
};
|
|
382
|
+
|
|
383
|
+
function PlPluralReader({ count }: { count: number }) {
|
|
384
|
+
const { t } = useLocale();
|
|
385
|
+
return <span data-testid="result">{t("fileCount", { count })}</span>;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
it("selects the correct Polish plural category for 1 (one), 2 (few), 5 (many), 12 (many), and 22 (few) — teens boundary", () => {
|
|
389
|
+
const { rerender } = render(
|
|
390
|
+
<LocaleProvider locale="pl-PL" messages={{ fileCount: PL_FILE_MESSAGE }}>
|
|
391
|
+
<PlPluralReader count={1} />
|
|
392
|
+
</LocaleProvider>,
|
|
393
|
+
);
|
|
394
|
+
expect(screen.getByTestId("result").textContent).toBe("1 plik");
|
|
395
|
+
|
|
396
|
+
rerender(
|
|
397
|
+
<LocaleProvider locale="pl-PL" messages={{ fileCount: PL_FILE_MESSAGE }}>
|
|
398
|
+
<PlPluralReader count={2} />
|
|
399
|
+
</LocaleProvider>,
|
|
400
|
+
);
|
|
401
|
+
expect(screen.getByTestId("result").textContent).toBe("2 pliki");
|
|
402
|
+
|
|
403
|
+
rerender(
|
|
404
|
+
<LocaleProvider locale="pl-PL" messages={{ fileCount: PL_FILE_MESSAGE }}>
|
|
405
|
+
<PlPluralReader count={5} />
|
|
406
|
+
</LocaleProvider>,
|
|
407
|
+
);
|
|
408
|
+
expect(screen.getByTestId("result").textContent).toBe("5 plików");
|
|
409
|
+
|
|
410
|
+
// Polish teens (11–21) always use "many" category, breaking the pattern.
|
|
411
|
+
// 12 → "many"; 22 → "few" demonstrates this non-contiguous boundary.
|
|
412
|
+
rerender(
|
|
413
|
+
<LocaleProvider locale="pl-PL" messages={{ fileCount: PL_FILE_MESSAGE }}>
|
|
414
|
+
<PlPluralReader count={12} />
|
|
415
|
+
</LocaleProvider>,
|
|
416
|
+
);
|
|
417
|
+
expect(screen.getByTestId("result").textContent).toBe("12 plików");
|
|
418
|
+
|
|
419
|
+
rerender(
|
|
420
|
+
<LocaleProvider locale="pl-PL" messages={{ fileCount: PL_FILE_MESSAGE }}>
|
|
421
|
+
<PlPluralReader count={22} />
|
|
422
|
+
</LocaleProvider>,
|
|
423
|
+
);
|
|
424
|
+
expect(screen.getByTestId("result").textContent).toBe("22 pliki");
|
|
425
|
+
});
|
|
426
|
+
|
|
427
|
+
it("falls back to the 'other' form when a category has no entry", () => {
|
|
428
|
+
// Only "one" and "other" are supplied; Polish "few"/"many" counts must
|
|
429
|
+
// fall back to "other" rather than rendering undefined/blank.
|
|
430
|
+
const PARTIAL = { one: "{count} rzecz", other: "{count} rzeczy" };
|
|
431
|
+
render(
|
|
432
|
+
<LocaleProvider locale="pl-PL" messages={{ fileCount: PARTIAL }}>
|
|
433
|
+
<PlPluralReader count={2} />
|
|
434
|
+
</LocaleProvider>,
|
|
435
|
+
);
|
|
436
|
+
expect(screen.getByTestId("result").textContent).toBe("2 rzeczy");
|
|
437
|
+
});
|
|
438
|
+
|
|
439
|
+
it("handles locales with zero/two categories (Arabic example)", () => {
|
|
440
|
+
// Arabic (ar-EG) has categories: zero, one, two, few, many, other.
|
|
441
|
+
// This test verifies that locales with non-English category sets work
|
|
442
|
+
// and that partial maps (not providing every category) fall back correctly.
|
|
443
|
+
const AR_MESSAGE: Record<string, string> = {
|
|
444
|
+
zero: "{count} ملفات", // zero
|
|
445
|
+
one: "{count} ملف", // one
|
|
446
|
+
two: "{count} ملفان", // two
|
|
447
|
+
few: "{count} ملفات", // few
|
|
448
|
+
many: "{count} ملف", // many
|
|
449
|
+
other: "{count} ملف", // other
|
|
450
|
+
};
|
|
451
|
+
|
|
452
|
+
function ArPluralReader({ count }: { count: number }) {
|
|
453
|
+
const { t } = useLocale();
|
|
454
|
+
return <span data-testid="result">{t("fileCount", { count })}</span>;
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
const { rerender } = render(
|
|
458
|
+
<LocaleProvider locale="ar-EG" messages={{ fileCount: AR_MESSAGE }}>
|
|
459
|
+
<ArPluralReader count={0} />
|
|
460
|
+
</LocaleProvider>,
|
|
461
|
+
);
|
|
462
|
+
expect(screen.getByTestId("result").textContent).toBe("0 ملفات");
|
|
463
|
+
|
|
464
|
+
rerender(
|
|
465
|
+
<LocaleProvider locale="ar-EG" messages={{ fileCount: AR_MESSAGE }}>
|
|
466
|
+
<ArPluralReader count={1} />
|
|
467
|
+
</LocaleProvider>,
|
|
468
|
+
);
|
|
469
|
+
expect(screen.getByTestId("result").textContent).toBe("1 ملف");
|
|
470
|
+
|
|
471
|
+
rerender(
|
|
472
|
+
<LocaleProvider locale="ar-EG" messages={{ fileCount: AR_MESSAGE }}>
|
|
473
|
+
<ArPluralReader count={2} />
|
|
474
|
+
</LocaleProvider>,
|
|
475
|
+
);
|
|
476
|
+
expect(screen.getByTestId("result").textContent).toBe("2 ملفان");
|
|
477
|
+
});
|
|
478
|
+
|
|
479
|
+
it("the no-provider default value also resolves plural forms (en-US)", () => {
|
|
480
|
+
// No provider, so this exercises makeDefaultValue()'s t() directly against
|
|
481
|
+
// the shipped DEFAULT_MESSAGES "itemsSelected" plural entry — proving the
|
|
482
|
+
// singleton default value branches on plural forms too, not only a
|
|
483
|
+
// provider's merged messages.
|
|
484
|
+
function DefaultPluralReader({ count }: { count: number }) {
|
|
485
|
+
const { t } = useLocale();
|
|
486
|
+
return <span data-testid="result">{t("itemsSelected", { count })}</span>;
|
|
487
|
+
}
|
|
488
|
+
const { rerender } = render(<DefaultPluralReader count={1} />);
|
|
489
|
+
expect(screen.getByTestId("result").textContent).toBe("1 item selected");
|
|
490
|
+
|
|
491
|
+
rerender(<DefaultPluralReader count={5} />);
|
|
492
|
+
expect(screen.getByTestId("result").textContent).toBe("5 items selected");
|
|
493
|
+
});
|
|
494
|
+
});
|
|
@@ -2,14 +2,19 @@
|
|
|
2
2
|
|
|
3
3
|
import { createContext, use, useMemo, type ReactNode } from "react";
|
|
4
4
|
import { DirectionProvider } from "@radix-ui/react-direction";
|
|
5
|
-
import { DEFAULT_MESSAGES } from "./messages";
|
|
5
|
+
import { DEFAULT_MESSAGES, type MessageValue, type PluralCategory } from "./messages";
|
|
6
6
|
|
|
7
7
|
// ---------------------------------------------------------------------------
|
|
8
8
|
// Public types
|
|
9
9
|
// ---------------------------------------------------------------------------
|
|
10
10
|
|
|
11
|
-
/**
|
|
12
|
-
|
|
11
|
+
/**
|
|
12
|
+
* A flat map of string keys to their translated values — a plain string, or a
|
|
13
|
+
* `PluralMessage` (one string per ICU cardinal-plural category, e.g.
|
|
14
|
+
* `{ one: "{count} item", other: "{count} items" }`) for microcopy whose
|
|
15
|
+
* wording changes with a count. See `PluralMessage` in `./messages`.
|
|
16
|
+
*/
|
|
17
|
+
export type Messages = Record<string, MessageValue>;
|
|
13
18
|
|
|
14
19
|
export interface LocaleContextValue {
|
|
15
20
|
/** BCP-47 locale tag, e.g. "en-US", "ar-EG", "de-DE". */
|
|
@@ -18,8 +23,13 @@ export interface LocaleContextValue {
|
|
|
18
23
|
dir: "ltr" | "rtl";
|
|
19
24
|
/**
|
|
20
25
|
* Translate a key.
|
|
21
|
-
* Falls back to:
|
|
22
|
-
*
|
|
26
|
+
* Falls back to: an external `translate` resolver (if the provider was
|
|
27
|
+
* given one) → consumer `messages` → English default → the key itself.
|
|
28
|
+
* Supports simple `{name}`-style variable interpolation, and ICU
|
|
29
|
+
* cardinal-plural forms (`Intl.PluralRules`) when the resolved message is a
|
|
30
|
+
* `PluralMessage` map and `vars.count` is a number. Note: `count` must be a
|
|
31
|
+
* number, not a numeric string (e.g., `count: 3`, not `count: "3"`) to select
|
|
32
|
+
* the correct plural category.
|
|
23
33
|
*/
|
|
24
34
|
t: (key: string, vars?: Record<string, string | number>) => string;
|
|
25
35
|
/** Format a number using the active locale. Memoized per locale+opts hash. */
|
|
@@ -45,18 +55,41 @@ function interpolate(template: string, vars?: Record<string, string | number>):
|
|
|
45
55
|
);
|
|
46
56
|
}
|
|
47
57
|
|
|
58
|
+
/**
|
|
59
|
+
* Resolve a `MessageValue` (a plain string or a `PluralMessage`) to its final,
|
|
60
|
+
* interpolated string.
|
|
61
|
+
*
|
|
62
|
+
* A `PluralMessage` selects its category via `Intl.PluralRules(locale).select(count)`
|
|
63
|
+
* when `vars.count` is a number; with no `count`, or when the selected
|
|
64
|
+
* category has no entry, it falls back to `"other"` (the one category every
|
|
65
|
+
* locale defines), then to the raw `key` if even `"other"` is missing.
|
|
66
|
+
*/
|
|
67
|
+
function resolveMessage(
|
|
68
|
+
key: string,
|
|
69
|
+
template: MessageValue,
|
|
70
|
+
vars: Record<string, string | number> | undefined,
|
|
71
|
+
pluralRules: Intl.PluralRules,
|
|
72
|
+
): string {
|
|
73
|
+
if (typeof template === "string") return interpolate(template, vars);
|
|
74
|
+
const count = typeof vars?.count === "number" ? vars.count : undefined;
|
|
75
|
+
const category: PluralCategory = count !== undefined ? pluralRules.select(count) : "other";
|
|
76
|
+
const form = template[category] ?? template.other ?? key;
|
|
77
|
+
return interpolate(form, vars);
|
|
78
|
+
}
|
|
79
|
+
|
|
48
80
|
function makeDefaultValue(): LocaleContextValue {
|
|
49
81
|
const locale = "en-US";
|
|
50
82
|
// Simple cache: one Intl object per serialized opts string
|
|
51
83
|
const numberCache = new Map<string, Intl.NumberFormat>();
|
|
52
84
|
const dateCache = new Map<string, Intl.DateTimeFormat>();
|
|
85
|
+
const pluralRules = new Intl.PluralRules(locale);
|
|
53
86
|
|
|
54
87
|
return {
|
|
55
88
|
locale,
|
|
56
89
|
dir: "ltr",
|
|
57
90
|
t(key, vars) {
|
|
58
91
|
const template = DEFAULT_MESSAGES[key] ?? key;
|
|
59
|
-
return
|
|
92
|
+
return resolveMessage(key, template, vars, pluralRules);
|
|
60
93
|
},
|
|
61
94
|
formatNumber(n, opts) {
|
|
62
95
|
const k = JSON.stringify(opts ?? null);
|
|
@@ -95,6 +128,21 @@ export interface LocaleProviderProps {
|
|
|
95
128
|
* to the English bundle.
|
|
96
129
|
*/
|
|
97
130
|
messages?: Messages;
|
|
131
|
+
/**
|
|
132
|
+
* Optional external resolver — hand brand-ui's microcopy off to an app's own
|
|
133
|
+
* i18n runtime (next-intl, react-intl, i18next, …) instead of maintaining a
|
|
134
|
+
* second, flattened message catalogue. Called with the raw key and vars
|
|
135
|
+
* (not pre-interpolated), so a real ICU formatter gets real values and can
|
|
136
|
+
* do its own pluralization/number/date formatting.
|
|
137
|
+
*
|
|
138
|
+
* Return a string to use it as-is (brand-ui does NOT re-run its own
|
|
139
|
+
* `{name}` interpolation on it — the app's runtime already formatted it).
|
|
140
|
+
* Return `undefined`/`null` to decline a key; brand-ui then falls back to
|
|
141
|
+
* `messages` → `DEFAULT_MESSAGES` → the raw key, in that order — so a
|
|
142
|
+
* resolver only needs to cover the keys it actually wants to override.
|
|
143
|
+
* See `docs/I18N.md`.
|
|
144
|
+
*/
|
|
145
|
+
translate?: (key: string, vars?: Record<string, string | number>) => string | undefined | null;
|
|
98
146
|
children: ReactNode;
|
|
99
147
|
}
|
|
100
148
|
|
|
@@ -118,6 +166,7 @@ export function LocaleProvider({
|
|
|
118
166
|
locale = "en-US",
|
|
119
167
|
dir = "ltr",
|
|
120
168
|
messages,
|
|
169
|
+
translate,
|
|
121
170
|
children,
|
|
122
171
|
}: LocaleProviderProps) {
|
|
123
172
|
// Merge consumer messages over the shipped English defaults.
|
|
@@ -131,14 +180,20 @@ export function LocaleProvider({
|
|
|
131
180
|
// Memoized Intl formatter caches — keyed by serialised opts
|
|
132
181
|
const numberCache = useMemo(() => new Map<string, Intl.NumberFormat>(), [locale]);
|
|
133
182
|
const dateCache = useMemo(() => new Map<string, Intl.DateTimeFormat>(), [locale]);
|
|
183
|
+
const pluralRules = useMemo(() => new Intl.PluralRules(locale), [locale]);
|
|
134
184
|
|
|
135
185
|
const value = useMemo<LocaleContextValue>(
|
|
136
186
|
() => ({
|
|
137
187
|
locale,
|
|
138
188
|
dir,
|
|
139
189
|
t(key, vars) {
|
|
190
|
+
// An external runtime (next-intl, react-intl, i18next, …) gets first
|
|
191
|
+
// say, and its result is used AS-IS — it already interpolated and
|
|
192
|
+
// pluralized. `undefined`/`null` means "I decline this key".
|
|
193
|
+
const resolved = translate?.(key, vars);
|
|
194
|
+
if (resolved != null) return resolved;
|
|
140
195
|
const template = merged[key] ?? key;
|
|
141
|
-
return
|
|
196
|
+
return resolveMessage(key, template, vars, pluralRules);
|
|
142
197
|
},
|
|
143
198
|
formatNumber(n, opts) {
|
|
144
199
|
const k = JSON.stringify(opts ?? null);
|
|
@@ -151,7 +206,7 @@ export function LocaleProvider({
|
|
|
151
206
|
return dateCache.get(k)!.format(d);
|
|
152
207
|
},
|
|
153
208
|
}),
|
|
154
|
-
[locale, dir, merged, numberCache, dateCache],
|
|
209
|
+
[locale, dir, merged, numberCache, dateCache, pluralRules, translate],
|
|
155
210
|
);
|
|
156
211
|
|
|
157
212
|
return (
|