Flesh out.

This commit is contained in:
Teffen Ellis
2026-03-07 08:19:42 +01:00
parent 5233e1c331
commit a9abf27162
15 changed files with 605 additions and 246 deletions

View File

@@ -62,7 +62,7 @@ async function fetchAboutDetails(): Promise<AboutEntry[]> {
export class AboutModal extends WithLicenseSummary(WithBrandConfig(AKModal)) {
static hostStyles = [
css`
.ak-c-modal:has(ak-about-modal) {
dialog.ak-c-modal:has(ak-about-modal) {
--ak-c-modal--BackgroundColor: var(--pf-global--palette--black-900);
--ak-c-modal--BorderColor: var(--pf-global--palette--black-600);
}

View File

@@ -1,6 +1,5 @@
import "#elements/banner/EnterpriseStatusBanner";
import "#elements/banner/VersionBanner";
import "#elements/commands/ak-command-palette";
import "#elements/messages/MessageContainer";
import "#elements/router/RouterOutlet";
import "#elements/sidebar/Sidebar";
@@ -19,7 +18,6 @@ import { isGuest } from "#common/users";
import { WebsocketClient } from "#common/ws/WebSocketClient";
import { AuthenticatedInterface } from "#elements/AuthenticatedInterface";
import { AKCommandPalette } from "#elements/commands/ak-command-palette";
import { listen } from "#elements/decorators/listen";
import { WithCapabilitiesConfig } from "#elements/mixins/capabilities";
import { WithNotifications } from "#elements/mixins/notifications";
@@ -117,8 +115,6 @@ export class AdminInterface extends WithCapabilitiesConfig(
//#region Lifecycle
protected commandPalette: AKCommandPalette;
constructor() {
configureSentry();
@@ -126,11 +122,45 @@ export class AdminInterface extends WithCapabilitiesConfig(
WebsocketClient.connect();
this.commandPalette = this.ownerDocument.createElement("ak-command-palette");
this.#sidebarMatcher = window.matchMedia("(width >= 1200px)");
this.sidebarOpen = this.#sidebarMatcher.matches;
}
#refreshCommandsFrameID = -1;
#refreshCommands = () => {
const commands = [
{
label: msg("Create a new application..."),
action: () => navigate("/core/applications", { createWizard: true }),
prefix: msg("Jump to", { id: "command-palette.prefix.jump-to" }),
group: msg("Applications"),
},
{
label: msg("Check the logs"),
action: () => navigate("/events/log"),
group: msg("Events"),
},
{
label: msg("Manage users"),
action: () => navigate("/identity/users"),
group: msg("Users"),
},
...this.entries.flatMap(([, label, , children]) => [
...(children ?? []).map(([path, childLabel]) => ({
label: childLabel,
prefix: msg("Jump to", { id: "command-palette.prefix.jump-to" }),
group: label,
action: () => {
navigate(path!);
},
})),
]),
];
this.commandPalette.modal.setCommands(commands);
};
public connectedCallback() {
super.connectedCallback();
@@ -142,6 +172,8 @@ export class AdminInterface extends WithCapabilitiesConfig(
public disconnectedCallback(): void {
super.disconnectedCallback();
cancelAnimationFrame(this.#refreshCommandsFrameID);
this.#sidebarMatcher.removeEventListener("change", this.#sidebarMediaQueryListener);
WebsocketClient.close();
@@ -150,18 +182,7 @@ export class AdminInterface extends WithCapabilitiesConfig(
public firstUpdated(changedProperties: PropertyValues<this>): void {
super.firstUpdated(changedProperties);
this.commandPalette.modal.addCommands(
this.entries.flatMap(([, label, , children]) => [
...(children ?? []).map(([path, childLabel]) => ({
label: childLabel,
suffix: msg("Jump to", { id: "command-palette.prefix.jump-to" }),
group: label,
action: () => {
navigate(path!);
},
})),
]),
);
this.#refreshCommandsFrameID = requestAnimationFrame(this.#refreshCommands);
}
public override updated(changedProperties: PropertyValues<this>): void {

View File

@@ -1,7 +1,10 @@
import "#elements/commands/ak-command-palette";
import { globalAK } from "#common/global";
import { applyDocumentTheme, createUIThemeEffect } from "#common/theme";
import { AKElement } from "#elements/Base";
import { AKCommandPalette } from "#elements/commands/ak-command-palette";
import { BrandingContextController } from "#elements/controllers/BrandContextController";
import { ConfigContextController } from "#elements/controllers/ConfigContextController";
import { ContextControllerRegistry } from "#elements/controllers/ContextControllerRegistry";
@@ -30,6 +33,12 @@ export abstract class Interface extends AKElement {
*/
#registryKeys = new WeakMap<ReactiveController, ContextType<Context<unknown, unknown>>>();
/**
* The command palette instance. This must be inserted by the extending class,
* as the palette may depend on a context that is not available at the time of this class's construction.
*/
public readonly commandPalette: AKCommandPalette;
constructor() {
super();
@@ -45,6 +54,7 @@ export abstract class Interface extends AKElement {
this.addController(new ModalOrchestrationController());
this.id = "interface-root";
this.commandPalette = this.ownerDocument.createElement("ak-command-palette");
}
public override addController(

View File

@@ -1,13 +1,21 @@
import { CURRENT_CLASS, EVENT_REFRESH } from "#common/constants";
import { AKElement } from "#elements/Base";
import {
CommandPaletteState,
PaletteCommandAction,
PaletteCommandDefinition,
} from "#elements/commands/shared";
import { intersectionObserver } from "#elements/decorators/intersection-observer";
import { getURLParams, updateURLParams } from "#elements/router/RouteMatch";
import Styles from "#elements/Tabs.css" with { type: "bundled-text" };
import { ifPresent } from "#elements/utils/attributes";
import { isFocusable } from "#elements/utils/focus";
import { msg } from "@lit/localize";
import { CSSResult, html, LitElement, TemplateResult } from "lit";
import { capitalCase } from "change-case";
import { msg, str } from "@lit/localize";
import { CSSResult, html, LitElement, PropertyValues, TemplateResult } from "lit";
import { customElement, property, state } from "lit/decorators.js";
import { createRef, ref } from "lit/directives/ref.js";
@@ -30,16 +38,59 @@ export class Tabs extends AKElement {
@state()
protected tabs: ReadonlyMap<string, Element> = new Map();
/**
* Whether the tab is visible in the viewport.
*/
@intersectionObserver()
public visible = false;
#focusTargetRef = createRef<HTMLSlotElement>();
#observer: MutationObserver | null = null;
#commands = new CommandPaletteState<string>();
#updateTabs = (): void => {
this.tabs = new Map(
Array.from(this.querySelectorAll(":scope > [slot^='page-']"), (element) => {
return [element.getAttribute("slot") || "", element];
}),
);
requestAnimationFrame(this.#updateCommands);
};
#updateCommands = (): void => {
const commands: PaletteCommandDefinition<string>[] = [];
if (!this.visible) {
this.#commands.clear();
return;
}
const group = msg(str`Landmark: ${capitalCase(this.pageIdentifier)}`);
const prefix = msg("Switch to tab", { id: "command-palette.switch-to-tab" });
const action: PaletteCommandAction<string> = (slotName) => {
this.activateTab(slotName);
};
for (const [slotName, tabPanel] of this.tabs) {
if (this.activeTabName === slotName) {
continue;
}
const label = tabPanel.getAttribute("aria-label") || slotName;
commands.push({
label,
action,
group,
prefix,
details: slotName,
});
}
this.#commands.set(commands);
};
public override connectedCallback(): void {
@@ -78,9 +129,18 @@ export class Tabs extends AKElement {
public override disconnectedCallback(): void {
this.#observer?.disconnect();
this.#commands.clear();
super.disconnectedCallback();
}
public override updated(changedProperties: PropertyValues<this>): void {
super.updated(changedProperties);
if (changedProperties.has("visible")) {
this.#updateCommands();
}
}
public findActiveTabPanel(): Element | null {
return this.querySelector(`[slot='${this.activeTabName}']`);
}

View File

@@ -10,29 +10,25 @@
--pf-global--BackgroundColor--dark-transparent-200
);
--ak-fieldset--BorderColor: var(--pf-global--palette--purple-500);
--ak-c-command-palette__item--BackgroundColor: transparent;
--ak-c-command-palette__item--Color: var(--pf-global--palette--purple-700);
--ak-c-command-palette__item--Color: var(--pf-global--palette--blue-50);
--ak-c-command-palette__item--selected--BackgroundColor: var(--pf-global--palette--blue-400);
--ak-c-command-palette__item--hover--BackgroundColor: var(--pf-global--palette--blue-600);
--ak-c-command-palette__item--hover--BackgroundColor: var(--pf-global--palette--purple-600);
--ak-c-command-palette__item--hover-selected--BackgroundColor: var(
--pf-global--palette--blue-300
);
--pf-global--Color--100: var(--pf-global--Color--light-100);
--pf-global--Color--100: var(--pf-global--palette--purple-50);
@media (prefers-reduced-transparency: reduce) {
--ak-c-command-palette--Translucency: 0%;
}
}
fieldset {
--ak-fieldset-border-color: var(--ak-c-command-palette__group--BorderColor) !important;
&:has(.more-contrast-only) {
--ak-c-command-palette__group--BorderColor: transparent;
}
will-change: opacity, text-decoration-color, background-color, color;
transform: translate3d(0, 0, 0); /* Fixes rendering artifacts. */
}
:host {
@@ -45,16 +41,13 @@ fieldset {
[part="command-field"] {
border-bottom: 0.5px solid var(--pf-global--palette--black-600);
margin-block-end: var(--pf-global--spacer--sm);
padding-block-end: var(--pf-global--spacer--sm);
margin-block-end: var(--pf-global--spacer--xs);
padding-block-end: var(--pf-global--spacer--xs);
margin-inline: var(--pf-global--spacer--sm);
legend {
padding: 0 !important;
}
}
#command-input {
background: transparent;
display: block;
width: 100%;
font-size: var(--pf-global--FontSize--2xl);
@@ -64,6 +57,26 @@ fieldset {
border: none;
outline: none;
&::placeholder {
color: var(--pf-global--palette--purple-100);
font-weight: 100;
font-family: var(--pf-global--FontFamily--heading--sans-serif);
}
}
[part="results-group"] {
border-width: 0.5px;
padding-inline: 0 !important;
padding-block-end: 0 !important;
legend {
padding-block: var(--pf-global--spacer--xs) !important;
}
&:not(:first-child) {
margin-block-start: var(--pf-global--spacer--md);
}
}
[part="results"] {
@@ -71,15 +84,34 @@ fieldset {
max-height: calc(100dvh - (1.75 * var(--ak-c-modal--MarginBlockStart)));
}
[part="results-list"] {
margin-block-start: calc(var(--pf-global--spacer--sm) * -1);
}
[part="command-item-label"] {
flex: 1 1 auto;
grid-row: label;
font-family: var(--pf-global--FontFamily--heading--sans-serif);
}
[part="command-item-suffix"] {
[part="command-item-prefix"] {
grid-area: prefix;
font-variant: all-small-caps;
font-weight: bold;
color: var(--pf-global--palette--blue-300);
color: var(--pf-global--palette--gold-200);
}
[part="command-item-suffix"] {
grid-area: suffix;
font-variant: all-small-caps;
font-weight: bold;
display: flex;
color: var(--pf-global--palette--gold-200);
justify-content: end;
align-items: center;
}
[part="command-item-description"] {
grid-area: description;
}
[part="group-heading"] {
@@ -95,34 +127,40 @@ fieldset {
--ak-c-command-palette__item--hover--BackgroundColor: var(
--ak-c-command-palette__item--hover-selected--BackgroundColor
);
--ak-c-command-palette__item--AccentColor: var(--pf-global--primary-color--100);
}
.pf-c-button {
display: flex;
gap: var(--pf-global--spacer--sm);
--pf-c-button--PaddingTop: var(--pf-global--spacer--md);
--pf-c-button--PaddingBottom: var(--pf-global--spacer--md);
border-inline-start: 3px solid var(--ak-c-command-palette__item--AccentColor, transparent);
background-color: color-mix(
var(--ak-c-command-palette__item--BackgroundColor),
transparent var(--ak-c-command-palette--Translucency)
);
width: 100%;
border-radius: 0;
text-align: start;
color: var(--ak-c-command-palette__item--Color);
&:hover {
background-color: color-mix(
var(--ak-c-command-palette__item--hover--BackgroundColor),
transparent var(--ak-c-command-palette--Translucency)
);
}
--ak-c-command-palette__item--AccentColor: var(--ak-accent);
}
}
[part="label"] {
[part="command-button"] {
display: grid;
grid-template-areas:
"prefix prefix prefix suffix"
"icon label label suffix"
"icon description description suffix";
width: 100%;
background: transparent;
padding: var(--pf-global--spacer--md);
border: none;
column-gap: var(--pf-global--spacer--sm);
border-inline-start: 3px solid var(--ak-c-command-palette__item--AccentColor, transparent);
background-color: color-mix(
var(--ak-c-command-palette__item--BackgroundColor),
transparent var(--ak-c-command-palette--Translucency)
);
width: 100%;
border-radius: 0;
text-align: start;
color: var(--ak-c-command-palette__item--Color);
&:hover {
background-color: color-mix(
var(--ak-c-command-palette__item--hover--BackgroundColor),
transparent var(--ak-c-command-palette--Translucency)
);
}
}
[part="input-label"] {
cursor: pointer;
position: absolute;
inset-block-start: var(--ak-c-command-palette--PaddingBlock);

View File

@@ -4,12 +4,11 @@ import { torusIndex } from "#common/collections";
import { PFSize } from "#common/enums";
import Styles from "#elements/commands/ak-command-palette-modal.css";
import { AKRegisterCommandsEvent } from "#elements/commands/events";
import { CommandPaletteCommand } from "#elements/commands/shared";
import { AKCommandChangeEvent } from "#elements/commands/events";
import { PaletteCommandDefinition } from "#elements/commands/shared";
import { listen } from "#elements/decorators/listen";
import { AKModal } from "#elements/modals/ak-modal";
import { asInvoker } from "#elements/modals/utils";
import { navigate } from "#elements/router/RouterOutlet";
import { SlottedTemplateResult } from "#elements/types";
import { FocusTarget } from "#elements/utils/focus";
@@ -21,9 +20,42 @@ import { msg, str } from "@lit/localize";
import { html, PropertyValues } from "lit";
import { guard } from "lit-html/directives/guard.js";
import { createRef, ref } from "lit-html/directives/ref.js";
import { customElement, property } from "lit/decorators.js";
import { customElement, property, state } from "lit/decorators.js";
import { repeat } from "lit/directives/repeat.js";
function openDocsSearch(query: string) {
const url = new URL("/search", import.meta.env.AK_DOCS_URL);
url.searchParams.set("q", query);
window.open(url, "_ak_docs", "noopener,noreferrer");
}
function createCommonCommands(): PaletteCommandDefinition<unknown>[] {
return [
{
label: msg("Integrations"),
prefix: msg("View", { id: "command-palette.prefix.view" }),
action: () => window.open("https://integrations.goauthentik.io/", "_blank"),
group: msg("Documentation"),
},
{
label: msg("Release notes"),
action: () => window.open(import.meta.env.AK_DOCS_RELEASE_NOTES_URL, "_blank"),
prefix: msg("View", { id: "command-palette.prefix.view" }),
suffix: msg(str`New in ${import.meta.env.AK_VERSION}`, {
id: "command-palette.suffix.new-in",
}),
group: msg("authentik"),
},
{
label: msg("About authentik"),
action: AboutModal.open,
prefix: msg("View", { id: "command-palette.prefix.view" }),
group: msg("authentik"),
},
];
}
@customElement("ak-command-palette-modal")
export class AKCommandPaletteModal extends AKModal {
static openOnConnect = false;
@@ -35,6 +67,9 @@ export class AKCommandPaletteModal extends AKModal {
protected autofocusTarget = new FocusTarget<HTMLInputElement>();
protected formRef = createRef<HTMLFormElement>();
#scrollCommandFrameID = -1;
#autoFocusFrameID = -1;
// TODO: Fix form references.
declare form: null;
@@ -42,12 +77,17 @@ export class AKCommandPaletteModal extends AKModal {
return this.autofocusTarget.target?.value.trim() || "";
}
protected fuse = new Fuse<CommandPaletteCommand>([], {
protected fuse = new Fuse<PaletteCommandDefinition>([], {
keys: [
// ---
{ name: "label", weight: 3 },
"description",
"group",
{
name: "keywords",
getFn: (command) => command.keywords?.join(" ") || "",
weight: 2,
},
],
findAllMatches: true,
includeScore: true,
@@ -62,56 +102,32 @@ export class AKCommandPaletteModal extends AKModal {
@property({ type: Number, attribute: false, useDefault: true })
public selectionIndex = 1;
public get selectedCommand(): PaletteCommandDefinition | null {
if (this.selectionIndex === -1) {
return null;
}
return this.filteredCommands[this.selectionIndex] || null;
}
@property({ type: Number, attribute: false, useDefault: true })
public maxCount = 20;
@property({ type: Array, attribute: false, useDefault: true })
public filteredCommands: readonly CommandPaletteCommand[] = [];
public filteredCommands: readonly PaletteCommandDefinition<unknown>[] = [];
@property({ attribute: false, type: Array })
public commands: CommandPaletteCommand[] = [
{
label: msg("Create a new application..."),
action: () => navigate("/core/applications", { createWizard: true }),
suffix: msg("Jump to", { id: "command-palette.prefix.jump-to" }),
group: msg("Applications"),
},
{
label: msg("Check the logs"),
action: () => navigate("/events/log"),
group: msg("Events"),
},
{
label: msg("Manage users"),
action: () => navigate("/identity/users"),
group: msg("Users"),
},
{
label: msg("Explore integrations"),
action: () => window.open("https://integrations.goauthentik.io/", "_blank"),
group: msg("authentik"),
},
{
label: msg("Check the release notes"),
action: () => window.open(import.meta.env.AK_DOCS_RELEASE_NOTES_URL, "_blank"),
/**
* A map of the currently filtered commands to their index in the flattened commands array,
* used to normalize the selection index while rendering groups.
*/
#filteredCommandsIndex = new Map<PaletteCommandDefinition<unknown>, number>();
/**
* A flattened array of all commands in the command palette, used for filtering and selection.
*/
#flattenedCommands: PaletteCommandDefinition<unknown>[] = [];
suffix: msg(str`New in ${import.meta.env.AK_VERSION}`, {
id: "command-palette.suffix.new-in",
}),
group: msg("authentik"),
},
{
label: msg("View documentation"),
action: () => this.#openDocs(),
suffix: msg("New Tab", { id: "command-palette.suffix.view-docs" }),
group: msg("authentik"),
},
{
label: msg("About authentik"),
action: AboutModal.open,
group: msg("authentik"),
},
];
@state()
public commands = new Set<readonly PaletteCommandDefinition<unknown>[]>();
public override size = PFSize.Medium;
@@ -119,16 +135,47 @@ export class AKCommandPaletteModal extends AKModal {
//#region Public Methods
public addCommands = (commands: CommandPaletteCommand[]) => {
this.commands = [...this.commands, ...commands];
public setCommands = (
commands?: readonly PaletteCommandDefinition<unknown>[] | null,
previousCommands?: readonly PaletteCommandDefinition<unknown>[] | null,
) => {
if (previousCommands) {
this.commands.delete(previousCommands);
}
if (commands) {
this.commands.add(commands);
this.#flattenedCommands = Array.from(this.commands).reverse().flat();
}
const { target } = this.autofocusTarget;
if (target) {
target.value = "";
}
if (this.open && (commands || previousCommands)) {
this.requestUpdate("commands");
}
};
public scrollCommandIntoView = (commandIndex = this.selectionIndex) => {
const id = `command-${commandIndex}`;
public scrollCommandIntoView = () => {
const id = `command-${this.selectionIndex}`;
const element = this.renderRoot.querySelector(`#${id}`);
element?.scrollIntoView({
if (!element) {
return;
}
const legend = element.closest("fieldset")?.querySelector("legend");
legend?.scrollIntoView({
behavior: "auto",
block: "nearest",
});
element.scrollIntoView({
behavior: "auto",
block: "nearest",
});
@@ -139,59 +186,80 @@ export class AKCommandPaletteModal extends AKModal {
public override connectedCallback(): void {
super.connectedCallback();
this.addEventListener("focus", this.autofocusTarget.toEventListener());
requestAnimationFrame(() => {
this.setCommands([
{
label: msg("Documentation"),
action: () => openDocsSearch(this.value),
keywords: [msg("Docs"), msg("Readme"), msg("Help")],
prefix: msg("View", { id: "command-palette.prefix.view" }),
suffix: msg("New Tab", { id: "command-palette.suffix.view-docs" }),
group: msg("Documentation"),
},
...createCommonCommands(),
]);
});
}
public override updated(changedProperties: PropertyValues<this>): void {
super.updated(changedProperties);
if (changedProperties.has("commands")) {
this.fuse.setCollection(this.commands);
this.fuse.setCollection(this.#flattenedCommands);
this.selectionIndex = 0;
this.synchronizeFilteredCommands();
}
if (changedProperties.has("open") && this.open) {
requestAnimationFrame(() => {
cancelAnimationFrame(this.#autoFocusFrameID);
this.#autoFocusFrameID = requestAnimationFrame(() => {
this.autofocusTarget.focus();
this.autofocusTarget.target?.select();
});
}
if (changedProperties.has("selectionIndex")) {
this.scrollCommandIntoView();
cancelAnimationFrame(this.#scrollCommandFrameID);
this.#scrollCommandFrameID = requestAnimationFrame(this.scrollCommandIntoView);
}
}
//#endregion
#scrollCommandFrameID = -1;
public synchronizeFilteredCommands = () => {
cancelAnimationFrame(this.#scrollCommandFrameID);
const { value } = this;
if (!value) {
this.filteredCommands = this.commands.slice(0, this.maxCount);
return;
}
const filteredCommands = this.fuse
.search(value, {
limit: this.maxCount,
})
.map((result) => result.item);
filteredCommands.push({
label: msg(str`Search the docs for "${value}"`),
suffix: msg("New Tab", { id: "command-palette.suffix.search-docs" }),
action: this.#openDocs,
});
this.filteredCommands = filteredCommands;
this.selectionIndex = 0;
this.#scrollCommandFrameID = requestAnimationFrame(() => this.scrollCommandIntoView());
const { value } = this;
if (value) {
const filteredCommands = this.fuse
.search(value, {
limit: this.maxCount,
})
.map((result) => result.item);
filteredCommands.push({
group: msg("Documentation"),
label: msg(str`Search the docs for "${value}"`),
prefix: msg("Open", { id: "command-palette.prefix.open" }),
suffix: msg("New Tab", { id: "command-palette.suffix.view-docs" }),
action: () => openDocsSearch(value),
});
this.filteredCommands = filteredCommands;
} else {
this.filteredCommands = this.#flattenedCommands.slice(0, this.maxCount);
}
this.#filteredCommandsIndex = new Map(
this.filteredCommands.map((command, index) => [command, index]),
);
this.#scrollCommandFrameID = requestAnimationFrame(this.scrollCommandIntoView);
};
public submit() {
@@ -222,7 +290,7 @@ export class AKCommandPaletteModal extends AKModal {
if (!command) return;
this.open = false;
command.action();
command.action(command.details || null);
};
#commandClickListener = (event: MouseEvent) => {
@@ -236,12 +304,12 @@ export class AKCommandPaletteModal extends AKModal {
//#region Event Listeners
@listen(AKRegisterCommandsEvent, {
target: window,
@listen(AKCommandChangeEvent, {
target: this,
})
protected registerCommandsListener(event: AKRegisterCommandsEvent) {
this.commands = [...this.commands, ...event.commands];
}
protected commandChangeListener = (event: AKCommandChangeEvent) => {
this.setCommands(event.commands, event.previousCommands);
};
#keydownListener = (event: KeyboardEvent) => {
const visibleCommandsCount = this.filteredCommands.length;
@@ -293,24 +361,21 @@ export class AKCommandPaletteModal extends AKModal {
return null;
}
#openDocs = (): void => {
const url = new URL("/search", import.meta.env.AK_DOCS_URL);
url.searchParams.set("q", this.value);
window.open(url, "_ak_docs", "noopener,noreferrer");
};
protected renderCommands() {
const { selectionIndex, value, filteredCommands } = this;
return guard([filteredCommands, selectionIndex, value], () => {
const grouped = Object.groupBy(filteredCommands, (command) => command.group || "");
let commandCount = 0;
return html`<div part="results">
return html`<div
part="results"
role="listbox"
id="command-suggestions"
aria-label=${msg("Query suggestions")}
>
${repeat(
Object.entries(grouped),
([groupLabel]) => groupLabel,
(_, groupIdx) => `group-${groupIdx}`,
([groupLabel, commands], groupIdx) => html`
<fieldset part="results-group">
<legend
@@ -324,43 +389,61 @@ export class AKCommandPaletteModal extends AKModal {
<ul
part="results-list"
data-group-index=${groupIdx}
role="listbox"
id="command-suggestions"
aria-label=${msg("Query suggestions")}
role="presentation"
>
${repeat(
commands!,
(command) => command,
({ label, prefix, suffix }) => {
const relativeIdx = commandCount;
commandCount++;
(_, commandIdx) => `group-${groupIdx}-command-${commandIdx}`,
(command) => {
const absoluteIdx =
this.#filteredCommandsIndex.get(command) ?? -1;
const { label, prefix, suffix, description } = command;
const selected = selectionIndex === relativeIdx;
const selected = selectionIndex === absoluteIdx;
return html`<li
role="option"
id="command-${relativeIdx}"
role="presentation"
id="command-${absoluteIdx}"
aria-selected=${selected ? "true" : "false"}
class="command-item ${selected ? "selected" : ""}"
part="command-item"
>
<button
class="pf-c-button"
part="command-button"
type="submit"
formmethod="dialog"
data-index=${relativeIdx}
data-index=${absoluteIdx}
@click=${this.#commandClickListener}
aria-labelledby="command-${absoluteIdx}-label"
aria-describedby="command-${absoluteIdx}-description"
>
${prefix
? html`<span part="command-item-prefix"
>${prefix}</span
>`
? html`<div
part="command-item-prefix"
id="command-${absoluteIdx}-prefix"
>
${prefix}
</div>`
: null}
<span part="command-item-label">${label}</span>
<div
part="command-item-label"
id="command-${absoluteIdx}-label"
>
${label}
</div>
${suffix
? html`<span part="command-item-suffix"
>${suffix}</span
>`
? html`<div
part="command-item-suffix"
id="command-${absoluteIdx}-suffix"
>
${suffix}
</div>`
: null}
<div
part="command-item-description"
id="command-${absoluteIdx}-description"
>
${description || ""}
</div>
</button>
</li>`;
},
@@ -395,8 +478,8 @@ export class AKCommandPaletteModal extends AKModal {
>
<div part="command-field">
<label
part="label"
for="locale-selector"
part="input-label"
for="command-input"
@click=${this.show}
aria-label=${msg("Type a command...", {
id: "command-palette-placeholder",
@@ -423,7 +506,10 @@ export class AKCommandPaletteModal extends AKModal {
name="command"
aria-controls="command-suggestions"
type="search"
placeholder=${msg("Type a command...")}
placeholder=${msg("What are you looking for?", {
id: "command-palette-placeholder-extended",
desc: "Placeholder for the command palette input",
})}
class="pf-c-control command-input"
autocomplete="off"
autocapitalize="off"

View File

@@ -5,7 +5,7 @@ ak-command-palette {
.ak-c-modal:has(ak-command-palette-modal) {
overflow: visible;
--ak-c-modal--MarginBlockStart: 25dvh;
--ak-c-modal--BackgroundColor: var(--pf-global--palette--cyan-700);
--ak-c-modal--BackgroundColor: var(--pf-global--palette--purple-700);
--ak-c-modal--BorderColor: var(--pf-global--BackgroundColor--dark-300);
--ak-c-modal__backdrop--active--BackdropFilter: blur(1px);
--ak-c-modal__backdrop--active--BackgroundColor: hsla(0, 0%, 0%, 0.25);

View File

@@ -1,21 +1,28 @@
import { CommandPaletteCommand } from "#elements/commands/shared";
import type { PaletteCommandDefinition } from "#elements/commands/shared";
/**
* Event dispatched when the state of the interface drawers changes.
* Event dispatched when the available commands in the command palette change.
* This is used by the command palette to update the list of available commands.
*/
export class AKRegisterCommandsEvent extends Event {
public static readonly eventName = "ak-register-commands";
export class AKCommandChangeEvent<D = unknown> extends Event {
public static readonly eventName = "ak-command-change";
public readonly commands: CommandPaletteCommand[];
constructor(commands: CommandPaletteCommand[]) {
super(AKRegisterCommandsEvent.eventName, { bubbles: true, composed: true });
public readonly commands: readonly PaletteCommandDefinition<D>[];
public readonly previousCommands: readonly PaletteCommandDefinition<D>[] | null;
constructor(
commands: PaletteCommandDefinition<D>[],
previousCommands?: PaletteCommandDefinition<D>[] | null,
) {
super(AKCommandChangeEvent.eventName, { bubbles: true, composed: true });
this.commands = commands;
this.previousCommands = previousCommands ?? null;
}
}
declare global {
interface WindowEventMap {
[AKRegisterCommandsEvent.eventName]: AKRegisterCommandsEvent;
[AKCommandChangeEvent.eventName]: AKCommandChangeEvent;
}
}

View File

@@ -1,10 +1,41 @@
import { AKCommandChangeEvent } from "#elements/commands/events";
import { SlottedTemplateResult } from "#elements/types";
export interface CommandPaletteCommand {
export type PaletteCommandAction<D = unknown> = (data: D) => unknown | Promise<unknown>;
export interface PaletteCommandDefinition<D = unknown> {
label: SlottedTemplateResult;
keywords?: string[];
prefix?: SlottedTemplateResult;
suffix?: SlottedTemplateResult;
description?: SlottedTemplateResult;
group?: string;
action: () => unknown | Promise<unknown>;
details?: D;
action: PaletteCommandAction<D>;
}
export interface CommandPaletteStateInit<D = unknown> {
commands?: PaletteCommandDefinition<D>[] | null;
target?: EventTarget;
}
export class CommandPaletteState<D = unknown> {
#commands: PaletteCommandDefinition<D>[] | null = null;
#target: EventTarget;
constructor({ commands = null, target = window }: CommandPaletteStateInit<D> = {}) {
this.#commands = commands;
this.#target = target ?? window;
}
public set(nextCommands: PaletteCommandDefinition<D>[] | null): void {
const previousCommands = this.#commands;
this.#commands = nextCommands;
this.#target.dispatchEvent(new AKCommandChangeEvent(nextCommands ?? [], previousCommands));
}
public clear(): void {
this.set(null);
}
}

View File

@@ -1,20 +1,30 @@
import { DEFAULT_CONFIG } from "#common/api/config";
import { type APIResult, isAPIResultReady } from "#common/api/responses";
import { globalAK } from "#common/global";
import { applyThemeChoice, formatColorScheme } from "#common/theme";
import { createUIConfig, DefaultUIConfig } from "#common/ui/config";
import { autoDetectLanguage } from "#common/ui/locale/utils";
import { me } from "#common/users";
import { CommandPaletteState, PaletteCommandDefinition } from "#elements/commands/shared";
import { ReactiveContextController } from "#elements/controllers/ReactiveContextController";
import { AKConfigMixin, kAKConfig } from "#elements/mixins/config";
import { kAKLocale, type LocaleMixin } from "#elements/mixins/locale";
import { SessionContext, SessionMixin, UIConfigContext } from "#elements/mixins/session";
import {
canAccessAdmin,
SessionContext,
SessionMixin,
UIConfigContext,
} from "#elements/mixins/session";
import { AKDrawerChangeEvent } from "#elements/notifications/events";
import type { ReactiveElementHost } from "#elements/types";
import { SessionUser } from "@goauthentik/api";
import { CoreApi, SessionUser } from "@goauthentik/api";
import { setUser } from "@sentry/browser";
import { ContextProvider } from "@lit/context";
import { msg } from "@lit/localize";
/**
* A controller that provides the session information to the element.
@@ -52,38 +62,128 @@ export class SessionContextController extends ReactiveContextController<APIResul
return me(requestInit);
}
#refreshCommandsFrameID = -1;
#commands = new CommandPaletteState({
target: this.host,
});
protected doRefresh(session: APIResult<SessionUser>): void {
this.context.setValue(session);
this.host.session = session;
if (isAPIResultReady(session)) {
const localeHint: string | undefined = session.user.settings.locale;
if (!isAPIResultReady(session)) return;
if (localeHint) {
const locale = autoDetectLanguage(localeHint);
this.logger.info(`Activating user's configured locale '${locale}'`);
this.host[kAKLocale]?.setLocale(locale);
}
const localeHint: string | undefined = session.user.settings.locale;
const { settings = {} } = session.user || {};
const nextUIConfig = createUIConfig(settings);
this.uiConfigContext.setValue(nextUIConfig);
this.host.uiConfig = nextUIConfig;
const colorScheme = formatColorScheme(nextUIConfig.theme.base);
applyThemeChoice(colorScheme, this.host.ownerDocument);
const config = this.host[kAKConfig];
if (config?.errorReporting.sendPii) {
this.logger.info("Sentry with PII enabled.");
setUser({ email: session.user.email });
}
if (localeHint) {
const locale = autoDetectLanguage(localeHint);
this.logger.info(`Activating user's configured locale '${locale}'`);
this.host[kAKLocale]?.setLocale(locale);
}
const { settings = {} } = session.user || {};
const nextUIConfig = createUIConfig(settings);
this.uiConfigContext.setValue(nextUIConfig);
this.host.uiConfig = nextUIConfig;
const colorScheme = formatColorScheme(nextUIConfig.theme.base);
applyThemeChoice(colorScheme, this.host.ownerDocument);
const config = this.host[kAKConfig];
if (config?.errorReporting.sendPii) {
this.logger.info("Sentry with PII enabled.");
setUser({ email: session.user.email });
}
this.#refreshCommandsFrameID = requestAnimationFrame(this.#refreshCommands);
}
#refreshCommands = (): void => {
const session = this.context.value;
if (!isAPIResultReady(session)) {
this.#commands.clear();
return;
}
const base = globalAK().api.base;
const group = msg("Session");
const commands: PaletteCommandDefinition[] = [
{
label: msg("Sign out"),
suffix: msg("Reloads page", { id: "command-palette.prefix.reloads-page" }),
keywords: [msg("Logout"), msg("Log off"), msg("Sign off")],
group,
action: () => {
window.location.assign(`${base}flows/-/default/invalidation/`);
},
},
{
label: msg("User settings"),
prefix: msg("Navigate to", { id: "command-palette.prefix.navigate" }),
group,
action: () => {
window.location.assign(`${base}if/user/#/settings`);
},
},
];
const { notificationDrawer, apiDrawer } = this.host.uiConfig?.enabledFeatures ?? {};
const drawerGroup = msg("Interface");
if (apiDrawer) {
commands.push({
label: msg("API requests drawer", {
id: "command-palette.label.api-requests-drawer",
}),
prefix: msg("Toggle", { id: "command-palette.prefix.toggle" }),
group: drawerGroup,
action: AKDrawerChangeEvent.dispatchAPIToggle,
});
}
if (notificationDrawer) {
commands.push({
label: msg("Notifications drawer", {
id: "command-palette.label.notifications-drawer",
}),
prefix: msg("Toggle", { id: "command-palette.prefix.toggle" }),
group: drawerGroup,
action: AKDrawerChangeEvent.dispatchNotificationsToggle,
});
}
if (canAccessAdmin(session.user)) {
commands.push({
label: msg("Admin interface"),
prefix: msg("Navigate to", { id: "command-palette.prefix.navigate" }),
group,
action: () => {
window.location.assign(`${base}if/admin/`);
},
});
}
if (session.original) {
commands.push({
label: msg("Stop impersonation"),
suffix: msg("Reloads page", { id: "command-palette.prefix.reloads-page" }),
group,
action: async () => {
await new CoreApi(DEFAULT_CONFIG).coreUsersImpersonateEndRetrieve();
window.location.reload();
},
});
}
this.#commands.set(commands);
};
public override hostConnected() {
this.logger.debug("Host connected, refreshing session");
this.refresh();
@@ -91,6 +191,7 @@ export class SessionContextController extends ReactiveContextController<APIResul
public override hostDisconnected() {
this.context.clearCallbacks();
cancelAnimationFrame(this.#refreshCommandsFrameID);
super.hostDisconnected();
}

View File

@@ -127,8 +127,8 @@
/* #region Footer */
fieldset.ak-c-modal__footer {
--ak-legend-padding-inline-base: var(--pf-global--spacer--md);
padding-block: calc(var(--ak-legend-padding-inline-base) / 2);
--ak-fieldset__legend--PaddingInlineBase: var(--pf-global--spacer--md);
padding-block: calc(var(--ak-fieldset__legend--PaddingInlineBase) / 2);
border-inline: none;
border-block-end: none;

View File

@@ -3,11 +3,11 @@
margin-block-end: var(--pf-global--spacer--md);
@media not (prefers-contrast: more) {
--ak-legend-margin-inline-start: calc(
var(--pf-c-card--child--PaddingLeft) - var(--ak-legend-padding-inline-base)
--ak-fieldset__legend--MarginInlineStart: calc(
var(--pf-c-card--child--PaddingLeft) - var(--ak-fieldset__legend--PaddingInlineBase)
);
--ak-legend-margin-inline-end: calc(
var(--pf-c-card--child--PaddingRight) - var(--ak-legend-padding-inline-base)
var(--pf-c-card--child--PaddingRight) - var(--ak-fieldset__legend--PaddingInlineBase)
);
border-width: 0;

View File

@@ -76,34 +76,34 @@
/* #region Fields */
fieldset {
--ak-fieldset-border-width: thin;
--ak-fieldset-border-color: var(--pf-global--BackgroundColor--light-100);
--ak-legend-margin-inline-base: var(--pf-global--spacer--sm);
--ak-legend-padding-inline-base: var(--pf-global--spacer--sm);
--ak-fieldset--BorderWidth: thin;
--ak-fieldset__legend--MarginInlineBase: var(--pf-global--spacer--sm);
--ak-fieldset__legend--PaddingInlineBase: var(--pf-global--spacer--sm);
border-color: var(--ak-fieldset-border-color);
border-width: var(--ak-fieldset-border-width);
padding: var(--ak-legend-padding-inline-base) !important;
border-color: var(--ak-fieldset--BorderColor, var(--pf-global--BackgroundColor--light-100));
@media (prefers-contrast: more) {
--ak-fieldset-border-color: var(--pf-global--BorderColor--200);
border-color: var(--ak-fieldset--BorderColor, var(--pf-global--BorderColor--200));
}
@media (prefers-contrast: less) {
--ak-fieldset-border-color: transparent;
border-color: var(--ak-fieldset--BorderColor, transparent);
}
border-width: var(--ak-fieldset--BorderWidth);
padding: var(--ak-fieldset__legend--PaddingInlineBase) !important;
& > legend {
line-height: 1;
padding: var(--ak-legend-padding-inline-base) !important;
padding: var(--ak-fieldset__legend--PaddingInlineBase) !important;
margin-inline-start: var(
--ak-legend-margin-inline-start,
var(--ak-legend-margin-inline-base)
--ak-fieldset__legend--MarginInlineStart,
var(--ak-fieldset__legend--MarginInlineBase)
) !important;
margin-inline-end: var(
--ak-legend-margin-inline-end,
var(--ak-legend-margin-inline-base)
var(--ak-fieldset__legend--MarginInlineBase)
) !important;
}
@@ -111,8 +111,8 @@ fieldset {
border-width: 0;
&:not(.pf-c-modal-box__footer) {
--ak-legend-padding-inline-base: 0;
--ak-legend-margin-inline-base: 0;
--ak-fieldset__legend--PaddingInlineBase: 0;
--ak-fieldset__legend--MarginInlineBase: 0;
}
}
@@ -137,8 +137,9 @@ fieldset {
}
&.pf-c-modal-box__footer {
--ak-legend-padding-inline-base: var(--pf-global--spacer--md);
padding-block: calc(var(--ak-legend-padding-inline-base) / 2);
--ak-fieldset__legend--PaddingInlineBase: var(--pf-global--spacer--md);
padding-block: calc(var(--ak-fieldset__legend--PaddingInlineBase) / 2);
border-inline: none;
border-block-end: none;
@@ -209,14 +210,17 @@ fieldset {
}
fieldset {
--ak-fieldset-border-color: var(--pf-global--BackgroundColor--dark-transparent-200);
border-color: var(
--ak-fieldset--BorderColor,
var(--pf-global--BackgroundColor--dark-transparent-200)
);
@media (prefers-contrast: more) {
--ak-fieldset-border-color: var(--pf-global--BorderColor--300);
border-color: var(--ak-fieldset--BorderColor, var(--pf-global--BorderColor--300));
}
@media (prefers-contrast: less) {
--ak-fieldset-border-color: transparent;
border-color: var(--ak-fieldset--BorderColor, transparent);
}
}

View File

@@ -132,7 +132,7 @@ ak-app-icon {
[part="app-group-header"] {
@media not (prefers-contrast: more) {
--ak-legend-padding-inline-base: 1rem;
--ak-fieldset__legend--PaddingInlineBase: 1rem;
padding-block-start: 0 !important;
padding-inline: 0 !important;
margin-inline: 0 !important;

View File

@@ -182,7 +182,8 @@ class UserInterface extends WithBrandConfig(WithSession(AuthenticatedInterface))
</div>
</div>
</div>
</div>`;
</div>
${this.commandPalette}`;
}
}