@dropsh/plugin-tui 0.5.3 → 0.5.5

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/src/link.tsx DELETED
@@ -1,78 +0,0 @@
1
- import { Text } from "ink";
2
- import type React from "react";
3
- import { createContext, useContext, useEffect, useMemo, useRef, useState } from "react";
4
- import type { LinkDescriptor } from "./types.js";
5
-
6
- interface Entry {
7
- id: symbol;
8
- target: LinkDescriptor;
9
- }
10
-
11
- interface FocusRegistry {
12
- // register/unregister are called from TuiLink effects, never during render,
13
- // so a TuiLink re-rendering without a Provider re-render cannot create
14
- // duplicate entries. Registration order (via mount-effect order) is DOM order.
15
- register(id: symbol, target: LinkDescriptor): void;
16
- unregister(id: symbol): void;
17
- indexOf(id: symbol): number;
18
- count(): number;
19
- focusedIndex: number;
20
- targetAt(i: number): LinkDescriptor | undefined;
21
- }
22
-
23
- const Ctx = createContext<FocusRegistry | null>(null);
24
-
25
- export function useFocusRegistry(): FocusRegistry {
26
- const reg = useContext(Ctx);
27
- if (!reg) throw new Error("useFocusRegistry must be used within FocusRegistryProvider");
28
- return reg;
29
- }
30
-
31
- export function FocusRegistryProvider({
32
- focusedIndex = 0,
33
- children,
34
- }: {
35
- focusedIndex?: number;
36
- children: React.ReactNode;
37
- }): React.ReactElement {
38
- const [entries, setEntries] = useState<Entry[]>([]);
39
- const reg = useMemo<FocusRegistry>(
40
- () => ({
41
- register(id, target) {
42
- setEntries((prev) => (prev.some((e) => e.id === id) ? prev : [...prev, { id, target }]));
43
- },
44
- unregister(id) {
45
- setEntries((prev) => prev.filter((e) => e.id !== id));
46
- },
47
- indexOf: (id) => entries.findIndex((e) => e.id === id),
48
- count: () => entries.length,
49
- focusedIndex,
50
- targetAt: (i) => entries[i]?.target,
51
- }),
52
- [entries, focusedIndex],
53
- );
54
- return <Ctx.Provider value={reg}>{children}</Ctx.Provider>;
55
- }
56
-
57
- export function TuiLink({
58
- target,
59
- children,
60
- }: {
61
- target: LinkDescriptor;
62
- children: React.ReactNode;
63
- }): React.ReactElement {
64
- const reg = useFocusRegistry();
65
- const idRef = useRef<symbol | null>(null);
66
- if (idRef.current === null) idRef.current = Symbol("tuilink");
67
- const id = idRef.current;
68
- // Register once on mount, unregister on unmount. A link's target is fixed for
69
- // its lifetime (derived from stable entity data), so no per-render update.
70
- // biome-ignore lint/correctness/useExhaustiveDependencies: register once on mount only
71
- useEffect(() => {
72
- reg.register(id, target);
73
- return () => reg.unregister(id);
74
- }, []);
75
- const index = reg.indexOf(id);
76
- const isFocused = index === reg.focusedIndex;
77
- return <Text inverse={isFocused}>{children}</Text>;
78
- }
package/src/registry.ts DELETED
@@ -1,106 +0,0 @@
1
- import type { JsonApiDocument, JsonApiResource } from "dropsh/plugin";
2
- import { DrupalJsonApiParams } from "drupal-jsonapi-params";
3
- import React from "react";
4
- import { GenericEntityList, TuiEntityList } from "./entity-list.js";
5
- import { GenericEntityView, TuiEntityView } from "./entity-view.js";
6
- import type {
7
- ControllerContext,
8
- EntityListClass,
9
- EntityViewClass,
10
- TuiRoute,
11
- TuiSubPlugin,
12
- } from "./types.js";
13
-
14
- export interface Registry {
15
- resolveView(entityType: string, bundle?: string): EntityViewClass;
16
- resolveList(entityType: string, bundle?: string): EntityListClass;
17
- routes: Map<string, TuiRoute>;
18
- }
19
-
20
- function key(entityType: string, bundle?: string): string {
21
- return `${entityType}:${bundle ?? ""}`;
22
- }
23
-
24
- function isView(c: unknown): c is EntityViewClass {
25
- return typeof c === "function" && (c as typeof TuiEntityView).prototype instanceof TuiEntityView;
26
- }
27
- function isList(c: unknown): c is EntityListClass {
28
- return typeof c === "function" && (c as typeof TuiEntityList).prototype instanceof TuiEntityList;
29
- }
30
-
31
- function buildCtxLink(ctx: ControllerContext) {
32
- return {
33
- viewMode: ctx.viewMode,
34
- link: (route: string, params: Record<string, string>) => ({ route, params }),
35
- };
36
- }
37
-
38
- function coreRoutes(): TuiRoute[] {
39
- const canonical: TuiRoute = {
40
- name: "entity.canonical",
41
- path: "/{type}/{bundle}/{id}",
42
- controller: async (params, ctx) => {
43
- const ViewClass = ctx.resolveView(params.type as string, params.bundle);
44
- const include = ViewClass?.viewModes?.[ctx.viewMode]?.include;
45
- const path = params.bundle
46
- ? `${params.type}/${params.bundle}/${params.id}`
47
- : `${params.type}/${params.id}`;
48
- const doc = (ctx.seededDoc ??
49
- (include?.length
50
- ? await ctx.client.get(path, new DrupalJsonApiParams().addInclude(include))
51
- : await ctx.client.get(path))) as JsonApiDocument;
52
- const entity = (Array.isArray(doc.data) ? doc.data[0] : doc.data) as JsonApiResource;
53
- const instance = new (
54
- ViewClass as unknown as { new (): { build: TuiEntityView["build"] } }
55
- )();
56
- return React.createElement(
57
- React.Fragment,
58
- null,
59
- instance.build(entity, { doc, ...buildCtxLink(ctx) }),
60
- );
61
- },
62
- };
63
- const collection: TuiRoute = {
64
- name: "entity.collection",
65
- path: "/{type}",
66
- controller: async (params, ctx) => {
67
- const ListClass = ctx.resolveList(params.type as string, params.bundle);
68
- const doc = (ctx.seededDoc ??
69
- (await ctx.client.get(
70
- params.bundle ? `${params.type}/${params.bundle}` : (params.type as string),
71
- ))) as JsonApiDocument;
72
- const rows = (Array.isArray(doc.data) ? doc.data : [doc.data]) as JsonApiResource[];
73
- const instance = new (
74
- ListClass as unknown as { new (): { build: TuiEntityList["build"] } }
75
- )();
76
- return React.createElement(
77
- React.Fragment,
78
- null,
79
- instance.build(rows, { doc, ...buildCtxLink(ctx) }),
80
- );
81
- },
82
- };
83
- return [canonical, collection];
84
- }
85
-
86
- export function buildRegistry(plugins: TuiSubPlugin[]): Registry {
87
- const views = new Map<string, EntityViewClass>();
88
- const lists = new Map<string, EntityListClass>();
89
- const routes = new Map<string, TuiRoute>();
90
-
91
- for (const r of coreRoutes()) routes.set(r.name, r);
92
-
93
- for (const p of plugins) {
94
- for (const entity of p.entities ?? []) {
95
- if (isView(entity)) views.set(key(entity.entityType, entity.bundle), entity);
96
- else if (isList(entity)) lists.set(key(entity.entityType, entity.bundle), entity);
97
- }
98
- for (const route of p.routes ?? []) routes.set(route.name, route);
99
- }
100
-
101
- return {
102
- resolveView: (t, b) => views.get(key(t, b)) ?? views.get(key(t)) ?? GenericEntityView,
103
- resolveList: (t, b) => lists.get(key(t, b)) ?? lists.get(key(t)) ?? GenericEntityList,
104
- routes,
105
- };
106
- }
package/src/router.ts DELETED
@@ -1,56 +0,0 @@
1
- import type { JsonApiClient } from "dropsh/plugin";
2
- import type { ReactElement } from "react";
3
- import type { Registry } from "./registry.js";
4
- import type { ControllerContext } from "./types.js";
5
-
6
- export interface Router {
7
- stackDepth(): number;
8
- current(): ReactElement | null;
9
- navigate(
10
- route: string,
11
- params: Record<string, string>,
12
- seededDoc?: import("dropsh/plugin").JsonApiDocument,
13
- ): Promise<void>;
14
- back(): boolean;
15
- }
16
-
17
- export function createRouter(opts: {
18
- registry: Registry;
19
- client: JsonApiClient;
20
- viewMode: string;
21
- }): Router {
22
- const stack: ReactElement[] = [];
23
-
24
- async function navigate(
25
- routeName: string,
26
- params: Record<string, string>,
27
- seededDoc?: import("dropsh/plugin").JsonApiDocument,
28
- ): Promise<void> {
29
- const route = opts.registry.routes.get(routeName);
30
- if (!route) throw new Error(`unknown route: ${routeName}`);
31
- const ctx: ControllerContext = {
32
- client: opts.client,
33
- viewMode: opts.viewMode,
34
- ...(seededDoc !== undefined ? { seededDoc } : {}),
35
- navigate: (r, p) => {
36
- // Host-level navigation surfaces errors in the UI; a controller-initiated
37
- // navigation failure must not become an unhandled rejection / crash.
38
- void navigate(r, p).catch(() => {});
39
- },
40
- resolveView: opts.registry.resolveView,
41
- resolveList: opts.registry.resolveList,
42
- };
43
- const element = await route.controller(params, ctx);
44
- stack.push(element);
45
- }
46
-
47
- return {
48
- stackDepth: () => stack.length,
49
- current: () => stack[stack.length - 1] ?? null,
50
- navigate,
51
- back() {
52
- stack.pop();
53
- return stack.length > 0;
54
- },
55
- };
56
- }
package/src/types.ts DELETED
@@ -1,59 +0,0 @@
1
- import type { JsonApiClient, JsonApiDocument } from "dropsh/plugin";
2
- import type { ReactElement } from "react";
3
-
4
- export interface LinkDescriptor {
5
- route: string;
6
- params: Record<string, string>;
7
- }
8
-
9
- export interface BuildContext {
10
- viewMode: string;
11
- doc: JsonApiDocument;
12
- link(route: string, params: Record<string, string>): LinkDescriptor;
13
- }
14
-
15
- export type ViewModeMap = Record<string, { include?: string[] }>;
16
-
17
- export interface ControllerContext {
18
- client: JsonApiClient;
19
- viewMode: string;
20
- seededDoc?: JsonApiDocument;
21
- navigate(route: string, params: Record<string, string>): void;
22
- resolveView(entityType: string, bundle?: string): EntityViewClass | undefined;
23
- resolveList(entityType: string, bundle?: string): EntityListClass | undefined;
24
- }
25
-
26
- export type TuiController = (
27
- params: Record<string, string>,
28
- ctx: ControllerContext,
29
- ) => Promise<ReactElement>;
30
-
31
- export interface TuiRoute {
32
- name: string;
33
- path: string;
34
- controller: TuiController;
35
- }
36
-
37
- // Structural handler class shapes; concrete bases live in entity-view.tsx / entity-list.tsx.
38
- export interface EntityViewClass {
39
- entityType: string;
40
- bundle?: string;
41
- viewModes: ViewModeMap;
42
- new (): {
43
- build(entity: import("dropsh/plugin").JsonApiResource, ctx: BuildContext): ReactElement;
44
- };
45
- }
46
- export interface EntityListClass {
47
- entityType: string;
48
- bundle?: string;
49
- new (): {
50
- build(resources: import("dropsh/plugin").JsonApiResource[], ctx: BuildContext): ReactElement;
51
- };
52
- }
53
- export type EntityHandlerClass = EntityViewClass | EntityListClass;
54
-
55
- export interface TuiSubPlugin {
56
- id: string;
57
- entities?: EntityHandlerClass[];
58
- routes?: TuiRoute[];
59
- }
@@ -1,142 +0,0 @@
1
- import type { JsonApiClient } from "dropsh/plugin";
2
- import { render } from "ink-testing-library";
3
- import React from "react";
4
- import { describe, expect, it, vi } from "vitest";
5
- import { Browse } from "../../src/browse-app.js";
6
- import { buildRegistry } from "../../src/registry.js";
7
- import { createRouter } from "../../src/router.js";
8
-
9
- function client(): JsonApiClient {
10
- return {
11
- get: vi.fn(async () => ({
12
- data: { type: "node--article", id: "u9", attributes: { title: "Fetched" } },
13
- })),
14
- post: vi.fn(),
15
- patch: vi.fn(),
16
- delete: vi.fn(),
17
- upload: vi.fn(),
18
- } as unknown as JsonApiClient;
19
- }
20
-
21
- const tick = () => new Promise((r) => setTimeout(r, 20));
22
-
23
- describe("Browse", () => {
24
- it("renders the seeded entity via the generic view", async () => {
25
- const c = client();
26
- const router = createRouter({ registry: buildRegistry([]), client: c, viewMode: "default" });
27
- await router.navigate(
28
- "entity.canonical",
29
- { type: "node", bundle: "article", id: "u1" },
30
- {
31
- data: {
32
- type: "node--article",
33
- id: "u1",
34
- attributes: { title: "Alpha" },
35
- relationships: { uid: { data: { type: "user--user", id: "a1" } } },
36
- },
37
- },
38
- );
39
- const { lastFrame } = render(<Browse router={router} onExit={() => {}} />);
40
- await tick();
41
- expect(lastFrame()).toContain("Alpha");
42
- expect(lastFrame()).toContain("uid");
43
- });
44
-
45
- it("exits when back() empties the stack", async () => {
46
- const c = client();
47
- const router = createRouter({ registry: buildRegistry([]), client: c, viewMode: "default" });
48
- await router.navigate(
49
- "entity.canonical",
50
- { type: "node", bundle: "article", id: "u1" },
51
- {
52
- data: { type: "node--article", id: "u1", attributes: { title: "Alpha" } },
53
- },
54
- );
55
- const onExit = vi.fn();
56
- const { stdin } = render(<Browse router={router} onExit={onExit} />);
57
- await tick();
58
- stdin.write("q");
59
- await tick();
60
- expect(onExit).toHaveBeenCalled();
61
- });
62
-
63
- it("surfaces a navigation error instead of crashing", async () => {
64
- const c = {
65
- get: vi.fn(async () => {
66
- throw new Error("boom");
67
- }),
68
- post: vi.fn(),
69
- patch: vi.fn(),
70
- delete: vi.fn(),
71
- upload: vi.fn(),
72
- } as unknown as JsonApiClient;
73
- const router = createRouter({ registry: buildRegistry([]), client: c, viewMode: "default" });
74
- await router.navigate(
75
- "entity.canonical",
76
- { type: "node", bundle: "article", id: "u1" },
77
- {
78
- data: {
79
- type: "node--article",
80
- id: "u1",
81
- attributes: { title: "Alpha" },
82
- relationships: { uid: { data: { type: "user--user", id: "a1" } } },
83
- },
84
- },
85
- );
86
- const { lastFrame, stdin } = render(<Browse router={router} onExit={() => {}} />);
87
- await tick();
88
- stdin.write("\r"); // Enter → navigate to user/a1 → client.get rejects
89
- await tick();
90
- expect(lastFrame()).toContain("boom");
91
- });
92
-
93
- it("navigates using the newly-rendered entity's links, not the previous entity's (regression)", async () => {
94
- const rel = (id: string) => ({
95
- field_ref: { data: { type: "node--article", id } },
96
- });
97
- const get = vi.fn(async (path: string) => {
98
- // A already seeded; B links to C; C links to D.
99
- if (path === "node/article/B") {
100
- return {
101
- data: {
102
- type: "node--article",
103
- id: "B",
104
- attributes: { title: "Bee" },
105
- relationships: rel("C"),
106
- },
107
- };
108
- }
109
- return {
110
- data: { type: "node--article", id: path.split("/").pop(), attributes: { title: "X" } },
111
- };
112
- });
113
- const c = {
114
- get,
115
- post: vi.fn(),
116
- patch: vi.fn(),
117
- delete: vi.fn(),
118
- upload: vi.fn(),
119
- } as unknown as JsonApiClient;
120
- const router = createRouter({ registry: buildRegistry([]), client: c, viewMode: "default" });
121
- // Seed A, which links to B.
122
- await router.navigate(
123
- "entity.canonical",
124
- { type: "node", bundle: "article", id: "A" },
125
- {
126
- data: {
127
- type: "node--article",
128
- id: "A",
129
- attributes: { title: "Ay" },
130
- relationships: rel("B"),
131
- },
132
- },
133
- );
134
- const { stdin } = render(<Browse router={router} onExit={() => {}} />);
135
- await tick();
136
- stdin.write("\r"); // Enter on A's field_ref → fetch B
137
- await tick();
138
- stdin.write("\r"); // Enter on the link now shown for B → must fetch C, NOT B again
139
- await tick();
140
- expect(get).toHaveBeenCalledWith("node/article/C");
141
- });
142
- });
@@ -1,29 +0,0 @@
1
- import type { JsonApiDocument, JsonApiResource } from "dropsh/plugin";
2
- import { render } from "ink-testing-library";
3
- import React from "react";
4
- import { describe, expect, it } from "vitest";
5
- import { GenericEntityList } from "../../src/entity-list.js";
6
- import { FocusRegistryProvider } from "../../src/link.js";
7
-
8
- const link = (route: string, params: Record<string, string>) => ({ route, params });
9
-
10
- describe("GenericEntityList", () => {
11
- it("renders one navigable row per resource with a title label", () => {
12
- const rows: JsonApiResource[] = [
13
- { type: "node--article", id: "u1", attributes: { title: "Alpha" } },
14
- { type: "node--article", id: "u2", attributes: { title: "Beta" } },
15
- ];
16
- const doc: JsonApiDocument = { data: rows };
17
- const el = new GenericEntityList().build(rows, { viewMode: "default", doc, link });
18
- const { lastFrame } = render(<FocusRegistryProvider>{el}</FocusRegistryProvider>);
19
- expect(lastFrame()).toContain("Alpha");
20
- expect(lastFrame()).toContain("Beta");
21
- });
22
-
23
- it("shows (no results) for an empty collection", () => {
24
- const doc: JsonApiDocument = { data: [] };
25
- const el = new GenericEntityList().build([], { viewMode: "default", doc, link });
26
- const { lastFrame } = render(<FocusRegistryProvider>{el}</FocusRegistryProvider>);
27
- expect(lastFrame()).toContain("(no results)");
28
- });
29
- });
@@ -1,29 +0,0 @@
1
- import type { JsonApiDocument, JsonApiResource } from "dropsh/plugin";
2
- import { render } from "ink-testing-library";
3
- import React from "react";
4
- import { describe, expect, it } from "vitest";
5
- import { GenericEntityView } from "../../src/entity-view.js";
6
- import { FocusRegistryProvider } from "../../src/link.js";
7
-
8
- const link = (route: string, params: Record<string, string>) => ({ route, params });
9
-
10
- function ctx(doc: JsonApiDocument) {
11
- return { viewMode: "default", doc, link };
12
- }
13
-
14
- describe("GenericEntityView", () => {
15
- it("renders attributes and a navigable relationship", () => {
16
- const entity: JsonApiResource = {
17
- type: "node--article",
18
- id: "u1",
19
- attributes: { title: "Alpha" },
20
- relationships: { uid: { data: { type: "user--user", id: "a1" } } },
21
- };
22
- const doc: JsonApiDocument = { data: entity };
23
- const el = new GenericEntityView().build(entity, ctx(doc));
24
- const { lastFrame } = render(<FocusRegistryProvider>{el}</FocusRegistryProvider>);
25
- expect(lastFrame()).toContain("title");
26
- expect(lastFrame()).toContain("Alpha");
27
- expect(lastFrame()).toContain("uid");
28
- });
29
- });
@@ -1,77 +0,0 @@
1
- import type { JsonApiDocument, RenderContext, RenderServices } from "dropsh/plugin";
2
- import { describe, expect, it, vi } from "vitest";
3
- import { initialTarget, tuiPlugin } from "../../src/index.js";
4
-
5
- function services(): RenderServices {
6
- return {
7
- client: { get: vi.fn(async () => ({ data: [] })) } as never,
8
- baseUrl: "https://x.test",
9
- };
10
- }
11
-
12
- describe("tuiPlugin", () => {
13
- it("exposes an interactive renderer with id 'tui'", () => {
14
- const plugin = tuiPlugin();
15
- const renderer = plugin.renderers?.[0];
16
- expect(renderer?.id).toBe("tui");
17
- expect((renderer as { interactive?: boolean }).interactive).toBe(true);
18
- });
19
-
20
- it("run() rejects when stdout is not a TTY", async () => {
21
- const plugin = tuiPlugin();
22
- const renderer = plugin.renderers?.[0] as {
23
- run: (d: JsonApiDocument, c: RenderContext, s?: RenderServices) => Promise<void>;
24
- };
25
- const orig = process.stdout.isTTY;
26
- Object.defineProperty(process.stdout, "isTTY", { value: false, configurable: true });
27
- await expect(
28
- renderer.run({ data: { type: "node--article", id: "u1" } }, { command: "read" }, services()),
29
- ).rejects.toThrow(/interactive terminal/i);
30
- Object.defineProperty(process.stdout, "isTTY", { value: orig, configurable: true });
31
- });
32
- });
33
-
34
- describe("initialTarget", () => {
35
- it("read of a single resource → canonical route with type/bundle/id", () => {
36
- const t = initialTarget({ data: { type: "node--article", id: "u1" } }, {
37
- command: "read",
38
- } as RenderContext);
39
- expect(t).toEqual({
40
- route: "entity.canonical",
41
- params: { type: "node", bundle: "article", id: "u1" },
42
- });
43
- });
44
-
45
- it("read of a collection → canonical route for the first resource", () => {
46
- const t = initialTarget(
47
- {
48
- data: [
49
- { type: "node--page", id: "p1" },
50
- { type: "node--page", id: "p2" },
51
- ],
52
- },
53
- { command: "read" } as RenderContext,
54
- );
55
- expect(t).toEqual({
56
- route: "entity.canonical",
57
- params: { type: "node", bundle: "page", id: "p1" },
58
- });
59
- });
60
-
61
- it("search → collection route with type and bundle from ctx", () => {
62
- const t = initialTarget({ data: [] }, {
63
- command: "search",
64
- entityType: "node",
65
- bundle: "article",
66
- } as RenderContext);
67
- expect(t).toEqual({ route: "entity.collection", params: { type: "node", bundle: "article" } });
68
- });
69
-
70
- it("search without a bundle → collection route with only type", () => {
71
- const t = initialTarget({ data: [] }, {
72
- command: "search",
73
- entityType: "node",
74
- } as RenderContext);
75
- expect(t).toEqual({ route: "entity.collection", params: { type: "node" } });
76
- });
77
- });
@@ -1,24 +0,0 @@
1
- import type { JsonApiResource } from "dropsh/plugin";
2
- import { describe, expect, it } from "vitest";
3
- import { label, splitType } from "../../src/jsonapi-type.js";
4
-
5
- describe("splitType", () => {
6
- it("splits type--bundle", () => {
7
- expect(splitType("node--article")).toEqual({ entityType: "node", bundle: "article" });
8
- });
9
- it("handles a type with no bundle segment", () => {
10
- expect(splitType("user")).toEqual({ entityType: "user" });
11
- });
12
- });
13
-
14
- describe("label", () => {
15
- it("prefers title, then name, then label, else id", () => {
16
- expect(label({ type: "n--a", id: "1", attributes: { title: "T" } } as JsonApiResource)).toBe(
17
- "T",
18
- );
19
- expect(label({ type: "n--a", id: "1", attributes: { name: "N" } } as JsonApiResource)).toBe(
20
- "N",
21
- );
22
- expect(label({ type: "n--a", id: "1", attributes: {} } as JsonApiResource)).toBe("1");
23
- });
24
- });
@@ -1,64 +0,0 @@
1
- import { describe, expect, it } from "vitest";
2
-
3
- // Force chalk (used internally by ink to render styles such as `inverse`) to
4
- // emit real ANSI escapes even though tests run outside a TTY. This must be
5
- // set before ink — and anything that imports it — is loaded, so those
6
- // modules are imported dynamically below, after this line runs.
7
- process.env.FORCE_COLOR = "1";
8
-
9
- const { Text } = await import("ink");
10
- const { render } = await import("ink-testing-library");
11
- const { FocusRegistryProvider, TuiLink, useFocusRegistry } = await import("../../src/link.js");
12
-
13
- const tick = () => new Promise((r) => setTimeout(r, 30));
14
-
15
- // Renders the registry's observable state so the test can assert registration
16
- // count and which target is focused — after mount effects have run.
17
- function Probe() {
18
- const reg = useFocusRegistry();
19
- return (
20
- <Text>
21
- count={reg.count()} focus={reg.targetAt(reg.focusedIndex)?.params.id ?? "none"}
22
- </Text>
23
- );
24
- }
25
-
26
- function tree(focusedIndex: number) {
27
- return (
28
- <FocusRegistryProvider focusedIndex={focusedIndex}>
29
- <TuiLink target={{ route: "entity.canonical", params: { id: "u1" } }}>Alpha</TuiLink>
30
- <TuiLink target={{ route: "entity.canonical", params: { id: "u2" } }}>Beta</TuiLink>
31
- <Probe />
32
- </FocusRegistryProvider>
33
- );
34
- }
35
-
36
- describe("TuiLink + FocusRegistry", () => {
37
- it("registers each link once, in order, and resolves the focused target", async () => {
38
- const { lastFrame } = render(tree(1));
39
- await tick();
40
- expect(lastFrame()).toContain("Alpha");
41
- expect(lastFrame()).toContain("Beta");
42
- // Both links registered exactly once (no render-time duplicate registration).
43
- expect(lastFrame()).toContain("count=2");
44
- // focusedIndex 1 resolves to the second registered link (Beta / u2), proving
45
- // registration order is DOM order and targetAt/focusedIndex wiring works.
46
- expect(lastFrame()).toContain("focus=u2");
47
- });
48
-
49
- it("wraps only the focused link in inverse styling", async () => {
50
- const a = render(tree(0));
51
- await tick();
52
- const f0 = a.lastFrame() ?? "";
53
- const b = render(tree(1));
54
- await tick();
55
- const f1 = b.lastFrame() ?? "";
56
- const INV = ""; // ANSI SGR: inverse on
57
- // focusedIndex 0 → only "Alpha" is highlighted.
58
- expect(f0).toContain(`${INV}Alpha`);
59
- expect(f0).not.toContain(`${INV}Beta`);
60
- // focusedIndex 1 → only "Beta" is highlighted.
61
- expect(f1).toContain(`${INV}Beta`);
62
- expect(f1).not.toContain(`${INV}Alpha`);
63
- });
64
- });