refactor(tasks-ui): align the page with the latest shadcn/ui defaults
The previous round kept a hand-trimmed, shadow-less, 11-13px variant of the
component set. This replaces it with the registry's own defaults and the
registry's own composition primitives, keeping only the adaptations this host
actually requires.
Registry refresh (npx shadcn@latest add --overwrite): the whole vendored set is
back to upstream sources, plus label, field, input-group, item and button-group.
Re-applied adaptations (documented in the design doc):
- cn from ./utils and explicit ./x.tsx specifiers (the registry writes virtual
aliases this repo has no resolver for);
- host IconXxx instead of lucide (skin contract);
- no dark: variants (the DSH tokens already flip) and no palette literals;
- React.forwardRef on Button (the host is React 18, so radix Slot needs it);
- tw-animate-css animation utilities stripped: installing that dependency would
inject generic keyframes into a page we do not own.
Restored to stock: the full Button variant/size set, shadow-xs on outline,
transition-all, badge ghost/link.
Recomposition on the newest primitives (per-file, verified here):
- board: full Card composition, Item rows, ToggleGroup filter, dropdown items
grouped, Empty for the empty state;
- task window: Card composition, FieldGroup/Field with data-invalid validation,
ToggleGroup owner (default variant: the outline variant's hover and pressed
states measured identically, hiding the selection), destructive-armed delete,
FieldError for conflicts;
- popovers: both portal cards use the same Card composition, rows are Item;
- drawer/header: Item rows with ItemActions, Separator bands, Spinner, Empty;
- graph/tree: stock node recipe, outline buttons, cn() for conditional classes.
Two no-preflight traps found by rendering, not by types:
- stock Empty ships border-dashed without a width, so the UA's 3px medium dashed
frame wrapped the whole page: border-0 on that Empty;
- <Spinner size={12}/> stayed type-clean after the refresh (size is a valid span
attribute) while the glyph silently rendered at size-4: className="size-3".
Verified: typecheck/lint clean; 136 files / 1515 tests; build with the core and
chunk bundle guards (core 836.3 KiB, chunk 734.7 KiB); mount e2e 7/7; and the
real-host-stylesheet mirror page re-shot for graph, tree, board, task window and
the 77-node stress fixture at 360 and 720.
This commit is contained in:
@@ -364,17 +364,36 @@ npx -y shadcn@latest add button card badge separator input textarea tooltip popo
|
||||
|
||||
**4. 模型去重(重复 React key 的根因)。** 可视化 harness 在压力 fixture 上抓到 `sub-live-011` 等三个重复 key:根因在 `tasks-model.ts` —— workflow member 的 `childId` 若**不在 run 发起者的 catalog 里**(但存在于树中别处),代码会走"合成节点"分支,生成一个与真实节点**同 id** 的第二张卡片。修法:先按整棵树收集 `knownAgentIds`,只对 catalog 完全不知道的 id 合成节点;并在返回前加一条"每个 session id 只出现一次"的兜底去重。新增两条单测(已知 member 不重复 / 未知 member 仍合成)。
|
||||
|
||||
### 升级路径(未来用 shadcn CLI `--diff` 跟进上游)
|
||||
### 升级路径与本地适配清单(stock 优先)
|
||||
|
||||
`components.json` 已把 CLI 上下文钉在这个仓库上,`npx -y shadcn@latest info` 能正确列出 16 个已装组件(`base: radix`、`tailwindVersion: v4`、`tailwindCss: src/client/ui/theme.css`),所以**不要手动从 GitHub 抄文件**,走 CLI:
|
||||
**政策(2026-09-20 起):与上游 stock 保持一致优先,本地改动只保留"不这样就得坏"的那几条。** 早先那版"裁剪变体、去阴影、压字号"的本地取向已被真机反馈否掉(用户要求"改为 shadcn/ui 最新的默认样式对齐"),因此 `src/client/ui/**` 用 `npx shadcn@latest add … --overwrite` 整批刷成了最新 registry(new-york v4 / radix base),并**重新施加**下面这份最小适配清单:
|
||||
|
||||
```bash
|
||||
npx -y shadcn@latest info # 当前上下文与已装组件清单
|
||||
npx -y shadcn@latest add <component> --dry-run # 先看会动哪些文件
|
||||
npx -y shadcn@latest add <component> --diff # 逐文件看上游 vs 本地差异
|
||||
npx -y shadcn@latest add <component> # 确认后再落盘
|
||||
```
|
||||
| # | 本地适配 | 为什么必须 |
|
||||
|---|---|---|
|
||||
| 1 | `cn` 从 `./utils` 导入;跨组件 import 写显式 `./x.tsx` | registry 写的是虚拟别名 `cn` 与裸 `src/client/ui/...` 说明符,本项目没有那些解析规则 |
|
||||
| 2 | 图标一律用宿主 `@deepseek-ai/dsh-client-ui-primitives` 的 `IconXxx` | 皮肤契约禁 lucide;宿主图标随主题变色。`spinner.tsx` 的 `Loader2Icon` → `IconLoadingOutline16`(外层 span 承接 props),`dropdown-menu.tsx` 的勾/箭头 → `IconCheckOutline16`/`IconChevronRightOutline14`,单选圆点用 `span.bg-current` |
|
||||
| 3 | 删除所有 `dark:` 变体 | 令牌本身随宿主深浅主题翻转,`dark:` 是多余且会改写宿主主题语义(技能规则也禁手写 dark 覆盖) |
|
||||
| 4 | `text-white` → `text-destructive-foreground` | 无颜色字面量是皮肤契约的硬线 |
|
||||
| 5 | `Button` 保留 `React.forwardRef` | 宿主是 **React 18**,`asChild` 触发器要 ref 才能量到锚点(registry 面向 React 19,不写 forwardRef) |
|
||||
| 6 | 删除 `tw-animate-css` 的动画工具类(`animate-in`/`fade-in-*`/`zoom-in-*`/`slide-in-from-*`) | 该依赖未安装;装了它会向**我们不拥有的宿主页面**注入通用 `@keyframes` 名字,有与宿主动画撞名的风险。缺省即"立即出现",与 reduced-motion 行为一致 |
|
||||
|
||||
**实测**(`npx -y shadcn@latest add button --diff`,本次迁移后执行):上游会把 `cn` 的 import 写回裸包名 `"cn"`、带回 `dark:bg-destructive/60` 与 `dark:focus-visible:ring-destructive/40`、把 `text-destructive-foreground` 换回 `text-white`、给 `outline` 加回 `shadow-xs`、并恢复被删的 `link` 变体与 `xs` / `lg` / `icon-xs` / `icon-sm` / `icon-lg` 五个尺寸——正是本文件记录的六类统一改动。所以升级的流程是**逐文件判断**:没有本地改动的文件可被覆盖,有本地改动的(几乎全部)读 diff 后**只取上游的结构性更新,重新施加本仓库的六类改动**——尤其 `input.tsx` 的 `file:` 类一旦回来,`tests/ui-bundle.spec.ts` 的 preflight 守卫会开始误报。落盘后必须复跑三个守卫 spec(`ui-foundation` / `ui-bundle` / `ui-shadows`)与 `pnpm build`(体积测量是 `ui-bundle` 的一部分),再跑 `pnpm vitest run tests/theme.spec.ts` 确认皮肤契约未被上游带回的颜色字面量破坏。
|
||||
**已恢复为 stock(此前被本地裁掉的)**:Button 的完整变体/尺寸集(`link`/`xs`/`lg`/`icon-xs|sm|lg`)、`outline` 的 `shadow-xs`、`transition-all`、Badge 的 `ghost`/`link` 变体。它们只是运行时字符串,未使用的工具类不会进 CSS。
|
||||
|
||||
`--overwrite` 与本仓库不兼容:它会一次性抹掉上面全部改动。真要用,先 `git stash` 出可对比的基线。
|
||||
**升级流程**:`npx shadcn@latest add <component> --dry-run` → `--diff` 逐文件看上游改动 → 有本地改动的按上表重新施加(`--overwrite` 后必须重跑下面四条)。落盘后必跑:`pnpm typecheck`、`pnpm vitest run tests/theme.spec.ts tests/ui-shadows.spec.ts tests/ui-foundation.spec.ts tests/ui-bundle.spec.ts`、`pnpm build`(体积与产物断言),以及**镜像页视觉复核**(真实宿主样式表 + 真实组件,见上一节)。
|
||||
|
||||
### 页面重组:用最新 stock 组件,而不是手搓 div(2026-09-20)
|
||||
|
||||
按 shadcn 技能的硬规则把任务页的组成方式整体换成最新 registry 的构件(4 个并行代理按文件分区改,主线程统一验收):
|
||||
|
||||
- **团队任务板**:`Card` 全组合(`CardHeader`/`CardTitle` + `Separator` 分带 + `CardContent` + `CardFooter`),任务行 = `Item asChild`(`ItemMedia` StateDot / `ItemContent`/`ItemTitle`/`ItemActions`),筛选 = `ToggleGroup`(radix radiogroup + roving tabindex),行内菜单 = `DropdownMenuGroup` 包住每个 `DropdownMenuItem`,空态 = `Empty`。
|
||||
- **任务窗口**:`Card` 全组合 + `FieldGroup`/`Field`/`FieldLabel`/`FieldDescription`/`FieldError`;标题空值走 `Field data-invalid` + `Input aria-invalid`(不新增 i18n key);负责人 = `ToggleGroup`(**default 变体**:实测 `outline` 变体的 hover 与 pressed 同色,选中态不可辨);删除 armed 用 `Button variant="destructive"`,冲突提示用 `FieldError(role=alert)`。
|
||||
- **浮窗**:两张 portal 卡同样 `Card` 全组合(`CardTitle` 放身份、`CardDescription` 放种类、`CardAction` 放状态 Badge、`CardFooter` 放跳转),行 = `Item`(可点击行 `asChild` 真按钮)。
|
||||
- **抽屉/页头**:行 = `Item`(终止按钮在 `ItemActions`),分隔全用 `Separator`,加载 = `Spinner`,目录失败横幅 = `Item variant="outline"`,空态 = `Empty`。
|
||||
- **图/树**:节点卡配方 `rounded-lg border bg-card p-2.5 shadow-xs`(不再重复声明 stock `Card` 已有的墨色),控制条全部 stock `Button variant="outline" size="sm|icon"`,`z-[6]` → `z-10`;树行的条件类名改走 `cn()`。
|
||||
|
||||
**本轮在真实浏览器里抓到的两个"无 preflight"陷阱**(都已修,且在测试/文档里留痕):
|
||||
|
||||
1. **stock `Empty` 只有 `border-dashed` 没有宽度** → 没有 preflight 时露出 UA 的 `3px medium` 虚线框,整页像套了个粗虚线相框;`Empty` 上加 `border-0` 修掉(截图证实)。
|
||||
2. **`<Spinner size={12} />` 类型合法但静默失效**:刷新后的 Spinner 变成 `React.ComponentProps<"span">`,`size` 是合法 HTML 属性 → typecheck 全绿而图标按默认 `size-4` 渲染。改用 `className="size-3"`。**教训:组件 API 变更中"仍然类型合法但语义丢失"的那类最危险,只有看渲染结果才能发现。**
|
||||
|
||||
**已知未修(记录在案)**:抽屉行在 ~420px 以下的窄宽度里会横向溢出(Radix ScrollArea 的 table 视口按 min-content 计算 + 行内 `nowrap` 的 mono 标题),修复需要重新决定 ScrollArea/行的最小宽度策略;宽面板(用户实机)不受影响。
|
||||
|
||||
+113
-77
@@ -11,17 +11,21 @@
|
||||
* cursor), with a copy action, a follow-latest switch, the two-click kill,
|
||||
* and a terminal-style tail while the job runs.
|
||||
*
|
||||
* Visual language: Tailwind utilities over the shadcn tokens
|
||||
* (src/client/ui/theme.css) with the vendored Collapsible / ScrollArea /
|
||||
* Button for the interactive shells. Hierarchy comes from a 1px `border-border`
|
||||
* hairline plus the surface ladder (`bg-background` → `bg-muted` on hover) —
|
||||
* Composition: the vendored shadcn set carries every surface. The drawer is a
|
||||
* `Collapsible` whose rows are `Item`s (`ItemGroup` → `Item` + `ItemActions`,
|
||||
* the row whose output is open taking the `muted` variant); the popover's
|
||||
* identity block is an `Item` (media / content / actions) and its footer an
|
||||
* `Item` holding the host `Switch` and the kill `Button`; hairlines are
|
||||
* `Separator`s, status ink rides the stock `Badge` variants, and the loading
|
||||
* line is the vendored `Spinner`. Hierarchy comes from the 1px `border-border`
|
||||
* hairline plus the surface ladder (`bg-background` → `bg-muted` on hover) —
|
||||
* the static drawer carries no shadow; the only float is the popover card,
|
||||
* whose surface/shadow belong to AnchoredPopover. Body copy is text-sm, meta
|
||||
* text-xs, identifiers/durations `font-mono tabular-nums`.
|
||||
*
|
||||
* Every non-shadcn control is a host primitive (Switch / StateDot), and every
|
||||
* icon is a host `IconXxx` glyph — the badges are the vendored shadcn `Badge`
|
||||
* on its stock variants.
|
||||
* `className` is used for layout only (the components own their color and
|
||||
* type), every icon is a host `IconXxx` glyph, and every other control is a
|
||||
* host primitive (`Switch` / `StateDot`).
|
||||
*/
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
|
||||
import {
|
||||
@@ -40,7 +44,10 @@ import { t } from './locales.ts'
|
||||
import { Badge } from './ui/badge.tsx'
|
||||
import { Button } from './ui/button.tsx'
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from './ui/collapsible.tsx'
|
||||
import { Item, ItemActions, ItemContent, ItemGroup, ItemMedia, ItemTitle } from './ui/item.tsx'
|
||||
import { ScrollArea } from './ui/scroll-area.tsx'
|
||||
import { Separator } from './ui/separator.tsx'
|
||||
import { Spinner } from './ui/spinner.tsx'
|
||||
import { cn } from './ui/utils.ts'
|
||||
|
||||
/** Refresh cadence of an open job-output popover while its job runs. */
|
||||
@@ -60,6 +67,15 @@ function statusVariant(job: SidebarJobView): 'default' | 'secondary' | 'destruct
|
||||
return 'secondary'
|
||||
}
|
||||
|
||||
/** The meta chip of one job kind: the stock `outline` variant, mono figures. */
|
||||
function KindBadge(props: { kind: string }): ReactNode {
|
||||
return (
|
||||
<Badge variant="outline" className="flex-none font-mono font-normal text-muted-foreground">
|
||||
{props.kind}
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
|
||||
export interface JobsDrawerProps {
|
||||
rows: readonly TreeJob[]
|
||||
/** Agent count of the tree (root included) — the auto-collapse signal. */
|
||||
@@ -129,8 +145,11 @@ export function JobsDrawer(props: JobsDrawerProps): ReactNode {
|
||||
onOpenChange={(next: boolean) => { setManualOpen(next) }}
|
||||
role="region"
|
||||
aria-label={t('jobs')}
|
||||
className="z-[5] flex-none border-t border-border bg-background"
|
||||
className="z-[5] flex-none bg-background"
|
||||
>
|
||||
{/* The drawer's top edge: the stock hairline (the bar itself carries the
|
||||
surface, the rows own their own rhythm). */}
|
||||
<Separator />
|
||||
{/* The bar keeps the drawer's whole toggle affordance (title, running
|
||||
tally, count line) and Radix owns aria-expanded / aria-controls. */}
|
||||
<CollapsibleTrigger
|
||||
@@ -155,13 +174,14 @@ export function JobsDrawer(props: JobsDrawerProps): ReactNode {
|
||||
<div className="px-3 pb-2 font-mono text-xs text-foreground-3">{t('jobsAutoCollapsed')}</div>
|
||||
)}
|
||||
<CollapsibleContent>
|
||||
{/* Bounded log surface: one row per job, hairline-separated by the
|
||||
<Separator />
|
||||
{/* Bounded log surface: one `Item` row per job, separated by the
|
||||
rows' own 4px rhythm rather than per-row borders. The height is
|
||||
definite (not `max-h`) because ScrollArea's viewport is `size-full`:
|
||||
a bare max-height leaves the viewport content-sized, so the rows
|
||||
would spill out of the drawer instead of scrolling. */}
|
||||
<ScrollArea className="h-[156px] w-full min-w-0 border-t border-dashed border-border">
|
||||
<div className="flex min-w-0 flex-col gap-0.5 px-2 pt-1 pb-2">
|
||||
<ScrollArea className="h-[156px] w-full min-w-0">
|
||||
<ItemGroup className="min-w-0 gap-0.5 px-2 pt-1 pb-2">
|
||||
{rows.map((row) => {
|
||||
const { job } = row
|
||||
const live = isJobLive(job)
|
||||
@@ -181,23 +201,25 @@ export function JobsDrawer(props: JobsDrawerProps): ReactNode {
|
||||
].join(' · ')
|
||||
const secondary = [context, duration].filter(Boolean).join(' · ')
|
||||
return (
|
||||
<div
|
||||
<Item
|
||||
key={job.id}
|
||||
size="sm"
|
||||
// The row whose output is open wears the muted surface (the
|
||||
// stock Item variant) instead of a one-off background class.
|
||||
variant={openJobId === job.id ? 'muted' : 'default'}
|
||||
data-settled={live ? 'false' : 'true'}
|
||||
data-open={openJobId === job.id ? 'true' : 'false'}
|
||||
className={cn(
|
||||
'group flex items-center gap-[7px] rounded-md px-1.5 py-1 transition-colors',
|
||||
'hover:bg-muted',
|
||||
openJobId === job.id && 'bg-muted',
|
||||
'min-w-0 flex-nowrap gap-2 rounded-md px-1.5 py-1 transition-colors hover:bg-muted',
|
||||
// A settled job recedes by ink (the label line drops to the
|
||||
// secondary level), not by opacity.
|
||||
!live && 'text-muted-foreground',
|
||||
)}
|
||||
data-settled={live ? 'false' : 'true'}
|
||||
data-open={openJobId === job.id ? 'true' : 'false'}
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-auto min-w-0 flex-1 justify-start gap-[7px] px-0 py-0 text-left text-sm font-normal tracking-normal normal-case"
|
||||
className="h-auto min-w-0 flex-1 justify-start gap-2 px-0 py-0 text-left"
|
||||
aria-label={`${job.label} ${jobStatusLabel(job.status, t)} ${secondary}`}
|
||||
title={t('jobViewOutput')}
|
||||
onClick={(event) => { onOpenOutput(row, event.currentTarget) }}
|
||||
@@ -207,9 +229,7 @@ export function JobsDrawer(props: JobsDrawerProps): ReactNode {
|
||||
running state draws an svg, the others a span), so the
|
||||
indicator keeps its 6px. */}
|
||||
<StateDot state={jobDotState(job.status)} size={6} className="size-1.5" />
|
||||
<Badge variant="outline" className="flex-none font-mono font-normal text-muted-foreground">
|
||||
{job.kind}
|
||||
</Badge>
|
||||
<KindBadge kind={job.kind} />
|
||||
<span
|
||||
className={cn(
|
||||
'min-w-0 flex-1 truncate font-mono text-sm',
|
||||
@@ -232,36 +252,36 @@ export function JobsDrawer(props: JobsDrawerProps): ReactNode {
|
||||
</span>
|
||||
</Button>
|
||||
{job.status === 'running' && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
// Inline row action: quiet until the row is hovered, but
|
||||
// keyboard focus brings it back (focus-visible below).
|
||||
className={cn(
|
||||
'h-auto shrink-0 font-mono text-xs font-normal text-muted-foreground',
|
||||
'opacity-0 transition-opacity group-hover:opacity-100 focus-visible:opacity-100',
|
||||
armed && 'border-destructive text-destructive',
|
||||
)}
|
||||
data-armed={armed ? 'true' : 'false'}
|
||||
aria-label={armed ? t('jobKillConfirm') : t('jobKill')}
|
||||
title={armed ? t('jobKillConfirm') : t('jobKill')}
|
||||
disabled={killing}
|
||||
onClick={() => {
|
||||
if (armed) void kill(row)
|
||||
else setArmedId(job.id)
|
||||
}}
|
||||
>
|
||||
<IconStopFill16 size={11} className="size-[11px]" />
|
||||
{armed ? t('jobKillConfirm') : undefined}
|
||||
</Button>
|
||||
<ItemActions className="gap-1">
|
||||
<Button
|
||||
// The armed confirm is the stock `destructive` variant;
|
||||
// unarmed stays a quiet outline chip.
|
||||
variant={armed ? 'destructive' : 'outline'}
|
||||
size="xs"
|
||||
// Inline row action: quiet until the row is hovered, but
|
||||
// keyboard focus brings it back (focus-visible below).
|
||||
className="opacity-0 transition-opacity group-hover/item:opacity-100 focus-visible:opacity-100"
|
||||
data-armed={armed ? 'true' : 'false'}
|
||||
aria-label={armed ? t('jobKillConfirm') : t('jobKill')}
|
||||
title={armed ? t('jobKillConfirm') : t('jobKill')}
|
||||
disabled={killing}
|
||||
onClick={() => {
|
||||
if (armed) void kill(row)
|
||||
else setArmedId(job.id)
|
||||
}}
|
||||
>
|
||||
<IconStopFill16 />
|
||||
{armed ? t('jobKillConfirm') : undefined}
|
||||
</Button>
|
||||
</ItemActions>
|
||||
)}
|
||||
{killFailed && (
|
||||
<span className="shrink-0 font-mono text-xs text-destructive">{t('jobKillError')}</span>
|
||||
)}
|
||||
</div>
|
||||
</Item>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</ItemGroup>
|
||||
</ScrollArea>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
@@ -277,9 +297,9 @@ export function JobsDrawer(props: JobsDrawerProps): ReactNode {
|
||||
*
|
||||
* The card fills the AnchoredPopover surface (rounded-lg + border + shadow
|
||||
* live there, since that host owns the float) and only places its content
|
||||
* inside it: header, title line, meta line, the terminal tail, and the action
|
||||
* footer. `data-popover-handle` / `data-popover-no-drag` keep the drag
|
||||
* contract unchanged — the body drags, the controls do not.
|
||||
* inside it: header, the `Item` identity block, the terminal tail, and the
|
||||
* `Item` action footer. `data-popover-handle` / `data-popover-no-drag` keep
|
||||
* the drag contract unchanged — the body drags, the controls do not.
|
||||
*/
|
||||
export function JobOutputPopoverContent(props: {
|
||||
ownerSessionId: string
|
||||
@@ -361,13 +381,13 @@ export function JobOutputPopoverContent(props: {
|
||||
// `dsw-tasks`: this card renders inside AnchoredPopover's portal at
|
||||
// document.body — OUTSIDE the page root — so it re-declares the page root
|
||||
// class for the scoped base reset (see TaskWindow.tsx).
|
||||
<div className="dsw-tasks box-border flex flex-col gap-1 p-2.5 text-sm text-popover-foreground" data-popover-handle>
|
||||
<div className="mb-1 flex items-baseline justify-between gap-2 font-mono text-xs tracking-[0.14em] text-foreground-3 uppercase">
|
||||
<span>{t('jobs')}</span>
|
||||
<span className="inline-flex items-center gap-0.5 tracking-normal normal-case" data-popover-no-drag>
|
||||
<div className="dsw-tasks box-border flex flex-col gap-2 p-2.5 text-sm text-popover-foreground" data-popover-handle>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="font-mono text-xs tracking-[0.14em] text-foreground-3 uppercase">{t('jobs')}</span>
|
||||
<span className="inline-flex items-center" data-popover-no-drag>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
size="icon-xs"
|
||||
aria-label={copied ? t('jobCopied') : t('jobCopyOutput')}
|
||||
title={copied ? t('jobCopied') : t('jobCopyOutput')}
|
||||
disabled={text === ''}
|
||||
@@ -378,27 +398,41 @@ export function JobOutputPopoverContent(props: {
|
||||
})
|
||||
}}
|
||||
>
|
||||
<IconCopyOutline16 size={12} className="size-3" />
|
||||
<IconCopyOutline16 />
|
||||
</Button>
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex min-w-0 items-center gap-1.5">
|
||||
<StateDot state={jobDotState(job.status)} size={6} />
|
||||
<span className="min-w-0 flex-1 truncate text-sm font-semibold text-foreground" title={job.label}>
|
||||
{job.label}
|
||||
</span>
|
||||
<Badge variant={statusVariant(job)} className="flex-none">
|
||||
{jobStatusLabel(job.status, t)}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<Badge variant="outline" className="flex-none font-mono font-normal text-muted-foreground">
|
||||
{job.kind}
|
||||
</Badge>
|
||||
{job.detail !== undefined && job.detail !== '' && <span>{job.detail}</span>}
|
||||
<span className="font-mono text-xs text-foreground-3">{t('jobDragHint')}</span>
|
||||
</div>
|
||||
{state === 'loading' && <div className="text-xs text-foreground-3">{t('loading')}</div>}
|
||||
{/* The job's identity: the stock `Item` composition (media / content /
|
||||
actions) rather than a hand-rolled row. */}
|
||||
<Item size="sm" variant="muted" className="min-w-0 flex-nowrap gap-2 rounded-md px-2 py-1.5">
|
||||
<ItemMedia>
|
||||
<StateDot state={jobDotState(job.status)} size={8} />
|
||||
</ItemMedia>
|
||||
<ItemContent className="min-w-0 gap-0.5">
|
||||
<ItemTitle className="w-full min-w-0">
|
||||
<span className="min-w-0 flex-1 truncate" title={job.label}>{job.label}</span>
|
||||
</ItemTitle>
|
||||
<span className="flex min-w-0 items-center gap-1.5 font-mono text-xs text-muted-foreground">
|
||||
<KindBadge kind={job.kind} />
|
||||
{job.detail !== undefined && job.detail !== '' && (
|
||||
<span className="min-w-0 truncate">{job.detail}</span>
|
||||
)}
|
||||
<span className="flex-none text-foreground-3">{t('jobDragHint')}</span>
|
||||
</span>
|
||||
</ItemContent>
|
||||
<ItemActions>
|
||||
<Badge variant={statusVariant(job)}>{jobStatusLabel(job.status, t)}</Badge>
|
||||
</ItemActions>
|
||||
</Item>
|
||||
{state === 'loading' && (
|
||||
<div className="flex items-center gap-1.5 text-xs text-foreground-3">
|
||||
{/* The visible line carries the copy; the glyph is decoration. */}
|
||||
<span className="flex flex-none items-center" aria-hidden="true">
|
||||
<Spinner className="size-3" />
|
||||
</span>
|
||||
{t('loading')}
|
||||
</div>
|
||||
)}
|
||||
{state === 'error' && <div className="text-xs text-destructive">{t('jobOutputError')}</div>}
|
||||
{typeof state === 'object' && (
|
||||
<>
|
||||
@@ -406,7 +440,7 @@ export function JobOutputPopoverContent(props: {
|
||||
? (
|
||||
<pre
|
||||
ref={preRef}
|
||||
className="mt-1.5 max-h-[168px] overflow-auto rounded-md border border-border bg-background p-2 font-mono text-xs leading-[1.55] break-words whitespace-pre-wrap text-muted-foreground"
|
||||
className="max-h-[168px] overflow-auto rounded-md border border-border bg-background p-2 font-mono text-xs leading-[1.55] break-words whitespace-pre-wrap text-muted-foreground"
|
||||
data-popover-no-drag
|
||||
>
|
||||
{state.text}
|
||||
@@ -418,8 +452,11 @@ export function JobOutputPopoverContent(props: {
|
||||
{state.truncated && <div className="text-xs text-foreground-3">{t('jobOutputTruncated')}</div>}
|
||||
</>
|
||||
)}
|
||||
<div
|
||||
className="mt-2 flex items-center justify-between gap-2 border-t border-border pt-2"
|
||||
<Separator />
|
||||
{/* The action footer: the follow-latest switch and the kill confirm. */}
|
||||
<Item
|
||||
size="sm"
|
||||
className="min-w-0 flex-nowrap justify-between gap-2 rounded-md px-0 py-0"
|
||||
data-popover-no-drag
|
||||
>
|
||||
<Switch
|
||||
@@ -432,18 +469,17 @@ export function JobOutputPopoverContent(props: {
|
||||
<Button
|
||||
variant={armed ? 'destructive' : 'outline'}
|
||||
size="sm"
|
||||
className="font-mono text-xs"
|
||||
disabled={killing}
|
||||
onClick={() => {
|
||||
if (armed) void kill()
|
||||
else setArmed(true)
|
||||
}}
|
||||
>
|
||||
<IconStopFill16 size={11} className="size-[11px]" />
|
||||
<IconStopFill16 />
|
||||
{armed ? t('jobKillConfirm') : t('jobKill')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</Item>
|
||||
{killFailed && <div className="text-xs text-destructive">{t('jobKillError')}</div>}
|
||||
</div>
|
||||
)
|
||||
|
||||
+50
-24
@@ -21,11 +21,17 @@
|
||||
* ⓘ button opens the detail popover. Completed leaf agents fold into one
|
||||
* aggregate node per parent (click it or the control-cluster toggle to
|
||||
* expand/collapse).
|
||||
*
|
||||
* The shell itself is the vendored shadcn set: the header is a toolbar closed
|
||||
* by a `Separator`, the catalog-failure banner is an `Item` (media / content /
|
||||
* actions), the empty state is the stock `Empty` composition, and the page's
|
||||
* only controls are the vendored `Button` + `Tooltip` — `className` carries
|
||||
* layout only, and every glyph is a host `IconXxx` primitive.
|
||||
*/
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
|
||||
import { useSyncExternalStore } from 'react'
|
||||
import {
|
||||
IconAgentPresetOutline16, IconRefreshOutline14,
|
||||
IconAgentPresetOutline16, IconRefreshOutline14, IconWarningOutline16,
|
||||
} from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type {
|
||||
Context,
|
||||
@@ -54,6 +60,8 @@ import type { SidebarStore } from './state.ts'
|
||||
import type { WorkflowRunView } from '../workflow-runs.ts'
|
||||
import { Button as UiButton } from './ui/button.tsx'
|
||||
import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from './ui/empty.tsx'
|
||||
import { Item, ItemActions, ItemContent, ItemMedia, ItemTitle } from './ui/item.tsx'
|
||||
import { Separator } from './ui/separator.tsx'
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from './ui/tooltip.tsx'
|
||||
import legacy from './SubagentView.module.css'
|
||||
|
||||
@@ -416,11 +424,11 @@ export function SubagentView(props: {
|
||||
return (
|
||||
<div className={`dsw-tasks ${legacy.subagent} relative`}>
|
||||
{/*
|
||||
Page header: title + mono descendant count + refresh, closed by a 1px
|
||||
hairline. The control cluster (view toggle / fold / zoom) stays on the
|
||||
canvas itself, so it is visible in BOTH modes.
|
||||
Page header: title + mono descendant count + refresh, closed by the
|
||||
stock hairline `Separator`. The control cluster (view toggle / fold /
|
||||
zoom) stays on the canvas itself, so it is visible in BOTH modes.
|
||||
*/}
|
||||
<div className="flex flex-none items-center gap-2 border-b border-border px-3 py-2">
|
||||
<div className="flex flex-none items-center gap-2 px-3 py-2">
|
||||
<span className="min-w-0 flex-1 truncate text-sm font-medium">
|
||||
{t('subagent')}
|
||||
{rootSummary?.displayTitle !== undefined && rootSummary.displayTitle !== ''
|
||||
@@ -448,13 +456,14 @@ export function SubagentView(props: {
|
||||
team.refresh()
|
||||
}}
|
||||
>
|
||||
<IconRefreshOutline14 size={13} />
|
||||
<IconRefreshOutline14 />
|
||||
</UiButton>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" className="text-xs">{t('refresh')}</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
<Separator />
|
||||
{rootId !== undefined && teamView?.available === true && teamView.team !== null && (
|
||||
<TeamBoard
|
||||
rootId={rootId}
|
||||
@@ -466,27 +475,44 @@ export function SubagentView(props: {
|
||||
/>
|
||||
)}
|
||||
{failedParents.length > 0 && (
|
||||
<div className="flex flex-none items-center justify-between gap-2 px-3 py-2 text-xs text-destructive">
|
||||
<span>{t('catalogLoadFailed', { count: failedParents.length })}</span>
|
||||
<UiButton
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="flex-none"
|
||||
onClick={() => { for (const parent of failedParents) refresh(parent) }}
|
||||
>
|
||||
<IconRefreshOutline14 size={12} />
|
||||
{t('retry')}
|
||||
</UiButton>
|
||||
</div>
|
||||
<Item
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="mx-3 my-2 min-w-0 flex-nowrap gap-2 rounded-md px-2 py-1.5"
|
||||
>
|
||||
<ItemMedia variant="icon" className="text-destructive">
|
||||
<IconWarningOutline16 />
|
||||
</ItemMedia>
|
||||
<ItemContent className="min-w-0 gap-0">
|
||||
<ItemTitle className="text-destructive">
|
||||
{t('catalogLoadFailed', { count: failedParents.length })}
|
||||
</ItemTitle>
|
||||
</ItemContent>
|
||||
<ItemActions>
|
||||
<UiButton
|
||||
variant="outline"
|
||||
size="xs"
|
||||
className="flex-none"
|
||||
onClick={() => { for (const parent of failedParents) refresh(parent) }}
|
||||
>
|
||||
<IconRefreshOutline14 />
|
||||
{t('retry')}
|
||||
</UiButton>
|
||||
</ItemActions>
|
||||
</Item>
|
||||
)}
|
||||
{readyEmpty && (
|
||||
<Empty className="flex-1 gap-4 border-0 p-4">
|
||||
<EmptyHeader className="gap-2">
|
||||
<EmptyMedia variant="icon" className="size-8 rounded-md">
|
||||
<IconAgentPresetOutline16 size={16} />
|
||||
// `border-0` is the no-preflight fix, not a style override: the stock
|
||||
// `Empty` declares only `border-dashed`, and with preflight never
|
||||
// shipped (see ui/theme.css) a bare `border-style: dashed` keeps the
|
||||
// UA's 3px medium width — a thick dashed frame around the page.
|
||||
<Empty className="flex-1 border-0">
|
||||
<EmptyHeader>
|
||||
<EmptyMedia variant="icon">
|
||||
<IconAgentPresetOutline16 />
|
||||
</EmptyMedia>
|
||||
<EmptyTitle className="text-sm font-medium">{t('subagentEmpty')}</EmptyTitle>
|
||||
<EmptyDescription className="text-xs">{t('subagentEmptyDesc')}</EmptyDescription>
|
||||
<EmptyTitle>{t('subagentEmpty')}</EmptyTitle>
|
||||
<EmptyDescription>{t('subagentEmptyDesc')}</EmptyDescription>
|
||||
</EmptyHeader>
|
||||
</Empty>
|
||||
)}
|
||||
|
||||
+130
-83
@@ -10,13 +10,20 @@
|
||||
* Actions: owner reassignment (CAS-immediate), edit/save, reopen or complete,
|
||||
* and a two-step delete.
|
||||
*
|
||||
* Visual base: the vendored shadcn/ui set — `Card` shell, `Badge` status,
|
||||
* `Input` / `Textarea` fields, a `ToggleGroup` owner picker and `Button`
|
||||
* actions — over the token bridge in src/client/ui/theme.css, plus the shared
|
||||
* `MarkdownText` renderer with the plugin's copy labels. The shell paints no
|
||||
* surface of its own: the window is portaled to `document.body`, where
|
||||
* `AnchoredPopover` owns the floating border/radius/shadow, so this card is
|
||||
* layout and content only (the page's "static panels cast no shadow" rule).
|
||||
* Visual base: the vendored shadcn/ui set, composed the way the registry
|
||||
* intends — the full `Card` (header + title + action slot + content + footer),
|
||||
* the task itself as an `Item` slab, the editable half as a `FieldGroup` of
|
||||
* `Field` rows (`FieldLabel` + control + `FieldDescription`), the owner picker
|
||||
* as a `ToggleGroup`, status as a stock `Badge` variant and every action a
|
||||
* stock `Button` variant (the armed delete takes the `destructive` variant
|
||||
* instead of a hand-painted red outline). Colours and type therefore come from
|
||||
* the components and the semantic tokens; `className` stays layout.
|
||||
*
|
||||
* Three resets ride the card root on purpose: the window paints NO surface of
|
||||
* its own. It is portaled to `document.body`, where `AnchoredPopover` owns the
|
||||
* floating border/radius/shadow, so `border-0 bg-transparent shadow-none` let
|
||||
* that shell's surface show through instead of nesting a second, shadowed card
|
||||
* inside it (the page's "static panels cast no shadow" rule).
|
||||
*
|
||||
* That portal also sits OUTSIDE the task page root, so the card re-declares
|
||||
* the page root class (`dsw-tasks`) to pick up the scoped base reset the
|
||||
@@ -24,7 +31,7 @@
|
||||
* inheritance; without preflight a bare `<button>`/`<textarea>` would render
|
||||
* in the UA's own font.
|
||||
*/
|
||||
import { useEffect, useRef, useState, type ReactNode } from 'react'
|
||||
import { useEffect, useId, useRef, useState, type ReactNode } from 'react'
|
||||
import {
|
||||
IconCheckOutline14, IconCloseOutline16, IconEditOutline16, IconPlusOutline16,
|
||||
IconRefreshOutline14, IconTrashOutline16, MarkdownText, StateDot,
|
||||
@@ -36,12 +43,14 @@ import { AnchoredPopover } from './AnchoredPopover.tsx'
|
||||
import { t, type CopyKey } from './locales.ts'
|
||||
import { Badge } from './ui/badge.tsx'
|
||||
import { Button } from './ui/button.tsx'
|
||||
import { Card, CardContent, CardFooter, CardHeader, CardTitle } from './ui/card.tsx'
|
||||
import { Card, CardAction, CardContent, CardFooter, CardHeader, CardTitle } from './ui/card.tsx'
|
||||
import { Field, FieldDescription, FieldError, FieldGroup, FieldLabel, FieldTitle } from './ui/field.tsx'
|
||||
import { Input } from './ui/input.tsx'
|
||||
import { Item, ItemActions, ItemContent, ItemMedia, ItemTitle } from './ui/item.tsx'
|
||||
import { Separator } from './ui/separator.tsx'
|
||||
import { cn } from './ui/utils.ts'
|
||||
import { Textarea } from './ui/textarea.tsx'
|
||||
import { ToggleGroup, ToggleGroupItem } from './ui/toggle-group.tsx'
|
||||
import { cn } from './ui/utils.ts'
|
||||
|
||||
/** One mutation outcome (the host route's own union). */
|
||||
type MutationResult = { ok: true } | { ok: false; error: { code: string; message: string } }
|
||||
@@ -59,12 +68,13 @@ function taskStatusKey(status: SidebarTeamTaskView['status']): CopyKey {
|
||||
/**
|
||||
* The status badge's stock variant: in progress takes `default` (the accent),
|
||||
* everything else settles on `secondary`; a BLOCKED task wears `outline` with
|
||||
* warning ink — hue carries meaning only.
|
||||
* warning ink — hue carries meaning only, and readiness outranks the status
|
||||
* word (the board row maps it the same way).
|
||||
*/
|
||||
function statusVariant(task: SidebarTeamTaskView): 'default' | 'secondary' | 'outline' {
|
||||
if (task.status === 'in_progress') return 'default'
|
||||
if (task.ready) return 'secondary'
|
||||
return 'outline'
|
||||
if (task.status === 'in_progress' && task.ready) return 'default'
|
||||
if (!task.ready) return 'outline'
|
||||
return 'secondary'
|
||||
}
|
||||
|
||||
/** The blocked badge's warning ink (the `outline` variant is otherwise neutral). */
|
||||
@@ -88,7 +98,7 @@ export function MultilineField(props: {
|
||||
}): ReactNode {
|
||||
return (
|
||||
<Textarea
|
||||
className="max-h-[45vh] min-h-[120px] resize-y leading-relaxed"
|
||||
className="max-h-[45vh] min-h-[120px] resize-y"
|
||||
value={props.value}
|
||||
rows={props.rows ?? 7}
|
||||
placeholder={props.placeholder}
|
||||
@@ -107,15 +117,13 @@ export function MultilineField(props: {
|
||||
const UNOWNED = '__unowned__'
|
||||
|
||||
/**
|
||||
* The owner chips' ink: an unselected owner is meta ink, the picked one is
|
||||
* full ink, and the vendored toggle adds its accent tint on top — the tint
|
||||
* alone would read exactly like hover. (Weight cannot carry the selection
|
||||
* here: the page's scoped base reset gives every form control `font: inherit`,
|
||||
* which outranks a font-weight utility.)
|
||||
* The owner picker (single-select toggles; CAS-immediate, so never a draft
|
||||
* field). The chips are the toggle's DEFAULT variant, the same one the board's
|
||||
* owner filter uses: its own state pair separates hover (accent tint, meta ink)
|
||||
* from pressed (accent tint, full ink) without help. That is not true of the
|
||||
* `outline` variant — its hover rules outrank the base ones, so hover and
|
||||
* pressed would look exactly alike and the picked owner would be invisible.
|
||||
*/
|
||||
const OWNER_CHIP = 'text-muted-foreground data-[state=on]:text-foreground'
|
||||
|
||||
/** The owner picker (single-select toggles; CAS-immediate, so never a draft field). */
|
||||
export function OwnerPicker(props: {
|
||||
members: readonly SidebarTeamMemberView[]
|
||||
owner: string | undefined
|
||||
@@ -138,11 +146,11 @@ export function OwnerPicker(props: {
|
||||
props.onPick(next === UNOWNED ? '' : next)
|
||||
}}
|
||||
>
|
||||
<ToggleGroupItem value={UNOWNED} variant="outline" size="sm" className={OWNER_CHIP}>
|
||||
<ToggleGroupItem value={UNOWNED} size="sm">
|
||||
{t('teamTaskUnowned')}
|
||||
</ToggleGroupItem>
|
||||
{props.members.map(member => (
|
||||
<ToggleGroupItem key={member.id} value={member.name} variant="outline" size="sm" className={OWNER_CHIP}>
|
||||
<ToggleGroupItem key={member.id} value={member.name} size="sm">
|
||||
{member.name}
|
||||
</ToggleGroupItem>
|
||||
))}
|
||||
@@ -150,7 +158,7 @@ export function OwnerPicker(props: {
|
||||
)
|
||||
}
|
||||
|
||||
/** The read-only body: markdown description, meta, owner picker. */
|
||||
/** The read-only body: the task slab, the markdown description, the owner picker. */
|
||||
function TaskViewBody(props: {
|
||||
task: SidebarTeamTaskView
|
||||
teammates: readonly SidebarTeamMemberView[]
|
||||
@@ -160,81 +168,113 @@ function TaskViewBody(props: {
|
||||
const { task } = props
|
||||
const body = task.description.trim()
|
||||
return (
|
||||
<div className="flex min-w-0 flex-col gap-2.5">
|
||||
<div className="flex min-w-0 items-center gap-1.5">
|
||||
<StateDot
|
||||
size={6}
|
||||
state={task.status === 'completed' ? 'done' : task.ready ? 'ongoing' : 'warning'}
|
||||
/>
|
||||
<span className="min-w-0 flex-1 truncate font-medium" title={task.subject}>{task.subject}</span>
|
||||
<Badge
|
||||
variant={statusVariant(task)}
|
||||
className={cn('flex-none', statusTone(task))}
|
||||
>
|
||||
{t(task.ready ? taskStatusKey(task.status) : 'teamTaskBlocked')}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-x-2 gap-y-0.5 text-xs text-muted-foreground">
|
||||
<span className="text-foreground-3">{t('teamTaskOwner')}</span>
|
||||
<span>{task.ownerName ?? t('teamTaskUnowned')}</span>
|
||||
{task.blockedBy.length > 0 && (
|
||||
<>
|
||||
<span className="text-foreground-3">{t('teamTaskBlockedBy')}</span>
|
||||
<span className="font-mono tabular-nums">{task.blockedBy.join(' · ')}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="max-h-[260px] overflow-y-auto leading-[1.55] text-foreground">
|
||||
<FieldGroup className="gap-4">
|
||||
{/* The task's own slab: an `Item` is the registry's row primitive, so the
|
||||
subject leads, the owner / blocker meta follows as its description and
|
||||
the status rides the trailing action slot. */}
|
||||
<Item variant="muted" size="sm" className="min-w-0 flex-nowrap gap-2 px-2 py-1.5">
|
||||
<ItemMedia>
|
||||
<StateDot
|
||||
size={6}
|
||||
state={task.status === 'completed' ? 'done' : task.ready ? 'ongoing' : 'warning'}
|
||||
/>
|
||||
</ItemMedia>
|
||||
<ItemContent className="min-w-0 gap-0.5">
|
||||
<ItemTitle className="w-full min-w-0">
|
||||
<span className="min-w-0 flex-1 truncate" title={task.subject}>{task.subject}</span>
|
||||
</ItemTitle>
|
||||
{/* The meta line keeps the page's three ink levels: the labels are a
|
||||
step quieter than the values, and ids stay mono. */}
|
||||
<span className="flex min-w-0 flex-wrap items-center gap-x-2 gap-y-0.5 text-xs text-muted-foreground">
|
||||
<span className="text-foreground-3">{t('teamTaskOwner')}</span>
|
||||
<span>{task.ownerName ?? t('teamTaskUnowned')}</span>
|
||||
{task.blockedBy.length > 0 && (
|
||||
<>
|
||||
<span className="text-foreground-3">{t('teamTaskBlockedBy')}</span>
|
||||
<span className="font-mono tabular-nums">{task.blockedBy.join(' · ')}</span>
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
</ItemContent>
|
||||
<ItemActions>
|
||||
<Badge variant={statusVariant(task)} className={cn('flex-none', statusTone(task))}>
|
||||
{t(task.ready ? taskStatusKey(task.status) : 'teamTaskBlocked')}
|
||||
</Badge>
|
||||
</ItemActions>
|
||||
</Item>
|
||||
|
||||
{/* The description is READ-ONLY here (the markdown preview is the point of
|
||||
the view mode); 编辑 is what turns it into the multi-line editor. */}
|
||||
<Field>
|
||||
<FieldTitle>{t('teamTaskDescription')}</FieldTitle>
|
||||
{body === ''
|
||||
? <div className="text-xs leading-snug text-muted-foreground">{t('teamTaskNoDescription')}</div>
|
||||
? <FieldDescription>{t('teamTaskNoDescription')}</FieldDescription>
|
||||
: (
|
||||
<MarkdownText
|
||||
{...markdownTextProps(body, { copyLabel: t('copy'), copiedLabel: t('copied') })}
|
||||
/>
|
||||
<div className="max-h-[260px] overflow-y-auto">
|
||||
<MarkdownText
|
||||
{...markdownTextProps(body, { copyLabel: t('copy'), copiedLabel: t('copied') })}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<span className="text-xs text-muted-foreground">{t('teamTaskOwner')}</span>
|
||||
</Field>
|
||||
|
||||
<Field data-disabled={props.busy ? true : undefined}>
|
||||
<FieldLabel>{t('teamTaskOwner')}</FieldLabel>
|
||||
<OwnerPicker
|
||||
members={props.teammates}
|
||||
owner={task.ownerName}
|
||||
disabled={props.busy}
|
||||
onPick={props.onReassign}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
)
|
||||
}
|
||||
|
||||
/** The editing body: subject (shadcn Input) + description (our multi-line field). */
|
||||
/**
|
||||
* The editing body: subject (a stock `Input`) and description (our multi-line
|
||||
* field) as two `Field` rows. A BLANK subject is the form's only invalid state
|
||||
* — it is reported on the `Field` (`data-invalid`) and the control
|
||||
* (`aria-invalid`) once the reader has been in the field, and Save stays
|
||||
* disabled until it is filled.
|
||||
*
|
||||
* The one-line input carries its hint as a `FieldDescription` (a placeholder
|
||||
* would vanish the moment the reader focuses it); the tall textarea keeps its
|
||||
* hint inside the field, where there is room for it.
|
||||
*/
|
||||
function TaskEditBody(props: {
|
||||
subject: string
|
||||
description: string
|
||||
invalid: boolean
|
||||
onSubject(next: string): void
|
||||
onDescription(next: string): void
|
||||
}): ReactNode {
|
||||
const subjectId = useId()
|
||||
const subjectHintId = useId()
|
||||
return (
|
||||
<div className="flex flex-col gap-2.5" data-popover-no-drag>
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="text-xs text-muted-foreground">{t('teamTaskSubject')}</span>
|
||||
<FieldGroup className="gap-4" data-popover-no-drag>
|
||||
<Field data-invalid={props.invalid ? true : undefined}>
|
||||
<FieldLabel htmlFor={subjectId}>{t('teamTaskSubject')}</FieldLabel>
|
||||
<Input
|
||||
className="h-8"
|
||||
id={subjectId}
|
||||
value={props.subject}
|
||||
placeholder={t('teamTaskSubjectPlaceholder')}
|
||||
aria-label={t('teamTaskSubject')}
|
||||
aria-invalid={props.invalid ? true : undefined}
|
||||
aria-describedby={subjectHintId}
|
||||
onChange={(event) => { props.onSubject(event.target.value) }}
|
||||
/>
|
||||
</label>
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="text-xs text-muted-foreground">{t('teamTaskDescription')}</span>
|
||||
<FieldDescription id={subjectHintId}>{t('teamTaskSubjectPlaceholder')}</FieldDescription>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel>{t('teamTaskDescription')}</FieldLabel>
|
||||
<MultilineField
|
||||
value={props.description}
|
||||
label={t('teamTaskDescription')}
|
||||
placeholder={t('teamTaskDescriptionPlaceholder')}
|
||||
onChange={props.onDescription}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -256,6 +296,8 @@ export function TaskWindow(props: TaskWindowProps): ReactNode {
|
||||
const [editing, setEditing] = useState(creating)
|
||||
const [subject, setSubject] = useState(task?.subject ?? '')
|
||||
const [description, setDescription] = useState(task?.description ?? '')
|
||||
/** The reader has been in the subject field (see TaskEditBody's contract). */
|
||||
const [subjectTouched, setSubjectTouched] = useState(false)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [note, setNote] = useState<string | undefined>(undefined)
|
||||
const [armedDelete, setArmedDelete] = useState(false)
|
||||
@@ -279,6 +321,7 @@ export function TaskWindow(props: TaskWindowProps): ReactNode {
|
||||
const seed = seedRef.current
|
||||
setSubject(seed.subject)
|
||||
setDescription(seed.description)
|
||||
setSubjectTouched(false)
|
||||
setEditing(seed.creating)
|
||||
}, [task?.id])
|
||||
|
||||
@@ -338,14 +381,14 @@ export function TaskWindow(props: TaskWindowProps): ReactNode {
|
||||
|
||||
return (
|
||||
<Card
|
||||
className="dsw-tasks box-border gap-0 rounded-lg border-0 bg-transparent py-0 text-sm"
|
||||
className="dsw-tasks box-border gap-0 rounded-lg border-0 bg-transparent py-0 shadow-none"
|
||||
data-popover-handle
|
||||
>
|
||||
<CardHeader className="flex flex-row items-center justify-between gap-2 px-4 py-3">
|
||||
<CardTitle className="min-w-0 flex-1 truncate text-sm font-medium text-muted-foreground">
|
||||
<CardHeader className="items-center px-4 py-3">
|
||||
<CardTitle className="min-w-0 truncate">
|
||||
{creating ? t('teamTaskCreate') : editing ? t('teamTaskEdit') : t('teamTaskDetail')}
|
||||
</CardTitle>
|
||||
<div className="flex flex-none items-center gap-1" data-popover-no-drag>
|
||||
<CardAction data-popover-no-drag>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
@@ -355,7 +398,7 @@ export function TaskWindow(props: TaskWindowProps): ReactNode {
|
||||
>
|
||||
<IconCloseOutline16 />
|
||||
</Button>
|
||||
</div>
|
||||
</CardAction>
|
||||
</CardHeader>
|
||||
<Separator />
|
||||
|
||||
@@ -365,7 +408,8 @@ export function TaskWindow(props: TaskWindowProps): ReactNode {
|
||||
<TaskEditBody
|
||||
subject={subject}
|
||||
description={description}
|
||||
onSubject={setSubject}
|
||||
invalid={subjectTouched && subject.trim() === ''}
|
||||
onSubject={(next) => { setSubjectTouched(true); setSubject(next) }}
|
||||
onDescription={setDescription}
|
||||
/>
|
||||
)
|
||||
@@ -377,7 +421,9 @@ export function TaskWindow(props: TaskWindowProps): ReactNode {
|
||||
onReassign={reassign}
|
||||
/>
|
||||
)}
|
||||
{note !== undefined && <div className="mt-2 text-xs leading-snug text-warning">{note}</div>}
|
||||
{/* The mutation outcome (CAS conflict / failure) is the form's own
|
||||
alert: FieldError carries the role and the destructive ink. */}
|
||||
{note !== undefined && <FieldError className="mt-2">{note}</FieldError>}
|
||||
</CardContent>
|
||||
|
||||
<Separator />
|
||||
@@ -402,6 +448,7 @@ export function TaskWindow(props: TaskWindowProps): ReactNode {
|
||||
else if (task !== undefined) {
|
||||
setSubject(task.subject)
|
||||
setDescription(task.description)
|
||||
setSubjectTouched(false)
|
||||
setEditing(false)
|
||||
}
|
||||
}}
|
||||
@@ -444,12 +491,12 @@ export function TaskWindow(props: TaskWindowProps): ReactNode {
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="outline"
|
||||
// Two-step confirm: the ARMED click takes the `destructive`
|
||||
// variant (its copy already says 确认删除) — danger is a
|
||||
// component variant, never a permanently red button and never a
|
||||
// hand-painted outline.
|
||||
variant={armedDelete ? 'destructive' : 'outline'}
|
||||
size="sm"
|
||||
// Two-step confirm, danger only on the armed click: outline +
|
||||
// destructive ink beats a permanently red button (and keeps the
|
||||
// variant's own 1px border).
|
||||
className={armedDelete ? 'border-destructive/40 text-destructive' : undefined}
|
||||
disabled={busy}
|
||||
onClick={() => {
|
||||
if (!armedDelete) { setArmedDelete(true); return }
|
||||
|
||||
@@ -78,8 +78,14 @@ const CLICK_TOLERANCE_PX = 4
|
||||
* The shared shell of every node card: absolute (the layout owns left/top),
|
||||
* the compact stock node recipe — `rounded-lg border bg-card p-2.5 shadow-xs`
|
||||
* — with `hover:bg-muted` as the only hover change. No tint.
|
||||
*
|
||||
* Every class here is stock: `Card` already carries `border` (hairline in
|
||||
* `--border`) and `text-card-foreground`, so this only tightens the padding,
|
||||
* drops the stock `gap-6` (the graph's three lines have their own `mt-0.5`)
|
||||
* and sets the canvas elevation. The border ink is never re-declared — the
|
||||
* lead node's stronger rung is the one exception, below.
|
||||
*/
|
||||
const NODE_CARD = 'absolute cursor-pointer gap-0 overflow-hidden rounded-lg border border-border bg-card p-2.5 shadow-xs transition-colors hover:bg-muted focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-offset-1 focus-visible:outline-ring'
|
||||
const NODE_CARD = 'absolute cursor-pointer gap-0 overflow-hidden rounded-lg bg-card p-2.5 shadow-xs transition-colors hover:bg-muted focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-offset-1 focus-visible:outline-ring'
|
||||
/** The on-screen session: a 2px accent bar, never a tinted card. */
|
||||
const NODE_ACCENT = 'border-l-2 border-l-primary'
|
||||
/** Settled nodes recede by INK, not by opacity: the whole card drops to the
|
||||
@@ -387,11 +393,11 @@ export function TasksGraph(props: TasksGraphProps): ReactNode {
|
||||
same `loading` copy key). `pointer-events-none` keeps the
|
||||
canvas's pan/zoom/node clicks fully live underneath. */}
|
||||
{loading === true && nodes.length === 0 && (
|
||||
<div className="pointer-events-none absolute inset-0 z-[6] flex items-center justify-center">
|
||||
<div className="pointer-events-none absolute inset-0 z-10 flex items-center justify-center">
|
||||
<span className="flex items-center gap-1.5 px-6 py-3 text-xs text-muted-foreground">
|
||||
{/* The visible line carries the copy; the glyph is decoration. */}
|
||||
<span aria-hidden="true" className="flex flex-none items-center">
|
||||
<Spinner size={12} />
|
||||
<Spinner className="size-3" />
|
||||
</span>
|
||||
{t('loading')}
|
||||
</span>
|
||||
@@ -458,7 +464,7 @@ export function TasksGraph(props: TasksGraphProps): ReactNode {
|
||||
})}
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
<div className="absolute right-2.5 bottom-2.5 z-[6] flex flex-row items-center gap-0.5" data-graph-controls>
|
||||
<div className="absolute right-2.5 bottom-2.5 z-10 flex flex-row items-center gap-0.5" data-graph-controls>
|
||||
<ViewModeToggle mode={mode} onModeChange={onModeChange} />
|
||||
<FoldToggleButton folded={folded} onToggleFold={onToggleFold} />
|
||||
<ControlButton label={t('tasksZoomOut')} onClick={() => { zoomBy(1 / 1.2) }}>−</ControlButton>
|
||||
|
||||
+151
-82
@@ -3,14 +3,20 @@
|
||||
* live in AnchoredPopover): the agent node detail with a transcript jump and
|
||||
* the workflow run detail with clickable member rows.
|
||||
*
|
||||
* Visual language: the vendored shadcn/ui pieces (Card + Separator + Badge +
|
||||
* Button) over Tailwind utilities bound to the `--dsw-*` tokens. The surface
|
||||
* is flat — a 1px hairline plus the popover surface ladder carries the
|
||||
* layering; the only shadow is the one AnchoredPopover itself puts on the
|
||||
* floating card. Ink stays on three levels (foreground / muted-foreground /
|
||||
* foreground-3), mono meta is `font-mono tabular-nums`, and hue is semantic
|
||||
* only: success = settled, warning = blocked, destructive = failed, primary =
|
||||
* the one main action per popover.
|
||||
* Visual base: the vendored shadcn/ui set composed the registry's way — the
|
||||
* full `Card` (header + title + description + action slot + content + footer),
|
||||
* the node / run and every list row as an `Item` (`ItemMedia` + `ItemTitle` +
|
||||
* `ItemActions`) inside an `ItemGroup`, status as a stock `Badge` variant
|
||||
* (running = `default`, settled = `secondary`, failed = `destructive`) and the
|
||||
* jump as a stock `Button` — so colours and type come from those components
|
||||
* and the semantic tokens. The surface is flat and owned by the shell: like
|
||||
* the task window, the card paints no surface of its own (`border-0
|
||||
* bg-transparent shadow-none`) because AnchoredPopover already draws the
|
||||
* floating border/radius/shadow — nesting a second bordered card inside it
|
||||
* would double the hairline. The only shadow is the shell's; ink stays on
|
||||
* three levels (foreground / muted-foreground / foreground-3) and mono meta is
|
||||
* `font-mono tabular-nums`; hue is semantic only — success = settled, warning =
|
||||
* blocked, destructive = failed, primary = the one main action per popover.
|
||||
*
|
||||
* The team board is NOT here: it is always visible as a strip above the
|
||||
* canvas (TeamBoard.tsx).
|
||||
@@ -22,12 +28,13 @@ import {
|
||||
import type { SidebarSubagentAddress } from '../context-types.ts'
|
||||
import type { TasksAgentNode, TasksWorkflowNode } from './tasks-model.ts'
|
||||
import {
|
||||
flatten, nodeDotState, taskDotState, taskStatusKey, workflowStatusKey,
|
||||
flatten, taskDotState, taskStatusKey, workflowStatusKey,
|
||||
} from './tasks-shared.tsx'
|
||||
import { t, type CopyKey } from './locales.ts'
|
||||
import { Badge } from './ui/badge.tsx'
|
||||
import { Button as UiButton } from './ui/button.tsx'
|
||||
import { Card } from './ui/card.tsx'
|
||||
import { Button } from './ui/button.tsx'
|
||||
import { Card, CardAction, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from './ui/card.tsx'
|
||||
import { Item, ItemActions, ItemContent, ItemGroup, ItemMedia, ItemTitle } from './ui/item.tsx'
|
||||
import { Separator } from './ui/separator.tsx'
|
||||
|
||||
/** The workflow run status (the badge's semantic ink selector). */
|
||||
@@ -54,9 +61,21 @@ function statusVariant(status: WorkflowStatus | 'pending' | 'in_progress' | 'com
|
||||
return 'secondary'
|
||||
}
|
||||
|
||||
/** The popover's key/value grid (label left, value right, mono on request).
|
||||
* The label column is a fixed 64px so every row's value starts on one axis —
|
||||
* an `auto` column makes the labels ragged across rows. */
|
||||
/** The node display state on the same stock variant set. */
|
||||
function stateVariant(state: TasksAgentNode['state']): 'default' | 'secondary' | 'destructive' {
|
||||
if (state === 'running') return 'default'
|
||||
if (state === 'error') return 'destructive'
|
||||
return 'secondary'
|
||||
}
|
||||
|
||||
/**
|
||||
* The popover's key/value grid (label left, value right, mono on request).
|
||||
* The read-only meta stays a definition list rather than a `FieldGroup`: the
|
||||
* three ink levels ARE the design language here, and a `FieldLabel` would have
|
||||
* to be repainted (or would shout) to say the same thing. The label column is
|
||||
* a fixed 64px so every row's value starts on one axis — an `auto` column
|
||||
* makes the labels ragged across rows.
|
||||
*/
|
||||
function PopRows(props: { children: ReactNode }): ReactNode {
|
||||
return <dl className="grid grid-cols-[64px_1fr] items-baseline gap-x-3 gap-y-1">{props.children}</dl>
|
||||
}
|
||||
@@ -79,7 +98,7 @@ function PopRow(props: { label: string; mono?: boolean; children: ReactNode }):
|
||||
)
|
||||
}
|
||||
|
||||
/** The mono kicker above a popover list ("任务" / a phase title). */
|
||||
/** The mono kicker above a popover list ("共享任务" / a phase title). */
|
||||
function GroupLabel(props: { children: ReactNode }): ReactNode {
|
||||
return (
|
||||
<div className="px-1 pt-0.5 font-mono text-xs tracking-wide text-foreground-3 uppercase">
|
||||
@@ -111,16 +130,21 @@ export function AgentNodePopover(props: {
|
||||
// document.body — OUTSIDE the page root — so it must re-declare the page
|
||||
// root class to pick up the scoped base reset the plugin ships instead of
|
||||
// preflight (see TaskWindow.tsx for the same pattern).
|
||||
<Card className="dsw-tasks gap-2 rounded-lg border-border bg-popover p-0 py-2.5">
|
||||
<div className="px-3 font-mono text-xs text-foreground-3">{t('tasksNodeDetail')}</div>
|
||||
<Card className="dsw-tasks box-border gap-0 rounded-lg border-0 bg-transparent py-0 shadow-none">
|
||||
{/* The node leads (title) over the card's kind (description), and its
|
||||
display state rides the header's action slot as a stock badge. */}
|
||||
<CardHeader className="items-center px-3 py-2">
|
||||
<CardTitle className="min-w-0">
|
||||
<span className="min-w-0 truncate" title={node.label}>{node.label}</span>
|
||||
</CardTitle>
|
||||
<CardDescription>{t('tasksNodeDetail')}</CardDescription>
|
||||
<CardAction>
|
||||
<StatusBadge variant={stateVariant(node.state)}>{t(stateKey(node.state))}</StatusBadge>
|
||||
</CardAction>
|
||||
</CardHeader>
|
||||
<Separator />
|
||||
<div className="flex flex-col gap-1.5 px-3">
|
||||
<span className="truncate text-sm font-medium" title={node.label}>{node.label}</span>
|
||||
<CardContent className="flex min-w-0 flex-col gap-2 px-3 py-2.5">
|
||||
<PopRows>
|
||||
<PopRow label={t('tasksNodeState')}>
|
||||
<StateDot state={nodeDotState(node.state)} size={6} />
|
||||
<span className="text-muted-foreground">{t(stateKey(node.state))}</span>
|
||||
</PopRow>
|
||||
{node.mode !== undefined && (
|
||||
<PopRow label={t('tasksNodeMode')}>
|
||||
{node.mode === 'one-shot' ? t('subagentModeOneShot') : t('subagentModeContinuable')}
|
||||
@@ -141,38 +165,59 @@ export function AgentNodePopover(props: {
|
||||
{liveText !== undefined && liveTool !== undefined && (
|
||||
<div className="line-clamp-2 text-xs text-muted-foreground" title={liveText}>{liveText}</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
{tasks.length > 0 && (
|
||||
<>
|
||||
<Separator />
|
||||
<div className="flex flex-col gap-1 px-3">
|
||||
<CardContent className="flex flex-col gap-1 px-3 py-2.5">
|
||||
<GroupLabel>{t('tasksNodeTasks')}</GroupLabel>
|
||||
<div className="-mx-1 flex max-h-[190px] flex-col gap-0.5 overflow-y-auto">
|
||||
{/* Each row opens the shared task window, so the whole row is the
|
||||
button (`Item asChild` puts the stock row geometry on it, and
|
||||
the trailing status badge on the actions slot). */}
|
||||
<ItemGroup className="max-h-[190px] overflow-y-auto">
|
||||
{tasks.map(task => (
|
||||
<UiButton
|
||||
<Item
|
||||
key={task.id}
|
||||
variant="ghost"
|
||||
asChild
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-auto w-full justify-start gap-1.5 rounded-sm px-1 py-1 text-left text-xs font-normal"
|
||||
title={task.subject}
|
||||
onClick={(event) => { onOpenTask(task.id, event.currentTarget) }}
|
||||
className="min-w-0 flex-nowrap gap-2 px-2 py-1.5 hover:bg-muted"
|
||||
>
|
||||
<StateDot state={taskDotState(task)} size={6} />
|
||||
<span className="min-w-0 flex-1 truncate text-xs font-medium">{task.subject}</span>
|
||||
<StatusBadge variant={statusVariant(task.status)}>{t(taskStatusKey(task.status))}</StatusBadge>
|
||||
</UiButton>
|
||||
<button
|
||||
type="button"
|
||||
className="text-left"
|
||||
title={task.subject}
|
||||
onClick={(event) => { onOpenTask(task.id, event.currentTarget) }}
|
||||
>
|
||||
<ItemMedia>
|
||||
<StateDot state={taskDotState(task)} size={6} />
|
||||
</ItemMedia>
|
||||
<ItemContent className="min-w-0">
|
||||
<ItemTitle className="w-full min-w-0">
|
||||
<span className="min-w-0 flex-1 truncate">{task.subject}</span>
|
||||
</ItemTitle>
|
||||
</ItemContent>
|
||||
<ItemActions>
|
||||
<StatusBadge variant={statusVariant(task.status)}>
|
||||
{t(taskStatusKey(task.status))}
|
||||
</StatusBadge>
|
||||
</ItemActions>
|
||||
</button>
|
||||
</Item>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</ItemGroup>
|
||||
</CardContent>
|
||||
</>
|
||||
)}
|
||||
{(node.childAddress !== undefined || node.parentId === undefined) && (
|
||||
<>
|
||||
{tasks.length > 0 && <Separator />}
|
||||
<UiButton size="sm" className="mx-3 w-auto" onClick={() => { onJump(node) }}>
|
||||
<IconRightUpOutline14 size={12} />
|
||||
{t('tasksNodeJump')}
|
||||
</UiButton>
|
||||
<Separator />
|
||||
<CardFooter className="justify-end px-3 py-2">
|
||||
<Button size="sm" onClick={() => { onJump(node) }}>
|
||||
<IconRightUpOutline14 />
|
||||
{t('tasksNodeJump')}
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</>
|
||||
)}
|
||||
</Card>
|
||||
@@ -195,58 +240,82 @@ export function WorkflowNodePopover(props: {
|
||||
}
|
||||
return (
|
||||
// `dsw-tasks`: same portal-scope reset as AgentNodePopover above.
|
||||
<Card className="dsw-tasks gap-2 rounded-lg border-border bg-popover p-0 py-2.5">
|
||||
<div className="px-3 font-mono text-xs text-foreground-3">{t('workflowRun')}</div>
|
||||
<Separator />
|
||||
<div className="flex flex-col gap-1.5 px-3">
|
||||
<div className="flex min-w-0 items-center gap-1.5" title={run.name}>
|
||||
<span className="flex-none text-muted-foreground" aria-hidden="true">
|
||||
<IconBranchOutline16 size={12} />
|
||||
<Card className="dsw-tasks box-border gap-0 rounded-lg border-0 bg-transparent py-0 shadow-none">
|
||||
{/* The run's identity leads and keeps its branch glyph; the run status
|
||||
rides the header's action slot as a stock badge. */}
|
||||
<CardHeader className="items-center px-3 py-2">
|
||||
<CardTitle className="flex min-w-0 items-center gap-1.5">
|
||||
{/* The visible line carries the copy; the glyph is decoration. */}
|
||||
<span className="flex flex-none items-center" aria-hidden="true">
|
||||
<IconBranchOutline16 />
|
||||
</span>
|
||||
<span className="truncate text-sm font-medium">{run.name}</span>
|
||||
</div>
|
||||
<PopRows>
|
||||
<PopRow label={t('tasksNodeState')}>
|
||||
<StatusBadge variant={statusVariant(run.status)}>{t(workflowStatusKey(run.status))}</StatusBadge>
|
||||
</PopRow>
|
||||
</PopRows>
|
||||
</div>
|
||||
<span className="min-w-0 truncate" title={run.name}>{run.name}</span>
|
||||
</CardTitle>
|
||||
<CardDescription>{t('workflowRun')}</CardDescription>
|
||||
<CardAction>
|
||||
<StatusBadge variant={statusVariant(run.status)}>{t(workflowStatusKey(run.status))}</StatusBadge>
|
||||
</CardAction>
|
||||
</CardHeader>
|
||||
{run.phases.length > 0 && (
|
||||
<>
|
||||
<Separator />
|
||||
<div className="flex max-h-[220px] flex-col gap-1 overflow-y-auto px-3">
|
||||
<CardContent className="flex max-h-[220px] flex-col gap-2 overflow-y-auto px-3 py-2.5">
|
||||
{run.phases.map((phase, phaseIndex) => (
|
||||
<div key={`${phase.title ?? 'phase'}-${phaseIndex}`} className="flex flex-col gap-0.5">
|
||||
<GroupLabel>{phase.title ?? t('workflowPhaseUnnamed')}</GroupLabel>
|
||||
{phase.members.length === 0 && (
|
||||
<div className="px-1 py-1 text-xs text-foreground-3">{t('subagentEmpty')}</div>
|
||||
)}
|
||||
{phase.members.map(member => (
|
||||
<div
|
||||
key={member.seq}
|
||||
role={member.childId === '' ? undefined : 'button'}
|
||||
className={
|
||||
member.childId === ''
|
||||
? 'flex items-center gap-1.5 rounded-sm px-1 py-1'
|
||||
: 'flex cursor-pointer items-center gap-1.5 rounded-sm px-1 py-1 transition-colors hover:bg-muted'
|
||||
}
|
||||
title={member.label}
|
||||
onClick={() => {
|
||||
if (member.childId === '') return
|
||||
onJumpMember({
|
||||
parentSessionId: run.originSessionId,
|
||||
childSessionId: member.childId,
|
||||
mode: 'one-shot',
|
||||
})
|
||||
}}
|
||||
>
|
||||
<StateDot size={6} state={memberDot(member.outcome)} />
|
||||
<span className="min-w-0 flex-1 truncate text-xs">{member.label}</span>
|
||||
</div>
|
||||
))}
|
||||
<ItemGroup>
|
||||
{phase.members.map(member => {
|
||||
const body = (
|
||||
<>
|
||||
<ItemMedia>
|
||||
<StateDot size={6} state={memberDot(member.outcome)} />
|
||||
</ItemMedia>
|
||||
<ItemContent className="min-w-0">
|
||||
<ItemTitle className="w-full min-w-0">
|
||||
<span className="min-w-0 flex-1 truncate" title={member.label}>{member.label}</span>
|
||||
</ItemTitle>
|
||||
</ItemContent>
|
||||
</>
|
||||
)
|
||||
// A member row IS a jump affordance when it has a child
|
||||
// session; without one it stays a plain tally line.
|
||||
return member.childId === ''
|
||||
? (
|
||||
<Item key={member.seq} size="sm" className="min-w-0 flex-nowrap gap-2 px-1.5 py-1">
|
||||
{body}
|
||||
</Item>
|
||||
)
|
||||
: (
|
||||
<Item
|
||||
key={member.seq}
|
||||
asChild
|
||||
size="sm"
|
||||
className="min-w-0 flex-nowrap gap-2 px-1.5 py-1 hover:bg-muted"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="text-left"
|
||||
title={member.label}
|
||||
onClick={() => {
|
||||
onJumpMember({
|
||||
parentSessionId: run.originSessionId,
|
||||
childSessionId: member.childId,
|
||||
mode: 'one-shot',
|
||||
})
|
||||
}}
|
||||
>
|
||||
{body}
|
||||
</button>
|
||||
</Item>
|
||||
)
|
||||
})}
|
||||
</ItemGroup>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
+12
-11
@@ -35,6 +35,7 @@ import { FoldToggleButton, ViewModeToggle } from './TasksGraph.tsx'
|
||||
import { Collapsible, CollapsibleContent } from './ui/collapsible.tsx'
|
||||
import { ScrollArea } from './ui/scroll-area.tsx'
|
||||
import { Spinner } from './ui/spinner.tsx'
|
||||
import { cn } from './ui/utils.ts'
|
||||
import { t } from './locales.ts'
|
||||
|
||||
/** The row slab: one shared box, so every row kind lines up on the same grid. */
|
||||
@@ -136,13 +137,13 @@ export function TasksTree(props: TasksTreeProps): ReactNode {
|
||||
tabIndex={0}
|
||||
aria-level={depth + 1}
|
||||
aria-label={`${t('tasksFoldCompleted', { count: node.count })} · ${t('tasksFoldExpand')}`}
|
||||
className={`${ROW} ${ROW_FOLD} ${ROW_HOVER}`}
|
||||
className={cn(ROW, ROW_FOLD, ROW_HOVER)}
|
||||
onClick={onToggleFold}
|
||||
onKeyDown={(event) => { activateOnKey(event, onToggleFold) }}
|
||||
>
|
||||
<span className={GLYPH} aria-hidden="true"><FoldGlyph /></span>
|
||||
<span className="flex min-w-0 flex-1 flex-col gap-0.5">
|
||||
<span className={`flex items-center gap-1.5 font-medium ${LINE}`}>
|
||||
<span className={cn('flex items-center gap-1.5 font-medium', LINE)}>
|
||||
{t('tasksFoldCompleted', { count: node.count })}
|
||||
<span className={META}>
|
||||
{t(folded ? 'tasksFoldExpand' : 'tasksFoldCollapse')}
|
||||
@@ -162,7 +163,7 @@ export function TasksTree(props: TasksTreeProps): ReactNode {
|
||||
aria-level={depth + 1}
|
||||
aria-expanded="true"
|
||||
aria-label={`${node.run.name} ${workflowMeta(node)}`}
|
||||
className={`${ROW} ${ROW_HOVER} ${node.run.status !== 'running' ? ROW_SETTLED : ''}`}
|
||||
className={cn(ROW, ROW_HOVER, node.run.status !== 'running' && ROW_SETTLED)}
|
||||
onClick={(event) => { onWorkflowInfo(node, event.currentTarget) }}
|
||||
onKeyDown={(event) => {
|
||||
activateOnKey(event, () => { onWorkflowInfo(node, event.currentTarget as HTMLElement) })
|
||||
@@ -173,7 +174,7 @@ export function TasksTree(props: TasksTreeProps): ReactNode {
|
||||
</span>
|
||||
<span className={GLYPH} aria-hidden="true"><WorkflowGlyph /></span>
|
||||
<span className="flex min-w-0 flex-1 flex-col gap-0.5">
|
||||
<span className={`font-medium ${LINE}`}>{node.run.name}</span>
|
||||
<span className={cn('font-medium', LINE)}>{node.run.name}</span>
|
||||
<span className={META}>{workflowMeta(node)}</span>
|
||||
</span>
|
||||
</div>
|
||||
@@ -187,12 +188,12 @@ export function TasksTree(props: TasksTreeProps): ReactNode {
|
||||
aria-level={depth + 1}
|
||||
aria-label={`${node.label} ${agentMeta(node)}`}
|
||||
aria-current={node.current ? 'true' : undefined}
|
||||
className={[
|
||||
className={cn(
|
||||
ROW,
|
||||
ROW_HOVER,
|
||||
node.current ? ROW_CURRENT : '',
|
||||
(node.state === 'done' || node.state === 'error') && !node.current ? ROW_SETTLED : '',
|
||||
].join(' ')}
|
||||
node.current && ROW_CURRENT,
|
||||
(node.state === 'done' || node.state === 'error') && !node.current && ROW_SETTLED,
|
||||
)}
|
||||
onClick={(event) => { onNodeInfo(node, event.currentTarget) }}
|
||||
onKeyDown={(event) => {
|
||||
activateOnKey(event, () => { onNodeInfo(node, event.currentTarget as HTMLElement) })
|
||||
@@ -207,7 +208,7 @@ export function TasksTree(props: TasksTreeProps): ReactNode {
|
||||
</span>
|
||||
<span className={GLYPH} aria-hidden="true"><AgentGlyph node={node} /></span>
|
||||
<span className="flex min-w-0 flex-1 flex-col gap-0.5">
|
||||
<span className={`font-medium ${LINE}`}>{node.label}</span>
|
||||
<span className={cn('font-medium', LINE)}>{node.label}</span>
|
||||
<span className={META}>{agentMeta(node)}</span>
|
||||
{node.state === 'running' && <LiveLine live={node.live} />}
|
||||
<TaskLine tasks={node.tasks} onOpenTask={onOpenTask} />
|
||||
@@ -243,7 +244,7 @@ export function TasksTree(props: TasksTreeProps): ReactNode {
|
||||
<div className="flex items-center justify-center gap-1.5 px-6 py-3 text-xs text-muted-foreground">
|
||||
{/* The visible line carries the copy; the glyph is decoration. */}
|
||||
<span aria-hidden="true" className="flex flex-none items-center">
|
||||
<Spinner size={12} />
|
||||
<Spinner className="size-3" />
|
||||
</span>
|
||||
{t('loading')}
|
||||
</div>
|
||||
@@ -252,7 +253,7 @@ export function TasksTree(props: TasksTreeProps): ReactNode {
|
||||
</div>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
<div className="absolute right-2.5 bottom-2.5 z-[6] flex flex-row items-center" data-graph-controls>
|
||||
<div className="absolute right-2.5 bottom-2.5 z-10 flex flex-row items-center" data-graph-controls>
|
||||
<ViewModeToggle mode={mode} onModeChange={onModeChange} />
|
||||
<FoldToggleButton folded={folded} onToggleFold={onToggleFold} />
|
||||
</div>
|
||||
|
||||
+163
-138
@@ -4,12 +4,16 @@
|
||||
* the native sidebar is narrow: members wrap as a segmented filter and the
|
||||
* task list scrolls inside a bounded height, so the canvas keeps its room.
|
||||
*
|
||||
* Visual language: shadcn/ui over the task page's Tailwind tokens
|
||||
* (src/client/ui/theme.css) — the strip is a stock Card (`rounded-xl border
|
||||
* bg-card shadow-sm`), members are a ToggleGroup whose selected chip carries
|
||||
* the accent, and every task row ends in one DropdownMenu. Ink stays on the
|
||||
* three token levels (text-sm body / text-xs mono meta, `font-mono
|
||||
* tabular-nums`), and status color is the stock Badge variant set only.
|
||||
* Visual language: the vendored shadcn/ui set over the task page's Tailwind
|
||||
* tokens (src/client/ui/theme.css) — one full `Card` whose bands (header,
|
||||
* filter, `CardContent` list, `CardFooter`) are separated by hairlines, task
|
||||
* rows are `Item`s, members are a `ToggleGroup` whose picked chip carries the
|
||||
* component's own accent, and every task row ends in one `DropdownMenu`.
|
||||
* Type and color come from the components' variants and the semantic tokens
|
||||
* (the count keeps the page's mono-meta convention); status color is the stock
|
||||
* `Badge` variant set only. The strip's compact band padding is its one
|
||||
* deliberate departure — it is a permanently visible strip, so it stays thin
|
||||
* and lets the canvas below it keep its height.
|
||||
*
|
||||
* Behaviour is unchanged, and deliberately so: the row and EVERY menu entry
|
||||
* open the shared task window, which is the ONE surface owning view / edit /
|
||||
@@ -19,21 +23,21 @@
|
||||
* before.
|
||||
*/
|
||||
import { useMemo, useRef, useState, type ReactNode } from 'react'
|
||||
import {
|
||||
Button, IconChecklistOutline14, IconChevronUpOutline14, IconPlusOutline16, StateDot,
|
||||
} from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import { IconChecklistOutline14, IconChevronUpOutline14, IconPlusOutline16, StateDot } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { SidebarTeamMemberView, SidebarTeamTaskView } from '../context-types.ts'
|
||||
import { t, type CopyKey } from './locales.ts'
|
||||
import { cn } from './ui/utils.ts'
|
||||
import { Badge } from './ui/badge.tsx'
|
||||
import { Card } from './ui/card.tsx'
|
||||
import { Empty } from './ui/empty.tsx'
|
||||
import { Separator } from './ui/separator.tsx'
|
||||
import { ToggleGroup, ToggleGroupItem } from './ui/toggle-group.tsx'
|
||||
import { Button } from './ui/button.tsx'
|
||||
import { Card, CardContent, CardFooter, CardHeader, CardTitle } from './ui/card.tsx'
|
||||
import {
|
||||
DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator,
|
||||
DropdownMenu, DropdownMenuContent, DropdownMenuGroup, DropdownMenuItem, DropdownMenuSeparator,
|
||||
DropdownMenuSub, DropdownMenuSubContent, DropdownMenuSubTrigger, DropdownMenuTrigger,
|
||||
} from './ui/dropdown-menu.tsx'
|
||||
import { Empty, EmptyDescription } from './ui/empty.tsx'
|
||||
import { Item, ItemContent, ItemGroup, ItemMedia, ItemTitle } from './ui/item.tsx'
|
||||
import { Separator } from './ui/separator.tsx'
|
||||
import { ToggleGroup, ToggleGroupItem } from './ui/toggle-group.tsx'
|
||||
import { cn } from './ui/utils.ts'
|
||||
|
||||
/** The task status label key. */
|
||||
function taskStatusKey(status: SidebarTeamTaskView['status']): CopyKey {
|
||||
@@ -81,22 +85,20 @@ function memberDotState(member: SidebarTeamMemberView): 'ongoing' | 'error' | 'i
|
||||
*/
|
||||
const FILTER_ALL = '__all__'
|
||||
|
||||
/** The shared slab: a filled rounded row whose only hover change is its background. */
|
||||
const ROW_CLASS =
|
||||
'flex min-w-0 cursor-pointer items-center gap-2 rounded-md bg-transparent px-2 py-2 text-left transition-colors outline-none hover:bg-muted focus-visible:ring-[3px] focus-visible:ring-ring/50'
|
||||
/** The shared filter chip: sm toggle geometry, accent when picked (stock). */
|
||||
const CHIP_CLASS =
|
||||
'h-6 w-auto min-w-0 gap-1 px-2 text-xs font-normal text-muted-foreground data-[state=on]:text-foreground'
|
||||
/** The row menu's rows: body size, no icon column (the label is the affordance). */
|
||||
const ITEM_CLASS = 'text-sm'
|
||||
/**
|
||||
* The row menu's trigger: revealed on hover / focus (the board stays quiet
|
||||
* until the reader reaches for it) but never removed from the tab order.
|
||||
*/
|
||||
const TRIGGER_CLASS = 'opacity-0 group-hover:opacity-100 focus-visible:opacity-100 data-[state=open]:opacity-100'
|
||||
|
||||
/**
|
||||
* One board row: state dot, subject, owner, status Badge, and the row menu.
|
||||
*
|
||||
* The clickable surface is a real `<button>` (the whole row except the menu),
|
||||
* and the menu is its SIBLING inside a grid row — a control may not nest
|
||||
* inside another, and the row's own press target must stay one element. The
|
||||
* node is held in a ref so the menu can hand a MOUNTED anchor to the task
|
||||
* and the menu is its SIBLING — a control may not nest inside another, and the
|
||||
* row's own press target must stay one element. `Item asChild` puts the stock
|
||||
* row geometry on that button itself instead of wrapping it in a second box.
|
||||
* The node is held in a ref so the menu can hand a MOUNTED anchor to the task
|
||||
* window: the menu content is portaled and unmounts the moment an entry is
|
||||
* picked, while the window measures its anchor on mount.
|
||||
*/
|
||||
@@ -112,68 +114,79 @@ function TeamTaskRow(props: {
|
||||
const row = rowRef.current
|
||||
if (row !== null) onOpenTask(task, row)
|
||||
}
|
||||
const badge = taskBadge(task)
|
||||
|
||||
return (
|
||||
<div ref={rowRef} className="group grid grid-cols-[1fr_auto] items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
className={ROW_CLASS}
|
||||
aria-label={`${t('teamTaskDetail')} ${task.subject}`}
|
||||
title={t('teamTaskDetail')}
|
||||
onClick={(event) => { onOpenTask(task, event.currentTarget) }}
|
||||
>
|
||||
<StateDot size={6} state={taskDotState(task)} />
|
||||
<span className="min-w-0 flex-1 truncate text-sm text-foreground">{task.subject}</span>
|
||||
{task.ownerName !== undefined && (
|
||||
<span className="max-w-[72px] flex-none truncate font-mono text-xs tabular-nums text-foreground-3">
|
||||
{task.ownerName}
|
||||
</span>
|
||||
)}
|
||||
{(() => {
|
||||
const badge = taskBadge(task)
|
||||
return (
|
||||
<Badge variant={badge.variant} className={cn('flex-none', badge.className)}>
|
||||
{taskStatusLabel(task)}
|
||||
</Badge>
|
||||
)
|
||||
})()}
|
||||
</button>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
aria-label={t('teamTaskActions')}
|
||||
title={t('teamTaskActions')}
|
||||
className="flex size-6 flex-none cursor-pointer items-center justify-center rounded-md bg-transparent text-muted-foreground opacity-0 outline-none transition-colors group-hover:opacity-100 hover:bg-muted hover:text-foreground focus-visible:opacity-100 focus-visible:ring-[3px] focus-visible:ring-ring/50"
|
||||
<div ref={rowRef} className="group flex min-w-0 items-center gap-1">
|
||||
<Item asChild size="sm" className="min-w-0 flex-1 flex-nowrap gap-2 px-2 py-1.5 hover:bg-muted">
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`${t('teamTaskDetail')} ${task.subject}`}
|
||||
title={t('teamTaskDetail')}
|
||||
onClick={(event) => { onOpenTask(task, event.currentTarget) }}
|
||||
>
|
||||
<IconChevronUpOutline14 size={12} className="rotate-90" />
|
||||
<ItemMedia><StateDot size={6} state={taskDotState(task)} /></ItemMedia>
|
||||
<ItemContent className="min-w-0">
|
||||
<ItemTitle className="w-full min-w-0">
|
||||
<span className="min-w-0 truncate">{task.subject}</span>
|
||||
</ItemTitle>
|
||||
</ItemContent>
|
||||
{task.ownerName !== undefined && (
|
||||
<span className="max-w-18 flex-none truncate font-mono text-xs tabular-nums text-foreground-3">
|
||||
{task.ownerName}
|
||||
</span>
|
||||
)}
|
||||
<Badge variant={badge.variant} className={cn('flex-none', badge.className)}>
|
||||
{taskStatusLabel(task)}
|
||||
</Badge>
|
||||
</button>
|
||||
</Item>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
className={TRIGGER_CLASS}
|
||||
aria-label={t('teamTaskActions')}
|
||||
title={t('teamTaskActions')}
|
||||
>
|
||||
<IconChevronUpOutline14 className="rotate-90" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-44">
|
||||
<DropdownMenuItem className={ITEM_CLASS} onClick={openWindow}>
|
||||
{task.status === 'completed' ? t('teamTaskReopen') : t('teamTaskComplete')}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem className={ITEM_CLASS} onClick={openWindow}>
|
||||
{t('teamTaskEdit')}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem onClick={openWindow}>
|
||||
{task.status === 'completed' ? t('teamTaskReopen') : t('teamTaskComplete')}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={openWindow}>
|
||||
{t('teamTaskEdit')}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger className={ITEM_CLASS}>{t('teamTaskOwner')}</DropdownMenuSubTrigger>
|
||||
<DropdownMenuSubTrigger>{t('teamTaskOwner')}</DropdownMenuSubTrigger>
|
||||
<DropdownMenuSubContent className="w-40">
|
||||
<DropdownMenuItem className={ITEM_CLASS} onClick={openWindow}>
|
||||
{t('teamTaskUnowned')}
|
||||
</DropdownMenuItem>
|
||||
{teammates.map(member => (
|
||||
<DropdownMenuItem key={member.id} className={ITEM_CLASS} onClick={openWindow}>
|
||||
{member.name}
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem onClick={openWindow}>
|
||||
{t('teamTaskUnowned')}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
{teammates.map(member => (
|
||||
<DropdownMenuItem key={member.id} onClick={openWindow}>
|
||||
{member.name}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuGroup>
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
<DropdownMenuSeparator />
|
||||
{/* Neutral on purpose: this entry only OPENS the shared task window
|
||||
(the danger lives in the window's two-click delete confirm), so a
|
||||
permanently red menu item would over-signal — see TaskWindow. */}
|
||||
<DropdownMenuItem className={ITEM_CLASS} onClick={openWindow}>
|
||||
{t('teamTaskDelete')}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuGroup>
|
||||
{/* Neutral on purpose: this entry only OPENS the shared task window
|
||||
(the danger lives in the window's two-click delete confirm), so a
|
||||
permanently red menu item would over-signal — see TaskWindow. */}
|
||||
<DropdownMenuItem onClick={openWindow}>
|
||||
{t('teamTaskDelete')}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
@@ -208,86 +221,98 @@ export function TeamBoard(props: TeamBoardProps): ReactNode {
|
||||
// card rhythm; `py-0` because each band owns its own padding. The rest is
|
||||
// the stock card surface (`rounded-xl border bg-card shadow-sm`); the
|
||||
// landmark role keeps the labelled region the old `<section>` provided.
|
||||
className="mb-2 shrink-0 gap-0 overflow-hidden rounded-xl py-0 shadow-sm"
|
||||
className="mb-2 shrink-0 gap-0 overflow-hidden py-0"
|
||||
role="region"
|
||||
aria-label={t('teamBoard')}
|
||||
>
|
||||
{/* Header: the disclosure, and the only control that collapses the board. */}
|
||||
<div className="flex items-center py-0.5 pr-2 pl-3">
|
||||
<button
|
||||
type="button"
|
||||
className={cn(ROW_CLASS, 'flex-1 py-1.5')}
|
||||
aria-expanded={!collapsed}
|
||||
aria-label={t('teamBoard')}
|
||||
onClick={onToggleCollapsed}
|
||||
>
|
||||
<span className="flex-none text-muted-foreground" aria-hidden="true">
|
||||
<IconChecklistOutline14 size={12} />
|
||||
</span>
|
||||
<span className="min-w-0 truncate text-sm font-medium text-foreground">{t('teamBoard')}</span>
|
||||
<span className="min-w-0 truncate font-mono text-xs tabular-nums text-foreground-3">
|
||||
{t('teamChip', { members: members.length, tasks: live.length })}
|
||||
</span>
|
||||
<span
|
||||
className={cn('ml-auto flex-none text-muted-foreground', collapsed ? '' : 'rotate-180')}
|
||||
aria-hidden="true"
|
||||
{/* Header: the disclosure, and the only control that collapses the board.
|
||||
It owns the WHOLE band (one press target, as before) and carries the
|
||||
board's accessible name and expanded state. */}
|
||||
<CardHeader className="flex flex-row items-center gap-1 p-1">
|
||||
<CardTitle className="min-w-0 flex-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="w-full justify-start"
|
||||
aria-expanded={!collapsed}
|
||||
aria-label={t('teamBoard')}
|
||||
onClick={onToggleCollapsed}
|
||||
>
|
||||
<IconChevronUpOutline14 size={12} />
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
<IconChecklistOutline14 />
|
||||
<span className="min-w-0 truncate">{t('teamBoard')}</span>
|
||||
<span className="ml-auto min-w-0 truncate font-mono text-xs tabular-nums text-foreground-3">
|
||||
{t('teamChip', { members: members.length, tasks: live.length })}
|
||||
</span>
|
||||
<span className={cn('flex-none', collapsed ? '' : 'rotate-180')} aria-hidden="true">
|
||||
<IconChevronUpOutline14 />
|
||||
</span>
|
||||
</Button>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
{!collapsed && (
|
||||
<>
|
||||
<Separator />
|
||||
{/* The owner filter: one chip per member, plus "all". */}
|
||||
<ToggleGroup
|
||||
type="single"
|
||||
variant="default"
|
||||
size="sm"
|
||||
value={ownerFilter ?? FILTER_ALL}
|
||||
aria-label={t('teamMembers')}
|
||||
className="flex-wrap justify-start gap-1 px-2 py-1"
|
||||
onValueChange={(next) => {
|
||||
// "all" is a sentinel, never a real member name — and re-clicking
|
||||
// the active member clears the filter (the toggle's own off state,
|
||||
// which Radix reports as an empty value).
|
||||
setOwnerFilter(next === '' ? undefined : next)
|
||||
}}
|
||||
>
|
||||
<ToggleGroupItem value={FILTER_ALL} className={CHIP_CLASS}>{t('teamFilterAll')}</ToggleGroupItem>
|
||||
{members.map(member => (
|
||||
<ToggleGroupItem
|
||||
key={member.id}
|
||||
value={member.name}
|
||||
title={`${member.name} · ${member.role}`}
|
||||
className={CHIP_CLASS}
|
||||
>
|
||||
<StateDot size={6} state={memberDotState(member)} />
|
||||
<span className="min-w-0 truncate">{member.name}</span>
|
||||
</ToggleGroupItem>
|
||||
))}
|
||||
</ToggleGroup>
|
||||
<Separator />
|
||||
<div className="max-h-40 overflow-y-auto p-1">
|
||||
{shown.length === 0
|
||||
// `Empty` is the design language's empty state; the stock card
|
||||
// rhythm is overridden so an empty board stays a thin band.
|
||||
? <Empty className="gap-0 rounded-md p-2 text-xs">{t('teamTasksEmpty')}</Empty>
|
||||
: shown.map(task => (
|
||||
<TeamTaskRow key={task.id} task={task} teammates={teammates} onOpenTask={onOpenTask} />
|
||||
<div className="px-2 py-1.5">
|
||||
<ToggleGroup
|
||||
type="single"
|
||||
variant="default"
|
||||
size="sm"
|
||||
spacing={1}
|
||||
value={ownerFilter ?? FILTER_ALL}
|
||||
aria-label={t('teamMembers')}
|
||||
className="flex-wrap justify-start"
|
||||
onValueChange={(next) => {
|
||||
// "all" is a sentinel, never a real member name — and re-clicking
|
||||
// the active member clears the filter (the toggle's own off state,
|
||||
// which Radix reports as an empty value).
|
||||
setOwnerFilter(next === '' ? undefined : next)
|
||||
}}
|
||||
>
|
||||
<ToggleGroupItem value={FILTER_ALL}>{t('teamFilterAll')}</ToggleGroupItem>
|
||||
{members.map(member => (
|
||||
<ToggleGroupItem
|
||||
key={member.id}
|
||||
value={member.name}
|
||||
title={`${member.name} · ${member.role}`}
|
||||
>
|
||||
<StateDot size={6} state={memberDotState(member)} />
|
||||
<span className="min-w-0 truncate">{member.name}</span>
|
||||
</ToggleGroupItem>
|
||||
))}
|
||||
</ToggleGroup>
|
||||
</div>
|
||||
<Separator />
|
||||
<div className="px-2 py-1.5">
|
||||
<CardContent className="p-1">
|
||||
{shown.length === 0
|
||||
// `Empty` is the design language's empty state; its stock card
|
||||
// rhythm is overridden — including the `md:` step, which would
|
||||
// otherwise re-inflate the band to 48px of padding above 768px —
|
||||
// so an empty board stays a thin band.
|
||||
? (
|
||||
<Empty className="gap-0 p-3 md:p-3">
|
||||
<EmptyDescription>{t('teamTasksEmpty')}</EmptyDescription>
|
||||
</Empty>
|
||||
)
|
||||
: (
|
||||
<ItemGroup className="max-h-40 overflow-y-auto">
|
||||
{shown.map(task => (
|
||||
<TeamTaskRow key={task.id} task={task} teammates={teammates} onOpenTask={onOpenTask} />
|
||||
))}
|
||||
</ItemGroup>
|
||||
)}
|
||||
</CardContent>
|
||||
<Separator />
|
||||
<CardFooter className="px-2 py-1.5">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
icon={<IconPlusOutline16 size={13} />}
|
||||
onClick={(event) => { onOpenTask(undefined, event.currentTarget) }}
|
||||
>
|
||||
<IconPlusOutline16 />
|
||||
{t('teamTaskCreate')}
|
||||
</Button>
|
||||
</div>
|
||||
</CardFooter>
|
||||
</>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
+33
-39
@@ -3,51 +3,45 @@ import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { cn } from "./utils"
|
||||
import { Slot } from "radix-ui"
|
||||
|
||||
/**
|
||||
* Vendored shadcn/ui badge, trimmed to the task page's real inventory.
|
||||
*
|
||||
* Removed from upstream (`new-york`): the `ghost` and `link` variants (dead
|
||||
* class strings in a runtime object, so they would ship). `text-white` became
|
||||
* the `--destructive-foreground` token, which is what the theme bridge defines
|
||||
* that role for — no color literal lives in this directory.
|
||||
*/
|
||||
const badgeVariants = cva(
|
||||
"inline-flex w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-full border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 [&>svg]:pointer-events-none [&>svg]:size-3",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
|
||||
destructive:
|
||||
"bg-destructive text-destructive-foreground focus-visible:ring-destructive/20 [a&]:hover:bg-destructive/90",
|
||||
outline:
|
||||
"border-border text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
"inline-flex w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-full border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 [&>svg]:pointer-events-none [&>svg]:size-3",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
|
||||
destructive:
|
||||
"bg-destructive text-destructive-foreground focus-visible:ring-destructive/20 [a&]:hover:bg-destructive/90",
|
||||
outline:
|
||||
"border-border text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
|
||||
ghost: "[a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
|
||||
link: "text-primary underline-offset-4 [a&]:hover:underline",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Badge({
|
||||
className,
|
||||
variant = "default",
|
||||
asChild = false,
|
||||
...props
|
||||
className,
|
||||
variant = "default",
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<"span"> &
|
||||
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
|
||||
const Comp = asChild ? Slot.Root : "span"
|
||||
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
|
||||
const Comp = asChild ? Slot.Root : "span"
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="badge"
|
||||
data-variant={variant}
|
||||
className={cn(badgeVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
return (
|
||||
<Comp
|
||||
data-slot="badge"
|
||||
data-variant={variant}
|
||||
className={cn(badgeVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants }
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { cn } from "./utils"
|
||||
import { Slot } from "radix-ui"
|
||||
|
||||
import { Separator } from "./separator.tsx"
|
||||
|
||||
const buttonGroupVariants = cva(
|
||||
"flex w-fit items-stretch has-[>[data-slot=button-group]]:gap-2 [&>*]:focus-visible:relative [&>*]:focus-visible:z-10 has-[select[aria-hidden=true]:last-child]:[&>[data-slot=select-trigger]:last-of-type]:rounded-r-md [&>[data-slot=select-trigger]:not([class*='w-'])]:w-fit [&>input]:flex-1",
|
||||
{
|
||||
variants: {
|
||||
orientation: {
|
||||
horizontal:
|
||||
"[&>*:not(:first-child)]:rounded-l-none [&>*:not(:first-child)]:border-l-0 [&>*:not(:last-child)]:rounded-r-none",
|
||||
vertical:
|
||||
"flex-col [&>*:not(:first-child)]:rounded-t-none [&>*:not(:first-child)]:border-t-0 [&>*:not(:last-child)]:rounded-b-none",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
orientation: "horizontal",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function ButtonGroup({
|
||||
className,
|
||||
orientation,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & VariantProps<typeof buttonGroupVariants>) {
|
||||
return (
|
||||
<div
|
||||
role="group"
|
||||
data-slot="button-group"
|
||||
data-orientation={orientation}
|
||||
className={cn(buttonGroupVariants({ orientation }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ButtonGroupText({
|
||||
className,
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
asChild?: boolean
|
||||
}) {
|
||||
const Comp = asChild ? Slot.Root : "div"
|
||||
|
||||
return (
|
||||
<Comp
|
||||
className={cn(
|
||||
"flex items-center gap-2 rounded-md border bg-muted px-4 text-sm font-medium shadow-xs [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ButtonGroupSeparator({
|
||||
className,
|
||||
orientation = "vertical",
|
||||
...props
|
||||
}: React.ComponentProps<typeof Separator>) {
|
||||
return (
|
||||
<Separator
|
||||
data-slot="button-group-separator"
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"relative m-0! self-stretch bg-input data-[orientation=vertical]:h-auto",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
ButtonGroup,
|
||||
ButtonGroupSeparator,
|
||||
ButtonGroupText,
|
||||
buttonGroupVariants,
|
||||
}
|
||||
+54
-61
@@ -3,74 +3,67 @@ import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { cn } from "./utils"
|
||||
import { Slot } from "radix-ui"
|
||||
|
||||
/**
|
||||
* Vendored shadcn/ui button, trimmed to the task page's real inventory.
|
||||
*
|
||||
* Removed from upstream (`new-york`): the `link` variant and the `xs` / `lg` /
|
||||
* `icon-xs` / `icon-sm` / `icon-lg` sizes. The page's control heights are 28 px
|
||||
* (`sm`), 36 px (`default`) and the square 36 px icon button; every removed
|
||||
* class still lived in this runtime object and would have shipped in the bundle.
|
||||
*/
|
||||
const buttonVariants = cva(
|
||||
// LOCAL ADAPTATION: `transition-all` narrowed to the same property list the
|
||||
// page's other vendored controls use (see ui/toggle.tsx) — `all` also
|
||||
// animates layout/size changes, which makes resizes and mount transitions
|
||||
// laggy on a dense task page.
|
||||
"inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-[color,background-color,border-color,box-shadow] outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
||||
destructive:
|
||||
"bg-destructive text-destructive-foreground hover:bg-destructive/90 focus-visible:ring-destructive/20",
|
||||
outline:
|
||||
"border bg-background hover:bg-accent hover:text-accent-foreground",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
ghost: "hover:bg-accent hover:text-accent-foreground",
|
||||
},
|
||||
size: {
|
||||
default: "h-9 px-4 py-2 has-[>svg]:px-3",
|
||||
sm: "h-8 gap-1.5 rounded-md px-3 has-[>svg]:px-2.5",
|
||||
icon: "size-9",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
"inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
||||
destructive:
|
||||
"bg-destructive text-destructive-foreground hover:bg-destructive/90 focus-visible:ring-destructive/20 ",
|
||||
outline:
|
||||
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground ",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
ghost:
|
||||
"hover:bg-accent hover:text-accent-foreground ",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default: "h-9 px-4 py-2 has-[>svg]:px-3",
|
||||
xs: "h-6 gap-1 rounded-md px-2 text-xs has-[>svg]:px-1.5 [&_svg:not([class*='size-'])]:size-3",
|
||||
sm: "h-8 gap-1.5 rounded-md px-3 has-[>svg]:px-2.5",
|
||||
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
|
||||
icon: "size-9",
|
||||
"icon-xs": "size-6 rounded-md [&_svg:not([class*='size-'])]:size-3",
|
||||
"icon-sm": "size-8",
|
||||
"icon-lg": "size-10",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
/**
|
||||
* LOCAL ADAPTATION (dsh-better-sidebar): `React.forwardRef`, because this
|
||||
* plugin runs on the DSH host's React 18 (react@^18.2.0 — ref-as-prop is a
|
||||
* React 19 feature). Without it, `<TooltipTrigger asChild><Button/></…>`
|
||||
* cannot measure its anchor: radix's `Slot` hands the child a ref and a plain
|
||||
* function component drops it (dev builds warn, production silently
|
||||
* mis-positions the floating layer). Upstream ships no forwardRef because the
|
||||
* registry targets React 19; the shadcn CLI `--diff` flow will try to remove
|
||||
* it — re-apply this adaptation when updating the file.
|
||||
* LOCAL ADAPTATION: `React.forwardRef`, because this plugin runs on the DSH
|
||||
* host's React 18 (ref-as-prop is a React 19 feature). Without it
|
||||
* `<TooltipTrigger asChild><Button/></TooltipTrigger>` cannot measure its
|
||||
* anchor: radix's `Slot` hands the child a ref and a plain function component
|
||||
* drops it (dev builds warn, production silently mis-positions the floating
|
||||
* layer). The registry targets React 19 and ships no forwardRef, so a
|
||||
* `shadcn add --overwrite` refresh must re-apply this.
|
||||
*/
|
||||
const Button = React.forwardRef<
|
||||
HTMLButtonElement,
|
||||
React.ComponentProps<"button"> &
|
||||
VariantProps<typeof buttonVariants> & {
|
||||
asChild?: boolean
|
||||
}
|
||||
HTMLButtonElement,
|
||||
React.ComponentProps<"button"> &
|
||||
VariantProps<typeof buttonVariants> & {
|
||||
asChild?: boolean
|
||||
}
|
||||
>(function Button({ className, variant = "default", size = "default", asChild = false, ...props }, ref) {
|
||||
const Comp = asChild ? Slot.Root : "button"
|
||||
const Comp = asChild ? Slot.Root : "button"
|
||||
|
||||
return (
|
||||
<Comp
|
||||
ref={ref}
|
||||
data-slot="button"
|
||||
data-variant={variant}
|
||||
data-size={size}
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
return (
|
||||
<Comp
|
||||
ref={ref}
|
||||
data-slot="button"
|
||||
data-variant={variant}
|
||||
data-size={size}
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
})
|
||||
|
||||
export { Button, buttonVariants }
|
||||
|
||||
+65
-65
@@ -2,90 +2,90 @@ import * as React from "react"
|
||||
import { cn } from "./utils"
|
||||
|
||||
function Card({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card"
|
||||
className={cn(
|
||||
"flex flex-col gap-6 rounded-xl border bg-card py-6 text-card-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
return (
|
||||
<div
|
||||
data-slot="card"
|
||||
className={cn(
|
||||
"flex flex-col gap-6 rounded-xl border bg-card py-6 text-card-foreground shadow-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-header"
|
||||
className={cn(
|
||||
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
return (
|
||||
<div
|
||||
data-slot="card-header"
|
||||
className={cn(
|
||||
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-title"
|
||||
className={cn("leading-none font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
return (
|
||||
<div
|
||||
data-slot="card-title"
|
||||
className={cn("leading-none font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-description"
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
return (
|
||||
<div
|
||||
data-slot="card-description"
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-action"
|
||||
className={cn(
|
||||
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
return (
|
||||
<div
|
||||
data-slot="card-action"
|
||||
className={cn(
|
||||
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-content"
|
||||
className={cn("px-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
return (
|
||||
<div
|
||||
data-slot="card-content"
|
||||
className={cn("px-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-footer"
|
||||
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
return (
|
||||
<div
|
||||
data-slot="card-footer"
|
||||
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardFooter,
|
||||
CardTitle,
|
||||
CardAction,
|
||||
CardDescription,
|
||||
CardContent,
|
||||
Card,
|
||||
CardHeader,
|
||||
CardFooter,
|
||||
CardTitle,
|
||||
CardAction,
|
||||
CardDescription,
|
||||
CardContent,
|
||||
}
|
||||
|
||||
@@ -1,31 +1,31 @@
|
||||
import { Collapsible as CollapsiblePrimitive } from "radix-ui"
|
||||
|
||||
function Collapsible({
|
||||
...props
|
||||
...props
|
||||
}: React.ComponentProps<typeof CollapsiblePrimitive.Root>) {
|
||||
return <CollapsiblePrimitive.Root data-slot="collapsible" {...props} />
|
||||
return <CollapsiblePrimitive.Root data-slot="collapsible" {...props} />
|
||||
}
|
||||
|
||||
function CollapsibleTrigger({
|
||||
...props
|
||||
...props
|
||||
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleTrigger>) {
|
||||
return (
|
||||
<CollapsiblePrimitive.CollapsibleTrigger
|
||||
data-slot="collapsible-trigger"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
return (
|
||||
<CollapsiblePrimitive.CollapsibleTrigger
|
||||
data-slot="collapsible-trigger"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CollapsibleContent({
|
||||
...props
|
||||
...props
|
||||
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleContent>) {
|
||||
return (
|
||||
<CollapsiblePrimitive.CollapsibleContent
|
||||
data-slot="collapsible-content"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
return (
|
||||
<CollapsiblePrimitive.CollapsibleContent
|
||||
data-slot="collapsible-content"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Collapsible, CollapsibleTrigger, CollapsibleContent }
|
||||
|
||||
+201
-131
@@ -1,184 +1,254 @@
|
||||
/**
|
||||
* Vendored shadcn/ui dropdown menu, trimmed to the task page's real inventory.
|
||||
*
|
||||
* Removed from upstream (`new-york`): `DropdownMenuCheckboxItem`,
|
||||
* `DropdownMenuRadioGroup`, `DropdownMenuRadioItem` and `DropdownMenuShortcut`
|
||||
* (no checkbox/radio/shortcut rows exist on the page). `DropdownMenuSub` and its
|
||||
* trigger/content stay — the node menu's "reassign owner" is a submenu.
|
||||
*
|
||||
* The submenu arrow is the host's `IconChevronRightOutline14`, so the file
|
||||
* imports no icon library of its own.
|
||||
*/
|
||||
import * as React from "react"
|
||||
import { IconChevronRightOutline14 } from "@deepseek-ai/dsh-client-ui-primitives"
|
||||
import { cn } from "./utils"
|
||||
import { DropdownMenu as DropdownMenuPrimitive } from "radix-ui"
|
||||
import { IconCheckOutline16, IconChevronRightOutline14 } from "@deepseek-ai/dsh-client-ui-primitives"
|
||||
|
||||
function DropdownMenu({
|
||||
...props
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
|
||||
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />
|
||||
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />
|
||||
}
|
||||
|
||||
function DropdownMenuPortal({
|
||||
...props
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
|
||||
)
|
||||
return (
|
||||
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuTrigger({
|
||||
...props
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Trigger
|
||||
data-slot="dropdown-menu-trigger"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
return (
|
||||
<DropdownMenuPrimitive.Trigger
|
||||
data-slot="dropdown-menu-trigger"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuContent({
|
||||
className,
|
||||
sideOffset = 4,
|
||||
...props
|
||||
className,
|
||||
sideOffset = 4,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Portal>
|
||||
<DropdownMenuPrimitive.Content
|
||||
data-slot="dropdown-menu-content"
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</DropdownMenuPrimitive.Portal>
|
||||
)
|
||||
return (
|
||||
<DropdownMenuPrimitive.Portal>
|
||||
<DropdownMenuPrimitive.Content
|
||||
data-slot="dropdown-menu-content"
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[side=bottom]: data-[side=left]: data-[side=right]: data-[side=top]: data-[state=closed]: data-[state=closed]: data-[state=closed]: data-[state=open]: data-[state=open]: data-[state=open]:",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</DropdownMenuPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuGroup({
|
||||
...props
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
|
||||
)
|
||||
return (
|
||||
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuItem({
|
||||
className,
|
||||
inset,
|
||||
variant = "default",
|
||||
...props
|
||||
className,
|
||||
inset,
|
||||
variant = "default",
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
|
||||
inset?: boolean
|
||||
variant?: "default" | "destructive"
|
||||
inset?: boolean
|
||||
variant?: "default" | "destructive"
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Item
|
||||
data-slot="dropdown-menu-item"
|
||||
data-inset={inset}
|
||||
data-variant={variant}
|
||||
className={cn(
|
||||
"relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground data-[variant=destructive]:*:[svg]:text-destructive!",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
return (
|
||||
<DropdownMenuPrimitive.Item
|
||||
data-slot="dropdown-menu-item"
|
||||
data-inset={inset}
|
||||
data-variant={variant}
|
||||
className={cn(
|
||||
"relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground data-[variant=destructive]:*:[svg]:text-destructive!",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuCheckboxItem({
|
||||
className,
|
||||
children,
|
||||
checked,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.CheckboxItem
|
||||
data-slot="dropdown-menu-checkbox-item"
|
||||
className={cn(
|
||||
"relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
checked={checked}
|
||||
{...props}
|
||||
>
|
||||
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<IconCheckOutline16 className="size-4" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.CheckboxItem>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuRadioGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.RadioGroup
|
||||
data-slot="dropdown-menu-radio-group"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuRadioItem({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.RadioItem
|
||||
data-slot="dropdown-menu-radio-item"
|
||||
className={cn(
|
||||
"relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<span className="size-2 rounded-full bg-current" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.RadioItem>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuLabel({
|
||||
className,
|
||||
inset,
|
||||
...props
|
||||
className,
|
||||
inset,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
|
||||
inset?: boolean
|
||||
inset?: boolean
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Label
|
||||
data-slot="dropdown-menu-label"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
return (
|
||||
<DropdownMenuPrimitive.Label
|
||||
data-slot="dropdown-menu-label"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuSeparator({
|
||||
className,
|
||||
...props
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Separator
|
||||
data-slot="dropdown-menu-separator"
|
||||
className={cn("-mx-1 my-1 h-px bg-border", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
return (
|
||||
<DropdownMenuPrimitive.Separator
|
||||
data-slot="dropdown-menu-separator"
|
||||
className={cn("-mx-1 my-1 h-px bg-border", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuShortcut({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
data-slot="dropdown-menu-shortcut"
|
||||
className={cn(
|
||||
"ml-auto text-xs tracking-widest text-muted-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuSub({
|
||||
...props
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
|
||||
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />
|
||||
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />
|
||||
}
|
||||
|
||||
function DropdownMenuSubTrigger({
|
||||
className,
|
||||
inset,
|
||||
children,
|
||||
...props
|
||||
className,
|
||||
inset,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
|
||||
inset?: boolean
|
||||
inset?: boolean
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.SubTrigger
|
||||
data-slot="dropdown-menu-sub-trigger"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[inset]:pl-8 data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<IconChevronRightOutline14 size={16} className="ml-auto" />
|
||||
</DropdownMenuPrimitive.SubTrigger>
|
||||
)
|
||||
return (
|
||||
<DropdownMenuPrimitive.SubTrigger
|
||||
data-slot="dropdown-menu-sub-trigger"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[inset]:pl-8 data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<IconChevronRightOutline14 className="ml-auto size-4" />
|
||||
</DropdownMenuPrimitive.SubTrigger>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuSubContent({
|
||||
className,
|
||||
...props
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.SubContent
|
||||
data-slot="dropdown-menu-sub-content"
|
||||
className={cn(
|
||||
"z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
return (
|
||||
<DropdownMenuPrimitive.SubContent
|
||||
data-slot="dropdown-menu-sub-content"
|
||||
className={cn(
|
||||
"z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[side=bottom]: data-[side=left]: data-[side=right]: data-[side=top]: data-[state=closed]: data-[state=closed]: data-[state=closed]: data-[state=open]: data-[state=open]: data-[state=open]:",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
DropdownMenu,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenu,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuSubContent,
|
||||
}
|
||||
|
||||
+76
-76
@@ -2,102 +2,102 @@ import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { cn } from "./utils"
|
||||
|
||||
function Empty({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="empty"
|
||||
className={cn(
|
||||
"flex min-w-0 flex-1 flex-col items-center justify-center gap-6 rounded-lg border-dashed p-6 text-center text-balance md:p-12",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
return (
|
||||
<div
|
||||
data-slot="empty"
|
||||
className={cn(
|
||||
"flex min-w-0 flex-1 flex-col items-center justify-center gap-6 rounded-lg border-dashed p-6 text-center text-balance md:p-12",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function EmptyHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="empty-header"
|
||||
className={cn(
|
||||
"flex max-w-sm flex-col items-center gap-2 text-center",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
return (
|
||||
<div
|
||||
data-slot="empty-header"
|
||||
className={cn(
|
||||
"flex max-w-sm flex-col items-center gap-2 text-center",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const emptyMediaVariants = cva(
|
||||
"mb-2 flex shrink-0 items-center justify-center [&_svg]:pointer-events-none [&_svg]:shrink-0",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-transparent",
|
||||
icon: "flex size-10 shrink-0 items-center justify-center rounded-lg bg-muted text-foreground [&_svg:not([class*='size-'])]:size-6",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
"mb-2 flex shrink-0 items-center justify-center [&_svg]:pointer-events-none [&_svg]:shrink-0",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-transparent",
|
||||
icon: "flex size-10 shrink-0 items-center justify-center rounded-lg bg-muted text-foreground [&_svg:not([class*='size-'])]:size-6",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function EmptyMedia({
|
||||
className,
|
||||
variant = "default",
|
||||
...props
|
||||
className,
|
||||
variant = "default",
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & VariantProps<typeof emptyMediaVariants>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="empty-icon"
|
||||
data-variant={variant}
|
||||
className={cn(emptyMediaVariants({ variant, className }))}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
return (
|
||||
<div
|
||||
data-slot="empty-icon"
|
||||
data-variant={variant}
|
||||
className={cn(emptyMediaVariants({ variant, className }))}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function EmptyTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="empty-title"
|
||||
className={cn("text-lg font-medium tracking-tight", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
return (
|
||||
<div
|
||||
data-slot="empty-title"
|
||||
className={cn("text-lg font-medium tracking-tight", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function EmptyDescription({ className, ...props }: React.ComponentProps<"p">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="empty-description"
|
||||
className={cn(
|
||||
"text-sm/relaxed text-muted-foreground [&>a]:underline [&>a]:underline-offset-4 [&>a:hover]:text-primary",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
return (
|
||||
<div
|
||||
data-slot="empty-description"
|
||||
className={cn(
|
||||
"text-sm/relaxed text-muted-foreground [&>a]:underline [&>a]:underline-offset-4 [&>a:hover]:text-primary",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function EmptyContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="empty-content"
|
||||
className={cn(
|
||||
"flex w-full max-w-sm min-w-0 flex-col items-center gap-4 text-sm text-balance",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
return (
|
||||
<div
|
||||
data-slot="empty-content"
|
||||
className={cn(
|
||||
"flex w-full max-w-sm min-w-0 flex-col items-center gap-4 text-sm text-balance",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Empty,
|
||||
EmptyHeader,
|
||||
EmptyTitle,
|
||||
EmptyDescription,
|
||||
EmptyContent,
|
||||
EmptyMedia,
|
||||
Empty,
|
||||
EmptyHeader,
|
||||
EmptyTitle,
|
||||
EmptyDescription,
|
||||
EmptyContent,
|
||||
EmptyMedia,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,246 @@
|
||||
import { useMemo } from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { cn } from "./utils"
|
||||
|
||||
import { Label } from "./label.tsx"
|
||||
import { Separator } from "./separator.tsx"
|
||||
|
||||
function FieldSet({ className, ...props }: React.ComponentProps<"fieldset">) {
|
||||
return (
|
||||
<fieldset
|
||||
data-slot="field-set"
|
||||
className={cn(
|
||||
"flex flex-col gap-6",
|
||||
"has-[>[data-slot=checkbox-group]]:gap-3 has-[>[data-slot=radio-group]]:gap-3",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function FieldLegend({
|
||||
className,
|
||||
variant = "legend",
|
||||
...props
|
||||
}: React.ComponentProps<"legend"> & { variant?: "legend" | "label" }) {
|
||||
return (
|
||||
<legend
|
||||
data-slot="field-legend"
|
||||
data-variant={variant}
|
||||
className={cn(
|
||||
"mb-3 font-medium",
|
||||
"data-[variant=legend]:text-base",
|
||||
"data-[variant=label]:text-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function FieldGroup({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="field-group"
|
||||
className={cn(
|
||||
"group/field-group @container/field-group flex w-full flex-col gap-7 data-[slot=checkbox-group]:gap-3 [&>[data-slot=field-group]]:gap-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const fieldVariants = cva(
|
||||
"group/field flex w-full gap-3 data-[invalid=true]:text-destructive",
|
||||
{
|
||||
variants: {
|
||||
orientation: {
|
||||
vertical: ["flex-col [&>*]:w-full [&>.sr-only]:w-auto"],
|
||||
horizontal: [
|
||||
"flex-row items-center",
|
||||
"[&>[data-slot=field-label]]:flex-auto",
|
||||
"has-[>[data-slot=field-content]]:items-start has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px",
|
||||
],
|
||||
responsive: [
|
||||
"flex-col @md/field-group:flex-row @md/field-group:items-center [&>*]:w-full @md/field-group:[&>*]:w-auto [&>.sr-only]:w-auto",
|
||||
"@md/field-group:[&>[data-slot=field-label]]:flex-auto",
|
||||
"@md/field-group:has-[>[data-slot=field-content]]:items-start @md/field-group:has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px",
|
||||
],
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
orientation: "vertical",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Field({
|
||||
className,
|
||||
orientation = "vertical",
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & VariantProps<typeof fieldVariants>) {
|
||||
return (
|
||||
<div
|
||||
role="group"
|
||||
data-slot="field"
|
||||
data-orientation={orientation}
|
||||
className={cn(fieldVariants({ orientation }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function FieldContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="field-content"
|
||||
className={cn(
|
||||
"group/field-content flex flex-1 flex-col gap-1.5 leading-snug",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function FieldLabel({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof Label>) {
|
||||
return (
|
||||
<Label
|
||||
data-slot="field-label"
|
||||
className={cn(
|
||||
"group/field-label peer/field-label flex w-fit gap-2 leading-snug group-data-[disabled=true]/field:opacity-50",
|
||||
"has-[>[data-slot=field]]:w-full has-[>[data-slot=field]]:flex-col has-[>[data-slot=field]]:rounded-md has-[>[data-slot=field]]:border [&>*]:data-[slot=field]:p-4",
|
||||
"has-data-[state=checked]:border-primary has-data-[state=checked]:bg-primary/5 ",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function FieldTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="field-label"
|
||||
className={cn(
|
||||
"flex w-fit items-center gap-2 text-sm leading-snug font-medium group-data-[disabled=true]/field:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function FieldDescription({ className, ...props }: React.ComponentProps<"p">) {
|
||||
return (
|
||||
<p
|
||||
data-slot="field-description"
|
||||
className={cn(
|
||||
"text-sm leading-normal font-normal text-muted-foreground group-has-[[data-orientation=horizontal]]/field:text-balance",
|
||||
"last:mt-0 nth-last-2:-mt-1 [[data-variant=legend]+&]:-mt-1.5",
|
||||
"[&>a]:underline [&>a]:underline-offset-4 [&>a:hover]:text-primary",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function FieldSeparator({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
children?: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
data-slot="field-separator"
|
||||
data-content={!!children}
|
||||
className={cn(
|
||||
"relative -my-2 h-5 text-sm group-data-[variant=outline]/field-group:-mb-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<Separator className="absolute inset-0 top-1/2" />
|
||||
{children && (
|
||||
<span
|
||||
className="relative mx-auto block w-fit bg-background px-2 text-muted-foreground"
|
||||
data-slot="field-separator-content"
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function FieldError({
|
||||
className,
|
||||
children,
|
||||
errors,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
errors?: Array<{ message?: string } | undefined>
|
||||
}) {
|
||||
const content = useMemo(() => {
|
||||
if (children) {
|
||||
return children
|
||||
}
|
||||
|
||||
if (!errors?.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
const uniqueErrors = [
|
||||
...new Map(errors.map((error) => [error?.message, error])).values(),
|
||||
]
|
||||
|
||||
if (uniqueErrors?.length == 1) {
|
||||
return uniqueErrors[0]?.message
|
||||
}
|
||||
|
||||
return (
|
||||
<ul className="ml-4 flex list-disc flex-col gap-1">
|
||||
{uniqueErrors.map(
|
||||
(error, index) =>
|
||||
error?.message && <li key={index}>{error.message}</li>
|
||||
)}
|
||||
</ul>
|
||||
)
|
||||
}, [children, errors])
|
||||
|
||||
if (!content) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
role="alert"
|
||||
data-slot="field-error"
|
||||
className={cn("text-sm font-normal text-destructive", className)}
|
||||
{...props}
|
||||
>
|
||||
{content}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Field,
|
||||
FieldLabel,
|
||||
FieldDescription,
|
||||
FieldError,
|
||||
FieldGroup,
|
||||
FieldLegend,
|
||||
FieldSeparator,
|
||||
FieldSet,
|
||||
FieldContent,
|
||||
FieldTitle,
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { cn } from "./utils"
|
||||
|
||||
import { Button } from "./button.tsx"
|
||||
import { Input } from "./input.tsx"
|
||||
import { Textarea } from "./textarea.tsx"
|
||||
|
||||
function InputGroup({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="input-group"
|
||||
role="group"
|
||||
className={cn(
|
||||
"group/input-group relative flex w-full items-center rounded-md border border-input shadow-xs transition-[color,box-shadow] outline-none ",
|
||||
"h-9 min-w-0 has-[>textarea]:h-auto",
|
||||
|
||||
// Variants based on alignment.
|
||||
"has-[>[data-align=inline-start]]:[&>input]:pl-2",
|
||||
"has-[>[data-align=inline-end]]:[&>input]:pr-2",
|
||||
"has-[>[data-align=block-start]]:h-auto has-[>[data-align=block-start]]:flex-col has-[>[data-align=block-start]]:[&>input]:pb-3",
|
||||
"has-[>[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-end]]:[&>input]:pt-3",
|
||||
|
||||
// Focus state.
|
||||
"has-[[data-slot=input-group-control]:focus-visible]:border-ring has-[[data-slot=input-group-control]:focus-visible]:ring-[3px] has-[[data-slot=input-group-control]:focus-visible]:ring-ring/50",
|
||||
|
||||
// Error state.
|
||||
"has-[[data-slot][aria-invalid=true]]:border-destructive has-[[data-slot][aria-invalid=true]]:ring-destructive/20 ",
|
||||
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const inputGroupAddonVariants = cva(
|
||||
"flex h-auto cursor-text items-center justify-center gap-2 py-1.5 text-sm font-medium text-muted-foreground select-none group-data-[disabled=true]/input-group:opacity-50 [&>kbd]:rounded-[calc(var(--radius)-5px)] [&>svg:not([class*='size-'])]:size-4",
|
||||
{
|
||||
variants: {
|
||||
align: {
|
||||
"inline-start":
|
||||
"order-first pl-3 has-[>button]:ml-[-0.45rem] has-[>kbd]:ml-[-0.35rem]",
|
||||
"inline-end":
|
||||
"order-last pr-3 has-[>button]:mr-[-0.45rem] has-[>kbd]:mr-[-0.35rem]",
|
||||
"block-start":
|
||||
"order-first w-full justify-start px-3 pt-3 group-has-[>input]/input-group:pt-2.5 [.border-b]:pb-3",
|
||||
"block-end":
|
||||
"order-last w-full justify-start px-3 pb-3 group-has-[>input]/input-group:pb-2.5 [.border-t]:pt-3",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
align: "inline-start",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function InputGroupAddon({
|
||||
className,
|
||||
align = "inline-start",
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & VariantProps<typeof inputGroupAddonVariants>) {
|
||||
return (
|
||||
<div
|
||||
role="group"
|
||||
data-slot="input-group-addon"
|
||||
data-align={align}
|
||||
className={cn(inputGroupAddonVariants({ align }), className)}
|
||||
onClick={(e) => {
|
||||
if ((e.target as HTMLElement).closest("button")) {
|
||||
return
|
||||
}
|
||||
e.currentTarget.parentElement?.querySelector("input")?.focus()
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const inputGroupButtonVariants = cva(
|
||||
"flex items-center gap-2 text-sm shadow-none",
|
||||
{
|
||||
variants: {
|
||||
size: {
|
||||
xs: "h-6 gap-1 rounded-[calc(var(--radius)-5px)] px-2 has-[>svg]:px-2 [&>svg:not([class*='size-'])]:size-3.5",
|
||||
sm: "h-8 gap-1.5 rounded-md px-2.5 has-[>svg]:px-2.5",
|
||||
"icon-xs":
|
||||
"size-6 rounded-[calc(var(--radius)-5px)] p-0 has-[>svg]:p-0",
|
||||
"icon-sm": "size-8 p-0 has-[>svg]:p-0",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
size: "xs",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function InputGroupButton({
|
||||
className,
|
||||
type = "button",
|
||||
variant = "ghost",
|
||||
size = "xs",
|
||||
...props
|
||||
}: Omit<React.ComponentProps<typeof Button>, "size"> &
|
||||
VariantProps<typeof inputGroupButtonVariants>) {
|
||||
return (
|
||||
<Button
|
||||
type={type}
|
||||
data-size={size}
|
||||
variant={variant}
|
||||
className={cn(inputGroupButtonVariants({ size }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function InputGroupText({ className, ...props }: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"flex items-center gap-2 text-sm text-muted-foreground [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function InputGroupInput({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"input">) {
|
||||
return (
|
||||
<Input
|
||||
data-slot="input-group-control"
|
||||
className={cn(
|
||||
"flex-1 rounded-none border-0 bg-transparent shadow-none focus-visible:ring-0 ",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function InputGroupTextarea({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"textarea">) {
|
||||
return (
|
||||
<Textarea
|
||||
data-slot="input-group-control"
|
||||
className={cn(
|
||||
"flex-1 resize-none rounded-none border-0 bg-transparent py-3 shadow-none focus-visible:ring-0 ",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
InputGroup,
|
||||
InputGroupAddon,
|
||||
InputGroupButton,
|
||||
InputGroupText,
|
||||
InputGroupInput,
|
||||
InputGroupTextarea,
|
||||
}
|
||||
+13
-26
@@ -1,33 +1,20 @@
|
||||
import * as React from "react"
|
||||
import { cn } from "./utils"
|
||||
|
||||
/**
|
||||
* Vendored shadcn/ui input, minus upstream's `file:` variant utilities
|
||||
* (`file:inline-flex` `file:h-7` `file:border-0` `file:bg-transparent`
|
||||
* `file:text-sm` `file:font-medium` `file:text-foreground`).
|
||||
*
|
||||
* Two reasons, and both are load-bearing: the plugin has no `<input
|
||||
* type="file">` surface, so they are dead class strings in a runtime object;
|
||||
* and they compile to `::file-selector-button` selectors, which is also
|
||||
* Tailwind preflight's signature — `tests/ui-foundation.spec.ts` uses that
|
||||
* string to prove the reset never ships (plugin CSS is a global `<style>`, so a
|
||||
* reset would repaint the whole DSH page), and the file-input variant would
|
||||
* trip that guard forever. Nothing else on the element changes.
|
||||
*/
|
||||
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
data-slot="input"
|
||||
className={cn(
|
||||
"h-9 w-full min-w-0 rounded-md border border-input bg-transparent px-3 py-1 text-base transition-[color,box-shadow] outline-none selection:bg-primary selection:text-primary-foreground placeholder:text-muted-foreground disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
"focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50",
|
||||
"aria-invalid:border-destructive aria-invalid:ring-destructive/20",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
data-slot="input"
|
||||
className={cn(
|
||||
"h-9 w-full min-w-0 rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none selection:bg-primary selection:text-primary-foreground file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm ",
|
||||
"focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50",
|
||||
"aria-invalid:border-destructive aria-invalid:ring-destructive/20 ",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Input }
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { cn } from "./utils"
|
||||
import { Slot } from "radix-ui"
|
||||
|
||||
import { Separator } from "./separator.tsx"
|
||||
|
||||
function ItemGroup({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
role="list"
|
||||
data-slot="item-group"
|
||||
className={cn("group/item-group flex flex-col", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ItemSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof Separator>) {
|
||||
return (
|
||||
<Separator
|
||||
data-slot="item-separator"
|
||||
orientation="horizontal"
|
||||
className={cn("my-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const itemVariants = cva(
|
||||
"group/item flex flex-wrap items-center rounded-md border border-transparent text-sm transition-colors duration-100 outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 [a]:transition-colors [a]:hover:bg-accent/50",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-transparent",
|
||||
outline: "border-border",
|
||||
muted: "bg-muted/50",
|
||||
},
|
||||
size: {
|
||||
default: "gap-4 p-4",
|
||||
sm: "gap-2.5 px-4 py-3",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Item({
|
||||
className,
|
||||
variant = "default",
|
||||
size = "default",
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> &
|
||||
VariantProps<typeof itemVariants> & { asChild?: boolean }) {
|
||||
const Comp = asChild ? Slot.Root : "div"
|
||||
return (
|
||||
<Comp
|
||||
data-slot="item"
|
||||
data-variant={variant}
|
||||
data-size={size}
|
||||
className={cn(itemVariants({ variant, size, className }))}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const itemMediaVariants = cva(
|
||||
"flex shrink-0 items-center justify-center gap-2 group-has-[[data-slot=item-description]]/item:translate-y-0.5 group-has-[[data-slot=item-description]]/item:self-start [&_svg]:pointer-events-none",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-transparent",
|
||||
icon: "size-8 rounded-sm border bg-muted [&_svg:not([class*='size-'])]:size-4",
|
||||
image:
|
||||
"size-10 overflow-hidden rounded-sm [&_img]:size-full [&_img]:object-cover",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function ItemMedia({
|
||||
className,
|
||||
variant = "default",
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & VariantProps<typeof itemMediaVariants>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="item-media"
|
||||
data-variant={variant}
|
||||
className={cn(itemMediaVariants({ variant, className }))}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ItemContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="item-content"
|
||||
className={cn(
|
||||
"flex flex-1 flex-col gap-1 [&+[data-slot=item-content]]:flex-none",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ItemTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="item-title"
|
||||
className={cn(
|
||||
"flex w-fit items-center gap-2 text-sm leading-snug font-medium",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ItemDescription({ className, ...props }: React.ComponentProps<"p">) {
|
||||
return (
|
||||
<p
|
||||
data-slot="item-description"
|
||||
className={cn(
|
||||
"line-clamp-2 text-sm leading-normal font-normal text-balance text-muted-foreground",
|
||||
"[&>a]:underline [&>a]:underline-offset-4 [&>a:hover]:text-primary",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ItemActions({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="item-actions"
|
||||
className={cn("flex items-center gap-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ItemHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="item-header"
|
||||
className={cn(
|
||||
"flex basis-full items-center justify-between gap-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ItemFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="item-footer"
|
||||
className={cn(
|
||||
"flex basis-full items-center justify-between gap-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Item,
|
||||
ItemMedia,
|
||||
ItemContent,
|
||||
ItemActions,
|
||||
ItemGroup,
|
||||
ItemSeparator,
|
||||
ItemTitle,
|
||||
ItemDescription,
|
||||
ItemHeader,
|
||||
ItemFooter,
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import * as React from "react"
|
||||
import { cn } from "./utils"
|
||||
import { Label as LabelPrimitive } from "radix-ui"
|
||||
|
||||
function Label({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
|
||||
return (
|
||||
<LabelPrimitive.Root
|
||||
data-slot="label"
|
||||
className={cn(
|
||||
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Label }
|
||||
+66
-25
@@ -3,43 +3,84 @@ import { cn } from "./utils"
|
||||
import { Popover as PopoverPrimitive } from "radix-ui"
|
||||
|
||||
function Popover({
|
||||
...props
|
||||
...props
|
||||
}: React.ComponentProps<typeof PopoverPrimitive.Root>) {
|
||||
return <PopoverPrimitive.Root data-slot="popover" {...props} />
|
||||
return <PopoverPrimitive.Root data-slot="popover" {...props} />
|
||||
}
|
||||
|
||||
function PopoverTrigger({
|
||||
...props
|
||||
...props
|
||||
}: React.ComponentProps<typeof PopoverPrimitive.Trigger>) {
|
||||
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />
|
||||
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />
|
||||
}
|
||||
|
||||
function PopoverContent({
|
||||
className,
|
||||
align = "center",
|
||||
sideOffset = 4,
|
||||
...props
|
||||
className,
|
||||
align = "center",
|
||||
sideOffset = 4,
|
||||
...props
|
||||
}: React.ComponentProps<typeof PopoverPrimitive.Content>) {
|
||||
return (
|
||||
<PopoverPrimitive.Portal>
|
||||
<PopoverPrimitive.Content
|
||||
data-slot="popover-content"
|
||||
align={align}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 w-72 origin-(--radix-popover-content-transform-origin) rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-hidden data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</PopoverPrimitive.Portal>
|
||||
)
|
||||
return (
|
||||
<PopoverPrimitive.Portal>
|
||||
<PopoverPrimitive.Content
|
||||
data-slot="popover-content"
|
||||
align={align}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 w-72 origin-(--radix-popover-content-transform-origin) rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-hidden data-[side=bottom]: data-[side=left]: data-[side=right]: data-[side=top]: data-[state=closed]: data-[state=closed]: data-[state=closed]: data-[state=open]: data-[state=open]: data-[state=open]:",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</PopoverPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
function PopoverAnchor({
|
||||
...props
|
||||
...props
|
||||
}: React.ComponentProps<typeof PopoverPrimitive.Anchor>) {
|
||||
return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />
|
||||
return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />
|
||||
}
|
||||
|
||||
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor }
|
||||
function PopoverHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="popover-header"
|
||||
className={cn("flex flex-col gap-1 text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function PopoverTitle({ className, ...props }: React.ComponentProps<"h2">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="popover-title"
|
||||
className={cn("font-medium", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function PopoverDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"p">) {
|
||||
return (
|
||||
<p
|
||||
data-slot="popover-description"
|
||||
className={cn("text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Popover,
|
||||
PopoverTrigger,
|
||||
PopoverContent,
|
||||
PopoverAnchor,
|
||||
PopoverHeader,
|
||||
PopoverTitle,
|
||||
PopoverDescription,
|
||||
}
|
||||
|
||||
@@ -3,53 +3,53 @@ import { cn } from "./utils"
|
||||
import { ScrollArea as ScrollAreaPrimitive } from "radix-ui"
|
||||
|
||||
function ScrollArea({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ScrollAreaPrimitive.Root>) {
|
||||
return (
|
||||
<ScrollAreaPrimitive.Root
|
||||
data-slot="scroll-area"
|
||||
className={cn("relative", className)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.Viewport
|
||||
data-slot="scroll-area-viewport"
|
||||
className="size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1"
|
||||
>
|
||||
{children}
|
||||
</ScrollAreaPrimitive.Viewport>
|
||||
<ScrollBar />
|
||||
<ScrollAreaPrimitive.Corner />
|
||||
</ScrollAreaPrimitive.Root>
|
||||
)
|
||||
return (
|
||||
<ScrollAreaPrimitive.Root
|
||||
data-slot="scroll-area"
|
||||
className={cn("relative", className)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.Viewport
|
||||
data-slot="scroll-area-viewport"
|
||||
className="size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1"
|
||||
>
|
||||
{children}
|
||||
</ScrollAreaPrimitive.Viewport>
|
||||
<ScrollBar />
|
||||
<ScrollAreaPrimitive.Corner />
|
||||
</ScrollAreaPrimitive.Root>
|
||||
)
|
||||
}
|
||||
|
||||
function ScrollBar({
|
||||
className,
|
||||
orientation = "vertical",
|
||||
...props
|
||||
className,
|
||||
orientation = "vertical",
|
||||
...props
|
||||
}: React.ComponentProps<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>) {
|
||||
return (
|
||||
<ScrollAreaPrimitive.ScrollAreaScrollbar
|
||||
data-slot="scroll-area-scrollbar"
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"flex touch-none p-px transition-colors select-none",
|
||||
orientation === "vertical" &&
|
||||
"h-full w-2.5 border-l border-l-transparent",
|
||||
orientation === "horizontal" &&
|
||||
"h-2.5 flex-col border-t border-t-transparent",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.ScrollAreaThumb
|
||||
data-slot="scroll-area-thumb"
|
||||
className="relative flex-1 rounded-full bg-border"
|
||||
/>
|
||||
</ScrollAreaPrimitive.ScrollAreaScrollbar>
|
||||
)
|
||||
return (
|
||||
<ScrollAreaPrimitive.ScrollAreaScrollbar
|
||||
data-slot="scroll-area-scrollbar"
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"flex touch-none p-px transition-colors select-none",
|
||||
orientation === "vertical" &&
|
||||
"h-full w-2.5 border-l border-l-transparent",
|
||||
orientation === "horizontal" &&
|
||||
"h-2.5 flex-col border-t border-t-transparent",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.ScrollAreaThumb
|
||||
data-slot="scroll-area-thumb"
|
||||
className="relative flex-1 rounded-full bg-border"
|
||||
/>
|
||||
</ScrollAreaPrimitive.ScrollAreaScrollbar>
|
||||
)
|
||||
}
|
||||
|
||||
export { ScrollArea, ScrollBar }
|
||||
|
||||
+16
-16
@@ -3,23 +3,23 @@ import { cn } from "./utils"
|
||||
import { Separator as SeparatorPrimitive } from "radix-ui"
|
||||
|
||||
function Separator({
|
||||
className,
|
||||
orientation = "horizontal",
|
||||
decorative = true,
|
||||
...props
|
||||
className,
|
||||
orientation = "horizontal",
|
||||
decorative = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
|
||||
return (
|
||||
<SeparatorPrimitive.Root
|
||||
data-slot="separator"
|
||||
decorative={decorative}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"shrink-0 bg-border data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
return (
|
||||
<SeparatorPrimitive.Root
|
||||
data-slot="separator"
|
||||
decorative={decorative}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"shrink-0 bg-border data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Separator }
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { cn } from "./utils"
|
||||
|
||||
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="skeleton"
|
||||
className={cn("animate-pulse rounded-md bg-accent", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
return (
|
||||
<div
|
||||
data-slot="skeleton"
|
||||
className={cn("animate-pulse rounded-md bg-accent", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Skeleton }
|
||||
|
||||
@@ -2,28 +2,16 @@ import { IconLoadingOutline16 } from "@deepseek-ai/dsh-client-ui-primitives"
|
||||
import { cn } from "./utils"
|
||||
|
||||
/**
|
||||
* Loading indicator. The host supplies the glyph (`IconLoadingOutline16`), and
|
||||
* the rotation rides `animate-spin` on it — the host icons render an `svg`, so
|
||||
* the utility applies to the glyph box the same way it did upstream.
|
||||
*
|
||||
* Upstream took `React.ComponentProps<"svg">`; host icons expose exactly
|
||||
* `size` / `className`, so the accessible status wrapper moved out one level
|
||||
* instead of being spread onto the glyph.
|
||||
* Vendored shadcn `Spinner` with the icon swapped to the host's primitive set
|
||||
* (the plugin never ships lucide). The host icons render their own `<svg>`, so
|
||||
* the props land on a wrapping span instead of the svg element.
|
||||
*/
|
||||
function Spinner({
|
||||
className,
|
||||
size = 16,
|
||||
label = "Loading",
|
||||
}: {
|
||||
className?: string
|
||||
size?: number
|
||||
label?: string
|
||||
}) {
|
||||
return (
|
||||
<span role="status" aria-label={label} className="inline-flex">
|
||||
<IconLoadingOutline16 size={size} className={cn("animate-spin", className)} />
|
||||
</span>
|
||||
)
|
||||
function Spinner({ className, ...props }: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span role="status" aria-label="Loading" className={cn("inline-flex", className)} {...props}>
|
||||
<IconLoadingOutline16 className="size-4 animate-spin" />
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export { Spinner }
|
||||
|
||||
+10
-10
@@ -2,16 +2,16 @@ import * as React from "react"
|
||||
import { cn } from "./utils"
|
||||
|
||||
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
|
||||
return (
|
||||
<textarea
|
||||
data-slot="textarea"
|
||||
className={cn(
|
||||
"flex field-sizing-content min-h-16 w-full rounded-md border border-input bg-transparent px-3 py-2 text-base transition-[color,box-shadow] outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 md:text-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
return (
|
||||
<textarea
|
||||
data-slot="textarea"
|
||||
className={cn(
|
||||
"flex field-sizing-content min-h-16 w-full rounded-md border border-input bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 md:text-sm ",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Textarea }
|
||||
|
||||
@@ -3,79 +3,79 @@ import { type VariantProps } from "class-variance-authority"
|
||||
import { cn } from "./utils"
|
||||
import { ToggleGroup as ToggleGroupPrimitive } from "radix-ui"
|
||||
|
||||
import { toggleVariants } from "./toggle"
|
||||
import { toggleVariants } from "./toggle.tsx"
|
||||
|
||||
const ToggleGroupContext = React.createContext<
|
||||
VariantProps<typeof toggleVariants> & {
|
||||
spacing?: number
|
||||
}
|
||||
VariantProps<typeof toggleVariants> & {
|
||||
spacing?: number
|
||||
}
|
||||
>({
|
||||
size: "default",
|
||||
variant: "default",
|
||||
spacing: 0,
|
||||
size: "default",
|
||||
variant: "default",
|
||||
spacing: 0,
|
||||
})
|
||||
|
||||
function ToggleGroup({
|
||||
className,
|
||||
variant,
|
||||
size,
|
||||
spacing = 0,
|
||||
children,
|
||||
...props
|
||||
className,
|
||||
variant,
|
||||
size,
|
||||
spacing = 0,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ToggleGroupPrimitive.Root> &
|
||||
VariantProps<typeof toggleVariants> & {
|
||||
spacing?: number
|
||||
}) {
|
||||
return (
|
||||
<ToggleGroupPrimitive.Root
|
||||
data-slot="toggle-group"
|
||||
data-variant={variant}
|
||||
data-size={size}
|
||||
data-spacing={spacing}
|
||||
style={{ "--gap": spacing } as React.CSSProperties}
|
||||
className={cn(
|
||||
"group/toggle-group flex w-fit items-center gap-[--spacing(var(--gap))] rounded-md",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ToggleGroupContext.Provider value={{ variant, size, spacing }}>
|
||||
{children}
|
||||
</ToggleGroupContext.Provider>
|
||||
</ToggleGroupPrimitive.Root>
|
||||
)
|
||||
VariantProps<typeof toggleVariants> & {
|
||||
spacing?: number
|
||||
}) {
|
||||
return (
|
||||
<ToggleGroupPrimitive.Root
|
||||
data-slot="toggle-group"
|
||||
data-variant={variant}
|
||||
data-size={size}
|
||||
data-spacing={spacing}
|
||||
style={{ "--gap": spacing } as React.CSSProperties}
|
||||
className={cn(
|
||||
"group/toggle-group flex w-fit items-center gap-[--spacing(var(--gap))] rounded-md data-[spacing=default]:data-[variant=outline]:shadow-xs",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ToggleGroupContext.Provider value={{ variant, size, spacing }}>
|
||||
{children}
|
||||
</ToggleGroupContext.Provider>
|
||||
</ToggleGroupPrimitive.Root>
|
||||
)
|
||||
}
|
||||
|
||||
function ToggleGroupItem({
|
||||
className,
|
||||
children,
|
||||
variant,
|
||||
size,
|
||||
...props
|
||||
className,
|
||||
children,
|
||||
variant,
|
||||
size,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ToggleGroupPrimitive.Item> &
|
||||
VariantProps<typeof toggleVariants>) {
|
||||
const context = React.useContext(ToggleGroupContext)
|
||||
VariantProps<typeof toggleVariants>) {
|
||||
const context = React.useContext(ToggleGroupContext)
|
||||
|
||||
return (
|
||||
<ToggleGroupPrimitive.Item
|
||||
data-slot="toggle-group-item"
|
||||
data-variant={context.variant || variant}
|
||||
data-size={context.size || size}
|
||||
data-spacing={context.spacing}
|
||||
className={cn(
|
||||
toggleVariants({
|
||||
variant: context.variant || variant,
|
||||
size: context.size || size,
|
||||
}),
|
||||
"w-auto min-w-0 shrink-0 px-3 focus:z-10 focus-visible:z-10",
|
||||
"data-[spacing=0]:rounded-none data-[spacing=0]:first:rounded-l-md data-[spacing=0]:last:rounded-r-md data-[spacing=0]:data-[variant=outline]:border-l-0 data-[spacing=0]:data-[variant=outline]:first:border-l",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</ToggleGroupPrimitive.Item>
|
||||
)
|
||||
return (
|
||||
<ToggleGroupPrimitive.Item
|
||||
data-slot="toggle-group-item"
|
||||
data-variant={context.variant || variant}
|
||||
data-size={context.size || size}
|
||||
data-spacing={context.spacing}
|
||||
className={cn(
|
||||
toggleVariants({
|
||||
variant: context.variant || variant,
|
||||
size: context.size || size,
|
||||
}),
|
||||
"w-auto min-w-0 shrink-0 px-3 focus:z-10 focus-visible:z-10",
|
||||
"data-[spacing=0]:rounded-none data-[spacing=0]:shadow-none data-[spacing=0]:first:rounded-l-md data-[spacing=0]:last:rounded-r-md data-[spacing=0]:data-[variant=outline]:border-l-0 data-[spacing=0]:data-[variant=outline]:first:border-l",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</ToggleGroupPrimitive.Item>
|
||||
)
|
||||
}
|
||||
|
||||
export { ToggleGroup, ToggleGroupItem }
|
||||
|
||||
+31
-37
@@ -3,48 +3,42 @@ import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { cn } from "./utils"
|
||||
import { Toggle as TogglePrimitive } from "radix-ui"
|
||||
|
||||
/**
|
||||
* Vendored shadcn/ui toggle, trimmed to the task page's real inventory.
|
||||
*
|
||||
* Removed from upstream (`new-york`): the `lg` size (the page's toggle heights
|
||||
* top out at 36 px). `Toggle` itself stays — the view switcher's segmented
|
||||
* control is built from it via `ToggleGroup`.
|
||||
*/
|
||||
const toggleVariants = cva(
|
||||
"inline-flex items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-[color,box-shadow] outline-none hover:bg-muted hover:text-muted-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-transparent",
|
||||
outline:
|
||||
"border border-input bg-transparent hover:bg-accent hover:text-accent-foreground",
|
||||
},
|
||||
size: {
|
||||
default: "h-9 min-w-9 px-2",
|
||||
sm: "h-8 min-w-8 px-1.5",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
"inline-flex items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-[color,box-shadow] outline-none hover:bg-muted hover:text-muted-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-transparent",
|
||||
outline:
|
||||
"border border-input bg-transparent shadow-xs hover:bg-accent hover:text-accent-foreground",
|
||||
},
|
||||
size: {
|
||||
default: "h-9 min-w-9 px-2",
|
||||
sm: "h-8 min-w-8 px-1.5",
|
||||
lg: "h-10 min-w-10 px-2.5",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Toggle({
|
||||
className,
|
||||
variant,
|
||||
size,
|
||||
...props
|
||||
className,
|
||||
variant,
|
||||
size,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TogglePrimitive.Root> &
|
||||
VariantProps<typeof toggleVariants>) {
|
||||
return (
|
||||
<TogglePrimitive.Root
|
||||
data-slot="toggle"
|
||||
className={cn(toggleVariants({ variant, size, className }))}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
VariantProps<typeof toggleVariants>) {
|
||||
return (
|
||||
<TogglePrimitive.Root
|
||||
data-slot="toggle"
|
||||
className={cn(toggleVariants({ variant, size, className }))}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Toggle, toggleVariants }
|
||||
|
||||
+33
-33
@@ -3,52 +3,52 @@ import { cn } from "./utils"
|
||||
import { Tooltip as TooltipPrimitive } from "radix-ui"
|
||||
|
||||
function TooltipProvider({
|
||||
delayDuration = 0,
|
||||
...props
|
||||
delayDuration = 0,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
|
||||
return (
|
||||
<TooltipPrimitive.Provider
|
||||
data-slot="tooltip-provider"
|
||||
delayDuration={delayDuration}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
return (
|
||||
<TooltipPrimitive.Provider
|
||||
data-slot="tooltip-provider"
|
||||
delayDuration={delayDuration}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function Tooltip({
|
||||
...props
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Root>) {
|
||||
return <TooltipPrimitive.Root data-slot="tooltip" {...props} />
|
||||
return <TooltipPrimitive.Root data-slot="tooltip" {...props} />
|
||||
}
|
||||
|
||||
function TooltipTrigger({
|
||||
...props
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
|
||||
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
|
||||
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
|
||||
}
|
||||
|
||||
function TooltipContent({
|
||||
className,
|
||||
sideOffset = 0,
|
||||
children,
|
||||
...props
|
||||
className,
|
||||
sideOffset = 0,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
|
||||
return (
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipPrimitive.Content
|
||||
data-slot="tooltip-content"
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 w-fit origin-(--radix-tooltip-content-transform-origin) animate-in rounded-md bg-foreground px-3 py-1.5 text-xs text-balance text-background fade-in-0 zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<TooltipPrimitive.Arrow className="z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px] bg-foreground fill-foreground" />
|
||||
</TooltipPrimitive.Content>
|
||||
</TooltipPrimitive.Portal>
|
||||
)
|
||||
return (
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipPrimitive.Content
|
||||
data-slot="tooltip-content"
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md bg-foreground px-3 py-1.5 text-xs text-balance text-background data-[side=bottom]: data-[side=left]: data-[side=right]: data-[side=top]: data-[state=closed]: data-[state=closed]: data-[state=closed]:",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<TooltipPrimitive.Arrow className="z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px] bg-foreground fill-foreground" />
|
||||
</TooltipPrimitive.Content>
|
||||
</TooltipPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
*
|
||||
* Static panels (cards, rows, the drawer) may carry the stock LIGHT shadows
|
||||
* only — `shadow-xs` (button-like compact surfaces such as graph node cards)
|
||||
* and `shadow-sm` (cards / panels). Anything heavier (`shadow-md|lg|xl|2xl`,
|
||||
* and `shadow-sm` (cards / panels); `shadow-none` (a reset) is fine. Anything heavier (`shadow-md|lg|xl|2xl`,
|
||||
* bare `shadow`, `shadow-inner`, arbitrary `shadow-[…]`) on a static panel is
|
||||
* the regression: elevation beyond the stock pair reads as a floating layer
|
||||
* that nothing anchors.
|
||||
@@ -54,7 +54,10 @@ const FLOATING_FILES = [
|
||||
const SHADOW_UTILITY = /(?<![-\w])shadow(?:-(?:2xs|xs|sm|md|lg|xl|2xl|inner|none)|\[|\()?/g
|
||||
|
||||
/** The stock pair a static panel may paint. */
|
||||
const STATIC_ALLOWED = new Set(['shadow-xs', 'shadow-sm'])
|
||||
// `shadow-none` is not an elevation at all (it clears the ring plumbing), so it
|
||||
// is allowed anywhere a light shadow is; upstream's input-group uses it to keep
|
||||
// nested controls flat inside the group's own surface.
|
||||
const STATIC_ALLOWED = new Set(['shadow-xs', 'shadow-sm', 'shadow-none'])
|
||||
|
||||
/** Every non-allowed shadow utility in the source, for a readable failure. */
|
||||
function offendingShadows(source: string): string[] {
|
||||
|
||||
Reference in New Issue
Block a user