Skip to content

Commit

Permalink
Add KeybindingHint component (#4750)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
iansan5653 authored Aug 16, 2024
1 parent c578afc commit 414c140
Show file tree
Hide file tree
Showing 20 changed files with 736 additions and 2 deletions.
5 changes: 5 additions & 0 deletions .changeset/short-boats-cover.md
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
4 changes: 2 additions & 2 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,11 @@
"json.schemas": [
{
"fileMatch": ["*.docs.json"],
"url": "./script/components-json/component.schema.json"
"url": "./packages/react/script/components-json/component.schema.json"
},
{
"fileMatch": ["generated/components.json"],
"url": "./script/components-json/output.schema.json"
"url": "./packages/react/script/components-json/output.schema.json"
}
]
}
162 changes: 162 additions & 0 deletions docs/content/KeybindingHint.mdx
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,
}}
/>
2 changes: 2 additions & 0 deletions docs/src/@primer/gatsby-theme-doctocat/nav.yml
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,8 @@
url: /Heading
- title: IconButton
url: /IconButton
- title: KeybindingHint
url: /KeybindingHint
- title: Label
url: /Label
- title: LabelGroup
Expand Down
28 changes: 28 additions & 0 deletions packages/react/src/KeybindingHint/KeybindingHint.docs.json
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'},
}
9 changes: 9 additions & 0 deletions packages/react/src/KeybindingHint/KeybindingHint.stories.tsx
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'}}
48 changes: 48 additions & 0 deletions packages/react/src/KeybindingHint/KeybindingHint.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import React, {type ReactNode} from 'react'
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'

/**
* 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
Loading

0 comments on commit 414c140

Please sign in to comment.