@storybook/react-native-ui-common 9.0.0-beta.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json ADDED
@@ -0,0 +1,81 @@
1
+ {
2
+ "name": "@storybook/react-native-ui-common",
3
+ "version": "9.0.0-beta.15",
4
+ "description": "common ui components for react native storybook",
5
+ "keywords": [
6
+ "react",
7
+ "react-native",
8
+ "storybook"
9
+ ],
10
+ "homepage": "https://storybook.js.org/",
11
+ "bugs": {
12
+ "url": "https://github.com/storybookjs/react-native/issues"
13
+ },
14
+ "repository": {
15
+ "type": "git",
16
+ "url": "https://github.com/storybookjs/react-native.git",
17
+ "directory": "packages/react-native-ui-common"
18
+ },
19
+ "react-native": "src/index.tsx",
20
+ "main": "dist/index.js",
21
+ "types": "src/index.tsx",
22
+ "license": "MIT",
23
+ "files": [
24
+ "dist/**/*",
25
+ "README.md",
26
+ "*.js",
27
+ "*.d.ts",
28
+ "src/**/*"
29
+ ],
30
+ "scripts": {
31
+ "dev": "tsup --watch",
32
+ "prepare": "tsup",
33
+ "test": "jest --passWithNoTests",
34
+ "test:ci": "jest"
35
+ },
36
+ "jest": {
37
+ "modulePathIgnorePatterns": [
38
+ "dist/"
39
+ ],
40
+ "moduleFileExtensions": [
41
+ "ts",
42
+ "tsx",
43
+ "js",
44
+ "jsx",
45
+ "json",
46
+ "node"
47
+ ],
48
+ "preset": "react-native"
49
+ },
50
+ "devDependencies": {
51
+ "@types/jest": "^29.4.3",
52
+ "@types/react": "~19.0.10",
53
+ "babel-jest": "^29.7.0",
54
+ "jest": "^29.7.0",
55
+ "react-test-renderer": "^19.1.0",
56
+ "tsup": "^7.2.0",
57
+ "typescript": "~5.8.3"
58
+ },
59
+ "dependencies": {
60
+ "@storybook/react": "9.0.0-beta.10",
61
+ "@storybook/react-native-theming": "^9.0.0-beta.15",
62
+ "es-toolkit": "^1.38.0",
63
+ "fuse.js": "^7.0.0",
64
+ "memoizerific": "^1.11.3",
65
+ "polished": "^4.3.1",
66
+ "store2": "^2.14.3",
67
+ "ts-dedent": "^2.2.0"
68
+ },
69
+ "peerDependencies": {
70
+ "react": "*",
71
+ "react-native": ">=0.57.0",
72
+ "storybook": "9.0.0-beta.10"
73
+ },
74
+ "engines": {
75
+ "node": ">=18.0.0"
76
+ },
77
+ "publishConfig": {
78
+ "access": "public"
79
+ },
80
+ "gitHead": "905adcca4ea272933c7f0fa16f10dda201da9a5f"
81
+ }
@@ -0,0 +1,133 @@
1
+ import type { Meta, StoryObj } from '@storybook/react';
2
+ import type { ReactNode } from 'react';
3
+
4
+ import { Image, View } from 'react-native';
5
+
6
+ import { Button } from './Button';
7
+
8
+ const meta = {
9
+ title: 'UI/Button',
10
+ component: Button,
11
+ args: {},
12
+ } satisfies Meta<typeof Button>;
13
+
14
+ export default meta;
15
+ type Story = StoryObj<typeof meta>;
16
+
17
+ const Stack = ({ children }: { children: ReactNode }) => (
18
+ <View style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>{children}</View>
19
+ );
20
+
21
+ const Row = ({ children }: { children: ReactNode }) => (
22
+ <View style={{ display: 'flex', flexDirection: 'row', alignItems: 'center', gap: 16 }}>
23
+ {children}
24
+ </View>
25
+ );
26
+
27
+ export const Base: Story = {};
28
+ const Icon = () => <Image source={require('./assets/react-native-logo.png')} />;
29
+ export const Variants: Story = {
30
+ render: (args) => (
31
+ <Stack>
32
+ <Row>
33
+ <Button variant="solid" text="Solid" {...args} />
34
+ <Button variant="outline" text="Outline" {...args} />
35
+ <Button variant="ghost" text="Ghost" {...args} />
36
+ </Row>
37
+ <Row>
38
+ <Button variant="solid" {...args} Icon={Icon} text="Solid" />
39
+ <Button variant="outline" Icon={Icon} text="Outline" {...args} />
40
+ <Button variant="ghost" Icon={Icon} text="Ghost" {...args} />
41
+ </Row>
42
+ <Row>
43
+ <Button variant="solid" padding="small" Icon={Icon} {...args} />
44
+ <Button variant="outline" padding="small" Icon={Icon} {...args} />
45
+ <Button variant="ghost" padding="small" Icon={Icon} {...args} />
46
+ </Row>
47
+ </Stack>
48
+ ),
49
+ };
50
+
51
+ export const Active: Story = {
52
+ args: {
53
+ active: true,
54
+ text: 'Button',
55
+ Icon,
56
+ },
57
+ render: (args) => (
58
+ <Row>
59
+ <Button variant="solid" {...args} />
60
+ <Button variant="outline" {...args} />
61
+ <Button variant="ghost" {...args} />
62
+ </Row>
63
+ ),
64
+ };
65
+
66
+ export const WithIcon: Story = {
67
+ args: {
68
+ Icon,
69
+ text: 'Button',
70
+ },
71
+ render: (args) => (
72
+ <Row>
73
+ <Button variant="solid" {...args} />
74
+ <Button variant="outline" {...args} />
75
+ <Button variant="ghost" {...args} />
76
+ </Row>
77
+ ),
78
+ };
79
+
80
+ export const IconOnly: Story = {
81
+ args: {
82
+ padding: 'small',
83
+ Icon,
84
+ },
85
+ render: (args) => (
86
+ <Row>
87
+ <Button variant="solid" {...args} />
88
+ <Button variant="outline" {...args} />
89
+ <Button variant="ghost" {...args} />
90
+ </Row>
91
+ ),
92
+ };
93
+
94
+ export const Sizes: Story = {
95
+ render: () => (
96
+ <Row>
97
+ <Button size="small" text="Small Button" />
98
+ <Button size="medium" text="Medium Button" />
99
+ </Row>
100
+ ),
101
+ };
102
+
103
+ export const Disabled: Story = {
104
+ args: {
105
+ disabled: true,
106
+ text: 'Disabled Button',
107
+ },
108
+ };
109
+
110
+ export const Animated: Story = {
111
+ args: {
112
+ variant: 'outline',
113
+ },
114
+ render: (args) => (
115
+ <Stack>
116
+ <Row>
117
+ <Button animation="glow" text="Button" {...args} />
118
+ <Button animation="jiggle" text="Button" {...args} />
119
+ <Button animation="rotate360" text="Button" {...args} />
120
+ </Row>
121
+ <Row>
122
+ <Button animation="glow" text="Button" Icon={Icon} {...args} />
123
+ <Button animation="jiggle" text="Button" Icon={Icon} {...args} />
124
+ <Button animation="rotate360" Icon={Icon} text="Button" {...args} />
125
+ </Row>
126
+ <Row>
127
+ <Button animation="glow" padding="small" Icon={Icon} {...args} />
128
+ <Button animation="jiggle" padding="small" Icon={Icon} {...args} />
129
+ <Button animation="rotate360" padding="small" Icon={Icon} {...args} />
130
+ </Row>
131
+ </Stack>
132
+ ),
133
+ };
package/src/Button.tsx ADDED
@@ -0,0 +1,171 @@
1
+ import { styled, useTheme } from '@storybook/react-native-theming';
2
+ import { ReactElement, forwardRef, useEffect, useMemo, useState } from 'react';
3
+ import type { TouchableOpacityProps } from 'react-native';
4
+
5
+ export interface ButtonProps extends TouchableOpacityProps {
6
+ asChild?: boolean;
7
+ size?: 'small' | 'medium';
8
+ padding?: 'small' | 'medium';
9
+ variant?: 'outline' | 'solid' | 'ghost';
10
+ disabled?: boolean;
11
+ active?: boolean;
12
+ animation?: 'none' | 'rotate360' | 'glow' | 'jiggle';
13
+ text?: string;
14
+ Icon?: (props: { color: string }) => ReactElement;
15
+ }
16
+
17
+ // TODO fix this type
18
+ export const Button = forwardRef<any, ButtonProps>(
19
+ (
20
+ {
21
+ Icon,
22
+ animation = 'none',
23
+ size = 'small',
24
+ variant = 'outline',
25
+ padding = 'medium',
26
+ disabled = false,
27
+ active = false,
28
+ onPress,
29
+ children,
30
+ text,
31
+ ...props
32
+ }: ButtonProps,
33
+ ref
34
+ ) => {
35
+ // let Comp: 'button' | 'a' | typeof Slot = 'button';
36
+ // if (props.isLink) Comp = 'a';
37
+ // if (asChild) Comp = Slot;
38
+ // let localVariant = variant;
39
+ // let localSize = size;
40
+
41
+ const [isAnimating, setIsAnimating] = useState(false);
42
+
43
+ const handleClick = (event) => {
44
+ if (onPress) onPress(event);
45
+ if (animation === 'none') return;
46
+ setIsAnimating(true);
47
+ };
48
+
49
+ useEffect(() => {
50
+ const timer = setTimeout(() => {
51
+ if (isAnimating) setIsAnimating(false);
52
+ }, 1000);
53
+ return () => clearTimeout(timer);
54
+ }, [isAnimating]);
55
+
56
+ return (
57
+ <StyledButton
58
+ ref={ref}
59
+ variant={variant}
60
+ size={size}
61
+ padding={padding}
62
+ disabled={disabled}
63
+ active={active}
64
+ animating={isAnimating}
65
+ animation={animation}
66
+ onPress={handleClick}
67
+ {...props}
68
+ >
69
+ {Icon && <ButtonIcon Icon={Icon} variant={variant} active={active} />}
70
+ {text && (
71
+ <ButtonText variant={variant} active={active}>
72
+ {text}
73
+ </ButtonText>
74
+ )}
75
+ {children}
76
+ </StyledButton>
77
+ );
78
+ }
79
+ );
80
+
81
+ Button.displayName = 'Button';
82
+
83
+ const StyledButton = styled.TouchableOpacity<
84
+ ButtonProps & { animating: boolean; animation: ButtonProps['animation'] }
85
+ >(({ theme, variant, size, disabled, active, padding }) => ({
86
+ border: 0,
87
+ // cursor: disabled ? 'not-allowed' : 'pointer',
88
+ display: 'flex',
89
+ flexDirection: 'row',
90
+ gap: 6,
91
+ alignItems: 'center',
92
+ justifyContent: 'center',
93
+ overflow: 'hidden',
94
+ paddingHorizontal: (() => {
95
+ if (padding === 'small' && size === 'small') return 7;
96
+ if (padding === 'small' && size === 'medium') return 9;
97
+ if (size === 'small') return 10;
98
+ if (size === 'medium') return 12;
99
+ return 0;
100
+ })(),
101
+ paddingVertical: 0,
102
+ height: size === 'small' ? 28 : 32,
103
+ position: 'relative',
104
+ transitionProperty: 'background, box-shadow',
105
+ transitionDuration: '150ms',
106
+ transitionTimingFunction: 'ease-out',
107
+ whiteSpace: 'nowrap',
108
+ userSelect: 'none',
109
+ opacity: disabled ? 0.5 : 1,
110
+ margin: 0,
111
+
112
+ backgroundColor: (() => {
113
+ if (variant === 'solid') return theme.color.secondary;
114
+ if (variant === 'outline') return theme.button.background;
115
+ if (variant === 'ghost' && active) return theme.background.hoverable;
116
+
117
+ return 'transparent';
118
+ })(),
119
+
120
+ boxShadow: variant === 'outline' ? `${theme.button.border} 0 0 0 1px inset` : 'none',
121
+ borderRadius: theme.input.borderRadius,
122
+ // Making sure that the button never shrinks below its minimum size
123
+ flexShrink: 0,
124
+ }));
125
+
126
+ export const ButtonText = styled.Text<{
127
+ variant: ButtonProps['variant'];
128
+ active: ButtonProps['active'];
129
+ }>(({ theme, variant, active }) => ({
130
+ color: (() => {
131
+ if (variant === 'solid') return theme.color.lightest;
132
+ if (variant === 'outline') return theme.input.color;
133
+ if (variant === 'ghost' && active) return theme.color.secondary;
134
+ if (variant === 'ghost') return theme.color.mediumdark;
135
+ return theme.input.color;
136
+ })(),
137
+ flexDirection: 'row',
138
+ gap: 6,
139
+ textAlign: 'center',
140
+ fontSize: theme.typography.size.s1,
141
+ fontWeight: theme.typography.weight.bold,
142
+ }));
143
+
144
+ export const ButtonIcon = ({
145
+ Icon,
146
+ active,
147
+ variant,
148
+ }: {
149
+ Icon: (props: { color: string }) => ReactElement;
150
+ variant: ButtonProps['variant'];
151
+ active: ButtonProps['active'];
152
+ }) => {
153
+ const theme = useTheme();
154
+
155
+ const color = useMemo(() => {
156
+ if (variant === 'solid') return theme.color.lightest;
157
+ if (variant === 'outline') return theme.input.color;
158
+ if (variant === 'ghost' && active) return theme.color.secondary;
159
+ if (variant === 'ghost') return theme.color.mediumdark;
160
+ return theme.input.color;
161
+ }, [
162
+ active,
163
+ theme.color.lightest,
164
+ theme.color.mediumdark,
165
+ theme.color.secondary,
166
+ theme.input.color,
167
+ variant,
168
+ ]);
169
+
170
+ return <Icon color={color} />;
171
+ };
@@ -0,0 +1,10 @@
1
+ import { Button, ButtonProps } from './Button';
2
+ import { forwardRef } from 'react';
3
+
4
+ export const IconButton = forwardRef(
5
+ ({ padding = 'small', variant = 'ghost', ...props }: ButtonProps, ref) => {
6
+ return <Button padding={padding} variant={variant} ref={ref} {...props} />;
7
+ }
8
+ );
9
+
10
+ IconButton.displayName = 'IconButton';
@@ -0,0 +1,32 @@
1
+ import type { FC, PropsWithChildren } from 'react';
2
+ import { createContext, useContext, useMemo } from 'react';
3
+ import { useWindowDimensions } from 'react-native';
4
+ import { BREAKPOINT } from './constants';
5
+
6
+ type LayoutContextType = {
7
+ isDesktop: boolean;
8
+ isMobile: boolean;
9
+ };
10
+
11
+ const LayoutContext = createContext<LayoutContextType>({
12
+ isDesktop: false,
13
+ isMobile: true,
14
+ });
15
+
16
+ export const LayoutProvider: FC<PropsWithChildren> = ({ children }) => {
17
+ const { width } = useWindowDimensions();
18
+ const isDesktop = width >= BREAKPOINT;
19
+ const isMobile = !isDesktop;
20
+
21
+ const contextValue = useMemo(
22
+ () => ({
23
+ isDesktop,
24
+ isMobile,
25
+ }),
26
+ [isDesktop, isMobile]
27
+ );
28
+
29
+ return <LayoutContext.Provider value={contextValue}>{children}</LayoutContext.Provider>;
30
+ };
31
+
32
+ export const useLayout = () => useContext(LayoutContext);
@@ -0,0 +1,21 @@
1
+ import type { FC, PropsWithChildren } from 'react';
2
+ import { createContext, useContext } from 'react';
3
+
4
+ export interface Storage {
5
+ getItem: (key: string) => Promise<string | null>;
6
+ setItem: (key: string, value: string) => Promise<void>;
7
+ }
8
+
9
+ const StorageContext = createContext<Storage>({
10
+ getItem: async () => null,
11
+ setItem: async () => {},
12
+ });
13
+
14
+ export const StorageProvider: FC<PropsWithChildren<{ storage: Storage }>> = ({
15
+ storage,
16
+ children,
17
+ }) => {
18
+ return <StorageContext.Provider value={storage}>{children}</StorageContext.Provider>;
19
+ };
20
+
21
+ export const useStorage = () => useContext(StorageContext);
Binary file
@@ -0,0 +1,4 @@
1
+ export const BREAKPOINT = 1000;
2
+ export const MEDIA_DESKTOP_BREAKPOINT = `@media (min-width: ${BREAKPOINT}px)`;
3
+ export const MOBILE_TRANSITION_DURATION = 300;
4
+ export const DEFAULT_REF_ID = 'storybook_internal';
@@ -0,0 +1,3 @@
1
+ export * from './useExpanded';
2
+ export * from './useLastViewed';
3
+ export * from './useStoreState';
@@ -0,0 +1,64 @@
1
+ import type { StoriesHash } from 'storybook/internal/manager-api';
2
+ import type { Dispatch } from 'react';
3
+ import { useCallback, useEffect, useReducer } from 'react';
4
+ import { getAncestorIds } from '../util/tree';
5
+
6
+ export type ExpandedState = Record<string, boolean>;
7
+
8
+ export interface ExpandAction {
9
+ ids: string[];
10
+ value: boolean;
11
+ }
12
+
13
+ export interface ExpandedProps {
14
+ refId: string;
15
+ data: StoriesHash;
16
+ initialExpanded?: ExpandedState;
17
+ rootIds: string[];
18
+ selectedStoryId: string | null;
19
+ onSelectStoryId: (storyId: string) => void;
20
+ }
21
+
22
+ const initializeExpanded = ({
23
+ initialExpanded,
24
+ rootIds,
25
+ }: {
26
+ refId: string;
27
+ data: StoriesHash;
28
+ initialExpanded?: ExpandedState;
29
+ rootIds: string[];
30
+ }) => {
31
+ const highlightedAncestors = [];
32
+ return [...rootIds, ...highlightedAncestors].reduce<ExpandedState>(
33
+ (acc, id) => Object.assign(acc, { [id]: id in initialExpanded ? initialExpanded[id] : true }),
34
+ {}
35
+ );
36
+ };
37
+
38
+ export const useExpanded = ({
39
+ refId,
40
+ data,
41
+ initialExpanded,
42
+ rootIds,
43
+ selectedStoryId,
44
+ }: ExpandedProps): [ExpandedState, Dispatch<ExpandAction>] => {
45
+ // Track the set of currently expanded nodes within this tree.
46
+ // Root nodes are expanded by default.
47
+ const [expanded, setExpanded] = useReducer(
48
+ (state: ExpandedState, { ids, value }: ExpandAction) =>
49
+ ids.reduce((acc, id) => Object.assign(acc, { [id]: value }), { ...state }),
50
+ { refId, data, rootIds, initialExpanded },
51
+ initializeExpanded
52
+ );
53
+
54
+ const updateExpanded = useCallback(({ ids, value }: ExpandAction) => {
55
+ setExpanded({ ids, value });
56
+ }, []);
57
+
58
+ // Expand the whole ancestry of the currently selected story whenever it changes.
59
+ useEffect(() => {
60
+ setExpanded({ ids: getAncestorIds(data, selectedStoryId), value: true });
61
+ }, [data, selectedStoryId]);
62
+
63
+ return [expanded, updateExpanded];
64
+ };
@@ -0,0 +1,35 @@
1
+ import { useCallback, useEffect, useRef } from 'react';
2
+
3
+ import type { Selection, StoryRef } from '../types';
4
+
5
+ export const useLastViewed = (selection: Selection) => {
6
+ const lastViewedRef = useRef([]);
7
+
8
+ const updateLastViewed = useCallback(
9
+ (story: StoryRef) => {
10
+ const items = lastViewedRef.current;
11
+ const index = items.findIndex(
12
+ ({ storyId, refId }) => storyId === story.storyId && refId === story.refId
13
+ );
14
+ if (index === 0) return;
15
+ if (index === -1) {
16
+ lastViewedRef.current = [story, ...items];
17
+ } else {
18
+ lastViewedRef.current = [story, ...items.slice(0, index), ...items.slice(index + 1)];
19
+ }
20
+ },
21
+ [lastViewedRef]
22
+ );
23
+
24
+ useEffect(() => {
25
+ if (selection) updateLastViewed(selection);
26
+ // eslint-disable-next-line react-hooks/exhaustive-deps
27
+ }, [selection]);
28
+
29
+ return {
30
+ getLastViewed: useCallback(() => lastViewedRef.current, [lastViewedRef]),
31
+ clearLastViewed: useCallback(() => {
32
+ lastViewedRef.current = lastViewedRef.current.slice(0, 1);
33
+ }, [lastViewedRef]),
34
+ };
35
+ };
@@ -0,0 +1,27 @@
1
+ import { useEffect, useState } from 'react';
2
+ import { useStorage } from '../StorageProvider';
3
+
4
+ export const useStoreBooleanState = (
5
+ key: string,
6
+ defaultValue: boolean
7
+ ): ReturnType<typeof useState<boolean>> => {
8
+ const storage = useStorage();
9
+
10
+ const [val, setVal] = useState<boolean>(defaultValue);
11
+
12
+ useEffect(() => {
13
+ storage.getItem(key).then((newVal) => {
14
+ if (newVal === null || newVal === undefined) {
15
+ setVal(defaultValue);
16
+ } else {
17
+ setVal(newVal === 'true');
18
+ }
19
+ });
20
+ }, [key, storage, defaultValue]);
21
+
22
+ useEffect(() => {
23
+ storage.setItem(key, val.toString());
24
+ }, [key, storage, val]);
25
+
26
+ return [val, setVal];
27
+ };
package/src/index.tsx ADDED
@@ -0,0 +1,7 @@
1
+ export * from './IconButton';
2
+ export * from './Button';
3
+ export * from './LayoutProvider';
4
+ export * from './types';
5
+ export * from './StorageProvider';
6
+ export * from './util';
7
+ export * from './hooks';
package/src/types.ts ADDED
@@ -0,0 +1,66 @@
1
+ import type { StoriesHash, State } from 'storybook/internal/manager-api';
2
+ import type { StatusValue, StatusesByStoryIdAndTypeId } from 'storybook/internal/types';
3
+ import * as Fuse from 'fuse.js';
4
+ import { PressableProps } from 'react-native';
5
+
6
+ export type Refs = State['refs'];
7
+ export type RefType = Refs[keyof Refs] & { allStatuses?: StatusesByStoryIdAndTypeId };
8
+ export type Item = StoriesHash[keyof StoriesHash];
9
+ export type Dataset = Record<string, Item>;
10
+
11
+ export interface CombinedDataset {
12
+ hash: Refs;
13
+ entries: [string, RefType][];
14
+ }
15
+
16
+ export interface ItemRef {
17
+ itemId: string;
18
+ refId: string;
19
+ }
20
+ export interface StoryRef {
21
+ storyId: string;
22
+ refId: string;
23
+ }
24
+
25
+ export type Highlight = ItemRef | null;
26
+ export type Selection = StoryRef | null;
27
+
28
+ export function isExpandType(x: any): x is ExpandType {
29
+ return !!(x && x.showAll);
30
+ }
31
+
32
+ export interface ExpandType {
33
+ showAll: () => void;
34
+ totalCount: number;
35
+ moreCount: number;
36
+ }
37
+
38
+ export type SearchItem = Item & {
39
+ refId: string;
40
+ path: string[];
41
+ status?: StatusValue;
42
+ showAll?: () => void;
43
+ };
44
+
45
+ export type SearchResult = Fuse.FuseResult<SearchItem>;
46
+
47
+ export type SearchResultProps = SearchResult & {
48
+ icon: string;
49
+ isHighlighted: boolean;
50
+ onPress: PressableProps['onPress'];
51
+ };
52
+
53
+ export type GetSearchItemProps = (args: {
54
+ item: SearchResult;
55
+ index: number;
56
+ key: string;
57
+ }) => SearchResultProps;
58
+
59
+ export type SearchChildrenFn = (args: {
60
+ query: string;
61
+ results: SearchResult[]; // TODO fix this type
62
+ isBrowsing: boolean;
63
+ closeMenu: (cb?: () => void) => void;
64
+ getItemProps: GetSearchItemProps;
65
+ highlightedIndex: number | null;
66
+ }) => React.ReactNode;