diff --git a/panel/biome.json b/panel/biome.json index 4705bef1a..b6b4c28b5 100644 --- a/panel/biome.json +++ b/panel/biome.json @@ -1,5 +1,5 @@ { - "$schema": "https://biomejs.dev/schemas/2.5.15/schema.json", + "$schema": "https://biomejs.dev/schemas/2.6.2/schema.json", "vcs": { "enabled": false, "clientKind": "git", @@ -31,13 +31,17 @@ "noArguments": "error", "noExtraBooleanCast": "error", "noUselessCatch": "error", + "noUselessCatchBinding": "error", "noUselessTernary": "error", "noUselessEscapeInRegex": "error", "noUselessTypeConstraint": "error" }, "correctness": { + "noInvalidUseBeforeDeclaration": "error", + "noPrecisionLoss": "error", "noUndeclaredVariables": "error", "noUnusedLabels": "error", + "noUnusedPrivateClassMembers": "error", "noUnusedVariables": "error" }, "style": { @@ -54,6 +58,10 @@ "useSpreadOverApply": "error", "useTemplate": "error" }, + "nursery": { + "noFloatingPromises": "error", + "noUnmodifiedLoopCondition": "error" + }, "suspicious": { "noCatchAssign": "error", "noConsole": "warn", @@ -61,14 +69,19 @@ "noDoubleEquals": "error", "noDuplicateEnumValues": "error", "noEmptyBlockStatements": "error", - "noExplicitAny": "off", + "noFallthroughSwitchClause": "error", "noExtraNonNullAssertion": "error", + "noImportCycles": "error", "noIrregularWhitespace": "error", "noPrototypeBuiltins": "error", + "noReturnAssign": "error", + "noSelfCompare": "error", "noUnassignedVariables": "error", + "noUnusedExpressions": "error", "noUselessRegexBackrefs": "error", "noVar": "error", - "useAwait": "error" + "useAwait": "error", + "noLoopFunc": "error" } } }, diff --git a/panel/build.js b/panel/build.js index d0d760414..1567d86a0 100644 --- a/panel/build.js +++ b/panel/build.js @@ -5,7 +5,7 @@ import * as esbuild from "esbuild"; const watch = process.argv.includes("--watch"); const options = { - entryPoints: { "app.min": "./src/ts/app.ts" }, + entryPoints: { "app.min": "./src/ts/index.ts" }, bundle: true, format: "esm", target: "es2020", diff --git a/panel/package.json b/panel/package.json index 7db0ef21d..06ec4f2a6 100644 --- a/panel/package.json +++ b/panel/package.json @@ -38,6 +38,7 @@ "@codemirror/state": "^6.7.6", "@codemirror/view": "^6.43.14", "@lezer/highlight": "^1.2.5", + "@preact/signals-core": "^1.14.4", "chartist": "^1.5.0", "prosemirror-commands": "^1.7.2", "prosemirror-history": "^1.5.1", @@ -51,7 +52,7 @@ "sortablejs": "^1.15.7" }, "devDependencies": { - "@biomejs/biome": "2.5.15", + "@biomejs/biome": "^2.6.2", "@types/sortablejs": "^1.15.9", "esbuild": "^0.28.2", "postcss": "^8.5.30", diff --git a/panel/pnpm-lock.yaml b/panel/pnpm-lock.yaml index c34874c2e..f25ce18d7 100644 --- a/panel/pnpm-lock.yaml +++ b/panel/pnpm-lock.yaml @@ -26,6 +26,9 @@ importers: '@lezer/highlight': specifier: ^1.2.5 version: 1.2.5 + '@preact/signals-core': + specifier: ^1.14.4 + version: 1.14.4 chartist: specifier: ^1.5.0 version: 1.5.0 @@ -61,8 +64,8 @@ importers: version: 1.15.7 devDependencies: '@biomejs/biome': - specifier: 2.5.15 - version: 2.5.15 + specifier: ^2.6.2 + version: 2.6.2 '@types/sortablejs': specifier: ^1.15.9 version: 1.15.9 @@ -101,59 +104,71 @@ packages: resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==} engines: {node: '>=6.9.0'} - '@biomejs/biome@2.5.15': - resolution: {integrity: sha512-WZTW4slm/pdkh92K6t/3aEN++44JD1PQ7squ7RCMLI1flHGl43DVOIGXvCjHiZgBMO1V4uYxoFNtqrIA4qGuIQ==} + '@biomejs/biome@2.6.2': + resolution: {integrity: sha512-JLTFAFF9vfJcWwLhwuL4ZHflLg+XfTEC4IUnAlJRX1cMRt57++Xd1Ysb9Fdmlh9YUrvyCXfPEHjjEIqKJGdPKw==} engines: {node: '>=14.21.3'} hasBin: true - '@biomejs/cli-darwin-arm64@2.5.15': - resolution: {integrity: sha512-BZVzFhJ/mUvTLMYbc9x6el0o2Uv5zP7bACYyhFb6fSc1giroXm7PL0a5KqMJ9F+/BnkYRjagD/ROSDVOZ6eapg==} + '@biomejs/cli-android-arm64@2.6.2': + resolution: {integrity: sha512-dMpdpH7zFEL5FxuIA9LJW+RoNyC3oeDB7PJyjqVVwtMNoKB5uo8OZYmrVSBikn71txzYwy/3xga7UME6bJQZ6A==} + engines: {node: '>=14.21.3'} + cpu: [arm64] + os: [android] + + '@biomejs/cli-android-x64@2.6.2': + resolution: {integrity: sha512-XCwEhPBGYzuRA502dRkCcQ+5cefQwuxQR7f9wa4do92dvIcuBL6VMLMFB/fL9hlpZ4dGSfHdpGFSIDlp52mg0A==} + engines: {node: '>=14.21.3'} + cpu: [x64] + os: [android] + + '@biomejs/cli-darwin-arm64@2.6.2': + resolution: {integrity: sha512-rpXtabRgyZNz3aCjd/cRoSZGYi92R9eAXdUUe1sigIsRDyfMwgThaa98Yxt1fiZ7mxquqkAyIHIo08N/e1ToBA==} engines: {node: '>=14.21.3'} cpu: [arm64] os: [darwin] - '@biomejs/cli-darwin-x64@2.5.15': - resolution: {integrity: sha512-V5Kw63V+fVGNFhFrizDxMbGXAzZCh8kYzJpy3GI6gTapWDg7bZK9oYC9CbVgQSEdTnupz0U5Efz8Ev+5vdBBXw==} + '@biomejs/cli-darwin-x64@2.6.2': + resolution: {integrity: sha512-7GGzlm1GxUKoQ9CNV8tQZ+XXad4sz+v+uzNsWlhaGjsnZqUOVBw1OmAMpG3uNV/Mr0UrCpRJvSK0qvfuqSQOjg==} engines: {node: '>=14.21.3'} cpu: [x64] os: [darwin] - '@biomejs/cli-linux-arm64-musl@2.5.15': - resolution: {integrity: sha512-tGzZUTcJCV7tj5Adh/Gn6nR4MycqA3iohq2LEcrarAgRBkU0h0OKd2UzCRkOsOYOuUGaUJb4YmqRqyy2feGGSw==} + '@biomejs/cli-linux-arm64-musl@2.6.2': + resolution: {integrity: sha512-I+FYCLVSmR3cYfKcQ+KNeLOZ5w0wRd9fQFlw3JbSvy7bkegvTqdHaBRRcJzHQB/1ldldTLIcG21ka6X2cTWmJw==} engines: {node: '>=14.21.3'} cpu: [arm64] os: [linux] libc: [musl] - '@biomejs/cli-linux-arm64@2.5.15': - resolution: {integrity: sha512-XaG7P7eeSLYETD3K9grfB0mQmpLQZjygdbsxekWBJAg4am79fGAtRpfRyNnTvXB2fZFNnJZG3nXm7HBat0+VUw==} + '@biomejs/cli-linux-arm64@2.6.2': + resolution: {integrity: sha512-Qs4CofCE8MdkjZFfEjswh2NAcbyQ7IVYtGSzQBvLqFlla/CSXroGbktUFndWG/kVSTywadauzAspYWtSfqAc2g==} engines: {node: '>=14.21.3'} cpu: [arm64] os: [linux] libc: [glibc] - '@biomejs/cli-linux-x64-musl@2.5.15': - resolution: {integrity: sha512-IlxUcyxilVGPsE008x13pWdkTbU3nQpP2i9b5UrbOYj6goBkKsRX1XB7yJLcV+O1H9LBchIIm4adOaVLZBX0ZQ==} + '@biomejs/cli-linux-x64-musl@2.6.2': + resolution: {integrity: sha512-wnjEXAK2pDd2j8r9gKI3L+44sIxjkYzS5aHmD1+K57RNJ3sFyT4n22NH6QFPqqeVdRBxN54H3kckUUCrFKCcJw==} engines: {node: '>=14.21.3'} cpu: [x64] os: [linux] libc: [musl] - '@biomejs/cli-linux-x64@2.5.15': - resolution: {integrity: sha512-xE4iEW/3LqlYj9GFgGrFsFSH51dEEpUbYWBfeOv0q87WkUDxK2o/HhreSb7qMJqck70RVM6Lg96hgedcAYcOkA==} + '@biomejs/cli-linux-x64@2.6.2': + resolution: {integrity: sha512-FJKsb95418139xDdbv9XnNJsIIwvsgR21Of0aE9B9U+nQlOtRq5gJl2ps0jZUmYBxTaOlP9BKo8N+Ff8+Rui1g==} engines: {node: '>=14.21.3'} cpu: [x64] os: [linux] libc: [glibc] - '@biomejs/cli-win32-arm64@2.5.15': - resolution: {integrity: sha512-2kPKzhNlm8C+Ru3GcO0Me2ODkCBLrVOUNuyi84RJyWDVKAA4+Kjj3jMjL938lF6BRfvltC4vB2nPY9SvN51Ovg==} + '@biomejs/cli-win32-arm64@2.6.2': + resolution: {integrity: sha512-/PEOo6evlgrQOfKhMNtC/4Gk8ai3hySXXEtjUzCqZe/4iAbMdqelpp7qvZEDyLxlZS+BjOMQFpq2gXI/BPaAqw==} engines: {node: '>=14.21.3'} cpu: [arm64] os: [win32] - '@biomejs/cli-win32-x64@2.5.15': - resolution: {integrity: sha512-yAzh4UqEImV6Hcy0zjUqNfJAsUhoD64FBQCdTG8Md/Z2wQeVP/ZmJ3XRYAs1g8J9fjTFyl2fCtc5FB5til47+g==} + '@biomejs/cli-win32-x64@2.6.2': + resolution: {integrity: sha512-FVlWD+mDDz8nN5LWKRWUhhb7wKIK0VE3oXQvQBX7Fwwj00CvqMVhMyiz1iz8O5aZGmnkhFlKSwJPrUvMh5prQw==} engines: {node: '>=14.21.3'} cpu: [x64] os: [win32] @@ -524,6 +539,9 @@ packages: resolution: {integrity: sha512-7FNeNl8NCE7aINx7WXiKQrPYZWC/hvrTsmk6zmxbI7LTXE7hVek/n8AfVgpe2y82zl3w0HvCHN0bVKMBoJcC0w==} engines: {node: '>= 10.0.0'} + '@preact/signals-core@1.14.4': + resolution: {integrity: sha512-HNB6HYeYKhQbJ1aKl+YRjrS4+QWHLKX6qKoUsfS/m0vqzsVaEBiZiaKbG/e+NKk2ch5ALQr/ihWaMHxiCuuWHA==} + '@sindresorhus/merge-streams@4.0.0': resolution: {integrity: sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==} engines: {node: '>=18'} @@ -1224,39 +1242,47 @@ snapshots: '@babel/helper-validator-identifier@7.29.7': {} - '@biomejs/biome@2.5.15': + '@biomejs/biome@2.6.2': optionalDependencies: - '@biomejs/cli-darwin-arm64': 2.5.15 - '@biomejs/cli-darwin-x64': 2.5.15 - '@biomejs/cli-linux-arm64': 2.5.15 - '@biomejs/cli-linux-arm64-musl': 2.5.15 - '@biomejs/cli-linux-x64': 2.5.15 - '@biomejs/cli-linux-x64-musl': 2.5.15 - '@biomejs/cli-win32-arm64': 2.5.15 - '@biomejs/cli-win32-x64': 2.5.15 + '@biomejs/cli-android-arm64': 2.6.2 + '@biomejs/cli-android-x64': 2.6.2 + '@biomejs/cli-darwin-arm64': 2.6.2 + '@biomejs/cli-darwin-x64': 2.6.2 + '@biomejs/cli-linux-arm64': 2.6.2 + '@biomejs/cli-linux-arm64-musl': 2.6.2 + '@biomejs/cli-linux-x64': 2.6.2 + '@biomejs/cli-linux-x64-musl': 2.6.2 + '@biomejs/cli-win32-arm64': 2.6.2 + '@biomejs/cli-win32-x64': 2.6.2 + + '@biomejs/cli-android-arm64@2.6.2': + optional: true - '@biomejs/cli-darwin-arm64@2.5.15': + '@biomejs/cli-android-x64@2.6.2': optional: true - '@biomejs/cli-darwin-x64@2.5.15': + '@biomejs/cli-darwin-arm64@2.6.2': optional: true - '@biomejs/cli-linux-arm64-musl@2.5.15': + '@biomejs/cli-darwin-x64@2.6.2': optional: true - '@biomejs/cli-linux-arm64@2.5.15': + '@biomejs/cli-linux-arm64-musl@2.6.2': optional: true - '@biomejs/cli-linux-x64-musl@2.5.15': + '@biomejs/cli-linux-arm64@2.6.2': optional: true - '@biomejs/cli-linux-x64@2.5.15': + '@biomejs/cli-linux-x64-musl@2.6.2': optional: true - '@biomejs/cli-win32-arm64@2.5.15': + '@biomejs/cli-linux-x64@2.6.2': optional: true - '@biomejs/cli-win32-x64@2.5.15': + '@biomejs/cli-win32-arm64@2.6.2': + optional: true + + '@biomejs/cli-win32-x64@2.6.2': optional: true '@cacheable/memory@2.2.0': @@ -1572,6 +1598,8 @@ snapshots: '@parcel/watcher-win32-x64': 2.6.0 optional: true + '@preact/signals-core@1.14.4': {} + '@sindresorhus/merge-streams@4.0.0': {} '@types/linkify-it@5.0.0': {} diff --git a/panel/src/scss/components/_modals.scss b/panel/src/scss/components/_modals.scss index 71f985a72..f2632ffef 100644 --- a/panel/src/scss/components/_modals.scss +++ b/panel/src/scss/components/_modals.scss @@ -18,6 +18,10 @@ display: flex; } +body:has(.modal.open) { + overflow: hidden; +} + .modal-header, .modal-content, .modal-footer { diff --git a/panel/src/scss/components/_sidebar.scss b/panel/src/scss/components/_sidebar.scss index c158b64aa..95ff3bf67 100644 --- a/panel/src/scss/components/_sidebar.scss +++ b/panel/src/scss/components/_sidebar.scss @@ -35,6 +35,9 @@ body:has(.sidebar.show) { overflow: hidden; + @media (min-width: #{map.get($breakpoints, "md")}) { + overflow: visible; + } } .sidebar-wrapper { diff --git a/panel/src/scss/components/forms/_forms-base.scss b/panel/src/scss/components/forms/_forms-base.scss index 7baa8bbd4..61519e34c 100644 --- a/panel/src/scss/components/forms/_forms-base.scss +++ b/panel/src/scss/components/forms/_forms-base.scss @@ -151,8 +151,15 @@ .form-input-action { position: absolute; top: 50%; - right: 0.5rem; + right: 0.25rem; + padding: 0 0.25rem; + border: 0; + border-radius: var(--border-radius); + background: none; color: var(--color-base-100); + cursor: pointer; + font: inherit; + line-height: inherit; transform: translateY(-50%); transition: color var(--transition-time-sm); @@ -160,6 +167,14 @@ color: var(--color-accent-500); } + &:focus { + outline: none; + } + + &:focus-visible { + @include focusring; + } + .form-input:disabled + &, .form-input:read-only + &, .form-input:placeholder-shown + &, @@ -179,11 +194,11 @@ } .form-input-wrap .form-input-icon + .form-input { - padding-left: 1.75rem; + padding-left: 1.875rem; } .form-input-wrap .form-input:has(+ .form-input-action) { - padding-right: 1.625rem; + padding-right: 1.875rem; } .form-input-description { diff --git a/panel/src/ts/app.ts b/panel/src/ts/app.ts deleted file mode 100644 index 3b7169876..000000000 --- a/panel/src/ts/app.ts +++ /dev/null @@ -1,117 +0,0 @@ -import { ColorScheme } from "./components/color-scheme"; -import { Dropdowns } from "./components/dropdowns"; -import { Files } from "./components/files"; -import { Forms } from "./components/forms"; -import { Modals } from "./components/modals"; -import { Navigation } from "./components/navigation"; -import { Notifications } from "./components/notifications"; -import { Sections } from "./components/sections"; -import { Tabs } from "./components/tabs"; -import { Tooltips } from "./components/tooltips"; -import { Translation } from "./components/translation"; - -import { Backups } from "./components/views/backups"; -import { Dashboard } from "./components/views/dashboard"; -import { Login } from "./components/views/login"; -import { Pages } from "./components/views/pages"; -import { Plugins } from "./components/views/plugins"; -import { Statistics } from "./components/views/statistics"; -import { Updates } from "./components/views/updates"; - -interface AppConfig { - siteUri: string; - baseUri: string; - translation: { - code: string; - data: { [key: string]: string | string[] }; - }; - csrfToken?: string; - colorScheme?: string; - DateInput?: any; - DurationInput?: any; - EditorInput?: any; - SelectInput?: any; - TagsInput?: any; - Backups?: any; -} - -interface Component { - // biome-ignore lint/suspicious/noConfusingVoidType: The constructor returns void, not undefined - new (app: App): void; -} - -interface ComponentConfig { - globalAlias?: string; -} - -class App { - config: AppConfig = { - siteUri: "/", - baseUri: "/", - translation: { - code: "en", - data: {}, - }, - }; - - translation: Translation = {} as Translation; - - modals: Modals = {}; - - forms: Forms = {}; - - [alias: string]: any; - - load(config: AppConfig) { - this.loadConfig(config); - - this.loadComponent(Translation, { - globalAlias: "translation", - }); - - this.loadComponent(Modals, { - globalAlias: "modals", - }); - - this.loadComponent(Forms, { - globalAlias: "forms", - }); - - this.loadComponent(Tabs); - this.loadComponent(Dropdowns); - this.loadComponent(Tooltips); - this.loadComponent(Navigation); - this.loadComponent(ColorScheme); - this.loadComponent(Notifications); - this.loadComponent(Sections); - - this.loadComponent(Login); - this.loadComponent(Dashboard); - this.loadComponent(Pages); - this.loadComponent(Files); - this.loadComponent(Statistics); - this.loadComponent(Backups); - this.loadComponent(Updates); - this.loadComponent(Plugins); - } - - loadConfig(config: AppConfig) { - Object.assign(this.config, config); - } - - loadComponent( - component: Component, - options: ComponentConfig = { - globalAlias: undefined, - }, - ) { - const instance = new component(this); - const { globalAlias } = options; - if (globalAlias) { - this[globalAlias] = instance; - } - } -} - -export type { App }; -export const app = new App(); diff --git a/panel/src/ts/components/dropdowns.ts b/panel/src/ts/components/dropdowns.ts deleted file mode 100644 index 40c93c176..000000000 --- a/panel/src/ts/components/dropdowns.ts +++ /dev/null @@ -1,186 +0,0 @@ -import { getOuterHeight, getOuterWidth } from "../utils/dimensions"; -import { throttle } from "../utils/events"; -import { $, $$ } from "../utils/selectors"; - -const itemSelector = ".dropdown-item:not([disabled]):not([aria-disabled=true])"; - -export class Dropdowns { - constructor() { - if ($(".dropdown")) { - $$(".dropdown-button").forEach(initButton); - - document.addEventListener("click", (event) => { - const button = (event.target as HTMLElement).closest(".dropdown-button"); - const dropdown = button ? getDropdown(button) : null; - const wasVisible = dropdown ? isVisible(dropdown) : false; - - closeAll(); - - if (button) { - event.preventDefault(); - - if (dropdown && !wasVisible) { - open(button, dropdown); - } - } - }); - - document.addEventListener("keydown", (event) => { - const target = event.target as HTMLElement; - - const button = target.closest(".dropdown-button"); - - if (button) { - handleButtonKeydown(event, button); - return; - } - - const menu = target.closest(".dropdown-menu"); - - if (menu) { - handleMenuKeydown(event, menu); - } - }); - - document.addEventListener("focusin", (event) => { - const target = event.target as HTMLElement; - - if (!target.closest(".dropdown")) { - closeAll(); - } - }); - } - } -} - -function getDropdown(button: HTMLElement): HTMLElement | null { - return button.dataset.dropdown ? document.getElementById(button.dataset.dropdown) : null; -} - -function getButton(dropdown: HTMLElement): HTMLElement | null { - return dropdown.id ? $(`.dropdown-button[data-dropdown="${dropdown.id}"]`) : null; -} - -function isVisible(dropdown: HTMLElement): boolean { - return getComputedStyle(dropdown).display !== "none"; -} - -function initButton(button: HTMLElement) { - const dropdown = getDropdown(button); - - if (dropdown) { - button.setAttribute("aria-controls", dropdown.id); - button.setAttribute("aria-expanded", String(isVisible(dropdown))); - } -} - -function open(button: HTMLElement, dropdown: HTMLElement) { - // A tooltip of the button would cover the first items of the menu - $$(".tooltip").forEach((tooltip) => { - tooltip.remove(); - }); - - const resizeHandler = throttle(() => setDropdownPosition(dropdown), 100); - - dropdown.style.display = "block"; - setDropdownPosition(dropdown); - window.addEventListener("resize", resizeHandler); - - initButton(button); - button.setAttribute("aria-expanded", "true"); -} - -function closeAll() { - $$(".dropdown-menu").forEach((element) => { - element.style.display = ""; - }); - $$(".dropdown-button[aria-expanded=true]").forEach((button) => { - button.setAttribute("aria-expanded", "false"); - }); -} - -function getItems(dropdown: HTMLElement): HTMLElement[] { - return Array.from($$(itemSelector, dropdown)).filter((item) => item.getClientRects().length > 0); -} - -function handleButtonKeydown(event: KeyboardEvent, button: HTMLElement) { - const dropdown = getDropdown(button); - - if (!dropdown) { - return; - } - - if (event.key === "Escape" && isVisible(dropdown)) { - closeAll(); - } else if (event.key === "ArrowDown" || event.key === "ArrowUp") { - event.preventDefault(); - - if (!isVisible(dropdown)) { - closeAll(); - open(button, dropdown); - } - - const items = getItems(dropdown); - - (event.key === "ArrowDown" ? items[0] : items[items.length - 1])?.focus(); - } -} - -function handleMenuKeydown(event: KeyboardEvent, menu: HTMLElement) { - const button = getButton(menu); - - // Menus without a button (e.g. select inputs) manage their own keyboard interaction - if (!button || !isVisible(menu)) { - return; - } - - const items = getItems(menu); - const index = items.indexOf(document.activeElement as HTMLElement); - - switch (event.key) { - case "Escape": - event.preventDefault(); - closeAll(); - button.focus(); - break; - case "ArrowDown": - event.preventDefault(); - items[(index + 1) % items.length]?.focus(); - break; - case "ArrowUp": - event.preventDefault(); - items[(index <= 0 ? items.length : index) - 1]?.focus(); - break; - case "Home": - event.preventDefault(); - items[0]?.focus(); - break; - case "End": - event.preventDefault(); - items[items.length - 1]?.focus(); - break; - } -} - -function setDropdownPosition(dropdown: HTMLElement) { - dropdown.style.left = "0"; - dropdown.style.right = ""; - - const dropdownRect = dropdown.getBoundingClientRect(); - const dropdownTop = dropdownRect.top + window.scrollY; - const dropdownLeft = dropdownRect.left + window.scrollX; - const dropdownWidth = getOuterWidth(dropdown); - const dropdownHeight = getOuterHeight(dropdown); - - const windowWidth = document.documentElement.clientWidth; - const windowHeight = document.documentElement.clientHeight; - - if (dropdownLeft + dropdownWidth > windowWidth) { - dropdown.style.left = "auto"; - dropdown.style.right = "0"; - } - - if (dropdownTop < window.scrollY || window.scrollY < dropdownTop + dropdownHeight - windowHeight) { - window.scrollTo(window.scrollX, dropdownTop + dropdownHeight - windowHeight); - } -} diff --git a/panel/src/ts/components/inputs/editor/code/menu.ts b/panel/src/ts/components/editor/code-menu.ts similarity index 64% rename from panel/src/ts/components/inputs/editor/code/menu.ts rename to panel/src/ts/components/editor/code-menu.ts index 3a9419e8b..a59d07ba9 100644 --- a/panel/src/ts/components/inputs/editor/code/menu.ts +++ b/panel/src/ts/components/editor/code-menu.ts @@ -1,24 +1,17 @@ import { redo, redoDepth, undo, undoDepth } from "@codemirror/commands"; import type { EditorView, ViewUpdate } from "@codemirror/view"; import { ViewPlugin } from "@codemirror/view"; -import { app } from "../../../../app"; -import * as icons from "../../../icons"; - -function createButton(icon: keyof typeof icons, title: string) { - const btn = document.createElement("button"); - btn.type = "button"; - btn.className = `button toolbar-button`; - btn.dataset.tooltip = title; - btn.ariaLabel = title; - - // biome-ignore lint/performance/noDynamicNamespaceImportAccess: Accessing the icon dynamically from the imported icons object is necessary for flexibility - btn.innerHTML = icons[icon] || ""; - - return btn; -} +import { app } from "../../services/app"; +import { createToolbarButton } from "./toolbar-button"; +/** + * Represents a command of the code editor + */ type Command = (view: EditorView) => boolean; +/** + * Describes a button of the toolbar + */ interface MenuItem { command: Command; enabler: Command; @@ -26,6 +19,9 @@ interface MenuItem { name?: string; } +/** + * Manages the toolbar of the code editor + */ class Menu { items: MenuItem[]; dom: HTMLElement; @@ -57,10 +53,16 @@ class Menu { }); } + /** + * Remove the toolbar + */ destroy() { this.dom.remove(); } + /** + * Enable or disable the buttons when the document changes + */ update(update: ViewUpdate) { if (update.docChanged) { this.items.forEach(({ dom, enabler }) => { @@ -70,13 +72,16 @@ class Menu { } } +/** + * Get the CodeMirror extension that adds the toolbar to the editor + */ export function MenuPlugin() { return ViewPlugin.define( (view) => new Menu( [ - { dom: createButton("rotateLeft", app.translation.get("panel.editor.undo", "Undo")), command: (view) => undo(view), enabler: (view) => undoDepth(view.state) > 0 }, - { dom: createButton("rotateRight", app.translation.get("panel.editor.redo", "Redo")), command: (view) => redo(view), enabler: (view) => redoDepth(view.state) > 0 }, + { dom: createToolbarButton("rotateLeft", app.translation.get("panel.editor.undo", "Undo")), command: (view) => undo(view), enabler: (view) => undoDepth(view.state) > 0 }, + { dom: createToolbarButton("rotateRight", app.translation.get("panel.editor.redo", "Redo")), command: (view) => redo(view), enabler: (view) => redoDepth(view.state) > 0 }, ], view, ), diff --git a/panel/src/ts/components/inputs/editor/code/view.ts b/panel/src/ts/components/editor/code-view.ts similarity index 84% rename from panel/src/ts/components/inputs/editor/code/view.ts rename to panel/src/ts/components/editor/code-view.ts index 569b9580e..444a40925 100644 --- a/panel/src/ts/components/inputs/editor/code/view.ts +++ b/panel/src/ts/components/editor/code-view.ts @@ -4,13 +4,19 @@ import { HighlightStyle, syntaxHighlighting } from "@codemirror/language"; import { Compartment } from "@codemirror/state"; import { EditorView, keymap, placeholder } from "@codemirror/view"; import { tags } from "@lezer/highlight"; -import { MenuPlugin } from "./menu"; +import { MenuPlugin } from "./code-menu"; +/** + * Holds the options of the code editor + */ interface CodeViewOptions { editable?: boolean; placeholder?: string; } +/** + * Theme of the editor + */ const theme = EditorView.theme({ "&": { color: "var(--color-base-100)", @@ -30,6 +36,9 @@ const theme = EditorView.theme({ }, }); +/** + * Syntax highlighting of the editor + */ const myHighlightStyle = HighlightStyle.define([ { tag: tags.comment, color: "#777" }, { tag: tags.link, textDecoration: "underline" }, @@ -45,9 +54,20 @@ const myHighlightStyle = HighlightStyle.define([ { tag: [tags.variableName, tags.macroName, tags.propertyName, tags.className], color: "#1d75b3" }, ]); +/** + * Configuration that makes the editor read-only + */ const editable = new Compartment(); +/** + * Wraps the code editor (CodeMirror) + * + * The handler is called with the content every time it changes + */ export class CodeView { + /** + * CodeMirror view + */ view: EditorView; constructor(target: Element, content: string, inputEventHandler: (content: string) => void, options: CodeViewOptions = {}) { @@ -78,30 +98,48 @@ export class CodeView { }); } + /** + * Get the content of the editor + */ get content() { return this.view.state.doc.toString(); } + /** + * Set the content of the editor + */ set content(value: string) { this.view.dispatch({ changes: { from: 0, to: this.view.state.doc.length, insert: value }, }); } + /** + * Return whether the editor is editable + */ get editable() { return this.view.state.facet(EditorView.editable); } + /** + * Make the editor editable or read-only + */ set editable(value: boolean) { this.view.dispatch({ effects: editable.reconfigure(EditorView.editable.of(value)), }); } + /** + * Focus the editor + */ focus() { this.view.focus(); } + /** + * Remove the editor + */ destroy() { this.view.destroy(); } diff --git a/panel/src/ts/components/inputs/editor/markdown/commands.ts b/panel/src/ts/components/editor/markdown-commands.ts similarity index 89% rename from panel/src/ts/components/inputs/editor/markdown/commands.ts rename to panel/src/ts/components/editor/markdown-commands.ts index 79f321d61..fe46d2ea4 100644 --- a/panel/src/ts/components/inputs/editor/markdown/commands.ts +++ b/panel/src/ts/components/editor/markdown-commands.ts @@ -7,10 +7,13 @@ export { sinkListItem, wrapInList } from "prosemirror-schema-list"; import { redo as historyRedo, undo as historyUndo, redoDepth, undoDepth } from "prosemirror-history"; import { schema } from "prosemirror-markdown"; -import { app } from "../../../../app"; -import { $ } from "../../../../utils/selectors"; -import type { ImagePicker } from "../../image-picker"; +import { app } from "../../services/app"; +import { $ } from "../../utils/selectors"; +import type { ImagePicker } from "../inputs/image-picker"; +/** + * Open the modal to insert or edit a link + */ export const insertLink: Command = (state, dispatch, view) => { if (!dispatch) { return true; @@ -81,6 +84,9 @@ export const insertLink: Command = (state, dispatch, view) => { return true; }; +/** + * Remove the link at the selection + */ export const removeLink: Command = (state, dispatch, view) => { let { from, to } = state.selection; @@ -104,6 +110,9 @@ export const removeLink: Command = (state, dispatch, view) => { return true; }; +/** + * Open the modal to insert an image + */ export const insertImage: Command = (state, dispatch, view) => { if (!dispatch) { return canInsert(state, schema.nodes.image); @@ -141,6 +150,9 @@ export const insertImage: Command = (state, dispatch, view) => { return true; }; +/** + * Undo the last change + */ export const undo: Command = (state, dispatch, view) => { if (!dispatch) { return undoDepth(state) > 0; @@ -148,6 +160,9 @@ export const undo: Command = (state, dispatch, view) => { return historyUndo(state, dispatch, view); }; +/** + * Redo the last undone change + */ export const redo: Command = (state, dispatch, view) => { if (!dispatch) { return redoDepth(state) > 0; @@ -155,12 +170,18 @@ export const redo: Command = (state, dispatch, view) => { return historyRedo(state, dispatch, view); }; +/** + * Scroll the selection into view without adding to the history + */ export function updateView(view: EditorView) { const tr = view.state.tr.scrollIntoView(); tr.setMeta("addToHistory", false); view.dispatch(tr); } +/** + * Return whether a node can be inserted at the selection + */ export function canInsert(state: EditorState, nodeType: NodeType) { const $from = state.selection.$from; for (let d = $from.depth; d >= 0; d--) { @@ -172,6 +193,9 @@ export function canInsert(state: EditorState, nodeType: NodeType) { return false; } +/** + * Return whether a mark is active at the selection + */ export function isMarkActive(state: EditorState, type: MarkType) { const { from, $from, to, empty } = state.selection; if (empty) { @@ -180,6 +204,11 @@ export function isMarkActive(state: EditorState, type: MarkType) { return state.doc.rangeHasMark(from, to, type); } +/** + * Get the range of the text having a mark at the cursor + * + * @returns Range, mark and node, or `null` if the cursor is not in a text having the mark + */ export function getMarkRange(state: EditorState, markType: MarkType): { from: number; to: number; mark: Mark; node: Node } | null { const { $from } = state.selection; let from = $from.pos; diff --git a/panel/src/ts/components/inputs/editor/markdown/inputrules.ts b/panel/src/ts/components/editor/markdown-input-rules.ts similarity index 81% rename from panel/src/ts/components/inputs/editor/markdown/inputrules.ts rename to panel/src/ts/components/editor/markdown-input-rules.ts index dd2f7962a..4d3b27827 100644 --- a/panel/src/ts/components/inputs/editor/markdown/inputrules.ts +++ b/panel/src/ts/components/editor/markdown-input-rules.ts @@ -1,10 +1,16 @@ import { ellipsis, emDash, inputRules, smartQuotes, textblockTypeInputRule, wrappingInputRule } from "prosemirror-inputrules"; import type { NodeType, Schema } from "prosemirror-model"; +/** + * Convert the `> ` shortcut to a quote + */ export function blockQuoteRule(nodeType: NodeType) { return wrappingInputRule(/^\s*>\s$/, nodeType); } +/** + * Convert the `1. ` shortcut to a numbered list + */ export function orderedListRule(nodeType: NodeType) { return wrappingInputRule( /^(\d+)\.\s$/, @@ -14,18 +20,32 @@ export function orderedListRule(nodeType: NodeType) { ); } +/** + * Convert the `- `, `+ ` and `* ` shortcuts to a bullet list + */ export function bulletListRule(nodeType: NodeType) { return wrappingInputRule(/^\s*([-+*])\s$/, nodeType); } +/** + * Convert three backticks to a code block + */ export function codeBlockRule(nodeType: NodeType) { return textblockTypeInputRule(/^```$/, nodeType); } +/** + * Convert `#` shortcuts to a heading + * + * @param maxLevel Maximum level of the heading + */ export function headingRule(nodeType: NodeType, maxLevel: number) { return textblockTypeInputRule(new RegExp(`^(#{1,${maxLevel}})\\s$`), nodeType, (match) => ({ level: match[1].length })); } +/** + * Build the input rules of the editor + */ export function buildInputRules(schema: Schema) { const rules = smartQuotes.concat(ellipsis, emDash); diff --git a/panel/src/ts/components/inputs/editor/markdown/keymap.ts b/panel/src/ts/components/editor/markdown-keymap.ts similarity index 95% rename from panel/src/ts/components/inputs/editor/markdown/keymap.ts rename to panel/src/ts/components/editor/markdown-keymap.ts index 332ab353c..1c4c59b75 100644 --- a/panel/src/ts/components/inputs/editor/markdown/keymap.ts +++ b/panel/src/ts/components/editor/markdown-keymap.ts @@ -7,9 +7,17 @@ import type { Command } from "prosemirror-state"; const mac = typeof navigator !== "undefined" ? /Mac|iP(hone|[oa]d)/.test(navigator.platform) : false; +/** + * Build the keymap of the editor + * + * @param mapKeys Keys to rename, or to disable with `false` + */ export function buildKeymap(schema: Schema, mapKeys?: { [key: string]: false | string }) { const keys: { [key: string]: Command } = {}; + /** + * Bind a command to a key + */ function bind(key: string, cmd: Command) { if (mapKeys) { const mapped = mapKeys[key]; diff --git a/panel/src/ts/components/editor/markdown-link-tooltip.ts b/panel/src/ts/components/editor/markdown-link-tooltip.ts new file mode 100644 index 000000000..e70e2aa89 --- /dev/null +++ b/panel/src/ts/components/editor/markdown-link-tooltip.ts @@ -0,0 +1,141 @@ +import { schema } from "prosemirror-markdown"; +import type { Mark } from "prosemirror-model"; +import { Plugin } from "prosemirror-state"; +import type { EditorView } from "prosemirror-view"; +import { app } from "../../services/app"; +import { debounce, throttle } from "../../utils/events"; +import { $ } from "../../utils/selectors"; +import { link as linkIcon, pencil, trash } from "../ui/icons"; +import { Tooltip } from "../ui/tooltip"; +import { getMarkRange, insertLink, removeLink } from "./markdown-commands"; + +/** + * Make a relative link absolute + */ +function addBaseUri(text: string, baseUri: string) { + return text.replace(/^\/?(?!https?:\/\/)(.+)/, `${baseUri}$1`); +} +/** + * Shows a tooltip with the address of the link at the cursor and the buttons to edit or remove it + */ +class LinkTooltipView { + editorView: EditorView; + tooltip?: Tooltip; + baseUri: string; + + constructor(view: EditorView, baseUri: string) { + this.editorView = view; + + this.baseUri = baseUri; + + const resizeHandler = throttle(() => this.update(this.editorView), 100); + + const scrollHandler = debounce(() => this.destroy(), 100, true); + + window.addEventListener("resize", resizeHandler); + window.addEventListener("scroll", scrollHandler); + + this.editorView.dom.addEventListener("scroll", scrollHandler); + this.editorView.dom.addEventListener("blur", () => this.destroy()); + } + + /** + * Show the tooltip if the cursor is in a link + */ + update(view: EditorView) { + const { from, to } = view.state.selection; + + const range = getMarkRange(view.state, schema.marks.link); + + if (!(range && from >= range.from && to <= range.to)) { + this.destroy(); + return; + } + + if (this.tooltip) { + this.tooltip.remove(); + } + + const linkDom = this.findLinkElement(view, range.from + 1); + + if (linkDom) { + this.tooltip = this.createTooltip(range.mark, linkDom); + this.tooltip.show(); + } + } + + /** + * Get the element of the link that contains a position of the document + */ + private findLinkElement(view: EditorView, position: number) { + let element: HTMLElement | null = view.domAtPos(position).node as HTMLElement; + + while (element && element.tagName !== "A") { + element = element.parentElement; + } + + return element; + } + + /** + * Create the tooltip of a link, with its address and the buttons to edit and remove it + */ + private createTooltip(link: Mark, linkDom: HTMLElement) { + const t = (key: string, defaultValue: string) => app.translation.get(key, defaultValue); + + const tooltip = new Tooltip( + ` +
+ +
+ `, + { + referenceElement: linkDom, + removeOnMouseout: false, + delay: 0, + zIndex: 18, + }, + ); + + const tooltipLink = $(".editor-link-tooltip", tooltip.element) as HTMLAnchorElement; + const tooltipEditLink = $('[data-command="edit-link"]', tooltip.element) as HTMLButtonElement; + const tooltipDeleteLink = $('[data-command="delete-link"]', tooltip.element) as HTMLButtonElement; + + tooltipLink.insertAdjacentHTML("afterbegin", linkIcon); + tooltipEditLink.insertAdjacentHTML("afterbegin", pencil); + tooltipDeleteLink.insertAdjacentHTML("afterbegin", trash); + + const { state, dispatch } = this.editorView; + + tooltipEditLink.addEventListener("click", () => insertLink(state, dispatch, this.editorView)); + tooltipDeleteLink.addEventListener("click", () => removeLink(state, dispatch, this.editorView)); + + tooltip.element.addEventListener("mousedown", (event) => event.preventDefault()); + + return tooltip; + } + + /** + * Hide the tooltip + */ + destroy() { + if (this.tooltip) { + const tooltip = this.tooltip; + window.setTimeout(() => tooltip.remove(), 100); + } + this.tooltip = undefined; + } +} + +/** + * Get the plugin that shows the tooltip of the links + * + * @param baseUri Base URI used to resolve relative links + */ +export function linkTooltip(baseUri: string): Plugin { + return new Plugin({ + view(editorView: EditorView) { + return new LinkTooltipView(editorView, baseUri); + }, + }); +} diff --git a/panel/src/ts/components/inputs/editor/markdown/menu.ts b/panel/src/ts/components/editor/markdown-menu.ts similarity index 50% rename from panel/src/ts/components/inputs/editor/markdown/menu.ts rename to panel/src/ts/components/editor/markdown-menu.ts index 287ad3cad..82a3e5fd6 100644 --- a/panel/src/ts/components/inputs/editor/markdown/menu.ts +++ b/panel/src/ts/components/editor/markdown-menu.ts @@ -3,11 +3,14 @@ import type { MarkType, NodeType } from "prosemirror-model"; import type { Command } from "prosemirror-state"; import { NodeSelection, Plugin } from "prosemirror-state"; import type { EditorView } from "prosemirror-view"; -import { app } from "../../../../app"; -import { $$ } from "../../../../utils/selectors"; -import * as icons from "../../../icons"; -import { insertImage, insertLink, isMarkActive, lift, redo, setBlockType, sinkListItem, toggleMark, undo, wrapIn, wrapInList } from "./commands"; - +import { app } from "../../services/app"; +import { $$ } from "../../utils/selectors"; +import { insertImage, insertLink, isMarkActive, lift, redo, setBlockType, sinkListItem, toggleMark, undo, wrapIn, wrapInList } from "./markdown-commands"; +import { createToolbarButton } from "./toolbar-button"; + +/** + * Describes an item of the toolbar + */ interface MenuItem { command: Command; dom: HTMLElement; @@ -18,6 +21,9 @@ interface MenuItem { group?: string; } +/** + * Manages the toolbar of the Markdown editor + */ class MenuView { items: MenuItem[]; editorView: EditorView; @@ -59,17 +65,22 @@ class MenuView { this.update(); + // The focus goes back to the editor only for the items, the button of a menu keeps it + // so that the menu can be used with the keyboard this.dom.addEventListener("click", (e) => { - e.preventDefault(); - editorView.focus(); - items.forEach(({ command, dom }) => { - if (dom.contains(e.target as HTMLElement)) { - command(editorView.state, editorView.dispatch, editorView); - } - }); + const item = items.find(({ dom }) => dom.contains(e.target as HTMLElement)); + + if (item) { + e.preventDefault(); + editorView.focus(); + item.command(editorView.state, editorView.dispatch, editorView); + } }); } + /** + * Update the state of the items + */ update() { this.items.forEach(({ command, dom, mark, dropdown, name, node }) => { const state = this.editorView.state; @@ -101,6 +112,9 @@ class MenuView { }); } + /** + * Remove the toolbar + */ destroy() { this.dom.remove(); } @@ -108,161 +122,141 @@ class MenuView { let modalsInitialized = false; +/** + * Get the plugin that adds the toolbar to the editor + * + * @param id Id of the editor + */ export function menuPlugin(id: string) { if (!modalsInitialized) { initModals(); modalsInitialized = true; } - const items = [ - { - name: app.translation.get("panel.editor.paragraph", "Paragraph"), - command: setBlockType(schema.nodes.paragraph), - dom: createMenuItem(app.translation.get("panel.editor.paragraph", "Paragraph")), - dropdown: "editor-level", - group: "style", - }, - { - name: app.translation.get("panel.editor.code", "Code"), - node: schema.nodes.code_block, - command: setBlockType(schema.nodes.code_block), - dom: createMenuItem(`${app.translation.get("panel.editor.code", "Code")}`), - dropdown: "editor-level", - group: "style", - }, - { - name: app.translation.get("panel.editor.heading1", "Heading 1"), - command: setBlockType(schema.nodes.heading, { level: 1 }), - dom: createMenuItem(`${app.translation.get("panel.editor.heading1", "Heading 1")}`), - dropdown: "editor-level", - group: "style", - }, - { - name: app.translation.get("panel.editor.heading2", "Heading 2"), - command: setBlockType(schema.nodes.heading, { level: 2 }), - dom: createMenuItem(`${app.translation.get("panel.editor.heading2", "Heading 2")}`), - dropdown: "editor-level", - group: "style", - }, - { - name: app.translation.get("panel.editor.heading3", "Heading 3"), - command: setBlockType(schema.nodes.heading, { level: 3 }), - dom: createMenuItem(`${app.translation.get("panel.editor.heading3", "Heading 3")}`), - dropdown: "editor-level", - group: "style", - }, - { - name: app.translation.get("panel.editor.heading4", "Heading 4"), - command: setBlockType(schema.nodes.heading, { level: 4 }), - dom: createMenuItem(`${app.translation.get("panel.editor.heading4", "Heading 4")}`), - dropdown: "editor-level", - group: "style", + const items = createMenuItems(); + + return new Plugin({ + view(editorView: EditorView) { + const menuView = new MenuView(id, items, editorView); + + const toolbar = editorView.dom.parentNode?.querySelector(".editor-toolbar"); + toolbar?.prepend(menuView.dom); + + return menuView; }, + }); +} + +/** + * Create the items of the toolbar: the block type menu, the formatting and block buttons, the + * media buttons, undo and redo + */ +function createMenuItems(): MenuItem[] { + const t = (key: string, defaultValue: string) => app.translation.get(key, defaultValue); + + const { nodes, marks } = schema; + + // Items of the menu to choose the type of the block + const blockType = { dropdown: "editor-level", group: "style" }; + + const headings = [1, 2, 3, 4, 5, 6].map((level): MenuItem => { + const name = t(`panel.editor.heading${level}`, `Heading ${level}`); + + return { + ...blockType, + name, + command: setBlockType(nodes.heading, { level }), + dom: createMenuItem(`${name}`), + }; + }); + + return [ { - name: app.translation.get("panel.editor.heading5", "Heading 5"), - command: setBlockType(schema.nodes.heading, { level: 5 }), - dom: createMenuItem(`${app.translation.get("panel.editor.heading5", "Heading 5")}`), - dropdown: "editor-level", - group: "style", + ...blockType, + name: t("panel.editor.paragraph", "Paragraph"), + command: setBlockType(nodes.paragraph), + dom: createMenuItem(t("panel.editor.paragraph", "Paragraph")), }, { - name: app.translation.get("panel.editor.heading6", "Heading 6"), - command: setBlockType(schema.nodes.heading, { level: 6 }), - dom: createMenuItem(`${app.translation.get("panel.editor.heading6", "Heading 6")}`), - dropdown: "editor-level", - group: "style", + ...blockType, + name: t("panel.editor.code", "Code"), + node: nodes.code_block, + command: setBlockType(nodes.code_block), + dom: createMenuItem(`${t("panel.editor.code", "Code")}`), }, + ...headings, { - mark: schema.marks.strong, - command: toggleMark(schema.marks.strong), - dom: createButton("bold", app.translation.get("panel.editor.bold", "Bold")), + mark: marks.strong, + command: toggleMark(marks.strong), + dom: createToolbarButton("bold", t("panel.editor.bold", "Bold")), group: "style", }, { - mark: schema.marks.em, - command: toggleMark(schema.marks.em), - dom: createButton("italic", app.translation.get("panel.editor.italic", "Italic")), + mark: marks.em, + command: toggleMark(marks.em), + dom: createToolbarButton("italic", t("panel.editor.italic", "Italic")), group: "style", }, { - mark: schema.marks.code, - command: toggleMark(schema.marks.code), - dom: createButton("code", app.translation.get("panel.editor.code", "Code")), + mark: marks.code, + command: toggleMark(marks.code), + dom: createToolbarButton("code", t("panel.editor.code", "Code")), group: "style", }, { - command: wrapInList(schema.nodes.bullet_list, schema.nodes.list_item), - dom: createButton("listUnordered", app.translation.get("panel.editor.bulletList", "Bullet list")), + command: wrapInList(nodes.bullet_list, nodes.list_item), + dom: createToolbarButton("listUnordered", t("panel.editor.bulletList", "Bullet list")), group: "blocks", }, { - command: wrapInList(schema.nodes.ordered_list, schema.nodes.list_item), - dom: createButton("listOrdered", app.translation.get("panel.editor.numberedList", "Numbered list")), + command: wrapInList(nodes.ordered_list, nodes.list_item), + dom: createToolbarButton("listOrdered", t("panel.editor.numberedList", "Numbered list")), group: "blocks", }, { - node: schema.nodes.blockquote, - command: wrapIn(schema.nodes.blockquote), - dom: createButton("blockquote", app.translation.get("panel.editor.quote", "Quote")), + node: nodes.blockquote, + command: wrapIn(nodes.blockquote), + dom: createToolbarButton("blockquote", t("panel.editor.quote", "Quote")), group: "blocks", }, { - command: sinkListItem(schema.nodes.list_item), - dom: createButton("indentIncrease", app.translation.get("panel.editor.increaseIndent", "Increase indent")), + command: sinkListItem(nodes.list_item), + dom: createToolbarButton("indentIncrease", t("panel.editor.increaseIndent", "Increase indent")), group: "blocks", }, { command: lift, - dom: createButton("indentDecrease", app.translation.get("panel.editor.decreaseIndent", "Decrease indent")), + dom: createToolbarButton("indentDecrease", t("panel.editor.decreaseIndent", "Decrease indent")), group: "blocks", }, { - node: schema.nodes.image, + node: nodes.image, command: insertImage, - dom: createButton("image", app.translation.get("panel.editor.image", "Image")), + dom: createToolbarButton("image", t("panel.editor.image", "Image")), group: "media", }, { - mark: schema.marks.link, + mark: marks.link, command: insertLink, - dom: createButton("link", app.translation.get("panel.editor.link", "Link")), + dom: createToolbarButton("link", t("panel.editor.link", "Link")), group: "media", }, { command: undo, - dom: createButton("rotateLeft", app.translation.get("panel.editor.undo", "Undo")), + dom: createToolbarButton("rotateLeft", t("panel.editor.undo", "Undo")), }, { command: redo, - dom: createButton("rotateRight", app.translation.get("panel.editor.redo", "Redo")), + dom: createToolbarButton("rotateRight", t("panel.editor.redo", "Redo")), }, ]; - - return new Plugin({ - view(editorView: EditorView) { - const menuView = new MenuView(id, items, editorView); - - const toolbar = editorView.dom.parentNode?.querySelector(".editor-toolbar"); - toolbar?.prepend(menuView.dom); - - return menuView; - }, - }); -} - -function createButton(icon: keyof typeof icons, title: string) { - const btn = document.createElement("button"); - btn.type = "button"; - btn.className = `button toolbar-button`; - btn.dataset.tooltip = title; - btn.ariaLabel = title; - - // biome-ignore lint/performance/noDynamicNamespaceImportAccess: Accessing the icon dynamically from the imported icons object is necessary for flexibility - btn.innerHTML = icons[icon] || ""; - - return btn; } +/** + * Create an item of a toolbar menu + * + * @param text Content of the item, as HTML + */ function createMenuItem(text: string) { const item = document.createElement("button"); item.type = "button"; @@ -271,6 +265,11 @@ function createMenuItem(text: string) { return item; } +/** + * Create a toolbar menu + * + * @param id Id of the menu + */ function createDropdown(id: string) { const dropdown = document.createElement("div"); dropdown.className = "dropdown"; @@ -291,6 +290,9 @@ function createDropdown(id: string) { return dropdown; } +/** + * Confirm the link modal with Enter + */ function initModals() { const { linkModal } = app.modals; diff --git a/panel/src/ts/components/inputs/editor/markdown/placeholder.ts b/panel/src/ts/components/editor/markdown-placeholder.ts similarity index 88% rename from panel/src/ts/components/inputs/editor/markdown/placeholder.ts rename to panel/src/ts/components/editor/markdown-placeholder.ts index 5f5eb4bc8..f8b79ad06 100644 --- a/panel/src/ts/components/inputs/editor/markdown/placeholder.ts +++ b/panel/src/ts/components/editor/markdown-placeholder.ts @@ -2,6 +2,11 @@ import type { EditorState } from "prosemirror-state"; import { Plugin } from "prosemirror-state"; import { Decoration, DecorationSet } from "prosemirror-view"; +/** + * Get the plugin that shows a placeholder when the document is empty + * + * @param text Text of the placeholder + */ export function placeholderPlugin(text: string) { if (!text) { return new Plugin({}); diff --git a/panel/src/ts/components/inputs/editor/markdown/view.ts b/panel/src/ts/components/editor/markdown-view.ts similarity index 75% rename from panel/src/ts/components/inputs/editor/markdown/view.ts rename to panel/src/ts/components/editor/markdown-view.ts index 617aef827..224fc1dbb 100644 --- a/panel/src/ts/components/inputs/editor/markdown/view.ts +++ b/panel/src/ts/components/editor/markdown-view.ts @@ -5,20 +5,31 @@ import { defaultMarkdownParser, defaultMarkdownSerializer, schema } from "prosem import type { Transaction } from "prosemirror-state"; import { EditorState, Plugin } from "prosemirror-state"; import { EditorView } from "prosemirror-view"; -import { app } from "../../../../app"; -import { buildInputRules } from "./inputrules"; -import { buildKeymap } from "./keymap"; -import { linkTooltip } from "./linktooltip"; -import { menuPlugin } from "./menu"; -import { placeholderPlugin } from "./placeholder"; +import { app } from "../../services/app"; +import { buildInputRules } from "./markdown-input-rules"; +import { buildKeymap } from "./markdown-keymap"; +import { linkTooltip } from "./markdown-link-tooltip"; +import { menuPlugin } from "./markdown-menu"; +import { placeholderPlugin } from "./markdown-placeholder"; +/** + * Holds the options of the Markdown editor + */ export interface MarkdownViewOptions { editable?: boolean; placeholder?: string; attributes?: Record; } +/** + * Wraps the Markdown editor (ProseMirror) + * + * The handler is called with the Markdown every time the document changes + */ export class MarkdownView { + /** + * ProseMirror view + */ view: EditorView; constructor(id: string, target: Element, content: string, inputEventHandler: (content: string) => void, options: MarkdownViewOptions = {}) { @@ -54,10 +65,16 @@ export class MarkdownView { }); } + /** + * Get the content of the editor as Markdown + */ get content() { return defaultMarkdownSerializer.serialize(this.view.state.doc); } + /** + * Set the content of the editor from Markdown + */ set content(value: string) { this.view.updateState( EditorState.create({ @@ -67,18 +84,30 @@ export class MarkdownView { ); } + /** + * Return whether the editor is editable + */ get editable() { return this.view.props.editable ? this.view.props.editable(this.view.state) : true; } + /** + * Make the editor editable or read-only + */ set editable(value: boolean) { this.view.setProps({ editable: () => value }); } + /** + * Focus the editor + */ focus() { this.view.focus(); } + /** + * Remove the editor + */ destroy() { this.view.destroy(); } diff --git a/panel/src/ts/components/editor/toolbar-button.ts b/panel/src/ts/components/editor/toolbar-button.ts new file mode 100644 index 000000000..02bb58cd2 --- /dev/null +++ b/panel/src/ts/components/editor/toolbar-button.ts @@ -0,0 +1,20 @@ +import * as icons from "../ui/icons"; + +/** + * Create a toolbar button with an icon + * + * @param icon Name of the icon + * @param title Title and label of the button + */ +export function createToolbarButton(icon: keyof typeof icons, title: string) { + const button = document.createElement("button"); + button.type = "button"; + button.className = "button toolbar-button"; + button.dataset.tooltip = title; + button.ariaLabel = title; + + // biome-ignore lint/performance/noDynamicNamespaceImportAccess: Accessing the icon dynamically from the imported icons object is necessary for flexibility + button.innerHTML = icons[icon] || ""; + + return button; +} diff --git a/panel/src/ts/components/files.ts b/panel/src/ts/components/files.ts deleted file mode 100644 index 79fc5a931..000000000 --- a/panel/src/ts/components/files.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { app } from "../app"; -import { $ } from "../utils/selectors"; -import { FilesList } from "./fileslist"; - -export class Files { - constructor() { - const filesView = $("[data-view=files]"); - - if (filesView) { - new FilesList($(".files-list", filesView) as HTMLElement); - } - - const fileForm = $("[data-form=file-form]"); - - if (fileForm) { - const renameFileModal = app.modals.renameFileModal; - - if (renameFileModal) { - renameFileModal.form?.element.addEventListener("keydown", (event) => { - if (event.key === "Enter") { - renameFileModal.form?.element.submit(); - event.preventDefault(); - } - }); - - renameFileModal.onOpen((modal, trigger) => { - if (trigger) { - const input = $('[id="renameFileModal.filename"]', modal.element) as HTMLInputElement; - input.value = trigger.dataset.filename as string; - input.setSelectionRange(0, input.value.lastIndexOf(".")); - } - }); - } - - const replaceFileCommand = $("[data-command=replaceFile]"); - - if (replaceFileCommand) { - replaceFileCommand.addEventListener("click", () => { - const form = document.createElement("form"); - form.hidden = true; - form.action = replaceFileCommand.dataset.action as string; - form.method = "post"; - form.enctype = "multipart/form-data"; - - const fileInput = document.createElement("input"); - fileInput.name = "file"; - fileInput.type = "file"; - fileInput.accept = replaceFileCommand.dataset.extension as string; - form.appendChild(fileInput); - - const csrfInput = document.createElement("input"); - csrfInput.name = "csrf-token"; - csrfInput.value = app.config.csrfToken as string; - form.appendChild(csrfInput); - - fileInput.click(); - - fileInput.addEventListener("change", () => { - document.body.appendChild(form); - form.submit(); - }); - }); - } - } - } -} diff --git a/panel/src/ts/components/files/file-inputs.ts b/panel/src/ts/components/files/file-inputs.ts new file mode 100644 index 000000000..60823e770 --- /dev/null +++ b/panel/src/ts/components/files/file-inputs.ts @@ -0,0 +1,95 @@ +import type { Form } from "../../services/form"; +import { toCamelCase } from "../../utils/strings"; +import { SelectInput } from "../inputs/select-input"; +import { TagsInput } from "../inputs/tags-input"; +import type * as icons from "../ui/icons"; + +/** + * Describes a file listed by the select and tags inputs + */ +export interface FileOption { + name: string; + type: string; + thumbnail?: string; +} + +/** + * Get the select and tags inputs of a form that list files If a type is given, the inputs that list + * only images are left out unless the type is `image` + * + * @param onlyForType Type of the file to offer to the inputs + */ +function fileInputs(form: Form | undefined, onlyForType?: string) { + const inputs: (SelectInput | TagsInput)[] = []; + + if (form) { + for (const name in form.inputs) { + const input = form.inputs[name]; + const acceptsType = (images: boolean) => onlyForType === undefined || !images || onlyForType === "image"; + + if (input instanceof SelectInput && (input.element.classList.contains("form-file") || (input.element.classList.contains("form-image") && acceptsType(true)))) { + inputs.push(input); + } + + if (input instanceof TagsInput && (input.element.classList.contains("form-files") || (input.element.classList.contains("form-images") && acceptsType(true)))) { + inputs.push(input); + } + } + } + + return inputs; +} + +/** + * Add a file to an input and keep its options sorted + */ +function addOption(input: SelectInput | TagsInput, file: FileOption) { + const thumb = file.type === "image" ? file.thumbnail : undefined; + + if (input instanceof SelectInput) { + input.addOption({ label: file.name, value: file.name, thumb, icon: `file-${file.type}` }); + } else { + input.addDropdownItem({ label: file.name, value: file.name, thumb, icon: toCamelCase(`file-${file.type}`) as keyof typeof icons }); + } + + input.sortDropdownItems(); +} + +/** + * Remove a file from an input + */ +function removeOption(input: SelectInput | TagsInput, filename: string) { + if (input instanceof SelectInput) { + input.removeOption(filename); + } else { + input.removeDropdownItem(filename); + } +} + +/** + * Add a new file to the inputs of a form + */ +export function addToFileInputs(form: Form | undefined, file: FileOption) { + for (const input of fileInputs(form, file.type)) { + addOption(input, file); + } +} + +/** + * Remove a deleted file from the inputs of a form + */ +export function removeFromFileInputs(form: Form | undefined, filename: string) { + for (const input of fileInputs(form)) { + removeOption(input, filename); + } +} + +/** + * Replace a renamed file in the inputs of a form + */ +export function replaceInFileInputs(form: Form | undefined, filename: string, file: FileOption) { + for (const input of fileInputs(form)) { + removeOption(input, filename); + addOption(input, file); + } +} diff --git a/panel/src/ts/components/files/files-list.ts b/panel/src/ts/components/files/files-list.ts new file mode 100644 index 000000000..780ad0475 --- /dev/null +++ b/panel/src/ts/components/files/files-list.ts @@ -0,0 +1,588 @@ +import { effect, signal } from "@preact/signals-core"; +import { app } from "../../services/app"; +import type { Form } from "../../services/form"; +import { post } from "../../services/request"; +import { initSearchInput, makeSearchRegExp } from "../../utils/search"; +import { $, $$ } from "../../utils/selectors"; +import { escapeHtml } from "../../utils/validation"; +import { Notification } from "../ui/notification"; +import { removeFromFileInputs, replaceInFileInputs } from "./file-inputs"; + +/** + * Describes the file data sent by the server after a rename + */ +interface RenamedFile { + filename: string; + type: string; + thumbnail?: string; + uri: string; + actions: Record<"info" | "rename" | "replace" | "delete", string>; +} + +/** + * Describes the file data sent by the server after a replacement + */ +interface ReplacedFile { + thumbnail?: string; + lastModifiedTime: string; + size: string; +} + +/** + * Handles a list of files with thumbnails or list view, search, multiple selection and file + * commands + */ +export class FilesList { + readonly element: HTMLElement; + readonly form?: Form; + private selectionAnchor?: HTMLElement; + + private readonly selected = signal(new Set()); + + // `null` means that no search has been performed yet + private readonly query = signal(null); + + constructor(element: HTMLElement, form?: Form) { + this.element = element; + this.form = form; + + this.initThumbnails(); + this.initViewAs(); + this.initSelection(); + this.initReplaceCommand(); + this.initSearch(); + this.initRenameModal(); + this.initDeleteModal(); + } + + /** + * Sort the items by the text of the elements matching a selector + * + * @param selector Selector of the element whose text is the sorting key + */ + sort(selector: string = ".file-name") { + const filesItems = $$(".files-item", this.element); + Array.from(filesItems) + .sort((a: HTMLElement, b: HTMLElement) => { + const keyA = $(selector, a)?.textContent; + const keyB = $(selector, b)?.textContent; + return keyA?.localeCompare(keyB ?? "") ?? 0; + }) + .forEach((element: HTMLElement) => { + element.parentElement?.appendChild(element); + }); + } + + /** + * Load the thumbnails of the files + */ + private initThumbnails() { + $$(".file-thumbnail[data-src]", this.element).forEach((thumbnail) => { + thumbnail.addEventListener("error", () => thumbnail.removeAttribute("src")); + thumbnail.src = thumbnail.dataset.src as string; + }); + } + + /** + * Switch between list and thumbnails view and remember the choice + */ + private initViewAs() { + const toggle = $(".form-togglegroup.files-list-view-as", this.element); + + if (!toggle) { + return; + } + + const formName = this.element.closest("form")?.dataset.form; + + const fieldName = toggle.dataset.for; + + const key = formName ? `${formName}.${fieldName}` : fieldName; + const viewAs = window.localStorage.getItem(`formwork.filesListViewAs[${key}]`); + + if (viewAs) { + $$("input", toggle).forEach((input) => { + input.checked = false; + }); + ($(`input[value=${viewAs}]`, this.element) as HTMLInputElement).checked = true; + this.element.classList.toggle("is-thumbnails", viewAs === "thumbnails"); + this.element.classList.toggle("is-list", viewAs === "list"); + } + + $$("input", toggle).forEach((input) => { + input.addEventListener("input", () => { + this.element.classList.toggle("is-thumbnails", input.value === "thumbnails"); + this.element.classList.toggle("is-list", input.value === "list"); + window.localStorage.setItem(`formwork.filesListViewAs[${key}]`, input.value); + }); + }); + } + + /** + * Select files with Ctrl/Cmd and Shift, and open them in thumbnails view + */ + private initSelection() { + document.addEventListener("click", (event) => this.handleClick(event)); + + effect(() => this.renderSelection()); + } + + /** + * Handle a click on the page: select the clicked file, or clear the selection + */ + private handleClick(event: MouseEvent) { + const target = event.target as HTMLElement; + const item = target.closest(".files-item"); + + // Ignore clicks on the files of a different list + if (!item || !this.element.contains(item)) { + if (!target.closest(".modal, .files-selection-actions")) { + this.clearSelection(); + } + return; + } + + if (this.hasSelectionActions()) { + if (target.closest(".dropdown")) { + this.clearSelection(); + return; + } + + if (event.ctrlKey || event.metaKey) { + event.preventDefault(); + this.toggleSelected(item); + return; + } + + if (event.shiftKey) { + event.preventDefault(); + this.selectRange(item); + return; + } + } + + const anchor = $(".file-name a", item) as HTMLAnchorElement; + + if (this.element.classList.contains("is-thumbnails") && anchor.href) { + this.clearSelection(); + location.href = anchor.href; + } + } + + /** + * Add an item to the selection, or remove it if selected + */ + private toggleSelected(item: HTMLElement) { + const selected = new Set(this.selected.value); + + if (!selected.delete(item)) { + selected.add(item); + } + + this.selected.value = selected; + this.selectionAnchor = item; + } + + /** + * Select the items between the last one selected with Ctrl/Cmd and an item + */ + private selectRange(item: HTMLElement) { + const items = Array.from($$(".files-item", this.element)); + + if (!this.selectionAnchor || !items.includes(this.selectionAnchor)) { + this.selectionAnchor = item; + } + + const anchorIndex = items.indexOf(this.selectionAnchor); + const currentIndex = items.indexOf(item); + + const start = Math.min(anchorIndex, currentIndex); + const end = Math.max(anchorIndex, currentIndex); + + this.selected.value = new Set(items.slice(start, end + 1)); + } + + /** + * Mark the selected items and show the actions for the selection with the number of items + */ + private renderSelection() { + const selected = this.selected.value; + const selectionActions = $(".files-selection-actions", this.element); + + // Items removed from the list are not counted as selected + const selectedItems = Array.from($$(".files-item", this.element)).filter((item) => selected.has(item)); + + $$(".files-item", this.element).forEach((item) => { + item.classList.toggle("is-selected", selected.has(item)); + }); + + if (selectionActions && selectionActions.children.length > 0) { + const countElement = $(".files-selection-count", selectionActions) as HTMLElement; + + selectionActions.hidden = selectedItems.length === 0; + + // The counter is left empty until the selection changes + if (selectedItems.length > 0 || countElement.textContent !== "") { + countElement.textContent = `(${selectedItems.length})`; + } + } + } + + /** + * Replace a file by uploading a new one + */ + private initReplaceCommand() { + this.element.addEventListener("click", (event) => { + const command = (event.target as HTMLElement).closest("[data-command=replaceFile]"); + + if (command) { + this.chooseReplacement(command); + } + }); + } + + /** + * Ask for a file to replace the one of a command and upload it + */ + private chooseReplacement(command: HTMLElement) { + const fileInput = document.createElement("input"); + fileInput.type = "file"; + fileInput.accept = command.dataset.mimetype as string; + fileInput.click(); + + fileInput.addEventListener("change", () => { + if (fileInput.files?.length) { + const formData = new FormData(); + formData.append("file", fileInput.files[0]); + + post(command.dataset.action as string, formData, (response) => { + const notification = new Notification(response.message, response.status); + + if (response.status === "success") { + if (command.closest("[data-form=file-form]")) { + window.location.reload(); + } else if (response.data.thumbnail) { + this.updateReplacedItem(command.closest(".files-item") as HTMLElement, { ...response.data, thumbnail: response.data.thumbnail }); + } + } + + notification.show(); + }); + } + + fileInput.remove(); + }); + } + + /** + * Show the thumbnail, date and size of a replaced file + */ + private updateReplacedItem(item: HTMLElement, data: ReplacedFile & { thumbnail: string }) { + ($(".file-thumbnail", item) as HTMLImageElement | HTMLVideoElement).src = data.thumbnail; + ($(".file-date", item) as HTMLElement).textContent = data.lastModifiedTime; + ($(".file-size", item) as HTMLElement).textContent = data.size; + } + + /** + * Filter the files by name and parent, highlighting the matches + */ + private initSearch() { + const searchInput = $(".files-search", this.element) as HTMLInputElement; + + if (!searchInput) { + return; + } + + effect(() => { + if (this.query.value !== null) { + this.filterItems(this.query.value); + } + }); + + initSearchInput(searchInput, () => { + this.query.value = escapeHtml(searchInput.value); + }); + } + + /** + * Show only the files matching a search, highlighting the matches + */ + private filterItems(query: string) { + const regexp = query ? makeSearchRegExp(query, { wordStart: false, global: true }) : null; + + ($(".files-item") as HTMLElement).classList.toggle("is-filtered", query.length > 0); + + $$(".files-item").forEach((item) => { + const matches = this.highlightMatches(item, regexp); + + item.style.display = !query || matches > 0 ? "" : "none"; + }); + } + + /** + * Highlight the matches in the name and in the parent of a file and return how many there are + */ + private highlightMatches(item: HTMLElement, regexp: RegExp | null) { + let matches = 0; + + for (const selector of [".file-name a", ".file-parent-title"]) { + const element = $(selector, item); + + if (!element) { + continue; + } + + const text = escapeHtml(element.textContent); + + if (regexp && text.match(regexp) !== null) { + element.innerHTML = text.replace(regexp, "$&"); + matches++; + } else { + element.innerHTML = text; + } + } + + return matches; + } + + /** + * Initialize the modal to rename a file + */ + private initRenameModal() { + const renameFileItemModal = app.modals.renameFileItemModal; + + if (!renameFileItemModal) { + return; + } + + $('[id="renameFileItemModal.filename"]', renameFileItemModal.element)?.addEventListener("keydown", (event) => { + if (event.key === "Enter") { + renameFileItemModal.triggerCommand("rename-file"); + event.preventDefault(); + } + }); + + renameFileItemModal.onOpen((modal, trigger) => { + if (trigger) { + const input = $('[id="renameFileItemModal.filename"]', modal.element) as HTMLInputElement; + input.value = (trigger.closest("[data-filename]") as HTMLElement)?.dataset.filename as string; + input.setSelectionRange(0, input.value.lastIndexOf(".")); + + Object.assign(modal.data, { + action: trigger.dataset.action, + item: trigger.closest(".files-item"), + filename: (trigger.closest(".files-item") as HTMLElement)?.dataset.filename, + input, + }); + } + }); + + renameFileItemModal.onCommand("rename-file", (modal) => { + const { action, item, filename, input } = modal.data; + + post( + action as string, + { + filename: filename as string, + "renameFileItemModal[filename]": (input as HTMLInputElement).value, + }, + (response) => { + if (response.status === "success") { + const data = response.data; + + this.updateItem(item as HTMLElement, data); + replaceInFileInputs(this.form, filename as string, { name: data.filename, type: data.type, thumbnail: data.thumbnail }); + this.sort(".file-name"); + } + + const notification = new Notification(response.message, response.status); + notification.show(); + + modal.close(); + }, + ); + + modal.close(); + }); + } + + /** + * Initialize the modal to delete a file or the selected files + */ + private initDeleteModal() { + const deleteFileItemModal = app.modals.deleteFileItemModal; + + if (!deleteFileItemModal) { + return; + } + + deleteFileItemModal.onOpen((modal, trigger) => { + if (!trigger) { + return; + } + + const item = trigger.closest(".files-item"); + + if (item) { + Object.assign(modal.data, { + action: trigger.dataset.action, + item, + filename: item.dataset.filename, + items: undefined, + }); + + modal.setMessage(app.translation.get("panel.pages.deleteFile.prompt")); + } else if (trigger.closest(".files-selection-actions")) { + const selectedItems = this.selectedItems(); + + Object.assign(modal.data, { + action: undefined, + item: undefined, + filename: undefined, + items: selectedItems, + }); + + if (selectedItems.length > 1) { + modal.setMessage(app.translation.get("panel.pages.deleteFile.multiple.prompt").replace("%d", String(selectedItems.length))); + } else { + modal.setMessage(app.translation.get("panel.pages.deleteFile.prompt")); + } + } + }); + + deleteFileItemModal.onCommand("delete-file", (modal) => { + const { action, item, filename, items } = modal.data; + + if (items) { + this.deleteItems(items as HTMLElement[]); + + modal.close(); + return; + } + + post( + action as string, + { + filename: filename as string, + }, + (response) => { + if (response.status === "success") { + (item as HTMLElement).remove(); + + if (this.element.querySelectorAll(".files-item").length === 0) { + this.element.hidden = true; + } + + removeFromFileInputs(this.form, filename as string); + } + + const notification = new Notification(response.message, response.status); + notification.show(); + + modal.close(); + }, + ); + + modal.close(); + }); + } + + /** + * Delete the selected files and show a single notification when all requests are done + */ + private deleteItems(selectedItems: HTMLElement[]) { + let pending = selectedItems.length; + let deletedCount = 0; + let lastMessage = ""; + + selectedItems.forEach((selectedItem) => { + const deleteCommand = $("[data-command=deleteFile]", selectedItem); + const itemAction = deleteCommand?.dataset.action; + const itemFilename = selectedItem.dataset.filename; + + if (!itemAction) { + pending -= 1; + return; + } + + post( + itemAction, + { + filename: itemFilename as string, + }, + (response) => { + pending -= 1; + lastMessage = response.message; + + if (response.status === "success") { + deletedCount += 1; + selectedItem.remove(); + + removeFromFileInputs(this.form, itemFilename as string); + } + + if (pending === 0) { + if (this.element.querySelectorAll(".files-item").length === 0) { + this.element.hidden = true; + } + + this.selected.value = new Set(this.selectedItems()); + + const allDeleted = deletedCount === selectedItems.length; + const message = allDeleted && deletedCount > 1 ? app.translation.get("panel.files.deleted.multiple").replace("%d", String(deletedCount)) : lastMessage; + const notification = new Notification(message, allDeleted ? "success" : "error"); + notification.show(); + } + }, + ); + }); + } + + /** + * Update an item after the file has been renamed + */ + private updateItem(item: HTMLElement, data: RenamedFile) { + item.dataset.filename = data.filename; + + const anchor = $(".file-name a", item) as HTMLAnchorElement; + anchor.textContent = data.filename; + anchor.href = data.uri; + + ($("[data-command=infoFile]", item) as HTMLAnchorElement).href = data.actions.info; + ($("[data-command=previewFile]", item) as HTMLAnchorElement).href = data.uri; + ($("[data-command=renameFile]", item) as HTMLElement).dataset.action = data.actions.rename; + ($("[data-command=replaceFile]", item) as HTMLElement).dataset.action = data.actions.replace; + ($("[data-command=deleteFile]", item) as HTMLElement).dataset.action = data.actions.delete; + + if (data.thumbnail) { + const thumbnail = $(".file-thumbnail", item) as HTMLImageElement | HTMLVideoElement; + thumbnail.src = data.thumbnail; + } + } + + /** + * Return whether the list has actions for the selected files + */ + private hasSelectionActions() { + const selectionActions = $(".files-selection-actions", this.element); + return !!selectionActions && selectionActions.children.length > 0; + } + + /** + * Get the selected items that are still in the list + */ + private selectedItems() { + return Array.from($$(".files-item", this.element)).filter((item) => this.selected.value.has(item)); + } + + /** + * Deselect all items + */ + private clearSelection() { + if (this.selectedItems().length === 0) { + return; + } + this.selected.value = new Set(); + this.selectionAnchor = undefined; + } +} diff --git a/panel/src/ts/components/fileslist.ts b/panel/src/ts/components/fileslist.ts deleted file mode 100644 index aa0593379..000000000 --- a/panel/src/ts/components/fileslist.ts +++ /dev/null @@ -1,504 +0,0 @@ -import { app } from "../app"; -import { debounce } from "../utils/events"; -import { Request } from "../utils/request"; -import { $, $$ } from "../utils/selectors"; -import { toCamelCase } from "../utils/strings"; -import { escapeHtml, escapeRegExp, makeDiacriticsRegExp } from "../utils/validation"; -import type { Form } from "./form"; -import type * as icons from "./icons"; -import { SelectInput } from "./inputs/select-input"; -import { TagsInput } from "./inputs/tags-input"; -import { Notification } from "./notification"; - -export class FilesList { - readonly element: HTMLElement; - readonly form?: Form; - private selectionAnchor?: HTMLElement; - - constructor(element: HTMLElement, form?: Form) { - this.element = element; - this.form = form; - - this.initFileList(); - this.initModals(); - } - - sort(selector: string = ".file-name") { - const filesItems = $$(".files-item", this.element); - Array.from(filesItems) - .sort((a: HTMLElement, b: HTMLElement) => { - const keyA = $(selector, a)?.textContent; - const keyB = $(selector, b)?.textContent; - return keyA?.localeCompare(keyB ?? "") ?? 0; - }) - .forEach((element: HTMLElement) => { - element.parentElement?.appendChild(element); - }); - } - - private initFileList() { - const toggle = $(".form-togglegroup.files-list-view-as", this.element); - const searchInput = $(".files-search", this.element) as HTMLInputElement; - - $$(".file-thumbnail[data-src]", this.element).forEach((thumbnail) => { - thumbnail.addEventListener("error", () => thumbnail.removeAttribute("src")); - thumbnail.src = thumbnail.dataset.src as string; - }); - - if (toggle) { - const formName = this.element.closest("form")?.dataset.form; - - const fieldName = toggle.dataset.for; - - const key = formName ? `${formName}.${fieldName}` : fieldName; - const viewAs = window.localStorage.getItem(`formwork.filesListViewAs[${key}]`); - - if (viewAs) { - $$("input", toggle).forEach((input) => { - input.checked = false; - }); - ($(`input[value=${viewAs}]`, this.element) as HTMLInputElement).checked = true; - this.element.classList.toggle("is-thumbnails", viewAs === "thumbnails"); - this.element.classList.toggle("is-list", viewAs === "list"); - } - - $$("input", toggle).forEach((input) => { - input.addEventListener("input", () => { - this.element.classList.toggle("is-thumbnails", input.value === "thumbnails"); - this.element.classList.toggle("is-list", input.value === "list"); - window.localStorage.setItem(`formwork.filesListViewAs[${key}]`, input.value); - }); - }); - } - - document.addEventListener("click", (event) => { - const target = event.target as HTMLElement; - const filesItem = target.closest(".files-item"); - - if (!filesItem || !this.element.contains(filesItem)) { - if (!target.closest(".modal, .files-selection-actions")) { - this.clearSelection(); - } - // Ignore clicks on file items that belong to a different FilesList instance - return; - } - - if (this.hasSelectionActions()) { - if (target.closest(".dropdown")) { - this.clearSelection(); - return; - } - - if (event.ctrlKey || event.metaKey) { - event.preventDefault(); - filesItem.classList.toggle("is-selected"); - this.selectionAnchor = filesItem; - this.updateSelectionActions(); - return; - } - - if (event.shiftKey) { - event.preventDefault(); - const items = Array.from($$(".files-item", this.element)); - const currentIndex = items.indexOf(filesItem); - - if (!this.selectionAnchor || !items.includes(this.selectionAnchor)) { - this.selectionAnchor = filesItem; - } - - const anchorIndex = items.indexOf(this.selectionAnchor); - const start = Math.min(anchorIndex, currentIndex); - const end = Math.max(anchorIndex, currentIndex); - - items.forEach((element, index) => { - element.classList.toggle("is-selected", index >= start && index <= end); - }); - this.updateSelectionActions(); - return; - } - } - - const anchor = $(".file-name a", filesItem) as HTMLAnchorElement; - if (this.element.classList.contains("is-thumbnails") && anchor.href) { - this.clearSelection(); - location.href = anchor.href; - } - }); - - this.element.addEventListener("click", (event) => { - const element = (event.target as HTMLElement).closest("[data-command=replaceFile]") as HTMLElement; - if (element) { - const fileInput = document.createElement("input"); - fileInput.type = "file"; - fileInput.accept = element.dataset.mimetype as string; - fileInput.click(); - - fileInput.addEventListener("change", () => { - if (fileInput.files?.length) { - const formData = new FormData(); - formData.append("csrf-token", app.config.csrfToken as string); - formData.append("file", fileInput.files[0]); - - new Request( - { - method: "POST", - url: element.dataset.action, - data: formData, - }, - (response) => { - const notification = new Notification(response.message, response.status); - - if (response.status === "success") { - if (element.closest("[data-form=file-form]")) { - window.location.reload(); - } else if (response.data.thumbnail) { - const thumbnail = $(".file-thumbnail", element.closest(".files-item") as HTMLElement) as HTMLImageElement | HTMLVideoElement; - thumbnail.src = response.data.thumbnail; - - const fileDate = $(".file-date", element.closest(".files-item") as HTMLElement) as HTMLElement; - fileDate.textContent = response.data.lastModifiedTime; - - const fileSize = $(".file-size", element.closest(".files-item") as HTMLElement) as HTMLElement; - fileSize.textContent = response.data.size; - } - } - - notification.show(); - }, - ); - } - - fileInput.remove(); - }); - } - }); - - if (searchInput) { - const handleSearch = () => { - const value = escapeHtml(searchInput.value); - ($(".files-item") as HTMLElement).classList.toggle("is-filtered", value.length > 0); - - $$(".files-item").forEach((element) => { - let matches = 0; - - for (const selector of [".file-name a", ".file-parent-title"]) { - const item = $(selector, element) as HTMLElement; - - if (!item) { - continue; - } - - const text = escapeHtml(item.textContent); - - const regexp = value ? new RegExp(`${makeDiacriticsRegExp(escapeRegExp(value))}`, "gi") : null; - - if (regexp && text.match(regexp) !== null) { - item.innerHTML = text.replace(regexp, "$&"); - matches++; - } else { - item.innerHTML = text; - } - } - - if (!value || matches > 0) { - element.style.display = ""; - } else { - element.style.display = "none"; - } - }); - }; - - searchInput.addEventListener("keyup", debounce(handleSearch, 100)); - searchInput.addEventListener("search", handleSearch); - - document.addEventListener("keydown", (event) => { - if (event.ctrlKey || event.metaKey) { - if (event.key === "f" && document.activeElement !== searchInput) { - searchInput.focus(); - event.preventDefault(); - } - } - }); - } - } - - private initModals() { - const renameFileItemModal = app.modals.renameFileItemModal; - - if (renameFileItemModal) { - $('[id="renameFileItemModal.filename"]', renameFileItemModal.element)?.addEventListener("keydown", (event) => { - if (event.key === "Enter") { - renameFileItemModal.triggerCommand("rename-file"); - event.preventDefault(); - } - }); - - renameFileItemModal.onOpen((modal, trigger) => { - if (trigger) { - const input = $('[id="renameFileItemModal.filename"]', modal.element) as HTMLInputElement; - input.value = (trigger.closest("[data-filename]") as HTMLElement)?.dataset.filename as string; - input.setSelectionRange(0, input.value.lastIndexOf(".")); - - Object.assign(modal.data, { - action: trigger.dataset.action, - item: trigger.closest(".files-item"), - filename: (trigger.closest(".files-item") as HTMLElement)?.dataset.filename, - input, - }); - } - }); - - renameFileItemModal.onCommand("rename-file", (modal) => { - const { action, item, filename, input } = modal.data; - - new Request( - { - method: "POST", - url: action as string, - data: { - filename, - "renameFileItemModal[filename]": (input as HTMLInputElement).value, - "csrf-token": app.config.csrfToken as string, - }, - }, - (response) => { - if (response.status === "success") { - const data = response.data; - - (item as HTMLElement).dataset.filename = data.filename; - - const anchor = $(".file-name a", item as HTMLElement) as HTMLAnchorElement; - anchor.innerText = data.filename; - anchor.href = data.uri; - - ($("[data-command=infoFile]", item as HTMLElement) as HTMLAnchorElement).href = data.actions.info; - ($("[data-command=previewFile]", item as HTMLElement) as HTMLAnchorElement).href = data.uri; - ($("[data-command=renameFile]", item as HTMLElement) as HTMLElement).dataset.action = data.actions.rename; - ($("[data-command=replaceFile]", item as HTMLElement) as HTMLElement).dataset.action = data.actions.replace; - ($("[data-command=deleteFile]", item as HTMLElement) as HTMLElement).dataset.action = data.actions.delete; - - if (data.thumbnail) { - const thumbnail = $(".file-thumbnail", item as HTMLElement) as HTMLImageElement | HTMLVideoElement; - thumbnail.src = data.thumbnail; - } - - if (this.form) { - for (const name in this.form.inputs) { - const input = this.form.inputs[name]; - - if (input instanceof SelectInput && (input.element.classList.contains("form-file") || input.element.classList.contains("form-image"))) { - input.removeOption(filename as string); - input.addOption({ - label: data.filename, - value: data.filename, - thumb: data.type === "image" ? data.thumbnail : undefined, - icon: `file-${data.type}`, - }); - input.sortDropdownItems(); - } - - if (input instanceof TagsInput && (input.element.classList.contains("form-files") || input.element.classList.contains("form-images"))) { - input.removeDropdownItem(filename as string); - input.addDropdownItem({ - label: data.filename, - value: data.filename, - thumb: data.type === "image" ? data.thumbnail : undefined, - icon: toCamelCase(`file-${data.type}`) as keyof typeof icons, - }); - input.sortDropdownItems(); - } - } - } - - this.sort(".file-name"); - } - - const notification = new Notification(response.message, response.status); - notification.show(); - - modal.close(); - }, - ); - - modal.close(); - }); - } - - const deleteFileItemModal = app.modals.deleteFileItemModal; - - if (deleteFileItemModal) { - deleteFileItemModal.onOpen((modal, trigger) => { - if (!trigger) { - return; - } - - const item = trigger.closest(".files-item"); - - if (item) { - Object.assign(modal.data, { - action: trigger.dataset.action, - item, - filename: item.dataset.filename, - items: undefined, - }); - - modal.setMessage(app.translation.get("panel.pages.deleteFile.prompt")); - } else if (trigger.closest(".files-selection-actions")) { - const selectedItems = $$(".files-item.is-selected", this.element); - - Object.assign(modal.data, { - action: undefined, - item: undefined, - filename: undefined, - items: selectedItems, - }); - - if (selectedItems.length > 1) { - modal.setMessage(app.translation.get("panel.pages.deleteFile.multiple.prompt").replace("%d", String(selectedItems.length))); - } else { - modal.setMessage(app.translation.get("panel.pages.deleteFile.prompt")); - } - } - }); - - deleteFileItemModal.onCommand("delete-file", (modal) => { - const { action, item, filename, items } = modal.data; - - if (items) { - const selectedItems = items as HTMLElement[]; - - let pending = selectedItems.length; - let deletedCount = 0; - let lastMessage = ""; - - selectedItems.forEach((selectedItem) => { - const deleteCommand = $("[data-command=deleteFile]", selectedItem); - const itemAction = deleteCommand?.dataset.action; - const itemFilename = selectedItem.dataset.filename; - - if (!itemAction) { - pending -= 1; - return; - } - - new Request( - { - method: "POST", - url: itemAction, - data: { - filename: itemFilename, - "csrf-token": app.config.csrfToken as string, - }, - }, - (response) => { - pending -= 1; - lastMessage = response.message; - - if (response.status === "success") { - deletedCount += 1; - selectedItem.remove(); - - if (this.form) { - for (const name in this.form.inputs) { - const input = this.form.inputs[name]; - if (input instanceof SelectInput && (input.element.classList.contains("form-file") || input.element.classList.contains("form-image"))) { - input.removeOption(itemFilename as string); - } - - if (input instanceof TagsInput && (input.element.classList.contains("form-files") || input.element.classList.contains("form-images"))) { - input.removeDropdownItem(itemFilename as string); - } - } - } - } - - if (pending === 0) { - if (this.element.querySelectorAll(".files-item").length === 0) { - this.element.hidden = true; - } - - this.updateSelectionActions(); - - const allDeleted = deletedCount === selectedItems.length; - const message = allDeleted && deletedCount > 1 ? app.translation.get("panel.files.deleted.multiple").replace("%d", String(deletedCount)) : lastMessage; - const notification = new Notification(message, allDeleted ? "success" : "error"); - notification.show(); - } - }, - ); - }); - - modal.close(); - return; - } - - new Request( - { - method: "POST", - url: action as string, - data: { - filename, - "csrf-token": app.config.csrfToken as string, - }, - }, - (response) => { - if (response.status === "success") { - (item as HTMLElement).remove(); - - if (this.element.querySelectorAll(".files-item").length === 0) { - this.element.hidden = true; - } - - if (this.form) { - for (const name in this.form.inputs) { - const input = this.form.inputs[name]; - if (input instanceof SelectInput && (input.element.classList.contains("form-file") || input.element.classList.contains("form-image"))) { - input.removeOption(filename as string); - } - - if (input instanceof TagsInput && (input.element.classList.contains("form-files") || input.element.classList.contains("form-images"))) { - input.removeDropdownItem(filename as string); - } - } - } - } - - const notification = new Notification(response.message, response.status); - notification.show(); - - modal.close(); - }, - ); - - modal.close(); - }); - } - } - - private hasSelectionActions() { - const selectionActions = $(".files-selection-actions", this.element); - return !!selectionActions && selectionActions.children.length > 0; - } - - private clearSelection() { - const selectedItems = $$(".files-item.is-selected", this.element); - if (selectedItems.length === 0) { - return; - } - selectedItems.forEach((element) => { - element.classList.remove("is-selected"); - }); - this.selectionAnchor = undefined; - this.updateSelectionActions(); - } - - private updateSelectionActions() { - const selectionActions = $(".files-selection-actions", this.element); - if (selectionActions && selectionActions.children.length > 0) { - const selectedItems = $$(".files-item.is-selected", this.element); - selectionActions.hidden = selectedItems.length === 0; - const countElement = $(".files-selection-count", selectionActions) as HTMLElement; - countElement.textContent = `(${selectedItems.length})`; - } - } -} diff --git a/panel/src/ts/components/form.ts b/panel/src/ts/components/form.ts deleted file mode 100644 index ec3fde98e..000000000 --- a/panel/src/ts/components/form.ts +++ /dev/null @@ -1,264 +0,0 @@ -import "../polyfills/request-submit"; -import { app } from "../app"; -import { serializeForm } from "../utils/forms"; -import { $, $$ } from "../utils/selectors"; -import { ArrayInput } from "./inputs/array-input"; -import { ColorInput } from "./inputs/color-input"; -import { DateInput } from "./inputs/date-input"; -import { DurationInput } from "./inputs/duration-input"; -import { EditorInput } from "./inputs/editor-input"; -import { ImagePicker } from "./inputs/image-picker"; -import { Input } from "./inputs/input"; -import { RangeInput } from "./inputs/range-input"; -import { SelectInput } from "./inputs/select-input"; -import { SlugInput } from "./inputs/slug-input"; -import { TagsInput } from "./inputs/tags-input"; -import { TogglegroupInput } from "./inputs/togglegroup-input"; -import { UploadInput } from "./inputs/upload-input"; - -interface FormOptions { - preventUnloadOnChanges?: boolean; -} - -interface InputElement extends HTMLElement { - name: string; -} - -interface FormInput { - element: InputElement; - name: string; - value: string; -} - -export type HTMLInputLike = HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement; - -export class Form { - readonly formInputs: FormInput[] = []; - - originalData: string; - element: HTMLFormElement; - options: FormOptions = { - preventUnloadOnChanges: true, - }; - - private associations: { [key: string]: (element: HTMLElement) => void } = { - ".editor-textarea": (element) => this.formInputs.push(new EditorInput(element as HTMLTextAreaElement)), - - ".form-input-color": (element) => this.formInputs.push(new ColorInput(element as HTMLInputElement)), - - ".form-input-array": (element) => this.formInputs.push(new ArrayInput(element as HTMLFieldSetElement, this)), - - ".form-input-date": (element) => this.formInputs.push(new DateInput(element as HTMLInputElement, app.config.DateInput)), - - ".form-input-duration": (element) => this.formInputs.push(new DurationInput(element as HTMLInputElement, app.config.DurationInput)), - - ".form-input-slug": (element) => this.formInputs.push(new SlugInput(element as HTMLInputElement)), - - ".form-input-tags": (element) => this.formInputs.push(new TagsInput(element as HTMLInputElement, app.config.TagsInput)), - - ".form-togglegroup": (element) => this.formInputs.push(new TogglegroupInput(element as HTMLFieldSetElement)), - - ".image-picker": (element) => this.formInputs.push(new ImagePicker(element as HTMLInputElement)), - - "input[type=file]": (element) => this.formInputs.push(new UploadInput(element as HTMLInputElement, this)), - - "input[type=range]": (element) => this.formInputs.push(new RangeInput(element as HTMLInputElement)), - - ".form-select": (element) => this.formInputs.push(new SelectInput(element as HTMLSelectElement, app.config.SelectInput)), - - ".form-input-action[data-reset]": (element) => { - const button = element as HTMLButtonElement; - const targetId = button.dataset.reset; - if (targetId) { - button.addEventListener("click", () => { - const target = document.getElementById(targetId) as HTMLInputElement; - target.value = ""; - target.dispatchEvent(new Event("input", { bubbles: true })); - target.dispatchEvent(new Event("change", { bubbles: true })); - }); - } - }, - - "input[data-enable]": (element) => { - const inputElement = element as HTMLInputElement; - inputElement.addEventListener("change", () => { - const targetId = inputElement.dataset.enable; - if (targetId) { - const inputs = targetId.split(","); - for (const name of inputs) { - const input = $(`input[name="${name}"]`); - if (!input) { - continue; - } - if (!inputElement.checked) { - input.disabled = true; - } else { - input.disabled = false; - } - } - } - }); - }, - - "input, select, textarea": (element) => { - const inputElement = element as HTMLInputLike; - if (!this.formInputs.find((input) => input.element === inputElement)) { - this.formInputs.push(new Input(inputElement)); - } - }, - }; - - constructor(form: HTMLFormElement, options: Partial = {}) { - this.element = form; - - this.loadInputs(); - - // Serialize after inputs are loaded - this.originalData = serializeForm(form); - - this.options = { ...this.options, ...options }; - - if (this.options.preventUnloadOnChanges) { - this.preventUnloadOnChanges(); - } - } - - get inputs(): { [name: string]: FormInput } { - const inputs: { [name: string]: FormInput } = {}; - for (const input of this.formInputs) { - inputs[input.name] = input; - } - return inputs; - } - - private loadInputs(parent: HTMLElement = this.element) { - for (const selector in this.associations) { - $$(selector, parent).forEach((element: HTMLElement) => { - this.associations[selector](element); - }); - } - } - - private loadInput(element: HTMLElement) { - for (const selector in this.associations) { - if (element.matches(selector)) { - this.associations[selector](element); - } - } - } - - hasChanged(checkFileInputs: boolean = true) { - const fileInputs = $$("input[type=file]", this.element); - - if (checkFileInputs === true && fileInputs.length > 0) { - for (const fileInput of Array.from(fileInputs)) { - if (fileInput.files && fileInput.files.length > 0) { - return true; - } - } - } - - return serializeForm(this.element) !== this.originalData; - } - - duplicateInput(element: HTMLInputLike, targetElement: HTMLElement) { - let newNode: HTMLElement; - let newInput: HTMLInputLike | undefined; - const wrap = element.closest(".form-input-wrap"); - - if (wrap) { - newNode = wrap.cloneNode() as HTMLElement; - for (const child of Array.from(wrap.children)) { - if (child === element) { - newInput = child.cloneNode(true) as HTMLInputLike; - if (newInput instanceof HTMLInputElement && (newInput.type === "checkbox" || newInput.type === "radio")) { - newInput.checked = false; - } else { - newInput.value = ""; - } - newNode.appendChild(newInput); - } else if (child.matches(`.form-input-action, .form-input-description, .form-input-icon`)) { - newNode.appendChild(child.cloneNode(true)); - } - } - if (newInput === undefined) { - throw new Error("Could not replicate input: input element not found in wrapper."); - } - } else { - newInput = newNode = element.cloneNode(true) as HTMLInputLike; - if (newInput instanceof HTMLInputElement && (newInput.type === "checkbox" || newInput.type === "radio")) { - newInput.checked = false; - } else { - newInput.value = ""; - } - } - - // Generate a new unique ID for the duplicated input - const previousId = (element as HTMLElement).id; - const newId = `${element.tagName.toLowerCase()}-${Math.random().toString(36).slice(2)}`; - newInput.id = newId; - - if (wrap && previousId) { - $$(`label[for="${previousId}"]`).forEach((label) => { - label.htmlFor = newId; - }); - } - - targetElement.appendChild(newNode); - - if (wrap) { - this.loadInputs(newNode); - } else { - this.loadInput(newInput); - } - } - - private preventUnloadOnChanges() { - const handleBeforeunload = (event: Event) => { - if (this.hasChanged()) { - event.preventDefault(); - event.returnValue = false; - } - }; - - const removeBeforeUnload = () => { - window.removeEventListener("beforeunload", handleBeforeunload); - }; - - window.addEventListener("beforeunload", handleBeforeunload); - - this.element.addEventListener("submit", removeBeforeUnload); - - const changesModal = app.modals.changesModal; - - if (changesModal) { - changesModal.onCommand("continue", (_, button) => { - removeBeforeUnload(); - if (button?.dataset.href) { - window.location.href = button.dataset.href; - } - }); - - $$('a[href]:not([href^="#"]):not([target="_blank"]):not([target^="formwork-"])').forEach((element) => { - if (element.closest(".editor-wrap")) { - return; - } - - element.addEventListener("click", (event) => { - if (this.hasChanged()) { - event.preventDefault(); - - app.modals.changesModal.onOpen((modal) => { - const continueCommand = $("[data-command=continue]", modal.element); - if (continueCommand) { - continueCommand.dataset.href = element.href; - } - }); - - app.modals.changesModal.open(); - } - }); - }); - } - } -} diff --git a/panel/src/ts/components/inputs/array-input.ts b/panel/src/ts/components/inputs/array-input.ts index 45859bd5e..affd8ce8b 100644 --- a/panel/src/ts/components/inputs/array-input.ts +++ b/panel/src/ts/components/inputs/array-input.ts @@ -1,23 +1,44 @@ +import type { Form, HTMLInputLike } from "../../services/form"; import { $, $$ } from "../../utils/selectors"; -import type { Form, HTMLInputLike } from "../form"; - -export class ArrayInput { - readonly element: HTMLFieldSetElement; +import { InputBase } from "./input"; + +/** + * Provides an input with a list of rows that can be added, removed and reordered + * + * Each row has a value input and, if the array is associative, a key input. The value is a JSON + * object + */ +export class ArrayInput extends InputBase { + /** + * Form containing the input + */ readonly form: Form; + /** + * Whether the rows have a key + */ readonly isAssociative: boolean; + /** + * Key and value inputs of each row + */ private itemCache = new WeakMap(); + /** + * Create the array input + */ constructor(element: HTMLFieldSetElement, form: Form) { - this.element = element; + super(element); this.form = form; this.isAssociative = element.classList.contains("form-input-array-associative"); - this.init(element); + void this.init(element); } + /** + * Bind the rows and make them sortable + */ private async init(element: HTMLFieldSetElement) { $$(".form-input-array-row", element).forEach((element) => { this.bindItemEvents(element); @@ -40,14 +61,9 @@ export class ArrayInput { }); } - get name(): string { - return this.element.name; - } - - set name(value: string) { - this.element.name = value; - } - + /** + * Get the value of the array as JSON + */ get value(): string { const values: { [key: string]: string } = {}; @@ -69,6 +85,9 @@ export class ArrayInput { return JSON.stringify(values); } + /** + * Rename the inputs without a key before the form is submitted, so that they are not sent + */ private handleSubmit() { const emptyKeyName = `${this.name}[]`; for (const input of this.form.formInputs) { @@ -79,6 +98,9 @@ export class ArrayInput { } } + /** + * Get the key and value inputs of a row + */ private getItemInputs(item: HTMLElement): { key?: HTMLInputLike; value: HTMLInputLike } { const cached = this.itemCache.get(item); if (cached) { @@ -92,6 +114,9 @@ export class ArrayInput { return entry; } + /** + * Add an empty row after a row + */ private addItem(item: HTMLElement) { const { value: valueInput } = this.getItemInputs(item); const newItem = item.cloneNode(true) as HTMLElement; @@ -121,6 +146,9 @@ export class ArrayInput { this.focusItem(newItem); } + /** + * Remove a row, or clear it if it is the only one + */ private removeItem(item: HTMLElement) { const parent = item.parentNode as ParentNode; if ($$(".form-input-array-row", parent).length > 1) { @@ -131,6 +159,9 @@ export class ArrayInput { } } + /** + * Clear a row + */ private clearItem(item: HTMLElement) { const { key: keyInput, value: valueInput } = this.getItemInputs(item); @@ -143,6 +174,9 @@ export class ArrayInput { } } + /** + * Bind the buttons of a row and keep the name of its value input in sync with the key + */ private bindItemEvents(item: HTMLElement) { const inputAdd = $(".form-input-array-add", item) as HTMLButtonElement; const inputRemove = $(".form-input-array-remove", item) as HTMLButtonElement; @@ -157,12 +191,18 @@ export class ArrayInput { } } + /** + * Focus the key input of a row, or the value input if the array is not associative + */ private focusItem(item: HTMLElement) { const { key: keyInput, value: valueInput } = this.getItemInputs(item); const inputToFocus = this.isAssociative && keyInput ? keyInput : valueInput; inputToFocus.focus(); } + /** + * Name the value input of a row after its key + */ private onKeyInput(keyInput: HTMLInputLike, valueInput: HTMLInputLike) { const newName = `${this.name}[${keyInput.value.trim()}]`; @@ -174,10 +214,16 @@ export class ArrayInput { this.onValueInput(keyInput, valueInput); } + /** + * Require the key of a row if it has a value + */ private onValueInput(keyInput: HTMLInputLike, valueInput: HTMLInputLike) { keyInput.required = valueInput.value.trim() !== ""; } + /** + * Change the name of the form input wrapping an element + */ private updateFormInputName(element: HTMLInputLike, newName: string) { for (const input of this.form.formInputs) { if (input.element === element) { @@ -187,6 +233,9 @@ export class ArrayInput { } } + /** + * Change the value of the form input wrapping an element + */ private updateFormInputValue(element: HTMLInputLike, newValue: string) { for (const input of this.form.formInputs) { if (input.element === element) { diff --git a/panel/src/ts/components/inputs/calendar.ts b/panel/src/ts/components/inputs/calendar.ts new file mode 100644 index 000000000..9dd854a23 --- /dev/null +++ b/panel/src/ts/components/inputs/calendar.ts @@ -0,0 +1,383 @@ +import { effect } from "@preact/signals-core"; +import { CalendarModel, type CalendarState, daysInMonth } from "../../models/calendar"; +import { getOuterHeight, getOuterWidth } from "../../utils/dimensions"; +import { longClick, throttle } from "../../utils/events"; +import { mod } from "../../utils/math"; +import { $, $$ } from "../../utils/selectors"; +import { calendarClock, chevronDown, chevronLeft, chevronRight, chevronUp } from "../ui/icons"; +import type { DateInput } from "./date-input"; + +/** + * Represents the calendar of a date input + */ +export class Calendar { + /** + * Date input of the calendar + */ + private readonly input: DateInput; + + /** + * Element of the calendar + */ + readonly element: HTMLElement; + + /** + * Selected date and visibility of the calendar + */ + private readonly model = new CalendarModel(); + + /** + * Create the calendar of a date input + */ + constructor(input: DateInput) { + this.input = input; + this.element = this.createElement(); + + this.bindButtons(); + this.bindWindowEvents(); + + document.body.appendChild(this.element); + + effect(() => this.render(this.model.state.value)); + + effect(() => { + if (this.model.shown.value) { + this.element.style.display = "block"; + this.setCalendarPosition(); + } else if (this.element.style.display === "block") { + this.element.style.display = "none"; + } + }); + } + + /** + * Get the selected date + */ + get date() { + return this.model.toDate(); + } + + /** + * Set the selected date + */ + set date(date: Date) { + this.model.setDate(date); + } + + /** + * Return whether the calendar is visible + */ + get visible() { + return this.model.shown.value; + } + + /** + * Select the current date and time + */ + now() { + this.model.now(); + } + + /** + * Show the calendar + */ + show() { + this.model.show(); + } + + /** + * Hide the calendar + */ + hide() { + this.model.hide(); + } + + /** + * Create the element of the calendar, with the buttons to change month and, if the input has + * the time, hours and minutes + */ + private createElement() { + const { labels, time } = this.input.options; + + const element = document.createElement("div"); + element.className = "calendar"; + element.dataset.for = this.input.element.id; + element.innerHTML = `
`; + + if (time) { + element.innerHTML += `
:
`; + + ($(".prevHour", element) as HTMLElement).innerHTML = chevronDown; + ($(".nextHour", element) as HTMLElement).innerHTML = chevronUp; + + ($(".prevMinute", element) as HTMLElement).innerHTML = chevronDown; + ($(".nextMinute", element) as HTMLElement).innerHTML = chevronUp; + } + + ($(".currentMonth", element) as HTMLElement).insertAdjacentHTML("afterbegin", calendarClock); + + ($(".prevMonth", element) as HTMLElement).innerHTML = chevronLeft; + ($(".nextMonth", element) as HTMLElement).innerHTML = chevronRight; + + return element; + } + + /** + * Bind the buttons of the calendar: a long press repeats the change + */ + private bindButtons() { + ($(".currentMonth", this.element) as HTMLElement).addEventListener("click", (event) => { + this.now(); + this.dispatchChange(); + event.preventDefault(); + }); + + this.bindLongClick(".prevMonth", () => this.model.prevMonth(), 500); + this.bindLongClick(".nextMonth", () => this.model.nextMonth(), 500); + + if (this.input.options.time) { + this.bindLongClick(".nextHour", () => this.model.nextHour(), 250); + this.bindLongClick(".prevHour", () => this.model.prevHour(), 250); + this.bindLongClick(".nextMinute", () => this.model.nextMinute(), 250); + this.bindLongClick(".prevMinute", () => this.model.prevMinute(), 250); + } + } + + /** + * Change the date when a button is pressed, and repeat the change while it stays pressed + * + * @param selector Selector of the button + * @param change Change of the date + * @param interval Interval in milliseconds between repeated changes + */ + private bindLongClick(selector: string, change: () => void, interval: number) { + longClick( + $(selector, this.element) as HTMLElement, + (event) => { + change(); + this.dispatchChange(); + event.preventDefault(); + }, + 750, + interval, + ); + } + + /** + * Keep the calendar in place and let the user change the date with the keyboard + */ + private bindWindowEvents() { + window.addEventListener( + "resize", + throttle(() => this.setCalendarPosition(), 100), + ); + + window.addEventListener("mousedown", (event) => { + if (this.element.style.display !== "none") { + if ((event.target as HTMLElement).closest(".calendar")) { + event.preventDefault(); + } + } + }); + + window.addEventListener("keydown", (event) => this.handleKeydown(event)); + } + + /** + * Change the date with the arrows, pick it with Enter and close the calendar with Backspace, + * Escape or Tab + */ + private handleKeydown(event: KeyboardEvent) { + if (!this.visible) { + return; + } + + const navigate = this.getNavigation(event); + + if (navigate) { + navigate(); + this.dispatchChange(); + event.preventDefault(); + return; + } + + switch (event.key) { + case "Enter": + ($(".calendar-day.selected", this.element) as HTMLElement).click(); + this.hide(); + break; + + case "Backspace": + case "Escape": + case "Tab": + this.hide(); + break; + + case "0": + if (event.ctrlKey || event.metaKey) { + this.now(); + } + this.dispatchChange(); + break; + + default: + return; + } + + event.preventDefault(); + } + + /** + * Get the change of the date for an arrow key + * + * Left and right move by a day, or by a month (by a year with Shift) with Ctrl or Cmd; up and + * down move by a week + */ + private getNavigation(event: KeyboardEvent) { + const byMonth = event.ctrlKey || event.metaKey; + + switch (event.key) { + case "ArrowLeft": + if (byMonth) { + return event.shiftKey ? () => this.model.prevYear() : () => this.model.prevMonth(); + } + return () => this.model.prevDay(); + + case "ArrowRight": + if (byMonth) { + return event.shiftKey ? () => this.model.nextYear() : () => this.model.nextMonth(); + } + return () => this.model.nextDay(); + + case "ArrowUp": + return () => this.model.prevWeek(); + + case "ArrowDown": + return () => this.model.nextWeek(); + } + } + + /** + * Notify the input that the date has changed + */ + private dispatchChange() { + this.input.options.onChange(this.date, this.input); + } + + /** + * Render the days of the month, and the time if the input has one + */ + private render(state: CalendarState) { + ($(".calendar-table", this.element) as HTMLElement).innerHTML = this.getInnerHTML(state); + + if (this.input.options.time) { + const has12HourFormat = this.has12HourFormat(this.input.format); + const hours = has12HourFormat ? mod(state.hours, 12) || 12 : state.hours; + + ($(".calendar-hours", this.element) as HTMLElement).textContent = `${hours}`.padStart(2, "0"); + ($(".calendar-minutes", this.element) as HTMLElement).textContent = `${state.minutes}`.padStart(2, "0"); + ($(".calendar-meridiem", this.element) as HTMLElement).textContent = has12HourFormat ? (state.hours < 12 ? "AM" : "PM") : ""; + } + + $$(".calendar-day", this.element).forEach((element) => { + element.addEventListener("mousedown", (event) => { + event.stopPropagation(); + event.preventDefault(); + }); + element.addEventListener("click", () => { + this.model.setDay(parseInt(`${element.textContent}`, 10)); + this.dispatchChange(); + }); + }); + } + + /** + * Position the calendar below the input, inside the viewport + */ + private setCalendarPosition() { + if (!this.input?.element || !this.visible) { + return; + } + + const inputRect = this.input.element.getBoundingClientRect(); + const inputTop = inputRect.top + window.scrollY; + const inputLeft = inputRect.left + window.scrollX; + + this.element.style.top = `${inputTop + this.input.element.offsetHeight}px`; + this.element.style.left = `${inputLeft + this.input.element.offsetLeft}px`; + + const calendarRect = this.element.getBoundingClientRect(); + const calendarTop = calendarRect.top + window.scrollY; + const calendarLeft = calendarRect.left + window.scrollX; + const calendarWidth = getOuterWidth(this.element); + const calendarHeight = getOuterHeight(this.element); + + const windowWidth = document.documentElement.clientWidth; + const windowHeight = document.documentElement.clientHeight; + + if (calendarLeft + calendarWidth > windowWidth) { + this.element.style.left = `${windowWidth - calendarWidth}px`; + } + + if (calendarTop < window.scrollY || window.scrollY < calendarTop + calendarHeight - windowHeight) { + window.scrollTo(window.scrollX, calendarTop + calendarHeight - windowHeight); + } + } + + /** + * Get the table of the days of a month + */ + private getInnerHTML({ year, month, day }: CalendarState) { + const firstDay = new Date(year, month, 1).getDay(); + const start = mod(firstDay - this.input.options.weekStarts, 7); + const monthLength = daysInMonth(month, year); + + let num = 1; + let html = ""; + + html += ''; + html += `${this.input.options.labels.months.long[month]} ${year}`; + html += ""; + html += ""; + + for (let i = 0; i < 7; i++) { + html += ''; + html += this.input.options.labels.weekdays.short[mod(i + this.input.options.weekStarts, 7)]; + html += ""; + } + + html += ""; + + for (let i = 0; i < 6; i++) { + for (let j = 0; j < 7; j++) { + if (num <= monthLength && (i > 0 || j >= start)) { + if (num === day) { + html += ''; + } else { + html += ''; + } + html += num++; + } else if (num === 1) { + html += ''; + html += daysInMonth(mod(month - 1, 12), year) - start + j + 1; + } else { + html += ''; + html += num++ - monthLength; + } + html += ""; + } + html += ""; + } + html += ""; + + return html; + } + + /** + * Return whether a format uses the 12-hour clock + */ + private has12HourFormat(format: string) { + const match = format.match(/\[([^\]]*)\]|H{1,2}/); + return match !== null && match[0][0] === "H"; + } +} diff --git a/panel/src/ts/components/inputs/color-input.ts b/panel/src/ts/components/inputs/color-input.ts index 50727bb21..15c831135 100644 --- a/panel/src/ts/components/inputs/color-input.ts +++ b/panel/src/ts/components/inputs/color-input.ts @@ -1,36 +1,25 @@ import { $ } from "../../utils/selectors"; +import { Input } from "./input"; -export class ColorInput { - readonly element: HTMLInputElement; - +/** + * Provides a color input that shows its value in the linked `output` element + */ +export class ColorInput extends Input { constructor(element: HTMLInputElement) { - this.element = element; + super(element); this.initInput(); } - get name() { - return this.element.name; - } - - set name(value: string) { - this.element.name = value; - } - - get value() { - return this.element.value; - } - - set value(value: string) { - this.element.value = value; - } - + /** + * Show the value in the output and open the picker when the output is clicked + */ private initInput() { const outputElement = $(`output[for="${this.element.id}"]`); if (outputElement) { const updateValueLabel = (element: HTMLInputElement) => { - outputElement.innerText = element.value; + outputElement.textContent = element.value; }; this.element.addEventListener("change", () => updateValueLabel(this.element)); diff --git a/panel/src/ts/components/inputs/date-input.ts b/panel/src/ts/components/inputs/date-input.ts index c9392f825..0b0874d28 100644 --- a/panel/src/ts/components/inputs/date-input.ts +++ b/panel/src/ts/components/inputs/date-input.ts @@ -1,11 +1,12 @@ -import { app } from "../../app"; -import { getOuterHeight, getOuterWidth } from "../../utils/dimensions"; -import { longClick, throttle } from "../../utils/events"; -import { mod } from "../../utils/math"; -import { $, $$ } from "../../utils/selectors"; -import { calendarClock, chevronDown, chevronLeft, chevronRight, chevronUp } from "../icons"; - -interface DateInputOptions { +import { app } from "../../services/app"; +import { formatDateTime } from "../../utils/date"; +import { Calendar } from "./calendar"; +import { InputBase } from "./input"; + +/** + * Holds the options of a date input + */ +export interface DateInputOptions { weekStarts: number; dateFormat: string; dateTimeFormat: string; @@ -30,15 +31,28 @@ interface DateInputOptions { onChange: (date: Date, input: DateInput) => void; } -export class DateInput { - readonly element: HTMLInputElement; - +/** + * Provides a date input with a calendar to pick the date and, optionally, the time + */ +export class DateInput extends InputBase { + /** + * Options of the input + */ readonly options: DateInputOptions; + /** + * Format of the date + */ readonly format: string; + /** + * Calendar of the input + */ private calendar: Calendar; + /** + * Create the date input + */ constructor(element: HTMLInputElement, options: Partial = {}) { const defaults: DateInputOptions = { weekStarts: 0, @@ -64,12 +78,11 @@ export class DateInput { }, onChange(date: Date, input: DateInput) { input.element.value = input.formatDateTime(date, input.format); - input.element.dispatchEvent(new Event("input", { bubbles: true })); - input.element.dispatchEvent(new Event("change", { bubbles: true })); + input.dispatchChange(); }, }; - this.element = element; + super(element); this.options = { ...defaults, ...options, time: this.element.dataset.time === "true" }; @@ -80,18 +93,20 @@ export class DateInput { this.initInput(); } - get name() { - return this.element.name; - } - - set name(value: string) { - this.element.name = value; - } - + /** + * Get the date as `YYYY-MM-DDThh:mm:ss`, or an empty string if there is no date + */ get value() { - return this.formatDateTime(this.calendar.date, "YYYY-MM-DD[T]HH:mm:ss").replace(":00$", ""); + if (!this.element.value) { + return ""; + } + + return this.formatDateTime(this.calendar.date, "YYYY-MM-DD[T]hh:mm:ss"); } + /** + * Set the date from a string, clearing the input if it is not a valid date + */ set value(value: string) { if (this.isValidDate(value)) { this.calendar.date = new Date(value); @@ -102,6 +117,12 @@ export class DateInput { } } + /** + * Show the calendar when the input is focused or clicked and handle the keyboard + * + * Backspace clears the date and Escape hides the calendar, keeping the focus; the calendar is + * shown again with the arrows, Enter or Space + */ private initInput() { this.element.readOnly = true; this.element.size = this.format.length; @@ -116,586 +137,47 @@ export class DateInput { this.calendar.hide(); }); + this.element.addEventListener("click", () => { + this.calendar.show(); + }); + this.element.addEventListener("keydown", (event: KeyboardEvent) => { switch (event.key) { case "Backspace": this.element.value = ""; - this.element.blur(); - this.element.dispatchEvent(new Event("input", { bubbles: true })); - this.element.dispatchEvent(new Event("change", { bubbles: true })); + this.calendar.hide(); + this.dispatchChange(); break; case "Escape": - this.element.blur(); + this.calendar.hide(); break; case "Tab": - this.element.blur(); return; - } - event.preventDefault(); - }); - } - - private isValidDate(date: string) { - return date && !Number.isNaN(Date.parse(date)); - } - - private formatDateTime(date: Date, format: string) { - const regex = /\[([^\]]*)\]|[YR]{4}|uuu|[YR]{2}|[MD]{1,4}|[WHhms]{1,2}|[AaZz]/g; - - const weekStart = (date: Date, firstDay: number = this.options.weekStarts) => { - let day = date.getDate(); - day -= mod(date.getDay() - firstDay, 7); - return new Date(date.getFullYear(), date.getMonth(), day); - }; - - const weekNumberingYear = (date: Date) => { - const year = date.getFullYear(); - const thisYearFirstWeekStart = weekStart(new Date(year, 0, 4), 1); - const nextYearFirstWeekStart = weekStart(new Date(year + 1, 0, 4), 1); - if (date.getTime() >= nextYearFirstWeekStart.getTime()) { - return year + 1; - } else if (date.getTime() >= thisYearFirstWeekStart.getTime()) { - return year; - } - return year - 1; - }; - - const weekOfYear = (date: Date) => { - const dateWeekNumberingYear = weekNumberingYear(date); - const dateFirstWeekStart = weekStart(new Date(dateWeekNumberingYear, 0, 4), 1); - const dateWeekStart = weekStart(date, 1); - return Math.round((dateWeekStart.getTime() - dateFirstWeekStart.getTime()) / 604800000) + 1; - }; - - const splitTimezoneOffset = (offset: number) => { - // Note that the offset returned by Date.getTimezoneOffset() - // is positive if behind UTC and negative if ahead UTC - const sign = offset > 0 ? "-" : "+"; - const hours = Math.floor(Math.abs(offset) / 60); - const minutes = Math.abs(offset) % 60; - return [sign + `${hours}`.padStart(2, "0"), `${minutes}`.padStart(2, "0")]; - }; - - return format.replace(regex, (match: string, $1) => { - switch (match) { - case "YY": - return `${date.getFullYear()}`.substr(-2); - case "YYYY": - return date.getFullYear(); - case "M": - return date.getMonth() + 1; - case "MM": - return `${date.getMonth() + 1}`.padStart(2, "0"); - case "MMM": - return this.options.labels.months.short[date.getMonth()]; - case "MMMM": - return this.options.labels.months.long[date.getMonth()]; - case "D": - return date.getDate(); - case "DD": - return `${date.getDate()}`.padStart(2, "0"); - case "DDD": - return this.options.labels.weekdays.short[mod(date.getDay() + this.options.weekStarts, 7)]; - case "DDDD": - return this.options.labels.weekdays.long[mod(date.getDay() + this.options.weekStarts, 7)]; - case "W": - return weekOfYear(date); - case "WW": - return `${weekOfYear(date)}`.padStart(2, "0"); - case "RR": - return weekNumberingYear(date).toString().substr(-2); - case "RRRR": - return weekNumberingYear(date); - case "H": - return mod(date.getHours(), 12) || 12; - case "HH": - return `${mod(date.getHours(), 12) || 12}`.padStart(2, "0"); - case "h": - return date.getHours(); - case "hh": - return `${date.getHours()}`.padStart(2, "0"); - case "m": - return date.getMinutes(); - case "mm": - return `${date.getMinutes()}`.padStart(2, "0"); - case "s": - return date.getSeconds(); - case "ss": - return `${date.getSeconds()}`.padStart(2, "0"); - case "uuu": - return `${date.getMilliseconds()}`.padStart(3, "0"); - case "A": - return date.getHours() < 12 ? "AM" : "PM"; - case "a": - return date.getHours() < 12 ? "am" : "pm"; - case "Z": - return splitTimezoneOffset(date.getTimezoneOffset()).join(":"); - case "z": - return splitTimezoneOffset(date.getTimezoneOffset()).join(""); - default: - return $1 || match; - } - }); - } -} - -class Calendar { - private readonly input: DateInput; - - readonly element: HTMLElement; - - private year!: number; - private month!: number; - private day!: number; - private hours!: number; - private minutes!: number; - private seconds!: number; - - constructor(input: DateInput) { - this.input = input; - this.element = this.createElement(); - this.date = new Date(); - } - - get date() { - return new Date(this.year, this.month, this.day, this.hours, this.minutes, this.seconds); - } - - set date(date: Date) { - this.year = date.getFullYear(); - this.month = date.getMonth(); - this.day = date.getDate(); - this.hours = date.getHours(); - this.minutes = date.getMinutes(); - this.seconds = date.getSeconds(); - this.update(); - } - - get visible() { - return getComputedStyle(this.element).display !== "none"; - } - - now() { - this.date = new Date(); - this.update(); - } - - prevYear() { - this.year--; - this.update(); - } - - nextYear() { - this.year++; - this.update(); - } - - lastDayOfMonth() { - this.day = this.daysInMonth(this.month, this.year); - this.update(); - } - - prevMonth() { - this.month = mod(this.month - 1, 12); - if (this.month === 11) { - this.prevYear(); - } - if (this.day > this.daysInMonth(this.month, this.year)) { - this.lastDayOfMonth(); - } - this.update(); - } - - nextMonth() { - this.month = mod(this.month + 1, 12); - if (this.month === 0) { - this.nextYear(); - } - if (this.day > this.daysInMonth(this.month, this.year)) { - this.lastDayOfMonth(); - } - this.update(); - } - - prevWeek() { - this.day -= 7; - if (this.day < 1) { - this.prevMonth(); - this.day += this.daysInMonth(this.month, this.year); - } - this.update(); - } - - nextWeek() { - this.day += 7; - if (this.day > this.daysInMonth(this.month, this.year)) { - this.day -= this.daysInMonth(this.month, this.year); - this.nextMonth(); - } - this.update(); - } - - prevDay() { - this.day--; - if (this.day < 1) { - this.prevMonth(); - this.lastDayOfMonth(); - } - this.update(); - } - - nextDay() { - this.day++; - if (this.day > this.daysInMonth(this.month, this.year)) { - this.nextMonth(); - this.day = 1; - } - this.update(); - } - - nextHour() { - this.hours = mod(this.hours + 1, 24); - if (this.hours === 0) { - this.nextDay(); - } - this.update(); - } - - prevHour() { - this.hours = mod(this.hours - 1, 24); - if (this.hours === 23) { - this.prevDay(); - } - this.update(); - } - - nextMinute() { - this.minutes = mod(this.minutes + 1, 60); - if (this.minutes === 0) { - this.nextHour(); - } - this.update(); - } - - prevMinute() { - this.minutes = mod(this.minutes - 1, 60); - if (this.minutes === 59) { - this.prevHour(); - } - this.update(); - } - - nextSecond() { - this.seconds = mod(this.seconds + 1, 60); - if (this.seconds === 0) { - this.nextMinute(); - } - this.update(); - } - - prevSecond() { - this.seconds = mod(this.seconds - 1, 60); - if (this.seconds === 59) { - this.prevMinute(); - } - this.update(); - } - - show() { - this.update(); - this.element.style.display = "block"; - this.setCalendarPosition(); - } - - hide() { - this.element.style.display = "none"; - } - - private createElement() { - const element = document.createElement("div"); - element.className = "calendar"; - element.dataset.for = this.input.element.id; - element.innerHTML = `
`; - - if (this.input.options.time) { - element.innerHTML += `
:
`; - - ($(".prevHour", element) as HTMLElement).innerHTML = chevronDown; - ($(".nextHour", element) as HTMLElement).innerHTML = chevronUp; - - ($(".prevMinute", element) as HTMLElement).innerHTML = chevronDown; - ($(".nextMinute", element) as HTMLElement).innerHTML = chevronUp; - } - - ($(".currentMonth", element) as HTMLElement).insertAdjacentHTML("afterbegin", calendarClock); - - ($(".prevMonth", element) as HTMLElement).innerHTML = chevronLeft; - ($(".nextMonth", element) as HTMLElement).innerHTML = chevronRight; - - ($(".currentMonth", element) as HTMLElement).addEventListener("click", (event) => { - this.now(); - this.dispatchChange(); - event.preventDefault(); - }); - - longClick( - $(".prevMonth", element) as HTMLElement, - (event) => { - this.prevMonth(); - this.dispatchChange(); - event.preventDefault(); - }, - 750, - 500, - ); - - longClick( - $(".nextMonth", element) as HTMLElement, - (event) => { - this.nextMonth(); - this.dispatchChange(); - event.preventDefault(); - }, - 750, - 500, - ); - - if (this.input.options.time) { - longClick( - $(".nextHour", element) as HTMLElement, - (event) => { - this.nextHour(); - this.dispatchChange(); - event.preventDefault(); - }, - 750, - 250, - ); - - longClick( - $(".prevHour", element) as HTMLElement, - (event) => { - this.prevHour(); - this.dispatchChange(); - event.preventDefault(); - }, - 750, - 250, - ); - - longClick( - $(".nextMinute", element) as HTMLElement, - (event) => { - this.nextMinute(); - this.dispatchChange(); - event.preventDefault(); - }, - 750, - 250, - ); - - longClick( - $(".prevMinute", element) as HTMLElement, - (event) => { - this.prevMinute(); - this.dispatchChange(); - event.preventDefault(); - }, - 750, - 250, - ); - } - - window.addEventListener( - "resize", - throttle(() => this.setCalendarPosition(), 100), - ); - - window.addEventListener("mousedown", (event) => { - if (element.style.display !== "none") { - if ((event.target as HTMLElement).closest(".calendar")) { - event.preventDefault(); - } - } - }); - - window.addEventListener("keydown", (event) => { - if (!this.visible) { - return; - } - switch (event.key) { - case "Enter": - ($(".calendar-day.selected", element) as HTMLElement).click(); - this.hide(); - break; - case "Backspace": - case "Escape": - case "Tab": - this.hide(); - break; - case "ArrowLeft": - if (event.ctrlKey || event.metaKey) { - if (event.shiftKey) { - this.prevYear(); - } else { - this.prevMonth(); - } - } else { - this.prevDay(); - } - this.dispatchChange(); - break; - case "ArrowUp": - this.prevWeek(); - this.dispatchChange(); - break; - case "ArrowRight": - if (event.ctrlKey || event.metaKey) { - if (event.shiftKey) { - this.nextYear(); - } else { - this.nextMonth(); - } - } else { - this.nextDay(); - } - this.dispatchChange(); - break; case "ArrowDown": - this.nextWeek(); - this.dispatchChange(); - break; - case "0": - if (event.ctrlKey || event.metaKey) { - this.now(); + case "ArrowUp": + case "Enter": + case " ": + // The key only shows a calendar that is hidden, it does not change the date as well + if (!this.calendar.visible) { + this.calendar.show(); + event.stopPropagation(); } - this.dispatchChange(); break; - default: - return; } - event.preventDefault(); }); - - document.body.appendChild(element); - - return element; - } - - private dispatchChange() { - this.input.options.onChange(this.date, this.input); - } - - private update() { - ($(".calendar-table", this.element) as HTMLElement).innerHTML = this.getInnerHTML(); - - if (this.input.options.time) { - ($(".calendar-hours", this.element) as HTMLElement).innerText = `${this.has12HourFormat(this.input.format) ? mod(this.hours, 12) || 12 : this.hours}`.padStart(2, "0"); - ($(".calendar-minutes", this.element) as HTMLElement).innerText = `${this.minutes}`.padStart(2, "0"); - ($(".calendar-meridiem", this.element) as HTMLElement).innerText = this.has12HourFormat(this.input.format) ? (this.hours < 12 ? "AM" : "PM") : ""; - } - - $$(".calendar-day", this.element).forEach((element) => { - element.addEventListener("mousedown", (event) => { - event.stopPropagation(); - event.preventDefault(); - }); - element.addEventListener("click", () => { - this.day = parseInt(`${element.textContent}`, 10); - this.update(); - this.dispatchChange(); - }); - }); - } - - private setCalendarPosition() { - if (!this.input?.element || !this.visible) { - return; - } - - const inputRect = this.input.element.getBoundingClientRect(); - const inputTop = inputRect.top + window.scrollY; - const inputLeft = inputRect.left + window.scrollX; - - this.element.style.top = `${inputTop + this.input.element.offsetHeight}px`; - this.element.style.left = `${inputLeft + this.input.element.offsetLeft}px`; - - const calendarRect = this.element.getBoundingClientRect(); - const calendarTop = calendarRect.top + window.scrollY; - const calendarLeft = calendarRect.left + window.scrollX; - const calendarWidth = getOuterWidth(this.element); - const calendarHeight = getOuterHeight(this.element); - - const windowWidth = document.documentElement.clientWidth; - const windowHeight = document.documentElement.clientHeight; - - if (calendarLeft + calendarWidth > windowWidth) { - this.element.style.left = `${windowWidth - calendarWidth}px`; - } - - if (calendarTop < window.scrollY || window.scrollY < calendarTop + calendarHeight - windowHeight) { - window.scrollTo(window.scrollX, calendarTop + calendarHeight - windowHeight); - } - } - - private getInnerHTML() { - const firstDay = new Date(this.year, this.month, 1).getDay(); - const start = mod(firstDay - this.input.options.weekStarts, 7); - const monthLength = this.daysInMonth(this.month, this.year); - - let num = 1; - let html = ""; - - html += ''; - html += `${this.input.options.labels.months.long[this.month]} ${this.year}`; - html += ""; - html += ""; - - for (let i = 0; i < 7; i++) { - html += ''; - html += this.input.options.labels.weekdays.short[mod(i + this.input.options.weekStarts, 7)]; - html += ""; - } - - html += ""; - - for (let i = 0; i < 6; i++) { - for (let j = 0; j < 7; j++) { - if (num <= monthLength && (i > 0 || j >= start)) { - if (num === this.day) { - html += ''; - } else { - html += ''; - } - html += num++; - } else if (num === 1) { - html += ''; - html += this.daysInMonth(mod(this.month - 1, 12), this.year) - start + j + 1; - } else { - html += ''; - html += num++ - monthLength; - } - html += ""; - } - html += ""; - } - html += ""; - - return html; } - private has12HourFormat(format: string) { - const match = format.match(/\[([^\]]*)\]|H{1,2}/); - return match !== null && match[0][0] === "H"; - } - - private isLeapYear(year: number) { - return (year % 4 === 0 && year % 100 !== 0) || year % 400 === 0; + /** + * Return whether a string is a valid date + */ + private isValidDate(date: string) { + return date && !Number.isNaN(Date.parse(date)); } - - private daysInMonth(month: number, year: number) { - const daysInMonth = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]; - return month === 1 && this.isLeapYear(year) ? 29 : daysInMonth[month]; + /** + * Format a date with the options of the input + */ + private formatDateTime(date: Date, format: string) { + return formatDateTime(date, format, this.options); } } diff --git a/panel/src/ts/components/inputs/dropdown-list.ts b/panel/src/ts/components/inputs/dropdown-list.ts new file mode 100644 index 000000000..c46f622c6 --- /dev/null +++ b/panel/src/ts/components/inputs/dropdown-list.ts @@ -0,0 +1,197 @@ +import { effect, type Signal, signal } from "@preact/signals-core"; +import { $, $$ } from "../../utils/selectors"; + +const isDisplayed = (element: HTMLElement) => getComputedStyle(element).display !== "none"; + +/** + * Represents the list of options of the select and tags inputs + * + * Items are hidden by the filter and skipped by the selection if they are hidden or disabled + */ +export class DropdownList { + readonly element: HTMLElement; + + /** + * Selected item + */ + readonly selected = signal(null); + + /** + * Whether the list is open, or `null` if it has not been opened or closed yet + */ + readonly open: Signal; + + constructor(element: HTMLElement, open: boolean | null = null) { + this.element = element; + + this.open = signal(open); + + effect(() => { + if (this.open.value !== null) { + this.element.style.display = this.open.value ? "block" : "none"; + } + }); + + effect(() => { + const selected = this.selected.value; + + for (const item of this.items) { + item.classList.toggle("selected", item === selected); + } + }); + } + + /** + * Get the items of the list + */ + get items() { + return Array.from($$(".dropdown-item", this.element)); + } + + /** + * Get the item with a value + * + * @returns Item, or `null` if there is none + */ + find(value: string) { + return $(`.dropdown-item[data-value="${CSS.escape(value)}"]`, this.element); + } + + /** + * Select an item and scroll the list to show it + * + * A disabled item cannot be selected, so the selection is cleared + * + * @param item Item to select, or `null` to clear the selection + */ + select(item: HTMLElement | null) { + const isSelectable = item !== null && !item.classList.contains("disabled"); + + this.selected.value = isSelectable ? item : null; + + if (isSelectable) { + this.scrollTo(item); + } + } + + /** + * Select the first selectable item + */ + selectFirst() { + const item = this.items.find((element) => this.isSelectable(element)); + + if (item) { + this.select(item); + } + } + + /** + * Select the last selectable item + */ + selectLast() { + const item = this.items.reverse().find((element) => this.isSelectable(element)); + + if (item) { + this.select(item); + } + } + + /** + * Select the previous item, or the last one if there is none + */ + selectPrevious() { + const items = this.items; + const index = this.selected.value ? items.indexOf(this.selected.value) : -1; + const previous = items + .slice(0, Math.max(index, 0)) + .reverse() + .find((item) => this.isSelectable(item)); + + if (previous) { + this.select(previous); + } else { + this.selectLast(); + } + } + + /** + * Select the next item, or the first one if there is none + */ + selectNext() { + const items = this.items; + const index = this.selected.value ? items.indexOf(this.selected.value) : -1; + const next = index === -1 ? undefined : items.slice(index + 1).find((item) => this.isSelectable(item)); + + if (next) { + this.select(next); + } else { + this.selectFirst(); + } + } + + /** + * Show only the items matching a condition + * + * @param isVisible Function returning whether an item is shown + * + * @returns Number of items shown + */ + filter(isVisible: (item: HTMLElement) => boolean) { + let visibleItems = 0; + + for (const item of this.items) { + const visible = isVisible(item); + item.style.display = visible ? "block" : "none"; + if (visible) { + visibleItems++; + } + } + + return visibleItems; + } + + /** + * Return the number of displayed items + */ + countDisplayed() { + return this.items.filter(isDisplayed).length; + } + + /** + * Sort the items by value + */ + sort() { + const sorted = this.items.sort((a, b) => (a.dataset.value as string).localeCompare(b.dataset.value as string)); + + for (const item of sorted) { + this.element.appendChild(item); + } + } + + /** + * Scroll the list to show an item + */ + scrollTo(item: HTMLElement) { + const scrollTop = this.element.scrollTop; + const height = this.element.clientHeight; + const scrollBottom = scrollTop + height; + const style = getComputedStyle(this.element); + const paddingTop = parseInt(style.paddingTop, 10); + const paddingBottom = parseInt(style.paddingBottom, 10); + const itemTop = item.offsetTop; + const itemBottom = itemTop + item.clientHeight; + + if (itemTop < scrollTop) { + this.element.scrollTop = itemTop - paddingTop; + } else if (itemBottom > scrollBottom) { + this.element.scrollTop = itemBottom - height + paddingBottom; + } + } + + /** + * Return whether an item is displayed and not disabled + */ + private isSelectable(item: HTMLElement) { + return isDisplayed(item) && !item.classList.contains("disabled"); + } +} diff --git a/panel/src/ts/components/inputs/duration-input.ts b/panel/src/ts/components/inputs/duration-input.ts index 04f7cc1c9..48f358213 100644 --- a/panel/src/ts/components/inputs/duration-input.ts +++ b/panel/src/ts/components/inputs/duration-input.ts @@ -1,37 +1,72 @@ -import { app } from "../../app"; -import { getSafeInteger } from "../../utils/numbers"; +import { effect, type Signal, signal } from "@preact/signals-core"; +import { app } from "../../services/app"; +import { intervalsToSeconds, secondsToIntervals, TIME_INTERVALS, type TimeInterval, type TimeIntervals } from "../../utils/duration"; import { $ } from "../../utils/selectors"; +import { Input } from "./input"; -const TIME_INTERVALS = { - years: 60 * 60 * 24 * 365, - months: 60 * 60 * 24 * 30, - weeks: 60 * 60 * 24 * 7, - days: 60 * 60 * 24, - hours: 60 * 60, - minutes: 60, - seconds: 1, -}; - -type TimeInterval = keyof typeof TIME_INTERVALS; +/** + * Represents the singular and plural name of a time interval + */ type TimeIntervalLabel = [singular: string, plural: string]; -interface DurationInputOptions { +/** + * Remove the leading zeros of a number and the digits that make it larger than the largest safe + * integer + */ +function cleanNumber(text: string) { + let cleaned = text; + + while (cleaned.charAt(0) === "0" && cleaned.length > 1 && !cleaned.charAt(1).match(/[.,]/)) { + cleaned = cleaned.slice(1); + } + + while (parseInt(cleaned, 10) > Number.MAX_SAFE_INTEGER) { + cleaned = cleaned.slice(0, -1); + } + + return cleaned; +} + +/** + * Holds the options of a duration input + */ +export interface DurationInputOptions { unit: TimeInterval; intervals: TimeInterval[]; labels: Record; } -export class DurationInput { - readonly element: HTMLInputElement; - +/** + * Provides a duration input with a field for each time interval + * + * The native input is hidden and holds the total, constrained by its `step`, `min` and `max` + */ +export class DurationInput extends Input { + /** + * Options of the input + */ readonly options: DurationInputOptions; + /** + * Element wrapping the fields + */ private field!: HTMLElement; + /** + * Field of each interval + */ private innerInputs: Partial> = {}; - private labels: Partial> = {}; - constructor(element: HTMLInputElement, options: Partial) { + // Text of the inner inputs, the width of the inputs and their labels are derived from it + /** + * Text of each field, used for its width and label + */ + private texts: Partial>> = {}; + + /** + * Create the duration input + */ + constructor(element: HTMLInputElement, options: Partial = {}) { const defaults: DurationInputOptions = { unit: "seconds", intervals: ["years", "months", "weeks", "days", "hours", "minutes", "seconds"], @@ -46,58 +81,35 @@ export class DurationInput { }, }; - this.element = element; + super(element); this.options = { ...defaults, ...options }; this.createField(); } - get name() { - return this.element.name; - } - - set name(value: string) { - this.element.name = value; - } - + // The getter is defined again because a class defining only the setter would lose it get value() { return this.element.value; } + /** + * Set the total and show it in the fields + */ set value(value: string) { this.element.value = value; this.updateInnerInputs(); - this.updateInnerInputsLength(); - this.updateLabels(); - } - - private secondsToIntervals(seconds: number, intervalNames: TimeInterval[] = this.options.intervals) { - const intervals: Partial> = {}; - seconds = getSafeInteger(seconds); - (Object.keys(TIME_INTERVALS) as TimeInterval[]).forEach((t) => { - if (intervalNames.includes(t)) { - intervals[t] = Math.floor(seconds / TIME_INTERVALS[t]); - seconds -= intervals[t] * TIME_INTERVALS[t]; - } - }); - return intervals; - } - - private intervalsToSeconds(intervals: Partial>) { - let seconds = 0; - (Object.entries(intervals) as [TimeInterval, number][]).forEach(([interval, value]) => { - seconds += value * TIME_INTERVALS[interval]; - }); - return getSafeInteger(seconds); } + /** + * Update the native input from the fields + */ private updateHiddenInput() { - const intervals: Partial> = {}; + const intervals: TimeIntervals = {}; (Object.entries(this.innerInputs) as [TimeInterval, HTMLInputElement][]).forEach(([i, input]) => { intervals[i] = parseInt(input.value, 10); }); - let seconds = this.intervalsToSeconds(intervals); + let seconds = intervalsToSeconds(intervals); if (this.element.step) { const step = parseInt(this.element.step, 10) * TIME_INTERVALS[this.options.unit]; seconds = Math.floor(seconds / step) * step; @@ -109,99 +121,52 @@ export class DurationInput { seconds = Math.min(seconds, parseInt(this.element.max, 10)); } this.element.value = `${Math.round(seconds / TIME_INTERVALS[this.options.unit])}`; - this.element.dispatchEvent(new Event("input", { bubbles: true })); - this.element.dispatchEvent(new Event("change", { bubbles: true })); + this.dispatchChange(); } + /** + * Update the fields from the native input + */ private updateInnerInputs() { - const intervals = this.secondsToIntervals(parseInt(this.element.value, 10) * TIME_INTERVALS[this.options.unit]); + const intervals = secondsToIntervals(parseInt(this.element.value, 10) * TIME_INTERVALS[this.options.unit], this.options.intervals); (Object.entries(this.innerInputs) as [TimeInterval, HTMLInputElement][]).forEach(([i, input]) => { input.value = `${intervals[i] || 0}`; }); + this.updateTexts(); } - private updateInnerInputsLength() { - Object.values(this.innerInputs).forEach((input) => { - input.style.width = `${Math.max(3, input.value.length + 2)}ch`; - }); - } - - private updateLabels() { + /** + * Update the texts from the fields + */ + private updateTexts() { (Object.entries(this.innerInputs) as [TimeInterval, HTMLInputElement][]).forEach(([i, input]) => { - (this.labels[i] as HTMLLabelElement).innerText = this.options.labels[i][parseInt(input.value, 10) === 1 ? 0 : 1]; + (this.texts[i] as Signal).value = input.value; }); } - private createInnerInputs(intervals: Partial>, steps: Partial>) { + /** + * Create a field and a label for each interval + * + * @param intervals Initial value of each field + * @param steps Step of each field + */ + private createInnerInputs(intervals: TimeIntervals, steps: TimeIntervals) { this.field = document.createElement("div"); this.field.className = "form-input-duration-wrap"; - let innerInput: HTMLInputElement; + let lastInput: HTMLInputElement | undefined; for (const name of this.options.intervals) { - innerInput = document.createElement("input"); - innerInput.id = `${this.element.id}.${name}`; - innerInput.className = "form-input"; - - const wrap = document.createElement("span"); - wrap.className = `duration-${name}`; - innerInput.type = "number"; - innerInput.value = `${intervals[name] || 0}`; - innerInput.style.width = `${Math.max(3, innerInput.value.length + 2)}ch`; - if ((steps[name] as number) > 1) { - innerInput.step = `${steps[name]}`; - } - if (this.element.disabled) { - innerInput.disabled = true; - } - this.innerInputs[name] = innerInput; - innerInput.addEventListener("input", () => { - while (innerInput.value.charAt(0) === "0" && innerInput.value.length > 1 && !innerInput.value.charAt(1).match(/[.,]/)) { - innerInput.value = innerInput.value.slice(1); - } - while (parseInt(innerInput.value, 10) > Number.MAX_SAFE_INTEGER) { - innerInput.value = innerInput.value.slice(0, -1); - } - this.updateInnerInputsLength(); - this.updateHiddenInput(); - this.updateLabels(); - }); - innerInput.addEventListener("blur", () => { - this.updateHiddenInput(); - this.updateInnerInputs(); - this.updateInnerInputsLength(); - this.updateLabels(); - }); - - innerInput.addEventListener("focus", () => this.field.classList.add("focused")); - - innerInput.addEventListener("blur", () => this.field.classList.remove("focused")); - - wrap.addEventListener("mousedown", function (event: MouseEvent) { - const input = $("input", this); - if (input && event.target !== input) { - input.focus(); - event.preventDefault(); - } - }); - - const label = document.createElement("label"); - const labelText = this.options.labels[name][parseInt(innerInput.value, 10) === 1 ? 0 : 1]; - - label.className = "form-label"; - label.innerText = labelText; - label.htmlFor = innerInput.id; - - this.labels[name] = label; - - wrap.appendChild(innerInput); - wrap.appendChild(label); + const wrap = this.createInterval(name, intervals[name] || 0, steps[name] as number); + + lastInput = $("input", wrap) as HTMLInputElement; + this.field.appendChild(wrap); } - this.field.addEventListener("mousedown", function (event: MouseEvent) { - if (event.target === this) { - innerInput.focus(); + this.field.addEventListener("mousedown", (event: MouseEvent) => { + if (event.target === this.field) { + lastInput?.focus(); event.preventDefault(); } }); @@ -209,6 +174,81 @@ export class DurationInput { return this.field; } + /** + * Create the field and the label of an interval + * + * @param name Name of the interval + * @param value Initial value of the field + * @param step Step of the field + */ + private createInterval(name: TimeInterval, value: number, step: number) { + const wrap = document.createElement("span"); + wrap.className = `duration-${name}`; + + const input = document.createElement("input"); + input.id = `${this.element.id}.${name}`; + input.className = "form-input"; + input.type = "number"; + input.value = `${value}`; + + if (step > 1) { + input.step = `${step}`; + } + + if (this.element.disabled) { + input.disabled = true; + } + + this.innerInputs[name] = input; + + // The width of the field and its label depend on the text + const text = signal(input.value); + this.texts[name] = text; + + input.addEventListener("input", () => { + const cleaned = cleanNumber(input.value); + + if (cleaned !== input.value) { + input.value = cleaned; + } + + text.value = input.value; + this.updateHiddenInput(); + }); + + input.addEventListener("focus", () => this.field.classList.add("focused")); + + input.addEventListener("blur", () => { + this.updateHiddenInput(); + this.updateInnerInputs(); + this.field.classList.remove("focused"); + }); + + wrap.addEventListener("mousedown", (event: MouseEvent) => { + if (event.target !== input) { + input.focus(); + event.preventDefault(); + } + }); + + const label = document.createElement("label"); + label.className = "form-label"; + label.htmlFor = input.id; + + effect(() => { + input.style.width = `${Math.max(3, text.value.length + 2)}ch`; + label.textContent = this.options.labels[name][parseInt(text.value, 10) === 1 ? 0 : 1]; + }); + + wrap.appendChild(input); + wrap.appendChild(label); + + return wrap; + } + + /** + * Replace the native input with the fields + */ private createField() { this.element.className = ""; this.element.readOnly = true; @@ -226,7 +266,7 @@ export class DurationInput { const valueSeconds = parseInt(this.element.value, 10) * TIME_INTERVALS[this.options.unit]; const stepSeconds = parseInt(this.element.step, 10) * TIME_INTERVALS[this.options.unit]; - const field = this.createInnerInputs(this.secondsToIntervals(valueSeconds || 0), this.secondsToIntervals(stepSeconds || 1)); + const field = this.createInnerInputs(secondsToIntervals(valueSeconds || 0, this.options.intervals), secondsToIntervals(stepSeconds || 1, this.options.intervals)); (this.element.parentNode as ParentNode).replaceChild(field, this.element); field.appendChild(this.element); $(`label[for="${this.element.id}"]`)?.addEventListener("click", () => $(".form-input", field)?.focus()); diff --git a/panel/src/ts/components/inputs/editor-input.ts b/panel/src/ts/components/inputs/editor-input.ts index 7cf7ef55e..a821a71ee 100644 --- a/panel/src/ts/components/inputs/editor-input.ts +++ b/panel/src/ts/components/inputs/editor-input.ts @@ -1,10 +1,15 @@ -import { app } from "../../app"; +import { effect, signal } from "@preact/signals-core"; +import { app } from "../../services/app"; import { debounce } from "../../utils/events"; import { $ } from "../../utils/selectors"; import { initToolbar } from "../../utils/toolbar"; import { escapeRegExp } from "../../utils/validation"; -import { markdown } from "../icons"; +import { markdown } from "../ui/icons"; +import { InputBase } from "./input"; +/** + * Describes the editor view used by the input + */ interface EditorView { view: { dom: HTMLElement; focus(): void }; content: string; @@ -12,14 +17,27 @@ interface EditorView { destroy(): void; } +/** + * Make the relative image paths of a Markdown text absolute + * + * @param baseUri Base URI prepended to the relative paths + */ function addBaseUri(markdown: string, baseUri: string) { return markdown.replace(/(!\[.*\])\((?!https?:\/\/)([^)]+)\)/g, `$1(${baseUri}$2)`); } +/** + * Make the image paths of a Markdown text relative + * + * @param baseUri Base URI removed from the paths + */ function removeBaseUri(markdown: string, baseUri: string) { return markdown.replace(new RegExp(`(!\\[.*\\])\\(${escapeRegExp(baseUri)}([^)]+)\\)`, "g"), "$1($2)"); } +/** + * Get the height of a textarea + */ function getTextareaHeight(textarea: HTMLTextAreaElement): number { const clone = textarea.cloneNode() as HTMLTextAreaElement; clone.style.visibility = "hidden"; @@ -30,6 +48,9 @@ function getTextareaHeight(textarea: HTMLTextAreaElement): number { return height; } +/** + * Holds the options of an editor input + */ interface EditorInputOptions { baseUri: string; height: number; @@ -37,21 +58,44 @@ interface EditorInputOptions { inputEventHandler: (value: string) => void; } -export class EditorInput { - readonly element: HTMLTextAreaElement; - +/** + * Provides an input with a Markdown or code editor in place of a textarea + * + * The editor mode is chosen with a button of the toolbar and remembered. Editors are loaded on + * demand + */ +export class EditorInput extends InputBase { + /** + * Id of the textarea + */ readonly id: string; + /** + * Options of the editor + */ readonly options: EditorInputOptions; - private container: HTMLElement | null; + /** + * Element wrapping toolbar and editor + */ + private container: HTMLElement; + /** + * Current editor + */ private editor: EditorView | undefined; - private editorPromise: Promise; + /** + * Promise resolved when the current editor is loaded + */ + private editorPromise!: Promise; + /** + * Create the editor input + */ constructor(textarea: HTMLTextAreaElement, options: Partial = {}) { - this.element = textarea; + super(textarea); + this.id = textarea.id; const defaults: EditorInputOptions = { @@ -60,83 +104,102 @@ export class EditorInput { spellcheck: true, inputEventHandler: (value: string) => { this.element.value = removeBaseUri(value, this.options.baseUri); - debounce( - () => { - this.element.dispatchEvent(new Event("input", { bubbles: true })); - this.element.dispatchEvent(new Event("change", { bubbles: true })); - }, - 500, - true, - )(); + debounce(() => this.dispatchChange(), 500, true)(); }, }; this.options = { ...defaults, ...options }; - this.container = (textarea.parentNode as HTMLElement)?.classList.contains("editor-wrap") ? (textarea.parentNode as HTMLElement) : null; - - if (!this.container) { - this.container = document.createElement("div"); - this.container.classList.add("editor-wrap"); - textarea.parentNode?.insertBefore(this.container, textarea); - const toolbar = document.createElement("div"); - toolbar.classList.add("editor-toolbar"); - - const toggleButton = document.createElement("button"); - toggleButton.type = "button"; - toggleButton.classList.add("button", "toolbar-button", "editor-toggle-markdown"); - toggleButton.dataset.command = "toggle-markdown"; - toggleButton.title = app.translation.get("panel.editor.toggleMarkdown", "Toggle Markdown"); - toggleButton.ariaLabel = app.translation.get("panel.editor.toggleMarkdown", "Toggle Markdown"); - toggleButton.disabled = this.element.disabled; - toggleButton.innerHTML = markdown; - toolbar.appendChild(toggleButton); - - this.container.appendChild(toolbar); - this.container.appendChild(textarea); - - this.options.height -= toolbar.offsetHeight; - this.element.style.height = `${this.options.height}px`; + this.container = this.getContainer(); + + this.bindModeToggle(); + this.bindLabel(); + } + + /** + * Get the element wrapping the textarea, creating it with the toolbar if the textarea has none + */ + private getContainer() { + const textarea = this.element; + + if ((textarea.parentNode as HTMLElement)?.classList.contains("editor-wrap")) { + return textarea.parentNode as HTMLElement; } + const container = document.createElement("div"); + container.classList.add("editor-wrap"); + textarea.parentNode?.insertBefore(container, textarea); + + const toolbar = document.createElement("div"); + toolbar.classList.add("editor-toolbar"); + toolbar.appendChild(this.createToggleButton()); + + container.appendChild(toolbar); + container.appendChild(textarea); + + this.options.height -= toolbar.offsetHeight; + textarea.style.height = `${this.options.height}px`; + + return container; + } + + /** + * Create the button of the toolbar that switches between the Markdown and the code editor + */ + private createToggleButton() { + const title = app.translation.get("panel.editor.toggleMarkdown", "Toggle Markdown"); + + const button = document.createElement("button"); + button.type = "button"; + button.classList.add("button", "toolbar-button", "editor-toggle-markdown"); + button.dataset.command = "toggle-markdown"; + button.title = title; + button.ariaLabel = title; + button.disabled = this.element.disabled; + button.innerHTML = markdown; + + return button; + } + + /** + * Show the editor of the mode remembered, and switch it with the button of the toolbar + */ + private bindModeToggle() { const formName = this.element.form?.dataset.form; const key = formName ? `${formName}.${this.name}` : this.name; - const mode = window.localStorage.getItem(`formwork.editorMode[${key}]`); + const mode = signal<"markdown" | "code">(window.localStorage.getItem(`formwork.editorMode[${key}]`) === "code" ? "code" : "markdown"); const toggleButton = $("[data-command=toggle-markdown]", this.container) as HTMLButtonElement; - if (mode === "code") { - this.editorPromise = this.switchToCode(); - toggleButton.classList.add("is-active"); - } else { - this.editorPromise = this.switchToMarkdown(); - toggleButton.classList.remove("is-active"); - } + effect(() => { + toggleButton.classList.toggle("is-active", mode.value === "code"); + this.editorPromise = mode.value === "code" ? this.switchToCode() : this.switchToMarkdown(); + }); toggleButton.addEventListener("click", () => { toggleButton.disabled = true; - if (toggleButton.classList.toggle("is-active")) { - this.editorPromise = this.switchToCode(); - window.localStorage.setItem(`formwork.editorMode[${key}]`, "code"); - } else { - this.editorPromise = this.switchToMarkdown(); - window.localStorage.setItem(`formwork.editorMode[${key}]`, "markdown"); - } - this.editorPromise.then(() => { + mode.value = mode.value === "code" ? "markdown" : "code"; + window.localStorage.setItem(`formwork.editorMode[${key}]`, mode.value); + void this.editorPromise.then(() => { this.editor?.view.focus(); toggleButton.disabled = false; }); }); + } - const label = $(`label[for="${textarea.id}"]`); + /** + * Focus the editor when the label is clicked and make the toolbar accessible + */ + private bindLabel() { + const label = $(`label[for="${this.id}"]`); const toolbar = $(".editor-toolbar", this.container); if (label) { label.addEventListener("click", () => { - this.editorPromise.then(() => this.editor?.view.focus()); + void this.editorPromise.then(() => this.editor?.view.focus()); }); } @@ -145,44 +208,58 @@ export class EditorInput { } } + /** + * Switch to the Markdown editor + */ async switchToMarkdown() { - if (!this.container) { - return; - } - this.editor?.destroy(); - this.element.classList.add("is-loading"); - this.element.style.display = ""; - const { MarkdownView } = await import("./editor/markdown/view"); - this.editor = new MarkdownView(this.name, this.container, addBaseUri(this.element.value, this.options.baseUri), this.options.inputEventHandler, { - editable: !(this.element.disabled || this.element.readOnly), - placeholder: this.element.placeholder, - attributes: { - spellcheck: this.options.spellcheck ? "true" : "false", - ...this.accessibilityAttributes(), - }, + await this.switchEditor(async () => { + const { MarkdownView } = await import("../editor/markdown-view"); + + return new MarkdownView(this.name, this.container, addBaseUri(this.element.value, this.options.baseUri), this.options.inputEventHandler, { + editable: !(this.element.disabled || this.element.readOnly), + placeholder: this.element.placeholder, + attributes: { + spellcheck: this.options.spellcheck ? "true" : "false", + ...this.accessibilityAttributes(), + }, + }); }); - this.element.style.display = "none"; - this.element.classList.remove("is-loading"); - this.editor.view.dom.style.height = `${this.options.height}px`; } + /** + * Switch to the code editor + */ async switchToCode() { - if (!this.container) { - return; - } + await this.switchEditor(async () => { + const { CodeView } = await import("../editor/code-view"); + + return new CodeView(this.container, removeBaseUri(this.element.value, this.options.baseUri), this.options.inputEventHandler, { + editable: !(this.element.disabled || this.element.readOnly), + placeholder: this.element.placeholder, + }); + }); + } + + /** + * Replace the current editor with a new one, hiding the textarea + * + * @param createEditor Function creating the new editor, which loads its code on demand + */ + private async switchEditor(createEditor: () => Promise) { this.editor?.destroy(); this.element.classList.add("is-loading"); this.element.style.display = ""; - const { CodeView } = await import("./editor/code/view"); - this.editor = new CodeView(this.container, removeBaseUri(this.element.value, this.options.baseUri), this.options.inputEventHandler, { - editable: !(this.element.disabled || this.element.readOnly), - placeholder: this.element.placeholder, - }); + + this.editor = await createEditor(); + this.element.style.display = "none"; this.element.classList.remove("is-loading"); this.editor.view.dom.style.height = `${this.options.height}px`; } + /** + * Get the accessibility attributes of the editor + */ private accessibilityAttributes(): Record { const attributes: Record = { role: "textbox", @@ -214,36 +291,40 @@ export class EditorInput { return attributes; } - get name() { - return this.element.name; - } - - set name(value: string) { - this.element.name = value; - } - + /** + * Get the content of the editor + */ get value(): string { return this.editor?.content ?? this.element.value; } + /** + * Return whether the editor is read-only + */ get disabled(): boolean { return this.editor ? !this.editor.editable : this.element.disabled; } + /** + * Make the editor read-only or editable + */ set disabled(value: boolean) { this.element.disabled = value; - this.editorPromise.then(() => { + void this.editorPromise.then(() => { if (this.editor) { this.editor.editable = !value; } }); - const toggleButton = $("[data-command=toggle-markdown]", this.container as HTMLElement) as HTMLButtonElement; + const toggleButton = $("[data-command=toggle-markdown]", this.container) as HTMLButtonElement; toggleButton.disabled = value; } + /** + * Set the content of the editor + */ set value(value: string) { this.element.value = value; - this.editorPromise.then(() => { + void this.editorPromise.then(() => { if (this.editor) { this.editor.content = value; } diff --git a/panel/src/ts/components/inputs/editor/markdown/linktooltip.ts b/panel/src/ts/components/inputs/editor/markdown/linktooltip.ts deleted file mode 100644 index fe9d34406..000000000 --- a/panel/src/ts/components/inputs/editor/markdown/linktooltip.ts +++ /dev/null @@ -1,115 +0,0 @@ -import { schema } from "prosemirror-markdown"; -import type { Mark } from "prosemirror-model"; -import { Plugin } from "prosemirror-state"; -import type { EditorView } from "prosemirror-view"; -import { app } from "../../../../app"; -import { debounce, throttle } from "../../../../utils/events"; -import { $ } from "../../../../utils/selectors"; -import { link as linkIcon, pencil, trash } from "../../../icons"; -import { Tooltip } from "../../../tooltip"; -import { getMarkRange, insertLink, removeLink } from "./commands"; - -function addBaseUri(text: string, baseUri: string) { - return text.replace(/^\/?(?!https?:\/\/)(.+)/, `${baseUri}$1`); -} -class LinkTooltipView { - editorView: EditorView; - tooltip?: Tooltip; - currentLink?: Mark; - baseUri: string; - - constructor(view: EditorView, baseUri: string) { - this.editorView = view; - - this.baseUri = baseUri; - - const resizeHandler = throttle(() => this.update(this.editorView), 100); - - const scrollHandler = debounce(() => this.destroy(), 100, true); - - window.addEventListener("resize", resizeHandler); - window.addEventListener("scroll", scrollHandler); - - this.editorView.dom.addEventListener("scroll", scrollHandler); - this.editorView.dom.addEventListener("blur", () => this.destroy()); - } - - update(view: EditorView) { - const state = view.state; - - const { from, to } = state.selection; - - const range = getMarkRange(state, schema.marks.link); - if (!(range && from >= range.from && to <= range.to)) { - this.destroy(); - return; - } - - const link = range.mark; - - if (link) { - if (this.tooltip) { - this.tooltip.remove(); - } - - const domAtPos = view.domAtPos(range.from + 1); - - let linkDom: HTMLElement | null = domAtPos.node as HTMLElement; - - while (linkDom && linkDom.tagName !== "A") { - linkDom = linkDom.parentElement; - } - - if (!linkDom) { - return; - } - - this.tooltip = new Tooltip( - ` -
- -
- `, - { - referenceElement: linkDom, - removeOnMouseout: false, - delay: 0, - zIndex: 18, - }, - ); - - const tooltipLink = $(".editor-link-tooltip", this.tooltip.element) as HTMLAnchorElement; - const tooltipEditLink = $('[data-command="edit-link"]', this.tooltip.element) as HTMLButtonElement; - const tooltipDeleteLink = $('[data-command="delete-link"]', this.tooltip.element) as HTMLButtonElement; - - tooltipLink.insertAdjacentHTML("afterbegin", linkIcon); - tooltipEditLink.insertAdjacentHTML("afterbegin", pencil); - tooltipDeleteLink.insertAdjacentHTML("afterbegin", trash); - - const { state, dispatch } = this.editorView; - - tooltipEditLink.addEventListener("click", () => insertLink(state, dispatch, this.editorView)); - tooltipDeleteLink.addEventListener("click", () => removeLink(state, dispatch, this.editorView)); - - this.tooltip.element.addEventListener("mousedown", (event) => event.preventDefault()); - - this.tooltip.show(); - } - } - - destroy() { - if (this.tooltip) { - const tooltip = this.tooltip; - window.setTimeout(() => tooltip.remove(), 100); - } - this.tooltip = undefined; - } -} - -export function linkTooltip(baseUri: string): Plugin { - return new Plugin({ - view(editorView: EditorView) { - return new LinkTooltipView(editorView, baseUri); - }, - }); -} diff --git a/panel/src/ts/components/inputs/image-picker.ts b/panel/src/ts/components/inputs/image-picker.ts index 5a60f3704..e5751ea1b 100644 --- a/panel/src/ts/components/inputs/image-picker.ts +++ b/panel/src/ts/components/inputs/image-picker.ts @@ -1,30 +1,27 @@ -import { app } from "../../app"; -import { Request } from "../../utils/request"; +import { app } from "../../services/app"; +import { post } from "../../services/request"; import { $, $$ } from "../../utils/selectors"; +import { Input } from "./input"; -export class ImagePicker { - readonly element: HTMLInputElement; - - constructor(element: HTMLInputElement) { - this.element = element; - } - - get name() { - return this.element.name; - } - - set name(value: string) { - this.element.name = value; - } - - get value() { - return this.element.value; - } - - set value(value: string) { - this.element.value = value; - } +/** + * Describes a file of the page, as listed in the images modal + */ +interface ImagePickerFile { + type?: string; + thumbnail?: string; + uri?: string; + filename?: string; +} +/** + * Provides an input holding the URI of an image chosen among the ones of a page + */ +export class ImagePicker extends Input { + /** + * Load the images and show their thumbnails + * + * A click selects an image, a double click picks it + */ update() { const parentElement = this.element.parentElement as HTMLElement; const emptyState = $(".image-picker-empty-state", parentElement) as HTMLElement; @@ -48,36 +45,28 @@ export class ImagePicker { } }; - new Request( - { - method: "POST", - url: this.element.dataset.src, - data: { "csrf-token": app.config.csrfToken as string }, - }, - (response) => { - const data = response.data ?? {}; - const images = Object.values(data as Record).filter((file) => file.type === "image"); + post>(this.element.dataset.src as string, {}, (response) => { + const images = Object.values(response.data ?? {}).filter((file) => file.type === "image"); - if (images.length > 0) { - const container = document.createElement("div"); - container.className = "image-picker-thumbnails"; + if (images.length > 0) { + const container = document.createElement("div"); + container.className = "image-picker-thumbnails"; - for (const image of images) { - const thumbnail = document.createElement("div"); - thumbnail.className = "image-picker-thumbnail"; - thumbnail.style.backgroundImage = `url(${image.thumbnail})`; - thumbnail.dataset.uri = image.uri; - thumbnail.dataset.filename = image.filename; - thumbnail.addEventListener("click", () => selectImage(thumbnail)); - thumbnail.addEventListener("dblclick", () => pickImage(thumbnail)); - container.appendChild(thumbnail); - } + for (const image of images) { + const thumbnail = document.createElement("div"); + thumbnail.className = "image-picker-thumbnail"; + thumbnail.style.backgroundImage = `url(${image.thumbnail})`; + thumbnail.dataset.uri = image.uri; + thumbnail.dataset.filename = image.filename; + thumbnail.addEventListener("click", () => selectImage(thumbnail)); + thumbnail.addEventListener("dblclick", () => pickImage(thumbnail)); + container.appendChild(thumbnail); + } - parentElement.appendChild(container); + parentElement.appendChild(container); - emptyState.style.display = "none"; - } - }, - ); + emptyState.style.display = "none"; + } + }); } } diff --git a/panel/src/ts/components/inputs/input.ts b/panel/src/ts/components/inputs/input.ts index 178d47d83..87f979b59 100644 --- a/panel/src/ts/components/inputs/input.ts +++ b/panel/src/ts/components/inputs/input.ts @@ -1,22 +1,74 @@ -export class Input { - readonly element: HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement; +/** + * Notify that the value of an element has changed + * + * The native elements do not emit events when their value is changed by a script + */ +export function dispatchChange(element: HTMLElement) { + element.dispatchEvent(new Event("input", { bubbles: true })); + element.dispatchEvent(new Event("change", { bubbles: true })); +} + +/** + * Represents the native elements holding a value + */ +type NativeInputElement = HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement; - constructor(element: HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement) { +/** + * Provides the base of the inputs of a form + * + * The input enhances the native element rendered by PHP, which is sent with the form + */ +export abstract class InputBase { + /** + * Native element of the input + */ + readonly element: Element; + + constructor(element: Element) { this.element = element; } + /** + * Get the name of the input + */ get name(): string { return this.element.name; } + /** + * Set the name of the input + */ set name(value: string) { this.element.name = value; } + /** + * Get the value sent with the form + */ + abstract get value(): string; + + /** + * Notify that the value has changed + */ + dispatchChange() { + dispatchChange(this.element); + } +} + +/** + * Represents an input without enhancements + */ +export class Input extends InputBase { + /** + * Get the value of the native element + */ get value(): string { return this.element.value; } + /** + * Set the value of the native element + */ set value(value: string) { this.element.value = value; } diff --git a/panel/src/ts/components/inputs/range-input.ts b/panel/src/ts/components/inputs/range-input.ts index 0a5e81f01..43af5e8e5 100644 --- a/panel/src/ts/components/inputs/range-input.ts +++ b/panel/src/ts/components/inputs/range-input.ts @@ -1,36 +1,26 @@ import { $ } from "../../utils/selectors"; +import { Input } from "./input"; -export class RangeInput { - readonly element: HTMLInputElement; - +/** + * Provides a range input that shows its progress, its value in the linked `output` element and, + * optionally, ticks + */ +export class RangeInput extends Input { constructor(element: HTMLInputElement) { - this.element = element; + super(element); this.initInput(); } - get name() { - return this.element.name; - } - - set name(value: string) { - this.element.name = value; - } - - get value() { - return this.element.value; - } - - set value(value: string) { - this.element.value = value; - } - + /** + * Update progress and output when the value changes and add the ticks + */ private initInput() { const updateValueLabel = (element: HTMLInputElement) => { element.style.setProperty("--progress", `${Math.round((parseInt(element.value, 10) / (parseInt(element.max, 10) - parseInt(element.min, 10))) * 100)}%`); const outputElement = $(`output[for="${element.id}"]`); if (outputElement) { - outputElement.innerText = element.value; + outputElement.textContent = element.value; } }; diff --git a/panel/src/ts/components/inputs/select-input.ts b/panel/src/ts/components/inputs/select-input.ts index ddb45f55a..fbb3f80db 100644 --- a/panel/src/ts/components/inputs/select-input.ts +++ b/panel/src/ts/components/inputs/select-input.ts @@ -1,8 +1,11 @@ -import { app } from "../../app"; +import { effect } from "@preact/signals-core"; +import { app } from "../../services/app"; +import { makeSearchRegExp } from "../../utils/search"; import { $, $$ } from "../../utils/selectors"; import { toCamelCase } from "../../utils/strings"; -import { escapeRegExp, makeDiacriticsRegExp } from "../../utils/validation"; -import * as icons from "../icons"; +import * as icons from "../ui/icons"; +import { DropdownList } from "./dropdown-list"; +import { InputBase } from "./input"; type SelectInputListItem = { label: string; @@ -12,7 +15,10 @@ type SelectInputListItem = { dataset: Record; }; -interface SelectInputOptions { +/** + * Holds the options of a select input + */ +export interface SelectInputOptions { labels: { empty: string; }; @@ -20,29 +26,38 @@ interface SelectInputOptions { let instanceCount = 0; -export class SelectInput { +/** + * Provides a select input with a searchable list of options in place of a native select + * + * The native select is hidden and holds the value sent with the form + */ +export class SelectInput extends InputBase { readonly options: SelectInputOptions; - readonly element: HTMLSelectElement; - - private dropdown!: HTMLElement; + private list!: DropdownList; private labelInput: HTMLInputElement; private emptyState: HTMLDivElement; + // Whether a key has been typed since the text field got the focus + private isTyping = false; + + // Whether Escape has just closed the list, so that the key is not left to the page + private isEscapeHandled = false; + private readonly uid = ++instanceCount; private optionCount = 0; - constructor(element: HTMLSelectElement, options: Partial) { + constructor(element: HTMLSelectElement, options: Partial = {}) { const defaults: SelectInputOptions = { labels: { empty: app.translation.get("fields.select.empty", "No matching options"), }, }; - this.element = element; + super(element); this.options = { ...defaults, ...options }; @@ -53,120 +68,156 @@ export class SelectInput { this.createField(); } - get dropdownElement() { - return this.dropdown; + /** + * Get the element of the list + */ + private get dropdown() { + return this.list.element; } - get name() { - return this.element.name; - } - - set name(value: string) { - this.element.name = value; + /** + * Get the element of the list + */ + get dropdownElement() { + return this.dropdown; } + // The getter is defined again because a class defining only the setter would lose it get value() { return this.element.value; } + /** + * Select the option with a value + */ set value(value: string) { this.element.value = value; this.setCurrent(); } + /** + * Get the selected item of the list + */ get selectedDropdownItem() { - return $(".dropdown-item.selected", this.dropdown); + return this.list.selected.value; } + /** + * Get the items of the list + */ get dropdownItems() { return $$(".dropdown-item", this.dropdown); } + /** + * Create the text field and the list from the options of the select + */ private createField() { - const hasWrap = this.element.closest(".form-input-wrap"); + const wrap = this.getWrap(); - const wrap = hasWrap || document.createElement("div"); + this.createLabelInput(); + this.hideSelect(); - const selectId = this.element.id; + const items = this.readOptions(); - if (!hasWrap) { - wrap.className = "form-input-wrap"; - (this.element.parentNode as ParentNode).insertBefore(wrap, this.element.nextSibling); - } + wrap.appendChild(this.labelInput); + wrap.appendChild(this.element); - this.element.hidden = true; + this.createList(items, wrap); - this.labelInput.type = "text"; + const labelId = this.labelInput.labels?.[0]?.id; - this.labelInput.className = this.element.className; - this.labelInput.classList.add("form-select"); - this.labelInput.id = selectId; + if (labelId) { + this.dropdown.setAttribute("aria-labelledby", labelId); + } + } - const describedBy = this.element.getAttribute("aria-describedby"); + /** + * Get the element wrapping the input, creating it if the select has none + */ + private getWrap() { + const existingWrap = this.element.closest(".form-input-wrap"); - if (describedBy) { - this.labelInput.setAttribute("aria-describedby", describedBy); + if (existingWrap) { + return existingWrap; } - // The text input acts as the combobox, its dropdown is the listbox - this.labelInput.setAttribute("role", "combobox"); - this.labelInput.setAttribute("aria-autocomplete", "list"); - this.labelInput.setAttribute("aria-haspopup", "listbox"); - this.labelInput.setAttribute("aria-expanded", "false"); + const wrap = document.createElement("div"); + wrap.className = "form-input-wrap"; + (this.element.parentNode as ParentNode).insertBefore(wrap, this.element.nextSibling); - this.element.removeAttribute("id"); + return wrap; + } - this.element.ariaHidden = "true"; + /** + * Set up the text field, which acts as the combobox of the list + */ + private createLabelInput() { + const input = this.labelInput; - this.element.tabIndex = -1; + input.type = "text"; + input.className = this.element.className; + input.classList.add("form-select"); + input.id = this.element.id; + + const describedBy = this.element.getAttribute("aria-describedby"); + + if (describedBy) { + input.setAttribute("aria-describedby", describedBy); + } + + input.setAttribute("role", "combobox"); + input.setAttribute("aria-autocomplete", "list"); + input.setAttribute("aria-haspopup", "listbox"); + input.setAttribute("aria-expanded", "false"); if (this.element.hasAttribute("disabled")) { - this.labelInput.disabled = true; + input.disabled = true; } for (const key in this.element.dataset) { - this.labelInput.dataset[key] = this.element.dataset[key]; + input.dataset[key] = this.element.dataset[key]; } + } - const list: SelectInputListItem[] = []; - - $$("option", this.element).forEach((option) => { - const dataset: Record = {}; + /** + * Hide the select from users and assistive technologies, leaving the id to the text field + */ + private hideSelect() { + this.element.hidden = true; + this.element.removeAttribute("id"); + this.element.ariaHidden = "true"; + this.element.tabIndex = -1; + } - for (const key in option.dataset) { - dataset[key] = option.dataset[key] as string; + /** + * Read the options of the select, showing the label of the selected one in the text field + */ + private readOptions() { + return Array.from($$("option", this.element), (option): SelectInputListItem => { + if (option.selected) { + this.labelInput.value = option.textContent; } - list.push({ - label: option.innerText, + return { + label: option.textContent, value: option.value, selected: option.selected, disabled: option.disabled, - dataset, - }); - - if (option.selected) { - this.labelInput.value = option.innerText; - } + dataset: { ...option.dataset } as Record, + }; }); - - wrap.appendChild(this.labelInput); - - wrap.appendChild(this.element); - - this.createDropdown(list, wrap as HTMLElement); - - const labelId = this.labelInput.labels?.[0]?.id; - - if (labelId) { - this.dropdown.setAttribute("aria-labelledby", labelId); - } } + /** + * Add an option + * + * @param option Label, value, disabled state, thumbnail URI and icon name of the option + */ addOption({ label, value, disabled, thumb, icon }: { label: string; value: string; disabled?: boolean; thumb?: string; icon?: string }) { const option = document.createElement("option"); option.value = value; - option.innerText = label; + option.textContent = label; option.disabled = disabled ?? false; if (thumb) { option.dataset.thumb = thumb; @@ -188,43 +239,68 @@ export class SelectInput { }); } + /** + * Remove an option, selecting the first available one if it was selected + */ removeOption(value: string) { - const option = $(`option[value="${value}"]`, this.element) as HTMLOptionElement; + const option = $(`option[value="${CSS.escape(value)}"]`, this.element) as HTMLOptionElement; if (option) { option.remove(); } - const item = $(`.dropdown-item[data-value="${value}"]`, this.dropdown); + const item = this.list.find(value); if (item) { item.remove(); + if (this.list.selected.value === item) { + this.list.selected.value = null; + } } if (option?.selected) { - this.selectFirstDropdownItem(); - this.setCurrent(this.getSelected()); + this.list.selectFirst(); + + const selected = this.list.selected.value; + + if (selected) { + this.setCurrent(selected); + } } } + /** + * Sort the items of the list by value + */ sortDropdownItems() { - const items = $$(".dropdown-item", this.dropdown); - const sorted = Array.from(items).sort((a, b) => (a.dataset.value as string).localeCompare(b.dataset.value as string)); - for (const item of sorted) { - this.dropdown.appendChild(item); - } + this.list.sort(); } + /** + * Add an item to the list + */ private addDropdownItem(option: SelectInputListItem) { + const item = this.createItem(option); + + this.bindItemEvents(item); + + this.dropdown.appendChild(item); + } + + /** + * Create the element of an option, with its thumbnail or icon + */ + private createItem(option: SelectInputListItem) { const item = document.createElement("div"); item.className = "dropdown-item"; item.id = `${this.dropdown.id}-option-${++this.optionCount}`; item.setAttribute("role", "option"); item.setAttribute("aria-selected", String(option.selected)); - item.innerText = option.label; + item.textContent = option.label; item.dataset.value = option.value; if (option.selected) { item.classList.add("selected"); + this.list.selected.value = item; } if (option.disabled) { @@ -236,6 +312,7 @@ export class SelectInput { const img = document.createElement("img"); img.src = option.dataset.thumb; img.className = "dropdown-thumb"; + img.alt = ""; item.insertAdjacentElement("afterbegin", img); } else if (option.dataset.icon) { const icon = toCamelCase(option.dataset.icon) as keyof typeof icons; @@ -245,18 +322,24 @@ export class SelectInput { } for (const key in option.dataset) { - if (["icon", "thumb"].includes(key)) { - continue; + if (key !== "icon" && key !== "thumb") { + item.dataset[key] = option.dataset[key]; } - item.dataset[key] = option.dataset[key]; } + return item; + } + + /** + * Pick the option of an item when it is clicked, but not when the pointer was pressed elsewhere + */ + private bindItemEvents(item: HTMLElement) { let mousedownCaptured = false; item.addEventListener("mousedown", (event) => { if (!item.classList.contains("disabled")) { mousedownCaptured = true; - this.selectDropdownItem(item); + this.list.select(item); } event.preventDefault(); event.stopPropagation(); @@ -266,18 +349,19 @@ export class SelectInput { const keyboardActivation = event.detail === 0; if (mousedownCaptured || keyboardActivation) { this.setCurrent(item); - this.labelInput.blur(); + this.close(); } mousedownCaptured = false; event.preventDefault(); event.stopPropagation(); }); - - this.dropdown.appendChild(item); } - private createDropdown(list: SelectInputListItem[], wrap: HTMLElement) { - this.dropdown = document.createElement("div"); + /** + * Create the list, fill it with the items and bind the text field to it + */ + private createList(items: SelectInputListItem[], wrap: HTMLElement) { + this.list = new DropdownList(document.createElement("div")); this.dropdown.className = "dropdown-list"; this.dropdown.dataset.for = this.labelInput.id; @@ -287,284 +371,309 @@ export class SelectInput { this.emptyState.className = "dropdown-empty"; this.emptyState.style.display = "none"; - this.emptyState.innerText = this.options.labels.empty; + this.emptyState.textContent = this.options.labels.empty; this.dropdown.appendChild(this.emptyState); - for (const option of list) { - this.addDropdownItem(option); + for (const item of items) { + this.addDropdownItem(item); } wrap.appendChild(this.dropdown); - let hasKeyboardInput = false; - - this.labelInput.addEventListener("focus", () => { - this.selectCurrent(); - this.labelInput.setSelectionRange(0, 0); - hasKeyboardInput = false; - }); + this.bindAriaState(); + this.bindLabelInput(); + } - this.labelInput.addEventListener("mousedown", (event) => { - this.labelInput.focus(); - event.preventDefault(); - }); + /** + * Keep the ARIA attributes of the text field and of the items in sync with the list + */ + private bindAriaState() { + effect(() => { + const expanded = this.list.open.value; - this.labelInput.addEventListener("blur", () => { - if (!this.validateDropdownItem(this.labelInput.value)) { - this.labelInput.value = this.getCurrentLabel(); + if (expanded === null) { + return; } - this.setExpanded(false); - }); - this.labelInput.addEventListener("keydown", (event) => { - const selectedItem = $(".dropdown-item.selected", this.dropdown); + this.labelInput.setAttribute("aria-expanded", String(expanded)); - switch (event.key) { - case "Backspace": // backspace - this.updateDropdown(); - break; + if (!expanded) { + this.labelInput.removeAttribute("aria-activedescendant"); + } + }); - case "ArrowUp": // up arrow - if (getComputedStyle(this.dropdown).display !== "none") { - this.selectPrevDropdownItem(); - } else { - this.selectCurrent(); - } - event.preventDefault(); - break; - - case "ArrowDown": // down arrow - if (getComputedStyle(this.dropdown).display !== "none") { - this.selectNextDropdownItem(); - } else { - this.selectCurrent(); - } - event.preventDefault(); - break; + let isFirstSelection = true; - case "Enter": - if (selectedItem && getComputedStyle(selectedItem).display !== "none") { - this.setCurrent(selectedItem); - } + effect(() => { + const selected = this.list.selected.value; - // dropdown.style.display = 'none'; - this.labelInput.blur(); - event.preventDefault(); - break; - - case "Escape": - case "ArrowLeft": - case "ArrowRight": - break; - - default: - if (!hasKeyboardInput) { - this.labelInput.value = ""; - hasKeyboardInput = true; - } - break; + for (const element of this.list.items) { + element.setAttribute("aria-selected", String(element === selected)); } - }); - this.labelInput.addEventListener("keyup", (event) => { - const value = this.labelInput.value.trim(); - switch (event.key) { - case "Escape": - this.labelInput.blur(); - event.stopPropagation(); - break; - case "ArrowUp": - case "ArrowDown": - case "ArrowLeft": - case "ArrowRight": - case "Tab": - case "Enter": - return true; - default: - this.setExpanded(true); - this.filterDropdown(value); - if (value.length > 0) { - this.selectFirstDropdownItem(); - } + // The attribute is not set for the option selected by the markup + if (selected && !isFirstSelection) { + this.labelInput.setAttribute("aria-activedescendant", selected.id); + } else if (!selected) { + this.labelInput.removeAttribute("aria-activedescendant"); } + + isFirstSelection = false; }); } - private updateDropdown() { - let visibleItems = 0; - $$(".dropdown-item", this.dropdown).forEach((element) => { - if (getComputedStyle(element).display !== "none") { - visibleItems++; + /** + * Bind the focus, blur and keyboard events of the text field + */ + private bindLabelInput() { + this.labelInput.addEventListener("focus", () => { + this.openWithCurrent(); + this.labelInput.setSelectionRange(0, 0); + this.isTyping = false; + }); + + this.labelInput.addEventListener("mousedown", (event) => { + if (document.activeElement === this.labelInput) { + // The list is not opened by the focus, which the text field already has + this.openWithCurrent(); + } else { + this.labelInput.focus(); } - element.classList.remove("selected"); - element.setAttribute("aria-selected", "false"); + + event.preventDefault(); }); - this.labelInput.removeAttribute("aria-activedescendant"); + this.labelInput.addEventListener("blur", () => this.commitLabel()); + this.labelInput.addEventListener("keydown", (event) => this.handleKeydown(event)); + this.labelInput.addEventListener("keyup", (event) => this.handleKeyup(event)); + } + + /** + * Pick the option whose label has been typed, or restore the label of the current one + */ + private commitLabel() { + const item = this.findDropdownItem(this.labelInput.value); - if (visibleItems > 0) { - this.emptyState.style.display = "none"; + if (!item) { + this.labelInput.value = this.getCurrentLabel(); + } else if (item.dataset.value !== this.element.value) { + this.setCurrent(item); } else { - this.emptyState.style.display = "block"; + this.labelInput.value = item.textContent.trim(); } - } - private setExpanded(expanded: boolean) { - this.dropdown.style.display = expanded ? "block" : "none"; - this.labelInput.setAttribute("aria-expanded", String(expanded)); + this.list.open.value = false; + } - if (!expanded) { - this.labelInput.removeAttribute("aria-activedescendant"); + /** + * Move through the items with the arrows, pick the selected one with Enter + */ + private handleKeydown(event: KeyboardEvent) { + switch (event.key) { + case "Backspace": + this.resetSelection(); + break; + + case "ArrowUp": + this.moveSelection(() => this.list.selectPrevious()); + event.preventDefault(); + break; + + case "ArrowDown": + this.moveSelection(() => this.list.selectNext()); + event.preventDefault(); + break; + + case "Enter": + this.pickSelected(); + event.preventDefault(); + break; + + case "Escape": + // A list that is open is closed, otherwise Escape is left to the page (e.g. a modal) + if (this.list.open.value === true) { + this.cancel(); + this.isEscapeHandled = true; + event.preventDefault(); + } + break; + + case "ArrowLeft": + case "ArrowRight": + break; + + default: + // The first key typed replaces the label instead of editing it + if (!this.isTyping) { + this.labelInput.value = ""; + this.isTyping = true; + } + break; } } - private filterDropdown(value: string) { - const filter = (element: HTMLElement) => { - if (value === "") { - return true; - } - const text = `${element.textContent}`; - const regexp = new RegExp(`(^|\\b)${makeDiacriticsRegExp(escapeRegExp(value))}`, "i"); - return regexp.test(text); - }; - - let visibleItems = 0; - $$(".dropdown-item", this.dropdown).forEach((element) => { - if (value === null || filter(element)) { - element.style.display = "block"; - visibleItems++; - } else { - element.style.display = "none"; + /** + * Filter the items by the typed text, keep an Escape that closed the list from reaching other listeners + */ + private handleKeyup(event: KeyboardEvent) { + switch (event.key) { + case "Escape": + if (this.isEscapeHandled) { + this.isEscapeHandled = false; + event.stopPropagation(); + } + break; + + case "ArrowUp": + case "ArrowDown": + case "ArrowLeft": + case "ArrowRight": + case "Tab": + case "Enter": + break; + + default: { + const value = this.labelInput.value.trim(); + + this.list.open.value = true; + this.filterItems(value); + + if (value.length > 0) { + this.list.selectFirst(); + } } - }); + } + } - if (visibleItems > 0) { - this.emptyState.style.display = "none"; + /** + * Move the selection if the list is open, otherwise open it + */ + private moveSelection(move: () => void) { + if (this.list.open.value === true) { + move(); } else { - this.emptyState.style.display = "block"; + this.openWithCurrent(); } } - private scrollToDropdownItem(item: HTMLElement) { - const dropdownScrollTop = this.dropdown.scrollTop; - const dropdownHeight = this.dropdown.clientHeight; - const dropdownScrollBottom = dropdownScrollTop + dropdownHeight; - const dropdownStyle = getComputedStyle(this.dropdown); - const dropdownPaddingTop = parseInt(dropdownStyle.paddingTop, 10); - const dropdownPaddingBottom = parseInt(dropdownStyle.paddingBottom, 10); - const itemTop = item.offsetTop; - const itemHeight = item.clientHeight; - const itemBottom = itemTop + itemHeight; - if (itemTop < dropdownScrollTop) { - this.dropdown.scrollTop = itemTop - dropdownPaddingTop; - } else if (itemBottom > dropdownScrollBottom) { - this.dropdown.scrollTop = itemBottom - dropdownHeight + dropdownPaddingBottom; + /** + * Pick the selected item, if it is displayed, and leave the text field + */ + private pickSelected() { + const selected = this.list.selected.value; + + if (selected && getComputedStyle(selected).display !== "none") { + this.setCurrent(selected); } + + this.close(); } - private selectDropdownItem(item: HTMLElement) { - const selectedItem = $(".dropdown-item.selected", this.dropdown); - if (selectedItem) { - selectedItem.classList.remove("selected"); - selectedItem.setAttribute("aria-selected", "false"); - } - if (item) { - const isDisabled = item.classList.contains("disabled"); - if (!isDisabled) { - item.classList.add("selected"); - item.setAttribute("aria-selected", "true"); - this.labelInput.setAttribute("aria-activedescendant", item.id); - this.scrollToDropdownItem(item); - } - } + /** + * Close the list picking the option whose label has been typed, keeping the focus + */ + private close() { + this.commitLabel(); + this.isTyping = false; } - private selectFirstDropdownItem() { - const items = $$(".dropdown-item", this.dropdown); - for (let i = 0; i < items.length; i++) { - if (getComputedStyle(items[i]).display !== "none") { - this.selectDropdownItem(items[i]); - return; - } - } + /** + * Close the list discarding the typed text, keeping the focus + */ + private cancel() { + this.labelInput.value = this.getCurrentLabel(); + this.list.open.value = false; + this.isTyping = false; } - private selectLastDropdownItem() { - const items = $$(".dropdown-item", this.dropdown); - for (let i = items.length - 1; i >= 0; i--) { - if (getComputedStyle(items[i]).display !== "none") { - this.selectDropdownItem(items[i]); - return; - } - } + /** + * Clear the selection and update the empty state + */ + private resetSelection() { + this.list.selected.value = null; + + this.updateEmptyState(); } - private selectPrevDropdownItem() { - const selectedItem = $(".dropdown-item.selected", this.dropdown) as HTMLElement; - if (selectedItem) { - let previousItem = selectedItem.previousSibling as HTMLElement; - while (previousItem && (previousItem.style.display === "none" || previousItem.classList.contains("disabled"))) { - previousItem = previousItem.previousSibling as HTMLElement; - } - if (previousItem) { - return this.selectDropdownItem(previousItem); - } - this.selectDropdownItem(selectedItem.previousSibling as HTMLElement); - } - this.selectLastDropdownItem(); + /** + * Show the empty state if no item is displayed + */ + private updateEmptyState() { + this.emptyState.style.display = this.list.countDisplayed() > 0 ? "none" : "block"; } - private selectNextDropdownItem() { - const selectedItem = $(".dropdown-item.selected", this.dropdown) as HTMLElement; - if (selectedItem) { - let nextItem = selectedItem.nextSibling as HTMLElement; - while (nextItem && (nextItem.style.display === "none" || nextItem.classList.contains("disabled"))) { - nextItem = nextItem.nextSibling as HTMLElement; - } - if (nextItem) { - return this.selectDropdownItem(nextItem); - } - } - this.selectFirstDropdownItem(); + /** + * Show only the items matching the typed text + */ + private filterItems(value: string) { + const regexp = value === "" ? null : makeSearchRegExp(value); + + this.list.filter((item) => regexp === null || regexp.test(`${item.textContent}`)); + + this.updateEmptyState(); } - private setCurrent(item: HTMLElement = this.getCurrent()) { - this.element.value = item.dataset.value as string; - this.labelInput.value = item.innerText.trim(); - this.element.dispatchEvent(new Event("input", { bubbles: true })); - this.element.dispatchEvent(new Event("change", { bubbles: true })); + /** + * Pick an option and update the select and the text field + * + * @param item Item of the option to pick, or the current one if missing + */ + private setCurrent(item: HTMLElement | null = this.getCurrent()) { + if (item) { + this.element.value = item.dataset.value as string; + this.labelInput.value = item.textContent.trim(); + } else { + // The value matches no option + this.labelInput.value = ""; + } + this.dispatchChange(); } + /** + * Get the item of the current option + */ private getCurrent() { - return $(`[data-value="${this.element.value}"]`, this.dropdown) as HTMLElement; + return this.list.find(this.element.value); } + /** + * Get the label of the current option + */ private getCurrentLabel() { - return this.getCurrent().innerText.trim(); + return this.getCurrent()?.textContent.trim() ?? ""; } - private getSelected() { - return $(".dropdown-item.selected", this.dropdown) as HTMLElement; - } + /** + * Open the list with the current option selected + */ + private openWithCurrent() { + if (this.list.open.value !== true) { + const current = this.getCurrent(); + + this.filterItems(""); + this.resetSelection(); + this.list.select(current); + this.list.open.value = true; - private selectCurrent() { - if (getComputedStyle(this.dropdown).display === "none") { - this.filterDropdown(""); - this.updateDropdown(); - this.selectDropdownItem(this.getCurrent()); - this.setExpanded(true); - this.scrollToDropdownItem(this.getCurrent()); + if (current) { + this.list.scrollTo(current); + } } } - private validateDropdownItem(value: string) { - const items = $$(".dropdown-item", this.dropdown); - for (let i = 0; i < items.length; i++) { - if (items[i].innerText === value) { - return true; - } + /** + * Get the option with a label, ignoring surrounding spaces + * + * @returns Item, or `null` if there is none + */ + private findDropdownItem(label: string) { + const current = this.getCurrent(); + + if (current?.textContent.trim() === label.trim()) { + return current; } - return false; + + return Array.from(this.dropdownItems).find((item) => !item.classList.contains("disabled") && item.textContent.trim() === label.trim()) ?? null; } } diff --git a/panel/src/ts/components/inputs/slug-input.ts b/panel/src/ts/components/inputs/slug-input.ts index fc7055654..d8288cc22 100644 --- a/panel/src/ts/components/inputs/slug-input.ts +++ b/panel/src/ts/components/inputs/slug-input.ts @@ -1,31 +1,35 @@ import { $ } from "../../utils/selectors"; import { makeSlug, validateSlug } from "../../utils/validation"; +import { Input } from "./input"; -export class SlugInput { - readonly element: HTMLInputElement; - +/** + * Provides a text input whose value is always a valid slug + * + * The slug can be generated from another field (`data-source`), automatically (`data-auto-update`) + * or with a button (`data-generate-slug`) + */ +export class SlugInput extends Input { constructor(element: HTMLInputElement) { - this.element = element; + super(element); this.initInput(); } - get name() { - return this.element.name; - } - - set name(value: string) { - this.element.name = value; - } - + // The getter is defined again because a class defining only the setter would lose it get value() { return this.element.value; } + /** + * Set the value, making it a valid slug + */ set value(value: string) { this.element.value = validateSlug(value); } + /** + * Generate the slug from the source field and clean the value while typing + */ private initInput() { const source = $(`[id="${this.element.dataset.source}"]`); const autoUpdate = "autoUpdate" in this.element.dataset && this.element.dataset.autoUpdate === "true"; diff --git a/panel/src/ts/components/inputs/tags-input.ts b/panel/src/ts/components/inputs/tags-input.ts index 2e7afcb08..f17ddf233 100644 --- a/panel/src/ts/components/inputs/tags-input.ts +++ b/panel/src/ts/components/inputs/tags-input.ts @@ -1,19 +1,28 @@ +import { effect, signal } from "@preact/signals-core"; import type { SortableEvent } from "sortablejs"; -import { app } from "../../app"; +import { app } from "../../services/app"; import { debounce } from "../../utils/events"; -import { $, $$ } from "../../utils/selectors"; +import { makeSearchRegExp } from "../../utils/search"; +import { $$ } from "../../utils/selectors"; import { toCamelCase } from "../../utils/strings"; -import { escapeRegExp, makeDiacriticsRegExp } from "../../utils/validation"; -import * as icons from "../icons"; - -interface TagsInputOptions { +import * as icons from "../ui/icons"; +import { DropdownList } from "./dropdown-list"; +import { InputBase } from "./input"; + +/** + * Holds the options of a tags input + */ +export interface TagsInputOptions { labels: { [key: string]: string }; - addKeyCodes: string[]; + addKeys: string[]; limit: number; accept: "options" | "any"; orderable: boolean; } +/** + * Describes a suggested tag + */ interface TagsInputDropdownItem { label: string; value: string; @@ -21,30 +30,45 @@ interface TagsInputDropdownItem { thumb?: string; } -export class TagsInput { - readonly element: HTMLInputElement; +/** + * Get the tags of a value, ignoring the empty ones + */ +function parseTags(value: string) { + return value + .split(", ") + .map((tag) => tag.trim()) + .filter((tag) => tag !== ""); +} +/** + * Provides an input with a list of tags followed by a text field + * + * Tags are added with Enter or comma and removed with Backspace. With `data-options` the field + * suggests tags while typing. The value is the list of tags separated by commas + */ +export class TagsInput extends InputBase { private options: TagsInputOptions; - private tags: string[] = []; + private readonly tags = signal([]); + private readonly tagValues = new WeakMap(); private placeholder: string = ""; - private dropdown: HTMLElement | undefined; + private dropdown: DropdownList | undefined; private field: HTMLDivElement; private list: HTMLSpanElement; private innerInput: HTMLInputElement; - constructor(element: HTMLInputElement, options: Partial) { + constructor(element: HTMLInputElement, options: Partial = {}) { const defaults = { labels: { remove: app.translation.get("fields.tags.remove", "Remove"), }, - addKeyCodes: ["Comma"], + addKeys: [","], limit: Infinity, accept: "options" as "options" | "any", orderable: true, }; - this.element = element; + super(element); this.options = { ...defaults, ...options }; @@ -58,38 +82,73 @@ export class TagsInput { this.registerInputEvents(); } - get name() { - return this.element.name; - } - - set name(value: string) { - this.element.name = value; - } - + /** + * Get the tags separated by commas + */ get value() { return this.element.value; } + /** + * Set the tags + */ set value(value: string) { this.element.value = value; - this.tags = value.split(", ").map((tag) => tag.trim()); - this.list.innerText = ""; - this.tags.forEach((tag) => { - this.insertTag(tag, this.list); - }); + this.list.replaceChildren(); + this.tags.value = parseTags(value); this.updatePlaceholder(); this.updateDropdown(); } - private async createField() { - if ("limit" in this.element.dataset) { - this.options.limit = parseInt(this.element.dataset.limit as string, 10); + /** + * Create the field with the tags and the text field + */ + private createField() { + this.readDataAttributes(); + this.replaceElement(); + + this.tags.value = parseTags(this.element.value); + + effect(() => this.renderTags(this.tags.value)); + + if (this.innerInput.placeholder) { + this.placeholder = this.innerInput.placeholder; + this.updatePlaceholder(); + } + + this.field.addEventListener("mousedown", (event) => { + this.innerInput.focus(); + event.preventDefault(); + }); + + if (this.options.orderable) { + void this.makeSortable(); + } + } + + /** + * Read the options set by the `data-*` attributes of the native input + */ + private readDataAttributes() { + const { dataset } = this.element; + + if ("limit" in dataset) { + this.options.limit = parseInt(dataset.limit as string, 10); + } + + if ("accept" in dataset) { + this.options.accept = (dataset.accept ?? "options") as "options" | "any"; } - if (!("orderable" in this.element.dataset)) { + if (!("orderable" in dataset)) { this.options.orderable = false; } + } + /** + * Replace the native input with the field, hiding it from users and assistive technologies + */ + private replaceElement() { this.field.className = "form-input-tags-wrap"; this.innerInput.className = "form-input"; @@ -119,78 +178,69 @@ export class TagsInput { this.field.appendChild(this.list); this.field.appendChild(this.innerInput); this.field.appendChild(this.element); + } - if (this.element.value) { - this.tags = this.element.value.split(", "); - this.tags.forEach((value, index) => { - value = value.trim(); - this.tags[index] = value; - this.insertTag(value, this.list); - }); - } + /** + * Let the user reorder the tags by dragging them (Sortable is loaded on demand) + */ + private async makeSortable() { + const { default: Sortable } = await import("sortablejs"); + + Sortable.create(this.list, { + forceFallback: true, + animation: 150, + filter: ".tag-remove", + preventOnFilter: false, + + onChoose: () => { + if (this.dropdown) { + this.dropdown.open.value = false; + } + }, - if (this.innerInput.placeholder) { - this.placeholder = this.innerInput.placeholder; - this.updatePlaceholder(); - } else { - this.placeholder = ""; - } + onStart: () => this.field.classList.add("is-dragging"), - this.field.addEventListener("mousedown", (event) => { - this.innerInput.focus(); - event.preventDefault(); + onEnd: ({ oldIndex, newIndex }: SortableEvent) => { + this.field.classList.remove("is-dragging"); + this.innerInput.blur(); + this.innerInput.focus(); + + if (oldIndex !== undefined && newIndex !== undefined && oldIndex !== newIndex) { + this.moveTag(oldIndex, newIndex); + } + }, }); + } - if (this.options.orderable) { - const { default: Sortable } = await import("sortablejs"); - - Sortable.create(this.list, { - forceFallback: true, - animation: 150, - filter: ".tag-remove", - preventOnFilter: false, - - onChoose: () => { - if (this.dropdown) { - this.dropdown.style.display = "none"; - } - }, - - onStart: () => { - this.field.classList.add("is-dragging"); - }, - - onEnd: (event: SortableEvent) => { - this.field.classList.remove("is-dragging"); - const newIndex = event.newIndex; - const oldIndex = event.oldIndex; - this.innerInput.blur(); - this.innerInput.focus(); - if (newIndex === oldIndex || newIndex === undefined || oldIndex === undefined) { - return; - } - this.tags.splice(newIndex, 0, this.tags.splice(oldIndex, 1)[0]); - this.updateTags(); - }, - }); - } + /** + * Move a tag to another position + */ + private moveTag(oldIndex: number, newIndex: number) { + const tags = [...this.tags.value]; + tags.splice(newIndex, 0, tags.splice(oldIndex, 1)[0]); + this.tags.value = tags; + this.updateTags(); } + /** + * Add a suggested tag + */ addDropdownItem(option: TagsInputDropdownItem) { if (!this.dropdown) { - this.createDropdown(); + return; } const item = document.createElement("div"); item.className = "dropdown-item"; - item.innerText = option.label; + item.textContent = option.label; item.dataset.value = option.value; if (option.thumb) { const img = document.createElement("img"); img.src = option.thumb; img.className = "dropdown-thumb"; + img.alt = ""; item.insertAdjacentElement("afterbegin", img); } else if (option.icon) { // biome-ignore lint/performance/noDynamicNamespaceImportAccess: Accessing the icon dynamically from the imported icons object is necessary for flexibility @@ -202,133 +252,111 @@ export class TagsInput { this.addTag(item.dataset.value); } }); - this.dropdown?.appendChild(item); + + this.dropdown.element.appendChild(item); } + /** + * Remove a suggested tag and the tag if added + */ removeDropdownItem(value: string) { - const item = $(`.dropdown-item[data-value="${value}"]`, this.dropdown); - if (item) { - this.dropdown?.removeChild(item); - } + this.dropdown?.find(value)?.remove(); this.updateDropdown(); this.removeTag(value); - $$(".tag", this.list).forEach((tag) => { - if (tag.textContent === value) { - this.list.removeChild(tag); - } - }); } + /** + * Sort the suggested tags by value + */ sortDropdownItems() { - const items = $$(".dropdown-item", this.dropdown); - const sorted = Array.from(items).sort((a, b) => (a.dataset.value as string).localeCompare(b.dataset.value as string)); - for (const item of sorted) { - this.dropdown?.appendChild(item); - } + this.dropdown?.sort(); } + /** + * Create the list of the suggested tags, if the native input has the `data-options` attribute + */ private createDropdown() { - if ("options" in this.element.dataset) { - const list: { [key: string | number]: string | TagsInputDropdownItem } = JSON.parse(this.element.dataset.options ?? "{}"); - const isAssociative = !Array.isArray(list); + if (!("options" in this.element.dataset)) { + return; + } - if ("accept" in this.element.dataset) { - this.options.accept = (this.element.dataset.accept ?? "options") as "options" | "any"; - } + const suggestions: { [key: string | number]: string | TagsInputDropdownItem } = JSON.parse(this.element.dataset.options ?? "{}"); + const isAssociative = !Array.isArray(suggestions); - this.dropdown = document.createElement("div"); - this.dropdown.className = "dropdown-list"; - this.dropdown.style.display = "none"; + this.dropdown = new DropdownList(document.createElement("div"), false); + this.dropdown.element.className = "dropdown-list"; - for (const key in list) { - const { value, icon, thumb } = typeof list[key] === "object" ? list[key] : { value: list[key], icon: undefined, thumb: undefined }; + for (const key in suggestions) { + const suggestion = suggestions[key]; + const { value, icon, thumb } = typeof suggestion === "object" ? suggestion : { value: suggestion, icon: undefined, thumb: undefined }; - this.addDropdownItem({ - label: value, - value: isAssociative ? key : value, - icon: icon ? (toCamelCase(icon) as keyof typeof icons) : undefined, - thumb, - }); + this.addDropdownItem({ + label: value, + value: isAssociative ? key : value, + icon: icon ? (toCamelCase(icon) as keyof typeof icons) : undefined, + thumb, + }); + } + + this.field.appendChild(this.dropdown.element); + + this.bindDropdownEvents(); + } + + /** + * Open the list when the text field is focused, close it when it is blurred, and filter it while typing + */ + private bindDropdownEvents() { + this.innerInput.addEventListener("focus", () => { + if (this.dropdown && !this.dropdown.open.value) { + this.updateDropdown(); + this.dropdown.element.scrollTop = 0; } + }); - this.field.appendChild(this.dropdown); + this.innerInput.addEventListener("blur", () => { + if (this.dropdown?.open.value) { + this.updateDropdown(); + this.dropdown.open.value = false; + } + }); - this.innerInput.addEventListener("focus", () => { - if (this.dropdown && getComputedStyle(this.dropdown).display === "none") { - this.updateDropdown(); - this.dropdown.scrollTop = 0; - } - }); + this.innerInput.addEventListener( + "keyup", + debounce((event: KeyboardEvent) => this.handleDropdownKeyup(event), 100), + ); + } - this.innerInput.addEventListener("blur", () => { - if (this.dropdown && getComputedStyle(this.dropdown).display !== "none") { - this.updateDropdown(); - this.dropdown.style.display = "none"; + /** + * Filter the suggested tags by the typed text, close the list with Escape + */ + private handleDropdownKeyup(event: KeyboardEvent) { + switch (event.key) { + case "Escape": + if (this.dropdown) { + this.dropdown.open.value = false; } - }); + break; - this.innerInput.addEventListener("keydown", (event) => { - switch (event.key) { - case "Backspace": - this.updateDropdown(); - break; - case "Enter": - if (this.dropdown && getComputedStyle(this.dropdown).display !== "none") { - this.addTagFromSelectedDropdownItem(); - if (this.innerInput.value !== "") { - event.preventDefault(); - } - } - break; - case "ArrowUp": - if (this.dropdown && getComputedStyle(this.dropdown).display !== "none") { - this.selectPrevDropdownItem(); - event.preventDefault(); - } - break; - case "ArrowDown": - if (this.dropdown && getComputedStyle(this.dropdown).display !== "none") { - this.selectNextDropdownItem(); - event.preventDefault(); - } - break; - default: - if (this.options.addKeyCodes.includes(event.code)) { - if (this.dropdown && getComputedStyle(this.dropdown).display !== "none") { - this.addTagFromSelectedDropdownItem(); - event.preventDefault(); - } - } - } - }); + case "ArrowUp": + case "ArrowDown": + break; + + default: { + const value = this.innerInput.value.trim(); - this.innerInput.addEventListener( - "keyup", - debounce((event: KeyboardEvent) => { - const value = this.innerInput.value.trim(); - switch (event.key) { - case "Escape": - if (this.dropdown) { - this.dropdown.style.display = "none"; - } - break; - case "ArrowUp": - case "ArrowDown": - return true; - default: - if (this.tags.length >= this.options.limit) { - event.preventDefault(); - } - this.filterDropdown(value); - if (value.length > 0) { - this.selectFirstDropdownItem(); - } - } - }, 100), - ); + this.filterDropdown(value); + + if (value.length > 0) { + this.dropdown?.selectFirst(); + } + } } } + /** + * Bind the keyboard and focus events of the text field + */ private registerInputEvents() { this.innerInput.addEventListener("focus", () => this.field.classList.add("focused")); @@ -349,63 +377,75 @@ export class TagsInput { } }); - this.innerInput.addEventListener("keydown", (event) => { - this.innerInput.classList.remove("form-input-invalid"); - const value = this.innerInput.value.trim(); - switch (event.key) { - case "Backspace": - if (value === "") { - this.removeTag(this.tags[this.tags.length - 1]); - const lastTag = this.list.childNodes[this.list.childNodes.length - 1]; - if (lastTag) { - this.list.removeChild(lastTag); - } - event.preventDefault(); - } - break; - case "Enter": - case "Comma": - if (value !== "") { - event.preventDefault(); - if (this.tags.length >= this.options.limit || this.tags.includes(value)) { - this.innerInput.classList.add("form-input-invalid"); - } else { - this.addTag(value); - } - } - break; - case "Escape": - this.clearInput(); - this.innerInput.blur(); - event.preventDefault(); - break; - default: - if (this.options.addKeyCodes.includes(event.key)) { - event.preventDefault(); - if (value !== "") { - if (this.tags.length >= this.options.limit || this.tags.includes(value)) { - this.innerInput.classList.add("form-input-invalid"); - } else { - this.addTag(value); - } - } - break; - } - break; + this.innerInput.addEventListener("keydown", (event) => this.handleKeydown(event)); + } + + /** + * Add or remove tags and move through the suggestions with the keyboard + */ + private handleKeydown(event: KeyboardEvent) { + this.innerInput.classList.remove("form-input-invalid"); + + const isDropdownOpen = this.dropdown?.open.value === true; + const isAddKey = this.options.addKeys.includes(event.key); + + // A typed tag is replaced by the selected suggestion + if (isDropdownOpen && (isAddKey || event.key === "Enter")) { + this.addTagFromSelectedDropdownItem(); + } + + const value = this.innerInput.value.trim(); + + if (event.key === "Backspace") { + this.updateDropdown(); + if (value === "") { + this.removeTag(this.tags.value[this.tags.value.length - 1]); + event.preventDefault(); } - }); + } else if (event.key === "Escape") { + this.clearInput(); + this.innerInput.blur(); + event.preventDefault(); + } else if (isDropdownOpen && event.key === "ArrowUp") { + this.dropdown?.selectPrevious(); + event.preventDefault(); + } else if (isDropdownOpen && event.key === "ArrowDown") { + this.dropdown?.selectNext(); + event.preventDefault(); + } else if (isAddKey || (event.key === "Enter" && value !== "")) { + event.preventDefault(); + if (value !== "") { + this.addTypedTag(value); + } + } + } + + /** + * Add a tag typed by the user, marking the text field as invalid if the tag cannot be added + */ + private addTypedTag(value: string) { + if (this.tags.value.length >= this.options.limit || this.tags.value.includes(value)) { + this.innerInput.classList.add("form-input-invalid"); + } else { + this.addTag(value); + } } + /** + * Update the native input and notify the form + */ private updateTags() { - this.element.value = this.tags.join(", "); - this.element.dispatchEvent(new Event("input", { bubbles: true })); - this.element.dispatchEvent(new Event("change", { bubbles: true })); + this.element.value = this.tags.value.join(", "); + this.dispatchChange(); this.updatePlaceholder(); } + /** + * Show the placeholder if there are no tags + */ private updatePlaceholder() { if (this.placeholder.length > 0) { - if (this.tags.length === 0) { + if (this.tags.value.length === 0) { this.innerInput.placeholder = this.placeholder; this.innerInput.size = this.placeholder.length; } else { @@ -415,26 +455,59 @@ export class TagsInput { } } + /** + * Return whether a tag can be added + * + * A tag cannot be added if the limit is reached or if it is a duplicate, and, when only the + * suggested tags are accepted, if it is not one of them + */ private validateTag(value: string) { - if (this.tags.length >= this.options.limit) { + if (this.tags.value.length >= this.options.limit) { return false; } - if (!this.tags.includes(value)) { + if (!this.tags.value.includes(value)) { if (this.dropdown && this.options.accept === "options") { - return $(`[data-value="${value}"]`, this.dropdown) !== null; + return this.dropdown.find(value) !== null; } return true; } return false; } - private insertTag(value: string, parent: HTMLElement) { + /** + * Render the tags, reusing the elements of the existing ones + */ + private renderTags(tags: string[]) { + const unused = new Map(); + + for (const tag of Array.from(this.list.children) as HTMLElement[]) { + const value = this.tagValues.get(tag) as string; + unused.set(value, [...(unused.get(value) ?? []), tag]); + } + + tags.forEach((value, index) => { + const tag = unused.get(value)?.shift() ?? this.createTag(value); + + if (this.list.children[index] !== tag) { + this.list.insertBefore(tag, this.list.children[index] ?? null); + } + }); + + for (const tag of Array.from(unused.values()).flat()) { + tag.remove(); + } + } + + /** + * Create the element of a tag + */ + private createTag(value: string) { const tag = document.createElement("span"); const tagRemove = document.createElement("button"); tag.className = "tag"; - tag.innerText = value; + tag.textContent = value; + this.tagValues.set(tag, value); tag.style.marginRight = ".25rem"; - parent.appendChild(tag); tagRemove.type = "button"; tagRemove.className = "tag-remove"; @@ -453,7 +526,6 @@ export class TagsInput { const keyboardActivation = event.detail === 0; if (mousedownCaptured || keyboardActivation) { this.removeTag(value); - parent.removeChild(tag); $$(".tooltip").forEach((tooltip) => { tooltip.remove(); @@ -469,12 +541,16 @@ export class TagsInput { event.stopPropagation(); }); tag.appendChild(tagRemove); + + return tag; } + /** + * Add a tag if valid and clear the text field + */ private addTag(value: string) { if (this.validateTag(value)) { - this.tags.push(value); - this.insertTag(value, this.list); + this.tags.value = [...this.tags.value, value]; this.updateTags(); } else { this.updatePlaceholder(); @@ -483,158 +559,65 @@ export class TagsInput { this.updateDropdown(); } + /** + * Remove a tag + */ private removeTag(value: string) { - const index = this.tags.indexOf(value); + const index = this.tags.value.indexOf(value); if (index > -1) { - this.tags.splice(index, 1); + this.tags.value = this.tags.value.filter((_, tagIndex) => tagIndex !== index); this.updateTags(); } this.updateDropdown(); } + /** + * Clear the text field + */ private clearInput() { this.innerInput.value = ""; this.updatePlaceholder(); } + /** + * Show the suggested tags that are not tags yet + */ private updateDropdown() { if (!this.dropdown) { return; } - if (this.tags.length >= this.options.limit) { - this.dropdown.style.display = "none"; + if (this.tags.value.length >= this.options.limit) { + this.dropdown.open.value = false; return; } - let visibleItems = 0; - $$(".dropdown-item", this.dropdown).forEach((element) => { - if (!this.tags.includes(element.dataset.value as string)) { - element.style.display = "block"; - visibleItems++; - } else { - element.style.display = "none"; - } - element.classList.remove("selected"); - }); - if (visibleItems > 0 && this.innerInput === document.activeElement) { - this.dropdown.style.display = "block"; - } else { - this.dropdown.style.display = "none"; - } + const visibleItems = this.dropdown.filter((item) => !this.tags.value.includes(item.dataset.value as string)); + this.dropdown.selected.value = null; + this.dropdown.open.value = visibleItems > 0 && this.innerInput === document.activeElement; } + /** + * Show only the suggested tags that are not tags yet and match the typed text + */ private filterDropdown(value: string) { if (!this.dropdown) { return; } - if (value === "" && this.tags.length < this.options.limit) { - this.dropdown.style.display = "block"; + if (this.tags.value.length >= this.options.limit) { + this.dropdown.open.value = false; return; } - let visibleItems = 0; - $$(".dropdown-item", this.dropdown).forEach((element) => { - if (value === "") { - return; - } - const text = `${element.textContent}`; - const regexp = new RegExp(`(^|\\b)${makeDiacriticsRegExp(escapeRegExp(value))}`, "i"); - if (text.match(regexp) !== null && element.style.display !== "none") { - element.style.display = "block"; - visibleItems++; - } else { - element.style.display = "none"; - } - }); - if (visibleItems > 0) { - this.dropdown.style.display = "block"; - } else { - this.dropdown.style.display = "none"; - } - } - - private scrollToDropdownItem(item: HTMLElement) { - if (!this.dropdown) { - return; - } - const dropdownScrollTop = this.dropdown.scrollTop; - const dropdownHeight = this.dropdown.clientHeight; - const dropdownScrollBottom = dropdownScrollTop + dropdownHeight; - const dropdownStyle = getComputedStyle(this.dropdown); - const dropdownPaddingTop = parseInt(dropdownStyle.paddingTop, 10); - const dropdownPaddingBottom = parseInt(dropdownStyle.paddingBottom, 10); - const itemTop = item.offsetTop; - const itemHeight = item.clientHeight; - const itemBottom = itemTop + itemHeight; - if (itemTop < dropdownScrollTop) { - this.dropdown.scrollTop = itemTop - dropdownPaddingTop; - } else if (itemBottom > dropdownScrollBottom) { - this.dropdown.scrollTop = itemBottom - dropdownHeight + dropdownPaddingBottom; - } + const regexp = value === "" ? null : makeSearchRegExp(value); + const visibleItems = this.dropdown.filter((item) => !this.tags.value.includes(item.dataset.value as string) && (regexp === null || regexp.test(`${item.textContent}`))); + this.dropdown.open.value = visibleItems > 0; } + /** + * Set the text field to the value of the selected suggestion + */ private addTagFromSelectedDropdownItem() { - const selectedItem = $(".dropdown-item.selected", this.dropdown); + const selectedItem = this.dropdown?.selected.value; if (selectedItem && getComputedStyle(selectedItem).display !== "none") { this.innerInput.value = selectedItem.dataset.value as string; } } - - private selectDropdownItem(item: HTMLElement) { - const selectedItem = $(".dropdown-item.selected", this.dropdown); - if (selectedItem) { - selectedItem.classList.remove("selected"); - } - if (item) { - item.classList.add("selected"); - this.scrollToDropdownItem(item); - } - } - - private selectFirstDropdownItem() { - const items = $$(".dropdown-item", this.dropdown); - for (let i = 0; i < items.length; i++) { - if (getComputedStyle(items[i]).display !== "none") { - this.selectDropdownItem(items[i]); - return; - } - } - } - - private selectLastDropdownItem() { - const items = $$(".dropdown-item", this.dropdown); - for (let i = items.length - 1; i >= 0; i--) { - if (getComputedStyle(items[i]).display !== "none") { - this.selectDropdownItem(items[i]); - return; - } - } - } - - private selectPrevDropdownItem() { - const selectedItem = $(".dropdown-item.selected", this.dropdown); - if (selectedItem) { - let previousItem = selectedItem.previousSibling as HTMLElement; - while (previousItem && previousItem.style.display === "none") { - previousItem = previousItem.previousSibling as HTMLElement; - } - if (previousItem) { - return this.selectDropdownItem(previousItem); - } - this.selectDropdownItem(selectedItem.previousSibling as HTMLElement); - } - this.selectLastDropdownItem(); - } - - private selectNextDropdownItem() { - const selectedItem = $(".dropdown-item.selected", this.dropdown); - if (selectedItem) { - let nextItem = selectedItem.nextSibling as HTMLElement; - while (nextItem && nextItem.style.display === "none") { - nextItem = nextItem.nextSibling as HTMLElement; - } - if (nextItem) { - return this.selectDropdownItem(nextItem); - } - } - this.selectFirstDropdownItem(); - } } diff --git a/panel/src/ts/components/inputs/togglegroup-input.ts b/panel/src/ts/components/inputs/togglegroup-input.ts index d080cf2f9..46652f039 100644 --- a/panel/src/ts/components/inputs/togglegroup-input.ts +++ b/panel/src/ts/components/inputs/togglegroup-input.ts @@ -1,20 +1,31 @@ import { $, $$ } from "../../utils/selectors"; +import { InputBase } from "./input"; -export class TogglegroupInput { - readonly element: HTMLFieldSetElement; - +/** + * Provides a group of radio buttons shown as a toggle + */ +export class TogglegroupInput extends InputBase { + /** + * Create the toggle group + */ constructor(fieldset: HTMLFieldSetElement) { - this.element = fieldset; + super(fieldset); $(`label[for="${this.name}"]`)?.addEventListener("click", () => { $("input:checked", this.element)?.focus(); }); } + /** + * Get the name of the group + */ get name() { return this.element.name; } + /** + * Set the name of the group and of its radio buttons + */ set name(value: string) { this.element.name = value; $$("input", this.element)?.forEach((input) => { @@ -22,10 +33,16 @@ export class TogglegroupInput { }); } + /** + * Get the value of the checked radio button + */ get value() { return ($(`input:checked`, this.element) as HTMLInputElement).value; } + /** + * Check the radio button with a value + */ set value(value: string) { const input = $(`input[value="${value}"]`, this.element) as HTMLInputElement; if (input) { diff --git a/panel/src/ts/components/inputs/upload-input.ts b/panel/src/ts/components/inputs/upload-input.ts index cf676a160..0310643e3 100644 --- a/panel/src/ts/components/inputs/upload-input.ts +++ b/panel/src/ts/components/inputs/upload-input.ts @@ -1,30 +1,74 @@ -import { app } from "../../app"; -import { Request } from "../../utils/request"; +import { batch, effect, signal } from "@preact/signals-core"; +import type { Form } from "../../services/form"; +import { post } from "../../services/request"; import { $ } from "../../utils/selectors"; import { toCamelCase } from "../../utils/strings"; import { escapeHtml } from "../../utils/validation"; -import { FilesList } from "../fileslist"; -import type { Form } from "../form"; -import * as icons from "../icons"; -import { Notification } from "../notification"; -import { SelectInput } from "./select-input"; -import { TagsInput } from "./tags-input"; - -export class UploadInput { - readonly element: HTMLInputElement; +import { addToFileInputs } from "../files/file-inputs"; +import { FilesList } from "../files/files-list"; +import * as icons from "../ui/icons"; +import { Notification } from "../ui/notification"; +import { InputBase } from "./input"; + +/** + * Describes a file sent by the server after it has been uploaded + */ +interface UploadedFile { + name: string; + type: string; + thumbnail?: string; + uri: string; + hash: string; + mimeType: string; + lastModifiedTime: string; + size: string; + actions: Record<"info" | "rename" | "replace" | "delete", string>; +} +/** + * Provides a file input with a drop target + * + * Files can be uploaded as soon as they are chosen (`data-auto-upload`) and are added to the list + * of files and to the inputs of the form + */ +export class UploadInput extends InputBase { + /** + * Form containing the input + */ private readonly form: Form; + /** + * Whether the form has been submitted + */ private isSubmitted: boolean = false; + /** + * Files chosen + */ + private readonly files = signal([]); + /** + * Whether the files are being uploaded + */ + private readonly uploading = signal(false); + /** + * Whether files are being dragged over the drop target + */ + private readonly dragging = signal(false); + private readonly label: HTMLElement; private readonly dropTarget: HTMLElement; private readonly dropTargetLabel: HTMLElement; private readonly defaultDropLabel: string; + /** + * List of files of the page + */ private readonly filesList!: FilesList; + /** + * Create the upload input + */ constructor(element: HTMLInputElement, form: Form) { - this.element = element; + super(element); this.form = form; @@ -33,35 +77,40 @@ export class UploadInput { this.dropTargetLabel = $("span", this.dropTarget) as HTMLElement; this.defaultDropLabel = this.dropTargetLabel.innerHTML ?? ""; - this.initInput(); + effect(() => { + this.dropTarget.classList.toggle("drag", this.dragging.value); + }); + + effect(() => this.renderDropTargetLabel(this.files.value, this.uploading.value)); + + this.bindFormEvents(); + this.bindDropTarget(); const filesList = $(`.files-list[data-for="${this.element.id}"]`) as HTMLElement; + const autoUpload = this.element.dataset.autoUpload === "true"; if (filesList) { this.filesList = new FilesList(filesList, this.form); - this.initFilesList(); - } else if (this.element.dataset.autoUpload === "true") { - this.element.addEventListener("change", () => { - if (!this.form.hasChanged(false)) { - this.form.element.requestSubmit($("[type=submit]", this.form.element)); - } - }); - } - } - get name() { - return this.element.name; - } - - set name(value: string) { - this.element.name = value; + if (autoUpload) { + this.uploadOnChange(); + } + } else if (autoUpload) { + this.submitOnChange(); + } } + /** + * Get the value of the native input + */ get value(): string { return this.element.value; } - private initInput() { + /** + * Update the files shown when they change, and block the input once the form is submitted + */ + private bindFormEvents() { this.label?.addEventListener("click", (event) => { this.dropTarget.focus(); event.preventDefault(); @@ -74,30 +123,33 @@ export class UploadInput { this.isSubmitted = true; this.updateDropTargetLabel(); }); + } + + /** + * Let the user choose the files by dropping them on the drop target or by pressing Enter + */ + private bindDropTarget() { + for (const type of ["drag", "dragstart", "dragend"]) { + this.dropTarget.addEventListener(type, (event) => event.preventDefault()); + } + + for (const type of ["dragover", "dragenter"]) { + this.dropTarget.addEventListener(type, (event) => { + this.dragging.value = true; + event.preventDefault(); + }); + } - this.dropTarget.addEventListener("drag", (event) => event.preventDefault()); - this.dropTarget.addEventListener("dragstart", (event) => event.preventDefault()); - this.dropTarget.addEventListener("dragend", (event) => event.preventDefault()); - this.dropTarget.addEventListener("dragover", (event) => { - this.dropTarget.classList.add("drag"); - event.preventDefault(); - }); - this.dropTarget.addEventListener("dragenter", (event) => { - this.dropTarget.classList.add("drag"); - event.preventDefault(); - }); this.dropTarget.addEventListener("dragleave", (event) => { - this.dropTarget.classList.remove("drag"); + this.dragging.value = false; event.preventDefault(); }); this.dropTarget.addEventListener("drop", (event) => { event.preventDefault(); - this.dropTarget.classList.remove("drag"); - if (this.isSubmitted) { - return; - } - if (event.dataTransfer) { + this.dragging.value = false; + + if (!this.isSubmitted && event.dataTransfer) { this.element.files = event.dataTransfer.files; // Firefox won't trigger a change event, so we explicitly do that this.element.dispatchEvent(new Event("change")); @@ -117,81 +169,101 @@ export class UploadInput { }); } - private initFilesList() { - if (this.element.dataset.autoUpload === "true") { - this.element.addEventListener("change", () => { - if (!this.element.files) { - return; - } - - let files = Array.from(this.element.files); - - this.updateDropTargetLabel(files, true); - - for (const file of files) { - const formData = new FormData(); - formData.append("csrf-token", app.config.csrfToken as string); - formData.append(this.element.name, file); - - new Request( - { - method: "POST", - data: formData, - }, - (response) => { - const notification = new Notification(response.message, response.status); - - if (response.status === "success") { - const data = response.data[0]; - const template = $("template[id=files-item]") as HTMLTemplateElement; - this.addFilesItem(data, template); - this.filesList.sort(".file-name"); - this.filesList.element.hidden = false; - - for (const name in this.form.inputs) { - const input = this.form.inputs[name]; - if (input instanceof SelectInput && (input.element.classList.contains("form-file") || (input.element.classList.contains("form-image") && data.type === "image"))) { - input.addOption({ - label: data.name, - value: data.name, - thumb: data.type === "image" ? data.thumbnail : undefined, - icon: `file-${data.type}`, - }); - input.sortDropdownItems(); - } - - if (input instanceof TagsInput && (input.element.classList.contains("form-files") || (input.element.classList.contains("form-images") && data.type === "image"))) { - input.addDropdownItem({ - label: data.name, - value: data.name, - thumb: data.type === "image" ? data.thumbnail : undefined, - icon: toCamelCase(`file-${data.type}`) as keyof typeof icons, - }); - input.sortDropdownItems(); - } - } - } - - this.element.value = ""; - - files = files.filter((f) => f !== file); - this.updateDropTargetLabel(files, true); - - notification.show(); - }, - ); - } - }); - } + /** + * Submit the form as soon as a file is chosen, if the form has no other changes + */ + private submitOnChange() { + this.element.addEventListener("change", () => { + if (!this.form.hasChanged(false)) { + this.form.element.requestSubmit($("[type=submit]", this.form.element)); + } + }); + } + + /** + * Upload the files as soon as they are chosen + */ + private uploadOnChange() { + this.element.addEventListener("change", () => { + if (!this.element.files) { + return; + } + + const files = Array.from(this.element.files); + + // Files still being uploaded + const pending = new Set(files); + + this.updateDropTargetLabel(files, true); + + for (const file of files) { + this.upload(file, () => { + this.element.value = ""; + + pending.delete(file); + this.updateDropTargetLabel(Array.from(pending), true); + }); + } + }); } + /** + * Upload a file and add it to the list of files and to the inputs of the form + * + * @param file File to upload + * @param finish Function called when the upload is done, before the notification is shown + */ + private upload(file: File, finish: () => void) { + const formData = new FormData(); + formData.append(this.element.name, file); + + post(window.location.href, formData, (response) => { + const notification = new Notification(response.message, response.status); + + if (response.status === "success") { + const data = response.data[0]; + + this.addFilesItem(data, $("template[id=files-item]") as HTMLTemplateElement); + this.filesList.sort(".file-name"); + this.filesList.element.hidden = false; + + addToFileInputs(this.form, data); + } + + finish(); + + notification.show(); + }); + } + + /** + * Format a file size with the most suitable unit + * + * @param size Size in bytes + */ private formatFileSize(size: number) { const units = ["B", "KB", "MB", "GB", "TB"]; const exp = Math.min(Math.floor(Math.log(size) / Math.log(1024)), units.length - 1); return `${(size / 1024 ** exp).toFixed(2)} ${units[exp]}`; } + /** + * Update the files shown in the drop target + * + * @param files Files to show + * @param uploading Whether the files are being uploaded + */ private updateDropTargetLabel(files: File[] = Array.from(this.element.files ?? []), uploading: boolean = this.isSubmitted) { + batch(() => { + this.files.value = files; + this.uploading.value = uploading; + }); + } + + /** + * Render the files in the drop target, or its default text if there are none + */ + private renderDropTargetLabel(files: File[], uploading: boolean) { if (files.length) { const filenames: string[] = []; for (const file of files) { @@ -209,29 +281,19 @@ export class UploadInput { } } - private addFilesItem(info: { [key: string]: any }, template: HTMLTemplateElement) { + /** + * Add the item of an uploaded file to the list of files + * + * @param info File data sent by the server + * @param template Template of the item + */ + private addFilesItem(info: UploadedFile, template: HTMLTemplateElement) { const node = template.content.cloneNode(true) as HTMLElement; const filesItem = $(".files-item", node) as HTMLElement; filesItem.dataset.filename = info.name; - if (info.type === "image") { - const img = document.createElement("img"); - img.className = "file-thumbnail"; - img.alt = ""; - img.src = info.thumbnail; - img.loading = "lazy"; - $(".file-thumbnail", filesItem)?.replaceWith(img); - } else if (info.type === "video") { - const video = document.createElement("video"); - video.classList.add("file-thumbnail"); - video.ariaHidden = "true"; - video.src = info.thumbnail; - video.preload = "metadata"; - $(".file-thumbnail", filesItem)?.replaceWith(video); - } else { - $(".file-thumbnail", filesItem)?.remove(); - } + this.setThumbnail(filesItem, info); // biome-ignore lint/performance/noDynamicNamespaceImportAccess: Accessing the icon dynamically from the imported icons object is necessary for flexibility const icon = icons[toCamelCase(`file-${info.type}`) as keyof typeof icons] || icons.file; @@ -248,24 +310,51 @@ export class UploadInput { ($(".dropdown-button", filesItem) as HTMLElement).dataset.dropdown = `dropdown-${info.hash}`; ($(".dropdown-menu", filesItem) as HTMLElement).id = `dropdown-${info.hash}`; - const infoFileCommand = $("[data-command=infoFile]", filesItem) as HTMLAnchorElement; + this.setCommands(filesItem, info); + + $(".files-items", this.filesList.element)?.appendChild(node); + } + + /** + * Show the thumbnail of an image or a video in an item, and remove it for the other files + */ + private setThumbnail(filesItem: HTMLElement, info: UploadedFile) { + if (info.type === "image") { + const img = document.createElement("img"); + img.className = "file-thumbnail"; + img.alt = ""; + img.src = info.thumbnail ?? ""; + img.loading = "lazy"; + $(".file-thumbnail", filesItem)?.replaceWith(img); + } else if (info.type === "video") { + const video = document.createElement("video"); + video.classList.add("file-thumbnail"); + video.ariaHidden = "true"; + video.src = info.thumbnail ?? ""; + video.preload = "metadata"; + $(".file-thumbnail", filesItem)?.replaceWith(video); + } else { + $(".file-thumbnail", filesItem)?.remove(); + } + } + + /** + * Set the links and actions of the commands of an item + */ + private setCommands(filesItem: HTMLElement, info: UploadedFile) { const previewFileCommand = $("[data-command=previewFile]", filesItem) as HTMLAnchorElement; - const renameFileCommand = $("[data-command=renameFile]", filesItem) as HTMLElement; const replaceFileCommand = $("[data-command=replaceFile]", filesItem) as HTMLElement; - const deleteFileCommand = $("[data-command=deleteFile]", filesItem) as HTMLElement; - infoFileCommand.href = info.actions.info; + ($("[data-command=infoFile]", filesItem) as HTMLAnchorElement).href = info.actions.info; previewFileCommand.href = info.uri; previewFileCommand.target = `formwork-preview-file-${info.hash}`; - renameFileCommand.dataset.action = info.actions.rename; + ($("[data-command=renameFile]", filesItem) as HTMLElement).dataset.action = info.actions.rename; replaceFileCommand.dataset.action = info.actions.replace; replaceFileCommand.dataset.mimetype = `${info.mimeType}`; - deleteFileCommand.dataset.action = info.actions.delete; - - $(".files-items", this.filesList.element)?.appendChild(node); + ($("[data-command=deleteFile]", filesItem) as HTMLElement).dataset.action = info.actions.delete; } } diff --git a/panel/src/ts/components/navigation.ts b/panel/src/ts/components/navigation.ts deleted file mode 100644 index 6f7f7e91e..000000000 --- a/panel/src/ts/components/navigation.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { $ } from "../utils/selectors"; - -export class Navigation { - constructor() { - const toggle = $(".sidebar-toggle"); - const sidebar = $(".sidebar"); - - if (toggle && sidebar) { - sidebar.id ||= "panel-sidebar"; - toggle.setAttribute("aria-controls", sidebar.id); - toggle.setAttribute("aria-expanded", "false"); - - const setOpen = (open: boolean) => { - sidebar.classList.toggle("show", open); - toggle.setAttribute("aria-expanded", String(open)); - - if (open) { - if (!$(".sidebar-backdrop")) { - const backdrop = document.createElement("div"); - backdrop.className = "sidebar-backdrop hide-from-md"; - document.body.appendChild(backdrop); - } - } else { - const backdrop = $(".sidebar-backdrop"); - if (backdrop) { - (backdrop.parentNode as ParentNode).removeChild(backdrop); - } - } - }; - - toggle.addEventListener("click", () => setOpen(!sidebar.classList.contains("show"))); - - document.addEventListener("keydown", (event) => { - if (event.key === "Escape" && sidebar.classList.contains("show")) { - setOpen(false); - toggle.focus(); - } - }); - } - - if ($("[data-command=save]")) { - document.addEventListener("keydown", (event) => { - if (!event.altKey && (event.ctrlKey || event.metaKey)) { - if (event.key === "s") { - $("[data-command=save]")?.click(); - event.preventDefault(); - } - } - }); - } - } -} diff --git a/panel/src/ts/components/notification.ts b/panel/src/ts/components/notification.ts deleted file mode 100644 index 556d919bc..000000000 --- a/panel/src/ts/components/notification.ts +++ /dev/null @@ -1,139 +0,0 @@ -import { $ } from "../utils/selectors"; -import * as icons from "./icons"; - -type NotificationType = "info" | "success" | "warning" | "error"; - -type NotificationOptions = { - interval: number; - icon?: keyof typeof icons; - newestOnTop: boolean; - fadeOutDelay: number; - mouseleaveDelay: number; - typeClass: Record; - defaultIcons: Record; -}; - -export class Notification { - static getContainer(): HTMLElement { - let container = $(".notification-container"); - - if (!container) { - // Layouts render this container with its live region attributes, this is only a fallback - container = document.createElement("div"); - container.className = "notification-container"; - document.body.appendChild(container); - } - - return container; - } - - text: string; - type: NotificationType; - options: NotificationOptions; - containerElement: HTMLElement | null; - notificationElement!: HTMLElement; - - constructor(text: string, type: NotificationType, options: Partial = {}) { - const defaults: NotificationOptions = { - interval: 5000, - icon: undefined, - newestOnTop: true, - fadeOutDelay: 300, - mouseleaveDelay: 1000, - typeClass: { - info: "info", - success: "success", - warning: "warning", - error: "danger", - }, - defaultIcons: { - info: "infoCircle", - success: "checkCircle", - warning: "exclamationTriangle", - error: "exclamationOctagon", - }, - }; - - this.text = text; - this.type = type; - - this.options = Object.assign({}, defaults, options); - - this.containerElement = null; - } - - show() { - const create = (text: string, type: NotificationType, interval: number) => { - this.containerElement = Notification.getContainer(); - - const notification = document.createElement("div"); - notification.className = `notification notification-${this.options.typeClass[type]}`; - notification.tabIndex = 0; - - if (type === "error" || type === "warning") { - notification.setAttribute("role", "alert"); - } - - notification.innerHTML = text; - - if (this.options.newestOnTop && this.containerElement.childNodes.length > 0) { - this.containerElement.insertBefore(notification, this.containerElement.childNodes[0]); - } else { - this.containerElement.appendChild(notification); - } - - let timer = window.setTimeout(() => this.remove(), interval); - - const pause = () => window.clearTimeout(timer); - - const resume = () => { - window.clearTimeout(timer); - timer = window.setTimeout(() => this.remove(), this.options.mouseleaveDelay); - }; - - notification.addEventListener("click", () => this.remove()); - - // Pause the countdown while the pointer or keyboard focus is on the notification - notification.addEventListener("mouseenter", pause); - notification.addEventListener("mouseleave", () => { - if (!notification.matches(":focus-within")) { - resume(); - } - }); - notification.addEventListener("focusin", pause); - notification.addEventListener("focusout", resume); - - notification.addEventListener("keydown", (event) => { - if (event.key === "Escape" || event.key === "Enter" || event.key === " ") { - event.preventDefault(); - this.remove(); - } - }); - - return notification; - }; - - if (!this.options.icon) { - this.options.icon = this.options.defaultIcons[this.type]; - } - - if (this.options.icon) { - this.notificationElement = create(this.text, this.type, this.options.interval); - - // biome-ignore lint/performance/noDynamicNamespaceImportAccess: Accessing the icon dynamically from the imported icons object is necessary for flexibility - this.notificationElement.insertAdjacentHTML("afterbegin", icons[this.options.icon] || ""); - } else { - this.notificationElement = create(this.text, this.type, this.options.interval); - } - } - - remove() { - this.notificationElement.classList.add("fadeout"); - - window.setTimeout(() => { - if (this.containerElement && this.notificationElement?.parentNode) { - this.containerElement.removeChild(this.notificationElement); - } - }, this.options.fadeOutDelay); - } -} diff --git a/panel/src/ts/components/sections.ts b/panel/src/ts/components/sections.ts deleted file mode 100644 index 12800da15..000000000 --- a/panel/src/ts/components/sections.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { $$ } from "../utils/selectors"; - -export class Sections { - constructor() { - $$(".collapsible .section-header").forEach((element) => { - const section = element.parentNode as HTMLElement; - - const formName = section.closest("form")?.dataset.form; - const key = formName ? `${formName}.${section.id}` : section.id; - - if (section.id) { - const state = window.localStorage.getItem(`formwork.sectionStatus[${key}]`); - if (state) { - section.classList.toggle("collapsed", state === "collapsed"); - } - } - - element.addEventListener("click", () => { - const collapsed = section.classList.toggle("collapsed"); - if (section.id) { - window.localStorage.setItem(`formwork.sectionStatus[${key}]`, collapsed ? "collapsed" : "expanded"); - } - }); - }); - } -} diff --git a/panel/src/ts/components/tabs.ts b/panel/src/ts/components/tabs.ts deleted file mode 100644 index 1a3b9f247..000000000 --- a/panel/src/ts/components/tabs.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { $, $$ } from "../utils/selectors"; - -export class Tabs { - constructor() { - $$(".tabs").forEach((tabs) => { - const formName = tabs.closest("form")?.dataset.form; - const tabButtons = Array.from($$(".tabs-tab[data-tab]", tabs)); - - const selectTab = (name: string) => { - tabButtons.forEach((button) => { - button.classList.toggle("active", button.dataset.tab === name); - button.ariaSelected = (button.dataset.tab === name).toString(); - button.tabIndex = button.dataset.tab === name ? 0 : -1; - const tabPanel = $(`.tabs-panel[data-tab="${button.dataset.tab}"]`); - tabPanel?.classList.toggle("visible", button.dataset.tab === name); - }); - }; - - const selectedTab = window.localStorage.getItem(`formwork.tabStatus[${formName}]`); - if (selectedTab) { - if (!$(`.tabs-tab[data-tab="${selectedTab}"]`, tabs)) { - window.localStorage.removeItem(`formwork.tabStatus[${formName}]`); - } else { - selectTab(selectedTab); - } - } - - tabButtons.forEach((tabButton) => { - tabButton.addEventListener("click", () => { - selectTab(tabButton.dataset.tab as string); - window.localStorage.setItem(`formwork.tabStatus[${formName}]`, tabButton.dataset.tab as string); - }); - - tabButton.addEventListener("keydown", (event) => { - const index = tabButtons.indexOf(tabButton); - const lastIndex = tabButtons.length - 1; - - const nextIndex = { - ArrowRight: index === lastIndex ? 0 : index + 1, - ArrowLeft: index === 0 ? lastIndex : index - 1, - Home: 0, - End: lastIndex, - }[event.key]; - - if (nextIndex !== undefined) { - event.preventDefault(); - tabButtons[nextIndex].focus(); - tabButtons[nextIndex].click(); - } - }); - }); - }); - } -} diff --git a/panel/src/ts/components/tooltip.ts b/panel/src/ts/components/tooltip.ts deleted file mode 100644 index d4ccf48bd..000000000 --- a/panel/src/ts/components/tooltip.ts +++ /dev/null @@ -1,177 +0,0 @@ -import { clamp } from "../utils/math"; - -interface TooltipOptions { - container: HTMLElement; - referenceElement: HTMLElement; - position: "top" | "right" | "bottom" | "left" | "center" | { x: number; y: number }; - offset: { - x: number; - y: number; - }; - delay: number; - timeout: number | null; - removeOnMouseout: boolean; - removeOnMousedown: boolean; - zIndex: number | null; -} - -export class Tooltip { - text: string; - options: TooltipOptions; - delayTimer?: number; - timeoutTimer?: number; - element: HTMLElement; - - get removed() { - return this.element === undefined || !this.options.container.contains(this.element); - } - - constructor(text: string, options: Partial = {}) { - const defaults = { - container: document.body, - referenceElement: document.body, - position: "top", - offset: { - x: 0, - y: 0, - }, - delay: 500, - timeout: null, - removeOnMouseout: true, - removeOnMousedown: true, - zIndex: null, - }; - - this.text = text; - this.options = Object.assign({}, defaults, options); - - this.element = document.createElement("div"); - this.element.className = "tooltip"; - this.element.role = "tooltip"; - this.element.innerHTML = this.text; - } - - show() { - const options = this.options; - const container = options.container; - - this.delayTimer = window.setTimeout(() => { - // Do not show a tooltip for an element that has been removed - // or cover the menu opened by the reference element - if (!options.referenceElement.isConnected || options.referenceElement.getAttribute("aria-expanded") === "true") { - return; - } - - const getRelativePosition = (tooltip: HTMLElement) => { - const offset = options.offset; - - const referenceElement = options.referenceElement; - - const rect = referenceElement.getBoundingClientRect(); - - const top = rect.top + window.scrollY; - const left = rect.left + window.scrollX; - - const hw = (rect.width - tooltip.offsetWidth) / 2; - const hh = (rect.height - tooltip.offsetHeight) / 2; - - switch (options.position) { - case "right": - return { - top: Math.round(top + hh + offset.y), - left: Math.round(left + referenceElement.offsetWidth + offset.x), - }; - case "bottom": - return { - top: Math.round(top + referenceElement.offsetHeight + offset.y), - left: Math.round(left + hw + offset.x), - }; - case "left": - return { - top: Math.round(top + hh + offset.y), - left: Math.round(left - tooltip.offsetWidth + offset.x), - }; - case "center": - return { - top: Math.round(top + hh + offset.y), - left: Math.round(left + hw + offset.x), - }; - default: - return { - top: Math.round(top - tooltip.offsetHeight + offset.y), - left: Math.round(left + hw + offset.x), - }; - } - }; - - const getTooltipPosition = (tooltip: HTMLElement) => { - const position = - typeof options.position === "string" - ? getRelativePosition(tooltip) - : { - top: options.position.y + options.offset.y, - left: options.position.x + options.offset.x, - }; - - const min = { - top: window.scrollY + 4, - left: window.scrollX + 4, - }; - - const max = { - top: window.innerHeight + window.scrollY - tooltip.offsetHeight - 20, - left: window.innerWidth + window.scrollX - tooltip.offsetWidth - 4, - }; - - return { - top: clamp(position.top, min.top, max.top), - left: clamp(position.left, min.left, max.left), - }; - }; - - container.appendChild(this.element); - - const position = getTooltipPosition(this.element); - this.element.style.top = `${position.top}px`; - this.element.style.left = `${position.left}px`; - - if (options.zIndex !== null) { - this.element.style.zIndex = `${options.zIndex}`; - } - - if (options.timeout !== null) { - this.timeoutTimer = window.setTimeout(() => this.remove(), options.timeout); - } - - if (options.removeOnMouseout) { - this.element.addEventListener("mouseout", () => this.remove()); - } - }, options.delay); - - const referenceElement = options.referenceElement; - - if (referenceElement.tagName.toLowerCase() === "button" || referenceElement.classList.contains("button")) { - referenceElement.addEventListener("mousedown", () => { - this.remove(); - }); - referenceElement.addEventListener("blur", () => this.remove()); - } - - if (options.removeOnMouseout) { - referenceElement.addEventListener("mouseout", (event: MouseEvent) => { - if (event.relatedTarget !== this.element) { - this.remove(); - } - }); - } - if (options.removeOnMousedown) { - referenceElement.addEventListener("mousedown", () => this.remove()); - } - } - - remove() { - clearTimeout(this.delayTimer); - clearTimeout(this.timeoutTimer); - this.element?.remove(); - } -} diff --git a/panel/src/ts/components/tooltips.ts b/panel/src/ts/components/tooltips.ts deleted file mode 100644 index 619517438..000000000 --- a/panel/src/ts/components/tooltips.ts +++ /dev/null @@ -1,112 +0,0 @@ -import { debounce } from "../utils/events"; -import { $, $$ } from "../utils/selectors"; -import { Tooltip } from "./tooltip"; - -export class Tooltips { - tooltip?: Tooltip; - element?: HTMLElement; - - constructor() { - $$("[title]", document.body).forEach((element) => { - element.dataset.tooltip = element.title; - element.removeAttribute("title"); - }); - - document.addEventListener( - "mousemove", - debounce((event: MouseEvent) => { - const target = event.target as HTMLElement; - - // The event is debounced, its target may have been removed in the meantime - if (!target.isConnected) { - return; - } - - const element = target.closest("[data-tooltip]") as HTMLElement; - - const position = { x: event.pageX, y: event.pageY }; - const offset = { x: 0, y: 16 }; - - if (element) { - // Do not cover an open menu with the tooltip of the button that opened it - if (element.getAttribute("aria-expanded") === "true") { - this.tooltip?.remove(); - return; - } - - if (this.element === element && !this.tooltip?.removed) { - return; - } - - this.element = element; - - this.tooltip?.remove(); - - const openModal = $(".modal.open"); - if (openModal && !openModal.contains(element)) { - return; - } - - this.tooltip = new Tooltip(element.dataset.tooltip as string, { - referenceElement: element, - position, - offset, - delay: 0, - }); - - this.tooltip.show(); - } - - const truncableElement = target.closest(".truncate") as HTMLElement; - - if (truncableElement && truncableElement.offsetWidth < truncableElement.scrollWidth) { - if (this.element === truncableElement && !this.tooltip?.removed) { - return; - } - - this.element = truncableElement; - - this.tooltip?.remove(); - - this.tooltip = new Tooltip((truncableElement.textContent ?? "").trim(), { - referenceElement: truncableElement, - position, - offset, - delay: 0, - }); - - this.tooltip.show(); - } - }, 500), - ); - - // Immediately show tooltip on focused buttons - document.addEventListener("focusin", (event) => { - const target = event.target as HTMLElement; - - const element = target.closest("[data-tooltip]") as HTMLElement; - - if (element && (element.tagName.toLowerCase() === "button" || element.classList.contains("button"))) { - if (this.element === element || element.getAttribute("aria-expanded") === "true") { - return; - } - - this.element = element; - - this.tooltip?.remove(); - - this.tooltip = new Tooltip(element.dataset.tooltip as string, { - referenceElement: element, - position: "bottom", - offset: { - x: 0, - y: 4, - }, - delay: 0, - }); - - this.tooltip.show(); - } - }); - } -} diff --git a/panel/src/ts/components/color-scheme.ts b/panel/src/ts/components/ui/color-scheme.ts similarity index 91% rename from panel/src/ts/components/color-scheme.ts rename to panel/src/ts/components/ui/color-scheme.ts index 3b35b6719..a246c132a 100644 --- a/panel/src/ts/components/color-scheme.ts +++ b/panel/src/ts/components/ui/color-scheme.ts @@ -1,9 +1,12 @@ -import { app } from "../app"; -import { getCookies, setCookie } from "../utils/cookies"; +import { app } from "../../services/app"; +import { getCookies, setCookie } from "../../utils/cookies"; type ColorSchemeName = "light" | "dark"; type ColorSchemePreference = ColorSchemeName | "light dark"; +/** + * Stores the preferred color scheme in a cookie, so that PHP can render it, and follows its changes + */ export class ColorScheme { constructor() { const getSupportedColorSchemes = (): ColorSchemePreference => { diff --git a/panel/src/ts/components/ui/dropdowns.ts b/panel/src/ts/components/ui/dropdowns.ts new file mode 100644 index 000000000..ea44496e0 --- /dev/null +++ b/panel/src/ts/components/ui/dropdowns.ts @@ -0,0 +1,340 @@ +import { getOuterHeight, getOuterWidth } from "../../utils/dimensions"; +import { throttle } from "../../utils/events"; +import { $, $$ } from "../../utils/selectors"; + +const itemSelector = ".dropdown-item:not([disabled]):not([aria-disabled=true])"; + +// Stops repositioning the open menu when the window is resized +let stopResizeHandler: (() => void) | undefined; + +/** + * Handles the dropdown menus opened by `.dropdown-button` elements + * + * The buttons and the menus follow the ARIA menu button pattern. Buttons created after the page is + * loaded, like the one in the toolbar of the editor, are set up when they get the focus + */ +export class Dropdowns { + /** + * Initialize the buttons of the page and bind the events of all dropdowns + */ + constructor() { + $$(".dropdown-button").forEach(initButton); + + document.addEventListener("click", handleClick); + document.addEventListener("keydown", handleKeydown); + document.addEventListener("focusin", handleFocusin); + } +} + +/** + * Toggle the menu of a button, and close the menus when something else is clicked + * + * A menu opened with the keyboard gets the focus on its first item, and an item chosen with the + * keyboard gives the focus back to the button + */ +function handleClick(event: MouseEvent) { + const target = event.target as HTMLElement; + + const button = target.closest(".dropdown-button"); + const dropdown = button ? getDropdown(button) : null; + const wasVisible = dropdown ? isVisible(dropdown) : false; + + const menu = target.closest(".dropdown-menu"); + const hadFocus = menu?.contains(document.activeElement) ?? false; + + const isKeyboard = event.detail === 0; + + closeAll(); + + if (button) { + event.preventDefault(); + + if (dropdown && !wasVisible) { + open(button, dropdown); + + // Some browsers do not focus a button when it is clicked, but the arrows need it + if (isKeyboard) { + getItems(dropdown)[0]?.focus(); + } else { + button.focus(); + } + } + } else if (menu && hadFocus && isKeyboard) { + getButton(menu)?.focus(); + } +} + +/** + * Handle the keyboard on the buttons and on the open menus + */ +function handleKeydown(event: KeyboardEvent) { + if (!(event.target instanceof HTMLElement)) { + return; + } + + const button = event.target.closest(".dropdown-button"); + + if (button) { + handleButtonKeydown(event, button); + return; + } + + const menu = event.target.closest(".dropdown-menu"); + + if (menu) { + handleMenuKeydown(event, menu); + } +} + +/** + * Close the menus when the focus moves out of them, and set up the buttons created after the page + * was loaded + */ +function handleFocusin(event: FocusEvent) { + const target = event.target as HTMLElement; + + if (!target.closest(".dropdown")) { + closeAll(); + } + + const button = target.closest(".dropdown-button"); + + if (button) { + initButton(button); + } +} + +/** + * Get the menu of a button + */ +function getDropdown(button: HTMLElement): HTMLElement | null { + return button.dataset.dropdown ? document.getElementById(button.dataset.dropdown) : null; +} + +/** + * Get the button of a menu, if it has one + */ +function getButton(dropdown: HTMLElement): HTMLElement | null { + return dropdown.id ? $(`.dropdown-button[data-dropdown="${dropdown.id}"]`) : null; +} + +/** + * Return whether a menu is displayed + */ +function isVisible(dropdown: HTMLElement): boolean { + return getComputedStyle(dropdown).display !== "none"; +} + +/** + * Link a button to its menu for assistive technologies + */ +function initButton(button: HTMLElement) { + const dropdown = getDropdown(button); + + if (dropdown) { + button.setAttribute("aria-haspopup", "menu"); + button.setAttribute("aria-controls", dropdown.id); + button.setAttribute("aria-expanded", String(isVisible(dropdown))); + } +} + +/** + * Make the items of a menu reachable only with the arrow keys, like the ARIA menu pattern requires + */ +function initMenu(menu: HTMLElement) { + menu.setAttribute("role", "menu"); + + $$(".dropdown-item", menu).forEach((item) => { + item.setAttribute("role", "menuitem"); + item.tabIndex = -1; + }); +} + +/** + * Open a menu, keeping it inside the viewport when the window is resized + */ +function open(button: HTMLElement, dropdown: HTMLElement) { + // A tooltip of the button would cover the first items of the menu + $$(".tooltip").forEach((tooltip) => { + tooltip.remove(); + }); + + const resizeHandler = throttle(() => setDropdownPosition(dropdown), 100); + + stopResizeHandler?.(); + + initMenu(dropdown); + + dropdown.style.display = "block"; + setDropdownPosition(dropdown); + window.addEventListener("resize", resizeHandler); + + stopResizeHandler = () => window.removeEventListener("resize", resizeHandler); + + initButton(button); + button.setAttribute("aria-expanded", "true"); +} + +/** + * Close all menus + */ +function closeAll() { + stopResizeHandler?.(); + stopResizeHandler = undefined; + + $$(".dropdown-menu").forEach((element) => { + element.style.display = ""; + }); + $$(".dropdown-button[aria-expanded=true]").forEach((button) => { + button.setAttribute("aria-expanded", "false"); + }); +} + +/** + * Get the visible and enabled items of a menu + */ +function getItems(dropdown: HTMLElement): HTMLElement[] { + return Array.from($$(itemSelector, dropdown)).filter((item) => item.getClientRects().length > 0); +} + +/** + * Open and close a menu with the keyboard, and focus its first or last item + */ +function handleButtonKeydown(event: KeyboardEvent, button: HTMLElement) { + const dropdown = getDropdown(button); + + if (!dropdown) { + return; + } + + if (event.key === "Escape" && isVisible(dropdown)) { + closeAll(); + } else if (event.key === "ArrowDown" || event.key === "ArrowUp") { + event.preventDefault(); + + if (!isVisible(dropdown)) { + closeAll(); + open(button, dropdown); + } + + const items = getItems(dropdown); + + (event.key === "ArrowDown" ? items[0] : items[items.length - 1])?.focus(); + } +} + +/** + * Move the focus between the items of an open menu + * + * The arrows move to the next or previous item (wrapping around), Home and End to the first and + * last one, a printable character to the next item starting with it. Space chooses a link, Escape + * closes the menu returning the focus to its button, and Tab closes it moving on from the button + */ +function handleMenuKeydown(event: KeyboardEvent, menu: HTMLElement) { + const button = getButton(menu); + + // Menus without a button (e.g. select inputs) manage their own keyboard interaction + if (!button || !isVisible(menu)) { + return; + } + + const items = getItems(menu); + const index = items.indexOf(document.activeElement as HTMLElement); + + switch (event.key) { + case "Escape": + event.preventDefault(); + closeAll(); + button.focus(); + break; + case "Tab": + closeAll(); + button.focus(); + break; + case "ArrowDown": + event.preventDefault(); + items[(index + 1) % items.length]?.focus(); + break; + case "ArrowUp": + event.preventDefault(); + items[(index <= 0 ? items.length : index) - 1]?.focus(); + break; + case "Home": + event.preventDefault(); + items[0]?.focus(); + break; + case "End": + event.preventDefault(); + items[items.length - 1]?.focus(); + break; + case " ": + // Buttons are chosen natively, links need to be clicked + if (document.activeElement instanceof HTMLAnchorElement) { + event.preventDefault(); + document.activeElement.click(); + } + break; + default: + if (event.key.length === 1 && !event.ctrlKey && !event.metaKey && !event.altKey) { + event.preventDefault(); + focusByText(items, index, event.key); + } + } +} + +// Characters typed in a menu to move to an item and the timer that forgets them +let typedText = ""; +let typedTimer: number | undefined; + +/** + * Focus the next item whose text starts with the characters typed in the last half second + * + * Typing the same character repeatedly cycles through the items starting with it + * + * @param items Items of the menu + * @param index Index of the focused item + * @param key Typed character + */ +function focusByText(items: HTMLElement[], index: number, key: string) { + window.clearTimeout(typedTimer); + typedTimer = window.setTimeout(() => { + typedText = ""; + }, 500); + + typedText += key.toLowerCase(); + + const search = [...typedText].every((character) => character === typedText[0]) ? typedText[0] : typedText; + + const startsWith = (item: HTMLElement) => (item.textContent ?? "").trim().toLowerCase().startsWith(search); + + // A longer text may still match the focused item, a repeated character looks for the next one + const candidates = search.length > 1 ? [...items.slice(Math.max(index, 0)), ...items.slice(0, Math.max(index, 0))] : [...items.slice(index + 1), ...items.slice(0, index + 1)]; + + candidates.find(startsWith)?.focus(); +} + +/** + * Position a menu inside the viewport + */ +function setDropdownPosition(dropdown: HTMLElement) { + dropdown.style.left = "0"; + dropdown.style.right = ""; + + const dropdownRect = dropdown.getBoundingClientRect(); + const dropdownTop = dropdownRect.top + window.scrollY; + const dropdownLeft = dropdownRect.left + window.scrollX; + const dropdownWidth = getOuterWidth(dropdown); + const dropdownHeight = getOuterHeight(dropdown); + + const windowWidth = document.documentElement.clientWidth; + const windowHeight = document.documentElement.clientHeight; + + if (dropdownLeft + dropdownWidth > windowWidth) { + dropdown.style.left = "auto"; + dropdown.style.right = "0"; + } + + if (dropdownTop < window.scrollY || window.scrollY < dropdownTop + dropdownHeight - windowHeight) { + window.scrollTo(window.scrollX, dropdownTop + dropdownHeight - windowHeight); + } +} diff --git a/panel/src/ts/components/icons.ts b/panel/src/ts/components/ui/icons.ts similarity index 100% rename from panel/src/ts/components/icons.ts rename to panel/src/ts/components/ui/icons.ts diff --git a/panel/src/ts/components/ui/navigation.ts b/panel/src/ts/components/ui/navigation.ts new file mode 100644 index 000000000..1306e0ec0 --- /dev/null +++ b/panel/src/ts/components/ui/navigation.ts @@ -0,0 +1,108 @@ +import { effect, signal } from "@preact/signals-core"; +import { $, $$ } from "../../utils/selectors"; + +/** + * Handles the sidebar toggle for small screens and the save shortcut + */ +export class Navigation { + /** + * Initialize the sidebar and the save shortcut + */ + constructor() { + this.initSidebar(); + this.initSaveShortcut(); + } + + /** + * Open and close the sidebar with its toggle, showing a backdrop while it is open + * + * While the sidebar is open the rest of the page is inert, so that the focus cannot reach what + * the backdrop covers. The sidebar is closed with Escape, by clicking the backdrop or when the + * window grows enough to show it always, which does not need the toggle + */ + private initSidebar() { + const toggle = $(".sidebar-toggle"); + const sidebar = $(".sidebar"); + + if (!toggle || !sidebar) { + return; + } + + sidebar.id ||= "panel-sidebar"; + toggle.setAttribute("aria-controls", sidebar.id); + + const isOpen = signal(sidebar.classList.contains("show")); + + effect(() => { + sidebar.classList.toggle("show", isOpen.value); + toggle.setAttribute("aria-expanded", String(isOpen.value)); + + $$(".panel-header, .panel-main").forEach((element) => { + element.inert = isOpen.value; + }); + + if (isOpen.value) { + this.showBackdrop(() => { + isOpen.value = false; + }); + } else { + this.hideBackdrop(); + } + }); + + toggle.addEventListener("click", () => { + isOpen.value = !isOpen.value; + }); + + document.addEventListener("keydown", (event) => { + if (event.key === "Escape" && isOpen.value) { + isOpen.value = false; + toggle.focus(); + } + }); + + window.addEventListener("resize", () => { + // The toggle is hidden when the sidebar is always visible + if (isOpen.value && toggle.getClientRects().length === 0) { + isOpen.value = false; + } + }); + } + + /** + * Add the backdrop behind the sidebar, if missing + * + * @param onClick Function called when the backdrop is clicked + */ + private showBackdrop(onClick: () => void) { + if (!$(".sidebar-backdrop")) { + const backdrop = document.createElement("div"); + backdrop.className = "sidebar-backdrop hide-from-md"; + backdrop.addEventListener("click", onClick); + document.body.appendChild(backdrop); + } + } + + /** + * Remove the backdrop behind the sidebar + */ + private hideBackdrop() { + $(".sidebar-backdrop")?.remove(); + } + + /** + * Click the save command with Ctrl/Cmd+S, if the page has one + */ + private initSaveShortcut() { + if (!$("[data-command=save]")) { + return; + } + + document.addEventListener("keydown", (event) => { + if (!event.altKey && (event.ctrlKey || event.metaKey) && event.key === "s") { + $("[data-command=save]")?.click(); + event.preventDefault(); + } + }); + } +} diff --git a/panel/src/ts/components/ui/notification.ts b/panel/src/ts/components/ui/notification.ts new file mode 100644 index 000000000..148a01f06 --- /dev/null +++ b/panel/src/ts/components/ui/notification.ts @@ -0,0 +1,185 @@ +import { $ } from "../../utils/selectors"; +import * as icons from "./icons"; + +/** + * Represents the kinds of notifications + */ +export type NotificationType = "info" | "success" | "warning" | "error"; + +/** + * Holds the options of a notification + */ +type NotificationOptions = { + interval: number; + icon?: keyof typeof icons; + newestOnTop: boolean; + fadeOutDelay: number; + mouseleaveDelay: number; + typeClass: Record; + defaultIcons: Record; +}; + +/** + * Shows a message in the corner of the page for a few seconds + * + * It stays while the pointer or the focus is on it and can be closed with a click, Escape or Enter + */ +export class Notification { + /** + * Get the element containing the notifications + */ + static getContainer(): HTMLElement { + let container = $(".notification-container"); + + if (!container) { + // Layouts render this container with its live region attributes, this is only a fallback + container = document.createElement("div"); + container.className = "notification-container"; + document.body.appendChild(container); + } + + return container; + } + + /** + * Text of the notification + */ + text: string; + /** + * Kind of the notification + */ + type: NotificationType; + /** + * Options of the notification + */ + options: NotificationOptions; + /** + * Element containing the notification + */ + containerElement: HTMLElement | null; + /** + * Element of the notification + */ + notificationElement!: HTMLElement; + + /** + * Create a notification, which is shown with `show()` + */ + constructor(text: string, type: NotificationType, options: Partial = {}) { + const defaults: NotificationOptions = { + interval: 5000, + icon: undefined, + newestOnTop: true, + fadeOutDelay: 300, + mouseleaveDelay: 1000, + typeClass: { + info: "info", + success: "success", + warning: "warning", + error: "danger", + }, + defaultIcons: { + info: "infoCircle", + success: "checkCircle", + warning: "exclamationTriangle", + error: "exclamationOctagon", + }, + }; + + this.text = text; + this.type = type; + + this.options = Object.assign({}, defaults, options); + + this.containerElement = null; + } + + /** + * Show the notification + */ + show() { + if (!this.options.icon) { + this.options.icon = this.options.defaultIcons[this.type]; + } + + this.notificationElement = this.create(); + + if (this.options.icon) { + // biome-ignore lint/performance/noDynamicNamespaceImportAccess: Accessing the icon dynamically from the imported icons object is necessary for flexibility + this.notificationElement.insertAdjacentHTML("afterbegin", icons[this.options.icon] || ""); + } + } + + /** + * Add the element of the notification to the container + */ + private create() { + this.containerElement = Notification.getContainer(); + + const notification = document.createElement("div"); + notification.className = `notification notification-${this.options.typeClass[this.type]}`; + notification.tabIndex = 0; + + if (this.type === "error" || this.type === "warning") { + notification.setAttribute("role", "alert"); + } + + notification.innerHTML = this.text; + + if (this.options.newestOnTop && this.containerElement.childNodes.length > 0) { + this.containerElement.insertBefore(notification, this.containerElement.childNodes[0]); + } else { + this.containerElement.appendChild(notification); + } + + this.bindEvents(notification); + + return notification; + } + + /** + * Remove the notification after the interval, unless the pointer or the focus is on it, and + * when it is clicked or dismissed with the keyboard + */ + private bindEvents(notification: HTMLElement) { + let timer = window.setTimeout(() => this.remove(), this.options.interval); + + const pause = () => window.clearTimeout(timer); + + const resume = () => { + window.clearTimeout(timer); + timer = window.setTimeout(() => this.remove(), this.options.mouseleaveDelay); + }; + + notification.addEventListener("click", () => this.remove()); + + notification.addEventListener("mouseenter", pause); + notification.addEventListener("mouseleave", () => { + if (!notification.matches(":focus-within")) { + resume(); + } + }); + notification.addEventListener("focusin", pause); + notification.addEventListener("focusout", resume); + + notification.addEventListener("keydown", (event) => { + if (event.key === "Escape" || event.key === "Enter" || event.key === " ") { + event.preventDefault(); + this.remove(); + } + }); + } + + /** + * Fade out and remove the notification + */ + remove() { + this.notificationElement.classList.add("fadeout"); + + window.setTimeout(() => { + if (this.containerElement && this.notificationElement?.parentNode) { + this.containerElement.removeChild(this.notificationElement); + } + }, this.options.fadeOutDelay); + } +} diff --git a/panel/src/ts/components/notifications.ts b/panel/src/ts/components/ui/notifications.ts similarity index 83% rename from panel/src/ts/components/notifications.ts rename to panel/src/ts/components/ui/notifications.ts index aa1614387..f9cb68906 100644 --- a/panel/src/ts/components/notifications.ts +++ b/panel/src/ts/components/ui/notifications.ts @@ -1,6 +1,9 @@ -import { $$ } from "../utils/selectors"; +import { $$ } from "../../utils/selectors"; import { Notification } from "./notification"; +/** + * Shows the notifications rendered by PHP in `meta[name=notification]` elements + */ export class Notifications { constructor() { let delay = 0; diff --git a/panel/src/ts/components/ui/sections.ts b/panel/src/ts/components/ui/sections.ts new file mode 100644 index 000000000..512e16202 --- /dev/null +++ b/panel/src/ts/components/ui/sections.ts @@ -0,0 +1,34 @@ +import { effect, signal } from "@preact/signals-core"; +import { $, $$ } from "../../utils/selectors"; + +/** + * Handles the collapsible sections, whose state is remembered + */ +export class Sections { + constructor() { + $$(".collapsible .section-header").forEach((element) => { + const section = element.parentNode as HTMLElement; + + const formName = section.closest("form")?.dataset.form; + const key = formName ? `${formName}.${section.id}` : section.id; + + const state = section.id ? window.localStorage.getItem(`formwork.sectionStatus[${key}]`) : null; + + const collapsed = signal(state ? state === "collapsed" : section.classList.contains("collapsed")); + + const toggleButton = $(".section-toggle", element); + + effect(() => { + section.classList.toggle("collapsed", collapsed.value); + toggleButton?.setAttribute("aria-expanded", String(!collapsed.value)); + }); + + element.addEventListener("click", () => { + collapsed.value = !collapsed.value; + if (section.id) { + window.localStorage.setItem(`formwork.sectionStatus[${key}]`, collapsed.value ? "collapsed" : "expanded"); + } + }); + }); + } +} diff --git a/panel/src/ts/components/ui/spinner.ts b/panel/src/ts/components/ui/spinner.ts new file mode 100644 index 000000000..87e53b2fd --- /dev/null +++ b/panel/src/ts/components/ui/spinner.ts @@ -0,0 +1,24 @@ +import { check, exclamation, info } from "./icons"; + +/** + * Represents the status of a spinner + */ +export type SpinnerStatus = "pending" | "info" | "success" | "danger"; + +/** + * Icon of each status + */ +const statusIcons: Record = { + pending: "", + info, + success: check, + danger: exclamation, +}; + +/** + * Show a status in a spinner + */ +export function renderSpinner(spinner: HTMLElement, status: SpinnerStatus) { + spinner.className = status === "pending" ? "spinner" : `spinner spinner-${status}`; + spinner.innerHTML = statusIcons[status]; +} diff --git a/panel/src/ts/components/statistics-chart.ts b/panel/src/ts/components/ui/statistics-chart.ts similarity index 51% rename from panel/src/ts/components/statistics-chart.ts rename to panel/src/ts/components/ui/statistics-chart.ts index 3d1dc0510..da9385109 100644 --- a/panel/src/ts/components/statistics-chart.ts +++ b/panel/src/ts/components/ui/statistics-chart.ts @@ -1,17 +1,29 @@ import { circleSmallFill } from "./icons"; import { Tooltip } from "./tooltip"; +/** + * Describes the data of a chart + */ interface NumericChartistData { labels: (string | number)[]; series: number[][]; } +/** + * Draws the line chart of the statistics + */ export class StatisticsChart { + /** + * Draw the chart in a container + */ constructor(container: HTMLElement, data: NumericChartistData) { container.classList.add("is-loading"); - this.init(container, data); + void this.init(container, data); } + /** + * Draw the chart + */ private async init(container: HTMLElement, data: NumericChartistData) { const spacing = 100; @@ -53,18 +65,40 @@ export class StatisticsChart { } }); - container.addEventListener("mouseover", (event) => { - const target = event.target as SVGElement; - if (target.getAttribute("class") === "ct-point" && target.hasAttribute("ct:index")) { - const strokeWidth = parseFloat(getComputedStyle(target).strokeWidth); - const index = parseInt(target.getAttribute("ct:index") as string, 10); - const text = `
${data.labels[index]}
${circleSmallFill} ${data.series[0][index]} ${circleSmallFill}${data.series[1][index]}
`; - const tooltip = new Tooltip(text, { - referenceElement: event.target as HTMLElement, - offset: { x: 0, y: -strokeWidth }, - }); - tooltip.show(); - } + container.addEventListener("mouseover", (event) => this.showPointTooltip(event, data)); + } + + /** + * Show a tooltip with the values of the point under the pointer + */ + private showPointTooltip(event: MouseEvent, data: NumericChartistData) { + const target = event.target as SVGElement; + + if (target.getAttribute("class") !== "ct-point" || !target.hasAttribute("ct:index")) { + return; + } + + const strokeWidth = parseFloat(getComputedStyle(target).strokeWidth); + const index = parseInt(target.getAttribute("ct:index") as string, 10); + + const text = `
${data.labels[index]}
${circleSmallFill} ${data.series[0][index]} ${circleSmallFill}${data.series[1][index]}
`; + + const tooltip = new Tooltip(text, { + referenceElement: target as unknown as HTMLElement, + offset: { x: 0, y: -strokeWidth }, }); + + tooltip.show(); + } +} + +/** + * Draw the chart with the data rendered in the `data-chart-data` attribute + */ +export function initStatisticsChart(container: HTMLElement) { + const chartData = container.dataset.chartData; + + if (chartData) { + new StatisticsChart(container, JSON.parse(chartData)); } } diff --git a/panel/src/ts/components/ui/tabs.ts b/panel/src/ts/components/ui/tabs.ts new file mode 100644 index 000000000..656e3fe78 --- /dev/null +++ b/panel/src/ts/components/ui/tabs.ts @@ -0,0 +1,80 @@ +import { effect, signal } from "@preact/signals-core"; +import { getNextIndex } from "../../utils/keyboard"; +import { $, $$ } from "../../utils/selectors"; + +/** + * Handles the tabs of the page with keyboard navigation, whose selection is remembered + */ +export class Tabs { + /** + * Initialize the tabs of the page + */ + constructor() { + for (const tabs of $$(".tabs")) { + this.initTabs(tabs); + } + } + + /** + * Initialize a group of tabs + */ + private initTabs(tabs: HTMLElement) { + const formName = tabs.closest("form")?.dataset.form; + const storageKey = `formwork.tabStatus[${formName}]`; + const tabButtons = Array.from($$(".tabs-tab[data-tab]", tabs)); + + // `null` means that the tab defined by the markup is selected + const selected = signal(null); + + effect(() => { + if (selected.value !== null) { + this.showTab(tabButtons, selected.value); + } + }); + + const savedTab = window.localStorage.getItem(storageKey); + + if (savedTab) { + if ($(`.tabs-tab[data-tab="${savedTab}"]`, tabs)) { + selected.value = savedTab; + } else { + window.localStorage.removeItem(storageKey); + } + } + + tabButtons.forEach((tabButton) => { + tabButton.addEventListener("click", () => { + selected.value = tabButton.dataset.tab as string; + window.localStorage.setItem(storageKey, tabButton.dataset.tab as string); + }); + + tabButton.addEventListener("keydown", (event) => { + const nextIndex = getNextIndex(event.key, tabButtons.indexOf(tabButton), tabButtons.length - 1); + + if (nextIndex !== undefined) { + event.preventDefault(); + tabButtons[nextIndex].focus(); + tabButtons[nextIndex].click(); + } + }); + }); + } + + /** + * Select a tab and show its panel + * + * @param tabButtons Buttons of the tabs + * @param name Name of the tab to select + */ + private showTab(tabButtons: HTMLElement[], name: string) { + tabButtons.forEach((button) => { + const isSelected = button.dataset.tab === name; + + button.classList.toggle("active", isSelected); + button.ariaSelected = isSelected.toString(); + button.tabIndex = isSelected ? 0 : -1; + + $(`.tabs-panel[data-tab="${button.dataset.tab}"]`)?.classList.toggle("visible", isSelected); + }); + } +} diff --git a/panel/src/ts/components/ui/tooltip.ts b/panel/src/ts/components/ui/tooltip.ts new file mode 100644 index 000000000..4aee113bf --- /dev/null +++ b/panel/src/ts/components/ui/tooltip.ts @@ -0,0 +1,195 @@ +import { clamp } from "../../utils/math"; + +/** + * Holds the options of a tooltip + */ +export interface TooltipOptions { + container: HTMLElement; + referenceElement: HTMLElement; + position: "top" | "right" | "bottom" | "left" | "center" | { x: number; y: number }; + offset: { + x: number; + y: number; + }; + delay: number; + timeout: number | null; + removeOnMouseout: boolean; + removeOnMousedown: boolean; + zIndex: number | null; +} + +/** + * Shows a text next to an element + */ +export class Tooltip { + /** + * Text of the tooltip, as HTML + */ + text: string; + /** + * Options of the tooltip + */ + options: TooltipOptions; + /** + * Timer showing the tooltip after its delay + */ + delayTimer?: number; + /** + * Timer removing the tooltip after its timeout + */ + timeoutTimer?: number; + /** + * Element of the tooltip + */ + element: HTMLElement; + + /** + * Return whether the tooltip has been removed + */ + get removed() { + return this.element === undefined || !this.options.container.contains(this.element); + } + + /** + * Create a tooltip, which is shown with `show()` + */ + constructor(text: string, options: Partial = {}) { + const defaults = { + container: document.body, + referenceElement: document.body, + position: "top", + offset: { + x: 0, + y: 0, + }, + delay: 500, + timeout: null, + removeOnMouseout: true, + removeOnMousedown: true, + zIndex: null, + }; + + this.text = text; + this.options = Object.assign({}, defaults, options); + + this.element = document.createElement("div"); + this.element.className = "tooltip"; + this.element.role = "tooltip"; + this.element.innerHTML = this.text; + } + + /** + * Show the tooltip after its delay + */ + show() { + const { delay, referenceElement } = this.options; + + this.delayTimer = window.setTimeout(() => this.place(), delay); + + if (referenceElement.tagName.toLowerCase() === "button" || referenceElement.classList.contains("button")) { + referenceElement.addEventListener("mousedown", () => this.remove()); + referenceElement.addEventListener("blur", () => this.remove()); + } + + if (this.options.removeOnMouseout) { + referenceElement.addEventListener("mouseout", (event: MouseEvent) => { + if (event.relatedTarget !== this.element) { + this.remove(); + } + }); + } + + if (this.options.removeOnMousedown) { + referenceElement.addEventListener("mousedown", () => this.remove()); + } + } + + /** + * Add the tooltip to its container and set its position and lifetime + * + * Nothing is shown for a reference element that has been removed, or whose menu is open and + * would be covered + */ + private place() { + const { container, referenceElement, zIndex, timeout, removeOnMouseout } = this.options; + + if (!referenceElement.isConnected || referenceElement.getAttribute("aria-expanded") === "true") { + return; + } + + container.appendChild(this.element); + + const position = this.getPosition(); + this.element.style.top = `${position.top}px`; + this.element.style.left = `${position.left}px`; + + if (zIndex !== null) { + this.element.style.zIndex = `${zIndex}`; + } + + if (timeout !== null) { + this.timeoutTimer = window.setTimeout(() => this.remove(), timeout); + } + + if (removeOnMouseout) { + this.element.addEventListener("mouseout", () => this.remove()); + } + } + + /** + * Get the position of the tooltip, kept inside the window + */ + private getPosition() { + const { position, offset } = this.options; + + const { top, left } = typeof position === "string" ? this.getPositionAroundReference(position) : { top: position.y + offset.y, left: position.x + offset.x }; + + const minTop = window.scrollY + 4; + const minLeft = window.scrollX + 4; + const maxTop = window.innerHeight + window.scrollY - this.element.offsetHeight - 20; + const maxLeft = window.innerWidth + window.scrollX - this.element.offsetWidth - 4; + + return { + top: clamp(top, minTop, maxTop), + left: clamp(left, minLeft, maxLeft), + }; + } + + /** + * Get the position of the tooltip on a side of the reference element, or at its center + */ + private getPositionAroundReference(side: "top" | "right" | "bottom" | "left" | "center") { + const { referenceElement, offset } = this.options; + const { offsetWidth: width, offsetHeight: height } = this.element; + + const rect = referenceElement.getBoundingClientRect(); + + const top = rect.top + window.scrollY; + const left = rect.left + window.scrollX; + + const centeredLeft = left + (rect.width - width) / 2 + offset.x; + const centeredTop = top + (rect.height - height) / 2 + offset.y; + + switch (side) { + case "right": + return { top: Math.round(centeredTop), left: Math.round(left + referenceElement.offsetWidth + offset.x) }; + case "bottom": + return { top: Math.round(top + referenceElement.offsetHeight + offset.y), left: Math.round(centeredLeft) }; + case "left": + return { top: Math.round(centeredTop), left: Math.round(left - width + offset.x) }; + case "center": + return { top: Math.round(centeredTop), left: Math.round(centeredLeft) }; + default: + return { top: Math.round(top - height + offset.y), left: Math.round(centeredLeft) }; + } + } + + /** + * Remove the tooltip + */ + remove() { + clearTimeout(this.delayTimer); + clearTimeout(this.timeoutTimer); + this.element?.remove(); + } +} diff --git a/panel/src/ts/components/ui/tooltips.ts b/panel/src/ts/components/ui/tooltips.ts new file mode 100644 index 000000000..de1581135 --- /dev/null +++ b/panel/src/ts/components/ui/tooltips.ts @@ -0,0 +1,141 @@ +import { debounce } from "../../utils/events"; +import { $, $$ } from "../../utils/selectors"; +import { Tooltip, type TooltipOptions } from "./tooltip"; + +/** + * Handles the tooltips of the page + * + * They are shown for the elements with `data-tooltip` (the `title` attributes are converted), for + * truncated texts and for focused buttons + */ +export class Tooltips { + /** + * Tooltip currently shown + */ + tooltip?: Tooltip; + /** + * Element of the tooltip currently shown + */ + element?: HTMLElement; + + /** + * Convert the titles and listen to the pointer and focus events + */ + constructor() { + $$("[title]", document.body).forEach((element) => { + element.dataset.tooltip = element.title; + element.removeAttribute("title"); + }); + + document.addEventListener( + "mousemove", + debounce((event: MouseEvent) => this.handleMousemove(event), 500), + ); + + document.addEventListener("focusin", (event) => this.handleFocusin(event)); + } + + /** + * Show the tooltip of the element under the pointer and the one of its truncated text + */ + private handleMousemove(event: MouseEvent) { + const target = event.target as HTMLElement; + + // The event is debounced, its target may have been removed in the meantime + if (!target.isConnected) { + return; + } + + const pointer = { + position: { x: event.pageX, y: event.pageY }, + offset: { x: 0, y: 16 }, + }; + + const element = target.closest("[data-tooltip]"); + + if (element) { + // Do not cover an open menu with the tooltip of the button that opened it + if (element.getAttribute("aria-expanded") === "true") { + this.tooltip?.remove(); + return; + } + + if (this.isShownFor(element)) { + return; + } + + this.track(element); + + const openModal = $(".modal.open"); + + if (openModal && !openModal.contains(element)) { + return; + } + + this.display(element, element.dataset.tooltip as string, pointer); + } + + const truncableElement = target.closest(".truncate"); + + if (truncableElement && truncableElement.offsetWidth < truncableElement.scrollWidth) { + if (this.isShownFor(truncableElement)) { + return; + } + + this.track(truncableElement); + + this.display(truncableElement, (truncableElement.textContent ?? "").trim(), pointer); + } + } + + /** + * Show immediately the tooltip of a button that gets the focus + */ + private handleFocusin(event: FocusEvent) { + const target = event.target as HTMLElement; + + const element = target.closest("[data-tooltip]"); + + if (!element || !(element.tagName.toLowerCase() === "button" || element.classList.contains("button"))) { + return; + } + + if (this.element === element || element.getAttribute("aria-expanded") === "true") { + return; + } + + this.track(element); + + this.display(element, element.dataset.tooltip as string, { + position: "bottom", + offset: { x: 0, y: 4 }, + }); + } + + /** + * Return whether the tooltip shown belongs to an element + */ + private isShownFor(element: HTMLElement) { + return this.element === element && !this.tooltip?.removed; + } + + /** + * Make an element the owner of the tooltip, removing the previous one + */ + private track(element: HTMLElement) { + this.element = element; + this.tooltip?.remove(); + } + + /** + * Show a tooltip for an element + * + * @param element Element the tooltip refers to + * @param text Text of the tooltip + * @param options Position and offset of the tooltip + */ + private display(element: HTMLElement, text: string, options: Pick) { + this.tooltip = new Tooltip(text, { referenceElement: element, delay: 0, ...options }); + this.tooltip.show(); + } +} diff --git a/panel/src/ts/components/views/backups.ts b/panel/src/ts/components/views/backups.ts deleted file mode 100644 index d09935cc9..000000000 --- a/panel/src/ts/components/views/backups.ts +++ /dev/null @@ -1,95 +0,0 @@ -import { app } from "../../app"; -import { triggerDownload } from "../../utils/forms"; -import { Request } from "../../utils/request"; -import { $, $$ } from "../../utils/selectors"; -import { check, exclamation } from "../icons"; -import { Notification } from "../notification"; - -export class Backups { - constructor() { - const makeBackupCommand = $("[data-view=backups] [data-command=make-backup]"); - - if (makeBackupCommand) { - makeBackupCommand.addEventListener("click", function () { - const button = this as HTMLButtonElement; - - const getSpinner = () => { - let spinner = $(".spinner"); - - if (!spinner) { - spinner = document.createElement("div"); - button.insertAdjacentElement("afterend", spinner); - } - - spinner.className = "spinner"; - spinner.innerText = ""; - - return spinner; - }; - - const spinner = getSpinner(); - - button.disabled = true; - - new Request( - { - method: "POST", - url: `${app.config.baseUri}backup/make/`, - data: { "csrf-token": app.config.csrfToken as string }, - }, - (response) => { - if (response.status === "success") { - button.disabled = false; - - spinner.classList.add("spinner-success"); - spinner.innerHTML = check; - - const template = $("#backups-row") as HTMLTemplateElement; - if (template) { - const table = $("#backups-table") as HTMLTableElement; - - const node = template.content.cloneNode(true) as HTMLElement; - - ($(".backup-uri", node) as HTMLAnchorElement).href = response.data.uri; - ($(".backup-uri", node) as HTMLElement).innerText = response.data.filename; - - ($(".backup-date", node) as HTMLElement).innerText = response.data.date; - ($(".backup-size", node) as HTMLElement).innerText = response.data.size; - ($(".backup-delete", node) as HTMLElement).dataset.modalAction = response.data.deleteUri; - - ($(".backup-last-time") as HTMLElement).innerText = app.translation.get("date.now", "Now"); - - ($("tbody", table) as HTMLElement).prepend(node); - - const limit = response.data.maxFiles; - - $$("tr", table).forEach((row, index) => { - if (index + 1 > limit) { - row.remove(); - } - }); - - ($("#backups-section") as HTMLDivElement).hidden = false; - } - } - - if (response.status === "error") { - spinner.classList.add("spinner-danger"); - spinner.innerHTML = exclamation; - button.disabled = false; - } - - const notification = new Notification(response.message, response.status, { icon: "checkCircle" }); - notification.show(); - - if (response.status === "success") { - window.setTimeout(() => { - triggerDownload(response.data.uri, app.config.csrfToken as string); - }, 1000); - } - }, - ); - }); - } - } -} diff --git a/panel/src/ts/components/views/dashboard.ts b/panel/src/ts/components/views/dashboard.ts deleted file mode 100644 index 406f54030..000000000 --- a/panel/src/ts/components/views/dashboard.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { app } from "../../app"; -import { Request } from "../../utils/request"; -import { $ } from "../../utils/selectors"; -import { Notification } from "../notification"; -import { StatisticsChart } from "../statistics-chart"; - -export class Dashboard { - constructor() { - const clearCacheCommand = $("[data-view=dashboard] [data-command=clear-cache]"); - const clearPagesCacheCommand = $("[data-view=dashboard] [data-command=clear-pages-cache]"); - const clearImagesCacheCommand = $("[data-view=dashboard] [data-command=clear-images-cache]"); - const clearConfigCacheCommand = $("[data-view=dashboard] [data-command=clear-config-cache]"); - const clearAllCacheCommand = $("[data-view=dashboard] [data-command=clear-all-cache]"); - - const chart = $(".dashboard-chart"); - - const clearCache = (type?: string) => { - new Request( - { - method: "POST", - url: `${app.config.baseUri}cache/clear/${type ?? ""}/`.replace(/\/+$/, "/"), - data: { "csrf-token": app.config.csrfToken as string }, - }, - (response) => { - const icon = response.status === "error" ? "exclamationOctagon" : "checkCircle"; - const notification = new Notification(response.message, response.status, { icon }); - notification.show(); - }, - ); - }; - - if (clearCacheCommand) { - clearCacheCommand.addEventListener("click", () => clearCache()); - } - - if (clearPagesCacheCommand) { - clearPagesCacheCommand.addEventListener("click", () => clearCache("pages")); - } - - if (clearImagesCacheCommand) { - clearImagesCacheCommand.addEventListener("click", () => clearCache("images")); - } - - if (clearConfigCacheCommand) { - clearConfigCacheCommand.addEventListener("click", () => clearCache("config")); - } - - if (clearAllCacheCommand) { - clearAllCacheCommand.addEventListener("click", () => clearCache("all")); - } - - if (chart) { - const chartData = chart.dataset.chartData; - if (chartData) { - new StatisticsChart(chart, JSON.parse(chartData)); - } - } - } -} diff --git a/panel/src/ts/components/views/login.ts b/panel/src/ts/components/views/login.ts deleted file mode 100644 index fc3b11e65..000000000 --- a/panel/src/ts/components/views/login.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { app } from "../../app"; - -export class Login { - constructor() { - app.forms["login-form"]?.element.addEventListener("submit", ({ submitter }) => { - if (submitter instanceof HTMLButtonElement) { - submitter.disabled = true; - } - }); - } -} diff --git a/panel/src/ts/components/views/pages.ts b/panel/src/ts/components/views/pages.ts deleted file mode 100644 index 5c02816ef..000000000 --- a/panel/src/ts/components/views/pages.ts +++ /dev/null @@ -1,377 +0,0 @@ -import type { MoveEvent, SortableEvent } from "sortablejs"; -import { app } from "../../app"; -import { debounce } from "../../utils/events"; -import { Request } from "../../utils/request"; -import { $, $$ } from "../../utils/selectors"; -import { escapeHtml, escapeRegExp, makeDiacriticsRegExp, makeSlug } from "../../utils/validation"; -import type { Form } from "../form"; -import type { SelectInput } from "../inputs/select-input"; -import { Notification } from "../notification"; - -export class Pages { - constructor() { - const commandExpandAllPages = $("[data-command=expand-all-pages]") as HTMLButtonElement; - const commandCollapseAllPages = $("[data-command=collapse-all-pages]") as HTMLButtonElement; - const commandReorderPages = $("[data-command=reorder-pages]") as HTMLButtonElement; - const commandPreview = $("[data-command=preview]") as HTMLButtonElement; - - const searchInput = $(".page-search") as HTMLInputElement; - - const newPageModal = app.modals.newPageModal; - const deletePageItemModal = app.modals.deletePageItemModal; - const duplicatePageModal = app.modals.duplicatePageModal; - - $$(".pages-tree").forEach((element) => { - if (element.dataset.orderableChildren === "true") { - initSortable(element); - } - }); - - $$(".page-details").forEach((element) => { - if ($(".pages-tree-children-toggle", element)) { - element.addEventListener("click", (event) => { - const target = event.target as HTMLElement; - if (target.closest("a, .sortable-handle")) { - return; - } - togglePageItem(element); - event.preventDefault(); - }); - } - }); - - if (commandExpandAllPages) { - commandExpandAllPages.addEventListener("click", () => { - expandAllPages(); - commandExpandAllPages.blur(); - }); - } - - if (commandCollapseAllPages) { - commandCollapseAllPages.addEventListener("click", () => { - collapseAllPages(); - commandCollapseAllPages.blur(); - }); - } - - if (commandReorderPages) { - commandReorderPages.addEventListener("click", () => { - commandReorderPages.classList.toggle("active"); - ($(".pages-tree") as HTMLElement).classList.toggle("is-reordering"); - commandReorderPages.blur(); - }); - } - - if (commandExpandAllPages || commandCollapseAllPages || commandReorderPages) { - setCommandsState(); - } - - if (searchInput) { - searchInput.addEventListener("focus", () => { - $$(".pages-tree-item").forEach((element) => { - element.dataset.expanded = element.classList.contains("expanded") ? "true" : "false"; - }); - }); - - const handleSearch = () => { - const value = escapeHtml(searchInput.value); - if (value.length === 0) { - ($(".pages-tree-root") as HTMLElement).classList.remove("is-filtered"); - - $$(".pages-tree-item").forEach((element) => { - const title = $(".page-title a", element) as HTMLElement; - title.innerText = title.textContent; - ($(".pages-tree-row", element) as HTMLElement).style.display = ""; - element.classList.toggle("is-expanded", element.dataset.expanded === "true"); - }); - } else { - ($(".pages-tree-root") as HTMLElement).classList.add("is-filtered"); - - const regexp = new RegExp(`(^|\\b)${makeDiacriticsRegExp(escapeRegExp(value))}`, "gi"); - - $$(".pages-tree-item").forEach((element) => { - const title = $(".page-title a", element) as HTMLElement; - const text = escapeHtml(title.textContent); - const pagesItem = $(".pages-tree-row", element) as HTMLElement; - - if (text.match(regexp) !== null) { - title.innerHTML = text.replace(regexp, "$&"); - pagesItem.style.display = ""; - } else { - pagesItem.style.display = "none"; - } - - element.classList.add("is-expanded"); - }); - } - }; - - searchInput.addEventListener("keyup", debounce(handleSearch, 100)); - searchInput.addEventListener("search", handleSearch); - - document.addEventListener("keydown", (event) => { - if (event.ctrlKey || event.metaKey) { - if (event.key === "f" && document.activeElement !== searchInput) { - searchInput.focus(); - event.preventDefault(); - } - } - }); - } - - if (newPageModal) { - const form = newPageModal.form as Form; - - const url = new URL(window.location.href); - - if (url.searchParams.has("createNew")) { - url.searchParams.delete("createNew"); - window.history.replaceState({}, document.title, url.toString()); - } - - const parentSelect = form.inputs["newPageModal[parent]"] as SelectInput; - - const filterAllowedTemplates = () => { - const option = parentSelect.selectedDropdownItem; - - if (!option) { - return; - } - - const allowedTemplates = option.dataset.allowedTemplates ? option.dataset.allowedTemplates.split(" ") : []; - - const templateSelect = form.inputs["newPageModal[template]"] as SelectInput; - - if (allowedTemplates.length > 0) { - templateSelect.element.dataset.previousValue = templateSelect.value; - - templateSelect.value = allowedTemplates[0]; - - templateSelect.dropdownItems.forEach((item) => { - if (!allowedTemplates.includes(item.dataset.value as string)) { - item.classList.add("disabled"); - } - }); - } else { - if ("previousValue" in templateSelect.element.dataset) { - templateSelect.value = templateSelect.element.dataset.previousValue as string; - delete templateSelect.element.dataset.previousValue; - } - - templateSelect.dropdownItems.forEach((item) => { - item.classList.remove("disabled"); - }); - } - }; - - parentSelect.element.addEventListener("change", () => filterAllowedTemplates()); - - filterAllowedTemplates(); - } - - if (deletePageItemModal) { - deletePageItemModal.onOpen((modal, trigger) => { - if (trigger) { - Object.assign(modal.data, { - action: trigger.dataset.action, - pageItem: trigger.closest(".pages-tree-item"), - }); - } - }); - - deletePageItemModal.onCommand("delete-page", (modal) => { - const { action, pageItem } = modal.data as { action: string; pageItem: HTMLElement }; - - new Request( - { - method: "POST", - url: action, - data: { - "csrf-token": app.config.csrfToken as string, - }, - }, - (response) => { - if (response.status === "success" && pageItem) { - const parentItem = pageItem.parentElement?.closest(".pages-tree-item"); - pageItem.remove(); - setCommandsState(); - - if (parentItem && $$(".pages-tree-item", parentItem).length === 0) { - parentItem.classList.remove("has-children", "is-expanded"); - - const deleteButton = $("[data-modal=deletePageItemModal]", parentItem) as HTMLButtonElement; - if (deleteButton) { - deleteButton.disabled = false; - } - - const duplicateButton = $("[data-modal=duplicatePageModal]", parentItem) as HTMLButtonElement; - if (duplicateButton) { - duplicateButton.disabled = false; - } - } - } - - const notification = new Notification(response.message, response.status); - notification.show(); - - modal.close(); - }, - ); - }); - } - - if (duplicatePageModal) { - duplicatePageModal.onOpen((modal, trigger) => { - if (trigger && modal.form) { - const duplicateTitle = trigger.dataset.duplicateTitle; - - if (duplicateTitle) { - const title = $('[name="duplicatePageModal[title]"]', modal.form.element) as HTMLInputElement; - const slug = $('[name="duplicatePageModal[slug]"]', modal.form.element) as HTMLInputElement; - title.value = duplicateTitle; - slug.value = makeSlug(duplicateTitle); - title.setSelectionRange(0, title.value.length); - } - } - }); - } - - if (commandPreview) { - const editorForm = app.forms["page-editor-form"]; - - const pageParent = $("#parent", editorForm.element) as HTMLInputElement; - const previousParent = pageParent.value; - - if (editorForm) { - editorForm.element.addEventListener( - "input", - debounce(() => { - if (pageParent.value !== previousParent) { - // Prevent preview if the parent page has changed - commandPreview.disabled = true; - commandPreview.classList.remove("button-indicator"); - return; - } - commandPreview.disabled = false; - commandPreview.classList.toggle("button-indicator", editorForm.hasChanged()); - }, 500), - ); - } - } - - function expandAllPages() { - $$(".pages-tree-item").forEach((element) => { - element.classList.add("is-expanded"); - }); - setCommandsState(); - } - - function collapseAllPages() { - $$(".pages-tree-item").forEach((element) => { - element.classList.remove("is-expanded"); - }); - setCommandsState(); - } - - function togglePageItem(list: HTMLElement) { - const element = list.closest(".pages-tree-item"); - element?.classList.toggle("is-expanded"); - setCommandsState(); - } - - function setCommandsState() { - const allPages = $$(".pages-tree-item.has-children"); - const expandedPages = $$(".pages-tree-item.has-children.is-expanded"); - const orderablePages = $$(".pages-tree-item.is-orderable"); - - if (commandExpandAllPages) { - commandExpandAllPages.disabled = allPages.length === expandedPages.length; - } - - if (commandCollapseAllPages) { - commandCollapseAllPages.disabled = expandedPages.length === 0; - } - - if (commandReorderPages) { - commandReorderPages.disabled = orderablePages.length <= 1; - } - } - - async function initSortable(element: HTMLElement) { - const { default: Sortable } = await import("sortablejs"); - - let originalOrder: string[] = []; - - const sortable = Sortable.create(element, { - handle: ".sortable-handle", - filter: ".is-not-orderable", - forceFallback: true, - swapThreshold: 0.75, - invertSwap: true, - animation: 150, - preventOnFilter: false, // Workaround to allow touch events on iOS - - onChoose() { - const height = document.body.offsetHeight; - document.body.style.height = `${height}px`; - - const e = () => { - window.document.body.style.height = ""; - window.removeEventListener("scroll", e); - }; - window.addEventListener("scroll", e); - }, - - onStart() { - element.classList.add("is-dragging"); - }, - - onMove(event: MoveEvent) { - if (event.related.classList.contains("is-not-orderable")) { - return false; - } - }, - - onEnd(event: SortableEvent) { - element.classList.remove("is-dragging"); - - document.body.style.height = ""; - - if (event.newIndex === event.oldIndex) { - return; - } - - sortable.option("disabled", true); - - const data = { - "csrf-token": app.config.csrfToken as string, - page: event.item.dataset.route, - before: (event.item.nextElementSibling as HTMLElement).dataset.route, - parent: element.dataset.parent, - }; - - new Request( - { - method: "POST", - url: `${app.config.baseUri}pages/reorder/`, - data: data, - }, - (response) => { - if (response.status) { - const notification = new Notification(response.message, response.status, { icon: "checkCircle" }); - notification.show(); - } - if (!response.status || response.status === "error") { - sortable.sort(originalOrder); - } - sortable.option("disabled", false); - originalOrder = sortable.toArray(); - }, - ); - }, - }); - - originalOrder = sortable.toArray(); - } - } -} diff --git a/panel/src/ts/components/views/plugins.ts b/panel/src/ts/components/views/plugins.ts deleted file mode 100644 index a9ea3a6f9..000000000 --- a/panel/src/ts/components/views/plugins.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { app } from "../../app"; -import { throttle } from "../../utils/events"; -import { Request } from "../../utils/request"; -import { $$ } from "../../utils/selectors"; -import { TogglegroupInput } from "../inputs/togglegroup-input"; -import { Notification } from "../notification"; - -export class Plugins { - constructor() { - $$(".plugin-status-toggle").forEach((toggle) => { - const togglegroup = new TogglegroupInput(toggle.closest(".form-togglegroup") as HTMLFieldSetElement); - const action = toggle.dataset.action; - - const form = app.forms["plugin-form"]; - - toggle.addEventListener("change", () => { - if (!action) { - return; - } - - const switchToggle = () => { - togglegroup.value = toggle.value === "1" ? "0" : "1"; - }; - - if (toggle.value === "1" && form && !form.element.noValidate && !form.element.reportValidity()) { - switchToggle(); - return; - } - - togglegroup.element.disabled = true; - - throttle(() => { - new Request( - { - method: "POST", - url: action, - data: { "csrf-token": app.config.csrfToken as string }, - }, - (response) => { - if (response.status === "success" && !form?.hasChanged()) { - window.location.reload(); - } else { - const notification = new Notification(response.message, response.status); - notification.show(); - if (response.status === "error") { - switchToggle(); - } - togglegroup.element.disabled = false; - } - }, - ); - }, 500)(); - }); - }); - } -} diff --git a/panel/src/ts/components/views/statistics.ts b/panel/src/ts/components/views/statistics.ts deleted file mode 100644 index 2b3dfd790..000000000 --- a/panel/src/ts/components/views/statistics.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { $ } from "../../utils/selectors"; -import { StatisticsChart } from "../statistics-chart"; - -export class Statistics { - constructor() { - const chart = $(".statistics-chart"); - if (chart) { - const chartData = chart.dataset.chartData; - if (chartData) { - new StatisticsChart(chart, JSON.parse(chartData)); - } - } - } -} diff --git a/panel/src/ts/components/views/updates.ts b/panel/src/ts/components/views/updates.ts deleted file mode 100644 index 0bea129a8..000000000 --- a/panel/src/ts/components/views/updates.ts +++ /dev/null @@ -1,95 +0,0 @@ -import { app } from "../../app"; -import { Request } from "../../utils/request"; -import { $ } from "../../utils/selectors"; -import { check, exclamation, info } from "../icons"; -import { Notification } from "../notification"; - -export class Updates { - constructor() { - const updaterComponent = document.getElementById("updater-component"); - - if (updaterComponent) { - const updateStatus = $(".update-status") as HTMLElement; - const spinner = $(".spinner") as HTMLElement; - const currentVersion = $(".current-version") as HTMLElement; - const currentVersionName = $(".current-version-name") as HTMLElement; - const newVersion = $(".new-version") as HTMLElement; - const newVersionName = $(".new-version-name") as HTMLElement; - const installCommand = $("[data-command=install-updates]") as HTMLElement; - - const showNewVersion = (name: string) => { - spinner.classList.add("spinner-info"); - spinner.innerHTML = info; - newVersionName.innerText = name; - newVersion.style.display = "block"; - }; - - const showCurrentVersion = () => { - spinner.classList.add("spinner-success"); - spinner.innerHTML = check; - currentVersion.style.display = "block"; - }; - - const showInstalledVersion = () => { - spinner.classList.add("spinner-success"); - spinner.innerHTML = check; - currentVersionName.innerText = newVersionName.innerText; - currentVersion.style.display = "block"; - }; - - window.setTimeout(() => { - const data = { "csrf-token": app.config.csrfToken as string }; - - new Request( - { - method: "POST", - url: `${app.config.baseUri}updates/check/`, - data: data, - }, - (response) => { - updateStatus.innerText = response.message; - - if (response.status === "success") { - if (response.data.uptodate === false) { - showNewVersion(response.data.release.name); - } else { - showCurrentVersion(); - } - } else { - spinner.classList.add("spinner-danger"); - spinner.innerHTML = exclamation; - } - }, - ); - }, 1000); - - installCommand.addEventListener("click", () => { - newVersion.style.display = "none"; - spinner.classList.remove("spinner-info"); - $(".icon", spinner)?.remove(); - updateStatus.innerText = updateStatus.dataset.installingText as string; - - new Request( - { - method: "POST", - url: `${app.config.baseUri}updates/update/`, - data: { "csrf-token": app.config.csrfToken as string }, - }, - (response) => { - const notification = new Notification(response.message, response.status, { icon: "checkCircle" }); - notification.show(); - - updateStatus.innerText = response.data.status; - - if (response.status === "success") { - showInstalledVersion(); - } else { - spinner.classList.add("spinner-danger"); - spinner.innerHTML = exclamation; - } - }, - ); - }); - } - } -} diff --git a/panel/src/ts/index.ts b/panel/src/ts/index.ts new file mode 100644 index 000000000..cd8527d51 --- /dev/null +++ b/panel/src/ts/index.ts @@ -0,0 +1,57 @@ +/** + * Entry point of the panel script, loaded by the PHP views + * + * It registers the services, the components acting on the whole page and the views, which are + * started by `app.load()` in this order + */ + +import { ColorScheme } from "./components/ui/color-scheme"; +import { Dropdowns } from "./components/ui/dropdowns"; +import { Navigation } from "./components/ui/navigation"; +import { Notifications } from "./components/ui/notifications"; +import { Sections } from "./components/ui/sections"; +import { initStatisticsChart } from "./components/ui/statistics-chart"; +import { Tabs } from "./components/ui/tabs"; +import { Tooltips } from "./components/ui/tooltips"; +import { app } from "./services/app"; +import { Forms } from "./services/forms"; +import { Modals } from "./services/modals"; +import { Translation } from "./services/translation"; +import { initBackups } from "./views/backups"; +import { initDashboard } from "./views/dashboard"; +import { initFileForm, initFilesView } from "./views/files"; +import { initLogin } from "./views/login"; +import { initDuplicatePageModal, initNewPageModal, initPagePreview, initPagesTree, initSortable } from "./views/pages"; +import { initPluginToggle } from "./views/plugins"; +import { initUpdates } from "./views/updates"; + +// The services come first: the other components use them while they start +app.service("translation", Translation); +app.service("modals", Modals); +app.service("forms", Forms); + +// Behaviors of the whole page: they look for what they need in the document +app.component(Tabs); +app.component(Dropdowns); +app.component(Tooltips); +app.component(Navigation); +app.component(ColorScheme); +app.component(Notifications); +app.component(Sections); + +// Views: each one starts only if the page contains the element it works on +app.view("[data-form=login-form]", initLogin); +app.view("[data-view=dashboard]", initDashboard); +app.view(".pages-tree", initPagesTree); +app.views(".pages-tree[data-orderable-children=true]", initSortable); +app.modal("newPageModal", initNewPageModal); +app.modal("duplicatePageModal", initDuplicatePageModal); +app.view("[data-form=page-editor-form]", initPagePreview); +app.view("[data-view=files]", initFilesView); +app.view("[data-form=file-form]", initFileForm); +app.view(".statistics-chart", initStatisticsChart); +app.view("[data-view=backups]", initBackups); +app.view("#updater-component", initUpdates); +app.views(".plugin-status-toggle", initPluginToggle); + +export { app }; diff --git a/panel/src/ts/models/calendar.ts b/panel/src/ts/models/calendar.ts new file mode 100644 index 000000000..c9b5ddcc3 --- /dev/null +++ b/panel/src/ts/models/calendar.ts @@ -0,0 +1,255 @@ +import { createModel, signal } from "@preact/signals-core"; +import { mod } from "../utils/math"; + +/** + * Describes the date and time of a calendar + * + * The month is zero-based + */ +export interface CalendarState { + year: number; + month: number; + day: number; + hours: number; + minutes: number; + seconds: number; +} + +/** + * Represents a change of the date that carries over to the larger units + */ +type CalendarOperation = (state: CalendarState) => void; + +/** + * Create the state of a date + */ +const stateFromDate = (date: Date): CalendarState => ({ + year: date.getFullYear(), + month: date.getMonth(), + day: date.getDate(), + hours: date.getHours(), + minutes: date.getMinutes(), + seconds: date.getSeconds(), +}); + +/** + * Return whether a year is a leap year + */ +const isLeapYear = (year: number) => (year % 4 === 0 && year % 100 !== 0) || year % 400 === 0; + +/** + * Get the number of days of a month + */ +export const daysInMonth = (month: number, year: number) => (month === 1 && isLeapYear(year) ? 29 : [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31][month]); + +/** + * Go to the previous year, using the last day of the month if the current day does not exist + */ +const prevYear: CalendarOperation = (state) => { + state.year--; + state.day = Math.min(state.day, daysInMonth(state.month, state.year)); +}; + +/** + * Go to the next year, using the last day of the month if the current day does not exist + */ +const nextYear: CalendarOperation = (state) => { + state.year++; + state.day = Math.min(state.day, daysInMonth(state.month, state.year)); +}; + +/** + * Go to the last day of the month + */ +const lastDayOfMonth: CalendarOperation = (state) => { + state.day = daysInMonth(state.month, state.year); +}; + +/** + * Go to the previous month, using its last day if the current day does not exist + */ +const prevMonth: CalendarOperation = (state) => { + state.month = mod(state.month - 1, 12); + if (state.month === 11) { + prevYear(state); + } + if (state.day > daysInMonth(state.month, state.year)) { + lastDayOfMonth(state); + } +}; + +/** + * Go to the next month, using its last day if the current day does not exist + */ +const nextMonth: CalendarOperation = (state) => { + state.month = mod(state.month + 1, 12); + if (state.month === 0) { + nextYear(state); + } + if (state.day > daysInMonth(state.month, state.year)) { + lastDayOfMonth(state); + } +}; + +/** + * Go back by one week + */ +const prevWeek: CalendarOperation = (state) => { + state.day -= 7; + if (state.day < 1) { + prevMonth(state); + state.day += daysInMonth(state.month, state.year); + } +}; + +/** + * Go forward by one week + */ +const nextWeek: CalendarOperation = (state) => { + state.day += 7; + if (state.day > daysInMonth(state.month, state.year)) { + state.day -= daysInMonth(state.month, state.year); + nextMonth(state); + } +}; + +/** + * Go to the previous day + */ +const prevDay: CalendarOperation = (state) => { + state.day--; + if (state.day < 1) { + prevMonth(state); + lastDayOfMonth(state); + } +}; + +/** + * Go to the next day + */ +const nextDay: CalendarOperation = (state) => { + state.day++; + if (state.day > daysInMonth(state.month, state.year)) { + nextMonth(state); + state.day = 1; + } +}; + +/** + * Go to the next hour + */ +const nextHour: CalendarOperation = (state) => { + state.hours = mod(state.hours + 1, 24); + if (state.hours === 0) { + nextDay(state); + } +}; + +/** + * Go to the previous hour + */ +const prevHour: CalendarOperation = (state) => { + state.hours = mod(state.hours - 1, 24); + if (state.hours === 23) { + prevDay(state); + } +}; + +/** + * Go to the next minute + */ +const nextMinute: CalendarOperation = (state) => { + state.minutes = mod(state.minutes + 1, 60); + if (state.minutes === 0) { + nextHour(state); + } +}; + +/** + * Go to the previous minute + */ +const prevMinute: CalendarOperation = (state) => { + state.minutes = mod(state.minutes - 1, 60); + if (state.minutes === 59) { + prevHour(state); + } +}; + +/** + * Holds the selected date of a calendar and its visibility + * + * Every action updates the date at once, so the calendar is rendered only for the final state + */ +export const CalendarModel = createModel(() => { + const state = signal(stateFromDate(new Date())); + const shown = signal(false); + + const apply = (operation: CalendarOperation) => { + const next = { ...state.value }; + operation(next); + state.value = next; + }; + + return { + state, + shown, + + /** + * Get the selected date + */ + toDate() { + const { year, month, day, hours, minutes, seconds } = state.value; + return new Date(year, month, day, hours, minutes, seconds); + }, + + /** + * Set the selected date + */ + setDate(date: Date) { + state.value = stateFromDate(date); + }, + + /** + * Select the current date and time + */ + now() { + state.value = stateFromDate(new Date()); + }, + + /** + * Select a day of the current month + */ + setDay(day: number) { + apply((next) => { + next.day = day; + }); + }, + + prevYear: () => apply(prevYear), + nextYear: () => apply(nextYear), + prevMonth: () => apply(prevMonth), + nextMonth: () => apply(nextMonth), + prevWeek: () => apply(prevWeek), + nextWeek: () => apply(nextWeek), + prevDay: () => apply(prevDay), + nextDay: () => apply(nextDay), + prevHour: () => apply(prevHour), + nextHour: () => apply(nextHour), + prevMinute: () => apply(prevMinute), + nextMinute: () => apply(nextMinute), + + /** + * Show the calendar + */ + show() { + shown.value = true; + }, + + /** + * Hide the calendar + */ + hide() { + shown.value = false; + }, + }; +}); diff --git a/panel/src/ts/models/pages-tree.ts b/panel/src/ts/models/pages-tree.ts new file mode 100644 index 000000000..c1052c9bd --- /dev/null +++ b/panel/src/ts/models/pages-tree.ts @@ -0,0 +1,107 @@ +import { computed, createModel, signal } from "@preact/signals-core"; +import { makeSearchRegExp } from "../utils/search"; +import { $$ } from "../utils/selectors"; + +/** + * Holds the state of the pages tree rendered by the server + * + * The signals mirror the initial classes of the items, the DOM is updated by the effects of the + * view + */ +export const PagesTreeModel = createModel((initialReordering: boolean) => { + const items = signal(Array.from($$(".pages-tree-item"))); + const parents = signal(new Set(items.value.filter((item) => item.classList.contains("has-children")))); + const expanded = signal(new Set(items.value.filter((item) => item.classList.contains("is-expanded")))); + + // `null` means that no search has been performed yet + const query = signal(null); + + const reordering = signal(initialReordering); + + const expandable = computed(() => items.value.filter((item) => parents.value.has(item))); + const expandedCount = computed(() => expandable.value.filter((item) => expanded.value.has(item)).length); + const orderableCount = computed(() => items.value.filter((item) => item.classList.contains("is-orderable")).length); + + const searchRegExp = computed(() => (query.value ? makeSearchRegExp(query.value, { global: true }) : null)); + + // While searching every item is shown expanded, without losing the expanded state + const filtering = computed(() => searchRegExp.value !== null); + + return { + items, + parents, + expanded, + query, + reordering, + expandable, + expandedCount, + orderableCount, + searchRegExp, + filtering, + + /** + * Expand all items + */ + expandAll() { + expanded.value = new Set(items.value); + }, + + /** + * Collapse all items + */ + collapseAll() { + expanded.value = new Set(); + }, + + /** + * Expand an item, or collapse it if expanded + */ + toggle(item: HTMLElement) { + const next = new Set(expanded.value); + if (!next.delete(item)) { + next.add(item); + } + expanded.value = next; + }, + + /** + * Turn the reordering of the pages on or off + */ + toggleReordering() { + reordering.value = !reordering.value; + }, + + /** + * Filter the tree by a text + */ + search(value: string) { + query.value = value; + }, + + /** + * Remove an item and its descendants from the tree + * + * @param removed Removed item + * @param parentItem Parent of the removed item + * + * @returns Parent item if it has no children left, or `null` + */ + remove(removed: HTMLElement, parentItem: HTMLElement | null | undefined) { + items.value = items.value.filter((item) => !removed.contains(item)); + + const emptiedParent = parentItem && !items.value.some((item) => item !== parentItem && parentItem.contains(item)) ? parentItem : null; + + if (emptiedParent) { + parents.value = new Set([...parents.value].filter((item) => item !== emptiedParent)); + expanded.value = new Set([...expanded.value].filter((item) => item !== emptiedParent)); + } + + return emptiedParent; + }, + }; +}); + +/** + * Represents an instance of the model + */ +export type PagesTree = InstanceType; diff --git a/panel/src/ts/models/updates.ts b/panel/src/ts/models/updates.ts new file mode 100644 index 000000000..12c7908fd --- /dev/null +++ b/panel/src/ts/models/updates.ts @@ -0,0 +1,91 @@ +import { computed, createModel, signal } from "@preact/signals-core"; +import type { SpinnerStatus } from "../components/ui/spinner"; +import type { PanelResponse } from "../services/request"; + +/** + * Describes the result of the check for updates + */ +export interface UpdateCheck { + uptodate: boolean; + release: { name: string }; +} + +/** + * Describes the result of the installation of an update + */ +export interface UpdateInstallation { + status: string; +} + +/** + * Represents the phases of the updater + */ +export type UpdatePhase = "checking" | "outdated" | "current" | "installing" | "installed" | "error"; + +/** + * Spinner status of each phase + */ +const spinnerStatuses: Record = { + checking: "pending", + outdated: "info", + current: "success", + installing: "pending", + installed: "success", + error: "danger", +}; + +/** + * Holds the state of the updater: checking for a new version and installing it + */ +export const UpdatesModel = createModel(() => { + const phase = signal("checking"); + + // `null` means that there is no message or release name yet + const message = signal(null); + const releaseName = signal(null); + + const spinnerStatus = computed(() => spinnerStatuses[phase.value]); + const newVersionVisible = computed(() => phase.value === "outdated"); + const currentVersionVisible = computed(() => phase.value === "current" || phase.value === "installed"); + + return { + phase, + message, + releaseName, + spinnerStatus, + newVersionVisible, + currentVersionVisible, + + /** + * Set the result of the check for updates + */ + checked(response: PanelResponse) { + message.value = response.message; + + if (response.status !== "success") { + phase.value = "error"; + } else if (response.data.uptodate === false) { + releaseName.value = response.data.release.name; + phase.value = "outdated"; + } else { + phase.value = "current"; + } + }, + + /** + * Set the installation as started + */ + installing(installingText: string) { + phase.value = "installing"; + message.value = installingText; + }, + + /** + * Set the result of the installation + */ + installed(response: PanelResponse) { + message.value = response.data.status; + phase.value = response.status === "success" ? "installed" : "error"; + }, + }; +}); diff --git a/panel/src/ts/polyfills/request-submit.ts b/panel/src/ts/polyfills/request-submit.ts deleted file mode 100644 index 38bc904a1..000000000 --- a/panel/src/ts/polyfills/request-submit.ts +++ /dev/null @@ -1,25 +0,0 @@ -// HTMLFormElement.prototype.requestSubmit polyfill -// see https://github.com/javan/form-request-submit-polyfill -if (typeof window.HTMLFormElement.prototype.requestSubmit === "undefined") { - window.HTMLFormElement.prototype.requestSubmit = function (submitter: HTMLInputElement) { - if (submitter) { - if (!(submitter instanceof HTMLElement)) { - throw new TypeError("Failed to execute 'requestSubmit' on 'HTMLFormElement': parameter 1 is not of type 'HTMLElement'."); - } - if (submitter.type !== "submit") { - throw new TypeError("Failed to execute 'requestSubmit' on 'HTMLFormElement': the specified element is not a submit button."); - } - if (submitter.form !== this) { - throw new DOMException("Failed to execute 'requestSubmit' on 'HTMLFormElement': the specified element is not owned by this form element.", "NotFoundError"); - } - submitter.click(); - } else { - submitter = document.createElement("input"); - submitter.type = "submit"; - submitter.hidden = true; - this.appendChild(submitter); - submitter.click(); - this.removeChild(submitter); - } - }; -} diff --git a/panel/src/ts/services/app.ts b/panel/src/ts/services/app.ts new file mode 100644 index 000000000..a194c933a --- /dev/null +++ b/panel/src/ts/services/app.ts @@ -0,0 +1,166 @@ +import type { DateInputOptions } from "../components/inputs/date-input"; +import type { DurationInputOptions } from "../components/inputs/duration-input"; +import type { SelectInputOptions } from "../components/inputs/select-input"; +import type { TagsInputOptions } from "../components/inputs/tags-input"; +import { $, $$ } from "../utils/selectors"; +import type { Forms } from "./forms"; +import type { Modal } from "./modal"; +import type { Modals } from "./modals"; +import type { Translation } from "./translation"; + +/** + * Holds the panel configuration passed by the PHP views to `app.load()` + */ +interface AppConfig { + siteUri: string; + baseUri: string; + translation: { + code: string; + data: { [key: string]: string | string[] }; + }; + csrfToken?: string; + colorScheme?: string; + DateInput?: Partial; + DurationInput?: Partial; + SelectInput?: Partial; + TagsInput?: Partial; +} + +/** + * Represents a component started with the application + */ +interface Component { + // biome-ignore lint/suspicious/noConfusingVoidType: The constructor returns void, not undefined + new (app: App): void; +} + +/** + * Describes the services available as properties of the application after `load()` + */ +interface Services { + translation: Translation; + modals: Modals; + forms: Forms; +} + +/** + * Represents the panel application + * + * This module imports no component, so that components can import `app` without circular + * dependencies. The components are registered in `index.ts` + */ +class App implements Services { + config: AppConfig = { + siteUri: "/", + baseUri: "/", + translation: { + code: "en", + data: {}, + }, + }; + + translation: Translation = {} as Translation; + + modals: Modals = {}; + + forms: Forms = {}; + + private readonly initializers: (() => void)[] = []; + + /** + * Register a service + * + * The service is created when the application is loaded and stored in the property with the + * same name. Services and components are started in the order they are registered + * + * @param name Name of the property of the application + * @param service Class of the service + */ + service(name: Name, service: new (app: App) => Services[Name]) { + this.initializers.push(() => { + (this as Services)[name] = new service(this); + }); + } + + /** + * Register a component + */ + component(component: Component) { + this.initializers.push(() => this.loadComponent(component)); + } + + /** + * Run an initializer with the first element matching a selector, if any + * + * @param selector Selector of the element + * @param init Function called with the element + */ + view(selector: string, init: (element: Element) => void) { + this.initializers.push(() => { + const element = $(selector); + if (element) { + init(element); + } + }); + } + + /** + * Run an initializer with each element matching a selector + * + * @param selector Selector of the elements + * @param init Function called with each element + */ + views(selector: string, init: (element: Element) => void) { + this.initializers.push(() => { + for (const element of $$(selector)) { + init(element); + } + }); + } + + /** + * Run an initializer with the modal with the given id, if any + * + * @param id Id of the modal + * @param init Function called with the modal + */ + modal(id: string, init: (modal: Modal) => void) { + this.initializers.push(() => { + const modal = this.modals[id]; + if (modal) { + init(modal); + } + }); + } + + /** + * Load the configuration and start the registered services and components + */ + load(config: AppConfig) { + this.loadConfig(config); + + for (const initialize of this.initializers) { + initialize(); + } + } + + /** + * Merge a configuration into the current one + */ + loadConfig(config: AppConfig) { + Object.assign(this.config, config); + } + + /** + * Start a component immediately + */ + loadComponent(component: Component) { + new component(this); + } +} + +export type { App }; +/** + * Panel application instance + */ +export const app = new App(); diff --git a/panel/src/ts/services/form.ts b/panel/src/ts/services/form.ts new file mode 100644 index 000000000..58d4b5483 --- /dev/null +++ b/panel/src/ts/services/form.ts @@ -0,0 +1,362 @@ +import { signal } from "@preact/signals-core"; +import { ArrayInput } from "../components/inputs/array-input"; +import { ColorInput } from "../components/inputs/color-input"; +import { DateInput } from "../components/inputs/date-input"; +import { DurationInput } from "../components/inputs/duration-input"; +import { EditorInput } from "../components/inputs/editor-input"; +import { ImagePicker } from "../components/inputs/image-picker"; +import { dispatchChange, Input } from "../components/inputs/input"; +import { RangeInput } from "../components/inputs/range-input"; +import { SelectInput } from "../components/inputs/select-input"; +import { SlugInput } from "../components/inputs/slug-input"; +import { TagsInput } from "../components/inputs/tags-input"; +import { TogglegroupInput } from "../components/inputs/togglegroup-input"; +import { UploadInput } from "../components/inputs/upload-input"; +import { debounce } from "../utils/events"; +import { serializeForm } from "../utils/forms"; +import { $, $$ } from "../utils/selectors"; +import { app } from "./app"; +import type { Modal } from "./modal"; + +/** + * Holds the options of a form + */ +interface FormOptions { + preventUnloadOnChanges?: boolean; +} + +/** + * Represents the native element of an input + */ +interface InputElement extends HTMLElement { + name: string; +} + +/** + * Represents an input of a form + */ +interface FormInput { + element: InputElement; + name: string; + value: string; +} + +/** + * Represents the native elements holding the value of a field + */ +export type HTMLInputLike = HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement; + +/** + * Manages a form of the page with its inputs + * + * The form is found by its `data-form` attribute and available as `app.forms[name]` + */ +export class Form { + /** + * Inputs of the form + */ + readonly formInputs: FormInput[] = []; + + /** + * Serialization of the form when it was loaded + */ + originalData: string; + + /** + * Whether the form differs from its initial state, updated shortly after the last input event + */ + readonly changed = signal(false); + + element: HTMLFormElement; + options: FormOptions = { + preventUnloadOnChanges: true, + }; + + /** + * Inputs replacing native elements, by selector + */ + private readonly inputFactories: { [selector: string]: (element: HTMLElement) => FormInput } = { + ".editor-textarea": (element) => new EditorInput(element as HTMLTextAreaElement), + ".form-input-color": (element) => new ColorInput(element as HTMLInputElement), + ".form-input-array": (element) => new ArrayInput(element as HTMLFieldSetElement, this), + ".form-input-date": (element) => new DateInput(element as HTMLInputElement, app.config.DateInput), + ".form-input-duration": (element) => new DurationInput(element as HTMLInputElement, app.config.DurationInput), + ".form-input-slug": (element) => new SlugInput(element as HTMLInputElement), + ".form-input-tags": (element) => new TagsInput(element as HTMLInputElement, app.config.TagsInput), + ".form-togglegroup": (element) => new TogglegroupInput(element as HTMLFieldSetElement), + ".image-picker": (element) => new ImagePicker(element as HTMLInputElement), + "input[type=file]": (element) => new UploadInput(element as HTMLInputElement, this), + "input[type=range]": (element) => new RangeInput(element as HTMLInputElement), + ".form-select": (element) => new SelectInput(element as HTMLSelectElement, app.config.SelectInput), + }; + + /** + * Behaviors of elements, by selector + */ + private readonly behaviors: { [selector: string]: (element: HTMLElement) => void } = { + ".form-input-action[data-reset]": (element) => { + const button = element as HTMLButtonElement; + const targetId = button.dataset.reset; + if (targetId) { + button.addEventListener("click", () => { + const target = document.getElementById(targetId) as HTMLInputElement; + target.value = ""; + dispatchChange(target); + }); + } + }, + + "input[data-enable]": (element) => { + const checkbox = element as HTMLInputElement; + + checkbox.addEventListener("change", () => { + for (const name of (checkbox.dataset.enable ?? "").split(",")) { + const input = $(`input[name="${name}"]`); + + if (input) { + input.disabled = !checkbox.checked; + } + } + }); + }, + }; + + /** + * Load the inputs of the form and detect its changes + */ + constructor(form: HTMLFormElement, options: Partial = {}) { + this.element = form; + + this.loadInputs(); + + // Serialize after inputs are loaded + this.originalData = serializeForm(form); + + form.addEventListener( + "input", + debounce(() => { + this.changed.value = this.hasChanged(); + }, 500), + ); + + this.options = { ...this.options, ...options }; + + if (this.options.preventUnloadOnChanges) { + this.preventUnloadOnChanges(); + } + } + + /** + * Get the inputs of the form by name + * + * If more inputs have the same name the last one is returned + */ + get inputs(): { [name: string]: FormInput } { + const inputs: { [name: string]: FormInput } = {}; + for (const input of this.formInputs) { + inputs[input.name] = input; + } + return inputs; + } + + /** + * Load inputs and behaviors of the elements found for each selector + */ + private load(find: (selector: string) => Iterable) { + for (const selector in this.inputFactories) { + for (const element of find(selector)) { + this.formInputs.push(this.inputFactories[selector](element)); + } + } + + for (const selector in this.behaviors) { + for (const element of find(selector)) { + this.behaviors[selector](element); + } + } + + // The named native elements that no input has replaced + for (const element of find("input, select, textarea")) { + const inputElement = element as HTMLInputLike; + if (inputElement.name && !this.formInputs.some((input) => input.element === inputElement)) { + this.formInputs.push(new Input(inputElement)); + } + } + } + + /** + * Load inputs and behaviors of the elements inside a parent + */ + private loadInputs(parent: HTMLElement = this.element) { + this.load((selector) => $$(selector, parent)); + } + + /** + * Load input and behaviors of a single element + */ + private loadInput(element: HTMLElement) { + this.load((selector) => (element.matches(selector) ? [element] : [])); + } + + /** + * Return whether the form has changed since it was loaded + * + * @param checkFileInputs Whether file inputs with selected files are considered changes + */ + hasChanged(checkFileInputs: boolean = true) { + return (checkFileInputs && this.hasSelectedFiles()) || serializeForm(this.element) !== this.originalData; + } + + /** + * Return whether any file input has selected files + */ + private hasSelectedFiles() { + return Array.from($$("input[type=file]", this.element)).some((input) => input.files && input.files.length > 0); + } + + /** + * Copy an input at the end of a target element, with a new id and an empty value + * + * @param element Input to copy + * @param targetElement Element the copy is appended to + */ + duplicateInput(element: HTMLInputLike, targetElement: HTMLElement) { + const wrap = element.closest(".form-input-wrap"); + + const { node, input } = wrap ? this.cloneWithWrap(wrap, element) : this.cloneAlone(element); + + // Generate a new unique ID for the duplicated input + const previousId = element.id; + const newId = `${element.tagName.toLowerCase()}-${Math.random().toString(36).slice(2)}`; + input.id = newId; + + if (wrap && previousId) { + $$(`label[for="${previousId}"]`).forEach((label) => { + label.htmlFor = newId; + }); + } + + targetElement.appendChild(node); + + if (wrap) { + this.loadInputs(node); + } else { + this.loadInput(input); + } + } + + /** + * Copy an input with an empty value + */ + private cloneEmpty(element: HTMLInputLike) { + const copy = element.cloneNode(true) as HTMLInputLike; + + if (copy instanceof HTMLInputElement && (copy.type === "checkbox" || copy.type === "radio")) { + copy.checked = false; + } else { + copy.value = ""; + } + + return copy; + } + + /** + * Copy an input that is not in a wrapper + */ + private cloneAlone(element: HTMLInputLike) { + const input = this.cloneEmpty(element); + + return { node: input, input }; + } + + /** + * Copy the wrapper of an input with the input, its actions, description and icon + */ + private cloneWithWrap(wrap: HTMLElement, element: HTMLInputLike) { + const node = wrap.cloneNode() as HTMLElement; + + let input: HTMLInputLike | undefined; + + for (const child of Array.from(wrap.children)) { + if (child === element) { + input = this.cloneEmpty(element); + node.appendChild(input); + } else if (child.matches(".form-input-action, .form-input-description, .form-input-icon")) { + node.appendChild(child.cloneNode(true)); + } + } + + if (input === undefined) { + throw new Error("Could not replicate input: input element not found in wrapper."); + } + + return { node, input }; + } + + /** + * Ask for confirmation before leaving the page if the form has unsaved changes + */ + private preventUnloadOnChanges() { + const handleBeforeunload = (event: Event) => { + if (this.hasChanged()) { + event.preventDefault(); + event.returnValue = false; + } + }; + + const removeBeforeUnload = () => { + window.removeEventListener("beforeunload", handleBeforeunload); + }; + + window.addEventListener("beforeunload", handleBeforeunload); + + this.element.addEventListener("submit", removeBeforeUnload); + + const changesModal = app.modals.changesModal; + + if (changesModal) { + this.confirmLinks(changesModal, removeBeforeUnload); + } + } + + /** + * Ask for confirmation with a modal when a link of the page is followed + * + * @param modal Modal asking for confirmation + * @param allowLeaving Function called when the user confirms, before the link is followed + */ + private confirmLinks(modal: Modal, allowLeaving: () => void) { + let nextHref: string | undefined; + + modal.onCommand("continue", (_, button) => { + allowLeaving(); + + if (button?.dataset.href) { + window.location.href = button.dataset.href; + } + }); + + modal.onOpen((openedModal) => { + const continueCommand = $("[data-command=continue]", openedModal.element); + + if (continueCommand && nextHref) { + continueCommand.dataset.href = nextHref; + } + }); + + const links = Array.from($$('a[href]:not([href^="#"]):not([target="_blank"]):not([target^="formwork-"])')); + + links + .filter((link) => !link.closest(".editor-wrap")) + .forEach((link) => { + link.addEventListener("click", (event) => { + if (this.hasChanged()) { + event.preventDefault(); + + nextHref = link.href; + modal.open(); + } + }); + }); + } +} diff --git a/panel/src/ts/components/forms.ts b/panel/src/ts/services/forms.ts similarity index 89% rename from panel/src/ts/components/forms.ts rename to panel/src/ts/services/forms.ts index bf88c5c3b..9c2b8a5e3 100644 --- a/panel/src/ts/components/forms.ts +++ b/panel/src/ts/services/forms.ts @@ -1,6 +1,9 @@ import { $$ } from "../utils/selectors"; import { Form } from "./form"; +/** + * Collects the forms of the page, by name + */ export class Forms { [name: string]: Form; diff --git a/panel/src/ts/components/modal.ts b/panel/src/ts/services/modal.ts similarity index 72% rename from panel/src/ts/components/modal.ts rename to panel/src/ts/services/modal.ts index c70aa889b..7529a896c 100644 --- a/panel/src/ts/components/modal.ts +++ b/panel/src/ts/services/modal.ts @@ -1,11 +1,17 @@ import { $, $$ } from "../utils/selectors"; import { Form } from "./form"; +/** + * Holds the options to open a modal + */ interface ModalShowOptions { action?: string; triggerElement?: HTMLElement; } +/** + * Holds the options to close a modal + */ interface ModalHideOptions { triggerElement?: HTMLElement; } @@ -16,6 +22,12 @@ type ModalState = "open" | "closed"; const focusableSelector = ["a[href]", "button:not([disabled])", "input:not([disabled]):not([type=hidden])", "select:not([disabled])", "textarea:not([disabled])", "[contenteditable=true]", "[tabindex]:not([tabindex='-1'])"].join(","); +/** + * Represents a dialog rendered by PHP, opened by the elements with `data-modal` set to its id + * + * Elements with `data-command` trigger the commands registered with `onCommand()`. While the modal + * is the topmost one it keeps the focus inside and gives it back to the opener when closed + */ export class Modal { private static readonly openModals: Modal[] = []; @@ -49,14 +61,23 @@ export class Modal { } } + /** + * Return whether the modal is open + */ get isOpen() { return this.state === "open"; } + /** + * Return whether the modal is closed + */ get isClosed() { return this.state === "closed"; } + /** + * Set the text of the modal + */ setMessage(message: string) { const messageElement = $(".modal-text", this.element); if (messageElement) { @@ -64,6 +85,11 @@ export class Modal { } } + /** + * Open the modal + * + * @param options Action of the form and element that opened the modal + */ open(options: ModalShowOptions = {}) { this.dispatchCallback("before-open", options.triggerElement); @@ -100,6 +126,11 @@ export class Modal { this.dispatchCallback("open", options.triggerElement); } + /** + * Close the modal + * + * @param options Element that closed the modal + */ close(options: ModalHideOptions = {}) { this.dispatchCallback("before-close", options.triggerElement); @@ -122,30 +153,57 @@ export class Modal { this.restoreFocus(); } + /** + * Set the function called before the modal is opened + */ onBeforeOpen(callback: ModalCallback) { this.callbacks["before-open"] = callback; } + /** + * Set the function called when the modal is opened + */ onOpen(callback: ModalCallback) { this.callbacks.open = callback; } + /** + * Set the function called before the modal is closed + */ onBeforeClose(callback: ModalCallback) { this.callbacks["before-close"] = callback; } + /** + * Set the function called when the modal is closed + */ onClose(callback: ModalCallback) { this.callbacks.close = callback; } + /** + * Set the function called when the element with the `data-command` of the given name is clicked + * + * @param command Value of the `data-command` attribute + * @param callback Function called with the modal and the element that triggered it + */ onCommand(command: string, callback: ModalCallback) { this.callbacks[`command-${command}`] = callback; } + /** + * Call the function of a command + * + * @param command Name of the command + * @param triggerElement Element passed to the function + */ triggerCommand(command: string, triggerElement?: HTMLElement) { this.dispatchCallback(`command-${command}`, triggerElement); } + /** + * Add the backdrop if missing + */ private createBackdrop() { if (!$(".modal-backdrop")) { const backdrop = document.createElement("div"); @@ -154,6 +212,9 @@ export class Modal { } } + /** + * Remove the backdrop + */ private removeBackdrop() { const backdrop = $(".modal-backdrop"); if (backdrop?.parentNode) { @@ -161,6 +222,12 @@ export class Modal { } } + /** + * Call the function of a modal event + * + * @param name Name of the event + * @param triggerElement Element passed to the function + */ private dispatchCallback(name: string, triggerElement?: HTMLElement) { const callback = this.callbacks[name]; if (callback) { @@ -168,7 +235,21 @@ export class Modal { } } + /** + * Bind the events that open and close the modal, trigger its commands and keep the focus inside it + */ private registerEvents() { + this.bindTriggers(); + this.bindKeyboard(); + this.bindFocus(); + this.bindBackdropClick(); + } + + /** + * Open the modal with the elements pointing to it, run its commands and close it with the + * dismiss button + */ + private bindTriggers() { document.addEventListener("click", (event) => { const target = (event.target as HTMLElement).closest(`[data-modal="${this.element.id}"]`) as HTMLElement; if (target) { @@ -184,7 +265,12 @@ export class Modal { const dismissButton = $("[data-dismiss]", this.element); dismissButton?.addEventListener("click", () => this.close({ triggerElement: dismissButton })); + } + /** + * Close the modal with Escape and keep Tab inside it + */ + private bindKeyboard() { document.addEventListener("keyup", (event) => { if (event.key === "Escape" && this.isOpen) { this.close(); @@ -196,13 +282,30 @@ export class Modal { this.trapFocus(event); } }); + } + /** + * Bring the focus back inside the modal when it moves out or the window gets it back + */ + private bindFocus() { document.addEventListener("focusin", (event) => { if (this.isTopmost && !this.element.contains(event.target as Node)) { this.getFirstFocusableElement(this.element).focus(); } }); + window.addEventListener("focus", () => { + if (this.element.classList.contains("open")) { + this.getFirstFocusableElement(this.element).focus(); + } + }); + } + + /** + * Close the modal when the backdrop is clicked, but not when a click that started inside the + * modal ends on the backdrop + */ + private bindBackdropClick() { let mousedownCaptured = false; this.element.addEventListener("mousedown", (event) => { @@ -215,22 +318,25 @@ export class Modal { } mousedownCaptured = false; }); - - window.addEventListener("focus", () => { - if (this.element.classList.contains("open")) { - this.getFirstFocusableElement(this.element).focus(); - } - }); } + /** + * Return whether the modal is the last one opened + */ private get isTopmost() { return this.isOpen && Modal.openModals[Modal.openModals.length - 1] === this; } + /** + * Get the visible elements of the modal that can receive the focus + */ private getFocusableElements(): HTMLElement[] { return Array.from($$(focusableSelector, this.element)).filter((element) => element.getClientRects().length > 0); } + /** + * Keep the focus inside the modal when Tab is pressed + */ private trapFocus(event: KeyboardEvent) { const focusable = this.getFocusableElements(); @@ -252,6 +358,11 @@ export class Modal { } } + /** + * Get the element that receives the focus when the modal is closed + * + * If the modal was opened from a dropdown menu it is the button of the menu + */ private resolveReturnFocusElement(element: HTMLElement | null): HTMLElement | null { // When the trigger lives in a dropdown menu, return focus to the button that opens that menu const menu = element?.closest(".dropdown-menu"); @@ -263,6 +374,9 @@ export class Modal { return element; } + /** + * Give the focus back to the element that opened the modal + */ private restoreFocus() { const element = this.returnFocusElement; @@ -273,6 +387,9 @@ export class Modal { } } + /** + * Get the element to focus when the modal is opened + */ private getFirstFocusableElement(parent: HTMLElement = document.body): HTMLElement { return parent.querySelector("[autofocus], button, .button, input:not([type=hidden]), select, textarea") || parent; } diff --git a/panel/src/ts/components/modals.ts b/panel/src/ts/services/modals.ts similarity index 84% rename from panel/src/ts/components/modals.ts rename to panel/src/ts/services/modals.ts index 1e9e0832b..d92b25501 100644 --- a/panel/src/ts/components/modals.ts +++ b/panel/src/ts/services/modals.ts @@ -1,6 +1,9 @@ import { $$ } from "../utils/selectors"; import { Modal } from "./modal"; +/** + * Collects the modals of the page, by id + */ export class Modals { [id: string]: Modal; constructor() { diff --git a/panel/src/ts/services/request.ts b/panel/src/ts/services/request.ts new file mode 100644 index 000000000..5528134ec --- /dev/null +++ b/panel/src/ts/services/request.ts @@ -0,0 +1,108 @@ +import type { NotificationType } from "../components/ui/notification"; +import { app } from "./app"; + +/** + * Represents the JSON response of the panel + */ +export interface PanelResponse { + status: NotificationType; + message: string; + code?: number | string; + /** + * Data of the response + */ + data: Data; +} + +/** + * Holds the options of a request + */ +interface RequestOptions { + method: string; + url: string; + data: Record | FormData | URLSearchParams | Blob; + headers: Record; +} + +const defaultOptions: RequestOptions = { + method: "GET", + url: "", + data: {}, + headers: {}, +}; + +/** + * Sends an AJAX request to the panel + * + * The callback receives the JSON response, or an error response if the request fails. Responses + * with code 400 or 403 are ignored + */ +export class Request { + constructor(userOptions: Partial, callback: (response: PanelResponse, request: XMLHttpRequest) => void) { + const request = new XMLHttpRequest(); + + const options: RequestOptions = Object.assign({}, defaultOptions, userOptions); + + if (!options.headers["X-Requested-With"]) { + options.headers["X-Requested-With"] = "XMLHttpRequest"; + } + + request.open(options.method, options.url, true); + + for (const key in options.headers) { + request.setRequestHeader(key, options.headers[key]); + } + + switch (true) { + case options.data instanceof FormData: + case options.data instanceof URLSearchParams: + case options.data instanceof Blob: + request.send(options.data); + break; + + default: + request.send(new URLSearchParams(options.data as Record)); + break; + } + + if (typeof callback === "function") { + const handler = () => { + let response: PanelResponse; + + try { + response = JSON.parse(request.response); + } catch { + // Network errors and non-JSON responses + response = { status: "error", message: request.statusText || "The request failed", data: {} as Data }; + } + + const code = parseInt(String(response.code), 10) || request.status; + if (code === 400 || code === 403) { + // location.reload(); + } else { + callback(response, request); + } + }; + request.onload = handler; + request.onerror = handler; + } + } +} + +/** + * Send a POST request with the CSRF token of the page + * + * @param data Data to send, the CSRF token is added to it + * @param callback Function called with the response + */ +export function post(url: string, data: Record | FormData, callback: (response: PanelResponse, request: XMLHttpRequest) => void) { + const token = app.config.csrfToken as string; + + if (data instanceof FormData) { + data.append("csrf-token", token); + } else { + data = { ...data, "csrf-token": token }; + } + + return new Request({ method: "POST", url, data }, callback); +} diff --git a/panel/src/ts/components/translation.ts b/panel/src/ts/services/translation.ts similarity index 57% rename from panel/src/ts/components/translation.ts rename to panel/src/ts/services/translation.ts index 997288700..2c7436c15 100644 --- a/panel/src/ts/components/translation.ts +++ b/panel/src/ts/services/translation.ts @@ -1,5 +1,8 @@ -import type { App } from "../app"; +import type { App } from "./app"; +/** + * Provides the translated strings of the panel, passed by PHP in the configuration + */ export class Translation { readonly code: string; @@ -9,10 +12,20 @@ export class Translation { ({ code: this.code, data: this.data } = app.config.translation); } + /** + * Return whether a string exists for a key + */ has(key: string): boolean { return key in this.data; } + /** + * Get the string of a key, or the default value if missing + * + * @param defaultValue Value returned if the key is missing + * + * @throws Error If the value is not a string + */ get(key: string, defaultValue?: string): string { const translation = this.data[key] ?? defaultValue; @@ -23,6 +36,13 @@ export class Translation { throw new Error(`Translation for key "${key}" is not a string.`); } + /** + * Get the list of strings of a key (e.g. singular and plural), or the default value if missing + * + * @param defaultValue Value returned if the key is missing + * + * @throws Error If the value is not a list + */ getStrings(key: string, defaultValue?: string[]): string[] { const translation = this.data[key] ?? defaultValue; diff --git a/panel/src/ts/utils/arrays.ts b/panel/src/ts/utils/arrays.ts deleted file mode 100644 index edda4b3be..000000000 --- a/panel/src/ts/utils/arrays.ts +++ /dev/null @@ -1,11 +0,0 @@ -export function arrayEquals(array1: Array, array2: Array) { - if (array1.length !== array2.length) { - return false; - } - for (let i = 0; i < array1.length; i++) { - if (array1[i] !== array2[i]) { - return false; - } - } - return true; -} diff --git a/panel/src/ts/utils/cookies.ts b/panel/src/ts/utils/cookies.ts index 7e15b7dc5..3c08b0fa8 100644 --- a/panel/src/ts/utils/cookies.ts +++ b/panel/src/ts/utils/cookies.ts @@ -1,3 +1,6 @@ +/** + * Get the cookies of the page + */ export function getCookies() { const result: Record = {}; const cookies = document.cookie.split(";"); @@ -10,6 +13,11 @@ export function getCookies() { return result; } +/** + * Set a cookie + * + * @param options Attributes of the cookie, e.g. `path` or `max-age` + */ export function setCookie(name: string, value: string, options: Record) { let cookie = `${name}=${value}`; for (const option in options) { diff --git a/panel/src/ts/utils/date.ts b/panel/src/ts/utils/date.ts new file mode 100644 index 000000000..610e8bcc9 --- /dev/null +++ b/panel/src/ts/utils/date.ts @@ -0,0 +1,153 @@ +import { mod } from "./math"; + +/** + * Holds the options to format a date + */ +export interface DateFormatOptions { + weekStarts: number; + labels: { + weekdays: { + long: string[]; + short: string[]; + }; + months: { + long: string[]; + short: string[]; + }; + }; +} + +/** + * Pad a number with leading zeros + */ +const pad = (value: number, length: number = 2) => `${value}`.padStart(length, "0"); + +/** + * Get the first day of the week of a date + * + * @param firstDay First day of the week, from 0 (Sunday) to 6 + */ +function getWeekStart(date: Date, firstDay: number) { + const day = date.getDate() - mod(date.getDay() - firstDay, 7); + + return new Date(date.getFullYear(), date.getMonth(), day); +} + +/** + * Get the year a date belongs to for the numbering of the weeks (ISO 8601): the first week of the + * year is the one with the fourth of January + */ +function getWeekNumberingYear(date: Date) { + const year = date.getFullYear(); + const thisYearFirstWeekStart = getWeekStart(new Date(year, 0, 4), 1); + const nextYearFirstWeekStart = getWeekStart(new Date(year + 1, 0, 4), 1); + + if (date.getTime() >= nextYearFirstWeekStart.getTime()) { + return year + 1; + } + + if (date.getTime() >= thisYearFirstWeekStart.getTime()) { + return year; + } + + return year - 1; +} + +/** + * Get the number of the week of a date (ISO 8601) + */ +function getWeekOfYear(date: Date) { + const firstWeekStart = getWeekStart(new Date(getWeekNumberingYear(date), 0, 4), 1); + const weekStart = getWeekStart(date, 1); + + return Math.round((weekStart.getTime() - firstWeekStart.getTime()) / 604800000) + 1; +} + +/** + * Split the offset of the timezone, in minutes, into the sign with the hours and the minutes + */ +function splitTimezoneOffset(offset: number) { + // Note that the offset returned by Date.getTimezoneOffset() + // is positive if behind UTC and negative if ahead UTC + const sign = offset > 0 ? "-" : "+"; + const hours = Math.floor(Math.abs(offset) / 60); + const minutes = Math.abs(offset) % 60; + + return [sign + pad(hours), pad(minutes)]; +} + +/** + * Format a date with a pattern + * + * @param format Pattern of the date: `YYYY`/`YY` year, `MM`/`M` month, `DD`/`D` day, `HH`/`H` + * 12-hour clock hours, `hh`/`h` 24-hour clock hours, `mm`/`m` minutes, `ss`/`s` + * seconds, `A`/`a` meridiem, `Z`/`z` timezone offset, `W` week of the year. Text in + * square brackets is left as is + * @param options Labels and first day of the week + */ +export function formatDateTime(date: Date, format: string, options: DateFormatOptions) { + const regex = /\[([^\]]*)\]|[YR]{4}|uuu|[YR]{2}|[MD]{1,4}|[WHhms]{1,2}|[AaZz]/g; + + const twelveHours = mod(date.getHours(), 12) || 12; + + return format.replace(regex, (match: string, escaped) => { + switch (match) { + case "YY": + return pad(date.getFullYear() % 100); + case "YYYY": + return date.getFullYear(); + case "M": + return date.getMonth() + 1; + case "MM": + return pad(date.getMonth() + 1); + case "MMM": + return options.labels.months.short[date.getMonth()]; + case "MMMM": + return options.labels.months.long[date.getMonth()]; + case "D": + return date.getDate(); + case "DD": + return pad(date.getDate()); + case "DDD": + return options.labels.weekdays.short[date.getDay()]; + case "DDDD": + return options.labels.weekdays.long[date.getDay()]; + case "W": + return getWeekOfYear(date); + case "WW": + return pad(getWeekOfYear(date)); + case "RR": + return pad(getWeekNumberingYear(date) % 100); + case "RRRR": + return getWeekNumberingYear(date); + case "H": + return twelveHours; + case "HH": + return pad(twelveHours); + case "h": + return date.getHours(); + case "hh": + return pad(date.getHours()); + case "m": + return date.getMinutes(); + case "mm": + return pad(date.getMinutes()); + case "s": + return date.getSeconds(); + case "ss": + return pad(date.getSeconds()); + case "uuu": + return pad(date.getMilliseconds(), 3); + case "A": + return date.getHours() < 12 ? "AM" : "PM"; + case "a": + return date.getHours() < 12 ? "am" : "pm"; + case "Z": + return splitTimezoneOffset(date.getTimezoneOffset()).join(":"); + case "z": + return splitTimezoneOffset(date.getTimezoneOffset()).join(""); + default: + return escaped || match; + } + }); +} diff --git a/panel/src/ts/utils/dimensions.ts b/panel/src/ts/utils/dimensions.ts index 513db8c81..58c7bfeed 100644 --- a/panel/src/ts/utils/dimensions.ts +++ b/panel/src/ts/utils/dimensions.ts @@ -1,8 +1,14 @@ +/** + * Get the width of an element including its margins + */ export function getOuterWidth(element: HTMLElement) { const style = getComputedStyle(element); return element.offsetWidth + parseInt(style.marginLeft, 10) + parseInt(style.marginRight, 10); } +/** + * Get the height of an element including its margins + */ export function getOuterHeight(element: HTMLElement) { const style = getComputedStyle(element); return element.offsetHeight + parseInt(style.marginTop, 10) + parseInt(style.marginBottom, 10); diff --git a/panel/src/ts/utils/duration.ts b/panel/src/ts/utils/duration.ts new file mode 100644 index 000000000..a29b37f56 --- /dev/null +++ b/panel/src/ts/utils/duration.ts @@ -0,0 +1,46 @@ +import { getSafeInteger } from "./numbers"; + +/** + * Seconds of each time interval + */ +export const TIME_INTERVALS = { + years: 60 * 60 * 24 * 365, + months: 60 * 60 * 24 * 30, + weeks: 60 * 60 * 24 * 7, + days: 60 * 60 * 24, + hours: 60 * 60, + minutes: 60, + seconds: 1, +}; + +export type TimeInterval = keyof typeof TIME_INTERVALS; + +export type TimeIntervals = Partial>; + +/** + * Split a number of seconds into time intervals + * + * @param intervalNames Intervals to split the seconds into + */ +export function secondsToIntervals(seconds: number, intervalNames: TimeInterval[] = Object.keys(TIME_INTERVALS) as TimeInterval[]) { + const intervals: TimeIntervals = {}; + seconds = getSafeInteger(seconds); + (Object.keys(TIME_INTERVALS) as TimeInterval[]).forEach((t) => { + if (intervalNames.includes(t)) { + intervals[t] = Math.floor(seconds / TIME_INTERVALS[t]); + seconds -= intervals[t] * TIME_INTERVALS[t]; + } + }); + return intervals; +} + +/** + * Get the number of seconds of time intervals + */ +export function intervalsToSeconds(intervals: TimeIntervals) { + let seconds = 0; + (Object.entries(intervals) as [TimeInterval, number][]).forEach(([interval, value]) => { + seconds += value * TIME_INTERVALS[interval]; + }); + return getSafeInteger(seconds); +} diff --git a/panel/src/ts/utils/events.ts b/panel/src/ts/utils/events.ts index 5e2bdbd67..288d7ae26 100644 --- a/panel/src/ts/utils/events.ts +++ b/panel/src/ts/utils/events.ts @@ -1,8 +1,16 @@ -export function debounce(callback: (...args: any[]) => any, delay: number, leading: boolean = false) { - let result: any; +/** + * Get a function that calls a callback after a delay since the last call + * + * If `leading` is true the callback is called at the first call instead + * + * @param delay Delay in milliseconds + * @param leading Whether the callback is called at the first call instead + */ +export function debounce(callback: (this: Context, ...args: Args) => Result, delay: number, leading: boolean = false) { + let result: Result | undefined; let timer: number | null = null; - function wrapper(this: any, ...args: any[]) { + function wrapper(this: Context, ...args: Args) { // biome-ignore lint/complexity/noUselessThisAlias: This alias is necessary to maintain the correct `this` context for the callback function const context = this; @@ -24,12 +32,17 @@ export function debounce(callback: (...args: any[]) => any, delay: number, leadi return wrapper; } -export function throttle(callback: (...args: any[]) => any, delay: number) { - let result: any; +/** + * Get a function that calls a callback at most once in a delay + * + * @param delay Minimum interval between calls in milliseconds + */ +export function throttle(callback: (this: Context, ...args: Args) => Result, delay: number) { + let result: Result | undefined; let previous = 0; let timer: number | null = null; - function wrapper(this: any, ...args: any[]) { + function wrapper(this: Context, ...args: Args) { const now = Date.now(); if (previous === 0) { previous = now; @@ -59,6 +72,12 @@ export function throttle(callback: (...args: any[]) => any, delay: number) { return wrapper; } +/** + * Call a callback when an element is pressed, and repeatedly while it stays pressed + * + * @param timeout Time in milliseconds before the callback is repeated + * @param interval Interval in milliseconds between repeated calls + */ export function longClick(element: HTMLElement, callback: (event: MouseEvent) => void, timeout: number, interval: number) { let timer: number; function clear() { @@ -69,7 +88,9 @@ export function longClick(element: HTMLElement, callback: (event: MouseEvent) => clear(); } else { callback.call(this, event); - timer = window.setTimeout(() => (timer = window.setInterval(callback.bind(this, event), interval)), timeout); + timer = window.setTimeout(() => { + timer = window.setInterval(callback.bind(this, event), interval); + }, timeout); } }); element.addEventListener("mouseout", clear); diff --git a/panel/src/ts/utils/forms.ts b/panel/src/ts/utils/forms.ts index 40f7491ee..348e17552 100644 --- a/panel/src/ts/utils/forms.ts +++ b/panel/src/ts/utils/forms.ts @@ -1,29 +1,45 @@ -export function serializeObject(object: Record) { - const serialized: string[] = []; - for (const property in object) { - serialized.push(`${encodeURIComponent(property)}=${encodeURIComponent(object[property])}`); - } - return serialized.join("&"); -} - +/** + * Serialize the fields of a form as a query string + * + * Disabled fields, buttons, file inputs, unchecked checkboxes and radios and fields with + * `data-form-ignore="true"` are left out + */ export function serializeForm(form: HTMLFormElement) { const serialized: string[] = []; + + const add = (name: string, value: string) => { + serialized.push(`${encodeURIComponent(name)}=${encodeURIComponent(value)}`); + }; + for (const field of Array.from(form.elements) as HTMLFormElement[]) { - if (field.name && !field.disabled && field.dataset.formIgnore !== "true" && field.type !== "file" && field.type !== "reset" && field.type !== "submit" && field.type !== "button") { - if (field.type === "select-multiple") { - for (const option of field.options) { - if (option.selected) { - serialized.push(`${encodeURIComponent(field.name)}=${encodeURIComponent(option.value)}`); - } + if (!isSerializable(field)) { + continue; + } + + if (field.type === "select-multiple") { + for (const option of field.options) { + if (option.selected) { + add(field.name, option.value); } - } else if ((field.type !== "checkbox" && field.type !== "radio") || field.checked) { - serialized.push(`${encodeURIComponent(field.name)}=${encodeURIComponent(field.value)}`); } + } else if (field.checked || (field.type !== "checkbox" && field.type !== "radio")) { + add(field.name, field.value); } } + return serialized.join("&"); } +/** + * Return whether a field is sent with the form and is considered in its changes + */ +function isSerializable(field: HTMLFormElement) { + return field.name && !field.disabled && field.dataset.formIgnore !== "true" && !["file", "reset", "submit", "button"].includes(field.type); +} + +/** + * Download a file sent by the server in answer to a POST request + */ export function triggerDownload(uri: string, csrfToken: string) { const form = document.createElement("form"); form.action = uri; diff --git a/panel/src/ts/utils/keyboard.ts b/panel/src/ts/utils/keyboard.ts new file mode 100644 index 000000000..c39e051f0 --- /dev/null +++ b/panel/src/ts/utils/keyboard.ts @@ -0,0 +1,24 @@ +/** + * Get the index of the item to move to with a key in a list navigated with the keyboard + * + * The arrows move to the next or previous item (wrapping around), Home and End to the first and + * last one + * + * @param key Pressed key + * @param index Index of the current item + * @param lastIndex Index of the last item + * + * @returns Index of the item, or `undefined` if the key does not move the focus + */ +export function getNextIndex(key: string, index: number, lastIndex: number) { + switch (key) { + case "ArrowRight": + return index === lastIndex ? 0 : index + 1; + case "ArrowLeft": + return index === 0 ? lastIndex : index - 1; + case "Home": + return 0; + case "End": + return lastIndex; + } +} diff --git a/panel/src/ts/utils/math.ts b/panel/src/ts/utils/math.ts index b73f4bf4f..36a2e4b62 100644 --- a/panel/src/ts/utils/math.ts +++ b/panel/src/ts/utils/math.ts @@ -1,7 +1,15 @@ +/** + * Limit a number to a range + */ export function clamp(value: number, min: number, max: number) { return Math.min(Math.max(value, min), max); } +/** + * Return the modulo of two numbers + * + * Unlike the `%` operator the result has the sign of the divisor + */ export function mod(x: number, y: number) { // Return x mod y (always rounded downwards, differs from x % y which is the remainder) return x - y * Math.floor(x / y); diff --git a/panel/src/ts/utils/numbers.ts b/panel/src/ts/utils/numbers.ts index ebabac40d..4a1638b92 100644 --- a/panel/src/ts/utils/numbers.ts +++ b/panel/src/ts/utils/numbers.ts @@ -1,3 +1,6 @@ +/** + * Limit a number to the safe integers + */ export function getSafeInteger(value: number) { const max = Number.MAX_SAFE_INTEGER; const min = -max; diff --git a/panel/src/ts/utils/request.ts b/panel/src/ts/utils/request.ts deleted file mode 100644 index 85680393e..000000000 --- a/panel/src/ts/utils/request.ts +++ /dev/null @@ -1,57 +0,0 @@ -interface RequestOptions { - method: string; - url: string; - data: Record | FormData; - headers: Record; -} - -const defaultOptions: RequestOptions = { - method: "GET", - url: "", - data: {}, - headers: {}, -}; - -export class Request { - constructor(userOptions: Partial, callback: (response: Record, request: XMLHttpRequest) => void) { - const request = new XMLHttpRequest(); - - const options: RequestOptions = Object.assign({}, defaultOptions, userOptions); - - if (!options.headers["X-Requested-With"]) { - options.headers["X-Requested-With"] = "XMLHttpRequest"; - } - - request.open(options.method, options.url, true); - - for (const key in options.headers) { - request.setRequestHeader(key, options.headers[key]); - } - - switch (true) { - case options.data instanceof FormData: - case options.data instanceof URLSearchParams: - case options.data instanceof Blob: - request.send(options.data); - break; - - default: - request.send(new URLSearchParams(options.data as Record)); - break; - } - - if (typeof callback === "function") { - const handler = () => { - const response = JSON.parse(request.response); - const code = parseInt(response.code, 10) || request.status; - if (code === 400 || code === 403) { - // location.reload(); - } else { - callback(response, request); - } - }; - request.onload = handler; - request.onerror = handler; - } - } -} diff --git a/panel/src/ts/utils/search.ts b/panel/src/ts/utils/search.ts new file mode 100644 index 000000000..9fffcf141 --- /dev/null +++ b/panel/src/ts/utils/search.ts @@ -0,0 +1,42 @@ +import { debounce } from "./events"; +import { escapeRegExp, makeDiacriticsRegExp } from "./validation"; + +/** + * Holds the options of a search regular expression + */ +interface SearchRegExpOptions { + /** + * Match only at the beginning of a word + */ + wordStart?: boolean; + /** + * Add the `g` flag + */ + global?: boolean; +} + +/** + * Make a regular expression matching a search text, ignoring case and diacritics + */ +export function makeSearchRegExp(value: string, { wordStart = true, global = false }: SearchRegExpOptions = {}) { + return new RegExp(`${wordStart ? "(^|\\b)" : ""}${makeDiacriticsRegExp(escapeRegExp(value))}`, global ? "gi" : "i"); +} + +/** + * Call a function when the user types in a search field or clears it + * + * The field is focused with Ctrl/Cmd+F + * + * @param onSearch Function called when the search text changes + */ +export function initSearchInput(input: HTMLInputElement, onSearch: () => void) { + input.addEventListener("keyup", debounce(onSearch, 100)); + input.addEventListener("search", onSearch); + + document.addEventListener("keydown", (event) => { + if ((event.ctrlKey || event.metaKey) && event.key === "f" && document.activeElement !== input) { + input.focus(); + event.preventDefault(); + } + }); +} diff --git a/panel/src/ts/utils/strings.ts b/panel/src/ts/utils/strings.ts index 56b328369..574f21a39 100644 --- a/panel/src/ts/utils/strings.ts +++ b/panel/src/ts/utils/strings.ts @@ -1,3 +1,6 @@ +/** + * Convert a string to camel case + */ export function toCamelCase(string: string) { return string .split(/[-_ ]+/) diff --git a/panel/src/ts/utils/toolbar.ts b/panel/src/ts/utils/toolbar.ts index ebb50f62a..787d2c7ef 100644 --- a/panel/src/ts/utils/toolbar.ts +++ b/panel/src/ts/utils/toolbar.ts @@ -1,6 +1,11 @@ +import { getNextIndex } from "./keyboard"; + /** - * Turn an element containing buttons into an ARIA toolbar with a roving tabindex: - * the toolbar is a single tab stop and its buttons are reached with the arrow keys. + * Make an element containing buttons an ARIA toolbar + * + * The toolbar is a single tab stop and its buttons are reached with the arrow keys + * + * @param labelledBy Id of the element labelling the toolbar */ export function initToolbar(toolbar: HTMLElement, labelledBy?: string) { toolbar.setAttribute("role", "toolbar"); @@ -49,14 +54,7 @@ export function initToolbar(toolbar: HTMLElement, labelledBy?: string) { return; } - const lastIndex = buttons.length - 1; - - const nextIndex = { - ArrowRight: index === lastIndex ? 0 : index + 1, - ArrowLeft: index === 0 ? lastIndex : index - 1, - Home: 0, - End: lastIndex, - }[event.key]; + const nextIndex = getNextIndex(event.key, index, buttons.length - 1); if (nextIndex !== undefined) { event.preventDefault(); diff --git a/panel/src/ts/utils/validation.ts b/panel/src/ts/utils/validation.ts index 9bdec0a1f..c9b89f32f 100644 --- a/panel/src/ts/utils/validation.ts +++ b/panel/src/ts/utils/validation.ts @@ -1,3 +1,6 @@ +/** + * Replacements of the characters not allowed in a slug + */ const slugMap: Record = { "'": "-", "’": "-", @@ -82,6 +85,9 @@ const slugMap: Record = { Ÿ: "y", }; +/** + * Characters matching each letter in a search ignoring diacritics + */ const diacriticsMap: Record = { a: "[aáàăâǎåäãȧąāảȁạ]", b: "[bḃḅ]", @@ -109,6 +115,9 @@ const diacriticsMap: Record = { z: "[zźẑžżẓ]", }; +/** + * HTML entities of the escaped characters + */ const htmlEntityMap: Record = { "&": "&", "<": "<", @@ -120,14 +129,23 @@ const htmlEntityMap: Record = { "=": "=", }; +/** + * Escape the characters of a string having a special meaning in a regular expression + */ export function escapeRegExp(string: string) { return string.replace(/[-[\]/{}()*+?.\\^$|]/g, "\\$&"); } +/** + * Make a regular expression string ignore diacritics + */ export function makeDiacriticsRegExp(string: string) { return string.replace(/./g, (match) => diacriticsMap[match] || diacriticsMap[match.toLowerCase()]?.toUpperCase() || match); } +/** + * Make a slug from a text + */ export function makeSlug(string: string) { return string .toLowerCase() @@ -137,10 +155,18 @@ export function makeSlug(string: string) { .replace(/^-+|-+$|[^a-z0-9-]/g, ""); } +/** + * Escape the characters of a string having a special meaning in HTML + */ export function escapeHtml(string: string) { return string.replace(/[&<>"'`=/]/g, (match) => htmlEntityMap[match]); } +/** + * Make a string a valid slug while typing + * + * Leading and trailing hyphens are kept + */ export function validateSlug(slug: string) { return slug .toLowerCase() diff --git a/panel/src/ts/views/backups.ts b/panel/src/ts/views/backups.ts new file mode 100644 index 000000000..d32c542be --- /dev/null +++ b/panel/src/ts/views/backups.ts @@ -0,0 +1,113 @@ +import { effect, signal } from "@preact/signals-core"; +import { Notification } from "../components/ui/notification"; +import { renderSpinner, type SpinnerStatus } from "../components/ui/spinner"; +import { app } from "../services/app"; +import { post } from "../services/request"; +import { triggerDownload } from "../utils/forms"; +import { $, $$ } from "../utils/selectors"; + +/** + * Describes a backup sent by the server + */ +interface BackupData { + uri: string; + filename: string; + date: string; + size: string; + deleteUri: string; + maxFiles: number; +} + +/** + * Initialize the button to make a backup + */ +export function initBackups(view: HTMLElement) { + const button = $("[data-command=make-backup]", view); + + if (!button) { + return; + } + + // `null` means that no backup has been requested yet + const status = signal(null); + + effect(() => { + if (status.value === null) { + return; + } + + let spinner = $(".spinner"); + + if (!spinner) { + spinner = document.createElement("div"); + button.insertAdjacentElement("afterend", spinner); + } + + renderSpinner(spinner, status.value); + }); + + effect(() => { + button.disabled = status.value === "pending"; + }); + + button.addEventListener("click", () => { + status.value = "pending"; + + post(`${app.config.baseUri}backup/make/`, {}, (response) => { + if (response.status === "success") { + status.value = "success"; + + addBackup(response.data); + } + + if (response.status === "error") { + status.value = "danger"; + } + + const notification = new Notification(response.message, response.status, { icon: "checkCircle" }); + notification.show(); + + if (response.status === "success") { + window.setTimeout(() => { + triggerDownload(response.data.uri, app.config.csrfToken as string); + }, 1000); + } + }); + }); +} + +/** + * Add a backup to the table of the backups, keeping only the most recent ones + * + * @param data Data of the backup sent by the server + */ +function addBackup(data: BackupData) { + const template = $("#backups-row") as HTMLTemplateElement; + + if (!template) { + return; + } + + const table = $("#backups-table") as HTMLTableElement; + + const node = template.content.cloneNode(true) as HTMLElement; + + ($(".backup-uri", node) as HTMLAnchorElement).href = data.uri; + ($(".backup-uri", node) as HTMLElement).textContent = data.filename; + + ($(".backup-date", node) as HTMLElement).textContent = data.date; + ($(".backup-size", node) as HTMLElement).textContent = data.size; + ($(".backup-delete", node) as HTMLElement).dataset.modalAction = data.deleteUri; + + ($(".backup-last-time") as HTMLElement).textContent = app.translation.get("date.now", "Now"); + + ($("tbody", table) as HTMLElement).prepend(node); + + $$("tr", table).forEach((row, index) => { + if (index + 1 > data.maxFiles) { + row.remove(); + } + }); + + ($("#backups-section") as HTMLDivElement).hidden = false; +} diff --git a/panel/src/ts/views/dashboard.ts b/panel/src/ts/views/dashboard.ts new file mode 100644 index 000000000..6c82d5515 --- /dev/null +++ b/panel/src/ts/views/dashboard.ts @@ -0,0 +1,44 @@ +import { Notification } from "../components/ui/notification"; +import { initStatisticsChart } from "../components/ui/statistics-chart"; +import { app } from "../services/app"; +import { post } from "../services/request"; +import { $ } from "../utils/selectors"; + +/** + * Caches that can be cleared, by command + */ +const clearCacheCommands: Record = { + "clear-cache": undefined, + "clear-pages-cache": "pages", + "clear-images-cache": "images", + "clear-config-cache": "config", + "clear-all-cache": "all", +}; + +/** + * Clear a cache of the site, or all of them if no type is given + * + * @param type Type of cache to clear: `pages`, `images`, `config` or `all` + */ +function clearCache(type?: string) { + post(`${app.config.baseUri}cache/clear/${type ?? ""}/`.replace(/\/+$/, "/"), {}, (response) => { + const icon = response.status === "error" ? "exclamationOctagon" : "checkCircle"; + const notification = new Notification(response.message, response.status, { icon }); + notification.show(); + }); +} + +/** + * Initialize the dashboard + */ +export function initDashboard(view: HTMLElement) { + for (const [command, type] of Object.entries(clearCacheCommands)) { + $(`[data-command=${command}]`, view)?.addEventListener("click", () => clearCache(type)); + } + + const chart = $(".dashboard-chart"); + + if (chart) { + initStatisticsChart(chart); + } +} diff --git a/panel/src/ts/views/files.ts b/panel/src/ts/views/files.ts new file mode 100644 index 000000000..316fa4aef --- /dev/null +++ b/panel/src/ts/views/files.ts @@ -0,0 +1,81 @@ +import { FilesList } from "../components/files/files-list"; +import { app } from "../services/app"; +import { $ } from "../utils/selectors"; + +/** + * Initialize the list of the files of a page + */ +export function initFilesView(view: HTMLElement) { + new FilesList($(".files-list", view) as HTMLElement); +} + +/** + * Initialize the form of a single file + */ +export function initFileForm() { + initRenameFileModal(); + initReplaceFileCommand(); +} + +/** + * Initialize the modal to rename the file + */ +function initRenameFileModal() { + const renameFileModal = app.modals.renameFileModal; + + if (!renameFileModal) { + return; + } + + renameFileModal.form?.element.addEventListener("keydown", (event) => { + if (event.key === "Enter") { + renameFileModal.form?.element.submit(); + event.preventDefault(); + } + }); + + renameFileModal.onOpen((modal, trigger) => { + if (trigger) { + const input = $('[id="renameFileModal.filename"]', modal.element) as HTMLInputElement; + input.value = trigger.dataset.filename as string; + input.setSelectionRange(0, input.value.lastIndexOf(".")); + } + }); +} + +/** + * Initialize the command to replace the file + */ +function initReplaceFileCommand() { + const replaceFileCommand = $("[data-command=replaceFile]"); + + if (!replaceFileCommand) { + return; + } + + replaceFileCommand.addEventListener("click", () => { + const form = document.createElement("form"); + form.hidden = true; + form.action = replaceFileCommand.dataset.action as string; + form.method = "post"; + form.enctype = "multipart/form-data"; + + const fileInput = document.createElement("input"); + fileInput.name = "file"; + fileInput.type = "file"; + fileInput.accept = replaceFileCommand.dataset.extension as string; + form.appendChild(fileInput); + + const csrfInput = document.createElement("input"); + csrfInput.name = "csrf-token"; + csrfInput.value = app.config.csrfToken as string; + form.appendChild(csrfInput); + + fileInput.click(); + + fileInput.addEventListener("change", () => { + document.body.appendChild(form); + form.submit(); + }); + }); +} diff --git a/panel/src/ts/views/login.ts b/panel/src/ts/views/login.ts new file mode 100644 index 000000000..78f564e21 --- /dev/null +++ b/panel/src/ts/views/login.ts @@ -0,0 +1,10 @@ +/** + * Prevent the login form from being submitted twice + */ +export function initLogin(form: HTMLElement) { + form.addEventListener("submit", ({ submitter }) => { + if (submitter instanceof HTMLButtonElement) { + submitter.disabled = true; + } + }); +} diff --git a/panel/src/ts/views/pages.ts b/panel/src/ts/views/pages.ts new file mode 100644 index 000000000..1c1b82573 --- /dev/null +++ b/panel/src/ts/views/pages.ts @@ -0,0 +1,419 @@ +import { effect, signal } from "@preact/signals-core"; +import type { MoveEvent, SortableEvent } from "sortablejs"; +import type { SelectInput } from "../components/inputs/select-input"; +import { Notification } from "../components/ui/notification"; +import { type PagesTree, PagesTreeModel } from "../models/pages-tree"; +import { app } from "../services/app"; +import type { Form } from "../services/form"; +import type { Modal } from "../services/modal"; +import { post } from "../services/request"; +import { debounce } from "../utils/events"; +import { initSearchInput } from "../utils/search"; +import { $, $$ } from "../utils/selectors"; +import { escapeHtml, makeSlug } from "../utils/validation"; + +/** + * Initialize the tree of the pages + */ +export function initPagesTree() { + const tree = new PagesTreeModel($("[data-command=reorder-pages]")?.classList.contains("active") ?? false); + + renderTree(tree); + initTreeToggles(tree); + initTreeCommands(tree); + initSearch(tree); + initDeletePageModal(tree); +} + +/** + * Update the classes of the tree items from the model + */ +function renderTree(tree: PagesTree) { + effect(() => { + for (const item of tree.items.value) { + const isExpanded = tree.filtering.value || tree.expanded.value.has(item); + + item.classList.toggle("has-children", tree.parents.value.has(item)); + item.classList.toggle("is-expanded", isExpanded); + + $(".pages-tree-children-toggle button", item)?.setAttribute("aria-expanded", String(isExpanded)); + } + }); +} + +/** + * Expand or collapse a page when its details are clicked + */ +function initTreeToggles(tree: PagesTree) { + $$(".page-details").forEach((element) => { + if ($(".pages-tree-children-toggle", element)) { + element.addEventListener("click", (event) => { + const target = event.target as HTMLElement; + if (target.closest("a, .sortable-handle")) { + return; + } + const item = element.closest(".pages-tree-item"); + if (item) { + tree.toggle(item); + } + event.preventDefault(); + }); + } + }); +} + +/** + * Initialize the commands to expand, collapse and reorder the pages + */ +function initTreeCommands(tree: PagesTree) { + bindCommand( + "expand-all-pages", + () => tree.expandAll(), + () => tree.expandable.value.length === tree.expandedCount.value, + ); + + bindCommand( + "collapse-all-pages", + () => tree.collapseAll(), + () => tree.expandedCount.value === 0, + ); + + const reorderCommand = bindCommand( + "reorder-pages", + () => tree.toggleReordering(), + () => tree.orderableCount.value <= 1, + ); + + if (reorderCommand) { + effect(() => { + reorderCommand.classList.toggle("active", tree.reordering.value); + ($(".pages-tree") as HTMLElement).classList.toggle("is-reordering", tree.reordering.value); + }); + } +} + +/** + * Bind a command of the tree, if the page has it: the button runs an action and is disabled + * according to the state of the tree + * + * @param name Value of the `data-command` attribute + * @param action Function called when the button is clicked + * @param isDisabled Function returning whether the button is disabled + */ +function bindCommand(name: string, action: () => void, isDisabled: () => boolean) { + const button = $(`[data-command=${name}]`); + + if (!button) { + return null; + } + + button.addEventListener("click", () => { + action(); + button.blur(); + }); + + effect(() => { + button.disabled = isDisabled(); + }); + + return button; +} + +/** + * Filter the tree while typing in the search field + */ +function initSearch(tree: PagesTree) { + const searchInput = $(".page-search") as HTMLInputElement; + + if (!searchInput) { + return; + } + + effect(() => { + if (tree.query.value !== null) { + filterPages(tree); + } + }); + + initSearchInput(searchInput, () => tree.search(escapeHtml(searchInput.value))); +} + +/** + * Show only the pages matching the search of the tree, highlighting the matches + */ +function filterPages(tree: PagesTree) { + const regexp = tree.searchRegExp.value; + + ($(".pages-tree-root") as HTMLElement).classList.toggle("is-filtered", regexp !== null); + + for (const item of tree.items.value) { + const title = $(".page-title a", item) as HTMLElement; + const row = $(".pages-tree-row", item) as HTMLElement; + + if (regexp === null) { + row.style.display = ""; + continue; + } + + const text = escapeHtml(title.textContent); + + if (text.match(regexp) !== null) { + title.innerHTML = text.replace(regexp, "$&"); + row.style.display = ""; + } else { + row.style.display = "none"; + } + } +} + +/** + * Allow only the templates accepted by the parent of the new page + */ +export function initNewPageModal(newPageModal: Modal) { + const form = newPageModal.form as Form; + + removeCreateNewParameter(); + + const parentSelect = form.inputs["newPageModal[parent]"] as SelectInput; + const templateSelect = form.inputs["newPageModal[template]"] as SelectInput; + + parentSelect.element.addEventListener("change", () => limitTemplates(parentSelect, templateSelect)); + + limitTemplates(parentSelect, templateSelect); +} + +/** + * Remove the `createNew` parameter, which opens the modal, from the address of the page + */ +function removeCreateNewParameter() { + const url = new URL(window.location.href); + + if (url.searchParams.has("createNew")) { + url.searchParams.delete("createNew"); + window.history.replaceState({}, document.title, url.toString()); + } +} + +/** + * Disable the templates that the selected parent does not accept, selecting the first allowed one + * + * When the parent accepts all templates the previous selection is restored. + * + * @param parentSelect Select of the parent page + * @param templateSelect Select of the template + */ +function limitTemplates(parentSelect: SelectInput, templateSelect: SelectInput) { + const parent = parentSelect.selectedDropdownItem; + + if (!parent) { + return; + } + + const allowedTemplates = parent.dataset.allowedTemplates ? parent.dataset.allowedTemplates.split(" ") : []; + + if (allowedTemplates.length > 0) { + templateSelect.element.dataset.previousValue = templateSelect.value; + + templateSelect.value = allowedTemplates[0]; + + templateSelect.dropdownItems.forEach((item) => { + if (!allowedTemplates.includes(item.dataset.value as string)) { + item.classList.add("disabled"); + } + }); + } else { + if ("previousValue" in templateSelect.element.dataset) { + templateSelect.value = templateSelect.element.dataset.previousValue as string; + delete templateSelect.element.dataset.previousValue; + } + + templateSelect.dropdownItems.forEach((item) => { + item.classList.remove("disabled"); + }); + } +} + +/** + * Initialize the modal to delete a page + */ +function initDeletePageModal(tree: PagesTree) { + const deletePageItemModal = app.modals.deletePageItemModal; + + if (!deletePageItemModal) { + return; + } + + deletePageItemModal.onOpen((modal, trigger) => { + if (trigger) { + Object.assign(modal.data, { + action: trigger.dataset.action, + pageItem: trigger.closest(".pages-tree-item"), + }); + } + }); + + deletePageItemModal.onCommand("delete-page", (modal) => { + const { action, pageItem } = modal.data as { action: string; pageItem: HTMLElement }; + + post(action, {}, (response) => { + if (response.status === "success" && pageItem) { + removePage(tree, pageItem); + } + + const notification = new Notification(response.message, response.status); + notification.show(); + + modal.close(); + }); + }); +} + +/** + * Remove a deleted page from the tree, enabling the commands of its parent if it has no children + * left + */ +function removePage(tree: PagesTree, pageItem: HTMLElement) { + const parentItem = pageItem.parentElement?.closest(".pages-tree-item"); + pageItem.remove(); + + const emptiedParent = tree.remove(pageItem, parentItem); + + if (emptiedParent) { + for (const modalId of ["deletePageItemModal", "duplicatePageModal"]) { + const button = $(`[data-modal=${modalId}]`, emptiedParent); + + if (button) { + button.disabled = false; + } + } + } +} + +/** + * Fill the title and the slug of the page to duplicate + */ +export function initDuplicatePageModal(duplicatePageModal: Modal) { + duplicatePageModal.onOpen((modal, trigger) => { + if (trigger && modal.form) { + const duplicateTitle = trigger.dataset.duplicateTitle; + + if (duplicateTitle) { + const title = $('[name="duplicatePageModal[title]"]', modal.form.element) as HTMLInputElement; + const slug = $('[name="duplicatePageModal[slug]"]', modal.form.element) as HTMLInputElement; + title.value = duplicateTitle; + slug.value = makeSlug(duplicateTitle); + title.setSelectionRange(0, title.value.length); + } + } + }); +} + +/** + * Disable the preview if the parent page has changed, and mark it if the form has unsaved changes + */ +export function initPagePreview(editorFormElement: HTMLElement) { + const commandPreview = $("[data-command=preview]") as HTMLButtonElement; + const editorForm = app.forms[editorFormElement.dataset.form as string]; + + if (!commandPreview) { + return; + } + + const parentInput = editorForm.inputs.parent; + const previousParent = parentInput?.value; + + const parentChanged = signal(false); + + editorForm.element.addEventListener( + "input", + debounce(() => { + parentChanged.value = parentInput?.value !== previousParent; + }, 500), + ); + + effect(() => { + commandPreview.disabled = parentChanged.value; + commandPreview.classList.toggle("button-indicator", !parentChanged.value && editorForm.changed.value); + }); +} + +/** + * Make the pages of a tree level sortable + */ +export function initSortable(element: HTMLElement) { + void createSortable(element); +} + +/** + * Make the pages of a tree level sortable and save the new order + * + * If the server refuses the new order the previous one is restored + */ +async function createSortable(element: HTMLElement) { + const { default: Sortable } = await import("sortablejs"); + + let originalOrder: string[] = []; + + const sortable = Sortable.create(element, { + handle: ".sortable-handle", + filter: ".is-not-orderable", + forceFallback: true, + swapThreshold: 0.75, + invertSwap: true, + animation: 150, + preventOnFilter: false, // Workaround to allow touch events on iOS + + onChoose() { + const height = document.body.offsetHeight; + document.body.style.height = `${height}px`; + + const e = () => { + window.document.body.style.height = ""; + window.removeEventListener("scroll", e); + }; + window.addEventListener("scroll", e); + }, + + onStart() { + element.classList.add("is-dragging"); + }, + + onMove(event: MoveEvent) { + if (event.related.classList.contains("is-not-orderable")) { + return false; + } + }, + + onEnd(event: SortableEvent) { + element.classList.remove("is-dragging"); + + document.body.style.height = ""; + + if (event.newIndex === event.oldIndex) { + return; + } + + sortable.option("disabled", true); + + const data = { + page: event.item.dataset.route, + before: (event.item.nextElementSibling as HTMLElement).dataset.route, + parent: element.dataset.parent, + }; + + post(`${app.config.baseUri}pages/reorder/`, data, (response) => { + if (response.status) { + const notification = new Notification(response.message, response.status, { icon: "checkCircle" }); + notification.show(); + } + if (!response.status || response.status === "error") { + sortable.sort(originalOrder); + } + sortable.option("disabled", false); + originalOrder = sortable.toArray(); + }); + }, + }); + + originalOrder = sortable.toArray(); +} diff --git a/panel/src/ts/views/plugins.ts b/panel/src/ts/views/plugins.ts new file mode 100644 index 000000000..10473565c --- /dev/null +++ b/panel/src/ts/views/plugins.ts @@ -0,0 +1,47 @@ +import { TogglegroupInput } from "../components/inputs/togglegroup-input"; +import { Notification } from "../components/ui/notification"; +import { app } from "../services/app"; +import { post } from "../services/request"; +import { throttle } from "../utils/events"; + +/** + * Enable or disable a plugin when its toggle changes + */ +export function initPluginToggle(toggle: HTMLInputElement) { + const togglegroup = new TogglegroupInput(toggle.closest(".form-togglegroup") as HTMLFieldSetElement); + const action = toggle.dataset.action; + + const form = app.forms["plugin-form"]; + + toggle.addEventListener("change", () => { + if (!action) { + return; + } + + const switchToggle = () => { + togglegroup.value = toggle.value === "1" ? "0" : "1"; + }; + + if (toggle.value === "1" && form && !form.element.noValidate && !form.element.reportValidity()) { + switchToggle(); + return; + } + + togglegroup.element.disabled = true; + + throttle(() => { + post(action, {}, (response) => { + if (response.status === "success" && !form?.hasChanged()) { + window.location.reload(); + } else { + const notification = new Notification(response.message, response.status); + notification.show(); + if (response.status === "error") { + switchToggle(); + } + togglegroup.element.disabled = false; + } + }); + }, 500)(); + }); +} diff --git a/panel/src/ts/views/updates.ts b/panel/src/ts/views/updates.ts new file mode 100644 index 000000000..76c2b0f0a --- /dev/null +++ b/panel/src/ts/views/updates.ts @@ -0,0 +1,61 @@ +import { effect } from "@preact/signals-core"; +import { Notification } from "../components/ui/notification"; +import { renderSpinner } from "../components/ui/spinner"; +import { type UpdateCheck, type UpdateInstallation, UpdatesModel } from "../models/updates"; +import { app } from "../services/app"; +import { post } from "../services/request"; +import { $ } from "../utils/selectors"; + +/** + * Check for updates and install them + */ +export function initUpdates(updater: HTMLElement) { + const updateStatus = $(".update-status", updater) as HTMLElement; + const spinner = $(".spinner", updater) as HTMLElement; + const currentVersion = $(".current-version", updater) as HTMLElement; + const currentVersionName = $(".current-version-name", updater) as HTMLElement; + const newVersion = $(".new-version", updater) as HTMLElement; + const newVersionName = $(".new-version-name", updater) as HTMLElement; + const installCommand = $("[data-command=install-updates]", updater) as HTMLElement; + + const updates = new UpdatesModel(); + + effect(() => { + renderSpinner(spinner, updates.spinnerStatus.value); + newVersion.style.display = updates.newVersionVisible.value ? "block" : "none"; + currentVersion.style.display = updates.currentVersionVisible.value ? "block" : "none"; + }); + + effect(() => { + if (updates.message.value !== null) { + updateStatus.textContent = updates.message.value; + } + }); + + effect(() => { + if (updates.releaseName.value !== null) { + newVersionName.textContent = updates.releaseName.value; + } + }); + + effect(() => { + if (updates.phase.value === "installed") { + currentVersionName.textContent = newVersionName.textContent; + } + }); + + window.setTimeout(() => { + post(`${app.config.baseUri}updates/check/`, {}, (response) => updates.checked(response)); + }, 1000); + + installCommand.addEventListener("click", () => { + updates.installing(updateStatus.dataset.installingText as string); + + post(`${app.config.baseUri}updates/update/`, {}, (response) => { + const notification = new Notification(response.message, response.status, { icon: "checkCircle" }); + notification.show(); + + updates.installed(response); + }); + }); +} diff --git a/panel/views/fields/date.php b/panel/views/fields/date.php index 2b7ffa76a..fa8285e1a 100644 --- a/panel/views/fields/date.php +++ b/panel/views/fields/date.php @@ -14,5 +14,5 @@ 'hidden' => $field->isHidden(), 'data-time' => $field->hasTime() ? 'true' : 'false', ]) ?>> - icon('times-circle') ?> - + + \ No newline at end of file diff --git a/panel/views/fields/slug.php b/panel/views/fields/slug.php index baae0a0fc..9f3f214f5 100644 --- a/panel/views/fields/slug.php +++ b/panel/views/fields/slug.php @@ -21,7 +21,7 @@ 'data-auto-update' => $field->autoUpdate() ? 'true' : 'false', ]) ?>> autoUpdate()): ?> - icon('sparks') ?> + diff --git a/panel/views/pages/index.php b/panel/views/pages/index.php index 6d8f6ff2c..b57f19b65 100644 --- a/panel/views/pages/index.php +++ b/panel/views/pages/index.php @@ -29,7 +29,7 @@
icon('search') ?> - +
diff --git a/panel/views/partials/files/file/list.php b/panel/views/partials/files/file/list.php index 6ab466d29..b0987d23a 100644 --- a/panel/views/partials/files/file/list.php +++ b/panel/views/partials/files/file/list.php @@ -5,7 +5,7 @@
icon('search') ?> - +