From 44b70590752a3358f6836e054466762a6cb6af91 Mon Sep 17 00:00:00 2001 From: Pavel Date: Sat, 26 Sep 2026 21:20:24 +0400 Subject: [PATCH] Mobile modal standartisation --- frontend/DESIGN.md | 34 +++++++-- .../components/SourcesPopoverFooter.test.tsx | 70 +++++++++++++++++++ .../src/components/SourcesPopoverFooter.tsx | 19 +++-- frontend/src/components/ui/modal.test.tsx | 2 +- frontend/src/components/ui/sheet.test.tsx | 2 +- frontend/src/components/ui/sheet.tsx | 9 +-- frontend/src/design/DesignSystem.tsx | 29 +++++++- frontend/src/index.css | 10 +++ frontend/src/navigation/MobileTopBar.test.tsx | 25 +++++++ frontend/src/navigation/MobileTopBar.tsx | 12 +++- 10 files changed, 185 insertions(+), 27 deletions(-) create mode 100644 frontend/src/components/SourcesPopoverFooter.test.tsx diff --git a/frontend/DESIGN.md b/frontend/DESIGN.md index 75cce079..6e5ca8a7 100644 --- a/frontend/DESIGN.md +++ b/frontend/DESIGN.md @@ -746,6 +746,15 @@ agents), Share, Rename and Delete. Rename edits the name in place, as the sidebar row does. A title with no actions (a shared agent's new chat) is plain text. +A long title truncates to one line and never runs under New Chat or the +avatar: the title slot is `flex min-w-0 flex-1`, the agent's `Avatar` and the +chevron keep their size, and only the name gives way (a `truncate` span with +`title=` holding the full name). This is the rule for any Button that holds a +user-supplied name (a chat, agent or source) in a flex row: Button's base is +`shrink-0 whitespace-nowrap`, so `min-w-0` alone does nothing. Pass `min-w-0 +shrink` (or `flex-1` / `w-full` when it should fill the row) and put the name +in the `truncate` span; its icons stay `shrink-0`. + New Chat is `SquarePen` everywhere: the phone bar, the sidebar's New Chat row and the collapsed rail. `Plus` means "add an item to this list", not "start a chat". @@ -822,14 +831,18 @@ scroller) and the same blurred overlay. Modal, DialogContent and every Sheet sha dark:bg-black/50`). Every phone bottom sheet has one shape: `bg-card`, 16px top corners, no top -border, at most 90% of the viewport, and bottom padding that clears the iPhone -home indicator. `SheetContent side="bottom"` gives it with `pb-safe-0` (the -bare inset); pass `handle` for the grab bar, which also hides the X (the handle and -the overlay dismiss it; pass `showCloseButton` to keep one). +border, `max-h-sheet` (the visible viewport less the top safe-area inset and a +3rem strip, so the scrim above it can always be tapped to close), and bottom +padding that clears the iPhone home indicator. Never cap a sheet with `vh`: on +iOS Safari it is the viewport with the toolbars hidden, so a `90vh` sheet slides +under the URL bar and covers its scrim. `SheetContent side="bottom"` gives the +shape with `pb-safe-0` (the bare inset); pass `handle` for the grab bar, which +also hides the X (the overlay dismisses it; the handle is only a cue and doesn't +drag; pass `showCloseButton` to keep an X). `Modal mobileVariant="sheet"` shares the shape and the `SheetHandle`, with -`pb-safe` (the inset, at least 1rem) under its footer. `pb-safe` and -`pb-safe-0` are the `index.css` utilities for `env(safe-area-inset-bottom)`; -never spell `env()` in a class. +`pb-safe` (the inset, at least 1rem) under its footer. `pb-safe`, `pb-safe-0` +and `max-h-sheet` are the `index.css` utilities for the safe-area insets; never +spell `env()` in a class. **Open question: side panel or right sheet for chat content.** Chat has two ways to show something beside an answer. Notes, todos and files open in @@ -850,6 +863,13 @@ follows the pointer and arrow keys, so an accent fill would look like hover. While a checked item is also highlighted it keeps its tint and text and gains a 1px inset `primary` ring, so the chosen row never turns plain grey. +A picker's footer (`MultiSelectPopover footer`) links to the page that manages +the list, as a `link inline` Button with a 12px `ArrowRight` (Go to Sources, Go +to Tools). A shortcut action (Upload new, an `outline-primary pill`) sits at the +right end of the same row: `flex flex-wrap items-center justify-between gap-3`, +so on a narrow sheet or in a long locale it wraps under the link rather than +taking a row of its own everywhere (`SourcesPopoverFooter`). + ### ActionMenu (`ui/dropdown-menu.tsx`) The three-dots menu on a card, tile or row. Pass `options: MenuOption[]` diff --git a/frontend/src/components/SourcesPopoverFooter.test.tsx b/frontend/src/components/SourcesPopoverFooter.test.tsx new file mode 100644 index 00000000..8e5f6aae --- /dev/null +++ b/frontend/src/components/SourcesPopoverFooter.test.tsx @@ -0,0 +1,70 @@ +import { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { MemoryRouter } from 'react-router-dom'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import SourcesPopoverFooter from './SourcesPopoverFooter'; + +Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }); + +let container: HTMLDivElement; +let root: Root; + +beforeEach(() => { + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); +}); + +afterEach(async () => { + await act(async () => root.unmount()); + container.remove(); +}); + +const renderFooter = async (onNavigate = vi.fn(), onUploadClick = vi.fn()) => { + await act(async () => + root.render( + + + , + ), + ); + return { onNavigate, onUploadClick }; +}; + +describe('SourcesPopoverFooter', () => { + it('puts the link and Upload new in one row that wraps when it does not fit', async () => { + await renderFooter(); + const row = container.firstElementChild as HTMLElement; + const classes = row.className.split(' '); + expect(classes).toEqual( + expect.arrayContaining([ + 'flex', + 'flex-wrap', + 'items-center', + 'justify-between', + ]), + ); + expect(classes).not.toContain('flex-col'); + }); + + it('renders the sources link as an inline link Button', async () => { + await renderFooter(); + const link = container.querySelector('a')!; + expect(link.getAttribute('href')).toBe('/settings/sources'); + expect(link.dataset.slot).toBe('button'); + expect(link.className).toContain('text-primary'); + expect(link.className).toContain('text-sm'); + }); + + it('fires onNavigate from the link and onUploadClick from the button', async () => { + const { onNavigate, onUploadClick } = await renderFooter(); + await act(async () => container.querySelector('a')!.click()); + await act(async () => container.querySelector('button')!.click()); + expect(onNavigate).toHaveBeenCalledOnce(); + expect(onUploadClick).toHaveBeenCalledOnce(); + }); +}); diff --git a/frontend/src/components/SourcesPopoverFooter.tsx b/frontend/src/components/SourcesPopoverFooter.tsx index 906ac6b0..0c7a3dcb 100644 --- a/frontend/src/components/SourcesPopoverFooter.tsx +++ b/frontend/src/components/SourcesPopoverFooter.tsx @@ -18,21 +18,20 @@ export default function SourcesPopoverFooter({ const { t } = useTranslation(); return ( -
- - {t('settings.sources.goToSources')} - - + // One row when it fits (link left, upload right); on a narrow sheet or a + // long locale the button wraps under the link. +
+ diff --git a/frontend/src/components/ui/modal.test.tsx b/frontend/src/components/ui/modal.test.tsx index 0912eb97..7ba01da9 100644 --- a/frontend/src/components/ui/modal.test.tsx +++ b/frontend/src/components/ui/modal.test.tsx @@ -199,7 +199,7 @@ describe('Modal mobile sheet', () => { expect.arrayContaining([ 'bg-card', 'rounded-t-2xl', - 'max-h-[90vh]', + 'max-h-sheet', 'pb-safe', 'gap-3', 'px-4', diff --git a/frontend/src/components/ui/sheet.test.tsx b/frontend/src/components/ui/sheet.test.tsx index de8c0ac5..f109115d 100644 --- a/frontend/src/components/ui/sheet.test.tsx +++ b/frontend/src/components/ui/sheet.test.tsx @@ -43,7 +43,7 @@ describe('SheetContent side="bottom"', () => { expect.arrayContaining([ 'bg-card', 'rounded-t-2xl', - 'max-h-[90vh]', + 'max-h-sheet', 'pb-safe-0', ]), ); diff --git a/frontend/src/components/ui/sheet.tsx b/frontend/src/components/ui/sheet.tsx index 48416ea9..8869a495 100644 --- a/frontend/src/components/ui/sheet.tsx +++ b/frontend/src/components/ui/sheet.tsx @@ -45,13 +45,13 @@ function SheetOverlay({ } /** - * The phone bottom-sheet shape: card fill, 16px top corners, capped at 90% of - * the viewport. Shared with Modal's `mobileVariant="sheet"` so every bottom + * The phone bottom-sheet shape: card fill, 16px top corners, capped by + * `max-h-sheet` so the scrim above it stays tappable. Shared with Modal's `mobileVariant="sheet"` so every bottom * sheet looks the same; the caller adds its own bottom padding (`pb-safe` or * `pb-safe-0`). */ const sheetBottomShape = - 'data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 h-auto max-h-[90vh] rounded-t-2xl bg-card'; + 'data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 h-auto max-h-sheet rounded-t-2xl bg-card'; /** The grab bar at the top of a bottom sheet, 8px below its edge. */ function SheetHandle({ className, ...props }: React.ComponentProps<'div'>) { @@ -91,7 +91,8 @@ function SheetContent({ children, side = 'right', handle = false, - // A bottom sheet with a grab handle closes by drag or scrim, not an X. + // A bottom sheet with a grab handle closes by its scrim, not an X (the + // handle is only a cue; it doesn't drag). showCloseButton = !handle, title, ...props diff --git a/frontend/src/design/DesignSystem.tsx b/frontend/src/design/DesignSystem.tsx index 66f5513d..50910fad 100644 --- a/frontend/src/design/DesignSystem.tsx +++ b/frontend/src/design/DesignSystem.tsx @@ -13,6 +13,7 @@ import { Plus, Search, Settings, + SquarePen, Bot, ChevronDown, ChevronRight, @@ -701,6 +702,30 @@ export default function DesignSystem() {
+ +
+
+ +
+ +
+
Tools The phone picker shape: card fill, rounded top, grab - handle, clear of the home indicator. + handle, clear of the home indicator. max-h-sheet caps it + below the visible screen, so the scrim above can always be + tapped to close it. diff --git a/frontend/src/index.css b/frontend/src/index.css index 9c2d052f..2c221f3a 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -28,6 +28,16 @@ padding-bottom: env(safe-area-inset-bottom); } +/* The bottom-sheet cap. vh is the viewport with the toolbars hidden on iOS + Safari, so a 90vh sheet slides under the URL bar and covers its scrim. + dvh follows the visible area; less the top inset (the notch in an installed + PWA) and a 3rem strip, so the scrim above the sheet can always be tapped. + svh is the fallback where dvh isn't supported. */ +@utility max-h-sheet { + max-height: 90svh; + max-height: calc(100dvh - env(safe-area-inset-top) - 3rem); +} + @utility no-scrollbar { /* Chrome, Safari and Opera */ &::-webkit-scrollbar { diff --git a/frontend/src/navigation/MobileTopBar.test.tsx b/frontend/src/navigation/MobileTopBar.test.tsx index 0d4049dd..37733702 100644 --- a/frontend/src/navigation/MobileTopBar.test.tsx +++ b/frontend/src/navigation/MobileTopBar.test.tsx @@ -90,6 +90,31 @@ describe('MobileTopBar', () => { expect(title?.tagName).not.toBe('BUTTON'); }); + it('lets a long title shrink and truncate instead of running under the actions', () => { + const long = + 'Explain the difference between agents and workflows in detail'; + render({ title: long, conversationId: 'c1', onNewChat: () => {} }); + + // Button's base is shrink-0, so min-w-0 alone never lets it narrow. + const trigger = container.querySelector('[data-testid="mobile-title"]')!; + const classes = trigger.className.split(' '); + expect(classes).toEqual(expect.arrayContaining(['min-w-0', 'shrink'])); + expect(classes).not.toContain('shrink-0'); + const name = trigger.querySelector('span.truncate')!; + expect(name.textContent).toBe(long); + expect(name.getAttribute('title')).toBe(long); + }); + + it('truncates a long plain-text title too', () => { + const long = 'A shared agent with a very long name that cannot fit'; + render({ title: long, agentImage: '', onNewChat: () => {} }); + + const name = container.querySelector( + '[data-testid="mobile-title"] span.truncate', + )!; + expect(name.getAttribute('title')).toBe(long); + }); + it('opens share, rename and delete from the conversation title', () => { render({ title: 'Router drops', diff --git a/frontend/src/navigation/MobileTopBar.tsx b/frontend/src/navigation/MobileTopBar.tsx index 3d783f3c..6018d302 100644 --- a/frontend/src/navigation/MobileTopBar.tsx +++ b/frontend/src/navigation/MobileTopBar.tsx @@ -184,7 +184,9 @@ export default function MobileTopBar({ className="text-foreground flex min-w-0 items-center gap-1.5 px-2 text-sm font-medium" > {agentMark} - {title} + + {title} + ); } @@ -196,10 +198,14 @@ export default function MobileTopBar({ variant="ghost" size="sm" data-testid="mobile-title" - className="min-w-0" + // Button is shrink-0; shrink lets it narrow to its slot so the + // name truncates instead of running under New Chat. + className="min-w-0 shrink" > {agentMark} - {title} + + {title} +