mirror of
https://github.com/tiennm99/DocsGPT.git
synced 2026-10-14 04:15:35 +00:00
Mobile modal standartisation
This commit is contained in:
1 parent
b54081f9b4
commit
44b7059075
10 files changed
+185
-27
No files matched your search
+27
-7
@@ -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>
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -43,7 +43,7 @@ describe('SheetContent side="bottom"', () => {
|
||||
expect.arrayContaining([
|
||||
'bg-card',
|
||||
'rounded-t-2xl',
|
||||
'max-h-[90vh]',
|
||||
'max-h-sheet',
|
||||
'pb-safe-0',
|
||||
]),
|
||||
);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user