Skip to content

Commit 414c140

Browse files
authored
Add KeybindingHint component (#4750)
* Add `KeybindingHint` component * Split file and refactor a bit * Split components out into individual files * Update comments * Create `useIsMacOS` hook for SSR support * Add changelog * Format * Replace space with "space" * Update exports snapshot * derp, fix my dumb mistakes * Try `canUseDOM` instead of `window !== undefined` * Move to draft status * Move export to drafts * Separate out `features` stories and add `onEmphasis` story * Add examples stories * Tweak styles * Remove comma between chords * Update import in docs * Form & update tests * Update snapshots, again * Move stories to Drafts
1 parent c578afc commit 414c140

File tree

20 files changed

+736
-2
lines changed

20 files changed

+736
-2
lines changed

.changeset/short-boats-cover.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@primer/react': minor
3+
---
4+
5+
Add `KeybindingHint` component for indicating an available keyboard shortcut

.vscode/settings.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,11 +13,11 @@
1313
"json.schemas": [
1414
{
1515
"fileMatch": ["*.docs.json"],
16-
"url": "./script/components-json/component.schema.json"
16+
"url": "./packages/react/script/components-json/component.schema.json"
1717
},
1818
{
1919
"fileMatch": ["generated/components.json"],
20-
"url": "./script/components-json/output.schema.json"
20+
"url": "./packages/react/script/components-json/output.schema.json"
2121
}
2222
]
2323
}

docs/content/KeybindingHint.mdx

Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
---
2+
title: KeybindingHint
3+
componentId: keybinding_hint
4+
status: Draft
5+
source: https://github.com/primer/react/tree/main/packages/react/src/KeybindingHint
6+
storybook: '/react/storybook?path=/story/components-keybindinghint'
7+
description: Indicates the presence of a keybinding available for an action.
8+
---
9+
10+
import data from '../../packages/react/src/KeybindingHint/KeybindingHint.docs.json'
11+
import {ActionList, Button, Text, Box} from '@primer/react'
12+
import {KeybindingHint} from '@primer/react/drafts'
13+
import {TrashIcon} from '@primer/octicons-react'
14+
15+
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.
16+
17+
<Box sx={{border: '1px solid', borderColor: 'border.default', borderRadius: 2, padding: 6, marginBottom: 3}}>
18+
<ActionList sx={{width: 320}}>
19+
<ActionList.Item>
20+
Move down
21+
<ActionList.TrailingVisual>
22+
<KeybindingHint keys="Mod+ArrowDown" />
23+
</ActionList.TrailingVisual>
24+
</ActionList.Item>
25+
<ActionList.Item>
26+
Unsubscribe
27+
<ActionList.TrailingVisual>
28+
<KeybindingHint keys="i j" />
29+
</ActionList.TrailingVisual>
30+
</ActionList.Item>
31+
<ActionList.Item variant="danger">
32+
<ActionList.LeadingVisual>
33+
<TrashIcon />
34+
</ActionList.LeadingVisual>
35+
Delete
36+
<ActionList.TrailingVisual>
37+
<KeybindingHint keys="Mod+Shift+Delete" />
38+
</ActionList.TrailingVisual>
39+
</ActionList.Item>
40+
</ActionList>
41+
</Box>
42+
43+
```js
44+
import {KeybindingHint} from '@primer/react/drafts'
45+
```
46+
47+
## Examples
48+
49+
### Single keys
50+
51+
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.
52+
53+
```javascript live noinline
54+
render(
55+
<>
56+
<KeybindingHint keys="a" /> <br />
57+
<KeybindingHint keys="B" /> <br />
58+
<KeybindingHint keys="ArrowLeft" /> <br />
59+
<KeybindingHint keys="shift" />
60+
</>,
61+
)
62+
```
63+
64+
#### Special key names
65+
66+
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.
67+
68+
```javascript live noinline
69+
render(
70+
<>
71+
<KeybindingHint keys="Plus" /> <br />
72+
<KeybindingHint keys="Space" />
73+
</>,
74+
)
75+
```
76+
77+
### Chords
78+
79+
_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.
80+
81+
```javascript live noinline
82+
render(
83+
<>
84+
<KeybindingHint keys="Alt+a" /> <br />
85+
<KeybindingHint keys="a+Alt" /> <br />
86+
<KeybindingHint keys="Control+Shift+ArrowUp" /> <br />
87+
<KeybindingHint keys="Meta+Shift+&" />
88+
</>,
89+
)
90+
```
91+
92+
#### Platform-dependent modifier
93+
94+
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`.
95+
96+
```javascript live noinline
97+
render(<KeybindingHint keys="Mod+Shift+X" />)
98+
```
99+
100+
### Sequences
101+
102+
_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".
103+
104+
```javascript live noinline
105+
render(
106+
<>
107+
<KeybindingHint keys="a b" /> <br />
108+
<KeybindingHint keys="Mod+g ArrowLeft" />
109+
</>,
110+
)
111+
```
112+
113+
### Full display format
114+
115+
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.
116+
117+
```javascript live noinline
118+
render(
119+
<Text>
120+
Press <KeybindingHint keys="Mod+Enter" format="ful" /> to submit the form.
121+
</Text>,
122+
)
123+
```
124+
125+
### `onEmphasis` variant
126+
127+
When rendering on 'emphasis' colors, use the `onEmphasis` variant.
128+
129+
```javascript live noinline
130+
const CmdEnterHint = () => <KeybindingHint variant="onEmphasis" keys="Mod+Enter" />
131+
132+
render(
133+
<Button variant="primary" trailingVisual={CmdEnterHint}>
134+
Submit
135+
</Button>,
136+
)
137+
```
138+
139+
## Props
140+
141+
<ComponentProps data={data} />
142+
143+
## Status
144+
145+
<ComponentChecklist
146+
items={{
147+
propsDocumented: true,
148+
noUnnecessaryDeps: true,
149+
adaptsToThemes: true,
150+
adaptsToScreenSizes: true,
151+
fullTestCoverage: true,
152+
usedInProduction: true,
153+
usageExamplesDocumented: true,
154+
hasStorybookStories: true,
155+
designReviewed: false,
156+
a11yReviewed: false,
157+
stableApi: false,
158+
addressedApiFeedback: false,
159+
hasDesignGuidelines: false,
160+
hasFigmaComponent: false,
161+
}}
162+
/>

docs/src/@primer/gatsby-theme-doctocat/nav.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,8 @@
8282
url: /Heading
8383
- title: IconButton
8484
url: /IconButton
85+
- title: KeybindingHint
86+
url: /KeybindingHint
8587
- title: Label
8688
url: /Label
8789
- title: LabelGroup
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
{
2+
"id": "KeybindingHint",
3+
"name": "KeybindingHint",
4+
"status": "draft",
5+
"a11yReviewed": false,
6+
"stories": [],
7+
"importPath": "@primer/react",
8+
"props": [
9+
{
10+
"name": "keys",
11+
"type": "string",
12+
"description": "The keys involved in this keybinding."
13+
},
14+
{
15+
"name": "format",
16+
"type": "'condensed' | 'full'",
17+
"defaultValue": "'condensed'",
18+
"description": "Control the display format."
19+
},
20+
{
21+
"name": "variant",
22+
"type": "'normal' | 'onEmphasis'",
23+
"defaultValue": "'normal'",
24+
"description": "Set to `onEmphasis` for display on 'emphasis' colors."
25+
}
26+
],
27+
"subcomponents": []
28+
}
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
import React from 'react'
2+
import type {Meta, StoryObj} from '@storybook/react'
3+
import {KeybindingHint, type KeybindingHintProps} from '.'
4+
import {Button, ActionList, FormControl, TextInput} from '..'
5+
6+
export default {
7+
title: 'Drafts/Components/KeybindingHint/Examples',
8+
component: KeybindingHint,
9+
} satisfies Meta<typeof KeybindingHint>
10+
11+
export const ButtonExample: StoryObj<KeybindingHintProps> = {
12+
render: args => <Button trailingVisual={() => <KeybindingHint {...args} />}>Pull requests</Button>,
13+
args: {keys: 'g p'},
14+
name: 'Button',
15+
}
16+
17+
export const PrimaryButton: StoryObj<KeybindingHintProps> = {
18+
render: args => (
19+
<Button variant="primary" trailingVisual={() => <KeybindingHint {...args} />}>
20+
Submit
21+
</Button>
22+
),
23+
args: {keys: 'Mod+Enter', variant: 'onEmphasis'},
24+
}
25+
26+
export const ActionListExample: StoryObj<KeybindingHintProps> = {
27+
render: args => (
28+
<ActionList sx={{maxWidth: '300px', border: '1px solid', borderColor: 'border.default', borderRadius: 2}}>
29+
<ActionList.Item>Add comment</ActionList.Item>
30+
<ActionList.Item>
31+
Copy text{' '}
32+
<ActionList.TrailingVisual>
33+
<KeybindingHint {...args} />
34+
</ActionList.TrailingVisual>
35+
</ActionList.Item>
36+
<ActionList.Item>Cancel</ActionList.Item>
37+
</ActionList>
38+
),
39+
args: {keys: 'Mod+c'},
40+
name: 'ActionList',
41+
}
42+
43+
export const Prose: StoryObj<KeybindingHintProps> = {
44+
render: args => (
45+
<p>
46+
Press <KeybindingHint {...args} /> to toggle between write and preview modes.
47+
</p>
48+
),
49+
args: {
50+
keys: 'Mod+Shift+P',
51+
format: 'full',
52+
},
53+
}
54+
55+
export const TextInputExample: StoryObj<KeybindingHintProps> = {
56+
render: args => (
57+
<FormControl>
58+
<FormControl.Label visuallyHidden>Search</FormControl.Label>
59+
<TextInput trailingVisual={() => <KeybindingHint {...args} />} placeholder="Search" />
60+
</FormControl>
61+
),
62+
args: {keys: '/'},
63+
name: 'TextInput',
64+
}
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import React from 'react'
2+
import type {Meta, StoryObj} from '@storybook/react'
3+
import {KeybindingHint, type KeybindingHintProps} from '.'
4+
import Box from '../Box'
5+
6+
export default {
7+
title: 'Drafts/Components/KeybindingHint/Features',
8+
component: KeybindingHint,
9+
} satisfies Meta<typeof KeybindingHint>
10+
11+
const chord = 'Mod+Shift+K'
12+
13+
export const Condensed = {args: {keys: chord}}
14+
15+
export const Full = {args: {keys: chord, format: 'full'}}
16+
17+
const sequence = 'Mod+x y z'
18+
19+
export const SequenceCondensed = {args: {keys: sequence}}
20+
21+
export const SequenceFull = {args: {keys: sequence, format: 'full'}}
22+
23+
export const OnEmphasis: StoryObj<KeybindingHintProps> = {
24+
render: args => (
25+
<Box sx={{backgroundColor: 'accent.fg', p: 3}}>
26+
<KeybindingHint {...args} />
27+
</Box>
28+
),
29+
args: {keys: chord, variant: 'onEmphasis'},
30+
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import type {Meta} from '@storybook/react'
2+
import {KeybindingHint} from './KeybindingHint'
3+
4+
export default {
5+
title: 'Drafts/Components/KeybindingHint',
6+
component: KeybindingHint,
7+
} satisfies Meta<typeof KeybindingHint>
8+
9+
export const Default = {args: {keys: 'Mod+Shift+K'}}
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import React, {type ReactNode} from 'react'
2+
import {memo} from 'react'
3+
import Text from '../Text'
4+
import type {KeybindingHintProps} from './props'
5+
import {accessibleSequenceString, Sequence} from './components/Sequence'
6+
7+
/** `kbd` element with style resets. */
8+
const Kbd = ({children}: {children: ReactNode}) => (
9+
<Text
10+
as={'kbd' as 'span'}
11+
sx={{
12+
color: 'inherit',
13+
fontFamily: 'inherit',
14+
fontSize: 'inherit',
15+
border: 'none',
16+
background: 'none',
17+
boxShadow: 'none',
18+
p: 0,
19+
lineHeight: 'unset',
20+
position: 'relative',
21+
overflow: 'visible',
22+
verticalAlign: 'baseline',
23+
textWrap: 'nowrap',
24+
}}
25+
>
26+
{children}
27+
</Text>
28+
)
29+
30+
/** Indicates the presence of an available keybinding. */
31+
// KeybindingHint is a good candidate for memoizing since props will rarely change
32+
export const KeybindingHint = memo((props: KeybindingHintProps) => (
33+
<Kbd>
34+
<Sequence {...props} />
35+
</Kbd>
36+
))
37+
KeybindingHint.displayName = 'KeybindingHint'
38+
39+
/**
40+
* AVOID: `KeybindingHint` is nearly always sufficient for providing both visible and accessible keyboard hints, and
41+
* will result in a good screen reader experience when used as the target for `aria-describedby` and `aria-labelledby`.
42+
* However, there may be cases where we need a plain string version, such as when building `aria-label` or
43+
* `aria-description`. In that case, this plain string builder can be used instead.
44+
*
45+
* NOTE that this string should _only_ be used when building `aria-label` or `aria-description` props (never rendered
46+
* visibly) and should nearly always also be paired with a visible hint for sighted users.
47+
*/
48+
export const getAccessibleKeybindingHintString = accessibleSequenceString

0 commit comments

Comments
 (0)