diff --git a/frontend/package.json b/frontend/package.json index d0d4225f36..d1f70a790a 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -17,6 +17,8 @@ "@shoelace-style/localize": "^3.2.1", "@shoelace-style/shoelace": "~2.18.0", "@tailwindcss/container-queries": "^0.1.1", + "@tanstack/lit-virtual": "^3.13.12", + "@tanstack/virtual-core": "^3.13.12", "@types/color": "^3.0.2", "@types/diff": "^5.0.9", "@types/lodash": "^4.14.178", diff --git a/frontend/patches/@shoelace-style+shoelace+2.18.0.patch b/frontend/patches/@shoelace-style+shoelace+2.18.0.patch new file mode 100644 index 0000000000..2c727eca68 --- /dev/null +++ b/frontend/patches/@shoelace-style+shoelace+2.18.0.patch @@ -0,0 +1,39 @@ +diff --git a/node_modules/@shoelace-style/shoelace/dist/chunks/chunk.ZLIGP6HZ.js b/node_modules/@shoelace-style/shoelace/dist/chunks/chunk.ZLIGP6HZ.js +index aa8ef35..de8c78d 100644 +--- a/node_modules/@shoelace-style/shoelace/dist/chunks/chunk.ZLIGP6HZ.js ++++ b/node_modules/@shoelace-style/shoelace/dist/chunks/chunk.ZLIGP6HZ.js +@@ -1,5 +1,8 @@ + // src/components/menu-item/submenu-controller.ts + import { createRef, ref } from "lit/directives/ref.js"; ++import { ++ LocalizeController ++} from "./chunk.WLV3FVBR.js"; + import { html } from "lit"; + var SubmenuController = class { + constructor(host, hasSlotController) { +@@ -9,6 +12,7 @@ var SubmenuController = class { + this.isPopupConnected = false; + this.skidding = 0; + this.submenuOpenDelay = 100; ++ this.localize = new LocalizeController(host); + // Set the safe triangle cursor position + this.handleMouseMove = (event) => { + this.host.style.setProperty("--safe-triangle-cursor-x", `${event.clientX}px`); +@@ -67,7 +71,7 @@ var SubmenuController = class { + this.handlePopupReposition = () => { + const submenuSlot = this.host.renderRoot.querySelector("slot[name='submenu']"); + const menu = submenuSlot == null ? void 0 : submenuSlot.assignedElements({ flatten: true }).filter((el) => el.localName === "sl-menu")[0]; +- const isRtl = getComputedStyle(this.host).direction === "rtl"; ++ const isRtl = this.localize.dir() === "rtl"; + if (!menu) { + return; + } +@@ -213,7 +217,7 @@ var SubmenuController = class { + return this.popupRef.value ? this.popupRef.value.active : false; + } + renderSubmenu() { +- const isRtl = getComputedStyle(this.host).direction === "rtl"; ++ const isRtl = this.localize.dir() === "rtl"; + if (!this.isConnected) { + return html` `; + } diff --git a/frontend/patches/@shoelace-style+shoelace+2.5.2.patch b/frontend/patches/@shoelace-style+shoelace+2.5.2.patch deleted file mode 100644 index ceea01c256..0000000000 --- a/frontend/patches/@shoelace-style+shoelace+2.5.2.patch +++ /dev/null @@ -1,57 +0,0 @@ -diff --git a/node_modules/@shoelace-style/shoelace/dist/components/format-date/format-date.d.ts b/node_modules/@shoelace-style/shoelace/dist/components/format-date/format-date.d.ts -index 74ef460..6233245 100644 ---- a/node_modules/@shoelace-style/shoelace/dist/components/format-date/format-date.d.ts -+++ b/node_modules/@shoelace-style/shoelace/dist/components/format-date/format-date.d.ts -@@ -1,4 +1,9 @@ - import ShoelaceElement from '../../internal/shoelace-element.js'; -+/** -+ * @attr {'short' | 'long'} time-zone-name -+ * @attr {String} time-zone -+ * @attr {'auto' | '12' | '24'} hour-format -+ */ - export default class SlFormatDate extends ShoelaceElement { - private readonly localize; - date: Date | string; -diff --git a/node_modules/@shoelace-style/shoelace/dist/components/input/input.d.ts b/node_modules/@shoelace-style/shoelace/dist/components/input/input.d.ts -index 7e9abef..cc5667d 100644 ---- a/node_modules/@shoelace-style/shoelace/dist/components/input/input.d.ts -+++ b/node_modules/@shoelace-style/shoelace/dist/components/input/input.d.ts -@@ -2,6 +2,10 @@ import '../icon/icon.js'; - import ShoelaceElement from '../../internal/shoelace-element.js'; - import type { CSSResultGroup } from 'lit'; - import type { ShoelaceFormControl } from '../../internal/shoelace-element.js'; -+/** -+ * @attr {String} help-text -+ * @attr {Boolean} password-toggle -+ */ - export default class SlInput extends ShoelaceElement implements ShoelaceFormControl { - static styles: CSSResultGroup; - private readonly formControlController; -diff --git a/node_modules/@shoelace-style/shoelace/dist/components/select/select.d.ts b/node_modules/@shoelace-style/shoelace/dist/components/select/select.d.ts -index 217f040..deee188 100644 ---- a/node_modules/@shoelace-style/shoelace/dist/components/select/select.d.ts -+++ b/node_modules/@shoelace-style/shoelace/dist/components/select/select.d.ts -@@ -6,6 +6,9 @@ import type { CSSResultGroup } from 'lit'; - import type { ShoelaceFormControl } from '../../internal/shoelace-element.js'; - import type SlOption from '../option/option.js'; - import type SlPopup from '../popup/popup.js'; -+/** -+ * @attr {Number} max-options-visible -+ */ - export default class SlSelect extends ShoelaceElement implements ShoelaceFormControl { - static styles: CSSResultGroup; - private readonly formControlController; -diff --git a/node_modules/@shoelace-style/shoelace/dist/components/textarea/textarea.d.ts b/node_modules/@shoelace-style/shoelace/dist/components/textarea/textarea.d.ts -index 9fd4c98..55108c4 100644 ---- a/node_modules/@shoelace-style/shoelace/dist/components/textarea/textarea.d.ts -+++ b/node_modules/@shoelace-style/shoelace/dist/components/textarea/textarea.d.ts -@@ -1,6 +1,9 @@ - import ShoelaceElement from '../../internal/shoelace-element.js'; - import type { CSSResultGroup } from 'lit'; - import type { ShoelaceFormControl } from '../../internal/shoelace-element.js'; -+/** -+ * @attr {String} help-text -+ */ - export default class SlTextarea extends ShoelaceElement implements ShoelaceFormControl { - static styles: CSSResultGroup; - private readonly formControlController; diff --git a/frontend/src/components/orgs-list.ts b/frontend/src/components/orgs-list.ts index 5f5252f6c4..991ac6a288 100644 --- a/frontend/src/components/orgs-list.ts +++ b/frontend/src/components/orgs-list.ts @@ -8,6 +8,8 @@ import type { SlRadioGroup, } from "@shoelace-style/shoelace"; import { serialize } from "@shoelace-style/shoelace/dist/utilities/form.js"; +import { WindowVirtualizerController } from "@tanstack/lit-virtual"; +import clsx from "clsx"; import Fuse from "fuse.js"; import { css, @@ -19,6 +21,7 @@ import { import { customElement, property, query, state } from "lit/decorators.js"; import { repeat } from "lit/directives/repeat.js"; import { when } from "lit/directives/when.js"; +import { debounce } from "lodash"; import { BtrixElement } from "@/classes/BtrixElement"; import type { Dialog } from "@/components/ui/dialog"; @@ -53,8 +56,13 @@ const none = html` export class OrgsList extends BtrixElement { static styles = css` btrix-table { - --btrix-table-grid-template-columns: min-content [clickable-start] - minmax(auto, 50ch) auto auto auto auto [clickable-end] min-content; + --btrix-table-grid-template-columns: 44px [clickable-start] + minmax(300px, 37fr) minmax(100px, 10fr) minmax(85px, 7fr) + minmax(90px, 9fr) minmax(100px, 10fr) [clickable-end] 40px; + } + btrix-table-head, + btrix-table-row { + grid-template-columns: var(--btrix-table-grid-template-columns); } `; @@ -113,12 +121,56 @@ export class OrgsList extends BtrixElement { @state() private orgFilter: OrgFilter = OrgFilter.All; - protected willUpdate(changedProperties: PropertyValues) { + @state() + private searchResults?: OrgData[]; + + @state() + private visibleOrgs?: OrgData[]; + + private readonly virtualizerController = + new WindowVirtualizerController(this, { + count: this.visibleOrgs?.length ?? 0, + estimateSize: () => 41, + getItemKey: (index) => this.visibleOrgs?.[index]?.id ?? index, + overscan: 20, + }); + + protected willUpdate( + changedProperties: PropertyValues & Map, + ) { if (changedProperties.has("orgList")) { this.fuse.setCollection(this.orgList ?? []); } + if (changedProperties.has("search") || changedProperties.has("orgFilter")) { + // if empty search string, immediately update; otherwise, debounce + if (this.search === "" || changedProperties.has("orgFilter")) { + this.updateVisibleOrgs(); + } else { + this.updateVisibleOrgsDebounced(); + } + } } + readonly updateVisibleOrgs = () => { + this.searchResults = this.search + ? this.fuse.search(this.search).map(({ item }) => item) + : this.orgList; + this.visibleOrgs = + this.searchResults?.filter((org) => + this.filterOrg(org, this.orgFilter), + ) ?? []; + + const virtualizer = this.virtualizerController.getVirtualizer(); + virtualizer.setOptions({ + ...virtualizer.options, + count: this.visibleOrgs.length, + }); + }; + + readonly updateVisibleOrgsDebounced = debounce(this.updateVisibleOrgs, 50, { + trailing: true, + }); + protected firstUpdated() { this.fuse.setCollection(this.orgList ?? []); } @@ -128,13 +180,8 @@ export class OrgsList extends BtrixElement { return this.renderSkeleton(); } - const searchResults = this.search - ? this.fuse.search(this.search).map(({ item }) => item) - : this.orgList; - - const orgs = searchResults?.filter((org) => - this.filterOrg(org, this.orgFilter), - ); + const virtualizer = this.virtualizerController.getVirtualizer(); + const virtualRows = virtualizer.getVirtualItems(); return html` this.renderFilterButton(searchResults, options))} + ].map((options) => + this.renderFilterButton(this.searchResults, options), + )} - + ${msg("Status")} @@ -225,7 +274,24 @@ export class OrgsList extends BtrixElement { - ${repeat(orgs || [], (org) => org.id, this.renderOrg)} +
+
+ ${repeat( + virtualRows, + (virtualRow) => virtualRow.key, + (virtualRow) => + this.renderOrg(this.visibleOrgs?.[virtualRow.index]), + )} +
+
@@ -765,7 +831,8 @@ export class OrgsList extends BtrixElement { } } - private readonly renderOrg = (org: OrgData) => { + private readonly renderOrg = (org?: OrgData) => { + if (!org) return; if (!this.userInfo) return; // There shouldn't really be a case where an org is in the org list but @@ -1031,7 +1098,7 @@ export class OrgsList extends BtrixElement { @@ -1048,9 +1115,13 @@ export class OrgsList extends BtrixElement { ${org.default diff --git a/frontend/yarn.lock b/frontend/yarn.lock index 7bca465ebe..f031b66c4f 100644 --- a/frontend/yarn.lock +++ b/frontend/yarn.lock @@ -2258,6 +2258,18 @@ resolved "https://registry.yarnpkg.com/@tailwindcss/container-queries/-/container-queries-0.1.1.tgz#9a759ce2cb8736a4c6a0cb93aeb740573a731974" integrity sha512-p18dswChx6WnTSaJCSGx6lTmrGzNNvm2FtXmiO6AuA1V4U5REyoqwmT6kgAsIMdjo07QdAfYXHJ4hnMtfHzWgA== +"@tanstack/lit-virtual@^3.13.12": + version "3.13.12" + resolved "https://registry.yarnpkg.com/@tanstack/lit-virtual/-/lit-virtual-3.13.12.tgz#36c2b55eae4c49dfe5a6e0f189c557fb0d4842b9" + integrity sha512-I1mGyQCCHT3GbKT2pUvyALHc7HPF9n/6FJzDFS3Ej6TuHNbCl2LN7/X3E+pVUtlFf4nZWRVH/TTlD7iO5y2iqQ== + dependencies: + "@tanstack/virtual-core" "3.13.12" + +"@tanstack/virtual-core@3.13.12", "@tanstack/virtual-core@^3.13.12": + version "3.13.12" + resolved "https://registry.yarnpkg.com/@tanstack/virtual-core/-/virtual-core-3.13.12.tgz#1dff176df9cc8f93c78c5e46bcea11079b397578" + integrity sha512-1YBOJfRHV4sXUmWsFSf5rQor4Ss82G8dQWLRbnk3GA4jeP8hQt1hxXh0tmflpC0dz3VgEv/1+qwPyLeWkQuPFA== + "@testing-library/dom@10.4.0": version "10.4.0" resolved "https://registry.yarnpkg.com/@testing-library/dom/-/dom-10.4.0.tgz#82a9d9462f11d240ecadbf406607c6ceeeff43a8"