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,