@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.
@@ -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.kind) }))
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
- <button
260
+ <Tooltip
259
261
  key={provider.id}
260
- type="button"
261
- className={`login-provider${Icon === Globe ? " login-provider--generic" : ""}`}
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
- {loading ? <Spinner className="w-5 h-5" /> : <Icon />}
268
- {Icon === Globe ? <span>{provider.displayName}</span> : null}
269
- </button>
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(kind: LoginProviderOption['kind']) {
412
- switch (kind) {
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("更新胶囊是状态点+文字而非实心按钮:焦点环走 focus-ring,无实底(§2/§6)", () => {
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
- // 层级错开:胶囊文字 caption/12px,不与账户名(fs-ui/13px)同档抢眼
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 stays hidden while my space remains in primary navigation", () => {
383
+ test("library is available after my space in primary navigation", () => {
384
384
  assert.match(source, /<NavItem\s+id="files"\s+label="我的空间"/)
385
- assert.doesNotMatch(source, /<NavItem\s+id="library"\s+label="资料库"/)
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.map((project) => (
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
- <ChevDown className="lead" aria-hidden="true" />
573
- {project.loadingMore ? "加载中…" : project.loadError ? "加载失败,点击重试" : "展开显示"}
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 || threadById(threadMenu.id)?.archived),
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
- /* 胶囊不是实心按钮:状态点 + 按态换字,状态说明走 title(与账户菜单「检查更新」行
842
- 同一份 updateRowText)。下载完(ready)点它的动作是重启安装,字还写「更新」会把
843
- 用户骗去等一个不会来的「更新中」——ready 直书「重启安装」;其余态「更新」不变。
844
- 下载中附百分比,点按行为由宿主按态分发。 */
878
+ /* 图标 + 角标小圆点:信息级提示,hover 才上色。ready 态图标换成 Refresh(动作是
879
+ 重启安装),与 available 的 Download 形成形状双编码,不靠颜色区分两个可点态;
880
+ 状态说明走 title(与账户菜单「检查更新」行同一份 updateRowText)。下载中禁用并
881
+ 附百分比读数(读数用墨不用黄),点按行为由宿主按态分发。 */
845
882
  const { title } = updateRowText(update)
846
- const label = update.state === "ready" ? "重启安装" : "更新"
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
- <i className="owuser-update-dot" aria-hidden="true" />
860
- <span>{label}</span>
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 disabled={loading} onClick={error ? onRetry : onLoadMore}>
356
- {loading ? "正在加载…" : error ? "重试" : "加载更多"}
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
- <span data-ow-part="label" className="inline-flex items-center gap-[inherit] whitespace-nowrap">
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
- "absolute left-0 right-0 z-[var(--ow-z-dropdown)] p-1.5 max-h-[264px] overflow-y-auto",
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
- if (wrapRef.current && !wrapRef.current.contains(event.target as Node)) {
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
- <div
318
- ref={listRef}
319
- id={listId}
320
- data-ow-part="list"
321
- className={selectMenuVariants({ position: up ? "up" : "down" })}
322
- role="listbox"
323
- >
324
- {options.map((option, index) => (
325
- <button
326
- key={option.value}
327
- id={optionId(index)}
328
- type="button"
329
- role="option"
330
- tabIndex={-1}
331
- aria-selected={option.value === value}
332
- data-index={index}
333
- data-ow-role={option.value === value ? "selected" : "option"}
334
- className={selectOptionVariants({ active: index === activeIndex })}
335
- onMouseDown={(event) => event.preventDefault()}
336
- onMouseEnter={() => setActiveIndex(index)}
337
- onClick={() => pick(option.value)}
338
- >
339
- <span className="flex-1 min-w-0 flex flex-col gap-0.5">
340
- <span
341
- className={cn(
342
- "overflow-hidden text-ellipsis whitespace-nowrap",
343
- option.value === value && "font-medium",
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.sub && (
349
- <span className="text-ow-fs-eyebrow font-normal leading-snug text-[color:var(--ow-text-3)] overflow-hidden text-ellipsis whitespace-nowrap">
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
- </span>
354
- {option.value === value && (
355
- <Check className="shrink-0 w-4 h-4 text-[color:var(--ow-text)]" aria-hidden="true" />
356
- )}
357
- </button>
358
- ))}
359
- {footer}
360
- </div>
361
- )}
398
+ </button>
399
+ ))}
400
+ {footer}
401
+ </div>,
402
+ document.body,
403
+ )}
362
404
  </div>
363
405
  )
364
406
  }