Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/quiet-controls-disable.md
Original file line number Diff line number Diff line change
@@ -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.
33 changes: 33 additions & 0 deletions docs/docs/intro/addons/controls.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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 (
<View style={{ padding: 16 }}>
<Text style={{ color: Appearance.getColorScheme() === 'dark' ? 'white' : 'black' }}>
{JSON.stringify(props, null, 2)}
</Text>
</View>
);
};

// 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<typeof DisabledControlsExample>;

export default meta;

type Story = StoryObj<typeof meta>;

// 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 },
},
};
Original file line number Diff line number Diff line change
@@ -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({});
});
60 changes: 11 additions & 49 deletions packages/ondevice-controls/src/ControlsPanel.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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<any>;
storyResult: ReactElement<unknown>;
Expand All @@ -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();

Expand All @@ -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;

Expand Down
73 changes: 73 additions & 0 deletions packages/ondevice-controls/src/controlArgTypes.ts
Original file line number Diff line number Diff line change
@@ -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<ArgTypes>((prev, [key, argType]) => {
if (!hasEnabledControl(argType) || !shouldIncludeArg(argType, args)) {
return prev;
}

return {
...prev,
[key]: {
...argType,
name: key,
type: argType.control.type,
value: args[key],
},
};
}, {});
}