From cfcf45d553bc3ab8c3f5e8b8d59a9a2a22e4702e Mon Sep 17 00:00:00 2001 From: Nikki Massaro Kauffman Date: Tue, 18 Jul 2023 14:34:17 -0400 Subject: [PATCH 01/40] feat(core): added `ariaActivedescendant` to `internals-controller` --- core/pfe-core/controllers/internals-controller.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/core/pfe-core/controllers/internals-controller.ts b/core/pfe-core/controllers/internals-controller.ts index a7be52c2d3..c46eeacb08 100644 --- a/core/pfe-core/controllers/internals-controller.ts +++ b/core/pfe-core/controllers/internals-controller.ts @@ -8,6 +8,7 @@ export class InternalsController implements ReactiveController, ARIAMixin { declare role: ARIAMixin['role']; declare ariaAtomic: ARIAMixin['ariaAtomic']; declare ariaAutoComplete: ARIAMixin['ariaAutoComplete']; + declare ariaActivedescendant: string | null; declare ariaBusy: ARIAMixin['ariaBusy']; declare ariaChecked: ARIAMixin['ariaChecked']; declare ariaColCount: ARIAMixin['ariaColCount']; From 20be42c85993283c8bb1f5bb8bc5cda9bea571e9 Mon Sep 17 00:00:00 2001 From: Nikki Massaro Kauffman Date: Tue, 18 Jul 2023 14:36:07 -0400 Subject: [PATCH 02/40] feat(listbox): init pf-listbox, pf-listbox-group, and pf-listbox-option --- elements/package.json | 3 ++ elements/pf-listbox/README.md | 11 +++++ elements/pf-listbox/demo/demo.css | 3 ++ elements/pf-listbox/demo/pf-listbox.html | 28 +++++++++++++ elements/pf-listbox/demo/pf-listbox.js | 1 + elements/pf-listbox/docs/pf-listbox.md | 17 ++++++++ elements/pf-listbox/pf-listbox-group.css | 3 ++ elements/pf-listbox/pf-listbox-group.ts | 31 ++++++++++++++ elements/pf-listbox/pf-listbox-option.css | 3 ++ elements/pf-listbox/pf-listbox-option.ts | 41 +++++++++++++++++++ elements/pf-listbox/pf-listbox.css | 3 ++ elements/pf-listbox/pf-listbox.ts | 45 +++++++++++++++++++++ elements/pf-listbox/test/pf-listbox.e2e.ts | 12 ++++++ elements/pf-listbox/test/pf-listbox.spec.ts | 21 ++++++++++ 14 files changed, 222 insertions(+) create mode 100644 elements/pf-listbox/README.md create mode 100644 elements/pf-listbox/demo/demo.css create mode 100644 elements/pf-listbox/demo/pf-listbox.html create mode 100644 elements/pf-listbox/demo/pf-listbox.js create mode 100644 elements/pf-listbox/docs/pf-listbox.md create mode 100644 elements/pf-listbox/pf-listbox-group.css create mode 100644 elements/pf-listbox/pf-listbox-group.ts create mode 100644 elements/pf-listbox/pf-listbox-option.css create mode 100644 elements/pf-listbox/pf-listbox-option.ts create mode 100644 elements/pf-listbox/pf-listbox.css create mode 100644 elements/pf-listbox/pf-listbox.ts create mode 100644 elements/pf-listbox/test/pf-listbox.e2e.ts create mode 100644 elements/pf-listbox/test/pf-listbox.spec.ts diff --git a/elements/package.json b/elements/package.json index 6c6965fe06..68a7a1fca6 100644 --- a/elements/package.json +++ b/elements/package.json @@ -36,6 +36,9 @@ "./pf-jump-links/pf-jump-links.js": "./pf-jump-links/pf-jump-links.js", "./pf-label/BaseLabel.js": "./pf-label/BaseLabel.js", "./pf-label/pf-label.js": "./pf-label/pf-label.js", + "./pf-listbox/pf-listbox.js": "./pf-listbox/pf-listbox.js", + "./pf-listbox/pf-listbox-group.js": "./pf-listbox/pf-listbox-group.js", + "./pf-listbox/pf-listbox-option.js": "./pf-listbox/pf-listbox-option.js", "./pf-modal/pf-modal.js": "./pf-modal/pf-modal.js", "./pf-panel/pf-panel.js": "./pf-panel/pf-panel.js", "./pf-progress-stepper/pf-progress-step.js": "./pf-progress-stepper/pf-progress-step.js", diff --git a/elements/pf-listbox/README.md b/elements/pf-listbox/README.md new file mode 100644 index 0000000000..b09287b7d3 --- /dev/null +++ b/elements/pf-listbox/README.md @@ -0,0 +1,11 @@ +# Listbox +Add a description of the component here. + +## Usage +Describe how best to use this web component along with best practices. + +```html + + + +``` diff --git a/elements/pf-listbox/demo/demo.css b/elements/pf-listbox/demo/demo.css new file mode 100644 index 0000000000..8e786b884f --- /dev/null +++ b/elements/pf-listbox/demo/demo.css @@ -0,0 +1,3 @@ +pf-listbox { + /* insert demo styles */ +} diff --git a/elements/pf-listbox/demo/pf-listbox.html b/elements/pf-listbox/demo/pf-listbox.html new file mode 100644 index 0000000000..18af296489 --- /dev/null +++ b/elements/pf-listbox/demo/pf-listbox.html @@ -0,0 +1,28 @@ + + + + + Blue + Green + Magenta + Orange + Purple + Pink + Red + Yellow + + + + + Breakfast + Eggs + Toast + Waffles + + + Lunch + Salad + Sandwich + Soup + + diff --git a/elements/pf-listbox/demo/pf-listbox.js b/elements/pf-listbox/demo/pf-listbox.js new file mode 100644 index 0000000000..dbfb27a103 --- /dev/null +++ b/elements/pf-listbox/demo/pf-listbox.js @@ -0,0 +1 @@ +import '@patternfly/elements/pf-listbox/pf-listbox.js'; diff --git a/elements/pf-listbox/docs/pf-listbox.md b/elements/pf-listbox/docs/pf-listbox.md new file mode 100644 index 0000000000..f3745b57be --- /dev/null +++ b/elements/pf-listbox/docs/pf-listbox.md @@ -0,0 +1,17 @@ +{% renderOverview %} + +{% endrenderOverview %} + +{% band header="Usage" %}{% endband %} + +{% renderSlots %}{% endrenderSlots %} + +{% renderAttributes %}{% endrenderAttributes %} + +{% renderMethods %}{% endrenderMethods %} + +{% renderEvents %}{% endrenderEvents %} + +{% renderCssCustomProperties %}{% endrenderCssCustomProperties %} + +{% renderCssParts %}{% endrenderCssParts %} diff --git a/elements/pf-listbox/pf-listbox-group.css b/elements/pf-listbox/pf-listbox-group.css new file mode 100644 index 0000000000..5d4e87f30f --- /dev/null +++ b/elements/pf-listbox/pf-listbox-group.css @@ -0,0 +1,3 @@ +:host { + display: block; +} diff --git a/elements/pf-listbox/pf-listbox-group.ts b/elements/pf-listbox/pf-listbox-group.ts new file mode 100644 index 0000000000..14096e195e --- /dev/null +++ b/elements/pf-listbox/pf-listbox-group.ts @@ -0,0 +1,31 @@ +import { LitElement, html } from 'lit'; +import { customElement } from 'lit/decorators/custom-element.js'; +import { InternalsController } from '@patternfly/pfe-core/controllers/internals-controller.js'; + +import styles from './pf-listbox-group.css'; + +/** + * Group of options within a listbox + * @slot - Place element content here + */ +@customElement('pf-listbox-group') +export class PfListboxGroup extends LitElement { + static readonly styles = [styles]; + + #internals = new InternalsController(this, { + role: 'group' + }); + + render() { + return html` + + + `; + } +} + +declare global { + interface HTMLElementTagNameMap { + 'pf-listbox-group': PfListboxGroup; + } +} diff --git a/elements/pf-listbox/pf-listbox-option.css b/elements/pf-listbox/pf-listbox-option.css new file mode 100644 index 0000000000..5d4e87f30f --- /dev/null +++ b/elements/pf-listbox/pf-listbox-option.css @@ -0,0 +1,3 @@ +:host { + display: block; +} diff --git a/elements/pf-listbox/pf-listbox-option.ts b/elements/pf-listbox/pf-listbox-option.ts new file mode 100644 index 0000000000..5dba3859d7 --- /dev/null +++ b/elements/pf-listbox/pf-listbox-option.ts @@ -0,0 +1,41 @@ +import { LitElement, html } from 'lit'; +import { customElement } from 'lit/decorators/custom-element.js'; +import { InternalsController } from '@patternfly/pfe-core/controllers/internals-controller.js'; + +import styles from './pf-listbox-option.css'; + +/** + * Option within a listbox + * @slot - Place element content here + */ +@customElement('pf-listbox-option') +export class PfListboxOption extends LitElement { + static readonly styles = [styles]; + + #internals = new InternalsController(this, { + role: 'option' + }); + + updateSet(setSize?: number | null, posInSet?: number | null) { + if (!setSize || !posInSet) { + this.#internals.ariaSetSize = null; + this.#internals.ariaPosInSet = null; + } else { + const parsedSize = Math.max(-1, setSize); + this.#internals.ariaSetSize = `${parsedSize}`; + this.#internals.ariaPosInSet = `${Math.min(Math.max(1, posInSet), parsedSize)}`; + } + } + + render() { + return html` + + `; + } +} + +declare global { + interface HTMLElementTagNameMap { + 'pf-listbox-option': PfListboxOption; + } +} diff --git a/elements/pf-listbox/pf-listbox.css b/elements/pf-listbox/pf-listbox.css new file mode 100644 index 0000000000..5d4e87f30f --- /dev/null +++ b/elements/pf-listbox/pf-listbox.css @@ -0,0 +1,3 @@ +:host { + display: block; +} diff --git a/elements/pf-listbox/pf-listbox.ts b/elements/pf-listbox/pf-listbox.ts new file mode 100644 index 0000000000..0bb7e24e18 --- /dev/null +++ b/elements/pf-listbox/pf-listbox.ts @@ -0,0 +1,45 @@ +import { LitElement, html } from 'lit'; +import { customElement } from 'lit/decorators/custom-element.js'; +import { property } from 'lit/decorators/property.js'; +import { InternalsController } from '@patternfly/pfe-core/controllers/internals-controller.js'; +import { PfListboxOption } from './pf-listbox-option.js'; +import { PfListboxGroup } from './pf-listbox-group.js'; + +import styles from './pf-listbox.css'; + +/** + * List of selectable items + * @slot - Place element content here + */ +@customElement('pf-listbox') +export class PfListbox extends LitElement { + static readonly styles = [styles]; + + #internals = new InternalsController(this, { + role: 'listbox' + }); + + get isVertical(): boolean { + return this?.getAttribute('aria-orientatiom') === 'vertical'; + } + + get isMultiselectable(): boolean { + return this?.getAttribute('aria-multiselectable') === 'true'; + } + + #updateActiveDescendant(idRef: string | null) { + this.#internals.ariaActivedescendant = idRef; + } + + render() { + return html` + + `; + } +} + +declare global { + interface HTMLElementTagNameMap { + 'pf-listbox': PfListbox; + } +} diff --git a/elements/pf-listbox/test/pf-listbox.e2e.ts b/elements/pf-listbox/test/pf-listbox.e2e.ts new file mode 100644 index 0000000000..60afcec218 --- /dev/null +++ b/elements/pf-listbox/test/pf-listbox.e2e.ts @@ -0,0 +1,12 @@ +import { test } from '@playwright/test'; +import { PfeDemoPage } from '@patternfly/pfe-tools/test/playwright/PfeDemoPage.js'; + +const tagName = 'pf-listbox'; + +test.describe(tagName, () => { + test('snapshot', async ({ page }) => { + const componentPage = new PfeDemoPage(page, tagName); + await componentPage.navigate(); + await componentPage.snapshot(); + }); +}); diff --git a/elements/pf-listbox/test/pf-listbox.spec.ts b/elements/pf-listbox/test/pf-listbox.spec.ts new file mode 100644 index 0000000000..0601c9f1d8 --- /dev/null +++ b/elements/pf-listbox/test/pf-listbox.spec.ts @@ -0,0 +1,21 @@ +import { expect, html } from '@open-wc/testing'; +import { createFixture } from '@patternfly/pfe-tools/test/create-fixture.js'; +import { PfListbox } from '@patternfly/elements/pf-listbox/pf-listbox.js'; + +describe('', function() { + describe('simply instantiating', function() { + let element: PfListbox; + it('imperatively instantiates', function() { + expect(document.createElement('pf-listbox')).to.be.an.instanceof(PfListbox); + }); + + it('should upgrade', async function() { + element = await createFixture(html``); + const klass = customElements.get('pf-listbox'); + expect(element) + .to.be.an.instanceOf(klass) + .and + .to.be.an.instanceOf(PfListbox); + }); + }); +}); From 12f6c52f3b70752f6677453e7aa15e10a04c27a0 Mon Sep 17 00:00:00 2001 From: Nikki Massaro Kauffman Date: Tue, 18 Jul 2023 15:17:52 -0400 Subject: [PATCH 03/40] feat(listbox): init pf-listbox-item --- elements/pf-listbox/demo/pf-listbox.js | 2 ++ elements/pf-listbox/pf-listbox.ts | 23 ++++++++++++++++++++++- 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/elements/pf-listbox/demo/pf-listbox.js b/elements/pf-listbox/demo/pf-listbox.js index dbfb27a103..462f34e98c 100644 --- a/elements/pf-listbox/demo/pf-listbox.js +++ b/elements/pf-listbox/demo/pf-listbox.js @@ -1 +1,3 @@ import '@patternfly/elements/pf-listbox/pf-listbox.js'; +import '@patternfly/elements/pf-listbox/pf-listbox-item.js'; +import '@patternfly/elements/pf-listbox/pf-listbox-group.js'; diff --git a/elements/pf-listbox/pf-listbox.ts b/elements/pf-listbox/pf-listbox.ts index 0bb7e24e18..34f1166f4c 100644 --- a/elements/pf-listbox/pf-listbox.ts +++ b/elements/pf-listbox/pf-listbox.ts @@ -1,6 +1,7 @@ import { LitElement, html } from 'lit'; import { customElement } from 'lit/decorators/custom-element.js'; import { property } from 'lit/decorators/property.js'; +import { queryAssignedElements } from 'lit/decorators/query-assigned-elements.js'; import { InternalsController } from '@patternfly/pfe-core/controllers/internals-controller.js'; import { PfListboxOption } from './pf-listbox-option.js'; import { PfListboxGroup } from './pf-listbox-group.js'; @@ -15,10 +16,18 @@ import styles from './pf-listbox.css'; export class PfListbox extends LitElement { static readonly styles = [styles]; + static isOption(element: PfListboxOption): element is PfListboxOption { + return element instanceof PfListboxOption; + } + + @queryAssignedElements() private options!: PfListboxOption[]; + #internals = new InternalsController(this, { role: 'listbox' }); + #_allOptions: PfListboxOption[] = []; + get isVertical(): boolean { return this?.getAttribute('aria-orientatiom') === 'vertical'; } @@ -27,13 +36,25 @@ export class PfListbox extends LitElement { return this?.getAttribute('aria-multiselectable') === 'true'; } + get #allOptions() { + return this.#_allOptions; + } + + set #allOptions(options: PfListboxOption[]) { + this.#_allOptions = options.filter(options => (this.constructor as typeof PfListbox).isOption(options)); + } + + #onSlotchange() { + this.#_allOptions = this.options; + } + #updateActiveDescendant(idRef: string | null) { this.#internals.ariaActivedescendant = idRef; } render() { return html` - + `; } } From 02e352bdd50a8c83ff792d9803ff7c9aa67e3e3c Mon Sep 17 00:00:00 2001 From: Nikki Massaro Kauffman Date: Wed, 19 Jul 2023 09:27:24 -0400 Subject: [PATCH 04/40] feat(core): added orientation to arrow key handling --- .../controllers/roving-tabindex-controller.ts | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/core/pfe-core/controllers/roving-tabindex-controller.ts b/core/pfe-core/controllers/roving-tabindex-controller.ts index e4a2ca53f1..f456b62256 100644 --- a/core/pfe-core/controllers/roving-tabindex-controller.ts +++ b/core/pfe-core/controllers/roving-tabindex-controller.ts @@ -101,20 +101,31 @@ export class RovingTabindexController< this.#focusableItems.includes(x as ItemType))) { return; } + + const role = this.host.getAttribute('role'); + const orientation = this.host.getAttribute('aria-orientation'); + const item = this.activeItem; let shouldPreventDefault = false; const horizontalOnly = !item ? false : item.tagName === 'SELECT' || - item.getAttribute('role') === 'spinbutton'; + item.getAttribute('role') === 'spinbutton' || orientation === 'horizontal'; + const verticalOnly = orientation === 'vertical'; switch (event.key) { case 'ArrowLeft': + if (verticalOnly) { + return; + } this.focusOnItem(this.prevItem); shouldPreventDefault = true; break; case 'ArrowRight': + if (verticalOnly) { + return; + } this.focusOnItem(this.nextItem); shouldPreventDefault = true; break; From 9b8cdafd584bb84570145ee28ad78fe86eb06806 Mon Sep 17 00:00:00 2001 From: Nikki Massaro Kauffman Date: Wed, 19 Jul 2023 09:37:25 -0400 Subject: [PATCH 05/40] fix(core): removed pageUp and pageDown key handlers because default behavior is more accessible --- .../controllers/roving-tabindex-controller.ts | 14 -------------- 1 file changed, 14 deletions(-) diff --git a/core/pfe-core/controllers/roving-tabindex-controller.ts b/core/pfe-core/controllers/roving-tabindex-controller.ts index f456b62256..112c2c475e 100644 --- a/core/pfe-core/controllers/roving-tabindex-controller.ts +++ b/core/pfe-core/controllers/roving-tabindex-controller.ts @@ -147,24 +147,10 @@ export class RovingTabindexController< this.focusOnItem(this.firstItem); shouldPreventDefault = true; break; - case 'PageUp': - if (horizontalOnly) { - return; - } - this.focusOnItem(this.firstItem); - shouldPreventDefault = true; - break; case 'End': this.focusOnItem(this.lastItem); shouldPreventDefault = true; break; - case 'PageDown': - if (horizontalOnly) { - return; - } - this.focusOnItem(this.lastItem); - shouldPreventDefault = true; - break; default: break; } From d6a46cc5f4eb4d0401ccd76e8563d6279dfd7aa5 Mon Sep 17 00:00:00 2001 From: Nikki Massaro Kauffman Date: Thu, 20 Jul 2023 09:06:26 -0400 Subject: [PATCH 06/40] feat(core): fixed roving-tabindex-controller's `updateActiveItem` so it only happens if item changes --- core/pfe-core/controllers/roving-tabindex-controller.ts | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/core/pfe-core/controllers/roving-tabindex-controller.ts b/core/pfe-core/controllers/roving-tabindex-controller.ts index 112c2c475e..5339f3a6fb 100644 --- a/core/pfe-core/controllers/roving-tabindex-controller.ts +++ b/core/pfe-core/controllers/roving-tabindex-controller.ts @@ -102,7 +102,6 @@ export class RovingTabindexController< return; } - const role = this.host.getAttribute('role'); const orientation = this.host.getAttribute('aria-orientation'); const item = this.activeItem; @@ -112,8 +111,6 @@ export class RovingTabindexController< : item.tagName === 'SELECT' || item.getAttribute('role') === 'spinbutton' || orientation === 'horizontal'; const verticalOnly = orientation === 'vertical'; - - switch (event.key) { case 'ArrowLeft': if (verticalOnly) { @@ -165,8 +162,8 @@ export class RovingTabindexController< * sets tabindex of item based on whether or not it is active */ updateActiveItem(item?: ItemType): void { - if (item) { - if (!!this.#activeItem && item !== this.#activeItem) { + if (item && item !== this.#activeItem) { + if (this.#activeItem) { this.#activeItem.tabIndex = -1; } item.tabIndex = 0; From c647911184ebd06eeb9194dc316214d2c5440c0e Mon Sep 17 00:00:00 2001 From: Nikki Massaro Kauffman Date: Thu, 20 Jul 2023 09:08:24 -0400 Subject: [PATCH 07/40] feat(elements): added listbox-optiontemporary styles for filtered and selected items --- elements/pf-listbox/pf-listbox-option.css | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/elements/pf-listbox/pf-listbox-option.css b/elements/pf-listbox/pf-listbox-option.css index 5d4e87f30f..3357654601 100644 --- a/elements/pf-listbox/pf-listbox-option.css +++ b/elements/pf-listbox/pf-listbox-option.css @@ -1,3 +1,12 @@ :host { display: block; } + +:host([hidden]), +:host([hidden-by-filter]) { + display: none !important; +} + +:host([aria-selected="true"]) { + background-color: #ddd; +} \ No newline at end of file From b95ba6787e9fcf3d381d92f3f40bed40e6305f71 Mon Sep 17 00:00:00 2001 From: Nikki Massaro Kauffman Date: Thu, 20 Jul 2023 09:09:03 -0400 Subject: [PATCH 08/40] feat(elements): added filtering to listbox-option --- elements/pf-listbox/pf-listbox-option.ts | 29 ++++++++++++++++++++---- 1 file changed, 25 insertions(+), 4 deletions(-) diff --git a/elements/pf-listbox/pf-listbox-option.ts b/elements/pf-listbox/pf-listbox-option.ts index 5dba3859d7..97084be3ea 100644 --- a/elements/pf-listbox/pf-listbox-option.ts +++ b/elements/pf-listbox/pf-listbox-option.ts @@ -1,6 +1,7 @@ import { LitElement, html } from 'lit'; import { customElement } from 'lit/decorators/custom-element.js'; import { InternalsController } from '@patternfly/pfe-core/controllers/internals-controller.js'; +import { getRandomId } from '@patternfly/pfe-core/functions/random.js'; import styles from './pf-listbox-option.css'; @@ -16,6 +17,27 @@ export class PfListboxOption extends LitElement { role: 'option' }); + override connectedCallback() { + super.connectedCallback(); + this.addEventListener('focus', this.#onFocus); + this.id = this.id || getRandomId(); + } + + getUpdateByFilter(filter = '') { + if (filter === '' || (this.textContent || '').toLowerCase().match(filter)) { + this.removeAttribute('hidden-by-filter'); + } else { + this.setAttribute('hidden-by-filter', 'hidden-by-filter'); + } + return !this.hasAttribute('hidden-by-filter'); + } + + render() { + return html` + + `; + } + updateSet(setSize?: number | null, posInSet?: number | null) { if (!setSize || !posInSet) { this.#internals.ariaSetSize = null; @@ -27,10 +49,9 @@ export class PfListboxOption extends LitElement { } } - render() { - return html` - - `; + #onFocus() { + // console.log('focus'); + // this.dispatchEvent(new Event('focus', { bubbles: true })); } } From dc739c7fa56641b1e75afbf26fe3d46aad015672 Mon Sep 17 00:00:00 2001 From: Nikki Massaro Kauffman Date: Thu, 20 Jul 2023 09:09:33 -0400 Subject: [PATCH 09/40] feat(elements): added temporary orientation styles to listbox --- elements/pf-listbox/pf-listbox.css | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/elements/pf-listbox/pf-listbox.css b/elements/pf-listbox/pf-listbox.css index 5d4e87f30f..b4741c0e42 100644 --- a/elements/pf-listbox/pf-listbox.css +++ b/elements/pf-listbox/pf-listbox.css @@ -1,3 +1,16 @@ :host { display: block; } + +slot { + display: flex; + flex-direction: column; +} + +:host([aria-orientation="horizontal"]) slot { + flex-direction: row; +} + +::slotted(*) { + margin: 2px 10px; +} From 1c0cdd6ca5ec4ef333ea6a0f609b679a1cf13399 Mon Sep 17 00:00:00 2001 From: Nikki Massaro Kauffman Date: Thu, 20 Jul 2023 09:10:10 -0400 Subject: [PATCH 10/40] feat(elements): added more variations to demo --- elements/pf-listbox/demo/pf-listbox.html | 65 +++++++++++++++++------- elements/pf-listbox/demo/pf-listbox.js | 2 - 2 files changed, 46 insertions(+), 21 deletions(-) diff --git a/elements/pf-listbox/demo/pf-listbox.html b/elements/pf-listbox/demo/pf-listbox.html index 18af296489..5fb1a76404 100644 --- a/elements/pf-listbox/demo/pf-listbox.html +++ b/elements/pf-listbox/demo/pf-listbox.html @@ -1,28 +1,55 @@ - - Blue - Green - Magenta - Orange - Purple - Pink - Red - Yellow +

aria-orientation="horizontal"

+ + Blue + Green + Magenta + Orange + Purple + Pink + Red + Yellow +
+

aria-orientation="vertical"

+ + Blue + Green + Magenta + Orange + Purple + Pink + Red + Yellow + +
+

aria-multiselectable="true"

+ + Blue + Green + Magenta + Orange + Purple + Pink + Red + Yellow + +
+

pf-listbox-group

- + Breakfast - Eggs - Toast - Waffles - - + Eggs + Toast + Waffles + + Lunch - Salad - Sandwich - Soup - + Salad + Sandwich + Soup + diff --git a/elements/pf-listbox/demo/pf-listbox.js b/elements/pf-listbox/demo/pf-listbox.js index 462f34e98c..dbfb27a103 100644 --- a/elements/pf-listbox/demo/pf-listbox.js +++ b/elements/pf-listbox/demo/pf-listbox.js @@ -1,3 +1 @@ import '@patternfly/elements/pf-listbox/pf-listbox.js'; -import '@patternfly/elements/pf-listbox/pf-listbox-item.js'; -import '@patternfly/elements/pf-listbox/pf-listbox-group.js'; From fc55e07f5ef638123c5715b43b726cd261bc1897 Mon Sep 17 00:00:00 2001 From: Nikki Massaro Kauffman Date: Thu, 20 Jul 2023 09:14:48 -0400 Subject: [PATCH 11/40] feat(elements): added roving-tabindex-controller and filtering to listbox --- elements/pf-listbox/pf-listbox.ts | 236 ++++++++++++++++++++++++++++-- 1 file changed, 227 insertions(+), 9 deletions(-) diff --git a/elements/pf-listbox/pf-listbox.ts b/elements/pf-listbox/pf-listbox.ts index 34f1166f4c..c03327b082 100644 --- a/elements/pf-listbox/pf-listbox.ts +++ b/elements/pf-listbox/pf-listbox.ts @@ -1,15 +1,18 @@ import { LitElement, html } from 'lit'; +import type { PropertyValues } from 'lit'; import { customElement } from 'lit/decorators/custom-element.js'; import { property } from 'lit/decorators/property.js'; import { queryAssignedElements } from 'lit/decorators/query-assigned-elements.js'; import { InternalsController } from '@patternfly/pfe-core/controllers/internals-controller.js'; +import { RovingTabindexController } from '@patternfly/pfe-core/controllers/roving-tabindex-controller.js'; import { PfListboxOption } from './pf-listbox-option.js'; -import { PfListboxGroup } from './pf-listbox-group.js'; +import './pf-listbox-group.js'; import styles from './pf-listbox.css'; /** * List of selectable items + * @see {@link https://www.w3.org/WAI/ARIA/apg/patterns/listbox/|WAI-ARIA Listbox Pattern} * @slot - Place element content here */ @customElement('pf-listbox') @@ -20,43 +23,258 @@ export class PfListbox extends LitElement { return element instanceof PfListboxOption; } + @property() filter = ''; + @property({ reflect: true, attribute: 'disable-filter' }) disableFilter = ''; + @queryAssignedElements() private options!: PfListboxOption[]; #internals = new InternalsController(this, { role: 'listbox' }); + #tabindex = new RovingTabindexController(this); + #_allOptions: PfListboxOption[] = []; - get isVertical(): boolean { - return this?.getAttribute('aria-orientatiom') === 'vertical'; + #hasFocus = false; + + get isHorizontal(): boolean { + return this?.getAttribute('aria-orientation') === 'horizontal'; } get isMultiselectable(): boolean { return this?.getAttribute('aria-multiselectable') === 'true'; } + get activeItem() { + const [active] = this.#allOptions.filter(option => option.getAttribute('id') === this.#internals.ariaActivedescendant); + return active || this.#tabindex.firstItem; + } + get #allOptions() { return this.#_allOptions; } set #allOptions(options: PfListboxOption[]) { - this.#_allOptions = options.filter(options => (this.constructor as typeof PfListbox).isOption(options)); + this.#_allOptions = options.filter(option => (this.constructor as typeof PfListbox).isOption(option) && (this.disableFilter || option.getUpdateByFilter(this.filter))); } - #onSlotchange() { - this.#_allOptions = this.options; + set value(items: string | null) { + const oldValue = this.value; + const selectedItems = items?.toLowerCase().split(','); + const [firstItem] = selectedItems || [null]; + this.options.forEach(option => { + const textContent = (option.textContent || '').toLowerCase(); + const selected = this.isMultiselectable ? selectedItems?.includes(textContent) : firstItem === textContent; + if (selected) { + option.setAttribute('aria-selected', 'true'); + } else { + option.removeAttribute('aria-selected'); + } + }); + if (oldValue !== this.value) { + this.#fireInput(); + } + } + + get value() { + const selectedItems = this.options.filter(option=>option.hasAttribute('aria-selected')).map(option => option.textContent); + return selectedItems.join(','); + } + + override connectedCallback() { + super.connectedCallback(); + this.addEventListener('focus', this.#onOptionFocus); } - #updateActiveDescendant(idRef: string | null) { - this.#internals.ariaActivedescendant = idRef; + updated(changed: PropertyValues) { + if (changed.has('filter')) { + this.#onFilterChange(); + } } render() { return html` - + + `; } + + /** + * filters listbox by keboard event when slotted option has focus, + * or by external element such as a text field + * @param event + * @returns { void } + */ + filterByKeyboardEvent(event: KeyboardEvent) { + const target = event.target as PfListboxOption; + let stopEvent = false; + if (event.ctrlKey || + event.altKey || + event.metaKey || + !event.composedPath().some(x => + this.options.includes(x as PfListboxOption))) { + this.filter = ''; + return; + } + + switch (event.key) { + case 'Backspace': + case 'Delete': + this.filter = this.filter.slice(0, this.filter.length - 2); + stopEvent = true; + break; + case event.key?.match(/^[\w]$/)?.input: + this.filter += event.key?.toLowerCase(); + stopEvent = true; + break; + case 'Enter': + case ' ': + this.#updateMultiselect(target); + stopEvent = true; + break; + default: + break; + } + if (stopEvent) { + event.stopPropagation(); + event.preventDefault(); + } + } + + focus() { + this.#tabindex.focusOnItem(this.activeItem); + } + + #updateActiveDescendant() { + const [desc] = this.#allOptions.filter(option => option === this.#tabindex.activeItem); + this.#internals.ariaActivedescendant = desc?.id; + this.#updateSingleselect(); + // TODO: set this.#value and fire events + } + + #updateSingleselect() { + if (!this.isMultiselectable) { + this.options.forEach(option => { + if (option === this.#tabindex.activeItem) { + option.setAttribute('aria-selected', 'true'); + } else { + option.removeAttribute('aria-selected'); + } + }); + this.#fireChange(); + } + } + + #updateMultiselect(option: PfListboxOption) { + if (this.isMultiselectable) { + if (!option.hasAttribute('aria-selected')) { + option.setAttribute('aria-selected', 'true'); + } else { + option.removeAttribute('aria-selected'); + } + this.#fireChange(); + } + } + + #onFilterChange() { + const oldValue = this.value; + this.#allOptions = this.options; + const all = this.#allOptions; + this.#tabindex.updateItems(all); + this.#updateActiveDescendant(); + if (oldValue !== this.value) { + this.#fireInput(); + } + } + + /** + * handles user user selection change similar to HTMLSelectElement events + * (@see {@link https://developer.mozilla.org/en-US/docs/Web/API/HTMLSelectElement#events|MDN: HTMLSelectElement Events}) + * @fires change + */ + #fireChange() { + this.dispatchEvent(new Event('change', { bubbles: true })); + } + + /** + * handles element value change similar to HTMLSelectElement events + * (@see {@link https://developer.mozilla.org/en-US/docs/Web/API/HTMLSelectElement#events|MDN: HTMLSelectElement Events}) + * @fires change + */ + #fireInput() { + this.dispatchEvent(new Event('input', { bubbles: true })); + } + + #onOptionBlur() { + this.#hasFocus = false; + } + + #onOptionFocus(event: FocusEvent) { + this.#hasFocus = true; + const target = event.target as PfListboxOption; + if (target !== this.#tabindex.activeItem) { + this.#tabindex.updateActiveItem(target); + } + this.#updateActiveDescendant(); + } + + #onOptionClick(event: MouseEvent) { + const target = event.target as PfListboxOption; + const sel = (option: PfListboxOption, selected = false) => { + if (selected) { + option.setAttribute('aria-selected', 'true'); + } else { + option.removeAttribute('aria-selected'); + } + }; + if (this.isMultiselectable) { + if (!event.shiftKey) { + // toggle target + this.#updateMultiselect(event.target); + } else { + // select all options between active descendant and target + const [start, end] = [this.options.indexOf(this.activeItem), this.options.indexOf(target)].sort(); + const options = [...this.options].slice(start, end + 1); + options.forEach(option=>sel(option, true)); + } + } else { + // select target and deselect all other options + this.options.forEach(option => sel(option, option === target)); + } + if (target !== this.#tabindex.activeItem) { + this.#tabindex.focusOnItem(target); + this.#updateActiveDescendant(); + this.#fireChange(); + } + } + + #onOptionKeydown(event: KeyboardEvent) { + const { filter } = this; + this.filterByKeyboardEvent(event); + // only change focus if keydown occurred when option has focus + // (as opposed to an external text input and if filter has changed + if (filter !== this.filter) { + this.#tabindex.focusOnItem(this.activeItem); + } + } + + #onSlotchange() { + this.#allOptions = this.options; + const all = this.#allOptions; + const setSize = this.options.length; + this.options.forEach((option, i) => { + option.setAttribute('aria-setsize', `${setSize}`); + option.setAttribute('aria-posinset', `${i + 1}`); + }); + this.#tabindex.initItems(all, this); + } } declare global { From 13cc4f92933f4382a2f700143862bdae80da3569 Mon Sep 17 00:00:00 2001 From: Nikki Massaro Kauffman Date: Thu, 20 Jul 2023 10:07:00 -0400 Subject: [PATCH 12/40] feat(elements): added temporary aria-selected styles to listbox-option --- elements/pf-listbox/pf-listbox-option.css | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/elements/pf-listbox/pf-listbox-option.css b/elements/pf-listbox/pf-listbox-option.css index 3357654601..047c545b09 100644 --- a/elements/pf-listbox/pf-listbox-option.css +++ b/elements/pf-listbox/pf-listbox-option.css @@ -7,6 +7,11 @@ display: none !important; } -:host([aria-selected="true"]) { - background-color: #ddd; +:host:before { + content: '\2713 '; + opacity: 0; +} + +:host([aria-selected="true"]):before { + opacity: 1; } \ No newline at end of file From 51944a012d0a446ea09eb64209ba0501ecab443e Mon Sep 17 00:00:00 2001 From: Nikki Massaro Kauffman Date: Thu, 20 Jul 2023 10:07:43 -0400 Subject: [PATCH 13/40] feat(elements): added focus and blur event handlers for listbox-option --- elements/pf-listbox/pf-listbox-option.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/elements/pf-listbox/pf-listbox-option.ts b/elements/pf-listbox/pf-listbox-option.ts index 97084be3ea..50b524ad89 100644 --- a/elements/pf-listbox/pf-listbox-option.ts +++ b/elements/pf-listbox/pf-listbox-option.ts @@ -20,6 +20,7 @@ export class PfListboxOption extends LitElement { override connectedCallback() { super.connectedCallback(); this.addEventListener('focus', this.#onFocus); + this.addEventListener('blur', this.#onBlur); this.id = this.id || getRandomId(); } @@ -50,8 +51,11 @@ export class PfListboxOption extends LitElement { } #onFocus() { - // console.log('focus'); - // this.dispatchEvent(new Event('focus', { bubbles: true })); + this.dispatchEvent(new Event('optionfocus', { bubbles: true })); + } + + #onBlur() { + this.dispatchEvent(new Event('optionblur', { bubbles: true })); } } From d3530bdf57a9dcfee7f55186a2625f517af165c0 Mon Sep 17 00:00:00 2001 From: Nikki Massaro Kauffman Date: Thu, 20 Jul 2023 10:08:36 -0400 Subject: [PATCH 14/40] feat(elements): added temporary active descendant syling when listbox has focus --- elements/pf-listbox/pf-listbox.css | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/elements/pf-listbox/pf-listbox.css b/elements/pf-listbox/pf-listbox.css index b4741c0e42..4d990a56b6 100644 --- a/elements/pf-listbox/pf-listbox.css +++ b/elements/pf-listbox/pf-listbox.css @@ -14,3 +14,7 @@ slot { ::slotted(*) { margin: 2px 10px; } + +:host(:focus-within) ::slotted([active-descendant]) { + background-color: #e8f7ff; +} From 471a6c9a08485d8fe818686c7c3de720b3abdd52 Mon Sep 17 00:00:00 2001 From: Nikki Massaro Kauffman Date: Thu, 20 Jul 2023 10:11:33 -0400 Subject: [PATCH 15/40] feat(elements): change and input events, and updated filter and active descndant logic --- elements/pf-listbox/pf-listbox.ts | 51 +++++++++++++++++-------------- 1 file changed, 28 insertions(+), 23 deletions(-) diff --git a/elements/pf-listbox/pf-listbox.ts b/elements/pf-listbox/pf-listbox.ts index c03327b082..c56a576a82 100644 --- a/elements/pf-listbox/pf-listbox.ts +++ b/elements/pf-listbox/pf-listbox.ts @@ -82,11 +82,6 @@ export class PfListbox extends LitElement { return selectedItems.join(','); } - override connectedCallback() { - super.connectedCallback(); - this.addEventListener('focus', this.#onOptionFocus); - } - updated(changed: PropertyValues) { if (changed.has('filter')) { this.#onFilterChange(); @@ -98,8 +93,8 @@ export class PfListbox extends LitElement { @@ -153,16 +148,26 @@ export class PfListbox extends LitElement { } #updateActiveDescendant() { - const [desc] = this.#allOptions.filter(option => option === this.#tabindex.activeItem); - this.#internals.ariaActivedescendant = desc?.id; + let found = false; + this.#allOptions.forEach(option => { + if (option === this.#tabindex.activeItem) { + this.#internals.ariaActivedescendant = option.id; + option.setAttribute('active-descendant', 'active-descendant'); + found = true; + } else { + option.removeAttribute('active-descendant'); + } + }); + if (!found) { + this.#internals.ariaActivedescendant = null; + } this.#updateSingleselect(); - // TODO: set this.#value and fire events } #updateSingleselect() { if (!this.isMultiselectable) { this.options.forEach(option => { - if (option === this.#tabindex.activeItem) { + if (option.id === this.#internals.ariaActivedescendant) { option.setAttribute('aria-selected', 'true'); } else { option.removeAttribute('aria-selected'); @@ -183,17 +188,6 @@ export class PfListbox extends LitElement { } } - #onFilterChange() { - const oldValue = this.value; - this.#allOptions = this.options; - const all = this.#allOptions; - this.#tabindex.updateItems(all); - this.#updateActiveDescendant(); - if (oldValue !== this.value) { - this.#fireInput(); - } - } - /** * handles user user selection change similar to HTMLSelectElement events * (@see {@link https://developer.mozilla.org/en-US/docs/Web/API/HTMLSelectElement#events|MDN: HTMLSelectElement Events}) @@ -212,6 +206,17 @@ export class PfListbox extends LitElement { this.dispatchEvent(new Event('input', { bubbles: true })); } + #onFilterChange() { + const oldValue = this.value; + this.#allOptions = this.options; + const all = this.#allOptions; + this.#tabindex.updateItems(all); + this.#updateActiveDescendant(); + if (oldValue !== this.value) { + this.#fireInput(); + } + } + #onOptionBlur() { this.#hasFocus = false; } @@ -237,7 +242,7 @@ export class PfListbox extends LitElement { if (this.isMultiselectable) { if (!event.shiftKey) { // toggle target - this.#updateMultiselect(event.target); + this.#updateMultiselect(target); } else { // select all options between active descendant and target const [start, end] = [this.options.indexOf(this.activeItem), this.options.indexOf(target)].sort(); From c8732e5984f11d02a0108d29589064c310de0376 Mon Sep 17 00:00:00 2001 From: Nikki Massaro Kauffman Date: Thu, 20 Jul 2023 10:28:02 -0400 Subject: [PATCH 16/40] feat(elements): fixed listbox filterbyKeyword for multiselect and external input --- elements/pf-listbox/pf-listbox.ts | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/elements/pf-listbox/pf-listbox.ts b/elements/pf-listbox/pf-listbox.ts index c56a576a82..c0f12dd282 100644 --- a/elements/pf-listbox/pf-listbox.ts +++ b/elements/pf-listbox/pf-listbox.ts @@ -112,10 +112,7 @@ export class PfListbox extends LitElement { let stopEvent = false; if (event.ctrlKey || event.altKey || - event.metaKey || - !event.composedPath().some(x => - this.options.includes(x as PfListboxOption))) { - this.filter = ''; + event.metaKey) { return; } @@ -131,7 +128,9 @@ export class PfListbox extends LitElement { break; case 'Enter': case ' ': - this.#updateMultiselect(target); + if (target) { + this.#updateMultiselect(target); + } stopEvent = true; break; default: From d772a34936cb097c8265107b25473247efce32a9 Mon Sep 17 00:00:00 2001 From: Nikki Massaro Kauffman Date: Thu, 20 Jul 2023 14:02:31 -0400 Subject: [PATCH 17/40] feat(listbox): added keyboardmultiselect support --- elements/pf-listbox/pf-listbox.css | 4 +- elements/pf-listbox/pf-listbox.ts | 69 ++++++++++++++---------------- 2 files changed, 36 insertions(+), 37 deletions(-) diff --git a/elements/pf-listbox/pf-listbox.css b/elements/pf-listbox/pf-listbox.css index 4d990a56b6..4ea58617cf 100644 --- a/elements/pf-listbox/pf-listbox.css +++ b/elements/pf-listbox/pf-listbox.css @@ -1,5 +1,7 @@ :host { display: block; + border: 1px solid #ddd; + padding: 10px 0; } slot { @@ -12,7 +14,7 @@ slot { } ::slotted(*) { - margin: 2px 10px; + padding: 2px 10px; } :host(:focus-within) ::slotted([active-descendant]) { diff --git a/elements/pf-listbox/pf-listbox.ts b/elements/pf-listbox/pf-listbox.ts index c0f12dd282..1457e51a2f 100644 --- a/elements/pf-listbox/pf-listbox.ts +++ b/elements/pf-listbox/pf-listbox.ts @@ -38,6 +38,8 @@ export class PfListbox extends LitElement { #hasFocus = false; + #shiftStartingItem: PfListboxOption | null = null; + get isHorizontal(): boolean { return this?.getAttribute('aria-orientation') === 'horizontal'; } @@ -66,11 +68,7 @@ export class PfListbox extends LitElement { this.options.forEach(option => { const textContent = (option.textContent || '').toLowerCase(); const selected = this.isMultiselectable ? selectedItems?.includes(textContent) : firstItem === textContent; - if (selected) { - option.setAttribute('aria-selected', 'true'); - } else { - option.removeAttribute('aria-selected'); - } + option.ariaSelected = `${selected}`; }); if (oldValue !== this.value) { this.#fireInput(); @@ -78,7 +76,7 @@ export class PfListbox extends LitElement { } get value() { - const selectedItems = this.options.filter(option=>option.hasAttribute('aria-selected')).map(option => option.textContent); + const selectedItems = this.options.filter(option=>option.ariaSelected).map(option => option.textContent); return selectedItems.join(','); } @@ -96,7 +94,8 @@ export class PfListbox extends LitElement { @optionfocus="${this.#onOptionFocus}" @optionblur="${this.#onOptionBlur}" @click="${this.#onOptionClick}" - @keydown="${this.#onOptionKeydown}"> + @keydown="${this.#onOptionKeydown}" + @keyup="${this.#onOptionKeyup}"> `; } @@ -128,8 +127,10 @@ export class PfListbox extends LitElement { break; case 'Enter': case ' ': + // enter and space are only applicable if a listbox option is clicked + // an external text input should not trigger multiselect if (target) { - this.#updateMultiselect(target); + this.#updateMultiselect(target, event.shiftKey); } stopEvent = true; break; @@ -165,23 +166,21 @@ export class PfListbox extends LitElement { #updateSingleselect() { if (!this.isMultiselectable) { - this.options.forEach(option => { - if (option.id === this.#internals.ariaActivedescendant) { - option.setAttribute('aria-selected', 'true'); - } else { - option.removeAttribute('aria-selected'); - } - }); + this.options.forEach(option => option.ariaSelected = `${option.id === this.#internals.ariaActivedescendant}`); this.#fireChange(); } } - #updateMultiselect(option: PfListboxOption) { + #updateMultiselect(option: PfListboxOption, shiftKey = false, startingItem = this.activeItem) { if (this.isMultiselectable) { - if (!option.hasAttribute('aria-selected')) { - option.setAttribute('aria-selected', 'true'); + if (shiftKey) { + // toggle target + option.ariaSelected = `${!option.ariaSelected}`; } else { - option.removeAttribute('aria-selected'); + // select all options between active descendant and target + const [start, end] = [this.options.indexOf(startingItem), this.options.indexOf(option)].sort(); + const options = [...this.options].slice(start, end + 1); + options.forEach(option => option.ariaSelected = 'true'); } this.#fireChange(); } @@ -231,26 +230,11 @@ export class PfListbox extends LitElement { #onOptionClick(event: MouseEvent) { const target = event.target as PfListboxOption; - const sel = (option: PfListboxOption, selected = false) => { - if (selected) { - option.setAttribute('aria-selected', 'true'); - } else { - option.removeAttribute('aria-selected'); - } - }; if (this.isMultiselectable) { - if (!event.shiftKey) { - // toggle target - this.#updateMultiselect(target); - } else { - // select all options between active descendant and target - const [start, end] = [this.options.indexOf(this.activeItem), this.options.indexOf(target)].sort(); - const options = [...this.options].slice(start, end + 1); - options.forEach(option=>sel(option, true)); - } + this.#updateMultiselect(target, event.shiftKey); } else { // select target and deselect all other options - this.options.forEach(option => sel(option, option === target)); + this.options.forEach(option => option.ariaSelected = `${option === target}`); } if (target !== this.#tabindex.activeItem) { this.#tabindex.focusOnItem(target); @@ -259,8 +243,21 @@ export class PfListbox extends LitElement { } } + #onOptionKeyup(event: KeyboardEvent) { + if (event.shiftKey) { + if (this.#shiftStartingItem && this.activeItem) { + this.#updateMultiselect(this.activeItem, true, this.#shiftStartingItem); + } + this.#shiftStartingItem = null; + } + } + #onOptionKeydown(event: KeyboardEvent) { const { filter } = this; + // need to set for keyboard support of multiselect + if (event.shiftKey) { + this.#shiftStartingItem = this.activeItem; + } this.filterByKeyboardEvent(event); // only change focus if keydown occurred when option has focus // (as opposed to an external text input and if filter has changed From ec5f9374c202a093d080ca2c585bbfcb540fbaa0 Mon Sep 17 00:00:00 2001 From: Nikki Massaro Kauffman Date: Thu, 20 Jul 2023 14:03:35 -0400 Subject: [PATCH 18/40] feat(listbox): added flex temporary style --- elements/pf-listbox/pf-listbox.css | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/elements/pf-listbox/pf-listbox.css b/elements/pf-listbox/pf-listbox.css index 4ea58617cf..3ca2eb1f25 100644 --- a/elements/pf-listbox/pf-listbox.css +++ b/elements/pf-listbox/pf-listbox.css @@ -1,5 +1,5 @@ :host { - display: block; + display: flex; border: 1px solid #ddd; padding: 10px 0; } From 43df77d8275500bc07fd54a0ae8d0c4e4b8121df Mon Sep 17 00:00:00 2001 From: Nikki Massaro Kauffman Date: Thu, 20 Jul 2023 16:48:38 -0400 Subject: [PATCH 19/40] feat(listbox): added temporary listbox styles --- elements/pf-listbox/pf-listbox.css | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/elements/pf-listbox/pf-listbox.css b/elements/pf-listbox/pf-listbox.css index 3ca2eb1f25..f00f659e93 100644 --- a/elements/pf-listbox/pf-listbox.css +++ b/elements/pf-listbox/pf-listbox.css @@ -1,7 +1,8 @@ :host { - display: flex; + display: inline-flex; border: 1px solid #ddd; - padding: 10px 0; + border-radius: 3px; + padding: 10px; } slot { From 159a37f27d7e470b829c9ee259c6bef653e8a2fa Mon Sep 17 00:00:00 2001 From: Nikki Massaro Kauffman Date: Thu, 20 Jul 2023 16:49:58 -0400 Subject: [PATCH 20/40] feat(listbox): added multiselect with keyboard events --- elements/pf-listbox/pf-listbox.ts | 111 +++++++++++++++++++----------- 1 file changed, 69 insertions(+), 42 deletions(-) diff --git a/elements/pf-listbox/pf-listbox.ts b/elements/pf-listbox/pf-listbox.ts index 1457e51a2f..4e174b5ac7 100644 --- a/elements/pf-listbox/pf-listbox.ts +++ b/elements/pf-listbox/pf-listbox.ts @@ -108,39 +108,55 @@ export class PfListbox extends LitElement { */ filterByKeyboardEvent(event: KeyboardEvent) { const target = event.target as PfListboxOption; + const oldValue = this.value; let stopEvent = false; - if (event.ctrlKey || - event.altKey || + if (event.altKey || event.metaKey) { return; - } - - switch (event.key) { - case 'Backspace': - case 'Delete': - this.filter = this.filter.slice(0, this.filter.length - 2); - stopEvent = true; - break; - case event.key?.match(/^[\w]$/)?.input: - this.filter += event.key?.toLowerCase(); + } else if (event.ctrlKey) { + if (event.key?.match(/^[aA]$/)?.input && this.#tabindex.firstItem) { + this.#updateMultiselect(this.#tabindex.firstItem, this.#tabindex.lastItem); stopEvent = true; - break; - case 'Enter': - case ' ': - // enter and space are only applicable if a listbox option is clicked - // an external text input should not trigger multiselect - if (target) { - this.#updateMultiselect(target, event.shiftKey); - } - stopEvent = true; - break; - default: - break; + } else { + return; + } + } else { + switch (event.key) { + case 'Backspace': + case 'Delete': + this.filter = this.filter.slice(0, this.filter.length - 2); + stopEvent = true; + break; + case event.key?.match(/^[\w]$/)?.input: + this.filter += event.key?.toLowerCase(); + stopEvent = true; + break; + case 'Enter': + case ' ': + // enter and space are only applicable if a listbox option is clicked + // an external text input should not trigger multiselect + if (target) { + if (this.isMultiselectable) { + if (event.shiftKey) { + this.#updateMultiselect(target); + } else { + target.ariaSelected = `${target.ariaSelected !== 'true'}`; + } + stopEvent = true; + } + } + break; + default: + break; + } } if (stopEvent) { event.stopPropagation(); event.preventDefault(); } + if (oldValue !== this.value) { + this.#fireChange(); + } } focus() { @@ -171,18 +187,15 @@ export class PfListbox extends LitElement { } } - #updateMultiselect(option: PfListboxOption, shiftKey = false, startingItem = this.activeItem) { + #updateMultiselect(currentItem: PfListboxOption, referenceitem = this.activeItem) { if (this.isMultiselectable) { - if (shiftKey) { - // toggle target - option.ariaSelected = `${!option.ariaSelected}`; - } else { - // select all options between active descendant and target - const [start, end] = [this.options.indexOf(startingItem), this.options.indexOf(option)].sort(); - const options = [...this.options].slice(start, end + 1); - options.forEach(option => option.ariaSelected = 'true'); - } - this.#fireChange(); + // select all options between active descendant and target + const [start, end] = [this.options.indexOf(referenceitem), this.options.indexOf(currentItem)].sort(); + const options = [...this.options].slice(start, end + 1); + // if all items in range are toggled, remove toggle + const toggle = options.filter(option => option.ariaSelected !== 'true')?.length !== 0; + options.forEach(option => option.ariaSelected = `${toggle}`); + this.#shiftStartingItem = currentItem; } } @@ -198,7 +211,7 @@ export class PfListbox extends LitElement { /** * handles element value change similar to HTMLSelectElement events * (@see {@link https://developer.mozilla.org/en-US/docs/Web/API/HTMLSelectElement#events|MDN: HTMLSelectElement Events}) - * @fires change + * @fires input */ #fireInput() { this.dispatchEvent(new Event('input', { bubbles: true })); @@ -230,8 +243,16 @@ export class PfListbox extends LitElement { #onOptionClick(event: MouseEvent) { const target = event.target as PfListboxOption; + const oldValue = this.value; if (this.isMultiselectable) { - this.#updateMultiselect(target, event.shiftKey); + if (!event.shiftKey) { + target.ariaSelected = `${target.ariaSelected !== 'true'}`; + } else { + if (this.#shiftStartingItem && target) { + this.#updateMultiselect(target, this.#shiftStartingItem); + this.#fireChange(); + } + } } else { // select target and deselect all other options this.options.forEach(option => option.ariaSelected = `${option === target}`); @@ -239,23 +260,29 @@ export class PfListbox extends LitElement { if (target !== this.#tabindex.activeItem) { this.#tabindex.focusOnItem(target); this.#updateActiveDescendant(); + } + if (oldValue !== this.value) { this.#fireChange(); } } #onOptionKeyup(event: KeyboardEvent) { - if (event.shiftKey) { - if (this.#shiftStartingItem && this.activeItem) { - this.#updateMultiselect(this.activeItem, true, this.#shiftStartingItem); + const target = event.target as PfListboxOption; + if (event.shiftKey && this.isMultiselectable) { + if (this.#shiftStartingItem && target) { + this.#updateMultiselect(target, this.#shiftStartingItem); + this.#fireChange(); + } + if (event.key === 'Shift') { + this.#shiftStartingItem = null; } - this.#shiftStartingItem = null; } } #onOptionKeydown(event: KeyboardEvent) { const { filter } = this; // need to set for keyboard support of multiselect - if (event.shiftKey) { + if (event.key === 'Shift' && this.isMultiselectable) { this.#shiftStartingItem = this.activeItem; } this.filterByKeyboardEvent(event); From fe95a58b4df2b8f3ba8bde791dc7a4f1b7c997fd Mon Sep 17 00:00:00 2001 From: Nikki Massaro Kauffman Date: Thu, 20 Jul 2023 17:04:25 -0400 Subject: [PATCH 21/40] feat(listbox): updated temporary styles and demo --- elements/pf-listbox/demo/demo.css | 4 ++-- elements/pf-listbox/demo/pf-listbox.html | 22 +++++++++++++++++++--- elements/pf-listbox/pf-listbox-group.css | 20 ++++++++++++++++++++ elements/pf-listbox/pf-listbox-option.css | 1 + elements/pf-listbox/pf-listbox.css | 2 +- 5 files changed, 43 insertions(+), 6 deletions(-) diff --git a/elements/pf-listbox/demo/demo.css b/elements/pf-listbox/demo/demo.css index 8e786b884f..1ac9938737 100644 --- a/elements/pf-listbox/demo/demo.css +++ b/elements/pf-listbox/demo/demo.css @@ -1,3 +1,3 @@ -pf-listbox { - /* insert demo styles */ +[data-demo="pf-listbox"] { + padding: 20px; } diff --git a/elements/pf-listbox/demo/pf-listbox.html b/elements/pf-listbox/demo/pf-listbox.html index 5fb1a76404..0a00d33689 100644 --- a/elements/pf-listbox/demo/pf-listbox.html +++ b/elements/pf-listbox/demo/pf-listbox.html @@ -1,7 +1,21 @@ +

default

+

Any arrow keys work. Only one option can be selected.

+ + Blue + Green + Magenta + Orange + Purple + Pink + Red + Yellow + +

aria-orientation="horizontal"

+

Only horizontal arrow keys work. Only one option can be selected.

Blue Green @@ -12,9 +26,9 @@

aria-orientation="horizontal"

Red Yellow
-

aria-orientation="vertical"

+

Only vertical arrow keys work. Only one option can be selected.

Blue Green @@ -25,8 +39,9 @@

aria-orientation="vertical"

Red Yellow
-
+

aria-multiselectable="true"

+

Any arrow keys work. Multiple option can be selected.

Blue Green @@ -37,8 +52,9 @@

aria-multiselectable="true"

Red Yellow
-
+

pf-listbox-group

+

Any arrow keys work. Only one option can be selected. Headings are noe selectable.

Breakfast diff --git a/elements/pf-listbox/pf-listbox-group.css b/elements/pf-listbox/pf-listbox-group.css index 5d4e87f30f..ba41c4be4f 100644 --- a/elements/pf-listbox/pf-listbox-group.css +++ b/elements/pf-listbox/pf-listbox-group.css @@ -1,3 +1,23 @@ :host { display: block; + margin: 0 -10px; +} + +:host(:first-child) { + margin-top: -10px; +} + +:host(:last-child) { + margin-bottom: -10px; +} + +slot[name="group-heading"] { + background-color: #f0f0f0; + display: block; + padding: 0 10px; + font-weight: bold; +} + +::slotted(pf-listobx-option) { + padding: 2px 10px; } diff --git a/elements/pf-listbox/pf-listbox-option.css b/elements/pf-listbox/pf-listbox-option.css index 047c545b09..b6d245ea7c 100644 --- a/elements/pf-listbox/pf-listbox-option.css +++ b/elements/pf-listbox/pf-listbox-option.css @@ -7,6 +7,7 @@ display: none !important; } +:host:after, :host:before { content: '\2713 '; opacity: 0; diff --git a/elements/pf-listbox/pf-listbox.css b/elements/pf-listbox/pf-listbox.css index f00f659e93..5736b59654 100644 --- a/elements/pf-listbox/pf-listbox.css +++ b/elements/pf-listbox/pf-listbox.css @@ -14,7 +14,7 @@ slot { flex-direction: row; } -::slotted(*) { +::slotted(pf-listobx-option) { padding: 2px 10px; } From 0fcd9cb24b3311890dfa4443f77d1a757a7ddaef Mon Sep 17 00:00:00 2001 From: Nikki Massaro Kauffman Date: Thu, 20 Jul 2023 17:44:23 -0400 Subject: [PATCH 22/40] feat(listbox): added support for listbox groups --- elements/pf-listbox/pf-listbox-group.ts | 16 +++++++++++++++- elements/pf-listbox/pf-listbox.ts | 19 +++++++++++++++++-- 2 files changed, 32 insertions(+), 3 deletions(-) diff --git a/elements/pf-listbox/pf-listbox-group.ts b/elements/pf-listbox/pf-listbox-group.ts index 14096e195e..84168be465 100644 --- a/elements/pf-listbox/pf-listbox-group.ts +++ b/elements/pf-listbox/pf-listbox-group.ts @@ -1,9 +1,13 @@ import { LitElement, html } from 'lit'; import { customElement } from 'lit/decorators/custom-element.js'; import { InternalsController } from '@patternfly/pfe-core/controllers/internals-controller.js'; +import { queryAssignedElements } from 'lit/decorators/query-assigned-elements.js'; +import { PfListboxOption } from './pf-listbox-option.js'; import styles from './pf-listbox-group.css'; +export type PfListboxGroupOrOption = PfListboxGroup | PfListboxOption; + /** * Group of options within a listbox * @slot - Place element content here @@ -16,12 +20,22 @@ export class PfListboxGroup extends LitElement { role: 'group' }); + @queryAssignedElements() private _options!: PfListboxOption[]; + render() { return html` - + `; } + + get options() { + return this._options; + } + + #onSlotchange() { + this.dispatchEvent(new Event('slotchange', { bubbles: true })); + } } declare global { diff --git a/elements/pf-listbox/pf-listbox.ts b/elements/pf-listbox/pf-listbox.ts index 4e174b5ac7..af7158127d 100644 --- a/elements/pf-listbox/pf-listbox.ts +++ b/elements/pf-listbox/pf-listbox.ts @@ -6,9 +6,10 @@ import { queryAssignedElements } from 'lit/decorators/query-assigned-elements.js import { InternalsController } from '@patternfly/pfe-core/controllers/internals-controller.js'; import { RovingTabindexController } from '@patternfly/pfe-core/controllers/roving-tabindex-controller.js'; import { PfListboxOption } from './pf-listbox-option.js'; -import './pf-listbox-group.js'; +import { PfListboxGroup, type PfListboxGroupOrOption } from './pf-listbox-group.js'; import styles from './pf-listbox.css'; +import type { PfJumpLinksItem } from '../pf-jump-links/pf-jump-links-item.js'; /** * List of selectable items @@ -26,7 +27,7 @@ export class PfListbox extends LitElement { @property() filter = ''; @property({ reflect: true, attribute: 'disable-filter' }) disableFilter = ''; - @queryAssignedElements() private options!: PfListboxOption[]; + @queryAssignedElements() private groupsOrOptions!: PfListboxGroupOrOption[]; #internals = new InternalsController(this, { role: 'listbox' @@ -293,6 +294,20 @@ export class PfListbox extends LitElement { } } + get options() { + const extractItems = (group: PfListboxGroupOrOption[]) => group.flatMap((item: PfListboxGroupOrOption) => { + let options: PfListboxOption[]; + if (item instanceof PfListboxGroup) { + const group = item as PfListboxGroup; + options = extractItems(group.options); + } else { + options = [item]; + } + return options; + }); + return extractItems(this.groupsOrOptions); + } + #onSlotchange() { this.#allOptions = this.options; const all = this.#allOptions; From fd4decf76e0f07abf701ebbf765115cb045ca0dc Mon Sep 17 00:00:00 2001 From: Nikki Massaro Kauffman Date: Thu, 20 Jul 2023 17:46:41 -0400 Subject: [PATCH 23/40] feat(listbox): removed unused `#hasFocus` variable from listbox --- elements/pf-listbox/pf-listbox.ts | 8 -------- 1 file changed, 8 deletions(-) diff --git a/elements/pf-listbox/pf-listbox.ts b/elements/pf-listbox/pf-listbox.ts index af7158127d..d380fed48b 100644 --- a/elements/pf-listbox/pf-listbox.ts +++ b/elements/pf-listbox/pf-listbox.ts @@ -37,8 +37,6 @@ export class PfListbox extends LitElement { #_allOptions: PfListboxOption[] = []; - #hasFocus = false; - #shiftStartingItem: PfListboxOption | null = null; get isHorizontal(): boolean { @@ -93,7 +91,6 @@ export class PfListbox extends LitElement { class="${this.isHorizontal ? 'horizontal' : 'vertical'}" @slotchange="${this.#onSlotchange}" @optionfocus="${this.#onOptionFocus}" - @optionblur="${this.#onOptionBlur}" @click="${this.#onOptionClick}" @keydown="${this.#onOptionKeydown}" @keyup="${this.#onOptionKeyup}"> @@ -229,12 +226,7 @@ export class PfListbox extends LitElement { } } - #onOptionBlur() { - this.#hasFocus = false; - } - #onOptionFocus(event: FocusEvent) { - this.#hasFocus = true; const target = event.target as PfListboxOption; if (target !== this.#tabindex.activeItem) { this.#tabindex.updateActiveItem(target); From d8b5d0c99426904e7de7288569c6fba4c85f4490 Mon Sep 17 00:00:00 2001 From: Nikki Massaro Kauffman Date: Thu, 20 Jul 2023 17:57:40 -0400 Subject: [PATCH 24/40] feat(listbox): updated demo styles --- elements/pf-listbox/pf-listbox-group.css | 17 ++++------------- elements/pf-listbox/pf-listbox-option.css | 5 +++++ elements/pf-listbox/pf-listbox.css | 11 ++++------- 3 files changed, 13 insertions(+), 20 deletions(-) diff --git a/elements/pf-listbox/pf-listbox-group.css b/elements/pf-listbox/pf-listbox-group.css index ba41c4be4f..da5afe506f 100644 --- a/elements/pf-listbox/pf-listbox-group.css +++ b/elements/pf-listbox/pf-listbox-group.css @@ -1,23 +1,14 @@ :host { display: block; - margin: 0 -10px; } -:host(:first-child) { - margin-top: -10px; -} - -:host(:last-child) { - margin-bottom: -10px; +slot { + padding: 10px; } slot[name="group-heading"] { background-color: #f0f0f0; display: block; - padding: 0 10px; font-weight: bold; -} - -::slotted(pf-listobx-option) { - padding: 2px 10px; -} + padding: 0 10px; +} \ No newline at end of file diff --git a/elements/pf-listbox/pf-listbox-option.css b/elements/pf-listbox/pf-listbox-option.css index b6d245ea7c..29396196a2 100644 --- a/elements/pf-listbox/pf-listbox-option.css +++ b/elements/pf-listbox/pf-listbox-option.css @@ -1,5 +1,6 @@ :host { display: block; + padding: 2px 10px; } :host([hidden]), @@ -15,4 +16,8 @@ :host([aria-selected="true"]):before { opacity: 1; +} + +:host([active-descendant]) { + background-color: var(--_active-descendant-color, transparent); } \ No newline at end of file diff --git a/elements/pf-listbox/pf-listbox.css b/elements/pf-listbox/pf-listbox.css index 5736b59654..1b4c7b349c 100644 --- a/elements/pf-listbox/pf-listbox.css +++ b/elements/pf-listbox/pf-listbox.css @@ -2,7 +2,7 @@ display: inline-flex; border: 1px solid #ddd; border-radius: 3px; - padding: 10px; + --_active-descendant-color: transparent; } slot { @@ -14,10 +14,7 @@ slot { flex-direction: row; } -::slotted(pf-listobx-option) { - padding: 2px 10px; -} - -:host(:focus-within) ::slotted([active-descendant]) { - background-color: #e8f7ff; +:host(:focus-within) { + border: 1px solid blue; + --_active-descendant-color: #e1f4fe; } From c464616dcf4ec20d47282ab91b6581dac0911eeb Mon Sep 17 00:00:00 2001 From: Nikki Massaro Kauffman Date: Fri, 21 Jul 2023 12:57:47 -0400 Subject: [PATCH 25/40] feat(listbox): fixed multiselect handling, added internals for aria, and updated demo --- elements/pf-listbox/demo/pf-listbox.html | 20 +++++----- elements/pf-listbox/pf-listbox-option.ts | 5 +++ elements/pf-listbox/pf-listbox.ts | 49 ++++++++++++++++++------ 3 files changed, 53 insertions(+), 21 deletions(-) diff --git a/elements/pf-listbox/demo/pf-listbox.html b/elements/pf-listbox/demo/pf-listbox.html index 0a00d33689..77e7023623 100644 --- a/elements/pf-listbox/demo/pf-listbox.html +++ b/elements/pf-listbox/demo/pf-listbox.html @@ -2,7 +2,7 @@

default

-

Any arrow keys work. Only one option can be selected.

+

Any arrow keys work. Only one option can be selected. Filter by typing a letter.

Blue Green @@ -14,9 +14,9 @@

default

Yellow
-

aria-orientation="horizontal"

-

Only horizontal arrow keys work. Only one option can be selected.

- +

orientation="horizontal"

+

Only horizontal arrow keys work.

+ Blue Green Magenta @@ -27,9 +27,9 @@

aria-orientation="horizontal"

Yellow
-

aria-orientation="vertical"

+

orientation="vertical"

Only vertical arrow keys work. Only one option can be selected.

- + Blue Green Magenta @@ -40,9 +40,9 @@

aria-orientation="vertical"

Yellow
-

aria-multiselectable="true"

-

Any arrow keys work. Multiple option can be selected.

- +

multiple

+

Multiple option can be selected. Shift will toggling off multiple items. Ctrl+A will toggle selection on all items.

+ Blue Green Magenta @@ -54,7 +54,7 @@

aria-multiselectable="true"

pf-listbox-group

-

Any arrow keys work. Only one option can be selected. Headings are noe selectable.

+

Headings are noe selectable.

Breakfast diff --git a/elements/pf-listbox/pf-listbox-option.ts b/elements/pf-listbox/pf-listbox-option.ts index 50b524ad89..fd782af47e 100644 --- a/elements/pf-listbox/pf-listbox-option.ts +++ b/elements/pf-listbox/pf-listbox-option.ts @@ -50,6 +50,11 @@ export class PfListboxOption extends LitElement { } } + updateSetSizeAndPosition(setSize: number | null, posInSet: number | null) { + this.#internals.ariaSetSize = setSize !== null ? `${setSize}` : null; + this.#internals.ariaPosInSet = posInSet !== null ? `${posInSet}` : null; + } + #onFocus() { this.dispatchEvent(new Event('optionfocus', { bubbles: true })); } diff --git a/elements/pf-listbox/pf-listbox.ts b/elements/pf-listbox/pf-listbox.ts index d380fed48b..2ca4036de4 100644 --- a/elements/pf-listbox/pf-listbox.ts +++ b/elements/pf-listbox/pf-listbox.ts @@ -9,7 +9,6 @@ import { PfListboxOption } from './pf-listbox-option.js'; import { PfListboxGroup, type PfListboxGroupOrOption } from './pf-listbox-group.js'; import styles from './pf-listbox.css'; -import type { PfJumpLinksItem } from '../pf-jump-links/pf-jump-links-item.js'; /** * List of selectable items @@ -24,9 +23,30 @@ export class PfListbox extends LitElement { return element instanceof PfListboxOption; } + /** + * filter options that start with a string (case-insensitive) + */ @property() filter = ''; + + /** + * disable filtering feature + */ @property({ reflect: true, attribute: 'disable-filter' }) disableFilter = ''; + /** + * whether list items are arranged vertically or horizontally; + * limits arrow keys based on orientation + */ + @property({ reflect: true, attribute: 'orientation', type: String }) orientation = ''; + + /** + * whether multiple items can be selected + */ + @property({ reflect: true, attribute: 'multiple', type: Boolean }) multiple = false; + + /** + * all slotted listbox options and/or groups of options + */ @queryAssignedElements() private groupsOrOptions!: PfListboxGroupOrOption[]; #internals = new InternalsController(this, { @@ -35,16 +55,19 @@ export class PfListbox extends LitElement { #tabindex = new RovingTabindexController(this); + /** + * all options that will not be hidden by a filter + * */ #_allOptions: PfListboxOption[] = []; #shiftStartingItem: PfListboxOption | null = null; get isHorizontal(): boolean { - return this?.getAttribute('aria-orientation') === 'horizontal'; + return this.#internals.ariaOrientation === 'horizontal'; } get isMultiselectable(): boolean { - return this?.getAttribute('aria-multiselectable') === 'true'; + return this.#internals.ariaMultiSelectable === 'true'; } get activeItem() { @@ -83,6 +106,12 @@ export class PfListbox extends LitElement { if (changed.has('filter')) { this.#onFilterChange(); } + if (changed.has('orientation')) { + this.#internals.ariaOrientation = ['vertical', 'horizontal'].includes(this.orientation) ? this.orientation : null; + } + if (changed.has('multiple')) { + this.#internals.ariaMultiSelectable = this.multiple ? 'true' : 'false'; + } } render() { @@ -113,7 +142,7 @@ export class PfListbox extends LitElement { return; } else if (event.ctrlKey) { if (event.key?.match(/^[aA]$/)?.input && this.#tabindex.firstItem) { - this.#updateMultiselect(this.#tabindex.firstItem, this.#tabindex.lastItem); + this.#updateMultiselect(this.#tabindex.firstItem, this.#tabindex.lastItem, true); stopEvent = true; } else { return; @@ -185,13 +214,14 @@ export class PfListbox extends LitElement { } } - #updateMultiselect(currentItem: PfListboxOption, referenceitem = this.activeItem) { + #updateMultiselect(currentItem: PfListboxOption, referenceItem = this.activeItem, ctrlKey = false) { if (this.isMultiselectable) { // select all options between active descendant and target - const [start, end] = [this.options.indexOf(referenceitem), this.options.indexOf(currentItem)].sort(); + const [start, end] = [this.options.indexOf(referenceItem), this.options.indexOf(currentItem)].sort(); const options = [...this.options].slice(start, end + 1); // if all items in range are toggled, remove toggle - const toggle = options.filter(option => option.ariaSelected !== 'true')?.length !== 0; + const allSelected = ctrlKey && options.filter(option => option.ariaSelected !== 'true')?.length === 0; + const toggle = ctrlKey && allSelected ? false : ctrlKey ? true : referenceItem.ariaSelected === 'true'; options.forEach(option => option.ariaSelected = `${toggle}`); this.#shiftStartingItem = currentItem; } @@ -304,10 +334,7 @@ export class PfListbox extends LitElement { this.#allOptions = this.options; const all = this.#allOptions; const setSize = this.options.length; - this.options.forEach((option, i) => { - option.setAttribute('aria-setsize', `${setSize}`); - option.setAttribute('aria-posinset', `${i + 1}`); - }); + this.options.forEach((option, i) => option.updateSetSizeAndPosition(setSize, i)); this.#tabindex.initItems(all, this); } } From 827ec5c354612cacdbb923cb3e6d27518a245110 Mon Sep 17 00:00:00 2001 From: Nikki Massaro Kauffman Date: Fri, 21 Jul 2023 16:36:10 -0400 Subject: [PATCH 26/40] feat(listbox): updated docs with more examples --- elements/pf-listbox/demo/demo.css | 6 ++ elements/pf-listbox/demo/grouped-options.html | 23 +++++++ .../pf-listbox/demo/multiple-selections.html | 19 ++++++ elements/pf-listbox/demo/orientation.html | 28 ++++++++ elements/pf-listbox/demo/pf-listbox.html | 64 ++----------------- 5 files changed, 82 insertions(+), 58 deletions(-) create mode 100644 elements/pf-listbox/demo/grouped-options.html create mode 100644 elements/pf-listbox/demo/multiple-selections.html create mode 100644 elements/pf-listbox/demo/orientation.html diff --git a/elements/pf-listbox/demo/demo.css b/elements/pf-listbox/demo/demo.css index 1ac9938737..62f58fd7b6 100644 --- a/elements/pf-listbox/demo/demo.css +++ b/elements/pf-listbox/demo/demo.css @@ -1,3 +1,9 @@ [data-demo="pf-listbox"] { padding: 20px; } + +#combo { + display: inline-flex; + align-items: stretch; + flex-direction: column; +} diff --git a/elements/pf-listbox/demo/grouped-options.html b/elements/pf-listbox/demo/grouped-options.html new file mode 100644 index 0000000000..62e0830ce7 --- /dev/null +++ b/elements/pf-listbox/demo/grouped-options.html @@ -0,0 +1,23 @@ + + + +

+ Any arrow keys work. + Only one option can be selected. + Filter by typing a letter. + Headings are not selectable. +

+ + + Breakfast + Eggs + Toast + Waffles + + + Lunch + Salad + Sandwich + Soup + + diff --git a/elements/pf-listbox/demo/multiple-selections.html b/elements/pf-listbox/demo/multiple-selections.html new file mode 100644 index 0000000000..ba94169504 --- /dev/null +++ b/elements/pf-listbox/demo/multiple-selections.html @@ -0,0 +1,19 @@ + + + +

+ Multiple options can be selected. Any arrow keys work. + Shift will toggling off multiple items. + Ctrl+A will toggle selection on all items. + Filter by typing a letter. +

+ + Blue + Green + Magenta + Orange + Purple + Pink + Red + Yellow + \ No newline at end of file diff --git a/elements/pf-listbox/demo/orientation.html b/elements/pf-listbox/demo/orientation.html new file mode 100644 index 0000000000..408ad9ebc7 --- /dev/null +++ b/elements/pf-listbox/demo/orientation.html @@ -0,0 +1,28 @@ + + + +

orientation="vertical"

+

Only vertical arrow keys work. Filter by typing a letter.

+ + Blue + Green + Magenta + Orange + Purple + Pink + Red + Yellow + + +

orientation="horizontal"

+

Only horizontal arrow keys work. Filter by typing a letter.

+ + Blue + Green + Magenta + Orange + Purple + Pink + Red + Yellow + \ No newline at end of file diff --git a/elements/pf-listbox/demo/pf-listbox.html b/elements/pf-listbox/demo/pf-listbox.html index 77e7023623..0a428c9b0e 100644 --- a/elements/pf-listbox/demo/pf-listbox.html +++ b/elements/pf-listbox/demo/pf-listbox.html @@ -2,7 +2,11 @@

default

-

Any arrow keys work. Only one option can be selected. Filter by typing a letter.

+

+ Any arrow keys work. + Only one option can be selected. + Filter by typing a letter. +

Blue Green @@ -12,60 +16,4 @@

default

Pink Red Yellow -
- -

orientation="horizontal"

-

Only horizontal arrow keys work.

- - Blue - Green - Magenta - Orange - Purple - Pink - Red - Yellow - - -

orientation="vertical"

-

Only vertical arrow keys work. Only one option can be selected.

- - Blue - Green - Magenta - Orange - Purple - Pink - Red - Yellow - - -

multiple

-

Multiple option can be selected. Shift will toggling off multiple items. Ctrl+A will toggle selection on all items.

- - Blue - Green - Magenta - Orange - Purple - Pink - Red - Yellow - - -

pf-listbox-group

-

Headings are noe selectable.

- - - Breakfast - Eggs - Toast - Waffles - - - Lunch - Salad - Sandwich - Soup - - +
\ No newline at end of file From ac10a6322449d57b15a4e77b51dc86e2d793b541 Mon Sep 17 00:00:00 2001 From: Nikki Massaro Kauffman Date: Fri, 21 Jul 2023 17:00:18 -0400 Subject: [PATCH 27/40] feat(listbox): added support for combobox filtering --- .../controllers/roving-tabindex-controller.ts | 1 + elements/pf-listbox/demo/demo.css | 4 ++ elements/pf-listbox/demo/filter-modes.html | 43 +++++++++++++++++++ elements/pf-listbox/demo/pf-listbox.js | 28 ++++++++++++ elements/pf-listbox/pf-listbox.css | 7 ++- elements/pf-listbox/pf-listbox.ts | 26 ++++++++--- 6 files changed, 103 insertions(+), 6 deletions(-) create mode 100644 elements/pf-listbox/demo/filter-modes.html diff --git a/core/pfe-core/controllers/roving-tabindex-controller.ts b/core/pfe-core/controllers/roving-tabindex-controller.ts index 5339f3a6fb..6bf6203eb9 100644 --- a/core/pfe-core/controllers/roving-tabindex-controller.ts +++ b/core/pfe-core/controllers/roving-tabindex-controller.ts @@ -186,6 +186,7 @@ export class RovingTabindexController< updateItems(items: ItemType[]) { const sequence = [...items.slice(this.#itemIndex), ...items.slice(0, this.#itemIndex)]; const first = sequence.find(item => this.#focusableItems.includes(item)); + this.#items = items ?? []; this.focusOnItem(first || this.firstItem); } diff --git a/elements/pf-listbox/demo/demo.css b/elements/pf-listbox/demo/demo.css index 62f58fd7b6..7a63e2ef0e 100644 --- a/elements/pf-listbox/demo/demo.css +++ b/elements/pf-listbox/demo/demo.css @@ -7,3 +7,7 @@ align-items: stretch; flex-direction: column; } + +#listbox { + display: flex; +} \ No newline at end of file diff --git a/elements/pf-listbox/demo/filter-modes.html b/elements/pf-listbox/demo/filter-modes.html new file mode 100644 index 0000000000..4fba5b2c40 --- /dev/null +++ b/elements/pf-listbox/demo/filter-modes.html @@ -0,0 +1,43 @@ + + + + + +

filter-mode="required": combobox example

+

+ Any arrow keys work. + Only one option can be selected. + Listbox wont appear until input has text. +

+
+ + + Blue + Green + Magenta + Orange + Purple + Pink + Red + Yellow + +
\ No newline at end of file diff --git a/elements/pf-listbox/demo/pf-listbox.js b/elements/pf-listbox/demo/pf-listbox.js index dbfb27a103..dfc8752c38 100644 --- a/elements/pf-listbox/demo/pf-listbox.js +++ b/elements/pf-listbox/demo/pf-listbox.js @@ -1 +1,29 @@ import '@patternfly/elements/pf-listbox/pf-listbox.js'; + +window.addEventListener('load', () => { + /** + * pf-listbox in a combox box + * @see {@link https://www.w3.org/WAI/ARIA/apg/patterns/combobox/examples/combobox-autocomplete-both/|WAI ARIA Examples: Combobox with ARIAutocomplete} + */ + const input = document.querySelector('#text'); + const listbox = document.querySelector('#listbox'); + if (listbox && input) { + listbox.filter = input.value; + input.addEventListener('input', () => { + if (input.value !== listbox.value) { + listbox.filter = input.value; + } + }); + listbox.addEventListener('change', () => { + const val = (listbox.value || ''); + if (val.length > 0) { + input.value = val; + input.focus(); + input.setSelectionRange( + listbox.filter.length, + val.length + ); + } + }); + } +}); diff --git a/elements/pf-listbox/pf-listbox.css b/elements/pf-listbox/pf-listbox.css index 1b4c7b349c..5831311e31 100644 --- a/elements/pf-listbox/pf-listbox.css +++ b/elements/pf-listbox/pf-listbox.css @@ -8,9 +8,14 @@ slot { display: flex; flex-direction: column; + width: 100%; } -:host([aria-orientation="horizontal"]) slot { +:host([orientation="horizontal"]) { + overflow: scroll; +} + +:host([orientation="horizontal"]) slot { flex-direction: row; } diff --git a/elements/pf-listbox/pf-listbox.ts b/elements/pf-listbox/pf-listbox.ts index 2ca4036de4..9a5f0c75e9 100644 --- a/elements/pf-listbox/pf-listbox.ts +++ b/elements/pf-listbox/pf-listbox.ts @@ -29,9 +29,12 @@ export class PfListbox extends LitElement { @property() filter = ''; /** - * disable filtering feature + * determines how filtering will be handled: + * - "" (default): will show all options until filter text is not "" + * - "required": will hide all options until filter text is not "" + * - "disabled": will not hide options at all, regardless of filtering */ - @property({ reflect: true, attribute: 'disable-filter' }) disableFilter = ''; + @property({ reflect: true, attribute: 'filter-mode' }) filterMode: '' | 'required' | 'disabled' = ''; /** * whether list items are arranged vertically or horizontally; @@ -80,7 +83,7 @@ export class PfListbox extends LitElement { } set #allOptions(options: PfListboxOption[]) { - this.#_allOptions = options.filter(option => (this.constructor as typeof PfListbox).isOption(option) && (this.disableFilter || option.getUpdateByFilter(this.filter))); + this.#_allOptions = this.filterMode === 'required' && this.filter === '' ? [] : options.filter(option => (this.constructor as typeof PfListbox).isOption(option) && (this.filterMode === 'disabled' || option.getUpdateByFilter(this.filter))); } set value(items: string | null) { @@ -98,10 +101,22 @@ export class PfListbox extends LitElement { } get value() { - const selectedItems = this.options.filter(option=>option.ariaSelected).map(option => option.textContent); + const selectedItems = this.options.filter(option=>option.ariaSelected === 'true').map(option => option.textContent); return selectedItems.join(','); } + get validOptions() { + return this.options; + } + + isValid(val: string | null) { + const vals = val?.split(',') || []; + const options = this.options.map(option => option.textContent); + return vals.every(val => { + return options.includes(val); + }); + } + updated(changed: PropertyValues) { if (changed.has('filter')) { this.#onFilterChange(); @@ -202,7 +217,8 @@ export class PfListbox extends LitElement { } }); if (!found) { - this.#internals.ariaActivedescendant = null; + this.#tabindex.updateActiveItem(this.#tabindex.firstItem); + this.#internals.ariaActivedescendant = this.#tabindex.firstItem?.id || null; } this.#updateSingleselect(); } From 9a965a46bae80b7da54e01b74073e673bb117dd0 Mon Sep 17 00:00:00 2001 From: Nikki Massaro Kauffman Date: Fri, 21 Jul 2023 17:08:27 -0400 Subject: [PATCH 28/40] feat(listbox): updated demo for filter modes --- elements/pf-listbox/demo/filter-modes.html | 40 +++++++++++----------- 1 file changed, 20 insertions(+), 20 deletions(-) diff --git a/elements/pf-listbox/demo/filter-modes.html b/elements/pf-listbox/demo/filter-modes.html index 4fba5b2c40..88a8ee6fa0 100644 --- a/elements/pf-listbox/demo/filter-modes.html +++ b/elements/pf-listbox/demo/filter-modes.html @@ -1,7 +1,7 @@ - +

filter-mode="required": combobox example

@@ -23,21 +23,21 @@

filter-mode="required": combobox example

Only one option can be selected. Listbox wont appear until input has text.

-
- - - Blue - Green - Magenta - Orange - Purple - Pink - Red - Yellow - -
\ No newline at end of file + \ No newline at end of file From 7a63196aa734d5393084fdcb907b83c9ecb2eb4d Mon Sep 17 00:00:00 2001 From: Nikki Massaro Kauffman Date: Mon, 24 Jul 2023 15:14:16 -0400 Subject: [PATCH 29/40] fix(listbox): updated demo js --- elements/pf-listbox/demo/pf-listbox.js | 52 +++++++++++++------------- 1 file changed, 25 insertions(+), 27 deletions(-) diff --git a/elements/pf-listbox/demo/pf-listbox.js b/elements/pf-listbox/demo/pf-listbox.js index dfc8752c38..bd228bc7d8 100644 --- a/elements/pf-listbox/demo/pf-listbox.js +++ b/elements/pf-listbox/demo/pf-listbox.js @@ -1,29 +1,27 @@ import '@patternfly/elements/pf-listbox/pf-listbox.js'; -window.addEventListener('load', () => { - /** - * pf-listbox in a combox box - * @see {@link https://www.w3.org/WAI/ARIA/apg/patterns/combobox/examples/combobox-autocomplete-both/|WAI ARIA Examples: Combobox with ARIAutocomplete} - */ - const input = document.querySelector('#text'); - const listbox = document.querySelector('#listbox'); - if (listbox && input) { - listbox.filter = input.value; - input.addEventListener('input', () => { - if (input.value !== listbox.value) { - listbox.filter = input.value; - } - }); - listbox.addEventListener('change', () => { - const val = (listbox.value || ''); - if (val.length > 0) { - input.value = val; - input.focus(); - input.setSelectionRange( - listbox.filter.length, - val.length - ); - } - }); - } -}); +/** + * pf-listbox in a combox box + * @see {@link https://www.w3.org/WAI/ARIA/apg/patterns/combobox/examples/combobox-autocomplete-both/|WAI ARIA Examples: Combobox with ARIAutocomplete} + */ +const input = document.querySelector('#text'); +const listbox = document.querySelector('#listbox'); +if (listbox && input) { + listbox.filter = input.value; + input.addEventListener('input', () => { + if (input.value !== listbox.value) { + listbox.filter = input.value; + } + }); + listbox.addEventListener('change', () => { + const val = (listbox.value || ''); + if (val.length > 0) { + input.value = val; + input.focus(); + input.setSelectionRange( + listbox.filter.length, + val.length + ); + } + }); +} From 40c038e1261cd3d784324b70ba78f6dec2865046 Mon Sep 17 00:00:00 2001 From: Nikki Massaro Kauffman Date: Mon, 24 Jul 2023 15:14:44 -0400 Subject: [PATCH 30/40] feat(listbox): added case sensitivity --- elements/pf-listbox/pf-listbox-option.ts | 6 ++++-- elements/pf-listbox/pf-listbox.ts | 9 +++++++-- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/elements/pf-listbox/pf-listbox-option.ts b/elements/pf-listbox/pf-listbox-option.ts index fd782af47e..cfb9635063 100644 --- a/elements/pf-listbox/pf-listbox-option.ts +++ b/elements/pf-listbox/pf-listbox-option.ts @@ -24,8 +24,10 @@ export class PfListboxOption extends LitElement { this.id = this.id || getRandomId(); } - getUpdateByFilter(filter = '') { - if (filter === '' || (this.textContent || '').toLowerCase().match(filter)) { + getUpdateByFilter(filter = '', caseSensitve = false) { + const search = caseSensitve ? filter : filter.toLowerCase(); + const text = caseSensitve ? (this.textContent || '') : (this.textContent || ''); + if (search === '' || text.match(search)) { this.removeAttribute('hidden-by-filter'); } else { this.setAttribute('hidden-by-filter', 'hidden-by-filter'); diff --git a/elements/pf-listbox/pf-listbox.ts b/elements/pf-listbox/pf-listbox.ts index 9a5f0c75e9..af547b9d6c 100644 --- a/elements/pf-listbox/pf-listbox.ts +++ b/elements/pf-listbox/pf-listbox.ts @@ -28,6 +28,11 @@ export class PfListbox extends LitElement { */ @property() filter = ''; + /** + * whetehr filtering (if enabled) will be case-sensitive + */ + @property({ reflect: true, attribute: 'case-sensitive' }) caseSensitive = false; + /** * determines how filtering will be handled: * - "" (default): will show all options until filter text is not "" @@ -83,7 +88,7 @@ export class PfListbox extends LitElement { } set #allOptions(options: PfListboxOption[]) { - this.#_allOptions = this.filterMode === 'required' && this.filter === '' ? [] : options.filter(option => (this.constructor as typeof PfListbox).isOption(option) && (this.filterMode === 'disabled' || option.getUpdateByFilter(this.filter))); + this.#_allOptions = this.filterMode === 'required' && this.filter === '' ? [] : options.filter(option => (this.constructor as typeof PfListbox).isOption(option) && (this.filterMode === 'disabled' || option.getUpdateByFilter(this.filter, this.caseSensitive))); } set value(items: string | null) { @@ -118,7 +123,7 @@ export class PfListbox extends LitElement { } updated(changed: PropertyValues) { - if (changed.has('filter')) { + if (changed.has('filter') || changed.has('caseSensitive') || changed.has('filterMode') || changed.has('filterMode')) { this.#onFilterChange(); } if (changed.has('orientation')) { From b99edd1c6737dc9b87bcd48e2b09748876758c50 Mon Sep 17 00:00:00 2001 From: Nikki Massaro Kauffman Date: Mon, 24 Jul 2023 15:45:22 -0400 Subject: [PATCH 31/40] fix(listbox): typo on option --- elements/pf-listbox/pf-listbox-option.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/elements/pf-listbox/pf-listbox-option.ts b/elements/pf-listbox/pf-listbox-option.ts index cfb9635063..b2a62f02a8 100644 --- a/elements/pf-listbox/pf-listbox-option.ts +++ b/elements/pf-listbox/pf-listbox-option.ts @@ -24,9 +24,9 @@ export class PfListboxOption extends LitElement { this.id = this.id || getRandomId(); } - getUpdateByFilter(filter = '', caseSensitve = false) { - const search = caseSensitve ? filter : filter.toLowerCase(); - const text = caseSensitve ? (this.textContent || '') : (this.textContent || ''); + getUpdateByFilter(filter = '', caseSensitive = false) { + const search = caseSensitive ? filter : filter.toLowerCase(); + const text = caseSensitive ? (this.textContent || '') : (this.textContent || ''); if (search === '' || text.match(search)) { this.removeAttribute('hidden-by-filter'); } else { From f942a2d3a2a9b320eff30c309d0203c5a6008631 Mon Sep 17 00:00:00 2001 From: Nikki Massaro Kauffman Date: Fri, 28 Jul 2023 09:21:52 -0400 Subject: [PATCH 32/40] feat(core): init listbox-controller --- .../controllers/listbox-controller.ts | 428 ++++++++++++++++++ 1 file changed, 428 insertions(+) create mode 100644 core/pfe-core/controllers/listbox-controller.ts diff --git a/core/pfe-core/controllers/listbox-controller.ts b/core/pfe-core/controllers/listbox-controller.ts new file mode 100644 index 0000000000..06b59e82c8 --- /dev/null +++ b/core/pfe-core/controllers/listbox-controller.ts @@ -0,0 +1,428 @@ +import type { ReactiveController, ReactiveControllerHost } from 'lit'; +import { InternalsController } from '@patternfly/pfe-core/controllers/internals-controller.js'; +import { RovingTabindexController } from '@patternfly/pfe-core/controllers/roving-tabindex-controller.js'; + +/** + * how filtering will be handled: + * - "" (default): will show all options until filter text is not "" + * - "required": will hide all options until filter text is not "" + * - "disabled": will not hide options at all, regardless of filtering + */ +export type ListboxFilterMode = '' | 'required' | 'disabled'; + +/** + * whether list items are arranged vertically or horizontally; + * limits arrow keys based on orientation + */ +export type ListboxOrientation = '' | 'horizontal' | 'vertical'; + +export interface ListboxOptions { + caseSensitive?: boolean; + filterMode?: ListboxFilterMode; + matchAnywhere?: boolean; + multiSelectable?: boolean; + orientation?: ListboxOrientation; +} + +/** + * Implements roving tabindex, as described in WAI-ARIA practices, [Managing Focus Within + * Components Using a Roving + * tabindex](https://www.w3.org/WAI/ARIA/apg/practices/keyboard-interface/#kbd_roving_tabindex) + */ +export class ListboxController< + ItemType extends HTMLElement = HTMLElement, +> implements ReactiveController { + /** + * filter options that start with a string (case-insensitive) + */ + #filter = ''; + + /** + * whether filtering (if enabled) will be case-sensitive + */ + #caseSensitive = false; + + /** + * determines how filtering will be handled: + * - "" (default): will show all options until filter text is not "" + * - "required": will hide all options until filter text is not "" + * - "disabled": will not hide options at all, regardless of filtering + */ + #filterMode: ListboxFilterMode = ''; + + #internals: InternalsController; + + /** event listeners for host element */ + #listeners = { + 'keydown': this.#onOptionKeydown.bind(this), + 'keyup': this.#onOptionKeyup.bind(this), + 'optionfocus': this.#onOptionFocus.bind(this), + 'click': this.#onOptionClick.bind(this), + }; + + /** + * whether filtering (if enabled) will look for filter match anywhere in option text + * (by default it will only match if the option starts with filter) + */ + #matchAnywhere = false; + + #shiftStartingItem: HTMLElement | null = null; + + #tabindex: RovingTabindexController; + + /** + * all options that will not be hidden by a filter + * */ + #options: HTMLElement[] = []; + + /** + * all options that will not be hidden by a filter + * */ + #visibleOptions: HTMLElement[] = []; + + get activeItem() { + const [active] = this.options.filter(option => option.getAttribute('id') === this.#internals.ariaActivedescendant); + return active || this.#tabindex.firstItem; + } + + set filter(str: string) { + this.filter = str; + this.#onFilterChange(); + } + + get filter() { + return this.#filter; + } + + set caseSensitive(caseSensitive: boolean) { + this.#caseSensitive = caseSensitive; + this.#onFilterChange(); + } + + get caseSensitive() { + return this.#caseSensitive; + } + + set filterMode(str: ListboxFilterMode) { + this.#filterMode = str; + this.#onFilterChange(); + } + + get filterMode(): ListboxFilterMode { + return this.#filterMode || ''; + } + + set multiSelectable(multiSelectable: boolean) { + this.#internals.ariaMultiSelectable = multiSelectable ? 'true' : 'false'; + } + + get multiSelectable(): boolean { + return this.#internals.ariaMultiSelectable === 'true'; + } + + set matchAnywhere(matchAnywhere: boolean) { + this.#matchAnywhere = matchAnywhere; + this.#onFilterChange(); + } + + get matchAnywhere() { + return this.#matchAnywhere; + } + + set orientation(orientation: ListboxOrientation) { + this.#internals.ariaOrientation = orientation; + } + + get orientation(): ListboxOrientation { + const orientation = this.#internals.ariaOrientation || ''; + return orientation as ListboxOrientation; + } + + get options() { + return this.#options; + } + + set options(options: HTMLElement[]) { + const setSize = options.length; + const filterOptions = (option: HTMLElement, posInSet: number) => { + option.ariaSetSize = setSize !== null ? `${setSize}` : null; + option.ariaPosInSet = posInSet !== null ? `${posInSet}` : null; + if (this.#filterMode === 'required' && this.#filter === '') { + return false; + } else if (this.#filterMode === 'disabled') { + return true; + } else { + const search = this.#matchAnywhere ? '' : '^'; + const text = option.textContent || ''; + const regex = new RegExp(`${search}${this.#filter}`, this.#caseSensitive ? 'i' : ''); + if (search === '' || text.match(regex)) { + option.removeAttribute('hidden-by-filter'); + } else { + option.setAttribute('hidden-by-filter', 'hidden-by-filter'); + } + return !option.hasAttribute('hidden-by-filter'); + } + }; + this.#tabindex.initItems(this.#visibleOptions, this.host); + this.#visibleOptions = options.filter((option, i) => filterOptions(option, i)); + this.#options = options; + } + + get value() { + const selectedItems = this.options.filter(option=>option.ariaSelected === 'true').map(option => option.textContent?.replace(',', '\\,')); + return selectedItems.join(','); + } + + set value(items: string | null) { + const oldValue = this.value; + const selectedItems = items?.toLowerCase().split(','); + const [firstItem] = selectedItems || [null]; + this.options.forEach(option => { + const textContent = (option.textContent || '').replace('\\,', ',').toLowerCase(); + const selected = this.multiSelectable ? selectedItems?.includes(textContent) : firstItem === textContent; + option.ariaSelected = `${selected}`; + }); + if (oldValue !== this.value) { + this.#fireInput(); + } + } + + get visibleOptions() { + return this.#visibleOptions; + } + + constructor(public host: ReactiveControllerHost & HTMLElement, options: ListboxOptions) { + this.host.addController(this); + this.#internals = new InternalsController(this.host, { + role: 'listbox' + }); + this.#tabindex = new RovingTabindexController(this.host); + this.#caseSensitive = options.caseSensitive || false; + this.#filterMode = options.filterMode || ''; + } + + /** + * adds event listeners to host + */ + hostConnected() { + for (const [event, listener] of Object.entries(this.#listeners)) { + this.host?.addEventListener(event, listener as (event: Event | null) => void); + } + } + + /** + * removes event listeners from host + */ + hostDisconnected() { + for (const [event, listener] of Object.entries(this.#listeners)) { + this.host?.removeEventListener(event, listener as (event: Event | null) => void); + } + } + + isValid(val: string | null) { + const vals = val?.split(',') || []; + const options = this.options.map(option => option.textContent); + return vals.every(val => { + return options.includes(val); + }); + } + + #updateOption(option: HTMLElement) { + const search = this.#caseSensitive ? this.#filter : this.#filter.toLowerCase(); + const text = this.#caseSensitive ? (option.textContent || '') : (option.textContent || ''); + if (search === '' || text.match(search)) { + option.removeAttribute('hidden-by-filter'); + } else { + option.setAttribute('hidden-by-filter', 'hidden-by-filter'); + } + return !option.hasAttribute('hidden-by-filter'); + } + + /** + * filters listbox by keboard event when slotted option has focus, + * or by external element such as a text field + * @param event + * @returns { void } + */ + filterByKeyboardEvent(event: KeyboardEvent) { + const target = event.target as HTMLElement; + const oldValue = this.value; + let stopEvent = false; + if (event.altKey || + event.metaKey) { + return; + } else if (event.ctrlKey) { + if (event.key?.match(/^[aA]$/)?.input && this.#tabindex.firstItem) { + this.#updateMultiselect(this.#tabindex.firstItem, this.#tabindex.lastItem, true); + stopEvent = true; + } else { + return; + } + } else { + switch (event.key) { + case 'Backspace': + case 'Delete': + this.#filter = this.#filter.slice(0, this.#filter.length - 2); + stopEvent = true; + break; + case event.key?.match(/^[\w]$/)?.input: + this.#filter += event.key?.toLowerCase(); + stopEvent = true; + break; + case 'Enter': + case ' ': + // enter and space are only applicable if a listbox option is clicked + // an external text input should not trigger multiselect + if (target) { + if (this.multiSelectable) { + if (event.shiftKey) { + this.#updateMultiselect(target); + } else { + target.ariaSelected = `${target.ariaSelected !== 'true'}`; + } + stopEvent = true; + } + } + break; + default: + break; + } + } + if (stopEvent) { + event.stopPropagation(); + event.preventDefault(); + } + if (oldValue !== this.value) { + this.#fireChange(); + } + } + + focus() { + this.#tabindex.focusOnItem(this.activeItem); + } + + #updateActiveDescendant() { + let found = false; + this.options.forEach(option => { + if (option === this.#tabindex.activeItem) { + this.#internals.ariaActivedescendant = option.id; + option.setAttribute('active-descendant', 'active-descendant'); + found = true; + } else { + option.removeAttribute('active-descendant'); + } + }); + if (!found) { + this.#tabindex.updateActiveItem(this.#tabindex.firstItem); + this.#internals.ariaActivedescendant = this.#tabindex.firstItem?.id || null; + } + this.#updateSingleselect(); + } + + #updateSingleselect() { + if (!this.multiSelectable) { + this.options.forEach(option => option.ariaSelected = `${option.id === this.#internals.ariaActivedescendant}`); + this.#fireChange(); + } + } + + #updateMultiselect(currentItem: HTMLElement, referenceItem = this.activeItem, ctrlKey = false) { + if (this.multiSelectable) { + // select all options between active descendant and target + const [start, end] = [this.options.indexOf(referenceItem), this.options.indexOf(currentItem)].sort(); + const options = [...this.options].slice(start, end + 1); + // if all items in range are toggled, remove toggle + const allSelected = ctrlKey && options.filter(option => option.ariaSelected !== 'true')?.length === 0; + const toggle = ctrlKey && allSelected ? false : ctrlKey ? true : referenceItem.ariaSelected === 'true'; + options.forEach(option => option.ariaSelected = `${toggle}`); + this.#shiftStartingItem = currentItem; + } + } + + /** + * handles user user selection change similar to HTMLSelectElement events + * (@see {@link https://developer.mozilla.org/en-US/docs/Web/API/HTMLSelectElement#events|MDN: HTMLSelectElement Events}) + * @fires change + */ + #fireChange() { + this.host.dispatchEvent(new Event('change', { bubbles: true })); + } + + /** + * handles element value change similar to HTMLSelectElement events + * (@see {@link https://developer.mozilla.org/en-US/docs/Web/API/HTMLSelectElement#events|MDN: HTMLSelectElement Events}) + * @fires input + */ + #fireInput() { + this.host.dispatchEvent(new Event('input', { bubbles: true })); + } + + #onFilterChange() { + const oldValue = this.value; + this.#tabindex.updateItems(this.#visibleOptions); + this.#updateActiveDescendant(); + if (oldValue !== this.value) { + this.#fireInput(); + } + } + + #onOptionFocus(event: FocusEvent) { + const target = event.target as HTMLElement; + if (target !== this.#tabindex.activeItem) { + this.#tabindex.updateActiveItem(target); + } + this.#updateActiveDescendant(); + } + + #onOptionClick(event: MouseEvent) { + const target = event.target as HTMLElement; + const oldValue = this.value; + if (this.multiSelectable) { + if (!event.shiftKey) { + target.ariaSelected = `${target.ariaSelected !== 'true'}`; + } else { + if (this.#shiftStartingItem && target) { + this.#updateMultiselect(target, this.#shiftStartingItem); + this.#fireChange(); + } + } + } else { + // select target and deselect all other options + this.options.forEach(option => option.ariaSelected = `${option === target}`); + } + if (target !== this.#tabindex.activeItem) { + this.#tabindex.focusOnItem(target); + this.#updateActiveDescendant(); + } + if (oldValue !== this.value) { + this.#fireChange(); + } + } + + #onOptionKeyup(event: KeyboardEvent) { + const target = event.target as HTMLElement; + if (event.shiftKey && this.multiSelectable) { + if (this.#shiftStartingItem && target) { + this.#updateMultiselect(target, this.#shiftStartingItem); + this.#fireChange(); + } + if (event.key === 'Shift') { + this.#shiftStartingItem = null; + } + } + } + + #onOptionKeydown(event: KeyboardEvent) { + const { filter } = this; + // need to set for keyboard support of multiselect + if (event.key === 'Shift' && this.multiSelectable) { + this.#shiftStartingItem = this.activeItem; + } + this.filterByKeyboardEvent(event); + // only change focus if keydown occurred when option has focus + // (as opposed to an external text input and if filter has changed + if (filter !== this.#filter) { + this.#tabindex.focusOnItem(this.activeItem); + } + } +} From 2220a4bc7727d9297bdb3d74199c0702c3bda8b8 Mon Sep 17 00:00:00 2001 From: Nikki Massaro Kauffman Date: Fri, 28 Jul 2023 09:24:32 -0400 Subject: [PATCH 33/40] feat(listbox): updated listbox to use listbox-controller --- elements/pf-listbox/pf-listbox-group.ts | 4 + elements/pf-listbox/pf-listbox-option.ts | 28 +-- elements/pf-listbox/pf-listbox.ts | 308 +++-------------------- 3 files changed, 35 insertions(+), 305 deletions(-) diff --git a/elements/pf-listbox/pf-listbox-group.ts b/elements/pf-listbox/pf-listbox-group.ts index 84168be465..d5109e1887 100644 --- a/elements/pf-listbox/pf-listbox-group.ts +++ b/elements/pf-listbox/pf-listbox-group.ts @@ -22,6 +22,10 @@ export class PfListboxGroup extends LitElement { @queryAssignedElements() private _options!: PfListboxOption[]; + override connectedCallback() { + this.#internals; + } + render() { return html` diff --git a/elements/pf-listbox/pf-listbox-option.ts b/elements/pf-listbox/pf-listbox-option.ts index b2a62f02a8..5fb6c6911a 100644 --- a/elements/pf-listbox/pf-listbox-option.ts +++ b/elements/pf-listbox/pf-listbox-option.ts @@ -22,17 +22,7 @@ export class PfListboxOption extends LitElement { this.addEventListener('focus', this.#onFocus); this.addEventListener('blur', this.#onBlur); this.id = this.id || getRandomId(); - } - - getUpdateByFilter(filter = '', caseSensitive = false) { - const search = caseSensitive ? filter : filter.toLowerCase(); - const text = caseSensitive ? (this.textContent || '') : (this.textContent || ''); - if (search === '' || text.match(search)) { - this.removeAttribute('hidden-by-filter'); - } else { - this.setAttribute('hidden-by-filter', 'hidden-by-filter'); - } - return !this.hasAttribute('hidden-by-filter'); + this.#internals; } render() { @@ -41,22 +31,6 @@ export class PfListboxOption extends LitElement { `; } - updateSet(setSize?: number | null, posInSet?: number | null) { - if (!setSize || !posInSet) { - this.#internals.ariaSetSize = null; - this.#internals.ariaPosInSet = null; - } else { - const parsedSize = Math.max(-1, setSize); - this.#internals.ariaSetSize = `${parsedSize}`; - this.#internals.ariaPosInSet = `${Math.min(Math.max(1, posInSet), parsedSize)}`; - } - } - - updateSetSizeAndPosition(setSize: number | null, posInSet: number | null) { - this.#internals.ariaSetSize = setSize !== null ? `${setSize}` : null; - this.#internals.ariaPosInSet = posInSet !== null ? `${posInSet}` : null; - } - #onFocus() { this.dispatchEvent(new Event('optionfocus', { bubbles: true })); } diff --git a/elements/pf-listbox/pf-listbox.ts b/elements/pf-listbox/pf-listbox.ts index af547b9d6c..cb4fe2ae83 100644 --- a/elements/pf-listbox/pf-listbox.ts +++ b/elements/pf-listbox/pf-listbox.ts @@ -3,8 +3,7 @@ import type { PropertyValues } from 'lit'; import { customElement } from 'lit/decorators/custom-element.js'; import { property } from 'lit/decorators/property.js'; import { queryAssignedElements } from 'lit/decorators/query-assigned-elements.js'; -import { InternalsController } from '@patternfly/pfe-core/controllers/internals-controller.js'; -import { RovingTabindexController } from '@patternfly/pfe-core/controllers/roving-tabindex-controller.js'; +import { ListboxController, type ListboxFilterMode, type ListboxOrientation } from '@patternfly/pfe-core/controllers/listbox-controller.js'; import { PfListboxOption } from './pf-listbox-option.js'; import { PfListboxGroup, type PfListboxGroupOrOption } from './pf-listbox-group.js'; @@ -29,9 +28,9 @@ export class PfListbox extends LitElement { @property() filter = ''; /** - * whetehr filtering (if enabled) will be case-sensitive + * whether filtering (if enabled) will be case-sensitive */ - @property({ reflect: true, attribute: 'case-sensitive' }) caseSensitive = false; + @property({ reflect: true, attribute: 'case-sensitive', type: Boolean }) caseSensitive = false; /** * determines how filtering will be handled: @@ -39,302 +38,58 @@ export class PfListbox extends LitElement { * - "required": will hide all options until filter text is not "" * - "disabled": will not hide options at all, regardless of filtering */ - @property({ reflect: true, attribute: 'filter-mode' }) filterMode: '' | 'required' | 'disabled' = ''; + @property({ reflect: true, attribute: 'filter-mode', type: String }) filterMode: ListboxFilterMode = ''; + + /** + * whether filtering (if enabled) will look for filter match anywhere in option text + * (by default it will only match if the option starts with filter) + */ + @property({ reflect: true, attribute: 'match-anywhere', type: Boolean }) matchAnywhere = false; + + /** + * whether multiple items can be selected + */ + @property({ reflect: true, attribute: 'multi-selectable', type: Boolean }) multiSelectable = false; /** * whether list items are arranged vertically or horizontally; * limits arrow keys based on orientation */ - @property({ reflect: true, attribute: 'orientation', type: String }) orientation = ''; + @property({ reflect: true, attribute: 'orientation', type: String }) orientation: ListboxOrientation = ''; /** - * whether multiple items can be selected + * value of listbox as a string */ - @property({ reflect: true, attribute: 'multiple', type: Boolean }) multiple = false; + @property({ reflect: true, attribute: 'value' }) value?: string = undefined; /** * all slotted listbox options and/or groups of options */ @queryAssignedElements() private groupsOrOptions!: PfListboxGroupOrOption[]; - #internals = new InternalsController(this, { - role: 'listbox' + #listbox = new ListboxController(this, { + caseSensitive: this.caseSensitive, + filterMode: this.filterMode, + matchAnywhere: this.matchAnywhere, + multiSelectable: this.multiSelectable, + orientation: this.orientation }); - #tabindex = new RovingTabindexController(this); - - /** - * all options that will not be hidden by a filter - * */ - #_allOptions: PfListboxOption[] = []; - - #shiftStartingItem: PfListboxOption | null = null; - - get isHorizontal(): boolean { - return this.#internals.ariaOrientation === 'horizontal'; - } - - get isMultiselectable(): boolean { - return this.#internals.ariaMultiSelectable === 'true'; - } - - get activeItem() { - const [active] = this.#allOptions.filter(option => option.getAttribute('id') === this.#internals.ariaActivedescendant); - return active || this.#tabindex.firstItem; - } - - get #allOptions() { - return this.#_allOptions; - } - - set #allOptions(options: PfListboxOption[]) { - this.#_allOptions = this.filterMode === 'required' && this.filter === '' ? [] : options.filter(option => (this.constructor as typeof PfListbox).isOption(option) && (this.filterMode === 'disabled' || option.getUpdateByFilter(this.filter, this.caseSensitive))); - } - - set value(items: string | null) { - const oldValue = this.value; - const selectedItems = items?.toLowerCase().split(','); - const [firstItem] = selectedItems || [null]; - this.options.forEach(option => { - const textContent = (option.textContent || '').toLowerCase(); - const selected = this.isMultiselectable ? selectedItems?.includes(textContent) : firstItem === textContent; - option.ariaSelected = `${selected}`; - }); - if (oldValue !== this.value) { - this.#fireInput(); - } - } - - get value() { - const selectedItems = this.options.filter(option=>option.ariaSelected === 'true').map(option => option.textContent); - return selectedItems.join(','); - } - - get validOptions() { - return this.options; - } - - isValid(val: string | null) { - const vals = val?.split(',') || []; - const options = this.options.map(option => option.textContent); - return vals.every(val => { - return options.includes(val); - }); - } - updated(changed: PropertyValues) { - if (changed.has('filter') || changed.has('caseSensitive') || changed.has('filterMode') || changed.has('filterMode')) { - this.#onFilterChange(); - } - if (changed.has('orientation')) { - this.#internals.ariaOrientation = ['vertical', 'horizontal'].includes(this.orientation) ? this.orientation : null; - } - if (changed.has('multiple')) { - this.#internals.ariaMultiSelectable = this.multiple ? 'true' : 'false'; - } + // console.log(changed); } render() { return html` + class="${this.orientation}" + @slotchange="${this.#onSlotchange}"> `; } - /** - * filters listbox by keboard event when slotted option has focus, - * or by external element such as a text field - * @param event - * @returns { void } - */ - filterByKeyboardEvent(event: KeyboardEvent) { - const target = event.target as PfListboxOption; - const oldValue = this.value; - let stopEvent = false; - if (event.altKey || - event.metaKey) { - return; - } else if (event.ctrlKey) { - if (event.key?.match(/^[aA]$/)?.input && this.#tabindex.firstItem) { - this.#updateMultiselect(this.#tabindex.firstItem, this.#tabindex.lastItem, true); - stopEvent = true; - } else { - return; - } - } else { - switch (event.key) { - case 'Backspace': - case 'Delete': - this.filter = this.filter.slice(0, this.filter.length - 2); - stopEvent = true; - break; - case event.key?.match(/^[\w]$/)?.input: - this.filter += event.key?.toLowerCase(); - stopEvent = true; - break; - case 'Enter': - case ' ': - // enter and space are only applicable if a listbox option is clicked - // an external text input should not trigger multiselect - if (target) { - if (this.isMultiselectable) { - if (event.shiftKey) { - this.#updateMultiselect(target); - } else { - target.ariaSelected = `${target.ariaSelected !== 'true'}`; - } - stopEvent = true; - } - } - break; - default: - break; - } - } - if (stopEvent) { - event.stopPropagation(); - event.preventDefault(); - } - if (oldValue !== this.value) { - this.#fireChange(); - } - } - focus() { - this.#tabindex.focusOnItem(this.activeItem); - } - - #updateActiveDescendant() { - let found = false; - this.#allOptions.forEach(option => { - if (option === this.#tabindex.activeItem) { - this.#internals.ariaActivedescendant = option.id; - option.setAttribute('active-descendant', 'active-descendant'); - found = true; - } else { - option.removeAttribute('active-descendant'); - } - }); - if (!found) { - this.#tabindex.updateActiveItem(this.#tabindex.firstItem); - this.#internals.ariaActivedescendant = this.#tabindex.firstItem?.id || null; - } - this.#updateSingleselect(); - } - - #updateSingleselect() { - if (!this.isMultiselectable) { - this.options.forEach(option => option.ariaSelected = `${option.id === this.#internals.ariaActivedescendant}`); - this.#fireChange(); - } - } - - #updateMultiselect(currentItem: PfListboxOption, referenceItem = this.activeItem, ctrlKey = false) { - if (this.isMultiselectable) { - // select all options between active descendant and target - const [start, end] = [this.options.indexOf(referenceItem), this.options.indexOf(currentItem)].sort(); - const options = [...this.options].slice(start, end + 1); - // if all items in range are toggled, remove toggle - const allSelected = ctrlKey && options.filter(option => option.ariaSelected !== 'true')?.length === 0; - const toggle = ctrlKey && allSelected ? false : ctrlKey ? true : referenceItem.ariaSelected === 'true'; - options.forEach(option => option.ariaSelected = `${toggle}`); - this.#shiftStartingItem = currentItem; - } - } - - /** - * handles user user selection change similar to HTMLSelectElement events - * (@see {@link https://developer.mozilla.org/en-US/docs/Web/API/HTMLSelectElement#events|MDN: HTMLSelectElement Events}) - * @fires change - */ - #fireChange() { - this.dispatchEvent(new Event('change', { bubbles: true })); - } - - /** - * handles element value change similar to HTMLSelectElement events - * (@see {@link https://developer.mozilla.org/en-US/docs/Web/API/HTMLSelectElement#events|MDN: HTMLSelectElement Events}) - * @fires input - */ - #fireInput() { - this.dispatchEvent(new Event('input', { bubbles: true })); - } - - #onFilterChange() { - const oldValue = this.value; - this.#allOptions = this.options; - const all = this.#allOptions; - this.#tabindex.updateItems(all); - this.#updateActiveDescendant(); - if (oldValue !== this.value) { - this.#fireInput(); - } - } - - #onOptionFocus(event: FocusEvent) { - const target = event.target as PfListboxOption; - if (target !== this.#tabindex.activeItem) { - this.#tabindex.updateActiveItem(target); - } - this.#updateActiveDescendant(); - } - - #onOptionClick(event: MouseEvent) { - const target = event.target as PfListboxOption; - const oldValue = this.value; - if (this.isMultiselectable) { - if (!event.shiftKey) { - target.ariaSelected = `${target.ariaSelected !== 'true'}`; - } else { - if (this.#shiftStartingItem && target) { - this.#updateMultiselect(target, this.#shiftStartingItem); - this.#fireChange(); - } - } - } else { - // select target and deselect all other options - this.options.forEach(option => option.ariaSelected = `${option === target}`); - } - if (target !== this.#tabindex.activeItem) { - this.#tabindex.focusOnItem(target); - this.#updateActiveDescendant(); - } - if (oldValue !== this.value) { - this.#fireChange(); - } - } - - #onOptionKeyup(event: KeyboardEvent) { - const target = event.target as PfListboxOption; - if (event.shiftKey && this.isMultiselectable) { - if (this.#shiftStartingItem && target) { - this.#updateMultiselect(target, this.#shiftStartingItem); - this.#fireChange(); - } - if (event.key === 'Shift') { - this.#shiftStartingItem = null; - } - } - } - - #onOptionKeydown(event: KeyboardEvent) { - const { filter } = this; - // need to set for keyboard support of multiselect - if (event.key === 'Shift' && this.isMultiselectable) { - this.#shiftStartingItem = this.activeItem; - } - this.filterByKeyboardEvent(event); - // only change focus if keydown occurred when option has focus - // (as opposed to an external text input and if filter has changed - if (filter !== this.filter) { - this.#tabindex.focusOnItem(this.activeItem); - } + this.#listbox.focus(); } get options() { @@ -348,15 +103,12 @@ export class PfListbox extends LitElement { } return options; }); + // console.log(this.querySelectorAll('pf-listbox-option'), extractItems(this.groupsOrOptions)); return extractItems(this.groupsOrOptions); } #onSlotchange() { - this.#allOptions = this.options; - const all = this.#allOptions; - const setSize = this.options.length; - this.options.forEach((option, i) => option.updateSetSizeAndPosition(setSize, i)); - this.#tabindex.initItems(all, this); + this.#listbox.options = this.options; } } From f61771ce28d83eab198fb8a750dba331c0a60e5b Mon Sep 17 00:00:00 2001 From: Nikki Massaro Kauffman Date: Fri, 28 Jul 2023 11:53:15 -0400 Subject: [PATCH 34/40] feat(core): updated listbox-controller api --- .../controllers/listbox-controller.ts | 174 ++++++++++-------- 1 file changed, 96 insertions(+), 78 deletions(-) diff --git a/core/pfe-core/controllers/listbox-controller.ts b/core/pfe-core/controllers/listbox-controller.ts index 06b59e82c8..19f5bd19d5 100644 --- a/core/pfe-core/controllers/listbox-controller.ts +++ b/core/pfe-core/controllers/listbox-controller.ts @@ -85,8 +85,8 @@ export class ListboxController< return active || this.#tabindex.firstItem; } - set filter(str: string) { - this.filter = str; + set filter(filtertext: string) { + this.filter = filtertext; this.#onFilterChange(); } @@ -103,8 +103,8 @@ export class ListboxController< return this.#caseSensitive; } - set filterMode(str: ListboxFilterMode) { - this.#filterMode = str; + set filterMode(filterMode: ListboxFilterMode) { + this.#filterMode = filterMode; this.#onFilterChange(); } @@ -163,7 +163,7 @@ export class ListboxController< return !option.hasAttribute('hidden-by-filter'); } }; - this.#tabindex.initItems(this.#visibleOptions, this.host); + this.#tabindex.initItems(this.#visibleOptions); this.#visibleOptions = options.filter((option, i) => filterOptions(option, i)); this.#options = options; } @@ -173,9 +173,9 @@ export class ListboxController< return selectedItems.join(','); } - set value(items: string | null) { + set value(optionsList: string | null) { const oldValue = this.value; - const selectedItems = items?.toLowerCase().split(','); + const selectedItems = optionsList?.toLowerCase().split(','); const [firstItem] = selectedItems || [null]; this.options.forEach(option => { const textContent = (option.textContent || '').replace('\\,', ',').toLowerCase(); @@ -227,76 +227,6 @@ export class ListboxController< }); } - #updateOption(option: HTMLElement) { - const search = this.#caseSensitive ? this.#filter : this.#filter.toLowerCase(); - const text = this.#caseSensitive ? (option.textContent || '') : (option.textContent || ''); - if (search === '' || text.match(search)) { - option.removeAttribute('hidden-by-filter'); - } else { - option.setAttribute('hidden-by-filter', 'hidden-by-filter'); - } - return !option.hasAttribute('hidden-by-filter'); - } - - /** - * filters listbox by keboard event when slotted option has focus, - * or by external element such as a text field - * @param event - * @returns { void } - */ - filterByKeyboardEvent(event: KeyboardEvent) { - const target = event.target as HTMLElement; - const oldValue = this.value; - let stopEvent = false; - if (event.altKey || - event.metaKey) { - return; - } else if (event.ctrlKey) { - if (event.key?.match(/^[aA]$/)?.input && this.#tabindex.firstItem) { - this.#updateMultiselect(this.#tabindex.firstItem, this.#tabindex.lastItem, true); - stopEvent = true; - } else { - return; - } - } else { - switch (event.key) { - case 'Backspace': - case 'Delete': - this.#filter = this.#filter.slice(0, this.#filter.length - 2); - stopEvent = true; - break; - case event.key?.match(/^[\w]$/)?.input: - this.#filter += event.key?.toLowerCase(); - stopEvent = true; - break; - case 'Enter': - case ' ': - // enter and space are only applicable if a listbox option is clicked - // an external text input should not trigger multiselect - if (target) { - if (this.multiSelectable) { - if (event.shiftKey) { - this.#updateMultiselect(target); - } else { - target.ariaSelected = `${target.ariaSelected !== 'true'}`; - } - stopEvent = true; - } - } - break; - default: - break; - } - } - if (stopEvent) { - event.stopPropagation(); - event.preventDefault(); - } - if (oldValue !== this.value) { - this.#fireChange(); - } - } - focus() { this.#tabindex.focusOnItem(this.activeItem); } @@ -326,6 +256,13 @@ export class ListboxController< } } + /** + * for listboxes that are multiselectable, updates listbox option selections: + * toggles all options between active descendant and target + * @param currentItem + * @param referenceItem + * @param ctrlKey + */ #updateMultiselect(currentItem: HTMLElement, referenceItem = this.activeItem, ctrlKey = false) { if (this.multiSelectable) { // select all options between active descendant and target @@ -357,6 +294,12 @@ export class ListboxController< this.host.dispatchEvent(new Event('input', { bubbles: true })); } + /** + * handles updates to filter text: + * hides options that do not match filter settings + * and updates active descendant based on which options are still visible + * @returns void + */ #onFilterChange() { const oldValue = this.value; this.#tabindex.updateItems(this.#visibleOptions); @@ -366,6 +309,12 @@ export class ListboxController< } } + /** + * handles focusing on an option: + * updates roving tabindex and active descendant + * @param event {FocusEvent} + * @returns void + */ #onOptionFocus(event: FocusEvent) { const target = event.target as HTMLElement; if (target !== this.#tabindex.activeItem) { @@ -374,6 +323,13 @@ export class ListboxController< this.#updateActiveDescendant(); } + /** + * handles clicking on a listbox option: + * which selects an item by default + * or toggles selection if multiselectable + * @param event {MouseEvent} + * @returns void + */ #onOptionClick(event: MouseEvent) { const target = event.target as HTMLElement; const oldValue = this.value; @@ -399,6 +355,12 @@ export class ListboxController< } } + /** + * handles keyup: + * track whether shift key is being used for multiselectable listbox + * @param event {KeyboardEvent} + * @returns void + */ #onOptionKeyup(event: KeyboardEvent) { const target = event.target as HTMLElement; if (event.shiftKey && this.multiSelectable) { @@ -412,13 +374,69 @@ export class ListboxController< } } + /** + * handles keydown: + * filters listbox by keboard event when slotted option has focus, + * or by external element such as a text field + * @param event {KeyboardEvent} + * @returns void + */ #onOptionKeydown(event: KeyboardEvent) { const { filter } = this; // need to set for keyboard support of multiselect if (event.key === 'Shift' && this.multiSelectable) { this.#shiftStartingItem = this.activeItem; } - this.filterByKeyboardEvent(event); + const target = event.target as HTMLElement; + const oldValue = this.value; + let stopEvent = false; + if (event.altKey || + event.metaKey) { + return; + } else if (event.ctrlKey) { + if (event.key?.match(/^[aA]$/)?.input && this.#tabindex.firstItem) { + this.#updateMultiselect(this.#tabindex.firstItem, this.#tabindex.lastItem, true); + stopEvent = true; + } else { + return; + } + } else { + switch (event.key) { + case 'Backspace': + case 'Delete': + this.#filter = this.#filter.slice(0, this.#filter.length - 2); + stopEvent = true; + break; + case event.key?.match(/^[\w]$/)?.input: + this.#filter += event.key?.toLowerCase(); + stopEvent = true; + break; + case 'Enter': + case ' ': + // enter and space are only applicable if a listbox option is clicked + // an external text input should not trigger multiselect + if (target) { + if (this.multiSelectable) { + if (event.shiftKey) { + this.#updateMultiselect(target); + } else { + target.ariaSelected = `${target.ariaSelected !== 'true'}`; + } + stopEvent = true; + } + } + break; + default: + break; + } + } + if (stopEvent) { + event.stopPropagation(); + event.preventDefault(); + } + if (oldValue !== this.value) { + this.#fireChange(); + } // only change focus if keydown occurred when option has focus // (as opposed to an external text input and if filter has changed if (filter !== this.#filter) { From 764671dad2351b57cc677c5604975e3243bf2bc3 Mon Sep 17 00:00:00 2001 From: Nikki Massaro Kauffman Date: Fri, 28 Jul 2023 11:54:13 -0400 Subject: [PATCH 35/40] feat(listbox): updated listbox to use listbox-controller --- elements/pf-listbox/pf-listbox.ts | 69 ++++++++++++++++--------------- 1 file changed, 35 insertions(+), 34 deletions(-) diff --git a/elements/pf-listbox/pf-listbox.ts b/elements/pf-listbox/pf-listbox.ts index cb4fe2ae83..b04c80f419 100644 --- a/elements/pf-listbox/pf-listbox.ts +++ b/elements/pf-listbox/pf-listbox.ts @@ -2,10 +2,9 @@ import { LitElement, html } from 'lit'; import type { PropertyValues } from 'lit'; import { customElement } from 'lit/decorators/custom-element.js'; import { property } from 'lit/decorators/property.js'; -import { queryAssignedElements } from 'lit/decorators/query-assigned-elements.js'; import { ListboxController, type ListboxFilterMode, type ListboxOrientation } from '@patternfly/pfe-core/controllers/listbox-controller.js'; -import { PfListboxOption } from './pf-listbox-option.js'; -import { PfListboxGroup, type PfListboxGroupOrOption } from './pf-listbox-group.js'; +import './pf-listbox-option.js'; +import './pf-listbox-group.js'; import styles from './pf-listbox.css'; @@ -18,10 +17,6 @@ import styles from './pf-listbox.css'; export class PfListbox extends LitElement { static readonly styles = [styles]; - static isOption(element: PfListboxOption): element is PfListboxOption { - return element instanceof PfListboxOption; - } - /** * filter options that start with a string (case-insensitive) */ @@ -57,16 +52,6 @@ export class PfListbox extends LitElement { */ @property({ reflect: true, attribute: 'orientation', type: String }) orientation: ListboxOrientation = ''; - /** - * value of listbox as a string - */ - @property({ reflect: true, attribute: 'value' }) value?: string = undefined; - - /** - * all slotted listbox options and/or groups of options - */ - @queryAssignedElements() private groupsOrOptions!: PfListboxGroupOrOption[]; - #listbox = new ListboxController(this, { caseSensitive: this.caseSensitive, filterMode: this.filterMode, @@ -75,8 +60,16 @@ export class PfListbox extends LitElement { orientation: this.orientation }); - updated(changed: PropertyValues) { - // console.log(changed); + get options() { + return [...this.querySelectorAll('pf-listbox-option')]; + } + + set value(optionsList: string | null) { + this.#listbox.value = optionsList; + } + + get value() { + return this.#listbox.value; } render() { @@ -88,23 +81,31 @@ export class PfListbox extends LitElement { `; } - focus() { - this.#listbox.focus(); + constructor() { + super(); + this.#listbox.options = this.options; } - get options() { - const extractItems = (group: PfListboxGroupOrOption[]) => group.flatMap((item: PfListboxGroupOrOption) => { - let options: PfListboxOption[]; - if (item instanceof PfListboxGroup) { - const group = item as PfListboxGroup; - options = extractItems(group.options); - } else { - options = [item]; - } - return options; - }); - // console.log(this.querySelectorAll('pf-listbox-option'), extractItems(this.groupsOrOptions)); - return extractItems(this.groupsOrOptions); + updated(changed: PropertyValues) { + if (changed.has('caseSensitive')) { + this.#listbox.caseSensitive = this.caseSensitive; + } + if (changed.has('filterMode')) { + this.#listbox.filterMode = this.filterMode; + } + if (changed.has('matchAnywhere')) { + this.#listbox.matchAnywhere = this.matchAnywhere; + } + if (changed.has('multiSelectable')) { + this.#listbox.multiSelectable = this.multiSelectable; + } + if (changed.has('orientation')) { + this.#listbox.orientation = this.orientation; + } + } + + focus() { + this.#listbox.focus(); } #onSlotchange() { From dae525866fc4a19766fb04d9fdbe8ea518dd451d Mon Sep 17 00:00:00 2001 From: Nikki Massaro Kauffman Date: Fri, 28 Jul 2023 12:30:23 -0400 Subject: [PATCH 36/40] feat(listbox): updated listbox group based on listbox changes --- elements/pf-listbox/pf-listbox-group.ts | 16 +--------------- 1 file changed, 1 insertion(+), 15 deletions(-) diff --git a/elements/pf-listbox/pf-listbox-group.ts b/elements/pf-listbox/pf-listbox-group.ts index d5109e1887..04c4ea546c 100644 --- a/elements/pf-listbox/pf-listbox-group.ts +++ b/elements/pf-listbox/pf-listbox-group.ts @@ -1,13 +1,9 @@ import { LitElement, html } from 'lit'; import { customElement } from 'lit/decorators/custom-element.js'; import { InternalsController } from '@patternfly/pfe-core/controllers/internals-controller.js'; -import { queryAssignedElements } from 'lit/decorators/query-assigned-elements.js'; -import { PfListboxOption } from './pf-listbox-option.js'; import styles from './pf-listbox-group.css'; -export type PfListboxGroupOrOption = PfListboxGroup | PfListboxOption; - /** * Group of options within a listbox * @slot - Place element content here @@ -20,8 +16,6 @@ export class PfListboxGroup extends LitElement { role: 'group' }); - @queryAssignedElements() private _options!: PfListboxOption[]; - override connectedCallback() { this.#internals; } @@ -29,17 +23,9 @@ export class PfListboxGroup extends LitElement { render() { return html` - + `; } - - get options() { - return this._options; - } - - #onSlotchange() { - this.dispatchEvent(new Event('slotchange', { bubbles: true })); - } } declare global { From 7768d4bdcda53661448a7b2f79aeeb310ec9f697 Mon Sep 17 00:00:00 2001 From: Nikki Massaro Kauffman Date: Fri, 28 Jul 2023 13:11:42 -0400 Subject: [PATCH 37/40] chore(simple-list): renamed pf-listbox to match upstream patternfly simple-list --- elements/package.json | 6 +-- elements/pf-listbox/README.md | 11 ----- elements/pf-listbox/demo/filter-modes.html | 43 ------------------- elements/pf-listbox/demo/grouped-options.html | 23 ---------- .../pf-listbox/demo/multiple-selections.html | 19 -------- elements/pf-listbox/demo/orientation.html | 28 ------------ elements/pf-listbox/demo/pf-listbox.html | 19 -------- elements/pf-listbox/test/pf-listbox.spec.ts | 21 --------- elements/pf-simple-list/README.md | 18 ++++++++ .../demo/demo.css | 2 +- .../pf-simple-list/demo/filter-modes.html | 43 +++++++++++++++++++ .../pf-simple-list/demo/grouped-options.html | 23 ++++++++++ .../demo/multiple-selections.html | 19 ++++++++ elements/pf-simple-list/demo/orientation.html | 28 ++++++++++++ .../pf-simple-list/demo/pf-simple-list.html | 19 ++++++++ .../demo/pf-simple-list.js} | 4 +- .../docs/pf-simple-list.md} | 2 +- .../pf-simple-list-group.css} | 0 .../pf-simple-list-group.ts} | 8 ++-- .../pf-simple-list-option.css} | 0 .../pf-simple-list-option.ts} | 8 ++-- .../pf-simple-list.css} | 0 .../pf-simple-list.ts} | 14 +++--- .../test/pf-listbox.e2e.ts | 2 +- .../pf-simple-list/test/pf-listbox.spec.ts | 21 +++++++++ 25 files changed, 194 insertions(+), 187 deletions(-) delete mode 100644 elements/pf-listbox/README.md delete mode 100644 elements/pf-listbox/demo/filter-modes.html delete mode 100644 elements/pf-listbox/demo/grouped-options.html delete mode 100644 elements/pf-listbox/demo/multiple-selections.html delete mode 100644 elements/pf-listbox/demo/orientation.html delete mode 100644 elements/pf-listbox/demo/pf-listbox.html delete mode 100644 elements/pf-listbox/test/pf-listbox.spec.ts create mode 100644 elements/pf-simple-list/README.md rename elements/{pf-listbox => pf-simple-list}/demo/demo.css (81%) create mode 100644 elements/pf-simple-list/demo/filter-modes.html create mode 100644 elements/pf-simple-list/demo/grouped-options.html create mode 100644 elements/pf-simple-list/demo/multiple-selections.html create mode 100644 elements/pf-simple-list/demo/orientation.html create mode 100644 elements/pf-simple-list/demo/pf-simple-list.html rename elements/{pf-listbox/demo/pf-listbox.js => pf-simple-list/demo/pf-simple-list.js} (87%) rename elements/{pf-listbox/docs/pf-listbox.md => pf-simple-list/docs/pf-simple-list.md} (91%) rename elements/{pf-listbox/pf-listbox-group.css => pf-simple-list/pf-simple-list-group.css} (100%) rename elements/{pf-listbox/pf-listbox-group.ts => pf-simple-list/pf-simple-list-group.ts} (77%) rename elements/{pf-listbox/pf-listbox-option.css => pf-simple-list/pf-simple-list-option.css} (100%) rename elements/{pf-listbox/pf-listbox-option.ts => pf-simple-list/pf-simple-list-option.ts} (83%) rename elements/{pf-listbox/pf-listbox.css => pf-simple-list/pf-simple-list.css} (100%) rename elements/{pf-listbox/pf-listbox.ts => pf-simple-list/pf-simple-list.ts} (91%) rename elements/{pf-listbox => pf-simple-list}/test/pf-listbox.e2e.ts (90%) create mode 100644 elements/pf-simple-list/test/pf-listbox.spec.ts diff --git a/elements/package.json b/elements/package.json index 68a7a1fca6..13ab1eeef0 100644 --- a/elements/package.json +++ b/elements/package.json @@ -36,9 +36,9 @@ "./pf-jump-links/pf-jump-links.js": "./pf-jump-links/pf-jump-links.js", "./pf-label/BaseLabel.js": "./pf-label/BaseLabel.js", "./pf-label/pf-label.js": "./pf-label/pf-label.js", - "./pf-listbox/pf-listbox.js": "./pf-listbox/pf-listbox.js", - "./pf-listbox/pf-listbox-group.js": "./pf-listbox/pf-listbox-group.js", - "./pf-listbox/pf-listbox-option.js": "./pf-listbox/pf-listbox-option.js", + "./pf-simple-list/pf-simple-list.js": "./pf-simple-list/pf-simple-list.js", + "./pf-simple-list/pf-simple-list-group.js": "./pf-simple-list/pf-simple-list-group.js", + "./pf-simple-list/pf-simple-list-option.js": "./pf-simple-list/pf-simple-list-option.js", "./pf-modal/pf-modal.js": "./pf-modal/pf-modal.js", "./pf-panel/pf-panel.js": "./pf-panel/pf-panel.js", "./pf-progress-stepper/pf-progress-step.js": "./pf-progress-stepper/pf-progress-step.js", diff --git a/elements/pf-listbox/README.md b/elements/pf-listbox/README.md deleted file mode 100644 index b09287b7d3..0000000000 --- a/elements/pf-listbox/README.md +++ /dev/null @@ -1,11 +0,0 @@ -# Listbox -Add a description of the component here. - -## Usage -Describe how best to use this web component along with best practices. - -```html - - - -``` diff --git a/elements/pf-listbox/demo/filter-modes.html b/elements/pf-listbox/demo/filter-modes.html deleted file mode 100644 index 88a8ee6fa0..0000000000 --- a/elements/pf-listbox/demo/filter-modes.html +++ /dev/null @@ -1,43 +0,0 @@ - - - -

filter-mode="disabled"

-

- Any arrow keys work. - Only one option can be selected. - Filter is disabled.

- - Blue - Green - Magenta - Orange - Purple - Pink - Red - Yellow - - -

filter-mode="required": combobox example

-

- Any arrow keys work. - Only one option can be selected. - Listbox wont appear until input has text. -

- \ No newline at end of file diff --git a/elements/pf-listbox/demo/grouped-options.html b/elements/pf-listbox/demo/grouped-options.html deleted file mode 100644 index 62e0830ce7..0000000000 --- a/elements/pf-listbox/demo/grouped-options.html +++ /dev/null @@ -1,23 +0,0 @@ - - - -

- Any arrow keys work. - Only one option can be selected. - Filter by typing a letter. - Headings are not selectable. -

- - - Breakfast - Eggs - Toast - Waffles - - - Lunch - Salad - Sandwich - Soup - - diff --git a/elements/pf-listbox/demo/multiple-selections.html b/elements/pf-listbox/demo/multiple-selections.html deleted file mode 100644 index ba94169504..0000000000 --- a/elements/pf-listbox/demo/multiple-selections.html +++ /dev/null @@ -1,19 +0,0 @@ - - - -

- Multiple options can be selected. Any arrow keys work. - Shift will toggling off multiple items. - Ctrl+A will toggle selection on all items. - Filter by typing a letter. -

- - Blue - Green - Magenta - Orange - Purple - Pink - Red - Yellow - \ No newline at end of file diff --git a/elements/pf-listbox/demo/orientation.html b/elements/pf-listbox/demo/orientation.html deleted file mode 100644 index 408ad9ebc7..0000000000 --- a/elements/pf-listbox/demo/orientation.html +++ /dev/null @@ -1,28 +0,0 @@ - - - -

orientation="vertical"

-

Only vertical arrow keys work. Filter by typing a letter.

- - Blue - Green - Magenta - Orange - Purple - Pink - Red - Yellow - - -

orientation="horizontal"

-

Only horizontal arrow keys work. Filter by typing a letter.

- - Blue - Green - Magenta - Orange - Purple - Pink - Red - Yellow - \ No newline at end of file diff --git a/elements/pf-listbox/demo/pf-listbox.html b/elements/pf-listbox/demo/pf-listbox.html deleted file mode 100644 index 0a428c9b0e..0000000000 --- a/elements/pf-listbox/demo/pf-listbox.html +++ /dev/null @@ -1,19 +0,0 @@ - - - -

default

-

- Any arrow keys work. - Only one option can be selected. - Filter by typing a letter. -

- - Blue - Green - Magenta - Orange - Purple - Pink - Red - Yellow - \ No newline at end of file diff --git a/elements/pf-listbox/test/pf-listbox.spec.ts b/elements/pf-listbox/test/pf-listbox.spec.ts deleted file mode 100644 index 0601c9f1d8..0000000000 --- a/elements/pf-listbox/test/pf-listbox.spec.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { expect, html } from '@open-wc/testing'; -import { createFixture } from '@patternfly/pfe-tools/test/create-fixture.js'; -import { PfListbox } from '@patternfly/elements/pf-listbox/pf-listbox.js'; - -describe('', function() { - describe('simply instantiating', function() { - let element: PfListbox; - it('imperatively instantiates', function() { - expect(document.createElement('pf-listbox')).to.be.an.instanceof(PfListbox); - }); - - it('should upgrade', async function() { - element = await createFixture(html``); - const klass = customElements.get('pf-listbox'); - expect(element) - .to.be.an.instanceOf(klass) - .and - .to.be.an.instanceOf(PfListbox); - }); - }); -}); diff --git a/elements/pf-simple-list/README.md b/elements/pf-simple-list/README.md new file mode 100644 index 0000000000..01bbe81351 --- /dev/null +++ b/elements/pf-simple-list/README.md @@ -0,0 +1,18 @@ +# Simple List +A simple list provides a list of selectable items that can be shown within a page. Each item is described by a text label. The list may be divided into logical sections by introducing group headers. + +## Usage +A simple list is used when the information presented does not follow a specific pattern, or does not require bullets to differentiate one list item from another. + +```html + + Blue + Green + Magenta + Orange + Purple + Pink + Red + Yellow + +``` diff --git a/elements/pf-listbox/demo/demo.css b/elements/pf-simple-list/demo/demo.css similarity index 81% rename from elements/pf-listbox/demo/demo.css rename to elements/pf-simple-list/demo/demo.css index 7a63e2ef0e..6a5018b783 100644 --- a/elements/pf-listbox/demo/demo.css +++ b/elements/pf-simple-list/demo/demo.css @@ -1,4 +1,4 @@ -[data-demo="pf-listbox"] { +[data-demo="pf-simple-list"] { padding: 20px; } diff --git a/elements/pf-simple-list/demo/filter-modes.html b/elements/pf-simple-list/demo/filter-modes.html new file mode 100644 index 0000000000..d924206499 --- /dev/null +++ b/elements/pf-simple-list/demo/filter-modes.html @@ -0,0 +1,43 @@ + + + +

filter-mode="disabled"

+

+ Any arrow keys work. + Only one option can be selected. + Filter is disabled.

+ + Blue + Green + Magenta + Orange + Purple + Pink + Red + Yellow + + +

filter-mode="required": combobox example

+

+ Any arrow keys work. + Only one option can be selected. + Listbox wont appear until input has text. +

+ \ No newline at end of file diff --git a/elements/pf-simple-list/demo/grouped-options.html b/elements/pf-simple-list/demo/grouped-options.html new file mode 100644 index 0000000000..1a2cd842f2 --- /dev/null +++ b/elements/pf-simple-list/demo/grouped-options.html @@ -0,0 +1,23 @@ + + + +

+ Any arrow keys work. + Only one option can be selected. + Filter by typing a letter. + Headings are not selectable. +

+ + + Breakfast + Eggs + Toast + Waffles + + + Lunch + Salad + Sandwich + Soup + + diff --git a/elements/pf-simple-list/demo/multiple-selections.html b/elements/pf-simple-list/demo/multiple-selections.html new file mode 100644 index 0000000000..ed8dff149e --- /dev/null +++ b/elements/pf-simple-list/demo/multiple-selections.html @@ -0,0 +1,19 @@ + + + +

+ Multiple options can be selected. Any arrow keys work. + Shift will toggling off multiple items. + Ctrl+A will toggle selection on all items. + Filter by typing a letter. +

+ + Blue + Green + Magenta + Orange + Purple + Pink + Red + Yellow + \ No newline at end of file diff --git a/elements/pf-simple-list/demo/orientation.html b/elements/pf-simple-list/demo/orientation.html new file mode 100644 index 0000000000..160aa6d6cb --- /dev/null +++ b/elements/pf-simple-list/demo/orientation.html @@ -0,0 +1,28 @@ + + + +

orientation="vertical"

+

Only vertical arrow keys work. Filter by typing a letter.

+ + Blue + Green + Magenta + Orange + Purple + Pink + Red + Yellow + + +

orientation="horizontal"

+

Only horizontal arrow keys work. Filter by typing a letter.

+ + Blue + Green + Magenta + Orange + Purple + Pink + Red + Yellow + \ No newline at end of file diff --git a/elements/pf-simple-list/demo/pf-simple-list.html b/elements/pf-simple-list/demo/pf-simple-list.html new file mode 100644 index 0000000000..0701759da6 --- /dev/null +++ b/elements/pf-simple-list/demo/pf-simple-list.html @@ -0,0 +1,19 @@ + + + +

default

+

+ Any arrow keys work. + Only one option can be selected. + Filter by typing a letter. +

+ + Blue + Green + Magenta + Orange + Purple + Pink + Red + Yellow + \ No newline at end of file diff --git a/elements/pf-listbox/demo/pf-listbox.js b/elements/pf-simple-list/demo/pf-simple-list.js similarity index 87% rename from elements/pf-listbox/demo/pf-listbox.js rename to elements/pf-simple-list/demo/pf-simple-list.js index bd228bc7d8..d80cd1e5c3 100644 --- a/elements/pf-listbox/demo/pf-listbox.js +++ b/elements/pf-simple-list/demo/pf-simple-list.js @@ -1,7 +1,7 @@ -import '@patternfly/elements/pf-listbox/pf-listbox.js'; +import '@patternfly/elements/pf-simple-list/pf-simple-list.js'; /** - * pf-listbox in a combox box + * pf-simple-list in a combox box * @see {@link https://www.w3.org/WAI/ARIA/apg/patterns/combobox/examples/combobox-autocomplete-both/|WAI ARIA Examples: Combobox with ARIAutocomplete} */ const input = document.querySelector('#text'); diff --git a/elements/pf-listbox/docs/pf-listbox.md b/elements/pf-simple-list/docs/pf-simple-list.md similarity index 91% rename from elements/pf-listbox/docs/pf-listbox.md rename to elements/pf-simple-list/docs/pf-simple-list.md index f3745b57be..e1de71c8b6 100644 --- a/elements/pf-listbox/docs/pf-listbox.md +++ b/elements/pf-simple-list/docs/pf-simple-list.md @@ -1,5 +1,5 @@ {% renderOverview %} - + {% endrenderOverview %} {% band header="Usage" %}{% endband %} diff --git a/elements/pf-listbox/pf-listbox-group.css b/elements/pf-simple-list/pf-simple-list-group.css similarity index 100% rename from elements/pf-listbox/pf-listbox-group.css rename to elements/pf-simple-list/pf-simple-list-group.css diff --git a/elements/pf-listbox/pf-listbox-group.ts b/elements/pf-simple-list/pf-simple-list-group.ts similarity index 77% rename from elements/pf-listbox/pf-listbox-group.ts rename to elements/pf-simple-list/pf-simple-list-group.ts index 04c4ea546c..0e4c8893b6 100644 --- a/elements/pf-listbox/pf-listbox-group.ts +++ b/elements/pf-simple-list/pf-simple-list-group.ts @@ -2,14 +2,14 @@ import { LitElement, html } from 'lit'; import { customElement } from 'lit/decorators/custom-element.js'; import { InternalsController } from '@patternfly/pfe-core/controllers/internals-controller.js'; -import styles from './pf-listbox-group.css'; +import styles from './pf-simple-list-group.css'; /** * Group of options within a listbox * @slot - Place element content here */ -@customElement('pf-listbox-group') -export class PfListboxGroup extends LitElement { +@customElement('pf-simple-list-group') +export class PfSimpleListGroup extends LitElement { static readonly styles = [styles]; #internals = new InternalsController(this, { @@ -30,6 +30,6 @@ export class PfListboxGroup extends LitElement { declare global { interface HTMLElementTagNameMap { - 'pf-listbox-group': PfListboxGroup; + 'pf-simple-list-group': PfSimpleListGroup; } } diff --git a/elements/pf-listbox/pf-listbox-option.css b/elements/pf-simple-list/pf-simple-list-option.css similarity index 100% rename from elements/pf-listbox/pf-listbox-option.css rename to elements/pf-simple-list/pf-simple-list-option.css diff --git a/elements/pf-listbox/pf-listbox-option.ts b/elements/pf-simple-list/pf-simple-list-option.ts similarity index 83% rename from elements/pf-listbox/pf-listbox-option.ts rename to elements/pf-simple-list/pf-simple-list-option.ts index 5fb6c6911a..8441078ada 100644 --- a/elements/pf-listbox/pf-listbox-option.ts +++ b/elements/pf-simple-list/pf-simple-list-option.ts @@ -3,14 +3,14 @@ import { customElement } from 'lit/decorators/custom-element.js'; import { InternalsController } from '@patternfly/pfe-core/controllers/internals-controller.js'; import { getRandomId } from '@patternfly/pfe-core/functions/random.js'; -import styles from './pf-listbox-option.css'; +import styles from './pf-simple-list-option.css'; /** * Option within a listbox * @slot - Place element content here */ -@customElement('pf-listbox-option') -export class PfListboxOption extends LitElement { +@customElement('pf-simple-list-option') +export class PfSimpleListOption extends LitElement { static readonly styles = [styles]; #internals = new InternalsController(this, { @@ -42,6 +42,6 @@ export class PfListboxOption extends LitElement { declare global { interface HTMLElementTagNameMap { - 'pf-listbox-option': PfListboxOption; + 'pf-simple-list-option': PfSimpleListOption; } } diff --git a/elements/pf-listbox/pf-listbox.css b/elements/pf-simple-list/pf-simple-list.css similarity index 100% rename from elements/pf-listbox/pf-listbox.css rename to elements/pf-simple-list/pf-simple-list.css diff --git a/elements/pf-listbox/pf-listbox.ts b/elements/pf-simple-list/pf-simple-list.ts similarity index 91% rename from elements/pf-listbox/pf-listbox.ts rename to elements/pf-simple-list/pf-simple-list.ts index b04c80f419..c774c7ec05 100644 --- a/elements/pf-listbox/pf-listbox.ts +++ b/elements/pf-simple-list/pf-simple-list.ts @@ -3,18 +3,18 @@ import type { PropertyValues } from 'lit'; import { customElement } from 'lit/decorators/custom-element.js'; import { property } from 'lit/decorators/property.js'; import { ListboxController, type ListboxFilterMode, type ListboxOrientation } from '@patternfly/pfe-core/controllers/listbox-controller.js'; -import './pf-listbox-option.js'; -import './pf-listbox-group.js'; +import './pf-simple-list-option.js'; +import './pf-simple-list-group.js'; -import styles from './pf-listbox.css'; +import styles from './pf-simple-list.css'; /** * List of selectable items * @see {@link https://www.w3.org/WAI/ARIA/apg/patterns/listbox/|WAI-ARIA Listbox Pattern} * @slot - Place element content here */ -@customElement('pf-listbox') -export class PfListbox extends LitElement { +@customElement('pf-simple-list') +export class PfSimpleList extends LitElement { static readonly styles = [styles]; /** @@ -61,7 +61,7 @@ export class PfListbox extends LitElement { }); get options() { - return [...this.querySelectorAll('pf-listbox-option')]; + return [...this.querySelectorAll('pf-simple-list-option')]; } set value(optionsList: string | null) { @@ -115,6 +115,6 @@ export class PfListbox extends LitElement { declare global { interface HTMLElementTagNameMap { - 'pf-listbox': PfListbox; + 'pf-simple-list': PfSimpleList; } } diff --git a/elements/pf-listbox/test/pf-listbox.e2e.ts b/elements/pf-simple-list/test/pf-listbox.e2e.ts similarity index 90% rename from elements/pf-listbox/test/pf-listbox.e2e.ts rename to elements/pf-simple-list/test/pf-listbox.e2e.ts index 60afcec218..faa48da84c 100644 --- a/elements/pf-listbox/test/pf-listbox.e2e.ts +++ b/elements/pf-simple-list/test/pf-listbox.e2e.ts @@ -1,7 +1,7 @@ import { test } from '@playwright/test'; import { PfeDemoPage } from '@patternfly/pfe-tools/test/playwright/PfeDemoPage.js'; -const tagName = 'pf-listbox'; +const tagName = 'pf-simple-list'; test.describe(tagName, () => { test('snapshot', async ({ page }) => { diff --git a/elements/pf-simple-list/test/pf-listbox.spec.ts b/elements/pf-simple-list/test/pf-listbox.spec.ts new file mode 100644 index 0000000000..394e32750c --- /dev/null +++ b/elements/pf-simple-list/test/pf-listbox.spec.ts @@ -0,0 +1,21 @@ +import { expect, html } from '@open-wc/testing'; +import { createFixture } from '@patternfly/pfe-tools/test/create-fixture.js'; +import { PfSimpleList } from '@patternfly/elements/pf-simple-list/pf-simple-list.js'; + +describe('', function() { + describe('simply instantiating', function() { + let element: PfSimpleList; + it('imperatively instantiates', function() { + expect(document.createElement('pf-simple-list')).to.be.an.instanceof(PfSimpleList); + }); + + it('should upgrade', async function() { + element = await createFixture(html``); + const klass = customElements.get('pf-simple-list'); + expect(element) + .to.be.an.instanceOf(klass) + .and + .to.be.an.instanceOf(PfSimpleList); + }); + }); +}); From 62d689e44d3766bc3d32d28394c3476806b04ecd Mon Sep 17 00:00:00 2001 From: Nikki Massaro Kauffman Date: Fri, 28 Jul 2023 15:29:10 -0400 Subject: [PATCH 38/40] feat(core): fixed selection issue with listbox-controller --- .../controllers/listbox-controller.ts | 99 +++++++++++-------- 1 file changed, 57 insertions(+), 42 deletions(-) diff --git a/core/pfe-core/controllers/listbox-controller.ts b/core/pfe-core/controllers/listbox-controller.ts index 19f5bd19d5..ca3935ac4c 100644 --- a/core/pfe-core/controllers/listbox-controller.ts +++ b/core/pfe-core/controllers/listbox-controller.ts @@ -44,9 +44,9 @@ export class ListboxController< /** * determines how filtering will be handled: - * - "" (default): will show all options until filter text is not "" - * - "required": will hide all options until filter text is not "" - * - "disabled": will not hide options at all, regardless of filtering + * - "" (default): will show only options that match filter or in no options match will show all options + * - "required": all listbox options are hidden _until_ option matches filter + * - "disabled": all listbox options are visible; ignores filter */ #filterMode: ListboxFilterMode = ''; @@ -75,18 +75,13 @@ export class ListboxController< * */ #options: HTMLElement[] = []; - /** - * all options that will not be hidden by a filter - * */ - #visibleOptions: HTMLElement[] = []; - get activeItem() { const [active] = this.options.filter(option => option.getAttribute('id') === this.#internals.ariaActivedescendant); return active || this.#tabindex.firstItem; } - set filter(filtertext: string) { - this.filter = filtertext; + set filter(filterText: string) { + this.#filter = filterText; this.#onFilterChange(); } @@ -144,27 +139,11 @@ export class ListboxController< set options(options: HTMLElement[]) { const setSize = options.length; - const filterOptions = (option: HTMLElement, posInSet: number) => { + options.forEach((option, posInSet) => { option.ariaSetSize = setSize !== null ? `${setSize}` : null; option.ariaPosInSet = posInSet !== null ? `${posInSet}` : null; - if (this.#filterMode === 'required' && this.#filter === '') { - return false; - } else if (this.#filterMode === 'disabled') { - return true; - } else { - const search = this.#matchAnywhere ? '' : '^'; - const text = option.textContent || ''; - const regex = new RegExp(`${search}${this.#filter}`, this.#caseSensitive ? 'i' : ''); - if (search === '' || text.match(regex)) { - option.removeAttribute('hidden-by-filter'); - } else { - option.setAttribute('hidden-by-filter', 'hidden-by-filter'); - } - return !option.hasAttribute('hidden-by-filter'); - } - }; - this.#tabindex.initItems(this.#visibleOptions); - this.#visibleOptions = options.filter((option, i) => filterOptions(option, i)); + }); + this.#tabindex.initItems(this.visibleOptions); this.#options = options; } @@ -188,7 +167,42 @@ export class ListboxController< } get visibleOptions() { - return this.#visibleOptions; + let matchedOptions = this.options; + if (this.filterMode !== 'disabled') { + if (this.#filterMode === 'required' && this.filter === '') { + matchedOptions = []; + } else { + matchedOptions = this.options.filter(option => { + if (this.filterMode === 'disabled' || this.filter === '*') { + return true; + } else { + const search = this.matchAnywhere ? '' : '^'; + const text = option.textContent || ''; + const regex = new RegExp(`${search}${this.filter}`, this.caseSensitive ? '' : 'i'); + if (this.filter === '' || text.match(regex)) { + return true; + } else { + return false; + } + } + }); + } + } + + // unless filter mode is required, + // ensure there is at least one option showing, + // regardless of matches + if (this.#filterMode !== 'required' && matchedOptions.length < 1) { + matchedOptions = this.options; + } + this.options.forEach(option => { + if (matchedOptions.includes(option)) { + option.removeAttribute('hidden-by-filter'); + } else { + option.setAttribute('hidden-by-filter', 'hidden-by-filter'); + } + }); + return matchedOptions; } constructor(public host: ReactiveControllerHost & HTMLElement, options: ListboxOptions) { @@ -198,7 +212,7 @@ export class ListboxController< }); this.#tabindex = new RovingTabindexController(this.host); this.#caseSensitive = options.caseSensitive || false; - this.#filterMode = options.filterMode || ''; + this.filterMode = options.filterMode || ''; } /** @@ -232,20 +246,17 @@ export class ListboxController< } #updateActiveDescendant() { - let found = false; this.options.forEach(option => { - if (option === this.#tabindex.activeItem) { + if (option === this.#tabindex.activeItem && this.visibleOptions.includes(option)) { this.#internals.ariaActivedescendant = option.id; option.setAttribute('active-descendant', 'active-descendant'); - found = true; } else { + if (this.#internals.ariaActivedescendant === option.id) { + this.#internals.ariaActivedescendant = null; + } option.removeAttribute('active-descendant'); } }); - if (!found) { - this.#tabindex.updateActiveItem(this.#tabindex.firstItem); - this.#internals.ariaActivedescendant = this.#tabindex.firstItem?.id || null; - } this.#updateSingleselect(); } @@ -302,7 +313,7 @@ export class ListboxController< */ #onFilterChange() { const oldValue = this.value; - this.#tabindex.updateItems(this.#visibleOptions); + this.#tabindex.initItems(this.visibleOptions); this.#updateActiveDescendant(); if (oldValue !== this.value) { this.#fireInput(); @@ -402,13 +413,17 @@ export class ListboxController< } } else { switch (event.key) { + case '*': + this.filter = '*'; + stopEvent = true; + break; case 'Backspace': case 'Delete': - this.#filter = this.#filter.slice(0, this.#filter.length - 2); + this.filter = ''; stopEvent = true; break; case event.key?.match(/^[\w]$/)?.input: - this.#filter += event.key?.toLowerCase(); + this.filter = event.key; stopEvent = true; break; case 'Enter': @@ -439,7 +454,7 @@ export class ListboxController< } // only change focus if keydown occurred when option has focus // (as opposed to an external text input and if filter has changed - if (filter !== this.#filter) { + if (filter !== this.filter) { this.#tabindex.focusOnItem(this.activeItem); } } From c3b47e7bbfbb88e6f6fd6df8f935c63d76bf13ba Mon Sep 17 00:00:00 2001 From: Nikki Massaro Kauffman Date: Fri, 28 Jul 2023 15:30:33 -0400 Subject: [PATCH 39/40] feat(simple-list): fixed required filterMode for comboboxes and updated demo --- .../{filter-modes.html => filtering.html} | 34 +++++++- .../pf-simple-list/demo/pf-simple-list.js | 7 +- elements/pf-simple-list/pf-simple-list.ts | 86 +++++++++++-------- 3 files changed, 87 insertions(+), 40 deletions(-) rename elements/pf-simple-list/demo/{filter-modes.html => filtering.html} (54%) diff --git a/elements/pf-simple-list/demo/filter-modes.html b/elements/pf-simple-list/demo/filtering.html similarity index 54% rename from elements/pf-simple-list/demo/filter-modes.html rename to elements/pf-simple-list/demo/filtering.html index d924206499..199ad827e7 100644 --- a/elements/pf-simple-list/demo/filter-modes.html +++ b/elements/pf-simple-list/demo/filtering.html @@ -1,6 +1,38 @@ +

Any arrow keys work. Only one option can be selected. diff --git a/elements/pf-simple-list/demo/pf-simple-list.js b/elements/pf-simple-list/demo/pf-simple-list.js index d80cd1e5c3..a6165ee008 100644 --- a/elements/pf-simple-list/demo/pf-simple-list.js +++ b/elements/pf-simple-list/demo/pf-simple-list.js @@ -6,16 +6,17 @@ import '@patternfly/elements/pf-simple-list/pf-simple-list.js'; */ const input = document.querySelector('#text'); const listbox = document.querySelector('#listbox'); +let filter = ''; if (listbox && input) { - listbox.filter = input.value; input.addEventListener('input', () => { - if (input.value !== listbox.value) { + if (input.value !== listbox.value && filter !== input.value) { listbox.filter = input.value; } }); listbox.addEventListener('change', () => { const val = (listbox.value || ''); - if (val.length > 0) { + if (val.length > 0 && input.value !== val) { + filter = val.slice(0, input.value.length); input.value = val; input.focus(); input.setSelectionRange( diff --git a/elements/pf-simple-list/pf-simple-list.ts b/elements/pf-simple-list/pf-simple-list.ts index c774c7ec05..3b80180f5d 100644 --- a/elements/pf-simple-list/pf-simple-list.ts +++ b/elements/pf-simple-list/pf-simple-list.ts @@ -1,5 +1,5 @@ import { LitElement, html } from 'lit'; -import type { PropertyValues } from 'lit'; +import type { PropertyValueMap, PropertyValues } from 'lit'; import { customElement } from 'lit/decorators/custom-element.js'; import { property } from 'lit/decorators/property.js'; import { ListboxController, type ListboxFilterMode, type ListboxOrientation } from '@patternfly/pfe-core/controllers/listbox-controller.js'; @@ -17,11 +17,6 @@ import styles from './pf-simple-list.css'; export class PfSimpleList extends LitElement { static readonly styles = [styles]; - /** - * filter options that start with a string (case-insensitive) - */ - @property() filter = ''; - /** * whether filtering (if enabled) will be case-sensitive */ @@ -29,9 +24,9 @@ export class PfSimpleList extends LitElement { /** * determines how filtering will be handled: - * - "" (default): will show all options until filter text is not "" - * - "required": will hide all options until filter text is not "" - * - "disabled": will not hide options at all, regardless of filtering + * - "" (default): will show only options that match filter or in no options match will show all options + * - "required": all listbox options are hidden _until_ option matches filter + * - "disabled": all listbox options are visible; ignores filter */ @property({ reflect: true, attribute: 'filter-mode', type: String }) filterMode: ListboxFilterMode = ''; @@ -52,24 +47,30 @@ export class PfSimpleList extends LitElement { */ @property({ reflect: true, attribute: 'orientation', type: String }) orientation: ListboxOrientation = ''; - #listbox = new ListboxController(this, { - caseSensitive: this.caseSensitive, - filterMode: this.filterMode, - matchAnywhere: this.matchAnywhere, - multiSelectable: this.multiSelectable, - orientation: this.orientation - }); + #listbox?: ListboxController; get options() { return [...this.querySelectorAll('pf-simple-list-option')]; } + set filter(filterText: string) { + if (this.#listbox) { + this.#listbox.filter = filterText; + } + } + + get filter() { + return this.#listbox?.filter || ''; + } + set value(optionsList: string | null) { - this.#listbox.value = optionsList; + if (this.#listbox) { + this.#listbox.value = optionsList; + } } get value() { - return this.#listbox.value; + return this.#listbox?.value || null; } render() { @@ -81,35 +82,48 @@ export class PfSimpleList extends LitElement { `; } - constructor() { - super(); + firstUpdated(changed: PropertyValueMap | Map): void { + this.#listbox = new ListboxController(this, { + caseSensitive: this.caseSensitive, + filterMode: this.filterMode, + matchAnywhere: this.matchAnywhere, + multiSelectable: this.multiSelectable, + orientation: this.orientation + }); this.#listbox.options = this.options; + super.firstUpdated(changed); } updated(changed: PropertyValues) { - if (changed.has('caseSensitive')) { - this.#listbox.caseSensitive = this.caseSensitive; - } - if (changed.has('filterMode')) { - this.#listbox.filterMode = this.filterMode; - } - if (changed.has('matchAnywhere')) { - this.#listbox.matchAnywhere = this.matchAnywhere; - } - if (changed.has('multiSelectable')) { - this.#listbox.multiSelectable = this.multiSelectable; - } - if (changed.has('orientation')) { - this.#listbox.orientation = this.orientation; + if (this.#listbox) { + if (changed.has('caseSensitive')) { + this.#listbox.caseSensitive = this.caseSensitive; + } + if (changed.has('filterMode')) { + this.#listbox.filterMode = this.filterMode; + } + if (changed.has('matchAnywhere')) { + this.#listbox.matchAnywhere = this.matchAnywhere; + } + if (changed.has('multiSelectable')) { + this.#listbox.multiSelectable = this.multiSelectable; + } + if (changed.has('orientation')) { + this.#listbox.orientation = this.orientation; + } } } focus() { - this.#listbox.focus(); + if (this.#listbox) { + this.#listbox.focus(); + } } #onSlotchange() { - this.#listbox.options = this.options; + if (this.#listbox) { + this.#listbox.options = this.options; + } } } From c6fe97222f4728c877601edc134e9cfe02917eaa Mon Sep 17 00:00:00 2001 From: Nikki Massaro Kauffman Date: Fri, 28 Jul 2023 15:33:22 -0400 Subject: [PATCH 40/40] feat(simple-list): updated demos --- elements/pf-simple-list/demo/filtering.html | 7 ++++--- elements/pf-simple-list/demo/pf-simple-list.html | 3 ++- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/elements/pf-simple-list/demo/filtering.html b/elements/pf-simple-list/demo/filtering.html index 199ad827e7..c543ddcfba 100644 --- a/elements/pf-simple-list/demo/filtering.html +++ b/elements/pf-simple-list/demo/filtering.html @@ -1,7 +1,7 @@ - +

filter-mode="required": combobox example

Any arrow keys work. Only one option can be selected. - Listbox wont appear until input has text. + Listbox wont appear until input has text. + Type * to show all options.