├── .eslintrc.cjs ├── .gitignore ├── .nvmrc ├── .prettierrc ├── LICENSE ├── README.md ├── bun.lockb ├── components.json ├── flex-wrap-detector └── README.md ├── index.html ├── other.html ├── package.json ├── pnpm-lock.yaml ├── postcss.config.js ├── prettier.rc ├── public ├── images │ ├── AdaptingContentExample.gif │ ├── BasicExample.gif │ ├── ConditionallyNestedExample.gif │ ├── DeepNestingExample.gif │ ├── NestedExample.gif │ ├── NestedExample_old.gif │ └── SingleChildExample.gif └── vite.svg ├── src ├── App.css ├── App.tsx ├── FlexWrapDetectorElement.d.ts ├── assets │ └── react.svg ├── components │ └── ui │ │ ├── button.tsx │ │ └── dropdown-menu.tsx ├── dom │ ├── FlexWrapDetectorElement.ts │ ├── MutationReverser.ts │ └── reverseMutations.ts ├── index.css ├── index.ts ├── lib │ └── utils.ts ├── main.tsx ├── react │ └── FluidFlexbox.tsx ├── usage │ ├── ExampleSelector.tsx │ ├── FlexWrapDetector.tsx │ ├── Resizer.tsx │ ├── custom-element-react-examples │ │ ├── AdaptingContentExampleCE.tsx │ │ ├── BasicUsageExampleCE.tsx │ │ ├── ConditionallyNestedExampleCE.tsx │ │ ├── DeepNestingExampleCE.tsx │ │ ├── DynamicContentExampleCE.tsx │ │ ├── InfiniteLoopCE.tsx │ │ ├── NestedExampleCE.tsx │ │ ├── OrderAndReverseCE.tsx │ │ └── SingleChildExampleCE.tsx │ ├── examples │ │ ├── AdaptingContentExample.tsx │ │ ├── AllPropsShowcaseExample.tsx │ │ ├── BasicUsageExample.tsx │ │ ├── ConditionallyNestedExample.tsx │ │ ├── DeepNestingExample.tsx │ │ ├── HolyGrailToolbarExample.tsx │ │ ├── InfiniteLoop.tsx │ │ ├── NestedExample.tsx │ │ ├── OrderAndReverse.tsx │ │ ├── SingleChildExample.tsx │ │ └── SmallerHeight.tsx │ └── html-examples │ │ ├── adapting-content-mutating.html │ │ ├── adapting-content.html │ │ ├── basic-usage.html │ │ ├── conditionally-nested.html │ │ ├── deep-nesting.html │ │ ├── dynamic-content.html │ │ ├── holy-grail.html │ │ ├── single-child.html │ │ └── two-levels-nesting.html ├── utils │ └── throttle.ts └── vite-env.d.ts ├── tailwind.config.js ├── tsconfig.build.json ├── tsconfig.json ├── tsconfig.node.json ├── vite.config.ts └── vite.web-comonenet-build.config.ts /.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | env: { browser: true, es2020: true }, 4 | extends: [ 5 | "eslint:recommended", 6 | "plugin:@strict-type-checked", 7 | "plugin:@typescript-eslint/stylistic-type-checked", 8 | "plugin:react-hooks/recommended", 9 | "plugin:react/recommended", 10 | "plugin:react/jsx-runtime", 11 | ], 12 | ignorePatterns: ["dist", ".eslintrc.cjs"], 13 | parser: "@typescript-eslint/parser", 14 | plugins: ["react-refresh"], 15 | rules: { 16 | "react-refresh/only-export-components": [ 17 | "warn", 18 | { allowConstantExport: true }, 19 | ], 20 | }, 21 | }; 22 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | notes 4 | *.log 5 | npm-debug.log* 6 | yarn-debug.log* 7 | yarn-error.log* 8 | pnpm-debug.log* 9 | lerna-debug.log* 10 | 11 | node_modules 12 | dist 13 | dist-ssr 14 | *.local 15 | 16 | # Editor directories and files 17 | .vscode/* 18 | !.vscode/extensions.json 19 | .idea 20 | .DS_Store 21 | *.suo 22 | *.ntvs* 23 | *.njsproj 24 | *.sln 25 | *.sw? 26 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | node 2 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "tabWidth": 2, 3 | "useTabs": false, 4 | "plugins": ["prettier-plugin-tailwindcss"] 5 | } 6 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 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. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # fluid-flexbox 2 | 3 | ### a "`flex-wrap` on steroids" 4 | 5 | React component that detects when it's flex children no longer fit in a single row. 6 | Allows styles and content to dynamically adapt the space available. 7 | 8 | Powerful tool for responsive layout that enables responsive styling not based on pixel sizes but on the available space. For example: 9 | 10 | Basic usage demo gif 11 | 12 | - uses css flexbox model and extends it 13 | - entirely dynamic (no calculations involved) adapts to any change in content, parent css etc.. 14 | - can be nested (deeply if needed) to create complex responsive rules 15 | - not just styling, but also content can be easily adapted using a render prop or the `useFluidFlexboxWrapped` hook 16 | - resilient to infinite render loops 17 | - works with any css framework (tailwind, bootstrap, etc) or inline styles 18 | 19 | and ... 20 | 21 | # flex-wrap-detector 22 | 23 | ### a generic, pure js based custom element 24 | 25 | Can be used with any js framework or as a standalone custom element: ``. 26 | 27 | [see full documentation](./flex-wrap-detector/README.md) 28 | 29 | - Uses same technique as the react component, but without react. 30 | - Faster and lighter than the react component, but more cumbersome to use when adapting content. 31 | - can be difficult to use when working with dynamically changing content 32 | 33 |

34 | 35 | ## `` - react component 36 | 37 | ### Checkout the live demo: 38 | 39 | [![Open in StackBlitz](https://developer.stackblitz.com/img/open_in_stackblitz.svg)](https://stackblitz.com/~/github.com/arturmarc/fluid-flexbox?file=src/usage/examples/BasicUsageExample.tsx) 40 | 41 | ## Installation 42 | 43 | Just install the package using npm or any other package manager: 44 | 45 | ```bash 46 | npm install fluid-flexbox 47 | ``` 48 | 49 | and import 50 | 51 | ```js 52 | import { FluidFlexBox } from "fluid-flexbox"; 53 | ``` 54 | 55 | ## Basic usage 56 | 57 | Use the `wrappedClass` prop to add a css class when flex content is wrapped (no longer fits in a single row) 58 | 59 | Basic usage demo gif 60 | 61 | [![Open in StackBlitz](https://developer.stackblitz.com/img/open_in_stackblitz_small.svg)](https://stackblitz.com/~/github.com/arturmarc/fluid-flexbox?file=src/usage/examples/BasicUsageExample.tsx) 62 | 63 | ```jsx 64 | import { FluidFlexbox } from "fluid-flexbox"; 65 | 66 | 67 | 68 | 69 | 70 | ; 71 | ``` 72 | 73 | This example showcases a simple but useful use case: changing the layout of a toolbar when buttons no longer fit in a single row and renders them in a column instead. 74 | 75 | > note: all examples are using [tailwind css utility classes](), If you're unfamiliar, Tailwind functions work similarly to inline styles. For example `flex-col` is equivalent to `style="flex-direction: column"`, just applied using an utility class 76 | 77 | ## Adapting content 78 | 79 | Adapting content demo gif 80 | 81 | Not just styling, but also content can be easily adapted using render prop: 82 | 83 | [![Open in StackBlitz](https://developer.stackblitz.com/img/open_in_stackblitz_small.svg)](https://stackblitz.com/~/github.com/arturmarc/fluid-flexbox?file=src/usage/examples/AdaptingContentExample.tsx) 84 | 85 | ```jsx 86 | 87 | {(isWrapped) => ( 88 | <> 89 | 90 | 91 | {!isWrapped && } 92 | 93 | )} 94 | 95 | ``` 96 | 97 | or using the `useFluidFlexboxWrapped` hook: 98 | 99 | ```jsx 100 | import { FluidFlexbox, useFluidFlexboxWrapped } from "fluid-flexbox"; 101 | 102 | function Buttons() { 103 | const isWrapped = useFluidFlexboxWrapped(); 104 | return ( 105 | <> 106 | 107 | 108 | {!isWrapped && } 109 | 110 | ); 111 | } 112 | 113 | function Toolbar() { 114 | return ( 115 | 116 | 117 | 118 | ); 119 | } 120 | ``` 121 | 122 | ## Nesting 123 | 124 | Two levels of nesting. \ 125 | This example example demonstrates how fluid flex-boxes can be nested. \ 126 | It also showcases fluid flexbox being able to grow by using `containerClassName="flex-grow"`. 127 | 128 | Two levels of nesting demo gif 129 | 130 | [![Open in StackBlitz](https://developer.stackblitz.com/img/open_in_stackblitz_small.svg)](https://stackblitz.com/~/github.com/arturmarc/fluid-flexbox?file=src/usage/examples/NestedExample.tsx) 131 | 132 | ```jsx 133 | 138 | {(isWrapped) => ( 139 | <> 140 | 145 | 146 | 147 | 148 | 153 | 154 | 155 | 156 | 157 | )} 158 | 159 | ``` 160 | 161 | Conditionally nested - if the widest content is wrapped, checks if the narrower version is wrapped to enable 162 | eventually rendering the narrower version. 163 | 164 | Conditionally nested demo gif 165 | 166 | [![Open in StackBlitz](https://developer.stackblitz.com/img/open_in_stackblitz_small.svg)](https://stackblitz.com/~/github.com/arturmarc/fluid-flexbox?file=src/usage/examples/ConditionallyNestedExample.tsx) 167 | 168 | ```jsx 169 | const contentWhenWidest = ( 170 | <> 171 | 172 | 173 | 174 | 175 | ); 176 | const contentWhenNarrower = ( 177 | <> 178 | 179 | 180 | 181 | 182 | ); 183 | const narrowestContent = ( 184 | <> 185 | 188 | 191 | 194 | 195 | ); 196 | return ( 197 | 198 | {(isWidestWrapped) => 199 | !isWidestWrapped ? ( 200 | contentWhenWidest 201 | ) : ( 202 | 203 | {(isNarrowerWrapped) => 204 | !isNarrowerWrapped ? contentWhenNarrower : narrowestContent 205 | } 206 | 207 | ) 208 | } 209 | 210 | ); 211 | ``` 212 | 213 | Deep nesting:\ 214 | This example demonstrates how elements can be changed one by one to replace text labels with icons. 215 | 216 | Deep nesting demo gif 217 | 218 | [![Open in StackBlitz](https://developer.stackblitz.com/img/open_in_stackblitz_small.svg)](https://stackblitz.com/~/github.com/arturmarc/fluid-flexbox?file=src/usage/examples/DeepNestingExample.tsx) 219 | 220 | ```jsx 221 | 222 | {(outerIsWrapped) => ( 223 | <> 224 | 225 | 226 | {(innerIsWrapped) => ( 227 | <> 228 | 229 | 230 | {(innermostIsWrapped) => ( 231 | <> 232 | 235 |
236 | 237 | )} 238 |
239 | 240 | )} 241 |
242 | 243 | )} 244 |
245 | ``` 246 | 247 | ## Single child 248 | 249 | Can also be used to detect if a single element is overflowing it's container using this trick: 250 | 251 | Single child demo gif 252 | 253 | [![Open in StackBlitz](https://developer.stackblitz.com/img/open_in_stackblitz_small.svg)](https://stackblitz.com/~/github.com/arturmarc/fluid-flexbox?file=src/usage/examples/SingleChildExample.tsx) 254 | 255 | ```jsx 256 | 257 | {(isWrapped) => ( 258 | <> 259 | 260 |
261 | 262 | )} 263 |
264 | ``` 265 | 266 | (..TODO advanced consideration: inside a flex container, and infinite loop) 267 | 268 | ## Additional props 269 | 270 | - `wrappedClass` - css class to add when flex content is wrapped 271 | - `wrappedStyle` - css style to add when flex content is wrapped 272 | - `containerClassName` - css class to add to the container element (the flexbox element is wrapped in a div that you might want to style using this prop) 273 | - `throttleTime` - throttles the detection of overflowing content. Default is no throttling 274 | - `hidden` - convenience prop to hide the element (applying `display: none` to the FluidFlexbox component does not work) 275 | - `removeClassWhenWrapped` - when set tot true `wrappedClass` replaces the `className` prop instead of adding to it. Default is false 276 | - `containerStyle` - css style to add to the container element (the flexbox element is wrapped in a div that you might want to style using this prop) 277 | 278 | # How it works and important considerations 279 | 280 | FluidFlexbox works by rendering two hidden clones of it's original content to detect when the flex items would wrap. 281 | 282 | With that comes an important consideration: \n 283 | this library might not work well near the root of a large component tree. 284 | It's probably at it's best when used for toolbars, menus or content blocks. 285 | 286 | ### Why two clones? 287 | 288 | They are basically identical copies of the flex container with differing values of the `flex-wrap` property. 289 | Then a [ResizeObserver](https://developer.mozilla.org/en-US/docs/Web/API/ResizeObserver/ResizeObserver) 290 | [use-resize-observer](https://github.com/ZeeCoder/use-resize-observer) is used to trigger measurements that determine whether the flex-items wrap or not. 291 | The clones are rendering the _original_, not wrapped version of the content (`isWrapped = false`) and the `wrappedClass` not added. 292 | That way `FluidFlexbox` can know if the original content would fit again when the alternative version is rendered (`isWrapped = true`). 293 | 294 | ### Infinite loops 295 | 296 | If the alternative styling or content when `` is wrapped is actually making it _grow_ to fit the original non wrapped content again, it is possible to get into an infinite loop. There is a built in protection against this, but it's not perfect and it will still cause multiple re-renders and flashes. The protection is timing based so depending on how fast the re-rendering is it might not trigger. \ 297 | Take care to adjust your wrapped styling and content to not cause that infinite loop. It's usually a mistake anyway since the whole point is to adjust your content and styling to fit better when original content is wrapped which means making the content smaller. \ 298 | The `` handles infinite loops much better, so consider using it if you have serious problem with them in the react version. 299 | -------------------------------------------------------------------------------- /bun.lockb: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/arturmarc/fluid-flexbox/9214ad8291a5b123848792639e6fc451e618269a/bun.lockb -------------------------------------------------------------------------------- /components.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://ui.shadcn.com/schema.json", 3 | "style": "default", 4 | "rsc": false, 5 | "tsx": true, 6 | "tailwind": { 7 | "config": "tailwind.config.js", 8 | "css": "src/index.css", 9 | "baseColor": "slate", 10 | "cssVariables": true, 11 | "prefix": "" 12 | }, 13 | "aliases": { 14 | "components": "@/components", 15 | "utils": "@/lib/utils", 16 | "ui": "@/components/ui", 17 | "lib": "@/lib", 18 | "hooks": "@/hooks" 19 | } 20 | } -------------------------------------------------------------------------------- /flex-wrap-detector/README.md: -------------------------------------------------------------------------------- 1 | # flex-wrap-detector 2 | 3 | ### "`flex-wrap` on steroids" - detect and react to when flex items wrap 4 | 5 | ### a generic, pure js based custom element that detects when a flex-container children no longer fit in a single row 6 | 7 | Can be used with any js framework or as a standalone custom element: ``. 8 | 9 | Uses same technique as the [react component](https://github.com/arturmarc/fluid-flexbox), but without react. 10 | 11 | ## `` 12 | 13 | ### Checkout the live demo: 14 | 15 | [![Open in StackBlitz](https://developer.stackblitz.com/img/open_in_stackblitz.svg)](https://stackblitz.com/~/github.com/arturmarc/fluid-flexbox?file=src/usage/html-examples/basic-usage.html) 16 | 17 | ## Installation 18 | 19 | Install the package using npm or any other package manager 20 | 21 | ```bash 22 | npm install fluid-flexbox 23 | ``` 24 | 25 | then import it like this 26 | 27 | ```js 28 | import "fluid-flexbox/flex-wrap-detector"; 29 | ``` 30 | 31 | or use a cdn 32 | 33 | ```html 34 | 38 | ``` 39 | 40 | ## Basic usage 41 | 42 | It is a custom element that needs to wrap a single child, that will become a flex row container (if it is not already). 43 | 44 | Use the `wrapped-class` attribute to add a css class when flex content of the detector child is wrapped (no longer fits in a single row) 45 | 46 | [![Open in StackBlitz](https://developer.stackblitz.com/img/open_in_stackblitz_small.svg)](https://stackblitz.com/~/github.com/arturmarc/fluid-flexbox?file=src/usage/html-examples/basic-usage.html) 47 | 48 | ```html 49 | 50 |
51 |
First
52 |
Second
53 |
Third
54 |
55 |
56 | ``` 57 | 58 | This example showcases a simple but useful use case: changing the layout of a toolbar when buttons no longer fit in a single row and renders then in a column instead. 59 | 60 | > note: all examples are using [tailwind css utility classes](), If you're unfamiliar, Tailwind functions work similarly to inline styles. For example `flex-col` is equivalent to `style="flex-direction: column"`, just applied using an utility class 61 | 62 | ## Adapting content - alternative content 63 | 64 | Not just styling, but also content can be adapted. One simple way is to fully specify alternative content using the `wrapped-content` slot. 65 | 66 | [![Open in StackBlitz](https://developer.stackblitz.com/img/open_in_stackblitz_small.svg)](https://stackblitz.com/~/github.com/arturmarc/fluid-flexbox?file=src/usage/html-examples/adapting-content.html) 67 | 68 | ```html 69 | 70 |
71 |
Remove
72 |
Extra
73 |
Button
74 |
75 |
76 |
Remove
77 |
Extra
78 |
79 |
80 | ``` 81 | 82 | > note: This has two potential downsides: 1. All the alternative content needs to be fully specified in the html, which might be cumbersome especially when only a small part of the content is different. 2. The alternative content is actually completely different html, so any state like input values will be lost when wrapping. 83 | 84 | ## Adapting content - mutating content 85 | 86 | Another way is to adjust the content using an event handler. Useful to overcome the limitations of the first approach. 87 | 88 | [![Open in StackBlitz](https://developer.stackblitz.com/img/open_in_stackblitz_small.svg)](https://stackblitz.com/~/github.com/arturmarc/fluid-flexbox?file=src/usage/html-examples/adapting-content-mutating.html) 89 | 90 | ```html 91 | 92 |
93 |
Input's
94 | 99 |
Removable
100 |
101 |
102 | 111 | ``` 112 | 113 | The `set-wrapped-content` event is fired when the content is wrapped. It allows to adjust the content using js and dom without recreating it. The event detail contains the element that was wrapped. \ 114 | The detector will automatically undo the changes when the content is no longer wrapped. 115 | 116 | This is very useful especially when there are stateful ui elements inside the detector. It means that the elements and their state can be preserved when the content is adjusted (not just replaced with a different element like in the example above). 117 | 118 | This is also how the `wrapped-class` attribute works internally. 119 | 120 | > note: This approach still has potential downsides. The obvious one is the need to use js to adjust the content. And another is, if the content is also changing dynamically (if the detectors are nested for example), it might have unexpected results (See [below](#dynamic-content) for an in depth explanation). 121 | 122 | ## Nesting 123 | 124 | Nesting is possible and works well with slot based approach to adapting content. 125 | 126 | See nested examples in stackblitz: 127 | 128 | - [two levels of nesting](https://stackblitz.com/~/github.com/arturmarc/fluid-flexbox?file=src/usage/html-examples/two-levels-nesting.html) 129 | - [conditionally nested](https://stackblitz.com/~/github.com/arturmarc/fluid-flexbox?file=src/usage/html-examples/conditionally-nested.html) 130 | - [deep nesting](https://stackblitz.com/~/github.com/arturmarc/fluid-flexbox?file=src/usage/html-examples/deep-nesting.html) 131 | 132 | With the second abroach (using 'set-wrapped-content' event), or by using just 'wrapped-class' attribute, there might be some unexpected results when the content is nested. In general it can work, but issues might occur and a warning might be shown (which can be suppressed using 'suppress-warning' attribute). Best to carefully test your use case. 133 | 134 | ## Single child 135 | 136 | Can also be used to detect if a single element is overflowing it's container using this trick: 137 | 138 | ```html 139 | 140 |
141 |
Long button
142 |
143 |
144 |
145 |
146 | 147 |
148 |
149 |
150 | ``` 151 | 152 | This is pretty useful cause it don't really need to be multiple flex children, and can just tell if a single child is overflowing it's container. 153 | 154 | This technique can lean to some involved layouts like the one in the [holy grail toolbar example](https://github.com/arturmarc/fluid-flexbox/blob/main/src/usage/examples/HolyGrailToolbarExample.tsx). 155 | 156 | ## Dynamic content 157 | 158 | ### tldr 159 | 160 | Dynamically changing the detector's content might break it when the alternative wrapped content is already applied. When you see this warning in the console make sure to test if the behavior is what you expect. If it is not, you can use the "wrapped-content" slot and change both copies of the content (original child and the slotted child). 161 | 162 | [![Open in StackBlitz](https://developer.stackblitz.com/img/open_in_stackblitz.svg)](https://stackblitz.com/~/github.com/arturmarc/fluid-flexbox?file=src/usage/html-examples/dynamic-content.html) 163 | 164 | ### Deep dive 165 | 166 | As mentioned above when dynamically changing the detector's content there are caveats to consider when _not_ using the "wrapped-content" slot (so when using wrapped-class and/or set-wrapped-content event). \ 167 | The problem is what happens when the content is changed dynamically when the alternative wrapped content is already applied. The detector keeps a copy of a non-wrapped/original content, but the dynamic change will not be applied to that copy. \ 168 | To explain [the example](https://stackblitz.com/~/github.com/arturmarc/fluid-flexbox?file=src/usage/html-examples/dynamic-content.html) - there are two buttons, that currently don't fit and have `flex: column` direction applied using 'wrapped-class' attribute. Say an unrelated user action causes one of those buttons to be removed. That change will not be applied to the detector's copy of the content, so the detector will still think that there are two buttons and not unwrap the content even if it now fits. \ 169 | There will be a warning in the console when that happens to inform you that the detector might not be fully functional. \ 170 | The easy solution is to use "wrapped-content" slot and change both copies of the content (original child and the slotted child). 171 | A more involved one, when this is not preferable is to actually reach into detector's internals on also mutate it's invisible copies. 172 | 173 | All of this is not ideal, but the detector version is meant to be used in more static contexts, so this should not be a common concern. 174 | 175 | Interestingly the React version `` does not have that problem at all, because its trivial to re-render the copy thanks to React's virtual-dom. To duplicate that capability in a vanilla js solution would require essentially building a small rendering engine that can would need to have some of the React capabilities. 176 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Fluid FlexBox playground 8 | 9 | 10 |
11 | 12 | 13 | -------------------------------------------------------------------------------- /other.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Fluid FlexBox elementplayground 8 | 9 | 10 |
11 | 12 | 13 |
14 | 15 |
Some content
16 |
Some more content
17 |
18 |
19 | 20 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "fluid-flexbox", 3 | "private": false, 4 | "version": "0.1.5", 5 | "type": "module", 6 | "repository": { 7 | "type": "git", 8 | "url": "git+ssh://git@github.com/arturmarc/fluid-flexbox.git" 9 | }, 10 | "description": "A \"flex-wrap on steroids\" component and custom element that detects when it's flex children no longer fit in a single row.", 11 | "author": "Artur Marczyk ", 12 | "license": "MIT", 13 | "keywords": [ 14 | "react", 15 | "react-component", 16 | "flex", 17 | "flexbox", 18 | "css", 19 | "tailwind", 20 | "utility", 21 | "responsive", 22 | "fluid", 23 | "layout", 24 | "wrap", 25 | "fit", 26 | "detect", 27 | "overflow", 28 | "collapsed", 29 | "vanilla-js" 30 | ], 31 | "scripts": { 32 | "dev": "vite", 33 | "build": "vite build && tsc --project tsconfig.build.json && vite build --config vite.web-comonenet-build.config.ts", 34 | "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0", 35 | "preview": "vite preview" 36 | }, 37 | "files": [ 38 | "dist", 39 | "src/dom/FlexWrapDetectorElement.ts", 40 | "src/lib/utils.ts", 41 | "src/react/FluidFlexbox.tsx" 42 | ], 43 | "main": "./dist/fluid-flexbox.cjs.js", 44 | "module": "./dist/fluid-flexbox.es.js", 45 | "types": "./dist/index.d.ts", 46 | "exports": { 47 | ".": { 48 | "types": "./dist/index.d.ts", 49 | "import": "./dist/fluid-flexbox.es.js", 50 | "require": "./dist/fluid-flexbox.cjs.js" 51 | }, 52 | "./flex-wrap-detector": { 53 | "types": "./dist/index.d.ts", 54 | "import": "./dist/flex-wrap-detector.es.js", 55 | "require": "./dist/flex-wrap-detector.cjs.js" 56 | } 57 | }, 58 | "peerDependencies": { 59 | "react": "16.8.0 - 18", 60 | "react-dom": "16.8.0 - 18" 61 | }, 62 | "dependencies": { 63 | "use-resize-observer": "^9.1.0" 64 | }, 65 | "devDependencies": { 66 | "@radix-ui/react-dropdown-menu": "^2.1.4", 67 | "@radix-ui/react-select": "^2.1.4", 68 | "@radix-ui/react-slot": "^1.1.1", 69 | "@types/node": "^22.10.2", 70 | "@types/react": "^19.0.2", 71 | "@types/react-dom": "^19.0.2", 72 | "@typescript-eslint/eslint-plugin": "^8.18.1", 73 | "@typescript-eslint/parser": "^8.18.1", 74 | "@vitejs/plugin-react-swc": "^3.7.2", 75 | "autoprefixer": "^10.4.20", 76 | "class-variance-authority": "^0.7.1", 77 | "clsx": "^2.1.1", 78 | "eslint": "^9.17.0", 79 | "eslint-plugin-react": "^7.37.2", 80 | "eslint-plugin-react-hooks": "^5.1.0", 81 | "eslint-plugin-react-refresh": "^0.4.16", 82 | "lucide-react": "^0.468.0", 83 | "postcss": "^8.4.49", 84 | "prettier-plugin-tailwindcss": "^0.6.9", 85 | "react": "^18.3.1", 86 | "react-dom": "^18.3.1", 87 | "rollup-preserve-directives": "^1.1.3", 88 | "tailwind-merge": "^2.5.5", 89 | "tailwindcss": "^3.4.17", 90 | "tailwindcss-animate": "^1.0.7", 91 | "typescript": "^5.7.2", 92 | "vite": "^6.0.3" 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /prettier.rc: -------------------------------------------------------------------------------- 1 | { 2 | "plugins": ["prettier-plugin-tailwindcss"] 3 | } -------------------------------------------------------------------------------- /public/images/AdaptingContentExample.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/arturmarc/fluid-flexbox/9214ad8291a5b123848792639e6fc451e618269a/public/images/AdaptingContentExample.gif -------------------------------------------------------------------------------- /public/images/BasicExample.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/arturmarc/fluid-flexbox/9214ad8291a5b123848792639e6fc451e618269a/public/images/BasicExample.gif -------------------------------------------------------------------------------- /public/images/ConditionallyNestedExample.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/arturmarc/fluid-flexbox/9214ad8291a5b123848792639e6fc451e618269a/public/images/ConditionallyNestedExample.gif -------------------------------------------------------------------------------- /public/images/DeepNestingExample.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/arturmarc/fluid-flexbox/9214ad8291a5b123848792639e6fc451e618269a/public/images/DeepNestingExample.gif -------------------------------------------------------------------------------- /public/images/NestedExample.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/arturmarc/fluid-flexbox/9214ad8291a5b123848792639e6fc451e618269a/public/images/NestedExample.gif -------------------------------------------------------------------------------- /public/images/NestedExample_old.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/arturmarc/fluid-flexbox/9214ad8291a5b123848792639e6fc451e618269a/public/images/NestedExample_old.gif -------------------------------------------------------------------------------- /public/images/SingleChildExample.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/arturmarc/fluid-flexbox/9214ad8291a5b123848792639e6fc451e618269a/public/images/SingleChildExample.gif -------------------------------------------------------------------------------- /public/vite.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/App.css: -------------------------------------------------------------------------------- 1 | #react-root { 2 | max-width: 1280px; 3 | margin: 0 auto; 4 | padding: 2rem; 5 | width: 100%; 6 | } 7 | -------------------------------------------------------------------------------- /src/App.tsx: -------------------------------------------------------------------------------- 1 | import "./App.css"; 2 | import { ExampleSelector } from "./usage/ExampleSelector"; 3 | 4 | function App() { 5 | return ; 6 | } 7 | 8 | export default App; 9 | -------------------------------------------------------------------------------- /src/FlexWrapDetectorElement.d.ts: -------------------------------------------------------------------------------- 1 | declare namespace JSX { 2 | interface IntrinsicElements { 3 | "flex-wrap-detector": React.DetailedHTMLProps< 4 | React.HTMLAttributes, 5 | FlexWrapDetectorElement 6 | >; 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /src/assets/react.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/components/ui/button.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { Slot } from "@radix-ui/react-slot" 3 | import { cva, type VariantProps } from "class-variance-authority" 4 | 5 | import { cn } from "@/lib/utils" 6 | 7 | const buttonVariants = cva( 8 | "inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50", 9 | { 10 | variants: { 11 | variant: { 12 | default: "bg-primary text-primary-foreground hover:bg-primary/90", 13 | destructive: 14 | "bg-destructive text-destructive-foreground hover:bg-destructive/90", 15 | outline: 16 | "border border-input bg-background hover:bg-accent hover:text-accent-foreground", 17 | secondary: 18 | "bg-secondary text-secondary-foreground hover:bg-secondary/80", 19 | ghost: "hover:bg-accent hover:text-accent-foreground", 20 | link: "text-primary underline-offset-4 hover:underline", 21 | }, 22 | size: { 23 | default: "h-10 px-4 py-2", 24 | sm: "h-9 rounded-md px-3", 25 | lg: "h-11 rounded-md px-8", 26 | icon: "h-10 w-10", 27 | }, 28 | }, 29 | defaultVariants: { 30 | variant: "default", 31 | size: "default", 32 | }, 33 | } 34 | ) 35 | 36 | export interface ButtonProps 37 | extends React.ButtonHTMLAttributes, 38 | VariantProps { 39 | asChild?: boolean 40 | } 41 | 42 | const Button = React.forwardRef( 43 | ({ className, variant, size, asChild = false, ...props }, ref) => { 44 | const Comp = asChild ? Slot : "button" 45 | return ( 46 | 51 | ) 52 | } 53 | ) 54 | Button.displayName = "Button" 55 | 56 | export { Button, buttonVariants } 57 | -------------------------------------------------------------------------------- /src/components/ui/dropdown-menu.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"; 3 | import { Check, ChevronRight, Circle } from "lucide-react"; 4 | 5 | import { cn } from "@/lib/utils"; 6 | 7 | const DropdownMenu = DropdownMenuPrimitive.Root; 8 | 9 | const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger; 10 | 11 | const DropdownMenuGroup = DropdownMenuPrimitive.Group; 12 | 13 | const DropdownMenuPortal = DropdownMenuPrimitive.Portal; 14 | 15 | const DropdownMenuSub = DropdownMenuPrimitive.Sub; 16 | 17 | const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup; 18 | 19 | const DropdownMenuSubTrigger = React.forwardRef< 20 | React.ElementRef, 21 | React.ComponentPropsWithoutRef & { 22 | inset?: boolean; 23 | } 24 | >(({ className, inset, children, ...props }, ref) => ( 25 | 34 | {children} 35 | 36 | 37 | )); 38 | DropdownMenuSubTrigger.displayName = 39 | DropdownMenuPrimitive.SubTrigger.displayName; 40 | 41 | const DropdownMenuSubContent = React.forwardRef< 42 | React.ElementRef, 43 | React.ComponentPropsWithoutRef 44 | >(({ className, ...props }, ref) => ( 45 | 53 | )); 54 | DropdownMenuSubContent.displayName = 55 | DropdownMenuPrimitive.SubContent.displayName; 56 | 57 | const DropdownMenuContent = React.forwardRef< 58 | React.ElementRef, 59 | React.ComponentPropsWithoutRef 60 | >(({ className, sideOffset = 4, ...props }, ref) => ( 61 | 62 | 71 | 72 | )); 73 | DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName; 74 | 75 | const DropdownMenuItem = React.forwardRef< 76 | React.ElementRef, 77 | React.ComponentPropsWithoutRef & { 78 | inset?: boolean; 79 | } 80 | >(({ className, inset, ...props }, ref) => ( 81 | 90 | )); 91 | DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName; 92 | 93 | const DropdownMenuCheckboxItem = React.forwardRef< 94 | React.ElementRef, 95 | React.ComponentPropsWithoutRef 96 | >(({ className, children, checked, ...props }, ref) => ( 97 | 106 | 107 | 108 | 109 | 110 | 111 | {children} 112 | 113 | )); 114 | DropdownMenuCheckboxItem.displayName = 115 | DropdownMenuPrimitive.CheckboxItem.displayName; 116 | 117 | const DropdownMenuRadioItem = React.forwardRef< 118 | React.ElementRef, 119 | React.ComponentPropsWithoutRef 120 | >(({ className, children, ...props }, ref) => ( 121 | 129 | 130 | 131 | 132 | 133 | 134 | {children} 135 | 136 | )); 137 | DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName; 138 | 139 | const DropdownMenuLabel = React.forwardRef< 140 | React.ElementRef, 141 | React.ComponentPropsWithoutRef & { 142 | inset?: boolean; 143 | } 144 | >(({ className, inset, ...props }, ref) => ( 145 | 154 | )); 155 | DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName; 156 | 157 | const DropdownMenuSeparator = React.forwardRef< 158 | React.ElementRef, 159 | React.ComponentPropsWithoutRef 160 | >(({ className, ...props }, ref) => ( 161 | 166 | )); 167 | DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName; 168 | 169 | const DropdownMenuShortcut = ({ 170 | className, 171 | ...props 172 | }: React.HTMLAttributes) => { 173 | return ( 174 | 178 | ); 179 | }; 180 | DropdownMenuShortcut.displayName = "DropdownMenuShortcut"; 181 | 182 | export { 183 | DropdownMenu, 184 | DropdownMenuTrigger, 185 | DropdownMenuContent, 186 | DropdownMenuItem, 187 | DropdownMenuCheckboxItem, 188 | DropdownMenuRadioItem, 189 | DropdownMenuLabel, 190 | DropdownMenuSeparator, 191 | DropdownMenuShortcut, 192 | DropdownMenuGroup, 193 | DropdownMenuPortal, 194 | DropdownMenuSub, 195 | DropdownMenuSubContent, 196 | DropdownMenuSubTrigger, 197 | DropdownMenuRadioGroup, 198 | }; 199 | -------------------------------------------------------------------------------- /src/dom/FlexWrapDetectorElement.ts: -------------------------------------------------------------------------------- 1 | import { reverseMutations, startRecordingMutations } from "./reverseMutations"; 2 | 3 | export class FlexWrapDetectorElement extends HTMLElement { 4 | // The child element of this custom element. 5 | // it has to be a single child that will become 6 | // flex row container (if it is not already) 7 | rawChildElement: HTMLElement | null = null; 8 | 9 | // type-safe getter 10 | get childElement() { 11 | if (!this.rawChildElement) { 12 | throw "[flex-wrap-detector] Something went wrong. No child element."; 13 | } 14 | return this.rawChildElement as HTMLElement; 15 | } 16 | 17 | static observedAttributes = ["wrapped-class"]; 18 | 19 | attributeChangedCallback(name: string, _oldValue: string, newValue: string) { 20 | if (name === "wrapped-class") { 21 | this.wrappedClass = newValue || ""; 22 | } 23 | // TODO - add and handle wrapped-style custom attribute 24 | } 25 | 26 | wrappedClass: string = ""; 27 | 28 | // the main slot 29 | slotElement: HTMLSlotElement; 30 | 31 | // copy of the child element 32 | // will go to invisible-non-wrapping slot 33 | // in this slot a copy of the child element will be placed 34 | // with flex-wrap: nowrap forced, 35 | // to compare to the invisible-wrapping slot 36 | invisibleNonWrappingEl: HTMLElement | null = null; 37 | 38 | // copy of the child element 39 | // will go to invisible-wrapping slot 40 | // this one has flex-wrap: wrap forced 41 | invisibleWrappingEl: HTMLElement | null = null; 42 | 43 | mutationObserver: MutationObserver | null = null; 44 | 45 | resizeObserver: ResizeObserver | null = null; 46 | 47 | // mark mutations that happen internally 48 | // so the mutation observer can skip them 49 | mutatingInternally = false; 50 | 51 | // save mutations to wrapped content to be able to reverse them 52 | wrappedContentMutations: MutationRecord[] = []; 53 | 54 | skipNextCheckIfWrapping = false; 55 | 56 | // used to display a warning that the event might not work 57 | hasListenersForWrappedContent = false; 58 | 59 | constructor() { 60 | super(); 61 | const shadowRoot = this.attachShadow({ mode: "open" }); 62 | 63 | shadowRoot.innerHTML = ` 64 | 94 |
95 | 96 |
97 |
98 | 99 |
100 |
101 | 102 |
103 |
104 | 105 |
106 | `; 107 | 108 | const slotElement = this.shadowRoot?.querySelector( 109 | "slot:not([name])", 110 | ) as HTMLSlotElement; 111 | 112 | if (!this.shadowRoot || !slotElement) { 113 | throw "[flex-wrap-detector] Failed to attach shadow root."; 114 | } 115 | 116 | this.slotElement = slotElement; 117 | } 118 | 119 | connectedCallback() { 120 | const slotChildren = this.slotElement.assignedElements(); 121 | if ( 122 | slotChildren.length !== 1 || 123 | !(slotChildren[0] instanceof HTMLElement) 124 | ) { 125 | throw "[flex-wrap-detector] Expected a single child element."; 126 | } 127 | this.rawChildElement = slotChildren[0] as HTMLElement; 128 | 129 | this.copyChildToInvisibleElements(); 130 | this.initMutationObserver(); 131 | } 132 | 133 | disconnectedCallback() { 134 | const pendingMutations = this.mutationObserver?.takeRecords(); 135 | if (pendingMutations && pendingMutations.length > 0) { 136 | // make sure to handle mutations before disconnecting 137 | this.handleMutations(); 138 | } 139 | this.mutationObserver?.disconnect(); 140 | this.resizeObserver?.disconnect(); 141 | } 142 | 143 | copyChildToInvisibleElements() { 144 | const existingNonWrapping = this.querySelector( 145 | ":scope > [slot='invisible-non-wrapping']", 146 | ) as HTMLElement | null; 147 | const existingWrapping = this.querySelector( 148 | ":scope > [slot='invisible-wrapping']", 149 | ) as HTMLElement | null; 150 | if ( 151 | existingNonWrapping && 152 | existingWrapping && 153 | !this.invisibleNonWrappingEl && 154 | !this.invisibleWrappingEl 155 | ) { 156 | // re-use existing invisible elements 157 | // they might already be there if the element has been cloned 158 | // (likely when nesting flex-wrap-detectors) 159 | this.invisibleNonWrappingEl = existingNonWrapping; 160 | this.invisibleWrappingEl = existingWrapping; 161 | return; 162 | } 163 | 164 | // clear already existing invisible copies 165 | this.querySelector(":scope > [slot='invisible-non-wrapping']")?.remove(); 166 | this.querySelector(":scope > [slot='invisible-wrapping']")?.remove(); 167 | 168 | if (this.children.length > 2) { 169 | console.error( 170 | "[flex-wrap-detector] Something went wrong. Too many child elements.", 171 | ); 172 | } 173 | 174 | this.invisibleNonWrappingEl = this.childElement.cloneNode( 175 | true, 176 | ) as HTMLElement; 177 | setStyleAndAttrDefaultsForInvisible(this.invisibleNonWrappingEl); 178 | // the following style is needed to make sure the children of the non-wrapping 179 | // hidden clone are not expanding the container horizontally 180 | // (without it the text inside might start wrapping to the next line) 181 | // (it can't be applied using cs because those are not direct children of the detector) 182 | [...this.invisibleNonWrappingEl.children].forEach((el) => { 183 | (el as HTMLElement).style.flexShrink = "0"; 184 | }); 185 | this.invisibleNonWrappingEl.dataset["flexWrapDetectorInvisible"] = 186 | "non-wrapping"; 187 | this.invisibleNonWrappingEl.slot = "invisible-non-wrapping"; 188 | this.childElement.parentElement?.insertBefore( 189 | this.invisibleNonWrappingEl, 190 | this.childElement, 191 | ); 192 | 193 | this.invisibleWrappingEl = this.childElement.cloneNode(true) as HTMLElement; 194 | setStyleAndAttrDefaultsForInvisible(this.invisibleWrappingEl); 195 | this.invisibleWrappingEl.style.flexWrap = "wrap"; 196 | this.invisibleWrappingEl.dataset["flexWrapDetectorInvisible"] = "wrapping"; 197 | this.invisibleWrappingEl.slot = "invisible-wrapping"; 198 | this.childElement.parentElement?.insertBefore( 199 | this.invisibleWrappingEl, 200 | this.childElement, 201 | ); 202 | 203 | this.initResizeObserver(); 204 | } 205 | 206 | initMutationObserver() { 207 | this.mutationObserver?.disconnect(); 208 | this.mutationObserver = new MutationObserver( 209 | this.handleMutations.bind(this), 210 | ); 211 | 212 | this.mutationObserver.observe(this.childElement, { 213 | childList: true, 214 | attributes: true, 215 | characterData: true, 216 | subtree: true, 217 | }); 218 | } 219 | 220 | handleMutations() { 221 | if (this.mutatingInternally) { 222 | return; 223 | } 224 | console.log(); 225 | const wrappedContent = this.querySelector( 226 | ":scope > [slot='wrapped-content']", 227 | ); 228 | 229 | if (this.wrappedChangesApplied && !wrappedContent) { 230 | const suppressWarning = this.hasAttribute("suppress-warning"); 231 | if (suppressWarning) { 232 | return; 233 | } 234 | 235 | console.warn( 236 | "[flex-wrap-detector] Changes to observed content detected while wrapped. " + 237 | "This will likely have undesired effects. See more at https://github.com/arturmarc/fluid-flexbox/tree/main/flex-wrap-detector#dynamic-content " + 238 | "You might see this warning because of nested elements. " + 239 | "In that case, or if you understand the implications, you can suppress this warning by " + 240 | "adding a 'suppress-warning' attribute to the . ", 241 | ); 242 | return; 243 | } 244 | // todo - consider benchmarking if this is fast enough 245 | this.copyChildToInvisibleElements(); 246 | } 247 | 248 | initResizeObserver() { 249 | if (!this.invisibleNonWrappingEl || !this.invisibleWrappingEl) { 250 | throw "[flex-wrap-detector] Failed to init resize observer. No child element."; 251 | } 252 | 253 | this.resizeObserver?.disconnect(); 254 | 255 | this.resizeObserver = new ResizeObserver(() => { 256 | requestAnimationFrame(() => { 257 | this.checkIfWrappingAndApply(); 258 | }); 259 | }); 260 | 261 | this.resizeObserver.observe(this.invisibleNonWrappingEl); 262 | this.resizeObserver.observe(this.invisibleWrappingEl); 263 | this.resizeObserver.observe(this); 264 | } 265 | 266 | checkIfWrapping() { 267 | if ( 268 | !this.invisibleNonWrappingEl || 269 | !this.invisibleWrappingEl || 270 | !this.childElement 271 | ) { 272 | throw "[flex-wrap-detector] Failed to check if overflowing. References missing this is a bug."; 273 | } 274 | // check children's offset top 275 | const childCount = this.invisibleNonWrappingEl.childElementCount; 276 | // go in reverse order for a slight optimization (most of the time the last child will wrap first) 277 | for (let childIdx = childCount - 1; childIdx >= 0; childIdx -= 1) { 278 | const nonWrappingChild = this.invisibleNonWrappingEl.children[ 279 | childIdx 280 | ] as HTMLElement; 281 | const wrappingChild = this.invisibleWrappingEl.children[ 282 | childIdx 283 | ] as HTMLElement; 284 | if (nonWrappingChild.offsetTop !== wrappingChild.offsetTop) { 285 | return true; 286 | } 287 | } 288 | return false; 289 | } 290 | 291 | checkIfWrappingAndApply() { 292 | if (this.skipNextCheckIfWrapping) { 293 | this.skipNextCheckIfWrapping = false; 294 | return; 295 | } 296 | const isWrapped = this.checkIfWrapping(); 297 | 298 | if (isWrapped && !this.wrappedChangesApplied) { 299 | this.applyWrappedChange(true); 300 | } 301 | if (!isWrapped && this.wrappedChangesApplied) { 302 | this.applyWrappedChange(false); 303 | } 304 | } 305 | 306 | startMutatingInternally() { 307 | const pendingMutations = this.mutationObserver?.takeRecords(); 308 | if (pendingMutations && pendingMutations.length > 0) { 309 | this.handleMutations(); 310 | } 311 | this.mutatingInternally = true; 312 | } 313 | 314 | endMutatingInternally() { 315 | this.mutationObserver?.takeRecords(); 316 | this.mutatingInternally = false; 317 | } 318 | 319 | // need to remember this value because the element might get copied 320 | // with the wrapped changes already applied so keep it in an attribute 321 | set wrappedChangesApplied(val: boolean) { 322 | this.startMutatingInternally(); 323 | // todo - consider benchmarking if this is fast enough 324 | if (val) { 325 | this.setAttribute("data-wrapped-changes-applied", "true"); 326 | } else { 327 | this.removeAttribute("data-wrapped-changes-applied"); 328 | } 329 | this.endMutatingInternally(); 330 | } 331 | 332 | get wrappedChangesApplied() { 333 | return this.hasAttribute("data-wrapped-changes-applied"); 334 | } 335 | 336 | applyWrappedChange(isWrapped: boolean) { 337 | this.startMutatingInternally(); 338 | 339 | // extra styling needed for invisible non-wrapping 340 | this.shadowRoot 341 | ?.querySelector(".invisible-non-wrapping") 342 | ?.classList.toggle("wrapped", isWrapped); 343 | 344 | const wrappedContent = this.querySelector( 345 | ":scope > [slot='wrapped-content']", 346 | ); 347 | if (wrappedContent) { 348 | if (this.wrappedClass) { 349 | console.warn( 350 | '[flex-wrap-detector] "wrapped-content" slot is set. The wrapped-class won\'t be applied.', 351 | ); 352 | } 353 | if (this.hasListenersForWrappedContent) { 354 | console.warn( 355 | '[flex-wrap-detector] "wrapped-content" slot is set. The "set-wrapped-content" event won\'t be fired.', 356 | ); 357 | } 358 | this.showHideWrappedContent(isWrapped); 359 | } else { 360 | this.doOrUndoWrappingContentMutations(isWrapped); 361 | } 362 | this.wrappedChangesApplied = isWrapped; // mark change as applied internally 363 | 364 | // check if the change is causing an infinite loop 365 | const isWrappedAfterApplying = this.checkIfWrapping(); 366 | if (isWrapped && !isWrappedAfterApplying) { 367 | this.skipNextCheckIfWrapping = true; 368 | } 369 | 370 | this.endMutatingInternally(); 371 | } 372 | 373 | addEventListener(...args: Parameters) { 374 | if (args[0] === "set-wrapped-content") { 375 | this.hasListenersForWrappedContent = true; 376 | } 377 | super.addEventListener(...args); 378 | } 379 | 380 | doOrUndoWrappingContentMutations(isWrapped: boolean) { 381 | if (isWrapped) { 382 | const recorder = startRecordingMutations(this.childElement); 383 | 384 | this.childElement.className = 385 | `${this.childElement.className} ${this.wrappedClass}`.trim(); 386 | 387 | const event = new CustomEvent("set-wrapped-content", { 388 | detail: { 389 | element: this.childElement, 390 | }, 391 | bubbles: false, 392 | composed: false, 393 | }); 394 | this.dispatchEvent(event); 395 | 396 | this.wrappedContentMutations = recorder.stopAndGetMutations(); 397 | } else { 398 | reverseMutations(this.wrappedContentMutations); 399 | this.wrappedContentMutations = []; 400 | } 401 | } 402 | 403 | showHideWrappedContent(isWrapped: boolean) { 404 | const wrappedContentContainer = this.shadowRoot?.querySelector( 405 | ".wrapped-content", 406 | ) as HTMLElement; 407 | if (wrappedContentContainer) { 408 | wrappedContentContainer.style.display = isWrapped ? "block" : "none"; 409 | } 410 | const contentContainer = this.shadowRoot?.querySelector( 411 | ".content", 412 | ) as HTMLElement; 413 | if (contentContainer) { 414 | contentContainer.style.display = isWrapped ? "none" : "block"; 415 | } 416 | } 417 | 418 | reApplyIfWrapped() { 419 | if (this.wrappedChangesApplied) { 420 | this.applyWrappedChange(true); 421 | } 422 | } 423 | } 424 | 425 | const setStyleAndAttrDefaultsForInvisible = (el: HTMLElement) => { 426 | el.style.flexWrap = "nowrap"; 427 | el.style.display = "flex"; 428 | el.style.flexDirection = "row"; 429 | el.style.visibility = "hidden "; 430 | el.removeAttribute("id"); 431 | // remove id attribute from all descendants that have it 432 | // todo - consider if this is needed, and are there any other modifications 433 | // that might be needed to make to invisible copies 434 | el.querySelectorAll("*").forEach((el) => { 435 | el.removeAttribute("id"); 436 | }); 437 | }; 438 | 439 | customElements.define("flex-wrap-detector", FlexWrapDetectorElement); 440 | -------------------------------------------------------------------------------- /src/dom/MutationReverser.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * TODO test and use this 3 | * A utility class that tracks and reverses DOM mutations. 4 | */ 5 | export class MutationReverser { 6 | constructor(targetNode: HTMLElement) { 7 | this.targetNode = targetNode; 8 | this.mutations = []; 9 | this.observer = null; 10 | } 11 | 12 | targetNode: HTMLElement; 13 | mutations: MutationRecord[]; 14 | observer: MutationObserver | null; 15 | 16 | startTracking() { 17 | this.observer = new MutationObserver((mutations) => { 18 | this.mutations.push(...mutations); 19 | }); 20 | 21 | this.observer.observe(this.targetNode, { 22 | subtree: true, 23 | childList: true, 24 | attributes: true, 25 | characterData: true, 26 | attributeOldValue: true, 27 | characterDataOldValue: true, 28 | }); 29 | } 30 | 31 | stopTracking() { 32 | this.observer?.disconnect(); 33 | this.mutations = []; 34 | } 35 | 36 | revertAll() { 37 | // Process mutations in reverse order 38 | for (const mutation of [...this.mutations].reverse()) { 39 | this.revertMutation(mutation); 40 | } 41 | this.mutations = []; 42 | } 43 | 44 | revertMutation(mutation: MutationRecord) { 45 | switch (mutation.type) { 46 | case "childList": 47 | this.revertChildListMutation(mutation); 48 | break; 49 | case "attributes": 50 | this.revertAttributeMutation(mutation); 51 | break; 52 | case "characterData": 53 | this.revertCharacterDataMutation(mutation); 54 | break; 55 | default: 56 | const exhaustiveCheck: never = mutation.type; 57 | } 58 | } 59 | 60 | revertChildListMutation(mutation: MutationRecord) { 61 | // Re-insert removed nodes 62 | mutation.removedNodes.forEach((node) => { 63 | if (mutation.nextSibling) { 64 | mutation.target.insertBefore(node, mutation.nextSibling); 65 | } else { 66 | mutation.target.appendChild(node); 67 | } 68 | }); 69 | 70 | // Remove added nodes 71 | mutation.addedNodes.forEach((node) => { 72 | if (node.parentNode) { 73 | node.parentNode.removeChild(node); 74 | } 75 | }); 76 | } 77 | 78 | revertAttributeMutation(mutation: MutationRecord) { 79 | const element = mutation.target as HTMLElement; 80 | const attributeName = mutation.attributeName; 81 | if (!attributeName) { 82 | return; 83 | } 84 | const oldValue = mutation.oldValue; 85 | 86 | if (oldValue === null) { 87 | element.removeAttribute(attributeName); 88 | } else { 89 | element.setAttribute(attributeName, oldValue); 90 | } 91 | } 92 | 93 | revertCharacterDataMutation(mutation: MutationRecord) { 94 | mutation.target.textContent = mutation.oldValue; 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /src/dom/reverseMutations.ts: -------------------------------------------------------------------------------- 1 | export function reverseMutations(mutations: MutationRecord[]) { 2 | for (const mutation of mutations) { 3 | switch (mutation.type) { 4 | case "childList": 5 | revertChildListMutation(mutation); 6 | break; 7 | case "attributes": 8 | revertAttributeMutation(mutation); 9 | break; 10 | case "characterData": 11 | revertCharacterDataMutation(mutation); 12 | break; 13 | default: 14 | exhaustiveCheck(mutation.type); 15 | } 16 | } 17 | } 18 | 19 | function exhaustiveCheck(x: never): never { 20 | throw new Error("Unexpected case: " + x); 21 | } 22 | 23 | export function startRecordingMutations(targetNode: HTMLElement) { 24 | const mutations: MutationRecord[] = []; 25 | const observer = new MutationObserver((mutations) => { 26 | mutations.push(...mutations); 27 | }); 28 | observer.observe(targetNode, { 29 | subtree: true, 30 | childList: true, 31 | attributes: true, 32 | characterData: true, 33 | attributeOldValue: true, 34 | characterDataOldValue: true, 35 | }); 36 | return { 37 | stopAndGetMutations: () => { 38 | mutations.push(...observer.takeRecords()); 39 | observer.disconnect(); 40 | return mutations; 41 | }, 42 | }; 43 | } 44 | 45 | function revertChildListMutation(mutation: MutationRecord) { 46 | // Re-insert removed nodes 47 | mutation.removedNodes.forEach((node) => { 48 | if (mutation.nextSibling) { 49 | mutation.target.insertBefore(node, mutation.nextSibling); 50 | } else { 51 | mutation.target.appendChild(node); 52 | } 53 | }); 54 | 55 | // Remove added nodes 56 | mutation.addedNodes.forEach((node) => { 57 | if (node.parentNode) { 58 | node.parentNode.removeChild(node); 59 | } 60 | }); 61 | } 62 | 63 | function revertAttributeMutation(mutation: MutationRecord) { 64 | const element = mutation.target as HTMLElement; 65 | const attributeName = mutation.attributeName; 66 | if (!attributeName) { 67 | return; 68 | } 69 | const oldValue = mutation.oldValue; 70 | 71 | if (oldValue === null) { 72 | element.removeAttribute(attributeName); 73 | } else { 74 | element.setAttribute(attributeName, oldValue); 75 | } 76 | } 77 | 78 | function revertCharacterDataMutation(mutation: MutationRecord) { 79 | mutation.target.textContent = mutation.oldValue; 80 | } 81 | -------------------------------------------------------------------------------- /src/index.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | @layer base { 6 | :root { 7 | --background: 0 0% 100%; 8 | --foreground: 224 71.4% 4.1%; 9 | --card: 0 0% 100%; 10 | --card-foreground: 224 71.4% 4.1%; 11 | --popover: 0 0% 100%; 12 | --popover-foreground: 224 71.4% 4.1%; 13 | --primary: 262.1 83.3% 57.8%; 14 | --primary-foreground: 210 20% 98%; 15 | --secondary: 220 14.3% 95.9%; 16 | --secondary-foreground: 220.9 39.3% 11%; 17 | --muted: 220 14.3% 95.9%; 18 | --muted-foreground: 220 8.9% 46.1%; 19 | --accent: 220 14.3% 95.9%; 20 | --accent-foreground: 220.9 39.3% 11%; 21 | --destructive: 0 84.2% 60.2%; 22 | --destructive-foreground: 210 20% 98%; 23 | --border: 220 13% 91%; 24 | --input: 220 13% 91%; 25 | --ring: 262.1 83.3% 57.8%; 26 | --radius: 0.75rem; 27 | --chart-1: 12 76% 61%; 28 | --chart-2: 173 58% 39%; 29 | --chart-3: 197 37% 24%; 30 | --chart-4: 43 74% 66%; 31 | --chart-5: 27 87% 67%; 32 | } 33 | 34 | .dark { 35 | --background: 224 71.4% 4.1%; 36 | --foreground: 210 20% 98%; 37 | --card: 224 71.4% 4.1%; 38 | --card-foreground: 210 20% 98%; 39 | --popover: 224 71.4% 4.1%; 40 | --popover-foreground: 210 20% 98%; 41 | --primary: 263.4 70% 50.4%; 42 | --primary-foreground: 210 20% 98%; 43 | --secondary: 215 27.9% 16.9%; 44 | --secondary-foreground: 210 20% 98%; 45 | --muted: 215 27.9% 16.9%; 46 | --muted-foreground: 217.9 10.6% 64.9%; 47 | --accent: 215 27.9% 16.9%; 48 | --accent-foreground: 210 20% 98%; 49 | --destructive: 0 62.8% 30.6%; 50 | --destructive-foreground: 210 20% 98%; 51 | --border: 215 27.9% 16.9%; 52 | --input: 215 27.9% 16.9%; 53 | --ring: 263.4 70% 50.4%; 54 | --chart-1: 220 70% 50%; 55 | --chart-2: 160 60% 45%; 56 | --chart-3: 30 80% 55%; 57 | --chart-4: 280 65% 60%; 58 | --chart-5: 340 75% 55%; 59 | } 60 | } 61 | 62 | @layer base { 63 | * { 64 | @apply border-border; 65 | } 66 | body { 67 | @apply bg-background text-foreground; 68 | } 69 | } 70 | 71 | .button-example { 72 | @apply inline-flex h-10 items-center justify-center whitespace-nowrap rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground ring-offset-background transition-colors hover:bg-primary/90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50; 73 | } 74 | 75 | .link-example { 76 | @apply min-w-0 flex-1 basis-1/3 px-4 py-2 text-center text-secondary-foreground underline hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground; 77 | } 78 | 79 | .button-example-secondary { 80 | @apply inline-flex h-10 items-center justify-center whitespace-nowrap rounded-md bg-secondary px-4 py-2 text-sm font-medium text-secondary-foreground ring-offset-background transition-colors hover:bg-secondary/80 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50; 81 | } 82 | .input-example { 83 | @apply h-9 rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring md:text-sm; 84 | } 85 | 86 | .hide-example-button { 87 | display: none !important; 88 | } 89 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | // export everything from both entry points to create a single t.ds file 2 | export { FluidFlexbox } from "./react/FluidFlexbox"; 3 | export { FlexWrapDetectorElement } from "./dom/FlexWrapDetectorElement"; 4 | -------------------------------------------------------------------------------- /src/lib/utils.ts: -------------------------------------------------------------------------------- 1 | import { clsx, type ClassValue } from "clsx" 2 | import { twMerge } from "tailwind-merge" 3 | 4 | export function cn(...inputs: ClassValue[]) { 5 | return twMerge(clsx(inputs)) 6 | } 7 | -------------------------------------------------------------------------------- /src/main.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import ReactDOM from "react-dom/client"; 3 | import App from "./App.tsx"; 4 | import "./index.css"; 5 | import "./dom/FlexWrapDetectorElement.ts"; 6 | 7 | ReactDOM.createRoot(document.getElementById("react-root")!).render( 8 | 9 | 10 | , 11 | ); 12 | -------------------------------------------------------------------------------- /src/react/FluidFlexbox.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import React, { 4 | HTMLAttributes, 5 | ReactNode, 6 | createContext, 7 | useCallback, 8 | useContext, 9 | useEffect, 10 | useRef, 11 | useState, 12 | } from "react"; 13 | import useResizeObserver from "use-resize-observer"; 14 | import { throttle } from "../utils/throttle"; 15 | 16 | const THROTTLE_TIME = 0; 17 | 18 | export interface FluidFlexboxProps 19 | extends Omit, "children"> { 20 | throttleTime?: number; 21 | children?: React.ReactNode | ((wrapped: boolean) => ReactNode); 22 | wrappedClass?: string; 23 | wrappedStyle?: React.CSSProperties; 24 | removeClassWhenWrapped?: boolean; 25 | hidden?: boolean; 26 | containerClassName?: string; 27 | containerStyle?: React.CSSProperties; 28 | } 29 | 30 | export interface FluidFlexboxContext { 31 | isWrapped: boolean; 32 | } 33 | 34 | const FluidFlexBoxContext = createContext({ 35 | isWrapped: false, 36 | }); 37 | 38 | export function useFluidFlexboxWrapped() { 39 | const { isWrapped } = useContext(FluidFlexBoxContext); 40 | return isWrapped; 41 | } 42 | 43 | export function FluidFlexbox({ 44 | children, 45 | throttleTime = THROTTLE_TIME, 46 | style, 47 | className, 48 | wrappedClass, 49 | wrappedStyle, 50 | containerClassName, 51 | containerStyle, 52 | hidden, 53 | removeClassWhenWrapped, 54 | ...rest 55 | }: FluidFlexboxProps) { 56 | const [isWrappedState, setIsWrapped] = useState(false); 57 | // locked is used to prevent infinite loops 58 | const [locked, setLocked] = useState(false); 59 | const isWrapped = locked ? true : isWrappedState; 60 | 61 | // use refs instead of state to avoid re-rendering 62 | const nonWrappingElHeight = useRef(0); 63 | const wrappingElHeight = useRef(0); 64 | 65 | const onNowrapResize = useCallback( 66 | throttle(({ height }: { height: number | undefined }) => { 67 | nonWrappingElHeight.current = height || 0; 68 | requestAnimationFrame(() => checkIfWrapping()); 69 | }, throttleTime), 70 | [throttleTime], 71 | ); 72 | 73 | const onWrappingResize = useCallback( 74 | throttle(({ height }: { height: number | undefined }) => { 75 | wrappingElHeight.current = height || 0; 76 | requestAnimationFrame(() => checkIfWrapping()); 77 | }, throttleTime), 78 | [throttleTime], 79 | ); 80 | 81 | const onContainerResize = useCallback( 82 | throttle(() => { 83 | requestAnimationFrame(() => checkIfWrapping()); 84 | }, throttleTime), 85 | [throttleTime], 86 | ); 87 | 88 | const { ref: notWrappingCopyRefCb } = useResizeObserver({ 89 | onResize: onNowrapResize, 90 | }); 91 | const { ref: wrappingCopyRefCb } = useResizeObserver({ 92 | onResize: onWrappingResize, 93 | }); 94 | const { ref: containerRef } = useResizeObserver({ 95 | onResize: onContainerResize, 96 | }); 97 | 98 | const notWrappingCopyRef = useRef(null); 99 | const wrappingCopyRef = useRef(null); 100 | 101 | const checkIfWrapping = useCallback(() => { 102 | if ( 103 | nonWrappingElHeight.current === wrappingElHeight.current && 104 | notWrappingCopyRef.current && 105 | wrappingCopyRef.current 106 | ) { 107 | const childCount = notWrappingCopyRef.current.childElementCount; 108 | // go in reverse order for a slight optimization 109 | for (let childIdx = childCount - 1; childIdx >= 0; childIdx -= 1) { 110 | const nonWrappingChild = notWrappingCopyRef.current.children[ 111 | childIdx 112 | ] as HTMLElement; 113 | const wrappingChild = wrappingCopyRef.current.children[ 114 | childIdx 115 | ] as HTMLElement; 116 | if (nonWrappingChild.offsetTop !== wrappingChild.offsetTop) { 117 | setIsWrapped(true); 118 | return; 119 | } 120 | } 121 | setIsWrapped(false); 122 | } 123 | 124 | setIsWrapped(nonWrappingElHeight.current !== wrappingElHeight.current); 125 | }, []); 126 | 127 | // inject global styles for styling children 128 | useEffect(() => { 129 | const styleId = "fluid-flexbox-style-rules"; 130 | if (document?.getElementById(styleId)) return; 131 | const styleEl = document.createElement("style"); 132 | styleEl.id = styleId; 133 | // first rule is needed to make sure the children of the non-wrapping 134 | // hidden clone are not expanding the container horizontally 135 | // (without it the text inside might start wrapping to the next line) 136 | // second rule is similar, but it's needed to prevent fouc i.e. content wrapping 137 | // before the smaller content is rendered by react and visible 138 | styleEl.innerHTML = ` 139 | [data-fluid-flexbox="invisible-non-wrapping"] > div > * { 140 | flex-shrink: 0 !important; 141 | } 142 | [data-fluid-flexbox="visible-not-wrapped"] > div > * { 143 | flex-shrink: 0 !important; 144 | } 145 | `; 146 | document.head.appendChild(styleEl); 147 | }, []); 148 | 149 | // infinite loop prevention 150 | // lock down changes if re-rendered more 151 | // than 7 times in 300ms 152 | const renderCountRef = useRef(0); 153 | const lastResetTimeRef = useRef(Date.now()); 154 | const timeWindow = 300; 155 | const threshold = 7; 156 | useEffect(() => { 157 | const now = Date.now(); 158 | if (now - lastResetTimeRef.current > timeWindow) { 159 | renderCountRef.current = 1; 160 | lastResetTimeRef.current = now; 161 | setLocked(false); 162 | } else { 163 | renderCountRef.current += 1; 164 | if (renderCountRef.current > threshold) { 165 | console.warn( 166 | "FluidFlexbox infinite loop detected. This is likely not what you want. " + 167 | "More details: https://github.com/arturmarc/fluid-flexbox#infinite-loops", 168 | ); 169 | setLocked(true); 170 | setIsWrapped(true); 171 | } 172 | } 173 | }); 174 | 175 | const baseStyle = { 176 | ...style, 177 | display: "flex", 178 | }; 179 | const invisibleStyle = { 180 | ...baseStyle, 181 | flexDirection: "row" as const, 182 | visibility: "hidden" as const, 183 | }; 184 | 185 | let computedClassName = isWrapped && removeClassWhenWrapped ? "" : className; 186 | if (wrappedClass && isWrapped) { 187 | computedClassName = `${computedClassName} ${wrappedClass}`.trim(); 188 | } 189 | 190 | // just to make sure some inherited styles are not applied 191 | const styleSizeReset = { 192 | padding: 0, 193 | margin: 0, 194 | }; 195 | 196 | return ( 197 | 311 | ); 312 | } 313 | -------------------------------------------------------------------------------- /src/usage/ExampleSelector.tsx: -------------------------------------------------------------------------------- 1 | import { ChevronsUpDownIcon } from "lucide-react"; 2 | import { 3 | ReactElement, 4 | useEffect, 5 | useLayoutEffect, 6 | useMemo, 7 | useState, 8 | } from "react"; 9 | import { 10 | DropdownMenu, 11 | DropdownMenuContent, 12 | DropdownMenuRadioGroup, 13 | DropdownMenuRadioItem, 14 | DropdownMenuTrigger, 15 | } from "../components/ui/dropdown-menu"; 16 | import { AdaptingContentExampleCE } from "./custom-element-react-examples/AdaptingContentExampleCE"; 17 | import { BasicUsageExampleCE } from "./custom-element-react-examples/BasicUsageExampleCE"; 18 | import { ConditionallyNestedExampleCE } from "./custom-element-react-examples/ConditionallyNestedExampleCE"; 19 | import { DeepNestingExampleCE } from "./custom-element-react-examples/DeepNestingExampleCE"; 20 | import { DynamicContentExampleCE } from "./custom-element-react-examples/DynamicContentExampleCE"; 21 | import { InfiniteLoopCE } from "./custom-element-react-examples/InfiniteLoopCE"; 22 | import { NestedExampleCE } from "./custom-element-react-examples/NestedExampleCE"; 23 | import { OrderAndReverseCE } from "./custom-element-react-examples/OrderAndReverseCE"; 24 | import { SingleChildExampleCE } from "./custom-element-react-examples/SingleChildExampleCE"; 25 | import { AdaptingContentExample } from "./examples/AdaptingContentExample"; 26 | import { AllPropsShowcaseExample } from "./examples/AllPropsShowcaseExample"; 27 | import { BasicUsageExample } from "./examples/BasicUsageExample"; 28 | import { ConditionallyNestedExample } from "./examples/ConditionallyNestedExample"; 29 | import { DeepNestingExample } from "./examples/DeepNestingExample"; 30 | import { HolyGrailToolbarExample } from "./examples/HolyGrailToolbarExample"; 31 | import { InfiniteLoop } from "./examples/InfiniteLoop"; 32 | import { NestedExample } from "./examples/NestedExample"; 33 | import { OrderAndReverse } from "./examples/OrderAndReverse"; 34 | import { SingleChildExample } from "./examples/SingleChildExample"; 35 | import { SmallerHeight } from "./examples/SmallerHeight"; 36 | import adaptingContentMutatingHtml from "./html-examples/adapting-content-mutating.html?raw"; 37 | import adaptingContentHtml from "./html-examples/adapting-content.html?raw"; 38 | import basicExampleHtml from "./html-examples/basic-usage.html?raw"; 39 | import conditionallyNestedHtml from "./html-examples/conditionally-nested.html?raw"; 40 | import deepNestingHtml from "./html-examples/deep-nesting.html?raw"; 41 | import dynamicContentHtml from "./html-examples/dynamic-content.html?raw"; 42 | import holyGrailHtml from "./html-examples/holy-grail.html?raw"; 43 | import singleChildHtml from "./html-examples/single-child.html?raw"; 44 | import twoLevelsNestingHtml from "./html-examples/two-levels-nesting.html?raw"; 45 | import { Resizer } from "./Resizer"; 46 | 47 | export function ExampleSelector() { 48 | const [reactExample, setReactExample] = useState("Basic Usage"); 49 | const [htmlExample, setHtmlExample] = useState("Basic Usage"); 50 | 51 | // show all examples / including internal ones that are more like 52 | // test and don't really showcase anything 53 | const [showAll, setShowAll] = useState(false); 54 | const [showCEReact, setShowCEReact] = useState(false); 55 | 56 | useEffect(() => { 57 | const searchParams = new URLSearchParams(window.location.search); 58 | setShowAll(searchParams.has("show-all")); 59 | setShowCEReact(searchParams.has("show-ce")); 60 | if (searchParams.has("show-ce")) { 61 | setReactExample("CE Basic Usage"); 62 | } 63 | }, []); 64 | 65 | let reactDemoExamples: Map = useMemo( 66 | () => 67 | new Map([ 68 | ["Basic Usage", ], 69 | ["Adapting content", ], 70 | ["Two levels of nesting", ], 71 | ["Conditionally nested", ], 72 | ["Deep nesting", ], 73 | ["Single child", ], 74 | ["Showcase other props usage", ], 75 | ['"Holy Grail" toolbar', ], 76 | ]), 77 | [], 78 | ); 79 | 80 | const allReactExamples = useMemo( 81 | () => 82 | new Map([ 83 | ...reactDemoExamples.entries(), 84 | // test examples 85 | ["Order and reverse", ], 86 | ["Infinite Loop", ], 87 | ["Smaller Height", ], 88 | ]), 89 | [], 90 | ); 91 | 92 | // custom element rendered using react 93 | const CEUsingReactExamples = useMemo( 94 | () => 95 | new Map([ 96 | ["CE Basic Usage", ], 97 | ["CE Dynamic Content", ], 98 | ["CE Adapting content", ], 99 | ["CE Two levels of nesting", ], 100 | ["CE Conditionally nested", ], 101 | ["CE Deep nesting", ], 102 | ["CE Single child", ], 103 | ["CE Order and reverse", ], 104 | ["CE Infinite Loop", ], 105 | ]), 106 | [], 107 | ); 108 | 109 | let reactExamples = showCEReact 110 | ? CEUsingReactExamples 111 | : showAll 112 | ? allReactExamples 113 | : reactDemoExamples; 114 | 115 | const htmlDemoExamples: Map = useMemo( 116 | () => 117 | new Map([ 118 | ["Basic Usage", basicExampleHtml], 119 | ["Adapting content", adaptingContentHtml], 120 | ["Adapting content by mutating", adaptingContentMutatingHtml], 121 | ["Two levels of nesting", twoLevelsNestingHtml], 122 | ["Conditionally nested", conditionallyNestedHtml], 123 | ["Deep nesting", deepNestingHtml], 124 | ["Single child", singleChildHtml], 125 | ['"Holy Grail" toolbar', holyGrailHtml], 126 | ["Dynamic content pitfalls", dynamicContentHtml], 127 | ]), 128 | [], 129 | ); 130 | 131 | let htmlExamples = htmlDemoExamples; 132 | 133 | useLayoutEffect(() => { 134 | // run any scripts that might be in the current html example 135 | const scriptEls = document.querySelectorAll( 136 | "#html-example-container script", 137 | ); 138 | scriptEls.forEach((el) => { 139 | const script = document.createElement("script"); 140 | script.textContent = el.textContent; 141 | script.type = "module"; 142 | el.parentElement?.replaceChild(script, el); 143 | }); 144 | }, [htmlExample]); 145 | 146 | return ( 147 |
148 |
149 | 150 | 151 |
152 | React Example: 153 | {reactExample} 154 | 155 |
156 |
157 | e.preventDefault()}> 158 | 162 | {[...reactExamples.keys()].map((example) => ( 163 | 164 | {example} 165 | 166 | ))} 167 | 168 | 169 |
170 |
171 | {reactExamples.get(reactExample)} 172 | 173 |
174 | 175 | 176 |
177 | HTML Example: 178 | {htmlExample} 179 | 180 |
181 |
182 | e.preventDefault()}> 183 | 187 | {[...htmlExamples.keys()].map((example) => ( 188 | 189 | {example} 190 | 191 | ))} 192 | 193 | 194 |
195 |
196 | 197 |
203 | 204 |
205 | ); 206 | } 207 | -------------------------------------------------------------------------------- /src/usage/FlexWrapDetector.tsx: -------------------------------------------------------------------------------- 1 | import { useRef, useEffect } from "react"; 2 | import { FlexWrapDetectorElement } from "../dom/FlexWrapDetectorElement"; 3 | 4 | export function FlexWrapDetector( 5 | props: React.DetailedHTMLProps< 6 | React.HTMLAttributes, 7 | FlexWrapDetectorElement 8 | > & { 9 | setWrappedContent?: (e: HTMLElement) => void; 10 | class?: string; 11 | }, 12 | ) { 13 | const detectorRef = useRef(null); 14 | const { setWrappedContent, ...rest } = props; 15 | 16 | useEffect(() => { 17 | if (!setWrappedContent) return; 18 | 19 | const handleSetWrappedContent = ((e: CustomEvent) => { 20 | setWrappedContent?.(e.detail.element); 21 | }) as EventListener; // todo remove the need to do this 22 | 23 | if (detectorRef.current) { 24 | detectorRef.current.addEventListener( 25 | "set-wrapped-content", 26 | handleSetWrappedContent, 27 | ); 28 | } 29 | return () => { 30 | if (detectorRef.current) { 31 | detectorRef.current.removeEventListener( 32 | "set-wrapped-content", 33 | handleSetWrappedContent, 34 | ); 35 | } 36 | }; 37 | }, [detectorRef, setWrappedContent]); 38 | 39 | return ; 40 | } 41 | -------------------------------------------------------------------------------- /src/usage/Resizer.tsx: -------------------------------------------------------------------------------- 1 | import { ArrowUpIcon } from "lucide-react"; 2 | 3 | export function Resizer({ children }: { children?: React.ReactNode }) { 4 | return ( 5 |
6 |
7 |
{children}
8 |
9 |
10 | Resize here 11 |
12 |
13 | ); 14 | } 15 | -------------------------------------------------------------------------------- /src/usage/custom-element-react-examples/AdaptingContentExampleCE.tsx: -------------------------------------------------------------------------------- 1 | import { Button } from "../../components/ui/button"; 2 | import { FlexWrapDetector } from "../FlexWrapDetector"; 3 | 4 | export function AdaptingContentExampleCE() { 5 | return ( 6 | { 9 | const child = el.querySelector("#to-remove"); 10 | if (child) child.textContent = "Wrapped"; 11 | }} 12 | > 13 |
14 | 15 | 16 | 17 |
18 |
19 | ); 20 | } 21 | -------------------------------------------------------------------------------- /src/usage/custom-element-react-examples/BasicUsageExampleCE.tsx: -------------------------------------------------------------------------------- 1 | import { Button } from "../../components/ui/button"; 2 | import { FlexWrapDetector } from "../FlexWrapDetector"; 3 | 4 | export function BasicUsageExampleCE() { 5 | return ( 6 | 7 |
8 | 9 | 10 | 11 |
12 |
13 | ); 14 | } 15 | -------------------------------------------------------------------------------- /src/usage/custom-element-react-examples/ConditionallyNestedExampleCE.tsx: -------------------------------------------------------------------------------- 1 | import { BookIcon, FileIcon, PanelBottomIcon } from "lucide-react"; 2 | import { Button } from "../../components/ui/button"; 3 | import { FlexWrapDetector } from "../FlexWrapDetector"; 4 | 5 | export function ConditionallyNestedExampleCE() { 6 | const contentWhenWidest = ( 7 | <> 8 | 9 | 10 | 11 | 12 | ); 13 | const contentWhenNarrower = ( 14 | <> 15 | 16 | 17 | 18 | 19 | ); 20 | const narrowestContent = ( 21 | <> 22 | 25 | 28 | 31 | 32 | ); 33 | return ( 34 | 35 |
{contentWhenWidest}
36 |
37 | 38 |
{contentWhenNarrower}
39 |
40 | {narrowestContent} 41 |
42 |
43 |
44 |
45 | ); 46 | } 47 | -------------------------------------------------------------------------------- /src/usage/custom-element-react-examples/DeepNestingExampleCE.tsx: -------------------------------------------------------------------------------- 1 | import { PlusIcon, TrashIcon, XIcon } from "lucide-react"; 2 | import { Button } from "../../components/ui/button"; 3 | import { FlexWrapDetector } from "../FlexWrapDetector"; 4 | 5 | export function DeepNestingExampleCE() { 6 | // can be used for all levels of nesting 7 | // hide text and show icon 8 | const handleSetWrappedContent = (el: HTMLElement) => { 9 | const butTxt = el.querySelector("button > span") as HTMLElement | null; 10 | if (butTxt) { 11 | butTxt.style.display = "none"; 12 | } 13 | const butIcon = el.querySelector("button > svg") as HTMLElement | null; 14 | if (butIcon) { 15 | butIcon.style.display = "block"; 16 | } 17 | }; 18 | 19 | return ( 20 | 25 |
26 | 30 | 35 |
36 | 40 | 41 |
42 | 46 |
47 |
48 |
49 |
50 |
51 |
52 |
53 | ); 54 | } 55 | -------------------------------------------------------------------------------- /src/usage/custom-element-react-examples/DynamicContentExampleCE.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Button } from "../../components/ui/button"; 3 | import { FlexWrapDetector } from "../FlexWrapDetector"; 4 | 5 | export function DynamicContentExampleCE() { 6 | const [altTxt, setAltTxt] = React.useState(false); 7 | 8 | return ( 9 | 10 |
11 | 14 | 15 |
16 |
17 | ); 18 | } 19 | -------------------------------------------------------------------------------- /src/usage/custom-element-react-examples/InfiniteLoopCE.tsx: -------------------------------------------------------------------------------- 1 | import { Button } from "../../components/ui/button"; 2 | import { FlexWrapDetector } from "../FlexWrapDetector"; 3 | 4 | export function InfiniteLoopCE() { 5 | return ( 6 |
7 |
Inside a flex container
8 | { 11 | const toChange = el.querySelector("#to_change"); 12 | if (toChange) { 13 | toChange.innerHTML = "THIRD EXPANDED"; 14 | } 15 | }} 16 | > 17 |
18 | 19 | 20 | 21 |
22 |
23 |
24 | ); 25 | } 26 | -------------------------------------------------------------------------------- /src/usage/custom-element-react-examples/NestedExampleCE.tsx: -------------------------------------------------------------------------------- 1 | import { Button } from "../../components/ui/button"; 2 | import { FlexWrapDetector } from "../FlexWrapDetector"; 3 | 4 | export function NestedExampleCE() { 5 | return ( 6 | 12 |
13 | 18 |
19 | 22 | 23 |
24 |
25 | 31 |
32 | 33 | 34 |
35 |
36 |
37 |
38 | ); 39 | } 40 | -------------------------------------------------------------------------------- /src/usage/custom-element-react-examples/OrderAndReverseCE.tsx: -------------------------------------------------------------------------------- 1 | import { Button } from "../../components/ui/button"; 2 | import { FlexWrapDetector } from "../FlexWrapDetector"; 3 | 4 | export function OrderAndReverseCE() { 5 | return ( 6 | 7 |
8 | 9 | 10 | 11 |
12 |
13 | ); 14 | } 15 | -------------------------------------------------------------------------------- /src/usage/custom-element-react-examples/SingleChildExampleCE.tsx: -------------------------------------------------------------------------------- 1 | import { StopCircle } from "lucide-react"; 2 | import { Button } from "../../components/ui/button"; 3 | import { FlexWrapDetector } from "../FlexWrapDetector"; 4 | 5 | export function SingleChildExampleCE() { 6 | return ( 7 | 8 |
9 | 10 |
11 |
12 |
13 | 16 |
17 |
18 | ); 19 | } 20 | -------------------------------------------------------------------------------- /src/usage/examples/AdaptingContentExample.tsx: -------------------------------------------------------------------------------- 1 | import { Button } from "../../components/ui/button"; 2 | import { FluidFlexbox, useFluidFlexboxWrapped } from "../../react/FluidFlexbox"; 3 | 4 | export function AdaptingContentExample() { 5 | return ( 6 |
7 | 8 | {(isWrapped) => ( 9 | <> 10 | 11 | 12 | {!isWrapped && } 13 | 14 | )} 15 | 16 | 17 |
18 | ); 19 | } 20 | 21 | function Buttons() { 22 | const isWrapped = useFluidFlexboxWrapped(); 23 | return ( 24 | <> 25 | 26 | 27 | {!isWrapped && } 28 | 29 | ); 30 | } 31 | 32 | function Toolbar() { 33 | return ( 34 | 35 | 36 | 37 | ); 38 | } 39 | -------------------------------------------------------------------------------- /src/usage/examples/AllPropsShowcaseExample.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect } from "react"; 2 | import { Button } from "../../components/ui/button"; 3 | import { FluidFlexbox } from "../../react/FluidFlexbox"; 4 | 5 | export function AllPropsShowcaseExample() { 6 | useEffect(() => { 7 | const styleId = "example-style-rules"; 8 | if (document.getElementById(styleId)) return; 9 | const styleEl = document.createElement("style"); 10 | styleEl.id = styleId; 11 | styleEl.innerHTML = ` 12 | .redundant-class-to-remove { 13 | border: 2px red solid; 14 | background-color: yellow; 15 | } 16 | `; 17 | document.head.appendChild(styleEl); 18 | }, []); 19 | 20 | return ( 21 | 29 | 30 | 31 | 32 | 35 | 36 | ); 37 | } 38 | -------------------------------------------------------------------------------- /src/usage/examples/BasicUsageExample.tsx: -------------------------------------------------------------------------------- 1 | import { Button } from "../../components/ui/button"; 2 | import { FluidFlexbox } from "../../react/FluidFlexbox"; 3 | 4 | export function BasicUsageExample() { 5 | return ( 6 | 7 | 8 | 9 | 10 | 11 | ); 12 | } 13 | -------------------------------------------------------------------------------- /src/usage/examples/ConditionallyNestedExample.tsx: -------------------------------------------------------------------------------- 1 | import { BookIcon, FileIcon, PanelBottomIcon } from "lucide-react"; 2 | import { Button } from "../../components/ui/button"; 3 | import { FluidFlexbox } from "../../react/FluidFlexbox"; 4 | 5 | export function ConditionallyNestedExample() { 6 | const contentWhenWidest = ( 7 | <> 8 | 9 | 10 | 11 | 12 | ); 13 | const contentWhenNarrower = ( 14 | <> 15 | 16 | 17 | 18 | 19 | ); 20 | const narrowestContent = ( 21 | <> 22 | 25 | 28 | 31 | 32 | ); 33 | return ( 34 | 35 | {(isWidestWrapped) => 36 | !isWidestWrapped ? ( 37 | contentWhenWidest 38 | ) : ( 39 | 40 | {(isNarrowerWrapped) => 41 | !isNarrowerWrapped ? contentWhenNarrower : narrowestContent 42 | } 43 | 44 | ) 45 | } 46 | 47 | ); 48 | } 49 | -------------------------------------------------------------------------------- /src/usage/examples/DeepNestingExample.tsx: -------------------------------------------------------------------------------- 1 | import { PlusIcon, TrashIcon, XIcon } from "lucide-react"; 2 | import { Button } from "../../components/ui/button"; 3 | import { FluidFlexbox } from "../../react/FluidFlexbox"; 4 | 5 | export function DeepNestingExample() { 6 | return ( 7 | 8 | {(outerIsWrapped) => ( 9 | <> 10 | 11 | 12 | {(innerIsWrapped) => ( 13 | <> 14 | 17 | 18 | {(innermostIsWrapped) => ( 19 | <> 20 | 27 |
28 | 29 | )} 30 |
31 | 32 | )} 33 |
34 | 35 | )} 36 |
37 | ); 38 | } 39 | -------------------------------------------------------------------------------- /src/usage/examples/HolyGrailToolbarExample.tsx: -------------------------------------------------------------------------------- 1 | import { Menu } from "lucide-react"; 2 | import { FluidFlexbox } from "../../react/FluidFlexbox"; 3 | 4 | export function HolyGrailToolbarExample() { 5 | return ( 6 | 7 | {(isOuterWrapped) => ( 8 | <> 9 | 10 | {(isInnerWrapped) => ( 11 | <> 12 | {isInnerWrapped && ( 13 |
14 | 15 |
16 | )} 17 | {!isInnerWrapped && ( 18 |
21 |
22 | Home 23 | Gallery 24 | Blog 25 |
26 |
27 | FAQ 28 | Contact 29 | About 30 |
31 |
32 | )} 33 | {isOuterWrapped &&
} 34 | 35 | )} 36 |
37 |
38 | 39 | )} 40 |
41 | ); 42 | } 43 | 44 | function Item({ children }: { children: React.ReactNode }) { 45 | return ( 46 | e.preventDefault()} 50 | > 51 | {children} 52 | 53 | ); 54 | } 55 | -------------------------------------------------------------------------------- /src/usage/examples/InfiniteLoop.tsx: -------------------------------------------------------------------------------- 1 | import { Button } from "../../components/ui/button"; 2 | import { FluidFlexbox } from "../../react/FluidFlexbox"; 3 | 4 | export function InfiniteLoop() { 5 | return ( 6 |
7 |
Inside a flex container
8 | 9 | {(isWrapped) => ( 10 | <> 11 | 12 | 13 | 14 | 15 | )} 16 | 17 |
18 | ); 19 | } 20 | -------------------------------------------------------------------------------- /src/usage/examples/NestedExample.tsx: -------------------------------------------------------------------------------- 1 | import { Button } from "../../components/ui/button"; 2 | import { FluidFlexbox } from "../../react/FluidFlexbox"; 3 | 4 | export function NestedExample() { 5 | return ( 6 | 11 | {(isWrapped) => ( 12 | <> 13 | 18 | 21 | 22 | 23 | 28 | 29 | 30 | 31 | 32 | )} 33 | 34 | ); 35 | } 36 | -------------------------------------------------------------------------------- /src/usage/examples/OrderAndReverse.tsx: -------------------------------------------------------------------------------- 1 | import { Button } from "../../components/ui/button"; 2 | import { FluidFlexbox } from "../../react/FluidFlexbox"; 3 | 4 | export function OrderAndReverse() { 5 | return ( 6 | 10 | 11 | 12 | 13 | 14 | ); 15 | } 16 | -------------------------------------------------------------------------------- /src/usage/examples/SingleChildExample.tsx: -------------------------------------------------------------------------------- 1 | import { StopCircle } from "lucide-react"; 2 | import { Button } from "../../components/ui/button"; 3 | import { FluidFlexbox } from "../../react/FluidFlexbox"; 4 | 5 | export function SingleChildExample() { 6 | return ( 7 | 8 | {(isWrapped) => ( 9 | <> 10 | 11 |
12 | 13 | )} 14 |
15 | ); 16 | } 17 | -------------------------------------------------------------------------------- /src/usage/examples/SmallerHeight.tsx: -------------------------------------------------------------------------------- 1 | import { StopCircle } from "lucide-react"; 2 | import { FluidFlexbox } from "../../react/FluidFlexbox"; 3 | 4 | export function SmallerHeight() { 5 | return ( 6 | 7 | {(isWrapped) => ( 8 | <> 9 |
10 | {isWrapped ? ( 11 | 12 | ) : ( 13 | <> 14 |

Multi line long text example

15 |

Multi line

16 |

Multi line

17 | 18 | )} 19 |
20 |
21 | 22 | )} 23 |
24 | ); 25 | } 26 | -------------------------------------------------------------------------------- /src/usage/html-examples/adapting-content-mutating.html: -------------------------------------------------------------------------------- 1 | 2 |
3 |
Input's
4 | 5 |
Removable
6 |
7 |
8 | -------------------------------------------------------------------------------- /src/usage/html-examples/adapting-content.html: -------------------------------------------------------------------------------- 1 | 2 |
3 |
Remove
4 |
Extra
5 |
Button
6 |
7 |
8 |
Remove
9 |
Extra
10 |
11 |
-------------------------------------------------------------------------------- /src/usage/html-examples/basic-usage.html: -------------------------------------------------------------------------------- 1 | 2 |
3 |
First
4 |
Second
5 |
Third
6 |
7 |
-------------------------------------------------------------------------------- /src/usage/html-examples/conditionally-nested.html: -------------------------------------------------------------------------------- 1 | 2 |
3 |
Longer
4 |
Button
5 |
Labels
6 |
7 |
8 | 9 |
10 |
Shrt
11 |
But
12 |
Lbl
13 |
14 |
15 |
x
16 |
+
17 |
-
18 |
19 |
20 |
21 |
-------------------------------------------------------------------------------- /src/usage/html-examples/deep-nesting.html: -------------------------------------------------------------------------------- 1 | 2 |
3 |
Close
4 |
New
5 |
Delete
6 |
7 |
8 |
X
9 | 10 |
11 |
New
12 |
Delete
13 |
14 |
15 |
+
16 | 17 |
18 |
Delete
19 |
20 |
21 |
22 |
-
23 |
24 |
25 |
26 |
27 |
28 |
-------------------------------------------------------------------------------- /src/usage/html-examples/dynamic-content.html: -------------------------------------------------------------------------------- 1 |
2 |
3 | 4 |
5 |
Dynamic content
6 |
breaks
7 |
8 |
9 |
10 | 13 |
Toggle when collapsed to see the wrapped class still applied even when one button fits
14 |
15 |
16 | 17 |
18 | 19 |
20 |
Dynamic content
21 |
easy fix
22 |
23 |
24 |
Dynamic content
25 |
easy fix
26 |
27 |
28 |
29 | 33 |
Now when collapsed hiding the extra button works as expected
34 |
35 |
36 | 37 |
38 | 39 |
40 |
Dynamic content
41 |
involved fix
42 |
43 |
44 |
45 | 48 |
Now when collapsed hiding the extra button works as expected
49 |
50 | 61 |
62 |
-------------------------------------------------------------------------------- /src/usage/html-examples/holy-grail.html: -------------------------------------------------------------------------------- 1 | 2 |
3 |
4 | Home 5 | Gallery 6 | Blog 7 | FAQ 8 | Contact 9 | About 10 |
11 |
12 |
13 |
14 | 15 |
16 |
17 |
18 | Home 19 | Gallery 20 | Blog 21 |
22 |
23 | FAQ 24 | Contact 25 | About 26 |
27 |
28 |
29 |
30 |
31 | 32 | 33 | 34 | 35 | 36 |
37 |
38 |
39 |
-------------------------------------------------------------------------------- /src/usage/html-examples/single-child.html: -------------------------------------------------------------------------------- 1 | 2 |
3 |
Long label
4 |
5 |
6 |
7 |
lbl
8 |
9 |
-------------------------------------------------------------------------------- /src/usage/html-examples/two-levels-nesting.html: -------------------------------------------------------------------------------- 1 | 2 |
3 |
First
4 |
Second
5 |
Third
6 |
Fourth
7 |
8 | 9 |
10 | 11 |
12 |
Wrapped
13 |
Second
14 |
15 |
16 | 17 |
18 |
Third
19 |
Fourth
20 |
21 |
22 |
23 |
-------------------------------------------------------------------------------- /src/utils/throttle.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-explicit-any */ 2 | 3 | // Simple throttle function - throttled function does not return a result 4 | export function throttle any>( 5 | fn: T, 6 | delay: number, 7 | ) { 8 | let timeout: NodeJS.Timeout | null = null; 9 | return (...args: Parameters) => { 10 | if (timeout) { 11 | clearTimeout(timeout); 12 | } 13 | timeout = setTimeout(() => { 14 | fn(...args); 15 | }, delay); 16 | }; 17 | } 18 | -------------------------------------------------------------------------------- /src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | export default { 3 | darkMode: ["class"], 4 | content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}"], 5 | theme: { 6 | extend: { 7 | animation: { 8 | "fade-in": "fade-in 0.5s ease-in-out", 9 | }, 10 | keyframes: { 11 | "fade-in": { 12 | "0%": { 13 | opacity: "0", 14 | }, 15 | "100%": { 16 | opacity: "1", 17 | }, 18 | }, 19 | }, 20 | borderRadius: { 21 | lg: "var(--radius)", 22 | md: "calc(var(--radius) - 2px)", 23 | sm: "calc(var(--radius) - 4px)", 24 | }, 25 | colors: { 26 | background: "hsl(var(--background))", 27 | foreground: "hsl(var(--foreground))", 28 | card: { 29 | DEFAULT: "hsl(var(--card))", 30 | foreground: "hsl(var(--card-foreground))", 31 | }, 32 | popover: { 33 | DEFAULT: "hsl(var(--popover))", 34 | foreground: "hsl(var(--popover-foreground))", 35 | }, 36 | primary: { 37 | DEFAULT: "hsl(var(--primary))", 38 | foreground: "hsl(var(--primary-foreground))", 39 | }, 40 | secondary: { 41 | DEFAULT: "hsl(var(--secondary))", 42 | foreground: "hsl(var(--secondary-foreground))", 43 | }, 44 | muted: { 45 | DEFAULT: "hsl(var(--muted))", 46 | foreground: "hsl(var(--muted-foreground))", 47 | }, 48 | accent: { 49 | DEFAULT: "hsl(var(--accent))", 50 | foreground: "hsl(var(--accent-foreground))", 51 | }, 52 | destructive: { 53 | DEFAULT: "hsl(var(--destructive))", 54 | foreground: "hsl(var(--destructive-foreground))", 55 | }, 56 | border: "hsl(var(--border))", 57 | input: "hsl(var(--input))", 58 | ring: "hsl(var(--ring))", 59 | chart: { 60 | 1: "hsl(var(--chart-1))", 61 | 2: "hsl(var(--chart-2))", 62 | 3: "hsl(var(--chart-3))", 63 | 4: "hsl(var(--chart-4))", 64 | 5: "hsl(var(--chart-5))", 65 | }, 66 | }, 67 | }, 68 | }, 69 | plugins: [require("tailwindcss-animate")], 70 | }; 71 | -------------------------------------------------------------------------------- /tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "include": ["src/index.ts"], 4 | "compilerOptions": { 5 | "declaration": true, // Generates .d.ts files 6 | "declarationDir": "./dist", // Where to put the .d.ts files 7 | "emitDeclarationOnly": true, // Only emit .d.ts files (no JS files) 8 | "noEmit": false 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020", 4 | "useDefineForClassFields": true, 5 | "lib": ["ES2020", "DOM", "DOM.Iterable"], 6 | "module": "ESNext", 7 | "skipLibCheck": true, 8 | 9 | /* Bundler mode */ 10 | "moduleResolution": "bundler", 11 | "allowImportingTsExtensions": true, 12 | "resolveJsonModule": true, 13 | "isolatedModules": true, 14 | "noEmit": true, 15 | "jsx": "react-jsx", 16 | 17 | /* Linting */ 18 | "strict": true, 19 | "noUnusedLocals": true, 20 | "noUnusedParameters": true, 21 | "noFallthroughCasesInSwitch": true, 22 | 23 | /* Module Resolution */ 24 | "baseUrl": ".", 25 | "paths": { 26 | "@/*": ["./src/*"] 27 | } 28 | }, 29 | "include": ["src", "FlexWrapDetectorElement.d.ts"], 30 | "references": [{ "path": "./tsconfig.node.json" }] 31 | } 32 | -------------------------------------------------------------------------------- /tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "composite": true, 4 | "skipLibCheck": true, 5 | "module": "ESNext", 6 | "moduleResolution": "bundler", 7 | "allowSyntheticDefaultImports": true, 8 | "strict": true 9 | }, 10 | "include": ["vite.config.ts"] 11 | } 12 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import path from "path"; 2 | import { defineConfig } from "vite"; 3 | import react from "@vitejs/plugin-react-swc"; 4 | import preserveDirectives from "rollup-preserve-directives"; 5 | 6 | // https://vitejs.dev/config/ 7 | export default defineConfig({ 8 | plugins: [react(), preserveDirectives()], 9 | resolve: { 10 | alias: { 11 | "@": path.resolve(__dirname, "./src"), 12 | }, 13 | }, 14 | build: { 15 | copyPublicDir: false, 16 | lib: { 17 | entry: [ 18 | path.resolve(__dirname, "src/react/FluidFlexbox.tsx"), 19 | path.resolve(__dirname, "src/dom/FlexWrapDetectorElement.ts"), 20 | ], 21 | fileName: (format, entryAlias) => { 22 | if (entryAlias === "FluidFlexbox") { 23 | return `fluid-flexbox.${format}.js`; 24 | } 25 | return `flex-wrap-detector.${format}.js`; 26 | }, 27 | }, 28 | minify: false, 29 | rollupOptions: { 30 | external: ["react", "react-dom"], 31 | }, 32 | }, 33 | }); 34 | -------------------------------------------------------------------------------- /vite.web-comonenet-build.config.ts: -------------------------------------------------------------------------------- 1 | import path from "path"; 2 | import { defineConfig } from "vite"; 3 | 4 | export default defineConfig({ 5 | build: { 6 | copyPublicDir: false, 7 | lib: { 8 | entry: [path.resolve(__dirname, "src/dom/FlexWrapDetectorElement.ts")], 9 | formats: ["umd"], 10 | name: "FlexWrapDetector", 11 | fileName: () => "flex-wrap-detector.umd.js", 12 | }, 13 | minify: false, 14 | outDir: "dist/web", 15 | emptyOutDir: false, 16 | }, 17 | }); 18 | --------------------------------------------------------------------------------