Files
vue-tui/packages/runtime/src/components/Transform.ts
T
Yunfei He 224afeb3a2 fix(runtime): <Transform> with no children renders no node, matching Ink (#105)
Ink's <Transform> returns null (no node) when children are undefined/null, and
that guard runs BEFORE the accessibilityLabel substitution (Transform.tsx:28-30).
vue always created a "transform" host node, so:
- an empty <Transform> in a flex `gap` row consumed a gap slot Ink never adds (P13); and
- a childless <Transform accessibilityLabel> emitted the label even though Ink's
  null guard wins over it (P19).

Add the null-children guard at the top of the render fn. Vue materializes a bare
null/false/undefined/v-if=false child as a single Comment vnode and cannot tell them
apart, so the predicate treats the whole group as "no children" (slot undefined OR
every vnode is a Comment) — matching Ink for the common `{null}`/`{cond ? x : null}`
idioms and keeping <Transform> consistent with vue-tui's documented comment-anchor
model (every other component already omits a false/v-if child). An empty-string ({''},
a Text vnode) or JSX empty array ({[]}, a Fragment) still renders, matching Ink.

This deliberately diverges from Ink only for a literal {false} / {cond && x}-false
child (React's false !== null → Ink renders an empty gap-slot node); documented in
ink-divergences.md and locked by a test, since Vue physically cannot distinguish it
from null.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 00:29:21 +08:00

95 lines
4.3 KiB
TypeScript

import {
Comment,
defineComponent,
h,
inject,
isVNode,
type ExtractPublicPropTypes,
type PropType,
type VNode,
} from "vue";
import { AppContextKey } from "../context.ts";
import type { WithChildren } from "./with-children.ts";
type TransformFn = (line: string, lineIndex: number) => string;
const transformProps = {
// `required: true as const` keeps `transform` a required key once the props
// object lives in a standalone `const` (which would otherwise widen `true` →
// `boolean`). Matches Ink's `TransformProps`.
transform: { type: Function as PropType<TransformFn>, required: true as const },
accessibilityLabel: String,
};
const TransformImpl = defineComponent({
name: "Transform",
props: transformProps,
setup(props, { slots }) {
const appCtx = inject(AppContextKey, null);
return () => {
const children = slots.default?.();
// Mirror Ink's Transform (Transform.tsx:28-30): when there are no children
// it returns null — creating NO host node — and this guard runs BEFORE the
// accessibilityLabel substitution. Two consequences we must match:
// - an empty <Transform> in a flex `gap` row adds neither a node nor a gap
// slot (P13); and
// - a childless <Transform accessibilityLabel> emits nothing even in
// screen-reader mode, because null wins over the label (P19).
//
// Ink's exact guard is `children === undefined || children === null`. Vue
// can't see that raw value — `slots.default?.()` materializes a bare
// `null`/`false`/falsy-`&&`/`v-if` child as a Comment vnode (the same
// representation the G52 squash logic skips), and CANNOT tell `null` from
// `false`. So the predicate treats the whole group as "no children": the slot
// is undefined OR every resolved vnode is a Comment. This matches Ink for the
// common conditional idioms (`{null}`, `{cond ? x : null}`) and follows
// vue-tui's documented comment-anchor model (a null/false/undefined child is
// inert, output equals omitting the element — see ink-divergences.md). It
// DELIBERATELY diverges from Ink only for a literal `{false}` / `{cond && x}`
// (when false): React's `false !== null`, so Ink renders an empty ink-text
// node (a gap slot); Vue, unable to distinguish it, omits it. Keeping
// Transform consistent with how every other component treats a `false`/`v-if`
// child is the principled choice — the pre-fix Transform was the inconsistent
// one (it rendered a stray node for `{null}` too).
// (Edge: `{''}` is a TEXT vnode and JSX `{[]}` a Fragment vnode — neither a
// Comment, so both still render, matching Ink. A non-JSX `() => []` empty
// array collapses to null here; invisible — an empty-node Transform paints
// nothing and only the gap-slot differs.)
if (isNoRenderableChildren(children)) {
return null;
}
const isScreenReaderEnabled = appCtx?.isScreenReaderEnabled ?? false;
// When screen reader is enabled and accessibilityLabel is set,
// render the label text instead of children.
if (isScreenReaderEnabled && props.accessibilityLabel) {
return h("transform", { transform: props.transform }, props.accessibilityLabel);
}
return h("transform", { transform: props.transform }, children);
};
},
});
export const Transform = TransformImpl as WithChildren<typeof TransformImpl>;
/**
* The Vue analogue of Ink's `children === undefined || children === null` guard
* (Transform.tsx:28). True when the default slot resolves to nothing renderable:
* either it's absent, or every vnode in it is a Comment — Vue's materialization
* of a bare `null`/`false`/falsy-`&&`/`v-if` child (the same nodes G52's squash
* skips). A text-leaf (`{''}`) or Fragment (`{[]}`) vnode is renderable, so it is
* NOT treated as null — matching Ink, which renders a node for `children === ''`
* or `[]` (both `!== null`).
*/
function isNoRenderableChildren(children: VNode[] | undefined): boolean {
if (children === undefined) return true;
return children.every((child) => isVNode(child) && child.type === Comment);
}
/** Props accepted by `<Transform>` — the vue-tui analogue of Ink's `TransformProps`. */
export type TransformProps = ExtractPublicPropTypes<typeof transformProps>;