diff --git a/web/src/components/stories/ak-radio-input.stories.ts b/web/src/components/stories/ak-radio-input.stories.ts new file mode 100644 index 000000000..0b6e9dbc8 --- /dev/null +++ b/web/src/components/stories/ak-radio-input.stories.ts @@ -0,0 +1,65 @@ +import "@goauthentik/elements/messages/MessageContainer"; +import { Meta } from "@storybook/web-components"; + +import { TemplateResult, html } from "lit"; + +import "../ak-radio-input"; +import AkRadioInput from "../ak-radio-input"; + +const metadata: Meta = { + 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,