From 1265e05ac300cfda8541510ce684768d43364d80 Mon Sep 17 00:00:00 2001 From: NullVoxPopuli <199018+NullVoxPopuli@users.noreply.github.com> Date: Wed, 19 Aug 2026 15:47:33 -0400 Subject: [PATCH] Add and is a combobox over a listbox: the input, the keyboard, and the aria wiring. It does not filter, and it does not open or close anything. Two forms, and the signature is a union so each describes only the arguments it uses: `@items` renders the rows for you, or a block renders them yourself. Passing both is a dev error. One way in for selection. `@onSelect` runs whenever a row is chosen, and is handed the entry when there is one, so it is both where a row's action goes and what closes a modal palette. A block form's rows carry their own action as a click handler, which Enter reaches too, since choosing a row dispatches a real click. Focus never leaves the input; `aria-activedescendant` is what moves, so the reader can keep typing, and the pointer sets that same state rather than painting a `:hover` of its own -- two highlights and one truth would disagree about what Enter does. Tabster does the finding, which the app sets up the same way requires. Nothing is registered that has to settle in a second render pass. The listbox is held by a modifier so it works inside a shadow root, and the active row is looked up within it. is the other half: a modal around its block, yielding `open` and `close` already wired to that element. Everything inside is hidden until it opens and a trigger cannot sit outside it, so it is for content that opens itself. stays for a dialog opened by a button beside it, and the palette composes with either. Both have docs pages with running demos -- a modal palette, one inline, one rendering its own rows, and one searching the Star Wars API for real loading state -- and 21 tests between them. Co-Authored-By: Claude Opus 5 (1M context) --- docs-app/app/styles/app.css | 7 +- .../5-floaty-bits/command-palette.gjs.md | 722 ++++++++++++++++++ .../app/templates/5-floaty-bits/dialog.gjs.md | 147 ++++ docs-app/package.json | 2 +- .../src/components/command-palette.gts | 719 +++++++++++++++++ ember-primitives/src/components/dialog.gts | 72 +- ember-primitives/src/index.ts | 3 +- package.json | 2 +- pnpm-lock.yaml | 53 +- .../command-palette/command-palette-test.gts | 472 ++++++++++++ test-app/tests/dialog/dialog-test.gts | 124 +++ ...alog-rendering-test.gts => modal-test.gts} | 2 +- 12 files changed, 2292 insertions(+), 33 deletions(-) create mode 100644 docs-app/app/templates/5-floaty-bits/command-palette.gjs.md create mode 100644 docs-app/app/templates/5-floaty-bits/dialog.gjs.md create mode 100644 ember-primitives/src/components/command-palette.gts create mode 100644 test-app/tests/command-palette/command-palette-test.gts create mode 100644 test-app/tests/dialog/dialog-test.gts rename test-app/tests/dialog/{dialog-rendering-test.gts => modal-test.gts} (99%) 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() {