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']; diff --git a/core/pfe-core/controllers/listbox-controller.ts b/core/pfe-core/controllers/listbox-controller.ts new file mode 100644 index 0000000000..ca3935ac4c --- /dev/null +++ b/core/pfe-core/controllers/listbox-controller.ts @@ -0,0 +1,461 @@ +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 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 = ''; + + #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[] = []; + + 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; + this.#onFilterChange(); + } + + get filter() { + return this.#filter; + } + + set caseSensitive(caseSensitive: boolean) { + this.#caseSensitive = caseSensitive; + this.#onFilterChange(); + } + + get caseSensitive() { + return this.#caseSensitive; + } + + set filterMode(filterMode: ListboxFilterMode) { + this.#filterMode = filterMode; + 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; + options.forEach((option, posInSet) => { + option.ariaSetSize = setSize !== null ? `${setSize}` : null; + option.ariaPosInSet = posInSet !== null ? `${posInSet}` : null; + }); + this.#tabindex.initItems(this.visibleOptions); + this.#options = options; + } + + get value() { + const selectedItems = this.options.filter(option=>option.ariaSelected === 'true').map(option => option.textContent?.replace(',', '\\,')); + return selectedItems.join(','); + } + + set value(optionsList: string | null) { + const oldValue = this.value; + const selectedItems = optionsList?.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() { + 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) { + 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); + }); + } + + focus() { + this.#tabindex.focusOnItem(this.activeItem); + } + + #updateActiveDescendant() { + this.options.forEach(option => { + if (option === this.#tabindex.activeItem && this.visibleOptions.includes(option)) { + this.#internals.ariaActivedescendant = option.id; + option.setAttribute('active-descendant', 'active-descendant'); + } else { + if (this.#internals.ariaActivedescendant === option.id) { + this.#internals.ariaActivedescendant = null; + } + option.removeAttribute('active-descendant'); + } + }); + this.#updateSingleselect(); + } + + #updateSingleselect() { + if (!this.multiSelectable) { + this.options.forEach(option => option.ariaSelected = `${option.id === this.#internals.ariaActivedescendant}`); + this.#fireChange(); + } + } + + /** + * 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 + 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 })); + } + + /** + * 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.initItems(this.visibleOptions); + this.#updateActiveDescendant(); + if (oldValue !== this.value) { + this.#fireInput(); + } + } + + /** + * 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) { + this.#tabindex.updateActiveItem(target); + } + 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; + 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(); + } + } + + /** + * 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) { + if (this.#shiftStartingItem && target) { + this.#updateMultiselect(target, this.#shiftStartingItem); + this.#fireChange(); + } + if (event.key === 'Shift') { + this.#shiftStartingItem = null; + } + } + } + + /** + * 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; + } + 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 '*': + this.filter = '*'; + stopEvent = true; + break; + case 'Backspace': + case 'Delete': + this.filter = ''; + stopEvent = true; + break; + case event.key?.match(/^[\w]$/)?.input: + this.filter = event.key; + 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) { + this.#tabindex.focusOnItem(this.activeItem); + } + } +} diff --git a/core/pfe-core/controllers/roving-tabindex-controller.ts b/core/pfe-core/controllers/roving-tabindex-controller.ts index e4a2ca53f1..6bf6203eb9 100644 --- a/core/pfe-core/controllers/roving-tabindex-controller.ts +++ b/core/pfe-core/controllers/roving-tabindex-controller.ts @@ -101,20 +101,28 @@ export class RovingTabindexController< this.#focusableItems.includes(x as ItemType))) { return; } + + 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; @@ -136,24 +144,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; } @@ -168,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; @@ -192,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/package.json b/elements/package.json index 6c6965fe06..13ab1eeef0 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-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-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-simple-list/demo/demo.css b/elements/pf-simple-list/demo/demo.css new file mode 100644 index 0000000000..6a5018b783 --- /dev/null +++ b/elements/pf-simple-list/demo/demo.css @@ -0,0 +1,13 @@ +[data-demo="pf-simple-list"] { + padding: 20px; +} + +#combo { + display: inline-flex; + align-items: stretch; + flex-direction: column; +} + +#listbox { + display: flex; +} \ No newline at end of file diff --git a/elements/pf-simple-list/demo/filtering.html b/elements/pf-simple-list/demo/filtering.html new file mode 100644 index 0000000000..c543ddcfba --- /dev/null +++ b/elements/pf-simple-list/demo/filtering.html @@ -0,0 +1,76 @@ + + + +

case-sensitive

+

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

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

match-anywhere

+

+ Any arrow keys work. + Only one option can be selected. + Filter matches anywhere in option text.

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

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. + Type * to show all options. +

+ \ 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..943aa3feb7 --- /dev/null +++ b/elements/pf-simple-list/demo/pf-simple-list.html @@ -0,0 +1,20 @@ + + + +

default

+

+ Any arrow keys work. + Only one option can be selected. + Filter by typing any letter. Type * + to remove filtering altogether. +

+ + 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.js b/elements/pf-simple-list/demo/pf-simple-list.js new file mode 100644 index 0000000000..a6165ee008 --- /dev/null +++ b/elements/pf-simple-list/demo/pf-simple-list.js @@ -0,0 +1,28 @@ +import '@patternfly/elements/pf-simple-list/pf-simple-list.js'; + +/** + * 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'); +const listbox = document.querySelector('#listbox'); +let filter = ''; +if (listbox && input) { + input.addEventListener('input', () => { + if (input.value !== listbox.value && filter !== input.value) { + listbox.filter = input.value; + } + }); + listbox.addEventListener('change', () => { + const val = (listbox.value || ''); + if (val.length > 0 && input.value !== val) { + filter = val.slice(0, input.value.length); + input.value = val; + input.focus(); + input.setSelectionRange( + listbox.filter.length, + val.length + ); + } + }); +} diff --git a/elements/pf-simple-list/docs/pf-simple-list.md b/elements/pf-simple-list/docs/pf-simple-list.md new file mode 100644 index 0000000000..e1de71c8b6 --- /dev/null +++ b/elements/pf-simple-list/docs/pf-simple-list.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-simple-list/pf-simple-list-group.css b/elements/pf-simple-list/pf-simple-list-group.css new file mode 100644 index 0000000000..da5afe506f --- /dev/null +++ b/elements/pf-simple-list/pf-simple-list-group.css @@ -0,0 +1,14 @@ +:host { + display: block; +} + +slot { + padding: 10px; +} + +slot[name="group-heading"] { + background-color: #f0f0f0; + display: block; + font-weight: bold; + padding: 0 10px; +} \ No newline at end of file diff --git a/elements/pf-simple-list/pf-simple-list-group.ts b/elements/pf-simple-list/pf-simple-list-group.ts new file mode 100644 index 0000000000..0e4c8893b6 --- /dev/null +++ b/elements/pf-simple-list/pf-simple-list-group.ts @@ -0,0 +1,35 @@ +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-simple-list-group.css'; + +/** + * Group of options within a listbox + * @slot - Place element content here + */ +@customElement('pf-simple-list-group') +export class PfSimpleListGroup extends LitElement { + static readonly styles = [styles]; + + #internals = new InternalsController(this, { + role: 'group' + }); + + override connectedCallback() { + this.#internals; + } + + render() { + return html` + + + `; + } +} + +declare global { + interface HTMLElementTagNameMap { + 'pf-simple-list-group': PfSimpleListGroup; + } +} diff --git a/elements/pf-simple-list/pf-simple-list-option.css b/elements/pf-simple-list/pf-simple-list-option.css new file mode 100644 index 0000000000..29396196a2 --- /dev/null +++ b/elements/pf-simple-list/pf-simple-list-option.css @@ -0,0 +1,23 @@ +:host { + display: block; + padding: 2px 10px; +} + +:host([hidden]), +:host([hidden-by-filter]) { + display: none !important; +} + +:host:after, +:host:before { + content: '\2713 '; + opacity: 0; +} + +: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-simple-list/pf-simple-list-option.ts b/elements/pf-simple-list/pf-simple-list-option.ts new file mode 100644 index 0000000000..8441078ada --- /dev/null +++ b/elements/pf-simple-list/pf-simple-list-option.ts @@ -0,0 +1,47 @@ +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-simple-list-option.css'; + +/** + * Option within a listbox + * @slot - Place element content here + */ +@customElement('pf-simple-list-option') +export class PfSimpleListOption extends LitElement { + static readonly styles = [styles]; + + #internals = new InternalsController(this, { + role: 'option' + }); + + override connectedCallback() { + super.connectedCallback(); + this.addEventListener('focus', this.#onFocus); + this.addEventListener('blur', this.#onBlur); + this.id = this.id || getRandomId(); + this.#internals; + } + + render() { + return html` + + `; + } + + #onFocus() { + this.dispatchEvent(new Event('optionfocus', { bubbles: true })); + } + + #onBlur() { + this.dispatchEvent(new Event('optionblur', { bubbles: true })); + } +} + +declare global { + interface HTMLElementTagNameMap { + 'pf-simple-list-option': PfSimpleListOption; + } +} diff --git a/elements/pf-simple-list/pf-simple-list.css b/elements/pf-simple-list/pf-simple-list.css new file mode 100644 index 0000000000..5831311e31 --- /dev/null +++ b/elements/pf-simple-list/pf-simple-list.css @@ -0,0 +1,25 @@ +:host { + display: inline-flex; + border: 1px solid #ddd; + border-radius: 3px; + --_active-descendant-color: transparent; +} + +slot { + display: flex; + flex-direction: column; + width: 100%; +} + +:host([orientation="horizontal"]) { + overflow: scroll; +} + +:host([orientation="horizontal"]) slot { + flex-direction: row; +} + +:host(:focus-within) { + border: 1px solid blue; + --_active-descendant-color: #e1f4fe; +} diff --git a/elements/pf-simple-list/pf-simple-list.ts b/elements/pf-simple-list/pf-simple-list.ts new file mode 100644 index 0000000000..3b80180f5d --- /dev/null +++ b/elements/pf-simple-list/pf-simple-list.ts @@ -0,0 +1,134 @@ +import { LitElement, html } 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'; +import './pf-simple-list-option.js'; +import './pf-simple-list-group.js'; + +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-simple-list') +export class PfSimpleList extends LitElement { + static readonly styles = [styles]; + + /** + * whether filtering (if enabled) will be case-sensitive + */ + @property({ reflect: true, attribute: 'case-sensitive', type: Boolean }) caseSensitive = false; + + /** + * determines how filtering will be handled: + * - "" (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 = ''; + + /** + * 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: ListboxOrientation = ''; + + #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) { + if (this.#listbox) { + this.#listbox.value = optionsList; + } + } + + get value() { + return this.#listbox?.value || null; + } + + render() { + return html` + + + `; + } + + 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 (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() { + if (this.#listbox) { + this.#listbox.focus(); + } + } + + #onSlotchange() { + if (this.#listbox) { + this.#listbox.options = this.options; + } + } +} + +declare global { + interface HTMLElementTagNameMap { + 'pf-simple-list': PfSimpleList; + } +} diff --git a/elements/pf-simple-list/test/pf-listbox.e2e.ts b/elements/pf-simple-list/test/pf-listbox.e2e.ts new file mode 100644 index 0000000000..faa48da84c --- /dev/null +++ b/elements/pf-simple-list/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-simple-list'; + +test.describe(tagName, () => { + test('snapshot', async ({ page }) => { + const componentPage = new PfeDemoPage(page, tagName); + await componentPage.navigate(); + await componentPage.snapshot(); + }); +}); 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); + }); + }); +});