diff --git a/README.md b/README.md index 9309fb9..cd3de08 100644 --- a/README.md +++ b/README.md @@ -4,28 +4,17 @@ ![Demo](https://raw.githubusercontent.com/SincerelyFaust/react-fit-list/main/.github/images/demo.gif) -`react-fit-list` is a small headless React utility for rendering a **single-line list that never wraps**. -When the available width is too small, the list collapses extra items into an overflow affordance such as `+3`. - -It ships with: - -- a ready-to-use `` component -- a headless `useFitList()` hook for custom renderers -- TypeScript types for component and hook APIs - 👉 **Live demo:** https://sincerelyfaust.github.io/react-fit-list/ 📦 **npm:** https://www.npmjs.com/package/react-fit-list -## Use cases +`react-fit-list` is a headless React utility for rendering a single horizontal row of items, keeping what fits visible and collapsing the rest behind an overflow trigger. -`react-fit-list` works well for interfaces where wrapping would break the layout, such as: +It ships with: -- tag and chip rows -- recipient lists -- breadcrumbs / metadata rows -- inline filters -- compact table or card cells +- a ready-to-use `` component +- a headless `useFitList()` hook for custom renderers +- TypeScript types for component and hook APIs ## Installation @@ -72,211 +61,92 @@ export function Example() { } ``` -## How it works - -The package measures the container width and item widths, then determines how many items can fit in a single row. -If not all items fit, the remainder is collapsed into an overflow element. - -By default: - -- the component keeps items on one row -- overflow collapses from the end -- the overflow trigger renders as `+N` -- item widths are measured from the DOM in `live` mode - ## Component API ### `` -```tsx -import { FitList } from 'react-fit-list' -``` - -#### Required props - -| Prop | Type | Description | -| --- | --- | --- | -| `items` | `readonly T[]` | Items to render. | -| `getKey` | `(item: T, index: number) => React.Key` | Returns a stable key for each item. | -| `renderItem` | `(item: T, index: number) => React.ReactNode` | Renders one item. | - -#### Optional props - | Prop | Type | Default | Description | | --- | --- | --- | --- | -| `renderOverflow` | `(args) => React.ReactNode` | renders `+N` | Custom overflow renderer. Receives `{ hiddenCount, hiddenItems, visibleItems, isExpanded, setExpanded, toggle }`. | -| `className` | `string` | — | Class for the root container. | -| `listClassName` | `string` | — | Class for the visible-items wrapper. | -| `itemClassName` | `string` | — | Class for each item wrapper. | -| `overflowClassName` | `string` | — | Class for the overflow trigger wrapper. | -| `measureClassName` | `string` | — | Class for hidden measurement nodes. Use when sizing depends on CSS classes. | -| `emptyFallback` | `React.ReactNode` | `null` | Rendered when `items` is empty. | -| `gap` | `number` | `8` | Pixel gap between items. | -| `collapseFrom` | `'end' \| 'start'` | `'end'` | Collapse from the end or start of the list. | -| `overflowPlacement` | `'end' \| 'closest'` | `'end'` | Keep the overflow pinned to the row end or place it next to the hidden segment. | -| `reserveOverflowSpace` | `boolean` | `false` | Reserve room for the overflow element even when everything currently fits. | -| `overflowWidth` | `number` | auto | Fixed overflow width in pixels. Useful when the trigger width is known. | -| `estimatedItemWidth` | `number \| ((item, index) => number)` | fallback `96` | Used in `estimate` mode or before live measurements are available. | -| `measurementMode` | `'live' \| 'estimate'` | `'live'` | `live` measures DOM nodes, `estimate` uses `estimatedItemWidth`. | +| `items` | `readonly T[]` | — | Items to fit into a single row. | +| `getKey` | `(item, index) => React.Key` | — | Returns a stable React key for each item. | +| `renderItem` | `(item, index) => React.ReactNode` | — | Renders a single item. | +| `renderOverflow` | `(args) => React.ReactNode` | `({ hiddenCount }) => +hiddenCount` | Renders the overflow trigger contents. | +| `className` | `string` | — | Class applied to the root row. | +| `itemsClassName` | `string` | — | Class applied to the visible-items wrapper. | +| `itemClassName` | `string` | — | Class applied to each visible item wrapper. | +| `overflowButtonClassName` | `string` | — | Class applied to the overflow button element. | +| `measurementClassName` | `string` | `itemClassName` | Class applied to hidden measurement nodes when sizing depends on matching CSS. | +| `emptyContent` | `React.ReactNode` | `null` | Content rendered when `items` is empty. | +| `gap` | `number` | `8` | Space between items and the overflow trigger. | +| `collapseFrom` | `'end' \| 'start'` | `'end'` | Which side of the list gets collapsed first. | +| `overflowPosition` | `'edge' \| 'inline'` | `'edge'` | Keep the overflow trigger at the row edge or place it next to the hidden side. | +| `preserveOverflowSpace` | `boolean` | `false` | Reserve room for the overflow trigger even when everything fits. | +| `overflowWidth` | `number` | auto | Fixed overflow width in pixels. Useful when the trigger size is known. | +| `itemWidthEstimate` | `number \| ((item, index) => number)` | fallback `96` | Width estimate used in `estimate` mode or before live measurements are available. | +| `measurement` | `'live' \| 'estimate'` | `'live'` | Width calculation strategy. | | `expanded` | `boolean` | uncontrolled | Controlled expanded state. | | `defaultExpanded` | `boolean` | `false` | Initial expanded state for uncontrolled usage. | | `onExpandedChange` | `(expanded: boolean) => void` | — | Called when expanded state changes. | -| `as` | `keyof React.JSX.IntrinsicElements` | `'div'` | Root element tag name. | -| `overflowAs` | `keyof React.JSX.IntrinsicElements` | `'button'` | Overflow element tag name. | +| `onOverflowClick` | `(args, event) => void` | — | Called when the overflow button is clicked. | + +### Overflow render args + +`renderOverflow` and `onOverflowClick` receive the same overflow state object: + +```ts +{ + hiddenCount: number + hiddenItems: T[] + visibleItems: T[] + isExpanded: boolean + setExpanded: (expanded: boolean) => void + toggle: () => void +} +``` ## Hook API ### `useFitList()` -Use the hook when you want to own the markup but reuse the fitting logic. - ```tsx import { useFitList } from 'react-fit-list' -``` -#### Hook options +const fit = useFitList({ + items, + getKey: (item) => item.id, + gap: 8, +}) +``` -The hook accepts the same fitting-related options used by the component: +#### Options -- `items` -- `getKey` -- `reserveOverflowSpace` -- `overflowWidth` -- `gap` -- `collapseFrom` -- `estimatedItemWidth` -- `measurementMode` -- `expanded` -- `defaultExpanded` -- `onExpandedChange` -- `measureOverflowWidth` +| Option | Type | Default | Description | +| --- | --- | --- | --- | +| `items` | `readonly T[]` | — | Items to measure. | +| `getKey` | `(item, index) => React.Key` | — | Returns a stable React key for each item. | +| `gap` | `number` | `8` | Space between items and overflow. | +| `collapseFrom` | `'end' \| 'start'` | `'end'` | Which side collapses first. | +| `preserveOverflowSpace` | `boolean` | `false` | Reserve space for overflow even when all items fit. | +| `overflowWidth` | `number` | auto | Fixed overflow width in pixels. | +| `itemWidthEstimate` | `number \| ((item, index) => number)` | fallback `96` | Width estimate for `estimate` mode. | +| `measurement` | `'live' \| 'estimate'` | `'live'` | Width calculation strategy. | +| `expanded` | `boolean` | uncontrolled | Controlled expanded state. | +| `defaultExpanded` | `boolean` | `false` | Initial expanded state. | +| `onExpandedChange` | `(expanded: boolean) => void` | — | Called whenever expanded state changes. | +| `measureOverflowWidth` | `(hiddenCount: number) => number` | — | Custom overflow width measurement callback. | #### Return value | Field | Type | Description | | --- | --- | --- | -| `containerRef` | `RefObject` | Attach to the outer container whose width should be measured. | -| `registerItem(key)` | `(node) => void` | Attach to each visible item wrapper. | -| `registerMeasureItem(key)` | `(node) => void` | Attach to hidden measurement nodes for accurate width calculation. | -| `registerOverflow(node)` | `(node) => void` | Attach to the overflow element. | -| `visibleItems` | `T[]` | Items currently visible. | -| `hiddenItems` | `T[]` | Items currently hidden. | -| `hiddenCount` | `number` | Count of hidden items. | +| `containerRef` | `RefObject` | Attach to the outer container. | +| `registerItem` | `(key) => (node) => void` | Registers visible item nodes for measurement. | +| `registerMeasureItem` | `(key) => (node) => void` | Registers hidden measurement nodes. | +| `registerOverflow` | `(node) => void` | Registers the overflow node. | +| `visibleItems` | `T[]` | Items currently visible in the collapsed row. | +| `hiddenItems` | `T[]` | Items currently hidden behind overflow. | +| `hiddenCount` | `number` | Number of hidden items. | | `isExpanded` | `boolean` | Whether the list is expanded. | -| `setExpanded` | `(expanded: boolean) => void` | Manually set expanded state. | -| `toggleExpanded` | `() => void` | Toggle expanded state. | -| `recompute` | `() => void` | Force a recalculation. | - -## Examples - -### Custom overflow label - -```tsx - item.id} - renderItem={(item) => {item.label}} - renderOverflow={({ hiddenCount }) => ( - - )} -/> -``` - -### Collapse from the start - -Useful when the most recent or most important items are at the end. - -```tsx - item.id} - renderItem={(item) => {item.label}} - collapseFrom="start" -/> -``` - -### Overflow placement - -By default, the overflow affordance stays pinned to the far end of the row. -Set `overflowPlacement="closest"` to make it hug the hidden segment instead. -This is especially useful with `collapseFrom="start"`. - -```tsx - item.id} - renderItem={(item) => {item.label}} - collapseFrom="start" - overflowPlacement="closest" -/> -``` - -### Estimate mode - -Estimate mode avoids relying on live measurement for every item and can be useful when item widths are predictable. - -```tsx - item.id} - renderItem={(item) => {item.label}} - measurementMode="estimate" - estimatedItemWidth={(item) => Math.max(72, item.label.length * 8)} -/> -``` - -### Controlled expanded state - -`FitList` does not impose any default click behavior for the overflow trigger. Use `renderOverflow` to define interactions such as opening a popover, modal, or expanding the list. - -Use `expanded` / `onExpandedChange` only when you intentionally want to control expansion behavior. - -```tsx -function ControlledExample() { - const [expanded, setExpanded] = useState(false) - - return ( - item.id} - renderItem={(item) => {item.label}} - expanded={expanded} - onExpandedChange={setExpanded} - /> - ) -} -``` - -## Styling guidance - -The package is intentionally headless. You control the appearance of the item contents. - -For best results: - -- keep each rendered item visually compact -- ensure item content does not wrap internally (`white-space: nowrap` is usually correct) -- use `measureClassName` when your measurement nodes need the same CSS as your visible nodes -- provide `overflowWidth` when you know the trigger width and want more predictable calculations - -## Accessibility notes - -- By default the overflow trigger renders as a ` ) : ( )} @@ -181,13 +162,13 @@ export function FitList({ const itemsNode = (
@@ -211,33 +192,23 @@ export function FitList({
); - const content = ( - <> - {shouldPlaceOverflowBeforeItems ? overflowNode : null} - {itemsNode} - {shouldPlaceOverflowBeforeItems ? null : overflowNode} - - ); - - const root = React.createElement( - Component, - { - ref: containerRef as React.Ref, - className, - style: { - display: "flex", - alignItems: "center", - gap, - minWidth: 0, - whiteSpace: "nowrap", - }, - }, - content - ); - return ( <> - {root} +
+ {shouldPlaceOverflowBeforeItems ? overflowNode : null} + {itemsNode} + {shouldPlaceOverflowBeforeItems ? null : overflowNode} +
{/* Hidden measurement tree used to capture accurate intrinsic widths without @@ -262,7 +233,7 @@ export function FitList({ ({ {shouldRenderMeasuredOverflow ? ( {overflowChildren} diff --git a/src/hooks/useFitList.tsx b/src/hooks/useFitList.tsx index f32ae2a..4b2c19b 100644 --- a/src/hooks/useFitList.tsx +++ b/src/hooks/useFitList.tsx @@ -16,12 +16,12 @@ const useIsoLayoutEffect = function getEstimatedWidth( item: T, index: number, - estimatedItemWidth: number | ((item: T, index: number) => number) | undefined, + itemWidthEstimate: number | ((item: T, index: number) => number) | undefined, fallback: number ) { - if (typeof estimatedItemWidth === "function") - return estimatedItemWidth(item, index); - if (typeof estimatedItemWidth === "number") return estimatedItemWidth; + if (typeof itemWidthEstimate === "function") + return itemWidthEstimate(item, index); + if (typeof itemWidthEstimate === "number") return itemWidthEstimate; return fallback; } @@ -45,12 +45,12 @@ function getEstimatedWidth( export function useFitList({ items, getKey, - reserveOverflowSpace = false, + preserveOverflowSpace = false, overflowWidth, gap = 8, collapseFrom = "end", - estimatedItemWidth, - measurementMode = "live", + itemWidthEstimate, + measurement = "live", expanded, defaultExpanded = false, onExpandedChange, @@ -90,11 +90,11 @@ export function useFitList({ const key = keys[index]; const measureNode = measureNodeMap.current.get(key); const liveNode = itemNodeMap.current.get(key); - if (measurementMode === "live") { + if (measurement === "live") { if (measureNode) return measureNode.offsetWidth; if (liveNode) return liveNode.offsetWidth; } - return getEstimatedWidth(item, index, estimatedItemWidth, 96); + return getEstimatedWidth(item, index, itemWidthEstimate, 96); }); let nextVisible = items.length; @@ -119,7 +119,7 @@ export function useFitList({ } else { currentOverflowWidth = overflowRef.current?.offsetWidth ?? 44; } - } else if (reserveOverflowSpace) { + } else if (preserveOverflowSpace) { if (typeof overflowWidth === "number") { currentOverflowWidth = overflowWidth; } else { @@ -128,7 +128,7 @@ export function useFitList({ } const overflowGap = - (hiddenCount > 0 || reserveOverflowSpace) && count > 0 ? gap : 0; + (hiddenCount > 0 || preserveOverflowSpace) && count > 0 ? gap : 0; const total = itemsWidth + itemsGap + overflowGap + currentOverflowWidth; if (total <= containerWidth) { @@ -140,15 +140,15 @@ export function useFitList({ setVisibleCount((prev) => (prev === nextVisible ? prev : nextVisible)); }, [ collapseFrom, - estimatedItemWidth, + itemWidthEstimate, gap, getKey, isExpanded, items, - measurementMode, + measurement, measureOverflowWidth, overflowWidth, - reserveOverflowSpace, + preserveOverflowSpace, ]); useIsoLayoutEffect(() => { diff --git a/src/index.ts b/src/index.ts index fff7bfe..77f157b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -8,8 +8,8 @@ export { FitList } from "./components/FitList"; export { useFitList } from "./hooks/useFitList"; export type { CollapseFrom, - OverflowPlacement, - FitListMeasurementMode, + OverflowPosition, + FitListMeasurement, FitListOverflowRenderArgs, FitListProps, UseFitListOptions, diff --git a/src/types/index.ts b/src/types/index.ts index 8a69fed..268e4e3 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -13,18 +13,18 @@ export type CollapseFrom = "end" | "start"; * Controls where the overflow affordance is rendered relative to the visible * items. * - * - `"end"`: always render the overflow affordance at the far end of the row. - * - `"closest"`: render the overflow affordance next to the hidden segment. + * - `"edge"`: always render the overflow affordance at the far edge of the row. + * - `"inline"`: render the overflow affordance next to the hidden segment. */ -export type OverflowPlacement = "end" | "closest"; +export type OverflowPosition = "edge" | "inline"; /** * Determines how item widths are measured when calculating how many items fit. * * - `"live"`: use hidden measurement nodes / rendered nodes for accurate widths. - * - `"estimate"`: use `estimatedItemWidth` for lower-cost calculations. + * - `"estimate"`: use `itemWidthEstimate` for lower-cost calculations. */ -export type FitListMeasurementMode = "live" | "estimate"; +export type FitListMeasurement = "live" | "estimate"; /** * Arguments passed to `renderOverflow` so consumers can customize the overflow @@ -57,7 +57,7 @@ export type UseFitListOptions = { * Keeps overflow space reserved even when all items currently fit. * Useful when you want layout to stay stable while container width changes. */ - reserveOverflowSpace?: boolean; + preserveOverflowSpace?: boolean; /** * Fixed overflow width in pixels. Supply this when your overflow trigger has a * known size and you want to skip measuring it. @@ -71,9 +71,9 @@ export type UseFitListOptions = { * Estimated width used in `"estimate"` mode, or as a fallback when a live * measurement is not available. */ - estimatedItemWidth?: number | ((item: T, index: number) => number); + itemWidthEstimate?: number | ((item: T, index: number) => number); /** Strategy used to determine item widths. */ - measurementMode?: FitListMeasurementMode; + measurement?: FitListMeasurement; /** Controlled expanded state. */ expanded?: boolean; /** Uncontrolled initial expanded state. */ @@ -132,43 +132,42 @@ export type FitListProps = { renderOverflow?: (args: FitListOverflowRenderArgs) => React.ReactNode; /** Class applied to the root container. */ className?: string; - /** Class applied to the inner list that contains visible items. */ - listClassName?: string; + /** Class applied to the visible-items wrapper. */ + itemsClassName?: string; /** Class applied to each visible item wrapper. */ itemClassName?: string; - /** Class applied to the overflow trigger wrapper. */ - overflowClassName?: string; + /** Class applied to the overflow button. */ + overflowButtonClassName?: string; /** * Class applied to hidden measurement nodes. Use this when item sizing depends * on CSS classes and must match the rendered item styles. */ - measureClassName?: string; + measurementClassName?: string; /** Content rendered when `items` is empty. Defaults to `null`. */ - emptyFallback?: React.ReactNode; + emptyContent?: React.ReactNode; /** Horizontal spacing, in pixels, between items and overflow trigger. */ gap?: number; /** Which side should collapse first when there is not enough room. */ collapseFrom?: CollapseFrom; - /** Controls whether the overflow stays pinned to the row end or hugs the hidden segment. */ - overflowPlacement?: OverflowPlacement; + /** Controls whether the overflow stays pinned to the row edge or sits inline with the hidden segment. */ + overflowPosition?: OverflowPosition; /** Keeps overflow space reserved even when everything fits. */ - reserveOverflowSpace?: boolean; + preserveOverflowSpace?: boolean; /** Fixed overflow width in pixels. */ overflowWidth?: number; /** Estimated item width used in `"estimate"` mode. */ - estimatedItemWidth?: number | ((item: T, index: number) => number); + itemWidthEstimate?: number | ((item: T, index: number) => number); /** Strategy used to determine widths. */ - measurementMode?: FitListMeasurementMode; + measurement?: FitListMeasurement; /** Controlled expanded state. */ expanded?: boolean; /** Uncontrolled initial expanded state. */ defaultExpanded?: boolean; /** Called whenever expanded state changes. */ onExpandedChange?: (expanded: boolean) => void; - /** Root element tag name. Defaults to `"div"`. */ - as?: keyof React.JSX.IntrinsicElements; - /** Called when the overflow trigger is clicked. No action is performed by default. */ - onOverflowClick?: (args: FitListOverflowRenderArgs, event: React.MouseEvent) => void; - /** Overflow trigger element tag name. Defaults to `"button"`. */ - overflowAs?: keyof React.JSX.IntrinsicElements; + /** Called when the overflow button is clicked. No action is performed by default. */ + onOverflowClick?: ( + args: FitListOverflowRenderArgs, + event: React.MouseEvent + ) => void; };