Sign In

ink-scrollable-box

Package Overview
Dependencies
Maintainers
1
Versions
4
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

ink-scrollable-box - npm Package Compare versions

Comparing version
1.1.0
to
1.1.1
+95
-25
dist/index.cjs

@@ -25,4 +25,9 @@ 'use strict';

}
if (initialOffset < 0 || !Number.isFinite(initialOffset)) {
throw new Error(
`useScrollable: \`initialOffset\` must be a non-negative finite number, got ${initialOffset}.`
);
}
const maxOffset = Math.max(0, contentHeight - viewportHeight);
const [offset, setOffset] = react.useState(() => clamp(initialOffset, 0, maxOffset));
const [offset, setOffset] = react.useState(() => clamp(Math.round(initialOffset), 0, maxOffset));
const [previousContentHeight, setPreviousContentHeight] = react.useState(contentHeight);

@@ -407,23 +412,25 @@ const [previousMaxOffset, setPreviousMaxOffset] = react.useState(maxOffset);

}, [onReachStart]);
const hasScrolledRef = react.useRef(false);
react.useEffect(() => {
if (scroll.offset !== 0 || hasScrolledRef.current) {
hasScrolledRef.current = true;
}
}, [scroll.offset]);
const threshold = reachThreshold ?? 5;
const maxOffset = Math.max(0, scroll.contentHeight - scroll.viewportHeight);
const inReachEndZoneRef = react.useRef(void 0);
const previousEndMaxOffsetRef = react.useRef(maxOffset);
react.useEffect(() => {
if (!hasScrolledRef.current) {
return;
}
if (scroll.offset >= maxOffset - threshold && maxOffset > 0) {
const inZone = maxOffset > 0 && scroll.offset >= maxOffset - threshold;
const wasInZone = inReachEndZoneRef.current;
const overflowAppeared = previousEndMaxOffsetRef.current === 0 && maxOffset > 0;
previousEndMaxOffsetRef.current = maxOffset;
inReachEndZoneRef.current = inZone;
if (wasInZone !== void 0 && !overflowAppeared && inZone && !wasInZone) {
onReachEndRef.current?.();
}
}, [scroll.offset, maxOffset, threshold]);
const inReachStartZoneRef = react.useRef(void 0);
const previousStartMaxOffsetRef = react.useRef(maxOffset);
react.useEffect(() => {
if (!hasScrolledRef.current) {
return;
}
if (scroll.offset <= threshold && maxOffset > 0) {
const inZone = maxOffset > 0 && scroll.offset <= threshold;
const wasInZone = inReachStartZoneRef.current;
const overflowAppeared = previousStartMaxOffsetRef.current === 0 && maxOffset > 0;
previousStartMaxOffsetRef.current = maxOffset;
inReachStartZoneRef.current = inZone;
if (wasInZone !== void 0 && !overflowAppeared && inZone && !wasInZone) {
onReachStartRef.current?.();

@@ -480,3 +487,3 @@ }

}
function validateProps({ height, lines, children, overscan, reachThreshold }) {
function validateProps({ height, lines, children, overscan, reachThreshold, initialOffset }) {
if (lines !== void 0 && children !== void 0) {

@@ -497,2 +504,5 @@ throw new Error("ScrollableBox: Provide either `lines` or `children`, not both.");

}
if (initialOffset !== void 0 && (initialOffset < 0 || !Number.isFinite(initialOffset))) {
throw new Error(`ScrollableBox: \`initialOffset\` must be a non-negative finite number, got ${initialOffset}.`);
}
}

@@ -665,2 +675,40 @@ function computeScrollToIndex(index, effectiveHeight, align, currentOffset) {

}
function allChildrenMeasured(heights, count) {
if (heights.length !== count) {
return false;
}
for (let i = 0; i < count; i++) {
if (typeof heights[i] !== "number") {
return false;
}
}
return true;
}
function Indicators({
showIndicators,
hasOverflow,
canScrollUp,
canScrollDown,
upIndicator,
downIndicator
}) {
if (!showIndicators || !hasOverflow) {
return null;
}
return /* @__PURE__ */ jsxRuntime.jsxs(ink.Box, { justifyContent: "space-between", "aria-hidden": true, children: [
/* @__PURE__ */ jsxRuntime.jsx(ink.Text, { dimColor: true, children: canScrollUp ? upIndicator : " " }),
/* @__PURE__ */ jsxRuntime.jsx(ink.Text, { dimColor: true, children: canScrollDown ? downIndicator : " " })
] });
}
function ScreenReaderAnnouncement({
isScreenReaderEnabled,
hasOverflow,
isFocused,
positionLabel
}) {
if (!isScreenReaderEnabled || !hasOverflow) {
return null;
}
return /* @__PURE__ */ jsxRuntime.jsx(ink.Box, { height: 0, overflowY: "hidden", children: /* @__PURE__ */ jsxRuntime.jsx(ink.Text, { "aria-label": isFocused ? `${positionLabel}, focused` : positionLabel, children: " " }) });
}
function ScrollableBoxRender(props, ref) {

@@ -673,2 +721,3 @@ const {

followOutput,
initialOffset,
scrollStep,

@@ -713,3 +762,4 @@ border,

overscan,
reachThreshold
reachThreshold,
initialOffset
});

@@ -722,2 +772,3 @@ const childrenArray = react.useMemo(

const [measuredHeight, setMeasuredHeight] = react.useState(childrenArray.length);
const [measurementVersion, setMeasurementVersion] = react.useState(0);
react.useEffect(() => {

@@ -729,9 +780,9 @@ if (heightsRef.current.length > childrenArray.length) {

react.useEffect(() => {
if (measureChildren && heightsRef.current.length === childrenArray.length) {
if (measureChildren && allChildrenMeasured(heightsRef.current, childrenArray.length)) {
const total = heightsRef.current.reduce((sum, h) => sum + h, 0);
if (total !== measuredHeight && total > 0) {
if (total !== measuredHeight) {
setMeasuredHeight(total);
}
}
}, [measureChildren, childrenArray.length, measuredHeight]);
}, [measureChildren, childrenArray.length, measuredHeight, measurementVersion]);
const onItemHeightChangeRef = react.useRef(onItemHeightChange);

@@ -744,2 +795,5 @@ react.useEffect(() => {

heightsRef.current[index] = h;
if (previous !== h) {
setMeasurementVersion((v) => v + 1);
}
if (previous !== void 0 && previous !== h) {

@@ -756,2 +810,3 @@ onItemHeightChangeRef.current?.(index, h, previous);

followOutput,
initialOffset,
controlledOffset: offset,

@@ -808,9 +863,24 @@ onOffsetChange

) : null;
const indicators = showIndicators && hasOverflow ? /* @__PURE__ */ jsxRuntime.jsxs(ink.Box, { justifyContent: "space-between", "aria-hidden": true, children: [
/* @__PURE__ */ jsxRuntime.jsx(ink.Text, { dimColor: true, children: scroll.canScrollUp ? upIndicator : " " }),
/* @__PURE__ */ jsxRuntime.jsx(ink.Text, { dimColor: true, children: scroll.canScrollDown ? downIndicator : " " })
] }) : null;
const indicators = /* @__PURE__ */ jsxRuntime.jsx(
Indicators,
{
showIndicators,
hasOverflow,
canScrollUp: scroll.canScrollUp,
canScrollDown: scroll.canScrollDown,
upIndicator,
downIndicator
}
);
const visibleEnd = Math.min(scroll.offset + effectiveHeight, contentHeight);
const positionLabel = `Showing lines ${scroll.offset + 1} to ${visibleEnd} of ${contentHeight}`;
const screenReaderAnnouncement = isScreenReaderEnabled && hasOverflow ? /* @__PURE__ */ jsxRuntime.jsx(ink.Box, { height: 0, overflowY: "hidden", children: /* @__PURE__ */ jsxRuntime.jsx(ink.Text, { "aria-label": isFocused ? `${positionLabel}, focused` : positionLabel, children: " " }) }) : null;
const screenReaderAnnouncement = /* @__PURE__ */ jsxRuntime.jsx(
ScreenReaderAnnouncement,
{
isScreenReaderEnabled,
hasOverflow,
isFocused,
positionLabel
}
);
const contentBox = /* @__PURE__ */ jsxRuntime.jsxs(

@@ -817,0 +887,0 @@ ink.Box,

@@ -51,3 +51,9 @@ import * as react_jsx_runtime from 'react/jsx-runtime';

followOutput?: boolean;
/** Starting scroll position (default: 0) */
/**
* Starting scroll position (default: 0). Applied once when the hook mounts,
* against the `contentHeight` known at that moment, and rounded/clamped to a
* valid integer offset. It does not re-apply when `contentHeight` grows later
* (async data, measured children), so it cannot pin to a not-yet-known
* bottom. For tailing growing content, use `followOutput` instead.
*/
initialOffset?: number;

@@ -128,2 +134,11 @@ /** External controlled offset. When provided, overrides internal state. */

followOutput?: boolean;
/**
* Starting scroll offset for uncontrolled mode (default: 0). Ignored when
* `offset` (controlled mode) is provided. Applied once against the content
* height known at mount and rounded/clamped to a valid integer, so it does
* not track content that loads or is measured after mount (async data,
* `measureChildren`). To keep a growing log pinned to the bottom, use
* `followOutput` instead.
*/
initialOffset?: number;
/** Lines per arrow key press (default: 1) */

@@ -194,5 +209,16 @@ scrollStep?: number;

onItemHeightChange?: (index: number, height: number, previousHeight: number) => void;
/** Called when scroll position is within `reachThreshold` lines of the bottom. Useful for loading more content. */
/**
* Called when the scroll position enters within `reachThreshold` lines of the
* bottom. Useful for loading more content. Edge-triggered: it fires once per
* entry into the zone and re-arms only after the offset leaves it, so it will
* not fire on mount and will not re-fire while the offset stays pinned to the
* bottom (e.g. under `followOutput` during a continuous append stream).
*/
onReachEnd?: () => void;
/** Called when scroll position is within `reachThreshold` lines of the top. Useful for loading earlier content. */
/**
* Called when the scroll position enters within `reachThreshold` lines of the
* top. Useful for loading earlier content. Edge-triggered: fires once per
* entry into the zone and re-arms only after the offset leaves it (does not
* fire on mount).
*/
onReachStart?: () => void;

@@ -199,0 +225,0 @@ /** Number of lines from the edge to trigger onReachEnd/onReachStart (default: 5) */

@@ -51,3 +51,9 @@ import * as react_jsx_runtime from 'react/jsx-runtime';

followOutput?: boolean;
/** Starting scroll position (default: 0) */
/**
* Starting scroll position (default: 0). Applied once when the hook mounts,
* against the `contentHeight` known at that moment, and rounded/clamped to a
* valid integer offset. It does not re-apply when `contentHeight` grows later
* (async data, measured children), so it cannot pin to a not-yet-known
* bottom. For tailing growing content, use `followOutput` instead.
*/
initialOffset?: number;

@@ -128,2 +134,11 @@ /** External controlled offset. When provided, overrides internal state. */

followOutput?: boolean;
/**
* Starting scroll offset for uncontrolled mode (default: 0). Ignored when
* `offset` (controlled mode) is provided. Applied once against the content
* height known at mount and rounded/clamped to a valid integer, so it does
* not track content that loads or is measured after mount (async data,
* `measureChildren`). To keep a growing log pinned to the bottom, use
* `followOutput` instead.
*/
initialOffset?: number;
/** Lines per arrow key press (default: 1) */

@@ -194,5 +209,16 @@ scrollStep?: number;

onItemHeightChange?: (index: number, height: number, previousHeight: number) => void;
/** Called when scroll position is within `reachThreshold` lines of the bottom. Useful for loading more content. */
/**
* Called when the scroll position enters within `reachThreshold` lines of the
* bottom. Useful for loading more content. Edge-triggered: it fires once per
* entry into the zone and re-arms only after the offset leaves it, so it will
* not fire on mount and will not re-fire while the offset stays pinned to the
* bottom (e.g. under `followOutput` during a continuous append stream).
*/
onReachEnd?: () => void;
/** Called when scroll position is within `reachThreshold` lines of the top. Useful for loading earlier content. */
/**
* Called when the scroll position enters within `reachThreshold` lines of the
* top. Useful for loading earlier content. Edge-triggered: fires once per
* entry into the zone and re-arms only after the offset leaves it (does not
* fire on mount).
*/
onReachStart?: () => void;

@@ -199,0 +225,0 @@ /** Number of lines from the edge to trigger onReachEnd/onReachStart (default: 5) */

@@ -23,4 +23,9 @@ import { forwardRef, useMemo, Children, useRef, useState, useEffect, useCallback, useImperativeHandle } from 'react';

}
if (initialOffset < 0 || !Number.isFinite(initialOffset)) {
throw new Error(
`useScrollable: \`initialOffset\` must be a non-negative finite number, got ${initialOffset}.`
);
}
const maxOffset = Math.max(0, contentHeight - viewportHeight);
const [offset, setOffset] = useState(() => clamp(initialOffset, 0, maxOffset));
const [offset, setOffset] = useState(() => clamp(Math.round(initialOffset), 0, maxOffset));
const [previousContentHeight, setPreviousContentHeight] = useState(contentHeight);

@@ -405,23 +410,25 @@ const [previousMaxOffset, setPreviousMaxOffset] = useState(maxOffset);

}, [onReachStart]);
const hasScrolledRef = useRef(false);
useEffect(() => {
if (scroll.offset !== 0 || hasScrolledRef.current) {
hasScrolledRef.current = true;
}
}, [scroll.offset]);
const threshold = reachThreshold ?? 5;
const maxOffset = Math.max(0, scroll.contentHeight - scroll.viewportHeight);
const inReachEndZoneRef = useRef(void 0);
const previousEndMaxOffsetRef = useRef(maxOffset);
useEffect(() => {
if (!hasScrolledRef.current) {
return;
}
if (scroll.offset >= maxOffset - threshold && maxOffset > 0) {
const inZone = maxOffset > 0 && scroll.offset >= maxOffset - threshold;
const wasInZone = inReachEndZoneRef.current;
const overflowAppeared = previousEndMaxOffsetRef.current === 0 && maxOffset > 0;
previousEndMaxOffsetRef.current = maxOffset;
inReachEndZoneRef.current = inZone;
if (wasInZone !== void 0 && !overflowAppeared && inZone && !wasInZone) {
onReachEndRef.current?.();
}
}, [scroll.offset, maxOffset, threshold]);
const inReachStartZoneRef = useRef(void 0);
const previousStartMaxOffsetRef = useRef(maxOffset);
useEffect(() => {
if (!hasScrolledRef.current) {
return;
}
if (scroll.offset <= threshold && maxOffset > 0) {
const inZone = maxOffset > 0 && scroll.offset <= threshold;
const wasInZone = inReachStartZoneRef.current;
const overflowAppeared = previousStartMaxOffsetRef.current === 0 && maxOffset > 0;
previousStartMaxOffsetRef.current = maxOffset;
inReachStartZoneRef.current = inZone;
if (wasInZone !== void 0 && !overflowAppeared && inZone && !wasInZone) {
onReachStartRef.current?.();

@@ -478,3 +485,3 @@ }

}
function validateProps({ height, lines, children, overscan, reachThreshold }) {
function validateProps({ height, lines, children, overscan, reachThreshold, initialOffset }) {
if (lines !== void 0 && children !== void 0) {

@@ -495,2 +502,5 @@ throw new Error("ScrollableBox: Provide either `lines` or `children`, not both.");

}
if (initialOffset !== void 0 && (initialOffset < 0 || !Number.isFinite(initialOffset))) {
throw new Error(`ScrollableBox: \`initialOffset\` must be a non-negative finite number, got ${initialOffset}.`);
}
}

@@ -663,2 +673,40 @@ function computeScrollToIndex(index, effectiveHeight, align, currentOffset) {

}
function allChildrenMeasured(heights, count) {
if (heights.length !== count) {
return false;
}
for (let i = 0; i < count; i++) {
if (typeof heights[i] !== "number") {
return false;
}
}
return true;
}
function Indicators({
showIndicators,
hasOverflow,
canScrollUp,
canScrollDown,
upIndicator,
downIndicator
}) {
if (!showIndicators || !hasOverflow) {
return null;
}
return /* @__PURE__ */ jsxs(Box, { justifyContent: "space-between", "aria-hidden": true, children: [
/* @__PURE__ */ jsx(Text, { dimColor: true, children: canScrollUp ? upIndicator : " " }),
/* @__PURE__ */ jsx(Text, { dimColor: true, children: canScrollDown ? downIndicator : " " })
] });
}
function ScreenReaderAnnouncement({
isScreenReaderEnabled,
hasOverflow,
isFocused,
positionLabel
}) {
if (!isScreenReaderEnabled || !hasOverflow) {
return null;
}
return /* @__PURE__ */ jsx(Box, { height: 0, overflowY: "hidden", children: /* @__PURE__ */ jsx(Text, { "aria-label": isFocused ? `${positionLabel}, focused` : positionLabel, children: " " }) });
}
function ScrollableBoxRender(props, ref) {

@@ -671,2 +719,3 @@ const {

followOutput,
initialOffset,
scrollStep,

@@ -711,3 +760,4 @@ border,

overscan,
reachThreshold
reachThreshold,
initialOffset
});

@@ -720,2 +770,3 @@ const childrenArray = useMemo(

const [measuredHeight, setMeasuredHeight] = useState(childrenArray.length);
const [measurementVersion, setMeasurementVersion] = useState(0);
useEffect(() => {

@@ -727,9 +778,9 @@ if (heightsRef.current.length > childrenArray.length) {

useEffect(() => {
if (measureChildren && heightsRef.current.length === childrenArray.length) {
if (measureChildren && allChildrenMeasured(heightsRef.current, childrenArray.length)) {
const total = heightsRef.current.reduce((sum, h) => sum + h, 0);
if (total !== measuredHeight && total > 0) {
if (total !== measuredHeight) {
setMeasuredHeight(total);
}
}
}, [measureChildren, childrenArray.length, measuredHeight]);
}, [measureChildren, childrenArray.length, measuredHeight, measurementVersion]);
const onItemHeightChangeRef = useRef(onItemHeightChange);

@@ -742,2 +793,5 @@ useEffect(() => {

heightsRef.current[index] = h;
if (previous !== h) {
setMeasurementVersion((v) => v + 1);
}
if (previous !== void 0 && previous !== h) {

@@ -754,2 +808,3 @@ onItemHeightChangeRef.current?.(index, h, previous);

followOutput,
initialOffset,
controlledOffset: offset,

@@ -806,9 +861,24 @@ onOffsetChange

) : null;
const indicators = showIndicators && hasOverflow ? /* @__PURE__ */ jsxs(Box, { justifyContent: "space-between", "aria-hidden": true, children: [
/* @__PURE__ */ jsx(Text, { dimColor: true, children: scroll.canScrollUp ? upIndicator : " " }),
/* @__PURE__ */ jsx(Text, { dimColor: true, children: scroll.canScrollDown ? downIndicator : " " })
] }) : null;
const indicators = /* @__PURE__ */ jsx(
Indicators,
{
showIndicators,
hasOverflow,
canScrollUp: scroll.canScrollUp,
canScrollDown: scroll.canScrollDown,
upIndicator,
downIndicator
}
);
const visibleEnd = Math.min(scroll.offset + effectiveHeight, contentHeight);
const positionLabel = `Showing lines ${scroll.offset + 1} to ${visibleEnd} of ${contentHeight}`;
const screenReaderAnnouncement = isScreenReaderEnabled && hasOverflow ? /* @__PURE__ */ jsx(Box, { height: 0, overflowY: "hidden", children: /* @__PURE__ */ jsx(Text, { "aria-label": isFocused ? `${positionLabel}, focused` : positionLabel, children: " " }) }) : null;
const screenReaderAnnouncement = /* @__PURE__ */ jsx(
ScreenReaderAnnouncement,
{
isScreenReaderEnabled,
hasOverflow,
isFocused,
positionLabel
}
);
const contentBox = /* @__PURE__ */ jsxs(

@@ -815,0 +885,0 @@ Box,

{
"name": "ink-scrollable-box",
"version": "1.1.0",
"version": "1.1.1",
"description": "Scrollable container component for Ink with keyboard navigation, vim bindings, and auto-follow",

@@ -30,5 +30,10 @@ "keywords": [

".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js",
"require": "./dist/index.cjs"
"import": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
},
"require": {
"types": "./dist/index.d.cts",
"default": "./dist/index.cjs"
}
}

@@ -40,3 +45,3 @@ },

"engines": {
"node": ">=18"
"node": ">=20"
},

@@ -55,4 +60,4 @@ "scripts": {

"peerDependencies": {
"ink": ">=4.0.0",
"react": ">=18.0.0"
"ink": ">=6.0.0",
"react": ">=19.0.0"
},

@@ -59,0 +64,0 @@ "devDependencies": {

@@ -20,3 +20,3 @@ # ink-scrollable-box

Requires `ink >= 4` and `react >= 18` as peer dependencies.
Requires `ink >= 6` and `react >= 19` as peer dependencies. (The component uses Ink 6's `useIsScreenReaderEnabled` hook for accessibility announcements.)

@@ -230,2 +230,3 @@ ## Quick Start

| `followOutput` | `boolean` | `false` | Auto-scroll to bottom when content grows |
| `initialOffset` | `number` | `0` | Starting scroll offset in uncontrolled mode (ignored when `offset` is set). Applied once against the content height known at mount; see note below |
| `scrollStep` | `number` | `1` | Lines per arrow key / j/k press |

@@ -237,2 +238,4 @@ | `border` | `boolean` | `false` | Render a rounded border around the viewport |

> **`initialOffset` caveat:** it is applied once at mount against the content height known at that moment and rounded/clamped to a valid offset. It does not track content that loads or is measured after mount (async data or `measureChildren`, where heights are unknown at mount), so it cannot pin to a not-yet-known bottom. To keep a growing log pinned to the bottom, use `followOutput`.
#### Scrollbar Props

@@ -280,6 +283,8 @@

| `onItemHeightChange` | `(index: number, height: number, previousHeight: number) => void` | -- | Called when a measured child's height changes (requires `measureChildren`) |
| `onReachEnd` | `() => void` | -- | Called when scroll is within `reachThreshold` of the bottom |
| `onReachStart` | `() => void` | -- | Called when scroll is within `reachThreshold` of the top |
| `onReachEnd` | `() => void` | -- | Called when scroll enters within `reachThreshold` of the bottom (edge-triggered; see note) |
| `onReachStart` | `() => void` | -- | Called when scroll enters within `reachThreshold` of the top (edge-triggered; see note) |
| `reachThreshold` | `number` | `5` | Lines from edge to trigger `onReachEnd` / `onReachStart` |
> **`onReachEnd` / `onReachStart` are edge-triggered:** each fires once when the offset crosses into its threshold zone and re-arms only after the offset leaves the zone. They do not fire on mount. In particular, when `followOutput` keeps the viewport pinned to the bottom across a continuous append stream, the offset never leaves the end zone, so `onReachEnd` fires once and does not re-fire while pinned.
#### Controlled Mode Props

@@ -286,0 +291,0 @@