IMPROVE the addon-background & background-viewport to use new APIs

This commit is contained in:
Norbert de Langen 2019-07-12 17:50:30 +02:00
parent 42b25ee076
commit 85bed265d7
12 changed files with 309 additions and 321 deletions

View File

@ -1,5 +1,5 @@
import { document } from 'global';
import React, { Component } from 'react';
import React, { Component, ReactNode } from 'react';
import memoize from 'memoizerific';
import { styled } from '@storybook/theming';
@ -38,86 +38,90 @@ const ColorIcon = styled.span(
interface ColorBlindnessProps {}
interface ColorBlindnessState {
expanded: boolean;
filter: string | null;
active: string | null;
}
const baseList = [
'protanopia',
'protanomaly',
'deuteranopia',
'deuteranomaly',
'tritanopia',
'tritanomaly',
'achromatopsia',
'achromatomaly',
'mono',
];
export interface Link {
id: string;
title: ReactNode;
right?: ReactNode;
active: boolean;
onClick: () => void;
}
const getColorList = (active: string | null, set: (i: string | null) => void): Link[] => [
...(active !== null
? [
{
id: 'reset',
title: 'Reset color filter',
onClick: () => {
set(null);
},
right: undefined,
active: false,
},
]
: []),
...baseList.map(i => ({
id: i,
title: i.charAt(0).toUpperCase() + i.slice(1),
onClick: () => {
set(i);
},
right: <ColorIcon filter={i} />,
active: active === i,
})),
];
export class ColorBlindness extends Component<ColorBlindnessProps, ColorBlindnessState> {
state: ColorBlindnessState = {
expanded: false,
filter: null,
active: null,
};
setFilter = (filter: string | null) => {
setActive = (active: string | null) => {
const iframe = getIframe();
if (iframe) {
iframe.style.filter = getFilter(filter);
iframe.style.filter = getFilter(active);
this.setState({
expanded: false,
filter,
active,
});
} else {
logger.error('Cannot find Storybook iframe');
}
};
onVisibilityChange = (s: boolean) => {
const { expanded } = this.state;
if (expanded !== s) {
this.setState({ expanded: s });
}
};
render() {
const { filter, expanded } = this.state;
let colorList = [
'protanopia',
'protanomaly',
'deuteranopia',
'deuteranomaly',
'tritanopia',
'tritanomaly',
'achromatopsia',
'achromatomaly',
'mono',
].map(i => ({
id: i,
title: i.charAt(0).toUpperCase() + i.slice(1),
onClick: () => {
this.setFilter(i);
},
right: <ColorIcon filter={i} />,
active: filter === i,
}));
if (filter !== null) {
colorList = [
{
id: 'reset',
title: 'Reset color filter',
onClick: () => {
this.setFilter(null);
},
right: undefined,
active: false,
},
...colorList,
];
}
const { active } = this.state;
return (
<WithTooltip
placement="top"
trigger="click"
tooltipShown={expanded}
onVisibilityChange={this.onVisibilityChange}
tooltip={<TooltipLinkList links={colorList} />}
tooltip={({ onHide }) => {
const colorList = getColorList(active, i => {
this.setActive(i);
onHide();
});
return <TooltipLinkList links={colorList} />;
}}
closeOnClick
onDoubleClick={() => this.setFilter(null)}
onDoubleClick={() => this.setActive(null)}
>
<IconButton key="filter" active={!!filter} title="Color Blindness Emulation">
<IconButton key="filter" active={!!active} title="Color Blindness Emulation">
<Icons icon="mirror" />
</IconButton>
</WithTooltip>

View File

@ -1,4 +1,4 @@
import React, { Component, Fragment } from 'react';
import React, { Component, Fragment, ReactElement } from 'react';
import memoize from 'memoizerific';
import { Combo, Consumer, API } from '@storybook/api';
@ -14,7 +14,7 @@ interface Item {
title: string;
onClick: () => void;
value: string;
right?: any;
right?: ReactElement;
}
interface Input {
@ -23,7 +23,7 @@ interface Input {
default?: boolean;
}
const iframeId = 'storybook-preview-background';
const iframeId = 'storybook-preview-iframe';
const createBackgroundSelectorItem = memoize(1000)(
(
@ -71,72 +71,56 @@ const mapper = ({ api, state }: Combo): { items: Input[]; selected: string | nul
return { items: list || [], selected };
};
const getDisplayedItems = memoize(10)((list: Input[], selected: string | null, change) => {
let availableBackgroundSelectorItems: Item[] = [];
const getDisplayedItems = memoize(10)(
(
list: Input[],
selected: string | null,
change: (arg: { selected: string; name: string }) => void
) => {
let availableBackgroundSelectorItems: Item[] = [];
if (selected !== 'transparent') {
availableBackgroundSelectorItems.push(
createBackgroundSelectorItem('reset', 'Clear background', 'transparent', null, change)
);
if (selected !== 'transparent') {
availableBackgroundSelectorItems.push(
createBackgroundSelectorItem('reset', 'Clear background', 'transparent', null, change)
);
}
if (list.length) {
availableBackgroundSelectorItems = [
...availableBackgroundSelectorItems,
...list.map(({ name, value }) =>
createBackgroundSelectorItem(null, name, value, true, change)
),
];
}
return availableBackgroundSelectorItems;
}
if (list.length) {
availableBackgroundSelectorItems = [
...availableBackgroundSelectorItems,
...list.map(({ name, value }) =>
createBackgroundSelectorItem(null, name, value, true, change)
),
];
}
return availableBackgroundSelectorItems;
});
);
interface GlobalState {
name: string | undefined;
selected: string | undefined;
}
interface State {
expanded: boolean;
}
interface Props {
api: API;
}
export class BackgroundSelector extends Component<Props, State> {
state: State = {
expanded: false,
};
export class BackgroundSelector extends Component<Props> {
change = ({ selected, name }: GlobalState) => {
const { api } = this.props;
const { expanded } = this.state;
if (expanded) {
this.setState({ expanded: false });
}
if (typeof selected === 'string') {
api.setAddonState<string>(PARAM_KEY, selected);
}
api.emit(EVENTS.UPDATE, { selected, name });
};
onVisibilityChange = (s: boolean) => {
const { expanded } = this.state;
if (expanded !== s) {
this.setState({ expanded: s });
}
};
render() {
const { expanded } = this.state;
return (
<Consumer filter={mapper}>
{({ items, selected }: ReturnType<typeof mapper>) => {
const selectedBackgroundColor = getSelectedBackgroundColor(items, selected);
const links = getDisplayedItems(items, selectedBackgroundColor, this.change);
return items.length ? (
<Fragment>
@ -155,9 +139,14 @@ export class BackgroundSelector extends Component<Props, State> {
<WithTooltip
placement="top"
trigger="click"
tooltipShown={expanded}
onVisibilityChange={this.onVisibilityChange}
tooltip={<TooltipLinkList links={links} />}
tooltip={({ onHide }) => (
<TooltipLinkList
links={getDisplayedItems(items, selectedBackgroundColor, i => {
this.change(i);
onHide();
})}
/>
)}
closeOnClick
>
<IconButton

View File

@ -1,119 +1,77 @@
import React, { Component, Fragment } from 'react';
import React, { Fragment, ReactNode, useEffect, useRef, FunctionComponent } from 'react';
import memoize from 'memoizerific';
import deprecate from 'util-deprecate';
import { styled, Global } from '@storybook/theming';
import { styled, Global, Theme, withTheme } from '@storybook/theming';
import { Icons, IconButton, WithTooltip, TooltipLinkList } from '@storybook/components';
import { SET_STORIES } from '@storybook/core-events';
import { PARAM_KEY } from './constants';
import { INITIAL_VIEWPORTS, DEFAULT_VIEWPORT } from './defaults';
import { ViewportAddonParameter, ViewportMap, ViewportStyles } from './models';
import { useParameter, useAddonState } from '@storybook/api';
import { PARAM_KEY, ADDON_ID } from './constants';
import { ViewportAddonParameter, ViewportMap, ViewportStyles, Viewport, Styles } from './models';
const toList = memoize(50)((items: ViewportMap) =>
items ? Object.entries(items).map(([id, value]) => ({ ...value, id })) : []
);
const iframeId = 'storybook-preview-iframe';
interface ViewportVM {
interface ViewportItem {
id: string;
title: string;
onClick: () => void;
right: string;
value: ViewportStyles;
styles: Styles;
type: 'desktop' | 'mobile' | 'tablet' | 'other';
default?: boolean;
}
const createItem = memoize(1000)(
(id: string, name: string, value: ViewportStyles, change: (...args: unknown[]) => void) => {
const result: ViewportVM = {
id: id || name,
title: name,
onClick: () => {
change({ selected: id, expanded: false });
},
right: `${value.width.replace('px', '')}x${value.height.replace('px', '')}`,
value,
};
return result;
}
);
const toList = memoize(50)((items: ViewportMap): ViewportItem[] => [
...baseViewports,
...Object.entries(items).map(([id, { name, ...rest }]) => ({ ...rest, id, title: name })),
]);
const responsiveViewport: ViewportItem = {
id: 'reset',
title: 'Reset viewport',
styles: null,
type: 'other',
};
const baseViewports: ViewportItem[] = [responsiveViewport];
const toLinks = memoize(50)((list: ViewportItem[], active: LinkBase, set, state, close): Link[] => {
return list
.map(i => {
switch (i.id) {
case responsiveViewport.id: {
if (active.id === i.id) {
return null;
}
}
// eslint-disable-next-line no-fallthrough
default: {
return {
...i,
onClick: () => {
set({ ...state, selected: i.id });
close();
},
};
}
}
})
.filter(Boolean);
});
const iframeId = 'storybook-preview-iframe';
const wrapperId = 'storybook-preview-wrapper';
interface LinkBase {
id: string;
title: string;
right?: ReactNode;
type: 'desktop' | 'mobile' | 'tablet' | 'other';
styles: ViewportStyles | ((s: ViewportStyles) => ViewportStyles) | null;
}
interface Link extends LinkBase {
onClick: () => void;
}
const flip = ({ width, height }: ViewportStyles) => ({ height: width, width: height });
const deprecatedViewportString = deprecate(
() => 0,
'The viewport parameter must be an object with keys `viewports` and `defaultViewport`'
);
const deprecateOnViewportChange = deprecate(
() => 0,
'The viewport parameter `onViewportChange` is no longer supported'
);
const getState = memoize(10)(
(
props: ViewportToolProps,
state: ViewportToolState,
change: (statePatch: Partial<ViewportToolState>) => void
) => {
const data = props.api.getCurrentStoryData();
const parameters: ViewportAddonParameter =
data && (data as any).parameters && (data as any).parameters[PARAM_KEY];
if (parameters && typeof parameters !== 'object') {
deprecatedViewportString();
}
const { disable, viewports, defaultViewport, onViewportChange } = parameters || ({} as any);
if (onViewportChange) {
deprecateOnViewportChange();
}
const list = disable ? [] : toList(viewports || INITIAL_VIEWPORTS);
const viewportVMList = list.map(({ id, name, styles: value }) =>
createItem(id, name, value, change)
);
const selected =
state.selected === 'responsive' || list.find(i => i.id === state.selected)
? state.selected
: list.find(i => i.default) || defaultViewport || DEFAULT_VIEWPORT;
const resets: ViewportVM[] =
selected !== 'responsive'
? [
{
id: 'reset',
title: 'Reset viewport',
onClick: () => {
change({ selected: undefined, expanded: false });
},
right: undefined,
value: undefined,
},
{
id: 'rotate',
title: 'Rotate viewport',
onClick: () => {
change({ isRotated: !state.isRotated, expanded: false });
},
right: undefined,
value: undefined,
},
]
: [];
const items = viewportVMList.length !== 0 ? resets.concat(viewportVMList) : [];
return {
isRotated: state.isRotated,
items,
selected,
};
}
);
const ActiveViewportSize = styled.div(() => ({
display: 'inline-flex',
}));
@ -144,125 +102,132 @@ const IconButtonLabel = styled.div<{}>(({ theme }) => ({
interface ViewportToolState {
isRotated: boolean;
items: any[];
selected: string;
expanded: boolean;
}
interface ViewportToolProps {
api: any;
selected: string | null;
}
export class ViewportTool extends Component<ViewportToolProps, ViewportToolState> {
listener: () => void;
const getStyles = (
prevStyles: ViewportStyles,
styles: Styles,
isRotated: boolean
): ViewportStyles => {
if (styles === null) {
return null;
}
const result = typeof styles === 'function' ? styles(prevStyles) : styles;
return isRotated ? flip(result) : result;
};
constructor(props: ViewportToolProps) {
super(props);
this.state = {
export const ViewportTool: FunctionComponent<{}> = React.memo(
withTheme(({ theme }: { theme: Theme }) => {
const { viewports, defaultViewport, disable } = useParameter<ViewportAddonParameter>(
PARAM_KEY,
{
viewports: {},
defaultViewport: responsiveViewport.id,
}
);
const [state, setState] = useAddonState<ViewportToolState>(ADDON_ID, {
selected: defaultViewport || responsiveViewport.id,
isRotated: false,
items: [],
selected: 'responsive',
expanded: false,
};
});
const list = toList(viewports);
this.listener = () => {
this.setState({
selected: null,
});
};
}
const { selected, isRotated } = state;
const item =
list.find(i => i.id === selected) ||
list.find(i => i.id === defaultViewport) ||
list.find(i => i.default) ||
responsiveViewport;
componentDidMount() {
const { api } = this.props;
api.on(SET_STORIES, this.listener);
}
const ref = useRef<ViewportStyles>();
componentWillUnmount() {
const { api } = this.props;
api.off(SET_STORIES, this.listener);
}
const styles = item
? getStyles(ref.current, item.styles, isRotated)
: (responsiveViewport.styles as ViewportStyles);
// @ts-ignore
change = (...args: any[]) => this.setState(...args);
useEffect(() => {
ref.current = styles;
}, [item]);
flipViewport = () =>
this.setState(({ isRotated }: { isRotated: boolean }) => ({
isRotated: !isRotated,
expanded: false,
}));
resetViewport = (e: React.MouseEvent<HTMLButtonElement, MouseEvent>) => {
e.stopPropagation();
e.nativeEvent.stopImmediatePropagation();
this.setState({ selected: undefined, expanded: false });
};
render() {
const { expanded } = this.state;
const { items, selected, isRotated } = getState(this.props, this.state, this.change);
const item = items.find(i => i.id === selected);
let viewportX = '0';
let viewportY = '0';
let viewportTitle = '';
if (item) {
const height = item.value.height.replace('px', '');
const width = item.value.width.replace('px', '');
viewportX = isRotated ? height : width;
viewportY = isRotated ? width : height;
viewportTitle = isRotated ? `${item.title} (L)` : `${item.title} (P)`;
if (styles === null) {
// debugger;
}
return items.length ? (
<Fragment>
{item ? (
<Global
styles={{
[`#${iframeId}`]: {
position: 'relative',
display: 'block',
margin: '10px auto',
border: '1px solid #888',
borderRadius: 4,
boxShadow: '0 4px 8px 0 rgba(0,0,0,0.12), 0 2px 4px 0 rgba(0,0,0,0.08);',
boxSizing: 'content-box',
if (disable || Object.entries(viewports).length === 0) {
return null;
}
...(isRotated ? flip(item.value) : item.value),
},
}}
/>
) : null}
return (
<Fragment>
<WithTooltip
placement="top"
trigger="click"
tooltipShown={expanded}
onVisibilityChange={s => this.setState({ expanded: s })}
tooltip={<TooltipLinkList links={items} />}
tooltip={({ onHide }) => (
<TooltipLinkList links={toLinks(list, item, setState, state, onHide)} />
)}
closeOnClick
>
<IconButtonWithLabel
key="viewport"
title="Change the size of the preview"
active={!!item}
onDoubleClick={e => this.resetViewport(e)}
active={!!styles}
onDoubleClick={() => {
setState({ ...state, selected: responsiveViewport.id });
}}
>
<Icons icon="grow" />
<IconButtonLabel>{viewportTitle}</IconButtonLabel>
{styles ? (
<IconButtonLabel>
{isRotated ? `${item.title} (L)` : `${item.title} (P)`}
</IconButtonLabel>
) : null}
</IconButtonWithLabel>
</WithTooltip>
{item ? (
{styles ? (
<ActiveViewportSize>
<ActiveViewportLabel title="Viewport width">{viewportX}</ActiveViewportLabel>
<IconButton key="viewport-rotate" title="Rotate viewport" onClick={this.flipViewport}>
<Global
styles={{
[`#${iframeId}`]: {
margin: `auto`,
transition: 'width .3s, height .3s',
position: 'relative',
border: `${theme.layoutMargin}px solid black`,
borderRadius: theme.appBorderRadius,
boxShadow:
'0 0 100px 1000px rgba(0,0,0,0.5), 0 4px 8px 0 rgba(0,0,0,0.12), 0 2px 4px 0 rgba(0,0,0,0.08)',
...styles,
},
[`#${wrapperId}`]: {
padding: theme.layoutMargin,
display: 'flex',
alignContent: 'center',
alignItems: 'center',
justifyContent: 'center',
justifyItems: 'center',
overflow: 'auto',
},
}}
/>
<ActiveViewportLabel title="Viewport width">
{styles.width.replace('px', '')}
</ActiveViewportLabel>
<IconButton
key="viewport-rotate"
title="Rotate viewport"
onClick={() => {
setState({ ...state, isRotated: !isRotated });
}}
>
<Icons icon="transfer" />
</IconButton>
<ActiveViewportLabel title="Viewport height">{viewportY}</ActiveViewportLabel>
<ActiveViewportLabel title="Viewport height">
{styles.height.replace('px', '')}
</ActiveViewportLabel>
</ActiveViewportSize>
) : null}
</Fragment>
) : null;
}
}
);
})
);

View File

@ -1,7 +1,9 @@
export type Styles = ViewportStyles | ((s: ViewportStyles) => ViewportStyles) | null;
export interface Viewport {
name: string;
styles: ViewportStyles;
type: 'desktop' | 'mobile' | 'tablet';
styles: Styles;
type: 'desktop' | 'mobile' | 'tablet' | 'other';
/*
* @deprecated
* Deprecated option?

View File

@ -1,8 +1,8 @@
import { ViewportMap } from './Viewport';
export interface ViewportAddonParameter {
disable: boolean;
defaultViewport: string;
disable?: boolean;
defaultViewport?: string;
viewports: ViewportMap;
/*
* @deprecated

View File

@ -5,11 +5,11 @@ import { ADDON_ID } from './constants';
import { ViewportTool } from './Tool';
addons.register(ADDON_ID, api => {
addons.register(ADDON_ID, () => {
addons.add(ADDON_ID, {
title: 'viewport / media-queries',
type: types.TOOL,
match: ({ viewMode }) => viewMode === 'story',
render: () => <ViewportTool api={api} />,
render: () => <ViewportTool />,
});
});

View File

@ -1,4 +1,4 @@
import React, { ReactElement, Component, useContext, useEffect } from 'react';
import React, { ReactElement, Component, useContext, useEffect, useRef } from 'react';
import memoize from 'memoizerific';
// @ts-ignore shallow-equal is not in DefinitelyTyped
import shallowEqualObjects from 'shallow-equal/objects';
@ -314,6 +314,7 @@ type StateMerger<S> = (input: S) => S;
export function useAddonState<S>(addonId: string, defaultState?: S) {
const api = useStorybookApi();
const ref = useRef<{ [k: string]: boolean }>({});
const existingState = api.getAddonState<S>(addonId);
const state = orDefault<S>(existingState, defaultState);
@ -322,8 +323,12 @@ export function useAddonState<S>(addonId: string, defaultState?: S) {
return api.setAddonState<S>(addonId, newStateOrMerger, options);
};
if (typeof existingState === 'undefined') {
api.setAddonState<S>(addonId, state);
if (typeof existingState === 'undefined' && typeof state !== 'undefined') {
if (!ref.current[addonId]) {
// debugger;
api.setAddonState<S>(addonId, state);
ref.current[addonId] = true;
}
}
return [state, setState] as [

View File

@ -115,6 +115,7 @@ export default ({ provider, store }: Module) => {
options?: Options
): Promise<S> {
let nextState;
const { addons: existing } = store.getState();
if (typeof newStateOrMerger === 'function') {
const merger = newStateOrMerger as StateMerger<S>;
nextState = merger(api.getAddonState<S>(addonId));
@ -122,10 +123,11 @@ export default ({ provider, store }: Module) => {
nextState = newStateOrMerger;
}
return store
.setState({ addons: { [addonId]: nextState } }, options)
.setState({ addons: { ...existing, [addonId]: nextState } }, options)
.then(() => api.getAddonState(addonId));
},
getAddonState: addonId => {
// debugger;
return store.getState().addons[addonId];
},
};

View File

@ -41,7 +41,7 @@ export const links = [
storiesOf('basics/Tooltip/TooltipLinkList', module)
.addDecorator(storyFn => (
<div style={{ height: '300px' }}>
<WithTooltip placement="top" trigger="click" tooltipShown tooltip={storyFn()}>
<WithTooltip placement="top" trigger="click" startOpen tooltip={storyFn()}>
<div>Tooltip</div>
</WithTooltip>
</div>

View File

@ -18,6 +18,10 @@ const TargetSvgContainer = styled.g<{ mode: string }>`
cursor: ${props => (props.mode === 'hover' ? 'default' : 'pointer')};
`;
interface WithHideFn {
onHide: () => void;
}
export interface WithTooltipPureProps {
svg?: boolean;
trigger?: 'none' | 'hover' | 'click' | 'right-click';
@ -25,7 +29,7 @@ export interface WithTooltipPureProps {
placement?: Placement;
modifiers?: Modifiers;
hasChrome?: boolean;
tooltip: ReactNode;
tooltip: ReactNode | ((p: WithHideFn) => ReactNode);
children: ReactNode;
tooltipShown?: boolean;
onVisibilityChange?: (visibility: boolean) => void;

View File

@ -2,6 +2,18 @@ import window from 'global';
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import { styled } from '@storybook/theming';
const StyledIframe = styled.iframe({
position: 'absolute',
display: 'block',
boxSizing: 'content-box',
height: '100%',
width: '100%',
border: '0 none',
transition: 'background .3s',
});
export class IFrame extends Component {
iframe = null;
@ -34,7 +46,7 @@ export class IFrame extends Component {
render() {
const { id, title, src, allowFullScreen, scale, ...rest } = this.props;
return (
<iframe
<StyledIframe
scrolling="yes"
id={id}
title={title}

View File

@ -64,18 +64,23 @@ const ActualPreview = ({
);
};
const IframeWrapper = styled.div(({ theme }) => ({
position: 'absolute',
top: 0,
left: 0,
bottom: 0,
right: 0,
width: '100%',
height: '100%',
background: theme.background.content,
}));
const defaultWrappers = [
{ render: p => <div hidden={!p.active}>{p.children}</div> },
{
render: p => (
<BackgroundConsumer>
{({ value, grid }) => (
<Background id="storybook-preview-background" value={value}>
{grid ? <Grid /> : null}
{p.children}
</Background>
)}
</BackgroundConsumer>
<IframeWrapper id="storybook-preview-wrapper" hidden={!p.active}>
{p.children}
</IframeWrapper>
),
},
];