diff --git a/MIGRATION.md b/MIGRATION.md index 802315c045bf..ba81b2731579 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -1,5 +1,38 @@

Migration

+- [From version 10.0.0 to 10.1.0](#from-version-1000-to-1010) + - [API and Component Changes](#api-and-component-changes) + - [Button Component API Changes](#button-component-api-changes) + - [Added: ariaLabel](#added-arialabel) + - [Added: shortcut](#added-shortcut) + - [Added: tooltip](#added-tooltip) + - [Deprecated: active](#deprecated-active) + - [IconButton is deprecated](#iconbutton-is-deprecated) + - [Bar Component API Changes](#bar-component-api-changes) + - [Added: innerStyle](#added-innerstyle) + - [FlexBar is deprecated](#flexbar-is-deprecated) + - [Tabs is deprecated](#tabs-is-deprecated) + - [TabsState is deprecated](#tabsstate-is-deprecated) + - [TabWrapper is deprecated](#tabwrapper-is-deprecated) + - [TabButton is deprecated](#tabbutton-is-deprecated) + - [TabBar is deprecated](#tabbar-is-deprecated) + - [Modal Component API Changes](#modal-component-api-changes) + - [Deprecated: onInteractOutside](#deprecated-oninteractoutside) + - [Deprecated: onEscapeKeyDown](#deprecated-onescapekeydown) + - [Added: `ariaLabel`](#added-arialabel-1) + - [Renamed: Modal.Dialog.Close and Modal.CloseButton](#renamed-modaldialogclose-and-modalclosebutton) + - [ListItem, TooltipLinkList and TooltipMessage are deprecated](#listitem-tooltiplinklist-and-tooltipmessage-are-deprecated) + - [PopoverProvider Component Added](#popoverprovider-component-added) + - [WithTooltip Component API Changes](#withtooltip-component-api-changes) + - [Removed: trigger](#removed-trigger) + - [Added: triggerOnFocusOnly](#added-triggeronfocusonly) + - [Renamed: startOpen](#renamed-startopen) + - [Removed: svg, strategy, withArrows, mutationObserverOptions](#removed-svg-strategy-witharrows-mutationobserveroptions) + - [Removed: hasChrome](#removed-haschrome) + - [Removed: closeOnTriggerHidden, followCursor, closeOnOutsideClick](#removed-closeontriggerhidden-followcursor-closeonoutsideclick) + - [Removed: interactive](#removed-interactive) + - [Other changes](#other-changes) + - [WithTooltipPure and WithTooltipState are deprecated](#withtooltippure-and-withtooltipstate-are-deprecated) - [From version 9.x to 10.0.0](#from-version-9x-to-1000) - [Core Changes](#core-changes) - [Local addons must be fully resolved](#local-addons-must-be-fully-resolved) @@ -26,8 +59,8 @@ - [Viewport/Backgrounds Addon synchronized configuration and `globals` usage](#viewportbackgrounds-addon-synchronized-configuration-and-globals-usage) - [Storysource Addon removed](#storysource-addon-removed) - [Mdx-gfm Addon removed](#mdx-gfm-addon-removed) - - [API and Component Changes](#api-and-component-changes) - - [Button Component API Changes](#button-component-api-changes) + - [API and Component Changes](#api-and-component-changes-1) + - [Button Component API Changes](#button-component-api-changes-1) - [Icon System Updates](#icon-system-updates) - [Sidebar Component Changes](#sidebar-component-changes) - [Story Store API Changes](#story-store-api-changes) @@ -485,6 +518,142 @@ - [Packages renaming](#packages-renaming) - [Deprecated embedded addons](#deprecated-embedded-addons) + +## From version 10.0.0 to 10.1.0 + +### API and Component Changes + +#### Button Component API Changes + +##### Added: ariaLabel +The Button component now has an `ariaLabel` prop, to ensure that Storybook UI code is accessible to screenreader users. The prop will become mandatory in Storybook 11. + +When buttons have text content as children, and when that text content does not rely on visual context to be understood, you may pass `false` to the `ariaLabel` prop to indicate that an ARIA label is not necessary. + +In every other case (your Button only contains an icon, has a responsive layout that can hide its text, or relies on visual context to make sense), you must pass a label to `ariaLabel`, which screenreaders will read. The label should be short and start with an action verb. + +##### Added: shortcut + +An optional `shortcut` prop was added for internal use. When `shortcut` is set, the Button will be appended with a human-readable string for the shortcut, and the `aria-keyshortcuts` prop will be set. + +##### Added: tooltip + +Button now displays a tooltip whenever `ariaLabel` or `shortcut` is set. The tooltip can be customised by passing a string to the optional `tooltip` prop. + +##### Deprecated: active + +The `active` prop is deprecated and will be removed in Storybook 11. + +The Button component has historically been used to implement Toggle and Select interactions. When you need a Button to have an active state, use ToggleButton if the active state denotes that a state or feature is enabled after pressing the Button. Use Select if the active state denotes that the Button is open while a selection is being made, or that the Button currently has a selected value. + +#### IconButton is deprecated + +The IconButton component is deprecated, as it overlaps with Button. Instead, use Button with the `'ghost'` variant and `'small'` padding, and add an `ariaLabel` prop for screenreaders to announce. + +IconButton will be removed in future versions. + +#### Bar Component API Changes + +The `Bar` component's internal layout has changed, to fix a height bug in scrollable bars. It now applies flex positioning and applies a default item gap, that can be controlled with the `innerStyle` prop. You may see slight changes in default padding as a result of this change. + +##### Added: innerStyle +When `scrollable` is set to `true`, `Bar` now adds an inner container that is used to ensure the scrollbar size does not impact the height of the bar. This inner container displays as 'flex' and has the following default style: + +```css + width: 100%; + min-height: 40; + display: flex; + align-items: center; + gap: 6px; + padding-inline: 6px; +``` + +The inner container's style can be overridden by passing CSS properties to `innerStyle`. + +#### FlexBar is deprecated + +The `FlexBar` component is deprecated. Instead, use the `Bar` component and apply `justifyContent: 'space-between'` through the `innerStyle` prop. + +#### Tabs is deprecated + +The `Tabs` component is deprecated as it was not accessible. Instead, use the new `TabsView` component or `TabList` and `TabPanel` with the `useTabsState` hook. Note that `TabsView` does not support mixing HTML links and tabs. + +#### TabsState is deprecated + +The `TabsState` class is deprecated as it was not accessible. Instead, use the new `TabsView` component or `TabList` and `TabPanel` with the `useTabsState` hook. Note that `TabsView` does not support mixing HTML links and tabs. + +#### TabWrapper is deprecated + +The `TabWrapper` component is deprecated as it was not accessible. Instead, use the new `TabsView` component or `TabList` and `TabPanel` with the `useTabsState` hook. Note that `TabsView` does not support mixing HTML links and tabs. + +#### TabButton is deprecated + +The `TabButton` class is deprecated as it was not accessible. It does not have a replacement, as the new `TabList` component handles tab buttons internally. + +#### TabBar is deprecated + +The `TabBar` component, a styled bar used inside `Tabs` and not intended to be public, is deprecated and will be hidden in Storybook 11. Use `TabsView` instead. + +#### Modal Component API Changes + +##### Deprecated: onInteractOutside +The `onInteractOutside` prop is deprecated 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. + +##### Deprecated: onEscapeKeyDown +The `onEscapeKeyDown` prop is deprecated 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 `ariaLabel` prop. It will become mandatory in Storybook 11. + +##### 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. Those names are deprecated and will be removed in Storybook 11. 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. +#### ListItem, TooltipLinkList and TooltipMessage are deprecated + +The ListItem and TooltipLinkList components were used in Storybook to make menus, and TooltipMessage to make message popovers. However, WithTooltip does not support keyboard interactions, so these components were not accessible. + +These components are now deprecated and will be removed in future versions. To replace TooltipMessage, replace WithTooltip with PopoverProvider, and use Popover as a base component for your popovers. To replace ListItem and TooltipLinkList, a dedicated menu component will be introduced in a future version, and Popover can be used in the meantime. + +#### PopoverProvider Component Added + +The PopoverProvider component acts as a counterpoint to WithTooltip. When you want an interactive overlay with buttons or inputs, use PopoverProvider and Popover. When you want a static overlay that shows on focus or hover, use WithTooltip with TooltipNote or Tooltip. + +PopoverProvider is based on react-aria. It must have a single child that acts as a trigger. This child must have a pressable role (can be clicked or pressed) and must be able to receive React refs. Wrap your trigger component in `forwardRef` if you notice placement issues for your popover. + +#### WithTooltip Component API Changes + +The WithTooltip component has been reimplemented from the ground up, under the new name `TooltipProvider`. The new implementation will replace `WithTooltip` entirely in Storybook 11. Below is a summary of the changes between both APIs, which will take full effect in Storybook 11. + +##### Removed: trigger +The `trigger` prop was removed to enforce better accessibility compliance. WithTooltip must not be triggered on click, as it is not reachable by keyboard. Buttons that open a popover, menu or select must use appropriate components instead. + +#### Added: triggerOnFocusOnly +The `triggerOnFocusOnly` prop was added. When set, tooltips will only show on focus. Use this to provide keyboard navigation hints to keyboard users. Do not use it for other purposes. + +#### Renamed: startOpen +The `startOpen` prop was renamed `defaultVisible` to match naming in other components that expose both controlled and uncontrolled visibility. The `startOpen` prop will be removed in future versions. + +#### Removed: svg, strategy, withArrows, mutationObserverOptions +These prop were not used inside Storybook and have been removed. + +#### Removed: hasChrome +The `hasChrome` prop was removed because it should be handled by the tooltip being shown instead. Popover and Tooltip both have a `hasChrome` prop. TooltipNote never needs this prop and does not have it. + +#### Removed: closeOnTriggerHidden, followCursor, closeOnOutsideClick +The `closeOnTriggerHidden`, `followCursor` and `closeOnOutsideClick` prop has been removed. WithTooltip will now authoritatively decide when and where to show or hide its tooltip. It will always close on clicks outside the tooltip, because tooltips should never be modal. + +#### Removed: interactive +Thed `interactive` prop has been removed as it does not align with our vision for accessible components with a well-defined role. Use PopoverProvider instead of WithTooltip to show interactive overlays. + +##### Other changes +The underlying implementation was switched from Popper.js to react-aria. Due to these changes, WithTooltip must now have a single child that has a focusable role and that can receive React refs. Wrap your trigger component in `forwardRef` if you notice placement issues for your tooltip. + +#### WithTooltipPure and WithTooltipState are deprecated + +Instead, use `WithTooltipNew` in Storybook 10, or `WithTooltip` in Storybook 11 or newer. For a controlled tooltip, use the `onVisibleChange` and `visible` props. For an uncontrolled tooltip with a default open state, use the `defaultVisible` prop. + + ## From version 9.x to 10.0.0 ### Core Changes @@ -1265,7 +1434,7 @@ Key changes: #### Angular: Introduce `features.angularFilterNonInputControls` -Storybook has added a new feature flag `angularFilterNonInputControls` which filters out non-input controls from Angular compoennts in Storybook's controls panel. +Storybook has added a new feature flag `angularFilterNonInputControls` which filters out non-input controls from Angular components in Storybook's controls panel. To enable it, just set the feature flag in your `.storybook/main. file. diff --git a/code/.eslintrc.js b/code/.eslintrc.js index 1c00177e9073..288b268ddc3d 100644 --- a/code/.eslintrc.js +++ b/code/.eslintrc.js @@ -38,6 +38,24 @@ module.exports = { message: 'Please dynamically import from vite instead, to force the use of ESM', allowTypeImports: true, }, + { + name: 'react-aria', + message: + "Don't import from react-aria directly, please use the specific submodule like @react-aria/overlays instead", + allowTypeImports: false, + }, + { + name: 'react-stately', + message: + "Don't import from react-stately directly, please use the specific submodule like @react-stately/overlays instead", + allowTypeImports: false, + }, + { + name: 'react-aria-components', + message: + "Don't import from react-aria-components root, but use the react-aria-components/patched-dist/ComponentX entrypoints which are optimised for tree-shaking. Might require addition patching of the package if using new, unpatched components. See https://github.com/storybookjs/storybook/pull/32594", + allowTypeImports: true, + }, ], }, ], diff --git a/code/.storybook/preview.tsx b/code/.storybook/preview.tsx index dc335c620322..89d60281caab 100644 --- a/code/.storybook/preview.tsx +++ b/code/.storybook/preview.tsx @@ -387,8 +387,8 @@ const parameters = { }, backgrounds: { options: { - light: { name: 'light', value: '#edecec' }, - dark: { name: 'dark', value: '#262424' }, + light: { name: 'light', value: '#F6F9FC' }, + dark: { name: 'dark', value: '#1B1C1D' }, blue: { name: 'blue', value: '#1b1a2c' }, }, grid: { diff --git a/code/.yarn/patches/react-aria-components-npm-1.12.2-6c5dcdafab.patch b/code/.yarn/patches/react-aria-components-npm-1.12.2-6c5dcdafab.patch new file mode 100644 index 000000000000..0458cad4eee6 --- /dev/null +++ b/code/.yarn/patches/react-aria-components-npm-1.12.2-6c5dcdafab.patch @@ -0,0 +1,171 @@ +diff --git a/dist/Button.mjs b/dist/Button.mjs +index cbc78bd98aa1b40634ff878b3039fd4aac636ce6..e5c8a179cf967d4a2e918535a3e23dcb1e53dd1d 100644 +--- a/dist/Button.mjs ++++ b/dist/Button.mjs +@@ -1,9 +1,11 @@ + import {useContextProps as $64fa3d84918910a7$export$29f1550f4b0d4415, useRenderProps as $64fa3d84918910a7$export$4d86445c2cf5e3} from "./utils.mjs"; + import {ProgressBarContext as $0393f8ab869a0f1a$export$e9f3bf65a26ce129} from "./ProgressBar.mjs"; + import {announce as $fM325$announce} from "@react-aria/live-announcer"; +-import {useButton as $fM325$useButton, useFocusRing as $fM325$useFocusRing, useHover as $fM325$useHover, useId as $fM325$useId, mergeProps as $fM325$mergeProps} from "react-aria"; ++import {useButton as $fM325$useButton} from "@react-aria/button"; ++import {useFocusRing as $fM325$useFocusRing} from "@react-aria/focus"; ++import {useHover as $fM325$useHover} from "@react-aria/interactions"; ++import {useId as $fM325$useId, mergeProps as $fM325$mergeProps, filterDOMProps as $fM325$filterDOMProps} from "@react-aria/utils"; + import {createHideableComponent as $fM325$createHideableComponent} from "@react-aria/collections"; +-import {filterDOMProps as $fM325$filterDOMProps} from "@react-aria/utils"; + import $fM325$react, {createContext as $fM325$createContext, useRef as $fM325$useRef, useEffect as $fM325$useEffect} from "react"; + + /* +diff --git a/dist/Dialog.mjs b/dist/Dialog.mjs +index 58e616de102c40d6bc70d329fbf5115b48d114a8..4df96fc4ce7720caba20ea0b43fe1a8fa1a82f0b 100644 +--- a/dist/Dialog.mjs ++++ b/dist/Dialog.mjs +@@ -3,9 +3,12 @@ import {DEFAULT_SLOT as $64fa3d84918910a7$export$c62b8e45d58ddad9, Provider as $ + import {HeadingContext as $4e85f108e88277b8$export$d688439359537581} from "./RSPContexts.mjs"; + import {PopoverContext as $07b14b47974efb58$export$9b9a0cd73afb7ca4} from "./Popover.mjs"; + import {RootMenuTriggerStateContext as $3674c52c6b3c5bce$export$795aec4671cbae19} from "./Menu.mjs"; +-import {useOverlayTrigger as $6IYYA$useOverlayTrigger, useId as $6IYYA$useId, useDialog as $6IYYA$useDialog} from "react-aria"; ++// import {useOverlayTrigger as $6IYYA$useOverlayTrigger, useId as $6IYYA$useId, useDialog as $6IYYA$useDialog} from "react-aria"; ++import {useOverlayTrigger as $6IYYA$useOverlayTrigger} from "@react-aria/overlays"; ++import {useDialog as $6IYYA$useDialog} from "@react-aria/dialog"; ++import {useId as $6IYYA$useId} from "@react-aria/utils"; + import {useResizeObserver as $6IYYA$useResizeObserver, filterDOMProps as $6IYYA$filterDOMProps, mergeProps as $6IYYA$mergeProps} from "@react-aria/utils"; +-import {useMenuTriggerState as $6IYYA$useMenuTriggerState} from "react-stately"; ++import {useMenuTriggerState as $6IYYA$useMenuTriggerState} from "@react-stately/menu"; + import {PressResponder as $6IYYA$PressResponder} from "@react-aria/interactions"; + import $6IYYA$react, {createContext as $6IYYA$createContext, useRef as $6IYYA$useRef, useState as $6IYYA$useState, useCallback as $6IYYA$useCallback, forwardRef as $6IYYA$forwardRef, useContext as $6IYYA$useContext} from "react"; + +diff --git a/dist/Menu.mjs b/dist/Menu.mjs +index 97afd5048083b0b2384fecfa32813841097e1f50..dedd3e08f06c857a02918df94d49f14f378b0465 100644 +--- a/dist/Menu.mjs ++++ b/dist/Menu.mjs +@@ -7,10 +7,14 @@ import {OverlayTriggerStateContext as $de32f1b87079253c$export$d2f961adcb0afbe} + import {PopoverContext as $07b14b47974efb58$export$9b9a0cd73afb7ca4} from "./Popover.mjs"; + import {SeparatorContext as $431f98aba6844401$export$6615d83f6de245ce} from "./Separator.mjs"; + import {TextContext as $514c0188e459b4c0$export$9afb8bc826b033ea} from "./Text.mjs"; +-import {useMenuTrigger as $kM2ZM$useMenuTrigger, useSubmenuTrigger as $kM2ZM$useSubmenuTrigger, useMenu as $kM2ZM$useMenu, FocusScope as $kM2ZM$FocusScope, mergeProps as $kM2ZM$mergeProps, useMenuSection as $kM2ZM$useMenuSection, useMenuItem as $kM2ZM$useMenuItem, useHover as $kM2ZM$useHover} from "react-aria"; ++import {useMenuTrigger as $kM2ZM$useMenuTrigger, useSubmenuTrigger as $kM2ZM$useSubmenuTrigger} from "@react-aria/menu"; ++import {useMenu as $kM2ZM$useMenu, useMenuSection as $kM2ZM$useMenuSection, useMenuItem as $kM2ZM$useMenuItem} from "@react-aria/menu"; ++import {FocusScope as $kM2ZM$FocusScope} from "@react-aria/focus"; ++import {mergeProps as $kM2ZM$mergeProps, useResizeObserver as $kM2ZM$useResizeObserver, useObjectRef as $kM2ZM$useObjectRef, filterDOMProps as $kM2ZM$filterDOMProps} from "@react-aria/utils"; ++import {useHover as $kM2ZM$useHover} from "@react-aria/interactions"; + import {CollectionNode as $kM2ZM$CollectionNode, createBranchComponent as $kM2ZM$createBranchComponent, CollectionBuilder as $kM2ZM$CollectionBuilder, Collection as $kM2ZM$Collection, SectionNode as $kM2ZM$SectionNode, createLeafComponent as $kM2ZM$createLeafComponent, ItemNode as $kM2ZM$ItemNode} from "@react-aria/collections"; +-import {useMenuTriggerState as $kM2ZM$useMenuTriggerState, useSubmenuTriggerState as $kM2ZM$useSubmenuTriggerState, useTreeState as $kM2ZM$useTreeState} from "react-stately"; +-import {useResizeObserver as $kM2ZM$useResizeObserver, useObjectRef as $kM2ZM$useObjectRef, filterDOMProps as $kM2ZM$filterDOMProps} from "@react-aria/utils"; ++import {useMenuTriggerState as $kM2ZM$useMenuTriggerState, useSubmenuTriggerState as $kM2ZM$useSubmenuTriggerState} from "@react-stately/menu"; ++import {useTreeState as $kM2ZM$useTreeState} from "@react-stately/tree"; + import {SelectionManager as $kM2ZM$SelectionManager, useMultipleSelectionState as $kM2ZM$useMultipleSelectionState} from "@react-stately/selection"; + import {PressResponder as $kM2ZM$PressResponder} from "@react-aria/interactions"; + import $kM2ZM$react, {createContext as $kM2ZM$createContext, useRef as $kM2ZM$useRef, useState as $kM2ZM$useState, useCallback as $kM2ZM$useCallback, useContext as $kM2ZM$useContext, forwardRef as $kM2ZM$forwardRef, useMemo as $kM2ZM$useMemo} from "react"; +diff --git a/dist/Modal.mjs b/dist/Modal.mjs +index 124c3ee386ffb85864f38755af07c153b9d6fc35..59f68ddff9dbe6cda35a0185c916b20a7ed14106 100644 +--- a/dist/Modal.mjs ++++ b/dist/Modal.mjs +@@ -1,8 +1,10 @@ + import {Provider as $64fa3d84918910a7$export$2881499e37b75b9a, useContextProps as $64fa3d84918910a7$export$29f1550f4b0d4415, useRenderProps as $64fa3d84918910a7$export$4d86445c2cf5e3} from "./utils.mjs"; + import {OverlayTriggerStateContext as $de32f1b87079253c$export$d2f961adcb0afbe} from "./Dialog.mjs"; +-import {useIsSSR as $daTMi$useIsSSR, useModalOverlay as $daTMi$useModalOverlay, Overlay as $daTMi$Overlay, DismissButton as $daTMi$DismissButton} from "react-aria"; ++// import {useIsSSR as $daTMi$useIsSSR, useModalOverlay as $daTMi$useModalOverlay, Overlay as $daTMi$Overlay, DismissButton as $daTMi$DismissButton} from "react-aria"; ++import {useIsSSR as $daTMi$useIsSSR } from "@react-aria/ssr"; ++import {useModalOverlay as $daTMi$useModalOverlay, Overlay as $daTMi$Overlay, DismissButton as $daTMi$DismissButton} from "@react-aria/overlays"; + import {useObjectRef as $daTMi$useObjectRef, useExitAnimation as $daTMi$useExitAnimation, useEnterAnimation as $daTMi$useEnterAnimation, useViewportSize as $daTMi$useViewportSize, mergeProps as $daTMi$mergeProps, filterDOMProps as $daTMi$filterDOMProps, mergeRefs as $daTMi$mergeRefs} from "@react-aria/utils"; +-import {useOverlayTriggerState as $daTMi$useOverlayTriggerState} from "react-stately"; ++import {useOverlayTriggerState as $daTMi$useOverlayTriggerState} from "@react-stately/overlays"; + import $daTMi$react, {createContext as $daTMi$createContext, forwardRef as $daTMi$forwardRef, useContext as $daTMi$useContext, useRef as $daTMi$useRef, useMemo as $daTMi$useMemo} from "react"; + + /* +diff --git a/dist/Popover.mjs b/dist/Popover.mjs +index 920cdb78159bb0b1c82a1cdd2d94957a23a3348d..6b644c20d6121b75446b647b9705f7e6cb9d6e75 100644 +--- a/dist/Popover.mjs ++++ b/dist/Popover.mjs +@@ -1,10 +1,11 @@ + import {useContextProps as $64fa3d84918910a7$export$29f1550f4b0d4415, useRenderProps as $64fa3d84918910a7$export$4d86445c2cf5e3} from "./utils.mjs"; + import {OverlayArrowContext as $44f671af83e7d9e0$export$2de4954e8ae13b9f} from "./OverlayArrow.mjs"; + import {OverlayTriggerStateContext as $de32f1b87079253c$export$d2f961adcb0afbe} from "./Dialog.mjs"; +-import {useLocale as $ehFet$useLocale, usePopover as $ehFet$usePopover, DismissButton as $ehFet$DismissButton, Overlay as $ehFet$Overlay} from "react-aria"; ++import {useLocale as $ehFet$useLocale} from "@react-aria/i18n"; ++import {usePopover as $ehFet$usePopover, DismissButton as $ehFet$DismissButton, Overlay as $ehFet$Overlay} from "@react-aria/overlays"; + import {useExitAnimation as $ehFet$useExitAnimation, useEnterAnimation as $ehFet$useEnterAnimation, useLayoutEffect as $ehFet$useLayoutEffect, mergeProps as $ehFet$mergeProps, filterDOMProps as $ehFet$filterDOMProps} from "@react-aria/utils"; + import {focusSafely as $ehFet$focusSafely} from "@react-aria/interactions"; +-import {useOverlayTriggerState as $ehFet$useOverlayTriggerState} from "react-stately"; ++import {useOverlayTriggerState as $ehFet$useOverlayTriggerState} from "@react-stately/overlays"; + import $ehFet$react, {createContext as $ehFet$createContext, forwardRef as $ehFet$forwardRef, useContext as $ehFet$useContext, useRef as $ehFet$useRef, useState as $ehFet$useState, useEffect as $ehFet$useEffect, useMemo as $ehFet$useMemo} from "react"; + import {useIsHidden as $ehFet$useIsHidden} from "@react-aria/collections"; + +diff --git a/dist/ProgressBar.mjs b/dist/ProgressBar.mjs +index 06d3286e3b287d5636f34bfd5c66a05054a51c81..3345b64094f7bf78f95179f00bb2e4500db60542 100644 +--- a/dist/ProgressBar.mjs ++++ b/dist/ProgressBar.mjs +@@ -1,6 +1,6 @@ + import {useContextProps as $64fa3d84918910a7$export$29f1550f4b0d4415, useRenderProps as $64fa3d84918910a7$export$4d86445c2cf5e3, useSlot as $64fa3d84918910a7$export$9d4c57ee4c6ffdd8} from "./utils.mjs"; + import {LabelContext as $01b77f81d0f07f68$export$75b6ee27786ba447} from "./Label.mjs"; +-import {useProgressBar as $hU2kz$useProgressBar} from "react-aria"; ++import {useProgressBar as $hU2kz$useProgressBar} from "@react-aria/progress"; + import {clamp as $hU2kz$clamp} from "@react-stately/utils"; + import {filterDOMProps as $hU2kz$filterDOMProps, mergeProps as $hU2kz$mergeProps} from "@react-aria/utils"; + import $hU2kz$react, {createContext as $hU2kz$createContext, forwardRef as $hU2kz$forwardRef} from "react"; +diff --git a/dist/Separator.mjs b/dist/Separator.mjs +index 9989b084d7402c9dbee2b0a7dc4b72ea1462bf0e..8bd5e4e926f051db8efcb20a6794a7e27d606327 100644 +--- a/dist/Separator.mjs ++++ b/dist/Separator.mjs +@@ -1,5 +1,5 @@ + import {useContextProps as $64fa3d84918910a7$export$29f1550f4b0d4415} from "./utils.mjs"; +-import {useSeparator as $i9JCE$useSeparator} from "react-aria"; ++import {useSeparator as $i9JCE$useSeparator} from "@react-aria/separator"; + import {CollectionNode as $i9JCE$CollectionNode, createLeafComponent as $i9JCE$createLeafComponent} from "@react-aria/collections"; + import {filterDOMProps as $i9JCE$filterDOMProps, mergeProps as $i9JCE$mergeProps} from "@react-aria/utils"; + import $i9JCE$react, {createContext as $i9JCE$createContext} from "react"; +diff --git a/dist/Tabs.mjs b/dist/Tabs.mjs +index 9d00500ac8fa5c5ae7db563fccc7a950b7070e53..1b27d380027f630e8329eda93176a19e635ebd66 100644 +--- a/dist/Tabs.mjs ++++ b/dist/Tabs.mjs +@@ -1,9 +1,13 @@ + import {CollectionRendererContext as $7135fc7d473fd974$export$4feb769f8ddf26c5, DefaultCollectionRenderer as $7135fc7d473fd974$export$a164736487e3f0ae, usePersistedKeys as $7135fc7d473fd974$export$90e00781bc59d8f9} from "./Collection.mjs"; + import {Provider as $64fa3d84918910a7$export$2881499e37b75b9a, useContextProps as $64fa3d84918910a7$export$29f1550f4b0d4415, useRenderProps as $64fa3d84918910a7$export$4d86445c2cf5e3, useSlottedContext as $64fa3d84918910a7$export$fabf2dc03a41866e} from "./utils.mjs"; +-import {useFocusRing as $7aSLZ$useFocusRing, mergeProps as $7aSLZ$mergeProps, useTabList as $7aSLZ$useTabList, useTab as $7aSLZ$useTab, useHover as $7aSLZ$useHover, useTabPanel as $7aSLZ$useTabPanel} from "react-aria"; ++// import {useFocusRing as $7aSLZ$useFocusRing, mergeProps as $7aSLZ$mergeProps, useTabList as $7aSLZ$useTabList, useTab as $7aSLZ$useTab, useHover as $7aSLZ$useHover, useTabPanel as $7aSLZ$useTabPanel} from "react-aria"; ++import {useFocusRing as $7aSLZ$useFocusRing} from "@react-aria/focus"; ++import {mergeProps as $7aSLZ$mergeProps } from "@react-aria/utils"; ++import {useTabList as $7aSLZ$useTabList, useTab as $7aSLZ$useTab, useTabPanel as $7aSLZ$useTabPanel} from "@react-aria/tabs"; ++import {useHover as $7aSLZ$useHover } from "@react-aria/interactions"; + import {CollectionBuilder as $7aSLZ$CollectionBuilder, Collection as $7aSLZ$Collection, CollectionNode as $7aSLZ$CollectionNode, createLeafComponent as $7aSLZ$createLeafComponent, createHideableComponent as $7aSLZ$createHideableComponent} from "@react-aria/collections"; + import {filterDOMProps as $7aSLZ$filterDOMProps, useObjectRef as $7aSLZ$useObjectRef, inertValue as $7aSLZ$inertValue} from "@react-aria/utils"; +-import {useTabListState as $7aSLZ$useTabListState} from "react-stately"; ++import {useTabListState as $7aSLZ$useTabListState} from "@react-stately/tabs"; + import $7aSLZ$react, {createContext as $7aSLZ$createContext, forwardRef as $7aSLZ$forwardRef, useMemo as $7aSLZ$useMemo, useContext as $7aSLZ$useContext} from "react"; + + /* +diff --git a/dist/Tooltip.mjs b/dist/Tooltip.mjs +index 091fc6694595cb7d135155a76735f60b38922775..18bee4f45ee6c1c3d04e8d193f7629b230091bee 100644 +--- a/dist/Tooltip.mjs ++++ b/dist/Tooltip.mjs +@@ -1,9 +1,11 @@ + import {Provider as $64fa3d84918910a7$export$2881499e37b75b9a, useContextProps as $64fa3d84918910a7$export$29f1550f4b0d4415, useRenderProps as $64fa3d84918910a7$export$4d86445c2cf5e3} from "./utils.mjs"; + import {OverlayArrowContext as $44f671af83e7d9e0$export$2de4954e8ae13b9f} from "./OverlayArrow.mjs"; +-import {useTooltipTrigger as $cCslV$useTooltipTrigger, OverlayContainer as $cCslV$OverlayContainer, useOverlayPosition as $cCslV$useOverlayPosition, mergeProps as $cCslV$mergeProps, useTooltip as $cCslV$useTooltip} from "react-aria"; +-import {useExitAnimation as $cCslV$useExitAnimation, useEnterAnimation as $cCslV$useEnterAnimation, filterDOMProps as $cCslV$filterDOMProps} from "@react-aria/utils"; ++import {useTooltipTrigger as $cCslV$useTooltipTrigger} from "@react-aria/tooltip"; ++import {OverlayContainer as $cCslV$OverlayContainer, useOverlayPosition as $cCslV$useOverlayPosition} from "@react-aria/overlays"; ++import {mergeProps as $cCslV$mergeProps, useExitAnimation as $cCslV$useExitAnimation, useEnterAnimation as $cCslV$useEnterAnimation, filterDOMProps as $cCslV$filterDOMProps} from "@react-aria/utils"; ++import {useTooltip as $cCslV$useTooltip} from "@react-aria/tooltip"; + import {FocusableProvider as $cCslV$FocusableProvider} from "@react-aria/focus"; +-import {useTooltipTriggerState as $cCslV$useTooltipTriggerState} from "react-stately"; ++import {useTooltipTriggerState as $cCslV$useTooltipTriggerState} from "@react-stately/tooltip"; + import $cCslV$react, {createContext as $cCslV$createContext, useRef as $cCslV$useRef, forwardRef as $cCslV$forwardRef, useContext as $cCslV$useContext} from "react"; + + /* +diff --git a/package.json b/package.json +index d3c48ef42b0d1374487a32ada1f1451ddcc4b0bd..356135b3598fc7a788d2fac161de25ea9409d432 100644 +--- a/package.json ++++ b/package.json +@@ -26,6 +26,10 @@ + "types": "./i18n/lang.d.ts", + "import": "./i18n/*.mjs", + "require": "./i18n/*.js" ++ }, ++ "./patched-dist/*": { ++ "import": "./dist/*.mjs", ++ "types": "./dist/types.d.ts" + } + }, + "files": [ diff --git a/code/addons/a11y/package.json b/code/addons/a11y/package.json index 3fd9f2b59f78..0d9a4860095b 100644 --- a/code/addons/a11y/package.json +++ b/code/addons/a11y/package.json @@ -79,7 +79,7 @@ "publishConfig": { "access": "public" }, - "gitHead": "a8e7fd8a655c69780bc20b9749d2699e45beae16", + "gitHead": "a8e7fd8a655c69780bc20b9749d2699e45beae17", "storybook": { "displayName": "Accessibility", "icon": "https://user-images.githubusercontent.com/263385/101991665-47042f80-3c7c-11eb-8f00-64b5a18f498a.png", diff --git a/code/addons/a11y/src/components/A11YPanel.stories.tsx b/code/addons/a11y/src/components/A11YPanel.stories.tsx index 1870eefd3b64..8638ba2ad6b7 100644 --- a/code/addons/a11y/src/components/A11YPanel.stories.tsx +++ b/code/addons/a11y/src/components/A11YPanel.stories.tsx @@ -1,7 +1,7 @@ import React from 'react'; import { ManagerContext } from 'storybook/manager-api'; -import { expect, fn, userEvent, within } from 'storybook/test'; +import { expect, fn, userEvent, waitFor, within } from 'storybook/test'; import { styled } from 'storybook/theming'; import preview from '../../../../.storybook/preview'; @@ -175,7 +175,11 @@ export const ReadyWithResults = meta.story({ }, play: async ({ canvasElement }) => { const canvas = within(canvasElement); - await userEvent.click(await canvas.findByRole('button', { name: /Rerun accessibility scan/ })); + const btn = await waitFor( + () => canvas.findByRole('button', { name: /Rerun accessibility scan/ }), + { timeout: 3000 } + ); + await userEvent.click(btn); expect(context.handleManual).toHaveBeenCalled(); }, }); diff --git a/code/addons/a11y/src/components/A11YPanel.tsx b/code/addons/a11y/src/components/A11YPanel.tsx index a1f69acaf16e..85210ab278c6 100644 --- a/code/addons/a11y/src/components/A11YPanel.tsx +++ b/code/addons/a11y/src/components/A11YPanel.tsx @@ -181,7 +181,7 @@ export const A11YPanel: React.FC = () => { accessibility tests manually.

-

@@ -208,7 +208,7 @@ export const A11YPanel: React.FC = () => { : JSON.stringify(error, null, 2)}

- @@ -222,7 +222,7 @@ export const A11YPanel: React.FC = () => { test manually.

- diff --git a/code/addons/a11y/src/components/Report/Details.tsx b/code/addons/a11y/src/components/Report/Details.tsx index d8f97ae292fc..7ec2126073ff 100644 --- a/code/addons/a11y/src/components/Report/Details.tsx +++ b/code/addons/a11y/src/components/Report/Details.tsx @@ -123,7 +123,7 @@ const CopyButton = ({ onClick }: { onClick: () => void }) => { }, [onClick]); return ( - ); @@ -163,7 +163,7 @@ export const Details = ({ id, item, type, selection, handleSelectionChange }: De return ( - + {index + 1}. {node.html} @@ -203,7 +203,7 @@ function getContent(node: EnhancedNodeResult) { - handleCopyLink(node.linkPath)} /> diff --git a/code/addons/a11y/src/components/Report/Report.tsx b/code/addons/a11y/src/components/Report/Report.tsx index b0f86b279f27..14861684f091 100644 --- a/code/addons/a11y/src/components/Report/Report.tsx +++ b/code/addons/a11y/src/components/Report/Report.tsx @@ -1,7 +1,7 @@ import type { ComponentProps, FC } from 'react'; import React from 'react'; -import { Badge, EmptyTabContent, IconButton } from 'storybook/internal/components'; +import { Badge, Button, EmptyTabContent } from 'storybook/internal/components'; import { ChevronSmallDownIcon } from '@storybook/icons'; @@ -121,14 +121,16 @@ export const Report: FC = ({ )} {item.nodes.length} - toggleOpen(event, type, item)} - aria-label={`${selection ? 'Collapse' : 'Expand'} details for ${title}`} + ariaLabel={`${selection ? 'Collapse' : 'Expand'} details for: ${title}`} aria-expanded={!!selection} aria-controls={detailsId} + variant="ghost" + padding="small" > - + {selection ? (
( - ({ theme }) => ({ - textDecoration: 'none', - padding: '10px 15px', - cursor: 'pointer', - color: theme.textMutedColor, - fontWeight: theme.typography.weight.bold, - fontSize: theme.typography.size.s2 - 1, - lineHeight: 1, - height: 40, - border: 'none', - borderBottom: '3px solid transparent', - background: 'transparent', - - '&:focus': { - outline: '0 none', - borderColor: theme.color.secondary, - }, - }), - ({ active, theme }) => - active - ? { - opacity: 1, - color: theme.color.secondary, - borderColor: theme.color.secondary, - } - : {} -); - -const Subnav = styled.div(({ theme }) => ({ - boxShadow: `${theme.appBorderColor} 0 -1px 0 0 inset`, - background: theme.background.app, - position: 'sticky', - top: 0, - zIndex: 1, + height: '100%', + overflow: 'hidden', display: 'flex', - alignItems: 'center', - whiteSpace: 'nowrap', - overflow: 'auto', - paddingRight: 10, - gap: 6, - scrollbarColor: `${theme.barTextColor} ${theme.background.app}`, - scrollbarWidth: 'thin', -})); + flexDirection: 'column', +}); -const TabsWrapper = styled.div({}); const ActionsWrapper = styled.div({ display: 'flex', - flexBasis: '100%', justifyContent: 'flex-end', - containerType: 'inline-size', - // 96px is the total width of the buttons without labels - minWidth: 96, gap: 6, }); -const ToggleButton = styled(IconButton)({ - // 193px is the total width of the action buttons when the label is visible - '@container (max-width: 193px)': { - span: { - display: 'none', - }, - }, -}); - interface TabsProps { tabs: { label: React.ReactElement; @@ -92,13 +35,8 @@ interface TabsProps { } export const Tabs: React.FC = ({ tabs }) => { - const { ref } = useResizeDetector({ - refreshMode: 'debounce', - handleHeight: false, - handleWidth: true, - }); const { - tab: activeTab, + tab, setTab, toggleHighlight, highlighted, @@ -108,74 +46,55 @@ export const Tabs: React.FC = ({ tabs }) => { handleExpandAll, } = useA11yContext(); - const handleToggle = React.useCallback( - (event: React.SyntheticEvent) => { - setTab(event.currentTarget.getAttribute('data-type') as RuleType); - }, - [setTab] - ); + const theme = useTheme(); return ( - - - - {tabs.map((tab, index) => ( - + ({ + id: tab.type, + title: tab.label, + children: tab.panel, + }))} + selected={tab} + // Safe to cast key to RuleType because we use RuleTypes as IDs above. + onSelectionChange={(key) => setTab(key as RuleType)} + tools={ + + + + + } + /> ); }; diff --git a/code/addons/a11y/src/components/VisionSimulator.tsx b/code/addons/a11y/src/components/VisionSimulator.tsx index 190bcf71a537..c029abe20332 100644 --- a/code/addons/a11y/src/components/VisionSimulator.tsx +++ b/code/addons/a11y/src/components/VisionSimulator.tsx @@ -1,7 +1,6 @@ -import type { ReactNode } from 'react'; import React, { useState } from 'react'; -import { IconButton, TooltipLinkList, WithTooltip } from 'storybook/internal/components'; +import { Select } from 'storybook/internal/components'; import { AccessibilityIcon } from '@storybook/icons'; @@ -51,7 +50,7 @@ const Hidden = styled.div({ }, }); -const ColorIcon = styled.span<{ filter: string }>( +const ColorIcon = styled.span<{ $filter: string }>( { background: 'linear-gradient(to right, #F44336, #FF9800, #FFEB3B, #8BC34A, #2196F3, #9C27B0)', borderRadius: '1rem', @@ -59,71 +58,27 @@ const ColorIcon = styled.span<{ filter: string }>( height: '1rem', width: '1rem', }, - ({ filter }) => ({ - filter: getFilter(filter), + ({ $filter }) => ({ + filter: getFilter($filter), }), ({ theme }) => ({ boxShadow: `${theme.appBorderColor} 0 0 0 1px inset`, }) ); -export interface Link { - id: string; - title: ReactNode; - right?: ReactNode; - active: boolean; - onClick: () => void; -} - -const Column = styled.span({ - display: 'flex', - flexDirection: 'column', -}); - -const Title = styled.span({ - textTransform: 'capitalize', -}); - -const Description = styled.span(({ theme }) => ({ - fontSize: 11, - color: theme.textMutedColor, -})); +export const VisionSimulator = () => { + const [filter, setFilter] = useState(null); -const getColorList = (active: Filter, set: (i: Filter) => void): Link[] => [ - ...(active !== null - ? [ - { - id: 'reset', - title: 'Reset color filter', - onClick: () => { - set(null); - }, - right: undefined, - active: false, - }, - ] - : []), - ...baseList.map((i) => { - const description = i.percentage !== undefined ? `${i.percentage}% of users` : undefined; + const options = baseList.map(({ name, percentage }) => { + const description = percentage !== undefined ? `${percentage}% of users` : undefined; return { - id: i.name, - title: ( - - {i.name} - {description && {description}} - - ), - onClick: () => { - set(i); - }, - right: , - active: active === i, + title: name, + description, + icon: , + value: name, }; - }), -]; + }); -export const VisionSimulator = () => { - const [filter, setFilter] = useState(null); return ( <> {filter && ( @@ -135,22 +90,15 @@ export const VisionSimulator = () => { }} /> )} - { - const colorList = getColorList(filter, (i) => { - setFilter(i); - onHide(); - }); - return ; - }} - closeOnOutsideClick - onDoubleClick={() => setFilter(null)} - > - - - - + ) => 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/docs/src/blocks/controls/Date.tsx b/code/addons/docs/src/blocks/controls/Date.tsx index 442d3af9d7c4..7996ee84287d 100644 --- a/code/addons/docs/src/blocks/controls/Date.tsx +++ b/code/addons/docs/src/blocks/controls/Date.tsx @@ -38,13 +38,20 @@ export const formatTime = (value: Date | number) => { return `${hours}:${minutes}`; }; -const FormInput = styled(Form.Input)(({ readOnly }) => ({ - opacity: readOnly ? 0.5 : 1, -})); +const FormInput = styled(Form.Input)(({ theme, readOnly }) => + readOnly + ? { + background: theme.base === 'light' ? theme.color.lighter : 'transparent', + } + : {} +); -const FlexSpaced = styled.div(({ theme }) => ({ +const FlexSpaced = styled.fieldset(({ theme }) => ({ flex: 1, display: 'flex', + border: 0, + marginInline: 0, + padding: 0, input: { marginLeft: 10, @@ -119,6 +126,10 @@ export const DateControl: FC = ({ name, value, onChange, onFocus, onB return ( + {name} + = ({ name, value, onChange, onFocus, onB onChange={onDateChange} {...{ onFocus, onBlur }} /> + = ({ } }, [value, name]); + const controlId = getControlId(name); + return ( - + <> + + + ); }; diff --git a/code/addons/docs/src/blocks/controls/Number.tsx b/code/addons/docs/src/blocks/controls/Number.tsx index 5c8757c5fab6..947a9f7afca7 100644 --- a/code/addons/docs/src/blocks/controls/Number.tsx +++ b/code/addons/docs/src/blocks/controls/Number.tsx @@ -21,8 +21,8 @@ export const parse = (value: string) => { export const format = (value: NumberValue) => (value != null ? String(value) : ''); -const FormInput = styled(Form.Input)(({ readOnly }) => ({ - opacity: readOnly ? 0.5 : 1, +const FormInput = styled(Form.Input)(({ theme }) => ({ + background: theme.base === 'light' ? theme.color.lighter : 'transparent', })); export const NumberControl: FC = ({ @@ -99,6 +99,7 @@ export const NumberControl: FC = ({ if (value === undefined) { return ( ); @@ -235,6 +224,7 @@ export const ObjectControl: FC = ({ name, value, onChange, argType ) => updateRaw(event.target.value)} @@ -249,19 +239,18 @@ export const ObjectControl: FC = ({ name, value, onChange, argType Array.isArray(value) || (typeof value === 'object' && value?.constructor === Object); return ( - + {isObjectOrArray && ( { e.preventDefault(); setShowRaw((isRaw) => !isRaw); }} > - {showRaw ? : } - RAW + Edit JSON )} {!showRaw ? ( diff --git a/code/addons/docs/src/blocks/controls/Range.tsx b/code/addons/docs/src/blocks/controls/Range.tsx index 75dbb6f742c6..5baea1088edb 100644 --- a/code/addons/docs/src/blocks/controls/Range.tsx +++ b/code/addons/docs/src/blocks/controls/Range.tsx @@ -42,12 +42,13 @@ const RangeInput = styled.input<{ min: number; max: number; value: number }>( width: 16, height: 16, - border: `1px solid ${rgba(theme.appBorderColor, 0.2)}`, + border: `1px solid ${theme.appBorderColor}`, borderRadius: '50px', - boxShadow: `0 1px 3px 0px ${rgba(theme.appBorderColor, 0.2)}`, + boxShadow: + theme.base === 'light' ? `0 1px 3px 0px ${rgba(theme.appBorderColor, 0.2)}` : 'unset', cursor: disabled ? 'not-allowed' : 'grab', appearance: 'none', - background: `${theme.input.background}`, + background: theme.input.background, transition: 'all 150ms ease-out', '&:hover': { @@ -72,7 +73,7 @@ const RangeInput = styled.input<{ min: number; max: number; value: number }>( '&::-webkit-slider-thumb': { borderColor: theme.color.secondary, - boxShadow: `0 0px 5px 0px ${theme.color.secondary}`, + boxShadow: theme.base === 'light' ? `0 0px 5px 0px ${theme.color.secondary}` : 'unset', }, }, @@ -98,11 +99,12 @@ const RangeInput = styled.input<{ min: number; max: number; value: number }>( '&::-moz-range-thumb': { width: 16, height: 16, - border: `1px solid ${rgba(theme.appBorderColor, 0.2)}`, + border: `1px solid ${theme.appBorderColor}`, borderRadius: '50px', - boxShadow: `0 1px 3px 0px ${rgba(theme.appBorderColor, 0.2)}`, - cursor: disabled ? 'not-allowed' : 'grap', - background: `${theme.input.background}`, + boxShadow: + theme.base === 'light' ? `0 1px 3px 0px ${rgba(theme.appBorderColor, 0.2)}` : 'unset', + cursor: disabled ? 'not-allowed' : 'grab', + background: theme.input.background, transition: 'all 150ms ease-out', '&:hover': { @@ -128,7 +130,7 @@ const RangeInput = styled.input<{ min: number; max: number; value: number }>( ${theme.color.green} 0%, ${theme.color.green} ${((value - min) / (max - min)) * 100}%, ${lighten(0.02, theme.input.background)} ${((value - min) / (max - min)) * 100}%, ${lighten(0.02, theme.input.background)} 100%)`, - boxShadow: `${theme.appBorderColor} 0 0 0 1px inset`, + boxShadow: theme.base === 'light' ? `${theme.appBorderColor} 0 0 0 1px inset` : 'unset', color: 'transparent', width: '100%', height: '6px', @@ -143,10 +145,10 @@ const RangeInput = styled.input<{ min: number; max: number; value: number }>( '&::-ms-thumb': { width: 16, height: 16, - background: `${theme.input.background}`, + background: theme.input.background, border: `1px solid ${rgba(theme.appBorderColor, 0.2)}`, borderRadius: 50, - cursor: 'grab', + cursor: disabled ? 'not-allowed' : 'grab', marginTop: 0, }, '@supports (-ms-ime-align:auto)': { 'input[type=range]': { margin: '0' } }, @@ -160,9 +162,6 @@ const RangeLabel = styled.span({ whiteSpace: 'nowrap', fontFeatureSettings: 'tnum', fontVariantNumeric: 'tabular-nums', - '[aria-readonly=true] &': { - opacity: 0.5, - }, }); const RangeCurrentAndMaxLabel = styled(RangeLabel)<{ @@ -176,11 +175,12 @@ const RangeCurrentAndMaxLabel = styled(RangeLabel)<{ flexShrink: 0, })); -const RangeWrapper = styled.div({ +const RangeWrapper = styled.div<{ readOnly: boolean }>(({ readOnly }) => ({ display: 'flex', alignItems: 'center', width: '100%', -}); + opacity: readOnly ? 0.5 : 1, +})); function getNumberOfDecimalPlaces(number: number) { const match = number.toString().match(/(?:\.(\d+))?(?:[eE]([+-]?\d+))?$/); @@ -214,12 +214,16 @@ export const RangeControl: FC = ({ const numberOFDecimalsPlaces = useMemo(() => getNumberOfDecimalPlaces(step), [step]); const readonly = !!argType?.table?.readonly; + const controlId = getControlId(name); return ( - + + {min} = ({ if (value === undefined) { return ( {step.content} @@ -140,13 +146,13 @@ export const Tooltip: FC = ({ {index + 1} of {size} {!step.hideNextButton && ( - + )} diff --git a/code/addons/onboarding/src/features/IntentSurvey/IntentSurvey.tsx b/code/addons/onboarding/src/features/IntentSurvey/IntentSurvey.tsx index 282fbec199ee..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(); + } + }} + >
@@ -229,7 +238,13 @@ export const IntentSurvey = ({ })} - diff --git a/code/addons/pseudo-states/src/manager/PseudoStateTool.tsx b/code/addons/pseudo-states/src/manager/PseudoStateTool.tsx index f7c9cfcb2705..1f90a36cc42c 100644 --- a/code/addons/pseudo-states/src/manager/PseudoStateTool.tsx +++ b/code/addons/pseudo-states/src/manager/PseudoStateTool.tsx @@ -1,70 +1,44 @@ -import React, { type ComponentProps, useCallback } from 'react'; +import React from 'react'; -import { Form, IconButton, TooltipLinkList, WithTooltip } from 'storybook/internal/components'; +import { Select } from 'storybook/internal/components'; -import { ButtonIcon, RefreshIcon } from '@storybook/icons'; +import { ButtonIcon } from '@storybook/icons'; import { useGlobals } from 'storybook/manager-api'; -import { color, styled } from 'storybook/theming'; import { PARAM_KEY, PSEUDO_STATES } from '../constants'; -const LinkTitle = styled.span<{ active?: boolean }>(({ active }) => ({ - color: active ? color.secondary : 'inherit', -})); - -const options = Object.keys(PSEUDO_STATES).sort() as (keyof typeof PSEUDO_STATES)[]; +const pseudoStates = Object.keys(PSEUDO_STATES).sort() as (keyof typeof PSEUDO_STATES)[]; export const PseudoStateTool = () => { const [globals, updateGlobals] = useGlobals(); - const pseudo = globals[PARAM_KEY]; - const isActive = useCallback( - (option: keyof typeof PSEUDO_STATES) => { - if (!pseudo) { - return false; - } - return pseudo[option] === true; - }, - [pseudo] + const defaultOptions = Object.keys(globals[PARAM_KEY] || {}).filter((key) => + pseudoStates.includes(key as keyof typeof PSEUDO_STATES) ); - const hasActive = options.some(isActive); - const reset = { - id: 'reset', - title: 'Reset pseudo states', - icon: , - disabled: !hasActive, - onClick: () => updateGlobals({ [PARAM_KEY]: {} }), - }; - - const toggleOption = useCallback( - (option: keyof typeof PSEUDO_STATES) => () => { - const { [option]: value, ...rest } = pseudo; - updateGlobals({ [PARAM_KEY]: value === true ? rest : { ...rest, [option]: true } }); - }, - [pseudo, updateGlobals] - ); - const links: ComponentProps['links'] = options.map((option) => { - const active = isActive(option); + const options = pseudoStates.map((option) => { return { - id: option, - title: :{PSEUDO_STATES[option]}, - input: , - active, + title: `:${PSEUDO_STATES[option]}`, + value: option, }; }); return ( - } - > - - - - + + ); diff --git a/code/addons/pseudo-states/src/stories/input.css b/code/addons/pseudo-states/src/stories/input.css index 06f474a22932..7ff64cb6a4ca 100644 --- a/code/addons/pseudo-states/src/stories/input.css +++ b/code/addons/pseudo-states/src/stories/input.css @@ -4,7 +4,7 @@ border-radius: 3em; background-color: #fff; padding: 11px 20px; - color: #888; + color: #666; font-size: 14px; line-height: 1; font-family: 'Nunito Sans', 'Helvetica Neue', Helvetica, Arial, sans-serif; diff --git a/code/addons/themes/package.json b/code/addons/themes/package.json index a74c8c3f577d..6d6b90824741 100644 --- a/code/addons/themes/package.json +++ b/code/addons/themes/package.json @@ -72,7 +72,7 @@ "publishConfig": { "access": "public" }, - "gitHead": "a8e7fd8a655c69780bc20b9749d2699e45beae16", + "gitHead": "a8e7fd8a655c69780bc20b9749d2699e45beae17", "storybook": { "displayName": "Themes", "unsupportedFrameworks": [ diff --git a/code/addons/themes/src/theme-switcher.tsx b/code/addons/themes/src/theme-switcher.tsx index 10eca3b4057d..3865cef7cedf 100644 --- a/code/addons/themes/src/theme-switcher.tsx +++ b/code/addons/themes/src/theme-switcher.tsx @@ -1,11 +1,10 @@ import React from 'react'; -import { IconButton, TooltipLinkList, WithTooltip } from 'storybook/internal/components'; +import { Button, Select } from 'storybook/internal/components'; import { PaintBrushIcon } from '@storybook/icons'; import { addons, useAddonState, useChannel, useGlobals, useParameter } from 'storybook/manager-api'; -import { styled } from 'storybook/theming'; import { DEFAULT_ADDON_STATE, @@ -19,10 +18,6 @@ import type { ThemesParameters as Parameters, ThemeAddonState } from './types'; type ThemesParameters = NonNullable; -const IconButtonLabel = styled.div(({ theme }) => ({ - fontSize: theme.typography.size.s2 - 1, -})); - const hasMultipleThemes = (themesList: ThemeAddonState['themesList']) => themesList.length > 1; const hasTwoThemes = (themesList: ThemeAddonState['themesList']) => themesList.length === 2; @@ -57,12 +52,18 @@ export const ThemeSwitcher = React.memo(function ThemeSwitcher() { }, }); - const themeName = selected || themeDefault; + const currentTheme = selected || themeDefault; + let ariaLabel = ''; let label = ''; + let tooltip = ''; if (isLocked) { label = 'Story override'; - } else if (themeName) { - label = `${themeName} theme`; + ariaLabel = 'Theme set by story parameters'; + tooltip = 'Theme set by story parameters'; + } else if (currentTheme) { + label = `${currentTheme} theme`; + ariaLabel = 'Theme'; // it's Select's job to announce the current value. + tooltip = 'Change theme'; } if (disable) { @@ -70,56 +71,40 @@ export const ThemeSwitcher = React.memo(function ThemeSwitcher() { } if (hasTwoThemes(themesList)) { - const currentTheme = selected || themeDefault; const alternateTheme = themesList.find((theme) => theme !== currentTheme); return ( - { updateGlobals({ theme: alternateTheme }); }} > - {label ? {label} : null} - + {label} + ); } if (hasMultipleThemes(themesList)) { return ( - { - return ( - ({ - id: theme, - title: theme, - active: selected === theme, - onClick: () => { - updateGlobals({ theme }); - onHide(); - }, - }))} - /> - ); - }} + ); } diff --git a/code/addons/vitest/src/components/GlobalErrorModal.tsx b/code/addons/vitest/src/components/GlobalErrorModal.tsx index af802389ebf0..f92ee795a987 100644 --- a/code/addons/vitest/src/components/GlobalErrorModal.tsx +++ b/code/addons/vitest/src/components/GlobalErrorModal.tsx @@ -1,8 +1,8 @@ import React, { useContext } from 'react'; -import { Button, IconButton, Modal } from 'storybook/internal/components'; +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,22 +147,20 @@ export function GlobalErrorModal({ onRerun, storeState }: GlobalErrorModalProps) ) : null; return ( - + Storybook Tests error details - - - - - + diff --git a/code/addons/vitest/src/components/TestProviderRender.tsx b/code/addons/vitest/src/components/TestProviderRender.tsx index 555e3362bcc5..2efb1d4a38b2 100644 --- a/code/addons/vitest/src/components/TestProviderRender.tsx +++ b/code/addons/vitest/src/components/TestProviderRender.tsx @@ -1,12 +1,11 @@ import React, { type ComponentProps, type FC } from 'react'; import { + Button, Form, - IconButton, ListItem, ProgressSpinner, - TooltipNote, - WithTooltip, + ToggleButton, } from 'storybook/internal/components'; import type { API_HashEntry, TestProviderState } from 'storybook/internal/types'; @@ -182,127 +181,126 @@ export const TestProviderRender: FC = ({ {!entry && ( - } + + store.send({ + type: 'TOGGLE_WATCHING', + payload: { + to: !watching, + }, + }) + } + disabled={isRunning} > - - store.send({ - type: 'TOGGLE_WATCHING', - payload: { - to: !watching, - }, - }) - } - disabled={isRunning} - > - - - + + )} {isRunning ? ( - } + ) : ( - } + )} - } /> - } + {!entry && ( @@ -323,63 +321,66 @@ export const TestProviderRender: FC = ({ /> } /> - - } - > - {watching || - (currentRun.triggeredBy && !FULL_RUN_TRIGGERS.includes(currentRun.triggeredBy)) ? ( - - - - ) : currentRun.coverageSummary ? ( - - - - - {currentRun.coverageSummary.percentage}% - - - - ) : ( - + + {/* FIXME: aria labels were not 100% consistent with the tooltip logic. Double check this logic during review please! */} + {watching || + (currentRun.triggeredBy && !FULL_RUN_TRIGGERS.includes(currentRun.triggeredBy)) ? ( + + ) : currentRun.coverageSummary ? ( + + ) : ( + + )} )} @@ -403,39 +404,32 @@ export const TestProviderRender: FC = ({ ) } /> - } + )} diff --git a/code/addons/vitest/src/components/TestStatusIcon.tsx b/code/addons/vitest/src/components/TestStatusIcon.tsx index 77c735dedf3d..74ea4d47fba3 100644 --- a/code/addons/vitest/src/components/TestStatusIcon.tsx +++ b/code/addons/vitest/src/components/TestStatusIcon.tsx @@ -40,7 +40,7 @@ export const TestStatusIcon = styled.div<{ }, ({ status, theme }) => status === 'unknown' && { - '--status-color': theme.color.mediumdark, - '--status-background': `${theme.color.mediumdark}66`, + '--status-color': theme.textMutedColor, + '--status-background': `${theme.textMutedColor}66`, } ); diff --git a/code/builders/builder-vite/package.json b/code/builders/builder-vite/package.json index 10f4335cad66..57377702c669 100644 --- a/code/builders/builder-vite/package.json +++ b/code/builders/builder-vite/package.json @@ -69,5 +69,5 @@ "publishConfig": { "access": "public" }, - "gitHead": "a8e7fd8a655c69780bc20b9749d2699e45beae16" + "gitHead": "a8e7fd8a655c69780bc20b9749d2699e45beae17" } diff --git a/code/builders/builder-webpack5/package.json b/code/builders/builder-webpack5/package.json index 6b5688c07cc4..9242c834693b 100644 --- a/code/builders/builder-webpack5/package.json +++ b/code/builders/builder-webpack5/package.json @@ -89,5 +89,5 @@ "publishConfig": { "access": "public" }, - "gitHead": "a8e7fd8a655c69780bc20b9749d2699e45beae16" + "gitHead": "a8e7fd8a655c69780bc20b9749d2699e45beae17" } diff --git a/code/core/package.json b/code/core/package.json index c9824392c4f8..fa5bf88e0bee 100644 --- a/code/core/package.json +++ b/code/core/package.json @@ -205,6 +205,7 @@ "esbuild": "^0.18.0 || ^0.19.0 || ^0.20.0 || ^0.21.0 || ^0.22.0 || ^0.23.0 || ^0.24.0 || ^0.25.0", "recast": "^0.23.5", "semver": "^7.6.2", + "use-sync-external-store": "^1.5.0", "ws": "^8.18.0" }, "devDependencies": { @@ -225,9 +226,16 @@ "@happy-dom/global-registrator": "^18.0.1", "@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/interactions": "^3.25.5", + "@react-aria/overlays": "^3.29.1", + "@react-aria/tabs": "^3.10.7", + "@react-aria/toolbar": "3.0.0-beta.20", + "@react-aria/utils": "^3.30.1", + "@react-stately/overlays": "^3.6.19", + "@react-stately/tabs": "^3.8.5", + "@react-types/shared": "^3.32.0", "@rolldown/pluginutils": "1.0.0-beta.18", "@storybook/docs-mdx": "4.0.0-next.1", "@tanstack/react-virtual": "^3.3.0", @@ -245,7 +253,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", @@ -305,14 +312,14 @@ "prompts": "^2.4.0", "qrcode.react": "^4.2.0", "react": "^18.2.0", + "react-aria-components": "patch:react-aria-components@npm%3A1.12.2#~/.yarn/patches/react-aria-components-npm-1.12.2-6c5dcdafab.patch", "react-dom": "^18.2.0", "react-helmet-async": "^1.3.0", "react-inspector": "^6.0.0", - "react-popper-tooltip": "^4.4.2", "react-router-dom": "6.15.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": "^1.22.11", "resolve.exports": "^2.0.3", @@ -345,5 +352,5 @@ "publishConfig": { "access": "public" }, - "gitHead": "a8e7fd8a655c69780bc20b9749d2699e45beae16" + "gitHead": "a8e7fd8a655c69780bc20b9749d2699e45beae17" } diff --git a/code/core/src/backgrounds/components/Tool.tsx b/code/core/src/backgrounds/components/Tool.tsx index 9e3324a545ed..1b6347ebbb2a 100644 --- a/code/core/src/backgrounds/components/Tool.tsx +++ b/code/core/src/backgrounds/components/Tool.tsx @@ -1,8 +1,8 @@ -import React, { Fragment, memo, useCallback, useState } from 'react'; +import React, { Fragment, memo, useCallback } from 'react'; -import { IconButton, TooltipLinkList, WithTooltip } from 'storybook/internal/components'; +import { Select, ToggleButton } from 'storybook/internal/components'; -import { CircleIcon, GridIcon, PhotoIcon, RefreshIcon } from '@storybook/icons'; +import { CircleIcon, GridIcon, PhotoIcon } from '@storybook/icons'; import { useGlobals, useParameter } from 'storybook/manager-api'; @@ -10,12 +10,9 @@ import { PARAM_KEY as KEY } from '../constants'; import { DEFAULT_BACKGROUNDS } from '../defaults'; import type { Background, BackgroundMap, BackgroundsParameters, GlobalStateUpdate } from '../types'; -type Link = Parameters['0']['links'][0]; - export const BackgroundTool = memo(function BackgroundSelector() { const config = useParameter(KEY); const [globals, updateGlobals, storyGlobals] = useGlobals(); - const [isTooltipVisible, setIsTooltipVisible] = useState(false); const { options = DEFAULT_BACKGROUNDS, disable = true } = config || {}; if (disable) { @@ -38,10 +35,8 @@ export const BackgroundTool = memo(function BackgroundSelector() { item, updateGlobals, backgroundName, - setIsTooltipVisible, isLocked, isGridActive, - isTooltipVisible, }} /> ); @@ -53,10 +48,8 @@ interface PureProps { item: Background | undefined; updateGlobals: ReturnType['1']; backgroundName: string | undefined; - setIsTooltipVisible: React.Dispatch>; isLocked: boolean; isGridActive: boolean; - isTooltipVisible: boolean; } const Pure = memo(function PureTool(props: PureProps) { @@ -64,12 +57,10 @@ const Pure = memo(function PureTool(props: PureProps) { item, length, updateGlobals, - setIsTooltipVisible, backgroundMap, backgroundName, isLocked, isGridActive: isGrid, - isTooltipVisible, } = props; const update = useCallback( @@ -81,65 +72,40 @@ const Pure = memo(function PureTool(props: PureProps) { [updateGlobals] ); + const options = Object.entries(backgroundMap).map(([k, value]) => ({ + value: k, + title: value.name, + icon: , + })); + return ( - update({ value: backgroundName, grid: !isGrid })} > - + {length > 0 ? ( - update(undefined)} + disabled={isLocked} key="background" - placement="top" - closeOnOutsideClick - tooltip={({ onHide }) => { - return ( - , - onClick: () => { - update(undefined); - onHide(); - }, - }, - ] - : []), - ...Object.entries(backgroundMap).map(([k, value]) => ({ - id: k, - title: value.name, - icon: , - active: k === backgroundName, - onClick: () => { - update({ value: k, grid: isGrid }); - onHide(); - }, - })), - ].flat()} - /> - ); - }} - onVisibleChange={setIsTooltipVisible} - > - - - - + icon={} + ariaLabel={isLocked ? 'Background set by story parameters' : 'Preview background'} + tooltip={isLocked ? 'Background set by story parameters' : 'Change background'} + defaultOptions={backgroundName} + options={options} + onSelect={(selected) => update({ value: selected, grid: isGrid })} + /> ) : null} ); diff --git a/code/core/src/component-testing/components/Interaction.stories.tsx b/code/core/src/component-testing/components/Interaction.stories.tsx index 56becc0d344a..985acaa97a8e 100644 --- a/code/core/src/component-testing/components/Interaction.stories.tsx +++ b/code/core/src/component-testing/components/Interaction.stories.tsx @@ -5,7 +5,7 @@ import { expect, userEvent, within } from 'storybook/test'; import { CallStates } from '../../instrumenter/types'; import { getCalls } from '../mocks'; import { Interaction } from './Interaction'; -import SubnavStories from './Subnav.stories'; +import ToolbarStories from './Toolbar.stories'; type Story = StoryObj; @@ -14,8 +14,8 @@ export default { component: Interaction, args: { callsById: new Map(getCalls(CallStates.DONE).map((call) => [call.id, call])), - controls: SubnavStories.args.controls, - controlStates: SubnavStories.args.controlStates, + controls: ToolbarStories.args.controls, + controlStates: ToolbarStories.args.controlStates, }, } as Meta; @@ -62,7 +62,7 @@ export const WithParent: Story = { }; export const Disabled: Story = { - args: { ...Done.args, controlStates: { ...SubnavStories.args.controlStates, goto: false } }, + args: { ...Done.args, controlStates: { ...ToolbarStories.args.controlStates, goto: false } }, }; export const Hovered: Story = { diff --git a/code/core/src/component-testing/components/Interaction.tsx b/code/core/src/component-testing/components/Interaction.tsx index 4a9a3eeb138e..34514a6bf9eb 100644 --- a/code/core/src/component-testing/components/Interaction.tsx +++ b/code/core/src/component-testing/components/Interaction.tsx @@ -1,6 +1,6 @@ import * as React from 'react'; -import { IconButton, TooltipNote, WithTooltip } from 'storybook/internal/components'; +import { Button } from 'storybook/internal/components'; import { ChevronDownIcon, ChevronUpIcon } from '@storybook/icons'; @@ -99,15 +99,11 @@ const RowActions = styled.div({ padding: 6, }); -export const StyledIconButton = styled(IconButton as any)(({ theme }) => ({ +const StyledButton = styled(Button)(({ theme }) => ({ color: theme.textMutedColor, margin: '0 3px', })); -const Note = styled(TooltipNote)(({ theme }) => ({ - fontFamily: theme.typography.fonts.base, -})); - const RowMessage = styled('div')(({ theme }) => ({ padding: '8px 10px 8px 36px', fontSize: typography.size.s1, @@ -133,7 +129,7 @@ const ErrorExplainer = styled.p(({ theme }) => ({ textWrap: 'balance', })); -export const Exception = ({ exception }: { exception: Call['exception'] }) => { +const Exception = ({ exception }: { exception: Call['exception'] }) => { const filter = useAnsiToHtmlFilter(); if (!exception) { return null; @@ -226,17 +222,15 @@ export const Interaction = ({ {(childCallIds?.length ?? 0) > 0 && ( - } + - - {isCollapsed ? : } - - + {/* FIXME: accordion pattern */} + {isCollapsed ? : } + )} diff --git a/code/core/src/component-testing/components/InteractionsPanel.stories.tsx b/code/core/src/component-testing/components/InteractionsPanel.stories.tsx index 46b7f983b07d..f779fd5b3030 100644 --- a/code/core/src/component-testing/components/InteractionsPanel.stories.tsx +++ b/code/core/src/component-testing/components/InteractionsPanel.stories.tsx @@ -10,7 +10,7 @@ import { isChromatic } from '../../../../.storybook/isChromatic'; import { CallStates } from '../../instrumenter/types'; import { getCalls, getInteractions } from '../mocks'; import { InteractionsPanel } from './InteractionsPanel'; -import SubnavStories from './Subnav.stories'; +import ToolbarStories from './Toolbar.stories'; const StyledWrapper = styled.div(({ theme }) => ({ backgroundColor: theme.background.content, @@ -54,8 +54,8 @@ const meta = { args: { status: 'completed', calls: new Map(getCalls(CallStates.DONE).map((call) => [call.id, call])), - controls: SubnavStories.args.controls, - controlStates: SubnavStories.args.controlStates, + controls: ToolbarStories.args.controls, + controlStates: ToolbarStories.args.controlStates, interactions, fileName: 'addon-interactions.stories.tsx', hasException: false, @@ -75,34 +75,39 @@ export const Passing: Story = { browserTestStatus: CallStates.DONE, interactions: getInteractions(CallStates.DONE), }, - play: async ({ args, canvasElement }) => { + play: async ({ args, canvasElement, step }) => { if (isChromatic()) { return; } const canvas = within(canvasElement); - await waitFor(async () => { - await userEvent.click(canvas.getByLabelText('Go to start')); + await step('Go to start', async () => { + const btn = await canvas.findByLabelText('Go to start'); + await userEvent.click(btn); await expect(args.controls.start).toHaveBeenCalled(); }); - await waitFor(async () => { - await userEvent.click(canvas.getByLabelText('Go back')); + await step('Go back', async () => { + const btn = await canvas.findByLabelText('Go back'); + await userEvent.click(btn); await expect(args.controls.back).toHaveBeenCalled(); }); - await waitFor(async () => { - await userEvent.click(canvas.getByLabelText('Go forward')); + await step('Go forward', async () => { + const btn = await canvas.findByLabelText('Go forward'); + await userEvent.click(btn); await expect(args.controls.next).not.toHaveBeenCalled(); }); - await waitFor(async () => { - await userEvent.click(canvas.getByLabelText('Go to end')); + await step('Go to end', async () => { + const btn = await canvas.findByLabelText('Go to end'); + await userEvent.click(btn); await expect(args.controls.end).not.toHaveBeenCalled(); }); - await waitFor(async () => { - await userEvent.click(canvas.getByLabelText('Rerun')); + await step('Rerun', async () => { + const btn = await canvas.findByLabelText('Rerun'); + await userEvent.click(btn); await expect(args.controls.rerun).toHaveBeenCalled(); }); }, diff --git a/code/core/src/component-testing/components/InteractionsPanel.tsx b/code/core/src/component-testing/components/InteractionsPanel.tsx index 5d0b822ae03c..d9baa4ff2013 100644 --- a/code/core/src/component-testing/components/InteractionsPanel.tsx +++ b/code/core/src/component-testing/components/InteractionsPanel.tsx @@ -11,8 +11,8 @@ import { DetachedDebuggerMessage } from './DetachedDebuggerMessage'; import { Empty } from './EmptyState'; import { Interaction } from './Interaction'; import type { PlayStatus } from './StatusBadge'; -import { Subnav } from './Subnav'; import { TestDiscrepancyMessage } from './TestDiscrepancyMessage'; +import { Toolbar } from './Toolbar'; export interface Controls { start: (args?: any) => void; @@ -121,7 +121,7 @@ export const InteractionsPanel: React.FC = React.memo( {controlStates.detached && (hasRealInteractions || hasException) && ( )} - if (global.IntersectionObserver) { observer = new global.IntersectionObserver( ([end]: any) => setScrollTarget(end.isIntersecting ? undefined : end.target), - { root: global.document.querySelector('#panel-tab-content') } + { root: global.document.querySelector('#storybook-panel-root [role="tabpanel"]') } ); if (endRef.current) { diff --git a/code/core/src/component-testing/components/StatusBadge.tsx b/code/core/src/component-testing/components/StatusBadge.tsx index 7206e7385cc3..8fff41734db5 100644 --- a/code/core/src/component-testing/components/StatusBadge.tsx +++ b/code/core/src/component-testing/components/StatusBadge.tsx @@ -62,7 +62,7 @@ export const StatusBadge: React.FC = ({ status }) => { trigger="hover" tooltip={} > - + {badgeText} diff --git a/code/core/src/component-testing/components/Subnav.tsx b/code/core/src/component-testing/components/Subnav.tsx deleted file mode 100644 index 51729516fc2d..000000000000 --- a/code/core/src/component-testing/components/Subnav.tsx +++ /dev/null @@ -1,221 +0,0 @@ -import type { ComponentProps } from 'react'; -import React from 'react'; - -import { - Bar, - Button, - IconButton, - P, - Separator, - TooltipNote, - WithTooltip, -} from 'storybook/internal/components'; - -import { - FastForwardIcon, - PlayBackIcon, - PlayNextIcon, - RewindIcon, - SyncIcon, -} from '@storybook/icons'; - -import { type API } from 'storybook/manager-api'; -import { styled, useTheme } from 'storybook/theming'; - -import { type ControlStates } from '../../instrumenter/types'; -import type { Controls } from './InteractionsPanel'; -import { type PlayStatus, StatusBadge } from './StatusBadge'; - -const SubnavWrapper = styled.div(({ theme }) => ({ - boxShadow: `${theme.appBorderColor} 0 -1px 0 0 inset`, - background: theme.background.app, - position: 'sticky', - top: 0, - zIndex: 1, -})); - -const StyledSubnav = styled.nav({ - height: 39, - display: 'flex', - alignItems: 'center', - justifyContent: 'space-between', - paddingLeft: 15, -}); - -interface SubnavProps { - controls: Controls; - controlStates: ControlStates; - status: PlayStatus; - storyFileName?: string; - onScrollToEnd?: () => void; - importPath?: string; - canOpenInEditor?: boolean; - api: API; -} - -const StyledButton = styled(Button)(({ theme }) => ({ - borderRadius: 4, - padding: 6, - color: theme.textMutedColor, - '&:not(:disabled)': { - '&:hover,&:focus-visible': { - color: theme.color.secondary, - }, - }, -})); - -const Note = styled(TooltipNote)(({ theme }) => ({ - fontFamily: theme.typography.fonts.base, -})); - -const StyledIconButton = styled(IconButton)(({ theme }) => ({ - color: theme.textMutedColor, - margin: '0 3px', -})); - -const StyledSeparator = styled(Separator)({ - marginTop: 0, -}); - -const StyledLocation = styled(P)<{ isText?: boolean }>(({ theme, isText }) => ({ - color: isText ? theme.textMutedColor : theme.color.secondary, - cursor: isText ? 'default' : 'pointer', - fontWeight: isText ? theme.typography.weight.regular : theme.typography.weight.bold, - justifyContent: 'flex-end', - textAlign: 'right', - whiteSpace: 'nowrap', - marginTop: 'auto', - marginBottom: 1, - paddingRight: 15, - fontSize: 13, -})); - -const Group = styled.div({ - display: 'flex', - alignItems: 'center', -}); - -const RewindButton = styled(StyledIconButton)({ - marginLeft: 9, -}); - -const JumpToEndButton = styled(StyledButton)({ - marginLeft: 9, - marginRight: 9, - lineHeight: '12px', -}); - -interface AnimatedButtonProps { - animating?: boolean; -} - -const RerunButton = styled(StyledIconButton)< - AnimatedButtonProps & ComponentProps ->(({ theme, animating, disabled }) => ({ - opacity: disabled ? 0.5 : 1, - svg: { - animation: animating ? `${theme.animation.rotate360} 200ms ease-out` : undefined, - }, -})); - -export const Subnav: React.FC = ({ - controls, - controlStates, - status, - storyFileName, - onScrollToEnd, - importPath, - canOpenInEditor, - api, -}) => { - const buttonText = status === 'errored' ? 'Scroll to error' : 'Scroll to end'; - const theme = useTheme(); - - return ( - - - - - - - - {buttonText} - - - - - }> - - - - - - }> - - - - - - }> - - - - - - }> - - - - - - }> - - - - - - {(importPath || storyFileName) && ( - - {canOpenInEditor ? ( - } - > - { - api.openInEditor({ - file: importPath as string, - }); - }} - > - {storyFileName} - - - ) : ( - {storyFileName} - )} - - )} - - - - ); -}; diff --git a/code/core/src/component-testing/components/Subnav.stories.tsx b/code/core/src/component-testing/components/Toolbar.stories.tsx similarity index 94% rename from code/core/src/component-testing/components/Subnav.stories.tsx rename to code/core/src/component-testing/components/Toolbar.stories.tsx index dd5dd6151125..0e22864e6837 100644 --- a/code/core/src/component-testing/components/Subnav.stories.tsx +++ b/code/core/src/component-testing/components/Toolbar.stories.tsx @@ -2,11 +2,11 @@ import React from 'react'; import { action } from 'storybook/actions'; -import { Subnav } from './Subnav'; +import { Toolbar } from './Toolbar'; export default { - title: 'Subnav', - component: Subnav, + title: 'Toolbar', + component: Toolbar, parameters: { layout: 'fullscreen', }, @@ -27,7 +27,7 @@ export default { next: false, end: false, }, - storyFileName: 'Subnav.stories.tsx', + storyFileName: 'Toolbar.stories.tsx', hasNext: true, hasPrevious: true, }, diff --git a/code/core/src/component-testing/components/Toolbar.tsx b/code/core/src/component-testing/components/Toolbar.tsx new file mode 100644 index 000000000000..b46251e1efdd --- /dev/null +++ b/code/core/src/component-testing/components/Toolbar.tsx @@ -0,0 +1,199 @@ +import type { ComponentProps } from 'react'; +import React from 'react'; + +import { Button, P, Separator, Toolbar as SharedToolbar } from 'storybook/internal/components'; + +import { + FastForwardIcon, + PlayBackIcon, + PlayNextIcon, + RewindIcon, + SyncIcon, +} from '@storybook/icons'; + +import { type API } from 'storybook/manager-api'; +import { styled, useTheme } from 'storybook/theming'; + +import { type ControlStates } from '../../instrumenter/types'; +import type { Controls } from './InteractionsPanel'; +import { type PlayStatus, StatusBadge } from './StatusBadge'; + +const ToolbarWrapper = styled.div(({ theme }) => ({ + boxShadow: `${theme.appBorderColor} 0 -1px 0 0 inset`, + background: theme.background.app, + position: 'sticky', + top: 0, + zIndex: 1, +})); + +interface ToolbarProps { + controls: Controls; + controlStates: ControlStates; + status: PlayStatus; + storyFileName?: string; + onScrollToEnd?: () => void; + importPath?: string; + canOpenInEditor?: boolean; + api: API; +} + +const StyledButton = styled(Button)(({ theme }) => ({ + borderRadius: 4, + padding: 6, + color: theme.textMutedColor, + '&:not(:disabled)': { + '&:hover,&:focus-visible': { + color: theme.color.secondary, + }, + }, +})); + +const StyledIconButton = styled(Button)(({ theme }) => ({ + color: theme.textMutedColor, +})); + +const OpenInEditorButton = styled(Button)(({ theme }) => ({ + color: theme.color.secondary, + fontWeight: theme.typography.weight.bold, + justifyContent: 'flex-end', + textAlign: 'right', + whiteSpace: 'nowrap', + fontSize: 13, + lineHeight: 24, +})); + +const StyledLocation = styled(P)(({ theme }) => ({ + color: theme.textMutedColor, + cursor: 'default', + fontWeight: theme.typography.weight.regular, + justifyContent: 'flex-end', + textAlign: 'right', + whiteSpace: 'nowrap', + margin: 0, + fontSize: 13, +})); + +const ControlsGroup = styled.div({ + display: 'flex', + alignItems: 'center', + flex: 1, + gap: 6, +}); + +const RewindButton = styled(StyledIconButton)({ + marginInlineStart: 3, +}); + +const JumpToEndButton = styled(StyledButton)({ + marginInline: 3, + lineHeight: '12px', +}); + +interface AnimatedButtonProps { + animating?: boolean; +} + +const RerunButton = styled(StyledIconButton)< + AnimatedButtonProps & ComponentProps +>(({ theme, animating, disabled }) => ({ + opacity: disabled ? 0.5 : 1, + svg: { + animation: animating ? `${theme.animation.rotate360} 200ms ease-out` : undefined, + }, +})); + +export const Toolbar: React.FC = ({ + controls, + controlStates, + status, + storyFileName, + onScrollToEnd, + importPath, + canOpenInEditor, + api, +}) => { + const buttonText = status === 'errored' ? 'Scroll to error' : 'Scroll to end'; + const theme = useTheme(); + + return ( + + + + + + + {buttonText} + + + + + + + + + + + + + + + + + + + + + + + + + {(importPath || storyFileName) && + (canOpenInEditor ? ( + { + api.openInEditor({ + file: importPath as string, + }); + }} + > + {storyFileName} + + ) : ( + {storyFileName} + ))} + + + ); +}; diff --git a/code/core/src/components/components/Badge/Badge.tsx b/code/core/src/components/components/Badge/Badge.tsx index 4e087fa6e409..038af02e8897 100644 --- a/code/core/src/components/components/Badge/Badge.tsx +++ b/code/core/src/components/components/Badge/Badge.tsx @@ -1,6 +1,6 @@ import React from 'react'; -import { transparentize } from 'polished'; +import { darken, transparentize } from 'polished'; import { styled } from 'storybook/theming'; const BadgeWrapper = styled.div( @@ -74,7 +74,8 @@ const BadgeWrapper = styled.div( } case 'active': { return { - color: theme.color.secondary, + color: + theme.base === 'light' ? darken(0.1, theme.color.secondary) : theme.color.secondary, background: theme.background.hoverable, boxShadow: `inset 0 0 0 1px ${transparentize(0.9, theme.color.secondary)}`, }; diff --git a/code/core/src/components/components/Bar/Bar.stories.tsx b/code/core/src/components/components/Bar/Bar.stories.tsx new file mode 100644 index 000000000000..71140d2a6086 --- /dev/null +++ b/code/core/src/components/components/Bar/Bar.stories.tsx @@ -0,0 +1,114 @@ +import React from 'react'; + +import { styled } from 'storybook/theming'; + +import preview from '../../../../../.storybook/preview'; +import { Bar } from './Bar'; + +const Wrapper = styled.div(({ theme }) => ({ + background: theme.background.app, + border: `1px solid ${theme.appBorderColor}`, + maxWidth: 500, + height: 200, +})); + +const meta = preview.meta({ + component: Bar, + title: 'Bar/Bar', + decorators: [ + (Story) => ( + + + + ), + ], +}); + +export default meta; + +const LongContent = () => + Array.from({ length: 12 }).map((_, i) => ( +
+ Item {i + 1} +
+ )); + +export const Default = meta.story({ args: { children: 'Default' } }); + +export const Bordered = meta.story({ + args: { border: true, children: 'Bar with border' }, +}); + +export const BackgroundBorderless = meta.story({ + args: { backgroundColor: '#f3f4f6', border: false, children: 'Bar with custom background' }, + globals: { + sb_theme: 'light', + }, +}); + +export const BackgroundBordered = meta.story({ + args: { backgroundColor: '#f3f4f6', border: true, children: 'Bar with custom background' }, + globals: { + sb_theme: 'light', + }, +}); + +export const NonScrollable = meta.story({ + name: 'Non-scrollable', + args: { + children: 'Non-scrollable Bar', + scrollable: false, + }, +}); + +export const Scrollable = meta.story({ + args: { scrollable: true, children: }, +}); + +export const ScrollableBordered = meta.story({ + name: 'Scrollable bordered', + args: { + border: true, + children: , + innerStyle: { justifyContent: 'start' }, + scrollable: true, + }, +}); + +export const ScrollableBackground = meta.story({ + name: 'Scrollable background', + args: { + backgroundColor: '#f3f4f6', + border: false, + children: , + innerStyle: { justifyContent: 'start' }, + scrollable: true, + }, + globals: { + sb_theme: 'light', + }, +}); + +export const ScrollableBackgroundBordered = meta.story({ + name: 'Scrollable background bordered', + args: { + backgroundColor: '#f3f4f6', + border: true, + children: , + innerStyle: { justifyContent: 'start' }, + scrollable: true, + }, + globals: { + sb_theme: 'light', + }, +}); + +export const InnerStyleOverride = meta.story({ + args: { + children:
Custom inner style
, + innerStyle: { backgroundColor: '#fff7ed', gap: 12 }, + }, + globals: { + sb_theme: 'light', + }, +}); diff --git a/code/core/src/components/components/Bar/Bar.tsx b/code/core/src/components/components/Bar/Bar.tsx new file mode 100644 index 000000000000..0994ff81e1e3 --- /dev/null +++ b/code/core/src/components/components/Bar/Bar.tsx @@ -0,0 +1,147 @@ +import React, { Children, forwardRef } from 'react'; + +import { deprecate } from 'storybook/internal/client-logger'; + +import { type CSSObject, styled } from 'storybook/theming'; + +export interface BarProps { + backgroundColor?: string; + border?: boolean; + className?: string; + children?: React.ReactNode; + scrollable?: boolean; + innerStyle?: CSSObject; +} + +const StyledBar = styled.div( + ({ backgroundColor, border = false, innerStyle = {}, scrollable, theme }) => ({ + color: theme.barTextColor, + width: '100%', + minHeight: 40, + flexShrink: 0, + // TODO in Storybook 11: Apply background regardless of border. + scrollbarColor: `${theme.barTextColor} ${border ? backgroundColor || theme.barBg : 'transparent'}`, + scrollbarWidth: 'thin', + overflow: scrollable ? 'auto' : 'hidden', + overflowY: 'hidden', + display: 'flex', + alignItems: 'center', + gap: scrollable ? 0 : 6, + paddingInline: scrollable ? 0 : 6, + // TODO in Storybook 11: Apply background regardless of border. + ...(border + ? { + boxShadow: `${theme.appBorderColor} 0 -1px 0 0 inset`, + background: backgroundColor || theme.barBg, + } + : {}), + ...innerStyle, + }) +); + +const HeightPreserver = styled.div>(({ innerStyle }) => ({ + minHeight: 40, + display: 'flex', + alignItems: 'center', + width: '100%', + gap: 6, + paddingInline: 6, + ...innerStyle, +})); + +export const Bar = forwardRef( + ({ scrollable = true, children, innerStyle, ...rest }, ref) => { + return ( + + {scrollable ? ( + {children} + ) : ( + children + )} + + ); + } +); + +Bar.displayName = 'Bar'; + +export interface SideProps { + left?: boolean; + right?: boolean; + scrollable?: boolean; +} + +export const Side = styled.div( + { + display: 'flex', + whiteSpace: 'nowrap', + flexBasis: 'auto', + marginLeft: 3, + marginRight: 10, + }, + ({ scrollable }) => (scrollable ? { flexShrink: 0 } : {}), + ({ left }) => + left + ? { + '& > *': { + marginLeft: 4, + }, + } + : {}, + ({ right }) => + right + ? { + gap: 6, + } + : {} +); +Side.displayName = 'Side'; + +interface BarInnerProps { + bgColor?: string; +} +const BarInner = styled.div(({ bgColor }) => ({ + display: 'flex', + justifyContent: 'space-between', + position: 'relative', + flexWrap: 'nowrap', + flexShrink: 0, + height: 40, + width: '100%', + backgroundColor: bgColor || '', +})); + +export interface FlexBarProps extends BarProps { + border?: boolean; + backgroundColor?: string; +} + +// Compensate new default inline padding for Bar to reduce the extent of visible changes in 10.1 for FlexBar users. +const BarWithoutPadding = styled(Bar)({ + paddingInline: 0, +}); + +export const FlexBar = ({ children, backgroundColor, className = '', ...rest }: FlexBarProps) => { + deprecate('FlexBar is deprecated. Use Bar with justifyContent: "space-between" instead.'); + const [left, right] = Children.toArray(children); + return ( + + + + {left} + + {right ? {right} : null} + + + ); +}; +FlexBar.displayName = 'FlexBar'; diff --git a/code/core/src/components/components/Bar/FlexBar.stories.tsx b/code/core/src/components/components/Bar/FlexBar.stories.tsx new file mode 100644 index 000000000000..770bcfaa79b4 --- /dev/null +++ b/code/core/src/components/components/Bar/FlexBar.stories.tsx @@ -0,0 +1,150 @@ +import React from 'react'; + +import { styled } from 'storybook/theming'; + +import preview from '../../../../../.storybook/preview'; +import { FlexBar } from './Bar'; + +const meta = preview.meta({ component: FlexBar, title: 'Bar/FlexBar (deprecated)' }); + +export default meta; + +const Row = styled.div({ + display: 'flex', + alignItems: 'center', + gap: 4, +}); + +const LongContent = () => + Array.from({ length: 12 }).map((_, i) => ( +
+ Item {i + 1} +
+ )); + +export const Default = meta.story({ + render: (args) => ( + + Left content + Right content + + ), +}); + +export const OnlyLeft = meta.story({ + render: (args) => ( + + Only left + + ), +}); + +export const OnlyRight = meta.story({ + render: (args) => ( + + + Only right + + ), +}); + +export const Background = meta.story({ + args: { + backgroundColor: '#f3f4f6', + }, + globals: { + sb_theme: 'light', + }, + render: (args) => ( + + Left content + Right content + + ), +}); + +export const Border = meta.story({ + args: { + border: true, + }, + render: (args) => ( + + Left content + Right content + + ), +}); + +export const BackgroundBorder = meta.story({ + args: { + backgroundColor: '#f3f4f6', + border: true, + }, + globals: { + sb_theme: 'light', + }, + render: (args) => ( + + Left content + Right content + + ), +}); + +export const ScrollableBackground = meta.story({ + args: { + backgroundColor: '#f3f4f6', + scrollable: true, + }, + globals: { + sb_theme: 'light', + }, + render: (args) => ( + + + + + + + + + ), +}); + +export const ScrollableBorder = meta.story({ + args: { + border: true, + scrollable: true, + }, + render: (args) => ( + + + + + + + + + ), +}); + +export const ScrollableBackgroundBorder = meta.story({ + args: { + backgroundColor: '#f3f4f6', + border: true, + scrollable: true, + }, + globals: { + sb_theme: 'light', + }, + render: (args) => ( + + + + + + + + + ), +}); diff --git a/code/core/src/components/components/bar/separator.tsx b/code/core/src/components/components/Bar/Separator.tsx similarity index 100% rename from code/core/src/components/components/bar/separator.tsx rename to code/core/src/components/components/Bar/Separator.tsx diff --git a/code/core/src/components/components/Button/Button.stories.tsx b/code/core/src/components/components/Button/Button.stories.tsx index 0534c75cda79..f186bc5239cd 100644 --- a/code/core/src/components/components/Button/Button.stories.tsx +++ b/code/core/src/components/components/Button/Button.stories.tsx @@ -2,6 +2,7 @@ import React from 'react'; import { FaceHappyIcon } from '@storybook/icons'; +import { fn } from 'storybook/test'; import { styled } from 'storybook/theming'; import preview from '../../../../../.storybook/preview'; @@ -11,48 +12,61 @@ const meta = preview.meta({ id: 'button-component', title: 'Button', component: Button, - args: { children: 'Button' }, + args: { onClick: fn() }, }); const Stack = styled.div({ display: 'flex', flexDirection: 'column', gap: '1rem' }); const Row = styled.div({ display: 'flex', alignItems: 'center', gap: '1rem' }); -export const Base = meta.story({}); +export const Base = meta.story({ + args: { ariaLabel: false, children: 'Button' }, +}); + +/** This is the variant most commonly used in a toolbar or when a button only contains an icon. */ +export const IconButton = meta.story({ + args: { + ariaLabel: 'Button', + children: , + padding: 'small', + variant: 'ghost', + }, +}); export const Variants = meta.story({ + args: { ariaLabel: false, children: 'Button' }, render: (args) => ( - - - - - - - - - @@ -64,57 +78,147 @@ export const PseudoStates = meta.story({ render: () => ( - - - + + + - - - + + + + + + + + - - - + + + - - - - + + + + ), parameters: { pseudo: { hover: '#hover button', - focus: '#focus button', active: '#active button', + focus: '#focus button', + focusVisible: '#focus-visible button', }, }, }); export const Active = meta.story({ - args: { - active: true, - children: ( - <> - - Button - - ), - }, + name: 'Active (deprecated)', + args: { ariaLabel: false, active: true }, render: (args) => ( - - + + + + + + + + + + + + + + + + + + + + + + + + ), + parameters: { + pseudo: { + hover: '#hover button', + active: '#active button', + focus: '#focus button', + focusVisible: '#focus-visible button', + }, + }, }); export const WithIcon = meta.story({ args: { + ariaLabel: false, children: ( <> @@ -133,6 +237,7 @@ export const WithIcon = meta.story({ export const IconOnly = meta.story({ args: { + ariaLabel: 'Button', children: , padding: 'small', }, @@ -148,14 +253,42 @@ export const IconOnly = meta.story({ export const Sizes = meta.story({ render: () => ( - - + + ), }); +export const Paddings = meta.story({ + render: () => ( + + + + + + + + + + + ), +}); + export const Disabled = meta.story({ args: { + ariaLabel: false, disabled: true, children: 'Disabled Button', }, @@ -164,8 +297,10 @@ export const Disabled = meta.story({ export const WithHref = meta.story({ render: () => ( - - + @@ -174,43 +309,92 @@ export const WithHref = meta.story({ export const Animated = meta.story({ args: { + ariaLabel: false, variant: 'outline', }, render: (args) => ( - - - - - - - - - ), }); + +export const AriaLabel = meta.story({ + args: { + ariaLabel: 'Button', + children: , + }, +}); + +export const Tooltip = meta.story({ + args: { + ariaLabel: false, + children: 'Button', + tooltip: 'A button can be pressed to perform an action', + }, +}); + +export const AriaDescription = meta.story({ + args: { + ariaLabel: 'Button', + ariaDescription: 'Clicking this button allegedly makes you happy.', + children: , + }, +}); + +export const Shortcut = meta.story({ + args: { + ariaLabel: false, + children: 'Button', + shortcut: ['Control', 'Shift', 'H'], + }, +}); + +export const ShortcutAndTooltip = meta.story({ + args: { + ariaLabel: false, + children: 'Button', + tooltip: 'A button can be pressed to perform an action', + shortcut: ['Control', 'Shift', 'H'], + }, +}); + +export const ShortcutAndDefaultTooltip = meta.story({ + args: { + ariaLabel: 'Button', + children: , + shortcut: ['Control', 'Shift', 'H'], + }, +}); diff --git a/code/core/src/components/components/Button/Button.tsx b/code/core/src/components/components/Button/Button.tsx index 30e8c0186b02..93a9821151f1 100644 --- a/code/core/src/components/components/Button/Button.tsx +++ b/code/core/src/components/components/Button/Button.tsx @@ -1,19 +1,59 @@ import type { ButtonHTMLAttributes, SyntheticEvent } from 'react'; -import React, { forwardRef, useEffect, useState } from 'react'; +import React, { forwardRef, useEffect, useMemo, useState } from 'react'; + +import { deprecate } from 'storybook/internal/client-logger'; import { Slot } from '@radix-ui/react-slot'; import { darken, lighten, rgba, transparentize } from 'polished'; +import { type API_KeyCollection, shortcutToAriaKeyshortcuts } from 'storybook/manager-api'; import { isPropValid, styled } from 'storybook/theming'; +import { InteractiveTooltipWrapper } from './helpers/InteractiveTooltipWrapper'; +import { useAriaDescription } from './helpers/useAriaDescription'; + export interface ButtonProps extends ButtonHTMLAttributes { asChild?: boolean; size?: 'small' | 'medium'; padding?: 'small' | 'medium' | 'none'; variant?: 'outline' | 'solid' | 'ghost'; onClick?: (event: SyntheticEvent) => void; - disabled?: boolean; active?: boolean; + disabled?: boolean; animation?: 'none' | 'rotate360' | 'glow' | 'jiggle'; + + /** + * A concise action label for the button announced by screen readers. Needed for buttons without + * text or with text that relies on visual cues to be understood. Pass false to indicate that the + * Button's content is already accessible to all. When a string is passed, it is also used as the + * default tooltip text. + */ + ariaLabel?: string | false; + + /** + * An optional tooltip to display when the Button is hovered. If the Button has no text content, + * consider making this the same as the aria-label. + */ + tooltip?: string; + + /** + * Only use this flag when tooltips on button interfere with other keyboard interactions, like + * when building a custom select or menu button. Disables tooltips from the `tooltip`, `shortcut` + * and `ariaLabel` props. + */ + disableAllTooltips?: boolean; + + /** + * A more thorough description of what the Button does, provided to non-sighted users through an + * aria-describedby attribute. Use sparingly for buttons that trigger complex actions. + */ + ariaDescription?: string; + + /** + * An optional keyboard shortcut to enable the button. Will be displayed in the tooltip and passed + * to aria-keyshortcuts for assistive technologies. The binding of the shortcut and action is + * managed globally in the manager's shortcuts module. + */ + shortcut?: API_KeyCollection; } export const Button = forwardRef( @@ -25,17 +65,44 @@ export const Button = forwardRef( variant = 'outline', padding = 'medium', disabled = false, - active = false, + active, onClick, + ariaLabel, + ariaDescription = undefined, + tooltip = undefined, + shortcut = undefined, + disableAllTooltips = false, ...props }, ref ) => { let Comp: 'button' | 'a' | typeof Slot = 'button'; + if (ariaLabel === undefined || ariaLabel === '') { + deprecate( + `The 'ariaLabel' prop on 'Button' will become mandatory in Storybook 11. Buttons with text content should set 'ariaLabel={false}' to indicate that they are accessible as-is. Buttons without text content must provide a meaningful 'ariaLabel' for accessibility. The button content is: ${props.children}.` + ); + + // TODO in Storybook 11 + // throw new Error( + // 'Button requires an ARIA label to be accessible. Please provide a valid ariaLabel prop.' + // ); + } + + if (active !== undefined) { + deprecate( + 'The `active` prop on `Button` is deprecated and will be removed in Storybook 11. Use specialized components like `ToggleButton` or `Select` instead.' + ); + } + if (asChild) { Comp = Slot; } + const { ariaDescriptionAttrs, AriaDescription } = useAriaDescription(ariaDescription); + + const shortcutAttribute = useMemo(() => { + return shortcut ? shortcutToAriaKeyshortcuts(shortcut) : undefined; + }, [shortcut]); const [isAnimating, setIsAnimating] = useState(false); @@ -59,20 +126,34 @@ export const Button = forwardRef( return () => clearTimeout(timer); }, [isAnimating]); + const finalTooltip = tooltip || (ariaLabel !== false ? ariaLabel : undefined); + return ( - + <> + + + + + ); } ); @@ -82,7 +163,7 @@ Button.displayName = 'Button'; const StyledButton = styled('button', { shouldForwardProp: (prop) => isPropValid(prop), })< - ButtonProps & { + Omit & { animating: boolean; animation: ButtonProps['animation']; } @@ -129,7 +210,7 @@ const StyledButton = styled('button', { lineHeight: '1', background: (() => { if (variant === 'solid') { - return theme.color.secondary; + return theme.base === 'light' ? theme.color.secondary : darken(0.18, theme.color.secondary); } if (variant === 'outline') { @@ -137,44 +218,11 @@ const StyledButton = styled('button', { } if (variant === 'ghost' && active) { - return theme.background.hoverable; + return transparentize(0.93, theme.barSelectedColor); } + return 'transparent'; })(), - ...(variant === 'ghost' - ? { - // This is a hack to apply bar styles to the button as soon as it is part of a bar - // It is a temporary solution until we have implemented Theming 2.0. - '.sb-bar &': { - background: (() => { - if (active) { - return transparentize(0.9, theme.barTextColor); - } - return 'transparent'; - })(), - color: (() => { - if (active) { - return theme.barSelectedColor; - } - return theme.barTextColor; - })(), - '&:hover': { - color: theme.barHoverColor, - background: transparentize(0.86, theme.barHoverColor), - }, - - '&:active': { - color: theme.barSelectedColor, - background: transparentize(0.9, theme.barSelectedColor), - }, - - '&:focus': { - boxShadow: `${rgba(theme.barHoverColor, 1)} 0 0 0 1px inset`, - outline: 'none', - }, - }, - } - : {}), color: (() => { if (variant === 'solid') { return theme.color.lightest; @@ -185,11 +233,11 @@ const StyledButton = styled('button', { } if (variant === 'ghost' && active) { - return theme.color.secondary; + return theme.base === 'light' ? darken(0.1, theme.color.secondary) : theme.color.secondary; } if (variant === 'ghost') { - return theme.color.mediumdark; + return theme.textMutedColor; } return theme.input.color; })(), @@ -204,7 +252,10 @@ const StyledButton = styled('button', { let bgColor = theme.color.secondary; if (variant === 'solid') { - bgColor = theme.color.secondary; + bgColor = + theme.base === 'light' + ? lighten(0.1, theme.color.secondary) + : darken(0.3, theme.color.secondary); } if (variant === 'outline') { @@ -238,9 +289,15 @@ const StyledButton = styled('button', { })(), }, - '&:focus': { - boxShadow: `${rgba(theme.color.secondary, 1)} 0 0 0 1px inset`, - outline: 'none', + '&:focus-visible': { + outline: `2px solid ${rgba(theme.color.secondary, 1)}`, + outlineOffset: 2, + // Should ensure focus outline gets drawn above next sibling + zIndex: '1', + }, + + '.sb-bar &:focus-visible, .sb-list &:focus-visible': { + outlineOffset: 0, }, '> svg': { @@ -248,3 +305,12 @@ const StyledButton = styled('button', { animating && animation !== 'none' ? `${theme.animation[animation]} 1000ms ease-out` : '', }, })); + +export const IconButton = forwardRef((props, ref) => { + deprecate( + '`IconButton` is deprecated and will be removed in Storybook 11, use `Button` instead.' + ); + + return + // Using the asChild prop to render a custom child - `} /> @@ -45,8 +48,8 @@ Use the `size` prop to change the size of the button. You can set the value to ` language="tsx" dark={true} code={` - - + + `} /> ### Button variants @@ -65,28 +68,28 @@ Use the `variant` prop to change the visual style of the button. You can set the ### Button with icon -You can add an icon to the button by adding the icon on the left of the text. Please use any icon from the icon library `@storybook/icons`. +You can add an icon to the button by adding the icon on the left of the text. Please use any icon from the icon library `@storybook/icons`. You can still set `ariaLabel` to `false` if the button's text fully conveys the meaning of the button. If the icon is necessary to understand the button's purpose, you should pass a more meaningful text to `ariaLabel`. - Button + `} /> ### Icon only buttons -You can also use the button as an icon only button by removing the text. to make sure the button is square, please set the padding prop to `small` +You can also use the button as an icon only button by removing the text. To make sure the button is square, please set the padding prop to `small`. You must pass an `ariaLabel` prop to ensure the button is accessible. The `ariaLabel` should describe the action that will be performed when the button is clicked, such as "Like" or "Delete". The Button will automatically display a tooltip based on the `ariaLabel`'s content when hovered. + `} /> @@ -100,10 +103,10 @@ If you want to use a custom wrapper to set the button as an external link or to language="tsx" dark={true} code={` - - `} /> @@ -117,32 +120,17 @@ You can use the `animate` prop to add animations to the button. You can set the language="tsx" dark={true} code={` - - - `} /> -### Active button - -You can use the `active` prop to set the button as active. This will change the background color of the button. - - - - Button - - `} -/> - ### Disabled button You can use the `disabled` prop to set the button as disabled. @@ -152,7 +140,7 @@ You can use the `disabled` prop to set the button as disabled. language="tsx" dark={true} code={` - `} diff --git a/code/core/src/components/components/Button/helpers/InteractiveTooltipWrapper.stories.tsx b/code/core/src/components/components/Button/helpers/InteractiveTooltipWrapper.stories.tsx new file mode 100644 index 000000000000..925222f56a3e --- /dev/null +++ b/code/core/src/components/components/Button/helpers/InteractiveTooltipWrapper.stories.tsx @@ -0,0 +1,66 @@ +import React from 'react'; + +import { styled } from 'storybook/theming'; + +import preview from '../../../../../../.storybook/preview'; +import { InteractiveTooltipWrapper } from './InteractiveTooltipWrapper'; + +const meta = preview.meta({ + id: 'interactive-tooltip-wrapper-component', + title: 'InteractiveTooltipWrapper', + component: InteractiveTooltipWrapper, + args: { children: }, +}); + +const Stack = styled.div({ display: 'flex', flexDirection: 'column', gap: '1rem' }); + +const Row = styled.div({ display: 'flex', alignItems: 'center', gap: '1rem' }); + +export const All = meta.story({ + render: () => ( + + + + + + + + + + + + + + + + + + + + + + + ), +}); + +export const Empty = meta.story({ + args: {}, +}); + +export const Tooltip = meta.story({ + args: { + tooltip: 'Save', + }, +}); + +export const Shortcut = meta.story({ + args: { + shortcut: ['Ctrl', 'S'], + }, +}); +export const TooltipAndShortcut = meta.story({ + args: { + shortcut: ['Ctrl', 'S'], + tooltip: 'Save', + }, +}); diff --git a/code/core/src/components/components/Button/helpers/InteractiveTooltipWrapper.tsx b/code/core/src/components/components/Button/helpers/InteractiveTooltipWrapper.tsx new file mode 100644 index 000000000000..3611a2a83410 --- /dev/null +++ b/code/core/src/components/components/Button/helpers/InteractiveTooltipWrapper.tsx @@ -0,0 +1,44 @@ +import React, { type DOMAttributes, type ReactElement, useMemo } from 'react'; + +import { type API_KeyCollection, shortcutToHumanString } from 'storybook/manager-api'; + +import { TooltipNote } from '../../tooltip/TooltipNote'; +import { TooltipProvider } from '../../tooltip/TooltipProvider'; + +export const InteractiveTooltipWrapper: React.FC<{ + children: ReactElement, string>; + shortcut?: API_KeyCollection; + disableAllTooltips?: boolean; + tooltip?: string; +}> = ({ children, disableAllTooltips, shortcut, tooltip }) => { + const tooltipLabel = useMemo(() => { + // We read from document despite the lack of reactivity, because this + // option isn't changeable in the UI. If it was, we'd need to fetch the + // addons singleton. This component is used in Buttons, etc., which are + // public API and can be imported in MDX. So We rely on a declarative + // DOM attribute instead of relying on the manager API. + const hasShortcuts = document?.body?.getAttribute('data-shortcuts-enabled') !== 'false'; + + if (!tooltip && (!shortcut || !hasShortcuts)) { + return undefined; + } + + return [tooltip, shortcut && hasShortcuts && `[${shortcutToHumanString(shortcut)}]`] + .filter(Boolean) + .join(' '); + }, [shortcut, tooltip]); + + return tooltipLabel ? ( + } + visible={!disableAllTooltips ? undefined : false} + > + {children} + + ) : ( + <>{children} + ); +}; + +InteractiveTooltipWrapper.displayName = 'InteractiveTooltipWrapper'; diff --git a/code/core/src/components/components/Button/helpers/useAriaDescription.tsx b/code/core/src/components/components/Button/helpers/useAriaDescription.tsx new file mode 100644 index 000000000000..a30f2b3b2b19 --- /dev/null +++ b/code/core/src/components/components/Button/helpers/useAriaDescription.tsx @@ -0,0 +1,29 @@ +import React, { type ReactElement } from 'react'; + +/** + * Provides a way to create an accessible description for an element. Returns a hidden element that + * contains the description and attributes to pass to the described element. + * + * @param description The description to provide for the element. + * @returns + */ +export function useAriaDescription(description = ''): { + ariaDescriptionAttrs: { + 'aria-describedby'?: string; + }; + AriaDescription: () => ReactElement | null; +} { + const describedbyId = description.toLowerCase().trim().replace(/\s+/g, '-'); + + return { + ariaDescriptionAttrs: { + 'aria-describedby': description ? describedbyId : undefined, + }, + AriaDescription: () => + description ? ( + + ) : null, + }; +} diff --git a/code/core/src/components/components/Form/Checkbox.stories.tsx b/code/core/src/components/components/Form/Checkbox.stories.tsx index 00458f58b8f6..9892b92a01bf 100644 --- a/code/core/src/components/components/Form/Checkbox.stories.tsx +++ b/code/core/src/components/components/Form/Checkbox.stories.tsx @@ -15,49 +15,86 @@ export const Checkbox: Story = { render: () => (
- Custom: - Native: + Custom: + Native: - Checked, focus: - + Checked, focus: +
- +
- Checked: - + Checked: +
- +
- Indeterminate: - + Indeterminate: +
- +
- Default: - + Default: +
- +
- Disabled, checked: - + Disabled, checked: +
- +
- Disabled, indeterminate: - + Disabled, indeterminate: +
- +
- Disabled: - + Disabled: +
- +
), diff --git a/code/core/src/components/components/Form/Checkbox.tsx b/code/core/src/components/components/Form/Checkbox.tsx index e9cb7c423d01..aadbd979e9f9 100644 --- a/code/core/src/components/components/Form/Checkbox.tsx +++ b/code/core/src/components/components/Form/Checkbox.tsx @@ -2,7 +2,7 @@ import React from 'react'; import { color, styled } from 'storybook/theming'; -const Input = styled.input({ +const Input = styled.input(({ theme }) => ({ appearance: 'none', display: 'grid', placeContent: 'center', @@ -10,19 +10,19 @@ const Input = styled.input({ height: 14, flexShrink: 0, margin: 0, - border: `1px solid ${color.border}`, + border: `1px solid ${theme.input.border}`, borderRadius: 2, - backgroundColor: 'white', + backgroundColor: theme.input.background, transition: 'background-color 0.1s', '&:enabled': { cursor: 'pointer', }, '&:disabled': { - backgroundColor: color.medium, + backgroundColor: theme.base === 'light' ? color.light : 'transparent', }, '&:disabled:checked, &:disabled:indeterminate': { - backgroundColor: color.mediumdark, + backgroundColor: theme.base === 'light' ? color.mediumdark : theme.color.dark, }, '&:checked, &:indeterminate': { backgroundColor: color.secondary, @@ -40,10 +40,10 @@ const Input = styled.input({ background: 'white', }, '&:enabled:focus-visible': { - outline: `1px solid ${color.secondary}`, - outlineOffset: 1, + outline: `2px solid ${theme.color.secondary}`, + outlineOffset: 2, }, -}); +})); export const Checkbox = (props: React.InputHTMLAttributes) => { return ; diff --git a/code/core/src/components/components/Form/Input.stories.tsx b/code/core/src/components/components/Form/Input.stories.tsx index b8fad2b7f506..285efd9833e4 100644 --- a/code/core/src/components/components/Form/Input.stories.tsx +++ b/code/core/src/components/components/Form/Input.stories.tsx @@ -11,4 +11,6 @@ export default meta; type Story = StoryObj; -export const Input: Story = {}; +export const Input: Story = { + render: (args) => , +}; diff --git a/code/core/src/components/components/Form/Radio.stories.tsx b/code/core/src/components/components/Form/Radio.stories.tsx index 8e6b62b14624..c37d5b50a305 100644 --- a/code/core/src/components/components/Form/Radio.stories.tsx +++ b/code/core/src/components/components/Form/Radio.stories.tsx @@ -15,49 +15,100 @@ export const Radio: Story = { render: () => (
- Custom: - Native: + Custom: + Native: - Checked, focus: - + Checked, focus: +
- +
- Checked: - + Checked: +
- +
- Indeterminate: - + Indeterminate: +
- +
- Default: - + Default: +
- +
- Disabled, checked: - + Disabled, checked: +
- +
- Disabled, indeterminate: - + Disabled, indeterminate: +
- +
- Disabled: - + Disabled: +
- +
), diff --git a/code/core/src/components/components/Form/Radio.tsx b/code/core/src/components/components/Form/Radio.tsx index b9d6297d9e73..2ea6e9cd7310 100644 --- a/code/core/src/components/components/Form/Radio.tsx +++ b/code/core/src/components/components/Form/Radio.tsx @@ -2,7 +2,7 @@ import React from 'react'; import { color, styled } from 'storybook/theming'; -const Input = styled.input({ +const Input = styled.input(({ theme }) => ({ appearance: 'none', display: 'grid', placeContent: 'center', @@ -10,29 +10,29 @@ const Input = styled.input({ height: 16, flexShrink: 0, margin: -1, - border: `1px solid ${color.border}`, + border: `1px solid ${theme.input.border}`, borderRadius: 8, - backgroundColor: 'white', + backgroundColor: theme.input.background, transition: 'background-color 0.1s', '&:enabled': { cursor: 'pointer', }, '&:disabled': { - backgroundColor: color.medium, + backgroundColor: theme.base === 'light' ? color.light : 'transparent', }, '&:disabled:checked': { - backgroundColor: color.mediumdark, + backgroundColor: theme.base === 'light' ? color.light : theme.color.mediumdark, }, '&:checked': { backgroundColor: color.secondary, - boxShadow: `inset 0 0 0 2px white`, + boxShadow: `inset 0 0 0 2px ${theme.input.background}`, }, '&:enabled:focus-visible': { - outline: `1px solid ${color.secondary}`, - outlineOffset: 1, + outline: `2px solid ${theme.color.secondary}`, + outlineOffset: 2, }, -}); +})); export const Radio = (props: React.InputHTMLAttributes) => { return ; diff --git a/code/core/src/components/components/Form/Select.stories.tsx b/code/core/src/components/components/Form/Select.stories.tsx index fee24c873bb8..c576e8f00013 100644 --- a/code/core/src/components/components/Form/Select.stories.tsx +++ b/code/core/src/components/components/Form/Select.stories.tsx @@ -15,7 +15,7 @@ export default meta; export const Select: Story = { render: (args) => ( - + diff --git a/code/core/src/components/components/Form/Select.tsx b/code/core/src/components/components/Form/Select.tsx index 9ea263307e86..e85b6583808c 100644 --- a/code/core/src/components/components/Form/Select.tsx +++ b/code/core/src/components/components/Form/Select.tsx @@ -47,7 +47,7 @@ const BaseSelect = styled.select(sizes, ({ theme }) => ({ '& > svg': { width: 14, height: 14, - color: theme.color.mediumdark, + color: theme.textMutedColor, }, }, '&:has(option:not([hidden]):checked)': { diff --git a/code/core/src/components/components/Form/Textarea.stories.tsx b/code/core/src/components/components/Form/Textarea.stories.tsx index a02bff32de13..6647707f210a 100644 --- a/code/core/src/components/components/Form/Textarea.stories.tsx +++ b/code/core/src/components/components/Form/Textarea.stories.tsx @@ -11,4 +11,6 @@ type Story = StoryObj; export default meta; -export const Textarea: Story = {}; +export const Textarea: Story = { + render: (args) => , +}; diff --git a/code/core/src/components/components/Form/styles.ts b/code/core/src/components/components/Form/styles.ts index 57924b844aba..ee88bb2ec3e1 100644 --- a/code/core/src/components/components/Form/styles.ts +++ b/code/core/src/components/components/Form/styles.ts @@ -108,8 +108,7 @@ export const styles = (({ theme }: { theme: StorybookTheme }) => ({ }, '&[disabled]': { - cursor: 'not-allowed', - opacity: 0.5, + background: theme.base === 'light' ? theme.color.lighter : 'transparent', }, '&:-webkit-autofill': { WebkitBoxShadow: `0 0 0 3em ${theme.color.lightest} inset` }, diff --git a/code/core/src/components/components/IconButton/IconButton.stories.tsx b/code/core/src/components/components/IconButton/IconButton.stories.tsx deleted file mode 100644 index b30009355b15..000000000000 --- a/code/core/src/components/components/IconButton/IconButton.stories.tsx +++ /dev/null @@ -1,84 +0,0 @@ -import React from 'react'; - -import { FaceHappyIcon } from '@storybook/icons'; - -import type { Meta, StoryObj } from '@storybook/react-vite'; - -import { IconButton } from './IconButton'; - -const meta = { - title: 'IconButton', - component: IconButton, - tags: ['autodocs'], - args: { children: }, -} satisfies Meta; - -export default meta; -type Story = StoryObj; - -export const Base = {}; - -export const Types: Story = { - render: ({ ...args }) => ( -
- - - -
- ), -}; - -export const Active: Story = { - args: { active: true }, - render: ({ ...args }) => ( -
- - - -
- ), -}; - -export const Sizes: Story = { - args: { variant: 'solid' }, - render: ({ ...args }) => ( -
- - -
- ), -}; - -export const Disabled: Story = { - args: { disabled: true }, - render: ({ ...args }) => ( -
- - - -
- ), -}; - -export const Animated: Story = { - render: ({ ...args }) => ( -
- - - -
- ), -}; - -export const WithHref: Story = { - render: ({ ...args }) => ( -
- console.log('Hello')} /> - - - - - -
- ), -}; diff --git a/code/core/src/components/components/IconButton/IconButton.tsx b/code/core/src/components/components/IconButton/IconButton.tsx deleted file mode 100644 index 31bf06902b36..000000000000 --- a/code/core/src/components/components/IconButton/IconButton.tsx +++ /dev/null @@ -1,12 +0,0 @@ -import React, { forwardRef } from 'react'; - -import type { ButtonProps } from '../Button/Button'; -import { Button } from '../Button/Button'; - -export const IconButton = forwardRef( - ({ padding = 'small', variant = 'ghost', ...props }, ref) => { - return + + + + + + + + + + + +); + +const MockContainer = forwardRef< + HTMLDivElement, + { + bgColor: string; + borderColor: string; + id?: string; + text: string; + } +>(({ bgColor, borderColor, id, text }, ref) => ( +
+
+ {text} +
+
+)); +MockContainer.displayName = 'MockContainer'; -const meta = { +const meta = preview.meta({ + id: 'overlay-Modal', + title: 'Overlay/Modal', component: Modal, + args: { + ariaLabel: 'Sample modal', + dismissOnClickOutside: true, + dismissOnEscape: true, + }, + globals: { + sb_theme: 'light', + }, + 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 Base = meta.story({ + args: { + children: , + }, + render: (args) => { + const [isOpen, setOpen] = useState(false); + + return ( + <> + + + + ); + }, +}); -export const Default: Story = { +export const FixedWidth = meta.story({ args: { - children: undefined, - width: undefined, - height: undefined, + 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 = meta.story({ + args: { + height: 300, + children: , }, -}; + render: (args) => { + const [isOpen, setOpen] = useState(false); -export const FixedWidth: Story = { + return ( + <> + + + + ); + }, +}); + +export const FixedDimensions = meta.story({ args: { - ...Default.args, - width: 1024, + 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

+ +
+
+ ), +}); -export const FixedHeight: Story = { +export const OnInteractOutside = meta.story({ + name: 'OnInteractOutside (deprecated)', args: { - ...Default.args, - height: 430, + children: , + onInteractOutside: fn(), }, - 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(); + play: async ({ args, canvas, step }) => { + await step('Open modal', async () => { + const trigger = canvas.getByText('Open Modal'); + await userEvent.click(trigger); + await waitFor(() => { + expect(screen.queryByText('Sample Modal')).toBeInTheDocument(); + }); + }); + + await step('Click outside to close', async () => { + const outsideButton = canvas.getByText('Outside Button'); + await userEvent.click(outsideButton); + expect(args.onInteractOutside).toHaveBeenCalled(); + await waitFor(() => { + expect(screen.queryByText('Sample Modal')).not.toBeInTheDocument(); + }); + }); }, -}; +}); -export const FixedWidthAndHeight: Story = { +export const OnInteractOutsidePreventDefault = meta.story({ + name: 'OnInteractOutside - e.preventDefault (deprecated)', args: { - ...Default.args, - width: 1024, - height: 430, + children: , + onInteractOutside: (e) => e.preventDefault(), }, - render: (props) => { + render: (args) => { const [isOpen, setOpen] = useState(false); return ( <> - -
-
Hello world!
- setOpen(false)}>Close -
-
- + + + + + ); + }, + play: async ({ canvas, step }) => { + await step('Open modal', async () => { + const trigger = canvas.getByText('Open Modal'); + await userEvent.click(trigger); + await waitFor(() => { + expect(screen.queryByText('Sample Modal')).toBeInTheDocument(); + }); + }); + + await step('Click outside to close but modal stays open', async () => { + const outsideButton = canvas.getByText('Outside Button'); + await userEvent.click(outsideButton); + // Wait a bit to ensure the modal close animation would've had time to play. + await new Promise((r) => setTimeout(r, 300)); + await waitFor(() => { + expect(screen.queryByText('Sample Modal')).toBeInTheDocument(); + }); + }); + }, +}); + +export const OnInteractOutsideDismissDisabled = meta.story({ + name: 'OnInteractOutside - dismiss disabled (deprecated)', + args: { + children: , + dismissOnClickOutside: false, + onInteractOutside: fn(), + }, + render: (args) => { + const [isOpen, setOpen] = useState(false); + + return ( + <> + + + + + ); + }, + play: async ({ args, canvas, step }) => { + await step('Open modal', async () => { + const trigger = canvas.getByText('Open Modal'); + await userEvent.click(trigger); + await waitFor(() => { + expect(screen.queryByText('Sample Modal')).toBeInTheDocument(); + }); + }); + + await step('Click outside to close, nothing should happen', async () => { + const outsideButton = canvas.getByText('Outside Button'); + await userEvent.click(outsideButton); + expect(args.onInteractOutside).not.toHaveBeenCalled(); + // Wait a bit to ensure the modal close animation would've had time to play. + await new Promise((r) => setTimeout(r, 300)); + await waitFor(() => { + expect(screen.queryByText('Sample Modal')).toBeInTheDocument(); + }); + }); + }, +}); + +export const OnEscapeKeyDown = meta.story({ + name: 'OnEscapeKeyDown (deprecated)', + args: { + children: , + onEscapeKeyDown: fn(), + }, + render: (args) => { + const [isOpen, setOpen] = useState(false); + + return ( + <> + + + + ); + }, + play: async ({ args, canvas, step }) => { + await step('Open modal', async () => { + const trigger = canvas.getByText('Open Modal'); + await userEvent.click(trigger); + await waitFor(() => { + expect(screen.queryByText('Sample Modal')).toBeInTheDocument(); + }); + }); + + await step('Close modal with Escape key', async () => { + await userEvent.keyboard('{Escape}'); + expect(args.onEscapeKeyDown).toHaveBeenCalled(); + await waitFor(() => { + expect(screen.queryByText('Sample Modal')).not.toBeInTheDocument(); + }); + }); + }, +}); + +export const OnEscapeKeyDownPreventDefault = meta.story({ + name: 'OnEscapeKeyDown - e.preventDefault (deprecated)', + args: { + children: , + onEscapeKeyDown: (e) => e.preventDefault(), + }, + render: (args) => { + const [isOpen, setOpen] = useState(false); + + return ( + <> + + ); }, - 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(); + play: async ({ canvas, step }) => { + await step('Open modal', async () => { + const trigger = canvas.getByText('Open Modal'); + await userEvent.click(trigger); + await waitFor(() => { + expect(screen.queryByText('Sample Modal')).toBeInTheDocument(); + }); + }); + + await step('Click outside to close but modal stays open', async () => { + await userEvent.keyboard('{Escape}'); + // Wait a bit to ensure the modal close animation would've had time to play. + await new Promise((r) => setTimeout(r, 300)); + await waitFor(() => { + expect(screen.queryByText('Sample Modal')).toBeInTheDocument(); + }); + }); }, +}); + +export const OnEscapeKeyDownEscDisabled = meta.story({ + name: 'OnEscapeKeyDown - dismiss disabled (deprecated)', + args: { + children: , + dismissOnEscape: false, + onEscapeKeyDown: fn(), + }, + render: (args) => { + const [isOpen, setOpen] = useState(false); + + return ( + <> + + + + ); + }, + play: async ({ args, canvas, step }) => { + await step('Open modal', async () => { + const trigger = canvas.getByText('Open Modal'); + await userEvent.click(trigger); + await waitFor(() => { + expect(screen.queryByText('Sample Modal')).toBeInTheDocument(); + }); + }); + + await step('Click outside to close, nothing should happen', async () => { + await userEvent.keyboard('{Escape}'); + expect(args.onEscapeKeyDown).not.toHaveBeenCalled(); + // Wait a bit to ensure the modal close animation would've had time to play. + await new Promise((r) => setTimeout(r, 300)); + await waitFor(() => { + expect(screen.queryByText('Sample Modal')).toBeInTheDocument(); + }); + }); + }, +}); + +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, step }) => { + const canvas = within(canvasElement); + + await step('Open modal and verify callback', async () => { + const trigger = canvas.getByText('Open Modal (with callback)'); + await userEvent.click(trigger); + await waitFor(() => { + expect(screen.queryByText('Sample Modal')).toBeInTheDocument(); + }); + await waitFor(() => { + expect(screen.getByRole('dialog')).toBeInTheDocument(); + }); + await expect(args.onOpenChange).toHaveBeenCalledWith(true); + }); + + await step('Close modal and verify callback', async () => { + const closeButton = await waitFor(() => screen.findByLabelText('Close modal'), { + timeout: 3000, + }); + 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 waitFor(() => { + expect(screen.queryByText('Sample Modal')).toBeInTheDocument(); + }); + await waitFor(() => { + expect(screen.getByRole('dialog')).toBeInTheDocument(); + }); + }); + + await step('Navigate through modal content with focus trap', async () => { + await new Promise((resolve) => setTimeout(resolve, 500)); + const closeButton = await waitFor( + () => screen.findByRole('button', { name: 'Close modal' }), + { timeout: 3000 } + ); + closeButton.focus(); + + 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 userEvent.tab(); + await expect(sampleButton).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 waitFor(() => { + expect(screen.queryByText('Sample Modal')).toBeInTheDocument(); + }); + await waitFor(() => { + expect(screen.getByRole('dialog')).toBeInTheDocument(); + }); + }); + + await step('Click close button', async () => { + const closeButton = await waitFor(() => screen.findByLabelText('Close modal'), { + timeout: 3000, + }); + 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 const WithContainer = meta.story({ + args: { + children: , + }, + render: (args) => { + const [isOpen, setOpen] = useState(false); + const container = useRef(null); + + return ( + <> + + + + + ); + }, +}); + +export const WithPortalSelector = meta.story({ + args: { + children: , + portalSelector: '#custom-modal-portal-target', + }, + render: (args) => { + const [isOpen, setOpen] = useState(false); + + return ( + <> + + + + + ); + }, +}); + +export const WithContainerAndPortalSelector = meta.story({ + args: { + children: , + portalSelector: '#ignored-portal-target', + }, + render: (args) => { + const [isOpen, setOpen] = useState(false); + const [container, setContainer] = useState(null); + + return ( + <> + + + setContainer(element ?? null)} + /> + { + + } + + ); + }, +}); + +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 05cd72f04c9e..b50defb0c350 100644 --- a/code/core/src/components/components/Modal/Modal.styled.tsx +++ b/code/core/src/components/components/Modal/Modal.styled.tsx @@ -1,18 +1,29 @@ import type { ComponentProps } from 'react'; -import React from 'react'; +import React, { useContext } from 'react'; + +import { deprecate } from 'storybook/internal/client-logger'; import { CrossIcon } from '@storybook/icons'; -import * as Dialog from '@radix-ui/react-dialog'; +import { Heading } from 'react-aria-components/patched-dist/Heading'; +import { Text } from 'react-aria-components/patched-dist/Text'; +import type { TransitionStatus } from 'react-transition-state'; import { keyframes, styled } from 'storybook/theming'; -import { IconButton } from '../IconButton/IconButton'; +import { Button } from '../Button/Button'; +// 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 +40,180 @@ 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, + maxHeight: '0px', + }, + to: { + opacity: 1, + maxHeight: '80vh', + }, +}); + +const slideToBottom = keyframes({ + from: { + opacity: 1, + maxHeight: '80vh', + }, + to: { + opacity: 0, + maxHeight: '0px', + }, +}); + +export const Overlay = styled.div<{ + $status?: TransitionStatus; + $transitionDuration?: number; +}>(({ $status, $transitionDuration }) => ({ backdropFilter: 'blur(24px)', - position: 'fixed', + background: 'rgba(0, 0, 0, 0.4)', + position: 'absolute', 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', + position: 'absolute', 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%', + interpolateSize: 'allow-keywords', + maxWidth: '100%', + '@media (prefers-reduced-motion: no-preference)': { + animationTimingFunction: 'cubic-bezier(.9,.16,.77,.64)', + animation: + $status === 'exiting' || $status === 'preExit' + ? `${slideToBottom} ${$transitionDuration}ms` + : `${slideFromBottom} ${$transitionDuration}ms`, + animationFillMode: 'forwards !important', + }, + } ); -export const CloseButton = (props: React.ComponentProps) => ( - - +interface CloseProps { + asChild?: boolean; + children?: React.ReactElement< + { + onClick?: (event: React.MouseEvent) => void; + }, + | string + | React.JSXElementConstructor<{ + onClick?: (event: React.MouseEvent) => void; + }> + >; + onClick?: (event: React.MouseEvent) => void; +} + +export const Close = ({ asChild, children, onClick, ...props }: CloseProps) => { + const { close } = useContext(ModalContext); + + if (asChild && React.isValidElement(children)) { + const handleClick = (event: React.MouseEvent) => { + onClick?.(event); + children.props.onClick?.(event); + close?.(); + }; + + return React.cloneElement(children, { + ...props, + onClick: handleClick, + }); + } + + return ( + + ); +}; + +export const Dialog = { + Close: () => { + deprecate('Modal.Dialog.Close is deprecated, please use Modal.Close instead.'); + return ; + }, +}; + +export const CloseButton = ({ ariaLabel, ...props }: React.ComponentProps) => { + deprecate('Modal.CloseButton is deprecated, please use Modal.Close instead.'); + + return ( + + + + ); +}; export const Content = styled.div({ display: 'flex', @@ -89,20 +234,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 +268,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..8d7efd17ccdd 100644 --- a/code/core/src/components/components/Modal/Modal.tsx +++ b/code/core/src/components/components/Modal/Modal.tsx @@ -1,58 +1,252 @@ -import React from 'react'; +import React, { type HTMLAttributes, createContext, useEffect, useRef, useState } from 'react'; -import * as Dialog from '@radix-ui/react-dialog'; +import { deprecate } from 'storybook/internal/client-logger'; +import type { DecoratorFunction } from 'storybook/internal/csf'; +import { FocusScope } from '@react-aria/focus'; +import { Overlay, UNSAFE_PortalProvider, useModalOverlay } from '@react-aria/overlays'; +import { mergeProps } from '@react-aria/utils'; +import { useOverlayTriggerState } from '@react-stately/overlays'; +import type { KeyboardEvent as RAKeyboardEvent } from '@react-types/shared'; +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 { + container?: HTMLElement; + + portalSelector?: string; + + /** 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; + + /** @deprecated Use `dismissOnEscape` instead. */ + onEscapeKeyDown?: (event: KeyboardEvent) => void; + + /** @deprecated Use `dismissOnInteractOutside` instead. */ + onInteractOutside?: (event: FocusEvent | MouseEvent | TouchEvent) => void; + + /** Handler called when visibility of the Modal changes. */ + onOpenChange?: (isOpen: boolean) => void; + + // TODO: Storybook 11, make this required + /** 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({ + container, + portalSelector, children, width, height, - onEscapeKeyDown, - onInteractOutside = (ev) => ev.preventDefault(), + ariaLabel, + dismissOnClickOutside = true, + dismissOnEscape = true, className, - container, - portalSelector, - ...rootProps + open, + onEscapeKeyDown, + onInteractOutside, + onOpenChange, + defaultOpen, + transitionDuration = 200, + variant = 'dialog', + ...props }: ModalProps) { + if (ariaLabel === undefined || ariaLabel === '') { + deprecate('The `ariaLabel` prop on `Modal` will become mandatory in Storybook 11.'); + // TODO in Storybook 11 + // throw new Error( + // 'Modal requires an ARIA label to be accessible. Please provide a valid ariaLabel prop.' + // ); + } + + if (onEscapeKeyDown !== undefined) { + deprecate( + 'The `onEscapeKeyDown` prop is deprecated and will be removed in Storybook 11. Use `dismissOnEscape` instead.' + ); + } + + if (onInteractOutside !== undefined) { + deprecate( + 'The `onInteractOutside` prop is deprecated and will be removed in Storybook 11. Use `dismissOnInteractOutside` instead.' + ); + } + + const overlayRef = useRef(null); + + const reducedMotion = useMediaQuery('(prefers-reduced-motion: reduce)'); + const [{ status, isMounted }, toggle] = useTransitionState({ + timeout: reducedMotion ? 0 : transitionDuration, + mountOnEnter: true, + unmountOnExit: true, + }); + + // Create state for the overlay trigger + const state = useOverlayTriggerState({ + isOpen: open || isMounted, + defaultOpen, + onOpenChange: (isOpen: boolean) => { + toggle(isOpen); + onOpenChange?.(isOpen); + }, + }); + + const close = () => { + state.close(); + }; + + const { modalProps, underlayProps } = useModalOverlay( + { + isDismissable: dismissOnClickOutside, + isKeyboardDismissDisabled: true, + shouldCloseOnInteractOutside: onInteractOutside + ? (element: Element) => { + const mockedEvent = new MouseEvent('click', { + bubbles: true, + cancelable: true, + relatedTarget: element, + }); + onInteractOutside(mockedEvent); + return !mockedEvent.defaultPrevented; + } + : undefined, + }, + state, + overlayRef + ); + + // 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 + useEffect(() => { + if (isMounted && (open || defaultOpen)) { + onOpenChange?.(true); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isMounted]); + + if (!isMounted || status === 'exited' || status === 'unmounted') { + return null; + } + + const finalModalProps = mergeProps(modalProps, { + onKeyDown: (e: RAKeyboardEvent) => { + if (e.key !== 'Escape') { + modalProps.onKeyDown?.(e); + } else { + if (dismissOnEscape) { + onEscapeKeyDown?.(e.nativeEvent); + if (!e.nativeEvent.defaultPrevented) { + close(); + } + } + } + }, + }); + const containerElement = - container ?? (portalSelector ? document.querySelector(portalSelector) : null) ?? document.body; + container ?? (portalSelector ? document.querySelector(portalSelector) : undefined); return ( - - - - - - - - {children} - - - - + + {/* Overlay won't place focus within the modal on its own, and so its own FocusScope + starts cycling through focusable elements only after we've clicked or tabbed into the modal. + So we use our own focus scope and autofocus within on mount. */} + {/* eslint-disable-next-line jsx-a11y/no-autofocus */} + + +
+ + {/* We need to set the FocusScope ourselves somehow, Overlay won't set it. */} + + {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); + + if (args.container || args.portalSelector) { + return ; + } + + 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..20614d2f8d70 --- /dev/null +++ b/code/core/src/components/components/Popover/Popover.tsx @@ -0,0 +1,116 @@ +import React, { type HTMLAttributes, forwardRef } from 'react'; + +import { CloseIcon } from '@storybook/icons'; + +import { lighten, styled } from 'storybook/theming'; + +import { Button } from '../Button/Button'; + +export interface PopoverProps extends HTMLAttributes { + /** 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/PopoverProvider.stories.tsx b/code/core/src/components/components/Popover/PopoverProvider.stories.tsx new file mode 100644 index 000000000000..c6a4f4f68cb5 --- /dev/null +++ b/code/core/src/components/components/Popover/PopoverProvider.stories.tsx @@ -0,0 +1,263 @@ +import React from 'react'; + +import { expect, fn, screen, userEvent, within } from 'storybook/test'; +import { styled } from 'storybook/theming'; + +import preview from '../../../../../.storybook/preview'; +import { OverlayTriggerDecorator, Trigger } from '../shared/overlayHelpers'; +import { PopoverProvider } from './PopoverProvider'; + +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-PopoverProvider', + title: 'Overlay/PopoverProvider', + component: PopoverProvider, + 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 = screen.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(screen.queryByText('Lorem ipsum dolor sit amet')).toBeInTheDocument(); + }); + + await step('Press Tab to enter popover', async () => { + await userEvent.tab(); + const continueButton = await screen.findByText('Continue'); + await expect(continueButton).toHaveFocus(); + }); + + await step('Press Esc to close popover', async () => { + await userEvent.keyboard('{Escape}'); + await expect(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(screen.queryByText('Lorem ipsum dolor sit amet')).toBeInTheDocument(); + }); + + await step('Click outside popover to close it', async () => { + const sibling = canvas.getByText('Sibling Button'); + await userEvent.click(sibling); + await expect(screen.queryByText('Lorem ipsum dolor sit amet')).not.toBeInTheDocument(); + }); + }, +}); + +export const WithLongContent = meta.story({ + args: { + children: Long content, + popover: ( +
+

Very Long Tooltip Content

+

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

+
+ ), + }, +}); diff --git a/code/core/src/components/components/Popover/PopoverProvider.tsx b/code/core/src/components/components/Popover/PopoverProvider.tsx new file mode 100644 index 000000000000..807cc79d1fb1 --- /dev/null +++ b/code/core/src/components/components/Popover/PopoverProvider.tsx @@ -0,0 +1,106 @@ +import type { DOMAttributes, ReactElement, ReactNode } from 'react'; +import React, { useCallback, useState } from 'react'; + +import { Pressable } from '@react-aria/interactions'; +import { DialogTrigger } from 'react-aria-components/patched-dist/Dialog'; +import { Popover as PopoverUpstream } from 'react-aria-components/patched-dist/Popover'; + +import { type PopperPlacement, convertToReactAriaPlacement } from '../shared/overlayHelpers'; +import { Popover } from './Popover'; + +export interface PopoverProviderProps { + /** 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 PopoverProvider = ({ + placement: placementProp = 'bottom-start', + hasChrome = true, + hasCloseButton = false, + closeLabel, + offset = 8, + padding, + popover, + children, + defaultVisible, + visible, + onVisibleChange, + ...props +}: PopoverProviderProps) => { + // 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/ScrollArea/ScrollArea.tsx b/code/core/src/components/components/ScrollArea/ScrollArea.tsx index 50df75de75a9..30394f03fdf8 100644 --- a/code/core/src/components/components/ScrollArea/ScrollArea.tsx +++ b/code/core/src/components/components/ScrollArea/ScrollArea.tsx @@ -10,6 +10,7 @@ export interface ScrollAreaProps { className?: string; offset?: number; scrollbarSize?: number; + scrollPadding?: number | string; } const ScrollAreaRoot = styled(ScrollAreaPrimitive.Root)<{ scrollbarsize: number; offset: number }>( @@ -79,11 +80,21 @@ const ScrollAreaThumb = styled(ScrollAreaPrimitive.Thumb)(({ theme }) => ({ export const ScrollArea = forwardRef( ( - { children, horizontal = false, vertical = false, offset = 2, scrollbarSize = 6, className }, + { + children, + horizontal = false, + vertical = false, + offset = 2, + scrollbarSize = 6, + scrollPadding = 0, + className, + }, ref ) => ( - {children} + + {children} + {horizontal && ( , + onChange: fn(), + onSelect: fn(), + onDeselect: fn(), + options: [ + { title: 'Tadpole', value: 'tadpole' }, + { title: 'Pollywog', value: 'pollywog' }, + { title: 'Frog', value: 'frog' }, + ], + }, + tags: ['!vitest'], +}); + +const Stack = styled.div({ display: 'flex', flexDirection: 'column', gap: '1rem' }); + +const Row = styled.div({ display: 'flex', alignItems: 'center', gap: '1rem' }); + +export const Base = meta.story({}); + +export const Sizes = meta.story({ + render: (args) => ( + + + + + + + ), +}); + +export const Paddings = meta.story({ + render: (args) => ( + + + + + + + + ), +}); + +export const PseudoStates = meta.story({ + args: { + options: [{ title: 'Frog', value: 'frog' }], + }, + render: (args) => ( + + +

Inactive

+ + + + +
+ +

Hover

+ + + + +
+ +

Focus

+ + + + +
+
+ ), + parameters: { + pseudo: { + hover: '.hover button', + focus: '.focus button', + focusVisible: '.focus button', + active: '.active button', + }, + }, +}); + +export const ManyOptions = meta.story({ + args: { + options: Array.from({ length: 20 }, (_, i) => ({ + title: `Option ${i + 1}`, + value: `option-${i + 1}`, + })), + }, +}); + +export const LongOptionLabels = meta.story({ + name: 'Long Option Labels', + args: { + children: 'Long labels', + options: [ + { + title: 'This is a very long option label that might cause wrapping issues', + value: 'long1', + }, + { + title: + 'Another extremely long option label that tests how the component handles overflow, and if you think that is too long, you may well be justified in thinking so, albeit it is a test case', + value: 'long2', + }, + { title: 'Short', value: 'short' }, + ], + }, +}); + +export const CustomOptionRendering = meta.story({ + name: 'Custom Option Rendering', + args: { + children: 'Custom options', + options: [ + { + title: 'Tadpole', + value: 'tadpole', + children: ( + <> + 1. 👶 Tadpole + + ), + }, + { + title: 'Pollywog', + value: 'pollywog', + children: ( + <> + 2. 👧 Pollywog + + ), + }, + { + title: 'Frog', + value: 'frog', + children: ( + <> + 3. 🐸 Frog + + ), + }, + ], + }, +}); + +export const WithSiblings = meta.story({ + render: (args) => ( + + + + + + ), + play: async ({ canvas, args }) => { + const selectButton = await canvas.findByRole('button', { name: /Animal/ }); + await userEvent.click(selectButton); + + const tadpoleOption = await screen.findByRole('option', { name: 'Tadpole' }); + await userEvent.click(tadpoleOption); + + expect(args.onSelect).toHaveBeenCalledWith('tadpole'); + expect(args.onChange).toHaveBeenCalledWith(['tadpole']); + expect(selectButton).toHaveTextContent('1'); + expect(await screen.findByRole('listbox')).toBeInTheDocument(); // Listbox should not close in multi select mode. + + const pollywogOption = await screen.findByRole('option', { name: 'Pollywog' }); + await userEvent.click(pollywogOption); + + expect(args.onChange).toHaveBeenLastCalledWith(['tadpole', 'pollywog']); + expect(selectButton).toHaveTextContent('2'); + expect(await screen.findByRole('listbox')).toBeInTheDocument(); + + await userEvent.click(await canvas.findByText('Other content')); + expect(screen.queryByRole('listbox')).not.toBeInTheDocument(); // Now closed. + }, +}); + +const kbSelectionTest = + (triggerKey: string, selectKey: string): StoryAnnotations['play'] => + async ({ canvas, args, step }) => { + const selectButton = await canvas.findByRole('button'); + selectButton.focus(); + + await step('Open listbox', async () => { + await userEvent.keyboard(triggerKey); + const listbox = await screen.findByRole('listbox'); + expect(listbox).toBeInTheDocument(); + const optionOne = await screen.findByRole('option', { name: 'Tadpole' }); + expect(document.activeElement).toBe(optionOne); + }); + + await step('Press ArrowDown', async () => { + await userEvent.keyboard('{ArrowDown}'); + const optionTwo = await screen.findByRole('option', { name: 'Pollywog' }); + expect(document.activeElement).toBe(optionTwo); + }); + + await step('Select active option (closes the Select)', async () => { + await userEvent.keyboard(selectKey); + expect(args.onSelect).toHaveBeenCalledWith('pollywog'); + expect(args.onChange).toHaveBeenCalledWith(['pollywog']); + + expect(screen.queryByRole('listbox')).not.toBeInTheDocument(); + expect(selectButton).toHaveTextContent('Pollywog'); + }); + }; + +export const KeyboardSelectionEE = meta.story({ + name: 'KB Selection (single, Enter, Enter)', + play: kbSelectionTest('{Enter}', '{Enter}'), +}); + +export const KeyboardSelectionES = meta.story({ + name: 'KB Selection (single, Enter, Space)', + play: kbSelectionTest('{Enter}', ' '), +}); + +export const KeyboardSelectionSE = meta.story({ + name: 'KB Selection (single, Space, Enter)', + play: kbSelectionTest(' ', '{Enter}'), +}); + +export const KeyboardSelectionSS = meta.story({ + name: 'KB Selection (single, Space, Space)', + play: kbSelectionTest(' ', ' '), +}); + +const kbMultiSelectionTest = + (triggerKey: string, selectKey: string): StoryAnnotations['play'] => + async ({ canvas, args, step }) => { + const selectButton = await canvas.findByRole('button', { name: /Animal/ }); + selectButton.focus(); + + await step('Open listbox', async () => { + await userEvent.keyboard(triggerKey); + const listbox = await screen.findByRole('listbox'); + expect(listbox).toBeInTheDocument(); + const optionOne = await screen.findByRole('option', { name: 'Tadpole' }); + expect(document.activeElement).toBe(optionOne); + }); + + await step('Select option one', async () => { + await userEvent.keyboard(selectKey); + expect(args.onSelect).toHaveBeenCalledWith('tadpole'); + expect(args.onChange).toHaveBeenCalledWith(['tadpole']); + expect(screen.queryByRole('listbox')).toBeInTheDocument(); + }); + + await step('Press ArrowDown', async () => { + await userEvent.keyboard('{ArrowDown}'); + const optionTwo = await screen.findByRole('option', { name: 'Pollywog' }); + expect(document.activeElement).toBe(optionTwo); + }); + + await step('Select option two', async () => { + await userEvent.keyboard(selectKey); + expect(args.onSelect).toHaveBeenCalledWith('pollywog'); + expect(args.onChange).toHaveBeenCalledWith(['tadpole', 'pollywog']); + expect(screen.queryByRole('listbox')).toBeInTheDocument(); + expect(selectButton).toHaveTextContent('2'); + }); + + await step('Tab away (closes the Select)', async () => { + await userEvent.keyboard('{Tab}'); + expect(screen.queryByRole('listbox')).not.toBeInTheDocument(); + }); + }; + +export const KeyboardSelectionMultiEE = meta.story({ + name: 'KB Selection (multi, Enter, Enter)', + args: { multiSelect: true }, + play: kbMultiSelectionTest('{Enter}', '{Enter}'), +}); + +export const KeyboardSelectionMultiES = meta.story({ + name: 'KB Selection (multi, Enter, Space)', + args: { multiSelect: true }, + play: kbMultiSelectionTest('{Enter}', ' '), +}); + +export const KeyboardSelectionMultiSE = meta.story({ + name: 'KB Selection (multi, Space, Enter)', + args: { multiSelect: true }, + play: kbMultiSelectionTest(' ', '{Enter}'), +}); + +export const KeyboardSelectionMultiSS = meta.story({ + name: 'KB Selection (multi, Space, Space)', + args: { multiSelect: true }, + play: kbMultiSelectionTest(' ', ' '), +}); + +export const MouseOpenNoAutoselect = meta.story({ + name: 'AutoSelect - nothing selected on Mouse open (single)', + play: async ({ canvas, args, step }) => { + const selectButton = await canvas.findByRole('button'); + + await step('Click on button', async () => { + await userEvent.click(selectButton); + expect(screen.queryByRole('listbox')).toBeInTheDocument(); + expect(args.onSelect).not.toHaveBeenCalled(); + expect(args.onChange).not.toHaveBeenCalled(); + expect(selectButton).not.toHaveTextContent('Tadpole'); + }); + + await step('Click again to close', async () => { + await userEvent.click(selectButton); + expect(screen.queryByRole('listbox')).not.toBeInTheDocument(); + expect(args.onSelect).not.toHaveBeenCalled(); + expect(args.onChange).not.toHaveBeenCalled(); + expect(selectButton).not.toHaveTextContent('Tadpole'); + }); + }, +}); + +export const KeyboardOpenAutoselect = meta.story({ + name: 'AutoSelect - first item select on Enter (single)', + play: async ({ canvas, args, step }) => { + const selectButton = await canvas.findByRole('button'); + + await step('Open with Enter', async () => { + selectButton.focus(); + await userEvent.keyboard('{Enter}'); + }); + + await step('Validate the first item was selected', async () => { + expect(args.onSelect).toHaveBeenCalledWith('tadpole'); + expect(args.onChange).toHaveBeenCalledWith(['tadpole']); + expect(selectButton).toHaveTextContent('Tadpole'); + }); + + await step('Close button with Escape', async () => { + await userEvent.keyboard('{Escape}'); + expect(screen.queryByRole('listbox')).not.toBeInTheDocument(); + }); + + await step('Validate the first item is still selected', async () => { + expect(args.onSelect).toHaveBeenCalledWith('tadpole'); + expect(args.onChange).toHaveBeenCalledWith(['tadpole']); + expect(selectButton).toHaveTextContent('Tadpole'); + }); + }, +}); + +export const ArrowDownAutoSelect = meta.story({ + name: 'AutoSelect - first item select on ArrowDown (single)', + play: async ({ canvas, args }) => { + const selectButton = await canvas.findByRole('button'); + selectButton.focus(); + await userEvent.keyboard('{ArrowDown}'); + expect(args.onSelect).toHaveBeenCalledWith('tadpole'); + expect(args.onChange).toHaveBeenCalledWith(['tadpole']); + expect(selectButton).toHaveTextContent('Tadpole'); + await userEvent.keyboard('{Escape}'); + expect(screen.queryByRole('listbox')).not.toBeInTheDocument(); + }, +}); + +export const ArrowUpAutoSelect = meta.story({ + name: 'AutoSelect - last item select on ArrowUp (single)', + play: async ({ canvas, args }) => { + const selectButton = await canvas.findByRole('button'); + selectButton.focus(); + await userEvent.keyboard('{ArrowUp}'); + expect(args.onSelect).toHaveBeenCalledWith('frog'); + expect(args.onChange).toHaveBeenCalledWith(['frog']); + expect(selectButton).toHaveTextContent('Frog'); + await userEvent.keyboard('{Escape}'); + expect(screen.queryByRole('listbox')).not.toBeInTheDocument(); + }, +}); + +export const MouseFastNavPage = meta.story({ + name: 'Mouse Open - PageUp/Down', + args: { + options: Array.from({ length: 20 }, (_, i) => ({ + title: `Option ${i + 1}`, + value: `option-${i + 1}`, + })), + }, + play: async ({ canvas, step }) => { + const selectButton = await canvas.findByRole('button'); + + await step('Open select (no active option)', async () => { + await userEvent.click(selectButton); + const listbox = await screen.findByRole('listbox'); + expect(listbox).toBeInTheDocument(); + expect(document.activeElement).toBe(listbox); + }); + + await step('Press PageDown (6th option is active)', async () => { + await userEvent.keyboard('{PageDown}'); + const sixthOption = await screen.findByRole('option', { name: 'Option 6' }); + expect(document.activeElement).toBe(sixthOption); + }); + + await step('Press PageUp (1st option is active)', async () => { + await userEvent.keyboard('{PageUp}'); + const firstOption = await screen.findByRole('option', { name: 'Option 1' }); + expect(document.activeElement).toBe(firstOption); + }); + }, +}); +export const KeyboardFastNavPage = meta.story({ + name: 'KB Open - PageUp/Down', + args: { + options: Array.from({ length: 20 }, (_, i) => ({ + title: `Option ${i + 1}`, + value: `option-${i + 1}`, + })), + }, + play: async ({ canvas, step }) => { + const selectButton = await canvas.findByRole('button'); + selectButton.focus(); + + await step('Open select (1st option is active)', async () => { + await userEvent.keyboard('{Enter}'); + const listbox = await screen.findByRole('listbox'); + expect(listbox).toBeInTheDocument(); + const firstOption = await screen.findByRole('option', { name: 'Option 1' }); + expect(document.activeElement).toBe(firstOption); + }); + + await step('Press PageDown (6th option is active)', async () => { + await userEvent.keyboard('{PageDown}'); + const sixthOption = await screen.findByRole('option', { name: 'Option 6' }); + expect(document.activeElement).toBe(sixthOption); + }); + + await step('Press PageUp (1st option is active)', async () => { + await userEvent.keyboard('{PageUp}'); + const firstOption = await screen.findByRole('option', { name: 'Option 1' }); + expect(document.activeElement).toBe(firstOption); + }); + }, +}); + +export const MouseFastNavHomeEnd = meta.story({ + name: 'Mouse Open - Home/End', + args: { + options: Array.from({ length: 20 }, (_, i) => ({ + title: `Option ${i + 1}`, + value: `option-${i + 1}`, + })), + }, + play: async ({ canvas, step }) => { + const selectButton = await canvas.findByRole('button'); + + await step('Open select (no active option)', async () => { + await userEvent.click(selectButton); + const listbox = await screen.findByRole('listbox'); + expect(listbox).toBeInTheDocument(); + expect(document.activeElement).toBe(listbox); + }); + + await step('Navigate to middle with ArrowDown', async () => { + await userEvent.keyboard('{ArrowDown}{ArrowDown}{ArrowDown}'); + const middleOption = await screen.findByRole('option', { name: 'Option 3' }); + expect(document.activeElement).toBe(middleOption); + }); + + await step('Navigate to end with End', async () => { + await userEvent.keyboard('{End}'); + const lastOption = await screen.findByRole('option', { name: 'Option 20' }); + expect(document.activeElement).toBe(lastOption); + }); + + await step('Navigate to start with Home', async () => { + await userEvent.keyboard('{Home}'); + const firstOption = await screen.findByRole('option', { name: 'Option 1' }); + expect(document.activeElement).toBe(firstOption); + }); + }, +}); + +export const KeyboardFastNavHomeEnd = meta.story({ + name: 'KB Open - Home/End', + args: { + options: Array.from({ length: 20 }, (_, i) => ({ + title: `Option ${i + 1}`, + value: `option-${i + 1}`, + })), + }, + play: async ({ canvas, step }) => { + const selectButton = await canvas.findByRole('button'); + selectButton.focus(); + + await step('Open select (1st option is active)', async () => { + await userEvent.keyboard('{Enter}'); + const listbox = await screen.findByRole('listbox'); + expect(listbox).toBeInTheDocument(); + const firstOption = await screen.findByRole('option', { name: 'Option 1' }); + expect(document.activeElement).toBe(firstOption); + }); + + await step('Navigate to middle with ArrowDown', async () => { + await userEvent.keyboard('{ArrowDown}{ArrowDown}{ArrowDown}'); + const middleOption = await screen.findByRole('option', { name: 'Option 4' }); + expect(document.activeElement).toBe(middleOption); + }); + + await step('Navigate to end with End', async () => { + await userEvent.keyboard('{End}'); + const lastOption = await screen.findByRole('option', { name: 'Option 20' }); + expect(document.activeElement).toBe(lastOption); + }); + + await step('Navigate to start with Home', async () => { + await userEvent.keyboard('{Home}'); + const firstOption = await screen.findByRole('option', { name: 'Option 1' }); + expect(document.activeElement).toBe(firstOption); + }); + }, +}); + +export const MouseDeselection = meta.story({ + name: 'Mouse Deselection (multi)', + args: { + multiSelect: true, + defaultOptions: ['tadpole', 'pollywog'], + }, + play: async ({ canvas, args, step }) => { + const selectButton = await canvas.findByRole('button', { name: /Animal/ }); + + await step('Check initial state', async () => { + expect(selectButton).toHaveTextContent('2'); + }); + + await step('Open select', async () => { + await userEvent.click(selectButton); + }); + + await step('Deselect first option', async () => { + const tadpoleOption = await screen.findByRole('option', { name: 'Tadpole' }); + expect(tadpoleOption).toHaveAttribute('aria-selected', 'true'); + await userEvent.click(tadpoleOption); + expect(args.onDeselect).toHaveBeenCalledWith('tadpole'); + expect(args.onChange).toHaveBeenCalledWith(['pollywog']); + }); + + await step('Check final state', async () => { + expect(selectButton).toHaveTextContent('1'); + }); + }, +}); + +export const KeyboardDeselection = meta.story({ + name: 'KB Deselection (multi)', + args: { + multiSelect: true, + defaultOptions: ['tadpole', 'pollywog'], + }, + play: async ({ canvas, args, step }) => { + const selectButton = await canvas.findByRole('button', { name: /Animal/ }); + + await step('Check initial state', async () => { + expect(selectButton).toHaveTextContent('2'); + }); + + await step('Open select', async () => { + selectButton.focus(); + await userEvent.keyboard('{Enter}'); + }); + + await step('Deselect first option', async () => { + const tadpoleOption = await screen.findByRole('option', { name: 'Tadpole' }); + expect(tadpoleOption).toHaveAttribute('aria-selected', 'true'); + await userEvent.keyboard('{Enter}'); + expect(args.onDeselect).toHaveBeenCalledWith('tadpole'); + expect(args.onChange).toHaveBeenCalledWith(['pollywog']); + }); + + await step('Tab away (closes the Select)', async () => { + await userEvent.keyboard('{Tab}'); + expect(screen.queryByRole('listbox')).not.toBeInTheDocument(); + }); + + await step('Check final state', async () => { + expect(selectButton).toHaveTextContent('1'); + }); + }, +}); + +export const OnSelectHandler = meta.story({ + name: 'Handlers - onSelect', + args: { + onSelect: fn().mockName('onSelect'), + }, + play: async ({ canvas, args }) => { + const selectButton = await canvas.findByRole('button'); + + await userEvent.click(selectButton); + + const frogOption = await screen.findByRole('option', { name: 'Frog' }); + await userEvent.click(frogOption); + + expect(args.onSelect).toHaveBeenCalledTimes(1); + expect(args.onSelect).toHaveBeenCalledWith('frog'); + }, +}); + +export const OnDeselectHandler = meta.story({ + name: 'Handlers - onDeselect', + args: { + multiSelect: true, + defaultOptions: ['tadpole'], + onDeselect: fn().mockName('onDeselect'), + }, + play: async ({ canvas, args }) => { + const selectButton = await canvas.findByRole('button', { name: /Animal/ }); + await userEvent.click(selectButton); + + const tadpoleOption = await screen.findByRole('option', { name: 'Tadpole' }); + await userEvent.click(tadpoleOption); + + expect(args.onDeselect).toHaveBeenCalledTimes(1); + expect(args.onDeselect).toHaveBeenCalledWith('tadpole'); + }, +}); + +export const OnChangeHandler = meta.story({ + name: 'Handlers - onChange', + args: { + multiSelect: true, + onChange: fn().mockName('onChange'), + }, + play: async ({ canvas, args, step }) => { + const selectButton = await canvas.findByRole('button', { name: /Animal/ }); + + await step('Open select', async () => { + await userEvent.click(selectButton); + }); + + await step('Select first option', async () => { + const tadpoleOption = await screen.findByRole('option', { name: 'Tadpole' }); + await userEvent.click(tadpoleOption); + expect(args.onChange).toHaveBeenCalledWith(['tadpole']); + }); + + await step('Select second option', async () => { + const frogOption = await screen.findByRole('option', { name: 'Frog' }); + await userEvent.click(frogOption); + expect(args.onChange).toHaveBeenLastCalledWith(['tadpole', 'frog']); + }); + }, +}); + +export const WithResetSingle = meta.story({ + name: 'With Reset (single)', + args: { + defaultOptions: 'frog', + onReset: fn().mockName('onReset'), + }, + play: async ({ canvas, args, step }) => { + const selectButton = await canvas.findByRole('button'); + + await step('Check initial state', async () => { + expect(selectButton).toHaveTextContent('Frog'); + }); + + await step('Open select', async () => { + await userEvent.click(selectButton); + expect(screen.getByRole('listbox')).toBeInTheDocument(); + }); + + await step('Check Reset option exists', async () => { + const resetOption = await screen.findByRole('option', { name: 'Reset selection' }); + expect(resetOption).toBeInTheDocument(); + }); + + await step('Click Reset', async () => { + const resetOption = await screen.findByRole('option', { name: 'Reset selection' }); + await userEvent.click(resetOption); + + expect(args.onReset).toHaveBeenCalledTimes(1); + expect(args.onChange).toHaveBeenCalledWith([]); + expect(selectButton).not.toHaveTextContent('Frog'); + expect(selectButton).not.toHaveTextContent('Tadpole'); + expect(selectButton).not.toHaveTextContent('Pollywog'); + }); + }, +}); + +export const WithResetMulti = meta.story({ + name: 'With Reset (multi)', + args: { + multiSelect: true, + defaultOptions: ['tadpole', 'frog'], + onReset: fn().mockName('onReset'), + }, + play: async ({ canvas, args, step }) => { + const selectButton = await canvas.findByRole('button'); + + await step('Check initial state', async () => { + expect(selectButton).toHaveTextContent('2'); + }); + + await step('Open select', async () => { + await userEvent.click(selectButton); + expect(screen.getByRole('listbox')).toBeInTheDocument(); + }); + + await step('Check Reset option exists', async () => { + const resetOption = await screen.findByRole('option', { name: 'Reset selection' }); + expect(resetOption).toBeInTheDocument(); + }); + + await step('Click Reset', async () => { + const resetOption = await screen.findByRole('option', { name: 'Reset selection' }); + await userEvent.click(resetOption); + + expect(args.onReset).toHaveBeenCalledTimes(1); + expect(args.onChange).toHaveBeenCalledWith([]); + expect(selectButton).not.toHaveTextContent('2'); + }); + }, +}); + +export const KeyboardResetSingle = meta.story({ + name: 'KB Reset (single, focus)', + args: { + defaultOptions: 'frog', + onReset: fn().mockName('onReset'), + }, + play: async ({ canvas, args, step }) => { + const selectButton = await canvas.findByRole('button'); + selectButton.focus(); + + await step('Open with Enter and navigate to reset option', async () => { + await userEvent.keyboard('{Enter}'); + await userEvent.keyboard('{Home}'); + + const resetOption = await screen.findByRole('option', { name: 'Reset selection' }); + expect(document.activeElement).toBe(resetOption); + }); + + await step('Check Select is reset', async () => { + expect(args.onReset).toHaveBeenCalledTimes(1); + expect(args.onChange).toHaveBeenCalledWith([]); + expect(selectButton).not.toHaveTextContent('Frog'); + expect(selectButton).not.toHaveTextContent('Tadpole'); + expect(selectButton).not.toHaveTextContent('Pollywog'); + }); + }, +}); + +export const KeyboardResetMulti = meta.story({ + name: 'KB Reset (multi, Enter)', + args: { + multiSelect: true, + defaultOptions: ['tadpole', 'frog'], + onReset: fn().mockName('onReset'), + }, + play: async ({ canvas, args, step }) => { + const selectButton = await canvas.findByRole('button'); + selectButton.focus(); + + await step('Open with Enter and navigate to reset option', async () => { + await userEvent.keyboard('{Enter}'); + await userEvent.keyboard('{Home}'); + + const resetOption = await screen.findByRole('option', { name: 'Reset selection' }); + expect(document.activeElement).toBe(resetOption); + }); + + await step('Press Enter to reset', async () => { + await userEvent.keyboard('{Enter}'); + + expect(args.onReset).toHaveBeenCalledTimes(1); + expect(args.onChange).toHaveBeenCalledWith([]); + expect(selectButton).not.toHaveTextContent('2'); + + expect(await screen.findByRole('listbox')).toBeInTheDocument(); + }); + + await step('Close with Escape', async () => { + await userEvent.keyboard('{Escape}'); + expect(screen.queryByRole('listbox')).not.toBeInTheDocument(); + }); + }, +}); + +export const KeyboardResetMultiSpace = meta.story({ + name: 'KB Reset (multi, Space)', + args: { + multiSelect: true, + defaultOptions: ['tadpole', 'frog'], + onReset: fn().mockName('onReset'), + }, + play: async ({ canvas, args, step }) => { + const selectButton = await canvas.findByRole('button'); + selectButton.focus(); + + await step('Open with Space and navigate to reset option', async () => { + await userEvent.keyboard(' '); + await userEvent.keyboard('{Home}'); + + const resetOption = await screen.findByRole('option', { name: 'Reset selection' }); + expect(document.activeElement).toBe(resetOption); + }); + + await step('Press Space to reset', async () => { + await userEvent.keyboard(' '); + + expect(args.onReset).toHaveBeenCalledTimes(1); + expect(args.onChange).toHaveBeenCalledWith([]); + expect(selectButton).not.toHaveTextContent('2'); + + expect(await screen.findByRole('listbox')).toBeInTheDocument(); + }); + + await step('Close with Escape', async () => { + await userEvent.keyboard('{Escape}'); + expect(screen.queryByRole('listbox')).not.toBeInTheDocument(); + }); + }, +}); + +export const ResetButtonVisibilitySingle = meta.story({ + name: 'Reset Button Visibility (single)', + args: { + onReset: fn().mockName('onReset'), + }, + play: async ({ canvas, step }) => { + const selectButton = await canvas.findByRole('button'); + + await step('Open without selection', async () => { + await userEvent.click(selectButton); + + const resetOption = await screen.findByRole('option', { name: 'Reset selection' }); + expect(resetOption).toBeInTheDocument(); + // Reset option should not be disabled when the user cursor is on it in + // single-select mode even without selection, because single-select Select + // auto triggers the focused option, and we don't want to have the selection + // reset whilst SRs announce that the reset option is disabled. + expect(resetOption).not.toHaveAttribute('aria-disabled', 'true'); + }); + + await step('Select an option', async () => { + const frogOption = await screen.findByRole('option', { name: 'Frog' }); + await userEvent.click(frogOption); + }); + + await step('Reopen select and check reset option', async () => { + await userEvent.click(selectButton); + + const resetOption = await screen.findByRole('option', { name: 'Reset selection' }); + expect(resetOption).toBeInTheDocument(); + expect(resetOption).not.toHaveAttribute('aria-disabled', 'true'); + }); + }, +}); + +export const ResetButtonVisibilityMulti = meta.story({ + name: 'Reset Button Visibility (multi)', + args: { + multiSelect: true, + onReset: fn().mockName('onReset'), + }, + play: async ({ canvas, step }) => { + const selectButton = await canvas.findByRole('button'); + + await step('Open without selection', async () => { + await userEvent.click(selectButton); + + const resetOption = await screen.findByRole('option', { name: 'Reset selection' }); + expect(resetOption).toBeInTheDocument(); + expect(resetOption).toHaveAttribute('aria-disabled', 'true'); + }); + + await step('Select an option', async () => { + const frogOption = await screen.findByRole('option', { name: 'Frog' }); + await userEvent.click(frogOption); + }); + + await step('Check reset option', async () => { + const resetOption = await screen.findByRole('option', { name: 'Reset selection' }); + expect(resetOption).toBeInTheDocument(); + expect(resetOption).not.toHaveAttribute('aria-disabled', 'true'); + }); + }, +}); + +export const CustomResetLabel = meta.story({ + name: 'Custom Reset Label', + args: { + defaultOptions: 'frog', + onReset: fn().mockName('onReset'), + resetLabel: 'Clear selection', + }, + play: async ({ canvas }) => { + const selectButton = await canvas.findByRole('button'); + + await userEvent.click(selectButton); + + const resetOption = await screen.findByRole('option', { name: 'Clear selection' }); + expect(resetOption).toBeInTheDocument(); + }, +}); + +export const WithoutReset = meta.story({ + name: 'Without Reset Option', + args: { + defaultOptions: 'frog', + }, + play: async ({ canvas }) => { + const selectButton = await canvas.findByRole('button'); + + await userEvent.click(selectButton); + + const options = await screen.findAllByRole('option'); + for (const option of options) { + expect(option).not.toHaveTextContent('Reset selection'); + } + + expect(options.length).toBe(3); + }, +}); diff --git a/code/core/src/components/components/Select/Select.tsx b/code/core/src/components/components/Select/Select.tsx new file mode 100644 index 000000000000..ba059983559b --- /dev/null +++ b/code/core/src/components/components/Select/Select.tsx @@ -0,0 +1,574 @@ +import type { FC, KeyboardEvent } from 'react'; +import React, { forwardRef, useCallback, useEffect, useMemo, useRef, useState } from 'react'; + +import { RefreshIcon } from '@storybook/icons'; + +import { useInteractOutside } from '@react-aria/interactions'; +import { Overlay, useOverlay, useOverlayPosition } from '@react-aria/overlays'; +import { useObjectRef } from '@react-aria/utils'; +import { useOverlayTriggerState } from '@react-stately/overlays'; +import { darken, transparentize } from 'polished'; +import { styled, useTheme } from 'storybook/theming'; + +import { Button, type ButtonProps } from '../Button/Button'; +import { Form } from '../Form/Form'; +import { Popover } from '../Popover/Popover'; +import { SelectOption } from './SelectOption'; +import type { Option, ResetOption } from './helpers'; +import { Listbox, PAGE_STEP_SIZE } from './helpers'; + +export interface SelectProps + extends Omit { + size?: 'small' | 'medium'; + padding?: 'small' | 'medium' | 'none'; + + /** + * Whether multiple options can be selected. In single select mode, this component acts like a + * HTML select element where the selected option follows focus. In multi select mode, it acts like + * a combobox and does not autoclose on select or autoselect the focused option. + */ + multiSelect?: boolean; + + /** + * Mandatory label that explains what is being selected. Do not include "change", "toggle" or + * "select" verbs in the label. Instead, only describe the type of content with a noun. + */ + ariaLabel: string; + + /** + * Label for the Select component. In single-select mode, is replaced by the currently selected + * option's title. + */ + children?: React.ReactNode; + + /** + * Icon shown next to the Select's children, still displayed when a value is selected and Select + * shows that value instead of children. + */ + icon?: React.ReactNode; + + /** Whether the Select is currently disabled. */ + disabled?: boolean; + + /** Options available in the select. */ + options: Option[]; + + /** IDs of the preselected options. */ + defaultOptions?: string | string[]; + + /** Whether the Select should render open. */ + defaultOpen?: boolean; + + /** When set, a reset option is rendered in the Select listbox. */ + onReset?: () => void; + + /** Custom text label for the reset option when it exists. */ + resetLabel?: string; + + onSelect?: (option: string) => void; + onDeselect?: (option: string) => void; + onChange?: (selected: string[]) => void; +} + +function valueToId(parentId: string, { value }: ResetOption | Option): string { + return `${parentId}-opt-${value ?? 'sb-reset'}`; +} + +const SelectedOptionCount = styled.span(({ theme }) => ({ + appearance: 'none', + color: theme.textMutedColor, + fontSize: 12, +})); + +function setSelectedFromDefault( + options: SelectProps['options'], + defaultOptions: SelectProps['defaultOptions'] +): Option[] { + if (!defaultOptions) { + return []; + } + + if (typeof defaultOptions === 'string') { + return options.filter((opt) => opt.value === defaultOptions); + } + + return options.filter((opt) => defaultOptions.some((def) => opt.value === def)); +} + +const StyledButton = styled(Button)( + ({ $isOpen: isOpen, $hasSelection: hasSelection, theme }) => + isOpen || hasSelection + ? { + boxShadow: 'none', + background: transparentize(0.93, theme.barSelectedColor), + color: + theme.base === 'light' ? darken(0.1, theme.color.secondary) : theme.color.secondary, + } + : {} +); + +const Underlay = styled.div({ + position: 'fixed', + inset: 0, + // This will do for now, our popovers use the max z-index of 2147483647. We'll want to + // inherit a base from a CSS variable and add preset values to it in the future (e.g. + // 100 for underlay, 200 for overlay) if we start using Select in dialogs. + zIndex: 1000, +}); + +/* + * This popover does not do any keyboard handling or placement. It uses a portal to place + * its children under its sibling's position. When clicking outside the popover, it closes. + */ +const MinimalistPopover: FC<{ + children: React.ReactNode; + handleClose: () => void; + triggerRef: React.RefObject; +}> = ({ children, handleClose, triggerRef }) => { + const popoverRef = React.useRef(null); + + useInteractOutside({ + ref: popoverRef, + onInteractOutside: handleClose, + }); + + const { overlayProps: positionProps } = useOverlayPosition({ + targetRef: triggerRef, + overlayRef: popoverRef, + placement: 'bottom start', + offset: 8, + maxHeight: 504, + isOpen: true, + }); + + const { overlayProps, underlayProps } = useOverlay( + { + isOpen: true, + onClose: handleClose, + isDismissable: true, + /* We do this ourselves. */ + shouldCloseOnBlur: false, + /* We also do this ourselves. */ + isKeyboardDismissDisabled: true, + }, + popoverRef + ); + + const theme = useTheme(); + + positionProps.style = { + ...positionProps.style, + overflow: 'hidden auto', + scrollbarColor: `${theme.barTextColor} transparent`, + scrollbarWidth: 'thin', + }; + + return ( + + + + {children} + + + ); +}; + +export const Select = forwardRef( + ( + { + children, + icon, + disabled = false, + options: calleeOptions, + defaultOptions, + multiSelect = false, + onReset, + padding = 'small', + resetLabel, + onSelect, + onDeselect, + onChange, + tooltip, + ariaLabel, + ...props + }, + ref + ) => { + const [isOpen, setIsOpen] = useState(props.defaultOpen || false); + const triggerRef = useObjectRef(ref); + + const id = useMemo(() => { + return 'select-' + Math.random().toString(36).substring(2, 15); + }, []); + const listboxId = `${id}-listbox`; + const listboxRef = useRef(null); + + const otState = useOverlayTriggerState({ + isOpen: isOpen && !disabled, + onOpenChange: setIsOpen, + }); + + const handleClose = useCallback(() => { + setIsOpen(false); + triggerRef.current?.focus(); + }, [triggerRef]); + + // The last selected option(s), which will be used by the app. + const [selectedOptions, setSelectedOptions] = useState( + setSelectedFromDefault(calleeOptions, defaultOptions) + ); + + // Selects an option (updating the selection state based on multiSelect). + const handleSelectOption = useCallback( + (option: Option | ResetOption) => { + // Reset option case. We check value === undefined for cleaner type handling in the other branch. + if (option.value === undefined) { + if (selectedOptions.length) { + onChange?.([]); + onReset?.(); + setSelectedOptions([]); + } + } else if (multiSelect) { + setSelectedOptions((previous) => { + let newSelected: Option[] = []; + + const isSelected = previous?.some((opt) => opt.value === option.value); + if (isSelected) { + onDeselect?.(option.value); + newSelected = previous?.filter((opt) => opt.value !== option.value) ?? []; + } else { + onSelect?.(option.value); + newSelected = [...(previous ?? []), option]; + } + + onChange?.(newSelected.map((opt) => opt.value)); + return newSelected; + }); + } else { + setSelectedOptions((current) => { + if (current.every((opt) => opt.value !== option.value)) { + onSelect?.(option.value); + onChange?.([option.value]); + return [option]; + } + return current; + }); + } + }, + [multiSelect, onChange, onSelect, onDeselect, onReset, selectedOptions] + ); + + // Reset option appears if a handler is defined and there are selected options. + const resetOption = useMemo( + () => + onReset + ? { + value: undefined, + title: resetLabel ?? 'Reset selection', + icon: , + description: undefined, + children: undefined, + } + : undefined, + [onReset, resetLabel] + ); + + // Synthetic object allowing us to implement the roving tabindex. + const options = useMemo( + () => (resetOption ? [resetOption, ...calleeOptions] : calleeOptions), + [calleeOptions, resetOption] + ); + + // We must do this to account for callees that have an unstable data model. + // For instance, when a URL query param is passed for the theme addon, the + // addon receives, undefined, then the default theme value (incorrectly), + // then the actual URL query param as a selected theme. + useEffect(() => { + if (defaultOptions) { + setSelectedOptions(setSelectedFromDefault(calleeOptions, defaultOptions)); + } + }, [defaultOptions, calleeOptions]); + + // The active option in the listbox, which will receive focus when the listbox is open. + const [activeOption, setActiveOptionState] = useState