-
Notifications
You must be signed in to change notification settings - Fork 13
feat: add Kbd component #886
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
Shreyag02
wants to merge
1
commit into
main
Choose a base branch
from
feat/kbd-component
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
+423
−0
Open
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,87 @@ | ||
| 'use client'; | ||
|
|
||
| export const preview = { | ||
| type: 'code', | ||
| code: `<Kbd.Group> | ||
| <Kbd>⌘</Kbd> | ||
| <Kbd>K</Kbd> | ||
| </Kbd.Group>` | ||
| }; | ||
|
|
||
| export const singleDemo = { | ||
| type: 'code', | ||
| code: `<Flex gap={5} align="center"> | ||
| <Kbd>Esc</Kbd> | ||
| <Kbd>⌘</Kbd> | ||
| <Kbd>⇧</Kbd> | ||
| <Kbd>↵</Kbd> | ||
| <Kbd>Tab</Kbd> | ||
| </Flex>` | ||
| }; | ||
|
|
||
| export const groupDemo = { | ||
| type: 'code', | ||
| code: `<Flex gap={7} align="center"> | ||
| <Kbd.Group> | ||
| <Kbd>⌘</Kbd> | ||
| <Kbd>K</Kbd> | ||
| </Kbd.Group> | ||
| <Kbd.Group> | ||
| <Kbd>⌘</Kbd> | ||
| <Kbd>⇧</Kbd> | ||
| <Kbd>P</Kbd> | ||
| </Kbd.Group> | ||
| </Flex>` | ||
| }; | ||
|
|
||
| export const separatorDemo = { | ||
| type: 'code', | ||
| tabs: [ | ||
| { | ||
| name: 'Plus', | ||
| code: `<Kbd.Group> | ||
| <Kbd>⌘</Kbd> | ||
| + | ||
| <Kbd>K</Kbd> | ||
| </Kbd.Group>` | ||
| }, | ||
| { | ||
| name: 'Then', | ||
| code: `<Kbd.Group> | ||
| <Kbd>G</Kbd> | ||
| then | ||
| <Kbd>P</Kbd> | ||
| </Kbd.Group>` | ||
| } | ||
| ] | ||
| }; | ||
|
|
||
| export const withTextDemo = { | ||
| type: 'code', | ||
| code: `<Flex gap={3} align="center"> | ||
| <Text size="small" variant="secondary">Press</Text> | ||
| <Kbd.Group> | ||
| <Kbd>⌘</Kbd> | ||
| <Kbd>K</Kbd> | ||
| </Kbd.Group> | ||
| <Text size="small" variant="secondary">to open the command palette</Text> | ||
| </Flex>` | ||
| }; | ||
|
|
||
| export const withTooltipDemo = { | ||
| type: 'code', | ||
| code: `<Tooltip> | ||
| <Tooltip.Trigger render={<Button variant="outline" />}> | ||
| Search | ||
| </Tooltip.Trigger> | ||
| <Tooltip.Content> | ||
| <Flex gap={3} align="center"> | ||
| Open search | ||
| <Kbd.Group> | ||
| <Kbd>⌘</Kbd> | ||
| <Kbd>K</Kbd> | ||
| </Kbd.Group> | ||
| </Flex> | ||
| </Tooltip.Content> | ||
| </Tooltip>` | ||
| }; | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,96 @@ | ||
| --- | ||
| title: Kbd | ||
| description: Displays a keyboard key or a shortcut sequence. | ||
| source: packages/raystack/components/kbd | ||
| tag: new | ||
| --- | ||
|
|
||
| import { | ||
| preview, | ||
| singleDemo, | ||
| groupDemo, | ||
| separatorDemo, | ||
| withTextDemo, | ||
| withTooltipDemo, | ||
| } from "./demo.ts"; | ||
|
|
||
| <Demo data={preview} /> | ||
|
|
||
| ## Anatomy | ||
|
|
||
| Import and assemble the component. A single `Kbd` renders one key; wrap several in `Kbd.Group` to show a sequence. | ||
|
|
||
| ```tsx | ||
| import { Kbd } from "@raystack/apsara"; | ||
|
|
||
| <Kbd>Esc</Kbd> | ||
|
|
||
| <Kbd.Group> | ||
| <Kbd>⌘</Kbd> | ||
| <Kbd>K</Kbd> | ||
| </Kbd.Group> | ||
| ``` | ||
|
|
||
| ## API Reference | ||
|
|
||
| Both parts render a `<kbd>` element and forward any native attributes (`id`, `title`, `aria-label`, …) to it. | ||
|
|
||
| ### Root | ||
|
|
||
| A single keyboard key. Renders a `<kbd>` element. | ||
|
|
||
| <auto-type-table path="./props.ts" name="KbdProps" /> | ||
|
|
||
| ### Group | ||
|
|
||
| Spaces a sequence of keys evenly. Also renders a `<kbd>`: per the HTML spec, a `kbd` nested inside a `kbd` represents an individual key within a larger input, which is exactly what a shortcut sequence is. | ||
|
|
||
| <auto-type-table path="./props.ts" name="KbdGroupProps" /> | ||
|
|
||
| ### Slots | ||
|
|
||
| Every rendered part carries a stable `data-slot` attribute for [styling and testing](/docs/styling#with-data-slot): | ||
|
|
||
| | Slot | Element | | ||
| |------|---------| | ||
| | `kbd` | Each individual key | | ||
| | `kbd-group` | The `Kbd.Group` wrapper | | ||
|
|
||
| ## Examples | ||
|
|
||
| ### Single keys | ||
|
|
||
| Use `Kbd` on its own for a one-key hint. Keys share a minimum width so a narrow `K` lines up with a wide `⌘`. | ||
|
|
||
| <Demo data={singleDemo} /> | ||
|
|
||
| ### Sequences | ||
|
|
||
| Wrap keys in `Kbd.Group` to show a chord. | ||
|
|
||
| <Demo data={groupDemo} /> | ||
|
|
||
| ### Separators | ||
|
|
||
| `Kbd.Group` renders whatever you put between the keys, so separators are plain text. Use `+` for keys pressed together and a word like `then` for keys pressed in order. | ||
|
|
||
| <Demo data={separatorDemo} /> | ||
|
|
||
| ### Inline with text | ||
|
|
||
| Keys sit on the text baseline, so they can be dropped into a sentence. | ||
|
|
||
| <Demo data={withTextDemo} /> | ||
|
|
||
| ### In a tooltip | ||
|
|
||
| A common use is surfacing a shortcut alongside the action it triggers. | ||
|
|
||
| <Demo data={withTooltipDemo} /> | ||
|
|
||
| ## Accessibility | ||
|
|
||
| - `Kbd` is presentational and renders the semantic `<kbd>` element, which screen readers announce as keyboard input. | ||
| - Symbol-only keys such as `⌘`, `⇧`, or `↵` are not announced usefully on their own. Add an `aria-label` when the symbol is the only cue: `<Kbd aria-label="Command">⌘</Kbd>`. | ||
| - Keys are not focusable and carry no interaction. Keep the shortcut wired to a real handler elsewhere — `Kbd` only displays it. | ||
| - Text selection is disabled so dragging across a menu row does not highlight the key labels. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,17 @@ | ||
| import type { ReactNode } from 'react'; | ||
|
|
||
| export interface KbdProps { | ||
| /** The key to display, e.g. `⌘`, `Esc`, or `Enter`. */ | ||
| children?: ReactNode; | ||
|
|
||
| /** Additional CSS class names. */ | ||
| className?: string; | ||
| } | ||
|
|
||
| export interface KbdGroupProps { | ||
| /** The keys in the sequence, plus any plain-text separators between them. */ | ||
| children?: ReactNode; | ||
|
|
||
| /** Additional CSS class names. */ | ||
| className?: string; | ||
| } |
38 changes: 38 additions & 0 deletions
38
packages/raystack/components/kbd/__tests__/data-slots.test.tsx
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,38 @@ | ||
| import { render } from '@testing-library/react'; | ||
| import { describe, expect, it } from 'vitest'; | ||
| import { expectSlots, getAllSlots, getSlot } from '~/test-utils/data-slots'; | ||
| import { Kbd } from '../kbd'; | ||
|
|
||
| describe('Kbd data-slot contract', () => { | ||
| it('exposes slots for every rendered part', () => { | ||
| const { container } = render( | ||
| <Kbd.Group> | ||
| <Kbd>⌘</Kbd> | ||
| <Kbd>K</Kbd> | ||
| </Kbd.Group> | ||
| ); | ||
| expectSlots(container, ['kbd-group', 'kbd']); | ||
| }); | ||
|
|
||
| it('marks each key with the same slot name', () => { | ||
| const { container } = render( | ||
| <Kbd.Group> | ||
| <Kbd>⌘</Kbd> | ||
| <Kbd>K</Kbd> | ||
| </Kbd.Group> | ||
| ); | ||
| expect(getAllSlots(container, 'kbd')).toHaveLength(2); | ||
| }); | ||
|
|
||
| it('drops the group slot when no group is rendered', () => { | ||
| const { container } = render(<Kbd>Esc</Kbd>); | ||
| expectSlots(container, ['kbd']); | ||
| expect(getSlot(container, 'kbd-group')).toBeNull(); | ||
| }); | ||
|
|
||
| it('lets callers override the slot name', () => { | ||
| const { container } = render(<Kbd data-slot='custom'>Esc</Kbd>); | ||
| expect(getSlot(container, 'custom')).not.toBeNull(); | ||
| expect(getSlot(container, 'kbd')).toBeNull(); | ||
| }); | ||
| }); |
119 changes: 119 additions & 0 deletions
119
packages/raystack/components/kbd/__tests__/kbd.test.tsx
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,119 @@ | ||
| import { render, screen } from '@testing-library/react'; | ||
| import { createRef } from 'react'; | ||
| import { describe, expect, it } from 'vitest'; | ||
| import { Kbd } from '../kbd'; | ||
| import styles from '../kbd.module.css'; | ||
|
|
||
| describe('Kbd', () => { | ||
| describe('Basic Rendering', () => { | ||
| it('renders its children', () => { | ||
| render(<Kbd>Ctrl</Kbd>); | ||
| expect(screen.getByText('Ctrl')).toBeInTheDocument(); | ||
| }); | ||
|
|
||
| it('renders a kbd element', () => { | ||
| render(<Kbd>Ctrl</Kbd>); | ||
| expect(screen.getByText('Ctrl').tagName).toBe('KBD'); | ||
| }); | ||
|
|
||
| it('applies the base class', () => { | ||
| render(<Kbd>Ctrl</Kbd>); | ||
| expect(screen.getByText('Ctrl')).toHaveClass(styles.kbd); | ||
| }); | ||
|
|
||
| it('merges a custom className with the base class', () => { | ||
| render(<Kbd className='custom'>Ctrl</Kbd>); | ||
| const kbd = screen.getByText('Ctrl'); | ||
| expect(kbd).toHaveClass(styles.kbd); | ||
| expect(kbd).toHaveClass('custom'); | ||
| }); | ||
|
|
||
| it('forwards arbitrary props to the element', () => { | ||
| render(<Kbd aria-label='Control key'>Ctrl</Kbd>); | ||
| expect(screen.getByText('Ctrl')).toHaveAttribute( | ||
| 'aria-label', | ||
| 'Control key' | ||
| ); | ||
| }); | ||
|
|
||
| it('forwards ref', () => { | ||
| const ref = createRef<HTMLElement>(); | ||
| render(<Kbd ref={ref}>Ctrl</Kbd>); | ||
| expect(ref.current).toBeInstanceOf(HTMLElement); | ||
| expect(ref.current?.tagName).toBe('KBD'); | ||
| }); | ||
| }); | ||
|
|
||
| describe('Kbd.Group', () => { | ||
| it('renders every key it contains', () => { | ||
| render( | ||
| <Kbd.Group> | ||
| <Kbd>⌘</Kbd> | ||
| <Kbd>K</Kbd> | ||
| </Kbd.Group> | ||
| ); | ||
| expect(screen.getByText('⌘')).toBeInTheDocument(); | ||
| expect(screen.getByText('K')).toBeInTheDocument(); | ||
| }); | ||
|
|
||
| it('renders a kbd element so nested keys stay semantic', () => { | ||
| const { container } = render( | ||
| <Kbd.Group> | ||
| <Kbd>K</Kbd> | ||
| </Kbd.Group> | ||
| ); | ||
| const group = container.querySelector(`.${styles['kbd-group']}`); | ||
| expect(group?.tagName).toBe('KBD'); | ||
| }); | ||
|
|
||
| it('applies the group class, not the key class', () => { | ||
| const { container } = render( | ||
| <Kbd.Group> | ||
| <Kbd>K</Kbd> | ||
| </Kbd.Group> | ||
| ); | ||
| const group = container.querySelector(`.${styles['kbd-group']}`); | ||
| expect(group).not.toHaveClass(styles.kbd); | ||
| }); | ||
|
|
||
| it('merges a custom className with the group class', () => { | ||
| const { container } = render( | ||
| <Kbd.Group className='custom'> | ||
| <Kbd>K</Kbd> | ||
| </Kbd.Group> | ||
| ); | ||
| const group = container.querySelector(`.${styles['kbd-group']}`); | ||
| expect(group).toHaveClass('custom'); | ||
| }); | ||
|
|
||
| it('forwards ref', () => { | ||
| const ref = createRef<HTMLElement>(); | ||
| render( | ||
| <Kbd.Group ref={ref}> | ||
| <Kbd>K</Kbd> | ||
| </Kbd.Group> | ||
| ); | ||
| expect(ref.current?.tagName).toBe('KBD'); | ||
| }); | ||
|
|
||
| it('allows plain text separators between keys', () => { | ||
| render( | ||
| <Kbd.Group> | ||
| <Kbd>⌘</Kbd>+<Kbd>K</Kbd> | ||
| </Kbd.Group> | ||
| ); | ||
| expect(screen.getByText('+')).toBeInTheDocument(); | ||
| }); | ||
| }); | ||
|
|
||
| describe('Composition', () => { | ||
| it('exposes Group off the root', () => { | ||
| expect(Kbd.Group).toBeDefined(); | ||
| }); | ||
|
|
||
| it('sets displayName on both parts', () => { | ||
| expect(Kbd.displayName).toBe('Kbd'); | ||
| expect(Kbd.Group.displayName).toBe('Kbd.Group'); | ||
| }); | ||
| }); | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| export { Kbd } from './kbd'; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,32 @@ | ||
| /* normalize.css sets `kbd { font-family: monospace }`, so both parts restore | ||
| the body font explicitly rather than relying on inheritance. */ | ||
|
|
||
| .kbd, | ||
| .kbd-group { | ||
| display: inline-flex; | ||
| align-items: center; | ||
| color: var(--rs-color-foreground-base-tertiary); | ||
| font-family: var(--rs-font-body); | ||
| font-size: var(--rs-font-size-mini); | ||
| line-height: var(--rs-line-height-mini); | ||
| letter-spacing: var(--rs-letter-spacing-mini); | ||
| } | ||
|
|
||
| .kbd { | ||
| justify-content: center; | ||
| box-sizing: border-box; | ||
| height: var(--rs-space-6); | ||
| /* Square minimum so a narrow "K" reads the same width as a wide "⌘". */ | ||
| min-width: var(--rs-space-6); | ||
| padding: 0 var(--rs-space-2); | ||
| border-radius: var(--rs-radius-1); | ||
| background: var(--rs-color-background-neutral-primary); | ||
| font-weight: var(--rs-font-weight-medium); | ||
| white-space: nowrap; | ||
| user-select: none; | ||
| } | ||
|
|
||
| /* Spacing container only — the nested keys carry the chip treatment. */ | ||
| .kbd-group { | ||
| gap: var(--rs-space-2); | ||
| } |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win
Add
aria-labelto every symbol-only key example.These examples use symbols as the only key content. They do not follow the accessibility guidance on
apps/www/src/content/docs/components/kbd/index.mdxline 94. Add labels such as<Kbd aria-label="Command">⌘</Kbd>.apps/www/src/content/docs/components/kbd/demo.ts#L5-L8: Label the Command key.apps/www/src/content/docs/components/kbd/demo.ts#L13-L19: Label the Command, Shift, and Enter keys.apps/www/src/content/docs/components/kbd/demo.ts#L24-L34: Label the Command and Shift keys.apps/www/src/content/docs/components/kbd/demo.ts#L42-L46: Label the Command key.apps/www/src/content/docs/components/kbd/demo.ts#L61-L68: Label the Command key.apps/www/src/content/docs/components/kbd/demo.ts#L73-L86: Label the Command key.📍 Affects 1 file
apps/www/src/content/docs/components/kbd/demo.ts#L5-L8(this comment)apps/www/src/content/docs/components/kbd/demo.ts#L13-L19apps/www/src/content/docs/components/kbd/demo.ts#L24-L34apps/www/src/content/docs/components/kbd/demo.ts#L42-L46apps/www/src/content/docs/components/kbd/demo.ts#L61-L68apps/www/src/content/docs/components/kbd/demo.ts#L73-L86🤖 Prompt for AI Agents