Skip to content

Commit

Permalink
Merge pull request #704 from Atom-Learning/feat/keyboard-shortcut
Browse files Browse the repository at this point in the history
Feat/keyboard shortcut
  • Loading branch information
LimeWub authored Sep 18, 2024
2 parents 184277c + c12144a commit 884ec90
Show file tree
Hide file tree
Showing 6 changed files with 306 additions and 0 deletions.
106 changes: 106 additions & 0 deletions documentation/content/components.keyboard-shortcut.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
---
slug: keyboard-shortcut
title: Keyboard Shortcut
links:
showReportAnIssue: true
tabs:
- title: Code
content: >-
Utility component to allow for consistently handling keyDown user events
to perform actions.
A `KeyboardShortcut.Indicator` is also exported. This is a simple styled `<Text as="kbd" />` element but the goal is to maintain consistency across uses.
<CodeBlock live={true} preview={true} code={`<KeyboardShortcut css={{ width: '100%', maxWidth: '100%', background: '$grey700', color: 'white', p: '$4' }} tabIndex="0" config={[
{
shortcut: { key: 's', metaKey: true },
action: ({ event }) => {
event.stopPropagation()
event.preventDefault()
alert('You tried to save!')
}
},
{
shortcut: { key: 'a' },
action: ({event}) => {
event.stopPropagation()
alert('A')
}
}
]}
>
<Text>Click on this area, then press <KeyboardShortcut.Indicator>⌘ + S</KeyboardShortcut.Indicator> to save </Text>
</KeyboardShortcut>`} language={"tsx"} />
## Config
The `KeyboardShortcut` must be given a config. The config is an array of objects including a `shortcut`, which is a partial `KeyboardEvent` to match on and an `action`, which is the function to call when a `KeyboardEvent` is matched.
<CodeBlock live={true} preview={true} code={`[
{
shortcut: { key: 'a', metaKey: true },
action: () => setValueM(toggleFromArray("1", valueM))
},
{
shortcut: { key: '1' },
action: () => setValueM(toggleFromArray("1", valueM))
},
{
shortcut: { key: 'b', metaKey: true },
action: () => setValueM(toggleFromArray("2", valueM))
},
{
shortcut: { key: '2' },
action: () => setValueM(toggleFromArray("2", valueM))
}
]`} language={"ts"} />
## Targeting events on the window
A `KeyboardShortcut` is a container so by default it listens for key events within itself. To listen on events on the window, use the `targetWindow` prop.
<CodeBlock live={true} preview={true} code={`<KeyboardShortcut
targetWindow tabIndex="0" config={[
{
shortcut: { key: 's', metaKey: true },
action: ({ event }) => {
event.preventDefault()
alert('You tried to save (window)!')
}
},
{
shortcut: { key: 'a' },
action: () => {
alert('A (window)')
}
}
]}
>
<Text>Press <KeyboardShortcut.Indicator>⌘ + S</KeyboardShortcut.Indicator> to save (anywhere on the window)</Text>
</KeyboardShortcut>`} language={"tsx"} />
## API Reference
<ComponentProps component="KeyboardShortcut" />
<ComponentProps component="KeyboardShortcut.Indicator" />
parent: J3bsmpB7-_uuqm05peuTA
uuid: b7a97ff6-417a-4258-bf43-8cfa5362a81f
nestedSlug:
- components
- keyboard-shortcut
---
1 change: 1 addition & 0 deletions lib/src/components/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,3 +84,4 @@ export { Tooltip } from './tooltip'
export { TopBar } from './top-bar'
export { Tree } from './tree'
export { Video } from './video'
export { KeyboardShortcut } from './keyboard-shortcut'
49 changes: 49 additions & 0 deletions lib/src/components/keyboard-shortcut/KeyboardShortcut.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { render, screen, fireEvent } from '@testing-library/react'
import React from 'react'
import { KeyboardShortcut } from '.'

const onKeyboardShortcutMock = jest.fn()

describe(`KeyboardShortcut component`, () => {
it('renders', async () => {
const { container } = render(<KeyboardShortcut config={[{
shortcut: { key: 'a' },
action: onKeyboardShortcutMock
}]}><KeyboardShortcut.Indicator>Meta + A</KeyboardShortcut.Indicator></KeyboardShortcut>)

expect(container).toMatchSnapshot()
})

it('works when targeting window', async () => {
render(<KeyboardShortcut
config={[{
shortcut: { key: 'a' },
action: onKeyboardShortcutMock
}]}
targetWindow
>just type</KeyboardShortcut>)

await fireEvent.keyDown(window, {
key: "a"
})
expect(onKeyboardShortcutMock).toHaveBeenCalledWith(expect.objectContaining({ shortcut: { key: 'a' } }))
})

it('works when targeting element', async () => {
render(<KeyboardShortcut
config={[{
shortcut: { key: 'a' },
action: onKeyboardShortcutMock
}]}
data-testid="keyboard-shortcut-capture-box"
tabIndex="0"
>focus this and type</KeyboardShortcut>)

const keyboardCaptureBox = screen.getByTestId('keyboard-shortcut-capture-box')
await fireEvent.keyDown(keyboardCaptureBox, {
key: "a"
})
expect(onKeyboardShortcutMock).toHaveBeenCalledWith(expect.objectContaining({ shortcut: { key: 'a' } }))
})

})
78 changes: 78 additions & 0 deletions lib/src/components/keyboard-shortcut/KeyboardShortcut.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { Slot } from '@radix-ui/react-slot'
import * as React from 'react'

import { styled } from '~/stitches'
import { useCallbackRefState } from '~/utilities/hooks/useCallbackRef'

import { Box } from '../box'
import { Text } from '../text'

const StyledSlot = styled(Slot)

type KeyboardEventWindowOrElement = KeyboardEvent | React.KeyboardEvent<HTMLDivElement>

type KeyboardShortcutProps = React.ComponentProps<typeof Box> & {
asChild?: boolean
config: {
shortcut: Partial<React.SyntheticEvent<KeyboardEvent>>, action: ({ event, shortcut }: {
event: KeyboardEvent | React.KeyboardEvent<HTMLDivElement>,
shortcut: Partial<React.SyntheticEvent<KeyboardEvent>>
}) => void
}[],
targetWindow?: boolean
onKeyDown?: (e: KeyboardEventWindowOrElement) => void
}

export const KeyboardShortcut: React.ForwardRefExoticComponent<KeyboardShortcutProps> =
React.forwardRef(({ asChild, config, targetWindow = false, onKeyDown, ...rest }, ref) => {
const [targetElRef, setTargetElRef] = useCallbackRefState()
React.useImperativeHandle(ref, () => targetElRef as HTMLDivElement)

const handleOnKeydown = React.useCallback((e: KeyboardEventWindowOrElement) => {
config.forEach(({ shortcut, action }) => {
if (Object.entries(shortcut).every(([key, value]) => e[key] === value)) action({ event: e, shortcut })
})
onKeyDown?.(e)
}, [config, onKeyDown])

React.useEffect(() => {
if (targetWindow) window.addEventListener('keydown', handleOnKeydown)

return () => {
window.removeEventListener('keydown', handleOnKeydown)
}

}, [targetWindow, handleOnKeydown])

const Component = asChild ? StyledSlot : Box

return (<Component onKeyDown={targetWindow ? undefined : handleOnKeydown} ref={setTargetElRef} {...rest} />)
})

KeyboardShortcut.displayName = 'KeyboardShortcut'

const StyledKeyboardShortcutIndicator = styled.withConfig({
shouldForwardStitchesProp: (propName) => ['as'].includes(propName)
})(Text, {
bg: '$grey100',
color: '$textSubtle',
px: '$2',
py: '$0',
minWidth: '$2',
minHeight: '$2',
fontWeight: 400,
display: 'inline-flex',
justifyContent: 'center',
alignItems: 'center',
borderRadius: '$1',
flexShrink: 0
})

type KeyboardShortcutIndicatorProps = React.ComponentProps<typeof StyledKeyboardShortcutIndicator>

export const KeyboardShortcutIndicator = (props: KeyboardShortcutIndicatorProps) => {
return (<StyledKeyboardShortcutIndicator size="sm" as="kbd" {...props} />)
}


KeyboardShortcutIndicator.displayName = 'KeyboardShortcut'
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP

exports[`KeyboardShortcut component renders 1`] = `
@media {
.c-dyvMgW {
font-family: var(--fonts-body);
font-weight: 400;
margin: 0;
}
.c-dyvMgW > .c-dyvMgW:before,
.c-dyvMgW > .c-dyvMgW:after {
display: none;
}
.c-ipiltK {
background: var(--colors-grey100);
color: var(--colors-textSubtle);
padding-left: var(--space-2);
padding-right: var(--space-2);
padding-top: var(--space-0);
padding-bottom: var(--space-0);
min-width: var(--sizes-2);
min-height: var(--sizes-2);
font-weight: 400;
display: inline-flex;
justify-content: center;
align-items: center;
border-radius: var(--radii-1);
flex-shrink: 0;
}
}
@media {
.c-dyvMgW-bndJoy-size-sm {
font-size: var(--fontSizes-sm);
line-height: 1.53;
}
.c-dyvMgW-bndJoy-size-sm::before {
content: '';
margin-bottom: -0.4056em;
display: table;
}
.c-dyvMgW-bndJoy-size-sm::after {
content: '';
margin-top: -0.4056em;
display: table;
}
}
<div>
<div
class="c-PJLV"
>
<kbd
class="c-dyvMgW c-dyvMgW-bndJoy-size-sm c-ipiltK"
>
Meta + A
</kbd>
</div>
</div>
`;
8 changes: 8 additions & 0 deletions lib/src/components/keyboard-shortcut/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { KeyboardShortcut as KeyboardShortcutComponent, KeyboardShortcutIndicator } from './KeyboardShortcut'


export const KeyboardShortcut = Object.assign(KeyboardShortcutComponent, {
Indicator: KeyboardShortcutIndicator
})

KeyboardShortcut.displayName = 'KeyboardShortcut'

0 comments on commit 884ec90

Please sign in to comment.