From bce43eddfb5bc98ba01719ed4bd208e79f284070 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 08:25:44 +0000 Subject: [PATCH 1/8] fix(ui): the list flow at phone width: 44 px touch targets, inset panels, a card pager, pull-to-refresh only at the top WIP: toolbar, view switcher, filter, sort and grouping builders, Display panel, the phone card list's pager and the pull-to-refresh guard. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01QsR2jUwDX3ZR6eBujakKoT --- .../src/views/objectViewTitleSwitcher.tsx | 18 +++--- .../components/src/custom/filter-builder.tsx | 30 +++++---- .../components/src/custom/grouping-editor.tsx | 11 ++-- .../components/src/custom/sort-builder.tsx | 22 ++++--- ui/packages/mobile/src/usePullToRefresh.ts | 19 +++++- ui/packages/plugin-grid/src/ObjectGrid.tsx | 33 ++++++++++ ui/packages/plugin-list/src/ListView.tsx | 32 +++++++--- .../src/components/ViewSettingsPopover.tsx | 62 +++++++++++++++---- 8 files changed, 172 insertions(+), 55 deletions(-) diff --git a/ui/packages/app-shell/src/views/objectViewTitleSwitcher.tsx b/ui/packages/app-shell/src/views/objectViewTitleSwitcher.tsx index bffe4eaadf2..17ced1692ce 100644 --- a/ui/packages/app-shell/src/views/objectViewTitleSwitcher.tsx +++ b/ui/packages/app-shell/src/views/objectViewTitleSwitcher.tsx @@ -154,7 +154,8 @@ export function ObjectViewTitleSwitcher({ : t('view.switcher.pin', { defaultValue: 'Pin: open this view by default' }); // Shown on hover or focus, and always while set, so a starred or pinned // view says so without a hover. - const reveal = 'opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 focus-visible:opacity-100'; + // A touch screen has no hover, so there they always show. + const reveal = 'opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 focus-visible:opacity-100 pointer-coarse:opacity-100'; return (
choose(view.id)} aria-current={active ? 'true' : undefined} className={cn( - 'flex min-w-0 flex-1 items-center gap-2 rounded-sm px-2 py-1.5 text-left text-sm', + 'flex min-w-0 flex-1 items-center gap-2 rounded-sm px-2 py-1.5 text-left text-sm max-sm:min-h-11', 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring', active ? 'font-medium text-foreground' : 'text-foreground/90', )} @@ -188,7 +189,7 @@ export function ObjectViewTitleSwitcher({ aria-disabled={inMenu || undefined} onClick={() => { if (!inMenu) favorites.toggle(view.id, view.label); }} className={cn( - 'flex h-7 w-7 shrink-0 items-center justify-center rounded-sm', + 'flex h-7 w-7 shrink-0 items-center justify-center rounded-sm max-sm:h-11 max-sm:w-11', 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring', inMenu ? 'cursor-default text-muted-foreground/40' @@ -207,7 +208,7 @@ export function ObjectViewTitleSwitcher({ aria-pressed={isPinned} onClick={() => onPinnedViewChange(isPinned ? null : view.id)} className={cn( - 'flex h-7 w-7 shrink-0 items-center justify-center rounded-sm text-muted-foreground hover:text-foreground', + 'flex h-7 w-7 shrink-0 items-center justify-center rounded-sm text-muted-foreground hover:text-foreground max-sm:h-11 max-sm:w-11', 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring', isPinned ? 'text-foreground' : reveal, )} @@ -229,7 +230,8 @@ export function ObjectViewTitleSwitcher({ // No negative margin: the title's width is its content's, and a // margin pulled out of it truncated the label it was sized for. className={cn( - 'inline-flex min-w-0 max-w-full items-center gap-1 rounded-md px-1 text-left', + // Below `sm` the trigger is a 44 px touch target. + 'inline-flex min-w-0 max-w-full items-center gap-1 rounded-md px-1 text-left max-sm:min-h-11', // Inset: the title clips what overflows it (`truncate`), an outer ring included. 'hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring', open && 'bg-muted', @@ -253,7 +255,7 @@ export function ObjectViewTitleSwitcher({ placeholder={t('view.searchViews', { defaultValue: 'Search views' })} aria-label={t('view.searchViews', { defaultValue: 'Search views' })} data-testid="list-view-switcher-search" - className="h-8" + className="h-8 max-sm:h-11" />
@@ -295,7 +297,7 @@ export function ObjectViewTitleSwitcher({ type="button" data-testid="list-view-switcher-new" onClick={() => { onOpenChange(false); onAddView(); }} - className="flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-sm hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" + className="flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-sm hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring max-sm:min-h-11" > {t('view.addNewView', { defaultValue: 'Add new view' })} @@ -306,7 +308,7 @@ export function ObjectViewTitleSwitcher({ type="button" data-testid="list-view-switcher-manage" onClick={() => { onOpenChange(false); onManageViews(); }} - className="flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-sm hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" + className="flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-sm hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring max-sm:min-h-11" > {t('view.manageViews', { defaultValue: 'Manage views' })} diff --git a/ui/packages/components/src/custom/filter-builder.tsx b/ui/packages/components/src/custom/filter-builder.tsx index 81a37609a06..333ec4cd20f 100644 --- a/ui/packages/components/src/custom/filter-builder.tsx +++ b/ui/packages/components/src/custom/filter-builder.tsx @@ -1954,7 +1954,7 @@ function FilterBuilder({ return ( - + @@ -2048,7 +2048,7 @@ function FilterBuilder({ updateCondition(condition.id, { value: value === "true" }) } > - + @@ -2113,7 +2113,8 @@ function FilterBuilder({ return ( handleValueChange(e.target.value)} @@ -2132,7 +2133,7 @@ function FilterBuilder({ variant="outline" size="sm" onClick={toggleLogic} - className="h-7 text-xs" + className="h-7 text-xs max-sm:h-11" disabled={filterGroup.logic === "and" && pairsCurrency} title={pairsCurrency ? t('filterBuilder.currencyNeedsAnd') : undefined} data-testid="filter-logic-toggle" @@ -2147,7 +2148,7 @@ function FilterBuilder({ variant="ghost" size="sm" onClick={clearAllConditions} - className="h-7 text-xs text-muted-foreground hover:text-destructive" + className="h-7 text-xs text-muted-foreground hover:text-destructive max-sm:h-11" > {t('filterBuilder.clearAll')} @@ -2164,18 +2165,21 @@ function FilterBuilder({
{visibleConditions.map((condition) => { const pairsWith = pairedCurrencyField(condition, fields, needsValueInput) - // Four controls on a paired row, three on every other. - const span = pairsWith ? "col-span-3" : "col-span-4" + // Four controls on a paired row, three on every other. Below `sm` the + // field takes the row's whole width, so its label is not cut, and the + // other controls share the line under it. + const span = pairsWith ? "col-span-3 max-sm:col-span-6" : "col-span-4 max-sm:col-span-6" + const fieldSpan = pairsWith ? "col-span-3 max-sm:col-span-12" : "col-span-4 max-sm:col-span-12" const code = pairsWith ? currencyCompanionOf(filterGroup, condition.id)?.value : undefined return (
-
+
{ const next = current.filter((_, i) => i !== idx); writeFields(next); @@ -179,7 +180,7 @@ export function GroupingEditor({ ; +} + +function Where() { + const location = useLocation(); + const navigate = useNavigate(); + return ( + <> + {`${location.pathname}${location.search}`} + + + ); +} + +function mountHistory(entries: Array, phone = true) { + const dataSource = { + find: vi.fn(async () => ({ data: [], total: 0 })), + findOne: vi.fn(async (_object: string, id: string) => ({ id, name: id === 'L-1' ? 'Mira Costa' : 'Theo Park' })), + create: vi.fn(), + update: vi.fn(async () => ({})), + delete: vi.fn(async () => ({})), + }; + const metadata = { + objects: OBJECTS, pages: [], loading: false, error: null, + refresh: async () => {}, invalidate: () => {}, ensureType: async () => [], getItem: async () => null, getItemsByType: () => [], + } as never; + render( + + + + } /> + } /> + {}} />} + /> + + + + , + ); +} + +const where = () => screen.getByTestId('where').textContent; +const backLink = () => document.querySelector('[data-record-page-root] > [data-record-top-bar] [data-record-crumb-back]') as HTMLElement | null; + +describe('the record page a phone list pushes (objectstack#29074)', () => { + beforeEach(() => { + cleanup(); + Object.defineProperty(window, 'innerWidth', { value: 390, configurable: true, writable: true }); + RelatedCountStore._reset(); + vi.stubGlobal('fetch', vi.fn(async () => new Response(JSON.stringify({ data: [] }), { status: 200, headers: { 'content-type': 'application/json' } }))); + const measure = HTMLElement.prototype.getBoundingClientRect; + vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function (this: HTMLElement) { + if (!this.hasAttribute('data-record-page-root')) return measure.call(this); + return { x: 0, y: 0, top: 0, left: 0, right: 390, bottom: 800, width: 390, height: 800, toJSON: () => ({}) } as DOMRect; + }); + }); + afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + Object.defineProperty(window, 'innerWidth', { value: 1440, configurable: true, writable: true }); + }); + + it('「‹ Leads」 goes back to the list entry, and Back then leaves the list for home', async () => { + mountHistory(['/home', `/apps/${APP}/${LEAD}?view=all`]); + fireEvent.click(screen.getByTestId('row')); + expect(where()).toBe(`/apps/${APP}/${LEAD}/record/r-1`); + await waitFor(() => expect(backLink()).toBeTruthy(), { timeout: 5000 }); + expect(backLink()!.tagName).toBe('BUTTON'); + expect(backLink()!.textContent).toBe('Leads'); + fireEvent.click(backLink()!); + await waitFor(() => expect(where()).toBe(`/apps/${APP}/${LEAD}?view=all`)); + fireEvent.click(screen.getByTestId('browser-back')); + expect(where()).toBe('/home'); + }); + + it('control, the push unmarked (a desktop list): 「‹」 is a link forward, and Back then opens the record again', async () => { + mountHistory(['/home', `/apps/${APP}/${LEAD}?view=all`], false); + fireEvent.click(screen.getByTestId('row')); + await waitFor(() => expect(backLink()).toBeTruthy(), { timeout: 5000 }); + expect(backLink()!.tagName).toBe('A'); + expect(backLink()!.textContent).toBe('Leads'); + fireEvent.click(backLink()!); + await waitFor(() => expect(where()).toBe(`/apps/${APP}/${LEAD}?view=all`)); + fireEvent.click(screen.getByTestId('browser-back')); + expect(where()).toBe(`/apps/${APP}/${LEAD}/record/r-1`); + }); + + it('a lead pushed from another lead\'s page still reads that record, not the plural', async () => { + const parent = `/apps/${APP}/${LEAD}/record/L-1`; + mountHistory([parent, { pathname: `/apps/${APP}/${LEAD}/record/r-2`, state: recordPagePushState({ pathname: parent, search: '' }, 'Mira Costa') }]); + await waitFor(() => expect(backLink()).toBeTruthy(), { timeout: 5000 }); + expect(backLink()!.tagName).toBe('BUTTON'); + expect(backLink()!.textContent).toBe('Mira Costa'); + }); +}); diff --git a/ui/packages/app-shell/src/views/objectViewRecordNavigation.ts b/ui/packages/app-shell/src/views/objectViewRecordNavigation.ts index d1f452c2171..1d167238cec 100644 --- a/ui/packages/app-shell/src/views/objectViewRecordNavigation.ts +++ b/ui/packages/app-shell/src/views/objectViewRecordNavigation.ts @@ -31,6 +31,7 @@ import type { ViewNavigationConfig } from '@object-ui/types'; import { RECORD_DRAWER_PARAM } from '../urlParams.js'; import { viewTabLabel } from './viewTabLabel.js'; import { listRecordDetailUrl, listRecordActionsValue } from './objectViewRecordLinks.js'; +import { RECORD_PAGE_PUSHED_STATE } from './recordPagePush.js'; import { expandedRecordPath, recordDrawerReturnState, @@ -238,6 +239,12 @@ export function useObjectViewRecordNavigation({ label: listLabel, ...(set ?? {}), }, + // A phone pushes the page over its list, so the page's 「‹」 + // goes back in the history to that list entry, as a page a + // related row pushes does (objectstack#29074). Not on a + // replace: the drawer link a phone answers takes the list's + // place, so there is no list entry below to go back to. + ...(phone && !replace ? { [RECORD_PAGE_PUSHED_STATE]: true } : {}), }; if (viewId) { navigate(`../../record/${encodeURIComponent(String(recordId))}`, { relative: 'path', state: originState, replace }); @@ -245,7 +252,7 @@ export function useObjectViewRecordNavigation({ navigate(`record/${encodeURIComponent(String(recordId))}`, { state: originState, replace }); } }, - [navigate, viewId, listLabel] + [navigate, viewId, listLabel, phone] ); /** * The list surface's half of the record-link mechanism (objectui#4490): From 6601c74541265b198fe49bee02a89c7bfdcf832d Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 11:03:49 +0000 Subject: [PATCH 6/8] test(ui): the icon-only button census reads the card list's pager as a third named pager Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01QsR2jUwDX3ZR6eBujakKoT --- .../src/__tests__/iconOnlyButtonCensus-12143.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/ui/packages/components/src/__tests__/iconOnlyButtonCensus-12143.test.ts b/ui/packages/components/src/__tests__/iconOnlyButtonCensus-12143.test.ts index 3fe55ab8387..16cad59cad4 100644 --- a/ui/packages/components/src/__tests__/iconOnlyButtonCensus-12143.test.ts +++ b/ui/packages/components/src/__tests__/iconOnlyButtonCensus-12143.test.ts @@ -272,12 +272,14 @@ describe('no shipped icon-only Button goes unnamed unless it is frozen in the ba * Since objectui#12620 `ObjectGrid.tsx` holds a SECOND such pager: a * server-grouped group's own pager, drawn in its band footer (it used to be * the per-group `data-table`'s pager), so that file reads two of each. + * Since objectstack#29074 it holds a THIRD: the phone card list's pager at + * the end of its scroller, named by the same `table.*` strings, so three. */ it('the repaired pagers are read as four named icon-only Buttons each', () => { const PAGER = ['ChevronLeft', 'ChevronRight', 'ChevronsLeft', 'ChevronsRight']; for (const [file, pagers] of [ ['packages/components/src/renderers/complex/data-table.tsx', 1], - ['packages/plugin-grid/src/ObjectGrid.tsx', 2], + ['packages/plugin-grid/src/ObjectGrid.tsx', 3], ] as const) { const chevrons = iconOnly .filter((j) => j.file === file && /^Chevrons?(Left|Right)$/.test(j.icon)) From b56a51fc997ee814626f31bbb03699d593802f00 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 11:34:19 +0000 Subject: [PATCH 7/8] test(ui): await each history step in the phone push test, as the router commits it in a transition Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01QsR2jUwDX3ZR6eBujakKoT --- .../objectViewRecordNavigation.phonePush-29074.test.tsx | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/ui/packages/app-shell/src/views/objectViewRecordNavigation.phonePush-29074.test.tsx b/ui/packages/app-shell/src/views/objectViewRecordNavigation.phonePush-29074.test.tsx index 15d5a1113d1..69fea94a628 100644 --- a/ui/packages/app-shell/src/views/objectViewRecordNavigation.phonePush-29074.test.tsx +++ b/ui/packages/app-shell/src/views/objectViewRecordNavigation.phonePush-29074.test.tsx @@ -221,14 +221,14 @@ describe('the record page a phone list pushes (objectstack#29074)', () => { it('「‹ Leads」 goes back to the list entry, and Back then leaves the list for home', async () => { mountHistory(['/home', `/apps/${APP}/${LEAD}?view=all`]); fireEvent.click(screen.getByTestId('row')); - expect(where()).toBe(`/apps/${APP}/${LEAD}/record/r-1`); + await waitFor(() => expect(where()).toBe(`/apps/${APP}/${LEAD}/record/r-1`)); await waitFor(() => expect(backLink()).toBeTruthy(), { timeout: 5000 }); expect(backLink()!.tagName).toBe('BUTTON'); expect(backLink()!.textContent).toBe('Leads'); fireEvent.click(backLink()!); await waitFor(() => expect(where()).toBe(`/apps/${APP}/${LEAD}?view=all`)); fireEvent.click(screen.getByTestId('browser-back')); - expect(where()).toBe('/home'); + await waitFor(() => expect(where()).toBe('/home')); }); it('control, the push unmarked (a desktop list): 「‹」 is a link forward, and Back then opens the record again', async () => { @@ -239,8 +239,10 @@ describe('the record page a phone list pushes (objectstack#29074)', () => { expect(backLink()!.textContent).toBe('Leads'); fireEvent.click(backLink()!); await waitFor(() => expect(where()).toBe(`/apps/${APP}/${LEAD}?view=all`)); + // MemoryRouter commits a location change in a transition, so the record + // page coming back is awaited, as every other step here is. fireEvent.click(screen.getByTestId('browser-back')); - expect(where()).toBe(`/apps/${APP}/${LEAD}/record/r-1`); + await waitFor(() => expect(where()).toBe(`/apps/${APP}/${LEAD}/record/r-1`)); }); it('a lead pushed from another lead\'s page still reads that record, not the plural', async () => { From cc0e7090a61f5d4cdca21ecb9ab69305b9948bcb Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 12:17:02 +0000 Subject: [PATCH 8/8] test(ui): the unmarked control reads the replacing back link main now draws Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01QsR2jUwDX3ZR6eBujakKoT --- ...tViewRecordNavigation.phonePush-29074.test.tsx | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/ui/packages/app-shell/src/views/objectViewRecordNavigation.phonePush-29074.test.tsx b/ui/packages/app-shell/src/views/objectViewRecordNavigation.phonePush-29074.test.tsx index 69fea94a628..c9762d45516 100644 --- a/ui/packages/app-shell/src/views/objectViewRecordNavigation.phonePush-29074.test.tsx +++ b/ui/packages/app-shell/src/views/objectViewRecordNavigation.phonePush-29074.test.tsx @@ -221,7 +221,7 @@ describe('the record page a phone list pushes (objectstack#29074)', () => { it('「‹ Leads」 goes back to the list entry, and Back then leaves the list for home', async () => { mountHistory(['/home', `/apps/${APP}/${LEAD}?view=all`]); fireEvent.click(screen.getByTestId('row')); - await waitFor(() => expect(where()).toBe(`/apps/${APP}/${LEAD}/record/r-1`)); + await waitFor(() => expect(where()).toBe(`/apps/${APP}/${LEAD}/record/r-1`), { timeout: 5000 }); await waitFor(() => expect(backLink()).toBeTruthy(), { timeout: 5000 }); expect(backLink()!.tagName).toBe('BUTTON'); expect(backLink()!.textContent).toBe('Leads'); @@ -231,18 +231,23 @@ describe('the record page a phone list pushes (objectstack#29074)', () => { await waitFor(() => expect(where()).toBe('/home')); }); - it('control, the push unmarked (a desktop list): 「‹」 is a link forward, and Back then opens the record again', async () => { + it('control, the push unmarked (a desktop list): 「‹」 is a link that replaces the record (#29210), so the list sits twice and Back stays on it', async () => { mountHistory(['/home', `/apps/${APP}/${LEAD}?view=all`], false); fireEvent.click(screen.getByTestId('row')); + await waitFor(() => expect(where()).toBe(`/apps/${APP}/${LEAD}/record/r-1`), { timeout: 5000 }); await waitFor(() => expect(backLink()).toBeTruthy(), { timeout: 5000 }); expect(backLink()!.tagName).toBe('A'); expect(backLink()!.textContent).toBe('Leads'); fireEvent.click(backLink()!); await waitFor(() => expect(where()).toBe(`/apps/${APP}/${LEAD}?view=all`)); - // MemoryRouter commits a location change in a transition, so the record - // page coming back is awaited, as every other step here is. + // The record's entry was replaced by a second copy of the list: Back + // lands on the first copy, and only a second Back reaches home. The + // marked push above reaches home in one. fireEvent.click(screen.getByTestId('browser-back')); - await waitFor(() => expect(where()).toBe(`/apps/${APP}/${LEAD}/record/r-1`)); + await waitFor(() => expect(screen.getByTestId('row')).toBeTruthy()); + expect(where()).toBe(`/apps/${APP}/${LEAD}?view=all`); + fireEvent.click(screen.getByTestId('browser-back')); + await waitFor(() => expect(where()).toBe('/home')); }); it('a lead pushed from another lead\'s page still reads that record, not the plural', async () => {