Mobile modal standartisation

This commit is contained in:
Pavel committed 2026-09-26 21:20:24 +04:00
1 parent b54081f9b4
commit 44b7059075
10 files changed
+185 -27

No files matched your search

+27 -7
View File
@@ -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[]`
@@ -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(
<MemoryRouter>
<SourcesPopoverFooter
onNavigate={onNavigate}
onUploadClick={onUploadClick}
/>
</MemoryRouter>,
),
);
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();
});
});
@@ -18,21 +18,20 @@ export default function SourcesPopoverFooter({
const { t } = useTranslation();
return (
<div className="flex flex-col gap-3">
<Link
to="/settings/sources"
className="text-primary inline-flex items-center gap-2 text-base font-medium"
onClick={onNavigate}
>
{t('settings.sources.goToSources')}
<ArrowRight className="size-3" />
</Link>
// One row when it fits (link left, upload right); on a narrow sheet or a
// long locale the button wraps under the link.
<div className="flex flex-wrap items-center justify-between gap-3">
<Button variant="link" size="inline" asChild>
<Link to="/settings/sources" onClick={onNavigate}>
{t('settings.sources.goToSources')}
<ArrowRight aria-hidden="true" className="size-3" />
</Link>
</Button>
<Button
type="button"
variant="outline-primary"
shape="pill"
onClick={onUploadClick}
className="w-auto self-start"
>
{t('settings.sources.uploadNew')}
</Button>
+1 -1
View File
@@ -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',
+1 -1
View File
@@ -43,7 +43,7 @@ describe('SheetContent side="bottom"', () => {
expect.arrayContaining([
'bg-card',
'rounded-t-2xl',
'max-h-[90vh]',
'max-h-sheet',
'pb-safe-0',
]),
);
+5 -4
View File
@@ -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
+28 -1
View File
@@ -13,6 +13,7 @@ import {
Plus,
Search,
Settings,
SquarePen,
Bot,
ChevronDown,
ChevronRight,
@@ -701,6 +702,30 @@ export default function DesignSystem() {
</Button>
</div>
</Example>
<Example
title="Truncating a name in a Button"
code='<Button className="min-w-0 shrink"><Avatar /><span className="truncate" title={name}>{name}</span><ChevronDown /></Button>'
>
<div className="flex w-64 items-center gap-1 rounded-md border p-1">
<div className="flex min-w-0 flex-1">
<Button variant="ghost" size="sm" className="min-w-0 shrink">
<span
className="truncate"
title="Explain the difference between agents and workflows"
>
Explain the difference between agents and workflows
</span>
<ChevronDown className="text-muted-foreground" aria-hidden />
</Button>
</div>
<IconButton
label="New chat"
variant="ghost-muted"
size="icon"
icon={SquarePen}
/>
</div>
</Example>
<Example
title="Combobox trigger"
code='<Button variant="combobox" role="combobox" data-placeholder={value ? undefined : ""}>'
@@ -2605,7 +2630,9 @@ export default function DesignSystem() {
<SheetTitle>Tools</SheetTitle>
<SheetDescription>
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.
</SheetDescription>
</SheetHeader>
</SheetContent>
+10
View File
@@ -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 {
@@ -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',
+9 -3
View File
@@ -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}
<span className="truncate">{title}</span>
<span className="truncate" title={title}>
{title}
</span>
</span>
);
}
@@ -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}
<span className="truncate">{title}</span>
<span className="truncate" title={title}>
{title}
</span>
<ChevronDown className="text-muted-foreground" aria-hidden />
</Button>
</DropdownMenuTrigger>