feat: update to latest overlay system

Co-authored-by: Thomas Allmer <Thomas.Allmer@ing.com>
Co-authored-by: Joren Broekema <Joren.Broekema@ing.com>
Co-authored-by: Mikhail Bashkirov <Mikhail.Bashkirov@ing.com>
Co-authored-by: Alex Ghiu <Alex.Ghiu@ing.com>
This commit is contained in:
Thijs Louisse 2019-10-10 16:42:40 +02:00
parent 364f185ad8
commit 4c26befaae
19 changed files with 243 additions and 259 deletions

View file

@ -221,7 +221,6 @@ export class LionCalendar extends LocalizeMixin(LitElement) {
firstUpdated() { firstUpdated() {
super.firstUpdated(); super.firstUpdated();
this.__contentWrapperElement = this.shadowRoot.getElementById('js-content-wrapper'); this.__contentWrapperElement = this.shadowRoot.getElementById('js-content-wrapper');
this.__addEventDelegationForClickDate(); this.__addEventDelegationForClickDate();
this.__addEventDelegationForFocusDate(); this.__addEventDelegationForFocusDate();
this.__addEventDelegationForBlurDate(); this.__addEventDelegationForBlurDate();
@ -501,6 +500,9 @@ export class LionCalendar extends LocalizeMixin(LitElement) {
} }
__removeEventDelegations() { __removeEventDelegations() {
if (!this.__contentWrapperElement) {
return;
}
this.__contentWrapperElement.removeEventListener('click', this.__clickDateDelegation); this.__contentWrapperElement.removeEventListener('click', this.__clickDateDelegation);
this.__contentWrapperElement.removeEventListener('focus', this.__focusDateDelegation); this.__contentWrapperElement.removeEventListener('focus', this.__focusDateDelegation);
this.__contentWrapperElement.removeEventListener('blur', this.__blurDateDelegation); this.__contentWrapperElement.removeEventListener('blur', this.__blurDateDelegation);

View file

@ -1,7 +1,7 @@
import { html, render, ifDefined } from '@lion/core'; import { html, ifDefined, render } from '@lion/core';
import { LionInputDate } from '@lion/input-date'; import { LionInputDate } from '@lion/input-date';
import { overlays, ModalDialogController } from '@lion/overlays'; import { OverlayController, withModalDialogConfig, OverlayMixin } from '@lion/overlays';
import { Unparseable, isValidatorApplied } from '@lion/validate'; import { isValidatorApplied } from '@lion/validate';
import '@lion/calendar/lion-calendar.js'; import '@lion/calendar/lion-calendar.js';
import './lion-calendar-overlay-frame.js'; import './lion-calendar-overlay-frame.js';
@ -9,7 +9,7 @@ import './lion-calendar-overlay-frame.js';
* @customElement lion-input-datepicker * @customElement lion-input-datepicker
* @extends {LionInputDate} * @extends {LionInputDate}
*/ */
export class LionInputDatepicker extends LionInputDate { export class LionInputDatepicker extends OverlayMixin(LionInputDate) {
static get properties() { static get properties() {
return { return {
/** /**
@ -46,7 +46,11 @@ export class LionInputDatepicker extends LionInputDate {
get slots() { get slots() {
return { return {
...super.slots, ...super.slots,
[this._calendarInvokerSlot]: () => this.__createPickerAndReturnInvokerNode(), [this._calendarInvokerSlot]: () => {
const renderParent = document.createElement('div');
render(this._invokerTemplate(), renderParent);
return renderParent.firstElementChild;
},
}; };
} }
@ -137,12 +141,8 @@ export class LionInputDatepicker extends LionInputDate {
return this.querySelector(`#${this.__invokerId}`); return this.querySelector(`#${this.__invokerId}`);
} }
get _calendarOverlayElement() {
return this._overlayCtrl.contentNode;
}
get _calendarElement() { get _calendarElement() {
return this._calendarOverlayElement.querySelector('#calendar'); return this._overlayCtrl.contentNode.querySelector('#calendar');
} }
constructor() { constructor() {
@ -200,7 +200,12 @@ export class LionInputDatepicker extends LionInputDate {
} }
} }
_calendarOverlayTemplate() { /**
* Defining this overlay as a templates lets OverlayInteraceMixin
* this is our source to give as .contentNode to OverlayController.
* Important: do not change the name of this method.
*/
_overlayTemplate() {
return html` return html`
<lion-calendar-overlay-frame @dialog-close=${() => this._overlayCtrl.hide()}> <lion-calendar-overlay-frame @dialog-close=${() => this._overlayCtrl.hide()}>
<span slot="heading">${this.calendarHeading}</span> <span slot="heading">${this.calendarHeading}</span>
@ -240,8 +245,6 @@ export class LionInputDatepicker extends LionInputDate {
type="button" type="button"
@click="${this.__openCalendarOverlay}" @click="${this.__openCalendarOverlay}"
id="${this.__invokerId}" id="${this.__invokerId}"
aria-haspopup="dialog"
aria-expanded="false"
aria-label="${this.msgLit('lion-input-datepicker:openDatepickerLabel')}" aria-label="${this.msgLit('lion-input-datepicker:openDatepickerLabel')}"
title="${this.msgLit('lion-input-datepicker:openDatepickerLabel')}" title="${this.msgLit('lion-input-datepicker:openDatepickerLabel')}"
> >
@ -250,27 +253,26 @@ export class LionInputDatepicker extends LionInputDate {
`; `;
} }
__createPickerAndReturnInvokerNode() { /**
const renderParent = document.createElement('div'); * @override Configures OverlayMixin
render(this._invokerTemplate(), renderParent); * @desc returns an instance of a (dynamic) overlay controller
const invokerNode = renderParent.firstElementChild; * @returns {OverlayController}
*/
// TODO: ModalDialogController could be replaced by a more flexible // eslint-disable-next-line class-methods-use-this
// overlay, allowing the overlay to switch on smaller screens, for instance from dropdown to _defineOverlay({ contentNode, invokerNode }) {
// bottom sheet via DynamicOverlayController const ctrl = new OverlayController({
this._overlayCtrl = overlays.add( ...withModalDialogConfig(),
new ModalDialogController({ contentNode,
contentTemplate: () => this._calendarOverlayTemplate(), invokerNode,
elementToFocusAfterHide: invokerNode, elementToFocusAfterHide: invokerNode,
}), });
); return ctrl;
return invokerNode;
} }
async __openCalendarOverlay() { async __openCalendarOverlay() {
this._overlayCtrl.show(); this._overlayCtrl.show();
await Promise.all([ await Promise.all([
this._calendarOverlayElement.updateComplete, this._overlayCtrl.contentNode.updateComplete,
this._calendarElement.updateComplete, this._calendarElement.updateComplete,
]); ]);
this._onCalendarOverlayOpened(); this._onCalendarOverlayOpened();
@ -301,7 +303,7 @@ export class LionInputDatepicker extends LionInputDate {
* @returns {Date|undefined} a 'guarded' modelValue * @returns {Date|undefined} a 'guarded' modelValue
*/ */
static __getSyncDownValue(modelValue) { static __getSyncDownValue(modelValue) {
return modelValue instanceof Unparseable ? undefined : modelValue; return modelValue instanceof Date ? modelValue : undefined;
} }
/** /**
@ -327,4 +329,11 @@ export class LionInputDatepicker extends LionInputDate {
} }
}); });
} }
/**
* @override Configures OverlayMixin
*/
get _overlayInvokerNode() {
return this._invokerElement;
}
} }

View file

@ -24,6 +24,10 @@ export class DatepickerInputObject {
return Promise.all(completePromises); return Promise.all(completePromises);
} }
async closeCalendar() {
this.overlayCloseButtonEl.click();
}
async selectMonthDay(day) { async selectMonthDay(day) {
this.overlayController.show(); this.overlayController.show();
await this.calendarEl.updateComplete; await this.calendarEl.updateComplete;

View file

@ -1,6 +1,5 @@
import { expect, fixture, defineCE } from '@open-wc/testing'; import { expect, fixture, defineCE } from '@open-wc/testing';
import sinon from 'sinon'; import sinon from 'sinon';
import { localizeTearDown } from '@lion/localize/test-helpers.js';
import { html, LitElement } from '@lion/core'; import { html, LitElement } from '@lion/core';
import { import {
maxDateValidator, maxDateValidator,
@ -15,10 +14,6 @@ import { LionInputDatepicker } from '../src/LionInputDatepicker.js';
import '../lion-input-datepicker.js'; import '../lion-input-datepicker.js';
describe('<lion-input-datepicker>', () => { describe('<lion-input-datepicker>', () => {
beforeEach(() => {
localizeTearDown();
});
describe('Calendar Overlay', () => { describe('Calendar Overlay', () => {
it('implements calendar-overlay Style component', async () => { it('implements calendar-overlay Style component', async () => {
const el = await fixture(html` const el = await fixture(html`
@ -287,14 +282,16 @@ describe('<lion-input-datepicker>', () => {
expect(elObj.invokerEl.getAttribute('aria-label')).to.equal('Open date picker'); expect(elObj.invokerEl.getAttribute('aria-label')).to.equal('Open date picker');
}); });
// TODO: move this functionality to GlobalOverlay it('adds [aria-expanded] to invoker button', async () => {
it('adds aria-haspopup="dialog" and aria-expanded="true" to invoker button', async () => {
const el = await fixture(html` const el = await fixture(html`
<lion-input-datepicker></lion-input-datepicker> <lion-input-datepicker></lion-input-datepicker>
`); `);
const elObj = new DatepickerInputObject(el); const elObj = new DatepickerInputObject(el);
expect(elObj.invokerEl.getAttribute('aria-haspopup')).to.equal('dialog'); expect(elObj.invokerEl.getAttribute('aria-expanded')).to.equal('false');
await elObj.openCalendar();
expect(elObj.invokerEl.getAttribute('aria-expanded')).to.equal('true');
await elObj.closeCalendar();
expect(elObj.invokerEl.getAttribute('aria-expanded')).to.equal('false'); expect(elObj.invokerEl.getAttribute('aria-expanded')).to.equal('false');
}); });
}); });

View file

@ -28,7 +28,8 @@ export class LionOption extends DisabledMixin(ChoiceInputMixin(FormRegisteringMi
padding: 4px; padding: 4px;
} }
:host([active]) { :host([active]),
:host(:hover) {
background-color: #ddd; background-color: #ddd;
} }
@ -46,7 +47,7 @@ export class LionOption extends DisabledMixin(ChoiceInputMixin(FormRegisteringMi
constructor() { constructor() {
super(); super();
this.active = false; this.active = false;
this.__registerEventListener(); this.__registerEventListeners();
} }
_requestUpdate(name, oldValue) { _requestUpdate(name, oldValue) {
@ -81,35 +82,16 @@ export class LionOption extends DisabledMixin(ChoiceInputMixin(FormRegisteringMi
this.setAttribute('role', 'option'); this.setAttribute('role', 'option');
} }
disconnectedCallback() { __registerEventListeners() {
super.disconnectedCallback();
this.__unRegisterEventListeners();
}
__registerEventListener() {
this.__onClick = () => { this.__onClick = () => {
if (!this.disabled) { if (!this.disabled) {
this.checked = true; this.checked = true;
} }
}; };
this.__onMouseEnter = () => {
if (!this.disabled) {
this.active = true;
}
};
this.__onMouseLeave = () => {
if (!this.disabled) {
this.active = false;
}
};
this.addEventListener('click', this.__onClick); this.addEventListener('click', this.__onClick);
this.addEventListener('mouseenter', this.__onMouseEnter);
this.addEventListener('mouseleave', this.__onMouseLeave);
} }
__unRegisterEventListeners() { __unRegisterEventListeners() {
this.removeEventListener('click', this.__onClick); this.removeEventListener('click', this.__onClick);
this.removeEventListener('mouseenter', this.__onMouseEnter);
this.removeEventListener('mouseleave', this.__onMouseLeave);
} }
} }

View file

@ -80,24 +80,6 @@ describe('lion-option', () => {
expect(el.hasAttribute('active')).to.be.false; expect(el.hasAttribute('active')).to.be.false;
}); });
it('does become active on [mouseenter]', async () => {
const el = await fixture(html`
<lion-option .choiceValue=${10}></lion-option>
`);
expect(el.active).to.be.false;
el.dispatchEvent(new Event('mouseenter'));
expect(el.active).to.be.true;
});
it('does become un-active on [mouseleave]', async () => {
const el = await fixture(html`
<lion-option .choiceValue=${10} active></lion-option>
`);
expect(el.active).to.be.true;
el.dispatchEvent(new Event('mouseleave'));
expect(el.active).to.be.false;
});
it('does become checked on [click]', async () => { it('does become checked on [click]', async () => {
const el = await fixture(html` const el = await fixture(html`
<lion-option .choiceValue=${10}></lion-option> <lion-option .choiceValue=${10}></lion-option>

View file

@ -1,53 +1,40 @@
import { UpdatingElement } from '@lion/core'; import { LitElement, html } from '@lion/core';
import { overlays, LocalOverlayController } from '@lion/overlays'; import { OverlayMixin, OverlayController } from '@lion/overlays';
export class LionPopup extends UpdatingElement { export class LionPopup extends OverlayMixin(LitElement) {
static get properties() { render() {
return { return html`
popperConfig: { <slot name="invoker"></slot>
type: Object, <slot name="content"></slot>
}, `;
};
} }
get popperConfig() { get _overlayContentNode() {
return this._popperConfig; return this.querySelector('[slot=content]');
} }
set popperConfig(config) { get _overlayInvokerNode() {
this._popperConfig = { return this.querySelector('[slot=invoker]');
...this._popperConfig, }
...config,
};
if (this._controller && this._controller._popper) { // eslint-disable-next-line class-methods-use-this
this._controller.updatePopperConfig(this._popperConfig); _defineOverlay() {
} return new OverlayController({
placementMode: 'local',
contentNode: this._overlayContentNode,
invokerNode: this._overlayInvokerNode,
handlesAccessibility: true,
});
} }
connectedCallback() { connectedCallback() {
super.connectedCallback(); super.connectedCallback();
this.contentNode = this.querySelector('[slot="content"]'); this.__toggle = () => this._overlayCtrl.toggle();
this.invokerNode = this.querySelector('[slot="invoker"]'); this._overlayInvokerNode.addEventListener('click', this.__toggle);
this._controller = overlays.add(
new LocalOverlayController({
hidesOnEsc: true,
hidesOnOutsideClick: true,
popperConfig: this.popperConfig,
contentNode: this.contentNode,
invokerNode: this.invokerNode,
}),
);
this._show = () => this._controller.show();
this._hide = () => this._controller.hide();
this._toggle = () => this._controller.toggle();
this.invokerNode.addEventListener('click', this._toggle);
} }
disconnectedCallback() { disconnectedCallback() {
super.disconnectedCallback(); super.disconnectedCallback();
this.invokerNode.removeEventListener('click', this._toggle); this._overlayInvokerNode.removeEventListener('click', this._toggle);
} }
} }

View file

@ -11,7 +11,7 @@ describe('lion-popup', () => {
<lion-button slot="invoker">Popup button</lion-button> <lion-button slot="invoker">Popup button</lion-button>
</lion-popup> </lion-popup>
`); `);
expect(el.querySelector('[slot="content"]').style.display).to.be.equal('none'); expect(el._overlayCtrl.isShown).to.be.false;
}); });
it('should toggle to show content on click', async () => { it('should toggle to show content on click', async () => {
@ -25,10 +25,10 @@ describe('lion-popup', () => {
invoker.click(); invoker.click();
await el.updateComplete; await el.updateComplete;
expect(el.querySelector('[slot="content"]').style.display).to.be.equal('inline-block'); expect(el._overlayCtrl.isShown).to.be.true;
invoker.click(); invoker.click();
await el.updateComplete; await el.updateComplete;
expect(el.querySelector('[slot="content"]').style.display).to.be.equal('none'); expect(el._overlayCtrl.isShown).to.be.false;
}); });
it('should support popup containing html when specified in popup content body', async () => { it('should support popup containing html when specified in popup content body', async () => {
@ -52,25 +52,12 @@ describe('lion-popup', () => {
<lion-button slot="invoker">Popup button</lion-button> <lion-button slot="invoker">Popup button</lion-button>
</lion-popup> </lion-popup>
`); `);
await el._controller.show(); await el._overlayCtrl.show();
expect(el._controller._popper.options.placement).to.equal('top'); expect(el._overlayCtrl._popper.options.placement).to.equal('top');
el.popperConfig = { placement: 'left' }; el.popperConfig = { placement: 'left' };
await el._controller.show(); await el._overlayCtrl.show();
expect(el._controller._popper.options.placement).to.equal('left'); expect(el._overlayCtrl._popper.options.placement).to.equal('left');
});
});
describe('Accessibility', () => {
it('should have aria-controls attribute set to the invoker', async () => {
const el = await fixture(html`
<lion-popup>
<div slot="content" class="popup">Hey there</div>
<lion-button slot="invoker">Popup button</lion-button>
</lion-popup>
`);
const invoker = el.querySelector('[slot="invoker"]');
expect(invoker.getAttribute('aria-controls')).to.not.be.null;
}); });
}); });
}); });

View file

@ -1 +1,3 @@
export { LionSelectRich } from './src/LionSelectRich.js'; export { LionSelectRich } from './src/LionSelectRich.js';
export { LionSelectInvoker } from './src/LionSelectInvoker.js';
export { LionOptions } from './src/LionOptions.js';

View file

@ -1,12 +1,13 @@
import { LitElement } from '@lion/core'; import { LitElement } from '@lion/core';
import { FormRegistrarPortalMixin } from '@lion/field';
/** /**
* LionOptions * LionOptions
* *
* @customElement * @customElement lion-options
* @extends LitElement * @extends LitElement
*/ */
export class LionOptions extends LitElement { export class LionOptions extends FormRegistrarPortalMixin(LitElement) {
static get properties() { static get properties() {
return { return {
role: { role: {

View file

@ -4,7 +4,7 @@ import { html } from '@lion/core';
/** /**
* LionSelectInvoker: invoker button consuming a selected element * LionSelectInvoker: invoker button consuming a selected element
* *
* @customElement * @customElement lion-select-invoker
* @extends LionButton * @extends LionButton
*/ */
export class LionSelectInvoker extends LionButton { export class LionSelectInvoker extends LionButton {
@ -13,6 +13,11 @@ export class LionSelectInvoker extends LionButton {
selectedElement: { selectedElement: {
type: Object, type: Object,
}, },
readOnly: {
type: Boolean,
reflect: true,
attribute: 'readonly',
},
}; };
} }
@ -34,6 +39,14 @@ export class LionSelectInvoker extends LionButton {
constructor() { constructor() {
super(); super();
this.selectedElement = null; this.selectedElement = null;
this.type = 'button';
}
_requestUpdate(name, oldValue) {
super._requestUpdate(name, oldValue);
if (name === 'readOnly') {
this.disabled = this.readOnly;
}
} }
_contentTemplate() { _contentTemplate() {

View file

@ -1,5 +1,5 @@
import { html, css, LitElement, SlotMixin } from '@lion/core'; import { html, css, LitElement, SlotMixin } from '@lion/core';
import { LocalOverlayController, overlays } from '@lion/overlays'; import { OverlayController, withDropdownConfig, OverlayMixin } from '@lion/overlays';
import { FormControlMixin, InteractionStateMixin, FormRegistrarMixin } from '@lion/field'; import { FormControlMixin, InteractionStateMixin, FormRegistrarMixin } from '@lion/field';
import { ValidateMixin } from '@lion/validate'; import { ValidateMixin } from '@lion/validate';
import './differentKeyNamesShimIE.js'; import './differentKeyNamesShimIE.js';
@ -22,11 +22,11 @@ function detectInteractionMode() {
/** /**
* LionSelectRich: wraps the <lion-listbox> element * LionSelectRich: wraps the <lion-listbox> element
* *
* @customElement * @customElement lion-select-rich
* @extends LionField * @extends LionField
*/ */
export class LionSelectRich extends FormRegistrarMixin( export class LionSelectRich extends OverlayMixin(
InteractionStateMixin(ValidateMixin(FormControlMixin(SlotMixin(LitElement)))), FormRegistrarMixin(InteractionStateMixin(ValidateMixin(FormControlMixin(SlotMixin(LitElement))))),
) { ) {
static get properties() { static get properties() {
return { return {
@ -39,9 +39,10 @@ export class LionSelectRich extends FormRegistrarMixin(
reflect: true, reflect: true,
}, },
opened: { readOnly: {
type: Boolean, type: Boolean,
reflect: true, reflect: true,
attribute: 'readonly',
}, },
interactionMode: { interactionMode: {
@ -98,7 +99,9 @@ export class LionSelectRich extends FormRegistrarMixin(
} }
get _listboxNode() { get _listboxNode() {
return this.querySelector('[slot=input]'); return (
(this._overlayCtrl && this._overlayCtrl.contentNode) || this.querySelector('[slot=input]')
);
} }
get _listboxActiveDescendantNode() { get _listboxActiveDescendantNode() {
@ -132,7 +135,6 @@ export class LionSelectRich extends FormRegistrarMixin(
super(); super();
this.interactionMode = 'auto'; this.interactionMode = 'auto';
this.disabled = false; this.disabled = false;
this.opened = false;
// for interaction states // for interaction states
// we use a different event as 'model-value-changed' would bubble up from all options // we use a different event as 'model-value-changed' would bubble up from all options
this._valueChangedEvent = 'select-model-value-changed'; this._valueChangedEvent = 'select-model-value-changed';
@ -143,6 +145,7 @@ export class LionSelectRich extends FormRegistrarMixin(
} }
connectedCallback() { connectedCallback() {
this._listboxNode.registrationTarget = this;
if (super.connectedCallback) { if (super.connectedCallback) {
super.connectedCallback(); super.connectedCallback();
} }
@ -150,6 +153,8 @@ export class LionSelectRich extends FormRegistrarMixin(
this.__setupOverlay(); this.__setupOverlay();
this.__setupInvokerNode(); this.__setupInvokerNode();
this.__setupListboxNode(); this.__setupListboxNode();
this._invokerNode.selectedElement = this.formElements[this.checkedIndex];
} }
disconnectedCallback() { disconnectedCallback() {
@ -162,6 +167,11 @@ export class LionSelectRich extends FormRegistrarMixin(
this.__teardownListboxNode(); this.__teardownListboxNode();
} }
firstUpdated(c) {
super.firstUpdated(c);
this.__toggleInvokerDisabled();
}
_requestUpdate(name, oldValue) { _requestUpdate(name, oldValue) {
super._requestUpdate(name, oldValue); super._requestUpdate(name, oldValue);
if ( if (
@ -185,17 +195,14 @@ export class LionSelectRich extends FormRegistrarMixin(
this.interactionMode = detectInteractionMode(); this.interactionMode = detectInteractionMode();
} }
} }
if (name === 'disabled' || name === 'readOnly') {
this.__toggleInvokerDisabled();
}
} }
updated(changedProps) { updated(changedProps) {
super.updated(changedProps); super.updated(changedProps);
if (changedProps.has('opened')) {
if (this.opened) {
this.__overlay.show();
} else {
this.__overlay.hide();
}
}
if (changedProps.has('disabled')) { if (changedProps.has('disabled')) {
if (this.disabled) { if (this.disabled) {
@ -293,15 +300,22 @@ export class LionSelectRich extends FormRegistrarMixin(
this.__onChildModelValueChanged = this.__onChildModelValueChanged.bind(this); this.__onChildModelValueChanged = this.__onChildModelValueChanged.bind(this);
this.__onKeyUp = this.__onKeyUp.bind(this); this.__onKeyUp = this.__onKeyUp.bind(this);
this.addEventListener('active-changed', this.__onChildActiveChanged); this._listboxNode.addEventListener('active-changed', this.__onChildActiveChanged);
this.addEventListener('model-value-changed', this.__onChildModelValueChanged); this._listboxNode.addEventListener('model-value-changed', this.__onChildModelValueChanged);
this.addEventListener('keyup', this.__onKeyUp); this.addEventListener('keyup', this.__onKeyUp);
} }
__teardownEventListeners() { __teardownEventListeners() {
this.removeEventListener('active-changed', this.__onChildActiveChanged); this._listboxNode.removeEventListener('active-changed', this.__onChildActiveChanged);
this.removeEventListener('model-value-changed', this.__onChildModelValueChanged); this._listboxNode.removeEventListener('model-value-changed', this.__onChildModelValueChanged);
this.removeEventListener('keyup', this.__onKeyUp); this._listboxNode.removeEventListener('keyup', this.__onKeyUp);
}
__toggleInvokerDisabled() {
if (this._invokerNode) {
this._invokerNode.disabled = this.disabled;
this._invokerNode.readOnly = this.readOnly;
}
} }
__onChildActiveChanged({ target }) { __onChildActiveChanged({ target }) {
@ -448,6 +462,7 @@ export class LionSelectRich extends FormRegistrarMixin(
switch (key) { switch (key) {
case 'ArrowUp': case 'ArrowUp':
ev.preventDefault(); ev.preventDefault();
if (this.interactionMode === 'mac') { if (this.interactionMode === 'mac') {
this.opened = true; this.opened = true;
} else { } else {
@ -492,7 +507,7 @@ export class LionSelectRich extends FormRegistrarMixin(
__setupInvokerNodeEventListener() { __setupInvokerNodeEventListener() {
this.__invokerOnClick = () => { this.__invokerOnClick = () => {
if (!this.disabled) { if (!this.disabled) {
this.toggle(); this._overlayCtrl.toggle();
} }
}; };
this._invokerNode.addEventListener('click', this.__invokerOnClick); this._invokerNode.addEventListener('click', this.__invokerOnClick);
@ -548,55 +563,33 @@ export class LionSelectRich extends FormRegistrarMixin(
} }
} }
/**
* @overridable Subclassers can override the default
*/
// eslint-disable-next-line class-methods-use-this // eslint-disable-next-line class-methods-use-this
_defineOverlay({ invokerNode, contentNode } = {}) { _defineOverlay({ invokerNode, contentNode } = {}) {
return overlays.add( return new OverlayController({
new LocalOverlayController({ ...withDropdownConfig(),
contentNode, contentNode,
invokerNode, invokerNode,
hidesOnEsc: false, });
hidesOnOutsideClick: true,
inheritsReferenceObjectWidth: true,
popperConfig: {
placement: 'bottom-start',
modifiers: {
offset: {
enabled: false,
},
},
},
}),
);
} }
__setupOverlay() { __setupOverlay() {
this.__overlay = this._defineOverlay({
invokerNode: this._invokerNode,
contentNode: this._listboxNode,
});
this.__overlayOnShow = () => { this.__overlayOnShow = () => {
this.opened = true;
if (this.checkedIndex) { if (this.checkedIndex) {
this.activeIndex = this.checkedIndex; this.activeIndex = this.checkedIndex;
} }
this._listboxNode.focus(); this._listboxNode.focus();
}; };
this.__overlay.addEventListener('show', this.__overlayOnShow); this._overlayCtrl.addEventListener('show', this.__overlayOnShow);
this.__overlayOnHide = () => { this.__overlayOnHide = () => {
this.opened = false;
this._invokerNode.focus(); this._invokerNode.focus();
}; };
this.__overlay.addEventListener('hide', this.__overlayOnHide); this._overlayCtrl.addEventListener('hide', this.__overlayOnHide);
} }
__teardownOverlay() { __teardownOverlay() {
this.__overlay.removeEventListener('show', this.__overlayOnShow); this._overlayCtrl.removeEventListener('show', this.__overlayOnShow);
this.__overlay.removeEventListener('hide', this.__overlayOnHide); this._overlayCtrl.removeEventListener('hide', this.__overlayOnHide);
} }
// eslint-disable-next-line class-methods-use-this // eslint-disable-next-line class-methods-use-this
@ -612,4 +605,18 @@ export class LionSelectRich extends FormRegistrarMixin(
(typeof value !== 'string' && value !== undefined && value !== null), (typeof value !== 'string' && value !== undefined && value !== null),
}; };
} }
/**
* @override Configures OverlayMixin
*/
get _overlayInvokerNode() {
return this._invokerNode;
}
/**
* @override Configures OverlayMixin
*/
get _overlayContentNode() {
return this._listboxNode;
}
} }

View file

@ -4,8 +4,9 @@ import '../lion-options.js';
describe('lion-options', () => { describe('lion-options', () => {
it('should have role="listbox"', async () => { it('should have role="listbox"', async () => {
const registrationTargetEl = document.createElement('div');
const el = await fixture(html` const el = await fixture(html`
<lion-options></lion-options> <lion-options .registrationTarget=${registrationTargetEl}></lion-options>
`); `);
expect(el.role).to.equal('listbox'); expect(el.role).to.equal('listbox');
}); });

View file

@ -48,6 +48,13 @@ describe('lion-select-invoker', () => {
expect(el.getAttribute('tabindex')).to.equal('0'); expect(el.getAttribute('tabindex')).to.equal('0');
}); });
it('delegates the readonly attribute to disabled', async () => {
const el = await fixture(html`
<lion-select-invoker readonly></lion-select-invoker>
`);
expect(el.hasAttribute('disabled')).to.be.true;
});
describe('Subclassers', () => { describe('Subclassers', () => {
it('supports a custom _contentTemplate', async () => { it('supports a custom _contentTemplate', async () => {
const myTag = defineCE( const myTag = defineCE(

View file

@ -362,13 +362,13 @@ describe('lion-select-rich interactions', () => {
</lion-options> </lion-options>
</lion-select-rich> </lion-select-rich>
`); `);
expect(el.activeIndex).to.equal(2);
el._listboxNode.dispatchEvent(new KeyboardEvent('keyup', { key: 'Home' }));
expect(el.activeIndex).to.equal(1); expect(el.activeIndex).to.equal(1);
el._listboxNode.dispatchEvent(new KeyboardEvent('keyup', { key: 'End' })); el._listboxNode.dispatchEvent(new KeyboardEvent('keyup', { key: 'End' }));
expect(el.activeIndex).to.equal(2); expect(el.activeIndex).to.equal(2);
el._listboxNode.dispatchEvent(new KeyboardEvent('keyup', { key: 'Home' }));
expect(el.activeIndex).to.equal(1);
}); });
it('checks the first enabled option', async () => { it('checks the first enabled option', async () => {

View file

@ -1,11 +1,14 @@
import { expect, fixture, html, aTimeout, defineCE, unsafeStatic } from '@open-wc/testing';
import '@lion/option/lion-option.js';
import { import {
overlays, expect,
LocalOverlayController, fixture,
GlobalOverlayController, html,
DynamicOverlayController, aTimeout,
} from '@lion/overlays'; defineCE,
unsafeStatic,
nextFrame,
} from '@open-wc/testing';
import '@lion/option/lion-option.js';
import { OverlayController } from '@lion/overlays';
import './keyboardEventShimIE.js'; import './keyboardEventShimIE.js';
import '../lion-options.js'; import '../lion-options.js';
@ -49,6 +52,24 @@ describe('lion-select-rich', () => {
el.checkedIndex = 1; el.checkedIndex = 1;
expect(el._invokerNode.selectedElement).to.equal(el.querySelectorAll('lion-option')[1]); expect(el._invokerNode.selectedElement).to.equal(el.querySelectorAll('lion-option')[1]);
}); });
it('delegates readonly to the invoker, where disabled is added on top of this to disable opening', async () => {
const el = await fixture(html`
<lion-select-rich readonly>
<lion-options slot="input">
<lion-option .choiceValue=${10}>Item 1</lion-option>
<lion-option .choiceValue=${20}>Item 2</lion-option>
</lion-options>
</lion-select-rich>
`);
expect(el.hasAttribute('readonly')).to.be.true;
// rich select is not disabled, so value is still serialized in forms when readonly
expect(el.hasAttribute('disabled')).to.be.false;
expect(el._invokerNode.hasAttribute('readonly')).to.be.true;
// invoker node has disabled, to disable it from being clicked
expect(el._invokerNode.hasAttribute('disabled')).to.be.true;
});
}); });
describe('overlay', () => { describe('overlay', () => {
@ -69,16 +90,16 @@ describe('lion-select-rich', () => {
`); `);
el.opened = true; el.opened = true;
await el.updateComplete; await el.updateComplete;
expect(el._listboxNode.style.display).to.be.equal('inline-block'); expect(el._overlayCtrl.isShown).to.be.true;
el.opened = false; el.opened = false;
await el.updateComplete; await el.updateComplete;
expect(el._listboxNode.style.display).to.be.equal('none'); expect(el._overlayCtrl.isShown).to.be.false;
}); });
it('syncs opened state with overlay shown', async () => { it('syncs opened state with overlay shown', async () => {
const el = await fixture(html` const el = await fixture(html`
<lion-select-rich opened> <lion-select-rich .opened=${true}>
<lion-options slot="input"></lion-options> <lion-options slot="input"></lion-options>
</lion-select-rich> </lion-select-rich>
`); `);
@ -98,7 +119,7 @@ describe('lion-select-rich', () => {
<lion-options slot="input"></lion-options> <lion-options slot="input"></lion-options>
</lion-select-rich> </lion-select-rich>
`); `);
el.opened = true; await el._overlayCtrl.show();
await el.updateComplete; await el.updateComplete;
expect(document.activeElement === el._listboxNode).to.be.true; expect(document.activeElement === el._listboxNode).to.be.true;
expect(document.activeElement === el._invokerNode).to.be.false; expect(document.activeElement === el._invokerNode).to.be.false;
@ -118,7 +139,7 @@ describe('lion-select-rich', () => {
</lion-options> </lion-options>
</lion-select-rich> </lion-select-rich>
`); `);
el.opened = true; await el._overlayCtrl.show();
await el.updateComplete; await el.updateComplete;
const options = Array.from(el.querySelectorAll('lion-option')); const options = Array.from(el.querySelectorAll('lion-option'));
@ -194,6 +215,7 @@ describe('lion-select-rich', () => {
`); `);
expect(el.opened).to.be.false; expect(el.opened).to.be.false;
el._invokerNode.click(); el._invokerNode.click();
await nextFrame();
expect(el.opened).to.be.true; expect(el.opened).to.be.true;
}); });
@ -337,30 +359,20 @@ describe('lion-select-rich', () => {
}); });
describe('Subclassers', () => { describe('Subclassers', () => {
it('allows to override the type of overlays', async () => { it('allows to override the type of overlay', async () => {
const mySelectTagString = defineCE( const mySelectTagString = defineCE(
class MySelect extends LionSelectRich { class MySelect extends LionSelectRich {
_defineOverlay({ invokerNode, contentNode }) { _defineOverlay({ invokerNode, contentNode }) {
// add a DynamicOverlayController const ctrl = new OverlayController({
const dynamicCtrl = new DynamicOverlayController(); placementMode: 'global',
contentNode,
invokerNode,
});
const localCtrl = overlays.add( this.addEventListener('switch', () => {
new LocalOverlayController({ ctrl.updateConfig({ placementMode: 'local' });
contentNode, });
invokerNode, return ctrl;
}),
);
dynamicCtrl.add(localCtrl);
const globalCtrl = overlays.add(
new GlobalOverlayController({
contentNode,
invokerNode,
}),
);
dynamicCtrl.add(globalCtrl);
return dynamicCtrl;
} }
}, },
); );
@ -379,7 +391,9 @@ describe('lion-select-rich', () => {
</${mySelectTag}> </${mySelectTag}>
`); `);
expect(el.__overlay).to.be.instanceOf(DynamicOverlayController); expect(el._overlayCtrl.placementMode).to.equal('global');
el.dispatchEvent(new Event('switch'));
expect(el._overlayCtrl.placementMode).to.equal('local');
}); });
}); });
}); });

View file

@ -9,7 +9,7 @@ export class LionTooltip extends LionPopup {
connectedCallback() { connectedCallback() {
super.connectedCallback(); super.connectedCallback();
this.contentNode.setAttribute('role', 'tooltip'); this._overlayContentNode.setAttribute('role', 'tooltip');
this.__resetActive = () => { this.__resetActive = () => {
this.mouseActive = false; this.mouseActive = false;
@ -19,42 +19,42 @@ export class LionTooltip extends LionPopup {
this.__showMouse = () => { this.__showMouse = () => {
if (!this.keyActive) { if (!this.keyActive) {
this.mouseActive = true; this.mouseActive = true;
this._controller.show(); this._overlayCtrl.show();
} }
}; };
this.__hideMouse = () => { this.__hideMouse = () => {
if (!this.keyActive) { if (!this.keyActive) {
this._controller.hide(); this._overlayCtrl.hide();
} }
}; };
this.__showKey = () => { this.__showKey = () => {
if (!this.mouseActive) { if (!this.mouseActive) {
this.keyActive = true; this.keyActive = true;
this._controller.show(); this._overlayCtrl.show();
} }
}; };
this.__hideKey = () => { this.__hideKey = () => {
if (!this.mouseActive) { if (!this.mouseActive) {
this._controller.hide(); this._overlayCtrl.hide();
} }
}; };
this._controller.addEventListener('hide', this.__resetActive); this._overlayCtrl.addEventListener('hide', this.__resetActive);
this.addEventListener('mouseenter', this.__showMouse); this.addEventListener('mouseenter', this.__showMouse);
this.addEventListener('mouseleave', this.__hideMouse); this.addEventListener('mouseleave', this.__hideMouse);
this.invokerNode.addEventListener('focusin', this.__showKey); this._overlayInvokerNode.addEventListener('focusin', this.__showKey);
this.invokerNode.addEventListener('focusout', this.__hideKey); this._overlayInvokerNode.addEventListener('focusout', this.__hideKey);
} }
disconnectedCallback() { disconnectedCallback() {
super.disconnectedCallback(); super.disconnectedCallback();
this._controller.removeEventListener('hide', this.__resetActive); this._overlayCtrl.removeEventListener('hide', this.__resetActive);
this.removeEventListener('mouseenter', this.__showMouse); this.removeEventListener('mouseenter', this.__showMouse);
this.removeEventListener('mouseleave', this._hideMouse); this.removeEventListener('mouseleave', this._hideMouse);
this.invokerNode.removeEventListener('focusin', this._showKey); this._overlayInvokerNode.removeEventListener('focusin', this._showKey);
this.invokerNode.removeEventListener('focusout', this._hideKey); this._overlayInvokerNode.removeEventListener('focusout', this._hideKey);
} }
} }

View file

@ -123,7 +123,7 @@ storiesOf('Local Overlay System|Tooltip', module)
}, },
})}" })}"
> >
<lion-button slot="invoker">${text('Invoker text', 'Click me!')}</lion-button> <lion-button slot="invoker">${text('Invoker text', 'Hover me!')}</lion-button>
<div slot="content" class="tooltip">${text('Content text', 'Hello, World!')}</div> <div slot="content" class="tooltip">${text('Content text', 'Hello, World!')}</div>
</lion-tooltip> </lion-tooltip>
</div> </div>

View file

@ -11,7 +11,7 @@ describe('lion-tooltip', () => {
<lion-button slot="invoker">Tooltip button</lion-button> <lion-button slot="invoker">Tooltip button</lion-button>
</lion-tooltip> </lion-tooltip>
`); `);
expect(el.querySelector('[slot="content"]').style.display).to.be.equal('none'); expect(el._overlayCtrl.isShown).to.equal(false);
}); });
it('should show content on mouseenter and hide on mouseleave', async () => { it('should show content on mouseenter and hide on mouseleave', async () => {
@ -24,11 +24,11 @@ describe('lion-tooltip', () => {
const eventMouseEnter = new Event('mouseenter'); const eventMouseEnter = new Event('mouseenter');
el.dispatchEvent(eventMouseEnter); el.dispatchEvent(eventMouseEnter);
await el.updateComplete; await el.updateComplete;
expect(el.querySelector('[slot="content"]').style.display).to.be.equal('inline-block'); expect(el._overlayCtrl.isShown).to.equal(true);
const eventMouseLeave = new Event('mouseleave'); const eventMouseLeave = new Event('mouseleave');
el.dispatchEvent(eventMouseLeave); el.dispatchEvent(eventMouseLeave);
await el.updateComplete; await el.updateComplete;
expect(el.querySelector('[slot="content"]').style.display).to.be.equal('none'); expect(el._overlayCtrl.isShown).to.equal(false);
}); });
it('should show content on mouseenter and remain shown on focusout', async () => { it('should show content on mouseenter and remain shown on focusout', async () => {
@ -41,11 +41,11 @@ describe('lion-tooltip', () => {
const eventMouseEnter = new Event('mouseenter'); const eventMouseEnter = new Event('mouseenter');
el.dispatchEvent(eventMouseEnter); el.dispatchEvent(eventMouseEnter);
await el.updateComplete; await el.updateComplete;
expect(el.querySelector('[slot="content"]').style.display).to.be.equal('inline-block'); expect(el._overlayCtrl.isShown).to.equal(true);
const eventFocusOut = new Event('focusout'); const eventFocusOut = new Event('focusout');
el.dispatchEvent(eventFocusOut); el.dispatchEvent(eventFocusOut);
await el.updateComplete; await el.updateComplete;
expect(el.querySelector('[slot="content"]').style.display).to.be.equal('inline-block'); expect(el._overlayCtrl.isShown).to.equal(true);
}); });
it('should show content on focusin and hide on focusout', async () => { it('should show content on focusin and hide on focusout', async () => {
@ -59,11 +59,11 @@ describe('lion-tooltip', () => {
const eventFocusIn = new Event('focusin'); const eventFocusIn = new Event('focusin');
invoker.dispatchEvent(eventFocusIn); invoker.dispatchEvent(eventFocusIn);
await el.updateComplete; await el.updateComplete;
expect(el.querySelector('[slot="content"]').style.display).to.be.equal('inline-block'); expect(el._overlayCtrl.isShown).to.equal(true);
const eventFocusOut = new Event('focusout'); const eventFocusOut = new Event('focusout');
invoker.dispatchEvent(eventFocusOut); invoker.dispatchEvent(eventFocusOut);
await el.updateComplete; await el.updateComplete;
expect(el.querySelector('[slot="content"]').style.display).to.be.equal('none'); expect(el._overlayCtrl.isShown).to.equal(false);
}); });
it('should show content on focusin and remain shown on mouseleave', async () => { it('should show content on focusin and remain shown on mouseleave', async () => {
@ -77,11 +77,11 @@ describe('lion-tooltip', () => {
const eventFocusIn = new Event('focusin'); const eventFocusIn = new Event('focusin');
invoker.dispatchEvent(eventFocusIn); invoker.dispatchEvent(eventFocusIn);
await el.updateComplete; await el.updateComplete;
expect(el.querySelector('[slot="content"]').style.display).to.be.equal('inline-block'); expect(el._overlayCtrl.isShown).to.equal(true);
const eventMouseLeave = new Event('mouseleave'); const eventMouseLeave = new Event('mouseleave');
invoker.dispatchEvent(eventMouseLeave); invoker.dispatchEvent(eventMouseLeave);
await el.updateComplete; await el.updateComplete;
expect(el.querySelector('[slot="content"]').style.display).to.be.equal('inline-block'); expect(el._overlayCtrl.isShown).to.equal(true);
}); });
it('should tooltip contains html when specified in tooltip content body', async () => { it('should tooltip contains html when specified in tooltip content body', async () => {
@ -112,16 +112,5 @@ describe('lion-tooltip', () => {
const invoker = el.querySelector('[slot="content"]'); const invoker = el.querySelector('[slot="content"]');
expect(invoker.getAttribute('role')).to.be.equal('tooltip'); expect(invoker.getAttribute('role')).to.be.equal('tooltip');
}); });
it('should have aria-controls attribute set to the invoker', async () => {
const el = await fixture(html`
<lion-tooltip>
<div slot="content">Hey there</div>
<lion-button slot="invoker">Tooltip button</lion-button>
</lion-tooltip>
`);
const invoker = el.querySelector('[slot="invoker"]');
expect(invoker.getAttribute('aria-controls')).to.not.be.null;
});
}); });
}); });