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 `<ak-radio>` 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 `<input type=radio` is pretty bad:
both input elements fire events, one for "losing selection" and
one for "gaining selection".  That can be very confusing to handle,
so we funnel them down in our aggregate radio element to a single
event, "selection changed".

As a quality-of-life measure, I've also set the label to be
unselectable; this means that a click on the label will trigger the
selection event, and a long click will not disable selection or
confuse the selection event generator.
This commit is contained in:
Ken Sternberg 2023-08-02 08:51:24 -07:00
parent b158074d78
commit b7b85d6f5d
4 changed files with 188 additions and 33 deletions

View File

@ -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<AkRadioInput> = {
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` <div style="background: #fff; padding: 2em">
<style>
li {
display: block;
}
p {
margin-top: 1em;
}
</style>
${testItem}
<ul id="radio-message-pad" style="margin-top: 1em"></ul>
</div>`;
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`<ak-radio-input
@input=${displayChange}
label="Test Radio Button"
name="ak-test-radio-input"
help="This is where you would read the help messages"
.options=${testOptions}
></ak-radio-input>
<div>${result}</div>`
);
};

View File

@ -1,7 +1,9 @@
import { AKElement } from "@goauthentik/elements/Base"; 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 { customElement, property } from "lit/decorators.js";
import { map } from "lit/directives/map.js";
import PFForm from "@patternfly/patternfly/components/Form/form.css"; import PFForm from "@patternfly/patternfly/components/Form/form.css";
import PFRadio from "@patternfly/patternfly/components/Radio/radio.css"; import PFRadio from "@patternfly/patternfly/components/Radio/radio.css";
@ -15,7 +17,7 @@ export interface RadioOption<T> {
} }
@customElement("ak-radio") @customElement("ak-radio")
export class Radio<T> extends AKElement { export class Radio<T> extends CustomEmitterElement(AKElement) {
@property({ attribute: false }) @property({ attribute: false })
options: RadioOption<T>[] = []; options: RadioOption<T>[] = [];
@ -36,44 +38,72 @@ export class Radio<T> extends AKElement {
var(--pf-c-form--m-horizontal__group-label--md--PaddingTop) * 1.3 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<string, any>) {
if (!this.value) { if (!this.value) {
const def = this.options.filter((opt) => opt.default); const maybeDefault = this.options.filter((opt) => opt.default);
if (def.length > 0) { if (maybeDefault.length > 0) {
this.value = def[0].value; 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<T>) {
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<T>) {
const elId = `${this.name}-${option.value}`;
const handler = this.buildChangeHandler(option);
return html`<div class="pf-c-radio" @click=${handler}>
<input
class="pf-c-radio__input"
type="radio"
name="${this.name}"
id=${elId}
.checked=${option.value === this.value}
/>
<label class="pf-c-radio__label" for=${elId}>${option.label}</label>
${option.description
? html`<span class="pf-c-radio__description">${option.description}</span>`
: nothing}
</div>`;
}
render() {
return html`<div class="pf-c-form__group-control pf-m-stack"> return html`<div class="pf-c-form__group-control pf-m-stack">
${this.options.map((opt) => { ${map(this.options, this.renderRadio)}
const elId = `${this.name}-${opt.value}`;
return html`<div class="pf-c-radio">
<input
class="pf-c-radio__input"
type="radio"
name="${this.name}"
id=${elId}
@change=${() => {
this.value = opt.value;
this.dispatchEvent(
new CustomEvent("change", {
bubbles: true,
composed: true,
detail: opt.value,
}),
);
}}
.checked=${opt.value === this.value}
/>
<label class="pf-c-radio__label" for=${elId}>${opt.label}</label>
${opt.description
? html`<span class="pf-c-radio__description">${opt.description}</span>`
: html``}
</div>`;
})}
</div>`; </div>`;
} }
} }
export default Radio;

View File

@ -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<Radio<any>> = {
title: "Elements / Basic Radio",
component: "ak-radio",
parameters: {
docs: {
description: {
component: "Our stylized radio button",
},
},
},
};
export default metadata;
const container = (testItem: TemplateResult) =>
html` <div style="background: #fff; padding: 2em">
<style>
li {
display: block;
}
p {
margin-top: 1em;
}
</style>
<ak-message-container></ak-message-container>
${testItem}
<ul id="radio-message-pad" style="margin-top: 1em"></ul>
</div>`;
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`<ak-radio
@input=${displayChange}
name="ak-test-radio-input"
.options=${testOptions}
></ak-radio>`
);
};

View File

@ -9,7 +9,7 @@ export const isCustomEvent = (v: any): v is CustomEvent =>
export function CustomEmitterElement<T extends Constructor<LitElement>>(superclass: T) { export function CustomEmitterElement<T extends Constructor<LitElement>>(superclass: T) {
return class EmmiterElementHandler extends superclass { return class EmmiterElementHandler extends superclass {
dispatchCustomEvent(eventName: string, detail = {}, options = {}) { dispatchCustomEvent(eventName: string, detail: any = {}, options = {}) {
this.dispatchEvent( this.dispatchEvent(
new CustomEvent(eventName, { new CustomEvent(eventName, {
composed: true, composed: true,