From 414c140cf86b37cd0104cdc8b027636a57cc0127 Mon Sep 17 00:00:00 2001 From: Ian Sanders Date: Fri, 16 Aug 2024 10:11:44 -0400 Subject: [PATCH] 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 --- .changeset/short-boats-cover.md | 5 + .vscode/settings.json | 4 +- docs/content/KeybindingHint.mdx | 162 ++++++++++++++++++ .../src/@primer/gatsby-theme-doctocat/nav.yml | 2 + .../KeybindingHint/KeybindingHint.docs.json | 28 +++ .../KeybindingHint.examples.stories.tsx | 64 +++++++ .../KeybindingHint.features.stories.tsx | 30 ++++ .../KeybindingHint/KeybindingHint.stories.tsx | 9 + .../src/KeybindingHint/KeybindingHint.tsx | 48 ++++++ .../src/KeybindingHint/components/Chord.tsx | 70 ++++++++ .../src/KeybindingHint/components/Key.tsx | 22 +++ .../KeybindingHint/components/Sequence.tsx | 27 +++ packages/react/src/KeybindingHint/index.ts | 3 + .../react/src/KeybindingHint/key-names.ts | 113 ++++++++++++ packages/react/src/KeybindingHint/props.ts | 30 ++++ .../src/__tests__/KeybindingHint.test.tsx | 97 +++++++++++ .../__snapshots__/exports.test.ts.snap | 6 + packages/react/src/drafts/index.ts | 2 + packages/react/src/hooks/index.ts | 1 + packages/react/src/hooks/useIsMacOS.ts | 15 ++ 20 files changed, 736 insertions(+), 2 deletions(-) create mode 100644 .changeset/short-boats-cover.md create mode 100644 docs/content/KeybindingHint.mdx create mode 100644 packages/react/src/KeybindingHint/KeybindingHint.docs.json create mode 100644 packages/react/src/KeybindingHint/KeybindingHint.examples.stories.tsx create mode 100644 packages/react/src/KeybindingHint/KeybindingHint.features.stories.tsx create mode 100644 packages/react/src/KeybindingHint/KeybindingHint.stories.tsx create mode 100644 packages/react/src/KeybindingHint/KeybindingHint.tsx create mode 100644 packages/react/src/KeybindingHint/components/Chord.tsx create mode 100644 packages/react/src/KeybindingHint/components/Key.tsx create mode 100644 packages/react/src/KeybindingHint/components/Sequence.tsx create mode 100644 packages/react/src/KeybindingHint/index.ts create mode 100644 packages/react/src/KeybindingHint/key-names.ts create mode 100644 packages/react/src/KeybindingHint/props.ts create mode 100644 packages/react/src/__tests__/KeybindingHint.test.tsx create mode 100644 packages/react/src/hooks/useIsMacOS.ts diff --git a/.changeset/short-boats-cover.md b/.changeset/short-boats-cover.md new file mode 100644 index 00000000000..2fcf2d6f408 --- /dev/null +++ b/.changeset/short-boats-cover.md @@ -0,0 +1,5 @@ +--- +'@primer/react': minor +--- + +Add `KeybindingHint` component for indicating an available keyboard shortcut diff --git a/.vscode/settings.json b/.vscode/settings.json index 89c7eb34f7f..eb183dfaed1 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -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" } ] } diff --git a/docs/content/KeybindingHint.mdx b/docs/content/KeybindingHint.mdx new file mode 100644 index 00000000000..a1e4bdd9080 --- /dev/null +++ b/docs/content/KeybindingHint.mdx @@ -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. + + + + + Move down + + + + + + Unsubscribe + + + + + + + + + Delete + + + + + + + +```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( + <> +
+
+
+ + , +) +``` + +#### 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( + <> +
+ + , +) +``` + +### 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( + <> +
+
+
+ + , +) +``` + +#### 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() +``` + +### 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( + <> +
+ + , +) +``` + +### 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( + + Press to submit the form. + , +) +``` + +### `onEmphasis` variant + +When rendering on 'emphasis' colors, use the `onEmphasis` variant. + +```javascript live noinline +const CmdEnterHint = () => + +render( + , +) +``` + +## Props + + + +## Status + + diff --git a/docs/src/@primer/gatsby-theme-doctocat/nav.yml b/docs/src/@primer/gatsby-theme-doctocat/nav.yml index 3d4c3327b64..e2c5e49d499 100644 --- a/docs/src/@primer/gatsby-theme-doctocat/nav.yml +++ b/docs/src/@primer/gatsby-theme-doctocat/nav.yml @@ -82,6 +82,8 @@ url: /Heading - title: IconButton url: /IconButton + - title: KeybindingHint + url: /KeybindingHint - title: Label url: /Label - title: LabelGroup diff --git a/packages/react/src/KeybindingHint/KeybindingHint.docs.json b/packages/react/src/KeybindingHint/KeybindingHint.docs.json new file mode 100644 index 00000000000..136c41954ef --- /dev/null +++ b/packages/react/src/KeybindingHint/KeybindingHint.docs.json @@ -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": [] +} diff --git a/packages/react/src/KeybindingHint/KeybindingHint.examples.stories.tsx b/packages/react/src/KeybindingHint/KeybindingHint.examples.stories.tsx new file mode 100644 index 00000000000..f7c152df371 --- /dev/null +++ b/packages/react/src/KeybindingHint/KeybindingHint.examples.stories.tsx @@ -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 + +export const ButtonExample: StoryObj = { + render: args => , + args: {keys: 'g p'}, + name: 'Button', +} + +export const PrimaryButton: StoryObj = { + render: args => ( + + ), + args: {keys: 'Mod+Enter', variant: 'onEmphasis'}, +} + +export const ActionListExample: StoryObj = { + render: args => ( + + Add comment + + Copy text{' '} + + + + + Cancel + + ), + args: {keys: 'Mod+c'}, + name: 'ActionList', +} + +export const Prose: StoryObj = { + render: args => ( +

+ Press to toggle between write and preview modes. +

+ ), + args: { + keys: 'Mod+Shift+P', + format: 'full', + }, +} + +export const TextInputExample: StoryObj = { + render: args => ( + + Search + } placeholder="Search" /> + + ), + args: {keys: '/'}, + name: 'TextInput', +} diff --git a/packages/react/src/KeybindingHint/KeybindingHint.features.stories.tsx b/packages/react/src/KeybindingHint/KeybindingHint.features.stories.tsx new file mode 100644 index 00000000000..75448e75e41 --- /dev/null +++ b/packages/react/src/KeybindingHint/KeybindingHint.features.stories.tsx @@ -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 + +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 = { + render: args => ( + + + + ), + args: {keys: chord, variant: 'onEmphasis'}, +} diff --git a/packages/react/src/KeybindingHint/KeybindingHint.stories.tsx b/packages/react/src/KeybindingHint/KeybindingHint.stories.tsx new file mode 100644 index 00000000000..2452f5be70e --- /dev/null +++ b/packages/react/src/KeybindingHint/KeybindingHint.stories.tsx @@ -0,0 +1,9 @@ +import type {Meta} from '@storybook/react' +import {KeybindingHint} from './KeybindingHint' + +export default { + title: 'Drafts/Components/KeybindingHint', + component: KeybindingHint, +} satisfies Meta + +export const Default = {args: {keys: 'Mod+Shift+K'}} diff --git a/packages/react/src/KeybindingHint/KeybindingHint.tsx b/packages/react/src/KeybindingHint/KeybindingHint.tsx new file mode 100644 index 00000000000..82338b748ca --- /dev/null +++ b/packages/react/src/KeybindingHint/KeybindingHint.tsx @@ -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}) => ( + + {children} + +) + +/** 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) => ( + + + +)) +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 diff --git a/packages/react/src/KeybindingHint/components/Chord.tsx b/packages/react/src/KeybindingHint/components/Chord.tsx new file mode 100644 index 00000000000..d12ce8f44ff --- /dev/null +++ b/packages/react/src/KeybindingHint/components/Chord.tsx @@ -0,0 +1,70 @@ +import React, {Fragment} from 'react' +import Text from '../../Text' +import type {KeybindingHintProps} from '../props' +import {Key} from './Key' +import {accessibleKeyName} from '../key-names' + +/** + * Consistent sort order for modifier keys. There should never be more than one non-modifier + * key in a chord, so we don't need to worry about sorting those - we just put them at + * the end. + */ +const keySortPriorities: Partial> = { + control: 1, + meta: 2, + alt: 3, + option: 4, + shift: 5, + function: 6, +} + +const keySortPriority = (priority: string) => keySortPriorities[priority] ?? Infinity + +const compareLowercaseKeys = (a: string, b: string) => keySortPriority(a) - keySortPriority(b) + +/** Split and sort the chord keys in standard order. */ +const splitChord = (chord: string) => + chord + .split('+') + .map(k => k.toLowerCase()) + .sort(compareLowercaseKeys) + +export const Chord = ({keys, format = 'condensed', variant = 'normal'}: KeybindingHintProps) => ( + + {splitChord(keys).map((k, i) => ( + + {i > 0 && format === 'full' ? ( + + // hiding the plus sign helps screen readers be more concise + ) : ( + ' ' // space is nonvisual due to flex layout but critical for labelling / screen readers + )} + + + + ))} + +) + +/** Plain string version of `Chord` for use in `aria` string attributes. */ +export const accessibleChordString = (chord: string, isMacOS: boolean) => + splitChord(chord) + .map(key => accessibleKeyName(key, isMacOS)) + .join(' ') diff --git a/packages/react/src/KeybindingHint/components/Key.tsx b/packages/react/src/KeybindingHint/components/Key.tsx new file mode 100644 index 00000000000..eb803924f09 --- /dev/null +++ b/packages/react/src/KeybindingHint/components/Key.tsx @@ -0,0 +1,22 @@ +import React from 'react' +import VisuallyHidden from '../../_VisuallyHidden' +import {accessibleKeyName, condensedKeyName, fullKeyName} from '../key-names' +import type {KeybindingHintFormat} from '../props' +import {useIsMacOS} from '../../hooks/useIsMacOS' + +interface KeyProps { + name: string + format: KeybindingHintFormat +} + +/** Renders a single key with accessible alternative text. */ +export const Key = ({name, format}: KeyProps) => { + const isMacOS = useIsMacOS() + + return ( + <> + {accessibleKeyName(name, isMacOS)} + {format === 'condensed' ? condensedKeyName(name, isMacOS) : fullKeyName(name, isMacOS)} + + ) +} diff --git a/packages/react/src/KeybindingHint/components/Sequence.tsx b/packages/react/src/KeybindingHint/components/Sequence.tsx new file mode 100644 index 00000000000..aaa4ba00f0f --- /dev/null +++ b/packages/react/src/KeybindingHint/components/Sequence.tsx @@ -0,0 +1,27 @@ +import React, {Fragment} from 'react' +import type {KeybindingHintProps} from '../props' +import VisuallyHidden from '../../_VisuallyHidden' +import {accessibleChordString, Chord} from './Chord' + +const splitSequence = (sequence: string) => sequence.split(' ') + +export const Sequence = ({keys, format = 'condensed', variant = 'normal'}: KeybindingHintProps) => + splitSequence(keys).map((c, i) => ( + + { + // Since we audibly separate individual keys in chord with space, we need some other separator for chords in a sequence + i > 0 && ( + <> + then{' '} + + ) + } + + + )) + +/** Plain string version of `Sequence` for use in `aria` string attributes. */ +export const accessibleSequenceString = (sequence: string, isMacOS: boolean) => + splitSequence(sequence) + .map(chord => accessibleChordString(chord, isMacOS)) + .join(' then ') diff --git a/packages/react/src/KeybindingHint/index.ts b/packages/react/src/KeybindingHint/index.ts new file mode 100644 index 00000000000..dc5f4cb31d5 --- /dev/null +++ b/packages/react/src/KeybindingHint/index.ts @@ -0,0 +1,3 @@ +export * from './KeybindingHint' + +export type {KeybindingHintProps} from './props' diff --git a/packages/react/src/KeybindingHint/key-names.ts b/packages/react/src/KeybindingHint/key-names.ts new file mode 100644 index 00000000000..de5eb5e95bb --- /dev/null +++ b/packages/react/src/KeybindingHint/key-names.ts @@ -0,0 +1,113 @@ +/** Converts the first character of the string to upper case and the remaining to lower case. */ +// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition +const capitalize = ([first, ...rest]: string) => (first?.toUpperCase() ?? '') + rest.join('').toLowerCase() + +// In the below records, we don't intend to cover every single possible key - only those that +// would be realistically used in shortcuts. For example, the Pause/Break key is not necessary +// because it is not found on many keyboards. + +/** + * Short-form iconic versions of keys. These should be intuitive (not archaic) and match icons on keyboards. + */ +export const condensedKeyName = (key: string, isMacOS: boolean) => + ({ + alt: isMacOS ? '⌥' : 'Alt', // the alt key _is_ the option key on MacOS - in the browser there is no "option" key + control: '⌃', + shift: '⇧', + meta: isMacOS ? '⌘' : 'Win', + mod: isMacOS ? '⌘' : '⌃', + pageup: 'PgUp', + pagedown: 'PgDn', + arrowup: '↑', + arrowdown: '↓', + arrowleft: '←', + arrowright: '→', + plus: '+', // needed to allow +-separated chords + backspace: '⌫', + delete: 'Del', + space: '␣', // needed to allow space-separated sequences + tab: '⇥', + enter: '⏎', + escape: 'Esc', + function: 'Fn', + capslock: 'CapsLock', + insert: 'Ins', + printscreen: 'PrtScn', + })[key] ?? capitalize(key) + +/** + * Specific key displays for 'full' format. We still do show some icons (ie punctuation) + * because that's more intuitive, but for the rest of keys we show the standard key name. + */ +export const fullKeyName = (key: string, isMacOS: boolean) => + ({ + alt: isMacOS ? 'Option' : 'Alt', + mod: isMacOS ? 'Command' : 'Control', + '+': 'Plus', + pageup: 'Page Up', + pagedown: 'Page Down', + arrowup: 'Up Arrow', + arrowdown: 'Down Arrow', + arrowleft: 'Left Arrow', + arrowright: 'Right Arrow', + capslock: 'Caps Lock', + printscreen: 'Print Screen', + })[key] ?? capitalize(key) + +/** + * Accessible key names intended to be read by a screen reader. This prevents screen + * readers from expressing punctuation in speech, ie, reading a long pause instead of the + * word "period". + */ +export const accessibleKeyName = (key: string, isMacOS: boolean) => + ({ + alt: isMacOS ? 'option' : 'alt', + meta: isMacOS ? 'command' : 'Windows', + mod: isMacOS ? 'command' : 'control', + // Screen readers may not be able to pronounce concatenated words - this provides a better experience + pageup: 'page up', + pagedown: 'page down', + arrowup: 'up arrow', + arrowdown: 'down arrow', + arrowleft: 'left arrow', + arrowright: 'right arrow', + capslock: 'caps lock', + printscreen: 'print screen', + // We don't need to represent _every_ symbol - only those found on standard keyboards. + // Other symbols should be avoided as keyboard shortcuts anyway. + // These should match the colloqiual names of the keys, not the names of the symbols. Ie, + // "Equals" not "Equal Sign", "Dash" not "Minus", "Period" not "Dot", etc. + '`': 'backtick', + '~': 'tilde', + '!': 'exclamation point', + '@': 'at', + '#': 'hash', + $: 'dollar sign', + '%': 'percent', + '^': 'caret', + '&': 'ampersand', + '*': 'asterisk', + '(': 'left parenthesis', + ')': 'right parenthesis', + _: 'underscore', + '-': 'dash', + '+': 'plus', + '=': 'equals', + '[': 'left bracket', + '{': 'left curly brace', + ']': 'right bracket', + '}': 'right curly brace', + '\\': 'backslash', + '|': 'pipe', + ';': 'semicolon', + ':': 'colon', + "'": 'single quote', + '"': 'double quote', + ',': 'comma', + '<': 'left angle bracket', + '.': 'period', + '>': 'right angle bracket', + '/': 'forward slash', + '?': 'question mark', + ' ': 'space', + })[key] ?? key.toLowerCase() diff --git a/packages/react/src/KeybindingHint/props.ts b/packages/react/src/KeybindingHint/props.ts new file mode 100644 index 00000000000..72584086435 --- /dev/null +++ b/packages/react/src/KeybindingHint/props.ts @@ -0,0 +1,30 @@ +export type KeybindingHintFormat = 'condensed' | 'full' + +export type KeybindingHintVariant = 'normal' | 'onEmphasis' + +export interface KeybindingHintProps { + /** + * The keys involved in this keybinding. These should be the full names of the keys as would + * be returned by `KeyboardEvent.key` (e.g. "Control", "Shift", "ArrowUp", "a", etc.). + * + * Combine keys with the "+" character to form chords. To represent the "+" key, use "Plus". + * + * Combine chords/keys with " " to form sequences that should be pressed one after the other. For example, "a b" + * represents "a then b". To represent the " " key, use "Space". + * + * The fake key name "Mod" can be used to represent "Command" on macOS and "Control" on other platforms. + * + * See https://github.com/github/hotkey for format details. + */ + keys: string + /** + * Control the display format. Condensed is most useful in menus and tooltips, while + * the full form is better for prose. + * @default "condensed" + */ + format?: KeybindingHintFormat + /** + * Set to `onEmphasis` for display on emphasis colors. + */ + variant?: KeybindingHintVariant +} diff --git a/packages/react/src/__tests__/KeybindingHint.test.tsx b/packages/react/src/__tests__/KeybindingHint.test.tsx new file mode 100644 index 00000000000..79281964828 --- /dev/null +++ b/packages/react/src/__tests__/KeybindingHint.test.tsx @@ -0,0 +1,97 @@ +import React from 'react' +import {render, screen} from '@testing-library/react' + +import {KeybindingHint, getAccessibleKeybindingHintString} from '../KeybindingHint' + +describe('KeybindingHint', () => { + it('renders condensed keys by default', () => { + render() + for (const icon of ['⇧', '⌃', 'Fn', 'PgUp']) { + const el = screen.getByText(icon) + expect(el).toBeVisible() + expect(el).toHaveAttribute('aria-hidden') + } + }) + + it('renders accessible key descriptions', () => { + render() + for (const name of ['control', 'shift', 'left curly brace']) { + const el = screen.getByText(name) + expect(el).toBeInTheDocument() + expect(el).not.toHaveAttribute('aria-hidden') + } + }) + + it('renders key names in full format', () => { + render() + for (const name of ['Shift', 'Control', 'Function', 'Up Arrow']) { + const el = screen.getByText(name) + expect(el).toBeVisible() + expect(el).toHaveAttribute('aria-hidden') + } + }) + + it('sorts modifier keys', () => { + render() + const namesInOrder = ['Control', 'Shift', 'Function', 'Page Up'] + const names = screen.getAllByText(text => namesInOrder.includes(text)).map(el => el.textContent) + expect(names).toEqual(namesInOrder) + }) + + it('capitalizes other keys', () => { + render() + for (const key of ['⌃', 'A']) expect(screen.getByText(key)).toBeInTheDocument() + }) + + it.each([ + ['Plus', '+'], + ['Space', '␣'], + ])('renders %s as symbol in condensed mode', (name, symbol) => { + render() + expect(screen.getByText(symbol)).toBeInTheDocument() + }) + + it.each(['Plus', 'Space'])('renders %s as name in full format', name => { + render() + expect(screen.getByText(name)).toBeInTheDocument() + }) + + it('does not render plus signs in condensed mode', () => { + render() + expect(screen.queryByText('+')).not.toBeInTheDocument() + }) + + it('renders plus signs between keys in full format', () => { + render() + const plus = screen.getByText('+') + expect(plus).toBeVisible() + expect(plus).toHaveAttribute('aria-hidden') + }) + + it('renders sequences separated by hidden "then"', () => { + render() + const el = screen.getByText('then') + expect(el).toBeInTheDocument() + expect(el).not.toHaveAttribute('aria-hidden') + }) +}) + +describe('getAccessibleKeybindingHintString', () => { + it('returns full readable key names', () => + expect(getAccessibleKeybindingHintString('{', false)).toBe('left curly brace')) + + it('joins keys in a chord with space', () => + expect(getAccessibleKeybindingHintString('Command+U', false)).toBe('command u')) + + it('sorts modifiers in standard order', () => + expect(getAccessibleKeybindingHintString('Alt+Shift+Command+%', false)).toBe('alt shift command percent')) + + it('joins chords in a sequence with "then"', () => + expect(getAccessibleKeybindingHintString('Alt+9 x y', false)).toBe('alt 9 then x then y')) + + it('returns "command" for "mod" on MacOS', () => + expect(getAccessibleKeybindingHintString('Mod+x', true)).toBe('command x')) + + it('returns "control" for "mod" on non-MacOS', () => + expect(getAccessibleKeybindingHintString('Mod+x', false)).toBe('control x')) +}) diff --git a/packages/react/src/__tests__/__snapshots__/exports.test.ts.snap b/packages/react/src/__tests__/__snapshots__/exports.test.ts.snap index 6dc1aab3864..782198e5dc0 100644 --- a/packages/react/src/__tests__/__snapshots__/exports.test.ts.snap +++ b/packages/react/src/__tests__/__snapshots__/exports.test.ts.snap @@ -287,6 +287,7 @@ exports[`@primer/react/drafts should not update exports without a semver change "type Emoji", "type FileType", "type FileUploadResult", + "getAccessibleKeybindingHintString", "Hidden", "type HiddenProps", "InlineAutocomplete", @@ -294,6 +295,8 @@ exports[`@primer/react/drafts should not update exports without a semver change "InlineMessage", "type InlineMessageProps", "type InteractiveMarkdownViewerProps", + "KeybindingHint", + "type KeybindingHintProps", "MarkdownEditor", "type MarkdownEditorHandle", "type MarkdownEditorProps", @@ -405,6 +408,7 @@ exports[`@primer/react/experimental should not update exports without a semver c "type FileUploadResult", "FilteredActionList", "type FilteredActionListProps", + "getAccessibleKeybindingHintString", "Hidden", "type HiddenProps", "InlineAutocomplete", @@ -412,6 +416,8 @@ exports[`@primer/react/experimental should not update exports without a semver c "InlineMessage", "type InlineMessageProps", "type InteractiveMarkdownViewerProps", + "KeybindingHint", + "type KeybindingHintProps", "MarkdownEditor", "type MarkdownEditorHandle", "type MarkdownEditorProps", diff --git a/packages/react/src/drafts/index.ts b/packages/react/src/drafts/index.ts index 16a78befb4a..c57847c7ef1 100644 --- a/packages/react/src/drafts/index.ts +++ b/packages/react/src/drafts/index.ts @@ -85,3 +85,5 @@ export {UnderlinePanels} from './UnderlinePanels' export type {UnderlinePanelsProps, UnderlinePanelsTabProps, UnderlinePanelsPanelProps} from './UnderlinePanels' export {SkeletonBox, SkeletonText, SkeletonAvatar} from './Skeleton' + +export * from '../KeybindingHint' diff --git a/packages/react/src/hooks/index.ts b/packages/react/src/hooks/index.ts index 01d1d97c4aa..d8259f5fd96 100644 --- a/packages/react/src/hooks/index.ts +++ b/packages/react/src/hooks/index.ts @@ -15,3 +15,4 @@ export {useMenuKeyboardNavigation} from './useMenuKeyboardNavigation' export {useMnemonics} from './useMnemonics' export {useRefObjectAsForwardedRef} from './useRefObjectAsForwardedRef' export {useId} from './useId' +export {useIsMacOS} from './useIsMacOS' diff --git a/packages/react/src/hooks/useIsMacOS.ts b/packages/react/src/hooks/useIsMacOS.ts new file mode 100644 index 00000000000..d4982ee7fe8 --- /dev/null +++ b/packages/react/src/hooks/useIsMacOS.ts @@ -0,0 +1,15 @@ +import {isMacOS as ssrUnsafeIsMacOS} from '@primer/behaviors/utils' +import {useEffect, useState} from 'react' +import {canUseDOM} from '../utils/environment' +/** + * SSR-safe hook for determining if the current platform is MacOS. When rendering + * server-side, will default to non-MacOS and then re-render in an effect if the + * client turns out to be a MacOS device. + */ +export function useIsMacOS() { + const [isMacOS, setIsMacOS] = useState(() => (canUseDOM ? ssrUnsafeIsMacOS() : false)) + + useEffect(() => setIsMacOS(ssrUnsafeIsMacOS()), []) + + return isMacOS +}