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>
* ```
*/
import type { TemplateResult } from "lit";
import { LitElement, html, css } from "lit";
import {
customElement,
property,
query,
queryAssignedElements,
state,
} from "lit/decorators.js";
import { msg, localized, str } from "@lit/localize";
import type { SlMenu } from "@shoelace-style/shoelace";
import queryString from "query-string";
import type { Button } from "./button";
import { RelativeDuration } from "./relative-duration";
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 { OverflowDropdown } from "./overflow-dropdown";
const mediumBreakpointCss = css`30rem`;
const largeBreakpointCss = css`60rem`;
@ -75,7 +74,6 @@ const hostVars = css`
export class CrawlListItem extends LitElement {
static styles = [
truncate,
dropdown,
rowCss,
columnCss,
hostVars,
@ -96,12 +94,6 @@ export class CrawlListItem extends LitElement {
}
}
.dropdown {
contain: content;
position: absolute;
z-index: 99;
}
.col {
display: flex;
align-items: center;
@ -172,10 +164,6 @@ export class CrawlListItem extends LitElement {
align-items: center;
justify-content: center;
}
.action sl-icon-button {
font-size: 1rem;
}
`,
];
@ -194,42 +182,15 @@ export class CrawlListItem extends LitElement {
@query(".row")
row!: HTMLElement;
// TODO consolidate with btrix-combobox
@query(".dropdown")
dropdown!: HTMLElement;
@query(".dropdownTrigger")
dropdownTrigger!: Button;
@queryAssignedElements({ selector: "sl-menu", slot: "menu" })
private menuArr!: Array<SlMenu>;
@state()
private dropdownIsOpen?: boolean;
@state()
private hasMenuItems?: boolean;
@query("btrix-overflow-dropdown")
dropdownMenu!: OverflowDropdown;
// TODO localize
private numberFormatter = new Intl.NumberFormat(undefined, {
notation: "compact",
});
willUpdate(changedProperties: Map<string, any>) {
if (changedProperties.has("dropdownIsOpen")) {
if (this.dropdownIsOpen) {
this.openDropdown();
} else {
this.closeDropdown();
}
}
}
render() {
return html`${this.renderRow()}${this.renderDropdown()}`;
}
renderRow() {
const search =
this.collectionId || this.workflowId
? `?${queryString.stringify(
@ -240,15 +201,16 @@ export class CrawlListItem extends LitElement {
{ skipEmptyString: true }
)}`
: "";
return html`<a
return html`<div
class="item row"
role="button"
href="/orgs/${this.orgSlug}/items/${this.crawl?.type}/${this.crawl
?.id}${search}"
@click=${async (e: MouseEvent) => {
if (e.target === this.dropdownMenu) {
return;
}
e.preventDefault();
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
const evt: NavigateEvent = new CustomEvent("navigate", {
detail: { url: href },
@ -361,33 +323,12 @@ export class CrawlListItem extends LitElement {
</div>
</div>
${this.renderActions()}
</a>`;
</div>`;
}
private renderDropdown() {
return html`<div
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) {
private safeRender(
render: (crawl: Crawl) => string | TemplateResult<1> | undefined
) {
if (!this.crawl) {
return html`<sl-skeleton></sl-skeleton>`;
}
@ -399,7 +340,7 @@ export class CrawlListItem extends LitElement {
if (!crawl.firstSeed)
return html`<span class="truncate">${crawl.id}</span>`;
const remainder = crawl.seedCount - 1;
let nameSuffix: any = "";
let nameSuffix: string | TemplateResult<1> = "";
if (remainder) {
if (remainder === 1) {
nameSuffix = html`<span class="additionalUrls"
@ -417,56 +358,19 @@ export class CrawlListItem extends LitElement {
}
private renderActions() {
if (!this.hasMenuItems) {
return;
}
return html` <div class="col action">
<sl-icon-button
class="dropdownTrigger"
label=${msg("Actions")}
name="three-dots-vertical"
@click=${(e: MouseEvent) => {
// Prevent anchor link default behavior
e.preventDefault();
// Stop prop to anchor link
e.stopPropagation();
this.dropdownIsOpen = !this.dropdownIsOpen;
}}
@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>
<btrix-overflow-dropdown>
<slot
name="menu"
@click=${(e: MouseEvent) => {
// Prevent navigation to detail view
e.preventDefault();
e.stopPropagation();
}}
></slot>
</btrix-overflow-dropdown>
</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()

View File

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