@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,6 +1,7 @@
|
|
|
1
1
|
import { useLayoutEffect, useRef, useState } from "react";
|
|
2
2
|
import type { Meta, StoryObj } from "@storybook/react-vite";
|
|
3
3
|
import { expect } from "storybook/test";
|
|
4
|
+
import { cn } from "../../lib/cn";
|
|
4
5
|
import { Heading, Text } from "./typography";
|
|
5
6
|
import {
|
|
6
7
|
ProseBlockquote,
|
|
@@ -242,3 +243,230 @@ export const Prose: Story = {
|
|
|
242
243
|
</div>
|
|
243
244
|
),
|
|
244
245
|
};
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* The CJK FONT FALLBACK + LINE-BREAKING seam (#15). `--font-cjk-sans` is a
|
|
249
|
+
* dedicated tail in `--font-sans` (system faces only — never bundled; a CJK
|
|
250
|
+
* face is megabytes and every target platform already ships one) that
|
|
251
|
+
* `:lang(zh)` / `:lang(ko)` re-point per locale (`ja` is the root default);
|
|
252
|
+
* `:lang(ja)`/`:lang(zh)` also get `line-break: strict` and `:lang(ko)` gets
|
|
253
|
+
* `word-break: keep-all` (`themes.css` §§ "CJK PER-LOCALE FONT SEAM" / "CJK
|
|
254
|
+
* line-breaking"). Before this story, no story anywhere rendered CJK text, so
|
|
255
|
+
* none of this was exercised.
|
|
256
|
+
*
|
|
257
|
+
* The designed real-world shape is `<html lang="zh">` for a whole document,
|
|
258
|
+
* with `font-family: var(--font-sans)` declared ONCE at that same theme-root
|
|
259
|
+
* element. That matters for how this story has to be built: a custom
|
|
260
|
+
* property's `var()` references are substituted at the element that declares
|
|
261
|
+
* the property, and the RESULT — not the raw token stream — is what
|
|
262
|
+
* descendants inherit. So `--font-sans`'s `var(--font-cjk-sans)` is resolved
|
|
263
|
+
* once, at the theme root, using whatever `lang` is active THERE; a nested
|
|
264
|
+
* `:lang(zh)` further down only re-points `--font-cjk-sans` on that
|
|
265
|
+
* descendant — it does nothing for `--font-sans`, which the descendant never
|
|
266
|
+
* redeclares, only inherits pre-resolved. To show all three locales side by
|
|
267
|
+
* side under one shared theme root (whose own `lang` cannot be three things
|
|
268
|
+
* at once), each "with the seam" sample below redeclares the SAME expansion
|
|
269
|
+
* `--font-sans` itself uses (see `themes/light.css`/`themes/dark.css`)
|
|
270
|
+
* directly in its `style`, so the `var(--font-cjk-sans)` substitution happens
|
|
271
|
+
* fresh, at that element, against the `--font-cjk-sans` value its own `lang`
|
|
272
|
+
* cascades in via `:lang()`.
|
|
273
|
+
*/
|
|
274
|
+
type CjkSample = {
|
|
275
|
+
lang: "ja" | "zh" | "ko";
|
|
276
|
+
label: string;
|
|
277
|
+
rule: string;
|
|
278
|
+
text: string;
|
|
279
|
+
width?: string; // Optional custom width, defaults to w-56
|
|
280
|
+
};
|
|
281
|
+
|
|
282
|
+
const CJK_SAMPLES: readonly CjkSample[] = [
|
|
283
|
+
{
|
|
284
|
+
lang: "ja",
|
|
285
|
+
label: "Japanese (ja)",
|
|
286
|
+
rule: "line-break: strict",
|
|
287
|
+
text:
|
|
288
|
+
"この文章は十分に長く、コンテナの幅を超えるとブラウザが自動的に折り返しを行います。" +
|
|
289
|
+
"折り返しの際に句読点や閉じ括弧(このように)が行頭に来ないようにするのが、" +
|
|
290
|
+
"line-break: strict の役割です。",
|
|
291
|
+
},
|
|
292
|
+
{
|
|
293
|
+
lang: "zh",
|
|
294
|
+
label: "Chinese (zh)",
|
|
295
|
+
rule: "line-break: strict",
|
|
296
|
+
text:
|
|
297
|
+
"这段文字足够长,当容器宽度不足时浏览器会自动换行。line-break: strict 的作用是避免" +
|
|
298
|
+
"标点符号(例如逗号、句号或右括号)出现在行首,让排版更符合中文书写习惯。",
|
|
299
|
+
},
|
|
300
|
+
{
|
|
301
|
+
lang: "ko",
|
|
302
|
+
label: "Korean (ko)",
|
|
303
|
+
rule: "word-break: keep-all",
|
|
304
|
+
text:
|
|
305
|
+
"소프트웨어개발환경구축및정보통신기술표준화를위한국제협력과연구개발사업이진행되고있습니다. " +
|
|
306
|
+
"word-break: keep-all 속성은 긴 복합어 중간에서 줄이 바뀌지 않도록 보장하여 " +
|
|
307
|
+
"가독성을 지켜 줍니다.",
|
|
308
|
+
width: "w-36",
|
|
309
|
+
},
|
|
310
|
+
];
|
|
311
|
+
|
|
312
|
+
/** One locale panel: the seam ON (real `lang`) next to the seam forced OFF, for comparison. */
|
|
313
|
+
function CjkPanel({ sample }: { sample: CjkSample }) {
|
|
314
|
+
const { lang, label, rule, text, width = "w-56" } = sample;
|
|
315
|
+
const withSeamRef = useRef<HTMLParagraphElement>(null);
|
|
316
|
+
const [measured, setMeasured] = useState("");
|
|
317
|
+
useLayoutEffect(() => {
|
|
318
|
+
const el = withSeamRef.current;
|
|
319
|
+
if (!el) return;
|
|
320
|
+
const { lineBreak, wordBreak, fontFamily } = getComputedStyle(el);
|
|
321
|
+
setMeasured(`line-break: ${lineBreak} · word-break: ${wordBreak} · font-family: ${fontFamily}`);
|
|
322
|
+
}, []);
|
|
323
|
+
return (
|
|
324
|
+
<section aria-labelledby={`cjk-${lang}`} className="space-y-2">
|
|
325
|
+
<h3 id={`cjk-${lang}`} className="text-subtitle text-foreground">
|
|
326
|
+
{label} — <code className="text-code">{rule}</code>
|
|
327
|
+
</h3>
|
|
328
|
+
<div className="grid gap-4 sm:grid-cols-2">
|
|
329
|
+
<figure className="m-0 space-y-1">
|
|
330
|
+
<figcaption className="text-meta text-muted-foreground">
|
|
331
|
+
Without the seam (comparison only — line-break/word-break forced to the CSS-initial
|
|
332
|
+
default)
|
|
333
|
+
</figcaption>
|
|
334
|
+
{/* Comparison-only override to show what the seam replaces; a real
|
|
335
|
+
consumer never does this. Font-family is left alone here — this
|
|
336
|
+
column isolates the LINE-BREAKING half of the seam. */}
|
|
337
|
+
<p
|
|
338
|
+
lang={lang}
|
|
339
|
+
style={{ fontFamily: "var(--font-sans)", lineBreak: "auto", wordBreak: "normal" }}
|
|
340
|
+
className={cn("m-0 rounded-md border border-border bg-card p-3 text-body", width)}
|
|
341
|
+
>
|
|
342
|
+
{text}
|
|
343
|
+
</p>
|
|
344
|
+
</figure>
|
|
345
|
+
<figure className="m-0 space-y-1">
|
|
346
|
+
<figcaption className="text-meta text-muted-foreground">
|
|
347
|
+
With the seam (the default result of{" "}
|
|
348
|
+
<code className="text-code">lang="{lang}"</code>)
|
|
349
|
+
</figcaption>
|
|
350
|
+
<p
|
|
351
|
+
ref={withSeamRef}
|
|
352
|
+
lang={lang}
|
|
353
|
+
data-testid={`cjk-with-seam-${lang}`}
|
|
354
|
+
// Redeclares --font-sans's own expansion (themes/light.css,
|
|
355
|
+
// themes/dark.css) directly, rather than using
|
|
356
|
+
// `var(--font-sans)`, because --font-sans is inherited from the
|
|
357
|
+
// shared theme root as an ALREADY-substituted string — see the
|
|
358
|
+
// comment above CjkSample. Writing the expansion here forces the
|
|
359
|
+
// `var(--font-cjk-sans)` substitution to happen at THIS element,
|
|
360
|
+
// against the value its own `lang` cascades in.
|
|
361
|
+
style={{
|
|
362
|
+
fontFamily:
|
|
363
|
+
'Inter, var(--font-cjk-sans), "Helvetica Neue", Helvetica, Arial, sans-serif',
|
|
364
|
+
}}
|
|
365
|
+
className={cn("m-0 rounded-md border border-border bg-card p-3 text-body", width)}
|
|
366
|
+
>
|
|
367
|
+
{text}
|
|
368
|
+
</p>
|
|
369
|
+
</figure>
|
|
370
|
+
</div>
|
|
371
|
+
{/* The declared (not rendered) values — a real, un-bundled system CJK
|
|
372
|
+
face never looks identical across two machines, so THIS is what a
|
|
373
|
+
reviewer can actually verify: the correct stack is being asked for.
|
|
374
|
+
|
|
375
|
+
NOTE: document.fonts.check() is NOT used to verify font resolution
|
|
376
|
+
(issue #62). That API reports load status, not face existence — it
|
|
377
|
+
returns true even for nonexistent font names. The measured values
|
|
378
|
+
instead read the browser's own getComputedStyle output, which is
|
|
379
|
+
verifiable. */}
|
|
380
|
+
<p className="m-0 text-meta text-muted-foreground" data-testid={`cjk-measured-${lang}`}>
|
|
381
|
+
{measured || "measuring…"}
|
|
382
|
+
</p>
|
|
383
|
+
</section>
|
|
384
|
+
);
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
export const CJKFontAndLineBreaking: Story = {
|
|
388
|
+
name: "CJK font fallback + line-breaking (#15)",
|
|
389
|
+
parameters: {
|
|
390
|
+
docs: {
|
|
391
|
+
description: {
|
|
392
|
+
story:
|
|
393
|
+
"Japanese, Chinese and Korean samples, each wrapped in a narrow (14rem) column so " +
|
|
394
|
+
"the text wraps across multiple lines — the line-breaking rules need a real wrap " +
|
|
395
|
+
"point to have anything to do.\n\n" +
|
|
396
|
+
"**Verification by locale:**\n" +
|
|
397
|
+
"* **Japanese (`ja`) and Chinese (`zh`):** `line-break: strict` is verified via " +
|
|
398
|
+
"computed style only; no empirically-validated visual wrap difference has been found at " +
|
|
399
|
+
"this column width for any practical sample text in chromium or webkit, so the rendered " +
|
|
400
|
+
"layout is NOT asserted to differ. The rule is confirmed present via `getComputedStyle`.\n" +
|
|
401
|
+
"* **Korean (`ko`):** `word-break: keep-all` is verified both via computed style AND " +
|
|
402
|
+
"an actual rendered wrap-position difference assertion in the play function.\n\n" +
|
|
403
|
+
"The measured line under each pair reads the browser's OWN computed `line-break` / " +
|
|
404
|
+
"`word-break` / `font-family`, live. Rendered glyph shape depends on which CJK system " +
|
|
405
|
+
"fonts are installed on the machine viewing this story (none are bundled, by design " +
|
|
406
|
+
"— #15); the declared font STACK is what is verifiable everywhere.",
|
|
407
|
+
},
|
|
408
|
+
},
|
|
409
|
+
},
|
|
410
|
+
render: () => (
|
|
411
|
+
<div className="max-w-3xl space-y-8">
|
|
412
|
+
{CJK_SAMPLES.map((s) => (
|
|
413
|
+
<CjkPanel key={s.lang} sample={s} />
|
|
414
|
+
))}
|
|
415
|
+
</div>
|
|
416
|
+
),
|
|
417
|
+
// Asserts the seam on the REAL rendered surface, mirroring the DensityScale
|
|
418
|
+
// play function above: read the browser's actual computed style rather than
|
|
419
|
+
// re-deriving the CSS by hand.
|
|
420
|
+
play: async ({ canvas }) => {
|
|
421
|
+
const ja = canvas.getByTestId("cjk-with-seam-ja");
|
|
422
|
+
const zh = canvas.getByTestId("cjk-with-seam-zh");
|
|
423
|
+
const ko = canvas.getByTestId("cjk-with-seam-ko");
|
|
424
|
+
|
|
425
|
+
// :lang(ja), :lang(zh) => line-break: strict. :lang(ko) is NOT in that
|
|
426
|
+
// rule, so it stays at the CSS-initial value ("auto").
|
|
427
|
+
expect(getComputedStyle(ja).lineBreak).toBe("strict");
|
|
428
|
+
expect(getComputedStyle(zh).lineBreak).toBe("strict");
|
|
429
|
+
expect(getComputedStyle(ko).lineBreak).toBe("auto");
|
|
430
|
+
|
|
431
|
+
// :lang(ko) => word-break: keep-all. ja/zh are NOT in that rule, so they
|
|
432
|
+
// stay at the CSS-initial value ("normal").
|
|
433
|
+
expect(getComputedStyle(ko).wordBreak).toBe("keep-all");
|
|
434
|
+
expect(getComputedStyle(ja).wordBreak).toBe("normal");
|
|
435
|
+
expect(getComputedStyle(zh).wordBreak).toBe("normal");
|
|
436
|
+
|
|
437
|
+
// --font-cjk-sans is re-pointed per :lang() — the DECLARED face list
|
|
438
|
+
// differs by locale, which is what the fallback seam actually ships.
|
|
439
|
+
expect(getComputedStyle(ja).fontFamily).toContain("Hiragino Sans");
|
|
440
|
+
expect(getComputedStyle(zh).fontFamily).toContain("PingFang SC");
|
|
441
|
+
expect(getComputedStyle(ko).fontFamily).toContain("Apple SD Gothic Neo");
|
|
442
|
+
|
|
443
|
+
// Korean's word-break: keep-all must produce a RENDERED wrap-position
|
|
444
|
+
// difference. Measure bounding height via getClientRects — if the seam
|
|
445
|
+
// (word-break: keep-all) doesn't change the wrap, the height stays the
|
|
446
|
+
// same. Find the corresponding "without the seam" panel by the section's
|
|
447
|
+
// sibling figure.
|
|
448
|
+
const koSection = canvas.getByTestId("cjk-with-seam-ko").closest("section");
|
|
449
|
+
if (!koSection) throw new Error("Korean section not found");
|
|
450
|
+
const koFigures = Array.from(koSection.querySelectorAll("figure"));
|
|
451
|
+
if (koFigures.length < 2) throw new Error("Korean figures not found");
|
|
452
|
+
|
|
453
|
+
const withSeamFigure = koFigures[1]; // "With the seam" is the second figure
|
|
454
|
+
const withoutSeamFigure = koFigures[0]; // "Without the seam" is the first figure
|
|
455
|
+
|
|
456
|
+
const withSeamP = withSeamFigure.querySelector("p");
|
|
457
|
+
const withoutSeamP = withoutSeamFigure.querySelector("p");
|
|
458
|
+
|
|
459
|
+
if (!withSeamP || !withoutSeamP) {
|
|
460
|
+
throw new Error("Korean paragraph elements not found");
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
// Measure bounding height: the word-break property should change how the
|
|
464
|
+
// text wraps, resulting in a different total height.
|
|
465
|
+
const withSeamHeight = withSeamP.getBoundingClientRect().height;
|
|
466
|
+
const withoutSeamHeight = withoutSeamP.getBoundingClientRect().height;
|
|
467
|
+
|
|
468
|
+
// Assert they differ — word-break: keep-all should prevent word breaks
|
|
469
|
+
// that would otherwise occur, changing the layout.
|
|
470
|
+
expect(withSeamHeight).not.toBe(withoutSeamHeight);
|
|
471
|
+
},
|
|
472
|
+
};
|
|
@@ -11,7 +11,15 @@ const meta = {
|
|
|
11
11
|
title: "Layout/ViewToolbar",
|
|
12
12
|
component: ViewToolbar,
|
|
13
13
|
tags: ["autodocs"],
|
|
14
|
-
parameters: {
|
|
14
|
+
parameters: {
|
|
15
|
+
layout: "padded",
|
|
16
|
+
docs: {
|
|
17
|
+
description: {
|
|
18
|
+
component:
|
|
19
|
+
"The row above a list, table or board — filters and context on the left, actions on the right. Its controls are ordinary tab stops on purpose: a dense secondary control row that should cost ONE tab stop with arrow-key roving is `Layout/Toolbar`, the component that actually claims the `toolbar` ARIA role. See [Choosing between similar components](?path=/docs/docs-choosing-between-similar-components--docs) and [Docs / View Toolbar Contract](?path=/docs/docs-view-toolbar-contract--docs) (rule R10) for the filter-chip, result-count and control-height rules of this row.",
|
|
20
|
+
},
|
|
21
|
+
},
|
|
22
|
+
},
|
|
15
23
|
argTypes: {
|
|
16
24
|
info: {
|
|
17
25
|
description: "What this view is — shown in a tooltip on the ⓘ trigger.",
|
|
@@ -313,6 +321,38 @@ export const KeyboardRemoval: Story = {
|
|
|
313
321
|
},
|
|
314
322
|
};
|
|
315
323
|
|
|
324
|
+
/**
|
|
325
|
+
* `FilterChip`'s `trailing` slot (#284) — a short, non-shrinking secondary
|
|
326
|
+
* text segment (a count) that CSS truncation can never reach, unlike `label`.
|
|
327
|
+
* In a narrow container the label truncates with an ellipsis; the count
|
|
328
|
+
* stays fully visible in its own element.
|
|
329
|
+
*/
|
|
330
|
+
export const FilterChipWithTrailing: Story = {
|
|
331
|
+
render: () => (
|
|
332
|
+
<div className="w-[220px]">
|
|
333
|
+
<FilterChip
|
|
334
|
+
label="Status: Awaiting downstream reconciliation review"
|
|
335
|
+
trailing="excluded 1,204"
|
|
336
|
+
onRemove={() => undefined}
|
|
337
|
+
/>
|
|
338
|
+
</div>
|
|
339
|
+
),
|
|
340
|
+
play: async ({ canvas, canvasElement }) => {
|
|
341
|
+
const trailing = await canvas.findByText("excluded 1,204");
|
|
342
|
+
await expect(trailing).toBeInTheDocument();
|
|
343
|
+
|
|
344
|
+
const truncating = canvasElement.querySelector('[class*="truncate"]');
|
|
345
|
+
await expect(truncating).not.toBeNull();
|
|
346
|
+
await expect(truncating?.contains(trailing)).toBe(false);
|
|
347
|
+
|
|
348
|
+
await expect(
|
|
349
|
+
canvas.getByRole("button", {
|
|
350
|
+
name: "Remove filter: Status: Awaiting downstream reconciliation review · excluded 1,204",
|
|
351
|
+
}),
|
|
352
|
+
).toBeInTheDocument();
|
|
353
|
+
},
|
|
354
|
+
};
|
|
355
|
+
|
|
316
356
|
/** The same row on `dark`. */
|
|
317
357
|
export const DarkTheme: Story = {
|
|
318
358
|
globals: { theme: "dark" },
|
|
@@ -208,6 +208,39 @@ describe("FilterChip", () => {
|
|
|
208
208
|
expect(chip).not.toBeNull();
|
|
209
209
|
expect(ref.current).toBe(chip);
|
|
210
210
|
});
|
|
211
|
+
|
|
212
|
+
describe("trailing slot", () => {
|
|
213
|
+
it("renders trailing in its own element, separate from the truncating label span (#284)", () => {
|
|
214
|
+
const { container } = render(
|
|
215
|
+
<FilterChip label="Status: Failed" trailing="excluded 1,204" onRemove={() => undefined} />,
|
|
216
|
+
);
|
|
217
|
+
const label = container.querySelector("[data-slot='view-toolbar-filter-chip-label']");
|
|
218
|
+
const trailing = container.querySelector("[data-slot='view-toolbar-filter-chip-trailing']");
|
|
219
|
+
expect(label).not.toBeNull();
|
|
220
|
+
expect(trailing).not.toBeNull();
|
|
221
|
+
expect(label?.textContent).toBe("Status: Failed");
|
|
222
|
+
expect(trailing?.textContent).toBe("excluded 1,204");
|
|
223
|
+
expect(label?.className).toContain("truncate");
|
|
224
|
+
expect(trailing?.contains(label)).toBe(false);
|
|
225
|
+
expect(label?.contains(trailing as Node)).toBe(false);
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
it("composes label and trailing into the accessible name", () => {
|
|
229
|
+
render(
|
|
230
|
+
<FilterChip label="Status: Failed" trailing="excluded 1,204" onRemove={() => undefined} />,
|
|
231
|
+
);
|
|
232
|
+
expect(
|
|
233
|
+
screen.getByRole("button", { name: "Remove filter: Status: Failed · excluded 1,204" }),
|
|
234
|
+
).toBeInTheDocument();
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
it("renders no trailing element when trailing is omitted", () => {
|
|
238
|
+
const { container } = render(
|
|
239
|
+
<FilterChip label="Status: Failed" onRemove={() => undefined} />,
|
|
240
|
+
);
|
|
241
|
+
expect(container.querySelector("[data-slot='view-toolbar-filter-chip-trailing']")).toBeNull();
|
|
242
|
+
});
|
|
243
|
+
});
|
|
211
244
|
});
|
|
212
245
|
|
|
213
246
|
describe("ResultCount", () => {
|
|
@@ -200,6 +200,22 @@ export interface FilterChipProps extends Omit<ButtonHTMLAttributes<HTMLButtonEle
|
|
|
200
200
|
* as a sentence fragment out of context (and in a screen reader).
|
|
201
201
|
*/
|
|
202
202
|
label: string;
|
|
203
|
+
/**
|
|
204
|
+
* Short, non-shrinking secondary text — a count, a unit (`"excluded 1,204"`).
|
|
205
|
+
* A second text slot rather than folding it into `label` (#284): `label` is
|
|
206
|
+
* the shrinkable, safe-to-truncate part; `trailing` is short and fixed-width
|
|
207
|
+
* and must never lose characters to the ellipsis. Same shape as
|
|
208
|
+
* `TurnStatus`'s and `TerminalWorking`'s `trailing` slot — a fixed-width
|
|
209
|
+
* companion beside a truncating label. Composed into the accessible name
|
|
210
|
+
* after `label` (`"Remove filter: <label> · <trailing>"`), so the byte-
|
|
211
|
+
* identical name from before this slot existed is unchanged.
|
|
212
|
+
*
|
|
213
|
+
* Trade-off: `trailing` is `shrink-0`, so an extremely narrow container now
|
|
214
|
+
* truncates `label` down to nothing before it touches `trailing`. That is
|
|
215
|
+
* the right default — the count is short and fixed, the label is the long,
|
|
216
|
+
* prose-shaped part — but it is a choice, not a free win.
|
|
217
|
+
*/
|
|
218
|
+
trailing?: string;
|
|
203
219
|
/** Remove this filter. The WHOLE chip is the remove control — see below. */
|
|
204
220
|
onRemove: () => void;
|
|
205
221
|
}
|
|
@@ -217,7 +233,7 @@ export interface FilterChipProps extends Omit<ButtonHTMLAttributes<HTMLButtonEle
|
|
|
217
233
|
* reach for `FacetFilter` (`@elabs-ai/components-data`), not a variant of this.
|
|
218
234
|
*/
|
|
219
235
|
export const FilterChip = forwardRef<HTMLButtonElement, FilterChipProps>(function FilterChip(
|
|
220
|
-
{ label, onRemove, className, onClick, ...props },
|
|
236
|
+
{ label, trailing, onRemove, className, onClick, ...props },
|
|
221
237
|
ref,
|
|
222
238
|
) {
|
|
223
239
|
const { t } = useLocale();
|
|
@@ -227,14 +243,16 @@ export const FilterChip = forwardRef<HTMLButtonElement, FilterChipProps>(functio
|
|
|
227
243
|
ref={ref}
|
|
228
244
|
type="button"
|
|
229
245
|
data-slot="view-toolbar-filter-chip"
|
|
230
|
-
aria-label={t("ui.viewToolbar.removeFilter", {
|
|
246
|
+
aria-label={t("ui.viewToolbar.removeFilter", {
|
|
247
|
+
label: trailing ? `${label} · ${trailing}` : label,
|
|
248
|
+
})}
|
|
231
249
|
className={cn(
|
|
232
250
|
badgeVariants({ variant: "secondary" }),
|
|
233
251
|
"group min-h-6 max-w-full gap-1 ps-2.5 pe-1.5",
|
|
234
252
|
"transition-colors duration-fast ease-standard hover:bg-secondary/70",
|
|
235
253
|
// Badge's own `focus:` ring is dead weight on a <span> but would fire on
|
|
236
254
|
// every MOUSE click here; neutralise it and keep the focus-visible ring.
|
|
237
|
-
"focus:ring-0 focus-
|
|
255
|
+
"focus:ring-0 focus-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
|
|
238
256
|
"disabled:pointer-events-none disabled:opacity-50",
|
|
239
257
|
className,
|
|
240
258
|
)}
|
|
@@ -244,7 +262,22 @@ export const FilterChip = forwardRef<HTMLButtonElement, FilterChipProps>(functio
|
|
|
244
262
|
}}
|
|
245
263
|
{...props}
|
|
246
264
|
>
|
|
247
|
-
<span className="truncate">
|
|
265
|
+
<span data-slot="view-toolbar-filter-chip-label" className="min-w-0 truncate">
|
|
266
|
+
{label}
|
|
267
|
+
</span>
|
|
268
|
+
{trailing ? (
|
|
269
|
+
<>
|
|
270
|
+
<span aria-hidden="true" className="shrink-0 text-muted-foreground">
|
|
271
|
+
·
|
|
272
|
+
</span>
|
|
273
|
+
<span
|
|
274
|
+
data-slot="view-toolbar-filter-chip-trailing"
|
|
275
|
+
className="shrink-0 whitespace-nowrap tabular-nums"
|
|
276
|
+
>
|
|
277
|
+
{trailing}
|
|
278
|
+
</span>
|
|
279
|
+
</>
|
|
280
|
+
) : null}
|
|
248
281
|
<X
|
|
249
282
|
aria-hidden="true"
|
|
250
283
|
className="size-3 shrink-0 opacity-60 transition-opacity duration-fast ease-standard group-hover:opacity-100"
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
* - Search filtering while open
|
|
11
11
|
* - Keyboard navigation (ArrowDown/Up/Enter/Escape) driving aria-activedescendant
|
|
12
12
|
* - Virtualizer scroll behavior with 10,000 options
|
|
13
|
-
* -
|
|
13
|
+
* - Cross-theme visual correctness
|
|
14
14
|
*/
|
|
15
15
|
import { describe, expect, it } from "vitest";
|
|
16
16
|
import { render, screen } from "@testing-library/react";
|
|
@@ -234,7 +234,7 @@ export const WizardSteps = forwardRef<HTMLOListElement, WizardStepsProps>(functi
|
|
|
234
234
|
onClick={() => goTo(index)}
|
|
235
235
|
className={cn(
|
|
236
236
|
"flex size-7 shrink-0 items-center justify-center rounded-full border text-xs font-medium tabular-nums",
|
|
237
|
-
"focus-
|
|
237
|
+
"focus-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
|
|
238
238
|
isActive && "border-primary bg-primary text-primary-foreground",
|
|
239
239
|
// #399 — the step NUMBER is text on a `/10` wash, not plate ink
|
|
240
240
|
// and not a mark: `-text` rung. The border keeps the fill rung.
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `WorkspacePicker`'s pure, directly-testable logic — kept out of the
|
|
3
|
+
* component for the same reason `../model-picker/model-picker-state.ts` does
|
|
4
|
+
* (see that file's header comment): an expression buried in JSX cannot be
|
|
5
|
+
* unit-tested in isolation, and relative-time arithmetic in particular has
|
|
6
|
+
* edge cases worth locking down on their own.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* One recent workspace. Deliberately a strict subset of `ModelPickerItem`'s
|
|
11
|
+
* shape (`id` / `name` → `label` / `path` → `description` / `lastOpenedAt` →
|
|
12
|
+
* a formatted `meta` entry) — see the reuse note in `workspace-picker.tsx`.
|
|
13
|
+
*/
|
|
14
|
+
export interface Workspace {
|
|
15
|
+
id: string;
|
|
16
|
+
/** Rendered as the row's primary label and, when current, the trigger text. */
|
|
17
|
+
name: string;
|
|
18
|
+
/**
|
|
19
|
+
* The filesystem path. Long, user-supplied content — the row truncates it,
|
|
20
|
+
* never wraps or overflows.
|
|
21
|
+
*/
|
|
22
|
+
path: string;
|
|
23
|
+
/**
|
|
24
|
+
* When this workspace was last opened. Rendered as a relative time via
|
|
25
|
+
* `Intl.RelativeTimeFormat` (`formatLastOpened`, below) — never a
|
|
26
|
+
* pre-formatted string the caller hands over.
|
|
27
|
+
*/
|
|
28
|
+
lastOpenedAt?: Date;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* "2 hours ago" / "in 3 minutes" / "yesterday" — `Intl.RelativeTimeFormat`
|
|
33
|
+
* picks the coarsest unit (minutes → hours → days) that still reads
|
|
34
|
+
* naturally, instead of e.g. "127 minutes ago".
|
|
35
|
+
*
|
|
36
|
+
* @param now - injection seam for tests; defaults to the real clock.
|
|
37
|
+
*/
|
|
38
|
+
export function formatLastOpened(date: Date, locale: string, now: number = Date.now()): string {
|
|
39
|
+
const diffMs = date.getTime() - now;
|
|
40
|
+
const rtf = new Intl.RelativeTimeFormat(locale, { numeric: "auto" });
|
|
41
|
+
|
|
42
|
+
const minutes = Math.round(diffMs / (60 * 1000));
|
|
43
|
+
if (Math.abs(minutes) < 60) return rtf.format(minutes, "minute");
|
|
44
|
+
|
|
45
|
+
const hours = Math.round(diffMs / (60 * 60 * 1000));
|
|
46
|
+
if (Math.abs(hours) < 24) return rtf.format(hours, "hour");
|
|
47
|
+
|
|
48
|
+
const days = Math.round(diffMs / (24 * 60 * 60 * 1000));
|
|
49
|
+
return rtf.format(days, "day");
|
|
50
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/react-vite";
|
|
2
|
+
import { expect, fn, within } from "storybook/test";
|
|
3
|
+
import { WorkspacePicker } from "./workspace-picker";
|
|
4
|
+
import type { Workspace } from "./workspace-picker-state";
|
|
5
|
+
|
|
6
|
+
const workspaces: Workspace[] = [
|
|
7
|
+
{
|
|
8
|
+
id: "ws-brand-ui",
|
|
9
|
+
name: "elabs-components",
|
|
10
|
+
path: "~/dev/elabs-components",
|
|
11
|
+
lastOpenedAt: new Date(Date.now() - 15 * 60 * 1000),
|
|
12
|
+
},
|
|
13
|
+
{
|
|
14
|
+
id: "ws-brainless",
|
|
15
|
+
name: "brainless",
|
|
16
|
+
path: "~/dev/oss/brainless",
|
|
17
|
+
lastOpenedAt: new Date(Date.now() - 26 * 60 * 60 * 1000),
|
|
18
|
+
},
|
|
19
|
+
{
|
|
20
|
+
id: "ws-monorepo",
|
|
21
|
+
name: "platform-monorepo",
|
|
22
|
+
// Long, user-supplied content — truncates instead of overflowing.
|
|
23
|
+
path: "~/workspaces/clients/northwind/services/platform-monorepo/packages/api-gateway",
|
|
24
|
+
lastOpenedAt: new Date(Date.now() - 9 * 24 * 60 * 60 * 1000),
|
|
25
|
+
},
|
|
26
|
+
];
|
|
27
|
+
|
|
28
|
+
const meta = {
|
|
29
|
+
title: "Core/WorkspacePicker",
|
|
30
|
+
component: WorkspacePicker,
|
|
31
|
+
tags: ["autodocs"],
|
|
32
|
+
parameters: {
|
|
33
|
+
docs: {
|
|
34
|
+
description: {
|
|
35
|
+
component:
|
|
36
|
+
"A recent-workspace switcher, built as a `ModelPicker` PRESET rather than a new " +
|
|
37
|
+
"picker (issue #111) — see `docs/decisions/2026-09-01-brainless-adoption-architecture.md` " +
|
|
38
|
+
"§ 7. Maps `Workspace[]` onto `ModelPicker`'s grouped rows, formats `lastOpenedAt` with " +
|
|
39
|
+
"`Intl.RelativeTimeFormat`, marks the current workspace in text, and renders a free-text " +
|
|
40
|
+
"path entry into `ModelPicker`'s `footer` slot.\n\n" +
|
|
41
|
+
"No `Sidebar` dependency, unlike `TeamSwitcher` — this works anywhere a picker is needed.",
|
|
42
|
+
},
|
|
43
|
+
},
|
|
44
|
+
},
|
|
45
|
+
args: { workspaces, currentId: "ws-brand-ui", onSelect: fn(), onSubmitPath: fn() },
|
|
46
|
+
} satisfies Meta<typeof WorkspacePicker>;
|
|
47
|
+
|
|
48
|
+
export default meta;
|
|
49
|
+
type Story = StoryObj<typeof meta>;
|
|
50
|
+
|
|
51
|
+
export const Default: Story = {};
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* The current workspace is marked in the row's TEXT (a "Current" badge, not
|
|
55
|
+
* only `ModelPicker`'s check glyph) — open the popover to see it, and confirm
|
|
56
|
+
* it survives a greyscale render.
|
|
57
|
+
*/
|
|
58
|
+
export const CurrentWorkspaceMarkedInText: Story = {
|
|
59
|
+
play: async ({ canvas, canvasElement, userEvent }) => {
|
|
60
|
+
await userEvent.click(canvas.getByRole("combobox"));
|
|
61
|
+
const body = within(canvasElement.ownerDocument.body);
|
|
62
|
+
const currentRow = await body.findByRole("option", { name: /elabs-components/i });
|
|
63
|
+
// The marker is part of the row's ACCESSIBLE NAME, not only a visual glyph.
|
|
64
|
+
await expect(currentRow).toHaveAccessibleName(/current/i);
|
|
65
|
+
const otherRow = body.getByRole("option", { name: /brainless/i });
|
|
66
|
+
await expect(otherRow).not.toHaveAccessibleName(/current/i);
|
|
67
|
+
},
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
/** No workspace chosen yet — the trigger falls back to a designed placeholder. */
|
|
71
|
+
export const NoCurrentWorkspace: Story = {
|
|
72
|
+
args: { currentId: undefined },
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Nothing to show — `ModelPicker`'s own designed empty panel (reused, not
|
|
77
|
+
* reimplemented) plus the free-text path entry, which always renders in the
|
|
78
|
+
* footer regardless of body state and is this state's one action.
|
|
79
|
+
*/
|
|
80
|
+
export const Empty: Story = {
|
|
81
|
+
args: { workspaces: [], currentId: undefined },
|
|
82
|
+
play: async ({ canvas, canvasElement, userEvent }) => {
|
|
83
|
+
await userEvent.click(canvas.getByRole("combobox"));
|
|
84
|
+
await within(canvasElement.ownerDocument.body).findByRole("dialog");
|
|
85
|
+
},
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
/** A very long, deeply nested path truncates instead of overflowing the row. */
|
|
89
|
+
export const LongPath: Story = {
|
|
90
|
+
args: {
|
|
91
|
+
workspaces: [
|
|
92
|
+
{
|
|
93
|
+
id: "ws-deep",
|
|
94
|
+
name: "api-gateway",
|
|
95
|
+
path: "~/workspaces/clients/northwind/services/platform-monorepo/packages/api-gateway/src/routes/internal",
|
|
96
|
+
},
|
|
97
|
+
],
|
|
98
|
+
currentId: "ws-deep",
|
|
99
|
+
},
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
export const Disabled: Story = { args: { disabled: true } };
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* The free-text path entry validates non-empty and reports the raw string —
|
|
106
|
+
* this component performs no filesystem access of any kind (D5). Also proves
|
|
107
|
+
* Enter submits the path instead of being hijacked by cmdk's own list
|
|
108
|
+
* navigation (both bind a keydown handler on the same `Command` root).
|
|
109
|
+
*/
|
|
110
|
+
export const SubmitFreeTextPath: Story = {
|
|
111
|
+
play: async ({ canvas, canvasElement, userEvent, args }) => {
|
|
112
|
+
await userEvent.click(canvas.getByRole("combobox"));
|
|
113
|
+
const body = within(canvasElement.ownerDocument.body);
|
|
114
|
+
const input = body.getByPlaceholderText(/path/i);
|
|
115
|
+
const submit = body.getByRole("button", { name: /open/i });
|
|
116
|
+
|
|
117
|
+
// Nothing typed yet — validated as empty, no call.
|
|
118
|
+
await userEvent.click(submit);
|
|
119
|
+
await expect(args.onSubmitPath).not.toHaveBeenCalled();
|
|
120
|
+
|
|
121
|
+
await userEvent.type(input, "~/dev/new-project{Enter}");
|
|
122
|
+
await expect(args.onSubmitPath).toHaveBeenCalledWith("~/dev/new-project");
|
|
123
|
+
// Enter submitted the path — it did not move cmdk's list highlight/select.
|
|
124
|
+
await expect(args.onSelect).not.toHaveBeenCalled();
|
|
125
|
+
},
|
|
126
|
+
};
|