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/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/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..a88efb44d369 100644 --- a/code/core/src/components/components/Modal/Modal.stories.tsx +++ b/code/core/src/components/components/Modal/Modal.stories.tsx @@ -1,192 +1,669 @@ 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, waitFor, 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, + dismissOnClickOutside: false, + dismissOnEscape: false, + 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 focus trap', 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 step('Await exit animation and check modal is closed', async () => { + await waitFor(() => 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 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 () => { + 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 step('Await exit animation and check modal is closed', async () => { + await waitFor(() => 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..7a32d8cccfbf 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,162 @@ 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, + '@media (prefers-reduced-motion: no-preference)': { + animation: + $status === 'exiting' || $status === 'preExit' + ? `${fadeOut} ${$transitionDuration}ms` + : `${fadeIn} ${$transitionDuration}ms`, + animationFillMode: 'forwards', + }, +})); -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%', + width: width ?? 740, + height: height ?? 'auto', + maxWidth: 'calc(100% - 40px)', + maxHeight: '85vh', + '@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', + left: '0', + right: '0', + width: width ?? '100%', + height: height ?? '80%', + maxWidth: '100%', + 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', + }, + } ); -export const CloseButton = () => ( - - - -); + ); +}; export const Content = styled.div({ display: 'flex', @@ -89,20 +214,25 @@ export const Col = styled.div({ gap: 4, }); -export const Header = (props: React.ComponentProps) => ( +export const Header = ({ + hasClose = true, + ...props +}: React.ComponentProps & { hasClose?: boolean }) => ( - + {hasClose && } ); -export const Title = styled(Dialog.Title)(({ theme }) => ({ +export const Title = styled((props: ComponentProps) => ( + +))(({ 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, @@ -118,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 0939d6ad4d09..dda4a5a22d82 100644 --- a/code/core/src/components/components/Modal/Modal.tsx +++ b/code/core/src/components/components/Modal/Modal.tsx @@ -1,58 +1,161 @@ -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 { useMediaQuery } from '../../../manager/hooks/useMedia'; 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 reducedMotion = useMediaQuery('(prefers-reduced-motion: reduce)'); + const [{ status, isMounted }, toggle] = useTransitionState({ + timeout: reducedMotion ? 0 : transitionDuration, + mountOnEnter: true, + unmountOnExit: 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; + if (shouldBeOpen && !isMounted) { + toggle(true); + } else if (!shouldBeOpen && isMounted) { + toggle(false); + } + }, [open, defaultOpen, isMounted, toggle]); + + // Call onOpenChange ourselves when the modal is initially opened, as react-aria won't. + useEffect(() => { + if (isMounted && (open || defaultOpen)) { + onOpenChange?.(true); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isMounted]); + + if (!isMounted || status === 'exited' || status === 'unmounted') { + 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/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..2a43bb560495 --- /dev/null +++ b/code/core/src/components/components/Popover/WithPopover.stories.tsx @@ -0,0 +1,263 @@ +import React from 'react'; + +import { expect, fn, 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 ({ canvasElement }) => { + const canvas = within(canvasElement); + const popover = canvas.getByText('Lorem ipsum dolor sit amet'); + await expect(popover).toBeInTheDocument(); + }, +}); + +export const NeverOpen = meta.story({ + args: { + visible: false, + children: Never visible tooltip, + popover: , + placement: 'right-start', + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect(canvas.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(canvas.queryByText('Lorem ipsum dolor sit amet')).toBeInTheDocument(); + }); + + await step('Press Tab to enter popover', async () => { + await userEvent.tab(); + const continueButton = await canvas.findByText('Continue'); + await expect(continueButton).toHaveFocus(); + }); + + await step('Press Esc to close popover', async () => { + await userEvent.keyboard('{Escape}'); + await expect(canvas.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(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(canvas.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..53ce417a30c7 --- /dev/null +++ b/code/core/src/components/components/shared/overlayHelpers.tsx @@ -0,0 +1,82 @@ +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'; + +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', + }, +}); + +/** + * 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/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..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'; @@ -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/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/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/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.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/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/ContextMenu.tsx b/code/core/src/manager/components/sidebar/ContextMenu.tsx index 12981333e5d4..fafcb7ed587f 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/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/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); 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..afac15a944c0 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,49 @@ 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; + onClick?: MouseEventHandler; +}> = ({ href, blank = true, children, onClick }) => { + 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 +158,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 +190,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 +241,7 @@ const ReadyMessage: FC<{ const theme = useTheme(); return ( - +
View external Storybook @@ -254,7 +259,7 @@ const SourceCodeMessage: FC<{ const theme = useTheme(); return ( - +
View source code @@ -265,23 +270,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 +307,8 @@ const ReadDocsMessage: FC = () => { const theme = useTheme(); return ( - - + +
Read Composition docs
Learn how to combine multiple Storybooks into one.
@@ -312,7 +321,7 @@ const ErrorOccurredMessage: FC<{ url: string }> = ({ url }) => { const theme = useTheme(); return ( - +
Something went wrong @@ -326,7 +335,7 @@ const LoadingMessage: FC<{ url: string }> = ({ url }) => { const theme = useTheme(); return ( - +
Please wait @@ -341,10 +350,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..21cbe3736588 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.getByRole('button', { name: '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.getByRole('button', { name: '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..352ec588846f 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'; @@ -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 && { @@ -117,42 +113,36 @@ export const TagsFilter = ({ } return ( - <> - ( - - )} - closeOnOutsideClick + ( + + )} + > + - - - - - {selectedTags.length > 0 && } - - - + + {selectedTags.length > 0 && } + + ); }; 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'); }, }; diff --git a/code/core/src/manager/globals/exports.ts b/code/core/src/manager/globals/exports.ts index 702702080175..90cde0dcf35c 100644 --- a/code/core/src/manager/globals/exports.ts +++ b/code/core/src/manager/globals/exports.ts @@ -510,9 +510,11 @@ export default { 'ListItem', 'Loader', 'Modal', + 'ModalDecorator', 'OL', 'P', 'Placeholder', + 'Popover', 'Pre', 'ProgressSpinner', 'ResetWrapper', @@ -536,11 +538,13 @@ export default { 'TooltipMessage', 'TooltipNote', 'UL', + 'WithPopover', 'WithTooltip', 'WithTooltipPure', 'Zoom', 'codeCommon', 'components', + 'convertToReactAriaPlacement', 'createCopyToClipboardFunction', 'getStoryHref', 'interleaveSeparators', 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/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/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" 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..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 @@ -504,16 +504,16 @@ 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(); 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"); + const sidebarContextMenu = page.getByRole('dialog'); await sidebarContextMenu.getByLabel("Start test run").click(); // Assert - Only one test is running and reported @@ -550,12 +550,20 @@ 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"); + 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" @@ -597,10 +605,10 @@ 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"); + const sidebarContextMenu = page.getByRole('dialog'); await sidebarContextMenu.getByLabel("Start test run").click(); // Assert - Tests are running and reported @@ -615,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 @@ -645,12 +661,20 @@ 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"); + 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") @@ -703,12 +727,20 @@ 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"); + 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") 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");