@elabs-ai/components-flow 4.1.0 → 4.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@elabs-ai/components-flow",
3
- "version": "4.1.0",
3
+ "version": "4.2.0",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",
@@ -32,8 +32,8 @@
32
32
  "@xyflow/react": "^12.11.1",
33
33
  "react": "^18.2.0 || ^19.0.0",
34
34
  "react-dom": "^18.2.0 || ^19.0.0",
35
- "@elabs-ai/components-tokens": "4.1.0",
36
- "@elabs-ai/components-ui": "4.1.0"
35
+ "@elabs-ai/components-tokens": "4.2.0",
36
+ "@elabs-ai/components-ui": "4.2.0"
37
37
  },
38
38
  "devDependencies": {
39
39
  "@testing-library/jest-dom": "^6.6.3",
@@ -51,11 +51,11 @@
51
51
  "tsup": "^8.3.5",
52
52
  "typescript": "^5.7.3",
53
53
  "vitest": "^3.0.2",
54
- "@elabs-ai/components-tokens": "4.1.0",
55
- "@elabs-ai/components-icons": "4.1.0",
56
54
  "@elabs-ai/components-eslint-config": "0.1.0",
57
- "@elabs-ai/components-ui": "4.1.0",
58
- "@elabs-ai/components-typescript-config": "0.1.0"
55
+ "@elabs-ai/components-icons": "4.2.0",
56
+ "@elabs-ai/components-tokens": "4.2.0",
57
+ "@elabs-ai/components-typescript-config": "0.1.0",
58
+ "@elabs-ai/components-ui": "4.2.0"
59
59
  },
60
60
  "scripts": {
61
61
  "build": "tsup",
@@ -0,0 +1,49 @@
1
+ // GENERATED by scripts/gen-contract-tests.mjs — do not edit; re-run the generator instead.
2
+ /**
3
+ * Contract probe for InspectorPanel (`packages/flow/src/inspector-panel/inspector-panel.tsx`), derived from its
4
+ * `Default` story (packages/flow/src/inspector-panel/inspector-panel.stories.tsx). See scripts/gen-contract-tests.mjs.
5
+ */
6
+ import { describe, it, expect, afterEach } from "vitest";
7
+ import { render, cleanup } from "@testing-library/react";
8
+ import { createRef } from "react";
9
+ import * as stories from "../inspector-panel/inspector-panel.stories";
10
+ import knownFailuresJson from "../../../../scripts/check/contract-known-failures.json";
11
+
12
+ afterEach(cleanup);
13
+
14
+ // Every component here has its own prop/ref/element shape; a generated probe
15
+ // stays generic on purpose (loosely typed, not untyped — see
16
+ // scripts/gen-contract-tests.mjs) rather than re-deriving each one.
17
+ const KNOWN_FAILURES: Record<string, string> = knownFailuresJson;
18
+ const meta = stories.default as { component?: unknown; args?: Record<string, unknown> };
19
+ const Default = (stories as { Default?: { args?: Record<string, unknown> } }).Default;
20
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- probe target; see comment above
21
+ const Component = meta.component as any;
22
+ const args = { ...(meta.args ?? {}), ...(Default?.args ?? {}) };
23
+ const isForwardRefComponent = Component?.["$$typeof"] === Symbol.for("react.forward_ref");
24
+
25
+ /** Wrap a known, tracked failure in `it.fails` so fixing it forces the key's removal. */
26
+ function contractIt(assertion: string, name: string, fn: () => void) {
27
+ const key = `flow-inspectorpanel--default|jsdom|jsdom|${assertion}`;
28
+ const reason = KNOWN_FAILURES[key];
29
+ if (reason) return it.fails(`${name} (known failure: ${reason})`, fn);
30
+ return it(name, fn);
31
+ }
32
+
33
+ describe("InspectorPanel contract", () => {
34
+ it.skipIf(!isForwardRefComponent)("forwards a ref to a DOM element", () => {
35
+ const ref = createRef<Element>();
36
+ render(<Component {...args} ref={ref} />);
37
+ expect(ref.current).toBeInstanceOf(Element);
38
+ });
39
+
40
+ contractIt("className", "merges a caller className onto the root", () => {
41
+ const { container } = render(<Component {...args} className="contract-probe" />);
42
+ expect(container.querySelector(".contract-probe")).not.toBeNull();
43
+ });
44
+
45
+ contractIt("data-slot", 'exposes data-slot="inspector-panel" on its root', () => {
46
+ const { container } = render(<Component {...args} />);
47
+ expect(container.querySelector('[data-slot="inspector-panel"]')).not.toBeNull();
48
+ });
49
+ });
@@ -0,0 +1,49 @@
1
+ // GENERATED by scripts/gen-contract-tests.mjs — do not edit; re-run the generator instead.
2
+ /**
3
+ * Contract probe for Legend (`packages/flow/src/legend/legend.tsx`), derived from its
4
+ * `Default` story (packages/flow/src/legend/legend.stories.tsx). See scripts/gen-contract-tests.mjs.
5
+ */
6
+ import { describe, it, expect, afterEach } from "vitest";
7
+ import { render, cleanup } from "@testing-library/react";
8
+ import { createRef } from "react";
9
+ import * as stories from "../legend/legend.stories";
10
+ import knownFailuresJson from "../../../../scripts/check/contract-known-failures.json";
11
+
12
+ afterEach(cleanup);
13
+
14
+ // Every component here has its own prop/ref/element shape; a generated probe
15
+ // stays generic on purpose (loosely typed, not untyped — see
16
+ // scripts/gen-contract-tests.mjs) rather than re-deriving each one.
17
+ const KNOWN_FAILURES: Record<string, string> = knownFailuresJson;
18
+ const meta = stories.default as { component?: unknown; args?: Record<string, unknown> };
19
+ const Default = (stories as { Default?: { args?: Record<string, unknown> } }).Default;
20
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- probe target; see comment above
21
+ const Component = meta.component as any;
22
+ const args = { ...(meta.args ?? {}), ...(Default?.args ?? {}) };
23
+ const isForwardRefComponent = Component?.["$$typeof"] === Symbol.for("react.forward_ref");
24
+
25
+ /** Wrap a known, tracked failure in `it.fails` so fixing it forces the key's removal. */
26
+ function contractIt(assertion: string, name: string, fn: () => void) {
27
+ const key = `flow-legend--default|jsdom|jsdom|${assertion}`;
28
+ const reason = KNOWN_FAILURES[key];
29
+ if (reason) return it.fails(`${name} (known failure: ${reason})`, fn);
30
+ return it(name, fn);
31
+ }
32
+
33
+ describe("Legend contract", () => {
34
+ it.skipIf(!isForwardRefComponent)("forwards a ref to a DOM element", () => {
35
+ const ref = createRef<Element>();
36
+ render(<Component {...args} ref={ref} />);
37
+ expect(ref.current).toBeInstanceOf(Element);
38
+ });
39
+
40
+ contractIt("className", "merges a caller className onto the root", () => {
41
+ const { container } = render(<Component {...args} className="contract-probe" />);
42
+ expect(container.querySelector(".contract-probe")).not.toBeNull();
43
+ });
44
+
45
+ contractIt("data-slot", 'exposes data-slot="legend" on its root', () => {
46
+ const { container } = render(<Component {...args} />);
47
+ expect(container.querySelector('[data-slot="legend"]')).not.toBeNull();
48
+ });
49
+ });
@@ -135,6 +135,23 @@ export const Default: Story = {
135
135
  return (hi! + 0.05) / (lo! + 0.05);
136
136
  };
137
137
 
138
+ // Measure the SETTLED paint, not the frame the theme switched in. The preview's
139
+ // `ThemeBoundary` writes `data-theme` in an effect, after the canvas has already
140
+ // rendered once under the `:root` (light) fallback. The storybook Vitest project runs
141
+ // with `reducedMotion: "reduce"`, and the tokens' reduced-motion backstop clamps
142
+ // `transition-duration` to 0.01ms on EVERY element — whose `transition-property`
143
+ // defaults to `all` — so the light→dark swap becomes a one-frame colour transition.
144
+ // Read synchronously, `getComputedStyle` still returns the transition's START value
145
+ // (the light panel's white) while the node rects, mounted after the switch, are
146
+ // already dark: a false 2.47:1 in the dark theme only. Awaiting the running
147
+ // transitions on the panel and its rects reads what the browser actually paints.
148
+ await Promise.all(
149
+ minimap.getAnimations({ subtree: true }).map((animation) =>
150
+ // A transition superseded by another change rejects `finished`; the settled
151
+ // value is still what the readback below measures.
152
+ animation.finished.catch(() => undefined),
153
+ ),
154
+ );
138
155
  const panelBackground = getComputedStyle(minimap).backgroundColor;
139
156
 
140
157
  // The whole point of this helper is that a blank minimap must FAIL the check — lock
@@ -1,5 +1,5 @@
1
1
  import { type CSSProperties, type ReactNode } from "react";
2
- import { Reveal, useCollapsiblePanel } from "@elabs-ai/components-ui";
2
+ import { Reveal, useCollapsiblePanel, useLocale } from "@elabs-ai/components-ui";
3
3
  import { cn } from "@elabs-ai/components-ui/lib/cn";
4
4
 
5
5
  const INSPECTOR_PANEL_WIDTH = "18rem";
@@ -41,10 +41,10 @@ export interface InspectorPanelProps {
41
41
  * motion safe) when `open`/`defaultOpen` drive it.
42
42
  */
43
43
  export function InspectorPanel({
44
- title = "Inspector",
44
+ title,
45
45
  children,
46
46
  onClose,
47
- emptyMessage = "Select a node to see its details.",
47
+ emptyMessage,
48
48
  hasSelection = true,
49
49
  selectionKey,
50
50
  open,
@@ -54,6 +54,9 @@ export function InspectorPanel({
54
54
  width = INSPECTOR_PANEL_WIDTH,
55
55
  className,
56
56
  }: InspectorPanelProps) {
57
+ const { t } = useLocale();
58
+ const resolvedTitle = title ?? t("flow.inspectorPanel.title");
59
+ const resolvedEmptyMessage = emptyMessage ?? t("flow.inspectorPanel.emptyMessage");
57
60
  const panel = useCollapsiblePanel({
58
61
  side,
59
62
  open,
@@ -84,11 +87,11 @@ export function InspectorPanel({
84
87
  {/* Detail-tier surface: raised `card` — robust across themes (#193). */}
85
88
  <div className="flex h-full w-full flex-col bg-card">
86
89
  <div className="flex items-center justify-between border-b px-4 py-3">
87
- <h3 className="text-body font-semibold text-foreground">{title}</h3>
90
+ <h3 className="text-body font-semibold text-foreground">{resolvedTitle}</h3>
88
91
  {onClose ? (
89
92
  <button
90
93
  type="button"
91
- aria-label="Close inspector"
94
+ aria-label={t("flow.inspectorPanel.close")}
92
95
  onClick={onClose}
93
96
  className="rounded-sm p-1 text-muted-foreground hover:text-foreground focus-ring"
94
97
  >
@@ -129,7 +132,7 @@ export function InspectorPanel({
129
132
  </Reveal>
130
133
  ) : (
131
134
  <div tabIndex={0} className="focus-ring flex-1 overflow-y-auto p-4 text-body">
132
- <p className="text-muted-foreground">{emptyMessage}</p>
135
+ <p className="text-muted-foreground">{resolvedEmptyMessage}</p>
133
136
  </div>
134
137
  )}
135
138
  </div>
@@ -1,3 +1,4 @@
1
+ import { LocaleProvider } from "@elabs-ai/components-ui";
1
2
  import { cleanup, render, screen } from "@testing-library/react";
2
3
  import { afterEach, describe, expect, it } from "vitest";
3
4
  import { computeEdgeWeightScale, type WeightedEdgeLike } from "../flow-weighted-edge/weight-scale";
@@ -212,4 +213,14 @@ describe('Legend variant="scale", kind="color"', () => {
212
213
  });
213
214
  expect(group).toHaveAttribute("tabindex", "0");
214
215
  });
216
+
217
+ it("formats the default tick label with the active LocaleProvider locale, not the host locale", () => {
218
+ render(
219
+ <LocaleProvider locale="de-DE">
220
+ <Legend variant="scale" kind="color" domain={[0, 1000]} />
221
+ </LocaleProvider>,
222
+ );
223
+ // de-DE groups thousands with a period, never a comma.
224
+ expect(screen.getByText("1.000")).toBeInTheDocument();
225
+ });
215
226
  });
@@ -1,3 +1,4 @@
1
+ import { useLocale } from "@elabs-ai/components-ui";
1
2
  import { cn } from "@elabs-ai/components-ui/lib/cn";
2
3
  import { computeEdgeWeightScale, type WeightedEdgeLike } from "../flow-weighted-edge/weight-scale";
3
4
 
@@ -33,7 +34,11 @@ export interface LegendScaleProps {
33
34
  kind: "width" | "color";
34
35
  /** `[min, max]` of the underlying value the ramp represents. */
35
36
  domain: [number, number];
36
- /** Formats a domain value for display at a tick. @default `(v) => v.toLocaleString()` */
37
+ /**
38
+ * Formats a domain value for display at a tick.
39
+ * @default the active `LocaleProvider` locale's `formatNumber` (host locale
40
+ * when no provider is mounted) — never a hardcoded `toLocaleString()`.
41
+ */
37
42
  format?: (value: number) => string;
38
43
  /**
39
44
  * Sample count for `kind: "width"`: `"minmax"` draws a min/max pair of
@@ -51,8 +56,6 @@ export interface LegendScaleProps {
51
56
 
52
57
  export type LegendProps = LegendCategoricalProps | LegendScaleProps;
53
58
 
54
- const defaultFormat = (value: number): string => value.toLocaleString();
55
-
56
59
  /**
57
60
  * Small legend mapping colors/types to labels for a canvas or chart
58
61
  * (`variant: "categorical"`, the default), or — via `variant="scale"` — a
@@ -121,16 +124,21 @@ function scaleKindLabel(kind: "width" | "color"): string {
121
124
  function LegendScale({
122
125
  kind,
123
126
  domain,
124
- format = defaultFormat,
127
+ format,
125
128
  ticks = "minmax",
126
129
  title,
127
130
  className,
128
131
  }: LegendScaleProps) {
132
+ // The active `LocaleProvider` locale, not a hardcoded `toLocaleString()` —
133
+ // otherwise a `de-DE`-configured app still reads the domain ticks in the
134
+ // browser's host locale.
135
+ const { formatNumber } = useLocale();
136
+ const format_ = format ?? formatNumber;
129
137
  const [min, max] = domain;
130
138
  // e.g. "Edge width scale, 2 to 48, minimum to maximum" — a single accessible
131
139
  // name for the whole reading key, since it is one keyboard tab stop, not one
132
140
  // per tick.
133
- const ariaLabel = `Edge ${scaleKindLabel(kind)} scale, ${format(min)} to ${format(max)}, minimum to maximum`;
141
+ const ariaLabel = `Edge ${scaleKindLabel(kind)} scale, ${format_(min)} to ${format_(max)}, minimum to maximum`;
134
142
 
135
143
  return (
136
144
  <div
@@ -145,9 +153,9 @@ function LegendScale({
145
153
  >
146
154
  {title ? <div className="text-body font-medium text-foreground">{title}</div> : null}
147
155
  {kind === "width" ? (
148
- <LegendScaleWidth domain={domain} format={format} ticks={ticks} />
156
+ <LegendScaleWidth domain={domain} format={format_} ticks={ticks} />
149
157
  ) : (
150
- <LegendScaleColor domain={domain} format={format} />
158
+ <LegendScaleColor domain={domain} format={format_} />
151
159
  )}
152
160
  </div>
153
161
  );