@orbit-work/ui 0.1.0 → 0.1.2-preview.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.cjs +13 -13
- package/dist/index.d.cts +23 -3
- package/dist/index.d.ts +23 -3
- package/dist/index.js +13 -13
- package/dist/orbitwork.css +97 -22
- package/package.json +1 -1
- package/src/components/composite/ContextUsageIndicatorView/index.tsx +1 -1
- package/src/components/composite/Icons/index.tsx +24 -4
- package/src/components/composite/Markdown/MarkdownStyleSource.test.mjs +16 -1
- package/src/components/domain/desktop/DesktopLibraryAuthorizationDialog/DesktopLibraryAuthorizationDialogSource.test.mjs +22 -0
- package/src/components/domain/desktop/DesktopLibraryAuthorizationDialog/index.tsx +72 -57
- package/src/components/domain/desktop/DesktopLibraryPageView/DesktopLibraryPageView.stories.tsx +7 -0
- package/src/components/domain/desktop/DesktopLibraryPageView/index.tsx +14 -0
- package/src/components/domain/desktop/DesktopLoginView/DesktopLoginViewSource.test.mjs +8 -1
- package/src/components/domain/desktop/DesktopLoginView/index.tsx +28 -14
- package/src/components/domain/desktop/DesktopSidebarView/DesktopSidebarViewSource.test.mjs +6 -4
- package/src/components/domain/desktop/DesktopSidebarView/index.tsx +53 -14
- package/src/components/domain/desktop/DesktopSitesListView/index.tsx +2 -2
- package/src/components/primitives/Button/index.tsx +4 -1
- package/src/components/primitives/Menu/index.tsx +2 -1
- package/src/components/primitives/Select/index.tsx +103 -61
|
@@ -3,8 +3,9 @@
|
|
|
3
3
|
import { useState } from "react"
|
|
4
4
|
import "./DesktopLoginView.css"
|
|
5
5
|
import { BeaconSpinner } from "../../../composite/BeaconSpinner"
|
|
6
|
-
import { ChevLeft, DingTalk, Feishu, Globe, WeCom } from "../../../composite/Icons"
|
|
6
|
+
import { ChevLeft, DingTalk, DingTalkAi, Feishu, Globe, WeCom } from "../../../composite/Icons"
|
|
7
7
|
import { OrbitworkMark } from "../../../composite/OrbitworkLogo"
|
|
8
|
+
import { Tooltip } from "../../../composite/Tooltip"
|
|
8
9
|
import { Button } from "../../../primitives"
|
|
9
10
|
import { Field } from "../../../primitives/Field"
|
|
10
11
|
import { InlineMessage } from "../../../primitives/InlineMessage"
|
|
@@ -158,7 +159,7 @@ export function DesktopLoginView({
|
|
|
158
159
|
const failed = stage === "expired" || stage === "error"
|
|
159
160
|
const passwordEnabled = Boolean(onPasswordLogin)
|
|
160
161
|
const canSubmitPassword = identifier.trim().length > 0 && password.length > 0 && !passwordSubmitting
|
|
161
|
-
const visibleProviders = providers.map((provider) => ({ provider, Icon: providerBrandIcon(provider
|
|
162
|
+
const visibleProviders = providers.map((provider) => ({ provider, Icon: providerBrandIcon(provider) }))
|
|
162
163
|
const providersEnabled = Boolean(onQrLogin) && visibleProviders.length > 0
|
|
163
164
|
|
|
164
165
|
const submitPassword = () => {
|
|
@@ -250,23 +251,30 @@ export function DesktopLoginView({
|
|
|
250
251
|
未知 kind 不渲染该入口——连接列表以后端 identity-providers
|
|
251
252
|
为准,不硬编码三家。裸图标点击件:品牌标自足辨识,不套按钮
|
|
252
253
|
边框底(hover/focus 态在本 CSS 的 .login-provider 一族),
|
|
253
|
-
可访问名取 displayName
|
|
254
|
+
可访问名取 displayName;悬浮提示走 Tooltip(键盘 focus 即出,
|
|
255
|
+
不用原生 title——与自定气泡同现会双重弹层);发起中换小转子。 */}
|
|
254
256
|
<div className="login-provider-row">
|
|
255
257
|
{visibleProviders.map(({ provider, Icon }) => {
|
|
256
258
|
const loading = qrRequesting === provider.id
|
|
257
259
|
return (
|
|
258
|
-
<
|
|
260
|
+
<Tooltip
|
|
259
261
|
key={provider.id}
|
|
260
|
-
|
|
261
|
-
|
|
262
|
+
content={`使用${provider.displayName}扫码登录`}
|
|
263
|
+
side="top"
|
|
262
264
|
disabled={loading}
|
|
263
|
-
aria-label={`使用${provider.displayName}扫码登录`}
|
|
264
|
-
aria-busy={loading || undefined}
|
|
265
|
-
onClick={() => onQrLogin?.(provider.id)}
|
|
266
265
|
>
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
266
|
+
<button
|
|
267
|
+
type="button"
|
|
268
|
+
className={`login-provider${Icon === Globe ? " login-provider--generic" : ""}`}
|
|
269
|
+
disabled={loading}
|
|
270
|
+
aria-label={`使用${provider.displayName}扫码登录`}
|
|
271
|
+
aria-busy={loading || undefined}
|
|
272
|
+
onClick={() => onQrLogin?.(provider.id)}
|
|
273
|
+
>
|
|
274
|
+
{loading ? <Spinner className="w-5 h-5" /> : <Icon />}
|
|
275
|
+
{Icon === Globe ? <span>{provider.displayName}</span> : null}
|
|
276
|
+
</button>
|
|
277
|
+
</Tooltip>
|
|
270
278
|
)
|
|
271
279
|
})}
|
|
272
280
|
</div>
|
|
@@ -408,8 +416,14 @@ export function DesktopLoginView({
|
|
|
408
416
|
}
|
|
409
417
|
|
|
410
418
|
// Unknown categories use a generic entry instead of hiding a usable connection.
|
|
411
|
-
function providerBrandIcon(
|
|
412
|
-
|
|
419
|
+
function providerBrandIcon(provider: LoginProviderOption) {
|
|
420
|
+
// TODO(kind-dingtalk-ai):「钉钉 AI 伙伴」与钉钉同为 DINGTALK kind(契约无独立
|
|
421
|
+
// 枚举、连接 id 是随机 UUID),临时按 displayName 精确匹配出同族蓝色标区分。
|
|
422
|
+
// proto 增加 IDENTITY_PROVIDER_KIND_DINGTALK_AI 后改为按 kind 映射并删除本分支。
|
|
423
|
+
if (provider.kind === "IDENTITY_PROVIDER_KIND_DINGTALK" && provider.displayName === "钉钉(AI伙伴)") {
|
|
424
|
+
return DingTalkAi
|
|
425
|
+
}
|
|
426
|
+
switch (provider.kind) {
|
|
413
427
|
case "IDENTITY_PROVIDER_KIND_DINGTALK": return DingTalk
|
|
414
428
|
case "IDENTITY_PROVIDER_KIND_FEISHU": return Feishu
|
|
415
429
|
case "IDENTITY_PROVIDER_KIND_WECOM": return WeCom
|
|
@@ -193,7 +193,7 @@ test("V1 遗留 .thread-menu 已随 MenuSurface 迁移删除(全仓零引用
|
|
|
193
193
|
assert.doesNotMatch(source, /thread-menu/)
|
|
194
194
|
})
|
|
195
195
|
|
|
196
|
-
test("
|
|
196
|
+
test("更新入口是图标+角标小圆点而非实心按钮:焦点环走 focus-ring,无实底(§2/§6)", () => {
|
|
197
197
|
assert.match(styles, /\.owuser-update:focus-visible \{[^}]*var\(--ow-focus-ring\)/)
|
|
198
198
|
assert.doesNotMatch(styles, /\.owuser-update:focus-visible \{[^}]*var\(--ow-primary\)/)
|
|
199
199
|
assert.match(styles, /\.owuser-update:active:not\(:disabled\)/)
|
|
@@ -201,7 +201,7 @@ test("更新胶囊是状态点+文字而非实心按钮:焦点环走 focus-r
|
|
|
201
201
|
assert.match(styles, /\.owuser-update-dot \{[^}]*background: var\(--ow-accent\)/)
|
|
202
202
|
assert.match(styles, /\.owuser-update:hover:not\(:disabled\) \{[^}]*var\(--ow-accent\)/)
|
|
203
203
|
assert.match(styles, /\.owuser-update\.error \.owuser-update-dot \{[^}]*var\(--ow-error\)/)
|
|
204
|
-
//
|
|
204
|
+
// 层级错开:读数/辅助文字 caption 档,不与账户名(fs-ui/13px)同档抢眼
|
|
205
205
|
assert.match(styles, /\.owuser-update \{[^}]*font: 400 var\(--ow-fs-caption\)\/1 var\(--ow-font-sans\)/)
|
|
206
206
|
// ready 态不翻成主色实心按钮;呼吸动画已退役——点静止,全链路零 animation
|
|
207
207
|
assert.doesNotMatch(styles, /\.owuser-update\.ready \{[^}]*background/)
|
|
@@ -380,7 +380,9 @@ test("R2 · T4:账户菜单的新数据口全部可选,缺省即该块不出
|
|
|
380
380
|
}
|
|
381
381
|
})
|
|
382
382
|
|
|
383
|
-
test("library
|
|
383
|
+
test("library is available after my space in primary navigation", () => {
|
|
384
384
|
assert.match(source, /<NavItem\s+id="files"\s+label="我的空间"/)
|
|
385
|
-
assert.
|
|
385
|
+
assert.match(source, /<NavItem\s+id="files"\s+label="我的空间"[\s\S]*<NavItem\s+id="library"\s+label="资料库"/)
|
|
386
|
+
assert.ok(source.includes('active={props.activeNav === "library"}'))
|
|
387
|
+
assert.ok(source.includes('onClick={() => props.onNavigate("library")}'))
|
|
386
388
|
})
|
|
@@ -13,10 +13,12 @@ import {
|
|
|
13
13
|
ChevDown,
|
|
14
14
|
ChevRight,
|
|
15
15
|
Clock,
|
|
16
|
+
Download,
|
|
16
17
|
Edit,
|
|
17
18
|
Folder,
|
|
18
19
|
Grid,
|
|
19
20
|
Icon,
|
|
21
|
+
Memo,
|
|
20
22
|
More,
|
|
21
23
|
Package,
|
|
22
24
|
PanelSidebar,
|
|
@@ -27,11 +29,12 @@ import {
|
|
|
27
29
|
Trash,
|
|
28
30
|
} from "../../../composite/Icons"
|
|
29
31
|
import { TextField } from "../../../composite/NativeControls"
|
|
30
|
-
import { OrbitworkLockup } from "../../../composite/OrbitworkLogo"
|
|
32
|
+
import { OrbitworkLockup, OrbitworkMark } from "../../../composite/OrbitworkLogo"
|
|
31
33
|
import { ProjectSelect } from "../../../composite/ProjectSelect"
|
|
32
34
|
import { Avatar, Button } from "../../../primitives"
|
|
33
35
|
import { MenuSurface } from "../../../primitives/Menu"
|
|
34
36
|
import type { MenuItem } from "../../../primitives/Menu"
|
|
37
|
+
import { Spinner } from "../../../primitives/Spinner"
|
|
35
38
|
import { DesktopAccountMenuView, updateRowText } from "../DesktopAccountMenuView"
|
|
36
39
|
|
|
37
40
|
export type DesktopSidebarNavId = "new" | "skills" | "sites" | "cron" | "files" | "library"
|
|
@@ -110,6 +113,12 @@ export interface DesktopSidebarViewProps {
|
|
|
110
113
|
* 宿主 `features/sidebar/Sidebar.tsx` 补 `view === 'home' → 'new'` 即可,见交接单。
|
|
111
114
|
*/
|
|
112
115
|
activeNav?: DesktopSidebarNavId
|
|
116
|
+
/**
|
|
117
|
+
* 首次会话列表加载中(宿主 `loadSessions` 首轮)。为 true 且还没有任何会话行时,
|
|
118
|
+
* 列表区出品牌 mark + spinner 的启动态,而不是一块空白——首屏读得出「在加载」
|
|
119
|
+
* 而不是「没有会话」。分页/刷新加载不改变此值(那是 `loadingMore` 的语义)。
|
|
120
|
+
*/
|
|
121
|
+
sessionsLoading?: boolean
|
|
113
122
|
/**
|
|
114
123
|
* 是否在导航里出「我的空间」(A11-B1 · 裁决 16)。
|
|
115
124
|
*
|
|
@@ -345,6 +354,17 @@ export function DesktopSidebarView(props: DesktopSidebarViewProps): React.ReactE
|
|
|
345
354
|
onClick={() => props.onNavigate("files")}
|
|
346
355
|
/>
|
|
347
356
|
)}
|
|
357
|
+
{/* 资料库:恢复的入口(曾隐藏导致已接线的 LibraryPage 不可达——App.tsx 已把
|
|
358
|
+
baseView === "library" 路由到 workbenchView 并以 active prop 挂载 LibraryPage)。
|
|
359
|
+
放在「我的空间」之后;只有 running 的 thread 才禁用移动,archived 可以移动。 */}
|
|
360
|
+
<NavItem
|
|
361
|
+
id="library"
|
|
362
|
+
label="资料库"
|
|
363
|
+
title="资料库"
|
|
364
|
+
active={props.activeNav === "library"}
|
|
365
|
+
icon={<Memo />}
|
|
366
|
+
onClick={() => props.onNavigate("library")}
|
|
367
|
+
/>
|
|
348
368
|
</nav>
|
|
349
369
|
|
|
350
370
|
<div className="owrecent-head" data-tour-anchor="projects">
|
|
@@ -368,7 +388,13 @@ export function DesktopSidebarView(props: DesktopSidebarViewProps): React.ReactE
|
|
|
368
388
|
</div>
|
|
369
389
|
</div>
|
|
370
390
|
<div className="owprojects">
|
|
371
|
-
{projects.
|
|
391
|
+
{props.sessionsLoading && projects.every((project) => project.threads.length === 0) ? (
|
|
392
|
+
<div className="owprojects-boot" role="status" aria-live="polite">
|
|
393
|
+
<OrbitworkMark variant="auto" size={40} />
|
|
394
|
+
<Spinner size="md" />
|
|
395
|
+
</div>
|
|
396
|
+
) : (
|
|
397
|
+
projects.map((project) => (
|
|
372
398
|
<div key={project.id} className="owproject">
|
|
373
399
|
{renamingProject?.id === project.id ? (
|
|
374
400
|
<div className="owproject__head renaming">
|
|
@@ -568,15 +594,26 @@ export function DesktopSidebarView(props: DesktopSidebarViewProps): React.ReactE
|
|
|
568
594
|
{/* R2 · L2-1-1:「展开显示」原来是一行没有图标、没有 chevron、
|
|
569
595
|
与会话行同缩进同字号的 --ow-text-3 灰字(浅档 2.29:1),读起来像
|
|
570
596
|
一条被禁用的会话。补一枚 14 的 ChevDown 前置图标(与同族
|
|
571
|
-
.owthread-new 的 Plus 同语法),让它读成动作;字色并轨见 CSS。
|
|
572
|
-
|
|
573
|
-
{project.loadingMore ?
|
|
597
|
+
.owthread-new 的 Plus 同语法),让它读成动作;字色并轨见 CSS。
|
|
598
|
+
加载中同理:纯文案太朴素,换成 spinner + 文案(#34)。 */}
|
|
599
|
+
{project.loadingMore ? (
|
|
600
|
+
<>
|
|
601
|
+
<Spinner size="sm" />
|
|
602
|
+
加载中…
|
|
603
|
+
</>
|
|
604
|
+
) : (
|
|
605
|
+
<>
|
|
606
|
+
<ChevDown className="lead" aria-hidden="true" />
|
|
607
|
+
{project.loadError ? "加载失败,点击重试" : "展开显示"}
|
|
608
|
+
</>
|
|
609
|
+
)}
|
|
574
610
|
</Button>
|
|
575
611
|
)}
|
|
576
612
|
</div>
|
|
577
613
|
)}
|
|
578
614
|
</div>
|
|
579
|
-
|
|
615
|
+
))
|
|
616
|
+
)}
|
|
580
617
|
</div>
|
|
581
618
|
|
|
582
619
|
{projectsMenu && (
|
|
@@ -613,7 +650,7 @@ export function DesktopSidebarView(props: DesktopSidebarViewProps): React.ReactE
|
|
|
613
650
|
key: "move",
|
|
614
651
|
label: "移动",
|
|
615
652
|
icon: <Folder />,
|
|
616
|
-
disabled: Boolean(threadById(threadMenu.id)?.running
|
|
653
|
+
disabled: Boolean(threadById(threadMenu.id)?.running),
|
|
617
654
|
},
|
|
618
655
|
...(!threadById(threadMenu.id)?.archived
|
|
619
656
|
? ([
|
|
@@ -838,12 +875,12 @@ function SidebarUpdateButton({
|
|
|
838
875
|
onClick,
|
|
839
876
|
}: { update: DesktopSidebarUpdate; onClick: () => void }): React.ReactElement {
|
|
840
877
|
const percent = Math.round(Math.max(0, Math.min(100, update.percent ?? 0)))
|
|
841
|
-
/*
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
878
|
+
/* 图标 + 角标小圆点:信息级提示,hover 才上色。ready 态图标换成 Refresh(动作是
|
|
879
|
+
重启安装),与 available 的 Download 形成形状双编码,不靠颜色区分两个可点态;
|
|
880
|
+
状态说明走 title(与账户菜单「检查更新」行同一份 updateRowText)。下载中禁用并
|
|
881
|
+
附百分比读数(读数用墨不用黄),点按行为由宿主按态分发。 */
|
|
845
882
|
const { title } = updateRowText(update)
|
|
846
|
-
const
|
|
883
|
+
const StateIcon = update.state === "ready" ? Refresh : Download
|
|
847
884
|
return (
|
|
848
885
|
<button
|
|
849
886
|
type="button"
|
|
@@ -856,8 +893,10 @@ function SidebarUpdateButton({
|
|
|
856
893
|
onClick()
|
|
857
894
|
}}
|
|
858
895
|
>
|
|
859
|
-
<
|
|
860
|
-
|
|
896
|
+
<span className="owuser-update-ic">
|
|
897
|
+
<StateIcon />
|
|
898
|
+
{update.state !== "downloading" && <i className="owuser-update-dot" aria-hidden="true" />}
|
|
899
|
+
</span>
|
|
861
900
|
{update.state === "downloading" && <em>{percent}%</em>}
|
|
862
901
|
</button>
|
|
863
902
|
)
|
|
@@ -352,8 +352,8 @@ export function DesktopSitesListView({
|
|
|
352
352
|
{(hasMore || error) && (
|
|
353
353
|
<div className="sites-grid__more">
|
|
354
354
|
{error && <InlineMessage variant="error" title="暂时无法加载更多站点" detail={error} />}
|
|
355
|
-
<Button
|
|
356
|
-
{
|
|
355
|
+
<Button loading={loading} onClick={error ? onRetry : onLoadMore}>
|
|
356
|
+
{error ? "重试" : "加载更多"}
|
|
357
357
|
</Button>
|
|
358
358
|
</div>
|
|
359
359
|
)}
|
|
@@ -207,7 +207,10 @@ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button
|
|
|
207
207
|
)
|
|
208
208
|
)}
|
|
209
209
|
{children && (
|
|
210
|
-
|
|
210
|
+
// min-w-0:受 max-w 约束的按钮(如 composer 模型选择器)要能在窄容器里
|
|
211
|
+
// 收缩,否则内部 nowrap 文本撑破按钮边界、溢出覆盖相邻控件。真正的省略
|
|
212
|
+
// 号由调用方的 label span(overflow-hidden text-ellipsis)承担。
|
|
213
|
+
<span data-ow-part="label" className="inline-flex min-w-0 items-center gap-[inherit] whitespace-nowrap">
|
|
211
214
|
{children}
|
|
212
215
|
</span>
|
|
213
216
|
)}
|
|
@@ -900,8 +900,9 @@ export function Menu({
|
|
|
900
900
|
/**
|
|
901
901
|
* 向上找最近的带 transform/perspective/filter/will-change 的祖先 —— 它会给
|
|
902
902
|
* fixed 元素造出新的包含块,让坐标不再相对视口。没有就返回 null。
|
|
903
|
+
* (Select 的 portal 菜单量位也复用这个。)
|
|
903
904
|
*/
|
|
904
|
-
function findFixedOffsetAncestor(el: HTMLElement): HTMLElement | null {
|
|
905
|
+
export function findFixedOffsetAncestor(el: HTMLElement): HTMLElement | null {
|
|
905
906
|
let parent = el.parentElement
|
|
906
907
|
while (parent && parent !== document.body && parent !== document.documentElement) {
|
|
907
908
|
const style = getComputedStyle(parent)
|
|
@@ -8,11 +8,14 @@ import {
|
|
|
8
8
|
forwardRef,
|
|
9
9
|
useEffect,
|
|
10
10
|
useId,
|
|
11
|
+
useLayoutEffect,
|
|
11
12
|
useRef,
|
|
12
13
|
useState,
|
|
13
14
|
} from "react"
|
|
15
|
+
import { createPortal } from "react-dom" // ui-imports-guard-disable-line
|
|
14
16
|
import { cn } from "../../../lib/utils"
|
|
15
17
|
import { Check, ChevDown } from "../../composite/Icons"
|
|
18
|
+
import { findFixedOffsetAncestor } from "../Menu"
|
|
16
19
|
|
|
17
20
|
export type SelectOption = {
|
|
18
21
|
value: string
|
|
@@ -52,22 +55,14 @@ const selectTriggerVariants = cva(
|
|
|
52
55
|
|
|
53
56
|
const selectMenuVariants = cva(
|
|
54
57
|
[
|
|
55
|
-
|
|
58
|
+
// R6:菜单纯 portal 到 document.body 做 fixed 定位(量位见下方 effect)。
|
|
59
|
+
// 原先 absolute 挂在触发器下,会被 Dialog 这类 overflow-hidden 立面裁到只剩
|
|
60
|
+
// 一行——裁剪抬 z-index 解决不了(MenuSurface portal 档注释同款结论)。
|
|
61
|
+
"p-1.5 max-h-[264px] overflow-y-auto",
|
|
56
62
|
"bg-[var(--ow-popover-bg)] border border-ow-border",
|
|
57
63
|
"shadow-[var(--ow-shadow-pop)]",
|
|
58
64
|
"[border-radius:var(--ow-radius-menu)]",
|
|
59
65
|
].join(" "),
|
|
60
|
-
{
|
|
61
|
-
variants: {
|
|
62
|
-
position: {
|
|
63
|
-
down: "top-[calc(100%+4px)]",
|
|
64
|
-
up: "bottom-[calc(100%+4px)]",
|
|
65
|
-
},
|
|
66
|
-
},
|
|
67
|
-
defaultVariants: {
|
|
68
|
-
position: "down",
|
|
69
|
-
},
|
|
70
|
-
},
|
|
71
66
|
)
|
|
72
67
|
|
|
73
68
|
const selectOptionVariants = cva(
|
|
@@ -187,20 +182,28 @@ export function Select({
|
|
|
187
182
|
className,
|
|
188
183
|
}: SelectProps) {
|
|
189
184
|
const [open, setOpen] = useState(false)
|
|
190
|
-
const [up, setUp] = useState(false)
|
|
191
185
|
const [activeIndex, setActiveIndex] = useState(-1)
|
|
186
|
+
const [menuPos, setMenuPos] = useState<{ top: number; left: number } | null>(null)
|
|
192
187
|
const wrapRef = useRef<HTMLDivElement>(null)
|
|
193
188
|
const buttonRef = useRef<HTMLButtonElement>(null)
|
|
194
189
|
const listRef = useRef<HTMLDivElement>(null)
|
|
195
190
|
const listId = useId()
|
|
196
191
|
const selected = options.find((option) => option.value === value)
|
|
197
192
|
const optionId = (index: number) => `${listId}-opt-${index}`
|
|
193
|
+
// 菜单纯 portal 到 body 后与 Dialog 同处 --ow-z-modal 层:弹层晚于对话框挂载(DOM
|
|
194
|
+
// 序在后),同层相叠画在立面之上;用 --ow-z-dropdown 会落到模态 scrim 下面。
|
|
198
195
|
|
|
199
196
|
useEffect(() => {
|
|
200
197
|
if (!open) return undefined
|
|
201
198
|
|
|
202
199
|
const closeOnOutsideClick = (event: MouseEvent) => {
|
|
203
|
-
|
|
200
|
+
const target = event.target as Node
|
|
201
|
+
// 菜单 portal 到 body,不在 wrapRef 子树里,命中面本体时不算外点。
|
|
202
|
+
if (
|
|
203
|
+
wrapRef.current &&
|
|
204
|
+
!wrapRef.current.contains(target) &&
|
|
205
|
+
!(listRef.current && listRef.current.contains(target))
|
|
206
|
+
) {
|
|
204
207
|
setOpen(false)
|
|
205
208
|
}
|
|
206
209
|
}
|
|
@@ -225,12 +228,48 @@ export function Select({
|
|
|
225
228
|
listRef.current?.querySelector(`[data-index="${activeIndex}"]`)?.scrollIntoView({ block: "nearest" })
|
|
226
229
|
}, [open, activeIndex])
|
|
227
230
|
|
|
231
|
+
// Portal 量位:贴触发器下缘,下方空间不足翻到下缘上方;滚动/缩放时跟锚点重算。
|
|
232
|
+
// transform 祖先(如 Dialog 的入场动画)会改写 fixed 的包含块,偏移要减回去。
|
|
233
|
+
useLayoutEffect(() => {
|
|
234
|
+
if (!open) return undefined
|
|
235
|
+
const el = listRef.current
|
|
236
|
+
const button = buttonRef.current
|
|
237
|
+
if (!el || !button) return undefined
|
|
238
|
+
const measure = () => {
|
|
239
|
+
const anchorRect = button.getBoundingClientRect()
|
|
240
|
+
// 菜单宽跟随触发器(原 absolute left-0 right-0 的同款语义),先写死再量高度。
|
|
241
|
+
el.style.width = `${anchorRect.width}px`
|
|
242
|
+
const dropRect = el.getBoundingClientRect()
|
|
243
|
+
const offsetParent = findFixedOffsetAncestor(el)
|
|
244
|
+
const offsetX = offsetParent ? offsetParent.getBoundingClientRect().left : 0
|
|
245
|
+
const offsetY = offsetParent ? offsetParent.getBoundingClientRect().top : 0
|
|
246
|
+
|
|
247
|
+
let top = anchorRect.bottom + 4 - offsetY
|
|
248
|
+
if (anchorRect.bottom + 4 + dropRect.height > window.innerHeight - 8) {
|
|
249
|
+
top = anchorRect.top - dropRect.height - 4 - offsetY
|
|
250
|
+
}
|
|
251
|
+
if (top + offsetY < 8) top = 8 - offsetY
|
|
252
|
+
let left = anchorRect.left - offsetX
|
|
253
|
+
if (left + offsetX + dropRect.width > window.innerWidth - 8) {
|
|
254
|
+
left = window.innerWidth - dropRect.width - 8 - offsetX
|
|
255
|
+
}
|
|
256
|
+
if (left + offsetX < 8) left = 8 - offsetX
|
|
257
|
+
|
|
258
|
+
setMenuPos((current) => (current && current.top === top && current.left === left ? current : { top, left }))
|
|
259
|
+
}
|
|
260
|
+
// 等菜单渲染完再量,否则拿不到高度。
|
|
261
|
+
const raf = requestAnimationFrame(measure)
|
|
262
|
+
window.addEventListener("resize", measure)
|
|
263
|
+
window.addEventListener("scroll", measure, true)
|
|
264
|
+
return () => {
|
|
265
|
+
cancelAnimationFrame(raf)
|
|
266
|
+
window.removeEventListener("resize", measure)
|
|
267
|
+
window.removeEventListener("scroll", measure, true)
|
|
268
|
+
}
|
|
269
|
+
}, [open, options.length])
|
|
270
|
+
|
|
228
271
|
const openMenu = () => {
|
|
229
272
|
if (disabled) return
|
|
230
|
-
if (buttonRef.current) {
|
|
231
|
-
const rect = buttonRef.current.getBoundingClientRect()
|
|
232
|
-
setUp(window.innerHeight - rect.bottom < 300)
|
|
233
|
-
}
|
|
234
273
|
const selectedIndex = options.findIndex((option) => option.value === value)
|
|
235
274
|
setActiveIndex(selectedIndex >= 0 ? selectedIndex : 0)
|
|
236
275
|
setOpen(true)
|
|
@@ -313,52 +352,55 @@ export function Select({
|
|
|
313
352
|
<span className="text-[color:var(--ow-placeholder)]">{placeholder}</span>
|
|
314
353
|
)}
|
|
315
354
|
</SelectTrigger>
|
|
316
|
-
{open &&
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
355
|
+
{open &&
|
|
356
|
+
createPortal(
|
|
357
|
+
<div
|
|
358
|
+
ref={listRef}
|
|
359
|
+
id={listId}
|
|
360
|
+
data-ow-part="list"
|
|
361
|
+
className={cn(selectMenuVariants(), "fixed z-[var(--ow-z-modal)]", !menuPos && "invisible")}
|
|
362
|
+
style={{ top: menuPos?.top ?? 0, left: menuPos?.left ?? 0 }}
|
|
363
|
+
role="listbox"
|
|
364
|
+
>
|
|
365
|
+
{options.map((option, index) => (
|
|
366
|
+
<button
|
|
367
|
+
key={option.value}
|
|
368
|
+
id={optionId(index)}
|
|
369
|
+
type="button"
|
|
370
|
+
role="option"
|
|
371
|
+
tabIndex={-1}
|
|
372
|
+
aria-selected={option.value === value}
|
|
373
|
+
data-index={index}
|
|
374
|
+
data-ow-role={option.value === value ? "selected" : "option"}
|
|
375
|
+
className={selectOptionVariants({ active: index === activeIndex })}
|
|
376
|
+
onMouseDown={(event) => event.preventDefault()}
|
|
377
|
+
onMouseEnter={() => setActiveIndex(index)}
|
|
378
|
+
onClick={() => pick(option.value)}
|
|
379
|
+
>
|
|
380
|
+
<span className="flex-1 min-w-0 flex flex-col gap-0.5">
|
|
381
|
+
<span
|
|
382
|
+
className={cn(
|
|
383
|
+
"overflow-hidden text-ellipsis whitespace-nowrap",
|
|
384
|
+
option.value === value && "font-medium",
|
|
385
|
+
)}
|
|
386
|
+
>
|
|
387
|
+
{option.label}
|
|
388
|
+
</span>
|
|
389
|
+
{option.sub && (
|
|
390
|
+
<span className="text-ow-fs-eyebrow font-normal leading-snug text-[color:var(--ow-text-3)] overflow-hidden text-ellipsis whitespace-nowrap">
|
|
391
|
+
{option.sub}
|
|
392
|
+
</span>
|
|
344
393
|
)}
|
|
345
|
-
>
|
|
346
|
-
{option.label}
|
|
347
394
|
</span>
|
|
348
|
-
{option.
|
|
349
|
-
<
|
|
350
|
-
{option.sub}
|
|
351
|
-
</span>
|
|
395
|
+
{option.value === value && (
|
|
396
|
+
<Check className="shrink-0 w-4 h-4 text-[color:var(--ow-text)]" aria-hidden="true" />
|
|
352
397
|
)}
|
|
353
|
-
</
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
{footer}
|
|
360
|
-
</div>
|
|
361
|
-
)}
|
|
398
|
+
</button>
|
|
399
|
+
))}
|
|
400
|
+
{footer}
|
|
401
|
+
</div>,
|
|
402
|
+
document.body,
|
|
403
|
+
)}
|
|
362
404
|
</div>
|
|
363
405
|
)
|
|
364
406
|
}
|