Files
authentik/web/src/elements/ak-checkbox-group/ak-checkbox-group.ts
Teffen Ellis ad733033d7 web: Packagify live reload plugin. (#14134)
* web: Packagify live reload plugin.

* web: Use shared formatter.

* web: Format.

* web: Use project mode typecheck.

* web: Fix type errors.
2025-04-21 19:07:45 +02:00

234 lines
7.8 KiB
TypeScript

import { AkControlElement } from "@goauthentik/elements/AkControlElement";
import { CustomEmitterElement } from "@goauthentik/elements/utils/eventEmitter";
import { msg } from "@lit/localize";
import { PropertyValues } from "@lit/reactive-element";
import { TemplateResult, css, html } from "lit";
import { customElement, property, queryAll, state } from "lit/decorators.js";
import { map } from "lit/directives/map.js";
import PFCheck from "@patternfly/patternfly/components/Check/check.css";
import PFForm from "@patternfly/patternfly/components/Form/form.css";
import PFBase from "@patternfly/patternfly/patternfly-base.css";
type CheckboxKv = { name: string; label: string | TemplateResult };
type CheckboxPr = [string, string | TemplateResult];
export type CheckboxPair = CheckboxKv | CheckboxPr;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const isCheckboxPr = (t: any): t is CheckboxPr => Array.isArray(t);
function* kvToPairs(items: CheckboxPair[]): Iterable<CheckboxPr> {
for (const item of items) {
yield isCheckboxPr(item) ? item : [item.name, item.label];
}
}
const AkElementWithCustomEvents = CustomEmitterElement(AkControlElement);
/**
* @element ak-checkbox-group
*
* @class CheckboxGroup
*
* @description
* CheckboxGroup renders a collection of checkboxes in a linear list. Multiple
* checkboxes may be picked.
*
* @attr {options} - An array of either `[string, string | TemplateResult]` or
* `{ name: string, label: string | TemplateResult }`. The first value or
* `name` field must be a valid HTML identifier compatible with the HTML
* `name` attribute.
*
* @attr {value} - An array of `name` values corresponding to the options that
* are selected when the element is rendered.
*
* @attr {name} - The name of this element as it will appear in any <form>
* transaction
*
* @attr {required} - If true, and if name is set, and no values are chosen,
* will automatically fail a form `submit` event, providing a warning
* message for any labeling. Note: if `name` is not set, this has no effect,
* and a warn() will appear on the console.
*
* @event {input} - Fired when the component's value has changed. Current value
* as an array of `name` will be in the `Event.detail` field.
*
* @event {change} - Fired when the component's value has changed. Current value
* as an array of `name` will be in the `Event.detail` field.
*
* @csspart checkbox - The div containing the checkbox item and the label
* @csspart label - the label
* @csspart input - the input item
* @csspart checkbox-group - the wrapper div with flexbox control
*
* ## Bigger hit area
*
* Providing properly formatted names for selections allows the element to
* associate the label with the event, so the entire horizontal area from
* checkbox to end-of-label will be the hit area.
*
* ## FormAssociated compliance
*
* If a <form> component is a parent, this component will correctly send its
* values to the form for `x-form-encoded` data; multiples will appear in the
* form of `name=value1&name=value2` format, and must be unpacked into an array
* correctly on the server side according to the CGI (common gateway interface)
* protocol.
*
*/
@customElement("ak-checkbox-group")
export class CheckboxGroup extends AkElementWithCustomEvents {
static get styles() {
return [
PFBase,
PFForm,
PFCheck,
css`
.pf-c-form__group-control {
padding-top: calc(
var(--pf-c-form--m-horizontal__group-label--md--PaddingTop) * 1.3
);
}
`,
];
}
static get formAssociated() {
return true;
}
@property({ type: Array })
options: CheckboxPair[] = [];
@property({ type: Array })
value: string[] = [];
@property({ type: String })
name?: string;
@property({ type: Boolean })
required = false;
@queryAll('input[type="checkbox"]')
checkboxes!: NodeListOf<HTMLInputElement>;
@state()
values: string[] = [];
internals?: ElementInternals;
doneFirstUpdate = false;
json() {
return this.values;
}
private get formValue() {
if (this.name === undefined) {
throw new Error("This cannot be called without having the name set.");
}
const name = this.name;
const entries = new FormData();
this.values.forEach((v) => entries.append(name, v));
return entries;
}
constructor() {
super();
this.onClick = this.onClick.bind(this);
}
onClick(ev: Event) {
ev.stopPropagation();
this.values = Array.from(this.checkboxes)
.filter((checkbox) => checkbox.checked)
.map((checkbox) => checkbox.name);
this.dispatchCustomEvent("change", this.values);
this.dispatchCustomEvent("input", this.values);
if (this.internals) {
this.internals.setValidity({});
if (this.required && this.values.length === 0) {
this.internals.setValidity(
{
valueMissing: true,
},
msg("A selection is required"),
this,
);
}
this.internals.setFormValue(this.formValue);
}
// Doing a write-back so anyone examining the checkbox.value field will get something
// meaningful. Doesn't do anything for anyone, usually, but it's nice to have.
this.value = this.values;
}
willUpdate(changed: PropertyValues<this>) {
if (changed.has("value") && !this.doneFirstUpdate) {
this.doneFirstUpdate = true;
this.values = this.value;
}
}
connectedCallback() {
super.connectedCallback();
this.dataset.akControl = "true";
if (this.name && !this.internals) {
this.internals = this.attachInternals();
}
if (this.internals && this.name) {
this.internals.ariaRequired = this.required ? "true" : "false";
}
if (this.required && !this.internals) {
console.warn(
"Setting `required` on ak-checkbox-group has no effect when the `name` attribute is unset",
);
}
// These are necessary to prevent the input components' own events from
// leaking out. This helps maintain the illusion that this component
// behaves similarly to the multiple selection behavior of, well,
// <select multiple>.
this.addEventListener("input", (ev) => {
ev.stopPropagation();
});
this.addEventListener("change", (ev) => {
ev.stopPropagation();
});
}
render() {
const renderOne = ([name, label]: CheckboxPr) => {
const selected = this.values.includes(name);
const blockFwd = (e: Event) => {
e.stopImmediatePropagation();
};
return html` <div part="checkbox" class="pf-c-check" @click=${this.onClick}>
<input
part="input"
@change=${blockFwd}
@input=${blockFwd}
name="${name}"
class="pf-c-check__input"
type="checkbox"
?checked=${selected}
id="ak-check-${name}"
/>
<label part="label" class="pf-c-check__label" for="ak-check-${name}"
>${label}</label
>
</div>`;
};
return html`<div part="checkbox-group" class="pf-c-form__group-control pf-m-stack">
${map(kvToPairs(this.options), renderOne)}
</div>`;
}
}
declare global {
interface HTMLElementTagNameMap {
"ak-checkbox-group": CheckboxGroup;
}
}