-
Notifications
You must be signed in to change notification settings - Fork 616
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
Changes from all commits
9104157
66dc77e
04904cd
cec4e50
8a37a6f
b0e1e7a
46c0135
9b131f3
e0345aa
d7eb853
daea8e6
41e1a66
035d5e9
cbefb0f
69aa696
253b9b3
acbd25b
0562fa2
03b6491
626d29b
6d0aedf
f96742a
7ca0d77
058d9fb
c03d046
c378c1e
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 @@ | ||
--- | ||
'@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 | ||
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 be under drafts as well in the nav? (non blocking because these docs are going away soon anyways in favor of https://primer.style/components) |
||
- 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.
Show resolved
Hide resolved
|
||
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' | ||
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 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.