Added the possibility to add addon shortcuts & created shortcuts for addon-viewport

This commit is contained in:
Charles Gruenais 2021-04-18 23:17:07 +02:00
parent ca184fc0b8
commit 8e45dbd361
8 changed files with 360 additions and 6 deletions

View File

@ -6,7 +6,8 @@ import { styled, Global, Theme, withTheme } from '@storybook/theming';
import { Icons, IconButton, WithTooltip, TooltipLinkList } from '@storybook/components';
import { useParameter, useAddonState } from '@storybook/api';
import { useStorybookApi, useParameter, useAddonState } from '@storybook/api';
import { registerShortcuts } from './shortcuts';
import { PARAM_KEY, ADDON_ID } from './constants';
import { MINIMAL_VIEWPORTS } from './defaults';
import { ViewportAddonParameter, ViewportMap, ViewportStyles, Styles } from './models';
@ -135,8 +136,10 @@ export const ViewportTool: FunctionComponent = memo(
});
const list = toList(viewports);
const api = useStorybookApi();
if (!list.find((i) => i.id === defaultViewport)) {
// eslint-disable-next-line no-console
console.warn(
`Cannot find "defaultViewport" of "${defaultViewport}" in addon-viewport configs, please check the "viewports" setting in the configuration.`
);
@ -148,6 +151,7 @@ export const ViewportTool: FunctionComponent = memo(
defaultViewport || (viewports[state.selected] ? state.selected : responsiveViewport.id),
isRotated: state.isRotated,
});
registerShortcuts(api, setState);
}, [defaultViewport]);
const { selected, isRotated } = state;

View File

@ -0,0 +1,59 @@
import { API } from '@storybook/api';
import { ADDON_ID } from './constants';
import { MINIMAL_VIEWPORTS } from './defaults';
const viewportsKeys = Object.keys(MINIMAL_VIEWPORTS);
const getCurrentViewportIndex = (current: string): number => viewportsKeys.indexOf(current);
const getNextViewport = (current: string): string => {
const currentViewportIndex = getCurrentViewportIndex(current);
return currentViewportIndex === viewportsKeys.length - 1
? viewportsKeys[0]
: viewportsKeys[currentViewportIndex + 1];
};
const getPreviousViewport = (current: string): string => {
const currentViewportIndex = getCurrentViewportIndex(current);
return currentViewportIndex < 1
? viewportsKeys[viewportsKeys.length - 1]
: viewportsKeys[currentViewportIndex - 1];
};
export const registerShortcuts = async (api: API, setState: any) => {
await api.setAddonShortcut(ADDON_ID, {
label: 'Previous viewport',
defaultShortcut: ['shift', 'V'],
actionName: 'previous',
action: () => {
const { selected, isRotated } = api.getAddonState(ADDON_ID);
setState({
selected: getPreviousViewport(selected),
isRotated,
});
},
});
await api.setAddonShortcut(ADDON_ID, {
label: 'Next viewport',
defaultShortcut: ['V'],
actionName: 'next',
action: () => {
const { selected, isRotated } = api.getAddonState(ADDON_ID);
setState({
selected: getNextViewport(selected),
isRotated,
});
},
});
await api.setAddonShortcut(ADDON_ID, {
label: 'Reset viewport',
defaultShortcut: ['control', 'V'],
actionName: 'reset',
action: () => {
const { isRotated } = api.getAddonState(ADDON_ID);
setState({
selected: 'reset',
isRotated,
});
},
});
};

View File

@ -128,3 +128,11 @@ You can change your story through [parameters](../writing-stories/parameters.md)
/>
<!-- prettier-ignore-end -->
### Keyboard shortcuts
* Previous viewport: `shift + v`
* Next viewport: `v`
* Reset viewport: `control + v`
These shortcuts can be edited in Storybook's Keyboard shortcuts page.

View File

@ -20,8 +20,13 @@ export interface SubState {
export interface SubAPI {
getShortcutKeys(): Shortcuts;
getDefaultShortcuts(): Shortcuts | AddonShortcutDefaults;
getAddonsShortcuts(): AddonShortcuts;
getAddonsShortcutLabels(): AddonShortcutLabels;
getAddonsShortcutDefaults(): AddonShortcutDefaults;
setShortcuts(shortcuts: Shortcuts): Promise<Shortcuts>;
setShortcut(action: Action, value: KeyCollection): Promise<KeyCollection>;
setAddonShortcut(addon: string, shortcut: AddonShortcut): Promise<AddonShortcut>;
restoreAllDefaultShortcuts(): Promise<Shortcuts>;
restoreDefaultShortcut(action: Action): Promise<KeyCollection>;
handleKeydownEvent(event: Event): void;
@ -52,6 +57,17 @@ export interface Shortcuts {
export type Action = keyof Shortcuts;
interface AddonShortcut {
label: string;
defaultShortcut: KeyCollection;
actionName: string;
showInMenu?: boolean;
action: (...args: any[]) => any;
}
type AddonShortcuts = Record<string, AddonShortcut>;
type AddonShortcutLabels = Record<string, string>;
type AddonShortcutDefaults = Record<string, KeyCollection>;
export const defaultShortcuts: Shortcuts = Object.freeze({
fullScreen: ['F'],
togglePanel: ['A'],
@ -73,6 +89,7 @@ export const defaultShortcuts: Shortcuts = Object.freeze({
expandAll: [controlOrMetaKey(), 'shift', 'ArrowDown'],
});
const addonsShortcuts: AddonShortcuts = {};
export interface Event extends KeyboardEvent {
target: {
tagName: string;
@ -96,20 +113,54 @@ export const init: ModuleFn = ({ store, fullAPI }) => {
getShortcutKeys(): Shortcuts {
return store.getState().shortcuts;
},
getDefaultShortcuts(): Shortcuts | AddonShortcutDefaults {
return {
...defaultShortcuts,
...api.getAddonsShortcutDefaults(),
};
},
getAddonsShortcuts(): AddonShortcuts {
return addonsShortcuts;
},
getAddonsShortcutLabels(): AddonShortcutLabels {
const labels: AddonShortcutLabels = {};
Object.entries(api.getAddonsShortcuts()).forEach(([actionName, { label }]) => {
labels[actionName] = label;
});
return labels;
},
getAddonsShortcutDefaults(): AddonShortcutDefaults {
const defaults: AddonShortcutDefaults = {};
Object.entries(api.getAddonsShortcuts()).forEach(([actionName, { defaultShortcut }]) => {
defaults[actionName] = defaultShortcut;
});
return defaults;
},
async setShortcuts(shortcuts: Shortcuts) {
await store.setState({ shortcuts }, { persistence: 'permanent' });
return shortcuts;
},
async restoreAllDefaultShortcuts() {
return api.setShortcuts(defaultShortcuts);
return api.setShortcuts(api.getDefaultShortcuts() as Shortcuts);
},
async setShortcut(action, value) {
const shortcuts = api.getShortcutKeys();
await api.setShortcuts({ ...shortcuts, [action]: value });
return value;
},
async setAddonShortcut(addon: string, shortcut: AddonShortcut) {
const shortcuts = api.getShortcutKeys();
await api.setShortcuts({
...shortcuts,
[`${addon}-${shortcut.actionName}`]: shortcut.defaultShortcut,
});
addonsShortcuts[`${addon}-${shortcut.actionName}`] = shortcut;
return shortcut;
},
async restoreDefaultShortcut(action) {
const defaultShortcut = defaultShortcuts[action];
const defaultShortcut = api.getDefaultShortcuts()[action];
return api.setShortcut(action, defaultShortcut);
},
@ -277,6 +328,7 @@ export const init: ModuleFn = ({ store, fullAPI }) => {
break;
}
default:
addonsShortcuts[feature].action();
break;
}
},

View File

@ -10,7 +10,134 @@ function createMockStore() {
};
}
const mockAddonShortcut = {
addon: 'my-addon',
shortcut: {
label: 'Do something',
defaultShortcut: ['O'],
actionName: 'doSomething',
action: () => {
//
},
},
};
const mockAddonSecondShortcut = {
addon: 'my-addon',
shortcut: {
label: 'Do something else',
defaultShortcut: ['P'],
actionName: 'doSomethingElse',
action: () => {
//
},
},
};
const mockSecondAddonShortcut = {
addon: 'my-other-addon',
shortcut: {
label: 'Create issue',
defaultShortcut: ['N'],
actionName: 'createIssue',
action: () => {
//
},
},
};
describe('shortcuts api', () => {
it('gets defaults', () => {
const store = createMockStore();
const { api, state } = initShortcuts({ store });
store.setState(state);
expect(api.getDefaultShortcuts()).toHaveProperty('fullScreen', ['F']);
});
it('gets defaults including addon ones', async () => {
const store = createMockStore();
const { api, state } = initShortcuts({ store });
store.setState(state);
await api.setAddonShortcut(mockAddonShortcut.addon, mockAddonShortcut.shortcut);
await api.setAddonShortcut(mockAddonSecondShortcut.addon, mockAddonSecondShortcut.shortcut);
await api.setAddonShortcut(mockSecondAddonShortcut.addon, mockSecondAddonShortcut.shortcut);
expect(api.getDefaultShortcuts()).toHaveProperty('fullScreen', ['F']);
expect(api.getDefaultShortcuts()).toHaveProperty(
`${mockAddonShortcut.addon}-${mockAddonShortcut.shortcut.actionName}`,
mockAddonShortcut.shortcut.defaultShortcut
);
expect(api.getDefaultShortcuts()).toHaveProperty(
`${mockAddonSecondShortcut.addon}-${mockAddonSecondShortcut.shortcut.actionName}`,
mockAddonSecondShortcut.shortcut.defaultShortcut
);
expect(api.getDefaultShortcuts()).toHaveProperty(
`${mockSecondAddonShortcut.addon}-${mockSecondAddonShortcut.shortcut.actionName}`,
mockSecondAddonShortcut.shortcut.defaultShortcut
);
});
it('gets addons shortcuts', async () => {
const store = createMockStore();
const { api, state } = initShortcuts({ store });
store.setState(state);
await api.setAddonShortcut(mockAddonShortcut.addon, mockAddonShortcut.shortcut);
await api.setAddonShortcut(mockAddonSecondShortcut.addon, mockAddonSecondShortcut.shortcut);
await api.setAddonShortcut(mockSecondAddonShortcut.addon, mockSecondAddonShortcut.shortcut);
expect(api.getAddonsShortcuts()).toStrictEqual({
[`${mockAddonShortcut.addon}-${mockAddonShortcut.shortcut.actionName}`]: mockAddonShortcut.shortcut,
[`${mockAddonSecondShortcut.addon}-${mockAddonSecondShortcut.shortcut.actionName}`]: mockAddonSecondShortcut.shortcut,
[`${mockSecondAddonShortcut.addon}-${mockSecondAddonShortcut.shortcut.actionName}`]: mockSecondAddonShortcut.shortcut,
});
});
it('gets addons shortcut labels', async () => {
const store = createMockStore();
const { api, state } = initShortcuts({ store });
store.setState(state);
await api.setAddonShortcut(mockAddonShortcut.addon, mockAddonShortcut.shortcut);
await api.setAddonShortcut(mockAddonSecondShortcut.addon, mockAddonSecondShortcut.shortcut);
await api.setAddonShortcut(mockSecondAddonShortcut.addon, mockSecondAddonShortcut.shortcut);
expect(api.getAddonsShortcutLabels()).toStrictEqual({
[`${mockAddonShortcut.addon}-${mockAddonShortcut.shortcut.actionName}`]: mockAddonShortcut
.shortcut.label,
[`${mockAddonSecondShortcut.addon}-${mockAddonSecondShortcut.shortcut.actionName}`]: mockAddonSecondShortcut
.shortcut.label,
[`${mockSecondAddonShortcut.addon}-${mockSecondAddonShortcut.shortcut.actionName}`]: mockSecondAddonShortcut
.shortcut.label,
});
});
it('gets addons shortcut defaults', async () => {
const store = createMockStore();
const { api, state } = initShortcuts({ store });
store.setState(state);
await api.setAddonShortcut(mockAddonShortcut.addon, mockAddonShortcut.shortcut);
await api.setAddonShortcut(mockAddonSecondShortcut.addon, mockAddonSecondShortcut.shortcut);
await api.setAddonShortcut(mockSecondAddonShortcut.addon, mockSecondAddonShortcut.shortcut);
expect(api.getAddonsShortcutDefaults()).toStrictEqual({
[`${mockAddonShortcut.addon}-${mockAddonShortcut.shortcut.actionName}`]: mockAddonShortcut
.shortcut.defaultShortcut,
[`${mockAddonSecondShortcut.addon}-${mockAddonSecondShortcut.shortcut.actionName}`]: mockAddonSecondShortcut
.shortcut.defaultShortcut,
[`${mockSecondAddonShortcut.addon}-${mockSecondAddonShortcut.shortcut.actionName}`]: mockSecondAddonShortcut
.shortcut.defaultShortcut,
});
});
it('sets defaults', () => {
const store = createMockStore();
@ -20,6 +147,31 @@ describe('shortcuts api', () => {
expect(api.getShortcutKeys().fullScreen).toEqual(['F']);
});
it('sets addon shortcut with default value', async () => {
const store = createMockStore();
const { api, state } = initShortcuts({ store });
store.setState(state);
await api.setAddonShortcut(mockAddonShortcut.addon, mockAddonShortcut.shortcut);
await api.setAddonShortcut(mockAddonSecondShortcut.addon, mockAddonSecondShortcut.shortcut);
await api.setAddonShortcut(mockSecondAddonShortcut.addon, mockSecondAddonShortcut.shortcut);
expect(api.getDefaultShortcuts()).toHaveProperty('fullScreen', ['F']);
expect(api.getDefaultShortcuts()).toHaveProperty(
`${mockAddonShortcut.addon}-${mockAddonShortcut.shortcut.actionName}`,
mockAddonShortcut.shortcut.defaultShortcut
);
expect(api.getDefaultShortcuts()).toHaveProperty(
`${mockAddonSecondShortcut.addon}-${mockAddonSecondShortcut.shortcut.actionName}`,
mockAddonSecondShortcut.shortcut.defaultShortcut
);
expect(api.getDefaultShortcuts()).toHaveProperty(
`${mockSecondAddonShortcut.addon}-${mockSecondAddonShortcut.shortcut.actionName}`,
mockSecondAddonShortcut.shortcut.defaultShortcut
);
});
it('sets defaults, augmenting anything that was persisted', () => {
const store = createMockStore();
store.setState({ shortcuts: { fullScreen: ['Z'] } });
@ -51,17 +203,38 @@ describe('shortcuts api', () => {
expect(api.getShortcutKeys().fullScreen).toEqual(['X']);
});
it('sets new values for addon shortcuts', async () => {
const store = createMockStore();
const { api, state } = initShortcuts({ store });
store.setState(state);
const { addon, shortcut } = mockAddonShortcut;
await api.setAddonShortcut(addon, shortcut);
await api.setShortcut(`${addon}-${shortcut.actionName}`, ['I']);
expect(api.getShortcutKeys()[`${addon}-${shortcut.actionName}`]).toEqual(['I']);
});
it('restores all defaults', async () => {
const store = createMockStore();
const { api, state } = initShortcuts({ store });
store.setState(state);
const { addon, shortcut } = mockAddonShortcut;
await api.setAddonShortcut(addon, shortcut);
await api.setShortcut('fullScreen', ['X']);
await api.setShortcut('togglePanel', ['B']);
await api.setShortcut(`${addon}-${shortcut.actionName}`, ['I']);
await api.restoreAllDefaultShortcuts();
expect(api.getShortcutKeys().fullScreen).toEqual(['F']);
expect(api.getShortcutKeys().togglePanel).toEqual(['A']);
expect(api.getShortcutKeys()[`${addon}-${shortcut.actionName}`]).toEqual(
shortcut.defaultShortcut
);
});
it('restores single default', async () => {
@ -70,10 +243,42 @@ describe('shortcuts api', () => {
const { api, state } = initShortcuts({ store });
store.setState(state);
await api.setAddonShortcut(mockAddonShortcut.addon, mockAddonShortcut.shortcut);
await api.setAddonShortcut(mockAddonSecondShortcut.addon, mockAddonSecondShortcut.shortcut);
await api.setAddonShortcut(mockSecondAddonShortcut.addon, mockSecondAddonShortcut.shortcut);
await api.setShortcut('fullScreen', ['X']);
await api.setShortcut('togglePanel', ['B']);
await api.setShortcut(`${mockAddonShortcut.addon}-${mockAddonShortcut.shortcut.actionName}`, [
'I',
]);
await api.setShortcut(
`${mockAddonSecondShortcut.addon}-${mockAddonSecondShortcut.shortcut.actionName}`,
['H']
);
await api.setShortcut(
`${mockSecondAddonShortcut.addon}-${mockSecondAddonShortcut.shortcut.actionName}`,
['G']
);
await api.restoreDefaultShortcut('fullScreen');
await api.restoreDefaultShortcut(
`${mockAddonShortcut.addon}-${mockAddonShortcut.shortcut.actionName}`
);
expect(api.getShortcutKeys().fullScreen).toEqual(['F']);
expect(api.getShortcutKeys().togglePanel).toEqual(['B']);
expect(
api.getShortcutKeys()[`${mockAddonShortcut.addon}-${mockAddonShortcut.shortcut.actionName}`]
).toEqual(mockAddonShortcut.shortcut.defaultShortcut);
expect(
api.getShortcutKeys()[
`${mockAddonSecondShortcut.addon}-${mockAddonSecondShortcut.shortcut.actionName}`
]
).toEqual(['H']);
expect(
api.getShortcutKeys()[
`${mockSecondAddonShortcut.addon}-${mockSecondAddonShortcut.shortcut.actionName}`
]
).toEqual(['G']);
});
});

View File

@ -205,6 +205,20 @@ export const useMenu = (
[api, enableShortcuts, shortcutKeys]
);
const getAddonsShortcuts = (): any[] => {
const addonsShortcuts = api.getAddonsShortcuts();
const keys = shortcutKeys as any;
return Object.entries(addonsShortcuts)
.filter(([actionName, { showInMenu }]) => showInMenu)
.map(([actionName, { label, action }]) => ({
id: actionName,
title: label,
onClick: () => action(),
right: enableShortcuts ? <Shortcut keys={keys[actionName]} /> : null,
left: <MenuItemIcon />,
}));
};
return useMemo(
() => [
about,
@ -221,6 +235,7 @@ export const useMenu = (
prev,
next,
collapse,
...getAddonsShortcuts(),
],
[
about,

View File

@ -143,10 +143,12 @@ 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;
@ -162,6 +164,7 @@ class ShortcutsScreen extends Component<ShortcutsScreenProps, ShortcutsScreenSta
// 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,
};
}
@ -261,10 +264,10 @@ class ShortcutsScreen extends Component<ShortcutsScreenProps, ShortcutsScreenSta
};
renderKeyInput = () => {
const { shortcutKeys } = this.state;
const { shortcutKeys, addonsShortcutLabels } = this.state;
const arr = Object.entries(shortcutKeys).map(([feature, { shortcut }]: [Feature, any]) => (
<Row key={feature}>
<Description>{shortcutLabels[feature]}</Description>
<Description>{shortcutLabels[feature] || addonsShortcutLabels[feature]}</Description>
<TextInput
spellCheck="false"
@ -272,6 +275,7 @@ class ShortcutsScreen extends Component<ShortcutsScreenProps, ShortcutsScreenSta
className="modalInput"
onBlur={this.onBlur}
onFocus={this.onFocus(feature)}
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
onKeyDown={this.onKeyDown}
value={shortcut ? shortcutToHumanString(shortcut) : ''}

View File

@ -7,10 +7,17 @@ import { ShortcutsScreen } from './shortcuts';
const ShortcutsPage: FunctionComponent<{}> = () => (
<Consumer>
{({
api: { getShortcutKeys, setShortcut, restoreDefaultShortcut, restoreAllDefaultShortcuts },
api: {
getShortcutKeys,
getAddonsShortcutLabels,
setShortcut,
restoreDefaultShortcut,
restoreAllDefaultShortcuts,
},
}) => (
<ShortcutsScreen
shortcutKeys={getShortcutKeys()}
addonsShortcutLabels={getAddonsShortcutLabels()}
{...{ setShortcut, restoreDefaultShortcut, restoreAllDefaultShortcuts }}
/>
)}