Uh oh!
There was an error while loading. Please reload this page.
- Notifications
You must be signed in to change notification settings - Fork 677
Add KeybindingHint component#4750
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
Uh oh!
There was an error while loading. Please reload this page.
Changes from all commits
910415766dc77e04904cdcec4e508a37a6fb0e1e7a46c01359b131f3e0345aad7eb853daea8e641e1a66035d5e9cbefb0f69aa696253b9b3acbd25b0562fa203b6491626d29b6d0aedff96742a7ca0d77058d9fbc03d046c378c1eFile filter
Filter by extension
Conversations
Uh oh!
There was an error while loading. Please reload this page.
Jump to
Uh oh!
There was an error while loading. Please reload this page.
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 @@ | ||
| --- | ||
| '@primer/react': minor | ||
| --- | ||
| Add `KeybindingHint` component for indicating an available keyboard shortcut |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,162 @@ | ||
| --- | ||
| title: KeybindingHint | ||
| componentId: keybinding_hint | ||
| status: Draft | ||
| source: https://github.com/primer/react/tree/main/packages/react/src/KeybindingHint | ||
| storybook: '/react/storybook?path=/story/components-keybindinghint' | ||
| description: Indicates the presence of a keybinding available for an action. | ||
| --- | ||
| import data from '../../packages/react/src/KeybindingHint/KeybindingHint.docs.json' | ||
| import {ActionList, Button, Text, Box} from '@primer/react' | ||
| import {KeybindingHint} from '@primer/react/drafts' | ||
| import {TrashIcon} from '@primer/octicons-react' | ||
| Use `KeybindingHint` to make keyboard shortcuts discoverable. Can render visual keybinding hints in condensed (abbreviated) form or expanded form, and provides accessible alternative text for screen reader users. | ||
| <Box sx={{border: '1px solid', borderColor: 'border.default', borderRadius: 2, padding: 6, marginBottom: 3}}> | ||
| <ActionList sx={{width: 320}}> | ||
| <ActionList.Item> | ||
| Move down | ||
| <ActionList.TrailingVisual> | ||
| <KeybindingHint keys="Mod+ArrowDown" /> | ||
| </ActionList.TrailingVisual> | ||
| </ActionList.Item> | ||
| <ActionList.Item> | ||
| Unsubscribe | ||
| <ActionList.TrailingVisual> | ||
| <KeybindingHint keys="i j" /> | ||
| </ActionList.TrailingVisual> | ||
| </ActionList.Item> | ||
| <ActionList.Item variant="danger"> | ||
| <ActionList.LeadingVisual> | ||
| <TrashIcon /> | ||
| </ActionList.LeadingVisual> | ||
| Delete | ||
| <ActionList.TrailingVisual> | ||
| <KeybindingHint keys="Mod+Shift+Delete" /> | ||
| </ActionList.TrailingVisual> | ||
| </ActionList.Item> | ||
| </ActionList> | ||
| </Box> | ||
| ```js | ||
| import {KeybindingHint} from '@primer/react/drafts' | ||
| ``` | ||
| ## Examples | ||
| ### Single keys | ||
| Use the [full names of the keys as returned by `KeyboardEvent.key`](https://developer.mozilla.org/en-US/docs/Web/API/UI_Events/Keyboard_event_key_values). Key names are case-insensitive. | ||
| ```javascript live noinline | ||
| render( | ||
| <> | ||
| <KeybindingHint keys="a" /> <br /> | ||
| <KeybindingHint keys="B" /> <br /> | ||
| <KeybindingHint keys="ArrowLeft" /> <br /> | ||
| <KeybindingHint keys="shift" /> | ||
| </>, | ||
| ) | ||
| ``` | ||
| #### Special key names | ||
| Because the `+` and space characters are used to build chords and sequences as described below, their names must be spelled out to be used as keys. | ||
| ```javascript live noinline | ||
| render( | ||
| <> | ||
| <KeybindingHint keys="Plus" /> <br /> | ||
| <KeybindingHint keys="Space" /> | ||
| </>, | ||
| ) | ||
| ``` | ||
| ### Chords | ||
| _Chords_ are multiple keys that are pressed at the same time. Combine keys in a chord with `+`. Keys are automatically sorted into a standardized order so that modifiers come first. | ||
| ```javascript live noinline | ||
| render( | ||
| <> | ||
| <KeybindingHint keys="Alt+a" /> <br /> | ||
| <KeybindingHint keys="a+Alt" /> <br /> | ||
| <KeybindingHint keys="Control+Shift+ArrowUp" /> <br /> | ||
| <KeybindingHint keys="Meta+Shift+&" /> | ||
| </>, | ||
| ) | ||
| ``` | ||
| #### Platform-dependent modifier | ||
| Typical chords use `Command` on MacOS and `Control` on other devices. To automatically render `Command` or `Control` based on the user's operating system, use the special key name `Mod`. | ||
| ```javascript live noinline | ||
| render(<KeybindingHint keys="Mod+Shift+X" />) | ||
| ``` | ||
| ### Sequences | ||
| _Sequences_ are keys or chords that are pressed one after the other. Combine elements in a sequence with a space. For example, `a b` means "press a, then press b". | ||
| ```javascript live noinline | ||
| render( | ||
| <> | ||
| <KeybindingHint keys="a b" /> <br /> | ||
| <KeybindingHint keys="Mod+g ArrowLeft" /> | ||
| </>, | ||
| ) | ||
| ``` | ||
| ### Full display format | ||
| The default `condensed` format should be used on UI elements like buttons, menuitems, and inputs. In long-form text (prose), the `full` variant can be used instead to help the text flow better. | ||
| ```javascript live noinline | ||
| render( | ||
| <Text> | ||
| Press <KeybindingHint keys="Mod+Enter" format="ful" /> to submit the form. | ||
| </Text>, | ||
| ) | ||
| ``` | ||
| ### `onEmphasis` variant | ||
| When rendering on 'emphasis' colors, use the `onEmphasis` variant. | ||
| ```javascript live noinline | ||
| const CmdEnterHint = () => <KeybindingHint variant="onEmphasis" keys="Mod+Enter" /> | ||
| render( | ||
| <Button variant="primary" trailingVisual={CmdEnterHint}> | ||
| Submit | ||
| </Button>, | ||
| ) | ||
| ``` | ||
| ## Props | ||
| <ComponentProps data={data} /> | ||
| ## Status | ||
| <ComponentChecklist | ||
| items={{ | ||
| propsDocumented: true, | ||
| noUnnecessaryDeps: true, | ||
| adaptsToThemes: true, | ||
| adaptsToScreenSizes: true, | ||
| fullTestCoverage: true, | ||
| usedInProduction: true, | ||
| usageExamplesDocumented: true, | ||
| hasStorybookStories: true, | ||
| designReviewed: false, | ||
| a11yReviewed: false, | ||
| stableApi: false, | ||
| addressedApiFeedback: false, | ||
| hasDesignGuidelines: false, | ||
| hasFigmaComponent: false, | ||
| }} | ||
| /> |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -82,6 +82,8 @@ | ||
| url: /Heading | ||
| - title: IconButton | ||
| url: /IconButton | ||
| - title: KeybindingHint | ||
| url: /KeybindingHint | ||
| ||
| - title: Label | ||
| url: /Label | ||
| - title: LabelGroup | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,28 @@ | ||
| { | ||
| "id": "KeybindingHint", | ||
| "name": "KeybindingHint", | ||
| "status": "draft", | ||
| "a11yReviewed": false, | ||
| "stories": [], | ||
| "importPath": "@primer/react", | ||
| "props": [ | ||
| { | ||
| "name": "keys", | ||
| "type": "string", | ||
| "description": "The keys involved in this keybinding." | ||
| }, | ||
| { | ||
| "name": "format", | ||
| "type": "'condensed' | 'full'", | ||
| "defaultValue": "'condensed'", | ||
| "description": "Control the display format." | ||
| }, | ||
| { | ||
| "name": "variant", | ||
| "type": "'normal' | 'onEmphasis'", | ||
| "defaultValue": "'normal'", | ||
| "description": "Set to `onEmphasis` for display on 'emphasis' colors." | ||
| } | ||
| ], | ||
| "subcomponents": [] | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,64 @@ | ||
| import React from 'react' | ||
| import type {Meta, StoryObj} from '@storybook/react' | ||
| import {KeybindingHint, type KeybindingHintProps} from '.' | ||
| import {Button, ActionList, FormControl, TextInput} from '..' | ||
| export default { | ||
| title: 'Drafts/Components/KeybindingHint/Examples', | ||
| component: KeybindingHint, | ||
| } satisfies Meta<typeof KeybindingHint> | ||
| export const ButtonExample: StoryObj<KeybindingHintProps> = { | ||
| render: args => <Button trailingVisual={() => <KeybindingHint {...args} />}>Pull requests</Button>, | ||
| args: {keys: 'g p'}, | ||
| name: 'Button', | ||
| } | ||
| export const PrimaryButton: StoryObj<KeybindingHintProps> = { | ||
| render: args => ( | ||
| <Button variant="primary" trailingVisual={() => <KeybindingHint {...args} />}> | ||
| Submit | ||
| </Button> | ||
| ), | ||
| args: {keys: 'Mod+Enter', variant: 'onEmphasis'}, | ||
| } | ||
| export const ActionListExample: StoryObj<KeybindingHintProps> = { | ||
| render: args => ( | ||
| <ActionList sx={{maxWidth: '300px', border: '1px solid', borderColor: 'border.default', borderRadius: 2}}> | ||
| <ActionList.Item>Add comment</ActionList.Item> | ||
| <ActionList.Item> | ||
| Copy text{' '} | ||
| <ActionList.TrailingVisual> | ||
| <KeybindingHint {...args} /> | ||
| </ActionList.TrailingVisual> | ||
| </ActionList.Item> | ||
| <ActionList.Item>Cancel</ActionList.Item> | ||
| </ActionList> | ||
| ), | ||
| args: {keys: 'Mod+c'}, | ||
| name: 'ActionList', | ||
| } | ||
| export const Prose: StoryObj<KeybindingHintProps> = { | ||
| render: args => ( | ||
| <p> | ||
| Press <KeybindingHint {...args} /> to toggle between write and preview modes. | ||
| </p> | ||
| ), | ||
| args: { | ||
| keys: 'Mod+Shift+P', | ||
| format: 'full', | ||
| }, | ||
| } | ||
| export const TextInputExample: StoryObj<KeybindingHintProps> = { | ||
| render: args => ( | ||
| <FormControl> | ||
| <FormControl.Label visuallyHidden>Search</FormControl.Label> | ||
| <TextInput trailingVisual={() => <KeybindingHint {...args} />} placeholder="Search" /> | ||
| </FormControl> | ||
| ), | ||
| args: {keys: '/'}, | ||
| name: 'TextInput', | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,30 @@ | ||
| import React from 'react' | ||
| import type {Meta, StoryObj} from '@storybook/react' | ||
| import {KeybindingHint, type KeybindingHintProps} from '.' | ||
| import Box from '../Box' | ||
| export default { | ||
| title: 'Drafts/Components/KeybindingHint/Features', | ||
| component: KeybindingHint, | ||
| } satisfies Meta<typeof KeybindingHint> | ||
| const chord = 'Mod+Shift+K' | ||
| export const Condensed = {args: {keys: chord}} | ||
| export const Full = {args: {keys: chord, format: 'full'}} | ||
| const sequence = 'Mod+x y z' | ||
| export const SequenceCondensed = {args: {keys: sequence}} | ||
| export const SequenceFull = {args: {keys: sequence, format: 'full'}} | ||
| export const OnEmphasis: StoryObj<KeybindingHintProps> = { | ||
| render: args => ( | ||
| <Box sx={{backgroundColor: 'accent.fg', p: 3}}> | ||
| <KeybindingHint {...args} /> | ||
| </Box> | ||
| ), | ||
| args: {keys: chord, variant: 'onEmphasis'}, | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,9 @@ | ||
| import type {Meta} from '@storybook/react' | ||
| import {KeybindingHint} from './KeybindingHint' | ||
| export default { | ||
| title: 'Drafts/Components/KeybindingHint', | ||
| component: KeybindingHint, | ||
| } satisfies Meta<typeof KeybindingHint> | ||
| export const Default = {args: {keys: 'Mod+Shift+K'}} |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,48 @@ | ||
| import React, {type ReactNode} from 'react' | ||
iansan5653 marked this conversation as resolved.
Uh oh!There was an error while loading. Please reload this page. | ||
| import {memo} from 'react' | ||
| import Text from '../Text' | ||
| import type {KeybindingHintProps} from './props' | ||
| import {accessibleSequenceString, Sequence} from './components/Sequence' | ||
| /** `kbd` element with style resets. */ | ||
| const Kbd = ({children}: {children: ReactNode}) => ( | ||
| <Text | ||
| as={'kbd' as 'span'} | ||
| sx={{ | ||
| color: 'inherit', | ||
| fontFamily: 'inherit', | ||
| fontSize: 'inherit', | ||
| border: 'none', | ||
| background: 'none', | ||
| boxShadow: 'none', | ||
| p: 0, | ||
| lineHeight: 'unset', | ||
| position: 'relative', | ||
| overflow: 'visible', | ||
| verticalAlign: 'baseline', | ||
| textWrap: 'nowrap', | ||
| }} | ||
| > | ||
| {children} | ||
| </Text> | ||
| ) | ||
| /** Indicates the presence of an available keybinding. */ | ||
| // KeybindingHint is a good candidate for memoizing since props will rarely change | ||
| export const KeybindingHint = memo((props: KeybindingHintProps) => ( | ||
| <Kbd> | ||
| <Sequence {...props} /> | ||
| </Kbd> | ||
| )) | ||
| KeybindingHint.displayName = 'KeybindingHint' | ||
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. Why are we setting the ContributorAuthor 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. The engine can infer a function name for simple named declarations like But in this case we are declaring an anonymous function without a name, then sending it through | ||
| /** | ||
| * AVOID: `KeybindingHint` is nearly always sufficient for providing both visible and accessible keyboard hints, and | ||
| * will result in a good screen reader experience when used as the target for `aria-describedby` and `aria-labelledby`. | ||
| * However, there may be cases where we need a plain string version, such as when building `aria-label` or | ||
| * `aria-description`. In that case, this plain string builder can be used instead. | ||
| * | ||
| * NOTE that this string should _only_ be used when building `aria-label` or `aria-description` props (never rendered | ||
| * visibly) and should nearly always also be paired with a visible hint for sighted users. | ||
| */ | ||
| export const getAccessibleKeybindingHintString = accessibleSequenceString | ||
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.