From 7021be7de2b6197c150f651750fbe1c875762caa Mon Sep 17 00:00:00 2001 From: Mathieu Blanchette Date: Mon, 11 May 2026 10:49:48 -0400 Subject: [PATCH 1/8] feat(react-native-ui): honor parameters[paramKey].disable on addon panels Closes #888 --- .changeset/panel-disable-support.md | 6 + packages/react-native-ui-lite/src/Layout.tsx | 41 ++- .../src/MobileAddonsPanel.tsx | 248 ++++++++++-------- packages/react-native-ui/src/Layout.tsx | 36 ++- .../react-native-ui/src/MobileAddonsPanel.tsx | 189 +++++++------ 5 files changed, 309 insertions(+), 211 deletions(-) create mode 100644 .changeset/panel-disable-support.md 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/packages/react-native-ui-lite/src/Layout.tsx b/packages/react-native-ui-lite/src/Layout.tsx index 4580f83b2d..bdf5a1e180 100644 --- a/packages/react-native-ui-lite/src/Layout.tsx +++ b/packages/react-native-ui-lite/src/Layout.tsx @@ -16,7 +16,12 @@ import { Text, TouchableOpacity, useWindowDimensions, View, ViewStyle } from 're 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 +124,12 @@ export const Layout = ({ 'desktopPanelState', true ); + + const allPanels: Addon_Collection = addons.getElements(Addon_TypesEnum.PANEL); + const hasEnabledPanels = Object.values(allPanels).some( + (p) => !p.paramKey || !story?.parameters?.[p.paramKey]?.disable + ); + const [isMobileSearchActive, setIsMobileSearchActive] = useState(false); const [sidebarWidth, setSidebarWidth] = useStoreNumberState('desktopSidebarWidth', 240); @@ -317,7 +328,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 +412,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..7ae472e5c9 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,130 +29,132 @@ export interface MobileAddonsPanelRef { setAddonsPanelOpen: (isOpen: boolean) => void; } -export const MobileAddonsPanel = forwardRef( - ({ storyId }, ref) => { - const theme = useTheme(); - const { height } = useWindowDimensions(); - const defaultPanelHeight = height / 2; - const positionBottomAnimation = useAnimatedValue(height / 2); - const [panelHeight, setPanelHeight] = useState(defaultPanelHeight); - const [isOpen, setIsOpen] = useState(false); +export const MobileAddonsPanel = forwardRef< + MobileAddonsPanelRef, + { storyId?: string; parameters?: Parameters } +>(({ storyId, parameters }, ref) => { + const theme = useTheme(); + const { height } = useWindowDimensions(); + const defaultPanelHeight = height / 2; + const positionBottomAnimation = useAnimatedValue(height / 2); + const [panelHeight, setPanelHeight] = useState(defaultPanelHeight); + const [isOpen, setIsOpen] = useState(false); - useEffect(() => { - setPanelHeight(defaultPanelHeight); - }, [defaultPanelHeight]); + useEffect(() => { + setPanelHeight(defaultPanelHeight); + }, [defaultPanelHeight]); - const setMobileMenuOpen = useCallback( - (open: boolean) => { - setIsOpen(open); + const setMobileMenuOpen = useCallback( + (open: boolean) => { + setIsOpen(open); - if (open) { + if (open) { + setPanelHeight(defaultPanelHeight); + positionBottomAnimation.setValue(defaultPanelHeight); + Animated.timing(positionBottomAnimation, { + toValue: 0, + duration: 350, + useNativeDriver: true, + easing: Easing.inOut(Easing.cubic), + }).start(); + } else { + Animated.timing(positionBottomAnimation, { + toValue: defaultPanelHeight, + duration: 350, + useNativeDriver: true, + easing: Easing.inOut(Easing.cubic), + }).start(() => { setPanelHeight(defaultPanelHeight); - positionBottomAnimation.setValue(defaultPanelHeight); - Animated.timing(positionBottomAnimation, { - toValue: 0, - duration: 350, - useNativeDriver: true, - easing: Easing.inOut(Easing.cubic), - }).start(); - } else { - Animated.timing(positionBottomAnimation, { - toValue: defaultPanelHeight, - duration: 350, - useNativeDriver: true, - easing: Easing.inOut(Easing.cubic), - }).start(() => { - setPanelHeight(defaultPanelHeight); - }); - } - }, - [defaultPanelHeight, positionBottomAnimation] + }); + } + }, + [defaultPanelHeight, positionBottomAnimation] + ); + + useEffect(() => { + const handleKeyboardShow = ({ endCoordinates }: KeyboardEvent) => { + if (isOpen) { + setPanelHeight((height - endCoordinates.height) / 2); + positionBottomAnimation.setValue(-endCoordinates.height); + } + }; + + const handleKeyboardHide = () => { + if (isOpen) { + setPanelHeight(defaultPanelHeight); + positionBottomAnimation.setValue(0); + } + }; + + const showSubscription = Keyboard.addListener( + Platform.OS === 'ios' ? 'keyboardWillShow' : 'keyboardDidShow', + handleKeyboardShow + ); + const hideSubscription = Keyboard.addListener( + Platform.OS === 'ios' ? 'keyboardWillHide' : 'keyboardDidHide', + handleKeyboardHide ); - useEffect(() => { - const handleKeyboardShow = ({ endCoordinates }: KeyboardEvent) => { - if (isOpen) { - setPanelHeight((height - endCoordinates.height) / 2); - positionBottomAnimation.setValue(-endCoordinates.height); - } - }; + // Clean up subscriptions on unmount + return () => { + showSubscription.remove(); + hideSubscription.remove(); + }; + }, [defaultPanelHeight, height, positionBottomAnimation, isOpen]); - const handleKeyboardHide = () => { - if (isOpen) { - setPanelHeight(defaultPanelHeight); - positionBottomAnimation.setValue(0); - } - }; - - const showSubscription = Keyboard.addListener( - Platform.OS === 'ios' ? 'keyboardWillShow' : 'keyboardDidShow', - handleKeyboardShow - ); - const hideSubscription = Keyboard.addListener( - Platform.OS === 'ios' ? 'keyboardWillHide' : 'keyboardDidHide', - handleKeyboardHide - ); - - // Clean up subscriptions on unmount - return () => { - showSubscription.remove(); - hideSubscription.remove(); - }; - }, [defaultPanelHeight, height, positionBottomAnimation, isOpen]); - - useImperativeHandle(ref, () => ({ - setAddonsPanelOpen: (open: boolean) => { - if (open) { - setMobileMenuOpen(true); - } else { - setMobileMenuOpen(false); - } - }, - })); - - return ( - ({ + setAddonsPanelOpen: (open: boolean) => { + if (open) { + setMobileMenuOpen(true); + } else { + setMobileMenuOpen(false); + } + }, + })); + + return ( + + - { + setMobileMenuOpen(false); + Keyboard.dismiss(); }} - > - { - setMobileMenuOpen(false); - Keyboard.dismiss(); - }} - storyId={storyId} - /> - + storyId={storyId} + parameters={parameters} + /> - - ); - } -); + + + ); +}); MobileAddonsPanel.displayName = 'MobileAddonsPanel'; @@ -191,11 +194,36 @@ 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 allPanels: Addon_Collection = addons.getElements(Addon_TypesEnum.PANEL); + + const panels = useMemo>( + () => + Object.fromEntries( + Object.entries(allPanels).filter( + ([, p]) => !p.paramKey || !parameters?.[p.paramKey]?.disable + ) + ), + [allPanels, parameters] + ); + const insets = useSafeAreaInsets(); const [addonSelected, setAddonSelected] = useState(Object.keys(panels)[0]); + useEffect(() => { + if (!panels[addonSelected] && Object.keys(panels).length > 0) { + setAddonSelected(Object.keys(panels)[0]); + } + }, [panels, addonSelected]); + const panelEntries = useMemo(() => Object.entries(panels), [panels]); const scrollContentContainerStyle = useStyle( diff --git a/packages/react-native-ui/src/Layout.tsx b/packages/react-native-ui/src/Layout.tsx index ae808b9160..a1a96b4de0 100644 --- a/packages/react-native-ui/src/Layout.tsx +++ b/packages/react-native-ui/src/Layout.tsx @@ -17,7 +17,12 @@ 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,11 @@ export const Layout = ({ true ); + const allPanels: Addon_Collection = addons.getElements(Addon_TypesEnum.PANEL); + const hasEnabledPanels = Object.values(allPanels).some( + (p) => !p.paramKey || !story?.parameters?.[p.paramKey]?.disable + ); + const [uiHidden, setUiHidden] = useState(false); useLayoutEffect(() => { @@ -270,10 +280,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 +346,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..bc632486f6 100644 --- a/packages/react-native-ui/src/MobileAddonsPanel.tsx +++ b/packages/react-native-ui/src/MobileAddonsPanel.tsx @@ -1,12 +1,13 @@ 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, type Addon_Collection, } from 'storybook/internal/types'; -import { forwardRef, useImperativeHandle, useMemo, useRef, useState } from 'react'; +import { forwardRef, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react'; import { Platform, StyleProp, Text, View, ViewStyle, useWindowDimensions } from 'react-native'; import { ScrollView } from 'react-native-gesture-handler'; import Animated, { @@ -31,84 +32,86 @@ const contentStyle = { flex: 1, } satisfies StyleProp; -export const MobileAddonsPanel = forwardRef( - ({ storyId }, ref) => { - const theme = useTheme(); - const reducedMotion = useReducedMotion(); - - const addonsPanelBottomSheetRef = useRef(null); - const insets = useSafeAreaInsets(); - - const animatedPosition = useSharedValue(0); - - // bringing in animated keyboard disables android resizing - // TODO replicate functionality without this - useAnimatedKeyboard(); - - useImperativeHandle(ref, () => ({ - setAddonsPanelOpen: (open: boolean) => { - if (open) { - addonsPanelBottomSheetRef.current?.present(); - } else { - addonsPanelBottomSheetRef.current?.dismiss(); - } - }, - })); - - const { height } = useWindowDimensions(); - - const adjustedBottomSheetSize = useAnimatedStyle(() => { - const extraPadding = Platform.OS === 'android' ? 32 : 16 + insets.bottom; - return { - maxHeight: height - animatedPosition.value - extraPadding, - }; - }, [animatedPosition, height, insets.bottom]); - - const backgroundStyle = useStyle(() => { - return { - borderRadius: 0, - borderTopColor: theme.appBorderColor, - borderTopWidth: 1, - backgroundColor: theme.background.content, - }; - }); - - const handleIndicatorStyle = useStyle(() => { - return { - backgroundColor: theme.textMutedColor, - }; - }); - - return ( - - - { - addonsPanelBottomSheetRef.current?.dismiss(); - }} - storyId={storyId} - /> - - - ); - } -); +export const MobileAddonsPanel = forwardRef< + MobileAddonsPanelRef, + { storyId?: string; parameters?: Parameters } +>(({ storyId, parameters }, ref) => { + const theme = useTheme(); + const reducedMotion = useReducedMotion(); + + const addonsPanelBottomSheetRef = useRef(null); + const insets = useSafeAreaInsets(); + + const animatedPosition = useSharedValue(0); + + // bringing in animated keyboard disables android resizing + // TODO replicate functionality without this + useAnimatedKeyboard(); + + useImperativeHandle(ref, () => ({ + setAddonsPanelOpen: (open: boolean) => { + if (open) { + addonsPanelBottomSheetRef.current?.present(); + } else { + addonsPanelBottomSheetRef.current?.dismiss(); + } + }, + })); + + const { height } = useWindowDimensions(); + + const adjustedBottomSheetSize = useAnimatedStyle(() => { + const extraPadding = Platform.OS === 'android' ? 32 : 16 + insets.bottom; + return { + maxHeight: height - animatedPosition.value - extraPadding, + }; + }, [animatedPosition, height, insets.bottom]); + + const backgroundStyle = useStyle(() => { + return { + borderRadius: 0, + borderTopColor: theme.appBorderColor, + borderTopWidth: 1, + backgroundColor: theme.background.content, + }; + }); + + const handleIndicatorStyle = useStyle(() => { + return { + backgroundColor: theme.textMutedColor, + }; + }); + + return ( + + + { + addonsPanelBottomSheetRef.current?.dismiss(); + }} + storyId={storyId} + parameters={parameters} + /> + + + ); +}); MobileAddonsPanel.displayName = 'MobileAddonsPanel'; @@ -148,11 +151,35 @@ 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 allPanels: Addon_Collection = addons.getElements(Addon_TypesEnum.PANEL); + + const panels = useMemo>( + () => + Object.fromEntries( + Object.entries(allPanels).filter( + ([, p]) => !p.paramKey || !parameters?.[p.paramKey]?.disable + ) + ), + [allPanels, parameters] + ); const [addonSelected, setAddonSelected] = useState(Object.keys(panels)[0]); + useEffect(() => { + if (!panels[addonSelected] && Object.keys(panels).length > 0) { + setAddonSelected(Object.keys(panels)[0]); + } + }, [panels, addonSelected]); + const insets = useSafeAreaInsets(); const scrollContentContainerStyle = useStyle(() => { From bf065bfa45f661019812bc0fcbddc35aae5f4f61 Mon Sep 17 00:00:00 2001 From: Daniel Williams Date: Tue, 2 Jun 2026 21:13:45 +0100 Subject: [PATCH 2/8] adjustment --- .../src/MobileAddonsPanel.tsx | 249 +++++++++--------- 1 file changed, 121 insertions(+), 128 deletions(-) diff --git a/packages/react-native-ui-lite/src/MobileAddonsPanel.tsx b/packages/react-native-ui-lite/src/MobileAddonsPanel.tsx index 7ae472e5c9..39d9ebbc2d 100644 --- a/packages/react-native-ui-lite/src/MobileAddonsPanel.tsx +++ b/packages/react-native-ui-lite/src/MobileAddonsPanel.tsx @@ -29,132 +29,133 @@ export interface MobileAddonsPanelRef { setAddonsPanelOpen: (isOpen: boolean) => void; } -export const MobileAddonsPanel = forwardRef< - MobileAddonsPanelRef, - { storyId?: string; parameters?: Parameters } ->(({ storyId, parameters }, ref) => { - const theme = useTheme(); - const { height } = useWindowDimensions(); - const defaultPanelHeight = height / 2; - const positionBottomAnimation = useAnimatedValue(height / 2); - const [panelHeight, setPanelHeight] = useState(defaultPanelHeight); - const [isOpen, setIsOpen] = useState(false); - - useEffect(() => { - setPanelHeight(defaultPanelHeight); - }, [defaultPanelHeight]); - - const setMobileMenuOpen = useCallback( - (open: boolean) => { - setIsOpen(open); - - if (open) { - setPanelHeight(defaultPanelHeight); - positionBottomAnimation.setValue(defaultPanelHeight); - Animated.timing(positionBottomAnimation, { - toValue: 0, - duration: 350, - useNativeDriver: true, - easing: Easing.inOut(Easing.cubic), - }).start(); - } else { - Animated.timing(positionBottomAnimation, { - toValue: defaultPanelHeight, - duration: 350, - useNativeDriver: true, - easing: Easing.inOut(Easing.cubic), - }).start(() => { +type MobileAddonsPanelProps = { storyId?: string; parameters?: Parameters }; + +export const MobileAddonsPanel = forwardRef( + ({ storyId, parameters }, ref) => { + const theme = useTheme(); + const { height } = useWindowDimensions(); + const defaultPanelHeight = height / 2; + const positionBottomAnimation = useAnimatedValue(height / 2); + const [panelHeight, setPanelHeight] = useState(defaultPanelHeight); + const [isOpen, setIsOpen] = useState(false); + + useEffect(() => { + setPanelHeight(defaultPanelHeight); + }, [defaultPanelHeight]); + + const setMobileMenuOpen = useCallback( + (open: boolean) => { + setIsOpen(open); + + if (open) { setPanelHeight(defaultPanelHeight); - }); - } - }, - [defaultPanelHeight, positionBottomAnimation] - ); - - useEffect(() => { - const handleKeyboardShow = ({ endCoordinates }: KeyboardEvent) => { - if (isOpen) { - setPanelHeight((height - endCoordinates.height) / 2); - positionBottomAnimation.setValue(-endCoordinates.height); - } - }; - - const handleKeyboardHide = () => { - if (isOpen) { - setPanelHeight(defaultPanelHeight); - positionBottomAnimation.setValue(0); - } - }; - - const showSubscription = Keyboard.addListener( - Platform.OS === 'ios' ? 'keyboardWillShow' : 'keyboardDidShow', - handleKeyboardShow - ); - const hideSubscription = Keyboard.addListener( - Platform.OS === 'ios' ? 'keyboardWillHide' : 'keyboardDidHide', - handleKeyboardHide + positionBottomAnimation.setValue(defaultPanelHeight); + Animated.timing(positionBottomAnimation, { + toValue: 0, + duration: 350, + useNativeDriver: true, + easing: Easing.inOut(Easing.cubic), + }).start(); + } else { + Animated.timing(positionBottomAnimation, { + toValue: defaultPanelHeight, + duration: 350, + useNativeDriver: true, + easing: Easing.inOut(Easing.cubic), + }).start(() => { + setPanelHeight(defaultPanelHeight); + }); + } + }, + [defaultPanelHeight, positionBottomAnimation] ); - // Clean up subscriptions on unmount - return () => { - showSubscription.remove(); - hideSubscription.remove(); - }; - }, [defaultPanelHeight, height, positionBottomAnimation, isOpen]); - - useImperativeHandle(ref, () => ({ - setAddonsPanelOpen: (open: boolean) => { - if (open) { - setMobileMenuOpen(true); - } else { - setMobileMenuOpen(false); - } - }, - })); + useEffect(() => { + const handleKeyboardShow = ({ endCoordinates }: KeyboardEvent) => { + if (isOpen) { + setPanelHeight((height - endCoordinates.height) / 2); + positionBottomAnimation.setValue(-endCoordinates.height); + } + }; - return ( - - { + if (isOpen) { + setPanelHeight(defaultPanelHeight); + positionBottomAnimation.setValue(0); + } + }; + + const showSubscription = Keyboard.addListener( + Platform.OS === 'ios' ? 'keyboardWillShow' : 'keyboardDidShow', + handleKeyboardShow + ); + const hideSubscription = Keyboard.addListener( + Platform.OS === 'ios' ? 'keyboardWillHide' : 'keyboardDidHide', + handleKeyboardHide + ); + + // Clean up subscriptions on unmount + return () => { + showSubscription.remove(); + hideSubscription.remove(); + }; + }, [defaultPanelHeight, height, positionBottomAnimation, isOpen]); + + useImperativeHandle(ref, () => ({ + setAddonsPanelOpen: (open: boolean) => { + if (open) { + setMobileMenuOpen(true); + } else { + setMobileMenuOpen(false); + } + }, + })); + + return ( + - { - setMobileMenuOpen(false); - Keyboard.dismiss(); + + > + { + setMobileMenuOpen(false); + Keyboard.dismiss(); + }} + storyId={storyId} + parameters={parameters} + /> + - - - ); -}); + + ); + } +); MobileAddonsPanel.displayName = 'MobileAddonsPanel'; @@ -194,15 +195,13 @@ const hiddenStyle = { const hitSlop = { top: 10, right: 10, bottom: 10, left: 10 }; -export const AddonsTabs = ({ - onClose, - storyId, - parameters, -}: { +type AddonsTabsProps = { onClose?: () => void; storyId?: string; parameters?: Parameters; -}) => { +}; + +export const AddonsTabs = ({ onClose, storyId, parameters }: AddonsTabsProps) => { const allPanels: Addon_Collection = addons.getElements(Addon_TypesEnum.PANEL); const panels = useMemo>( @@ -217,14 +216,8 @@ export const AddonsTabs = ({ const insets = useSafeAreaInsets(); const [addonSelected, setAddonSelected] = useState(Object.keys(panels)[0]); - - useEffect(() => { - if (!panels[addonSelected] && Object.keys(panels).length > 0) { - setAddonSelected(Object.keys(panels)[0]); - } - }, [panels, addonSelected]); - const panelEntries = useMemo(() => Object.entries(panels), [panels]); + const activeAddonId = panels[addonSelected] ? addonSelected : panelEntries[0]?.[0]; const scrollContentContainerStyle = useStyle( () => ({ @@ -248,7 +241,7 @@ export const AddonsTabs = ({ return ( setAddonSelected(id)} text={String(resolvedTitle)} /> @@ -280,7 +273,7 @@ export const AddonsTabs = ({ ) : ( panelEntries.map(([id, p]) => ( - + )) From 21d7a968ccffdfd251e5ded3c622ded26e994cda Mon Sep 17 00:00:00 2001 From: Daniel Williams Date: Tue, 2 Jun 2026 21:49:53 +0100 Subject: [PATCH 3/8] example and more adjustments --- .../AddonPanelDisable.stories.tsx | 136 +++++++++++++ .../src/MobileAddonsPanel.tsx | 18 +- .../react-native-ui/src/MobileAddonsPanel.tsx | 192 +++++++++--------- 3 files changed, 235 insertions(+), 111 deletions(-) create mode 100644 examples/expo-example/components/AddonPanelDisable/AddonPanelDisable.stories.tsx 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..22db04b20e --- /dev/null +++ b/examples/expo-example/components/AddonPanelDisable/AddonPanelDisable.stories.tsx @@ -0,0 +1,136 @@ +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'} + + 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', + }, +}); diff --git a/packages/react-native-ui-lite/src/MobileAddonsPanel.tsx b/packages/react-native-ui-lite/src/MobileAddonsPanel.tsx index 39d9ebbc2d..500725bc04 100644 --- a/packages/react-native-ui-lite/src/MobileAddonsPanel.tsx +++ b/packages/react-native-ui-lite/src/MobileAddonsPanel.tsx @@ -202,17 +202,13 @@ type AddonsTabsProps = { }; export const AddonsTabs = ({ onClose, storyId, parameters }: AddonsTabsProps) => { - const allPanels: Addon_Collection = addons.getElements(Addon_TypesEnum.PANEL); - - const panels = useMemo>( - () => - Object.fromEntries( - Object.entries(allPanels).filter( - ([, p]) => !p.paramKey || !parameters?.[p.paramKey]?.disable - ) - ), - [allPanels, 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 insets = useSafeAreaInsets(); const [addonSelected, setAddonSelected] = useState(Object.keys(panels)[0]); diff --git a/packages/react-native-ui/src/MobileAddonsPanel.tsx b/packages/react-native-ui/src/MobileAddonsPanel.tsx index bc632486f6..ea5efd0f6b 100644 --- a/packages/react-native-ui/src/MobileAddonsPanel.tsx +++ b/packages/react-native-ui/src/MobileAddonsPanel.tsx @@ -7,7 +7,7 @@ import { type Addon_BaseType, type Addon_Collection, } from 'storybook/internal/types'; -import { forwardRef, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react'; +import { forwardRef, useImperativeHandle, useMemo, useRef, useState } from 'react'; import { Platform, StyleProp, Text, View, ViewStyle, useWindowDimensions } from 'react-native'; import { ScrollView } from 'react-native-gesture-handler'; import Animated, { @@ -32,86 +32,87 @@ const contentStyle = { flex: 1, } satisfies StyleProp; -export const MobileAddonsPanel = forwardRef< - MobileAddonsPanelRef, - { storyId?: string; parameters?: Parameters } ->(({ storyId, parameters }, ref) => { - const theme = useTheme(); - const reducedMotion = useReducedMotion(); - - const addonsPanelBottomSheetRef = useRef(null); - const insets = useSafeAreaInsets(); - - const animatedPosition = useSharedValue(0); - - // bringing in animated keyboard disables android resizing - // TODO replicate functionality without this - useAnimatedKeyboard(); - - useImperativeHandle(ref, () => ({ - setAddonsPanelOpen: (open: boolean) => { - if (open) { - addonsPanelBottomSheetRef.current?.present(); - } else { - addonsPanelBottomSheetRef.current?.dismiss(); - } - }, - })); - - const { height } = useWindowDimensions(); - - const adjustedBottomSheetSize = useAnimatedStyle(() => { - const extraPadding = Platform.OS === 'android' ? 32 : 16 + insets.bottom; - return { - maxHeight: height - animatedPosition.value - extraPadding, - }; - }, [animatedPosition, height, insets.bottom]); - - const backgroundStyle = useStyle(() => { - return { - borderRadius: 0, - borderTopColor: theme.appBorderColor, - borderTopWidth: 1, - backgroundColor: theme.background.content, - }; - }); - - const handleIndicatorStyle = useStyle(() => { - return { - backgroundColor: theme.textMutedColor, - }; - }); - - return ( - - - { - addonsPanelBottomSheetRef.current?.dismiss(); - }} - storyId={storyId} - parameters={parameters} - /> - - - ); -}); +type MobileAddonsPanelProps = { storyId?: string; parameters?: Parameters }; + +export const MobileAddonsPanel = forwardRef( + ({ storyId, parameters }, ref) => { + const theme = useTheme(); + const reducedMotion = useReducedMotion(); + + const addonsPanelBottomSheetRef = useRef(null); + const insets = useSafeAreaInsets(); + + const animatedPosition = useSharedValue(0); + + // bringing in animated keyboard disables android resizing + // TODO replicate functionality without this + useAnimatedKeyboard(); + + useImperativeHandle(ref, () => ({ + setAddonsPanelOpen: (open: boolean) => { + if (open) { + addonsPanelBottomSheetRef.current?.present(); + } else { + addonsPanelBottomSheetRef.current?.dismiss(); + } + }, + })); + + const { height } = useWindowDimensions(); + + const adjustedBottomSheetSize = useAnimatedStyle(() => { + const extraPadding = Platform.OS === 'android' ? 32 : 16 + insets.bottom; + return { + maxHeight: height - animatedPosition.value - extraPadding, + }; + }, [animatedPosition, height, insets.bottom]); + + const backgroundStyle = useStyle(() => { + return { + borderRadius: 0, + borderTopColor: theme.appBorderColor, + borderTopWidth: 1, + backgroundColor: theme.background.content, + }; + }); + + const handleIndicatorStyle = useStyle(() => { + return { + backgroundColor: theme.textMutedColor, + }; + }); + + return ( + + + { + addonsPanelBottomSheetRef.current?.dismiss(); + }} + storyId={storyId} + parameters={parameters} + /> + + + ); + } +); MobileAddonsPanel.displayName = 'MobileAddonsPanel'; @@ -160,25 +161,15 @@ export const AddonsTabs = ({ storyId?: string; parameters?: Parameters; }) => { - const allPanels: Addon_Collection = addons.getElements(Addon_TypesEnum.PANEL); - - const panels = useMemo>( - () => - Object.fromEntries( - Object.entries(allPanels).filter( - ([, p]) => !p.paramKey || !parameters?.[p.paramKey]?.disable - ) - ), - [allPanels, parameters] - ); + const panels = useMemo>(() => { + const allPanels: Addon_Collection = addons.getElements(Addon_TypesEnum.PANEL); - const [addonSelected, setAddonSelected] = useState(Object.keys(panels)[0]); + return Object.fromEntries( + Object.entries(allPanels).filter(([, p]) => !p.paramKey || !parameters?.[p.paramKey]?.disable) + ); + }, [parameters]); - useEffect(() => { - if (!panels[addonSelected] && Object.keys(panels).length > 0) { - setAddonSelected(Object.keys(panels)[0]); - } - }, [panels, addonSelected]); + const [addonSelected, setAddonSelected] = useState(Object.keys(panels)[0]); const insets = useSafeAreaInsets(); @@ -189,6 +180,7 @@ export const AddonsTabs = ({ }); const panelEntries = useMemo(() => Object.entries(panels), [panels]); + const activeAddonId = panels[addonSelected] ? addonSelected : panelEntries[0]?.[0]; return ( @@ -204,7 +196,7 @@ export const AddonsTabs = ({ return ( setAddonSelected(id)} text={String(resolvedTitle)} /> @@ -234,7 +226,7 @@ export const AddonsTabs = ({ ) : ( panelEntries.map(([id, p]) => ( - + )) From a10ecfa6fe0e334611ac78631fe2e0417d1b80d9 Mon Sep 17 00:00:00 2001 From: Daniel Williams Date: Tue, 2 Jun 2026 22:03:23 +0100 Subject: [PATCH 4/8] pressed style and requires update --- .../.rnstorybook/storybook.requires.ts | 4 ++-- .../AddonPanelDisable.stories.tsx | 15 ++++++++------- 2 files changed, 10 insertions(+), 9 deletions(-) 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 index 22db04b20e..90f1e1b42f 100644 --- a/examples/expo-example/components/AddonPanelDisable/AddonPanelDisable.stories.tsx +++ b/examples/expo-example/components/AddonPanelDisable/AddonPanelDisable.stories.tsx @@ -9,17 +9,15 @@ type AddonPanelDisableDemoProps = { onPress: () => void; }; -const AddonPanelDisableDemo = ({ - label, - count, - enabled, - onPress, -}: AddonPanelDisableDemoProps) => ( +const AddonPanelDisableDemo = ({ label, count, enabled, onPress }: AddonPanelDisableDemoProps) => ( {label} Count: {count} Enabled: {enabled ? 'true' : 'false'} - + [styles.button, pressed && styles.buttonPressed]} + onPress={onPress} + > Log action @@ -133,4 +131,7 @@ const styles = StyleSheet.create({ color: '#ffffff', fontWeight: '700', }, + buttonPressed: { + opacity: 0.8, + }, }); From 5f0213b9adad0a7c416e0c4a7fb1be9217c61f15 Mon Sep 17 00:00:00 2001 From: Daniel Williams Date: Tue, 2 Jun 2026 22:10:43 +0100 Subject: [PATCH 5/8] memo in layout --- packages/react-native-ui-lite/src/Layout.tsx | 20 +++++++++++++++----- packages/react-native-ui/src/Layout.tsx | 13 ++++++++----- 2 files changed, 23 insertions(+), 10 deletions(-) diff --git a/packages/react-native-ui-lite/src/Layout.tsx b/packages/react-native-ui-lite/src/Layout.tsx index bdf5a1e180..d62502f512 100644 --- a/packages/react-native-ui-lite/src/Layout.tsx +++ b/packages/react-native-ui-lite/src/Layout.tsx @@ -11,7 +11,15 @@ 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'; @@ -125,10 +133,12 @@ export const Layout = ({ true ); - const allPanels: Addon_Collection = addons.getElements(Addon_TypesEnum.PANEL); - const hasEnabledPanels = Object.values(allPanels).some( - (p) => !p.paramKey || !story?.parameters?.[p.paramKey]?.disable - ); + 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); diff --git a/packages/react-native-ui/src/Layout.tsx b/packages/react-native-ui/src/Layout.tsx index a1a96b4de0..f83b705b68 100644 --- a/packages/react-native-ui/src/Layout.tsx +++ b/packages/react-native-ui/src/Layout.tsx @@ -11,7 +11,7 @@ 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'; @@ -122,10 +122,13 @@ export const Layout = ({ true ); - const allPanels: Addon_Collection = addons.getElements(Addon_TypesEnum.PANEL); - const hasEnabledPanels = Object.values(allPanels).some( - (p) => !p.paramKey || !story?.parameters?.[p.paramKey]?.disable - ); + 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); From d2be448d92e030999017133dc3ae9be01acf5015 Mon Sep 17 00:00:00 2001 From: Daniel Williams Date: Tue, 2 Jun 2026 22:38:30 +0100 Subject: [PATCH 6/8] fix: typecheck --- packages/react-native-ui/src/MobileMenuDrawer.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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, From 2713c036598a0ab0fc2b05c1cbad028a84f7560a Mon Sep 17 00:00:00 2001 From: Daniel Williams Date: Tue, 2 Jun 2026 22:49:10 +0100 Subject: [PATCH 7/8] mention in docs --- docs/docs/intro/addons/index.md | 24 ++++++++++++++++++++++++ docs/docs/intro/writing-stories.md | 12 ++++++++++++ 2 files changed, 36 insertions(+) diff --git a/docs/docs/intro/addons/index.md b/docs/docs/intro/addons/index.md index 53ca6b9304..405a2bcd83 100644 --- a/docs/docs/intro/addons/index.md +++ b/docs/docs/intro/addons/index.md @@ -35,8 +35,32 @@ 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 From 5232382dee383f8fad664df2756e6a2bee0292d6 Mon Sep 17 00:00:00 2001 From: Daniel Williams Date: Tue, 2 Jun 2026 22:53:21 +0100 Subject: [PATCH 8/8] fix: format --- docs/docs/intro/addons/index.md | 2 -- 1 file changed, 2 deletions(-) diff --git a/docs/docs/intro/addons/index.md b/docs/docs/intro/addons/index.md index 405a2bcd83..98b4c625fe 100644 --- a/docs/docs/intro/addons/index.md +++ b/docs/docs/intro/addons/index.md @@ -36,8 +36,6 @@ On-device addons contain React Native code that can only run on the device. When 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