From 10e0c98f3e7bc71e703e4c3a61f81a2b1b3a3f39 Mon Sep 17 00:00:00 2001 From: Giuseppe Criscione <18699708+giuscris@users.noreply.github.com> Date: Sat, 24 Oct 2026 18:18:20 +0200 Subject: [PATCH 1/8] Add `@preact/signals-core` and enable stricter lint rules in the panel Add the signals library used to derive the state of the panel scripts, update Biome to 2.6 and enable the rules for import cycles, floating promises, unused expressions, loop functions and explicit `any`. Co-Authored-By: Claude Sonnet 5.5 --- panel/biome.json | 19 ++++++-- panel/package.json | 3 +- panel/pnpm-lock.yaml | 102 +++++++++++++++++++++++++++---------------- 3 files changed, 83 insertions(+), 41 deletions(-) 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/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': {} From 92d5741311f9776b717dfbd43281c5e83e881e76 Mon Sep 17 00:00:00 2001 From: Giuseppe Criscione <18699708+giuscris@users.noreply.github.com> Date: Sat, 24 Oct 2026 18:18:41 +0200 Subject: [PATCH 2/8] Restructure the panel scripts and derive their state with signals Group the scripts in `services`, `components`, `views`, `models` and `utils`, with a single entry point (`index.ts`) that registers what the panel starts. `app` no longer imports any component, so there are no import cycles. Keep the state that was read from the DOM in signals, with models for the pages tree, the calendar and the updater, and move each view to a function started only when the page contains its element. Share the inputs base class, the dropdown list of the select and tags inputs, the `post()` request helper with the CSRF token and the synchronization of the file inputs. Split the longest methods, fix the quirks found in the select and tags inputs (stale filters, selectors broken by special characters, empty tags), document the methods with TSDoc and replace every explicit `any` with a type. Co-Authored-By: Claude Sonnet 5.5 --- panel/build.js | 2 +- panel/src/ts/app.ts | 117 --- .../code/menu.ts => editor/code-menu.ts} | 39 +- .../code/view.ts => editor/code-view.ts} | 40 +- .../markdown-commands.ts} | 35 +- .../markdown-input-rules.ts} | 20 + .../keymap.ts => editor/markdown-keymap.ts} | 8 + .../editor/markdown-link-tooltip.ts | 141 ++++ .../menu.ts => editor/markdown-menu.ts} | 214 +++--- .../markdown-placeholder.ts} | 5 + .../view.ts => editor/markdown-view.ts} | 41 +- .../ts/components/editor/toolbar-button.ts | 20 + panel/src/ts/components/files.ts | 66 -- panel/src/ts/components/files/file-inputs.ts | 95 +++ panel/src/ts/components/files/files-list.ts | 588 +++++++++++++++ panel/src/ts/components/fileslist.ts | 504 ------------- panel/src/ts/components/form.ts | 264 ------- panel/src/ts/components/inputs/array-input.ts | 77 +- panel/src/ts/components/inputs/calendar.ts | 383 ++++++++++ panel/src/ts/components/inputs/color-input.ts | 31 +- panel/src/ts/components/inputs/date-input.ts | 634 ++-------------- .../src/ts/components/inputs/dropdown-list.ts | 197 +++++ .../ts/components/inputs/duration-input.ts | 292 ++++---- .../src/ts/components/inputs/editor-input.ts | 269 ++++--- .../inputs/editor/markdown/linktooltip.ts | 115 --- .../src/ts/components/inputs/image-picker.ts | 91 +-- panel/src/ts/components/inputs/input.ts | 58 +- panel/src/ts/components/inputs/range-input.ts | 32 +- .../src/ts/components/inputs/select-input.ts | 690 +++++++++-------- panel/src/ts/components/inputs/slug-input.ts | 28 +- panel/src/ts/components/inputs/tags-input.ts | 704 +++++++++--------- .../ts/components/inputs/togglegroup-input.ts | 25 +- .../src/ts/components/inputs/upload-input.ts | 365 +++++---- panel/src/ts/components/navigation.ts | 52 -- panel/src/ts/components/notification.ts | 139 ---- panel/src/ts/components/sections.ts | 26 - panel/src/ts/components/tabs.ts | 54 -- panel/src/ts/components/tooltip.ts | 177 ----- panel/src/ts/components/tooltips.ts | 112 --- .../ts/components/{ => ui}/color-scheme.ts | 7 +- panel/src/ts/components/{ => ui}/dropdowns.ts | 49 +- panel/src/ts/components/{ => ui}/icons.ts | 0 panel/src/ts/components/ui/navigation.ts | 88 +++ panel/src/ts/components/ui/notification.ts | 185 +++++ .../ts/components/{ => ui}/notifications.ts | 5 +- panel/src/ts/components/ui/sections.ts | 31 + panel/src/ts/components/ui/spinner.ts | 24 + .../components/{ => ui}/statistics-chart.ts | 60 +- panel/src/ts/components/ui/tabs.ts | 80 ++ panel/src/ts/components/ui/tooltip.ts | 195 +++++ panel/src/ts/components/ui/tooltips.ts | 141 ++++ panel/src/ts/components/views/backups.ts | 95 --- panel/src/ts/components/views/dashboard.ts | 59 -- panel/src/ts/components/views/login.ts | 11 - panel/src/ts/components/views/pages.ts | 377 ---------- panel/src/ts/components/views/plugins.ts | 56 -- panel/src/ts/components/views/statistics.ts | 14 - panel/src/ts/components/views/updates.ts | 95 --- panel/src/ts/index.ts | 57 ++ panel/src/ts/models/calendar.ts | 253 +++++++ panel/src/ts/models/pages-tree.ts | 107 +++ panel/src/ts/models/updates.ts | 91 +++ panel/src/ts/services/app.ts | 166 +++++ panel/src/ts/services/form.ts | 363 +++++++++ .../src/ts/{components => services}/forms.ts | 3 + .../src/ts/{components => services}/modal.ts | 129 +++- .../src/ts/{components => services}/modals.ts | 3 + panel/src/ts/services/request.ts | 108 +++ .../{components => services}/translation.ts | 22 +- panel/src/ts/utils/arrays.ts | 11 - panel/src/ts/utils/cookies.ts | 8 + panel/src/ts/utils/date.ts | 153 ++++ panel/src/ts/utils/dimensions.ts | 6 + panel/src/ts/utils/duration.ts | 46 ++ panel/src/ts/utils/events.ts | 35 +- panel/src/ts/utils/forms.ts | 48 +- panel/src/ts/utils/keyboard.ts | 24 + panel/src/ts/utils/math.ts | 8 + panel/src/ts/utils/numbers.ts | 3 + panel/src/ts/utils/request.ts | 57 -- panel/src/ts/utils/search.ts | 42 ++ panel/src/ts/utils/strings.ts | 3 + panel/src/ts/utils/toolbar.ts | 18 +- panel/src/ts/utils/validation.ts | 26 + panel/src/ts/views/backups.ts | 113 +++ panel/src/ts/views/dashboard.ts | 44 ++ panel/src/ts/views/files.ts | 81 ++ panel/src/ts/views/login.ts | 10 + panel/src/ts/views/pages.ts | 415 +++++++++++ panel/src/ts/views/plugins.ts | 47 ++ panel/src/ts/views/updates.ts | 61 ++ 91 files changed, 6551 insertions(+), 4332 deletions(-) delete mode 100644 panel/src/ts/app.ts rename panel/src/ts/components/{inputs/editor/code/menu.ts => editor/code-menu.ts} (64%) rename panel/src/ts/components/{inputs/editor/code/view.ts => editor/code-view.ts} (84%) rename panel/src/ts/components/{inputs/editor/markdown/commands.ts => editor/markdown-commands.ts} (89%) rename panel/src/ts/components/{inputs/editor/markdown/inputrules.ts => editor/markdown-input-rules.ts} (81%) rename panel/src/ts/components/{inputs/editor/markdown/keymap.ts => editor/markdown-keymap.ts} (95%) create mode 100644 panel/src/ts/components/editor/markdown-link-tooltip.ts rename panel/src/ts/components/{inputs/editor/markdown/menu.ts => editor/markdown-menu.ts} (52%) rename panel/src/ts/components/{inputs/editor/markdown/placeholder.ts => editor/markdown-placeholder.ts} (88%) rename panel/src/ts/components/{inputs/editor/markdown/view.ts => editor/markdown-view.ts} (75%) create mode 100644 panel/src/ts/components/editor/toolbar-button.ts delete mode 100644 panel/src/ts/components/files.ts create mode 100644 panel/src/ts/components/files/file-inputs.ts create mode 100644 panel/src/ts/components/files/files-list.ts delete mode 100644 panel/src/ts/components/fileslist.ts delete mode 100644 panel/src/ts/components/form.ts create mode 100644 panel/src/ts/components/inputs/calendar.ts create mode 100644 panel/src/ts/components/inputs/dropdown-list.ts delete mode 100644 panel/src/ts/components/inputs/editor/markdown/linktooltip.ts delete mode 100644 panel/src/ts/components/navigation.ts delete mode 100644 panel/src/ts/components/notification.ts delete mode 100644 panel/src/ts/components/sections.ts delete mode 100644 panel/src/ts/components/tabs.ts delete mode 100644 panel/src/ts/components/tooltip.ts delete mode 100644 panel/src/ts/components/tooltips.ts rename panel/src/ts/components/{ => ui}/color-scheme.ts (91%) rename panel/src/ts/components/{ => ui}/dropdowns.ts (83%) rename panel/src/ts/components/{ => ui}/icons.ts (100%) create mode 100644 panel/src/ts/components/ui/navigation.ts create mode 100644 panel/src/ts/components/ui/notification.ts rename panel/src/ts/components/{ => ui}/notifications.ts (83%) create mode 100644 panel/src/ts/components/ui/sections.ts create mode 100644 panel/src/ts/components/ui/spinner.ts rename panel/src/ts/components/{ => ui}/statistics-chart.ts (51%) create mode 100644 panel/src/ts/components/ui/tabs.ts create mode 100644 panel/src/ts/components/ui/tooltip.ts create mode 100644 panel/src/ts/components/ui/tooltips.ts delete mode 100644 panel/src/ts/components/views/backups.ts delete mode 100644 panel/src/ts/components/views/dashboard.ts delete mode 100644 panel/src/ts/components/views/login.ts delete mode 100644 panel/src/ts/components/views/pages.ts delete mode 100644 panel/src/ts/components/views/plugins.ts delete mode 100644 panel/src/ts/components/views/statistics.ts delete mode 100644 panel/src/ts/components/views/updates.ts create mode 100644 panel/src/ts/index.ts create mode 100644 panel/src/ts/models/calendar.ts create mode 100644 panel/src/ts/models/pages-tree.ts create mode 100644 panel/src/ts/models/updates.ts create mode 100644 panel/src/ts/services/app.ts create mode 100644 panel/src/ts/services/form.ts rename panel/src/ts/{components => services}/forms.ts (89%) rename panel/src/ts/{components => services}/modal.ts (72%) rename panel/src/ts/{components => services}/modals.ts (84%) create mode 100644 panel/src/ts/services/request.ts rename panel/src/ts/{components => services}/translation.ts (57%) delete mode 100644 panel/src/ts/utils/arrays.ts create mode 100644 panel/src/ts/utils/date.ts create mode 100644 panel/src/ts/utils/duration.ts create mode 100644 panel/src/ts/utils/keyboard.ts delete mode 100644 panel/src/ts/utils/request.ts create mode 100644 panel/src/ts/utils/search.ts create mode 100644 panel/src/ts/views/backups.ts create mode 100644 panel/src/ts/views/dashboard.ts create mode 100644 panel/src/ts/views/files.ts create mode 100644 panel/src/ts/views/login.ts create mode 100644 panel/src/ts/views/pages.ts create mode 100644 panel/src/ts/views/plugins.ts create mode 100644 panel/src/ts/views/updates.ts 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/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/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 52% rename from panel/src/ts/components/inputs/editor/markdown/menu.ts rename to panel/src/ts/components/editor/markdown-menu.ts index 287ad3cad..7f309dbf2 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; @@ -70,6 +76,9 @@ class MenuView { }); } + /** + * Update the state of the items + */ update() { this.items.forEach(({ command, dom, mark, dropdown, name, node }) => { const state = this.editorView.state; @@ -101,6 +110,9 @@ class MenuView { }); } + /** + * Remove the toolbar + */ destroy() { this.dom.remove(); } @@ -108,161 +120,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 +263,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 +288,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..ba314dde6 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,22 @@ 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`, or an empty string if there is no date + * + * Seconds are included only if they are not zero + */ 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").replace(/:00$/, ""); } + /** + * 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 +119,9 @@ export class DateInput { } } + /** + * Show the calendar when the input is focused and handle the keyboard + */ private initInput() { this.element.readOnly = true; this.element.size = this.format.length; @@ -121,8 +141,7 @@ export class DateInput { 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.dispatchChange(); break; case "Escape": this.element.blur(); @@ -135,567 +154,16 @@ export class DateInput { }); } + /** + * Return whether a string is a valid date + */ private isValidDate(date: string) { return date && !Number.isNaN(Date.parse(date)); } - + /** + * Format a date with the options of the input + */ 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(); - } - 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; - } - - 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]; + 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..6664d6175 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,35 @@ 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; + 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 +65,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 +236,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) { @@ -245,18 +318,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(); @@ -272,12 +351,13 @@ export class SelectInput { 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 +367,276 @@ 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.bindAriaState(); + this.bindLabelInput(); + } - this.labelInput.addEventListener("focus", () => { - this.selectCurrent(); - this.labelInput.setSelectionRange(0, 0); - hasKeyboardInput = false; - }); + /** + * 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("mousedown", (event) => { - this.labelInput.focus(); - event.preventDefault(); - }); + if (expanded === null) { + return; + } - this.labelInput.addEventListener("blur", () => { - if (!this.validateDropdownItem(this.labelInput.value)) { - this.labelInput.value = this.getCurrentLabel(); + this.labelInput.setAttribute("aria-expanded", String(expanded)); + + if (!expanded) { + this.labelInput.removeAttribute("aria-activedescendant"); } - this.setExpanded(false); }); - this.labelInput.addEventListener("keydown", (event) => { - const selectedItem = $(".dropdown-item.selected", this.dropdown); - - switch (event.key) { - case "Backspace": // backspace - this.updateDropdown(); - break; - - 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; - - case "Enter": - if (selectedItem && getComputedStyle(selectedItem).display !== "none") { - this.setCurrent(selectedItem); - } - - // 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; + let isFirstSelection = true; + + effect(() => { + const selected = this.list.selected.value; + + 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++; - } - element.classList.remove("selected"); - element.setAttribute("aria-selected", "false"); + /** + * 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.removeAttribute("aria-activedescendant"); - - if (visibleItems > 0) { - this.emptyState.style.display = "none"; - } else { - this.emptyState.style.display = "block"; - } - } - - private setExpanded(expanded: boolean) { - this.dropdown.style.display = expanded ? "block" : "none"; - this.labelInput.setAttribute("aria-expanded", String(expanded)); + this.labelInput.addEventListener("mousedown", (event) => { + this.labelInput.focus(); + event.preventDefault(); + }); - if (!expanded) { - 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)); } - 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"; - } - }); + /** + * 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(); } + + this.list.open.value = false; } - 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; + /** + * 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": + 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 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); + /** + * Filter the items by the typed text, close the text field with Escape + */ + private handleKeyup(event: KeyboardEvent) { + switch (event.key) { + case "Escape": + this.labelInput.blur(); + 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(); + } } } } - 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; - } + /** + * Move the selection if the list is open, otherwise open it + */ + private moveSelection(move: () => void) { + if (this.list.open.value === true) { + move(); + } else { + this.openWithCurrent(); } } - 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; - } + /** + * 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.labelInput.blur(); } - 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(); + /** + * Clear the selection and update the empty state + */ + private resetSelection() { + this.list.selected.value = null; + + this.updateEmptyState(); } - 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 the empty state if no item is displayed + */ + private updateEmptyState() { + this.emptyState.style.display = this.list.countDisplayed() > 0 ? "none" : "block"; + } + + /** + * 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..fc45725ae 100644 --- a/panel/src/ts/components/inputs/tags-input.ts +++ b/panel/src/ts/components/inputs/tags-input.ts @@ -1,12 +1,18 @@ +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[]; limit: number; @@ -14,6 +20,9 @@ interface TagsInputOptions { orderable: boolean; } +/** + * Describes a suggested tag + */ interface TagsInputDropdownItem { label: string; value: string; @@ -21,19 +30,34 @@ 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"), @@ -44,7 +68,7 @@ export class TagsInput { 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,72 +178,62 @@ 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) { @@ -202,133 +251,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 +376,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.addKeyCodes.includes(event.code); + + // 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 +454,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 +525,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 +540,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 +558,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/dropdowns.ts b/panel/src/ts/components/ui/dropdowns.ts similarity index 83% rename from panel/src/ts/components/dropdowns.ts rename to panel/src/ts/components/ui/dropdowns.ts index 40c93c176..b122aef28 100644 --- a/panel/src/ts/components/dropdowns.ts +++ b/panel/src/ts/components/ui/dropdowns.ts @@ -1,9 +1,15 @@ -import { getOuterHeight, getOuterWidth } from "../utils/dimensions"; -import { throttle } from "../utils/events"; -import { $, $$ } from "../utils/selectors"; +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 + */ export class Dropdowns { constructor() { if ($(".dropdown")) { @@ -53,18 +59,30 @@ export class Dropdowns { } } +/** + * 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); @@ -74,6 +92,9 @@ function initButton(button: HTMLElement) { } } +/** + * 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) => { @@ -82,15 +103,25 @@ function open(button: HTMLElement, dropdown: HTMLElement) { const resizeHandler = throttle(() => setDropdownPosition(dropdown), 100); + stopResizeHandler?.(); + 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 = ""; }); @@ -99,10 +130,16 @@ function closeAll() { }); } +/** + * 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); @@ -126,6 +163,9 @@ function handleButtonKeydown(event: KeyboardEvent, button: HTMLElement) { } } +/** + * Move the focus between the items of an open menu + */ function handleMenuKeydown(event: KeyboardEvent, menu: HTMLElement) { const button = getButton(menu); @@ -162,6 +202,9 @@ function handleMenuKeydown(event: KeyboardEvent, menu: HTMLElement) { } } +/** + * Position a menu inside the viewport + */ function setDropdownPosition(dropdown: HTMLElement) { dropdown.style.left = "0"; dropdown.style.right = ""; 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..bfb3bba2c --- /dev/null +++ b/panel/src/ts/components/ui/navigation.ts @@ -0,0 +1,88 @@ +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 + */ + 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)); + + if (isOpen.value) { + this.showBackdrop(); + } 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(); + } + }); + } + + /** + * Add the backdrop behind the sidebar, if missing + */ + private showBackdrop() { + if (!$(".sidebar-backdrop")) { + const backdrop = document.createElement("div"); + backdrop.className = "sidebar-backdrop hide-from-md"; + 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..a6df4ab1f --- /dev/null +++ b/panel/src/ts/components/ui/sections.ts @@ -0,0 +1,31 @@ +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")); + + effect(() => { + section.classList.toggle("collapsed", 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..ef0dc71be --- /dev/null +++ b/panel/src/ts/models/calendar.ts @@ -0,0 +1,253 @@ +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 + */ +const prevYear: CalendarOperation = (state) => { + state.year--; +}; + +/** + * Go to the next year + */ +const nextYear: CalendarOperation = (state) => { + 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/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..7a906d945 --- /dev/null +++ b/panel/src/ts/services/form.ts @@ -0,0 +1,363 @@ +import "../polyfills/request-submit"; +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..ce626701a --- /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[mod(date.getDay() + options.weekStarts, 7)]; + case "DDDD": + return options.labels.weekdays.long[mod(date.getDay() + options.weekStarts, 7)]; + 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..d1a67fb56 --- /dev/null +++ b/panel/src/ts/views/pages.ts @@ -0,0 +1,415 @@ +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) { + item.classList.toggle("has-children", tree.parents.value.has(item)); + item.classList.toggle("is-expanded", tree.filtering.value || tree.expanded.value.has(item)); + } + }); +} + +/** + * 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); + }); + }); +} From 3c12a62768eb3bb5b58081c3cb3447bf901885fb Mon Sep 17 00:00:00 2001 From: Giuseppe Criscione <18699708+giuscris@users.noreply.github.com> Date: Sat, 24 Oct 2026 18:31:49 +0200 Subject: [PATCH 3/8] Remove the `requestSubmit()` polyfill from the panel The panel already relies on features that are newer than `requestSubmit()` (`:has()`, `SubmitEvent.submitter`, ARIA reflection), so the native method is available in every browser it supports. Co-Authored-By: Claude Sonnet 5.5 --- panel/src/ts/polyfills/request-submit.ts | 25 ------------------------ panel/src/ts/services/form.ts | 1 - 2 files changed, 26 deletions(-) delete mode 100644 panel/src/ts/polyfills/request-submit.ts 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/form.ts b/panel/src/ts/services/form.ts index 7a906d945..58d4b5483 100644 --- a/panel/src/ts/services/form.ts +++ b/panel/src/ts/services/form.ts @@ -1,4 +1,3 @@ -import "../polyfills/request-submit"; import { signal } from "@preact/signals-core"; import { ArrayInput } from "../components/inputs/array-input"; import { ColorInput } from "../components/inputs/color-input"; From 289df1215491a0dc4756102208a1828d8e389119 Mon Sep 17 00:00:00 2001 From: Giuseppe Criscione <18699708+giuscris@users.noreply.github.com> Date: Sat, 24 Oct 2026 18:51:20 +0200 Subject: [PATCH 4/8] Add keyboard navigation to the dropdown menus of the panel Open a menu with Enter or Space and focus its first item, choose an item with the keyboard giving the focus back to the button, close the menu with Tab and jump to an item by typing its first letters. Follow the ARIA menu button pattern with `aria-haspopup`, `role="menu"` and `role="menuitem"`, and set up the buttons created after the page is loaded. Do not move the focus to the editor when the button of the toolbar menu is clicked, otherwise the arrow keys moved the cursor of the editor instead of the focus in the menu. Co-Authored-By: Claude Sonnet 5.5 --- .../src/ts/components/editor/markdown-menu.ts | 16 +- panel/src/ts/components/ui/dropdowns.ts | 177 ++++++++++++++---- 2 files changed, 153 insertions(+), 40 deletions(-) diff --git a/panel/src/ts/components/editor/markdown-menu.ts b/panel/src/ts/components/editor/markdown-menu.ts index 7f309dbf2..82a3e5fd6 100644 --- a/panel/src/ts/components/editor/markdown-menu.ts +++ b/panel/src/ts/components/editor/markdown-menu.ts @@ -65,14 +65,16 @@ 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); + } }); } diff --git a/panel/src/ts/components/ui/dropdowns.ts b/panel/src/ts/components/ui/dropdowns.ts index b122aef28..ea44496e0 100644 --- a/panel/src/ts/components/ui/dropdowns.ts +++ b/panel/src/ts/components/ui/dropdowns.ts @@ -9,53 +9,98 @@ 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() { - if ($(".dropdown")) { - $$(".dropdown-button").forEach(initButton); + $$(".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; + document.addEventListener("click", handleClick); + document.addEventListener("keydown", handleKeydown); + document.addEventListener("focusin", handleFocusin); + } +} - closeAll(); +/** + * 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; - if (button) { - event.preventDefault(); + const button = target.closest(".dropdown-button"); + const dropdown = button ? getDropdown(button) : null; + const wasVisible = dropdown ? isVisible(dropdown) : false; - if (dropdown && !wasVisible) { - open(button, dropdown); - } - } - }); + const menu = target.closest(".dropdown-menu"); + const hadFocus = menu?.contains(document.activeElement) ?? false; - document.addEventListener("keydown", (event) => { - const target = event.target as HTMLElement; + const isKeyboard = event.detail === 0; - const button = target.closest(".dropdown-button"); + closeAll(); - if (button) { - handleButtonKeydown(event, button); - return; - } + if (button) { + event.preventDefault(); - const menu = target.closest(".dropdown-menu"); + if (dropdown && !wasVisible) { + open(button, dropdown); - if (menu) { - handleMenuKeydown(event, menu); - } - }); + // 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(); + } +} - document.addEventListener("focusin", (event) => { - const target = event.target as HTMLElement; +/** + * Handle the keyboard on the buttons and on the open menus + */ +function handleKeydown(event: KeyboardEvent) { + if (!(event.target instanceof HTMLElement)) { + return; + } - if (!target.closest(".dropdown")) { - closeAll(); - } - }); - } + 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); } } @@ -87,11 +132,24 @@ 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 */ @@ -105,6 +163,8 @@ function open(button: HTMLElement, dropdown: HTMLElement) { stopResizeHandler?.(); + initMenu(dropdown); + dropdown.style.display = "block"; setDropdownPosition(dropdown); window.addEventListener("resize", resizeHandler); @@ -165,6 +225,10 @@ function handleButtonKeydown(event: KeyboardEvent, button: HTMLElement) { /** * 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); @@ -183,6 +247,10 @@ function handleMenuKeydown(event: KeyboardEvent, menu: HTMLElement) { closeAll(); button.focus(); break; + case "Tab": + closeAll(); + button.focus(); + break; case "ArrowDown": event.preventDefault(); items[(index + 1) % items.length]?.focus(); @@ -199,9 +267,52 @@ function handleMenuKeydown(event: KeyboardEvent, menu: HTMLElement) { 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 */ From 813bdfeee2fc9ebbdeb0b99350ccf5fd70f2cf62 Mon Sep 17 00:00:00 2001 From: Giuseppe Criscione <18699708+giuscris@users.noreply.github.com> Date: Sat, 24 Oct 2026 23:24:09 +0200 Subject: [PATCH 5/8] Fix the keyboard, focus and overlay issues of the panel Keep the focus on the select input after choosing an item or closing the list with Escape, and on the date input after Escape or Backspace, so that Tab continues from the field and the calendar can be opened again with the arrow keys, Enter, Space or a click. Detect the comma of the tags input with `event.key` instead of the layout-dependent `event.code`, with the keys configurable through the `addKeys` option. Set `aria-expanded` on the toggles of the sections and of the pages tree, and give the thumbnails of the dropdown lists an empty `alt`. While the sidebar is open on small screens, make the page behind it inert, close it when its backdrop is clicked or when the window grows to the desktop layout, and lock the scroll of the page only below that breakpoint. Lock the scroll of the page behind an open modal too. Co-Authored-By: Claude Sonnet 5.5 --- panel/src/scss/components/_modals.scss | 4 ++ panel/src/scss/components/_sidebar.scss | 3 ++ panel/src/ts/components/inputs/date-input.ts | 24 +++++++-- .../src/ts/components/inputs/select-input.ts | 49 ++++++++++++++++--- panel/src/ts/components/inputs/tags-input.ts | 7 +-- panel/src/ts/components/ui/navigation.ts | 26 ++++++++-- panel/src/ts/components/ui/sections.ts | 5 +- panel/src/ts/views/pages.ts | 6 ++- 8 files changed, 106 insertions(+), 18 deletions(-) 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/ts/components/inputs/date-input.ts b/panel/src/ts/components/inputs/date-input.ts index ba314dde6..c339ab755 100644 --- a/panel/src/ts/components/inputs/date-input.ts +++ b/panel/src/ts/components/inputs/date-input.ts @@ -120,7 +120,10 @@ export class DateInput extends InputBase { } /** - * Show the calendar when the input is focused and handle the keyboard + * 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; @@ -136,19 +139,32 @@ export class DateInput extends InputBase { 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.calendar.hide(); this.dispatchChange(); break; case "Escape": - this.element.blur(); + this.calendar.hide(); break; case "Tab": - this.element.blur(); return; + case "ArrowDown": + 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(); + } + break; } event.preventDefault(); }); diff --git a/panel/src/ts/components/inputs/select-input.ts b/panel/src/ts/components/inputs/select-input.ts index 6664d6175..fbb3f80db 100644 --- a/panel/src/ts/components/inputs/select-input.ts +++ b/panel/src/ts/components/inputs/select-input.ts @@ -43,6 +43,9 @@ export class SelectInput extends InputBase { // 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; @@ -309,6 +312,7 @@ export class SelectInput extends InputBase { 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; @@ -345,7 +349,7 @@ export class SelectInput extends InputBase { const keyboardActivation = event.detail === 0; if (mousedownCaptured || keyboardActivation) { this.setCurrent(item); - this.labelInput.blur(); + this.close(); } mousedownCaptured = false; event.preventDefault(); @@ -430,7 +434,13 @@ export class SelectInput extends InputBase { }); this.labelInput.addEventListener("mousedown", (event) => { - this.labelInput.focus(); + 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(); + } + event.preventDefault(); }); @@ -481,6 +491,14 @@ export class SelectInput extends InputBase { 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; @@ -496,13 +514,15 @@ export class SelectInput extends InputBase { } /** - * Filter the items by the typed text, close the text field with Escape + * 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": - this.labelInput.blur(); - event.stopPropagation(); + if (this.isEscapeHandled) { + this.isEscapeHandled = false; + event.stopPropagation(); + } break; case "ArrowUp": @@ -547,7 +567,24 @@ export class SelectInput extends InputBase { this.setCurrent(selected); } - this.labelInput.blur(); + this.close(); + } + + /** + * Close the list picking the option whose label has been typed, keeping the focus + */ + private close() { + this.commitLabel(); + this.isTyping = false; + } + + /** + * 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; } /** diff --git a/panel/src/ts/components/inputs/tags-input.ts b/panel/src/ts/components/inputs/tags-input.ts index fc45725ae..f17ddf233 100644 --- a/panel/src/ts/components/inputs/tags-input.ts +++ b/panel/src/ts/components/inputs/tags-input.ts @@ -14,7 +14,7 @@ import { InputBase } from "./input"; */ export interface TagsInputOptions { labels: { [key: string]: string }; - addKeyCodes: string[]; + addKeys: string[]; limit: number; accept: "options" | "any"; orderable: boolean; @@ -62,7 +62,7 @@ export class TagsInput extends InputBase { labels: { remove: app.translation.get("fields.tags.remove", "Remove"), }, - addKeyCodes: ["Comma"], + addKeys: [","], limit: Infinity, accept: "options" as "options" | "any", orderable: true, @@ -240,6 +240,7 @@ export class TagsInput extends InputBase { 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 @@ -386,7 +387,7 @@ export class TagsInput extends InputBase { this.innerInput.classList.remove("form-input-invalid"); const isDropdownOpen = this.dropdown?.open.value === true; - const isAddKey = this.options.addKeyCodes.includes(event.code); + const isAddKey = this.options.addKeys.includes(event.key); // A typed tag is replaced by the selected suggestion if (isDropdownOpen && (isAddKey || event.key === "Enter")) { diff --git a/panel/src/ts/components/ui/navigation.ts b/panel/src/ts/components/ui/navigation.ts index bfb3bba2c..1306e0ec0 100644 --- a/panel/src/ts/components/ui/navigation.ts +++ b/panel/src/ts/components/ui/navigation.ts @@ -1,5 +1,5 @@ import { effect, signal } from "@preact/signals-core"; -import { $ } from "../../utils/selectors"; +import { $, $$ } from "../../utils/selectors"; /** * Handles the sidebar toggle for small screens and the save shortcut @@ -15,6 +15,10 @@ export class Navigation { /** * 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"); @@ -33,8 +37,14 @@ export class Navigation { 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(); + this.showBackdrop(() => { + isOpen.value = false; + }); } else { this.hideBackdrop(); } @@ -50,15 +60,25 @@ export class Navigation { 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() { + 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); } } diff --git a/panel/src/ts/components/ui/sections.ts b/panel/src/ts/components/ui/sections.ts index a6df4ab1f..512e16202 100644 --- a/panel/src/ts/components/ui/sections.ts +++ b/panel/src/ts/components/ui/sections.ts @@ -1,5 +1,5 @@ import { effect, signal } from "@preact/signals-core"; -import { $$ } from "../../utils/selectors"; +import { $, $$ } from "../../utils/selectors"; /** * Handles the collapsible sections, whose state is remembered @@ -16,8 +16,11 @@ export class Sections { 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", () => { diff --git a/panel/src/ts/views/pages.ts b/panel/src/ts/views/pages.ts index d1a67fb56..1c1b82573 100644 --- a/panel/src/ts/views/pages.ts +++ b/panel/src/ts/views/pages.ts @@ -31,8 +31,12 @@ export function initPagesTree() { 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", tree.filtering.value || tree.expanded.value.has(item)); + item.classList.toggle("is-expanded", isExpanded); + + $(".pages-tree-children-toggle button", item)?.setAttribute("aria-expanded", String(isExpanded)); } }); } From 1f752396f9e922396759fcce19a91a8be50f4147 Mon Sep 17 00:00:00 2001 From: Giuseppe Criscione <18699708+giuscris@users.noreply.github.com> Date: Sat, 24 Oct 2026 23:47:01 +0200 Subject: [PATCH 6/8] Make the actions of the form inputs buttons and label the search inputs Turn the actions that clear a date and generate a slug into buttons, so that they can be reached and activated with the keyboard, with a visible focus ring. Name the generate button and the search inputs of the pages and files lists with an `aria-label`, since the placeholder is not a label. Co-Authored-By: Claude Sonnet 5.5 --- .../scss/components/forms/_forms-base.scss | 21 ++++++++++++++++--- panel/views/fields/date.php | 4 ++-- panel/views/fields/slug.php | 2 +- panel/views/pages/index.php | 2 +- panel/views/partials/files/file/list.php | 2 +- 5 files changed, 23 insertions(+), 8 deletions(-) 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/views/fields/date.php b/panel/views/fields/date.php index 2b7ffa76a..cc352a23d 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') ?> - +
\ No newline at end of file