@elabs-ai/components-editor 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 +6 -7
- package/dist/{chunk-LBC5VJBD.js → chunk-C62O7IOQ.js} +110 -21
- package/dist/chunk-C62O7IOQ.js.map +1 -0
- package/dist/index.css +4 -2
- package/dist/index.css.map +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.js +57 -20
- package/dist/index.js.map +1 -1
- package/dist/markdown/index.css +4 -2
- package/dist/markdown/index.css.map +1 -1
- package/dist/markdown/index.d.ts +2 -2
- package/dist/markdown/index.js +9 -9
- package/dist/markdown/index.js.map +1 -1
- package/dist/{markdown-editor-DfBZibAn.d.ts → markdown-editor-CBp_4eDv.d.ts} +1 -1
- package/package.json +6 -6
- package/src/ai-objects/entity.tsx +1 -1
- package/src/ai-objects/knowledge-card.tsx +1 -1
- package/src/calc-block/calc-editor.css +1 -1
- package/src/code-editor/code-editor.stories.tsx +76 -1
- package/src/code-editor/code-editor.tsx +1 -1
- package/src/code-workspace/code-workspace.test.tsx +83 -0
- package/src/code-workspace/code-workspace.tsx +73 -20
- package/src/diff-editor/diff-editor.stories.tsx +9 -1
- package/src/lib/monaco-deep-imports.d.ts +59 -0
- package/src/lib/monaco-theme-bridge.test.ts +307 -0
- package/src/lib/monaco-theme-bridge.ts +154 -10
- package/src/markdown-academic/citations.tsx +2 -2
- package/src/markdown-academic/footnotes.tsx +2 -2
- package/src/markdown-academic/toc.tsx +1 -1
- package/src/markdown-editor/directive-views.tsx +2 -5
- package/src/markdown-editor/markdown-editor.css +26 -3
- package/src/markdown-editor/markdown-editor.focus.test.ts +49 -0
- package/src/markdown-editor/markdown-editor.stories.tsx +128 -7
- package/src/markdown-editor/markdown-editor.tsx +8 -5
- package/src/markdown-editor/milkdown-react/use-get-editor.timer-leak.test.ts +80 -0
- package/src/markdown-editor/milkdown-react/use-get-editor.ts +37 -1
- package/src/markdown-editor/paste-embed.ts +2 -1
- package/src/markdown-editor/slash/slash-menu.stories.tsx +8 -3
- package/src/markdown-editor/slash/slash-menu.test.tsx +27 -0
- package/src/markdown-editor/slash/slash-menu.tsx +13 -1
- package/src/markdown-editor/table-view.tsx +3 -2
- package/src/markdown-iteration/iteration-builder-dialog.stories.tsx +57 -19
- package/src/markdown-outline/document-outline.stories.tsx +1 -1
- package/src/markdown-outline/document-outline.tsx +1 -1
- package/src/markdown-preview/markdown-preview-transclusion.test.tsx +1 -1
- package/src/markdown-preview/markdown-preview.stories.tsx +24 -2
- package/src/markdown-preview/markdown-preview.test.tsx +20 -3
- package/src/markdown-toolbar/markdown-toolbar.stories.tsx +3 -0
- package/src/markdown-workspace/markdown-workspace.test.tsx +31 -1
- package/src/mermaid-diagram/mermaid-viewer.tsx +1 -1
- package/src/prose/prose.stories.tsx +3 -0
- package/src/prose/prose.test.ts +54 -0
- package/src/prose/prose.tsx +7 -0
- package/dist/chunk-LBC5VJBD.js.map +0 -1
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
import { resolve } from "node:path";
|
|
4
|
+
|
|
5
|
+
// Read the stylesheet as text. The repo runs vitest per-package (turbo /
|
|
6
|
+
// `--filter`), so cwd is the package root — `src/...` resolves deterministically.
|
|
7
|
+
const css = readFileSync(resolve(process.cwd(), "src/markdown-editor/markdown-editor.css"), "utf8");
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* #309: `.ProseMirror` used to suppress the platform focus outline
|
|
11
|
+
* (`outline: none`, repeated as a dead no-op at `:focus`) with no replacement
|
|
12
|
+
* on the element itself — the only stand-in lived on a DIFFERENT element in a
|
|
13
|
+
* different file (`markdown-editor.tsx`'s wrapper `focus-ring-within` class),
|
|
14
|
+
* an implicit, unlocked, easily-overridden dependency raw CSS can't express.
|
|
15
|
+
* jsdom doesn't apply real focus/outline rendering, so this locks the
|
|
16
|
+
* stylesheet TEXT deterministically; `markdown-editor.stories.tsx`'s
|
|
17
|
+
* `FocusIndicator` play is the rendered-surface proof (resolved computed
|
|
18
|
+
* values, real contrast, both themes).
|
|
19
|
+
*/
|
|
20
|
+
describe("editor focus indicator CSS (regression, #309)", () => {
|
|
21
|
+
it("gives the editable its own :focus-visible indicator with a real outline", () => {
|
|
22
|
+
const focusVisibleRule = css.match(/\.milkdown-host \.ProseMirror:focus-visible\s*\{([^}]*)\}/);
|
|
23
|
+
expect(
|
|
24
|
+
focusVisibleRule,
|
|
25
|
+
"expected a .milkdown-host .ProseMirror:focus-visible rule",
|
|
26
|
+
).not.toBeNull();
|
|
27
|
+
const body = focusVisibleRule![1]!;
|
|
28
|
+
expect(body).toMatch(/outline:\s*(?!none\b)\S/);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("never suppresses the outline on a :focus/:focus-visible rule with nothing else in it", () => {
|
|
32
|
+
// A rule whose ENTIRE body is `outline: none;` (whitespace only otherwise)
|
|
33
|
+
// on a focus selector is exactly the dead-suppression defect #309 found —
|
|
34
|
+
// catches a future edit re-adding `.ProseMirror:focus { outline: none; }`
|
|
35
|
+
// (or a :focus-visible equivalent) with no compensating declaration.
|
|
36
|
+
const focusRules = [...css.matchAll(/([.\w-]+:focus(?:-visible)?)\s*\{([^}]*)\}/g)];
|
|
37
|
+
for (const [, selector, body] of focusRules) {
|
|
38
|
+
const bareOutlineNone = /^\s*outline:\s*none;?\s*$/.test(body!);
|
|
39
|
+
expect(
|
|
40
|
+
bareOutlineNone,
|
|
41
|
+
`${selector} suppresses outline with no replacement in the same rule`,
|
|
42
|
+
).toBe(false);
|
|
43
|
+
}
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("no longer contains the dead .ProseMirror:focus { outline: none; } rule", () => {
|
|
47
|
+
expect(css).not.toMatch(/\.milkdown-host \.ProseMirror:focus\s*\{\s*outline:\s*none;?\s*\}/);
|
|
48
|
+
});
|
|
49
|
+
});
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { Meta, StoryObj } from "@storybook/react-vite";
|
|
2
|
-
import { expect, waitFor, within } from "storybook/test";
|
|
2
|
+
import { expect, userEvent, waitFor, within } from "storybook/test";
|
|
3
3
|
import { useRef, useState } from "react";
|
|
4
4
|
import { ThemeProvider } from "@elabs-ai/components-tokens";
|
|
5
5
|
import { MarkdownEditor, type MarkdownEditorHandle } from "./markdown-editor";
|
|
@@ -14,9 +14,12 @@ const meta = {
|
|
|
14
14
|
docs: {
|
|
15
15
|
description: {
|
|
16
16
|
component:
|
|
17
|
+
"The WYSIWYG markdown surface; the Monaco SOURCE editor is `Editor/CodeEditor` " +
|
|
18
|
+
"and the read-only render is `Editor/MarkdownPreview` — see " +
|
|
19
|
+
"[Choosing between similar components](?path=/docs/docs-choosing-between-similar-components--docs). " +
|
|
17
20
|
"A headless Milkdown (ProseMirror) WYSIWYG markdown surface vendored onto " +
|
|
18
21
|
"brand-ui. Companion to the Monaco `CodeEditor` (source) in the same package. " +
|
|
19
|
-
"Theming is 100% semantic tokens, so it tracks
|
|
22
|
+
"Theming is 100% semantic tokens, so it tracks every theme via `data-theme`. " +
|
|
20
23
|
"Depends on only `@milkdown/kit` — no Vue/Crepe.",
|
|
21
24
|
},
|
|
22
25
|
},
|
|
@@ -138,7 +141,7 @@ Two upstream data sources are still un-mapped.
|
|
|
138
141
|
* The brand `:::` directives render as the REAL @brand components inside the editor
|
|
139
142
|
* (a live `Card`, `Alert`, `MetricBlock`), with the card/callout title and the metric
|
|
140
143
|
* label/value editable inline — not the old token-styled `toDOM` chrome. Switch the
|
|
141
|
-
* Storybook theme to confirm they track
|
|
144
|
+
* Storybook theme to confirm they track every theme.
|
|
142
145
|
*/
|
|
143
146
|
export const LiveDirectives: Story = {
|
|
144
147
|
name: "Live directives (inline-editable)",
|
|
@@ -275,9 +278,9 @@ Prose continues normally after the table.
|
|
|
275
278
|
* - Tab moves to the next cell; Shift+Tab to the previous.
|
|
276
279
|
* - The serialized markdown (shown below the editor) stays lossless GFM.
|
|
277
280
|
*
|
|
278
|
-
*
|
|
281
|
+
* Cross-theme sweep: switch the Storybook theme toolbar to verify the table
|
|
279
282
|
* borders (`border-border-strong`), header tint (`bg-surface-muted`), and
|
|
280
|
-
* toolbar chrome use only semantic tokens across
|
|
283
|
+
* toolbar chrome use only semantic tokens across every theme.
|
|
281
284
|
*/
|
|
282
285
|
export const EditableTable: Story = {
|
|
283
286
|
name: "Editable table (wysiwyg-tables)",
|
|
@@ -342,7 +345,14 @@ export const FillsContainer: Story = {
|
|
|
342
345
|
await expect(editable).toBeVisible();
|
|
343
346
|
// The 1-line doc's editable fills the 700px container (well past the ~144px
|
|
344
347
|
// content floor) — clickable everywhere, with room for the `/` menu.
|
|
345
|
-
|
|
348
|
+
//
|
|
349
|
+
// Waited for, not read once: the fill chain is `.milkdown-host .ProseMirror.editor
|
|
350
|
+
// { flex: 1 1 auto }`, and Milkdown adds the `editor` class in a later commit than
|
|
351
|
+
// the one that puts `role="textbox"` in the DOM. Between the two the editable sits at
|
|
352
|
+
// its own `min-height: 9rem` (144px) inside an already-700px host — measured, not
|
|
353
|
+
// guessed. Reading `clientHeight` on the frame `findByRole` resolves therefore samples
|
|
354
|
+
// a real but transient layout; the settled condition is the one this story is about.
|
|
355
|
+
await waitFor(() => expect(editable.clientHeight).toBeGreaterThan(500), { timeout: 8000 });
|
|
346
356
|
},
|
|
347
357
|
};
|
|
348
358
|
|
|
@@ -504,7 +514,7 @@ export const IterationContextMenu: Story = {
|
|
|
504
514
|
};
|
|
505
515
|
|
|
506
516
|
/**
|
|
507
|
-
*
|
|
517
|
+
* Cross-theme sweep for the node menu (#223 round-2): both `NodeMenu` (⋯) and
|
|
508
518
|
* `IterationContextMenu` (right-click) above run only under the toolbar's
|
|
509
519
|
* DEFAULT theme (`light`) — Storybook's `defaultTheme` isn't overridable
|
|
510
520
|
* per-story from the global decorator alone, and toggling `preview.tsx` by hand
|
|
@@ -558,3 +568,114 @@ export const IterationContextMenuHighDecoration: Story = {
|
|
|
558
568
|
render: IterationContextMenu.render,
|
|
559
569
|
play: IterationContextMenu.play,
|
|
560
570
|
};
|
|
571
|
+
|
|
572
|
+
/**
|
|
573
|
+
* #309 — the editable's own compound focus indicator. Resolves the governing
|
|
574
|
+
* theme element FROM THE SUBJECT (`el.closest("[data-theme]")`, never a
|
|
575
|
+
* guessed ancestor — quality-gates.md § Theme-safe), reads RESOLVED computed
|
|
576
|
+
* values (not the class string — a class-string assertion passed on the
|
|
577
|
+
* previous, invisible wrapper ring, which is exactly how this shipped), and
|
|
578
|
+
* asserts at least one of the indicator's two layers clears 3:1 against the
|
|
579
|
+
* ground the editable sits on. `markdown-editor.focus.test.ts` locks the
|
|
580
|
+
* stylesheet TEXT; this is the rendered-surface proof.
|
|
581
|
+
*/
|
|
582
|
+
export const FocusIndicator: Story = {
|
|
583
|
+
name: "Focus indicator (#309)",
|
|
584
|
+
render: () => (
|
|
585
|
+
<div className="mx-auto max-w-3xl p-6">
|
|
586
|
+
<MarkdownEditor
|
|
587
|
+
defaultValue={"# Focus indicator\n\nClick or Tab into the editable below."}
|
|
588
|
+
aria-label="Markdown editor"
|
|
589
|
+
/>
|
|
590
|
+
</div>
|
|
591
|
+
),
|
|
592
|
+
play: async ({ canvasElement }) => {
|
|
593
|
+
const canvas = within(canvasElement);
|
|
594
|
+
const editable = await canvas.findByRole(
|
|
595
|
+
"textbox",
|
|
596
|
+
{ name: "Markdown editor" },
|
|
597
|
+
{ timeout: 8000 },
|
|
598
|
+
);
|
|
599
|
+
await expect(editable).toBeVisible();
|
|
600
|
+
expect(editable.classList.contains("ProseMirror")).toBe(true);
|
|
601
|
+
|
|
602
|
+
const wrapper = canvasElement.querySelector<HTMLElement>('[data-testid="markdown-editor"]');
|
|
603
|
+
expect(wrapper).not.toBeNull();
|
|
604
|
+
|
|
605
|
+
// Resolve ANY CSS colour string down to sRGB so a contrast ratio is a
|
|
606
|
+
// measurement, not an assumption — same helper `FlowNode`'s
|
|
607
|
+
// `FocusIndicator` lock uses for the flow-canvas half of this same fix
|
|
608
|
+
// family (#286).
|
|
609
|
+
const toSrgb = (colour: string): [number, number, number] => {
|
|
610
|
+
const surface = document.createElement("canvas");
|
|
611
|
+
surface.width = 1;
|
|
612
|
+
surface.height = 1;
|
|
613
|
+
const ctx = surface.getContext("2d")!;
|
|
614
|
+
ctx.fillStyle = colour;
|
|
615
|
+
ctx.fillRect(0, 0, 1, 1);
|
|
616
|
+
const [r, g, b] = ctx.getImageData(0, 0, 1, 1).data;
|
|
617
|
+
return [r! / 255, g! / 255, b! / 255];
|
|
618
|
+
};
|
|
619
|
+
const luminance = ([r, g, b]: [number, number, number]) => {
|
|
620
|
+
const lin = (v: number) => (v <= 0.04045 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4));
|
|
621
|
+
return 0.2126 * lin(r) + 0.7152 * lin(g) + 0.0722 * lin(b);
|
|
622
|
+
};
|
|
623
|
+
const contrast = (a: string, b: string) => {
|
|
624
|
+
const [hi, lo] = [luminance(toSrgb(a)), luminance(toSrgb(b))].sort((x, y) => y - x);
|
|
625
|
+
return (hi! + 0.05) / (lo! + 0.05);
|
|
626
|
+
};
|
|
627
|
+
|
|
628
|
+
// Resting: no indicator on the editable, and the wrapper draws no ring of
|
|
629
|
+
// its own either (#309 — exactly one indicator, never a doubled one).
|
|
630
|
+
await expect(getComputedStyle(editable).outlineStyle).toBe("none");
|
|
631
|
+
await expect(getComputedStyle(wrapper!).boxShadow).toBe("none");
|
|
632
|
+
|
|
633
|
+
// Click into the editable. `:focus-visible` matches on pointer click for
|
|
634
|
+
// a contenteditable surface per spec (unlike a plain <input>/<button>),
|
|
635
|
+
// so this is a faithful proxy for the real "click to edit" path.
|
|
636
|
+
await userEvent.click(editable);
|
|
637
|
+
await waitFor(() => {
|
|
638
|
+
expect(getComputedStyle(editable).outlineStyle).toBe("solid");
|
|
639
|
+
});
|
|
640
|
+
|
|
641
|
+
// Exactly one indicator: the wrapper still draws nothing.
|
|
642
|
+
await expect(getComputedStyle(wrapper!).boxShadow).toBe("none");
|
|
643
|
+
|
|
644
|
+
const ground = getComputedStyle(wrapper!).backgroundColor;
|
|
645
|
+
const contourInk = getComputedStyle(editable).outlineColor;
|
|
646
|
+
const ringInk = getComputedStyle(editable).getPropertyValue("--ring").trim();
|
|
647
|
+
const contourRatio = contrast(contourInk, ground);
|
|
648
|
+
const ringRatio = contrast(ringInk, ground);
|
|
649
|
+
const bestRatio = Math.max(contourRatio, ringRatio);
|
|
650
|
+
await expect(
|
|
651
|
+
bestRatio,
|
|
652
|
+
`focus indicator vs editor ground: contour ${contourInk} = ${contourRatio.toFixed(2)}:1, ring ${ringInk} = ${ringRatio.toFixed(2)}:1`,
|
|
653
|
+
).toBeGreaterThanOrEqual(3);
|
|
654
|
+
|
|
655
|
+
// Blur restores the resting state.
|
|
656
|
+
(editable as HTMLElement).blur();
|
|
657
|
+
await waitFor(() => {
|
|
658
|
+
expect(getComputedStyle(editable).outlineStyle).toBe("none");
|
|
659
|
+
});
|
|
660
|
+
},
|
|
661
|
+
};
|
|
662
|
+
|
|
663
|
+
export const FocusIndicatorDark: Story = {
|
|
664
|
+
name: "Focus indicator (#309) — dark",
|
|
665
|
+
decorators: [
|
|
666
|
+
(Story) => (
|
|
667
|
+
<ThemeProvider defaultTheme="dark" storageKey={null}>
|
|
668
|
+
<Story />
|
|
669
|
+
</ThemeProvider>
|
|
670
|
+
),
|
|
671
|
+
],
|
|
672
|
+
render: FocusIndicator.render,
|
|
673
|
+
play: FocusIndicator.play,
|
|
674
|
+
};
|
|
675
|
+
|
|
676
|
+
export const FocusIndicatorHighDecoration: Story = {
|
|
677
|
+
name: "Focus indicator (#309) — high decoration",
|
|
678
|
+
globals: { decoration: "10" },
|
|
679
|
+
render: FocusIndicator.render,
|
|
680
|
+
play: FocusIndicator.play,
|
|
681
|
+
};
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
*
|
|
8
8
|
* - Engine dep: only `@milkdown/kit` (headless). React glue is vendored under
|
|
9
9
|
* ./milkdown-react so we never pull `@milkdown/react` → `@milkdown/crepe` → Vue.
|
|
10
|
-
* - Theming: token-driven via markdown-editor.css (
|
|
10
|
+
* - Theming: token-driven via markdown-editor.css (every theme, no raw color).
|
|
11
11
|
* - Controlled (`value` + `onChange`) or uncontrolled (`defaultValue`). Mirrors the
|
|
12
12
|
* platform: `isControlled = value !== undefined`; never flips between modes.
|
|
13
13
|
* - StrictMode-safe (see markdown-editor.strictmode.test.tsx).
|
|
@@ -527,10 +527,13 @@ export const MarkdownEditor = forwardRef<MarkdownEditorHandle, MarkdownEditorPro
|
|
|
527
527
|
<div
|
|
528
528
|
data-testid="markdown-editor"
|
|
529
529
|
className={cn(
|
|
530
|
-
//
|
|
531
|
-
//
|
|
532
|
-
//
|
|
533
|
-
|
|
530
|
+
// No focus-ring utility here (deliberately, #309): the editable
|
|
531
|
+
// `.ProseMirror` element owns the compound focus indicator itself
|
|
532
|
+
// (`markdown-editor.css`'s `:focus-visible` rule), so it renders
|
|
533
|
+
// even when a consumer `className` overrides this wrapper's
|
|
534
|
+
// classes (`MarkdownWorkspace` does exactly that via
|
|
535
|
+
// `className="border-0"`) and never doubles up with a wrapper ring.
|
|
536
|
+
"milkdown-host overflow-auto rounded-md border border-border bg-background text-foreground",
|
|
534
537
|
className,
|
|
535
538
|
)}
|
|
536
539
|
// Publish the shared markdown scale as CSS vars the editor CSS reads, so the
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { cleanup, render, screen, waitFor } from "@testing-library/react";
|
|
2
|
+
import { createElement } from "react";
|
|
3
|
+
import { afterEach, expect, test, vi } from "vitest";
|
|
4
|
+
|
|
5
|
+
import { MarkdownEditor } from "../markdown-editor";
|
|
6
|
+
import { waitForPendingMilkdownTeardown } from "./use-get-editor";
|
|
7
|
+
|
|
8
|
+
afterEach(cleanup);
|
|
9
|
+
|
|
10
|
+
// @milkdown/ctx's internal `Timer` (armed by `Ctx.wait()`, which
|
|
11
|
+
// `@milkdown/core`'s `create()`/`destroy()` call internally for every
|
|
12
|
+
// `createTimer(name)` — e.g. `ConfigReady`, `InitReady`, `EditorViewReady`)
|
|
13
|
+
// uses `TimerType`'s default delay: `createTimer(name, timeout = 3e3)`,
|
|
14
|
+
// never overridden anywhere in this repo's editor setup.
|
|
15
|
+
const MILKDOWN_TIMER_DELAY_MS = 3000;
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Locks issue #84: `@milkdown/ctx`'s `Timer` class starts a 3-second
|
|
19
|
+
* `setTimeout` on every wait and never clears the handle on the resolve
|
|
20
|
+
* path — only the DOM/global event listener is removed
|
|
21
|
+
* (`lib/index.js:281-288`, vendored `@milkdown/ctx@7.21.2`). That leaves the
|
|
22
|
+
* timer armed for up to 3s after the promise it backs has already settled,
|
|
23
|
+
* and if it fires after Vitest has recycled the test file's jsdom
|
|
24
|
+
* environment, its callback's bare (unqualified) `removeEventListener` call
|
|
25
|
+
* throws `ReferenceError: removeEventListener is not defined` — the
|
|
26
|
+
* mechanism behind #65's originally-reported error.
|
|
27
|
+
*
|
|
28
|
+
* A bare "was `clearTimeout` called at some point" assertion would pass
|
|
29
|
+
* regardless of this bug, since unrelated React/ProseMirror machinery also
|
|
30
|
+
* calls `clearTimeout`. So this test tracks every `setTimeout` handle armed
|
|
31
|
+
* with Milkdown's exact 3-second delay, and every `clearTimeout` call, and
|
|
32
|
+
* asserts each such handle is cleared once Milkdown's own teardown
|
|
33
|
+
* (`waitForPendingMilkdownTeardown`, tracked in `use-get-editor.ts`) has
|
|
34
|
+
* settled. Against the unpatched `@milkdown/ctx` dependency this fails (no
|
|
35
|
+
* `clearTimeout` call in the vendored file at all — `grep -c clearTimeout`
|
|
36
|
+
* returns 0); the `patches/@milkdown__ctx@7.21.2.patch` fix (storing the
|
|
37
|
+
* handle and clearing it in `#removeListener`, which both the resolve and
|
|
38
|
+
* reject/timeout paths already call) makes it pass.
|
|
39
|
+
*/
|
|
40
|
+
test("clears the vendored @milkdown/ctx wait timer on teardown (#84)", async () => {
|
|
41
|
+
const originalSetTimeout = globalThis.setTimeout;
|
|
42
|
+
const originalClearTimeout = globalThis.clearTimeout;
|
|
43
|
+
|
|
44
|
+
const armedHandles = new Set<ReturnType<typeof setTimeout>>();
|
|
45
|
+
const clearedHandles = new Set<Parameters<typeof clearTimeout>[0]>();
|
|
46
|
+
|
|
47
|
+
vi.spyOn(globalThis, "setTimeout").mockImplementation(((
|
|
48
|
+
handler: TimerHandler,
|
|
49
|
+
timeout?: number,
|
|
50
|
+
...args: unknown[]
|
|
51
|
+
) => {
|
|
52
|
+
const handle = originalSetTimeout(handler as never, timeout, ...args);
|
|
53
|
+
if (timeout === MILKDOWN_TIMER_DELAY_MS) armedHandles.add(handle);
|
|
54
|
+
return handle;
|
|
55
|
+
}) as unknown as typeof setTimeout);
|
|
56
|
+
|
|
57
|
+
vi.spyOn(globalThis, "clearTimeout").mockImplementation(((
|
|
58
|
+
handle?: Parameters<typeof clearTimeout>[0],
|
|
59
|
+
) => {
|
|
60
|
+
clearedHandles.add(handle);
|
|
61
|
+
return originalClearTimeout(handle as never);
|
|
62
|
+
}) as unknown as typeof clearTimeout);
|
|
63
|
+
|
|
64
|
+
const { unmount } = render(createElement(MarkdownEditor, { defaultValue: "# Hello Timer" }));
|
|
65
|
+
await waitFor(() => expect(screen.getByText("Hello Timer")).toBeInTheDocument());
|
|
66
|
+
|
|
67
|
+
unmount();
|
|
68
|
+
await waitForPendingMilkdownTeardown();
|
|
69
|
+
|
|
70
|
+
vi.restoreAllMocks();
|
|
71
|
+
|
|
72
|
+
// Sanity: the mechanism under test actually armed at least one 3-second
|
|
73
|
+
// wait timer during create()/destroy() — otherwise the loop below would
|
|
74
|
+
// trivially pass over an empty set.
|
|
75
|
+
expect(armedHandles.size).toBeGreaterThan(0);
|
|
76
|
+
|
|
77
|
+
for (const handle of armedHandles) {
|
|
78
|
+
expect(clearedHandles.has(handle)).toBe(true);
|
|
79
|
+
}
|
|
80
|
+
});
|
|
@@ -9,6 +9,16 @@
|
|
|
9
9
|
* settles), so the double-mount converges to a single editor. The co-located
|
|
10
10
|
* `markdown-editor.strictmode.test.tsx` is the gate that proves that convergence
|
|
11
11
|
* for our vendored copy; harden here only if that test ever shows >1 surface.
|
|
12
|
+
*
|
|
13
|
+
* DESTROY gets the same rigor (issue #65). Effect cleanup fires
|
|
14
|
+
* `editor.destroy()` but nothing awaited it, and `@milkdown/ctx` schedules its
|
|
15
|
+
* own internal async cleanup (a timer) inside that promise — so an unmount that
|
|
16
|
+
* doesn't wait can let the timer fire after Vitest has already recycled the
|
|
17
|
+
* file's jsdom environment (`ReferenceError: removeEventListener is not
|
|
18
|
+
* defined`). `pendingDestroys` tracks every in-flight `destroy()`, and
|
|
19
|
+
* `waitForPendingMilkdownTeardown()` lets a caller — a test's `afterEach`, or any
|
|
20
|
+
* consumer that cares — await "every Milkdown teardown has settled" the same way
|
|
21
|
+
* `create()` already converges before this effect resolves.
|
|
12
22
|
*/
|
|
13
23
|
import { createContext, useContext, useEffect, useRef } from "react";
|
|
14
24
|
|
|
@@ -16,6 +26,25 @@ import type { EditorInfoCtx } from "./types";
|
|
|
16
26
|
|
|
17
27
|
export const editorInfoContext = createContext<EditorInfoCtx>({} as EditorInfoCtx);
|
|
18
28
|
|
|
29
|
+
/** Module-scoped registry of in-flight Milkdown `destroy()` promises. Not part
|
|
30
|
+
* of the package's public API (not re-exported from `./index`) — reached by
|
|
31
|
+
* relative import from tests/consumers that specifically need to await
|
|
32
|
+
* teardown, mirroring how `editorInfoContext` itself stays internal. */
|
|
33
|
+
const pendingDestroys = new Set<Promise<unknown>>();
|
|
34
|
+
|
|
35
|
+
/** True while at least one Milkdown `destroy()` is still in flight. */
|
|
36
|
+
export function hasPendingMilkdownTeardown(): boolean {
|
|
37
|
+
return pendingDestroys.size > 0;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Await every Milkdown `destroy()` currently in flight. Resolves immediately
|
|
41
|
+
* when none are pending. Never rejects — `destroy()` already routes its own
|
|
42
|
+
* failure through `console.error` (see the cleanup below), so a caller awaiting
|
|
43
|
+
* teardown only needs "settled", not "succeeded". */
|
|
44
|
+
export function waitForPendingMilkdownTeardown(): Promise<void> {
|
|
45
|
+
return Promise.all(pendingDestroys).then(() => undefined);
|
|
46
|
+
}
|
|
47
|
+
|
|
19
48
|
export function useGetEditor() {
|
|
20
49
|
const {
|
|
21
50
|
dom,
|
|
@@ -48,7 +77,14 @@ export function useGetEditor() {
|
|
|
48
77
|
.catch(console.error);
|
|
49
78
|
|
|
50
79
|
return () => {
|
|
51
|
-
|
|
80
|
+
// `.catch(console.error)` means this promise itself never rejects, so
|
|
81
|
+
// it's safe to await via `Promise.all` in `waitForPendingMilkdownTeardown`
|
|
82
|
+
// without a stray unhandled rejection.
|
|
83
|
+
const destroyPromise = editor.destroy().catch(console.error);
|
|
84
|
+
pendingDestroys.add(destroyPromise);
|
|
85
|
+
void destroyPromise.finally(() => {
|
|
86
|
+
pendingDestroys.delete(destroyPromise);
|
|
87
|
+
});
|
|
52
88
|
};
|
|
53
89
|
}, [dom, editorRef, getEditor, setLoading]);
|
|
54
90
|
|
|
@@ -123,8 +123,9 @@ function makeErrorChip(message: string): HTMLElement {
|
|
|
123
123
|
chip.setAttribute("role", "alert");
|
|
124
124
|
chip.setAttribute("aria-live", "assertive");
|
|
125
125
|
chip.setAttribute("title", message);
|
|
126
|
+
// #124: the chip's own "Upload failed" label is running text — ink rung.
|
|
126
127
|
chip.className =
|
|
127
|
-
"inline-flex items-center gap-1 rounded px-2 py-0.5 text-caption bg-destructive/10 text-destructive " +
|
|
128
|
+
"inline-flex items-center gap-1 rounded px-2 py-0.5 text-caption bg-destructive/10 text-destructive-text " +
|
|
128
129
|
"border border-destructive/30 select-none";
|
|
129
130
|
|
|
130
131
|
const label = document.createElement("span");
|
|
@@ -14,19 +14,22 @@ import { SlashMenu } from "./slash-menu";
|
|
|
14
14
|
* `MarkdownWorkspace` (default `true`).
|
|
15
15
|
*
|
|
16
16
|
* The popup itself is rendered here standalone (the `SlashMenu` stories) so its
|
|
17
|
-
* branding + a11y can be verified directly across
|
|
17
|
+
* branding + a11y can be verified directly across every theme; the in-editor
|
|
18
18
|
* stories exercise the integration (mount + the `slashMenu={false}` no-op). The
|
|
19
19
|
* live keyboard/insert UX is best verified in a real browser — ProseMirror
|
|
20
20
|
* keystroke dispatch can't be fully simulated in jsdom.
|
|
21
21
|
*/
|
|
22
22
|
const meta = {
|
|
23
|
-
title: "Editor/MarkdownEditor/
|
|
23
|
+
title: "Editor/MarkdownEditor/SlashMenu",
|
|
24
24
|
component: SlashMenu,
|
|
25
25
|
tags: ["autodocs"],
|
|
26
26
|
parameters: {
|
|
27
27
|
docs: {
|
|
28
28
|
description: {
|
|
29
29
|
component:
|
|
30
|
+
"The EDITOR slash menu, which inserts ProseMirror nodes; a chat composer uses " +
|
|
31
|
+
"`AI/Composer/PromptInputSlash` and a console composer `Terminal/TerminalSlashMenu` — see " +
|
|
32
|
+
"[Choosing between similar components](?path=/docs/docs-choosing-between-similar-components--docs). " +
|
|
30
33
|
"Branded `/` command menu for the WYSIWYG editor. Inserts brand `:::` " +
|
|
31
34
|
"directives + basic blocks at the caret. Token-driven `listbox`/`option` " +
|
|
32
35
|
"list; the editor `textbox` owns `aria-activedescendant`.",
|
|
@@ -92,7 +95,9 @@ export const Empty: Story = {
|
|
|
92
95
|
),
|
|
93
96
|
play: async ({ canvasElement }) => {
|
|
94
97
|
const canvas = within(canvasElement);
|
|
95
|
-
|
|
98
|
+
// A `role="option"` (not a bare div), so it's reachable via an
|
|
99
|
+
// assistive-tech query and stays inside the listbox contract (#157).
|
|
100
|
+
await expect(canvas.getByRole("option", { name: "No matching blocks" })).toBeVisible();
|
|
96
101
|
},
|
|
97
102
|
};
|
|
98
103
|
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Locking test for #157 (site 1): `SlashMenu`'s empty state used to be a bare
|
|
3
|
+
* `<div>` inside `role="listbox"` — not `option`/`group`, so a user who types
|
|
4
|
+
* a query matching nothing got silence (axe `aria-required-children`).
|
|
5
|
+
*/
|
|
6
|
+
import { describe, it, expect } from "vitest";
|
|
7
|
+
import { render, screen } from "@testing-library/react";
|
|
8
|
+
|
|
9
|
+
import { SlashMenu } from "./slash-menu";
|
|
10
|
+
|
|
11
|
+
describe("SlashMenu — empty state a11y (#157)", () => {
|
|
12
|
+
it("announces the no-match message as a disabled option, not a bare div", () => {
|
|
13
|
+
render(<SlashMenu commands={[]} onSelect={() => {}} emptyLabel="No matching blocks" />);
|
|
14
|
+
|
|
15
|
+
const listbox = screen.getByRole("listbox", { name: "Insert block" });
|
|
16
|
+
// Reachable via an assistive-tech query, not only present in the DOM.
|
|
17
|
+
const empty = screen.getByRole("option", { name: "No matching blocks" });
|
|
18
|
+
expect(listbox).toContainElement(empty);
|
|
19
|
+
expect(empty).toHaveAttribute("aria-disabled", "true");
|
|
20
|
+
|
|
21
|
+
// Every direct child of the listbox is an `option` or `group` — never a
|
|
22
|
+
// bare, roleless element.
|
|
23
|
+
for (const child of Array.from(listbox.children)) {
|
|
24
|
+
expect(["option", "group"]).toContain(child.getAttribute("role"));
|
|
25
|
+
}
|
|
26
|
+
});
|
|
27
|
+
});
|
|
@@ -65,7 +65,19 @@ export const SlashMenu = forwardRef<HTMLDivElement, SlashMenuProps>(function Sla
|
|
|
65
65
|
{...props}
|
|
66
66
|
>
|
|
67
67
|
{commands.length === 0 ? (
|
|
68
|
-
|
|
68
|
+
// A `role="listbox"` may only own `option`/`group` children (WCAG 1.3.1,
|
|
69
|
+
// #157): a bare `<div>` here is an unannounced, dangling message — an
|
|
70
|
+
// AT user who types a query matching nothing gets silence. Shaping it
|
|
71
|
+
// as a disabled, unselectable option keeps the listbox contract AND
|
|
72
|
+
// makes the message reachable by an accessible-name query.
|
|
73
|
+
<div
|
|
74
|
+
role="option"
|
|
75
|
+
aria-disabled="true"
|
|
76
|
+
aria-selected="false"
|
|
77
|
+
className="cursor-default select-none px-2 py-6 text-center text-caption text-muted-foreground"
|
|
78
|
+
>
|
|
79
|
+
{emptyLabel}
|
|
80
|
+
</div>
|
|
69
81
|
) : (
|
|
70
82
|
groups.map(({ group, commands: groupCommands }) => (
|
|
71
83
|
<div key={group} role="group" aria-label={group} className="overflow-hidden p-1">
|
|
@@ -109,10 +109,11 @@ function ToolbarButton({
|
|
|
109
109
|
className={cn(
|
|
110
110
|
"inline-flex h-7 items-center gap-1 rounded px-2 text-caption font-medium",
|
|
111
111
|
"border border-border-strong",
|
|
112
|
-
"focus-
|
|
112
|
+
"focus-ring",
|
|
113
113
|
"transition-colors duration-fast ease-standard motion-reduce:transition-none",
|
|
114
|
+
// #124: the button's LABEL text, not a mark — ink rung.
|
|
114
115
|
variant === "destructive"
|
|
115
|
-
? "bg-background text-destructive hover:bg-destructive/10"
|
|
116
|
+
? "bg-background text-destructive-text hover:bg-destructive/10"
|
|
116
117
|
: "bg-background text-foreground hover:bg-surface-muted",
|
|
117
118
|
)}
|
|
118
119
|
>
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { Button } from "@elabs-ai/components-ui";
|
|
2
2
|
import type { Meta, StoryObj } from "@storybook/react-vite";
|
|
3
3
|
import { useState } from "react";
|
|
4
|
-
import { expect, userEvent, within } from "storybook/test";
|
|
4
|
+
import { expect, userEvent, waitFor, within } from "storybook/test";
|
|
5
5
|
|
|
6
6
|
import { IterationBuilderDialog } from "./iteration-builder-dialog";
|
|
7
7
|
import type { IterationBuilderValue } from "./iteration-builder";
|
|
@@ -41,6 +41,32 @@ const room = (child: HTMLElement, box: HTMLElement) => {
|
|
|
41
41
|
};
|
|
42
42
|
};
|
|
43
43
|
|
|
44
|
+
/**
|
|
45
|
+
* Presence is not visibility. `findByText` resolves the instant the node lands in
|
|
46
|
+
* the DOM — which is INSIDE the dialog's enter animation (`data-[state=open]:animate-in
|
|
47
|
+
* fade-in-0 zoom-in-95` on `DialogContent`), so the query can hand back an element
|
|
48
|
+
* still holding that animation's start value, `opacity: 0`. A one-shot
|
|
49
|
+
* `toBeVisible()` on the node the query just returned then fails.
|
|
50
|
+
*
|
|
51
|
+
* Reduced motion does NOT remove that window: the token backstop in `themes.css`
|
|
52
|
+
* (`@media (prefers-reduced-motion: reduce)`) clamps every animation to `0.01ms`,
|
|
53
|
+
* it does not cancel it — and by taking the enter animation's real duration out of
|
|
54
|
+
* the way it makes the play function reach the assertion sooner. That is why these
|
|
55
|
+
* stories only started failing in CI once the browser context asked for reduced
|
|
56
|
+
* motion (`apps/docs/vitest.config.ts`); the two stories below that already waited
|
|
57
|
+
* for the settled state kept passing in the same run.
|
|
58
|
+
*
|
|
59
|
+
* (The failure PRINTS as an empty element — `<th scope="col" />`. That is jest-dom's
|
|
60
|
+
* formatting, not the DOM: `toBeVisible` reports `element.cloneNode(false)`, a
|
|
61
|
+
* shallow clone, so children are never shown. The element was populated.)
|
|
62
|
+
*
|
|
63
|
+
* So assert the SETTLED end state: re-query inside `waitFor` and re-check visibility
|
|
64
|
+
* until it holds. Not a timeout bump — a stale node can never be asserted, and the
|
|
65
|
+
* text still has to become visible for the story to pass.
|
|
66
|
+
*/
|
|
67
|
+
const expectVisibleText = (scope: HTMLElement, text: string) =>
|
|
68
|
+
waitFor(() => expect(within(scope).getByText(text)).toBeVisible(), { timeout: 8000 });
|
|
69
|
+
|
|
44
70
|
const meta = {
|
|
45
71
|
title: "Editor/Iteration/BuilderDialog",
|
|
46
72
|
component: IterationBuilderDialog,
|
|
@@ -105,14 +131,12 @@ export const PivotBuilder: Story = {
|
|
|
105
131
|
// Each value string renders TWICE — once as a TagInput chip in the left
|
|
106
132
|
// column, once in the live preview — so scope queries to the preview region
|
|
107
133
|
// (the confirming pattern from PivotWithCalc / WithNestedCard below).
|
|
108
|
-
const preview =
|
|
109
|
-
await d.findByRole("region", { name: /live preview/i }, { timeout: 8000 }),
|
|
110
|
-
);
|
|
134
|
+
const preview = await d.findByRole("region", { name: /live preview/i }, { timeout: 8000 });
|
|
111
135
|
// The live preview renders a populated matrix from the embedded value lists:
|
|
112
136
|
// axis headers + an interpolated cell.
|
|
113
|
-
await
|
|
114
|
-
await
|
|
115
|
-
await
|
|
137
|
+
await expectVisibleText(preview, "North");
|
|
138
|
+
await expectVisibleText(preview, "Q1");
|
|
139
|
+
await expectVisibleText(preview, "Q1 · North");
|
|
116
140
|
},
|
|
117
141
|
};
|
|
118
142
|
|
|
@@ -149,13 +173,24 @@ export const IterateBuilder: Story = {
|
|
|
149
173
|
const canvas = within(canvasElement);
|
|
150
174
|
await userEvent.click(canvas.getByRole("button", { name: /Edit iteration/i }));
|
|
151
175
|
const dialog = await within(document.body).findByRole("dialog", {}, { timeout: 12000 });
|
|
176
|
+
// The per-cell template field is a Monaco editor (`MarkdownWorkspace`
|
|
177
|
+
// defaultMode="source"). axe scans whatever is in the DOM the instant it
|
|
178
|
+
// runs, so a scan that races Monaco's first paint can measure a token span
|
|
179
|
+
// before its (AA-clamped) syntax color has actually been applied — wait for
|
|
180
|
+
// a real rendered token span first (#88).
|
|
181
|
+
await waitFor(
|
|
182
|
+
() => expect(dialog.querySelector('.view-line span[class*="mtk"]')).toBeTruthy(),
|
|
183
|
+
{ timeout: 8000 },
|
|
184
|
+
);
|
|
152
185
|
// "Alice" renders TWICE — as a TagInput chip AND in the live preview — so
|
|
153
186
|
// scope to the preview region (mirrors the PivotBuilder fix above).
|
|
154
|
-
const preview = within(
|
|
155
|
-
|
|
187
|
+
const preview = await within(dialog).findByRole(
|
|
188
|
+
"region",
|
|
189
|
+
{ name: /live preview/i },
|
|
190
|
+
{ timeout: 8000 },
|
|
156
191
|
);
|
|
157
192
|
// The live preview renders one cell per embedded value.
|
|
158
|
-
await
|
|
193
|
+
await expectVisibleText(preview, "Alice");
|
|
159
194
|
|
|
160
195
|
// #425 acceptance criterion: this dialog's `Bind name` field sits directly
|
|
161
196
|
// against the (now real) `DialogBody` scrollport as its first child — the
|
|
@@ -244,12 +279,13 @@ export const WithNestedCard: Story = {
|
|
|
244
279
|
const canvas = within(canvasElement);
|
|
245
280
|
await userEvent.click(canvas.getByRole("button", { name: /Edit iteration with card/i }));
|
|
246
281
|
const dialog = await within(document.body).findByRole("dialog", {}, { timeout: 12000 });
|
|
247
|
-
|
|
248
|
-
//
|
|
249
|
-
|
|
282
|
+
// The nested card rendered as a real component (title + body interpolated) —
|
|
283
|
+
// waited out through `expectVisibleText` (see its note: presence ≠ visibility
|
|
284
|
+
// while the dialog's enter animation is on its first frame)...
|
|
285
|
+
await expectVisibleText(dialog, "Lead engineer for Ada");
|
|
250
286
|
// ...and the content AFTER it survived (dropped before the fence-collision fix).
|
|
251
|
-
await
|
|
252
|
-
await
|
|
287
|
+
await expectVisibleText(dialog, "Notes for Ada");
|
|
288
|
+
await expectVisibleText(dialog, "Notes for Grace");
|
|
253
289
|
},
|
|
254
290
|
};
|
|
255
291
|
|
|
@@ -284,10 +320,12 @@ export const BentoBuilder: Story = {
|
|
|
284
320
|
const dialog = await within(document.body).findByRole("dialog", {}, { timeout: 12000 });
|
|
285
321
|
// "Alpha"/"Epsilon" render TWICE — as TagInput chips AND in the live
|
|
286
322
|
// preview's BentoGrid tiles — so scope to the preview region.
|
|
287
|
-
const preview = within(
|
|
288
|
-
|
|
323
|
+
const preview = await within(dialog).findByRole(
|
|
324
|
+
"region",
|
|
325
|
+
{ name: /live preview/i },
|
|
326
|
+
{ timeout: 8000 },
|
|
289
327
|
);
|
|
290
|
-
await
|
|
291
|
-
await
|
|
328
|
+
await expectVisibleText(preview, "Alpha");
|
|
329
|
+
await expectVisibleText(preview, "Epsilon");
|
|
292
330
|
},
|
|
293
331
|
};
|
|
@@ -60,7 +60,7 @@ function ItemActionsDemo() {
|
|
|
60
60
|
aria-label={`Pin section “${item.text}”`}
|
|
61
61
|
aria-pressed={pinned === item.id}
|
|
62
62
|
onClick={() => setPinned((p) => (p === item.id ? null : item.id))}
|
|
63
|
-
className="rounded-sm p-0.5 text-muted-foreground hover:text-foreground focus-
|
|
63
|
+
className="rounded-sm p-0.5 text-muted-foreground hover:text-foreground focus-ring"
|
|
64
64
|
>
|
|
65
65
|
<Pin className="size-3" aria-hidden="true" />
|
|
66
66
|
</button>
|