diff --git a/build-css.mjs b/build-css.mjs index abc2409c3..710f5d6ce 100644 --- a/build-css.mjs +++ b/build-css.mjs @@ -16,6 +16,7 @@ const CSS_FILES = [ 'styles/search-box.css', // SearchBox component with proper BEM scoping 'styles/modal-bem.css', // Modal components with proper BEM scoping 'styles/task-modal.css', // Task modal components (Google Keep/Todoist style) + 'styles/task-modal-sheet.css', // Task modal sheet, chips, sidebar 'styles/reminder-modal.css', // Reminder modal component with proper BEM scoping 'styles/date-picker.css', // Enhanced date/time picker styling 'styles/task-selector-with-create-modal.css', // TaskSelectorWithCreateModal component with proper BEM scoping diff --git a/docs/releases/unreleased.md b/docs/releases/unreleased.md index 121110a03..dc7c63964 100644 --- a/docs/releases/unreleased.md +++ b/docs/releases/unreleased.md @@ -31,3 +31,67 @@ When a change has user-facing documentation, include a canonical tasknotes.dev l ``` --> + +## Changed + +- Redesigned task creation and edit modals with a Todoist-inspired layout: labeled action chips, a dedicated scheduled date row, organization sections with completion counts, and labeled bottom actions on desktop (icon-only on mobile). +- On mobile, task create/edit now opens as a bottom sheet with drag-to-resize and swipe-down-to-dismiss gestures. Properties are grouped into differentiated card sections on a recessed background, similar to a native grouped list. +- On mobile, the bottom sheet footer action buttons now sit closer to the content above them on both create and edit, with a modest gap above the buttons. +- On desktop, the edit modal uses a right sidebar for task properties and organization fields (projects, subtasks, dependencies) while the main column focuses on title, description, and task information. +- On desktop, the expanded create-task dialog now uses the same two-pane layout as the edit modal, with title and description in the main column and task properties in the right sidebar. +- The title and description fields now show "Title" and "Description" as placeholder text instead of a persisting label above the field. +- Title and description are now the first fields shown in the task modal, with the description appearing directly below the title. +- Removed the duplicate title field from the expanded create-task dialog when using natural-language input; expanding now switches to the same title-and-description layout as the edit modal. +- Clicking contexts or tags in the task modal now opens a compact editor dialog directly instead of showing an intermediate menu. Existing values appear as removable chips, with a separate input and vault autocomplete for adding new ones. +- On mobile, the contexts and tags editor dialogs now use icon-only cancel and confirm buttons on a single row, matching the task modal footer. +- The due date icon is now a target instead of a calendar, to distinguish it from the scheduled date icon. +- Create-task action chips now wrap onto multiple lines instead of scrolling horizontally. +- Scheduled date in the create-task dialog is now a chip alongside the other task properties instead of a separate full-width row. +- In the create-task dialog, property chips now appear on a second row below the natural-language actions. +- Simplified property row labels in the mobile task modal bottom sheet (for example, "Due date" instead of "Set due date") and removed the "Add date" subtitle from empty date rows. + +## Fixed + +- Fixed the "Add project/subtask/blocked by/blocking" sections in the task modal showing an extra empty header and "+" button alongside the "Add" button when the section had no items. +- Fixed the project/subtask/blocked by/blocking section headers staying clickable when a separate "+" add button was already shown. +- Removed the completed/total counts from project, subtask, and dependency section headers in the task modal. +- Fixed the desktop task edit modal rendering a second, disconnected title field near the bottom of the form. +- Fixed the mobile task modal showing the "Add to project/subtask/blocked by/blocking" sections above the title and description fields instead of below them. +- Fixed the title field appearing clipped on a wide bottom-sheet modal (e.g. a touch-screen device with a wide window), caused by the legacy wide-screen split layout being applied at the same time as the mobile bottom sheet. +- Fixed the desktop task edit modal showing duplicate contexts, tags, and time estimate fields in the main column when they were already shown in the right sidebar. +- Fixed the desktop task edit modal showing subtasks in the main column instead of the right sidebar with the other organization fields. +- Fixed the desktop task edit modal placing project, subtask, and dependency lists at the bottom of the sidebar instead of directly under their property row. +- Fixed the desktop task edit modal sidebar clipping project, subtask, and dependency lists inside a short scroll box instead of letting them grow with their content. +- Fixed misaligned remove buttons in the desktop task edit modal sidebar organization lists. +- Fixed the desktop task edit modal leaving a large empty gap on the right side of the dialog because its content area was capped at a much narrower width than the dialog itself. +- Fixed the desktop task edit modal's content not scrolling, which could squeeze the Save/Cancel buttons in with the rest of the form instead of keeping them pinned to the bottom. +- Fixed the mobile task edit modal action buttons wrapping onto a second row, caused by legacy footer layout rules treating each icon as a separate grid/flex item instead of grouping leading actions and cancel/save into left/right clusters on one row. +- Fixed the mobile bottom sheet drag handle indicator not being centered in its touch target. +- Fixed the Save button in the task modal not showing a full primary accent fill on mobile bottom-sheet layouts. +- Fixed the Task information section in the mobile bottom sheet and desktop edit modal so its section title stays flush with other section headers while metadata rows sit inset inside the grouped card. +- Fixed the Task information section in the mobile bottom sheet using a different card style than the desktop edit modal. +- Fixed the Task information section in the mobile bottom sheet still using Obsidian's generic metadata styles instead of the grouped card layout shown on desktop. +- Fixed repeated console errors when scrolling the mobile task edit modal. +- Fixed the completions calendar month navigation arrows not being visible on mobile, caused by Obsidian hiding SVG icons that are direct children of generic modal buttons. +- Fixed extra horizontal inset on the Completions calendar in the mobile bottom sheet. +- Fixed the Completions and Task information sections in the edit modal showing divider lines that other grouped sections no longer use. +- Fixed missing vertical spacing between the Completions and Task information sections in the edit modal. +- Fixed the Completions and Task information section headers rendering in all caps instead of sentence case. +- Fixed tight spacing between the Completions and Task information section titles and their content in the edit modal. +- Fixed the task modal description field only accepting focus when tapping the first line, instead of anywhere in the description area. +- Fixed missing dividers between some property rows in the mobile task edit bottom sheet. +- Fixed inconsistent divider widths in the mobile task edit bottom sheet by rendering full-width divider elements between property rows instead of relying on row borders across separate grouped cards. +- Fixed the desktop task edit modal sidebar showing "Add to project" and "Add subtask" subtitles on the Projects and Subtasks rows when those sections were empty. +- Fixed the desktop task edit modal sidebar missing its top and bottom border, caused by the panel stretching flush against the scroll container and clipping the border edges. +- Fixed the desktop task edit modal scrollbar sitting flush against the right sidebar instead of leaving a small inset beside it. +- Fixed uneven divider spacing in the desktop task edit modal sidebar after Projects, Subtasks, and dependency rows when those sections were empty. +- Fixed sidebar property row chevrons rendering larger than their row icons and sitting off-center on rows with a value. +- Fixed the mobile task edit modal showing plain "Add to project/subtask/blocked by/blocking" buttons for empty organization sections instead of the icon-and-chevron sidebar rows used on desktop. +- Fixed the mobile task edit modal listing organization and dependency sections after the property rows instead of matching the desktop sidebar order (projects and subtasks first, then properties, then blocked-by and blocking). +- Fixed the mobile task edit modal splitting projects/subtasks, properties, and blocked-by/blocking into separate grouped cards instead of one continuous sidebar-style list. +- Fixed the mobile create/edit bottom sheet staying at the desktop dialog width and leaving large side gutters instead of sizing to its content up to the small-screen maximum. +- Fixed the mobile create/edit bottom sheet expanding flush to the top of the screen when dragged to full height instead of leaving a safe area above the sheet. +- Fixed the mobile create/edit bottom sheet staying behind the on-screen keyboard or formatting toolbar when editing fields. The whole sheet now displaces upward from the bottom edge without changing height. +- Fixed the mobile create/edit bottom sheet footer action buttons scrolling out of view or being clipped when the sheet was partially expanded. +- Fixed the mobile create/edit bottom sheet leaving the content scrollbar inside the padded container instead of along its outer edge. +- Fixed task modal footer buttons so non-save actions use an outlined style while Save stays filled, and icons align vertically with their labels on desktop. Removed Obsidian's default button shadow from footer actions so outlined buttons stay flat. \ No newline at end of file diff --git a/i18n.manifest.json b/i18n.manifest.json index a30b9bbec..77692c8d4 100644 --- a/i18n.manifest.json +++ b/i18n.manifest.json @@ -1537,9 +1537,9 @@ "modals.migration.prompt.remindLater": "bebc1c9cd489e6013a5c3229ef5dbd13702aa9e1", "modals.task.titlePlaceholder": "c8196f1d1039c2fb290add52ee990c571173d6f1", "modals.task.titleLabel": "768e0c1c69573fb588f61f1308a015c11468e05f", - "modals.task.titleDetailedPlaceholder": "da3700738242c039de32d3f5e3a0ba10bc28ba0b", + "modals.task.titleDetailedPlaceholder": "768e0c1c69573fb588f61f1308a015c11468e05f", "modals.task.detailsLabel": "dc3decbb93847518f1a049dcf49d0d7c6560bcc6", - "modals.task.detailsPlaceholder": "52fa1dfb1b96ceaa971d057d24e0b1f9dd17e61d", + "modals.task.detailsPlaceholder": "55f8ebc805e65b5b71ddafdae390e3be2bcd69af", "modals.task.projectsLabel": "53e890d5f0fffe09587c55dce74e9a6febf59b5c", "modals.task.projectsAdd": "2470e8667bfae5270588f1574daf0d7cd7a89c41", "modals.task.projectsTooltip": "6f7dba5b3167c06a50500ad739f8e6462a7ac47f", @@ -1559,6 +1559,8 @@ "modals.task.dependencies.blocking": "d785c0d4b3b9c24878b62f64a7bcf78e9506ab27", "modals.task.dependencies.placeholder": "67c2a027f7b139a8b9103081eb4fb418e135cd38", "modals.task.dependencies.addTaskButton": "24700e60fedcf00a741c5adb1d9f15aee05f95d8", + "modals.task.dependencies.addBlockedByButton": "7239dddd703cf9a0813a78c4e439a4a289f168c1", + "modals.task.dependencies.addBlockingButton": "e5c2a4e67304f3e6d57030ee79bdd09e3155abab", "modals.task.dependencies.selectTaskTooltip": "752b3be46866220ce17567bede45bdac374d4a4f", "modals.task.dependencies.removeTaskTooltip": "5be1bc9e1837b131abac53534235a44b4bca541e", "modals.task.organization.projects": "53e890d5f0fffe09587c55dce74e9a6febf59b5c", @@ -1569,15 +1571,28 @@ "modals.task.organization.addSubtasksButton": "65b9d7a6f28a02d5a60ab0005804c73e137d14b6", "modals.task.organization.addSubtasksTooltip": "1a875945612b63d144cb3cf02c792a38443378e1", "modals.task.organization.removeSubtaskTooltip": "d6acf378b038ad0b4b130817b370fb9dba31caef", + "modals.task.organization.completionCount": "c55d20b2f6cea63c674b3cd3083f8f2b0daaf852", "modals.task.organization.notices.noEligibleSubtasks": "bce7ca1d7ce5603179f86b83b9074a4aa27de835", "modals.task.organization.notices.subtaskSelectFailed": "333c06c796628bc21ecdaa6da4556bbe343c2284", + "modals.task.scheduledRow.label": "eb9a4bc1c0c153e4e4b042a79113b815b7e3021d", + "modals.task.scheduledRow.placeholder": "3ae746949032a7a1812864103fa43d1a5560de39", + "modals.task.detailsShowMore": "25911d48e023cd10fc08d378641ee620f5e39d86", + "modals.task.detailsShowLess": "4c852b26d1b71054cb8fbccc79ae72e91dc94144", + "modals.task.chips.timeEstimate": "4f46c57b82ca0a791c945fde8075d2c4d8fcd907", + "modals.task.chips.timeEstimateValue": "09a22c220816f5d148ab0269bda9f7d1463c55d5", + "modals.task.chips.contextsPlural": "103dd09aab7acd6c6e3b7b5547231f51d85ed787", + "modals.task.chips.tagsPlural": "6cabb6f2900acd83895435f56f046a15adc09f35", + "modals.task.chips.tagSelected": "5dc2abcb70ea56a88ab2bfadf4012a0e0cad4265", + "modals.task.chips.remindersSingle": "38ce8707038cb67ede5e3492fa81023e208343b5", + "modals.task.chips.remindersPlural": "a1ac44f49ac22dc5e94882cb7a587c72ebd7c381", + "modals.task.chipList.removeItem": "0ae125470113ea9a36b875a8a99d290620b454ea", "modals.task.customFieldsLabel": "d1f86d5227e987ad9feec36387a01f71ac50987e", - "modals.task.actions.due": "ecf07cea2d2a6681f6074ac3d82bcae990c04a8d", - "modals.task.actions.scheduled": "d45d07555befa2ffc895f9d0425ce30f4bc92c3d", - "modals.task.actions.status": "45c5d329b685f50b513e431eb0cc226e3cba63eb", - "modals.task.actions.priority": "993fd2a5326b1f03d2cf7f4bb7e6a5bc13154f28", - "modals.task.actions.recurrence": "1aa43ddb78d67c24780c22cb775db45de4a0becf", - "modals.task.actions.reminders": "230294dc92087b5bab4f814f2151680d45063213", + "modals.task.actions.due": "4c1aeebc433bcb8eb4b05434619ea3136b785ea8", + "modals.task.actions.scheduled": "b41cf063eca8243b7b8039af57f2b039fc868668", + "modals.task.actions.status": "bae7d5be70820ed56467bd9a63744e23b47bd711", + "modals.task.actions.priority": "886cbff9d9df761ec642945e519631a23ed173a2", + "modals.task.actions.recurrence": "f7ad40f58caa3ee3d9e72be4378d5acf7807f657", + "modals.task.actions.reminders": "ae8c3939382fbd2f6a05825c70d474b3c1de2b8d", "modals.task.buttons.openNote": "c66a827e3397b1e4eb14c164a586dbd77b36e8bc", "modals.task.buttons.save": "efc007a393f66cdb14d57d385822a3d9e36ef873", "modals.task.tooltips.dueValue": "b3812726f9ed81d808a0976e193b08617690b0e5", diff --git a/i18n.state.json b/i18n.state.json index 01d90a504..ff2aaa59b 100644 --- a/i18n.state.json +++ b/i18n.state.json @@ -6153,16 +6153,16 @@ "translation": "950701e758d18a72134a6b8948c7cf42867c0eb9" }, "modals.task.titleDetailedPlaceholder": { - "source": "da3700738242c039de32d3f5e3a0ba10bc28ba0b", - "translation": "c14fd1d7220258125b19b093baa81e1415284a37" + "source": "768e0c1c69573fb588f61f1308a015c11468e05f", + "translation": "950701e758d18a72134a6b8948c7cf42867c0eb9" }, "modals.task.detailsLabel": { "source": "dc3decbb93847518f1a049dcf49d0d7c6560bcc6", "translation": "dc3decbb93847518f1a049dcf49d0d7c6560bcc6" }, "modals.task.detailsPlaceholder": { - "source": "52fa1dfb1b96ceaa971d057d24e0b1f9dd17e61d", - "translation": "a0ab862a6d2d2b22290c378e924a9180c3ded80c" + "source": "55f8ebc805e65b5b71ddafdae390e3be2bcd69af", + "translation": "b3c8defcacc0ad93a3cfcbdd962b7b95ab5d019f" }, "modals.task.projectsLabel": { "source": "53e890d5f0fffe09587c55dce74e9a6febf59b5c", @@ -6240,6 +6240,8 @@ "source": "24700e60fedcf00a741c5adb1d9f15aee05f95d8", "translation": "f70aa0bbccbe1e16b3f0ba07ac8d7deccda0d0f5" }, + "modals.task.dependencies.addBlockedByButton": null, + "modals.task.dependencies.addBlockingButton": null, "modals.task.dependencies.selectTaskTooltip": { "source": "752b3be46866220ce17567bede45bdac374d4a4f", "translation": "0aecb358d365c96cdb9e3642a9386dfa39c69329" @@ -6280,6 +6282,7 @@ "source": "d6acf378b038ad0b4b130817b370fb9dba31caef", "translation": "e428d6c70359f8587c3b7c937a5d4f6d872e1063" }, + "modals.task.organization.completionCount": null, "modals.task.organization.notices.noEligibleSubtasks": { "source": "bce7ca1d7ce5603179f86b83b9074a4aa27de835", "translation": "3aba970cbab5a921ff75cf842207b3a04bd4a62e" @@ -6288,33 +6291,45 @@ "source": "333c06c796628bc21ecdaa6da4556bbe343c2284", "translation": "5235f8e57aed1fe31a5953c0b83ba1008f653bb8" }, + "modals.task.scheduledRow.label": null, + "modals.task.scheduledRow.placeholder": null, + "modals.task.detailsShowMore": null, + "modals.task.detailsShowLess": null, + "modals.task.chips.timeEstimate": null, + "modals.task.chips.timeEstimateValue": null, + "modals.task.chips.contextsPlural": null, + "modals.task.chips.tagsPlural": null, + "modals.task.chips.tagSelected": null, + "modals.task.chips.remindersSingle": null, + "modals.task.chips.remindersPlural": null, + "modals.task.chipList.removeItem": null, "modals.task.customFieldsLabel": { "source": "d1f86d5227e987ad9feec36387a01f71ac50987e", "translation": "7d3d8436efecaa9f20ce24761b137b89eac2bdcc" }, "modals.task.actions.due": { - "source": "ecf07cea2d2a6681f6074ac3d82bcae990c04a8d", - "translation": "ea17eb3a01fafaa08a2c6068709006374270596c" + "source": "4c1aeebc433bcb8eb4b05434619ea3136b785ea8", + "translation": "0fb757b8f746cea74271e0a0c3d3f63d11508ce3" }, "modals.task.actions.scheduled": { - "source": "d45d07555befa2ffc895f9d0425ce30f4bc92c3d", - "translation": "0607180da3b0a5e663bf489b9a04146a6422f798" + "source": "b41cf063eca8243b7b8039af57f2b039fc868668", + "translation": "b20833645977b748a4432ac4a36a20709f116946" }, "modals.task.actions.status": { - "source": "45c5d329b685f50b513e431eb0cc226e3cba63eb", - "translation": "86fde65d5a80262c0cb89a3e68738472915ceb74" + "source": "bae7d5be70820ed56467bd9a63744e23b47bd711", + "translation": "bae7d5be70820ed56467bd9a63744e23b47bd711" }, "modals.task.actions.priority": { - "source": "993fd2a5326b1f03d2cf7f4bb7e6a5bc13154f28", - "translation": "53d8a8c161584bdcd37a14673362c00bea70c51c" + "source": "886cbff9d9df761ec642945e519631a23ed173a2", + "translation": "c3ae74d4763f2029465abfeeec924b77d9d218e1" }, "modals.task.actions.recurrence": { - "source": "1aa43ddb78d67c24780c22cb775db45de4a0becf", - "translation": "e68c31e40a320fbe8889dfc599e3bfde5a95879c" + "source": "f7ad40f58caa3ee3d9e72be4378d5acf7807f657", + "translation": "31786adb16beeaebf49f5d8ca82ee5cdd77aff84" }, "modals.task.actions.reminders": { - "source": "230294dc92087b5bab4f814f2151680d45063213", - "translation": "2370b7f7af67235c59866b53e144c9c630b98ed7" + "source": "ae8c3939382fbd2f6a05825c70d474b3c1de2b8d", + "translation": "5d7bb869bb9f9995c46abff33f37f8e1f31504f8" }, "modals.task.buttons.openNote": { "source": "c66a827e3397b1e4eb14c164a586dbd77b36e8bc", @@ -15019,16 +15034,16 @@ "translation": "98a5efa60beb882ed4dbac1ac013f73418acda77" }, "modals.task.titleDetailedPlaceholder": { - "source": "da3700738242c039de32d3f5e3a0ba10bc28ba0b", - "translation": "73f7fcacf2f48eec088a457f89c95de8b680c7b7" + "source": "768e0c1c69573fb588f61f1308a015c11468e05f", + "translation": "98a5efa60beb882ed4dbac1ac013f73418acda77" }, "modals.task.detailsLabel": { "source": "dc3decbb93847518f1a049dcf49d0d7c6560bcc6", "translation": "b1bb8ea50ebe596ae57b82901e6febaebb5cc946" }, "modals.task.detailsPlaceholder": { - "source": "52fa1dfb1b96ceaa971d057d24e0b1f9dd17e61d", - "translation": "847f327e5deab1de5740738c597bfe6d234d783e" + "source": "55f8ebc805e65b5b71ddafdae390e3be2bcd69af", + "translation": "7fda3970598ef60b0b151b2274290dd18559cf83" }, "modals.task.projectsLabel": { "source": "53e890d5f0fffe09587c55dce74e9a6febf59b5c", @@ -15106,6 +15121,8 @@ "source": "24700e60fedcf00a741c5adb1d9f15aee05f95d8", "translation": "1d1d9b6ce35e0c8d12d2d508906f13422e8e44f2" }, + "modals.task.dependencies.addBlockedByButton": null, + "modals.task.dependencies.addBlockingButton": null, "modals.task.dependencies.selectTaskTooltip": { "source": "752b3be46866220ce17567bede45bdac374d4a4f", "translation": "3fd5d72fce169d67306dbc316603986cde97862c" @@ -15146,6 +15163,7 @@ "source": "d6acf378b038ad0b4b130817b370fb9dba31caef", "translation": "465ed798c31c9e02bdc043938f95e7c53218bdce" }, + "modals.task.organization.completionCount": null, "modals.task.organization.notices.noEligibleSubtasks": { "source": "bce7ca1d7ce5603179f86b83b9074a4aa27de835", "translation": "f9cb5246aca2376c8c6ff684ddbb277643b72499" @@ -15154,33 +15172,45 @@ "source": "333c06c796628bc21ecdaa6da4556bbe343c2284", "translation": "75e5eb23780023d00655ef9b404fb1780f8f9c19" }, + "modals.task.scheduledRow.label": null, + "modals.task.scheduledRow.placeholder": null, + "modals.task.detailsShowMore": null, + "modals.task.detailsShowLess": null, + "modals.task.chips.timeEstimate": null, + "modals.task.chips.timeEstimateValue": null, + "modals.task.chips.contextsPlural": null, + "modals.task.chips.tagsPlural": null, + "modals.task.chips.tagSelected": null, + "modals.task.chips.remindersSingle": null, + "modals.task.chips.remindersPlural": null, + "modals.task.chipList.removeItem": null, "modals.task.customFieldsLabel": { "source": "d1f86d5227e987ad9feec36387a01f71ac50987e", "translation": "f801a31165b3ed6f937579f5d006937f4e7f9d0e" }, "modals.task.actions.due": { - "source": "ecf07cea2d2a6681f6074ac3d82bcae990c04a8d", - "translation": "912590ebaf2895ce8451d7fbdd2fd45532f39c1e" + "source": "4c1aeebc433bcb8eb4b05434619ea3136b785ea8", + "translation": "ee1e0f39e45ddf8007f79069c57e1362ac698870" }, "modals.task.actions.scheduled": { - "source": "d45d07555befa2ffc895f9d0425ce30f4bc92c3d", - "translation": "f385967d314e0f18ec67e0656c5d9c0449cb6579" + "source": "b41cf063eca8243b7b8039af57f2b039fc868668", + "translation": "d993412cdda43c87b63b68675e3b219add3bb102" }, "modals.task.actions.status": { - "source": "45c5d329b685f50b513e431eb0cc226e3cba63eb", - "translation": "5902e3fa71c67be31e3acb9898128641f38309eb" + "source": "bae7d5be70820ed56467bd9a63744e23b47bd711", + "translation": "87cf2a843e2e43a05edc326327fca7e1388bc196" }, "modals.task.actions.priority": { - "source": "993fd2a5326b1f03d2cf7f4bb7e6a5bc13154f28", - "translation": "a33c98da47bf2a3cdd6a701e3f462cfef5114b2c" + "source": "886cbff9d9df761ec642945e519631a23ed173a2", + "translation": "48f9c9750a53cc8f910fa331f141f80f025af271" }, "modals.task.actions.recurrence": { - "source": "1aa43ddb78d67c24780c22cb775db45de4a0becf", - "translation": "c2bc0b9a2c54481aeea733b140bd3fcf636bbcba" + "source": "f7ad40f58caa3ee3d9e72be4378d5acf7807f657", + "translation": "a11310c8459688e1e71051c34e1275459bf822f1" }, "modals.task.actions.reminders": { - "source": "230294dc92087b5bab4f814f2151680d45063213", - "translation": "ce79e8b14cb212f47b564f40dfa8fa9d4f1928a1" + "source": "ae8c3939382fbd2f6a05825c70d474b3c1de2b8d", + "translation": "55f9302885af7b8d22d2d32fab779a69c261633e" }, "modals.task.buttons.openNote": { "source": "c66a827e3397b1e4eb14c164a586dbd77b36e8bc", @@ -23885,16 +23915,16 @@ "translation": "eb97899aedcd5609782b5ccb4ffa390e0e66a3eb" }, "modals.task.titleDetailedPlaceholder": { - "source": "da3700738242c039de32d3f5e3a0ba10bc28ba0b", - "translation": "4138e284dce61c373d456f50fa8c1bafb52bd089" + "source": "768e0c1c69573fb588f61f1308a015c11468e05f", + "translation": "eb97899aedcd5609782b5ccb4ffa390e0e66a3eb" }, "modals.task.detailsLabel": { "source": "dc3decbb93847518f1a049dcf49d0d7c6560bcc6", "translation": "aaa029e6a2551635a0a7d512c19ded29bd44b597" }, "modals.task.detailsPlaceholder": { - "source": "52fa1dfb1b96ceaa971d057d24e0b1f9dd17e61d", - "translation": "ad5fa3a7aa92fdfa795bd3d2c79f138242c57b9e" + "source": "55f8ebc805e65b5b71ddafdae390e3be2bcd69af", + "translation": "55f8ebc805e65b5b71ddafdae390e3be2bcd69af" }, "modals.task.projectsLabel": { "source": "53e890d5f0fffe09587c55dce74e9a6febf59b5c", @@ -23972,6 +24002,8 @@ "source": "24700e60fedcf00a741c5adb1d9f15aee05f95d8", "translation": "fdea3fbacf865fe8585416782828c41e9b2f0032" }, + "modals.task.dependencies.addBlockedByButton": null, + "modals.task.dependencies.addBlockingButton": null, "modals.task.dependencies.selectTaskTooltip": { "source": "752b3be46866220ce17567bede45bdac374d4a4f", "translation": "6c940fd4d2fdda15a8d47fbd49f4234fa63436f0" @@ -24012,6 +24044,7 @@ "source": "d6acf378b038ad0b4b130817b370fb9dba31caef", "translation": "efeb9ce93cf5a3dfbd6a3127667486f967f60365" }, + "modals.task.organization.completionCount": null, "modals.task.organization.notices.noEligibleSubtasks": { "source": "bce7ca1d7ce5603179f86b83b9074a4aa27de835", "translation": "1313d6c9fe0754bced4281ce86b100e094e96154" @@ -24020,33 +24053,45 @@ "source": "333c06c796628bc21ecdaa6da4556bbe343c2284", "translation": "77f1289b8d0b2605c670edde90c98a68e9e8e235" }, + "modals.task.scheduledRow.label": null, + "modals.task.scheduledRow.placeholder": null, + "modals.task.detailsShowMore": null, + "modals.task.detailsShowLess": null, + "modals.task.chips.timeEstimate": null, + "modals.task.chips.timeEstimateValue": null, + "modals.task.chips.contextsPlural": null, + "modals.task.chips.tagsPlural": null, + "modals.task.chips.tagSelected": null, + "modals.task.chips.remindersSingle": null, + "modals.task.chips.remindersPlural": null, + "modals.task.chipList.removeItem": null, "modals.task.customFieldsLabel": { "source": "d1f86d5227e987ad9feec36387a01f71ac50987e", "translation": "3b3eae0b1b54b8f551d41a0dea45ab0c79b93b7a" }, "modals.task.actions.due": { - "source": "ecf07cea2d2a6681f6074ac3d82bcae990c04a8d", - "translation": "9d1c189a967291eebcffac65e0e60247ead079fc" + "source": "4c1aeebc433bcb8eb4b05434619ea3136b785ea8", + "translation": "f9a77ff6737530591a06e797df51c4c1de16c199" }, "modals.task.actions.scheduled": { - "source": "d45d07555befa2ffc895f9d0425ce30f4bc92c3d", - "translation": "314ca703150644eb1bcf6b6f145bf5f805b640a9" + "source": "b41cf063eca8243b7b8039af57f2b039fc868668", + "translation": "a0a3c2d4cce388135d6267c00a58264fba1513b2" }, "modals.task.actions.status": { - "source": "45c5d329b685f50b513e431eb0cc226e3cba63eb", - "translation": "83bfe3060b1418db3214e5d83f75e7cad1da69fe" + "source": "bae7d5be70820ed56467bd9a63744e23b47bd711", + "translation": "659499f38a5647a0d307be6f23a5d367a167fbf7" }, "modals.task.actions.priority": { - "source": "993fd2a5326b1f03d2cf7f4bb7e6a5bc13154f28", - "translation": "c77bdec09a54bbdc4fa924c05eccbf1c5700a711" + "source": "886cbff9d9df761ec642945e519631a23ed173a2", + "translation": "eede91d2f77e0901ba7ebf704f4ea2e1738e04dc" }, "modals.task.actions.recurrence": { - "source": "1aa43ddb78d67c24780c22cb775db45de4a0becf", - "translation": "0c265ae08221eb451096e92d091b567a7b9a532c" + "source": "f7ad40f58caa3ee3d9e72be4378d5acf7807f657", + "translation": "a1e1903a7ee91c0a90e6208f97b4a7fb12c448d3" }, "modals.task.actions.reminders": { - "source": "230294dc92087b5bab4f814f2151680d45063213", - "translation": "85fdb3902c7c45299e9a6fba2f4ed6ab4425dbd8" + "source": "ae8c3939382fbd2f6a05825c70d474b3c1de2b8d", + "translation": "2aca64b8e57e096029ccea247f7301579b273455" }, "modals.task.buttons.openNote": { "source": "c66a827e3397b1e4eb14c164a586dbd77b36e8bc", @@ -32751,16 +32796,16 @@ "translation": "47b4753f220d4e6205ab2eb57371e940cbbdfd46" }, "modals.task.titleDetailedPlaceholder": { - "source": "da3700738242c039de32d3f5e3a0ba10bc28ba0b", - "translation": "3b44123c41c370f4b8596ed3087ad573a4b8e65e" + "source": "768e0c1c69573fb588f61f1308a015c11468e05f", + "translation": "47b4753f220d4e6205ab2eb57371e940cbbdfd46" }, "modals.task.detailsLabel": { "source": "dc3decbb93847518f1a049dcf49d0d7c6560bcc6", "translation": "12d470debc6cc12a4768dd9af273326cd440c4b0" }, "modals.task.detailsPlaceholder": { - "source": "52fa1dfb1b96ceaa971d057d24e0b1f9dd17e61d", - "translation": "59d34d0aeb5eb2ef1627bc576d866620ce134dae" + "source": "55f8ebc805e65b5b71ddafdae390e3be2bcd69af", + "translation": "f6c2c8d0f4629d905c124c8a59694eb1343a2a5a" }, "modals.task.projectsLabel": { "source": "53e890d5f0fffe09587c55dce74e9a6febf59b5c", @@ -32838,6 +32883,8 @@ "source": "24700e60fedcf00a741c5adb1d9f15aee05f95d8", "translation": "c4ceb61be3910ae46fc82c81d0749db6a6eb049b" }, + "modals.task.dependencies.addBlockedByButton": null, + "modals.task.dependencies.addBlockingButton": null, "modals.task.dependencies.selectTaskTooltip": { "source": "752b3be46866220ce17567bede45bdac374d4a4f", "translation": "60a78f87905b43c27448e17dc886a16553291c62" @@ -32878,6 +32925,7 @@ "source": "d6acf378b038ad0b4b130817b370fb9dba31caef", "translation": "c65e46611af6a5924eb6612b97d2fa85e19614fb" }, + "modals.task.organization.completionCount": null, "modals.task.organization.notices.noEligibleSubtasks": { "source": "bce7ca1d7ce5603179f86b83b9074a4aa27de835", "translation": "1ea18f4603267b75d7acd7e7dcae8efe45617ccf" @@ -32886,33 +32934,45 @@ "source": "333c06c796628bc21ecdaa6da4556bbe343c2284", "translation": "670e3f9a1ec5dd226c41ede3f0c270ecfe97c77b" }, + "modals.task.scheduledRow.label": null, + "modals.task.scheduledRow.placeholder": null, + "modals.task.detailsShowMore": null, + "modals.task.detailsShowLess": null, + "modals.task.chips.timeEstimate": null, + "modals.task.chips.timeEstimateValue": null, + "modals.task.chips.contextsPlural": null, + "modals.task.chips.tagsPlural": null, + "modals.task.chips.tagSelected": null, + "modals.task.chips.remindersSingle": null, + "modals.task.chips.remindersPlural": null, + "modals.task.chipList.removeItem": null, "modals.task.customFieldsLabel": { "source": "d1f86d5227e987ad9feec36387a01f71ac50987e", "translation": "f98bcee411bb492921551ff3e6d1dd449ecfe287" }, "modals.task.actions.due": { - "source": "ecf07cea2d2a6681f6074ac3d82bcae990c04a8d", - "translation": "66bd7e64ba93ecb64a23e690462b5264b3906040" + "source": "4c1aeebc433bcb8eb4b05434619ea3136b785ea8", + "translation": "7b83dfd5f7b333f1af13708f38d4bb811d8f79e5" }, "modals.task.actions.scheduled": { - "source": "d45d07555befa2ffc895f9d0425ce30f4bc92c3d", - "translation": "a4408df7067a113458f5ea431affc8729feea89f" + "source": "b41cf063eca8243b7b8039af57f2b039fc868668", + "translation": "c1a4728e83ab35ff2b84086e1ba8d7c2fc6279d2" }, "modals.task.actions.status": { - "source": "45c5d329b685f50b513e431eb0cc226e3cba63eb", - "translation": "b4c8ba6751fc42bea50a07a696276385db30cf76" + "source": "bae7d5be70820ed56467bd9a63744e23b47bd711", + "translation": "0a409cee235c7f6064ee84956d3cbb8e45973ca3" }, "modals.task.actions.priority": { - "source": "993fd2a5326b1f03d2cf7f4bb7e6a5bc13154f28", - "translation": "b7ff2cba2e2d7d4fa7ca7f6f1bae6bfe708b82e4" + "source": "886cbff9d9df761ec642945e519631a23ed173a2", + "translation": "edf69de9e405e36ed4ed4703019ea42701fdcde7" }, "modals.task.actions.recurrence": { - "source": "1aa43ddb78d67c24780c22cb775db45de4a0becf", - "translation": "f36c1b106e8f352a4c0b5b66790706c84d07a576" + "source": "f7ad40f58caa3ee3d9e72be4378d5acf7807f657", + "translation": "f5969b332b9a921a81f68cec1c26712f421d4156" }, "modals.task.actions.reminders": { - "source": "230294dc92087b5bab4f814f2151680d45063213", - "translation": "a2faf0636f2bdcc97a2a0d225b4612f4e90e4d69" + "source": "ae8c3939382fbd2f6a05825c70d474b3c1de2b8d", + "translation": "07d94016cc6ceb31a9a582d4cb3abd152122f27d" }, "modals.task.buttons.openNote": { "source": "c66a827e3397b1e4eb14c164a586dbd77b36e8bc", @@ -41617,16 +41677,16 @@ "translation": "078b3a1b0a3dc17487beabf5119cf7cdf99489ff" }, "modals.task.titleDetailedPlaceholder": { - "source": "da3700738242c039de32d3f5e3a0ba10bc28ba0b", - "translation": "a1e10af0a75d92ebda6dfdce80fdd676fcfd0113" + "source": "768e0c1c69573fb588f61f1308a015c11468e05f", + "translation": "078b3a1b0a3dc17487beabf5119cf7cdf99489ff" }, "modals.task.detailsLabel": { "source": "dc3decbb93847518f1a049dcf49d0d7c6560bcc6", "translation": "c7e5d553d382d39066b64f4b2124ac2d81e59e8c" }, "modals.task.detailsPlaceholder": { - "source": "52fa1dfb1b96ceaa971d057d24e0b1f9dd17e61d", - "translation": "404bf3e01615157a1c588334da511f465915e48f" + "source": "55f8ebc805e65b5b71ddafdae390e3be2bcd69af", + "translation": "841964364c395ce4575d8bb872725ce6f1481817" }, "modals.task.projectsLabel": { "source": "53e890d5f0fffe09587c55dce74e9a6febf59b5c", @@ -41704,6 +41764,8 @@ "source": "24700e60fedcf00a741c5adb1d9f15aee05f95d8", "translation": "537c1c93e3e88a5b5a1b36ad3647c5f135d4d64e" }, + "modals.task.dependencies.addBlockedByButton": null, + "modals.task.dependencies.addBlockingButton": null, "modals.task.dependencies.selectTaskTooltip": { "source": "752b3be46866220ce17567bede45bdac374d4a4f", "translation": "7fca687b5009a412aa3c467d05f7f9b4b2ae291d" @@ -41744,6 +41806,7 @@ "source": "d6acf378b038ad0b4b130817b370fb9dba31caef", "translation": "e227696033d2909b989b3af16d58a9c41e6922d6" }, + "modals.task.organization.completionCount": null, "modals.task.organization.notices.noEligibleSubtasks": { "source": "bce7ca1d7ce5603179f86b83b9074a4aa27de835", "translation": "314bdd4cf938c1907c6b7a26509ee0a4cbdec5db" @@ -41752,33 +41815,45 @@ "source": "333c06c796628bc21ecdaa6da4556bbe343c2284", "translation": "08d443cf9b3c9458e11c788147c457240ce13031" }, + "modals.task.scheduledRow.label": null, + "modals.task.scheduledRow.placeholder": null, + "modals.task.detailsShowMore": null, + "modals.task.detailsShowLess": null, + "modals.task.chips.timeEstimate": null, + "modals.task.chips.timeEstimateValue": null, + "modals.task.chips.contextsPlural": null, + "modals.task.chips.tagsPlural": null, + "modals.task.chips.tagSelected": null, + "modals.task.chips.remindersSingle": null, + "modals.task.chips.remindersPlural": null, + "modals.task.chipList.removeItem": null, "modals.task.customFieldsLabel": { "source": "d1f86d5227e987ad9feec36387a01f71ac50987e", "translation": "eb9ac080fb66e2cc6662c0217533c0d0e3186a76" }, "modals.task.actions.due": { - "source": "ecf07cea2d2a6681f6074ac3d82bcae990c04a8d", - "translation": "3a62361e71f557ce405601c25bd33821c05e8c15" + "source": "4c1aeebc433bcb8eb4b05434619ea3136b785ea8", + "translation": "7484df0283554ae6df780ca6380c7bdd15cf00f2" }, "modals.task.actions.scheduled": { - "source": "d45d07555befa2ffc895f9d0425ce30f4bc92c3d", - "translation": "0e4ec80d5894cb196374c5ff12129abafa95128c" + "source": "b41cf063eca8243b7b8039af57f2b039fc868668", + "translation": "0ac577ce5eb53a87a45785afaba017ed857a8fd7" }, "modals.task.actions.status": { - "source": "45c5d329b685f50b513e431eb0cc226e3cba63eb", - "translation": "f80c3a10bdd33a8740f6505ce16591370c6ebc6c" + "source": "bae7d5be70820ed56467bd9a63744e23b47bd711", + "translation": "2926977ba7c9d5465edab324771625f471dc3020" }, "modals.task.actions.priority": { - "source": "993fd2a5326b1f03d2cf7f4bb7e6a5bc13154f28", - "translation": "4f82e21ea20322936abbd63abcd3d2d4f36b1af8" + "source": "886cbff9d9df761ec642945e519631a23ed173a2", + "translation": "a0914b4d2260611bf2afbcab3ff6e8f2bd0fed42" }, "modals.task.actions.recurrence": { - "source": "1aa43ddb78d67c24780c22cb775db45de4a0becf", - "translation": "c5ea9ce871e8e899171db9770855e61c41b146e0" + "source": "f7ad40f58caa3ee3d9e72be4378d5acf7807f657", + "translation": "b65a1e0b3cadf3bb2e7a75790c3faa29a455554e" }, "modals.task.actions.reminders": { - "source": "230294dc92087b5bab4f814f2151680d45063213", - "translation": "466eb5159a9eea734f9302632b36498b92180148" + "source": "ae8c3939382fbd2f6a05825c70d474b3c1de2b8d", + "translation": "0f7f7106d4613e1f9e92f4b83843dbb1fb3fda9b" }, "modals.task.buttons.openNote": { "source": "c66a827e3397b1e4eb14c164a586dbd77b36e8bc", @@ -50483,16 +50558,16 @@ "translation": "98a5efa60beb882ed4dbac1ac013f73418acda77" }, "modals.task.titleDetailedPlaceholder": { - "source": "da3700738242c039de32d3f5e3a0ba10bc28ba0b", - "translation": "c0b39adf02a5ca9f6b8f8315b7d2ed34f9f4ec78" + "source": "768e0c1c69573fb588f61f1308a015c11468e05f", + "translation": "98a5efa60beb882ed4dbac1ac013f73418acda77" }, "modals.task.detailsLabel": { "source": "dc3decbb93847518f1a049dcf49d0d7c6560bcc6", "translation": "6dd94e23a8f96f6d36e7f90909d9502695da4534" }, "modals.task.detailsPlaceholder": { - "source": "52fa1dfb1b96ceaa971d057d24e0b1f9dd17e61d", - "translation": "bcd2f27836e0dc23a10635cc108f4ef69ce05c7e" + "source": "55f8ebc805e65b5b71ddafdae390e3be2bcd69af", + "translation": "469768598e0fd225cdf8609caffb128dc3e0bcd0" }, "modals.task.projectsLabel": { "source": "53e890d5f0fffe09587c55dce74e9a6febf59b5c", @@ -50570,6 +50645,8 @@ "source": "24700e60fedcf00a741c5adb1d9f15aee05f95d8", "translation": "ec34132cf76934be08b03dfbd79aa5b7b10a98d0" }, + "modals.task.dependencies.addBlockedByButton": null, + "modals.task.dependencies.addBlockingButton": null, "modals.task.dependencies.selectTaskTooltip": { "source": "752b3be46866220ce17567bede45bdac374d4a4f", "translation": "1e17526f990fbce71fb7334f782e5f45cfb7eea2" @@ -50610,6 +50687,7 @@ "source": "d6acf378b038ad0b4b130817b370fb9dba31caef", "translation": "8fbf2d2c8b8df91ff1df2dec0e6d71466e546282" }, + "modals.task.organization.completionCount": null, "modals.task.organization.notices.noEligibleSubtasks": { "source": "bce7ca1d7ce5603179f86b83b9074a4aa27de835", "translation": "6b551e7a48899ecb79ea8058fb83970ec0624496" @@ -50618,33 +50696,45 @@ "source": "333c06c796628bc21ecdaa6da4556bbe343c2284", "translation": "50dcefc5e0691264e0ec2672f002d24dba8ca38a" }, + "modals.task.scheduledRow.label": null, + "modals.task.scheduledRow.placeholder": null, + "modals.task.detailsShowMore": null, + "modals.task.detailsShowLess": null, + "modals.task.chips.timeEstimate": null, + "modals.task.chips.timeEstimateValue": null, + "modals.task.chips.contextsPlural": null, + "modals.task.chips.tagsPlural": null, + "modals.task.chips.tagSelected": null, + "modals.task.chips.remindersSingle": null, + "modals.task.chips.remindersPlural": null, + "modals.task.chipList.removeItem": null, "modals.task.customFieldsLabel": { "source": "d1f86d5227e987ad9feec36387a01f71ac50987e", "translation": "cb455885fc6834e1d21311690e47948e9897fc5f" }, "modals.task.actions.due": { - "source": "ecf07cea2d2a6681f6074ac3d82bcae990c04a8d", - "translation": "77c16f47795dffb1d8c7f69374f0129fe955d6da" + "source": "4c1aeebc433bcb8eb4b05434619ea3136b785ea8", + "translation": "5fcea15ed5d9c5766534c2418c8419f77078e6b0" }, "modals.task.actions.scheduled": { - "source": "d45d07555befa2ffc895f9d0425ce30f4bc92c3d", - "translation": "9289778907e83f9db5f8c049e09e1a8feadc13da" + "source": "b41cf063eca8243b7b8039af57f2b039fc868668", + "translation": "17ca4e5ed72e97e99908a38a242d40e177b39c45" }, "modals.task.actions.status": { - "source": "45c5d329b685f50b513e431eb0cc226e3cba63eb", - "translation": "2393dbe3552667698bef593f1e33bddee1abcaa3" + "source": "bae7d5be70820ed56467bd9a63744e23b47bd711", + "translation": "bae7d5be70820ed56467bd9a63744e23b47bd711" }, "modals.task.actions.priority": { - "source": "993fd2a5326b1f03d2cf7f4bb7e6a5bc13154f28", - "translation": "c63a6e5dcdb72b0bda10778277354aa5e20eda0d" + "source": "886cbff9d9df761ec642945e519631a23ed173a2", + "translation": "c8c122c31c0c397797f0c1d72dbdb8568b890a05" }, "modals.task.actions.recurrence": { - "source": "1aa43ddb78d67c24780c22cb775db45de4a0becf", - "translation": "9c1385982df93fedd5f0608d1e3dd3bdb95a02e6" + "source": "f7ad40f58caa3ee3d9e72be4378d5acf7807f657", + "translation": "214469f5cc1695d05fa9154b92960cad62d68429" }, "modals.task.actions.reminders": { - "source": "230294dc92087b5bab4f814f2151680d45063213", - "translation": "e09f03943d0c96b5fe48f313ba8b67712a0c3f3e" + "source": "ae8c3939382fbd2f6a05825c70d474b3c1de2b8d", + "translation": "c922b1de4695598699aa2fe60e42c20a3af2ebc6" }, "modals.task.buttons.openNote": { "source": "c66a827e3397b1e4eb14c164a586dbd77b36e8bc", @@ -59349,16 +59439,16 @@ "translation": "3de49828e86a4016140f16781af6cceba1ad3c3c" }, "modals.task.titleDetailedPlaceholder": { - "source": "da3700738242c039de32d3f5e3a0ba10bc28ba0b", - "translation": "bd96afc85e9d0ba5b53023a65337738ca7e618a7" + "source": "768e0c1c69573fb588f61f1308a015c11468e05f", + "translation": "3de49828e86a4016140f16781af6cceba1ad3c3c" }, "modals.task.detailsLabel": { "source": "dc3decbb93847518f1a049dcf49d0d7c6560bcc6", "translation": "85a76a73dd68467aee540234cb44d1556129a542" }, "modals.task.detailsPlaceholder": { - "source": "52fa1dfb1b96ceaa971d057d24e0b1f9dd17e61d", - "translation": "a936dca809ea7045c5a7554bf8004d14900a24ad" + "source": "55f8ebc805e65b5b71ddafdae390e3be2bcd69af", + "translation": "f5441f6aee76222c4120066575e80c2d177ac3c0" }, "modals.task.projectsLabel": { "source": "53e890d5f0fffe09587c55dce74e9a6febf59b5c", @@ -59436,6 +59526,8 @@ "source": "24700e60fedcf00a741c5adb1d9f15aee05f95d8", "translation": "ca5f8743a2e0d3730c9e247e34186bd9dc61af9d" }, + "modals.task.dependencies.addBlockedByButton": null, + "modals.task.dependencies.addBlockingButton": null, "modals.task.dependencies.selectTaskTooltip": { "source": "752b3be46866220ce17567bede45bdac374d4a4f", "translation": "dbadae6acc56de6cb1d7f962cee361d64900bc80" @@ -59476,6 +59568,7 @@ "source": "d6acf378b038ad0b4b130817b370fb9dba31caef", "translation": "e9f7eaef87c23f1f90ef7c55e1e046966cb1e8cd" }, + "modals.task.organization.completionCount": null, "modals.task.organization.notices.noEligibleSubtasks": { "source": "bce7ca1d7ce5603179f86b83b9074a4aa27de835", "translation": "b5afd9994d807531fe1cc116c8f75eb398fb9579" @@ -59484,33 +59577,45 @@ "source": "333c06c796628bc21ecdaa6da4556bbe343c2284", "translation": "8e49b42974f02b6fa3fa3f6b29eccc235ab69c68" }, + "modals.task.scheduledRow.label": null, + "modals.task.scheduledRow.placeholder": null, + "modals.task.detailsShowMore": null, + "modals.task.detailsShowLess": null, + "modals.task.chips.timeEstimate": null, + "modals.task.chips.timeEstimateValue": null, + "modals.task.chips.contextsPlural": null, + "modals.task.chips.tagsPlural": null, + "modals.task.chips.tagSelected": null, + "modals.task.chips.remindersSingle": null, + "modals.task.chips.remindersPlural": null, + "modals.task.chipList.removeItem": null, "modals.task.customFieldsLabel": { "source": "d1f86d5227e987ad9feec36387a01f71ac50987e", "translation": "2808dd841af40296a1083d396549e6c47a7ac4d1" }, "modals.task.actions.due": { - "source": "ecf07cea2d2a6681f6074ac3d82bcae990c04a8d", - "translation": "23ba71c9ff6b67a5966dea1e8b9eaa24137a6575" + "source": "4c1aeebc433bcb8eb4b05434619ea3136b785ea8", + "translation": "dfd30eaa3551da74a39cc31a167db3dda79d52e0" }, "modals.task.actions.scheduled": { - "source": "d45d07555befa2ffc895f9d0425ce30f4bc92c3d", - "translation": "7c46c71abe6037c18e15b5b2f5aec762ec799865" + "source": "b41cf063eca8243b7b8039af57f2b039fc868668", + "translation": "cd12b1152eedf79bce5dd2cee1664c0384f6990e" }, "modals.task.actions.status": { - "source": "45c5d329b685f50b513e431eb0cc226e3cba63eb", - "translation": "f609149bbbcc0f947eaaa4e5c35ca46d5251a9c5" + "source": "bae7d5be70820ed56467bd9a63744e23b47bd711", + "translation": "f7f293b5c58cf8a1508319db3e59577951e44343" }, "modals.task.actions.priority": { - "source": "993fd2a5326b1f03d2cf7f4bb7e6a5bc13154f28", - "translation": "bf0910b8c1c70c67ec25556d03a345d644d00c11" + "source": "886cbff9d9df761ec642945e519631a23ed173a2", + "translation": "b5b6ddbf62afe4383eba7628e4ab66877476e51c" }, "modals.task.actions.recurrence": { - "source": "1aa43ddb78d67c24780c22cb775db45de4a0becf", - "translation": "4798c96749f5ef22e4ae77b5cc5464f08c14ddf0" + "source": "f7ad40f58caa3ee3d9e72be4378d5acf7807f657", + "translation": "107232ba34a46152dd3a329fa95083c480bc0acd" }, "modals.task.actions.reminders": { - "source": "230294dc92087b5bab4f814f2151680d45063213", - "translation": "7959563a28f955523ec7c349dac760527bf1c5c2" + "source": "ae8c3939382fbd2f6a05825c70d474b3c1de2b8d", + "translation": "6c9173653f037bea9adeab9f3cc79e07afbbc306" }, "modals.task.buttons.openNote": { "source": "c66a827e3397b1e4eb14c164a586dbd77b36e8bc", @@ -68215,16 +68320,16 @@ "translation": "748d7dc7e3212d62e207d204624705af70ea635c" }, "modals.task.titleDetailedPlaceholder": { - "source": "da3700738242c039de32d3f5e3a0ba10bc28ba0b", - "translation": "720e3af166e8f40538937458669c7a6ec3f5dfa9" + "source": "768e0c1c69573fb588f61f1308a015c11468e05f", + "translation": "748d7dc7e3212d62e207d204624705af70ea635c" }, "modals.task.detailsLabel": { "source": "dc3decbb93847518f1a049dcf49d0d7c6560bcc6", "translation": "4f55ee1e687ff9bc6416894b5b37a2150ad6ab8a" }, "modals.task.detailsPlaceholder": { - "source": "52fa1dfb1b96ceaa971d057d24e0b1f9dd17e61d", - "translation": "482600a3817215a06f197f3a827bf59f27077d73" + "source": "55f8ebc805e65b5b71ddafdae390e3be2bcd69af", + "translation": "412f54dc38e642eda535746e715503df35ad768f" }, "modals.task.projectsLabel": { "source": "53e890d5f0fffe09587c55dce74e9a6febf59b5c", @@ -68302,6 +68407,8 @@ "source": "24700e60fedcf00a741c5adb1d9f15aee05f95d8", "translation": "2de2818c5b9f938f095949d61943f2fb777a02f0" }, + "modals.task.dependencies.addBlockedByButton": null, + "modals.task.dependencies.addBlockingButton": null, "modals.task.dependencies.selectTaskTooltip": { "source": "752b3be46866220ce17567bede45bdac374d4a4f", "translation": "166eb3f82530d4b1b01965223352efa677a2528d" @@ -68342,6 +68449,7 @@ "source": "d6acf378b038ad0b4b130817b370fb9dba31caef", "translation": "165f2995e1ba002fabc3a8adc3aa06bedb0c3aa4" }, + "modals.task.organization.completionCount": null, "modals.task.organization.notices.noEligibleSubtasks": { "source": "bce7ca1d7ce5603179f86b83b9074a4aa27de835", "translation": "4206e6aa5673cb84b3e70cc84ddfbc928fd5bab9" @@ -68350,33 +68458,45 @@ "source": "333c06c796628bc21ecdaa6da4556bbe343c2284", "translation": "530515abf9b28d0993d2cbfad4a9de2b7db7608d" }, + "modals.task.scheduledRow.label": null, + "modals.task.scheduledRow.placeholder": null, + "modals.task.detailsShowMore": null, + "modals.task.detailsShowLess": null, + "modals.task.chips.timeEstimate": null, + "modals.task.chips.timeEstimateValue": null, + "modals.task.chips.contextsPlural": null, + "modals.task.chips.tagsPlural": null, + "modals.task.chips.tagSelected": null, + "modals.task.chips.remindersSingle": null, + "modals.task.chips.remindersPlural": null, + "modals.task.chipList.removeItem": null, "modals.task.customFieldsLabel": { "source": "d1f86d5227e987ad9feec36387a01f71ac50987e", "translation": "40516c8f045553da81624db7da31da311d942c51" }, "modals.task.actions.due": { - "source": "ecf07cea2d2a6681f6074ac3d82bcae990c04a8d", - "translation": "f026d9b9641613528baa06c0e18ed8d76071aab5" + "source": "4c1aeebc433bcb8eb4b05434619ea3136b785ea8", + "translation": "e3e6269d95aac4bb2dd7e993f84ad1f7957a0578" }, "modals.task.actions.scheduled": { - "source": "d45d07555befa2ffc895f9d0425ce30f4bc92c3d", - "translation": "d950b373159aa0aa0bcd5711a8a60450d8cdab3e" + "source": "b41cf063eca8243b7b8039af57f2b039fc868668", + "translation": "5ec2b1a71b2df03809e56c47cc145081a8afc436" }, "modals.task.actions.status": { - "source": "45c5d329b685f50b513e431eb0cc226e3cba63eb", - "translation": "2ea8a276badd845c74bc4d3997f60b46837e8809" + "source": "bae7d5be70820ed56467bd9a63744e23b47bd711", + "translation": "62e951a692ff8f4e8a0e9049d726f42685237d0d" }, "modals.task.actions.priority": { - "source": "993fd2a5326b1f03d2cf7f4bb7e6a5bc13154f28", - "translation": "8ec40eebd57dd25f139ac852158401851739c6b5" + "source": "886cbff9d9df761ec642945e519631a23ed173a2", + "translation": "7cabe771ebffbab6a13b58923112b60e51c47e97" }, "modals.task.actions.recurrence": { - "source": "1aa43ddb78d67c24780c22cb775db45de4a0becf", - "translation": "6fbae62916686021ef85afbeb59e4d75f99c14ec" + "source": "f7ad40f58caa3ee3d9e72be4378d5acf7807f657", + "translation": "6e708ba759df876033086fc8c687785ca4c74b6c" }, "modals.task.actions.reminders": { - "source": "230294dc92087b5bab4f814f2151680d45063213", - "translation": "8ba8d00a05309dbf7af9a80774e84e8f206f4276" + "source": "ae8c3939382fbd2f6a05825c70d474b3c1de2b8d", + "translation": "81944e48a3b4755f86edeb4de2cbb27161623b89" }, "modals.task.buttons.openNote": { "source": "c66a827e3397b1e4eb14c164a586dbd77b36e8bc", diff --git a/package-lock.json b/package-lock.json index b46bb3cb2..d18dd53ef 100644 --- a/package-lock.json +++ b/package-lock.json @@ -163,6 +163,7 @@ "integrity": "sha512-bXYxrXFubeYdvB0NhD/NBB3Qi6aZeV20GOWVI47t2dkecCEoneR4NPVcb7abpXDEvejgrUfFtG6vG/zxAKmg+g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.27.1", @@ -701,6 +702,7 @@ "integrity": "sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@keyv/serialize": "^1.1.1" } @@ -825,6 +827,7 @@ "resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.5.0.tgz", "integrity": "sha512-MwBHVK60IiIHDcoMet78lxt6iw5gJOGSbNbOIVBHWVXIH4/Nq1+GQgLLGgI1KlnN86WDXsPudVaqYHKBIx7Eyw==", "license": "MIT", + "peer": true, "dependencies": { "@marijn/find-cluster-break": "^1.0.0" } @@ -834,6 +837,7 @@ "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.38.6.tgz", "integrity": "sha512-qiS0z1bKs5WOvHIAC0Cybmv4AJSkAXgX5aD6Mqd2epSLlVJsQl8NG23jCVouIgkh4All/mrbdsf2UOLFnJw0tw==", "license": "MIT", + "peer": true, "dependencies": { "@codemirror/state": "^6.5.0", "crelt": "^1.0.6", @@ -929,6 +933,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=18" }, @@ -977,6 +982,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=18" } @@ -1759,6 +1765,7 @@ "resolved": "https://registry.npmjs.org/@fullcalendar/core/-/core-6.1.20.tgz", "integrity": "sha512-1cukXLlePFiJ8YKXn/4tMKsy0etxYLCkXk8nUCFi11nRONF2Ba2CD5b21/ovtOO2tL6afTJfwmc1ed3HG7eB1g==", "license": "MIT", + "peer": true, "dependencies": { "preact": "~10.12.1" } @@ -3060,6 +3067,7 @@ "integrity": "sha512-7199re3wvMAlVqXLaCyAr8IkJSXqkeVAxcYyB2rBu4Id5m2hhlGX1dQsdMBiCXLwu6/LLVqDvJggSNVQBzL6ZQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/css-font-loading-module": "^0.0.7" }, @@ -3109,6 +3117,7 @@ "integrity": "sha512-0XtvrfxHlS2T+beBBSpo7GI8+QLyyTqMVQpNmPqB4woYxzrOEJ9JaUFBaBfCvycLeUkfVih1u6HAbtF+2d1EjQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@pixi/color": "7.2.4", "@pixi/constants": "7.2.4", @@ -3131,6 +3140,7 @@ "integrity": "sha512-w5tqb8cWEO5qIDaO9GEqRvxYhL0iMk0Wsngw23bbLm1gLEQmrFkB2tpJlRAqd7H82C3DrDDeWvkrrxW6+m4apg==", "dev": true, "license": "MIT", + "peer": true, "peerDependencies": { "@pixi/core": "7.2.4" } @@ -3141,6 +3151,7 @@ "integrity": "sha512-/JtmoB98fzIU8giN9xvlRvmvOi6u4MaD2DnKNOMHkQ1MBraj3pmrXM9fZ0JbNzi+324GraAAY76QidgHjIYoYQ==", "dev": true, "license": "MIT", + "peer": true, "peerDependencies": { "@pixi/core": "7.2.4", "@pixi/display": "7.2.4" @@ -3229,6 +3240,7 @@ "integrity": "sha512-3A2EumTjWJgXlDLOyuBrl9b6v1Za/E+/IjOGUIX843HH4NYaf1a2sfDfljx6r3oiDvy+VhuBFmgynRcV5IyA0Q==", "dev": true, "license": "MIT", + "peer": true, "peerDependencies": { "@pixi/core": "7.2.4", "@pixi/display": "7.2.4", @@ -3248,6 +3260,7 @@ "integrity": "sha512-wiALIqcRKib2BqeH9kOA5fOKWN352nqAspgbDa8gA7OyWzmNwqIedIlElixd0oLFOrIN5jOZAdzeKnoYQlt9Aw==", "dev": true, "license": "MIT", + "peer": true, "peerDependencies": { "@pixi/core": "7.2.4", "@pixi/display": "7.2.4" @@ -3354,6 +3367,7 @@ "integrity": "sha512-DhR1B+/d0eXpxHIesJMXcVPrKFwQ+zRA1LvEIFfzewqfaRN3X6PMIuoKX8SIb6tl+Hq8Ba9Pe28zI7d2rmRzrA==", "dev": true, "license": "MIT", + "peer": true, "peerDependencies": { "@pixi/core": "7.2.4", "@pixi/display": "7.2.4" @@ -3399,6 +3413,7 @@ "integrity": "sha512-DGu7ktpe+zHhqR2sG9NsJt4mgvSObv5EqXTtUxD4Z0li1gmqF7uktpLyn5I6vSg1TTEL4TECClRDClVDGiykWw==", "dev": true, "license": "MIT", + "peer": true, "peerDependencies": { "@pixi/core": "7.2.4", "@pixi/sprite": "7.2.4" @@ -3449,6 +3464,7 @@ "integrity": "sha512-VUGQHBOINIS4ePzoqafwxaGPVRTa3oM/mEutIIHbNGI3b+QvSO+1Dnk40M0zcH6Bo+MxQZbOZK5X/wO9oU5+LQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@pixi/color": "7.2.4", "@pixi/constants": "7.2.4", @@ -4236,6 +4252,7 @@ "integrity": "sha512-HPwA+hVkfcriajbNvTmZv4VRauibay+cWArYUYq7u7W7PmGShMxbPxLvrwDme55a6d5alG3nrYfhyJ/G28XlLg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.59.3", "@typescript-eslint/types": "8.59.3", @@ -4768,6 +4785,7 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -5334,6 +5352,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "caniuse-lite": "^1.0.30001718", "electron-to-chromium": "^1.5.160", @@ -5587,6 +5606,7 @@ "integrity": "sha512-ci2iJH6LeIkvP9eJW6gpueU8cnZhv85ELY8w8WiFtNjMHA5ad6pQLaJo9mEly/9qUyCpvqX8/POVUTf18/HFdw==", "dev": true, "license": "Apache-2.0", + "peer": true, "dependencies": { "@chevrotain/cst-dts-gen": "11.0.3", "@chevrotain/gast": "11.0.3", @@ -5878,6 +5898,7 @@ "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "mdn-data": "2.27.1", "source-map-js": "^1.2.1" @@ -5919,6 +5940,7 @@ "integrity": "sha512-iJc4TwyANnOGR1OmWhsS9ayRS3s+XQ185FmuHObThD+5AeJCakAAbWv8KimMTt08xCCLNgneQwFp+JRJOr9qGQ==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=0.10" } @@ -6353,6 +6375,7 @@ "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", "dev": true, "license": "ISC", + "peer": true, "engines": { "node": ">=12" } @@ -7161,6 +7184,7 @@ "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -7237,6 +7261,7 @@ "integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==", "dev": true, "license": "MIT", + "peer": true, "bin": { "eslint-config-prettier": "bin/cli.js" }, @@ -7950,6 +7975,7 @@ "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", "license": "MIT", + "peer": true, "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", @@ -8901,6 +8927,7 @@ "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.19.tgz", "integrity": "sha512-xa3eYXYXx68XTT4hZ7dRzsXBhaq85ToSrlUJNoR0gwz/1Ap/CNwX47wfvV7pc/xWhjKVVkLT7zBJy8chhNguqQ==", "license": "MIT", + "peer": true, "engines": { "node": ">=16.9.0" } @@ -9823,6 +9850,7 @@ "integrity": "sha512-yC3JvpP/ZcAZX5rYCtXO/g9k6VTCQz0VFE2v1FpxytWzUqfDtu0XL/pwnNvptzYItvGwomh1ehomRNMOyhCJKw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@jest/core": "30.1.1", "@jest/types": "30.0.5", @@ -10582,6 +10610,7 @@ "integrity": "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "cssstyle": "^4.2.1", "data-urls": "^5.0.0", @@ -11583,6 +11612,7 @@ "integrity": "sha512-HxWqe763dOqzXjnNiHmAJTRERN8KILBSqxDSEqbeSr7W8R8Jxezzbca+nz1LiiqXnMpM8lV2jzAezw3CZ4xNUw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/codemirror": "5.60.8", "moment": "2.29.4" @@ -12033,6 +12063,7 @@ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -12278,6 +12309,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -12320,6 +12352,7 @@ "integrity": "sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" @@ -12361,6 +12394,7 @@ "integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==", "dev": true, "license": "MIT", + "peer": true, "bin": { "prettier": "bin/prettier.cjs" }, @@ -13708,6 +13742,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=20.19.0" }, @@ -13731,6 +13766,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=20.19.0" } @@ -14485,6 +14521,7 @@ "integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -15206,6 +15243,7 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/src/components/BatchContextMenu.ts b/src/components/BatchContextMenu.ts index 7a4084c20..05360c980 100644 --- a/src/components/BatchContextMenu.ts +++ b/src/components/BatchContextMenu.ts @@ -103,7 +103,7 @@ export class BatchContextMenu { // Due Date submenu this.menu.addItem((item) => { item.setTitle(this.t("contextMenus.task.dueDate")); - item.setIcon("calendar"); + item.setIcon("target"); const submenu = getSubmenu(item); this.addDateOptions(submenu, "due"); diff --git a/src/components/TaskContextMenu.ts b/src/components/TaskContextMenu.ts index 7760f07b3..6dbf954b2 100644 --- a/src/components/TaskContextMenu.ts +++ b/src/components/TaskContextMenu.ts @@ -70,7 +70,7 @@ function normalizeContextValue(value: string): string { return value.trim(); } -function normalizeContextList(contexts: string[] | undefined): string[] { +export function normalizeContextList(contexts: string[] | undefined): string[] { const seen = new Set(); const normalized: string[] = []; @@ -203,7 +203,7 @@ export class TaskContextMenu { // Due Date submenu this.menu.addItem((item) => { item.setTitle(this.t("contextMenus.task.dueDate")); - item.setIcon("calendar"); + item.setIcon("target"); const submenu = getSubmenu(item); this.addDateOptions( diff --git a/src/editor/EmbeddableMarkdownEditor.ts b/src/editor/EmbeddableMarkdownEditor.ts index 78b008f64..5a1de860f 100644 --- a/src/editor/EmbeddableMarkdownEditor.ts +++ b/src/editor/EmbeddableMarkdownEditor.ts @@ -60,6 +60,7 @@ type MarkdownEditorInternal = { type MarkdownEditorOwner = { editMode: unknown; editor: MarkdownEditorInternal | null; + syncScroll?: () => void; }; type ActiveMarkdownEditorOwner = { @@ -551,6 +552,9 @@ export class EmbeddableMarkdownEditor extends getEditorBase() { this.owner.editMode = this; this.owner.editor = this.editor; + // Obsidian's markdown scroll handler calls owner.syncScroll during scroll + // events. Embed editors are source-only, so a no-op is sufficient. + this.owner.syncScroll = () => {}; this.activeEditorOwner = { editMode: this, editor: new CodeMirrorEditorAdapter(this.editor.cm as unknown as EditorView) as unknown as Editor, diff --git a/src/i18n/resources/de.ts b/src/i18n/resources/de.ts index 46a27f84c..61ebad470 100644 --- a/src/i18n/resources/de.ts +++ b/src/i18n/resources/de.ts @@ -2446,9 +2446,9 @@ export const de: TranslationTree = { task: { titlePlaceholder: "Was muss getan werden?", titleLabel: "Titel", - titleDetailedPlaceholder: "Aufgabentitel...", + titleDetailedPlaceholder: "Titel", detailsLabel: "Details", - detailsPlaceholder: "Weitere Details hinzufügen...", + detailsPlaceholder: "Beschreibung", projectsLabel: "Projekte", projectsAdd: "Projekt hinzufügen", projectsTooltip: "Projektnotiz mit unscharfer Suche auswählen", @@ -2490,12 +2490,12 @@ export const de: TranslationTree = { }, customFieldsLabel: "Benutzerdefinierte Felder", actions: { - due: "Fälligkeitsdatum setzen", - scheduled: "Planungsdatum setzen", - status: "Status setzen", - priority: "Priorität setzen", - recurrence: "Wiederholung setzen", - reminders: "Erinnerungen setzen" + due: "Fälligkeitsdatum", + scheduled: "Planungsdatum", + status: "Status", + priority: "Priorität", + recurrence: "Wiederholung", + reminders: "Erinnerungen" }, buttons: { openNote: "Notiz öffnen", diff --git a/src/i18n/resources/en.ts b/src/i18n/resources/en.ts index 0926250b6..6eb7ce02e 100644 --- a/src/i18n/resources/en.ts +++ b/src/i18n/resources/en.ts @@ -2604,9 +2604,9 @@ export const en: TranslationTree = { task: { titlePlaceholder: "What needs to be done?", titleLabel: "Title", - titleDetailedPlaceholder: "Task title...", + titleDetailedPlaceholder: "Title", detailsLabel: "Details", - detailsPlaceholder: "Add more details...", + detailsPlaceholder: "Description", projectsLabel: "Projects", projectsAdd: "Add project", projectsTooltip: "Select a project note using fuzzy search", @@ -2629,6 +2629,8 @@ export const en: TranslationTree = { blocking: "Blocking", placeholder: "[[Task Note]]", addTaskButton: "Add task", + addBlockedByButton: "Add blocked by", + addBlockingButton: "Add blocking", selectTaskTooltip: "Select a task note using fuzzy search", removeTaskTooltip: "Remove task", }, @@ -2641,19 +2643,38 @@ export const en: TranslationTree = { addSubtasksButton: "Add subtask", addSubtasksTooltip: "Select tasks to make them subtasks of this task", removeSubtaskTooltip: "Remove subtask", + completionCount: "{completed}/{total}", notices: { noEligibleSubtasks: "No eligible tasks available to assign as subtasks", subtaskSelectFailed: "Failed to open subtask selector", }, }, + scheduledRow: { + label: "Date", + placeholder: "Add date", + }, + detailsShowMore: "Show more", + detailsShowLess: "Show less", + chips: { + timeEstimate: "Time estimate", + timeEstimateValue: "{minutes} min", + contextsPlural: "{count} contexts", + tagsPlural: "{count} tags", + tagSelected: "✓ {tag}", + remindersSingle: "1 reminder", + remindersPlural: "{count} reminders", + }, + chipList: { + removeItem: "Remove {item}", + }, customFieldsLabel: "Custom fields", actions: { - due: "Set due date", - scheduled: "Set scheduled date", - status: "Set status", - priority: "Set priority", - recurrence: "Set recurrence", - reminders: "Set reminders", + due: "Due date", + scheduled: "Scheduled date", + status: "Status", + priority: "Priority", + recurrence: "Recurrence", + reminders: "Reminders", }, buttons: { openNote: "Open note", diff --git a/src/i18n/resources/es.ts b/src/i18n/resources/es.ts index 07d2caeaf..c9a09a250 100644 --- a/src/i18n/resources/es.ts +++ b/src/i18n/resources/es.ts @@ -2446,9 +2446,9 @@ export const es: TranslationTree = { task: { titlePlaceholder: "¿Qué necesita hacerse?", titleLabel: "Título", - titleDetailedPlaceholder: "Título de la tarea...", + titleDetailedPlaceholder: "Título", detailsLabel: "Detalles", - detailsPlaceholder: "Agregar más detalles...", + detailsPlaceholder: "Descripción", projectsLabel: "Proyectos", projectsAdd: "Agregar proyecto", projectsTooltip: "Seleccionar una nota de proyecto usando búsqueda difusa", @@ -2490,12 +2490,12 @@ export const es: TranslationTree = { }, customFieldsLabel: "Campos personalizados", actions: { - due: "Establecer fecha de vencimiento", - scheduled: "Establecer fecha programada", - status: "Establecer estado", - priority: "Establecer prioridad", - recurrence: "Establecer recurrencia", - reminders: "Establecer recordatorios" + due: "Fecha de vencimiento", + scheduled: "Fecha programada", + status: "Estado", + priority: "Prioridad", + recurrence: "Recurrencia", + reminders: "Recordatorios" }, buttons: { openNote: "Abrir nota", diff --git a/src/i18n/resources/fr.ts b/src/i18n/resources/fr.ts index 9d16e2657..8e1aac1f2 100644 --- a/src/i18n/resources/fr.ts +++ b/src/i18n/resources/fr.ts @@ -2446,9 +2446,9 @@ export const fr: TranslationTree = { task: { titlePlaceholder: "Quel est votre prochain objectif ?", titleLabel: "Titre", - titleDetailedPlaceholder: "Titre de la tâche...", + titleDetailedPlaceholder: "Titre", detailsLabel: "Détails", - detailsPlaceholder: "Ajoutez davantage de détails...", + detailsPlaceholder: "Description", projectsLabel: "Projets", projectsAdd: "Ajouter un projet", projectsTooltip: "Sélectionnez une note de projet via la recherche floue", @@ -2490,12 +2490,12 @@ export const fr: TranslationTree = { }, customFieldsLabel: "Champs personnalisés", actions: { - due: "Définir l'échéance", - scheduled: "Définir la date planifiée", - status: "Définir le statut", - priority: "Définir la priorité", - recurrence: "Définir la récurrence", - reminders: "Définir les rappels" + due: "Échéance", + scheduled: "Date planifiée", + status: "Statut", + priority: "Priorité", + recurrence: "Récurrence", + reminders: "Rappels" }, buttons: { openNote: "Ouvrir la note", diff --git a/src/i18n/resources/ja.ts b/src/i18n/resources/ja.ts index 1e3f4df6e..4b9745e39 100644 --- a/src/i18n/resources/ja.ts +++ b/src/i18n/resources/ja.ts @@ -2446,9 +2446,9 @@ export const ja: TranslationTree = { task: { titlePlaceholder: "何をする必要がありますか?", titleLabel: "タイトル", - titleDetailedPlaceholder: "タスクタイトル...", + titleDetailedPlaceholder: "タイトル", detailsLabel: "詳細", - detailsPlaceholder: "詳細を追加...", + detailsPlaceholder: "説明", projectsLabel: "プロジェクト", projectsAdd: "プロジェクトを追加", projectsTooltip: "ファジー検索を使用してプロジェクトノートを選択", @@ -2490,12 +2490,12 @@ export const ja: TranslationTree = { }, customFieldsLabel: "カスタムフィールド", actions: { - due: "期限日を設定", - scheduled: "予定日を設定", - status: "ステータスを設定", - priority: "優先度を設定", - recurrence: "繰り返しを設定", - reminders: "リマインダーを設定" + due: "期限日", + scheduled: "予定日", + status: "ステータス", + priority: "優先度", + recurrence: "繰り返し", + reminders: "リマインダー" }, buttons: { openNote: "ノートを開く", diff --git a/src/i18n/resources/ko.ts b/src/i18n/resources/ko.ts index ee54da2c2..20d719c6e 100644 --- a/src/i18n/resources/ko.ts +++ b/src/i18n/resources/ko.ts @@ -2430,9 +2430,9 @@ export const ko: TranslationTree = { task: { titlePlaceholder: "무엇을 해야 하나요?", titleLabel: "제목", - titleDetailedPlaceholder: "작업 제목...", + titleDetailedPlaceholder: "제목", detailsLabel: "세부정보", - detailsPlaceholder: "세부정보 추가...", + detailsPlaceholder: "설명", projectsLabel: "프로젝트", projectsAdd: "프로젝트 추가", projectsTooltip: "퍼지 검색을 사용하여 프로젝트 노트 선택", @@ -2474,12 +2474,12 @@ export const ko: TranslationTree = { }, customFieldsLabel: "사용자 지정 필드", actions: { - due: "마감일 설정", - scheduled: "예정일 설정", - status: "상태 설정", - priority: "우선순위 설정", - recurrence: "반복 설정", - reminders: "리마인더 설정" + due: "마감일", + scheduled: "예정일", + status: "상태", + priority: "우선순위", + recurrence: "반복", + reminders: "리마인더" }, buttons: { openNote: "노트 열기", diff --git a/src/i18n/resources/pt.ts b/src/i18n/resources/pt.ts index 7d377b4c7..4a1a77299 100644 --- a/src/i18n/resources/pt.ts +++ b/src/i18n/resources/pt.ts @@ -2448,9 +2448,9 @@ export const pt: TranslationTree = { task: { titlePlaceholder: "O que precisa ser feito?", titleLabel: "Título", - titleDetailedPlaceholder: "Título da tarefa...", + titleDetailedPlaceholder: "Título", detailsLabel: "Detalhes", - detailsPlaceholder: "Adicionar mais detalhes...", + detailsPlaceholder: "Descrição", projectsLabel: "Projetos", projectsAdd: "Adicionar Projeto", projectsTooltip: "Selecionar uma nota de projeto usando busca difusa", @@ -2492,12 +2492,12 @@ export const pt: TranslationTree = { }, customFieldsLabel: "Campos Personalizados", actions: { - due: "Definir data de vencimento", - scheduled: "Definir data agendada", - status: "Definir status", - priority: "Definir prioridade", - recurrence: "Definir recorrência", - reminders: "Definir lembretes" + due: "Data de vencimento", + scheduled: "Data agendada", + status: "Status", + priority: "Prioridade", + recurrence: "Recorrência", + reminders: "Lembretes" }, buttons: { openNote: "Abrir nota", diff --git a/src/i18n/resources/ru.ts b/src/i18n/resources/ru.ts index d0f75346a..f39236720 100644 --- a/src/i18n/resources/ru.ts +++ b/src/i18n/resources/ru.ts @@ -2446,9 +2446,9 @@ export const ru: TranslationTree = { task: { titlePlaceholder: "Что нужно сделать?", titleLabel: "Название", - titleDetailedPlaceholder: "Название задачи...", + titleDetailedPlaceholder: "Название", detailsLabel: "Детали", - detailsPlaceholder: "Добавить больше деталей...", + detailsPlaceholder: "Описание", projectsLabel: "Проекты", projectsAdd: "Добавить проект", projectsTooltip: "Выберите заметку проекта, используя нечеткий поиск", @@ -2490,12 +2490,12 @@ export const ru: TranslationTree = { }, customFieldsLabel: "Пользовательские поля", actions: { - due: "Установить срок выполнения", - scheduled: "Установить запланированную дату", - status: "Установить статус", - priority: "Установить приоритет", - recurrence: "Установить повторение", - reminders: "Установить напоминания" + due: "Срок выполнения", + scheduled: "Запланированная дата", + status: "Статус", + priority: "Приоритет", + recurrence: "Повторение", + reminders: "Напоминания" }, buttons: { openNote: "Открыть заметку", diff --git a/src/i18n/resources/zh.ts b/src/i18n/resources/zh.ts index 0665e8e0c..21ccd8dfb 100644 --- a/src/i18n/resources/zh.ts +++ b/src/i18n/resources/zh.ts @@ -2446,9 +2446,9 @@ export const zh: TranslationTree = { task: { titlePlaceholder: "需要做什么?", titleLabel: "标题", - titleDetailedPlaceholder: "任务标题...", + titleDetailedPlaceholder: "标题", detailsLabel: "详情", - detailsPlaceholder: "添加更多详情...", + detailsPlaceholder: "描述", projectsLabel: "项目", projectsAdd: "添加项目", projectsTooltip: "使用模糊搜索选择项目笔记", @@ -2490,12 +2490,12 @@ export const zh: TranslationTree = { }, customFieldsLabel: "自定义字段", actions: { - due: "设置到期日期", - scheduled: "设置安排日期", - status: "设置状态", - priority: "设置优先级", - recurrence: "设置重复", - reminders: "设置提醒" + due: "到期日期", + scheduled: "安排日期", + status: "状态", + priority: "优先级", + recurrence: "重复", + reminders: "提醒" }, buttons: { openNote: "打开笔记", diff --git a/src/modals/TaskActionPaletteModal.ts b/src/modals/TaskActionPaletteModal.ts index 8520aca67..74483b3db 100644 --- a/src/modals/TaskActionPaletteModal.ts +++ b/src/modals/TaskActionPaletteModal.ts @@ -136,7 +136,7 @@ export class TaskActionPaletteModal extends FuzzySuggestModal { id: "set-due-date", title: "Set due date", description: "Set or change the task due date", - icon: "calendar", + icon: "target", category: "dates", keywords: ["due", "date", "deadline", "set", "change"], isApplicable: () => true, diff --git a/src/modals/TaskCreationModal.ts b/src/modals/TaskCreationModal.ts index 058ac332c..7a6dd404c 100644 --- a/src/modals/TaskCreationModal.ts +++ b/src/modals/TaskCreationModal.ts @@ -23,7 +23,9 @@ import { applyTaskCreationSubtaskAssignments } from "./taskCreationSubtasks"; import { NLPSuggest } from "./taskCreationSuggest"; import { shouldShowFilenameShortenedNotice } from "../utils/filenameGenerator"; import { setTaskModalDetailsEditorValue } from "./taskModalDetailsEditor"; +import { createTaskModalChipRow, createTaskModalChipBreak } from "./taskModalChipRow"; import { collapseTaskModalDetailsLayout } from "./taskModalLayout"; +import type { TaskModalLeadingIconButton } from "./taskModalActionButtons"; import { createTaskNotesLogger } from "../utils/tasknotesLogger"; const tasknotesLogger = createTaskNotesLogger({ tag: "Modals/TaskCreationModal" }); @@ -160,11 +162,7 @@ export class TaskCreationModal extends TaskModal { if (this.plugin.settings.enableNaturalLanguageInput) { this.createNaturalLanguageInput(container); } else { - // Fall back to regular title input - this.createTitleInput(container); - // When NLP is disabled, start with the modal expanded - this.isExpanded = true; - this.containerEl.addClass("expanded"); + super.createPrimaryInput(container); } } @@ -231,20 +229,10 @@ export class TaskCreationModal extends TaskModal { if (shift) { return this.focusPreviousNaturalLanguageField(editorContainer); } - // Tab - jump to title input (expand form if needed) if (!this.isExpanded) { this.expandModal(); } - // Focus title input - window.setTimeout(() => { - const titleInput = this.modalEl.querySelector( - ".title-input-detailed" - ) as HTMLInputElement; - if (titleInput) { - titleInput.focus(); - } - }, 50); - return true; // Prevent default tab behavior + return this.focusNextNaturalLanguageField(editorContainer); }, onEnter: (editor, mod, shift) => { if (mod) { @@ -305,6 +293,32 @@ export class TaskCreationModal extends TaskModal { } } + private focusNextNaturalLanguageField(editorContainer: HTMLElement): boolean { + const root = this.modalEl.contains(editorContainer) ? this.modalEl : this.contentEl; + const orderedElements = Array.from( + root.querySelectorAll(NLP_TAB_ORDER_SELECTOR) + ); + const currentIndex = orderedElements.findIndex( + (element) => element === editorContainer || editorContainer.contains(element) + ); + + if (currentIndex < 0 || currentIndex >= orderedElements.length - 1) { + return true; + } + + const nextElement = orderedElements + .slice(currentIndex + 1) + .find(isFocusableModalElement); + + if (nextElement) { + window.setTimeout(() => { + nextElement.focus(); + }, 50); + } + + return true; + } + private focusPreviousNaturalLanguageField(editorContainer: HTMLElement): boolean { const root = this.modalEl.contains(editorContainer) ? this.modalEl : this.contentEl; const orderedElements = Array.from( @@ -332,6 +346,26 @@ export class TaskCreationModal extends TaskModal { return true; } + protected focusPreviousField(): boolean { + if (this.plugin.settings.enableNaturalLanguageInput && !this.titleInput) { + window.setTimeout(() => { + const cm = this.nlMarkdownEditor?.editor?.cm; + if (cm) { + cm.focus(); + cm.scrollDOM.scrollTop = 0; + return; + } + + if (this.nlInput) { + this.nlInput.focus({ preventScroll: true }); + } + }, 50); + return true; + } + + return super.focusPreviousField(); + } + protected focusTitleInput(): void { if (!this.plugin.settings.enableNaturalLanguageInput) { super.focusTitleInput(); @@ -412,90 +446,54 @@ export class TaskCreationModal extends TaskModal { } protected createActionBar(container: HTMLElement): void { - this.actionBar = container.createDiv("tn-task-modal__action-bar"); + this.actionBar = createTaskModalChipRow(container); // NLP-specific icons (only if NLP is enabled) if (this.plugin.settings.enableNaturalLanguageInput) { - // Fill form icon - this.createActionIcon( + this.createActionChip( this.actionBar, "wand", this.t("modals.taskCreation.actions.fillFromNaturalLanguage"), - (icon, event) => { + (icon, _event) => { const input = this.getNLPInputValue().trim(); if (input) { this.parseAndFillForm(input); } - } + }, + "nlp-fill" ); - // Expand/collapse icon - this.createActionIcon( + this.createActionChip( this.actionBar, this.isExpanded ? "chevron-up" : "chevron-down", this.isExpanded ? this.t("modals.taskCreation.actions.hideDetailedOptions") : this.t("modals.taskCreation.actions.showDetailedOptions"), - (icon, event) => { + (icon, _event) => { this.toggleDetailedForm(); - // Update icon and tooltip - const iconEl = icon.querySelector(".icon"); + const iconEl = icon.querySelector(".tn-task-modal__chip-icon .icon, .icon"); if (iconEl) { setIcon( iconEl as HTMLElement, this.isExpanded ? "chevron-up" : "chevron-down" ); } - setTooltip( - icon, - this.isExpanded - ? this.t("modals.taskCreation.actions.hideDetailedOptions") - : this.t("modals.taskCreation.actions.showDetailedOptions"), - { placement: "top" } - ); - } + const nextLabel = this.isExpanded + ? this.t("modals.taskCreation.actions.hideDetailedOptions") + : this.t("modals.taskCreation.actions.showDetailedOptions"); + setTooltip(icon, nextLabel, { placement: "top" }); + const labelEl = icon.querySelector(".tn-task-modal__chip-label"); + if (labelEl) { + labelEl.textContent = nextLabel; + } + }, + "nlp-expand" ); - // Add separator - const separator = this.actionBar.createDiv("action-separator"); - separator.classList.remove( - "tn-static-width-100-0466783d", - "tn-static-width-12px-fbf353fb", - "tn-static-width-16px-7375d50b", - "tn-static-width-200px-2acaf3b5", - "tn-static-width-60px-bd09c419", - "tn-static-width-80px-8573bae3" - ); - separator.classList.add("tn-static-width-1px-aa77e27e"); - separator.classList.remove( - "tn-static-display-flex-4d51fc62", - "tn-static-height-0-7a31cef0", - "tn-static-height-100-62264068", - "tn-static-height-12px-06c0747e", - "tn-static-height-16px-30de4aee", - "tn-static-min-height-800px-997b4c8c" - ); - separator.classList.add("tn-static-height-24px-29a11d37"); - separator.classList.remove( - "tn-static-background-color-var-background-se-9087a23e", - "tn-static-background-color-var-color-base-40-ef5f175e", - "tn-static-background-color-var-color-red-134bc721", - "tn-static-background-color-var-text-accent-a954c70f" - ); - separator.classList.add("tn-static-background-color-var-background-mo-94b219f0"); - separator.classList.remove( - "tn-static-margin-0-11696618", - "tn-static-margin-0-auto-266e9b04", - "tn-static-margin-0-db0d5f36", - "tn-static-margin-2px-0-edce9b14", - "tn-static-margin-8px-0-0-0-a2eb8382", - "tn-static-padding-12px-43bef435", - "tn-static-padding-20px-ebe8e48c" - ); - separator.classList.add("tn-static-margin-0-var-size-4-2-77f7dc08"); + createTaskModalChipBreak(this.actionBar); } - this.createCoreActionIcons(this.actionBar); + this.createCoreChips(this.actionBar); this.updateIconStates(); } @@ -572,19 +570,116 @@ export class TaskCreationModal extends TaskModal { private toggleDetailedForm(): void { if (this.isExpanded) { - // Collapse this.isExpanded = false; + if (!this.isMobileLikeEnvironment()) { + this.syncFormStateFromInputs(); + this.refreshCreationDesktopLayout(); + return; + } + collapseTaskModalDetailsLayout({ detailsContainer: this.detailsContainer, splitRightColumn: this.splitRightColumn, }); this.containerEl.removeClass("expanded"); } else { - // Expand this.expandModal(); } } + protected expandModal(): void { + if (this.isExpanded) return; + + this.isExpanded = true; + if (!this.isMobileLikeEnvironment()) { + this.applyNaturalLanguageInputIfNeeded(); + this.refreshCreationDesktopLayout(); + return; + } + + super.expandModal(); + } + + private applyNaturalLanguageInputIfNeeded(): void { + if (!this.plugin.settings.enableNaturalLanguageInput) { + return; + } + + const nlContent = this.getNLPInputValue().trim(); + if (!nlContent) { + return; + } + + const parsed = this.nlParser.parseInput(nlContent); + this.applyParsedData(parsed); + } + + private syncFormStateFromInputs(): void { + if (this.titleInput) { + this.title = this.titleInput.value; + } + + if (this.detailsMarkdownEditor) { + this.details = this.detailsMarkdownEditor.value; + } + } + + private refreshCreationDesktopLayout(): void { + this.syncFormStateFromInputs(); + this.teardownCreationModalContent(); + this.applyModalLayoutClasses(); + this.createModalContent(); + this.renderProjectsList(); + this.updateIconStates(); + + if (this.isExpanded) { + this.focusTitleInput(); + } else { + this.focusNaturalLanguageInput(); + } + } + + private teardownCreationModalContent(): void { + if (this.nlMarkdownEditor) { + this.nlMarkdownEditor.destroy(); + this.nlMarkdownEditor = null; + } + + this.teardownModalContent(); + } + + private focusNaturalLanguageInput(): void { + const editorContainer = this.contentEl.querySelector( + ".tn-task-modal__markdown-editor--nlp" + ); + if (!editorContainer) { + return; + } + + editorContainer.focus(); + } + + protected getLeadingActionButtons(): TaskModalLeadingIconButton[] { + if ( + !this.plugin.settings.enableNaturalLanguageInput || + !this.isExpanded || + this.isMobileLikeEnvironment() + ) { + return []; + } + + return [ + { + className: "tn-task-modal__collapse-details-button", + iconName: "chevron-up", + label: this.t("modals.taskCreation.actions.hideDetailedOptions"), + onClick: () => { + this.toggleDetailedForm(); + }, + }, + ]; + } + async initializeFormData(): Promise { const formState = buildTaskCreationFormState({ defaultPriority: this.plugin.settings.defaultTaskPriority, @@ -612,6 +707,10 @@ export class TaskCreationModal extends TaskModal { this.initializeProjectsFromStrings(formState.projectStrings); } + if (!this.plugin.settings.enableNaturalLanguageInput) { + this.isExpanded = true; + } + this.details = this.normalizeDetails(this.details); this.originalDetails = this.details; } diff --git a/src/modals/TaskEditModal.ts b/src/modals/TaskEditModal.ts index f800917b1..a3adfd5c6 100644 --- a/src/modals/TaskEditModal.ts +++ b/src/modals/TaskEditModal.ts @@ -18,6 +18,7 @@ import { showTaskModalReminderContextMenu } from "./taskModalActionMenus"; import { buildTaskEditChangesFromModalState } from "./taskEditChangeState"; import { buildTaskEditFormStateFromTask } from "./taskEditFormState"; import { applyTaskEditSubtaskChanges, hasTaskEditSubtaskChanges } from "./taskEditSubtasks"; +import { shouldUseSplitLayoutEnabledClass } from "./taskModalLayout"; import { createTaskNotesLogger } from "../utils/tasknotesLogger"; const tasknotesLogger = createTaskNotesLogger({ tag: "Modals/TaskEditModal" }); @@ -133,9 +134,31 @@ export class TaskEditModal extends TaskModal { } onOpen(): void { + this.applyEditModalLayoutClasses(); void this.openEditModal(); } + private applyEditModalLayoutClasses(): void { + this.containerEl.addClass("tasknotes-plugin", "minimalist-task-modal", "expanded"); + if ( + shouldUseSplitLayoutEnabledClass({ + enableModalSplitLayout: this.plugin.settings.enableModalSplitLayout, + usesEditSidebarLayout: this.usesEditSidebarLayout(), + usesSheetLayout: this.usesSheetLayout(), + }) + ) { + this.containerEl.addClass("split-layout-enabled"); + } + if (this.usesSheetLayout()) { + this.containerEl.addClass("tn-task-modal--sheet"); + this.modalEl.addClass("tn-task-modal__sheet--pending"); + } + if (this.usesEditSidebarLayout()) { + this.containerEl.addClass("tn-task-modal--edit-desktop"); + } + this.modalEl.addClass("mod-tasknotes"); + } + private async openEditModal(): Promise { // Clear any previous completion changes this.completedInstancesChanges = []; @@ -144,12 +167,6 @@ export class TaskEditModal extends TaskModal { // Refresh task data from file before opening await this.refreshTaskData(); - this.containerEl.addClass("tasknotes-plugin", "minimalist-task-modal", "expanded"); - if (this.plugin.settings.enableModalSplitLayout) { - this.containerEl.addClass("split-layout-enabled"); - } - this.modalEl.addClass("mod-tasknotes"); - // Set the modal title using the standard Obsidian approach (preserves close button) this.titleEl.setText(this.getModalTitle()); @@ -167,9 +184,8 @@ export class TaskEditModal extends TaskModal { void this.initializeFormData().then(() => { this.createModalContent(); - // Render projects list after modal content is created + this.setupSheetGestures(); this.renderProjectsList(); - // Update icon states after creating the action bar this.updateIconStates(); this.focusTitleInput(); }); @@ -372,12 +388,14 @@ export class TaskEditModal extends TaskModal { } private createMetadataSection(container: HTMLElement): void { - this.metadataContainer = container.createDiv("metadata-container"); + this.metadataContainer = container.createDiv({ + cls: "metadata-container tn-task-modal__task-info", + }); const metadataLabel = this.metadataContainer.createDiv("detail-label"); metadataLabel.textContent = this.t("modals.taskEdit.sections.taskInfo"); - const metadataContent = this.metadataContainer.createDiv("metadata-content"); + const metadataContent = this.metadataContainer.createDiv("tn-task-modal__task-info-card"); const timeFormat = this.plugin.settings.calendarViewSettings?.timeFormat ?? "24"; // Total tracked time @@ -437,9 +455,9 @@ export class TaskEditModal extends TaskModal { } private createMetadataItem(container: HTMLElement, key: string, value: string): void { - const item = container.createDiv("metadata-item"); - item.createSpan("metadata-key").textContent = `${key} `; - item.createSpan("metadata-value").textContent = value; + const item = container.createDiv("tn-task-modal__task-info-row metadata-item"); + item.createSpan("tn-task-modal__task-info-key").textContent = `${key} `; + item.createSpan("tn-task-modal__task-info-value metadata-value").textContent = value; } async handleSave(): Promise { @@ -681,26 +699,31 @@ export class TaskEditModal extends TaskModal { leadingButtons: [ { className: "tn-task-modal__open-note-button", - text: this.t("modals.task.buttons.openNote"), + iconName: "external-link", + label: this.t("modals.task.buttons.openNote"), onClick: () => { void this.openTaskNote(); }, }, { - className: "mod-warning tn-task-modal__archive-button", - text: this.task.archived + className: "tn-task-modal__archive-button", + iconName: "archive", + label: this.task.archived ? this.t("modals.taskEdit.buttons.unarchive") : this.t("modals.taskEdit.buttons.archive"), onClick: () => { void this.archiveTask(); }, + isWarning: true, }, { - className: "mod-warning tn-task-modal__delete-button", - text: this.t("contextMenus.task.delete"), + className: "tn-task-modal__delete-button", + iconName: "trash-2", + label: this.t("contextMenus.task.delete"), onClick: () => { void this.deleteTask(); }, + isWarning: true, }, ], onSave: () => this.handleSave(), diff --git a/src/modals/TaskModal.ts b/src/modals/TaskModal.ts index 7635f8a3c..11cf0fa2d 100644 --- a/src/modals/TaskModal.ts +++ b/src/modals/TaskModal.ts @@ -15,6 +15,7 @@ import { destroyTaskModalDetailsEditor, } from "./taskModalDetailsEditor"; import { splitFrontmatterAndBody } from "../utils/helpers"; +import { formatDateTimeForDisplay } from "../utils/dateUtils"; import { ProjectSelectModal } from "./ProjectSelectModal"; import { TaskDependency, Reminder } from "../types"; import { DEFAULT_DEPENDENCY_RELTYPE, formatDependencyLink } from "../utils/dependencyUtils"; @@ -28,6 +29,7 @@ import { createTaskModalSubtasksField, type TaskModalOrganizationFieldContext, } from "./taskModalOrganizationFields"; +import { countTaskModalCompletion } from "./taskModalOrgCounts"; import { createTaskCard } from "../ui/TaskCard"; import { addDependencyItem, @@ -56,19 +58,42 @@ import { import { createTaskModalActionButtons, type TaskModalActionButtonContext, - type TaskModalLeadingButton, + type TaskModalLeadingIconButton, } from "./taskModalActionButtons"; import { - createTaskModalActionIcon, - createTaskModalActionIcons, - type TaskModalActionIconSpec, -} from "./taskModalActionBar"; + createTaskModalChip, + createTaskModalChipRow, + createTaskModalChips, + type TaskModalChipSpec, +} from "./taskModalChipRow"; import { updateTaskModalActionIconStates } from "./taskModalActionIconStates"; import { - buildTaskModalActionIconState, + buildTaskModalChipState, createTaskModalActionMenuContext, createTaskModalActionMenuState, } from "./taskModalActionState"; +import { getTaskModalRecurrenceDisplayText } from "./taskModalActionValues"; +import { + showTaskModalContextsInput, + showTaskModalTagsInput, + showTaskModalTimeEstimateInput, + type TaskModalPropertyMenuContext, +} from "./taskModalPropertyMenus"; +import { + attachTaskModalDescriptionClamp, + type TaskModalDescriptionClampController, +} from "./taskModalDescriptionClamp"; +import { + attachTaskModalSheetGestures, + createTaskModalSheetHandle, + type TaskModalSheetGestureController, +} from "./taskModalSheetGestures"; +import { + createTaskModalSidebarOrgSection, + createTaskModalSidebarRow, + updateTaskModalSidebarRow, + type TaskModalSidebarRowSpec, +} from "./taskModalSidebar"; import { showTaskModalDateContextMenu, showTaskModalPriorityContextMenu, @@ -99,7 +124,12 @@ import { createTaskModalTitleTextarea, type TaskModalTitleInputElement, } from "./taskModalTitleInput"; -import { collapseTaskModalDetailsLayout, expandTaskModalDetailsLayout } from "./taskModalLayout"; +import { + collapseTaskModalDetailsLayout, + expandTaskModalDetailsLayout, + shouldUseEditSidebarLayout, + shouldUseSplitLayoutEnabledClass, +} from "./taskModalLayout"; import { TaskModalFocusGuards, type TaskModalMobileKeyboardScrollGuardOptions, @@ -108,6 +138,40 @@ import { createTaskNotesLogger } from "../utils/tasknotesLogger"; const tasknotesLogger = createTaskNotesLogger({ tag: "Modals/TaskModal" }); +/** + * Field ids for the core task properties shared between the desktop edit + * sidebar and the mobile grouped property sections. Excludes + * projects/subtasks/blocked-by/blocking, which have their own dedicated + * organization-field cards with completion counts. + */ +const PRIMARY_PROPERTY_FIELD_IDS = [ + "status", + "priority", + "due-date", + "scheduled-date", + "recurrence", + "reminders", + "contexts", + "tags", + "time-estimate", +] as const; + +/** Full mobile sheet row order, matching the desktop edit sidebar. */ +const MOBILE_SHEET_FIELD_ORDER = [ + "projects", + "subtasks", + ...PRIMARY_PROPERTY_FIELD_IDS, + "blocked-by", + "blocking", +] as const; + +const MOBILE_ORG_FIELD_IDS = new Set([ + "projects", + "subtasks", + "blocked-by", + "blocking", +]); + export abstract class TaskModal extends Modal { plugin: TaskNotesPlugin; private keyboardHandler: ((e: KeyboardEvent) => void) | null = null; @@ -170,6 +234,8 @@ export abstract class TaskModal extends Modal { protected renderDependencyLists(): void { this.renderBlockedByList(); this.renderBlockingList(); + this.refreshOrganizationHeaders(); + this.updateSidebarStates(); } protected getLinkServices(): LinkServices { @@ -364,6 +430,10 @@ export abstract class TaskModal extends Modal { protected actionBar: HTMLElement = undefined as unknown as HTMLElement; protected detailsContainer: HTMLElement = undefined as unknown as HTMLElement; protected isExpanded = false; + protected sidebarEl: HTMLElement | undefined = undefined; + protected sheetGestureController: TaskModalSheetGestureController | undefined = undefined; + protected descriptionClampController: TaskModalDescriptionClampController | undefined = undefined; + private organizationHeaderUpdaters: Array<() => void> = []; constructor(app: App, plugin: TaskNotesPlugin) { super(app); @@ -439,8 +509,14 @@ export abstract class TaskModal extends Modal { onOpen() { this.containerEl.addClass("tasknotes-plugin", "minimalist-task-modal"); - if (this.plugin.settings.enableModalSplitLayout) { - this.containerEl.addClass("split-layout-enabled"); + if (this.usesSheetLayout()) { + this.containerEl.addClass("tn-task-modal--sheet"); + // Keep the sheet off-screen until its content has loaded and the + // gesture controller has measured the real partial-snap offset. + // Removed once `attachTaskModalSheetGestures` applies that offset, + // so the sheet slides straight to its resting position instead of + // flashing fully expanded first. + this.modalEl.addClass("tn-task-modal__sheet--pending"); } this.modalEl.addClass("mod-tasknotes"); @@ -462,11 +538,67 @@ export abstract class TaskModal extends Modal { this.containerEl.addEventListener("keydown", this.keyboardHandler); void this.initializeFormData().then(() => { + this.applyModalLayoutClasses(); this.createModalContent(); + this.setupSheetGestures(); this.focusTitleInput(); }); } + protected applyModalLayoutClasses(): void { + const usesEditSidebar = this.usesEditSidebarLayout(); + this.containerEl.toggleClass( + "split-layout-enabled", + shouldUseSplitLayoutEnabledClass({ + enableModalSplitLayout: this.plugin.settings.enableModalSplitLayout, + usesEditSidebarLayout: usesEditSidebar, + usesSheetLayout: this.usesSheetLayout(), + }) + ); + this.containerEl.toggleClass("tn-task-modal--edit-desktop", usesEditSidebar); + this.containerEl.toggleClass("expanded", this.isExpanded); + } + + protected usesSheetLayout(): boolean { + return this.isMobileLikeEnvironment(); + } + + protected usesEditSidebarLayout(): boolean { + return shouldUseEditSidebarLayout({ + isEditMode: this.isEditMode(), + isCreationMode: this.isCreationMode(), + isExpanded: this.isExpanded, + isMobileLikeEnvironment: this.isMobileLikeEnvironment(), + }); + } + + protected usesChipRow(): boolean { + return !this.usesEditSidebarLayout() && !this.usesSheetLayout(); + } + + protected usesGroupedPropertyList(): boolean { + return this.usesSheetLayout(); + } + + protected setupSheetGestures(): void { + if (!this.usesSheetLayout()) return; + + this.sheetGestureController = attachTaskModalSheetGestures({ + containerEl: this.containerEl, + modalEl: this.modalEl, + onDismiss: () => { + this.close(); + }, + onSnapChange: (snap) => { + if (snap === "full") { + this.containerEl.removeClass("tn-task-modal--sheet-partial"); + } else { + this.containerEl.addClass("tn-task-modal--sheet-partial"); + } + }, + }); + } + // Store references to split layout containers for potential reuse protected splitContentWrapper: HTMLElement; protected splitLeftColumn: HTMLElement; @@ -475,30 +607,476 @@ export abstract class TaskModal extends Modal { protected createModalContent(): void { const { contentEl } = this; contentEl.empty(); + this.organizationHeaderUpdaters = []; + + if (this.usesSheetLayout()) { + createTaskModalSheetHandle(contentEl); + } - // Create main container const container = contentEl.createDiv("minimalist-modal-container"); - // Create split content wrapper at the top level for wide screen layout + if (this.usesEditSidebarLayout()) { + this.createEditDesktopLayout(container); + this.createActionButtons(container); + return; + } + this.splitContentWrapper = container.createDiv("modal-split-content"); this.splitLeftColumn = this.splitContentWrapper.createDiv("modal-split-left"); + this.splitRightColumn = this.splitLeftColumn.createDiv("modal-split-right"); - // Create primary input area (title or NLP) - subclasses can override this.createPrimaryInput(this.splitLeftColumn); + this.createDetailsSection(container); - // Create action bar with icons - goes in left column - this.createActionBar(this.splitLeftColumn); + if (this.usesChipRow()) { + this.createActionBar(this.splitLeftColumn); + } else if (this.usesGroupedPropertyList()) { + this.createMobileSheetSections(this.splitLeftColumn); + } - this.splitRightColumn = this.splitLeftColumn.createDiv("modal-split-right"); + this.createAdditionalSections(this.splitLeftColumn); + this.createActionButtons(container); + } - // Create collapsible details section (fields in left, details editor in right) - this.createDetailsSection(container); + protected createEditDesktopLayout(container: HTMLElement): void { + const panes = container.createDiv("tn-task-modal__edit-panes"); + const mainColumn = panes.createDiv("tn-task-modal__edit-main"); + const sidebarColumn = panes.createDiv("tn-task-modal__edit-sidebar-wrap"); + + if (this.shouldShowField("title", this.plugin.settings.modalFieldsConfig)) { + const titleContainer = mainColumn.createDiv("title-input-container"); + this.titleInput = this.createTitleTextarea( + titleContainer, + "title-input", + this.t("modals.task.titlePlaceholder") + ); + } + + this.splitContentWrapper = mainColumn.createDiv("modal-split-content"); + this.splitLeftColumn = this.splitContentWrapper.createDiv("modal-split-left"); + this.splitRightColumn = this.splitLeftColumn.createDiv("modal-split-right"); - // Hook for subclasses to add additional sections to left column + this.createDetailsSection(container, { mainColumn, sidebarColumn }); this.createAdditionalSections(this.splitLeftColumn); + } - // Create save/cancel buttons - outside the split, at bottom - this.createActionButtons(container); + protected createEditSidebar( + sidebarColumn: HTMLElement, + config: ModalFieldsConfigLike | undefined + ): void { + const sidebar = sidebarColumn.createDiv("tn-task-modal__sidebar"); + this.sidebarEl = sidebar; + + const addOrgSection = ( + fieldId: string, + row: TaskModalSidebarRowSpec, + assignList: (listEl: HTMLElement) => void + ): void => { + if (!this.shouldShowField(fieldId, config)) { + return; + } + + const { listElement } = createTaskModalSidebarOrgSection(sidebar, row); + assignList(listElement); + }; + + addOrgSection( + "projects", + { + id: "projects", + iconName: "folder", + label: this.t("modals.task.organization.projects"), + value: this.getSidebarProjectsValue(), + hasValue: this.selectedProjectItems.length > 0, + onClick: () => { + const modal = new ProjectSelectModal(this.app, this.plugin, (file) => { + this.addProject(file); + }); + modal.open(); + }, + }, + (listEl) => { + this.projectsList = listEl; + this.renderProjectsList(); + } + ); + addOrgSection( + "subtasks", + { + id: "subtasks", + iconName: "list-tree", + label: this.t("modals.task.organization.subtasks"), + value: this.getSidebarSubtasksValue(), + hasValue: this.selectedSubtaskFiles.length > 0, + onClick: () => { + void this.openSubtaskSelector(); + }, + }, + (listEl) => { + this.subtasksList = listEl; + void this.renderSubtasksList(); + } + ); + + for (const row of this.buildPropertyRows(PRIMARY_PROPERTY_FIELD_IDS, config)) { + createTaskModalSidebarRow(sidebar, row); + } + + addOrgSection( + "blocked-by", + { + id: "blocked-by", + iconName: "git-pull-request", + label: this.t("modals.task.dependencies.blockedBy"), + value: this.getSidebarDependencyValue(this.blockedByItems), + hasValue: this.blockedByItems.length > 0, + onClick: () => { + void this.openBlockedBySelector(); + }, + }, + (listEl) => { + this.blockedByList = listEl; + this.renderDependencyLists(); + } + ); + addOrgSection( + "blocking", + { + id: "blocking", + iconName: "git-branch", + label: this.t("modals.task.dependencies.blocking"), + value: this.getSidebarDependencyValue(this.blockingItems), + hasValue: this.blockingItems.length > 0, + onClick: () => { + void this.openBlockingSelector(); + }, + }, + (listEl) => { + this.blockingList = listEl; + this.renderDependencyLists(); + } + ); + + this.createSidebarUserFields(sidebarColumn, config); + } + + protected createSidebarUserFields( + sidebarColumn: HTMLElement, + config: ModalFieldsConfigLike | undefined + ): void { + const userFields = (config?.fields || []).filter( + (field) => + field.fieldType === "user" && + field.enabled && + (this.isCreationMode() ? field.visibleInCreation : field.visibleInEdit) + ); + + if (!userFields.length) return; + + const container = sidebarColumn.createDiv("tn-task-modal__sidebar-user-fields"); + for (const fieldConfig of userFields) { + this.createUserFieldByConfig(container, fieldConfig); + } + } + + /** + * Renders the mobile bottom sheet sidebar rows in a single grouped card, + * matching the desktop edit sidebar order. + */ + protected createMobileSheetSections(container: HTMLElement): void { + const config = this.plugin.settings.modalFieldsConfig; + const wrapper = container.createDiv("tn-task-modal__mobile-sections"); + const card = wrapper.createDiv("tn-task-modal__mobile-group"); + + const createOrgFieldById: Record void> = { + projects: (target) => this.createProjectsField(target), + subtasks: (target) => this.createSubtasksField(target), + "blocked-by": (target) => this.createBlockedByField(target), + blocking: (target) => this.createBlockingField(target), + }; + + let hasRows = false; + const appendDivider = (): void => { + if (hasRows) { + card.createDiv("tn-task-modal__sidebar-row-divider"); + } + }; + + for (const fieldId of MOBILE_SHEET_FIELD_ORDER) { + if (!this.shouldShowField(fieldId, config)) { + continue; + } + + if (MOBILE_ORG_FIELD_IDS.has(fieldId)) { + appendDivider(); + createOrgFieldById[fieldId]?.(card); + hasRows = true; + continue; + } + + const row = this.buildPropertyRowSpec(fieldId); + if (!row) { + continue; + } + + appendDivider(); + createTaskModalSidebarRow(card, row); + hasRows = true; + } + + if (!hasRows) { + wrapper.remove(); + return; + } + + this.sidebarEl = wrapper; + } + + protected buildPropertyRows( + fieldIds: readonly string[], + config: ModalFieldsConfigLike | undefined + ): TaskModalSidebarRowSpec[] { + const rows: TaskModalSidebarRowSpec[] = []; + for (const fieldId of fieldIds) { + if (!this.shouldShowField(fieldId, config)) continue; + const row = this.buildPropertyRowSpec(fieldId); + if (row) { + rows.push(row); + } + } + return rows; + } + + protected buildPropertyRowSpec(fieldId: string): TaskModalSidebarRowSpec | null { + switch (fieldId) { + case "status": + return { + id: "status", + iconName: "dot-square", + label: this.t("modals.task.actions.status"), + value: this.getSidebarStatusValue(), + hasValue: true, + onClick: (event) => this.showStatusContextMenu(event), + }; + case "priority": + return { + id: "priority", + iconName: "star", + label: this.t("modals.task.actions.priority"), + value: this.getSidebarPriorityValue(), + hasValue: true, + onClick: (event) => this.showPriorityContextMenu(event), + }; + case "due-date": + return { + id: "due-date", + iconName: "target", + label: this.t("modals.task.actions.due"), + value: this.getSidebarDateValue(this.dueDate), + hasValue: Boolean(this.dueDate), + onClick: () => this.showDateContextMenu({} as UIEvent, "due"), + }; + case "scheduled-date": + return { + id: "scheduled-date", + iconName: "calendar-clock", + label: this.t("modals.task.scheduledRow.label"), + value: this.getSidebarDateValue(this.scheduledDate), + hasValue: Boolean(this.scheduledDate), + onClick: () => this.showDateContextMenu({} as UIEvent, "scheduled"), + }; + case "recurrence": + return { + id: "recurrence", + iconName: "refresh-ccw", + label: this.t("modals.task.actions.recurrence"), + // No placeholder here: repeating the row's own label as its + // value (e.g. "Set recurrence" / "Set recurrence") was + // redundant, so leave the subtitle blank until a rule is set. + value: this.recurrenceRule + ? getTaskModalRecurrenceDisplayText(this.recurrenceRule) + : "", + hasValue: Boolean(this.recurrenceRule.trim()), + onClick: (event) => this.showRecurrenceContextMenu(event), + }; + case "reminders": + return { + id: "reminders", + iconName: "bell", + label: this.t("modals.task.actions.reminders"), + value: this.reminders.length > 0 ? String(this.reminders.length) : "", + hasValue: this.reminders.length > 0, + onClick: (event) => this.showReminderContextMenu(event), + }; + case "contexts": + return { + id: "contexts", + iconName: "at-sign", + label: this.t("modals.task.contextsLabel"), + value: this.contexts, + hasValue: Boolean(this.contexts.trim()), + onClick: () => { + void this.showContextsInput(); + }, + }; + case "tags": + return { + id: "tags", + iconName: "tags", + label: this.t("modals.task.tagsLabel"), + value: this.tags, + hasValue: Boolean(this.tags.trim()), + onClick: () => { + void this.showTagsInput(); + }, + }; + case "time-estimate": + return { + id: "time-estimate", + iconName: "clock", + label: this.t("modals.task.chips.timeEstimate"), + value: + this.timeEstimate > 0 + ? this.t("modals.task.chips.timeEstimateValue", { + minutes: this.timeEstimate, + }) + : "", + hasValue: this.timeEstimate > 0, + onClick: () => { + void this.showTimeEstimateInput(); + }, + }; + default: + return null; + } + } + + private getSidebarProjectsValue(): string { + if (!this.selectedProjectItems.length) { + return ""; + } + return this.selectedProjectItems.map((item) => item.name).join(", "); + } + + private getSidebarSubtasksValue(): string { + if (!this.selectedSubtaskFiles.length) { + return ""; + } + return this.selectedSubtaskFiles + .map((file) => this.getSidebarSubtaskDisplayName(file)) + .join(", "); + } + + private getSidebarSubtaskDisplayName(file: TAbstractFile): string { + const taskInfo = this.plugin.cacheManager.getCachedTaskInfoSync(file.path); + if (taskInfo?.title) { + return taskInfo.title; + } + if (file instanceof TFile) { + return file.basename; + } + return file.name; + } + + private getSidebarStatusValue(): string { + const config = this.plugin.settings.customStatuses?.find((s) => s.value === this.status); + return config?.label || this.status; + } + + private getSidebarPriorityValue(): string { + const config = this.plugin.settings.customPriorities?.find((p) => p.value === this.priority); + return config?.label || this.priority; + } + + private getSidebarDateValue(value: string): string { + if (!value) return ""; + return formatDateTimeForDisplay(value, { + showTime: value.includes("T") || value.includes(":"), + }); + } + + private getSidebarDependencyValue(items: DependencyItem[]): string { + if (!items.length) return ""; + const count = countTaskModalCompletion( + this.plugin, + items.map((item) => item.path) + ); + return `${count.completed}/${count.total}`; + } + + protected updateSidebarStates(): void { + if (!this.sidebarEl) return; + + updateTaskModalSidebarRow( + this.sidebarEl, + "projects", + this.getSidebarProjectsValue(), + this.selectedProjectItems.length > 0 + ); + updateTaskModalSidebarRow( + this.sidebarEl, + "subtasks", + this.getSidebarSubtasksValue(), + this.selectedSubtaskFiles.length > 0 + ); + updateTaskModalSidebarRow( + this.sidebarEl, + "status", + this.getSidebarStatusValue(), + true + ); + updateTaskModalSidebarRow( + this.sidebarEl, + "priority", + this.getSidebarPriorityValue(), + true + ); + updateTaskModalSidebarRow( + this.sidebarEl, + "due-date", + this.getSidebarDateValue(this.dueDate), + Boolean(this.dueDate) + ); + updateTaskModalSidebarRow( + this.sidebarEl, + "scheduled-date", + this.getSidebarDateValue(this.scheduledDate), + Boolean(this.scheduledDate) + ); + updateTaskModalSidebarRow(this.sidebarEl, "contexts", this.contexts, Boolean(this.contexts.trim())); + updateTaskModalSidebarRow(this.sidebarEl, "tags", this.tags, Boolean(this.tags.trim())); + updateTaskModalSidebarRow( + this.sidebarEl, + "time-estimate", + this.timeEstimate > 0 + ? this.t("modals.task.chips.timeEstimateValue", { minutes: this.timeEstimate }) + : "", + this.timeEstimate > 0 + ); + updateTaskModalSidebarRow( + this.sidebarEl, + "recurrence", + this.recurrenceRule ? getTaskModalRecurrenceDisplayText(this.recurrenceRule) : "", + Boolean(this.recurrenceRule.trim()) + ); + updateTaskModalSidebarRow( + this.sidebarEl, + "reminders", + this.reminders.length > 0 ? String(this.reminders.length) : "", + this.reminders.length > 0 + ); + updateTaskModalSidebarRow( + this.sidebarEl, + "blocked-by", + this.getSidebarDependencyValue(this.blockedByItems), + this.blockedByItems.length > 0 + ); + updateTaskModalSidebarRow( + this.sidebarEl, + "blocking", + this.getSidebarDependencyValue(this.blockingItems), + this.blockingItems.length > 0 + ); } /** @@ -548,92 +1126,145 @@ export abstract class TaskModal extends Modal { } protected createActionBar(container: HTMLElement): void { - this.actionBar = container.createDiv("tn-task-modal__action-bar"); - this.createCoreActionIcons(this.actionBar); + this.actionBar = createTaskModalChipRow(container); + this.createCoreChips(this.actionBar); this.updateIconStates(); } - protected createCoreActionIcons(container: HTMLElement): HTMLElement[] { - return createTaskModalActionIcons(container, this.getCoreActionIconSpecs()); - } - - protected getCoreActionIconSpecs(): TaskModalActionIconSpec[] { - return [ - { - iconName: "dot-square", - tooltip: this.t("modals.task.actions.status"), - onClick: (_, event) => { - this.showStatusContextMenu(event); - }, - dataType: "status", - }, - { - iconName: "star", - tooltip: this.t("modals.task.actions.priority"), - onClick: (_, event) => { - this.showPriorityContextMenu(event); - }, - dataType: "priority", - }, - { - iconName: "calendar", - tooltip: this.t("modals.task.actions.due"), - onClick: (_, event) => { - this.showDateContextMenu(event, "due"); - }, - dataType: "due-date", - }, - { - iconName: "calendar-clock", - tooltip: this.t("modals.task.actions.scheduled"), - onClick: (_, event) => { - this.showDateContextMenu(event, "scheduled"); - }, - dataType: "scheduled-date", - }, - { - iconName: "refresh-ccw", - tooltip: this.t("modals.task.actions.recurrence"), - onClick: (_, event) => { - this.showRecurrenceContextMenu(event); - }, - dataType: "recurrence", - }, - { - iconName: "bell", - tooltip: this.t("modals.task.actions.reminders"), - onClick: (_, event) => { - this.showReminderContextMenu(event); - }, - dataType: "reminders", - }, - ]; + protected createCoreChips(container: HTMLElement): HTMLElement[] { + return createTaskModalChips(container, this.getCoreChipSpecs()); } - protected createActionIcon( + protected createActionChip( container: HTMLElement, iconName: string, - tooltip: string, + label: string, onClick: (icon: HTMLElement, event: UIEvent) => void, dataType?: string ): HTMLElement { - return createTaskModalActionIcon(container, { + return createTaskModalChip(container, { iconName, - tooltip, + label, onClick, - dataType, + dataType: dataType || iconName, }); } - protected createDetailsSection(container: HTMLElement): void { + protected getCoreChipSpecs(): TaskModalChipSpec[] { + const config = this.plugin.settings.modalFieldsConfig; + const specs: TaskModalChipSpec[] = []; + + const maybeAdd = (fieldId: string, spec: TaskModalChipSpec): void => { + if (this.shouldShowField(fieldId, config)) { + specs.push(spec); + } + }; + + maybeAdd("status", { + iconName: "dot-square", + label: this.t("modals.task.actions.status"), + onClick: (_, event) => this.showStatusContextMenu(event), + dataType: "status", + }); + maybeAdd("priority", { + iconName: "star", + label: this.t("modals.task.actions.priority"), + onClick: (_, event) => this.showPriorityContextMenu(event), + dataType: "priority", + }); + maybeAdd("due-date", { + iconName: "target", + label: this.t("modals.task.actions.due"), + onClick: (_, event) => this.showDateContextMenu(event, "due"), + dataType: "due-date", + }); + maybeAdd("scheduled-date", { + iconName: "calendar-clock", + label: this.t("modals.task.actions.scheduled"), + onClick: (_, event) => this.showDateContextMenu(event, "scheduled"), + dataType: "scheduled-date", + }); + maybeAdd("recurrence", { + iconName: "refresh-ccw", + label: this.t("modals.task.actions.recurrence"), + onClick: (_, event) => this.showRecurrenceContextMenu(event), + dataType: "recurrence", + }); + maybeAdd("reminders", { + iconName: "bell", + label: this.t("modals.task.actions.reminders"), + onClick: (_, event) => this.showReminderContextMenu(event), + dataType: "reminders", + }); + maybeAdd("contexts", { + iconName: "at-sign", + label: this.t("modals.task.contextsLabel"), + onClick: () => { + void this.showContextsInput(); + }, + dataType: "contexts", + }); + maybeAdd("tags", { + iconName: "tags", + label: this.t("modals.task.tagsLabel"), + onClick: () => { + void this.showTagsInput(); + }, + dataType: "tags", + }); + maybeAdd("time-estimate", { + iconName: "clock", + label: this.t("modals.task.chips.timeEstimate"), + onClick: () => { + void this.showTimeEstimateInput(); + }, + dataType: "time-estimate", + }); + + return specs; + } + + protected async showContextsInput(): Promise { + await showTaskModalContextsInput(this.getPropertyMenuContext()); + } + + protected async showTagsInput(): Promise { + await showTaskModalTagsInput(this.getPropertyMenuContext()); + } + + protected async showTimeEstimateInput(): Promise { + await showTaskModalTimeEstimateInput(this.getPropertyMenuContext()); + } + + protected getPropertyMenuContext(): TaskModalPropertyMenuContext { + return { + plugin: this.plugin, + translate: (key, params) => this.t(key, params), + getContexts: () => this.contexts, + setContexts: (value) => { + this.contexts = value; + }, + getTags: () => this.tags, + setTags: (value) => { + this.tags = value; + }, + getTimeEstimate: () => this.timeEstimate, + setTimeEstimate: (value) => { + this.timeEstimate = value; + }, + onChange: () => this.updateIconStates(), + }; + } + + protected createDetailsSection( + container: HTMLElement, + layout?: { mainColumn: HTMLElement; sidebarColumn: HTMLElement } + ): void { this.userFieldInputs.clear(); this.userFieldToggles.clear(); - // The details container wraps the expandable fields (for hide/show animation) - // It goes inside the left column for proper expand/collapse - this.detailsContainer = this.splitLeftColumn - ? this.splitLeftColumn.createDiv("details-container") - : container.createDiv("details-container"); + const parentForDetails = layout?.mainColumn ?? this.splitLeftColumn ?? container; + this.detailsContainer = parentForDetails.createDiv("details-container"); if (!this.isExpanded) { collapseTaskModalDetailsLayout({ @@ -642,46 +1273,36 @@ export abstract class TaskModal extends Modal { }); } - // Check field configuration to determine which fields to show const config = this.plugin.settings.modalFieldsConfig; const shouldShowTitle = this.shouldShowField("title", config); const shouldShowDetails = this.shouldShowField("details", config); - this.splitContentWrapper.classList.toggle( + this.splitContentWrapper?.classList.toggle( "modal-split-content--right-empty", !shouldShowDetails ); - // Title field appears in details section for: - // 1. Edit modals (always, if enabled in config) - // 2. Creation modals when NLP is enabled (since the main title input is replaced by NLP textarea) const isEditModal = this.isEditMode(); - const isCreationWithNLP = - this.isCreationMode() && this.plugin.settings.enableNaturalLanguageInput; - if (shouldShowTitle && (isEditModal || isCreationWithNLP)) { - const titleLabel = this.detailsContainer.createDiv("detail-label"); - titleLabel.textContent = this.t("modals.task.titleLabel"); - - const titleInputDetailed = this.createTitleTextarea( - this.detailsContainer, + const rightColumn = this.splitRightColumn || this.detailsContainer; + + // Only render the multi-line title field when no title input has been + // created yet (e.g. the desktop edit sidebar already renders the + // primary title at the top of the main column). Placing it directly + // in the same column as the description keeps title and description + // adjacent regardless of layout. Creation modals with natural-language + // input already capture the title on the left, so skip the duplicate. + if (shouldShowTitle && isEditModal && !this.titleInput) { + this.titleInput = this.createTitleTextarea( + rightColumn, "title-input-detailed", this.t("modals.task.titleDetailedPlaceholder") ); - - // Store reference for modals that use this as their title input - if ((isEditModal || isCreationWithNLP) && !this.titleInput) { - this.titleInput = titleInputDetailed; - } } - // Details editor goes in the right column if (shouldShowDetails) { - const rightColumn = this.splitRightColumn || this.detailsContainer; - this.detailsMarkdownEditor = createTaskModalDetailsEditor({ app: this.app, parent: rightColumn, - label: this.t("modals.task.detailsLabel"), value: this.details, placeholder: this.t("modals.task.detailsPlaceholder"), file: this.getModalEditorFile(), @@ -698,9 +1319,20 @@ export abstract class TaskModal extends Modal { focusNextField: () => this.focusNextField(), focusPreviousField: () => this.focusPreviousField(), }); + + if (this.usesSheetLayout()) { + this.descriptionClampController?.destroy(); + this.descriptionClampController = attachTaskModalDescriptionClamp({ + editorContainer: rightColumn, + translate: (key) => this.t(key), + }); + } + } + + if (this.usesEditSidebarLayout() && layout) { + this.createEditSidebar(layout.sidebarColumn, config); } - // Additional form fields (contexts, tags, etc.) go in the details container (left side) this.createAdditionalFields(this.detailsContainer); } @@ -752,17 +1384,56 @@ export abstract class TaskModal extends Modal { } private getFieldRenderers(): TaskModalFieldRendererMap { + const wrapRenderer = ( + fieldId: string, + render: (container: HTMLElement) => void + ): ((container: HTMLElement) => void) => { + return (container: HTMLElement) => { + if (!this.shouldRenderFieldInDetails(fieldId)) return; + render(container); + }; + }; + return { - contexts: (container) => this.createContextsField(container), - tags: (container) => this.createTagsField(container), - "time-estimate": (container) => this.createTimeEstimateField(container), - projects: (container) => this.createProjectsField(container), - subtasks: (container) => this.createSubtasksField(container), - "blocked-by": (container) => this.createBlockedByField(container), - blocking: (container) => this.createBlockingField(container), + contexts: wrapRenderer("contexts", (container) => this.createContextsField(container)), + tags: wrapRenderer("tags", (container) => this.createTagsField(container)), + "time-estimate": wrapRenderer("time-estimate", (container) => + this.createTimeEstimateField(container) + ), + projects: wrapRenderer("projects", (container) => this.createProjectsField(container)), + subtasks: wrapRenderer("subtasks", (container) => this.createSubtasksField(container)), + "blocked-by": wrapRenderer("blocked-by", (container) => + this.createBlockedByField(container) + ), + blocking: wrapRenderer("blocking", (container) => this.createBlockingField(container)), }; } + protected shouldRenderFieldInDetails(fieldId: string): boolean { + if ( + (this.usesChipRow() || this.usesGroupedPropertyList() || this.usesEditSidebarLayout()) && + ["contexts", "tags", "time-estimate"].includes(fieldId) + ) { + return false; + } + + if (this.usesEditSidebarLayout()) { + return !["projects", "subtasks", "blocked-by", "blocking"].includes(fieldId); + } + + if (this.usesGroupedPropertyList()) { + return !["projects", "subtasks", "blocked-by", "blocking"].includes(fieldId); + } + + return true; + } + + protected refreshOrganizationHeaders(): void { + for (const update of this.organizationHeaderUpdaters) { + update(); + } + } + protected createContextsField(container: HTMLElement): void { this.contextsInput = createTaskModalContextsField(this.getMetadataFieldContext(), { container, @@ -805,7 +1476,7 @@ export abstract class TaskModal extends Modal { } protected createProjectsField(container: HTMLElement): void { - this.projectsList = createTaskModalProjectsField(this.getOrganizationFieldContext(), { + const row = createTaskModalProjectsField(this.getOrganizationFieldContext(), { container, onButtonClick: () => { const modal = new ProjectSelectModal(this.app, this.plugin, (file) => { @@ -814,19 +1485,29 @@ export abstract class TaskModal extends Modal { modal.open(); }, listElement: this.projectsList, + getItemCount: () => this.selectedProjectItems.length, + onHeaderUpdate: (update) => { + this.organizationHeaderUpdaters.push(update); + }, }); + this.projectsList = row.listElement; this.renderOrganizationLists(); } protected createSubtasksField(container: HTMLElement): void { - this.subtasksList = createTaskModalSubtasksField(this.getOrganizationFieldContext(), { + const row = createTaskModalSubtasksField(this.getOrganizationFieldContext(), { container, onButtonClick: () => { void this.openSubtaskSelector(); }, listElement: this.subtasksList, + getItemCount: () => this.selectedSubtaskFiles.length, + onHeaderUpdate: (update) => { + this.organizationHeaderUpdaters.push(update); + }, }); + this.subtasksList = row.listElement; this.renderOrganizationLists(); } @@ -849,25 +1530,35 @@ export abstract class TaskModal extends Modal { } protected createBlockedByField(container: HTMLElement): void { - this.blockedByList = createTaskModalBlockedByField(this.getOrganizationFieldContext(), { + const row = createTaskModalBlockedByField(this.getOrganizationFieldContext(), { container, onButtonClick: () => { void this.openBlockedBySelector(); }, listElement: this.blockedByList, + getItemCount: () => this.blockedByItems.length, + onHeaderUpdate: (update) => { + this.organizationHeaderUpdaters.push(update); + }, }); + this.blockedByList = row.listElement; this.renderDependencyLists(); } protected createBlockingField(container: HTMLElement): void { - this.blockingList = createTaskModalBlockingField(this.getOrganizationFieldContext(), { + const row = createTaskModalBlockingField(this.getOrganizationFieldContext(), { container, onButtonClick: () => { void this.openBlockingSelector(); }, listElement: this.blockingList, + getItemCount: () => this.blockingItems.length, + onHeaderUpdate: (update) => { + this.organizationHeaderUpdaters.push(update); + }, }); + this.blockingList = row.listElement; this.renderDependencyLists(); } @@ -913,21 +1604,27 @@ export abstract class TaskModal extends Modal { }); } - protected createActionButtons(container: HTMLElement): void { - const leadingButtons: TaskModalLeadingButton[] = []; - if (this.isEditMode()) { - leadingButtons.push({ + protected getLeadingActionButtons(): TaskModalLeadingIconButton[] { + if (!this.isEditMode()) { + return []; + } + + return [ + { className: "tn-task-modal__open-note-button", - text: this.t("modals.task.buttons.openNote"), + iconName: "external-link", + label: this.t("modals.task.buttons.openNote"), onClick: () => { void this.openTaskNote(); }, - }); - } + }, + ]; + } + protected createActionButtons(container: HTMLElement): void { createTaskModalActionButtons(this.getActionButtonContext(), { container, - leadingButtons, + leadingButtons: this.getLeadingActionButtons(), onSave: () => this.handleSave(), onSaved: () => { this.close(); @@ -955,6 +1652,26 @@ export abstract class TaskModal extends Modal { }); } + protected teardownModalContent(): void { + destroyTaskModalDetailsEditor(this.detailsMarkdownEditor); + this.detailsMarkdownEditor = null; + this.descriptionClampController?.destroy(); + this.descriptionClampController = undefined; + this.sidebarEl = undefined; + this.actionBar = undefined as unknown as HTMLElement; + this.detailsContainer = undefined as unknown as HTMLElement; + this.titleInput = undefined as unknown as TaskModalTitleInputElement; + this.contextsInput = undefined as unknown as HTMLInputElement; + this.tagsInput = undefined as unknown as HTMLInputElement; + this.timeEstimateInput = undefined as unknown as HTMLInputElement; + this.projectsList = undefined as unknown as HTMLElement; + this.subtasksList = undefined as unknown as HTMLElement; + this.blockedByList = undefined; + this.blockingList = undefined; + this.userFieldInputs.clear(); + this.userFieldToggles.clear(); + } + protected showDateContextMenu(_event: UIEvent, type: "due" | "scheduled"): void { showTaskModalDateContextMenu(this.getActionMenuContext(), type); } @@ -1045,11 +1762,21 @@ export abstract class TaskModal extends Modal { updateTaskModalActionIconStates( this.actionBar, { translate: (key, params) => this.t(key, params) }, - buildTaskModalActionIconState(actionMenuState, { - statusConfigs: this.plugin.settings.customStatuses || [], - priorityConfigs: this.plugin.settings.customPriorities || [], - }) + buildTaskModalChipState( + { + ...actionMenuState, + contexts: this.contexts, + tags: this.tags, + timeEstimate: this.timeEstimate, + }, + { + statusConfigs: this.plugin.settings.customStatuses || [], + priorityConfigs: this.plugin.settings.customPriorities || [], + } + ) ); + + this.updateSidebarStates(); } protected focusTitleInput(): void { @@ -1146,6 +1873,8 @@ export abstract class TaskModal extends Modal { translate: (key, params) => this.t(key, params), onRemove: (item) => this.removeProject(item), }); + this.refreshOrganizationHeaders(); + this.updateSidebarStates(); } // Subtask management methods @@ -1202,6 +1931,8 @@ export abstract class TaskModal extends Modal { translate: (key, params) => this.t(key, params), onRemove: (file) => this.removeSubtask(file), }); + this.refreshOrganizationHeaders(); + this.updateSidebarStates(); } protected renderOrganizationLists(): void { @@ -1248,11 +1979,14 @@ export abstract class TaskModal extends Modal { } onClose(): void { - // Clean up keyboard handler if (this.keyboardHandler) { this.containerEl.removeEventListener("keydown", this.keyboardHandler); this.keyboardHandler = null; } + this.sheetGestureController?.destroy(); + this.sheetGestureController = undefined; + this.descriptionClampController?.destroy(); + this.descriptionClampController = undefined; this.focusGuards.destroy(); destroyTaskModalDetailsEditor(this.detailsMarkdownEditor); diff --git a/src/modals/TaskSelectorWithCreateModal.ts b/src/modals/TaskSelectorWithCreateModal.ts index cee5417e8..2c36e1f3e 100644 --- a/src/modals/TaskSelectorWithCreateModal.ts +++ b/src/modals/TaskSelectorWithCreateModal.ts @@ -398,7 +398,7 @@ export class TaskSelectorWithCreateModal extends SuggestModal { // Due date if (parsed.dueDate) { const dateStr = parsed.dueTime ? `${parsed.dueDate} ${parsed.dueTime}` : parsed.dueDate; - parts.push({ icon: "calendar", text: dateStr, type: "due" }); + parts.push({ icon: "target", text: dateStr, type: "due" }); } // Scheduled date diff --git a/src/modals/TextInputModal.ts b/src/modals/TextInputModal.ts index 7c11b4dfa..ffb396caa 100644 --- a/src/modals/TextInputModal.ts +++ b/src/modals/TextInputModal.ts @@ -1,14 +1,44 @@ import { App, Modal, Setting } from "obsidian"; +export interface TextInputModalFitContentOptions { + minCh?: number; + maxCh?: number; +} + export interface TextInputModalOptions { title: string; placeholder?: string; initialValue?: string; confirmText?: string; cancelText?: string; + /** When true, confirming with an empty value resolves to "" instead of null. */ + allowEmpty?: boolean; + /** When false, places the cursor at the end instead of selecting all text on open. */ + selectOnFocus?: boolean; + /** Shrink the modal and input width to match the current value. */ + fitContent?: boolean | TextInputModalFitContentOptions; onInputReady?: (inputEl: HTMLInputElement) => void; } +export function bindTextInputModalWidthToContent( + input: HTMLInputElement, + options: TextInputModalFitContentOptions = {} +): void { + const minCh = options.minCh ?? 14; + const maxCh = options.maxCh ?? 48; + const update = (): void => { + const contentLength = Math.max( + input.value.length, + input.placeholder.length, + minCh + ); + input.style.width = `${Math.min(contentLength + 2, maxCh)}ch`; + }; + + input.addEventListener("input", update); + update(); +} + /** * Generic text input modal */ @@ -33,13 +63,31 @@ export class TextInputModal extends Modal { }); } + private getFitContentOptions(): TextInputModalFitContentOptions | null { + const { fitContent } = this.options; + if (!fitContent) return null; + return fitContent === true ? {} : fitContent; + } + onOpen() { const { contentEl } = this; contentEl.empty(); + contentEl.addClass("tn-text-input-modal__content"); + + const fitContentOptions = this.getFitContentOptions(); + this.modalEl.addClass("tn-text-input-modal"); + if (fitContentOptions) { + this.modalEl.addClass("tn-text-input-modal--fit-content"); + } new Setting(contentEl).setName(this.options.title).setHeading(); - new Setting(contentEl).addText((text) => { + const inputSetting = new Setting(contentEl); + if (fitContentOptions) { + inputSetting.settingEl.addClass("tn-text-input-modal__input-setting"); + } + + inputSetting.addText((text) => { this.inputEl = text.inputEl; text.setPlaceholder(this.options.placeholder || "") .setValue(this.options.initialValue || "") @@ -47,16 +95,26 @@ export class TextInputModal extends Modal { // Optional: real-time validation could go here }); - // Focus the input + if (fitContentOptions) { + bindTextInputModalWidthToContent(this.inputEl, fitContentOptions); + } + + // Focus the input after the modal is visible so suggest popovers can attach. window.setTimeout(() => { this.inputEl.focus(); - this.inputEl.select(); + if (this.options.selectOnFocus !== false) { + this.inputEl.select(); + } else { + const length = this.inputEl.value.length; + this.inputEl.setSelectionRange(length, length); + } + this.options.onInputReady?.(this.inputEl); }, 100); - - this.options.onInputReady?.(this.inputEl); }); - const buttonContainer = contentEl.createEl("div", { cls: "modal-button-container" }); + const buttonContainer = contentEl.createEl("div", { + cls: "modal-button-container tn-text-input-modal__buttons", + }); buttonContainer.classList.remove( "tn-static-display-block-2a1b75c9", "tn-static-display-flex-4d51fc62", @@ -108,7 +166,7 @@ export class TextInputModal extends Modal { confirmButton.addEventListener("click", () => { const value = this.inputEl.value.trim(); - this.resolve(value || null); + this.resolve(value || (this.options.allowEmpty ? value : null)); this.close(); }); diff --git a/src/modals/taskEditCompletions.ts b/src/modals/taskEditCompletions.ts index 9afb0a21a..c8ba9c51d 100644 --- a/src/modals/taskEditCompletions.ts +++ b/src/modals/taskEditCompletions.ts @@ -75,7 +75,7 @@ function renderCalendarMonth( "aria-label": "Previous month", }, }); - setIcon(prevButton, "chevron-left"); + setIcon(prevButton.createSpan("recurring-calendar__nav-icon"), "chevron-left"); const monthLabel = header.createSpan("recurring-calendar__month"); const locale = options.plugin.i18n.getCurrentLocale() || "en"; const monthFormatter = new Intl.DateTimeFormat(locale, { month: "short", year: "numeric" }); @@ -87,7 +87,7 @@ function renderCalendarMonth( "aria-label": "Next month", }, }); - setIcon(nextButton, "chevron-right"); + setIcon(nextButton.createSpan("recurring-calendar__nav-icon"), "chevron-right"); const grid = container.createDiv("recurring-calendar__grid"); const monthStart = getUTCStartOfMonth(displayDate); diff --git a/src/modals/taskModalActionButtons.ts b/src/modals/taskModalActionButtons.ts index bfbb99b7f..b9acb99bf 100644 --- a/src/modals/taskModalActionButtons.ts +++ b/src/modals/taskModalActionButtons.ts @@ -1,51 +1,92 @@ +import { setIcon, setTooltip } from "obsidian"; + export interface TaskModalActionButtonContext { translate: (key: string) => string; } -export interface TaskModalLeadingButton { +export interface TaskModalLeadingIconButton { className?: string; - text: string; + iconName: string; + label: string; onClick: () => void; + isWarning?: boolean; } export interface CreateTaskModalActionButtonsOptions { container: HTMLElement; - leadingButtons?: readonly TaskModalLeadingButton[]; + leadingButtons?: readonly TaskModalLeadingIconButton[]; onSave: () => Promise; onSaved: () => void; onCancel: () => void; } +export function createTaskModalIconButton( + parent: HTMLElement, + options: { + className?: string; + iconName: string; + label: string; + isWarning?: boolean; + isPrimary?: boolean; + } +): HTMLButtonElement { + const button = parent.createEl("button", { + cls: [ + "tn-task-modal__icon-button", + options.className, + options.isWarning ? "mod-warning" : "", + options.isPrimary ? "mod-cta" : "", + ] + .filter(Boolean) + .join(" "), + attr: { + "aria-label": options.label, + type: "button", + }, + }); + setIcon(button.createSpan("tn-task-modal__icon-button-icon"), options.iconName); + button.createSpan({ cls: "tn-task-modal__icon-button-label", text: options.label }); + setTooltip(button, options.label, { placement: "top" }); + return button; +} + export function createTaskModalActionButtons( context: TaskModalActionButtonContext, options: CreateTaskModalActionButtonsOptions ): HTMLElement { const buttonContainer = options.container.createDiv( - "modal-button-container tn-task-modal__button-bar" + "modal-button-container tn-task-modal__button-bar tn-task-modal__icon-button-bar" ); + const leadingGroup = buttonContainer.createDiv("tn-task-modal__icon-button-leading"); + for (const buttonConfig of options.leadingButtons ?? []) { - const button = buttonContainer.createEl("button", { - cls: buttonConfig.className, - text: buttonConfig.text, + const button = createTaskModalIconButton(leadingGroup, { + className: buttonConfig.className, + iconName: buttonConfig.iconName, + label: buttonConfig.label, + isWarning: buttonConfig.isWarning, }); button.addEventListener("click", buttonConfig.onClick); } - const saveButton = buttonContainer.createEl("button", { - cls: "mod-cta", - text: context.translate("modals.task.buttons.save"), - }); + const trailingGroup = buttonContainer.createDiv("tn-task-modal__icon-button-trailing"); - saveButton.addEventListener("click", () => { - void runTaskModalSaveAction(saveButton, options.onSave, options.onSaved); + const cancelButton = createTaskModalIconButton(trailingGroup, { + iconName: "x", + label: context.translate("common.cancel"), }); + cancelButton.addEventListener("click", options.onCancel); - const cancelButton = buttonContainer.createEl("button", { - text: context.translate("common.cancel"), + const saveButton = createTaskModalIconButton(trailingGroup, { + iconName: "check", + label: context.translate("modals.task.buttons.save"), + isPrimary: true, }); - cancelButton.addEventListener("click", options.onCancel); + saveButton.addEventListener("click", () => { + void runTaskModalSaveAction(saveButton, options.onSave, options.onSaved); + }); return buttonContainer; } diff --git a/src/modals/taskModalActionIconStates.ts b/src/modals/taskModalActionIconStates.ts index 66e14b128..091f70d4a 100644 --- a/src/modals/taskModalActionIconStates.ts +++ b/src/modals/taskModalActionIconStates.ts @@ -1,5 +1,8 @@ import { setTooltip } from "obsidian"; import type { PriorityConfig, StatusConfig } from "../types"; +import { formatDateTimeForDisplay } from "../utils/dateUtils"; +import { getTaskModalChip, setTaskModalChipLabel } from "./taskModalChipRow"; +import { parseTaskModalCommaList } from "./taskModalOrgCounts"; const STATIC_COLOR_CLASSES = [ "tn-static-color-var-color-accent-d2cad743", @@ -30,6 +33,9 @@ export interface TaskModalActionIconState { recurrenceRule: string; recurrenceDisplayText: string; reminderCount: number; + contexts: string; + tags: string; + timeEstimate: number; defaultStatus: string; defaultPriority: string; statusConfigs: readonly StatusConfig[]; @@ -43,111 +49,123 @@ export function updateTaskModalActionIconStates( ): void { if (!actionBar) return; - updateDateIcon(actionBar, context, "due-date", state.dueDate, { + updateDateChip(actionBar, context, "due-date", state.dueDate, { activeTooltipKey: "modals.task.tooltips.dueValue", - defaultTooltipKey: "modals.task.actions.due", + defaultLabelKey: "modals.task.actions.due", }); - updateDateIcon(actionBar, context, "scheduled-date", state.scheduledDate, { + updateDateChip(actionBar, context, "scheduled-date", state.scheduledDate, { activeTooltipKey: "modals.task.tooltips.scheduledValue", - defaultTooltipKey: "modals.task.actions.scheduled", + defaultLabelKey: "modals.task.actions.scheduled", }); - updateStatusIcon(actionBar, context, state); - updatePriorityIcon(actionBar, context, state); - updateRecurrenceIcon(actionBar, context, state); - updateReminderIcon(actionBar, context, state.reminderCount); + updateStatusChip(actionBar, context, state); + updatePriorityChip(actionBar, context, state); + updateRecurrenceChip(actionBar, context, state); + updateReminderChip(actionBar, context, state.reminderCount); + updateContextsChip(actionBar, context, state.contexts); + updateTagsChip(actionBar, context, state.tags); + updateTimeEstimateChip(actionBar, context, state.timeEstimate); } -function updateDateIcon( +function updateDateChip( actionBar: HTMLElement, context: TaskModalActionIconStateContext, dataType: string, value: string, - tooltipKeys: { + options: { activeTooltipKey: string; - defaultTooltipKey: string; + defaultLabelKey: string; } ): void { - const icon = getActionIcon(actionBar, dataType); - if (!icon) return; + const chip = getActionChip(actionBar, dataType); + if (!chip) return; if (value) { - icon.classList.add("has-value"); - setTooltip(icon, context.translate(tooltipKeys.activeTooltipKey, { value }), { + const displayValue = formatDateTimeForDisplay(value, { + showTime: value.includes("T") || value.includes(":"), + }); + chip.classList.add("has-value"); + setTaskModalChipLabel(chip, displayValue); + setTooltip(chip, context.translate(options.activeTooltipKey, { value: displayValue }), { placement: "top", }); return; } - icon.classList.remove("has-value"); - setTooltip(icon, context.translate(tooltipKeys.defaultTooltipKey), { placement: "top" }); + chip.classList.remove("has-value"); + const defaultLabel = context.translate(options.defaultLabelKey); + setTaskModalChipLabel(chip, defaultLabel); + setTooltip(chip, defaultLabel, { placement: "top" }); } -function updateStatusIcon( +function updateStatusChip( actionBar: HTMLElement, context: TaskModalActionIconStateContext, state: TaskModalActionIconState ): void { - const icon = getActionIcon(actionBar, "status"); - if (!icon) return; + const chip = getActionChip(actionBar, "status"); + if (!chip) return; const statusConfig = state.statusConfigs.find((config) => config.value === state.status); const statusLabel = statusConfig ? statusConfig.label : state.status; - updateConfiguredValueIcon(icon, context, { + updateConfiguredValueChip(chip, context, { isActive: Boolean( state.status && statusConfig && statusConfig.value !== state.defaultStatus ), activeTooltipKey: "modals.task.tooltips.statusValue", - defaultTooltipKey: "modals.task.actions.status", + defaultLabelKey: "modals.task.actions.status", label: statusLabel, color: statusConfig?.color, }); } -function updatePriorityIcon( +function updatePriorityChip( actionBar: HTMLElement, context: TaskModalActionIconStateContext, state: TaskModalActionIconState ): void { - const icon = getActionIcon(actionBar, "priority"); - if (!icon) return; + const chip = getActionChip(actionBar, "priority"); + if (!chip) return; const priorityConfig = state.priorityConfigs.find( (config) => config.value === state.priority ); const priorityLabel = priorityConfig ? priorityConfig.label : state.priority; - updateConfiguredValueIcon(icon, context, { + updateConfiguredValueChip(chip, context, { isActive: Boolean( state.priority && priorityConfig && priorityConfig.value !== state.defaultPriority ), activeTooltipKey: "modals.task.tooltips.priorityValue", - defaultTooltipKey: "modals.task.actions.priority", + defaultLabelKey: "modals.task.actions.priority", label: priorityLabel, color: priorityConfig?.color, }); } -function updateConfiguredValueIcon( - icon: HTMLElement, +function updateConfiguredValueChip( + chip: HTMLElement, context: TaskModalActionIconStateContext, options: { isActive: boolean; activeTooltipKey: string; - defaultTooltipKey: string; + defaultLabelKey: string; label: string; color?: string; } ): void { if (options.isActive) { - icon.classList.add("has-value"); - setTooltip(icon, context.translate(options.activeTooltipKey, { value: options.label }), { + chip.classList.add("has-value"); + setTaskModalChipLabel(chip, options.label); + setTooltip(chip, context.translate(options.activeTooltipKey, { value: options.label }), { placement: "top", }); } else { - icon.classList.remove("has-value"); - setTooltip(icon, context.translate(options.defaultTooltipKey), { placement: "top" }); + chip.classList.remove("has-value"); + const defaultLabel = context.translate(options.defaultLabelKey); + setTaskModalChipLabel(chip, defaultLabel); + setTooltip(chip, defaultLabel, { placement: "top" }); } - const iconElement = icon.querySelector(".icon"); + const iconElement = chip.querySelector(".tn-task-modal__chip-icon .icon, .icon"); if (!iconElement) return; if (options.color) { @@ -159,18 +177,19 @@ function updateConfiguredValueIcon( iconElement.style.removeProperty("color"); } -function updateRecurrenceIcon( +function updateRecurrenceChip( actionBar: HTMLElement, context: TaskModalActionIconStateContext, state: TaskModalActionIconState ): void { - const icon = getActionIcon(actionBar, "recurrence"); - if (!icon) return; + const chip = getActionChip(actionBar, "recurrence"); + if (!chip) return; if (state.recurrenceRule.trim()) { - icon.classList.add("has-value"); + chip.classList.add("has-value"); + setTaskModalChipLabel(chip, state.recurrenceDisplayText); setTooltip( - icon, + chip, context.translate("modals.task.tooltips.recurrenceValue", { value: state.recurrenceDisplayText, }), @@ -179,38 +198,119 @@ function updateRecurrenceIcon( return; } - icon.classList.remove("has-value"); - setTooltip(icon, context.translate("modals.task.actions.recurrence"), { - placement: "top", - }); + chip.classList.remove("has-value"); + const defaultLabel = context.translate("modals.task.actions.recurrence"); + setTaskModalChipLabel(chip, defaultLabel); + setTooltip(chip, defaultLabel, { placement: "top" }); } -function updateReminderIcon( +function updateReminderChip( actionBar: HTMLElement, context: TaskModalActionIconStateContext, reminderCount: number ): void { - const icon = getActionIcon(actionBar, "reminders"); - if (!icon) return; + const chip = getActionChip(actionBar, "reminders"); + if (!chip) return; if (reminderCount > 0) { - icon.classList.add("has-value"); + chip.classList.add("has-value"); + const label = + reminderCount === 1 + ? context.translate("modals.task.chips.remindersSingle") + : context.translate("modals.task.chips.remindersPlural", { count: reminderCount }); + setTaskModalChipLabel(chip, label); const tooltip = reminderCount === 1 ? context.translate("modals.task.tooltips.remindersSingle") : context.translate("modals.task.tooltips.remindersPlural", { count: reminderCount, }); - setTooltip(icon, tooltip, { placement: "top" }); + setTooltip(chip, tooltip, { placement: "top" }); return; } - icon.classList.remove("has-value"); - setTooltip(icon, context.translate("modals.task.actions.reminders"), { - placement: "top", - }); + chip.classList.remove("has-value"); + const defaultLabel = context.translate("modals.task.actions.reminders"); + setTaskModalChipLabel(chip, defaultLabel); + setTooltip(chip, defaultLabel, { placement: "top" }); +} + +function updateContextsChip( + actionBar: HTMLElement, + context: TaskModalActionIconStateContext, + contexts: string +): void { + const chip = getActionChip(actionBar, "contexts"); + if (!chip) return; + + const values = parseTaskModalCommaList(contexts); + if (values.length > 0) { + chip.classList.add("has-value"); + const label = + values.length === 1 + ? values[0] + : context.translate("modals.task.chips.contextsPlural", { count: values.length }); + setTaskModalChipLabel(chip, label); + setTooltip(chip, contexts, { placement: "top" }); + return; + } + + chip.classList.remove("has-value"); + const defaultLabel = context.translate("modals.task.contextsLabel"); + setTaskModalChipLabel(chip, defaultLabel); + setTooltip(chip, defaultLabel, { placement: "top" }); +} + +function updateTagsChip( + actionBar: HTMLElement, + context: TaskModalActionIconStateContext, + tags: string +): void { + const chip = getActionChip(actionBar, "tags"); + if (!chip) return; + + const values = parseTaskModalCommaList(tags); + if (values.length > 0) { + chip.classList.add("has-value"); + const label = + values.length === 1 + ? `#${values[0]}` + : context.translate("modals.task.chips.tagsPlural", { count: values.length }); + setTaskModalChipLabel(chip, label); + setTooltip(chip, tags, { placement: "top" }); + return; + } + + chip.classList.remove("has-value"); + const defaultLabel = context.translate("modals.task.tagsLabel"); + setTaskModalChipLabel(chip, defaultLabel); + setTooltip(chip, defaultLabel, { placement: "top" }); +} + +function updateTimeEstimateChip( + actionBar: HTMLElement, + context: TaskModalActionIconStateContext, + timeEstimate: number +): void { + const chip = getActionChip(actionBar, "time-estimate"); + if (!chip) return; + + if (timeEstimate > 0) { + chip.classList.add("has-value"); + const label = context.translate("modals.task.chips.timeEstimateValue", { + minutes: timeEstimate, + }); + setTaskModalChipLabel(chip, label); + setTooltip(chip, label, { placement: "top" }); + return; + } + + chip.classList.remove("has-value"); + const defaultLabel = context.translate("modals.task.chips.timeEstimate"); + setTaskModalChipLabel(chip, defaultLabel); + setTooltip(chip, defaultLabel, { placement: "top" }); } -function getActionIcon(actionBar: HTMLElement, dataType: string): HTMLElement | null { - return actionBar.querySelector(`[data-type="${dataType}"]`); +function getActionChip(actionBar: HTMLElement, dataType: string): HTMLElement | null { + return getTaskModalChip(actionBar, dataType); } diff --git a/src/modals/taskModalActionState.ts b/src/modals/taskModalActionState.ts index e770ffd6a..6493d9468 100644 --- a/src/modals/taskModalActionState.ts +++ b/src/modals/taskModalActionState.ts @@ -104,9 +104,31 @@ export function buildTaskModalActionIconState( recurrenceRule: state.recurrenceRule, recurrenceDisplayText: getTaskModalRecurrenceDisplayText(state.recurrenceRule), reminderCount: state.reminders.length, + contexts: "", + tags: "", + timeEstimate: 0, defaultStatus: getDefaultTaskModalStatus(statusConfigs), defaultPriority: getDefaultTaskModalPriority(priorityConfigs), statusConfigs, priorityConfigs, }; } + +export interface TaskModalChipStateInput extends TaskModalActionMenuState { + contexts: string; + tags: string; + timeEstimate: number; +} + +export function buildTaskModalChipState( + state: TaskModalChipStateInput, + options: TaskModalActionIconStateOptions +): TaskModalActionIconState { + const base = buildTaskModalActionIconState(state, options); + return { + ...base, + contexts: state.contexts, + tags: state.tags, + timeEstimate: state.timeEstimate, + }; +} diff --git a/src/modals/taskModalChipListEditor.ts b/src/modals/taskModalChipListEditor.ts new file mode 100644 index 000000000..ad5155c6c --- /dev/null +++ b/src/modals/taskModalChipListEditor.ts @@ -0,0 +1,296 @@ +import { App, Modal, Setting, setIcon } from "obsidian"; +import type TaskNotesPlugin from "../main"; +import { addContextToList, normalizeContextList } from "../components/TaskContextMenu"; +import { createTaskModalIconButton } from "./taskModalActionButtons"; +import { + attachChipEditorContextSuggest, + attachChipEditorTagSuggest, +} from "./taskModalSuggests"; +import { parseTaskModalCommaList } from "./taskModalOrgCounts"; +import { addTagsToList, normalizeTaskTagList, parseTaskTagInput } from "../utils/taskTagList"; + +export type TaskModalChipListVariant = "contexts" | "tags"; + +export interface TaskModalChipListEditorOptions { + title: string; + placeholder: string; + initialValues: string[]; + variant: TaskModalChipListVariant; + confirmText: string; + cancelText: string; + removeItemLabel: (item: string) => string; + formatChipLabel: (value: string) => string; +} + +export function formatTaskModalChipListEditorLabel( + variant: TaskModalChipListVariant, + value: string +): string { + return variant === "tags" ? `#${value}` : value; +} + +export function isTaskModalChipListEditorMobileLikeEnvironment( + doc: Document = document +): boolean { + const win = doc.defaultView || window; + return ( + doc.body.classList.contains("is-mobile") || + win.matchMedia?.("(pointer: coarse)")?.matches === true + ); +} + +export function finalizeTaskModalChipListEditorValues( + values: readonly string[], + pendingInput: string, + variant: TaskModalChipListVariant +): string[] { + if (variant === "contexts") { + let next = [...values]; + for (const entry of parseTaskModalCommaList(pendingInput)) { + next = addContextToList(next, entry) ?? next; + } + return normalizeContextList(next); + } + + let next = [...values]; + for (const entry of parseTaskTagInput(pendingInput)) { + next = addTagsToList(next, [entry]) ?? next; + } + return normalizeTaskTagList(next); +} + +export class TaskModalChipListEditorModal extends Modal { + private readonly options: TaskModalChipListEditorOptions; + private readonly plugin: TaskNotesPlugin; + private selectedValues: string[]; + private chipsContainer: HTMLElement; + private inputEl: HTMLInputElement; + private resolve: (value: string[] | null) => void; + private submitted = false; + + constructor( + app: App, + plugin: TaskNotesPlugin, + options: TaskModalChipListEditorOptions, + initialValues: string[] + ) { + super(app); + this.plugin = plugin; + this.options = options; + this.selectedValues = + options.variant === "contexts" + ? normalizeContextList(initialValues) + : normalizeTaskTagList(initialValues); + } + + public show(): Promise { + return new Promise((resolve) => { + this.resolve = resolve; + this.open(); + }); + } + + onOpen(): void { + const { contentEl } = this; + contentEl.empty(); + contentEl.addClass("tasknotes-plugin", "tn-chip-list-editor__content"); + this.modalEl.addClass("tn-chip-list-editor"); + + const useIconButtons = isTaskModalChipListEditorMobileLikeEnvironment( + contentEl.ownerDocument + ); + if (useIconButtons) { + this.modalEl.addClass("tn-chip-list-editor--mobile-actions"); + } + + new Setting(contentEl).setName(this.options.title).setHeading(); + + this.chipsContainer = contentEl.createDiv("tn-chip-list-editor__chips"); + this.renderChips(); + + const inputSetting = new Setting(contentEl); + inputSetting.settingEl.addClass("tn-chip-list-editor__input-setting"); + inputSetting.addText((text) => { + this.inputEl = text.inputEl; + text.setPlaceholder(this.options.placeholder); + this.attachSuggest(); + }); + + const buttonContainer = contentEl.createEl("div", { + cls: useIconButtons + ? "modal-button-container tn-chip-list-editor__buttons tn-task-modal__icon-button-bar" + : "modal-button-container tn-chip-list-editor__buttons", + }); + + let cancelButton: HTMLButtonElement; + let confirmButton: HTMLButtonElement; + + if (useIconButtons) { + const trailingGroup = buttonContainer.createDiv("tn-task-modal__icon-button-trailing"); + cancelButton = createTaskModalIconButton(trailingGroup, { + iconName: "x", + label: this.options.cancelText, + }); + confirmButton = createTaskModalIconButton(trailingGroup, { + iconName: "check", + label: this.options.confirmText, + isPrimary: true, + }); + } else { + cancelButton = buttonContainer.createEl("button", { + text: this.options.cancelText, + }); + confirmButton = buttonContainer.createEl("button", { + text: this.options.confirmText, + cls: "mod-cta", + }); + } + + cancelButton.addEventListener("click", () => { + this.resolve(null); + this.close(); + }); + + confirmButton.addEventListener("click", () => { + this.submit(); + }); + + this.inputEl.addEventListener("keydown", (event) => { + if (event.key === "Enter") { + event.preventDefault(); + if (this.inputEl.value.trim()) { + this.addFromInput(); + return; + } + confirmButton.click(); + } else if (event.key === "Escape") { + event.preventDefault(); + cancelButton.click(); + } + }); + + window.setTimeout(() => { + this.inputEl.focus(); + }, 100); + } + + onClose(): void { + this.contentEl.empty(); + if (this.resolve && !this.submitted) { + this.resolve(null); + } + } + + private attachSuggest(): void { + const callbacks = { + getSelectedValues: () => this.selectedValues, + onAdd: (value: string) => { + this.addValue(value); + }, + }; + + if (this.options.variant === "contexts") { + attachChipEditorContextSuggest(this.app, this.inputEl, this.plugin, callbacks); + return; + } + + attachChipEditorTagSuggest(this.app, this.inputEl, this.plugin, callbacks); + } + + private renderChips(): void { + this.chipsContainer.empty(); + this.chipsContainer.classList.toggle("is-empty", this.selectedValues.length === 0); + + for (const value of this.selectedValues) { + const chip = this.chipsContainer.createDiv("tn-chip-list-editor__chip"); + chip.createSpan({ + cls: "tn-chip-list-editor__chip-label", + text: this.options.formatChipLabel(value), + }); + + const removeButton = chip.createEl("button", { + cls: "tn-chip-list-editor__chip-remove", + attr: { + "aria-label": this.options.removeItemLabel( + this.options.formatChipLabel(value) + ), + type: "button", + }, + }); + setIcon(removeButton, "x"); + removeButton.addEventListener("click", (event) => { + event.preventDefault(); + event.stopPropagation(); + this.removeValue(value); + }); + } + } + + private addValue(rawValue: string): void { + const entries = + this.options.variant === "contexts" + ? parseTaskModalCommaList(rawValue) + : parseTaskTagInput(rawValue); + + if (!entries.length) { + this.inputEl.value = ""; + return; + } + + let nextValues = this.selectedValues; + for (const entry of entries) { + const updated = + this.options.variant === "contexts" + ? addContextToList(nextValues, entry) + : addTagsToList(nextValues, [entry]); + if (updated) { + nextValues = updated; + } + } + + const normalized = + this.options.variant === "contexts" + ? normalizeContextList(nextValues) + : normalizeTaskTagList(nextValues); + + if (normalized.length === this.selectedValues.length) { + this.inputEl.value = ""; + return; + } + + this.selectedValues = normalized; + this.inputEl.value = ""; + this.renderChips(); + } + + private addFromInput(): void { + this.addValue(this.inputEl.value); + } + + private removeValue(value: string): void { + this.selectedValues = this.selectedValues.filter((entry) => entry !== value); + this.renderChips(); + this.inputEl.focus(); + } + + private submit(): void { + const next = finalizeTaskModalChipListEditorValues( + this.selectedValues, + this.inputEl.value, + this.options.variant + ); + this.submitted = true; + this.resolve(next); + this.close(); + } +} + +export async function showTaskModalChipListEditor( + app: App, + plugin: TaskNotesPlugin, + options: TaskModalChipListEditorOptions, + initialValues: string[] +): Promise { + const modal = new TaskModalChipListEditorModal(app, plugin, options, initialValues); + return modal.show(); +} diff --git a/src/modals/taskModalChipRow.ts b/src/modals/taskModalChipRow.ts new file mode 100644 index 000000000..c781d1848 --- /dev/null +++ b/src/modals/taskModalChipRow.ts @@ -0,0 +1,75 @@ +import { setIcon, setTooltip } from "obsidian"; + +export interface TaskModalChipSpec { + iconName: string; + label: string; + onClick: (chip: HTMLElement, event: UIEvent) => void; + dataType: string; +} + +export function createTaskModalChipRow(container: HTMLElement): HTMLElement { + return container.createDiv("tn-task-modal__chip-row tn-task-modal__action-bar"); +} + +export function createTaskModalChipBreak(container: HTMLElement): HTMLElement { + const breakEl = container.createDiv("tn-task-modal__chip-break"); + breakEl.setAttribute("aria-hidden", "true"); + return breakEl; +} + +export function createTaskModalChip( + container: HTMLElement, + spec: TaskModalChipSpec +): HTMLElement { + const chip = container.createDiv("tn-task-modal__chip action-chip"); + chip.setAttribute("aria-label", spec.label); + chip.setAttribute("data-initial-label", spec.label); + chip.setAttribute("tabindex", "0"); + chip.setAttribute("role", "button"); + chip.setAttribute("data-type", spec.dataType); + + setTooltip(chip, spec.label, { placement: "top" }); + + const iconWrap = chip.createSpan("tn-task-modal__chip-icon"); + setIcon(iconWrap, spec.iconName); + + const labelEl = chip.createSpan("tn-task-modal__chip-label"); + labelEl.textContent = spec.label; + + const activate = (event: UIEvent): void => { + event.preventDefault(); + event.stopPropagation(); + spec.onClick(chip, event); + }; + + chip.addEventListener("click", activate); + chip.addEventListener("keydown", (event) => { + if (event.key === "Enter" || event.key === " ") { + activate(event); + } + }); + + return chip; +} + +export function createTaskModalChips( + container: HTMLElement, + specs: readonly TaskModalChipSpec[] +): HTMLElement[] { + return specs.map((spec) => createTaskModalChip(container, spec)); +} + +export function getTaskModalChip( + container: HTMLElement | undefined, + dataType: string +): HTMLElement | null { + if (!container) return null; + return container.querySelector(`[data-type="${dataType}"]`); +} + +export function setTaskModalChipLabel(chip: HTMLElement, label: string): void { + const labelEl = chip.querySelector(".tn-task-modal__chip-label"); + if (labelEl) { + labelEl.textContent = label; + } +} diff --git a/src/modals/taskModalDescriptionClamp.ts b/src/modals/taskModalDescriptionClamp.ts new file mode 100644 index 000000000..54c87e068 --- /dev/null +++ b/src/modals/taskModalDescriptionClamp.ts @@ -0,0 +1,79 @@ +const DEFAULT_MAX_HEIGHT_PX = 120; + +export interface TaskModalDescriptionClampOptions { + editorContainer: HTMLElement; + translate: (key: string) => string; + maxHeightPx?: number; +} + +export interface TaskModalDescriptionClampController { + destroy: () => void; + refresh: () => void; +} + +export function attachTaskModalDescriptionClamp( + options: TaskModalDescriptionClampOptions +): TaskModalDescriptionClampController { + const maxHeight = options.maxHeightPx ?? DEFAULT_MAX_HEIGHT_PX; + const wrapper = options.editorContainer.closest(".tn-task-modal__markdown-editor--details"); + if (!wrapper?.instanceOf(HTMLElement)) { + return { destroy: () => undefined, refresh: () => undefined }; + } + + wrapper.classList.add("tn-task-modal__details--clampable"); + + const toggleButton = activeDocument.createElement("button"); + toggleButton.type = "button"; + toggleButton.className = "tn-task-modal__details-toggle mod-cta"; + toggleButton.hidden = true; + wrapper.insertAdjacentElement("afterend", toggleButton); + + let expanded = false; + + const setExpanded = (nextExpanded: boolean): void => { + expanded = nextExpanded; + wrapper.classList.toggle("tn-task-modal__details--expanded", expanded); + toggleButton.textContent = expanded + ? options.translate("modals.task.detailsShowLess") + : options.translate("modals.task.detailsShowMore"); + }; + + const refresh = (): void => { + if (expanded) { + toggleButton.hidden = false; + return; + } + + wrapper.style.maxHeight = `${maxHeight}px`; + const overflows = wrapper.scrollHeight > wrapper.clientHeight + 1; + wrapper.style.removeProperty("max-height"); + toggleButton.hidden = !overflows; + }; + + toggleButton.addEventListener("click", () => { + setExpanded(!expanded); + }); + + const resizeObserver = + typeof ResizeObserver !== "undefined" + ? new ResizeObserver(() => { + refresh(); + }) + : null; + + resizeObserver?.observe(wrapper); + window.setTimeout(refresh, 0); + window.setTimeout(refresh, 250); + + return { + destroy: () => { + resizeObserver?.disconnect(); + toggleButton.remove(); + wrapper.classList.remove( + "tn-task-modal__details--clampable", + "tn-task-modal__details--expanded" + ); + }, + refresh, + }; +} diff --git a/src/modals/taskModalDetailsEditor.ts b/src/modals/taskModalDetailsEditor.ts index 980297493..c48a601bf 100644 --- a/src/modals/taskModalDetailsEditor.ts +++ b/src/modals/taskModalDetailsEditor.ts @@ -7,7 +7,6 @@ type Nullable = T | null; export interface TaskModalDetailsEditorOptions { app: App; parent: HTMLElement; - label: string; value: string; placeholder: string; file?: Nullable; @@ -22,14 +21,11 @@ export interface TaskModalDetailsEditorOptions { export function createTaskModalDetailsEditor( options: TaskModalDetailsEditorOptions ): EmbeddableMarkdownEditor | null { - const labelEl = options.parent.createDiv("detail-label"); - labelEl.textContent = options.label; - const editorContainer = options.parent.createDiv( "tn-task-modal__markdown-editor tn-task-modal__markdown-editor--details" ); - return createTaskModalMarkdownEditor(options.app, editorContainer, { + const editor = createTaskModalMarkdownEditor(options.app, editorContainer, { value: options.value, placeholder: options.placeholder, cls: "details-editor", @@ -45,6 +41,44 @@ export function createTaskModalDetailsEditor( }, file: options.file ?? null, }); + + attachDetailsEditorFocusOnClick(editorContainer, editor); + + return editor; +} + +function attachDetailsEditorFocusOnClick( + editorContainer: HTMLElement, + editor: EmbeddableMarkdownEditor | null +): void { + editorContainer.addEventListener("mousedown", (event) => { + if (event.button !== 0) { + return; + } + + const fallback = editorContainer.querySelector( + ".details-editor-fallback" + ); + if (fallback) { + if (event.target !== fallback) { + event.preventDefault(); + fallback.focus(); + } + return; + } + + if (!editor) { + return; + } + + const content = editorContainer.querySelector(".cm-content"); + if (content?.contains(event.target as Node)) { + return; + } + + event.preventDefault(); + editor.editor.cm.focus(); + }); } export function setTaskModalDetailsEditorValue( diff --git a/src/modals/taskModalFocusGuards.ts b/src/modals/taskModalFocusGuards.ts index 106742f7e..6fbc74f32 100644 --- a/src/modals/taskModalFocusGuards.ts +++ b/src/modals/taskModalFocusGuards.ts @@ -60,7 +60,7 @@ export class TaskModalFocusGuards { }; input.addEventListener("pointerdown", capture, { capture: true }); - input.addEventListener("touchstart", capture, { capture: true }); + input.addEventListener("touchstart", capture, { capture: true, passive: true }); input.addEventListener("focus", () => { if (!this.pendingTitleFocusScrollPositions) return; this.scheduleTitleFocusScrollRestore(this.pendingTitleFocusScrollPositions); @@ -130,9 +130,17 @@ export class TaskModalFocusGuards { const addElement = (element: Element | null | undefined) => { const elementWindow = element?.ownerDocument.defaultView; const HTMLElementConstructor = elementWindow?.HTMLElement ?? HTMLElement; - if (element instanceof HTMLElementConstructor) { - elements.add(element); + if (!(element instanceof HTMLElementConstructor)) { + return; } + + // CodeMirror manages its own scroller; forcing scrollTop on it triggers + // Obsidian's markdown scroll sync and can spam console errors. + if (element.classList.contains("cm-scroller")) { + return; + } + + elements.add(element); }; addElement(this.elements.containerEl); @@ -222,8 +230,16 @@ export class TaskModalFocusGuards { } } + private usesSheetLayout(): boolean { + return this.elements.containerEl.classList.contains("tn-task-modal--sheet"); + } + private scrollMobileKeyboardTargetIntoView(input: HTMLElement): void { if (!this.isMobileLikeEnvironment()) return; + // The sheet layout lifts the whole modal above the keyboard/toolbar. + // Scrolling inside the content area fights that and leaves the footer + // behind the bottom obstruction. + if (this.usesSheetLayout()) return; const target = input.closest(".setting-item") ?? input; target.scrollIntoView({ diff --git a/src/modals/taskModalLayout.ts b/src/modals/taskModalLayout.ts index 7a7d5c1ff..afa215c33 100644 --- a/src/modals/taskModalLayout.ts +++ b/src/modals/taskModalLayout.ts @@ -17,6 +17,43 @@ const TASK_MODAL_OPACITY_ONE_CLASS = "tn-static-opacity-1-c6e7979d"; const TASK_MODAL_TRANSLATE_START_CLASS = "tn-static-transform-translatey-10px-5b91bf02"; const TASK_MODAL_TRANSLATE_END_CLASS = "tn-static-transform-translatey-0-1b976432"; +/** + * The legacy wide-screen "split layout" (side-by-side form + description + * columns) predates the Todoist-style redesign and is only meant for the + * desktop, non-sheet, non-sidebar layout. It must never be combined with the + * mobile/touch bottom-sheet layout: on a mobile-like environment with a wide + * viewport, applying both simultaneously causes the sheet's title/description + * column to inherit the split layout's edge-to-edge negative margins and + * `overflow: hidden`, clipping the title field. + */ +export function shouldUseEditSidebarLayout({ + isEditMode, + isCreationMode, + isExpanded, + isMobileLikeEnvironment, +}: { + isEditMode: boolean; + isCreationMode: boolean; + isExpanded: boolean; + isMobileLikeEnvironment: boolean; +}): boolean { + return ( + (isEditMode || (isCreationMode && isExpanded)) && !isMobileLikeEnvironment + ); +} + +export function shouldUseSplitLayoutEnabledClass({ + enableModalSplitLayout, + usesEditSidebarLayout, + usesSheetLayout, +}: { + enableModalSplitLayout: boolean; + usesEditSidebarLayout: boolean; + usesSheetLayout: boolean; +}): boolean { + return enableModalSplitLayout && !usesEditSidebarLayout && !usesSheetLayout; +} + export interface TaskModalLayoutElements { containerEl: HTMLElement; detailsContainer: HTMLElement; diff --git a/src/modals/taskModalOrgCounts.ts b/src/modals/taskModalOrgCounts.ts new file mode 100644 index 000000000..c5e7abe0a --- /dev/null +++ b/src/modals/taskModalOrgCounts.ts @@ -0,0 +1,53 @@ +import type TaskNotesPlugin from "../main"; + +export interface TaskModalCompletionCount { + completed: number; + total: number; +} + +export function parseTaskModalCommaList(value: string): string[] { + if (!value.trim()) return []; + return value + .split(",") + .map((entry) => entry.trim()) + .filter(Boolean); +} + +export function formatTaskModalCommaList(values: readonly string[]): string { + return values.join(", "); +} + +export function countTaskModalCompletion( + plugin: TaskNotesPlugin, + paths: readonly (string | undefined)[] +): TaskModalCompletionCount { + let completed = 0; + // Every entry counts toward the total, including unresolved dependencies + // that have no linked path, so this matches the number of items actually + // rendered in the list. + const total = paths.length; + + for (const path of paths) { + if (!path) continue; + + const taskInfo = plugin.cacheManager.getCachedTaskInfoSync(path); + if (!taskInfo) continue; + + if (plugin.statusManager.isCompletedStatus(taskInfo.status)) { + completed++; + } + } + + return { completed, total }; +} + +export function formatTaskModalCompletionCount( + translate: (key: string, params?: Record) => string, + completed: number, + total: number +): string { + return translate("modals.task.organization.completionCount", { + completed, + total, + }); +} diff --git a/src/modals/taskModalOrganizationFields.ts b/src/modals/taskModalOrganizationFields.ts index 26571fd5c..3e78a288a 100644 --- a/src/modals/taskModalOrganizationFields.ts +++ b/src/modals/taskModalOrganizationFields.ts @@ -1,86 +1,136 @@ -import { Setting } from "obsidian"; - -interface TaskModalListFieldOptions { - label: string; - buttonText: string; - buttonTooltip: string; - onButtonClick: () => void; - listElement?: HTMLElement; -} +import { setIcon } from "obsidian"; +import { createTaskModalSidebarRow } from "./taskModalSidebar"; export interface TaskModalOrganizationFieldContext { - translate: (key: string) => string; + translate: (key: string, params?: Record) => string; } export interface CreateTaskModalOrganizationFieldOptions { container: HTMLElement; onButtonClick: () => void; listElement?: HTMLElement; + getItemCount?: () => number; + onHeaderUpdate?: (update: () => void) => void; } -export function createTaskModalListField( +export interface TaskModalOrganizationRowController { + listElement: HTMLElement; + updateHeader: () => void; +} + +interface OrganizationRowCopy { + sectionLabelKey: string; + addAriaKey: string; + iconName: string; +} + +const ORGANIZATION_ROW_COPY: Record = { + projects: { + iconName: "folder", + sectionLabelKey: "modals.task.organization.projects", + addAriaKey: "modals.task.projectsTooltip", + }, + subtasks: { + iconName: "list-tree", + sectionLabelKey: "modals.task.organization.subtasks", + addAriaKey: "modals.task.organization.addSubtasksTooltip", + }, + "blocked-by": { + iconName: "git-pull-request", + sectionLabelKey: "modals.task.dependencies.blockedBy", + addAriaKey: "modals.task.dependencies.selectTaskTooltip", + }, + blocking: { + iconName: "git-branch", + sectionLabelKey: "modals.task.dependencies.blocking", + addAriaKey: "modals.task.dependencies.selectTaskTooltip", + }, +}; + +function createTaskModalOrganizationRow( container: HTMLElement, - options: TaskModalListFieldOptions -): HTMLElement { - new Setting(container).setName(options.label).addButton((button) => { - button - .setButtonText(options.buttonText) - .setTooltip(options.buttonTooltip) - .onClick(options.onButtonClick); - button.buttonEl.addClasses(["tn-btn", "tn-btn--ghost"]); + rowId: string, + context: TaskModalOrganizationFieldContext, + options: CreateTaskModalOrganizationFieldOptions +): TaskModalOrganizationRowController { + const copy = ORGANIZATION_ROW_COPY[rowId]; + const fieldContainer = container.createDiv(`tn-task-modal__org-field tn-task-modal__org-field--${rowId}`); + + const emptyRowContainer = fieldContainer.createDiv("tn-task-modal__org-empty-row"); + createTaskModalSidebarRow(emptyRowContainer, { + id: rowId, + iconName: copy.iconName, + label: context.translate(copy.sectionLabelKey), + value: "", + hasValue: false, + onClick: () => { + options.onButtonClick(); + }, }); - return options.listElement ?? container.createDiv({ cls: "task-projects-list" }); + const header = fieldContainer.createDiv("tn-task-modal__org-header"); + header.hidden = true; + + const titleLabel = header.createDiv("tn-task-modal__org-header-label"); + const titleText = titleLabel.createSpan("tn-task-modal__org-header-title"); + + const addButton = header.createEl("button", { + cls: "tn-task-modal__org-add-button", + attr: { "aria-label": context.translate(copy.addAriaKey) }, + }); + setIcon(addButton.createSpan(), "plus"); + addButton.addEventListener("click", (event) => { + event.stopPropagation(); + options.onButtonClick(); + }); + + const listElement = + options.listElement ?? + fieldContainer.createDiv({ + cls: "task-projects-list tn-task-modal__org-list", + }); + + const updateHeader = (): void => { + const total = options.getItemCount?.() ?? 0; + const isEmpty = total === 0; + emptyRowContainer.hidden = !isEmpty; + header.hidden = isEmpty; + + if (!isEmpty) { + titleText.setText(context.translate(copy.sectionLabelKey)); + } + }; + + options.onHeaderUpdate?.(updateHeader); + updateHeader(); + + return { listElement, updateHeader }; } export function createTaskModalProjectsField( context: TaskModalOrganizationFieldContext, options: CreateTaskModalOrganizationFieldOptions -): HTMLElement { - return createTaskModalListField(options.container, { - label: context.translate("modals.task.organization.projects"), - buttonText: context.translate("modals.task.organization.addToProjectButton"), - buttonTooltip: context.translate("modals.task.projectsTooltip"), - onButtonClick: options.onButtonClick, - listElement: options.listElement, - }); +): TaskModalOrganizationRowController { + return createTaskModalOrganizationRow(options.container, "projects", context, options); } export function createTaskModalSubtasksField( context: TaskModalOrganizationFieldContext, options: CreateTaskModalOrganizationFieldOptions -): HTMLElement { - return createTaskModalListField(options.container, { - label: context.translate("modals.task.organization.subtasks"), - buttonText: context.translate("modals.task.organization.addSubtasksButton"), - buttonTooltip: context.translate("modals.task.organization.addSubtasksTooltip"), - onButtonClick: options.onButtonClick, - listElement: options.listElement, - }); +): TaskModalOrganizationRowController { + return createTaskModalOrganizationRow(options.container, "subtasks", context, options); } export function createTaskModalBlockedByField( context: TaskModalOrganizationFieldContext, options: CreateTaskModalOrganizationFieldOptions -): HTMLElement { - return createTaskModalListField(options.container, { - label: context.translate("modals.task.dependencies.blockedBy"), - buttonText: context.translate("modals.task.dependencies.addTaskButton"), - buttonTooltip: context.translate("modals.task.dependencies.selectTaskTooltip"), - onButtonClick: options.onButtonClick, - listElement: options.listElement, - }); +): TaskModalOrganizationRowController { + return createTaskModalOrganizationRow(options.container, "blocked-by", context, options); } export function createTaskModalBlockingField( context: TaskModalOrganizationFieldContext, options: CreateTaskModalOrganizationFieldOptions -): HTMLElement { - return createTaskModalListField(options.container, { - label: context.translate("modals.task.dependencies.blocking"), - buttonText: context.translate("modals.task.dependencies.addTaskButton"), - buttonTooltip: context.translate("modals.task.dependencies.selectTaskTooltip"), - onButtonClick: options.onButtonClick, - listElement: options.listElement, - }); +): TaskModalOrganizationRowController { + return createTaskModalOrganizationRow(options.container, "blocking", context, options); } diff --git a/src/modals/taskModalPropertyMenus.ts b/src/modals/taskModalPropertyMenus.ts new file mode 100644 index 000000000..31e5f36ea --- /dev/null +++ b/src/modals/taskModalPropertyMenus.ts @@ -0,0 +1,116 @@ +import type TaskNotesPlugin from "../main"; +import { normalizeContextList } from "../components/TaskContextMenu"; +import { showTextInputModal } from "./TextInputModal"; +import { + formatTaskModalChipListEditorLabel, + showTaskModalChipListEditor, +} from "./taskModalChipListEditor"; +import { + formatTaskModalCommaList, + parseTaskModalCommaList, +} from "./taskModalOrgCounts"; +import { sanitizeTags } from "../utils/helpers"; + +export interface TaskModalPropertyMenuContext { + plugin: TaskNotesPlugin; + translate: (key: string, params?: Record) => string; + getContexts: () => string; + setContexts: (value: string) => void; + getTags: () => string; + setTags: (value: string) => void; + getTimeEstimate: () => number; + setTimeEstimate: (value: number) => void; + onChange: () => void; +} + +function contextsListToString(contexts: string[] | undefined): string { + if (!contexts?.length) return ""; + return formatTaskModalCommaList(contexts); +} + +function tagsStringToList(value: string): string[] { + return parseTaskModalCommaList(sanitizeTags(value)); +} + +function tagsListToString(tags: readonly string[]): string { + if (!tags.length) return ""; + return formatTaskModalCommaList(tags); +} + +export async function showTaskModalContextsInput( + context: TaskModalPropertyMenuContext +): Promise { + const result = await showTaskModalChipListEditor( + context.plugin.app, + context.plugin, + { + title: context.translate("modals.task.contextsLabel"), + placeholder: context.translate("contextMenus.task.organization.addContext"), + initialValues: parseTaskModalCommaList(context.getContexts()), + variant: "contexts", + confirmText: context.translate("common.confirm"), + cancelText: context.translate("common.cancel"), + removeItemLabel: (item) => + context.translate("modals.task.chipList.removeItem", { item }), + formatChipLabel: (value) => formatTaskModalChipListEditorLabel("contexts", value), + }, + parseTaskModalCommaList(context.getContexts()) + ); + + if (result === null) return; + + const next = contextsListToString(normalizeContextList(result)); + if (next === context.getContexts()) return; + + context.setContexts(next); + context.onChange(); +} + +export async function showTaskModalTagsInput( + context: TaskModalPropertyMenuContext +): Promise { + const result = await showTaskModalChipListEditor( + context.plugin.app, + context.plugin, + { + title: context.translate("modals.task.tagsLabel"), + placeholder: context.translate("contextMenus.task.addTag"), + initialValues: tagsStringToList(context.getTags()), + variant: "tags", + confirmText: context.translate("common.confirm"), + cancelText: context.translate("common.cancel"), + removeItemLabel: (item) => + context.translate("modals.task.chipList.removeItem", { item }), + formatChipLabel: (value) => formatTaskModalChipListEditorLabel("tags", value), + }, + tagsStringToList(context.getTags()) + ); + + if (result === null) return; + + const next = tagsListToString(result); + if (next === context.getTags()) return; + + context.setTags(next); + context.onChange(); +} + +export async function showTaskModalTimeEstimateInput( + context: TaskModalPropertyMenuContext +): Promise { + const current = context.getTimeEstimate(); + const input = await showTextInputModal(context.plugin.app, { + title: context.translate("modals.task.timeEstimateLabel"), + placeholder: context.translate("modals.task.timeEstimatePlaceholder"), + initialValue: current > 0 ? String(current) : "", + confirmText: context.translate("common.confirm"), + cancelText: context.translate("common.cancel"), + fitContent: { minCh: 8, maxCh: 12 }, + }); + + if (input === null) return; + + const parsed = parseInt(input, 10); + context.setTimeEstimate(Number.isFinite(parsed) && parsed > 0 ? parsed : 0); + context.onChange(); +} diff --git a/src/modals/taskModalSheetGestures.ts b/src/modals/taskModalSheetGestures.ts new file mode 100644 index 000000000..99d64a2fd --- /dev/null +++ b/src/modals/taskModalSheetGestures.ts @@ -0,0 +1,271 @@ +import { attachTaskModalSheetKeyboardAvoidance } from "./taskModalSheetKeyboard"; + +export interface TaskModalSheetGestureOptions { + containerEl: HTMLElement; + modalEl: HTMLElement; + onDismiss: () => void; + onSnapChange?: (snap: "partial" | "full") => void; +} + +export interface TaskModalSheetGestureController { + destroy: () => void; + expandToFull: () => void; +} + +export const PARTIAL_SNAP_MAX_HEIGHT = "60dvh"; +/** Minimum gap left above the sheet when fully expanded (matches mobile modal rules). */ +export const SHEET_TOP_SAFE_INSET = "16px"; +export const FULL_SNAP_MAX_HEIGHT = `calc(100dvh - ${SHEET_TOP_SAFE_INSET} - env(safe-area-inset-top))`; +const DISMISS_THRESHOLD_PX = 120; +const EXPAND_DRAG_THRESHOLD_PX = 40; +const COLLAPSE_DRAG_THRESHOLD_PX = 80; +const FLING_VELOCITY = 0.8; +// If the pointer has been stationary for longer than this before release, +// the last measured velocity is stale and should not be treated as a fling. +const STALE_VELOCITY_MS = 100; + +function setSheetOffset(modalEl: HTMLElement, offsetPx: number): void { + const value = offsetPx > 0 ? `${offsetPx}px` : "0"; + if (typeof modalEl.setCssProps === "function") { + modalEl.setCssProps({ "--tn-sheet-offset": value }); + return; + } + + modalEl.style.setProperty("--tn-sheet-offset", value); +} + +function setSheetMaxHeight(modalEl: HTMLElement, value: string): void { + if (typeof modalEl.setCssProps === "function") { + modalEl.setCssProps({ "--tn-sheet-max-height": value }); + return; + } + + modalEl.style.setProperty("--tn-sheet-max-height", value); +} + +export function attachTaskModalSheetGestures( + options: TaskModalSheetGestureOptions +): TaskModalSheetGestureController { + const { containerEl, modalEl, onDismiss } = options; + const handle = containerEl.querySelector(".tn-task-modal__sheet-handle"); + if (!handle) { + // No handle to drive the reveal - make sure the sheet doesn't stay + // stuck off-screen from the "--pending" state applied in onOpen(). + modalEl.removeClass("tn-task-modal__sheet--pending"); + return { destroy: () => undefined, expandToFull: () => undefined }; + } + + let dragging = false; + let activePointerId: number | null = null; + let startY = 0; + let startOffset = 0; + let currentOffset = 0; + let currentSnap: "partial" | "full" = "partial"; + let lastY = 0; + let lastTime = 0; + let velocity = 0; + + const getMaxHeight = (): number => { + return modalEl.getBoundingClientRect().height; + }; + + const applyOffset = (offset: number): void => { + currentOffset = Math.max(0, offset); + setSheetOffset(modalEl, currentOffset); + }; + + const getSnapMaxHeight = (): string => { + return currentSnap === "full" ? FULL_SNAP_MAX_HEIGHT : PARTIAL_SNAP_MAX_HEIGHT; + }; + + const keyboardAvoidance = attachTaskModalSheetKeyboardAvoidance({ + modalEl, + getSnapMaxHeight, + isDragging: () => dragging, + }); + + const snapTo = (snap: "partial" | "full"): void => { + currentSnap = snap; + setSheetMaxHeight(modalEl, getSnapMaxHeight()); + modalEl.removeClass("tn-task-modal__sheet--dragging"); + modalEl.addClass("tn-task-modal__sheet--snapping"); + applyOffset(0); + options.onSnapChange?.(snap); + keyboardAvoidance.refresh(); + window.setTimeout(() => { + modalEl.removeClass("tn-task-modal__sheet--snapping"); + keyboardAvoidance.refresh(); + }, 250); + }; + + const expandToFull = (): void => { + snapTo("full"); + }; + + // pointermove/pointerup are attached to the window (rather than just the + // handle) while a drag is active. The handle is a small hit target, and + // relying solely on setPointerCapture to keep delivering events to it once + // the finger moves outside its bounds is not reliable across all mobile + // WebViews. Without this, a fast or large drag motion could leave the + // handle's bounds and silently stop responding until the pointer was + // re-pressed, which is what made the gesture feel "stuck" on mobile. + const endDrag = (): void => { + dragging = false; + activePointerId = null; + window.removeEventListener("pointermove", onPointerMove); + window.removeEventListener("pointerup", onPointerUp); + window.removeEventListener("pointercancel", onPointerCancel); + }; + + const onPointerDown = (event: PointerEvent): void => { + if (event.button !== 0) return; + // Ignore additional touches (e.g. an accidental second finger) while a + // drag is already in progress, since restarting the gesture mid-drag + // caused the sheet to jump. + if (dragging) return; + + dragging = true; + activePointerId = event.pointerId; + startY = event.clientY; + startOffset = currentOffset; + lastY = event.clientY; + lastTime = event.timeStamp; + velocity = 0; + + try { + handle.setPointerCapture(event.pointerId); + } catch { + // Pointer capture is best-effort; the window-level listeners below + // are the primary mechanism keeping the drag alive. + } + + window.addEventListener("pointermove", onPointerMove); + window.addEventListener("pointerup", onPointerUp); + window.addEventListener("pointercancel", onPointerCancel); + + modalEl.addClass("tn-task-modal__sheet--dragging"); + modalEl.removeClass("tn-task-modal__sheet--snapping"); + }; + + const onPointerMove = (event: PointerEvent): void => { + if (!dragging || event.pointerId !== activePointerId) return; + event.preventDefault(); + + const delta = event.clientY - startY; + applyOffset(startOffset + delta); + + const dt = event.timeStamp - lastTime; + if (dt > 0) { + velocity = (event.clientY - lastY) / dt; + } + lastY = event.clientY; + lastTime = event.timeStamp; + }; + + const finishDrag = (event: PointerEvent): void => { + if (!dragging || event.pointerId !== activePointerId) return; + + try { + handle.releasePointerCapture(event.pointerId); + } catch { + // Ignore: capture may not have been established, or the pointer may + // already have been released by the browser. + } + + // If the pointer has been idle for a while before release, the last + // recorded velocity sample is stale (common with touchmove batching + // where trailing events report a delta-time of zero) and should not be + // used to decide a fling. + const idleTime = event.timeStamp - lastTime; + const effectiveVelocity = idleTime > STALE_VELOCITY_MS ? 0 : velocity; + const draggedUp = startY - event.clientY; + const draggedDown = event.clientY - startY; + + endDrag(); + modalEl.removeClass("tn-task-modal__sheet--dragging"); + + if (currentOffset > DISMISS_THRESHOLD_PX || effectiveVelocity > FLING_VELOCITY) { + modalEl.addClass("tn-task-modal__sheet--dismissing"); + applyOffset(getMaxHeight()); + window.setTimeout(() => { + modalEl.removeClass("tn-task-modal__sheet--dismissing"); + setSheetOffset(modalEl, 0); + onDismiss(); + }, 200); + return; + } + + if (currentOffset > 0) { + if (currentSnap === "full" && currentOffset > COLLAPSE_DRAG_THRESHOLD_PX) { + snapTo("partial"); + } else { + snapTo(currentSnap); + } + return; + } + + if (effectiveVelocity < -FLING_VELOCITY || draggedUp > EXPAND_DRAG_THRESHOLD_PX) { + snapTo("full"); + return; + } + + if ( + currentSnap === "full" && + (effectiveVelocity > FLING_VELOCITY / 2 || draggedDown > COLLAPSE_DRAG_THRESHOLD_PX) + ) { + snapTo("partial"); + return; + } + + snapTo(currentSnap); + }; + + const onPointerUp = (event: PointerEvent): void => { + finishDrag(event); + }; + + const onPointerCancel = (event: PointerEvent): void => { + if (!dragging || event.pointerId !== activePointerId) return; + // Treat a cancelled gesture (e.g. an interrupting system gesture) the + // same as a release, snapping back rather than leaving the sheet + // stranded mid-drag. + finishDrag(event); + }; + + handle.addEventListener("pointerdown", onPointerDown); + + // Reveal the sheet at its partial resting height. Removing "--pending" + // and applying the snap max-height together lets the sheet slide in + // without first flashing at full height. + window.setTimeout(() => { + modalEl.removeClass("tn-task-modal__sheet--pending"); + snapTo("partial"); + }, 0); + + return { + destroy: () => { + keyboardAvoidance.destroy(); + handle.removeEventListener("pointerdown", onPointerDown); + window.removeEventListener("pointermove", onPointerMove); + window.removeEventListener("pointerup", onPointerUp); + window.removeEventListener("pointercancel", onPointerCancel); + modalEl.removeClass( + "tn-task-modal__sheet--pending", + "tn-task-modal__sheet--dragging", + "tn-task-modal__sheet--snapping", + "tn-task-modal__sheet--dismissing" + ); + setSheetOffset(modalEl, 0); + modalEl.style.removeProperty("--tn-sheet-max-height"); + modalEl.style.removeProperty("--tn-sheet-bottom-inset"); + }, + expandToFull, + }; +} + +export function createTaskModalSheetHandle(container: HTMLElement): HTMLElement { + const handle = container.createDiv("tn-task-modal__sheet-handle"); + handle.createDiv("tn-task-modal__sheet-handle-bar"); + handle.setAttribute("role", "presentation"); + return handle; +} diff --git a/src/modals/taskModalSheetKeyboard.ts b/src/modals/taskModalSheetKeyboard.ts new file mode 100644 index 000000000..22b1c6883 --- /dev/null +++ b/src/modals/taskModalSheetKeyboard.ts @@ -0,0 +1,327 @@ +const KEYBOARD_INSET_THRESHOLD_PX = 48; +const TOOLBAR_INSET_THRESHOLD_PX = 8; +const SHEET_TOP_SAFE_INSET_PX = 16; +const REFRESH_DELAYS_MS = [0, 150, 350]; +export const MOBILE_TOOLBAR_SELECTOR = ".mobile-toolbar"; + +export interface TaskModalSheetKeyboardOptions { + modalEl: HTMLElement; + getSnapMaxHeight: () => string; + isDragging: () => boolean; +} + +export interface TaskModalSheetKeyboardController { + destroy: () => void; + refresh: () => void; +} + +export interface TaskModalSheetBottomInsetAdjustment { + bottomInsetPx: number; +} + +/** @deprecated Use TaskModalSheetBottomInsetAdjustment */ +export type TaskModalSheetKeyboardAdjustment = TaskModalSheetBottomInsetAdjustment; + +export function computeSheetKeyboardInset( + layoutViewportHeight: number, + visualViewport: Pick | null | undefined +): number { + if (!visualViewport || !Number.isFinite(visualViewport.height)) { + return 0; + } + + const inset = layoutViewportHeight - (visualViewport.offsetTop + visualViewport.height); + return inset > KEYBOARD_INSET_THRESHOLD_PX ? Math.round(inset) : 0; +} + +export function isMobileToolbarVisible(toolbar: HTMLElement): boolean { + const win = toolbar.ownerDocument.defaultView; + if (!win) { + return false; + } + + const style = win.getComputedStyle(toolbar); + if (style.display === "none" || style.visibility === "hidden") { + return false; + } + + const rect = toolbar.getBoundingClientRect(); + return rect.height > TOOLBAR_INSET_THRESHOLD_PX && rect.width > 0; +} + +export function computeMobileToolbarInset( + layoutViewportHeight: number, + toolbar: Pick | null | undefined +): number { + if (!toolbar || !isMobileToolbarVisible(toolbar as HTMLElement)) { + return 0; + } + + const rect = toolbar.getBoundingClientRect(); + // Only trust a toolbar that is actually docked within the visible + // viewport. A stale/detached clone sharing the same class name (or one + // not yet laid out, e.g. top: 0 before it snaps to the bottom) would + // otherwise report a huge, bogus inset that pushes the whole sheet + // off-screen above the top of the viewport instead of just resizing it. + if (rect.top < 0 || rect.top >= layoutViewportHeight || rect.height <= 0) { + return 0; + } + + const inset = layoutViewportHeight - rect.top; + return inset > TOOLBAR_INSET_THRESHOLD_PX ? Math.round(inset) : 0; +} + +export function computeSheetBottomInset( + layoutViewportHeight: number, + visualViewport: Pick | null | undefined, + toolbar?: Pick | null +): number { + const rawInset = Math.max( + computeSheetKeyboardInset(layoutViewportHeight, visualViewport), + computeMobileToolbarInset(layoutViewportHeight, toolbar ?? null) + ); + + // Defensive clamp: no matter what the individual measurements report, + // never let the combined inset push the sheet's translateY past the top + // safe area. A bug (or an unexpected host environment) in either + // measurement should degrade to "sheet sits near the top" rather than + // "sheet is translated fully off-screen and invisible". + const maxSaneInset = Math.max(0, layoutViewportHeight - SHEET_TOP_SAFE_INSET_PX); + return Math.min(rawInset, maxSaneInset); +} + +export function computeSheetBottomInsetMaxHeightPx( + layoutViewportHeight: number, + bottomInsetPx: number, + visualViewport?: Pick | null +): number { + const safeTop = Math.max(visualViewport?.offsetTop ?? 0, 0) + SHEET_TOP_SAFE_INSET_PX; + const visibleBottom = layoutViewportHeight - bottomInsetPx; + return Math.max(120, Math.floor(visibleBottom - safeTop)); +} + +/** @deprecated Use computeSheetBottomInsetMaxHeightPx */ +export function computeSheetKeyboardMaxHeightPx( + visualViewport: Pick, + topSafeInsetPx = SHEET_TOP_SAFE_INSET_PX +): number { + const safeTop = Math.max(visualViewport.offsetTop, 0) + topSafeInsetPx; + return Math.max(120, Math.floor(visualViewport.height - safeTop)); +} + +export function computeSheetBottomInsetAdjustment( + layoutViewportHeight: number, + visualViewport: Pick | null | undefined, + toolbar?: Pick | null +): TaskModalSheetBottomInsetAdjustment { + return { + bottomInsetPx: computeSheetBottomInset(layoutViewportHeight, visualViewport, toolbar), + }; +} + +/** @deprecated Use computeSheetBottomInsetAdjustment */ +export function computeSheetKeyboardAdjustment( + layoutViewportHeight: number, + visualViewport: Pick | null | undefined +): TaskModalSheetBottomInsetAdjustment { + const adjustment = computeSheetBottomInsetAdjustment( + layoutViewportHeight, + visualViewport, + null + ); + return { + bottomInsetPx: adjustment.bottomInsetPx, + }; +} + +function setSheetBottomInset(modalEl: HTMLElement, insetPx: number): void { + const value = insetPx > 0 ? `${insetPx}px` : "0"; + if (typeof modalEl.setCssProps === "function") { + modalEl.setCssProps({ "--tn-sheet-bottom-inset": value }); + return; + } + + modalEl.style.setProperty("--tn-sheet-bottom-inset", value); +} + +export function attachTaskModalSheetKeyboardAvoidance( + options: TaskModalSheetKeyboardOptions +): TaskModalSheetKeyboardController { + const { modalEl, getSnapMaxHeight, isDragging } = options; + const doc = modalEl.ownerDocument; + const win = doc.defaultView || window; + const visualViewport = win.visualViewport; + const refreshTimers: number[] = []; + let toolbarResizeObserver: ResizeObserver | undefined; + let toolbarMutationObserver: MutationObserver | undefined; + let observedToolbar: HTMLElement | null = null; + + if (!visualViewport) { + return { destroy: () => undefined, refresh: () => undefined }; + } + + let bottomInsetActive = false; + let frozenSheetHeightPx: number | null = null; + + const freezeSheetHeight = (): void => { + if ( + bottomInsetActive || + modalEl.classList.contains("tn-task-modal__sheet--pending") + ) { + return; + } + + const heightPx = Math.round(modalEl.getBoundingClientRect().height); + if (heightPx <= 0) { + return; + } + + frozenSheetHeightPx = heightPx; + modalEl.style.setProperty("--tn-sheet-max-height", `${heightPx}px`); + }; + + const getToolbar = (): HTMLElement | null => doc.querySelector(MOBILE_TOOLBAR_SELECTOR); + + const applyAdjustment = (): void => { + if (isDragging()) return; + + const adjustment = computeSheetBottomInsetAdjustment( + win.innerHeight, + visualViewport, + getToolbar() + ); + const nextBottomInsetActive = adjustment.bottomInsetPx > 0; + + if (nextBottomInsetActive) { + // Prefer the height captured before the keyboard animation started. + // Reading getBoundingClientRect() here can return an already-shrunk + // value because 100dvh updates as soon as the visual viewport + // resizes on iOS/Android. + if ( + !bottomInsetActive && + !modalEl.classList.contains("tn-task-modal__sheet--pending") + ) { + const heightPx = + frozenSheetHeightPx ?? Math.round(modalEl.getBoundingClientRect().height); + if (heightPx > 0) { + frozenSheetHeightPx = heightPx; + modalEl.style.setProperty("--tn-sheet-max-height", `${heightPx}px`); + } + } + setSheetBottomInset(modalEl, adjustment.bottomInsetPx); + modalEl.addClass("tn-task-modal__sheet--bottom-inset"); + } else if (bottomInsetActive) { + setSheetBottomInset(modalEl, 0); + frozenSheetHeightPx = null; + modalEl.style.setProperty("--tn-sheet-max-height", getSnapMaxHeight()); + modalEl.removeClass("tn-task-modal__sheet--bottom-inset"); + } else { + setSheetBottomInset(modalEl, 0); + } + + bottomInsetActive = nextBottomInsetActive; + }; + + const bindToolbarObserver = (): void => { + const toolbar = getToolbar(); + if (toolbar === observedToolbar) { + return; + } + + if (toolbarResizeObserver && observedToolbar) { + toolbarResizeObserver.unobserve(observedToolbar); + } + + observedToolbar = toolbar; + if (!toolbar || typeof ResizeObserver === "undefined") { + return; + } + + if (!toolbarResizeObserver) { + toolbarResizeObserver = new ResizeObserver(() => { + applyAdjustment(); + }); + } + + toolbarResizeObserver.observe(toolbar); + }; + + const scheduleRefresh = (): void => { + for (const delay of REFRESH_DELAYS_MS) { + const timer = win.setTimeout(() => { + refreshTimers.splice(refreshTimers.indexOf(timer), 1); + bindToolbarObserver(); + applyAdjustment(); + }, delay); + refreshTimers.push(timer); + } + }; + + const onViewportChange = (): void => { + applyAdjustment(); + }; + + const onFocusIn = (event: FocusEvent): void => { + const target = event.target; + if (!(target instanceof win.Node) || !modalEl.contains(target)) { + return; + } + // Freeze before the OS keyboard animation shrinks dvh on the next frame. + freezeSheetHeight(); + scheduleRefresh(); + }; + + visualViewport.addEventListener("resize", onViewportChange); + visualViewport.addEventListener("scroll", onViewportChange); + modalEl.addEventListener("focusin", onFocusIn, true); + + if (typeof MutationObserver !== "undefined") { + toolbarMutationObserver = new MutationObserver((mutations) => { + // Skip mutations caused by our own updates to modalEl (class/style + // changes applied by applyAdjustment itself) - reacting to those + // would create a self-triggering feedback loop since modalEl lives + // inside the observed doc.body subtree. + const relevant = mutations.some((mutation) => { + const target = mutation.target; + return !(target instanceof win.Node) || !modalEl.contains(target); + }); + if (!relevant) return; + + bindToolbarObserver(); + applyAdjustment(); + }); + toolbarMutationObserver.observe(doc.body, { + childList: true, + subtree: true, + attributes: true, + attributeFilter: ["class", "style", "hidden"], + }); + } + + bindToolbarObserver(); + applyAdjustment(); + + return { + refresh: () => { + bindToolbarObserver(); + applyAdjustment(); + }, + destroy: () => { + for (const timer of refreshTimers) { + win.clearTimeout(timer); + } + refreshTimers.length = 0; + visualViewport.removeEventListener("resize", onViewportChange); + visualViewport.removeEventListener("scroll", onViewportChange); + modalEl.removeEventListener("focusin", onFocusIn, true); + toolbarMutationObserver?.disconnect(); + if (toolbarResizeObserver && observedToolbar) { + toolbarResizeObserver.unobserve(observedToolbar); + } + toolbarResizeObserver?.disconnect(); + frozenSheetHeightPx = null; + modalEl.removeClass("tn-task-modal__sheet--bottom-inset"); + setSheetBottomInset(modalEl, 0); + }, + }; +} diff --git a/src/modals/taskModalSidebar.ts b/src/modals/taskModalSidebar.ts new file mode 100644 index 000000000..f03c0a3f1 --- /dev/null +++ b/src/modals/taskModalSidebar.ts @@ -0,0 +1,139 @@ +import { setIcon } from "obsidian"; +import type { ModalFieldsConfigLike } from "./taskModalFieldConfig"; +import { shouldShowFieldForModal } from "./taskModalFieldConfig"; + +export interface TaskModalSidebarRowSpec { + id: string; + iconName: string; + label: string; + value: string; + hasValue: boolean; + onClick: (event: UIEvent) => void; +} + +export interface CreateTaskModalSidebarOptions { + container: HTMLElement; + rows: readonly TaskModalSidebarRowSpec[]; +} + +export function createTaskModalSidebar(options: CreateTaskModalSidebarOptions): HTMLElement { + const sidebar = options.container.createDiv("tn-task-modal__sidebar"); + + for (const row of options.rows) { + createTaskModalSidebarRow(sidebar, row); + } + + return sidebar; +} + +export function createTaskModalSidebarRow( + container: HTMLElement, + spec: TaskModalSidebarRowSpec +): HTMLElement { + const row = container.createDiv("tn-task-modal__sidebar-row"); + row.classList.toggle("has-value", spec.hasValue); + row.setAttribute("data-field-id", spec.id); + row.setAttribute("role", "button"); + row.setAttribute("tabindex", "0"); + + const iconWrap = row.createSpan("tn-task-modal__sidebar-row-icon"); + setIcon(iconWrap, spec.iconName); + + const body = row.createDiv("tn-task-modal__sidebar-row-body"); + body.createDiv("tn-task-modal__sidebar-row-label").setText(spec.label); + body.createDiv("tn-task-modal__sidebar-row-value").setText(spec.value); + + const chevron = row.createSpan("tn-task-modal__sidebar-row-chevron"); + setIcon(chevron, "chevron-right"); + + const activate = (event: UIEvent): void => { + event.preventDefault(); + spec.onClick(event); + }; + + row.addEventListener("click", activate); + row.addEventListener("keydown", (event) => { + if (event.key === "Enter" || event.key === " ") { + activate(event); + } + }); + + return row; +} + +export interface CreateTaskModalSidebarOrgSectionResult { + section: HTMLElement; + row: HTMLElement; + listElement: HTMLElement; +} + +/** + * Desktop edit sidebar section: a property row with its organization list + * rendered directly underneath instead of in a shared footer container. + */ +export function createTaskModalSidebarOrgSection( + container: HTMLElement, + spec: TaskModalSidebarRowSpec +): CreateTaskModalSidebarOrgSectionResult { + const section = container.createDiv("tn-task-modal__sidebar-section"); + const row = createTaskModalSidebarRow(section, spec); + const listElement = section.createDiv("task-projects-list tn-task-modal__org-list"); + + return { section, row, listElement }; +} + +export interface CreateTaskModalPropertySectionsOptions { + container: HTMLElement; + groups: readonly (readonly TaskModalSidebarRowSpec[])[]; +} + +/** + * Renders property rows in a single grouped card for the mobile bottom sheet. + * Full-width divider elements sit between rows so separators stay edge-to-edge + * regardless of row padding or icon width. + */ +export function createTaskModalPropertySections( + options: CreateTaskModalPropertySectionsOptions +): HTMLElement { + const wrapper = options.container.createDiv("tn-task-modal__mobile-sections"); + const rows = options.groups.flat(); + if (!rows.length) return wrapper; + + const card = wrapper.createDiv("tn-task-modal__mobile-group"); + for (const row of rows) { + if (card.childElementCount > 0) { + card.createDiv("tn-task-modal__sidebar-row-divider"); + } + createTaskModalSidebarRow(card, row); + } + + return wrapper; +} + +export function updateTaskModalSidebarRow( + sidebar: HTMLElement | undefined, + rowId: string, + value: string, + hasValue: boolean +): void { + const row = sidebar?.querySelector(`[data-field-id="${rowId}"]`); + if (!row) return; + + row.classList.toggle("has-value", hasValue); + const valueEl = row.querySelector(".tn-task-modal__sidebar-row-value"); + if (valueEl) { + valueEl.setText(value); + } +} + +export interface TaskModalSidebarFieldVisibilityOptions { + config: ModalFieldsConfigLike | undefined; + isCreationMode: boolean; +} + +export function isTaskModalSidebarFieldVisible( + fieldId: string, + options: TaskModalSidebarFieldVisibilityOptions +): boolean { + return shouldShowFieldForModal(fieldId, options.config, options.isCreationMode); +} diff --git a/src/modals/taskModalSuggests.ts b/src/modals/taskModalSuggests.ts index a97444a22..c69033e65 100644 --- a/src/modals/taskModalSuggests.ts +++ b/src/modals/taskModalSuggests.ts @@ -21,6 +21,18 @@ function openSuggestionsOnFieldSelection( input.addEventListener("click", openSuggestions); } +export function attachTaskModalContextSuggest( + app: App, + inputEl: HTMLInputElement, + plugin: TaskNotesPlugin +): ContextSuggest { + const suggest = new ContextSuggest(app, inputEl, plugin); + window.setTimeout(() => { + suggest.open(); + }, 0); + return suggest; +} + export class ContextSuggest extends AbstractInputSuggest { private plugin: TaskNotesPlugin; private input: HTMLInputElement; @@ -36,7 +48,7 @@ export class ContextSuggest extends AbstractInputSuggest { const currentValues = this.input.value.split(",").map((value: string) => value.trim()); const currentQuery = currentValues[currentValues.length - 1]; - const contexts = this.plugin.cacheManager.getAllContexts(); + const contexts = this.plugin.cacheManager.getAllContexts?.() ?? []; const alreadySelected = currentValues.slice(0, -1); return contexts .filter((context) => context && typeof context === "string") @@ -76,6 +88,18 @@ interface TagSuggestion { toString(): string; } +export function attachTaskModalTagSuggest( + app: App, + inputEl: HTMLInputElement, + plugin: TaskNotesPlugin +): TagSuggest { + const suggest = new TagSuggest(app, inputEl, plugin); + window.setTimeout(() => { + suggest.open(); + }, 0); + return suggest; +} + export class TagSuggest extends AbstractInputSuggest { private plugin: TaskNotesPlugin; private input: HTMLInputElement; @@ -92,7 +116,7 @@ export class TagSuggest extends AbstractInputSuggest { const currentQuery = currentValues[currentValues.length - 1]; const tags = filterTagsForTaskModalSuggestions( - this.plugin.cacheManager.getAllTags(), + this.plugin.cacheManager.getAllTags?.() ?? [], this.plugin.settings ); const alreadySelected = currentValues.slice(0, -1); @@ -127,6 +151,176 @@ export class TagSuggest extends AbstractInputSuggest { } } +export interface ChipEditorSuggestCallbacks { + getSelectedValues: () => string[]; + onAdd: (value: string) => void; +} + +interface ChipEditorSuggestLike { + getSuggestions(query: string): Promise; + open(): void; + close(): void; +} + +async function openChipEditorSuggestWhenAvailable( + suggest: ChipEditorSuggestLike, + query: string +): Promise { + const suggestions = await suggest.getSuggestions(query); + if (suggestions.length === 0) { + suggest.close(); + return; + } + suggest.open(); +} + +function bindChipEditorSuggestHandlers( + input: HTMLInputElement, + suggest: ChipEditorSuggestLike +): void { + const tryOpen = (): void => { + void openChipEditorSuggestWhenAvailable(suggest, input.value); + }; + input.addEventListener("focus", tryOpen); + input.addEventListener("click", tryOpen); + input.addEventListener("input", tryOpen); +} + +export function attachChipEditorContextSuggest( + app: App, + inputEl: HTMLInputElement, + plugin: TaskNotesPlugin, + callbacks: ChipEditorSuggestCallbacks +): ChipEditorContextSuggest { + const suggest = new ChipEditorContextSuggest(app, inputEl, plugin, callbacks); + window.setTimeout(() => { + void openChipEditorSuggestWhenAvailable(suggest, inputEl.value); + }, 0); + return suggest; +} + +export function attachChipEditorTagSuggest( + app: App, + inputEl: HTMLInputElement, + plugin: TaskNotesPlugin, + callbacks: ChipEditorSuggestCallbacks +): ChipEditorTagSuggest { + const suggest = new ChipEditorTagSuggest(app, inputEl, plugin, callbacks); + window.setTimeout(() => { + void openChipEditorSuggestWhenAvailable(suggest, inputEl.value); + }, 0); + return suggest; +} + +class ChipEditorContextSuggest extends AbstractInputSuggest { + private plugin: TaskNotesPlugin; + private input: HTMLInputElement; + private callbacks: ChipEditorSuggestCallbacks; + + constructor( + app: App, + inputEl: HTMLInputElement, + plugin: TaskNotesPlugin, + callbacks: ChipEditorSuggestCallbacks + ) { + super(app, inputEl); + this.plugin = plugin; + this.input = inputEl; + this.callbacks = callbacks; + bindChipEditorSuggestHandlers(this.input, this); + } + + public async getSuggestions(_query: string): Promise { + const query = this.input.value.trim(); + const selected = new Set(this.callbacks.getSelectedValues()); + const contexts = this.plugin.cacheManager.getAllContexts?.() ?? []; + + return contexts + .filter((context) => context && typeof context === "string") + .filter( + (context) => + !selected.has(context) && + (!query || context.toLowerCase().includes(query.toLowerCase())) + ) + .slice(0, 10) + .map((context) => ({ + value: context, + display: context, + type: "context" as const, + toString() { + return this.value; + }, + })); + } + + public renderSuggestion(contextSuggestion: ContextSuggestion, el: HTMLElement): void { + el.textContent = contextSuggestion.display; + } + + public selectSuggestion(contextSuggestion: ContextSuggestion): void { + this.callbacks.onAdd(contextSuggestion.value); + this.input.value = ""; + this.input.focus(); + void openChipEditorSuggestWhenAvailable(this, this.input.value); + } +} + +class ChipEditorTagSuggest extends AbstractInputSuggest { + private plugin: TaskNotesPlugin; + private input: HTMLInputElement; + private callbacks: ChipEditorSuggestCallbacks; + + constructor( + app: App, + inputEl: HTMLInputElement, + plugin: TaskNotesPlugin, + callbacks: ChipEditorSuggestCallbacks + ) { + super(app, inputEl); + this.plugin = plugin; + this.input = inputEl; + this.callbacks = callbacks; + bindChipEditorSuggestHandlers(this.input, this); + } + + public async getSuggestions(_query: string): Promise { + const query = this.input.value.trim(); + const selected = new Set(this.callbacks.getSelectedValues()); + const tags = filterTagsForTaskModalSuggestions( + this.plugin.cacheManager.getAllTags?.() ?? [], + this.plugin.settings + ); + + return tags + .filter((tag) => tag && typeof tag === "string") + .filter( + (tag) => + !selected.has(tag) && + (!query || tag.toLowerCase().includes(query.toLowerCase())) + ) + .slice(0, 10) + .map((tag) => ({ + value: tag, + display: tag, + type: "tag" as const, + toString() { + return this.value; + }, + })); + } + + public renderSuggestion(tagSuggestion: TagSuggestion, el: HTMLElement): void { + el.textContent = tagSuggestion.display; + } + + public selectSuggestion(tagSuggestion: TagSuggestion): void { + this.callbacks.onAdd(tagSuggestion.value); + this.input.value = ""; + this.input.focus(); + void openChipEditorSuggestWhenAvailable(this, this.input.value); + } +} + interface UserFieldSuggestion { value: string; display: string; diff --git a/styles/modal-bem.css b/styles/modal-bem.css index be1068781..06bab342c 100644 --- a/styles/modal-bem.css +++ b/styles/modal-bem.css @@ -920,7 +920,7 @@ font-family: var(--font-monospace); } -.tasknotes-plugin .modal-button-container { +.tasknotes-plugin .modal-button-container:not(.tn-task-modal__icon-button-bar) { display: flex; justify-content: flex-end; gap: var(--size-4-2); diff --git a/styles/task-modal-sheet.css b/styles/task-modal-sheet.css new file mode 100644 index 000000000..baafc1b07 --- /dev/null +++ b/styles/task-modal-sheet.css @@ -0,0 +1,716 @@ +/* ===================================================================== + TASK MODAL - CHIP ROW + ===================================================================== */ + +.tasknotes-plugin .tn-task-modal__chip-row { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--size-4-2); + margin-bottom: var(--size-4-3); + padding: var(--size-4-1) 0; +} + +.tasknotes-plugin .tn-task-modal__chip { + display: inline-flex; + align-items: center; + gap: var(--size-4-1); + flex: 0 0 auto; + max-width: 220px; + padding: var(--size-4-1) var(--size-4-2); + border: 1px solid var(--background-modifier-border); + border-radius: 999px; + background: var(--background-secondary); + color: var(--text-muted); + cursor: var(--cursor-link, pointer); + transition: background-color 0.2s ease, border-color 0.2s ease, color 0.2s ease; + position: relative; +} + +.tasknotes-plugin .tn-task-modal__chip:hover { + background: var(--background-modifier-hover); + color: var(--text-normal); +} + +.tasknotes-plugin .tn-task-modal__chip.has-value { + border-color: var(--color-accent); + color: var(--text-normal); + background: var(--background-modifier-hover); +} + +.tasknotes-plugin .tn-task-modal__chip.has-value::after { + content: ""; + position: absolute; + top: -2px; + right: -2px; + width: 8px; + height: 8px; + background: var(--color-accent); + border: 2px solid var(--background-primary); + border-radius: 50%; +} + +.tasknotes-plugin .tn-task-modal__chip-icon { + display: inline-flex; + align-items: center; + justify-content: center; + flex-shrink: 0; +} + +.tasknotes-plugin .tn-task-modal__chip-icon .icon { + width: 14px; + height: 14px; +} + +.tasknotes-plugin .tn-task-modal__chip-label { + font-size: var(--font-ui-smaller); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.tasknotes-plugin .tn-task-modal__chip-break { + flex-basis: 100%; + height: 0; +} + +/* ===================================================================== + MOBILE GROUPED PROPERTY SECTIONS + Vertically stacked, differentiated "card" groups used on the mobile + bottom sheet in place of the horizontal chip row. + ===================================================================== */ + +.tasknotes-plugin .tn-task-modal__mobile-sections { + display: flex; + flex-direction: column; + gap: var(--size-4-3); + margin-bottom: var(--size-4-3); +} + +.tasknotes-plugin .tn-task-modal__mobile-group { + display: flex; + flex-direction: column; + border-radius: var(--radius-l); + background: var(--background-primary); + overflow: hidden; +} + +.tasknotes-plugin .tn-task-modal__mobile-group .tn-task-modal__sidebar-row { + padding: var(--size-4-3); +} + +.tasknotes-plugin .tn-task-modal__mobile-group .tn-task-modal__org-empty-row .tn-task-modal__sidebar-row { + padding: var(--size-4-3); +} + +.tasknotes-plugin .tn-task-modal__mobile-group .tn-task-modal__org-field { + margin-bottom: 0; +} + +.tasknotes-plugin .tn-task-modal__mobile-group .tn-task-modal__org-header { + margin-bottom: 0; + padding: var(--size-4-3) var(--size-4-3) 0; +} + +/* Empty organization lists sit directly under their sidebar row with no extra + gap. The shared .task-projects-list rule adds margin-top even when empty, + which made org-field rows (projects/subtasks/dependencies) look farther + apart than standalone property rows in the mobile sheet card. */ +.tasknotes-plugin .tn-task-modal__mobile-group .tn-task-modal__org-list { + margin-top: 0; + margin-bottom: 0; +} + +.tasknotes-plugin .tn-task-modal__mobile-group .tn-task-modal__org-list:not(:empty) { + margin: var(--size-4-1) 0 var(--size-4-3); + padding: 0 var(--size-4-3); +} + +.tasknotes-plugin .tn-task-modal__sidebar-row-divider { + display: block; + height: 1px; + margin: 0 var(--size-4-3); + padding: 0; + border: none; + background: var(--background-modifier-border); + flex-shrink: 0; +} + +/* ===================================================================== + ORGANIZATION ROWS + ===================================================================== */ + +.tasknotes-plugin .tn-task-modal__org-field { + margin-bottom: var(--size-4-3); +} + +.tasknotes-plugin .tn-task-modal__org-empty-row[hidden] { + display: none; +} + +.tasknotes-plugin .tn-task-modal__org-header { + display: flex; + align-items: center; + gap: var(--size-4-2); + margin-bottom: var(--size-4-2); +} + +/* Two classes outweigh the UA `[hidden]` attribute selector, so an explicit + override is required to actually hide the header (and its child title/add + buttons) when the section has no items. Without this, both the empty + sidebar row and the populated header row rendered simultaneously. */ +.tasknotes-plugin .tn-task-modal__org-header[hidden] { + display: none; +} + +.tasknotes-plugin .tn-task-modal__org-header-label { + width: 100%; + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--size-4-2); + padding: var(--size-4-2) var(--size-4-3); + border: 1px solid var(--background-modifier-border); + border-radius: var(--radius-m); + background: var(--background-secondary); + color: var(--text-normal); + flex: 1; +} + +.tasknotes-plugin .tn-task-modal__org-header-count { + color: var(--text-muted); + font-size: var(--font-ui-smaller); +} + +.tasknotes-plugin .tn-task-modal__org-add-button { + display: inline-flex; + align-items: center; + justify-content: center; + width: 36px; + height: 36px; + border-radius: var(--radius-m); + border: 1px solid var(--background-modifier-border); + background: var(--background-secondary); + cursor: var(--cursor-link, pointer); +} + +.tasknotes-plugin .tn-task-modal__org-add-button:hover { + background: var(--background-modifier-hover); +} + +/* ===================================================================== + ICON BUTTON BAR + ===================================================================== */ + +.tasknotes-plugin .modal-button-container.tn-task-modal__icon-button-bar { + display: flex; + flex-direction: row; + flex-wrap: nowrap; + align-items: center; + justify-content: space-between; + width: 100%; + box-sizing: border-box; + gap: var(--size-4-2); +} + +.tasknotes-plugin .tn-task-modal__icon-button-leading, +.tasknotes-plugin .tn-task-modal__icon-button-trailing { + display: inline-flex; + flex-direction: row; + flex-wrap: nowrap; + align-items: center; + gap: var(--size-4-2); + flex-shrink: 0; +} + +.tasknotes-plugin .tn-task-modal__icon-button-leading:empty { + display: none; +} + +.tasknotes-plugin .tn-task-modal__icon-button-trailing { + margin-left: auto; +} + +.tasknotes-plugin .tn-task-modal__icon-button { + display: inline-flex; + align-items: center; + justify-content: center; + width: 40px; + height: 40px; + border-radius: var(--radius-m); + cursor: var(--cursor-link, pointer); + box-shadow: none; +} + +.tasknotes-plugin .tn-task-modal__icon-button:not(.mod-cta) { + border: 1px solid var(--background-modifier-border); + background: transparent; + color: var(--text-normal); +} + +.tasknotes-plugin .tn-task-modal__icon-button:not(.mod-cta):hover, +.tasknotes-plugin .tn-task-modal__icon-button:not(.mod-cta):active { + background: var(--background-modifier-hover); + box-shadow: none; +} + +.tasknotes-plugin .tn-task-modal__icon-button.mod-cta { + background: var(--interactive-accent); + color: var(--text-on-accent); + border-color: var(--interactive-accent); + box-shadow: none; +} + +.tasknotes-plugin .tn-task-modal__icon-button.mod-cta:hover, +.tasknotes-plugin .tn-task-modal__icon-button.mod-cta:active { + background: var(--interactive-accent-hover); + border-color: var(--interactive-accent-hover); + color: var(--text-on-accent); + box-shadow: none; +} + +.tasknotes-plugin .tn-task-modal__icon-button.mod-cta .tn-task-modal__icon-button-icon, +.tasknotes-plugin .tn-task-modal__icon-button.mod-cta .icon { + color: var(--text-on-accent); +} + +.tasknotes-plugin .tn-task-modal__icon-button.mod-warning { + color: var(--text-error); +} + +.tasknotes-plugin .tn-task-modal__icon-button-icon { + display: inline-flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + line-height: 0; +} + +.tasknotes-plugin .tn-task-modal__icon-button-icon .icon { + width: 18px; + height: 18px; +} + +.tasknotes-plugin .tn-task-modal__icon-button-label { + display: none; + font-size: var(--font-ui-smaller); + line-height: 1; + white-space: nowrap; +} + +body:not(.is-mobile) .tasknotes-plugin .tn-task-modal__icon-button { + width: auto; + height: auto; + min-height: 40px; + padding: 0 var(--size-4-3); + gap: var(--size-4-1); +} + +body:not(.is-mobile) .tasknotes-plugin .tn-task-modal__icon-button-label { + display: inline-flex; + align-items: center; +} + +/* ===================================================================== + EDIT DESKTOP SIDEBAR + ===================================================================== */ + +/* The base modal-content rule caps width at 600px, which fights the wider + .modal width set below and leaves a large empty gap next to the two-pane + layout. Let the content fill whatever width the modal itself is given. */ +.tasknotes-plugin.minimalist-task-modal.tn-task-modal--edit-desktop .modal-content { + min-width: 100%; + max-width: 100%; +} + +/* Scrollable content area with the action buttons pinned outside it. + Without flex/overflow here, the panes take their natural height, get + clipped by the (overflow: hidden) modal-content, and the button row ends + up squeezed in with the content instead of staying fixed at the bottom. + A little right padding keeps the scrollbar inset from the sidebar, + matching the container's horizontal padding. */ +.tasknotes-plugin.minimalist-task-modal.tn-task-modal--edit-desktop.expanded .tn-task-modal__edit-panes { + flex: 1 1 auto; + min-height: 0; + overflow-y: auto; + overflow-x: hidden; + padding-right: var(--size-4-4); +} + +.tasknotes-plugin.minimalist-task-modal.tn-task-modal--edit-desktop .tn-task-modal__edit-panes { + display: grid; + grid-template-columns: minmax(0, 1fr) 280px; + gap: var(--size-4-4); + min-height: 0; +} + +.tasknotes-plugin.minimalist-task-modal.tn-task-modal--edit-desktop .tn-task-modal__edit-main { + min-width: 0; +} + +.tasknotes-plugin.minimalist-task-modal.tn-task-modal--edit-desktop .tn-task-modal__edit-sidebar-wrap { + border: 1px solid var(--background-modifier-border); + background: var(--background-secondary); + border-radius: var(--radius-m); + padding: var(--size-4-3) var(--size-4-3) var(--size-4-3) var(--size-4-4); + box-sizing: border-box; + /* Height follows sidebar content so top/bottom borders frame the card instead + of stretching flush against the scroll container and getting clipped. */ + align-self: start; + position: sticky; + top: 0; +} + +.tasknotes-plugin .tn-task-modal__sidebar-row { + display: grid; + grid-template-columns: auto minmax(0, 1fr) auto; + gap: var(--size-4-2); + align-items: center; + width: 100%; + padding: var(--size-4-2) 0; + border: none; + background: transparent; + text-align: left; + cursor: var(--cursor-link, pointer); +} + +.tasknotes-plugin .tn-task-modal__sidebar-row-icon { + display: inline-flex; + align-items: center; + justify-content: center; + width: 18px; + height: 18px; + flex-shrink: 0; + align-self: center; +} + +.tasknotes-plugin .tn-task-modal__sidebar-row-icon .icon, +.tasknotes-plugin .tn-task-modal__sidebar-row-icon .svg-icon, +.tasknotes-plugin .tn-task-modal__sidebar-row-icon svg { + width: 18px; + height: 18px; +} + +.tasknotes-plugin .tn-task-modal__sidebar-row-body { + min-width: 0; +} + +.tasknotes-plugin .tn-task-modal__sidebar > * + * { + border-top: 1px solid var(--background-modifier-border); +} + +/* Empty organization lists sit directly under their sidebar row with no extra + gap. The shared .task-projects-list rule adds margin-top even when empty, + which made dividers after Projects/Subtasks/Dependencies look farther apart + than dividers between standalone property rows. */ +.tasknotes-plugin .tn-task-modal__sidebar-section .tn-task-modal__org-list { + margin-top: 0; + margin-bottom: 0; +} + +.tasknotes-plugin .tn-task-modal__sidebar-section .tn-task-modal__org-list:not(:empty) { + margin-top: var(--size-4-1); + margin-bottom: var(--size-4-1); + padding-left: calc(18px + var(--size-4-2)); + max-height: none; + overflow: visible; +} + +.tasknotes-plugin .tn-task-modal__sidebar-section .tn-task-modal__org-list .task-project-item { + align-items: flex-start; +} + +.tasknotes-plugin .tn-task-modal__sidebar-section .tn-task-modal__org-list .task-project-remove { + display: inline-flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + align-self: flex-start; + margin-top: 0; +} + +.tasknotes-plugin .tn-task-modal__sidebar-section .tn-task-modal__org-list .task-project-item--task-card .task-project-remove { + margin-top: var(--tn-spacing-md); +} + +.tasknotes-plugin .tn-task-modal__sidebar-row-label { + font-size: var(--font-ui-smaller); + color: var(--text-muted); +} + +.tasknotes-plugin .tn-task-modal__sidebar-row-value { + font-size: var(--font-ui-small); + color: var(--text-normal); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +/* Rows without a placeholder value (e.g. recurrence/reminders before either + is set) render an empty value element rather than repeating the row's own + label as a pointless "subtitle". Collapse it so only the label shows. */ +.tasknotes-plugin .tn-task-modal__sidebar-row-value:empty { + display: none; +} + +.tasknotes-plugin .tn-task-modal__sidebar-row-chevron { + display: inline-flex; + align-items: center; + justify-content: center; + width: 14px; + height: 14px; + flex-shrink: 0; + align-self: center; +} + +.tasknotes-plugin .tn-task-modal__sidebar-row-chevron .icon, +.tasknotes-plugin .tn-task-modal__sidebar-row-chevron .svg-icon, +.tasknotes-plugin .tn-task-modal__sidebar-row-chevron svg { + width: 14px; + height: 14px; + color: var(--text-faint); +} + +/* ===================================================================== + DESCRIPTION CLAMP + ===================================================================== */ + +.tasknotes-plugin .tn-task-modal__details--clampable:not(.tn-task-modal__details--expanded) { + max-height: 120px; + overflow: hidden; +} + +.tasknotes-plugin .tn-task-modal__details-toggle { + display: block; + margin: var(--size-4-2) 0 var(--size-4-3); + padding: 0; + border: none; + background: transparent; + color: var(--text-accent); + font-size: var(--font-ui-small); + cursor: var(--cursor-link, pointer); +} + +.tasknotes-plugin .tn-task-modal__details-toggle[hidden] { + display: none; +} + +/* ===================================================================== + MOBILE SHEET + ===================================================================== */ + +body.is-mobile .tasknotes-plugin.minimalist-task-modal.tn-task-modal--sheet, +.tasknotes-plugin.minimalist-task-modal.tn-task-modal--sheet { + display: flex; + align-items: flex-end; + justify-content: center; +} +.tasknotes-plugin.minimalist-task-modal.tn-task-modal--sheet > .modal { + display: flex; + flex-direction: column; + margin-top: auto; + margin-bottom: var(--tn-sheet-bottom-inset, 0px); + margin-inline: auto; + width: min(600px, 100%); + max-width: min(600px, calc(100vw - env(safe-area-inset-left) - env(safe-area-inset-right))); + max-height: min( + var(--tn-sheet-max-height, 60dvh), + calc(100dvh - 16px - env(safe-area-inset-top, 0px)) + ); + border-radius: var(--radius-l) var(--radius-l) 0 0; + overflow: hidden; + background: var(--background-secondary); + transform: translateY(var(--tn-sheet-offset, 0px)); +} + +.tasknotes-plugin.minimalist-task-modal.tn-task-modal--sheet > .modal.tn-task-modal__sheet--bottom-inset { + transition: margin-bottom 0.2s ease, transform 0.2s ease; + /* Keep the snap height fixed while displacing. The default min(..., 100dvh) + cap shrinks with the keyboard and would compress the sheet in place of a + pure upward shift. */ + max-height: var(--tn-sheet-max-height, 60dvh); +} + +.tasknotes-plugin.minimalist-task-modal.tn-task-modal--sheet > .modal > :not(.modal-content) { + flex-shrink: 0; +} + +/* The base task modal caps .modal-content at 480–600px. On the bottom sheet + that leaves large side gutters while the rows inside are full-width, so let + the sheet follow its content width instead of the desktop dialog cap. */ +.tasknotes-plugin.minimalist-task-modal.tn-task-modal--sheet .modal-content { + min-width: 0; + width: 100%; + max-width: 100%; + display: flex; + flex-direction: column; + flex: 1 1 auto; + min-height: 0; + overflow: hidden; +} + +/* Applied synchronously as soon as the sheet layout is chosen, before the + gesture controller has applied its resting partial max-height. Without this, + the modal would render at its default full height for however long content + loading takes, then abruptly jump down to the partial position once the + gesture controller measured the sheet - a visible "opens at the top, then + flickers down" glitch. Keeping the sheet off-screen until that measurement + is ready, then sliding it in with the same transform/transition mechanism + used for drag-snapping, removes the second, competing animation entirely. */ +.tasknotes-plugin.minimalist-task-modal.tn-task-modal--sheet > .modal.tn-task-modal__sheet--pending { + transform: translateY(100%); +} + +/* Recessed page background makes the grouped cards below visually pop, + mirroring a native grouped-list bottom sheet. */ +.tasknotes-plugin.minimalist-task-modal.tn-task-modal--sheet .tn-task-modal__org-header-label, +.tasknotes-plugin.minimalist-task-modal.tn-task-modal--sheet .tn-task-modal__org-add-button { + background: var(--background-primary); + border-color: transparent; +} + +.tasknotes-plugin.minimalist-task-modal.tn-task-modal--sheet .tn-task-modal__org-add-button:hover { + background: var(--background-modifier-hover); +} + +.tasknotes-plugin.minimalist-task-modal.tn-task-modal--sheet .tn-task-modal__icon-button:not(.mod-cta) { + background: var(--background-primary); + border-color: var(--background-modifier-border); +} + +.tasknotes-plugin.minimalist-task-modal.tn-task-modal--sheet > .modal.tn-task-modal__sheet--snapping { + transition: max-height 0.25s ease, transform 0.25s ease; +} + +.tasknotes-plugin.minimalist-task-modal.tn-task-modal--sheet > .modal.tn-task-modal__sheet--dismissing { + transition: transform 0.2s ease; +} + +.tasknotes-plugin .tn-task-modal__sheet-handle { + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + padding: var(--size-4-3) 0; + touch-action: none; + cursor: grab; +} + +.tasknotes-plugin .tn-task-modal__sheet-handle-bar { + width: 28px; + height: 3px; + border-radius: 999px; + background: var(--background-modifier-border-hover); +} + +body.is-mobile .tasknotes-plugin.minimalist-task-modal.tn-task-modal--sheet .minimalist-modal-container, +.tasknotes-plugin.minimalist-task-modal.tn-task-modal--sheet .minimalist-modal-container { + display: flex; + flex-direction: column; + min-height: 0; + flex: 1; + overflow: hidden; +} + +body.is-mobile .tasknotes-plugin.minimalist-task-modal.tn-task-modal--sheet .modal-split-content, +.tasknotes-plugin.minimalist-task-modal.tn-task-modal--sheet .modal-split-content { + flex: 1 1 auto; + min-height: 0; + overflow-y: auto; + overflow-x: hidden; + /* Pull the scrollbar into the container's padding box so content keeps + its right inset while the track sits outside minimalist-modal-container. */ + margin-inline-end: calc(-1 * var(--size-4-4)); + padding-inline-end: var(--size-4-4); +} + +/* The bottom sheet lifts as a whole unit above the keyboard/toolbar via + --tn-sheet-bottom-inset on the modal itself. Do not add extra keyboard + scroll padding inside the content area - that made only the inner content + reflow while the sheet footer stayed behind the toolbar. */ +body.is-mobile .tasknotes-plugin.minimalist-task-modal.tn-task-modal--sheet.is-mobile-keyboard-focused .modal-split-content, +.tasknotes-plugin.minimalist-task-modal.tn-task-modal--sheet.is-mobile-keyboard-focused .modal-split-content { + padding-bottom: 0; + scroll-padding-bottom: 0; +} + +@media (max-width: 600px) { + body.is-mobile .tasknotes-plugin.minimalist-task-modal.tn-task-modal--sheet .modal-split-content, + .tasknotes-plugin.minimalist-task-modal.tn-task-modal--sheet .modal-split-content { + margin-inline-end: calc(-1 * var(--size-4-3)); + padding-inline-end: var(--size-4-3); + } +} + +body.is-mobile .tasknotes-plugin.minimalist-task-modal.tn-task-modal--sheet .tn-task-modal__icon-button-bar, +.tasknotes-plugin.minimalist-task-modal.tn-task-modal--sheet .tn-task-modal__icon-button-bar { + flex-shrink: 0; + background: var(--modal-background); + margin-top: var(--size-4-1); + padding-top: var(--size-4-2); + margin-bottom: max(var(--size-4-1), env(safe-area-inset-bottom)); +} + +/* Content order for stacked mobile layout */ +.tasknotes-plugin.minimalist-task-modal .modal-split-left .title-input-container, +.tasknotes-plugin.minimalist-task-modal .modal-split-left .tn-task-modal__markdown-editor--nlp { + order: 1; +} + +.tasknotes-plugin.minimalist-task-modal .modal-split-right { + order: 2; +} + +.tasknotes-plugin.minimalist-task-modal .modal-split-left .tn-task-modal__chip-row { + order: 3; +} + +.tasknotes-plugin.minimalist-task-modal .modal-split-left .tn-task-modal__mobile-sections { + order: 4; +} + +.tasknotes-plugin.minimalist-task-modal.tn-task-modal--sheet .modal-content, +.tasknotes-plugin.minimalist-task-modal.tn-task-modal--sheet .minimalist-modal-container { + background: var(--modal-background); +} + +.tasknotes-plugin.minimalist-task-modal.tn-task-modal--sheet .tn-task-modal__task-info { + border-top: none; + padding-top: 0; +} + +.tasknotes-plugin.minimalist-task-modal.tn-task-modal--sheet .completions-calendar-container { + overflow: visible; + box-sizing: border-box; +} + +.tasknotes-plugin.minimalist-task-modal.tn-task-modal--sheet .completions-calendar-content, +.tasknotes-plugin.minimalist-task-modal.tn-task-modal--sheet .recurring-calendar { + overflow: visible; +} + +.tasknotes-plugin.minimalist-task-modal .modal-split-left .metadata-container { + order: 7; +} + +/* Desktop edit stacks description, completions, and task information in the + main column; rely on section margin-top instead of the editor's bottom margin. */ +.tasknotes-plugin.minimalist-task-modal.tn-task-modal--edit-desktop .modal-split-right .tn-task-modal__markdown-editor--details { + margin-bottom: 0; +} + +.tasknotes-plugin.minimalist-task-modal .modal-split-left .completions-calendar-container { + order: 6; +} + +.tasknotes-plugin.minimalist-task-modal .modal-split-left .details-container { + order: 5; +} + +@media (min-width: 700px) { + .tasknotes-plugin.minimalist-task-modal.tn-task-modal--edit-desktop .modal, + .tasknotes-plugin.minimalist-task-modal.tn-task-modal--edit-desktop.modal { + width: min(960px, 92vw); + max-width: min(960px, 92vw); + } +} diff --git a/styles/task-modal.css b/styles/task-modal.css index 1135c7770..038f9a2a2 100644 --- a/styles/task-modal.css +++ b/styles/task-modal.css @@ -2,6 +2,222 @@ TASK MODAL - Google Keep/Todoist Inspired ===================================================================== */ +/* Keep vault context/tag suggestions above text-input modals */ +.suggestion-container { + z-index: calc(var(--layer-modal, 30) + 1); +} + +.suggestion-container:empty { + display: none !important; + border: none !important; + box-shadow: none !important; + padding: 0 !important; + margin: 0 !important; + min-height: 0 !important; +} + +/* Compact property editors (contexts, tags, time estimate) */ +.modal.tn-text-input-modal--fit-content { + width: auto; + max-width: min(92vw, 28rem); +} + +.modal.tn-text-input-modal--fit-content .modal-content { + width: auto; + min-width: min(92vw, 14rem); +} + +.modal.tn-text-input-modal--fit-content .tn-text-input-modal__content { + display: flex; + flex-direction: column; + align-items: stretch; + gap: var(--size-4-2); +} + +.modal.tn-text-input-modal--fit-content .tn-text-input-modal__content .setting-item-heading { + border: none; + padding: 0; +} + +.modal.tn-text-input-modal--fit-content .tn-text-input-modal__input-setting { + border: none; + padding: 0; + flex-direction: column; + align-items: stretch; + gap: var(--size-4-1); +} + +.modal.tn-text-input-modal--fit-content .tn-text-input-modal__input-setting .setting-item-info { + display: none; +} + +.modal.tn-text-input-modal--fit-content .tn-text-input-modal__input-setting .setting-item-control { + width: auto; + max-width: 100%; +} + +.modal.tn-text-input-modal--fit-content .tn-text-input-modal__input-setting input { + width: auto; + min-width: 8ch; + max-width: 100%; + box-sizing: border-box; +} + +.modal.tn-text-input-modal--fit-content .tn-text-input-modal__buttons { + margin-top: 0; + padding-top: 0; + border-top: none; +} + +/* Context/tag chip list editor */ +.modal.tn-chip-list-editor { + width: auto; + max-width: min(92vw, 28rem); +} + +.modal.tn-chip-list-editor .modal-content { + width: auto; + min-width: min(92vw, 16rem); +} + +.modal.tn-chip-list-editor .tn-chip-list-editor__content { + display: flex; + flex-direction: column; + gap: var(--size-4-2); +} + +.modal.tn-chip-list-editor .tn-chip-list-editor__content .setting-item { + border: none; + border-top: none; + border-bottom: none; + padding: 0; +} + +.modal.tn-chip-list-editor .tn-chip-list-editor__content .setting-item::before, +.modal.tn-chip-list-editor .tn-chip-list-editor__content .setting-item::after { + display: none; + content: none; +} + +.modal.tn-chip-list-editor .tn-chip-list-editor__content .setting-item-heading { + border: none; + padding: 0; +} + +.modal.tn-chip-list-editor .tn-chip-list-editor__chips { + display: flex; + flex-wrap: wrap; + gap: var(--size-4-1); +} + +.modal.tn-chip-list-editor .tn-chip-list-editor__chips.is-empty { + display: none; +} + +.modal.tn-chip-list-editor .tn-chip-list-editor__chip { + display: inline-flex; + align-items: center; + gap: var(--size-4-1); + max-width: 100%; + padding: 4px 6px 4px 10px; + border-radius: var(--radius-l); + background: var(--background-modifier-hover); + color: var(--text-normal); + font-size: var(--font-ui-small); + line-height: 1.2; +} + +.modal.tn-chip-list-editor .tn-chip-list-editor__chip-label { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.modal.tn-chip-list-editor .tn-chip-list-editor__chip-remove { + display: inline-flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + width: 20px; + height: 20px; + padding: 0; + border: none; + border-radius: 50%; + background: transparent; + box-shadow: none; + color: var(--text-muted); + cursor: var(--cursor-link, pointer); + appearance: none; + -webkit-appearance: none; +} + +.modal.tn-chip-list-editor .tn-chip-list-editor__chip-remove:hover { + background: var(--background-modifier-border); + border: none; + box-shadow: none; + color: var(--text-normal); +} + +.modal.tn-chip-list-editor .tn-chip-list-editor__chip-remove:focus, +.modal.tn-chip-list-editor .tn-chip-list-editor__chip-remove:focus-visible { + outline: 2px solid var(--interactive-accent); + outline-offset: 1px; + border: none; + box-shadow: none; +} + +.modal.tn-chip-list-editor .tn-chip-list-editor__chip-remove svg { + width: 12px; + height: 12px; +} + +.modal.tn-chip-list-editor .tn-chip-list-editor__input-setting { + border: none; + padding: 0; + flex-direction: column; + align-items: stretch; + gap: var(--size-4-1); +} + +.modal.tn-chip-list-editor .tn-chip-list-editor__input-setting .setting-item-info { + display: none; +} + +.modal.tn-chip-list-editor .tn-chip-list-editor__input-setting .setting-item-control { + width: 100%; +} + +.modal.tn-chip-list-editor .tn-chip-list-editor__input-setting input { + width: 100%; + box-sizing: border-box; +} + +.modal.tn-chip-list-editor .tn-chip-list-editor__buttons { + margin-top: 0; + padding-top: 0; + border-top: none; + display: flex; + justify-content: flex-end; + gap: var(--size-4-2); +} + +.modal.tn-chip-list-editor--mobile-actions .tn-chip-list-editor__buttons { + flex-direction: row; + flex-wrap: nowrap; + align-items: center; + justify-content: flex-end; + width: 100%; +} + +.modal.tn-chip-list-editor--mobile-actions .tn-task-modal__icon-button-trailing { + display: inline-flex; + flex-direction: row; + flex-wrap: nowrap; + align-items: center; + gap: var(--size-4-2); + flex-shrink: 0; +} + /* Fix suggestion text truncation */ .suggestion-item .nlp-suggest-text { white-space: nowrap; @@ -16,15 +232,22 @@ ===================================================================== */ .tn-task-modal__markdown-editor--details { + display: flex; + flex-direction: column; margin-bottom: var(--size-4-3); border: 1px solid var(--background-modifier-border); border-radius: var(--radius-s); overflow: hidden; min-height: 200px; max-height: 400px; + cursor: text; } +.tn-task-modal__markdown-editor--details .details-editor, .tn-task-modal__markdown-editor--details .task-details-editor { + display: flex; + flex: 1 1 auto; + flex-direction: column; min-height: 200px; max-height: 400px; overflow-y: auto; @@ -32,15 +255,17 @@ /* Make the editor container scrollable */ .tn-task-modal__markdown-editor--details .cm-editor { - height: 100%; + display: flex; + flex: 1 1 auto; + flex-direction: column; min-height: 200px; max-height: 400px; } .tn-task-modal__markdown-editor--details .cm-scroller { overflow: auto; - min-height: inherit; - height: 100%; + flex: 1 1 auto; + min-height: 200px; max-height: 400px; cursor: text; } @@ -49,7 +274,7 @@ .tn-task-modal__markdown-editor--details .cm-contentContainer, .tn-task-modal__markdown-editor--details .cm-content { box-sizing: border-box; - min-height: inherit; + min-height: 100%; min-width: 100%; width: 100%; } @@ -652,13 +877,20 @@ ===================================================================== */ /* Use native modal-button-container class */ -.modal.mod-tasknotes .modal-button-container { +.modal.mod-tasknotes .modal-button-container:not(.tn-task-modal__icon-button-bar) { display: flex; gap: var(--size-4-2); } -/* Push Save/Cancel buttons to the right on desktop only */ -body:not(.is-mobile) .modal.mod-tasknotes .modal-button-container .mod-cta { +.modal.mod-tasknotes .modal-button-container.tn-task-modal__icon-button-bar { + display: flex; + flex-direction: row; + flex-wrap: nowrap; + align-items: center; + width: 100%; +} + +body:not(.is-mobile) .modal.mod-tasknotes .modal-button-container:not(.tn-task-modal__icon-button-bar) .mod-cta { margin-left: auto; } @@ -691,13 +923,16 @@ body:not(.is-mobile) .modal.mod-tasknotes .modal-button-container .mod-cta { } /* Keep buttons pinned at the bottom, outside the scrollable area */ -.tasknotes-plugin.minimalist-task-modal.expanded .modal-button-container { +.tasknotes-plugin.minimalist-task-modal.expanded:not(.tn-task-modal--sheet) .modal-button-container { flex-shrink: 0; - flex-wrap: wrap; padding-top: var(--size-4-3); margin-top: var(--size-4-3); } +.tasknotes-plugin.minimalist-task-modal.expanded .tn-task-modal__icon-button-bar { + flex-wrap: nowrap; +} + /* ===================================================================== RESPONSIVE DESIGN ===================================================================== */ @@ -717,16 +952,15 @@ body:not(.is-mobile) .modal.mod-tasknotes .modal-button-container .mod-cta { gap: var(--size-4-2); } - .modal.mod-tasknotes .modal-button-container { + .modal.mod-tasknotes .modal-button-container:not(.tn-task-modal__icon-button-bar) { flex-direction: column; } - .modal.mod-tasknotes .modal-button-container button { + .modal.mod-tasknotes .modal-button-container:not(.tn-task-modal__icon-button-bar) button { width: 100%; } - /* Reset margin for small screens on desktop too */ - .modal.mod-tasknotes .modal-button-container .mod-cta { + .modal.mod-tasknotes .modal-button-container:not(.tn-task-modal__icon-button-bar) .mod-cta { margin-left: 0; } } @@ -746,7 +980,13 @@ body.is-mobile .tasknotes-plugin.minimalist-task-modal.expanded .modal-content { max-height: calc(100vh - 16px - env(safe-area-inset-top) - env(safe-area-inset-bottom)); } -body.is-mobile .tasknotes-plugin.minimalist-task-modal > .modal { +/* Excludes the bottom-sheet layout: its own rule in task-modal-sheet.css caps + the sheet at a content-driven width instead of the full mobile viewport. + Without the :not() here, this rule's "body" type selector outranks that + sheet-specific rule on specificity alone (despite having fewer classes), + overriding it regardless of stylesheet order and forcing the sheet back to + full width. */ +body.is-mobile .tasknotes-plugin.minimalist-task-modal:not(.tn-task-modal--sheet) > .modal { width: calc(100vw - 16px - env(safe-area-inset-left) - env(safe-area-inset-right)); max-width: calc(100vw - 16px - env(safe-area-inset-left) - env(safe-area-inset-right)); max-height: 100%; @@ -775,15 +1015,8 @@ body.is-mobile .tasknotes-plugin.minimalist-task-modal.expanded .modal-split-con padding-bottom: var(--size-4-2); } -body.is-mobile .tasknotes-plugin.minimalist-task-modal .modal-split-left .details-container { - order: 3; -} - -body.is-mobile .tasknotes-plugin.minimalist-task-modal .modal-split-right { - order: 4; -} - body.is-mobile .tasknotes-plugin .tn-task-modal__markdown-editor--details, +body.is-mobile .tasknotes-plugin .tn-task-modal__markdown-editor--details .details-editor, body.is-mobile .tasknotes-plugin .tn-task-modal__markdown-editor--details .task-details-editor, body.is-mobile .tasknotes-plugin .tn-task-modal__markdown-editor--details .cm-editor, body.is-mobile .tasknotes-plugin .tn-task-modal__markdown-editor--details .details-editor-fallback { @@ -800,12 +1033,12 @@ body.is-mobile .tasknotes-plugin .action-icon { height: 40px; } -body.is-mobile .tasknotes-plugin.minimalist-task-modal.is-mobile-keyboard-focused.expanded .modal-split-content { +body.is-mobile .tasknotes-plugin.minimalist-task-modal.is-mobile-keyboard-focused.expanded:not(.tn-task-modal--sheet) .modal-split-content { padding-bottom: calc(min(42vh, 320px) + env(safe-area-inset-bottom)); scroll-padding-bottom: calc(min(42vh, 320px) + env(safe-area-inset-bottom)); } -body.is-mobile .tasknotes-plugin.minimalist-task-modal.expanded .modal-button-container { +body.is-mobile .tasknotes-plugin.minimalist-task-modal.expanded:not(.tn-task-modal--sheet) .modal-button-container { flex-shrink: 0; position: relative; background: var(--modal-background); @@ -814,24 +1047,30 @@ body.is-mobile .tasknotes-plugin.minimalist-task-modal.expanded .modal-button-co margin-bottom: max(var(--size-4-1), env(safe-area-inset-bottom)); } -body.is-mobile .modal.mod-tasknotes .tn-task-modal__button-bar { - display: grid; - grid-template-columns: repeat(3, minmax(0, 1fr)); +/* Icon button bars keep leading actions on the left and cancel/save grouped on + the right. The legacy 3-column grid targeted flat text buttons and breaks once + cancel/save live inside .tn-task-modal__icon-button-trailing. */ +body.is-mobile .modal.mod-tasknotes .tn-task-modal__icon-button-bar { + display: flex; + flex-wrap: nowrap; + align-items: center; + justify-content: space-between; gap: var(--size-4-2); } -body.is-mobile .modal.mod-tasknotes .tn-task-modal__button-bar button { - width: 100%; - min-height: 40px; +body.is-mobile .modal.mod-tasknotes .tn-task-modal__icon-button-bar .tn-task-modal__icon-button { + width: 40px; + min-width: 40px; + flex-shrink: 0; margin: 0; - padding-inline: var(--size-4-2); - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; } -body.is-mobile .modal.mod-tasknotes .tn-task-modal__button-bar .mod-cta { - grid-column: span 2; +body.is-mobile .modal.mod-tasknotes .tn-task-modal__icon-button-bar .tn-task-modal__icon-button-trailing { + display: inline-flex; + align-items: center; + gap: var(--size-4-2); + margin-left: auto; + flex-shrink: 0; } body.is-mobile .tasknotes-plugin .task-project-remove { @@ -1082,33 +1321,40 @@ body.is-mobile .tasknotes-plugin .task-project-item--task-card .task-project-rem METADATA SECTION - FOR EDIT MODAL ===================================================================== */ -.tasknotes-plugin .metadata-container { +.tasknotes-plugin .metadata-container:not(.tn-task-modal__task-info) { margin-top: var(--size-4-3); padding-top: var(--size-4-3); border-top: 0.5px solid var(--background-modifier-border); } -.tasknotes-plugin .metadata-content { +.tasknotes-plugin .metadata-container:not(.tn-task-modal__task-info) > .detail-label { + padding-inline: var(--size-4-3); +} + +.tasknotes-plugin .metadata-container:not(.tn-task-modal__task-info) > .metadata-content { display: flex; flex-direction: column; gap: var(--size-4-1); + min-width: 0; + box-sizing: border-box; + padding: var(--size-4-3); } -.tasknotes-plugin .metadata-item { +.tasknotes-plugin .metadata-container:not(.tn-task-modal__task-info) .metadata-item { display: flex; align-items: center; font-size: var(--font-ui-small); min-width: 0; } -.tasknotes-plugin .metadata-key { +.tasknotes-plugin .metadata-container:not(.tn-task-modal__task-info) .metadata-key { color: var(--text-muted); flex: 0 0 auto; font-weight: 500; min-width: 80px; } -.tasknotes-plugin .metadata-value { +.tasknotes-plugin .metadata-container:not(.tn-task-modal__task-info) .metadata-value { color: var(--text-normal); flex: 1 1 auto; font-family: var(--font-monospace); @@ -1117,6 +1363,56 @@ body.is-mobile .tasknotes-plugin .task-project-item--task-card .task-project-rem white-space: normal; } +/* Task information — grouped card with aligned monospace rows */ +.tasknotes-plugin .tn-task-modal__task-info { + margin-top: var(--size-4-6); + margin-bottom: var(--size-4-3); + padding-top: 0; + border-top: none; +} + +.tasknotes-plugin .tn-task-modal__task-info > .detail-label { + margin: 0 0 var(--size-4-3) 0; + padding: 0; + text-transform: none; + letter-spacing: normal; +} + +.tasknotes-plugin .tn-task-modal__task-info-card { + display: flex; + flex-direction: column; + gap: var(--size-4-1); + min-width: 0; + box-sizing: border-box; + padding: var(--size-4-3); + border-radius: var(--radius-l); + overflow: hidden; + background: var(--background-secondary); + border: 1px solid var(--background-modifier-border); +} + +.tasknotes-plugin .tn-task-modal__task-info-card .tn-task-modal__task-info-row { + display: grid; + grid-template-columns: auto minmax(0, 1fr); + column-gap: var(--size-4-3); + align-items: baseline; + font-size: var(--font-ui-small); + min-width: 0; +} + +.tasknotes-plugin .tn-task-modal__task-info-card .tn-task-modal__task-info-key, +.tasknotes-plugin .tn-task-modal__task-info-card .tn-task-modal__task-info-value { + font-family: var(--font-monospace); + font-weight: 400; + color: var(--text-normal); + min-width: 0; +} + +.tasknotes-plugin .tn-task-modal__task-info-card .tn-task-modal__task-info-value { + overflow-wrap: anywhere; + white-space: normal; +} + /* ===================================================================== AUTOCOMPLETE STYLING ===================================================================== */ @@ -1193,9 +1489,33 @@ body.is-mobile .tasknotes-plugin .task-project-item--task-card .task-project-rem border-top: 0.5px solid var(--background-modifier-border); } +/* Redesigned edit modal — replace section dividers with spacing */ +.tasknotes-plugin.minimalist-task-modal .completions-calendar-container { + padding-top: 0; + border-top: none; + margin-top: var(--size-4-6); +} + +.tasknotes-plugin.minimalist-task-modal .completions-calendar-container > .detail-label { + margin: 0 0 var(--size-4-3) 0; + padding: 0; + text-transform: none; + letter-spacing: normal; +} + .tasknotes-plugin .completions-calendar-content { + width: min(100%, 200px); max-width: 200px; - margin: 0 auto; + margin-inline: auto; + box-sizing: border-box; +} + +/* Redesigned edit modal — keep the compact calendar width fixed so the header + navigation buttons are not clipped by the mobile sheet scroll container's + overflow-x: hidden when the grid would otherwise grow with the viewport. */ +.tasknotes-plugin.minimalist-task-modal .completions-calendar-content { + width: 200px; + max-width: 100%; } /* ===================================================================== @@ -1212,13 +1532,24 @@ body.is-mobile .tasknotes-plugin .task-project-item--task-card .task-project-rem font-size: var(--font-ui-small); width: 100%; max-width: 200px; + box-sizing: border-box; +} + +.tasknotes-plugin.minimalist-task-modal .recurring-calendar { + width: 200px; + max-width: 100%; + margin-inline: auto; } .tasknotes-plugin .recurring-calendar__header { - display: flex; + display: grid; + grid-template-columns: minmax(20px, auto) minmax(0, 1fr) minmax(20px, auto); align-items: center; - justify-content: space-between; + column-gap: var(--size-4-1); margin-bottom: var(--size-4-1); + width: 100%; + box-sizing: border-box; + overflow: visible; } .tasknotes-plugin .recurring-calendar__nav { @@ -1230,6 +1561,7 @@ body.is-mobile .tasknotes-plugin .task-project-item--task-card .task-project-rem height: 20px; min-width: 20px; min-height: 20px; + flex-shrink: 0; margin: 0; padding: 0; border: 0; @@ -1248,13 +1580,31 @@ body.is-mobile .tasknotes-plugin .task-project-item--task-card .task-project-rem color: var(--text-normal); } -.tasknotes-plugin .recurring-calendar__nav svg { +.tasknotes-plugin .recurring-calendar__nav-icon { + display: inline-flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + line-height: 0; + color: inherit; +} + +.tasknotes-plugin .recurring-calendar__nav-icon svg, +.tasknotes-plugin .recurring-calendar__nav-icon .svg-icon { width: 16px; height: 16px; + color: inherit; + stroke: currentColor; +} + +.tasknotes-plugin .recurring-calendar__nav-icon svg path { stroke: currentColor; } .tasknotes-plugin .recurring-calendar__month { + grid-column: 2; + min-width: 0; + text-align: center; font-size: var(--font-ui-small); color: var(--text-normal); font-weight: 500; @@ -1262,13 +1612,18 @@ body.is-mobile .tasknotes-plugin .task-project-item--task-card .task-project-rem .tasknotes-plugin .recurring-calendar__grid { display: grid; - grid-template-columns: repeat(7, 1fr); + grid-template-columns: repeat(7, minmax(0, 1fr)); gap: 2px; + width: 100%; + box-sizing: border-box; } .tasknotes-plugin .recurring-calendar__day { - width: 20px; + box-sizing: border-box; + width: 100%; + max-width: 20px; height: 20px; + justify-self: center; display: flex; align-items: center; justify-content: center; @@ -1578,6 +1933,9 @@ body.is-mobile .tasknotes-plugin .task-project-item--task-card .task-project-rem /* Use consistent button system for project remove button */ .tasknotes-plugin .task-project-remove { + display: inline-flex; + align-items: center; + justify-content: center; background: transparent; border: none; box-shadow: none; diff --git a/tests/unit/issues/issue-1009-task-edit-modal-delete-button.test.ts b/tests/unit/issues/issue-1009-task-edit-modal-delete-button.test.ts index 7e98960d3..a278aa94a 100644 --- a/tests/unit/issues/issue-1009-task-edit-modal-delete-button.test.ts +++ b/tests/unit/issues/issue-1009-task-edit-modal-delete-button.test.ts @@ -121,8 +121,8 @@ describe('Issue #1009 - Edit Task modal delete action', () => { ); expect(deleteButton).not.toBeNull(); - expect(deleteButton?.textContent).toBe('Delete'); - expect(deleteButton?.classList.contains('mod-warning')).toBe(true); + expect(deleteButton?.getAttribute("aria-label")).toBe("Delete"); + expect(deleteButton?.classList.contains("mod-warning")).toBe(true); }); it('confirms before deleting the task file from the modal', async () => { diff --git a/tests/unit/issues/issue-1023-mobile-custom-field-keyboard.test.ts b/tests/unit/issues/issue-1023-mobile-custom-field-keyboard.test.ts index a70d5bd2c..56a869013 100644 --- a/tests/unit/issues/issue-1023-mobile-custom-field-keyboard.test.ts +++ b/tests/unit/issues/issue-1023-mobile-custom-field-keyboard.test.ts @@ -107,7 +107,7 @@ describe("Issue #1023: mobile custom field keyboard visibility", () => { const cssContent = fs.readFileSync(cssFilePath, "utf-8"); expect(cssContent).toContain( - ".is-mobile-keyboard-focused.expanded .modal-split-content" + ".is-mobile-keyboard-focused.expanded:not(.tn-task-modal--sheet) .modal-split-content" ); expect(cssContent).toContain("scroll-padding-bottom"); expect(cssContent).toContain("env(safe-area-inset-bottom)"); diff --git a/tests/unit/issues/issue-1047-task-modal-metadata-overflow.test.ts b/tests/unit/issues/issue-1047-task-modal-metadata-overflow.test.ts index 228f0916e..2122babd1 100644 --- a/tests/unit/issues/issue-1047-task-modal-metadata-overflow.test.ts +++ b/tests/unit/issues/issue-1047-task-modal-metadata-overflow.test.ts @@ -13,11 +13,17 @@ const cssFilePath = path.resolve(__dirname, "../../../styles/task-modal.css"); describe("Issue #1047: task modal metadata overflow", () => { it("allows long metadata values to wrap instead of widening the modal", () => { const cssContent = fs.readFileSync(cssFilePath, "utf-8"); - const metadataItemBlock = extractCssBlock(cssContent, ".tasknotes-plugin .metadata-item"); - const metadataKeyBlock = extractCssBlock(cssContent, ".tasknotes-plugin .metadata-key"); + const metadataItemBlock = extractCssBlock( + cssContent, + ".tasknotes-plugin .metadata-container:not(.tn-task-modal__task-info) .metadata-item" + ); + const metadataKeyBlock = extractCssBlock( + cssContent, + ".tasknotes-plugin .metadata-container:not(.tn-task-modal__task-info) .metadata-key" + ); const metadataValueBlock = extractCssBlock( cssContent, - ".tasknotes-plugin .metadata-value" + ".tasknotes-plugin .metadata-container:not(.tn-task-modal__task-info) .metadata-value" ); expect(metadataItemBlock).toContain("min-width: 0"); diff --git a/tests/unit/issues/issue-1383-modal-active-editor.test.ts b/tests/unit/issues/issue-1383-modal-active-editor.test.ts index 3d7f91069..495861217 100644 --- a/tests/unit/issues/issue-1383-modal-active-editor.test.ts +++ b/tests/unit/issues/issue-1383-modal-active-editor.test.ts @@ -27,6 +27,10 @@ describe("Issue #1383: modal markdown editors expose active editor API", () => { file: file as any, }); + expect(typeof (editor as { owner: { syncScroll?: () => void } }).owner.syncScroll).toBe( + "function" + ); + editor.editor.cm.contentDOM.dispatchEvent(new FocusEvent("focusin", { bubbles: true })); const activeEditor = app.workspace.activeEditor; diff --git a/tests/unit/issues/issue-1520-tag-context-focus-suggest.test.ts b/tests/unit/issues/issue-1520-tag-context-focus-suggest.test.ts index e20babc9b..cfb1ba8c3 100644 --- a/tests/unit/issues/issue-1520-tag-context-focus-suggest.test.ts +++ b/tests/unit/issues/issue-1520-tag-context-focus-suggest.test.ts @@ -1,5 +1,9 @@ import { App } from "obsidian"; -import { ContextSuggest, TagSuggest } from "../../../src/modals/taskModalSuggests"; +import { + attachChipEditorContextSuggest, + ContextSuggest, + TagSuggest, +} from "../../../src/modals/taskModalSuggests"; import { MockObsidian } from "../../helpers/obsidian-runtime"; const createMockApp = (mockApp: unknown): App => mockApp as App; @@ -75,3 +79,43 @@ describe("Issue #1520: tag and context suggestions on field focus", () => { ]); }); }); + +describe("Chip editor suggests", () => { + let app: App; + + beforeEach(() => { + MockObsidian.reset(); + app = createMockApp(MockObsidian.createMockApp()); + document.body.innerHTML = ""; + }); + + it("does not open the suggest popover when every context is already selected", async () => { + jest.useFakeTimers(); + const input = document.createElement("input"); + document.body.appendChild(input); + const plugin = { + cacheManager: { + getAllContexts: jest.fn(() => ["awd", "fdawd"]), + }, + }; + const suggest = attachChipEditorContextSuggest(app, input, plugin as never, { + getSelectedValues: () => ["awd", "fdawd"], + onAdd: jest.fn(), + }); + const openSpy = jest.spyOn(suggest, "open"); + const closeSpy = jest.spyOn(suggest, "close"); + + jest.runOnlyPendingTimers(); + await Promise.resolve(); + + openSpy.mockClear(); + closeSpy.mockClear(); + + input.dispatchEvent(new Event("focus")); + await Promise.resolve(); + + expect(openSpy).not.toHaveBeenCalled(); + expect(closeSpy).toHaveBeenCalled(); + jest.useRealTimers(); + }); +}); diff --git a/tests/unit/issues/issue-1903-mobile-edit-modal.test.ts b/tests/unit/issues/issue-1903-mobile-edit-modal.test.ts index 1a5d3f2e7..4b04067ec 100644 --- a/tests/unit/issues/issue-1903-mobile-edit-modal.test.ts +++ b/tests/unit/issues/issue-1903-mobile-edit-modal.test.ts @@ -97,39 +97,33 @@ describe("Issue #1903: mobile edit task modal", () => { expect( extractCssBlock( cssContent, - "body.is-mobile .tasknotes-plugin.minimalist-task-modal > .modal" + "body.is-mobile .tasknotes-plugin.minimalist-task-modal:not(.tn-task-modal--sheet) > .modal" ) ).toContain("width: calc(100vw - 16px"); expect( extractCssBlock( cssContent, - "body.is-mobile .tasknotes-plugin.minimalist-task-modal .modal-split-left .details-container" + "body.is-mobile .modal.mod-tasknotes .tn-task-modal__icon-button-bar" ) - ).toContain("order: 3"); + ).toContain("flex-wrap: nowrap"); expect( extractCssBlock( cssContent, - "body.is-mobile .modal.mod-tasknotes .tn-task-modal__button-bar" + "body.is-mobile .modal.mod-tasknotes .tn-task-modal__icon-button-bar .tn-task-modal__icon-button-trailing" ) - ).toContain("grid-template-columns: repeat(3, minmax(0, 1fr))"); + ).toContain("margin-left: auto"); expect( extractCssBlock( cssContent, - "body.is-mobile .tasknotes-plugin.minimalist-task-modal.expanded .modal-button-container" + "body.is-mobile .modal.mod-tasknotes .tn-task-modal__icon-button-bar .tn-task-modal__icon-button" ) - ).toContain("margin-bottom: max(var(--size-4-1), env(safe-area-inset-bottom))"); - expect( - extractCssBlock( - cssContent, - "body.is-mobile .modal.mod-tasknotes .tn-task-modal__button-bar .tn-task-modal__open-note-button" - ) - ).not.toContain("grid-column: 1 / -1"); + ).toContain("flex-shrink: 0"); expect( extractCssBlock( cssContent, - "body.is-mobile .modal.mod-tasknotes .tn-task-modal__button-bar .mod-cta" + "body.is-mobile .tasknotes-plugin.minimalist-task-modal.expanded:not(.tn-task-modal--sheet) .modal-button-container" ) - ).toContain("grid-column: span 2"); + ).toContain("margin-bottom: max(var(--size-4-1), env(safe-area-inset-bottom))"); expect(cssContent).toMatch( /body\.is-mobile \.tasknotes-plugin \.tn-task-modal__markdown-editor--details,[^{]*\{[^}]*min-height:\s*140px/s ); diff --git a/tests/unit/issues/issue-2009-task-creation-tab-focus.test.ts b/tests/unit/issues/issue-2009-task-creation-tab-focus.test.ts index b6c19bfaf..3f081a7b8 100644 --- a/tests/unit/issues/issue-2009-task-creation-tab-focus.test.ts +++ b/tests/unit/issues/issue-2009-task-creation-tab-focus.test.ts @@ -69,13 +69,14 @@ describe("Issue #2009: task creation NLP editor Tab focus", () => { const app = MockObsidian.createMockApp() as unknown as App; const modal = new TaskCreationModal(app, createPlugin(app)); const previousButton = document.createElement("button"); - const titleInput = document.createElement("textarea"); + const detailsEditor = document.createElement("div"); previousButton.focus = jest.fn(); - titleInput.focus = jest.fn(); - titleInput.className = "title-input-detailed"; + detailsEditor.focus = jest.fn(); + detailsEditor.tabIndex = 0; + detailsEditor.className = "tn-task-modal__markdown-editor--details"; modal.contentEl.append(previousButton); - modal.modalEl.append(modal.contentEl, titleInput); + modal.modalEl.append(modal.contentEl, detailsEditor); (modal as any).isExpanded = true; (modal as any).createNaturalLanguageInput(modal.contentEl); @@ -85,7 +86,7 @@ describe("Issue #2009: task creation NLP editor Tab focus", () => { expect(editorOptions.onTab?.(editor, false)).toBe(true); jest.advanceTimersByTime(50); - expect(titleInput.focus).toHaveBeenCalledTimes(1); + expect(detailsEditor.focus).toHaveBeenCalledTimes(1); expect(editorOptions.onTab?.(editor, true)).toBe(true); jest.advanceTimersByTime(50); diff --git a/tests/unit/issues/issue-2048-edit-modal-date-metadata.test.ts b/tests/unit/issues/issue-2048-edit-modal-date-metadata.test.ts index 81b13f3a4..0f36370d2 100644 --- a/tests/unit/issues/issue-2048-edit-modal-date-metadata.test.ts +++ b/tests/unit/issues/issue-2048-edit-modal-date-metadata.test.ts @@ -29,7 +29,7 @@ function createPlugin(app: App): never { } as never; } -function renderMetadata(task: TaskInfo): HTMLElement[] { +function renderMetadataContainer(task: TaskInfo): HTMLElement { MockObsidian.reset(); const app = createMockApp(MockObsidian.createMockApp()); const modal = new TaskEditModal(app, createPlugin(app), { task }); @@ -38,7 +38,11 @@ function renderMetadata(task: TaskInfo): HTMLElement[] { (modal as never as { createMetadataSection: (container: HTMLElement) => void }) .createMetadataSection(container); - return Array.from(container.querySelectorAll(".metadata-item")); + return container; +} + +function renderMetadata(task: TaskInfo): HTMLElement[] { + return Array.from(renderMetadataContainer(task).querySelectorAll(".metadata-item")); } function textRows(items: HTMLElement[]): string[] { @@ -46,6 +50,21 @@ function textRows(items: HTMLElement[]): string[] { } describe("Issue #2048: edit modal date metadata", () => { + it("does not add generic metadata-content or metadata-key classes that Obsidian styles on mobile", () => { + const container = renderMetadataContainer({ + title: "Date reference task", + status: "open", + priority: "normal", + path: "TaskNotes/Date reference task.md", + archived: false, + } as TaskInfo); + + expect(container.querySelector(".tn-task-modal__task-info-card")).not.toBeNull(); + expect(container.querySelector(".metadata-content")).toBeNull(); + expect(container.querySelector(".metadata-key")).toBeNull(); + expect(container.querySelector(".tn-task-modal__task-info-key")).not.toBeNull(); + }); + it("shows current due and scheduled dates in the task information block", () => { const items = renderMetadata({ title: "Date reference task", diff --git a/tests/unit/issues/issue-323-task-modal-action-order.test.ts b/tests/unit/issues/issue-323-task-modal-action-order.test.ts index ce3245ba1..ac40a66d8 100644 --- a/tests/unit/issues/issue-323-task-modal-action-order.test.ts +++ b/tests/unit/issues/issue-323-task-modal-action-order.test.ts @@ -64,6 +64,9 @@ describe("Issue #323: task modal action order", () => { "scheduled-date", "recurrence", "reminders", + "contexts", + "tags", + "time-estimate", ]); }); }); diff --git a/tests/unit/modals/TextInputModal.test.ts b/tests/unit/modals/TextInputModal.test.ts new file mode 100644 index 000000000..1197128a0 --- /dev/null +++ b/tests/unit/modals/TextInputModal.test.ts @@ -0,0 +1,24 @@ +import { bindTextInputModalWidthToContent } from "../../../src/modals/TextInputModal"; + +describe("TextInputModal", () => { + beforeEach(() => { + document.body.innerHTML = ""; + }); + + it("bindTextInputModalWidthToContent sizes the input from its value", () => { + const input = document.createElement("input"); + input.placeholder = "tag"; + document.body.appendChild(input); + + bindTextInputModalWidthToContent(input, { minCh: 10, maxCh: 20 }); + expect(input.style.width).toBe("12ch"); + + input.value = "home"; + input.dispatchEvent(new Event("input")); + expect(input.style.width).toBe("12ch"); + + input.value = "a very long comma separated list of contexts"; + input.dispatchEvent(new Event("input")); + expect(input.style.width).toBe("20ch"); + }); +}); diff --git a/tests/unit/modals/taskEditCompletions.contextmenu.test.ts b/tests/unit/modals/taskEditCompletions.contextmenu.test.ts index 6af392123..29c175fb6 100644 --- a/tests/unit/modals/taskEditCompletions.contextmenu.test.ts +++ b/tests/unit/modals/taskEditCompletions.contextmenu.test.ts @@ -78,6 +78,20 @@ describe("Task edit completions calendar occurrence context menu", () => { document.body.innerHTML = ""; }); + it("renders month navigation icons inside a wrapper span", () => { + const container = renderCompletionsCalendar(); + const navButtons = Array.from( + container.querySelectorAll(".recurring-calendar__nav") + ); + + expect(navButtons).toHaveLength(2); + for (const button of navButtons) { + const iconWrap = button.querySelector(".recurring-calendar__nav-icon"); + expect(iconWrap?.parentElement).toBe(button); + expect(button.firstElementChild?.classList.contains("recurring-calendar__nav-icon")).toBe(true); + } + }); + afterEach(() => { jest.clearAllMocks(); }); diff --git a/tests/unit/modals/taskModalActionBar.test.ts b/tests/unit/modals/taskModalActionBar.test.ts index de1dd30f8..3612bdc6f 100644 --- a/tests/unit/modals/taskModalActionBar.test.ts +++ b/tests/unit/modals/taskModalActionBar.test.ts @@ -74,7 +74,7 @@ describe("taskModalActionBar", () => { onClick: jest.fn(), }, { - iconName: "calendar", + iconName: "target", tooltip: "Due", dataType: "due-date", onClick: jest.fn(), diff --git a/tests/unit/modals/taskModalActionButtons.test.ts b/tests/unit/modals/taskModalActionButtons.test.ts index de86fd2f5..ec2602694 100644 --- a/tests/unit/modals/taskModalActionButtons.test.ts +++ b/tests/unit/modals/taskModalActionButtons.test.ts @@ -4,6 +4,14 @@ import { type TaskModalActionButtonContext, } from "../../../src/modals/taskModalActionButtons"; +const mockSetIcon = jest.fn(); +const mockSetTooltip = jest.fn(); + +jest.mock("obsidian", () => ({ + setIcon: (...args: unknown[]) => mockSetIcon(...args), + setTooltip: (...args: unknown[]) => mockSetTooltip(...args), +})); + function createContext(): TaskModalActionButtonContext { return { translate: (key) => @@ -17,9 +25,11 @@ function createContext(): TaskModalActionButtonContext { describe("taskModalActionButtons", () => { beforeEach(() => { document.body.innerHTML = ""; + mockSetIcon.mockClear(); + mockSetTooltip.mockClear(); }); - it("renders leading actions before save and cancel and wires click handlers", () => { + it("renders labeled leading actions and trailing save/cancel controls", () => { const container = document.createElement("div"); const open = jest.fn(); const archive = jest.fn(); @@ -28,28 +38,55 @@ describe("taskModalActionButtons", () => { const buttonContainer = createTaskModalActionButtons(createContext(), { container, leadingButtons: [ - { className: "open-button", text: "Open note", onClick: open }, - { className: "archive-button", text: "Archive", onClick: archive }, + { + className: "open-button", + iconName: "external-link", + label: "Open note", + onClick: open, + }, + { + className: "archive-button", + iconName: "archive", + label: "Archive", + onClick: archive, + isWarning: true, + }, ], onSave: jest.fn().mockResolvedValue(undefined), onSaved: jest.fn(), onCancel: cancel, }); + expect( + buttonContainer.querySelector(".tn-task-modal__icon-button-leading") + ).not.toBeNull(); + expect( + buttonContainer.querySelector(".tn-task-modal__icon-button-trailing") + ).not.toBeNull(); + const buttons = Array.from(buttonContainer.querySelectorAll("button")); - expect(buttons.map((button) => button.textContent)).toEqual([ - "Open note", - "Archive", - "Save", - "Cancel", - ]); - expect(buttons[0].classList.contains("open-button")).toBe(true); - expect(buttons[1].classList.contains("archive-button")).toBe(true); - expect(buttons[2].classList.contains("mod-cta")).toBe(true); + expect(buttons).toHaveLength(4); + expect(buttons[0].getAttribute("aria-label")).toBe("Open note"); + expect(buttons[1].getAttribute("aria-label")).toBe("Archive"); + expect(buttons[2].getAttribute("aria-label")).toBe("Cancel"); + expect(buttons[3].getAttribute("aria-label")).toBe("Save"); + expect( + buttons[0].querySelector(".tn-task-modal__icon-button-label")?.textContent + ).toBe("Open note"); + expect( + buttons[1].querySelector(".tn-task-modal__icon-button-label")?.textContent + ).toBe("Archive"); + expect( + buttons[2].querySelector(".tn-task-modal__icon-button-label")?.textContent + ).toBe("Cancel"); + expect( + buttons[3].querySelector(".tn-task-modal__icon-button-label")?.textContent + ).toBe("Save"); + expect(buttons[3].classList.contains("mod-cta")).toBe(true); buttons[0].click(); buttons[1].click(); - buttons[3].click(); + buttons[2].click(); expect(open).toHaveBeenCalledTimes(1); expect(archive).toHaveBeenCalledTimes(1); diff --git a/tests/unit/modals/taskModalActionIconStates.test.ts b/tests/unit/modals/taskModalActionIconStates.test.ts index aff608342..137302db0 100644 --- a/tests/unit/modals/taskModalActionIconStates.test.ts +++ b/tests/unit/modals/taskModalActionIconStates.test.ts @@ -1,7 +1,14 @@ const mockSetTooltip = jest.fn(); +const mockSetIcon = jest.fn(); jest.mock("obsidian", () => ({ setTooltip: mockSetTooltip, + setIcon: (el: HTMLElement, _iconName: string) => { + mockSetIcon(el, _iconName); + const icon = document.createElement("span"); + icon.className = "icon"; + el.appendChild(icon); + }, })); import type { PriorityConfig, StatusConfig } from "../../../src/types"; @@ -9,9 +16,11 @@ import { updateTaskModalActionIconStates, type TaskModalActionIconState, } from "../../../src/modals/taskModalActionIconStates"; +import { createTaskModalChip, createTaskModalChipRow } from "../../../src/modals/taskModalChipRow"; -function createActionBar(): HTMLElement { - const actionBar = document.createElement("div"); +function createChipRow(): HTMLElement { + const container = document.createElement("div"); + const row = createTaskModalChipRow(container); for (const dataType of [ "due-date", "scheduled-date", @@ -19,12 +28,18 @@ function createActionBar(): HTMLElement { "priority", "recurrence", "reminders", + "contexts", + "tags", + "time-estimate", ]) { - const icon = actionBar.createDiv({ cls: "action-icon" }); - icon.dataset.type = dataType; - icon.createSpan({ cls: "icon" }); + createTaskModalChip(row, { + iconName: "dot-square", + label: dataType, + dataType, + onClick: jest.fn(), + }); } - return actionBar; + return row; } function createState(overrides: Partial = {}): TaskModalActionIconState { @@ -36,6 +51,9 @@ function createState(overrides: Partial = {}): TaskMod recurrenceRule: "", recurrenceDisplayText: "", reminderCount: 0, + contexts: "", + tags: "", + timeEstimate: 0, defaultStatus: "open", defaultPriority: "normal", statusConfigs: [ @@ -96,157 +114,82 @@ describe("taskModalActionIconStates", () => { mockSetTooltip.mockClear(); }); - it("marks date icons active and updates translated tooltips when values exist", () => { - const actionBar = createActionBar(); + it("marks due-date chip active and updates chip labels when values exist", () => { + const actionBar = createChipRow(); updateTaskModalActionIconStates( actionBar, { translate }, createState({ dueDate: "2026-05-20", - scheduledDate: "2026-05-19T09:30", }) ); - expect(actionBar.querySelector('[data-type="due-date"]')?.classList).toContain("has-value"); - expect(actionBar.querySelector('[data-type="scheduled-date"]')?.classList).toContain( - "has-value" - ); - expect(mockSetTooltip).toHaveBeenCalledWith( - actionBar.querySelector('[data-type="due-date"]'), - "modals.task.tooltips.dueValue:value=2026-05-20", - { placement: "top" } - ); - expect(mockSetTooltip).toHaveBeenCalledWith( - actionBar.querySelector('[data-type="scheduled-date"]'), - "modals.task.tooltips.scheduledValue:value=2026-05-19T09:30", - { placement: "top" } - ); - }); - - it("marks non-default status and priority active and applies configured colors", () => { - const actionBar = createActionBar(); - - updateTaskModalActionIconStates( - actionBar, - { translate }, - createState({ status: "done", priority: "high" }) - ); - - const statusIcon = actionBar.querySelector('[data-type="status"]'); - const priorityIcon = actionBar.querySelector('[data-type="priority"]'); - - expect(statusIcon?.classList.contains("has-value")).toBe(true); - expect(priorityIcon?.classList.contains("has-value")).toBe(true); - expect(statusIcon?.querySelector(".icon")?.style.color).toBe("rgb(0, 170, 0)"); - expect(priorityIcon?.querySelector(".icon")?.style.color).toBe( - "rgb(255, 0, 0)" - ); - expect(mockSetTooltip).toHaveBeenCalledWith( - statusIcon, - "modals.task.tooltips.statusValue:value=Done", - { placement: "top" } - ); - expect(mockSetTooltip).toHaveBeenCalledWith( - priorityIcon, - "modals.task.tooltips.priorityValue:value=High", - { placement: "top" } - ); + const dueChip = actionBar.querySelector('[data-type="due-date"]'); + expect(dueChip?.classList).toContain("has-value"); + expect(dueChip?.querySelector(".tn-task-modal__chip-label")?.textContent).toBeTruthy(); }); - it("clears active status and priority state while preserving configured default colors", () => { - const actionBar = createActionBar(); - const statusIcon = actionBar.querySelector('[data-type="status"]')!; - const priorityIcon = actionBar.querySelector('[data-type="priority"]')!; - statusIcon.classList.add("has-value"); - priorityIcon.classList.add("has-value"); - statusIcon.querySelector(".icon")!.style.color = "#00aa00"; - priorityIcon.querySelector(".icon")!.style.color = "#ff0000"; - - updateTaskModalActionIconStates(actionBar, { translate }, createState()); - - expect(statusIcon.classList.contains("has-value")).toBe(false); - expect(priorityIcon.classList.contains("has-value")).toBe(false); - expect(statusIcon.querySelector(".icon")?.style.color).toBe( - "rgb(153, 153, 153)" - ); - expect(priorityIcon.querySelector(".icon")?.style.color).toBe( - "rgb(153, 153, 153)" - ); - expect(mockSetTooltip).toHaveBeenCalledWith( - statusIcon, - "modals.task.actions.status", - { placement: "top" } - ); - expect(mockSetTooltip).toHaveBeenCalledWith( - priorityIcon, - "modals.task.actions.priority", - { placement: "top" } - ); - }); - - it("removes stale icon colors when no config color is available", () => { - const actionBar = createActionBar(); - const statusIcon = actionBar.querySelector('[data-type="status"]')!; - const priorityIcon = actionBar.querySelector('[data-type="priority"]')!; - statusIcon.querySelector(".icon")!.style.color = "#00aa00"; - priorityIcon.querySelector(".icon")!.style.color = "#ff0000"; + it("marks scheduled-date chip active and updates chip labels when values exist", () => { + const actionBar = createChipRow(); updateTaskModalActionIconStates( actionBar, { translate }, createState({ - statusConfigs: [], - priorityConfigs: [], + scheduledDate: "2026-07-13", }) ); - expect(statusIcon.querySelector(".icon")?.style.color).toBe(""); - expect(priorityIcon.querySelector(".icon")?.style.color).toBe(""); + const scheduledChip = actionBar.querySelector('[data-type="scheduled-date"]'); + expect(scheduledChip?.classList).toContain("has-value"); + expect( + scheduledChip?.querySelector(".tn-task-modal__chip-label")?.textContent + ).toBeTruthy(); }); - it("updates recurrence and reminder active states", () => { - const actionBar = createActionBar(); + it("marks non-default status and priority active and applies configured colors", () => { + const actionBar = createChipRow(); updateTaskModalActionIconStates( actionBar, { translate }, - createState({ - recurrenceRule: "FREQ=WEEKLY", - recurrenceDisplayText: "Weekly", - reminderCount: 2, - }) + createState({ status: "done", priority: "high" }) ); - const recurrenceIcon = actionBar.querySelector('[data-type="recurrence"]'); - const reminderIcon = actionBar.querySelector('[data-type="reminders"]'); - expect(recurrenceIcon?.classList.contains("has-value")).toBe(true); - expect(reminderIcon?.classList.contains("has-value")).toBe(true); - expect(mockSetTooltip).toHaveBeenCalledWith( - recurrenceIcon, - "modals.task.tooltips.recurrenceValue:value=Weekly", - { placement: "top" } - ); - expect(mockSetTooltip).toHaveBeenCalledWith( - reminderIcon, - "modals.task.tooltips.remindersPlural:count=2", - { placement: "top" } + const statusChip = actionBar.querySelector('[data-type="status"]'); + const priorityChip = actionBar.querySelector('[data-type="priority"]'); + + expect(statusChip?.classList.contains("has-value")).toBe(true); + expect(priorityChip?.classList.contains("has-value")).toBe(true); + expect(statusChip?.querySelector(".icon")?.style.color).toBe("rgb(0, 170, 0)"); + expect(priorityChip?.querySelector(".icon")?.style.color).toBe( + "rgb(255, 0, 0)" ); }); - it("uses the singular reminder tooltip for one reminder", () => { - const actionBar = createActionBar(); + it("updates contexts, tags, and time estimate chip labels", () => { + const actionBar = createChipRow(); updateTaskModalActionIconStates( actionBar, { translate }, - createState({ reminderCount: 1 }) + createState({ + contexts: "home, work", + tags: "errand, shop", + timeEstimate: 45, + }) ); - expect(mockSetTooltip).toHaveBeenCalledWith( - actionBar.querySelector('[data-type="reminders"]'), - "modals.task.tooltips.remindersSingle", - { placement: "top" } - ); + expect( + actionBar.querySelector('[data-type="contexts"] .tn-task-modal__chip-label')?.textContent + ).toBe("modals.task.chips.contextsPlural:count=2"); + expect( + actionBar.querySelector('[data-type="tags"] .tn-task-modal__chip-label')?.textContent + ).toBe("modals.task.chips.tagsPlural:count=2"); + expect( + actionBar.querySelector('[data-type="time-estimate"] .tn-task-modal__chip-label') + ?.textContent + ).toBe("modals.task.chips.timeEstimateValue:minutes=45"); }); }); diff --git a/tests/unit/modals/taskModalActionState.test.ts b/tests/unit/modals/taskModalActionState.test.ts index 7dfa6d5de..279218075 100644 --- a/tests/unit/modals/taskModalActionState.test.ts +++ b/tests/unit/modals/taskModalActionState.test.ts @@ -182,6 +182,9 @@ describe("taskModalActionState", () => { recurrenceRule: "FREQ=WEEKLY;BYDAY=TU", recurrenceDisplayText: "Weekly on Tuesday", reminderCount: 1, + contexts: "", + tags: "", + timeEstimate: 0, defaultStatus: "open", defaultPriority: "normal", statusConfigs, diff --git a/tests/unit/modals/taskModalChipListEditor.test.ts b/tests/unit/modals/taskModalChipListEditor.test.ts new file mode 100644 index 000000000..befeca360 --- /dev/null +++ b/tests/unit/modals/taskModalChipListEditor.test.ts @@ -0,0 +1,167 @@ +import { + isTaskModalChipListEditorMobileLikeEnvironment, + TaskModalChipListEditorModal, +} from "../../../src/modals/taskModalChipListEditor"; + +const mockSetIcon = jest.fn(); +const mockSetTooltip = jest.fn(); + +jest.mock("obsidian", () => { + const actual = jest.requireActual("obsidian"); + return { + ...actual, + Modal: class { + app: unknown; + modalEl = document.createElement("div"); + contentEl = document.createElement("div"); + + constructor(app: unknown) { + this.app = app; + this.modalEl.classList.add("modal"); + this.modalEl.appendChild(this.contentEl); + } + + open(): void { + this.onOpen(); + } + + close(): void {} + + onOpen(): void {} + }, + Setting: class { + settingEl = document.createElement("div"); + + constructor(container: HTMLElement) { + container.appendChild(this.settingEl); + } + + setName(): this { + return this; + } + + setHeading(): this { + return this; + } + + addText(callback: (text: { inputEl: HTMLInputElement; setPlaceholder: () => void }) => void): this { + const control = document.createElement("div"); + const input = document.createElement("input"); + control.appendChild(input); + this.settingEl.appendChild(control); + callback({ + inputEl: input, + setPlaceholder: jest.fn(), + }); + return this; + } + }, + setIcon: (...args: unknown[]) => mockSetIcon(...args), + setTooltip: (...args: unknown[]) => mockSetTooltip(...args), + }; +}); + +jest.mock("../../../src/modals/taskModalSuggests", () => ({ + attachChipEditorContextSuggest: jest.fn(), + attachChipEditorTagSuggest: jest.fn(), +})); + +function createModal(): TaskModalChipListEditorModal { + return new TaskModalChipListEditorModal( + {} as never, + { + settings: { + taskTag: "task", + taskIdentificationMethod: "tag", + hideIdentifyingTagsInCards: false, + }, + cacheManager: { + getAllContexts: jest.fn(() => []), + getAllTags: jest.fn(() => []), + }, + } as never, + { + title: "Tags", + placeholder: "Add tag", + initialValues: ["review"], + variant: "tags", + confirmText: "Confirm", + cancelText: "Cancel", + removeItemLabel: (item) => `Remove ${item}`, + formatChipLabel: (value) => `#${value}`, + }, + ["review"] + ); +} + +describe("taskModalChipListEditor", () => { + const originalMatchMedia = window.matchMedia; + + beforeEach(() => { + document.body.innerHTML = ""; + document.body.classList.remove("is-mobile"); + window.matchMedia = originalMatchMedia; + mockSetIcon.mockClear(); + mockSetTooltip.mockClear(); + }); + + it("detects mobile-like environments from body class or coarse pointer media", () => { + expect(isTaskModalChipListEditorMobileLikeEnvironment(document)).toBe(false); + + document.body.classList.add("is-mobile"); + expect(isTaskModalChipListEditorMobileLikeEnvironment(document)).toBe(true); + + document.body.classList.remove("is-mobile"); + Object.defineProperty(window, "matchMedia", { + writable: true, + value: jest.fn().mockImplementation((query: string) => ({ + matches: query === "(pointer: coarse)", + media: query, + addEventListener: jest.fn(), + removeEventListener: jest.fn(), + })), + }); + expect(isTaskModalChipListEditorMobileLikeEnvironment(document)).toBe(true); + }); + + it("renders icon-only cancel and confirm buttons in one row on mobile", () => { + document.body.classList.add("is-mobile"); + const modal = createModal(); + modal.onOpen(); + + expect(modal.modalEl.classList.contains("tn-chip-list-editor--mobile-actions")).toBe(true); + + const buttonBar = modal.contentEl.querySelector(".tn-chip-list-editor__buttons"); + expect(buttonBar?.classList.contains("tn-task-modal__icon-button-bar")).toBe(true); + + const trailing = modal.contentEl.querySelector(".tn-task-modal__icon-button-trailing"); + expect(trailing).not.toBeNull(); + + const buttons = Array.from(trailing?.querySelectorAll("button") ?? []); + expect(buttons).toHaveLength(2); + expect(buttons[0].getAttribute("aria-label")).toBe("Cancel"); + expect(buttons[1].getAttribute("aria-label")).toBe("Confirm"); + expect( + buttons[0].querySelector(".tn-task-modal__icon-button-label")?.textContent + ).toBe("Cancel"); + expect( + buttons[1].querySelector(".tn-task-modal__icon-button-label")?.textContent + ).toBe("Confirm"); + expect(buttons[1].classList.contains("mod-cta")).toBe(true); + }); + + it("keeps labeled text buttons on desktop", () => { + const modal = createModal(); + modal.onOpen(); + + expect(modal.modalEl.classList.contains("tn-chip-list-editor--mobile-actions")).toBe(false); + + const buttonBar = modal.contentEl.querySelector(".tn-chip-list-editor__buttons"); + expect(buttonBar?.classList.contains("tn-task-modal__icon-button-bar")).toBe(false); + + const buttons = Array.from(buttonBar?.querySelectorAll("button") ?? []); + expect(buttons).toHaveLength(2); + expect(buttons[0].textContent).toBe("Cancel"); + expect(buttons[1].textContent).toBe("Confirm"); + }); +}); diff --git a/tests/unit/modals/taskModalChipRow.test.ts b/tests/unit/modals/taskModalChipRow.test.ts new file mode 100644 index 000000000..b6e4dde8d --- /dev/null +++ b/tests/unit/modals/taskModalChipRow.test.ts @@ -0,0 +1,92 @@ +const mockSetIcon = jest.fn(); +const mockSetTooltip = jest.fn(); + +jest.mock("obsidian", () => ({ + setIcon: mockSetIcon, + setTooltip: mockSetTooltip, +})); + +import { + createTaskModalChip, + createTaskModalChipBreak, + createTaskModalChipRow, + createTaskModalChips, +} from "../../../src/modals/taskModalChipRow"; + +describe("taskModalChipRow", () => { + beforeEach(() => { + document.body.innerHTML = ""; + mockSetIcon.mockClear(); + mockSetTooltip.mockClear(); + }); + + it("creates an accessible chip with icon, label, and click activation", () => { + const container = document.createElement("div"); + const onClick = jest.fn(); + + const chip = createTaskModalChip(container, { + iconName: "star", + label: "Priority", + dataType: "priority", + onClick, + }); + + expect(chip.classList.contains("tn-task-modal__chip")).toBe(true); + expect(chip.getAttribute("aria-label")).toBe("Priority"); + expect(chip.getAttribute("data-type")).toBe("priority"); + expect(chip.querySelector(".tn-task-modal__chip-label")?.textContent).toBe("Priority"); + expect(mockSetTooltip).toHaveBeenCalledWith(chip, "Priority", { placement: "top" }); + + const event = new MouseEvent("click", { bubbles: true, cancelable: true }); + chip.dispatchEvent(event); + expect(onClick).toHaveBeenCalledWith(chip, event); + }); + + it("creates chips in spec order inside a wrapping chip row", () => { + const container = document.createElement("div"); + const row = createTaskModalChipRow(container); + const chips = createTaskModalChips(row, [ + { + iconName: "dot-square", + label: "Status", + dataType: "status", + onClick: jest.fn(), + }, + { + iconName: "target", + label: "Due", + dataType: "due-date", + onClick: jest.fn(), + }, + ]); + + expect(row.classList.contains("tn-task-modal__chip-row")).toBe(true); + expect(chips.map((chip) => chip.dataset.type)).toEqual(["status", "due-date"]); + }); + + it("creates a flex line break that forces the next chips onto a new row", () => { + const container = document.createElement("div"); + const row = createTaskModalChipRow(container); + createTaskModalChip(row, { + iconName: "wand", + label: "Fill", + dataType: "nlp-fill", + onClick: jest.fn(), + }); + const lineBreak = createTaskModalChipBreak(row); + createTaskModalChip(row, { + iconName: "dot-square", + label: "Status", + dataType: "status", + onClick: jest.fn(), + }); + + expect(lineBreak.classList.contains("tn-task-modal__chip-break")).toBe(true); + expect(lineBreak.getAttribute("aria-hidden")).toBe("true"); + expect(Array.from(row.children).map((child) => child.className)).toEqual([ + "tn-task-modal__chip action-chip", + "tn-task-modal__chip-break", + "tn-task-modal__chip action-chip", + ]); + }); +}); diff --git a/tests/unit/modals/taskModalChipRowCss.test.ts b/tests/unit/modals/taskModalChipRowCss.test.ts new file mode 100644 index 000000000..d1677401c --- /dev/null +++ b/tests/unit/modals/taskModalChipRowCss.test.ts @@ -0,0 +1,35 @@ +import * as fs from "fs"; +import * as path from "path"; + +const sheetCssPath = path.resolve(__dirname, "../../../styles/task-modal-sheet.css"); + +function extractCssBlock(css: string, selector: string): string { + const escapedSelector = selector.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const regex = new RegExp(`${escapedSelector}\\s*\\{([^}]*?)\\}`, "s"); + const match = css.match(regex); + return match ? match[1] : ""; +} + +describe("task modal chip row CSS", () => { + const sheetCss = fs.readFileSync(sheetCssPath, "utf-8"); + + it("wraps create-task chips instead of using a horizontal scroll row", () => { + const chipRowBlock = extractCssBlock(sheetCss, ".tasknotes-plugin .tn-task-modal__chip-row"); + + expect(chipRowBlock).toContain("flex-wrap: wrap"); + expect(chipRowBlock).not.toContain("overflow-x: auto"); + expect(sheetCss).not.toMatch( + /\.tasknotes-plugin \.tn-task-modal__chip-row::-webkit-scrollbar/ + ); + }); + + it("forces property chips onto a new row after the NLP actions", () => { + const chipBreakBlock = extractCssBlock( + sheetCss, + ".tasknotes-plugin .tn-task-modal__chip-break" + ); + + expect(chipBreakBlock).toContain("flex-basis: 100%"); + expect(sheetCss).not.toMatch(/\.tasknotes-plugin \.tn-task-modal__chip-separator/); + }); +}); diff --git a/tests/unit/modals/taskModalCompletionsCalendarCss.test.ts b/tests/unit/modals/taskModalCompletionsCalendarCss.test.ts new file mode 100644 index 000000000..5eb0a4338 --- /dev/null +++ b/tests/unit/modals/taskModalCompletionsCalendarCss.test.ts @@ -0,0 +1,77 @@ +import * as fs from "fs"; +import * as path from "path"; + +const modalCssPath = path.resolve(__dirname, "../../../styles/task-modal.css"); +const sheetCssPath = path.resolve(__dirname, "../../../styles/task-modal-sheet.css"); + +function extractCssBlock(css: string, selector: string): string { + const escapedSelector = selector.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const regex = new RegExp(`${escapedSelector}\\s*\\{([^}]*?)\\}`, "s"); + const match = css.match(regex); + return match ? match[1] : ""; +} + +describe("task modal completions calendar CSS", () => { + const modalCss = fs.readFileSync(modalCssPath, "utf-8"); + const sheetCss = fs.readFileSync(sheetCssPath, "utf-8"); + + it("keeps the completions calendar at the compact desktop width", () => { + const contentBlock = extractCssBlock( + modalCss, + ".tasknotes-plugin.minimalist-task-modal .completions-calendar-content" + ); + const calendarBlock = extractCssBlock( + modalCss, + ".tasknotes-plugin.minimalist-task-modal .recurring-calendar" + ); + const labelBlock = extractCssBlock( + modalCss, + ".tasknotes-plugin.minimalist-task-modal .completions-calendar-container > .detail-label" + ); + + expect(contentBlock).toContain("width: 200px"); + expect(contentBlock).toContain("max-width: 100%"); + expect(calendarBlock).toContain("width: 200px"); + expect(labelBlock).toContain("text-transform: none"); + expect(labelBlock).toContain("margin: 0 0 var(--size-4-3) 0"); + expect(calendarBlock).toContain("max-width: 100%"); + expect(calendarBlock).toContain("margin-inline: auto"); + }); + + it("lays out the month header as a three-column grid so nav buttons keep space", () => { + const headerBlock = extractCssBlock(modalCss, ".tasknotes-plugin .recurring-calendar__header"); + const monthBlock = extractCssBlock(modalCss, ".tasknotes-plugin .recurring-calendar__month"); + + expect(headerBlock).toContain("display: grid"); + expect(headerBlock).toContain("grid-template-columns: minmax(20px, auto) minmax(0, 1fr) minmax(20px, auto)"); + expect(monthBlock).toContain("grid-column: 2"); + expect(monthBlock).toContain("text-align: center"); + }); + + it("styles nav icons through an inner wrapper so mobile does not hide button > svg", () => { + const navBlock = extractCssBlock(modalCss, ".tasknotes-plugin .recurring-calendar__nav"); + const iconBlock = extractCssBlock(modalCss, ".tasknotes-plugin .recurring-calendar__nav-icon"); + + expect(navBlock).toContain("flex-shrink: 0"); + expect(iconBlock).toContain("display: inline-flex"); + expect(iconBlock).toContain("line-height: 0"); + expect(modalCss).toMatch( + /\.tasknotes-plugin \.recurring-calendar__nav-icon svg,\s*\n\.tasknotes-plugin \.recurring-calendar__nav-icon \.svg-icon\s*\{[^}]*stroke:\s*currentColor/s + ); + expect(modalCss).not.toMatch(/\.tasknotes-plugin \.recurring-calendar__nav svg,/); + }); + + it("keeps the calendar grid from growing wider than its header on mobile sheet", () => { + const gridBlock = extractCssBlock(modalCss, ".tasknotes-plugin .recurring-calendar__grid"); + + expect(gridBlock).toContain("grid-template-columns: repeat(7, minmax(0, 1fr))"); + expect(gridBlock).toContain("width: 100%"); + const sheetCalendarContainerBlock = extractCssBlock( + sheetCss, + ".tasknotes-plugin.minimalist-task-modal.tn-task-modal--sheet .completions-calendar-container" + ); + + expect(sheetCalendarContainerBlock).toContain("overflow: visible"); + expect(sheetCalendarContainerBlock).not.toMatch(/padding-inline/); + }); +}); diff --git a/tests/unit/modals/taskModalDetailsEditor.test.ts b/tests/unit/modals/taskModalDetailsEditor.test.ts index d782fecf8..cdc33d9aa 100644 --- a/tests/unit/modals/taskModalDetailsEditor.test.ts +++ b/tests/unit/modals/taskModalDetailsEditor.test.ts @@ -19,7 +19,7 @@ describe("taskModalDetailsEditor", () => { jest.clearAllMocks(); }); - it("creates the details label, editor container, and markdown editor options", () => { + it("creates the editor container and markdown editor options without a persisting label", () => { const app = {} as never; const parent = document.createElement("div"); const editor = { @@ -37,7 +37,6 @@ describe("taskModalDetailsEditor", () => { const returnedEditor = createTaskModalDetailsEditor({ app, parent, - label: "Details", value: "Existing details", placeholder: "Add details", file, @@ -54,7 +53,7 @@ describe("taskModalDetailsEditor", () => { ".tn-task-modal__markdown-editor--details" ); expect(returnedEditor).toBe(editor); - expect(label?.textContent).toBe("Details"); + expect(label).toBeNull(); expect(container).not.toBeNull(); expect(createTaskModalMarkdownEditorMock).toHaveBeenCalledWith( app, @@ -86,7 +85,6 @@ describe("taskModalDetailsEditor", () => { createTaskModalDetailsEditor({ app: {} as never, parent, - label: "Details", value: "", placeholder: "", tabMovesFocus: true, @@ -114,7 +112,6 @@ describe("taskModalDetailsEditor", () => { createTaskModalDetailsEditor({ app: {} as never, parent, - label: "Details", value: "", placeholder: "", tabMovesFocus: false, @@ -147,4 +144,77 @@ describe("taskModalDetailsEditor", () => { expect(editor.setValue).toHaveBeenCalledWith("Parsed details"); expect(editor.destroy).toHaveBeenCalledTimes(1); }); + + it("focuses the markdown editor when clicking the empty area of the details field", () => { + const parent = document.createElement("div"); + const focus = jest.fn(); + createTaskModalMarkdownEditorMock.mockReturnValue({ + destroy: jest.fn(), + editor: { cm: { focus } }, + } as never); + + createTaskModalDetailsEditor({ + app: {} as never, + parent, + value: "", + placeholder: "Description", + tabMovesFocus: false, + onChange: jest.fn(), + onSubmit: jest.fn(), + onEscape: jest.fn(), + focusNextField: jest.fn(), + focusPreviousField: jest.fn(), + }); + + const container = parent.querySelector( + ".tn-task-modal__markdown-editor--details" + ); + expect(container).not.toBeNull(); + + container?.dispatchEvent( + new MouseEvent("mousedown", { bubbles: true, cancelable: true, button: 0 }) + ); + + expect(focus).toHaveBeenCalledTimes(1); + }); + + it("focuses the fallback textarea when clicking the empty area of the details field", () => { + const parent = document.createElement("div"); + createTaskModalMarkdownEditorMock.mockImplementation((_app, container, options) => { + const fallback = container.createEl("textarea", { + cls: `${options.cls}-fallback`, + }); + Object.defineProperty(fallback, "focus", { + value: jest.fn(), + configurable: true, + }); + return null; + }); + + createTaskModalDetailsEditor({ + app: {} as never, + parent, + value: "", + placeholder: "Description", + tabMovesFocus: false, + onChange: jest.fn(), + onSubmit: jest.fn(), + onEscape: jest.fn(), + focusNextField: jest.fn(), + focusPreviousField: jest.fn(), + }); + + const container = parent.querySelector( + ".tn-task-modal__markdown-editor--details" + ); + const fallback = parent.querySelector(".details-editor-fallback"); + expect(container).not.toBeNull(); + expect(fallback).not.toBeNull(); + + container?.dispatchEvent( + new MouseEvent("mousedown", { bubbles: true, cancelable: true, button: 0 }) + ); + + expect(fallback?.focus).toHaveBeenCalledTimes(1); + }); }); diff --git a/tests/unit/modals/taskModalEditDesktopScrollCss.test.ts b/tests/unit/modals/taskModalEditDesktopScrollCss.test.ts new file mode 100644 index 000000000..c73736753 --- /dev/null +++ b/tests/unit/modals/taskModalEditDesktopScrollCss.test.ts @@ -0,0 +1,25 @@ +import * as fs from "fs"; +import * as path from "path"; + +const sheetCssPath = path.resolve(__dirname, "../../../styles/task-modal-sheet.css"); + +function extractCssBlock(css: string, selector: string): string { + const escapedSelector = selector.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const regex = new RegExp(`${escapedSelector}\\s*\\{([^}]*?)\\}`, "s"); + const match = css.match(regex); + return match ? match[1] : ""; +} + +describe("task modal edit desktop scroll CSS", () => { + const sheetCss = fs.readFileSync(sheetCssPath, "utf-8"); + + it("keeps a single scroll container with inset padding before the scrollbar", () => { + const panesBlock = extractCssBlock( + sheetCss, + ".tasknotes-plugin.minimalist-task-modal.tn-task-modal--edit-desktop.expanded .tn-task-modal__edit-panes" + ); + + expect(panesBlock).toContain("overflow-y: auto"); + expect(panesBlock).toContain("padding-right: var(--size-4-4)"); + }); +}); diff --git a/tests/unit/modals/taskModalFocusGuards.test.ts b/tests/unit/modals/taskModalFocusGuards.test.ts index 641cc3d6c..e8d1e0977 100644 --- a/tests/unit/modals/taskModalFocusGuards.test.ts +++ b/tests/unit/modals/taskModalFocusGuards.test.ts @@ -76,6 +76,21 @@ describe("taskModalFocusGuards", () => { expect(scrollContainer.scrollTop).toBe(44); }); + it("does not scroll sheet content when the bottom sheet layout is active", () => { + const elements = createElements(); + elements.containerEl.classList.add("tn-task-modal--sheet"); + const guards = new TaskModalFocusGuards(elements); + const scrollContainer = elements.contentEl.createDiv({ cls: "modal-split-content" }); + const settingItem = scrollContainer.createDiv({ cls: "setting-item" }); + const input = settingItem.createEl("input"); + + guards.attachMobileKeyboardScrollGuard(input); + input.dispatchEvent(new Event("focus")); + jest.runOnlyPendingTimers(); + + expect(HTMLElement.prototype.scrollIntoView).not.toHaveBeenCalled(); + }); + it("scrolls mobile keyboard fields into view and cleans up on destroy", () => { const elements = createElements(); const guards = new TaskModalFocusGuards(elements); diff --git a/tests/unit/modals/taskModalIconButtonBarCss.test.ts b/tests/unit/modals/taskModalIconButtonBarCss.test.ts new file mode 100644 index 000000000..34628369c --- /dev/null +++ b/tests/unit/modals/taskModalIconButtonBarCss.test.ts @@ -0,0 +1,110 @@ +import * as fs from "fs"; +import * as path from "path"; + +const modalCssPath = path.resolve(__dirname, "../../../styles/task-modal.css"); +const sheetCssPath = path.resolve(__dirname, "../../../styles/task-modal-sheet.css"); +const modalBemCssPath = path.resolve(__dirname, "../../../styles/modal-bem.css"); + +function extractCssBlock(css: string, selector: string): string { + const escapedSelector = selector.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const regex = new RegExp(`${escapedSelector}\\s*\\{([^}]*?)\\}`, "s"); + const match = css.match(regex); + return match ? match[1] : ""; +} + +describe("task modal icon button bar CSS", () => { + const modalCss = fs.readFileSync(modalCssPath, "utf-8"); + const sheetCss = fs.readFileSync(sheetCssPath, "utf-8"); + const modalBemCss = fs.readFileSync(modalBemCssPath, "utf-8"); + + it("keeps icon button bars on a single horizontal row", () => { + const iconButtonBarBlock = extractCssBlock( + sheetCss, + ".tasknotes-plugin .modal-button-container.tn-task-modal__icon-button-bar" + ); + + expect(iconButtonBarBlock).toContain("flex-direction: row"); + expect(iconButtonBarBlock).toContain("flex-wrap: nowrap"); + expect(sheetCss).toMatch( + /\.tasknotes-plugin \.tn-task-modal__icon-button-leading,\s*\n\.tasknotes-plugin \.tn-task-modal__icon-button-trailing\s*\{[^}]*flex-direction:\s*row/s + ); + expect(sheetCss).toMatch( + /\.tasknotes-plugin \.tn-task-modal__icon-button-trailing\s*\{[^}]*margin-left:\s*auto/s + ); + }); + + it("does not apply legacy text-button footer rules to icon button bars", () => { + expect(modalBemCss).toMatch( + /\.tasknotes-plugin \.modal-button-container:not\(\.tn-task-modal__icon-button-bar\)/ + ); + expect( + extractCssBlock( + modalCss, + ".modal.mod-tasknotes .modal-button-container.tn-task-modal__icon-button-bar" + ) + ).toContain("flex-direction: row"); + }); + + it("shows action button labels on desktop while keeping mobile icon-only", () => { + expect(sheetCss).toMatch( + /\.tasknotes-plugin \.tn-task-modal__icon-button-label\s*\{[^}]*display:\s*none/s + ); + expect(sheetCss).toMatch( + /body:not\(\.is-mobile\) \.tasknotes-plugin \.tn-task-modal__icon-button-label\s*\{[^}]*display:\s*inline/s + ); + expect(sheetCss).toMatch( + /body:not\(\.is-mobile\) \.tasknotes-plugin \.tn-task-modal__icon-button\s*\{[^}]*width:\s*auto/s + ); + }); + + it("keeps non-save footer buttons outlined and the save button filled on sheet layouts", () => { + expect( + extractCssBlock( + sheetCss, + ".tasknotes-plugin .tn-task-modal__icon-button:not(.mod-cta)" + ) + ).toContain("background: transparent"); + expect( + extractCssBlock( + sheetCss, + ".tasknotes-plugin .tn-task-modal__icon-button:not(.mod-cta)" + ) + ).toContain("border: 1px solid var(--background-modifier-border)"); + expect( + extractCssBlock( + sheetCss, + ".tasknotes-plugin.minimalist-task-modal.tn-task-modal--sheet .tn-task-modal__icon-button:not(.mod-cta)" + ) + ).toContain("border-color: var(--background-modifier-border)"); + expect( + extractCssBlock( + sheetCss, + ".tasknotes-plugin .tn-task-modal__icon-button.mod-cta" + ) + ).toContain("background: var(--interactive-accent)"); + expect(sheetCss).toMatch( + /\.tasknotes-plugin \.tn-task-modal__icon-button\.mod-cta:hover,\s*\n\.tasknotes-plugin \.tn-task-modal__icon-button\.mod-cta:active\s*\{[^}]*background:\s*var\(--interactive-accent-hover\)/s + ); + }); + + it("removes Obsidian default button shadows from footer icon buttons", () => { + expect( + extractCssBlock(sheetCss, ".tasknotes-plugin .tn-task-modal__icon-button") + ).toContain("box-shadow: none"); + expect(sheetCss).toMatch( + /\.tasknotes-plugin \.tn-task-modal__icon-button\.mod-cta:hover,\s*\n\.tasknotes-plugin \.tn-task-modal__icon-button\.mod-cta:active\s*\{[^}]*box-shadow:\s*none/s + ); + }); + + it("vertically centers icon button icons with their labels", () => { + expect( + extractCssBlock(sheetCss, ".tasknotes-plugin .tn-task-modal__icon-button-icon") + ).toContain("align-items: center"); + expect(sheetCss).toMatch( + /body:not\(\.is-mobile\) \.tasknotes-plugin \.tn-task-modal__icon-button-label\s*\{[^}]*display:\s*inline-flex/s + ); + expect(sheetCss).toMatch( + /body:not\(\.is-mobile\) \.tasknotes-plugin \.tn-task-modal__icon-button-label\s*\{[^}]*align-items:\s*center/s + ); + }); +}); diff --git a/tests/unit/modals/taskModalLayout.test.ts b/tests/unit/modals/taskModalLayout.test.ts index 42f8085f1..152626343 100644 --- a/tests/unit/modals/taskModalLayout.test.ts +++ b/tests/unit/modals/taskModalLayout.test.ts @@ -1,6 +1,8 @@ import { collapseTaskModalDetailsLayout, expandTaskModalDetailsLayout, + shouldUseEditSidebarLayout, + shouldUseSplitLayoutEnabledClass, } from "../../../src/modals/taskModalLayout"; describe("taskModalLayout", () => { @@ -92,3 +94,91 @@ describe("taskModalLayout", () => { ).toBe(true); }); }); + +describe("shouldUseEditSidebarLayout", () => { + it("uses the edit sidebar for desktop edit modals", () => { + expect( + shouldUseEditSidebarLayout({ + isEditMode: true, + isCreationMode: false, + isExpanded: false, + isMobileLikeEnvironment: false, + }) + ).toBe(true); + }); + + it("uses the edit sidebar for expanded desktop creation modals", () => { + expect( + shouldUseEditSidebarLayout({ + isEditMode: false, + isCreationMode: true, + isExpanded: true, + isMobileLikeEnvironment: false, + }) + ).toBe(true); + }); + + it("keeps collapsed desktop creation modals on the chip-row layout", () => { + expect( + shouldUseEditSidebarLayout({ + isEditMode: false, + isCreationMode: true, + isExpanded: false, + isMobileLikeEnvironment: false, + }) + ).toBe(false); + }); + + it("never uses the edit sidebar on mobile-like environments", () => { + expect( + shouldUseEditSidebarLayout({ + isEditMode: true, + isCreationMode: false, + isExpanded: true, + isMobileLikeEnvironment: true, + }) + ).toBe(false); + }); +}); + +describe("shouldUseSplitLayoutEnabledClass", () => { + it("enables the legacy split layout on a wide desktop window when the setting is on", () => { + expect( + shouldUseSplitLayoutEnabledClass({ + enableModalSplitLayout: true, + usesEditSidebarLayout: false, + usesSheetLayout: false, + }) + ).toBe(true); + }); + + it("stays disabled when the setting is off", () => { + expect( + shouldUseSplitLayoutEnabledClass({ + enableModalSplitLayout: false, + usesEditSidebarLayout: false, + usesSheetLayout: false, + }) + ).toBe(false); + }); + + it("stays disabled for the desktop edit sidebar layout", () => { + expect( + shouldUseSplitLayoutEnabledClass({ + enableModalSplitLayout: true, + usesEditSidebarLayout: true, + usesSheetLayout: false, + }) + ).toBe(false); + }); + + it("never combines with the mobile/touch bottom-sheet layout, even on a wide viewport", () => { + expect( + shouldUseSplitLayoutEnabledClass({ + enableModalSplitLayout: true, + usesEditSidebarLayout: false, + usesSheetLayout: true, + }) + ).toBe(false); + }); +}); diff --git a/tests/unit/modals/taskModalMetadataContentCss.test.ts b/tests/unit/modals/taskModalMetadataContentCss.test.ts new file mode 100644 index 000000000..b0b9b9eb4 --- /dev/null +++ b/tests/unit/modals/taskModalMetadataContentCss.test.ts @@ -0,0 +1,119 @@ +import * as fs from "fs"; +import * as path from "path"; + +const modalCssPath = path.resolve(__dirname, "../../../styles/task-modal.css"); +const sheetCssPath = path.resolve(__dirname, "../../../styles/task-modal-sheet.css"); + +function extractCssBlock(css: string, selector: string): string { + const escapedSelector = selector.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const regex = new RegExp(`${escapedSelector}\\s*\\{([^}]*?)\\}`, "s"); + const match = css.match(regex); + return match ? match[1] : ""; +} + +describe("task modal metadata content CSS", () => { + const modalCss = fs.readFileSync(modalCssPath, "utf-8"); + const sheetCss = fs.readFileSync(sheetCssPath, "utf-8"); + + it("keeps legacy metadata padding for non-edit sections", () => { + const metadataContentBlock = extractCssBlock( + modalCss, + ".tasknotes-plugin .metadata-container:not(.tn-task-modal__task-info) > .metadata-content" + ); + + expect(metadataContentBlock).toContain("padding: var(--size-4-3)"); + expect(metadataContentBlock).toContain("min-width: 0"); + expect(metadataContentBlock).toContain("box-sizing: border-box"); + }); + + it("renders task information as a grouped card with aligned monospace rows", () => { + const taskInfoCardBlock = extractCssBlock( + modalCss, + ".tasknotes-plugin .tn-task-modal__task-info-card" + ); + const taskInfoRowBlock = extractCssBlock( + modalCss, + ".tasknotes-plugin .tn-task-modal__task-info-card .tn-task-modal__task-info-row" + ); + + expect(taskInfoCardBlock).toContain("background: var(--background-secondary)"); + expect(taskInfoCardBlock).toContain("border: 1px solid var(--background-modifier-border)"); + expect(taskInfoCardBlock).toContain("border-radius: var(--radius-l)"); + expect(taskInfoRowBlock).toContain("display: grid"); + expect(taskInfoRowBlock).toContain("grid-template-columns: auto minmax(0, 1fr)"); + expect(modalCss).toMatch( + /\.tasknotes-plugin \.tn-task-modal__task-info-card \.tn-task-modal__task-info-key,\s*\n\.tasknotes-plugin \.tn-task-modal__task-info-card \.tn-task-modal__task-info-value\s*\{[^}]*font-family:\s*var\(--font-monospace\)/s + ); + expect(modalCss).toMatch( + /\.tasknotes-plugin \.tn-task-modal__task-info-card \.tn-task-modal__task-info-value\s*\{[^}]*overflow-wrap:\s*anywhere/s + ); + }); + + it("removes the divider above completions in the redesigned edit modal", () => { + const sectionContainerBlock = extractCssBlock( + modalCss, + ".tasknotes-plugin.minimalist-task-modal .completions-calendar-container" + ); + + expect(sectionContainerBlock).toContain("border-top: none"); + expect(sectionContainerBlock).toContain("padding-top: 0"); + }); + + it("removes the divider above task information in the redesigned edit modal", () => { + const taskInfoBlock = extractCssBlock(modalCss, ".tasknotes-plugin .tn-task-modal__task-info"); + + expect(taskInfoBlock).toContain("border-top: none"); + expect(taskInfoBlock).toContain("padding-top: 0"); + expect(taskInfoBlock).toContain("margin-top: var(--size-4-6)"); + }); + + it("adds vertical spacing between completions and task information", () => { + const completionsBlock = extractCssBlock( + modalCss, + ".tasknotes-plugin.minimalist-task-modal .completions-calendar-container" + ); + + expect(completionsBlock).toContain("margin-top: var(--size-4-6)"); + }); + + it("uses section margin instead of editor margin below description on desktop edit", () => { + const descriptionBlock = extractCssBlock( + sheetCss, + ".tasknotes-plugin.minimalist-task-modal.tn-task-modal--edit-desktop .modal-split-right .tn-task-modal__markdown-editor--details" + ); + + expect(descriptionBlock).toContain("margin-bottom: 0"); + }); + + it("keeps the mobile sheet content area on modal background so the task information card matches desktop", () => { + const modalContentBlock = extractCssBlock( + sheetCss, + ".tasknotes-plugin.minimalist-task-modal.tn-task-modal--sheet .modal-content,\n.tasknotes-plugin.minimalist-task-modal.tn-task-modal--sheet .minimalist-modal-container" + ); + const taskInfoBlock = extractCssBlock( + sheetCss, + ".tasknotes-plugin.minimalist-task-modal.tn-task-modal--sheet .tn-task-modal__task-info" + ); + + expect(modalContentBlock).toContain("background: var(--modal-background)"); + expect(taskInfoBlock).toContain("border-top: none"); + expect(taskInfoBlock).toContain("padding-top: 0"); + }); + + it("keeps the task information title flush while padding rows inside the card", () => { + const labelBlock = extractCssBlock( + modalCss, + ".tasknotes-plugin .tn-task-modal__task-info > .detail-label" + ); + const taskInfoCardBlock = extractCssBlock( + modalCss, + ".tasknotes-plugin .tn-task-modal__task-info-card" + ); + + expect(labelBlock).toContain("padding: 0"); + expect(labelBlock).toContain("text-transform: none"); + expect(labelBlock).toContain("margin: 0 0 var(--size-4-3) 0"); + expect(taskInfoCardBlock).toContain("padding: var(--size-4-3)"); + expect(taskInfoCardBlock).toContain("border-radius: var(--radius-l)"); + }); +}); diff --git a/tests/unit/modals/taskModalMobileOrgOrderCss.test.ts b/tests/unit/modals/taskModalMobileOrgOrderCss.test.ts new file mode 100644 index 000000000..106b06648 --- /dev/null +++ b/tests/unit/modals/taskModalMobileOrgOrderCss.test.ts @@ -0,0 +1,41 @@ +/** + * Mobile sheet rows must follow the desktop edit sidebar order inside one + * grouped card, not as separate blocks. + */ + +import * as fs from "fs"; +import * as path from "path"; + +const sheetCssPath = path.resolve(__dirname, "../../../styles/task-modal-sheet.css"); + +function extractOrder(css: string, selector: string): number { + const escapedSelector = selector.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const regex = new RegExp(`${escapedSelector}\\s*\\{([^}]*?)\\}`, "s"); + const match = css.match(regex); + if (!match) { + throw new Error(`Could not find CSS block for selector: ${selector}`); + } + const orderMatch = match[1].match(/order:\s*(-?\d+)/); + if (!orderMatch) { + throw new Error(`Could not find an order declaration for selector: ${selector}`); + } + return Number(orderMatch[1]); +} + +describe("task modal mobile sheet section CSS ordering", () => { + const sheetCss = fs.readFileSync(sheetCssPath, "utf-8"); + + it("uses a single mobile sections block after the description column", () => { + const descriptionOrder = extractOrder( + sheetCss, + ".tasknotes-plugin.minimalist-task-modal .modal-split-right" + ); + const sectionsOrder = extractOrder( + sheetCss, + ".tasknotes-plugin.minimalist-task-modal .modal-split-left .tn-task-modal__mobile-sections" + ); + + expect(descriptionOrder).toBeLessThan(sectionsOrder); + expect(sheetCss).not.toMatch(/tn-task-modal__mobile-org-sections/); + }); +}); diff --git a/tests/unit/modals/taskModalOrgCounts.test.ts b/tests/unit/modals/taskModalOrgCounts.test.ts new file mode 100644 index 000000000..5596afd5e --- /dev/null +++ b/tests/unit/modals/taskModalOrgCounts.test.ts @@ -0,0 +1,53 @@ +import type TaskNotesPlugin from "../../../src/main"; +import { + countTaskModalCompletion, + formatTaskModalCompletionCount, + parseTaskModalCommaList, +} from "../../../src/modals/taskModalOrgCounts"; + +function createPlugin(taskStatuses: Record): TaskNotesPlugin { + return { + cacheManager: { + getCachedTaskInfoSync: (path: string) => + taskStatuses[path] + ? { + path, + status: taskStatuses[path], + } + : null, + }, + statusManager: { + isCompletedStatus: (status: string) => status === "done", + }, + } as unknown as TaskNotesPlugin; +} + +describe("taskModalOrgCounts", () => { + it("parses comma-separated lists", () => { + expect(parseTaskModalCommaList(" home , work ")).toEqual(["home", "work"]); + }); + + it("counts completed tasks and includes unresolved/non-task entries in the total", () => { + const plugin = createPlugin({ + "a.md": "done", + "b.md": "open", + }); + + expect( + countTaskModalCompletion(plugin, ["a.md", "b.md", "note.md", undefined]) + ).toEqual({ + completed: 1, + total: 4, + }); + }); + + it("formats completion counts for organization headers", () => { + expect( + formatTaskModalCompletionCount( + (key, params) => `${key}:${params?.completed}/${params?.total}`, + 2, + 5 + ) + ).toBe("modals.task.organization.completionCount:2/5"); + }); +}); diff --git a/tests/unit/modals/taskModalOrganizationFields.test.ts b/tests/unit/modals/taskModalOrganizationFields.test.ts index 1e92d8d34..08b258c61 100644 --- a/tests/unit/modals/taskModalOrganizationFields.test.ts +++ b/tests/unit/modals/taskModalOrganizationFields.test.ts @@ -1,15 +1,21 @@ import { createTaskModalBlockedByField, createTaskModalBlockingField, - createTaskModalListField, createTaskModalProjectsField, createTaskModalSubtasksField, type TaskModalOrganizationFieldContext, } from "../../../src/modals/taskModalOrganizationFields"; +jest.mock("obsidian", () => ({ + setIcon: jest.fn((element: HTMLElement) => { + element.textContent = "icon"; + }), +})); + function createContext(): TaskModalOrganizationFieldContext { return { - translate: (key) => `translated:${key}`, + translate: (key, params) => + params ? `${key}:${JSON.stringify(params)}` : `translated:${key}`, }; } @@ -19,112 +25,95 @@ describe("taskModalOrganizationFields", () => { jest.clearAllMocks(); }); - it("creates a generic list field with ghost button styling and a reusable list element", () => { + it("creates an empty projects row with a sidebar row and chevron", () => { const container = document.createElement("div"); - const existingList = document.createElement("div"); const onButtonClick = jest.fn(); - const list = createTaskModalListField(container, { - label: "Projects", - buttonText: "Add project", - buttonTooltip: "Choose a project", - onButtonClick, - listElement: existingList, - }); - - const button = container.querySelector("button"); - expect(list).toBe(existingList); - expect(container.textContent).toContain("Projects"); - expect(button?.textContent).toBe("Add project"); - expect(button?.title).toBe("Choose a project"); - expect(button?.classList.contains("tn-btn")).toBe(true); - expect(button?.classList.contains("tn-btn--ghost")).toBe(true); - - button?.click(); - expect(onButtonClick).toHaveBeenCalledTimes(1); - }); - - it("creates the projects field with translated copy and a new list element", () => { - const container = document.createElement("div"); - const onButtonClick = jest.fn(); - - const list = createTaskModalProjectsField(createContext(), { + const row = createTaskModalProjectsField(createContext(), { container, onButtonClick, + getItemCount: () => 0, }); - const button = container.querySelector("button"); - expect(list.classList.contains("task-projects-list")).toBe(true); - expect(container.textContent).toContain("translated:modals.task.organization.projects"); - expect(button?.textContent).toBe( - "translated:modals.task.organization.addToProjectButton" + const emptyRow = container.querySelector( + ".tn-task-modal__org-empty-row .tn-task-modal__sidebar-row" + ); + expect(row.listElement.classList.contains("tn-task-modal__org-list")).toBe(true); + expect(emptyRow?.querySelector(".tn-task-modal__sidebar-row-icon")).not.toBeNull(); + expect(emptyRow?.querySelector(".tn-task-modal__sidebar-row-chevron")).not.toBeNull(); + expect(emptyRow?.querySelector(".tn-task-modal__sidebar-row-label")?.textContent).toBe( + "translated:modals.task.organization.projects" + ); + expect(container.querySelector(".tn-task-modal__org-header")?.hasAttribute("hidden")).toBe( + true ); - expect(button?.title).toBe("translated:modals.task.projectsTooltip"); - button?.click(); + emptyRow?.click(); expect(onButtonClick).toHaveBeenCalledTimes(1); }); - it("creates the subtasks field with translated copy and preserves existing lists", () => { + it("shows section title without a completion count when items exist", () => { const container = document.createElement("div"); - const existingList = document.createElement("div"); - const onButtonClick = jest.fn(); - - const list = createTaskModalSubtasksField(createContext(), { + const row = createTaskModalProjectsField(createContext(), { container, - onButtonClick, - listElement: existingList, + onButtonClick: jest.fn(), + getItemCount: () => 2, }); - const button = container.querySelector("button"); - expect(list).toBe(existingList); - expect(container.textContent).toContain("translated:modals.task.organization.subtasks"); - expect(button?.textContent).toBe( - "translated:modals.task.organization.addSubtasksButton" - ); - expect(button?.title).toBe("translated:modals.task.organization.addSubtasksTooltip"); + row.updateHeader(); - button?.click(); - expect(onButtonClick).toHaveBeenCalledTimes(1); + expect(container.querySelector(".tn-task-modal__org-header-title")?.textContent).toBe( + "translated:modals.task.organization.projects" + ); + expect(container.querySelector(".tn-task-modal__org-header-count")).toBeNull(); }); - it("creates the blocked-by dependency field with translated copy", () => { + it("shows section title and add button when items exist", () => { const container = document.createElement("div"); const onButtonClick = jest.fn(); - - const list = createTaskModalBlockedByField(createContext(), { + const row = createTaskModalSubtasksField(createContext(), { container, onButtonClick, + getItemCount: () => 3, }); - const button = container.querySelector("button"); - expect(list.classList.contains("task-projects-list")).toBe(true); - expect(container.textContent).toContain("translated:modals.task.dependencies.blockedBy"); - expect(button?.textContent).toBe("translated:modals.task.dependencies.addTaskButton"); - expect(button?.title).toBe("translated:modals.task.dependencies.selectTaskTooltip"); + row.updateHeader(); + + expect(container.querySelector(".tn-task-modal__org-empty-row")?.hidden).toBe(true); + expect(container.querySelector(".tn-task-modal__org-header-title")?.textContent).toBe( + "translated:modals.task.organization.subtasks" + ); + expect(container.querySelector(".tn-task-modal__org-header-count")).toBeNull(); + + const titleLabel = container.querySelector(".tn-task-modal__org-header-label"); + expect(titleLabel?.tagName).toBe("DIV"); + titleLabel?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + expect(onButtonClick).not.toHaveBeenCalled(); - button?.click(); + container.querySelector(".tn-task-modal__org-add-button")?.click(); expect(onButtonClick).toHaveBeenCalledTimes(1); }); - it("creates the blocking dependency field with translated copy and preserves existing lists", () => { - const container = document.createElement("div"); - const existingList = document.createElement("div"); - const onButtonClick = jest.fn(); + it("creates blocked-by and blocking rows with translated section labels", () => { + const blockedByContainer = document.createElement("div"); + const blockingContainer = document.createElement("div"); - const list = createTaskModalBlockingField(createContext(), { - container, - onButtonClick, - listElement: existingList, + createTaskModalBlockedByField(createContext(), { + container: blockedByContainer, + onButtonClick: jest.fn(), + getItemCount: () => 0, + }); + createTaskModalBlockingField(createContext(), { + container: blockingContainer, + onButtonClick: jest.fn(), + getItemCount: () => 0, }); - const button = container.querySelector("button"); - expect(list).toBe(existingList); - expect(container.textContent).toContain("translated:modals.task.dependencies.blocking"); - expect(button?.textContent).toBe("translated:modals.task.dependencies.addTaskButton"); - expect(button?.title).toBe("translated:modals.task.dependencies.selectTaskTooltip"); - - button?.click(); - expect(onButtonClick).toHaveBeenCalledTimes(1); + expect( + blockedByContainer.querySelector(".tn-task-modal__sidebar-row-label")?.textContent + ).toBe("translated:modals.task.dependencies.blockedBy"); + expect( + blockingContainer.querySelector(".tn-task-modal__sidebar-row-label")?.textContent + ).toBe("translated:modals.task.dependencies.blocking"); }); }); diff --git a/tests/unit/modals/taskModalOrganizationFieldsHiddenCss.test.ts b/tests/unit/modals/taskModalOrganizationFieldsHiddenCss.test.ts new file mode 100644 index 000000000..bd1cf542f --- /dev/null +++ b/tests/unit/modals/taskModalOrganizationFieldsHiddenCss.test.ts @@ -0,0 +1,35 @@ +/** + * The organization row header/empty-button/details-toggle rules set an + * unconditional `display` value, which has the same specificity as the + * browser's default `[hidden] { display: none }` rule. Without an explicit + * `[hidden]` override, toggling the `hidden` DOM property does not actually + * hide the element, causing the "Add X" button, the (empty) section header, + * and its "+" button to all render at once for empty organization sections. + */ + +import * as fs from "fs"; +import * as path from "path"; + +const cssFilePath = path.resolve(__dirname, "../../../styles/task-modal-sheet.css"); + +describe("task modal organization/details hidden-state CSS", () => { + const cssContent = fs.readFileSync(cssFilePath, "utf-8"); + + it("hides the organization header when marked hidden", () => { + expect(cssContent).toMatch( + /\.tn-task-modal__org-header\[hidden\]\s*\{\s*display:\s*none;\s*\}/ + ); + }); + + it("hides the organization empty row when marked hidden", () => { + expect(cssContent).toMatch( + /\.tn-task-modal__org-empty-row\[hidden\]\s*\{\s*display:\s*none;\s*\}/ + ); + }); + + it("hides the details show-more toggle when marked hidden", () => { + expect(cssContent).toMatch( + /\.tn-task-modal__details-toggle\[hidden\]\s*\{\s*display:\s*none;\s*\}/ + ); + }); +}); diff --git a/tests/unit/modals/taskModalPropertyMenus.test.ts b/tests/unit/modals/taskModalPropertyMenus.test.ts new file mode 100644 index 000000000..715a0a3a3 --- /dev/null +++ b/tests/unit/modals/taskModalPropertyMenus.test.ts @@ -0,0 +1,122 @@ +import { + finalizeTaskModalChipListEditorValues, + formatTaskModalChipListEditorLabel, +} from "../../../src/modals/taskModalChipListEditor"; +import { + showTaskModalContextsInput, + showTaskModalTagsInput, +} from "../../../src/modals/taskModalPropertyMenus"; + +const mockShowTaskModalChipListEditor = jest.fn(); + +jest.mock("../../../src/modals/taskModalChipListEditor", () => { + const actual = jest.requireActual("../../../src/modals/taskModalChipListEditor"); + return { + ...actual, + showTaskModalChipListEditor: (...args: unknown[]) => + mockShowTaskModalChipListEditor(...args), + }; +}); + +function createPlugin() { + return { + app: {}, + settings: { + taskTag: "task", + taskIdentificationMethod: "tag", + hideIdentifyingTagsInCards: false, + }, + cacheManager: { + getAllContexts: jest.fn(() => ["home", "office", "errands"]), + getAllTags: jest.fn(() => ["review", "writing"]), + }, + }; +} + +function createMenuContext(overrides: Partial[0]> = {}) { + return { + plugin: createPlugin() as never, + translate: (key: string, params?: Record) => + params ? `${key}:${JSON.stringify(params)}` : key, + getContexts: () => "home, office", + setContexts: jest.fn(), + getTags: () => "review, writing", + setTags: jest.fn(), + getTimeEstimate: () => 0, + setTimeEstimate: jest.fn(), + onChange: jest.fn(), + ...overrides, + }; +} + +describe("taskModalPropertyMenus", () => { + beforeEach(() => { + mockShowTaskModalChipListEditor.mockReset(); + }); + + it("opens the contexts chip editor with current values", async () => { + const context = createMenuContext(); + mockShowTaskModalChipListEditor.mockResolvedValue(null); + + await showTaskModalContextsInput(context); + + expect(mockShowTaskModalChipListEditor).toHaveBeenCalledWith( + context.plugin.app, + context.plugin, + expect.objectContaining({ + title: "modals.task.contextsLabel", + placeholder: "contextMenus.task.organization.addContext", + variant: "contexts", + initialValues: ["home", "office"], + }), + ["home", "office"] + ); + }); + + it("saves updated contexts from the chip editor", async () => { + const context = createMenuContext(); + mockShowTaskModalChipListEditor.mockResolvedValue(["home", "errands"]); + + await showTaskModalContextsInput(context); + + expect(context.setContexts).toHaveBeenCalledWith("home, errands"); + expect(context.onChange).toHaveBeenCalled(); + }); + + it("opens the tags chip editor with current values", async () => { + const context = createMenuContext(); + mockShowTaskModalChipListEditor.mockResolvedValue(null); + + await showTaskModalTagsInput(context); + + expect(mockShowTaskModalChipListEditor).toHaveBeenCalledWith( + context.plugin.app, + context.plugin, + expect.objectContaining({ + title: "modals.task.tagsLabel", + placeholder: "contextMenus.task.addTag", + variant: "tags", + initialValues: ["review", "writing"], + }), + ["review", "writing"] + ); + }); +}); + +describe("taskModalChipListEditor helpers", () => { + it("formats tag chip labels with a hash prefix", () => { + expect(formatTaskModalChipListEditorLabel("tags", "review")).toBe("#review"); + expect(formatTaskModalChipListEditorLabel("contexts", "home")).toBe("home"); + }); + + it("finalizes chip editor values including pending input", () => { + expect( + finalizeTaskModalChipListEditorValues(["home"], "office, errands", "contexts") + ).toEqual(["home", "office", "errands"]); + + expect(finalizeTaskModalChipListEditorValues(["review"], "writing", "tags")).toEqual([ + "review", + "writing", + ]); + }); +}); diff --git a/tests/unit/modals/taskModalSheetFooterCss.test.ts b/tests/unit/modals/taskModalSheetFooterCss.test.ts new file mode 100644 index 000000000..7a959ce4d --- /dev/null +++ b/tests/unit/modals/taskModalSheetFooterCss.test.ts @@ -0,0 +1,86 @@ +import * as fs from "fs"; +import * as path from "path"; + +const sheetCssPath = path.resolve(__dirname, "../../../styles/task-modal-sheet.css"); + +function extractCssBlock(css: string, selector: string): string { + const escapedSelector = selector.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const regex = new RegExp(`${escapedSelector}\\s*\\{([^}]*?)\\}`, "s"); + const match = css.match(regex); + return match ? match[1] : ""; +} + +describe("task modal sheet footer CSS", () => { + const sheetCss = fs.readFileSync(sheetCssPath, "utf-8"); + + it("lifts the whole sheet above bottom obstructions via margin-bottom", () => { + const modalBlock = extractCssBlock( + sheetCss, + ".tasknotes-plugin.minimalist-task-modal.tn-task-modal--sheet > .modal" + ); + + expect(modalBlock).toContain("margin-bottom: var(--tn-sheet-bottom-inset, 0px)"); + expect(modalBlock).toContain("transform: translateY(var(--tn-sheet-offset, 0px))"); + }); + + it("animates the whole sheet lift when a bottom obstruction is present", () => { + const modalBlock = extractCssBlock( + sheetCss, + ".tasknotes-plugin.minimalist-task-modal.tn-task-modal--sheet > .modal.tn-task-modal__sheet--bottom-inset" + ); + + expect(modalBlock).toContain("transition: margin-bottom 0.2s ease, transform 0.2s ease"); + expect(modalBlock).toContain("max-height: var(--tn-sheet-max-height, 60dvh)"); + }); + + it("does not add keyboard scroll padding inside sheet content", () => { + const scrollBlock = extractCssBlock( + sheetCss, + ".tasknotes-plugin.minimalist-task-modal.tn-task-modal--sheet.is-mobile-keyboard-focused .modal-split-content" + ); + + expect(scrollBlock).toContain("padding-bottom: 0"); + expect(scrollBlock).toContain("scroll-padding-bottom: 0"); + }); + + it("caps resting sheet height with a max-height variable instead of translating the footer off-screen", () => { + const modalBlock = extractCssBlock( + sheetCss, + ".tasknotes-plugin.minimalist-task-modal.tn-task-modal--sheet > .modal" + ); + + expect(modalBlock).toContain("max-height: min("); + expect(modalBlock).toContain("var(--tn-sheet-max-height, 60dvh)"); + expect(modalBlock).toContain("calc(100dvh - 16px - env(safe-area-inset-top, 0px))"); + }); + + it("pins the footer outside the scrollable content area on all sheet layouts", () => { + const contentBlock = extractCssBlock( + sheetCss, + ".tasknotes-plugin.minimalist-task-modal.tn-task-modal--sheet .modal-content" + ); + const containerBlock = extractCssBlock( + sheetCss, + ".tasknotes-plugin.minimalist-task-modal.tn-task-modal--sheet .minimalist-modal-container" + ); + const scrollBlock = extractCssBlock( + sheetCss, + ".tasknotes-plugin.minimalist-task-modal.tn-task-modal--sheet .modal-split-content" + ); + const footerBlock = extractCssBlock( + sheetCss, + ".tasknotes-plugin.minimalist-task-modal.tn-task-modal--sheet .tn-task-modal__icon-button-bar" + ); + + expect(contentBlock).toContain("display: flex"); + expect(contentBlock).toContain("overflow: hidden"); + expect(containerBlock).toContain("overflow: hidden"); + expect(scrollBlock).toContain("overflow-y: auto"); + expect(scrollBlock).toContain("margin-inline-end: calc(-1 * var(--size-4-4))"); + expect(scrollBlock).toContain("padding-inline-end: var(--size-4-4)"); + expect(footerBlock).toContain("flex-shrink: 0"); + expect(footerBlock).toContain("background: var(--modal-background)"); + expect(footerBlock).toContain("margin-top: var(--size-4-1)"); + expect(footerBlock).toContain("padding-top: var(--size-4-2)"); + }); +}); diff --git a/tests/unit/modals/taskModalSheetGestures.test.ts b/tests/unit/modals/taskModalSheetGestures.test.ts new file mode 100644 index 000000000..7a7f05958 --- /dev/null +++ b/tests/unit/modals/taskModalSheetGestures.test.ts @@ -0,0 +1,200 @@ +import { + attachTaskModalSheetGestures, + createTaskModalSheetHandle, + FULL_SNAP_MAX_HEIGHT, + PARTIAL_SNAP_MAX_HEIGHT, +} from "../../../src/modals/taskModalSheetGestures"; + +/** + * jsdom does not implement the PointerEvent constructor or pointer capture + * methods, so tests build plain Events and manually attach the fields the + * gesture code reads (pointerId, clientY, button, timeStamp). + */ +function createPointerEvent( + type: string, + init: { pointerId?: number; clientY?: number; button?: number; timeStamp?: number } +): Event { + const event = new Event(type, { bubbles: true, cancelable: true }); + Object.assign(event, { + pointerId: init.pointerId ?? 1, + clientY: init.clientY ?? 0, + button: init.button ?? 0, + }); + Object.defineProperty(event, "timeStamp", { value: init.timeStamp ?? 0, configurable: true }); + return event; +} + +function setupSheet(height = 500) { + const containerEl = document.createElement("div"); + const modalEl = document.createElement("div"); + containerEl.appendChild(modalEl); + createTaskModalSheetHandle(containerEl); + + jest.spyOn(modalEl, "getBoundingClientRect").mockReturnValue({ + height, + width: 0, + top: 0, + left: 0, + right: 0, + bottom: height, + x: 0, + y: 0, + toJSON: () => ({}), + } as DOMRect); + + const handle = containerEl.querySelector(".tn-task-modal__sheet-handle")!; + return { containerEl, modalEl, handle }; +} + +describe("attachTaskModalSheetGestures", () => { + afterEach(() => { + jest.useRealTimers(); + jest.restoreAllMocks(); + }); + + it("returns a no-op controller when the handle element is missing, without leaving the sheet stuck off-screen", () => { + const containerEl = document.createElement("div"); + const modalEl = document.createElement("div"); + modalEl.classList.add("tn-task-modal__sheet--pending"); + const controller = attachTaskModalSheetGestures({ + containerEl, + modalEl, + onDismiss: jest.fn(), + }); + expect(modalEl.classList.contains("tn-task-modal__sheet--pending")).toBe(false); + expect(() => controller.destroy()).not.toThrow(); + expect(() => controller.expandToFull()).not.toThrow(); + }); + + it("reveals the sheet by removing the pending class and applying the partial max-height together", () => { + jest.useFakeTimers(); + const containerEl = document.createElement("div"); + const modalEl = document.createElement("div"); + modalEl.classList.add("tn-task-modal__sheet--pending"); + containerEl.appendChild(modalEl); + createTaskModalSheetHandle(containerEl); + jest.spyOn(modalEl, "getBoundingClientRect").mockReturnValue({ + height: 500, + width: 0, + top: 0, + left: 0, + right: 0, + bottom: 500, + x: 0, + y: 0, + toJSON: () => ({}), + } as DOMRect); + + attachTaskModalSheetGestures({ containerEl, modalEl, onDismiss: jest.fn() }); + + // Before content height has been measured, the sheet must stay hidden + // off-screen rather than flashing at the fully-expanded default - + // this is what previously caused the "opens at the top" glitch. + expect(modalEl.classList.contains("tn-task-modal__sheet--pending")).toBe(true); + + jest.runOnlyPendingTimers(); + + expect(modalEl.classList.contains("tn-task-modal__sheet--pending")).toBe(false); + expect(modalEl.style.getPropertyValue("--tn-sheet-max-height")).toBe(PARTIAL_SNAP_MAX_HEIGHT); + expect(modalEl.style.getPropertyValue("--tn-sheet-offset")).toBe("0"); + }); + + it("tracks the drag using window-level listeners so movement outside the handle's bounds is not lost", () => { + jest.useFakeTimers(); + const { containerEl, modalEl, handle } = setupSheet(); + const onDismiss = jest.fn(); + attachTaskModalSheetGestures({ containerEl, modalEl, onDismiss }); + jest.runOnlyPendingTimers(); + + handle.dispatchEvent(createPointerEvent("pointerdown", { clientY: 100, timeStamp: 0 })); + // Movement is dispatched on window, not the (small) handle element, + // mirroring how a finger quickly leaves the handle's hit area during + // a real drag. + window.dispatchEvent(createPointerEvent("pointermove", { clientY: 160, timeStamp: 100 })); + expect(modalEl.style.getPropertyValue("--tn-sheet-offset")).toBe("60px"); + + window.dispatchEvent(createPointerEvent("pointerup", { clientY: 160, timeStamp: 120 })); + + // The pointerup dispatched on window was handled and the sheet snapped + // back to its resting partial height with no dismiss offset. + expect(modalEl.style.getPropertyValue("--tn-sheet-offset")).toBe("0"); + expect(modalEl.style.getPropertyValue("--tn-sheet-max-height")).toBe(PARTIAL_SNAP_MAX_HEIGHT); + expect(onDismiss).not.toHaveBeenCalled(); + }); + + it("ignores a second pointer starting a drag while one is already active", () => { + jest.useFakeTimers(); + const { containerEl, modalEl, handle } = setupSheet(); + attachTaskModalSheetGestures({ containerEl, modalEl, onDismiss: jest.fn() }); + jest.runOnlyPendingTimers(); + + handle.dispatchEvent( + createPointerEvent("pointerdown", { pointerId: 1, clientY: 100, timeStamp: 0 }) + ); + window.dispatchEvent( + createPointerEvent("pointermove", { pointerId: 1, clientY: 150, timeStamp: 16 }) + ); + const offsetDuringFirstDrag = modalEl.style.getPropertyValue("--tn-sheet-offset"); + + // A second finger touches the handle mid-drag; it must not reset the + // gesture's start position and cause the sheet to jump. + handle.dispatchEvent( + createPointerEvent("pointerdown", { pointerId: 2, clientY: 10, timeStamp: 20 }) + ); + window.dispatchEvent( + createPointerEvent("pointermove", { pointerId: 2, clientY: 10, timeStamp: 24 }) + ); + + expect(modalEl.style.getPropertyValue("--tn-sheet-offset")).toBe(offsetDuringFirstDrag); + }); + + it("does not treat a stale velocity sample as a fling when the pointer paused before release", () => { + jest.useFakeTimers(); + const { containerEl, modalEl, handle } = setupSheet(500); + attachTaskModalSheetGestures({ containerEl, modalEl, onDismiss: jest.fn() }); + jest.runOnlyPendingTimers(); + + // Starting from the partial snap position, a brief fast downward flick + // establishes a high velocity... + handle.dispatchEvent(createPointerEvent("pointerdown", { clientY: 100, timeStamp: 0 })); + window.dispatchEvent(createPointerEvent("pointermove", { clientY: 210, timeStamp: 5 })); + expect(modalEl.style.getPropertyValue("--tn-sheet-offset")).toBe("110px"); + + // ...but the pointer then pauses for a long while before being + // released, so that earlier fling velocity is stale and must be + // ignored. The final offset (110px) is below the dismiss threshold + // (120px), so it should snap back to partial - not dismiss, which is + // what the stale, now-irrelevant velocity would otherwise incorrectly + // trigger. + window.dispatchEvent(createPointerEvent("pointerup", { clientY: 210, timeStamp: 505 })); + + jest.runOnlyPendingTimers(); + expect(modalEl.style.getPropertyValue("--tn-sheet-offset")).toBe("0"); + expect(modalEl.style.getPropertyValue("--tn-sheet-max-height")).toBe(PARTIAL_SNAP_MAX_HEIGHT); + }); + + it("cleans up window listeners on destroy", () => { + jest.useFakeTimers(); + const { containerEl, modalEl, handle } = setupSheet(); + const controller = attachTaskModalSheetGestures({ containerEl, modalEl, onDismiss: jest.fn() }); + jest.runOnlyPendingTimers(); + + handle.dispatchEvent(createPointerEvent("pointerdown", { clientY: 100, timeStamp: 0 })); + controller.destroy(); + + const offsetBefore = modalEl.style.getPropertyValue("--tn-sheet-offset"); + window.dispatchEvent(createPointerEvent("pointermove", { clientY: 400, timeStamp: 16 })); + expect(modalEl.style.getPropertyValue("--tn-sheet-offset")).toBe(offsetBefore); + }); + + it("expandToFull snaps to the full max-height with no dismiss offset", () => { + jest.useFakeTimers(); + const { containerEl, modalEl } = setupSheet(); + const controller = attachTaskModalSheetGestures({ containerEl, modalEl, onDismiss: jest.fn() }); + jest.runOnlyPendingTimers(); + + controller.expandToFull(); + expect(modalEl.style.getPropertyValue("--tn-sheet-max-height")).toBe(FULL_SNAP_MAX_HEIGHT); + expect(modalEl.style.getPropertyValue("--tn-sheet-offset")).toBe("0"); + }); +}); diff --git a/tests/unit/modals/taskModalSheetKeyboard.test.ts b/tests/unit/modals/taskModalSheetKeyboard.test.ts new file mode 100644 index 000000000..0d8cb415c --- /dev/null +++ b/tests/unit/modals/taskModalSheetKeyboard.test.ts @@ -0,0 +1,392 @@ +import { + attachTaskModalSheetKeyboardAvoidance, + computeMobileToolbarInset, + computeSheetBottomInset, + computeSheetBottomInsetAdjustment, + computeSheetBottomInsetMaxHeightPx, + computeSheetKeyboardInset, + computeSheetKeyboardMaxHeightPx, + isMobileToolbarVisible, +} from "../../../src/modals/taskModalSheetKeyboard"; + +describe("taskModalSheetKeyboard", () => { + describe("computeSheetKeyboardInset", () => { + it("returns zero when the visual viewport still fills the layout viewport", () => { + expect( + computeSheetKeyboardInset(800, { height: 800, offsetTop: 0 }) + ).toBe(0); + }); + + it("returns the covered height when the keyboard shrinks the visual viewport", () => { + expect( + computeSheetKeyboardInset(800, { height: 420, offsetTop: 0 }) + ).toBe(380); + }); + + it("accounts for visual viewport offset when the browser shifts content", () => { + expect( + computeSheetKeyboardInset(800, { height: 420, offsetTop: 40 }) + ).toBe(340); + }); + + it("ignores small viewport deltas from browser chrome", () => { + expect( + computeSheetKeyboardInset(800, { height: 770, offsetTop: 0 }) + ).toBe(0); + }); + }); + + describe("computeMobileToolbarInset", () => { + it("returns zero when the Obsidian mobile toolbar is hidden", () => { + const toolbar = document.createElement("div"); + toolbar.className = "mobile-toolbar"; + Object.defineProperty(toolbar, "getBoundingClientRect", { + value: () => ({ top: 800, height: 0, width: 0 }), + }); + + expect(computeMobileToolbarInset(800, toolbar)).toBe(0); + }); + + it("returns the toolbar height when it is visible at the bottom of the screen", () => { + const toolbar = document.createElement("div"); + toolbar.className = "mobile-toolbar"; + Object.defineProperty(toolbar, "getBoundingClientRect", { + value: () => ({ top: 756, height: 44, width: 390 }), + }); + jest.spyOn(window, "getComputedStyle").mockReturnValue({ + display: "flex", + visibility: "visible", + } as CSSStyleDeclaration); + + expect(computeMobileToolbarInset(800, toolbar)).toBe(44); + expect(isMobileToolbarVisible(toolbar)).toBe(true); + }); + + it("ignores a toolbar that is not actually docked within the visible viewport", () => { + jest.spyOn(window, "getComputedStyle").mockReturnValue({ + display: "flex", + visibility: "visible", + } as CSSStyleDeclaration); + + const offscreenAbove = document.createElement("div"); + Object.defineProperty(offscreenAbove, "getBoundingClientRect", { + value: () => ({ top: -50, height: 44, width: 390 }), + }); + expect(computeMobileToolbarInset(800, offscreenAbove)).toBe(0); + + const offscreenBelow = document.createElement("div"); + Object.defineProperty(offscreenBelow, "getBoundingClientRect", { + value: () => ({ top: 900, height: 44, width: 390 }), + }); + expect(computeMobileToolbarInset(800, offscreenBelow)).toBe(0); + + const zeroHeight = document.createElement("div"); + Object.defineProperty(zeroHeight, "getBoundingClientRect", { + value: () => ({ top: 756, height: 0, width: 390 }), + }); + expect(computeMobileToolbarInset(800, zeroHeight)).toBe(0); + }); + }); + + describe("computeSheetBottomInset", () => { + it("uses whichever bottom obstruction is larger between keyboard and toolbar", () => { + const toolbar = document.createElement("div"); + Object.defineProperty(toolbar, "getBoundingClientRect", { + value: () => ({ top: 340, height: 44, width: 390 }), + }); + jest.spyOn(window, "getComputedStyle").mockReturnValue({ + display: "flex", + visibility: "visible", + } as CSSStyleDeclaration); + + expect( + computeSheetBottomInset(800, { height: 420, offsetTop: 0 }, toolbar) + ).toBe(460); + }); + + it("clamps the combined inset so the sheet can never be pushed past the top safe area", () => { + expect( + computeSheetBottomInset(800, { height: 0, offsetTop: 0 }, null) + ).toBeLessThanOrEqual(800 - 16); + }); + }); + + describe("computeSheetBottomInsetMaxHeightPx", () => { + it("leaves the configured top safe area above the sheet", () => { + expect( + computeSheetBottomInsetMaxHeightPx(800, 44, { offsetTop: 0 }) + ).toBe(740); + expect( + computeSheetBottomInsetMaxHeightPx(800, 44, { offsetTop: 20 }) + ).toBe(720); + }); + }); + + describe("computeSheetKeyboardMaxHeightPx", () => { + it("leaves the configured top safe area above the sheet", () => { + expect(computeSheetKeyboardMaxHeightPx({ height: 420, offsetTop: 0 })).toBe(404); + expect(computeSheetKeyboardMaxHeightPx({ height: 420, offsetTop: 20 })).toBe(384); + }); + }); + + describe("computeSheetBottomInsetAdjustment", () => { + it("returns zero inset when nothing obscures the bottom edge", () => { + expect( + computeSheetBottomInsetAdjustment(800, { height: 800, offsetTop: 0 }, null) + ).toEqual({ + bottomInsetPx: 0, + }); + }); + + it("returns only a bottom inset when the keyboard is open", () => { + expect( + computeSheetBottomInsetAdjustment(800, { height: 420, offsetTop: 0 }, null) + ).toEqual({ + bottomInsetPx: 380, + }); + }); + + it("returns only a bottom inset for the mobile toolbar when the keyboard is closed", () => { + const toolbar = document.createElement("div"); + Object.defineProperty(toolbar, "getBoundingClientRect", { + value: () => ({ top: 756, height: 44, width: 390 }), + }); + jest.spyOn(window, "getComputedStyle").mockReturnValue({ + display: "flex", + visibility: "visible", + } as CSSStyleDeclaration); + + expect( + computeSheetBottomInsetAdjustment(800, { height: 800, offsetTop: 0 }, toolbar) + ).toEqual({ + bottomInsetPx: 44, + }); + }); + }); + + describe("attachTaskModalSheetKeyboardAvoidance", () => { + afterEach(() => { + jest.useRealTimers(); + jest.restoreAllMocks(); + }); + + function mockVisualViewport( + win: Window, + viewport: { height: number; offsetTop: number } + ): VisualViewport { + const listeners = new Map>(); + const visualViewport = { + height: viewport.height, + offsetTop: viewport.offsetTop, + addEventListener: (type: string, listener: EventListener) => { + if (!listeners.has(type)) { + listeners.set(type, new Set()); + } + listeners.get(type)!.add(listener); + }, + removeEventListener: (type: string, listener: EventListener) => { + listeners.get(type)?.delete(listener); + }, + dispatch: (type: string) => { + for (const listener of listeners.get(type) ?? []) { + listener(new Event(type)); + } + }, + }; + + Object.defineProperty(win, "visualViewport", { + configurable: true, + value: visualViewport, + }); + Object.defineProperty(win, "innerHeight", { + configurable: true, + value: 800, + }); + + return visualViewport as unknown as VisualViewport; + } + + it("lifts the whole sheet via bottom inset and freezes its current height", () => { + const modalEl = document.createElement("div"); + modalEl.style.setProperty("--tn-sheet-max-height", "60dvh"); + jest.spyOn(modalEl, "getBoundingClientRect").mockReturnValue({ + height: 480, + width: 0, + top: 0, + left: 0, + right: 0, + bottom: 480, + x: 0, + y: 0, + toJSON: () => ({}), + } as DOMRect); + const win = modalEl.ownerDocument.defaultView!; + const visualViewport = mockVisualViewport(win, { height: 420, offsetTop: 0 }); + + const controller = attachTaskModalSheetKeyboardAvoidance({ + modalEl, + getSnapMaxHeight: () => "60dvh", + isDragging: () => false, + }); + + visualViewport.dispatch("resize"); + + expect(modalEl.style.getPropertyValue("--tn-sheet-bottom-inset")).toBe("380px"); + expect(modalEl.style.getPropertyValue("--tn-sheet-max-height")).toBe("480px"); + expect(modalEl.classList.contains("tn-task-modal__sheet--bottom-inset")).toBe(true); + + controller.destroy(); + }); + + it("lifts the sheet when Obsidian's mobile toolbar is visible", () => { + const modalEl = document.createElement("div"); + modalEl.style.setProperty("--tn-sheet-max-height", "60dvh"); + jest.spyOn(modalEl, "getBoundingClientRect").mockReturnValue({ + height: 480, + width: 0, + top: 0, + left: 0, + right: 0, + bottom: 480, + x: 0, + y: 0, + toJSON: () => ({}), + } as DOMRect); + const win = modalEl.ownerDocument.defaultView!; + mockVisualViewport(win, { height: 800, offsetTop: 0 }); + + const toolbar = document.createElement("div"); + toolbar.className = "mobile-toolbar"; + document.body.appendChild(toolbar); + Object.defineProperty(toolbar, "getBoundingClientRect", { + value: () => ({ top: 756, height: 44, width: 390 }), + }); + jest.spyOn(win, "getComputedStyle").mockReturnValue({ + display: "flex", + visibility: "visible", + } as CSSStyleDeclaration); + + const controller = attachTaskModalSheetKeyboardAvoidance({ + modalEl, + getSnapMaxHeight: () => "60dvh", + isDragging: () => false, + }); + + expect(modalEl.style.getPropertyValue("--tn-sheet-bottom-inset")).toBe("44px"); + expect(modalEl.style.getPropertyValue("--tn-sheet-max-height")).toBe("480px"); + expect(modalEl.classList.contains("tn-task-modal__sheet--bottom-inset")).toBe(true); + + controller.destroy(); + toolbar.remove(); + }); + + it("restores the snap max-height when the obstruction closes", () => { + const modalEl = document.createElement("div"); + jest.spyOn(modalEl, "getBoundingClientRect").mockReturnValue({ + height: 480, + width: 0, + top: 0, + left: 0, + right: 0, + bottom: 480, + x: 0, + y: 0, + toJSON: () => ({}), + } as DOMRect); + const win = modalEl.ownerDocument.defaultView!; + const visualViewport = mockVisualViewport(win, { height: 420, offsetTop: 0 }); + + const controller = attachTaskModalSheetKeyboardAvoidance({ + modalEl, + getSnapMaxHeight: () => "calc(100dvh - 16px)", + isDragging: () => false, + }); + + visualViewport.dispatch("resize"); + (visualViewport as unknown as { height: number }).height = 800; + visualViewport.dispatch("resize"); + + expect(modalEl.style.getPropertyValue("--tn-sheet-bottom-inset")).toBe("0"); + expect(modalEl.style.getPropertyValue("--tn-sheet-max-height")).toBe( + "calc(100dvh - 16px)" + ); + expect(modalEl.classList.contains("tn-task-modal__sheet--bottom-inset")).toBe(false); + + controller.destroy(); + }); + + it("does not apply bottom inset adjustments while the sheet is being dragged", () => { + const modalEl = document.createElement("div"); + const win = modalEl.ownerDocument.defaultView!; + const visualViewport = mockVisualViewport(win, { height: 420, offsetTop: 0 }); + + const controller = attachTaskModalSheetKeyboardAvoidance({ + modalEl, + getSnapMaxHeight: () => "60dvh", + isDragging: () => true, + }); + + visualViewport.dispatch("resize"); + + expect(modalEl.style.getPropertyValue("--tn-sheet-bottom-inset")).toBe(""); + expect(modalEl.classList.contains("tn-task-modal__sheet--bottom-inset")).toBe(false); + + controller.destroy(); + }); + + it("freezes the sheet height on focus before the keyboard viewport resize", () => { + jest.useFakeTimers(); + const modalEl = document.createElement("div"); + modalEl.style.setProperty("--tn-sheet-max-height", "60dvh"); + const input = document.createElement("input"); + modalEl.appendChild(input); + jest.spyOn(modalEl, "getBoundingClientRect").mockReturnValue({ + height: 480, + width: 0, + top: 0, + left: 0, + right: 0, + bottom: 480, + x: 0, + y: 0, + toJSON: () => ({}), + } as DOMRect); + const win = modalEl.ownerDocument.defaultView!; + mockVisualViewport(win, { height: 800, offsetTop: 0 }); + + const controller = attachTaskModalSheetKeyboardAvoidance({ + modalEl, + getSnapMaxHeight: () => "60dvh", + isDragging: () => false, + }); + + input.dispatchEvent(new Event("focusin", { bubbles: true })); + + expect(modalEl.style.getPropertyValue("--tn-sheet-max-height")).toBe("480px"); + + controller.destroy(); + }); + + it("schedules a refresh after a field inside the sheet receives focus", () => { + jest.useFakeTimers(); + const modalEl = document.createElement("div"); + const input = document.createElement("input"); + modalEl.appendChild(input); + const win = modalEl.ownerDocument.defaultView!; + mockVisualViewport(win, { height: 420, offsetTop: 0 }); + + const controller = attachTaskModalSheetKeyboardAvoidance({ + modalEl, + getSnapMaxHeight: () => "60dvh", + isDragging: () => false, + }); + + input.dispatchEvent(new Event("focusin", { bubbles: true })); + jest.runOnlyPendingTimers(); + + expect(modalEl.style.getPropertyValue("--tn-sheet-bottom-inset")).toBe("380px"); + + controller.destroy(); + }); + }); +}); diff --git a/tests/unit/modals/taskModalSheetWidthCss.test.ts b/tests/unit/modals/taskModalSheetWidthCss.test.ts new file mode 100644 index 000000000..085158cbe --- /dev/null +++ b/tests/unit/modals/taskModalSheetWidthCss.test.ts @@ -0,0 +1,69 @@ +import * as fs from "fs"; +import * as path from "path"; + +const sheetCssPath = path.resolve(__dirname, "../../../styles/task-modal-sheet.css"); +const modalCssPath = path.resolve(__dirname, "../../../styles/task-modal.css"); + +function extractCssBlock(css: string, selector: string): string { + const escapedSelector = selector.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const regex = new RegExp(`${escapedSelector}\\s*\\{([^}]*?)\\}`, "s"); + const match = css.match(regex); + return match ? match[1] : ""; +} + +describe("task modal sheet width CSS", () => { + const sheetCss = fs.readFileSync(sheetCssPath, "utf-8"); + const modalCss = fs.readFileSync(modalCssPath, "utf-8"); + + /** + * `body.is-mobile .tasknotes-plugin.minimalist-task-modal > .modal` in + * task-modal.css has higher CSS specificity than + * `.tasknotes-plugin.minimalist-task-modal.tn-task-modal--sheet > .modal` + * in task-modal-sheet.css (the extra `body` type selector outweighs the + * sheet rule's extra class), so it silently overrode the sheet's width + * cap regardless of stylesheet order or the sheet's own more-specific- + * looking selector. The legacy rule must explicitly exclude the sheet + * layout so it no longer forces the sheet back to full viewport width. + */ + it("excludes the bottom-sheet layout from the legacy full-viewport-width mobile modal rule", () => { + expect(modalCss).toMatch( + /body\.is-mobile \.tasknotes-plugin\.minimalist-task-modal:not\(\.tn-task-modal--sheet\) > \.modal\s*\{[^}]*width:\s*calc\(100vw/s + ); + expect(modalCss).not.toMatch( + /body\.is-mobile \.tasknotes-plugin\.minimalist-task-modal > \.modal\s*\{/ + ); + }); + + it("lets the bottom sheet grow with its full-width content instead of the desktop dialog cap", () => { + const contentBlock = extractCssBlock( + sheetCss, + ".tasknotes-plugin.minimalist-task-modal.tn-task-modal--sheet .modal-content" + ); + + expect(contentBlock).toContain("min-width: 0"); + expect(contentBlock).toContain("width: 100%"); + expect(contentBlock).toContain("max-width: 100%"); + }); + + it("caps the sheet at the small-screen modal width while filling narrower viewports", () => { + const modalBlock = extractCssBlock( + sheetCss, + ".tasknotes-plugin.minimalist-task-modal.tn-task-modal--sheet > .modal" + ); + + expect(modalBlock).toContain("width: min(600px, 100%)"); + expect(modalBlock).toContain( + "max-width: min(600px, calc(100vw - env(safe-area-inset-left) - env(safe-area-inset-right)))" + ); + expect(modalBlock).toContain("margin-inline: auto"); + }); + + it("centers the sheet horizontally in the modal container", () => { + const containerBlock = extractCssBlock( + sheetCss, + ".tasknotes-plugin.minimalist-task-modal.tn-task-modal--sheet" + ); + + expect(containerBlock).toContain("justify-content: center"); + }); +}); diff --git a/tests/unit/modals/taskModalSidebar.test.ts b/tests/unit/modals/taskModalSidebar.test.ts new file mode 100644 index 000000000..5a640a4f4 --- /dev/null +++ b/tests/unit/modals/taskModalSidebar.test.ts @@ -0,0 +1,124 @@ +import { setIcon } from "obsidian"; +import { + createTaskModalPropertySections, + createTaskModalSidebarOrgSection, + type TaskModalSidebarRowSpec, +} from "../../../src/modals/taskModalSidebar"; + +jest.mock("obsidian", () => ({ + setIcon: jest.fn((element: HTMLElement) => { + element.textContent = "icon"; + }), +})); + +function installObsidianElementPolyfills(): void { + const proto = HTMLElement.prototype as typeof HTMLElement.prototype & { + createDiv?: (cls?: string) => HTMLElement; + }; + + proto.createDiv ??= function createDiv(this: HTMLElement, cls?: string) { + const element = document.createElement("div"); + if (cls) element.className = cls; + this.appendChild(element); + return element; + }; +} + +function makeRow(id: string): TaskModalSidebarRowSpec { + return { + id, + iconName: "dot-square", + label: id, + value: id, + hasValue: true, + onClick: jest.fn(), + }; +} + +describe("createTaskModalSidebarOrgSection", () => { + beforeAll(() => { + installObsidianElementPolyfills(); + }); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it("renders the organization list directly under its sidebar row", () => { + const container = document.createElement("div"); + const { section, row, listElement } = createTaskModalSidebarOrgSection( + container, + makeRow("subtasks") + ); + + expect(section.className).toBe("tn-task-modal__sidebar-section"); + expect(section.children[0]).toBe(row); + expect(section.children[1]).toBe(listElement); + expect(listElement.classList.contains("tn-task-modal__org-list")).toBe(true); + }); +}); + +describe("createTaskModalPropertySections", () => { + beforeAll(() => { + installObsidianElementPolyfills(); + }); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it("renders all property rows in one mobile group card", () => { + const container = document.createElement("div"); + createTaskModalPropertySections({ + container, + groups: [[makeRow("status"), makeRow("priority")], [makeRow("contexts")]], + }); + + expect(container.querySelectorAll(".tn-task-modal__mobile-group")).toHaveLength(1); + expect(container.querySelectorAll(".tn-task-modal__sidebar-row")).toHaveLength(3); + }); + + it("inserts a full-width divider element between every row", () => { + const container = document.createElement("div"); + createTaskModalPropertySections({ + container, + groups: [[makeRow("status"), makeRow("priority")], [makeRow("contexts")]], + }); + + const card = container.querySelector(".tn-task-modal__mobile-group"); + expect(card).not.toBeNull(); + expect(card?.querySelectorAll(".tn-task-modal__sidebar-row-divider")).toHaveLength(2); + expect(card?.children[1]?.classList.contains("tn-task-modal__sidebar-row-divider")).toBe( + true + ); + expect(card?.children[3]?.classList.contains("tn-task-modal__sidebar-row-divider")).toBe( + true + ); + }); + + it("does not render a divider before the first row", () => { + const container = document.createElement("div"); + createTaskModalPropertySections({ + container, + groups: [[makeRow("status")]], + }); + + const card = container.querySelector(".tn-task-modal__mobile-group"); + expect(card?.firstElementChild?.classList.contains("tn-task-modal__sidebar-row")).toBe( + true + ); + expect(card?.querySelector(".tn-task-modal__sidebar-row-divider")).toBeNull(); + }); + + it("returns an empty wrapper when no rows are visible", () => { + const container = document.createElement("div"); + const wrapper = createTaskModalPropertySections({ + container, + groups: [[], []], + }); + + expect(wrapper.className).toBe("tn-task-modal__mobile-sections"); + expect(wrapper.querySelector(".tn-task-modal__mobile-group")).toBeNull(); + expect(setIcon).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/unit/modals/taskModalSidebarRowChevronCss.test.ts b/tests/unit/modals/taskModalSidebarRowChevronCss.test.ts new file mode 100644 index 000000000..a9a52b3f1 --- /dev/null +++ b/tests/unit/modals/taskModalSidebarRowChevronCss.test.ts @@ -0,0 +1,39 @@ +import * as fs from "fs"; +import * as path from "path"; + +const sheetCssPath = path.resolve(__dirname, "../../../styles/task-modal-sheet.css"); + +function extractCssBlock(css: string, selector: string): string { + const escapedSelector = selector.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const regex = new RegExp(`${escapedSelector}\\s*\\{([^}]*?)\\}`, "s"); + const match = css.match(regex); + return match ? match[1] : ""; +} + +describe("task modal sidebar row chevron CSS", () => { + const sheetCss = fs.readFileSync(sheetCssPath, "utf-8"); + + it("centers the chevron wrapper in every sidebar row", () => { + const chevronBlock = extractCssBlock( + sheetCss, + ".tasknotes-plugin .tn-task-modal__sidebar-row-chevron" + ); + + expect(chevronBlock).toContain("display: inline-flex"); + expect(chevronBlock).toContain("align-items: center"); + expect(chevronBlock).toContain("justify-content: center"); + expect(chevronBlock).toContain("align-self: center"); + }); + + it("sizes chevron icons consistently regardless of Obsidian svg class", () => { + expect(sheetCss).toMatch( + /\.tasknotes-plugin \.tn-task-modal__sidebar-row-chevron \.icon,\s*\n\.tasknotes-plugin \.tn-task-modal__sidebar-row-chevron \.svg-icon,\s*\n\.tasknotes-plugin \.tn-task-modal__sidebar-row-chevron svg\s*\{[^}]*width:\s*14px/s + ); + }); + + it("sizes row icons consistently regardless of Obsidian svg class", () => { + expect(sheetCss).toMatch( + /\.tasknotes-plugin \.tn-task-modal__sidebar-row-icon \.icon,\s*\n\.tasknotes-plugin \.tn-task-modal__sidebar-row-icon \.svg-icon,\s*\n\.tasknotes-plugin \.tn-task-modal__sidebar-row-icon svg\s*\{[^}]*width:\s*18px/s + ); + }); +}); diff --git a/tests/unit/modals/taskModalSidebarRowDividersCss.test.ts b/tests/unit/modals/taskModalSidebarRowDividersCss.test.ts new file mode 100644 index 000000000..88e3856b7 --- /dev/null +++ b/tests/unit/modals/taskModalSidebarRowDividersCss.test.ts @@ -0,0 +1,97 @@ +import * as fs from "fs"; +import * as path from "path"; + +const sheetCssPath = path.resolve(__dirname, "../../../styles/task-modal-sheet.css"); + +function extractCssBlock(css: string, selector: string): string { + const escapedSelector = selector.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const regex = new RegExp(`${escapedSelector}\\s*\\{([^}]*?)\\}`, "s"); + const match = css.match(regex); + return match ? match[1] : ""; +} + +describe("task modal sidebar row divider CSS", () => { + const sheetCss = fs.readFileSync(sheetCssPath, "utf-8"); + const dividerBlock = extractCssBlock( + sheetCss, + ".tasknotes-plugin .tn-task-modal__sidebar-row-divider" + ); + + it("insets mobile sheet dividers to match row horizontal padding", () => { + expect(dividerBlock).toContain("margin: 0 var(--size-4-3)"); + expect(dividerBlock).toContain("height: 1px"); + expect(dividerBlock).toContain("background: var(--background-modifier-border)"); + }); + + it("does not draw mobile sheet dividers with row border-top rules", () => { + expect(sheetCss).not.toMatch( + /\.tasknotes-plugin \.tn-task-modal__mobile-group \.tn-task-modal__sidebar-row \+ \.tn-task-modal__sidebar-row\s*\{[^}]*border-top:/ + ); + expect(sheetCss).not.toContain( + ".tn-task-modal__mobile-group .tn-task-modal__sidebar-row + .tn-task-modal__sidebar-row::before" + ); + }); + + it("keeps border-top dividers between desktop edit sidebar blocks", () => { + expect(sheetCss).toMatch( + /\.tasknotes-plugin \.tn-task-modal__sidebar > \* \+ \*\s*\{\s*border-top:\s*1px solid var\(--background-modifier-border\);\s*\}/ + ); + }); + + it("removes default organization list margins from empty desktop sidebar sections", () => { + expect( + extractCssBlock( + sheetCss, + ".tasknotes-plugin .tn-task-modal__sidebar-section .tn-task-modal__org-list" + ) + ).toContain("margin-top: 0"); + expect( + extractCssBlock( + sheetCss, + ".tasknotes-plugin .tn-task-modal__sidebar-section .tn-task-modal__org-list" + ) + ).toContain("margin-bottom: 0"); + }); + + it("removes default organization list margins from empty mobile sheet org fields", () => { + expect( + extractCssBlock( + sheetCss, + ".tasknotes-plugin .tn-task-modal__mobile-group .tn-task-modal__org-list" + ) + ).toContain("margin-top: 0"); + expect( + extractCssBlock( + sheetCss, + ".tasknotes-plugin .tn-task-modal__mobile-group .tn-task-modal__org-list" + ) + ).toContain("margin-bottom: 0"); + }); + + it("nests organization lists under their sidebar section row", () => { + expect(sheetCss).toMatch( + /\.tasknotes-plugin \.tn-task-modal__sidebar-section \.tn-task-modal__org-list:not\(:empty\)/ + ); + expect( + extractCssBlock( + sheetCss, + ".tasknotes-plugin .tn-task-modal__sidebar-section .tn-task-modal__org-list:not(:empty)" + ) + ).toContain("max-height: none"); + }); + + it("vertically aligns sidebar organization remove buttons", () => { + expect( + extractCssBlock( + sheetCss, + ".tasknotes-plugin .tn-task-modal__sidebar-section .tn-task-modal__org-list .task-project-remove" + ) + ).toContain("align-items: center"); + expect( + extractCssBlock( + sheetCss, + ".tasknotes-plugin .tn-task-modal__sidebar-section .tn-task-modal__org-list .task-project-item" + ) + ).toContain("align-items: flex-start"); + }); +}); diff --git a/tests/unit/modals/taskModalSidebarRowPlaceholders.test.ts b/tests/unit/modals/taskModalSidebarRowPlaceholders.test.ts new file mode 100644 index 000000000..9112e0e01 --- /dev/null +++ b/tests/unit/modals/taskModalSidebarRowPlaceholders.test.ts @@ -0,0 +1,201 @@ +import { TaskEditModal } from "../../../src/modals/TaskEditModal"; +import { MockObsidian } from "../../helpers/obsidian-runtime"; +import type { App } from "obsidian"; +import type { TaskInfo } from "../../../src/types"; + +function createMockApp(mockApp: unknown): App { + return mockApp as App; +} + +function translate(key: string, params?: Record): string { + const translations: Record = { + "modals.task.actions.recurrence": "Recurrence", + "modals.task.actions.reminders": "Reminders", + "modals.task.contextsLabel": "Contexts", + "modals.task.contextsPlaceholder": "context1, context2", + "modals.task.tagsLabel": "Tags", + "modals.task.tagsPlaceholder": "tag1, tag2", + "modals.task.chips.timeEstimate": "Time estimate", + "modals.task.timeEstimatePlaceholder": "30", + "modals.task.chips.timeEstimateValue": "{minutes}m", + "modals.task.organization.projects": "Projects", + "modals.task.organization.subtasks": "Subtasks", + }; + const template = translations[key] || key; + return template.replace(/\{(\w+)\}/g, (_match, name: string) => + params?.[name] !== undefined ? String(params[name]) : `{${name}}` + ); +} + +function createMockPlugin(app: App) { + return { + app, + i18n: { + translate: jest.fn(translate), + }, + settings: { + enableModalSplitLayout: false, + taskTag: "task", + taskIdentificationMethod: "tag", + hideIdentifyingTagsMode: "exact", + defaultTaskStatus: "open", + userFields: [], + customStatuses: [], + customPriorities: [], + }, + cacheManager: { + getTaskInfo: jest.fn(), + getCachedTaskInfoSync: jest.fn(), + isTaskFile: jest.fn(() => true), + }, + fieldMapper: { + toUserField: jest.fn((key: string) => key), + }, + }; +} + +function createTask(): TaskInfo { + return { + title: "Some task", + status: "open", + priority: "normal", + path: "TaskNotes/Tasks/some-task.md", + archived: false, + tags: ["task"], + contexts: [], + projects: [], + } as TaskInfo; +} + +describe("Task modal sidebar rows: without a value", () => { + let app: App; + let plugin: ReturnType; + let modal: TaskEditModal; + + beforeEach(() => { + MockObsidian.reset(); + app = createMockApp(MockObsidian.createMockApp()); + plugin = createMockPlugin(app); + modal = new TaskEditModal(app, plugin as any, { task: createTask() }); + }); + + it("leaves the due date row's value blank instead of showing an add-date placeholder", () => { + (modal as any).dueDate = ""; + + const row = (modal as any).buildPropertyRowSpec("due-date"); + + expect(row.value).toBe(""); + expect(row.hasValue).toBe(false); + }); + + it("leaves the scheduled date row's value blank instead of showing an add-date placeholder", () => { + (modal as any).scheduledDate = ""; + + const row = (modal as any).buildPropertyRowSpec("scheduled-date"); + + expect(row.value).toBe(""); + expect(row.hasValue).toBe(false); + }); + + it("leaves the recurrence row's value blank instead of repeating its own label", () => { + (modal as any).recurrenceRule = ""; + + const row = (modal as any).buildPropertyRowSpec("recurrence"); + + expect(row.label).toBe("Recurrence"); + expect(row.value).toBe(""); + expect(row.hasValue).toBe(false); + }); + + it("leaves the reminders row's value blank instead of repeating its own label", () => { + (modal as any).reminders = []; + + const row = (modal as any).buildPropertyRowSpec("reminders"); + + expect(row.label).toBe("Reminders"); + expect(row.value).toBe(""); + expect(row.hasValue).toBe(false); + }); + + it("still shows a real value once recurrence/reminders are set", () => { + (modal as any).recurrenceRule = "FREQ=DAILY"; + (modal as any).reminders = [{ id: "1" }]; + + const recurrenceRow = (modal as any).buildPropertyRowSpec("recurrence"); + const remindersRow = (modal as any).buildPropertyRowSpec("reminders"); + + expect(recurrenceRow.value).not.toBe(""); + expect(recurrenceRow.hasValue).toBe(true); + expect(remindersRow.value).toBe("1"); + expect(remindersRow.hasValue).toBe(true); + }); + + it("leaves the contexts row's value blank instead of showing the example placeholder", () => { + (modal as any).contexts = ""; + + const row = (modal as any).buildPropertyRowSpec("contexts"); + + expect(row.label).toBe("Contexts"); + expect(row.value).toBe(""); + expect(row.hasValue).toBe(false); + }); + + it("leaves the tags row's value blank instead of showing the example placeholder", () => { + (modal as any).tags = ""; + + const row = (modal as any).buildPropertyRowSpec("tags"); + + expect(row.label).toBe("Tags"); + expect(row.value).toBe(""); + expect(row.hasValue).toBe(false); + }); + + it("leaves the time estimate row's value blank instead of showing the example placeholder", () => { + (modal as any).timeEstimate = 0; + + const row = (modal as any).buildPropertyRowSpec("time-estimate"); + + expect(row.label).toBe("Time estimate"); + expect(row.value).toBe(""); + expect(row.hasValue).toBe(false); + }); + + it("leaves the projects row's value blank instead of showing the add placeholder", () => { + (modal as any).selectedProjectItems = []; + + expect((modal as any).getSidebarProjectsValue()).toBe(""); + }); + + it("leaves the subtasks row's value blank instead of showing the add placeholder", () => { + (modal as any).selectedSubtaskFiles = []; + + expect((modal as any).getSidebarSubtasksValue()).toBe(""); + }); + + it("still shows real values once projects/subtasks are set", () => { + (modal as any).selectedProjectItems = [{ name: "Work", link: "Work" }]; + (modal as any).selectedSubtaskFiles = [ + { name: "My subtask", path: "TaskNotes/Tasks/subtask.md" }, + ]; + + expect((modal as any).getSidebarProjectsValue()).toBe("Work"); + expect((modal as any).getSidebarSubtasksValue()).toBe("My subtask"); + }); + + it("still shows real values once contexts/tags/time estimate are set", () => { + (modal as any).contexts = "home, work"; + (modal as any).tags = "urgent"; + (modal as any).timeEstimate = 30; + + const contextsRow = (modal as any).buildPropertyRowSpec("contexts"); + const tagsRow = (modal as any).buildPropertyRowSpec("tags"); + const timeEstimateRow = (modal as any).buildPropertyRowSpec("time-estimate"); + + expect(contextsRow.value).toBe("home, work"); + expect(contextsRow.hasValue).toBe(true); + expect(tagsRow.value).toBe("urgent"); + expect(tagsRow.hasValue).toBe(true); + expect(timeEstimateRow.value).toBe("30m"); + expect(timeEstimateRow.hasValue).toBe(true); + }); +}); diff --git a/tests/unit/modals/taskModalTitleDescriptionOrderCss.test.ts b/tests/unit/modals/taskModalTitleDescriptionOrderCss.test.ts new file mode 100644 index 000000000..7cc9ca362 --- /dev/null +++ b/tests/unit/modals/taskModalTitleDescriptionOrderCss.test.ts @@ -0,0 +1,70 @@ +/** + * The title (rendered via the detailed title textarea) and the description + * editor both live inside `.modal-split-right`, which must be ordered before + * the mobile organization sections and property rows so that title and + * description always appear first in the modal. + * + * A legacy `body.is-mobile` rule in task-modal.css used to set + * `.details-container { order: 3 }` and `.modal-split-right { order: 4 }`, + * which has higher specificity than the unconditional rules in + * task-modal-sheet.css and silently reversed the intended order on real + * mobile devices, pushing "Add project/subtask/blocked by/blocking" above + * the title and description fields. + */ + +import * as fs from "fs"; +import * as path from "path"; + +const sheetCssPath = path.resolve(__dirname, "../../../styles/task-modal-sheet.css"); +const modalCssPath = path.resolve(__dirname, "../../../styles/task-modal.css"); + +function extractOrder(css: string, selector: string): number { + const escapedSelector = selector.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const regex = new RegExp(`${escapedSelector}\\s*\\{([^}]*?)\\}`, "s"); + const match = css.match(regex); + if (!match) { + throw new Error(`Could not find CSS block for selector: ${selector}`); + } + const orderMatch = match[1].match(/order:\s*(-?\d+)/); + if (!orderMatch) { + throw new Error(`Could not find an order declaration for selector: ${selector}`); + } + return Number(orderMatch[1]); +} + +describe("task modal title/description CSS ordering", () => { + const sheetCss = fs.readFileSync(sheetCssPath, "utf-8"); + const modalCss = fs.readFileSync(modalCssPath, "utf-8"); + + it("orders the description column before organization and property sections", () => { + const descriptionOrder = extractOrder( + sheetCss, + ".tasknotes-plugin.minimalist-task-modal .modal-split-right" + ); + const chipRowOrder = extractOrder( + sheetCss, + ".tasknotes-plugin.minimalist-task-modal .modal-split-left .tn-task-modal__chip-row" + ); + const organizationOrder = extractOrder( + sheetCss, + ".tasknotes-plugin.minimalist-task-modal .modal-split-left .tn-task-modal__mobile-sections" + ); + const propertyOrder = extractOrder( + sheetCss, + ".tasknotes-plugin.minimalist-task-modal .modal-split-left .tn-task-modal__mobile-sections" + ); + + expect(descriptionOrder).toBeLessThan(chipRowOrder); + expect(descriptionOrder).toBeLessThan(organizationOrder); + expect(descriptionOrder).toBeLessThan(propertyOrder); + }); + + it("does not reintroduce a body.is-mobile override that reverses the description/organization order", () => { + expect(modalCss).not.toMatch( + /body\.is-mobile[^{]*\.modal-split-left \.details-container\s*\{[^}]*order:/s + ); + expect(modalCss).not.toMatch( + /body\.is-mobile[^{]*\.modal-split-right\s*\{[^}]*order:/s + ); + }); +});