fix(chat): keep the 常用问题 panel above or below the trigger
The 432px panel fitted neither above nor below the composer in a blank new chat at 1280x860, so Base UI fell back to the trigger's right and covered the input. The panel is now 404px (nav gap 0), and the Popover only flips between top and bottom (fallbackAxisSide "none", collisionPadding 8). The kit PopoverContent passes collisionAvoidance / collisionPadding to the positioner. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DbBXoyeZrYn6ryNcSe5Y2Y
This commit is contained in:
+1
-1
@@ -3,7 +3,7 @@
|
||||
## 2026-11-03 — 常用问题:文字入口和两栏弹窗(issue #151 改版,BUG-1534)
|
||||
|
||||
- 入口:输入框左下角的红色圆底图标换成一个 32px 高的细边胶囊,左边一颗主题色小星,右边写「常用问题」;无底色,悬停浅灰底,可点区域 44px 高。桌面和手机同一个样子。
|
||||
- 桌面弹窗:固定 600×432,左边 168px 一列列出全部 12 个分类,右边直接是当前分类的 5 个问题,中间一条细线。鼠标停在分类上就换右边的问题,不用点;上下键换分类,右键或 Tab 进问题,回车选中。打开时停在上一次选过问题的分类(只记在这一页里)。去掉「›」箭头、衬线粗体标题和返回按钮。「压力与生活节奏」的安全提示照旧显示在问题上方。
|
||||
- 桌面弹窗:固定 600×404,只在入口上方或下方弹出,不会跑到入口右边盖住输入框,左边 168px 一列列出全部 12 个分类,右边直接是当前分类的 5 个问题,中间一条细线。鼠标停在分类上就换右边的问题,不用点;上下键换分类,右键或 Tab 进问题,回车选中。打开时停在上一次选过问题的分类(只记在这一页里)。去掉「›」箭头、衬线粗体标题和返回按钮。「压力与生活节奏」的安全提示照旧显示在问题上方。
|
||||
- 手机:底部弹出固定高度,标题「常用问题」,下面一排 12 个分类胶囊可以左右拖,当前分类浅主题色;再下面是 5 个问题,每行 52px、细线分隔。
|
||||
- 修正(BUG-1534):分类列表里面不再上下滚动;鼠标移动时不会两行同时变色,同一时刻只有一行问题有浅底。
|
||||
- 选中问题后仍是填进输入框、弹窗关闭,不会直接发出。问题文字没有改。没有数据库变更。Skill 版本不变。
|
||||
|
||||
+2
-2
@@ -21008,8 +21008,8 @@
|
||||
- 根因:
|
||||
1. 菜单借用了「选择星盘」的列表样式 `.chat-profile-picker-options`(`max-height: min(50vh, 320px); overflow-y: auto`),12 行 × 48px 超出 320px,只能在里面滚。
|
||||
2. 行的底色只靠 `:hover`,而且有两层背景渐变:自己的 `transition: background-color 120ms` 和 kit `Button` 的 `duration-150`。鼠标离开的那一行还在褪色,新进入的一行已经上色,两行同时有底。
|
||||
- 修复:随产品确认的「A + 1」改版一起改。桌面换成固定 600×432 的两栏(168px 分类 + 问题),手机换成固定高度的底部弹出(分类胶囊一排 + 5 个问题),两处都不再有纵向滚动,高度按最高的一组(「压力与生活节奏」带两三行提示)算好。问题行不再用 `:hover` 上色,改成 `.is-active` 一个状态(最后一次指针进入或获得焦点的那一行),并去掉背景渐变(`transition: none`),同一时刻只有一行有底。
|
||||
- 验证:新增 `frontend/tests/consult-preset-menu-20261103.test.tsx` 七条:入口是文字胶囊、可点区域按 CSS 算不小于 44px;桌面 12 个分类加 5 个问题、所有 `.consult-preset-*` 规则里没有 `overflow(-y): auto|scroll`、12 行和最高一组问题都放得进 430px;悬停(80ms)或方向键换分类、斜着划过不换;同一时刻只有一行 `is-active`,没有 `.consult-preset-question:hover` 规则;选中后 `onPick` 收到该分类的原文并关闭,下次打开停在这个分类;手机有标题、12 个胶囊和 5 个问题,面板固定高度放得下最高一组;最长标题在 360px 和桌面都一行放得下。本地 `next build --webpack` + `next start` + 拦截接口的虚构资料截图(1280 / 390,浅色 / 深色),不是登录后的真页面。
|
||||
- 修复:随产品确认的「A + 1」改版一起改。桌面换成固定 600×404 的两栏(168px 分类 + 问题),手机换成固定高度的底部弹出(分类胶囊一排 + 5 个问题),两处都不再有纵向滚动,高度按最高的一组(「压力与生活节奏」带两三行提示)算好。问题行不再用 `:hover` 上色,改成 `.is-active` 一个状态(最后一次指针进入或获得焦点的那一行),并去掉背景渐变(`transition: none`),同一时刻只有一行有底。
|
||||
- 验证:新增 `frontend/tests/consult-preset-menu-20261103.test.tsx` 八条:入口是文字胶囊、可点区域按 CSS 算不小于 44px;桌面 12 个分类加 5 个问题、所有 `.consult-preset-*` 规则里没有 `overflow(-y): auto|scroll`、12 行和最高一组问题都放得进 430px;悬停(80ms)或方向键换分类、斜着划过不换;同一时刻只有一行 `is-active`,没有 `.consult-preset-question:hover` 规则;选中后 `onPick` 收到该分类的原文并关闭,下次打开停在这个分类;手机有标题、12 个胶囊和 5 个问题,面板固定高度放得下最高一组;最长标题在 360px 和桌面都一行放得下;弹窗只在入口上下翻转、不退到侧面(`fallbackAxisSide: "none"`),高度不超过 408px。改版第一稿是 432px 高、用 kit 默认的碰撞处理,空白新对话(1280×860)里上下都放不下,弹窗跑到入口右侧盖住输入框,产品验收前已在同一分支改掉。本地 `next build --webpack` + `next start` + 拦截接口的虚构资料截图(1280 / 390,浅色 / 深色),不是登录后的真页面。
|
||||
- 防复发:上面的测试。规则:弹出层里的列表要按内容算好固定尺寸,不要借用另一个组件带 `max-height` 和滚动的列表类;可悬停的行不要同时用 `:hover` 底色和背景渐变,用一个状态类决定哪一行有底。
|
||||
- 关联记录:issue #151(常用问题菜单)。
|
||||
- 复发自:无
|
||||
|
||||
+1
-1
@@ -303,7 +303,7 @@ Radii have two visual steps. Controls use 8px (`--radius-md`; `--radius-xs` and
|
||||
- **One composer:** the rectification surface renders the same `ChatComposer` as the main chat; there is no second composer. A surface that owns its own draft passes it as `value`; the main chat reads the draft store. Both count down from the same 500-character ceiling through `CharacterRemaining`, which the composer renders inline and only once `characterRemainingVisible()` is true — it no longer has a permanent container. The `toolbar` slot held only the model picker and left with it (2026-11-02); neither surface puts a control in the box's bottom row besides send / stop and the count. The only thing under the box is the one-line AI notice, not another control.
|
||||
- **Preset questions 「常用问题」 (issue #151; redesigned 2026-11-03, product 「A + 1」, BUG-1534):** the one control at the left of the composer row (`leadingSlot` → `.composer-row-start`), in the ordinary chat only (not synastry, not rectification). Everything else in the row is as described above.
|
||||
- **Trigger:** a text pill — a 14px `Sparkle` in `--color-action` and 「常用问题」 at 13px `--color-ink-secondary`; 32px tall, `padding: 0 12px`, `1px --color-border`, radius 999px, transparent; hover and open take `--color-canvas-muted`; a `::before` stretches the target to 44px tall. Same pill on desktop and phone. It replaced a 36px `--color-action-soft` disc with an icon only, which read as an unexplained red dot.
|
||||
- **Desktop (`> 640px`):** a Popover (side top, align start) fixed at 600×432, two columns: a 168px domain nav and the question pane, split by one `--color-border` hairline. The nav lists all twelve domains as kit vertical Tabs, rows 32px / radius 8 / 13px; the current domain is `.is-current` (`--color-action-soft`, `--color-ink`), the rest `--color-ink-secondary`. Resting the pointer on a domain for 80ms (`CONSULT_PRESET_HOVER_MS`) switches the pane, so a diagonal pass on the way to the questions does not; Up / Down move and select, Right / Enter / Tab go into the questions, Up / Down move there, Left comes back. The pane is a 12px `--color-ink-tertiary` domain label, the stress group's fixed safety note when it has one (two lines at this width), and five question rows (min 52px, 14px `--color-ink`, radius 10). The title is visually hidden; no chevrons, no back button.
|
||||
- **Desktop (`> 640px`):** a Popover (side top, align start) fixed at 600×404, two columns: a 168px domain nav and the question pane, split by one `--color-border` hairline. The panel only flips between above and below the trigger (`collisionAvoidance` `{ side: "flip", align: "shift", fallbackAxisSide: "none" }`, `collisionPadding` 8): in a blank new chat the composer sits mid screen, and a side fallback put the panel at the trigger's right, over the input. 404px keeps it above the trigger on an 860px-tall window. The nav lists all twelve domains as kit vertical Tabs, rows 32px with no gap (12 × 32 + 16 = 400) / radius 8 / 13px; the current domain is `.is-current` (`--color-action-soft`, `--color-ink`), the rest `--color-ink-secondary`. Resting the pointer on a domain for 80ms (`CONSULT_PRESET_HOVER_MS`) switches the pane, so a diagonal pass on the way to the questions does not; Up / Down move and select, Right / Enter / Tab go into the questions, Up / Down move there, Left comes back. The pane is a 12px `--color-ink-tertiary` domain label, the stress group's fixed safety note when it has one (two lines at this width), and five question rows (min 52px, 14px `--color-ink`, radius 10). The title is visually hidden; no chevrons, no back button.
|
||||
- **Phone (`≤ 640px`):** the Drawer, fixed height: a 15px/600 title 「常用问题」, a sideways-dragged row of twelve 36px chips (`.is-current` in `--color-action-soft`), then the current domain's five 52px rows with hairlines between. The pane is a fixed 344px, sized for the tallest domain (the stress note at three lines on a 320px screen plus five rows), so switching chips never changes the sheet's height.
|
||||
- **No inner vertical scroll and one tint:** nothing in either surface scrolls vertically. Only one question row is tinted at a time: `.is-active` (`--color-canvas-muted`) follows the last pointer-enter or focus, there is no `:hover` background and no background fade, so two rows are never shaded together (BUG-1534).
|
||||
- **Pick:** fills the composer exactly as before (`onPick`; the page selects the first 「(…)」 blank) and closes; nothing is sent. The menu opens next time on the domain the last pick came from (module memory, gone on reload). The prompt text is the astrologer-reviewed wording in `consult-preset-prompts.ts`; this component never edits it.
|
||||
|
||||
@@ -1984,12 +1984,12 @@ button.chat-chart-resize-handle:active { position: absolute; top: 0; left: -3px;
|
||||
.consult-preset-trigger:hover:not(:disabled), .consult-preset-trigger[data-popup-open] { background: var(--color-canvas-muted); }
|
||||
.consult-preset-trigger:disabled { cursor: default; opacity: 0.5; }
|
||||
.consult-preset-trigger:focus-visible { outline: 3px solid color-mix(in srgb, var(--color-focus) 56%, transparent); outline-offset: 2px; }
|
||||
.consult-preset-popup { width: 600px; height: 432px; max-width: calc(100vw - var(--space-6)); gap: 0; padding: 0; overflow: hidden; border: 1px solid var(--color-border); border-radius: 14px; background: var(--color-canvas); box-shadow: var(--shadow-elevated); transform-origin: var(--transform-origin); transition: opacity 120ms ease-out, transform 120ms var(--ease-out); }
|
||||
.consult-preset-popup { width: 600px; height: 404px; max-width: calc(100vw - var(--space-6)); gap: 0; padding: 0; overflow: hidden; border: 1px solid var(--color-border); border-radius: 14px; background: var(--color-canvas); box-shadow: var(--shadow-elevated); transform-origin: var(--transform-origin); transition: opacity 120ms ease-out, transform 120ms var(--ease-out); }
|
||||
.consult-preset-popup[data-starting-style], .consult-preset-popup[data-ending-style] { opacity: 0; transform: translateY(var(--space-1)); }
|
||||
@media (prefers-reduced-motion: reduce) { .consult-preset-popup[data-starting-style], .consult-preset-popup[data-ending-style] { transform: none; } }
|
||||
.consult-preset-panel { height: 100%; min-height: 0; display: grid; gap: 0; grid-template-columns: 168px minmax(0, 1fr); }
|
||||
/* 12 rows x 32px + 11 x 2px gaps + 16px padding = 422px of the 430px inside the frame. */
|
||||
.consult-preset-nav { width: auto; min-height: 0; display: flex; flex-direction: column; align-items: stretch; gap: 2px; padding: 8px; overflow: hidden; border-right: 1px solid var(--color-border); }
|
||||
/* 12 rows x 32px + 16px padding = 400px of the 402px inside the frame. */
|
||||
.consult-preset-nav { width: auto; min-height: 0; display: flex; flex-direction: column; align-items: stretch; gap: 0; padding: 8px; overflow: hidden; border-right: 1px solid var(--color-border); }
|
||||
.consult-preset-domain { height: 32px; flex: none; display: flex; align-items: center; justify-content: flex-start; margin: 0; padding: 0 10px; border: 0; border-radius: 8px; background: transparent; color: var(--color-ink-secondary); cursor: pointer; font: inherit; font-size: 13px; line-height: 1.2; text-align: left; white-space: nowrap; }
|
||||
.consult-preset-domain:hover { color: var(--color-ink); }
|
||||
.consult-preset-domain.is-current { background: var(--color-action-soft); color: var(--color-ink); }
|
||||
|
||||
@@ -33,6 +33,13 @@ export const CONSULT_PRESET_MENU_LABEL = "常用问题";
|
||||
*/
|
||||
export const CONSULT_PRESET_HOVER_MS = 80;
|
||||
|
||||
/**
|
||||
* Above or below the trigger only. In a blank new chat the composer sits mid
|
||||
* screen; the kit default then fell back to the trigger's right and covered
|
||||
* the input. With no side fallback the panel stays on the top/bottom axis.
|
||||
*/
|
||||
export const CONSULT_PRESET_COLLISION = { side: "flip", align: "shift", fallbackAxisSide: "none" } as const;
|
||||
|
||||
// The domain the last question was picked from. Memory only: a reload starts
|
||||
// at the first domain again.
|
||||
let lastDomainIndex = 0;
|
||||
@@ -221,6 +228,8 @@ export function ConsultPresetMenu({ disabled = false, onPick }: ConsultPresetMen
|
||||
side="top"
|
||||
align="start"
|
||||
sideOffset={8}
|
||||
collisionAvoidance={CONSULT_PRESET_COLLISION}
|
||||
collisionPadding={8}
|
||||
initialFocus={currentDomainRef}
|
||||
>
|
||||
<PopoverTitle className="sr-only">{CONSULT_PRESET_MENU_LABEL}</PopoverTitle>
|
||||
|
||||
@@ -20,11 +20,13 @@ function PopoverContent({
|
||||
side = "bottom",
|
||||
sideOffset = 4,
|
||||
anchor,
|
||||
collisionAvoidance,
|
||||
collisionPadding,
|
||||
...props
|
||||
}: PopoverPrimitive.Popup.Props &
|
||||
Pick<
|
||||
PopoverPrimitive.Positioner.Props,
|
||||
"align" | "alignOffset" | "side" | "sideOffset" | "anchor"
|
||||
"align" | "alignOffset" | "side" | "sideOffset" | "anchor" | "collisionAvoidance" | "collisionPadding"
|
||||
>) {
|
||||
return (
|
||||
<PopoverPrimitive.Portal>
|
||||
@@ -34,6 +36,8 @@ function PopoverContent({
|
||||
alignOffset={alignOffset}
|
||||
side={side}
|
||||
sideOffset={sideOffset}
|
||||
collisionAvoidance={collisionAvoidance}
|
||||
collisionPadding={collisionPadding}
|
||||
className="isolate z-50"
|
||||
>
|
||||
<PopoverPrimitive.Popup
|
||||
|
||||
@@ -11,7 +11,7 @@ import test from "node:test";
|
||||
import React, { act } from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import { CONSULT_PRESET_HOVER_MS, ConsultPresetMenu, CONSULT_PRESET_MENU_LABEL } from "../src/components/consult-preset-menu.tsx";
|
||||
import { CONSULT_PRESET_COLLISION, CONSULT_PRESET_HOVER_MS, ConsultPresetMenu, CONSULT_PRESET_MENU_LABEL } from "../src/components/consult-preset-menu.tsx";
|
||||
import { CONSULT_PRESET_DOMAINS } from "../src/lib/consult-preset-prompts.ts";
|
||||
import { createClientLifecycleHarness } from "./react-client-lifecycle-test-support";
|
||||
|
||||
@@ -113,7 +113,9 @@ test("the desktop panel lists all 12 domains beside 5 questions and nothing scro
|
||||
for (const line of presetRules) assert.doesNotMatch(line, /overflow(-y)?:\s*(auto|scroll)/, line);
|
||||
const popup = rule(".consult-preset-popup");
|
||||
assert.equal(popup.width, "600px");
|
||||
assert.equal(popup.height, "432px");
|
||||
// 原值:固定 432px 高。新值:不超过 408px(现为 404)。原因:空白新对话里输入框在屏幕中间,
|
||||
// 432px 上下都放不下时会跑到入口右侧盖住输入框;缩短后 860px 高的窗口上方放得下。
|
||||
assert.ok(px(popup.height) <= 408, `panel ${popup.height}`);
|
||||
assert.equal(popup.overflow, "hidden");
|
||||
assert.equal(rule(".consult-preset-panel")["grid-template-columns"], "168px minmax(0, 1fr)");
|
||||
assert.equal(rule(".consult-preset-nav")["border-right"], "1px solid var(--color-border)");
|
||||
@@ -125,7 +127,7 @@ test("the desktop panel lists all 12 domains beside 5 questions and nothing scro
|
||||
assert.equal(row["border-radius"], "8px");
|
||||
assert.equal(row["font-size"], "13px");
|
||||
const count = CONSULT_PRESET_DOMAINS.length;
|
||||
assert.ok(count * px(row.height) + (count - 1) * px(nav.gap) + 2 * px(nav.padding) <= inside);
|
||||
assert.ok(count * px(row.height) + (count - 1) * (Number.parseFloat(nav.gap!) || 0) + 2 * px(nav.padding) <= inside);
|
||||
const pane = rule(".consult-preset-pane");
|
||||
const label = rule(".consult-preset-pane-label");
|
||||
const question = rule(".consult-preset-question");
|
||||
@@ -259,3 +261,16 @@ test("the longest question titles fit one line at 360px and in the desktop pane"
|
||||
// Desktop row: 600 - 2 frame - 168 nav - 28 pane padding - 24 row padding - 24 icon and gap.
|
||||
assert.ok(longest * 14 <= 600 - 2 - 168 - 28 - 24 - 24);
|
||||
});
|
||||
|
||||
test("the desktop panel only flips between above and below the trigger, never beside it", () => {
|
||||
// In a blank new chat the composer sits mid screen; a side fallback put the
|
||||
// panel at the trigger's right, over the input.
|
||||
assert.deepEqual(CONSULT_PRESET_COLLISION, { side: "flip", align: "shift", fallbackAxisSide: "none" });
|
||||
assert.match(source, /side="top"\s+align="start"\s+sideOffset=\{8\}\s+collisionAvoidance=\{CONSULT_PRESET_COLLISION\}\s+collisionPadding=\{8\}/);
|
||||
// The kit popover hands both props to the positioner, not to the popup element.
|
||||
const kit = readFileSync(new URL("../src/components/ui/popover.tsx", import.meta.url), "utf8");
|
||||
assert.match(kit, /collisionAvoidance=\{collisionAvoidance\}\s+collisionPadding=\{collisionPadding\}/);
|
||||
assert.equal(rule(".consult-preset-nav").gap, "0");
|
||||
// 12 x 32 + padding, plus the 1px frame, within the 408px limit.
|
||||
assert.ok(12 * 32 + 16 + 2 <= px(rule(".consult-preset-popup").height));
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user