feat(plugins): opt into the L2 semantic attributes (#506)

This commit is contained in:
zhu1090093659
2026-08-18 18:12:04 +08:00
parent b0af70671d
commit d73bffc2a4
25 changed files with 574 additions and 6 deletions
@@ -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')
})
})
+1
View File
@@ -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())
+1
View File
@@ -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()
})
})
+11
View File
@@ -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())
+17
View File
@@ -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