From faa977fb2960521c30e80612442796b7677aadd2 Mon Sep 17 00:00:00 2001 From: pku-xht Date: Mon, 31 Aug 2026 18:59:28 +0800 Subject: [PATCH] fix(web): align Schedule catalog within viewport --- .../2026-08-05-durable-web-schedule.i18n.yaml | 4 +- .../2026-08-05-durable-web-schedule.md | 4 +- .../2026-08-05-durable-web-schedule.zh.md | 4 +- apps/web/tests/schedule-after.e2e.ts | 44 +++++++++++++++---- packages/client/ui-schedule/README.i18n.yaml | 4 +- packages/client/ui-schedule/README.md | 4 +- packages/client/ui-schedule/README.zh.md | 4 +- packages/client/ui-schedule/package.json | 1 + .../client/ScheduleCatalogAction.module.css | 4 +- .../src/client/ScheduleCatalogAction.tsx | 29 +++++++++--- .../schedule-catalog-action.client.spec.tsx | 35 ++++++++++++++- pnpm-lock.yaml | 3 ++ 12 files changed, 110 insertions(+), 30 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-08-05-durable-web-schedule.i18n.yaml b/.agents/notes/implemented/feature/2026-08-05-durable-web-schedule.i18n.yaml index b959794f2d..5381b7f346 100644 --- a/.agents/notes/implemented/feature/2026-08-05-durable-web-schedule.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-05-durable-web-schedule.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-05-durable-web-schedule.md -2026-08-05-durable-web-schedule.md: 2a07d8257df6e940b316749a9b96a13abaf201dc -2026-08-05-durable-web-schedule.zh.md: c3a37f69ab74e5ededb7ca89c45ad9acfd029251 +2026-08-05-durable-web-schedule.md: bac4a5cfd8965032dad2cf689ca42b1f8e5da1e3 +2026-08-05-durable-web-schedule.zh.md: 266a90d3a56caccda56be9fe648c059939b8155f diff --git a/.agents/notes/implemented/feature/2026-08-05-durable-web-schedule.md b/.agents/notes/implemented/feature/2026-08-05-durable-web-schedule.md index 2a07d8257d..bac4a5cfd8 100644 --- a/.agents/notes/implemented/feature/2026-08-05-durable-web-schedule.md +++ b/.agents/notes/implemented/feature/2026-08-05-durable-web-schedule.md @@ -60,7 +60,7 @@ Dispatch records queue admission, not model completion or user receipt. Framing ### Read-only Web catalog -The Schedule overlay enables the otherwise-disabled [`dsh-client-ui-schedule`](../../../../packages/client/ui-schedule/README.md) client together with the Host service. The complete active projection also feeds [`dsh-client-ui-workspace`](../../../../packages/client/ui-workspace/README.md). This note owns that opt-in read-only presentation boundary: the projection is current active state, not a dispatch or delivery receipt, so ordinary Assistant turns remain the delivery presentation. +The Schedule overlay enables the otherwise-disabled [`dsh-client-ui-schedule`](../../../../packages/client/ui-schedule/README.md) client together with the Host service. The complete active projection also feeds [`dsh-client-ui-workspace`](../../../../packages/client/ui-workspace/README.md). This note owns that opt-in read-only presentation boundary: the projection is current active state, not a dispatch or delivery receipt, so ordinary Assistant turns remain the delivery presentation. The catalog is a fixed `document.body` portal whose left edge follows the trigger when space permits and shifts left to retain a 16px viewport margin near the right edge. `useAnchoredPosition` owns measurement and resize, captured-scroll, panel-resize, and cleanup behavior; Schedule supplies the trigger and portal refs, bottom placement, a 5px gap, and the existing inside/outside dismissal boundary without adding a general popover abstraction. ## Alternatives considered @@ -80,7 +80,7 @@ The Schedule overlay enables the otherwise-disabled [`dsh-client-ui-schedule`](. ## Verification -Package tests pin strict replay, one-shot and Every transitions, creation-anchor arithmetic, latest-only catch-up, multi-record batching, fork suffixes, id reuse, offset and local-calendar profiles, IANA validation, daylight-saving gaps and overlaps, time bounds, timer segmentation, wall-clock movement, overdue admission, fixed framing, enqueue and append failures, barrier recovery, projection registration and restoration, registration rollback, and quiescent disposal at per-file 100% coverage. A property test compares Every calculation and replay across varied intervals and skipped spans. A production JSONL restart test proves one overdue reminder dispatches through the real Agent lifecycle and does not redispatch after another restart. Focused client suites own catalog and sidebar behavior. Keyless assembled Web scenarios retain ordinary After/At/Every delivery evidence plus one Schedule-catalog smoke for overlay reachability, the current header catalog, ordinary/search alarms, narrow dark layout, and one live empty update. +Package tests pin strict replay, one-shot and Every transitions, creation-anchor arithmetic, latest-only catch-up, multi-record batching, fork suffixes, id reuse, offset and local-calendar profiles, IANA validation, daylight-saving gaps and overlaps, time bounds, timer segmentation, wall-clock movement, overdue admission, fixed framing, enqueue and append failures, barrier recovery, projection registration and restoration, registration rollback, and quiescent disposal at per-file 100% coverage. A property test compares Every calculation and replay across varied intervals and skipped spans. A production JSONL restart test proves one overdue reminder dispatches through the real Agent lifecycle and does not redispatch after another restart. Focused client suites own catalog and sidebar behavior, including the body portal, spacious left alignment, portal-inside pointer handling, outside dismissal, Escape, live empty, and timer cleanup. The shared primitive suite owns the positioning hook's resize, captured-scroll, panel-resize, and cleanup lifecycle. Keyless assembled Web scenarios retain ordinary After/At/Every delivery evidence plus one 900×900 Schedule-catalog smoke for overlay reachability, fixed portal placement, right-edge clamping, width and overflow, ordinary/search alarms, narrow dark layout, a light-theme browser screenshot, and one live empty update. ## Consequences diff --git a/.agents/notes/implemented/feature/2026-08-05-durable-web-schedule.zh.md b/.agents/notes/implemented/feature/2026-08-05-durable-web-schedule.zh.md index c3a37f69ab..266a90d3a5 100644 --- a/.agents/notes/implemented/feature/2026-08-05-durable-web-schedule.zh.md +++ b/.agents/notes/implemented/feature/2026-08-05-durable-web-schedule.zh.md @@ -60,7 +60,7 @@ dispatch 记录的是队列准入,而不是模型完成或用户收到提醒 ### 只读 Web 目录 -Schedule overlay 会把默认禁用的 [`dsh-client-ui-schedule`](../../../../packages/client/ui-schedule/README.zh.md) client 与 Host 服务一同启用。完整活动 projection 也会交给 [`dsh-client-ui-workspace`](../../../../packages/client/ui-workspace/README.zh.md)。本 Note 拥有这条 opt-in 只读呈现边界:该 projection 表示当前活动状态,而非 dispatch 或交付回执,因此普通 Assistant 轮次仍是交付呈现。 +Schedule overlay 会把默认禁用的 [`dsh-client-ui-schedule`](../../../../packages/client/ui-schedule/README.zh.md) client 与 Host 服务一同启用。完整活动 projection 也会交给 [`dsh-client-ui-workspace`](../../../../packages/client/ui-workspace/README.zh.md)。本 Note 拥有这条 opt-in 只读呈现边界:该 projection 表示当前活动状态,而非 dispatch 或交付回执,因此普通 Assistant 轮次仍是交付呈现。目录是挂到 `document.body` 的 fixed portal;空间足够时左边缘跟随触发按钮,靠近视口右侧时向左避让并保留 16px 边距。`useAnchoredPosition` 拥有测量以及 resize、捕获阶段 scroll、面板 resize 与清理行为;Schedule 提供触发器与 portal ref、bottom 放置、5px 间距和既有内外 dismissal 边界,不增加通用 popover 抽象。 ## 已考虑的替代方案 @@ -80,7 +80,7 @@ Schedule overlay 会把默认禁用的 [`dsh-client-ui-schedule`](../../../../pa ## 验证 -包测试以逐文件 100% coverage 固定严格回放、一次性与 Every 状态转换、创建锚点运算、只追赶最新一次、多记录批处理、fork 后缀、id 复用、偏移量与本地日历 profile、IANA 校验、夏令时缺口与重叠、时间边界、timer 分段、墙钟变化、overdue 准入、固定 framing、入队与 append 失败、barrier 恢复、projection 注册与恢复、注册 rollback 和完全停稳的 dispose。属性测试会在不同间隔与跳过跨度下比较 Every 计算与回放。production JSONL restart 测试证明一条 overdue 提醒会经过真实 Agent 生命周期 dispatch,并且再次 restart 后不会重复 dispatch。聚焦 client suite 拥有目录与侧边栏行为。无密钥组装 Web 场景保留普通 After/At/Every 交付证据,再由一个 Schedule 目录 smoke 覆盖 overlay 可达性、当前 header 目录、普通/搜索闹钟、窄屏暗色布局与一次 live empty 更新。 +包测试以逐文件 100% coverage 固定严格回放、一次性与 Every 状态转换、创建锚点运算、只追赶最新一次、多记录批处理、fork 后缀、id 复用、偏移量与本地日历 profile、IANA 校验、夏令时缺口与重叠、时间边界、timer 分段、墙钟变化、overdue 准入、固定 framing、入队与 append 失败、barrier 恢复、projection 注册与恢复、注册 rollback 和完全停稳的 dispose。属性测试会在不同间隔与跳过跨度下比较 Every 计算与回放。production JSONL restart 测试证明一条 overdue 提醒会经过真实 Agent 生命周期 dispatch,并且再次 restart 后不会重复 dispatch。聚焦 client suite 拥有目录与侧边栏行为,包括 body portal、空间充足时的左对齐、portal 内指针处理、外部 dismissal、Escape、live empty 与 timer 清理。共享 primitive suite 拥有定位 hook 的 resize、捕获阶段 scroll、面板 resize 与清理生命周期。无密钥组装 Web 场景保留普通 After/At/Every 交付证据,再由一个 900×900 Schedule 目录 smoke 覆盖 overlay 可达性、fixed portal 定位、右侧钳制、宽度与 overflow、普通/搜索闹钟、窄屏暗色布局、浅色主题浏览器截图与一次 live empty 更新。 ## 后果 diff --git a/apps/web/tests/schedule-after.e2e.ts b/apps/web/tests/schedule-after.e2e.ts index 7dfe576560..d0e09bfd14 100644 --- a/apps/web/tests/schedule-after.e2e.ts +++ b/apps/web/tests/schedule-after.e2e.ts @@ -1,6 +1,6 @@ /** Keyless assembled-Web evidence for conversational Schedule delivery. */ -import { readFile } from 'node:fs/promises' +import { mkdir, readFile, writeFile } from 'node:fs/promises' import { join } from 'node:path' import { fileURLToPath } from 'node:url' import type { Browser, Page } from 'playwright' @@ -29,6 +29,7 @@ import { type WebScaffold, } from './scaffold.ts' import { + REPO_ROOT, connectFreshWorkspace, conversationContextKey, saveFailureShot, @@ -708,18 +709,34 @@ describe.skipIf(MODE === 'record')('web e2e: active Schedule catalog', () => { const catalog = page.getByRole('list', { name: 'Active reminders' }) await catalog.waitFor({ timeout: 10_000 }) expect(await catalog.getByRole('listitem').count()).toBe(3) - const lightLayout = await catalog.evaluate((element) => { - const box = element.getBoundingClientRect() + const lightLayout = await page.evaluate(() => { + const triggerElement = document.querySelector('button[aria-label="3 reminders"]') + const catalogElement = document.querySelector('[aria-label="Active reminders"]') + if (!(triggerElement instanceof HTMLElement) || !(catalogElement instanceof HTMLElement)) { + throw new Error('active reminder trigger or catalog is not mounted') + } + const triggerBox = triggerElement.getBoundingClientRect() + const catalogBox = catalogElement.getBoundingClientRect() + const viewport = window.innerWidth return { - width: box.width, - right: box.right, - viewport: window.innerWidth, + bodyPortal: catalogElement.parentElement === document.body, + position: getComputedStyle(catalogElement).position, + triggerLeft: triggerBox.left, + catalogLeft: catalogBox.left, + catalogRight: catalogBox.right, + width: catalogBox.width, + viewport, + expectedLeft: Math.min(Math.max(16, triggerBox.left), viewport - catalogBox.width - 16), scrollWidth: document.documentElement.scrollWidth, - background: getComputedStyle(element).backgroundColor, + background: getComputedStyle(catalogElement).backgroundColor, } }) + expect(lightLayout.bodyPortal).toBe(true) + expect(lightLayout.position).toBe('fixed') expect(lightLayout.width).toBe(336) - expect(lightLayout.right).toBeLessThanOrEqual(lightLayout.viewport) + expect(lightLayout.catalogLeft).toBe(lightLayout.expectedLeft) + expect(lightLayout.catalogLeft).toBeLessThan(lightLayout.triggerLeft) + expect(lightLayout.catalogRight).toBeLessThanOrEqual(lightLayout.viewport - 16) expect(lightLayout.scrollWidth).toBeLessThanOrEqual(lightLayout.viewport) expect(lightLayout.background).not.toBe('rgba(0, 0, 0, 0)') const longRow = catalog.getByRole('listitem').filter({ hasText: 'Join release review' }) @@ -760,6 +777,17 @@ describe.skipIf(MODE === 'record')('web e2e: active Schedule catalog', () => { })) expect(scrollLayout.scrollHeight).toBeGreaterThan(scrollLayout.clientHeight) + const evidenceDir = join(REPO_ROOT, '.artifacts') + await mkdir(evidenceDir, { recursive: true }) + await writeFile( + join(evidenceDir, 'web-e2e-schedule-catalog-left-alignment.json'), + `${JSON.stringify(lightLayout, null, 2)}\n`, + ) + await page.screenshot({ + path: join(evidenceDir, 'web-e2e-schedule-catalog-left-alignment.png'), + fullPage: true, + }) + await page.evaluate(() => { document.body.setAttribute('data-ds-dark-theme', '') }) const darkBackground = await catalog.evaluate(element => getComputedStyle(element).backgroundColor) expect(darkBackground).not.toBe('rgba(0, 0, 0, 0)') diff --git a/packages/client/ui-schedule/README.i18n.yaml b/packages/client/ui-schedule/README.i18n.yaml index 14a1f747f0..60cf025dd8 100644 --- a/packages/client/ui-schedule/README.i18n.yaml +++ b/packages/client/ui-schedule/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-schedule/README.md -README.md: 2d75746876daf453b01ff2e84b6e62ed018c205c -README.zh.md: 32925e73aed252189764c4eafc0f5ecc58bb2a08 +README.md: 2792cef4ae5cc0d0ad76262d05e06c83a7d0fc54 +README.zh.md: 0add76f0455ff2b2ca720444f3a0250134664853 diff --git a/packages/client/ui-schedule/README.md b/packages/client/ui-schedule/README.md index 2d75746876..2792cef4ae 100644 --- a/packages/client/ui-schedule/README.md +++ b/packages/client/ui-schedule/README.md @@ -35,7 +35,7 @@ The shipped Web graph already resolves `@deepseek-ai/dsh-client-ui-schedule` thr ### Read and dismiss the catalog -Each row shows the complete wrapping prompt, a separate Scheduled or Overdue status, localized Once or the largest exact whole unit for a repeating interval, browser-local target time, and browser-clock-relative time. Intervals are never rounded, and the three metadata fields wrap across lines instead of clipping valid large values. The 336px popover scrolls vertically when needed and exposes no Schedule id, raw UTC value, details, or action controls. +Each row shows the complete wrapping prompt, a separate Scheduled or Overdue status, localized Once or the largest exact whole unit for a repeating interval, browser-local target time, and browser-clock-relative time. Intervals are never rounded, and the three metadata fields wrap across lines instead of clipping valid large values. The body-portaled popover targets 336px, shares the trigger's left edge when space permits, and shifts left to retain a 16px viewport margin when the trigger is near the right edge; its maximum width is the viewport width minus 32px. It scrolls vertically when needed and exposes no Schedule id, raw UTC value, details, or action controls. Only the native trigger button enters the tab order. Enter and Space use normal button activation; while focus remains on the trigger or catalog, Escape closes the popover and restores trigger focus; an outside pointer press dismisses it. If a live update removes the final record, the component closes and unmounts without moving focus to another header action. A failed Session open hides the trigger even when a tentative cached projection exists. @@ -47,7 +47,7 @@ Only the native trigger button enters the tab order. Enter and Space use normal
Implementation internals — click to expand -The browser plugin contributes `schedule-catalog` to `conversation.session.header.actions` at order 10, after static Agent and Subagent context and before background Jobs. It reads `openState` through the standard Session hook and the complete value through `useProjection('schedule')`; popover visibility is its only local interaction state. Browser formatting uses the viewing locale, time zone, and clock, while durable Schedule records remain unchanged. +The browser plugin contributes `schedule-catalog` to `conversation.session.header.actions` at order 10, after static Agent and Subagent context and before background Jobs. It reads `openState` through the standard Session hook and the complete value through `useProjection('schedule')`; popover visibility is its only local interaction state. The component portals the catalog to `document.body` and gives its trigger and panel refs to `useAnchoredPosition`, which publishes fixed coordinates after measuring the rendered panel, keeps a 5px gap below the trigger, clamps to a 16px viewport margin, and remeasures on resize, captured scroll, and panel resize. The catalog ref also makes pointer presses inside the portal part of the existing dismissal boundary. Browser formatting uses the viewing locale, time zone, and clock, while durable Schedule records remain unchanged. ### Source map diff --git a/packages/client/ui-schedule/README.zh.md b/packages/client/ui-schedule/README.zh.md index 32925e73ae..0add76f045 100644 --- a/packages/client/ui-schedule/README.zh.md +++ b/packages/client/ui-schedule/README.zh.md @@ -35,7 +35,7 @@ dsh web --patch apps/cli/config/examples/schedule/cordis.yml ### 阅读和关闭目录 -每一行显示可完整换行的 prompt、独立的「等待中」或「已逾期」状态、本地化的「单次」或重复间隔可整除的最大完整单位、浏览器本地目标时间,以及按浏览器时钟派生的相对时间。间隔绝不舍入,三项元数据会按行换行,不会裁剪合法的大数值。336px 宽的弹层在需要时纵向滚动,不显示 Schedule id、原始 UTC 值、详情或操作控件。 +每一行显示可完整换行的 prompt、独立的「等待中」或「已逾期」状态、本地化的「单次」或重复间隔可整除的最大完整单位、浏览器本地目标时间,以及按浏览器时钟派生的相对时间。间隔绝不舍入,三项元数据会按行换行,不会裁剪合法的大数值。通过 portal 挂到 body 的弹层目标宽度为 336px;空间足够时与触发按钮左边缘对齐,触发器靠近视口右侧时向左避让并保留 16px 视口边距,最大宽度为视口宽度减 32px。弹层会在需要时纵向滚动,且不显示 Schedule id、原始 UTC 值、详情或操作控件。 只有原生触发按钮进入 Tab 顺序。Enter 与 Space 使用按钮的正常激活行为;焦点仍在触发器或目录内时,Escape 会关闭弹层并把焦点交还触发器;在外部按下指针也会关闭。若 live 更新移除最后一条记录,组件会关闭并卸载,但不会把焦点移到另一个会话头部动作。Session 打开失败时,即使存在暂定的缓存 projection,也会隐藏触发器。 @@ -47,7 +47,7 @@ dsh web --patch apps/cli/config/examples/schedule/cordis.yml
实现细节——点击展开 -浏览器插件以顺序 10 向 `conversation.session.header.actions` 贡献 `schedule-catalog`,位于静态 Agent 与 Subagent 上下文之后、后台 Jobs 之前。它通过标准 Session hook 读取 `openState`,通过 `useProjection('schedule')` 读取完整值;弹层开合是它唯一的本地交互状态。浏览器格式化使用查看方的 locale、时区与时钟,持久 Schedule 记录保持不变。 +浏览器插件以顺序 10 向 `conversation.session.header.actions` 贡献 `schedule-catalog`,位于静态 Agent 与 Subagent 上下文之后、后台 Jobs 之前。它通过标准 Session hook 读取 `openState`,通过 `useProjection('schedule')` 读取完整值;弹层开合是它唯一的本地交互状态。组件把目录 portal 到 `document.body`,并将触发器与面板 ref 交给 `useAnchoredPosition`;该 hook 在测量已渲染面板后发布 fixed 坐标,使面板位于触发器下方 5px、钳制在 16px 视口边距内,并在 resize、捕获阶段 scroll 与面板 resize 时重新测量。目录 ref 也让 portal 内的指针按下继续属于既有 dismissal 边界之内。浏览器格式化使用查看方的 locale、时区与时钟,持久 Schedule 记录保持不变。 ### 源码地图 diff --git a/packages/client/ui-schedule/package.json b/packages/client/ui-schedule/package.json index 57cd158c11..9cae9286e8 100644 --- a/packages/client/ui-schedule/package.json +++ b/packages/client/ui-schedule/package.json @@ -61,6 +61,7 @@ "@deepseek-ai/dsh-session": "workspace:^", "@testing-library/react": "^16.1.0", "@types/react": "~18.3.1", + "@types/react-dom": "~18.3.0", "@deepseek-ai/cordis": "workspace:^", "react": "^18.2.0", "react-dom": "^18.2.0" diff --git a/packages/client/ui-schedule/src/client/ScheduleCatalogAction.module.css b/packages/client/ui-schedule/src/client/ScheduleCatalogAction.module.css index 3f4d6c5380..82c10bcfb7 100644 --- a/packages/client/ui-schedule/src/client/ScheduleCatalogAction.module.css +++ b/packages/client/ui-schedule/src/client/ScheduleCatalogAction.module.css @@ -39,9 +39,7 @@ } .menu { - position: absolute; - top: calc(100% + 5px); - right: 0; + position: fixed; z-index: 100; box-sizing: border-box; display: flex; diff --git a/packages/client/ui-schedule/src/client/ScheduleCatalogAction.tsx b/packages/client/ui-schedule/src/client/ScheduleCatalogAction.tsx index 34d071f9bc..64b57d7e2a 100644 --- a/packages/client/ui-schedule/src/client/ScheduleCatalogAction.tsx +++ b/packages/client/ui-schedule/src/client/ScheduleCatalogAction.tsx @@ -1,8 +1,12 @@ -import { useEffect, useMemo, useRef, useState, type KeyboardEvent } from 'react' +import { + useEffect, useMemo, useRef, useState, type CSSProperties, type KeyboardEvent, +} from 'react' +import { createPortal } from 'react-dom' import type { ScheduleRecord } from '@deepseek-ai/dsh-schedule/client' import { IconAlarmClockOutline16, IconChevronDownOutline14, + useAnchoredPosition, useDismissOnOutsidePointer, } from '@deepseek-ai/dsh-client-ui-primitives' import type { PropsLocale, PropsRuntime, TranslateNS } from '@deepseek-ai/dsh-client-ui-slots' @@ -19,6 +23,7 @@ type TimeUnit = 'day' | 'hour' | 'minute' | 'second' const EMPTY_RECORDS: readonly ScheduleRecord[] = [] const SECOND_MS = 1_000 const SECOND_UNIT = { unit: 'second', seconds: 1 } as const +const MEASURE_STYLE: CSSProperties = { visibility: 'hidden', left: 0, top: 0 } const UNIT_SECONDS: readonly { unit: TimeUnit; seconds: number }[] = [ { unit: 'day', seconds: 86_400 }, { unit: 'hour', seconds: 3_600 }, @@ -105,8 +110,17 @@ export function ScheduleCatalogAction({ useSession, useProjection, t }: Schedule const [now, setNow] = useState(() => Date.now()) const rootRef = useRef(null) const triggerRef = useRef(null) + const catalogRef = useRef(null) + const catalogPosition = useAnchoredPosition({ + open, + anchorRef: triggerRef, + panelRef: catalogRef, + side: 'bottom', + gap: 5, + margin: 16, + }) - useDismissOnOutsidePointer(rootRef, open, setOpen) + useDismissOnOutsidePointer(rootRef, open, setOpen, catalogRef) useEffect(() => { if (!open) return @@ -151,8 +165,13 @@ export function ScheduleCatalogAction({ useSession, useProjection, t }: Schedule ) const catalog = open - ? ( -
    + ? createPortal(( +
      {rows.map((record) => { const overdue = Date.parse(record.scheduledAt) <= now return ( @@ -178,7 +197,7 @@ export function ScheduleCatalogAction({ useSession, useProjection, t }: Schedule ) })}
    - ) + ), document.body) : null return ( diff --git a/packages/client/ui-schedule/tests/schedule-catalog-action.client.spec.tsx b/packages/client/ui-schedule/tests/schedule-catalog-action.client.spec.tsx index c181a5f745..086d9e05b9 100644 --- a/packages/client/ui-schedule/tests/schedule-catalog-action.client.spec.tsx +++ b/packages/client/ui-schedule/tests/schedule-catalog-action.client.spec.tsx @@ -27,6 +27,7 @@ beforeEach(() => { afterEach(() => { cleanup() + vi.restoreAllMocks() vi.useRealTimers() }) @@ -124,6 +125,34 @@ describe('ScheduleCatalogAction visibility', () => { }) }) +describe('ScheduleCatalogAction positioning', () => { + it('portals the catalog to the body and left-aligns it when space is available', () => { + const active = [record('active', 'after', START + 60_000)] + const view = render() + const trigger = screen.getByRole('button', { name: '1 reminder' }) + vi.spyOn(trigger, 'getBoundingClientRect').mockReturnValue({ + x: 240, + y: 20, + left: 240, + right: 320, + top: 20, + bottom: 48, + width: 80, + height: 28, + toJSON: () => ({}), + }) + + fireEvent.click(trigger) + + const catalog = screen.getByRole('list', { name: en['list.aria'] }) + expect(view.container.contains(catalog)).toBe(false) + expect(catalog.parentElement).toBe(document.body) + expect(catalog.style.left).toBe('240px') + expect(catalog.style.top).toBe('53px') + expect(catalog.style.visibility).toBe('') + }) +}) + describe('ScheduleCatalogAction rows', () => { it('shows only prompt and the three derived metadata fields, with overdue records first', () => { const rawPrompt = ' Keep the complete long reminder prompt visible without truncation.' @@ -238,11 +267,13 @@ describe('ScheduleCatalogAction dismissal', () => { expect(document.activeElement).toBe(sibling) }) - it('toggles from the trigger and dismisses only on an outside pointer press', () => { + it('keeps a pointer press inside the portaled catalog open and dismisses outside', () => { render() const trigger = screen.getByRole('button') fireEvent.click(trigger) - fireEvent.pointerDown(screen.getByRole('list', { name: en['list.aria'] })) + const catalog = screen.getByRole('list', { name: en['list.aria'] }) + expect(catalog.parentElement).toBe(document.body) + fireEvent.pointerDown(catalog) expect(trigger.getAttribute('aria-expanded')).toBe('true') fireEvent.pointerDown(document.body) expect(trigger.getAttribute('aria-expanded')).toBe('false') diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 272909451f..45098dc345 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -3080,6 +3080,9 @@ importers: '@types/react': specifier: ~18.3.1 version: 18.3.31 + '@types/react-dom': + specifier: ~18.3.0 + version: 18.3.7(@types/react@18.3.31) react: specifier: ^18.2.0 version: 18.3.1