From 44fee0f86e66c943b745719b81530d1d261d9f84 Mon Sep 17 00:00:00 2001 From: Steve Dodier-Lazaro Date: Wed, 17 Sep 2025 17:07:15 +0200 Subject: [PATCH 01/15] UI: Add WithPopover and port relevant internals to use it --- .../addons/docs/src/blocks/controls/Color.tsx | 137 +++++---- .../components/Popover/Popover.stories.tsx | 148 ++++++++++ .../components/components/Popover/Popover.tsx | 116 ++++++++ .../Popover/WithPopover.stories.tsx | 260 ++++++++++++++++++ .../components/Popover/WithPopover.tsx | 104 +++++++ .../components/Popover/lazy-WithPopover.tsx | 12 + .../components/components/Select/Select.tsx | 7 +- .../components/shared/overlayHelpers.tsx | 65 +++++ .../components/typography/link/link.tsx | 49 ++-- code/core/src/components/index.ts | 10 + .../components/layout/LayoutProvider.tsx | 8 +- .../components/sidebar/ContextMenu.tsx | 30 +- .../src/manager/components/sidebar/Menu.tsx | 12 +- .../manager/components/sidebar/RefBlocks.tsx | 72 +++-- .../components/sidebar/RefIndicator.tsx | 225 +++++++-------- .../components/sidebar/Refs.stories.tsx | 49 +++- .../components/sidebar/Sidebar.stories.tsx | 56 +++- .../components/sidebar/StatusButton.tsx | 11 +- .../manager/components/sidebar/TagsFilter.tsx | 69 +++-- code/core/src/manager/globals/exports.ts | 1 + 20 files changed, 1146 insertions(+), 295 deletions(-) create mode 100644 code/core/src/components/components/Popover/Popover.stories.tsx create mode 100644 code/core/src/components/components/Popover/Popover.tsx create mode 100644 code/core/src/components/components/Popover/WithPopover.stories.tsx create mode 100644 code/core/src/components/components/Popover/WithPopover.tsx create mode 100644 code/core/src/components/components/Popover/lazy-WithPopover.tsx create mode 100644 code/core/src/components/components/shared/overlayHelpers.tsx diff --git a/code/addons/docs/src/blocks/controls/Color.tsx b/code/addons/docs/src/blocks/controls/Color.tsx index d732bf811daa..a57c73dc5b87 100644 --- a/code/addons/docs/src/blocks/controls/Color.tsx +++ b/code/addons/docs/src/blocks/controls/Color.tsx @@ -1,7 +1,7 @@ import type { ChangeEvent, FC, FocusEvent } from 'react'; import React, { useCallback, useEffect, useMemo, useState } from 'react'; -import { Form, TooltipNote, WithTooltip } from 'storybook/internal/components'; +import { Button, Form, WithPopover } from 'storybook/internal/components'; import { MarkupIcon } from '@storybook/icons'; @@ -18,13 +18,6 @@ const Wrapper = styled.div({ maxWidth: 250, }); -const PickerTooltip = styled(WithTooltip)({ - position: 'absolute', - zIndex: 1, - top: 4, - left: 4, -}); - const TooltipContent = styled.div({ width: 200, margin: 5, @@ -40,10 +33,6 @@ const TooltipContent = styled.div({ }, }); -const Note = styled(TooltipNote)(({ theme }) => ({ - fontFamily: theme.typography.fonts.base, -})); - const Swatches = styled.div({ display: 'grid', gridTemplateColumns: 'repeat(9, 16px)', @@ -53,22 +42,24 @@ const Swatches = styled.div({ width: 200, }); -const SwatchColor = styled.div<{ active?: boolean }>(({ theme, active }) => ({ - width: 16, - height: 16, - boxShadow: active - ? `${theme.appBorderColor} 0 0 0 1px inset, ${theme.textMutedColor}50 0 0 0 4px` - : `${theme.appBorderColor} 0 0 0 1px inset`, - borderRadius: theme.appBorderRadius, -})); - const swatchBackground = `url('data:image/svg+xml;charset=utf-8,')`; -type SwatchProps = { value: string } & React.ComponentProps; -const Swatch = ({ value, style, ...props }: SwatchProps) => { - const backgroundImage = `linear-gradient(${value}, ${value}), ${swatchBackground}, linear-gradient(hsl(0 0 100 / .4), hsl(0 0 100 / .4))`; - return ; -}; +const SwatchColor = styled(Button)<{ selected?: boolean; value: string }>( + ({ value, selected, theme }) => ({ + width: 16, + height: 16, + boxShadow: selected + ? `${theme.appBorderColor} 0 0 0 1px inset, ${theme.textMutedColor}50 0 0 0 4px` + : `${theme.appBorderColor} 0 0 0 1px inset`, + border: 'none', + borderRadius: theme.appBorderRadius, + '&, &:hover': { + background: 'unset', + backgroundColor: 'unset', + backgroundImage: `linear-gradient(${value}, ${value}), ${swatchBackground}, linear-gradient(hsl(0 0 100 / .4), hsl(0 0 100 / .4))`, + }, + }) +); const Input = styled(Form.Input)(({ theme }) => ({ width: '100%', @@ -82,7 +73,15 @@ const Input = styled(Form.Input)(({ theme }) => ({ }, })); -const ToggleIcon = styled(MarkupIcon)(({ theme }) => ({ +const PopoverTrigger = styled(SwatchColor)<{ disabled: boolean }>(({ disabled }) => ({ + position: 'absolute', + top: 4, + left: 4, + zIndex: 1, + cursor: disabled ? 'not-allowed' : 'pointer', +})); + +const CycleColorSpaceButton = styled(Button)(({ theme }) => ({ position: 'absolute', zIndex: 1, top: 6, @@ -384,12 +383,19 @@ export const ColorControl: FC = ({ - ) => updateValue(e.target.value)} + onFocus={(e: FocusEvent) => e.target.select()} + readOnly={readOnly} + placeholder="Choose color..." + /> + color && addPreset(color)} - tooltip={ + popover={ = ({ {presets.length > 0 && ( {presets.map((preset, index: number) => ( - } - > - preset && updateValue(preset.value || '')} - /> - + variant="ghost" + padding="small" + size="small" + ariaLabel="Pick this color" + tooltip={preset?.keyword || preset?.value || ''} + value={preset?.value || ''} + selected={ + !!( + color && + preset && + preset[colorSpace] && + id(preset[colorSpace] || '') === id(color[colorSpace]) + ) + } + onClick={() => preset && updateValue(preset.value || '')} + /> ))} )} } > - - - ) => updateValue(e.target.value)} - onFocus={(e: FocusEvent) => e.target.select()} - readOnly={readOnly} - placeholder="Choose color..." - /> - {value ? : null} + + + {value ? ( + + + + ) : null} ); }; diff --git a/code/core/src/components/components/Popover/Popover.stories.tsx b/code/core/src/components/components/Popover/Popover.stories.tsx new file mode 100644 index 000000000000..5991f11fdd5f --- /dev/null +++ b/code/core/src/components/components/Popover/Popover.stories.tsx @@ -0,0 +1,148 @@ +import React from 'react'; + +import { Button } from 'storybook/internal/components'; + +import { CloseAltIcon } from '@storybook/icons'; + +import { fn } from 'storybook/test'; + +import preview from '../../../../../.storybook/preview'; +import { Popover } from './Popover'; + +const SampleTooltip = () => 'Lorem ipsum dolor sit amet'; + +const SamplePopover = () => ( +
+

Lorem ipsum dolor sit amet

+

Consectatur vestibulum concet durum politu coret weirom

+ +
+); + +const meta = preview.meta({ + id: 'overlay-Popover', + title: 'Overlay/Popover', + component: Popover, + args: { + children: , + color: undefined, + hasChrome: true, + }, + argTypes: { + color: { + type: 'string', + control: 'select', + options: ['default', 'inverse', 'positive', 'negative', 'warning', 'none'], + }, + }, +}); + +export const AsTooltip = meta.story({ + args: { + children: , + }, +}); + +export const AsPopover = meta.story({ + args: { + children: , + }, +}); + +export const WithChrome = meta.story({ + args: { + hasChrome: true, + }, +}); + +export const WithoutChrome = meta.story({ + args: { + hasChrome: false, + }, +}); + +export const WithHideButton = meta.story({ + args: { + hasChrome: true, + onHide: fn(), + }, +}); + +export const WithCustomHideLabel = meta.story({ + args: { + hasChrome: true, + onHide: fn(), + hideLabel: 'Close Popover', + }, +}); + +export const WithHideButtonAndPadding = meta.story({ + args: { + children: ( +
+ When the close button covers content, setting padding to{' '} + 8px 40px 8px 8px solves simple use cases. +
+ ), + hasChrome: true, + onHide: fn(), + padding: '8px 40px 8px 8px', + }, +}); + +export const WithCustomHideButton = meta.story({ + args: { + children: ( +
+
For more advanced use cases, pass your own close button to the popover.
+ +
+ ), + hasChrome: true, + }, +}); + +export const ColorDefault = meta.story({ + args: { + color: 'default', + }, +}); + +export const ColorInverse = meta.story({ + args: { + color: 'inverse', + }, +}); + +export const ColorPositive = meta.story({ + args: { + color: 'positive', + }, +}); + +export const ColorNegative = meta.story({ + args: { + color: 'negative', + }, +}); + +export const ColorWarning = meta.story({ + args: { + color: 'warning', + }, +}); + +/** Useful for WithTooltip where we'll use specialized tooltips like TooltipNote. */ +export const WithoutColor = meta.story({ + args: { + color: 'none', + }, +}); diff --git a/code/core/src/components/components/Popover/Popover.tsx b/code/core/src/components/components/Popover/Popover.tsx new file mode 100644 index 000000000000..b9e130c73df1 --- /dev/null +++ b/code/core/src/components/components/Popover/Popover.tsx @@ -0,0 +1,116 @@ +import React, { forwardRef } from 'react'; + +import { Button } from 'storybook/internal/components'; + +import { CloseIcon } from '@storybook/icons'; + +import { lighten, styled } from 'storybook/theming'; + +export interface PopoverProps { + /** Content of the popover. */ + children: React.ReactNode; + + /** Preset popover color taken from the theme, affecting both bathground and foreground. */ + color?: 'default' | 'inverse' | 'positive' | 'negative' | 'warning' | 'none'; + + /** Whether the popover is rendered with a decorative window-like appearance. */ + hasChrome: boolean; + + /** Optional callback connected to a close button. Then button is shown only when passed. */ + onHide?: () => void; + + /** Optional custom label for the close button, if there is one. */ + hideLabel?: string; + + /** Padding between the content and popover edge. */ + padding?: number | string; +} + +const Wrapper = styled.div<{ + bgColor: NonNullable; + hasChrome: boolean; + hasCloseButton: boolean; + padding: NonNullable; +}>( + ({ hasCloseButton, padding }) => ({ + display: 'inline-block', + position: 'relative', + minHeight: hasCloseButton ? 36 : undefined, + zIndex: 2147483647, + colorScheme: 'light dark', + padding, + }), + ({ theme, hasChrome }) => + hasChrome + ? { + filter: ` + drop-shadow(0px 5px 5px rgba(0,0,0,0.05)) + drop-shadow(0 1px 3px rgba(0,0,0,0.1)) + `, + borderRadius: theme.appBorderRadius + 2, + fontSize: theme.typography.size.s1, + } + : {}, + ({ theme, bgColor }) => + bgColor === 'default' && { + background: theme.base === 'light' ? lighten(theme.background.app) : theme.background.app, + color: theme.color.defaultText, + }, + ({ theme, bgColor }) => + bgColor === 'inverse' && { + background: theme.base === 'light' ? theme.color.darkest : theme.color.lightest, + color: theme.color.inverseText, + }, + ({ theme, bgColor }) => + (bgColor === 'positive' || bgColor === 'negative' || bgColor === 'warning') && { + background: theme.background[bgColor], + color: theme.color[`${bgColor}Text`], + } +); + +const AbsoluteButton = styled(Button)({ + position: 'absolute', + top: 4, + right: 4, +}); + +export const Popover = forwardRef( + ( + { + children, + color = 'default', + hasChrome = true, + hideLabel = 'Close', + onHide, + padding = 8, + ...props + }, + ref + ) => { + return ( + + {children} + {onHide && ( + + + + )} + + ); + } +); + +Popover.displayName = 'Popover'; diff --git a/code/core/src/components/components/Popover/WithPopover.stories.tsx b/code/core/src/components/components/Popover/WithPopover.stories.tsx new file mode 100644 index 000000000000..2bfeb712cfd7 --- /dev/null +++ b/code/core/src/components/components/Popover/WithPopover.stories.tsx @@ -0,0 +1,260 @@ +import React from 'react'; + +import { expect, fn, screen, userEvent, within } from 'storybook/test'; +import { styled } from 'storybook/theming'; + +import preview from '../../../../../.storybook/preview'; +import { OverlayTriggerDecorator, Trigger } from '../shared/overlayHelpers'; +import { WithPopover } from './WithPopover'; + +const StyledSamplePopover = styled.div({ + padding: 10, + maxWidth: 200, + display: 'flex', + flexDirection: 'column', + gap: 10, +}); + +const SamplePopover = () => ( + +

Lorem ipsum dolor sit amet

+

Consectatur vestibulum concet durum politu coret weirom

+ +
+); + +const meta = preview.meta({ + id: 'overlay-WithPopover', + title: 'Overlay/WithPopover', + component: WithPopover, + args: { + hasChrome: true, + offset: 8, + placement: 'top', + }, + decorators: [OverlayTriggerDecorator], +}); + +export const Base = meta.story({ + args: { + children: Click me!, + popover: , + }, +}); + +export const Placements = meta.story({ + args: { + children: ignored, + popover: 'ignored', + }, + render: (args) => ( +
+ + Top + + + Top Start + + + Top End + + + Bottom + + + Bottom Start + + + Bottom End + + + Left + + + Left Start + + + Left End + + + Right + + + Right Start + + + Right End + +
+ ), +}); + +export const WithChrome = meta.story({ + args: { + hasChrome: true, + children: Click me!, + popover: , + }, +}); + +export const WithoutChrome = meta.story({ + args: { + hasChrome: false, + children: Click me!, + popover: , + }, +}); + +export const CustomOffset = meta.story({ + args: { + offset: 20, + children: Click me!, + popover: , + }, +}); + +export const CustomPadding = meta.story({ + args: { + padding: 20, + children: Click me!, + popover: , + }, +}); + +export const WithCloseButton = meta.story({ + args: { + children: Click me!, + popover: , + hasCloseButton: true, + }, +}); + +export const WithoutCloseButton = meta.story({ + args: { + children: Click me!, + popover: , + hasCloseButton: false, + }, +}); + +export const AlwaysOpen = meta.story({ + args: { + visible: true, + children: Always visible tooltip, + popover: , + placement: 'right-start', + }, + play: async () => { + await expect(await screen.findByText('Lorem ipsum dolor sit')).toBeInTheDocument(); + }, +}); + +export const NeverOpen = meta.story({ + args: { + visible: false, + children: Never visible tooltip, + popover: , + placement: 'right-start', + }, + play: async () => { + await expect(screen.queryByText('Lorem ipsum dolor sit')).not.toBeInTheDocument(); + }, +}); + +export const WithVisibilityCallback = meta.story({ + args: { + children: Click me!, + popover: , + onVisibleChange: fn(), + }, + play: async ({ args, canvasElement }) => { + const canvas = within(canvasElement); + const trigger = canvas.getByText('Click me!'); + + await userEvent.click(trigger); + await expect(args.onVisibleChange).toHaveBeenCalledWith(true); + + await userEvent.click(trigger); + await expect(args.onVisibleChange).toHaveBeenCalledWith(false); + }, +}); + +export const InteractivePopoverKB = meta.story({ + args: { + children: Click me!, + popover: , + }, + play: async ({ canvasElement, step }) => { + const canvas = within(canvasElement); + const trigger = canvas.getByText('Click me!'); + + await step('Open popover', async () => { + trigger.focus(); + await userEvent.keyboard('{Enter}'); + await expect(screen.queryByText('Lorem ipsum dolor sit amet')).toBeInTheDocument(); + }); + + await step('Press Tab to enter popover', async () => { + await userEvent.tab(); + const continueButton = await screen.findByText('Continue'); + await expect(continueButton).toHaveFocus(); + }); + + await step('Press Esc to close popover', async () => { + await userEvent.keyboard('{Escape}'); + await expect(screen.queryByText('Lorem ipsum dolor sit amet')).not.toBeInTheDocument(); + }); + }, +}); + +export const InteractivePopoverMouse = meta.story({ + args: { + children: Click me!, + popover: , + }, + render: (args) => ( +
+ + +
+ ), + play: async ({ canvasElement, step }) => { + const canvas = within(canvasElement); + + await step('Open popover', async () => { + const trigger = canvas.getByText('Click me!'); + await userEvent.click(trigger); + await expect(screen.queryByText('Lorem ipsum dolor sit amet')).toBeInTheDocument(); + }); + + await step('Click outside popover to close it', async () => { + const sibling = canvas.getByText('Sibling Button'); + await userEvent.click(sibling); + await expect(screen.queryByText('Lorem ipsum dolor sit amet')).not.toBeInTheDocument(); + }); + }, +}); + +export const WithLongContent = meta.story({ + args: { + children: Long content, + popover: ( +
+

Very Long Tooltip Content

+

+ This is a very long popover that demonstrates how the popover component handles extensive + content. It should wrap properly and maintain good readability even with multiple lines of + text. The popover positioning should also adapt to ensure it remains visible within the + viewport boundaries. +

+
+ ), + }, +}); diff --git a/code/core/src/components/components/Popover/WithPopover.tsx b/code/core/src/components/components/Popover/WithPopover.tsx new file mode 100644 index 000000000000..5e5f9ff8d0d5 --- /dev/null +++ b/code/core/src/components/components/Popover/WithPopover.tsx @@ -0,0 +1,104 @@ +import type { DOMAttributes, ReactElement, ReactNode } from 'react'; +import React, { useCallback, useState } from 'react'; + +import { Popover, convertToReactAriaPlacement } from 'storybook/internal/components'; +import type { PopperPlacement } from 'storybook/internal/components'; + +import { DialogTrigger, Popover as PopoverUpstream, Pressable } from 'react-aria-components'; + +export interface WithPopoverProps { + /** Whether to display the Popover in a prestyled container. True by default. */ + hasChrome?: boolean; + + /** + * Whether to display a close button in the top right corner of the popover overlay. Can overlap + * with overlay content, make sure to test your use case. False by default. + */ + hasCloseButton?: boolean; + + /** Optional custom label for the close button, if there is one. */ + closeLabel?: string; + + /** Optional custom padding for the popover overlay. */ + padding?: number | string; + + /** Distance between the trigger and Popover. Customize only if you have a good reason to. */ + offset?: number; + + /** + * Placement of the Popover. Start and End variants involve additional JS dimension calculations + * and should be used sparingly. Left and Right get inverted in RTL. + */ + placement?: PopperPlacement; + + /** + * Popover content. Pass a function to receive a onHide callback to collect to your close button, + * or if you want to wait for the popover to be opened to call your content component. + */ + popover: ReactNode | ((props: { onHide: () => void }) => ReactNode); + + /** Popover trigger, must be a single child with click/press events. Must forward refs. */ + children: ReactElement, string>; + + /** Uncontrolled state: whether the Popover is initially visible. */ + defaultVisible?: boolean; + + /** Controlled state: whether the Popover is visible. */ + visible?: boolean; + + /** Controlled state: fires when user interaction causes the Popover to change visibility. */ + onVisibleChange?: (isVisible: boolean) => void; +} + +export const WithPopover = ({ + placement: placementProp = 'bottom-start', + hasChrome = true, + hasCloseButton = false, + closeLabel, + offset = 8, + padding, + popover, + children, + defaultVisible, + visible, + onVisibleChange, + ...props +}: WithPopoverProps) => { + // Map Popper.js placement to react-aria placement best we can. + const placement = convertToReactAriaPlacement(placementProp); + + const [isOpen, setIsOpen] = useState(defaultVisible ?? false); + const onOpenChange = useCallback( + (isOpen: boolean) => { + setIsOpen(isOpen); + onVisibleChange?.(isOpen); + }, + [onVisibleChange] + ); + const onHide = useCallback(() => setIsOpen(false), []); + + return ( + + {children} + + + {typeof popover === 'function' ? popover({ onHide }) : popover} + + + + ); +}; diff --git a/code/core/src/components/components/Popover/lazy-WithPopover.tsx b/code/core/src/components/components/Popover/lazy-WithPopover.tsx new file mode 100644 index 000000000000..10c0932b6db5 --- /dev/null +++ b/code/core/src/components/components/Popover/lazy-WithPopover.tsx @@ -0,0 +1,12 @@ +import type { ComponentProps } from 'react'; +import React, { Suspense, lazy } from 'react'; + +const LazyWithPopover = lazy(() => + import('./WithPopover').then((mod) => ({ default: mod.WithPopover })) +); + +export const WithPopover = (props: ComponentProps) => ( + }> + + +); diff --git a/code/core/src/components/components/Select/Select.tsx b/code/core/src/components/components/Select/Select.tsx index 954442003826..c032fd7241ab 100644 --- a/code/core/src/components/components/Select/Select.tsx +++ b/code/core/src/components/components/Select/Select.tsx @@ -2,7 +2,7 @@ import type { FC, KeyboardEvent } from 'react'; import React, { forwardRef, useCallback, useEffect, useId, useMemo, useRef, useState } from 'react'; import type { ButtonProps } from 'storybook/internal/components'; -import { Button, ScrollArea } from 'storybook/internal/components'; +import { Button, Popover, ScrollArea } from 'storybook/internal/components'; import { RefreshIcon } from '@storybook/icons'; @@ -12,7 +12,6 @@ import { Overlay, useInteractOutside, useOverlay, useOverlayPosition } from 'rea import { useOverlayTriggerState } from 'react-stately'; import { styled } from 'storybook/theming'; -import { Tooltip } from '../tooltip/Tooltip'; import { SelectOption } from './SelectOption'; import type { Option, ResetOption } from './helpers'; import { Listbox, PAGE_STEP_SIZE } from './helpers'; @@ -162,9 +161,9 @@ const MinimalistPopover: FC<{ return ( - + {children} - + ); }; diff --git a/code/core/src/components/components/shared/overlayHelpers.tsx b/code/core/src/components/components/shared/overlayHelpers.tsx new file mode 100644 index 000000000000..d5c8497afc59 --- /dev/null +++ b/code/core/src/components/components/shared/overlayHelpers.tsx @@ -0,0 +1,65 @@ +import type { Decorator } from '@storybook/react'; + +import type { PositionProps } from '@react-types/overlays'; +import memoize from 'memoizerific'; +import { styled } from 'storybook/theming'; + +type BasicPlacement = 'top' | 'bottom' | 'left' | 'right'; + +type PlacementWithModifier = + | 'top-start' + | 'top-end' + | 'bottom-start' + | 'bottom-end' + | 'left-start' + | 'left-end' + | 'right-start' + | 'right-end'; + +export type PopperPlacement = BasicPlacement | PlacementWithModifier; + +export const convertToReactAriaPlacement = memoize(1000)(( + p: PopperPlacement +): NonNullable => { + if (p === 'left-end') { + return 'left bottom'; + } + + if (p === 'right-end') { + return 'right bottom'; + } + + if (p === 'left-start') { + return 'left top'; + } + + if (p === 'right-start') { + return 'right top'; + } + + return p.replace('-', ' ') as NonNullable; +}); + +// Story helper +const Container = styled.div({ + width: 500, + height: 500, + paddingTop: 100, + overflowY: 'scroll', + background: '#eee', + position: 'relative', +}); + +// Story helper +export const Trigger = styled.button({ + width: 120, + height: 50, + margin: 10, + '&:focus-visible': { + outline: '2px solid blue', + outlineOffset: '2px', + }, +}); + +// Story helper +export const OverlayTriggerDecorator: Decorator = (storyFn) => {storyFn()}; diff --git a/code/core/src/components/components/typography/link/link.tsx b/code/core/src/components/components/typography/link/link.tsx index 665a91639536..8e4f56325020 100644 --- a/code/core/src/components/components/typography/link/link.tsx +++ b/code/core/src/components/components/typography/link/link.tsx @@ -1,5 +1,5 @@ import type { AnchorHTMLAttributes, MouseEvent } from 'react'; -import React from 'react'; +import React, { forwardRef } from 'react'; import { ChevronRightIcon } from '@storybook/icons'; @@ -185,24 +185,31 @@ export interface LinkProps extends LinkInnerProps, LinkStylesProps, AProps { href?: string; } -export const Link = ({ - cancel = true, - children, - onClick = undefined, - withArrow = false, - containsIcon = false, - className = undefined, - style = undefined, - ...rest -}: LinkProps) => ( - cancelled(e, onClick) : onClick} - className={className} - > - - {children} - {withArrow && } - - +export const Link = forwardRef( + ( + { + cancel = true, + children, + onClick = undefined, + withArrow = false, + containsIcon = false, + className = undefined, + style = undefined, + ...rest + }, + ref + ) => ( + cancelled(e, onClick) : onClick} + className={className} + > + + {children} + {withArrow && } + + + ) ); +Link.displayName = 'Link'; diff --git a/code/core/src/components/index.ts b/code/core/src/components/index.ts index 7270e8bd3a0e..30ea5852dbfb 100644 --- a/code/core/src/components/index.ts +++ b/code/core/src/components/index.ts @@ -58,6 +58,16 @@ export { Select } from './components/Select/Select'; // Forms export { Form } from './components/Form/Form'; +// Overlay helpers for popovers, menus, tooltips +export { convertToReactAriaPlacement } from './components/shared/overlayHelpers'; +export type { PopperPlacement } from './components/shared/overlayHelpers'; + +// Popovers +export { Popover } from './components/Popover/Popover'; +export type { PopoverProps } from './components/Popover/Popover'; +export { WithPopover } from './components/Popover/lazy-WithPopover'; +export type { WithPopoverProps } from './components/Popover/WithPopover'; + // Tooltips export { WithTooltip, WithTooltipPure } from './components/tooltip/lazy-WithTooltip'; export { TooltipMessage } from './components/tooltip/TooltipMessage'; diff --git a/code/core/src/manager/components/layout/LayoutProvider.tsx b/code/core/src/manager/components/layout/LayoutProvider.tsx index 204935bd6ba6..78d5e5db053a 100644 --- a/code/core/src/manager/components/layout/LayoutProvider.tsx +++ b/code/core/src/manager/components/layout/LayoutProvider.tsx @@ -26,11 +26,15 @@ const LayoutContext = createContext({ isMobile: false, }); -export const LayoutProvider: FC = ({ children }) => { +export const LayoutProvider: FC< + PropsWithChildren & { + forceDesktop?: boolean; + } +> = ({ children, forceDesktop }) => { const [isMobileMenuOpen, setMobileMenuOpen] = useState(false); const [isMobileAboutOpen, setMobileAboutOpen] = useState(false); const [isMobilePanelOpen, setMobilePanelOpen] = useState(false); - const isDesktop = useMediaQuery(`(min-width: ${BREAKPOINT}px)`); + const isDesktop = forceDesktop ?? useMediaQuery(`(min-width: ${BREAKPOINT}px)`); const isMobile = !isDesktop; const contextValue = useMemo( diff --git a/code/core/src/manager/components/sidebar/ContextMenu.tsx b/code/core/src/manager/components/sidebar/ContextMenu.tsx index 12981333e5d4..fa7af520750a 100644 --- a/code/core/src/manager/components/sidebar/ContextMenu.tsx +++ b/code/core/src/manager/components/sidebar/ContextMenu.tsx @@ -1,7 +1,7 @@ import type { ComponentProps, FC, SyntheticEvent } from 'react'; import React, { useMemo, useState } from 'react'; -import { TooltipLinkList, WithTooltip } from 'storybook/internal/components'; +import { TooltipLinkList, WithPopover } from 'storybook/internal/components'; import { type API_HashEntry, type Addon_Collection, @@ -26,12 +26,6 @@ const empty = { node: null, }; -const PositionedWithTooltip = styled(WithTooltip)({ - position: 'absolute', - right: 0, - zIndex: 1, -}); - const FloatingStatusButton = styled(StatusButton)({ background: 'var(--tree-node-background-hover)', boxShadow: '0 0 5px 5px var(--tree-node-background-hover)', @@ -127,28 +121,26 @@ export const useContextMenu = (context: API_HashEntry, links: Link[], api: API) return { onMouseEnter: handlers.onMouseEnter, node: shouldRender ? ( - { - if (!visible) { - handlers.onClose(); - } else { - setIsOpen(true); - } - }} - tooltip={} + defaultVisible={false} + visible={isOpen} + onVisibleChange={setIsOpen} + popover={} + hasChrome={false} + padding={0} > - + ) : null, }; }, [context, handlers, isOpen, shouldRender, links, topLinks]); diff --git a/code/core/src/manager/components/sidebar/Menu.tsx b/code/core/src/manager/components/sidebar/Menu.tsx index effda7836a3b..f4e6ed2cf585 100644 --- a/code/core/src/manager/components/sidebar/Menu.tsx +++ b/code/core/src/manager/components/sidebar/Menu.tsx @@ -1,7 +1,7 @@ import type { ComponentProps, FC } from 'react'; import React, { useState } from 'react'; -import { Button, ToggleButton, TooltipLinkList, WithTooltip } from 'storybook/internal/components'; +import { Button, ToggleButton, TooltipLinkList, WithPopover } from 'storybook/internal/components'; import { CloseIcon, CogIcon } from '@storybook/icons'; @@ -124,10 +124,10 @@ export const SidebarMenu: FC = ({ menu, isHighlighted, onClick } return ( - } + } onVisibleChange={setIsTooltipVisible} > = ({ menu, isHighlighted, onClick > - + ); }; diff --git a/code/core/src/manager/components/sidebar/RefBlocks.tsx b/code/core/src/manager/components/sidebar/RefBlocks.tsx index 1e5774dbfb0e..b4c8daf6fe7e 100644 --- a/code/core/src/manager/components/sidebar/RefBlocks.tsx +++ b/code/core/src/manager/components/sidebar/RefBlocks.tsx @@ -2,13 +2,14 @@ import type { FC } from 'react'; import React, { Fragment, useCallback, useState } from 'react'; import { logger } from 'storybook/internal/client-logger'; -import { Button, ErrorFormatter, Link, Spaced, WithTooltip } from 'storybook/internal/components'; +import { Button, ErrorFormatter, Link, Spaced, WithPopover } from 'storybook/internal/components'; import { global } from '@storybook/global'; import { ChevronDownIcon, LockIcon, SyncIcon } from '@storybook/icons'; import { styled } from 'storybook/theming'; +import { useLayout } from '../layout/LayoutProvider'; import { Contained, Loader } from './Loader'; const { window: globalWindow } = global; @@ -34,14 +35,22 @@ const Text = styled.div(({ theme }) => ({ }, })); -const ErrorDisplay = styled.pre( +const ErrorDisplay = styled.pre<{ isMobile: boolean }>( { - width: 420, boxSizing: 'border-box', borderRadius: 8, overflow: 'auto', whiteSpace: 'pre', }, + ({ isMobile }) => + isMobile + ? { + maxWidth: 'calc(100vw - 40px)', + } + : { + minWidth: 420, + maxWidth: 640, + }, ({ theme }) => ({ color: theme.color.dark, }) @@ -103,30 +112,41 @@ export const AuthBlock: FC<{ loginUrl: string; id: string }> = ({ loginUrl, id } ); }; -export const ErrorBlock: FC<{ error: Error }> = ({ error }) => ( - - - - Oh no! Something went wrong loading this Storybook. -
- - - - } - > - - View error +export const ErrorBlock: FC<{ error: Error }> = ({ error }) => { + const { isMobile } = useLayout(); + return ( + + + + Oh no! Something went wrong loading this Storybook. +
+ + + + } + > + + View error + + {' '} + + View docs -
{' '} - - View docs - -
-
-
-); + + + + ); +}; const FlexSpaced = styled(Spaced)({ display: 'flex', diff --git a/code/core/src/manager/components/sidebar/RefIndicator.tsx b/code/core/src/manager/components/sidebar/RefIndicator.tsx index 00b25c732337..9cfb56628716 100644 --- a/code/core/src/manager/components/sidebar/RefIndicator.tsx +++ b/code/core/src/manager/components/sidebar/RefIndicator.tsx @@ -1,13 +1,11 @@ -import type { FC, MouseEventHandler } from 'react'; +import type { FC, MouseEventHandler, ReactNode } from 'react'; import React, { forwardRef, useCallback, useMemo } from 'react'; -import { Spaced, TooltipLinkList, WithTooltip } from 'storybook/internal/components'; +import { Button, Select, Spaced, WithPopover } from 'storybook/internal/components'; import { global } from '@storybook/global'; import { AlertIcon, - CheckIcon, - ChevronDownIcon, DocumentIcon, GlobeIcon, LightningIcon, @@ -21,6 +19,7 @@ import { styled, useTheme } from 'storybook/theming'; import type { NormalLink } from '../../../components/components/tooltip/TooltipLinkList'; import type { getStateType } from '../../utils/tree'; +import { useLayout } from '../layout/LayoutProvider'; import type { RefType } from './types'; const { document, window: globalWindow } = global; @@ -45,17 +44,7 @@ const IndicatorPlacement = styled.aside(({ theme }) => ({ }, })); -const IndicatorClickTarget = styled.button(({ theme }) => ({ - height: 20, - width: 20, - padding: 0, - margin: 0, - display: 'flex', - alignItems: 'center', - justifyContent: 'center', - background: 'transparent', - outline: 'none', - border: '1px solid transparent', +const IndicatorClickTarget = styled(Button)(({ theme }) => ({ borderRadius: '100%', cursor: 'pointer', color: theme.textMutedColor, @@ -79,7 +68,7 @@ const MessageTitle = styled.span(({ theme }) => ({ fontWeight: theme.typography.weight.bold, })); -const Message = styled.a(({ theme }) => ({ +const StyledMessage = styled.a(({ theme }) => ({ textDecoration: 'none', lineHeight: '16px', padding: 15, @@ -98,6 +87,12 @@ const Message = styled.a(({ theme }) => ({ '&:link, &:active, &:focus': { color: theme.color.defaultText, }, + '&:focus-visible': { + background: theme.background.hoverable, + borderRadius: 8, + boxShadow: `inset 0 0 0 2px ${theme.color.secondary}`, + outline: 'none', + }, '& > *': { flex: 1, }, @@ -110,49 +105,48 @@ const Message = styled.a(({ theme }) => ({ }, })); -export const MessageWrapper = styled.div({ - width: 280, - boxSizing: 'border-box', - borderRadius: 8, - overflow: 'hidden', -}); +const Message: FC<{ + blank?: boolean; + children: ReactNode; + href: string; +}> = ({ href, blank = true, children }) => { + return ( + + {children} + + ); +}; -const Version = styled.div(({ theme }) => ({ - display: 'flex', - alignItems: 'center', +export const MessageWrapper = styled.div<{ + isMobile: boolean; +}>( + ({ isMobile }) => ({ + width: isMobile ? 'calc(100vw - 20px)' : 280, + boxSizing: 'border-box', + borderRadius: 8, + overflow: 'hidden', + }), + ({ theme }) => ({ + color: theme.color.dark, + }) +); + +const SubtleSelect = styled(Select)(({ theme }) => ({ + background: 'transparent', + color: theme.color.defaultText, fontSize: theme.typography.size.s1, fontWeight: theme.typography.weight.regular, - color: theme.color.defaultText, - - '& > * + *': { - marginLeft: 4, - }, - - svg: { - height: 10, - width: 10, + '&:hover': { + background: 'transparent', + color: theme.color.defaultText, }, })); -const CurrentVersion: FC = ({ url, versions }) => { - const currentVersionId = useMemo(() => { - // @ts-expect-error (non strict) - const c = Object.entries(versions).find(([k, v]) => v === url); - return c && c[0] ? c[0] : 'current'; - }, [url, versions]); - - return ( - - {currentVersionId} - - - ); -}; - export const RefIndicator = React.memo( forwardRef }>( ({ state, ...ref }, forwardedRef) => { const api = useStorybookApi(); + const { isMobile } = useLayout(); const list = useMemo(() => Object.values(ref.index || {}), [ref.index]); const componentCount = useMemo( () => list.filter((v) => v.type === 'component').length, @@ -163,14 +157,20 @@ export const RefIndicator = React.memo( [list] ); + const currentVersion = useMemo(() => { + if (ref.versions) { + return Object.entries(ref.versions).find(([, v]) => v === ref.url)?.[0]; + } + return undefined; + }, [ref.versions, ref.url]); + return ( - + ( + {state === 'loading' && } {(state === 'error' || state === 'empty') && ( @@ -189,38 +189,42 @@ export const RefIndicator = React.memo( {state !== 'loading' && } - } + )} > - + - + {ref.versions && Object.keys(ref.versions).length ? ( - ( - ({ - icon: href === ref.url ? : undefined, - id, - title: id, - href, - onClick: (event, item) => { - event.preventDefault(); - // @ts-expect-error (non strict) - api.changeRefVersion(ref.id, item.href); - tooltip.onHide(); - }, - }))} - /> - )} - > - - + <> + { + const href = ref.versions?.[item]; + if (href) { + api.changeRefVersion(ref.id, href); + } + }} + options={Object.entries(ref.versions).map(([id, href]) => ({ + value: id, + title: id, + href, + }))} + > + version + + ) : null} ); @@ -236,7 +240,7 @@ const ReadyMessage: FC<{ const theme = useTheme(); return ( - +
View external Storybook @@ -254,7 +258,7 @@ const SourceCodeMessage: FC<{ const theme = useTheme(); return ( - +
View source code @@ -265,23 +269,30 @@ const SourceCodeMessage: FC<{ const LoginRequiredMessage: FC = ({ loginUrl, id }) => { const theme = useTheme(); - const open = useCallback((e) => { - e.preventDefault(); - const childWindow = globalWindow.open(loginUrl, `storybook_auth_${id}`, 'resizable,scrollbars'); - - // poll for window to close - const timer = setInterval(() => { - if (!childWindow) { - clearInterval(timer); - } else if (childWindow.closed) { - clearInterval(timer); - document.location.reload(); - } - }, 1000); - }, []); + const open = useCallback( + (e) => { + e.preventDefault(); + const childWindow = globalWindow.open( + loginUrl, + `storybook_auth_${id}`, + 'resizable,scrollbars' + ); + + // poll for window to close + const timer = setInterval(() => { + if (!childWindow) { + clearInterval(timer); + } else if (childWindow.closed) { + clearInterval(timer); + document.location.reload(); + } + }, 1000); + }, + [id, loginUrl] + ); return ( - +
Log in required @@ -295,11 +306,8 @@ const ReadDocsMessage: FC = () => { const theme = useTheme(); return ( - - + +
Read Composition docs
Learn how to combine multiple Storybooks into one.
@@ -312,7 +320,7 @@ const ErrorOccurredMessage: FC<{ url: string }> = ({ url }) => { const theme = useTheme(); return ( - +
Something went wrong @@ -326,7 +334,7 @@ const LoadingMessage: FC<{ url: string }> = ({ url }) => { const theme = useTheme(); return ( - +
Please wait @@ -341,10 +349,7 @@ const PerformanceDegradedMessage: FC = () => { const theme = useTheme(); return ( - +
Reduce lag diff --git a/code/core/src/manager/components/sidebar/Refs.stories.tsx b/code/core/src/manager/components/sidebar/Refs.stories.tsx index d183e41896b8..7432f0d69fe2 100644 --- a/code/core/src/manager/components/sidebar/Refs.stories.tsx +++ b/code/core/src/manager/components/sidebar/Refs.stories.tsx @@ -1,7 +1,9 @@ import React from 'react'; +import type { StoryAnnotations } from 'storybook/internal/csf'; + import { ManagerContext } from 'storybook/manager-api'; -import { fn } from 'storybook/test'; +import { fn, within } from 'storybook/test'; import { standardData as standardHeaderData } from './Heading.stories'; import { IconSymbols } from './IconSymbols'; @@ -249,6 +251,51 @@ export const Errored = () => ( setHighlighted={() => {}} /> ); +export const ErroredMobile = () => ( + {}} + /> +); +ErroredMobile.globals = { sb_theme: 'stacked', viewport: { value: 'mobile1' } }; +export const ErroredWithErrorOpen: StoryAnnotations = { + render: () => Errored(), + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const button = canvas.getByText('View error'); + button.click(); + }, +}; +export const ErroredMobileWithErrorOpen: StoryAnnotations = { + render: () => ErroredMobile(), + globals: { sb_theme: 'stacked', viewport: { value: 'mobile1' } }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const button = canvas.getByText('View error'); + button.click(); + }, +}; +export const ErroredWithIndicatorOpen: StoryAnnotations = { + render: () => Errored(), + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const button = canvas.getByText('Extra actions'); + button.click(); + }, +}; +export const ErroredMobileWithIndicatorOpen: StoryAnnotations = { + render: () => ErroredMobile(), + globals: { sb_theme: 'stacked', viewport: { value: 'mobile1' } }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const button = canvas.getByText('Extra actions'); + button.click(); + }, +}; export const Auth = () => ( ( + (storyFn, { globals, title }) => ( - + {storyFn()} @@ -108,6 +114,21 @@ export default meta; type Story = StoryObj; +const mobileLayoutDecorator: DecoratorFunction = (storyFn, { globals, title }) => ( + + + + {storyFn()} + + +); + const refs: Record = { optimized: { id: 'optimized', @@ -148,6 +169,11 @@ export const SimpleInProduction: Story = { }, }; +export const Mobile: Story = { + decorators: [mobileLayoutDecorator], + globals: { sb_theme: 'light', viewport: { value: 'mobile1' } }, +}; + export const Loading: Story = { args: { previewInitialized: false, @@ -155,12 +181,24 @@ export const Loading: Story = { }, }; +export const LoadingMobile: Story = { + args: Loading.args, + decorators: [mobileLayoutDecorator], + globals: { sb_theme: 'light', viewport: { value: 'mobile1' } }, +}; + export const Empty: Story = { args: { index: {}, }, }; +export const EmptyMobile: Story = { + args: Empty.args, + decorators: [mobileLayoutDecorator], + globals: { sb_theme: 'light', viewport: { value: 'mobile1' } }, +}; + export const IndexError: Story = { args: { indexError, @@ -209,6 +247,12 @@ export const WithRefsNarrow: Story = { }, }; +export const WithRefsMobile: Story = { + args: WithRefs.args, + decorators: [mobileLayoutDecorator], + globals: { sb_theme: 'light', viewport: { value: 'mobile1' } }, +}; + export const LoadingWithRefs: Story = { args: { ...Loading.args, @@ -223,6 +267,12 @@ export const LoadingWithRefError: Story = { }, }; +export const LoadingWithRefErrorMobile: Story = { + args: LoadingWithRefError.args, + decorators: [mobileLayoutDecorator], + globals: { sb_theme: 'light', viewport: { value: 'mobile1' } }, +}; + export const WithRefEmpty: Story = { args: { ...Empty.args, diff --git a/code/core/src/manager/components/sidebar/StatusButton.tsx b/code/core/src/manager/components/sidebar/StatusButton.tsx index a0c9573bcaa6..e2b1079cc632 100644 --- a/code/core/src/manager/components/sidebar/StatusButton.tsx +++ b/code/core/src/manager/components/sidebar/StatusButton.tsx @@ -1,5 +1,5 @@ -import type { ComponentProps, FC } from 'react'; -import React from 'react'; +import type { ComponentProps } from 'react'; +import React, { forwardRef } from 'react'; import { Button } from 'storybook/internal/components'; import type { StatusValue } from 'storybook/internal/types'; @@ -88,6 +88,7 @@ const StyledButton = styled(Button)<{ } ); -export const StatusButton: FC = (props) => { - return ; -}; +export const StatusButton = forwardRef((props, ref) => { + return ; +}); +StatusButton.displayName = 'StatusButton'; diff --git a/code/core/src/manager/components/sidebar/TagsFilter.tsx b/code/core/src/manager/components/sidebar/TagsFilter.tsx index 9f31381dabdd..4a49fd414d75 100644 --- a/code/core/src/manager/components/sidebar/TagsFilter.tsx +++ b/code/core/src/manager/components/sidebar/TagsFilter.tsx @@ -1,6 +1,6 @@ -import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import React, { useCallback, useEffect, useState } from 'react'; -import { Badge, Button, WithTooltip } from 'storybook/internal/components'; +import { Badge, Button, WithPopover } from 'storybook/internal/components'; import type { StoryIndex, Tag } from 'storybook/internal/types'; import { FilterIcon } from '@storybook/icons'; @@ -117,42 +117,35 @@ export const TagsFilter = ({ } return ( - <> - ( - - )} - closeOnOutsideClick + ( + + )} + > + - - - - - {selectedTags.length > 0 && } - - - + + {selectedTags.length > 0 && } + + ); }; diff --git a/code/core/src/manager/globals/exports.ts b/code/core/src/manager/globals/exports.ts index 702702080175..7219a551356c 100644 --- a/code/core/src/manager/globals/exports.ts +++ b/code/core/src/manager/globals/exports.ts @@ -536,6 +536,7 @@ export default { 'TooltipMessage', 'TooltipNote', 'UL', + 'WithPopover', 'WithTooltip', 'WithTooltipPure', 'Zoom', From 0a2153f577a31ff5719cfe1b69f7dcd1bd7ae124 Mon Sep 17 00:00:00 2001 From: Steve Dodier-Lazaro Date: Wed, 17 Sep 2025 18:40:29 +0200 Subject: [PATCH 02/15] fix: Use safer type annotation for overlay helpers --- .../src/components/components/shared/overlayHelpers.tsx | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/code/core/src/components/components/shared/overlayHelpers.tsx b/code/core/src/components/components/shared/overlayHelpers.tsx index d5c8497afc59..cb1864881cc6 100644 --- a/code/core/src/components/components/shared/overlayHelpers.tsx +++ b/code/core/src/components/components/shared/overlayHelpers.tsx @@ -1,4 +1,6 @@ -import type { Decorator } from '@storybook/react'; +import React from 'react'; + +import type { DecoratorFunction } from 'storybook/internal/csf'; import type { PositionProps } from '@react-types/overlays'; import memoize from 'memoizerific'; @@ -62,4 +64,6 @@ export const Trigger = styled.button({ }); // Story helper -export const OverlayTriggerDecorator: Decorator = (storyFn) => {storyFn()}; +export const OverlayTriggerDecorator: DecoratorFunction = (storyFn) => ( + {storyFn()} +); From 94e3877f07a03d5c4becbc3a7f6409d473d95157 Mon Sep 17 00:00:00 2001 From: Steve Dodier-Lazaro Date: Wed, 17 Sep 2025 19:08:44 +0200 Subject: [PATCH 03/15] UI: Fix types in RefIndicator --- code/core/src/manager/components/sidebar/RefIndicator.tsx | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/code/core/src/manager/components/sidebar/RefIndicator.tsx b/code/core/src/manager/components/sidebar/RefIndicator.tsx index 9cfb56628716..d97dce68502b 100644 --- a/code/core/src/manager/components/sidebar/RefIndicator.tsx +++ b/code/core/src/manager/components/sidebar/RefIndicator.tsx @@ -108,10 +108,11 @@ const StyledMessage = styled.a(({ theme }) => ({ const Message: FC<{ blank?: boolean; children: ReactNode; - href: string; -}> = ({ href, blank = true, children }) => { + href?: string; + onClick?: MouseEventHandler; +}> = ({ href, blank = true, children, onClick }) => { return ( - + {children} ); From 1af60510aa702f30e8756cf4652b82829d6712cc Mon Sep 17 00:00:00 2001 From: Steve Dodier-Lazaro Date: Wed, 17 Sep 2025 22:29:13 +0200 Subject: [PATCH 04/15] Core: Add new exports --- code/core/src/manager/globals/exports.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/code/core/src/manager/globals/exports.ts b/code/core/src/manager/globals/exports.ts index 7219a551356c..e2b3dce7489a 100644 --- a/code/core/src/manager/globals/exports.ts +++ b/code/core/src/manager/globals/exports.ts @@ -513,6 +513,7 @@ export default { 'OL', 'P', 'Placeholder', + 'Popover', 'Pre', 'ProgressSpinner', 'ResetWrapper', @@ -542,6 +543,7 @@ export default { 'Zoom', 'codeCommon', 'components', + 'convertToReactAriaPlacement', 'createCopyToClipboardFunction', 'getStoryHref', 'interleaveSeparators', From e7922ef0134d498d38cc17dd635a66e25cf89ec2 Mon Sep 17 00:00:00 2001 From: Steve Dodier-Lazaro Date: Wed, 17 Sep 2025 22:29:40 +0200 Subject: [PATCH 05/15] UI: Fix padding regression in TagsFilter --- code/core/src/manager/components/sidebar/TagsFilter.tsx | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/code/core/src/manager/components/sidebar/TagsFilter.tsx b/code/core/src/manager/components/sidebar/TagsFilter.tsx index 4a49fd414d75..352ec588846f 100644 --- a/code/core/src/manager/components/sidebar/TagsFilter.tsx +++ b/code/core/src/manager/components/sidebar/TagsFilter.tsx @@ -14,10 +14,6 @@ const TAGS_FILTER = 'tags-filter'; const BUILT_IN_TAGS_HIDE = new Set(['dev', 'autodocs', 'test', 'attached-mdx', 'unattached-mdx']); -const Wrapper = styled.div({ - position: 'relative', -}); - // Temporary to prevent regressions until TagFilterPanel can be refactored. const StyledIconButton = styled(Button)<{ active: boolean }>(({ active, theme }) => ({ ...(active && { @@ -121,6 +117,7 @@ export const TagsFilter = ({ placement="bottom" onVisibleChange={setExpanded} offset={8} + padding={0} popover={() => ( Date: Sat, 20 Sep 2025 13:28:29 +0200 Subject: [PATCH 06/15] refactor: Remove unused handler param --- code/core/src/manager/components/sidebar/RefIndicator.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/code/core/src/manager/components/sidebar/RefIndicator.tsx b/code/core/src/manager/components/sidebar/RefIndicator.tsx index d97dce68502b..afac15a944c0 100644 --- a/code/core/src/manager/components/sidebar/RefIndicator.tsx +++ b/code/core/src/manager/components/sidebar/RefIndicator.tsx @@ -170,7 +170,7 @@ export const RefIndicator = React.memo( ( + popover={() => ( {state === 'loading' && } From 69264e3b8a43b15e28cae8d8f06f44d5ea8c61a6 Mon Sep 17 00:00:00 2001 From: Steve Dodier-Lazaro Date: Sat, 20 Sep 2025 20:56:43 +0200 Subject: [PATCH 07/15] UI: Port Modal from radix to react-aria --- MIGRATION.md | 25 + .../features/IntentSurvey/IntentSurvey.tsx | 11 +- .../src/components/GlobalErrorModal.tsx | 9 +- code/core/package.json | 4 +- .../components/Modal/Modal.stories.tsx | 680 +++++++++++++++--- .../components/Modal/Modal.styled.tsx | 161 ++++- .../src/components/components/Modal/Modal.tsx | 169 ++++- code/core/src/components/index.ts | 2 +- .../controls/components/SaveStory.stories.tsx | 18 +- .../src/controls/components/SaveStory.tsx | 19 +- .../components/mobile/about/MobileAbout.tsx | 195 ++--- .../mobile/navigation/MobileAddonsDrawer.tsx | 84 +-- .../mobile/navigation/MobileMenuDrawer.tsx | 219 +----- .../mobile/navigation/MobileNavigation.tsx | 14 +- .../sidebar/FileSearchModal.stories.tsx | 25 +- .../components/sidebar/FileSearchModal.tsx | 10 +- code/core/src/manager/globals/exports.ts | 1 + code/e2e-tests/addon-controls.spec.ts | 3 + code/e2e-tests/manager.spec.ts | 27 +- code/e2e-tests/tags.spec.ts | 18 +- code/e2e-tests/util.ts | 10 + code/yarn.lock | 404 +---------- 22 files changed, 1098 insertions(+), 1010 deletions(-) diff --git a/MIGRATION.md b/MIGRATION.md index 705cc291e59a..e56c3984b3ea 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -15,6 +15,12 @@ - [Added: tooltip](#added-tooltip) - [Removed: active](#removed-active) - [IconButton is deprecated](#iconbutton-is-deprecated) + - [Modal Component API Changes](#modal-component-api-changes) + - [Removed: container and portalSelector](#removed-container-and-portalselector) + - [Removed: onInteractOutside](#removed-oninteractoutside) + - [Removed: onEscapeKeyDown](#removed-onescapekeydown) + - [Added: `ariaLabel`](#added-arialabel-1) + - [Renamed: Modal.Dialog.Close and Modal.CloseButton](#renamed-modaldialogclose-and-modalclosebutton) - [From version 8.x to 9.0.0](#from-version-8x-to-900) - [Core Changes and Removals](#core-changes-and-removals) - [Dropped support for legacy packages](#dropped-support-for-legacy-packages) @@ -625,6 +631,25 @@ The IconButton component is deprecated, as it overlaps with Button. Instead, use IconButton will be removed in future versions. +#### Modal Component API Changes + +##### Removed: container and portalSelector +The `container` and `portalSelector` props were not used inside Storybook, so they have been removed. The new Modal component does not support custom portal locations, because it is not recommended practice. A single portal at the end of the document ensures modals appear in their order of creation and are never cropped by CSS `overflow` properties. + +##### Removed: onInteractOutside +The `onInteractOutside` prop is removed in favor of `dismissOnClickOutside`, because it was only used to close the modal when clicking outside. Use `dismissOnClickOutside` to control whether clicking outside the modal should close it or not. + +##### Removed: onEscapeKeyDown +The `onEscapeKeyDown` prop is removed in favor of `dismissOnEscape`, because it was only used to close the modal when pressing Escape. Use `dismissOnEscape` to control whether pressing Escape should close it or not. + +##### Added: `ariaLabel` +Modal elements must have a title to be accessible. Set that title through the mandatory `ariaLabel` prop. + +##### Renamed: Modal.Dialog.Close and Modal.CloseButton +The `Modal.Dialog.Close` component and `Modal.CloseButton` components are replaced by `Modal.Close` for consistency with other components. You may call `` for a default close button, or `...` to wrap your own custom button. + +The `Modal.Close` component no longer requires an `onClick` handler to close the modal. It will automatically close the modal when clicked. If you need to perform additional actions when the close button is clicked, you can still provide an `onClick` handler, and it will be called in addition to closing the modal. + ## From version 8.x to 9.0.0 ### Core Changes and Removals diff --git a/code/addons/onboarding/src/features/IntentSurvey/IntentSurvey.tsx b/code/addons/onboarding/src/features/IntentSurvey/IntentSurvey.tsx index 4631608c4693..fc521153a7c2 100644 --- a/code/addons/onboarding/src/features/IntentSurvey/IntentSurvey.tsx +++ b/code/addons/onboarding/src/features/IntentSurvey/IntentSurvey.tsx @@ -170,7 +170,16 @@ export const IntentSurvey = ({ }; return ( - + { + if (!isOpen) { + onDismiss(); + } + }} + >
diff --git a/code/addons/vitest/src/components/GlobalErrorModal.tsx b/code/addons/vitest/src/components/GlobalErrorModal.tsx index 110d96978fb8..f92ee795a987 100644 --- a/code/addons/vitest/src/components/GlobalErrorModal.tsx +++ b/code/addons/vitest/src/components/GlobalErrorModal.tsx @@ -2,7 +2,7 @@ import React, { useContext } from 'react'; import { Button, Modal } from 'storybook/internal/components'; -import { CloseIcon, SyncIcon } from '@storybook/icons'; +import { SyncIcon } from '@storybook/icons'; import { useStorybookApi } from 'storybook/manager-api'; import { styled } from 'storybook/theming'; @@ -77,7 +77,6 @@ function ErrorCause({ error }: { error: ErrorLike }) { export function GlobalErrorModal({ onRerun, storeState }: GlobalErrorModalProps) { const api = useStorybookApi(); const { isModalOpen, setModalOpen } = useContext(GlobalErrorContext); - const handleClose = () => setModalOpen?.(false); const troubleshootURL = api.getDocsUrl({ subpath: DOCUMENTATION_FATAL_ERROR_LINK, @@ -148,7 +147,7 @@ export function GlobalErrorModal({ onRerun, storeState }: GlobalErrorModalProps) ) : null; return ( - + Storybook Tests error details @@ -161,9 +160,7 @@ export function GlobalErrorModal({ onRerun, storeState }: GlobalErrorModalProps) Troubleshoot - + diff --git a/code/core/package.json b/code/core/package.json index 021f642b2ab6..1f2aad34e76c 100644 --- a/code/core/package.json +++ b/code/core/package.json @@ -258,7 +258,6 @@ "@ndelangen/get-tarball": "^3.0.7", "@ngard/tiny-isequal": "^1.1.0", "@polka/compression": "^1.0.0-next.28", - "@radix-ui/react-dialog": "^1.1.2", "@radix-ui/react-scroll-area": "1.2.0-rc.7", "@radix-ui/react-slot": "^1.0.2", "@react-aria/utils": "^3.30.1", @@ -279,7 +278,6 @@ "@types/pretty-hrtime": "^1.0.0", "@types/prompts": "^2.0.9", "@types/react-syntax-highlighter": "11.0.5", - "@types/react-transition-group": "^4", "@types/semver": "^7.5.8", "@types/ws": "^8", "@vitest/utils": "^3.2.4", @@ -348,7 +346,7 @@ "react-stately": "^3.41.0", "react-syntax-highlighter": "^15.4.5", "react-textarea-autosize": "^8.3.0", - "react-transition-group": "^4.4.5", + "react-transition-state": "^2.3.1", "require-from-string": "^2.0.2", "resolve.exports": "^2.0.3", "sirv": "^2.0.4", diff --git a/code/core/src/components/components/Modal/Modal.stories.tsx b/code/core/src/components/components/Modal/Modal.stories.tsx index 6ab3e4fa34f2..ea819d3ffcce 100644 --- a/code/core/src/components/components/Modal/Modal.stories.tsx +++ b/code/core/src/components/components/Modal/Modal.stories.tsx @@ -1,192 +1,658 @@ import React, { useState } from 'react'; -import type { Meta, StoryObj } from '@storybook/react-vite'; +import { Button } from 'storybook/internal/components'; -import { expect, userEvent, within } from 'storybook/test'; +import { action } from 'storybook/actions'; +import { expect, fn, screen, userEvent, within } from 'storybook/test'; -import { Button } from '../Button/Button'; +import preview from '../../../../../.storybook/preview'; import { Modal } from './Modal'; -type Story = StoryObj; +const SampleModalContent = () => ( + + + Sample Modal + + Lorem ipsum dolor sit amet, consectetur adipiscing elit. + + + +

This is a sample modal with various content sections.

+

You can interact with the elements below:

+ + + +
+ + + + + + + + +
+); -const meta = { +const meta = preview.meta({ + id: 'overlay-Modal', + title: 'Overlay/Modal', component: Modal, + args: { + ariaLabel: 'Sample modal', + dismissOnClickOutside: true, + dismissOnEscape: true, + }, + argTypes: { + width: { + control: { type: 'number', min: 200, max: 1200, step: 50 }, + description: 'Fixed width for the modal in pixels', + }, + height: { + control: { type: 'number', min: 200, max: 800, step: 50 }, + description: 'Fixed height for the modal in pixels', + }, + ariaLabel: { + control: 'text', + description: 'The accessible name for the modal', + }, + dismissOnClickOutside: { + control: 'boolean', + description: 'Whether the modal can be dismissed by clicking outside', + }, + dismissOnEscape: { + control: 'boolean', + description: 'Whether the modal can be dismissed by pressing Escape', + }, + open: { + control: 'boolean', + description: 'Controlled state for modal visibility', + }, + defaultOpen: { + control: 'boolean', + description: 'Default open state for uncontrolled usage', + }, + onOpenChange: { + action: 'onOpenChange', + description: 'Callback when modal open state changes', + }, + }, decorators: [ (storyFn) => (
{storyFn()}
), ], -} satisfies Meta; - -export default meta; +}); -export const Default: Story = { +export const Base = meta.story({ args: { - children: undefined, - width: undefined, - height: undefined, + children: , }, - render: (props) => { + render: (args) => { const [isOpen, setOpen] = useState(false); return ( <> - -
-
Hello world!
- setOpen(false)}>Close -
-
- + + ); }, - play: async ({ canvasElement }) => { - const canvas = within(canvasElement.parentElement!); - const button = canvas.getByText('Open modal'); - await userEvent.click(button); - await expect(canvas.findByText('Hello world!')).resolves.toBeInTheDocument(); - }, -}; +}); -export const FixedWidth: Story = { +export const FixedWidth = meta.story({ args: { - ...Default.args, - width: 1024, + width: 300, + children: , }, - render: (props) => { + render: (args) => { const [isOpen, setOpen] = useState(false); return ( <> - -
-
Hello world!
- setOpen(false)}>Close -
-
- + + ); }, - play: async ({ canvasElement }) => { - const canvas = within(canvasElement.parentElement!); - const button = canvas.getByText('Open modal'); - await userEvent.click(button); - await expect(canvas.findByText('Hello world!')).resolves.toBeInTheDocument(); - }, -}; +}); -export const FixedHeight: Story = { +export const FixedHeight = meta.story({ args: { - ...Default.args, - height: 430, + height: 300, + children: , }, - render: (props) => { + render: (args) => { const [isOpen, setOpen] = useState(false); return ( <> - -
-
Hello world!
- setOpen(false)}>Close -
-
- + + ); }, - play: async ({ canvasElement }) => { - const canvas = within(canvasElement.parentElement!); - const button = canvas.getByText('Open modal'); - await userEvent.click(button); - await expect(canvas.findByText('Hello world!')).resolves.toBeInTheDocument(); - }, -}; +}); -export const FixedWidthAndHeight: Story = { +export const FixedDimensions = meta.story({ args: { - ...Default.args, - width: 1024, - height: 430, + width: 400, + height: 400, + children: , }, - render: (props) => { + render: (args) => { const [isOpen, setOpen] = useState(false); return ( <> - -
-
Hello world!
- setOpen(false)}>Close -
-
- + + ); }, - play: async ({ canvasElement }) => { - const canvas = within(canvasElement.parentElement!); - const button = canvas.getByText('Open modal'); - await userEvent.click(button); - await expect(canvas.findByText('Hello world!')).resolves.toBeInTheDocument(); +}); + +export const DismissalBehavior = meta.story({ + args: { + children: , }, + render: (args) => ( +
+
+

Default (dismissible)

+ +
+
+

No outside click dismissal

+ +
+
+

No escape dismissal

+ +
+
+

No dismissal

+ +
+
+ ), +}); + +const ModalWithTrigger = ({ + triggerText, + ...modalProps +}: { triggerText: string } & React.ComponentProps) => { + const [isOpen, setOpen] = useState(false); + return ( + <> + + + + ); }; -export const StyledComponents: Story = { +export const StyledComponents = meta.story({ args: { - ...Default.args, - width: 500, + width: 600, + children: , }, - render: (props) => { + render: (args) => { const [isOpen, setOpen] = useState(false); return ( <> - + - Hello - Lorem ipsum dolor sit amet. + Styled Components Demo + + This modal demonstrates all available styled components. + - One - Two +

Left Column

+

Content in the left column

+
    +
  • Item 1
  • +
  • Item 2
  • +
  • Item 3
  • +
+
+ +

Right Column

+

Content in the right column

+

This demonstrates the Row/Col layout system.

- Right
- Another section + +

Full Width Section

+

This section spans the full width of the modal.

+
- + + + + + + + + +
+
+ + + ); + }, +}); + +export const WithError = meta.story({ + args: { + width: 500, + children: , + }, + render: (args) => { + const [isOpen, setOpen] = useState(false); + const [showError, setShowError] = useState(false); + + return ( + <> + + + + Form with Error + Try the button to see an error message. + + + + + + - - - + + + - Oops. Something went wrong. + {showError && ( + Invalid email address. Please check and try again. + )} - + ); }, - play: async ({ canvasElement }) => { - const canvas = within(canvasElement.parentElement!); - const button = canvas.getAllByText('Open modal')[0]; - await userEvent.click(button); - await expect(canvas.findByText('Hello')).resolves.toBeInTheDocument(); +}); + +export const AlwaysOpen = meta.story({ + args: { + open: true, + children: , }, -}; + render: (args) => ( + + + + Always Open Modal + This modal is always visible for demonstration. + + +

This modal cannot be closed through normal means.

+
+
+
+ ), +}); + +export const WithOpenChangeCallback = meta.story({ + args: { + children: , + onOpenChange: fn(), + }, + render: (args) => { + const [isOpen, setOpen] = useState(false); + + const handleOpenChange = (open: boolean) => { + setOpen(open); + args.onOpenChange?.(open); + }; + + return ( + <> + + + + ); + }, + play: async ({ args, canvasElement }) => { + const canvas = within(canvasElement); + const trigger = canvas.getByText('Open Modal (with callback)'); + + await userEvent.click(trigger); + await expect(args.onOpenChange).toHaveBeenCalledWith(true); + + const closeButton = await screen.findByLabelText('Close modal'); + await userEvent.click(closeButton); + await expect(args.onOpenChange).toHaveBeenCalledWith(false); + }, +}); + +export const InteractiveKeyboard = meta.story({ + args: { + children: , + }, + render: (args) => { + const [isOpen, setOpen] = useState(false); + + return ( + <> + + + + ); + }, + play: async ({ canvasElement, step }) => { + const canvas = within(canvasElement); + const trigger = canvas.getByText('Open Modal (Keyboard Test)'); + + await step('Open modal with Enter key', async () => { + trigger.focus(); + await userEvent.keyboard('{Enter}'); + await expect(screen.queryByText('Sample Modal')).toBeInTheDocument(); + }); + + await step('Navigate through modal content with Tab', async () => { + await userEvent.tab(); + const closeButton = await screen.findByRole('button', { name: 'Close modal' }); + await expect(closeButton).toHaveFocus(); + + await userEvent.tab(); + const sampleButton = await screen.findByText('Sample Button'); + await expect(sampleButton).toHaveFocus(); + + await userEvent.tab(); + const saveButton = await screen.findByText('Save'); + await expect(saveButton).toHaveFocus(); + + await userEvent.tab(); + const cancelButton = await screen.findByText('Cancel'); + await expect(cancelButton).toHaveFocus(); + + await userEvent.tab(); + await expect(closeButton).toHaveFocus(); + }); + + await step('Close modal with Escape key', async () => { + await userEvent.keyboard('{Escape}'); + await expect(screen.queryByText('Sample Modal')).not.toBeInTheDocument(); + }); + }, +}); + +export const InteractiveMouse = meta.story({ + args: { + children: , + }, + render: (args) => { + const [isOpen, setOpen] = useState(false); + + return ( +
+ + + +
+ ); + }, + play: async ({ canvasElement, step }) => { + const canvas = within(canvasElement); + + await step('Open modal', async () => { + const trigger = canvas.getByText('Open Modal (Mouse Test)'); + await userEvent.click(trigger); + await expect(screen.queryByText('Sample Modal')).toBeInTheDocument(); + }); + + await step('Click close button', async () => { + const closeButton = await screen.findByLabelText('Close modal'); + await userEvent.click(closeButton); + await expect(screen.queryByText('Sample Modal')).not.toBeInTheDocument(); + }); + + await step('Open modal and click outside to close', async () => { + const trigger = canvas.getByText('Open Modal (Mouse Test)'); + await userEvent.click(trigger); + await expect(screen.queryByText('Sample Modal')).toBeInTheDocument(); + + const outsideButton = canvas.getByText('Outside Button'); + await userEvent.click(outsideButton); + await expect(screen.queryByText('Sample Modal')).not.toBeInTheDocument(); + }); + }, +}); + +export const LongContent = meta.story({ + args: { + height: 400, + ariaLabel: 'Long content modal', + children: , + }, + render: (args) => { + const [isOpen, setOpen] = useState(false); + + return ( + <> + + + + Modal with Long Content + + This modal demonstrates scrolling behavior with extensive content. + + + +

Lorem Ipsum Content

+ {Array.from({ length: 10 }, (_, i) => ( +

+ Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor + incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud + exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute + irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla + pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui + officia deserunt mollit anim id est laborum. +

+ ))} +
+ + + + + + + + +
+
+ + + ); + }, +}); + +export const DialogTransitions = meta.story({ + args: { + variant: 'dialog', + ariaLabel: 'Dialog with transitions', + children: , + }, + render: (args) => { + const [isOpen, setOpen] = useState(false); + + return ( + <> + + + + Dialog with Smooth Transitions + + This dialog demonstrates the zoom-in/zoom-out transition animations. + + + +

Open and close this modal to see the smooth dialog transitions:

+
    +
  • Enter: Zoom-in with fade-in
  • +
  • Exit: Zoom-out with fade-out
  • +
+

The animations are centrally managed for system coherence.

+
+ + + + + + + + +
+
+ + + ); + }, +}); + +export const BottomDrawerTransitions = meta.story({ + args: { + variant: 'bottom-drawer', + ariaLabel: 'Bottom drawer with transitions', + children: , + }, + render: (args) => { + const [isOpen, setOpen] = useState(false); + + return ( + <> + + + + Bottom Drawer with Smooth Transitions + + This drawer demonstrates the slide-from-bottom/slide-to-bottom transition + animations. + + + +

Open and close this modal to see the smooth drawer transitions:

+
    +
  • Enter: Slide from bottom with fade-in
  • +
  • Exit: Slide to bottom with fade-out
  • +
+

Perfect for mobile-friendly interfaces and actions sheets.

+
+ + + + + + + + +
+
+ + + ); + }, +}); + +export default meta; diff --git a/code/core/src/components/components/Modal/Modal.styled.tsx b/code/core/src/components/components/Modal/Modal.styled.tsx index 59e0c878200f..3f66505a4dac 100644 --- a/code/core/src/components/components/Modal/Modal.styled.tsx +++ b/code/core/src/components/components/Modal/Modal.styled.tsx @@ -1,18 +1,27 @@ import type { ComponentProps } from 'react'; -import React from 'react'; +import React, { useContext } from 'react'; import { Button } from 'storybook/internal/components'; import { CrossIcon } from '@storybook/icons'; -import * as Dialog from '@radix-ui/react-dialog'; +import { Heading, Text } from 'react-aria-components'; +import type { TransitionStatus } from 'react-transition-state'; import { keyframes, styled } from 'storybook/theming'; +// Import the ModalContext from the main Modal component +import { ModalContext } from './Modal'; + const fadeIn = keyframes({ from: { opacity: 0 }, to: { opacity: 1 }, }); +const fadeOut = keyframes({ + from: { opacity: 1 }, + to: { opacity: 0 }, +}); + const expand = keyframes({ from: { maxHeight: 0 }, to: {}, @@ -29,46 +38,146 @@ const zoomIn = keyframes({ }, }); -export const Overlay = styled.div({ +const zoomOut = keyframes({ + from: { + opacity: 1, + transform: 'translate(-50%, -50%) scale(1)', + }, + to: { + opacity: 0, + transform: 'translate(-50%, -50%) scale(0.9)', + }, +}); + +const slideFromBottom = keyframes({ + from: { + opacity: 0, + transform: 'translate(0, 100%)', + }, + to: { + opacity: 1, + transform: 'translate(0, 0)', + }, +}); + +const slideToBottom = keyframes({ + from: { + opacity: 1, + transform: 'translate(0, 0)', + }, + to: { + opacity: 0, + transform: 'translate(0, 100%)', + }, +}); + +export const Overlay = styled.div<{ + $status?: TransitionStatus; + $transitionDuration?: number; +}>(({ $status, $transitionDuration }) => ({ backdropFilter: 'blur(24px)', position: 'fixed', inset: 0, width: '100%', height: '100%', - zIndex: 10, - animation: `${fadeIn} 200ms`, -}); + zIndex: 90, + animation: + $status === 'exiting' + ? `${fadeOut} ${$transitionDuration}ms` + : `${fadeIn} ${$transitionDuration}ms`, +})); -export const Container = styled.div<{ width?: number; height?: number }>( - ({ theme, width, height }) => ({ +export const Container = styled.div<{ + $variant: 'dialog' | 'bottom-drawer'; + $status?: TransitionStatus; + $transitionDuration?: number; + width?: number | string; + height?: number | string; +}>( + ({ theme }) => ({ backgroundColor: theme.background.bar, borderRadius: 6, boxShadow: '0px 4px 67px 0px #00000040', position: 'fixed', - top: '50%', - left: '50%', - transform: 'translate(-50%, -50%)', - width: width ?? 740, - height: height ?? 'auto', - maxWidth: 'calc(100% - 40px)', - maxHeight: '85vh', overflow: 'auto', - zIndex: 11, - animation: `${zoomIn} 200ms`, + zIndex: 100, '&:focus-visible': { outline: 'none', }, - }) + }), + ({ width, height, $variant, $status, $transitionDuration }) => + $variant === 'dialog' + ? { + top: '50%', + left: '50%', + transform: 'translate(-50%, -50%)', + width: width ?? 740, + height: height ?? 'auto', + maxWidth: 'calc(100% - 40px)', + maxHeight: '85vh', + animation: + $status === 'exiting' + ? `${zoomOut} ${$transitionDuration}ms` + : `${zoomIn} ${$transitionDuration}ms`, + } + : { + bottom: '0', + left: '0', + right: '0', + width: width ?? '100%', + height: height ?? '80%', + maxWidth: '100%', + maxHeight: '85vh', + animation: + $status === 'exiting' + ? `${slideToBottom} ${$transitionDuration}ms` + : `${slideFromBottom} ${$transitionDuration}ms`, + } ); -export const CloseButton = () => ( - - - -); + ); +}; export const Content = styled.div({ display: 'flex', @@ -92,17 +201,17 @@ export const Col = styled.div({ export const Header = (props: React.ComponentProps) => ( - + ); -export const Title = styled(Dialog.Title)(({ theme }) => ({ +export const Title = styled(Heading)(({ theme }) => ({ margin: 0, fontSize: theme.typography.size.s3, fontWeight: theme.typography.weight.bold, })); -export const Description = styled(Dialog.Description)(({ theme }) => ({ +export const Description = styled(Text)(({ theme }) => ({ position: 'relative', zIndex: 1, margin: 0, diff --git a/code/core/src/components/components/Modal/Modal.tsx b/code/core/src/components/components/Modal/Modal.tsx index 0939d6ad4d09..66282c12cd41 100644 --- a/code/core/src/components/components/Modal/Modal.tsx +++ b/code/core/src/components/components/Modal/Modal.tsx @@ -1,58 +1,153 @@ -import React from 'react'; +import React, { type HTMLAttributes, createContext, useEffect, useState } from 'react'; -import * as Dialog from '@radix-ui/react-dialog'; +import type { DecoratorFunction } from 'storybook/internal/csf'; + +import { UNSAFE_PortalProvider } from 'react-aria'; +import { Dialog, ModalOverlay, Modal as ModalUpstream } from 'react-aria-components'; +import { useTransitionState } from 'react-transition-state'; import * as Components from './Modal.styled'; -type ContentProps = React.ComponentProps; +interface ModalProps extends HTMLAttributes { + /** Width of the Modal. Defaults to `740`. */ + width?: number | string; + + /** Height of the Modal. Defaults to `auto`. */ + height?: number | string; -interface ModalProps extends Omit, 'children'> { - width?: number; - height?: number; + /** Modal content. */ children: React.ReactNode; - onEscapeKeyDown?: ContentProps['onEscapeKeyDown']; - onInteractOutside?: ContentProps['onInteractOutside']; + + /** Additional class names for the Modal. */ className?: string; - container?: HTMLElement; - portalSelector?: string; + + /** Controlled state: whether the Modal is currently open. */ + open?: boolean; + + /** Uncontrolled state: whether the Modal is initially open on the first. */ + defaultOpen?: boolean; + + /** Handler called when visibility of the Modal changes. */ + onOpenChange?: (isOpen: boolean) => void; + + /** The accessible name for the modal. */ + ariaLabel: string; + + /** Whether the modal can be dismissed by clicking outside. Defaults to `true`. */ + dismissOnClickOutside?: boolean; + + /** Whether the modal can be dismissed by pressing Escape. Defaults to `true`. */ + dismissOnEscape?: boolean; + + /** Transition duration, so we can slow down transitions on mobile. */ + transitionDuration?: number; + + /** The max dimensions, initial position and animations of the Modal. Defaults to 'dialog'. */ + variant?: 'dialog' | 'bottom-drawer'; } -export const initial = { opacity: 0 }; -export const animate = { opacity: 1, transition: { duration: 0.3 } }; -export const exit = { opacity: 0, transition: { duration: 0.3 } }; +// Create a context to provide the close function like Radix Dialog +export const ModalContext = createContext<{ close?: () => void }>({}); function BaseModal({ children, width, height, - onEscapeKeyDown, - onInteractOutside = (ev) => ev.preventDefault(), + ariaLabel, + dismissOnClickOutside = true, + dismissOnEscape = true, className, - container, - portalSelector, - ...rootProps + open, + onOpenChange, + defaultOpen, + transitionDuration = 200, + variant = 'dialog', + ...props }: ModalProps) { - const containerElement = - container ?? (portalSelector ? document.querySelector(portalSelector) : null) ?? document.body; + const [{ status, isMounted }, toggle] = useTransitionState({ + timeout: 200, + mountOnEnter: true, + unmountOnExit: true, + enter: true, + exit: true, + }); + + // Sync external open state with transition state + useEffect(() => { + const shouldBeOpen = open ?? defaultOpen ?? false; + if (shouldBeOpen && !isMounted) { + toggle(true); + } else if (!shouldBeOpen && isMounted) { + toggle(false); + } + }, [open, defaultOpen, isMounted, toggle]); + + const close = () => { + handleOpenChange(false); + }; + + const handleOpenChange = (isOpen: boolean) => { + toggle(isOpen); + onOpenChange?.(isOpen); + }; + + if (!isMounted) { + return null; + } return ( - - - - - - - - {children} - - - - + + + + + + + {children} + + + + + ); } -export const Modal = Object.assign(BaseModal, Components, { Dialog }); +export const Modal = Object.assign(BaseModal, Components); + +/** + * Storybook decorator to help render Modals in stories with multiple theme layouts. Internal to + * Storybook. Use at your own risk. + */ +export const ModalDecorator: DecoratorFunction = (Story, { args }) => { + const [container, setContainer] = useState(null); + + return ( + <> + container}> + + +
setContainer(element ?? null)} + style={{ + width: '100%', + height: '100%', + minHeight: '600px', + transform: 'translateZ(0)', + }} + >
+ + ); +}; diff --git a/code/core/src/components/index.ts b/code/core/src/components/index.ts index 30ea5852dbfb..83bc1b588628 100644 --- a/code/core/src/components/index.ts +++ b/code/core/src/components/index.ts @@ -41,7 +41,7 @@ export { createCopyToClipboardFunction } from './components/syntaxhighlighter/sy // UI export { ActionBar } from './components/ActionBar/ActionBar'; -export { Modal } from './components/Modal/Modal'; +export { Modal, ModalDecorator } from './components/Modal/Modal'; export { Spaced } from './components/spaced/Spaced'; export { Placeholder } from './components/placeholder/placeholder'; export { ScrollArea } from './components/ScrollArea/ScrollArea'; diff --git a/code/core/src/controls/components/SaveStory.stories.tsx b/code/core/src/controls/components/SaveStory.stories.tsx index a08dba96d183..f09918f7dee7 100644 --- a/code/core/src/controls/components/SaveStory.stories.tsx +++ b/code/core/src/controls/components/SaveStory.stories.tsx @@ -1,8 +1,10 @@ import React from 'react'; +import { ModalDecorator } from 'storybook/internal/components'; + import type { Meta, StoryObj } from '@storybook/react-vite'; -import { expect, fireEvent, fn, within } from 'storybook/test'; +import { expect, fireEvent, fn, screen, within } from 'storybook/test'; import { SaveStory } from './SaveStory'; @@ -12,19 +14,11 @@ const meta = { saveStory: fn(), createStory: fn(), resetArgs: fn(), - portalSelector: '#portal-container', }, parameters: { layout: 'fullscreen', }, - decorators: [ - (Story) => ( -
- -
-
- ), - ], + decorators: [ModalDecorator], tags: ['!vitest'], } satisfies Meta; @@ -42,11 +36,11 @@ export const Creating = { } satisfies Story; export const Created: Story = { - play: async ({ canvas, context, userEvent }) => { + play: async ({ context, userEvent }) => { await Creating.play(context); const event = userEvent.setup({ delay: null }); - const dialog = await canvas.findByRole('dialog'); + const dialog = await screen.findByRole('dialog'); const input = await within(dialog).findByRole('textbox'); await event.type(input, 'MyNewStory'); diff --git a/code/core/src/controls/components/SaveStory.tsx b/code/core/src/controls/components/SaveStory.tsx index 286d7dc7b6ea..a4d4effc06dc 100644 --- a/code/core/src/controls/components/SaveStory.tsx +++ b/code/core/src/controls/components/SaveStory.tsx @@ -89,15 +89,9 @@ type SaveStoryProps = { saveStory: () => Promise; createStory: (storyName: string) => Promise; resetArgs: () => void; - portalSelector?: string; }; -export const SaveStory = ({ - saveStory, - createStory, - resetArgs, - portalSelector, -}: SaveStoryProps) => { +export const SaveStory = ({ saveStory, createStory, resetArgs }: SaveStoryProps) => { const inputRef = React.useRef(null); const [saving, setSaving] = React.useState(false); const [creating, setCreating] = React.useState(false); @@ -172,12 +166,7 @@ export const SaveStory = ({ - + @@ -203,11 +192,11 @@ export const SaveStory = ({ > Create - + - + diff --git a/code/core/src/manager/components/mobile/about/MobileAbout.tsx b/code/core/src/manager/components/mobile/about/MobileAbout.tsx index ea3cc6b7a457..d6b0c51967c5 100644 --- a/code/core/src/manager/components/mobile/about/MobileAbout.tsx +++ b/code/core/src/manager/components/mobile/about/MobileAbout.tsx @@ -1,12 +1,12 @@ import type { FC } from 'react'; -import React, { useRef } from 'react'; +import React, { useEffect, useRef } from 'react'; import { Button, Link, ScrollArea } from 'storybook/internal/components'; import { ArrowLeftIcon, GithubIcon, ShareAltIcon, StorybookIcon } from '@storybook/icons'; -import { Transition, type TransitionStatus } from 'react-transition-group'; -import { styled } from 'storybook/theming'; +import { useTransitionState } from 'react-transition-state'; +import { keyframes, styled } from 'storybook/theming'; import { MOBILE_TRANSITION_DURATION } from '../../../constants'; import { useLayout } from '../../layout/LayoutProvider'; @@ -16,110 +16,119 @@ export const MobileAbout: FC = () => { const { isMobileAboutOpen, setMobileAboutOpen } = useLayout(); const aboutRef = useRef(null); + const [state, toggle] = useTransitionState({ + timeout: MOBILE_TRANSITION_DURATION, + mountOnEnter: true, + unmountOnExit: true, + }); + + // Update transition state when isMobileAboutOpen changes + useEffect(() => { + toggle(isMobileAboutOpen); + }, [isMobileAboutOpen, toggle]); + + if (!state.isMounted) { + return null; + } + return ( - - {(state) => ( - - - - setMobileAboutOpen(false)} - ariaLabel="Close about section" - tooltip="Close about section" - variant="ghost" - > - - Back - - - - - - Github - - - - - - - Documentation - - - - - - - Open source software maintained by{' '} - - Chromatic - {' '} - and the{' '} - - Storybook Community - - - - - - )} - + + + setMobileAboutOpen(false)} + ariaLabel="Close about section" + tooltip="Close about section" + variant="ghost" + > + + Back + + + + + + Github + + + + + + + Documentation + + + + + + + Open source software maintained by{' '} + + Chromatic + {' '} + and the{' '} + + Storybook Community + + + + + ); }; -const Container = styled.div<{ state: TransitionStatus; transitionDuration: number }>( - ({ theme, state, transitionDuration }) => ({ +const slideFromRight = keyframes({ + from: { + opacity: 0, + transform: 'translate(20px, 0)', + }, + to: { + opacity: 1, + transform: 'translate(0, 0)', + }, +}); + +const slideToRight = keyframes({ + from: { + opacity: 1, + transform: 'translate(0, 0)', + }, + to: { + opacity: 0, + transform: 'translate(20px, 0)', + }, +}); + +const Container = styled.div<{ $status: string; $transitionDuration: number }>( + ({ theme, $status, $transitionDuration }) => ({ position: 'absolute', width: '100%', height: '100%', top: 0, left: 0, zIndex: 11, - transition: `all ${transitionDuration}ms ease-in-out`, overflow: 'auto', color: theme.color.defaultText, background: theme.background.content, - opacity: `${(() => { - switch (state) { - case 'entering': - case 'entered': - return 1; - case 'exiting': - case 'exited': - return 0; - default: - return 0; - } - })()}`, - transform: `${(() => { - switch (state) { - case 'entering': - case 'entered': - return 'translateX(0)'; - case 'exiting': - case 'exited': - return 'translateX(20px)'; - default: - return 'translateX(0)'; - } - })()}`, + animation: + $status === 'exiting' + ? `${slideToRight} ${$transitionDuration}ms` + : `${slideFromRight} ${$transitionDuration}ms`, }) ); diff --git a/code/core/src/manager/components/mobile/navigation/MobileAddonsDrawer.tsx b/code/core/src/manager/components/mobile/navigation/MobileAddonsDrawer.tsx index 2170a07e3bc3..e2a627bcbb8f 100644 --- a/code/core/src/manager/components/mobile/navigation/MobileAddonsDrawer.tsx +++ b/code/core/src/manager/components/mobile/navigation/MobileAddonsDrawer.tsx @@ -1,92 +1,42 @@ import type { FC, ReactNode } from 'react'; -import React, { useCallback, useRef } from 'react'; +import React from 'react'; + +import { Modal } from 'storybook/internal/components'; -import { Transition } from 'react-transition-group'; -import type { TransitionStatus } from 'react-transition-group/Transition'; import { styled } from 'storybook/theming'; import { MOBILE_TRANSITION_DURATION } from '../../../constants'; -import { useModalDialog } from '../../../hooks/useModalDialog'; interface MobileAddonsDrawerProps { children: ReactNode; id?: string; isOpen: boolean; - onClose: () => void; + onOpenChange: (isOpen: boolean) => void; } -const Container = styled.dialog<{ state: TransitionStatus }>(({ theme, state }) => ({ - position: 'fixed', - bottom: 0, - left: 0, - right: 0, - top: 'auto', - boxSizing: 'border-box', - width: '100%', - maxWidth: '100vw', +const StyledModal = styled(Modal)(({ theme }) => ({ background: theme.background.content, - height: '42vh', - zIndex: 11, - overflow: 'hidden', + borderRadius: '10px 10px 0 0', border: 'none', - padding: 0, - margin: 0, - transform: `translateY(${(() => { - if (state === 'entering' || state === 'entered') { - return '0'; - } - return '100%'; - })()})`, - transition: `all ${MOBILE_TRANSITION_DURATION}ms ease-in-out`, - '&[open]': { - position: 'fixed', - bottom: 0, - left: 0, - right: 0, - top: 'auto', - width: '100%', - maxWidth: '100vw', - margin: 0, - }, -})); - -const ContentContainer = styled.div<{ state: TransitionStatus }>(({ state }) => ({ - width: '100%', - height: '100%', - transition: `all ${MOBILE_TRANSITION_DURATION}ms ease-in-out`, - opacity: state === 'entered' || state === 'entering' ? 1 : 0, })); export const MobileAddonsDrawer: FC = ({ children, id, isOpen, - onClose, + onOpenChange, }) => { - const dialogRef = useModalDialog({ isOpen, onClose }); - - const forceCloseDialog = useCallback(() => { - if (dialogRef.current && dialogRef.current.hasAttribute('open')) { - dialogRef.current.close(); - } - }, []); - return ( - { - forceCloseDialog(); - }} + - {(state) => ( - - {children} - - )} - + {children} + ); }; diff --git a/code/core/src/manager/components/mobile/navigation/MobileMenuDrawer.tsx b/code/core/src/manager/components/mobile/navigation/MobileMenuDrawer.tsx index 7e85a47c3d00..b6e450afc0e5 100644 --- a/code/core/src/manager/components/mobile/navigation/MobileMenuDrawer.tsx +++ b/code/core/src/manager/components/mobile/navigation/MobileMenuDrawer.tsx @@ -1,205 +1,44 @@ -import type { FC } from 'react'; -import React, { useCallback, useRef } from 'react'; +import type { FC, ReactNode } from 'react'; +import React from 'react'; + +import { Modal } from 'storybook/internal/components'; -import { Transition } from 'react-transition-group'; -import type { TransitionStatus } from 'react-transition-group/Transition'; import { styled } from 'storybook/theming'; import { MOBILE_TRANSITION_DURATION } from '../../../constants'; -import { useModalDialog } from '../../../hooks/useModalDialog'; -import { useLayout } from '../../layout/LayoutProvider'; import { MobileAbout } from '../about/MobileAbout'; interface MobileMenuDrawerProps { - children?: React.ReactNode; + children: ReactNode; id?: string; + isOpen: boolean; + onOpenChange: (isOpen: boolean) => void; } -export const MobileMenuDrawer: FC = ({ children, id }) => { - const sidebarRef = useRef(null); - const overlayRef = useRef(null); - const { isMobileMenuOpen, setMobileMenuOpen, isMobileAboutOpen, setMobileAboutOpen } = - useLayout(); - - const handleClose = useCallback(() => { - setMobileMenuOpen(false); - }, [setMobileMenuOpen]); - - const dialogRef = useModalDialog({ - isOpen: isMobileMenuOpen, - onClose: handleClose, - }); - - const forceCloseDialog = useCallback(() => { - if (dialogRef.current && dialogRef.current.hasAttribute('open')) { - dialogRef.current.close(); - } - }, []); - - return ( - <> - { - setMobileAboutOpen(false); - forceCloseDialog(); - }} - > - {(state) => ( - - - {(sidebarState) => ( - - {children} - - )} - - - - )} - - - {(state) => ( - - )} - - - ); -}; - -const Container = styled.dialog<{ state: TransitionStatus }>(({ theme, state }) => ({ - position: 'fixed', - bottom: 0, - left: 0, - right: 0, - top: 'auto', - boxSizing: 'border-box', - width: '100%', - maxWidth: '100vw', +const StyledModal = styled(Modal)(({ theme }) => ({ background: theme.background.content, - height: '80%', - zIndex: 11, borderRadius: '10px 10px 0 0', - transition: `all ${MOBILE_TRANSITION_DURATION}ms ease-in-out`, - overflow: 'hidden', - transform: `${(() => { - if (state === 'entering') { - return 'translateY(0)'; - } - - if (state === 'entered') { - return 'translateY(0)'; - } - - if (state === 'exiting') { - return 'translateY(100%)'; - } - - if (state === 'exited') { - return 'translateY(100%)'; - } - return 'translateY(0)'; - })()}`, border: 'none', - padding: 0, - margin: 0, - '&[open]': { - position: 'fixed', - bottom: 0, - left: 0, - right: 0, - top: 'auto', - width: '100%', - maxWidth: '100vw', - margin: 0, - }, -})); - -const SidebarContainer = styled.div<{ state: TransitionStatus }>(({ theme, state }) => ({ - position: 'absolute', - width: '100%', - height: '100%', - top: 0, - left: 0, - zIndex: 1, - transition: `all ${MOBILE_TRANSITION_DURATION}ms ease-in-out`, - overflow: 'hidden', - opacity: `${(() => { - if (state === 'entered') { - return 1; - } - - if (state === 'entering') { - return 1; - } - - if (state === 'exiting') { - return 0; - } - - if (state === 'exited') { - return 0; - } - return 1; - })()}`, - transform: `${(() => { - switch (state) { - case 'entering': - case 'entered': - return 'translateX(0)'; - case 'exiting': - case 'exited': - return 'translateX(-20px)'; - default: - return 'translateX(0)'; - } - })()}`, })); -const Overlay = styled.div<{ state: TransitionStatus }>(({ state }) => ({ - position: 'fixed', - boxSizing: 'border-box', - background: 'rgba(0, 0, 0, 0.5)', - top: 0, - bottom: 0, - right: 0, - left: 0, - zIndex: 10, - transition: `all ${MOBILE_TRANSITION_DURATION}ms ease-in-out`, - cursor: 'pointer', - opacity: `${(() => { - switch (state) { - case 'entering': - case 'entered': - return 1; - case 'exiting': - case 'exited': - return 0; - default: - return 0; - } - })()}`, - - '&:hover': { - background: 'rgba(0, 0, 0, 0.6)', - }, -})); +export const MobileMenuDrawer: FC = ({ + children, + id, + isOpen, + onOpenChange, +}) => { + return ( + + {children} + + + ); +}; diff --git a/code/core/src/manager/components/mobile/navigation/MobileNavigation.tsx b/code/core/src/manager/components/mobile/navigation/MobileNavigation.tsx index cb6ab3673481..2898697e90db 100644 --- a/code/core/src/manager/components/mobile/navigation/MobileNavigation.tsx +++ b/code/core/src/manager/components/mobile/navigation/MobileNavigation.tsx @@ -75,18 +75,20 @@ export const MobileNavigation: FC { - setMobilePanelOpen(false); - }; - return ( - {menu} + + {menu} + {panel} diff --git a/code/core/src/manager/components/sidebar/FileSearchModal.stories.tsx b/code/core/src/manager/components/sidebar/FileSearchModal.stories.tsx index 1265865992ba..e36c2179d1e3 100644 --- a/code/core/src/manager/components/sidebar/FileSearchModal.stories.tsx +++ b/code/core/src/manager/components/sidebar/FileSearchModal.stories.tsx @@ -1,4 +1,6 @@ -import React, { useState } from 'react'; +import React from 'react'; + +import { ModalDecorator } from 'storybook/internal/components'; import type { Meta, StoryObj } from '@storybook/react-vite'; @@ -20,26 +22,7 @@ const meta = { parameters: { layout: 'fullscreen', }, - // This decorator is used to show the modal in the side by side view - decorators: [ - (Story, context) => { - const [container, setContainer] = useState(undefined); - - return ( -
setContainer(element ?? undefined)} - style={{ - width: '100%', - height: '100%', - minHeight: '600px', - transform: 'translateZ(0)', - }} - > - -
- ); - }, - ], + decorators: [ModalDecorator], } satisfies Meta; export default meta; diff --git a/code/core/src/manager/components/sidebar/FileSearchModal.tsx b/code/core/src/manager/components/sidebar/FileSearchModal.tsx index aac61a0129b9..12bdf945b15e 100644 --- a/code/core/src/manager/components/sidebar/FileSearchModal.tsx +++ b/code/core/src/manager/components/sidebar/FileSearchModal.tsx @@ -126,7 +126,6 @@ interface FileSearchModalProps { searchResults: SearchResult[] | null; onCreateNewStory: (payload: NewStoryPayload) => void; setError: (error: Error) => void; - container?: HTMLElement; } export const FileSearchModal = ({ @@ -139,7 +138,6 @@ export const FileSearchModal = ({ searchResults, onCreateNewStory, setError, - container, }: FileSearchModalProps) => { const [modalContentRef, modalContentDimensions] = useMeasure(); // @ts-expect-error (non strict) @@ -159,17 +157,11 @@ export const FileSearchModal = ({ return ( { - onOpenChange(false); - }} - onInteractOutside={() => { - onOpenChange(false); - }} - container={container} > {/* @ts-expect-error (non strict) */} diff --git a/code/core/src/manager/globals/exports.ts b/code/core/src/manager/globals/exports.ts index e2b3dce7489a..90cde0dcf35c 100644 --- a/code/core/src/manager/globals/exports.ts +++ b/code/core/src/manager/globals/exports.ts @@ -510,6 +510,7 @@ export default { 'ListItem', 'Loader', 'Modal', + 'ModalDecorator', 'OL', 'P', 'Placeholder', diff --git a/code/e2e-tests/addon-controls.spec.ts b/code/e2e-tests/addon-controls.spec.ts index c2e3a4e97928..6ce2b16625fe 100644 --- a/code/e2e-tests/addon-controls.spec.ts +++ b/code/e2e-tests/addon-controls.spec.ts @@ -82,6 +82,8 @@ test.describe('addon-controls', () => { const sbPage = new SbPage(page, expect); await sbPage.waitUntilLoaded(); + await sbPage.closeAnyPendingModal(); + await sbPage.viewAddonPanel('Controls'); await sbPage.panelContent().locator('#control-select').selectOption('double space'); @@ -94,6 +96,7 @@ test.describe('addon-controls', () => { const sbPage = new SbPage(page, expect); await sbPage.waitUntilLoaded(); + await sbPage.closeAnyPendingModal(); await sbPage.viewAddonPanel('Controls'); await sbPage.panelContent().locator('#control-multiSelect').selectOption('double space'); diff --git a/code/e2e-tests/manager.spec.ts b/code/e2e-tests/manager.spec.ts index f14559df7b98..f450ede70fe4 100644 --- a/code/e2e-tests/manager.spec.ts +++ b/code/e2e-tests/manager.spec.ts @@ -21,17 +21,17 @@ test.describe('Manager UI', () => { await page.locator('[aria-label="Settings"]').click(); // should only hide if pressing Escape, and not other keyboard inputs - await expect(page.getByTestId('tooltip')).toBeVisible(); + await expect(page.getByRole('dialog')).toBeVisible(); await page.keyboard.press('A'); - await expect(page.getByTestId('tooltip')).toBeVisible(); + await expect(page.getByRole('dialog')).toBeVisible(); await page.keyboard.press('Escape'); - await expect(page.getByTestId('tooltip')).toBeHidden(); + await expect(page.getByRole('dialog')).toBeHidden(); // should also hide if clicking anywhere outside the tooltip await page.locator('[aria-label="Settings"]').click(); - await expect(page.getByTestId('tooltip')).toBeVisible(); + await expect(page.getByRole('dialog')).toBeVisible(); await page.click('body'); - await expect(page.getByTestId('tooltip')).toBeHidden(); + await expect(page.getByRole('dialog')).toBeHidden(); }); test('Sidebar toggling', async ({ page }) => { @@ -63,9 +63,10 @@ test.describe('Manager UI', () => { // Context menu should contain open in editor for component node await page.locator('[data-item-id="example-button"]').hover(); await page - .locator('[data-item-id="example-button"] div[data-testid="context-menu"] button') + .locator('[data-item-id="example-button"]') + .getByRole('button', { name: 'Open context menu' }) .click(); - const sidebarContextMenu = page.getByTestId('tooltip'); + const sidebarContextMenu = page.getByRole('dialog'); await expect( sidebarContextMenu.getByRole('button', { name: /open in editor/i }) ).toBeVisible(); @@ -74,23 +75,25 @@ test.describe('Manager UI', () => { // Context menu should contain open in editor for docs node await page.locator('[data-item-id="example-button--docs"]').hover(); await page - .locator('[data-item-id="example-button--docs"] div[data-testid="context-menu"] button') + .locator('[data-item-id="example-button--docs"]') + .getByRole('button', { name: 'Open context menu' }) .click(); await expect( - page.getByTestId('tooltip').getByRole('button', { name: /open in editor/i }) + page.getByRole('dialog').getByRole('button', { name: /open in editor/i }) ).toBeVisible(); await page.click('body'); // Context menu should contain open in editor and copy story name for story node await page.locator('[data-item-id="example-button--primary"]').hover(); await page - .locator('[data-item-id="example-button--primary"] div[data-testid="context-menu"] button') + .locator('[data-item-id="example-button--primary"]') + .getByRole('button', { name: 'Open context menu' }) .click(); await expect( - page.getByTestId('tooltip').getByRole('button', { name: /open in editor/i }) + page.getByRole('dialog').getByRole('button', { name: /open in editor/i }) ).toBeVisible(); await page - .getByTestId('tooltip') + .getByRole('dialog') .getByRole('button', { name: /copy story name/i }) .click(); diff --git a/code/e2e-tests/tags.spec.ts b/code/e2e-tests/tags.spec.ts index 57761f59a63c..c3e624730c8f 100644 --- a/code/e2e-tests/tags.spec.ts +++ b/code/e2e-tests/tags.spec.ts @@ -51,7 +51,7 @@ test.describe('tags', () => { test('filters stories via Tag filters tooltip (desktop)', async ({ page }) => { // Open Tag filters tooltip await page.locator('[aria-label="Tag filters"]').click(); - const tooltip = page.locator('[data-testid="tooltip"]'); + const tooltip = page.locator('[role="dialog"]'); await expect(tooltip).toBeVisible(); // No checkbox selected by default and "Select all tags" is shown @@ -84,26 +84,26 @@ test.describe('tags', () => { // Open Tag filters tooltip await page.locator('[aria-label="Tag filters"]').click(); - const tooltip = page.locator('[data-testid="tooltip"]'); - await expect(tooltip).toBeVisible(); + const tagFilterPopover = page.getByRole('dialog', { name: 'Tag filters' }); + await expect(tagFilterPopover).toBeVisible(); // No checkbox selected by default and "Select all tags" is shown - await expect(tooltip.locator('#select-all')).toBeVisible(); - await expect(tooltip.locator('input[type="checkbox"]:checked')).toHaveCount(0); + await expect(tagFilterPopover.locator('#select-all')).toBeVisible(); + await expect(tagFilterPopover.locator('input[type="checkbox"]:checked')).toHaveCount(0); // Select the dev-only tag - await tooltip.locator('#list-item-tag-dev-only').click(); + await tagFilterPopover.locator('#list-item-tag-dev-only').click(); // Assert that only one story is visible in the (mobile) sidebar const stories = page.locator('#storybook-explorer-menu .sidebar-item'); await expect(stories).toHaveCount(1); // Clear selection - await expect(tooltip.locator('#unselect-all')).toBeVisible(); - await tooltip.locator('#unselect-all').click(); + await expect(tagFilterPopover.locator('#unselect-all')).toBeVisible(); + await tagFilterPopover.locator('#unselect-all').click(); // Checkboxes are not selected anymore - await expect(tooltip.locator('input[type="checkbox"]:checked')).toHaveCount(0); + await expect(tagFilterPopover.locator('input[type="checkbox"]:checked')).toHaveCount(0); }); }); }); diff --git a/code/e2e-tests/util.ts b/code/e2e-tests/util.ts index e761e46166aa..d7274e9a4b44 100644 --- a/code/e2e-tests/util.ts +++ b/code/e2e-tests/util.ts @@ -147,6 +147,16 @@ export class SbPage { await this.waitForStoryLoaded(); } + /** + * We have stories with modals set to auto-open (e.g. startOpen color control). This helper closes + * them to free scroll and keyboard focus traps. + */ + async closeAnyPendingModal() { + const popover = this.page.locator('[role="dialog"]'); + await this.page.keyboard.press('Escape'); + await popover.waitFor({ state: 'hidden' }); + } + previewIframe() { return this.page.frameLocator('#storybook-preview-iframe'); } diff --git a/code/yarn.lock b/code/yarn.lock index 9dcb3f485243..325a3a37738d 100644 --- a/code/yarn.lock +++ b/code/yarn.lock @@ -5130,13 +5130,6 @@ __metadata: languageName: node linkType: hard -"@radix-ui/primitive@npm:1.1.3": - version: 1.1.3 - resolution: "@radix-ui/primitive@npm:1.1.3" - checksum: 10c0/88860165ee7066fa2c179f32ffcd3ee6d527d9dcdc0e8be85e9cb0e2c84834be8e3c1a976c74ba44b193f709544e12f54455d892b28e32f0708d89deda6b9f1d - languageName: node - linkType: hard - "@radix-ui/react-collection@npm:1.0.3": version: 1.0.3 resolution: "@radix-ui/react-collection@npm:1.0.3" @@ -5229,51 +5222,6 @@ __metadata: languageName: node linkType: hard -"@radix-ui/react-context@npm:1.1.2": - version: 1.1.2 - resolution: "@radix-ui/react-context@npm:1.1.2" - peerDependencies: - "@types/react": "*" - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - "@types/react": - optional: true - checksum: 10c0/cece731f8cc25d494c6589cc681e5c01a93867d895c75889973afa1a255f163c286e390baa7bc028858eaabe9f6b57270d0ca6377356f652c5557c1c7a41ccce - languageName: node - linkType: hard - -"@radix-ui/react-dialog@npm:^1.1.2": - version: 1.1.15 - resolution: "@radix-ui/react-dialog@npm:1.1.15" - dependencies: - "@radix-ui/primitive": "npm:1.1.3" - "@radix-ui/react-compose-refs": "npm:1.1.2" - "@radix-ui/react-context": "npm:1.1.2" - "@radix-ui/react-dismissable-layer": "npm:1.1.11" - "@radix-ui/react-focus-guards": "npm:1.1.3" - "@radix-ui/react-focus-scope": "npm:1.1.7" - "@radix-ui/react-id": "npm:1.1.1" - "@radix-ui/react-portal": "npm:1.1.9" - "@radix-ui/react-presence": "npm:1.1.5" - "@radix-ui/react-primitive": "npm:2.1.3" - "@radix-ui/react-slot": "npm:1.2.3" - "@radix-ui/react-use-controllable-state": "npm:1.2.2" - aria-hidden: "npm:^1.2.4" - react-remove-scroll: "npm:^2.6.3" - peerDependencies: - "@types/react": "*" - "@types/react-dom": "*" - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - "@types/react": - optional: true - "@types/react-dom": - optional: true - checksum: 10c0/2f2c88e3c281acaea2fd9b96fa82132d59177d3aa5da2e7c045596fd4028e84e44ac52ac28f4f236910605dd7d9338c2858ba44a9ced2af2e3e523abbfd33014 - languageName: node - linkType: hard - "@radix-ui/react-direction@npm:1.0.1": version: 1.0.1 resolution: "@radix-ui/react-direction@npm:1.0.1" @@ -5302,63 +5250,6 @@ __metadata: languageName: node linkType: hard -"@radix-ui/react-dismissable-layer@npm:1.1.11": - version: 1.1.11 - resolution: "@radix-ui/react-dismissable-layer@npm:1.1.11" - dependencies: - "@radix-ui/primitive": "npm:1.1.3" - "@radix-ui/react-compose-refs": "npm:1.1.2" - "@radix-ui/react-primitive": "npm:2.1.3" - "@radix-ui/react-use-callback-ref": "npm:1.1.1" - "@radix-ui/react-use-escape-keydown": "npm:1.1.1" - peerDependencies: - "@types/react": "*" - "@types/react-dom": "*" - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - "@types/react": - optional: true - "@types/react-dom": - optional: true - checksum: 10c0/c825572a64073c4d3853702029979f6658770ffd6a98eabc4984e1dee1b226b4078a2a4dc7003f96475b438985e9b21a58e75f51db74dd06848dcae1f2d395dc - languageName: node - linkType: hard - -"@radix-ui/react-focus-guards@npm:1.1.3": - version: 1.1.3 - resolution: "@radix-ui/react-focus-guards@npm:1.1.3" - peerDependencies: - "@types/react": "*" - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - "@types/react": - optional: true - checksum: 10c0/0bab65eb8d7e4f72f685d63de7fbba2450e3cb15ad6a20a16b42195e9d335c576356f5a47cb58d1ffc115393e46d7b14b12c5d4b10029b0ec090861255866985 - languageName: node - linkType: hard - -"@radix-ui/react-focus-scope@npm:1.1.7": - version: 1.1.7 - resolution: "@radix-ui/react-focus-scope@npm:1.1.7" - dependencies: - "@radix-ui/react-compose-refs": "npm:1.1.2" - "@radix-ui/react-primitive": "npm:2.1.3" - "@radix-ui/react-use-callback-ref": "npm:1.1.1" - peerDependencies: - "@types/react": "*" - "@types/react-dom": "*" - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - "@types/react": - optional: true - "@types/react-dom": - optional: true - checksum: 10c0/8a6071331bdeeb79b223463de75caf759b8ad19339cab838e537b8dbb2db236891a1f4df252445c854d375d43d9d315dfcce0a6b01553a2984ec372bb8f1300e - languageName: node - linkType: hard - "@radix-ui/react-id@npm:1.0.1": version: 1.0.1 resolution: "@radix-ui/react-id@npm:1.0.1" @@ -5375,41 +5266,6 @@ __metadata: languageName: node linkType: hard -"@radix-ui/react-id@npm:1.1.1": - version: 1.1.1 - resolution: "@radix-ui/react-id@npm:1.1.1" - dependencies: - "@radix-ui/react-use-layout-effect": "npm:1.1.1" - peerDependencies: - "@types/react": "*" - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - "@types/react": - optional: true - checksum: 10c0/7d12e76818763d592c331277ef62b197e2e64945307e650bd058f0090e5ae48bbd07691b23b7e9e977901ef4eadcb3e2d5eaeb17a13859083384be83fc1292c7 - languageName: node - linkType: hard - -"@radix-ui/react-portal@npm:1.1.9": - version: 1.1.9 - resolution: "@radix-ui/react-portal@npm:1.1.9" - dependencies: - "@radix-ui/react-primitive": "npm:2.1.3" - "@radix-ui/react-use-layout-effect": "npm:1.1.1" - peerDependencies: - "@types/react": "*" - "@types/react-dom": "*" - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - "@types/react": - optional: true - "@types/react-dom": - optional: true - checksum: 10c0/45b432497c722720c72c493a29ef6085bc84b50eafe79d48b45c553121b63e94f9cdb77a3a74b9c49126f8feb3feee009fe400d48b7759d3552396356b192cd7 - languageName: node - linkType: hard - "@radix-ui/react-presence@npm:1.0.1": version: 1.0.1 resolution: "@radix-ui/react-presence@npm:1.0.1" @@ -5451,26 +5307,6 @@ __metadata: languageName: node linkType: hard -"@radix-ui/react-presence@npm:1.1.5": - version: 1.1.5 - resolution: "@radix-ui/react-presence@npm:1.1.5" - dependencies: - "@radix-ui/react-compose-refs": "npm:1.1.2" - "@radix-ui/react-use-layout-effect": "npm:1.1.1" - peerDependencies: - "@types/react": "*" - "@types/react-dom": "*" - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - "@types/react": - optional: true - "@types/react-dom": - optional: true - checksum: 10c0/d0e61d314250eeaef5369983cb790701d667f51734bafd98cf759072755562018052c594e6cdc5389789f4543cb0a4d98f03ff4e8f37338d6b5bf51a1700c1d1 - languageName: node - linkType: hard - "@radix-ui/react-primitive@npm:1.0.3": version: 1.0.3 resolution: "@radix-ui/react-primitive@npm:1.0.3" @@ -5510,25 +5346,6 @@ __metadata: languageName: node linkType: hard -"@radix-ui/react-primitive@npm:2.1.3": - version: 2.1.3 - resolution: "@radix-ui/react-primitive@npm:2.1.3" - dependencies: - "@radix-ui/react-slot": "npm:1.2.3" - peerDependencies: - "@types/react": "*" - "@types/react-dom": "*" - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - "@types/react": - optional: true - "@types/react-dom": - optional: true - checksum: 10c0/fdff9b84913bb4172ef6d3af7442fca5f9bba5f2709cba08950071f819d7057aec3a4a2d9ef44cf9cbfb8014d02573c6884a04cff175895823aaef809ebdb034 - languageName: node - linkType: hard - "@radix-ui/react-roving-focus@npm:1.0.4": version: 1.0.4 resolution: "@radix-ui/react-roving-focus@npm:1.0.4" @@ -5615,7 +5432,7 @@ __metadata: languageName: node linkType: hard -"@radix-ui/react-slot@npm:1.2.3, @radix-ui/react-slot@npm:^1.0.2": +"@radix-ui/react-slot@npm:^1.0.2": version: 1.2.3 resolution: "@radix-ui/react-slot@npm:1.2.3" dependencies: @@ -5685,19 +5502,6 @@ __metadata: languageName: node linkType: hard -"@radix-ui/react-use-callback-ref@npm:1.1.1": - version: 1.1.1 - resolution: "@radix-ui/react-use-callback-ref@npm:1.1.1" - peerDependencies: - "@types/react": "*" - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - "@types/react": - optional: true - checksum: 10c0/5f6aff8592dea6a7e46589808912aba3fb3b626cf6edd2b14f01638b61dbbe49eeb9f67cd5601f4c15b2fb547b9a7e825f7c4961acd4dd70176c969ae405f8d8 - languageName: node - linkType: hard - "@radix-ui/react-use-controllable-state@npm:1.0.1": version: 1.0.1 resolution: "@radix-ui/react-use-controllable-state@npm:1.0.1" @@ -5714,52 +5518,6 @@ __metadata: languageName: node linkType: hard -"@radix-ui/react-use-controllable-state@npm:1.2.2": - version: 1.2.2 - resolution: "@radix-ui/react-use-controllable-state@npm:1.2.2" - dependencies: - "@radix-ui/react-use-effect-event": "npm:0.0.2" - "@radix-ui/react-use-layout-effect": "npm:1.1.1" - peerDependencies: - "@types/react": "*" - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - "@types/react": - optional: true - checksum: 10c0/f55c4b06e895293aed4b44c9ef26fb24432539f5346fcd6519c7745800535b571058685314e83486a45bf61dc83887e24826490d3068acc317fb0a9010516e63 - languageName: node - linkType: hard - -"@radix-ui/react-use-effect-event@npm:0.0.2": - version: 0.0.2 - resolution: "@radix-ui/react-use-effect-event@npm:0.0.2" - dependencies: - "@radix-ui/react-use-layout-effect": "npm:1.1.1" - peerDependencies: - "@types/react": "*" - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - "@types/react": - optional: true - checksum: 10c0/e84ff72a3e76c5ae9c94941028bb4b6472f17d4104481b9eab773deab3da640ecea035e54da9d6f4df8d84c18ef6913baf92b7511bee06930dc58bd0c0add417 - languageName: node - linkType: hard - -"@radix-ui/react-use-escape-keydown@npm:1.1.1": - version: 1.1.1 - resolution: "@radix-ui/react-use-escape-keydown@npm:1.1.1" - dependencies: - "@radix-ui/react-use-callback-ref": "npm:1.1.1" - peerDependencies: - "@types/react": "*" - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - "@types/react": - optional: true - checksum: 10c0/bff53be99e940fef1d3c4df7d560e1d9133182e5a98336255d3063327d1d3dd4ec54a95dc5afe15cca4fb6c184f0a956c70de2815578c318cf995a7f9beabaa1 - languageName: node - linkType: hard - "@radix-ui/react-use-layout-effect@npm:1.0.1": version: 1.0.1 resolution: "@radix-ui/react-use-layout-effect@npm:1.0.1" @@ -5788,19 +5546,6 @@ __metadata: languageName: node linkType: hard -"@radix-ui/react-use-layout-effect@npm:1.1.1": - version: 1.1.1 - resolution: "@radix-ui/react-use-layout-effect@npm:1.1.1" - peerDependencies: - "@types/react": "*" - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - "@types/react": - optional: true - checksum: 10c0/9f98fdaba008dfc58050de60a77670b885792df473cf82c1cef8daee919a5dd5a77d270209f5f0b0abfaac78cb1627396e3ff56c81b735be550409426fe8b040 - languageName: node - linkType: hard - "@react-aria/autocomplete@npm:3.0.0-rc.1": version: 3.0.0-rc.1 resolution: "@react-aria/autocomplete@npm:3.0.0-rc.1" @@ -9861,15 +9606,6 @@ __metadata: languageName: node linkType: hard -"@types/react-transition-group@npm:^4": - version: 4.4.12 - resolution: "@types/react-transition-group@npm:4.4.12" - peerDependencies: - "@types/react": "*" - checksum: 10c0/0441b8b47c69312c89ec0760ba477ba1a0808a10ceef8dc1c64b1013ed78517332c30f18681b0ec0b53542731f1ed015169fed1d127cc91222638ed955478ec7 - languageName: node - linkType: hard - "@types/react@npm:^18.0.0": version: 18.3.24 resolution: "@types/react@npm:18.3.24" @@ -11376,15 +11112,6 @@ __metadata: languageName: node linkType: hard -"aria-hidden@npm:^1.2.4": - version: 1.2.6 - resolution: "aria-hidden@npm:1.2.6" - dependencies: - tslib: "npm:^2.0.0" - checksum: 10c0/7720cb539497a9f760f68f98a4b30f22c6767aa0e72fa7d58279f7c164e258fc38b2699828f8de881aab0fc8e9c56d1313a3f1a965046fc0381a554dbc72b54a - languageName: node - linkType: hard - "aria-query@npm:5.1.3": version: 5.1.3 resolution: "aria-query@npm:5.1.3" @@ -14342,13 +14069,6 @@ __metadata: languageName: node linkType: hard -"detect-node-es@npm:^1.1.0": - version: 1.1.0 - resolution: "detect-node-es@npm:1.1.0" - checksum: 10c0/e562f00de23f10c27d7119e1af0e7388407eb4b06596a25f6d79a360094a109ff285de317f02b090faae093d314cf6e73ac3214f8a5bb3a0def5bece94557fbe - languageName: node - linkType: hard - "detect-node@npm:^2.0.4": version: 2.1.0 resolution: "detect-node@npm:2.1.0" @@ -14476,16 +14196,6 @@ __metadata: languageName: node linkType: hard -"dom-helpers@npm:^5.0.1": - version: 5.2.1 - resolution: "dom-helpers@npm:5.2.1" - dependencies: - "@babel/runtime": "npm:^7.8.7" - csstype: "npm:^3.0.2" - checksum: 10c0/f735074d66dd759b36b158fa26e9d00c9388ee0e8c9b16af941c38f014a37fc80782de83afefd621681b19ac0501034b4f1c4a3bff5caa1b8667f0212b5e124c - languageName: node - linkType: hard - "dom-serializer@npm:^1.0.1": version: 1.4.1 resolution: "dom-serializer@npm:1.4.1" @@ -17253,13 +16963,6 @@ __metadata: languageName: node linkType: hard -"get-nonce@npm:^1.0.0": - version: 1.0.1 - resolution: "get-nonce@npm:1.0.1" - checksum: 10c0/2d7df55279060bf0568549e1ffc9b84bc32a32b7541675ca092dce56317cdd1a59a98dcc4072c9f6a980779440139a3221d7486f52c488e69dc0fd27b1efb162 - languageName: node - linkType: hard - "get-npm-tarball-url@npm:^2.0.3": version: 2.1.0 resolution: "get-npm-tarball-url@npm:2.1.0" @@ -23623,7 +23326,7 @@ __metadata: languageName: node linkType: hard -"prop-types@npm:^15.6.2, prop-types@npm:^15.7.2, prop-types@npm:^15.8.1": +"prop-types@npm:^15.7.2, prop-types@npm:^15.8.1": version: 15.8.1 resolution: "prop-types@npm:15.8.1" dependencies: @@ -24368,41 +24071,6 @@ __metadata: languageName: node linkType: hard -"react-remove-scroll-bar@npm:^2.3.7": - version: 2.3.8 - resolution: "react-remove-scroll-bar@npm:2.3.8" - dependencies: - react-style-singleton: "npm:^2.2.2" - tslib: "npm:^2.0.0" - peerDependencies: - "@types/react": "*" - react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - peerDependenciesMeta: - "@types/react": - optional: true - checksum: 10c0/9a0675c66cbb52c325bdbfaed80987a829c4504cefd8ff2dd3b6b3afc9a1500b8ec57b212e92c1fb654396d07bbe18830a8146fe77677d2a29ce40b5e1f78654 - languageName: node - linkType: hard - -"react-remove-scroll@npm:^2.6.3": - version: 2.7.1 - resolution: "react-remove-scroll@npm:2.7.1" - dependencies: - react-remove-scroll-bar: "npm:^2.3.7" - react-style-singleton: "npm:^2.2.3" - tslib: "npm:^2.1.0" - use-callback-ref: "npm:^1.3.3" - use-sidecar: "npm:^1.1.3" - peerDependencies: - "@types/react": "*" - react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc - peerDependenciesMeta: - "@types/react": - optional: true - checksum: 10c0/7ad8f6ffd3e2aedf9b3d79f0c9088a9a3d7c5332d80c923427a6d97fe0626fb4cb33a6d9174d19fad57d860be69c96f68497a0619c3a8af0e8a5332e49bdde31 - languageName: node - linkType: hard - "react-resize-detector@npm:^7.1.2": version: 7.1.2 resolution: "react-resize-detector@npm:7.1.2" @@ -24475,22 +24143,6 @@ __metadata: languageName: node linkType: hard -"react-style-singleton@npm:^2.2.2, react-style-singleton@npm:^2.2.3": - version: 2.2.3 - resolution: "react-style-singleton@npm:2.2.3" - dependencies: - get-nonce: "npm:^1.0.0" - tslib: "npm:^2.0.0" - peerDependencies: - "@types/react": "*" - react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc - peerDependenciesMeta: - "@types/react": - optional: true - checksum: 10c0/841938ff16d16a6b76895f4cb2e1fea957e5fe3b30febbf03a54892dae1c9153f2383e231dea0b3ba41192ad2f2849448fa859caccd288943bce32639e971bee - languageName: node - linkType: hard - "react-syntax-highlighter@npm:^15.4.5": version: 15.6.6 resolution: "react-syntax-highlighter@npm:15.6.6" @@ -24520,18 +24172,13 @@ __metadata: languageName: node linkType: hard -"react-transition-group@npm:^4.4.5": - version: 4.4.5 - resolution: "react-transition-group@npm:4.4.5" - dependencies: - "@babel/runtime": "npm:^7.5.5" - dom-helpers: "npm:^5.0.1" - loose-envify: "npm:^1.4.0" - prop-types: "npm:^15.6.2" +"react-transition-state@npm:^2.3.1": + version: 2.3.1 + resolution: "react-transition-state@npm:2.3.1" peerDependencies: - react: ">=16.6.0" - react-dom: ">=16.6.0" - checksum: 10c0/2ba754ba748faefa15f87c96dfa700d5525054a0141de8c75763aae6734af0740e77e11261a1e8f4ffc08fd9ab78510122e05c21c2d79066c38bb6861a886c82 + react: ">=16.8.0" + react-dom: ">=16.8.0" + checksum: 10c0/145f20e45c53d570f9db5fe749e0fd794b62bdf464c67c594c454df44f31fa58c16ccec705f9fdccf4c5250a309e4f7da5bd2e85cd686ba52d6b79e1c22b811f languageName: node linkType: hard @@ -26458,7 +26105,6 @@ __metadata: "@ndelangen/get-tarball": "npm:^3.0.7" "@ngard/tiny-isequal": "npm:^1.1.0" "@polka/compression": "npm:^1.0.0-next.28" - "@radix-ui/react-dialog": "npm:^1.1.2" "@radix-ui/react-scroll-area": "npm:1.2.0-rc.7" "@radix-ui/react-slot": "npm:^1.0.2" "@react-aria/utils": "npm:^3.30.1" @@ -26483,7 +26129,6 @@ __metadata: "@types/pretty-hrtime": "npm:^1.0.0" "@types/prompts": "npm:^2.0.9" "@types/react-syntax-highlighter": "npm:11.0.5" - "@types/react-transition-group": "npm:^4" "@types/semver": "npm:^7.5.8" "@types/ws": "npm:^8" "@vitest/expect": "npm:3.2.4" @@ -26555,7 +26200,7 @@ __metadata: react-stately: "npm:^3.41.0" react-syntax-highlighter: "npm:^15.4.5" react-textarea-autosize: "npm:^8.3.0" - react-transition-group: "npm:^4.4.5" + react-transition-state: "npm:^2.3.1" recast: "npm:^0.23.5" require-from-string: "npm:^2.0.2" resolve.exports: "npm:^2.0.3" @@ -28110,21 +27755,6 @@ __metadata: languageName: node linkType: hard -"use-callback-ref@npm:^1.3.3": - version: 1.3.3 - resolution: "use-callback-ref@npm:1.3.3" - dependencies: - tslib: "npm:^2.0.0" - peerDependencies: - "@types/react": "*" - react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc - peerDependenciesMeta: - "@types/react": - optional: true - checksum: 10c0/f887488c6e6075cdad4962979da1714b217bcb1ee009a9e57ce9a844bcfc4c3a99e93983dfc2e5af9e0913824d24e730090ff255e902c516dcb58d2d3837e01c - languageName: node - linkType: hard - "use-composed-ref@npm:^1.3.0": version: 1.4.0 resolution: "use-composed-ref@npm:1.4.0" @@ -28175,22 +27805,6 @@ __metadata: languageName: node linkType: hard -"use-sidecar@npm:^1.1.3": - version: 1.1.3 - resolution: "use-sidecar@npm:1.1.3" - dependencies: - detect-node-es: "npm:^1.1.0" - tslib: "npm:^2.0.0" - peerDependencies: - "@types/react": "*" - react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc - peerDependenciesMeta: - "@types/react": - optional: true - checksum: 10c0/161599bf921cfaa41c85d2b01c871975ee99260f3e874c2d41c05890d41170297bdcf314bc5185e7a700de2034ac5b888e3efc8e9f35724f4918f53538d717c9 - languageName: node - linkType: hard - "use-sync-external-store@npm:^1.4.0": version: 1.5.0 resolution: "use-sync-external-store@npm:1.5.0" From fdfcef51a82439ab5f38c8708f6c20741fcab970 Mon Sep 17 00:00:00 2001 From: Steve Dodier-Lazaro Date: Sat, 20 Sep 2025 21:24:59 +0200 Subject: [PATCH 08/15] UI: Fix missing onOpenChange trigger on initial Modal render --- code/core/src/components/components/Modal/Modal.tsx | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/code/core/src/components/components/Modal/Modal.tsx b/code/core/src/components/components/Modal/Modal.tsx index 66282c12cd41..e02240b8e208 100644 --- a/code/core/src/components/components/Modal/Modal.tsx +++ b/code/core/src/components/components/Modal/Modal.tsx @@ -91,6 +91,14 @@ function BaseModal({ onOpenChange?.(isOpen); }; + // Call onOpenChange ourselves when the modal is initially opened, as react-aria won't. + useEffect(() => { + if (open || defaultOpen) { + onOpenChange?.(true); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isMounted]); + if (!isMounted) { return null; } From d599fa36b5e636438b3cfa4f2cc195da966776f2 Mon Sep 17 00:00:00 2001 From: Steve Dodier-Lazaro Date: Sat, 20 Sep 2025 21:27:18 +0200 Subject: [PATCH 09/15] UI: Make Modal.Title use h2 for WCAG compliance --- code/core/src/components/components/Modal/Modal.styled.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/code/core/src/components/components/Modal/Modal.styled.tsx b/code/core/src/components/components/Modal/Modal.styled.tsx index 3f66505a4dac..a0ca35e1a275 100644 --- a/code/core/src/components/components/Modal/Modal.styled.tsx +++ b/code/core/src/components/components/Modal/Modal.styled.tsx @@ -205,7 +205,9 @@ export const Header = (props: React.ComponentProps) => ( ); -export const Title = styled(Heading)(({ theme }) => ({ +export const Title = styled((props: ComponentProps) => ( + +))(({ theme }) => ({ margin: 0, fontSize: theme.typography.size.s3, fontWeight: theme.typography.weight.bold, From aec588a23a353b45efd8c3461f5fc17c6b786bc9 Mon Sep 17 00:00:00 2001 From: Steve Dodier-Lazaro Date: Sun, 21 Sep 2025 13:30:58 +0200 Subject: [PATCH 10/15] UI: Fix Modal animation flicker and support reduced motion --- .../components/Modal/Modal.stories.tsx | 4 +- .../components/Modal/Modal.styled.tsx | 55 +++++++++++++------ .../src/components/components/Modal/Modal.tsx | 28 +++++----- 3 files changed, 55 insertions(+), 32 deletions(-) diff --git a/code/core/src/components/components/Modal/Modal.stories.tsx b/code/core/src/components/components/Modal/Modal.stories.tsx index ea819d3ffcce..d41e032a7e55 100644 --- a/code/core/src/components/components/Modal/Modal.stories.tsx +++ b/code/core/src/components/components/Modal/Modal.stories.tsx @@ -350,12 +350,14 @@ export const WithError = meta.story({ export const AlwaysOpen = meta.story({ args: { open: true, + dismissOnClickOutside: false, + dismissOnEscape: false, children: , }, render: (args) => ( - + Always Open Modal This modal is always visible for demonstration. diff --git a/code/core/src/components/components/Modal/Modal.styled.tsx b/code/core/src/components/components/Modal/Modal.styled.tsx index a0ca35e1a275..7a32d8cccfbf 100644 --- a/code/core/src/components/components/Modal/Modal.styled.tsx +++ b/code/core/src/components/components/Modal/Modal.styled.tsx @@ -81,10 +81,13 @@ export const Overlay = styled.div<{ width: '100%', height: '100%', zIndex: 90, - animation: - $status === 'exiting' - ? `${fadeOut} ${$transitionDuration}ms` - : `${fadeIn} ${$transitionDuration}ms`, + '@media (prefers-reduced-motion: no-preference)': { + animation: + $status === 'exiting' || $status === 'preExit' + ? `${fadeOut} ${$transitionDuration}ms` + : `${fadeIn} ${$transitionDuration}ms`, + animationFillMode: 'forwards', + }, })); export const Container = styled.div<{ @@ -111,15 +114,22 @@ export const Container = styled.div<{ ? { top: '50%', left: '50%', - transform: 'translate(-50%, -50%)', width: width ?? 740, height: height ?? 'auto', maxWidth: 'calc(100% - 40px)', maxHeight: '85vh', - animation: - $status === 'exiting' - ? `${zoomOut} ${$transitionDuration}ms` - : `${zoomIn} ${$transitionDuration}ms`, + '@media (prefers-reduced-motion: no-preference)': { + willChange: 'transform, opacity', + animationTimingFunction: 'cubic-bezier(0.32, 0.72, 0, 1)', + animation: + $status === 'exiting' || $status === 'preExit' + ? `${zoomOut} ${$transitionDuration}ms` + : `${zoomIn} ${$transitionDuration}ms`, + animationFillMode: 'forwards !important', + }, + '@media (prefers-reduced-motion: reduce)': { + transform: 'translate(-50%, -50%) scale(1)', + }, } : { bottom: '0', @@ -128,11 +138,17 @@ export const Container = styled.div<{ width: width ?? '100%', height: height ?? '80%', maxWidth: '100%', - maxHeight: '85vh', - animation: - $status === 'exiting' - ? `${slideToBottom} ${$transitionDuration}ms` - : `${slideFromBottom} ${$transitionDuration}ms`, + maxHeight: '80vh', + '@media (prefers-reduced-motion: no-preference)': { + animationTimingFunction: 'cubic-bezier(0.32, 0.72, 0, 1)', + animation: + $status === 'exiting' || $status === 'preExit' + ? `${slideToBottom} ${$transitionDuration}ms` + : $status === 'entering' || $status === 'preEnter' + ? `${slideFromBottom} ${$transitionDuration}ms` + : 'none', + animationFillMode: 'forwards !important', + }, } ); @@ -198,10 +214,13 @@ export const Col = styled.div({ gap: 4, }); -export const Header = (props: React.ComponentProps) => ( +export const Header = ({ + hasClose = true, + ...props +}: React.ComponentProps & { hasClose?: boolean }) => ( - + {hasClose && } ); @@ -229,7 +248,9 @@ export const Actions = styled.div({ export const ErrorWrapper = styled.div(({ theme }) => ({ maxHeight: 100, overflow: 'auto', - animation: `${expand} 300ms, ${fadeIn} 300ms`, + '@media (prefers-reduced-motion: no-preference)': { + animation: `${expand} 300ms, ${fadeIn} 300ms`, + }, backgroundColor: theme.background.critical, color: theme.color.lightest, fontSize: theme.typography.size.s2, diff --git a/code/core/src/components/components/Modal/Modal.tsx b/code/core/src/components/components/Modal/Modal.tsx index e02240b8e208..dda4a5a22d82 100644 --- a/code/core/src/components/components/Modal/Modal.tsx +++ b/code/core/src/components/components/Modal/Modal.tsx @@ -6,6 +6,7 @@ import { UNSAFE_PortalProvider } from 'react-aria'; import { Dialog, ModalOverlay, Modal as ModalUpstream } from 'react-aria-components'; import { useTransitionState } from 'react-transition-state'; +import { useMediaQuery } from '../../../manager/hooks/useMedia'; import * as Components from './Modal.styled'; interface ModalProps extends HTMLAttributes { @@ -64,14 +65,22 @@ function BaseModal({ variant = 'dialog', ...props }: ModalProps) { + const reducedMotion = useMediaQuery('(prefers-reduced-motion: reduce)'); const [{ status, isMounted }, toggle] = useTransitionState({ - timeout: 200, + timeout: reducedMotion ? 0 : transitionDuration, mountOnEnter: true, unmountOnExit: true, - enter: true, - exit: true, }); + const close = () => { + handleOpenChange(false); + }; + + const handleOpenChange = (isOpen: boolean) => { + toggle(isOpen); + onOpenChange?.(isOpen); + }; + // Sync external open state with transition state useEffect(() => { const shouldBeOpen = open ?? defaultOpen ?? false; @@ -82,24 +91,15 @@ function BaseModal({ } }, [open, defaultOpen, isMounted, toggle]); - const close = () => { - handleOpenChange(false); - }; - - const handleOpenChange = (isOpen: boolean) => { - toggle(isOpen); - onOpenChange?.(isOpen); - }; - // Call onOpenChange ourselves when the modal is initially opened, as react-aria won't. useEffect(() => { - if (open || defaultOpen) { + if (isMounted && (open || defaultOpen)) { onOpenChange?.(true); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [isMounted]); - if (!isMounted) { + if (!isMounted || status === 'exited' || status === 'unmounted') { return null; } From 2dacde404ac346caa7ac185580b55bbc56c0e995 Mon Sep 17 00:00:00 2001 From: Steve Dodier-Lazaro Date: Sun, 21 Sep 2025 14:30:22 +0200 Subject: [PATCH 11/15] UI: Fix story tests --- .../components/Modal/Modal.stories.tsx | 19 +++++++--- .../Popover/WithPopover.stories.tsx | 23 ++++++------ .../components/shared/overlayHelpers.tsx | 25 +++++++++---- .../navigation/MobileNavigation.stories.tsx | 36 +++++++++++++------ .../components/sidebar/ContextMenu.tsx | 2 +- .../components/sidebar/Refs.stories.tsx | 4 +-- .../components/sidebar/Tree.stories.tsx | 17 ++++----- 7 files changed, 82 insertions(+), 44 deletions(-) diff --git a/code/core/src/components/components/Modal/Modal.stories.tsx b/code/core/src/components/components/Modal/Modal.stories.tsx index d41e032a7e55..a88efb44d369 100644 --- a/code/core/src/components/components/Modal/Modal.stories.tsx +++ b/code/core/src/components/components/Modal/Modal.stories.tsx @@ -3,7 +3,7 @@ import React, { useState } from 'react'; import { Button } from 'storybook/internal/components'; import { action } from 'storybook/actions'; -import { expect, fn, screen, userEvent, within } from 'storybook/test'; +import { expect, fn, screen, userEvent, waitFor, within } from 'storybook/test'; import preview from '../../../../../.storybook/preview'; import { Modal } from './Modal'; @@ -430,7 +430,7 @@ export const InteractiveKeyboard = meta.story({ await expect(screen.queryByText('Sample Modal')).toBeInTheDocument(); }); - await step('Navigate through modal content with Tab', async () => { + await step('Navigate through modal content with focus trap', async () => { await userEvent.tab(); const closeButton = await screen.findByRole('button', { name: 'Close modal' }); await expect(closeButton).toHaveFocus(); @@ -453,7 +453,10 @@ export const InteractiveKeyboard = meta.story({ await step('Close modal with Escape key', async () => { await userEvent.keyboard('{Escape}'); - await expect(screen.queryByText('Sample Modal')).not.toBeInTheDocument(); + }); + + await step('Await exit animation and check modal is closed', async () => { + await waitFor(() => expect(screen.queryByText('Sample Modal')).not.toBeInTheDocument()); }); }, }); @@ -489,7 +492,10 @@ export const InteractiveMouse = meta.story({ await step('Click close button', async () => { const closeButton = await screen.findByLabelText('Close modal'); await userEvent.click(closeButton); - await expect(screen.queryByText('Sample Modal')).not.toBeInTheDocument(); + }); + + await step('Await exit animation and check modal is closed', async () => { + await waitFor(() => expect(screen.queryByText('Sample Modal')).not.toBeInTheDocument()); }); await step('Open modal and click outside to close', async () => { @@ -499,7 +505,10 @@ export const InteractiveMouse = meta.story({ const outsideButton = canvas.getByText('Outside Button'); await userEvent.click(outsideButton); - await expect(screen.queryByText('Sample Modal')).not.toBeInTheDocument(); + }); + + await step('Await exit animation and check modal is closed', async () => { + await waitFor(() => expect(screen.queryByText('Sample Modal')).not.toBeInTheDocument()); }); }, }); diff --git a/code/core/src/components/components/Popover/WithPopover.stories.tsx b/code/core/src/components/components/Popover/WithPopover.stories.tsx index 2bfeb712cfd7..2a43bb560495 100644 --- a/code/core/src/components/components/Popover/WithPopover.stories.tsx +++ b/code/core/src/components/components/Popover/WithPopover.stories.tsx @@ -1,6 +1,6 @@ import React from 'react'; -import { expect, fn, screen, userEvent, within } from 'storybook/test'; +import { expect, fn, userEvent, within } from 'storybook/test'; import { styled } from 'storybook/theming'; import preview from '../../../../../.storybook/preview'; @@ -151,8 +151,10 @@ export const AlwaysOpen = meta.story({ popover: , placement: 'right-start', }, - play: async () => { - await expect(await screen.findByText('Lorem ipsum dolor sit')).toBeInTheDocument(); + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const popover = canvas.getByText('Lorem ipsum dolor sit amet'); + await expect(popover).toBeInTheDocument(); }, }); @@ -163,8 +165,9 @@ export const NeverOpen = meta.story({ popover: , placement: 'right-start', }, - play: async () => { - await expect(screen.queryByText('Lorem ipsum dolor sit')).not.toBeInTheDocument(); + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect(canvas.queryByText('Lorem ipsum dolor sit')).not.toBeInTheDocument(); }, }); @@ -198,18 +201,18 @@ export const InteractivePopoverKB = meta.story({ await step('Open popover', async () => { trigger.focus(); await userEvent.keyboard('{Enter}'); - await expect(screen.queryByText('Lorem ipsum dolor sit amet')).toBeInTheDocument(); + await expect(canvas.queryByText('Lorem ipsum dolor sit amet')).toBeInTheDocument(); }); await step('Press Tab to enter popover', async () => { await userEvent.tab(); - const continueButton = await screen.findByText('Continue'); + const continueButton = await canvas.findByText('Continue'); await expect(continueButton).toHaveFocus(); }); await step('Press Esc to close popover', async () => { await userEvent.keyboard('{Escape}'); - await expect(screen.queryByText('Lorem ipsum dolor sit amet')).not.toBeInTheDocument(); + await expect(canvas.queryByText('Lorem ipsum dolor sit amet')).not.toBeInTheDocument(); }); }, }); @@ -231,13 +234,13 @@ export const InteractivePopoverMouse = meta.story({ await step('Open popover', async () => { const trigger = canvas.getByText('Click me!'); await userEvent.click(trigger); - await expect(screen.queryByText('Lorem ipsum dolor sit amet')).toBeInTheDocument(); + await expect(canvas.queryByText('Lorem ipsum dolor sit amet')).toBeInTheDocument(); }); await step('Click outside popover to close it', async () => { const sibling = canvas.getByText('Sibling Button'); await userEvent.click(sibling); - await expect(screen.queryByText('Lorem ipsum dolor sit amet')).not.toBeInTheDocument(); + await expect(canvas.queryByText('Lorem ipsum dolor sit amet')).not.toBeInTheDocument(); }); }, }); diff --git a/code/core/src/components/components/shared/overlayHelpers.tsx b/code/core/src/components/components/shared/overlayHelpers.tsx index cb1864881cc6..53ce417a30c7 100644 --- a/code/core/src/components/components/shared/overlayHelpers.tsx +++ b/code/core/src/components/components/shared/overlayHelpers.tsx @@ -1,9 +1,10 @@ -import React from 'react'; +import React, { useState } from 'react'; import type { DecoratorFunction } from 'storybook/internal/csf'; import type { PositionProps } from '@react-types/overlays'; import memoize from 'memoizerific'; +import { UNSAFE_PortalProvider } from 'react-aria'; import { styled } from 'storybook/theming'; type BasicPlacement = 'top' | 'bottom' | 'left' | 'right'; @@ -53,7 +54,7 @@ const Container = styled.div({ }); // Story helper -export const Trigger = styled.button({ +export const Trigger = styled('button')({ width: 120, height: 50, margin: 10, @@ -63,7 +64,19 @@ export const Trigger = styled.button({ }, }); -// Story helper -export const OverlayTriggerDecorator: DecoratorFunction = (storyFn) => ( - {storyFn()} -); +/** + * Storybook decorator to help render WithPopover in stories. Internal to Storybook. Use at your own + * risk. + */ +export const OverlayTriggerDecorator: DecoratorFunction = (Story, { args }) => { + const [container, setContainer] = useState(null); + + return ( + + container}> + + +
setContainer(element ?? null)}>
+
+ ); +}; diff --git a/code/core/src/manager/components/mobile/navigation/MobileNavigation.stories.tsx b/code/core/src/manager/components/mobile/navigation/MobileNavigation.stories.tsx index 74bfdc9bfb6f..7696f2aeb9b1 100644 --- a/code/core/src/manager/components/mobile/navigation/MobileNavigation.stories.tsx +++ b/code/core/src/manager/components/mobile/navigation/MobileNavigation.stories.tsx @@ -4,11 +4,27 @@ import type { Meta, StoryObj } from '@storybook/react-vite'; import { startCase } from 'es-toolkit/string'; import { ManagerContext } from 'storybook/manager-api'; -import { within } from 'storybook/test'; +import { screen, within } from 'storybook/test'; import { LayoutProvider, useLayout } from '../../layout/LayoutProvider'; import { MobileNavigation } from './MobileNavigation'; +const MockMenu = () => { + const { setMobileMenuOpen } = useLayout(); + return ( +
+ menu + +
+ ); +}; + const MockPanel = () => { const { setMobilePanelOpen } = useLayout(); return ( @@ -82,7 +98,7 @@ const meta = { chromatic: { viewports: [320] }, }, args: { - menu:
navigation menu
, + menu: , panel: , showPanel: true, }, @@ -145,8 +161,8 @@ export const LongStoryName: Story = { export const MenuOpen: Story = { play: async ({ canvasElement }) => { - const menuOpen = await within(canvasElement).getByLabelText('Open navigation menu'); - await menuOpen.click(); + const menuOpen = within(canvasElement).getByLabelText('Open navigation menu'); + menuOpen.click(); }, }; @@ -155,15 +171,15 @@ export const MenuClosed: Story = { // @ts-expect-error (non strict) await MenuOpen.play(context); await new Promise((resolve) => setTimeout(resolve, 500)); - const overlay = await within(context.canvasElement).getByLabelText('Close navigation menu'); - await overlay.click(); + const overlay = screen.getByLabelText('Close navigation menu'); + overlay.click(); }, }; export const PanelOpen: Story = { play: async ({ canvasElement }) => { - const panelButton = await within(canvasElement).getByLabelText('Open addon panel'); - await panelButton.click(); + const panelButton = within(canvasElement).getByLabelText('Open addon panel'); + panelButton.click(); }, }; @@ -172,8 +188,8 @@ export const PanelClosed: Story = { // @ts-expect-error (non strict) await PanelOpen.play(context); await new Promise((resolve) => setTimeout(resolve, 500)); - const closeButton = await within(context.canvasElement).getByLabelText('Close addon panel'); - await closeButton.click(); + const closeButton = screen.getByLabelText('Close addon panel'); + closeButton.click(); }, }; diff --git a/code/core/src/manager/components/sidebar/ContextMenu.tsx b/code/core/src/manager/components/sidebar/ContextMenu.tsx index fa7af520750a..fafcb7ed587f 100644 --- a/code/core/src/manager/components/sidebar/ContextMenu.tsx +++ b/code/core/src/manager/components/sidebar/ContextMenu.tsx @@ -123,7 +123,6 @@ export const useContextMenu = (context: API_HashEntry, links: Link[], api: API) node: shouldRender ? ( Errored(), play: async ({ canvasElement }) => { const canvas = within(canvasElement); - const button = canvas.getByText('Extra actions'); + const button = canvas.getByRole('button', { name: 'Extra actions' }); button.click(); }, }; @@ -292,7 +292,7 @@ export const ErroredMobileWithIndicatorOpen: StoryAnnotations = { globals: { sb_theme: 'stacked', viewport: { value: 'mobile1' } }, play: async ({ canvasElement }) => { const canvas = within(canvasElement); - const button = canvas.getByText('Extra actions'); + const button = canvas.getByRole('button', { name: 'Extra actions' }); button.click(); }, }; diff --git a/code/core/src/manager/components/sidebar/Tree.stories.tsx b/code/core/src/manager/components/sidebar/Tree.stories.tsx index beaffabf02bb..02c9f6f5b099 100644 --- a/code/core/src/manager/components/sidebar/Tree.stories.tsx +++ b/code/core/src/manager/components/sidebar/Tree.stories.tsx @@ -10,7 +10,7 @@ import type { Meta, StoryObj } from '@storybook/react-vite'; import { action } from 'storybook/actions'; import { type ComponentEntry, type IndexHash, ManagerContext } from 'storybook/manager-api'; -import { expect, fn, userEvent, within } from 'storybook/test'; +import { expect, fn, screen, userEvent, within } from 'storybook/test'; import { DEFAULT_REF_ID } from './Sidebar'; import { Tree } from './Tree'; @@ -299,19 +299,16 @@ export const WithContextContent: Story = { viewport: { value: 'desktop' }, }, play: async ({ canvasElement }) => { - const screen = await within(canvasElement); + const canvas = within(canvasElement); - const link = await screen.findByText('TooltipBuildList'); + const link = await canvas.findByText('TooltipBuildList'); await userEvent.hover(link); - const contextButton = await screen.findAllByTestId('context-menu'); + const contextButton = await canvas.findAllByTestId('context-menu'); await userEvent.click(contextButton[0]); - const body = await within(document.body); - - const tooltip = await body.findByTestId('tooltip'); - - await expect(tooltip).toBeVisible(); - expect(tooltip).toHaveTextContent('TEST_PROVIDER_CONTEXT_CONTENT'); + const popover = screen.getByRole('dialog'); + await expect(popover).toBeVisible(); + expect(popover).toHaveTextContent('TEST_PROVIDER_CONTEXT_CONTENT'); }, }; From 528848b5f042c0546e78eaad712985dbb7b71357 Mon Sep 17 00:00:00 2001 From: Steve Dodier-Lazaro Date: Sun, 21 Sep 2025 17:28:59 +0200 Subject: [PATCH 12/15] Tests: Fix dialog and context-menu E2E test locators --- code/e2e-tests/addon-onboarding.spec.ts | 4 ++-- .../react/e2e-tests/component-testing.spec.ts | 10 +++++----- .../react/e2e-tests/save-from-controls.spec.ts | 2 +- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/code/e2e-tests/addon-onboarding.spec.ts b/code/e2e-tests/addon-onboarding.spec.ts index 06840cfc3858..aac9498b7f48 100644 --- a/code/e2e-tests/addon-onboarding.spec.ts +++ b/code/e2e-tests/addon-onboarding.spec.ts @@ -35,14 +35,14 @@ test.describe('addon-onboarding', () => { // so we just create a random id to make it easier to run tests const id = Math.random().toString(36).substring(7); await page.getByPlaceholder('Story export name').fill('Test-' + id); - await page.getByRole('button', { name: 'Create' }).click(); + await page.getByRole('button', { exact: true, name: 'Create' }).click(); await expect(page.getByText('You just added your first')).toBeVisible(); await page.getByLabel('Last').click(); await page.getByRole('checkbox', { name: 'Application UI' }).check(); await page.getByRole('checkbox', { name: 'Functional testing' }).check(); - await page.getByRole('combobox').selectOption('Web Search'); + await page.locator('#referrer').selectOption('Web Search'); await page.getByRole('button', { name: 'Submit' }).click(); await expect( diff --git a/test-storybooks/portable-stories-kitchen-sink/react/e2e-tests/component-testing.spec.ts b/test-storybooks/portable-stories-kitchen-sink/react/e2e-tests/component-testing.spec.ts index 4305bcbfbe1a..292eecf90d65 100644 --- a/test-storybooks/portable-stories-kitchen-sink/react/e2e-tests/component-testing.spec.ts +++ b/test-storybooks/portable-stories-kitchen-sink/react/e2e-tests/component-testing.spec.ts @@ -510,7 +510,7 @@ test.describe("component testing", () => { .hover(); await page .locator( - '[data-item-id="addons-group-test--expected-failure"] div[data-testid="context-menu"] button' + '[data-item-id="addons-group-test--expected-failure"] button[data-testid="context-menu"]' ) .click(); const sidebarContextMenu = page.getByTestId("tooltip"); @@ -550,7 +550,7 @@ test.describe("component testing", () => { await page.locator('[data-item-id="example-unhandlederrors"]').hover(); await page .locator( - '[data-item-id="example-unhandlederrors"] div[data-testid="context-menu"] button' + '[data-item-id="example-unhandlederrors"] button[data-testid="context-menu"]' ) .click(); const sidebarContextMenu = page.getByTestId("tooltip"); @@ -597,7 +597,7 @@ test.describe("component testing", () => { await page.locator('[data-item-id="addons-group-test"]').hover(); await page .locator( - '[data-item-id="addons-group-test"] div[data-testid="context-menu"] button' + '[data-item-id="addons-group-test"] button[data-testid="context-menu"]' ) .click(); const sidebarContextMenu = page.getByTestId("tooltip"); @@ -645,7 +645,7 @@ test.describe("component testing", () => { await page.locator('[data-item-id="addons-group"]').hover(); await page .locator( - '[data-item-id="addons-group"] div[data-testid="context-menu"] button' + '[data-item-id="addons-group"] button[data-testid="context-menu"]' ) .click(); const sidebarContextMenu = page.getByTestId("tooltip"); @@ -703,7 +703,7 @@ test.describe("component testing", () => { .hover(); await page .locator( - '[data-item-id="example-button--csf-3-primary"] div[data-testid="context-menu"] button' + '[data-item-id="example-button--csf-3-primary"] button[data-testid="context-menu"]' ) .click(); const sidebarContextMenu = page.getByTestId("tooltip"); diff --git a/test-storybooks/portable-stories-kitchen-sink/react/e2e-tests/save-from-controls.spec.ts b/test-storybooks/portable-stories-kitchen-sink/react/e2e-tests/save-from-controls.spec.ts index 21341d1276e8..0e093e00d614 100644 --- a/test-storybooks/portable-stories-kitchen-sink/react/e2e-tests/save-from-controls.spec.ts +++ b/test-storybooks/portable-stories-kitchen-sink/react/e2e-tests/save-from-controls.spec.ts @@ -77,7 +77,7 @@ test.describe("save-from-controls", () => { await sbPage.page .getByPlaceholder("Story export name") .fill("ClonedStory" + id); - await sbPage.page.getByRole("button", { name: "Create" }).click(); + await sbPage.page.getByRole("button", { exact: true, name: "Create" }).click(); // Assert the file is saved const notification2 = sbPage.page.getByTitle("Story created"); From 1f1b8ba6b997c7b4e6a2e33778550f826b30fe0c Mon Sep 17 00:00:00 2001 From: Steve Dodier-Lazaro Date: Sun, 21 Sep 2025 17:43:04 +0200 Subject: [PATCH 13/15] Tests: Increase loading timeout duration on flaky test --- code/core/src/manager/components/sidebar/Menu.stories.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/code/core/src/manager/components/sidebar/Menu.stories.tsx b/code/core/src/manager/components/sidebar/Menu.stories.tsx index 924bb26eee5a..31c8d383d71a 100644 --- a/code/core/src/manager/components/sidebar/Menu.stories.tsx +++ b/code/core/src/manager/components/sidebar/Menu.stories.tsx @@ -79,8 +79,9 @@ export const Expanded: Story = { }, play: async ({ canvasElement }) => { const canvas = within(canvasElement); + // This story can have significant loading time. await new Promise((res) => { - setTimeout(res, 500); + setTimeout(res, 1000); }); const menuButton = await canvas.findByRole('switch'); await userEvent.click(menuButton); From f1551fee26a99d1c615bf5d9655c59c351177ee8 Mon Sep 17 00:00:00 2001 From: Steve Dodier-Lazaro Date: Sun, 21 Sep 2025 19:02:30 +0200 Subject: [PATCH 14/15] Tests: Make E2E tests close context menu when relevant --- .../react/e2e-tests/component-testing.spec.ts | 44 ++++++++++++++++--- 1 file changed, 38 insertions(+), 6 deletions(-) diff --git a/test-storybooks/portable-stories-kitchen-sink/react/e2e-tests/component-testing.spec.ts b/test-storybooks/portable-stories-kitchen-sink/react/e2e-tests/component-testing.spec.ts index 292eecf90d65..dc722b446cf5 100644 --- a/test-storybooks/portable-stories-kitchen-sink/react/e2e-tests/component-testing.spec.ts +++ b/test-storybooks/portable-stories-kitchen-sink/react/e2e-tests/component-testing.spec.ts @@ -504,7 +504,7 @@ test.describe("component testing", () => { .getByRole("button", { name: "test" }); await expect(storyElement).toBeVisible({ timeout: 30000 }); - // Act - Open sidebar context menu and start focused test + // Act - Open sidebar context menu, start focused test then close menu await page .locator('[data-item-id="addons-group-test--expected-failure"]') .hover(); @@ -513,7 +513,7 @@ test.describe("component testing", () => { '[data-item-id="addons-group-test--expected-failure"] button[data-testid="context-menu"]' ) .click(); - const sidebarContextMenu = page.getByTestId("tooltip"); + const sidebarContextMenu = page.getByRole('dialog'); await sidebarContextMenu.getByLabel("Start test run").click(); // Assert - Only one test is running and reported @@ -553,9 +553,17 @@ test.describe("component testing", () => { '[data-item-id="example-unhandlederrors"] button[data-testid="context-menu"]' ) .click(); - const sidebarContextMenu = page.getByTestId("tooltip"); + const sidebarContextMenu = page.getByRole('dialog'); await sidebarContextMenu.getByLabel("Start test run").click(); + // HACK: the testing module popover has poor tracking of focus due to how many disabled + // buttons it has and how deeply it changes its UI on events. This would be solved once + // we move to a declarative menu, and there's an ongoing PR for that. Until then, we tab + // around to reset focus. + await page.keyboard.press('Tab'); + await page.keyboard.press('Escape'); + await expect(sidebarContextMenu).not.toBeVisible(); + // Assert - Tests are running and errors are reported const errorLink = page.locator( "#storybook-testing-module #testing-module-description a" @@ -600,9 +608,17 @@ test.describe("component testing", () => { '[data-item-id="addons-group-test"] button[data-testid="context-menu"]' ) .click(); - const sidebarContextMenu = page.getByTestId("tooltip"); + const sidebarContextMenu = page.getByRole('dialog'); await sidebarContextMenu.getByLabel("Start test run").click(); + // HACK: the testing module popover has poor tracking of focus due to how many disabled + // buttons it has and how deeply it changes its UI on events. This would be solved once + // we move to a declarative menu, and there's an ongoing PR for that. Until then, we tab + // around to reset focus. + await page.keyboard.press('Tab'); + await page.keyboard.press('Escape'); + await expect(sidebarContextMenu).not.toBeVisible(); + // Assert - Tests are running and reported await expect( sidebarContextMenu.locator("#testing-module-description") @@ -648,9 +664,17 @@ test.describe("component testing", () => { '[data-item-id="addons-group"] button[data-testid="context-menu"]' ) .click(); - const sidebarContextMenu = page.getByTestId("tooltip"); + const sidebarContextMenu = page.getByRole('dialog'); await sidebarContextMenu.getByLabel("Start test run").click(); + // HACK: the testing module popover has poor tracking of focus due to how many disabled + // buttons it has and how deeply it changes its UI on events. This would be solved once + // we move to a declarative menu, and there's an ongoing PR for that. Until then, we tab + // around to reset focus. + await page.keyboard.press('Tab'); + await page.keyboard.press('Escape'); + await expect(sidebarContextMenu).not.toBeVisible(); + // Assert - Tests are running and reported await expect( sidebarContextMenu.locator("#testing-module-description") @@ -706,9 +730,17 @@ test.describe("component testing", () => { '[data-item-id="example-button--csf-3-primary"] button[data-testid="context-menu"]' ) .click(); - const sidebarContextMenu = page.getByTestId("tooltip"); + const sidebarContextMenu = page.getByRole('dialog'); await sidebarContextMenu.getByLabel("Start test run").click(); + // HACK: the testing module popover has poor tracking of focus due to how many disabled + // buttons it has and how deeply it changes its UI on events. This would be solved once + // we move to a declarative menu, and there's an ongoing PR for that. Until then, we tab + // around to reset focus. + await page.keyboard.press('Tab'); + await page.keyboard.press('Escape'); + await expect(sidebarContextMenu).not.toBeVisible(); + // Arrange - Wait for test to finish and unfocus sidebar context menu await expect( sidebarContextMenu.locator("#testing-module-description") From 7159dd5346b78a047d719c41ca5e067c96394c58 Mon Sep 17 00:00:00 2001 From: Steve Dodier-Lazaro Date: Mon, 22 Sep 2025 19:01:05 +0200 Subject: [PATCH 15/15] Tests: Close testing module *after* reading result status --- .../react/e2e-tests/component-testing.spec.ts | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/test-storybooks/portable-stories-kitchen-sink/react/e2e-tests/component-testing.spec.ts b/test-storybooks/portable-stories-kitchen-sink/react/e2e-tests/component-testing.spec.ts index dc722b446cf5..fdafda129cfd 100644 --- a/test-storybooks/portable-stories-kitchen-sink/react/e2e-tests/component-testing.spec.ts +++ b/test-storybooks/portable-stories-kitchen-sink/react/e2e-tests/component-testing.spec.ts @@ -611,14 +611,6 @@ test.describe("component testing", () => { const sidebarContextMenu = page.getByRole('dialog'); await sidebarContextMenu.getByLabel("Start test run").click(); - // HACK: the testing module popover has poor tracking of focus due to how many disabled - // buttons it has and how deeply it changes its UI on events. This would be solved once - // we move to a declarative menu, and there's an ongoing PR for that. Until then, we tab - // around to reset focus. - await page.keyboard.press('Tab'); - await page.keyboard.press('Escape'); - await expect(sidebarContextMenu).not.toBeVisible(); - // Assert - Tests are running and reported await expect( sidebarContextMenu.locator("#testing-module-description") @@ -631,6 +623,14 @@ test.describe("component testing", () => { sidebarContextMenu.getByLabel("Component tests failed") ).toHaveCount(1); + // HACK: the testing module popover has poor tracking of focus due to how many disabled + // buttons it has and how deeply it changes its UI on events. This would be solved once + // we move to a declarative menu, and there's an ongoing PR for that. Until then, we tab + // around to reset focus. + await page.keyboard.press('Tab'); + await page.keyboard.press('Escape'); + await expect(sidebarContextMenu).not.toBeVisible(); + await page.click("body"); await expect( page