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],
+ },
+ };
+ }, {});
+}