ember-primitives 0.61.1 → 0.62.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,719 @@
1
+ /**
2
+ * References:
3
+ * - https://www.w3.org/WAI/ARIA/apg/patterns/combobox/
4
+ * - https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Attributes/aria-activedescendant
5
+ *
6
+ * A combobox (the input) over a listbox (the results).
7
+ *
8
+ * Focus never leaves the input; `aria-activedescendant` is what moves. This is
9
+ * why there is no tabster mover here: the arrow keys must not move focus, or
10
+ * the user stops being able to type. Tabster still does the finding.
11
+ *
12
+ * Filtering is not this component's job. Render the results you want, in the
13
+ * order you want.
14
+ */
15
+ import Component from "@glimmer/component";
16
+ import { tracked } from "@glimmer/tracking";
17
+ import { assert } from "@ember/debug";
18
+ import { registerDestructor } from "@ember/destroyable";
19
+ import { hash } from "@ember/helper";
20
+ import { on } from "@ember/modifier";
21
+ import { guidFor } from "@ember/object/internals";
22
+
23
+ import { modifier as eModifier } from "ember-modifier";
24
+ import { getTabster } from "tabster";
25
+ // temp
26
+ // https://github.com/tracked-tools/tracked-toolbox/issues/38
27
+ // eslint-disable-next-line @typescript-eslint/ban-ts-comment
28
+ // @ts-expect-error
29
+ import { localCopy } from "tracked-toolbox";
30
+
31
+ import { Link, type Signature as LinkSignature } from "./link.gts";
32
+
33
+ import type { TOC } from "@ember/component/template-only";
34
+ import type Owner from "@ember/owner";
35
+ import type { ModifierLike, WithBoundArgs } from "@glint/template";
36
+
37
+ const OPTION = '[role="option"]';
38
+
39
+ function isMac() {
40
+ return navigator.userAgent.includes("Mac OS");
41
+ }
42
+
43
+ /**
44
+ * Matches a hotkey description, such as `"mod+k"`, against a keyboard event.
45
+ *
46
+ * `mod` is <kbd>Meta</kbd> on macOS and <kbd>Control</kbd> everywhere else,
47
+ * the same normalization `<KeyCombo>` uses to render one.
48
+ */
49
+ function matches(event: KeyboardEvent, hotkey: string) {
50
+ const parts = hotkey
51
+ .toLowerCase()
52
+ .split("+")
53
+ .map((part) => part.trim());
54
+ const key = parts.pop();
55
+ const modifiers = new Set(parts);
56
+ const mod = modifiers.has("mod");
57
+
58
+ return (
59
+ event.key.toLowerCase() === key &&
60
+ event.metaKey === (modifiers.has("meta") || (mod && isMac())) &&
61
+ event.ctrlKey === (modifiers.has("ctrl") || (mod && !isMac())) &&
62
+ event.altKey === modifiers.has("alt") &&
63
+ event.shiftKey === modifiers.has("shift")
64
+ );
65
+ }
66
+
67
+ export interface ItemSignature {
68
+ Element: HTMLDivElement;
69
+ Blocks: { default: [] };
70
+ }
71
+
72
+ interface PrivateItemSignature {
73
+ Element: ItemSignature["Element"];
74
+ Args: { activeId: string | undefined };
75
+ Blocks: ItemSignature["Blocks"];
76
+ }
77
+
78
+ class Item extends Component<PrivateItemSignature> {
79
+ id = guidFor(this);
80
+
81
+ get isActive() {
82
+ return this.args.activeId === this.id;
83
+ }
84
+
85
+ <template>
86
+ <div
87
+ id={{this.id}}
88
+ role="option"
89
+ tabindex="-1"
90
+ aria-selected="{{this.isActive}}"
91
+ data-active="{{this.isActive}}"
92
+ ...attributes
93
+ >
94
+ {{yield}}
95
+ </div>
96
+ </template>
97
+ }
98
+
99
+ export interface LinkItemSignature {
100
+ Element: HTMLAnchorElement;
101
+ Args: LinkSignature["Args"];
102
+ Blocks: { default: [] };
103
+ }
104
+
105
+ interface PrivateLinkItemSignature {
106
+ Element: LinkItemSignature["Element"];
107
+ Args: LinkItemSignature["Args"] & { activeId: string | undefined };
108
+ Blocks: LinkItemSignature["Blocks"];
109
+ }
110
+
111
+ /**
112
+ * An option that is also a link. <kbd>Enter</kbd> dispatches a real click on
113
+ * the anchor, so the router navigates exactly as it would have for a mouse.
114
+ */
115
+ class LinkItem extends Component<PrivateLinkItemSignature> {
116
+ id = guidFor(this);
117
+
118
+ get isActive() {
119
+ return this.args.activeId === this.id;
120
+ }
121
+
122
+ <template>
123
+ <Link
124
+ id={{this.id}}
125
+ role="option"
126
+ tabindex="-1"
127
+ aria-selected="{{this.isActive}}"
128
+ data-active="{{this.isActive}}"
129
+ @href={{@href}}
130
+ @includeActiveQueryParams={{@includeActiveQueryParams}}
131
+ @activeOnSubPaths={{@activeOnSubPaths}}
132
+ ...attributes
133
+ >
134
+ {{yield}}
135
+ </Link>
136
+ </template>
137
+ }
138
+
139
+ export interface ListSignature {
140
+ Element: HTMLDivElement;
141
+ Blocks: {
142
+ default: [
143
+ {
144
+ Item: WithBoundArgs<typeof Item, "activeId">;
145
+ LinkItem: WithBoundArgs<typeof LinkItem, "activeId">;
146
+ },
147
+ ];
148
+ };
149
+ }
150
+
151
+ interface PrivateListSignature {
152
+ Element: ListSignature["Element"];
153
+ Args: {
154
+ id: string;
155
+ register: ModifierLike<{ Element: HTMLElement }>;
156
+ onPointerMove: (event: PointerEvent) => void;
157
+ onClick: (event: MouseEvent) => void;
158
+ Item: ListSignature["Blocks"]["default"][0]["Item"];
159
+ LinkItem: ListSignature["Blocks"]["default"][0]["LinkItem"];
160
+ };
161
+ Blocks: ListSignature["Blocks"];
162
+ }
163
+
164
+ /**
165
+ * The pointer is handled here rather than on each option, and on
166
+ * `pointermove` rather than `pointerenter`, so that an option under a resting
167
+ * cursor re-activates when the cursor moves after the keyboard has activated
168
+ * something else. Native menus behave this way.
169
+ *
170
+ * `:hover` cannot do this job. There is one active option, it is what
171
+ * <kbd>Enter</kbd> chooses, and it is what `aria-activedescendant` reports.
172
+ * Hovering while the keyboard has a different option active would light two
173
+ * rows and tell a screen reader about neither, so the pointer sets the same
174
+ * state the arrow keys do instead of painting its own.
175
+ */
176
+ const List: TOC<PrivateListSignature> = <template>
177
+ <div
178
+ id={{@id}}
179
+ role="listbox"
180
+ {{@register}}
181
+ {{on "click" @onClick}}
182
+ {{on "pointermove" @onPointerMove}}
183
+ ...attributes
184
+ >
185
+ {{yield (hash Item=@Item LinkItem=@LinkItem)}}
186
+ </div>
187
+ </template>;
188
+
189
+ export interface InputSignature {
190
+ Element: HTMLInputElement;
191
+ }
192
+
193
+ interface PrivateInputSignature {
194
+ Element: InputSignature["Element"];
195
+ Args: {
196
+ listId: string;
197
+ activeId: string | undefined;
198
+ query: string;
199
+ onInput: (event: Event) => void;
200
+ onKeydown: (event: KeyboardEvent) => void;
201
+ };
202
+ }
203
+
204
+ const Input: TOC<PrivateInputSignature> = <template>
205
+ <input
206
+ type="text"
207
+ role="combobox"
208
+ autocomplete="off"
209
+ autocorrect="off"
210
+ autocapitalize="off"
211
+ spellcheck="false"
212
+ aria-autocomplete="list"
213
+ aria-expanded="true"
214
+ aria-controls={{@listId}}
215
+ aria-activedescendant={{@activeId}}
216
+ value={{@query}}
217
+ {{on "input" @onInput}}
218
+ {{on "keydown" @onKeydown}}
219
+ ...attributes
220
+ />
221
+ </template>;
222
+
223
+ /**
224
+ * One entry in the default layout. A bare string is the label.
225
+ */
226
+ export type PaletteItem =
227
+ | string
228
+ | {
229
+ label: string;
230
+ description?: string;
231
+ icon?: string;
232
+ };
233
+
234
+ const labelOf = (item: PaletteItem) => (typeof item === "string" ? item : item.label);
235
+ const descriptionOf = (item: PaletteItem) =>
236
+ typeof item === "string" ? undefined : item.description;
237
+ const iconOf = (item: PaletteItem) => (typeof item === "string" ? undefined : item.icon);
238
+
239
+ /**
240
+ * The default layout: hand it the rows and it renders the whole palette.
241
+ */
242
+ export interface ItemsSignature {
243
+ Args: {
244
+ /**
245
+ * The text in the input.
246
+ *
247
+ * The state is managed internally, so this does not need to be a
248
+ * maintained value, but whenever it changes, the input reflects it. Pair
249
+ * it with `@onQueryChange` to keep the query somewhere else, such as a
250
+ * query param.
251
+ */
252
+ query?: string;
253
+ /**
254
+ * Called with the input's text every time it changes.
255
+ */
256
+ onQueryChange?: (query: string) => void;
257
+ /**
258
+ * A key combination that calls `@onOpen` from anywhere on the page, such
259
+ * as `"mod+k"`. `mod` is <kbd>Meta</kbd> on macOS and <kbd>Control</kbd>
260
+ * everywhere else.
261
+ *
262
+ * Needs `@onOpen` to have anything to do. No listener is installed
263
+ * without both.
264
+ */
265
+ hotkey?: string;
266
+ /**
267
+ * Called when `@hotkey` is pressed. Hand it the `open` of whatever the
268
+ * palette is in:
269
+ *
270
+ * ```hbs
271
+ * <CommandPalette @hotkey="mod+k" @onOpen={{d.open}} />
272
+ * ```
273
+ */
274
+ onOpen?: () => void;
275
+ /**
276
+ * The entries to render. Each is a string, or an object with a `label`
277
+ * and optionally a `description` and an `icon`.
278
+ */
279
+ items: PaletteItem[];
280
+ /**
281
+ * Called every time a row is chosen, with the entry that was chosen.
282
+ * This is where a modal palette closes itself:
283
+ *
284
+ * ```hbs
285
+ * <Dialog as |d|>
286
+ * <CommandPalette @items={{this.commands}} @onSelect={{d.close}} />
287
+ * </Dialog>
288
+ * ```
289
+ *
290
+ * Also where a row's own action goes, since the entry it is handed says
291
+ * which row was chosen.
292
+ */
293
+ onSelect?: (item: PaletteItem, event: Event) => void;
294
+ /**
295
+ * The input's placeholder, and its accessible name.
296
+ *
297
+ * Defaults to "Search".
298
+ */
299
+ placeholder?: string;
300
+ };
301
+ /**
302
+ * No blocks: this form renders the rows. Passing one is an error.
303
+ */
304
+ Blocks: Record<string, never>;
305
+ }
306
+
307
+ /**
308
+ * The composed form: you render the rows.
309
+ */
310
+ export interface BlockSignature {
311
+ Args: {
312
+ /**
313
+ * The text in the input.
314
+ *
315
+ * The state is managed internally, so this does not need to be a
316
+ * maintained value, but whenever it changes, the input reflects it. Pair
317
+ * it with `@onQueryChange` to keep the query somewhere else, such as a
318
+ * query param.
319
+ */
320
+ query?: string;
321
+ /**
322
+ * Called with the input's text every time it changes.
323
+ */
324
+ onQueryChange?: (query: string) => void;
325
+ /**
326
+ * A key combination that calls `@onOpen` from anywhere on the page, such
327
+ * as `"mod+k"`. `mod` is <kbd>Meta</kbd> on macOS and <kbd>Control</kbd>
328
+ * everywhere else.
329
+ *
330
+ * Needs `@onOpen` to have anything to do. No listener is installed
331
+ * without both.
332
+ */
333
+ hotkey?: string;
334
+ /**
335
+ * Called when `@hotkey` is pressed. Hand it the `open` of whatever the
336
+ * palette is in:
337
+ *
338
+ * ```hbs
339
+ * <CommandPalette @hotkey="mod+k" @onOpen={{d.open}} />
340
+ * ```
341
+ */
342
+ onOpen?: () => void;
343
+ /**
344
+ * Not for this form: the rows come from the block.
345
+ */
346
+ items?: never;
347
+ /**
348
+ * Not for this form: set it on `Input` yourself.
349
+ */
350
+ placeholder?: never;
351
+ /**
352
+ * Called every time a row is chosen. Hand it `close` to make a modal
353
+ * palette close itself.
354
+ *
355
+ * The rows are yours here, so there is no entry to hand back. Which row
356
+ * was chosen is `event.target.closest("[role=option]")`.
357
+ */
358
+ onSelect?: (event: Event) => void;
359
+ };
360
+ Blocks: {
361
+ default: [
362
+ {
363
+ /**
364
+ * The current text of the input.
365
+ */
366
+ query: string;
367
+ /**
368
+ * Sets the text of the input, for a "clear" button or a suggestion.
369
+ */
370
+ setQuery: (query: string) => void;
371
+ /**
372
+ * The `<input>`, wired as a combobox over `List`.
373
+ */
374
+ Input: WithBoundArgs<
375
+ typeof Input,
376
+ "listId" | "activeId" | "query" | "onInput" | "onKeydown"
377
+ >;
378
+ /**
379
+ * The listbox the rows are rendered into.
380
+ */
381
+ List: WithBoundArgs<
382
+ typeof List,
383
+ "id" | "register" | "onPointerMove" | "onClick" | "Item" | "LinkItem"
384
+ >;
385
+ },
386
+ ];
387
+ };
388
+ }
389
+
390
+ export type Signature = ItemsSignature | BlockSignature;
391
+
392
+ export class CommandPalette extends Component<Signature> {
393
+ listId = guidFor(this);
394
+
395
+ /**
396
+ * Held rather than looked up by id, because `document.getElementById` does
397
+ * not cross into a shadow root. A plain field: it is read when a key is
398
+ * pressed, never while rendering.
399
+ */
400
+ #list: HTMLElement | undefined;
401
+
402
+ /**
403
+ * Which row is active, as an id rather than an element or a focus state.
404
+ *
405
+ * This is unusual for this library, where keyboard navigation means tabster
406
+ * moving focus. A combobox cannot do that: focus has to stay in the
407
+ * `<input>` or the reader stops being able to type. So nothing among the
408
+ * rows is ever focused, there is no focus for tabster to track, and what
409
+ * moves instead is `aria-activedescendant` -- which is an id, on the input,
410
+ * pointing at a row. Holding the id is holding exactly what that attribute
411
+ * needs.
412
+ *
413
+ * Tabster still does the finding, in `#find`. This only remembers which of
414
+ * the rows it landed on.
415
+ */
416
+ @tracked activeId: string | undefined;
417
+
418
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-call
419
+ @localCopy("args.query") declare _query: string;
420
+
421
+ constructor(owner: Owner, args: Signature["Args"]) {
422
+ super(owner, args);
423
+
424
+ document.addEventListener("keydown", this.handleHotkey);
425
+
426
+ registerDestructor(this, () => {
427
+ document.removeEventListener("keydown", this.handleHotkey);
428
+ });
429
+ }
430
+
431
+ get placeholder() {
432
+ return this.args.placeholder ?? "Search";
433
+ }
434
+
435
+ /**
436
+ * Whether `@items` was passed, not whether it has anything in it: an empty
437
+ * array is falsy in a template, and a palette whose results have gone away
438
+ * still has to render the input they would be typed into.
439
+ */
440
+ get hasItems() {
441
+ return this.args.items !== undefined;
442
+ }
443
+
444
+ /**
445
+ * Each form is called the way its own type describes: with the entry that
446
+ * was chosen, or with the event alone when the rows are the caller's.
447
+ *
448
+ * `@items` is what tells the two apart, and it is `never` on the form that
449
+ * does not take it, so testing it narrows `this.args` to one of them.
450
+ */
451
+ #select(option: HTMLElement, event: Event) {
452
+ if (this.args.items === undefined) {
453
+ this.args.onSelect?.(event);
454
+
455
+ return;
456
+ }
457
+
458
+ const entry = this.#entryFor(option, this.args.items);
459
+
460
+ assert(
461
+ "[BUG] a row of the default layout was chosen, but is not among `@items`",
462
+ entry !== undefined,
463
+ );
464
+
465
+ this.args.onSelect?.(entry, event);
466
+ }
467
+
468
+ /**
469
+ * `@items` and a block are two ways to say the same thing, and saying both
470
+ * means one of them is being quietly ignored.
471
+ */
472
+ get bothGiven() {
473
+ assert(
474
+ "<CommandPalette> was given both `@items` and a block. Use one: `@items` renders the rows for you, a block renders them yourself.",
475
+ false,
476
+ );
477
+
478
+ return "";
479
+ }
480
+
481
+ get query() {
482
+ return this._query ?? "";
483
+ }
484
+ set query(value: string) {
485
+ this._query = value;
486
+ }
487
+
488
+ registerList = eModifier((element: HTMLElement) => {
489
+ this.#list = element;
490
+ });
491
+
492
+ get #activeElement() {
493
+ const { activeId } = this;
494
+
495
+ if (!activeId) return undefined;
496
+
497
+ // scoped to the listbox, so this works inside a shadow root
498
+ return this.#list?.querySelector<HTMLElement>(`[id="${activeId}"]`) ?? undefined;
499
+ }
500
+
501
+ /**
502
+ * The next, previous, first or last option.
503
+ *
504
+ * Tabster does the finding, so hidden and inert options are skipped by the
505
+ * same rules as everything else that moves around the page. It has to be
506
+ * set up by the app, the same way `<Menu>` requires it.
507
+ */
508
+ #find(direction: "next" | "prev" | "first" | "last") {
509
+ const container = this.#list;
510
+
511
+ if (!container) return undefined;
512
+
513
+ const tabster = getTabster(window);
514
+
515
+ assert(
516
+ "<CommandPalette> needs tabster, which the application sets up. " +
517
+ "Call `setupTabster` from 'ember-primitives/tabster' in your application route. " +
518
+ "See https://tabster.io/docs/core",
519
+ tabster,
520
+ );
521
+
522
+ const options = { container, includeProgrammaticallyFocusable: true };
523
+ const currentElement = this.#activeElement;
524
+ const { focusable } = tabster;
525
+
526
+ if (direction === "first") return focusable.findFirst(options);
527
+ if (direction === "last") return focusable.findLast(options);
528
+
529
+ if (!currentElement) {
530
+ return direction === "next" ? focusable.findFirst(options) : focusable.findLast(options);
531
+ }
532
+
533
+ const found =
534
+ direction === "next"
535
+ ? focusable.findNext({ ...options, currentElement })
536
+ : focusable.findPrev({ ...options, currentElement });
537
+
538
+ // wrap, rather than stop, at either end
539
+ return (
540
+ found ?? (direction === "next" ? focusable.findFirst(options) : focusable.findLast(options))
541
+ );
542
+ }
543
+
544
+ #activate(element: HTMLElement | null | undefined) {
545
+ if (!element) return;
546
+
547
+ this.activeId = element.id;
548
+ element.scrollIntoView({ block: "nearest" });
549
+ }
550
+
551
+ setQuery = (query: string) => {
552
+ this.query = query;
553
+ // the results are about to be somebody else's; whatever was active is not
554
+ this.activeId = undefined;
555
+ this.args.onQueryChange?.(query);
556
+ };
557
+
558
+ handleInput = (event: Event) => {
559
+ const { target } = event;
560
+
561
+ assert("[BUG] input event without an input", target instanceof HTMLInputElement);
562
+
563
+ this.setQuery(target.value);
564
+ };
565
+
566
+ handlePointerMove = (event: PointerEvent) => {
567
+ const { target } = event;
568
+
569
+ if (!(target instanceof Element)) return;
570
+
571
+ const option = target.closest<HTMLElement>(OPTION);
572
+
573
+ if (option) {
574
+ this.activeId = option.id;
575
+ }
576
+ };
577
+
578
+ handleKeydown = (event: KeyboardEvent) => {
579
+ // mid-composition (IME), the arrow keys belong to the candidate window
580
+ if (event.isComposing) return;
581
+
582
+ switch (event.key) {
583
+ case "ArrowDown": {
584
+ event.preventDefault();
585
+ this.#activate(this.#find("next"));
586
+
587
+ return;
588
+ }
589
+ case "ArrowUp": {
590
+ event.preventDefault();
591
+ this.#activate(this.#find("prev"));
592
+
593
+ return;
594
+ }
595
+ case "Enter": {
596
+ // nothing arrowed yet chooses the first result, so a reader can type
597
+ // and press Enter without leaving the keys they were already on
598
+ const active = this.#activeElement ?? this.#find("first");
599
+
600
+ if (!active) return;
601
+
602
+ event.preventDefault();
603
+ // a real click, so one handler covers the mouse and the keyboard, and
604
+ // an anchor navigates the way the browser would have
605
+ active.click();
606
+
607
+ return;
608
+ }
609
+ /**
610
+ * Home and End are left to the browser: in an editable combobox they
611
+ * move the caret, which is what the APG asks for.
612
+ */
613
+ }
614
+ };
615
+
616
+ handleHotkey = (event: KeyboardEvent) => {
617
+ const { hotkey, onOpen } = this.args;
618
+
619
+ if (!hotkey || !onOpen) return;
620
+ if (!matches(event, hotkey)) return;
621
+
622
+ event.preventDefault();
623
+ onOpen();
624
+ };
625
+
626
+ /**
627
+ * The entry a row came from, for the default layout. Rows are rendered one
628
+ * per entry and in order, so a row's position among the options is its
629
+ * entry's position in `@items`. A block form has no entries, and gets
630
+ * `undefined`.
631
+ */
632
+ #entryFor(option: HTMLElement, items: PaletteItem[]) {
633
+ const options = this.#list?.querySelectorAll<HTMLElement>(OPTION);
634
+
635
+ for (let i = 0; i < (options?.length ?? 0); i++) {
636
+ if (options?.[i] === option) return items[i];
637
+ }
638
+
639
+ return undefined;
640
+ }
641
+
642
+ /**
643
+ * Choosing is delegated, so a row is markup rather than a listener, and
644
+ * `@onSelect` is reached the same way however the rows were rendered.
645
+ */
646
+ handleClick = (event: MouseEvent) => {
647
+ const { target } = event;
648
+
649
+ if (!(target instanceof Element)) return;
650
+
651
+ const option = target.closest<HTMLElement>(OPTION);
652
+
653
+ if (!option) return;
654
+
655
+ /**
656
+ * A modified click on a link opens it somewhere else and leaves the
657
+ * reader where they are, so the palette stays where they left it.
658
+ */
659
+ if (option.closest("a")) {
660
+ if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
661
+ if (event.button !== 0) return;
662
+ }
663
+
664
+ this.#select(option, event);
665
+ };
666
+
667
+ <template>
668
+ {{#let
669
+ (component
670
+ Input
671
+ listId=this.listId
672
+ activeId=this.activeId
673
+ query=this.query
674
+ onInput=this.handleInput
675
+ onKeydown=this.handleKeydown
676
+ )
677
+ (component
678
+ List
679
+ id=this.listId
680
+ register=this.registerList
681
+ onPointerMove=this.handlePointerMove
682
+ onClick=this.handleClick
683
+ Item=(component Item activeId=this.activeId)
684
+ LinkItem=(component LinkItem activeId=this.activeId)
685
+ )
686
+ as |PaletteInput PaletteList|
687
+ }}
688
+ {{#if this.hasItems}}
689
+ {{#if (has-block)}}{{this.bothGiven}}{{/if}}
690
+
691
+ <PaletteInput
692
+ class="ember-primitives__command-palette__input"
693
+ placeholder={{this.placeholder}}
694
+ aria-label={{this.placeholder}}
695
+ />
696
+
697
+ <PaletteList class="ember-primitives__command-palette__list" as |l|>
698
+ {{#each @items as |item|}}
699
+ <l.Item class="ember-primitives__command-palette__item">
700
+ {{#if (iconOf item)}}
701
+ <span class="ember-primitives__command-palette__icon">{{iconOf item}}</span>
702
+ {{/if}}
703
+ <span class="ember-primitives__command-palette__label">{{labelOf item}}</span>
704
+ {{#if (descriptionOf item)}}
705
+ <span class="ember-primitives__command-palette__description">{{descriptionOf
706
+ item
707
+ }}</span>
708
+ {{/if}}
709
+ </l.Item>
710
+ {{/each}}
711
+ </PaletteList>
712
+ {{else}}
713
+ {{yield (hash query=this.query setQuery=this.setQuery Input=PaletteInput List=PaletteList)}}
714
+ {{/if}}
715
+ {{/let}}
716
+ </template>
717
+ }
718
+
719
+ export default CommandPalette;