├── .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 | Action 1
415 | Action 2
416 |
417 |
418 | ```
419 |
420 | ### Navigation Bar
421 |
422 | ```tsx
423 |
424 | Logo
425 |
426 | Home
427 | About
428 | Contact
429 |
430 | Sign In
431 |
432 | ```
433 |
434 | ### Form Layout
435 |
436 | ```tsx
437 |
438 |
439 | Name
440 |
441 |
442 |
443 | Email
444 |
445 |
446 |
447 | Cancel
448 | Submit
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 |
--------------------------------------------------------------------------------