ink-scrollable-box
Advanced tools
+95
-25
@@ -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, |
+29
-3
@@ -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) */ |
+29
-3
@@ -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) */ |
+95
-25
@@ -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, |
+12
-7
| { | ||
| "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": { |
+8
-3
@@ -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 @@ |
105924
8.17%2122
8.43%481
1.05%