Skip to content

Commit 8a37a6f

Browse files
authored
Create useIsMacOS hook for SSR support
1 parent cec4e50 commit 8a37a6f

File tree

7 files changed

+59
-29
lines changed

7 files changed

+59
-29
lines changed

packages/react/src/KeybindingHint/components/Chord.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,4 +64,7 @@ export const Chord = ({keys, format = 'condensed', variant = 'normal'}: Keybindi
6464
)
6565

6666
/** Plain string version of `Chord` for use in `aria` string attributes. */
67-
export const accessibleChordString = (chord: string) => splitChord(chord).map(accessibleKeyName).join(' ')
67+
export const accessibleChordString = (chord: string, isMacOS: boolean) =>
68+
splitChord(chord)
69+
.map(key => accessibleKeyName(key, isMacOS))
70+
.join(' ')

packages/react/src/KeybindingHint/components/Key.tsx

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,16 +2,21 @@ import React from 'react'
22
import VisuallyHidden from '../../_VisuallyHidden'
33
import {accessibleKeyName, condensedKeyName, fullKeyName} from '../key-names'
44
import type {KeybindingHintFormat} from '../props'
5+
import {useIsMacOS} from '../../hooks/useIsMacOS'
56

67
interface KeyProps {
78
name: string
89
format: KeybindingHintFormat
910
}
1011

1112
/** Renders a single key with accessible alternative text. */
12-
export const Key = ({name, format}: KeyProps) => (
13-
<>
14-
<VisuallyHidden>{accessibleKeyName(name)}</VisuallyHidden>
15-
<span aria-hidden>{format === 'condensed' ? condensedKeyName(name) : fullKeyName(name)}</span>
16-
</>
17-
)
13+
export const Key = ({name, format}: KeyProps) => {
14+
const isMacOS = useIsMacOS()
15+
16+
return (
17+
<>
18+
<VisuallyHidden>{accessibleKeyName(name, isMacOS)}</VisuallyHidden>
19+
<span aria-hidden>{format === 'condensed' ? condensedKeyName(name, isMacOS) : fullKeyName(name, isMacOS)}</span>
20+
</>
21+
)
22+
}

packages/react/src/KeybindingHint/components/Sequence.tsx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,5 +21,7 @@ export const Sequence = ({keys, format = 'condensed', variant = 'normal'}: Keybi
2121
))
2222

2323
/** Plain string version of `Sequence` for use in `aria` string attributes. */
24-
export const accessibleSequenceString = (sequence: string) =>
25-
splitSequence(sequence).map(accessibleChordString).join(', then ')
24+
export const accessibleSequenceString = (sequence: string, isMacOS: boolean) =>
25+
splitSequence(sequence)
26+
.map(chord => accessibleChordString(chord, isMacOS))
27+
.join(', then ')

packages/react/src/KeybindingHint/key-names.ts

Lines changed: 11 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,3 @@
1-
import {isMacOS} from '@primer/behaviors/utils'
2-
31
/** Converts the first character of the string to upper case and the remaining to lower case. */
42
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
53
const capitalize = ([first, ...rest]: string) => (first?.toUpperCase() ?? '') + rest.join('').toLowerCase()
@@ -8,19 +6,16 @@ const capitalize = ([first, ...rest]: string) => (first?.toUpperCase() ?? '') +
86
// would be realistically used in shortcuts. For example, the Pause/Break key is not necessary
97
// because it is not found on many keyboards.
108

11-
// These are methods instead of plain objects to delay calling isMacOS (which depends on
12-
// `window.navigator`) and avoid SSR issues
13-
149
/**
1510
* Short-form iconic versions of keys. These should be intuitive (not archaic) and match icons on keyboards.
1611
*/
17-
export const condensedKeyName = (key: string) =>
12+
export const condensedKeyName = (key: string, isMacOS: boolean) =>
1813
({
19-
alt: isMacOS() ? '⌥' : 'Alt', // the alt key _is_ the option key on MacOS - in the browser there is no "option" key
14+
alt: isMacOS ? '⌥' : 'Alt', // the alt key _is_ the option key on MacOS - in the browser there is no "option" key
2015
control: '⌃',
2116
shift: '⇧',
22-
meta: isMacOS() ? '⌘' : 'Win',
23-
mod: isMacOS() ? '⌘' : '⌃',
17+
meta: isMacOS ? '⌘' : 'Win',
18+
mod: isMacOS ? '⌘' : '⌃',
2419
pageup: 'PgUp',
2520
pagedown: 'PgDn',
2621
arrowup: '↑',
@@ -44,10 +39,10 @@ export const condensedKeyName = (key: string) =>
4439
* Specific key displays for 'full' format. We still do show some icons (ie punctuation)
4540
* because that's more intuitive, but for the rest of keys we show the standard key name.
4641
*/
47-
export const fullKeyName = (key: string) =>
42+
export const fullKeyName = (key: string, isMacOS: boolean) =>
4843
({
49-
alt: isMacOS() ? 'Option' : 'Alt',
50-
mod: isMacOS() ? 'Command' : 'Control',
44+
alt: isMacOS ? 'Option' : 'Alt',
45+
mod: isMacOS ? 'Command' : 'Control',
5146
'+': 'Plus',
5247
pageup: 'Page Up',
5348
pagedown: 'Page Down',
@@ -64,11 +59,11 @@ export const fullKeyName = (key: string) =>
6459
* readers from expressing punctuation in speech, ie, reading a long pause instead of the
6560
* word "period".
6661
*/
67-
export const accessibleKeyName = (key: string) =>
62+
export const accessibleKeyName = (key: string, isMacOS: boolean) =>
6863
({
69-
alt: isMacOS() ? 'option' : 'alt',
70-
meta: isMacOS() ? 'command' : 'Windows',
71-
mod: isMacOS() ? 'command' : 'control',
64+
alt: isMacOS ? 'option' : 'alt',
65+
meta: isMacOS ? 'command' : 'Windows',
66+
mod: isMacOS ? 'command' : 'control',
7267
// Screen readers may not be able to pronounce concatenated words - this provides a better experience
7368
pageup: 'page up',
7469
pagedown: 'page down',

packages/react/src/__tests__/KeybindingHint.test.tsx

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -77,13 +77,21 @@ describe('KeybindingHint', () => {
7777
})
7878

7979
describe('getAccessibleKeybindingHintString', () => {
80-
it('returns full readable key names', () => expect(getAccessibleKeybindingHintString('{')).toBe('left curly brace'))
80+
it('returns full readable key names', () =>
81+
expect(getAccessibleKeybindingHintString('{', false)).toBe('left curly brace'))
8182

82-
it('joins keys in a chord with space', () => expect(getAccessibleKeybindingHintString('Command+U')).toBe('command u'))
83+
it('joins keys in a chord with space', () =>
84+
expect(getAccessibleKeybindingHintString('Command+U', false)).toBe('command u'))
8385

8486
it('sorts modifiers in standard order', () =>
85-
expect(getAccessibleKeybindingHintString('Alt+Shift+Command+%')).toBe('alt shift command percent'))
87+
expect(getAccessibleKeybindingHintString('Alt+Shift+Command+%', false)).toBe('alt shift command percent'))
8688

8789
it('joins chords in a sequence with "then"', () =>
88-
expect(getAccessibleKeybindingHintString('Alt+9 x y')).toBe('alt 9, then x, then y'))
90+
expect(getAccessibleKeybindingHintString('Alt+9 x y', false)).toBe('alt 9, then x, then y'))
91+
92+
it('returns "command" for "mod" on MacOS', () =>
93+
expect(getAccessibleKeybindingHintString('Mod+x', true)).toBe('command x'))
94+
95+
it('returns "control" for "mod" on non-MacOS', () =>
96+
expect(getAccessibleKeybindingHintString('Mod+x', false)).toBe('control x'))
8997
})

packages/react/src/hooks/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,3 +15,4 @@ export {useMenuKeyboardNavigation} from './useMenuKeyboardNavigation'
1515
export {useMnemonics} from './useMnemonics'
1616
export {useRefObjectAsForwardedRef} from './useRefObjectAsForwardedRef'
1717
export {useId} from './useId'
18+
export {useIsMacOS} from './useIsMacOS'
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import {isMacOS as ssrUnsafeIsMacOS} from '@primer/behaviors/utils'
2+
import {useEffect, useState} from 'react'
3+
4+
/**
5+
* SSR-safe hook for determining if the current platform is MacOS. When rendering
6+
* server-side, will default to non-MacOS and then re-render in an effect if the
7+
* client turns out to be a MacOS device.
8+
*/
9+
export function useIsMacOS() {
10+
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
11+
const [isMacOS, setIsMacOS] = useState(() => (window !== undefined ? ssrUnsafeIsMacOS() : false))
12+
13+
useEffect(() => setIsMacOS(ssrUnsafeIsMacOS()), [])
14+
15+
return isMacOS
16+
}

0 commit comments

Comments
 (0)