web: search select with focus, autocomplete, and progressive search (#10728)

* web: much better focus discipline

Fix the way focus is handled in SearchSelect so that the drop-down isn't grabbing the focus away
from the Input when the user wants to type in their selection.

Because it was broken otherwise!

There's still a bug where it's possible to type in a complete value
*Label*, then leave the component's focus (input and menu) completely,
in which case the Label remains, looking innocent and correct, but
it is *not* reflective of the value as understood by the SearchSelect
API controller.

Gonna try to fix that next.  But I'm saving this as a useful checkpoint.

* .

* root: insert daphne app in correct order

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* web: implement ak-list-select

Creates a new element, ak-list-select, which is a scrollable list that reports when an element is clicked or
selected by the keyboard.

I was hideously over-engineering ak-search-select-menu, and I decided to try something simpler.  This is
that something.  The events we care about are just "change" and "lost focus", and both of those can be
attached by the parent regardless of portaling.

* web: ak-list-select is complete

An extraction of the "menu" and "list" features from SearchSelect
and DualSelect, this is a very simplified version of a visible list
that emulates the Radio/Select behavior (i.e only one from the
collection may be "valued" at the time).  It has no visible indicators
of selection (aside from some highlighting), as it's meant to be
used to present the list rather than be indicative of any state of
the list.

I was seriously over-engineering the menu.  It turns out, it's just
not that difficult after all.  The only things we care about, really,
are "did the user change the selection," "did the user click out
of the list," and "did the user press the escape key."  Those are
pre-existing events (click w/value, blur, and keydown w/keycode,
respectively), so there was no need for me to introduce new custom
events to handler them.

* web: downgrade sonarjs again, because dependabot

Dammit, really need to tell that machine to leave our versions alone.

* web: search select

After a lot of testing and experimenting, it's finally starting to look stable.
What a pain in the neck this has all been.

* web: hold

* web: search select with focus and progressive search

- New component: ak-list-select, which allows you to select from a list of elements, with keyboard
  control.
- New component: ak-portal, which manages elements by moving "slotted" content into a distant
  component, usually one attached to the body, and positions it relative to an existing element.
- ak-search-select-view has been revamped to handle focus, change, input, and blur using
  the browser native event handlers, rather than inventing my own.
- ak-search-select has been turned into a simple driver that manages the view.
- ak-search-select has a new declarative syntax for the most common use case.

I seriously over-engineered this thing, leaning too heavily on outdated knowledge or assumptions
about how the browser works.  The native event handlers attached at the component's borders works
more than fine, and by attaching the event handlers to the portaled component before sending it
off to the slots, the correct handlers get the message.  This revision leverages the browser
a *lot* more, and gets much more effective interaction with much less code.

`<ak-list-select>` is a new component that replaces the ad-hoc menu object of the old SearchSelect.
It is a standalone component that just shows a list, allows someone to navigate that list with the
keyboard or the mouse. By default, it is limited to half the height of the viewport.

The list does not have an indicator of "selected" at this time.  That's just a side effect of it
being developed as an adjunct to search-select.  Its design does not preclude extension.

It has a *lot* of CSS components that can be customized. The properties and events are documented,
but there is only one event: `change`. Consistent with HTML, the value is not sent with the `change`
event; clients are expected to extract it with `change:event.target.value`.

Like all HTML components, it is completely stringly defined; the value is either a string or
undefined.

`<ak-portal>` is a somewhat specialized "portal" component that places an `ak-list-select` in an
object on top of the existing DOM content. It can generalized to do this with any component, though,
and can be extended. It has no events or CSS, since it's "just" managing the portaling relationship.

`<ak-search-select-view>` is the heart of the system.  It takes a collection options and behaves
like an autocomplete component for them.  The only unique event it sends out is `change`, and like
`ak-list-select`, it expects the client to retrieve the value.

Like all HTML components, it is completely stringly defined; the value is either a string or
undefined.

This is the SearchSelect component we've all known to come and love, but with a better pop-up and
cleaner keyboard interaction.  It emits only one event, `ak-change`, which *does* carry the value
with it.

The Storybooks have been updated to show the current version of Search Select, with a (simulated)
API layer as well as more blunt stringly-typed tests for the View layer.  A handful of tests have
been provided to cover a number of edge cases that I discovered during testing.  These run fine
with the `npx` command, and I would love to see them integrated into CI/CD.

The search select fields `renderElement`, `renderDescription`, and `value` properties of
`ak-search-select` have been modified to take a string.  For example, the search for the
list of user looks like this:

```
<ak-search-select
    .fetchObjects=${async (query?: string): Promise<User[]> => {
        const args: CoreUsersListRequest = { ordering: "username" };
        if (query !== undefined) {
            args.search = query;
        }
        const users = await new CoreApi(DEFAULT_CONFIG).coreUsersList(args);
        return users.results;
    }}
    .renderElement=${(user: User): string => {
        return user.username;
    }}
    .renderDescription=${(user: User): TemplateResult => {
        return html`${user.name}`;
    }}
    .value=${(user: User | undefined): string | undefined => {
        return user?.username;
     }}
></ak-search-select>
```

The most common syntax for the these three fields is "just return the string contents of a field by
name," in the case of the description wrapped in a TemplateResult with no DOM components. By
automating that initialization in the `connectedCallback` of the `ak-search-select` component,
this object would look like:

<ak-search-select
    .fetchObjects=${async (query?: string): Promise<User[]> => {
        const args: CoreUsersListRequest = { ordering: "username" };
        if (query !== undefined) {
            args.search = query;
        }
        const users = await new CoreApi(DEFAULT_CONFIG).coreUsersList(args);
        return users.results;
    }}
    .renderElement=${"username"}
    .renderDescription=${"name"}
    .value=${"username"}
></ak-search-select>
```

Due to a limitation in the way properties (such as functions) are interpreted, the syntax
`renderElement="username"` is invalid; it has to be a property expression. Sorry; best I could do.

The old syntax works just fine.  This is a "detect and extend at runtime" enhancement.

* Added comments to the Component Driver Harness.

* Added more safety and comments.

* web: remove string-based access to API; replace with a consolidated "adapter" layer.

Clean out the string-based API layer in SearchSelect.  Break SearchSelect into a
"Base" that does all the work, and then wrap it in two different front-ends:
one that conforms to the old WCAPI, and one with a slightly new WCAPI:

```
<ak-search-select-ez
    .config=${{
        fetchObjects: async (query?: string): Promise<Group[]> => {
            const args: CoreGroupsListRequest = {
                ordering: "name",
                includeUsers: false,
            };
            if (query !== undefined) {
                args.search = query;
            }
            const groups = await new CoreApi(DEFAULT_CONFIG).coreGroupsList(
                args,
            );
            return groups.results;
        },
        renderElement: (group: Group): string => group.name,
        value: (group: Group | undefined): string | undefined => group?.pk,
        selected: (group: Group): boolean => group.pk === this.instance?.group
     }}
    blankable
>
</ak-search-select-ez>
```

* Prettier had opinions. In one case, an important opinion.

* Rename test and fix lint error.

* fix lint

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

---------

Signed-off-by: Jens Langhammer <jens@goauthentik.io>
Co-authored-by: Jens Langhammer <jens@goauthentik.io>
This commit is contained in:
Ken Sternberg
2024-08-14 09:35:00 -07:00
committed by GitHub
parent 6aee405397
commit 752735d480
31 changed files with 2232 additions and 1196 deletions

View File

@ -12,6 +12,10 @@ export default [
{
ignores: [
"dist/",
// don't lint the cache
".wireit/",
// let packages have their own configurations
"packages/",
// don't ever lint node_modules
"node_modules/",
".storybook/*",

211
web/package-lock.json generated
View File

@ -96,7 +96,6 @@
"cross-env": "^7.0.3",
"esbuild": "^0.23.0",
"eslint": "^9.8.0",
"eslint-config-google": "^0.14.0",
"eslint-plugin-lit": "^1.14.0",
"eslint-plugin-sonarjs": "^1.0.4",
"eslint-plugin-wc": "^2.1.0",
@ -8637,9 +8636,8 @@
},
"node_modules/@types/mocha": {
"version": "10.0.7",
"resolved": "https://registry.npmjs.org/@types/mocha/-/mocha-10.0.7.tgz",
"integrity": "sha512-GN8yJ1mNTcFcah/wKEFIJckJx9iJLoMSzWcfRRuxz/Jk+U6KQNnml+etbtxFK8lPjzOw3zp4Ha/kjSst9fsHYw==",
"dev": true
"dev": true,
"license": "MIT"
},
"node_modules/@types/ms": {
"version": "0.7.34",
@ -9063,9 +9061,8 @@
},
"node_modules/@wdio/browser-runner": {
"version": "8.40.2",
"resolved": "https://registry.npmjs.org/@wdio/browser-runner/-/browser-runner-8.40.2.tgz",
"integrity": "sha512-CqWRREUk5VYjPAq1abglHApVntVOuEEf7KKzjO6hmnPuzSDKpplbLX1+131GwweN042UdtghxAjzsGl0+Kk0fQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/plugin-proposal-class-properties": "^7.18.6",
"@originjs/vite-plugin-commonjs": "^1.0.3",
@ -9577,9 +9574,8 @@
},
"node_modules/@wdio/cli": {
"version": "8.40.2",
"resolved": "https://registry.npmjs.org/@wdio/cli/-/cli-8.40.2.tgz",
"integrity": "sha512-/h6Md8yMZqH8Z4XK9wjbDb/YIf9UibDkdaUxWKj5UyLA5PIyrIsLvz42PXH9ArdSq8YCUO1Jl859Z2tKdxwfgA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "^20.1.1",
"@vitest/snapshot": "^2.0.4",
@ -9639,9 +9635,8 @@
},
"node_modules/@wdio/config": {
"version": "8.40.2",
"resolved": "https://registry.npmjs.org/@wdio/config/-/config-8.40.2.tgz",
"integrity": "sha512-RED2vcdX5Zdd6r+K+aWcjK4douxjJY4LP/8YvvavgqM0TURd5PDI0Y7IEz7+BIJOT4Uh+3atZawIN9/3yWFeag==",
"dev": true,
"license": "MIT",
"dependencies": {
"@wdio/logger": "8.38.0",
"@wdio/types": "8.39.0",
@ -9657,9 +9652,8 @@
},
"node_modules/@wdio/config/node_modules/glob": {
"version": "10.4.5",
"resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz",
"integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==",
"dev": true,
"license": "ISC",
"dependencies": {
"foreground-child": "^3.1.0",
"jackspeak": "^3.1.2",
@ -9677,9 +9671,8 @@
},
"node_modules/@wdio/config/node_modules/jackspeak": {
"version": "3.4.3",
"resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz",
"integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==",
"dev": true,
"license": "BlueOak-1.0.0",
"dependencies": {
"@isaacs/cliui": "^8.0.2"
},
@ -9692,15 +9685,13 @@
},
"node_modules/@wdio/config/node_modules/lru-cache": {
"version": "10.4.3",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz",
"integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==",
"dev": true
"dev": true,
"license": "ISC"
},
"node_modules/@wdio/config/node_modules/path-scurry": {
"version": "1.11.1",
"resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz",
"integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==",
"dev": true,
"license": "BlueOak-1.0.0",
"dependencies": {
"lru-cache": "^10.2.0",
"minipass": "^5.0.0 || ^6.0.2 || ^7.0.0"
@ -9714,9 +9705,8 @@
},
"node_modules/@wdio/globals": {
"version": "8.40.2",
"resolved": "https://registry.npmjs.org/@wdio/globals/-/globals-8.40.2.tgz",
"integrity": "sha512-eF47oRE79JY2Cgl0/OCpCLdEAh4eNgU11e4O8fvkPrwbPgW6gcS8xG23ZmNGc3EjhHUZUOzrm7uJ8ymcRPIuoA==",
"dev": true,
"license": "MIT",
"engines": {
"node": "^16.13 || >=18"
},
@ -9727,9 +9717,8 @@
},
"node_modules/@wdio/local-runner": {
"version": "8.40.2",
"resolved": "https://registry.npmjs.org/@wdio/local-runner/-/local-runner-8.40.2.tgz",
"integrity": "sha512-Q6NDtI5IqYHciv+3t6mxwUefmdTdKmXf1aAg/KzJUTDl0RaISb9gKBOBW4pyMbY2ot5yF2mB7rXF93aN2Kmq6Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "^20.1.0",
"@wdio/logger": "8.38.0",
@ -9746,18 +9735,16 @@
},
"node_modules/@wdio/local-runner/node_modules/@types/node": {
"version": "20.14.14",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.14.14.tgz",
"integrity": "sha512-d64f00982fS9YoOgJkAMolK7MN8Iq3TDdVjchbYHdEmjth/DHowx82GnoA+tVUAN+7vxfYUgAzi+JXbKNd2SDQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~5.26.4"
}
},
"node_modules/@wdio/local-runner/node_modules/undici-types": {
"version": "5.26.5",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz",
"integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==",
"dev": true
"dev": true,
"license": "MIT"
},
"node_modules/@wdio/logger": {
"version": "8.38.0",
@ -9786,9 +9773,8 @@
},
"node_modules/@wdio/mocha-framework": {
"version": "8.40.2",
"resolved": "https://registry.npmjs.org/@wdio/mocha-framework/-/mocha-framework-8.40.2.tgz",
"integrity": "sha512-hqhyYzfIe40aAXrC6SQXKqRbrpnf4BSaLlJyxDbMVkge/5du/pCinciz25HmOdfHDhG/t9Ox9q1fNfD6+1IMew==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/mocha": "^10.0.0",
"@types/node": "^20.1.0",
@ -9803,18 +9789,16 @@
},
"node_modules/@wdio/mocha-framework/node_modules/@types/node": {
"version": "20.14.14",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.14.14.tgz",
"integrity": "sha512-d64f00982fS9YoOgJkAMolK7MN8Iq3TDdVjchbYHdEmjth/DHowx82GnoA+tVUAN+7vxfYUgAzi+JXbKNd2SDQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~5.26.4"
}
},
"node_modules/@wdio/mocha-framework/node_modules/undici-types": {
"version": "5.26.5",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz",
"integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==",
"dev": true
"dev": true,
"license": "MIT"
},
"node_modules/@wdio/protocols": {
"version": "8.38.0",
@ -9875,9 +9859,8 @@
},
"node_modules/@wdio/runner": {
"version": "8.40.2",
"resolved": "https://registry.npmjs.org/@wdio/runner/-/runner-8.40.2.tgz",
"integrity": "sha512-alK1n5fHiAG/Pf77O9gb8mjs/KIbLFEedrQsIk2ZMVvgTfmyriKb790Iy64RCYDfZpWQmCvcj9yDARc64IhSGw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "^20.11.28",
"@wdio/config": "8.40.2",
@ -9897,18 +9880,16 @@
},
"node_modules/@wdio/runner/node_modules/@types/node": {
"version": "20.14.14",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.14.14.tgz",
"integrity": "sha512-d64f00982fS9YoOgJkAMolK7MN8Iq3TDdVjchbYHdEmjth/DHowx82GnoA+tVUAN+7vxfYUgAzi+JXbKNd2SDQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~5.26.4"
}
},
"node_modules/@wdio/runner/node_modules/undici-types": {
"version": "5.26.5",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz",
"integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==",
"dev": true
"dev": true,
"license": "MIT"
},
"node_modules/@wdio/spec-reporter": {
"version": "8.39.0",
@ -9962,9 +9943,8 @@
},
"node_modules/@wdio/utils": {
"version": "8.40.2",
"resolved": "https://registry.npmjs.org/@wdio/utils/-/utils-8.40.2.tgz",
"integrity": "sha512-leYcCUSaAdLUCVKqRKNgMCASPOUo/VvOTKETiZ/qpdY2azCBt/KnLugtiycCzakeYg6Kp+VIjx5fkm0M7y4qhA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@puppeteer/browsers": "^1.6.0",
"@wdio/logger": "8.38.0",
@ -11133,9 +11113,8 @@
},
"node_modules/browser-stdout": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/browser-stdout/-/browser-stdout-1.3.1.tgz",
"integrity": "sha512-qhAVI1+Av2X7qelOfAIYwXONood6XlZE/fXaBSmW/T5SzLAmCgzi+eiWE7fUvbHaeNBQH13UftjpXxsfLkMpgw==",
"dev": true
"dev": true,
"license": "ISC"
},
"node_modules/browserslist": {
"version": "4.23.3",
@ -13970,17 +13949,6 @@
"url": "https://eslint.org/donate"
}
},
"node_modules/eslint-config-google": {
"version": "0.14.0",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": ">=0.10.0"
},
"peerDependencies": {
"eslint": ">=5.16.0"
}
},
"node_modules/eslint-plugin-lit": {
"version": "1.14.0",
"dev": true,
@ -14952,9 +14920,8 @@
},
"node_modules/flat": {
"version": "5.0.2",
"resolved": "https://registry.npmjs.org/flat/-/flat-5.0.2.tgz",
"integrity": "sha512-b6suED+5/3rTpUBdG1gupIl8MPFCAMA0QXwmljLhvCUKcUvdE4gWky9zpuGCcXHOsz4J9wPGNWq6OKpmIzz3hQ==",
"dev": true,
"license": "BSD-3-Clause",
"bin": {
"flat": "cli.js"
}
@ -15174,9 +15141,8 @@
},
"node_modules/gaze": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/gaze/-/gaze-1.1.3.tgz",
"integrity": "sha512-BRdNm8hbWzFzWHERTrejLqwHDfS4GibPoq5wjTPIoJHoBtKGPg3xAFfxmM+9ztbXelxcf2hwQcaz1PtmFeue8g==",
"dev": true,
"license": "MIT",
"dependencies": {
"globule": "^1.0.0"
},
@ -15532,9 +15498,8 @@
},
"node_modules/globule": {
"version": "1.3.4",
"resolved": "https://registry.npmjs.org/globule/-/globule-1.3.4.tgz",
"integrity": "sha512-OPTIfhMBh7JbBYDpa5b+Q5ptmMWKwcNcFSR/0c6t8V4f3ZAVBEsKNY37QdVqmLRYSMhOUGYrY0QhSoEpzGr/Eg==",
"dev": true,
"license": "MIT",
"dependencies": {
"glob": "~7.1.1",
"lodash": "^4.17.21",
@ -15546,9 +15511,8 @@
},
"node_modules/globule/node_modules/brace-expansion": {
"version": "1.1.11",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz",
"integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==",
"dev": true,
"license": "MIT",
"dependencies": {
"balanced-match": "^1.0.0",
"concat-map": "0.0.1"
@ -15556,10 +15520,8 @@
},
"node_modules/globule/node_modules/glob": {
"version": "7.1.7",
"resolved": "https://registry.npmjs.org/glob/-/glob-7.1.7.tgz",
"integrity": "sha512-OvD9ENzPLbegENnYP5UUfJIirTg4+XwMWGaQfQTY0JenxNvvIKP3U3/tAQSPIu/lHxXYSZmpXlUHeqAIdKzBLQ==",
"deprecated": "Glob versions prior to v9 are no longer supported",
"dev": true,
"license": "ISC",
"dependencies": {
"fs.realpath": "^1.0.0",
"inflight": "^1.0.4",
@ -15577,9 +15539,8 @@
},
"node_modules/globule/node_modules/minimatch": {
"version": "3.0.8",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.0.8.tgz",
"integrity": "sha512-6FsRAQsxQ61mw+qP1ZzbL9Bc78x2p5OqNgNpnoAFLTrX8n5Kxph0CsnhmKKNXTWjXqU5L0pGPR7hYk+XWZr60Q==",
"dev": true,
"license": "ISC",
"dependencies": {
"brace-expansion": "^1.1.7"
},
@ -18550,9 +18511,8 @@
},
"node_modules/mocha": {
"version": "10.7.0",
"resolved": "https://registry.npmjs.org/mocha/-/mocha-10.7.0.tgz",
"integrity": "sha512-v8/rBWr2VO5YkspYINnvu81inSz2y3ODJrhO175/Exzor1RcEZZkizgE2A+w/CAXXoESS8Kys5E62dOHGHzULA==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-colors": "^4.1.3",
"browser-stdout": "^1.3.1",
@ -18585,9 +18545,8 @@
},
"node_modules/mocha/node_modules/ansi-styles": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
"dev": true,
"license": "MIT",
"dependencies": {
"color-convert": "^2.0.1"
},
@ -18600,15 +18559,13 @@
},
"node_modules/mocha/node_modules/argparse": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
"dev": true
"dev": true,
"license": "Python-2.0"
},
"node_modules/mocha/node_modules/cliui": {
"version": "7.0.4",
"resolved": "https://registry.npmjs.org/cliui/-/cliui-7.0.4.tgz",
"integrity": "sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==",
"dev": true,
"license": "ISC",
"dependencies": {
"string-width": "^4.2.0",
"strip-ansi": "^6.0.0",
@ -18617,9 +18574,8 @@
},
"node_modules/mocha/node_modules/escape-string-regexp": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
"integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=10"
},
@ -18629,9 +18585,8 @@
},
"node_modules/mocha/node_modules/find-up": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz",
"integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==",
"dev": true,
"license": "MIT",
"dependencies": {
"locate-path": "^6.0.0",
"path-exists": "^4.0.0"
@ -18645,10 +18600,8 @@
},
"node_modules/mocha/node_modules/glob": {
"version": "8.1.0",
"resolved": "https://registry.npmjs.org/glob/-/glob-8.1.0.tgz",
"integrity": "sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ==",
"deprecated": "Glob versions prior to v9 are no longer supported",
"dev": true,
"license": "ISC",
"dependencies": {
"fs.realpath": "^1.0.0",
"inflight": "^1.0.4",
@ -18665,18 +18618,16 @@
},
"node_modules/mocha/node_modules/has-flag": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
"integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/mocha/node_modules/js-yaml": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz",
"integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==",
"dev": true,
"license": "MIT",
"dependencies": {
"argparse": "^2.0.1"
},
@ -18686,9 +18637,8 @@
},
"node_modules/mocha/node_modules/locate-path": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
"integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==",
"dev": true,
"license": "MIT",
"dependencies": {
"p-locate": "^5.0.0"
},
@ -18701,9 +18651,8 @@
},
"node_modules/mocha/node_modules/minimatch": {
"version": "5.1.6",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz",
"integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==",
"dev": true,
"license": "ISC",
"dependencies": {
"brace-expansion": "^2.0.1"
},
@ -18713,15 +18662,13 @@
},
"node_modules/mocha/node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"dev": true
"dev": true,
"license": "MIT"
},
"node_modules/mocha/node_modules/p-limit": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz",
"integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"yocto-queue": "^0.1.0"
},
@ -18734,9 +18681,8 @@
},
"node_modules/mocha/node_modules/p-locate": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz",
"integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==",
"dev": true,
"license": "MIT",
"dependencies": {
"p-limit": "^3.0.2"
},
@ -18749,9 +18695,8 @@
},
"node_modules/mocha/node_modules/strip-ansi": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-regex": "^5.0.1"
},
@ -18761,9 +18706,8 @@
},
"node_modules/mocha/node_modules/supports-color": {
"version": "8.1.1",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz",
"integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"has-flag": "^4.0.0"
},
@ -18776,9 +18720,8 @@
},
"node_modules/mocha/node_modules/wrap-ansi": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
"integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-styles": "^4.0.0",
"string-width": "^4.1.0",
@ -18793,9 +18736,8 @@
},
"node_modules/mocha/node_modules/yargs": {
"version": "16.2.0",
"resolved": "https://registry.npmjs.org/yargs/-/yargs-16.2.0.tgz",
"integrity": "sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw==",
"dev": true,
"license": "MIT",
"dependencies": {
"cliui": "^7.0.2",
"escalade": "^3.1.1",
@ -20560,9 +20502,8 @@
},
"node_modules/randombytes": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz",
"integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"safe-buffer": "^5.1.0"
}
@ -21823,9 +21764,8 @@
},
"node_modules/serialize-javascript": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.2.tgz",
"integrity": "sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g==",
"dev": true,
"license": "BSD-3-Clause",
"dependencies": {
"randombytes": "^2.1.0"
}
@ -22755,9 +22695,8 @@
},
"node_modules/stream-buffers": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/stream-buffers/-/stream-buffers-3.0.3.tgz",
"integrity": "sha512-pqMqwQCso0PBJt2PQmDO0cFj0lyqmiwOMiMSkVtRokl7e+ZTRYgDHKnuZNbqjiJXgsg4nuqtD/zxuo9KqTp0Yw==",
"dev": true,
"license": "Unlicense",
"engines": {
"node": ">= 0.10.0"
}
@ -23791,7 +23730,6 @@
},
"node_modules/tree-sitter-json": {
"version": "0.20.2",
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"dependencies": {
@ -23800,7 +23738,6 @@
},
"node_modules/tree-sitter-yaml": {
"version": "0.5.0",
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"dependencies": {
@ -24899,9 +24836,8 @@
},
"node_modules/webdriver": {
"version": "8.40.2",
"resolved": "https://registry.npmjs.org/webdriver/-/webdriver-8.40.2.tgz",
"integrity": "sha512-GoRR94m3yL8tWC9Myf+xIBSdVK8fi1ilZgEZZaYT8+XIWewR02dvrC6rml+/2ZjXUQzeee0RFGDwk9IC7cyYrg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "^20.1.0",
"@types/ws": "^8.5.3",
@ -24921,24 +24857,21 @@
},
"node_modules/webdriver/node_modules/@types/node": {
"version": "20.14.14",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.14.14.tgz",
"integrity": "sha512-d64f00982fS9YoOgJkAMolK7MN8Iq3TDdVjchbYHdEmjth/DHowx82GnoA+tVUAN+7vxfYUgAzi+JXbKNd2SDQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~5.26.4"
}
},
"node_modules/webdriver/node_modules/undici-types": {
"version": "5.26.5",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz",
"integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==",
"dev": true
"dev": true,
"license": "MIT"
},
"node_modules/webdriverio": {
"version": "8.40.2",
"resolved": "https://registry.npmjs.org/webdriverio/-/webdriverio-8.40.2.tgz",
"integrity": "sha512-6yuzUlE064qNuMy98Du1+8QHbXk0st8qTWF7MDZRgYK19FGoy+KhQbaUv1wlFJuFHM0PiAYuduTURL4ub6HvzQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "^20.1.0",
"@wdio/config": "8.40.2",
@ -25133,9 +25066,8 @@
},
"node_modules/workerpool": {
"version": "6.5.1",
"resolved": "https://registry.npmjs.org/workerpool/-/workerpool-6.5.1.tgz",
"integrity": "sha512-Fs4dNYcsdpYSAfVxhnl1L5zTksjvOJxtC5hzMNl+1t9B8hTJTdKDyZ5ju7ztgPy+ft9tBFXoOlDNiOT9WUXZlA==",
"dev": true
"dev": true,
"license": "Apache-2.0"
},
"node_modules/wrap-ansi": {
"version": "6.2.0",
@ -25314,18 +25246,16 @@
},
"node_modules/yargs-parser": {
"version": "20.2.9",
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-20.2.9.tgz",
"integrity": "sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==",
"dev": true,
"license": "ISC",
"engines": {
"node": ">=10"
}
},
"node_modules/yargs-unparser": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/yargs-unparser/-/yargs-unparser-2.0.0.tgz",
"integrity": "sha512-7pRTIA9Qc1caZ0bZ6RYRGbHJthJWuakf+WmHK0rVeLkNrrGhfoabBNdue6kdINI6r4if7ocq9aD/n7xwKOdzOA==",
"dev": true,
"license": "MIT",
"dependencies": {
"camelcase": "^6.0.0",
"decamelize": "^4.0.0",
@ -25338,9 +25268,8 @@
},
"node_modules/yargs-unparser/node_modules/camelcase": {
"version": "6.3.0",
"resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz",
"integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=10"
},
@ -25350,9 +25279,8 @@
},
"node_modules/yargs-unparser/node_modules/decamelize": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/decamelize/-/decamelize-4.0.0.tgz",
"integrity": "sha512-9iE1PgSik9HeIIw2JO94IidnE3eBoQrFJ3w7sFuzSX4DpmZ3v5sZpUiV5Swcf6mQEF+Y0ru8Neo+p+nyh2J+hQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=10"
},
@ -25362,9 +25290,8 @@
},
"node_modules/yargs-unparser/node_modules/is-plain-obj": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-2.1.0.tgz",
"integrity": "sha512-YWnfyRwxL/+SsrWYfOpUtz5b3YD+nyfkHvjbcanzk8zgyO4ASD67uVMRt8k5bM4lLMDnXfriRhOpemw+NfT1eA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
@ -25505,9 +25432,7 @@
}
},
"packages/sfe/node_modules/@goauthentik/api": {
"version": "2024.6.0-1720200294",
"resolved": "https://registry.npmjs.org/@goauthentik/api/-/api-2024.6.0-1720200294.tgz",
"integrity": "sha512-qGpI+0BpsHWlO8waj89q+6SWjVVuRtYqdmpSIrKFsZt9GLNXCvIAvgS5JI1Sq2z1uWK/8kLNZKDocI/XagqMPQ=="
"version": "2024.6.0-1720200294"
}
}
}

View File

@ -84,7 +84,6 @@
"cross-env": "^7.0.3",
"esbuild": "^0.23.0",
"eslint": "^9.8.0",
"eslint-config-google": "^0.14.0",
"eslint-plugin-lit": "^1.14.0",
"eslint-plugin-sonarjs": "^1.0.4",
"eslint-plugin-wc": "^2.1.0",
@ -137,7 +136,9 @@
"format": "wireit",
"lint": "wireit",
"lint:lockfile": "wireit",
"lint:nightmare": "wireit",
"lint:package": "wireit",
"lint:precommit": "wireit",
"lit-analyse": "wireit",
"postinstall": "bash scripts/patch-spotlight.sh",
"precommit": "wireit",

View File

@ -0,0 +1,67 @@
import { execFileSync } from "child_process";
import { ESLint } from "eslint";
import path from "path";
import process from "process";
// Code assumes this script is in the './web/scripts' folder.
const projectRoot = execFileSync("git", ["rev-parse", "--show-toplevel"], {
encoding: "utf8",
}).replace("\n", "");
process.chdir(path.join(projectRoot, "./web"));
const eslintConfig = {
fix: true,
overrideConfig: {
env: {
browser: true,
es2021: true,
},
extends: [
"eslint:recommended",
"plugin:@typescript-eslint/recommended",
"plugin:lit/recommended",
"plugin:custom-elements/recommended",
"plugin:storybook/recommended",
"plugin:sonarjs/recommended",
],
parser: "@typescript-eslint/parser",
parserOptions: {
ecmaVersion: 12,
sourceType: "module",
project: true,
},
plugins: ["@typescript-eslint", "lit", "custom-elements", "sonarjs"],
ignorePatterns: ["authentik-live-tests/**", "./.storybook/**/*.ts"],
rules: {
"indent": "off",
"linebreak-style": ["error", "unix"],
"quotes": ["error", "double", { avoidEscape: true }],
"semi": ["error", "always"],
"@typescript-eslint/ban-ts-comment": "off",
"no-unused-vars": "off",
"sonarjs/cognitive-complexity": ["warn", 9],
"sonarjs/no-duplicate-string": "off",
"sonarjs/no-nested-template-literals": "off",
"@typescript-eslint/no-unused-vars": [
"error",
{
argsIgnorePattern: "^_",
varsIgnorePattern: "^_",
caughtErrorsIgnorePattern: "^_",
},
],
"no-console": ["error", { allow: ["debug", "warn", "error"] }],
},
},
};
const updated = ["./src/", "./build.mjs", "./scripts/*.mjs"];
const eslint = new ESLint(eslintConfig);
const results = await eslint.lintFiles(updated);
const formatter = await eslint.loadFormatter("stylish");
const resultText = formatter.format(results);
const errors = results.reduce((acc, result) => acc + result.errorCount, 0);
console.log(resultText);
process.exit(errors > 1 ? 1 : 0);

View File

@ -0,0 +1,94 @@
import { execFileSync } from "child_process";
import { ESLint } from "eslint";
import path from "path";
import process from "process";
// Code assumes this script is in the './web/scripts' folder.
const projectRoot = execFileSync("git", ["rev-parse", "--show-toplevel"], {
encoding: "utf8",
}).replace("\n", "");
process.chdir(path.join(projectRoot, "./web"));
const eslintConfig = {
fix: true,
overrideConfig: {
env: {
browser: true,
es2021: true,
},
extends: [
"eslint:recommended",
"plugin:@typescript-eslint/recommended",
"plugin:lit/recommended",
"plugin:custom-elements/recommended",
"plugin:storybook/recommended",
"plugin:sonarjs/recommended",
],
parser: "@typescript-eslint/parser",
parserOptions: {
ecmaVersion: 12,
sourceType: "module",
project: true,
},
plugins: ["@typescript-eslint", "lit", "custom-elements", "sonarjs"],
ignorePatterns: ["authentik-live-tests/**", "./.storybook/**/*.ts"],
rules: {
"indent": "off",
"linebreak-style": ["error", "unix"],
"quotes": ["error", "double", { avoidEscape: true }],
"semi": ["error", "always"],
"@typescript-eslint/ban-ts-comment": "off",
"no-unused-vars": "off",
"sonarjs/cognitive-complexity": ["warn", 9],
"sonarjs/no-duplicate-string": "off",
"sonarjs/no-nested-template-literals": "off",
"@typescript-eslint/no-unused-vars": [
"error",
{
argsIgnorePattern: "^_",
varsIgnorePattern: "^_",
caughtErrorsIgnorePattern: "^_",
},
],
"no-console": ["error", { allow: ["debug", "warn", "error"] }],
},
},
};
const porcelainV1 = /^(..)\s+(.*$)/;
const gitStatus = execFileSync("git", ["status", "--porcelain", "."], { encoding: "utf8" });
const statuses = gitStatus.split("\n").reduce((acc, line) => {
const match = porcelainV1.exec(line.replace("\n"));
if (!match) {
return acc;
}
const [status, path] = Array.from(match).slice(1, 3);
return [...acc, [status, path.split("\x00")[0]]];
}, []);
const isModified = /^(M|\?|\s)(M|\?|\s)/;
const modified = (s) => isModified.test(s);
const isCheckable = /\.(ts|js|mjs)$/;
const checkable = (s) => isCheckable.test(s);
const ignored = /\/\.storybook\//;
const notIgnored = (s) => !ignored.test(s);
const updated = statuses.reduce(
(acc, [status, filename]) =>
modified(status) && checkable(filename) && notIgnored(filename)
? [...acc, path.join(projectRoot, filename)]
: acc,
[],
);
const eslint = new ESLint(eslintConfig);
const results = await eslint.lintFiles(updated);
const formatter = await eslint.loadFormatter("stylish");
const resultText = formatter.format(results);
const errors = results.reduce((acc, result) => acc + result.errorCount, 0);
console.log(resultText);
process.exit(errors > 1 ? 1 : 0);

View File

@ -13,6 +13,8 @@ export default [
{
ignores: [
"dist/",
".wireit/",
"packages/",
// don't ever lint node_modules
"node_modules/",
".storybook/*",

View File

@ -13,6 +13,8 @@ export default [
{
ignores: [
"dist/",
".wireit/",
"packages/",
// don't ever lint node_modules
"node_modules/",
".storybook/*",

View File

@ -61,7 +61,7 @@ const mockData = {
};
const metadata: Meta<AkFlowSearch<Flow>> = {
title: "Elements / Select Search / Flow",
title: "Elements / Search Select / Flow",
component: "ak-flow-search",
parameters: {
docs: {

View File

@ -2,9 +2,21 @@ import { TemplateResult } from "lit";
import { Pagination } from "@goauthentik/api";
// Key, Label (string or TemplateResult), (optional) string to sort by. If the sort string is
// missing, it will use the label, which doesn't always work for TemplateResults).
export type DualSelectPair<T = never> = [string, string | TemplateResult, string?, T?];
//
// - key: string
// - label (string or TemplateResult),
// - sortBy (optional) string to sort by. If the sort string is
// - localMapping: The object the key represents; used by some specific apps. API layers may use
// this as a way to find the preset object.
//
// Note that this is a *tuple*, not a record or map!
export type DualSelectPair<T = never> = [
key: string,
label: string | TemplateResult,
sortBy?: string,
localMapping?: T,
];
export type BasePagination = Pick<
Pagination,

View File

@ -0,0 +1,338 @@
import { AKElement } from "@goauthentik/elements/Base.js";
import { bound } from "@goauthentik/elements/decorators/bound.js";
import type {
GroupedOptions,
SelectGroup,
SelectOption,
SelectOptions,
} from "@goauthentik/elements/types.js";
import { randomId } from "@goauthentik/elements/utils/randomId.js";
import { match } from "ts-pattern";
import { PropertyValueMap, css, html, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators.js";
import PFDropdown from "@patternfly/patternfly/components/Dropdown/dropdown.css";
import PFSelect from "@patternfly/patternfly/components/Select/select.css";
import PFBase from "@patternfly/patternfly/patternfly-base.css";
import { groupOptions, isVisibleInScrollRegion } from "./utils.js";
export interface IListSelect {
options: SelectOptions;
value?: string;
emptyOption?: string;
}
/**
* @class ListSelect
* @element ak-list-select
*
* authentik scrolling list select element
*
* Provides a menu of elements to be used for selection.
*
* - @prop options (SelectOption[]): The options to display.
* - @attr value (string): the current value of the Component
* - @attr emptyOption (string): if defined, the component can be `undefined` and will
* display this string at the top.
*
* - @fires change: When the value of the element has changed
*
* - @part ak-list-select-wrapper: the `<div>` that contains the whole
* - @part ak-list-select: the `<ul>` that defines the list. This is the component
* to target if you want to change the max height.
* - @part ak-list-select-option: The `<li>` items of the list
* - @part ak-list-select-button: The `<button>` element of an item.
* - @part ak-list-select-desc: The description element of the list
* - @part ak-list-select-group: A section of a grouped list.
* - @part ak-list-select-title: The title of a group
*/
@customElement("ak-list-select")
export class ListSelect extends AKElement implements IListSelect {
static get styles() {
return [
PFBase,
PFDropdown,
PFSelect,
css`
:host {
overflow: visible;
z-index: 9999;
}
:host([hidden]) {
display: none;
}
.pf-c-dropdown__menu {
max-height: 50vh;
overflow-y: auto;
width: 100%;
}
`,
];
}
/**
* See the search options type, described in the `./types` file, for the relevant types.
*
* @prop
*/
@property({ type: Array, attribute: false })
set options(options: SelectOptions) {
this._options = groupOptions(options);
}
get options() {
return this._options;
}
_options!: GroupedOptions;
/**
* The current value of the menu.
*
* @prop
*/
@property({ type: String, reflect: true })
value?: string;
/**
* The string representation that means an empty option. If not present, no empty option is
* possible.
*
* @prop
*/
@property()
emptyOption?: string;
// We have two different states that we're tracking in this component: the `value`, which is the
// element that is currently selected according to the client, and the `index`, which is the
// element that is being tracked for keyboard interaction. On a click, the index points to the
// value element; on Keydown.Enter, the value becomes whatever the index points to.
@state()
indexOfFocusedItem = 0;
@query("#ak-list-select-list")
ul!: HTMLUListElement;
get json(): string {
return this.value ?? "";
}
public constructor() {
super();
this.addEventListener("focus", this.onFocus);
this.addEventListener("blur", this.onBlur);
}
public override connectedCallback() {
super.connectedCallback();
this.setAttribute("data-ouia-component-type", "ak-menu-select");
this.setAttribute("data-ouia-component-id", this.getAttribute("id") || randomId());
this.setIndexOfFocusedItemFromValue();
this.highlightFocusedItem();
}
public get hasFocus() {
return this.renderRoot.contains(document.activeElement) || document.activeElement === this;
}
private get displayedElements(): HTMLElement[] {
return Array.from(this.renderRoot.querySelectorAll(".ak-select-item"));
}
public get currentElement(): HTMLElement | undefined {
const curIndex = this.indexOfFocusedItem;
return curIndex < 0 || curIndex > this.displayedElements.length - 1
? undefined
: this.displayedElements[curIndex];
}
private setIndexOfFocusedItemFromValue() {
const index = this.displayedElements.findIndex((element) => {
return element.getAttribute("value") === this.value;
});
const elementCount = this.displayedElements.length;
const checkIndex = () => (index === -1 ? 0 : index);
return elementCount === 0 ? -1 : checkIndex();
}
private highlightFocusedItem() {
this.displayedElements.forEach((item) => {
item.classList.remove("ak-highlight-item");
item.removeAttribute("aria-selected");
item.tabIndex = -1;
});
const currentElement = this.currentElement;
if (!currentElement) {
return;
}
currentElement.classList.add("ak-highlight-item");
// This is currently a radio emulation; "selected" is true here.
// If this were a checkbox emulation (i.e. multi), "checked" would be appropriate.
currentElement.setAttribute("aria-selected", "true");
currentElement.scrollIntoView({ block: "center", behavior: "smooth" });
}
@bound
onFocus() {
// Allow the event to propagate.
this.currentElement?.focus();
this.addEventListener("keydown", this.onKeydown);
}
@bound
onBlur() {
// Allow the event to propagate.
this.removeEventListener("keydown", this.onKeydown);
this.indexOfFocusedItem = 0;
}
@bound
onClick(value: string | undefined) {
// let the click through, but include the change event.
this.value = value;
this.setIndexOfFocusedItemFromValue();
this.dispatchEvent(new Event("change", { bubbles: true, composed: true })); // prettier-ignore
}
@bound
onKeydown(event: KeyboardEvent) {
const key = event.key;
const lastItem = this.displayedElements.length - 1;
const current = this.indexOfFocusedItem;
const updateIndex = (pos: number) => {
event.preventDefault();
this.indexOfFocusedItem = pos;
this.highlightFocusedItem();
this.currentElement?.focus();
};
const setValueAndDispatch = () => {
event.preventDefault();
this.value = this.currentElement?.getAttribute("value") ?? undefined;
this.dispatchEvent(new Event("change", { bubbles: true, composed: true })); // prettier-ignore
};
const pageBy = (direction: number) => {
const visibleElementCount =
this.displayedElements.filter((element) =>
isVisibleInScrollRegion(element, this.ul),
).length - 1;
return visibleElementCount * direction + current;
};
match({ key })
.with({ key: "ArrowDown" }, () => updateIndex(Math.min(current + 1, lastItem)))
.with({ key: "ArrowUp" }, () => updateIndex(Math.max(current - 1, 0)))
.with({ key: "PageDown" }, () => updateIndex(Math.min(pageBy(1), lastItem)))
.with({ key: "PageUp" }, () => updateIndex(Math.max(pageBy(-1), 0)))
.with({ key: "Home" }, () => updateIndex(0))
.with({ key: "End" }, () => updateIndex(lastItem))
.with({ key: " " }, () => setValueAndDispatch())
.with({ key: "Enter" }, () => setValueAndDispatch());
}
public override performUpdate() {
this.removeAttribute("data-ouia-component-safe");
super.performUpdate();
}
public override updated(changed: PropertyValueMap<this>) {
super.updated(changed);
this.setAttribute("data-ouia-component-safe", "true");
}
private renderEmptyMenuItem() {
return html`<li role="option" class="ak-select-item" part="ak-list-select-option">
<button
class="pf-c-dropdown__menu-item"
role="option"
tabindex="0"
@click=${() => this.onClick(undefined)}
part="ak-list-select-button"
>
${this.emptyOption}
</button>
</li>`;
}
private renderMenuItems(options: SelectOption[]) {
return options.map(
([value, label, desc]: SelectOption) => html`
<li
role="option"
value=${value}
class="ak-select-item"
part="ak-list-select-option"
>
<button
class="pf-c-dropdown__menu-item pf-m-description"
value="${value}"
tabindex="0"
@click=${() => this.onClick(value)}
part="ak-list-select-button"
>
<div class="pf-c-dropdown__menu-item-main" part="ak-list-select-label">
${label}
</div>
${desc
? html`<div
class="pf-c-dropdown__menu-item-description"
part="ak-list-select-desc"
>
${desc}
</div>`
: nothing}
</button>
</li>
`,
);
}
private renderMenuGroups(optionGroups: SelectGroup[]) {
return optionGroups.map(
({ name, options }) => html`
<section class="pf-c-dropdown__group" part="ak-list-select-group">
<h1 class="pf-c-dropdown__group-title" part="ak-list-select-group-title">
${name}
</h1>
<ul>
${this.renderMenuItems(options)}
</ul>
</section>
`,
);
}
public override render() {
return html`<div
class="pf-c-dropdown pf-m-expanded"
tabindex="1"
part="ak-list-select-wrapper"
>
<ul
class="pf-c-dropdown__menu pf-m-static"
id="ak-list-select-list"
role="listbox"
tabindex="0"
part="ak-list-select"
>
${this.emptyOption === undefined ? nothing : this.renderEmptyMenuItem()}
${this._options.grouped
? this.renderMenuGroups(this._options.options)
: this.renderMenuItems(this._options.options)}
</ul>
</div> `;
}
}
declare global {
interface HTMLElementTagNameMap {
"ak-list-select": ListSelect;
}
}

View File

@ -0,0 +1,97 @@
import { EVENT_MESSAGE } from "@goauthentik/common/constants";
import "@goauthentik/elements/messages/MessageContainer";
import { Meta, StoryObj } from "@storybook/web-components";
import { slug } from "github-slugger";
import { TemplateResult, html } from "lit";
import "../ak-list-select.js";
import { ListSelect } from "../ak-list-select.js";
import { groupedSampleData, sampleData } from "./sampleData.js";
const longGoodForYouPairs = {
grouped: false,
options: sampleData.map(({ produce }) => [slug(produce), produce]),
};
const metadata: Meta<ListSelect> = {
title: "Elements / List Select",
component: "ak-list-select",
parameters: {
docs: {
description: {
component: "A scrolling component from which elements can be selected",
},
},
},
argTypes: {
options: {
type: "string",
description: "An array of [key, label, desc] pairs of what to show",
},
},
};
export default metadata;
type Story = StoryObj;
const sendMessage = (message: string) =>
document.dispatchEvent(
new CustomEvent(EVENT_MESSAGE, { bubbles: true, composed: true, detail: { message } }),
);
const container = (testItem: TemplateResult) => {
window.setTimeout(() => {
const menu = document.getElementById("ak-list-select");
if (!menu) {
throw new Error("Test was not initialized correctly.");
}
menu.addEventListener("focusin", () => sendMessage("Element received focus"));
menu.addEventListener("blur", () => sendMessage("Element lost focus"));
menu.addEventListener("change", (event: Event) =>
sendMessage(`Value changed to: ${(event.target as HTMLInputElement)?.value}`),
);
}, 250);
return html` <div
style="background: #fff; padding: 2em; position: relative"
id="the-main-event"
>
<style>
li {
display: block;
}
p {
margin-top: 1em;
}
#the-answer-block {
padding-top: 3em;
}
</style>
<ak-message-container></ak-message-container>
${testItem}
</div>`;
};
export const Default: Story = {
render: () =>
container(
html` <ak-list-select
id="ak-list-select"
style="top: 1em; left: 1em"
.options=${longGoodForYouPairs}
></ak-list-select>`,
),
};
export const Grouped: Story = {
render: () =>
container(
html` <ak-list-select
id="ak-list-select"
style="top: 1em; left: 1em"
.options=${groupedSampleData}
></ak-list-select>`,
),
};

View File

@ -0,0 +1,359 @@
import { slug } from "github-slugger";
import type { TemplateResult } from "lit";
// The descriptions were generated by ChatGPT. Don't blame us.
export type ViewSample = {
produce: string;
seasons: string[];
desc?: string;
};
export const sampleData: ViewSample[] = [
{
produce: "Apples",
seasons: ["Spring", "Summer", "Fall", "Winter"],
desc: "Apples are a sweet and crunchy fruit that can be eaten fresh or used in pies, juice, and ciders.",
},
{
produce: "Apricots",
seasons: ["Spring", "Summer"],
desc: "Apricots are a sweet and tangy stone fruit with a velvety skin that's often orange-yellow in color",
},
{
produce: "Asparagus",
seasons: ["Spring"],
desc: "Asparagus is a delicate and nutritious vegetable with a tender spear-like shape",
},
{
produce: "Avocados",
seasons: ["Spring", "Summer", "Winter"],
desc: "Avocados are a nutritious fruit with a creamy texture and nutty flavor",
},
{
produce: "Bananas",
seasons: ["Spring", "Summer", "Fall", "Winter"],
desc: "Bananas are a type of curved, yellow fruit that grows on banana plants",
},
{
produce: "Beets",
seasons: ["Summer", "Fall", "Winter"],
desc: "Beets are a sweet and earthy root vegetable that can be pickled, roasted, or boiled",
},
{
produce: "Bell Peppers",
seasons: ["Summer", "Fall"],
desc: "Bell peppers are a sweet and crunchy type of pepper that can be green, red, yellow, or orange",
},
{
produce: "Blackberries",
seasons: ["Summer"],
desc: "Blackberries are a type of fruit that are dark purple in color and have a sweet-tart taste",
},
{
produce: "Blueberries",
seasons: ["Summer"],
desc: "Blueberries are small, round, and sweet-tart berries with a powdery coating and a burst of juicy flavor.",
},
{
produce: "Broccoli",
seasons: ["Spring", "Fall"],
desc: "Broccoli is a green, cruciferous vegetable with a tree-like shape and a slightly bitter taste.",
},
{
produce: "Brussels Sprouts",
seasons: ["Fall", "Winter"],
desc: "Brussels sprouts are a cruciferous vegetable that is small, green, and formed like a tiny cabbage head, with a sweet and slightly bitter flavor.",
},
{
produce: "Cabbage",
seasons: ["Spring", "Fall", "Winter"],
desc: "Cabbage is a crunchy, sweet, and slightly bitter vegetable with a dense head of tightly packed leaves.",
},
{
produce: "Cantaloupe",
seasons: ["Summer"],
desc: "Cantaloupe is a sweet and juicy melon with a netted or reticulated rind and yellow-orange flesh.",
},
{
produce: "Carrots",
seasons: ["Spring", "Summer", "Fall", "Winter"],
desc: "Carrots are a crunchy and sweet root vegetable commonly eaten raw or cooked in various dishes.",
},
{
produce: "Cauliflower",
seasons: ["Fall"],
desc: "Cauliflower is a cruciferous vegetable with a white or pale yellow florets resembling tiny trees",
},
{
produce: "Celery",
seasons: ["Spring", "Summer", "Fall", "Winter"],
desc: "Celery is a crunchy, sweet-tasting vegetable with a mild flavor, often used in salads and as a snack.",
},
{
produce: "Cherries",
seasons: ["Summer"],
desc: "Cherries are a sweet and juicy stone fruit that typically range in color from bright red to dark purple.",
},
{
produce: "Collard Greens",
seasons: ["Spring", "Fall", "Winter"],
desc: "Collard greens are a type of leafy green vegetable with a slightly bitter and earthy flavor.",
},
{
produce: "Corn",
seasons: ["Summer"],
desc: "Corn is a sweet and savory grain that can be eaten fresh or used in various dishes, such as soups, salads, and baked goods.",
},
{
produce: "Cranberries",
seasons: ["Fall"],
desc: "Cranberries are a type of small, tart-tasting fruit native to North America",
},
{
produce: "Cucumbers",
seasons: ["Summer"],
desc: "Cucumbers are a long, green vegetable that is commonly consumed raw or pickled",
},
{
produce: "Eggplant",
seasons: ["Summer"],
desc: "Eggplant is a purple vegetable with a spongy texture and a slightly bitter taste.",
},
{
produce: "Garlic",
seasons: ["Spring", "Summer", "Fall"],
desc: "Garlic is a pungent and flavorful herb with a distinctive aroma and taste",
},
{
produce: "Ginger",
seasons: ["Fall"],
desc: "Ginger is a spicy, sweet, and tangy root commonly used in Asian cuisine to add warmth and depth",
},
{
produce: "Grapefruit",
seasons: ["Winter"],
desc: "Grapefruit is a tangy and sweet citrus fruit with a tart flavor profile and a slightly bitter aftertaste.",
},
{
produce: "Grapes",
seasons: ["Fall"],
desc: "Grapes are a type of fruit that grow in clusters on vines and are often eaten fresh or used to make wine, jam, and juice.",
},
{
produce: "Green Beans",
seasons: ["Summer", "Fall"],
desc: "Green beans are a type of long, thin, green vegetable that is commonly eaten as a side dish or used in various recipes.",
},
{
produce: "Herbs",
seasons: ["Spring", "Summer", "Fall", "Winter"],
desc: "Herbs are plant parts, such as leaves, stems, or flowers, used to add flavor or aroma",
},
{
produce: "Honeydew Melon",
seasons: ["Summer"],
desc: "Honeydew melons are sweet and refreshing, with a smooth, pale green rind and juicy, creamy white flesh.",
},
{
produce: "Kale",
seasons: ["Spring", "Fall", "Winter"],
desc: "Kale is a type of leafy green vegetable that is packed with nutrients and has a slightly bitter, earthy flavor.",
},
{
produce: "Kiwifruit",
seasons: ["Spring", "Fall", "Winter"],
desc: "Kiwifruit is a small, oval-shaped fruit with a fuzzy exterior and bright green or yellow flesh that tastes sweet and slightly tart.",
},
{
produce: "Leeks",
seasons: ["Winter"],
desc: "Leeks are a type of vegetable that is similar to onions and garlic, but has a milder flavor and a more delicate texture.",
},
{
produce: "Lemons",
seasons: ["Spring", "Summer", "Fall", "Winter"],
desc: "Lemons are a sour and tangy citrus fruit with a bright yellow color and a strong, distinctive flavor used in cooking, cleaning, and as a natural remedy.",
},
{
produce: "Lettuce",
seasons: ["Spring", "Fall"],
desc: "Lettuce is a crisp and refreshing green leafy vegetable often used in salads.",
},
{
produce: "Lima Beans",
seasons: ["Summer"],
desc: "Lima beans are a type of green legume with a mild flavor and soft, creamy texture.",
},
{
produce: "Limes",
seasons: ["Spring", "Summer", "Fall", "Winter"],
desc: "Limes are small, citrus fruits with a sour taste and a bright green color.",
},
{
produce: "Mangos",
seasons: ["Summer", "Fall"],
desc: "Mangos are sweet and creamy tropical fruits with a velvety texture",
},
{
produce: "Mushrooms",
seasons: ["Spring", "Fall"],
desc: "Mushrooms are a type of fungus that grow underground or on decaying organic matter",
},
{
produce: "Okra",
seasons: ["Summer"],
desc: "Okra is a nutritious, green vegetable with a unique texture and flavor",
},
{
produce: "Onions",
seasons: ["Spring", "Fall", "Winter"],
desc: "Onions are a type of vegetable characterized by their layered, bulbous structure and pungent flavor.",
},
{
produce: "Oranges",
seasons: ["Winter"],
desc: "Oranges are a sweet and juicy citrus fruit with a thick, easy-to-peel skin.",
},
{
produce: "Parsnips",
seasons: ["Fall", "Winter"],
desc: "Parsnips are a type of root vegetable that is sweet and nutty in flavor, with a texture similar to carrots.",
},
{
produce: "Peaches",
seasons: ["Summer"],
desc: "Peaches are sweet and juicy stone fruits with a soft, velvety texture.",
},
{
produce: "Pears",
seasons: ["Fall", "Winter"],
desc: "Pears are a type of sweet and juicy fruit with a smooth, buttery texture and a mild flavor",
},
{
produce: "Peas",
seasons: ["Spring", "Fall"],
desc: "Peas are small, round, sweet-tasting legumes that grow on vines and are often eaten as a side dish or added to various recipes.",
},
{
produce: "Pineapples",
seasons: ["Spring", "Fall", "Winter"],
desc: "Pineapples are a tropical fruit with tough, prickly skin and juicy, sweet flesh.",
},
{
produce: "Plums",
seasons: ["Summer"],
desc: "Plums are a type of stone fruit characterized by their juicy sweetness and rough, dark skin.",
},
{
produce: "Potatoes",
seasons: ["Fall", "Winter"],
desc: "Potatoes are a starchy root vegetable that is often brown on the outside and white or yellow on the inside.",
},
{
produce: "Pumpkin",
seasons: ["Fall", "Winter"],
desc: "Pumpkin is a type of squash that is typically orange in color and is often used to make pies, soups, and other sweet or savory dishes.",
},
{
produce: "Radishes",
seasons: ["Spring", "Fall"],
desc: "Radishes are a pungent, crunchy and spicy root vegetable that can be eaten raw or cooked,",
},
{
produce: "Raspberries",
seasons: ["Summer", "Fall"],
desc: "Raspberries are a type of sweet-tart fruit that grows on thorny bushes and is often eaten fresh or used in jams, preserves, and desserts.",
},
{
produce: "Rhubarb",
seasons: ["Spring"],
desc: "Rhubarb is a perennial vegetable with long, tart stalks that are often used in pies and preserves",
},
{
produce: "Rutabagas",
seasons: ["Fall", "Winter"],
desc: "Rutabagas are a type of root vegetable that is similar to a cross between a cabbage and a turnip",
},
{
produce: "Spinach",
seasons: ["Spring", "Fall"],
desc: "Spinach is a nutritious leafy green vegetable that is rich in iron and vitamins A, C, and K.",
},
{
produce: "Strawberries",
seasons: ["Spring", "Summer"],
desc: "Sweet and juicy, strawberries are a popular type of fruit that grow on low-lying plants with sweet-tasting seeds.",
},
{
produce: "Summer Squash",
seasons: ["Summer"],
desc: "Summer squash is a type of warm-season vegetable that includes varieties like zucchini, yellow crookneck, and straightneck",
},
{
produce: "Sweet Potatoes",
seasons: ["Fall", "Winter"],
desc: "Sweet potatoes are a type of root vegetable with a sweet and nutty flavor, often orange in color",
},
{
produce: "Swiss Chard",
seasons: ["Spring", "Fall", "Winter"],
desc: "Swiss Chard is a leafy green vegetable with a slightly bitter taste and a vibrant red or gold stem",
},
{
produce: "Tomatillos",
seasons: ["Summer"],
desc: "Tomatillos are a type of fruit that is similar to tomatoes, but with a papery husk and a more tart, slightly sweet flavor.",
},
{
produce: "Tomatoes",
seasons: ["Summer"],
desc: "Tomatoes are a juicy, sweet, and tangy fruit that is commonly used in salads, sandwiches, and as a topping for various dishes.",
},
{
produce: "Turnips",
seasons: ["Spring", "Fall", "Winter"],
desc: "Turnips are a root vegetable with a sweet and peppery flavor, often used in soups, stews, and salads.",
},
{
produce: "Watermelon",
seasons: ["Summer"],
desc: "Watermelon is a juicy and refreshing sweet fruit with a green rind and pink or yellow flesh.",
},
{
produce: "Winter Squash",
seasons: ["Fall", "Winter"],
desc: "Winter squash is a type of starchy vegetable that is harvested in the fall and has a hard, dry rind that can be stored for several months.",
},
{
produce: "Zucchini",
seasons: ["Summer"],
desc: "Zucchini is a popular summer squash that is often green or yellow in color and has a mild, slightly sweet flavor.",
},
];
type Seasoned = [string, string, string | TemplateResult];
const reseason = (acc: Seasoned[], { produce, seasons, desc }: ViewSample): Seasoned[] => [
...acc,
...seasons.map((s) => [s, produce, desc] as Seasoned),
];
export const groupedSampleData = (() => {
const seasoned: Seasoned[] = sampleData.reduce(reseason, [] as Seasoned[]);
const grouped = Object.groupBy(seasoned, ([season]) => season);
const ungrouped = ([_season, label, desc]: Seasoned) => [slug(label), label, desc];
if (grouped === undefined) {
throw new Error("Not possible with existing data.");
}
return {
grouped: true,
options: ["Spring", "Summer", "Fall", "Winter"].map((season) => ({
name: season,
options: grouped[season]?.map(ungrouped) ?? [],
})),
};
})();

View File

@ -0,0 +1,17 @@
import type { GroupedOptions, SelectOptions } from "@goauthentik/elements/types";
export function isVisibleInScrollRegion(el: HTMLElement, container: HTMLElement) {
const elTop = el.offsetTop;
const elBottom = elTop + el.clientHeight;
const containerTop = container.scrollTop;
const containerBottom = containerTop + container.clientHeight;
return (
(elTop >= containerTop && elBottom <= containerBottom) ||
(elTop < containerTop && containerTop < elBottom) ||
(elTop < containerBottom && containerBottom < elBottom)
);
}
export function groupOptions(options: SelectOptions): GroupedOptions {
return Array.isArray(options) ? { grouped: false, options: options } : options;
}

View File

@ -1,142 +0,0 @@
import { bound } from "@goauthentik/elements/decorators/bound.js";
import { match } from "ts-pattern";
import { LitElement, ReactiveController, ReactiveControllerHost } from "lit";
import {
KeyboardControllerCloseEvent,
KeyboardControllerSelectEvent,
} from "./SearchKeyboardControllerEvents.js";
type ReactiveElementHost = Partial<ReactiveControllerHost> & LitElement & { value?: string };
type ValuedHtmlElement = HTMLElement & { value: string };
/**
* @class AkKeyboardController
*
* This reactive controller connects to the host and sets up listeners for keyboard events to manage
* a list of elements. Navigational controls (up, down, home, end) do what you'd expect. Enter and Space
* "select" the current item, which means:
*
* - All other items lose focus and tabIndex
* - The selected item gains focus and tabIndex
* - The value of the selected item is sent to the host as an event
*
* @fires ak-keyboard-controller-select - When an element is selected. Contains the `value` of the
* selected item.
*
* @fires ak-keyboard-controller-close - When `Escape` is pressed. Clients can do with this as they
* wish.
*
*/
export class AkKeyboardController implements ReactiveController {
private host: ReactiveElementHost;
private index: number = 0;
private selector: string;
private highlighter: string;
private items: ValuedHtmlElement[] = [];
/**
* @arg selector: The class identifier (it *must* be a class identifier) of the DOM objects
* that this controller will be working with.
*
* NOTE: The objects identified by the selector *must* have a `value` associated with them, and
* as in all things HTML, that value must be a string.
*
* @arg highlighter: The class identifier that clients *may* use to set an alternative focus
* on the object. Note that the object will always receive focus.
*
*/
constructor(
host: ReactiveElementHost,
selector = ".ak-select-item",
highlighter = ".ak-highlight-item",
) {
this.host = host;
host.addController(this);
this.selector = selector[0] === "." ? selector : `.${selector}`;
this.highlighter = highlighter.replace(/^\./, "");
}
hostUpdated() {
this.items = Array.from(this.host.renderRoot.querySelectorAll(this.selector));
const current = this.items.findIndex((item) => item.value === this.host.value);
if (current >= 0) {
this.index = current;
}
}
hostConnected() {
this.host.addEventListener("keydown", this.onKeydown);
}
hostDisconnected() {
this.host.removeEventListener("keydown", this.onKeydown);
}
hostVisible() {
this.items[this.index].focus();
}
get current() {
return this.items[this.index];
}
get value() {
return this.current?.value;
}
set value(v: string) {
const index = this.items.findIndex((i) => i.value === v);
if (index !== undefined) {
this.index = index;
this.performUpdate();
}
}
private performUpdate() {
const items = this.items;
items.forEach((item) => {
item.classList.remove(this.highlighter);
item.tabIndex = -1;
});
items[this.index].classList.add(this.highlighter);
items[this.index].tabIndex = 0;
items[this.index].focus();
}
@bound
onKeydown(event: KeyboardEvent) {
const key = event.key;
match({ key })
.with({ key: "ArrowDown" }, () => {
this.index = Math.min(this.index + 1, this.items.length - 1);
this.performUpdate();
})
.with({ key: "ArrowUp" }, () => {
this.index = Math.max(this.index - 1, 0);
this.performUpdate();
})
.with({ key: "Home" }, () => {
this.index = 0;
this.performUpdate();
})
.with({ key: "End" }, () => {
this.index = this.items.length - 1;
this.performUpdate();
})
.with({ key: " " }, () => {
this.host.dispatchEvent(new KeyboardControllerSelectEvent(this.value));
})
.with({ key: "Enter" }, () => {
this.host.dispatchEvent(new KeyboardControllerSelectEvent(this.value));
})
.with({ key: "Escape" }, () => {
this.host.dispatchEvent(new KeyboardControllerCloseEvent());
});
}
}

View File

@ -1,20 +0,0 @@
export class KeyboardControllerSelectEvent extends Event {
value: string | undefined;
constructor(value: string | undefined) {
super("ak-keyboard-controller-select", { composed: true, bubbles: true });
this.value = value;
}
}
export class KeyboardControllerCloseEvent extends Event {
constructor() {
super("ak-keyboard-controller-close", { composed: true, bubbles: true });
}
}
declare global {
interface GlobalEventHandlersEventMap {
"ak-keyboard-controller-select": KeyboardControllerSelectEvent;
"ak-keyboard-controller-close": KeyboardControllerCloseEvent;
}
}

View File

@ -0,0 +1,260 @@
import { EVENT_REFRESH } from "@goauthentik/common/constants";
import { APIErrorTypes, parseAPIError } from "@goauthentik/common/errors";
import { groupBy } from "@goauthentik/common/utils";
import { AkControlElement } from "@goauthentik/elements/AkControlElement.js";
import { PreventFormSubmit } from "@goauthentik/elements/forms/helpers";
import type { GroupedOptions, SelectGroup, SelectOption } from "@goauthentik/elements/types.js";
import { CustomEmitterElement } from "@goauthentik/elements/utils/eventEmitter";
import { randomId } from "@goauthentik/elements/utils/randomId.js";
import { msg } from "@lit/localize";
import { TemplateResult, html } from "lit";
import { property, state } from "lit/decorators.js";
import { ifDefined } from "lit/directives/if-defined.js";
import PFBase from "@patternfly/patternfly/patternfly-base.css";
import { ResponseError } from "@goauthentik/api";
import "./ak-search-select-view.js";
import { SearchSelectView } from "./ak-search-select-view.js";
type Group<T> = [string, T[]];
export interface ISearchSelectBase<T> {
blankable: boolean;
query?: string;
objects?: T[];
selectedObject?: T;
name?: string;
placeholder: string;
emptyOption: string;
}
export class SearchSelectBase<T>
extends CustomEmitterElement(AkControlElement)
implements ISearchSelectBase<T>
{
static get styles() {
return [PFBase];
}
// A function which takes the query state object (accepting that it may be empty) and returns a
// new collection of objects.
fetchObjects!: (query?: string) => Promise<T[]>;
// A function passed to this object that extracts a string representation of items of the
// collection under search.
renderElement!: (element: T) => string;
// A function passed to this object that extracts an HTML representation of additional
// information for items of the collection under search.
renderDescription?: (element: T) => string | TemplateResult;
// A function which returns the currently selected object's primary key, used for serialization
// into forms.
value!: (element: T | undefined) => unknown;
// A function passed to this object that determines an object in the collection under search
// should be automatically selected. Only used when the search itself is responsible for
// fetching the data; sets an initial default value.
selected?: (element: T, elements: T[]) => boolean;
// A function passed to this object (or using the default below) that groups objects in the
// collection under search into categories.
groupBy: (items: T[]) => [string, T[]][] = (items: T[]): [string, T[]][] => {
return groupBy(items, () => {
return "";
});
};
// Whether or not the dropdown component can be left blank
@property({ type: Boolean })
blankable = false;
// An initial string to filter the search contents, and the value of the input which further
// serves to restrict the search
@property()
query?: string;
// The objects currently available under search
@property({ attribute: false })
objects?: T[];
// The currently selected object
@property({ attribute: false })
selectedObject?: T;
// Used to inform the form of the name of the object
@property()
name?: string;
// The textual placeholder for the search's <input> object, if currently empty. Used as the
// native <input> object's `placeholder` field.
@property()
placeholder: string = msg("Select an object.");
// A textual string representing "The user has affirmed they want to leave the selection blank."
// Only used if `blankable` above is true.
@property()
emptyOption = "---------";
isFetchingData = false;
@state()
error?: APIErrorTypes;
public toForm(): unknown {
if (!this.objects) {
throw new PreventFormSubmit(msg("Loading options..."));
}
return this.value(this.selectedObject) || "";
}
public json() {
return this.toForm();
}
public async updateData() {
if (this.isFetchingData) {
return Promise.resolve();
}
this.isFetchingData = true;
return this.fetchObjects(this.query)
.then((objects) => {
objects.forEach((obj) => {
if (this.selected && this.selected(obj, objects || [])) {
this.selectedObject = obj;
this.dispatchCustomEvent("ak-change", { value: this.selectedObject });
}
});
this.objects = objects;
this.isFetchingData = false;
})
.catch((exc: ResponseError) => {
this.isFetchingData = false;
this.objects = undefined;
parseAPIError(exc).then((err) => {
this.error = err;
});
});
}
public override connectedCallback(): void {
super.connectedCallback();
this.setAttribute("data-ouia-component-type", "ak-search-select");
this.setAttribute("data-ouia-component-id", this.getAttribute("id") || randomId());
this.dataset.akControl = "true";
this.updateData();
this.addEventListener(EVENT_REFRESH, this.updateData);
}
public override disconnectedCallback(): void {
super.disconnectedCallback();
this.removeEventListener(EVENT_REFRESH, this.updateData);
}
private onSearch(event: InputEvent) {
const value = (event.target as SearchSelectView).rawValue;
if (value === undefined) {
this.selectedObject = undefined;
return;
}
this.query = value;
this.updateData()?.then(() => {
this.dispatchCustomEvent("ak-change", { value: this.selectedObject });
});
}
private onSelect(event: InputEvent) {
const value = (event.target as SearchSelectView).value;
if (value === undefined) {
this.selectedObject = undefined;
this.dispatchCustomEvent("ak-change", { value: undefined });
return;
}
const selected = (this.objects ?? []).find((obj) => `${this.value(obj)}` === value);
if (!selected) {
console.warn(`ak-search-select: No corresponding object found for value (${value}`);
}
this.selectedObject = selected;
this.dispatchCustomEvent("ak-change", { value: this.selectedObject });
}
private getGroupedItems(): GroupedOptions {
const groupedItems = this.groupBy(this.objects || []);
const makeSearchTuples = (items: T[]): SelectOption[] =>
items.map((item) => [
`${this.value(item)}`,
this.renderElement(item),
this.renderDescription ? this.renderDescription(item) : undefined,
]);
const makeSearchGroups = (items: Group<T>[]): SelectGroup[] =>
items.map((group) => ({
name: group[0],
options: makeSearchTuples(group[1]),
}));
if (groupedItems.length === 0) {
return { grouped: false, options: [] };
}
if (
groupedItems.length === 1 &&
(groupedItems[0].length < 1 || groupedItems[0][0] === "")
) {
return {
grouped: false,
options: makeSearchTuples(groupedItems[0][1]),
};
}
return {
grouped: true,
options: makeSearchGroups(groupedItems),
};
}
public override performUpdate() {
this.removeAttribute("data-ouia-component-safe");
super.performUpdate();
}
public override render() {
if (this.error) {
return html`<em>${msg("Failed to fetch objects: ")} ${this.error.detail}</em>`;
}
if (!this.objects) {
return html`${msg("Loading...")}`;
}
const options = this.getGroupedItems();
const value = this.selectedObject ? `${this.value(this.selectedObject) ?? ""}` : undefined;
return html`<ak-search-select-view
managed
.options=${options}
value=${ifDefined(value)}
?blankable=${this.blankable}
name=${ifDefined(this.name)}
placeholder=${this.placeholder}
emptyOption=${ifDefined(this.blankable ? this.emptyOption : undefined)}
@input=${this.onSearch}
@change=${this.onSelect}
></ak-search-select-view> `;
}
public override updated() {
// It is not safe for automated tests to interact with this component while it is fetching
// data.
if (!this.isFetchingData) {
this.setAttribute("data-ouia-component-safe", "true");
}
}
}
export default SearchSelectBase;

View File

@ -1,63 +0,0 @@
/**
* class SearchSelectSelectEvent
*
* Intended meaning: the user selected an item from the entire dialogue, either by clicking on it
* with the mouse, or selecting it with the keyboard controls and pressing Enter or Space.
*/
export class SearchSelectSelectEvent extends Event {
value: string | undefined;
constructor(value: string | undefined) {
super("ak-search-select-select", { composed: true, bubbles: true });
this.value = value;
}
}
/**
* class SearchSelectSelectMenuEvent
*
* Intended meaning: the user selected an item from the menu, either by clicking on it with the
* mouse, or selecting it with the keyboard controls and pressing Enter or Space. This is
* intercepted an interpreted internally, usually resulting in a throw of SearchSelectSelectEvent.
* They have to be distinct to avoid an infinite event loop.
*/
export class SearchSelectSelectMenuEvent extends Event {
value: string | undefined;
constructor(value: string | undefined) {
super("ak-search-select-select-menu", { composed: true, bubbles: true });
this.value = value;
}
}
/**
* class SearchSelectCloseEvent
*
* Intended meaning: the user requested that the menu dropdown close. Usually triggered by pressing
* the Escape key.
*/
export class SearchSelectCloseEvent extends Event {
constructor() {
super("ak-search-select-close", { composed: true, bubbles: true });
}
}
/**
* class SearchSelectInputEvent
*
* Intended meaning: the user made a change to the content of the `<input>` field
*/
export class SearchSelectInputEvent extends Event {
value: string | undefined;
constructor(value: string | undefined) {
super("ak-search-select-input", { composed: true, bubbles: true });
this.value = value;
}
}
declare global {
interface GlobalEventHandlersEventMap {
"ak-search-select-select-menu": SearchSelectSelectMenuEvent;
"ak-search-select-select": SearchSelectSelectEvent;
"ak-search-select-input": SearchSelectInputEvent;
"ak-search-select-close": SearchSelectCloseEvent;
}
}

View File

@ -0,0 +1,143 @@
import { autoUpdate, computePosition, flip, hide } from "@floating-ui/dom";
import { randomId } from "@goauthentik/elements/utils/randomId.js";
import { LitElement, nothing } from "lit";
import { customElement, property } from "lit/decorators.js";
/**
* @class Portal
* @element ak-portal
*
* An intermediate class to handle a menu and its position.
*
* It has no rendering of its own, and mostly is just a pass-through for options to the menu.
* DOTADIW: it tracks the top-of-DOM object into which we render our menu, guaranteeing that it
* appears above everything else, and operates the positioning control for it.
*
* - @prop anchor (HTMLElement): The component which will be visually associated with the portaled popup.
* - @attr open (boolean): whether or not the component is visible
* - @attr name (string): (optional) used to managed the relationship the portal mediates.
*/
export interface IPortal {
anchor: HTMLElement;
open: boolean;
name?: string;
}
@customElement("ak-portal")
export class Portal extends LitElement implements IPortal {
/**
* The host element which will be our reference point for rendering. Is not necessarily
* the element that receives the events.
*
* @prop
*/
@property({ type: Object, attribute: false })
anchor!: HTMLElement;
/**
* Whether or not the content is visible
*
* @attr
*/
@property({ type: Boolean, reflect: true })
open = false;
/**
* The name; used mostly for the management layer.
*
* @attr
*/
@property()
name?: string;
/**
* The tether object.
*/
dropdownContainer!: HTMLDivElement;
public cleanup?: () => void;
connected = false;
content!: Element;
connectedCallback() {
super.connectedCallback();
this.setAttribute("data-ouia-component-type", "ak-portal");
this.setAttribute("data-ouia-component-id", this.getAttribute("id") || randomId());
this.dropdownContainer = document.createElement("div");
this.dropdownContainer.dataset["managedBy"] = "ak-portal";
if (this.name) {
this.dropdownContainer.dataset["managedFor"] = this.name;
}
document.body.append(this.dropdownContainer);
if (!this.anchor) {
throw new Error("Tether entrance initialized incorrectly: missing anchor");
}
this.connected = true;
if (this.firstElementChild) {
this.content = this.firstElementChild as Element;
} else {
throw new Error("No content to be portaled included in the tag");
}
}
disconnectedCallback(): void {
this.connected = false;
this.dropdownContainer?.remove();
this.cleanup?.();
super.disconnectedCallback();
}
setPosition() {
if (!(this.anchor && this.dropdownContainer)) {
throw new Error("Tether initialized incorrectly: missing anchor or tether destination");
}
this.cleanup = autoUpdate(this.anchor, this.dropdownContainer, async () => {
const { x, y } = await computePosition(this.anchor, this.dropdownContainer, {
placement: "bottom-start",
strategy: "fixed",
middleware: [flip(), hide()],
});
Object.assign(this.dropdownContainer.style, {
"position": "fixed",
"display": "block",
"z-index": "9999",
"top": 0,
"left": 0,
"transform": `translate(${x}px, ${y}px)`,
});
});
}
public override performUpdate() {
this.removeAttribute("data-ouia-component-safe");
super.performUpdate();
}
render() {
this.dropdownContainer.appendChild(this.content);
// This is a dummy object that just has to exist to be the communications channel between
// the tethered object and its anchor.
return nothing;
}
updated() {
(this.content as HTMLElement).style.display = "none";
if (this.anchor && this.dropdownContainer && this.open && !this.hidden) {
(this.content as HTMLElement).style.display = "";
this.setPosition();
}
// Testing should always check if this component is open, even if it's set safe.
this.setAttribute("data-ouia-component-safe", "true");
}
}
declare global {
interface HTMLElementTagNameMap {
"ak-portal": Portal;
}
}

View File

@ -0,0 +1,74 @@
import { TemplateResult } from "lit";
import { customElement, property } from "lit/decorators.js";
import PFBase from "@patternfly/patternfly/patternfly-base.css";
import { type ISearchSelectBase, SearchSelectBase } from "./SearchSelect.js";
export interface ISearchSelectApi<T> {
fetchObjects: (query?: string) => Promise<T[]>;
renderElement: (element: T) => string;
renderDescription?: (element: T) => string | TemplateResult;
value: (element: T | undefined) => unknown;
selected?: (element: T, elements: T[]) => boolean;
groupBy: (items: T[]) => [string, T[]][];
}
export interface ISearchSelectEz<T> extends ISearchSelectBase<T> {
config: ISearchSelectApi<T>;
}
/**
* @class SearchSelectEz
* @element ak-search-select-ez
*
* The API layer of ak-search-select, now in EZ format!
*
* - @prop config (Object): A Record <string, function> that fulfills the API needed by Search
* Select to retrieve, filter, group, describe, and return elements.
* - @attr blankable (boolean): if true, the component is blankable and can return `undefined`
* - @attr name (string): The name of the component, for forms
* - @attr query (string): The current search criteria for fetching objects
* - @attr placeholder (string): What to show when the input is empty
* - @attr emptyOption (string): What to show in the menu to indicate "leave this undefined". Only
* shown if `blankable`
* - @attr selectedObject (Object<T>): The current object, or undefined, selected
*
* ¹ Due to a limitation in the parsing of properties-vs-attributes, these must be defined as
* properties, not attributes. As a consequence, they must be declared in property syntax.
* Example:
*
* `.renderElement=${"name"}`
*
* - @fires ak-change - When a value from the collection has been positively chosen, either as a
* consequence of the user typing or when selecting from the list.
*
*/
@customElement("ak-search-select-ez")
export class SearchSelectEz<T> extends SearchSelectBase<T> implements ISearchSelectEz<T> {
static get styles() {
return [PFBase];
}
@property({ type: Object, attribute: false })
config!: ISearchSelectApi<T>;
connectedCallback() {
this.fetchObjects = this.config.fetchObjects;
this.renderElement = this.config.renderElement;
this.renderDescription = this.config.renderDescription;
this.value = this.config.value;
this.selected = this.config.selected;
this.groupBy = this.config.groupBy;
super.connectedCallback();
}
}
export default SearchSelectEz;
declare global {
interface HTMLElementTagNameMap {
"ak-search-select-ez": SearchSelectEz<unknown>;
}
}

View File

@ -1,185 +0,0 @@
import { autoUpdate, computePosition, flip, hide } from "@floating-ui/dom";
import { LitElement, html, nothing, render } from "lit";
import { customElement, property } from "lit/decorators.js";
import { ifDefined } from "lit/directives/if-defined.js";
import { Ref, createRef, ref } from "lit/directives/ref.js";
import { KeyboardControllerCloseEvent } from "./SearchKeyboardControllerEvents.js";
import "./ak-search-select-menu.js";
import { type SearchSelectMenu } from "./ak-search-select-menu.js";
import type { SearchOptions } from "./types.js";
/**
* An intermediate class to handle the menu and its position.
*
* It has no rendering of its own, and mostly is just a pass-through for options to the menu.
* DOTADIW: it tracks the top-of-DOM object into which we render our menu, guaranteeing that it
* appears above everything else, and operates the positioning control for it.
*
* - @fires ak-search-select-close - Fired (by the keyboard controller) when the tethered end loses
* focus. Clients can do with this information as they wish.
*/
@customElement("ak-search-select-menu-position")
export class SearchSelectMenuPosition extends LitElement {
/**
* The host to which all relevant events will be routed. Useful for managing floating / tethered
* components.
*
* @prop
*/
@property({ type: Object, attribute: false })
host!: HTMLElement;
/**
* The host element which will be our reference point for rendering.
*
* @prop
*/
@property({ type: Object, attribute: false })
anchor!: HTMLElement;
/**
* Passthrough of the options that we'll be rendering.
*
* @prop
*/
@property({ type: Array, attribute: false })
options: SearchOptions = [];
/**
* Passthrough of the current value
*
* @prop
*/
@property()
value?: string;
/**
* If undefined, there will be no empty option shown
*
* @attr
*/
@property()
emptyOption?: string;
/**
* Whether or not the menu is visible
*
* @attr
*/
@property({ type: Boolean, reflect: true })
open = false;
/**
* The name; used mostly for the management layer.
*
* @attr
*/
@property()
name?: string;
/**
* The tether object.
*/
dropdownContainer!: HTMLDivElement;
public cleanup?: () => void;
connected = false;
/**
*Communicates forward with the menu to detect when the tether has lost focus
*/
menuRef: Ref<SearchSelectMenu> = createRef();
connectedCallback() {
super.connectedCallback();
this.dropdownContainer = document.createElement("div");
this.dropdownContainer.dataset["managedBy"] = "ak-search-select";
if (this.name) {
this.dropdownContainer.dataset["managedFor"] = this.name;
}
document.body.append(this.dropdownContainer);
if (!this.host) {
throw new Error("Tether entrance initialized incorrectly: missing host");
}
this.connected = true;
}
disconnectedCallback(): void {
this.connected = false;
this.dropdownContainer?.remove();
this.cleanup?.();
super.disconnectedCallback();
}
setPosition() {
if (!(this.anchor && this.dropdownContainer)) {
throw new Error("Tether initialized incorrectly: missing anchor or tether destination");
}
this.cleanup = autoUpdate(this.anchor, this.dropdownContainer, async () => {
const { x, y } = await computePosition(this.anchor, this.dropdownContainer, {
placement: "bottom-start",
strategy: "fixed",
middleware: [flip(), hide()],
});
Object.assign(this.dropdownContainer.style, {
"position": "fixed",
"z-index": "9999",
"top": 0,
"left": 0,
"transform": `translate(${x}px, ${y}px)`,
});
});
}
updated() {
if (this.anchor && this.dropdownContainer && !this.hidden) {
this.setPosition();
}
}
hasFocus() {
return (
this.menuRef.value &&
(this.menuRef.value === document.activeElement ||
this.menuRef.value.renderRoot.contains(document.activeElement))
);
}
onFocusOut() {
this.dispatchEvent(new KeyboardControllerCloseEvent());
}
render() {
// The 'hidden' attribute is a little weird and the current Typescript definition for
// it is incompatible with actual implementations, so we drill `open` all the way down,
// but we set the hidden attribute here, and on the actual menu use CSS and the
// the attribute's presence to hide/show as needed.
render(
html`<ak-search-select-menu
.options=${this.options}
value=${ifDefined(this.value)}
.host=${this.host}
.emptyOption=${this.emptyOption}
@focusout=${this.onFocusOut}
?open=${this.open}
?hidden=${!this.open}
${ref(this.menuRef)}
></ak-search-select-menu>`,
this.dropdownContainer,
);
// This is a dummy object that just has to exist to be the communications channel between
// the tethered object and its anchor.
return nothing;
}
}
declare global {
interface HTMLElementTagNameMap {
"ak-search-select-menu-position": SearchSelectMenuPosition;
}
}

View File

@ -1,192 +0,0 @@
import { AKElement } from "@goauthentik/elements/Base.js";
import { bound } from "@goauthentik/elements/decorators/bound.js";
import { PropertyValues, css, html, nothing } from "lit";
import { customElement, property } from "lit/decorators.js";
import PFDropdown from "@patternfly/patternfly/components/Dropdown/dropdown.css";
import PFSelect from "@patternfly/patternfly/components/Select/select.css";
import PFBase from "@patternfly/patternfly/patternfly-base.css";
import { AkKeyboardController } from "./SearchKeyboardController.js";
import {
KeyboardControllerCloseEvent,
KeyboardControllerSelectEvent,
} from "./SearchKeyboardControllerEvents.js";
import { SearchSelectCloseEvent, SearchSelectSelectMenuEvent } from "./SearchSelectEvents.js";
import type { GroupedOptions, SearchGroup, SearchOptions, SearchTuple } from "./types.js";
/**
* @class SearchSelectMenu
* @element ak-search-select-menu
*
* The actual renderer of our components. Intended to be positioned and controlled automatically
* from the outside.
*
* @fires ak-search-select-select - An element has been selected. Contains the `value` of the
* selected item.
*
* @fires ak-search-select-close - The user has triggered the `close` event. Clients can do with this
* as they wish.
*/
@customElement("ak-search-select-menu")
export class SearchSelectMenu extends AKElement {
static get styles() {
return [
PFBase,
PFDropdown,
PFSelect,
css`
:host {
overflow: visible;
z-index: 9999;
}
:host([hidden]) {
display: none;
}
.pf-c-dropdown__menu {
max-height: 50vh;
overflow-y: auto;
}
`,
];
}
/**
* The host to which all relevant events will be routed. Useful for managing floating / tethered
* components.
*/
@property({ type: Object, attribute: false })
host!: HTMLElement;
/**
* See the search options type, described in the `./types` file, for the relevant types.
*/
@property({ type: Array, attribute: false })
options: SearchOptions = [];
@property()
value?: string;
@property()
emptyOption?: string;
@property({ type: Boolean, reflect: true })
open = false;
private keyboardController: AkKeyboardController;
constructor() {
super();
this.keyboardController = new AkKeyboardController(this);
this.addEventListener("ak-keyboard-controller-select", this.onKeySelect);
this.addEventListener("ak-keyboard-controller-close", this.onKeyClose);
}
// Handles the "easy mode" of just passing an array of tuples.
fixedOptions(): GroupedOptions {
return Array.isArray(this.options)
? { grouped: false, options: this.options }
: this.options;
}
@bound
onClick(event: Event, value: string) {
event.stopPropagation();
this.host.dispatchEvent(new SearchSelectSelectMenuEvent(value));
this.value = value;
}
@bound
onEmptyClick(event: Event) {
event.stopPropagation();
this.host.dispatchEvent(new SearchSelectSelectMenuEvent(undefined));
this.value = undefined;
}
@bound
onKeySelect(event: KeyboardControllerSelectEvent) {
event.stopPropagation();
this.value = event.value;
this.host.dispatchEvent(new SearchSelectSelectMenuEvent(this.value));
}
@bound
onKeyClose(event: KeyboardControllerCloseEvent) {
event.stopPropagation();
this.host.dispatchEvent(new SearchSelectCloseEvent());
}
updated(changed: PropertyValues<this>) {
if (changed.has("open") && this.open) {
this.keyboardController.hostVisible();
}
}
renderEmptyMenuItem() {
return html`<li>
<button class="pf-c-dropdown__menu-item" role="option" @click=${this.onEmptyClick}>
${this.emptyOption}
</button>
</li>`;
}
renderMenuItems(options: SearchTuple[]) {
return options.map(
([value, label, desc]: SearchTuple) => html`
<li>
<button
class="pf-c-dropdown__menu-item pf-m-description ak-select-item"
role="option"
value=${value}
@click=${(ev: Event) => {
this.onClick(ev, value);
}}
@keypress=${() => {
/* noop */
}}
>
<div class="pf-c-dropdown__menu-item-main">${label}</div>
${desc
? html`<div class="pf-c-dropdown__menu-item-description">${desc}</div>`
: nothing}
</button>
</li>
`,
);
}
renderMenuGroups(options: SearchGroup[]) {
return options.map(
({ name, options }) => html`
<section class="pf-c-dropdown__group">
<h1 class="pf-c-dropdown__group-title">${name}</h1>
<ul>
${this.renderMenuItems(options)}
</ul>
</section>
`,
);
}
render() {
const options = this.fixedOptions();
return html`<div class="pf-c-dropdown pf-m-expanded">
<ul class="pf-c-dropdown__menu pf-m-static" role="listbox" tabindex="0">
${this.emptyOption !== undefined ? this.renderEmptyMenuItem() : nothing}
${options.grouped
? this.renderMenuGroups(options.options)
: this.renderMenuItems(options.options)}
</ul>
</div> `;
}
}
declare global {
interface HTMLElementTagNameMap {
"ak-search-select-menu": SearchSelectMenu;
}
}

View File

@ -1,10 +1,13 @@
import { AKElement } from "@goauthentik/elements/Base";
import "@goauthentik/elements/ak-list-select/ak-list-select.js";
import { ListSelect } from "@goauthentik/elements/ak-list-select/ak-list-select.js";
import { bound } from "@goauthentik/elements/decorators/bound.js";
import "@goauthentik/elements/forms/SearchSelect/ak-search-select-menu-position.js";
import type { SearchSelectMenuPosition } from "@goauthentik/elements/forms/SearchSelect/ak-search-select-menu-position.js";
import "@goauthentik/elements/forms/SearchSelect/ak-portal.js";
import type { GroupedOptions, SelectOption, SelectOptions } from "@goauthentik/elements/types.js";
import { randomId } from "@goauthentik/elements/utils/randomId.js";
import { msg } from "@lit/localize";
import { PropertyValues, html } from "lit";
import { PropertyValues, html, nothing } from "lit";
import { customElement, property, state } from "lit/decorators.js";
import { ifDefined } from "lit/directives/if-defined.js";
import { Ref, createRef, ref } from "lit/directives/ref.js";
@ -14,14 +17,19 @@ import PFFormControl from "@patternfly/patternfly/components/FormControl/form-co
import PFSelect from "@patternfly/patternfly/components/Select/select.css";
import PFBase from "@patternfly/patternfly/patternfly-base.css";
import {
SearchSelectCloseEvent,
SearchSelectInputEvent,
SearchSelectSelectEvent,
SearchSelectSelectMenuEvent,
} from "./SearchSelectEvents.js";
import type { SearchOptions } from "./types.js";
import { optionsToOptionsMap } from "./utils.js";
import { findFlatOptions, findOptionsSubset, groupOptions, optionsToFlat } from "./utils.js";
export interface ISearchSelectView {
options: SelectOptions;
value?: string;
open: boolean;
blankable: boolean;
caseSensitive: boolean;
name?: string;
placeholder: string;
managed: boolean;
emptyOption: string;
}
/**
* @class SearchSelectView
@ -30,8 +38,26 @@ import { optionsToOptionsMap } from "./utils.js";
* Main component of ak-search-select, renders the <input> object and controls interaction with the
* portaled menu list.
*
* @fires ak-search-select-input - When the user selects an item from the list. A derivative Event
* with the `value` as its payload.
* - @prop options! (GroupedOptions): The options passed to the component
* - @attr value? (string): The current value. Reflected.
* - @attr open (boolean): if the menu dropdown is visible
* - @attr blankable (boolean): if true, the component is blankable and can return `undefined`
* - @attr managed (boolean): if true, the options and search are managed by a higher-level
component.
* - @attr caseSensitive (boolean): if `managed`, local searches will be case sensitive. False by
default.
* - @attr name? (string): The name of the component, for forms
* - @attr placeholder (string): What to show when the input is empty
* - @attr emptyOption (string): What to show in the menu to indicate "leave this undefined". Only
* shown if `blankable`
*
* - @fires change - When a value from the list has been positively chosen, either as a consequence of
* the user typing or when selecting from the list.
*
* - @part ak-search-select: The main Patternfly div
* - @part ak-search-select-toggle: The Patternfly inner div
* - @part ak-search-select-wrapper: Yet another Patternfly inner div
* - @part ak-search-select-toggle-typeahead: The `<input>` component itself
*
* Note that this is more on the HTML / Web Component side of the operational line: the keys which
* represent the values we pass back to clients are always strings here. This component is strictly
@ -41,9 +67,8 @@ import { optionsToOptionsMap } from "./utils.js";
* the object that key references when extracting the value for use.
*
*/
@customElement("ak-search-select-view")
export class SearchSelectView extends AKElement {
export class SearchSelectView extends AKElement implements ISearchSelectView {
/**
* The options collection. The simplest variant is just [key, label, optional<description>]. See
* the `./types.ts` file for variants and how to use them.
@ -51,16 +76,33 @@ export class SearchSelectView extends AKElement {
* @prop
*/
@property({ type: Array, attribute: false })
options: SearchOptions = [];
set options(options: SelectOptions) {
this._options = groupOptions(options);
this.flatOptions = optionsToFlat(this._options);
}
get options() {
return this._options;
}
_options!: GroupedOptions;
/**
* The current value. Must be one of the keys in the options group above.
*
* @prop
*/
@property()
@property({ type: String, reflect: true })
value?: string;
/**
* Whether or not the dropdown is open
*
* @attr
*/
@property({ type: Boolean, reflect: true })
open = false;
/**
* If set to true, this object MAY return undefined in no value is passed in and none is set
* during interaction.
@ -70,31 +112,42 @@ export class SearchSelectView extends AKElement {
@property({ type: Boolean })
blankable = false;
/**
* If not managed, make the matcher case-sensitive during interaction. If managed,
* the manager must handle this.
*
* @attr
*/
@property({ type: Boolean, attribute: "case-sensitive" })
caseSensitive = false;
/**
* The name of the input, for forms
*
* @attr
*/
@property()
@property({ type: String })
name?: string;
/**
* Whether or not the portal is open
*
* @attr
*/
@property({ type: Boolean, reflect: true })
open = false;
/**
* The textual placeholder for the search's <input> object, if currently empty. Used as the
* native <input> object's `placeholder` field.
*
* @attr
*/
@property()
@property({ type: String })
placeholder: string = msg("Select an object.");
/**
* If true, the component only sends an input message up to a parent component. If false, the
* list of options sent downstream will be filtered by the contents of the `<input>` field
* locally.
*
*@attr
*/
@property({ type: Boolean })
managed = false;
/**
* A textual string representing "The user has affirmed they want to leave the selection blank."
* Only used if `blankable` above is true.
@ -106,136 +159,206 @@ export class SearchSelectView extends AKElement {
// Handle the behavior of the drop-down when the :host scrolls off the page.
scrollHandler?: () => void;
observer: IntersectionObserver;
// observer: IntersectionObserver;
@state()
displayValue = "";
// Tracks when the inputRef is populated, so we can safely reschedule the
// render of the dropdown with respect to it.
@state()
inputRefIsAvailable = false;
/**
* Permanent identity with the portal so focus events can be checked.
*/
menuRef: Ref<ListSelect> = createRef();
/**
* Permanent identify for the input object, so the floating portal can find where to anchor
* itself.
*/
inputRef: Ref<HTMLInputElement> = createRef();
/**
* Permanent identity with the portal so focus events can be checked.
*/
menuRef: Ref<SearchSelectMenuPosition> = createRef();
/**
* Maps a value from the portal to labels to be put into the <input> field>
*/
optionsMap: Map<string, string> = new Map();
flatOptions: [string, SelectOption][] = [];
static get styles() {
return [PFBase, PFForm, PFFormControl, PFSelect];
}
constructor() {
super();
this.observer = new IntersectionObserver(() => {
this.open = false;
});
this.observer.observe(this);
/* These can't be attached with the `@` syntax because they're not passed through to the
* menu; the positioner is in the way, and it deliberately renders objects *outside* of the
* path from `document` to this object. That's why we pass the positioner (and its target)
* the `this` (host) object; so they can send messages to this object despite being outside
* the event's bubble path.
*/
this.addEventListener("ak-search-select-select-menu", this.onSelect);
this.addEventListener("ak-search-select-close", this.onClose);
connectedCallback() {
super.connectedCallback();
this.setAttribute("data-ouia-component-type", "ak-search-select-view");
this.setAttribute("data-ouia-component-id", this.getAttribute("id") || randomId());
}
disconnectedCallback(): void {
this.observer.disconnect();
super.disconnectedCallback();
}
// TODO: Reconcile value <-> display value, Reconcile option changes to value <-> displayValue
onOpenEvent(event: Event) {
this.open = true;
if (
this.blankable &&
this.value === this.emptyOption &&
event.target &&
event.target instanceof HTMLInputElement
) {
event.target.value = "";
}
// If the user has changed the content of the input box, they are manipulating the *Label*, not
// the value. We'll have to retroactively decide the value and publish it to any listeners.
settleValue() {
// TODO
}
@bound
onSelect(event: SearchSelectSelectMenuEvent) {
this.open = false;
this.value = event.value;
this.displayValue = this.value ? (this.optionsMap.get(this.value) ?? this.value ?? "") : "";
this.dispatchEvent(new SearchSelectSelectEvent(this.value));
}
@bound
onClose(event: SearchSelectCloseEvent) {
event.stopPropagation();
onClick(_ev: Event) {
this.open = !this.open;
this.inputRef.value?.focus();
this.open = false;
}
@bound
onFocus(event: FocusEvent) {
this.onOpenEvent(event);
}
@bound
onClick(event: Event) {
this.onOpenEvent(event);
}
@bound
onInput(_event: InputEvent) {
this.value = this.inputRef?.value?.value ?? "";
this.displayValue = this.value ? (this.optionsMap.get(this.value) ?? this.value ?? "") : "";
this.dispatchEvent(new SearchSelectInputEvent(this.value));
setFromMatchList(value: string | undefined) {
if (value === undefined) {
return;
}
const probableValue = this.flatOptions.find((option) => option[0] === this.value);
if (probableValue && this.inputRef.value) {
this.inputRef.value.value = probableValue[1][1];
}
}
@bound
onKeydown(event: KeyboardEvent) {
if (event.key === "Escape") {
if (event.code === "Escape") {
event.stopPropagation();
this.open = false;
}
if (event.code === "ArrowDown" || event.code === "ArrowUp") {
this.open = true;
}
if (event.code === "Tab" && this.open) {
event.preventDefault();
this.setFromMatchList(this.value);
this.menuRef.value?.currentElement?.focus();
}
}
@bound
onFocusOut(event: FocusEvent) {
event.stopPropagation();
window.setTimeout(() => {
if (!this.menuRef.value?.hasFocus()) {
this.open = false;
onListBlur(event: FocusEvent) {
// If we lost focus but the menu got it, don't do anything;
const relatedTarget = event.relatedTarget as HTMLElement | undefined;
if (
relatedTarget &&
(this.contains(relatedTarget) ||
this.renderRoot.contains(relatedTarget) ||
this.menuRef.value?.contains(relatedTarget) ||
this.menuRef.value?.renderRoot.contains(relatedTarget))
) {
return;
}
this.open = false;
if (this.value === undefined) {
if (this.inputRef.value) {
this.inputRef.value.value = "";
}
}, 0);
}
willUpdate(changed: PropertyValues<this>) {
if (changed.has("options")) {
this.optionsMap = optionsToOptionsMap(this.options);
}
if (changed.has("value")) {
this.displayValue = this.value
? (this.optionsMap.get(this.value) ?? this.value ?? "")
: "";
this.setValue(undefined);
}
}
updated() {
if (this.inputRef?.value && this.inputRef?.value?.value !== this.displayValue) {
this.inputRef.value.value = this.displayValue;
setValue(newValue: string | undefined) {
this.value = newValue;
this.dispatchEvent(new Event("change", { bubbles: true, composed: true })); // prettier-ignore
}
findValueForInput() {
const value = this.inputRef.value?.value;
if (value === undefined || value.trim() === "") {
this.setValue(undefined);
return;
}
const matchesFound = findFlatOptions(this.flatOptions, value);
if (matchesFound.length > 0) {
const newValue = matchesFound[0][0];
if (newValue === value) {
return;
}
this.setValue(newValue);
} else {
this.setValue(undefined);
}
}
render() {
return html`<div class="pf-c-select">
<div class="pf-c-select__toggle pf-m-typeahead">
<div class="pf-c-select__toggle-wrapper">
@bound
onInput(_ev: InputEvent) {
if (!this.managed) {
this.findValueForInput();
this.requestUpdate();
}
this.open = true;
}
@bound
onListKeydown(event: KeyboardEvent) {
if (event.key === "Escape") {
this.open = false;
this.inputRef.value?.focus();
}
if (event.key === "Tab" && event.shiftKey) {
event.preventDefault();
this.inputRef.value?.focus();
}
}
@bound
onListChange(event: InputEvent) {
if (!event.target) {
return;
}
const value = (event.target as HTMLInputElement).value;
if (value !== undefined) {
const newDisplayValue = this.findDisplayForValue(value);
if (this.inputRef.value) {
this.inputRef.value.value = newDisplayValue ?? "";
}
} else if (this.inputRef.value) {
this.inputRef.value.value = "";
}
this.open = false;
this.setValue(value);
}
findDisplayForValue(value: string) {
const newDisplayValue = this.flatOptions.find((option) => option[0] === value);
return newDisplayValue ? newDisplayValue[1][1] : undefined;
}
public override performUpdate() {
this.removeAttribute("data-ouia-component-safe");
super.performUpdate();
}
public override willUpdate(changed: PropertyValues<this>) {
if (changed.has("value") && this.value) {
const newDisplayValue = this.findDisplayForValue(this.value);
if (newDisplayValue) {
this.displayValue = newDisplayValue;
}
}
}
get rawValue() {
return this.inputRef.value?.value ?? "";
}
get managedOptions() {
return this.managed
? this._options
: findOptionsSubset(this._options, this.rawValue, this.caseSensitive);
}
public override render() {
const emptyOption = this.blankable ? this.emptyOption : undefined;
const open = this.open;
return html`<div class="pf-c-select" part="ak-search-select">
<div class="pf-c-select__toggle pf-m-typeahead" part="ak-search-select-toggle">
<div class="pf-c-select__toggle-wrapper" part="ak-search-select-wrapper">
<input
part="ak-search-select-toggle-typeahead"
autocomplete="off"
class="pf-c-form-control pf-c-select__toggle-typeahead"
type="text"
@ -243,25 +366,45 @@ export class SearchSelectView extends AKElement {
placeholder=${this.placeholder}
spellcheck="false"
@input=${this.onInput}
@focus=${this.onFocus}
@click=${this.onClick}
@blur=${this.onListBlur}
@keydown=${this.onKeydown}
@focusout=${this.onFocusOut}
value=${this.displayValue}
/>
</div>
</div>
</div>
<ak-search-select-menu-position
name=${ifDefined(this.name)}
.options=${this.options}
value=${ifDefined(this.value)}
.host=${this}
.anchor=${this.inputRef.value}
.emptyOption=${(this.blankable && this.emptyOption) || undefined}
${ref(this.menuRef)}
?open=${this.open}
></ak-search-select-menu-position> `;
${this.inputRefIsAvailable
? html`
<ak-portal
name=${ifDefined(this.name)}
.anchor=${this.inputRef.value}
?open=${open}
>
<ak-list-select
id="menu-${this.getAttribute("data-ouia-component-id")}"
${ref(this.menuRef)}
.options=${this.managedOptions}
value=${ifDefined(this.value)}
@change=${this.onListChange}
@blur=${this.onListBlur}
emptyOption=${ifDefined(emptyOption)}
@keydown=${this.onListKeydown}
></ak-list-select>
</ak-portal>
`
: nothing}`;
}
public override updated() {
this.setAttribute("data-ouia-component-safe", "true");
}
public override firstUpdated() {
// Route around Lit's scheduling algorithm complaining about re-renders
window.setTimeout(() => {
this.inputRefIsAvailable = Boolean(this.inputRef?.value);
}, 0);
}
}

View File

@ -1,27 +1,61 @@
import { EVENT_REFRESH } from "@goauthentik/common/constants";
import { APIErrorTypes, parseAPIError } from "@goauthentik/common/errors";
import { groupBy } from "@goauthentik/common/utils";
import { AkControlElement } from "@goauthentik/elements/AkControlElement.js";
import { PreventFormSubmit } from "@goauthentik/elements/forms/helpers";
import { CustomEmitterElement } from "@goauthentik/elements/utils/eventEmitter";
import { msg } from "@lit/localize";
import { TemplateResult, html } from "lit";
import { customElement, property, state } from "lit/decorators.js";
import { ifDefined } from "lit/directives/if-defined.js";
import { TemplateResult } from "lit";
import { customElement, property } from "lit/decorators.js";
import PFBase from "@patternfly/patternfly/patternfly-base.css";
import { ResponseError } from "@goauthentik/api";
import { type ISearchSelectBase, SearchSelectBase } from "./SearchSelect.js";
import { SearchSelectInputEvent, SearchSelectSelectEvent } from "./SearchSelectEvents.js";
import "./ak-search-select-view.js";
import type { GroupedOptions, SearchGroup, SearchTuple } from "./types.js";
export interface ISearchSelect<T> extends ISearchSelectBase<T> {
fetchObjects: (query?: string) => Promise<T[]>;
renderElement: (element: T) => string;
renderDescription?: (element: T) => string | TemplateResult;
value: (element: T | undefined) => unknown;
selected?: (element: T, elements: T[]) => boolean;
groupBy: (items: T[]) => [string, T[]][];
}
type Group<T> = [string, T[]];
/**
* @class SearchSelect
* @element ak-search-select
*
* The API layer of ak-search-select
*
* - @prop fetchObjects (Function): The function by which objects are retrieved by the API.
* - @prop renderElement (Function | string): Either a function that can retrieve the string
* "label" of the element, or the name of the field from which the label can be retrieved.¹
* - @prop renderDescription (Function | string): Either a function that can retrieve the string
* or TemplateResult "description" of the element, or the name of the field from which the
* description can be retrieved.¹
* - @prop value (Function | string): Either a function that can retrieve the value (the current
* API object's primary key) selected or the name of the field from which the value can be
* retrieved.¹
* - @prop selected (Function): A function that retrieves the current "live" value from the
list of objects fetched by the function above.
* - @prop groupBy (Function): A function that can group the objects fetched from the API by
an internal criteria.
* - @attr blankable (boolean): if true, the component is blankable and can return `undefined`
* - @attr name (string): The name of the component, for forms
* - @attr query (string): The current search criteria for fetching objects
* - @attr placeholder (string): What to show when the input is empty
* - @attr emptyOption (string): What to show in the menu to indicate "leave this undefined". Only
* shown if `blankable`
* - @attr selectedObject (Object<T>): The current object, or undefined, selected
*
* ¹ Due to a limitation in the parsing of properties-vs-attributes, these must be defined as
* properties, not attributes. As a consequence, they must be declared in property syntax.
* Example:
*
* `.renderElement=${"name"}`
*
* - @fires ak-change - When a value from the collection has been positively chosen, either as a
* consequence of the user typing or when selecting from the list.
*
*/
@customElement("ak-search-select")
export class SearchSelect<T> extends CustomEmitterElement(AkControlElement) {
export class SearchSelect<T> extends SearchSelectBase<T> implements ISearchSelect<T> {
static get styles() {
return [PFBase];
}
@ -39,7 +73,7 @@ export class SearchSelect<T> extends CustomEmitterElement(AkControlElement) {
// A function passed to this object that extracts an HTML representation of additional
// information for items of the collection under search.
@property({ attribute: false })
renderDescription?: (element: T) => TemplateResult;
renderDescription?: (element: T) => string | TemplateResult;
// A function which returns the currently selected object's primary key, used for serialization
// into forms.
@ -60,174 +94,6 @@ export class SearchSelect<T> extends CustomEmitterElement(AkControlElement) {
return "";
});
};
// Whether or not the dropdown component can be left blank
@property({ type: Boolean })
blankable = false;
// An initial string to filter the search contents, and the value of the input which further
// serves to restrict the search
@property()
query?: string;
// The objects currently available under search
@property({ attribute: false })
objects?: T[];
// The currently selected object
@property({ attribute: false })
selectedObject?: T;
// Used to inform the form of the name of the object
@property()
name?: string;
// The textual placeholder for the search's <input> object, if currently empty. Used as the
// native <input> object's `placeholder` field.
@property()
placeholder: string = msg("Select an object.");
// A textual string representing "The user has affirmed they want to leave the selection blank."
// Only used if `blankable` above is true.
@property()
emptyOption = "---------";
isFetchingData = false;
@state()
error?: APIErrorTypes;
toForm(): unknown {
if (!this.objects) {
throw new PreventFormSubmit(msg("Loading options..."));
}
return this.value(this.selectedObject) || "";
}
json() {
return this.toForm();
}
async updateData() {
if (this.isFetchingData) {
return Promise.resolve();
}
this.isFetchingData = true;
return this.fetchObjects(this.query)
.then((objects) => {
objects.forEach((obj) => {
if (this.selected && this.selected(obj, objects || [])) {
this.selectedObject = obj;
this.dispatchCustomEvent("ak-change", { value: this.selectedObject });
}
});
this.objects = objects;
this.isFetchingData = false;
})
.catch((exc: ResponseError) => {
this.isFetchingData = false;
this.objects = undefined;
parseAPIError(exc).then((err) => {
this.error = err;
});
});
}
connectedCallback(): void {
super.connectedCallback();
this.dataset.akControl = "true";
this.updateData();
this.addEventListener(EVENT_REFRESH, this.updateData);
}
disconnectedCallback(): void {
super.disconnectedCallback();
this.removeEventListener(EVENT_REFRESH, this.updateData);
}
onSearch(event: SearchSelectInputEvent) {
if (event.value === undefined) {
this.selectedObject = undefined;
return;
}
this.query = event.value;
this.updateData()?.then(() => {
this.dispatchCustomEvent("ak-change", { value: this.selectedObject });
});
}
onSelect(event: SearchSelectSelectEvent) {
if (event.value === undefined) {
this.selectedObject = undefined;
this.dispatchCustomEvent("ak-change", { value: undefined });
return;
}
const selected = (this.objects ?? []).find((obj) => `${this.value(obj)}` === event.value);
if (!selected) {
console.warn(
`ak-search-select: No corresponding object found for value (${event.value}`,
);
}
this.selectedObject = selected;
this.dispatchCustomEvent("ak-change", { value: this.selectedObject });
}
getGroupedItems(): GroupedOptions {
const items = this.groupBy(this.objects || []);
const makeSearchTuples = (items: T[]): SearchTuple[] =>
items.map((item) => [
`${this.value(item)}`,
this.renderElement(item),
this.renderDescription ? this.renderDescription(item) : undefined,
]);
const makeSearchGroups = (items: Group<T>[]): SearchGroup[] =>
items.map((group) => ({
name: group[0],
options: makeSearchTuples(group[1]),
}));
if (items.length === 0) {
return { grouped: false, options: [] };
}
if (items.length === 1 && (items[0].length < 1 || items[0][0] === "")) {
return {
grouped: false,
options: makeSearchTuples(items[0][1]),
};
}
return {
grouped: true,
options: makeSearchGroups(items),
};
}
render() {
if (this.error) {
return html`<em>${msg("Failed to fetch objects: ")} ${this.error.detail}</em>`;
}
if (!this.objects) {
return html`${msg("Loading...")}`;
}
const options = this.getGroupedItems();
const value = this.selectedObject ? `${this.value(this.selectedObject) ?? ""}` : undefined;
return html`<ak-search-select-view
.options=${options}
.value=${value}
?blankable=${this.blankable}
name=${ifDefined(this.name)}
placeholder=${this.placeholder}
emptyOption=${ifDefined(this.blankable ? this.emptyOption : undefined)}
@ak-search-select-input=${this.onSearch}
@ak-search-select-select=${this.onSelect}
></ak-search-select-view> `;
}
}
export default SearchSelect;

View File

@ -1,119 +0,0 @@
import "@goauthentik/elements/messages/MessageContainer";
import { Meta, StoryObj } from "@storybook/web-components";
import { slug } from "github-slugger";
import { TemplateResult, html } from "lit";
import { SearchSelectSelectMenuEvent } from "../SearchSelectEvents.js";
import "../ak-search-select-menu.js";
import { SearchSelectMenu } from "../ak-search-select-menu.js";
import { groupedSampleData, sampleData } from "./sampleData.js";
const metadata: Meta<SearchSelectMenu> = {
title: "Elements / Search Select / Tethered Menu",
component: "ak-search-select-menu",
parameters: {
docs: {
description: {
component: "The tethered panel containing the scrollable list of selectable items",
},
},
},
argTypes: {
options: {
type: "string",
description: "An array of [key, label, desc] pairs of what to show",
},
},
};
export default metadata;
const onClick = (event: SearchSelectSelectMenuEvent) => {
const target = document.querySelector("#action-button-message-pad");
target!.innerHTML = "";
target!.append(
new DOMParser().parseFromString(`<li>${event.value}</li>`, "text/xml").firstChild!,
);
};
const container = (testItem: TemplateResult) => {
window.setTimeout(() => {
const menu = document.getElementById("ak-search-select-menu");
const container = document.getElementById("the-main-event");
if (menu && container) {
container.addEventListener("ak-search-select-select-menu", onClick);
(menu as SearchSelectMenu).host = container;
}
}, 250);
return html` <div
style="background: #fff; padding: 2em; position: relative"
id="the-main-event"
>
<style>
li {
display: block;
}
p {
margin-top: 1em;
}
#the-answer-block {
padding-top: 3em;
}
</style>
<ak-message-container></ak-message-container>
${testItem}
<div id="the-answer-block">
<p>Messages received from the menu:</p>
<ul id="action-button-message-pad" style="margin-top: 1em"></ul>
</div>
</div>`;
};
type Story = StoryObj;
const goodForYouPairs = {
grouped: false,
options: sampleData.slice(0, 20).map(({ produce }) => [slug(produce), produce]),
};
export const Default: Story = {
render: () =>
container(
html` <ak-search-select-menu
id="ak-search-select-menu"
style="top: 1em; left: 1em"
.options=${goodForYouPairs}
></ak-search-select-menu>`,
),
};
const longGoodForYouPairs = {
grouped: false,
options: sampleData.map(({ produce }) => [slug(produce), produce]),
};
export const Scrolling: Story = {
render: () =>
container(
html` <ak-search-select-menu
id="ak-search-select-menu"
style="top: 1em; left: 1em"
.options=${longGoodForYouPairs}
.host=${document}
></ak-search-select-menu>`,
),
};
export const Grouped: Story = {
render: () =>
container(
html` <ak-search-select-menu
id="ak-search-select-menu"
style="top: 1em; left: 1em"
.options=${groupedSampleData}
.host=${document}
></ak-search-select-menu>`,
),
};

View File

@ -1,6 +1,8 @@
import { groupBy } from "@goauthentik/common/utils";
import "@goauthentik/elements/forms/SearchSelect/ak-search-select";
import { SearchSelect } from "@goauthentik/elements/forms/SearchSelect/ak-search-select";
import "@goauthentik/elements/forms/SearchSelect/ak-search-select-ez";
import { type ISearchSelectApi } from "@goauthentik/elements/forms/SearchSelect/ak-search-select-ez";
import { Meta } from "@storybook/web-components";
import { TemplateResult, html } from "lit";
@ -59,11 +61,8 @@ const container = (testItem: TemplateResult) =>
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const displayChange = (ev: any) => {
document.getElementById("message-pad")!.innerText = `Value selected: ${JSON.stringify(
ev.detail.value,
null,
2,
)}`;
document.getElementById("message-pad")!.innerText =
`Value selected: ${JSON.stringify(ev.detail.value, null, 2)}`;
};
export const Default = () =>
@ -89,6 +88,23 @@ export const Grouped = () => {
);
};
export const GroupedAndEz = () => {
const config: ISearchSelectApi<Sample> = {
fetchObjects: getSamples,
renderElement: (sample: Sample) => sample.name,
value: (sample: Sample | undefined) => sample?.pk,
groupBy: (samples: Sample[]) =>
groupBy(samples, (sample: Sample) => sample.season[0] ?? ""),
};
return container(
html`<ak-search-select-ez
.config=${config}
@ak-change=${displayChange}
></ak-search-select-ez>`,
);
};
export const SelectedAndBlankable = () => {
return container(
html`<ak-search-select

View File

@ -0,0 +1,74 @@
import { $, browser } from "@wdio/globals";
browser.addCommand(
"focus",
function () {
browser.execute(function (domElement) {
domElement.focus();
// @ts-ignore
}, this);
},
true,
);
/**
* Search Select View Driver
*
* This class provides a collection of easy-to-use methods for interacting with and examining the
* results of an interaction with an `ak-search-select-view` via WebdriverIO.
*
* It's hoped that with the OUIA tags, we could automate testing further. The OUIA tag would
* instruct the test harness "use this driver to test this component," and we could test Forms and
* Tables with a small DSL of test language concepts
*/
export class AkSearchSelectViewDriver {
constructor(
public element: WebdriverIO.Element,
public menu: WebdriverIO.Element,
) {
/* no op */
}
static async build(element: WebdriverIO.Element) {
const tagname = await element.getTagName();
const comptype = await element.getAttribute("data-ouia-component-type");
if (comptype !== "ak-search-select-view") {
throw new Error(
`SearchSelectView driver passed incorrect component. Expected ak-search-select-view, got ${comptype ? `'${comptype}` : `No test data type, tag name: '${tagname}'`}`,
);
}
const id = await element.getAttribute("data-ouia-component-id");
const menu = await $(`[data-ouia-component-id="menu-${id}"]`);
return new AkSearchSelectViewDriver(element, menu);
}
get open() {
return this.element.getProperty("open");
}
async input() {
return await this.element.$(">>>input");
}
async listElements() {
return await this.menu.$$(">>>li");
}
async focusOnInput() {
// @ts-ignore
await (await this.input()).focus();
}
async inputIsVisible() {
return await this.element.$(">>>input").isDisplayed();
}
async menuIsVisible() {
return (await this.menu.isExisting()) && (await this.menu.isDisplayed());
}
async clickInput() {
return await (await this.input()).click();
}
}

View File

@ -0,0 +1,104 @@
import { $, browser } from "@wdio/globals";
import { slug } from "github-slugger";
import { Key } from "webdriverio";
import { html, render } from "lit";
import "../ak-search-select-view.js";
import { sampleData } from "../stories/sampleData.js";
import { AkSearchSelectViewDriver } from "./ak-search-select-view.comp.js";
const longGoodForYouPairs = {
grouped: false,
options: sampleData.map(({ produce }) => [slug(produce), produce]),
};
describe("Search select: Test Input Field", () => {
let select: AkSearchSelectViewDriver;
beforeEach(async () => {
await render(
html`<ak-search-select-view .options=${longGoodForYouPairs}> </ak-search-select-view>`,
document.body,
);
// @ts-ignore
select = await AkSearchSelectViewDriver.build(await $("ak-search-select-view"));
});
it("should open the menu when the input is clicked", async () => {
expect(await select.open).toBe(false);
expect(await select.menuIsVisible()).toBe(false);
await select.clickInput();
expect(await select.open).toBe(true);
// expect(await select.menuIsVisible()).toBe(true);
});
it("should not open the menu when the input is focused", async () => {
expect(await select.open).toBe(false);
await select.focusOnInput();
expect(await select.open).toBe(false);
expect(await select.menuIsVisible()).toBe(false);
});
it("should close the menu when the input is clicked a second time", async () => {
expect(await select.open).toBe(false);
expect(await select.menuIsVisible()).toBe(false);
await select.clickInput();
expect(await select.menuIsVisible()).toBe(true);
expect(await select.open).toBe(true);
await select.clickInput();
expect(await select.open).toBe(false);
expect(await select.open).toBe(false);
});
it("should open the menu from a focused but closed input when a search is begun", async () => {
expect(await select.open).toBe(false);
await select.focusOnInput();
expect(await select.open).toBe(false);
expect(await select.menuIsVisible()).toBe(false);
await browser.keys("A");
expect(await select.open).toBe(true);
expect(await select.menuIsVisible()).toBe(true);
});
it("should update the list as the user types", async () => {
await select.focusOnInput();
await browser.keys("Ap");
expect(await select.menuIsVisible()).toBe(true);
const elements = Array.from(await select.listElements());
expect(elements.length).toBe(2);
});
it("set the value when a match is close", async () => {
await select.focusOnInput();
await browser.keys("Ap");
expect(await select.menuIsVisible()).toBe(true);
const elements = Array.from(await select.listElements());
expect(elements.length).toBe(2);
await browser.keys(Key.Tab);
expect(await (await select.input()).getValue()).toBe("Apples");
});
it("should close the menu when the user clicks away", async () => {
document.body.insertAdjacentHTML(
"afterbegin",
'<input id="a-separate-component" type="text" />',
);
const input = await browser.$("#a-separate-component");
await select.clickInput();
expect(await select.open).toBe(true);
await input.click();
expect(await select.open).toBe(false);
});
afterEach(async () => {
await document.body.querySelector("#a-separate-component")?.remove();
await document.body.querySelector("ak-search-select-view")?.remove();
// @ts-expect-error expression of type '"_$litPart$"' is added by Lit
if (document.body["_$litPart$"]) {
// @ts-expect-error expression of type '"_$litPart$"' is added by Lit
delete document.body["_$litPart$"];
}
});
});

View File

@ -0,0 +1,22 @@
const isStyledVisible = ({ visibility, display }: CSSStyleDeclaration) =>
visibility !== "hidden" && display !== "none";
const isDisplayContents = ({ display }: CSSStyleDeclaration) => display === "contents";
function computedStyleIsVisible(element: HTMLElement) {
const computedStyle = window.getComputedStyle(element);
return (
isStyledVisible(computedStyle) &&
(isDisplayContents(computedStyle) ||
!!(element.offsetWidth || element.offsetHeight || element.getClientRects().length))
);
}
export function isVisible(element: HTMLElement) {
return (
element &&
element.isConnected &&
isStyledVisible(element.style) &&
computedStyleIsVisible(element)
);
}

View File

@ -1,16 +1,67 @@
import type { SearchOptions, SearchTuple } from "./types.js";
import type {
GroupedOptions,
SelectGrouped,
SelectOption,
SelectOptions,
} from "@goauthentik/elements/types.js";
type Pair = [string, string];
const justThePair = ([key, label]: SearchTuple): Pair => [key, label];
type Pair = [string, SelectOption];
const mapPair = (option: SelectOption): Pair => [option[0], option];
export function optionsToOptionsMap(options: SearchOptions): Map<string, string> {
const pairs: Pair[] = Array.isArray(options)
? options.map(justThePair)
: options.grouped
? options.options.reduce(
(acc: Pair[], { options }): Pair[] => [...acc, ...options.map(justThePair)],
[] as Pair[],
)
: options.options.map(justThePair);
return new Map(pairs);
const isSelectOptionsArray = (v: unknown): v is SelectOption[] => Array.isArray(v);
// prettier-ignore
const isGroupedOptionsCollection = (v: unknown): v is SelectGrouped =>
v !== null && typeof v === "object" && "grouped" in v && v.grouped === true;
export const groupOptions = (options: SelectOptions): GroupedOptions =>
isSelectOptionsArray(options) ? { grouped: false, options: options } : options;
export function optionsToFlat(groupedOptions: GroupedOptions): Pair[] {
return isGroupedOptionsCollection(groupedOptions)
? groupedOptions.options.reduce(
(acc: Pair[], { options }): Pair[] => [...acc, ...options.map(mapPair)],
[] as Pair[],
)
: groupedOptions.options.map(mapPair);
}
export function findFlatOptions(options: Pair[], value: string): Pair[] {
const fragLength = value.length;
return options.filter((option) => (option[1][1] ?? "").substring(0, fragLength) === value);
}
export function findOptionsSubset(
groupedOptions: GroupedOptions,
value: string,
caseSensitive = false,
): GroupedOptions {
const fragLength = value.length;
if (value.trim() === "") {
return groupedOptions;
}
const compValue = caseSensitive ? value : value.toLowerCase();
const compOption = (option: SelectOption) => {
const extractedOption = (option[1] ?? "").substring(0, fragLength);
return caseSensitive ? extractedOption : extractedOption.toLowerCase();
};
const optFilter = (options: SelectOption[]) =>
options.filter((option) => compOption(option) === compValue);
return groupedOptions.grouped
? {
grouped: true,
options: groupedOptions.options
.map(({ name, options }) => ({
name,
options: optFilter(options),
}))
.filter(({ options }) => options.length !== 0),
}
: {
grouped: false,
options: optFilter(groupedOptions.options),
};
}

View File

@ -1,5 +1,6 @@
import { AKElement } from "@goauthentik/elements/Base";
import { TemplateResult } from "lit";
import { ReactiveControllerHost } from "lit";
export type ReactiveElementHost<T = AKElement> = Partial<ReactiveControllerHost> & T;
@ -9,3 +10,66 @@ export type Constructor<T = object> = new (...args: any[]) => T;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export type AbstractConstructor<T = object> = abstract new (...args: any[]) => T;
// authentik Search/List types
//
// authentik's list types (ak-dual-select, ak-list-select, ak-search-select) all take a tuple of two
// or three items, or a collection of groups of such tuples. In order to push dynamic checking
// around, we also allow the inclusion of a fourth component, which is just a scratchpad the
// developer can use for their own reasons.
// The displayed element for our list can be a TemplateResult. If it is, we *strongly* recommend
// that you include the `sortBy` string as well, which is used for sorting but is also used for our
// autocomplete element (ak-search-select) both for tracking the user's input and for what we
// display in the autocomplete input box.
// - key: string
// - label (string). This is the field that will be sorted and used for filtering and searching.
// - desc (optional) A string or TemplateResult used to describe the option.
// - localMapping: The object the key represents; used by some specific apps. API layers may use
// this as a way to find the referenced object, rather than the string and keeping a local map.
//
// Note that this is a *tuple*, not a record or map!
// prettier-ignore
export type SelectOption<T = never> = [
key: string,
label: string,
desc?: string | TemplateResult,
localMapping?: T,
];
/**
* A search list without groups will always just consist of an array of SelectTuples and the
* `grouped: false` flag. Note that it *is* possible to pass to any of the rendering components an
* array of SelectTuples; they will be automatically mapped to a SelectFlat object.
*
*/
/* PRIVATE */
export type SelectFlat<T = never> = {
grouped: false;
options: SelectOption<T>[];
};
/**
* A search group consists of a group name and a collection of SelectTuples.
*
*/
export type SelectGroup<T = never> = { name: string; options: SelectOption<T>[] };
/**
* A grouped search is an array of SelectGroups, of course!
*
*/
export type SelectGrouped<T = never> = {
grouped: true;
options: SelectGroup<T>[];
};
/**
* Internally, we only work with these two, but we have the `SelectOptions` variant
* below to support the case where you just want to pass in an array of SelectTuples.
*
*/
export type GroupedOptions<T = never> = SelectGrouped<T> | SelectFlat<T>;
export type SelectOptions<T = never> = SelectOption<T>[] | GroupedOptions<T>;

View File

@ -0,0 +1,22 @@
const isStyledVisible = ({ visibility, display }: CSSStyleDeclaration) =>
visibility !== "hidden" && display !== "none";
const isDisplayContents = ({ display }: CSSStyleDeclaration) => display === "contents";
function computedStyleIsVisible(element: HTMLElement) {
const computedStyle = window.getComputedStyle(element);
return (
isStyledVisible(computedStyle) &&
(isDisplayContents(computedStyle) ||
!!(element.offsetWidth || element.offsetHeight || element.getClientRects().length))
);
}
export function isVisible(element: HTMLElement) {
return (
element &&
element.isConnected &&
isStyledVisible(element.style) &&
computedStyleIsVisible(element)
);
}