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 Popover component #51

Draft
wants to merge 13 commits into
base: main
Choose a base branch
from
3 changes: 3 additions & 0 deletions .eslintrc.js
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,9 @@ module.exports = {
'plugin:@typescript-eslint/recommended',
'plugin:prettier/recommended',
],
rules: {
'@typescript-eslint/explicit-module-boundary-types': 'off',
},
},

// node files
Expand Down
1 change: 1 addition & 0 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{}
4 changes: 2 additions & 2 deletions addon/components/announcement/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,10 @@ import Component from '@glimmer/component';
*
* @see https://www.w3.org/TR/wai-aria-1.1/#attrs_liveregions
*/
export default class Text extends Component {
export default class Announcement extends Component {
appParent;

constructor(owner, args) {
constructor(owner: unknown, args: Record<string, unknown>) {
super(owner, args);

// eslint-disable-next-line
Expand Down
30 changes: 30 additions & 0 deletions addon/components/popover/index.hbs
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
<PopperJS @placement="bottom-end" as |triggerRef panelRef|>
{{yield
(hash
isOpen=this.isOpen
openPopover=this.openPopover
closePopover=this.closePopover
Trigger=(component
"popover/trigger"
triggerRef=triggerRef
triggerGuid=this.triggerGuid
panelGuid=this.panelGuid
isOpen=this.isOpen
openPopover=this.openPopover
closePopover=this.closePopover
togglePopover=this.togglePopover
)
Panel=(component
"popover/panel"
panelRef=panelRef
triggerGuid=this.triggerGuid
panelGuid=this.panelGuid
panelElement=this.panelElement
isOpen=this.isOpen
closePopover=this.closePopover
)
)
}}
</PopperJS>
{{on-window "click" this.handleClickOutside}}
{{on-window "focus" this.handleFocusOut}}
96 changes: 96 additions & 0 deletions addon/components/popover/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import { action } from '@ember/object';
import { guidFor } from '@ember/object/internals';
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';

interface PopoverArgs {
className?: string;
}

/**
*
*
* @export
* @class Popover
* @extends {Component<PopoverArgs>}
* @see link to on-helper docs
* @see link to ember-popperjs docs
*/
export default class Popover extends Component<PopoverArgs> {
guid = `${guidFor(this)}-popover`;
@tracked isOpen = false;

@action
openPopover() {
this.isOpen = true;
}

@action
closePopover(focusableElement?: HTMLElement) {
this.isOpen = false;

const elementToFocus = (() => {
if (!focusableElement) return this.triggerElement;
if (focusableElement instanceof HTMLElement) return focusableElement;

return this.triggerElement;
})();

elementToFocus?.focus();
}

@action
togglePopover() {
if (this.isOpen) {
this.closePopover();
} else {
this.openPopover();
}
}

@action
handleClickOutside(event: Event) {
const target = event.target as HTMLElement;

if (!this.isOpen) return;
if (this.triggerElement?.contains(target)) return;
if (this.panelElement?.contains(target)) return;

this.isOpen = false;
event.preventDefault();
this.triggerElement?.focus();
}

@action
handleFocusOut(event: FocusEvent) {
console.log('focus event', event);

if (!this.isOpen) return;
if (this.triggerElement?.contains(document.activeElement)) return;
if (this.panelElement?.contains(document.activeElement)) return;

this.closePopover();
}

get triggerGuid() {
return `${this.guid}-trigger`;
}

get panelGuid() {
return `${this.guid}-panel`;
}

get triggerElement() {
// eslint-disable-next-line
// @ts-ignore
return typeof FastBoot === 'undefined'
? document.getElementById(this.triggerGuid)
: null;
}

get panelElement() {
return typeof FastBoot === 'undefined'
? document.getElementById(this.panelGuid)
: null;
}
}
12 changes: 12 additions & 0 deletions addon/components/popover/panel/index.hbs
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
{{#if @isOpen}}
<div
id={{@panelGuid}}
aria-labelledby={{@buttonGuid}}
tabindex="-1"
...attributes
{{on "keyup" this.handleKeyUp}}
{{@panelRef}}
>
{{yield}}
</div>
{{/if}}
30 changes: 30 additions & 0 deletions addon/components/popover/panel/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { action } from '@ember/object';
import Component from '@glimmer/component';

import { normalizeKey } from '@showbie/backpack-ember/utils/normalize-keycode';

interface PanelArgs {
isOpen: boolean;
panelElement: HTMLElement;
closePopover: () => void;
}

export default class Panel extends Component<PanelArgs> {
@action
handleKeyUp(event: KeyboardEvent) {
const { isOpen, panelElement, closePopover } = this.args;
const eventKey = normalizeKey(event);

switch (eventKey) {
case 'Escape':
if (!isOpen) return;
if (!panelElement) return;
if (!panelElement.contains(document.activeElement)) return;

event.preventDefault();
event.stopPropagation();
closePopover();
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This internal action calls the closePopover argument function, which in turn updates the value of isOpen.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure if this affects anything that you are working with right now but shouldn't this be this.args.closePopover()?

Copy link
Contributor Author

@stormwarning stormwarning Nov 22, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm, it's destructured off of this.args a few lines up, but I'll see if it makes a difference...

NARRATOR: It didn't.

break;
}
}
}
14 changes: 14 additions & 0 deletions addon/components/popover/trigger/index.hbs
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
{{! template-lint-disable no-down-event-binding }}
<button
id={{@triggerGuid}}
type="button"
aria-haspopup="true"
aria-controls={{if @isOpen @panelGuid}}
aria-expanded={{@isOpen}}
...attributes
{{@triggerRef}}
{{on "click" @togglePopover}}
{{on "keydown" this.handleKeydown}}
>
{{yield}}
</button>
46 changes: 46 additions & 0 deletions addon/components/popover/trigger/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { action } from '@ember/object';
import { next } from '@ember/runloop';
import Component from '@glimmer/component';

import { normalizeKey } from '@showbie/backpack-ember/utils/normalize-keycode';

interface TriggerArgs {
triggerGuid: string;
isOpen: boolean;
openPopover: () => void;
closePopover: () => void;
}

export default class Trigger extends Component<TriggerArgs> {
@action
handleKeydown(event: KeyboardEvent) {
// @ts-expect-error -- `disabled` does exist on evt.target
if (event.target?.disabled) return;

const eventKey = normalizeKey(event);
switch (eventKey) {
case 'Space':
case 'Enter':
event.preventDefault();
event.stopPropagation();

if (this.args.isOpen && eventKey === 'Enter') {
this.args.closePopover();
} else {
this.args.openPopover();
next(() => {
// Focus first item?
});
}
break;

case 'Escape':
if (!this.args.isOpen) return;

event.preventDefault();
event.stopPropagation();
this.args.closePopover();
break;
}
}
}
14 changes: 14 additions & 0 deletions addon/utils/normalize-keycode.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
type KeyboardEventValues = Pick<KeyboardEvent, 'key' | 'keyCode'>;

/**
* Normalizes the 'key' property of a KeyboardEvent in IE/Edge.
*/
export function normalizeKey({ key, keyCode }: KeyboardEventValues) {
if (keyCode >= 37 && keyCode <= 40 && key.indexOf('Arrow') !== 0) {
return `Arrow${key}`;
}
if (keyCode === 27) {
return 'Escape';
}
return key;
}
1 change: 1 addition & 0 deletions app/components/popover.js
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { default } from '@showbie/backpack-ember/components/popover';
1 change: 1 addition & 0 deletions app/components/popover/panel.js
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { default } from '@showbie/backpack-ember/components/popover/panel';
1 change: 1 addition & 0 deletions app/components/popover/trigger.js
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { default } from '@showbie/backpack-ember/components/popover/trigger';
1 change: 1 addition & 0 deletions app/utils/normalize-keycode.js
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { normalizeKey } from '@showbie/backpack-ember/utils/normalize-keycode';
2 changes: 1 addition & 1 deletion ember-cli-build.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ module.exports = function (defaults) {

/** @see https://github.com/ef4/prember */
prember: {
urls: ['/', '/badge', '/stack', '/text'],
urls: ['/', '/badge', '/popover', '/stack', '/text'],
},
});

Expand Down
Loading