├── .github └── FUNDING.yml ├── .gitignore ├── .prettierignore ├── LICENSE ├── README.md ├── eslint.config.js ├── package.json ├── pnpm-lock.yaml ├── prettier.config.js ├── src ├── Alignment.tsx ├── Gap.tsx ├── Types.d.ts ├── __tests__ │ ├── index.native.test.tsx │ └── index.test.tsx ├── index.native.tsx └── index.tsx ├── tsconfig.json ├── tsdown.config.js └── vitest.config.ts /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: cpojer 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .eslintcache 2 | .pnpm-debug.log 3 | /lib/ 4 | /node_modules/ 5 | tsconfig.tsbuildinfo 6 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | lib 2 | node_modules 3 | pnpm-lock.yaml 4 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2025 Nakazawa Tech 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 | # `@nkzw/stack` 2 | 3 | _Zero-dependency, type-safe Stack component for streamlining flexbox usage in React & React Native._ 4 | 5 | You always end up with visual clutter and boilerplate when using flexbox, no matter which styling solution you choose. `` supports all flexbox properties directly as named props, making your flexbox components visually cleaner and easier to read. For most use cases, you'll only need prop shorthands like `gap`, `vertical`, `center`, and `padding`: 6 | 7 | ```tsx 8 | import Stack from '@nkzw/stack'; 9 | 10 | 11 |
Apple
// Use on web 12 | Banana // Use in React Native 13 | Kiwi 14 |
; 15 | ``` 16 | 17 | Other benefits include: 18 | 19 | - **Minimal API:** Easily control direction, spacing, alignment, and more using shorthand props. 20 | - **Sensible Defaults:** Ships with sensible cross-platform defaults for React & React Native. 21 | - **Consistent spacing:** Enforces 4px grid for gap & padding values via TypeScript. 22 | - **Flexible padding:** Automatically derives padding from gap or accepts custom values. 23 | - **Polymorphic by design:** Render as any HTML or custom component via the `as` prop while maintaining full type safety. 24 | 25 | ## Installation 26 | 27 | ```bash 28 | npm install @nkzw/stack 29 | ``` 30 | 31 | ## Flexbox Defaults 32 | 33 | Using a `` component without any props renders it like this: 34 | 35 | ```tsx 36 |
44 | ``` 45 | 46 | Unlike the default flexbox styles on web, `justify-content` is using `flex-start` instead of `start`. Flexbox on web and on React Natie use different default values for `flexDirection`, `alignContent` and `flexShrink`. `` uses these defaults for both platforms: 47 | 48 | - `flexDirection: 'row'` 49 | - `alignContent: 'flex-start'` 50 | 51 | `flexShrink` is set to `1` by default on web and `0` on React Native. 52 | 53 | ## Props 54 | 55 | Props for ` are meant to be mostly static and passed as shorthand only, without any values. 56 | 57 | ```tsx 58 | type StackProps = { 59 | alignCenter?: boolean; // `align-items: center` 60 | alignEnd?: boolean; // `align-items: flex-end` 61 | alignStart?: boolean; // `align-items: center` 62 | around?: boolean; // `justify-content: space-around` 63 | as: ElementType; // The element to render, defaults to `
` on the web, and `` on React Native. 64 | baseline?: boolean; // `align-items: baseline` 65 | between?: boolean; // `justify-content: space-between` 66 | center?: boolean; // `justify-content: center` 67 | children?: ReactNode; 68 | columnGap?: Gap; // `column-gap: ` 69 | content?: AlignContent; // `align-content: start | end | center | stretch | space-between | space-around | space-evenly` 70 | end?: boolean; // `justify-content: flex-end` 71 | evenly?: boolean; // `justify-content: space-evenly` 72 | flex1?: boolean; // `flex: 1` 73 | gap?: Gap; // `gap: ` 74 | horizontalPadding?: Gap; // `padding-left` and `padding-right` set to the same value as `gap` when boolean or a value. 75 | inline?: boolean; // `display: inline-flex` (*web only*) 76 | padding?: Gap; // `padding: ` or `padding: 8px` when `gap` is `true`. 77 | reverse?: boolean; // `flex-direction: row-reverse` or `flex-direction: column-reverse` when `vertical` is `true`. 78 | rowGap?: Gap; // `row-gap: ` 79 | safe?: boolean; // When used with `center` or `end`: `justify-content: safe center | safe flex-end` 80 | self?: AlignSelf; // `align-self: center | end | start | stretch | baseline` 81 | shrink0?: boolean; // `flex-shrink: 0` 82 | stretch?: boolean; // `flex-grow: 1` 83 | vertical?: boolean; // `flex-direction: column` 84 | verticalPadding?: Gap; // `padding-top` and `padding-bottom` set to the same value as `gap` when true or a value. 85 | wrap?: boolean; // `flex-wrap: wrap` 86 | }; 87 | ``` 88 | 89 | ## Usage 90 | 91 | ### Vertical Layout and Gap 92 | 93 | ```tsx 94 | import Stack from '@nkzw/stack'; 95 | 96 | // Horizontal layout (default). 97 | 98 |
Apple
99 |
Banana
100 |
Kiwi
101 |
102 | 103 | // Vertical layout. 104 | 105 |
Apple
106 |
Banana
107 |
Kiwi
108 |
109 | 110 | // Using boolean gap (uses default gap of 8px). 111 | 112 |
Apple
113 |
Banana
114 |
115 | ``` 116 | 117 | Or with React Native: 118 | 119 | ```tsx 120 | import Stack from '@nkzw/stack/native'; 121 | 122 | 123 | Apple 124 | Banana 125 | Kiwi 126 | ; 127 | ``` 128 | 129 | If you are using NativeWind, `` will automatically support `className`: 130 | 131 | ```tsx 132 | 133 | Apple 134 | Banana 135 | Kiwi 136 | 137 | ``` 138 | 139 | There is also a shorthand for vertical stacks: 140 | 141 | ```tsx 142 | import { VStack } from '@nkzw/stack'; 143 | 144 | // Same as ``. 145 |
Apple
146 |
Banana
147 |
Kiwi
148 |
; 149 | ``` 150 | 151 | ### Padding 152 | 153 | ```tsx 154 | // Add padding equal to the gap. 155 | 156 |
Content
157 |
158 | 159 | // Custom padding value. 160 | 161 |
Content
162 |
163 | 164 | // Padding using the default gap value. 165 | 166 |
Content
167 |
168 | ``` 169 | 170 | ## Advanced Gap Control 171 | 172 | ### Row and Column Gaps 173 | 174 | ```tsx 175 | // Different gaps for rows and columns. 176 | 177 |
Apple
178 |
Banana
179 |
Kiwi
180 |
Item 4
181 |
182 | 183 | // Vertical layout with row gap. 184 | 185 |
Apple
186 |
Banana
187 |
188 | ``` 189 | 190 | ### Directional Padding 191 | 192 | ```tsx 193 | // Vertical padding only. 194 | 195 |
Content
196 |
197 | 198 | // Horizontal padding only. 199 | 200 |
Content
201 |
202 | 203 | // Custom directional padding. 204 | 205 |
Content
206 |
207 | 208 | // Mix with gaps. 209 | 210 |
Content
211 |
212 | ``` 213 | 214 | ## Justification (Main Axis) 215 | 216 | ```tsx 217 | // Center items. 218 | 219 |
Centered
220 |
221 | 222 | // Align to end. 223 | 224 |
At end
225 |
226 | 227 | // Space around items. 228 | 229 |
Apple
230 |
Banana
231 |
232 | 233 | // Align between. 234 | 235 |
Between
236 |
237 | 238 | // Space evenly. 239 | 240 |
Apple
241 |
Banana
242 |
243 | 244 | // Space between (default). 245 | 246 |
Apple
247 |
Banana
248 |
249 | ``` 250 | 251 | On web, the `safe` prop is supported too: 252 | 253 | ```tsx 254 | // Renders with `justify-content: safe center`. 255 | 256 |
Centered
257 |
258 | 259 | // Renders with `justify-content: safe flex-end`. 260 | 261 |
At end
262 |
263 | ``` 264 | 265 | ## Alignment (Cross Axis) 266 | 267 | ```tsx 268 | // Center align items. 269 | 270 |
Centered
271 |
272 | 273 | // Align to start. 274 | 275 |
At start
276 |
277 | 278 | // Align to end. 279 | 280 |
At end
281 |
282 | 283 | // Baseline alignment. 284 | 285 |
Small
286 |
Large
287 |
288 | ``` 289 | 290 | ## Flex Properties 291 | 292 | ```tsx 293 | // Take all available space. 294 | 295 |
Full width/height
296 |
297 | 298 | // Grow to fill container. 299 | 300 |
Stretched
301 |
302 | 303 | // Prevent shrinking. 304 | 305 |
Won't shrink
306 |
307 | ``` 308 | 309 | ## Self Alignment 310 | 311 | ```tsx 312 | // Align self to center. 313 | 314 |
Self-centered
315 |
316 | 317 | // Other self alignment options. 318 | 319 |
Self at start
320 |
321 | 322 | 323 |
Self at end
324 |
325 | 326 | 327 |
Self stretched
328 |
329 | ``` 330 | 331 | ## Content Alignment 332 | 333 | ```tsx 334 | 335 |
Content
336 |
337 | 338 | 339 |
Content
340 |
341 | ``` 342 | 343 | ## Layout Options 344 | 345 | ```tsx 346 | // Inline flex. 347 | 348 |
Inline flex
349 |
350 | 351 | // Reverse direction. 352 | 353 |
Apple (appears last)
354 |
Banana (appears first)
355 |
356 | 357 | // Vertical reverse. 358 | 359 |
Apple (appears at bottom)
360 |
Banana (appears at top)
361 |
362 | 363 | // Wrap (`flex-wrap: nowrap` is the default). 364 | 365 |
Apple
366 |
Banana
367 |
Kiwi
368 |
369 | ``` 370 | 371 | ## Gap Values 372 | 373 | Available gap values: `1`, `2`, `4`, `8`, `12`, `16`, `20`, `24`, `28`, `32`, `36`, `40`, `44`, `48`, or `true` for default. 374 | 375 | You can set the default gap value globally: 376 | 377 | ```tsx 378 | import { setDefaultGap } from '@nkzw/stack'; 379 | 380 | setDefaultGap(12); // Now gap={true} will use 12px. 381 | ``` 382 | 383 | ## Render as different elements or custom commponents 384 | 385 | ```tsx 386 | 387 |
Content
388 |
389 | ``` 390 | 391 | Stack carries over component props from custom components in a type-safe manner: 392 | 393 | ```tsx 394 | 395 | Home 396 | About 397 | 398 | ``` 399 | 400 | ## Examples 401 | 402 | ### Card Layout 403 | 404 | ```tsx 405 | 411 |

Card Title

412 |

Card content goes here

413 | 414 | 415 | 416 | 417 |
418 | ``` 419 | 420 | ### Navigation Bar 421 | 422 | ```tsx 423 | 424 |

Logo

425 | 426 | Home 427 | About 428 | Contact 429 | 430 | 431 |
432 | ``` 433 | 434 | ### Form Layout 435 | 436 | ```tsx 437 | 438 | 439 | 440 | 441 | 442 | 443 | 444 | 445 | 446 | 447 | 448 | 449 | 450 | 451 | ``` 452 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | import nkzw from '@nkzw/eslint-config'; 2 | 3 | export default [...nkzw, { ignores: ['lib/**/*'] }]; 4 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@nkzw/stack", 3 | "version": "2.1.1", 4 | "description": "Zero-dependency, type-safe Stack component for streamlining flexbox usage in React & React Native.", 5 | "homepage": "https://github.com/nkzw-tech/stack", 6 | "repository": { 7 | "type": "git", 8 | "url": "https://github.com/nkzw-tech/stack" 9 | }, 10 | "license": "MIT", 11 | "author": { 12 | "name": "Christoph Nakazawa", 13 | "email": "christoph.pojer@gmail.com" 14 | }, 15 | "type": "module", 16 | "exports": { 17 | ".": { 18 | "react-native": "./lib/index.native.js", 19 | "default": "./lib/index.js" 20 | } 21 | }, 22 | "main": "./lib/index.js", 23 | "types": "./lib/index.d.ts", 24 | "files": [ 25 | "lib" 26 | ], 27 | "scripts": { 28 | "build": "mkdir -p lib && tsdown --clean --platform=browser src/index.tsx && tsdown --no-clean src/index.native.tsx", 29 | "format": "prettier --experimental-cli --write .", 30 | "lint": "eslint --cache .", 31 | "lint:format": "prettier --experimental-cli --cache --check .", 32 | "test": "npm-run-all --parallel tsc:check lint lint:format vitest:run", 33 | "tsc:check": "tsc", 34 | "vitest:run": "vitest run" 35 | }, 36 | "devDependencies": { 37 | "@ianvs/prettier-plugin-sort-imports": "^4.4.2", 38 | "@nkzw/eslint-config": "^3.0.1", 39 | "@prettier/plugin-oxc": "^0.0.4", 40 | "@testing-library/react": "^16.3.0", 41 | "@types/node": "^24.0.3", 42 | "@types/react": "^19.1.8", 43 | "@typescript-eslint/eslint-plugin": "^8.35.0", 44 | "@typescript-eslint/parser": "^8.35.0", 45 | "@vitejs/plugin-react": "^4.6.0", 46 | "eslint": "^9.29.0", 47 | "happy-dom": "^18.0.1", 48 | "nativewind": "^4.1.23", 49 | "npm-run-all2": "^8.0.4", 50 | "prettier": "^3.6.0", 51 | "prettier-plugin-packagejson": "^2.5.15", 52 | "react": "^19.1.0", 53 | "react-native": "^0.80.0", 54 | "tsdown": "^0.12.8", 55 | "typescript": "^5.8.3", 56 | "vitest": "^3.2.4" 57 | }, 58 | "peerDependencies": { 59 | "nativewind": "^4.1.23", 60 | "react": ">=17.0.0", 61 | "react-native": ">=0.67.0" 62 | }, 63 | "peerDependenciesMeta": { 64 | "nativewind": { 65 | "optional": true 66 | }, 67 | "react-native": { 68 | "optional": true 69 | } 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /prettier.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | plugins: [ 3 | '@prettier/plugin-oxc', 4 | '@ianvs/prettier-plugin-sort-imports', 5 | 'prettier-plugin-packagejson', 6 | ], 7 | singleQuote: true, 8 | }; 9 | -------------------------------------------------------------------------------- /src/Alignment.tsx: -------------------------------------------------------------------------------- 1 | import { AlignContent, AlignSelf } from './Types.js'; 2 | 3 | export function resolveAlignment< 4 | T extends AlignContent | AlignSelf, 5 | S extends Exclude | 'flex-start' | 'flex-end' | undefined, 6 | >(value: T | undefined): S { 7 | return (value === 'start' 8 | ? 'flex-start' 9 | : value === 'end' 10 | ? 'flex-end' 11 | : value) as unknown as S; 12 | } 13 | -------------------------------------------------------------------------------- /src/Gap.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * Spacing scale used for layout gaps and paddings. 3 | * Values represent pixel-based increments (e.g., 8 = 8px). 4 | * Use `true` to apply a the default gap (8px). 5 | * You can set the default gap using `setDefaultGap()`. 6 | */ 7 | export type Gap = 8 | | 0 9 | | 1 10 | | 2 11 | | 4 12 | | 8 13 | | 12 14 | | 16 15 | | 20 16 | | 24 17 | | 28 18 | | 32 19 | | 36 20 | | 40 21 | | 44 22 | | 48 23 | | true; 24 | 25 | let defaultGap = 8; 26 | 27 | export function setDefaultGap(gap: number) { 28 | defaultGap = gap; 29 | } 30 | 31 | export function resolveGap(gap: Gap | undefined) { 32 | return gap === true ? defaultGap : gap; 33 | } 34 | -------------------------------------------------------------------------------- /src/Types.d.ts: -------------------------------------------------------------------------------- 1 | import { ElementType, ComponentProps, ReactNode } from 'react'; 2 | import { Gap } from './Gap.tsx'; 3 | 4 | export type AlignContent = 5 | /** Items packed to the start of the container */ 6 | | 'start' 7 | /** Items packed to the end of the container */ 8 | | 'end' 9 | /** Items centered along the container */ 10 | | 'center' 11 | /** Items stretched to fill the container */ 12 | | 'stretch' 13 | /** Items spaced evenly with first and last item at edges */ 14 | | 'space-between' 15 | /** Items spaced with equal space around each */ 16 | | 'space-around' 17 | /** Items spaced with equal space between and around */ 18 | | 'space-evenly'; 19 | 20 | export type AlignSelf = 21 | /** Aligns this element to the center of the cross axis. */ 22 | | 'center' 23 | /** Aligns this element to the end of the cross axis. */ 24 | | 'end' 25 | /** Aligns this element to the start of the cross axis. */ 26 | | 'start' 27 | /** Stretches this element to fill the container along the cross axis. */ 28 | | 'stretch' 29 | /** Aligns this element based on its text baseline. */ 30 | | 'baseline'; 31 | 32 | export type StackPropsInternal = { 33 | /** Aligns items to the center of the cross axis (`align-items: center`). */ 34 | alignCenter?: boolean; 35 | /** Aligns items to the end of the cross axis (`align-items: flex-end`). */ 36 | alignEnd?: boolean; 37 | /** Aligns items to the start of the cross axis (`align-items: flex-start`). */ 38 | alignStart?: boolean; 39 | /** Distributes space evenly around items (`justify-content: space-around`). */ 40 | around?: boolean; 41 | /** Aligns items based on their text baselines (`align-items: baseline`). */ 42 | baseline?: boolean; 43 | /** Distributes items with space between them (`justify-content: space-between`). */ 44 | between?: boolean; 45 | /** Centers items along the main axis (`justify-content: center`). */ 46 | center?: boolean; 47 | children?: ReactNode; 48 | /** Sets spacing column gap elements (`column-gap: `). Accepts 0–48px in steps of 4, or `true` to apply a default of 8px. */ 49 | columnGap?: Gap; 50 | /** Defines how multiple rows align along the cross axis (`align-content: ...`). */ 51 | content?: AlignContent; 52 | /** Aligns items to the end of the main axis (`justify-content: flex-end`). */ 53 | end?: boolean; 54 | /** Evenly distributes items with equal space between and around (`justify-content: space-evenly`). */ 55 | evenly?: boolean; 56 | /** Sets `flex: 1` to allow the container to grow and fill available space. */ 57 | flex1?: boolean; 58 | /** Sets spacing between elements (`gap: `). Accepts 0–48px in steps of 4, or `true` to apply a default of 8px. */ 59 | gap?: Gap; 60 | /** Applies left and right padding (`padding-left: ; padding-right: `). Accepts 0–48px in steps of 4, or `true` to apply a default of 8px. */ 61 | horizontalPadding?: Gap; 62 | /** Displays as `inline-flex` instead of `flex` (web only). */ 63 | inline?: boolean; 64 | /** Applies padding (`padding: `). Accepts 0–48px in steps of 4, or `true` to apply a default of 8px. */ 65 | padding?: Gap; 66 | /** Reverses the order of items along the main axis, respects `vertical` setting. */ 67 | reverse?: boolean; 68 | /** Sets spacing row gap elements (`row-gap: `). Accepts 0–48px in steps of 4, or `true` to apply a default of 8px. */ 69 | rowGap?: Gap; 70 | /** Adds `safe` zone alignment to `center` or `end` (`justify-content: safe ...`). */ 71 | safe?: boolean; 72 | /** Controls individual item alignment (`align-self: ...`). */ 73 | self?: AlignSelf; 74 | /** Prevents items from shrinking (`flex-shrink: 0`). */ 75 | shrink0?: boolean; 76 | /** Enables stretching to fill remaining space (`flex-grow: 1`). */ 77 | stretch?: boolean; 78 | /** Stacks items vertically (`flex-direction: column`). */ 79 | vertical?: boolean; 80 | /** Applies top and bottom padding (`padding-top: ; padding-bottom: `). Accepts 0–48px in steps of 4, or `true` to apply a default of 8px. */ 81 | verticalPadding?: Gap; 82 | /** Enables wrapping of items onto multiple lines (`flex-wrap: wrap`). */ 83 | wrap?: boolean; 84 | }; 85 | 86 | export type AcceptsStyle = 87 | 'style' extends keyof ComponentProps ? C : never; 88 | 89 | export type AsProp = { 90 | /** The element to render, defaults to `
` on the web, and `` on React Native. */ 91 | as?: Component; 92 | }; 93 | 94 | export type PropsToOmit< 95 | Component extends ElementType, 96 | Props, 97 | > = keyof (AsProp & Props); 98 | -------------------------------------------------------------------------------- /src/__tests__/index.native.test.tsx: -------------------------------------------------------------------------------- 1 | import { beforeEach, expect, vi, test } from 'vitest'; 2 | import { render } from '@testing-library/react'; 3 | import Stack, { setDefaultGap } from '../index.native.tsx'; 4 | import { createElement } from 'react'; 5 | import { StyleProp, View, ViewProps, ViewStyle } from 'react-native'; 6 | 7 | const flattenStyle = (style: StyleProp) => 8 | Array.isArray(style) 9 | ? // @ts-expect-error: We only care about one level of styles. 10 | style.reduce((object, item) => ({ ...object, ...item }), {}) 11 | : style; 12 | 13 | vi.mock('react-native', () => { 14 | return { 15 | View: ({ children, style, ...props }: ViewProps) => 16 | createElement('view', { ...props, style: flattenStyle(style) }, children), 17 | }; 18 | }); 19 | 20 | beforeEach(() => { 21 | setDefaultGap(8); 22 | }); 23 | 24 | test('renders with default props', () => { 25 | const { container } = render(Content); 26 | expect(container.firstChild).toMatchInlineSnapshot(` 27 | 30 | Content 31 | 32 | `); 33 | }); 34 | 35 | test('renders with gap', () => { 36 | const { container } = render(Content); 37 | expect(container.firstChild).toMatchInlineSnapshot(` 38 | 41 | Content 42 | 43 | `); 44 | }); 45 | 46 | test('renders with gap=true (uses default)', () => { 47 | const { container } = render(Content); 48 | expect(container.firstChild).toMatchInlineSnapshot(` 49 | 52 | Content 53 | 54 | `); 55 | }); 56 | 57 | test('renders with vertical layout', () => { 58 | const { container } = render(Content); 59 | expect(container.firstChild).toMatchInlineSnapshot(` 60 | 63 | Content 64 | 65 | `); 66 | }); 67 | 68 | test('renders with reverse layout', () => { 69 | const { container } = render(Content); 70 | expect(container.firstChild).toMatchInlineSnapshot(` 71 | 74 | Content 75 | 76 | `); 77 | }); 78 | 79 | test('renders with vertical reverse layout', () => { 80 | const { container } = render( 81 | 82 | Content 83 | , 84 | ); 85 | expect(container.firstChild).toMatchInlineSnapshot(` 86 | 89 | Content 90 | 91 | `); 92 | }); 93 | 94 | test('renders with inline display', () => { 95 | const { container } = render(Content); 96 | expect(container.firstChild).toMatchInlineSnapshot(` 97 | 100 | Content 101 | 102 | `); 103 | }); 104 | 105 | test('renders with wrap', () => { 106 | const { container } = render(Content); 107 | expect(container.firstChild).toMatchInlineSnapshot(` 108 | 111 | Content 112 | 113 | `); 114 | }); 115 | 116 | test('renders with alignStart', () => { 117 | const { container } = render(Content); 118 | expect(container.firstChild).toMatchInlineSnapshot(` 119 | 122 | Content 123 | 124 | `); 125 | }); 126 | 127 | test('renders with alignCenter', () => { 128 | const { container } = render(Content); 129 | expect(container.firstChild).toMatchInlineSnapshot(` 130 | 133 | Content 134 | 135 | `); 136 | }); 137 | 138 | test('renders with alignEnd', () => { 139 | const { container } = render(Content); 140 | expect(container.firstChild).toMatchInlineSnapshot(` 141 | 144 | Content 145 | 146 | `); 147 | }); 148 | 149 | test('renders with center justification', () => { 150 | const { container } = render(Content); 151 | expect(container.firstChild).toMatchInlineSnapshot(` 152 | 155 | Content 156 | 157 | `); 158 | }); 159 | 160 | test('renders with between justification', () => { 161 | const { container } = render(Content); 162 | expect(container.firstChild).toMatchInlineSnapshot(` 163 | 166 | Content 167 | 168 | `); 169 | }); 170 | 171 | test('renders with end justification', () => { 172 | const { container } = render(Content); 173 | expect(container.firstChild).toMatchInlineSnapshot(` 174 | 177 | Content 178 | 179 | `); 180 | }); 181 | 182 | test('renders with flex1', () => { 183 | const { container } = render(Content); 184 | expect(container.firstChild).toMatchInlineSnapshot(` 185 | 188 | Content 189 | 190 | `); 191 | }); 192 | 193 | test('renders with stretch', () => { 194 | const { container } = render(Content); 195 | expect(container.firstChild).toMatchInlineSnapshot(` 196 | 199 | Content 200 | 201 | `); 202 | }); 203 | 204 | test('renders with self alignment start', () => { 205 | const { container } = render(Content); 206 | expect(container.firstChild).toMatchInlineSnapshot(` 207 | 210 | Content 211 | 212 | `); 213 | }); 214 | 215 | test('renders with self alignment center', () => { 216 | const { container } = render(Content); 217 | expect(container.firstChild).toMatchInlineSnapshot(` 218 | 221 | Content 222 | 223 | `); 224 | }); 225 | 226 | test('renders with padding and gap', () => { 227 | const { container } = render( 228 | 229 | Content 230 | , 231 | ); 232 | expect(container.firstChild).toMatchInlineSnapshot(` 233 | 236 | Content 237 | 238 | `); 239 | }); 240 | 241 | test('renders with verticalPadding and gap', () => { 242 | const { container } = render( 243 | 244 | Content 245 | , 246 | ); 247 | expect(container.firstChild).toMatchInlineSnapshot(` 248 | 251 | Content 252 | 253 | `); 254 | }); 255 | 256 | test('renders with horizontalPadding and gap', () => { 257 | const { container } = render( 258 | 259 | Content 260 | , 261 | ); 262 | expect(container.firstChild).toMatchInlineSnapshot(` 263 | 266 | Content 267 | 268 | `); 269 | }); 270 | 271 | test('renders with custom style', () => { 272 | const { container } = render( 273 | Content, 274 | ); 275 | expect(container.firstChild).toMatchInlineSnapshot(` 276 | 279 | Content 280 | 281 | `); 282 | }); 283 | 284 | test('renders with className', () => { 285 | const { container } = render(Content); 286 | 287 | expect(container.firstChild).toMatchInlineSnapshot(` 288 | 292 | Content 293 | 294 | `); 295 | }); 296 | 297 | test('renders with multiple props combined', () => { 298 | const { container } = render( 299 | 300 | Content 301 | , 302 | ); 303 | expect(container.firstChild).toMatchInlineSnapshot(` 304 | 307 | Content 308 | 309 | `); 310 | }); 311 | 312 | test('passes through native attributes', () => { 313 | const { container } = render( 314 | 315 | Content 316 | , 317 | ); 318 | expect(container.firstChild).toMatchInlineSnapshot(` 319 | 323 | Content 324 | 325 | `); 326 | }); 327 | 328 | test('changes default gap when gap=true', () => { 329 | setDefaultGap(24); 330 | const { container } = render(Content); 331 | expect(container.firstChild).toMatchInlineSnapshot(` 332 | 335 | Content 336 | 337 | `); 338 | }); 339 | 340 | test('supports shrink0', () => { 341 | const { container } = render(Content); 342 | expect(container.firstChild).toMatchInlineSnapshot(` 343 | 346 | Content 347 | 348 | `); 349 | }); 350 | 351 | test('renders with around justification', () => { 352 | const { container } = render(Content); 353 | expect(container.firstChild).toMatchInlineSnapshot(` 354 | 357 | Content 358 | 359 | `); 360 | }); 361 | 362 | test('renders with evenly justification', () => { 363 | const { container } = render(Content); 364 | expect(container.firstChild).toMatchInlineSnapshot(` 365 | 368 | Content 369 | 370 | `); 371 | }); 372 | 373 | test('renders with baseline alignment', () => { 374 | const { container } = render(Content); 375 | expect(container.firstChild).toMatchInlineSnapshot(` 376 | 379 | Content 380 | 381 | `); 382 | }); 383 | 384 | test('renders with rowGap only', () => { 385 | const { container } = render(Content); 386 | expect(container.firstChild).toMatchInlineSnapshot(` 387 | 390 | Content 391 | 392 | `); 393 | }); 394 | 395 | test('renders with columnGap only', () => { 396 | const { container } = render(Content); 397 | expect(container.firstChild).toMatchInlineSnapshot(` 398 | 401 | Content 402 | 403 | `); 404 | }); 405 | 406 | test('renders with both rowGap and columnGap', () => { 407 | const { container } = render( 408 | 409 | Content 410 | , 411 | ); 412 | expect(container.firstChild).toMatchInlineSnapshot(` 413 | 416 | Content 417 | 418 | `); 419 | }); 420 | 421 | test('prefers rowGap/columnGap over gap when both are provided', () => { 422 | const { container } = render( 423 | 424 | Content 425 | , 426 | ); 427 | expect(container.firstChild).toMatchInlineSnapshot(` 428 | 431 | Content 432 | 433 | `); 434 | }); 435 | 436 | test('renders with verticalPadding=true using gap value', () => { 437 | const { container } = render( 438 | 439 | Content 440 | , 441 | ); 442 | expect(container.firstChild).toMatchInlineSnapshot(` 443 | 446 | Content 447 | 448 | `); 449 | }); 450 | 451 | test('renders with horizontalPadding=true using gap value', () => { 452 | const { container } = render( 453 | 454 | Content 455 | , 456 | ); 457 | expect(container.firstChild).toMatchInlineSnapshot(` 458 | 461 | Content 462 | 463 | `); 464 | }); 465 | 466 | test('renders with verticalPadding=true using rowGap value', () => { 467 | const { container } = render( 468 | 469 | Content 470 | , 471 | ); 472 | expect(container.firstChild).toMatchInlineSnapshot(` 473 | 476 | Content 477 | 478 | `); 479 | }); 480 | 481 | test('renders with horizontalPadding=true using columnGap value', () => { 482 | const { container } = render( 483 | 484 | Content 485 | , 486 | ); 487 | expect(container.firstChild).toMatchInlineSnapshot(` 488 | 491 | Content 492 | 493 | `); 494 | }); 495 | 496 | test('renders with specific verticalPadding value', () => { 497 | const { container } = render( 498 | 499 | Content 500 | , 501 | ); 502 | expect(container.firstChild).toMatchInlineSnapshot(` 503 | 506 | Content 507 | 508 | `); 509 | }); 510 | 511 | test('renders with specific horizontalPadding value', () => { 512 | const { container } = render( 513 | 514 | Content 515 | , 516 | ); 517 | expect(container.firstChild).toMatchInlineSnapshot(` 518 | 521 | Content 522 | 523 | `); 524 | }); 525 | 526 | test('renders with padding=true overriding other padding props', () => { 527 | const { container } = render( 528 | 529 | Content 530 | , 531 | ); 532 | expect(container.firstChild).toMatchInlineSnapshot(` 533 | 536 | Content 537 | 538 | `); 539 | }); 540 | 541 | test('renders with padding numeric value overriding other padding props', () => { 542 | const { container } = render( 543 | 544 | Content 545 | , 546 | ); 547 | expect(container.firstChild).toMatchInlineSnapshot(` 548 | 551 | Content 552 | 553 | `); 554 | }); 555 | 556 | test('renders with complex gap and padding combination', () => { 557 | const { container } = render( 558 | 559 | Content 560 | , 561 | ); 562 | expect(container.firstChild).toMatchInlineSnapshot(` 563 | 566 | Content 567 | 568 | `); 569 | }); 570 | 571 | test('it renders with custom component', () => { 572 | const { container } = render(Content); 573 | 574 | expect(container.firstChild).toMatchInlineSnapshot(` 575 | 578 | Content 579 | 580 | `); 581 | }); 582 | 583 | test('it works with custom Link component', () => { 584 | const Link = ({ 585 | children, 586 | title, 587 | ...props 588 | }: { 589 | children: React.ReactNode; 590 | style?: ViewStyle; 591 | title: string; 592 | }) => ( 593 | 594 | {title} 595 | {children} 596 | 597 | ); 598 | 599 | const { container } = render( 600 | 601 | Content 602 | , 603 | ); 604 | 605 | expect(container.firstChild).toMatchInlineSnapshot(` 606 | 609 | Banana 610 | Content 611 | 612 | `); 613 | }); 614 | 615 | test('does not work if the component does not have a style prop', () => { 616 | const NoStyle = ({ 617 | children, 618 | title, 619 | }: { 620 | children: React.ReactNode; 621 | title: string; 622 | }) => ( 623 | 624 | {title} 625 | {children} 626 | 627 | ); 628 | 629 | const { container } = render( 630 | // @ts-expect-error: NoStyle does not accept style prop. 631 | 632 | Content 633 | , 634 | ); 635 | 636 | expect(container.firstChild).toMatchInlineSnapshot( 637 | ` 638 | 639 | Banana 640 | Content 641 | 642 | `, 643 | ); 644 | }); 645 | 646 | test('supports the `ref` prop', () => { 647 | const ref = { current: null }; 648 | const { container } = render(Content); 649 | 650 | expect(container.firstChild).toMatchInlineSnapshot(` 651 | 654 | Content 655 | 656 | `); 657 | }); 658 | -------------------------------------------------------------------------------- /src/__tests__/index.test.tsx: -------------------------------------------------------------------------------- 1 | import { beforeEach, expect, test } from 'vitest'; 2 | import { render } from '@testing-library/react'; 3 | import Stack, { setDefaultGap, VStack } from '../index.tsx'; 4 | import { CSSProperties } from 'react'; 5 | 6 | beforeEach(() => { 7 | setDefaultGap(8); 8 | }); 9 | 10 | test('renders with default props', () => { 11 | const { container } = render(Content); 12 | expect(container.firstChild).toMatchInlineSnapshot(` 13 |
16 | Content 17 |
18 | `); 19 | }); 20 | 21 | test('renders with gap', () => { 22 | const { container } = render(Content); 23 | expect(container.firstChild).toMatchInlineSnapshot(` 24 |
27 | Content 28 |
29 | `); 30 | }); 31 | 32 | test('renders with gap=true (uses default)', () => { 33 | const { container } = render(Content); 34 | expect(container.firstChild).toMatchInlineSnapshot(` 35 |
38 | Content 39 |
40 | `); 41 | }); 42 | 43 | test('renders with vertical layout', () => { 44 | const { container } = render(Content); 45 | expect(container.firstChild).toMatchInlineSnapshot(` 46 |
49 | Content 50 |
51 | `); 52 | }); 53 | 54 | test('renders with reverse layout', () => { 55 | const { container } = render(Content); 56 | expect(container.firstChild).toMatchInlineSnapshot(` 57 |
60 | Content 61 |
62 | `); 63 | }); 64 | 65 | test('renders with vertical reverse layout', () => { 66 | const { container } = render( 67 | 68 | Content 69 | , 70 | ); 71 | expect(container.firstChild).toMatchInlineSnapshot(` 72 |
75 | Content 76 |
77 | `); 78 | }); 79 | 80 | test('renders with inline display', () => { 81 | const { container } = render(Content); 82 | expect(container.firstChild).toMatchInlineSnapshot(` 83 |
86 | Content 87 |
88 | `); 89 | }); 90 | 91 | test('renders with wrap', () => { 92 | const { container } = render(Content); 93 | expect(container.firstChild).toMatchInlineSnapshot(` 94 |
97 | Content 98 |
99 | `); 100 | }); 101 | 102 | test('renders with alignStart', () => { 103 | const { container } = render(Content); 104 | expect(container.firstChild).toMatchInlineSnapshot(` 105 |
108 | Content 109 |
110 | `); 111 | }); 112 | 113 | test('renders with alignCenter', () => { 114 | const { container } = render(Content); 115 | expect(container.firstChild).toMatchInlineSnapshot(` 116 |
119 | Content 120 |
121 | `); 122 | }); 123 | 124 | test('renders with alignEnd', () => { 125 | const { container } = render(Content); 126 | expect(container.firstChild).toMatchInlineSnapshot(` 127 |
130 | Content 131 |
132 | `); 133 | }); 134 | 135 | test('renders with center justification', () => { 136 | const { container } = render(Content); 137 | expect(container.firstChild).toMatchInlineSnapshot(` 138 |
141 | Content 142 |
143 | `); 144 | }); 145 | 146 | test('renders with safe center justification', () => { 147 | const { container } = render( 148 | 149 | Content 150 | , 151 | ); 152 | expect(container.firstChild).toMatchInlineSnapshot(` 153 |
156 | Content 157 |
158 | `); 159 | }); 160 | 161 | test('renders with safe end justification', () => { 162 | const { container } = render( 163 | 164 | Content 165 | , 166 | ); 167 | expect(container.firstChild).toMatchInlineSnapshot(` 168 |
171 | Content 172 |
173 | `); 174 | }); 175 | 176 | test('renders with between justification', () => { 177 | const { container } = render(Content); 178 | expect(container.firstChild).toMatchInlineSnapshot(` 179 |
182 | Content 183 |
184 | `); 185 | }); 186 | 187 | test('renders with end justification', () => { 188 | const { container } = render(Content); 189 | expect(container.firstChild).toMatchInlineSnapshot(` 190 |
193 | Content 194 |
195 | `); 196 | }); 197 | 198 | test('renders with flex1', () => { 199 | const { container } = render(Content); 200 | expect(container.firstChild).toMatchInlineSnapshot(` 201 |
204 | Content 205 |
206 | `); 207 | }); 208 | 209 | test('renders with stretch', () => { 210 | const { container } = render(Content); 211 | expect(container.firstChild).toMatchInlineSnapshot(` 212 |
215 | Content 216 |
217 | `); 218 | }); 219 | 220 | test('renders with self alignment start', () => { 221 | const { container } = render(Content); 222 | expect(container.firstChild).toMatchInlineSnapshot(` 223 |
226 | Content 227 |
228 | `); 229 | }); 230 | 231 | test('renders with self alignment center', () => { 232 | const { container } = render(Content); 233 | expect(container.firstChild).toMatchInlineSnapshot(` 234 |
237 | Content 238 |
239 | `); 240 | }); 241 | 242 | test('renders with padding and gap', () => { 243 | const { container } = render( 244 | 245 | Content 246 | , 247 | ); 248 | expect(container.firstChild).toMatchInlineSnapshot(` 249 |
252 | Content 253 |
254 | `); 255 | }); 256 | 257 | test('renders with verticalPadding and gap', () => { 258 | const { container } = render( 259 | 260 | Content 261 | , 262 | ); 263 | expect(container.firstChild).toMatchInlineSnapshot(` 264 |
267 | Content 268 |
269 | `); 270 | }); 271 | 272 | test('renders with horizontalPadding and gap', () => { 273 | const { container } = render( 274 | 275 | Content 276 | , 277 | ); 278 | expect(container.firstChild).toMatchInlineSnapshot(` 279 |
282 | Content 283 |
284 | `); 285 | }); 286 | 287 | test('renders with custom style', () => { 288 | const { container } = render( 289 | Content, 290 | ); 291 | expect(container.firstChild).toMatchInlineSnapshot(` 292 |
295 | Content 296 |
297 | `); 298 | }); 299 | 300 | test('renders with className', () => { 301 | const { container } = render(Content); 302 | expect(container.firstChild).toMatchInlineSnapshot(` 303 |
307 | Content 308 |
309 | `); 310 | }); 311 | 312 | test('renders with multiple props combined', () => { 313 | const { container } = render( 314 | 315 | Content 316 | , 317 | ); 318 | expect(container.firstChild).toMatchInlineSnapshot(` 319 |
322 | Content 323 |
324 | `); 325 | }); 326 | 327 | test('passes through HTML attributes', () => { 328 | const { container } = render( 329 | {}}> 330 | Content 331 | , 332 | ); 333 | expect(container.firstChild).toMatchInlineSnapshot(` 334 |
338 | Content 339 |
340 | `); 341 | }); 342 | 343 | test('changes default gap when gap=true', () => { 344 | setDefaultGap(24); 345 | const { container } = render(Content); 346 | expect(container.firstChild).toMatchInlineSnapshot(` 347 |
350 | Content 351 |
352 | `); 353 | }); 354 | 355 | test('supports shrink0', () => { 356 | const { container } = render(Content); 357 | expect(container.firstChild).toMatchInlineSnapshot(` 358 |
361 | Content 362 |
363 | `); 364 | }); 365 | 366 | test('renders with around justification', () => { 367 | const { container } = render(Content); 368 | expect(container.firstChild).toMatchInlineSnapshot(` 369 |
372 | Content 373 |
374 | `); 375 | }); 376 | 377 | test('renders with evenly justification', () => { 378 | const { container } = render(Content); 379 | expect(container.firstChild).toMatchInlineSnapshot(` 380 |
383 | Content 384 |
385 | `); 386 | }); 387 | 388 | test('renders with baseline alignment', () => { 389 | const { container } = render(Content); 390 | expect(container.firstChild).toMatchInlineSnapshot(` 391 |
394 | Content 395 |
396 | `); 397 | }); 398 | 399 | test('renders with rowGap only', () => { 400 | const { container } = render(Content); 401 | expect(container.firstChild).toMatchInlineSnapshot(` 402 |
405 | Content 406 |
407 | `); 408 | }); 409 | 410 | test('renders with columnGap only', () => { 411 | const { container } = render(Content); 412 | expect(container.firstChild).toMatchInlineSnapshot(` 413 |
416 | Content 417 |
418 | `); 419 | }); 420 | 421 | test('renders with both rowGap and columnGap', () => { 422 | const { container } = render( 423 | 424 | Content 425 | , 426 | ); 427 | expect(container.firstChild).toMatchInlineSnapshot(` 428 |
431 | Content 432 |
433 | `); 434 | }); 435 | 436 | test('prefers rowGap/columnGap over gap when both are provided', () => { 437 | const { container } = render( 438 | 439 | Content 440 | , 441 | ); 442 | expect(container.firstChild).toMatchInlineSnapshot(` 443 |
446 | Content 447 |
448 | `); 449 | }); 450 | 451 | test('renders with verticalPadding=true using gap value', () => { 452 | const { container } = render( 453 | 454 | Content 455 | , 456 | ); 457 | expect(container.firstChild).toMatchInlineSnapshot(` 458 |
461 | Content 462 |
463 | `); 464 | }); 465 | 466 | test('renders with horizontalPadding=true using gap value', () => { 467 | const { container } = render( 468 | 469 | Content 470 | , 471 | ); 472 | expect(container.firstChild).toMatchInlineSnapshot(` 473 |
476 | Content 477 |
478 | `); 479 | }); 480 | 481 | test('renders with verticalPadding=true using rowGap value', () => { 482 | const { container } = render( 483 | 484 | Content 485 | , 486 | ); 487 | expect(container.firstChild).toMatchInlineSnapshot(` 488 |
491 | Content 492 |
493 | `); 494 | }); 495 | 496 | test('renders with horizontalPadding=true using columnGap value', () => { 497 | const { container } = render( 498 | 499 | Content 500 | , 501 | ); 502 | expect(container.firstChild).toMatchInlineSnapshot(` 503 |
506 | Content 507 |
508 | `); 509 | }); 510 | 511 | test('renders with specific verticalPadding value', () => { 512 | const { container } = render( 513 | 514 | Content 515 | , 516 | ); 517 | expect(container.firstChild).toMatchInlineSnapshot(` 518 |
521 | Content 522 |
523 | `); 524 | }); 525 | 526 | test('renders with specific horizontalPadding value', () => { 527 | const { container } = render( 528 | 529 | Content 530 | , 531 | ); 532 | expect(container.firstChild).toMatchInlineSnapshot(` 533 |
536 | Content 537 |
538 | `); 539 | }); 540 | 541 | test('renders with padding=true overriding other padding props', () => { 542 | const { container } = render( 543 | 544 | Content 545 | , 546 | ); 547 | expect(container.firstChild).toMatchInlineSnapshot(` 548 |
551 | Content 552 |
553 | `); 554 | }); 555 | 556 | test('renders with padding numeric value overriding other padding props', () => { 557 | const { container } = render( 558 | 559 | Content 560 | , 561 | ); 562 | expect(container.firstChild).toMatchInlineSnapshot(` 563 |
566 | Content 567 |
568 | `); 569 | }); 570 | 571 | test('renders with complex gap and padding combination', () => { 572 | const { container } = render( 573 | 574 | Content 575 | , 576 | ); 577 | expect(container.firstChild).toMatchInlineSnapshot(` 578 |
581 | Content 582 |
583 | `); 584 | }); 585 | 586 | test('it renders with custom component', () => { 587 | const { container } = render( 588 | 589 | Content 590 | , 591 | ); 592 | 593 | expect(container.firstChild).toMatchInlineSnapshot(` 594 | 598 | Content 599 | 600 | `); 601 | }); 602 | 603 | test('it works with custom Link component', () => { 604 | type Route = '/banana' | '/apple'; 605 | 606 | const Link = ({ 607 | children, 608 | to, 609 | ...props 610 | }: { 611 | children: React.ReactNode; 612 | style?: CSSProperties; 613 | to: Route; 614 | }) => ( 615 | 616 | {children} 617 | 618 | ); 619 | 620 | const { container } = render( 621 | 622 | Content 623 | , 624 | ); 625 | 626 | expect(container.firstChild).toMatchInlineSnapshot(` 627 | 631 | Content 632 | 633 | `); 634 | }); 635 | 636 | test('does not work if the component does not have a style prop', () => { 637 | const NoStyle = ({ 638 | children, 639 | to, 640 | }: { 641 | children: React.ReactNode; 642 | to: string; 643 | }) => {children}; 644 | 645 | const { container } = render( 646 | // @ts-expect-error: NoStyle does not accept style prop. 647 | 648 | Content 649 | , 650 | ); 651 | 652 | expect(container.firstChild).toMatchInlineSnapshot( 653 | ` 654 | 657 | Content 658 | 659 | `, 660 | ); 661 | }); 662 | 663 | test('exports a VStack component that is vertical by default', () => { 664 | const { container } = render(Content); 665 | 666 | expect(container.firstChild).toMatchInlineSnapshot(` 667 |
670 | Content 671 |
672 | `); 673 | }); 674 | 675 | test('VStack does not support a vertical prop', () => { 676 | // @ts-expect-error 677 | const { container } = render(Content); 678 | 679 | expect(container.firstChild).toMatchInlineSnapshot(` 680 |
683 | Content 684 |
685 | `); 686 | }); 687 | 688 | test('supports the `ref` prop', () => { 689 | const ref = { current: null }; 690 | const { container } = render(Content); 691 | 692 | expect(container.firstChild).toMatchInlineSnapshot(` 693 |
696 | Content 697 |
698 | `); 699 | }); 700 | -------------------------------------------------------------------------------- /src/index.native.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | ComponentProps, 3 | ComponentPropsWithRef, 4 | ElementType, 5 | useMemo, 6 | } from 'react'; 7 | import { resolveGap } from './Gap.tsx'; 8 | import { View, ViewStyle } from 'react-native'; 9 | import { 10 | AcceptsStyle, 11 | AsProp, 12 | PropsToOmit, 13 | StackPropsInternal, 14 | } from './Types.js'; 15 | import { resolveAlignment } from './Alignment.tsx'; 16 | export { setDefaultGap, type Gap } from './Gap.tsx'; 17 | 18 | export type StackProps = 19 | AcceptsStyle extends never 20 | ? never 21 | : AsProp & 22 | StackPropsInternal & 23 | Omit< 24 | ComponentProps, 25 | PropsToOmit 26 | > & 27 | Partial, 'ref'>>; 28 | 29 | let Stack = function Stack({ 30 | alignCenter, 31 | alignEnd, 32 | alignStart, 33 | around, 34 | as, 35 | baseline, 36 | between, 37 | center, 38 | columnGap: _columnGap, 39 | content, 40 | end, 41 | evenly, 42 | flex1, 43 | gap: _gap, 44 | horizontalPadding, 45 | inline, 46 | padding, 47 | reverse, 48 | rowGap: _rowGap, 49 | safe, 50 | self, 51 | shrink0, 52 | stretch, 53 | style, 54 | vertical, 55 | verticalPadding, 56 | wrap, 57 | ...props 58 | }: StackProps & { className?: string }) { 59 | const baseStyle = useMemo(() => { 60 | const baseStyle: ViewStyle = { 61 | alignContent: resolveAlignment(content), 62 | alignItems: alignStart 63 | ? 'flex-start' 64 | : alignCenter 65 | ? 'center' 66 | : alignEnd 67 | ? 'flex-end' 68 | : baseline 69 | ? 'baseline' 70 | : undefined, 71 | alignSelf: resolveAlignment(self), 72 | flex: flex1 ? 1 : undefined, 73 | flexDirection: vertical 74 | ? reverse 75 | ? 'column-reverse' 76 | : 'column' 77 | : reverse 78 | ? 'row-reverse' 79 | : 'row', 80 | flexGrow: stretch ? 1 : undefined, 81 | flexShrink: shrink0 ? 0 : undefined, 82 | flexWrap: wrap ? 'wrap' : 'nowrap', 83 | justifyContent: center 84 | ? 'center' 85 | : end 86 | ? 'flex-end' 87 | : between 88 | ? 'space-between' 89 | : evenly 90 | ? 'space-evenly' 91 | : around 92 | ? 'space-around' 93 | : 'flex-start', 94 | }; 95 | 96 | const gap = resolveGap(_gap); 97 | const rowGap = resolveGap(_rowGap); 98 | const columnGap = resolveGap(_columnGap); 99 | 100 | if (rowGap != null) { 101 | baseStyle.rowGap = rowGap; 102 | } 103 | if (columnGap != null) { 104 | baseStyle.columnGap = columnGap; 105 | } 106 | if (gap != null && rowGap == null && columnGap == null) { 107 | baseStyle.gap = gap; 108 | } 109 | 110 | const vGap = rowGap ?? gap; 111 | const hGap = columnGap ?? gap; 112 | if (padding === true) { 113 | if (vGap != null) { 114 | baseStyle.paddingTop = baseStyle.paddingBottom = vGap; 115 | } 116 | if (hGap != null) { 117 | baseStyle.paddingLeft = baseStyle.paddingRight = hGap; 118 | } 119 | } else if (padding != null) { 120 | baseStyle.padding = padding; 121 | } else { 122 | if (verticalPadding != null || vGap != null) { 123 | const paddingValue = verticalPadding === true ? vGap : verticalPadding; 124 | baseStyle.paddingTop = baseStyle.paddingBottom = paddingValue; 125 | } 126 | 127 | if (horizontalPadding != null || hGap != null) { 128 | const paddingValue = 129 | horizontalPadding === true ? hGap : horizontalPadding; 130 | baseStyle.paddingLeft = baseStyle.paddingRight = paddingValue; 131 | } 132 | } 133 | 134 | return baseStyle; 135 | }, [ 136 | _columnGap, 137 | _gap, 138 | _rowGap, 139 | alignCenter, 140 | alignEnd, 141 | alignStart, 142 | around, 143 | baseline, 144 | between, 145 | center, 146 | content, 147 | end, 148 | evenly, 149 | flex1, 150 | horizontalPadding, 151 | padding, 152 | reverse, 153 | self, 154 | shrink0, 155 | stretch, 156 | vertical, 157 | verticalPadding, 158 | wrap, 159 | ]); 160 | 161 | const Component = as || View; 162 | return ; 163 | }; 164 | 165 | try { 166 | // eslint-disable-next-line @typescript-eslint/no-require-imports 167 | const { cssInterop } = require('nativewind'); 168 | 169 | Stack = cssInterop(Stack, { 170 | className: { 171 | target: 'style', 172 | }, 173 | }); 174 | } catch { 175 | /* empty */ 176 | } 177 | 178 | export default Stack; 179 | 180 | export const VStack = ( 181 | props: StackProps & { vertical?: never }, 182 | ) => ; 183 | -------------------------------------------------------------------------------- /src/index.tsx: -------------------------------------------------------------------------------- 1 | import { ComponentProps, CSSProperties, ElementType, useMemo } from 'react'; 2 | import { 3 | AcceptsStyle, 4 | AsProp, 5 | PropsToOmit, 6 | StackPropsInternal, 7 | } from './Types.js'; 8 | import { resolveGap } from './Gap.tsx'; 9 | import { resolveAlignment } from './Alignment.tsx'; 10 | export { setDefaultGap, type Gap } from './Gap.tsx'; 11 | 12 | export type StackProps = 13 | AcceptsStyle extends never 14 | ? never 15 | : AsProp & 16 | StackPropsInternal & 17 | Omit< 18 | ComponentProps, 19 | PropsToOmit 20 | >; 21 | 22 | export default function Stack({ 23 | alignCenter, 24 | alignEnd, 25 | alignStart, 26 | around, 27 | as, 28 | baseline, 29 | between, 30 | center, 31 | columnGap: _columnGap, 32 | content, 33 | end, 34 | evenly, 35 | flex1, 36 | gap: _gap, 37 | horizontalPadding, 38 | inline, 39 | padding, 40 | reverse, 41 | rowGap: _rowGap, 42 | safe, 43 | self, 44 | shrink0, 45 | stretch, 46 | style, 47 | vertical, 48 | verticalPadding, 49 | wrap, 50 | ...props 51 | }: StackProps) { 52 | const baseStyle = useMemo(() => { 53 | const baseStyle: CSSProperties = { 54 | alignContent: resolveAlignment(content), 55 | alignItems: alignStart 56 | ? 'flex-start' 57 | : alignCenter 58 | ? 'center' 59 | : alignEnd 60 | ? 'flex-end' 61 | : baseline 62 | ? 'baseline' 63 | : undefined, 64 | alignSelf: resolveAlignment(self), 65 | display: inline ? 'inline-flex' : 'flex', 66 | flex: flex1 ? 1 : undefined, 67 | flexDirection: vertical 68 | ? reverse 69 | ? 'column-reverse' 70 | : 'column' 71 | : reverse 72 | ? 'row-reverse' 73 | : 'row', 74 | flexGrow: stretch ? 1 : undefined, 75 | flexShrink: shrink0 ? 0 : undefined, 76 | flexWrap: wrap ? 'wrap' : 'nowrap', 77 | justifyContent: center 78 | ? `${safe ? 'safe ' : ''}center` 79 | : end 80 | ? `${safe ? 'safe ' : ''}flex-end` 81 | : between 82 | ? 'space-between' 83 | : evenly 84 | ? 'space-evenly' 85 | : around 86 | ? 'space-around' 87 | : 'flex-start', 88 | }; 89 | 90 | const gap = resolveGap(_gap); 91 | const rowGap = resolveGap(_rowGap); 92 | const columnGap = resolveGap(_columnGap); 93 | 94 | if (rowGap != null) { 95 | baseStyle.rowGap = rowGap; 96 | } 97 | if (columnGap != null) { 98 | baseStyle.columnGap = columnGap; 99 | } 100 | if (gap != null && rowGap == null && columnGap == null) { 101 | baseStyle.gap = gap; 102 | } 103 | 104 | const vGap = rowGap ?? gap; 105 | const hGap = columnGap ?? gap; 106 | if (padding === true) { 107 | if (vGap != null) { 108 | baseStyle.paddingTop = baseStyle.paddingBottom = vGap; 109 | } 110 | if (hGap != null) { 111 | baseStyle.paddingLeft = baseStyle.paddingRight = hGap; 112 | } 113 | } else if (padding != null) { 114 | baseStyle.padding = padding; 115 | } else { 116 | if (verticalPadding != null || vGap != null) { 117 | const paddingValue = verticalPadding === true ? vGap : verticalPadding; 118 | baseStyle.paddingTop = baseStyle.paddingBottom = paddingValue; 119 | } 120 | 121 | if (horizontalPadding != null || hGap != null) { 122 | const paddingValue = 123 | horizontalPadding === true ? hGap : horizontalPadding; 124 | baseStyle.paddingLeft = baseStyle.paddingRight = paddingValue; 125 | } 126 | } 127 | 128 | return baseStyle; 129 | }, [ 130 | _columnGap, 131 | _gap, 132 | _rowGap, 133 | alignCenter, 134 | alignEnd, 135 | alignStart, 136 | around, 137 | baseline, 138 | between, 139 | center, 140 | content, 141 | end, 142 | evenly, 143 | flex1, 144 | horizontalPadding, 145 | inline, 146 | padding, 147 | reverse, 148 | safe, 149 | self, 150 | shrink0, 151 | stretch, 152 | vertical, 153 | verticalPadding, 154 | wrap, 155 | ]); 156 | 157 | const Component = as || 'div'; 158 | return ; 159 | } 160 | 161 | export const VStack = ( 162 | props: StackProps & { vertical?: never }, 163 | ) => ; 164 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "allowJs": true, 4 | "allowImportingTsExtensions": true, 5 | "checkJs": true, 6 | "esModuleInterop": true, 7 | "forceConsistentCasingInFileNames": true, 8 | "incremental": false, 9 | "isolatedModules": true, 10 | "jsx": "preserve", 11 | "lib": ["dom", "dom.iterable", "esnext"], 12 | "module": "nodenext", 13 | "moduleResolution": "nodenext", 14 | "noEmit": true, 15 | "noImplicitOverride": true, 16 | "noUnusedLocals": true, 17 | "plugins": [ 18 | { 19 | "lint": { 20 | "unknownProperties": "error" 21 | } 22 | } 23 | ], 24 | "resolveJsonModule": true, 25 | "resolvePackageJsonExports": true, 26 | "skipLibCheck": true, 27 | "strict": true, 28 | "target": "es2024" 29 | }, 30 | "exclude": ["lib/", "node_modules"], 31 | "include": ["src/**/*.ts", "src/**/*.tsx"] 32 | } 33 | -------------------------------------------------------------------------------- /tsdown.config.js: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'tsdown'; 2 | 3 | export default defineConfig({ 4 | dts: { 5 | compilerOptions: { 6 | removeComments: false, 7 | }, 8 | isolatedDeclarations: false, 9 | }, 10 | format: 'esm', 11 | outDir: './lib', 12 | outputOptions: { 13 | polyfillRequire: false, 14 | }, 15 | }); 16 | -------------------------------------------------------------------------------- /vitest.config.ts: -------------------------------------------------------------------------------- 1 | import react from '@vitejs/plugin-react'; 2 | import { defineConfig } from 'vitest/config'; 3 | 4 | export default defineConfig({ 5 | plugins: [react()], 6 | test: { 7 | environment: 'happy-dom', 8 | }, 9 | }); 10 | --------------------------------------------------------------------------------