lib/ui/src/settings/shortcuts.tsx
import React, { Component } from 'react';
import { styled, keyframes } from '@storybook/theming';
import {
eventToShortcut,
shortcutToHumanString,
shortcutMatchesShortcut,
} from '@storybook/api/shortcut';
import { Form, Icons } from '@storybook/components';
import SettingsFooter from './SettingsFooter';
const { Button, Input } = Form;
const Header = styled.header(({ theme }) => ({
marginBottom: 20,
fontSize: theme.typography.size.m3,
fontWeight: theme.typography.weight.black,
alignItems: 'center',
display: 'flex',
}));
// Grid
export const HeaderItem = styled.div(({ theme }) => ({
fontWeight: theme.typography.weight.bold,
}));
export const GridHeaderRow = styled.div({
alignSelf: 'flex-end',
display: 'grid',
margin: '10px 0',
gridTemplateColumns: '1fr 1fr 12px',
'& > *:last-of-type': {
gridColumn: '2 / 2',
justifySelf: 'flex-end',
gridRow: '1',
},
});
export const Row = styled.div(({ theme }) => ({
padding: '6px 0',
borderTop: `1px solid ${theme.appBorderColor}`,
display: 'grid',
gridTemplateColumns: '1fr 1fr 0px',
}));
export const GridWrapper = styled.div({
display: 'grid',
gridTemplateColumns: '1fr',
gridAutoRows: 'minmax(auto, auto)',
marginBottom: 20,
});
// Form
export const Description = styled.div({
alignSelf: 'center',
});
export type ValidationStates = 'valid' | 'error' | 'warn';
export const TextInput = styled(Input)<{ valid: ValidationStates }>(
({ valid, theme }) =>
valid === 'error'
? {
animation: `${theme.animation.jiggle} 700ms ease-out`,
}
: {},
{
display: 'flex',
width: 80,
flexDirection: 'column',
justifySelf: 'flex-end',
paddingLeft: 4,
paddingRight: 4,
textAlign: 'center',
}
);
export const Fade = keyframes`
0%,100% { opacity: 0; }
50% { opacity: 1; }
`;
export const SuccessIcon = styled(Icons)<{ valid: string }>(
({ valid, theme }) =>
valid === 'valid'
? {
color: theme.color.positive,
animation: `${Fade} 2s ease forwards`,
}
: {
opacity: 0,
},
{
alignSelf: 'center',
display: 'flex',
marginLeft: 10,
height: 14,
width: 14,
}
);
const Container = styled.div(({ theme }) => ({
fontSize: theme.typography.size.s2,
padding: `3rem 20px`,
maxWidth: 600,
margin: '0 auto',
}));
const shortcutLabels = {
fullScreen: 'Go full screen',
togglePanel: 'Toggle addons',
panelPosition: 'Toggle addons orientation',
toggleNav: 'Toggle sidebar',
toolbar: 'Toggle canvas toolbar',
search: 'Focus search',
focusNav: 'Focus sidebar',
focusIframe: 'Focus canvas',
focusPanel: 'Focus addons',
prevComponent: 'Previous component',
nextComponent: 'Next component',
prevStory: 'Previous story',
nextStory: 'Next story',
shortcutsPage: 'Go to shortcuts page',
aboutPage: 'Go to about page',
collapseAll: 'Collapse all items on sidebar',
expandAll: 'Expand all items on sidebar',
};
export type Feature = keyof typeof shortcutLabels;
// Shortcuts that cannot be configured
const fixedShortcuts = ['escape'];
function toShortcutState(shortcutKeys: ShortcutsScreenProps['shortcutKeys']) {
return Object.entries(shortcutKeys).reduce(
(acc, [feature, shortcut]: [Feature, string]) =>
fixedShortcuts.includes(feature) ? acc : { ...acc, [feature]: { shortcut, error: false } },
{} as Record<Feature, any>
);
}
export interface ShortcutsScreenState {
activeFeature: Feature;
successField: Feature;
shortcutKeys: Record<Feature, any>;
addonsShortcutLabels?: Record<string, string>;
}
export interface ShortcutsScreenProps {
shortcutKeys: Record<Feature, any>;
addonsShortcutLabels?: Record<string, string>;
setShortcut: Function;
restoreDefaultShortcut: Function;
restoreAllDefaultShortcuts: Function;
}
class ShortcutsScreen extends Component<ShortcutsScreenProps, ShortcutsScreenState> {
constructor(props: ShortcutsScreenProps) {
super(props);
this.state = {
activeFeature: undefined,
successField: undefined,
// The initial shortcutKeys that come from props are the defaults/what was saved
// As the user interacts with the page, the state stores the temporary, unsaved shortcuts
// This object also includes the error attached to each shortcut
shortcutKeys: toShortcutState(props.shortcutKeys),
addonsShortcutLabels: props.addonsShortcutLabels,
};
}
onKeyDown = (e: KeyboardEvent) => {
const { activeFeature, shortcutKeys } = this.state;
if (e.key === 'Backspace') {
return this.restoreDefault();
}
const shortcut = eventToShortcut(e);
// Keypress is not a potential shortcut
if (!shortcut) {
return false;
}
// Check we don't match any other shortcuts
const error = !!Object.entries(shortcutKeys).find(
([feature, { shortcut: existingShortcut }]) =>
feature !== activeFeature &&
existingShortcut &&
shortcutMatchesShortcut(shortcut, existingShortcut)
);
return this.setState({
shortcutKeys: { ...shortcutKeys, [activeFeature]: { shortcut, error } },
});
};
onFocus = (focusedInput: Feature) => () => {
const { shortcutKeys } = this.state;
this.setState({
activeFeature: focusedInput,
shortcutKeys: {
...shortcutKeys,
[focusedInput]: { shortcut: null, error: false },
},
});
};
onBlur = async () => {
const { shortcutKeys, activeFeature } = this.state;
if (shortcutKeys[activeFeature]) {
const { shortcut, error } = shortcutKeys[activeFeature];
if (!shortcut || error) {
return this.restoreDefault();
}
return this.saveShortcut();
}
return false;
};
saveShortcut = async () => {
const { activeFeature, shortcutKeys } = this.state;
const { setShortcut } = this.props;
await setShortcut(activeFeature, shortcutKeys[activeFeature].shortcut);
this.setState({ successField: activeFeature });
};
restoreDefaults = async () => {
const { restoreAllDefaultShortcuts } = this.props;
const defaultShortcuts = await restoreAllDefaultShortcuts();
return this.setState({ shortcutKeys: toShortcutState(defaultShortcuts) });
};
restoreDefault = async () => {
const { activeFeature, shortcutKeys } = this.state;
const { restoreDefaultShortcut } = this.props;
const defaultShortcut = await restoreDefaultShortcut(activeFeature);
return this.setState({
shortcutKeys: {
...shortcutKeys,
...toShortcutState({ [activeFeature]: defaultShortcut } as Record<Feature, any>),
},
});
};
displaySuccessMessage = (activeElement: Feature) => {
const { successField, shortcutKeys } = this.state;
return activeElement === successField && shortcutKeys[activeElement].error === false
? 'valid'
: undefined;
};
displayError = (activeElement: Feature): ValidationStates => {
const { activeFeature, shortcutKeys } = this.state;
return activeElement === activeFeature && shortcutKeys[activeElement].error === true
? 'error'
: undefined;
};
renderKeyInput = () => {
const { shortcutKeys, addonsShortcutLabels } = this.state;
const arr = Object.entries(shortcutKeys).map(([feature, { shortcut }]: [Feature, any]) => (
<Row key={feature}>
<Description>{shortcutLabels[feature] || addonsShortcutLabels[feature]}</Description>
<TextInput
spellCheck="false"
valid={this.displayError(feature)}
className="modalInput"
onBlur={this.onBlur}
onFocus={this.onFocus(feature)}
// @ts-ignore
onKeyDown={this.onKeyDown}
value={shortcut ? shortcutToHumanString(shortcut) : ''}
placeholder="Type keys"
readOnly
/>
<SuccessIcon valid={this.displaySuccessMessage(feature)} icon="check" />
</Row>
));
return arr;
};
renderKeyForm = () => (
<GridWrapper>
<GridHeaderRow>
<HeaderItem>Commands</HeaderItem>
<HeaderItem>Shortcut</HeaderItem>
</GridHeaderRow>
{this.renderKeyInput()}
</GridWrapper>
);
render() {
const layout = this.renderKeyForm();
return (
<Container>
<Header>Keyboard shortcuts</Header>
{layout}
<Button tertiary small id="restoreDefaultsHotkeys" onClick={this.restoreDefaults}>
Restore defaults
</Button>
<SettingsFooter />
</Container>
);
}
}
export { ShortcutsScreen };