diff --git a/docs-app/app/styles/app.css b/docs-app/app/styles/app.css index d73e42132..d7a8d5f9b 100644 --- a/docs-app/app/styles/app.css +++ b/docs-app/app/styles/app.css @@ -87,7 +87,12 @@ body { padding-bottom: 0; } -.prose .featured-demo input { +/* + * Demo inputs sit on the frame's gradient, so they are dark by default. A + * combobox belongs to a palette that paints its own surface and follows the + * colour scheme, so it keeps its own colour. + */ +.prose .featured-demo input:not([role="combobox"]) { color: black; } diff --git a/docs-app/app/templates/5-floaty-bits/command-palette.gjs.md b/docs-app/app/templates/5-floaty-bits/command-palette.gjs.md new file mode 100644 index 000000000..362d50874 --- /dev/null +++ b/docs-app/app/templates/5-floaty-bits/command-palette.gjs.md @@ -0,0 +1,722 @@ +# Command Palette + +A combobox over a listbox. The K pattern. + +`` gives you the input, the keyboard, and the aria wiring. It does not filter, and it does not open or close anything. Put it inside a [``](/5-floaty-bits/dialog.md) for the K case, or render it on a page of its own. + + + +## Without a modal + +Leave out the `` and the palette is a search box with a list under it, for a page a reader can link to. + + + +## Rendering your own rows + +Leave `@items` off and pass a block instead. Then you render the rows. Choosing is still delegated, so a row is markup rather than a listener, and `@onSelect` is handed the event that reached it. + + + +## Results from a request + +`@items` does not have to hold anything when the reader types. Set it once the request answers, and the palette re-activates the top row, so Enter still chooses the best match. + + + +Only the source of the rows changed. `@onQueryChange` starts the request, `@items` takes what comes back, and the palette does the rest. + +`LinkItem` is a row that is also a link. Enter dispatches a real click on the anchor, so the router navigates exactly as it would have for a mouse, and -click still opens a new tab. + +`LinkItem` needs [`properLinks`](/4-routing/proper-links.md) set up in the application route for a plain `` support. + +```hbs + + {{#each this.results as |result|}} + {{result.title}} + {{/each}} + +``` + +## Closing, and opening + +The palette does not open or close anything by itself. Hand it the two functions [``](/5-floaty-bits/dialog.md) yields: + +```hbs + + + +``` + +`` renders the element, so a trigger cannot sit outside it. That suits a palette, which opens on a key combination. When a button opens it instead, use [``](/5-floaty-bits/modal.md). The palette composes with either. + +`@onSelect` runs every time a row is chosen, whichever row and however it was rendered. That is why `d.close` is all it takes to close on select. What a row does belongs to the row: `onSelect` on its `@items` entry, or `@onSelect` on its `Item`. + +`@hotkey` calls `@onOpen`. Without `@onOpen` there is nothing to open, and no listener is installed. + +## Styling + +The active row carries `data-active="true"` and `aria-selected="true"`. Style either. + +Do not use `:hover` for this. The pointer and the keyboard set the same active row, so one selector covers both, and the two can never disagree about what Enter will do. + +## Install + +```hbs live + +``` + +## Accessibility + +Adheres to the [Combobox WAI-ARIA design pattern][apg-combobox], in its list-autocomplete form: the `` is the combobox, the rows are its listbox, and `aria-activedescendant` reports the active row without moving focus. + +### Keyboard Interactions + +| key | description | +| :-------------------------------------: | :-------------------------------------------------------------------------------------------- | +| `@hotkey`, if set | Opens the surrounding `` from anywhere on the page. | +| ArrowDown ArrowUp | Moves the active row, wrapping at either end. Focus does not move. | +| Enter | Chooses the active row, or the first row when you have not moved yet. | +| Esc | Closes the `` and returns focus to whatever opened it. Handled by the browser. | +| Home End | Left to the browser: in a text field these move the caret, so the palette does not take them. | + +## API Reference + +`Signature` is a union of the two forms, so each is documented on its own rather than as one shape with arguments that only apply half the time. + +### With `@items` + +```gjs live no-shadow +import { ComponentSignature } from "kolay"; + + +``` + +### With a block + +```gjs live no-shadow +import { ComponentSignature } from "kolay"; + + +``` + +### Item + +```gjs live no-shadow +import { ComponentSignature } from "kolay"; + + +``` + +### LinkItem + +```gjs live no-shadow +import { ComponentSignature } from "kolay"; + + +``` + +[apg-combobox]: https://www.w3.org/WAI/ARIA/apg/patterns/combobox/ diff --git a/docs-app/app/templates/5-floaty-bits/dialog.gjs.md b/docs-app/app/templates/5-floaty-bits/dialog.gjs.md new file mode 100644 index 000000000..bc7a04248 --- /dev/null +++ b/docs-app/app/templates/5-floaty-bits/dialog.gjs.md @@ -0,0 +1,147 @@ +# Dialog + +A small utility component. It renders a modal `` around its block, and yields `open` and `close` already wired to that element. + +Everything inside is hidden until it opens, and a trigger cannot sit outside it, so this is for content that opens and closes itself: a key combination, a router hook, anything that already has a reason to run. When a button beside the dialog is what opens it, use [``](/5-floaty-bits/modal.md) instead, which hands you the element to place. + +Here that content is a [``](/5-floaty-bits/command-palette.md), which takes `open` for its hotkey and `close` for its selection: + + + +## What the browser already does + +- Escape closes it. +- Focus moves into the dialog when it opens, and back to whatever had it when it closes. +- The rest of the page is inert while it is open, and `::backdrop` styles the layer behind it. + +[`closedby`][mdn-closedby] controls which actions dismiss the dialog. `` does not set it, so pass it for click-outside: + +```hbs + +``` + +## Install + +```hbs live + +``` + +## Accessibility + +Adheres to the [Dialog (Modal) WAI-ARIA design pattern][apg-dialog]. The `` element does all of it: focus moves in on open, stays there while open, and returns on close. + +## API Reference + +```gjs live no-shadow +import { ComponentSignature } from "kolay"; + + +``` + +[mdn-closedby]: https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/dialog#closedby +[apg-dialog]: https://www.w3.org/WAI/ARIA/apg/patterns/dialog-modal/ diff --git a/docs-app/package.json b/docs-app/package.json index f976bd908..8abb14a7b 100644 --- a/docs-app/package.json +++ b/docs-app/package.json @@ -95,7 +95,7 @@ "ember-qunit": "^9.0.4", "ember-resources": "^7.0.7", "ember-scoped-css": "^2.0.4", - "ember-source": "^7.1.0", + "ember-source": "~7.3.0-beta.1", "ember-template-lint": "^7.9.3", "ember-welcome-page": "^8.0.5", "eslint": "^9.39.2", diff --git a/ember-primitives/src/components/command-palette.gts b/ember-primitives/src/components/command-palette.gts new file mode 100644 index 000000000..25fe02495 --- /dev/null +++ b/ember-primitives/src/components/command-palette.gts @@ -0,0 +1,719 @@ +/** + * References: + * - https://www.w3.org/WAI/ARIA/apg/patterns/combobox/ + * - https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Attributes/aria-activedescendant + * + * A combobox (the input) over a listbox (the results). + * + * Focus never leaves the input; `aria-activedescendant` is what moves. This is + * why there is no tabster mover here: the arrow keys must not move focus, or + * the user stops being able to type. Tabster still does the finding. + * + * Filtering is not this component's job. Render the results you want, in the + * order you want. + */ +import Component from "@glimmer/component"; +import { tracked } from "@glimmer/tracking"; +import { assert } from "@ember/debug"; +import { registerDestructor } from "@ember/destroyable"; +import { hash } from "@ember/helper"; +import { on } from "@ember/modifier"; +import { guidFor } from "@ember/object/internals"; + +import { modifier as eModifier } from "ember-modifier"; +import { getTabster } from "tabster"; +// temp +// https://github.com/tracked-tools/tracked-toolbox/issues/38 +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-expect-error +import { localCopy } from "tracked-toolbox"; + +import { Link, type Signature as LinkSignature } from "./link.gts"; + +import type { TOC } from "@ember/component/template-only"; +import type Owner from "@ember/owner"; +import type { ModifierLike, WithBoundArgs } from "@glint/template"; + +const OPTION = '[role="option"]'; + +function isMac() { + return navigator.userAgent.includes("Mac OS"); +} + +/** + * Matches a hotkey description, such as `"mod+k"`, against a keyboard event. + * + * `mod` is Meta on macOS and Control everywhere else, + * the same normalization `` uses to render one. + */ +function matches(event: KeyboardEvent, hotkey: string) { + const parts = hotkey + .toLowerCase() + .split("+") + .map((part) => part.trim()); + const key = parts.pop(); + const modifiers = new Set(parts); + const mod = modifiers.has("mod"); + + return ( + event.key.toLowerCase() === key && + event.metaKey === (modifiers.has("meta") || (mod && isMac())) && + event.ctrlKey === (modifiers.has("ctrl") || (mod && !isMac())) && + event.altKey === modifiers.has("alt") && + event.shiftKey === modifiers.has("shift") + ); +} + +export interface ItemSignature { + Element: HTMLDivElement; + Blocks: { default: [] }; +} + +interface PrivateItemSignature { + Element: ItemSignature["Element"]; + Args: { activeId: string | undefined }; + Blocks: ItemSignature["Blocks"]; +} + +class Item extends Component { + id = guidFor(this); + + get isActive() { + return this.args.activeId === this.id; + } + + +} + +export interface LinkItemSignature { + Element: HTMLAnchorElement; + Args: LinkSignature["Args"]; + Blocks: { default: [] }; +} + +interface PrivateLinkItemSignature { + Element: LinkItemSignature["Element"]; + Args: LinkItemSignature["Args"] & { activeId: string | undefined }; + Blocks: LinkItemSignature["Blocks"]; +} + +/** + * An option that is also a link. Enter dispatches a real click on + * the anchor, so the router navigates exactly as it would have for a mouse. + */ +class LinkItem extends Component { + id = guidFor(this); + + get isActive() { + return this.args.activeId === this.id; + } + + +} + +export interface ListSignature { + Element: HTMLDivElement; + Blocks: { + default: [ + { + Item: WithBoundArgs; + LinkItem: WithBoundArgs; + }, + ]; + }; +} + +interface PrivateListSignature { + Element: ListSignature["Element"]; + Args: { + id: string; + register: ModifierLike<{ Element: HTMLElement }>; + onPointerMove: (event: PointerEvent) => void; + onClick: (event: MouseEvent) => void; + Item: ListSignature["Blocks"]["default"][0]["Item"]; + LinkItem: ListSignature["Blocks"]["default"][0]["LinkItem"]; + }; + Blocks: ListSignature["Blocks"]; +} + +/** + * The pointer is handled here rather than on each option, and on + * `pointermove` rather than `pointerenter`, so that an option under a resting + * cursor re-activates when the cursor moves after the keyboard has activated + * something else. Native menus behave this way. + * + * `:hover` cannot do this job. There is one active option, it is what + * Enter chooses, and it is what `aria-activedescendant` reports. + * Hovering while the keyboard has a different option active would light two + * rows and tell a screen reader about neither, so the pointer sets the same + * state the arrow keys do instead of painting its own. + */ +const List: TOC = ; + +export interface InputSignature { + Element: HTMLInputElement; +} + +interface PrivateInputSignature { + Element: InputSignature["Element"]; + Args: { + listId: string; + activeId: string | undefined; + query: string; + onInput: (event: Event) => void; + onKeydown: (event: KeyboardEvent) => void; + }; +} + +const Input: TOC = ; + +/** + * One entry in the default layout. A bare string is the label. + */ +export type PaletteItem = + | string + | { + label: string; + description?: string; + icon?: string; + }; + +const labelOf = (item: PaletteItem) => (typeof item === "string" ? item : item.label); +const descriptionOf = (item: PaletteItem) => + typeof item === "string" ? undefined : item.description; +const iconOf = (item: PaletteItem) => (typeof item === "string" ? undefined : item.icon); + +/** + * The default layout: hand it the rows and it renders the whole palette. + */ +export interface ItemsSignature { + Args: { + /** + * The text in the input. + * + * The state is managed internally, so this does not need to be a + * maintained value, but whenever it changes, the input reflects it. Pair + * it with `@onQueryChange` to keep the query somewhere else, such as a + * query param. + */ + query?: string; + /** + * Called with the input's text every time it changes. + */ + onQueryChange?: (query: string) => void; + /** + * A key combination that calls `@onOpen` from anywhere on the page, such + * as `"mod+k"`. `mod` is Meta on macOS and Control + * everywhere else. + * + * Needs `@onOpen` to have anything to do. No listener is installed + * without both. + */ + hotkey?: string; + /** + * Called when `@hotkey` is pressed. Hand it the `open` of whatever the + * palette is in: + * + * ```hbs + * + * ``` + */ + onOpen?: () => void; + /** + * The entries to render. Each is a string, or an object with a `label` + * and optionally a `description` and an `icon`. + */ + items: PaletteItem[]; + /** + * Called every time a row is chosen, with the entry that was chosen. + * This is where a modal palette closes itself: + * + * ```hbs + * + * + * + * ``` + * + * Also where a row's own action goes, since the entry it is handed says + * which row was chosen. + */ + onSelect?: (item: PaletteItem, event: Event) => void; + /** + * The input's placeholder, and its accessible name. + * + * Defaults to "Search". + */ + placeholder?: string; + }; + /** + * No blocks: this form renders the rows. Passing one is an error. + */ + Blocks: Record; +} + +/** + * The composed form: you render the rows. + */ +export interface BlockSignature { + Args: { + /** + * The text in the input. + * + * The state is managed internally, so this does not need to be a + * maintained value, but whenever it changes, the input reflects it. Pair + * it with `@onQueryChange` to keep the query somewhere else, such as a + * query param. + */ + query?: string; + /** + * Called with the input's text every time it changes. + */ + onQueryChange?: (query: string) => void; + /** + * A key combination that calls `@onOpen` from anywhere on the page, such + * as `"mod+k"`. `mod` is Meta on macOS and Control + * everywhere else. + * + * Needs `@onOpen` to have anything to do. No listener is installed + * without both. + */ + hotkey?: string; + /** + * Called when `@hotkey` is pressed. Hand it the `open` of whatever the + * palette is in: + * + * ```hbs + * + * ``` + */ + onOpen?: () => void; + /** + * Not for this form: the rows come from the block. + */ + items?: never; + /** + * Not for this form: set it on `Input` yourself. + */ + placeholder?: never; + /** + * Called every time a row is chosen. Hand it `close` to make a modal + * palette close itself. + * + * The rows are yours here, so there is no entry to hand back. Which row + * was chosen is `event.target.closest("[role=option]")`. + */ + onSelect?: (event: Event) => void; + }; + Blocks: { + default: [ + { + /** + * The current text of the input. + */ + query: string; + /** + * Sets the text of the input, for a "clear" button or a suggestion. + */ + setQuery: (query: string) => void; + /** + * The ``, wired as a combobox over `List`. + */ + Input: WithBoundArgs< + typeof Input, + "listId" | "activeId" | "query" | "onInput" | "onKeydown" + >; + /** + * The listbox the rows are rendered into. + */ + List: WithBoundArgs< + typeof List, + "id" | "register" | "onPointerMove" | "onClick" | "Item" | "LinkItem" + >; + }, + ]; + }; +} + +export type Signature = ItemsSignature | BlockSignature; + +export class CommandPalette extends Component { + listId = guidFor(this); + + /** + * Held rather than looked up by id, because `document.getElementById` does + * not cross into a shadow root. A plain field: it is read when a key is + * pressed, never while rendering. + */ + #list: HTMLElement | undefined; + + /** + * Which row is active, as an id rather than an element or a focus state. + * + * This is unusual for this library, where keyboard navigation means tabster + * moving focus. A combobox cannot do that: focus has to stay in the + * `` or the reader stops being able to type. So nothing among the + * rows is ever focused, there is no focus for tabster to track, and what + * moves instead is `aria-activedescendant` -- which is an id, on the input, + * pointing at a row. Holding the id is holding exactly what that attribute + * needs. + * + * Tabster still does the finding, in `#find`. This only remembers which of + * the rows it landed on. + */ + @tracked activeId: string | undefined; + + // eslint-disable-next-line @typescript-eslint/no-unsafe-call + @localCopy("args.query") declare _query: string; + + constructor(owner: Owner, args: Signature["Args"]) { + super(owner, args); + + document.addEventListener("keydown", this.handleHotkey); + + registerDestructor(this, () => { + document.removeEventListener("keydown", this.handleHotkey); + }); + } + + get placeholder() { + return this.args.placeholder ?? "Search"; + } + + /** + * Whether `@items` was passed, not whether it has anything in it: an empty + * array is falsy in a template, and a palette whose results have gone away + * still has to render the input they would be typed into. + */ + get hasItems() { + return this.args.items !== undefined; + } + + /** + * Each form is called the way its own type describes: with the entry that + * was chosen, or with the event alone when the rows are the caller's. + * + * `@items` is what tells the two apart, and it is `never` on the form that + * does not take it, so testing it narrows `this.args` to one of them. + */ + #select(option: HTMLElement, event: Event) { + if (this.args.items === undefined) { + this.args.onSelect?.(event); + + return; + } + + const entry = this.#entryFor(option, this.args.items); + + assert( + "[BUG] a row of the default layout was chosen, but is not among `@items`", + entry !== undefined, + ); + + this.args.onSelect?.(entry, event); + } + + /** + * `@items` and a block are two ways to say the same thing, and saying both + * means one of them is being quietly ignored. + */ + get bothGiven() { + assert( + " was given both `@items` and a block. Use one: `@items` renders the rows for you, a block renders them yourself.", + false, + ); + + return ""; + } + + get query() { + return this._query ?? ""; + } + set query(value: string) { + this._query = value; + } + + registerList = eModifier((element: HTMLElement) => { + this.#list = element; + }); + + get #activeElement() { + const { activeId } = this; + + if (!activeId) return undefined; + + // scoped to the listbox, so this works inside a shadow root + return this.#list?.querySelector(`[id="${activeId}"]`) ?? undefined; + } + + /** + * The next, previous, first or last option. + * + * Tabster does the finding, so hidden and inert options are skipped by the + * same rules as everything else that moves around the page. It has to be + * set up by the app, the same way `` requires it. + */ + #find(direction: "next" | "prev" | "first" | "last") { + const container = this.#list; + + if (!container) return undefined; + + const tabster = getTabster(window); + + assert( + " needs tabster, which the application sets up. " + + "Call `setupTabster` from 'ember-primitives/tabster' in your application route. " + + "See https://tabster.io/docs/core", + tabster, + ); + + const options = { container, includeProgrammaticallyFocusable: true }; + const currentElement = this.#activeElement; + const { focusable } = tabster; + + if (direction === "first") return focusable.findFirst(options); + if (direction === "last") return focusable.findLast(options); + + if (!currentElement) { + return direction === "next" ? focusable.findFirst(options) : focusable.findLast(options); + } + + const found = + direction === "next" + ? focusable.findNext({ ...options, currentElement }) + : focusable.findPrev({ ...options, currentElement }); + + // wrap, rather than stop, at either end + return ( + found ?? (direction === "next" ? focusable.findFirst(options) : focusable.findLast(options)) + ); + } + + #activate(element: HTMLElement | null | undefined) { + if (!element) return; + + this.activeId = element.id; + element.scrollIntoView({ block: "nearest" }); + } + + setQuery = (query: string) => { + this.query = query; + // the results are about to be somebody else's; whatever was active is not + this.activeId = undefined; + this.args.onQueryChange?.(query); + }; + + handleInput = (event: Event) => { + const { target } = event; + + assert("[BUG] input event without an input", target instanceof HTMLInputElement); + + this.setQuery(target.value); + }; + + handlePointerMove = (event: PointerEvent) => { + const { target } = event; + + if (!(target instanceof Element)) return; + + const option = target.closest(OPTION); + + if (option) { + this.activeId = option.id; + } + }; + + handleKeydown = (event: KeyboardEvent) => { + // mid-composition (IME), the arrow keys belong to the candidate window + if (event.isComposing) return; + + switch (event.key) { + case "ArrowDown": { + event.preventDefault(); + this.#activate(this.#find("next")); + + return; + } + case "ArrowUp": { + event.preventDefault(); + this.#activate(this.#find("prev")); + + return; + } + case "Enter": { + // nothing arrowed yet chooses the first result, so a reader can type + // and press Enter without leaving the keys they were already on + const active = this.#activeElement ?? this.#find("first"); + + if (!active) return; + + event.preventDefault(); + // a real click, so one handler covers the mouse and the keyboard, and + // an anchor navigates the way the browser would have + active.click(); + + return; + } + /** + * Home and End are left to the browser: in an editable combobox they + * move the caret, which is what the APG asks for. + */ + } + }; + + handleHotkey = (event: KeyboardEvent) => { + const { hotkey, onOpen } = this.args; + + if (!hotkey || !onOpen) return; + if (!matches(event, hotkey)) return; + + event.preventDefault(); + onOpen(); + }; + + /** + * The entry a row came from, for the default layout. Rows are rendered one + * per entry and in order, so a row's position among the options is its + * entry's position in `@items`. A block form has no entries, and gets + * `undefined`. + */ + #entryFor(option: HTMLElement, items: PaletteItem[]) { + const options = this.#list?.querySelectorAll(OPTION); + + for (let i = 0; i < (options?.length ?? 0); i++) { + if (options?.[i] === option) return items[i]; + } + + return undefined; + } + + /** + * Choosing is delegated, so a row is markup rather than a listener, and + * `@onSelect` is reached the same way however the rows were rendered. + */ + handleClick = (event: MouseEvent) => { + const { target } = event; + + if (!(target instanceof Element)) return; + + const option = target.closest(OPTION); + + if (!option) return; + + /** + * A modified click on a link opens it somewhere else and leaves the + * reader where they are, so the palette stays where they left it. + */ + if (option.closest("a")) { + if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return; + if (event.button !== 0) return; + } + + this.#select(option, event); + }; + + +} + +export default CommandPalette; diff --git a/ember-primitives/src/components/dialog.gts b/ember-primitives/src/components/dialog.gts index f6b5ccf2d..f3a9f3db2 100644 --- a/ember-primitives/src/components/dialog.gts +++ b/ember-primitives/src/components/dialog.gts @@ -228,7 +228,77 @@ class ModalDialog extends Component { }; } +export interface SimpleSignature { + Element: HTMLDialogElement; + Blocks: { + default: [ + { + /** + * Opens the dialog, modally. + */ + open: () => void; + /** + * Closes the dialog. + */ + close: () => void; + }, + ]; + }; +} + +/** + * A modal `` around whatever you put in it, and the two things needed + * to drive it. + * + * ```gjs + * + * + * + * ``` + * + * The element is here, so a trigger cannot be: whatever opens this either + * lives inside it, or is a key combination. For a dialog opened by a button + * beside it, use ``, which hands you the element to place. + * + * Escape closes it and focus returns to whatever opened it, which + * is the `` element's own behaviour. Set `closedby` to change which + * actions dismiss it. + */ +export class Dialog extends Component { + /** + * A plain field rather than tracked state: it is read when somebody calls + * `open` or `close`, never while rendering, so nothing has to settle in a + * second pass. + */ + #element: HTMLDialogElement | undefined; + + register = eModifier((element: HTMLDialogElement) => { + this.#element = element; + + return () => { + this.#element = undefined; + }; + }); + + /** + * `showModal` on an open dialog, and `close` on a closed one, are both + * no-ops per spec, so neither needs guarding here. + */ + open = () => { + this.#element?.showModal(); + }; + + close = () => { + this.#element?.close(); + }; + + +} + export const Modal = ModalDialog; -export const Dialog = ModalDialog; export default ModalDialog; diff --git a/ember-primitives/src/index.ts b/ember-primitives/src/index.ts index 0b1d35e58..12d9fe0f4 100644 --- a/ember-primitives/src/index.ts +++ b/ember-primitives/src/index.ts @@ -20,7 +20,8 @@ export type { } from './components/accordion/public.ts'; export { Avatar } from './components/avatar.gts'; export { Breadcrumb } from './components/breadcrumb.gts'; -export { Dialog, Dialog as Modal } from './components/dialog.gts'; +export { CommandPalette } from './components/command-palette.gts'; +export { Dialog, Modal } from './components/dialog.gts'; export { Drawer } from './components/drawer.gts'; export { ExternalLink } from './components/external-link.gts'; export { Form } from './components/form.gts'; diff --git a/package.json b/package.json index 0d04b8ccb..527fe8f0c 100644 --- a/package.json +++ b/package.json @@ -57,7 +57,7 @@ "ember-element-helper": "^0.8.8", "ember-primitives": "workspace:^", "ember-repl": "^8.0.0", - "ember-source": "^7.1.0", + "ember-source": "~7.3.0-beta.1", "kolay": "github:universal-ember/kolay#dist", "reactiveweb": "^1.9.1", "tracked-toolbox": "^3.0.0" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3d1ef18e9..91bf49828 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -16,7 +16,7 @@ overrides: ember-element-helper: ^0.8.8 ember-primitives: workspace:^ ember-repl: ^8.0.0 - ember-source: ^7.1.0 + ember-source: ~7.3.0-beta.1 kolay: github:universal-ember/kolay#dist reactiveweb: ^1.9.1 tracked-toolbox: ^3.0.0 @@ -62,7 +62,7 @@ importers: version: 3.23.0 '@universal-ember/docs-support': specifier: workspace:* - version: file:packages/docs-support(44d57c380a15df4809fb9a178bbf4658) + version: file:packages/docs-support(643be662b7daabf7b7b7f845ec7a656c) assert: specifier: ^2.0.0 version: 2.1.0 @@ -80,7 +80,7 @@ importers: version: 2.0.0(@babel/core@7.29.0) ember-mobile-menu: specifier: ^6.0.0 - version: 6.1.0(f99eb3e0deb9fd5eb4258289317e9973) + version: 6.1.0(ef7bc343ef0b81bce83cc9352f58adc3) ember-modifier: specifier: ^4.3.0 version: 4.3.0(@babel/core@7.29.0) @@ -239,8 +239,8 @@ importers: specifier: ^2.0.4 version: 2.2.2(ember-template-lint@7.9.3) ember-source: - specifier: ^7.1.0 - version: 7.1.0(@glimmer/component@2.1.1) + specifier: ~7.3.0-beta.1 + version: 7.3.0-beta.1(@glimmer/component@2.1.1) ember-template-lint: specifier: ^7.9.3 version: 7.9.3 @@ -411,8 +411,8 @@ importers: specifier: ^7.0.7 version: 7.0.7(2935cfb77147881c8250be8c1bb7d0d1) ember-source: - specifier: ^7.1.0 - version: 7.1.0(@glimmer/component@2.1.1) + specifier: ~7.3.0-beta.1 + version: 7.3.0-beta.1(@glimmer/component@2.1.1) ember-template-lint: specifier: ^7.9.3 version: 7.9.3 @@ -463,7 +463,7 @@ importers: version: 2.3.2(@babel/core@7.29.0) ember-mobile-menu: specifier: ^6.0.0 - version: 6.1.0(f99eb3e0deb9fd5eb4258289317e9973) + version: 6.1.0(ef7bc343ef0b81bce83cc9352f58adc3) ember-modifier: specifier: ^4.3.0 version: 4.3.0(@babel/core@7.29.0) @@ -532,8 +532,8 @@ importers: specifier: ^2.0.4 version: 2.2.2(ember-template-lint@7.9.3) ember-source: - specifier: ^7.1.0 - version: 7.1.0(@glimmer/component@2.1.1) + specifier: ~7.3.0-beta.1 + version: 7.3.0-beta.1(@glimmer/component@2.1.1) ember-template-lint: specifier: ^7.9.3 version: 7.9.3 @@ -705,7 +705,7 @@ importers: version: 1.1.3 ember-load-initializers: specifier: ^3.0.1 - version: 3.0.1(b44793b7d4517f1faf6dca9cfb99f5ed) + version: 3.0.1(c9955039c36bad04f9664949a3024519) ember-modifier: specifier: ^4.3.0 version: 4.3.0(@babel/core@7.29.0) @@ -719,8 +719,8 @@ importers: specifier: ^13.1.1 version: 13.2.0 ember-source: - specifier: ^7.1.0 - version: 7.1.0(@glimmer/component@2.1.1) + specifier: ~7.3.0-beta.1 + version: 7.3.0-beta.1(@glimmer/component@2.1.1) ember-source-channel-url: specifier: ^3.0.0 version: 3.0.0(encoding@0.1.13) @@ -3904,7 +3904,7 @@ packages: resolution: {integrity: sha512-LY92LRkO+wDiqeIjni3IXn1xV8lIUJzIm7Ywh7+sGeKpxy2qBtSlnyyFIAxBjCfD1RrM3wGSh3WKA41y+LS5lw==} peerDependencies: '@ember/test-helpers': '>=3.0.0' - ember-source: ^7.1.0 + ember-source: ~7.3.0-beta.1 peerDependenciesMeta: '@ember/test-helpers': optional: true @@ -3913,7 +3913,7 @@ packages: resolution: {integrity: sha512-qV3vxJKw5+7TVDdtdLPy8PhVsh58MlK8jwzqh5xeOwJPNP7o0+BlhvwoIlLYTPzGaHdfjEIFCgVSyMRGd74E1g==} engines: {node: '>= 18.*'} peerDependencies: - ember-source: ^7.1.0 + ember-source: ~7.3.0-beta.1 ember-mobile-menu@6.1.0: resolution: {integrity: sha512-GbQYyZ7ABVLzBJZunuINJFfyNr+ow9aSMaOJJkjIy3DnlE6+KfC6w3sovykaZEaOSAmTYuzRj6ST24RJgqXAeA==} @@ -3994,8 +3994,8 @@ packages: engines: {node: 10.* || 12.* || >= 14} hasBin: true - ember-source@7.1.0: - resolution: {integrity: sha512-qOHhTiVMeYcNp2UQKuAqsf33LKgNtzqvw46dQX5AqutTfXXPn4KYR0+aiGQdJaCuzs81nFvvTDJuHzELOZ5UBg==} + ember-source@7.3.0-beta.1: + resolution: {integrity: sha512-EMAhw0C5bEIuaz8SzEu9v9mIR2tTflE0y/zFavbX1a5ZuCsI/d8aZCEK9lrq4buz8TZk/vA9scinUakoyaLlSQ==} engines: {node: '>= 20.19'} peerDependencies: '@glimmer/component': ^2.0.0 @@ -10119,13 +10119,13 @@ snapshots: '@ungap/structured-clone@1.3.0': {} - '@universal-ember/docs-support@file:packages/docs-support(44d57c380a15df4809fb9a178bbf4658)': + '@universal-ember/docs-support@file:packages/docs-support(643be662b7daabf7b7b7f845ec7a656c)': dependencies: '@embroider/addon-shim': 1.10.3 '@fontsource/lexend': 5.2.11 change-case: 5.4.4 decorator-transforms: 2.3.2(@babel/core@7.29.0) - ember-mobile-menu: 6.1.0(f99eb3e0deb9fd5eb4258289317e9973) + ember-mobile-menu: 6.1.0(ef7bc343ef0b81bce83cc9352f58adc3) ember-modifier: 4.3.0(@babel/core@7.29.0) ember-primitives: file:ember-primitives(5c0eaebf1b4b32661be1beb69576d86a) ember-resources: 7.0.7(2935cfb77147881c8250be8c1bb7d0d1) @@ -11427,30 +11427,30 @@ snapshots: - '@babel/core' - supports-color - ember-gesture-modifiers@6.1.0(b3308737fb0c0c38e0c6422436071d39): + ember-gesture-modifiers@6.1.0(1af90b599e0cc51c2284ccd9490a685a): dependencies: '@embroider/addon-shim': 1.10.2 decorator-transforms: 2.3.2(@babel/core@7.29.0) ember-modifier: 4.3.0(@babel/core@7.29.0) - ember-source: 7.1.0(@glimmer/component@2.1.1) + ember-source: 7.3.0-beta.1(@glimmer/component@2.1.1) optionalDependencies: '@ember/test-helpers': 5.4.2(c8332dc3f90c0e60eb130a3a85f980b9) transitivePeerDependencies: - '@babel/core' - supports-color - ember-load-initializers@3.0.1(b44793b7d4517f1faf6dca9cfb99f5ed): + ember-load-initializers@3.0.1(c9955039c36bad04f9664949a3024519): dependencies: - ember-source: 7.1.0(@glimmer/component@2.1.1) + ember-source: 7.3.0-beta.1(@glimmer/component@2.1.1) - ember-mobile-menu@6.1.0(f99eb3e0deb9fd5eb4258289317e9973): + ember-mobile-menu@6.1.0(ef7bc343ef0b81bce83cc9352f58adc3): dependencies: '@ember/test-waiters': 4.1.1(c8332dc3f90c0e60eb130a3a85f980b9) '@embroider/addon-shim': 1.10.2 '@glimmer/component': 2.1.1 decorator-transforms: 2.3.2(@babel/core@7.29.0) ember-concurrency: 5.2.0(c8332dc3f90c0e60eb130a3a85f980b9) - ember-gesture-modifiers: 6.1.0(b3308737fb0c0c38e0c6422436071d39) + ember-gesture-modifiers: 6.1.0(1af90b599e0cc51c2284ccd9490a685a) ember-modifier: 4.3.0(@babel/core@7.29.0) ember-primitives: file:ember-primitives(5c0eaebf1b4b32661be1beb69576d86a) tracked-built-ins: 4.1.2(@babel/core@7.29.0) @@ -11593,7 +11593,7 @@ snapshots: transitivePeerDependencies: - encoding - ember-source@7.1.0(@glimmer/component@2.1.1): + ember-source@7.3.0-beta.1(@glimmer/component@2.1.1): dependencies: '@babel/core': 7.29.0 '@embroider/addon-shim': 1.10.3 @@ -11602,7 +11602,6 @@ snapshots: backburner.js: 2.8.0 broccoli-file-creator: 2.1.1 chalk: 4.1.2 - ember-cli-babel: 8.3.1(@babel/core@7.29.0) ember-cli-get-component-path-option: 1.0.0 ember-cli-normalize-entity-name: 1.0.0 ember-cli-path-utils: 1.0.0 diff --git a/test-app/tests/command-palette/command-palette-test.gts b/test-app/tests/command-palette/command-palette-test.gts new file mode 100644 index 000000000..64bdc32e0 --- /dev/null +++ b/test-app/tests/command-palette/command-palette-test.gts @@ -0,0 +1,472 @@ +import { fn } from '@ember/helper'; +import { on } from '@ember/modifier'; +import { + clearRender, + click, + currentURL, + fillIn, + find, + findAll, + render, + resetOnerror, + setupOnerror, + triggerEvent, + triggerKeyEvent, + visit, +} from '@ember/test-helpers'; +import { module, test } from 'qunit'; +import { setupApplicationTest, setupRenderingTest } from 'ember-qunit'; + +import { CommandPalette, Dialog, Modal } from 'ember-primitives'; + +import { setupRouting, setupTabster } from 'ember-primitives/test-support'; + +const COMMANDS = ['Open File', 'Open Folder', 'Close Window']; + +function activeText() { + const id = find('input')?.getAttribute('aria-activedescendant'); + + return id ? document.getElementById(id)?.textContent?.trim() : undefined; +} + +module('Rendering | command-palette', function (hooks) { + setupRenderingTest(hooks); + setupTabster(hooks); + + test('@items and a block together is an error', async function (assert) { + setupOnerror((error: Error) => { + assert.ok(/both `@items` and a block/.test(error.message), `got: ${error.message}`); + }); + + const ITEMS = ['One']; + + await render( + + ); + + resetOnerror(); + }); + + test('with no block, it renders a whole palette from @items', async function (assert) { + const chosen: unknown[] = []; + const onSelect = (item: unknown) => chosen.push(item); + + const ITEMS = [ + { label: 'New File', description: 'Create a file in this folder', icon: '+' }, + { label: 'Close Window' }, + 'Toggle Theme', + ]; + + await render(); + + assert.dom('input').hasAttribute('role', 'combobox'); + assert.strictEqual(findAll('[role="option"]').length, 3); + assert.dom('[role="option"]:first-child').containsText('New File'); + assert.dom('[role="option"]:first-child').containsText('Create a file in this folder'); + assert.dom('[role="option"]:last-child').hasText('Toggle Theme', 'a bare string is the label'); + + await click('[role="option"]:last-child'); + + assert.deepEqual(chosen, ['Toggle Theme'], '@onSelect is handed the entry'); + + await click('[role="option"]:first-child'); + + assert.strictEqual(chosen.length, 2, 'once per row, and it knows which'); + assert.deepEqual(chosen[1], ITEMS[0]); + }); + + test('an empty @items still renders the input to type into', async function (assert) { + const NONE: string[] = []; + + await render(); + + assert.dom('input').exists('an empty array is falsy in a template, but the palette is not'); + assert.dom('[role="listbox"]').exists(); + assert.strictEqual(findAll('[role="option"]').length, 0); + }); + + test('wires the combobox to the listbox', async function (assert) { + await render( + + ); + + assert.dom('input').hasAttribute('role', 'combobox'); + assert.dom('input').hasAttribute('aria-autocomplete', 'list'); + + const list = find('[role="listbox"]'); + + assert.dom('input').hasAttribute('aria-controls', list?.id ?? ''); + assert.strictEqual(findAll('[role="option"]').length, 3); + assert.dom('input').doesNotHaveAttribute('aria-activedescendant', 'nothing is active yet'); + }); + + test('the arrow keys move aria-activedescendant, not focus', async function (assert) { + await render( + + ); + + const input = find('input'); + + input?.focus(); + + await triggerKeyEvent('input', 'keydown', 'ArrowDown'); + assert.strictEqual(activeText(), 'Open File'); + assert.strictEqual(document.activeElement, input, 'focus stayed in the input'); + + await triggerKeyEvent('input', 'keydown', 'ArrowDown'); + assert.strictEqual(activeText(), 'Open Folder'); + + await triggerKeyEvent('input', 'keydown', 'ArrowDown'); + await triggerKeyEvent('input', 'keydown', 'ArrowDown'); + assert.strictEqual(activeText(), 'Open File', 'wraps around'); + + await triggerKeyEvent('input', 'keydown', 'ArrowUp'); + assert.strictEqual(activeText(), 'Close Window', 'wraps backwards'); + }); + + test('Enter chooses the active item, or the first when none is', async function (assert) { + const chosen: string[] = []; + const choose = (event: Event) => { + const target = event.target as HTMLElement; + + chosen.push(target.closest('[role="option"]')?.textContent?.trim() ?? ''); + }; + + await render( + + ); + + // no arrowing: Enter takes the first + await triggerKeyEvent('input', 'keydown', 'Enter'); + assert.deepEqual(chosen, ['Open File']); + + await triggerKeyEvent('input', 'keydown', 'ArrowDown'); + await triggerKeyEvent('input', 'keydown', 'ArrowDown'); + await triggerKeyEvent('input', 'keydown', 'Enter'); + assert.deepEqual(chosen, ['Open File', 'Open Folder']); + + await click('[role="option"]:last-child'); + assert.deepEqual(chosen, ['Open File', 'Open Folder', 'Close Window']); + }); + + test('typing forgets what was active', async function (assert) { + const matching = (query: string) => + COMMANDS.filter((command) => command.toLowerCase().includes(query.toLowerCase())); + + await render( + + ); + + await triggerKeyEvent('input', 'keydown', 'ArrowDown'); + await triggerKeyEvent('input', 'keydown', 'ArrowDown'); + assert.strictEqual(activeText(), 'Open Folder'); + + await fillIn('input', 'close'); + + assert.strictEqual(findAll('[role="option"]').length, 1); + assert.dom('input').doesNotHaveAttribute('aria-activedescendant'); + + // and Enter still takes the best of the new results + await triggerKeyEvent('input', 'keydown', 'ArrowDown'); + assert.strictEqual(activeText(), 'Close Window'); + + await fillIn('input', 'nothing matches this'); + assert.strictEqual(findAll('[role="option"]').length, 0); + + // no options, no crash + await triggerKeyEvent('input', 'keydown', 'Enter'); + await triggerKeyEvent('input', 'keydown', 'ArrowDown'); + }); + + test('the pointer activates an item without stealing focus', async function (assert) { + await render( + + ); + + const input = find('input'); + + input?.focus(); + + await triggerEvent('[role="option"]:last-child', 'pointermove'); + + assert.strictEqual(activeText(), 'Close Window'); + assert.dom('[role="option"]:last-child').hasAttribute('data-active', 'true'); + assert.strictEqual(document.activeElement, input); + }); + + test('choosing is delegated, so a row needs no listener of its own', async function (assert) { + const chosen: string[] = []; + const choose = (event: Event) => { + const target = event.target as HTMLElement; + + chosen.push(target.closest('[role="option"]')?.textContent?.trim() ?? ''); + }; + + await render( + + ); + + await click('[role="option"]:first-child'); + await click('[role="option"]:last-child'); + + assert.deepEqual(chosen, ['Open File', 'Close Window'], 'once per row, via the event'); + }); + + test('inside , @onSelect={{d.close}} and @onOpen={{d.open}} are the whole wiring', async function (assert) { + const MOD = navigator.userAgent.includes('Mac OS') ? { metaKey: true } : { ctrlKey: true }; + + await render( + + ); + + assert.dom('dialog').doesNotHaveAttribute('open'); + + await triggerKeyEvent(document.body, 'keydown', 'K', MOD); + + assert.dom('dialog').hasAttribute('open'); + + await triggerKeyEvent('input', 'keydown', 'Enter'); + + assert.dom('dialog').doesNotHaveAttribute('open'); + }); + + test('a palette in still composes the same way', async function (assert) { + await render( + + ); + + await click('button'); + assert.dom('dialog').hasAttribute('open'); + + await triggerKeyEvent('input', 'keydown', 'Enter'); + + assert.dom('dialog').doesNotHaveAttribute('open'); + }); + + test('@hotkey calls @onOpen from anywhere on the page', async function (assert) { + const MOD = navigator.userAgent.includes('Mac OS') ? { metaKey: true } : { ctrlKey: true }; + + await render( + + ); + + assert.dom('dialog').doesNotHaveAttribute('open'); + + await triggerKeyEvent(document.body, 'keydown', 'K', MOD); + assert.dom('dialog').hasAttribute('open'); + + // a bare `k` is just typing + await triggerKeyEvent(document.body, 'keydown', 'K'); + assert.dom('dialog').hasAttribute('open'); + }); + + test('@hotkey without @onOpen installs no listener', async function (assert) { + const MOD = navigator.userAgent.includes('Mac OS') ? { metaKey: true } : { ctrlKey: true }; + + await render( + + ); + + await triggerKeyEvent(document.body, 'keydown', 'K', MOD); + + assert.dom('input').exists('nothing to open, and nothing thrown'); + }); + + test('the hotkey listener goes when the palette does', async function (assert) { + const MOD = navigator.userAgent.includes('Mac OS') ? { metaKey: true } : { ctrlKey: true }; + const opens: number[] = []; + const onOpen = () => opens.push(1); + + await render( + + ); + + await triggerKeyEvent(document.body, 'keydown', 'K', MOD); + assert.deepEqual(opens, [1]); + + await clearRender(); + + // the listener is on `document`, which holds it, so nothing collects it + await triggerKeyEvent(document.body, 'keydown', 'K', MOD); + assert.deepEqual(opens, [1], 'a torn-down palette no longer answers its hotkey'); + }); + + test('the query is controllable', async function (assert) { + const queries: string[] = []; + const onQueryChange = (query: string) => queries.push(query); + + await render( + + ); + + assert.dom('input').hasValue('initial'); + assert.dom('output').hasText('initial'); + + await fillIn('input', 'typed'); + + assert.dom('output').hasText('typed'); + assert.deepEqual(queries, ['typed']); + + await click('button'); + + assert.dom('input').hasValue(''); + assert.deepEqual(queries, ['typed', '']); + }); +}); + +module('Application | command-palette', function (hooks) { + setupApplicationTest(hooks); + setupTabster(hooks); + + test('a LinkItem is an option and a link', async function (assert) { + setupRouting(this.owner, function () { + this.route('one'); + this.route('two'); + }); + + this.owner.register( + 'template:application', + + ); + + await visit('/'); + + assert.dom('a[href="/one"]').hasAttribute('role', 'option'); + + await triggerKeyEvent('input', 'keydown', 'ArrowDown'); + await triggerKeyEvent('input', 'keydown', 'ArrowDown'); + assert.strictEqual(activeText(), 'Two'); + + // Enter dispatches a real click on the anchor: the router navigates + await triggerKeyEvent('input', 'keydown', 'Enter'); + + assert.strictEqual(currentURL(), '/two'); + }); +}); diff --git a/test-app/tests/dialog/dialog-test.gts b/test-app/tests/dialog/dialog-test.gts new file mode 100644 index 000000000..0fd52d38d --- /dev/null +++ b/test-app/tests/dialog/dialog-test.gts @@ -0,0 +1,124 @@ +import { on } from '@ember/modifier'; +import { click, find, render, settled } from '@ember/test-helpers'; +import { module, test } from 'qunit'; +import { setupRenderingTest } from 'ember-qunit'; + +import { Dialog } from 'ember-primitives'; + +/** + * The `close` event is queued as a task, and headless Chrome's timings around + * it are inconsistent -- the same wait ``'s own tests use. + */ +async function closeNatively() { + find('dialog')?.close(); + await new Promise((resolve) => requestAnimationFrame(resolve)); + await settled(); +} + +module('Rendering | ', function (hooks) { + setupRenderingTest(hooks); + + test('it renders a closed dialog around its block', async function (assert) { + await render( + + ); + + assert.dom('dialog').exists({ count: 1 }); + assert.dom('dialog').doesNotHaveAttribute('open'); + assert.dom('dialog').hasText('content'); + }); + + test('closedby is the browser default until a caller says otherwise', async function (assert) { + await render( + + ); + + const dialogs = document.querySelectorAll('dialog'); + + assert.dom(dialogs[0]).doesNotHaveAttribute('closedby'); + assert.dom(dialogs[1]).hasAttribute('closedby', 'any'); + }); + + test('open and close drive it', async function (assert) { + await render( + + ); + + assert.dom('dialog').doesNotHaveAttribute('open'); + + await click('#open'); + assert.dom('dialog').hasAttribute('open'); + assert.dom('dialog').hasStyle({ display: 'block' }, 'it is modal, not inline'); + + await click('#close'); + assert.dom('dialog').doesNotHaveAttribute('open'); + }); + + test('opening twice and closing twice is not an error', async function (assert) { + await render( + + ); + + // `close` on a closed dialog, before it has ever opened + await click('#close'); + assert.dom('dialog').doesNotHaveAttribute('open'); + + await click('#open'); + await click('#open'); + assert.dom('dialog').hasAttribute('open'); + + await click('#close'); + await click('#close'); + assert.dom('dialog').doesNotHaveAttribute('open'); + }); + + test('closing without us, then opening again', async function (assert) { + await render( + + ); + + await click('#open'); + assert.dom('dialog').hasAttribute('open'); + + // Escape and a click on the backdrop are the browser's; both end here + await closeNatively(); + assert.dom('dialog').doesNotHaveAttribute('open'); + + await click('#open'); + assert.dom('dialog').hasAttribute('open'); + }); + + test('attributes reach the dialog element', async function (assert) { + await render( + + ); + + assert.dom('dialog').hasClass('mine'); + assert.dom('dialog').hasAttribute('data-thing', 'x'); + }); +}); diff --git a/test-app/tests/dialog/dialog-rendering-test.gts b/test-app/tests/dialog/modal-test.gts similarity index 99% rename from test-app/tests/dialog/dialog-rendering-test.gts rename to test-app/tests/dialog/modal-test.gts index 9659f3d41..a1c531ad9 100644 --- a/test-app/tests/dialog/dialog-rendering-test.gts +++ b/test-app/tests/dialog/modal-test.gts @@ -8,7 +8,7 @@ import { setupRenderingTest } from 'ember-qunit'; import { Modal } from 'ember-primitives'; -module('Rendering | dialog', function (hooks) { +module('Rendering | ', function (hooks) { setupRenderingTest(hooks); async function close() {