diff --git a/code/core/package.json b/code/core/package.json index 5fd7a913a167..9a566b675cde 100644 --- a/code/core/package.json +++ b/code/core/package.json @@ -228,6 +228,7 @@ "@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/landmark": "^3.0.8", "@react-aria/overlays": "^3.29.1", "@react-aria/tabs": "^3.10.7", "@react-aria/toolbar": "3.0.0-beta.20", diff --git a/code/core/src/components/components/Card/Card.tsx b/code/core/src/components/components/Card/Card.tsx index 4485b55e6116..add8eef940e5 100644 --- a/code/core/src/components/components/Card/Card.tsx +++ b/code/core/src/components/components/Card/Card.tsx @@ -1,4 +1,4 @@ -import React, { type ComponentProps, forwardRef } from 'react'; +import React, { type ComponentProps, type DOMAttributes, forwardRef } from 'react'; import type { CSSObject, color } from 'storybook/theming'; import { keyframes, styled } from 'storybook/theming'; @@ -92,15 +92,16 @@ const CardOutline = styled.div<{ interface CardProps extends ComponentProps { outlineAnimation?: 'none' | 'rainbow' | 'spin'; outlineColor?: keyof typeof color; + outlineAttrs?: DOMAttributes; } export const Card = Object.assign( forwardRef(function Card( - { outlineAnimation = 'none', outlineColor, ...props }, + { outlineAnimation = 'none', outlineColor, outlineAttrs: outlineAttrs = {}, ...props }, ref ) { return ( - + ); diff --git a/code/core/src/components/components/Form/styles.ts b/code/core/src/components/components/Form/styles.ts index ee88bb2ec3e1..9d43f09148ea 100644 --- a/code/core/src/components/components/Form/styles.ts +++ b/code/core/src/components/components/Form/styles.ts @@ -107,8 +107,9 @@ export const styles = (({ theme }: { theme: StorybookTheme }) => ({ }, }, - '&[disabled]': { + '&[disabled], &[aria-disabled="true"]': { background: theme.base === 'light' ? theme.color.lighter : 'transparent', + cursor: 'not-allowed', }, '&:-webkit-autofill': { WebkitBoxShadow: `0 0 0 3em ${theme.color.lightest} inset` }, diff --git a/code/core/src/manager-api/modules/shortcuts.ts b/code/core/src/manager-api/modules/shortcuts.ts index ba890991b1a2..e0dc374bea16 100644 --- a/code/core/src/manager-api/modules/shortcuts.ts +++ b/code/core/src/manager-api/modules/shortcuts.ts @@ -117,6 +117,8 @@ export interface API_Shortcuts { openInEditor: API_KeyCollection; openInIsolation: API_KeyCollection; copyStoryLink: API_KeyCollection; + goToPreviousLandmark: API_KeyCollection; + goToNextLandmark: API_KeyCollection; // TODO: bring this back once we want to add shortcuts for this // copyStoryName: API_KeyCollection; } @@ -157,6 +159,8 @@ export const defaultShortcuts: API_Shortcuts = Object.freeze({ openInEditor: ['alt', 'shift', 'E'], openInIsolation: ['alt', 'shift', 'I'], copyStoryLink: ['alt', 'shift', 'L'], + goToPreviousLandmark: ['shift', 'F6'], // hardcoded in react-aria + goToNextLandmark: ['F6'], // hardcoded in react-aria // TODO: bring this back once we want to add shortcuts for this // copyStoryName: ['alt', 'shift', 'C'], }); @@ -273,6 +277,11 @@ export const init: ModuleFn = ({ store, fullAPI, provider }) => { break; } + // Handled by @react-aria/interactions and useLandmarkIndicator + case 'goToNextLandmark': + case 'goToPreviousLandmark': + break; + case 'focusNav': { if (fullAPI.getIsFullscreen()) { fullAPI.toggleFullscreen(false); diff --git a/code/core/src/manager/components/layout/Layout.tsx b/code/core/src/manager/components/layout/Layout.tsx index 4398df3c5c89..32bc1e009326 100644 --- a/code/core/src/manager/components/layout/Layout.tsx +++ b/code/core/src/manager/components/layout/Layout.tsx @@ -11,6 +11,7 @@ import { Notifications } from '../../container/Notifications'; import { MobileNavigation } from '../mobile/navigation/MobileNavigation'; import { useLayout } from './LayoutProvider'; import { useDragging } from './useDragging'; +import { useLandmarkIndicator } from './useLandmarkIndicator'; interface InternalLayoutState { isDragging: boolean; @@ -158,6 +159,9 @@ export const Layout = ({ managerLayoutState, setManagerLayoutState, hasTab, ...s isDragging, } = useLayoutSyncingState({ api, managerLayoutState, setManagerLayoutState, isDesktop, hasTab }); + // Install landmark navigation listener in parent container of all landmarks. + useLandmarkIndicator(); + return ( (null); + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key !== 'F6') { + return; + } + + const landmarkElement = findActiveLandmarkElement(); + if (!landmarkElement) { + return; + } + + // Cancel previous landmark animation if user switches fast. + if (currentAnimationRef.current) { + currentAnimationRef.current.cancel(); + currentAnimationRef.current = null; + } + + const animation = landmarkElement.animate( + [{ border: `2px solid ${theme.color.primary}` }, { border: `2px solid transparent` }], + { + duration: 1500, + pseudoElement: '::after', + } + ); + currentAnimationRef.current = animation; + + animation.onfinish = () => { + currentAnimationRef.current = null; + }; + }; + + document.addEventListener('keydown', handleKeyDown, { capture: true }); + return () => { + document.removeEventListener('keydown', handleKeyDown, { capture: true }); + }; + }, [theme.color.primary]); +} diff --git a/code/core/src/manager/components/mobile/navigation/MobileNavigation.tsx b/code/core/src/manager/components/mobile/navigation/MobileNavigation.tsx index c1dde6c08125..83cc6f4596b2 100644 --- a/code/core/src/manager/components/mobile/navigation/MobileNavigation.tsx +++ b/code/core/src/manager/components/mobile/navigation/MobileNavigation.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { useRef } from 'react'; import type { ComponentProps, FC } from 'react'; import { Button } from 'storybook/internal/components'; @@ -10,6 +10,7 @@ import { useId } from '@react-aria/utils'; import { useStorybookApi, useStorybookState } from 'storybook/manager-api'; import { styled } from 'storybook/theming'; +import { useLandmark } from '../../../hooks/useLandmark'; import { useLayout } from '../../layout/LayoutProvider'; import { MobileAddonsDrawer } from './MobileAddonsDrawer'; import { MobileMenuDrawer } from './MobileMenuDrawer'; @@ -77,6 +78,12 @@ export const MobileNavigation: FC(null); + const { landmarkProps } = useLandmark( + { 'aria-labelledby': headingId, role: 'banner' }, + sectionRef + ); + return ( {!isMobilePanelOpen && ( - +

Navigation controls

@@ -132,7 +139,7 @@ export const MobileNavigation: FC ({ +const Container = styled.section(({ theme }) => ({ bottom: 0, left: 0, width: '100%', @@ -141,7 +148,7 @@ const Container = styled.div(({ theme }) => ({ borderTop: `1px solid ${theme.appBorderColor}`, })); -const MobileBottomBar = styled.section({ +const MobileBottomBar = styled.header({ display: 'flex', alignItems: 'center', justifyContent: 'space-between', diff --git a/code/core/src/manager/components/panel/Panel.tsx b/code/core/src/manager/components/panel/Panel.tsx index 3b12c105b4e8..41393b27634b 100644 --- a/code/core/src/manager/components/panel/Panel.tsx +++ b/code/core/src/manager/components/panel/Panel.tsx @@ -1,5 +1,5 @@ import type { ReactNode } from 'react'; -import React, { Component, useMemo } from 'react'; +import React, { Component, useMemo, useRef } from 'react'; import { Button, @@ -17,6 +17,7 @@ import { BottomBarIcon, CloseIcon, DocumentIcon, SidebarAltIcon } from '@storybo import type { State } from 'storybook/manager-api'; import { styled } from 'storybook/theming'; +import { useLandmark } from '../../hooks/useLandmark'; import { useLayout } from '../layout/LayoutProvider'; export interface SafeTabProps { @@ -61,7 +62,7 @@ class TabErrorBoundary extends Component(null); + const { landmarkProps } = useLandmark( + { 'aria-labelledby': 'storybook-panel-heading', role: 'region' }, + asideRef + ); + return ( -
+
+ ); }); diff --git a/code/core/src/manager/components/preview/FramesRenderer.tsx b/code/core/src/manager/components/preview/FramesRenderer.tsx index 5aa1547e2508..5628cfef9e68 100644 --- a/code/core/src/manager/components/preview/FramesRenderer.tsx +++ b/code/core/src/manager/components/preview/FramesRenderer.tsx @@ -97,7 +97,7 @@ export const FramesRenderer: FC = ({ } return ( - + Skip to sidebar diff --git a/code/core/src/manager/components/preview/Preview.tsx b/code/core/src/manager/components/preview/Preview.tsx index 2ffcaba4f939..48e1a9899040 100644 --- a/code/core/src/manager/components/preview/Preview.tsx +++ b/code/core/src/manager/components/preview/Preview.tsx @@ -12,6 +12,7 @@ import type { TabListState } from '@react-stately/tabs'; import { Helmet } from 'react-helmet-async'; import { type Combo, Consumer, addons, merge, types } from 'storybook/manager-api'; +import { useLandmark } from '../../hooks/useLandmark'; import { FramesRenderer } from './FramesRenderer'; import { ToolbarComp } from './Toolbar'; import { ApplyWrappers } from './Wrappers'; @@ -111,6 +112,12 @@ const Preview = React.memo(function Preview(props) { } }, [entry, viewMode, storyId, api]); + const mainRef = useRef(null); + const { landmarkProps } = useLandmark( + { 'aria-labelledby': 'main-preview-heading', role: 'main' }, + mainRef + ); + return ( {previewId === 'main' && ( @@ -128,7 +135,7 @@ const Preview = React.memo(function Preview(props) { tools={tools} toolsExtra={toolsExtra} /> - +

Main preview area

diff --git a/code/core/src/manager/components/preview/Toolbar.tsx b/code/core/src/manager/components/preview/Toolbar.tsx index 640ee5194e10..adb600b9db35 100644 --- a/code/core/src/manager/components/preview/Toolbar.tsx +++ b/code/core/src/manager/components/preview/Toolbar.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { useRef } from 'react'; import { AbstractToolbar, Button, Separator, TabList } from 'storybook/internal/components'; import { type Addon_BaseType, Addon_TypesEnum } from 'storybook/internal/types'; @@ -18,6 +18,7 @@ import { } from 'storybook/manager-api'; import { styled } from 'storybook/theming'; +import { useLandmark } from '../../hooks/useLandmark'; import { useLayout } from '../layout/LayoutProvider'; import type { PreviewProps } from './utils/types'; @@ -83,12 +84,19 @@ export const ToolbarComp = React.memo(function ToolbarComp({ tabs, tabState, }) { + const sectionRef = useRef(null); + const { landmarkProps } = useLandmark( + { 'aria-labelledby': 'sb-preview-toolbar-title', role: 'region' }, + sectionRef + ); + return isShown && (tabs || tools || toolsExtra) ? (

Toolbar diff --git a/code/core/src/manager/components/preview/utils/components.ts b/code/core/src/manager/components/preview/utils/components.ts index a93acd7685fc..ff3c4233f43f 100644 --- a/code/core/src/manager/components/preview/utils/components.ts +++ b/code/core/src/manager/components/preview/utils/components.ts @@ -2,7 +2,7 @@ import { Link } from 'storybook/internal/router'; import { styled } from 'storybook/theming'; -export const PreviewContainer = styled.main({ +export const PreviewContainer = styled.div({ display: 'flex', flexDirection: 'column', width: '100%', @@ -10,7 +10,7 @@ export const PreviewContainer = styled.main({ overflow: 'hidden', }); -export const FrameWrap = styled.section({ +export const FrameWrap = styled.main({ overflow: 'auto', width: '100%', zIndex: 3, diff --git a/code/core/src/manager/components/sidebar/Explorer.stories.tsx b/code/core/src/manager/components/sidebar/Explorer.stories.tsx index 96151717f30e..87f4cef896f8 100644 --- a/code/core/src/manager/components/sidebar/Explorer.stories.tsx +++ b/code/core/src/manager/components/sidebar/Explorer.stories.tsx @@ -83,6 +83,7 @@ export const Simple = () => ( isLoading={false} isBrowsing hasEntries={true} + isHidden={false} /> ); @@ -96,5 +97,6 @@ export const WithRefs = () => ( isLoading={false} isBrowsing hasEntries={true} + isHidden={false} /> ); diff --git a/code/core/src/manager/components/sidebar/Explorer.tsx b/code/core/src/manager/components/sidebar/Explorer.tsx index bd312377bfde..5352a50b677d 100644 --- a/code/core/src/manager/components/sidebar/Explorer.tsx +++ b/code/core/src/manager/components/sidebar/Explorer.tsx @@ -1,14 +1,17 @@ import type { FC } from 'react'; import React, { useRef } from 'react'; +import { useLandmark } from '../../hooks/useLandmark'; import { HighlightStyles } from './HighlightStyles'; import { Ref } from './Refs'; import type { CombinedDataset, Selection } from './types'; import { useHighlighted } from './useHighlighted'; export interface ExplorerProps { + className?: string; isLoading: boolean; isBrowsing: boolean; + isHidden: boolean; hasEntries: boolean; dataset: CombinedDataset; selected: Selection; @@ -18,10 +21,12 @@ export const Explorer: FC = React.memo(function Explorer({ hasEntries, isLoading, isBrowsing, + isHidden, dataset, selected, + ...restProps }) { - const containerRef = useRef(null); + const containerRef = useRef(null); // Track highlighted nodes, keep it in sync with props and enable keyboard navigation const [highlighted, setHighlighted, highlightedRef] = useHighlighted({ @@ -31,13 +36,26 @@ export const Explorer: FC = React.memo(function Explorer({ selected, }); + const { landmarkProps } = useLandmark( + { 'aria-labelledby': 'storybook-explorer-tree-heading', role: 'navigation' }, + containerRef + ); + return ( - + ); }); diff --git a/code/core/src/manager/components/sidebar/Heading.stories.tsx b/code/core/src/manager/components/sidebar/Heading.stories.tsx index d74b0a82c09b..d78f82e64955 100644 --- a/code/core/src/manager/components/sidebar/Heading.stories.tsx +++ b/code/core/src/manager/components/sidebar/Heading.stories.tsx @@ -236,6 +236,6 @@ export const SkipToCanvasLinkFocused: StoryObj = { parameters: { layout: 'padded', chromatic: { delay: 300 } }, play: () => { // focus each instance for chromatic/storybook's stacked theme - screen.getAllByText('Skip to canvas').forEach((x) => x.focus()); + screen.getAllByText('Skip to content').forEach((x) => x.focus()); }, }; diff --git a/code/core/src/manager/components/sidebar/Heading.tsx b/code/core/src/manager/components/sidebar/Heading.tsx index 423a2378b865..29d99db5b864 100644 --- a/code/core/src/manager/components/sidebar/Heading.tsx +++ b/code/core/src/manager/components/sidebar/Heading.tsx @@ -90,7 +90,7 @@ export const Heading: FC> = {skipLinkHref && ( - Skip to canvas + Skip to content )} diff --git a/code/core/src/manager/components/sidebar/Search.tsx b/code/core/src/manager/components/sidebar/Search.tsx index 0bcd4635d164..f2d9e6a6d9ed 100644 --- a/code/core/src/manager/components/sidebar/Search.tsx +++ b/code/core/src/manager/components/sidebar/Search.tsx @@ -12,6 +12,7 @@ import Fuse from 'fuse.js'; import { shortcutToHumanString, useStorybookApi } from 'storybook/manager-api'; import { styled } from 'storybook/theming'; +import { useLandmark } from '../../hooks/useLandmark'; import { getGroupStatus, getMostCriticalStatusValue } from '../../utils/status'; import { scrollIntoView, searchItem } from '../../utils/tree'; import { useLayout } from '../layout/LayoutProvider'; @@ -312,6 +313,9 @@ export const Search = React.memo(function Search({ ); const { isMobile } = useLayout(); + const searchLandmarkRef = useRef(null); + const { landmarkProps } = useLandmark({ role: 'search' }, searchLandmarkRef); + return ( // @ts-expect-error (non strict) @@ -388,7 +392,7 @@ export const Search = React.memo(function Search({ return ( <> Search for components - + (function Search({ {children({ query: input, results, - isBrowsing: !isOpen && document.activeElement !== inputRef.current, + isNavVisible: !isOpen && document.activeElement !== inputRef.current, + isNavReachable: input.length === 0, + isSearchResultRendered: isOpen, closeMenu, getMenuProps, getItemProps, diff --git a/code/core/src/manager/components/sidebar/Sidebar.stories.tsx b/code/core/src/manager/components/sidebar/Sidebar.stories.tsx index 25cbe035cf46..a9b673414423 100644 --- a/code/core/src/manager/components/sidebar/Sidebar.stories.tsx +++ b/code/core/src/manager/components/sidebar/Sidebar.stories.tsx @@ -17,7 +17,6 @@ import { } from '../../manager-stores.mock'; import { LayoutProvider } from '../layout/LayoutProvider'; import { standardData as standardHeaderData } from './Heading.stories'; -import { IconSymbols } from './IconSymbols'; import { DEFAULT_REF_ID, Sidebar } from './Sidebar'; import { mockDataset } from './mockdata'; import type { RefType } from './types'; @@ -109,7 +108,6 @@ const meta = { title.endsWith('scrolled') } > - {storyFn()} @@ -144,7 +142,6 @@ const mobileLayoutDecorator: DecoratorFunction = (storyFn, { globals, title }) = title.endsWith('scrolled') } > - {storyFn()} diff --git a/code/core/src/manager/components/sidebar/Sidebar.tsx b/code/core/src/manager/components/sidebar/Sidebar.tsx index 46bf07a61b6b..4b5798198f63 100644 --- a/code/core/src/manager/components/sidebar/Sidebar.tsx +++ b/code/core/src/manager/components/sidebar/Sidebar.tsx @@ -1,4 +1,4 @@ -import React, { useMemo, useState } from 'react'; +import React, { useMemo, useRef, useState } from 'react'; import { Button, ScrollArea } from 'storybook/internal/components'; import type { API_LoadedRefData, StoryIndex, TagsOptions } from 'storybook/internal/types'; @@ -11,12 +11,14 @@ import { type State, useStorybookApi } from 'storybook/manager-api'; import { styled } from 'storybook/theming'; import { MEDIA_DESKTOP_BREAKPOINT } from '../../constants'; +import { useLandmark } from '../../hooks/useLandmark'; import { useLayout } from '../layout/LayoutProvider'; import { ChecklistWidget } from './ChecklistWidget'; import { CreateNewStoryFileModal } from './CreateNewStoryFileModal'; import { Explorer } from './Explorer'; import type { HeadingProps } from './Heading'; import { Heading } from './Heading'; +import { IconSymbols } from './IconSymbols'; import { Search } from './Search'; import { SearchResults } from './SearchResults'; import { SidebarBottom } from './SidebarBottom'; @@ -26,7 +28,7 @@ import { useLastViewed } from './useLastViewed'; export const DEFAULT_REF_ID = 'storybook_internal'; -const Container = styled.nav(({ theme }) => ({ +const Container = styled.header(({ theme }) => ({ position: 'absolute', zIndex: 1, left: 0, @@ -58,22 +60,6 @@ const CreateNewStoryButton = styled(Button)<{ isMobile: boolean }>(({ theme, isM borderRadius: theme.appBorderRadius + 2, })); -const Swap = React.memo(function Swap({ - children, - condition, -}: { - children: React.ReactNode; - condition: boolean; -}) { - const [a, b] = React.Children.toArray(children); - return ( - <> -
{a}
-
{b}
- - ); -}); - const useCombination = ( index: SidebarProps['index'], indexError: SidebarProps['indexError'], @@ -153,8 +139,18 @@ export const Sidebar = React.memo(function Sidebar({ [] ); + const headerRef = useRef(null); + const { landmarkProps } = useLandmark( + { 'aria-labelledby': 'global-site-h1', role: 'banner' }, + headerRef + ); + return ( - + +

+ Storybook +

+
@@ -202,32 +198,39 @@ export const Sidebar = React.memo(function Sidebar({ {({ query, results, - isBrowsing, + isNavVisible, + isNavReachable, + isSearchResultRendered, closeMenu, getMenuProps, getItemProps, highlightedIndex, }) => ( - - - - + <> + { + + } + {isSearchResultRendered && ( + + )} + )} diff --git a/code/core/src/manager/components/sidebar/TestingWidget.tsx b/code/core/src/manager/components/sidebar/TestingWidget.tsx index 8f31c62116e3..950be08b3dc7 100644 --- a/code/core/src/manager/components/sidebar/TestingWidget.tsx +++ b/code/core/src/manager/components/sidebar/TestingWidget.tsx @@ -21,6 +21,7 @@ import { ChevronSmallUpIcon, PlayAllHollowIcon, SweepIcon } from '@storybook/ico import { internal_fullTestProviderStore } from '#manager-stores'; import { styled } from 'storybook/theming'; +import { useLandmark } from '../../hooks/useLandmark'; import { Optional } from '../Optional/Optional'; import { useDynamicFavicon } from './useDynamicFavicon'; @@ -249,6 +250,12 @@ export const TestingWidget = ({ : undefined ); + const cardRef = useRef(null); + const { landmarkProps } = useLandmark( + { 'aria-labelledby': 'storybook-testing-widget-heading', role: 'region' }, + cardRef + ); + if (!hasTestProviders && !errorCount && !warningCount) { return null; } @@ -261,7 +268,12 @@ export const TestingWidget = ({ outlineColor={ isCrashed || (isRunning && errorCount > 0) ? 'negative' : isUpdated ? 'positive' : undefined } + ref={cardRef} + outlineAttrs={landmarkProps} > +

+ Component tests +

toggleCollapsed(e) } : {})}> {hasTestProviders && ( diff --git a/code/core/src/manager/components/sidebar/Tree.stories.tsx b/code/core/src/manager/components/sidebar/Tree.stories.tsx index 006219a7afd1..96f15677703a 100644 --- a/code/core/src/manager/components/sidebar/Tree.stories.tsx +++ b/code/core/src/manager/components/sidebar/Tree.stories.tsx @@ -12,6 +12,7 @@ import { action } from 'storybook/actions'; import { type ComponentEntry, type IndexHash, ManagerContext } from 'storybook/manager-api'; import { expect, fn, screen, userEvent, within } from 'storybook/test'; +import { IconSymbols } from './IconSymbols'; import { DEFAULT_REF_ID } from './Sidebar'; import { Tree } from './Tree'; import { index } from './mockdata.large'; @@ -77,7 +78,10 @@ const meta = { }, decorators: [ (storyFn) => ( - {storyFn()} + + + {storyFn()} + ), ], } as Meta; @@ -272,7 +276,7 @@ export const SkipToCanvasLinkFocused: Story = { }, play: async ({ canvasElement }) => { const screen = await within(canvasElement); - const link = await screen.findByText('Skip to canvas'); + const link = await screen.findByText('Skip to content'); await link.focus(); diff --git a/code/core/src/manager/components/sidebar/Tree.tsx b/code/core/src/manager/components/sidebar/Tree.tsx index 53e91c636f4a..c2dd3fded13e 100644 --- a/code/core/src/manager/components/sidebar/Tree.tsx +++ b/code/core/src/manager/components/sidebar/Tree.tsx @@ -302,7 +302,7 @@ const Node = React.memo(function Node(props) { {isSelected && ( - Skip to canvas + Skip to content )} {contextMenu.node} @@ -443,7 +443,7 @@ const Node = React.memo(function Node(props) { {isSelected && ( - Skip to canvas + Skip to content )} {contextMenu.node} @@ -501,7 +501,7 @@ const Node = React.memo(function Node(props) { {isSelected && ( - Skip to canvas + Skip to content )} {contextMenu.node} @@ -751,10 +751,7 @@ export const Tree = React.memo<{ ]); return ( -
- - {treeItems} -
+
{treeItems}
); }); diff --git a/code/core/src/manager/components/sidebar/types.ts b/code/core/src/manager/components/sidebar/types.ts index 4f867ab3ffd8..aa956ab22b33 100644 --- a/code/core/src/manager/components/sidebar/types.ts +++ b/code/core/src/manager/components/sidebar/types.ts @@ -54,7 +54,16 @@ export type DownshiftItem = SearchResult | ExpandType; export type SearchChildrenFn = (args: { query: string; results: DownshiftItem[]; - isBrowsing: boolean; + // Whether the nav explorer should be visible in the UI. When the search input is + // focused, it gets replaced with a list of recently viewed stories. When there + // is a search query, it gets replaced by the search results. + isNavVisible: boolean; + // Whether the nav explorer should be reachable in the document. When the search + // input is focused, we keep the nav rendered in the document and reachable by + // keyboard, so keyboard shortcuts to navigate to it still work. + isNavReachable: boolean; + // Whether the UI with search results or recently viewed pages is visible. + isSearchResultRendered: boolean; closeMenu: (cb?: () => void) => void; getMenuProps: ControllerStateAndHelpers['getMenuProps']; getItemProps: ControllerStateAndHelpers['getItemProps']; diff --git a/code/core/src/manager/hooks/useLandmark.ts b/code/core/src/manager/hooks/useLandmark.ts new file mode 100644 index 000000000000..06bc856377c9 --- /dev/null +++ b/code/core/src/manager/hooks/useLandmark.ts @@ -0,0 +1,22 @@ +import { type RefObject } from 'react'; + +import { + type AriaLandmarkProps, + type LandmarkAria, + useLandmark as useUpstream, +} from '@react-aria/landmark'; +import type { FocusableElement } from '@react-types/shared'; + +export function useLandmark( + props: AriaLandmarkProps, + ref: RefObject +): LandmarkAria & { landmarkProps: { 'data-sb-landmark': true } } { + const { landmarkProps } = useUpstream(props, ref); + + return { + landmarkProps: { + ...landmarkProps, + 'data-sb-landmark': true, + }, + }; +} diff --git a/code/core/src/manager/settings/defaultShortcuts.tsx b/code/core/src/manager/settings/defaultShortcuts.tsx index b892f742ddd5..ec4561e979d4 100644 --- a/code/core/src/manager/settings/defaultShortcuts.tsx +++ b/code/core/src/manager/settings/defaultShortcuts.tsx @@ -23,6 +23,8 @@ export const defaultShortcuts: State['shortcuts'] = { openInEditor: ['alt', 'shift', 'E'], openInIsolation: ['alt', 'shift', 'I'], copyStoryLink: ['alt', 'shift', 'L'], + goToPreviousLandmark: ['shift', 'F6'], // hardcoded in react-aria + goToNextLandmark: ['F6'], // hardcoded in react-aria // TODO: bring this back once we want to add shortcuts for this // copyStoryName: ['alt', 'shift', 'C'], }; diff --git a/code/core/src/manager/settings/shortcuts.tsx b/code/core/src/manager/settings/shortcuts.tsx index ebb0c4951a4c..62b20331170a 100644 --- a/code/core/src/manager/settings/shortcuts.tsx +++ b/code/core/src/manager/settings/shortcuts.tsx @@ -1,11 +1,12 @@ import type { ComponentProps, FC } from 'react'; import React, { Component } from 'react'; -import { Button, Form } from 'storybook/internal/components'; +import { Button, Form, Tooltip, TooltipProvider } from 'storybook/internal/components'; import { CheckIcon } from '@storybook/icons'; import { + type API_KeyCollection, eventToShortcut, shortcutMatchesShortcut, shortcutToHumanString, @@ -135,22 +136,34 @@ const shortcutLabels = { openInEditor: 'Open story in editor', openInIsolation: 'Open story in isolation', copyStoryLink: 'Copy story link to clipboard', + goToPreviousLandmark: 'Go to previous landmark', + goToNextLandmark: 'Go to next landmark', // TODO: bring this back once we want to add shortcuts for this // copyStoryName: 'Copy story name to clipboard', }; export type Feature = keyof typeof shortcutLabels; +type ConfiguredShortcut = { shortcut: API_KeyCollection; error: boolean; hardcoded?: boolean }; + // Shortcuts that cannot be configured const fixedShortcuts = ['escape']; -function toShortcutState(shortcutKeys: ShortcutsScreenProps['shortcutKeys']) { - return Object.entries(shortcutKeys).reduce( - // @ts-expect-error (non strict) - (acc, [feature, shortcut]: [Feature, string]) => - fixedShortcuts.includes(feature) ? acc : { ...acc, [feature]: { shortcut, error: false } }, - {} as Record - ); +// Shortcuts that cannot be changed by the user (imposed by third-party libraries). +const hardcodedShortcuts = ['goToPreviousLandmark', 'goToNextLandmark']; +function toShortcutState( + shortcutKeys: ShortcutsScreenProps['shortcutKeys'] +): Record { + const state: Record = {}; + for (const key of Object.keys(shortcutKeys).filter((k) => !fixedShortcuts.includes(k))) { + state[key] = { + shortcut: shortcutKeys[key as Feature], + error: false, + hardcoded: hardcodedShortcuts.includes(key), + }; + } + + return state; } export interface ShortcutsScreenState { @@ -179,7 +192,6 @@ class ShortcutsScreen extends Component ( - - {/* @ts-expect-error (non strict) */} - {shortcutLabels[feature] || addonsShortcutLabels[feature]} - - - - {/* @ts-expect-error (non strict) */} - - - )); + const arr = availableShortcuts.map( + ([feature, { shortcut, hardcoded }]: [Feature, ConfiguredShortcut]) => ( + + {/* @ts-expect-error (non strict) */} + {shortcutLabels[feature] || addonsShortcutLabels[feature]} + + {hardcoded ? ( + <> + This shortcut cannot be changed.} + placement="right" + > + + + + ) : ( + + )} + + {/* @ts-expect-error (non strict) */} + + + ) + ); return arr; }; @@ -347,7 +376,6 @@ class ShortcutsScreen extends Component Restore defaults - ); diff --git a/code/core/src/theming/global.ts b/code/core/src/theming/global.ts index 190dad605bad..ba0f089b1603 100644 --- a/code/core/src/theming/global.ts +++ b/code/core/src/theming/global.ts @@ -134,6 +134,26 @@ export const createGlobal = memoize(1)(({ opacity: 1, }, + '[data-sb-landmark]': { + position: 'relative', + }, + + '[data-sb-landmark]:focus-visible': { + outline: 'none', + }, + + '[data-sb-landmark]:focus-visible::after': { + outline: `2px solid ${color.primary}`, + outlineOffset: '-2px', + }, + + '[data-sb-landmark]::after': { + content: "''", + position: 'absolute', + inset: 0, + pointerEvents: 'none', + }, + '.react-aria-Popover:focus-visible': { outline: 'none', }, diff --git a/docs/get-started/browse-stories.mdx b/docs/get-started/browse-stories.mdx index 9332cdb02592..302166b68d34 100644 --- a/docs/get-started/browse-stories.mdx +++ b/docs/get-started/browse-stories.mdx @@ -8,18 +8,22 @@ sidebar: Last chapter, we learned that stories correspond with discrete component states. This chapter demonstrates how to use Storybook as a workshop for building components. -## Sidebar and Canvas +## Sidebar and Preview -A `*.stories.js|ts|svelte` file defines all the stories for a component. Each story has a corresponding sidebar item. When you click on a story, it renders in the Canvas an isolated preview iframe. +A `*.stories.js|ts|svelte` file defines all the stories for a component. Each story has a corresponding sidebar item. When you click on a story, it renders in an isolated preview iframe.