From 4f199290df1f1a00478109a3631dc695d88a2406 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B8=85=E5=87=A4?= Date: Tue, 8 Sep 2026 01:26:29 +0800 Subject: [PATCH 1/3] =?UTF-8?q?feat(ui):=20=E4=BF=9D=E5=AD=98=E8=8F=9C?= =?UTF-8?q?=E5=8D=95=E3=80=81=E6=BB=9A=E5=8A=A8=E5=AE=B9=E5=99=A8=E4=B8=8E?= =?UTF-8?q?=E6=8E=A7=E4=BB=B6=E8=A7=86=E8=A7=89=E8=A7=84=E8=8C=83=E6=A3=80?= =?UTF-8?q?=E6=9F=A5=E7=82=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AGENTS.md | 27 +++ package.json | 2 +- showcase/AGENTS.md | 32 +++ .../pages/examples/SettingsPageExample.vue | 221 ++++++++++++++++++ showcase/registry.ts | 13 ++ src/components/accordion/AccordionTrigger.vue | 2 +- .../collapsible/CollapsibleTrigger.vue | 3 + src/components/command/CommandGroup.vue | 4 +- .../context-menu/ContextMenuCheckboxItem.vue | 4 +- .../context-menu/ContextMenuContent.vue | 12 +- .../context-menu/ContextMenuRadioItem.vue | 4 +- .../context-menu/ContextMenuSubContent.vue | 47 ++-- .../date-range-picker/DateRangePicker.vue | 2 +- src/components/dialog/DialogBody.vue | 79 +------ .../DropdownMenuCheckboxItem.vue | 4 +- .../dropdown-menu/DropdownMenuContent.vue | 14 +- .../dropdown-menu/DropdownMenuRadioItem.vue | 4 +- .../dropdown-menu/DropdownMenuSubContent.vue | 30 ++- src/components/input/Input.vue | 8 +- .../menu-scroll-area/MenuScrollArea.vue | 52 +++++ src/components/menu-scroll-area/index.ts | 1 + src/components/native-select/NativeSelect.vue | 2 +- src/components/persona-tile/index.vue | 2 +- src/components/popover/PopoverContent.vue | 23 +- .../range-calendar/RangeCalendar.vue | 4 +- src/components/select/SelectContent.vue | 97 +++++--- src/components/select/SelectItem.vue | 7 +- src/components/settings/BackendCard.vue | 2 +- src/components/settings/DetailPane.vue | 31 +-- .../settings/ExpandableSettingsRow.vue | 2 +- src/components/settings/ModelListRow.vue | 2 +- src/components/settings/SettingsSection.vue | 7 +- .../tags-input/TagsInputItemDelete.vue | 2 +- src/index.ts | 3 + src/lib/menu.ts | 40 ++-- src/lib/trigger.ts | 7 +- src/lib/useScrollFade.ts | 36 +++ src/lib/useSubmenuAlignment.ts | 35 +++ src/style.css | 159 ++++++++++++- 39 files changed, 790 insertions(+), 236 deletions(-) create mode 100644 showcase/pages/examples/SettingsPageExample.vue create mode 100644 src/components/menu-scroll-area/MenuScrollArea.vue create mode 100644 src/components/menu-scroll-area/index.ts create mode 100644 src/lib/useScrollFade.ts create mode 100644 src/lib/useSubmenuAlignment.ts diff --git a/AGENTS.md b/AGENTS.md index 615f10f..78b2311 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -633,6 +633,23 @@ grows (clipped controls, cramped rows, same-row controls that stop matching heig - Every interactive control sets `cursor-pointer` (Button, Switch, segmented item, …). Disabled flips to `cursor-not-allowed`. +- **It must be set explicitly — there is no safety net.** Tailwind v4's + preflight dropped the v3-era `button, [role=button] { cursor: pointer }` + rule, so every clickable element falls back to the UA default (the plain + arrow) unless the component sets the class itself. When adding a new + interactive component, `cursor-pointer` is part of the base class, not an + afterthought — a shared trigger class (e.g. `lib/trigger.ts` + `selectTriggerClass`) carries it once for all consumers. +- **Deliberate non-pointer exceptions** (do not "fix" these): + - **Menu rows and menu labels** (`lib/menu.ts`) use `cursor-default` — the + macOS menu convention: an open menu is a transient command surface the + pointer glides over, not a page of buttons. + - **Select scroll up/down buttons** — part of the menu surface, same + convention. + - **Text-field addons** (`InputGroup` inline text/icons) use `cursor-text` — + they belong to the field's text zone. + - **Resize rails** (`SidebarRail`) use the resize cursor; **the ScrollArea + thumb** keeps its default (drag affordance is its own vocabulary). ## Dirty patterns — anti-examples (do NOT copy) @@ -842,3 +859,13 @@ When you lock a new cross-cutting decision (a color role, a duration, an icon rule, a shape law): (1) add/identify the token in `style.css`, (2) document it here, (3) add a guard check in `scripts/check-ui-contract.mjs` if it is mechanically detectable. A decision that is not written here will be re-invented. + +## Nested menu alignment + +DropdownMenuSubContent 与 ContextMenuSubContent 默认将子菜单首个条目的垂直中心对齐父触发条目的中心,而非对齐两个矩形的顶边。共享 useSubmenuAlignment 测量条目、边框与内框,适应字体缩放和异步内容;调用层不添加补偿偏移。显式 alignOffset 仍可覆盖默认规则。碰撞处理继续由 Reka 负责:空间不足时允许翻转或移动以保持可操作,不以绝对对齐强迫内容溢出。子菜单复用锚定菜单的进入/退出动画与变换原点。 + +## Menu leading icons + +菜单首图标的外框尺寸与间距由菜单组件统一管理,调用方直接传入图标,不添加 `mr-*`、`ml-*`、尺寸或偏移修正。共享样式兼容旧调用的 margin/size 类;SVG 内部几何仍由图标层负责。DropdownMenu 和 ContextMenu 的菜单项遵循同一规则;根 ContextMenu 在光标锚点使用共享淡入缩放与退出动画,不使用方向滑动。 + +普通操作菜单与 Select 共用 `--menu-min-width`(10rem)作为舒展的基础宽度,窄屏允许收缩。Select 仍不得窄于触发器;更长内容可扩展。菜单比例通过宽度和内容组织调整,不通过压低共享行高补偿。业务确需不同宽度时使用已有 class 覆盖,不为短文案逐页设置最小宽度。 diff --git a/package.json b/package.json index a307742..74bc7aa 100644 --- a/package.json +++ b/package.json @@ -32,6 +32,7 @@ "reka-ui": "^2.10.1", "tailwind-merge": "^3.4.0", "tailwindcss": "^4.3.3", + "tw-animate-css": "^1.4.0", "vee-validate": "^4.15.1", "vue-input-otp": "^0.3.2", "zod": "3.25.76" @@ -50,7 +51,6 @@ "misans-vf": "1.0.0", "npm-run-all2": "^8.0.4", "tsx": "^4.21.0", - "tw-animate-css": "^1.4.0", "typescript": "~6.0.3", "unplugin-dts": "1.0.0-beta.6", "vite": "^8.1.5", diff --git a/showcase/AGENTS.md b/showcase/AGENTS.md index b4fd68d..297dc55 100644 --- a/showcase/AGENTS.md +++ b/showcase/AGENTS.md @@ -103,6 +103,38 @@ spec has `usage`). - **A spec opts into the matrix** by declaring `matrix: { rows, cols }` with control keys — only axes a reviewer actually scans (Button: variant × size). +## Examples pages — the example IS the page + +`pages/examples/` holds whole-page specimens of classic product surfaces +(a settings page, …) — the surfaces every product has, where library-wide +passes (cursor, motion, width) are judged on a real interface instead of +single-component playgrounds. They are a third page kind beside component +pages and foundation pages, with their own law: + +- **No doc frame.** The example fills the content area directly — its own + `PageShell` IS the page. Never wrap it in a documentation spine, a + `STAGE_FRAME_CLASS` frame, or a `CanvasStage`: the point is to feel the + components in a real surface, and a stage would turn them back into + boxed specimens. +- **Owners only, stricter than anywhere else.** An example page is the + strongest copy-precedent surface in the repo — compose it exclusively + from owner vocabulary (`PageShell` / `SettingsSection` / `SettingsRow` / + the controls themselves). If a shape the page needs has no owner, that + is a vocabulary gap to legislate, not a class to hand-write. +- **No width pinning, no state seeding beyond defaults.** Controls render + at their library defaults (`Select` at `w-fit`, `Input` at `w-full`, …) + so the page exposes true default behavior in real rows. If a row looks + wrong, the fix belongs in the library, not in a class on the page. +- **Overlays uncontrolled, state local** (per the overlay invariants + below): each `Select`/`Dialog` owns its open state; page state is plain + refs. No fake backends, no demo data plumbing. +- **Bilingual, generic copy.** All strings go through `tt()` and read as + "any product's" copy — an example names no concrete product and teaches + no Memoh-specific business wording. + +Register each page in `registry.ts` under the `examples` nav group via the +`example()` helper (id prefix `examples/`). + ## Overlay invariants (dead-locked three times — do not re-try) - **Overlay specs render UNCONTROLLED — `interactive: true`, NO `open` diff --git a/showcase/pages/examples/SettingsPageExample.vue b/showcase/pages/examples/SettingsPageExample.vue new file mode 100644 index 0000000..a2a3b42 --- /dev/null +++ b/showcase/pages/examples/SettingsPageExample.vue @@ -0,0 +1,221 @@ + + + diff --git a/showcase/registry.ts b/showcase/registry.ts index cbefd8e..681951f 100644 --- a/showcase/registry.ts +++ b/showcase/registry.ts @@ -10,6 +10,7 @@ import RadiusPage from './pages/foundations/RadiusPage.vue' import SpacingPage from './pages/foundations/SpacingPage.vue' import TypographyPage from './pages/foundations/TypographyPage.vue' import OverviewPage from './pages/OverviewPage.vue' +import SettingsPageExample from './pages/examples/SettingsPageExample.vue' // Single manifest: drives the sidebar groups, the hash routes, AND prev/next // order — one list, three consumers, so they can never drift. @@ -31,6 +32,10 @@ function foundation(id: string, title: string, titleZh: string, component: Compo return { kind: 'static', id: `foundations/${id}`, title, titleZh, component } } +function example(id: string, title: string, titleZh: string, component: Component): PageEntry { + return { kind: 'static', id: `examples/${id}`, title, titleZh, component } +} + export const navGroups: NavGroup[] = [ { id: 'foundations', @@ -59,6 +64,14 @@ export const navGroups: NavGroup[] = [ spec, })), }, + { + id: 'examples', + label: 'Examples', + labelZh: '示例', + pages: [ + example('settings', 'Settings', '设置', SettingsPageExample), + ], + }, ] export const flatPages: PageEntry[] = navGroups.flatMap(g => g.pages) diff --git a/src/components/accordion/AccordionTrigger.vue b/src/components/accordion/AccordionTrigger.vue index b73f144..981e8c7 100644 --- a/src/components/accordion/AccordionTrigger.vue +++ b/src/components/accordion/AccordionTrigger.vue @@ -18,7 +18,7 @@ const forwarded = useForwardProps(delegatedProps) data-slot="accordion-trigger" v-bind="forwarded" :class="cn( - 'flex flex-1 items-center justify-between gap-2 py-3.5 text-label font-medium outline-none transition-colors hover:underline disabled:pointer-events-none disabled:opacity-40 [&[data-state=open]>svg]:rotate-180', + 'flex flex-1 cursor-pointer items-center justify-between gap-2 py-3.5 text-label font-medium outline-none transition-colors hover:underline disabled:pointer-events-none disabled:opacity-40 [&[data-state=open]>svg]:rotate-180', props.class, )" > diff --git a/src/components/collapsible/CollapsibleTrigger.vue b/src/components/collapsible/CollapsibleTrigger.vue index ca6b60c..8c19e73 100644 --- a/src/components/collapsible/CollapsibleTrigger.vue +++ b/src/components/collapsible/CollapsibleTrigger.vue @@ -6,8 +6,11 @@ const props = defineProps() diff --git a/src/components/context-menu/ContextMenuRadioItem.vue b/src/components/context-menu/ContextMenuRadioItem.vue index fb8a66c..ef0ec9b 100644 --- a/src/components/context-menu/ContextMenuRadioItem.vue +++ b/src/components/context-menu/ContextMenuRadioItem.vue @@ -23,9 +23,9 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits) - + diff --git a/src/components/context-menu/ContextMenuSubContent.vue b/src/components/context-menu/ContextMenuSubContent.vue index fdfe974..55ddd41 100644 --- a/src/components/context-menu/ContextMenuSubContent.vue +++ b/src/components/context-menu/ContextMenuSubContent.vue @@ -1,12 +1,15 @@ diff --git a/src/components/date-range-picker/DateRangePicker.vue b/src/components/date-range-picker/DateRangePicker.vue index 1410045..a6f2b3a 100644 --- a/src/components/date-range-picker/DateRangePicker.vue +++ b/src/components/date-range-picker/DateRangePicker.vue @@ -102,7 +102,7 @@ function applyPreset(preset: DateRangePreset) { v-for="preset in presets" :key="preset.label" type="button" - class="rounded-menu px-2.5 py-1.5 text-left text-control transition-colors hover:bg-[color:var(--ui-hover)] data-[active=true]:bg-[color:var(--ui-selected)] data-[active=true]:font-medium" + class="cursor-pointer rounded-menu px-2.5 py-1.5 text-left text-control transition-colors hover:bg-[color:var(--ui-hover)] data-[active=true]:bg-[color:var(--ui-selected)] data-[active=true]:font-medium" :data-active="activePreset?.label === preset.label ? 'true' : undefined" @click="applyPreset(preset)" > diff --git a/src/components/dialog/DialogBody.vue b/src/components/dialog/DialogBody.vue index 8e2f307..31a204c 100644 --- a/src/components/dialog/DialogBody.vue +++ b/src/components/dialog/DialogBody.vue @@ -21,7 +21,8 @@ // overflow-hidden (it clips its height tween) so it can never be the // scroller. Nest it: . import type { HTMLAttributes } from 'vue' -import { onBeforeUnmount, onMounted, ref } from 'vue' +import { ref } from 'vue' +import { useScrollFade } from '#/lib/useScrollFade' import { cn } from '#/lib/utils' const props = defineProps<{ @@ -29,88 +30,20 @@ const props = defineProps<{ }>() const el = ref(null) -const fadeTop = ref(false) -const fadeBottom = ref(false) -let observer: ResizeObserver | null = null - -function update() { - const node = el.value - if (!node) - return - // 1px slack: fractional scroll positions (zoom, dvh rounding) never quite - // reach scrollHeight - clientHeight exactly. - const remaining = node.scrollHeight - node.clientHeight - node.scrollTop - fadeTop.value = node.scrollTop > 1 - fadeBottom.value = remaining > 1 -} - -onMounted(() => { - update() - // Watch BOTH boxes: the scroller (dialog cap / viewport changes) and the - // content (AutoHeight tween, rows added/removed). Observing only the - // scroller misses scrollHeight changes — its own box doesn't resize when - // content grows inside a capped row. - observer = new ResizeObserver(update) - if (el.value) { - observer.observe(el.value) - for (const child of Array.from(el.value.children)) observer.observe(child) - } -}) - -onBeforeUnmount(() => { - observer?.disconnect() - observer = null -}) +const { fadeTop, fadeBottom } = useScrollFade(el) - - diff --git a/src/components/dropdown-menu/DropdownMenuCheckboxItem.vue b/src/components/dropdown-menu/DropdownMenuCheckboxItem.vue index 8da0374..77b9bd8 100644 --- a/src/components/dropdown-menu/DropdownMenuCheckboxItem.vue +++ b/src/components/dropdown-menu/DropdownMenuCheckboxItem.vue @@ -23,9 +23,9 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits) - + diff --git a/src/components/dropdown-menu/DropdownMenuContent.vue b/src/components/dropdown-menu/DropdownMenuContent.vue index 469e63c..0e9eef4 100644 --- a/src/components/dropdown-menu/DropdownMenuContent.vue +++ b/src/components/dropdown-menu/DropdownMenuContent.vue @@ -7,7 +7,8 @@ import { DropdownMenuPortal, useForwardPropsEmits, } from 'reka-ui' -import { menuContentClass, menuSlideClass, menuViewportClass } from '#/lib/menu' +import { menuWidthClass, menuContentClass, menuAnchoredMotionClass } from '#/lib/menu' +import MenuScrollArea from '../menu-scroll-area/MenuScrollArea.vue' import { cn } from '#/lib/utils' defineOptions({ @@ -33,14 +34,17 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits) data-slot="dropdown-menu-content" v-bind="{ ...$attrs, ...forwarded }" :class="cn( + menuWidthClass, menuContentClass, - menuSlideClass, - menuViewportClass, - 'max-h-(--reka-dropdown-menu-content-available-height) min-w-[8rem] origin-(--reka-dropdown-menu-content-transform-origin)', + menuAnchoredMotionClass, + 'flex min-h-0 flex-col overflow-hidden', + 'max-h-(--reka-dropdown-menu-content-available-height) origin-(--reka-dropdown-menu-content-transform-origin)', props.class )" > - + + + diff --git a/src/components/dropdown-menu/DropdownMenuRadioItem.vue b/src/components/dropdown-menu/DropdownMenuRadioItem.vue index e83040c..8dd6b05 100644 --- a/src/components/dropdown-menu/DropdownMenuRadioItem.vue +++ b/src/components/dropdown-menu/DropdownMenuRadioItem.vue @@ -24,9 +24,9 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits) - + diff --git a/src/components/dropdown-menu/DropdownMenuSubContent.vue b/src/components/dropdown-menu/DropdownMenuSubContent.vue index 467a408..766dde7 100644 --- a/src/components/dropdown-menu/DropdownMenuSubContent.vue +++ b/src/components/dropdown-menu/DropdownMenuSubContent.vue @@ -1,12 +1,15 @@ diff --git a/src/components/input/Input.vue b/src/components/input/Input.vue index d44bb5a..4d48703 100644 --- a/src/components/input/Input.vue +++ b/src/components/input/Input.vue @@ -28,9 +28,9 @@ const modelValue = useVModel(props, 'modelValue', emits, { }) const sizeClass = computed(() => ({ - sm: 'h-8 px-2.5 text-body', - default: 'h-9 px-3 text-label', - lg: 'h-10 px-3.5 text-control', + sm: 'h-8 px-2.5', + default: 'h-9 px-3', + lg: 'h-9.5 px-3.5', }[props.size])) @@ -41,7 +41,7 @@ const sizeClass = computed(() => ({ :data-size="props.size" :data-emphasis="props.emphasis && props.emphasis !== 'solid' ? props.emphasis : undefined" :class="cn( - 'w-full min-w-0 rounded-md tracking-[0.01em] py-2 text-foreground', + 'w-full min-w-0 rounded-md tracking-[0.01em] py-2 text-control font-medium text-foreground', sizeClass, 'outline-none', '[&:read-only:not(:disabled)]:bg-muted [&:read-only:not(:disabled)]:text-muted-foreground [&:read-only:not(:disabled)]:cursor-not-allowed', diff --git a/src/components/menu-scroll-area/MenuScrollArea.vue b/src/components/menu-scroll-area/MenuScrollArea.vue new file mode 100644 index 0000000..8de53b0 --- /dev/null +++ b/src/components/menu-scroll-area/MenuScrollArea.vue @@ -0,0 +1,52 @@ + + + diff --git a/src/components/menu-scroll-area/index.ts b/src/components/menu-scroll-area/index.ts new file mode 100644 index 0000000..dd15454 --- /dev/null +++ b/src/components/menu-scroll-area/index.ts @@ -0,0 +1 @@ +export { default as MenuScrollArea } from './MenuScrollArea.vue' diff --git a/src/components/native-select/NativeSelect.vue b/src/components/native-select/NativeSelect.vue index 6f99086..212b57d 100644 --- a/src/components/native-select/NativeSelect.vue +++ b/src/components/native-select/NativeSelect.vue @@ -45,7 +45,7 @@ const sizeClass = computed(() => ({ data-slot="native-select" :data-size="props.size" :class="cn( - 'selection:bg-foreground selection:text-background w-full min-w-0 appearance-none rounded-md py-2 pr-9 tracking-[0.01em] outline-none disabled:pointer-events-none disabled:cursor-not-allowed', + 'selection:bg-foreground selection:text-background w-full min-w-0 cursor-pointer appearance-none rounded-md py-2 pr-9 tracking-[0.01em] outline-none disabled:pointer-events-none disabled:cursor-not-allowed', sizeClass, props.class, )" diff --git a/src/components/persona-tile/index.vue b/src/components/persona-tile/index.vue index d725faf..3aa7835 100644 --- a/src/components/persona-tile/index.vue +++ b/src/components/persona-tile/index.vue @@ -1,7 +1,7 @@