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:
parent
b15c5ccddd
commit
ffc8b75ea8
@ -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()
|
||||
|
@ -48,3 +48,4 @@ import("./code");
|
||||
import("./pw-strength-alert");
|
||||
import("./search-combobox");
|
||||
import("./meter");
|
||||
import("./overflow-dropdown");
|
||||
|
57
frontend/src/components/overflow-dropdown.ts
Normal file
57
frontend/src/components/overflow-dropdown.ts
Normal 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>
|
||||
`;
|
||||
}
|
||||
}
|
@ -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()
|
||||
|
Loading…
Reference in New Issue
Block a user