From 2c918f580762853f23547303a2c88c28446cb686 Mon Sep 17 00:00:00 2001 From: Daniel Williams Date: Sat, 5 Sep 2026 20:08:36 +0200 Subject: [PATCH] fix(controls): honor control: false and table.disable argTypes on device The on-device Controls panel only hid an arg when it was removed by parameters.controls.include/exclude, because core deletes those argTypes before they reach the panel. The per-argType forms Storybook documents for disabling a single control are left in place with a flag instead, and the panel ignored the flag: - control: false (normalized by core to control: { disable: true }) - control: { disable: true } - table: { disable: true } Since { disable: true } is truthy, the existing Boolean(argType.control) check let these through and the control rendered as editable. Move the row filtering into a controlArgTypes helper that checks the disable flags alongside the existing if-condition handling, and base the no-controls warning on the filtered rows so a story with every control hidden shows the warning instead of an empty table. Add a DisabledControls example story covering each form, a test against the composed story, and a docs section. --- .changeset/quiet-controls-disable.md | 5 ++ docs/docs/intro/addons/controls.md | 33 +++++++++ .../DisabledControls.stories.tsx | 58 +++++++++++++++ .../DisabledControls.test.tsx | 26 +++++++ .../ondevice-controls/src/ControlsPanel.tsx | 60 +++------------ .../ondevice-controls/src/controlArgTypes.ts | 73 +++++++++++++++++++ 6 files changed, 206 insertions(+), 49 deletions(-) create mode 100644 .changeset/quiet-controls-disable.md create mode 100644 examples/expo-example/components/ControlExamples/DisabledControls/DisabledControls.stories.tsx create mode 100644 examples/expo-example/components/ControlExamples/DisabledControls/DisabledControls.test.tsx create mode 100644 packages/ondevice-controls/src/controlArgTypes.ts diff --git a/.changeset/quiet-controls-disable.md b/.changeset/quiet-controls-disable.md new file mode 100644 index 0000000000..1119ba2e7f --- /dev/null +++ b/.changeset/quiet-controls-disable.md @@ -0,0 +1,5 @@ +--- +'@storybook/addon-ondevice-controls': patch +--- + +Honor `control: false`, `control: { disable: true }` and `table: { disable: true }` argType annotations in the on-device Controls panel, matching Storybook web. Previously only `parameters.controls.exclude` and `include` could hide a control on device, and a disabled control would still render as editable. diff --git a/docs/docs/intro/addons/controls.md b/docs/docs/intro/addons/controls.md index e6db7643f2..0966375800 100644 --- a/docs/docs/intro/addons/controls.md +++ b/docs/docs/intro/addons/controls.md @@ -386,6 +386,39 @@ export default { }; ``` +### Disabling Controls + +To hide a single prop from the Controls panel, use any of the same `argTypes` annotations as Storybook web: + +```ts +export default { + component: MyComponent, + argTypes: { + // Hide the control for `internalId` + internalId: { control: false }, + + // Equivalent explicit form + trackingId: { control: { disable: true } }, + + // Hide the whole row + debugFlag: { table: { disable: true } }, + }, +}; +``` + +To filter by name, use `include` or `exclude` in the `controls` parameter. Both accept either an array of prop names or a regular expression: + +```ts +export default { + component: MyComponent, + parameters: { + controls: { exclude: /^on[A-Z].*/ }, + }, +}; +``` + +These can also be set on an individual story. To hide the Controls panel entirely for a story, set `parameters.controls.disable` to `true`. + ## Best Practices 1. **Use descriptive labels**: Provide clear labels for select and radio options diff --git a/examples/expo-example/components/ControlExamples/DisabledControls/DisabledControls.stories.tsx b/examples/expo-example/components/ControlExamples/DisabledControls/DisabledControls.stories.tsx new file mode 100644 index 0000000000..8883ee2d84 --- /dev/null +++ b/examples/expo-example/components/ControlExamples/DisabledControls/DisabledControls.stories.tsx @@ -0,0 +1,58 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { Appearance, Text, View } from 'react-native'; + +interface DisabledControlsExampleProps { + label: string; + controlFalse: string; + controlDisable: string; + tableDisable: string; + excluded: string; +} + +const DisabledControlsExample = (props: DisabledControlsExampleProps) => { + return ( + + + {JSON.stringify(props, null, 2)} + + + ); +}; + +// Every way Storybook lets a story hide a single control from the Controls panel. The component +// renders all of its props, so a hidden control still has a value; it just can't be edited. +const meta = { + title: 'ControlExamples/DisabledControls', + component: DisabledControlsExample, + args: { + label: 'editable', + controlFalse: 'hidden via control: false', + controlDisable: 'hidden via control: { disable: true }', + tableDisable: 'hidden via table: { disable: true }', + excluded: 'hidden via parameters.controls.exclude', + }, + argTypes: { + label: { control: 'text' }, + controlFalse: { control: false }, + controlDisable: { control: { disable: true } }, + tableDisable: { control: 'text', table: { disable: true } }, + excluded: { control: 'text' }, + }, + parameters: { + controls: { exclude: ['excluded'] }, + }, +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +// Only the `label` control should appear in the panel. +export const OnlyLabelEditable: Story = {}; + +// Every control is hidden, so the panel shows the "not configured to handle controls" message. +export const AllDisabled: Story = { + argTypes: { + label: { control: false }, + }, +}; diff --git a/examples/expo-example/components/ControlExamples/DisabledControls/DisabledControls.test.tsx b/examples/expo-example/components/ControlExamples/DisabledControls/DisabledControls.test.tsx new file mode 100644 index 0000000000..51c5c8a270 --- /dev/null +++ b/examples/expo-example/components/ControlExamples/DisabledControls/DisabledControls.test.tsx @@ -0,0 +1,26 @@ +import { composeStories } from '@storybook/react'; +import { getControlArgTypes } from '@storybook/addon-ondevice-controls/dist/controlArgTypes'; +import * as DisabledControlsStories from './DisabledControls.stories'; + +const { OnlyLabelEditable, AllDisabled } = composeStories(DisabledControlsStories); + +test('only the label keeps an enabled control', () => { + // `parameters.controls.exclude` is applied by core before the argTypes reach the panel. + expect(Object.keys(OnlyLabelEditable.argTypes)).toEqual([ + 'label', + 'controlFalse', + 'controlDisable', + 'tableDisable', + ]); + + const rows = getControlArgTypes(OnlyLabelEditable.argTypes, OnlyLabelEditable.args); + + expect(Object.keys(rows)).toEqual(['label']); + expect(rows.label).toMatchObject({ name: 'label', type: 'text', value: 'editable' }); +}); + +test('a story with every control hidden has no rows', () => { + const rows = getControlArgTypes(AllDisabled.argTypes, AllDisabled.args); + + expect(rows).toEqual({}); +}); diff --git a/packages/ondevice-controls/src/ControlsPanel.tsx b/packages/ondevice-controls/src/ControlsPanel.tsx index bf27017156..6a218dcddd 100644 --- a/packages/ondevice-controls/src/ControlsPanel.tsx +++ b/packages/ondevice-controls/src/ControlsPanel.tsx @@ -1,16 +1,15 @@ import type { API } from 'storybook/manager-api'; import { Channel } from 'storybook/internal/channels'; -import { - type Args, - type StoryContextForLoaders, - includeConditionalArg, -} from 'storybook/internal/csf'; +import type { Args, StoryContextForLoaders } from 'storybook/internal/csf'; import type { Renderer } from 'storybook/internal/types'; import React, { ComponentType, ReactElement, useCallback, useState } from 'react'; import NoControlsWarning from './NoControlsWarning'; import PropForm from './PropForm'; +import { getControlArgTypes } from './controlArgTypes'; import { useArgs } from './hooks'; +export type { ArgType, ArgTypes } from './controlArgTypes'; + export interface Selection { storyId: string; viewMode: 'story'; @@ -30,16 +29,6 @@ export interface ControlsParameters { presetColors?: PresetColor[]; hideNoControlsWarning?: boolean; } -export interface ArgType { - name?: string; - description?: string; - defaultValue?: any; - [key: string]: any; -} -export interface ArgTypes { - [key: string]: ArgType; -} - export interface ReactNativeFramework extends Renderer { component: ComponentType; storyResult: ReactElement; @@ -51,14 +40,6 @@ type ApiStore = { _channel: Channel; }; -function shouldIncludeArg(argType: ArgType, args: Args) { - try { - return includeConditionalArg(argType, args, {}); - } catch { - return true; - } -} - const ControlsPanel = ({ api }: { api: API }) => { const store: ApiStore = api.store(); @@ -68,38 +49,19 @@ const ControlsPanel = ({ api }: { api: API }) => { const [argsFromHook, updateArgs, resetArgs] = useArgs(storyId, store); - const { argsObject, argTypes, parameters } = React.useMemo(() => { - const { argTypes: storyArgTypes, parameters: storyParameters } = store.fromId(storyId); - - const storyArgsObject = Object.entries(storyArgTypes).reduce( - (prev, [key, argType]: [string, ArgType]) => { - const isControl = Boolean(argType?.control); - - const shouldInclude = shouldIncludeArg(argType, argsFromHook); - - return isControl && shouldInclude - ? { - ...prev, - [key]: { - ...argType, - name: key, - type: argType?.control?.type, - value: argsFromHook[key], - }, - } - : prev; - }, - {} - ); + const { argsObject, parameters } = React.useMemo(() => { + const { argTypes, parameters: storyParameters } = store.fromId(storyId); return { - argTypes: storyArgTypes, parameters: storyParameters, - argsObject: storyArgsObject, + argsObject: getControlArgTypes(argTypes, argsFromHook), }; }, [store, storyId, argsFromHook]); - const hasControls = Object.keys(argTypes).length > 0; + // Match the web Controls panel: an arg only counts once its control is enabled (not `control: + // false`, `control.disable` or `table.disable`) and its `if` condition passes, so a story whose + // controls are all hidden shows the warning instead of an empty table. + const hasControls = Object.keys(argsObject).length > 0; const isArgsStory = parameters.__isArgsStory; diff --git a/packages/ondevice-controls/src/controlArgTypes.ts b/packages/ondevice-controls/src/controlArgTypes.ts new file mode 100644 index 0000000000..da1ffe63e1 --- /dev/null +++ b/packages/ondevice-controls/src/controlArgTypes.ts @@ -0,0 +1,73 @@ +import { type Args, includeConditionalArg } from 'storybook/internal/csf'; + +export interface ArgType { + name?: string; + description?: string; + defaultValue?: any; + [key: string]: any; +} + +export interface ArgTypes { + [key: string]: ArgType; +} + +/** + * Whether an argType has a control that should be shown in the panel. + * + * Storybook core supports three per-argType ways of hiding a control, which all need to be honored + * here because core leaves the argType in place and only sets a flag on it: + * + * - `argTypes.foo.control = false`, which core normalizes to `control: { disable: true }` + * - `argTypes.foo.control = { disable: true }` + * - `argTypes.foo.table = { disable: true }` + * + * On web the `control` forms keep the row (with its description) and only drop the input, and only + * `table.disable` removes the row. The on-device panel has no description column, so a row without + * an input is just noise and all three forms hide the row. + * + * `parameters.controls.include` / `exclude` are applied by core's `inferControls` enhancer before + * the argTypes reach the panel, so they need no handling here. + */ +export function hasEnabledControl(argType: ArgType | undefined): boolean { + const control = argType?.control; + + if (!control || control.disable === true) { + return false; + } + + if (argType?.table?.disable === true) { + return false; + } + + return true; +} + +function shouldIncludeArg(argType: ArgType, args: Args) { + try { + return includeConditionalArg(argType, args, {}); + } catch { + return true; + } +} + +/** + * Turns a story's argTypes into the rows the panel renders: only argTypes with an enabled control + * whose `if` condition (if any) passes, each annotated with its name, control type and current value. + */ +export function getControlArgTypes(argTypes: ArgTypes, args: Args): ArgTypes { + return Object.entries(argTypes ?? {}).reduce((prev, [key, argType]) => { + if (!hasEnabledControl(argType) || !shouldIncludeArg(argType, args)) { + return prev; + } + + return { + ...prev, + [key]: { + ...argType, + name: key, + type: argType.control.type, + value: args[key], + }, + }; + }, {}); +}