Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions examples/scroll/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
import './scroll.js';
96 changes: 96 additions & 0 deletions examples/scroll/scroll.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import React, {useRef, useState} from 'react';
import {
render,
Box,
Text,
useInput,
useApp,
useBoxMetrics,
} from '../../src/index.js';

function ScrollView({
height,
children,
}: {
readonly height: number;
readonly children: React.ReactNode;
}) {
const ref = useRef(null);
const {clientWidth, clientHeight, scrollWidth, scrollHeight} =
useBoxMetrics(ref);
const [scrollTop, setScrollTop] = useState(0);
const [scrollLeft, setScrollLeft] = useState(0);
const maxScrollTop = Math.max(0, scrollHeight - clientHeight);
const maxScrollLeft = Math.max(0, scrollWidth - clientWidth);

useInput((_input, key) => {
if (key.upArrow) {
setScrollTop(previousScrollTop => Math.max(0, previousScrollTop - 1));
}

if (key.downArrow) {
setScrollTop(previousScrollTop =>
Math.min(maxScrollTop, previousScrollTop + 1),
);
}

if (key.leftArrow) {
setScrollLeft(previousScrollLeft => Math.max(0, previousScrollLeft - 2));
}

if (key.rightArrow) {
setScrollLeft(previousScrollLeft =>
Math.min(maxScrollLeft, previousScrollLeft + 2),
);
}
});

return (
<Box flexDirection="column">
<Box
ref={ref}
height={height}
overflow="hidden"
contentOffsetY={scrollTop}
contentOffsetX={scrollLeft}
flexDirection="column"
borderStyle="round"
>
{/* flexShrink=0 keeps the content at its natural height, so it can overflow (and scroll) instead of being squeezed into the viewport. The explicit width gives the content a horizontal extent wider than the viewport; without it, text wraps or truncates at the viewport edge and there is nothing to scroll horizontally. */}
<Box flexDirection="column" flexShrink={0} width={140}>
{children}
</Box>
</Box>
<Text dimColor>
scrollTop={scrollTop}/{maxScrollTop} scrollLeft={scrollLeft}/
{maxScrollLeft} client={clientWidth}x{clientHeight} scroll=
{scrollWidth}x{scrollHeight} (arrows to scroll, q to quit)
</Text>
</Box>
);
}

function Demo() {
const {exit} = useApp();

useInput(input => {
if (input === 'q') {
exit();
}
});

return (
<ScrollView height={10}>
{Array.from({length: 40}, (_, index) => (
<Text key={index} wrap="truncate">
Line {String(index + 1).padStart(2, '0')}{' '}
{index % 5 === 0
? `◀ marker ${'·'.repeat(100)} end-of-line-${index + 1} ▶`
: ''}
</Text>
))}
</ScrollView>
);
}

render(<Demo />);
86 changes: 83 additions & 3 deletions readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -1030,6 +1030,60 @@ Default: `visible`

A shortcut for setting `overflowX` and `overflowY` at the same time.

##### contentOffsetX

Type: `number`\
Default: `0`

Horizontal offset applied to the element's children, in columns. Children are shifted left by this amount. Combine with `overflow="hidden"` to build scrollable views.

##### contentOffsetY

Type: `number`\
Default: `0`

Vertical offset applied to the element's children, in rows. Children are shifted up by this amount. Combine with `overflow="hidden"` to build scrollable views.

```jsx
import {useState, useRef} from 'react';
import {Box, useInput, useBoxMetrics} from 'ink';

const ScrollView = ({height, children}) => {
const ref = useRef(null);
const {clientHeight, scrollHeight} = useBoxMetrics(ref);
const [scrollTop, setScrollTop] = useState(0);
const maxScrollTop = Math.max(0, scrollHeight - clientHeight);

useInput((input, key) => {
if (key.upArrow) {
setScrollTop(previousScrollTop => Math.max(0, previousScrollTop - 1));
}

if (key.downArrow) {
setScrollTop(previousScrollTop =>
Math.min(maxScrollTop, previousScrollTop + 1),
);
}
});

return (
<Box
ref={ref}
height={height}
overflow="hidden"
contentOffsetY={scrollTop}
flexDirection="column"
>
<Box flexDirection="column" flexShrink={0}>
{children}
</Box>
</Box>
);
};
```

Note: wrap the content in a `flexShrink={0}` container so it keeps its natural height. Without it, Yoga squeezes the children into the fixed-height viewport and `scrollHeight` never exceeds `clientHeight`, leaving nothing to scroll.

#### Borders

##### borderStyle
Expand Down Expand Up @@ -2176,14 +2230,38 @@ Type: `number`

Distance from the top edge of the parent.

#### clientWidth

Type: `number`

Element width excluding borders.

#### clientHeight

Type: `number`

Element height excluding borders.

#### scrollWidth

Type: `number`

Total width of the element's content, including content not visible due to overflow. Always at least `clientWidth`.

#### scrollHeight

Type: `number`

Total height of the element's content, including content not visible due to overflow. Always at least `clientHeight`.

#### hasMeasured

Type: `boolean`

Whether the currently tracked element has been measured.

> [!NOTE]
> The hook returns `{width: 0, height: 0, left: 0, top: 0}` until the first layout pass completes. It also returns zeros when the tracked ref is detached.
> The hook returns zeros for all metrics until the first layout pass completes. It also returns zeros when the tracked ref is detached.

### useStderr()

Expand Down Expand Up @@ -2925,12 +3003,14 @@ clear();
#### measureElement(ref)

Measure the layout metrics of a particular `<Box>` element.
Returns an object with `x`, `y`, `width`, and `height` properties.
Returns an object with `x`, `y`, `width`, `height`, `clientWidth`, `clientHeight`, `scrollWidth` and `scrollHeight` properties.

`x` and `y` are the element's position within the live layout region, computed by walking up the layout tree. These are layout-tree coordinates, not terminal viewport coordinates. To compare them with mouse events, convert the event coordinates using the live region's viewport position. This is necessary even in alternate-screen mode when output, such as `<Static>` content, appears above the live region.

`clientWidth` and `clientHeight` are the element's dimensions excluding borders. `scrollWidth` and `scrollHeight` are the total dimensions of the element's content, including content not visible due to overflow, and are always at least `clientWidth`/`clientHeight`. These are useful when your component needs to know the amount of available space it has, or how far its content overflows, for example to build scrollable views together with the `contentOffsetX`/`contentOffsetY` props.

> [!NOTE]
> `measureElement()` returns `{x: 0, y: 0, width: 0, height: 0}` when called during render (before layout is calculated). Call it from post-render code, such as `useEffect`, `useLayoutEffect`, input handlers, or timer callbacks. When content changes, pass the relevant dependency to your effect so it re-measures after each update.
> `measureElement()` returns zeros for all properties when called during render (before layout is calculated). Call it from post-render code, such as `useEffect`, `useLayoutEffect`, input handlers, or timer callbacks. When content changes, pass the relevant dependency to your effect so it re-measures after each update.

##### ref

Expand Down
51 changes: 42 additions & 9 deletions src/hooks/use-box-metrics.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import {type RefObject, useState, useEffect, useCallback, useMemo} from 'react';
import {type DOMElement, addLayoutListener} from '../dom.js';
import measureElement from '../measure-element.js';

// Yoga's `right`/`bottom` are omitted: always `0` for flow layout and unintuitive for absolute positioning.
/**
Expand Down Expand Up @@ -27,6 +28,26 @@ export type BoxMetrics = {
Distance from the top edge of the parent.
*/
readonly top: number;

/**
Element width excluding borders.
*/
readonly clientWidth: number;

/**
Element height excluding borders.
*/
readonly clientHeight: number;

/**
Total width of the element's content, including content not visible due to overflow. Always at least `clientWidth`.
*/
readonly scrollWidth: number;

/**
Total height of the element's content, including content not visible due to overflow. Always at least `clientHeight`.
*/
readonly scrollHeight: number;
};

export type UseBoxMetricsResult = BoxMetrics & {
Expand All @@ -41,6 +62,10 @@ const emptyMetrics: BoxMetrics = {
height: 0,
left: 0,
top: 0,
clientWidth: 0,
clientHeight: 0,
scrollWidth: 0,
scrollHeight: 0,
};

// eslint-disable-next-line @typescript-eslint/no-restricted-types
Expand All @@ -60,7 +85,7 @@ const findRootNode = (node: DOMElement | null): DOMElement | undefined => {
A React hook that returns the current layout metrics for a tracked box element.
It updates when layout changes (for example terminal resize, sibling/content changes, or position changes).

The hook returns `{width: 0, height: 0, left: 0, top: 0}` until the first layout pass completes. It also returns zeros when the tracked ref is detached.
The hook returns zeros for all metrics until the first layout pass completes. It also returns zeros when the tracked ref is detached.

Use `hasMeasured` to detect when the currently tracked element has been measured.

Expand Down Expand Up @@ -92,19 +117,27 @@ const useBoxMetrics = (
const [hasMeasured, setHasMeasured] = useState(false);

const updateMetrics = useCallback(() => {
const layout = ref.current?.yogaNode?.getComputedLayout() ?? emptyMetrics;
const node = ref.current;
const layout = node?.yogaNode?.getComputedLayout();

const nextMetrics: BoxMetrics =
node && layout
? {
left: layout.left,
top: layout.top,
...measureElement(node),
}
: emptyMetrics;

setMetrics(previousMetrics => {
const hasChanged =
previousMetrics.width !== layout.width ||
previousMetrics.height !== layout.height ||
previousMetrics.left !== layout.left ||
previousMetrics.top !== layout.top;
const hasChanged = (
Object.keys(nextMetrics) as Array<keyof BoxMetrics>
).some(key => previousMetrics[key] !== nextMetrics[key]);

return hasChanged ? layout : previousMetrics;
return hasChanged ? nextMetrics : previousMetrics;
});

setHasMeasured(Boolean(ref.current));
setHasMeasured(Boolean(node));
}, [ref]);

// Runs after every render of this component.
Expand Down
Loading