diff --git a/code/addons/a11y/src/components/VisionSimulator.tsx b/code/addons/a11y/src/components/VisionSimulator.tsx index 4783a556ed57..ec8d1050a792 100644 --- a/code/addons/a11y/src/components/VisionSimulator.tsx +++ b/code/addons/a11y/src/components/VisionSimulator.tsx @@ -21,10 +21,11 @@ const Hidden = styled.div({ const ColorIcon = styled.span<{ $filter: string }>( { background: 'linear-gradient(to right, #F44336, #FF9800, #FFEB3B, #8BC34A, #2196F3, #9C27B0)', - borderRadius: '1rem', + borderRadius: 14, display: 'block', - height: '1rem', - width: '1rem', + flexShrink: 0, + height: 14, + width: 14, }, ({ $filter }) => ({ filter: filters[$filter as keyof typeof filters].filter || 'none', diff --git a/code/core/src/components/components/ActionList/ActionList.stories.tsx b/code/core/src/components/components/ActionList/ActionList.stories.tsx index d2f011af0b59..848ecc02861e 100644 --- a/code/core/src/components/components/ActionList/ActionList.stories.tsx +++ b/code/core/src/components/components/ActionList/ActionList.stories.tsx @@ -62,7 +62,9 @@ export const Default = meta.story({ - + + + Active with an icon @@ -77,10 +79,59 @@ export const Default = meta.story({ Some very long text which will ellipsize when the container is too narrow + + + + + + + Title + Description + + + + + + + + + + Some very long text which is going to wrap around + Here is a very long description which is also going to wrap around + + + ), }); +export const Listbox = meta.story({ + render: () => ( + <> + + + + + + Option + + + + + + Selected + + + + + + Visually disabled + + + > + ), +}); + export const Groups = meta.story({ render: () => ( <> diff --git a/code/core/src/components/components/ActionList/ActionList.tsx b/code/core/src/components/components/ActionList/ActionList.tsx index 73c75393b077..0c6abee84258 100644 --- a/code/core/src/components/components/ActionList/ActionList.tsx +++ b/code/core/src/components/components/ActionList/ActionList.tsx @@ -1,5 +1,6 @@ import React, { type ComponentProps, forwardRef } from 'react'; +import { darken, transparentize } from 'polished'; import type { TransitionStatus } from 'react-transition-state'; import { styled } from 'storybook/theming'; @@ -16,22 +17,53 @@ const ActionListItem = styled.li<{ justifyContent: 'space-between', flex: '0 0 auto', overflow: 'hidden', + minHeight: 32, gap: 4, fontSize: theme.typography.size.s1, fontWeight: active ? theme.typography.weight.bold : theme.typography.weight.regular, - color: active ? theme.color.secondary : theme.color.defaultText, - '--listbox-item-muted-color': active ? theme.color.secondary : theme.color.mediumdark, + color: active ? 'var(--listbox-item-active-color)' : theme.color.defaultText, + '--listbox-item-active-color': + theme.base === 'light' ? darken(0.1, theme.color.secondary) : theme.color.secondary, + '--listbox-item-muted-color': active + ? 'var(--listbox-item-active-color)' + : theme.color.mediumdark, + + '&[aria-disabled="true"]': { + opacity: 0.5, + cursor: 'not-allowed', + }, + '&[aria-selected="true"]': { + color: 'var(--listbox-item-active-color)', + fontWeight: theme.typography.weight.bold, + '--listbox-item-muted-color': 'var(--listbox-item-active-color)', + }, '&:not(:hover, :has(:focus-visible)) svg + input': { position: 'absolute', opacity: 0, }, + '&[role="option"]': { + cursor: 'pointer', + borderRadius: theme.input.borderRadius, + outlineOffset: -2, + padding: '0 9px', + gap: 8, + + '&:hover': { + background: transparentize(0.86, theme.color.secondary), + }, + '&:focus-visible': { + outline: `2px solid ${theme.color.secondary}`, + }, + }, + '@supports (interpolate-size: allow-keywords)': { interpolateSize: 'allow-keywords', - transition: 'all var(--transition-duration, 0.2s)', transitionBehavior: 'allow-discrete', + transitionDuration: 'var(--transition-duration, 0.2s)', + transitionProperty: 'opacity, block-size, content-visibility', }, '@media (prefers-reduced-motion: reduce)': { @@ -83,12 +115,14 @@ const ActionListHoverItem = styled(ActionListItem)<{ targetId: string }>(({ targ }, })); -const StyledButton = styled(Button)({ +const StyledButton = styled(Button)(({ size }) => ({ + gap: size === 'small' ? 6 : 8, + '&:focus-visible': { // Prevent focus outline from being cut off by overflow: hidden outlineOffset: -2, }, -}); +})); const StyledToggleButton = styled(ToggleButton)({ '&:focus-visible': { @@ -116,6 +150,8 @@ const ActionListToggle = forwardRef ({ + height: 'auto', + minHeight: 32, flex: '0 1 100%', textAlign: 'start', justifyContent: 'space-between', @@ -137,33 +173,41 @@ const ActionListLink = ( props: ComponentProps & React.AnchorHTMLAttributes ) => ; -const ActionListText = styled.div({ +const ActionListText = styled.div(({ theme }) => ({ display: 'flex', - alignItems: 'center', - gap: 8, + flexDirection: 'column', + justifyContent: 'center', flexGrow: 1, minWidth: 0, padding: '8px 0', lineHeight: '16px', - '& span': { + '& > *': { + margin: 0, + whiteSpace: 'normal', + }, + '& > span': { overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', }, + '& small': { + fontSize: 'inherit', + color: theme.textMutedColor, + }, '&:first-child': { paddingLeft: 8, }, '&:last-child': { paddingRight: 8, }, - 'button > &:first-child': { + 'button > &:first-child, [role="option"] > &:first-child': { paddingLeft: 0, }, - 'button > &:last-child': { + 'button > &:last-child, [role="option"] > &:last-child': { paddingRight: 0, }, -}); +})); const ActionListIcon = styled.div({ display: 'flex', diff --git a/code/core/src/components/components/Select/Select.tsx b/code/core/src/components/components/Select/Select.tsx index 4ac310957c5f..b9287463839a 100644 --- a/code/core/src/components/components/Select/Select.tsx +++ b/code/core/src/components/components/Select/Select.tsx @@ -317,9 +317,9 @@ export const Select = forwardRef( // wrap setActiveOption to handle selection. We never close the listbox // in that scenario. const setActiveOption = useCallback( - (option: Option | ResetOption) => { + (option: Option | ResetOption, changeSelection = true) => { setActiveOptionState(optionOrResetToInternal(option)); - if (!multiSelect) { + if (!multiSelect && changeSelection) { handleSelectOption(optionOrResetToInternal(option)); } }, @@ -559,6 +559,7 @@ export const Select = forwardRef( key={option.value === undefined ? 'sb-reset' : String(option.value)} title={option.title} description={option.description} + aside={option.aside} icon={ !isReset && multiSelect ? ( // Purely decorative. @@ -576,7 +577,7 @@ export const Select = forwardRef( handleClose(); } }} - onFocus={() => setActiveOption(externalOption)} + onFocus={() => setActiveOption(externalOption, false)} shouldLookDisabled={isReset && selectedOptions.length === 0 && multiSelect} onKeyDown={(e: KeyboardEvent) => { if (e.key === 'Enter' || e.key === ' ') { diff --git a/code/core/src/components/components/Select/SelectOption.tsx b/code/core/src/components/components/Select/SelectOption.tsx index 178a37313130..be9447889f26 100644 --- a/code/core/src/components/components/Select/SelectOption.tsx +++ b/code/core/src/components/components/Select/SelectOption.tsx @@ -1,7 +1,6 @@ import React from 'react'; -import { darken, transparentize } from 'polished'; -import { styled } from 'storybook/theming'; +import { ActionList } from '../..'; export interface SelectOptionProps { /** @@ -17,9 +16,12 @@ export interface SelectOptionProps { /** Secondary text or description not necessary to identify the option. */ description?: string; - /** Decorative icon. */ + /** Decorative icon, displayed to the left of the title and description. */ icon?: React.ReactNode; + /** Extra content, displayed to the right of the title and description. */ + aside?: React.ReactNode; + /** Optional rendering of the option. Use sparingly. */ children?: React.ReactNode; @@ -40,67 +42,12 @@ export interface SelectOptionProps { shouldLookDisabled: boolean; } -const Item = styled('li')(({ theme }) => ({ - padding: '6px 12px', - fontSize: 12, - lineHeight: 1.5, - background: 'transparent', - color: theme.color.defaultText, - cursor: 'pointer', - borderRadius: 4, - '&[aria-disabled="true"]': { - opacity: 0.5, - cursor: 'not-allowed', - }, - '&[aria-selected="true"]': { - color: theme.base === 'light' ? darken(0.1, theme.color.secondary) : theme.color.secondary, - fontWeight: theme.typography.weight.bold, - }, - ':hover': { - background: transparentize(0.93, theme.color.secondary), - }, - ':focus-visible': { - background: theme.background.hoverable, - outline: `2px solid ${theme.barSelectedColor}`, - outlineOffset: 1, - borderRadius: 2, - }, - display: 'flex', - alignItems: 'flex-start', - gap: 8, -})); - -const Row = styled('div')({ - display: 'flex', - flexDirection: 'row', - gap: 4, - alignItems: 'center', -}); - -const Col = styled('div')({ - display: 'flex', - flexDirection: 'column', - flexGrow: 1, -}); - -const Icon = styled('span')(() => ({ - display: 'block', - height: '1rem', - width: '1rem', -})); - -const Title = styled('span')(({}) => ({})); - -const Description = styled('span')(({ theme }) => ({ - fontSize: 11, - color: theme.textMutedColor, -})); - export const SelectOption: React.FC = ({ id, title, description, icon, + aside, children, isSelected, isActive, @@ -111,7 +58,7 @@ export const SelectOption: React.FC = ({ ...props }) => { return ( - = ({ onKeyDown={onKeyDown} > {children ?? ( - - {icon && {icon}} - - {title} - {description && {description}} - - + <> + {icon && {icon}} + + {title} + {description && {description}} + + {aside} + > )} - + ); }; diff --git a/code/core/src/components/components/Select/helpers.tsx b/code/core/src/components/components/Select/helpers.tsx index b115812ffe45..56500dd66a29 100644 --- a/code/core/src/components/components/Select/helpers.tsx +++ b/code/core/src/components/components/Select/helpers.tsx @@ -14,6 +14,7 @@ export interface Option { title: string; description?: string; icon?: React.ReactNode; + aside?: React.ReactNode; value: Value; } export interface InternalOption extends Omit { diff --git a/code/core/src/manager/components/layout/useDragging.ts b/code/core/src/manager/components/layout/useDragging.ts index 88727a1ff2b3..1028f45df9f5 100644 --- a/code/core/src/manager/components/layout/useDragging.ts +++ b/code/core/src/manager/components/layout/useDragging.ts @@ -34,7 +34,7 @@ export function useDragging({ useEffect(() => { const panelResizer = panelResizerRef.current; const sidebarResizer = sidebarResizerRef.current; - const previewIframe = document.querySelector('#storybook-preview-wrapper') as HTMLIFrameElement; + const previewIframe = document.querySelector('#storybook-preview-iframe') as HTMLIFrameElement; let draggedElement: typeof panelResizer | typeof sidebarResizer | null = null; const onDragStart = (e: MouseEvent) => { diff --git a/code/core/src/manager/components/preview/FramesRenderer.tsx b/code/core/src/manager/components/preview/FramesRenderer.tsx index 03a47e247676..5aa1547e2508 100644 --- a/code/core/src/manager/components/preview/FramesRenderer.tsx +++ b/code/core/src/manager/components/preview/FramesRenderer.tsx @@ -8,7 +8,7 @@ import { Consumer } from 'storybook/manager-api'; import { Global, styled } from 'storybook/theming'; import type { CSSObject } from 'storybook/theming'; -import { IFrame } from './Iframe'; +import { Viewport } from './Viewport'; import type { FramesRendererProps } from './utils/types'; const getActive = (refId: FramesRendererProps['refId'], refs: FramesRendererProps['refs']) => { @@ -104,21 +104,9 @@ export const FramesRenderer: FC = ({ ); }} - {Object.entries(frames).map(([id, src]) => { - return ( - - - - ); - })} + {Object.entries(frames).map(([id, src]) => ( + + ))} ); }; diff --git a/code/core/src/manager/components/preview/Iframe.tsx b/code/core/src/manager/components/preview/Iframe.tsx index 2a9dfc3b245e..1541e78409c4 100644 --- a/code/core/src/manager/components/preview/Iframe.tsx +++ b/code/core/src/manager/components/preview/Iframe.tsx @@ -11,11 +11,10 @@ const StyledIframe = styled.iframe(({ theme }) => ({ boxSizing: 'content-box', height: '100%', width: '100%', - border: '0 none', + border: 'none', transition: 'background-position 0s, visibility 0s', backgroundPosition: '-1px -1px, -1px -1px, -1px -1px, -1px -1px', margin: `auto`, - boxShadow: '0 0 100px 100vw rgba(0,0,0,0.5)', })); export interface IFrameProps { diff --git a/code/core/src/manager/components/preview/SizeInput.tsx b/code/core/src/manager/components/preview/SizeInput.tsx new file mode 100644 index 000000000000..f02c45b5bc1e --- /dev/null +++ b/code/core/src/manager/components/preview/SizeInput.tsx @@ -0,0 +1,101 @@ +import type { ChangeEvent, ComponentProps } from 'react'; +import React, { useCallback, useEffect, useRef, useState } from 'react'; + +import { Form } from 'storybook/internal/components'; + +import { useId } from '@react-aria/utils'; +import { styled } from 'storybook/theming'; + +const Wrapper = styled.span<{ prefix?: string }>(({ theme, prefix }) => ({ + position: 'relative', + fontSize: theme.typography.size.s1, + input: { + width: 70, + height: 28, + minHeight: 28, + paddingLeft: 25, + paddingRight: 0, + fontSize: 'inherit', + '&:focus': { + boxShadow: 'none', + outline: `2px solid ${theme.color.secondary}`, + outlineOffset: -2, + }, + }, + ...(prefix && { + '&::before': { + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + content: `"${prefix}"`, + position: 'absolute', + left: 5, + top: 0, + bottom: 0, + width: 20, + zIndex: 1, + color: theme.textMutedColor, + }, + }), +})); + +export const SizeInput = ({ + label, + prefix, + value, + setValue, + ...props +}: { + label?: string; + prefix?: string; + value: string; + setValue: (value: string) => void; +} & Omit, 'value'>) => { + const inputId = useId(); + const inputRef = useRef(null); + const [inputValue, setInputValue] = useState(value.replace(/px$/, '')); + const id = props.id || inputId; + + useEffect(() => setInputValue(value.replace(/px$/, '')), [value]); + + const onChange = useCallback( + (e: ChangeEvent) => { + setInputValue(e.target.value); + setValue(Number.isNaN(Number(e.target.value)) ? e.target.value : `${e.target.value}px`); + }, + [setValue] + ); + + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key !== 'ArrowUp' && e.key !== 'ArrowDown') { + return; + } + e.preventDefault(); + const num = parseInt(inputValue, 10); + const update = e.key === 'ArrowUp' ? num + 1 : num - 1; + if (!Number.isNaN(num) && update >= 0) { + const unit = inputValue.match(/[0-9]{1,4}(%|[a-z]{0,4})?$/)?.[1] || 'px'; + setInputValue(`${update}${unit === 'px' ? '' : unit}`); + setValue(`${update}${unit}`); + } + }; + + const input = inputRef.current; + if (input) { + input.addEventListener('keydown', handleKeyDown); + return () => input.removeEventListener('keydown', handleKeyDown); + } + }, [inputValue, setValue]); + + return ( + + {label && ( + + {label} + + )} + + + ); +}; diff --git a/code/core/src/manager/components/preview/Viewport.stories.tsx b/code/core/src/manager/components/preview/Viewport.stories.tsx new file mode 100644 index 000000000000..67a842259f4e --- /dev/null +++ b/code/core/src/manager/components/preview/Viewport.stories.tsx @@ -0,0 +1,121 @@ +import { ManagerContext } from 'storybook/manager-api'; +import { fn } from 'storybook/test'; +import type { ViewportMap } from 'storybook/viewport'; + +import preview from '../../../../../.storybook/preview'; +import { Viewport } from './Viewport'; + +const managerContext: any = { + state: {}, + api: { + getCurrentParameter: fn(), + getGlobals: fn(() => ({})), + getStoryGlobals: fn(() => ({})), + getUserGlobals: fn(() => ({})), + getUrlState: fn(() => ({ viewMode: 'story' })), + updateGlobals: fn(), + setAddonShortcut: fn(), + on: fn(), + off: fn(), + emit: fn(), + }, +}; + +const customViewports = { + narrow: { + name: 'Narrow', + styles: { + height: '100%', + width: '400px', + }, + type: 'other', + }, + short: { + name: 'Short', + styles: { + height: '400px', + width: '100%', + }, + type: 'other', + }, +} as ViewportMap; + +const meta = preview.meta({ + component: Viewport, + args: { + active: true, + id: 'storybook-preview-iframe', + src: '/iframe.html?id=manager-settings-checklist--default', + scale: 1, + }, + decorators: [ + (Story) => ( + + + + ), + ], + globals: { + viewport: { value: undefined }, + }, + parameters: { + layout: 'centered', + }, + beforeEach: () => { + managerContext.api.getCurrentParameter.mockReset(); + managerContext.api.getGlobals.mockReset(); + managerContext.api.getStoryGlobals.mockReset(); + managerContext.api.getUserGlobals.mockReset(); + }, +}); + +export const Default = meta.story({ + parameters: { + layout: 'fullscreen', + }, +}); + +export const Mobile = meta.story({ + beforeEach() { + managerContext.api.getGlobals.mockReturnValue({ viewport: { value: 'mobile1' } }); + }, +}); + +export const Locked = meta.story({ + beforeEach() { + managerContext.api.getGlobals.mockReturnValue({ + viewport: { value: 'mobile1' }, + }); + managerContext.api.getStoryGlobals.mockReturnValue({ + viewport: { value: 'mobile1' }, + }); + }, +}); + +export const Rotated = meta.story({ + beforeEach() { + managerContext.api.getGlobals.mockReturnValue({ + viewport: { value: 'mobile1', isRotated: true }, + }); + }, +}); + +export const Short = meta.story({ + globals: { + viewport: { value: 'short' }, + }, + parameters: { + viewport: { options: customViewports }, + }, + render: () => <>>, +}); + +export const Narrow = meta.story({ + globals: { + viewport: { value: 'narrow' }, + }, + parameters: { + viewport: { options: customViewports }, + }, + render: () => <>>, +}); diff --git a/code/core/src/manager/components/preview/Viewport.tsx b/code/core/src/manager/components/preview/Viewport.tsx new file mode 100644 index 000000000000..b20a30639d64 --- /dev/null +++ b/code/core/src/manager/components/preview/Viewport.tsx @@ -0,0 +1,334 @@ +import React, { useEffect, useRef, useState } from 'react'; + +import { ActionList } from 'storybook/internal/components'; + +import { TransferIcon, UndoIcon } from '@storybook/icons'; + +import { styled } from 'storybook/theming'; + +import { + VIEWPORT_MIN_HEIGHT, + VIEWPORT_MIN_WIDTH, + useViewport, +} from '../../../viewport/useViewport'; +import { IFrame } from './Iframe'; +import { SizeInput } from './SizeInput'; + +type DragSide = 'none' | 'both' | 'bottom' | 'right'; + +const ViewportWrapper = styled.div<{ + active: boolean; + isDefault: boolean; +}>(({ active, isDefault, theme }) => ({ + gridArea: '1 / 1', + alignSelf: 'start', + justifySelf: 'start', + display: active ? 'inline-flex' : 'none', + flexDirection: 'column', + gap: 6, + width: '100%', + height: '100%', + paddingTop: isDefault ? 0 : 6, + paddingBottom: isDefault ? 0 : 40, + paddingInline: isDefault ? 0 : 40, + + '&:has([data-size-input="width"]:focus-visible)': { + '[data-dragging]': { + borderRightColor: theme.color.secondary, + boxShadow: `4px 0 5px -2px ${theme.background.hoverable}`, + }, + }, + '&:has([data-size-input="height"]:focus-visible)': { + '[data-dragging]': { + borderBottomColor: theme.color.secondary, + boxShadow: `0 4px 5px -2px ${theme.background.hoverable}`, + }, + }, +})); + +const ViewportControls = styled.div({ + display: 'flex', + gap: 6, +}); + +const ViewportDimensions = styled.div({ + display: 'flex', + gap: 2, +}); + +const FrameWrapper = styled.div<{ + isDefault: boolean; + 'data-dragging': DragSide; +}>(({ isDefault, 'data-dragging': dragging, theme }) => ({ + position: 'relative', + minWidth: VIEWPORT_MIN_WIDTH, + minHeight: VIEWPORT_MIN_HEIGHT, + boxSizing: 'content-box', // we're sizing the contents, not the box itself + border: `1px solid ${theme.button.border}`, + borderWidth: isDefault ? 0 : 1, + borderRadius: isDefault ? 0 : 4, + transition: 'border-color 0.2s, box-shadow 0.2s', + '&:has([data-side="right"]:hover), &[data-dragging="right"]': { + borderRightColor: theme.color.secondary, + boxShadow: `4px 0 5px -2px ${theme.background.hoverable}`, + '[data-side="right"]::after': { + opacity: 1, + }, + }, + '&:has([data-side="bottom"]:hover), &[data-dragging="bottom"]': { + borderBottomColor: theme.color.secondary, + boxShadow: `0 4px 5px -2px ${theme.background.hoverable}`, + '[data-side="bottom"]::after': { + opacity: 1, + }, + }, + '&:has([data-side="both"]:hover), &[data-dragging="both"]': { + boxShadow: `3px 3px 5px -2px ${theme.background.hoverable}`, + '&::after, [data-side]::after': { + opacity: 1, + }, + }, + '&::after': { + content: '""', + display: 'block', + position: 'absolute', + pointerEvents: 'none', + bottom: 1, + right: 1, + width: 12, + height: 12, + opacity: 0, + transition: 'opacity 0.2s', + background: `linear-gradient(to top left, + rgba(0,0,0,0) 0%, + rgba(0,0,0,0) calc(25% - 1px), + ${theme.color.secondary} 25%, + rgba(0,0,0,0) calc(25% + 1px), + rgba(0,0,0,0) calc(45% - 1px), + ${theme.color.secondary} 45%, + rgba(0,0,0,0) calc(45% + 1px), + rgba(0,0,0,0) 100%)`, + }, + iframe: { + borderRadius: 'inherit', + pointerEvents: dragging === 'none' ? 'auto' : 'none', + }, +})); + +const DragHandle = styled.div<{ + 'data-side': DragSide; + isDefault: boolean; +}>( + { display: 'none' }, + ({ theme, isDefault }) => + !isDefault && { + display: 'block', + position: 'absolute', + fontSize: 10, + '&[data-side="both"]': { + right: -12, + bottom: -12, + width: 25, + height: 25, + cursor: 'nwse-resize', + }, + '&[data-side="bottom"]': { + left: 0, + right: 13, + bottom: -12, + height: 20, + cursor: 'row-resize', + '&::after': { + content: 'attr(data-value)', + position: 'absolute', + top: '50%', + left: '50%', + transform: 'translate(-50%, -50%)', + borderRadius: 4, + backgroundColor: theme.background.hoverable, + padding: '2px 4px', + opacity: 0, + transition: 'opacity 0.2s', + }, + }, + '&[data-side="right"]': { + top: 0, + right: -12, + bottom: 13, + width: 20, + cursor: 'col-resize', + '&::after': { + content: 'attr(data-value)', + position: 'absolute', + top: '50%', + left: '50%', + transform: 'translate(-50%, -50%)', + borderRadius: 4, + backgroundColor: theme.background.hoverable, + padding: '2px 4px', + opacity: 0, + transition: 'opacity 0.2s', + }, + }, + } +); + +const ScrollEdge = styled.div<{ 'data-edge': DragSide }>({ + position: 'absolute', + pointerEvents: 'none', + width: 0, + height: 0, + '&[data-edge="right"]': { + right: -40, + height: '100%', + }, + '&[data-edge="bottom"]': { + bottom: -40, + width: '100%', + }, + '&[data-edge="both"]': { + right: -40, + bottom: -40, + }, +}); + +export const Viewport = ({ + active, + id, + src, + scale, +}: { + active: boolean; + id: string; + src: string; + scale: number; +}) => { + const { width, height, isCustom, isDefault, lastSelectedOption, resize, rotate, select } = + useViewport(); + + const [dragging, setDragging] = useState('none'); + const targetRef = useRef(null); + const dragRefX = useRef(null); + const dragRefY = useRef(null); + const dragRefXY = useRef(null); + const dragSide = useRef('none'); + const dragStart = useRef<[number, number] | undefined>(); + + useEffect(() => { + const onDrag = (e: MouseEvent) => { + if (dragSide.current === 'both' || dragSide.current === 'right') { + targetRef.current!.style.width = `${dragStart.current![0] + e.clientX}px`; + dragRefX.current!.dataset.value = `${dragStart.current![0] + e.clientX}`; + } + if (dragSide.current === 'both' || dragSide.current === 'bottom') { + targetRef.current!.style.height = `${dragStart.current![1] + e.clientY}px`; + dragRefY.current!.dataset.value = `${dragStart.current![1] + e.clientY}`; + } + }; + + const onEnd = () => { + window.removeEventListener('mouseup', onEnd); + window.removeEventListener('mousemove', onDrag); + setDragging('none'); + resize(`${targetRef.current!.clientWidth}px`, `${targetRef.current!.clientHeight}px`); + dragStart.current = undefined; + }; + + const onStart = (e: MouseEvent) => { + e.preventDefault(); + window.addEventListener('mouseup', onEnd); + window.addEventListener('mousemove', onDrag); + dragSide.current = (e.currentTarget as HTMLElement).dataset.side as DragSide; + dragStart.current = [ + (targetRef.current?.clientWidth ?? 0) - e.clientX, + (targetRef.current?.clientHeight ?? 0) - e.clientY, + ]; + setDragging(dragSide.current); + }; + + const handles = [dragRefX.current, dragRefY.current, dragRefXY.current]; + handles.forEach((el) => el?.addEventListener('mousedown', onStart)); + return () => handles.forEach((el) => el?.removeEventListener('mousedown', onStart)); + }, [resize]); + + return ( + + {!isDefault && ( + + + resize(value, height)} + /> + + + + resize(width, value)} + /> + {isCustom && lastSelectedOption && ( + select(lastSelectedOption)} + > + + + )} + + + )} + + + {!isDefault && ( + <> + + + + > + )} + + + + + + ); +}; diff --git a/code/core/src/manager/components/preview/utils/components.ts b/code/core/src/manager/components/preview/utils/components.ts index 1315aa1cd6bf..a93acd7685fc 100644 --- a/code/core/src/manager/components/preview/utils/components.ts +++ b/code/core/src/manager/components/preview/utils/components.ts @@ -27,8 +27,8 @@ export const CanvasWrap = styled.div<{ show: boolean }>( gridTemplateColumns: '100%', gridTemplateRows: '100%', position: 'relative', - width: '100%', - height: '100%', + minWidth: '100%', + minHeight: '100%', }, ({ show }) => ({ display: show ? 'grid' : 'none' }) ); diff --git a/code/core/src/manager/components/sidebar/ChecklistWidget.tsx b/code/core/src/manager/components/sidebar/ChecklistWidget.tsx index 129480d1584b..1f93f9c56fed 100644 --- a/code/core/src/manager/components/sidebar/ChecklistWidget.tsx +++ b/code/core/src/manager/components/sidebar/ChecklistWidget.tsx @@ -121,6 +121,7 @@ const ItemLabel = styled.span<{ isCompleted: boolean; isSkipped: boolean }>( }), ({ theme, isSkipped }) => isSkipped && { + alignSelf: 'flex-start', '&:after': { content: '""', position: 'absolute', diff --git a/code/core/src/viewport/components/Tool.tsx b/code/core/src/viewport/components/Tool.tsx index 96422c2164e3..e94133f242f6 100644 --- a/code/core/src/viewport/components/Tool.tsx +++ b/code/core/src/viewport/components/Tool.tsx @@ -1,103 +1,26 @@ -import React, { type FC, Fragment, useCallback, useEffect, useMemo } from 'react'; +import React, { useMemo } from 'react'; -import { Button, Select } from 'storybook/internal/components'; +import { Select } from 'storybook/internal/components'; -import { GrowIcon, TransferIcon } from '@storybook/icons'; +import { GrowIcon } from '@storybook/icons'; -import { type API, useGlobals, useParameter } from 'storybook/manager-api'; -import { Global, styled } from 'storybook/theming'; +import { styled } from 'storybook/theming'; -import { PARAM_KEY } from '../constants'; -import { MINIMAL_VIEWPORTS } from '../defaults'; -import { responsiveViewport } from '../responsiveViewport'; -import { registerShortcuts } from '../shortcuts'; -import type { GlobalStateUpdate, Viewport, ViewportMap, ViewportParameters } from '../types'; -import { ActiveViewportLabel, ActiveViewportSize, iconsMap } from '../utils'; +import { useViewport } from '../useViewport'; +import { iconsMap } from '../viewportIcons'; -interface PureProps { - item: Viewport; - updateGlobals: ReturnType['1']; - viewportMap: ViewportMap; - viewportName: keyof ViewportMap; - isLocked: boolean; - isRotated: boolean | undefined; - width: string; - height: string; -} +const Dimensions = styled.div(({ theme }) => ({ + display: 'flex', + gap: 2, + marginLeft: 20, + fontFamily: theme.typography.fonts.mono, + fontSize: theme.typography.size.s1 - 1, + fontWeight: theme.typography.weight.regular, + color: theme.textMutedColor, +})); -export const ViewportTool: FC<{ api: API }> = ({ api }) => { - const config = useParameter(PARAM_KEY); - const [globals, updateGlobals, storyGlobals] = useGlobals(); - - const { options = MINIMAL_VIEWPORTS, disable } = config || {}; - const data = globals?.[PARAM_KEY] || {}; - const viewportName = typeof data === 'string' ? data : data.value; - const isRotated = typeof data === 'string' ? false : !!data.isRotated; - - const item = (options as ViewportMap)[viewportName] || responsiveViewport; - const isLocked = PARAM_KEY in storyGlobals; - - const length = Object.keys(options).length; - - useEffect(() => { - registerShortcuts(api, viewportName, updateGlobals, Object.keys(options)); - }, [options, viewportName, updateGlobals, api]); - - if (item.styles === null || !options || length < 1) { - return null; - } - - if (typeof item.styles === 'function') { - console.warn( - 'Addon Viewport no longer supports dynamic styles using a function, use css calc() instead' - ); - return null; - } - - const width = isRotated ? item.styles.height : item.styles.width; - const height = isRotated ? item.styles.width : item.styles.height; - - if (disable) { - return null; - } - - return ( - - ); -}; - -// These ensure that we both present a logical DOM order based on whether -// or not viewport dimensions are locked, and display them with the '/' or -// rotate button in the middle. -const FirstDimension = styled(ActiveViewportLabel)({ - order: 1, -}); -const DimensionSeparator = styled.div({ - order: 2, -}); -const LastDimension = styled(ActiveViewportLabel)({ - order: 3, -}); - -const Pure = React.memo(function PureTool(props: PureProps) { - const { item, viewportMap, viewportName, isRotated, updateGlobals, isLocked, width, height } = - props; - - const update = useCallback( - (input: GlobalStateUpdate | undefined) => updateGlobals({ [PARAM_KEY]: input }), - [updateGlobals] - ); +export const ViewportTool = () => { + const { name, value, isDefault, isLocked, options: viewportMap, reset, select } = useViewport(); const options = useMemo( () => @@ -105,66 +28,32 @@ const Pure = React.memo(function PureTool(props: PureProps) { value: k, title: value.name, icon: iconsMap[value.type!], + right: ( + + {value.styles.width.replace('px', '')} + × + {value.styles.height.replace('px', '')} + + ), })), [viewportMap] ); return ( - - update({ value: undefined, isRotated: false })} - key="viewport" - disabled={isLocked} - ariaLabel={isLocked ? 'Viewport size set by story parameters' : 'Viewport size'} - ariaDescription="Select a viewport size among predefined options for the preview area, or reset to the default size." - tooltip={isLocked ? 'Viewport size set by story parameters' : 'Resize viewport'} - defaultOptions={viewportName} - options={options} - onSelect={(selected) => update({ value: selected as string, isRotated: false })} - icon={} - > - {item !== responsiveViewport ? ( - <> - {item.name} {isRotated ? `(L)` : `(P)`} - > - ) : null} - - - - - {item !== responsiveViewport ? ( - - - Viewport width: - {width.replace('px', '')} - - {isLocked && /} - - Viewport height: - {height.replace('px', '')} - - {!isLocked && ( - - { - update({ value: viewportName, isRotated: !isRotated }); - }} - > - - - - )} - - ) : null} - + select(selected as string)} + icon={} + > + {isDefault ? null : name} + ); -}); +}; diff --git a/code/core/src/viewport/manager.tsx b/code/core/src/viewport/manager.tsx index 534ddc2cbf00..31eba80ae5d3 100644 --- a/code/core/src/viewport/manager.tsx +++ b/code/core/src/viewport/manager.tsx @@ -5,13 +5,13 @@ import { addons, types } from 'storybook/manager-api'; import { ViewportTool } from './components/Tool'; import { ADDON_ID, TOOL_ID } from './constants'; -export default addons.register(ADDON_ID, (api) => { +export default addons.register(ADDON_ID, () => { if (globalThis?.FEATURES?.viewport) { addons.add(TOOL_ID, { title: 'viewport / media-queries', type: types.TOOL, match: ({ viewMode, tabId }) => viewMode === 'story' && !tabId, - render: () => , + render: () => , }); } }); diff --git a/code/core/src/viewport/shortcuts.ts b/code/core/src/viewport/shortcuts.ts deleted file mode 100644 index 47fb45c96f8d..000000000000 --- a/code/core/src/viewport/shortcuts.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { type API } from 'storybook/manager-api'; - -import { ADDON_ID } from './constants'; - -const getCurrentViewportIndex = (viewportsKeys: string[], current: string): number => - viewportsKeys.indexOf(current); - -const getNextViewport = (viewportsKeys: string[], current: string): string => { - const currentViewportIndex = getCurrentViewportIndex(viewportsKeys, current); - return currentViewportIndex === viewportsKeys.length - 1 - ? viewportsKeys[0] - : viewportsKeys[currentViewportIndex + 1]; -}; - -const getPreviousViewport = (viewportsKeys: string[], current: string): string => { - const currentViewportIndex = getCurrentViewportIndex(viewportsKeys, current); - return currentViewportIndex < 1 - ? viewportsKeys[viewportsKeys.length - 1] - : viewportsKeys[currentViewportIndex - 1]; -}; - -export const registerShortcuts = async ( - api: API, - viewport: any, - updateGlobals: any, - viewportsKeys: string[] -) => { - await api.setAddonShortcut(ADDON_ID, { - label: 'Previous viewport', - defaultShortcut: ['alt', 'shift', 'V'], - actionName: 'previous', - action: () => { - updateGlobals({ - viewport: getPreviousViewport(viewportsKeys, viewport), - }); - }, - }); - - await api.setAddonShortcut(ADDON_ID, { - label: 'Next viewport', - defaultShortcut: ['alt', 'V'], - actionName: 'next', - action: () => { - updateGlobals({ - viewport: getNextViewport(viewportsKeys, viewport), - }); - }, - }); - - await api.setAddonShortcut(ADDON_ID, { - label: 'Reset viewport', - defaultShortcut: ['alt', 'control', 'V'], - actionName: 'reset', - action: () => { - updateGlobals({ - viewport: { value: undefined, isRotated: false }, - }); - }, - }); -}; diff --git a/code/core/src/viewport/types.ts b/code/core/src/viewport/types.ts index d53de5771d0c..4bca0ffd885b 100644 --- a/code/core/src/viewport/types.ts +++ b/code/core/src/viewport/types.ts @@ -1,9 +1,11 @@ export interface Viewport { name: string; styles: ViewportStyles; - type?: 'desktop' | 'mobile' | 'tablet' | 'other'; + type?: ViewportType; } +export type ViewportType = 'desktop' | 'mobile' | 'tablet' | 'watch' | 'other'; + export interface ViewportStyles { height: string; width: string; @@ -14,7 +16,8 @@ export type ViewportMap = Record; export type GlobalState = { /** * When set, the viewport is applied and cannot be changed using the toolbar. Must match the key - * of one of the available viewports. + * of one of the available viewports or follow the format '{width}-{height}', e.g. '320-480' which + * may include a unit (e.g. '100vw' or '100pct'). */ value: string | undefined; diff --git a/code/core/src/viewport/useViewport.ts b/code/core/src/viewport/useViewport.ts new file mode 100644 index 000000000000..36612789f989 --- /dev/null +++ b/code/core/src/viewport/useViewport.ts @@ -0,0 +1,261 @@ +import { useCallback, useEffect, useMemo, useRef } from 'react'; + +import type { Globals } from 'storybook/internal/csf'; + +import { useGlobals, useParameter, useStorybookApi } from 'storybook/manager-api'; + +import { ADDON_ID, PARAM_KEY } from './constants'; +import { MINIMAL_VIEWPORTS } from './defaults'; +import type { + GlobalState, + GlobalStateUpdate, + ViewportMap, + ViewportParameters, + ViewportType, +} from './types'; + +// Custom viewport format, e.g. '100pct-200px' (width-height) +const URL_VALUE_PATTERN = /^([0-9]{1,4})([a-z]{0,4})-([0-9]{1,4})([a-z]{0,4})$/; + +export const VIEWPORT_MIN_WIDTH = 40; +export const VIEWPORT_MIN_HEIGHT = 40; + +const cycle = ( + viewports: ViewportMap, + current: string | undefined, + direction: 1 | -1 = 1 +): string => { + const keys = Object.keys(viewports); + const currentIndex = current ? keys.indexOf(current) : -1; + const nextIndex = currentIndex + direction; + return nextIndex < 0 + ? keys[keys.length - 1] + : nextIndex >= keys.length + ? keys[0] + : keys[nextIndex]; +}; + +const normalizeGlobal = (value: string | GlobalState, defaultIsRotated?: boolean): GlobalState => + typeof value === 'string' + ? { value, isRotated: defaultIsRotated } + : { value: value?.value, isRotated: value?.isRotated ?? defaultIsRotated }; + +const parseGlobals = ( + globals: Globals, + storyGlobals: Globals, + userGlobals: Globals, + options: ViewportMap, + lastSelectedOption: string | undefined, + disable: boolean, + viewMode: string | undefined +): { + name: string; + type: ViewportType; + width: string; + height: string; + value: string; + option: string | undefined; + isCustom: boolean; + isDefault: boolean; + isLocked: boolean; + isRotated: boolean; +} => { + if (viewMode !== 'story') { + return { + name: 'Responsive', + type: 'desktop', + width: '100%', + height: '100%', + value: '100pct-100pct', + option: undefined, + isCustom: false, + isDefault: true, + isLocked: true, + isRotated: false, + }; + } + + // Ensure URL-defined viewports (user globals) override story globals. + // Spreading is not sufficient here, because undefined would still override defined values. + const global = normalizeGlobal(globals?.[PARAM_KEY]); + const userGlobal = normalizeGlobal(userGlobals?.[PARAM_KEY]); + const storyGlobal = normalizeGlobal(storyGlobals?.[PARAM_KEY]); + const value = userGlobal?.value ?? storyGlobal?.value ?? global?.value; + const isRotated = userGlobal?.isRotated ?? storyGlobal?.isRotated ?? global?.isRotated ?? false; + + const keys = Object.keys(options); + const isLocked = disable || PARAM_KEY in storyGlobals || !keys.length; + const [match, vx, ux, vy, uy] = value?.match(URL_VALUE_PATTERN) || []; + + if (match) { + // Clamp pixel values to at least MIN_WIDTH / MIN_HEIGHT + const x = ux && ux !== 'px' ? vx : Math.max(Number(vx), VIEWPORT_MIN_WIDTH); + const y = uy && uy !== 'px' ? vy : Math.max(Number(vy), VIEWPORT_MIN_HEIGHT); + + // Ensure we have a valid CSS value, including unit + const width = `${x}${ux === 'pct' ? '%' : ux || 'px'}`; + const height = `${y}${uy === 'pct' ? '%' : uy || 'px'}`; + + const selection = lastSelectedOption ? options[lastSelectedOption] : undefined; + return { + name: selection?.name ?? 'Custom', + type: selection?.type ?? 'other', + width: isRotated ? height : width, + height: isRotated ? width : height, + value: match, + option: undefined, + isCustom: true, + isDefault: false, + isLocked, + isRotated, + }; + } + + if (value && keys.length) { + const { name, styles, type = 'other' } = options[value] ?? options[keys[0]]; + return { + name, + type, + width: isRotated ? styles.height : styles.width, + height: isRotated ? styles.width : styles.height, + value, + option: value, + isCustom: false, + isDefault: false, + isLocked, + isRotated, + }; + } + + return { + name: 'Responsive', + type: 'desktop', + width: '100%', + height: '100%', + value: '100pct-100pct', + option: undefined, + isCustom: false, + isDefault: true, + isLocked, + isRotated: false, + }; +}; + +export const useViewport = () => { + const api = useStorybookApi(); + const { viewMode } = api.getUrlState(); + + const lastSelectedOption = useRef(); + + const parameter = useParameter(PARAM_KEY); + const [globals, updateGlobals, storyGlobals, userGlobals] = useGlobals(); + + const { options = MINIMAL_VIEWPORTS, disable = false } = parameter || {}; + const { name, type, width, height, value, option, isCustom, isDefault, isLocked, isRotated } = + parseGlobals( + globals, + storyGlobals, + userGlobals, + options, + lastSelectedOption.current, + disable, + viewMode + ); + + const update = useCallback( + (input: GlobalStateUpdate) => updateGlobals({ [PARAM_KEY]: input }), + [updateGlobals] + ); + + const resize = useCallback( + (width: string, height: string) => { + const w = width.replace(/px$/, '').replace(/%$/, 'pct'); + const h = height.replace(/px$/, '').replace(/%$/, 'pct'); + const value = isRotated ? `${h}-${w}` : `${w}-${h}`; + const [match, vx, ux, vy, uy] = value.match(URL_VALUE_PATTERN) || []; + + // Don't update to pixel values less than 40 + if (match && (ux || Number(vx) >= 40) && (uy || Number(vy) >= 40)) { + update({ value: match, isRotated }); + } + }, + [update, isRotated] + ); + + useEffect(() => { + // Reset the viewport to the story global value if the story defines one, regardless of URL state + if (PARAM_KEY in storyGlobals) { + update(normalizeGlobal(storyGlobals?.[PARAM_KEY], false)); + lastSelectedOption.current = undefined; + } + }, [storyGlobals, update]); + + useEffect(() => { + // Reset the viewport to the story global value if the URL state defines an invalid option + if (option) { + if (Object.hasOwn(options, option)) { + lastSelectedOption.current = option; + } else { + lastSelectedOption.current = undefined; + update(normalizeGlobal(storyGlobals?.[PARAM_KEY], false)); + } + } + }, [storyGlobals, options, option, update]); + + useEffect(() => { + api.setAddonShortcut(ADDON_ID, { + label: 'Next viewport', + defaultShortcut: ['alt', 'V'], + actionName: 'next', + action: () => update({ value: cycle(options, lastSelectedOption.current), isRotated }), + }); + api.setAddonShortcut(ADDON_ID, { + label: 'Previous viewport', + defaultShortcut: ['alt', 'shift', 'V'], + actionName: 'previous', + action: () => update({ value: cycle(options, lastSelectedOption.current, -1), isRotated }), + }); + api.setAddonShortcut(ADDON_ID, { + label: 'Reset viewport', + defaultShortcut: ['alt', 'control', 'V'], + actionName: 'reset', + action: () => update({ value: undefined, isRotated: false }), + }); + }, [api, update, options, isRotated]); + + return useMemo( + () => ({ + name, + type, + width, + height, + value, + option, + isCustom, + isDefault, + isLocked, + isRotated, + options, + lastSelectedOption: lastSelectedOption.current, + resize, + reset: () => update({ value: undefined, isRotated: false }), + rotate: () => update({ value, isRotated: !isRotated }), + select: (value: string) => update({ value, isRotated }), + }), + [ + name, + type, + width, + height, + value, + option, + isCustom, + isDefault, + isRotated, + isLocked, + options, + resize, + update, + ] + ); +}; diff --git a/code/core/src/viewport/utils.tsx b/code/core/src/viewport/utils.tsx deleted file mode 100644 index 934727dae7b0..000000000000 --- a/code/core/src/viewport/utils.tsx +++ /dev/null @@ -1,33 +0,0 @@ -import React, { Fragment } from 'react'; - -import { BrowserIcon, MobileIcon, TabletIcon } from '@storybook/icons'; - -import { styled } from 'storybook/theming'; - -import type { Viewport } from './types'; - -export const ActiveViewportSize = styled.div({ - display: 'inline-flex', - alignItems: 'center', -}); - -export const ActiveViewportLabel = styled.div(({ theme }) => ({ - display: 'inline-block', - textDecoration: 'none', - padding: 10, - fontWeight: theme.typography.weight.bold, - fontSize: theme.typography.size.s2 - 1, - lineHeight: '1', - height: 40, - border: 'none', - borderTop: '3px solid transparent', - borderBottom: '3px solid transparent', - background: 'transparent', -})); - -export const iconsMap: Record, React.ReactNode> = { - desktop: , - mobile: , - tablet: , - other: , -}; diff --git a/code/core/src/viewport/viewportIcons.tsx b/code/core/src/viewport/viewportIcons.tsx new file mode 100644 index 000000000000..6e058b90c859 --- /dev/null +++ b/code/core/src/viewport/viewportIcons.tsx @@ -0,0 +1,13 @@ +import React from 'react'; + +import { BrowserIcon, DiamondIcon, MobileIcon, TabletIcon, WatchIcon } from '@storybook/icons'; + +import type { Viewport } from './types'; + +export const iconsMap: Record, React.ReactNode> = { + desktop: , + mobile: , + tablet: , + watch: , + other: , +};
Title
Some very long text which is going to wrap around
{title}