From b7b85d6f5d3f8a97b1dc7fd1db924a7bd48d316d Mon Sep 17 00:00:00 2001 From: Ken Sternberg Date: Wed, 2 Aug 2023 08:51:24 -0700 Subject: [PATCH] web: tracked down that weirld bug with the radio. Because radio inputs are actually multiples, the events handling for radio is... wonky. If we want our `` component to be a unitary event dispatcher, saying "This is the element selected," we needed to do more than what was currently being handled. I've intercepted the events that we care about and have placed them into a controller that dictates both the setting and the re-render of the component. This makes it "controlled" (to use the Angular/React/Vue) language and depends on Lit's reactiveElement lifecycle to work, rather than trust the browser, but the browser's experience with respect to the ` = { + title: "Components / Radio Input", + component: "ak-radio-input", + parameters: { + docs: { + description: { + component: "A stylized value control for radio buttons", + }, + }, + }, +}; + +export default metadata; + +const container = (testItem: TemplateResult) => + html`
+ +${testItem} +
    +
    `; + +const testOptions = [ + { label: "Option One", description: html`This is option one.`, value: { funky: 1 } }, + { label: "Option Two", description: html`This is option two.`, value: { invalid: 2 } }, + { label: "Option Three", description: html`This is option three.`, value: { weird: 3 } }, +]; + +export const ButtonWithSuccess = () => { + let result = ""; + + const displayChange = (ev: any) => { + console.log(ev.type, ev.target.name, ev.target.value, ev.detail); + document.getElementById("radio-message-pad")!.innerText = `Value selected: ${JSON.stringify( + ev.target.value, + null, + 2 + )}`; + }; + + return container( + html` +
    ${result}
    ` + ); +}; diff --git a/web/src/elements/forms/Radio.ts b/web/src/elements/forms/Radio.ts index 55b60a3ac..78687b3a2 100644 --- a/web/src/elements/forms/Radio.ts +++ b/web/src/elements/forms/Radio.ts @@ -1,7 +1,9 @@ import { AKElement } from "@goauthentik/elements/Base"; +import { CustomEmitterElement } from "@goauthentik/elements/utils/eventEmitter"; -import { CSSResult, TemplateResult, css, html } from "lit"; +import { CSSResult, TemplateResult, css, html, nothing } from "lit"; import { customElement, property } from "lit/decorators.js"; +import { map } from "lit/directives/map.js"; import PFForm from "@patternfly/patternfly/components/Form/form.css"; import PFRadio from "@patternfly/patternfly/components/Radio/radio.css"; @@ -15,7 +17,7 @@ export interface RadioOption { } @customElement("ak-radio") -export class Radio extends AKElement { +export class Radio extends CustomEmitterElement(AKElement) { @property({ attribute: false }) options: RadioOption[] = []; @@ -36,44 +38,72 @@ export class Radio extends AKElement { var(--pf-c-form--m-horizontal__group-label--md--PaddingTop) * 1.3 ); } + .pf-c-radio label, + .pf-c-radio span { + user-select: none; + } `, ]; } - render(): TemplateResult { + constructor() { + super(); + this.renderRadio = this.renderRadio.bind(this); + this.buildChangeHandler = this.buildChangeHandler.bind(this); + } + + // Set the value if it's not set already. Property changes inside the `willUpdate()` method do + // not trigger an element update. + willUpdate(changedProperties: Map) { if (!this.value) { - const def = this.options.filter((opt) => opt.default); - if (def.length > 0) { - this.value = def[0].value; + const maybeDefault = this.options.filter((opt) => opt.default); + if (maybeDefault.length > 0) { + this.value = maybeDefault[0].value; } } + } + + // When a user clicks on `type="radio"`, *two* events happen in rapid succession: the original + // radio loses its setting, and the selected radio gains its setting. We want radio buttons to + // present a unified event interface, so we prevent the event from triggering if the value is + // already set. + buildChangeHandler(option: RadioOption) { + return (ev: Event) => { + // This is a controlled input. Stop the native event from escaping. + ev.stopPropagation(); + ev.preventDefault(); + if (this.value === option.value) { + return; + } + this.value = option.value; + this.dispatchCustomEvent("change", option.value); + this.dispatchCustomEvent("input", option.value); + }; + } + + renderRadio(option: RadioOption) { + const elId = `${this.name}-${option.value}`; + const handler = this.buildChangeHandler(option); + return html`
    + + + ${option.description + ? html`${option.description}` + : nothing} +
    `; + } + + render() { return html`
    - ${this.options.map((opt) => { - const elId = `${this.name}-${opt.value}`; - return html`
    - { - this.value = opt.value; - this.dispatchEvent( - new CustomEvent("change", { - bubbles: true, - composed: true, - detail: opt.value, - }), - ); - }} - .checked=${opt.value === this.value} - /> - - ${opt.description - ? html`${opt.description}` - : html``} -
    `; - })} + ${map(this.options, this.renderRadio)}
    `; } } + +export default Radio; diff --git a/web/src/elements/forms/stories/Radio.stories.ts b/web/src/elements/forms/stories/Radio.stories.ts new file mode 100644 index 000000000..f6b2c1f52 --- /dev/null +++ b/web/src/elements/forms/stories/Radio.stories.ts @@ -0,0 +1,60 @@ +import "@goauthentik/elements/messages/MessageContainer"; +import { Meta } from "@storybook/web-components"; + +import { TemplateResult, html } from "lit"; + +import "../Radio"; +import Radio from "../Radio"; + +const metadata: Meta> = { + title: "Elements / Basic Radio", + component: "ak-radio", + parameters: { + docs: { + description: { + component: "Our stylized radio button", + }, + }, + }, +}; + +export default metadata; + +const container = (testItem: TemplateResult) => + html`
    + + + ${testItem} +
      +
      `; + +const testOptions = [ + { label: "Option One", description: html`This is option one.`, value: 1 }, + { label: "Option Two", description: html`This is option two.`, value: 2 }, + { label: "Option Three", description: html`This is option three.`, value: 3 }, +]; + +export const BasicRadioElement = () => { + const displayChange = (ev: any) => { + document.getElementById("radio-message-pad")!.innerText = `Value selected: ${JSON.stringify( + ev.target.value, + null, + 2 + )}`; + }; + + return container( + html`` + ); +}; diff --git a/web/src/elements/utils/eventEmitter.ts b/web/src/elements/utils/eventEmitter.ts index 9afa8be3b..121a4507e 100644 --- a/web/src/elements/utils/eventEmitter.ts +++ b/web/src/elements/utils/eventEmitter.ts @@ -9,7 +9,7 @@ export const isCustomEvent = (v: any): v is CustomEvent => export function CustomEmitterElement>(superclass: T) { return class EmmiterElementHandler extends superclass { - dispatchCustomEvent(eventName: string, detail = {}, options = {}) { + dispatchCustomEvent(eventName: string, detail: any = {}, options = {}) { this.dispatchEvent( new CustomEvent(eventName, { composed: true,