Skip to content

Commit 39951d2

Browse files
fix(fields, plugin-form): form controls reach a 44 px touch target at phone width (#29209)
domain:ui#4 · zhuangjianguo · session_01QsR2jR7ZFkpqmm4U8S1f9y <!-- claude-projects-attribution: {"github_login":"zhuangjianguo"} --> _Requested by **Steedos** · [project thread](https://claude.ai/code/project/chan_018ETKaNwMZCNRYD7D8Q9Ubb?thread=cmsg_018ETKaNwMZCNRYD7D8Q9UbbNADoD69X24js3pV37rARs6)_ Fixes #29072 Clause-②: no Metadata-protocol: none ## 维护者速读 - **改了什么**:手机宽度(低于 `sm`,640 px)下,新建 / 编辑表单里的输入框、下拉、查找字段、日期选择器、明细行表格和表单底部按钮,触控区域统一达到 44 px;桌面宽度外观不变。 - **为什么改**:移动端 App 用 WebView 承载 Console(#29040),实测 375 / 390 px 下表单控件只有 28–40 px,日历格子和下拉选项 32 px,低于 44 px 触控下限,容易点错。 - **风险与回滚**:只加 Tailwind 类(`min-h-[44px] sm:min-h-0`,与表单内置控件已用的同一个记号),不改逻辑、不改导出、不改元数据;回滚即 revert 本 PR。 - **先看**:`ui/packages/fields/src/widgets/date-picker/DatePickerField.tsx`(右侧按钮变大后输入框右内边距改为 `pr-24 sm:pr-16`、日历格子 44 px);`ui/packages/fields/src/widgets/LookupField.tsx`;`ui/packages/fields/src/widgets/touchTarget.ts`。 - **两分钟验证**:手机尺寸打开 CRM「商机 → 新建」,点开「预计成交日期」日历:每个日期格子约 44 px,一周七天仍完整放在屏幕内;桌面宽度下同一日历仍是 32 px 格子。 - **需要你做的**:无,席位落地。 ## What changes At phone width (below `sm`), the create / edit form's registered field widgets now carry the same touch floor the form renderer's built-in controls already use, `min-h-[44px] sm:min-h-0`, through a package-internal token module `ui/packages/fields/src/widgets/touchTarget.ts` (not re-exported, so no published surface moves). From `sm` up every control keeps its previous height. - **Inputs:** text, email, phone, URL, number, currency and percent inputs; the percent slider's hit area (its root takes the pointer along its whole height). - **Select:** the trigger and every option. - **Lookup:** the trigger, the clear and browse-all buttons, the dropdown's search box, its "show all results" and quick-create buttons; the record picker dialog's search box and Filters button. - **Date picker:** the input, its clear and calendar buttons, the Today / Tomorrow shortcuts, and a 44 px calendar cell below `sm` (`[--cell-size:2.75rem] sm:[--cell-size:2rem]` passed to the `Calendar` primitive, which `cn` merges over its default). Seven 44 px days plus the calendar padding measure 332 px, inside a 343 px form column at 375 px. The input's right padding grows to `pr-24` below `sm` so its text stops short of the two 44 px buttons. - **Line-item grid** (opportunity products): cell inputs, cell selects and their options, the compact lookup cell, the three row buttons, and "Add line". - **Form footers:** the modal, drawer and master-detail forms' Cancel / Reset / Submit buttons. - **File field** (added from #29071's run, on the seat's order): the "Take photo" camera button gets `PHONE_TOUCH_HEIGHT` and the per-file remove button `PHONE_TOUCH_SQUARE` in `FileField.tsx`. Their before sizes, 36 px (`Button` `size="sm"`, `h-9`) and 24×24 (`h-6 w-6`), were read from the `Button` token and the class, not measured on a page; no create form in either example drew a file field on this flow. Fence check: `FormPage.tsx` is not touched (its refusal path stays with #29019), nor `sectionFields.ts` (#29021), `sheet.tsx` (#29078), `dialog.tsx` (#29107), `navigation-overlay.tsx`, `record-list-face.tsx`, `ObjectView.tsx`, `recordOpenPreference.ts`, `ui/packages/mobile/**`, the console's `main` / `App` / `AppContent`, or any shared chrome. No `ui/packages/components/src/ui/**` primitive is edited: every change is a className handed to it. ## Run record Driven in Chromium (Playwright, `isMobile`, `hasTouch`, DPR 2) at **375×812** and **390×844**, against the Console served from source (Vite) over `examples/app-crm` and `examples/app-showcase` started with `objectstack dev --seed-admin --fresh`. Flow per object: list → phone create button → create form → select listbox → date picker (where the object has one) → lookup dropdown → browse-all record picker → keyboard check on the last and a middle text control → clear the required field and submit (validation) → open a seeded record → Edit form. **Before** = merge base `a4015ab4` (the widget sources restored from it), **after** = this branch head. | Check | Example · page | 375×812 before | 375×812 after | 390×844 before | 390×844 after | |:--|:--|:--|:--|:--|:--| | 1 No horizontal page scroll | CRM · lead create + edit | pass | pass | pass | pass | | 1 No horizontal page scroll | CRM · opportunity create + edit | pass | pass | pass | pass | | 1 No horizontal page scroll | Showcase · task create + edit | pass | pass | pass | pass | | 2 Touch targets ≥ 44 px | CRM · lead create + edit | **fail**: 15 controls at 40 px, 6 options at 32, lookup search 32 / quick-create 36, picker search 40 / Filters 36 / Close 16 | **fail**: picker Close 16×16 only (finding, #29107) | **fail**: same as 375 | **fail**: picker Close 16×16 only | | 2 Touch targets ≥ 44 px | CRM · opportunity create + edit | **fail**: controls at 40, date buttons 28, line-item cells 32, Add line 36, slider 8, 5 options at 32, 39 calendar cells at 32, lookup and picker as above | **fail**: picker Close 16×16 only | **fail**: same as 375 | **fail**: picker Close 16×16 only | | 2 Touch targets ≥ 44 px | Showcase · task create + edit | **fail**: controls at 40, date buttons 28, 5 options at 32, 39 calendar cells at 32, lookup and picker as above | **fail**: picker Close 16×16 only | **fail**: same as 375 | **fail**: picker Close 16×16 only | | 3 Dialogs and popovers fit the viewport | all three (form dialog, select listbox, date popover, lookup dropdown, record picker) | pass | pass | pass | pass | | 4 Virtual keyboard leaves the focused field visible | all three (last and middle text control) | pass | pass | pass | pass | | Validation: first error focused and in view | all three | pass | pass | pass | pass | | 5 Long lists responsive at 4× CPU | CRM · opportunity create, Account lookup over 303 accounts, **production bundle** | pass (open 176 ms, typing 64, scroll no long task) | borderline (open 184–256 ms over 3 runs, typing ≤ 88, scroll ≤ 51 ms tasks) | pass (open 192, typing 72) | pass (open 192–256, typing ≤ 88) | <details><summary>How each check was measured</summary> - **Horizontal scroll:** `document.documentElement.scrollWidth` against `innerWidth`, and every element in the open dialog whose box crosses the viewport edge without an `overflow-x` ancestor clipping it (the line-item table and the picker table scroll inside their own containers, which is by design). - **Touch targets:** every visible `button`, `input`, `select`, `textarea`, `[role=combobox]`, `[role=option]`, `[role=checkbox]`, `[role=switch]`, `a[href]` and the percent slider root inside the open dialog, listbox or popover, by bounding box, against 44 px on both axes. - **Fit:** the open dialog / listbox / popover box inside the viewport. - **Keyboard:** the WebView `adjustResize` model: focus the control, shrink the layout viewport by 336 px (a Gboard-height keyboard on an 812 px screen), let the browser scroll the focused editable into view, then assert its box is inside the shortened viewport and `elementFromPoint` at its centre hits it (no sticky footer or header over it). A real device IME was not exercised. - **4× CPU:** CDP `Emulation.setCPUThrottlingRate` 4 on the production Console bundle (`pnpm console:build`, served at `/_console` by `objectstack dev`), with 300 extra accounts seeded through the data API. Pass = every Event Timing entry (click, key) at most 200 ms (the "good" INP line), and no long task over 200 ms while scrolling. The dev-server (unbundled React) numbers the harness also prints are 2–3× slower and are not the reading. - Screenshots for every step, before and after, are in the project folder (not attached here). </details> ## Tests - `ui/packages/fields/src/widgets/touchTarget.formControls-29072.test.tsx` (14 cases): the token shape, each input widget, a host className still reaching the input beside the token, the slider root, the select trigger and options, the lookup trigger / clear / browse-all, the date picker's input, buttons and calendar cell size, and the file field's camera and remove buttons. Reverse-verified: with the widget sources restored from the merge base, 12 of 13 go red (the token-shape case reads only the new module). - `ui/e2e/form-controls-phone-touch.spec.ts` (3 cases, shape of `record-path-phone-row.spec.ts`): reads the tokens, the `Input` / icon-`Button` base heights and the calendar's cell-size classes out of the sources, compiles real Tailwind for them, and measures a fixture: at 375 and 390 px the input, the date-picker button, the browse-all button and all seven calendar days are at least 44 px and the calendar ends inside the form column with no page scroll; at 1024 px the primitives keep 40 / 28 / 40 / 32 px. Red on the merge base (the date picker carries no cell-size override there). ## Acceptance notes - **Finding (dialog close, carrier #29107):** the lookup's browse-all record picker uses the plain `DialogContent`, whose Close button measures 16×16 px at both widths (CRM lead and opportunity Account picker, showcase task Project picker). `dialog.tsx` is #29107's file, so it is left as is here. - **Finding (borderline, 4× CPU):** opening the Account lookup dropdown on the production bundle with 303 accounts takes one 150–215 ms task; the click's Event Timing duration measured 176 and 192 ms before this change and 184–256 ms over three runs after it (two of six samples over 200 ms). This diff adds classes only, so the cost predates it; typing and scrolling stay under 100 ms. - **Observation:** the opportunity form (master-detail) keeps its Cancel / Create bar at the end of the scrolling body, unlike the modal form's sticky footer, so at 375×812 the Create button is not on the first screen. None of the five checks covers it; noted for the seat. - The flow's create and edit surfaces at phone width are the full-screen form dialog for all three objects; no object in either example declares the page or drawer form mode, so `RecordFormPage` was not reached. The drawer form's footer gets the same token for parity. ## Verification At head `99a5359b` (main merged at `dedc3505`; main touched none of these files). The file-field commit was re-verified on its own: the touchTarget test 14 / 14 (its new file case red with `FileField.tsx` from the previous head), the touched test files plus `complex-widgets.test.tsx` 110 files / 851 tests, the `@object-ui/fields` type-check 47 tasks exit 0, ESLint on the file 0 errors, `check-sdui-manifest` 0. The readings below were taken at `6034e7ec`: - `node scripts/pm/dispatch-gates.mjs --ran`: 34 derived, 34 run, all exit 0. `pnpm --dir ui exec vitest run scripts/` was killed once by memory pressure while the run-record servers were up; rerun alone it passes (169 files, 4900 tests). - `node scripts/check-sdui-manifest.mjs`: exit 0, after the merge. - Type-check of `@object-ui/fields` and `@object-ui/plugin-form` (turbo, 49 tasks): exit 0. ESLint on the changed files: 0 errors. - The new unit file (13 at `6034e7ec`, 14 now) and the touched widgets' and form footers' existing test files: 106 files, 768 tests, pass after the merge. The e2e spec: 3 pass. - Diff at `99a5359b`: 20 files, +405 / −38, well under 3000 lines. No exported type of a published package moves. The os-dev report on #29072 carries the same readings. --- _Generated by [Claude Code](https://claude.ai/code/session_01QsR2jUqXiWNJKjMJYThRr1)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 9f120af commit 39951d2

20 files changed

Lines changed: 405 additions & 38 deletions
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
'@object-ui/fields': patch
3+
'@object-ui/plugin-form': patch
4+
---
5+
6+
fix(fields, plugin-form): the create and edit form's controls reach a 44 px touch target at phone width
7+
8+
Clause-②: no
9+
Metadata-protocol: none
10+
11+
Below the `sm` breakpoint (640 px) the field widgets a create or edit form draws now carry the same `min-h-[44px] sm:min-h-0` touch floor the form renderer's built-in controls already did. That covers the text, email, phone, URL, number, currency and percent inputs, the percent slider's hit area, the select trigger and its options, the lookup trigger with its clear, browse-all, search and quick-create controls, the record picker's search and filter buttons, the date picker's input, clear and calendar buttons, a 44 px calendar day, and the line-item grid's cells, row buttons and add-line button, and the file field's take-photo and per-file remove buttons. The modal, drawer and master-detail form footers' buttons do the same. From `sm` up every control keeps its previous height. No prop, export or metadata key changes.
Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
import { test, expect, type Page } from '@playwright/test';
2+
import { compile } from 'tailwindcss';
3+
import { readFileSync } from 'node:fs';
4+
import { fileURLToPath } from 'node:url';
5+
import path from 'node:path';
6+
import { uiRoot } from '../scripts/ui-root.mjs';
7+
8+
/**
9+
* GEOMETRY pin for the form field controls' phone touch floor (objectstack#29072).
10+
*
11+
* At 375×812 and 390×844 the create / edit form drew 40 px inputs, select and
12+
* lookup triggers, 32 px calendar days and 28 px date-picker buttons. The fix
13+
* gives each control `PHONE_TOUCH_HEIGHT` / `PHONE_TOUCH_SQUARE`, and the
14+
* calendar a 44 px cell below `sm`.
15+
*
16+
* ⚠️ A Playwright spec and not a vitest test for the reason
17+
* `record-path-phone-row.spec.ts` gives: jsdom has no layout engine. The
18+
* class-shape half sits beside the widgets in
19+
* `touchTarget.formControls-29072.test.tsx`.
20+
*
21+
* REAL, read out of the source at run time: the two tokens, the `Input` and
22+
* icon-`Button` base heights, and the calendar's cell-size classes (the
23+
* primitive's default and the date picker's override) — plus real Tailwind
24+
* compiled from the installed `tailwindcss` for exactly those candidates.
25+
* FIXTURE: the element tree.
26+
*/
27+
28+
const REPO_ROOT = uiRoot();
29+
const read = (rel: string) => readFileSync(path.join(REPO_ROOT, rel), 'utf8');
30+
31+
function grab(file: string, re: RegExp, mustContain: string[]): string {
32+
const m = read(file).match(re);
33+
if (!m) throw new Error(`${file}: nothing matched ${re}`);
34+
for (const token of mustContain) {
35+
if (!m[1].split(/\s+/).includes(token)) {
36+
throw new Error(`${file}: ${re} matched "${m[1]}", which does not carry "${token}".`);
37+
}
38+
}
39+
return m[1];
40+
}
41+
42+
const TOUCH = 'packages/fields/src/widgets/touchTarget.ts';
43+
const CLS = {
44+
height: grab(TOUCH, /PHONE_TOUCH_HEIGHT = '([^']+)'/, ['min-h-[44px]', 'sm:min-h-0']),
45+
square: grab(TOUCH, /PHONE_TOUCH_SQUARE = '([^']+)'/, ['min-w-[44px]', 'sm:min-w-0']),
46+
input: grab('packages/components/src/ui/input.tsx', /"(flex h-10 w-full[^"]+)"/, ['h-10']),
47+
iconButton: grab('packages/components/src/ui/button.tsx', /icon: "([^"]+)"/, ['h-10']),
48+
calendarRoot: grab('packages/components/src/ui/calendar.tsx', /"(bg-background group\/calendar p-3 [^"]+)"/, ['[--cell-size:2rem]']),
49+
pickerCell: grab(
50+
'packages/fields/src/widgets/date-picker/DatePickerField.tsx',
51+
/className="(\[--cell-size:[^"]+)"/,
52+
['[--cell-size:2.75rem]', 'sm:[--cell-size:2rem]'],
53+
),
54+
};
55+
56+
const mergedCalendarRoot = CLS.calendarRoot
57+
.split(/\s+/)
58+
.filter((t) => !t.startsWith('[--cell-size:'))
59+
.join(' ');
60+
61+
/** A form column at phone width (the dialog body's 16 px gutters) holding the controls. */
62+
const fixture = () =>
63+
'<div style="padding:0 16px">' +
64+
`<input id="input" class="${CLS.input} ${CLS.height}" />` +
65+
`<div style="position:relative;margin-top:8px"><input class="${CLS.input} ${CLS.height}" />` +
66+
`<button id="dateTrigger" class="h-7 w-7 ${CLS.square}" style="position:absolute;right:4px;top:0"></button></div>` +
67+
`<button id="browse" class="${CLS.iconButton} ${CLS.square}"></button>` +
68+
// The calendar: the primitive's root class merged with the date picker's
69+
// override the way `cn` merges them (tailwind-merge keeps the LATER
70+
// `[--cell-size:…]`); seven day cells in one row.
71+
`<div id="calendar" class="${mergedCalendarRoot} ${CLS.pickerCell}" style="display:inline-block">` +
72+
'<div id="week" style="display:flex">' +
73+
Array.from({ length: 7 }, (_, i) => `<button class="size-(--cell-size)">${i + 1}</button>`).join('') +
74+
'</div></div>' +
75+
'</div>';
76+
77+
let css = '';
78+
test.beforeAll(async () => {
79+
const twEntry = fileURLToPath(import.meta.resolve('tailwindcss/index.css'));
80+
const compiler = await compile('@import "tailwindcss";', {
81+
base: path.dirname(twEntry),
82+
loadStylesheet: async (id: string, base: string) => {
83+
const file = id === 'tailwindcss' ? twEntry : path.resolve(base, id);
84+
return { path: file, base: path.dirname(file), content: readFileSync(file, 'utf8') };
85+
},
86+
});
87+
css = compiler.build(
88+
[...Object.values(CLS), 'h-7 w-7 size-(--cell-size)'].join(' ').split(/\s+/).filter(Boolean),
89+
);
90+
if (!css.includes('@media')) throw new Error('Tailwind produced no media queries — `sm:` would be inert.');
91+
});
92+
93+
type Box = { w: number; h: number; right: number };
94+
async function measure(page: Page, width: number) {
95+
await page.setViewportSize({ width, height: 812 });
96+
await page.setContent(`<style>html,body{margin:0}${css}</style>${fixture()}`, { waitUntil: 'load' });
97+
return page.evaluate(() => {
98+
const box = (sel: string) => {
99+
const r = document.querySelector(sel)!.getBoundingClientRect();
100+
return { w: Math.round(r.width * 100) / 100, h: Math.round(r.height * 100) / 100, right: Math.round(r.right) };
101+
};
102+
const days = Array.from(document.querySelectorAll('#week button')).map((b) => {
103+
const r = b.getBoundingClientRect();
104+
return { w: r.width, h: r.height, right: r.right };
105+
});
106+
return {
107+
input: box('#input'),
108+
dateTrigger: box('#dateTrigger'),
109+
browse: box('#browse'),
110+
calendar: box('#calendar'),
111+
days,
112+
docScroll: document.documentElement.scrollWidth,
113+
};
114+
}) as Promise<{ input: Box; dateTrigger: Box; browse: Box; calendar: Box; days: Box[]; docScroll: number }>;
115+
}
116+
117+
test.describe('form field controls at phone width (objectstack#29072)', () => {
118+
for (const width of [375, 390]) {
119+
test(`every control is at least 44 px at ${width}px, and the calendar fits the form`, async ({ page }) => {
120+
const r = await measure(page, width);
121+
expect(r.input.h, 'text input height').toBeGreaterThanOrEqual(44);
122+
expect(r.dateTrigger.h, 'date-picker button height').toBeGreaterThanOrEqual(44);
123+
expect(r.dateTrigger.w, 'date-picker button width').toBeGreaterThanOrEqual(44);
124+
expect(r.browse.h, 'browse-all button height').toBeGreaterThanOrEqual(44);
125+
expect(r.browse.w, 'browse-all button width').toBeGreaterThanOrEqual(44);
126+
for (const d of r.days) {
127+
expect(d.w, 'calendar day width').toBeGreaterThanOrEqual(44);
128+
expect(d.h, 'calendar day height').toBeGreaterThanOrEqual(44);
129+
}
130+
// A week of 44 px days plus the calendar's padding stays inside the form
131+
// column: no horizontal page scroll.
132+
expect(r.calendar.right, 'calendar ends past the form column').toBeLessThanOrEqual(width - 16);
133+
expect(r.docScroll, 'the page scrolls sideways').toBeLessThanOrEqual(width);
134+
});
135+
}
136+
137+
test('from sm up the primitives keep their own heights', async ({ page }) => {
138+
const r = await measure(page, 1024);
139+
expect(r.input.h).toBe(40);
140+
expect(r.dateTrigger.h).toBe(28);
141+
expect(r.browse.h).toBe(40);
142+
for (const d of r.days) expect(d.h).toBe(32);
143+
});
144+
});

‎ui/packages/fields/src/widgets/CurrencyField.tsx‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import React from 'react';
22
import { Input, EmptyValue, cn } from '@object-ui/components';
33
import { FieldWidgetComponentProps } from './types.js';
44
import { toDomProps } from './toDomProps.js';
5+
import { PHONE_TOUCH_HEIGHT } from './touchTarget.js';
56
import { useLocalization, useDisplayLocale } from '@object-ui/i18n';
67
import { resolveFieldCurrency, currencySymbol } from '../currency.js';
78
import { useBadInputRefusal, BadInputMessage, BAD_INPUT_BORDER } from './numberBadInput.js';
@@ -190,7 +191,7 @@ export function CurrencyField({ value, onChange, field, readonly, error, classNa
190191
// (and carries the wrong width for a JPY or KWD field).
191192
placeholder={currencyField?.placeholder}
192193
disabled={readonly || props.disabled}
193-
className={cn(symbol ? 'pl-8' : '', refusal ? BAD_INPUT_BORDER : '', className)}
194+
className={cn(PHONE_TOUCH_HEIGHT, symbol ? 'pl-8' : '', refusal ? BAD_INPUT_BORDER : '', className)}
194195
// Surface the field's declared range (e.g. `min: 0` on a budget) so the
195196
// browser's spinner/keyboard affordances respect it (objectui#2572);
196197
// server-side validation still owns enforcement.

‎ui/packages/fields/src/widgets/EmailField.tsx‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import React from 'react';
2-
import { Input, EmptyValue } from '@object-ui/components';
2+
import { Input, EmptyValue, cn } from '@object-ui/components';
3+
import { PHONE_TOUCH_HEIGHT } from './touchTarget.js';
34
import { FieldWidgetComponentProps } from './types.js';
45
import { toDomProps } from './toDomProps.js';
56

@@ -27,6 +28,7 @@ export function EmailField({ value, onChange, field, readonly, error, ...props }
2728
<Input
2829
{...domProps}
2930
type="email"
31+
className={cn(PHONE_TOUCH_HEIGHT, domProps.className)}
3032
value={value || ''}
3133
onChange={(e) => onChange(e.target.value)}
3234
placeholder={config?.placeholder || 'email@example.com'}

‎ui/packages/fields/src/widgets/FileField.tsx‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
11
import React, { useRef, useState, useCallback } from 'react';
2-
import { Button, EmptyValue } from '@object-ui/components';
2+
import { Button, EmptyValue, cn } from '@object-ui/components';
33
import { useUpload } from '@object-ui/providers';
44
import { useObjectTranslation } from '@object-ui/i18n';
55
import { Upload, X, File as FileIcon, ImageIcon, Camera, Loader2, Lock } from 'lucide-react';
66
import { FieldWidgetComponentProps } from './types.js';
77
import { toDomProps } from './toDomProps.js';
88
import { toHostGroupProps } from './toHostGroupProps.js';
9+
import { PHONE_TOUCH_HEIGHT, PHONE_TOUCH_SQUARE } from './touchTarget.js';
910
import { useUploadingSignal } from './useUploadingSignal.js';
1011
import { useUploadingScopeHold } from './uploadingScope.js';
1112
import { maxSizeError, type TranslateFn } from './file-size-guard.js';
@@ -344,7 +345,7 @@ export function FileField({ value, onChange, field, readonly, onUploadingChange,
344345
type="button"
345346
variant="outline"
346347
size="sm"
347-
className="w-full"
348+
className={cn('w-full', PHONE_TOUCH_HEIGHT)}
348349
onClick={(e) => {
349350
e.stopPropagation();
350351
cameraRef.current?.click();
@@ -430,7 +431,7 @@ export function FileField({ value, onChange, field, readonly, onUploadingChange,
430431
e.stopPropagation();
431432
handleRemove(idx);
432433
}}
433-
className="h-6 w-6 p-0"
434+
className={cn('h-6 w-6 p-0', PHONE_TOUCH_SQUARE)}
434435
aria-label={t('fields.file.remove', { name: file.name })}
435436
>
436437
<X className="size-3" />

‎ui/packages/fields/src/widgets/GridField.tsx‎

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import { FileCell } from './FileField.js';
2525
import { toDateInputValue, toDateTimeInputValue, fromDateTimeInputValue, isImpossibleStoredDay } from './nativeDateValue.js';
2626
import { useFieldTranslation } from './useFieldTranslation.js';
2727
import { toDomProps } from './toDomProps.js';
28+
import { PHONE_TOUCH_HEIGHT, PHONE_TOUCH_SQUARE } from './touchTarget.js';
2829
import { toHostGroupProps } from './toHostGroupProps.js';
2930
import type { InlineGridColumn } from '@objectstack/spec/data';
3031
import { GRID_FIELD_RETIRED_KEYS, type GridFieldMetadata, type GridFieldRetiredKey } from '@object-ui/types';
@@ -1246,15 +1247,15 @@ function GridFieldBody({
12461247
return (
12471248
<Select value={val != null ? String(val) : ''} onValueChange={(v) => setCell(rowIdx, c, v)} disabled={locked}>
12481249
<SelectTrigger
1249-
className="h-8 rounded-none border-0 bg-transparent px-2 shadow-none focus:ring-1 focus:ring-ring/60"
1250+
className={cn('h-8 rounded-none border-0 bg-transparent px-2 shadow-none focus:ring-1 focus:ring-ring/60', PHONE_TOUCH_HEIGHT)}
12501251
aria-label={c.label || c.name}
12511252
aria-invalid={invalid || undefined}
12521253
>
12531254
<SelectValue placeholder="—" />
12541255
</SelectTrigger>
12551256
<SelectContent>
12561257
{(c.options || []).map((o) => (
1257-
<SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>
1258+
<SelectItem key={o.value} value={o.value} className={PHONE_TOUCH_HEIGHT}>{o.label}</SelectItem>
12581259
))}
12591260
</SelectContent>
12601261
</Select>
@@ -1284,6 +1285,7 @@ function GridFieldBody({
12841285
onKeyDown={(e) => onCellKeyDown(e, rowIdx, colIdx)}
12851286
className={cn(
12861287
'h-8 rounded-none border-0 bg-transparent px-2 shadow-none focus-visible:ring-1 focus-visible:ring-ring/60',
1288+
PHONE_TOUCH_HEIGHT,
12871289
adornment && 'pl-6',
12881290
isNumeric(c.type) && 'text-right tabular-nums',
12891291
)}
@@ -1471,7 +1473,7 @@ function GridFieldBody({
14711473
type="button"
14721474
variant="ghost"
14731475
size="icon"
1474-
className="h-8 w-8 text-muted-foreground hover:text-foreground"
1476+
className={cn('h-8 w-8 text-muted-foreground hover:text-foreground', PHONE_TOUCH_SQUARE)}
14751477
aria-label={openRowLabel}
14761478
title={openRowLabel}
14771479
data-testid={`line-items-expand-${rowIdx}`}
@@ -1489,7 +1491,7 @@ function GridFieldBody({
14891491
// discoverable and reachable on touch/coarse-pointer devices,
14901492
// which have no hover. The action column width is reserved
14911493
// regardless, so this adds no layout shift.
1492-
className="h-8 w-8 text-muted-foreground hover:text-foreground"
1494+
className={cn('h-8 w-8 text-muted-foreground hover:text-foreground', PHONE_TOUCH_SQUARE)}
14931495
aria-label={duplicateRowLabel}
14941496
title={duplicateRowLabel}
14951497
data-testid={`line-items-duplicate-${rowIdx}`}
@@ -1505,7 +1507,7 @@ function GridFieldBody({
15051507
variant="ghost"
15061508
size="icon"
15071509
// Always visible — see the duplicate button above.
1508-
className="h-8 w-8 text-muted-foreground hover:text-destructive"
1510+
className={cn('h-8 w-8 text-muted-foreground hover:text-destructive', PHONE_TOUCH_SQUARE)}
15091511
aria-label={removeRowLabel}
15101512
data-testid={`line-items-remove-${rowIdx}`}
15111513
onClick={() => removeRow(rowIdx)}
@@ -1548,6 +1550,7 @@ function GridFieldBody({
15481550
type="button"
15491551
variant="outline"
15501552
size="sm"
1553+
className={PHONE_TOUCH_HEIGHT}
15511554
onClick={isList && onAdd ? onAdd : addRow}
15521555
disabled={maxRows != null && rows.length >= maxRows}
15531556
data-testid="line-items-add"

‎ui/packages/fields/src/widgets/LookupField.tsx‎

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { cn,
1212
import { Search, X, Loader2, AlertCircle, Plus, TableProperties } from 'lucide-react';
1313
import { FieldWidgetComponentProps } from './types.js';
1414
import { toDomProps } from './toDomProps.js';
15+
import { PHONE_TOUCH_HEIGHT, PHONE_TOUCH_SQUARE } from './touchTarget.js';
1516
import type { DataSource, FormField, QueryParams, LookupColumnDef, LookupFieldMetadata } from '@object-ui/types';
1617
import { RecordPickerDialog, lookupFiltersToRecord } from './RecordPickerDialog.js';
1718
import type { RecordPickerFilterColumn } from './RecordPickerDialog.js';
@@ -1604,6 +1605,7 @@ export function LookupField({ value, onChange, field, readonly, error: fieldErro
16041605
// clip instead (objectui#3466). flex-1/min-w-0 keep handling the
16051606
// flex-parent (form row) case.
16061607
'min-w-0 max-w-full flex-1 justify-start text-left font-normal',
1608+
PHONE_TOUCH_HEIGHT,
16071609
compact && 'h-8 rounded-none border-0 bg-transparent px-2 shadow-none focus-visible:ring-1 focus-visible:ring-ring/60',
16081610
)}
16091611
type="button"
@@ -1682,7 +1684,7 @@ export function LookupField({ value, onChange, field, readonly, error: fieldErro
16821684
<Button
16831685
variant="ghost"
16841686
size="icon"
1685-
className="shrink-0 text-muted-foreground hover:text-destructive"
1687+
className={cn('shrink-0 text-muted-foreground hover:text-destructive', PHONE_TOUCH_SQUARE)}
16861688
type="button"
16871689
onClick={() => handleRemove(selectedOptions[0]?.value)}
16881690
aria-label={t('lookup.remove', { label: singleSelectedLabel })}
@@ -1801,7 +1803,7 @@ export function LookupField({ value, onChange, field, readonly, error: fieldErro
18011803
value={searchQuery}
18021804
onChange={(e) => handleSearchChange(e.target.value)}
18031805
onKeyDown={handleSearchKeyDown}
1804-
className="w-full pl-9 h-8 text-sm"
1806+
className={cn('w-full pl-9 h-8 text-sm', PHONE_TOUCH_HEIGHT)}
18051807
role="combobox"
18061808
aria-autocomplete="list"
18071809
aria-controls={listboxId}
@@ -1855,7 +1857,7 @@ export function LookupField({ value, onChange, field, readonly, error: fieldErro
18551857
<Button
18561858
variant="ghost"
18571859
size="sm"
1858-
className="mt-2 gap-1"
1860+
className={cn('mt-2 gap-1', PHONE_TOUCH_HEIGHT)}
18591861
type="button"
18601862
disabled={creating}
18611863
onClick={() => handleCreateNew(searchQuery)}
@@ -1932,7 +1934,7 @@ export function LookupField({ value, onChange, field, readonly, error: fieldErro
19321934
{hasDataSource && totalCount > filteredOptions.length && (
19331935
<button
19341936
type="button"
1935-
className="w-full text-center px-3 py-2 rounded-md text-sm font-medium text-primary hover:bg-accent flex items-center justify-center gap-1.5"
1937+
className={cn('w-full text-center px-3 py-2 rounded-md text-sm font-medium text-primary hover:bg-accent flex items-center justify-center gap-1.5', PHONE_TOUCH_HEIGHT)}
19361938
onClick={() => {
19371939
setIsOpen(false);
19381940
setIsPickerOpen(true);
@@ -1947,7 +1949,7 @@ export function LookupField({ value, onChange, field, readonly, error: fieldErro
19471949
{canCreate && (
19481950
<button
19491951
type="button"
1950-
className="w-full text-left px-3 py-2 rounded-md text-sm hover:bg-accent flex items-center gap-1.5 text-muted-foreground"
1952+
className={cn('w-full text-left px-3 py-2 rounded-md text-sm hover:bg-accent flex items-center gap-1.5 text-muted-foreground', PHONE_TOUCH_HEIGHT)}
19511953
disabled={creating}
19521954
onClick={() => handleCreateNew(searchQuery)}
19531955
>
@@ -1970,7 +1972,7 @@ export function LookupField({ value, onChange, field, readonly, error: fieldErro
19701972
<Button
19711973
variant="outline"
19721974
size="icon"
1973-
className="shrink-0"
1975+
className={cn('shrink-0', PHONE_TOUCH_SQUARE)}
19741976
type="button"
19751977
// Gated exactly like the main trigger (#2215) — pre-fix this button
19761978
// opened the full unscoped table while the dependency was missing.

0 commit comments

Comments
 (0)