Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

add types to Menu component #185

Open
wants to merge 4 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,19 +1,41 @@
/* eslint-disable padding-line-between-statements */
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { action } from '@ember/object';
import { guidFor } from '@ember/object/internals';

import { restartableTask, timeout } from 'ember-concurrency';
import perform from 'ember-concurrency/helpers/perform';

import Button from './menu/button';
import type Item from './menu/item';
import Items from './menu/items';
import { hash } from '@ember/helper';
import { ensureSafeComponent } from '@embroider/util';

interface MenuSignature {
Blocks: {
default: [
{
isOpen: boolean;
open: () => void;
close: () => void;
Button: typeof Button;
Items: typeof Items;
}
];
};
}

export default class Menu extends Component {
export default class Menu extends Component<MenuSignature> {
guid = `${guidFor(this)}-tailwindui-menu`;
@tracked items = [];
@tracked items: Item[] = [];
@tracked isOpen = false;
@tracked activeItem;
@tracked activeItem?: Item;
@tracked searchTerm = '';

get activeItemIndex() {
return this.items.indexOf(this.activeItem);
return this.activeItem ? this.items.indexOf(this.activeItem) : -1;
}

@action
Expand Down Expand Up @@ -79,18 +101,18 @@ export default class Menu extends Component {
}

@action
goToItem(item) {
goToItem(item: Item) {
this._setActiveItem(item);
}

@restartableTask
*searchTask(nextCharacter) {
*searchTask(nextCharacter: string) {
this.searchTerm += nextCharacter.toLowerCase();

const searchResult = this.items.find((item) => {
const textValue = item.element.textContent.toLowerCase().trim();
const textValue = item.element?.textContent?.toLowerCase().trim();

return item.isEnabled && textValue.startsWith(this.searchTerm);
return item.isEnabled && textValue?.startsWith(this.searchTerm);
});

if (searchResult) {
Expand All @@ -103,28 +125,28 @@ export default class Menu extends Component {
}

@action
async registerItem(item) {
async registerItem(item: Item) {
let { items } = this;

items.push(item);
await Promise.resolve(() => (this.items = items));
}

@action
async unregisterItem(item) {
async unregisterItem(item: Item) {
let { items } = this;

let index = items.indexOf(item);
items.splice(index, 1);
await Promise.resolve(() => (this.items = items));
}

_setActiveItem(item) {
_setActiveItem(item?: Item) {
if (item) {
this.activeItem = item;
this.items.forEach((item) => item.deactivate());
this.activeItem.activate();
this.itemsElement.focus();
this.itemsElement?.focus();
}
}

Expand All @@ -143,4 +165,44 @@ export default class Menu extends Component {
get buttonElement() {
return document.getElementById(this.buttonGuid);
}

<template>
{{yield
(hash
isOpen=this.isOpen
open=this.open
close=this.close
Button=(component
(ensureSafeComponent Button this)
buttonGuid=this.buttonGuid
itemsGuid=this.itemsGuid
isOpen=this.isOpen
openMenu=this.open
closeMenu=this.close
toggleMenu=this.toggle
goToFirstItem=this.goToFirstItem
goToLastItem=this.goToLastItem
goToNextItem=this.goToNextItem
goToPreviousItem=this.goToPreviousItem
)
Items=(component
(ensureSafeComponent Items this)
buttonGuid=this.buttonGuid
itemsGuid=this.itemsGuid
isOpen=this.isOpen
closeMenu=this.close
activeItem=this.activeItem
registerItem=this.registerItem
unregisterItem=this.unregisterItem
goToFirstItem=this.goToFirstItem
goToLastItem=this.goToLastItem
goToNextItem=this.goToNextItem
goToPreviousItem=this.goToPreviousItem
goToItem=this.goToItem
search=(perform this.searchTask)
searchTaskIsRunning=this.searchTask.isRunning
)
)
}}
</template>
}
37 changes: 0 additions & 37 deletions ember-headlessui/addon/components/menu.hbs

This file was deleted.

82 changes: 82 additions & 0 deletions ember-headlessui/addon/components/menu/button.gts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import Component from '@glimmer/component';
import { action } from '@ember/object';
import { next } from '@ember/runloop';

import { Keys } from 'ember-headlessui/utils/keyboard';
import { on } from '@ember/modifier';
import or from 'ember-truth-helpers/helpers/or';

interface MenuButtonSignature {
Element: HTMLButtonElement;
Args: {
buttonGuid?: string;
itemsGuid?: string;
isOpen?: boolean;
openMenu?: () => void;
closeMenu?: () => void;
toggleMenu?: () => void;
goToFirstItem?: () => void;
goToLastItem?: () => void;
goToNextItem?: () => void;
goToPreviousItem?: () => void;
};
Blocks: {
default: [];
};
}

function noop() {
return function () {}
}


export default class Button extends Component<MenuButtonSignature> {
@action
onKeydown(event: KeyboardEvent) {
const target = event.target as HTMLButtonElement | undefined;

if (target?.disabled) return;

switch (event.key) {
case Keys.Space:
case Keys.Enter:
case Keys.ArrowDown:
event.preventDefault();
event.stopPropagation();

if (this.args.isOpen && event.key === Keys.Enter) {
this.args.closeMenu?.();
} else {
this.args.openMenu?.();
next(() => {
this.args.goToFirstItem?.();
});
}

break;
case Keys.ArrowUp:
event.preventDefault();
event.stopPropagation();
this.args.openMenu?.();

next(() => {
this.args.goToLastItem?.();
});
break;
}
}
<template>
<button
type='button'
aria-haspopup={{true}}
aria-controls={{if @isOpen @itemsGuid}}
aria-expanded={{@isOpen}}
id={{@buttonGuid}}
...attributes
{{on 'click' (or @toggleMenu (noop))}}
{{on 'keydown' this.onKeydown}}
>
{{yield}}
</button>
</template>
}
13 changes: 0 additions & 13 deletions ember-headlessui/addon/components/menu/button.hbs

This file was deleted.

38 changes: 0 additions & 38 deletions ember-headlessui/addon/components/menu/button.js

This file was deleted.

70 changes: 70 additions & 0 deletions ember-headlessui/addon/components/menu/item-element.gts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import Component from '@glimmer/component';
import { element, type ElementFromTagName, type ElementSignature } from 'ember-element-helper';
import { on } from '@ember/modifier';
import { FunctionBasedModifier} from 'ember-modifier';

import or from 'ember-truth-helpers/helpers/or';

function noop() {
return function () {}
}

interface ItemElementSignature<
T extends string = 'a'
> {
Element: Element | ElementFromTagName<T>;
Args: {
tagName?: string | typeof Component;
guid?: string;
isActive?: boolean;
isDisabled?: boolean;
registerItemElement?: FunctionBasedModifier<ElementSignature<T>['Return']>;
onMouseOver?: (event: MouseEvent) => void;
onClick?: (event: MouseEvent) => void;
};
Blocks: {
default: [];
};
}

export default class ItemElement extends Component<ItemElementSignature> {

get tagAsComponent () {
return this.args.tagName instanceof Component ? this.args.tagName as Component: null;
}

get tagAsString () {
return typeof this.args.tagName === 'string' ? this.args.tagName : null;
}

<template>
{{#let
(or this.tagAsComponent (element (or this.tagAsString 'a')))
as |Tag|
}}
<Tag
...attributes
id={{@guid}}
role='menuitem'
tabindex='-1'
disabled={{@isDisabled}}
data-disabled={{@isDisabled}}
{{@registerItemElement}}
{{on 'mouseover' (or @onMouseOver (noop))}}
{{on 'click' (or @onClick (noop))}}
>
{{yield}}
</Tag>

<Component/>
{{/let}}
</template>

}

declare module '@glint/environment-ember-loose/registry' {
export default interface Registry {
'Menu::ItemElement': typeof ItemElement;
'menu/item-element': typeof ItemElement;
}
}
Loading