From d679eefeadd632bfad28a3683247307ee054fdb9 Mon Sep 17 00:00:00 2001 From: Shreyag02 Date: Tue, 11 Aug 2026 03:30:10 +0530 Subject: [PATCH 1/2] 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'; From b5a3b97c63f3f940d51a508ec12a2b31ca788320 Mon Sep 17 00:00:00 2001 From: Shreyag02 Date: Wed, 12 Aug 2026 17:59:18 +0530 Subject: [PATCH 2/2] feat: add Kbd variants and address review feedback Addresses the review on #886. Component: - Add `solid` (default) and `ghost` variants. `variant` on `Kbd.Group` propagates to its keys via context, with a per-key override. - Add `width: fit-content` so a key no longer stretches in a column-flex or grid parent, and `pointer-events: none` alongside `user-select: none`. - Move typography onto `.kbd` only; `.kbd-group` takes `font: inherit` so separator text picks up the surrounding type instead of the key styling (and still escapes normalize's monospace default for `kbd`). - Drop the explanatory comments. Command: - `Command.Shortcut` is now an alias of `Kbd.Group` + `Kbd` defaulting to `ghost`, forwarding all props. Whitespace splitting and the `command-shortcut` / `command-shortcut-key` slots are preserved, and element children pass through without a second key wrapper. - Remove the now-unused shortcut typography. Accessibility: - `ghost` uses `foreground-base-secondary`. `tertiary` measured 3.33-4.46:1 across the surfaces Kbd is documented on, below AA's 4.5 for 11px text and worst on a hovered row; `secondary` clears it everywhere at >=5.19:1. - Correct the docs claim about screen readers: `kbd` maps to no ARIA role and no accessible object per HTML-AAM. - Label symbol-only keys in the examples. Docs: - Add a playground and a Variants section, reword the description and the Group summary, and make the "Inline with text" example actually inline. - Add an Input example, using a single key since the trailing slot is sized for an icon and clips a multi-key group. - Document `variant` on `Command.Shortcut`. Co-Authored-By: Claude Opus 5 (1M context) --- .../content/docs/components/command/index.mdx | 2 +- .../content/docs/components/command/props.ts | 12 +++ .../src/content/docs/components/kbd/demo.ts | 80 +++++++++++++------ .../src/content/docs/components/kbd/index.mdx | 34 +++++--- .../src/content/docs/components/kbd/props.ts | 12 +++ .../command/__tests__/command.test.tsx | 55 +++++++++++++ .../components/command/command-misc.tsx | 29 ++++--- .../components/command/command.module.css | 14 ---- .../components/kbd/__tests__/kbd.test.tsx | 55 +++++++++++++ packages/raystack/components/kbd/index.tsx | 2 +- .../raystack/components/kbd/kbd.module.css | 30 ++++--- packages/raystack/components/kbd/kbd.tsx | 61 +++++++++----- 12 files changed, 293 insertions(+), 93 deletions(-) diff --git a/apps/www/src/content/docs/components/command/index.mdx b/apps/www/src/content/docs/components/command/index.mdx index f3b03b5f8..1f0527ac9 100644 --- a/apps/www/src/content/docs/components/command/index.mdx +++ b/apps/www/src/content/docs/components/command/index.mdx @@ -97,7 +97,7 @@ Visual divider between groups. The separator is hidden automatically while the u ### Shortcut -A `` element for keyboard hints. Typically passed as `trailingIcon` on `Command.Item`. +Keyboard hints for an item, typically passed as `trailingIcon` on `Command.Item`. Built on [`Kbd`](/docs/components/kbd): it renders a `Kbd.Group` of `ghost` keys and forwards every prop. diff --git a/apps/www/src/content/docs/components/command/props.ts b/apps/www/src/content/docs/components/command/props.ts index 5b1b54945..906c6580b 100644 --- a/apps/www/src/content/docs/components/command/props.ts +++ b/apps/www/src/content/docs/components/command/props.ts @@ -132,6 +132,18 @@ export interface CommandSeparatorProps { } export interface CommandShortcutProps { + /** + * The keys to display. A whitespace-separated string is split into one key + * per token, so `"⌘ K"` renders two keys. + */ + children?: React.ReactNode; + + /** + * Visual style variant, applied to every key in the shortcut. + * @defaultValue "ghost" + */ + variant?: 'solid' | 'ghost'; + /** Additional CSS class names. */ className?: string; } diff --git a/apps/www/src/content/docs/components/kbd/demo.ts b/apps/www/src/content/docs/components/kbd/demo.ts index 68dd4874d..c5a6f6dca 100644 --- a/apps/www/src/content/docs/components/kbd/demo.ts +++ b/apps/www/src/content/docs/components/kbd/demo.ts @@ -1,34 +1,65 @@ 'use client'; -export const preview = { - type: 'code', - code: ` - - K - ` +import type { ComponentPropsType } from '@/components/demo/types'; +import { getPropsString } from '@/lib/utils'; + +export const getCode = (props: ComponentPropsType) => { + const { children, ...rest } = props; + + return `${children}`; +}; + +export const playground = { + type: 'playground', + controls: { + variant: { + type: 'select', + options: ['solid', 'ghost'], + defaultValue: 'solid' + }, + children: { + type: 'text', + initialValue: 'Esc' + } + }, + getCode }; export const singleDemo = { type: 'code', code: ` Esc - - - + + + Tab ` }; +export const variantDemo = { + type: 'code', + code: ` + + + K + + + + K + + ` +}; + export const groupDemo = { type: 'code', code: ` - + K - - + + P ` @@ -40,7 +71,7 @@ export const separatorDemo = { { name: 'Plus', code: ` - + + K ` @@ -58,14 +89,17 @@ export const separatorDemo = { export const withTextDemo = { type: 'code', - code: ` - Press - - - K - - to open the command palette - ` + code: ` + Press K to open the command palette. + ` +}; + +export const withInputDemo = { + type: 'code', + code: `⌘K} + />` }; export const withTooltipDemo = { @@ -77,8 +111,8 @@ export const withTooltipDemo = { 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 index 08538bea8..8607491e8 100644 --- a/apps/www/src/content/docs/components/kbd/index.mdx +++ b/apps/www/src/content/docs/components/kbd/index.mdx @@ -1,20 +1,22 @@ --- title: Kbd -description: Displays a keyboard key or a shortcut sequence. +description: A component for displaying keyboard keys and shortcuts. source: packages/raystack/components/kbd tag: new --- import { - preview, + playground, singleDemo, + variantDemo, groupDemo, separatorDemo, withTextDemo, + withInputDemo, withTooltipDemo, } from "./demo.ts"; - + ## Anatomy @@ -43,7 +45,7 @@ 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. +Groups multiple keyboard keys for key combinations. @@ -64,24 +66,36 @@ Use `Kbd` on its own for a one-key hint. Keys share a minimum width so a narrow +### Variants + +`solid` is the default and suits standalone hints. Use `ghost` on surfaces that already have their own background, such as a menu row, a tooltip, or an input. + + + ### Sequences -Wrap keys in `Kbd.Group` to show a chord. +Wrap keys in `Kbd.Group` to show a chord. Setting `variant` on the group applies it to every key inside. ### 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. +`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. Separator text takes the surrounding typography rather than the key styling. ### Inline with text -Keys sit on the text baseline, so they can be dropped into a sentence. +Keys sit on the text baseline, so they can be dropped straight into a sentence. +### In an input + +Surface a focus shortcut in a search field. Use a single `ghost` key here — the input's trailing slot is sized for an icon, so a multi-key `Kbd.Group` will be clipped. + + + ### In a tooltip A common use is surfacing a shortcut alongside the action it triggers. @@ -90,7 +104,7 @@ 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: ``. +- `Kbd` is presentational and renders the semantic `` element. It has no ARIA role of its own and is not exposed as a separate accessible object, so it does not change how surrounding content is announced. +- Symbol-only keys such as `⌘`, `⇧`, or `↵` are not announced usefully on their own — they are read by their Unicode names, if at all. 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. +- Keys ignore pointer events and text selection, so clicking or 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 index 67fa91b3f..311b8701a 100644 --- a/apps/www/src/content/docs/components/kbd/props.ts +++ b/apps/www/src/content/docs/components/kbd/props.ts @@ -4,6 +4,12 @@ export interface KbdProps { /** The key to display, e.g. `⌘`, `Esc`, or `Enter`. */ children?: ReactNode; + /** + * Visual style variant. Inherited from a parent `Kbd.Group` when set there. + * @defaultValue "solid" + */ + variant?: 'solid' | 'ghost'; + /** Additional CSS class names. */ className?: string; } @@ -12,6 +18,12 @@ export interface KbdGroupProps { /** The keys in the sequence, plus any plain-text separators between them. */ children?: ReactNode; + /** + * Visual style variant applied to every key in the group. + * @defaultValue "solid" + */ + variant?: 'solid' | 'ghost'; + /** Additional CSS class names. */ className?: string; } diff --git a/packages/raystack/components/command/__tests__/command.test.tsx b/packages/raystack/components/command/__tests__/command.test.tsx index 2cdefaf0d..1b298f1c3 100644 --- a/packages/raystack/components/command/__tests__/command.test.tsx +++ b/packages/raystack/components/command/__tests__/command.test.tsx @@ -2,6 +2,8 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import * as React from 'react'; import { describe, expect, it, vi } from 'vitest'; +import { Kbd } from '../../kbd'; +import kbdStyles from '../../kbd/kbd.module.css'; import { Command } from '../command'; import styles from '../command.module.css'; @@ -315,4 +317,57 @@ describe('Command', () => { }); }); }); + + describe('Command.Shortcut', () => { + it('splits a string of keys into individual keys', () => { + render(⌘ K); + expect(screen.getByText('⌘')).toBeInTheDocument(); + expect(screen.getByText('K')).toBeInTheDocument(); + }); + + it('renders each key through Kbd', () => { + render(⌘ K); + const key = screen.getByText('⌘'); + expect(key.tagName).toBe('KBD'); + expect(key).toHaveClass(kbdStyles['kbd']); + }); + + it('defaults its keys to the ghost variant', () => { + render(⌘ K); + expect(screen.getByText('⌘')).toHaveClass(kbdStyles['kbd-ghost']); + }); + + it('allows the variant to be overridden', () => { + render(⌘ K); + expect(screen.getByText('⌘')).toHaveClass(kbdStyles['kbd-solid']); + }); + + it('forwards props and merges className onto the group', () => { + const { container } = render( + + ⌘ K + + ); + const group = container.querySelector('[data-slot="command-shortcut"]'); + expect(group).toHaveClass('custom'); + expect(group).toHaveClass(styles.shortcut); + expect(group).toHaveAttribute('aria-label', 'Command K'); + }); + + it('forwards ref', () => { + const ref = React.createRef(); + render(⌘ K); + expect(ref.current?.tagName).toBe('KBD'); + }); + + it('does not double-wrap element children in a second key', () => { + const { container } = render( + + + + ); + const keys = container.querySelectorAll(`.${kbdStyles['kbd']}`); + expect(keys).toHaveLength(1); + }); + }); }); diff --git a/packages/raystack/components/command/command-misc.tsx b/packages/raystack/components/command/command-misc.tsx index a1563995a..8bcefe9d8 100644 --- a/packages/raystack/components/command/command-misc.tsx +++ b/packages/raystack/components/command/command-misc.tsx @@ -2,7 +2,8 @@ import { Autocomplete as AutocompletePrimitive } from '@base-ui/react/autocomplete'; import { cx } from 'class-variance-authority'; -import { type ComponentProps } from 'react'; +import { Fragment, isValidElement } from 'react'; +import { Kbd, type KbdGroupProps } from '../kbd'; import styles from './command.module.css'; import { useCommandContext } from './command-root'; @@ -63,11 +64,12 @@ export const CommandSeparator = ({ }; CommandSeparator.displayName = 'Command.Separator'; -export type CommandShortcutProps = ComponentProps<'span'>; +export type CommandShortcutProps = KbdGroupProps; export const CommandShortcut = ({ className, children, + variant = 'ghost', ...props }: CommandShortcutProps) => { const keys = @@ -78,21 +80,22 @@ export const CommandShortcut = ({ : [children]; return ( - - {keys.map((key, index) => ( - - {key} - - ))} - + {keys.map((key, index) => + isValidElement(key) ? ( + {key} + ) : ( + + {key} + + ) + )} + ); }; CommandShortcut.displayName = 'Command.Shortcut'; diff --git a/packages/raystack/components/command/command.module.css b/packages/raystack/components/command/command.module.css index 0cbdd837e..0eaa63891 100644 --- a/packages/raystack/components/command/command.module.css +++ b/packages/raystack/components/command/command.module.css @@ -103,22 +103,8 @@ } .shortcut { - display: inline-flex; - align-items: center; justify-content: flex-end; - gap: var(--rs-space-1); white-space: nowrap; - font-family: var(--rs-font-body); - font-weight: var(--rs-font-weight-regular); - font-size: var(--rs-font-size-micro); - line-height: var(--rs-line-height-micro); - letter-spacing: var(--rs-letter-spacing-micro); - color: var(--rs-color-foreground-base-tertiary); -} - -.shortcutKey { - font: inherit; - color: inherit; } .empty { diff --git a/packages/raystack/components/kbd/__tests__/kbd.test.tsx b/packages/raystack/components/kbd/__tests__/kbd.test.tsx index fa0f8f401..79a353504 100644 --- a/packages/raystack/components/kbd/__tests__/kbd.test.tsx +++ b/packages/raystack/components/kbd/__tests__/kbd.test.tsx @@ -44,6 +44,61 @@ describe('Kbd', () => { }); }); + describe('Variants', () => { + it('applies the solid variant by default', () => { + render(Ctrl); + expect(screen.getByText('Ctrl')).toHaveClass(styles['kbd-solid']); + }); + + it('applies the ghost variant when requested', () => { + render(Ctrl); + const kbd = screen.getByText('Ctrl'); + expect(kbd).toHaveClass(styles['kbd-ghost']); + expect(kbd).not.toHaveClass(styles['kbd-solid']); + }); + + it('inherits the variant from a parent group', () => { + render( + + + K + + ); + expect(screen.getByText('⌘')).toHaveClass(styles['kbd-ghost']); + expect(screen.getByText('K')).toHaveClass(styles['kbd-ghost']); + }); + + it('lets a key override the variant inherited from its group', () => { + render( + + + K + + ); + expect(screen.getByText('⌘')).toHaveClass(styles['kbd-solid']); + expect(screen.getByText('K')).toHaveClass(styles['kbd-ghost']); + }); + + it('falls back to solid for keys in a group with no variant', () => { + render( + + K + + ); + expect(screen.getByText('K')).toHaveClass(styles['kbd-solid']); + }); + + it('does not put a key variant class on the group', () => { + const { container } = render( + + K + + ); + const group = container.querySelector(`.${styles['kbd-group']}`); + expect(group).not.toHaveClass(styles['kbd-ghost']); + }); + }); + describe('Kbd.Group', () => { it('renders every key it contains', () => { render( diff --git a/packages/raystack/components/kbd/index.tsx b/packages/raystack/components/kbd/index.tsx index fbbeae156..a170b82e2 100644 --- a/packages/raystack/components/kbd/index.tsx +++ b/packages/raystack/components/kbd/index.tsx @@ -1 +1 @@ -export { Kbd } from './kbd'; +export { Kbd, type KbdGroupProps, type KbdProps } from './kbd'; diff --git a/packages/raystack/components/kbd/kbd.module.css b/packages/raystack/components/kbd/kbd.module.css index 400847896..b899ad6f9 100644 --- a/packages/raystack/components/kbd/kbd.module.css +++ b/packages/raystack/components/kbd/kbd.module.css @@ -1,32 +1,38 @@ -/* 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); + width: fit-content; + pointer-events: none; + user-select: none; } .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-family: var(--rs-font-body); + font-size: var(--rs-font-size-mini); font-weight: var(--rs-font-weight-medium); + line-height: var(--rs-line-height-mini); + letter-spacing: var(--rs-letter-spacing-mini); white-space: nowrap; - user-select: none; } -/* Spacing container only — the nested keys carry the chip treatment. */ +.kbd-solid { + background: var(--rs-color-background-neutral-primary); + color: var(--rs-color-foreground-base-secondary); +} + +.kbd-ghost { + background: transparent; + color: var(--rs-color-foreground-base-secondary); +} + .kbd-group { gap: var(--rs-space-2); + font: inherit; } diff --git a/packages/raystack/components/kbd/kbd.tsx b/packages/raystack/components/kbd/kbd.tsx index dfc60c6b9..ad9a6ccbc 100644 --- a/packages/raystack/components/kbd/kbd.tsx +++ b/packages/raystack/components/kbd/kbd.tsx @@ -1,28 +1,51 @@ -import { cx } from 'class-variance-authority'; -import type { ComponentProps } from 'react'; +'use client'; + +import { cva, cx, type VariantProps } from 'class-variance-authority'; +import { type ComponentProps, createContext, useContext } from 'react'; import styles from './kbd.module.css'; -export type KbdProps = ComponentProps<'kbd'>; +const kbd = cva(styles['kbd'], { + variants: { + variant: { + solid: styles['kbd-solid'], + ghost: styles['kbd-ghost'] + } + }, + defaultVariants: { + variant: 'solid' + } +}); + +type KbdVariant = NonNullable['variant']>; -const KbdRoot = ({ className, ...props }: KbdProps) => ( - -); +const KbdGroupContext = createContext(undefined); + +export type KbdProps = ComponentProps<'kbd'> & VariantProps; + +const KbdRoot = ({ className, variant, ...props }: KbdProps) => { + const groupVariant = useContext(KbdGroupContext); + + return ( + + ); +}; 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) => ( - +export type KbdGroupProps = ComponentProps<'kbd'> & VariantProps; + +const KbdGroup = ({ className, variant, ...props }: KbdGroupProps) => ( + + + ); KbdGroup.displayName = 'Kbd.Group';