mirror of
https://github.com/zhu1090093659/dsh-web.git
synced 2026-09-28 14:24:03 +08:00
feat(plugins): opt into the L2 semantic attributes (#506)
This commit is contained in:
@@ -249,6 +249,8 @@ export function BranchChip(props: BranchChipProps) {
|
||||
<div
|
||||
ref={anchorRef}
|
||||
data-gitgraph-chip-anchor
|
||||
data-dsh-plugin="git-graph"
|
||||
data-dsh-part="chip"
|
||||
data-gitgraph-stock-light={stockLightTheme || undefined}
|
||||
className={cx(css.anchor, heroSeat && css.anchorHero)}
|
||||
style={heroSeat && heroPlacement !== null
|
||||
|
||||
@@ -103,7 +103,7 @@ export function GraphDialog({ graph, onClose, t }: GraphDialogProps) {
|
||||
return (
|
||||
<>
|
||||
<Backdrop onClose={onClose} />
|
||||
<div className={css.dialog} role="dialog" aria-label={t('graph.title')} data-gitgraph-dialog>
|
||||
<div className={css.dialog} role="dialog" aria-label={t('graph.title')} data-gitgraph-dialog data-dsh-plugin="git-graph" data-dsh-part="dialog">
|
||||
<div className={css.dialogHeader}>
|
||||
<div className={css.dialogHeading}>
|
||||
<h3 className={css.dialogTitle}>{t('graph.title')}</h3>
|
||||
|
||||
@@ -15,6 +15,7 @@ import type { BranchesView, GraphView, RepoStatus, SwitchResult } from '../src/c
|
||||
import type { GitGraphInjected } from '../src/client/index.ts'
|
||||
import type { BranchChipProps } from '../src/client/chips/BranchChip.tsx'
|
||||
import { BranchChip } from '../src/client/chips/BranchChip.tsx'
|
||||
import { GraphDialog } from '../src/client/graph/GraphDialog.tsx'
|
||||
import { zh, type GitGraphKey } from '../src/client/locales.ts'
|
||||
import css from '../src/client/chips/context.module.css'
|
||||
|
||||
@@ -165,6 +166,14 @@ describe('BranchChip', () => {
|
||||
expect(branchChip.textContent).toContain('main')
|
||||
})
|
||||
|
||||
it('opts the chip anchor into the L2 semantic attributes (#506)', async () => {
|
||||
bench()
|
||||
const branchChip = await screen.findByRole('button', { name: '分支' })
|
||||
const anchor = anchorOf(branchChip)
|
||||
expect(anchor.getAttribute('data-dsh-plugin')).toBe('git-graph')
|
||||
expect(anchor.getAttribute('data-dsh-part')).toBe('chip')
|
||||
})
|
||||
|
||||
it('marks only the unskinned light skin-center page for stock-light fallback styles', async () => {
|
||||
document.body.setAttribute('data-dsh-skin-center', '')
|
||||
bench()
|
||||
@@ -419,3 +428,26 @@ describe('BranchChip', () => {
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('GraphDialog', () => {
|
||||
it('opts the dialog into the L2 semantic attributes (#506)', async () => {
|
||||
render(
|
||||
<GraphDialog
|
||||
graph={async () => ({
|
||||
root: '/ws/proj', branch: 'main',
|
||||
commits: [
|
||||
{ oid: 'aabbcc', parents: [], subject: 'root commit', author: 'Bob', authorTime: 1690000000, refs: [] },
|
||||
],
|
||||
hasMore: false,
|
||||
})}
|
||||
onClose={() => {}}
|
||||
t={makeTranslate()}
|
||||
/>,
|
||||
)
|
||||
const dialog = await screen.findByRole('dialog', { name: 'Git 图谱' })
|
||||
expect(dialog.getAttribute('data-gitgraph-dialog')).not.toBeNull()
|
||||
expect(dialog.getAttribute('data-dsh-plugin')).toBe('git-graph')
|
||||
expect(dialog.getAttribute('data-dsh-part')).toBe('dialog')
|
||||
expect(await screen.findByText('root commit')).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* The hidden-state summon button opts into the L2 semantic attributes
|
||||
* (issue #506): it carries data-dsh-part="summon-button" so skins can target
|
||||
* it without hash-class selectors.
|
||||
*/
|
||||
import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'
|
||||
import { cleanup, render, screen } from '@testing-library/react'
|
||||
// The npm SDK's client half is a closure-factory bundle for the GUI's
|
||||
// __ModuleLoader__ (not importable under vitest); provide the defineStore
|
||||
// the pet store needs (same fake-store pattern as the settings-card tests).
|
||||
vi.mock('@deepseek-ai/dsh-client-runtime/client', () => ({
|
||||
defineStore: (spec: {
|
||||
init: () => unknown
|
||||
actions: Record<string, (draft: never, ...args: never[]) => void>
|
||||
}) => ({
|
||||
create: () => {
|
||||
let value = spec.init()
|
||||
const listeners = new Set<() => void>()
|
||||
const actions: Record<string, (...args: unknown[]) => void> = {}
|
||||
for (const [name, fn] of Object.entries(spec.actions)) {
|
||||
actions[name] = (...args: unknown[]) => {
|
||||
fn(value as never, ...(args as never[]))
|
||||
for (const listener of listeners) listener()
|
||||
}
|
||||
}
|
||||
return {
|
||||
getSnapshot: () => value,
|
||||
subscribe: (listener: () => void) => {
|
||||
listeners.add(listener)
|
||||
return () => { listeners.delete(listener) }
|
||||
},
|
||||
actions,
|
||||
}
|
||||
},
|
||||
}),
|
||||
}))
|
||||
import { PetDockEntry, type PetInjected } from './PetDockEntry.tsx'
|
||||
import { createPetStore } from './pet-store.ts'
|
||||
import { t } from './locales.ts'
|
||||
import type { PetStateView } from '../service.ts'
|
||||
|
||||
beforeAll(() => {
|
||||
document.documentElement.lang = 'zh'
|
||||
})
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
/** Snapshot fixture with the pet hidden (the summon-button state). */
|
||||
const hiddenSnapshot: PetStateView = {
|
||||
animation: 'idle',
|
||||
phase: 'idle',
|
||||
sessionActive: false,
|
||||
affinity: {
|
||||
points: 0,
|
||||
rank: '幼鲸',
|
||||
rankEmoji: '*',
|
||||
pets: 0,
|
||||
feeds: 0,
|
||||
turns: 0,
|
||||
petCooldown: false,
|
||||
feedCooldown: false,
|
||||
},
|
||||
display: { visible: false, size: 160, right: 24, bottom: 20 },
|
||||
pet: { id: 'whale-girl', displayName: '鲸鱼娘', description: '测试用鲸鱼娘' },
|
||||
name: '泡泡',
|
||||
treats: { stocked: 0, max: 5 },
|
||||
}
|
||||
|
||||
function injected(): PetInjected {
|
||||
return {
|
||||
store: createPetStore().create(),
|
||||
ensure: vi.fn(),
|
||||
pet: vi.fn(),
|
||||
feed: vi.fn(),
|
||||
hide: vi.fn(),
|
||||
summon: vi.fn(),
|
||||
dragEnd: vi.fn(),
|
||||
rename: vi.fn(),
|
||||
openSession: vi.fn(),
|
||||
feedbackDone: vi.fn(),
|
||||
}
|
||||
}
|
||||
|
||||
describe('PetDockEntry L2 semantic attributes (#506)', () => {
|
||||
it('tags the summon button as the summon-button part', () => {
|
||||
const props = injected()
|
||||
props.store.actions.setSnapshot(hiddenSnapshot)
|
||||
render(<PetDockEntry {...props} t={t} />)
|
||||
const summon = screen.getByTestId('pet-summon')
|
||||
expect(summon.getAttribute('data-dsh-part')).toBe('summon-button')
|
||||
})
|
||||
})
|
||||
@@ -106,6 +106,7 @@ export function PetDockEntry(props: PetDockEntryProps): ReactElement {
|
||||
}}
|
||||
onClick={props.summon}
|
||||
data-testid="pet-summon"
|
||||
data-dsh-part="summon-button"
|
||||
>
|
||||
{props.t('pet.summon', { name: snapshot?.name ?? '' })}
|
||||
</button>
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* The global pet entry container opts into the L2 semantic attributes
|
||||
* (issue #506): the apply body mounts [data-dsh-pet-root] with
|
||||
* data-dsh-plugin="pet" so skins can target the pet subtree.
|
||||
*/
|
||||
import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'
|
||||
// The npm SDK's client half is a closure-factory bundle for the GUI's
|
||||
// __ModuleLoader__ (not importable under vitest); provide defineStore /
|
||||
// createSnapshotStore (same fake-store pattern as the settings-card tests).
|
||||
vi.mock('@deepseek-ai/dsh-client-runtime/client', () => ({
|
||||
defineStore: (spec: {
|
||||
init: () => unknown
|
||||
actions: Record<string, (draft: never, ...args: never[]) => void>
|
||||
}) => ({
|
||||
create: () => {
|
||||
let value = spec.init()
|
||||
const listeners = new Set<() => void>()
|
||||
const actions: Record<string, (...args: unknown[]) => void> = {}
|
||||
for (const [name, fn] of Object.entries(spec.actions)) {
|
||||
actions[name] = (...args: unknown[]) => {
|
||||
fn(value as never, ...(args as never[]))
|
||||
for (const listener of listeners) listener()
|
||||
}
|
||||
}
|
||||
return {
|
||||
getSnapshot: () => value,
|
||||
subscribe: (listener: () => void) => {
|
||||
listeners.add(listener)
|
||||
return () => { listeners.delete(listener) }
|
||||
},
|
||||
actions,
|
||||
}
|
||||
},
|
||||
}),
|
||||
createSnapshotStore: (init: unknown) => {
|
||||
let value = init
|
||||
const listeners = new Set<() => void>()
|
||||
return {
|
||||
getSnapshot: () => value,
|
||||
set: (next: unknown) => { value = next; for (const listener of listeners) listener() },
|
||||
update: (mutator: (draft: never) => void) => { mutator(value as never); for (const listener of listeners) listener() },
|
||||
subscribe: (listener: () => void) => { listeners.add(listener); return () => { listeners.delete(listener) } },
|
||||
}
|
||||
},
|
||||
}))
|
||||
import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { apply } from './index.ts'
|
||||
|
||||
beforeAll(() => {
|
||||
document.documentElement.lang = 'zh'
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
document.body.replaceChildren()
|
||||
})
|
||||
|
||||
/** A minimal client root context: ready settings scope, no-op slot system. */
|
||||
function fakeContext(): ClientContext {
|
||||
const scope = {
|
||||
getSnapshot: () => ({
|
||||
status: 'ready',
|
||||
writable: true,
|
||||
value: undefined,
|
||||
base: undefined,
|
||||
user: {},
|
||||
revision: 1,
|
||||
mode: 'host',
|
||||
}),
|
||||
subscribe: () => () => {},
|
||||
}
|
||||
return {
|
||||
effect: (fn: () => unknown) => {
|
||||
const dispose = fn()
|
||||
return typeof dispose === 'function' ? dispose : () => {}
|
||||
},
|
||||
locale: { register: () => () => {} },
|
||||
get: () => undefined,
|
||||
settingsScope: { bind: () => scope },
|
||||
slots: {
|
||||
inject: (_name: string, callback: () => () => void) => callback(),
|
||||
register: () => () => {},
|
||||
},
|
||||
sessions: undefined,
|
||||
} as unknown as ClientContext
|
||||
}
|
||||
|
||||
describe('pet client apply L2 semantic attributes (#506)', () => {
|
||||
it('mounts the pet root container with data-dsh-plugin="pet"', () => {
|
||||
apply(fakeContext())
|
||||
const root = document.body.querySelector('[data-dsh-pet-root]')
|
||||
expect(root).not.toBeNull()
|
||||
expect(root!.getAttribute('data-dsh-plugin')).toBe('pet')
|
||||
})
|
||||
})
|
||||
@@ -292,6 +292,7 @@ export function apply(ctx: ClientContext): void {
|
||||
// when visible, and the hidden-state summon button is fixed-positioned.
|
||||
const container = document.createElement('div')
|
||||
container.dataset.dshPetRoot = ''
|
||||
container.dataset.dshPlugin = 'pet'
|
||||
document.body.appendChild(container)
|
||||
const petRoot = createRoot(container)
|
||||
petRoot.render(createElement(PetDockEntry, { ...injected(), t }))
|
||||
|
||||
@@ -21,6 +21,13 @@ export interface SidebarEntryOptions {
|
||||
rowAttribute: string
|
||||
/** CSS selector matching the injected row, e.g. '[data-dsh-ssh-entry]'. */
|
||||
rowSelector: string
|
||||
/**
|
||||
* L2 semantic-attribute plugin id (issue #506, enum table:
|
||||
* skins/skin-center/contracts/semantic-attrs-v1.md). When set, the row also
|
||||
* outputs data-dsh-plugin="<id>" and data-dsh-part="sidebar-entry"; unset
|
||||
* leaves the row without semantic attributes.
|
||||
*/
|
||||
plugin?: string
|
||||
/** Inline icon markup (matches the shell's 16px nav-icon look). */
|
||||
icon: string
|
||||
/** CSS module class names for the row and its two spans (entry / entryIcon / entryLabel). */
|
||||
@@ -73,6 +80,10 @@ function createEntry(options: SidebarEntryOptions): HTMLButtonElement {
|
||||
const entry = document.createElement('button')
|
||||
entry.type = 'button'
|
||||
entry.setAttribute(options.rowAttribute, '')
|
||||
if (options.plugin !== undefined) {
|
||||
entry.setAttribute('data-dsh-plugin', options.plugin)
|
||||
entry.setAttribute('data-dsh-part', 'sidebar-entry')
|
||||
}
|
||||
entry.className = options.css['entry'] ?? ''
|
||||
entry.setAttribute('aria-label', options.label())
|
||||
if (options.tooltip !== undefined) entry.setAttribute('title', options.tooltip())
|
||||
|
||||
@@ -57,6 +57,7 @@ export function mountPanel(controller: PanelController, api: SshApi): () => void
|
||||
if (column === undefined) return
|
||||
container = document.createElement('div')
|
||||
container.dataset.dshSshView = ''
|
||||
container.dataset.dshPlugin = 'ssh'
|
||||
container.className = css.view
|
||||
column.appendChild(container)
|
||||
root = createRoot(container)
|
||||
|
||||
@@ -66,7 +66,7 @@ export function SshPanel({ controller, api }: SshPanelProps) {
|
||||
</button>
|
||||
<h2 className={css.panelTitle}>{tt('panel.title')}</h2>
|
||||
</div>
|
||||
<div className={css.tabBar} role="tablist">
|
||||
<div className={css.tabBar} role="tablist" data-dsh-part="tab-bar">
|
||||
{TABS.map(tab => (
|
||||
<button key={tab.id} type="button" role="tab" aria-selected={activeTab === tab.id} data-active={activeTab === tab.id ? '' : undefined} className={css.tab} onClick={() => { setActiveTab(tab.id) }}>
|
||||
{tab.label()}
|
||||
|
||||
@@ -173,7 +173,7 @@ export function TerminalTab({ api, presetAlias, requestId }: TerminalTabProps) {
|
||||
)}
|
||||
{status.kind === 'error' && <div className={css.banner} data-kind="error">{tt('terminal.error', { error: status.detail })}</div>}
|
||||
<div className={css.termWrap}>
|
||||
<div ref={containerRef} className={css.termContainer} />
|
||||
<div ref={containerRef} className={css.termContainer} data-dsh-part="terminal" />
|
||||
{status.kind === 'idle' && (
|
||||
<div className={css.termPlaceholder}>{hosts.length === 0 ? tt('hosts.empty') : tt('terminal.placeholder')}</div>
|
||||
)}
|
||||
|
||||
@@ -21,6 +21,13 @@ export interface SidebarEntryOptions {
|
||||
rowAttribute: string
|
||||
/** CSS selector matching the injected row, e.g. '[data-dsh-ssh-entry]'. */
|
||||
rowSelector: string
|
||||
/**
|
||||
* L2 semantic-attribute plugin id (issue #506, enum table:
|
||||
* skins/skin-center/contracts/semantic-attrs-v1.md). When set, the row also
|
||||
* outputs data-dsh-plugin="<id>" and data-dsh-part="sidebar-entry"; unset
|
||||
* leaves the row without semantic attributes.
|
||||
*/
|
||||
plugin?: string
|
||||
/** Inline icon markup (matches the shell's 16px nav-icon look). */
|
||||
icon: string
|
||||
/** CSS module class names for the row and its two spans (entry / entryIcon / entryLabel). */
|
||||
@@ -73,6 +80,10 @@ function createEntry(options: SidebarEntryOptions): HTMLButtonElement {
|
||||
const entry = document.createElement('button')
|
||||
entry.type = 'button'
|
||||
entry.setAttribute(options.rowAttribute, '')
|
||||
if (options.plugin !== undefined) {
|
||||
entry.setAttribute('data-dsh-plugin', options.plugin)
|
||||
entry.setAttribute('data-dsh-part', 'sidebar-entry')
|
||||
}
|
||||
entry.className = options.css['entry'] ?? ''
|
||||
entry.setAttribute('aria-label', options.label())
|
||||
if (options.tooltip !== undefined) entry.setAttribute('title', options.tooltip())
|
||||
|
||||
@@ -29,6 +29,7 @@ export function mountSidebarEntry(controller: PanelController): () => void {
|
||||
return mountSharedSidebarEntry({
|
||||
rowAttribute: 'data-dsh-ssh-entry',
|
||||
rowSelector: ENTRY_SELECTOR,
|
||||
plugin: 'ssh',
|
||||
icon: ICON,
|
||||
css,
|
||||
label: () => tt('entry.label'),
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* L2 semantic attributes of the SSH panel (issue #506): the mounted panel
|
||||
* container and the tab bar opt into the semantic-attrs/v1 enum
|
||||
* (data-dsh-plugin / data-dsh-part) so skins can target them without
|
||||
* hash-class selectors.
|
||||
*/
|
||||
import { act } from 'react'
|
||||
import { createRoot, type Root } from 'react-dom/client'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { mountPanel } from '../src/client/mount.tsx'
|
||||
import { SshPanel } from '../src/client/panel/SshPanel.tsx'
|
||||
import type { SshApi } from '../src/client/api.ts'
|
||||
import type { PanelController } from '../src/client/panel/controller.ts'
|
||||
|
||||
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
|
||||
|
||||
const roots: Root[] = []
|
||||
let disposeMount: (() => void) | undefined
|
||||
|
||||
afterEach(() => {
|
||||
disposeMount?.()
|
||||
disposeMount = undefined
|
||||
for (const root of roots.splice(0)) {
|
||||
act(() => { root.unmount() })
|
||||
}
|
||||
document.body.replaceChildren()
|
||||
document.documentElement.removeAttribute('data-dsh-ssh-active')
|
||||
})
|
||||
|
||||
function fakeApi(): SshApi {
|
||||
return {
|
||||
listHosts: vi.fn(async () => []),
|
||||
} as unknown as SshApi
|
||||
}
|
||||
|
||||
function fakeController(): PanelController {
|
||||
return {
|
||||
getSnapshot: () => ({ panelOpen: false }),
|
||||
subscribe: () => () => {},
|
||||
close: () => {},
|
||||
} as unknown as PanelController
|
||||
}
|
||||
|
||||
describe('SshPanel L2 semantic attributes (#506)', () => {
|
||||
it('tags the tab bar as the tab-bar part', async () => {
|
||||
const container = document.createElement('div')
|
||||
document.body.appendChild(container)
|
||||
const root = createRoot(container)
|
||||
roots.push(root)
|
||||
await act(async () => { root.render(<SshPanel controller={fakeController()} api={fakeApi()} />) })
|
||||
|
||||
const tabBar = container.querySelector('[role="tablist"]')
|
||||
expect(tabBar).not.toBeNull()
|
||||
expect(tabBar!.getAttribute('data-dsh-part')).toBe('tab-bar')
|
||||
})
|
||||
})
|
||||
|
||||
describe('mountPanel L2 semantic attributes (#506)', () => {
|
||||
it('tags the injected panel container with data-dsh-plugin', async () => {
|
||||
const column = document.createElement('div')
|
||||
column.setAttribute('data-pane', 'conversation')
|
||||
document.body.appendChild(column)
|
||||
|
||||
await act(async () => { disposeMount = mountPanel(fakeController(), fakeApi()) })
|
||||
|
||||
const view = column.querySelector('[data-dsh-ssh-view]')
|
||||
expect(view).not.toBeNull()
|
||||
expect(view!.getAttribute('data-dsh-plugin')).toBe('ssh')
|
||||
})
|
||||
})
|
||||
@@ -35,6 +35,19 @@ function fakeApi(): SshApi {
|
||||
} as unknown as SshApi
|
||||
}
|
||||
|
||||
describe('TerminalTab L2 semantic attributes (#506)', () => {
|
||||
it('tags the terminal container as the terminal part', async () => {
|
||||
const container = document.createElement('div')
|
||||
document.body.appendChild(container)
|
||||
const root = createRoot(container)
|
||||
await act(async () => { root.render(<TerminalTab api={fakeApi()} />) })
|
||||
await act(async () => { await Promise.resolve() })
|
||||
const terminal = container.querySelector('[data-dsh-part="terminal"]')
|
||||
expect(terminal).not.toBeNull()
|
||||
await act(async () => { root.unmount() })
|
||||
})
|
||||
})
|
||||
|
||||
describe('TerminalTab dispose and resize cleanup', () => {
|
||||
it('registers a resize listener on mount and removes it on unmount', async () => {
|
||||
const addResize = vi.fn()
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* The ssh sidebar entry row opts into the L2 semantic attributes (issue
|
||||
* #506): the shared injection core receives the plugin id, so the row
|
||||
* carries data-dsh-plugin="ssh" and data-dsh-part="sidebar-entry".
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { mountSidebarEntry } from '../src/client/sidebar-entry.ts'
|
||||
|
||||
describe('mountSidebarEntry L2 semantic attributes (#506)', () => {
|
||||
beforeEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it('tags the entry row with data-dsh-plugin and data-dsh-part', () => {
|
||||
const entryEl = {
|
||||
dataset: {},
|
||||
setAttribute: vi.fn(),
|
||||
addEventListener: vi.fn(),
|
||||
remove: vi.fn(),
|
||||
}
|
||||
vi.stubGlobal('document', {
|
||||
querySelector: () => null,
|
||||
createElement: () => entryEl,
|
||||
body: {},
|
||||
documentElement: { lang: 'zh-CN' },
|
||||
})
|
||||
const controller = {
|
||||
subscribe: () => () => {},
|
||||
getSnapshot: () => ({ panelOpen: false }),
|
||||
} as never
|
||||
|
||||
class FakeMutationObserver {
|
||||
observe(): void {}
|
||||
disconnect(): void {}
|
||||
}
|
||||
vi.stubGlobal('MutationObserver', FakeMutationObserver)
|
||||
|
||||
const dispose = mountSidebarEntry(controller)
|
||||
|
||||
expect(entryEl.setAttribute).toHaveBeenCalledWith('data-dsh-ssh-entry', '')
|
||||
expect(entryEl.setAttribute).toHaveBeenCalledWith('data-dsh-plugin', 'ssh')
|
||||
expect(entryEl.setAttribute).toHaveBeenCalledWith('data-dsh-part', 'sidebar-entry')
|
||||
dispose()
|
||||
})
|
||||
})
|
||||
@@ -49,6 +49,7 @@ export function mountBoard(controller: BoardController): () => void {
|
||||
if (column === undefined) return
|
||||
container = document.createElement('div')
|
||||
container.dataset.dshTaskboardView = ''
|
||||
container.dataset.dshPlugin = 'task-board'
|
||||
container.className = css.boardView
|
||||
column.appendChild(container)
|
||||
root = createRoot(container)
|
||||
|
||||
@@ -50,7 +50,7 @@ export function TaskBoard({ controller }: { controller: BoardController }) {
|
||||
const openTask = useCallback((id: string): void => { controller.openTask(id) }, [controller])
|
||||
|
||||
return (
|
||||
<div className={css.board} data-dsh-taskboard-board="">
|
||||
<div className={css.board} data-dsh-taskboard-board="" data-dsh-plugin="task-board">
|
||||
<header className={css.boardHeader}>
|
||||
<button
|
||||
type="button"
|
||||
@@ -107,7 +107,7 @@ export function TaskBoard({ controller }: { controller: BoardController }) {
|
||||
|
||||
<div className={css.columns}>
|
||||
{archiveView ? (
|
||||
<section className={css.column} data-status="archived">
|
||||
<section className={css.column} data-status="archived" data-dsh-part="column">
|
||||
<header className={css.columnHeader}>
|
||||
<h3 className={css.columnTitle}>{t('board.archive')}</h3>
|
||||
<span className={css.columnCount}>{visible.length}</span>
|
||||
@@ -123,7 +123,7 @@ export function TaskBoard({ controller }: { controller: BoardController }) {
|
||||
COLUMNS.map(column => {
|
||||
const tasks = visible.filter(task => task.status === column.status)
|
||||
return (
|
||||
<section key={column.status} className={css.column} data-status={column.status}>
|
||||
<section key={column.status} className={css.column} data-status={column.status} data-dsh-part="column">
|
||||
<header className={css.columnHeader}>
|
||||
<span className={css.statusDot} data-status={column.status} aria-hidden="true" />
|
||||
<h3 className={css.columnTitle}>{t(STATUS_KEY[column.status])}</h3>
|
||||
|
||||
@@ -47,6 +47,7 @@ function TaskCardInner({ task, pending, timeZone, onClick }: { task: TaskRecord;
|
||||
type="button"
|
||||
className={css.card}
|
||||
data-status={archived ? 'archived' : task.status}
|
||||
data-dsh-part="card"
|
||||
data-pending={pending || undefined}
|
||||
onClick={onClick}
|
||||
title={task.description !== '' ? task.description : task.title}
|
||||
|
||||
@@ -21,6 +21,13 @@ export interface SidebarEntryOptions {
|
||||
rowAttribute: string
|
||||
/** CSS selector matching the injected row, e.g. '[data-dsh-ssh-entry]'. */
|
||||
rowSelector: string
|
||||
/**
|
||||
* L2 semantic-attribute plugin id (issue #506, enum table:
|
||||
* skins/skin-center/contracts/semantic-attrs-v1.md). When set, the row also
|
||||
* outputs data-dsh-plugin="<id>" and data-dsh-part="sidebar-entry"; unset
|
||||
* leaves the row without semantic attributes.
|
||||
*/
|
||||
plugin?: string
|
||||
/** Inline icon markup (matches the shell's 16px nav-icon look). */
|
||||
icon: string
|
||||
/** CSS module class names for the row and its two spans (entry / entryIcon / entryLabel). */
|
||||
@@ -73,6 +80,10 @@ function createEntry(options: SidebarEntryOptions): HTMLButtonElement {
|
||||
const entry = document.createElement('button')
|
||||
entry.type = 'button'
|
||||
entry.setAttribute(options.rowAttribute, '')
|
||||
if (options.plugin !== undefined) {
|
||||
entry.setAttribute('data-dsh-plugin', options.plugin)
|
||||
entry.setAttribute('data-dsh-part', 'sidebar-entry')
|
||||
}
|
||||
entry.className = options.css['entry'] ?? ''
|
||||
entry.setAttribute('aria-label', options.label())
|
||||
if (options.tooltip !== undefined) entry.setAttribute('title', options.tooltip())
|
||||
|
||||
@@ -32,6 +32,7 @@ export function mountSidebarEntry(controller: BoardController): () => void {
|
||||
return mountSharedSidebarEntry({
|
||||
rowAttribute: 'data-dsh-taskboard-entry',
|
||||
rowSelector: ENTRY_SELECTOR,
|
||||
plugin: 'task-board',
|
||||
icon: ICON,
|
||||
css,
|
||||
label: () => t('entry.label'),
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* L2 semantic attributes of the board view (issue #506): the mounted board
|
||||
* container, the board root, every status column, and every task card opt
|
||||
* into the semantic-attrs/v1 enum (data-dsh-plugin / data-dsh-part) so skins
|
||||
* can target them without hash-class selectors.
|
||||
*/
|
||||
import { act } from 'react'
|
||||
import { createRoot, type Root } from 'react-dom/client'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { mountBoard } from '../src/client/board-mount.tsx'
|
||||
import { TaskBoard } from '../src/client/board/TaskBoard.tsx'
|
||||
import type { BoardController, ControllerSnapshot } from '../src/core/controller.ts'
|
||||
import type { TaskRecord } from '../src/core/tasks.ts'
|
||||
|
||||
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
|
||||
|
||||
const roots: Root[] = []
|
||||
let disposeMount: (() => void) | undefined
|
||||
|
||||
afterEach(() => {
|
||||
disposeMount?.()
|
||||
disposeMount = undefined
|
||||
for (const root of roots.splice(0)) {
|
||||
act(() => { root.unmount() })
|
||||
}
|
||||
document.body.replaceChildren()
|
||||
document.documentElement.removeAttribute('data-dsh-taskboard-active')
|
||||
})
|
||||
|
||||
function task(overrides: Partial<TaskRecord> = {}): TaskRecord {
|
||||
return {
|
||||
id: 't1',
|
||||
title: 'Task A',
|
||||
description: '',
|
||||
prompt: 'do it',
|
||||
status: 'todo',
|
||||
createdAt: 0,
|
||||
updatedAt: Date.now(),
|
||||
executions: [],
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function fakeController(snapshot?: Partial<ControllerSnapshot>): BoardController {
|
||||
const state: ControllerSnapshot = {
|
||||
tasks: [task()],
|
||||
boardOpen: false,
|
||||
archiveView: false,
|
||||
selectedTaskId: undefined,
|
||||
executionOptions: { workspaces: [], presets: [] },
|
||||
pendingTaskIds: [],
|
||||
...snapshot,
|
||||
}
|
||||
return {
|
||||
getSnapshot: () => state,
|
||||
subscribe: () => () => {},
|
||||
closeBoard: () => {},
|
||||
toggleArchiveView: () => {},
|
||||
retryHostSync: async () => {},
|
||||
openTask: () => {},
|
||||
} as unknown as BoardController
|
||||
}
|
||||
|
||||
describe('TaskBoard L2 semantic attributes (#506)', () => {
|
||||
it('tags the board root, the status columns, and the task cards', async () => {
|
||||
const container = document.createElement('div')
|
||||
document.body.appendChild(container)
|
||||
const root = createRoot(container)
|
||||
roots.push(root)
|
||||
await act(async () => { root.render(<TaskBoard controller={fakeController()} />) })
|
||||
|
||||
const board = container.querySelector('[data-dsh-taskboard-board]')
|
||||
expect(board).not.toBeNull()
|
||||
expect(board!.getAttribute('data-dsh-plugin')).toBe('task-board')
|
||||
|
||||
const columns = container.querySelectorAll('section[data-status]')
|
||||
expect(columns.length).toBeGreaterThan(0)
|
||||
for (const column of columns) {
|
||||
expect(column.getAttribute('data-dsh-part')).toBe('column')
|
||||
}
|
||||
|
||||
const card = container.querySelector('[data-dsh-part="card"]')
|
||||
expect(card).not.toBeNull()
|
||||
expect(card!.textContent).toContain('Task A')
|
||||
})
|
||||
|
||||
it('tags the archive column as a column too', async () => {
|
||||
const container = document.createElement('div')
|
||||
document.body.appendChild(container)
|
||||
const root = createRoot(container)
|
||||
roots.push(root)
|
||||
const controller = fakeController({
|
||||
archiveView: true,
|
||||
tasks: [task({ archivedAt: Date.now(), status: 'done' })],
|
||||
})
|
||||
await act(async () => { root.render(<TaskBoard controller={controller} />) })
|
||||
|
||||
const archive = container.querySelector('section[data-status="archived"]')
|
||||
expect(archive).not.toBeNull()
|
||||
expect(archive!.getAttribute('data-dsh-part')).toBe('column')
|
||||
})
|
||||
})
|
||||
|
||||
describe('mountBoard L2 semantic attributes (#506)', () => {
|
||||
it('tags the injected board container with data-dsh-plugin', async () => {
|
||||
const column = document.createElement('div')
|
||||
column.setAttribute('data-pane', 'conversation')
|
||||
document.body.appendChild(column)
|
||||
|
||||
await act(async () => { disposeMount = mountBoard(fakeController()) })
|
||||
|
||||
const view = column.querySelector('[data-dsh-taskboard-view]')
|
||||
expect(view).not.toBeNull()
|
||||
expect(view!.getAttribute('data-dsh-plugin')).toBe('task-board')
|
||||
})
|
||||
})
|
||||
@@ -59,4 +59,36 @@ describe('mountSidebarEntry DOM idempotency', () => {
|
||||
dispose()
|
||||
expect(entryEl.remove).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('opts the entry row into the L2 semantic attributes (#506)', () => {
|
||||
const entryEl = {
|
||||
dataset: {},
|
||||
setAttribute: vi.fn(),
|
||||
addEventListener: vi.fn(),
|
||||
remove: vi.fn(),
|
||||
}
|
||||
vi.stubGlobal('document', {
|
||||
querySelector: () => null,
|
||||
createElement: () => entryEl,
|
||||
body: {},
|
||||
documentElement: { lang: 'zh-CN' },
|
||||
})
|
||||
const controller = {
|
||||
subscribe: () => () => {},
|
||||
getSnapshot: () => ({ boardOpen: false }),
|
||||
} as never
|
||||
|
||||
class FakeMutationObserver {
|
||||
observe(): void {}
|
||||
disconnect(): void {}
|
||||
}
|
||||
vi.stubGlobal('MutationObserver', FakeMutationObserver)
|
||||
|
||||
const dispose = mountSidebarEntry(controller)
|
||||
|
||||
expect(entryEl.setAttribute).toHaveBeenCalledWith('data-dsh-taskboard-entry', '')
|
||||
expect(entryEl.setAttribute).toHaveBeenCalledWith('data-dsh-plugin', 'task-board')
|
||||
expect(entryEl.setAttribute).toHaveBeenCalledWith('data-dsh-part', 'sidebar-entry')
|
||||
dispose()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -20,6 +20,13 @@ export interface SidebarEntryOptions {
|
||||
rowAttribute: string
|
||||
/** CSS selector matching the injected row, e.g. '[data-dsh-ssh-entry]'. */
|
||||
rowSelector: string
|
||||
/**
|
||||
* L2 semantic-attribute plugin id (issue #506, enum table:
|
||||
* skins/skin-center/contracts/semantic-attrs-v1.md). When set, the row also
|
||||
* outputs data-dsh-plugin="<id>" and data-dsh-part="sidebar-entry"; unset
|
||||
* leaves the row without semantic attributes.
|
||||
*/
|
||||
plugin?: string
|
||||
/** Inline icon markup (matches the shell's 16px nav-icon look). */
|
||||
icon: string
|
||||
/** CSS module class names for the row and its two spans (entry / entryIcon / entryLabel). */
|
||||
@@ -72,6 +79,10 @@ function createEntry(options: SidebarEntryOptions): HTMLButtonElement {
|
||||
const entry = document.createElement('button')
|
||||
entry.type = 'button'
|
||||
entry.setAttribute(options.rowAttribute, '')
|
||||
if (options.plugin !== undefined) {
|
||||
entry.setAttribute('data-dsh-plugin', options.plugin)
|
||||
entry.setAttribute('data-dsh-part', 'sidebar-entry')
|
||||
}
|
||||
entry.className = options.css['entry'] ?? ''
|
||||
entry.setAttribute('aria-label', options.label())
|
||||
if (options.tooltip !== undefined) entry.setAttribute('title', options.tooltip())
|
||||
|
||||
@@ -125,6 +125,23 @@ describe('shared sidebar-entry core', () => {
|
||||
expect(created[0]!.removed).toBe(true)
|
||||
})
|
||||
|
||||
it('outputs the L2 semantic attributes only when the plugin option is set (#506)', () => {
|
||||
installShell()
|
||||
const withPlugin: FakeElement[] = []
|
||||
stubDocument(false, withPlugin)
|
||||
const disposeWith = mountSidebarEntry(options({ plugin: 'task-board' }))
|
||||
expect(withPlugin[0]!.attrs['data-dsh-plugin']).toBe('task-board')
|
||||
expect(withPlugin[0]!.attrs['data-dsh-part']).toBe('sidebar-entry')
|
||||
disposeWith()
|
||||
|
||||
const withoutPlugin: FakeElement[] = []
|
||||
stubDocument(false, withoutPlugin)
|
||||
const disposeWithout = mountSidebarEntry(options())
|
||||
expect(withoutPlugin[0]!.attrs['data-dsh-plugin']).toBeUndefined()
|
||||
expect(withoutPlugin[0]!.attrs['data-dsh-part']).toBeUndefined()
|
||||
disposeWithout()
|
||||
})
|
||||
|
||||
it('skips mounting when an entry row already exists (idempotency)', () => {
|
||||
installShell()
|
||||
let created = 0
|
||||
|
||||
Reference in New Issue
Block a user