@neoinkjs/components 0.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/package.json ADDED
@@ -0,0 +1,46 @@
1
+ {
2
+ "name": "@neoinkjs/components",
3
+ "version": "0.1.0",
4
+ "description": "Box, Text, Spacer, Newline, Static, Transform — the React components for neoink.",
5
+ "license": "MIT",
6
+ "author": "Nicholas Zolton",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/NicholasZolton/neoink.git",
10
+ "directory": "packages/components"
11
+ },
12
+ "homepage": "https://github.com/NicholasZolton/neoink#readme",
13
+ "keywords": [
14
+ "neovim",
15
+ "nvim",
16
+ "react",
17
+ "tui",
18
+ "terminal",
19
+ "react-reconciler",
20
+ "ink"
21
+ ],
22
+ "type": "module",
23
+ "exports": {
24
+ ".": {
25
+ "types": "./src/index.ts",
26
+ "default": "./src/index.ts"
27
+ }
28
+ },
29
+ "main": "./src/index.ts",
30
+ "types": "./src/index.ts",
31
+ "files": [
32
+ "src"
33
+ ],
34
+ "publishConfig": {
35
+ "access": "public"
36
+ },
37
+ "dependencies": {
38
+ "@neoinkjs/core": "0.1.0"
39
+ },
40
+ "peerDependencies": {
41
+ "react": "^19.0.0"
42
+ },
43
+ "devDependencies": {
44
+ "@types/react": "^19.0.0"
45
+ }
46
+ }
@@ -0,0 +1,13 @@
1
+ import { createContext } from "react";
2
+
3
+ /**
4
+ * Ambient background color set by the nearest ancestor `<Box backgroundColor>`.
5
+ * `<Text>` reads this as a fallback when it has no `backgroundColor` of its
6
+ * own, so text nested inside a colored box inherits the box's background
7
+ * (matching Ink — see §7 of the ink-architecture-digest). `undefined` means
8
+ * "no ancestor Box set one" (or there is no ancestor Box at all).
9
+ *
10
+ * Internal to `@neoinkjs/components` — not re-exported from `index.ts`. `Box`
11
+ * is the only producer, `Text` the only consumer.
12
+ */
13
+ export const backgroundContext = createContext<string | undefined>(undefined);
package/src/box.tsx ADDED
@@ -0,0 +1,142 @@
1
+ import React, { forwardRef } from "react";
2
+ import type { ReactNode, Ref } from "react";
3
+ import type { AriaRole, AriaState, NeoElement, Styles, TextStyle } from "@neoinkjs/core";
4
+ import { DEFAULT_BOX_STYLE as CORE_DEFAULT_BOX_STYLE, useIsScreenReaderEnabled } from "@neoinkjs/core";
5
+ import { backgroundContext } from "./background-context";
6
+ import { Text } from "./text";
7
+
8
+ /**
9
+ * The layout-affecting subset of `Styles`: everything except the cosmetic
10
+ * `TextStyle` keys (inapplicable to `<Box>`) and `textWrap` (a `<Text>`-only
11
+ * concern). Deriving this via `Omit` keeps it in sync with `Styles`
12
+ * automatically — no re-listing of layout keys. Note `borderColor`/
13
+ * `borderBackgroundColor` and their per-edge variants are NOT part of
14
+ * `TextStyle`, so they already flow through this `Omit` untouched.
15
+ */
16
+ type BoxNodeStyle = Omit<Styles, keyof TextStyle | "textWrap">;
17
+
18
+ /**
19
+ * `backgroundColor` IS a `TextStyle` key, so the `Omit` above drops it —
20
+ * unlike border colors, it has to be re-added explicitly. It's still a
21
+ * cosmetic `<Box>` prop (Ink lets `<Box>` set it directly, not just via
22
+ * inheritance), so `<Box>` writes it into `style` and also fans it out to
23
+ * descendant `<Text>` via `backgroundContext`.
24
+ */
25
+ type BoxNodeStyleWithBackground = BoxNodeStyle & Pick<Styles, "backgroundColor">;
26
+
27
+ export type BoxProps = BoxNodeStyleWithBackground & {
28
+ children?: ReactNode;
29
+ /**
30
+ * A label for the element for screen readers. In screen-reader mode
31
+ * (`useIsScreenReaderEnabled()`), this REPLACES `children` in the box's
32
+ * screen-reader output — matches Ink's `<Box>` `aria-label` exactly.
33
+ */
34
+ "aria-label"?: string;
35
+ /**
36
+ * Hides the element (and its subtree) from screen readers. Has NO effect
37
+ * outside screen-reader mode — matches Ink: `aria-hidden` never changes a
38
+ * normal render's visual output.
39
+ */
40
+ "aria-hidden"?: boolean;
41
+ /** The element's ARIA role, surfaced in screen-reader output as a `"<role>: "` prefix. */
42
+ "aria-role"?: AriaRole;
43
+ /** The element's ARIA state, surfaced in screen-reader output as a `"(<flags>) "` prefix. */
44
+ "aria-state"?: AriaState;
45
+ };
46
+
47
+ /**
48
+ * Layout defaults merged *under* the caller's props, matching Ink's `<Box>`
49
+ * defaults. Re-exported (narrowed to `BoxNodeStyle`) so the fallback flex
50
+ * behavior can't drift from what callers expect when they omit these props —
51
+ * `<Transform>` and `<Static>`, which also emit a `neoink-box`, spread this
52
+ * as their base rather than re-listing the same triple. The actual literal
53
+ * values live in `@neoinkjs/core`'s `style.ts` (`DEFAULT_BOX_STYLE`), NOT
54
+ * here — that package's own `components/error-overview.tsx` needs the same
55
+ * defaults but can't import this `<Box>` COMPONENT (that would make
56
+ * `@neoinkjs/core` depend on `@neoinkjs/components`, which already depends on
57
+ * `core` — a cycle), so the raw values are defined once in `core` and both
58
+ * this re-export and that file build on them.
59
+ *
60
+ * Deliberately field-by-field, NOT `{ ...CORE_DEFAULT_BOX_STYLE }`: `Styles`
61
+ * (core's constant's type) is a strict superset of `BoxNodeStyle` (it also
62
+ * allows every `TextStyle`/`textWrap` key `BoxNodeStyle`'s `Omit` exists to
63
+ * exclude), and TypeScript's excess-property check — the thing that would
64
+ * catch a future `DEFAULT_BOX_STYLE` in core accidentally growing a stray
65
+ * cosmetic key — only fires for a FRESH object literal, not for values
66
+ * copied in via a spread. Spreading here would silently defeat that check
67
+ * (verified: temporarily adding `color: "red"` to core's constant compiled
68
+ * with zero errors under a spread-based version of this line); listing each
69
+ * field explicitly keeps it real.
70
+ */
71
+ export const DEFAULT_BOX_STYLE: BoxNodeStyle = {
72
+ flexDirection: CORE_DEFAULT_BOX_STYLE.flexDirection,
73
+ flexGrow: CORE_DEFAULT_BOX_STYLE.flexGrow,
74
+ flexShrink: CORE_DEFAULT_BOX_STYLE.flexShrink,
75
+ flexWrap: CORE_DEFAULT_BOX_STYLE.flexWrap,
76
+ };
77
+
78
+ /**
79
+ * A layout container. Flattens its layout props (Ink-style) into a single
80
+ * `style` object — defaults first, then the passed props — and renders a
81
+ * `neoink-box` host node. When `backgroundColor` is set, wraps `children` in
82
+ * a `backgroundContext.Provider` so descendant `<Text>` without their own
83
+ * `backgroundColor` inherit it (Ink behavior — see §7 of the architecture
84
+ * digest).
85
+ *
86
+ * Forwards a `ref` to the underlying `neoink-box` host element — the
87
+ * reconciler's `getPublicInstance` resolves it to that element's `NeoElement`
88
+ * (see `@neoinkjs/core`'s `reconciler.ts`), so `ref.current` is exactly what
89
+ * `measureElement()`/`useBoxMetrics()` expect. Matches Ink's `forwardRef`
90
+ * `<Box>`.
91
+ *
92
+ * Screen-reader accessibility (Task 6), ported from Ink's `<Box>`
93
+ * (`src/components/Box.tsx`) field for field:
94
+ * - `aria-hidden`: when `useIsScreenReaderEnabled()` is true, returns `null`
95
+ * — the whole subtree is omitted, exactly like Ink. Outside screen-reader
96
+ * mode this is a no-op: a normal render never reads it.
97
+ * - `aria-label`: rendered (via `<Text>`, standing in for Ink's raw
98
+ * `<ink-text>`) and used AS this box's content — instead of `children` —
99
+ * only when `useIsScreenReaderEnabled()` is true. A non-screen-reader
100
+ * render always uses `children`, regardless of `aria-label`.
101
+ * - `aria-role`/`aria-state`: always attached to the node as
102
+ * `internal_accessibility` (Ink does the same — its `internal_accessibility`
103
+ * prop is unconditional, not gated on screen-reader mode); the ordinary
104
+ * paint path ignores it, only `renderNodeToScreenReaderOutput` reads it.
105
+ */
106
+ export const Box = forwardRef(function Box(
107
+ props: BoxProps,
108
+ ref: Ref<NeoElement>,
109
+ ): React.ReactElement | null {
110
+ const {
111
+ children,
112
+ "aria-label": ariaLabel,
113
+ "aria-hidden": ariaHidden,
114
+ "aria-role": role,
115
+ "aria-state": ariaState,
116
+ ...layoutProps
117
+ } = props;
118
+ const style: BoxNodeStyleWithBackground = { ...DEFAULT_BOX_STYLE, ...layoutProps };
119
+ const isScreenReaderEnabled = useIsScreenReaderEnabled();
120
+
121
+ if (isScreenReaderEnabled && ariaHidden) {
122
+ return null;
123
+ }
124
+
125
+ const label = ariaLabel ? React.createElement(Text, null, ariaLabel) : undefined;
126
+ const boxChildren = isScreenReaderEnabled && label ? label : children;
127
+
128
+ const content =
129
+ style.backgroundColor !== undefined
130
+ ? React.createElement(
131
+ backgroundContext.Provider,
132
+ { value: style.backgroundColor },
133
+ boxChildren,
134
+ )
135
+ : boxChildren;
136
+
137
+ return React.createElement(
138
+ "neoink-box",
139
+ { style, ref, internal_accessibility: { role, state: ariaState } },
140
+ content,
141
+ );
142
+ });
package/src/index.ts ADDED
@@ -0,0 +1,11 @@
1
+ export { Box } from "./box";
2
+ export type { BoxProps } from "./box";
3
+ export { Text } from "./text";
4
+ export type { TextProps } from "./text";
5
+ export { Spacer } from "./spacer";
6
+ export { Newline } from "./newline";
7
+ export type { NewlineProps } from "./newline";
8
+ export { Transform } from "./transform";
9
+ export type { TransformProps } from "./transform";
10
+ export { Static } from "./static";
11
+ export type { StaticProps } from "./static";
@@ -0,0 +1,22 @@
1
+ import React from "react";
2
+ import { Text } from "./text";
3
+
4
+ export interface NewlineProps {
5
+ count?: number;
6
+ }
7
+
8
+ /**
9
+ * Renders `count` newline characters as text content. Only meaningful inside
10
+ * a `<Text>`: it delegates to `<Text>`, which the reconciler remaps to a
11
+ * cosmetic `neoink-virtual-text` node when nested inside another `<Text>`
12
+ * (see `createInstance` in `@neoinkjs/core`'s reconciler), so its newline
13
+ * string composes into the ancestor `<Text>`'s squashed content exactly like
14
+ * `<Text>Hello <Text>World</Text></Text>` does.
15
+ */
16
+ export function Newline({ count = 1 }: NewlineProps): React.ReactElement {
17
+ if (count < 0) {
18
+ throw new Error(`<Newline count> must be >= 0, got ${count}`);
19
+ }
20
+
21
+ return React.createElement(Text, null, "\n".repeat(count));
22
+ }
package/src/spacer.tsx ADDED
@@ -0,0 +1,13 @@
1
+ import React from "react";
2
+ import { Box } from "./box";
3
+
4
+ /**
5
+ * A flexible filler: a `<Box>` that grows to consume any remaining space
6
+ * along its parent's main axis, pushing its siblings toward opposite ends
7
+ * of the parent. Inherits `Box`'s default `flexShrink: 1`, so — matching
8
+ * Ink's `<Spacer>` — it can still shrink below its grown size in an
9
+ * overflowing row.
10
+ */
11
+ export function Spacer(): React.ReactElement {
12
+ return React.createElement(Box, { flexGrow: 1 });
13
+ }
package/src/static.tsx ADDED
@@ -0,0 +1,53 @@
1
+ import React from "react";
2
+ import type { ReactNode } from "react";
3
+ import type { Styles } from "@neoinkjs/core";
4
+ import { DEFAULT_BOX_STYLE } from "./box";
5
+
6
+ export interface StaticProps<T> {
7
+ items: readonly T[];
8
+ children: (item: T, index: number) => ReactNode;
9
+ }
10
+
11
+ /**
12
+ * Column layout for a stacked list of static entries. Spreads `<Box>`'s
13
+ * shared `DEFAULT_BOX_STYLE` (single source of truth for the flex-default
14
+ * triple) and overrides only `flexDirection` to `"column"`.
15
+ */
16
+ const STATIC_NODE_STYLE: Styles = {
17
+ ...DEFAULT_BOX_STYLE,
18
+ flexDirection: "column",
19
+ };
20
+
21
+ /**
22
+ * Renders `items` once each, via `children(item, index)`, stacked in a
23
+ * column.
24
+ *
25
+ * Ink's `<Static>` renders its subtree exactly once and then removes it from
26
+ * the live tree, so the *persistent* render loop's per-frame diff never
27
+ * repaints it — the content is written once, above the diffed region, and
28
+ * never re-erased (see `internal_static`/`isStaticDirty` in Ink's DOM).
29
+ * neoink's `<Static>` instead keeps rendering the FULL `items` array every
30
+ * commit (simpler: no internal index/slicing state) and tags the wrapping
31
+ * node `internal_static` (a typed field on `NeoElement`, see `@neoinkjs/core`'s
32
+ * `dom.ts`); it's `@neoinkjs/core`'s persistent render loop
33
+ * (`instance.ts`/`render.ts`) that re-derives the equivalent of Ink's
34
+ * once-only semantics — finding this subtree, diffing its growing output
35
+ * against what it already emitted, and writing only the new suffix, once,
36
+ * above the diffed dynamic frame. `renderToString`'s one-shot mount has no
37
+ * persistent frame to dedup against, so there `<Static>` simply renders
38
+ * inline, in order, like any other content.
39
+ *
40
+ * Like `<Transform>`, its host element is `neoink-box` — a deliberate neoink
41
+ * design choice, not Ink parity. As a result `<Static>` can't be nested
42
+ * inside a `<Text>` subtree: the reconciler throws (`<Box>` can't be nested
43
+ * inside `<Text>`) by design.
44
+ */
45
+ export function Static<T>(props: StaticProps<T>): React.ReactElement {
46
+ const { items, children } = props;
47
+
48
+ return React.createElement(
49
+ "neoink-box",
50
+ { style: STATIC_NODE_STYLE, internal_static: true },
51
+ items.map((item, index) => children(item, index)),
52
+ );
53
+ }
package/src/text.tsx ADDED
@@ -0,0 +1,82 @@
1
+ import React, { useContext } from "react";
2
+ import type { ReactNode } from "react";
3
+ import { DEFAULT_TEXT_WRAP, TEXT_STYLE_KEYS, useIsScreenReaderEnabled } from "@neoinkjs/core";
4
+ import type { Styles, TextStyle } from "@neoinkjs/core";
5
+ import { backgroundContext } from "./background-context";
6
+
7
+ export interface TextProps extends TextStyle {
8
+ wrap?: Styles["textWrap"];
9
+ children?: ReactNode;
10
+ /**
11
+ * A label for the element for screen readers. In screen-reader mode
12
+ * (`useIsScreenReaderEnabled()`), this REPLACES `children` — matches
13
+ * Ink's `<Text>` `aria-label` exactly. Note: unlike `<Box>`, Ink's
14
+ * `<Text>` has no `aria-role`/`aria-state` — neither does neoink's.
15
+ */
16
+ "aria-label"?: string;
17
+ /**
18
+ * Hides the element from screen readers. Has NO effect outside
19
+ * screen-reader mode — matches Ink.
20
+ */
21
+ "aria-hidden"?: boolean;
22
+ }
23
+
24
+ /** The subset of `Styles` a `<Text>` node ever writes: `textWrap` + the cosmetic keys. */
25
+ type TextNodeStyle = Pick<Styles, keyof TextStyle | "textWrap">;
26
+
27
+ /**
28
+ * Renders text. Builds a `style` object carrying `textWrap` (defaulting to
29
+ * `"wrap"`) plus whichever cosmetic keys the caller actually set — an unset
30
+ * prop is omitted rather than written as `color: undefined`, so it never
31
+ * shadows an inherited/default style at paint time. Reuses core's
32
+ * `TEXT_STYLE_KEYS` (the same list `extractTextStyle` in the reconciler
33
+ * consumes) so the two never drift apart. Renders `null` when the effective
34
+ * content — `children`, or `aria-label` in screen-reader mode (see below) —
35
+ * is `null`/`undefined` (no `neoink-text` node at all).
36
+ *
37
+ * `backgroundColor` falls back to the nearest ancestor `<Box backgroundColor>`
38
+ * (read via `backgroundContext`) when this `<Text>` doesn't set its own —
39
+ * matching Ink's inheritance. `useContext` is called unconditionally, before
40
+ * the early-return below, so it stays consistent across renders regardless
41
+ * of whether `children` is present.
42
+ *
43
+ * Screen-reader accessibility (Task 6), ported from Ink's `<Text>`
44
+ * (`src/components/Text.tsx`) field for field:
45
+ * - `aria-label`: when `useIsScreenReaderEnabled()` is true AND set, stands
46
+ * in for `children` (`childrenOrAriaLabel` below) — so `<Text aria-label="x" />`
47
+ * with no children still renders `"x"` in screen-reader mode. Outside
48
+ * screen-reader mode this is a no-op: `children` is always used.
49
+ * - `aria-hidden`: when `useIsScreenReaderEnabled()` is true, returns `null`
50
+ * — checked AFTER the null-content check below, exactly mirroring Ink's
51
+ * ordering (`Text.tsx` computes `childrenOrAriaLabel`/its null-check first,
52
+ * then the `aria-hidden` check, right before constructing `<ink-text>`).
53
+ */
54
+ export function Text(props: TextProps): React.ReactElement | null {
55
+ const { wrap, children, "aria-label": ariaLabel, "aria-hidden": ariaHidden = false } = props;
56
+ const inheritedBackgroundColor = useContext(backgroundContext);
57
+ const isScreenReaderEnabled = useIsScreenReaderEnabled();
58
+
59
+ const childrenOrAriaLabel = isScreenReaderEnabled && ariaLabel ? ariaLabel : children;
60
+
61
+ if (childrenOrAriaLabel === null || childrenOrAriaLabel === undefined) {
62
+ return null;
63
+ }
64
+
65
+ if (isScreenReaderEnabled && ariaHidden) {
66
+ return null;
67
+ }
68
+
69
+ const style: TextNodeStyle = { textWrap: wrap ?? DEFAULT_TEXT_WRAP };
70
+ for (const key of TEXT_STYLE_KEYS) {
71
+ const value = props[key];
72
+ if (value !== undefined) {
73
+ (style[key] as TextStyle[typeof key]) = value;
74
+ }
75
+ }
76
+
77
+ if (style.backgroundColor === undefined && inheritedBackgroundColor !== undefined) {
78
+ style.backgroundColor = inheritedBackgroundColor;
79
+ }
80
+
81
+ return React.createElement("neoink-text", { style }, childrenOrAriaLabel);
82
+ }
@@ -0,0 +1,44 @@
1
+ import React from "react";
2
+ import type { ReactNode } from "react";
3
+ import type { LineTransform } from "@neoinkjs/core";
4
+ import { DEFAULT_BOX_STYLE } from "./box";
5
+
6
+ export interface TransformProps {
7
+ /**
8
+ * A pure text→text mapping applied to each line of the squashed content
9
+ * beneath this node (e.g. uppercasing). Unlike Ink's ANSI-emitting
10
+ * `<Transform>`, neoink's transform never carries styling — that stays
11
+ * `<Text>`'s job — it only ever rewrites line content.
12
+ */
13
+ transform: LineTransform;
14
+ children?: ReactNode;
15
+ }
16
+
17
+ /**
18
+ * Wraps `children` in a `neoink-box` carrying `internal_transform`. At paint
19
+ * time (`renderNodeToOutput` in `@neoinkjs/core`), every descendant
20
+ * `<Text>`'s squashed + wrapped lines are passed through this (and any
21
+ * enclosing `<Transform>`'s) transform before being written — nested
22
+ * `<Transform>`s compose inner-out. Uses `<Box>`'s shared `DEFAULT_BOX_STYLE`
23
+ * (same source, not a re-listed copy) so the wrapper lays out like a plain
24
+ * `<Box>` around the same children.
25
+ *
26
+ * Architecture note — a deliberate neoink divergence from Ink, NOT parity:
27
+ * Ink's `<Transform>` renders an `ink-text` node (`<ink-text
28
+ * internal_transform=...>`), so upstream it CAN be nested inside `<Text>`.
29
+ * neoink instead hosts a `neoink-box`, a P2 design choice. The consequence is
30
+ * a real, intentional limitation: because the reconciler throws when a
31
+ * `neoink-box` is instantiated inside a `<Text>` subtree (`<Box>` can't be
32
+ * nested inside `<Text>`), `<Transform>` can only wrap a `<Text>`/`<Box>`
33
+ * from the outside — nesting it as an inline child of `<Text>` throws loudly
34
+ * by design.
35
+ */
36
+ export function Transform(props: TransformProps): React.ReactElement {
37
+ const { transform, children } = props;
38
+
39
+ return React.createElement(
40
+ "neoink-box",
41
+ { style: DEFAULT_BOX_STYLE, internal_transform: transform },
42
+ children,
43
+ );
44
+ }