Fix crawl list action menu positioning (#1399)

Refactors `btrix-crawl-list` dropdown action menu to use `sl-dropdown`
auto-positioning to fix menu clipping
This commit is contained in:
sua yoo 2023-11-26 15:50:22 -08:00 committed by GitHub
parent b15c5ccddd
commit ffc8b75ea8
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
4 changed files with 112 additions and 234 deletions

View File

@ -11,23 +11,22 @@
* </btrix-crawl-list> * </btrix-crawl-list>
* ``` * ```
*/ */
import type { TemplateResult } from "lit";
import { LitElement, html, css } from "lit"; import { LitElement, html, css } from "lit";
import { import {
customElement, customElement,
property, property,
query, query,
queryAssignedElements, queryAssignedElements,
state,
} from "lit/decorators.js"; } from "lit/decorators.js";
import { msg, localized, str } from "@lit/localize"; import { msg, localized, str } from "@lit/localize";
import type { SlMenu } from "@shoelace-style/shoelace";
import queryString from "query-string"; import queryString from "query-string";
import type { Button } from "./button";
import { RelativeDuration } from "./relative-duration"; import { RelativeDuration } from "./relative-duration";
import type { Crawl } from "../types/crawler"; import type { Crawl } from "../types/crawler";
import { srOnly, truncate, dropdown } from "../utils/css"; import { srOnly, truncate } from "../utils/css";
import type { NavigateEvent } from "../utils/LiteElement"; import type { NavigateEvent } from "../utils/LiteElement";
import type { OverflowDropdown } from "./overflow-dropdown";
const mediumBreakpointCss = css`30rem`; const mediumBreakpointCss = css`30rem`;
const largeBreakpointCss = css`60rem`; const largeBreakpointCss = css`60rem`;
@ -75,7 +74,6 @@ const hostVars = css`
export class CrawlListItem extends LitElement { export class CrawlListItem extends LitElement {
static styles = [ static styles = [
truncate, truncate,
dropdown,
rowCss, rowCss,
columnCss, columnCss,
hostVars, hostVars,
@ -96,12 +94,6 @@ export class CrawlListItem extends LitElement {
} }
} }
.dropdown {
contain: content;
position: absolute;
z-index: 99;
}
.col { .col {
display: flex; display: flex;
align-items: center; align-items: center;
@ -172,10 +164,6 @@ export class CrawlListItem extends LitElement {
align-items: center; align-items: center;
justify-content: center; justify-content: center;
} }
.action sl-icon-button {
font-size: 1rem;
}
`, `,
]; ];
@ -194,42 +182,15 @@ export class CrawlListItem extends LitElement {
@query(".row") @query(".row")
row!: HTMLElement; row!: HTMLElement;
// TODO consolidate with btrix-combobox @query("btrix-overflow-dropdown")
@query(".dropdown") dropdownMenu!: OverflowDropdown;
dropdown!: HTMLElement;
@query(".dropdownTrigger")
dropdownTrigger!: Button;
@queryAssignedElements({ selector: "sl-menu", slot: "menu" })
private menuArr!: Array<SlMenu>;
@state()
private dropdownIsOpen?: boolean;
@state()
private hasMenuItems?: boolean;
// TODO localize // TODO localize
private numberFormatter = new Intl.NumberFormat(undefined, { private numberFormatter = new Intl.NumberFormat(undefined, {
notation: "compact", notation: "compact",
}); });
willUpdate(changedProperties: Map<string, any>) {
if (changedProperties.has("dropdownIsOpen")) {
if (this.dropdownIsOpen) {
this.openDropdown();
} else {
this.closeDropdown();
}
}
}
render() { render() {
return html`${this.renderRow()}${this.renderDropdown()}`;
}
renderRow() {
const search = const search =
this.collectionId || this.workflowId this.collectionId || this.workflowId
? `?${queryString.stringify( ? `?${queryString.stringify(
@ -240,15 +201,16 @@ export class CrawlListItem extends LitElement {
{ skipEmptyString: true } { skipEmptyString: true }
)}` )}`
: ""; : "";
return html`<a return html`<div
class="item row" class="item row"
role="button" role="button"
href="/orgs/${this.orgSlug}/items/${this.crawl?.type}/${this.crawl
?.id}${search}"
@click=${async (e: MouseEvent) => { @click=${async (e: MouseEvent) => {
if (e.target === this.dropdownMenu) {
return;
}
e.preventDefault(); e.preventDefault();
await this.updateComplete; await this.updateComplete;
const href = (e.currentTarget as HTMLAnchorElement).href; const href = `/orgs/${this.orgSlug}/items/${this.crawl?.type}/${this.crawl?.id}${search}`;
// TODO consolidate with LiteElement navTo // TODO consolidate with LiteElement navTo
const evt: NavigateEvent = new CustomEvent("navigate", { const evt: NavigateEvent = new CustomEvent("navigate", {
detail: { url: href }, detail: { url: href },
@ -361,33 +323,12 @@ export class CrawlListItem extends LitElement {
</div> </div>
</div> </div>
${this.renderActions()} ${this.renderActions()}
</a>`; </div>`;
} }
private renderDropdown() { private safeRender(
return html`<div render: (crawl: Crawl) => string | TemplateResult<1> | undefined
class="dropdown hidden" ) {
aria-hidden=${!this.dropdownIsOpen}
@animationend=${(e: AnimationEvent) => {
const el = e.target as HTMLDivElement;
if (e.animationName === "dropdownShow") {
el.classList.remove("animateShow");
}
if (e.animationName === "dropdownHide") {
el.classList.add("hidden");
el.classList.remove("animateHide");
}
}}
>
<slot
name="menu"
@slotchange=${() => (this.hasMenuItems = this.menuArr.length > 0)}
@sl-select=${() => (this.dropdownIsOpen = false)}
></slot>
</div> `;
}
private safeRender(render: (crawl: Crawl) => any) {
if (!this.crawl) { if (!this.crawl) {
return html`<sl-skeleton></sl-skeleton>`; return html`<sl-skeleton></sl-skeleton>`;
} }
@ -399,7 +340,7 @@ export class CrawlListItem extends LitElement {
if (!crawl.firstSeed) if (!crawl.firstSeed)
return html`<span class="truncate">${crawl.id}</span>`; return html`<span class="truncate">${crawl.id}</span>`;
const remainder = crawl.seedCount - 1; const remainder = crawl.seedCount - 1;
let nameSuffix: any = ""; let nameSuffix: string | TemplateResult<1> = "";
if (remainder) { if (remainder) {
if (remainder === 1) { if (remainder === 1) {
nameSuffix = html`<span class="additionalUrls" nameSuffix = html`<span class="additionalUrls"
@ -417,56 +358,19 @@ export class CrawlListItem extends LitElement {
} }
private renderActions() { private renderActions() {
if (!this.hasMenuItems) {
return;
}
return html` <div class="col action"> return html` <div class="col action">
<sl-icon-button <btrix-overflow-dropdown>
class="dropdownTrigger" <slot
label=${msg("Actions")} name="menu"
name="three-dots-vertical" @click=${(e: MouseEvent) => {
@click=${(e: MouseEvent) => { // Prevent navigation to detail view
// Prevent anchor link default behavior e.preventDefault();
e.preventDefault(); e.stopPropagation();
// Stop prop to anchor link }}
e.stopPropagation(); ></slot>
this.dropdownIsOpen = !this.dropdownIsOpen; </btrix-overflow-dropdown>
}}
@focusout=${(e: FocusEvent) => {
const relatedTarget = e.relatedTarget as HTMLElement;
if (relatedTarget) {
if (this.menuArr[0]?.contains(relatedTarget)) {
// Keep dropdown open if moving to menu selection
return;
}
if (this.row?.isEqualNode(relatedTarget)) {
// Handle with click event
return;
}
}
this.dropdownIsOpen = false;
}}
>
</sl-icon-button>
</div>`; </div>`;
} }
private repositionDropdown() {
const { x, y } = this.dropdownTrigger.getBoundingClientRect();
this.dropdown.style.left = `${x + window.scrollX}px`;
this.dropdown.style.top = `${y + window.scrollY - 8}px`;
}
private openDropdown() {
this.repositionDropdown();
this.dropdown.classList.add("animateShow");
this.dropdown.classList.remove("hidden");
}
private closeDropdown() {
this.dropdown.classList.add("animateHide");
}
} }
@localized() @localized()

View File

@ -48,3 +48,4 @@ import("./code");
import("./pw-strength-alert"); import("./pw-strength-alert");
import("./search-combobox"); import("./search-combobox");
import("./meter"); import("./meter");
import("./overflow-dropdown");

View File

@ -0,0 +1,57 @@
import { LitElement, html, css } from "lit";
import { customElement, state, queryAssignedElements } from "lit/decorators.js";
import { msg, localized } from "@lit/localize";
import type { SlMenu } from "@shoelace-style/shoelace";
/**
* Dropdown for additional actions.
*
* Usage:
* ```ts
* <btrix-overflow-dropdown>
* <sl-menu>
* <sl-menu-item>Item 1</sl-menu-item>
* <sl-menu-item>Item 2</sl-menu-item>
* </sl-menu>
*< /btrix-overflow-dropdown>
* ```
*/
@localized()
@customElement("btrix-overflow-dropdown")
export class OverflowDropdown extends LitElement {
static style = [
css`
.trigger {
font-size: 1rem;
}
.trigger[disabled] {
visibility: hidden;
}
`,
];
@state()
private hasMenuItems?: boolean;
@queryAssignedElements({ selector: "sl-menu", flatten: true })
private menu!: Array<SlMenu>;
render() {
return html`
<sl-dropdown ?disabled=${!this.hasMenuItems}>
<sl-icon-button
slot="trigger"
class="trigger"
label=${msg("Actions")}
name="three-dots-vertical"
?disabled=${!this.hasMenuItems}
>
</sl-icon-button>
<slot
@slotchange=${() => (this.hasMenuItems = this.menu.length > 0)}
></slot>
</sl-dropdown>
`;
}
}

View File

@ -11,23 +11,23 @@
* </btrix-workflow-list> * </btrix-workflow-list>
* ``` * ```
*/ */
import type { TemplateResult } from "lit";
import { LitElement, html, css } from "lit"; import { LitElement, html, css } from "lit";
import { import {
property, property,
query, query,
queryAssignedElements, queryAssignedElements,
state,
customElement, customElement,
} from "lit/decorators.js"; } from "lit/decorators.js";
import { msg, localized, str } from "@lit/localize"; import { msg, localized, str } from "@lit/localize";
import type { SlIconButton, SlMenu } from "@shoelace-style/shoelace";
import { RelativeDuration } from "./relative-duration"; import { RelativeDuration } from "./relative-duration";
import type { ListWorkflow } from "../types/crawler"; import type { ListWorkflow } from "../types/crawler";
import { srOnly, truncate, dropdown } from "../utils/css"; import { srOnly, truncate } from "../utils/css";
import type { NavigateEvent } from "../utils/LiteElement"; import type { NavigateEvent } from "../utils/LiteElement";
import { humanizeSchedule } from "../utils/cron"; import { humanizeSchedule } from "../utils/cron";
import { numberFormatter } from "../utils/number"; import { numberFormatter } from "../utils/number";
import type { OverflowDropdown } from "./overflow-dropdown";
const mediumBreakpointCss = css`30rem`; const mediumBreakpointCss = css`30rem`;
const largeBreakpointCss = css`60rem`; const largeBreakpointCss = css`60rem`;
@ -74,7 +74,6 @@ const hostVars = css`
export class WorkflowListItem extends LitElement { export class WorkflowListItem extends LitElement {
static styles = [ static styles = [
truncate, truncate,
dropdown,
rowCss, rowCss,
columnCss, columnCss,
hostVars, hostVars,
@ -84,8 +83,7 @@ export class WorkflowListItem extends LitElement {
} }
.item { .item {
contain: content; contain: size;
content-visibility: auto;
contain-intrinsic-height: auto 4rem; contain-intrinsic-height: auto 4rem;
cursor: pointer; cursor: pointer;
transition-property: background-color, box-shadow, margin; transition-property: background-color, box-shadow, margin;
@ -99,11 +97,7 @@ export class WorkflowListItem extends LitElement {
.item:focus-within { .item:focus-within {
background-color: var(--sl-color-neutral-50); background-color: var(--sl-color-neutral-50);
} }
.dropdown {
contain: content;
position: absolute;
z-index: 99;
}
.item:hover { .item:hover {
background-color: var(--sl-color-neutral-50); background-color: var(--sl-color-neutral-50);
margin-left: calc(-1 * var(--row-offset)); margin-left: calc(-1 * var(--row-offset));
@ -196,10 +190,6 @@ export class WorkflowListItem extends LitElement {
justify-content: center; justify-content: center;
} }
.action sl-icon-button {
font-size: 1rem;
}
@media only screen and (min-width: ${largeBreakpointCss}) { @media only screen and (min-width: ${largeBreakpointCss}) {
.action { .action {
border-left: 1px solid var(--sl-panel-border-color); border-left: 1px solid var(--sl-panel-border-color);
@ -217,52 +207,30 @@ export class WorkflowListItem extends LitElement {
@query(".row") @query(".row")
row!: HTMLElement; row!: HTMLElement;
// TODO consolidate with btrix-combobox @query("btrix-overflow-dropdown")
@query(".dropdown") dropdownMenu!: OverflowDropdown;
dropdown!: HTMLElement;
@query(".dropdownTrigger")
dropdownTrigger!: SlIconButton;
@queryAssignedElements({ selector: "sl-menu", slot: "menu" })
private menuArr!: Array<SlMenu>;
@state()
private dropdownIsOpen?: boolean;
private numberFormatter = numberFormatter(undefined, { private numberFormatter = numberFormatter(undefined, {
notation: "compact", notation: "compact",
}); });
willUpdate(changedProperties: Map<string, any>) {
if (changedProperties.has("dropdownIsOpen")) {
if (this.dropdownIsOpen) {
this.openDropdown();
} else {
this.closeDropdown();
}
}
}
render() { render() {
return html`${this.renderRow()}${this.renderDropdown()}`;
}
renderRow() {
const notSpecified = html`<span class="notSpecified" role="presentation" const notSpecified = html`<span class="notSpecified" role="presentation"
>---</span >---</span
>`; >`;
return html`<a return html`<div
class="item row" class="item row"
role="button" role="button"
href=${`/orgs/${this.orgSlug}/workflows/crawl/${this.workflow?.id}#${
this.workflow?.isCrawlRunning ? "watch" : "crawls"
}`}
@click=${async (e: MouseEvent) => { @click=${async (e: MouseEvent) => {
if (e.target === this.dropdownMenu) {
return;
}
e.preventDefault(); e.preventDefault();
await this.updateComplete; await this.updateComplete;
const href = (e.currentTarget as HTMLAnchorElement).href; const href = `/orgs/${this.orgSlug}/workflows/crawl/${
this.workflow?.id
}#${this.workflow?.isCrawlRunning ? "watch" : "crawls"}`;
// TODO consolidate with LiteElement navTo // TODO consolidate with LiteElement navTo
const evt: NavigateEvent = new CustomEvent("navigate", { const evt: NavigateEvent = new CustomEvent("navigate", {
detail: { url: href }, detail: { url: href },
@ -422,59 +390,23 @@ export class WorkflowListItem extends LitElement {
</div> </div>
</div> </div>
<div class="col action"> <div class="col action">
<sl-icon-button <btrix-overflow-dropdown>
class="dropdownTrigger" <slot
name="three-dots-vertical" name="menu"
label=${msg("Actions")} @click=${(e: MouseEvent) => {
@click=${(e: MouseEvent) => { // Prevent navigation to detail view
// Prevent anchor link default behavior e.preventDefault();
e.preventDefault(); e.stopPropagation();
// Stop prop to anchor link }}
e.stopPropagation(); ></slot>
this.dropdownIsOpen = !this.dropdownIsOpen; </btrix-overflow-dropdown>
}}
@focusout=${(e: FocusEvent) => {
const relatedTarget = e.relatedTarget as HTMLElement;
if (relatedTarget) {
if (this.menuArr[0]?.contains(relatedTarget)) {
// Keep dropdown open if moving to menu selection
return;
}
if (this.row?.isEqualNode(relatedTarget)) {
// Handle with click event
return;
}
}
this.dropdownIsOpen = false;
}}
></sl-icon-button>
</div> </div>
</a>`; </div>`;
} }
private renderDropdown() { private safeRender(
return html`<div render: (workflow: ListWorkflow) => string | TemplateResult<1>
class="dropdown hidden" ) {
aria-hidden=${!this.dropdownIsOpen}
@animationend=${(e: AnimationEvent) => {
const el = e.target as HTMLDivElement;
if (e.animationName === "dropdownShow") {
el.classList.remove("animateShow");
}
if (e.animationName === "dropdownHide") {
el.classList.add("hidden");
el.classList.remove("animateHide");
}
}}
>
<slot
name="menu"
@sl-select=${() => (this.dropdownIsOpen = false)}
></slot>
</div> `;
}
private safeRender(render: (workflow: ListWorkflow) => any) {
if (!this.workflow) { if (!this.workflow) {
return html`<sl-skeleton></sl-skeleton>`; return html`<sl-skeleton></sl-skeleton>`;
} }
@ -488,7 +420,7 @@ export class WorkflowListItem extends LitElement {
if (!workflow.firstSeed) if (!workflow.firstSeed)
return html`<span class="truncate">${workflow.id}</span>`; return html`<span class="truncate">${workflow.id}</span>`;
const remainder = workflow.seedCount - 1; const remainder = workflow.seedCount - 1;
let nameSuffix: any = ""; let nameSuffix: string | TemplateResult<1> = "";
if (remainder) { if (remainder) {
if (remainder === 1) { if (remainder === 1) {
nameSuffix = html`<span class="additionalUrls" nameSuffix = html`<span class="additionalUrls"
@ -505,22 +437,6 @@ export class WorkflowListItem extends LitElement {
>${nameSuffix} >${nameSuffix}
`; `;
} }
private repositionDropdown() {
const { x, y } = this.dropdownTrigger.getBoundingClientRect();
this.dropdown.style.left = `${x + window.scrollX}px`;
this.dropdown.style.top = `${y + window.scrollY - 8}px`;
}
private openDropdown() {
this.repositionDropdown();
this.dropdown.classList.add("animateShow");
this.dropdown.classList.remove("hidden");
}
private closeDropdown() {
this.dropdown.classList.add("animateHide");
}
} }
@localized() @localized()