diff --git a/elements/package.json b/elements/package.json index 4b159d1d9c..3d3b40a2fd 100644 --- a/elements/package.json +++ b/elements/package.json @@ -36,6 +36,7 @@ "./pf-jump-links/pf-jump-links-list.js": "./pf-jump-links/pf-jump-links-list.js", "./pf-jump-links/pf-jump-links.js": "./pf-jump-links/pf-jump-links.js", "./pf-label/pf-label.js": "./pf-label/pf-label.js", + "./pf-radio/pf-radio.js": "./pf-radio/pf-radio.js", "./pf-select/pf-select.js": "./pf-select/pf-select.js", "./pf-select/pf-listbox.js": "./pf-select/pf-listbox.js", "./pf-select/pf-option-group.js": "./pf-select/pf-option-group.js", diff --git a/elements/pf-radio/README.md b/elements/pf-radio/README.md new file mode 100644 index 0000000000..42617ae474 --- /dev/null +++ b/elements/pf-radio/README.md @@ -0,0 +1,11 @@ +# Radio +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-radio/demo/multiple-groups.html b/elements/pf-radio/demo/multiple-groups.html new file mode 100644 index 0000000000..6cbde72e0e --- /dev/null +++ b/elements/pf-radio/demo/multiple-groups.html @@ -0,0 +1,118 @@ +
+

Basic <pf-radio> group

+
+

Salutation:

+ + + + +
+ +

<pf-radio> group with different name inside same parent

+
+
+

Salutation:

+ + + + + + + +
+ +

Score:

+ + + +
+
+ +

<pf-radio> group with same name inside different parent

+
+
+

Score:

+ + + +
+ +
+

Score:

+ + + +
+
+ +

<pf-radio> group inside pf-card component (component inside shadowroot)

+ +
+

Score:

+ + + + +
+
+ Submit +
+ + + + + diff --git a/elements/pf-radio/demo/pf-radio.html b/elements/pf-radio/demo/pf-radio.html new file mode 100644 index 0000000000..a37f63f17f --- /dev/null +++ b/elements/pf-radio/demo/pf-radio.html @@ -0,0 +1,36 @@ +
+

Select a title

+
+ + + + + + +
+ Submit +
+ + + + + diff --git a/elements/pf-radio/docs/pf-radio.md b/elements/pf-radio/docs/pf-radio.md new file mode 100644 index 0000000000..c2bd3a1b21 --- /dev/null +++ b/elements/pf-radio/docs/pf-radio.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-radio/pf-radio.css b/elements/pf-radio/pf-radio.css new file mode 100644 index 0000000000..5d4e87f30f --- /dev/null +++ b/elements/pf-radio/pf-radio.css @@ -0,0 +1,3 @@ +:host { + display: block; +} diff --git a/elements/pf-radio/pf-radio.ts b/elements/pf-radio/pf-radio.ts new file mode 100644 index 0000000000..96deacbbea --- /dev/null +++ b/elements/pf-radio/pf-radio.ts @@ -0,0 +1,227 @@ +import { LitElement, html, type TemplateResult } from 'lit'; +import { customElement } from 'lit/decorators/custom-element.js'; +import { property } from 'lit/decorators/property.js'; +// import { observes } from '@patternfly/pfe-core/decorators/observes.js'; +import { state } from 'lit/decorators/state.js'; + +import styles from './pf-radio.css'; + +export class PfRadioChangeEvent extends Event { + constructor(public event: Event, public value: string) { + super('change', { bubbles: true }); + } +} + +/** + * Radio + * @slot - Place element content here + */ +@customElement('pf-radio') +export class PfRadio extends LitElement { + static readonly styles: CSSStyleSheet[] = [styles]; + + static formAssociated = true; + + static shadowRootOptions: ShadowRootInit = { + ...LitElement.shadowRootOptions, + delegatesFocus: true, + }; + + @property({ type: Boolean, reflect: true }) + checked = false; + + @property({ type: Boolean, reflect: true }) + disabled = false; + + @property({ reflect: true }) name = ''; + + @property({ reflect: true }) label?: string; + + @property({ reflect: true }) value = ''; + + @state() private focusable = false; + + /** Radio groups: instances.get(groupName).forEach(pfRadio => { ... }) */ + private static instances = new Map>(); + private static radioInstances = new Map>>(); + private static selected = new Map>(); + + static { + globalThis.addEventListener('keydown', (e: KeyboardEvent) => { + switch (e.key) { + case 'Tab': + this.radioInstances.forEach((radioGroup, parentNode) => { + radioGroup.forEach((radioSet, groupName) => { + const selectedNode = this.selected.get(parentNode); + const selected = selectedNode?.get(groupName); + [...radioSet].forEach((radio: PfRadio, i: number, radios: PfRadio[]) => { + // the radio group has a selected element + // it should be the only focusable member of the group + radio.focusable = false; + if (groupName === radio.name) { + if (selected) { + radio.focusable = radio === selected; + // when Shift-tabbing into a group, only the last member should be selected + } else if (e.shiftKey) { + radio.focusable = radio === radios.at(-1); + // otherwise, the first member must be focusable + } else { + radio.focusable = i === 0; + } + } + }); + }); + }); + break; + } + }); + } + + connectedCallback(): void { + super.connectedCallback(); + this.addEventListener('keydown', this.#onKeydown); + + // Function to group radios based on parent node and name + const root: Node = this.getRootNode(); + let radioGroup: NodeListOf; + if (root instanceof Document || root instanceof ShadowRoot) { + radioGroup = root.querySelectorAll('pf-radio'); + radioGroup.forEach((radio: PfRadio) => { + if (radio.parentNode && radio.parentNode === this.parentNode && radio.name === this.name) { + let radioGroupMap = PfRadio.radioInstances.get(radio.parentNode); + if (!radioGroupMap) { + radioGroupMap = new Map>(); + PfRadio.radioInstances.set(radio.parentNode, radioGroupMap); + } + let radioSet: Set | undefined = radioGroupMap.get(this.name); + if (!radioSet) { + radioSet = new Set(); + radioGroupMap.set(this.name, radioSet); + } + radioSet.add(radio); + } + }); + } + } + + // @observes('checked') + // protected checkedChanged(): void { + // if (this.checked) { + // PfRadio.selected.set(this.name, this); + // } + // } + + // @observes('name') + // protected nameChanged(oldName: string): void { + // // reset the map of groupname to selected radio button + // if (PfRadio.selected.get(oldName) === this) { + // PfRadio.selected.delete(oldName); + // PfRadio.selected.set(this.name, this); + // } + // if (typeof oldName === 'string') { + // PfRadio.instances.get(oldName)?.delete(this); + // } + // if (!PfRadio.instances.has(this.name)) { + // PfRadio.instances.set(this.name, new Set()); + // } + // PfRadio.instances.get(this.name)?.add(this); + // } + + disconnectedCallback(): void { + PfRadio.instances.get(this.name)?.delete(this); + if (this.parentNode) { + const parentNode = PfRadio.radioInstances.get(this.parentNode); + if (parentNode) { + PfRadio.radioInstances.delete(this.parentNode); + } + } + super.disconnectedCallback(); + } + + #onChange(event: Event) { + if (!this.checked) { + PfRadio.radioInstances.forEach((radioGroup, parentNode) => { + if (parentNode === this.parentNode) { + radioGroup.forEach((radioSet, groupName) => { + if (this.parentNode && groupName === this.name) { + [...radioSet].forEach((radio: PfRadio) => { + radio.checked = false; + }); + this.checked = true; + this.dispatchEvent(new PfRadioChangeEvent(event, this.value)); + this.#updateSelected(this.parentNode, this, this.name); + } + }); + } + }); + } + } + + #updateSelected(parentNode: ParentNode, radio: PfRadio, name: string) { + if (!PfRadio.selected.has(parentNode)) { + PfRadio.selected.set(parentNode, new Map()); + } + const nodeMap = PfRadio.selected.get(parentNode); + if (nodeMap) { + PfRadio.selected.get(parentNode)?.set(name, radio); + } + } + + // Function to handle keyboard navigation + #onKeydown = (event: KeyboardEvent) => { + const arrowKeys: string[] = ['ArrowDown', 'ArrowRight', 'ArrowUp', 'ArrowLeft']; + if (arrowKeys.includes(event.key)) { + PfRadio.radioInstances.forEach((radioGroup, parentNode) => { + if (parentNode === this.parentNode) { + radioGroup.forEach((radioSet: Set, groupName: string) => { + if (groupName === this.name) { + this.checked = false; + [...radioSet].forEach((radio: PfRadio, index: number, radios: PfRadio[]) => { + if (this.parentNode && radio === event.target) { + const isArrowDownOrRight: boolean = + ['ArrowDown', 'ArrowRight'].includes(event.key); + const isArrowUpOrLeft: boolean = ['ArrowUp', 'ArrowLeft'].includes(event.key); + const direction: 1 | 0 | -1 = isArrowDownOrRight ? 1 : isArrowUpOrLeft ? -1 : 0; + if (direction === 0) { + return; + } + const nextIndex: number = (index + direction + radios.length) % radios.length; + radios[nextIndex].focus(); + radios[nextIndex].checked = true; + // TODO: move this to an @observes + // consider the api of this event. + // do we add the group to it? do we fire from every element on every change? + this.dispatchEvent(new PfRadioChangeEvent(event, radios[nextIndex].value)); + this.#updateSelected(this.parentNode, radios[nextIndex], radios[nextIndex].name); + } + }); + } + }); + } + }); + } + }; + + + // Add a pf component and check if there is any change with the values. + render(): TemplateResult<1> { + return html` + + + `; + } +} + +declare global { + interface HTMLElementTagNameMap { + 'pf-radio': PfRadio; + } +} diff --git a/elements/pf-radio/test/pf-radio.e2e.ts b/elements/pf-radio/test/pf-radio.e2e.ts new file mode 100644 index 0000000000..da6108b886 --- /dev/null +++ b/elements/pf-radio/test/pf-radio.e2e.ts @@ -0,0 +1,25 @@ +import { test } from '@playwright/test'; +import { PfeDemoPage } from '@patternfly/pfe-tools/test/playwright/PfeDemoPage.js'; +import { SSRPage } from '@patternfly/pfe-tools/test/playwright/SSRPage.js'; + +const tagName = 'pf-radio'; + +test.describe(tagName, () => { + test('snapshot', async ({ page }) => { + const componentPage = new PfeDemoPage(page, tagName); + await componentPage.navigate(); + await componentPage.snapshot(); + }); + + test('ssr', async ({ browser }) => { + const fixture = new SSRPage({ + tagName, + browser, + demoDir: new URL('../demo/', import.meta.url), + importSpecifiers: [ + `@patternfly/elements/${tagName}/${tagName}.js`, + ], + }); + await fixture.snapshots(); + }); +}); diff --git a/elements/pf-radio/test/pf-radio.spec.ts b/elements/pf-radio/test/pf-radio.spec.ts new file mode 100644 index 0000000000..a5e7aa9b8b --- /dev/null +++ b/elements/pf-radio/test/pf-radio.spec.ts @@ -0,0 +1,21 @@ +import { expect, html } from '@open-wc/testing'; +import { createFixture } from '@patternfly/pfe-tools/test/create-fixture.js'; +import { PfRadio } from '@patternfly/elements/pf-radio/pf-radio.js'; + +describe('', function() { + describe('simply instantiating', function() { + let element: PfRadio; + it('imperatively instantiates', function() { + expect(document.createElement('pf-radio')).to.be.an.instanceof(PfRadio); + }); + + it('should upgrade', async function() { + element = await createFixture(html``); + const klass = customElements.get('pf-radio'); + expect(element) + .to.be.an.instanceOf(klass) + .and + .to.be.an.instanceOf(PfRadio); + }); + }); +});