diff --git a/.changeset/panel-disable-support.md b/.changeset/panel-disable-support.md new file mode 100644 index 0000000000..ef5f8fb75f --- /dev/null +++ b/.changeset/panel-disable-support.md @@ -0,0 +1,6 @@ +--- +'@storybook/react-native-ui': minor +'@storybook/react-native-ui-lite': minor +--- + +feat: honor `parameters[paramKey].disable` on addon panels — matches web Storybook. When every panel is disabled for the current story, the addons UI is hidden. diff --git a/docs/docs/intro/addons/index.md b/docs/docs/intro/addons/index.md index 53ca6b9304..98b4c625fe 100644 --- a/docs/docs/intro/addons/index.md +++ b/docs/docs/intro/addons/index.md @@ -35,8 +35,30 @@ export default main; On-device addons contain React Native code that can only run on the device. When listed in the regular `addons` array, Storybook Core tries to evaluate them as presets during server-side operations (like `extract` or `build`), which fails because Node.js can't load React Native modules. The `deviceAddons` property ensures they're only loaded at runtime on the device. For backwards compatibility, on-device addons in the `addons` array still work — they're detected by the "ondevice" substring in their name and handled correctly. However, `deviceAddons` is the recommended approach. + ::: +## Hiding addon panels per story + +On-device addon panels can be hidden for a story with the same `parameters[addonKey].disable` convention used by Storybook web. When every registered panel is disabled for the selected story, the addons button and panel are hidden. + +```ts +export const WithoutControls = { + parameters: { + controls: { disable: true }, + }, +}; + +export const WithoutAnyAddonPanels = { + parameters: { + actions: { disable: true }, + backgrounds: { disable: true }, + controls: { disable: true }, + notes: { disable: true }, + }, +}; +``` + ## Actions The Actions addon lets you log events and actions inside your stories. It's useful for verifying component interactions and event handling. diff --git a/docs/docs/intro/writing-stories.md b/docs/docs/intro/writing-stories.md index 9baa9df1e3..9d3073830c 100644 --- a/docs/docs/intro/writing-stories.md +++ b/docs/docs/intro/writing-stories.md @@ -144,6 +144,8 @@ React Native Storybook provides several built-in parameters to control the on-de | `hideFullScreenButton` | `boolean` | When `true`, hides the fullscreen toggle button | | `layout` | `'padded'` \| `'centered'` \| `'fullscreen'` | Controls the layout of the story container. `'padded'` adds padding, `'centered'` centers the content, `'fullscreen'` removes any default spacing | +On-device addon panels can also be hidden per story with `parameters[addonKey].disable`. This follows the same convention as Storybook web. For example, `parameters: { controls: { disable: true } }` hides the Controls panel for that story. When every addon panel is disabled for the selected story, the addons UI is hidden. + ```tsx // Button.stories.tsx import type { Meta, StoryObj } from '@storybook/react-native'; @@ -186,6 +188,16 @@ export const EdgeToEdge: Story = { noSafeArea: true, }, }; + +// Story without the Controls addon panel +export const WithoutControls: Story = { + args: { + label: 'Button', + }, + parameters: { + controls: { disable: true }, + }, +}; ``` ### Using decorators diff --git a/examples/expo-example/.rnstorybook/storybook.requires.ts b/examples/expo-example/.rnstorybook/storybook.requires.ts index 6e5a4cada9..fdd434d6cc 100644 --- a/examples/expo-example/.rnstorybook/storybook.requires.ts +++ b/examples/expo-example/.rnstorybook/storybook.requires.ts @@ -1,6 +1,6 @@ /* do not change this file, it is auto generated by storybook. */ /// -import { start, updateView, View, type Features } from '@storybook/react-native'; +import { start, updateView, type View, type Features } from '@storybook/react-native'; import "storybook-addon-deep-controls/register"; @@ -65,7 +65,7 @@ const annotations = [ globalThis.STORIES = normalizedStories; globalThis.STORYBOOK_WEBSOCKET = { - host: '192.168.1.171', + host: '192.168.1.210', port: 7007, secured: false, }; diff --git a/examples/expo-example/components/AddonPanelDisable/AddonPanelDisable.stories.tsx b/examples/expo-example/components/AddonPanelDisable/AddonPanelDisable.stories.tsx new file mode 100644 index 0000000000..90f1e1b42f --- /dev/null +++ b/examples/expo-example/components/AddonPanelDisable/AddonPanelDisable.stories.tsx @@ -0,0 +1,137 @@ +import type { Meta, StoryObj } from '@storybook/react-native'; +import { Pressable, StyleSheet, Text, View } from 'react-native'; +import { fn } from 'storybook/test'; + +type AddonPanelDisableDemoProps = { + label: string; + count: number; + enabled: boolean; + onPress: () => void; +}; + +const AddonPanelDisableDemo = ({ label, count, enabled, onPress }: AddonPanelDisableDemoProps) => ( + + {label} + Count: {count} + Enabled: {enabled ? 'true' : 'false'} + [styles.button, pressed && styles.buttonPressed]} + onPress={onPress} + > + Log action + + +); + +const meta = { + title: 'AddonPanelDisable', + component: AddonPanelDisableDemo, + args: { + label: 'Addon panel disable test', + count: 1, + enabled: true, + onPress: fn(), + }, + argTypes: { + label: { control: 'text' }, + count: { control: 'number' }, + enabled: { control: 'boolean' }, + }, + parameters: { + notes: ` +# Addon panel disable test + +Use these stories to manually verify \`parameters[paramKey].disable\`. +`, + backgrounds: { + options: { + panelDisableLight: { name: 'Panel disable light', value: '#f7f2e8' }, + panelDisableDark: { name: 'Panel disable dark', value: '#20232a' }, + }, + }, + }, +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +export const AllPanelsEnabled: Story = {}; + +export const ControlsDisabled: Story = { + parameters: { + controls: { disable: true }, + }, +}; + +export const ActionsDisabled: Story = { + parameters: { + actions: { disable: true }, + }, +}; + +export const BackgroundsDisabled: Story = { + parameters: { + backgrounds: { disable: true }, + }, +}; + +export const NotesDisabled: Story = { + parameters: { + notes: { disable: true }, + }, +}; + +export const FirstPanelDisabled: Story = { + parameters: { + controls: { disable: true }, + }, +}; + +export const LastPanelOnly: Story = { + parameters: { + actions: { disable: true }, + backgrounds: { disable: true }, + controls: { disable: true }, + }, +}; + +export const AllPanelsDisabled: Story = { + parameters: { + actions: { disable: true }, + backgrounds: { disable: true }, + controls: { disable: true }, + notes: { disable: true }, + }, +}; + +const styles = StyleSheet.create({ + container: { + flex: 1, + alignItems: 'center', + justifyContent: 'center', + gap: 12, + padding: 24, + }, + heading: { + fontSize: 20, + fontWeight: '700', + textAlign: 'center', + }, + body: { + fontSize: 16, + }, + button: { + backgroundColor: '#1ea7fd', + borderRadius: 4, + paddingHorizontal: 16, + paddingVertical: 10, + }, + buttonText: { + color: '#ffffff', + fontWeight: '700', + }, + buttonPressed: { + opacity: 0.8, + }, +}); diff --git a/packages/react-native-ui-lite/src/Layout.tsx b/packages/react-native-ui-lite/src/Layout.tsx index 4580f83b2d..d62502f512 100644 --- a/packages/react-native-ui-lite/src/Layout.tsx +++ b/packages/react-native-ui-lite/src/Layout.tsx @@ -11,12 +11,25 @@ import { useStoreNumberState, useStyle, } from '@storybook/react-native-ui-common'; -import { ReactElement, ReactNode, useCallback, useLayoutEffect, useRef, useState } from 'react'; +import { + ReactElement, + ReactNode, + useCallback, + useLayoutEffect, + useMemo, + useRef, + useState, +} from 'react'; import { Text, TouchableOpacity, useWindowDimensions, View, ViewStyle } from 'react-native'; import { SafeAreaProvider, useSafeAreaInsets } from 'react-native-safe-area-context'; import { SET_CURRENT_STORY } from 'storybook/internal/core-events'; import type { Args, StoryContext } from 'storybook/internal/csf'; -import { type API_IndexHash } from 'storybook/internal/types'; +import { + Addon_TypesEnum, + type Addon_BaseType, + type Addon_Collection, + type API_IndexHash, +} from 'storybook/internal/types'; import { addons } from 'storybook/manager-api'; import { AddonsTabs, MobileAddonsPanel, MobileAddonsPanelRef } from './MobileAddonsPanel'; import { MobileMenuDrawer, MobileMenuDrawerRef } from './MobileMenuDrawer'; @@ -119,6 +132,14 @@ export const Layout = ({ 'desktopPanelState', true ); + + const hasEnabledPanels = useMemo(() => { + const allPanels: Addon_Collection = addons.getElements(Addon_TypesEnum.PANEL); + return Object.values(allPanels).some( + (p) => !p.paramKey || !story?.parameters?.[p.paramKey]?.disable + ); + }, [story?.parameters]); + const [isMobileSearchActive, setIsMobileSearchActive] = useState(false); const [sidebarWidth, setSidebarWidth] = useStoreNumberState('desktopSidebarWidth', 240); @@ -317,7 +338,7 @@ export const Layout = ({ )} - {isDesktop ? ( + {isDesktop && hasEnabledPanels ? ( <> {desktopAddonsPanelOpen ? ( {desktopAddonsPanelOpen ? ( - setDesktopAddonsPanelOpen(false)} /> + setDesktopAddonsPanelOpen(false)} + /> ) : ( - addonPanelRef.current.setAddonsPanelOpen(true)} - Icon={BottomBarToggleIcon} - accessibilityLabel="Open addons panel" - /> + {hasEnabledPanels && ( + addonPanelRef.current.setAddonsPanelOpen(true)} + Icon={BottomBarToggleIcon} + accessibilityLabel="Open addons panel" + /> + )} ) : null} @@ -395,7 +422,9 @@ export const Layout = ({ )} - {isDesktop ? null : } + {!isDesktop && hasEnabledPanels ? ( + + ) : null} ); }; diff --git a/packages/react-native-ui-lite/src/MobileAddonsPanel.tsx b/packages/react-native-ui-lite/src/MobileAddonsPanel.tsx index abc7934553..500725bc04 100644 --- a/packages/react-native-ui-lite/src/MobileAddonsPanel.tsx +++ b/packages/react-native-ui-lite/src/MobileAddonsPanel.tsx @@ -1,5 +1,6 @@ import { styled, useTheme } from '@storybook/react-native-theming'; import { IconButton, useStyle } from '@storybook/react-native-ui-common'; +import type { Parameters } from 'storybook/internal/csf'; import { forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useState } from 'react'; import { Animated, @@ -28,8 +29,10 @@ export interface MobileAddonsPanelRef { setAddonsPanelOpen: (isOpen: boolean) => void; } -export const MobileAddonsPanel = forwardRef( - ({ storyId }, ref) => { +type MobileAddonsPanelProps = { storyId?: string; parameters?: Parameters }; + +export const MobileAddonsPanel = forwardRef( + ({ storyId, parameters }, ref) => { const theme = useTheme(); const { height } = useWindowDimensions(); const defaultPanelHeight = height / 2; @@ -145,6 +148,7 @@ export const MobileAddonsPanel = forwardRef @@ -191,12 +195,25 @@ const hiddenStyle = { const hitSlop = { top: 10, right: 10, bottom: 10, left: 10 }; -export const AddonsTabs = ({ onClose, storyId }: { onClose?: () => void; storyId?: string }) => { - const panels: Addon_Collection = addons.getElements(Addon_TypesEnum.PANEL); +type AddonsTabsProps = { + onClose?: () => void; + storyId?: string; + parameters?: Parameters; +}; + +export const AddonsTabs = ({ onClose, storyId, parameters }: AddonsTabsProps) => { + const panels = useMemo>(() => { + const allPanels: Addon_Collection = addons.getElements(Addon_TypesEnum.PANEL); + + return Object.fromEntries( + Object.entries(allPanels).filter(([, p]) => !p.paramKey || !parameters?.[p.paramKey]?.disable) + ); + }, [parameters]); + const insets = useSafeAreaInsets(); const [addonSelected, setAddonSelected] = useState(Object.keys(panels)[0]); - const panelEntries = useMemo(() => Object.entries(panels), [panels]); + const activeAddonId = panels[addonSelected] ? addonSelected : panelEntries[0]?.[0]; const scrollContentContainerStyle = useStyle( () => ({ @@ -220,7 +237,7 @@ export const AddonsTabs = ({ onClose, storyId }: { onClose?: () => void; storyId return ( setAddonSelected(id)} text={String(resolvedTitle)} /> @@ -252,7 +269,7 @@ export const AddonsTabs = ({ onClose, storyId }: { onClose?: () => void; storyId ) : ( panelEntries.map(([id, p]) => ( - + )) diff --git a/packages/react-native-ui/src/Layout.tsx b/packages/react-native-ui/src/Layout.tsx index ae808b9160..f83b705b68 100644 --- a/packages/react-native-ui/src/Layout.tsx +++ b/packages/react-native-ui/src/Layout.tsx @@ -11,13 +11,18 @@ import { useStyle, type SBUI, } from '@storybook/react-native-ui-common'; -import { ReactNode, useCallback, useLayoutEffect, useRef, useState } from 'react'; +import { ReactNode, useCallback, useLayoutEffect, useMemo, useRef, useState } from 'react'; import { ScrollView, Text, TouchableOpacity, View, ViewStyle } from 'react-native'; import { GestureHandlerRootView } from 'react-native-gesture-handler'; import { SafeAreaProvider, useSafeAreaInsets } from 'react-native-safe-area-context'; import { SET_CURRENT_STORY } from 'storybook/internal/core-events'; import type { Args, StoryContext } from 'storybook/internal/csf'; -import type { API_IndexHash } from 'storybook/internal/types'; +import { + Addon_TypesEnum, + type Addon_BaseType, + type Addon_Collection, + type API_IndexHash, +} from 'storybook/internal/types'; import { addons } from 'storybook/manager-api'; import { DEFAULT_REF_ID } from './constants'; import { BottomBarToggleIcon } from './icon/BottomBarToggleIcon'; @@ -117,6 +122,14 @@ export const Layout = ({ true ); + const hasEnabledPanels = useMemo(() => { + const allPanels: Addon_Collection = addons.getElements(Addon_TypesEnum.PANEL); + + return Object.values(allPanels).some( + (p) => !p.paramKey || !story?.parameters?.[p.paramKey]?.disable + ); + }, [story?.parameters]); + const [uiHidden, setUiHidden] = useState(false); useLayoutEffect(() => { @@ -270,10 +283,14 @@ export const Layout = ({ )} - {isDesktop ? ( + {isDesktop && hasEnabledPanels ? ( {desktopAddonsPanelOpen ? ( - setDesktopAddonsPanelOpen(false)} /> + setDesktopAddonsPanelOpen(false)} + /> ) : ( - addonPanelRef.current.setAddonsPanelOpen(true)} - Icon={BottomBarToggleIcon} - /> + {hasEnabledPanels && ( + addonPanelRef.current.setAddonsPanelOpen(true)} + Icon={BottomBarToggleIcon} + /> + )} ) : null} @@ -330,7 +349,9 @@ export const Layout = ({ ) : null} - {!isDesktop ? : null} + {!isDesktop && hasEnabledPanels ? ( + + ) : null} ); }; diff --git a/packages/react-native-ui/src/MobileAddonsPanel.tsx b/packages/react-native-ui/src/MobileAddonsPanel.tsx index 6a845170b4..ea5efd0f6b 100644 --- a/packages/react-native-ui/src/MobileAddonsPanel.tsx +++ b/packages/react-native-ui/src/MobileAddonsPanel.tsx @@ -1,6 +1,7 @@ import { BottomSheetModal } from '@gorhom/bottom-sheet'; import { addons } from 'storybook/manager-api'; import { styled, useTheme } from '@storybook/react-native-theming'; +import type { Parameters } from 'storybook/internal/csf'; import { Addon_TypesEnum, type Addon_BaseType, @@ -31,8 +32,10 @@ const contentStyle = { flex: 1, } satisfies StyleProp; -export const MobileAddonsPanel = forwardRef( - ({ storyId }, ref) => { +type MobileAddonsPanelProps = { storyId?: string; parameters?: Parameters }; + +export const MobileAddonsPanel = forwardRef( + ({ storyId, parameters }, ref) => { const theme = useTheme(); const reducedMotion = useReducedMotion(); @@ -103,6 +106,7 @@ export const MobileAddonsPanel = forwardRef @@ -148,8 +152,22 @@ const hiddenStyle = { const hitSlop = { top: 10, right: 10, bottom: 10, left: 10 }; -export const AddonsTabs = ({ onClose, storyId }: { onClose?: () => void; storyId?: string }) => { - const panels: Addon_Collection = addons.getElements(Addon_TypesEnum.PANEL); +export const AddonsTabs = ({ + onClose, + storyId, + parameters, +}: { + onClose?: () => void; + storyId?: string; + parameters?: Parameters; +}) => { + const panels = useMemo>(() => { + const allPanels: Addon_Collection = addons.getElements(Addon_TypesEnum.PANEL); + + return Object.fromEntries( + Object.entries(allPanels).filter(([, p]) => !p.paramKey || !parameters?.[p.paramKey]?.disable) + ); + }, [parameters]); const [addonSelected, setAddonSelected] = useState(Object.keys(panels)[0]); @@ -162,6 +180,7 @@ export const AddonsTabs = ({ onClose, storyId }: { onClose?: () => void; storyId }); const panelEntries = useMemo(() => Object.entries(panels), [panels]); + const activeAddonId = panels[addonSelected] ? addonSelected : panelEntries[0]?.[0]; return ( @@ -177,7 +196,7 @@ export const AddonsTabs = ({ onClose, storyId }: { onClose?: () => void; storyId return ( setAddonSelected(id)} text={String(resolvedTitle)} /> @@ -207,7 +226,7 @@ export const AddonsTabs = ({ onClose, storyId }: { onClose?: () => void; storyId ) : ( panelEntries.map(([id, p]) => ( - + )) diff --git a/packages/react-native-ui/src/MobileMenuDrawer.tsx b/packages/react-native-ui/src/MobileMenuDrawer.tsx index 6c97a8702c..0116ec3ac0 100644 --- a/packages/react-native-ui/src/MobileMenuDrawer.tsx +++ b/packages/react-native-ui/src/MobileMenuDrawer.tsx @@ -13,7 +13,7 @@ import { useMemo, useRef, } from 'react'; -import { Keyboard, Platform } from 'react-native'; +import { Keyboard, Platform, type ViewStyle } from 'react-native'; import { useAnimatedStyle, useReducedMotion } from 'react-native-reanimated'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { useSelectedNode } from './SelectedNodeProvider'; @@ -44,7 +44,7 @@ export const BottomSheetBackdropComponent = (backdropComponentProps: BottomSheet pressBehavior={'close'} style={[ backdropComponentProps.style, - androidTouchEventFix, + androidTouchEventFix as unknown as ViewStyle, { backgroundColor: 'rgba(0,0,0,0.5)', paddingTop: Platform.OS === 'android' ? 1 : undefined,