From d679eefeadd632bfad28a3683247307ee054fdb9 Mon Sep 17 00:00:00 2001 From: Shreyag02 Date: Tue, 11 Aug 2026 03:30:10 +0530 Subject: [PATCH] feat: add Kbd component --- .../src/content/docs/components/kbd/demo.ts | 87 +++++++++++++ .../src/content/docs/components/kbd/index.mdx | 96 ++++++++++++++ .../src/content/docs/components/kbd/props.ts | 17 +++ .../kbd/__tests__/data-slots.test.tsx | 38 ++++++ .../components/kbd/__tests__/kbd.test.tsx | 119 ++++++++++++++++++ packages/raystack/components/kbd/index.tsx | 1 + .../raystack/components/kbd/kbd.module.css | 32 +++++ packages/raystack/components/kbd/kbd.tsx | 32 +++++ packages/raystack/index.tsx | 1 + 9 files changed, 423 insertions(+) create mode 100644 apps/www/src/content/docs/components/kbd/demo.ts create mode 100644 apps/www/src/content/docs/components/kbd/index.mdx create mode 100644 apps/www/src/content/docs/components/kbd/props.ts create mode 100644 packages/raystack/components/kbd/__tests__/data-slots.test.tsx create mode 100644 packages/raystack/components/kbd/__tests__/kbd.test.tsx create mode 100644 packages/raystack/components/kbd/index.tsx create mode 100644 packages/raystack/components/kbd/kbd.module.css create mode 100644 packages/raystack/components/kbd/kbd.tsx diff --git a/apps/www/src/content/docs/components/kbd/demo.ts b/apps/www/src/content/docs/components/kbd/demo.ts new file mode 100644 index 000000000..68dd4874d --- /dev/null +++ b/apps/www/src/content/docs/components/kbd/demo.ts @@ -0,0 +1,87 @@ +'use client'; + +export const preview = { + type: 'code', + code: ` + + K + ` +}; + +export const singleDemo = { + type: 'code', + code: ` + Esc + + + + Tab + ` +}; + +export const groupDemo = { + type: 'code', + code: ` + + + K + + + + + P + + ` +}; + +export const separatorDemo = { + type: 'code', + tabs: [ + { + name: 'Plus', + code: ` + + + + K + ` + }, + { + name: 'Then', + code: ` + G + then + P + ` + } + ] +}; + +export const withTextDemo = { + type: 'code', + code: ` + Press + + + K + + to open the command palette + ` +}; + +export const withTooltipDemo = { + type: 'code', + code: ` + }> + Search + + + + Open search + + + K + + + + ` +}; diff --git a/apps/www/src/content/docs/components/kbd/index.mdx b/apps/www/src/content/docs/components/kbd/index.mdx new file mode 100644 index 000000000..08538bea8 --- /dev/null +++ b/apps/www/src/content/docs/components/kbd/index.mdx @@ -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"; + + + +## 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"; + +Esc + + + + K + +``` + +## API Reference + +Both parts render a `` element and forward any native attributes (`id`, `title`, `aria-label`, …) to it. + +### Root + +A single keyboard key. Renders a `` element. + + + +### Group + +Spaces a sequence of keys evenly. Also renders a ``: 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. + + + +### 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 `⌘`. + + + +### Sequences + +Wrap keys in `Kbd.Group` to show a chord. + + + +### 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. + + + +### Inline with text + +Keys sit on the text baseline, so they can be dropped into a sentence. + + + +### In a tooltip + +A common use is surfacing a shortcut alongside the action it triggers. + + + +## Accessibility + +- `Kbd` is presentational and renders the semantic `` 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: ``. +- 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. diff --git a/apps/www/src/content/docs/components/kbd/props.ts b/apps/www/src/content/docs/components/kbd/props.ts new file mode 100644 index 000000000..67fa91b3f --- /dev/null +++ b/apps/www/src/content/docs/components/kbd/props.ts @@ -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; +} diff --git a/packages/raystack/components/kbd/__tests__/data-slots.test.tsx b/packages/raystack/components/kbd/__tests__/data-slots.test.tsx new file mode 100644 index 000000000..577ef4b09 --- /dev/null +++ b/packages/raystack/components/kbd/__tests__/data-slots.test.tsx @@ -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( + + + K + + ); + expectSlots(container, ['kbd-group', 'kbd']); + }); + + it('marks each key with the same slot name', () => { + const { container } = render( + + + K + + ); + expect(getAllSlots(container, 'kbd')).toHaveLength(2); + }); + + it('drops the group slot when no group is rendered', () => { + const { container } = render(Esc); + expectSlots(container, ['kbd']); + expect(getSlot(container, 'kbd-group')).toBeNull(); + }); + + it('lets callers override the slot name', () => { + const { container } = render(Esc); + expect(getSlot(container, 'custom')).not.toBeNull(); + expect(getSlot(container, 'kbd')).toBeNull(); + }); +}); diff --git a/packages/raystack/components/kbd/__tests__/kbd.test.tsx b/packages/raystack/components/kbd/__tests__/kbd.test.tsx new file mode 100644 index 000000000..fa0f8f401 --- /dev/null +++ b/packages/raystack/components/kbd/__tests__/kbd.test.tsx @@ -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(Ctrl); + expect(screen.getByText('Ctrl')).toBeInTheDocument(); + }); + + it('renders a kbd element', () => { + render(Ctrl); + expect(screen.getByText('Ctrl').tagName).toBe('KBD'); + }); + + it('applies the base class', () => { + render(Ctrl); + expect(screen.getByText('Ctrl')).toHaveClass(styles.kbd); + }); + + it('merges a custom className with the base class', () => { + render(Ctrl); + const kbd = screen.getByText('Ctrl'); + expect(kbd).toHaveClass(styles.kbd); + expect(kbd).toHaveClass('custom'); + }); + + it('forwards arbitrary props to the element', () => { + render(Ctrl); + expect(screen.getByText('Ctrl')).toHaveAttribute( + 'aria-label', + 'Control key' + ); + }); + + it('forwards ref', () => { + const ref = createRef(); + render(Ctrl); + expect(ref.current).toBeInstanceOf(HTMLElement); + expect(ref.current?.tagName).toBe('KBD'); + }); + }); + + describe('Kbd.Group', () => { + it('renders every key it contains', () => { + render( + + + K + + ); + expect(screen.getByText('⌘')).toBeInTheDocument(); + expect(screen.getByText('K')).toBeInTheDocument(); + }); + + it('renders a kbd element so nested keys stay semantic', () => { + const { container } = render( + + K + + ); + const group = container.querySelector(`.${styles['kbd-group']}`); + expect(group?.tagName).toBe('KBD'); + }); + + it('applies the group class, not the key class', () => { + const { container } = render( + + K + + ); + 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( + + K + + ); + const group = container.querySelector(`.${styles['kbd-group']}`); + expect(group).toHaveClass('custom'); + }); + + it('forwards ref', () => { + const ref = createRef(); + render( + + K + + ); + expect(ref.current?.tagName).toBe('KBD'); + }); + + it('allows plain text separators between keys', () => { + render( + + +K + + ); + 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'); + }); + }); +}); diff --git a/packages/raystack/components/kbd/index.tsx b/packages/raystack/components/kbd/index.tsx new file mode 100644 index 000000000..fbbeae156 --- /dev/null +++ b/packages/raystack/components/kbd/index.tsx @@ -0,0 +1 @@ +export { Kbd } from './kbd'; diff --git a/packages/raystack/components/kbd/kbd.module.css b/packages/raystack/components/kbd/kbd.module.css new file mode 100644 index 000000000..400847896 --- /dev/null +++ b/packages/raystack/components/kbd/kbd.module.css @@ -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); +} diff --git a/packages/raystack/components/kbd/kbd.tsx b/packages/raystack/components/kbd/kbd.tsx new file mode 100644 index 000000000..dfc60c6b9 --- /dev/null +++ b/packages/raystack/components/kbd/kbd.tsx @@ -0,0 +1,32 @@ +import { cx } from 'class-variance-authority'; +import type { ComponentProps } from 'react'; +import styles from './kbd.module.css'; + +export type KbdProps = ComponentProps<'kbd'>; + +const KbdRoot = ({ className, ...props }: KbdProps) => ( + +); + +KbdRoot.displayName = 'Kbd'; + +export type KbdGroupProps = ComponentProps<'kbd'>; + +/** + * Renders a `` rather than a `
`: per the HTML spec a `kbd` nested + * inside a `kbd` represents an individual key within a larger input, which is + * exactly a shortcut sequence. + */ +const KbdGroup = ({ className, ...props }: KbdGroupProps) => ( + +); + +KbdGroup.displayName = 'Kbd.Group'; + +export const Kbd = Object.assign(KbdRoot, { + Group: KbdGroup +}); diff --git a/packages/raystack/index.tsx b/packages/raystack/index.tsx index 562cbdb2d..c48d4e57e 100644 --- a/packages/raystack/index.tsx +++ b/packages/raystack/index.tsx @@ -97,6 +97,7 @@ export { IconButton } from './components/icon-button'; export { Image } from './components/image'; export { Indicator } from './components/indicator'; export { Input } from './components/input'; +export { Kbd } from './components/kbd'; export { Label } from './components/label'; export { Link } from './components/link'; export { List } from './components/list';