diff --git a/.changeset/lucky-moose-count.md b/.changeset/lucky-moose-count.md new file mode 100644 index 000000000..89a2a57d5 --- /dev/null +++ b/.changeset/lucky-moose-count.md @@ -0,0 +1,5 @@ +--- +'@lion/accordion': minor +--- + +focus now jumps from header to panel when tabbing diff --git a/docs/components/accordion/overview.md b/docs/components/accordion/overview.md index e0f1c613e..26ba74897 100644 --- a/docs/components/accordion/overview.md +++ b/docs/components/accordion/overview.md @@ -28,7 +28,7 @@ class MyComponent extends ScopedElementsMixin(LitElement) { decrease gradually during development, whereas volatile aroma compounds tend to peak in mid– to late–season 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 - is acidic, with pH levels ranging from 2.9 to 4.0. + is acidic, with pH levels ranging from 2.9 to 4.0. Link

Sensory qualities vary according to genetic background, environmental conditions during diff --git a/packages/accordion/src/LionAccordion.js b/packages/accordion/src/LionAccordion.js index 5d762c0e8..66bdb0898 100644 --- a/packages/accordion/src/LionAccordion.js +++ b/packages/accordion/src/LionAccordion.js @@ -43,21 +43,21 @@ export class LionAccordion extends LitElement { flex-direction: column; } - .accordion ::slotted([slot='invoker']) { + .accordion [slot='invoker'] { margin: 0; } - .accordion ::slotted([slot='invoker'][expanded]) { + .accordion [slot='invoker'][expanded] { font-weight: bold; } - .accordion ::slotted([slot='content']) { + .accordion [slot='content'] { margin: 0; visibility: hidden; display: none; } - .accordion ::slotted([slot='content'][expanded]) { + .accordion [slot='content'][expanded] { visibility: visible; display: block; } @@ -129,6 +129,7 @@ export class LionAccordion extends LitElement {

+
`; } @@ -137,12 +138,16 @@ export class LionAccordion extends LitElement { * @private */ __setupSlots() { - const invokerSlot = this.shadowRoot?.querySelector('slot[name=invoker]'); + const invokerSlot = /** @type {HTMLSlotElement} */ ( + this.shadowRoot?.querySelector('slot[name=invoker]') + ); const handleSlotChange = () => { - this.__cleanStore(); - this.__setupStore(); - this.__updateFocused(); - this.__updateExpanded(); + if (invokerSlot.assignedNodes().length > 0) { + this.__cleanStore(); + this.__setupStore(); + this.__updateFocused(); + this.__updateExpanded(); + } }; if (invokerSlot) { invokerSlot.addEventListener('slotchange', handleSlotChange); @@ -153,12 +158,20 @@ export class LionAccordion extends LitElement { * @private */ __setupStore() { - 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]'); + const existingInvokers = accordion ? accordion.querySelectorAll('[slot=invoker]') : []; + const existingContent = accordion ? accordion.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) { // eslint-disable-next-line no-console console.warn( @@ -184,6 +197,30 @@ export class LionAccordion extends LitElement { this._collapse(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]); + }); + } } /** diff --git a/packages/accordion/test/lion-accordion.test.js b/packages/accordion/test/lion-accordion.test.js index 9ab22cf01..9a49ab724 100644 --- a/packages/accordion/test/lion-accordion.test.js +++ b/packages/accordion/test/lion-accordion.test.js @@ -18,6 +18,33 @@ const basicAccordion = html` `; +/** + * @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('', () => { describe('Accordion', () => { it('sets expanded to [] by default', async () => { @@ -37,15 +64,16 @@ describe('', () => { `) ); expect(el.expanded).to.deep.equal([1]); + expect( - Array.from(el.children).find( + Array.from(getAccordionChildren(el)).find( child => child.slot === 'invoker' && child.hasAttribute('expanded'), )?.textContent, ).to.equal('invoker 2'); el.expanded = [0]; expect( - Array.from(el.children).find( + Array.from(getAccordionChildren(el)).find( child => child.slot === 'invoker' && child.hasAttribute('expanded'), )?.textContent, ).to.equal('invoker 1'); @@ -53,7 +81,7 @@ describe('', () => { it('has [expanded] on current expanded invoker which serves as styling hook', async () => { const el = /** @type {LionAccordion} */ (await fixture(basicAccordion)); - const invokers = el.querySelectorAll('[slot=invoker]'); + const invokers = getInvokers(el); el.expanded = [0]; expect(invokers[0]).to.have.attribute('expanded'); expect(invokers[1]).to.not.have.attribute('expanded'); @@ -65,7 +93,7 @@ describe('', () => { it('has [expanded] on current expanded invoker first child which serves as styling hook', async () => { const el = /** @type {LionAccordion} */ (await fixture(basicAccordion)); - const invokers = el.querySelectorAll('[slot=invoker]'); + const invokers = getInvokers(el); el.expanded = [0]; expect(invokers[0].firstElementChild).to.have.attribute('expanded'); expect(invokers[1].firstElementChild).to.not.have.attribute('expanded'); @@ -118,14 +146,14 @@ describe('', () => { ); expect(el.focusedIndex).to.equal(1); expect( - Array.from(el.children).find( + Array.from(getAccordionChildren(el)).find( child => child.slot === 'invoker' && child.firstElementChild?.hasAttribute('focused'), )?.textContent, ).to.equal('invoker 2'); el.focusedIndex = 0; expect( - Array.from(el.children).find( + Array.from(getAccordionChildren(el)).find( child => child.slot === 'invoker' && child.firstElementChild?.hasAttribute('focused'), )?.textContent, ).to.equal('invoker 1'); @@ -133,7 +161,7 @@ describe('', () => { it('has [focused] on current focused invoker first child which serves as styling hook', async () => { const el = /** @type {LionAccordion} */ (await fixture(basicAccordion)); - const invokers = el.querySelectorAll('[slot=invoker]'); + const invokers = getInvokers(el); el.focusedIndex = 0; expect(invokers[0]).to.not.have.attribute('focused'); expect(invokers[1]).to.not.have.attribute('focused'); @@ -159,14 +187,19 @@ describe('', () => { describe('Accordion Contents (slot=content)', () => { it('are visible when corresponding invoker is expanded', async () => { const el = /** @type {LionAccordion} */ (await fixture(basicAccordion)); - const contents = el.querySelectorAll('[slot=content]'); el.expanded = [0]; - expect(contents[0]).to.be.visible; - expect(contents[1]).to.be.not.visible; - el.expanded = [1]; - expect(contents[0]).to.be.not.visible; - expect(contents[1]).to.be.visible; + const contents = getContents(el); + + setTimeout(() => { + expect(contents[0]).to.be.visible; + expect(contents[1]).to.be.not.visible; + + el.expanded = [1]; + + expect(contents[0]).to.be.not.visible; + expect(contents[1]).to.be.visible; + }, 250); }); it.skip('have a DOM structure that allows them to be animated ', async () => {}); @@ -183,21 +216,21 @@ describe('', () => { describe('User interaction', () => { it('opens a invoker on click', async () => { const el = /** @type {LionAccordion} */ (await fixture(basicAccordion)); - const invokers = el.querySelectorAll('[slot=invoker]'); + const invokers = getInvokers(el); invokers[1].firstElementChild?.dispatchEvent(new Event('click')); expect(el.expanded).to.deep.equal([1]); }); it('selects a invoker on click', async () => { const el = /** @type {LionAccordion} */ (await fixture(basicAccordion)); - const invokers = el.querySelectorAll('[slot=invoker]'); + const invokers = getInvokers(el); invokers[1].firstElementChild?.dispatchEvent(new Event('click')); expect(el.focusedIndex).to.equal(1); }); it.skip('opens/close invoker on [enter] and [space]', async () => { 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' })); expect(el.expanded).to.deep.equal([0]); invokers[0].firstElementChild?.dispatchEvent(new KeyboardEvent('keyup', { key: ' ' })); @@ -206,7 +239,7 @@ describe('', () => { it('selects next invoker on [arrow-right] and [arrow-down]', async () => { const el = /** @type {LionAccordion} */ (await fixture(basicAccordion)); - const invokers = el.querySelectorAll('[slot=invoker]'); + const invokers = getInvokers(el); el.focusedIndex = 0; invokers[0].firstElementChild?.dispatchEvent( new KeyboardEvent('keydown', { key: 'ArrowRight' }), @@ -231,7 +264,7 @@ describe('', () => { `) ); - const invokers = el.querySelectorAll('[slot=invoker]'); + const invokers = getInvokers(el); el.focusedIndex = 2; invokers[2].firstElementChild?.dispatchEvent( new KeyboardEvent('keydown', { key: 'ArrowLeft' }), @@ -254,14 +287,14 @@ describe('', () => { `) ); - const invokers = el.querySelectorAll('[slot=invoker]'); + const invokers = getInvokers(el); invokers[1].firstElementChild?.dispatchEvent(new KeyboardEvent('keydown', { key: 'Home' })); expect(el.focusedIndex).to.equal(0); }); it('selects last invoker on [end]', async () => { 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' })); expect(el.focusedIndex).to.equal(2); }); @@ -279,7 +312,7 @@ describe('', () => { `) ); - const invokers = el.querySelectorAll('[slot=invoker]'); + const invokers = getInvokers(el); invokers[2].firstElementChild?.dispatchEvent( new KeyboardEvent('keydown', { key: 'ArrowRight' }), ); @@ -299,7 +332,7 @@ describe('', () => { `) ); - const invokers = el.querySelectorAll('[slot=invoker]'); + const invokers = getInvokers(el); invokers[0].firstElementChild?.dispatchEvent( new KeyboardEvent('keydown', { key: 'ArrowLeft' }), ); @@ -324,13 +357,14 @@ describe('', () => { } el.expanded = [4]; await el.updateComplete; + expect( - Array.from(el.children).find( + Array.from(getAccordionChildren(el)).find( child => child.slot === 'invoker' && child.hasAttribute('expanded'), )?.textContent, ).to.equal('invoker 5'); expect( - Array.from(el.children).find( + Array.from(getAccordionChildren(el)).find( child => child.slot === 'content' && child.hasAttribute('expanded'), )?.textContent, ).to.equal('content 5'); @@ -375,12 +409,12 @@ describe('', () => {
content 2
`); - expect(Array.from(el.children).find(child => child.slot === 'content')).to.not.have.attribute( - 'tabindex', - ); - expect(Array.from(el.children).find(child => child.slot === 'content')).to.not.have.attribute( - 'tabindex', - ); + expect( + Array.from(getAccordionChildren(el)).find(child => child.slot === 'content'), + ).to.not.have.attribute('tabindex'); + expect( + Array.from(getAccordionChildren(el)).find(child => child.slot === 'content'), + ).to.not.have.attribute('tabindex'); }); describe('Invokers', () => { @@ -393,8 +427,8 @@ describe('', () => {
content 2
`); - const invokers = el.querySelectorAll('[slot=invoker]'); - const contents = el.querySelectorAll('[slot=content]'); + const invokers = getInvokers(el); + const contents = getContents(el); expect(invokers[0].firstElementChild?.getAttribute('aria-controls')).to.equal( contents[0].id, ); @@ -411,7 +445,8 @@ describe('', () => { `); 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'); }); @@ -426,7 +461,8 @@ describe('', () => { ); el.expanded = [0]; 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'); }); }); @@ -441,8 +477,8 @@ describe('', () => {
content 2
`); - const contents = el.querySelectorAll('[slot=content]'); - const invokers = el.querySelectorAll('[slot=invoker]'); + const contents = getContents(el); + const invokers = getInvokers(el); expect(contents[0]).to.have.attribute('aria-labelledby', invokers[0].firstElementChild?.id); expect(contents[1]).to.have.attribute('aria-labelledby', invokers[1].firstElementChild?.id); });