Skip to content
Merged
Changes from 1 commit
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
Prev Previous commit
Next Next commit
Apply cfs2 to vsf3 migration tool, keep named functions
  • Loading branch information
ciampo committed Dec 22, 2024
commit fd4b11860e1251c2a03a32f95871922c05f60c14
259 changes: 139 additions & 120 deletions packages/components/src/menu/stories/index.story.tsx
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hiding whitespace changes removes a lot of noise while reviewing

Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
/**
* External dependencies
*/
import type { Meta, StoryObj } from '@storybook/react';
import type { StoryObj, Meta } from '@storybook/react';
import { css } from '@emotion/react';

/**
Expand All @@ -22,7 +22,7 @@ import { createSlotFill, Provider as SlotFillProvider } from '../../slot-fill';
import { ContextSystemProvider } from '../../context';
import type { MenuProps } from '../types';

const meta = {
const meta: Meta< typeof Menu > = {
id: 'components-experimental-menu',
title: 'Components (Experimental)/Actions/Menu',
component: Menu,
Expand Down Expand Up @@ -64,126 +64,120 @@ const meta = {
source: { excludeDecorators: true },
},
},
} satisfies Meta< typeof Menu >;
};
export default meta;

type Story = StoryObj< MenuProps >;

export const Default: Story = {
args: {
children: (
<>
<Menu.TriggerButton
render={
<Button __next40pxDefaultSize variant="secondary" />
}
>
Open menu
</Menu.TriggerButton>
<Menu.Popover>
<Menu.Item>
<Menu.ItemLabel>Label</Menu.ItemLabel>
</Menu.Item>
<Menu.Item>
<Menu.ItemLabel>Label</Menu.ItemLabel>
<Menu.ItemHelpText>Help text</Menu.ItemHelpText>
</Menu.Item>
<Menu.Item>
<Menu.ItemLabel>Label</Menu.ItemLabel>
<Menu.ItemHelpText>
The menu item help text is automatically truncated
when there are more than two lines of text
</Menu.ItemHelpText>
export const Default: StoryObj< typeof Menu > = {
render: ( props: MenuProps ) => (
<Menu { ...props }>
<Menu.TriggerButton
render={ <Button __next40pxDefaultSize variant="secondary" /> }
>
Open menu
</Menu.TriggerButton>
<Menu.Popover>
<Menu.Item>
<Menu.ItemLabel>Label</Menu.ItemLabel>
</Menu.Item>
<Menu.Item>
<Menu.ItemLabel>Label</Menu.ItemLabel>
<Menu.ItemHelpText>Help text</Menu.ItemHelpText>
</Menu.Item>
<Menu.Item>
<Menu.ItemLabel>Label</Menu.ItemLabel>
<Menu.ItemHelpText>
The menu item help text is automatically truncated when
there are more than two lines of text
</Menu.ItemHelpText>
</Menu.Item>
<Menu.Item hideOnClick={ false }>
<Menu.ItemLabel>Label</Menu.ItemLabel>
<Menu.ItemHelpText>
This item doesn&apos;t close the menu on click
</Menu.ItemHelpText>
</Menu.Item>
<Menu.Item disabled>Disabled item</Menu.Item>
<Menu.Separator />
<Menu.Group>
<Menu.GroupLabel>Group label</Menu.GroupLabel>
<Menu.Item
prefix={ <Icon icon={ customLink } size={ 24 } /> }
>
<Menu.ItemLabel>With prefix</Menu.ItemLabel>
</Menu.Item>
<Menu.Item hideOnClick={ false }>
<Menu.ItemLabel>Label</Menu.ItemLabel>
<Menu.ItemHelpText>
This item doesn&apos;t close the menu on click
</Menu.ItemHelpText>
<Menu.Item suffix="⌘S">With suffix</Menu.Item>
<Menu.Item
disabled
prefix={
<Icon icon={ formatCapitalize } size={ 24 } />
}
suffix="⌥⌘T"
>
<Menu.ItemLabel>
Disabled with prefix and suffix
</Menu.ItemLabel>
<Menu.ItemHelpText>And help text</Menu.ItemHelpText>
</Menu.Item>
<Menu.Item disabled>Disabled item</Menu.Item>
<Menu.Separator />
<Menu.Group>
<Menu.GroupLabel>Group label</Menu.GroupLabel>
<Menu.Item
prefix={ <Icon icon={ customLink } size={ 24 } /> }
>
<Menu.ItemLabel>With prefix</Menu.ItemLabel>
</Menu.Item>
<Menu.Item suffix="⌘S">With suffix</Menu.Item>
<Menu.Item
disabled
prefix={
<Icon icon={ formatCapitalize } size={ 24 } />
}
suffix="⌥⌘T"
>
<Menu.ItemLabel>
Disabled with prefix and suffix
</Menu.ItemLabel>
<Menu.ItemHelpText>And help text</Menu.ItemHelpText>
</Menu.Item>
</Menu.Group>
</Menu.Popover>
</>
),
},
</Menu.Group>
</Menu.Popover>
</Menu>
),

args: {},
};

export const WithSubmenu: Story = {
args: {
children: (
<>
<Menu.TriggerButton
render={
<Button __next40pxDefaultSize variant="secondary" />
}
>
Open menu
</Menu.TriggerButton>
<Menu.Popover>
<Menu.Item>Level 1 item</Menu.Item>
<Menu>
<Menu.SubmenuTriggerItem suffix="Suffix">
<Menu.ItemLabel>
Submenu trigger item with a long label
</Menu.ItemLabel>
</Menu.SubmenuTriggerItem>
<Menu.Popover>
<Menu.Item>
<Menu.ItemLabel>Level 2 item</Menu.ItemLabel>
</Menu.Item>
<Menu.Item>
<Menu.ItemLabel>Level 2 item</Menu.ItemLabel>
</Menu.Item>
<Menu>
<Menu.SubmenuTriggerItem>
export const WithSubmenu: StoryObj< typeof Menu > = {
render: ( props: MenuProps ) => (
<Menu { ...props }>
<Menu.TriggerButton
render={ <Button __next40pxDefaultSize variant="secondary" /> }
>
Open menu
</Menu.TriggerButton>
<Menu.Popover>
<Menu.Item>Level 1 item</Menu.Item>
<Menu>
<Menu.SubmenuTriggerItem suffix="Suffix">
<Menu.ItemLabel>
Submenu trigger item with a long label
</Menu.ItemLabel>
</Menu.SubmenuTriggerItem>
<Menu.Popover>
<Menu.Item>
<Menu.ItemLabel>Level 2 item</Menu.ItemLabel>
</Menu.Item>
<Menu.Item>
<Menu.ItemLabel>Level 2 item</Menu.ItemLabel>
</Menu.Item>
<Menu>
<Menu.SubmenuTriggerItem>
<Menu.ItemLabel>Submenu trigger</Menu.ItemLabel>
</Menu.SubmenuTriggerItem>
<Menu.Popover>
<Menu.Item>
<Menu.ItemLabel>
Submenu trigger
Level 3 item
</Menu.ItemLabel>
</Menu.SubmenuTriggerItem>
<Menu.Popover>
<Menu.Item>
<Menu.ItemLabel>
Level 3 item
</Menu.ItemLabel>
</Menu.Item>
<Menu.Item>
<Menu.ItemLabel>
Level 3 item
</Menu.ItemLabel>
</Menu.Item>
</Menu.Popover>
</Menu>
</Menu.Popover>
</Menu>
</Menu.Popover>
</>
),
</Menu.Item>
<Menu.Item>
<Menu.ItemLabel>
Level 3 item
</Menu.ItemLabel>
</Menu.Item>
</Menu.Popover>
</Menu>
</Menu.Popover>
</Menu>
</Menu.Popover>
</Menu>
),

args: {
...Default.args,
},
};

export const WithCheckboxes: Story = {
export const WithCheckboxes: StoryObj< typeof Menu > = {
render: function WithCheckboxes( props: MenuProps ) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why do we need to provide a named function here? Can't we just do:

	render: ( props: MenuProps ) => (
		<>
			//...
		</>
	),

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I used named functions to prevent warnings about using hooks outside of a component.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I see. The rules of hooks are broken because React doesn't know how to statically treat hooks inside a dynamically assigned function component like this - it just can't possibly guarantee if render() is not going to be passed around, which would break rules of hooks.

To me that's just another reason not to use this render() syntax and to use args.children if possible.

Alternatively, we could move the component declaration outside of render() and just pass an instance here - would that work better? The hack with tricking the linter bit us back and we had to change it, which is why I'm hesitant to do it once more.

I'm leaving this to your judgment, as I don't have particularly strong feelings.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My understanding was that the hooks linter simply can't know that render: () => {} is meant to be a React component. render: function UppercaseName() {} just tells the linter that it's meant to be a component, like any other function with a uppercased name.

What we're doing for render isn't that different from the way we can define subcomponents, e.g.:

// this is fine
Menu.Submenu = () => { /* something with hooks */ };

// this is not fine because lowercase
Menu.submenu = () => { /* something with hooks */ };

// would've been fine if the `render` field had been uppercase like this 😅
Default.Render = () => { /* something with hooks */ };

So while I can't say that this isn't a "hack", I don't think it's hacky in a way that's unsafe?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've elaborated on it here, and it's definitely not unsafe, and we can move forward with it for Storybook.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These parameter types shouldn't be necessary, since that is already handled through the StoryObj<> generic.

Suggested change
render: function WithCheckboxes( props: MenuProps ) {
render: function WithCheckboxes( props ) {

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I did that initially, but then I added back the explicit props because it seems to improve TypeScript performance (TS doesn't need to compute the prop types since they are explicitly declared)

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We ran some TS profiling here, and it turned out to make a big difference. Results were similar to here:

#67422 (comment)

As I discussed with @ciampo over DM earlier, I think this has something to do with how Storybook infers component props for stories when working with compound components, and maybe the inference is not optimized to work with subcomponents and does extra cycles, resulting in infinite loop. It's worth debugging with a dev Storybook setup and an isolated story test case, IMO.

Copy link
Member

@mirka mirka Dec 23, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Huh, that's unfortunate!

One ideological issue I have with props: MenuProps is that MyComponentProps is often not the canonically accurate set of props in our library, because we often mix in element props with WordPressComponentProps<>. I feel like we should generally prefer React.ComponentProps< typeof MyComponent > for accuracy. Would that still work here while maintaining the performance benefits? (Not a blocker though, since element props mixed in with WordPressComponentProps<> are not going to show up as Storybook controls anyway.)

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's try it out and measure it, but I think it should be fine, as long as we don't let Storybook infer the prop types.

Copy link
Contributor Author

@ciampo ciampo Dec 23, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What about React.ComponentProps< Menu > ? It could hit a sweet spot between accuracy and performance?

We can test it out in a separate PR, if needed

const [ isAChecked, setAChecked ] = useState( false );
const [ isBChecked, setBChecked ] = useState( true );
Expand Down Expand Up @@ -327,9 +321,13 @@ export const WithCheckboxes: Story = {
</Menu>
);
},

args: {
...Default.args,
},
};

export const WithRadios: Story = {
export const WithRadios: StoryObj< typeof Menu > = {
render: function WithRadios( props: MenuProps ) {
const [ radioValue, setRadioValue ] = useState( 'two' );
const onRadioChange: React.ComponentProps<
Expand Down Expand Up @@ -395,6 +393,10 @@ export const WithRadios: Story = {
</Menu>
);
},

args: {
...Default.args,
},
};

const modalOnTopOfMenuPopover = css`
Expand All @@ -403,8 +405,7 @@ const modalOnTopOfMenuPopover = css`
}
`;

// For more examples with `Modal`, check https://ariakit.org/examples/menu-wordpress-modal
export const WithModals: Story = {
export const WithModals: StoryObj< typeof Menu > = {
render: function WithModals( props: MenuProps ) {
const [ isOuterModalOpen, setOuterModalOpen ] = useState( false );
const [ isInnerModalOpen, setInnerModalOpen ] = useState( false );
Expand Down Expand Up @@ -466,6 +467,10 @@ export const WithModals: Story = {
</>
);
},

args: {
...Default.args,
},
};

const ExampleSlotFill = createSlotFill( 'Example' );
Expand Down Expand Up @@ -516,8 +521,8 @@ const Fill = ( { children }: { children: React.ReactNode } ) => {
);
};

export const WithSlotFill: Story = {
render: ( props ) => {
export const WithSlotFill: StoryObj< typeof Menu > = {
render: ( props: MenuProps ) => {
return (
<SlotFillProvider>
<Menu { ...props }>
Expand Down Expand Up @@ -556,15 +561,20 @@ export const WithSlotFill: Story = {
</SlotFillProvider>
);
},

args: {
...Default.args,
},
};

const toolbarVariantContextValue = {
Menu: {
variant: 'toolbar',
},
};
export const ToolbarVariant: Story = {
render: ( props ) => (

export const ToolbarVariant: StoryObj< typeof Menu > = {
render: ( props: MenuProps ) => (
// TODO: add toolbar
<ContextSystemProvider value={ toolbarVariantContextValue }>
<Menu { ...props }>
Expand Down Expand Up @@ -597,10 +607,14 @@ export const ToolbarVariant: Story = {
</Menu>
</ContextSystemProvider>
),

args: {
...Default.args,
},
};

export const InsideModal: Story = {
render: function InsideModal( props ) {
export const InsideModal: StoryObj< typeof Menu > = {
render: function InsideModal( props: MenuProps ) {
const [ isModalOpen, setModalOpen ] = useState( false );
return (
<>
Expand Down Expand Up @@ -663,6 +677,11 @@ export const InsideModal: Story = {
</>
);
},

args: {
...Default.args,
},

parameters: {
docs: {
source: { type: 'code' },
Expand Down
Loading