overflow-guard-react 0.0.1
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 +354 -0
- package/dist/index.cjs +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +124 -0
- package/dist/overflow-guard.d.ts +23 -0
- package/dist/utils.d.ts +6 -0
- package/package.json +81 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Artur Marczyk
|
|
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,354 @@
|
|
|
1
|
+
<p align="center">
|
|
2
|
+
<img src="https://raw.githubusercontent.com/arturmarc/overflow-guard/main/docs/assets/overflow-guard-logo.svg" alt="Overflow Guard logo" width="88" />
|
|
3
|
+
</p>
|
|
4
|
+
|
|
5
|
+
# overflow-guard-react
|
|
6
|
+
|
|
7
|
+

|
|
8
|
+
|
|
9
|
+
`overflow-guard-react` helps React UI adapt when content stops fitting, instead of relying on viewport breakpoints, container breakpoints, or magic numbers.
|
|
10
|
+
|
|
11
|
+
Repository: <https://github.com/arturmarc/overflow-guard>
|
|
12
|
+
Website: <https://overflow-guard.vercel.app/>
|
|
13
|
+
|
|
14
|
+
Wrap a piece of UI in `<OverflowGuard>`, and it tells you when content no longer fits the available space. You can then switch to a compact layout, collapse actions to icons, swap a full nav for a menu, or reveal a "Read more" affordance.
|
|
15
|
+
|
|
16
|
+
## Why use it
|
|
17
|
+
|
|
18
|
+
- Build around content, not guessed pixel values
|
|
19
|
+
- Handle dynamic labels, localization, and data-driven content
|
|
20
|
+
- Reuse the same component across narrow sidebars, wide panels, and resizable layouts
|
|
21
|
+
- Respond to horizontal overflow, vertical overflow, or both
|
|
22
|
+
|
|
23
|
+
## Installation
|
|
24
|
+
|
|
25
|
+
```sh
|
|
26
|
+
bun add overflow-guard-react
|
|
27
|
+
npm install overflow-guard-react
|
|
28
|
+
pnpm add overflow-guard-react
|
|
29
|
+
yarn add overflow-guard-react
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
Peer dependencies:
|
|
33
|
+
|
|
34
|
+
- `react`
|
|
35
|
+
- `react-dom`
|
|
36
|
+
|
|
37
|
+
`overflow-guard-react` is a client-side package. It measures rendered DOM with `ResizeObserver`, so use it from client components in frameworks that distinguish server and client rendering.
|
|
38
|
+
|
|
39
|
+
## Quick start
|
|
40
|
+
|
|
41
|
+
```tsx
|
|
42
|
+
import { OverflowGuard } from 'overflow-guard-react'
|
|
43
|
+
|
|
44
|
+
export function AdaptiveToolbar() {
|
|
45
|
+
return (
|
|
46
|
+
<OverflowGuard>
|
|
47
|
+
{(isOverflowing) => (
|
|
48
|
+
<div className={isOverflowing ? 'flex flex-wrap gap-2' : 'flex gap-2'}>
|
|
49
|
+
<button>Search</button>
|
|
50
|
+
<button>Share update</button>
|
|
51
|
+
<button>Launch flow</button>
|
|
52
|
+
</div>
|
|
53
|
+
)}
|
|
54
|
+
</OverflowGuard>
|
|
55
|
+
)
|
|
56
|
+
}
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
## How it works
|
|
60
|
+
|
|
61
|
+
`OverflowGuard` renders your content into a hidden measurement layer and compares its `scrollWidth` and `scrollHeight` against the available `clientWidth` and `clientHeight`.
|
|
62
|
+
|
|
63
|
+
At runtime it exposes:
|
|
64
|
+
|
|
65
|
+
- `isOverflowing`: `true` when content overflows on any axis
|
|
66
|
+
- `overflowAxis`: `'none' | 'horizontal' | 'vertical' | 'both'`
|
|
67
|
+
|
|
68
|
+
## Usage patterns
|
|
69
|
+
|
|
70
|
+
### 1. Fallback mode
|
|
71
|
+
|
|
72
|
+
Use fallback mode when the compact state should be a different tree entirely.
|
|
73
|
+
|
|
74
|
+
```tsx
|
|
75
|
+
import { OverflowGuard } from 'overflow-guard-react'
|
|
76
|
+
import { Menu } from 'lucide-react'
|
|
77
|
+
|
|
78
|
+
function Brand() {
|
|
79
|
+
return <div className="font-semibold">OverflowGuard</div>
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function ResponsiveNav() {
|
|
83
|
+
return (
|
|
84
|
+
<OverflowGuard
|
|
85
|
+
fallbackOn="horizontal"
|
|
86
|
+
fallback={
|
|
87
|
+
<nav className="flex items-center justify-between rounded-3xl border px-4 py-3">
|
|
88
|
+
<Brand />
|
|
89
|
+
<button aria-label="Open menu">
|
|
90
|
+
<Menu />
|
|
91
|
+
</button>
|
|
92
|
+
</nav>
|
|
93
|
+
}
|
|
94
|
+
>
|
|
95
|
+
<nav className="flex min-w-max items-center justify-between gap-3 rounded-3xl border px-4 py-3">
|
|
96
|
+
<Brand />
|
|
97
|
+
<div className="flex flex-nowrap gap-2">
|
|
98
|
+
<a href="/docs">Docs</a>
|
|
99
|
+
<a href="/recipes">Recipes</a>
|
|
100
|
+
<a href="/playground">Playground</a>
|
|
101
|
+
<a href="/pricing">Pricing</a>
|
|
102
|
+
</div>
|
|
103
|
+
</nav>
|
|
104
|
+
</OverflowGuard>
|
|
105
|
+
)
|
|
106
|
+
}
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
### 2. Render prop mode
|
|
110
|
+
|
|
111
|
+
Use the render prop when both states share most of the same structure and only behavior or presentation changes.
|
|
112
|
+
|
|
113
|
+
```tsx
|
|
114
|
+
import { OverflowGuard } from 'overflow-guard-react'
|
|
115
|
+
|
|
116
|
+
function ActionButtons() {
|
|
117
|
+
return (
|
|
118
|
+
<>
|
|
119
|
+
<button>Search</button>
|
|
120
|
+
<button>Export brief</button>
|
|
121
|
+
<button>Share update</button>
|
|
122
|
+
<button>Launch flow</button>
|
|
123
|
+
</>
|
|
124
|
+
)
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function ActionIcons() {
|
|
128
|
+
return (
|
|
129
|
+
<>
|
|
130
|
+
<button aria-label="Search">S</button>
|
|
131
|
+
<button aria-label="Export brief">E</button>
|
|
132
|
+
<button aria-label="Share update">U</button>
|
|
133
|
+
<button aria-label="Launch flow">L</button>
|
|
134
|
+
</>
|
|
135
|
+
)
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export function ProjectActions() {
|
|
139
|
+
return (
|
|
140
|
+
<OverflowGuard>
|
|
141
|
+
{(isOverflowing, overflowAxis) => (
|
|
142
|
+
<section className="flex flex-col gap-3">
|
|
143
|
+
<div className="text-sm text-slate-500">overflow: {overflowAxis}</div>
|
|
144
|
+
<div className={isOverflowing ? 'flex flex-wrap gap-2' : 'flex gap-2'}>
|
|
145
|
+
<div className="min-w-max rounded-xl border px-3 py-2">
|
|
146
|
+
Sprint planning
|
|
147
|
+
</div>
|
|
148
|
+
<div className="flex gap-2">
|
|
149
|
+
{isOverflowing ? <ActionIcons /> : <ActionButtons />}
|
|
150
|
+
</div>
|
|
151
|
+
</div>
|
|
152
|
+
</section>
|
|
153
|
+
)}
|
|
154
|
+
</OverflowGuard>
|
|
155
|
+
)
|
|
156
|
+
}
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
### 3. Custom hook mode
|
|
160
|
+
|
|
161
|
+
Use `useOverflowGuard()` when nested children need access to the overflow state without threading props through every layer.
|
|
162
|
+
|
|
163
|
+
The hook only returns the boolean state. If you need axis details, keep using the render prop at the boundary.
|
|
164
|
+
|
|
165
|
+
```tsx
|
|
166
|
+
import {
|
|
167
|
+
OverflowGuard,
|
|
168
|
+
useOverflowGuard,
|
|
169
|
+
} from 'overflow-guard-react'
|
|
170
|
+
|
|
171
|
+
function ToolbarSummary() {
|
|
172
|
+
const isOverflowing = useOverflowGuard()
|
|
173
|
+
|
|
174
|
+
return (
|
|
175
|
+
<span className="text-sm">
|
|
176
|
+
{isOverflowing ? 'Compact mode' : 'Expanded mode'}
|
|
177
|
+
</span>
|
|
178
|
+
)
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function ToolbarActions() {
|
|
182
|
+
const isOverflowing = useOverflowGuard()
|
|
183
|
+
|
|
184
|
+
return isOverflowing ? (
|
|
185
|
+
<>
|
|
186
|
+
<button aria-label="Overview">O</button>
|
|
187
|
+
<button aria-label="Team">T</button>
|
|
188
|
+
<button aria-label="Docs">D</button>
|
|
189
|
+
<button aria-label="Support">S</button>
|
|
190
|
+
</>
|
|
191
|
+
) : (
|
|
192
|
+
<>
|
|
193
|
+
<button>Overview</button>
|
|
194
|
+
<button>Team updates</button>
|
|
195
|
+
<button>Docs space</button>
|
|
196
|
+
<button>Support</button>
|
|
197
|
+
</>
|
|
198
|
+
)
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
export function NestedExample() {
|
|
202
|
+
return (
|
|
203
|
+
<OverflowGuard className="w-full">
|
|
204
|
+
{() => (
|
|
205
|
+
<div className="flex flex-col gap-3">
|
|
206
|
+
<ToolbarSummary />
|
|
207
|
+
<div className="flex min-w-0 gap-2">
|
|
208
|
+
<ToolbarActions />
|
|
209
|
+
</div>
|
|
210
|
+
</div>
|
|
211
|
+
)}
|
|
212
|
+
</OverflowGuard>
|
|
213
|
+
)
|
|
214
|
+
}
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
### 4. Vertical overflow example
|
|
218
|
+
|
|
219
|
+
Overflow handling is not limited to width. You can react to height constraints too.
|
|
220
|
+
|
|
221
|
+
```tsx
|
|
222
|
+
import { OverflowGuard } from 'overflow-guard-react'
|
|
223
|
+
|
|
224
|
+
export function ReadMoreCard() {
|
|
225
|
+
return (
|
|
226
|
+
<OverflowGuard className="h-full" containerClassName="h-64">
|
|
227
|
+
{(isOverflowing, overflowAxis) => {
|
|
228
|
+
const showReadMore =
|
|
229
|
+
overflowAxis === 'vertical' || overflowAxis === 'both'
|
|
230
|
+
|
|
231
|
+
return (
|
|
232
|
+
<article className="flex h-full flex-col rounded-3xl border p-5">
|
|
233
|
+
<div className="mb-3">
|
|
234
|
+
<h2 className="font-semibold">Release notes draft</h2>
|
|
235
|
+
</div>
|
|
236
|
+
<p className={showReadMore ? 'max-h-24 overflow-hidden' : undefined}>
|
|
237
|
+
OverflowGuard can reveal a call to action when the content exceeds
|
|
238
|
+
the available height instead of letting the card blow up the
|
|
239
|
+
surrounding layout.
|
|
240
|
+
</p>
|
|
241
|
+
<div className="mt-4">
|
|
242
|
+
{showReadMore ? <button>Read more</button> : null}
|
|
243
|
+
</div>
|
|
244
|
+
</article>
|
|
245
|
+
)
|
|
246
|
+
}}
|
|
247
|
+
</OverflowGuard>
|
|
248
|
+
)
|
|
249
|
+
}
|
|
250
|
+
```
|
|
251
|
+
|
|
252
|
+
## API
|
|
253
|
+
|
|
254
|
+
### `OverflowGuard`
|
|
255
|
+
|
|
256
|
+
`OverflowGuard` supports two mutually exclusive modes:
|
|
257
|
+
|
|
258
|
+
- fallback mode: pass regular `children` plus `fallback`
|
|
259
|
+
- render prop mode: pass a function as `children`
|
|
260
|
+
|
|
261
|
+
### Common props
|
|
262
|
+
|
|
263
|
+
These props are available in both modes.
|
|
264
|
+
|
|
265
|
+
| Prop | Type | Default | Description |
|
|
266
|
+
| --- | --- | --- | --- |
|
|
267
|
+
| `children` | `ReactNode` or `(isOverflowing, overflowAxis) => ReactNode` | - | Regular children in fallback mode, or a render function in render-prop mode. |
|
|
268
|
+
| `className` | `string` | - | Applied to the inner measured and visible content box. |
|
|
269
|
+
| `style` | `CSSProperties` | - | Applied to the inner measured and visible content box. |
|
|
270
|
+
| `containerClassName` | `string` | - | Applied to the outer wrapper that hosts the measurement and visible layers. |
|
|
271
|
+
| `containerStyle` | `CSSProperties` | - | Applied to the outer wrapper. The component also sets `display: grid` and `position: relative`. |
|
|
272
|
+
| `throttleTime` | `number` | `0` | Debounce-like delay, in milliseconds, before re-running overflow checks after resize observer updates. |
|
|
273
|
+
| `...divProps` | `HTMLAttributes<HTMLDivElement>` | - | Standard div props such as `id`, `role`, `data-*`, and `aria-*`. These are forwarded to the inner content box. |
|
|
274
|
+
|
|
275
|
+
### Fallback mode props
|
|
276
|
+
|
|
277
|
+
| Prop | Type | Default | Description |
|
|
278
|
+
| --- | --- | --- | --- |
|
|
279
|
+
| `fallback` | `ReactNode` | required | Rendered when overflow matches `fallbackOn`. |
|
|
280
|
+
| `fallbackOn` | `'horizontal' \| 'vertical' \| 'both'` | `'both'` | Controls which overflow axis activates the fallback. `'both'` means any overflow triggers it. |
|
|
281
|
+
|
|
282
|
+
Example:
|
|
283
|
+
|
|
284
|
+
```tsx
|
|
285
|
+
<OverflowGuard fallback={<CompactNav />} fallbackOn="horizontal">
|
|
286
|
+
<FullNav />
|
|
287
|
+
</OverflowGuard>
|
|
288
|
+
```
|
|
289
|
+
|
|
290
|
+
### Render prop signature
|
|
291
|
+
|
|
292
|
+
```ts
|
|
293
|
+
(isOverflowing: boolean, overflowAxis: OverflowAxis) => ReactNode
|
|
294
|
+
```
|
|
295
|
+
|
|
296
|
+
Parameters:
|
|
297
|
+
|
|
298
|
+
| Parameter | Type | Description |
|
|
299
|
+
| --- | --- | --- |
|
|
300
|
+
| `isOverflowing` | `boolean` | `true` when content overflows on any axis. |
|
|
301
|
+
| `overflowAxis` | `'none' \| 'horizontal' \| 'vertical' \| 'both'` | The measured overflow direction. |
|
|
302
|
+
|
|
303
|
+
Example:
|
|
304
|
+
|
|
305
|
+
```tsx
|
|
306
|
+
<OverflowGuard>
|
|
307
|
+
{(isOverflowing, overflowAxis) => (
|
|
308
|
+
<div data-overflow-axis={overflowAxis}>
|
|
309
|
+
{isOverflowing ? <CompactLayout /> : <FullLayout />}
|
|
310
|
+
</div>
|
|
311
|
+
)}
|
|
312
|
+
</OverflowGuard>
|
|
313
|
+
```
|
|
314
|
+
|
|
315
|
+
### `useOverflowGuard()`
|
|
316
|
+
|
|
317
|
+
```ts
|
|
318
|
+
const isOverflowing = useOverflowGuard()
|
|
319
|
+
```
|
|
320
|
+
|
|
321
|
+
Returns the nearest `OverflowGuard` boolean overflow state.
|
|
322
|
+
|
|
323
|
+
Use it inside descendants rendered by `OverflowGuard` when you want nested components to react to the current compact or expanded state.
|
|
324
|
+
|
|
325
|
+
## Exported types
|
|
326
|
+
|
|
327
|
+
```ts
|
|
328
|
+
import type {
|
|
329
|
+
FallbackOn,
|
|
330
|
+
OverflowAxis,
|
|
331
|
+
OverflowGuardProps,
|
|
332
|
+
} from 'overflow-guard-react'
|
|
333
|
+
```
|
|
334
|
+
|
|
335
|
+
## Notes and gotchas
|
|
336
|
+
|
|
337
|
+
- `fallback` and render-prop `children` are mutually exclusive.
|
|
338
|
+
- `fallbackOn` can only be used together with `fallback`.
|
|
339
|
+
- The hook returns only `boolean`. It does not expose the overflow axis.
|
|
340
|
+
- `className` and `style` apply to the measured content box, so layout-affecting styles should usually live there.
|
|
341
|
+
- The outer container always uses `display: grid` and `position: relative` so the visible layer can stack on top of the hidden measurement layer.
|
|
342
|
+
- Overflow detection uses a small `+1` tolerance to avoid noisy flips from sub-pixel measurement differences.
|
|
343
|
+
- The component includes loop protection for layouts that oscillate endlessly between states. In that case it locks into an overflowing state and warns in the console.
|
|
344
|
+
|
|
345
|
+
## When to choose this over CSS queries
|
|
346
|
+
|
|
347
|
+
Use `overflow-guard-react` when the decision depends on whether the rendered content actually fits:
|
|
348
|
+
|
|
349
|
+
- action bars that collapse only when labels stop fitting
|
|
350
|
+
- navigation that turns into a menu based on content length
|
|
351
|
+
- translated UI where text length changes by locale
|
|
352
|
+
- cards that reveal "Read more" only when body copy exceeds available height
|
|
353
|
+
|
|
354
|
+
Use media or container queries when you already know the rule should be based on viewport or container size alone.
|
package/dist/index.cjs
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"use client";Object.defineProperty(exports,Symbol.toStringTag,{value:`Module`});var e=Object.create,t=Object.defineProperty,n=Object.getOwnPropertyDescriptor,r=Object.getOwnPropertyNames,i=Object.getPrototypeOf,a=Object.prototype.hasOwnProperty,o=(e,i,o,s)=>{if(i&&typeof i==`object`||typeof i==`function`)for(var c=r(i),l=0,u=c.length,d;l<u;l++)d=c[l],!a.call(e,d)&&d!==o&&t(e,d,{get:(e=>i[e]).bind(null,d),enumerable:!(s=n(i,d))||s.enumerable});return e},s=(n,r,a)=>(a=n==null?{}:e(i(n)),o(r||!n||!n.__esModule?t(a,`default`,{value:n,enumerable:!0}):a,n));let c=require(`react`),l=require(`use-resize-observer`);l=s(l);let u=require(`react/jsx-runtime`);function d(e,t){return e&&t?`both`:e?`horizontal`:t?`vertical`:`none`}function f(e,t){return t===`none`?!1:e===`both`?!0:t===e||t===`both`}function p(e,t){let n=null;return(...r)=>{n&&clearTimeout(n),n=setTimeout(()=>{e(...r)},t)}}var m=0,h=300,g=7,_=(0,c.createContext)({isOverflowing:!1,overflowAxis:`none`});function v(){return(0,c.useContext)(_).isOverflowing}function y(e){let{children:t,className:n,containerClassName:r,containerStyle:i,style:a,throttleTime:o=m}=e,s=(0,c.useRef)(null),[v,y]=(0,c.useState)(`none`),[x,S]=(0,c.useState)(!1),C=b(e),w=(0,c.useCallback)(()=>{let e=s.current;if(!e)return;let t=e.scrollWidth>e.clientWidth+1,n=e.scrollHeight>e.clientHeight+1;y(e=>{let r=d(t,n);return e===r?e:r})},[]),T=(0,c.useCallback)(()=>{requestAnimationFrame(w)},[w]),E=(0,c.useMemo)(()=>p(()=>{T()},o),[T,o]),{ref:D}=(0,l.default)({onResize:E}),{ref:O}=(0,l.default)({onResize:E}),k=(0,c.useCallback)(e=>{s.current=e,D(e),T()},[D,T]);(0,c.useLayoutEffect)(()=>{w()});let A=(0,c.useRef)(0),j=(0,c.useRef)(0);(0,c.useEffect)(()=>{let e=Date.now();if(j.current===0&&(j.current=e),e-j.current>h){A.current=1,j.current=e,S(!1);return}A.current+=1,A.current>g&&(console.warn(`OverflowGuard infinite loop detected. The component will stay in the overflowing state to stabilize rendering.`),S(!0),y(e=>e===`none`?`both`:e))});let M=x&&v===`none`?`both`:v,N=M!==`none`,P=typeof t==`function`?t(!1,`none`):t,F=`fallback`in e?e.fallbackOn??`both`:`both`,I=`fallback`in e&&f(F,M),L=typeof t==`function`?t(N,M):I?e.fallback:t,R={...a,minWidth:0,minHeight:0};return(0,u.jsxs)(`div`,{ref:O,"data-overflow-guard":`container`,className:r,style:{...i,display:`grid`,position:`relative`},children:[(0,u.jsx)(`div`,{"aria-hidden":`true`,"data-overflow-guard":`hidden-measurement-layer`,style:{position:`absolute`,inset:0,overflow:`auto`,pointerEvents:`none`,visibility:`hidden`,zIndex:-1},children:(0,u.jsx)(`div`,{...C,ref:k,"data-overflow-guard":`hidden-measurement-box`,className:n,style:R,children:(0,u.jsx)(_.Provider,{value:{isOverflowing:!1,overflowAxis:`none`},children:P})})}),(0,u.jsx)(`div`,{"data-overflow-guard":`visible-layer-${M}`,style:{gridArea:`1 / 1`,minWidth:0,minHeight:0},children:(0,u.jsx)(`div`,{...C,className:n,style:R,"data-overflow-guard":`visible-box`,children:(0,u.jsx)(_.Provider,{value:{isOverflowing:N,overflowAxis:M},children:L})})})]})}function b(e){let t={...e};return delete t.children,delete t.className,delete t.containerClassName,delete t.containerStyle,delete t.fallback,delete t.fallbackOn,delete t.style,delete t.throttleTime,t}exports.OverflowGuard=y,exports.useOverflowGuard=v;
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { createContext as e, useCallback as t, useContext as n, useEffect as r, useLayoutEffect as i, useMemo as a, useRef as o, useState as s } from "react";
|
|
3
|
+
import c from "use-resize-observer";
|
|
4
|
+
import { jsx as l, jsxs as u } from "react/jsx-runtime";
|
|
5
|
+
//#region src/utils.ts
|
|
6
|
+
function d(e, t) {
|
|
7
|
+
return e && t ? "both" : e ? "horizontal" : t ? "vertical" : "none";
|
|
8
|
+
}
|
|
9
|
+
function f(e, t) {
|
|
10
|
+
return t === "none" ? !1 : e === "both" ? !0 : t === e || t === "both";
|
|
11
|
+
}
|
|
12
|
+
function p(e, t) {
|
|
13
|
+
let n = null;
|
|
14
|
+
return (...r) => {
|
|
15
|
+
n && clearTimeout(n), n = setTimeout(() => {
|
|
16
|
+
e(...r);
|
|
17
|
+
}, t);
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
//#endregion
|
|
21
|
+
//#region src/overflow-guard.tsx
|
|
22
|
+
var m = 0, h = 300, g = 7, _ = e({
|
|
23
|
+
isOverflowing: !1,
|
|
24
|
+
overflowAxis: "none"
|
|
25
|
+
});
|
|
26
|
+
function v() {
|
|
27
|
+
return n(_).isOverflowing;
|
|
28
|
+
}
|
|
29
|
+
function y(e) {
|
|
30
|
+
let { children: n, className: v, containerClassName: y, containerStyle: x, style: S, throttleTime: C = m } = e, w = o(null), [T, E] = s("none"), [D, O] = s(!1), k = b(e), A = t(() => {
|
|
31
|
+
let e = w.current;
|
|
32
|
+
if (!e) return;
|
|
33
|
+
let t = e.scrollWidth > e.clientWidth + 1, n = e.scrollHeight > e.clientHeight + 1;
|
|
34
|
+
E((e) => {
|
|
35
|
+
let r = d(t, n);
|
|
36
|
+
return e === r ? e : r;
|
|
37
|
+
});
|
|
38
|
+
}, []), j = t(() => {
|
|
39
|
+
requestAnimationFrame(A);
|
|
40
|
+
}, [A]), M = a(() => p(() => {
|
|
41
|
+
j();
|
|
42
|
+
}, C), [j, C]), { ref: N } = c({ onResize: M }), { ref: P } = c({ onResize: M }), F = t((e) => {
|
|
43
|
+
w.current = e, N(e), j();
|
|
44
|
+
}, [N, j]);
|
|
45
|
+
i(() => {
|
|
46
|
+
A();
|
|
47
|
+
});
|
|
48
|
+
let I = o(0), L = o(0);
|
|
49
|
+
r(() => {
|
|
50
|
+
let e = Date.now();
|
|
51
|
+
if (L.current === 0 && (L.current = e), e - L.current > h) {
|
|
52
|
+
I.current = 1, L.current = e, O(!1);
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
I.current += 1, I.current > g && (console.warn("OverflowGuard infinite loop detected. The component will stay in the overflowing state to stabilize rendering."), O(!0), E((e) => e === "none" ? "both" : e));
|
|
56
|
+
});
|
|
57
|
+
let R = D && T === "none" ? "both" : T, z = R !== "none", B = typeof n == "function" ? n(!1, "none") : n, V = "fallback" in e ? e.fallbackOn ?? "both" : "both", H = "fallback" in e && f(V, R), U = typeof n == "function" ? n(z, R) : H ? e.fallback : n, W = {
|
|
58
|
+
...S,
|
|
59
|
+
minWidth: 0,
|
|
60
|
+
minHeight: 0
|
|
61
|
+
};
|
|
62
|
+
return /* @__PURE__ */ u("div", {
|
|
63
|
+
ref: P,
|
|
64
|
+
"data-overflow-guard": "container",
|
|
65
|
+
className: y,
|
|
66
|
+
style: {
|
|
67
|
+
...x,
|
|
68
|
+
display: "grid",
|
|
69
|
+
position: "relative"
|
|
70
|
+
},
|
|
71
|
+
children: [/* @__PURE__ */ l("div", {
|
|
72
|
+
"aria-hidden": "true",
|
|
73
|
+
"data-overflow-guard": "hidden-measurement-layer",
|
|
74
|
+
style: {
|
|
75
|
+
position: "absolute",
|
|
76
|
+
inset: 0,
|
|
77
|
+
overflow: "auto",
|
|
78
|
+
pointerEvents: "none",
|
|
79
|
+
visibility: "hidden",
|
|
80
|
+
zIndex: -1
|
|
81
|
+
},
|
|
82
|
+
children: /* @__PURE__ */ l("div", {
|
|
83
|
+
...k,
|
|
84
|
+
ref: F,
|
|
85
|
+
"data-overflow-guard": "hidden-measurement-box",
|
|
86
|
+
className: v,
|
|
87
|
+
style: W,
|
|
88
|
+
children: /* @__PURE__ */ l(_.Provider, {
|
|
89
|
+
value: {
|
|
90
|
+
isOverflowing: !1,
|
|
91
|
+
overflowAxis: "none"
|
|
92
|
+
},
|
|
93
|
+
children: B
|
|
94
|
+
})
|
|
95
|
+
})
|
|
96
|
+
}), /* @__PURE__ */ l("div", {
|
|
97
|
+
"data-overflow-guard": `visible-layer-${R}`,
|
|
98
|
+
style: {
|
|
99
|
+
gridArea: "1 / 1",
|
|
100
|
+
minWidth: 0,
|
|
101
|
+
minHeight: 0
|
|
102
|
+
},
|
|
103
|
+
children: /* @__PURE__ */ l("div", {
|
|
104
|
+
...k,
|
|
105
|
+
className: v,
|
|
106
|
+
style: W,
|
|
107
|
+
"data-overflow-guard": "visible-box",
|
|
108
|
+
children: /* @__PURE__ */ l(_.Provider, {
|
|
109
|
+
value: {
|
|
110
|
+
isOverflowing: z,
|
|
111
|
+
overflowAxis: R
|
|
112
|
+
},
|
|
113
|
+
children: U
|
|
114
|
+
})
|
|
115
|
+
})
|
|
116
|
+
})]
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
function b(e) {
|
|
120
|
+
let t = { ...e };
|
|
121
|
+
return delete t.children, delete t.className, delete t.containerClassName, delete t.containerStyle, delete t.fallback, delete t.fallbackOn, delete t.style, delete t.throttleTime, t;
|
|
122
|
+
}
|
|
123
|
+
//#endregion
|
|
124
|
+
export { y as OverflowGuard, v as useOverflowGuard };
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { CSSProperties, HTMLAttributes, ReactNode } from 'react';
|
|
2
|
+
export type OverflowAxis = 'none' | 'horizontal' | 'vertical' | 'both';
|
|
3
|
+
export type FallbackOn = Exclude<OverflowAxis, 'none'>;
|
|
4
|
+
type SharedProps = Omit<HTMLAttributes<HTMLDivElement>, 'children'> & {
|
|
5
|
+
containerClassName?: string;
|
|
6
|
+
containerStyle?: CSSProperties;
|
|
7
|
+
throttleTime?: number;
|
|
8
|
+
};
|
|
9
|
+
type RenderProp = (isOverflowing: boolean, overflowAxis: OverflowAxis) => ReactNode;
|
|
10
|
+
type FallbackModeProps = SharedProps & {
|
|
11
|
+
children?: ReactNode;
|
|
12
|
+
fallback: ReactNode;
|
|
13
|
+
fallbackOn?: FallbackOn;
|
|
14
|
+
};
|
|
15
|
+
type RenderPropModeProps = SharedProps & {
|
|
16
|
+
children?: RenderProp;
|
|
17
|
+
fallback?: never;
|
|
18
|
+
fallbackOn?: never;
|
|
19
|
+
};
|
|
20
|
+
export type OverflowGuardProps = FallbackModeProps | RenderPropModeProps;
|
|
21
|
+
export declare function useOverflowGuard(): boolean;
|
|
22
|
+
export declare function OverflowGuard(props: OverflowGuardProps): import("react/jsx-runtime").JSX.Element;
|
|
23
|
+
export {};
|
package/dist/utils.d.ts
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
type OverflowAxisLike = "none" | "horizontal" | "vertical" | "both";
|
|
2
|
+
type FallbackOnLike = Exclude<OverflowAxisLike, "none">;
|
|
3
|
+
export declare function resolveOverflowAxis(horizontalOverflow: boolean, verticalOverflow: boolean): OverflowAxisLike;
|
|
4
|
+
export declare function shouldUseFallback(fallbackOn: FallbackOnLike, overflowAxis: OverflowAxisLike): boolean;
|
|
5
|
+
export declare function throttle<T extends (...args: unknown[]) => void>(fn: T, delay: number): (...args: Parameters<T>) => void;
|
|
6
|
+
export {};
|
package/package.json
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "overflow-guard-react",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"description": "React component and hook for content-aware responsive UI that adapts when content stops fitting, without breakpoints or magic numbers.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"author": "Artur Marczyk",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/arturmarc/overflow-guard.git",
|
|
10
|
+
"directory": "packages/overflow-guard-react"
|
|
11
|
+
},
|
|
12
|
+
"homepage": "https://github.com/arturmarc/overflow-guard/tree/main/packages/overflow-guard-react#readme",
|
|
13
|
+
"bugs": {
|
|
14
|
+
"url": "https://github.com/arturmarc/overflow-guard/issues"
|
|
15
|
+
},
|
|
16
|
+
"keywords": [
|
|
17
|
+
"react",
|
|
18
|
+
"overflow",
|
|
19
|
+
"responsive",
|
|
20
|
+
"content-aware",
|
|
21
|
+
"content-aware-ui",
|
|
22
|
+
"responsive-ui",
|
|
23
|
+
"adaptive-ui",
|
|
24
|
+
"layout",
|
|
25
|
+
"toolbar",
|
|
26
|
+
"navigation",
|
|
27
|
+
"menu",
|
|
28
|
+
"dynamic-content",
|
|
29
|
+
"localization",
|
|
30
|
+
"resize-observer",
|
|
31
|
+
"render-props",
|
|
32
|
+
"hook",
|
|
33
|
+
"truncate"
|
|
34
|
+
],
|
|
35
|
+
"type": "module",
|
|
36
|
+
"main": "./dist/index.cjs",
|
|
37
|
+
"module": "./dist/index.js",
|
|
38
|
+
"types": "./dist/index.d.ts",
|
|
39
|
+
"sideEffects": false,
|
|
40
|
+
"publishConfig": {
|
|
41
|
+
"access": "public"
|
|
42
|
+
},
|
|
43
|
+
"exports": {
|
|
44
|
+
".": {
|
|
45
|
+
"types": "./dist/index.d.ts",
|
|
46
|
+
"import": "./dist/index.js",
|
|
47
|
+
"require": "./dist/index.cjs"
|
|
48
|
+
}
|
|
49
|
+
},
|
|
50
|
+
"files": [
|
|
51
|
+
"dist",
|
|
52
|
+
"README.md",
|
|
53
|
+
"LICENSE"
|
|
54
|
+
],
|
|
55
|
+
"scripts": {
|
|
56
|
+
"build": "vite build && tsc -p tsconfig.build.json",
|
|
57
|
+
"test": "vitest run",
|
|
58
|
+
"typecheck": "tsc -p tsconfig.json --noEmit"
|
|
59
|
+
},
|
|
60
|
+
"dependencies": {
|
|
61
|
+
"use-resize-observer": "^9.1.0"
|
|
62
|
+
},
|
|
63
|
+
"peerDependencies": {
|
|
64
|
+
"react": ">=16.8.0",
|
|
65
|
+
"react-dom": ">=16.8.0"
|
|
66
|
+
},
|
|
67
|
+
"devDependencies": {
|
|
68
|
+
"@testing-library/jest-dom": "^6.9.1",
|
|
69
|
+
"@testing-library/react": "^16.3.2",
|
|
70
|
+
"@types/node": "^24.10.1",
|
|
71
|
+
"@types/react": "^19.2.7",
|
|
72
|
+
"@types/react-dom": "^19.2.3",
|
|
73
|
+
"@vitejs/plugin-react": "^5.1.0",
|
|
74
|
+
"jsdom": "^28.1.0",
|
|
75
|
+
"react": "^19.2.0",
|
|
76
|
+
"react-dom": "^19.2.0",
|
|
77
|
+
"typescript": "~5.9.3",
|
|
78
|
+
"vite": "^8.0.0-beta.13",
|
|
79
|
+
"vitest": "^4.0.18"
|
|
80
|
+
}
|
|
81
|
+
}
|