Skip to content

Commit 2f380dd

Browse files
fix(components): dropdown-menu rows and tabs reach a 44 px touch target at phone width (#29348)
<!-- claude-projects-attribution: {"github_login":"zhuangjianguo"} --> _Requested by **Steedos** · [project thread](https://claude.ai/code/project/chan_018ETKaNwMZCNRYD7D8Q9Ubb?thread=cmsg_018ETKaNwMZCNRYD7D8Q9Ubb6rhJS7ZbYPhkr8QGvprAUj)_ Fixes #29215 Clause-②: no Metadata-protocol: none ## 维护者速读 - **改了什么**:手机宽度(小于 sm 断点)下,所有下拉菜单的每一行(普通项、勾选项、单选项、子菜单入口)和所有 Tabs 标签页按钮的点击高度至少 44 px,Tabs 外框随之长高;桌面宽度下样式一字未改。 - **为什么改**:移动端 App 用 WebView 承载 Console,记录页「更多」菜单、列表行操作菜单和各处标签页都用这两个共享组件,原来菜单行 32 px、标签页 32 到 38 px,手机上容易点错。 - **风险与回滚**:只加了 `max-sm:` 前缀的样式类,桌面不受影响;手机上带固定高度的标签栏(如 AI 对话页的紧凑切换器)会变高。回滚即 revert 本 PR。 - **先看**:`ui/scripts/shadcn-local-patches.mjs`(新增的两组补丁声明)、`ui/packages/components/src/ui/tabs.tsx`(TabsList 与 TabsTrigger 两行)、`ui/packages/components/src/__tests__/menu-tabs-touch-target-29215.test.tsx`。 - **两分钟验证**:`pnpm --dir ui exec vitest run packages/components/src/__tests__/menu-tabs-touch-target-29215.test.tsx scripts/__tests__/shadcn-local-patches.test.ts`,预期全绿;或在 375 px 宽的 Console 打开任一记录页点「More actions」,每行高 44 px。 - **需要你做的**:无,席位落地。 ## What changes - Below `sm`, every `DropdownMenu` row is at least 44 px tall: `DropdownMenuItem`, `DropdownMenuCheckboxItem`, `DropdownMenuRadioItem` and `DropdownMenuSubTrigger` each gain `max-sm:min-h-11` right after their `py-1.5`. `DropdownMenuSubTrigger` is included because a phone user taps it to open the sub-menu; `DropdownMenuLabel` is not a control and is left alone. - Below `sm`, `TabsTrigger` gains `max-sm:min-h-11`, and `TabsList` gains `max-sm:h-auto` at the end of its class string, so the list drops its fixed `h-10` there and grows with its triggers. - From `sm` up, every class list is upstream's, token for token. No export, prop or `TabsContent` change. - `dropdown-menu.tsx` and `tabs.tsx` are Shadcn-synced (`ui/AGENTS.md` commandment 7), so both edits are declared in `ui/scripts/shadcn-local-patches.mjs` as two new families, `dropdownMenuRowTouchTargetPatches` and `tabsTouchTargetPatches`, built by one small `phoneTouchTargetPatch` helper that inserts the phone class after a named token inside a registry anchor. The shipped files were written by applying those patches, and `pnpm shadcn:update` re-applies them. The checkbox and radio items share one upstream class string, so their anchor declares two occurrences. - Both families are new to `LOCAL_PATCHES`, so the registry provenance test requires vendored fixtures: `ui/scripts/__tests__/fixtures/shadcn-registry/dropdown-menu.registry.txt` and `tabs.registry.txt`, captured verbatim from shadcn-ui/ui `8a7701ec27eb9cb8e0377db769fbe6d744113c52` through `raw.githubusercontent.com`. The same capture reproduces `dialog`'s recorded sha256 byte for byte. `tabs` round-trips byte for byte (`roundTrip: true`). `dropdown-menu` cannot, because it already ships the undeclared Tailwind v4 `origin-(--radix-…)` spelling that `shadcn-components.json` documents, so its entry pins the inequality with that reason, as `calendar` does. - `ui/packages/components/shadcn-components.json`: the `dropdown-menu` `localEdits` note names the new patch beside the Tailwind v4 note, and `tabs` gains a `localEdits` note. - Changeset `.changeset/29215-menu-tabs-phone-touch-target.md`: `@object-ui/components` patch. ## Tests - New `ui/packages/components/src/__tests__/menu-tabs-touch-target-29215.test.tsx` (10 cases). For each of `DropdownMenuItem`, `DropdownMenuCheckboxItem`, `DropdownMenuRadioItem`, `DropdownMenuSubTrigger`, `TabsTrigger` and `TabsList`, the rendered element carries exactly the one phone class, and every other token equals upstream's list in order. A label and `TabsContent` get no phone class. A caller's own `py-2`, or `h-7` and `h-6`, still merge through `cn`, and the phone class survives them. - `ui/scripts/__tests__/shadcn-local-patches.test.ts`: provenance entries for the two fixtures, plus a new describe block. It checks the declared ids. Applied to registry bytes, each family adds only `max-sm:` classes, leaves every class string's other tokens identical in order, and adds exactly 4 `max-sm:min-h-11` for dropdown-menu and 1 `max-sm:min-h-11` plus 1 `max-sm:h-auto` for tabs. Only the four menu rows carry the floor, and `TabsList` still opens with `inline-flex h-10` (the opening `e2e/approvals-inbox-phone.spec.ts` reads it by). The family refuses when upstream re-pads the item (found 0x), and when the checkbox and radio rows stop sharing a class string (found 1x). - `ui/packages/components/src/__tests__/__snapshots__/snapshot-critical.test.tsx.snap`: the two "Tabs snapshots" regenerated with `vitest --update`. The diff is the `TabsList` and `TabsTrigger` class attributes only, gaining `max-sm:h-auto` and `max-sm:min-h-11`. A sweep of every `*.snap`, `*.test.*` and `*.spec.*` file for the upstream item, trigger and list class strings finds no other pin. - Ablation, one-shot, at 43e61db, through `scripts/ablation-replace.mjs` (anchor 1 to 0, restore verified `git diff HEAD` empty): with `max-sm:min-h-11` dropped from `DropdownMenuItem` in the shipped file, 3 cases go red. They are "DropdownMenuItem: at least 44 px below sm, upstream classes from sm up", "a caller's own padding still merges, and the phone floor survives it" and "dropdown-menu.tsx carries its declared patches". ## Acceptance notes - **Token choice.** The card asked for `min-h-[44px] sm:min-h-0`. This PR uses `max-sm:min-h-11` and `max-sm:h-auto` instead, accepted by the seat before the build. It follows the shadcn-patch precedent on main (the Sheet close and Dialog close families are `max-sm:` only). It leaves every desktop token byte-identical to upstream, which the tests assert token for token. `sm:min-h-0` would also change desktop `min-height` from `auto` to `0`, which matters in a `flex-col` tab list (the vertical `tabs` renderer, `ViewSwitcher`'s vertical orientation). The helper #29072 landed (`touchTarget.ts`) is internal to `@object-ui/fields`, so `@object-ui/components` cannot import it either way. - **Fixed-height callers grow on phones.** A `TabsList` or `TabsTrigger` given a fixed height by its caller still gets the phone floor, because `max-sm:` classes do not conflict with unprefixed ones in `tailwind-merge`. The AI chat page's compact assistant switcher (`h-7` list, `h-6` triggers) is therefore 45 px tall below `sm`. That is the card's intent: every tab strip. - **Per-page phone arms now overlap the primitive floor.** `ApprovalsInboxPage` (`tabTouch`), `InboxPopover` (`TAB_TRIGGER_CLASS`) and `AppHeader` (`max-md:[&_[role=menuitem]]:min-h-11`) already lift their own tabs or menu rows below `md`. They still own 640 to 767 px, so nothing is removed here. - **Not covered, same shape, not measured.** `ContextMenu`, `Menubar`, `Select` and `Command` rows have the same `py-1.5` shape upstream. This card names only DropdownMenu and Tabs. ## Verification - Real browser: app-crm backend (`objectstack dev --seed-admin --fresh`) plus the Console Vite dev server on source, Playwright, CRM account record page (`/apps/crm_app/crm_account/record/:id`). Before means main's `dropdown-menu.tsx` and `tabs.tsx`, with Vite restarted with `--force` before the leg; the files were restored from HEAD afterwards and `git status` was clean. - "More actions" menu rows (Refresh, Add to favorites, Share, Delete): 375 x 812, 32 px before, 44 px after. 1280 x 800, 32 px before and after. - The tab row's "+3" overflow menu rows (Contacts, Opportunities, Leads), 375 x 812: 32 px before, 44 px after. - `TabsTrigger` in real Chromium with real Tailwind (the installed `tailwindcss`, compiled for the class strings read out of each `tabs.tsx`): 375 px, trigger 32 to 44 px and list 40 to 52 px. 1280 px, trigger 32 px and list 40 px in both legs. - The record page's own tab row (Overview, Activity, …) is not this primitive. It is `RecordTabRow` in `@object-ui/plugin-detail`, an `h-9` button strip, so it measures 36 px in both legs and both widths. It is reported to the seat as a separate finding and is not changed here. - Local gates at d67694b (main merged after #29204 landed): the 36 families `dispatch-gates --commands` derives for this diff all exit 0, and the `--ran` reconciliation reads 36 derived, 36 run, 0 NOT-MEASURED. They include `node scripts/check-sdui-manifest.mjs` and `pnpm --dir ui exec vitest run scripts/` (169 files, 4921 tests passed). `@object-ui/components` type-check (with `tsconfig.test.json`), `pnpm --dir ui run type-check:scripts`, eslint on the five touched source and test files and `pnpm check:new-line-citations` (0 new) all exit 0, measured at 43e61db. - At c25c35b (main merged again, no incoming commit touches these 10 paths): the four touch-target component tests, `snapshot-critical`, the patch registry test and main's new `phoneCardRowActions-29150` re-run green (6 files, 111 tests), and `check-sdui-manifest` exits 0. The whole components package is left to CI Test UI. ## Human Minimum - [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic. - [x] 2. Every name I added uses the casing for its kind. - [x] 3. No formatter ran; `pnpm lint` passes. - [x] 4. One issue, one branch off an up-to-date `main`, one pull request. - [x] 5. A change that publishes carries a changeset, and both declaration lines are filled in here and in the changeset. - [x] 6. `packages/spec` touched: its generated artifacts are regenerated and committed. - [x] 7. No hand edits to `content/docs/references/`, `packages/*/CHANGELOG.md` or `content/docs/releases/`. - [x] 8. Title, body and comments are in English. - [x] The *Before Submitting* list in CONTRIBUTING.md is done. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01QsR2jSMMkBJxrdeZwTifs8 --- _Generated by [Claude Code](https://claude.ai/code/session_01QsR2jSMMkBJxrdeZwTifs8)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 3ff32c4 commit 2f380dd

10 files changed

Lines changed: 719 additions & 15 deletions

File tree

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
---
2+
'@object-ui/components': patch
3+
---
4+
5+
fix(components): dropdown-menu rows and tabs are 44 px touch targets at phone width
6+
7+
Clause-②: no
8+
Metadata-protocol: none
9+
10+
Below the `sm` breakpoint every `DropdownMenu` row (`DropdownMenuItem`, `DropdownMenuCheckboxItem`, `DropdownMenuRadioItem` and `DropdownMenuSubTrigger`) was 32 px tall and every `TabsTrigger` 38 px, under the 44 px touch target the phone-width Console is held to. Below `sm` each of them is now at least 44 px tall (`max-sm:min-h-11`), and `TabsList` drops its fixed `h-10` there (`max-sm:h-auto`) so it grows with its triggers. From `sm` up their classes are unchanged, so desktop menus and tab strips look and measure as before. Every record "more" menu, list row-action menu and tab strip built on these primitives gets it. `dropdown-menu.tsx` and `tabs.tsx` are Shadcn-synced files, so the change is declared in `ui/scripts/shadcn-local-patches.mjs` and re-applied on every `pnpm shadcn:update`. No export or prop changes.

‎ui/packages/components/shadcn-components.json‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -146,7 +146,7 @@
146146
"radix-ui"
147147
],
148148
"registryDependencies": [],
149-
"localEdits": "Tailwind v4 syntax. Local uses `origin-(--radix-dropdown-menu-content-transform-origin)`; upstream still ships v3's `origin-[--radix-…]`, which on Tailwind 4.x compiles to the bare `transform-origin: --radix-…` rather than `var(--radix-…)` — invalid CSS that browsers drop, so the open/close animation would scale from the default origin instead of the one Radix computes. Verified against tailwindcss 4.3.3: the paren form emits `var()`, the bracket form does not. Applied repo-wide in 925051db6; do not revert piecemeal."
149+
"localEdits": "Tailwind v4 syntax. Local uses `origin-(--radix-dropdown-menu-content-transform-origin)`; upstream still ships v3's `origin-[--radix-…]`, which on Tailwind 4.x compiles to the bare `transform-origin: --radix-…` rather than `var(--radix-…)` — invalid CSS that browsers drop, so the open/close animation would scale from the default origin instead of the one Radix computes. Verified against tailwindcss 4.3.3: the paren form emits `var()`, the bracket form does not. Applied repo-wide in 925051db6; do not revert piecemeal. It also carries the objectstack#29215 phone touch-target patch: below `sm` every menu row (item, checkbox item, radio item, sub-menu trigger) is at least 44 px tall (`max-sm:min-h-11`), and from `sm` up its classes are upstream's. That edit is DECLARED as data in `scripts/shadcn-local-patches.mjs` and re-applied on every `--update`, and `pnpm shadcn:check` exits non-zero if it is ever missing from the file or can no longer be re-applied to upstream."
150150
},
151151
"form": {
152152
"source": "https://ui.shadcn.com/r/styles/default/form.json",
@@ -316,7 +316,8 @@
316316
"dependencies": [
317317
"radix-ui"
318318
],
319-
"registryDependencies": []
319+
"registryDependencies": [],
320+
"localEdits": "objectstack#29215 phone touch-target patch: below `sm` `TabsTrigger` is at least 44 px tall (`max-sm:min-h-11`) and `TabsList` drops its fixed `h-10` (`max-sm:h-auto`) so it grows with its triggers; from `sm` up both class lists are upstream's. The edit is DECLARED as data in `scripts/shadcn-local-patches.mjs` and re-applied on every `--update`, and `pnpm shadcn:check` exits non-zero if it is ever missing from the file or can no longer be re-applied to upstream."
320321
},
321322
"textarea": {
322323
"source": "https://ui.shadcn.com/r/styles/default/textarea.json",

‎ui/packages/components/src/__tests__/__snapshots__/snapshot-critical.test.tsx.snap‎

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -647,7 +647,7 @@ exports[`Tabs snapshots > renders tabs with multiple panels 1`] = `
647647
>
648648
<div
649649
aria-orientation="horizontal"
650-
class="inline-flex h-10 items-center justify-center rounded-md bg-muted p-1 text-muted-foreground"
650+
class="inline-flex h-10 items-center justify-center rounded-md bg-muted p-1 text-muted-foreground max-sm:h-auto"
651651
data-orientation="horizontal"
652652
role="tablist"
653653
style="outline-color: none; outline-style: none; outline-width: initial;"
@@ -656,7 +656,7 @@ exports[`Tabs snapshots > renders tabs with multiple panels 1`] = `
656656
<button
657657
aria-controls="radix-_r_3_-content-account"
658658
aria-selected="true"
659-
class="inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm"
659+
class="inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 max-sm:min-h-11 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm"
660660
data-orientation="horizontal"
661661
data-radix-collection-item=""
662662
data-state="active"
@@ -670,7 +670,7 @@ exports[`Tabs snapshots > renders tabs with multiple panels 1`] = `
670670
<button
671671
aria-controls="radix-_r_3_-content-password"
672672
aria-selected="false"
673-
class="inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm"
673+
class="inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 max-sm:min-h-11 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm"
674674
data-orientation="horizontal"
675675
data-radix-collection-item=""
676676
data-state="inactive"
@@ -684,7 +684,7 @@ exports[`Tabs snapshots > renders tabs with multiple panels 1`] = `
684684
<button
685685
aria-controls="radix-_r_3_-content-settings"
686686
aria-selected="false"
687-
class="inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm"
687+
class="inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 max-sm:min-h-11 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm"
688688
data-orientation="horizontal"
689689
data-radix-collection-item=""
690690
data-state="inactive"
@@ -738,7 +738,7 @@ exports[`Tabs snapshots > renders tabs with second tab active 1`] = `
738738
>
739739
<div
740740
aria-orientation="horizontal"
741-
class="inline-flex h-10 items-center justify-center rounded-md bg-muted p-1 text-muted-foreground"
741+
class="inline-flex h-10 items-center justify-center rounded-md bg-muted p-1 text-muted-foreground max-sm:h-auto"
742742
data-orientation="horizontal"
743743
role="tablist"
744744
style="outline-color: none; outline-style: none; outline-width: initial;"
@@ -747,7 +747,7 @@ exports[`Tabs snapshots > renders tabs with second tab active 1`] = `
747747
<button
748748
aria-controls="radix-_r_7_-content-account"
749749
aria-selected="false"
750-
class="inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm"
750+
class="inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 max-sm:min-h-11 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm"
751751
data-orientation="horizontal"
752752
data-radix-collection-item=""
753753
data-state="inactive"
@@ -761,7 +761,7 @@ exports[`Tabs snapshots > renders tabs with second tab active 1`] = `
761761
<button
762762
aria-controls="radix-_r_7_-content-password"
763763
aria-selected="true"
764-
class="inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm"
764+
class="inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 max-sm:min-h-11 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm"
765765
data-orientation="horizontal"
766766
data-radix-collection-item=""
767767
data-state="active"
Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
1+
/**
2+
* ObjectUI
3+
* Copyright (c) 2024-present ObjectStack Inc.
4+
*
5+
* This source code is licensed under the MIT license found in the
6+
* LICENSE file in the root directory of this source tree.
7+
*/
8+
9+
/**
10+
* objectstack#29215 — dropdown-menu rows and tabs are 44 px touch targets at
11+
* phone width.
12+
*
13+
* Every overflow menu, row-action menu and tab strip the Console shows a phone
14+
* user is one of these shared primitives, so the floor lives in them. The test
15+
* environment has no layout engine, so the target is pinned as drawn: the
16+
* phone class below `sm`, and from `sm` up the class list the desktop had
17+
* before, token for token. `src/ui/dropdown-menu.tsx` and `src/ui/tabs.tsx` are
18+
* regenerated from the registry; the classes are declared patches in
19+
* `scripts/shadcn-local-patches.mjs`, whose own test proves the anchors.
20+
*
21+
* ⚠️ `open` on `DropdownMenu` is load-bearing: Radix mounts the content lazily,
22+
* so without it there is no row to read.
23+
*/
24+
25+
import { describe, it, expect, afterEach } from 'vitest';
26+
import { render, screen, cleanup } from '@testing-library/react';
27+
import {
28+
DropdownMenu,
29+
DropdownMenuTrigger,
30+
DropdownMenuContent,
31+
DropdownMenuItem,
32+
DropdownMenuCheckboxItem,
33+
DropdownMenuRadioGroup,
34+
DropdownMenuRadioItem,
35+
DropdownMenuSub,
36+
DropdownMenuSubTrigger,
37+
DropdownMenuSubContent,
38+
DropdownMenuLabel,
39+
} from '../ui/dropdown-menu';
40+
import { Tabs, TabsList, TabsTrigger, TabsContent } from '../ui/tabs';
41+
42+
afterEach(() => cleanup());
43+
44+
const tokens = (el: Element) => (el.getAttribute('class') ?? '').split(/\s+/).filter(Boolean);
45+
46+
/** Upstream's class lists, unchanged from `sm` up. */
47+
const DESKTOP = {
48+
item:
49+
'relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',
50+
choice:
51+
'relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
52+
subTrigger:
53+
'flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',
54+
tabsList: 'inline-flex h-10 items-center justify-center rounded-md bg-muted p-1 text-muted-foreground',
55+
tabsTrigger:
56+
'inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm',
57+
};
58+
59+
/** The element carries exactly `phone` below `sm`, and `desktop` everywhere else. */
60+
function expectPhoneOnly(el: Element, phone: string[], desktop: string) {
61+
const cls = tokens(el);
62+
expect(cls.filter((c) => c.startsWith('max-sm:'))).toEqual(phone);
63+
expect(cls.filter((c) => !c.startsWith('max-sm:'))).toEqual(desktop.split(' '));
64+
}
65+
66+
describe('#29215 — dropdown-menu rows on a phone', () => {
67+
function renderMenu() {
68+
render(
69+
<DropdownMenu open>
70+
<DropdownMenuTrigger>More</DropdownMenuTrigger>
71+
<DropdownMenuContent>
72+
<DropdownMenuLabel>Record</DropdownMenuLabel>
73+
<DropdownMenuItem>Edit</DropdownMenuItem>
74+
<DropdownMenuCheckboxItem checked>Follow</DropdownMenuCheckboxItem>
75+
<DropdownMenuRadioGroup value="a">
76+
<DropdownMenuRadioItem value="a">Compact</DropdownMenuRadioItem>
77+
</DropdownMenuRadioGroup>
78+
<DropdownMenuSub>
79+
<DropdownMenuSubTrigger>Share</DropdownMenuSubTrigger>
80+
<DropdownMenuSubContent>
81+
<DropdownMenuItem>Copy link</DropdownMenuItem>
82+
</DropdownMenuSubContent>
83+
</DropdownMenuSub>
84+
</DropdownMenuContent>
85+
</DropdownMenu>,
86+
);
87+
}
88+
89+
it('DropdownMenuItem: at least 44 px below sm, upstream classes from sm up', () => {
90+
renderMenu();
91+
expectPhoneOnly(screen.getByRole('menuitem', { name: 'Edit' }), ['max-sm:min-h-11'], DESKTOP.item);
92+
});
93+
94+
it('DropdownMenuCheckboxItem: at least 44 px below sm, upstream classes from sm up', () => {
95+
renderMenu();
96+
expectPhoneOnly(screen.getByRole('menuitemcheckbox', { name: 'Follow' }), ['max-sm:min-h-11'], DESKTOP.choice);
97+
});
98+
99+
it('DropdownMenuRadioItem: at least 44 px below sm, upstream classes from sm up', () => {
100+
renderMenu();
101+
expectPhoneOnly(screen.getByRole('menuitemradio', { name: 'Compact' }), ['max-sm:min-h-11'], DESKTOP.choice);
102+
});
103+
104+
it('DropdownMenuSubTrigger: at least 44 px below sm, upstream classes from sm up', () => {
105+
renderMenu();
106+
expectPhoneOnly(screen.getByRole('menuitem', { name: 'Share' }), ['max-sm:min-h-11'], DESKTOP.subTrigger);
107+
});
108+
109+
it('a label is not a control and keeps its height', () => {
110+
renderMenu();
111+
expect(tokens(screen.getByText('Record')).some((c) => c.startsWith('max-sm:'))).toBe(false);
112+
});
113+
114+
it("a caller's own padding still merges, and the phone floor survives it", () => {
115+
render(
116+
<DropdownMenu open>
117+
<DropdownMenuTrigger>Apps</DropdownMenuTrigger>
118+
<DropdownMenuContent>
119+
<DropdownMenuItem className="py-2">Home</DropdownMenuItem>
120+
</DropdownMenuContent>
121+
</DropdownMenu>,
122+
);
123+
const cls = tokens(screen.getByRole('menuitem', { name: 'Home' }));
124+
expect(cls).toContain('py-2');
125+
expect(cls).not.toContain('py-1.5');
126+
expect(cls).toContain('max-sm:min-h-11');
127+
});
128+
});
129+
130+
describe('#29215 — tabs on a phone', () => {
131+
function renderTabs(listClass?: string, triggerClass?: string) {
132+
render(
133+
<Tabs defaultValue="details">
134+
<TabsList className={listClass}>
135+
<TabsTrigger value="details" className={triggerClass}>Details</TabsTrigger>
136+
<TabsTrigger value="related" className={triggerClass}>Related</TabsTrigger>
137+
</TabsList>
138+
<TabsContent value="details">Fields</TabsContent>
139+
</Tabs>,
140+
);
141+
}
142+
143+
it('TabsTrigger: at least 44 px below sm, upstream classes from sm up', () => {
144+
renderTabs();
145+
for (const name of ['Details', 'Related']) {
146+
expectPhoneOnly(screen.getByRole('tab', { name }), ['max-sm:min-h-11'], DESKTOP.tabsTrigger);
147+
}
148+
});
149+
150+
it('TabsList: grows with its triggers below sm, upstream h-10 from sm up', () => {
151+
renderTabs();
152+
expectPhoneOnly(screen.getByRole('tablist'), ['max-sm:h-auto'], DESKTOP.tabsList);
153+
});
154+
155+
it("a caller's fixed height still wins on desktop, and the phone classes survive it", () => {
156+
// The AI chat header's compact switcher passes `h-7` / `h-6`.
157+
renderTabs('h-7 gap-0.5 p-0.5', 'h-6 px-2.5 text-xs');
158+
const list = tokens(screen.getByRole('tablist'));
159+
expect(list).toContain('h-7');
160+
expect(list).not.toContain('h-10');
161+
expect(list).toContain('max-sm:h-auto');
162+
const trigger = tokens(screen.getByRole('tab', { name: 'Details' }));
163+
expect(trigger).toContain('h-6');
164+
expect(trigger).toContain('max-sm:min-h-11');
165+
});
166+
167+
it('TabsContent is not a control and gets no phone class', () => {
168+
renderTabs();
169+
expect(tokens(screen.getByRole('tabpanel')).some((c) => c.startsWith('max-sm:'))).toBe(false);
170+
});
171+
});

‎ui/packages/components/src/ui/dropdown-menu.tsx‎

Lines changed: 4 additions & 4 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)