From 4457260c60b14b52c3b36155134a0b2162ec1e93 Mon Sep 17 00:00:00 2001 From: Anthony Stirling <77850077+Frooodle@users.noreply.github.com> Date: Sat, 22 Aug 2026 18:47:14 +0100 Subject: [PATCH] Make the editor and settings menu mobile friendly-er (#7518) Co-authored-by: EthanHealy01 <80844253+EthanHealy01@users.noreply.github.com> --- frontend/editor/index.html | 5 +- .../public/locales/en-US/translation.toml | 16 + .../editor/src/core/components/AppLayout.tsx | 2 +- .../core/components/filesPage/FileGrid.tsx | 4 +- .../components/filesPage/FileManagerView.tsx | 656 ++++++++++-------- .../core/components/filesPage/FilesPage.css | 108 ++- .../filesPage/FilesToolbarBulkMenu.tsx | 104 +++ .../filesPage/FilesToolbarCount.tsx | 41 ++ .../filesPage/FilesToolbarFilterMenu.tsx | 149 ++++ .../filesPage/FilesToolbarSortMenu.tsx | 86 +++ .../components/layout/Workbench.module.css | 7 + .../core/components/shared/AppConfigModal.css | 63 +- .../core/components/shared/AppConfigModal.tsx | 146 ++-- .../core/components/shared/WorkbenchBar.css | 99 ++- .../core/components/shared/WorkbenchBar.tsx | 276 +++----- .../SettingsMobileBackButton.stories.tsx | 17 + .../config/SettingsMobileBackButton.tsx | 31 + .../SettingsMobileNavHeader.stories.tsx | 23 + .../shared/config/SettingsMobileNavHeader.tsx | 44 ++ .../config/SettingsNavChevron.stories.tsx | 18 + .../shared/config/SettingsNavChevron.tsx | 20 + .../shared/config/configNavSections.tsx | 4 +- .../shared/superSearch/SuperSearch.css | 14 +- .../shared/superSearch/SuperSearch.tsx | 8 +- .../WorkbenchBarDesktopActions.tsx | 121 ++++ .../WorkbenchBarMobileActions.tsx | 94 +++ .../WorkbenchBarToolbarHandle.tsx | 67 ++ .../components/shared/workbenchBar/types.ts | 20 + .../workbenchBar/workbenchBarTooltip.tsx | 66 ++ .../core/components/tools/RightSidebar.tsx | 7 +- .../src/core/components/tools/ToolPanel.css | 14 + .../core/components/viewer/NonPdfViewer.tsx | 19 +- .../components/viewer/nonpdf/HtmlViewer.tsx | 74 +- frontend/editor/src/core/pages/HomePage.css | 60 +- frontend/editor/src/core/pages/HomePage.tsx | 139 ++-- .../src/core/tests/stubbed/files-page.spec.ts | 5 +- .../config/configSections/GeneralSection.tsx | 10 +- .../shared/config/LoginLandingSetting.tsx | 1 + .../shared/config/configNavSections.tsx | 2 +- .../saas/components/shared/AppConfigModal.tsx | 114 ++- 40 files changed, 2045 insertions(+), 709 deletions(-) create mode 100644 frontend/editor/src/core/components/filesPage/FilesToolbarBulkMenu.tsx create mode 100644 frontend/editor/src/core/components/filesPage/FilesToolbarCount.tsx create mode 100644 frontend/editor/src/core/components/filesPage/FilesToolbarFilterMenu.tsx create mode 100644 frontend/editor/src/core/components/filesPage/FilesToolbarSortMenu.tsx create mode 100644 frontend/editor/src/core/components/shared/config/SettingsMobileBackButton.stories.tsx create mode 100644 frontend/editor/src/core/components/shared/config/SettingsMobileBackButton.tsx create mode 100644 frontend/editor/src/core/components/shared/config/SettingsMobileNavHeader.stories.tsx create mode 100644 frontend/editor/src/core/components/shared/config/SettingsMobileNavHeader.tsx create mode 100644 frontend/editor/src/core/components/shared/config/SettingsNavChevron.stories.tsx create mode 100644 frontend/editor/src/core/components/shared/config/SettingsNavChevron.tsx create mode 100644 frontend/editor/src/core/components/shared/workbenchBar/WorkbenchBarDesktopActions.tsx create mode 100644 frontend/editor/src/core/components/shared/workbenchBar/WorkbenchBarMobileActions.tsx create mode 100644 frontend/editor/src/core/components/shared/workbenchBar/WorkbenchBarToolbarHandle.tsx create mode 100644 frontend/editor/src/core/components/shared/workbenchBar/types.ts create mode 100644 frontend/editor/src/core/components/shared/workbenchBar/workbenchBarTooltip.tsx diff --git a/frontend/editor/index.html b/frontend/editor/index.html index 427b6d1449..1e782db2de 100644 --- a/frontend/editor/index.html +++ b/frontend/editor/index.html @@ -4,7 +4,10 @@ - +
{banner}
{children}
diff --git a/frontend/editor/src/core/components/filesPage/FileGrid.tsx b/frontend/editor/src/core/components/filesPage/FileGrid.tsx index 7fd2eda133..40e17dc502 100644 --- a/frontend/editor/src/core/components/filesPage/FileGrid.tsx +++ b/frontend/editor/src/core/components/filesPage/FileGrid.tsx @@ -978,7 +978,9 @@ function FileCard({ )}
{fileSize} - · + {fileDate}
diff --git a/frontend/editor/src/core/components/filesPage/FileManagerView.tsx b/frontend/editor/src/core/components/filesPage/FileManagerView.tsx index e87b348780..36ffb9c4dc 100644 --- a/frontend/editor/src/core/components/filesPage/FileManagerView.tsx +++ b/frontend/editor/src/core/components/filesPage/FileManagerView.tsx @@ -33,6 +33,10 @@ import InfoOutlinedIcon from "@mui/icons-material/InfoOutlined"; import CloudUploadIcon from "@mui/icons-material/CloudUpload"; import KeyboardArrowRightIcon from "@mui/icons-material/KeyboardArrowRight"; import RefreshIcon from "@mui/icons-material/Refresh"; +import { FilesToolbarBulkMenu } from "@app/components/filesPage/FilesToolbarBulkMenu"; +import { FilesToolbarCount } from "@app/components/filesPage/FilesToolbarCount"; +import { FilesToolbarFilterMenu } from "@app/components/filesPage/FilesToolbarFilterMenu"; +import { FilesToolbarSortMenu } from "@app/components/filesPage/FilesToolbarSortMenu"; import { stripBasePath } from "@app/constants/app"; import { useAuth } from "@app/auth/UseSession"; @@ -893,6 +897,9 @@ export default function FileManagerView() { () => Array.from(selectedFileIds), [selectedFileIds], ); + // A phone with files selected shows a contextual selection bar instead of the + // full toolbar - five bulk buttons plus filters cannot fit the width. + const mobileSelection = isMobile && selectedFiles.length > 0; // Local-only subset of selection; drives Save-to-server visibility. const localOnlySelectedStubs = useMemo( @@ -1210,22 +1217,12 @@ export default function FileManagerView() { })()}
- - {loading - ? t("filesPage.loading", "Loading…") - : t("filesPage.summary", "{{count}} items", { - count: totalCount, - })} - {selectedFiles.length > 0 && ( - - {" "} - ·{" "} - {t("filesPage.selectedCount", "{{count}} selected", { - count: selectedFiles.length, - })} - - )} - + {(() => { // Select all / Clear toggle over visible files. if (visibleFiles.length === 0) return null; @@ -1265,289 +1262,382 @@ export default function FileManagerView() { ); })()}
- {selectedFiles.length > 0 && - (() => { - // Bulk-action labels; CSS collapses to icon-only below 900px. - const addLabel = + {mobileSelection ? ( + handleAddToWorkspace(selectedFiles)} + onSaveToServer={ + localOnlySelectedStubs.length > 0 + ? () => setSaveToServerTarget(localOnlySelectedStubs) + : undefined + } + saveToServerDisabledReason={ + saveToServerDisabledReason ?? undefined + } + onShowDetails={ selectedFiles.length === 1 - ? t("filesPage.addToWorkspace", "Add to workspace") - : t( - "filesPage.addToWorkspaceCount", - "Add {{count}} to workspace", - { count: selectedFiles.length }, - ); - const moveLabel = t("filesPage.moveTo", "Move to…"); - const removeLabel = t("filesPage.remove", "Remove"); - return ( - // wrap="nowrap" keeps the row single-line. - - - - - {/* Save to server; shown whenever local-only files are + ? () => setMobileDetailsOpen(true) + : undefined + } + onMove={() => promptMoveFiles(selectedFiles)} + onRemove={() => handleRemoveFiles(selectedFiles)} + /> + ) : ( + <> + {selectedFiles.length > 0 && + (() => { + // Bulk-action labels; CSS collapses to icon-only below 900px. + const addLabel = + selectedFiles.length === 1 + ? t("filesPage.addToWorkspace", "Add to workspace") + : t( + "filesPage.addToWorkspaceCount", + "Add {{count}} to workspace", + { count: selectedFiles.length }, + ); + const moveLabel = t("filesPage.moveTo", "Move to…"); + const removeLabel = t("filesPage.remove", "Remove"); + return ( + // wrap="nowrap" keeps the row single-line. + + + + + {/* Save to server; shown whenever local-only files are selected. When storage is off it stays visible but disabled, tooltip pointing at the admin. */} - {localOnlySelectedStubs.length > 0 && ( - - + + )} + {/* Show details button on compact viewports. */} + {selectedFiles.length === 1 && + isCompactDetailsViewport && ( + + + )} - > - {t("filesPage.saveToServer", "Save to server")} - - - )} - {/* Show details button on compact viewports. */} - {selectedFiles.length === 1 && - isCompactDetailsViewport && ( - + + + + + + clearSelection()} + aria-label={t( + "filesPage.clearSelection", + "Clear selection", + )} + > + × + + + + ); + })()} + {selectedFiles.length > 0 && ( +
diff --git a/frontend/editor/src/core/components/filesPage/FilesPage.css b/frontend/editor/src/core/components/filesPage/FilesPage.css index aa9d8248aa..6d4070edb6 100644 --- a/frontend/editor/src/core/components/filesPage/FilesPage.css +++ b/frontend/editor/src/core/components/filesPage/FilesPage.css @@ -215,6 +215,17 @@ .files-page-toolbar-actions .mantine-Button-label { display: none; } +/* Exception: the bulk-actions trigger IS its label. Collapsed to an icon it + would read as a bare chevron with nothing to say what it opens. */ +.files-page-toolbar-actions + .files-page-toolbar-bulk-trigger + .mantine-Button-label { + display: inline; +} +.files-page-toolbar-actions .files-page-toolbar-bulk-trigger { + padding-left: 0.75rem; + padding-right: 0.5rem; +} /* Pin the view toggle: never let it clip off the right. flex-shrink:0 keeps its width fixed; the rest of the row shrinks around it. */ .files-page-toolbar-actions .mantine-SegmentedControl-root { @@ -476,6 +487,23 @@ gap: 0.4rem; } +/* Narrow cards: let the values wrap as whole units onto their own lines rather + than breaking mid-value ("239.26 / KB") around a stranded separator. */ +@media (max-width: 64rem) { + .files-page-card-meta { + flex-wrap: wrap; + align-items: baseline; + column-gap: 0.4rem; + row-gap: 0.05rem; + } + .files-page-card-meta > span { + white-space: nowrap; + } + .files-page-card-meta-sep { + display: none; + } +} + /* Parent-folder breadcrumb shown on cards/rows during recursive search so the user can tell which folder each hit lives in without navigating. */ .files-page-card-path { @@ -1295,30 +1323,38 @@ sits next to the Upload button without breaking the action row. */ display: none; } -@media (max-width: 900px) { +@media (max-width: 1024px) { .files-page-toolbar { /* nowrap so "7 items" + "Select all" sit on the same row as the filter dropdowns and view-toggle instead of stacking on three - separate lines. Per-child min-width:0 lets them shrink as needed. - Used to only kick in at ≤640px which left a broken zone where - both side panels were hidden but the toolbar was still wrapping - to multiple rows. */ + separate lines. Runs to the app's mobile breakpoint: capping it at + 900px left 901-1024px wrapping to two rows, which is the band the + mobile layout actually renders in. + + Scrolls rather than clips. With a selection active the bulk-action + strip cannot fit any phone width, and `overflow-x: hidden` put those + buttons permanently out of reach behind the edge. */ flex-wrap: nowrap; gap: 0.35rem; padding: 0.35rem 0.5rem; min-height: auto; - overflow-x: hidden; + overflow-x: auto; + scrollbar-width: none; + } + .files-page-toolbar::-webkit-scrollbar { + display: none; } .files-page-toolbar-info { - /* Was `flex-basis: 100%` which forced a row break. Let it share - the row, shrink hard if needed, and ellipsize so the count line - collapses gracefully (was overlapping the bulk-action buttons - at ~400px because no truncation rule existed). */ - flex: 0 1 auto; + /* The toolbar's only status text. Pinned, because against nowrap + siblings it lost every shrink round and rendered as "3 i". */ + flex-shrink: 0; min-width: 0; white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; + } + /* Filter and sort collapse to icon triggers here (see FilesToolbar*Menu); + they are the whole control, so they never shrink. */ + .files-page-toolbar-icon-btn { + flex-shrink: 0; } .files-page-toolbar-actions { flex-wrap: nowrap; @@ -1350,41 +1386,48 @@ navigation, so the in-header Home/Apps/Close trio is duplicated and the first to go. Same for "Upload" - the user can use the centre drop overlay. */ -@media (max-width: 640px) { - /* Drop the 3-column grid on phones; flex-wrap lets the search slip onto - * its own row when chrome is too cramped to share. */ +/* ── Mobile + tablet chrome (≤1024px = useIsMobile) ────────────────── + The desktop header is a 3-column grid whose middle track can grow to + 40rem. Below ~1024px that track eats the row: the breadcrumb column + collapsed to ~36px (wrapping "All files" to two lines) and the action + column overflowed, pushing Upload off the right edge. One flex row + instead - breadcrumb and actions keep their intrinsic width and the + search takes whatever is left. Ends at the app's mobile breakpoint so + it matches the layout HomePage is already rendering. */ +@media (max-width: 1024px) { .files-page-header { display: flex; - flex-wrap: wrap; + flex-wrap: nowrap; + align-items: center; gap: 0.4rem; - padding: 0 0.4rem; + padding: 0.25rem 0.4rem; overflow-x: hidden; } - .files-page-header [data-mobile-hide="true"] { - display: none !important; + .files-page-header-search { + flex: 1 1 auto; + min-width: 0; + justify-content: flex-start; } - .files-page-header [data-desktop-hide="true"] { - display: inline-flex !important; - } - /* Mobile-hide for sub-toolbar create buttons. */ - .files-page-toolbar [data-mobile-hide="true"] { - display: none !important; + /* Undo the fixed 24rem basis so the pill tracks the row's spare width. */ + .files-page-header-search .super-search { + flex: 1 1 auto; + width: 100%; + max-width: none; } .files-page-header-actions { + flex: 0 0 auto; margin-left: auto; - gap: 0.3rem; + gap: 0.25rem; flex-wrap: nowrap; } .files-page-breadcrumbs { + flex: 0 1 auto; font-size: 0.85rem; flex-wrap: nowrap; overflow-x: auto; min-width: 0; } - /* Upload becomes an icon-only square button on mobile so the action - row stops getting clipped. Scoped to `.files-page-header-actions` - so the Back button at the header level keeps its visible "Back" - label (Back has no other on-screen indicator that it's about leaving). */ + /* Icon-only actions: the labels are what pushed Upload past the edge. */ .files-page-header-actions .mantine-Button-root { padding-left: 0.55rem; padding-right: 0.55rem; @@ -1395,6 +1438,9 @@ .files-page-header-actions .mantine-Button-label { display: none; } +} + +@media (max-width: 640px) { /* Grid: single column on very narrow phones; two columns from ~440px */ .files-page-grid { grid-template-columns: repeat(auto-fill, minmax(9rem, 1fr)); diff --git a/frontend/editor/src/core/components/filesPage/FilesToolbarBulkMenu.tsx b/frontend/editor/src/core/components/filesPage/FilesToolbarBulkMenu.tsx new file mode 100644 index 0000000000..3e344ac7b0 --- /dev/null +++ b/frontend/editor/src/core/components/filesPage/FilesToolbarBulkMenu.tsx @@ -0,0 +1,104 @@ +import { Menu } from "@mantine/core"; +import { useTranslation } from "react-i18next"; +import CloudUploadIcon from "@mui/icons-material/CloudUpload"; +import DeleteIcon from "@mui/icons-material/Delete"; +import DriveFileMoveIcon from "@mui/icons-material/DriveFileMove"; +import ExpandMoreIcon from "@mui/icons-material/ExpandMore"; +import InfoOutlinedIcon from "@mui/icons-material/InfoOutlined"; +import OpenInNewIcon from "@mui/icons-material/OpenInNew"; + +import { Button } from "@app/ui/Button"; + +interface FilesToolbarBulkMenuProps { + selectedCount: number; + onAddToWorkspace: () => void; + /** Local-only files in the selection; omit when there are none to upload. */ + onSaveToServer?: () => void; + /** Set when storage is off - the item stays listed but disabled. */ + saveToServerDisabledReason?: string; + onShowDetails?: () => void; + onMove: () => void; + onRemove: () => void; +} + +/** + * Bulk actions behind one trigger. The full strip is five buttons wide, which + * no phone can hold alongside the count and the clear control, so rather than + * letting the row scroll them off the edge they collapse into a menu where + * every action keeps its label. + */ +export function FilesToolbarBulkMenu({ + selectedCount, + onAddToWorkspace, + onSaveToServer, + saveToServerDisabledReason, + onShowDetails, + onMove, + onRemove, +}: FilesToolbarBulkMenuProps) { + const { t } = useTranslation(); + + const addLabel = + selectedCount === 1 + ? t("filesPage.addToWorkspace", "Add to workspace") + : t("filesPage.addToWorkspaceCount", "Add {{count}} to workspace", { + count: selectedCount, + }); + + return ( + + + + + + } + onClick={onAddToWorkspace} + > + {addLabel} + + {onSaveToServer && ( + } + disabled={Boolean(saveToServerDisabledReason)} + onClick={onSaveToServer} + > + {t("filesPage.saveToServer", "Save to server")} + + )} + {onShowDetails && ( + } + onClick={onShowDetails} + > + {t("filesPage.showDetails", "Show details")} + + )} + } + onClick={onMove} + > + {t("filesPage.moveTo", "Move to…")} + + + } + onClick={onRemove} + > + {t("filesPage.remove", "Remove")} + + + + ); +} + +export default FilesToolbarBulkMenu; diff --git a/frontend/editor/src/core/components/filesPage/FilesToolbarCount.tsx b/frontend/editor/src/core/components/filesPage/FilesToolbarCount.tsx new file mode 100644 index 0000000000..d808638d63 --- /dev/null +++ b/frontend/editor/src/core/components/filesPage/FilesToolbarCount.tsx @@ -0,0 +1,41 @@ +import { useTranslation } from "react-i18next"; + +interface FilesToolbarCountProps { + loading: boolean; + totalCount: number; + selectedCount: number; + /** + * Selection-bar mode: report only the selection. A phone spends the room on + * the actions rather than on "3 items · 3 selected". + */ + selectionOnly: boolean; +} + +/** Status text at the head of the files toolbar. */ +export function FilesToolbarCount({ + loading, + totalCount, + selectedCount, + selectionOnly, +}: FilesToolbarCountProps) { + const { t } = useTranslation(); + + const selected = t("filesPage.selectedCount", "{{count}} selected", { + count: selectedCount, + }); + + if (selectionOnly) { + return {selected}; + } + + return ( + + {loading + ? t("filesPage.loading", "Loading…") + : t("filesPage.summary", "{{count}} items", { count: totalCount })} + {selectedCount > 0 && · {selected}} + + ); +} + +export default FilesToolbarCount; diff --git a/frontend/editor/src/core/components/filesPage/FilesToolbarFilterMenu.tsx b/frontend/editor/src/core/components/filesPage/FilesToolbarFilterMenu.tsx new file mode 100644 index 0000000000..81d84daf67 --- /dev/null +++ b/frontend/editor/src/core/components/filesPage/FilesToolbarFilterMenu.tsx @@ -0,0 +1,149 @@ +import { MultiSelect, Popover, Select, Stack, TextInput } from "@mantine/core"; +import { useTranslation } from "react-i18next"; +import CloseIcon from "@mui/icons-material/Close"; +import SearchIcon from "@mui/icons-material/Search"; +import TuneIcon from "@mui/icons-material/Tune"; + +import { ActionIcon } from "@app/ui/ActionIcon"; +import { Button } from "@app/ui/Button"; +import { Tooltip } from "@app/components/shared/Tooltip"; +import type { FilesPageOriginFilter } from "@app/contexts/FilesPageContext"; + +interface FilesToolbarFilterMenuProps { + originFilter: FilesPageOriginFilter; + onOriginChange: (value: FilesPageOriginFilter) => void; + availableTypes: string[]; + typeFilter: string[]; + onTypeChange: (value: string[]) => void; + search: string; + onSearchChange: (value: string) => void; +} + +/** + * Source, type and name filters collapsed behind one icon. Side by side these + * three need ~480px, so on narrow viewports they were each truncated to + * unreadable stubs ("All sour"). In the popover they get their full width back, + * and a dot on the trigger keeps an active filter discoverable while hidden. + */ +export function FilesToolbarFilterMenu({ + originFilter, + onOriginChange, + availableTypes, + typeFilter, + onTypeChange, + search, + onSearchChange, +}: FilesToolbarFilterMenuProps) { + const { t } = useTranslation(); + + const activeCount = + (originFilter !== "all" ? 1 : 0) + + (typeFilter.length > 0 ? 1 : 0) + + (search.trim() !== "" ? 1 : 0); + const label = t("filesPage.filters.label", "Filters"); + + const clearAll = () => { + onOriginChange("all"); + onTypeChange([]); + onSearchChange(""); + }; + + return ( + + +
+ 0 + ? t( + "filesPage.filters.activeCount", + "{{count}} filters active", + { + count: activeCount, + }, + ) + : label + } + position="bottom" + > + 0 ? "primary" : "tertiary"} + size="sm" + aria-label={label} + className="files-page-toolbar-icon-btn" + > + + + +
+
+ + + onSearchChange(e.currentTarget.value)} + placeholder={t("filesPage.search.placeholder", "Filter files…")} + leftSection={} + rightSection={ + search ? ( + onSearchChange("")} + aria-label={t("filesPage.search.clear", "Clear filter")} + > + + + ) : null + } + aria-label={t("filesPage.search.label", "Filter files by name")} + /> +