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

[WIP] Convert BsDropdown to TypeScript #2109

Draft
wants to merge 10 commits into
base: master
Choose a base branch
from
9 changes: 7 additions & 2 deletions ember-bootstrap/addon/components/bs-dropdown.hbs
Original file line number Diff line number Diff line change
@@ -1,10 +1,15 @@
{{! @glint-nocheck }}
{{#let (element this.htmlTag) as |Tag|}}
<Tag
class="{{this.containerClass}}
{{if this.inNav "nav-item"}}
{{if this.isOpen "show"}}"
...attributes
{{!
TODO: Implementation is likely broken.

1. `@open` argument seems to be a typo. The documented argument is `isOpen`.
2. `updateIsOpen` function expect value of open as first argument. But it gets
the element instead.
}}
{{did-update this.updateIsOpen @open}}
>
{{yield
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@ import { assert } from '@ember/debug';
import { getOwnConfig, macroCondition } from '@embroider/macros';
import { tracked } from '@glimmer/tracking';
import { next } from '@ember/runloop';
import type { ComponentLike } from '@glint/template';
import type BsDropdownMenuComponent from './bs-dropdown/menu';
import type BsDropdownToggleComponent from './bs-dropdown/toggle';
import type EmbroiderConfig from '../utils/embroider-config';

const ESCAPE_KEYCODE = 27; // KeyboardEvent.which value for Escape (Esc) key
const SPACE_KEYCODE = 32; // KeyboardEvent.which value for space key
Expand All @@ -17,6 +21,41 @@ const SUPPORTED_KEYCODES = [
ARROW_UP_KEYCODE,
];

interface DropdownSignature {
Element: Element;
Args: {
buttonComponent?: ComponentLike<unknown>;
closeOnMenuClick?: boolean;
direction?: 'down' | 'up' | 'left' | 'right';
htmlTag?: string;
isOpen?: boolean;
menuComponent: ComponentLike<BsDropdownMenuComponent>;
onHide: () => undefined | false;
onShow: () => void;
toggleComponent?: ComponentLike<BsDropdownToggleComponent>;

// TODO: Clarify usage of `open` and `isOpen` arguments. Likely one is a typo.
open: boolean;

/** private */
inNav?: boolean;
};
Blocks: {
default: [
{
// TODO: improve typing
button: ComponentLike<unknown>;
closeDropdown: () => void;
isOpen: boolean;
menu: ComponentLike<BsDropdownMenuComponent>;
openDropdown: () => void;
toggle: ComponentLike<BsDropdownToggleComponent>;
toggleDropdown: () => void;
},
];
};
}

/**
Bootstrap style [dropdown menus](http://getbootstrap.com/components/#dropdowns), consisting
of a toggle element, and the dropdown menu itself.
Expand Down Expand Up @@ -175,7 +214,7 @@ const SUPPORTED_KEYCODES = [
@extends Component
@public
s*/
export default class Dropdown extends Component {
export default class Dropdown extends Component<DropdownSignature> {
/**
* The tag name used for the dropdown element.
*
Expand Down Expand Up @@ -245,7 +284,7 @@ export default class Dropdown extends Component {
* @private
*/
get containerClass() {
if (macroCondition(getOwnConfig().isBS5)) {
if (macroCondition(getOwnConfig<EmbroiderConfig>().isBS5)) {
if (this.direction === 'left') {
return 'dropstart';
} else if (this.direction === 'right') {
Expand All @@ -261,7 +300,7 @@ export default class Dropdown extends Component {
* @private
*/
@tracked
toggleElement = null;
toggleElement: HTMLElement | null = null;

/**
* The DOM element of the `.dropdown-menu` element
Expand All @@ -270,7 +309,7 @@ export default class Dropdown extends Component {
* @private
*/
@tracked
menuElement = null;
menuElement: HTMLElement | null = null;

/**
* Action is called when dropdown is about to be shown
Expand Down Expand Up @@ -323,13 +362,19 @@ export default class Dropdown extends Component {
* @protected
*/
@action
closeHandler(e) {
let { target } = e;
let { toggleElement, menuElement } = this;
closeHandler(e: Event) {
const { target } = e;
const { toggleElement, menuElement } = this;

assert('Event must have a target', target);
assert(
'Event target must be an HTML element',
target instanceof HTMLElement,
);
if (
!this.isDestroyed &&
((e.type === 'keyup' &&
((e instanceof KeyboardEvent &&
e.type === 'keyup' &&
e.which === TAB_KEYCODE &&
menuElement &&
!menuElement.contains(target)) ||
Expand All @@ -344,7 +389,14 @@ export default class Dropdown extends Component {
}

@action
handleKeyEvent(event) {
handleKeyEvent(event: Event) {
assert('Event must have a target', event.target);
assert(
'Event target must be an HTMLElement',
event.target instanceof HTMLElement,
);
assert('Event must be a keyboard event', event instanceof KeyboardEvent);

// If not input/textarea:
// - And not a key in REGEXP_KEYDOWN => not a dropdown command
// If input/textarea:
Expand All @@ -358,7 +410,7 @@ export default class Dropdown extends Component {
(event.which !== ESCAPE_KEYCODE &&
((event.which !== ARROW_DOWN_KEYCODE &&
event.which !== ARROW_UP_KEYCODE) ||
this.menuElement.contains(event.target)))
this.menuElement?.contains(event.target)))
: !SUPPORTED_KEYCODES.includes(event.which)
) {
return;
Expand All @@ -375,21 +427,26 @@ export default class Dropdown extends Component {
event.which === SPACE_KEYCODE
) {
this.closeDropdown();
this.toggleElement.focus();
this.toggleElement?.focus();
return;
}

let items = [].slice.call(
assert('Menu element must be set', this.menuElement);
const items = Array.from(
this.menuElement.querySelectorAll(
'.dropdown-item:not(.disabled):not(:disabled)',
),
) as NodeListOf<HTMLElement>,
);

if (items.length === 0) {
return;
}

let index = items.indexOf(event.target);
assert(
'Event target must be an item of the dropdown which is not disabled',
index,
);

if (event.which === ARROW_UP_KEYCODE && index > 0) {
// Up
Expand All @@ -405,11 +462,12 @@ export default class Dropdown extends Component {
index = 0;
}

items[index].focus();
assert('Element targeted by keyboard navigation must exist', items[index]);
items[index]?.focus();
}

@action
registerChildElement(element, [type]) {
registerChildElement(element: HTMLElement, [type]: ['toggle' | 'menu']) {
assert(
`Unknown child element type "${type}"`,
type === 'toggle' || type === 'menu',
Expand All @@ -423,7 +481,7 @@ export default class Dropdown extends Component {
}

@action
unregisterChildElement(element, [type]) {
unregisterChildElement(element: HTMLElement, [type]: ['toggle' | 'menu']) {
assert(
`Unknown child element type "${type}"`,
type === 'toggle' || type === 'menu',
Expand All @@ -433,7 +491,7 @@ export default class Dropdown extends Component {
}

@action
updateIsOpen(open) {
updateIsOpen(open: boolean) {
this.isOpen = open;
}

Expand Down
1 change: 0 additions & 1 deletion ember-bootstrap/addon/components/bs-dropdown/button.hbs
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
{{! @glint-nocheck }}
<BsButton
{{! cannot pass @block directly because that triggers reserved keyword assertion when compiling the template }}
{{! template-lint-disable no-capital-arguments no-args-paths }}
Expand Down
13 changes: 0 additions & 13 deletions ember-bootstrap/addon/components/bs-dropdown/button.js

This file was deleted.

54 changes: 54 additions & 0 deletions ember-bootstrap/addon/components/bs-dropdown/button.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import templateOnly from '@ember/component/template-only';

interface DropdownButtonSignature {
Element: HTMLButtonElement;
Args: {
block?: boolean;

/**
* @internal
*/
isOpen: boolean;

/**
* @internal
*/
onClick: () => void;

/**
* @internal
*/
onKeyDown: () => void;

/**
* @internal
*/
registerChildElement: (
element: HTMLButtonElement,
[type]: ['toggle'],
) => void;

/**
* @internal
*/
unregisterChildElement: (
element: HTMLButtonElement,
[type]: ['toggle'],
) => void;
};
Blocks: {
default: [];
};
}

/**
Button component with that can act as a dropdown toggler.

See [Components.Dropdown](Components.Dropdown.html) for examples.

@class DropdownButton
@namespace Components
@extends Components.Button
@public
*/
export default templateOnly<DropdownButtonSignature>();
1 change: 0 additions & 1 deletion ember-bootstrap/addon/components/bs-dropdown/menu.hbs
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
{{! @glint-nocheck }}
{{#if @isOpen}}
{{#if this._renderInPlace}}
<div
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,47 @@ import { getDestinationElement } from 'ember-bootstrap/utils/dom';
import { ref } from 'ember-ref-bucket';
import { tracked } from '@glimmer/tracking';
import { getOwnConfig, macroCondition } from '@embroider/macros';
import type { Placement as PopperPlacement } from '@popperjs/core';
import type BsDropdownMenuDividerComponent from './menu/divider';
import type BsDropdownMenuItemComponent from './menu/item';
import type BsLinkToComponent from '../bs-link-to';
import type { ComponentLike } from '@glint/template';
import type EmbroiderConfig from '../../utils/embroider-config';

interface DropdownMenuSignature {
Element: HTMLDivElement;
Args: {
align?: 'left' | 'right';
dividerComponent: ComponentLike<typeof BsDropdownMenuDividerComponent>;
isOpen?: boolean;
itemComponent: ComponentLike<typeof BsDropdownMenuItemComponent>;
linkToComponent: ComponentLike<BsLinkToComponent>;
registerChildElement: (element: HTMLDivElement, [type]: ['menu']) => void;
renderInPlace?: boolean;
toggleElement: HTMLAnchorElement;
unregisterChildElement: (element: HTMLDivElement, [type]: ['menu']) => void;

/**
* @internal
*/
direction?: 'up' | 'down' | 'left' | 'right';

/**
* @internal
*/
inNav?: boolean;
};
Blocks: {
default: [
{
divider: ComponentLike<typeof BsDropdownMenuDividerComponent>;
item: ComponentLike<typeof BsDropdownMenuItemComponent>;
linkTo: ComponentLike<BsLinkToComponent>;
'link-to': ComponentLike<BsLinkToComponent>;
},
];
};
}

/**
Component for the dropdown menu.
Expand All @@ -16,13 +57,13 @@ import { getOwnConfig, macroCondition } from '@embroider/macros';
@extends Component
@public
*/
export default class DropdownMenu extends Component {
export default class DropdownMenu extends Component<DropdownMenuSignature> {
/**
* @property _element
* @type null | HTMLElement
* @private
*/
@ref('menuElement') menuElement = null;
@ref('menuElement') menuElement: HTMLDivElement | null = null;

/**
* Alignment of the menu, either "left" or "right"
Expand Down Expand Up @@ -91,7 +132,9 @@ export default class DropdownMenu extends Component {

get alignClass() {
if (this.align === 'right') {
const alignClass = macroCondition(getOwnConfig().isBS4) ? 'right' : 'end';
const alignClass = macroCondition(getOwnConfig<EmbroiderConfig>().isBS4)
? 'right'
: 'end';
return `dropdown-menu-${alignClass}`;
}

Expand All @@ -102,7 +145,7 @@ export default class DropdownMenu extends Component {
isOpen = this.args.isOpen;

@action
updateIsOpen(value) {
updateIsOpen(value: boolean) {
// delay removing the menu from DOM to allow (delegated Ember) event to fire for the menu's children
// Fixes https://github.com/kaliber5/ember-bootstrap/issues/660
next(() => {
Expand All @@ -115,9 +158,9 @@ export default class DropdownMenu extends Component {

flip = true;

get popperPlacement() {
let placement = 'bottom-start';
let { direction, align } = this;
get popperPlacement(): PopperPlacement {
let placement: PopperPlacement = 'bottom-start';
const { direction, align } = this;

if (direction === 'up') {
placement = 'top-start';
Expand Down