From 332fc98a7b241ac3ffbc2ecf1490d2bc1e8318a4 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 4 Jan 2026 22:57:40 +0000 Subject: [PATCH 01/13] Initial plan From 8d0adb720ae624d28339f4ed90f94c7a40d6a3b8 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 4 Jan 2026 23:08:55 +0000 Subject: [PATCH 02/13] Implement CommandPalette component with tests and docs - Created command-palette.gts with Dialog-based implementation - Added Tabster integration for keyboard navigation - Implemented Input, List, and Item subcomponents - Added comprehensive tests for accessibility and keyboard interactions - Created documentation with examples - Updated exports in index.ts - Fixed pre-existing TypeScript error in menu.gts to allow build to pass Co-authored-by: NullVoxPopuli <199018+NullVoxPopuli@users.noreply.github.com> --- .../docs/5-floaty-bits/command-palette.md | 402 ++++++++++++++++ .../src/components/command-palette.gts | 317 +++++++++++++ ember-primitives/src/components/menu.gts | 1 - ember-primitives/src/index.ts | 1 + .../command-palette-rendering-test.gts | 444 ++++++++++++++++++ 5 files changed, 1164 insertions(+), 1 deletion(-) create mode 100644 docs-app/public/docs/5-floaty-bits/command-palette.md create mode 100644 ember-primitives/src/components/command-palette.gts create mode 100644 test-app/tests/command-palette/command-palette-rendering-test.gts diff --git a/docs-app/public/docs/5-floaty-bits/command-palette.md b/docs-app/public/docs/5-floaty-bits/command-palette.md new file mode 100644 index 000000000..b8b1f5ea6 --- /dev/null +++ b/docs-app/public/docs/5-floaty-bits/command-palette.md @@ -0,0 +1,402 @@ +# Command Palette + +A command palette is a modal dialog that provides quick access to commands and actions through a searchable interface. It's a common pattern in modern applications for power users to navigate and execute actions quickly via keyboard. + +The `` component is built with the `` primitive and provides keyboard navigation via Tabster. It follows the [WAI-ARIA Combobox Pattern](https://www.w3.org/WAI/ARIA/apg/patterns/combobox/) for accessibility. + +## Features + +- **Styleless**: No CSS shipped; only structure, semantics, and state management +- **Accessible**: Proper ARIA roles and attributes for screen readers +- **Keyboard Navigation**: Arrow keys navigate through items, Escape closes the palette +- **Focus Management**: Focus is trapped within the dialog when open and returned to the trigger when closed +- **Composable**: Build your own search/filtering logic with full control over rendering + +## Example + + + +## API + +### `` + +The root component that manages the command palette state. + +#### Arguments + +- `@open` (optional, `boolean`) - Set the initial open state of the command palette +- `@onClose` (optional, `() => void`) - Callback invoked when the palette is closed + +#### Yields + +- `isOpen` (`boolean`) - Current open state of the palette +- `open` (`() => void`) - Function to open the palette +- `close` (`() => void`) - Function to close the palette +- `focusOnClose` (`Modifier`) - Modifier to apply to the trigger element for focus management +- `Dialog` (`Component`) - Dialog wrapper component +- `Input` (`Component`) - Search input component +- `List` (`Component`) - List container component +- `Item` (`Component`) - Individual item component + +### `` + +The dialog wrapper for the command palette. Wraps the `` element. + +### `` + +The search input element with proper ARIA attributes for the combobox pattern. + +#### Attributes + +All standard `` attributes are supported (e.g., `placeholder`, `value`). + +### `` + +The container for command palette items. Handles keyboard navigation via Tabster. + +### `` + +An individual item in the command palette. + +#### Arguments + +- `@onSelect` (optional, `(event: Event) => void`) - Callback invoked when the item is selected + +## Keyboard Interaction + +- **Arrow Down**: Move focus to the next item (cycles to first when at end) +- **Arrow Up**: Move focus to the previous item (cycles to last when at beginning) +- **Tab**: Move focus between input and list +- **Escape**: Close the command palette +- **Click**: Select an item and close the palette + +## Accessibility + +The Command Palette follows the [ARIA Combobox Pattern](https://www.w3.org/WAI/ARIA/apg/patterns/combobox/) with the following features: + +- `role="combobox"` on the input element +- `role="listbox"` on the list container +- `role="option"` on each item +- `aria-controls` linking input to list +- `aria-labelledby` linking list to input +- `aria-expanded` indicating open state +- Focus trap within the dialog when open +- Focus restoration to trigger when closed + +## Examples + +### Basic Usage + +```gjs +import { CommandPalette } from 'ember-primitives'; +import { on } from '@ember/modifier'; + + +``` + +### With Search Filtering + +```gjs +import { CommandPalette } from 'ember-primitives'; +import { on } from '@ember/modifier'; +import { tracked } from '@glimmer/tracking'; + +class MyComponent { + @tracked query = ''; + + commands = ['New File', 'Open File', 'Save', 'Close']; + + get filtered() { + return this.commands.filter(cmd => + cmd.toLowerCase().includes(this.query.toLowerCase()) + ); + } + + handleInput = (e) => { + this.query = e.target.value; + }; +} + +const state = new MyComponent(); + + +``` + +### With Selection Handler + +```gjs +import { CommandPalette } from 'ember-primitives'; +import { on } from '@ember/modifier'; +import { fn } from '@ember/helper'; + +function handleSelect(commandId) { + console.log('Selected:', commandId); + // Execute command logic here +} + + +``` + +### Controlled Open State + +```gjs +import { CommandPalette } from 'ember-primitives'; +import { tracked } from '@glimmer/tracking'; + +class Controller { + @tracked isOpen = false; + + open = () => { this.isOpen = true; }; + close = () => { this.isOpen = false; }; +} + +const state = new Controller(); + + +``` + +## Implementation Notes + +- The Command Palette is built on top of the native `` element, providing native modal behavior +- Keyboard navigation is powered by [Tabster](https://tabster.io/), which handles focus movement between items +- The component is completely styleless - you have full control over the appearance +- Search/filtering logic is not built-in, allowing you to implement custom filtering strategies +- Focus is automatically moved to the input when the palette opens +- The palette closes automatically when an item is selected or when Escape is pressed diff --git a/ember-primitives/src/components/command-palette.gts b/ember-primitives/src/components/command-palette.gts new file mode 100644 index 000000000..06ebd9f8c --- /dev/null +++ b/ember-primitives/src/components/command-palette.gts @@ -0,0 +1,317 @@ +/** + * Command Palette Component + * + * A modal dialog-based command palette with search input and keyboard-navigable list. + * + * References: + * - https://www.w3.org/WAI/ARIA/apg/patterns/dialog-modal/ + * - https://www.w3.org/WAI/ARIA/apg/patterns/combobox/ + * + * Keyboard behaviors provided by tabster: + * - Arrow keys navigate between items + * - Escape closes the palette + * - Focus is trapped within the dialog + */ + +import Component from "@glimmer/component"; +import { tracked } from "@glimmer/tracking"; +import { hash } from "@ember/helper"; +import { on } from "@ember/modifier"; +import { guidFor } from "@ember/object/internals"; + +import { modifier as eModifier } from "ember-modifier"; +import { cell } from "ember-resources"; +import { getTabster, getTabsterAttribute, MoverDirections, setTabsterAttribute } from "tabster"; + +import { Dialog } from "./dialog.gts"; + +import type { Signature as DialogSignature } from "./dialog.gts"; +import type { TOC } from "@ember/component/template-only"; +import type { ModifierLike, WithBoundArgs } from "@glint/template"; + +type Cell = ReturnType>; + +const TABSTER_CONFIG_LIST = getTabsterAttribute( + { + mover: { + direction: MoverDirections.Vertical, + cyclic: true, + }, + }, + true, +); + +export interface Signature { + Args: { + /** + * Optionally set the open state of the command palette + * The state will still be managed internally, + * so this does not need to be a maintained value, but whenever it changes, + * the dialog element will reflect that change accordingly. + */ + open?: boolean; + /** + * When the command palette is closed, this function will be called + */ + onClose?: () => void; + }; + Blocks: { + default: [ + { + /** + * Represents the open state of the command palette + */ + isOpen: boolean; + + /** + * Closes the command palette + */ + close: () => void; + + /** + * Opens the command palette + */ + open: () => void; + + /** + * This modifier should be applied to the button that opens the command palette + * so that it can be re-focused when the palette closes. + */ + focusOnClose: ModifierLike<{ Element: HTMLElement }>; + + /** + * The dialog element wrapper for the command palette + */ + Dialog: WithBoundArgs; + + /** + * The input element for search/filtering + */ + Input: WithBoundArgs; + + /** + * The list container for command items + */ + List: WithBoundArgs; + + /** + * Individual command items + */ + Item: WithBoundArgs; + }, + ]; + }; +} + +type CommandPaletteState = ReturnType; + +interface PrivateItemSignature { + Element: HTMLDivElement; + Args: { + state: CommandPaletteState; + onSelect?: (event: Event) => void; + }; + Blocks: { default: [] }; +} + +export interface ItemSignature { + Element: PrivateItemSignature["Element"]; + Args: Omit; + Blocks: PrivateItemSignature["Blocks"]; +} + +/** + * We focus items on `pointerMove` to achieve the following: + * + * - Mouse over an item (it focuses) + * - Leave mouse where it is and use keyboard to focus a different item + * - Wiggle mouse without it leaving previously focused item + * - Previously focused item should re-focus + * + * If we used `mouseOver`/`mouseEnter` it would not re-focus when the mouse + * wiggles. This is to match native menu implementation. + */ +function focusOnHover(e: PointerEvent) { + const item = e.currentTarget; + + if (item instanceof HTMLElement) { + item?.focus(); + } +} + +const CommandPaletteItem: TOC = ; + +interface PrivateListSignature { + Element: HTMLDivElement; + Args: { + state: CommandPaletteState; + listId: string; + inputId: string; + }; + Blocks: { default: [] }; +} + +export interface ListSignature { + Element: PrivateListSignature["Element"]; + Blocks: PrivateListSignature["Blocks"]; +} + +const CommandPaletteList: TOC = ; + +interface PrivateInputSignature { + Element: HTMLInputElement; + Args: { + state: CommandPaletteState; + inputId: string; + listId: string; + }; + Blocks: {}; +} + +export interface InputSignature { + Element: PrivateInputSignature["Element"]; +} + +const focusInput = eModifier<{ + Element: HTMLInputElement; +}>((element) => { + // Focus the input when it's rendered + void (async () => { + await Promise.resolve(); + element.focus(); + })(); +}); + +const CommandPaletteInput: TOC = ; + +interface PrivateDialogSignature { + Element: HTMLDivElement; + Args: { + state: CommandPaletteState; + dialogProps: DialogSignature["Blocks"]["default"][0]; + }; + Blocks: { default: [] }; +} + +export interface CommandPaletteDialogSignature { + Element: PrivateDialogSignature["Element"]; + Blocks: PrivateDialogSignature["Blocks"]; +} + +const installDialog = eModifier<{ + Element: HTMLElement; + Args: { + Named: { + state: CommandPaletteState; + }; + }; +}>((element, _: [], { state }) => { + // Set tabster attributes for the dialog content + setTabsterAttribute(element, { + deloser: {}, + }); + + // Listen for the escape key + function onDocumentKeydown(e: KeyboardEvent) { + if (e.key === "Escape") { + state.close(); + } + } + + document.addEventListener("keydown", onDocumentKeydown); + + return () => { + document.removeEventListener("keydown", onDocumentKeydown); + }; +}); + +const CommandPaletteDialog: TOC = ; + +function createCommandPaletteState( + dialog: DialogSignature["Blocks"]["default"][0], + owner: object, +) { + const guid = guidFor(owner); + const inputId = `command-palette-input-${guid}`; + const listId = `command-palette-list-${guid}`; + + return { + inputId, + listId, + get isOpen() { + return dialog.isOpen; + }, + close: dialog.close, + open: dialog.open, + focusOnClose: dialog.focusOnClose, + }; +} + +export class CommandPalette extends Component { + +} + +export default CommandPalette; diff --git a/ember-primitives/src/components/menu.gts b/ember-primitives/src/components/menu.gts index 00ef3ccc8..b2864cd4a 100644 --- a/ember-primitives/src/components/menu.gts +++ b/ember-primitives/src/components/menu.gts @@ -102,7 +102,6 @@ const Item: TOC =