Skip to content

Commit

Permalink
Create useIsMacOS hook for SSR support
Browse files Browse the repository at this point in the history
  • Loading branch information
iansan5653 authored Jul 18, 2024
1 parent cec4e50 commit 8a37a6f
Show file tree
Hide file tree
Showing 7 changed files with 59 additions and 29 deletions.
5 changes: 4 additions & 1 deletion packages/react/src/KeybindingHint/components/Chord.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -64,4 +64,7 @@ export const Chord = ({keys, format = 'condensed', variant = 'normal'}: Keybindi
)

/** Plain string version of `Chord` for use in `aria` string attributes. */
export const accessibleChordString = (chord: string) => splitChord(chord).map(accessibleKeyName).join(' ')
export const accessibleChordString = (chord: string, isMacOS: boolean) =>
splitChord(chord)
.map(key => accessibleKeyName(key, isMacOS))
.join(' ')
17 changes: 11 additions & 6 deletions packages/react/src/KeybindingHint/components/Key.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,21 @@ 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) => (
<>
<VisuallyHidden>{accessibleKeyName(name)}</VisuallyHidden>
<span aria-hidden>{format === 'condensed' ? condensedKeyName(name) : fullKeyName(name)}</span>
</>
)
export const Key = ({name, format}: KeyProps) => {
const isMacOS = useIsMacOS()

return (
<>
<VisuallyHidden>{accessibleKeyName(name, isMacOS)}</VisuallyHidden>
<span aria-hidden>{format === 'condensed' ? condensedKeyName(name, isMacOS) : fullKeyName(name, isMacOS)}</span>
</>
)
}
6 changes: 4 additions & 2 deletions packages/react/src/KeybindingHint/components/Sequence.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,5 +21,7 @@ export const Sequence = ({keys, format = 'condensed', variant = 'normal'}: Keybi
))

/** Plain string version of `Sequence` for use in `aria` string attributes. */
export const accessibleSequenceString = (sequence: string) =>
splitSequence(sequence).map(accessibleChordString).join(', then ')
export const accessibleSequenceString = (sequence: string, isMacOS: boolean) =>
splitSequence(sequence)
.map(chord => accessibleChordString(chord, isMacOS))
.join(', then ')
27 changes: 11 additions & 16 deletions packages/react/src/KeybindingHint/key-names.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
import {isMacOS} from '@primer/behaviors/utils'

/** 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()
Expand All @@ -8,19 +6,16 @@ const capitalize = ([first, ...rest]: string) => (first?.toUpperCase() ?? '') +
// would be realistically used in shortcuts. For example, the Pause/Break key is not necessary
// because it is not found on many keyboards.

// These are methods instead of plain objects to delay calling isMacOS (which depends on
// `window.navigator`) and avoid SSR issues

/**
* Short-form iconic versions of keys. These should be intuitive (not archaic) and match icons on keyboards.
*/
export const condensedKeyName = (key: string) =>
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
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() ? '⌘' : '⌃',
meta: isMacOS ? '⌘' : 'Win',
mod: isMacOS ? '⌘' : '⌃',
pageup: 'PgUp',
pagedown: 'PgDn',
arrowup: '↑',
Expand All @@ -44,10 +39,10 @@ export const condensedKeyName = (key: string) =>
* 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) =>
export const fullKeyName = (key: string, isMacOS: boolean) =>
({
alt: isMacOS() ? 'Option' : 'Alt',
mod: isMacOS() ? 'Command' : 'Control',
alt: isMacOS ? 'Option' : 'Alt',
mod: isMacOS ? 'Command' : 'Control',
'+': 'Plus',
pageup: 'Page Up',
pagedown: 'Page Down',
Expand All @@ -64,11 +59,11 @@ export const fullKeyName = (key: string) =>
* readers from expressing punctuation in speech, ie, reading a long pause instead of the
* word "period".
*/
export const accessibleKeyName = (key: string) =>
export const accessibleKeyName = (key: string, isMacOS: boolean) =>
({
alt: isMacOS() ? 'option' : 'alt',
meta: isMacOS() ? 'command' : 'Windows',
mod: isMacOS() ? 'command' : 'control',
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',
Expand Down
16 changes: 12 additions & 4 deletions packages/react/src/__tests__/KeybindingHint.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -77,13 +77,21 @@ describe('KeybindingHint', () => {
})

describe('getAccessibleKeybindingHintString', () => {
it('returns full readable key names', () => expect(getAccessibleKeybindingHintString('{')).toBe('left curly brace'))
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')).toBe('command u'))
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+%')).toBe('alt shift command percent'))
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')).toBe('alt 9, then x, then y'))
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'))
})
1 change: 1 addition & 0 deletions packages/react/src/hooks/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,3 +15,4 @@ export {useMenuKeyboardNavigation} from './useMenuKeyboardNavigation'
export {useMnemonics} from './useMnemonics'
export {useRefObjectAsForwardedRef} from './useRefObjectAsForwardedRef'
export {useId} from './useId'
export {useIsMacOS} from './useIsMacOS'
16 changes: 16 additions & 0 deletions packages/react/src/hooks/useIsMacOS.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import {isMacOS as ssrUnsafeIsMacOS} from '@primer/behaviors/utils'
import {useEffect, useState} from 'react'

/**
* 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() {
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
const [isMacOS, setIsMacOS] = useState(() => (window !== undefined ? ssrUnsafeIsMacOS() : false))

useEffect(() => setIsMacOS(ssrUnsafeIsMacOS()), [])

return isMacOS
}

0 comments on commit 8a37a6f

Please sign in to comment.