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:
Ken Sternberg 2024-01-04 11:10:41 -08:00
parent 0a0afbe08d
commit b7e7835ce1
9 changed files with 379 additions and 9 deletions

View file

@ -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";',

View file

@ -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.`) : "&nbsp;";
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>

View file

@ -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;

View file

@ -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;
}
`;

View file

@ -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;
}

View file

@ -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>`),
};

View file

@ -17,3 +17,10 @@ export type DataProvision = {
};
export type DataProvider = (page: number) => Promise<DataProvision>;
export interface SearchbarEvent extends CustomEvent {
detail: {
source: string;
value: string;
}
}

View file

@ -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);
};
}

View file

@ -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,
);
}
};