web: Add searchbar and enable it for "selected"
"Available" requires a round-trip to the provider level, so that's next.
This commit is contained in:
parent
0a0afbe08d
commit
b7e7835ce1
|
@ -62,6 +62,7 @@ const cssImportMapSources = [
|
|||
'import PFSwitch from "@patternfly/patternfly/components/Switch/switch.css";',
|
||||
'import PFTable from "@patternfly/patternfly/components/Table/table.css";',
|
||||
'import PFTabs from "@patternfly/patternfly/components/Tabs/tabs.css";',
|
||||
'import PFTextInputGroup from "@patternfly/patternfly/components/TextInputGroup/text-input-group.css";',
|
||||
'import PFTitle from "@patternfly/patternfly/components/Title/title.css";',
|
||||
'import PFToggleGroup from "@patternfly/patternfly/components/ToggleGroup/toggle-group.css";',
|
||||
'import PFToolbar from "@patternfly/patternfly/components/Toolbar/toolbar.css";',
|
||||
|
|
|
@ -6,7 +6,7 @@ import {
|
|||
|
||||
import { msg, str } from "@lit/localize";
|
||||
import { PropertyValues, html, nothing } from "lit";
|
||||
import { customElement, property } from "lit/decorators.js";
|
||||
import { customElement, property, state } from "lit/decorators.js";
|
||||
import { createRef, ref } from "lit/directives/ref.js";
|
||||
import type { Ref } from "lit/directives/ref.js";
|
||||
import { unsafeHTML } from "lit/directives/unsafe-html.js";
|
||||
|
@ -21,6 +21,7 @@ import "./components/ak-dual-select-controls";
|
|||
import "./components/ak-dual-select-selected-pane";
|
||||
import { AkDualSelectSelectedPane } from "./components/ak-dual-select-selected-pane";
|
||||
import "./components/ak-pagination";
|
||||
import "./components/ak-search-bar";
|
||||
import {
|
||||
EVENT_ADD_ALL,
|
||||
EVENT_ADD_ONE,
|
||||
|
@ -30,7 +31,7 @@ import {
|
|||
EVENT_REMOVE_ONE,
|
||||
EVENT_REMOVE_SELECTED,
|
||||
} from "./constants";
|
||||
import type { BasePagination, DualSelectPair } from "./types";
|
||||
import type { BasePagination, DualSelectPair, SearchbarEvent } from "./types";
|
||||
|
||||
function alphaSort([_k1, v1, s1]: DualSelectPair, [_k2, v2, s2]: DualSelectPair) {
|
||||
const [l, r] = [s1 !== undefined ? s1 : v1, s2 !== undefined ? s2 : v2];
|
||||
|
@ -82,6 +83,9 @@ export class AkDualSelect extends CustomEmitterElement(CustomListenerElement(AKE
|
|||
@property({ attribute: "selected-label" })
|
||||
selectedLabel = msg("Selected options");
|
||||
|
||||
@state()
|
||||
selectedFilter: string = "";
|
||||
|
||||
availablePane: Ref<AkDualSelectAvailablePane> = createRef();
|
||||
|
||||
selectedPane: Ref<AkDualSelectSelectedPane> = createRef();
|
||||
|
@ -91,6 +95,7 @@ export class AkDualSelect extends CustomEmitterElement(CustomListenerElement(AKE
|
|||
constructor() {
|
||||
super();
|
||||
this.handleMove = this.handleMove.bind(this);
|
||||
this.handleSearch = this.handleSearch.bind(this);
|
||||
[
|
||||
EVENT_ADD_ALL,
|
||||
EVENT_ADD_SELECTED,
|
||||
|
@ -105,6 +110,7 @@ export class AkDualSelect extends CustomEmitterElement(CustomListenerElement(AKE
|
|||
this.addCustomListener("ak-dual-select-move", () => {
|
||||
this.requestUpdate();
|
||||
});
|
||||
this.addCustomListener("ak-search", this.handleSearch);
|
||||
}
|
||||
|
||||
get value() {
|
||||
|
@ -221,6 +227,25 @@ export class AkDualSelect extends CustomEmitterElement(CustomListenerElement(AKE
|
|||
this.selectedPane.value!.clearMove();
|
||||
}
|
||||
|
||||
handleSearch(event: SearchbarEvent) {
|
||||
switch (event.detail.source) {
|
||||
case "ak-dual-list-available-search":
|
||||
return this.handleAvailableSearch(event.detail.value);
|
||||
case "ak-dual-list-selected-search":
|
||||
return this.handleSelectedSearch(event.detail.value);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
handleAvailbleSearch(value: string) {
|
||||
console.log(value);
|
||||
}
|
||||
|
||||
handleSelectedSearch(value: string) {
|
||||
this.selectedFilter = value;
|
||||
this.selectedPane.value!.clearMove();
|
||||
}
|
||||
|
||||
get canAddAll() {
|
||||
// False unless any visible option cannot be found in the selected list, so can still be
|
||||
// added.
|
||||
|
@ -243,9 +268,18 @@ export class AkDualSelect extends CustomEmitterElement(CustomListenerElement(AKE
|
|||
}
|
||||
|
||||
render() {
|
||||
const selected = this.selectedFilter === "" ? this.selected :
|
||||
this.selected.filter(([_k, v, s]) => {
|
||||
const value = s !== undefined ? s : v;
|
||||
if (typeof value !== "string") {
|
||||
throw new Error("Filter only works when there's a string comparator");
|
||||
}
|
||||
return value.toLowerCase().includes(this.selectedFilter.toLowerCase())
|
||||
});
|
||||
|
||||
const availableCount = this.availablePane.value?.toMove.size ?? 0;
|
||||
const selectedCount = this.selectedPane.value?.toMove.size ?? 0;
|
||||
const selectedTotal = this.selected.length;
|
||||
const selectedTotal = selected.length;
|
||||
const availableStatus =
|
||||
availableCount > 0 ? msg(str`${availableCount} items marked to add.`) : " ";
|
||||
const selectedTotalStatus = msg(str`${selectedTotal} items selected.`);
|
||||
|
@ -263,7 +297,7 @@ export class AkDualSelect extends CustomEmitterElement(CustomListenerElement(AKE
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ak-search-bar name="ak-dual-list-available-search"></ak-search-bar>
|
||||
<div class="pf-c-dual-list-selector__status">
|
||||
<span
|
||||
class="pf-c-dual-list-selector__status-text"
|
||||
|
@ -297,7 +331,7 @@ export class AkDualSelect extends CustomEmitterElement(CustomListenerElement(AKE
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ak-search-bar name="ak-dual-list-selected-search"></ak-search-bar>
|
||||
<div class="pf-c-dual-list-selector__status">
|
||||
<span
|
||||
class="pf-c-dual-list-selector__status-text"
|
||||
|
@ -308,7 +342,7 @@ export class AkDualSelect extends CustomEmitterElement(CustomListenerElement(AKE
|
|||
|
||||
<ak-dual-select-selected-pane
|
||||
${ref(this.selectedPane)}
|
||||
.selected=${this.selected.toSorted(alphaSort)}
|
||||
.selected=${selected.toSorted(alphaSort)}
|
||||
></ak-dual-select-selected-pane>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -0,0 +1,65 @@
|
|||
import { AKElement } from "@goauthentik/elements/Base";
|
||||
import { CustomEmitterElement } from "@goauthentik/elements/utils/eventEmitter";
|
||||
|
||||
import { html } from "lit";
|
||||
import { customElement, property } from "lit/decorators.js";
|
||||
import { createRef, ref } from "lit/directives/ref.js";
|
||||
import type { Ref } from "lit/directives/ref.js";
|
||||
|
||||
import { globalVariables, searchStyles } from "./search.css";
|
||||
import PFBase from "@patternfly/patternfly/patternfly-base.css";
|
||||
|
||||
import type { SearchbarEvent } from "../types";
|
||||
|
||||
const styles = [PFBase, globalVariables, searchStyles];
|
||||
|
||||
@customElement("ak-search-bar")
|
||||
export class AkSearchbar extends CustomEmitterElement(AKElement) {
|
||||
static get styles() {
|
||||
return styles;
|
||||
}
|
||||
|
||||
input: Ref<HTMLInputElement> = createRef();
|
||||
|
||||
@property({ type: String, reflect: true })
|
||||
value = "";
|
||||
|
||||
@property({ type: String })
|
||||
name = "";
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.onChange = this.onChange.bind(this);
|
||||
}
|
||||
|
||||
onChange(_event: Event) {
|
||||
if (this.input.value) {
|
||||
this.value = this.input.value.value;
|
||||
}
|
||||
this.dispatchCustomEvent<SearchbarEvent>("ak-search", {
|
||||
source: this.name,
|
||||
value: this.value
|
||||
});
|
||||
}
|
||||
|
||||
render() {
|
||||
return html`
|
||||
<div class="pf-c-text-input-group">
|
||||
<div class="pf-c-text-input-group__main pf-m-icon">
|
||||
<span class="pf-c-text-input-group__text"
|
||||
><span class="pf-c-text-input-group__icon"
|
||||
><i class="fa fa-search fa-fw"></i></span
|
||||
><input
|
||||
type="search"
|
||||
class="pf-c-text-input-group__text-input"
|
||||
${ref(this.input)}
|
||||
@input=${this.onChange}
|
||||
value="${this.value}"
|
||||
/></span>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
export default AkSearchbar;
|
|
@ -0,0 +1,166 @@
|
|||
import { css } from "lit";
|
||||
|
||||
// The `host` information for the Patternfly dual list selector came with some default settings that
|
||||
// we do not want in a web component. By isolating what we *really* use into this collection here,
|
||||
// we get all the benefits of Patternfly without having to wrestle without also having to counteract
|
||||
// those default settings.
|
||||
|
||||
export const globalVariables = css`
|
||||
:host {
|
||||
--pf-c-text-input-group--BackgroundColor: var(--pf-global--BackgroundColor--100);
|
||||
--pf-c-text-input-group__text--before--BorderWidth: var(--pf-global--BorderWidth--sm);
|
||||
--pf-c-text-input-group__text--before--BorderColor: var(--pf-global--BorderColor--300);
|
||||
--pf-c-text-input-group__text--after--BorderBottomWidth: var(--pf-global--BorderWidth--sm);
|
||||
--pf-c-text-input-group__text--after--BorderBottomColor: var(--pf-global--BorderColor--200);
|
||||
--pf-c-text-input-group--hover__text--after--BorderBottomColor: var(
|
||||
--pf-global--primary-color--100
|
||||
);
|
||||
--pf-c-text-input-group__text--focus-within--after--BorderBottomWidth: var(
|
||||
--pf-global--BorderWidth--md
|
||||
);
|
||||
--pf-c-text-input-group__text--focus-within--after--BorderBottomColor: var(
|
||||
--pf-global--primary-color--100
|
||||
);
|
||||
--pf-c-text-input-group__main--first-child--not--text-input--MarginLeft: var(
|
||||
--pf-global--spacer--sm
|
||||
);
|
||||
--pf-c-text-input-group__main--m-icon__text-input--PaddingLeft: var(
|
||||
--pf-global--spacer--xl
|
||||
);
|
||||
--pf-c-text-input-group__main--RowGap: var(--pf-global--spacer--xs);
|
||||
--pf-c-text-input-group__main--ColumnGap: var(--pf-global--spacer--sm);
|
||||
--pf-c-text-input-group--c-chip-group__main--PaddingTop: var(--pf-global--spacer--xs);
|
||||
--pf-c-text-input-group--c-chip-group__main--PaddingRight: var(--pf-global--spacer--xs);
|
||||
--pf-c-text-input-group--c-chip-group__main--PaddingBottom: var(--pf-global--spacer--xs);
|
||||
--pf-c-text-input-group__text-input--PaddingTop: var(--pf-global--spacer--form-element);
|
||||
--pf-c-text-input-group__text-input--PaddingRight: var(--pf-global--spacer--sm);
|
||||
--pf-c-text-input-group__text-input--PaddingBottom: var(--pf-global--spacer--form-element);
|
||||
--pf-c-text-input-group__text-input--PaddingLeft: var(--pf-global--spacer--sm);
|
||||
--pf-c-text-input-group__text-input--MinWidth: 12ch;
|
||||
--pf-c-text-input-group__text-input--m-hint--Color: var(--pf-global--Color--dark-200);
|
||||
--pf-c-text-input-group--placeholder--Color: var(--pf-global--Color--dark-200);
|
||||
--pf-c-text-input-group__icon--Left: var(--pf-global--spacer--sm);
|
||||
--pf-c-text-input-group__icon--Color: var(--pf-global--Color--200);
|
||||
--pf-c-text-input-group__text--hover__icon--Color: var(--pf-global--Color--100);
|
||||
--pf-c-text-input-group__icon--TranslateY: -50%;
|
||||
--pf-c-text-input-group__utilities--MarginRight: var(--pf-global--spacer--sm);
|
||||
--pf-c-text-input-group__utilities--MarginLeft: var(--pf-global--spacer--xs);
|
||||
--pf-c-text-input-group__utilities--child--MarginLeft: var(--pf-global--spacer--xs);
|
||||
--pf-c-text-input-group__utilities--c-button--PaddingRight: var(--pf-global--spacer--xs);
|
||||
--pf-c-text-input-group__utilities--c-button--PaddingLeft: var(--pf-global--spacer--xs);
|
||||
--pf-c-text-input-group--m-disabled--Color: var(--pf-global--disabled-color--100);
|
||||
--pf-c-text-input-group--m-disabled--BackgroundColor: var(--pf-global--disabled-color--300);
|
||||
}
|
||||
`;
|
||||
|
||||
export const searchStyles = css`
|
||||
i.fa,
|
||||
i.fas,
|
||||
i.far,
|
||||
i.fal,
|
||||
i.fab {
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
display: inline-block;
|
||||
font-style: normal;
|
||||
font-variant: normal;
|
||||
text-rendering: auto;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
i.fa-search:before {
|
||||
content: "\f002";
|
||||
}
|
||||
|
||||
.fa,
|
||||
.fas {
|
||||
position: relative;
|
||||
font-family: "Font Awesome 5 Free";
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
i.fa-fw {
|
||||
text-align: center;
|
||||
width: 1.25em;
|
||||
}
|
||||
|
||||
.pf-c-text-input-group {
|
||||
position: relative;
|
||||
display: flex;
|
||||
width: 100%;
|
||||
color: var(--pf-c-text-input-group--Color, inherit);
|
||||
background-color: var(--pf-c-text-input-group--BackgroundColor);
|
||||
}
|
||||
|
||||
.pf-c-text-input-group__main {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--pf-c-text-input-group__main--RowGap)
|
||||
var(--pf-c-text-input-group__main--ColumnGap);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.pf-c-text-input-group__main.pf-m-icon {
|
||||
--pf-c-text-input-group__text-input--PaddingLeft: var(
|
||||
--pf-c-text-input-group__main--m-icon__text-input--PaddingLeft
|
||||
);
|
||||
}
|
||||
.pf-c-text-input-group__text {
|
||||
display: inline-grid;
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-areas: "text-input";
|
||||
flex: 1;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.pf-c-text-input-group__text::before {
|
||||
border: var(--pf-c-text-input-group__text--before--BorderWidth) solid
|
||||
var(--pf-c-text-input-group__text--before--BorderColor);
|
||||
}
|
||||
|
||||
.pf-c-text-input-group__text::after {
|
||||
border-bottom: var(--pf-c-text-input-group__text--after--BorderBottomWidth) solid
|
||||
var(--pf-c-text-input-group__text--after--BorderBottomColor);
|
||||
}
|
||||
|
||||
.pf-c-text-input-group__text::before,
|
||||
.pf-c-text-input-group__text::after {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
pointer-events: none;
|
||||
content: "";
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.pf-c-text-input-group__icon {
|
||||
z-index: 4;
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: var(--pf-c-text-input-group__icon--Left);
|
||||
color: var(--pf-c-text-input-group__icon--Color);
|
||||
transform: translateY(var(--pf-c-text-input-group__icon--TranslateY));
|
||||
}
|
||||
|
||||
.pf-c-text-input-group__text-input,
|
||||
.pf-c-text-input-group__text-input.pf-m-hint {
|
||||
grid-area: text-input;
|
||||
}
|
||||
|
||||
.pf-c-text-input-group__text-input {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
position: relative;
|
||||
width: 100%;
|
||||
min-width: var(--pf-c-text-input-group__text-input--MinWidth);
|
||||
padding: var(--pf-c-text-input-group__text-input--PaddingTop)
|
||||
var(--pf-c-text-input-group__text-input--PaddingRight)
|
||||
var(--pf-c-text-input-group__text-input--PaddingBottom)
|
||||
var(--pf-c-text-input-group__text-input--PaddingLeft);
|
||||
border: 0;
|
||||
}
|
||||
`;
|
|
@ -86,8 +86,12 @@ export const globalVariables = css`
|
|||
--pf-c-dual-list-selector__list-item--m-disabled__item-toggle-icon--Color: var(
|
||||
--pf-global--disabled-color--200
|
||||
);
|
||||
|
||||
/* Unique to authentik */
|
||||
--pf-c-dual-list-selector--selection-desc--FontSize: var(--pf-global--FontSize--xs);
|
||||
--pf-c-dual-list-selector--selection-desc--Color: var(--pf-global--Color--dark-200);
|
||||
--pf-c-dual-list-selector__status--top-padding: var(--pf-global--spacer--xs);
|
||||
--pf-c-dual-list-panels__gap: var(--pf-global--spacer--xs);
|
||||
}
|
||||
`;
|
||||
|
||||
|
@ -104,6 +108,10 @@ export const mainStyles = css`
|
|||
font-weight: var(--pf-c-dual-list-selector__title-text--FontWeight);
|
||||
}
|
||||
|
||||
.pf-c-dual-list-selector__status {
|
||||
padding-top: var(--pf-c-dual-list-selector__status--top-padding);
|
||||
}
|
||||
|
||||
.pf-c-dual-list-selector__status-text {
|
||||
font-size: var(--pf-c-dual-list-selector__status-text--FontSize);
|
||||
color: var(--pf-c-dual-list-selector__status-text--Color);
|
||||
|
@ -117,7 +125,8 @@ export const mainStyles = css`
|
|||
.ak-available-pane,
|
||||
.ak-selected-pane {
|
||||
display: grid;
|
||||
grid-template-rows: auto auto 1fr auto;
|
||||
grid-template-rows: auto auto auto 1fr auto;
|
||||
gap: var(--pf-c-dual-list-panels__gap);
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
|
|
@ -0,0 +1,70 @@
|
|||
import "@goauthentik/elements/messages/MessageContainer";
|
||||
import { debounce } from "@goauthentik/elements/utils/debounce";
|
||||
import { Meta, StoryObj } from "@storybook/web-components";
|
||||
|
||||
import { TemplateResult, html } from "lit";
|
||||
|
||||
import "../components/ak-search-bar";
|
||||
import { AkSearchbar } from "../components/ak-search-bar";
|
||||
|
||||
const metadata: Meta<AkSearchbar> = {
|
||||
title: "Elements / Dual Select / Search Bar",
|
||||
component: "ak-dual-select-search",
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
component: "A search input bar",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
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}
|
||||
<p>Messages received from the button:</p>
|
||||
<div id="action-button-message-pad" style="margin-top: 1em"></div>
|
||||
<div id="action-button-message-pad-2" style="margin-top: 1em"></div>
|
||||
</div>`;
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const displayMessage = (result: any) => {
|
||||
const doc = new DOMParser().parseFromString(`<p><i>Content</i>: ${result}</p>`, "text/xml");
|
||||
const target = document.querySelector("#action-button-message-pad");
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
target!.replaceChildren(doc.firstChild!);
|
||||
};
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const displayMessage2 = (result: any) => {
|
||||
console.log("Huh.");
|
||||
const doc = new DOMParser().parseFromString(`<p><i>Behavior</i>: ${result}</p>`, "text/xml");
|
||||
const target = document.querySelector("#action-button-message-pad-2");
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
target!.replaceChildren(doc.firstChild!);
|
||||
};
|
||||
|
||||
const displayMessage2b = debounce(displayMessage2, 250);
|
||||
|
||||
window.addEventListener("input", (event: Event) => {
|
||||
const message = (event.target as HTMLInputElement | undefined)?.value ?? "-- undefined --";
|
||||
displayMessage(message);
|
||||
displayMessage2b(message);
|
||||
});
|
||||
|
||||
type Story = StoryObj;
|
||||
|
||||
export const Default: Story = {
|
||||
render: () => container(html` <ak-search-bar></ak-search-bar>`),
|
||||
};
|
|
@ -17,3 +17,10 @@ export type DataProvision = {
|
|||
};
|
||||
|
||||
export type DataProvider = (page: number) => Promise<DataProvision>;
|
||||
|
||||
export interface SearchbarEvent extends CustomEvent {
|
||||
detail: {
|
||||
source: string;
|
||||
value: string;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,13 @@
|
|||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
type Callback = (...args: any[]) => any;
|
||||
export function debounce<F extends Callback, T extends object>(callback: F, wait: number) {
|
||||
let timeout: ReturnType<typeof setTimeout>;
|
||||
return (...args: Parameters<F>) => {
|
||||
// @ts-ignore
|
||||
const context: T = this satisfies object;
|
||||
if (timeout !== undefined) {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
timeout = setTimeout(() => callback.apply(context, args), wait);
|
||||
};
|
||||
}
|
|
@ -10,20 +10,25 @@ export const isCustomEvent = (v: any): v is CustomEvent =>
|
|||
export function CustomEmitterElement<T extends Constructor<LitElement>>(superclass: T) {
|
||||
return class EmmiterElementHandler extends superclass {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
dispatchCustomEvent(eventName: string, detail: any = {}, options = {}) {
|
||||
dispatchCustomEvent<F extends CustomEvent>(
|
||||
eventName: string,
|
||||
detail: any = {},
|
||||
options = {},
|
||||
) {
|
||||
const fullDetail =
|
||||
typeof detail === "object" && !Array.isArray(detail)
|
||||
? {
|
||||
...detail,
|
||||
}
|
||||
: detail;
|
||||
|
||||
this.dispatchEvent(
|
||||
new CustomEvent(eventName, {
|
||||
composed: true,
|
||||
bubbles: true,
|
||||
...options,
|
||||
detail: fullDetail,
|
||||
}),
|
||||
}) as F,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
|
Reference in New Issue