☰
Pomotroid UI Tooltips 组件体系:从需求到源码的完整实现指南
2026/10/13 12:10:54 网站建设 项目流程

【免费下载链接】pomotroid

:tomato: Simple and visually-pleasing Pomodoro timer

项目地址:https://gitcode.com/gh_mirrors/po/pomotroid
点击查看免费下载

导读

Pomotroid 的计时器窗口大量使用纯图标按钮(如 Restart Round、Skip、Reset、Mute),设置窗口中的开关行也只有简短标签,用户往往无法仅凭界面判断这些控件的具体行为或平台差异。本文以仓库内openspec/changes/archive/2026-03-26-ui-tooltips/变更包为主线,完整讲解 Pomotroid 如何通过自研的Tooltip.svelte与TooltipInfo.svelte组件、两档延迟交互模型、视口边缘翻转定位以及全 8 语言 i18n 接入,为计时器窗口与设置窗口的每个目标控件补齐"即时上下文"。读完本文,你将掌握该组件的 props 契约、定位算法、无障碍接线方式,以及从 spec 到真实源码的完整落地路径。


一、为什么需要 Tooltip:问题背景与设计目标

Pomotroid 的计时器窗口(主窗口)为了保持界面简洁,多数控制按钮是纯图标形态,仅靠aria-label提供可访问性描述;设置窗口的开关行则使用简短标签,诸如"Countdown Dial""Verbose Logging"等术语对新手并不直观。变更提案 proposal.md 指出:Tooltip 的价值是在用户需要的那一刻、在 UI 原位上提供上下文,从而降低新用户的学习摩擦,并能在最相关的时刻呈现平台注意事项(例如 GNOME 下托盘图标需要 AppIndicator 扩展这一关键提示)。

本次变更的边界非常清晰(proposal 的 Impact 一节):

  • 前端:新增Tooltip.svelte组件,修改计时器页面与设置分区组件;
  • i18n:在全部 8 个语言文件(src/messages/*.json)中新增所有 tooltip 字符串,并需要重新生成 Paraglide 消息;
  • 无后端改动:Tooltip 纯属前端关注点;
  • 无新增依赖:行为完全用 CSS + Svelte 实现,不引入 Floating UI、Popper.js 等第三方库。

设计文档 design.md 明确了目标与非目标:

目标非目标
可在两个窗口任意位置复用的Tooltip.svelteTooltip 内支持富内容(HTML、图片)——仅纯文本
两种交互模式:延迟(按钮/开关)与即时(info 图标)触屏/移动端支持——Pomotroid 是纯桌面应用
CSS 驱动定位 + JS 兜底翻转(近视口边缘时上下翻转)引入第三方 Tooltip 库——保持依赖面平坦
全部 tooltip 字符串走 Paraglide 完整国际化—
用 info 图标 + 即时 tooltip 替换 Linux 下 GNOME 托盘的静态.note段落—

二、核心组件契约:Tooltip.svelte

任务清单 tasks.md 第 1 节定义了组件基础能力,而 spec 文件 specs/tooltips/spec.md 给出了精确的 props 契约。

2.1 Props 接口

组件接受以下 props(src/lib/components/Tooltip.svelte):

interface Props { text: string; // tooltip 文本,由调用方通过 Paraglide 翻译 delay?: number; // 悬停后出现 tooltip 的毫秒数,默认 600 placement?: 'above' | 'below'; // 首选放置方向,默认 'above' children: Snippet; // 触发器元素,通过默认 slot 传入 }

关键行为(对应 spec 中的场景):

  • 延迟出现:鼠标悬停触发器至少delay毫秒后显示 tooltip(spec 场景 "Tooltip appears after delay");
  • 立即消失:鼠标移出触发器时 tooltip 立即隐藏(场景 "Tooltip disappears on mouse leave");
  • 即时显示:设置delay={0}时悬停即显(场景 "Instant tooltip with delay=0");
  • 边缘翻转:触发器位置导致 tooltip 在首选方向溢出视口时,改向反方向渲染(场景 "Tooltip flips placement near viewport edge")。

2.2 事件模型与延迟计时器

源码中的显示逻辑使用show()/hide()两个函数:

async function show() { if (visible || timer !== undefined) return; const run = async () => { timer = undefined; visible = true; await tick(); updatePosition(); }; if (delay === 0) { await run(); } else { timer = setTimeout(run, delay); } } function hide() { clearTimeout(timer); timer = undefined; visible = false; positioned = false; flipped = false; tooltipStyle = ''; }
  • 触发器同时绑定onpointerenter与onpointermove(后者让鼠标在触发区内小幅移动时不至于因pointerenter未触发而丢失显示),离开时onpointerleave立即隐藏;
  • 隐藏时会清理延迟计时器并复位positioned/flipped/tooltipStyle,确保下次显示重新计算定位;
  • tooltip 的 DOM id 用Math.random().toString(36).slice(2, 9)生成,格式为tooltip-xxxxxxx,供aria-describedby引用。

2.3 视口边缘检测与翻转算法

updatePosition()是定位核心,对应任务 1.2("on show, checkgetBoundingClientRect()and flip placement if tooltip would overflow"):

function updatePosition() { if (!wrapper || !tooltipEl) return; const wRect = wrapper.getBoundingClientRect(); const tRect = tooltipEl.getBoundingClientRect(); const centerX = wRect.left + wRect.width / 2; const pad = 8; // 垂直方向:若触发器距顶部过近且首选 above,则翻转到下方 flipped = wRect.top < 70 && placement === 'above'; let top: number; if (actualPlacement === 'below') { top = wRect.bottom + 8; } else { top = wRect.top - 8 - tRect.height; } // 水平方向:水平居中于触发器,并夹在视口内 let left = centerX - tRect.width / 2; if (left < pad) left = pad; if (left + tRect.width > window.innerWidth - pad) { left = window.innerWidth - pad - tRect.width; } // 箭头偏移:始终指向触发器的水平中心 const arrowLeft = centerX - left; tooltipStyle = `top:${top}px;left:${left}px;--arrow-left:${arrowLeft}px`; positioned = true; }

算法要点:

  • 垂直翻转阈值:当触发器的getBoundingClientRect().top < 70px且首选方向为above时,翻转为below(70px大致对应标题栏高度加上 tooltip 自身高度余量);这对应验证任务 6.2"把窗口调小、确认靠近顶部的 tooltip 翻转到下方";
  • 水平夹取:tooltip 相对触发器水平居中,但左右各保留8px视口边距,超宽时贴边显示;
  • 箭头跟随:通过 CSS 自定义属性--arrow-left把箭头(::after伪元素)始终对准触发器的水平中心;
  • 防闪烁:tooltip 默认visibility: hidden,只有 JS 完成定位(.positioned类)后才可见,避免首帧闪现在左上角(源码注释明确说明这一设计)。

2.4 渲染结构与 ARIA 接线

模板部分(对应任务 1.3"Wirearia-describedby"):

<span class="tooltip-wrapper" bind:this={wrapper} onpointerenter={show} onpointermove={show} onpointerleave={hide} aria-describedby={visible ? tooltipId : undefined} > {@render children()} {#if visible} <span bind:this={tooltipEl} class="tooltip" class:below={actualPlacement === 'below'} class:positioned style={tooltipStyle} id={tooltipId} role="tooltip">{text}</span > {/if} </span>

无障碍契约(spec "Accessibility" 一节):

  • tooltip可见时,触发器元素获得aria-describedby指向 tooltip 元素的id,屏幕阅读器会把 tooltip 文本与控件标签一并朗读(场景 "Screen reader announces tooltip text");
  • tooltip隐藏时,aria-describedby属性被移除(源码中visible ? tooltipId : undefined),确保读屏不读到已隐藏的内容;
  • tooltip 元素本身带role="tooltip"。

2.5 样式:主题感知与箭头

样式契约(spec "Tooltip styling" 一节)与实现对照:

spec 要求源码实现(Tooltip.svelte的<style>块)
背景--color-background-light(主题未定义时用深色叠加)--tooltip-bg: var(--color-background-light, color-mix(in oklch, var(--color-foreground) 10%, var(--color-background)))
文字色--color-foregroundcolor: var(--color-foreground)
字号0.75rem实现为0.72rem、line-height: 1.4(实现较 spec 微调)
圆角4pxborder-radius: 4px
最大宽度240px、文字换行max-width: 240px; white-space: normal; text-align: center
轻微阴影box-shadow: 0 2px 8px color-mix(in oklch, black 30%, transparent),外加1px前景色 12% 透明描边
指向触发器的小箭头.tooltip::after使用 5px 边框三角;.tooltip.below::after反向

主题适配通过 CSS 自定义属性完成:--color-*由 src/lib/stores/theme.ts 的applyTheme()在切换主题时写入:root,因此任何可见的 tooltip 都会立即反映新主题(spec 场景 "Tooltip uses active theme colors")。全仓 40+ 套主题 JSON(见 static/themes)都定义这些颜色变量。


三、信息图标组件:TooltipInfo.svelte

任务 2.1 要求创建TooltipInfo.svelte作为Tooltip的薄封装,spec 定义了其契约:渲染一个样式化的ⓘ图标,悬停时立即显示 tooltip(delay={0}),接受单一textprop,用于设置窗口开关行旁展示上下文备注而不增加布局负担。

源码实现(src/lib/components/TooltipInfo.svelte):

<Tooltip {text} delay={0}> <span class="info-icon" role="img" aria-label="More information" onclick={(e) => e.stopPropagation()} > <svg width="13" height="13" viewBox="0 0 13 13" fill="none" aria-hidden="true"> <circle cx="6.5" cy="6.5" r="5.75" stroke="currentColor" stroke-width="1.5" /> <circle cx="6.5" cy="4" r="0.9" fill="currentColor" /> <line x1="6.5" y1="6" x2="6.5" y2="10" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" /> </svg> </span> </Tooltip>

值得注意的实现细节:

  • 必须是<span>而非<button>:因为TooltipInfo经常被渲染在<button class="row">(开关整行)内部,<span>是<button>中合法的 phrasing content(源码注释明确说明);
  • onclick必须stopPropagation():防止点击 info 图标时触发父级按钮的onclick而意外切换开关;
  • 视觉层级:rest 状态opacity: 0.55、color: var(--color-foreground),hover 时opacity: 1——对应 spec 场景"Info icon is visually distinct but unobtrusive"(spec 原文以--color-foreground-darker描述 rest 状态,实现上通过降低透明度达到"次要于相邻标签"的同等效果)。

四、i18n:14 个 tooltip 键与 8 语言覆盖

4.1 键与英文文案

本地化 spec specs/localization/spec.md 列出全部 14 个新增键。以下英文值均可在 src/messages/en.json 中核实:

i18n 键英文值
tooltip_settingsOpen Settings
tooltip_statisticsOpen Statistics
tooltip_restart_roundRestart the current round from the beginning.
tooltip_skipSkip to the next round.
tooltip_resetReset the timer to the first work round. Current session progress will be cleared.
tooltip_muteMute alert sounds
tooltip_unmuteUnmute alert sounds
tooltip_verbose_loggingEnables detailed debug logging. Use when reporting issues. Log files are accessible via Open Log Folder in Advanced settings.
tooltip_dial_countdownWhen enabled, the progress arc counts down to zero instead of filling up.
tooltip_auto_start_workAutomatically start the next work round when a break ends.
tooltip_auto_start_breakAutomatically start the break when a work round completes.
tooltip_websocketEnables a local WebSocket server for external integrations such as stream overlays. Disabled by default.
tooltip_round_counterCurrent work round out of the total rounds before a long break.
tooltip_round_counter_sessionContinuous session round count. Resets only when the timer is reset.

补充说明(spec 原文明确):system_tray_gnome_hint是已有键,被 Linux 专属的 System Tray 开关 TooltipInfo 直接复用,无需新增。

4.2 多语言落地

  • 任务 3.1/3.2 要求:en 之外,fr.json、de.json、es.json、pt.json、zh.json、ja.json、tr.json全部补齐 14 个键;
  • 仓库搜索确认全部 8 个语言文件都包含tooltip_websocket、tooltip_auto_start_work、tooltip_dial_countdown等键(每个文件命中 3 处);
  • 中文示例可核对 src/messages/zh.json:如"tooltip_reset": "将计时器重置到第一个工作轮次。当前会话进度将被清除。"、"tooltip_websocket": "启用本地 WebSocket 服务器,用于直播覆盖层等外部集成。默认禁用。";
  • spec 注明非英语翻译初始版本可以机器翻译,后续由社区改进;
  • 调用方式统一为 Paraglide 函数:m.tooltip_restart_round()等,消息编译命令为npm run paraglide:compile(见 package.json 的 scripts),完整类型检查见下文第七节。

五、计时器窗口:7 个 tooltip 目标的落地

spec "Tooltip inventory — timer window" 给出了完整清单(全部 600 ms 延迟),与源码逐一对应:

元素位置i18n 键源码位置
Settings 按钮Titlebartooltip_settingsTitlebar.svelte 的settingsBtn()snippet
Statistics 按钮Titlebartooltip_statisticsTitlebar.svelte 的statsBtn()snippet
Restart Round 按钮Timer controlstooltip_restart_roundTimer.svelte 的"Back: restart current round"
Skip 按钮Timer controlstooltip_skipTimer.svelte 的"Skip: advance to next round"
Reset 按钮Footertooltip_resetTimerFooter.svelte
Mute/Unmute 按钮Footertooltip_mute/tooltip_unmuteTimerFooter.svelte
Round indicatorFootertooltip_round_counter/tooltip_round_counter_sessionTimerFooter.svelte

5.1 Titlebar:Settings 与 Statistics

Titlebar.svelte用 Svelte 5 snippet 组织两组带 tooltip 的图标按钮:

{#snippet settingsBtn()} <Tooltip text={m.tooltip_settings()}> <button class="btn-icon" onclick={openSettings} aria-label="Settings"> <!-- 齿轮 SVG --> </button> </Tooltip> {/snippet}

注意平台差异:非 macOS 时这两个按钮渲染在标题栏左侧,macOS 时渲染在右侧(绕过系统红绿灯区域),但 tooltip 包装完全一致。

5.2 Timer:Restart Round 与 Skip

Timer.svelte中 Restart Round(回到当前轮起点)与 Skip(跳到下一轮)按钮都被<Tooltip>包裹,分别对应 spec 场景:

  • "Reset tooltip communicates consequence":悬停 Reset 600 ms 后显示tooltip_reset,说明"计时器回到第一个工作轮次并清除会话进度";
  • "Skip tooltip describes the action":显示tooltip_skip,说明"跳过当前轮并开始下一轮"。

5.3 TimerFooter:Reset、Mute/Unmute、Round indicator

TimerFooter.svelte是状态相关 tooltip 最密集的组件:

Reset(任务 4.3):

<Tooltip text={m.tooltip_reset()}> <button class="btn-text" onclick={timerReset} aria-label={m.timer_reset()}> {m.timer_reset()} </button> </Tooltip>

Mute/Unmute(任务 4.4):tooltip 文本跟随静音状态实时切换,对应 spec 场景 "Mute tooltip reflects current state":

<Tooltip text={localVolume === 0 ? m.tooltip_unmute() : m.tooltip_mute()}> <button class="btn-icon" onclick={toggleMute} aria-label={localVolume === 0 ? 'Unmute' : 'Mute'}> <!-- 音量图标,静音态画叉 --> </button> </Tooltip>

toggleMute()还会记住静音前的音量(premuteVolume),取消静音时恢复。

Round indicator(任务 4.5):按长休息是否启用切换文案,对应 spec 场景 "Round indicator tooltip reflects long-break mode":

<Tooltip text={$settings.long_breaks_enabled ? m.tooltip_round_counter() : m.tooltip_round_counter_session()} > <span class="rounds"> {#if $settings.long_breaks_enabled} {snap.work_round_number} &nbsp;|&nbsp; {snap.work_rounds_total} {:else} {m.timer_session_round({ n: snap.session_work_count })} {/if} </span> </Tooltip>

即:开启长休息时显示"当前工作轮 / 总轮数"(如2 | 4),并解释这是长休息前的第几轮;关闭长休息时显示"Round 3"式的连续会话计数,并说明只有重置计时器才会清零。


六、设置窗口:6 个 TooltipInfo 的落地

spec "Tooltip inventory — settings window" 清单(全部即时显示):

设置项分区i18n 键源码位置
Show in System Tray(仅 Linux)Systemsystem_tray_gnome_hint(已有键复用)SystemSection.svelte
Verbose LoggingSystem(Logging)tooltip_verbose_loggingSystemSection.svelte
Dial CountdownTimertooltip_dial_countdownTimerSection.svelte
Auto-start WorkTimertooltip_auto_start_workTimerSection.svelte
Auto-start BreakTimertooltip_auto_start_breakTimerSection.svelte
WebSocket ServerSystem(Integrations)tooltip_websocketSystemSection.svelte

6.1 SettingsToggle:统一的 tooltip 注入点

这些开关全部经由SettingsToggle.svelte渲染,该组件新增了可选tooltip?: stringprop,有值时在标签旁渲染TooltipInfo:

<button class="row" {onclick}> <span class="text"> <span class="label" >{label}{#if tooltip}<TooltipInfo text={tooltip} />{/if}</span > {#if description} <span class="desc">{description}</span> {/if} </span> <span class="toggle" class:on={checked} aria-checked={checked} role="switch"></span> </button>

这也解释了TooltipInfo内层必须用<span>且stopPropagation()的原因——它嵌套在整行<button>内。

6.2 GNOME 提示:Linux 专属 info 图标

任务 5.1 是本变更最特别的点:此前 GNOME AppIndicator 提示以静态.note段落显示在所有平台上(design.md 的 Context 明确批评了这一点),本次改为仅 Linux 渲染的 info 图标 + 即时 tooltip,并移除.note段落。

<SettingsToggle label={m.system_toggle_show_tray()} description={m.system_toggle_show_tray_desc()} tooltip={isLinux ? m.system_tray_gnome_hint() : undefined} checked={$settings.tray_icon_enabled} onclick={() => toggle('tray_icon_enabled', $settings.tray_icon_enabled)} />
  • isLinux来自 src/lib/utils/platform.ts:/Linux/.test(navigator.userAgent);
  • spec 场景 "GNOME hint shown only on Linux":Linux 上显示该 info 图标,macOS/Windows 上该开关不附带任何 info 图标;
  • 英文文案提示用户:GNOME 上托盘图标需要 AppIndicator 扩展(如gnome-shell-extension-appindicator),安装后需注销并重新登录;
  • 另外,整个托盘分组还被trayAvailable门控——Linux 下运行时探测libayatana-appindicator3,库缺失时整个分组隐藏(SystemSection.svelte的onMount)。

6.3 其余设置项

  • Verbose Logging(tooltip_verbose_logging):解释启用详细调试日志用于问题上报,日志文件通过 Settings → About 的 Open Log Folder 访问;
  • WebSocket Server(tooltip_websocket):解释启用本地 WebSocket 服务器供外部集成(如直播覆盖层)使用、默认禁用。SystemSection.svelte中启用后还会显示端口输入行与ws://127.0.0.1:{port}/ws的.note说明(这部分是 WebSocket 功能原有文案,不属于本次 tooltip 变更);
  • Dial Countdown(tooltip_dial_countdown):解释进度弧从满倒计至零而非从零填充;
  • Auto-start Work / Auto-start Break(tooltip_auto_start_work/tooltip_auto_start_break):分别解释休息结束后自动开始下一工作轮、工作轮结束后自动开始休息。

任务 5.4–5.6 中 Auto-start 与 Dial Countdown 的注入点在 TimerSection.svelte。由于仓库中TimerSection.svelte的这些开关通过SettingsToggle渲染,tooltipprop 的传入位置在组件调用处。


七、验证流程与质量保障

任务清单第 6 节定义了验证标准,其中部分可以从源码直接佐证:

  • 6.1 两窗口内 tooltip 正确显示与消失:由show()/hide()的事件模型保证(onpointerenter/onpointermove/onpointerleave);
  • 6.2 视口边缘翻转:将窗口调小、确认靠近顶部的 tooltip 翻转到下方——由updatePosition()中wRect.top < 70 && placement === 'above'的逻辑保证;CHANGELOG(CHANGELOG.md 第 71 行附近)也记载了实现最终采用position: fixed以避免窗口边缘裁剪;
  • 6.3 GNOME 提示仅 Linux 出现:由tooltip={isLinux ? m.system_tray_gnome_hint() : undefined}保证;
  • 6.4 类型检查:执行npm run check(paraglide-js compile --project ./project.inlang --outdir ./src/paraglide && svelte-kit sync && svelte-check --tsconfig ./tsconfig.json),确保新增消息键编译后无 TypeScript 错误。

另外,design.md 记录了三个明确的权衡,理解它们有助于在扩展 tooltip 时避免踩坑:

  • 溢出裁剪风险:若某父容器overflow: hidden,tooltip 可能被裁剪。缓解手段是审计父容器、必要时改为overflow: visible,或对该实例改用 portal 方案;
  • 紧凑模式(compact mode):Timer.svelte中isCompact时隐藏 footer 与 controls(渲染MiniControls),这些元素上的 tooltip 自然永远不会触发——无需处理,元素不存在即可;
  • 翻译质量:7 个非英语语言的 tooltip 字符串初始版本允许机器翻译,后续由社区打磨。

还有一个未决问题:Play/Pause 按钮是否加 tooltip?design.md 的 Open Questions 倾向不加——它是纯图标但功能普遍易懂,避免对显而易见的控件过度 tooltip,留待用户反馈再定。


八、小结:一套零依赖、可复用的即时上下文方案

从 tasks.md 的 6 组任务到 specs/tooltips/spec.md 的完整契约,Pomotroid 的 tooltip 体系最终落地为:

  1. Tooltip.svelte——slot 包裹式复用组件,text/delay/placement三 props,600 ms 默认延迟、delay={0}即时模式、getBoundingClientRect()视口翻转、aria-describedby无障碍接线、CSS 自定义属性主题适配;
  2. TooltipInfo.svelte——delay={0}的即时ⓘ图标封装,专用于设置开关行;
  3. 7 个计时器窗口目标 + 6 个设置窗口目标——状态条件文本(Mute/Unmute、Round counter)与平台条件渲染(Linux GNOME 提示)是两处最具代表性的用法;
  4. 14 个新 i18n 键 × 8 语言——Paraglide 全量接入,npm run check把关。

该方案未引入任何第三方依赖,纯 CSS/Svelte 实现,符合 Pomotroid 保持依赖面平坦的一贯风格。对于希望在自家 Tauri/Svelte 应用中实现轻量 tooltip 的开发者,上述组件契约、定位算法与无障碍模式都可以直接作为参考蓝图。


延伸阅读(均位于本仓库):变更总览见 proposal.md 与 design.md;组件源码见 Tooltip.svelte 与 TooltipInfo.svelte;消息文件见 src/messages。

【免费下载链接】pomotroid

:tomato: Simple and visually-pleasing Pomodoro timer

项目地址:https://gitcode.com/gh_mirrors/po/pomotroid
点击查看免费下载

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询