create-react-native-library-template 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/README.md +27 -0
  2. package/index.mjs +62 -0
  3. package/package.json +41 -0
  4. package/template/.changeset/README.md +7 -0
  5. package/template/.changeset/config.json +11 -0
  6. package/template/.github/workflows/ci.yml +42 -0
  7. package/template/.github/workflows/release.yml +46 -0
  8. package/template/AGENTS.md +97 -0
  9. package/template/CLAUDE.md +5 -0
  10. package/template/README.md +67 -0
  11. package/template/apps/native/.rnstorybook/index.tsx +11 -0
  12. package/template/apps/native/.rnstorybook/main.ts +12 -0
  13. package/template/apps/native/.rnstorybook/preview.tsx +24 -0
  14. package/template/apps/native/app.json +17 -0
  15. package/template/apps/native/babel.config.js +8 -0
  16. package/template/apps/native/index.ts +5 -0
  17. package/template/apps/native/metro.config.js +18 -0
  18. package/template/apps/native/package.json +40 -0
  19. package/template/apps/native/tsconfig.json +8 -0
  20. package/template/apps/storybook/.storybook/main.ts +12 -0
  21. package/template/apps/storybook/.storybook/preview.tsx +14 -0
  22. package/template/apps/storybook/.storybook/vitest.setup.ts +7 -0
  23. package/template/apps/storybook/package.json +33 -0
  24. package/template/apps/storybook/tsconfig.json +7 -0
  25. package/template/apps/storybook/vitest.config.ts +25 -0
  26. package/template/biome.json +9 -0
  27. package/template/bun.lock +2479 -0
  28. package/template/gitignore +23 -0
  29. package/template/package.json +33 -0
  30. package/template/packages/ui/package.json +81 -0
  31. package/template/packages/ui/src/components/Button/button.stories.tsx +79 -0
  32. package/template/packages/ui/src/components/Button/button.test.tsx +33 -0
  33. package/template/packages/ui/src/components/Button/button.tsx +128 -0
  34. package/template/packages/ui/src/theme/tokens.ts +40 -0
  35. package/template/packages/ui/tsconfig.build.json +13 -0
  36. package/template/packages/ui/tsconfig.json +7 -0
  37. package/template/packages/ui/vitest.config.ts +16 -0
  38. package/template/packages/ui/vitest.setup.ts +1 -0
  39. package/template/tsconfig.base.json +20 -0
@@ -0,0 +1,23 @@
1
+ # Dependencies
2
+ node_modules/
3
+
4
+ # Build outputs
5
+ lib/
6
+ dist/
7
+ storybook-static/
8
+
9
+ # Expo
10
+ .expo/
11
+ expo-env.d.ts
12
+
13
+ # Storybook React Native (generated)
14
+ storybook.requires.ts
15
+
16
+ # Tests
17
+ coverage/
18
+
19
+ # Misc
20
+ *.log
21
+ .DS_Store
22
+ .env*
23
+ !.env.example
@@ -0,0 +1,33 @@
1
+ {
2
+ "name": "react-native-library-template",
3
+ "version": "0.0.0",
4
+ "private": true,
5
+ "workspaces": [
6
+ "packages/*",
7
+ "apps/*"
8
+ ],
9
+ "scripts": {
10
+ "build": "bun run --cwd packages/ui build",
11
+ "typecheck": "bun run --cwd packages/ui typecheck && bun run --cwd apps/storybook typecheck && bun run --cwd apps/native typecheck",
12
+ "lint": "biome check .",
13
+ "lint:fix": "biome check --write .",
14
+ "test": "bun run test:unit && bun run test:storybook",
15
+ "test:unit": "bun run --cwd packages/ui test",
16
+ "test:storybook": "bun run --cwd apps/storybook test",
17
+ "storybook": "bun run --cwd apps/storybook storybook",
18
+ "storybook:native": "bun run --cwd apps/native start",
19
+ "changeset": "changeset",
20
+ "version-packages": "changeset version",
21
+ "release": "bun run build && changeset publish"
22
+ },
23
+ "devDependencies": {
24
+ "@biomejs/biome": "^2.5.3",
25
+ "@changesets/cli": "^2.31.0",
26
+ "@iv-stpn/biome-config": "1.2.10",
27
+ "biome-one-liner-plugin": "1.3.2",
28
+ "biome-drizzle-best-practices-plugin": "2.0.0",
29
+ "biome-react-best-practices-plugin": "1.1.0",
30
+ "biome-typescript-best-practices-plugin": "1.0.0",
31
+ "typescript": "^6"
32
+ }
33
+ }
@@ -0,0 +1,81 @@
1
+ {
2
+ "name": "@template/ui",
3
+ "version": "0.1.0",
4
+ "description": "Reusable React Native UI component library",
5
+ "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "https://github.com/your-org/react-native-library-template.git",
9
+ "directory": "packages/ui"
10
+ },
11
+ "keywords": [
12
+ "react-native",
13
+ "react",
14
+ "ui",
15
+ "components"
16
+ ],
17
+ "exports": {
18
+ "./button": {
19
+ "source": "./src/components/Button/button.tsx",
20
+ "types": "./lib/typescript/components/Button/button.d.ts",
21
+ "default": "./lib/module/components/Button/button.js"
22
+ },
23
+ "./theme": {
24
+ "source": "./src/theme/tokens.ts",
25
+ "types": "./lib/typescript/theme/tokens.d.ts",
26
+ "default": "./lib/module/theme/tokens.js"
27
+ },
28
+ "./package.json": "./package.json"
29
+ },
30
+ "files": [
31
+ "src",
32
+ "lib",
33
+ "!**/*.test.*",
34
+ "!**/*.stories.*"
35
+ ],
36
+ "sideEffects": false,
37
+ "scripts": {
38
+ "build": "bob build",
39
+ "clean": "rm -rf lib",
40
+ "typecheck": "tsc --noEmit",
41
+ "test": "vitest run",
42
+ "test:watch": "vitest"
43
+ },
44
+ "react-native-builder-bob": {
45
+ "source": "src",
46
+ "output": "lib",
47
+ "exclude": "**/{*.test,*.stories}.{ts,tsx}",
48
+ "targets": [
49
+ [
50
+ "module",
51
+ {
52
+ "esm": true
53
+ }
54
+ ],
55
+ [
56
+ "typescript",
57
+ {
58
+ "project": "tsconfig.build.json"
59
+ }
60
+ ]
61
+ ]
62
+ },
63
+ "devDependencies": {
64
+ "@storybook/react": "^10.4.6",
65
+ "@testing-library/jest-dom": "^6.9.1",
66
+ "@testing-library/react": "^16.3.2",
67
+ "@types/react": "~19.1.10",
68
+ "jsdom": "^29.1.1",
69
+ "react": "19.1.0",
70
+ "react-dom": "19.1.0",
71
+ "react-native": "0.81.5",
72
+ "react-native-builder-bob": "^0.43.0",
73
+ "react-native-web": "0.21.2",
74
+ "storybook": "^10.4.6",
75
+ "typescript": "^6",
76
+ "vitest": "^4.1.10"
77
+ },
78
+ "publishConfig": {
79
+ "access": "public"
80
+ }
81
+ }
@@ -0,0 +1,79 @@
1
+ import type { Meta, StoryObj } from '@storybook/react';
2
+ import { expect, fireEvent, fn, userEvent, within } from 'storybook/test';
3
+ import { Button } from './button';
4
+
5
+ const meta = {
6
+ title: 'Components/Button',
7
+ component: Button,
8
+ args: {
9
+ label: 'Press me',
10
+ onPress: fn(),
11
+ variant: 'primary',
12
+ size: 'md',
13
+ disabled: false,
14
+ loading: false,
15
+ },
16
+ argTypes: {
17
+ variant: {
18
+ control: 'select',
19
+ options: ['primary', 'secondary', 'ghost'],
20
+ },
21
+ size: {
22
+ control: 'select',
23
+ options: ['sm', 'md', 'lg'],
24
+ },
25
+ },
26
+ } satisfies Meta<typeof Button>;
27
+
28
+ type Story = StoryObj<typeof meta>;
29
+
30
+ export const Primary: Story = {};
31
+
32
+ export const Secondary: Story = {
33
+ args: { variant: 'secondary' },
34
+ };
35
+
36
+ export const Ghost: Story = {
37
+ args: { variant: 'ghost' },
38
+ };
39
+
40
+ export const Small: Story = {
41
+ args: { size: 'sm' },
42
+ };
43
+
44
+ export const Large: Story = {
45
+ args: { size: 'lg' },
46
+ };
47
+
48
+ export const Disabled: Story = {
49
+ args: { disabled: true },
50
+ };
51
+
52
+ export const Loading: Story = {
53
+ args: { loading: true },
54
+ };
55
+
56
+ /** Pressing the button fires `onPress` exactly once. */
57
+ export const FiresOnPress: Story = {
58
+ play: async ({ canvasElement, args }) => {
59
+ const canvas = within(canvasElement);
60
+ await userEvent.click(canvas.getByRole('button', { name: 'Press me' }));
61
+ await expect(args.onPress).toHaveBeenCalledTimes(1);
62
+ },
63
+ };
64
+
65
+ /** A disabled button is exposed as disabled and ignores presses. */
66
+ export const IgnoresPressWhenDisabled: Story = {
67
+ args: { disabled: true },
68
+ play: async ({ canvasElement, args }) => {
69
+ const canvas = within(canvasElement);
70
+ const button = canvas.getByRole('button', { name: 'Press me' });
71
+ await expect(button).toHaveAttribute('aria-disabled', 'true');
72
+ // A disabled Pressable has `pointer-events: none`, so a real user cannot
73
+ // click it; dispatch the event directly to prove onPress is still guarded.
74
+ await fireEvent.click(button);
75
+ await expect(args.onPress).not.toHaveBeenCalled();
76
+ },
77
+ };
78
+
79
+ export default meta;
@@ -0,0 +1,33 @@
1
+ import { fireEvent, render, screen } from '@testing-library/react';
2
+ import { describe, expect, it, vi } from 'vitest';
3
+ import { Button } from './button';
4
+
5
+ describe('Button', () => {
6
+ it('renders its label', () => {
7
+ render(<Button label="Save" />);
8
+ expect(screen.getByRole('button', { name: 'Save' })).toBeInTheDocument();
9
+ });
10
+
11
+ it('calls onPress when pressed', () => {
12
+ const onPress = vi.fn();
13
+ render(<Button label="Save" onPress={onPress} />);
14
+ fireEvent.click(screen.getByRole('button', { name: 'Save' }));
15
+ expect(onPress).toHaveBeenCalledTimes(1);
16
+ });
17
+
18
+ it('does not call onPress when disabled', () => {
19
+ const onPress = vi.fn();
20
+ render(<Button label="Save" onPress={onPress} disabled={true} />);
21
+ fireEvent.click(screen.getByRole('button', { name: 'Save' }));
22
+ expect(onPress).not.toHaveBeenCalled();
23
+ });
24
+
25
+ it('shows a spinner instead of the label while loading', () => {
26
+ const onPress = vi.fn();
27
+ render(<Button label="Save" onPress={onPress} loading={true} />);
28
+ expect(screen.queryByText('Save')).not.toBeInTheDocument();
29
+ expect(screen.getByTestId('button-spinner')).toBeInTheDocument();
30
+ fireEvent.click(screen.getByRole('button', { name: 'Save' }));
31
+ expect(onPress).not.toHaveBeenCalled();
32
+ });
33
+ });
@@ -0,0 +1,128 @@
1
+ import { useCallback, useMemo } from 'react';
2
+ import { ActivityIndicator, Pressable, type StyleProp, StyleSheet, Text, type ViewStyle } from 'react-native';
3
+ import { colors, fontSizes, radii, spacing } from '../../theme/tokens';
4
+
5
+ function getButtonOpacity(inactive: boolean, pressed: boolean) {
6
+ if (inactive) return 0.5;
7
+ if (pressed) return 0.7;
8
+ return 1;
9
+ }
10
+
11
+ function variantStyles(variant: ButtonVariant, pressed: boolean, inactive: boolean): ViewStyle {
12
+ if (inactive && variant !== 'ghost') return { backgroundColor: colors.disabled };
13
+ switch (variant) {
14
+ case 'primary':
15
+ return { backgroundColor: pressed ? colors.primaryPressed : colors.primary };
16
+ case 'secondary':
17
+ return { backgroundColor: pressed ? colors.secondaryPressed : colors.secondary };
18
+ case 'ghost':
19
+ return {
20
+ backgroundColor: 'transparent',
21
+ borderWidth: StyleSheet.hairlineWidth,
22
+ borderColor: colors.border,
23
+ opacity: getButtonOpacity(inactive, pressed),
24
+ };
25
+ default:
26
+ return {};
27
+ }
28
+ }
29
+
30
+ function labelStyles(variant: ButtonVariant, inactive: boolean) {
31
+ if (inactive && variant !== 'ghost') return { color: colors.textDisabled };
32
+ return { color: variant === 'primary' ? colors.textInverse : colors.textPrimary };
33
+ }
34
+
35
+ function spinnerColor(variant: ButtonVariant): typeof colors.textInverse | typeof colors.textPrimary {
36
+ return variant === 'primary' ? colors.textInverse : colors.textPrimary;
37
+ }
38
+
39
+ const styles = StyleSheet.create({
40
+ base: {
41
+ alignItems: 'center',
42
+ justifyContent: 'center',
43
+ flexDirection: 'row',
44
+ borderRadius: radii.md,
45
+ alignSelf: 'flex-start',
46
+ },
47
+ label: {
48
+ fontWeight: '600',
49
+ },
50
+ });
51
+
52
+ const sizeStyles = StyleSheet.create({
53
+ sm: { paddingVertical: spacing.xs, paddingHorizontal: spacing.md, minHeight: 32 },
54
+ md: { paddingVertical: spacing.sm, paddingHorizontal: spacing.lg, minHeight: 40 },
55
+ lg: { paddingVertical: spacing.md, paddingHorizontal: spacing.xl, minHeight: 48 },
56
+ });
57
+
58
+ const labelSizeStyles = StyleSheet.create({
59
+ sm: { fontSize: fontSizes.sm },
60
+ md: { fontSize: fontSizes.md },
61
+ lg: { fontSize: fontSizes.lg },
62
+ });
63
+
64
+ export type ButtonVariant = 'primary' | 'secondary' | 'ghost';
65
+ export type ButtonSize = 'sm' | 'md' | 'lg';
66
+
67
+ export type ButtonProps = {
68
+ /** Text rendered inside the button. */
69
+ label: string;
70
+ /** Called when the button is pressed. */
71
+ onPress?: () => void;
72
+ /** Visual style of the button. @default 'primary' */
73
+ variant?: ButtonVariant;
74
+ /** Size of the button. @default 'md' */
75
+ size?: ButtonSize;
76
+ /** Disables interaction and applies a muted style. @default false */
77
+ disabled?: boolean;
78
+ /** Shows a spinner instead of the label and disables interaction. @default false */
79
+ loading?: boolean;
80
+ /** Style override for the outer pressable. */
81
+ style?: StyleProp<ViewStyle>;
82
+ /** Accessibility label; falls back to `label`. */
83
+ accessibilityLabel?: string;
84
+ /** Test identifier. */
85
+ testID?: string;
86
+ };
87
+
88
+ export function Button({
89
+ label,
90
+ onPress,
91
+ variant = 'primary',
92
+ size = 'md',
93
+ disabled = false,
94
+ loading = false,
95
+ style,
96
+ accessibilityLabel,
97
+ testID,
98
+ }: ButtonProps) {
99
+ const inactive = disabled || loading;
100
+ const accessibilityState = useMemo(() => ({ disabled: inactive, busy: loading }), [inactive, loading]);
101
+ const buttonStyles = useCallback(
102
+ ({ pressed }: { pressed: boolean }) => [styles.base, sizeStyles[size], variantStyles(variant, pressed, inactive), style],
103
+ [size, variant, inactive, style],
104
+ );
105
+
106
+ const textStyles = useCallback(
107
+ () => [styles.label, labelSizeStyles[size], labelStyles(variant, inactive)],
108
+ [size, variant, inactive],
109
+ );
110
+
111
+ return (
112
+ <Pressable
113
+ accessibilityRole="button"
114
+ accessibilityLabel={accessibilityLabel ?? label}
115
+ accessibilityState={accessibilityState}
116
+ disabled={inactive}
117
+ onPress={onPress}
118
+ testID={testID}
119
+ style={buttonStyles}
120
+ >
121
+ {loading ? (
122
+ <ActivityIndicator size="small" color={spinnerColor(variant)} testID="button-spinner" />
123
+ ) : (
124
+ <Text style={textStyles()}>{label}</Text>
125
+ )}
126
+ </Pressable>
127
+ );
128
+ }
@@ -0,0 +1,40 @@
1
+ export const colors = {
2
+ primary: '#4f46e5',
3
+ primaryPressed: '#4338ca',
4
+ secondary: '#e0e7ff',
5
+ secondaryPressed: '#c7d2fe',
6
+ surface: '#ffffff',
7
+ border: '#d4d4d8',
8
+ textPrimary: '#18181b',
9
+ textInverse: '#ffffff',
10
+ textMuted: '#71717a',
11
+ danger: '#dc2626',
12
+ disabled: '#e4e4e7',
13
+ textDisabled: '#a1a1aa',
14
+ } as const;
15
+
16
+ export const spacing = {
17
+ xs: 4,
18
+ sm: 8,
19
+ md: 12,
20
+ lg: 16,
21
+ xl: 24,
22
+ } as const;
23
+
24
+ export const radii = {
25
+ sm: 4,
26
+ md: 8,
27
+ lg: 16,
28
+ full: 9999,
29
+ } as const;
30
+
31
+ export const fontSizes = {
32
+ sm: 13,
33
+ md: 15,
34
+ lg: 17,
35
+ } as const;
36
+
37
+ export type Color = keyof typeof colors;
38
+ export type Spacing = keyof typeof spacing;
39
+ export type Radius = keyof typeof radii;
40
+ export type FontSize = keyof typeof fontSizes;
@@ -0,0 +1,13 @@
1
+ {
2
+ "extends": "./tsconfig.json",
3
+ "compilerOptions": {
4
+ "noEmit": false,
5
+ "emitDeclarationOnly": true,
6
+ "declaration": true,
7
+ "declarationMap": true,
8
+ "rootDir": "src",
9
+ "outDir": "lib/typescript"
10
+ },
11
+ "include": ["src"],
12
+ "exclude": ["src/**/*.test.*", "src/**/*.stories.*"]
13
+ }
@@ -0,0 +1,7 @@
1
+ {
2
+ "extends": "../../tsconfig.base.json",
3
+ "compilerOptions": {
4
+ "lib": ["ES2023", "DOM", "DOM.Iterable"]
5
+ },
6
+ "include": ["src", "vitest.config.ts", "vitest.setup.ts"]
7
+ }
@@ -0,0 +1,16 @@
1
+ import { defineConfig } from 'vitest/config';
2
+
3
+ export default defineConfig({
4
+ resolve: {
5
+ // Unit tests run in jsdom, so RN primitives are backed by react-native-web.
6
+ alias: {
7
+ 'react-native': 'react-native-web',
8
+ },
9
+ },
10
+ test: {
11
+ environment: 'jsdom',
12
+ globals: true,
13
+ setupFiles: ['./vitest.setup.ts'],
14
+ include: ['src/**/*.test.{ts,tsx}'],
15
+ },
16
+ });
@@ -0,0 +1 @@
1
+ import '@testing-library/jest-dom/vitest';
@@ -0,0 +1,20 @@
1
+ {
2
+ "compilerOptions": {
3
+ "strict": true,
4
+ "target": "ES2022",
5
+ "lib": ["ES2023"],
6
+ "module": "Preserve",
7
+ "moduleResolution": "bundler",
8
+ "moduleDetection": "force",
9
+ "jsx": "react-jsx",
10
+ "resolveJsonModule": true,
11
+ "verbatimModuleSyntax": true,
12
+ "isolatedModules": true,
13
+ "esModuleInterop": true,
14
+ "skipLibCheck": true,
15
+ "noEmit": true,
16
+ "noUncheckedIndexedAccess": true,
17
+ "noFallthroughCasesInSwitch": true,
18
+ "forceConsistentCasingInFileNames": true
19
+ }
20
+ }