From cf7d874092249829c47a459c2c2e51705d053454 Mon Sep 17 00:00:00 2001 From: kev1n77 Date: Fri, 28 Aug 2026 20:28:24 +0800 Subject: [PATCH 1/9] feat(web-ui): adopt SegmentedControl for exclusive view-mode switchers Replace the custom two-option diff type switcher (GitDiffView), the markdown editor view-mode button pairs, and the font size preset button group (FontPreferencePanel) with the design-system SegmentedControl. Retire the corresponding appearance parts and migrate persisted appearance packages that still reference them. --- docs/development/ui-testids-CN.md | 3 +- docs/development/ui-testids.md | 3 +- .../compiler/AppearanceCompiler.test.ts | 4 +- .../schema/migrateAppearancePackage.ts | 4 +- .../font-preference/appearance.ts | 6 - .../components/FontPreferencePanel.scss | 50 ----- .../components/FontPreferencePanel.tsx | 188 ++++++++---------- .../components/MarkdownEditor.appearance.ts | 2 +- .../editor/components/MarkdownEditor.scss | 4 +- .../editor/components/MarkdownEditor.tsx | 62 ++---- .../GitDiffView/GitDiffView.appearance.ts | 3 +- .../components/GitDiffView/GitDiffView.scss | 37 ---- .../components/GitDiffView/GitDiffView.tsx | 31 +-- 13 files changed, 128 insertions(+), 269 deletions(-) diff --git a/docs/development/ui-testids-CN.md b/docs/development/ui-testids-CN.md index fb7209f07e..f5e88175ab 100644 --- a/docs/development/ui-testids-CN.md +++ b/docs/development/ui-testids-CN.md @@ -270,8 +270,7 @@ | Appearance 语言选项 | `appearance-language-option` | 重复的语言下拉选项。包含 `data-locale-id`,并带有 Select 组件提供的 `data-selected`。 | | Appearance 主题选择器 | `appearance-theme-select` | Appearance 中 theme Select 的真实触发节点。 | | Appearance 外观选项 | `appearance-palette-option` | 重复的外观下拉选项。包含 `data-appearance-id`,并带有 Select 组件提供的 `data-selected`。 | -| Appearance UI 字号分组 | `appearance-ui-font-level-group` | UI font size 预置级别按钮组根节点。 | -| Appearance UI 字号按钮 | `appearance-ui-font-level-btn` | 重复的 UI font size 预置级别按钮。包含 `data-font-level` 和 `data-selected`。 | +| Appearance UI 字号分组 | `appearance-ui-font-level-group` | UI font size 预置级别控件根节点。预置级别渲染为设计系统 SegmentedControl 分段,可通过 `[data-bf-part="segment"][data-bf-value=""]` 定位,选中分段带 `aria-checked="true"`。 | | Appearance UI 自定义字号控制区 | `appearance-ui-font-custom-controls` | custom UI 字号控制区根节点,仅在 custom 激活时渲染。 | | Appearance UI 自定义字号输入框 | `appearance-ui-font-custom-input` | custom UI 字号 px 输入框。包含 `data-font-level="custom"`。 | | Appearance UI 自定义字号减一按钮 | `appearance-ui-font-custom-step-minus` | custom UI 字号减一按钮。 | diff --git a/docs/development/ui-testids.md b/docs/development/ui-testids.md index 80689b39fa..343a6ff5a3 100644 --- a/docs/development/ui-testids.md +++ b/docs/development/ui-testids.md @@ -270,8 +270,7 @@ Avoid adding IDs to these surfaces unless there is a clear automated workflow. | Appearance language option | `appearance-language-option` | Repeated language dropdown option. Includes `data-locale-id` and Select-provided `data-selected`. | | Appearance theme select | `appearance-theme-select` | Theme select trigger in Appearance settings. | | Appearance palette option | `appearance-palette-option` | Repeated appearance dropdown option. Includes `data-appearance-id` and Select-provided `data-selected`. | -| Appearance UI font level group | `appearance-ui-font-level-group` | UI font preset button group root. | -| Appearance UI font level button | `appearance-ui-font-level-btn` | Repeated preset button. Includes `data-font-level` and `data-selected`. | +| Appearance UI font level group | `appearance-ui-font-level-group` | UI font preset control root. Presets render as design-system segmented control segments; target one via `[data-bf-part="segment"][data-bf-value=""]`, selected segment has `aria-checked="true"`. | | Appearance UI font custom controls | `appearance-ui-font-custom-controls` | Custom UI font px controls root, rendered when custom is active. | | Appearance UI font custom input | `appearance-ui-font-custom-input` | Custom UI font px number input. Includes `data-font-level="custom"`. | | Appearance UI font custom step minus | `appearance-ui-font-custom-step-minus` | Custom UI font px decrement button. | diff --git a/src/web-ui/src/infrastructure/appearance/compiler/AppearanceCompiler.test.ts b/src/web-ui/src/infrastructure/appearance/compiler/AppearanceCompiler.test.ts index 372c5e9ff9..d08adf052a 100644 --- a/src/web-ui/src/infrastructure/appearance/compiler/AppearanceCompiler.test.ts +++ b/src/web-ui/src/infrastructure/appearance/compiler/AppearanceCompiler.test.ts @@ -900,7 +900,7 @@ describe('AppearanceCompiler', () => { parts: { commit: { states: { expanded: { borderColor: accent } } } }, }, 'git-diff-view': { - parts: { typeOption: { states: { active: { backgroundColor: accent } } } }, + parts: { file: { states: { expanded: { backgroundColor: accent } } } }, }, 'git-settings-view': { parts: { status: { states: { error: { borderColor: accent } } } }, @@ -980,7 +980,7 @@ describe('AppearanceCompiler', () => { expect(snapshot.cssText).toContain('[data-bf-component="session-file-modifications-bar"][data-bf-part="file"][data-bf-operation="edit"]'); expect(snapshot.cssText).toContain('[data-bf-component="editor-breadcrumb"][data-bf-part="item"][data-bf-state~="active"]'); expect(snapshot.cssText).toContain('[data-bf-component="git-branch-history"][data-bf-part="commit"][data-bf-state~="expanded"]'); - expect(snapshot.cssText).toContain('[data-bf-component="git-diff-view"][data-bf-part="typeOption"][data-bf-state~="active"]'); + expect(snapshot.cssText).toContain('[data-bf-component="git-diff-view"][data-bf-part="file"][data-bf-state~="expanded"]'); expect(snapshot.cssText).toContain('[data-bf-component="git-settings-view"][data-bf-part="status"][data-bf-state~="error"]'); }); }); diff --git a/src/web-ui/src/infrastructure/appearance/schema/migrateAppearancePackage.ts b/src/web-ui/src/infrastructure/appearance/schema/migrateAppearancePackage.ts index 60054b8b42..618afbfa74 100644 --- a/src/web-ui/src/infrastructure/appearance/schema/migrateAppearancePackage.ts +++ b/src/web-ui/src/infrastructure/appearance/schema/migrateAppearancePackage.ts @@ -31,8 +31,10 @@ const RETIRED_COMPONENT_PARTS: Readonly>> = { 'context-list': new Set(['clear']), 'copy-output-button': new Set(['action', 'icon', 'text']), 'create-agent-page': new Set(['back']), - 'font-preference': new Set(['resetButton']), + 'font-preference': new Set(['resetButton', 'levelGroup', 'levelButton']), + 'git-diff-view': new Set(['typeSwitcher', 'typeOption']), 'image-analysis-card': new Set(['expand']), + 'markdown-editor': new Set(['modeToggle']), 'mini-app-tool-display': new Set(['open']), 'peer-device': new Set(['switcherDisconnect']), 'review-session-summary-card': new Set(['open']), diff --git a/src/web-ui/src/infrastructure/font-preference/appearance.ts b/src/web-ui/src/infrastructure/font-preference/appearance.ts index b096d7a7fd..ba07d1b79e 100644 --- a/src/web-ui/src/infrastructure/font-preference/appearance.ts +++ b/src/web-ui/src/infrastructure/font-preference/appearance.ts @@ -4,19 +4,13 @@ export const fontPreferenceAppearanceDescriptor: AppearanceSurfaceDescriptor = { id: 'font-preference', parts: [ { id: 'root' }, - { id: 'levelGroup' }, - { id: 'levelButton' }, { id: 'customControls' }, { id: 'numberInput' }, { id: 'error' }, { id: 'preview' }, { id: 'flowChatControls' }, ], - facets: [ - { id: 'level', attribute: 'data-bf-level', values: ['compact', 'small', 'default', 'medium', 'large', 'custom'] }, - ], states: [ - { id: 'selected', selector: { kind: 'self', suffix: '[data-bf-state~="selected"]' } }, { id: 'error', selector: { kind: 'self', suffix: '[data-bf-state~="error"]' } }, ], }; diff --git a/src/web-ui/src/infrastructure/font-preference/components/FontPreferencePanel.scss b/src/web-ui/src/infrastructure/font-preference/components/FontPreferencePanel.scss index 136afe1050..e9295f5b41 100644 --- a/src/web-ui/src/infrastructure/font-preference/components/FontPreferencePanel.scss +++ b/src/web-ui/src/infrastructure/font-preference/components/FontPreferencePanel.scss @@ -1,5 +1,4 @@ @use '../../../component-library/styles/tokens.scss' as *; -@use '../../../component-library/styles/btn-primary-tokens.scss' as btn-primary; .font-pref-panel { /* 界面字体:多行区块,标题与说明之间略松一点 */ @@ -40,15 +39,6 @@ min-width: 0; } - /* 第六档「自定义」与右侧步进器保持同一行,整组可随前置按钮换行 */ - &__custom-segment-inline { - display: inline-flex; - flex-wrap: nowrap; - align-items: center; - gap: $size-gap-2; - min-width: 0; - } - &__custom-controls { display: inline-flex; flex-wrap: nowrap; @@ -98,46 +88,6 @@ } } - &__level-btn { - display: inline-flex; - align-items: center; - justify-content: center; - min-height: 30px; - min-width: 0; - padding: $size-gap-1 $size-gap-3; - font-size: var(--bf-appearance-token-font-size-sm); - font-family: var(--bf-appearance-token-font-family-sans); - background: transparent; - border: 1px solid var(--bf-appearance-token-border-base); - border-radius: $size-radius-sm; - color: var(--bf-appearance-token-color-text-secondary); - cursor: pointer; - transition: background $motion-fast $easing-standard, - border-color $motion-fast $easing-standard, - color $motion-fast $easing-standard, - box-shadow $motion-fast $easing-standard; - white-space: nowrap; - - &:hover:not(:disabled):not(&--active) { - background: var(--bf-appearance-token-element-bg-subtle); - border-color: var(--bf-appearance-token-border-strong); - color: var(--bf-appearance-token-color-text-primary); - } - - &--active { - @include btn-primary.btn-primary-surface-default; - border-color: var(--bf-appearance-token-btn-primary-border); - font-weight: $font-weight-semibold; - box-shadow: var(--bf-appearance-token-btn-primary-shadow); - - &:hover:not(:disabled) { - @include btn-primary.btn-primary-surface-hover; - border-color: var(--bf-appearance-token-btn-primary-hover-border); - box-shadow: var(--bf-appearance-token-btn-primary-hover-shadow); - } - } - } - &__level-label { display: block; line-height: 1.15; diff --git a/src/web-ui/src/infrastructure/font-preference/components/FontPreferencePanel.tsx b/src/web-ui/src/infrastructure/font-preference/components/FontPreferencePanel.tsx index c47bc8a5c3..193a4fc750 100644 --- a/src/web-ui/src/infrastructure/font-preference/components/FontPreferencePanel.tsx +++ b/src/web-ui/src/infrastructure/font-preference/components/FontPreferencePanel.tsx @@ -1,4 +1,11 @@ -import { Button, Select, Switch, type SelectOption } from '@bitfun/ui'; +import { + Button, + SegmentedControl, + Select, + Switch, + type SegmentedControlOption, + type SelectOption, +} from '@bitfun/ui'; import React, { useMemo, useState, useCallback, useEffect } from 'react'; import { useTranslation } from 'react-i18next'; import { ConfigPageRow, ConfigPageSection } from '@/infrastructure/config/components/common'; @@ -89,6 +96,34 @@ export function FontPreferencePanel() { return !isNaN(n) && n >= 12 && n <= 20 ? n : 14; })(); + const levelOptions = useMemo( + () => [ + ...UI_LEVELS.map((l) => ({ + value: l, + label: ( + + {t(`appearance.fontSize.levels.${l}`)} + + ), + })), + { + value: 'custom', + label: ( + + {t('appearance.fontSize.levels.custom')} + + ), + }, + ], + [t, customLevelLabelPx] + ); + const fcIndependent = preference.flowChat.mode === 'independent'; const flowChatPxValue = (() => { const n = parseInt(fcBaseInput, 10); @@ -152,111 +187,62 @@ export function FontPreferencePanel() {
- {UI_LEVELS.map((l) => ( - - ))} -
- - {level === 'custom' && ( -
-
- - void handleLevelClick('custom')} - aria-invalid={!!customError} - data-testid="appearance-ui-font-custom-input" - data-font-level="custom" - data-bf-component="font-preference" - data-bf-part="numberInput" - data-bf-state={customError ? 'error' : undefined} - /> - -
- px +
+ + void handleLevelClick('custom')} + aria-invalid={!!customError} + data-testid="appearance-ui-font-custom-input" + data-font-level="custom" + data-bf-component="font-preference" + data-bf-part="numberInput" + data-bf-state={customError ? 'error' : undefined} + /> +
- )} -
+ px +
+ )}
{customError && ( diff --git a/src/web-ui/src/tools/editor/components/MarkdownEditor.appearance.ts b/src/web-ui/src/tools/editor/components/MarkdownEditor.appearance.ts index 01e21b474f..2da695f3d5 100644 --- a/src/web-ui/src/tools/editor/components/MarkdownEditor.appearance.ts +++ b/src/web-ui/src/tools/editor/components/MarkdownEditor.appearance.ts @@ -4,7 +4,7 @@ export const markdownEditorAppearanceDescriptor: AppearanceSurfaceDescriptor = { id: 'markdown-editor', parts: [ { id: 'root' }, { id: 'loading' }, { id: 'error' }, - { id: 'toolbar' }, { id: 'modeToggle' }, { id: 'actions' }, { id: 'body' }, + { id: 'toolbar' }, { id: 'actions' }, { id: 'body' }, ], facets: [{ id: 'view', attribute: 'data-bf-view', values: ['preview', 'markdown', 'source'] }], states: [ diff --git a/src/web-ui/src/tools/editor/components/MarkdownEditor.scss b/src/web-ui/src/tools/editor/components/MarkdownEditor.scss index 69b8a1b055..041d87707e 100644 --- a/src/web-ui/src/tools/editor/components/MarkdownEditor.scss +++ b/src/web-ui/src/tools/editor/components/MarkdownEditor.scss @@ -26,9 +26,7 @@ } .bitfun-markdown-editor__mode-toggle { - display: inline-flex; - align-items: center; - gap: 4px; + flex-shrink: 0; } .bitfun-markdown-editor__toolbar-actions { diff --git a/src/web-ui/src/tools/editor/components/MarkdownEditor.tsx b/src/web-ui/src/tools/editor/components/MarkdownEditor.tsx index d9591690d4..d619510c92 100644 --- a/src/web-ui/src/tools/editor/components/MarkdownEditor.tsx +++ b/src/web-ui/src/tools/editor/components/MarkdownEditor.tsx @@ -5,7 +5,7 @@ * @module components/MarkdownEditor */ -import { Button, IconButton } from '@bitfun/ui'; +import { Button, IconButton, SegmentedControl } from '@bitfun/ui'; import React, { useEffect, useState, useCallback, useRef } from 'react'; import { MEditor } from '../meditor'; import type { EditorInstance } from '../meditor'; @@ -652,26 +652,16 @@ const MarkdownEditor: React.FC = ({ return (
-
- - -
+ setUnsafeViewMode(value as 'source' | 'preview')} + />
= ({ return (
-
- - -
+ setViewMode(value as 'preview' | 'markdown')} + />
= ({ )} {!sourceCommit && !targetCommit && ( -
- - -
+ setCurrentShowStaged(value === 'staged')} + /> )} {loading && ( From 6fd86b4af3cf964eb2dd1bbe92c0a76c96ac933c Mon Sep 17 00:00:00 2001 From: kev1n77 Date: Fri, 28 Aug 2026 20:28:49 +0800 Subject: [PATCH 2/9] feat(web-ui): render market account dropdown with design-system Menu Replace the hand-rolled menu surface and menu item in MarketAccountControls with Menu/MenuItem, keep only positioning overrides in SCSS, retire the menu/menuItem appearance parts, and migrate persisted appearance packages. --- .../market-account/MarketAccountControls.scss | 36 +++---------------- .../MarketAccountControls.test.tsx | 4 +++ .../market-account/MarketAccountControls.tsx | 21 ++++------- .../src/features/market-account/appearance.ts | 2 -- .../schema/migrateAppearancePackage.ts | 1 + 5 files changed, 17 insertions(+), 47 deletions(-) diff --git a/src/web-ui/src/features/market-account/MarketAccountControls.scss b/src/web-ui/src/features/market-account/MarketAccountControls.scss index 2e7d8a35e6..320d13cbd9 100644 --- a/src/web-ui/src/features/market-account/MarketAccountControls.scss +++ b/src/web-ui/src/features/market-account/MarketAccountControls.scss @@ -1,5 +1,4 @@ @use '../../component-library/styles/tokens' as *; -@use '../../component-library/styles/overlay-surfaces' as surfaces; .market-account-controls { position: relative; @@ -48,17 +47,14 @@ } } + /* Positioning-only overrides; the design-system Menu owns the surface look. */ &__menu { - @include surfaces.floating-surface; - position: fixed; z-index: $z-popover; - width: max-content; - min-width: min(190px, calc(100vw - 16px)); - max-width: min(230px, calc(100vw - 16px)); - max-height: calc(100vh - 16px); - overflow-y: auto; - padding: $size-gap-2; + inline-size: max-content; + min-inline-size: min(190px, calc(100vw - 16px)); + max-inline-size: min(230px, calc(100vw - 16px)); + max-block-size: calc(100vh - 16px); } &__profile { @@ -94,28 +90,6 @@ } } - &__menu-item { - display: flex; - align-items: center; - gap: $size-gap-2; - width: 100%; - margin-top: $size-gap-1; - padding: $size-gap-2; - border: 0; - border-radius: $size-radius-sm; - background: transparent; - color: var(--bf-appearance-token-color-text-secondary); - font-size: var(--bf-appearance-token-font-size-sm); - text-align: left; - cursor: pointer; - - &:hover, - &:focus-visible { - background: var(--bf-appearance-token-element-bg-subtle); - color: var(--bf-appearance-token-color-text-primary); - } - } - &__spinner { animation: market-account-spin 0.85s linear infinite; } diff --git a/src/web-ui/src/features/market-account/MarketAccountControls.test.tsx b/src/web-ui/src/features/market-account/MarketAccountControls.test.tsx index 38a9370f82..a789f2925d 100644 --- a/src/web-ui/src/features/market-account/MarketAccountControls.test.tsx +++ b/src/web-ui/src/features/market-account/MarketAccountControls.test.tsx @@ -47,6 +47,10 @@ vi.mock('@/shared/notification-system', () => ({ vi.mock('@bitfun/ui', () => ({ Button: ({ children, ...props }: any) => , + Menu: ({ children, ...props }: any) =>
{children}
, + MenuItem: ({ children, leading, ...props }: any) => ( + + ), Modal: ({ isOpen, title, children }: any) => isOpen ? (
{children}
) : null, diff --git a/src/web-ui/src/features/market-account/MarketAccountControls.tsx b/src/web-ui/src/features/market-account/MarketAccountControls.tsx index 602cb15d2e..6d566b2ec1 100644 --- a/src/web-ui/src/features/market-account/MarketAccountControls.tsx +++ b/src/web-ui/src/features/market-account/MarketAccountControls.tsx @@ -1,4 +1,4 @@ -import { Button, Modal } from '@bitfun/ui'; +import { Button, Menu, MenuItem, Modal } from '@bitfun/ui'; import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'; import { createPortal } from 'react-dom'; import { ChevronDown, Github, Loader2, LogOut } from 'lucide-react'; @@ -181,12 +181,10 @@ export function MarketAccountControls({
- -
, + + , getAppearanceOverlayHost(), )}
diff --git a/src/web-ui/src/features/market-account/appearance.ts b/src/web-ui/src/features/market-account/appearance.ts index 146fbab90c..0b208ee115 100644 --- a/src/web-ui/src/features/market-account/appearance.ts +++ b/src/web-ui/src/features/market-account/appearance.ts @@ -5,9 +5,7 @@ export const marketAccountControlsAppearanceDescriptor: AppearanceSurfaceDescrip parts: [ { id: 'root' }, { id: 'identityTrigger' }, - { id: 'menu' }, { id: 'profile' }, - { id: 'menuItem' }, { id: 'login' }, { id: 'waiting' }, { id: 'error' }, diff --git a/src/web-ui/src/infrastructure/appearance/schema/migrateAppearancePackage.ts b/src/web-ui/src/infrastructure/appearance/schema/migrateAppearancePackage.ts index 618afbfa74..445ebf9909 100644 --- a/src/web-ui/src/infrastructure/appearance/schema/migrateAppearancePackage.ts +++ b/src/web-ui/src/infrastructure/appearance/schema/migrateAppearancePackage.ts @@ -35,6 +35,7 @@ const RETIRED_COMPONENT_PARTS: Readonly>> = { 'git-diff-view': new Set(['typeSwitcher', 'typeOption']), 'image-analysis-card': new Set(['expand']), 'markdown-editor': new Set(['modeToggle']), + 'market-account-controls': new Set(['menu', 'menuItem']), 'mini-app-tool-display': new Set(['open']), 'peer-device': new Set(['switcherDisconnect']), 'review-session-summary-card': new Set(['open']), From 08996e59110391835800d3d9ca364227e29bfa17 Mon Sep 17 00:00:00 2001 From: kev1n77 Date: Fri, 28 Aug 2026 20:29:11 +0800 Subject: [PATCH 3/9] feat(web-ui): rebuild image viewer toolbar on design-system Toolbar Compose the image viewer header from Toolbar/ToolbarGroup/ToolbarSeparator with IconButton and Button controls, drop the bespoke toolbar button styles, retire the toolbar/controls/action appearance parts, and migrate persisted appearance packages. --- .../schema/migrateAppearancePackage.ts | 1 + .../components/ImageViewer.appearance.ts | 4 +- .../tools/editor/components/ImageViewer.scss | 62 +------ .../tools/editor/components/ImageViewer.tsx | 171 +++++++++--------- 4 files changed, 94 insertions(+), 144 deletions(-) diff --git a/src/web-ui/src/infrastructure/appearance/schema/migrateAppearancePackage.ts b/src/web-ui/src/infrastructure/appearance/schema/migrateAppearancePackage.ts index 445ebf9909..a59c778c1f 100644 --- a/src/web-ui/src/infrastructure/appearance/schema/migrateAppearancePackage.ts +++ b/src/web-ui/src/infrastructure/appearance/schema/migrateAppearancePackage.ts @@ -34,6 +34,7 @@ const RETIRED_COMPONENT_PARTS: Readonly>> = { 'font-preference': new Set(['resetButton', 'levelGroup', 'levelButton']), 'git-diff-view': new Set(['typeSwitcher', 'typeOption']), 'image-analysis-card': new Set(['expand']), + 'image-viewer': new Set(['toolbar', 'controls', 'action']), 'markdown-editor': new Set(['modeToggle']), 'market-account-controls': new Set(['menu', 'menuItem']), 'mini-app-tool-display': new Set(['open']), diff --git a/src/web-ui/src/tools/editor/components/ImageViewer.appearance.ts b/src/web-ui/src/tools/editor/components/ImageViewer.appearance.ts index 04e4117956..8a5a432da6 100644 --- a/src/web-ui/src/tools/editor/components/ImageViewer.appearance.ts +++ b/src/web-ui/src/tools/editor/components/ImageViewer.appearance.ts @@ -2,8 +2,8 @@ import type { AppearanceSurfaceDescriptor } from '@/infrastructure/appearance'; export const imageViewerAppearanceDescriptor: AppearanceSurfaceDescriptor = { id: 'image-viewer', parts: [ - { id: 'root' }, { id: 'toolbar' }, { id: 'info' }, { id: 'controls' }, - { id: 'action' }, { id: 'container' }, { id: 'loading' }, { id: 'error' }, + { id: 'root' }, { id: 'info' }, + { id: 'container' }, { id: 'loading' }, { id: 'error' }, { id: 'imageWrapper' }, { id: 'image' }, ], states: [{ id: 'fullscreen', selector: { kind: 'self', suffix: '[data-bf-state~="fullscreen"]' } }], diff --git a/src/web-ui/src/tools/editor/components/ImageViewer.scss b/src/web-ui/src/tools/editor/components/ImageViewer.scss index 5f9e70e805..4fd4de2570 100644 --- a/src/web-ui/src/tools/editor/components/ImageViewer.scss +++ b/src/web-ui/src/tools/editor/components/ImageViewer.scss @@ -24,15 +24,8 @@ background: var(--bf-appearance-token-color-bg-primary); } - // Toolbar + // Toolbar layout comes from the design-system Toolbar; keep it pinned to the top. &__toolbar { - display: flex; - align-items: center; - gap: $size-gap-2; - padding: $size-gap-2 $size-gap-3; - background: var(--bf-appearance-token-color-bg-primary); - border-bottom: 1px solid var(--bf-appearance-token-border-base); - min-height: 32px; flex-shrink: 0; } @@ -60,57 +53,8 @@ white-space: nowrap; } - &__controls { - display: flex; - align-items: center; - gap: $size-gap-2; - flex-shrink: 0; - margin-left: auto; - } - - // Button Styles - &__btn { - display: flex; - align-items: center; - justify-content: center; - width: 24px; - height: 24px; - padding: 0; - background: transparent; - border: none; - color: var(--bf-appearance-token-color-text-secondary); - cursor: pointer; - transition: color $motion-base $easing-standard; - - &:hover:not(:disabled) { - color: var(--bf-appearance-token-color-text-primary); - } - - &:disabled { - opacity: $opacity-disabled; - cursor: default; - } - - svg { - width: 14px; - height: 14px; - flex-shrink: 0; - } - - &--zoom-display { - width: auto; - min-width: 40px; - padding: 0 $size-gap-1; - font-size: var(--bf-appearance-token-font-size-xs); - font-weight: $font-weight-medium; - } - } - - &__divider { - width: 1px; - height: 20px; - background: var(--bf-appearance-token-border-base); - margin: 0 $size-gap-1; + &__zoom-display { + min-width: 40px; } // Image Container diff --git a/src/web-ui/src/tools/editor/components/ImageViewer.tsx b/src/web-ui/src/tools/editor/components/ImageViewer.tsx index da67e09de3..91a92b8a85 100644 --- a/src/web-ui/src/tools/editor/components/ImageViewer.tsx +++ b/src/web-ui/src/tools/editor/components/ImageViewer.tsx @@ -7,6 +7,7 @@ import React, { useState, useEffect, useCallback } from 'react'; import { ZoomIn, ZoomOut, RotateCw, Download, Maximize2 } from 'lucide-react'; +import { Button, IconButton, Toolbar, ToolbarGroup, ToolbarSeparator } from '@bitfun/ui'; import { createLogger } from '@/shared/utils/logger'; import { Tooltip } from '@/component-library'; import { useI18n } from '@/infrastructure/i18n'; @@ -153,89 +154,93 @@ export const ImageViewer: React.FC = ({ data-bf-part="root" data-bf-state={isFullscreen ? 'fullscreen' : undefined} > -
-
- {fileName || filePath.split(/[/\\]/).pop()} - {imageDimensions && ( - - {imageDimensions.width} × {imageDimensions.height} - - )} - {fileSize > 0 && ( - - {formatFileSize(fileSize)} - - )} -
-
- - - - - - - - - -
- - - - - - - - - -
-
+ + {fileName || filePath.split(/[/\\]/).pop()} + {imageDimensions && ( + + {imageDimensions.width} × {imageDimensions.height} + + )} + {fileSize > 0 && ( + + {formatFileSize(fileSize)} + + )} +
+ } + trailing={ + <> + + + } + onClick={handleZoomOut} + disabled={zoom <= 25} + /> + + + + + + } + onClick={handleZoomIn} + disabled={zoom >= 500} + /> + + + + + + } + onClick={handleRotate} + /> + + + } + onClick={handleDownload} + /> + + + } + onClick={handleToggleFullscreen} + /> + + + + } + />
{loading && ( From 4433fb127679e586c2c1d5ca31ca1bdec545e7c5 Mon Sep 17 00:00:00 2001 From: kev1n77 Date: Fri, 28 Aug 2026 21:13:38 +0800 Subject: [PATCH 4/9] feat(design-system): add danger tone to ActionItem for destructive menu rows Destructive actions such as close/delete/reset need a semantic danger treatment inside Menu lists. Expose tone=neutral|danger on ActionItem (inherited by MenuItem), style it with the shared status danger tokens, and cover the contract in tests. --- .../src/components/ActionItem/ActionItem.meta.ts | 5 ++++- .../components/ActionItem/ActionItem.module.css | 14 ++++++++++++++ .../ui/src/components/ActionItem/ActionItem.tsx | 5 +++++ .../packages/ui/src/components/ActionItem/index.ts | 1 + design-system/packages/ui/src/index.ts | 1 + .../packages/ui/tests/action-item.test.mjs | 12 ++++++++++++ 6 files changed, 37 insertions(+), 1 deletion(-) diff --git a/design-system/packages/ui/src/components/ActionItem/ActionItem.meta.ts b/design-system/packages/ui/src/components/ActionItem/ActionItem.meta.ts index 8c9c90cf13..06b9fe7043 100644 --- a/design-system/packages/ui/src/components/ActionItem/ActionItem.meta.ts +++ b/design-system/packages/ui/src/components/ActionItem/ActionItem.meta.ts @@ -13,8 +13,9 @@ export const actionItemMeta = { { name: "shortcut", type: "ReactNode" }, { defaultValue: "[]", name: "actions", type: "readonly ActionItemAction[]" }, { defaultValue: "false", name: "disabled", type: "boolean" }, + { defaultValue: "neutral", name: "tone", type: "neutral | danger" }, ], - states: ["default", "hover", "active", "focus-visible", "disabled"], + states: ["default", "hover", "active", "focus-visible", "disabled", "danger"], tokens: [ "color.action.neutral.content", "color.action.neutral.contentDisabled", @@ -22,6 +23,8 @@ export const actionItemMeta = { "color.action.neutral.surfacePressed", "color.content.muted", "color.focus.ring", + "color.status.danger.content", + "color.status.danger.surface", "control.height.sm", "font.family.control", "font.size.small", diff --git a/design-system/packages/ui/src/components/ActionItem/ActionItem.module.css b/design-system/packages/ui/src/components/ActionItem/ActionItem.module.css index 71f05faf6c..334adba05d 100644 --- a/design-system/packages/ui/src/components/ActionItem/ActionItem.module.css +++ b/design-system/packages/ui/src/components/ActionItem/ActionItem.module.css @@ -38,6 +38,20 @@ cursor: not-allowed; } + .root[data-bf-tone="danger"]:not([data-disabled="true"]) { + color: var(--bf-color-status-danger-content); + } + + .root[data-bf-tone="danger"]:not([data-disabled="true"]):hover, + .root[data-bf-tone="danger"]:not([data-disabled="true"]):has(.trigger[data-bf-preview-state="hover"]) { + background: var(--bf-color-status-danger-surface); + } + + .root[data-bf-tone="danger"]:not([data-disabled="true"]):has(.trigger:active), + .root[data-bf-tone="danger"]:not([data-disabled="true"]):has(.trigger[data-bf-preview-state="active"]) { + background: var(--bf-color-status-danger-surface); + } + .trigger { display: inline-flex; flex: 1 1 auto; diff --git a/design-system/packages/ui/src/components/ActionItem/ActionItem.tsx b/design-system/packages/ui/src/components/ActionItem/ActionItem.tsx index dbcea44a8b..4489219504 100644 --- a/design-system/packages/ui/src/components/ActionItem/ActionItem.tsx +++ b/design-system/packages/ui/src/components/ActionItem/ActionItem.tsx @@ -17,6 +17,8 @@ export interface ActionItemAction { tone?: IconButtonProps["tone"]; } +export type ActionItemTone = "neutral" | "danger"; + export interface ActionItemProps extends Omit, "children" | "className"> { actions?: readonly ActionItemAction[]; @@ -26,6 +28,7 @@ export interface ActionItemProps metadata?: ReactNode; reserveLeadingSpace?: boolean; shortcut?: ReactNode; + tone?: ActionItemTone; triggerClassName?: string; } @@ -38,6 +41,7 @@ export const ActionItem = forwardRef(functio metadata, reserveLeadingSpace = false, shortcut, + tone = "neutral", triggerClassName, type = "button", ...props @@ -48,6 +52,7 @@ export const ActionItem = forwardRef(functio
{menuOpen ? createPortal( -
= ({ {orderedAssistants.map(workspace => { const assistantName = getAssistantDisplayName(workspace); return ( - + ); })} -
, + , getAppearanceOverlayHost(), ) : null}
diff --git a/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceItem.appearance.ts b/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceItem.appearance.ts index 1fbc2b7d7d..e3925e6242 100644 --- a/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceItem.appearance.ts +++ b/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceItem.appearance.ts @@ -5,8 +5,8 @@ export const workspaceItemAppearanceDescriptor: AppearanceSurfaceDescriptor = { parts: [ { id: 'root' }, { id: 'card' }, { id: 'collapse' }, { id: 'icon' }, { id: 'name' }, { id: 'label' }, { id: 'badge' }, { id: 'action' }, - { id: 'menu' }, { id: 'menuTrigger' }, { id: 'menuPopover' }, { id: 'menuItem' }, - { id: 'menuDivider' }, { id: 'sessions' }, { id: 'remoteStatus' }, + { id: 'menu' }, { id: 'menuTrigger' }, + { id: 'sessions' }, { id: 'remoteStatus' }, { id: 'indexIndicator' }, { id: 'indexPanel' }, ], facets: [{ id: 'variant', attribute: 'data-bf-variant', values: ['workspace', 'assistant'] }], diff --git a/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceItem.tsx b/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceItem.tsx index 0a774b5d0d..194be2d566 100644 --- a/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceItem.tsx +++ b/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceItem.tsx @@ -1,4 +1,4 @@ -import { Button, Modal, ConfirmDialog } from '@bitfun/ui'; +import { Button, Modal, ConfirmDialog, Menu, MenuItem, MenuSeparator } from '@bitfun/ui'; import React, { lazy, Suspense, useCallback, useContext, useEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react'; import { createPortal } from 'react-dom'; import { Folder, FolderOpen, MoreHorizontal, FolderSearch, Plus, ChevronDown, Trash2, RotateCcw, Copy, FileText, Bot, Link2, ListChecks, Loader2, Clock3, ShieldCheck, Pencil, Network } from 'lucide-react'; @@ -903,125 +903,84 @@ const WorkspaceItem: React.FC = ({
{menuOpen && menuPosition && createPortal( -
- - - - {portForwardConnectionId ? ( - + {t('ssh.portForward.menuEntry')} + ) : null} -
- - + + } + onClick={() => { void handleCopyWorkspacePath(); }} + disabled={!workspace.rootPath} + data-testid="nav-workspace-menu-copy-path" + > + {t('nav.workspaces.actions.copyPath')} + + } + onClick={() => { void handleReveal(); }} + disabled={isRemoteWorkspace(workspace)} + data-testid="nav-workspace-menu-reveal" + > + {t('nav.workspaces.actions.reveal')} + {(isDefaultAssistantWorkspace || isDeletableAssistantWorkspace) ? ( <> -
+ {isDefaultAssistantWorkspace ? ( - + {t('nav.workspaces.actions.resetWorkspace')} + ) : null} {isDeletableAssistantWorkspace ? ( - + {t('nav.workspaces.actions.deleteAssistant')} + ) : null} ) : null} -
, + , getAppearanceOverlayHost() )}
@@ -1386,186 +1345,112 @@ const WorkspaceItem: React.FC = ({
{menuOpen && menuPosition && createPortal( -
- + {t('nav.sessions.newSession')} + {acpClients.map(client => { const label = client.name || client.id; return ( - + {t('nav.sessions.newExternalAgentSessionShort', { agentName: label })} + ); })} {acpClientsLoading ? ( - + } disabled> + {t('app.loading')} + ) : null} - - - - + {t('nav.workspaces.actions.manageProjectPermissions')} + + } onClick={handleOpenScheduledJobs}> + {t('nav.scheduledJobs.open')} + {portForwardConnectionId ? ( - + {t('ssh.portForward.menuEntry')} + ) : null} -
- - - -
- - -
, + {t('nav.workspaces.actions.close')} + + , getAppearanceOverlayHost() )}
diff --git a/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceListSection.scss b/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceListSection.scss index fd0820b57f..f8a6f915cf 100644 --- a/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceListSection.scss +++ b/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceListSection.scss @@ -1,5 +1,4 @@ @use '../../../../../component-library/styles/tokens.scss' as *; -@use '../../../../../component-library/styles/overlay-surfaces' as surfaces; @use '../../../../styles/nav-panel-font-scope.scss' as nav-font; @use '../../../../styles/workspace-shell-surfaces' as shell-surfaces; @@ -957,87 +956,20 @@ } } + /* Positioning-only overrides; the design-system Menu owns the surface look. */ &__workspace-item-menu-popover { @include nav-font.nav-panel-font-token-scope; - @include surfaces.floating-surface; position: fixed; - width: max-content; - min-width: 180px; - max-width: min(320px, calc(100vw - 24px)); - max-height: min(560px, calc(100vh - 16px)); - overflow-x: hidden; - overflow-y: auto; - padding: $size-gap-1 0; + inline-size: max-content; + min-inline-size: 180px; + max-inline-size: min(320px, calc(100vw - 24px)); + max-block-size: min(560px, calc(100vh - 16px)); z-index: 10000; transform-origin: top left; animation: bitfun-footer-menu-in $motion-fast $easing-decelerate forwards; } - /* Match `.bitfun-nav-panel__footer-menu-divider` (PersistentFooterActions more menu) */ - &__workspace-item-menu-divider { - height: 1px; - margin: $size-gap-1 $size-gap-2; - background: var(--bf-appearance-token-border-subtle); - } - - &__workspace-item-menu-item { - display: flex; - align-items: center; - gap: $size-gap-2; - width: 100%; - min-height: 30px; - padding: 0 $size-gap-2; - border: none; - border-radius: $size-radius-sm; - background: transparent; - color: var(--bf-appearance-token-color-text-secondary); - font-size: var(--bf-appearance-token-font-size-sm); - font-weight: 400; - cursor: pointer; - text-align: left; - white-space: nowrap; - transition: color $motion-fast $easing-standard, - background $motion-fast $easing-standard; - - svg { - flex-shrink: 0; - opacity: 0.7; - transition: opacity $motion-fast $easing-standard; - } - - &:hover:not(:disabled) { - color: var(--bf-appearance-token-color-text-primary); - background: var(--bf-appearance-token-element-bg-soft); - - svg { - opacity: 1; - } - } - - &:active:not(:disabled) { - background: var(--bf-appearance-token-element-bg-medium); - } - - &:disabled { - opacity: 0.45; - cursor: not-allowed; - } - - &.is-danger:hover:not(:disabled) { - color: var(--bf-appearance-token-color-error); - background: color-mix(in srgb, var(--bf-appearance-token-color-error) 10%, transparent); - } - } - - &__workspace-item-menu-label { - flex: 1; - text-align: left; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - } - &__workspace-item-sessions { position: relative; z-index: 0; @@ -1530,22 +1462,9 @@ } &__workspace-item-menu-popover { - padding: $size-gap-1; transform-origin: top left; animation: bitfun-workspace-popover-in 150ms cubic-bezier(0.23, 1, 0.32, 1) both; } - - &__workspace-item-menu-item { - border-radius: $size-radius-sm; - transition: - transform 120ms cubic-bezier(0.23, 1, 0.32, 1), - background 120ms ease, - color 120ms ease; - - &:active:not(:disabled) { - transform: scale(0.985); - } - } } @media (hover: hover) and (pointer: fine) { diff --git a/src/web-ui/src/app/scenes/miniapps/views/MiniAppGalleryView.scss b/src/web-ui/src/app/scenes/miniapps/views/MiniAppGalleryView.scss index b15844afff..d41dd2ce8e 100644 --- a/src/web-ui/src/app/scenes/miniapps/views/MiniAppGalleryView.scss +++ b/src/web-ui/src/app/scenes/miniapps/views/MiniAppGalleryView.scss @@ -1,5 +1,4 @@ @use '../../../../component-library/styles/tokens' as *; -@use '../../../../component-library/styles/overlay-surfaces' as surfaces; .miniapp-gallery { background: var(--bf-appearance-token-color-bg-scene); @@ -97,46 +96,11 @@ } +/* Positioning-only overrides; the design-system Menu owns the surface look. */ .miniapp-gallery__import-menu { - @include surfaces.floating-surface; - position: fixed; z-index: 10020; - display: grid; - gap: 2px; - min-width: 196px; - padding: $size-gap-1; -} - -.miniapp-gallery__import-menu-item { - display: flex; - align-items: center; - gap: $size-gap-2; - width: 100%; - min-height: 34px; - padding: 0 $size-gap-3; - border: 0; - border-radius: $size-radius-base; - background: transparent; - color: var(--bf-appearance-token-color-text-secondary); - font: inherit; - font-size: var(--bf-appearance-token-font-size-xs); - text-align: left; - cursor: pointer; - transition: - background-color $motion-fast $easing-standard, - color $motion-fast $easing-standard; - - &:hover, - &:focus-visible { - background: var(--bf-appearance-token-element-bg-soft); - color: var(--bf-appearance-token-color-text-primary); - } - - &:focus-visible { - outline: 2px solid var(--bf-appearance-token-color-accent-500); - outline-offset: -2px; - } + min-inline-size: 196px; } @media (max-width: 720px) { diff --git a/src/web-ui/src/app/scenes/miniapps/views/MiniAppGalleryView.tsx b/src/web-ui/src/app/scenes/miniapps/views/MiniAppGalleryView.tsx index e9ed4f9e25..41907dfdf7 100644 --- a/src/web-ui/src/app/scenes/miniapps/views/MiniAppGalleryView.tsx +++ b/src/web-ui/src/app/scenes/miniapps/views/MiniAppGalleryView.tsx @@ -23,7 +23,7 @@ import { type MarketPackageInspection, } from '@/infrastructure/api/service-api/MiniAppMarketAPI'; import { createLogger } from '@/shared/utils/logger'; -import { ConfirmDialog, SearchField, IconButton } from '@bitfun/ui'; +import { ConfirmDialog, SearchField, IconButton, Menu, MenuItem } from '@bitfun/ui'; import { GalleryEmpty, @@ -438,46 +438,38 @@ const MiniAppGalleryView: React.FC = () => { icon={} /> {importMenuOpen ? createPortal( -
- - -
, + {t('market.import.action')} + + , getAppearanceOverlayHost(), ) : null} diff --git a/src/web-ui/src/component-library/styles/overlay-surfaces.contract.test.ts b/src/web-ui/src/component-library/styles/overlay-surfaces.contract.test.ts index 98689bd13f..ffad10f3ee 100644 --- a/src/web-ui/src/component-library/styles/overlay-surfaces.contract.test.ts +++ b/src/web-ui/src/component-library/styles/overlay-surfaces.contract.test.ts @@ -9,8 +9,6 @@ const readSource = (path: string): string => readFileSync(join(SOURCE_ROOT, path const readRepositorySource = (path: string): string => readFileSync(join(REPOSITORY_ROOT, path), 'utf8'); const portalStyleOverrides: Record = { - 'app/components/NavPanel/components/AssistantSessionCreateMenu.tsx': - 'app/components/NavPanel/NavPanel.scss', 'app/components/NavPanel/components/DeviceStatusControl.tsx': 'app/components/NavPanel/NavPanel.scss', 'app/components/NavPanel/components/PersistentFooterActions.tsx': @@ -18,8 +16,6 @@ const portalStyleOverrides: Record = { 'app/components/NavPanel/components/WorkspaceSessionFilterMenu.tsx': 'app/components/NavPanel/sections/sessions/SessionsSection.scss', 'app/components/NavPanel/MainNav.tsx': 'app/components/NavPanel/NavPanel.scss', - 'app/components/NavPanel/sections/workspaces/WorkspaceItem.tsx': - 'app/components/NavPanel/sections/workspaces/WorkspaceListSection.scss', 'app/components/scheduled-jobs/DateTimePickerPopover.tsx': 'app/components/scheduled-jobs/LocalizedDateTimeField.scss', 'app/scenes/profile/views/AssistantAvatarPicker.tsx': @@ -29,6 +25,12 @@ const portalStyleOverrides: Record = { 'flow_chat/components/WelcomePanel.tsx': 'flow_chat/components/WelcomePanelSurface.scss', }; +// Portals whose chrome is owned by a design-system surface component satisfy +// the contract without a floating-surface/dialog-surface SCSS include. +const rendersDesignSystemSurface = (source: string): boolean => + /import\s*\{[^}]*\b(?:Menu|Modal|ConfirmDialog)\b[^}]*\}\s*from\s*'@bitfun\/ui'/.test(source) + && /createPortal\(\s*<(?:Menu|Modal|ConfirmDialog)\b/.test(source); + const portalSurfaceExceptions: Record = { 'app/components/panels/DiffFullscreenViewer.tsx': 'fullscreen viewer', 'component-library/components/Tooltip/Tooltip.tsx': 'tooltip primitive', @@ -270,6 +272,10 @@ describe('overlay surface contracts', () => { continue; } + if (rendersDesignSystemSurface(readSource(sourcePath))) { + continue; + } + const stylePath = resolvePortalStylePath(sourcePath); expect(stylePath, `${sourcePath} must resolve to an overlay stylesheet`).toBeDefined(); const styleSource = readSource(stylePath!); diff --git a/src/web-ui/src/infrastructure/appearance/schema/migrateAppearancePackage.ts b/src/web-ui/src/infrastructure/appearance/schema/migrateAppearancePackage.ts index a59c778c1f..8891fc547a 100644 --- a/src/web-ui/src/infrastructure/appearance/schema/migrateAppearancePackage.ts +++ b/src/web-ui/src/infrastructure/appearance/schema/migrateAppearancePackage.ts @@ -38,6 +38,8 @@ const RETIRED_COMPONENT_PARTS: Readonly>> = { 'markdown-editor': new Set(['modeToggle']), 'market-account-controls': new Set(['menu', 'menuItem']), 'mini-app-tool-display': new Set(['open']), + 'nav-panel': new Set(['assistantSessionMenu']), + 'workspace-item': new Set(['menuPopover', 'menuItem', 'menuDivider']), 'peer-device': new Set(['switcherDisconnect']), 'review-session-summary-card': new Set(['open']), 'sessions-section': new Set(['retry', 'aggregateRetry']), From b19b6d31cc456a4bb3382b2570f5e845d0aa3f43 Mon Sep 17 00:00:00 2001 From: kev1n77 Date: Sat, 29 Aug 2026 09:36:26 +0800 Subject: [PATCH 6/9] feat(web-ui): migrate nav footer and workspace switcher menus to design-system Menu --- .../src/app/components/NavPanel/MainNav.tsx | 150 +++++++--------- .../src/app/components/NavPanel/NavPanel.scss | 169 +----------------- .../src/app/components/NavPanel/appearance.ts | 10 +- .../components/PersistentFooterActions.tsx | 69 +++---- .../TitleBar/NotificationButton.appearance.ts | 2 +- .../TitleBar/NotificationButton.tsx | 29 ++- src/web-ui/src/app/scenes/shell/ShellNav.scss | 32 +--- .../components/ShellNavWorkspaceSwitcher.tsx | 23 +-- .../schema/migrateAppearancePackage.ts | 7 +- 9 files changed, 124 insertions(+), 367 deletions(-) diff --git a/src/web-ui/src/app/components/NavPanel/MainNav.tsx b/src/web-ui/src/app/components/NavPanel/MainNav.tsx index be380cfb9e..671a03aef7 100644 --- a/src/web-ui/src/app/components/NavPanel/MainNav.tsx +++ b/src/web-ui/src/app/components/NavPanel/MainNav.tsx @@ -12,7 +12,7 @@ import React, { useCallback, useState, useMemo, useEffect, useRef, useSyncExternalStore } from 'react'; import { createPortal } from 'react-dom'; -import { KeyHint } from '@bitfun/ui'; +import { KeyHint, Menu, MenuItem, MenuSection, MenuSeparator } from '@bitfun/ui'; import { getAppearanceOverlayHost } from '@/infrastructure/appearance/runtime/AppearanceOverlayHost'; import { isImeOwnedKeyboardEvent } from '@/shared/utils/ime'; import { FolderOpen, FolderPlus, History, Check, User, Users, Puzzle, ChevronDown, Network, Search, CalendarClock } from 'lucide-react'; @@ -276,105 +276,87 @@ const MainNav: React.FC = ({ }, [isAgentsActive, isEcosystemCompatibilityActive, isSkillsActive]); const workspaceMenuPortal = workspaceMenuOpen ? createPortal( -
- - - - -
-
-
- {recentWorkspaces.length === 0 ? ( -
- {t('header.noRecentWorkspaces')} -
- ) : ( -
- {recentWorkspaces.map((workspace) => { + {t('ssh.remote.connect')} + + + +
- )} -
, + }) + )} + + , getAppearanceOverlayHost() ) : null; diff --git a/src/web-ui/src/app/components/NavPanel/NavPanel.scss b/src/web-ui/src/app/components/NavPanel/NavPanel.scss index c82861c849..5b724fb5af 100644 --- a/src/web-ui/src/app/components/NavPanel/NavPanel.scss +++ b/src/web-ui/src/app/components/NavPanel/NavPanel.scss @@ -1110,21 +1110,15 @@ $_section-header-height: 22px; position: relative; } + /* Positioning-only overrides; the design-system Menu owns the surface look. */ &__workspace-menu { @include nav-font.nav-panel-font-token-scope; - @include surfaces.floating-surface; position: fixed; /* Shrink-wrap to widest row; cap width for long paths + viewport on small screens */ - display: inline-block; width: fit-content; max-width: min(440px, calc(100vw - 32px)); max-height: min(560px, calc(100vh - 16px)); - overflow-x: hidden; - overflow-y: auto; - vertical-align: top; - box-sizing: border-box; - padding: $size-gap-1 0; z-index: 9999; transform-origin: top left; animation: bitfun-footer-menu-in $motion-fast $easing-decelerate forwards; @@ -1134,59 +1128,13 @@ $_section-header-height: 22px; } } - &__workspace-menu-item { - display: flex; - align-items: center; - gap: $size-gap-2; - width: 100%; - padding: $size-gap-2 $size-gap-3; - border: none; - background: transparent; - color: var(--bf-appearance-token-color-text-secondary); - cursor: pointer; - font-size: var(--bf-appearance-token-font-size-sm); - text-align: left; - transition: color $motion-fast $easing-standard, - background $motion-fast $easing-standard; - - &:hover:not(:disabled):not(.is-disabled) { - color: var(--bf-appearance-token-color-text-primary); - background: var(--bf-appearance-token-element-bg-soft); - } - - &.is-disabled, - &:disabled { - opacity: 0.45; - cursor: not-allowed; - color: var(--bf-appearance-token-color-text-muted); - } - - svg { - flex-shrink: 0; - } - - /* Only the label row grows; nested spans inside --main must NOT get flex:1 or text splits across columns */ - > span { - flex: 1; - min-width: 0; - } - - &--workspace { - padding-right: $size-gap-2; - } - } - &__workspace-menu-item-main { - min-width: 0; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - } - - &__workspace-menu-item--workspace &__workspace-menu-item-main { display: flex; align-items: baseline; gap: 0.3em; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; white-space: nowrap; } @@ -1214,23 +1162,6 @@ $_section-header-height: 22px; text-overflow: ellipsis; } - &__workspace-menu-divider { - height: 1px; - margin: $size-gap-1 0; - background: var(--bf-appearance-token-border-subtle); - } - - &__workspace-menu-section-title { - display: flex; - align-items: center; - gap: $size-gap-1; - padding: $size-gap-1 $size-gap-3; - color: var(--bf-appearance-token-color-text-muted); - font-size: var(--bf-appearance-token-font-size-xs); - text-transform: uppercase; - letter-spacing: 0.04em; - } - &__workspace-menu-empty { padding: $size-gap-2 $size-gap-3; color: var(--bf-appearance-token-color-text-muted); @@ -2011,19 +1942,13 @@ $_section-header-height: 22px; z-index: 9998; } +/* Positioning-only overrides; the design-system Menu owns the surface look. */ .bitfun-nav-panel__footer-menu { - @include surfaces.floating-surface; - position: fixed; min-width: 148px; max-width: calc(100vw - 16px); max-height: calc(100vh - 16px); - overflow-y: auto; - padding: $size-gap-1; z-index: 9999; - color: var(--bf-appearance-token-color-text-primary); - font-family: var(--bf-appearance-token-font-family-sans); - font-size: var(--bf-appearance-token-font-size-sm); transform-origin: bottom right; animation: bitfun-footer-menu-in $motion-fast $easing-decelerate forwards; @@ -2364,88 +2289,6 @@ $_section-header-height: 22px; padding: 0 4px; } -.bitfun-nav-panel__footer-menu-divider { - height: 1px; - margin: $size-gap-1 $size-gap-2; - background: var(--bf-appearance-token-border-subtle); -} - -.bitfun-nav-panel__footer-menu-item { - display: flex; - align-items: center; - gap: $size-gap-2; - width: 100%; - padding: 0 $size-gap-2; - height: 30px; - border: none; - border-radius: $size-radius-sm; - background: transparent; - color: var(--bf-appearance-token-color-text-secondary); - cursor: pointer; - font-size: var(--bf-appearance-token-font-size-sm); - font-weight: 400; - text-align: left; - white-space: nowrap; - transition: color $motion-fast $easing-standard, - background $motion-fast $easing-standard; - - svg { - flex-shrink: 0; - opacity: 0.7; - transition: opacity $motion-fast $easing-standard; - } - - &:hover { - color: var(--bf-appearance-token-color-text-primary); - background: var(--bf-appearance-token-element-bg-soft); - - svg { - opacity: 1; - } - } - - &:active { - background: var(--bf-appearance-token-element-bg-medium); - } - - &:focus-visible { - outline: 2px solid var(--bf-appearance-token-color-accent-500); - outline-offset: -1px; - } - - &.is-disabled, - &:disabled { - opacity: 0.4; - cursor: not-allowed; - pointer-events: auto; - - &:hover { - color: var(--bf-appearance-token-color-text-secondary); - background: transparent; - - svg { - opacity: 0.7; - } - } - } -} - -.bitfun-nav-panel__footer-menu-item-label { - flex: 1; - min-width: 0; - max-width: 160px; - overflow: hidden; - text-overflow: ellipsis; -} - -.bitfun-nav-panel__footer-menu-item-dot { - flex-shrink: 0; - width: 7px; - height: 7px; - border-radius: 50%; - background: var(--bf-appearance-token-color-success); -} - .bitfun-nav-panel__remote-disclaimer { display: flex; flex-direction: column; @@ -3350,9 +3193,7 @@ $_section-header-height: 22px; animation: bitfun-nav-centered-popover-in 150ms cubic-bezier(0.23, 1, 0.32, 1) both; } -.bitfun-nav-panel__workspace-menu-item, .bitfun-nav-panel__mode-dropdown-item, -.bitfun-nav-panel__footer-menu-item, .bitfun-nav-panel__footer-multimodal-item { border-radius: $size-radius-sm; transition: diff --git a/src/web-ui/src/app/components/NavPanel/appearance.ts b/src/web-ui/src/app/components/NavPanel/appearance.ts index c8d332310c..cb88637afd 100644 --- a/src/web-ui/src/app/components/NavPanel/appearance.ts +++ b/src/web-ui/src/app/components/NavPanel/appearance.ts @@ -27,18 +27,10 @@ export const navPanelAppearanceDescriptor: AppearanceSurfaceDescriptor = { { id: 'deviceStatus', propertyProfile: 'control', visualRole: 'control' }, { id: 'settingsEntry', propertyProfile: 'control', visualRole: 'control' }, { id: 'footerButton', propertyProfile: 'control', visualRole: 'control' }, - { id: 'footerMenu', propertyProfile: 'overlay', visualRole: 'popup' }, - { id: 'footerMenuItem', propertyProfile: 'control', visualRole: 'control' }, - { id: 'footerMenuDivider', propertyProfile: 'paint', visualRole: 'divider' }, - { id: 'workspaceMenu', propertyProfile: 'overlay', visualRole: 'popup' }, - { id: 'workspaceMenuItem', propertyProfile: 'control', visualRole: 'control' }, - { id: 'workspaceMenuDivider', propertyProfile: 'paint', visualRole: 'divider' }, - { id: 'workspaceMenuTitle', propertyProfile: 'paint', visualRole: 'content' }, - { id: 'workspaceMenuEmpty', visualRole: 'content' }, ], facets: [ { id: 'layer', attribute: 'data-bf-layer', values: ['main', 'scene'] }, - { id: 'action', attribute: 'data-bf-action', values: ['new-session', 'smart-members', 'long-term-tracking', 'todos', 'extensions', 'agents', 'skills', 'ecosystem-compatibility', 'session-filter', 'assistant-manager', 'floating-window', 'appearance-configuration', 'open-settings', 'about'] }, + { id: 'action', attribute: 'data-bf-action', values: ['new-session', 'smart-members', 'long-term-tracking', 'todos', 'extensions', 'agents', 'skills', 'ecosystem-compatibility', 'session-filter', 'assistant-manager'] }, { id: 'section', attribute: 'data-bf-section', values: ['smart-members', 'workspace', 'sessions'] }, ], states: [ diff --git a/src/web-ui/src/app/components/NavPanel/components/PersistentFooterActions.tsx b/src/web-ui/src/app/components/NavPanel/components/PersistentFooterActions.tsx index b407969376..023fe204cf 100644 --- a/src/web-ui/src/app/components/NavPanel/components/PersistentFooterActions.tsx +++ b/src/web-ui/src/app/components/NavPanel/components/PersistentFooterActions.tsx @@ -6,7 +6,7 @@ import { PictureInPicture2, Palette, } from 'lucide-react'; -import { Modal } from '@bitfun/ui'; +import { Menu, MenuItem, MenuSeparator, Modal } from '@bitfun/ui'; import { Tooltip, PresenceBoundary } from '@/component-library'; import { useI18n } from '@/infrastructure/i18n/hooks/useI18n'; import { useSceneStore } from '../../../stores/sceneStore'; @@ -212,77 +212,48 @@ const PersistentFooterActions: React.FC = () => { className="bitfun-nav-panel__footer-backdrop" onClick={closeMenu} /> -
- + {t('nav.settingsMenu.floatingWindow')} + - -
- - -
+ {t('nav.settingsMenu.about')} + + , getAppearanceOverlayHost(), )} diff --git a/src/web-ui/src/app/components/TitleBar/NotificationButton.appearance.ts b/src/web-ui/src/app/components/TitleBar/NotificationButton.appearance.ts index 4983012a12..b3a6a227c8 100644 --- a/src/web-ui/src/app/components/TitleBar/NotificationButton.appearance.ts +++ b/src/web-ui/src/app/components/TitleBar/NotificationButton.appearance.ts @@ -1,2 +1,2 @@ import type { AppearanceSurfaceDescriptor } from '@/infrastructure/appearance'; -export const notificationButtonAppearanceDescriptor: AppearanceSurfaceDescriptor = { id: 'notification-button', parts: [{ id: 'root' }, { id: 'menuItem' }, { id: 'progress' }, { id: 'loadingIcon' }, { id: 'progressIcon' }, { id: 'progressText' }, { id: 'tooltip' }, { id: 'tooltipContent' }] }; +export const notificationButtonAppearanceDescriptor: AppearanceSurfaceDescriptor = { id: 'notification-button', parts: [{ id: 'root' }, { id: 'progress' }, { id: 'loadingIcon' }, { id: 'progressIcon' }, { id: 'progressText' }, { id: 'tooltip' }, { id: 'tooltipContent' }] }; diff --git a/src/web-ui/src/app/components/TitleBar/NotificationButton.tsx b/src/web-ui/src/app/components/TitleBar/NotificationButton.tsx index 3d74bc3566..fa4930d275 100644 --- a/src/web-ui/src/app/components/TitleBar/NotificationButton.tsx +++ b/src/web-ui/src/app/components/TitleBar/NotificationButton.tsx @@ -7,6 +7,7 @@ import React, { useRef, useEffect, useState } from 'react'; import { Bell, BellDot, BellRing } from 'lucide-react'; +import { MenuItem } from '@bitfun/ui'; import { Tooltip } from '@/component-library'; import { useI18n } from '@/infrastructure/i18n/hooks/useI18n'; import { @@ -64,17 +65,9 @@ const NotificationButton: React.FC = ({ : null; return ( - + ) : undefined} + onClick={handleActivate} + aria-label={t('nav.notifications')} + data-testid="notification-button" + > + {t('nav.notifications')} + ); } diff --git a/src/web-ui/src/app/scenes/shell/ShellNav.scss b/src/web-ui/src/app/scenes/shell/ShellNav.scss index e9f678ecab..acb7f06aae 100644 --- a/src/web-ui/src/app/scenes/shell/ShellNav.scss +++ b/src/web-ui/src/app/scenes/shell/ShellNav.scss @@ -88,44 +88,15 @@ opacity: 0.7; } + /* Positioning-only overrides; the design-system Menu owns the surface look. */ &__workspace-menu { - @include surfaces.floating-surface; - position: fixed; min-width: 220px; max-width: min(320px, calc(100vw - 32px)); max-height: min(560px, calc(100vh - 16px)); - overflow-x: hidden; - overflow-y: auto; - padding: 6px; z-index: 8; } - &__workspace-menu-item { - display: flex; - align-items: center; - gap: 8px; - width: 100%; - min-height: 30px; - padding: 0 8px; - border: none; - border-radius: 8px; - background: transparent; - color: var(--bf-appearance-token-color-text-secondary); - cursor: pointer; - text-align: left; - font-size: var(--bf-appearance-token-font-size-xs); - transition: - color $motion-fast $easing-standard, - background $motion-fast $easing-standard; - - &:hover, - &.is-active { - color: var(--bf-appearance-token-color-text-primary); - background: var(--bf-appearance-token-element-bg-soft); - } - } - &__workspace-menu-check { display: inline-flex; align-items: center; @@ -485,7 +456,6 @@ @media (prefers-reduced-motion: reduce) { .bitfun-shell-nav { &__workspace-trigger, - &__workspace-menu-item, &__split-button, &__split-button-main, &__split-button-toggle, diff --git a/src/web-ui/src/app/scenes/shell/components/ShellNavWorkspaceSwitcher.tsx b/src/web-ui/src/app/scenes/shell/components/ShellNavWorkspaceSwitcher.tsx index 6c3ea28729..3fe8a72a93 100644 --- a/src/web-ui/src/app/scenes/shell/components/ShellNavWorkspaceSwitcher.tsx +++ b/src/web-ui/src/app/scenes/shell/components/ShellNavWorkspaceSwitcher.tsx @@ -2,6 +2,7 @@ import React from 'react'; import { createPortal } from 'react-dom'; import { getAppearanceOverlayHost } from '@/infrastructure/appearance/runtime/AppearanceOverlayHost'; import { Check, ChevronDown } from 'lucide-react'; +import { Menu, MenuItem } from '@bitfun/ui'; import { Tooltip } from '@/component-library/components/Tooltip'; import { WorkspaceKind, type WorkspaceInfo } from '@/shared/types'; @@ -66,10 +67,9 @@ const ShellNavWorkspaceSwitcher: React.FC = ({ {workspaceMenuOpen && hasMultipleWorkspaces && workspaceMenuPosition ? createPortal( -
= ({ placement="right" disabled={!workspace.rootPath} > - + ); })} -
, + , getAppearanceOverlayHost(), ) : null} diff --git a/src/web-ui/src/infrastructure/appearance/schema/migrateAppearancePackage.ts b/src/web-ui/src/infrastructure/appearance/schema/migrateAppearancePackage.ts index 8891fc547a..3eae5e02f7 100644 --- a/src/web-ui/src/infrastructure/appearance/schema/migrateAppearancePackage.ts +++ b/src/web-ui/src/infrastructure/appearance/schema/migrateAppearancePackage.ts @@ -38,7 +38,12 @@ const RETIRED_COMPONENT_PARTS: Readonly>> = { 'markdown-editor': new Set(['modeToggle']), 'market-account-controls': new Set(['menu', 'menuItem']), 'mini-app-tool-display': new Set(['open']), - 'nav-panel': new Set(['assistantSessionMenu']), + 'nav-panel': new Set([ + 'assistantSessionMenu', + 'footerMenu', 'footerMenuItem', 'footerMenuDivider', + 'workspaceMenu', 'workspaceMenuItem', 'workspaceMenuDivider', 'workspaceMenuTitle', 'workspaceMenuEmpty', + ]), + 'notification-button': new Set(['menuItem']), 'workspace-item': new Set(['menuPopover', 'menuItem', 'menuDivider']), 'peer-device': new Set(['switcherDisconnect']), 'review-session-summary-card': new Set(['open']), From ac17ef1ccb2d26de40a8800cfad11ca6b4487480 Mon Sep 17 00:00:00 2001 From: kev1n77 Date: Fri, 28 Aug 2026 22:02:48 +0800 Subject: [PATCH 7/9] feat(design-system): add Tooltip component with provider and Design Lab preview Introduce @bitfun/ui Tooltip with TooltipProvider, placement flipping, follow-cursor and interactive persistence, backed by new overlay.tooltip dimension tokens. Register it in the component registry and expose placement previews in Design Lab. --- .../design-lab/src/i18n/componentMetadata.ts | 1 + .../apps/design-lab/src/i18n/messages.ts | 15 + .../src/pages/ComponentDetailPage.tsx | 26 +- .../design-lab/src/pages/ComponentsPage.tsx | 10 + .../design-tokens/src/system.tokens.json | 14 + .../ui/src/components/Tooltip/Tooltip.meta.ts | 38 ++ .../src/components/Tooltip/Tooltip.module.css | 166 ++++++ .../ui/src/components/Tooltip/Tooltip.tsx | 472 ++++++++++++++++++ .../ui/src/components/Tooltip/index.ts | 10 + design-system/packages/ui/src/index.ts | 10 + design-system/packages/ui/src/registry.ts | 2 + .../packages/ui/tests/registry.test.mjs | 1 + .../packages/ui/tests/tooltip.test.mjs | 67 +++ 13 files changed, 831 insertions(+), 1 deletion(-) create mode 100644 design-system/packages/ui/src/components/Tooltip/Tooltip.meta.ts create mode 100644 design-system/packages/ui/src/components/Tooltip/Tooltip.module.css create mode 100644 design-system/packages/ui/src/components/Tooltip/Tooltip.tsx create mode 100644 design-system/packages/ui/src/components/Tooltip/index.ts create mode 100644 design-system/packages/ui/tests/tooltip.test.mjs diff --git a/design-system/apps/design-lab/src/i18n/componentMetadata.ts b/design-system/apps/design-lab/src/i18n/componentMetadata.ts index 834840cb1a..999b9a822e 100644 --- a/design-system/apps/design-lab/src/i18n/componentMetadata.ts +++ b/design-system/apps/design-lab/src/i18n/componentMetadata.ts @@ -58,6 +58,7 @@ const descriptionKeys: Readonly> = { TerminalControlToolCard: "component.TerminalControlToolCard.description", TodoToolCard: "component.TodoToolCard.description", Toolbar: "component.Toolbar.description", + Tooltip: "component.Tooltip.description", ViewImageToolCard: "component.ViewImageToolCard.description", WebFetchToolCard: "component.WebFetchToolCard.description", WebSearchToolCard: "component.WebSearchToolCard.description", diff --git a/design-system/apps/design-lab/src/i18n/messages.ts b/design-system/apps/design-lab/src/i18n/messages.ts index ec18d6eda6..09046d6a76 100644 --- a/design-system/apps/design-lab/src/i18n/messages.ts +++ b/design-system/apps/design-lab/src/i18n/messages.ts @@ -99,6 +99,7 @@ export const enUSMessages = { "component.TerminalControlToolCard.description": "An ambient trace for interrupting or terminating a terminal session.", "component.TodoToolCard.description": "An ambient task-progress card with compact and expandable list presentations.", "component.Toolbar.description": "A responsive bar with independent leading, centered, trailing, and horizontally scrollable composition regions.", + "component.Tooltip.description": "A portaled, delay-managed hover label that flips placement at viewport edges and supports cursor-following and interactive content.", "component.ViewImageToolCard.description": "An ambient image-result card with an expandable preview and accessible lightbox.", "component.WebFetchToolCard.description": "An ambient web-fetch card with source metadata, result actions, and content preview.", "component.WebSearchToolCard.description": "An ambient web-search card with link, snippet, URL, and summary result presentations.", @@ -244,6 +245,8 @@ export const enUSMessages = { "components.preview.scrollAreaLabel": "Scrollable activity", "components.preview.scrollAreaItem": "Activity item {index}", "components.preview.menuLabel": "Session menu", + "components.preview.tooltipTrigger": "Rename session", + "components.preview.tooltipContent": "Rename the current session", "components.preview.menuSectionTitle": "Sessions", "components.preview.menuItemOne": "Welcome session", "components.preview.menuItemTwo": "Design review", @@ -402,6 +405,8 @@ export const enUSMessages = { "detail.option.left": "Left", "detail.option.right": "Right", "detail.option.confirmation": "Confirmation", + "detail.option.top": "Top", + "detail.option.bottom": "Bottom", "detail.option.off": "Off", "detail.option.on": "On", "detail.option.selected": "Selected", @@ -694,6 +699,7 @@ export const zhCNMessages = { "component.TerminalControlToolCard.description": "低关注终端控制轨迹,用于呈现中断或终止终端会话的结果。", "component.TodoToolCard.description": "低关注任务进度卡片,支持紧凑摘要与可展开列表两种呈现。", "component.Toolbar.description": "具备独立前置、居中、尾部与横向滚动区域的响应式工具栏。", + "component.Tooltip.description": "具备 Portal、延迟管理、视口边缘翻转,并支持跟随光标与可交互内容的悬停提示。", "component.ViewImageToolCard.description": "低关注图片结果卡片,包含可展开预览和无障碍灯箱。", "component.WebFetchToolCard.description": "低关注网页读取卡片,包含来源元数据、结果操作与内容预览。", "component.WebSearchToolCard.description": "低关注网页搜索卡片,统一呈现链接、摘要、网址与结果列表。", @@ -839,6 +845,8 @@ export const zhCNMessages = { "components.preview.scrollAreaLabel": "可滚动动态", "components.preview.scrollAreaItem": "动态项目 {index}", "components.preview.menuLabel": "会话菜单", + "components.preview.tooltipTrigger": "重命名会话", + "components.preview.tooltipContent": "重命名当前会话", "components.preview.menuSectionTitle": "会话", "components.preview.menuItemOne": "欢迎会话", "components.preview.menuItemTwo": "设计审查", @@ -997,6 +1005,8 @@ export const zhCNMessages = { "detail.option.left": "左侧", "detail.option.right": "右侧", "detail.option.confirmation": "待确认", + "detail.option.top": "顶部", + "detail.option.bottom": "底部", "detail.option.off": "关闭", "detail.option.on": "开启", "detail.option.selected": "选中", @@ -1283,6 +1293,7 @@ export const zhTWMessages = { "component.TerminalControlToolCard.description": "低關注終端控制軌跡,用於呈現中斷或終止終端工作階段的結果。", "component.TodoToolCard.description": "低關注任務進度卡片,支援緊湊摘要與可展開列表兩種呈現。", "component.Toolbar.description": "具備獨立前置、置中、尾端與橫向捲動區域的響應式工具列。", + "component.Tooltip.description": "具備 Portal、延遲管理、視口邊緣翻轉,並支援跟隨游標與可互動內容的懸停提示。", "component.ViewImageToolCard.description": "低關注圖片結果卡片,包含可展開預覽和無障礙燈箱。", "component.WebFetchToolCard.description": "低關注網頁讀取卡片,包含來源中繼資料、結果操作與內容預覽。", "component.WebSearchToolCard.description": "低關注網頁搜尋卡片,統一呈現連結、摘要、網址與結果列表。", @@ -1423,6 +1434,8 @@ export const zhTWMessages = { "components.preview.scrollAreaLabel": "可捲動動態", "components.preview.scrollAreaItem": "動態項目 {index}", "components.preview.menuLabel": "工作階段選單", + "components.preview.tooltipTrigger": "重新命名工作階段", + "components.preview.tooltipContent": "重新命名目前的工作階段", "components.preview.menuSectionTitle": "工作階段", "components.preview.menuItemOne": "歡迎工作階段", "components.preview.menuItemTwo": "設計審查", @@ -1575,6 +1588,8 @@ export const zhTWMessages = { "detail.option.left": "左側", "detail.option.right": "右側", "detail.option.confirmation": "待確認", + "detail.option.top": "頂部", + "detail.option.bottom": "底部", "detail.option.off": "關閉", "detail.option.on": "開啟", "detail.option.selected": "已選取", diff --git a/design-system/apps/design-lab/src/pages/ComponentDetailPage.tsx b/design-system/apps/design-lab/src/pages/ComponentDetailPage.tsx index 1c9c88d3e6..42f104a4a2 100644 --- a/design-system/apps/design-lab/src/pages/ComponentDetailPage.tsx +++ b/design-system/apps/design-lab/src/pages/ComponentDetailPage.tsx @@ -69,6 +69,7 @@ import { ToolbarBadge, ToolbarGroup, ToolbarSeparator, + Tooltip, type ColorScheme, type ConfirmDialogType, type ContrastMode, @@ -282,6 +283,8 @@ export function ComponentDetailPage({ ? "selected" : component.name === "ScrollArea" ? "auto" + : component.name === "Tooltip" + ? "top" : "default", ); const [inspectorDisabled, setInspectorDisabled] = useState(false); @@ -345,6 +348,8 @@ export function ComponentDetailPage({ return ["selected", "unselected", "hover", "disabled"] as const; case "Toolbar": return ["default", "with-center", "overflow"] as const; + case "Tooltip": + return ["top", "bottom", "left", "right"] as const; default: return ["off", "on", "focus-visible", "disabled"] as const; } @@ -436,6 +441,9 @@ export function ComponentDetailPage({ if (component.name === "FieldGroup") { return `import { Field, FieldGroup, FieldRow, FormSection, Input } from "@bitfun/ui";\nimport { Settings } from "lucide-react";\n\n}\n title="${t("components.preview.modalSectionTitle")}"\n>\n \n \n \n \n \n \n \n \n \n \n \n \n`; } + if (component.name === "Tooltip") { + return `import { Tooltip } from "@bitfun/ui";\n\n\n \n`; + } if (component.name === "Menu") { return `import { Menu, MenuItem, MenuSection, MenuSeparator } from "@bitfun/ui";\nimport { MessageCircle } from "lucide-react";\n\n\n \n }>${t("components.preview.menuItemOne")}\n }>${t("components.preview.menuItemTwo")}\n \n \n \n ${t("components.preview.menuDisabledItem")}\n \n`; } @@ -1040,6 +1048,20 @@ export function ComponentDetailPage({ ); } + if (component.name === "Tooltip") { + return ( + + + + ); + } + if (component.name === "Menu") { const itemCount = state === "scrolling" ? 12 : 3; return ( @@ -1619,7 +1641,7 @@ export function ComponentDetailPage({ ))}
- ) : component.name === "ActionCard" || component.name === "ActionItem" || component.name === "Field" || component.name === "FieldGroup" || component.name === "Input" || component.name === "KeyHint" || component.name === "Menu" || component.name === "NavigationPanel" || component.name === "PageHeader" || component.name === "ScrollArea" || component.name === "SearchField" || component.name === "SegmentedControl" || component.name === "Select" || component.name === "StatusPill" ? ( + ) : component.name === "ActionCard" || component.name === "ActionItem" || component.name === "Field" || component.name === "FieldGroup" || component.name === "Input" || component.name === "KeyHint" || component.name === "Menu" || component.name === "NavigationPanel" || component.name === "PageHeader" || component.name === "ScrollArea" || component.name === "SearchField" || component.name === "SegmentedControl" || component.name === "Select" || component.name === "StatusPill" || component.name === "Tooltip" ? (
diff --git a/design-system/apps/design-lab/src/pages/ComponentsPage.tsx b/design-system/apps/design-lab/src/pages/ComponentsPage.tsx index 96e0ff5a8e..4a6ff35152 100644 --- a/design-system/apps/design-lab/src/pages/ComponentsPage.tsx +++ b/design-system/apps/design-lab/src/pages/ComponentsPage.tsx @@ -57,6 +57,7 @@ import { ToolbarBadge, ToolbarGroup, ToolbarSeparator, + Tooltip, type ColorScheme, type ContrastMode, type DensityMode, @@ -109,6 +110,7 @@ const componentIcons = { Switch: ToggleLeft, TabGroup: PanelTop, Toolbar: PanelTop, + Tooltip: MessageCircle, } as const; function ComponentCardPreview({ component }: { component: ComponentMeta }) { @@ -452,6 +454,14 @@ function ComponentCardPreview({ component }: { component: ComponentMeta }) { )} /> ); + case "Tooltip": + return ( + + + + ); default: return null; } diff --git a/design-system/packages/design-tokens/src/system.tokens.json b/design-system/packages/design-tokens/src/system.tokens.json index ea7a32b1eb..e48b5612c3 100644 --- a/design-system/packages/design-tokens/src/system.tokens.json +++ b/design-system/packages/design-tokens/src/system.tokens.json @@ -352,6 +352,20 @@ "itemIconSize": { "$value": "14px" }, "scrollbarGap": { "$value": "2px" } }, + "tooltip": { + "$type": "dimension", + "maxInlineSize": { "$value": "280px" }, + "maxBlockSize": { "$value": "320px" }, + "paddingBlock": { "$value": "6px" }, + "paddingInline": { "$value": "10px" }, + "surfaceRadius": { "$value": "{radius.sm}" }, + "arrowSize": { "$value": "8px" }, + "gap": { + "$description": "Distance between the trigger (or cursor) and the tooltip surface.", + "$value": "{space.2}" + }, + "fontSize": { "$value": "{font.size.xs}" } + }, "modal": { "$type": "dimension", "viewportGutter": { diff --git a/design-system/packages/ui/src/components/Tooltip/Tooltip.meta.ts b/design-system/packages/ui/src/components/Tooltip/Tooltip.meta.ts new file mode 100644 index 0000000000..62da7be279 --- /dev/null +++ b/design-system/packages/ui/src/components/Tooltip/Tooltip.meta.ts @@ -0,0 +1,38 @@ +import type { ComponentMeta } from "../../registry.types"; + +export const tooltipMeta = { + category: "feedback", + description: "A portaled, delay-managed hover label that flips placement at viewport edges and supports cursor-following and interactive content.", + maturity: "stable", + name: "Tooltip", + props: [ + { name: "children", type: "ReactElement" }, + { name: "content", type: "ReactNode" }, + { defaultValue: "top", name: "placement", type: "top | bottom | left | right" }, + { defaultValue: "hover", name: "trigger", type: "hover | click | focus" }, + { defaultValue: "450", name: "delay", type: "number" }, + { defaultValue: "false", name: "disabled", type: "boolean" }, + { defaultValue: "false", name: "followCursor", type: "boolean" }, + { defaultValue: "false", name: "interactive", type: "boolean" }, + { name: "portalContainer", type: "TooltipPortalTarget" }, + ], + states: ["default", "visible", "interactive", "instant"], + tokens: [ + "color.surface.raised", + "color.content.primary", + "color.border.subtle", + "color.border.default", + "color.border.strong", + "font.family.sans", + "font.weight.regular", + "shadow.sm", + "overlay.tooltip.maxInlineSize", + "overlay.tooltip.maxBlockSize", + "overlay.tooltip.paddingBlock", + "overlay.tooltip.paddingInline", + "overlay.tooltip.surfaceRadius", + "overlay.tooltip.arrowSize", + "overlay.tooltip.gap", + "overlay.tooltip.fontSize", + ], +} as const satisfies ComponentMeta; diff --git a/design-system/packages/ui/src/components/Tooltip/Tooltip.module.css b/design-system/packages/ui/src/components/Tooltip/Tooltip.module.css new file mode 100644 index 0000000000..0a3451f77a --- /dev/null +++ b/design-system/packages/ui/src/components/Tooltip/Tooltip.module.css @@ -0,0 +1,166 @@ +@layer bf.components { + .root, + .arrow, + .content, + .body { + box-sizing: border-box; + } + + .root { + --_enter-x: 0px; + --_enter-y: 0px; + + position: fixed; + z-index: var(--bf-layer-tooltip); + opacity: 0; + pointer-events: none; + transform: translate3d(var(--_enter-x), var(--_enter-y), 0) scale(0.98); + transition: + opacity var(--bf-motion-duration-fast) ease, + transform var(--bf-motion-duration-fast) var(--bf-motion-easing-standard); + } + + .root[data-bf-state="visible"] { + opacity: 1; + transform: translate3d(0, 0, 0) scale(1); + } + + .root[data-bf-interactive="true"][data-bf-state="visible"] { + pointer-events: auto; + } + + .content { + position: relative; + z-index: 1; + max-inline-size: var(--bf-overlay-tooltip-max-inline-size); + max-block-size: min(var(--bf-overlay-tooltip-max-block-size), calc(100vh - 24px)); + overflow-y: auto; + overscroll-behavior: contain; + padding: var(--bf-overlay-tooltip-padding-block) var(--bf-overlay-tooltip-padding-inline); + border: var(--bf-border-width-default) solid var(--bf-color-border-subtle); + border-radius: var(--bf-overlay-tooltip-surface-radius); + background: color-mix(in srgb, var(--bf-color-surface-raised) 96%, transparent); + color: var(--bf-color-content-primary); + font-family: var(--bf-font-family-sans); + font-size: var(--bf-overlay-tooltip-font-size); + font-weight: var(--bf-font-weight-regular); + line-height: var(--bf-line-height-base); + overflow-wrap: break-word; + user-select: text; + box-shadow: var(--bf-shadow-sm); + backdrop-filter: var(--bf-effect-blur-base); + -webkit-backdrop-filter: var(--bf-effect-blur-base); + } + + .content::-webkit-scrollbar { + inline-size: 4px; + } + + .content::-webkit-scrollbar-track { + background: transparent; + } + + .content::-webkit-scrollbar-thumb { + background: var(--bf-color-border-default); + border-radius: 2px; + } + + .arrow { + position: absolute; + z-index: 0; + inline-size: var(--bf-overlay-tooltip-arrow-size); + block-size: var(--bf-overlay-tooltip-arrow-size); + border: var(--bf-border-width-default) solid var(--bf-color-border-subtle); + background: color-mix(in srgb, var(--bf-color-surface-raised) 96%, transparent); + transform: rotate(45deg); + } + + .root[data-bf-placement="top"] .arrow, + .root[data-bf-placement="bottom"] .arrow { + left: calc(50% - var(--bf-overlay-tooltip-arrow-size) / 2); + } + + .root[data-bf-placement="left"] .arrow, + .root[data-bf-placement="right"] .arrow { + top: calc(50% - var(--bf-overlay-tooltip-arrow-size) / 2); + } + + .root[data-bf-placement="top"] .arrow { + bottom: -3px; + border-top: 0; + border-left: 0; + } + + .root[data-bf-placement="bottom"] .arrow { + top: -3px; + border-right: 0; + border-bottom: 0; + } + + .root[data-bf-placement="left"] .arrow { + right: -3px; + border-bottom: 0; + border-left: 0; + } + + .root[data-bf-placement="right"] .arrow { + left: -3px; + border-top: 0; + border-right: 0; + } + + .root[data-bf-placement="top"] { + --_enter-y: 3px; + transform-origin: center bottom; + } + + .root[data-bf-placement="bottom"] { + --_enter-y: -3px; + transform-origin: center top; + } + + .root[data-bf-placement="left"] { + --_enter-x: 3px; + transform-origin: right center; + } + + .root[data-bf-placement="right"] { + --_enter-x: -3px; + transform-origin: left center; + } + + .root[data-instant="true"] { + transition-duration: 0ms; + } + + @media (prefers-contrast: more) { + .content, + .arrow { + border-color: var(--bf-color-border-strong); + } + } + + @media (forced-colors: active) { + .content, + .arrow { + border-color: CanvasText; + background: Canvas; + box-shadow: none; + } + } + + @media (prefers-reduced-motion: reduce) { + .root { + transition: opacity 80ms ease; + transform: none; + } + + .root[data-bf-state="visible"] { + transform: none; + } + + .root[data-instant="true"] { + transition-duration: 0ms; + } + } +} diff --git a/design-system/packages/ui/src/components/Tooltip/Tooltip.tsx b/design-system/packages/ui/src/components/Tooltip/Tooltip.tsx new file mode 100644 index 0000000000..1064ddc7a7 --- /dev/null +++ b/design-system/packages/ui/src/components/Tooltip/Tooltip.tsx @@ -0,0 +1,472 @@ +import { + cloneElement, + createContext, + useCallback, + useContext, + useEffect, + useId, + useMemo, + useRef, + useState, + type FocusEvent as ReactFocusEvent, + type MouseEvent as ReactMouseEvent, + type ReactElement, + type ReactNode, + type Ref, +} from "react"; +import { createPortal } from "react-dom"; +import { classNames } from "../../internal/classNames"; +import styles from "./Tooltip.module.css"; + +export type TooltipPlacement = "top" | "bottom" | "left" | "right"; +export type TooltipTrigger = "hover" | "click" | "focus"; +export type TooltipPortalContainer = Element | DocumentFragment; +export type TooltipPortalTarget = + | TooltipPortalContainer + | (() => TooltipPortalContainer | null) + | null; + +const DEFAULT_TOOLTIP_DELAY_MS = 450; +const INTERACTIVE_HIDE_DELAY_MS = 400; +/** + * After a tooltip hides, tooltips shown again within this window skip the + * open delay so scanning across adjacent triggers feels instant. + */ +const WARM_WINDOW_MS = 300; +let tooltipWarmUntil = 0; + +/** Cursor offset when followCursor: right and down so the tooltip never covers the cursor. */ +const CURSOR_OFFSET_X = 12; +const CURSOR_OFFSET_Y = 8; +const GAP = 8; +const VIEWPORT_PADDING = 8; + +interface TooltipContextValue { + delay?: number; + portalContainer?: TooltipPortalTarget; +} + +const TooltipContext = createContext({}); + +export interface TooltipProviderProps { + children: ReactNode; + /** Default open delay in milliseconds for descendant tooltips. */ + delay?: number; + portalContainer?: TooltipPortalTarget; +} + +export function TooltipProvider({ + children, + delay, + portalContainer, +}: TooltipProviderProps) { + const value = useMemo( + () => ({ delay, portalContainer }), + [delay, portalContainer], + ); + + return {children}; +} + +export interface TooltipProps { + /** Single focusable trigger element the tooltip describes. */ + children: ReactElement; + className?: string; + content: ReactNode; + /** Open delay in milliseconds. Falls back to the provider value, then 450ms. */ + delay?: number; + disabled?: boolean; + /** Position near the mouse cursor instead of the trigger element. */ + followCursor?: boolean; + /** Keep the tooltip open while hovered so its content can be selected or clicked. */ + interactive?: boolean; + /** Preferred side of the trigger; flips to the opposite side when space runs out. */ + placement?: TooltipPlacement; + portalContainer?: TooltipPortalTarget; + trigger?: TooltipTrigger; +} + +function resolvePortalContainer( + target: TooltipPortalTarget | undefined, +): TooltipPortalContainer | null { + if (typeof target === "function") return target(); + if (target) return target; + return typeof document === "undefined" ? null : document.body; +} + +function assignRef(ref: Ref | undefined, value: T | null): void { + if (!ref) return; + if (typeof ref === "function") { + ref(value); + return; + } + (ref as { current: T | null }).current = value; +} + +const OPPOSITE_PLACEMENT: Record = { + top: "bottom", + bottom: "top", + left: "right", + right: "left", +}; + +function getAvailableSpace(triggerRect: DOMRect, placement: TooltipPlacement): number { + switch (placement) { + case "top": + return triggerRect.top - VIEWPORT_PADDING; + case "bottom": + return window.innerHeight - triggerRect.bottom - VIEWPORT_PADDING; + case "left": + return triggerRect.left - VIEWPORT_PADDING; + case "right": + return window.innerWidth - triggerRect.right - VIEWPORT_PADDING; + } +} + +function getPositionForPlacement( + triggerRect: DOMRect, + tooltipRect: DOMRect, + placement: TooltipPlacement, +): { top: number; left: number } { + switch (placement) { + case "top": + return { + top: triggerRect.top - tooltipRect.height - GAP, + left: triggerRect.left + (triggerRect.width - tooltipRect.width) / 2, + }; + case "bottom": + return { + top: triggerRect.bottom + GAP, + left: triggerRect.left + (triggerRect.width - tooltipRect.width) / 2, + }; + case "left": + return { + top: triggerRect.top + (triggerRect.height - tooltipRect.height) / 2, + left: triggerRect.left - tooltipRect.width - GAP, + }; + case "right": + return { + top: triggerRect.top + (triggerRect.height - tooltipRect.height) / 2, + left: triggerRect.right + GAP, + }; + } +} + +function determineBestPlacement( + triggerRect: DOMRect, + tooltipRect: DOMRect, + preferredPlacement: TooltipPlacement, +): TooltipPlacement { + const requiredSpace = preferredPlacement === "top" || preferredPlacement === "bottom" + ? tooltipRect.height + GAP + : tooltipRect.width + GAP; + + const preferredSpace = getAvailableSpace(triggerRect, preferredPlacement); + if (preferredSpace >= requiredSpace) return preferredPlacement; + + const oppositePlacement = OPPOSITE_PLACEMENT[preferredPlacement]; + const oppositeSpace = getAvailableSpace(triggerRect, oppositePlacement); + if (oppositeSpace >= requiredSpace) return oppositePlacement; + + return oppositeSpace > preferredSpace ? oppositePlacement : preferredPlacement; +} + +function applyBoundaryConstraints( + position: { top: number; left: number }, + tooltipRect: DOMRect, +): { top: number; left: number } { + let { top, left } = position; + + if (left < VIEWPORT_PADDING) { + left = VIEWPORT_PADDING; + } else if (left + tooltipRect.width > window.innerWidth - VIEWPORT_PADDING) { + left = window.innerWidth - tooltipRect.width - VIEWPORT_PADDING; + } + + if (top < VIEWPORT_PADDING) { + top = VIEWPORT_PADDING; + } else if (top + tooltipRect.height > window.innerHeight - VIEWPORT_PADDING) { + top = window.innerHeight - tooltipRect.height - VIEWPORT_PADDING; + } + + return { top, left }; +} + +interface TooltipLayout { + top: number; + left: number; + placement: TooltipPlacement; + ready: boolean; +} + +export function Tooltip({ + children, + className, + content, + delay, + disabled = false, + followCursor = false, + interactive = false, + placement = "top", + portalContainer, + trigger = "hover", +}: TooltipProps) { + const context = useContext(TooltipContext); + const resolvedDelayMs = delay ?? context.delay ?? DEFAULT_TOOLTIP_DELAY_MS; + const resolvedPortalContainer = resolvePortalContainer( + portalContainer === undefined ? context.portalContainer : portalContainer, + ); + + const tooltipId = useId(); + const [visible, setVisible] = useState(false); + // Single layout state (position + placement + ready) so one recalculation + // commits at most one re-render. + const [layout, setLayout] = useState({ + top: 0, + left: 0, + placement, + ready: false, + }); + const [mousePosition, setMousePosition] = useState<{ x: number; y: number } | null>(null); + const triggerRef = useRef(null); + const tooltipRef = useRef(null); + const showTimeoutRef = useRef | null>(null); + const hideTimeoutRef = useRef | null>(null); + const latestMousePositionRef = useRef<{ x: number; y: number } | null>(null); + const recalcFrameRef = useRef(null); + const instantRef = useRef(false); + + const calculatePosition = useCallback(() => { + if (!tooltipRef.current) return; + + const tooltipRect = tooltipRef.current.getBoundingClientRect(); + + if (followCursor && mousePosition) { + const raw = { + top: mousePosition.y + CURSOR_OFFSET_Y, + left: mousePosition.x + CURSOR_OFFSET_X, + }; + const pos = applyBoundaryConstraints(raw, tooltipRect); + setLayout({ top: pos.top, left: pos.left, placement: "bottom", ready: true }); + return; + } + + if (!triggerRef.current) return; + + const triggerRect = triggerRef.current.getBoundingClientRect(); + const bestPlacement = determineBestPlacement(triggerRect, tooltipRect, placement); + const pos = applyBoundaryConstraints( + getPositionForPlacement(triggerRect, tooltipRect, bestPlacement), + tooltipRect, + ); + + setLayout({ top: pos.top, left: pos.left, placement: bestPlacement, ready: true }); + }, [placement, followCursor, mousePosition]); + + // rAF-merged recalculation for scroll/resize storms: at most one + // getBoundingClientRect pass per frame. + const scheduleCalculatePosition = useCallback(() => { + if (recalcFrameRef.current !== null) return; + recalcFrameRef.current = requestAnimationFrame(() => { + recalcFrameRef.current = null; + calculatePosition(); + }); + }, [calculatePosition]); + + const showTooltip = useCallback((event?: ReactMouseEvent) => { + if (disabled) return; + if (showTimeoutRef.current) clearTimeout(showTimeoutRef.current); + if (hideTimeoutRef.current) { + clearTimeout(hideTimeoutRef.current); + hideTimeoutRef.current = null; + } + if (followCursor && event) { + latestMousePositionRef.current = { x: event.clientX, y: event.clientY }; + } + const openDelay = trigger === "hover" && Date.now() < tooltipWarmUntil + ? 0 + : resolvedDelayMs; + instantRef.current = openDelay === 0; + showTimeoutRef.current = setTimeout(() => { + showTimeoutRef.current = null; + if (followCursor) { + setMousePosition(latestMousePositionRef.current); + } + setLayout((prev) => (prev.ready ? { ...prev, ready: false } : prev)); + setVisible(true); + }, openDelay); + }, [disabled, followCursor, resolvedDelayMs, trigger]); + + const hideTooltip = useCallback(() => { + if (showTimeoutRef.current) { + clearTimeout(showTimeoutRef.current); + showTimeoutRef.current = null; + } + if (hideTimeoutRef.current) { + clearTimeout(hideTimeoutRef.current); + hideTimeoutRef.current = null; + } + if (visible) { + tooltipWarmUntil = Date.now() + WARM_WINDOW_MS; + } + setVisible(false); + setLayout((prev) => (prev.ready ? { ...prev, ready: false } : prev)); + if (followCursor) { + latestMousePositionRef.current = null; + setMousePosition(null); + } + }, [followCursor, visible]); + + const scheduleHideTooltip = useCallback(() => { + if (!interactive) { + hideTooltip(); + return; + } + + if (hideTimeoutRef.current) clearTimeout(hideTimeoutRef.current); + hideTimeoutRef.current = setTimeout(() => { + hideTimeoutRef.current = null; + hideTooltip(); + }, INTERACTIVE_HIDE_DELAY_MS); + }, [hideTooltip, interactive]); + + useEffect(() => { + setLayout((prev) => (prev.placement === placement ? prev : { ...prev, placement })); + }, [placement]); + + // When the tooltip becomes disabled (e.g. the parent opens a menu that + // covers the trigger), cancel any pending show timer and force-hide so a + // tooltip cannot appear or linger above the new overlay. + useEffect(() => { + if (disabled) hideTooltip(); + }, [disabled, hideTooltip]); + + useEffect(() => { + if (!visible) return; + + scheduleCalculatePosition(); + if (!followCursor) { + window.addEventListener("scroll", scheduleCalculatePosition, { capture: true, passive: true }); + } + window.addEventListener("resize", scheduleCalculatePosition, { passive: true }); + return () => { + if (!followCursor) { + window.removeEventListener("scroll", scheduleCalculatePosition, { capture: true }); + } + window.removeEventListener("resize", scheduleCalculatePosition); + if (recalcFrameRef.current !== null) { + cancelAnimationFrame(recalcFrameRef.current); + recalcFrameRef.current = null; + } + }; + }, [visible, followCursor, scheduleCalculatePosition]); + + useEffect(() => () => { + if (showTimeoutRef.current) clearTimeout(showTimeoutRef.current); + if (hideTimeoutRef.current) clearTimeout(hideTimeoutRef.current); + }, []); + + const childProps = children.props as Record; + const childRef = (children as ReactElement & { ref?: Ref }).ref; + + const handleTriggerRef = useCallback((node: HTMLElement | null) => { + triggerRef.current = node; + assignRef(childRef, node); + }, [childRef]); + + const handleMouseEnter = (event: ReactMouseEvent) => { + if (trigger === "hover") showTooltip(event); + (childProps.onMouseEnter as ((event: ReactMouseEvent) => void) | undefined)?.(event); + }; + + const handleMouseLeave = (event: ReactMouseEvent) => { + if (trigger === "hover") scheduleHideTooltip(); + (childProps.onMouseLeave as ((event: ReactMouseEvent) => void) | undefined)?.(event); + }; + + const handleMouseMove = (event: ReactMouseEvent) => { + if (followCursor && !visible) { + latestMousePositionRef.current = { x: event.clientX, y: event.clientY }; + } + (childProps.onMouseMove as ((event: ReactMouseEvent) => void) | undefined)?.(event); + }; + + const handleClick = (event: ReactMouseEvent) => { + // Always cancel any pending show timer so a click before the tooltip + // appears cannot surface a stale tooltip after the trigger is covered + // by a menu or backdrop (which prevents the natural mouseleave). + if (showTimeoutRef.current) { + clearTimeout(showTimeoutRef.current); + showTimeoutRef.current = null; + } + if (visible) { + hideTooltip(); + } else if (trigger === "click") { + showTooltip(); + } + (childProps.onClick as ((event: ReactMouseEvent) => void) | undefined)?.(event); + }; + + const handleFocus = (event: ReactFocusEvent) => { + if (trigger === "focus") showTooltip(); + (childProps.onFocus as ((event: ReactFocusEvent) => void) | undefined)?.(event); + }; + + const handleBlur = (event: ReactFocusEvent) => { + if (trigger === "focus") hideTooltip(); + (childProps.onBlur as ((event: ReactFocusEvent) => void) | undefined)?.(event); + }; + + const isShown = visible && layout.ready; + + const triggerElement = cloneElement(children as ReactElement>, { + ref: handleTriggerRef, + onMouseEnter: handleMouseEnter, + onMouseLeave: handleMouseLeave, + onMouseMove: followCursor ? handleMouseMove : childProps.onMouseMove, + onClick: handleClick, + onFocus: handleFocus, + onBlur: handleBlur, + "aria-describedby": isShown + ? [childProps["aria-describedby"], tooltipId].filter(Boolean).join(" ") + : childProps["aria-describedby"], + } as Record); + + return ( + <> + {triggerElement} + {visible && resolvedPortalContainer && createPortal( +