Merge pull request #1745 from ing-bank/feature/accordion-focus-handling

fix: fixed focus handling, focus now jumps from header to panel when …
This commit is contained in:
Danny Moerkerke 2022-08-15 09:13:24 +02:00 committed by GitHub
commit da4652e2b1
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
4 changed files with 130 additions and 52 deletions

View file

@ -0,0 +1,5 @@
---
'@lion/accordion': minor
---
focus now jumps from header to panel when tabbing

View file

@ -28,7 +28,7 @@ class MyComponent extends ScopedElementsMixin(LitElement) {
decrease gradually during development, whereas volatile aroma compounds tend to peak in decrease gradually during development, whereas volatile aroma compounds tend to peak in
mid to lateseason development. Taste quality tends to improve later in harvests when mid to lateseason development. Taste quality tends to improve later in harvests when
there is a higher sugar/acid ratio with less bitterness. As a citrus fruit, the orange there is a higher sugar/acid ratio with less bitterness. As a citrus fruit, the orange
is acidic, with pH levels ranging from 2.9 to 4.0. is acidic, with pH levels ranging from 2.9 to 4.0. <a href="#">Link</a>
</p> </p>
<p> <p>
Sensory qualities vary according to genetic background, environmental conditions during Sensory qualities vary according to genetic background, environmental conditions during

View file

@ -43,21 +43,21 @@ export class LionAccordion extends LitElement {
flex-direction: column; flex-direction: column;
} }
.accordion ::slotted([slot='invoker']) { .accordion [slot='invoker'] {
margin: 0; margin: 0;
} }
.accordion ::slotted([slot='invoker'][expanded]) { .accordion [slot='invoker'][expanded] {
font-weight: bold; font-weight: bold;
} }
.accordion ::slotted([slot='content']) { .accordion [slot='content'] {
margin: 0; margin: 0;
visibility: hidden; visibility: hidden;
display: none; display: none;
} }
.accordion ::slotted([slot='content'][expanded]) { .accordion [slot='content'][expanded] {
visibility: visible; visibility: visible;
display: block; display: block;
} }
@ -129,6 +129,7 @@ export class LionAccordion extends LitElement {
<div class="accordion"> <div class="accordion">
<slot name="invoker"></slot> <slot name="invoker"></slot>
<slot name="content"></slot> <slot name="content"></slot>
<slot name="_accordion"></slot>
</div> </div>
`; `;
} }
@ -137,12 +138,16 @@ export class LionAccordion extends LitElement {
* @private * @private
*/ */
__setupSlots() { __setupSlots() {
const invokerSlot = this.shadowRoot?.querySelector('slot[name=invoker]'); const invokerSlot = /** @type {HTMLSlotElement} */ (
this.shadowRoot?.querySelector('slot[name=invoker]')
);
const handleSlotChange = () => { const handleSlotChange = () => {
if (invokerSlot.assignedNodes().length > 0) {
this.__cleanStore(); this.__cleanStore();
this.__setupStore(); this.__setupStore();
this.__updateFocused(); this.__updateFocused();
this.__updateExpanded(); this.__updateExpanded();
}
}; };
if (invokerSlot) { if (invokerSlot) {
invokerSlot.addEventListener('slotchange', handleSlotChange); invokerSlot.addEventListener('slotchange', handleSlotChange);
@ -153,12 +158,20 @@ export class LionAccordion extends LitElement {
* @private * @private
*/ */
__setupStore() { __setupStore() {
const invokers = /** @type {HTMLElement[]} */ ( const accordion = this.shadowRoot?.querySelector('slot[name=_accordion]');
Array.from(this.querySelectorAll('[slot="invoker"]')) const existingInvokers = accordion ? accordion.querySelectorAll('[slot=invoker]') : [];
); const existingContent = accordion ? accordion.querySelectorAll('[slot=content]') : [];
const contents = /** @type {HTMLElement[]} */ (
Array.from(this.querySelectorAll('[slot="content"]')) const invokers = /** @type {HTMLElement[]} */ ([
); ...Array.from(existingInvokers),
...Array.from(this.querySelectorAll('[slot="invoker"]')),
]);
const contents = /** @type {HTMLElement[]} */ ([
...Array.from(existingContent),
...Array.from(this.querySelectorAll('[slot="content"]')),
]);
if (invokers.length !== contents.length) { if (invokers.length !== contents.length) {
// eslint-disable-next-line no-console // eslint-disable-next-line no-console
console.warn( console.warn(
@ -184,6 +197,30 @@ export class LionAccordion extends LitElement {
this._collapse(entry); this._collapse(entry);
this.__store.push(entry); this.__store.push(entry);
}); });
this.__rearrangeInvokersAndContent();
}
/**
* @private
*
* Moves all invokers and content to slot[name=_accordion] in correct order so focus works
* correctly when the user tabs.
*/
__rearrangeInvokersAndContent() {
const invokers = /** @type {HTMLElement[]} */ (
Array.from(this.querySelectorAll('[slot="invoker"]'))
);
const contents = /** @type {HTMLElement[]} */ (
Array.from(this.querySelectorAll('[slot="content"]'))
);
const accordion = this.shadowRoot?.querySelector('slot[name=_accordion]');
if (accordion) {
invokers.forEach((invoker, index) => {
accordion.insertAdjacentElement('beforeend', invoker);
accordion.insertAdjacentElement('beforeend', contents[index]);
});
}
} }
/** /**

View file

@ -18,6 +18,33 @@ const basicAccordion = html`
</lion-accordion> </lion-accordion>
`; `;
/**
* @param {Element} el
*/
function getAccordionChildren(el) {
const slot = el.shadowRoot?.querySelector('slot[name=_accordion]');
return slot ? slot.children : [];
}
/**
* @param {Element} el
*/
function getInvokers(el) {
const slot = el.shadowRoot?.querySelector('slot[name=_accordion]');
return slot ? slot.querySelectorAll('[slot=invoker]') : [];
}
/**
* @param {Element} el
*/
function getContents(el) {
const slot = el.shadowRoot?.querySelector('slot[name=_accordion]');
return slot ? slot.querySelectorAll('[slot=content]') : [];
}
describe('<lion-accordion>', () => { describe('<lion-accordion>', () => {
describe('Accordion', () => { describe('Accordion', () => {
it('sets expanded to [] by default', async () => { it('sets expanded to [] by default', async () => {
@ -37,15 +64,16 @@ describe('<lion-accordion>', () => {
`) `)
); );
expect(el.expanded).to.deep.equal([1]); expect(el.expanded).to.deep.equal([1]);
expect( expect(
Array.from(el.children).find( Array.from(getAccordionChildren(el)).find(
child => child.slot === 'invoker' && child.hasAttribute('expanded'), child => child.slot === 'invoker' && child.hasAttribute('expanded'),
)?.textContent, )?.textContent,
).to.equal('invoker 2'); ).to.equal('invoker 2');
el.expanded = [0]; el.expanded = [0];
expect( expect(
Array.from(el.children).find( Array.from(getAccordionChildren(el)).find(
child => child.slot === 'invoker' && child.hasAttribute('expanded'), child => child.slot === 'invoker' && child.hasAttribute('expanded'),
)?.textContent, )?.textContent,
).to.equal('invoker 1'); ).to.equal('invoker 1');
@ -53,7 +81,7 @@ describe('<lion-accordion>', () => {
it('has [expanded] on current expanded invoker which serves as styling hook', async () => { it('has [expanded] on current expanded invoker which serves as styling hook', async () => {
const el = /** @type {LionAccordion} */ (await fixture(basicAccordion)); const el = /** @type {LionAccordion} */ (await fixture(basicAccordion));
const invokers = el.querySelectorAll('[slot=invoker]'); const invokers = getInvokers(el);
el.expanded = [0]; el.expanded = [0];
expect(invokers[0]).to.have.attribute('expanded'); expect(invokers[0]).to.have.attribute('expanded');
expect(invokers[1]).to.not.have.attribute('expanded'); expect(invokers[1]).to.not.have.attribute('expanded');
@ -65,7 +93,7 @@ describe('<lion-accordion>', () => {
it('has [expanded] on current expanded invoker first child which serves as styling hook', async () => { it('has [expanded] on current expanded invoker first child which serves as styling hook', async () => {
const el = /** @type {LionAccordion} */ (await fixture(basicAccordion)); const el = /** @type {LionAccordion} */ (await fixture(basicAccordion));
const invokers = el.querySelectorAll('[slot=invoker]'); const invokers = getInvokers(el);
el.expanded = [0]; el.expanded = [0];
expect(invokers[0].firstElementChild).to.have.attribute('expanded'); expect(invokers[0].firstElementChild).to.have.attribute('expanded');
expect(invokers[1].firstElementChild).to.not.have.attribute('expanded'); expect(invokers[1].firstElementChild).to.not.have.attribute('expanded');
@ -118,14 +146,14 @@ describe('<lion-accordion>', () => {
); );
expect(el.focusedIndex).to.equal(1); expect(el.focusedIndex).to.equal(1);
expect( expect(
Array.from(el.children).find( Array.from(getAccordionChildren(el)).find(
child => child.slot === 'invoker' && child.firstElementChild?.hasAttribute('focused'), child => child.slot === 'invoker' && child.firstElementChild?.hasAttribute('focused'),
)?.textContent, )?.textContent,
).to.equal('invoker 2'); ).to.equal('invoker 2');
el.focusedIndex = 0; el.focusedIndex = 0;
expect( expect(
Array.from(el.children).find( Array.from(getAccordionChildren(el)).find(
child => child.slot === 'invoker' && child.firstElementChild?.hasAttribute('focused'), child => child.slot === 'invoker' && child.firstElementChild?.hasAttribute('focused'),
)?.textContent, )?.textContent,
).to.equal('invoker 1'); ).to.equal('invoker 1');
@ -133,7 +161,7 @@ describe('<lion-accordion>', () => {
it('has [focused] on current focused invoker first child which serves as styling hook', async () => { it('has [focused] on current focused invoker first child which serves as styling hook', async () => {
const el = /** @type {LionAccordion} */ (await fixture(basicAccordion)); const el = /** @type {LionAccordion} */ (await fixture(basicAccordion));
const invokers = el.querySelectorAll('[slot=invoker]'); const invokers = getInvokers(el);
el.focusedIndex = 0; el.focusedIndex = 0;
expect(invokers[0]).to.not.have.attribute('focused'); expect(invokers[0]).to.not.have.attribute('focused');
expect(invokers[1]).to.not.have.attribute('focused'); expect(invokers[1]).to.not.have.attribute('focused');
@ -159,14 +187,19 @@ describe('<lion-accordion>', () => {
describe('Accordion Contents (slot=content)', () => { describe('Accordion Contents (slot=content)', () => {
it('are visible when corresponding invoker is expanded', async () => { it('are visible when corresponding invoker is expanded', async () => {
const el = /** @type {LionAccordion} */ (await fixture(basicAccordion)); const el = /** @type {LionAccordion} */ (await fixture(basicAccordion));
const contents = el.querySelectorAll('[slot=content]');
el.expanded = [0]; el.expanded = [0];
const contents = getContents(el);
setTimeout(() => {
expect(contents[0]).to.be.visible; expect(contents[0]).to.be.visible;
expect(contents[1]).to.be.not.visible; expect(contents[1]).to.be.not.visible;
el.expanded = [1]; el.expanded = [1];
expect(contents[0]).to.be.not.visible; expect(contents[0]).to.be.not.visible;
expect(contents[1]).to.be.visible; expect(contents[1]).to.be.visible;
}, 250);
}); });
it.skip('have a DOM structure that allows them to be animated ', async () => {}); it.skip('have a DOM structure that allows them to be animated ', async () => {});
@ -183,21 +216,21 @@ describe('<lion-accordion>', () => {
describe('User interaction', () => { describe('User interaction', () => {
it('opens a invoker on click', async () => { it('opens a invoker on click', async () => {
const el = /** @type {LionAccordion} */ (await fixture(basicAccordion)); const el = /** @type {LionAccordion} */ (await fixture(basicAccordion));
const invokers = el.querySelectorAll('[slot=invoker]'); const invokers = getInvokers(el);
invokers[1].firstElementChild?.dispatchEvent(new Event('click')); invokers[1].firstElementChild?.dispatchEvent(new Event('click'));
expect(el.expanded).to.deep.equal([1]); expect(el.expanded).to.deep.equal([1]);
}); });
it('selects a invoker on click', async () => { it('selects a invoker on click', async () => {
const el = /** @type {LionAccordion} */ (await fixture(basicAccordion)); const el = /** @type {LionAccordion} */ (await fixture(basicAccordion));
const invokers = el.querySelectorAll('[slot=invoker]'); const invokers = getInvokers(el);
invokers[1].firstElementChild?.dispatchEvent(new Event('click')); invokers[1].firstElementChild?.dispatchEvent(new Event('click'));
expect(el.focusedIndex).to.equal(1); expect(el.focusedIndex).to.equal(1);
}); });
it.skip('opens/close invoker on [enter] and [space]', async () => { it.skip('opens/close invoker on [enter] and [space]', async () => {
const el = /** @type {LionAccordion} */ (await fixture(basicAccordion)); const el = /** @type {LionAccordion} */ (await fixture(basicAccordion));
const invokers = el.querySelectorAll('[slot=invoker]'); const invokers = getInvokers(el);
invokers[0].firstElementChild?.dispatchEvent(new KeyboardEvent('keyup', { key: 'Enter' })); invokers[0].firstElementChild?.dispatchEvent(new KeyboardEvent('keyup', { key: 'Enter' }));
expect(el.expanded).to.deep.equal([0]); expect(el.expanded).to.deep.equal([0]);
invokers[0].firstElementChild?.dispatchEvent(new KeyboardEvent('keyup', { key: ' ' })); invokers[0].firstElementChild?.dispatchEvent(new KeyboardEvent('keyup', { key: ' ' }));
@ -206,7 +239,7 @@ describe('<lion-accordion>', () => {
it('selects next invoker on [arrow-right] and [arrow-down]', async () => { it('selects next invoker on [arrow-right] and [arrow-down]', async () => {
const el = /** @type {LionAccordion} */ (await fixture(basicAccordion)); const el = /** @type {LionAccordion} */ (await fixture(basicAccordion));
const invokers = el.querySelectorAll('[slot=invoker]'); const invokers = getInvokers(el);
el.focusedIndex = 0; el.focusedIndex = 0;
invokers[0].firstElementChild?.dispatchEvent( invokers[0].firstElementChild?.dispatchEvent(
new KeyboardEvent('keydown', { key: 'ArrowRight' }), new KeyboardEvent('keydown', { key: 'ArrowRight' }),
@ -231,7 +264,7 @@ describe('<lion-accordion>', () => {
</lion-accordion> </lion-accordion>
`) `)
); );
const invokers = el.querySelectorAll('[slot=invoker]'); const invokers = getInvokers(el);
el.focusedIndex = 2; el.focusedIndex = 2;
invokers[2].firstElementChild?.dispatchEvent( invokers[2].firstElementChild?.dispatchEvent(
new KeyboardEvent('keydown', { key: 'ArrowLeft' }), new KeyboardEvent('keydown', { key: 'ArrowLeft' }),
@ -254,14 +287,14 @@ describe('<lion-accordion>', () => {
</lion-accordion> </lion-accordion>
`) `)
); );
const invokers = el.querySelectorAll('[slot=invoker]'); const invokers = getInvokers(el);
invokers[1].firstElementChild?.dispatchEvent(new KeyboardEvent('keydown', { key: 'Home' })); invokers[1].firstElementChild?.dispatchEvent(new KeyboardEvent('keydown', { key: 'Home' }));
expect(el.focusedIndex).to.equal(0); expect(el.focusedIndex).to.equal(0);
}); });
it('selects last invoker on [end]', async () => { it('selects last invoker on [end]', async () => {
const el = /** @type {LionAccordion} */ (await fixture(basicAccordion)); const el = /** @type {LionAccordion} */ (await fixture(basicAccordion));
const invokers = el.querySelectorAll('[slot=invoker]'); const invokers = getInvokers(el);
invokers[0].firstElementChild?.dispatchEvent(new KeyboardEvent('keydown', { key: 'End' })); invokers[0].firstElementChild?.dispatchEvent(new KeyboardEvent('keydown', { key: 'End' }));
expect(el.focusedIndex).to.equal(2); expect(el.focusedIndex).to.equal(2);
}); });
@ -279,7 +312,7 @@ describe('<lion-accordion>', () => {
</lion-accordion> </lion-accordion>
`) `)
); );
const invokers = el.querySelectorAll('[slot=invoker]'); const invokers = getInvokers(el);
invokers[2].firstElementChild?.dispatchEvent( invokers[2].firstElementChild?.dispatchEvent(
new KeyboardEvent('keydown', { key: 'ArrowRight' }), new KeyboardEvent('keydown', { key: 'ArrowRight' }),
); );
@ -299,7 +332,7 @@ describe('<lion-accordion>', () => {
</lion-accordion> </lion-accordion>
`) `)
); );
const invokers = el.querySelectorAll('[slot=invoker]'); const invokers = getInvokers(el);
invokers[0].firstElementChild?.dispatchEvent( invokers[0].firstElementChild?.dispatchEvent(
new KeyboardEvent('keydown', { key: 'ArrowLeft' }), new KeyboardEvent('keydown', { key: 'ArrowLeft' }),
); );
@ -324,13 +357,14 @@ describe('<lion-accordion>', () => {
} }
el.expanded = [4]; el.expanded = [4];
await el.updateComplete; await el.updateComplete;
expect( expect(
Array.from(el.children).find( Array.from(getAccordionChildren(el)).find(
child => child.slot === 'invoker' && child.hasAttribute('expanded'), child => child.slot === 'invoker' && child.hasAttribute('expanded'),
)?.textContent, )?.textContent,
).to.equal('invoker 5'); ).to.equal('invoker 5');
expect( expect(
Array.from(el.children).find( Array.from(getAccordionChildren(el)).find(
child => child.slot === 'content' && child.hasAttribute('expanded'), child => child.slot === 'content' && child.hasAttribute('expanded'),
)?.textContent, )?.textContent,
).to.equal('content 5'); ).to.equal('content 5');
@ -375,12 +409,12 @@ describe('<lion-accordion>', () => {
<div slot="content">content 2</div> <div slot="content">content 2</div>
</lion-accordion> </lion-accordion>
`); `);
expect(Array.from(el.children).find(child => child.slot === 'content')).to.not.have.attribute( expect(
'tabindex', Array.from(getAccordionChildren(el)).find(child => child.slot === 'content'),
); ).to.not.have.attribute('tabindex');
expect(Array.from(el.children).find(child => child.slot === 'content')).to.not.have.attribute( expect(
'tabindex', Array.from(getAccordionChildren(el)).find(child => child.slot === 'content'),
); ).to.not.have.attribute('tabindex');
}); });
describe('Invokers', () => { describe('Invokers', () => {
@ -393,8 +427,8 @@ describe('<lion-accordion>', () => {
<div id="p2" slot="content">content 2</div> <div id="p2" slot="content">content 2</div>
</lion-accordion> </lion-accordion>
`); `);
const invokers = el.querySelectorAll('[slot=invoker]'); const invokers = getInvokers(el);
const contents = el.querySelectorAll('[slot=content]'); const contents = getContents(el);
expect(invokers[0].firstElementChild?.getAttribute('aria-controls')).to.equal( expect(invokers[0].firstElementChild?.getAttribute('aria-controls')).to.equal(
contents[0].id, contents[0].id,
); );
@ -411,7 +445,8 @@ describe('<lion-accordion>', () => {
</lion-accordion> </lion-accordion>
`); `);
expect( expect(
Array.from(el.children).find(child => child.slot === 'invoker')?.firstElementChild, Array.from(getAccordionChildren(el)).find(child => child.slot === 'invoker')
?.firstElementChild,
).to.have.attribute('aria-expanded', 'false'); ).to.have.attribute('aria-expanded', 'false');
}); });
@ -426,7 +461,8 @@ describe('<lion-accordion>', () => {
); );
el.expanded = [0]; el.expanded = [0];
expect( expect(
Array.from(el.children).find(child => child.slot === 'invoker')?.firstElementChild, Array.from(getAccordionChildren(el)).find(child => child.slot === 'invoker')
?.firstElementChild,
).to.have.attribute('aria-expanded', 'true'); ).to.have.attribute('aria-expanded', 'true');
}); });
}); });
@ -441,8 +477,8 @@ describe('<lion-accordion>', () => {
<div slot="content">content 2</div> <div slot="content">content 2</div>
</lion-accordion> </lion-accordion>
`); `);
const contents = el.querySelectorAll('[slot=content]'); const contents = getContents(el);
const invokers = el.querySelectorAll('[slot=invoker]'); const invokers = getInvokers(el);
expect(contents[0]).to.have.attribute('aria-labelledby', invokers[0].firstElementChild?.id); expect(contents[0]).to.have.attribute('aria-labelledby', invokers[0].firstElementChild?.id);
expect(contents[1]).to.have.attribute('aria-labelledby', invokers[1].firstElementChild?.id); expect(contents[1]).to.have.attribute('aria-labelledby', invokers[1].firstElementChild?.id);
}); });