Skip to content
Open
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
22 changes: 22 additions & 0 deletions code/addons/docs/src/blocks/blocks/Controls.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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: () => (
<>
<Controls of={ExampleStories.NoParameters} />
<Controls of={ExampleStories.NoParameters} />
</>
),
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);
},
};
8 changes: 7 additions & 1 deletion code/addons/docs/src/blocks/blocks/Controls.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -43,6 +43,10 @@ const ControlsImpl: FC<ControlsProps> = (props) => {
const { of } = props;
const context = useContext(DocsContext);
const primaryStory = usePrimaryStory();
// Disambiguate multiple <Controls /> 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;

Expand Down Expand Up @@ -71,6 +75,7 @@ const ControlsImpl: FC<ControlsProps> = (props) => {
return (
<PureArgsTable
storyId={story.id}
controlsId={controlsId}
rows={filteredArgTypes as any}
sort={sort}
args={args}
Expand Down Expand Up @@ -104,6 +109,7 @@ const ControlsImpl: FC<ControlsProps> = (props) => {
updateArgs={updateArgs}
resetArgs={resetArgs}
storyId={story.id}
controlsId={controlsId}
/>
);
};
Expand Down
11 changes: 10 additions & 1 deletion code/addons/docs/src/blocks/components/ArgsTable/ArgControl.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ export interface ArgControlProps {
updateArgs: (args: Args) => void;
isHovered: boolean;
storyId?: string;
controlsId?: string;
}

const Controls: Record<string, FC<any>> = {
Expand All @@ -44,7 +45,14 @@ const Controls: Record<string, FC<any>> = {

const NoControl = () => <>-</>;

export const ArgControl: FC<ArgControlProps> = ({ row, arg, updateArgs, isHovered, storyId }) => {
export const ArgControl: FC<ArgControlProps> = ({
row,
arg,
updateArgs,
isHovered,
storyId,
controlsId,
}) => {
const { key, control } = row;

const [isFocused, setFocused] = useState(false);
Expand Down Expand Up @@ -88,6 +96,7 @@ export const ArgControl: FC<ArgControlProps> = ({ row, arg, updateArgs, isHovere
const props = {
name: key,
storyId,
controlsId,
argType: row,
value: boxedValue.value,
onChange,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ interface ArgRowProps {
expandable?: boolean;
initialExpandedArgs?: boolean;
storyId?: string;
controlsId?: string;
}

const Name = styled.span({ fontWeight: 'bold' });
Expand Down
11 changes: 10 additions & 1 deletion code/addons/docs/src/blocks/components/ArgsTable/ArgsTable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,7 @@ export interface ArgsTableOptionProps {
isLoading?: boolean;
sort?: SortType;
storyId?: string;
controlsId?: string;
}
interface ArgsTableDataProps {
rows: ArgTypes;
Expand Down Expand Up @@ -340,6 +341,7 @@ export const ArgsTable: FC<ArgsTableProps> = (props) => {
sort = 'none',
isLoading,
storyId,
controlsId,
} = props;

if ('error' in props) {
Expand Down Expand Up @@ -393,7 +395,14 @@ export const ArgsTable: FC<ArgsTableProps> = (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 (
<ResetWrapper>
Expand Down
5 changes: 3 additions & 2 deletions code/addons/docs/src/blocks/controls/Boolean.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,7 @@ export type BooleanProps = ControlProps<BooleanValue> & BooleanConfig;
export const BooleanControl: FC<BooleanProps> = ({
name,
storyId,
controlsId,
value,
onChange,
onBlur,
Expand All @@ -137,15 +138,15 @@ export const BooleanControl: FC<BooleanProps> = ({
ariaLabel={false}
variant="outline"
size="medium"
id={getControlSetterButtonId(name, storyId)}
id={getControlSetterButtonId(name, storyId, controlsId)}
onClick={onSetFalse}
disabled={readonly}
>
Set boolean
</Button>
);
}
const controlId = getControlId(name, storyId);
const controlId = getControlId(name, storyId, controlsId);

const parsedValue = typeof value === 'string' ? parse(value) : value;

Expand Down
3 changes: 2 additions & 1 deletion code/addons/docs/src/blocks/controls/Color.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -367,6 +367,7 @@ export type ColorControlProps = ControlProps<ColorValue> & ColorConfig;
export const ColorControl: FC<ColorControlProps> = ({
name,
storyId,
controlsId,
value: initialValue,
onChange,
onFocus,
Expand All @@ -385,7 +386,7 @@ export const ColorControl: FC<ColorControlProps> = ({
const Picker = ColorPicker[colorSpace];

const readOnly = !!argType?.table?.readonly;
const controlId = getControlId(name, storyId);
const controlId = getControlId(name, storyId, controlsId);

return (
<Wrapper>
Expand Down
3 changes: 2 additions & 1 deletion code/addons/docs/src/blocks/controls/Date.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ export type DateProps = ControlProps<DateValue> & DateConfig;
export const DateControl: FC<DateProps> = ({
name,
storyId,
controlsId,
value,
onChange,
onFocus,
Expand Down Expand Up @@ -122,7 +123,7 @@ export const DateControl: FC<DateProps> = ({
setValid(!!time);
};

const controlId = getControlId(name, storyId);
const controlId = getControlId(name, storyId, controlsId);

return (
<FlexSpaced>
Expand Down
3 changes: 2 additions & 1 deletion code/addons/docs/src/blocks/controls/Files.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ export const FilesControl: FC<FilesControlProps> = ({
onChange,
name,
storyId,
controlsId,
accept = 'image/*',
value,
argType,
Expand All @@ -64,7 +65,7 @@ export const FilesControl: FC<FilesControlProps> = ({
}
}, [value, name]);

const controlId = getControlId(name, storyId);
const controlId = getControlId(name, storyId, controlsId);

return (
<>
Expand Down
5 changes: 3 additions & 2 deletions code/addons/docs/src/blocks/controls/Number.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ const FormInput = styled(Form.Input)(({ theme }) => ({
export const NumberControl: FC<NumberProps> = ({
name,
storyId,
controlsId,
value,
onChange,
min,
Expand Down Expand Up @@ -103,7 +104,7 @@ export const NumberControl: FC<NumberProps> = ({
ariaLabel={false}
variant="outline"
size="medium"
id={getControlSetterButtonId(name, storyId)}
id={getControlSetterButtonId(name, storyId, controlsId)}
onClick={onForceVisible}
disabled={readonly}
>
Expand All @@ -116,7 +117,7 @@ export const NumberControl: FC<NumberProps> = ({
<Wrapper>
<FormInput
ref={htmlElRef}
id={getControlId(name, storyId)}
id={getControlId(name, storyId, controlsId)}
type="number"
onChange={handleChange}
size="flex"
Expand Down
13 changes: 10 additions & 3 deletions code/addons/docs/src/blocks/controls/Object.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -163,7 +163,14 @@ const selectValue = (event: SyntheticEvent<HTMLInputElement>) => {

export type ObjectProps = ControlProps<ObjectValue> & ObjectConfig;

export const ObjectControl: FC<ObjectProps> = ({ name, storyId, value, onChange, argType }) => {
export const ObjectControl: FC<ObjectProps> = ({
name,
storyId,
controlsId,
value,
onChange,
argType,
}) => {
const theme = useTheme();
const data = useMemo(() => value && cloneDeep(value), [value]);
const hasData = data !== null && data !== undefined;
Expand Down Expand Up @@ -208,7 +215,7 @@ export const ObjectControl: FC<ObjectProps> = ({ name, storyId, value, onChange,
<Button
ariaLabel={false}
disabled={readonly}
id={getControlSetterButtonId(name, storyId)}
id={getControlSetterButtonId(name, storyId, controlsId)}
onClick={onForceVisible}
>
Set object
Expand All @@ -219,7 +226,7 @@ export const ObjectControl: FC<ObjectProps> = ({ name, storyId, value, onChange,
const rawJSONForm = (
<RawInput
ref={htmlElRef}
id={getControlId(name, storyId)}
id={getControlId(name, storyId, controlsId)}
minRows={3}
name={name}
key={jsonString}
Expand Down
3 changes: 2 additions & 1 deletion code/addons/docs/src/blocks/controls/Range.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,7 @@ function getNumberOfDecimalPlaces(number: number) {
export const RangeControl: FC<RangeProps> = ({
name,
storyId,
controlsId,
value,
onChange,
min = 0,
Expand All @@ -195,7 +196,7 @@ export const RangeControl: FC<RangeProps> = ({
const numberOFDecimalsPlaces = useMemo(() => getNumberOfDecimalPlaces(step), [step]);

const readonly = !!argType?.table?.readonly;
const controlId = getControlId(name, storyId);
const controlId = getControlId(name, storyId, controlsId);

return (
<RangeWrapper readOnly={readonly}>
Expand Down
5 changes: 3 additions & 2 deletions code/addons/docs/src/blocks/controls/Text.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ const MaxLength = styled.div<{ isMaxed: boolean }>(({ isMaxed }) => ({
export const TextControl: FC<TextProps> = ({
name,
storyId,
controlsId,
value,
onChange,
onFocus,
Expand Down Expand Up @@ -50,7 +51,7 @@ export const TextControl: FC<TextProps> = ({
variant="outline"
size="medium"
disabled={readonly}
id={getControlSetterButtonId(name, storyId)}
id={getControlSetterButtonId(name, storyId, controlsId)}
onClick={onForceVisible}
>
Set string
Expand All @@ -63,7 +64,7 @@ export const TextControl: FC<TextProps> = ({
return (
<Wrapper>
<Form.Textarea
id={getControlId(name, storyId)}
id={getControlId(name, storyId, controlsId)}
maxLength={maxLength}
onChange={handleChange}
disabled={readonly}
Expand Down
18 changes: 18 additions & 0 deletions code/addons/docs/src/blocks/controls/helpers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,14 @@ describe('getControlId', () => {
it('includes storyId when provided', () => {
expect(getControlId('some-id', 'story--name')).toBe('control-story--name-some-id');
});

it('includes controlsId when provided', () => {
expect(getControlId('some-id', undefined, 'r1')).toBe('control-r1-some-id');
});

it('includes both controlsId and storyId when provided', () => {
expect(getControlId('some-id', 'story--name', 'r1')).toBe('control-r1-story--name-some-id');
});
});

describe('getControlSetterButtonId', () => {
Expand All @@ -30,4 +38,14 @@ describe('getControlSetterButtonId', () => {
it('includes storyId when provided', () => {
expect(getControlSetterButtonId('some-id', 'story--name')).toBe('set-story--name-some-id');
});

it('includes controlsId when provided', () => {
expect(getControlSetterButtonId('some-id', undefined, 'r1')).toBe('set-r1-some-id');
});

it('includes both controlsId and storyId when provided', () => {
expect(getControlSetterButtonId('some-id', 'story--name', 'r1')).toBe(
'set-r1-story--name-some-id'
);
});
});
30 changes: 24 additions & 6 deletions code/addons/docs/src/blocks/controls/helpers.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
/**
* Adds `control` prefix to make ID attribute more specific. Removes spaces because spaces are not
* allowed in ID attributes
* allowed in ID attributes. The optional `controlsId` disambiguates multiple `<Controls>` blocks
* rendered for the same story on a single page.
*
* @example
*
Expand All @@ -10,14 +11,23 @@
*
* @link http://xahlee.info/js/html_allowed_chars_in_attribute.html
*/
export const getControlId = (value: string, storyId?: string) => {
export const getControlId = (value: string, storyId?: string, controlsId?: string) => {
const base = value.replace(/\s+/g, '-');
return storyId ? `control-${storyId}-${base}` : `control-${base}`;
const parts = ['control'];
if (controlsId) {
parts.push(controlsId);
}
if (storyId) {
parts.push(storyId);
}
parts.push(base);
return parts.join('-');
};

/**
* Adds `set` prefix to make ID attribute more specific. Removes spaces because spaces are not
* allowed in ID attributes
* allowed in ID attributes. The optional `controlsId` disambiguates multiple `<Controls>` blocks
* rendered for the same story on a single page.
*
* @example
*
Expand All @@ -27,7 +37,15 @@ export const getControlId = (value: string, storyId?: string) => {
*
* @link http://xahlee.info/js/html_allowed_chars_in_attribute.html
*/
export const getControlSetterButtonId = (value: string, storyId?: string) => {
export const getControlSetterButtonId = (value: string, storyId?: string, controlsId?: string) => {
const base = value.replace(/\s+/g, '-');
return storyId ? `set-${storyId}-${base}` : `set-${base}`;
const parts = ['set'];
if (controlsId) {
parts.push(controlsId);
}
if (storyId) {
parts.push(storyId);
}
parts.push(base);
return parts.join('-');
};
Loading
Loading