react-fit-list 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +259 -0
- package/dist/index.cjs +435 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +197 -0
- package/dist/index.d.ts +197 -0
- package/dist/index.js +413 -0
- package/dist/index.js.map +1 -0
- package/package.json +66 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Marin Heđeš
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
# react-fit-list
|
|
2
|
+
|
|
3
|
+

|
|
4
|
+
|
|
5
|
+
`react-fit-list` is a small headless React utility for rendering a **single-line list that never wraps**.
|
|
6
|
+
When the available width is too small, the list collapses extra items into an overflow affordance such as `+3`.
|
|
7
|
+
|
|
8
|
+
It ships with:
|
|
9
|
+
|
|
10
|
+
- a ready-to-use `<FitList />` component
|
|
11
|
+
- a headless `useFitList()` hook for custom renderers
|
|
12
|
+
- TypeScript types for component and hook APIs
|
|
13
|
+
|
|
14
|
+
## Use cases
|
|
15
|
+
|
|
16
|
+
`react-fit-list` works well for interfaces where wrapping would break the layout, such as:
|
|
17
|
+
|
|
18
|
+
- tag and chip rows
|
|
19
|
+
- recipient lists
|
|
20
|
+
- breadcrumbs / metadata rows
|
|
21
|
+
- inline filters
|
|
22
|
+
- compact table or card cells
|
|
23
|
+
|
|
24
|
+
## Installation
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
npm install react-fit-list
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
## Quick start
|
|
31
|
+
|
|
32
|
+
```tsx
|
|
33
|
+
import { FitList } from 'react-fit-list'
|
|
34
|
+
|
|
35
|
+
const items = [
|
|
36
|
+
{ id: 1, label: 'Security' },
|
|
37
|
+
{ id: 2, label: 'Startups' },
|
|
38
|
+
{ id: 3, label: 'Fintech' },
|
|
39
|
+
{ id: 4, label: 'B2B SaaS' },
|
|
40
|
+
]
|
|
41
|
+
|
|
42
|
+
export function Example() {
|
|
43
|
+
return (
|
|
44
|
+
<div style={{ width: 240 }}>
|
|
45
|
+
<FitList
|
|
46
|
+
items={items}
|
|
47
|
+
getKey={(item) => item.id}
|
|
48
|
+
renderItem={(item) => (
|
|
49
|
+
<span
|
|
50
|
+
style={{
|
|
51
|
+
display: 'inline-flex',
|
|
52
|
+
alignItems: 'center',
|
|
53
|
+
border: '1px solid #d0d7de',
|
|
54
|
+
borderRadius: 999,
|
|
55
|
+
padding: '4px 10px',
|
|
56
|
+
fontSize: 12,
|
|
57
|
+
whiteSpace: 'nowrap',
|
|
58
|
+
}}
|
|
59
|
+
>
|
|
60
|
+
{item.label}
|
|
61
|
+
</span>
|
|
62
|
+
)}
|
|
63
|
+
/>
|
|
64
|
+
</div>
|
|
65
|
+
)
|
|
66
|
+
}
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
## How it works
|
|
70
|
+
|
|
71
|
+
The package measures the container width and item widths, then determines how many items can fit in a single row.
|
|
72
|
+
If not all items fit, the remainder is collapsed into an overflow element.
|
|
73
|
+
|
|
74
|
+
By default:
|
|
75
|
+
|
|
76
|
+
- the component keeps items on one row
|
|
77
|
+
- overflow collapses from the end
|
|
78
|
+
- the overflow trigger renders as `+N`
|
|
79
|
+
- item widths are measured from the DOM in `live` mode
|
|
80
|
+
|
|
81
|
+
## Component API
|
|
82
|
+
|
|
83
|
+
### `<FitList />`
|
|
84
|
+
|
|
85
|
+
```tsx
|
|
86
|
+
import { FitList } from 'react-fit-list'
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
#### Required props
|
|
90
|
+
|
|
91
|
+
| Prop | Type | Description |
|
|
92
|
+
| --- | --- | --- |
|
|
93
|
+
| `items` | `readonly T[]` | Items to render. |
|
|
94
|
+
| `getKey` | `(item: T, index: number) => React.Key` | Returns a stable key for each item. |
|
|
95
|
+
| `renderItem` | `(item: T, index: number) => React.ReactNode` | Renders one item. |
|
|
96
|
+
|
|
97
|
+
#### Optional props
|
|
98
|
+
|
|
99
|
+
| Prop | Type | Default | Description |
|
|
100
|
+
| --- | --- | --- | --- |
|
|
101
|
+
| `renderOverflow` | `(args) => React.ReactNode` | renders `+N` | Custom overflow renderer. Receives `{ hiddenCount, hiddenItems, visibleItems, isExpanded, setExpanded, toggle }`. |
|
|
102
|
+
| `className` | `string` | — | Class for the root container. |
|
|
103
|
+
| `listClassName` | `string` | — | Class for the visible-items wrapper. |
|
|
104
|
+
| `itemClassName` | `string` | — | Class for each item wrapper. |
|
|
105
|
+
| `overflowClassName` | `string` | — | Class for the overflow trigger wrapper. |
|
|
106
|
+
| `measureClassName` | `string` | — | Class for hidden measurement nodes. Use when sizing depends on CSS classes. |
|
|
107
|
+
| `emptyFallback` | `React.ReactNode` | `null` | Rendered when `items` is empty. |
|
|
108
|
+
| `gap` | `number` | `8` | Pixel gap between items. |
|
|
109
|
+
| `collapseFrom` | `'end' \| 'start'` | `'end'` | Collapse from the end or start of the list. |
|
|
110
|
+
| `reserveOverflowSpace` | `boolean` | `false` | Reserve room for the overflow element even when everything currently fits. |
|
|
111
|
+
| `overflowWidth` | `number` | auto | Fixed overflow width in pixels. Useful when the trigger width is known. |
|
|
112
|
+
| `estimatedItemWidth` | `number \| ((item, index) => number)` | fallback `96` | Used in `estimate` mode or before live measurements are available. |
|
|
113
|
+
| `measurementMode` | `'live' \| 'estimate'` | `'live'` | `live` measures DOM nodes, `estimate` uses `estimatedItemWidth`. |
|
|
114
|
+
| `expanded` | `boolean` | uncontrolled | Controlled expanded state. |
|
|
115
|
+
| `defaultExpanded` | `boolean` | `false` | Initial expanded state for uncontrolled usage. |
|
|
116
|
+
| `onExpandedChange` | `(expanded: boolean) => void` | — | Called when expanded state changes. |
|
|
117
|
+
| `as` | `keyof React.JSX.IntrinsicElements` | `'div'` | Root element tag name. |
|
|
118
|
+
| `overflowAs` | `keyof React.JSX.IntrinsicElements` | `'button'` | Overflow element tag name. |
|
|
119
|
+
|
|
120
|
+
## Hook API
|
|
121
|
+
|
|
122
|
+
### `useFitList()`
|
|
123
|
+
|
|
124
|
+
Use the hook when you want to own the markup but reuse the fitting logic.
|
|
125
|
+
|
|
126
|
+
```tsx
|
|
127
|
+
import { useFitList } from 'react-fit-list'
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
#### Hook options
|
|
131
|
+
|
|
132
|
+
The hook accepts the same fitting-related options used by the component:
|
|
133
|
+
|
|
134
|
+
- `items`
|
|
135
|
+
- `getKey`
|
|
136
|
+
- `reserveOverflowSpace`
|
|
137
|
+
- `overflowWidth`
|
|
138
|
+
- `gap`
|
|
139
|
+
- `collapseFrom`
|
|
140
|
+
- `estimatedItemWidth`
|
|
141
|
+
- `measurementMode`
|
|
142
|
+
- `expanded`
|
|
143
|
+
- `defaultExpanded`
|
|
144
|
+
- `onExpandedChange`
|
|
145
|
+
- `measureOverflowWidth`
|
|
146
|
+
|
|
147
|
+
#### Return value
|
|
148
|
+
|
|
149
|
+
| Field | Type | Description |
|
|
150
|
+
| --- | --- | --- |
|
|
151
|
+
| `containerRef` | `RefObject<HTMLDivElement \| null>` | Attach to the outer container whose width should be measured. |
|
|
152
|
+
| `registerItem(key)` | `(node) => void` | Attach to each visible item wrapper. |
|
|
153
|
+
| `registerMeasureItem(key)` | `(node) => void` | Attach to hidden measurement nodes for accurate width calculation. |
|
|
154
|
+
| `registerOverflow(node)` | `(node) => void` | Attach to the overflow element. |
|
|
155
|
+
| `visibleItems` | `T[]` | Items currently visible. |
|
|
156
|
+
| `hiddenItems` | `T[]` | Items currently hidden. |
|
|
157
|
+
| `hiddenCount` | `number` | Count of hidden items. |
|
|
158
|
+
| `isExpanded` | `boolean` | Whether the list is expanded. |
|
|
159
|
+
| `setExpanded` | `(expanded: boolean) => void` | Manually set expanded state. |
|
|
160
|
+
| `toggleExpanded` | `() => void` | Toggle expanded state. |
|
|
161
|
+
| `recompute` | `() => void` | Force a recalculation. |
|
|
162
|
+
|
|
163
|
+
## Examples
|
|
164
|
+
|
|
165
|
+
### Custom overflow label
|
|
166
|
+
|
|
167
|
+
```tsx
|
|
168
|
+
<FitList
|
|
169
|
+
items={items}
|
|
170
|
+
getKey={(item) => item.id}
|
|
171
|
+
renderItem={(item) => <Tag>{item.label}</Tag>}
|
|
172
|
+
renderOverflow={({ hiddenCount }) => (
|
|
173
|
+
<button type="button">+{hiddenCount}</button>
|
|
174
|
+
)}
|
|
175
|
+
/>
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
### Collapse from the start
|
|
179
|
+
|
|
180
|
+
Useful when the most recent or most important items are at the end.
|
|
181
|
+
|
|
182
|
+
```tsx
|
|
183
|
+
<FitList
|
|
184
|
+
items={items}
|
|
185
|
+
getKey={(item) => item.id}
|
|
186
|
+
renderItem={(item) => <Tag>{item.label}</Tag>}
|
|
187
|
+
collapseFrom="start"
|
|
188
|
+
/>
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
### Estimate mode
|
|
192
|
+
|
|
193
|
+
Estimate mode avoids relying on live measurement for every item and can be useful when item widths are predictable.
|
|
194
|
+
|
|
195
|
+
```tsx
|
|
196
|
+
<FitList
|
|
197
|
+
items={items}
|
|
198
|
+
getKey={(item) => item.id}
|
|
199
|
+
renderItem={(item) => <Tag>{item.label}</Tag>}
|
|
200
|
+
measurementMode="estimate"
|
|
201
|
+
estimatedItemWidth={(item) => Math.max(72, item.label.length * 8)}
|
|
202
|
+
/>
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
### Controlled expanded state
|
|
206
|
+
|
|
207
|
+
`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.
|
|
208
|
+
|
|
209
|
+
Use `expanded` / `onExpandedChange` only when you intentionally want to control expansion behavior.
|
|
210
|
+
|
|
211
|
+
```tsx
|
|
212
|
+
function ControlledExample() {
|
|
213
|
+
const [expanded, setExpanded] = useState(false)
|
|
214
|
+
|
|
215
|
+
return (
|
|
216
|
+
<FitList
|
|
217
|
+
items={items}
|
|
218
|
+
getKey={(item) => item.id}
|
|
219
|
+
renderItem={(item) => <Tag>{item.label}</Tag>}
|
|
220
|
+
expanded={expanded}
|
|
221
|
+
onExpandedChange={setExpanded}
|
|
222
|
+
/>
|
|
223
|
+
)
|
|
224
|
+
}
|
|
225
|
+
```
|
|
226
|
+
|
|
227
|
+
## Styling guidance
|
|
228
|
+
|
|
229
|
+
The package is intentionally headless. You control the appearance of the item contents.
|
|
230
|
+
|
|
231
|
+
For best results:
|
|
232
|
+
|
|
233
|
+
- keep each rendered item visually compact
|
|
234
|
+
- ensure item content does not wrap internally (`white-space: nowrap` is usually correct)
|
|
235
|
+
- use `measureClassName` when your measurement nodes need the same CSS as your visible nodes
|
|
236
|
+
- provide `overflowWidth` when you know the trigger width and want more predictable calculations
|
|
237
|
+
|
|
238
|
+
## Accessibility notes
|
|
239
|
+
|
|
240
|
+
- By default the overflow trigger renders as a `<button>`.
|
|
241
|
+
- When using a custom `renderOverflow`, make sure the resulting UI still communicates its action clearly.
|
|
242
|
+
- If you switch `overflowAs` away from `button`, you are responsible for semantics and interaction behavior.
|
|
243
|
+
|
|
244
|
+
## SSR / browser behavior
|
|
245
|
+
|
|
246
|
+
- the package is safe to render during SSR
|
|
247
|
+
- measurements are applied on the client
|
|
248
|
+
- `ResizeObserver` is used when available
|
|
249
|
+
- a `window.resize` listener is also attached as a fallback
|
|
250
|
+
|
|
251
|
+
## Local development
|
|
252
|
+
|
|
253
|
+
A Vite demo app is included in the `example/` directory. The demo uses a native resize handle instead of a slider so you can drag the container width directly.
|
|
254
|
+
|
|
255
|
+
```bash
|
|
256
|
+
cd example
|
|
257
|
+
npm install
|
|
258
|
+
npm run dev
|
|
259
|
+
```
|
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,435 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
var React = require('react');
|
|
4
|
+
var jsxRuntime = require('react/jsx-runtime');
|
|
5
|
+
|
|
6
|
+
function _interopNamespace(e) {
|
|
7
|
+
if (e && e.__esModule) return e;
|
|
8
|
+
var n = Object.create(null);
|
|
9
|
+
if (e) {
|
|
10
|
+
Object.keys(e).forEach(function (k) {
|
|
11
|
+
if (k !== 'default') {
|
|
12
|
+
var d = Object.getOwnPropertyDescriptor(e, k);
|
|
13
|
+
Object.defineProperty(n, k, d.get ? d : {
|
|
14
|
+
enumerable: true,
|
|
15
|
+
get: function () { return e[k]; }
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
n.default = e;
|
|
21
|
+
return Object.freeze(n);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
var React__namespace = /*#__PURE__*/_interopNamespace(React);
|
|
25
|
+
|
|
26
|
+
// src/components/FitList.tsx
|
|
27
|
+
function useControllableState({
|
|
28
|
+
value,
|
|
29
|
+
defaultValue,
|
|
30
|
+
onChange
|
|
31
|
+
}) {
|
|
32
|
+
const [internal, setInternal] = React.useState(defaultValue);
|
|
33
|
+
const isControlled = value !== void 0;
|
|
34
|
+
const current = isControlled ? value : internal;
|
|
35
|
+
const setValue = React.useCallback(
|
|
36
|
+
(next) => {
|
|
37
|
+
if (!isControlled) {
|
|
38
|
+
setInternal(next);
|
|
39
|
+
}
|
|
40
|
+
onChange?.(next);
|
|
41
|
+
},
|
|
42
|
+
[isControlled, onChange]
|
|
43
|
+
);
|
|
44
|
+
return [current, setValue];
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// src/hooks/useFitList.tsx
|
|
48
|
+
var useIsoLayoutEffect = typeof window !== "undefined" ? React.useLayoutEffect : React.useEffect;
|
|
49
|
+
function getEstimatedWidth(item, index, estimatedItemWidth, fallback) {
|
|
50
|
+
if (typeof estimatedItemWidth === "function")
|
|
51
|
+
return estimatedItemWidth(item, index);
|
|
52
|
+
if (typeof estimatedItemWidth === "number") return estimatedItemWidth;
|
|
53
|
+
return fallback;
|
|
54
|
+
}
|
|
55
|
+
function useFitList({
|
|
56
|
+
items,
|
|
57
|
+
getKey,
|
|
58
|
+
reserveOverflowSpace = false,
|
|
59
|
+
overflowWidth,
|
|
60
|
+
gap = 8,
|
|
61
|
+
collapseFrom = "end",
|
|
62
|
+
estimatedItemWidth,
|
|
63
|
+
measurementMode = "live",
|
|
64
|
+
expanded,
|
|
65
|
+
defaultExpanded = false,
|
|
66
|
+
onExpandedChange,
|
|
67
|
+
measureOverflowWidth
|
|
68
|
+
}) {
|
|
69
|
+
const containerRef = React.useRef(null);
|
|
70
|
+
const overflowRef = React.useRef(null);
|
|
71
|
+
const itemNodeMap = React.useRef(/* @__PURE__ */ new Map());
|
|
72
|
+
const measureNodeMap = React.useRef(/* @__PURE__ */ new Map());
|
|
73
|
+
const [visibleCount, setVisibleCount] = React.useState(items.length);
|
|
74
|
+
const [isExpanded, setExpanded] = useControllableState({
|
|
75
|
+
value: expanded,
|
|
76
|
+
defaultValue: defaultExpanded,
|
|
77
|
+
onChange: onExpandedChange
|
|
78
|
+
});
|
|
79
|
+
const compute = React.useCallback(() => {
|
|
80
|
+
if (isExpanded) {
|
|
81
|
+
setVisibleCount(items.length);
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
const container = containerRef.current;
|
|
85
|
+
if (!container) {
|
|
86
|
+
setVisibleCount(items.length);
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
const containerWidth = container.clientWidth;
|
|
90
|
+
if (!containerWidth) {
|
|
91
|
+
setVisibleCount(items.length);
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
const keys = items.map(getKey);
|
|
95
|
+
const itemWidths = items.map((item, index) => {
|
|
96
|
+
const key = keys[index];
|
|
97
|
+
const measureNode = measureNodeMap.current.get(key);
|
|
98
|
+
const liveNode = itemNodeMap.current.get(key);
|
|
99
|
+
if (measurementMode === "live") {
|
|
100
|
+
if (measureNode) return measureNode.offsetWidth;
|
|
101
|
+
if (liveNode) return liveNode.offsetWidth;
|
|
102
|
+
}
|
|
103
|
+
return getEstimatedWidth(item, index, estimatedItemWidth, 96);
|
|
104
|
+
});
|
|
105
|
+
let nextVisible = items.length;
|
|
106
|
+
for (let count = items.length; count >= 0; count -= 1) {
|
|
107
|
+
const hiddenCount = items.length - count;
|
|
108
|
+
const visibleWidths = collapseFrom === "end" ? itemWidths.slice(0, count) : itemWidths.slice(items.length - count);
|
|
109
|
+
const itemsWidth = visibleWidths.reduce((sum, width) => sum + width, 0);
|
|
110
|
+
const itemsGap = count > 1 ? gap * (count - 1) : 0;
|
|
111
|
+
let currentOverflowWidth = 0;
|
|
112
|
+
if (hiddenCount > 0) {
|
|
113
|
+
if (typeof overflowWidth === "number") {
|
|
114
|
+
currentOverflowWidth = overflowWidth;
|
|
115
|
+
} else if (measureOverflowWidth) {
|
|
116
|
+
currentOverflowWidth = measureOverflowWidth(hiddenCount);
|
|
117
|
+
} else {
|
|
118
|
+
currentOverflowWidth = overflowRef.current?.offsetWidth ?? 44;
|
|
119
|
+
}
|
|
120
|
+
} else if (reserveOverflowSpace) {
|
|
121
|
+
if (typeof overflowWidth === "number") {
|
|
122
|
+
currentOverflowWidth = overflowWidth;
|
|
123
|
+
} else {
|
|
124
|
+
currentOverflowWidth = overflowRef.current?.offsetWidth ?? 44;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
const overflowGap = (hiddenCount > 0 || reserveOverflowSpace) && count > 0 ? gap : 0;
|
|
128
|
+
const total = itemsWidth + itemsGap + overflowGap + currentOverflowWidth;
|
|
129
|
+
if (total <= containerWidth) {
|
|
130
|
+
nextVisible = count;
|
|
131
|
+
break;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
setVisibleCount((prev) => prev === nextVisible ? prev : nextVisible);
|
|
135
|
+
}, [
|
|
136
|
+
collapseFrom,
|
|
137
|
+
estimatedItemWidth,
|
|
138
|
+
gap,
|
|
139
|
+
getKey,
|
|
140
|
+
isExpanded,
|
|
141
|
+
items,
|
|
142
|
+
measurementMode,
|
|
143
|
+
measureOverflowWidth,
|
|
144
|
+
overflowWidth,
|
|
145
|
+
reserveOverflowSpace
|
|
146
|
+
]);
|
|
147
|
+
useIsoLayoutEffect(() => {
|
|
148
|
+
compute();
|
|
149
|
+
}, [compute]);
|
|
150
|
+
useIsoLayoutEffect(() => {
|
|
151
|
+
const container = containerRef.current;
|
|
152
|
+
if (!container || typeof ResizeObserver === "undefined") return;
|
|
153
|
+
const observer = new ResizeObserver(() => {
|
|
154
|
+
requestAnimationFrame(compute);
|
|
155
|
+
});
|
|
156
|
+
observer.observe(container);
|
|
157
|
+
return () => observer.disconnect();
|
|
158
|
+
}, [compute]);
|
|
159
|
+
React.useEffect(() => {
|
|
160
|
+
if (typeof window === "undefined") return;
|
|
161
|
+
const onResize = () => compute();
|
|
162
|
+
window.addEventListener("resize", onResize);
|
|
163
|
+
return () => window.removeEventListener("resize", onResize);
|
|
164
|
+
}, [compute]);
|
|
165
|
+
const registerItem = React.useCallback(
|
|
166
|
+
(key) => (node) => {
|
|
167
|
+
if (node) {
|
|
168
|
+
itemNodeMap.current.set(key, node);
|
|
169
|
+
} else {
|
|
170
|
+
itemNodeMap.current.delete(key);
|
|
171
|
+
}
|
|
172
|
+
},
|
|
173
|
+
[]
|
|
174
|
+
);
|
|
175
|
+
const registerMeasureItem = React.useCallback(
|
|
176
|
+
(key) => (node) => {
|
|
177
|
+
if (node) {
|
|
178
|
+
measureNodeMap.current.set(key, node);
|
|
179
|
+
} else {
|
|
180
|
+
measureNodeMap.current.delete(key);
|
|
181
|
+
}
|
|
182
|
+
},
|
|
183
|
+
[]
|
|
184
|
+
);
|
|
185
|
+
const registerOverflow = React.useCallback((node) => {
|
|
186
|
+
overflowRef.current = node;
|
|
187
|
+
}, []);
|
|
188
|
+
const clampedVisibleCount = Math.max(0, Math.min(visibleCount, items.length));
|
|
189
|
+
const visibleItems = React.useMemo(() => {
|
|
190
|
+
if (isExpanded) return [...items];
|
|
191
|
+
if (collapseFrom === "end") return items.slice(0, clampedVisibleCount);
|
|
192
|
+
return items.slice(items.length - clampedVisibleCount);
|
|
193
|
+
}, [clampedVisibleCount, collapseFrom, isExpanded, items]);
|
|
194
|
+
const hiddenItems = React.useMemo(() => {
|
|
195
|
+
if (isExpanded) return [];
|
|
196
|
+
if (collapseFrom === "end") return items.slice(clampedVisibleCount);
|
|
197
|
+
return items.slice(0, items.length - clampedVisibleCount);
|
|
198
|
+
}, [clampedVisibleCount, collapseFrom, isExpanded, items]);
|
|
199
|
+
const toggleExpanded = React.useCallback(() => {
|
|
200
|
+
setExpanded(!isExpanded);
|
|
201
|
+
}, [isExpanded, setExpanded]);
|
|
202
|
+
return {
|
|
203
|
+
containerRef,
|
|
204
|
+
registerItem,
|
|
205
|
+
registerMeasureItem,
|
|
206
|
+
registerOverflow,
|
|
207
|
+
visibleItems,
|
|
208
|
+
hiddenItems,
|
|
209
|
+
hiddenCount: hiddenItems.length,
|
|
210
|
+
isExpanded,
|
|
211
|
+
setExpanded,
|
|
212
|
+
toggleExpanded,
|
|
213
|
+
recompute: compute
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
function defaultOverflow({ hiddenCount }) {
|
|
217
|
+
return /* @__PURE__ */ jsxRuntime.jsxs("span", { children: [
|
|
218
|
+
"+",
|
|
219
|
+
hiddenCount
|
|
220
|
+
] });
|
|
221
|
+
}
|
|
222
|
+
function FitList({
|
|
223
|
+
items,
|
|
224
|
+
getKey,
|
|
225
|
+
renderItem,
|
|
226
|
+
renderOverflow = defaultOverflow,
|
|
227
|
+
className,
|
|
228
|
+
listClassName,
|
|
229
|
+
itemClassName,
|
|
230
|
+
overflowClassName,
|
|
231
|
+
measureClassName,
|
|
232
|
+
emptyFallback = null,
|
|
233
|
+
gap = 8,
|
|
234
|
+
collapseFrom = "end",
|
|
235
|
+
reserveOverflowSpace = false,
|
|
236
|
+
overflowWidth,
|
|
237
|
+
estimatedItemWidth,
|
|
238
|
+
measurementMode = "live",
|
|
239
|
+
expanded,
|
|
240
|
+
defaultExpanded = false,
|
|
241
|
+
onExpandedChange,
|
|
242
|
+
as = "div",
|
|
243
|
+
onOverflowClick,
|
|
244
|
+
overflowAs = "button"
|
|
245
|
+
}) {
|
|
246
|
+
const Component = as;
|
|
247
|
+
const OverflowComponent = overflowAs;
|
|
248
|
+
const overflowMeasureRef = React__namespace.useRef(null);
|
|
249
|
+
const isDefaultOverflowRenderer = renderOverflow === defaultOverflow;
|
|
250
|
+
const measureOverflowWidth = React__namespace.useCallback(
|
|
251
|
+
(hiddenCount2) => {
|
|
252
|
+
if (typeof overflowWidth === "number") return overflowWidth;
|
|
253
|
+
const node = overflowMeasureRef.current;
|
|
254
|
+
if (!node) return 44;
|
|
255
|
+
if (isDefaultOverflowRenderer) {
|
|
256
|
+
const previous = node.textContent;
|
|
257
|
+
node.textContent = `+${hiddenCount2}`;
|
|
258
|
+
const width = node.offsetWidth;
|
|
259
|
+
node.textContent = previous;
|
|
260
|
+
return width;
|
|
261
|
+
}
|
|
262
|
+
return node.offsetWidth;
|
|
263
|
+
},
|
|
264
|
+
[isDefaultOverflowRenderer, overflowWidth]
|
|
265
|
+
);
|
|
266
|
+
const {
|
|
267
|
+
containerRef,
|
|
268
|
+
registerItem,
|
|
269
|
+
registerMeasureItem,
|
|
270
|
+
registerOverflow,
|
|
271
|
+
visibleItems,
|
|
272
|
+
hiddenItems,
|
|
273
|
+
hiddenCount,
|
|
274
|
+
isExpanded,
|
|
275
|
+
setExpanded,
|
|
276
|
+
toggleExpanded
|
|
277
|
+
} = useFitList({
|
|
278
|
+
items,
|
|
279
|
+
getKey,
|
|
280
|
+
gap,
|
|
281
|
+
collapseFrom,
|
|
282
|
+
reserveOverflowSpace,
|
|
283
|
+
overflowWidth,
|
|
284
|
+
estimatedItemWidth,
|
|
285
|
+
measurementMode,
|
|
286
|
+
expanded,
|
|
287
|
+
defaultExpanded,
|
|
288
|
+
onExpandedChange,
|
|
289
|
+
measureOverflowWidth
|
|
290
|
+
});
|
|
291
|
+
const visibleEntries = React__namespace.useMemo(() => {
|
|
292
|
+
if (isExpanded) {
|
|
293
|
+
return items.map((item, index) => ({ item, index }));
|
|
294
|
+
}
|
|
295
|
+
if (collapseFrom === "end") {
|
|
296
|
+
return items.slice(0, visibleItems.length).map((item, index) => ({ item, index }));
|
|
297
|
+
}
|
|
298
|
+
const startIndex = items.length - visibleItems.length;
|
|
299
|
+
return items.slice(startIndex).map((item, index) => ({ item, index: startIndex + index }));
|
|
300
|
+
}, [collapseFrom, isExpanded, items, visibleItems.length]);
|
|
301
|
+
if (items.length === 0) {
|
|
302
|
+
return /* @__PURE__ */ jsxRuntime.jsx(jsxRuntime.Fragment, { children: emptyFallback });
|
|
303
|
+
}
|
|
304
|
+
const overflowArgs = {
|
|
305
|
+
hiddenCount,
|
|
306
|
+
hiddenItems: [...hiddenItems],
|
|
307
|
+
visibleItems: [...visibleItems],
|
|
308
|
+
isExpanded,
|
|
309
|
+
setExpanded,
|
|
310
|
+
toggle: toggleExpanded
|
|
311
|
+
};
|
|
312
|
+
const overflowChildren = renderOverflow(overflowArgs);
|
|
313
|
+
const overflowButtonProps = {
|
|
314
|
+
className: overflowClassName,
|
|
315
|
+
type: "button",
|
|
316
|
+
onClick: (event) => onOverflowClick?.(overflowArgs, event),
|
|
317
|
+
"aria-expanded": isExpanded,
|
|
318
|
+
children: overflowChildren
|
|
319
|
+
};
|
|
320
|
+
const content = /* @__PURE__ */ jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [
|
|
321
|
+
/* @__PURE__ */ jsxRuntime.jsx(
|
|
322
|
+
"div",
|
|
323
|
+
{
|
|
324
|
+
className: listClassName,
|
|
325
|
+
style: {
|
|
326
|
+
display: "flex",
|
|
327
|
+
alignItems: "center",
|
|
328
|
+
gap,
|
|
329
|
+
minWidth: 0,
|
|
330
|
+
flex: "1 1 auto",
|
|
331
|
+
overflow: "hidden"
|
|
332
|
+
},
|
|
333
|
+
children: visibleEntries.map(({ item, index }) => {
|
|
334
|
+
const key = getKey(item, index);
|
|
335
|
+
return /* @__PURE__ */ jsxRuntime.jsx(
|
|
336
|
+
"div",
|
|
337
|
+
{
|
|
338
|
+
ref: registerItem(key),
|
|
339
|
+
className: itemClassName,
|
|
340
|
+
style: {
|
|
341
|
+
minWidth: 0,
|
|
342
|
+
flex: "0 0 auto",
|
|
343
|
+
whiteSpace: "nowrap"
|
|
344
|
+
},
|
|
345
|
+
children: renderItem(item, index)
|
|
346
|
+
},
|
|
347
|
+
key
|
|
348
|
+
);
|
|
349
|
+
})
|
|
350
|
+
}
|
|
351
|
+
),
|
|
352
|
+
(hiddenCount > 0 || reserveOverflowSpace) && /* @__PURE__ */ jsxRuntime.jsx(
|
|
353
|
+
"div",
|
|
354
|
+
{
|
|
355
|
+
ref: registerOverflow,
|
|
356
|
+
style: {
|
|
357
|
+
visibility: hiddenCount > 0 ? "visible" : "hidden",
|
|
358
|
+
flex: "0 0 auto",
|
|
359
|
+
whiteSpace: "nowrap",
|
|
360
|
+
display: "block"
|
|
361
|
+
},
|
|
362
|
+
children: hiddenCount > 0 ? overflowAs === "button" ? /* @__PURE__ */ jsxRuntime.jsx("button", { ...overflowButtonProps }) : React__namespace.createElement(
|
|
363
|
+
OverflowComponent,
|
|
364
|
+
{ className: overflowClassName },
|
|
365
|
+
overflowChildren
|
|
366
|
+
) : /* @__PURE__ */ jsxRuntime.jsx("span", { "aria-hidden": "true", children: "+0" })
|
|
367
|
+
}
|
|
368
|
+
)
|
|
369
|
+
] });
|
|
370
|
+
const root = React__namespace.createElement(
|
|
371
|
+
Component,
|
|
372
|
+
{
|
|
373
|
+
ref: containerRef,
|
|
374
|
+
className,
|
|
375
|
+
style: {
|
|
376
|
+
display: "flex",
|
|
377
|
+
alignItems: "center",
|
|
378
|
+
gap,
|
|
379
|
+
minWidth: 0,
|
|
380
|
+
whiteSpace: "nowrap"
|
|
381
|
+
}
|
|
382
|
+
},
|
|
383
|
+
content
|
|
384
|
+
);
|
|
385
|
+
return /* @__PURE__ */ jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [
|
|
386
|
+
root,
|
|
387
|
+
/* @__PURE__ */ jsxRuntime.jsx(
|
|
388
|
+
"div",
|
|
389
|
+
{
|
|
390
|
+
"aria-hidden": "true",
|
|
391
|
+
style: {
|
|
392
|
+
pointerEvents: "none",
|
|
393
|
+
position: "fixed",
|
|
394
|
+
top: 0,
|
|
395
|
+
left: 0,
|
|
396
|
+
zIndex: -1,
|
|
397
|
+
overflow: "hidden",
|
|
398
|
+
opacity: 0
|
|
399
|
+
},
|
|
400
|
+
children: /* @__PURE__ */ jsxRuntime.jsxs("div", { style: { display: "flex", alignItems: "center", gap }, children: [
|
|
401
|
+
items.map((item, index) => {
|
|
402
|
+
const key = getKey(item, index);
|
|
403
|
+
return /* @__PURE__ */ jsxRuntime.jsx(
|
|
404
|
+
"span",
|
|
405
|
+
{
|
|
406
|
+
ref: registerMeasureItem(key),
|
|
407
|
+
className: measureClassName ?? itemClassName,
|
|
408
|
+
style: {
|
|
409
|
+
display: "inline-flex",
|
|
410
|
+
whiteSpace: "nowrap"
|
|
411
|
+
},
|
|
412
|
+
children: renderItem(item, index)
|
|
413
|
+
},
|
|
414
|
+
`measure:${String(key)}`
|
|
415
|
+
);
|
|
416
|
+
}),
|
|
417
|
+
/* @__PURE__ */ jsxRuntime.jsx(
|
|
418
|
+
"span",
|
|
419
|
+
{
|
|
420
|
+
ref: overflowMeasureRef,
|
|
421
|
+
className: overflowClassName,
|
|
422
|
+
style: { display: "inline-flex", whiteSpace: "nowrap" },
|
|
423
|
+
children: overflowChildren
|
|
424
|
+
}
|
|
425
|
+
)
|
|
426
|
+
] })
|
|
427
|
+
}
|
|
428
|
+
)
|
|
429
|
+
] });
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
exports.FitList = FitList;
|
|
433
|
+
exports.useFitList = useFitList;
|
|
434
|
+
//# sourceMappingURL=index.cjs.map
|
|
435
|
+
//# sourceMappingURL=index.cjs.map
|