Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(KeyboardShortcuts): add customizable shortcuts for common actions #5097

Draft
wants to merge 10 commits into
base: production
Choose a base branch
from
68 changes: 53 additions & 15 deletions specifyweb/frontend/js_src/lib/components/Atoms/DataEntry.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,12 @@ import type { LocalizedString } from 'typesafe-i18n';
import { commonText } from '../../localization/common';
import { formsText } from '../../localization/forms';
import type { RA } from '../../utils/types';
import { localized } from '../../utils/types';
import type { AnySchema } from '../DataModel/helperTypes';
import type { SpecifyResource } from '../DataModel/legacyTypes';
import type { ViewDescription } from '../FormParse';
import type { cellAlign, cellVerticalAlign } from '../FormParse/cells';
import { userPreferences } from '../Preferences/userPreferences';
import { Button } from './Button';
import { className } from './className';
import type { icons } from './Icons';
Expand All @@ -19,23 +21,24 @@ const dataEntryButton = (
className: string,
title: LocalizedString,
icon: keyof typeof icons
) =>
function (
) => {
const component = (
props: Omit<TagProps<'button'>, 'children' | 'type'> & {
readonly onClick:
| ((event: React.MouseEvent<HTMLButtonElement>) => void)
| undefined;
}
): JSX.Element {
return (
<Button.Icon
className={`${className} ${props.className ?? ''}`}
icon={icon}
title={title}
{...props}
/>
);
};
): JSX.Element => (
<Button.Icon
className={`${className} ${props.className ?? ''}`}
icon={icon}
title={title}
{...props}
/>
);
Object.defineProperty(component, 'name', { value: icon });
return component;
};

export const columnDefinitionsToCss = (
columns: RA<number | undefined>,
Expand All @@ -54,6 +57,12 @@ export const columnDefinitionsToCss = (
* This is called DataEntry instead of Form because "Form" is already taken
*/

const DataEntryAdd = dataEntryButton(
className.dataEntryAdd,
commonText.add(),
'plus'
);

export const DataEntry = {
Grid: wrap<
'div',
Expand Down Expand Up @@ -145,7 +154,29 @@ export const DataEntry = {
})
),
SubFormTitle: wrap('DataEntry.SubFormTitle', 'h3', className.formTitle),
Add: dataEntryButton(className.dataEntryAdd, commonText.add(), 'plus'),
Add({
enableShortcut,
onClick: handleClick,
title = commonText.add(),
...rest
}: Omit<Parameters<typeof DataEntryAdd>[0], 'onClick'> & {
readonly onClick: (() => void) | undefined;
readonly enableShortcut: boolean;
}): JSX.Element {
const addButtonShortcut = userPreferences.useKeyboardShortcut(
'form',
'recordSet',
'addResource',
enableShortcut && rest.disabled !== true ? handleClick : undefined
);
return (
<DataEntryAdd
title={`${title}${addButtonShortcut}`}
onClick={handleClick}
{...rest}
/>
);
},
View: dataEntryButton(className.dataEntryView, commonText.view(), 'eye'),
Edit: dataEntryButton(className.dataEntryEdit, commonText.edit(), 'pencil'),
Clone: dataEntryButton(
Expand All @@ -170,14 +201,21 @@ export const DataEntry = {
readonly className?: string;
readonly resource: SpecifyResource<AnySchema> | undefined;
}): JSX.Element | null {
const ref = React.useRef<HTMLAnchorElement>(null);
const keyboardShortcut = userPreferences.useKeyboardShortcut(
'form',
'queryComboBox',
'openRelatedRecordInNewTab',
resource === undefined ? undefined : (): void => ref.current?.click()
);
return typeof resource === 'object' && !resource.isNew() ? (
<Link.NewTab
aria-label={commonText.openInNewTab()}
className={`${className.dataEntryVisit} ${localClassName}`}
forwardRef={ref}
href={resource.viewUrl()}
title={commonText.openInNewTab()}
title={localized(`${commonText.openInNewTab()}${keyboardShortcut}`)}
/>
) : null;
},
};
/* eslint-enable @typescript-eslint/naming-convention */
10 changes: 4 additions & 6 deletions specifyweb/frontend/js_src/lib/components/Atoms/Icons.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ export const icons = {
collection: <svg aria-hidden className={iconClassName} fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path d="M7 3a1 1 0 000 2h6a1 1 0 100-2H7zM4 7a1 1 0 011-1h10a1 1 0 110 2H5a1 1 0 01-1-1zM2 11a2 2 0 012-2h12a2 2 0 012 2v4a2 2 0 01-2 2H4a2 2 0 01-2-2v-4z" /></svg>,
cube: <svg aria-hidden className={iconClassName} fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path d="M11 17a1 1 0 001.447.894l4-2A1 1 0 0017 15V9.236a1 1 0 00-1.447-.894l-4 2a1 1 0 00-.553.894V17zM15.211 6.276a1 1 0 000-1.788l-4.764-2.382a1 1 0 00-.894 0L4.789 4.488a1 1 0 000 1.788l4.764 2.382a1 1 0 00.894 0l4.764-2.382zM4.447 8.342A1 1 0 003 9.236V15a1 1 0 00.553.894l4 2A1 1 0 009 17v-5.764a1 1 0 00-.553-.894l-4-2z" /></svg>,
cubeTransparent: <svg aria-hidden className={iconClassName} fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path clipRule="evenodd" d="M9.504 1.132a1 1 0 01.992 0l1.75 1a1 1 0 11-.992 1.736L10 3.152l-1.254.716a1 1 0 11-.992-1.736l1.75-1zM5.618 4.504a1 1 0 01-.372 1.364L5.016 6l.23.132a1 1 0 11-.992 1.736L4 7.723V8a1 1 0 01-2 0V6a.996.996 0 01.52-.878l1.734-.99a1 1 0 011.364.372zm8.764 0a1 1 0 011.364-.372l1.733.99A1.002 1.002 0 0118 6v2a1 1 0 11-2 0v-.277l-.254.145a1 1 0 11-.992-1.736l.23-.132-.23-.132a1 1 0 01-.372-1.364zm-7 4a1 1 0 011.364-.372L10 8.848l1.254-.716a1 1 0 11.992 1.736L11 10.58V12a1 1 0 11-2 0v-1.42l-1.246-.712a1 1 0 01-.372-1.364zM3 11a1 1 0 011 1v1.42l1.246.712a1 1 0 11-.992 1.736l-1.75-1A1 1 0 012 14v-2a1 1 0 011-1zm14 0a1 1 0 011 1v2a1 1 0 01-.504.868l-1.75 1a1 1 0 11-.992-1.736L16 13.42V12a1 1 0 011-1zm-9.618 5.504a1 1 0 011.364-.372l.254.145V16a1 1 0 112 0v.277l.254-.145a1 1 0 11.992 1.736l-1.735.992a.995.995 0 01-1.022 0l-1.735-.992a1 1 0 01-.372-1.364z" fillRule="evenodd" /></svg>,
cursorClick: <svg aria-hidden className={iconClassName} fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path clipRule="evenodd" d="M6.672 1.911a1 1 0 10-1.932.518l.259.966a1 1 0 001.932-.518l-.26-.966zM2.429 4.74a1 1 0 10-.517 1.932l.966.259a1 1 0 00.517-1.932l-.966-.26zm8.814-.569a1 1 0 00-1.415-1.414l-.707.707a1 1 0 101.415 1.415l.707-.708zm-7.071 7.072l.707-.707A1 1 0 003.465 9.12l-.708.707a1 1 0 001.415 1.415zm3.2-5.171a1 1 0 00-1.3 1.3l4 10a1 1 0 001.823.075l1.38-2.759 3.018 3.02a1 1 0 001.414-1.415l-3.019-3.02 2.76-1.379a1 1 0 00-.076-1.822l-10-4z" fillRule="evenodd" /></svg>,
database: <svg aria-hidden className={iconClassName} fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path d="M3 12v3c0 1.657 3.134 3 7 3s7-1.343 7-3v-3c0 1.657-3.134 3-7 3s-7-1.343-7-3z" /><path d="M3 7v3c0 1.657 3.134 3 7 3s7-1.343 7-3V7c0 1.657-3.134 3-7 3S3 8.657 3 7z" /><path d="M17 5c0 1.657-3.134 3-7 3S3 6.657 3 5s3.134-3 7-3 7 1.343 7 3z" /></svg>,
document: <svg aria-hidden className={iconClassName} fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path clipRule="evenodd" d="M4 4a2 2 0 012-2h4.586A2 2 0 0112 2.586L15.414 6A2 2 0 0116 7.414V16a2 2 0 01-2 2H6a2 2 0 01-2-2V4z" fillRule="evenodd" /></svg>,
documentReport: <svg aria-hidden className={iconClassName} fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path clipRule="evenodd" d="M6 2a2 2 0 00-2 2v12a2 2 0 002 2h8a2 2 0 002-2V7.414A2 2 0 0015.414 6L12 2.586A2 2 0 0010.586 2H6zm2 10a1 1 0 10-2 0v3a1 1 0 102 0v-3zm2-3a1 1 0 011 1v5a1 1 0 11-2 0v-5a1 1 0 011-1zm4-1a1 1 0 10-2 0v7a1 1 0 102 0V8z" fillRule="evenodd" /></svg>,
Expand All @@ -84,13 +85,10 @@ export const icons = {
exclamationCircle: <svg aria-hidden className={iconClassName} fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path clipRule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7 4a1 1 0 11-2 0 1 1 0 012 0zm-1-9a1 1 0 00-1 1v4a1 1 0 102 0V6a1 1 0 00-1-1z" fillRule="evenodd" /></svg>,
externalLink: <svg aria-hidden className={iconClassName} fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path d="M11 3a1 1 0 100 2h2.586l-6.293 6.293a1 1 0 101.414 1.414L15 6.414V9a1 1 0 102 0V4a1 1 0 00-1-1h-5z" /><path d="M5 5a2 2 0 00-2 2v8a2 2 0 002 2h8a2 2 0 002-2v-3a1 1 0 10-2 0v3H5V7h3a1 1 0 000-2H5z" /></svg>,
eye: <svg aria-hidden className={iconClassName} fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path d="M10 12a2 2 0 100-4 2 2 0 000 4z" /><path clipRule="evenodd" d="M.458 10C1.732 5.943 5.522 3 10 3s8.268 2.943 9.542 7c-1.274 4.057-5.064 7-9.542 7S1.732 14.057.458 10zM14 10a4 4 0 11-8 0 4 4 0 018 0z" fillRule="evenodd" /></svg>,
filter: <svg aria-hidden className={iconClassName} fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path clipRule="evenodd" d="M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z" fillRule="evenodd" /></svg>,
fingerPrint: <svg aria-hidden className={iconClassName} fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path clipRule="evenodd" d="M6.625 2.655A9 9 0 0119 11a1 1 0 11-2 0 7 7 0 00-9.625-6.492 1 1 0 11-.75-1.853zM4.662 4.959A1 1 0 014.75 6.37 6.97 6.97 0 003 11a1 1 0 11-2 0 8.97 8.97 0 012.25-5.953 1 1 0 011.412-.088z" fillRule="evenodd" /><path clipRule="evenodd" d="M5 11a5 5 0 1110 0 1 1 0 11-2 0 3 3 0 10-6 0c0 1.677-.345 3.276-.968 4.729a1 1 0 11-1.838-.789A9.964 9.964 0 005 11zm8.921 2.012a1 1 0 01.831 1.145 19.86 19.86 0 01-.545 2.436 1 1 0 11-1.92-.558c.207-.713.371-1.445.49-2.192a1 1 0 011.144-.83z" fillRule="evenodd" /><path clipRule="evenodd" d="M10 10a1 1 0 011 1c0 2.236-.46 4.368-1.29 6.304a1 1 0 01-1.838-.789A13.952 13.952 0 009 11a1 1 0 011-1z" fillRule="evenodd" /></svg>,
gallery:<svg aria-hidden className={iconClassName} fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg">
<path clipRule="evenodd" d="M5 3a2 2 0 00-2 2v2a2 2 0 002 2h2a2 2 0 002-2V5a2 2 0 00-2-2H5zM5 11a2 2 0 00-2 2v2a2 2 0 002 2h2a2 2 0 002-2v-2a2 2 0 00-2-2H5zM11 5a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V5zM11 13a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z" fillRule="evenodd" />
</svg>,
globe: <svg aria-hidden className={iconClassName} fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg">
<path clipRule="evenodd" d="M4.083 9h1.946c.089-1.546.383-2.97.837-4.118A6.004 6.004 0 004.083 9zM10 2a8 8 0 100 16 8 8 0 000-16zm0 2c-.076 0-.232.032-.465.262-.238.234-.497.623-.737 1.182-.389.907-.673 2.142-.766 3.556h3.936c-.093-1.414-.377-2.649-.766-3.556-.24-.56-.5-.948-.737-1.182C10.232 4.032 10.076 4 10 4zm3.971 5c-.089-1.546-.383-2.97-.837-4.118A6.004 6.004 0 0115.917 9h-1.946zm-2.003 2H8.032c.093 1.414.377 2.649.766 3.556.24.56.5.948.737 1.182.233.23.389.262.465.262.076 0 .232-.032.465-.262.238-.234.498-.623.737-1.182.389-.907.673-2.142.766-3.556zm1.166 4.118c.454-1.147.748-2.572.837-4.118h1.946a6.004 6.004 0 01-2.783 4.118zm-6.268 0C6.412 13.97 6.118 12.546 6.03 11H4.083a6.004 6.004 0 002.783 4.118z" fillRule="evenodd" />
</svg>,
gallery:<svg aria-hidden className={iconClassName} fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path clipRule="evenodd" d="M5 3a2 2 0 00-2 2v2a2 2 0 002 2h2a2 2 0 002-2V5a2 2 0 00-2-2H5zM5 11a2 2 0 00-2 2v2a2 2 0 002 2h2a2 2 0 002-2v-2a2 2 0 00-2-2H5zM11 5a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V5zM11 13a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z" fillRule="evenodd" /></svg>,
globe: <svg aria-hidden className={iconClassName} fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path clipRule="evenodd" d="M4.083 9h1.946c.089-1.546.383-2.97.837-4.118A6.004 6.004 0 004.083 9zM10 2a8 8 0 100 16 8 8 0 000-16zm0 2c-.076 0-.232.032-.465.262-.238.234-.497.623-.737 1.182-.389.907-.673 2.142-.766 3.556h3.936c-.093-1.414-.377-2.649-.766-3.556-.24-.56-.5-.948-.737-1.182C10.232 4.032 10.076 4 10 4zm3.971 5c-.089-1.546-.383-2.97-.837-4.118A6.004 6.004 0 0115.917 9h-1.946zm-2.003 2H8.032c.093 1.414.377 2.649.766 3.556.24.56.5.948.737 1.182.233.23.389.262.465.262.076 0 .232-.032.465-.262.238-.234.498-.623.737-1.182.389-.907.673-2.142.766-3.556zm1.166 4.118c.454-1.147.748-2.572.837-4.118h1.946a6.004 6.004 0 01-2.783 4.118zm-6.268 0C6.412 13.97 6.118 12.546 6.03 11H4.083a6.004 6.004 0 002.783 4.118z" fillRule="evenodd" /></svg>,
hashtag: <svg aria-hidden className={iconClassName} fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path clipRule="evenodd" d="M9.243 3.03a1 1 0 01.727 1.213L9.53 6h2.94l.56-2.243a1 1 0 111.94.486L14.53 6H17a1 1 0 110 2h-2.97l-1 4H15a1 1 0 110 2h-2.47l-.56 2.242a1 1 0 11-1.94-.485L10.47 14H7.53l-.56 2.242a1 1 0 11-1.94-.485L5.47 14H3a1 1 0 110-2h2.97l1-4H5a1 1 0 110-2h2.47l.56-2.243a1 1 0 011.213-.727zM9.03 8l-1 4h2.938l1-4H9.031z" fillRule="evenodd" /></svg>,
// This icon is not from Heroicons. It was drawn by @grantfitzsimmons
history: <svg aria-hidden className={iconClassName} fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path d="M17.043,6.984c-0.382-0.935-0.942-1.753-1.665-2.433c-0.767-0.722-1.572-1.249-2.459-1.611 c-0.911-0.391-1.94-0.597-2.979-0.597c-1.147,0-2.207,0.251-3.239,0.768C5.87,3.526,5.136,4.05,4.512,4.671V3.746 c0-0.47-0.397-0.867-0.867-0.867c-0.486,0-0.867,0.381-0.867,0.867v2.866c0,0.577,0.469,1.046,1.045,1.046h2.825 c0.51,0,0.909-0.399,0.909-0.908c0-0.471-0.355-0.826-0.826-0.826H5.752C6.191,5.5,6.688,5.131,7.258,4.802 c0.797-0.46,1.688-0.71,2.579-0.723c1.683-0.052,3.085,0.532,4.291,1.698c1.178,1.139,1.775,2.532,1.775,4.141 c0,1.729-0.555,3.125-1.696,4.267c-1.18,1.18-2.616,1.778-4.267,1.778c-1.647,0-3.016-0.568-4.181-1.733 c-0.983-1.022-1.542-2.259-1.661-3.685C4.041,10.084,3.677,9.75,3.233,9.75c-0.257,0-0.499,0.104-0.662,0.288 c-0.158,0.176-0.23,0.406-0.205,0.638c0.127,1.823,0.858,3.413,2.171,4.727c1.496,1.495,3.313,2.254,5.402,2.254 c0.977,0,1.947-0.18,2.983-0.558c0.977-0.443,1.805-1.001,2.46-1.655c0.74-0.739,1.282-1.547,1.659-2.469 c0.395-0.966,0.595-1.979,0.595-3.016C17.638,8.937,17.443,7.964,17.043,6.984z"/> <path d="M10.022,5.595H9.94c-0.426,0-0.785,0.359-0.785,0.784v3.333c0,0.411,0.158,0.796,0.445,1.084l2.345,2.303 c0.175,0.175,0.402,0.264,0.637,0.264c0.2,0,0.405-0.064,0.587-0.194c0.258-0.181,0.328-0.4,0.341-0.553 c0.021-0.251-0.09-0.519-0.31-0.737l-2.31-2.231V6.462C10.89,5.976,10.509,5.595,10.022,5.595z"/></svg>,
Expand Down
7 changes: 6 additions & 1 deletion specifyweb/frontend/js_src/lib/components/Atoms/Link.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,12 @@ export const Link = {
children: (
<>
{props.children}
<span title={commonText.opensInNewTab()}>
<span
title={
(props.children === undefined ? props.title : undefined) ??
commonText.opensInNewTab()
}
>
<span className="sr-only">{commonText.opensInNewTab()}</span>
{icons.externalLink}
</span>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ snapshot(DataEntry.Footer, { children: 'Test' });
snapshot(DataEntry.SubForm, { children: 'Test' });
snapshot(DataEntry.SubFormHeader, { children: 'Test' });
snapshot(DataEntry.SubFormTitle, { children: 'Test' });
snapshot(DataEntry.Add, { onClick: f.never });
snapshot(DataEntry.Add, { onClick: f.never, enableShortcut: true });
snapshot(DataEntry.View, { onClick: f.never });
snapshot(DataEntry.Edit, { onClick: f.never });
snapshot(DataEntry.Clone, { onClick: f.never });
Expand Down
Loading
Loading