From d80100e66205a42e318a36949c03649fb7ae6b61 Mon Sep 17 00:00:00 2001 From: Seydi Charyyev Date: Thu, 14 May 2026 09:09:31 +0500 Subject: [PATCH] fix: scope control ids to instance via controlsId --- .../src/blocks/blocks/Controls.stories.tsx | 22 ++++++++++++++ .../docs/src/blocks/blocks/Controls.tsx | 8 ++++- .../components/ArgsTable/ArgControl.tsx | 11 ++++++- .../blocks/components/ArgsTable/ArgRow.tsx | 1 + .../blocks/components/ArgsTable/ArgsTable.tsx | 11 ++++++- .../docs/src/blocks/controls/Boolean.tsx | 5 ++-- .../addons/docs/src/blocks/controls/Color.tsx | 3 +- code/addons/docs/src/blocks/controls/Date.tsx | 3 +- .../addons/docs/src/blocks/controls/Files.tsx | 3 +- .../docs/src/blocks/controls/Number.tsx | 5 ++-- .../docs/src/blocks/controls/Object.tsx | 13 ++++++-- .../addons/docs/src/blocks/controls/Range.tsx | 3 +- code/addons/docs/src/blocks/controls/Text.tsx | 5 ++-- .../docs/src/blocks/controls/helpers.test.ts | 18 +++++++++++ .../docs/src/blocks/controls/helpers.ts | 30 +++++++++++++++---- .../src/blocks/controls/options/Checkbox.tsx | 3 +- .../src/blocks/controls/options/Radio.tsx | 3 +- .../src/blocks/controls/options/Select.tsx | 24 ++++++++++++--- code/addons/docs/src/blocks/controls/types.ts | 1 + 19 files changed, 144 insertions(+), 28 deletions(-) diff --git a/code/addons/docs/src/blocks/blocks/Controls.stories.tsx b/code/addons/docs/src/blocks/blocks/Controls.stories.tsx index 0a312c1e2f55..d1289aadb7f3 100644 --- a/code/addons/docs/src/blocks/blocks/Controls.stories.tsx +++ b/code/addons/docs/src/blocks/blocks/Controls.stories.tsx @@ -181,3 +181,25 @@ export const MultipleControlsOnSamePage: Story = { await expect(uniqueIds.size).toBe(allIds.length); }, }; + +/** + * When multiple Controls blocks for the SAME story are on the same docs page, each control should + * still have a unique id (and unique name across blocks, so that radio button groups remain + * independent). This verifies the fix for https://github.com/storybookjs/storybook/issues/29295. + */ +export const MultipleControlsForSameStoryOnSamePage: Story = { + render: () => ( + <> + + + + ), + play: async ({ canvasElement }) => { + const allIds = Array.from(canvasElement.querySelectorAll('[id^="control-"]')).map( + (el) => el.id + ); + const uniqueIds = new Set(allIds); + await expect(allIds.length).toBeGreaterThan(0); + await expect(uniqueIds.size).toBe(allIds.length); + }, +}; diff --git a/code/addons/docs/src/blocks/blocks/Controls.tsx b/code/addons/docs/src/blocks/blocks/Controls.tsx index 5110ba6687c0..daef8088287e 100644 --- a/code/addons/docs/src/blocks/blocks/Controls.tsx +++ b/code/addons/docs/src/blocks/blocks/Controls.tsx @@ -1,6 +1,6 @@ /* eslint-disable react/destructuring-assignment */ import type { FC } from 'react'; -import React, { useContext } from 'react'; +import React, { useContext, useId } from 'react'; import type { Parameters, Renderer, StrictArgTypes } from 'storybook/internal/csf'; import type { ArgTypesExtractor } from 'storybook/internal/docs-tools'; @@ -43,6 +43,10 @@ const ControlsImpl: FC = (props) => { const { of } = props; const context = useContext(DocsContext); const primaryStory = usePrimaryStory(); + // Disambiguate multiple blocks rendered for the same story on a single page. + // React's useId produces a stable id per component instance; strip colons since they require + // escaping in CSS selectors. + const controlsId = useId().replace(/:/g, ''); const story = of ? context.resolveOf(of, ['story']).story : primaryStory; @@ -71,6 +75,7 @@ const ControlsImpl: FC = (props) => { return ( = (props) => { updateArgs={updateArgs} resetArgs={resetArgs} storyId={story.id} + controlsId={controlsId} /> ); }; diff --git a/code/addons/docs/src/blocks/components/ArgsTable/ArgControl.tsx b/code/addons/docs/src/blocks/components/ArgsTable/ArgControl.tsx index 97d3013b4bd8..700a90eeffdb 100644 --- a/code/addons/docs/src/blocks/components/ArgsTable/ArgControl.tsx +++ b/code/addons/docs/src/blocks/components/ArgsTable/ArgControl.tsx @@ -22,6 +22,7 @@ export interface ArgControlProps { updateArgs: (args: Args) => void; isHovered: boolean; storyId?: string; + controlsId?: string; } const Controls: Record> = { @@ -44,7 +45,14 @@ const Controls: Record> = { const NoControl = () => <>-; -export const ArgControl: FC = ({ row, arg, updateArgs, isHovered, storyId }) => { +export const ArgControl: FC = ({ + row, + arg, + updateArgs, + isHovered, + storyId, + controlsId, +}) => { const { key, control } = row; const [isFocused, setFocused] = useState(false); @@ -88,6 +96,7 @@ export const ArgControl: FC = ({ row, arg, updateArgs, isHovere const props = { name: key, storyId, + controlsId, argType: row, value: boxedValue.value, onChange, diff --git a/code/addons/docs/src/blocks/components/ArgsTable/ArgRow.tsx b/code/addons/docs/src/blocks/components/ArgsTable/ArgRow.tsx index 83ad20c476ac..adb6f33e8453 100644 --- a/code/addons/docs/src/blocks/components/ArgsTable/ArgRow.tsx +++ b/code/addons/docs/src/blocks/components/ArgsTable/ArgRow.tsx @@ -22,6 +22,7 @@ interface ArgRowProps { expandable?: boolean; initialExpandedArgs?: boolean; storyId?: string; + controlsId?: string; } const Name = styled.span({ fontWeight: 'bold' }); diff --git a/code/addons/docs/src/blocks/components/ArgsTable/ArgsTable.tsx b/code/addons/docs/src/blocks/components/ArgsTable/ArgsTable.tsx index 11e0325d6460..9dd2ce45e630 100644 --- a/code/addons/docs/src/blocks/components/ArgsTable/ArgsTable.tsx +++ b/code/addons/docs/src/blocks/components/ArgsTable/ArgsTable.tsx @@ -216,6 +216,7 @@ export interface ArgsTableOptionProps { isLoading?: boolean; sort?: SortType; storyId?: string; + controlsId?: string; } interface ArgsTableDataProps { rows: ArgTypes; @@ -340,6 +341,7 @@ export const ArgsTable: FC = (props) => { sort = 'none', isLoading, storyId, + controlsId, } = props; if ('error' in props) { @@ -393,7 +395,14 @@ export const ArgsTable: FC = (props) => { } const expandable = Object.keys(groups.sections).length > 0; - const common = { updateArgs, compact, inAddonPanel, initialExpandedArgs, storyId }; + const common = { + updateArgs, + compact, + inAddonPanel, + initialExpandedArgs, + storyId, + controlsId, + }; return ( diff --git a/code/addons/docs/src/blocks/controls/Boolean.tsx b/code/addons/docs/src/blocks/controls/Boolean.tsx index ee89ab44d8cf..e302aa6f2b93 100644 --- a/code/addons/docs/src/blocks/controls/Boolean.tsx +++ b/code/addons/docs/src/blocks/controls/Boolean.tsx @@ -123,6 +123,7 @@ export type BooleanProps = ControlProps & BooleanConfig; export const BooleanControl: FC = ({ name, storyId, + controlsId, value, onChange, onBlur, @@ -137,7 +138,7 @@ export const BooleanControl: FC = ({ ariaLabel={false} variant="outline" size="medium" - id={getControlSetterButtonId(name, storyId)} + id={getControlSetterButtonId(name, storyId, controlsId)} onClick={onSetFalse} disabled={readonly} > @@ -145,7 +146,7 @@ export const BooleanControl: FC = ({ ); } - const controlId = getControlId(name, storyId); + const controlId = getControlId(name, storyId, controlsId); const parsedValue = typeof value === 'string' ? parse(value) : value; diff --git a/code/addons/docs/src/blocks/controls/Color.tsx b/code/addons/docs/src/blocks/controls/Color.tsx index b109004c2e63..f837fdfa52ed 100644 --- a/code/addons/docs/src/blocks/controls/Color.tsx +++ b/code/addons/docs/src/blocks/controls/Color.tsx @@ -367,6 +367,7 @@ export type ColorControlProps = ControlProps & ColorConfig; export const ColorControl: FC = ({ name, storyId, + controlsId, value: initialValue, onChange, onFocus, @@ -385,7 +386,7 @@ export const ColorControl: FC = ({ const Picker = ColorPicker[colorSpace]; const readOnly = !!argType?.table?.readonly; - const controlId = getControlId(name, storyId); + const controlId = getControlId(name, storyId, controlsId); return ( diff --git a/code/addons/docs/src/blocks/controls/Date.tsx b/code/addons/docs/src/blocks/controls/Date.tsx index 55fe0063bb71..ad54e31be7cc 100644 --- a/code/addons/docs/src/blocks/controls/Date.tsx +++ b/code/addons/docs/src/blocks/controls/Date.tsx @@ -69,6 +69,7 @@ export type DateProps = ControlProps & DateConfig; export const DateControl: FC = ({ name, storyId, + controlsId, value, onChange, onFocus, @@ -122,7 +123,7 @@ export const DateControl: FC = ({ setValid(!!time); }; - const controlId = getControlId(name, storyId); + const controlId = getControlId(name, storyId, controlsId); return ( diff --git a/code/addons/docs/src/blocks/controls/Files.tsx b/code/addons/docs/src/blocks/controls/Files.tsx index 85f2d839701a..e795b71e1238 100644 --- a/code/addons/docs/src/blocks/controls/Files.tsx +++ b/code/addons/docs/src/blocks/controls/Files.tsx @@ -41,6 +41,7 @@ export const FilesControl: FC = ({ onChange, name, storyId, + controlsId, accept = 'image/*', value, argType, @@ -64,7 +65,7 @@ export const FilesControl: FC = ({ } }, [value, name]); - const controlId = getControlId(name, storyId); + const controlId = getControlId(name, storyId, controlsId); return ( <> diff --git a/code/addons/docs/src/blocks/controls/Number.tsx b/code/addons/docs/src/blocks/controls/Number.tsx index a3f33db74de7..df5941d53c05 100644 --- a/code/addons/docs/src/blocks/controls/Number.tsx +++ b/code/addons/docs/src/blocks/controls/Number.tsx @@ -28,6 +28,7 @@ const FormInput = styled(Form.Input)(({ theme }) => ({ export const NumberControl: FC = ({ name, storyId, + controlsId, value, onChange, min, @@ -103,7 +104,7 @@ export const NumberControl: FC = ({ ariaLabel={false} variant="outline" size="medium" - id={getControlSetterButtonId(name, storyId)} + id={getControlSetterButtonId(name, storyId, controlsId)} onClick={onForceVisible} disabled={readonly} > @@ -116,7 +117,7 @@ export const NumberControl: FC = ({ ) => { export type ObjectProps = ControlProps & ObjectConfig; -export const ObjectControl: FC = ({ name, storyId, value, onChange, argType }) => { +export const ObjectControl: FC = ({ + name, + storyId, + controlsId, + value, + onChange, + argType, +}) => { const theme = useTheme(); const data = useMemo(() => value && cloneDeep(value), [value]); const hasData = data !== null && data !== undefined; @@ -208,7 +215,7 @@ export const ObjectControl: FC = ({ name, storyId, value, onChange,