diff --git a/ROADMAP.md b/ROADMAP.md index eec067f460..d1c1997192 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,12 +1,9 @@ # Roadmap -Now that V6.5 is ready, we're looking to the future. Here's what we're working on next: - -- Firstly stablise the V6.5 release -- Version 7 -- Support story categorisation for more organisation in the story list sidebar -- Experiment with use of metro’s experimental `require.context` to simplify story imports -- Better testing support, potentially support interaction tests with the play function -- Better integration with `@storybook/addon-react-native-web` -- Better way to display addons to avoid shrinking the preview -- … and more \ No newline at end of file +- [x] Create a roadmap +- [ ] UI overhaul + - [ ] Redo theming + - [ ] integrate reanimated and gorhom as new dependencies + - [ ] New storybook/react-native-ui library for ondevice ui components + - [ ] implement new ondevice ui based on storybook v8 mobile design +- [ ] improve controls api implementation to match more closely web api diff --git a/examples/expo-example/.storybook-web/main.ts b/examples/expo-example/.storybook-web/main.ts index 10fa2f4b6e..ed554f5b12 100644 --- a/examples/expo-example/.storybook-web/main.ts +++ b/examples/expo-example/.storybook-web/main.ts @@ -17,18 +17,14 @@ const main: ServerStorybookConfig = { addons: [ getAbsolutePath('@storybook/addon-essentials'), getAbsolutePath('@storybook/addon-interactions'), - getAbsolutePath('@storybook/addon-react-native-web'), + '@storybook/addon-react-native-web', // note why does this break with get absolute? '@storybook/addon-react-native-server', ], // logLevel: 'debug', framework: { name: '@storybook/react-webpack5', - options: { - builder: { - useSWC: true, - }, - }, + options: {}, }, reactNativeServerOptions: { @@ -36,9 +32,9 @@ const main: ServerStorybookConfig = { port: 7007, }, - docs: { - autodocs: 'tag', - }, + // docs: { + // autodocs: 'tag', + // }, }; export default main; diff --git a/examples/expo-example/.storybook/index.tsx b/examples/expo-example/.storybook/index.tsx index 2380f210aa..4c92868bba 100644 --- a/examples/expo-example/.storybook/index.tsx +++ b/examples/expo-example/.storybook/index.tsx @@ -7,7 +7,7 @@ const StorybookUIRoot = view.getStorybookUI({ getItem: AsyncStorage.getItem, setItem: AsyncStorage.setItem, }, - enableWebsockets: true, + // enableWebsockets: true, // initialSelection: { kind: 'TextInput', name: 'Basic' }, // isUIHidden: true, diff --git a/examples/expo-example/.storybook/indexV6Mode.tsx b/examples/expo-example/.storybook/indexV6Mode.tsx deleted file mode 100644 index 7f6c2d632b..0000000000 --- a/examples/expo-example/.storybook/indexV6Mode.tsx +++ /dev/null @@ -1,13 +0,0 @@ -import { getStorybookUI } from '@storybook/react-native/V6'; -import './storybook.requires'; -import AsyncStorage from '@react-native-async-storage/async-storage'; - -const StorybookUIRoot = getStorybookUI({ - shouldPersistSelection: true, - storage: { - getItem: AsyncStorage.getItem, - setItem: AsyncStorage.setItem, - }, -}); - -export default StorybookUIRoot; diff --git a/examples/expo-example/.storybook/main.ts b/examples/expo-example/.storybook/main.ts index 0617fb1977..3b8df3d9dd 100644 --- a/examples/expo-example/.storybook/main.ts +++ b/examples/expo-example/.storybook/main.ts @@ -3,6 +3,7 @@ import { StorybookConfig } from '@storybook/react-native'; const main: StorybookConfig = { stories: [ '../components/**/*.stories.?(ts|tsx|js|jsx)', + '../../../packages/react-native-ui/**/*.stories.?(ts|tsx|js|jsx)', { directory: '../other_components', files: '**/*.stories.?(ts|tsx|js|jsx)', @@ -16,11 +17,10 @@ const main: StorybookConfig = { // '../components/**/*.storiesof.?(ts|tsx|js|jsx)', ], addons: [ - '@storybook/addon-ondevice-notes', '@storybook/addon-ondevice-controls', - '@storybook/addon-ondevice-knobs', '@storybook/addon-ondevice-backgrounds', '@storybook/addon-ondevice-actions', + '@storybook/addon-ondevice-notes', ], reactNative: { playFn: false, diff --git a/examples/expo-example/.storybook/storybook.requires.ts b/examples/expo-example/.storybook/storybook.requires.ts index 995dfe91d5..0c7deb9167 100644 --- a/examples/expo-example/.storybook/storybook.requires.ts +++ b/examples/expo-example/.storybook/storybook.requires.ts @@ -1,16 +1,11 @@ /* do not change this file, it is auto generated by storybook. */ -import { - start, - prepareStories, - getProjectAnnotations, -} from "@storybook/react-native"; +import { start, updateView } from "@storybook/react-native"; -import "@storybook/addon-ondevice-notes/register"; import "@storybook/addon-ondevice-controls/register"; -import "@storybook/addon-ondevice-knobs/register"; import "@storybook/addon-ondevice-backgrounds/register"; import "@storybook/addon-ondevice-actions/register"; +import "@storybook/addon-ondevice-notes/register"; const normalizedStories = [ { @@ -26,6 +21,19 @@ const normalizedStories = [ /^\.(?:(?:^|\/|(?:(?:(?!(?:^|\/)\.).)*?)\/)(?!\.)(?=.)[^/]*?\.stories\.(?:ts|tsx|js|jsx)?)$/ ), }, + { + titlePrefix: "", + directory: "../../packages/react-native-ui", + files: "**/*.stories.?(ts|tsx|js|jsx)", + importPathMatcher: + /^\.(?:(?:^|\/|(?:(?:(?!(?:^|\/)\.).)*?)\/)(?!\.)(?=.)[^/]*?\.stories\.(?:ts|tsx|js|jsx)?)$/, + // @ts-ignore + req: require.context( + "../../../packages/react-native-ui", + true, + /^\.(?:(?:^|\/|(?:(?:(?!(?:^|\/)\.).)*?)\/)(?!\.)(?=.)[^/]*?\.stories\.(?:ts|tsx|js|jsx)?)$/ + ), + }, { titlePrefix: "OtherComponents", directory: "./other_components", @@ -66,18 +74,7 @@ if (!global.view) { options, }); } else { - const { importMap } = prepareStories({ - storyEntries: normalizedStories, - options, - }); - - global.view._preview.onStoriesChanged({ - importFn: async (importPath: string) => importMap[importPath], - }); - - global.view._preview.onGetProjectAnnotationsChanged({ - getProjectAnnotations: getProjectAnnotations(global.view, annotations), - }); + updateView(global.view, annotations, normalizedStories, options); } export const view = global.view; diff --git a/examples/expo-example/App.tsx b/examples/expo-example/App.tsx index 49bfeecf36..cfb168b52f 100644 --- a/examples/expo-example/App.tsx +++ b/examples/expo-example/App.tsx @@ -20,7 +20,6 @@ let AppEntryPoint = App; if (Constants.expoConfig?.extra?.storybookEnabled === 'true') { AppEntryPoint = require('./.storybook').default; - // AppEntryPoint = require('./.storybook/indexV6Mode').default; } export default AppEntryPoint; diff --git a/examples/expo-example/components/ActionExample/Actions.stories.tsx b/examples/expo-example/components/ActionExample/Actions.stories.tsx index 68c3a23241..de18d24b56 100644 --- a/examples/expo-example/components/ActionExample/Actions.stories.tsx +++ b/examples/expo-example/components/ActionExample/Actions.stories.tsx @@ -1,11 +1,12 @@ import type { Meta, StoryObj } from '@storybook/react'; + import { ActionButton } from './Actions'; const meta = { title: 'ActionButton', component: ActionButton, argTypes: { - onPress: { action: 'pressed the button' }, + onPress: { action: 'pressed' }, }, args: { text: 'Press me!', diff --git a/examples/expo-example/components/ActionExample/Actions.tsx b/examples/expo-example/components/ActionExample/Actions.tsx index 769acc6cc3..b26eab37c8 100644 --- a/examples/expo-example/components/ActionExample/Actions.tsx +++ b/examples/expo-example/components/ActionExample/Actions.tsx @@ -1,8 +1,7 @@ -import React from 'react'; import { TouchableOpacity, Text, StyleSheet } from 'react-native'; interface ActionButtonProps { - onPress: () => void; + onPress?: () => void; text: string; } diff --git a/examples/expo-example/components/BackgroundExample/Background.storiesof.tsx b/examples/expo-example/components/BackgroundExample/Background.storiesof.tsx deleted file mode 100644 index 3aaf62ef14..0000000000 --- a/examples/expo-example/components/BackgroundExample/Background.storiesof.tsx +++ /dev/null @@ -1,19 +0,0 @@ -import React from 'react'; -import { addDecorator, storiesOf } from '@storybook/react-native/V6'; -import { withBackgrounds } from '@storybook/addon-ondevice-backgrounds'; -import { Text } from 'react-native'; - -// Remember to also include '@storybook/addon-ondevice-backgrounds' in your addons config: see /examples/expo-example/.storybook/main.ts -addDecorator(withBackgrounds); - -storiesOf('BackgroundExample/Background StoriesOf', module) - .addParameters({ - backgrounds: { - default: 'warm', - values: [ - { name: 'warm', value: 'hotpink' }, - { name: 'cool', value: 'deepskyblue' }, - ], - }, - }) - .add('Basic', () => Change background color via Addons -> Background); diff --git a/examples/expo-example/components/KnobsExample/KnobsExample.js b/examples/expo-example/components/KnobsExample/KnobsExample.js deleted file mode 100644 index 68c8f21723..0000000000 --- a/examples/expo-example/components/KnobsExample/KnobsExample.js +++ /dev/null @@ -1,34 +0,0 @@ -import React from 'react'; -import { View, Text } from 'react-native'; - -export default ({ - backgroundColor, - name, - age, - fruit, - otherFruit, - birthday, - dollars, - items, - nice, - customStyles, -}) => { - const intro = `My name is ${name}, I'm ${age} years old, and my favorite fruit is ${fruit}. I also enjoy ${otherFruit}.`; - const style = { backgroundColor, ...customStyles }; - const salutation = nice ? 'Nice to meet you!' : 'Leave me alone!'; - const dateOptions = { year: 'numeric', month: 'long', day: 'numeric' }; - return ( - - {intro} - My birthday is: {new Date(birthday).toLocaleDateString('en-US', dateOptions)} - My wallet contains: ${dollars.toFixed(2)} - In my backpack, I have: - - {items.map((item) => ( - {item} - ))} - - {salutation} - - ); -}; diff --git a/examples/expo-example/components/KnobsExample/KnobsExample.storiesof.js b/examples/expo-example/components/KnobsExample/KnobsExample.storiesof.js deleted file mode 100644 index def0f1e03a..0000000000 --- a/examples/expo-example/components/KnobsExample/KnobsExample.storiesof.js +++ /dev/null @@ -1,77 +0,0 @@ -import { - array, - boolean, - color, - date, - number, - object, - radios, - select, - text, -} from '@storybook/addon-knobs'; -import { withKnobs } from '@storybook/addon-ondevice-knobs'; -import { storiesOf } from '@storybook/react-native/V6'; -import React from 'react'; - -import KnobsExample from './KnobsExample'; - -storiesOf('Knobs Example', module) - .addDecorator(withKnobs) - .add('with knobs', () => { - const name = text('Name', 'Storyteller'); - - const age = number('Age', 70, { range: true, min: 0, max: 90, step: 5 }); - - const fruits = { - Apple: 'apple', - Banana: 'banana', - Cherry: 'cherry', - }; - - const fruit = select('Fruit', fruits, 'apple'); - - const otherFruits = { - Kiwi: 'kiwi', - Guava: 'guava', - Watermelon: 'watermelon', - }; - - const otherFruit = radios('Other Fruit', otherFruits, 'watermelon'); - - const dollars = number('Dollars', 12.5); - - // NOTE: color picker is currently broken - const backgroundColor = color('background', '#eaeaea'); - - const items = array('Items', ['Laptop', 'Book', 'Whiskey']); - - const customStyles = object('Styles', { - borderWidth: 3, - borderColor: '#000', - padding: 10, - }); - - const nice = boolean('Nice', true); - - const birthday = date('Birthday', new Date(2017, 0, 20)); - - // eslint-disable-next-line no-unused-vars - const grouped = text('groupedKnob', '', 'More'); - - return ( - - ); - }); diff --git a/examples/expo-example/components/NestingExample/ChatMessageBubble.stories.tsx b/examples/expo-example/components/NestingExample/ChatMessageBubble.stories.tsx index 9a48e9e2f1..f3bf2f848c 100644 --- a/examples/expo-example/components/NestingExample/ChatMessageBubble.stories.tsx +++ b/examples/expo-example/components/NestingExample/ChatMessageBubble.stories.tsx @@ -16,7 +16,7 @@ export const First: StoryObj = { }; export const Second: StoryObj = { - storyName: 'Second Story', + name: 'Second Story', args: { text: 'Second', }, diff --git a/examples/expo-example/components/NestingExample/ChatMessageBubbleAgain.stories.tsx b/examples/expo-example/components/NestingExample/ChatMessageBubbleAgain.stories.tsx index 9cc38aabad..8d896a1e36 100644 --- a/examples/expo-example/components/NestingExample/ChatMessageBubbleAgain.stories.tsx +++ b/examples/expo-example/components/NestingExample/ChatMessageBubbleAgain.stories.tsx @@ -17,7 +17,7 @@ export const First: StoryObj = { }; export const Second: StoryObj = { - storyName: 'Second Story', + name: 'Second Story', args: { text: 'Second', }, diff --git a/examples/expo-example/components/NestingExample/StoryList.stories.ignore.tsx b/examples/expo-example/components/NestingExample/StoryList.stories.ignore.tsx new file mode 100644 index 0000000000..e698b569ec --- /dev/null +++ b/examples/expo-example/components/NestingExample/StoryList.stories.ignore.tsx @@ -0,0 +1,81 @@ +// import { Meta, StoryObj } from '@storybook/react'; +// import StoryListView from '@storybook/react-native/src/components/StoryListView/StoryListView'; + +// export default { +// title: 'StoryListView', +// component: StoryListView, +// } as Meta; + +// export const Basic: StoryObj = { +// parameters: { deviceOnly: true }, +// args: { +// storyIndex: { +// entries: { +// 'chat-message--message-first': { +// id: 'chat-message--message-first', +// importPath: './components/NestingExample/ChatMessage.stories.tsx', +// name: 'Message First', +// title: 'Chat/Message', +// type: 'story', +// }, +// 'chat-message--message-second': { +// id: 'chat-message--message-second', +// importPath: './components/NestingExample/ChatMessage.stories.tsx', +// name: 'Message Second', +// title: 'Chat/Message', +// type: 'story', +// }, +// 'chat-message-bubble--first': { +// id: 'chat-message-bubble--first', +// importPath: './components/NestingExample/ChatMessageBubble.stories.tsx', +// name: 'First', +// title: 'Chat/Message/bubble', +// type: 'story', +// }, +// 'chat-message-bubble--second': { +// id: 'chat-message-bubble--second', +// importPath: './components/NestingExample/ChatMessageBubble.stories.tsx', +// name: 'Second Story', +// title: 'Chat/Message/bubble', +// type: 'story', +// }, +// 'chat-message-reactions--message-one': { +// id: 'chat-message-reactions--message-one', +// importPath: './components/NestingExample/ChatMessageReactions.stories.tsx', +// name: 'Message One', +// title: 'Chat/Message/Reactions', +// type: 'story', +// }, +// 'chat-message-reactions--message-two': { +// id: 'chat-message-reactions--message-two', +// importPath: './components/NestingExample/ChatMessageReactions.stories.tsx', +// name: 'Message Two', +// title: 'Chat/Message/Reactions', +// type: 'story', +// }, +// 'chat-messageinput--basic': { +// id: 'chat-messageinput--basic', +// importPath: './components/NestingExample/ChatMessageMessageInput.stories.tsx', +// name: 'Basic', +// title: 'Chat/MessageInput', +// type: 'story', +// }, +// 'storylistview--basic': { +// id: 'storylistview--basic', +// importPath: './components/NestingExample/StoryList.stories.tsx', +// name: 'Basic', +// title: 'StoryListView', +// type: 'story', +// }, +// 'text-control--basic': { +// id: 'text-control--basic', +// importPath: './components/ControlExamples/Text/Text.stories.tsx', +// name: 'Basic', +// title: 'Text control', +// type: 'story', +// }, +// }, +// v: 3, +// }, +// }, +// }; diff --git a/examples/expo-example/components/NestingExample/StoryList.stories.tsx b/examples/expo-example/components/NestingExample/StoryList.stories.tsx deleted file mode 100644 index 7853354a7b..0000000000 --- a/examples/expo-example/components/NestingExample/StoryList.stories.tsx +++ /dev/null @@ -1,81 +0,0 @@ -import { Meta, StoryObj } from '@storybook/react'; -import StoryListView from '@storybook/react-native/src/components/StoryListView/StoryListView'; - -export default { - title: 'StoryListView', - component: StoryListView, -} as Meta; - -export const Basic: StoryObj = { - parameters: { deviceOnly: true }, - args: { - storyIndex: { - entries: { - 'chat-message--message-first': { - id: 'chat-message--message-first', - importPath: './components/NestingExample/ChatMessage.stories.tsx', - name: 'Message First', - title: 'Chat/Message', - type: 'story', - }, - 'chat-message--message-second': { - id: 'chat-message--message-second', - importPath: './components/NestingExample/ChatMessage.stories.tsx', - name: 'Message Second', - title: 'Chat/Message', - type: 'story', - }, - 'chat-message-bubble--first': { - id: 'chat-message-bubble--first', - importPath: './components/NestingExample/ChatMessageBubble.stories.tsx', - name: 'First', - title: 'Chat/Message/bubble', - type: 'story', - }, - 'chat-message-bubble--second': { - id: 'chat-message-bubble--second', - importPath: './components/NestingExample/ChatMessageBubble.stories.tsx', - name: 'Second Story', - title: 'Chat/Message/bubble', - type: 'story', - }, - 'chat-message-reactions--message-one': { - id: 'chat-message-reactions--message-one', - importPath: './components/NestingExample/ChatMessageReactions.stories.tsx', - name: 'Message One', - title: 'Chat/Message/Reactions', - type: 'story', - }, - 'chat-message-reactions--message-two': { - id: 'chat-message-reactions--message-two', - importPath: './components/NestingExample/ChatMessageReactions.stories.tsx', - name: 'Message Two', - title: 'Chat/Message/Reactions', - type: 'story', - }, - 'chat-messageinput--basic': { - id: 'chat-messageinput--basic', - importPath: './components/NestingExample/ChatMessageMessageInput.stories.tsx', - name: 'Basic', - title: 'Chat/MessageInput', - type: 'story', - }, - 'storylistview--basic': { - id: 'storylistview--basic', - importPath: './components/NestingExample/StoryList.stories.tsx', - name: 'Basic', - title: 'StoryListView', - type: 'story', - }, - 'text-control--basic': { - id: 'text-control--basic', - importPath: './components/ControlExamples/Text/Text.stories.tsx', - name: 'Basic', - title: 'Text control', - type: 'story', - }, - }, - v: 3, - }, - }, -}; diff --git a/examples/expo-example/components/PromiseTest/Button.storiesof.tsx b/examples/expo-example/components/PromiseTest/Button.storiesof.tsx deleted file mode 100644 index b903d8d702..0000000000 --- a/examples/expo-example/components/PromiseTest/Button.storiesof.tsx +++ /dev/null @@ -1,36 +0,0 @@ -import { storiesOf } from '@storybook/react-native/V6'; -import React, { useState } from 'react'; -import { Button } from './Button'; - -storiesOf('button promise', module) - .add( - 'finally', - () => { - const [text, setText] = useState('Do a promise'); - const testing = () => { - const timeout = new Promise((resolve) => { - setTimeout(() => { - resolve('Promise resolved'); - }, 1000); - }); - timeout - .then((res: string) => { - setText(res); - }) - .finally(() => { - setText('Done!'); - }); - }; - return + + addonPanelRef.current.setAddonsPanelOpen(true)} + Icon={BottomBarToggleIcon} + /> + + + )} + + ); +}; + +const Nav = styled.View({ + display: 'flex', + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + width: '100%', + height: 40, + paddingHorizontal: 12, +}); + +const Container = styled.View(({ theme }) => ({ + alignSelf: 'flex-end', + width: '100%', + zIndex: 10, + background: theme.barBg, + borderTopColor: theme.appBorderColor, + borderTopWidth: 1, +})); + +const Button = styled.TouchableOpacity(({ theme }) => ({ + display: 'flex', + flexDirection: 'row', + alignItems: 'center', + gap: 10, + color: theme.color.mediumdark, + fontSize: theme.typography.size?.s2 - 1, + paddingHorizontal: 7, + fontWeight: theme.typography.weight.bold, +})); diff --git a/packages/react-native-ui/src/LayoutProvider.tsx b/packages/react-native-ui/src/LayoutProvider.tsx new file mode 100644 index 0000000000..c94c86d9ce --- /dev/null +++ b/packages/react-native-ui/src/LayoutProvider.tsx @@ -0,0 +1,90 @@ +import type { FC, PropsWithChildren } from 'react'; +import React, { createContext, useCallback, useContext, useMemo, useRef } from 'react'; +import { BREAKPOINT } from './constants'; +import { useWindowDimensions } from 'react-native'; +// import { +// BottomSheetModal, +// BottomSheetModalProvider, +// BottomSheetScrollView, +// } from '@gorhom/bottom-sheet'; + +type LayoutContextType = { + // isMobileMenuOpen: boolean; + openMobileMenu: () => void; + closeMobileMenu: () => void; + // isMobileAboutOpen: boolean; + // setMobileAboutOpen: React.Dispatch>; + // isMobilePanelOpen: boolean; + // setMobilePanelOpen: React.Dispatch>; + isDesktop: boolean; + isMobile: boolean; +}; + +const LayoutContext = createContext({ + // isMobileMenuOpen: false, + + openMobileMenu: () => {}, + closeMobileMenu: () => {}, + // isMobileAboutOpen: false, + // setMobileAboutOpen: () => {}, + // isMobilePanelOpen: false, + // setMobilePanelOpen: () => {}, + + isDesktop: false, + isMobile: false, +}); + +export const LayoutProvider: FC = ({ children }) => { + // const [isMobileMenuOpen, setMobileMenuOpen] = useState(false); + // const [isMobileAboutOpen, setMobileAboutOpen] = useState(false); + // const [isMobilePanelOpen, setMobilePanelOpen] = useState(false); + const { width } = useWindowDimensions(); + const isDesktop = width >= BREAKPOINT; + const isMobile = !isDesktop; + // const bottomSheetModalRef = useRef(null); + + const openMobileMenu = useCallback(() => { + // bottomSheetModalRef.current?.present(); + }, []); + const closeMobileMenu = useCallback(() => { + // bottomSheetModalRef.current?.dismiss(); + }, []); + + const contextValue = useMemo( + () => ({ + // isMobileMenuOpen, + openMobileMenu, + closeMobileMenu, + // isMobileAboutOpen, + // setMobileAboutOpen, + // isMobilePanelOpen, + // setMobilePanelOpen, + isDesktop, + isMobile, + }), + [ + // isMobileMenuOpen, + openMobileMenu, + closeMobileMenu, + // // isMobileAboutOpen, + // setMobileAboutOpen, + // isMobilePanelOpen, + // setMobilePanelOpen, + isDesktop, + isMobile, + ] + ); + + return ( + // + <> + {children} + {/* + bla + */} + + // + ); +}; + +export const useLayout = () => useContext(LayoutContext); diff --git a/packages/react-native-ui/src/MobileAddonsPanel.tsx b/packages/react-native-ui/src/MobileAddonsPanel.tsx new file mode 100644 index 0000000000..35029643c2 --- /dev/null +++ b/packages/react-native-ui/src/MobileAddonsPanel.tsx @@ -0,0 +1,181 @@ +import { BottomSheetModal } from '@gorhom/bottom-sheet'; +import { addons } from '@storybook/manager-api'; +import { styled } from '@storybook/react-native-theming'; +import { Addon_TypesEnum } from '@storybook/types'; +import { forwardRef, useImperativeHandle, useRef, useState } from 'react'; +import { Text, View, useWindowDimensions } from 'react-native'; +import { ScrollView } from 'react-native-gesture-handler'; +import Animated, { + useAnimatedKeyboard, + useAnimatedStyle, + useReducedMotion, + useSharedValue, +} from 'react-native-reanimated'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; + +import { IconButton } from './IconButton'; +import { CloseIcon } from './icon/CloseIcon'; + +export interface MobileAddonsPanelRef { + setAddonsPanelOpen: (isOpen: boolean) => void; +} + +export const MobileAddonsPanel = forwardRef< + MobileAddonsPanelRef, + { storyId?: string; onStateChange: (open: boolean) => void } +>(({ storyId, onStateChange }, ref) => { + const reducedMotion = useReducedMotion(); + + const addonsPanelBottomSheetRef = useRef(null); + const insets = useSafeAreaInsets(); + + const panels = addons.getElements(Addon_TypesEnum.PANEL); + const [addonSelected, setAddonSelected] = useState(Object.keys(panels)[0]); + const animatedPosition = useSharedValue(0); + + // bringing in animated keyboard disables android resizing + // TODO replicate functionality without this + useAnimatedKeyboard(); + + useImperativeHandle(ref, () => ({ + setAddonsPanelOpen: (open: boolean) => { + if (open) { + onStateChange(true); + addonsPanelBottomSheetRef.current?.present(); + } else { + onStateChange(false); + addonsPanelBottomSheetRef.current?.dismiss(); + } + }, + })); + + const { height } = useWindowDimensions(); + + const adjustedBottomSheetSize = useAnimatedStyle(() => { + return { + maxHeight: height - animatedPosition.value - insets.bottom, + }; + }, [animatedPosition.value, height, insets.bottom]); + + return ( + { + onStateChange(false); + }} + snapPoints={['25%', '50%', '75%']} + style={{ + paddingTop: 8, + }} + animatedPosition={animatedPosition} + containerStyle={{}} + backgroundStyle={{ + borderRadius: 0, + borderTopColor: 'lightgrey', + borderTopWidth: 1, + }} + keyboardBehavior="extend" + // keyboardBlurBehavior="restore" + enableDismissOnClose + enableHandlePanningGesture={true} + // enableContentPanningGesture={true} + stackBehavior="replace" + > + + + + {Object.values(panels).map(({ id, title }) => { + const resolvedTitle = typeof title === 'function' ? title({}) : title; + + return ( + setAddonSelected(id)} + text={resolvedTitle} + /> + ); + })} + + { + onStateChange(false); + addonsPanelBottomSheetRef.current?.dismiss(); + }} + /> + + + {(() => { + if (!storyId) { + return ( + + No Story Selected + + ); + } + + if (Object.keys(panels).length === 0) { + return ( + + No addons loaded. + + ); + } + + return panels[addonSelected].render({ active: true }); + })()} + + + + ); +}); + +const Tab = ({ active, onPress, text }: { active: boolean; onPress: () => void; text: string }) => { + return ( + + {text} + + ); +}; + +const TabButton = styled.TouchableOpacity<{ active: boolean }>(({ theme, active }) => ({ + borderBottomWidth: active ? 2 : 0, + borderBottomColor: active ? theme.barSelectedColor : undefined, + overflow: 'hidden', + paddingHorizontal: 15, + justifyContent: 'center', + alignItems: 'center', +})); + +const TabText = styled.Text<{ active: boolean }>(({ theme, active }) => ({ + color: active ? theme.barSelectedColor : theme.color.mediumdark, + textAlign: 'center', + fontWeight: 'bold', + fontSize: 12, + lineHeight: 12, +})); diff --git a/packages/react-native-ui/src/MobileMenuDrawer.tsx b/packages/react-native-ui/src/MobileMenuDrawer.tsx new file mode 100644 index 0000000000..82e332a225 --- /dev/null +++ b/packages/react-native-ui/src/MobileMenuDrawer.tsx @@ -0,0 +1,80 @@ +import { + BottomSheetBackdrop, + BottomSheetBackdropProps, + BottomSheetModal, + BottomSheetScrollView, +} from '@gorhom/bottom-sheet'; +import { ReactNode, forwardRef, useImperativeHandle, useRef } from 'react'; +import { Keyboard } from 'react-native'; +import { useReducedMotion } from 'react-native-reanimated'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; + +interface MobileMenuDrawerProps { + children: ReactNode | ReactNode[]; + onStateChange: (isOpen: boolean) => void; +} + +export interface MobileMenuDrawerRef { + setMobileMenuOpen: (isOpen: boolean) => void; +} + +export const BottomSheetBackdropComponent = (backdropComponentProps: BottomSheetBackdropProps) => ( + +); + +export const MobileMenuDrawer = forwardRef( + ({ children, onStateChange }, ref) => { + const reducedMotion = useReducedMotion(); + const insets = useSafeAreaInsets(); + + const menuBottomSheetRef = useRef(null); + + useImperativeHandle(ref, () => ({ + setMobileMenuOpen: (open: boolean) => { + if (open) { + onStateChange(true); + + menuBottomSheetRef.current?.present(); + } else { + Keyboard.dismiss(); + onStateChange(false); + menuBottomSheetRef.current?.dismiss(); + } + }, + })); + + return ( + { + onStateChange(false); + }} + snapPoints={['50%', '75%']} + enableDismissOnClose + enableHandlePanningGesture + enableContentPanningGesture + keyboardBehavior="extend" + keyboardBlurBehavior="restore" + stackBehavior="replace" + backdropComponent={BottomSheetBackdropComponent} + > + + {children} + + + ); + } +); diff --git a/packages/react-native-ui/src/Refs.tsx b/packages/react-native-ui/src/Refs.tsx new file mode 100644 index 0000000000..b5075c7a47 --- /dev/null +++ b/packages/react-native-ui/src/Refs.tsx @@ -0,0 +1,82 @@ +import type { FC } from 'react'; +import React, { useMemo, useCallback, useEffect, useState } from 'react'; +import type { State } from '@storybook/manager-api'; +import { styled } from '@storybook/react-native-theming'; +import { Tree } from './Tree'; +import type { RefType } from './types'; +import { getStateType } from './util/tree'; + +export interface RefProps { + isLoading: boolean; + isBrowsing: boolean; + selectedStoryId: string | null; + setSelection: (selection: { refId: string; storyId: string }) => void; +} + +const Wrapper = styled.View<{ isMain: boolean }>(({}) => ({ + position: 'relative', +})); + +export const Ref: FC = React.memo(function Ref( + props +) { + const { + index, + id: refId, + title = refId, + isLoading: isLoadingMain, + isBrowsing, + selectedStoryId, + loginUrl, + type, + expanded = true, + indexError, + previewInitialized, + setSelection, + } = props; + const length = useMemo(() => (index ? Object.keys(index).length : 0), [index]); + + const isLoadingInjected = + (type === 'auto-inject' && !previewInitialized) || type === 'server-checked'; + const isLoading = isLoadingMain || isLoadingInjected || type === 'unknown'; + const isError = !!indexError; + const isEmpty = !isLoading && length === 0; + const isAuthRequired = !!loginUrl && length === 0; + + const state = getStateType(isLoading, isAuthRequired, isError, isEmpty); + const [isExpanded, setExpanded] = useState(expanded); + + useEffect(() => { + if (index && selectedStoryId && index[selectedStoryId]) { + setExpanded(true); + } + }, [setExpanded, index, selectedStoryId]); + + const onSelectStoryId = useCallback( + (storyId: string) => { + setSelection({ refId, storyId }); + }, + [refId, setSelection] + ); + + return ( + <> + {isExpanded && ( + + {state === 'ready' && ( + + )} + + )} + + ); +}); diff --git a/packages/react-native-ui/src/Search.tsx b/packages/react-native-ui/src/Search.tsx new file mode 100644 index 0000000000..ce75f8c1eb --- /dev/null +++ b/packages/react-native-ui/src/Search.tsx @@ -0,0 +1,243 @@ +import { styled } from '@storybook/react-native-theming'; +import type { IFuseOptions } from 'fuse.js'; +import Fuse from 'fuse.js'; +import React, { useRef, useState, useCallback } from 'react'; +import { + type CombinedDataset, + type SearchItem, + type SearchResult, + type SearchChildrenFn, + type Selection, + type GetSearchItemProps, + isExpandType, +} from './types'; +import { searchItem } from './util/tree'; +import { getGroupStatus, getHighestStatus } from './util/status'; +import { SearchIcon } from './icon/SearchIcon'; +import { CloseIcon } from './icon/CloseIcon'; +import { TextInput, View } from 'react-native'; +import { BottomSheetTextInput } from '@gorhom/bottom-sheet'; + +const DEFAULT_MAX_SEARCH_RESULTS = 50; + +const options = { + shouldSort: true, + tokenize: true, + findAllMatches: true, + includeScore: true, + includeMatches: true, + threshold: 0.2, + location: 0, + distance: 100, + maxPatternLength: 32, + minMatchCharLength: 1, + keys: [ + { name: 'name', weight: 0.7 }, + { name: 'path', weight: 0.3 }, + ], +} as IFuseOptions; + +const SearchIconWrapper = styled.View(({ theme }) => ({ + position: 'absolute', + top: 0, + left: 8, + zIndex: 1, + pointerEvents: 'none', + color: theme.textMutedColor, + display: 'flex', + flexDirection: 'row', + alignItems: 'center', + height: '100%', +})); + +const SearchField = styled.View({ + display: 'flex', + flexDirection: 'column', + position: 'relative', +}); + +const Input = styled(BottomSheetTextInput)(({ theme }) => ({ + height: 32, + paddingLeft: 28, + paddingRight: 28, + borderWidth: 1, + borderColor: theme.appBorderColor, + backgroundColor: 'transparent', + borderRadius: 4, + fontSize: theme.typography.size.s1 + 1, + color: theme.color.defaultText, + width: '100%', +})); + +const ClearIcon = styled.TouchableOpacity(({ theme }) => ({ + position: 'absolute', + top: 0, + bottom: 0, + right: 8, + zIndex: 1, + color: theme.textMutedColor, + cursor: 'pointer', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + height: '100%', +})); + +export const Search = React.memo<{ + children: SearchChildrenFn; + dataset: CombinedDataset; + setSelection: (selection: Selection) => void; + getLastViewed: () => Selection[]; + initialQuery?: string; +}>(function Search({ children, dataset, setSelection, getLastViewed, initialQuery = '' }) { + const inputRef = useRef(null); + const [inputValue, setInputValue] = useState(initialQuery); + const [isOpen, setIsOpen] = useState(false); + const [allComponents, showAllComponents] = useState(false); + + const selectStory = useCallback( + (id: string, refId: string) => { + setSelection({ storyId: id, refId }); + inputRef.current?.blur(); + + showAllComponents(false); + }, + [setSelection] + ); + + const getItemProps: GetSearchItemProps = useCallback( + ({ item: result }) => { + return { + icon: result?.item?.type === 'component' ? 'component' : 'story', + result, + onPress: () => { + if (result?.item?.type === 'story') { + selectStory(result.item.id, result.item.refId); + } else if (result?.item?.type === 'component') { + selectStory(result.item.children[0], result.item.refId); + } else if (isExpandType(result) && result.showAll) { + result.showAll(); + } + }, + score: result.score, + refIndex: result.refIndex, + item: result.item, + matches: result.matches, + isHighlighted: false, + }; + }, + [selectStory] + ); + + const makeFuse = useCallback(() => { + const list = dataset.entries.reduce((acc, [refId, { index, status }]) => { + const groupStatus = getGroupStatus(index || {}, status); + + if (index) { + acc.push( + ...Object.values(index).map((item) => { + const statusValue = + status && status[item.id] + ? getHighestStatus(Object.values(status[item.id] || {}).map((s) => s.status)) + : null; + return { + ...searchItem(item, dataset.hash[refId]), + status: statusValue || groupStatus[item.id] || null, + }; + }) + ); + } + return acc; + }, []); + return new Fuse(list, options); + }, [dataset]); + + const getResults = useCallback( + (input: string) => { + const fuse = makeFuse(); + if (!input) return []; + + let results = []; + const resultIds: Set = new Set(); + const distinctResults = (fuse.search(input) as SearchResult[]).filter(({ item }) => { + if ( + !(item.type === 'component' || item.type === 'docs' || item.type === 'story') || + resultIds.has(item.parent) + ) { + return false; + } + resultIds.add(item.id); + return true; + }); + + if (distinctResults.length) { + results = distinctResults.slice(0, allComponents ? 1000 : DEFAULT_MAX_SEARCH_RESULTS); + if (distinctResults.length > DEFAULT_MAX_SEARCH_RESULTS && !allComponents) { + results.push({ + showAll: () => showAllComponents(true), + totalCount: distinctResults.length, + moreCount: distinctResults.length - DEFAULT_MAX_SEARCH_RESULTS, + }); + } + } + + const lastViewed = !input && getLastViewed(); + if (lastViewed && lastViewed.length) { + results = lastViewed.reduce((acc, { storyId, refId }) => { + const data = dataset.hash[refId]; + if (data && data.index && data.index[storyId]) { + const story = data.index[storyId]; + const item = story.type === 'story' ? data.index[story.parent] : story; + // prevent duplicates + if (!acc.some((res) => res.item.refId === refId && res.item.id === item.id)) { + acc.push({ item: searchItem(item, dataset.hash[refId]), matches: [], score: 0 }); + } + } + return acc; + }, []); + } + + return results; + }, + [allComponents, dataset.hash, getLastViewed, makeFuse] + ); + + const input = inputValue ? inputValue.trim() : ''; + const results = input ? getResults(input) : []; + + return ( + + + + + + + setIsOpen(true)} + onBlur={() => setIsOpen(false)} + /> + {isOpen && ( + { + setInputValue(''); + inputRef.current.clear(); + }} + > + + + )} + + + {children({ + query: input, + results, + isBrowsing: !isOpen || !inputValue.length, + closeMenu: () => {}, + getItemProps, + highlightedIndex: null, + })} + + ); +}); diff --git a/packages/react-native-ui/src/SearchResults.tsx b/packages/react-native-ui/src/SearchResults.tsx new file mode 100644 index 0000000000..8001a1b6ee --- /dev/null +++ b/packages/react-native-ui/src/SearchResults.tsx @@ -0,0 +1,245 @@ +import { styled } from '@storybook/react-native-theming'; +import type { FC, PropsWithChildren, ReactNode } from 'react'; +import React, { useCallback } from 'react'; +import { transparentize } from 'polished'; +import type { GetSearchItemProps, SearchResult, SearchResultProps } from './types'; +import { isExpandType } from './types'; + +import { FuseResultMatch } from 'fuse.js'; +import { PressableProps, Text, View } from 'react-native'; +import { Button } from './Button'; +import { IconButton } from './IconButton'; +import { ComponentIcon } from './icon/ComponentIcon'; +import { StoryIcon } from './icon/StoryIcon'; +import { statusMapping } from './util/status'; + +const ResultsList = styled.View({ + margin: 0, + padding: 0, + marginTop: 8, +}); + +const ResultRow = styled.TouchableOpacity<{ isHighlighted: boolean }>( + ({ theme, isHighlighted }) => ({ + width: '100%', + border: 'none', + cursor: 'pointer', + display: 'flex', + flexDirection: 'row', + alignItems: 'flex-start', + textAlign: 'left', + color: 'inherit', + fontSize: theme.typography.size.s2, + backgroundColor: isHighlighted ? theme.background.hoverable : 'transparent', + minHeight: 28, + borderRadius: 4, + gap: 6, + paddingTop: 7, + paddingBottom: 7, + paddingLeft: 8, + paddingRight: 8, + + '&:hover, &:focus': { + backgroundColor: transparentize(0.93, theme.color.secondary), + outline: 'none', + }, + }) +); + +const IconWrapper = styled.View({ + marginTop: 2, +}); + +const ResultRowContent = styled.View(() => ({ + display: 'flex', + flexDirection: 'column', +})); + +const NoResults = styled.View(({ theme }) => ({ + marginTop: 20, + textAlign: 'center', + fontSize: theme.typography.size.s2, + lineHeight: 18, + color: theme.color.defaultText, +})); + +const Mark = styled.Text(({ theme }) => ({ + backgroundColor: 'transparent', + color: theme.color.secondary, +})); + +const MoreWrapper = styled.View({ + marginTop: 8, +}); + +const RecentlyOpenedTitle = styled.View(({ theme }) => ({ + display: 'flex', + flexDirection: 'row', + justifyContent: 'space-between', + fontSize: theme.typography.size.s1 - 1, + fontWeight: theme.typography.weight.bold, + minHeight: 28, + // letterSpacing: '0.16em', <-- todo + textTransform: 'uppercase', + color: theme.textMutedColor, + marginTop: 16, + marginBottom: 4, + alignItems: 'center', +})); + +const Highlight: FC> = React.memo( + function Highlight({ children, match }) { + if (!match) return children; + const { value, indices } = match; + + const { nodes: result } = indices.reduce<{ cursor: number; nodes: ReactNode[] }>( + ({ cursor, nodes }, [start, end], index, { length }) => { + nodes.push({value.slice(cursor, start)}); + nodes.push({value.slice(start, end + 1)}); + if (index === length - 1) { + nodes.push({value.slice(end + 1)}); + } + return { cursor: end + 1, nodes }; + }, + { cursor: 0, nodes: [] } + ); + return {result}; + } +); + +const Title = styled.Text(({ theme }) => ({ + justifyContent: 'flex-start', + color: theme.textMutedColor, + fontSize: theme.typography.size.s2, +})); + +const Path = styled.View(({ theme }) => ({ + justifyContent: 'flex-start', + marginVertical: 2, + color: theme.textMutedColor, + fontSize: theme.typography.size.s1 - 1, + flexDirection: 'row', +})); + +const PathText = styled.Text(({ theme }) => ({ + fontSize: theme.typography.size.s1 - 1, + color: theme.textMutedColor, +})); + +const Result: FC = React.memo(function Result({ + item, + matches, + icon: _icon, + onPress, + ...props +}) { + const press: PressableProps['onPress'] = useCallback( + (event) => { + event.preventDefault(); + onPress?.(event); + }, + [onPress] + ); + + const nameMatch = matches.find((match: FuseResultMatch) => match.key === 'name'); + const pathMatches = matches.filter((match: FuseResultMatch) => match.key === 'path'); + + const [i] = item.status ? statusMapping[item.status] : []; + + return ( + + + {item.type === 'component' && } + {item.type === 'story' && } + + + + <Highlight key="search-result-item--label-highlight" match={nameMatch}> + {item.name} + </Highlight> + + + {item.path.map((group, index) => ( + + + match.refIndex === index)} + > + {group} + + + + ))} + + + {item.status ? i : null} + + ); +}); + +export const SearchResults: FC<{ + query: string; + results: SearchResult[]; + closeMenu: (cb?: () => void) => void; + getItemProps: GetSearchItemProps; + highlightedIndex: number | null; + isLoading?: boolean; + enableShortcuts?: boolean; + clearLastViewed?: () => void; +}> = React.memo(function SearchResults({ + query, + results, + closeMenu, + getItemProps, + highlightedIndex, + clearLastViewed, +}) { + const handleClearLastViewed = () => { + clearLastViewed(); + closeMenu(); + }; + + return ( + + {results.length > 0 && !query && ( + + Recently opened + + + )} + {results.length === 0 && query && ( + + + No components found + Find components by name or path. + + + )} + {results.map((result, index) => { + if (isExpandType(result)) { + return ( + + + ), + }, + { + id: '2', + type: types.experimental_SIDEBAR_BOTTOM, + render: () =>