-
Notifications
You must be signed in to change notification settings - Fork 114
Keyboard Shortcut Pattern #5886
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
base: main
Are you sure you want to change the base?
Changes from 4 commits
1696f74
7f38259
8ab154b
661a199
9ea1de9
1915e18
7895f54
8d0bb40
c8680cc
7e95d73
b0363e7
d74ac6a
827849b
cc79d09
0be6461
81d4fae
9dd7f41
00a3f03
16a5818
d42742e
6155c80
7b953e0
67e5ad2
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| --- | ||
| "@salt-ds/lab": minor | ||
| --- | ||
|
|
||
| KS pattern - Added KS pattern and KeyboardKey component. | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,7 @@ | ||
| .keyboardShortcuts-description { | ||
| padding: var(--salt-spacing-25) 0; | ||
| } | ||
|
|
||
| .keyboardShortcuts-actions-title { | ||
| font-weight: var(--salt-text-heading-fontWeight); | ||
| } |
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Enter a non-match into the filter and the layout collapses.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @honey-chang should I add some min-height to maintain the height of the dialog when search has no match? |
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,281 @@ | ||||||||||||||||||||||||||||||
| import { | ||||||||||||||||||||||||||||||
| Button, | ||||||||||||||||||||||||||||||
| ComboBox, | ||||||||||||||||||||||||||||||
| Dialog, | ||||||||||||||||||||||||||||||
| DialogContent, | ||||||||||||||||||||||||||||||
| FlexLayout, | ||||||||||||||||||||||||||||||
| Label, | ||||||||||||||||||||||||||||||
| StackLayout, | ||||||||||||||||||||||||||||||
| Switch, | ||||||||||||||||||||||||||||||
| Text, | ||||||||||||||||||||||||||||||
| } from "@salt-ds/core"; | ||||||||||||||||||||||||||||||
| import { FilterIcon } from "@salt-ds/icons"; | ||||||||||||||||||||||||||||||
| import { KeyboardKey, Table, TBody, TD, TH, THead, TR } from "@salt-ds/lab"; | ||||||||||||||||||||||||||||||
| import type { Meta } from "@storybook/react-vite"; | ||||||||||||||||||||||||||||||
| import React, { type ChangeEvent, type SyntheticEvent, useState } from "react"; | ||||||||||||||||||||||||||||||
| import { HotkeysProvider, useHotkeys } from "react-hotkeys-hook"; | ||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||
| import "./keyboard-shortcuts.stories.css"; | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| export default { | ||||||||||||||||||||||||||||||
| title: "Patterns/Keyboard Shortcuts", | ||||||||||||||||||||||||||||||
| } as Meta; | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| // Keyboard shortcut data type | ||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||
| type KeyboardShortcut = { | ||||||||||||||||||||||||||||||
| label: string; | ||||||||||||||||||||||||||||||
| shortcut: string[][]; | ||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||
| connector: string; | ||||||||||||||||||||||||||||||
| action: () => void; | ||||||||||||||||||||||||||||||
| description?: string; | ||||||||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| const keyboardShortcuts: KeyboardShortcut[] = [ | ||||||||||||||||||||||||||||||
| { | ||||||||||||||||||||||||||||||
| label: "Open command palette", | ||||||||||||||||||||||||||||||
| shortcut: [["meta", "option", "p"]], | ||||||||||||||||||||||||||||||
| connector: "+", | ||||||||||||||||||||||||||||||
| action: () => alert("Open command palette triggered!"), | ||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||
| }, | ||||||||||||||||||||||||||||||
| { | ||||||||||||||||||||||||||||||
| label: "Next", | ||||||||||||||||||||||||||||||
| shortcut: [["meta", "shift", "e"]], | ||||||||||||||||||||||||||||||
| connector: "+", | ||||||||||||||||||||||||||||||
| action: () => alert("Next triggered!"), | ||||||||||||||||||||||||||||||
| }, | ||||||||||||||||||||||||||||||
| { | ||||||||||||||||||||||||||||||
| label: "Previous", | ||||||||||||||||||||||||||||||
| shortcut: [["meta", "e"]], | ||||||||||||||||||||||||||||||
| connector: "+", | ||||||||||||||||||||||||||||||
| action: () => alert("Previous triggered!"), | ||||||||||||||||||||||||||||||
| }, | ||||||||||||||||||||||||||||||
| { | ||||||||||||||||||||||||||||||
| label: "Duplicate ticket", | ||||||||||||||||||||||||||||||
| description: "Make a copy of your ticket", | ||||||||||||||||||||||||||||||
| shortcut: [["meta", "d"]], | ||||||||||||||||||||||||||||||
| connector: "+", | ||||||||||||||||||||||||||||||
| action: () => alert("Duplicate ticket triggered!"), | ||||||||||||||||||||||||||||||
| }, | ||||||||||||||||||||||||||||||
| { | ||||||||||||||||||||||||||||||
| label: "Set direction to buy", | ||||||||||||||||||||||||||||||
| shortcut: [["meta", "b"]], | ||||||||||||||||||||||||||||||
| connector: "+", | ||||||||||||||||||||||||||||||
| action: () => alert("Set direction to buy triggered!"), | ||||||||||||||||||||||||||||||
| }, | ||||||||||||||||||||||||||||||
| { | ||||||||||||||||||||||||||||||
| label: "Set direction to sell", | ||||||||||||||||||||||||||||||
| shortcut: [["meta", "s"]], | ||||||||||||||||||||||||||||||
| connector: "+", | ||||||||||||||||||||||||||||||
| action: () => alert("Set direction to sell triggered!"), | ||||||||||||||||||||||||||||||
| }, | ||||||||||||||||||||||||||||||
| { | ||||||||||||||||||||||||||||||
| label: "Bottom of list", | ||||||||||||||||||||||||||||||
| shortcut: [["meta", "end"]], | ||||||||||||||||||||||||||||||
| connector: "+", | ||||||||||||||||||||||||||||||
| action: () => alert("Bottom of list triggered!"), | ||||||||||||||||||||||||||||||
| }, | ||||||||||||||||||||||||||||||
| { | ||||||||||||||||||||||||||||||
| label: "Top of list", | ||||||||||||||||||||||||||||||
| shortcut: [["meta", "home"]], | ||||||||||||||||||||||||||||||
| connector: "+", | ||||||||||||||||||||||||||||||
| action: () => alert("Top of list triggered!"), | ||||||||||||||||||||||||||||||
| }, | ||||||||||||||||||||||||||||||
| { | ||||||||||||||||||||||||||||||
| label: "Test", | ||||||||||||||||||||||||||||||
| shortcut: [ | ||||||||||||||||||||||||||||||
| ["meta", "u"], // Cmd+T | ||||||||||||||||||||||||||||||
| ["meta", "y"], // Cmd+Y | ||||||||||||||||||||||||||||||
| ], | ||||||||||||||||||||||||||||||
| connector: "+", | ||||||||||||||||||||||||||||||
| action: () => alert("Test shortcut triggered!"), | ||||||||||||||||||||||||||||||
| description: "Trigger test action with Cmd+U or Cmd+Y", | ||||||||||||||||||||||||||||||
| }, | ||||||||||||||||||||||||||||||
| ]; | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| // Utility: highlight search match | ||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||
| function highlightMatch(text: string, query: string): React.ReactNode { | ||||||||||||||||||||||||||||||
| if (!query) return text; | ||||||||||||||||||||||||||||||
| const regex = new RegExp(`(${query})`, "gi"); | ||||||||||||||||||||||||||||||
| return text | ||||||||||||||||||||||||||||||
| .split(regex) | ||||||||||||||||||||||||||||||
| .map((part, i) => | ||||||||||||||||||||||||||||||
| part.toLowerCase() === query.toLowerCase() ? ( | ||||||||||||||||||||||||||||||
| <strong key={i}>{part}</strong> | ||||||||||||||||||||||||||||||
| ) : ( | ||||||||||||||||||||||||||||||
| part | ||||||||||||||||||||||||||||||
| ), | ||||||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| function displayKeyName(key: string) { | ||||||||||||||||||||||||||||||
| if (key === "meta") return "ctrl"; | ||||||||||||||||||||||||||||||
| return key; | ||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| // Table row for a shortcut | ||||||||||||||||||||||||||||||
| const ShortcutRow: React.FC<{ shortcut: KeyboardShortcut; filter: string }> = ({ | ||||||||||||||||||||||||||||||
| shortcut, | ||||||||||||||||||||||||||||||
| filter, | ||||||||||||||||||||||||||||||
| }) => ( | ||||||||||||||||||||||||||||||
| <TR> | ||||||||||||||||||||||||||||||
| <TD> | ||||||||||||||||||||||||||||||
| <StackLayout gap={0.5}> | ||||||||||||||||||||||||||||||
| <Text>{highlightMatch(shortcut.label, filter)}</Text> | ||||||||||||||||||||||||||||||
| {shortcut.description && ( | ||||||||||||||||||||||||||||||
| <Label color="secondary">{shortcut.description}</Label> | ||||||||||||||||||||||||||||||
| )} | ||||||||||||||||||||||||||||||
| </StackLayout> | ||||||||||||||||||||||||||||||
| </TD> | ||||||||||||||||||||||||||||||
| <TD> | ||||||||||||||||||||||||||||||
| <StackLayout gap={0.5}> | ||||||||||||||||||||||||||||||
| {shortcut.shortcut.map((comboArr, comboIdx) => ( | ||||||||||||||||||||||||||||||
| <FlexLayout | ||||||||||||||||||||||||||||||
| align="center" | ||||||||||||||||||||||||||||||
| gap={0.5} | ||||||||||||||||||||||||||||||
| key={comboArr.join("-") + comboIdx} | ||||||||||||||||||||||||||||||
| > | ||||||||||||||||||||||||||||||
| {comboArr.map((keyName, idx) => ( | ||||||||||||||||||||||||||||||
| <React.Fragment key={keyName + idx}> | ||||||||||||||||||||||||||||||
| <KeyboardKey aria-label={displayKeyName(keyName)}> | ||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||
| {displayKeyName(keyName)} | ||||||||||||||||||||||||||||||
| </KeyboardKey> | ||||||||||||||||||||||||||||||
| {idx < comboArr.length - 1 && <Text>{shortcut.connector}</Text>} | ||||||||||||||||||||||||||||||
| </React.Fragment> | ||||||||||||||||||||||||||||||
| ))} | ||||||||||||||||||||||||||||||
| </FlexLayout> | ||||||||||||||||||||||||||||||
| ))} | ||||||||||||||||||||||||||||||
| </StackLayout> | ||||||||||||||||||||||||||||||
| </TD> | ||||||||||||||||||||||||||||||
| </TR> | ||||||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| // Register all hotkeys when enabled | ||||||||||||||||||||||||||||||
| const RegisterShortcuts: React.FC<{ enabled: boolean }> = ({ enabled }) => { | ||||||||||||||||||||||||||||||
| keyboardShortcuts.forEach((shortcut) => { | ||||||||||||||||||||||||||||||
| shortcut.shortcut.forEach((comboArr) => { | ||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||
| const combo = comboArr.join("+"); | ||||||||||||||||||||||||||||||
| useHotkeys( | ||||||||||||||||||||||||||||||
|
Check failure on line 156 in packages/core/stories/patterns/keyboard-shortcuts/keyboard-shortcuts.stories.tsx
|
||||||||||||||||||||||||||||||
| combo, | ||||||||||||||||||||||||||||||
| (event) => { | ||||||||||||||||||||||||||||||
| event.preventDefault(); | ||||||||||||||||||||||||||||||
| if (enabled) shortcut.action(); | ||||||||||||||||||||||||||||||
| }, | ||||||||||||||||||||||||||||||
| { enabled }, | ||||||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||
| return null; | ||||||||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| const KeyboardShortcuts: React.FC = () => { | ||||||||||||||||||||||||||||||
| const [open, setOpen] = useState(false); | ||||||||||||||||||||||||||||||
| const [shortcutsEnabled, setShortcutsEnabled] = useState(true); | ||||||||||||||||||||||||||||||
| const [filter, setFilter] = useState(""); | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| useHotkeys( | ||||||||||||||||||||||||||||||
| "meta+shift+k", // To open the keyboard shortcut key panel | ||||||||||||||||||||||||||||||
| (event) => { | ||||||||||||||||||||||||||||||
| event.preventDefault(); | ||||||||||||||||||||||||||||||
| setOpen(true); | ||||||||||||||||||||||||||||||
| }, | ||||||||||||||||||||||||||||||
| { enabled: shortcutsEnabled }, | ||||||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| const filteredShortcuts = keyboardShortcuts.filter((s) => | ||||||||||||||||||||||||||||||
| s.label.toLowerCase().includes(filter.trim().toLowerCase()), | ||||||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| // Handlers | ||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||
| const handleDialogOpen = () => setOpen(true); | ||||||||||||||||||||||||||||||
| const handleDialogChange = (value: boolean) => setOpen(value); | ||||||||||||||||||||||||||||||
| const handleSwitchChange = (event: ChangeEvent<HTMLInputElement>) => | ||||||||||||||||||||||||||||||
| setShortcutsEnabled(event.target.checked); | ||||||||||||||||||||||||||||||
| const handleFilterChange = (event: ChangeEvent<HTMLInputElement>) => | ||||||||||||||||||||||||||||||
| setFilter(event.target.value); | ||||||||||||||||||||||||||||||
| const handleFilterSelectionChange = ( | ||||||||||||||||||||||||||||||
| _: SyntheticEvent, | ||||||||||||||||||||||||||||||
| newSelected: string[], | ||||||||||||||||||||||||||||||
| ) => setFilter(newSelected.length === 1 ? newSelected[0] : ""); | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| return ( | ||||||||||||||||||||||||||||||
| <HotkeysProvider> | ||||||||||||||||||||||||||||||
| {/* Register all shortcuts, only when enabled */} | ||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||
| <RegisterShortcuts enabled={shortcutsEnabled} /> | ||||||||||||||||||||||||||||||
| <StackLayout gap={1}> | ||||||||||||||||||||||||||||||
| <Button data-testid="dialog-button" onClick={handleDialogOpen}> | ||||||||||||||||||||||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Should this toggle open/close?
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. But the dialog button is not visible when the dialog is open. Visually not able to toggle it. |
||||||||||||||||||||||||||||||
| Keyboard shortcuts panel | ||||||||||||||||||||||||||||||
| </Button> | ||||||||||||||||||||||||||||||
| <FlexLayout align="center" gap={1}> | ||||||||||||||||||||||||||||||
| <Text>hit </Text> | ||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||
| Term | Usage | Why |
|---|---|---|
| Press | ✅ Standard | Official term in WCAG, ARIA, and UI guidelines |
| Hit | ❌ Informal | Sounds aggressive, not professional |
Outdated
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Ctrl isn't the right key, you've set it to meta
Outdated
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
| <FlexLayout gap={1}> | |
| <Switch | |
| checked={shortcutsEnabled} | |
| onChange={handleSwitchChange} | |
| /> | |
| <FlexLayout className="keyboardShortcuts-description"> | |
| <Text>Turn on keyboard shortcuts</Text> | |
| </FlexLayout> | |
| </FlexLayout> | |
| <Switch | |
| checked={shortcutsEnabled} | |
| onChange={handleSwitchChange} | |
| label="Turn on keyboard shortcuts" | |
| /> |
Not sure if there was a specific reason we split the label out into a separate block, but I think this would be a bit cleaner + more consistent with the Switch pattern if we use the label prop. Also, doing it this way ensures the Switch has an accessible name. Thoughts?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
makes sense, i missed the api for switch. Have added the label as a part of switch.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
gap = 0.75?
Outdated
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Should this just be an input?
Outdated
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This control doesn't have an accessible label
Outdated
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
| startAdornment={<FilterIcon color="secondary" />} | |
| startAdornment={<FilterIcon color="secondary" aria-hidden="true" />} |
Should add aria-hidden to properly hide the SVG from screen readers.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,16 @@ | ||
| .saltKeyboardKey { | ||
| display: flex; | ||
| --saltText-fontFamily: var(--salt-text-code-fontFamily); | ||
| width: fit-content; | ||
| border-radius: var(--salt-palette-corner-weaker); | ||
| border: var(--salt-size-fixed-100) solid var(--salt-container-primary-borderColor); | ||
| background: var(--salt-container-primary-background); | ||
| box-shadow: 0 var(--salt-size-fixed-100) 0 0 var(--salt-container-primary-borderColor); | ||
| min-height: calc(var(--salt-size-base) - var(--salt-spacing-100)); | ||
| align-items: center; | ||
| padding: 0 var(--salt-spacing-50); | ||
| } | ||
|
|
||
| .saltKeyboardKey .saltText { | ||
| width: fit-content; | ||
| } |
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
| @@ -0,0 +1,31 @@ | ||||||
| import { makePrefixer, Text } from "@salt-ds/core"; | ||||||
| import { useComponentCssInjection } from "@salt-ds/styles"; | ||||||
| import { useWindow } from "@salt-ds/window"; | ||||||
| import { clsx } from "clsx"; | ||||||
| import { forwardRef, type HTMLAttributes, useEffect, useState } from "react"; | ||||||
| import { useIsViewportLargerThanBreakpoint } from "../utils"; | ||||||
|
|
||||||
| import keyboardKeyCss from "./KeyboardKey.css"; | ||||||
|
|
||||||
| export interface KeyboardKeyProps extends HTMLAttributes<HTMLElement> {} | ||||||
|
||||||
| export interface KeyboardKeyProps extends HTMLAttributes<HTMLElement> {} | |
| export interface KeyboardKeyProps extends ComponentPropsWithoutRef<"kbd"> {} |
Outdated
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This the see called KBD, like we name Table elements ?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I have renamed it to Kbd following the Pascal case in React. Let me know if it make sense to be KBD.
Outdated
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
| <kbd ref={ref} className={clsx(withBaseName(), className, {})} {...props}> | |
| <kbd ref={ref} className={clsx(withBaseName(), className)} {...props}> |
Outdated
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We don't need to use here we can just put the styling on the root element via CSS
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
dont use the text element and add the style to the kbd instead
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| export * from "./KeyboardKey"; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Treat changelogs as documentation, does every Salt user know what KS is ?
So I would say
Keyboard Shortcutsrather than KS and describe what the pattern is, so when I read the changelog, I would say, "I know what that is".