feat(layout): add YaziPaneRow 3-pane layout primitive
Add the shared parent | current | preview row primitive that implements yazi's mgr.ratio = [1, 3, 3] contract via Yoga flexGrow, so every list tab renders an identical, layout-stable shell regardless of terminal size. - New YaziPaneRow component: three bordered columns grow at 1/7 : 3/7 : 3/7 with flexBasis=0 (content can never stretch its slot). The current column carries the accent focus ring when `focused` is truthy; parent and preview stay muted. The blank parent keeps its 1/7 slot with a muted placeholder rather than collapsing. - PANE_RATIO.current 4 -> 3 to match yazi's [1, 3, 3]. - Add render-based tests covering the 1:3:3 ratios (incl. null parent/preview) and the focused/unfocused accent ring behavior. - Configure the bun test preload for the solid JSX transform.
This commit is contained in:
163
src/components/YaziPaneRow.tsx
Normal file
163
src/components/YaziPaneRow.tsx
Normal file
@@ -0,0 +1,163 @@
|
||||
/**
|
||||
* YaziPaneRow — the shared parent | current | preview 3-pane layout primitive.
|
||||
*
|
||||
* Implements yazi's `mgr.ratio = [1, 3, 3]` contract: three bordered columns
|
||||
* grow at 1/7 : 3/7 : 3/7 of the row width via Yoga `flexGrow`, so every list
|
||||
* tab renders an identical, layout-stable shell. Columns use `flexBasis={0}`
|
||||
* so the ratio is exact regardless of content width — a column's content can
|
||||
* never stretch its slot.
|
||||
*
|
||||
* Column semantics (per the yazi depth model):
|
||||
* parent — the previous-depth list. Renders a muted `—` placeholder and
|
||||
* KEEPS its 1/7 slot when blank (never collapses to width 0).
|
||||
* current — the current-depth list. The only focusable content column; it
|
||||
* carries the accent focus ring when `focused` is truthy.
|
||||
* preview — detail of the hovered item in `current`; always muted border.
|
||||
*
|
||||
* The primitive is purely structural: callers pass their own JSX per column
|
||||
* (static elements or accessors) plus header labels. Theme colors are resolved
|
||||
* internally via `useTheme()`. Only the current column's `<scrollbox>` receives
|
||||
* `focused`, so scroll focus follows the cursor (j/k stay in the current pane).
|
||||
*
|
||||
* Example:
|
||||
* <YaziPaneRow
|
||||
* parent={parentList}
|
||||
* current={currentList}
|
||||
* preview={detail}
|
||||
* parentLabel="Up"
|
||||
* currentLabel="List · 42"
|
||||
* previewLabel="Detail"
|
||||
* focused={isActive}
|
||||
* />
|
||||
*/
|
||||
|
||||
import { children as solidChildren, createMemo, Show } from "solid-js";
|
||||
import type { JSX } from "solid-js";
|
||||
import type { RGBA } from "@opentui/core";
|
||||
import { useTheme } from "@/context/ThemeContext";
|
||||
import { PANE_RATIO } from "@/utils/navigation";
|
||||
|
||||
// ── Types ───────────────────────────────────────────────────────────────────
|
||||
type PaneContent = JSX.Element | (() => JSX.Element);
|
||||
type PaneLabel = string | (() => string);
|
||||
|
||||
export type YaziPaneRowProps = {
|
||||
/** Parent column content (previous-depth list, or null for a muted
|
||||
* placeholder — the 1/7 slot is always preserved). */
|
||||
parent?: PaneContent;
|
||||
/** Current column content (the focused list). */
|
||||
current?: PaneContent;
|
||||
/** Preview column content (detail of the hovered item). */
|
||||
preview?: PaneContent;
|
||||
parentLabel?: PaneLabel;
|
||||
currentLabel?: PaneLabel;
|
||||
previewLabel?: PaneLabel;
|
||||
/** Whether the current column carries the accent focus ring. Defaults to
|
||||
* true; pass `false` (or a signal) when the row is inactive. Parent and
|
||||
* preview columns always render muted borders. */
|
||||
focused?: boolean | (() => boolean);
|
||||
};
|
||||
|
||||
// ── Helpers ─────────────────────────────────────────────────────────────────
|
||||
function resolveLabel(v: PaneLabel | undefined): string {
|
||||
if (v == null) return "";
|
||||
return typeof v === "function" ? v() : v;
|
||||
}
|
||||
|
||||
function Placeholder(props: { color: () => RGBA }) {
|
||||
return (
|
||||
<box padding={1}>
|
||||
<text fg={props.color()}>—</text>
|
||||
</box>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Pane column ─────────────────────────────────────────────────────────────
|
||||
function YaziPane(props: {
|
||||
grow: number;
|
||||
label: () => string;
|
||||
content: () => JSX.Element | undefined;
|
||||
borderColor: () => RGBA;
|
||||
scrollFocused: () => boolean;
|
||||
}) {
|
||||
const { theme } = useTheme();
|
||||
const muted = () => theme.muted ?? theme.textMuted ?? theme.text;
|
||||
|
||||
// Memoize accessor results so the prop expressions below stay reactive
|
||||
// when the underlying signals (e.g. `focused`) change.
|
||||
const borderColor = createMemo(() => props.borderColor());
|
||||
const scrollFocused = createMemo(() => props.scrollFocused());
|
||||
|
||||
return (
|
||||
<box flexDirection="column" flexGrow={props.grow} flexBasis={0} height="100%">
|
||||
{/* ── slim header label row ─────────────────────────────────────────── */}
|
||||
<box height={1} paddingLeft={1} backgroundColor={theme.background}>
|
||||
<text fg={theme.textSecondary}>{props.label()}</text>
|
||||
</box>
|
||||
{/* ── bordered scrollbox ────────────────────────────────────────────── */}
|
||||
<scrollbox
|
||||
height="100%"
|
||||
focused={scrollFocused()}
|
||||
border
|
||||
borderColor={borderColor()}
|
||||
backgroundColor={theme.background}
|
||||
>
|
||||
<Show
|
||||
when={props.content()}
|
||||
fallback={<Placeholder color={muted} />}
|
||||
>
|
||||
{props.content()}
|
||||
</Show>
|
||||
</scrollbox>
|
||||
</box>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Row primitive ───────────────────────────────────────────────────────────
|
||||
export function YaziPaneRow(props: YaziPaneRowProps) {
|
||||
const { theme } = useTheme();
|
||||
|
||||
/** true → the current column gets the accent focus ring. */
|
||||
const focused = createMemo(() => {
|
||||
const f = props.focused;
|
||||
return typeof f === "function" ? f() : f ?? true;
|
||||
});
|
||||
|
||||
// Normalize static JSX and accessor children into reactive accessors.
|
||||
const parentContent = solidChildren(() => props.parent);
|
||||
const currentContent = solidChildren(() => props.current);
|
||||
const previewContent = solidChildren(() => props.preview);
|
||||
|
||||
const parentLabel = createMemo(() => resolveLabel(props.parentLabel));
|
||||
const currentLabel = createMemo(() => resolveLabel(props.currentLabel));
|
||||
const previewLabel = createMemo(() => resolveLabel(props.previewLabel));
|
||||
|
||||
return (
|
||||
<box flexDirection="row" flexGrow={1} width="100%" height="100%">
|
||||
{/* ── parent (1/7) — previous-depth list; always muted ─────────────── */}
|
||||
<YaziPane
|
||||
grow={PANE_RATIO.parent}
|
||||
label={parentLabel}
|
||||
content={parentContent}
|
||||
borderColor={() => theme.border}
|
||||
scrollFocused={() => false}
|
||||
/>
|
||||
{/* ── current (3/7) — the focused list; accent ring when focused ───── */}
|
||||
<YaziPane
|
||||
grow={PANE_RATIO.current}
|
||||
label={currentLabel}
|
||||
content={currentContent}
|
||||
borderColor={() => (focused() ? theme.accent : theme.border)}
|
||||
scrollFocused={() => focused()}
|
||||
/>
|
||||
{/* ── preview (3/7) — hovered-item detail; always muted ────────────── */}
|
||||
<YaziPane
|
||||
grow={PANE_RATIO.preview}
|
||||
label={previewLabel}
|
||||
content={previewContent}
|
||||
borderColor={() => theme.border}
|
||||
scrollFocused={() => false}
|
||||
/>
|
||||
</box>
|
||||
);
|
||||
}
|
||||
@@ -66,13 +66,13 @@ export const LayerDepths = {
|
||||
[TABS.SETTINGS]: SettingsPaneCount,
|
||||
};
|
||||
|
||||
// Yazi-style pane grow ratios (parent : current : preview) ≈ [1, 4, 3].
|
||||
// Yazi-style pane grow ratios (parent : current : preview) = [1, 3, 3].
|
||||
// Panes use flexGrow (Yoga) so columns always sum to the row width regardless
|
||||
// of terminal size — more robust than fixed percentages and exactly mirrors
|
||||
// yazi's `mgr.ratio` config. Set a slot's ratio to 0 to hide it (2-pane tabs).
|
||||
export const PANE_RATIO = {
|
||||
parent: 1,
|
||||
current: 4,
|
||||
current: 3,
|
||||
preview: 3,
|
||||
} as const;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user