mirror of
https://github.com/zhu1090093659/dsh-web.git
synced 2026-09-29 17:08:39 +08:00
fix(i18n): route the family sidebar rows through the SDK locale runtime
GUI verification showed the L1 sidebar rows of task-board, dsh-ssh and dsh-skill-explorer staying Chinese while their settings sections switched language: those rows read a package-local t/tt helper that picks zh/en from documentElement.lang, which the SDK Language switch never updates (the rows also stayed Chinese under English — proven by a live switch test). The module-level helpers now prefer a wired SDK translate seat (ctx.locale.bind(NS), set in apply once the dictionaries register) and fall back to the document-language pick only when unwired; the shared sidebar-entry core gains an optional refresh subscription (ctx.locale .subscribe) that re-applies label/aria-label/tooltip on locale changes, and the board/panel mounts re-render an open view on the same signal. Verified live: switching to Русский flips the rows to Доска задач / Центр навыков without a reload.
This commit is contained in:
@@ -18,6 +18,7 @@ import type {} from '@deepseek-ai/dsh-client-ui-renderer/client'
|
||||
// Type-only: pulls the LocaleNamespaceMap merge table.
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import { SkillApi } from './api.ts'
|
||||
import { setRuntimeTranslate } from './panel-helpers.ts'
|
||||
import { en, zh, type SkillExplorerKey } from './locales.ts'
|
||||
import { mountPanel } from './panel-mount.tsx'
|
||||
import { mountSidebarEntry } from './sidebar-entry.ts'
|
||||
@@ -58,11 +59,16 @@ export function apply(ctx: ClientContext): void {
|
||||
}
|
||||
}, 'skill-explorer: dictionaries')
|
||||
|
||||
// Wire the SDK translate seat into the module-level tt (sidebar row and
|
||||
// other plain-DOM callers): reads the active locale at call time, so they
|
||||
// follow the Language setting without a reload.
|
||||
try { setRuntimeTranslate(ctx.locale.bind(NS)) } catch { /* locale missing: document-language fallback stays */ }
|
||||
|
||||
const api = new SkillApi()
|
||||
const panel = mountPanel(api)
|
||||
const panel = mountPanel(api, ctx.locale)
|
||||
const disposers: Array<() => void> = []
|
||||
try {
|
||||
disposers.push(mountSidebarEntry(() => panel.toggle()))
|
||||
disposers.push(mountSidebarEntry(() => panel.toggle(), ctx.locale))
|
||||
disposers.push(() => panel.dispose())
|
||||
} catch (error) {
|
||||
// DOM failures degrade the panel, never the GUI.
|
||||
|
||||
@@ -13,8 +13,22 @@ export function dictionary(): Record<string, string> {
|
||||
return lang.toLowerCase().startsWith('en') ? { ...en } : { ...zh }
|
||||
}
|
||||
|
||||
/**
|
||||
* SDK translate seat wired by the browser apply() once ctx.locale is bound
|
||||
* (setRuntimeTranslate): reads the ACTIVE locale at call time, so plain-DOM
|
||||
* surfaces follow a runtime language switch. The document-language pick stays
|
||||
* as the unwired fallback (locale service absent, module-scope early callers).
|
||||
*/
|
||||
let runtimeT: ((key: SkillExplorerKey, values?: TranslateValues) => string) | undefined
|
||||
|
||||
/** Wire the SDK translate seat; pass undefined to restore the document-language pick. */
|
||||
export function setRuntimeTranslate(t: ((key: SkillExplorerKey, values?: TranslateValues) => string) | undefined): void {
|
||||
runtimeT = t
|
||||
}
|
||||
|
||||
/** Translate a key with optional {name} template params (current language). */
|
||||
export function tt(key: SkillExplorerKey, values?: TranslateValues): string {
|
||||
if (runtimeT !== undefined) return runtimeT(key, values)
|
||||
let text: string = dictionary()[key] ?? key
|
||||
if (values !== undefined) {
|
||||
for (const [name, value] of Object.entries(values)) {
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
import { createRoot, type Root } from 'react-dom/client'
|
||||
import type { SkillApi } from './api.ts'
|
||||
import { SkillPanel } from './SkillPanel.tsx'
|
||||
import type { LocaleRefreshSource } from './sidebar-entry.ts'
|
||||
|
||||
/** Mounted panel controller: toggle/open/close plus the disposer. */
|
||||
export interface SkillPanelMount {
|
||||
@@ -21,11 +22,14 @@ export interface SkillPanelMount {
|
||||
/**
|
||||
* Mount the skill center overlay panel.
|
||||
* @param api - the skill center API client.
|
||||
* @param locale - locale-change source; when given, re-renders an open panel
|
||||
* on a Language switch.
|
||||
* @returns controller (toggle/open/close) and the disposer.
|
||||
*/
|
||||
export function mountPanel(api: SkillApi): SkillPanelMount {
|
||||
export function mountPanel(api: SkillApi, locale?: LocaleRefreshSource): SkillPanelMount {
|
||||
let root: Root | undefined
|
||||
let container: HTMLDivElement | undefined
|
||||
let unsubscribeLocale: (() => void) | undefined
|
||||
|
||||
const close = (): void => {
|
||||
if (root === undefined) return
|
||||
@@ -34,6 +38,11 @@ export function mountPanel(api: SkillApi): SkillPanelMount {
|
||||
container?.remove()
|
||||
container = undefined
|
||||
}
|
||||
try {
|
||||
unsubscribeLocale = locale?.subscribe(() => {
|
||||
if (root !== undefined) root.render(<SkillPanel api={api} onClose={close} />)
|
||||
})
|
||||
} catch { /* locale service absent: the panel follows its next natural re-render */ }
|
||||
|
||||
const open = (): void => {
|
||||
if (root !== undefined) return
|
||||
@@ -52,5 +61,5 @@ export function mountPanel(api: SkillApi): SkillPanelMount {
|
||||
else open()
|
||||
}
|
||||
|
||||
return { toggle, open, close, dispose: close }
|
||||
return { toggle, open, close, dispose: () => { close(); unsubscribeLocale?.() } }
|
||||
}
|
||||
|
||||
@@ -36,6 +36,14 @@ export interface SidebarEntryOptions {
|
||||
label(): string
|
||||
/** Optional localized tooltip (title attribute). */
|
||||
tooltip?(): string
|
||||
/**
|
||||
* Optional locale-change subscription: re-applies label / aria-label /
|
||||
* tooltip whenever the active locale changes. Plain-DOM rows would
|
||||
* otherwise keep the label captured at mount; pass the SDK locale runtime
|
||||
* subscription (ctx.locale.subscribe) so the row follows the language
|
||||
* switch without a reload.
|
||||
*/
|
||||
refresh?: { subscribe(listener: () => void): () => void }
|
||||
/** Click action (open/toggle the owning panel). */
|
||||
onToggle(): void
|
||||
/** Family-block position: 'before' inserts ahead of sibling plugin rows, 'after' behind them. */
|
||||
@@ -76,7 +84,7 @@ function newSessionButton(root: HTMLElement): HTMLButtonElement | undefined {
|
||||
}
|
||||
|
||||
/** Build the entry row (a detached button; insert once the shell is up). */
|
||||
function createEntry(options: SidebarEntryOptions): HTMLButtonElement {
|
||||
function createEntry(options: SidebarEntryOptions): { entry: HTMLButtonElement; applyLabel: () => void } {
|
||||
const entry = document.createElement('button')
|
||||
entry.type = 'button'
|
||||
entry.setAttribute(options.rowAttribute, '')
|
||||
@@ -85,12 +93,20 @@ function createEntry(options: SidebarEntryOptions): HTMLButtonElement {
|
||||
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())
|
||||
entry.innerHTML = '<span class="' + (options.css['entryIcon'] ?? '') + '">' + options.icon
|
||||
+ '</span><span class="' + (options.css['entryLabel'] ?? '') + '">' + options.label() + '</span>'
|
||||
const labelSpan = document.createElement('span')
|
||||
labelSpan.className = options.css['entryLabel'] ?? ''
|
||||
const iconSpan = document.createElement('span')
|
||||
iconSpan.className = options.css['entryIcon'] ?? ''
|
||||
iconSpan.innerHTML = options.icon
|
||||
entry.append(iconSpan, labelSpan)
|
||||
const applyLabel = (): void => {
|
||||
entry.setAttribute('aria-label', options.label())
|
||||
if (options.tooltip !== undefined) entry.setAttribute('title', options.tooltip())
|
||||
labelSpan.textContent = options.label()
|
||||
}
|
||||
applyLabel()
|
||||
entry.addEventListener('click', options.onToggle)
|
||||
return entry
|
||||
return { entry, applyLabel }
|
||||
}
|
||||
|
||||
/** Re-insert the entry after the New Session row (before the browser region). */
|
||||
@@ -132,9 +148,18 @@ export function mountSidebarEntry(options: SidebarEntryOptions): () => void {
|
||||
if (typeof document !== 'undefined' && document.querySelector(options.rowSelector) !== null) {
|
||||
return () => {}
|
||||
}
|
||||
const entry = createEntry(options)
|
||||
const { entry, applyLabel } = createEntry(options)
|
||||
let root: HTMLElement | undefined
|
||||
let placed = false
|
||||
let unsubscribeRefresh: (() => void) | undefined
|
||||
if (options.refresh !== undefined) {
|
||||
try {
|
||||
unsubscribeRefresh = options.refresh.subscribe(applyLabel)
|
||||
} catch {
|
||||
// A throwing subscription must not break the mount; the label stays at
|
||||
// its initial value and the next reload resolves it again.
|
||||
}
|
||||
}
|
||||
|
||||
const tryPlace = (): void => {
|
||||
if (root !== undefined && !root.isConnected) {
|
||||
@@ -202,6 +227,7 @@ export function mountSidebarEntry(options: SidebarEntryOptions): () => void {
|
||||
return () => {
|
||||
waitObserver.disconnect()
|
||||
rootObserver.disconnect()
|
||||
unsubscribeRefresh?.()
|
||||
unsubscribeActive?.()
|
||||
entry.remove()
|
||||
}
|
||||
|
||||
@@ -20,13 +20,18 @@ export const ENTRY_SELECTOR = '[data-dsh-skill-explorer-entry]'
|
||||
/** Inline book icon normalized to the shell's 18px navigation glyph size. */
|
||||
const ICON = '<svg viewBox="0 0 16 16" width="18" height="18" fill="none" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M8 3.2C6.6 2 4.5 2 3 2v10.5c1.5 0 3.6 0 5 1.3 1.4-1.3 3.5-1.3 5-1.3V2c-1.5 0-3.6 0-5 1.2z"/><path d="M8 3.2v10.6"/></svg>'
|
||||
|
||||
/** Locale-change subscription the shared core asks for (ctx.locale.subscribe shape). */
|
||||
export interface LocaleRefreshSource { subscribe(listener: () => void): () => void }
|
||||
|
||||
/**
|
||||
* Mount the sidebar entry, waiting for the shell to render and self-healing
|
||||
* on later React re-renders.
|
||||
* @param onClick - opens the skill center overlay.
|
||||
* @param locale - locale-change source; when given, re-applies the label on
|
||||
* a Language switch (the plain-DOM row otherwise keeps the mount-time copy).
|
||||
* @returns disposer removing the entry and its observers.
|
||||
*/
|
||||
export function mountSidebarEntry(onClick: () => void): () => void {
|
||||
export function mountSidebarEntry(onClick: () => void, locale?: LocaleRefreshSource): () => void {
|
||||
return mountSharedSidebarEntry({
|
||||
rowAttribute: 'data-dsh-skill-explorer-entry',
|
||||
rowSelector: ENTRY_SELECTOR,
|
||||
@@ -37,6 +42,7 @@ export function mountSidebarEntry(onClick: () => void): () => void {
|
||||
css,
|
||||
label: () => tt('entry.label'),
|
||||
tooltip: () => tt('entry.tooltip'),
|
||||
refresh: locale === undefined ? undefined : { subscribe: (listener) => locale.subscribe(listener) },
|
||||
onToggle: onClick,
|
||||
position: 'after',
|
||||
familySelectors: ['[data-dsh-taskboard-entry]', '[data-dsh-ssh-entry]', '[data-dsh-skill-explorer-entry]'],
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { setRuntimeTranslate, tt } from '../src/client/panel-helpers.ts'
|
||||
|
||||
describe('skill-explorer localized tt runtime wiring', () => {
|
||||
afterEach(() => { setRuntimeTranslate(undefined) })
|
||||
|
||||
it('prefers the wired SDK translate seat (reads the active locale at call time)', () => {
|
||||
setRuntimeTranslate((key) => `RU(${key})`)
|
||||
expect(tt('entry.label')).toBe('RU(entry.label)')
|
||||
})
|
||||
|
||||
it('falls back to the document-language dictionary when unwired', () => {
|
||||
const label = tt('entry.label')
|
||||
expect(label.startsWith('RU(')).toBe(false)
|
||||
expect(label.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('passes template params through to the wired seat', () => {
|
||||
setRuntimeTranslate((key, values) => `${key}:${String(values?.count)}`)
|
||||
expect(tt('list.count', { count: 3 })).toBe('list.count:3')
|
||||
})
|
||||
})
|
||||
@@ -25,6 +25,7 @@ import { en, zh, type SshKey } from './locales.ts'
|
||||
import { mountPanel } from './mount.tsx'
|
||||
import { PanelController } from './panel/controller.ts'
|
||||
import type { TerminalFontSource } from './panel/helpers.ts'
|
||||
import { setRuntimeTranslate } from './panel/helpers.ts'
|
||||
import { mountSidebarEntry } from './sidebar-entry.ts'
|
||||
import { reportDailyHeartbeat } from './telemetry.ts'
|
||||
|
||||
@@ -89,6 +90,11 @@ export function apply(ctx: ClientContext): void {
|
||||
}
|
||||
}, 'dsh-ssh: dictionaries')
|
||||
|
||||
// Wire the SDK translate seat into the module-level tt (sidebar row and
|
||||
// other plain-DOM callers): reads the active locale at call time, so they
|
||||
// follow the Language setting without a reload.
|
||||
try { setRuntimeTranslate(ctx.locale.bind(NS)) } catch { /* locale missing: document-language fallback stays */ }
|
||||
|
||||
const controller = new PanelController()
|
||||
const api = new SshApi()
|
||||
// Live terminal-font preference (issue #577): the settings namespace is
|
||||
@@ -105,8 +111,8 @@ export function apply(ctx: ClientContext): void {
|
||||
}
|
||||
const disposers: Array<() => void> = []
|
||||
try {
|
||||
disposers.push(mountSidebarEntry(controller))
|
||||
disposers.push(mountPanel(controller, api, terminalFont))
|
||||
disposers.push(mountSidebarEntry(controller, ctx.locale))
|
||||
disposers.push(mountPanel(controller, api, terminalFont, ctx.locale))
|
||||
} catch (error) {
|
||||
// DOM failures degrade the panel, never the GUI.
|
||||
console.warn('[dsh-ssh] mount failed:', error)
|
||||
|
||||
@@ -16,6 +16,7 @@ import type { SshApi } from './api.ts'
|
||||
import type { PanelController } from './panel/controller.ts'
|
||||
import type { TerminalFontSource } from './panel/helpers.ts'
|
||||
import { SshPanel } from './panel/SshPanel.tsx'
|
||||
import type { LocaleRefreshSource } from './sidebar-entry.ts'
|
||||
import css from './panel/panel.module.css'
|
||||
|
||||
/** The injected panel container (kept in the DOM, hidden when inactive). */
|
||||
@@ -40,11 +41,19 @@ function conversationColumn(): HTMLElement | undefined {
|
||||
* @param controller - the panel controller driving the view.
|
||||
* @param api - the SSH API client the tabs operate through.
|
||||
* @param terminalFont - live terminal-font setting source (issue #577).
|
||||
* @param locale - locale-change source; when given, re-renders an open panel
|
||||
* on a Language switch.
|
||||
* @returns disposer unmounting the tree and restoring the column.
|
||||
*/
|
||||
export function mountPanel(controller: PanelController, api: SshApi, terminalFont?: TerminalFontSource): () => void {
|
||||
export function mountPanel(controller: PanelController, api: SshApi, terminalFont?: TerminalFontSource, locale?: LocaleRefreshSource): () => void {
|
||||
let root: Root | undefined
|
||||
let container: HTMLDivElement | undefined
|
||||
let unsubscribeLocale: (() => void) | undefined
|
||||
try {
|
||||
unsubscribeLocale = locale?.subscribe(() => {
|
||||
if (root !== undefined) root.render(<SshPanel controller={controller} api={api} terminalFont={terminalFont} />)
|
||||
})
|
||||
} catch { /* locale service absent: the panel follows its next natural re-render */ }
|
||||
|
||||
const ensure = (): void => {
|
||||
if (container !== undefined) {
|
||||
@@ -110,6 +119,7 @@ export function mountPanel(controller: PanelController, api: SshApi, terminalFon
|
||||
document.removeEventListener(ACTIVATE_EVENT, onOtherActivate)
|
||||
waitObserver.disconnect()
|
||||
unsubscribe()
|
||||
unsubscribeLocale?.()
|
||||
document.documentElement.removeAttribute(ACTIVE_ATTR)
|
||||
root?.unmount()
|
||||
root = undefined
|
||||
|
||||
@@ -14,8 +14,22 @@ export function dictionary(): Record<string, string> {
|
||||
return lang.toLowerCase().startsWith('en') ? { ...en } : { ...zh }
|
||||
}
|
||||
|
||||
/**
|
||||
* SDK translate seat wired by the browser apply() once ctx.locale is bound
|
||||
* (setRuntimeTranslate): reads the ACTIVE locale at call time, so plain-DOM
|
||||
* surfaces follow a runtime language switch. The document-language pick stays
|
||||
* as the unwired fallback (locale service absent, module-scope early callers).
|
||||
*/
|
||||
let runtimeT: ((key: SshKey, values?: TranslateValues) => string) | undefined
|
||||
|
||||
/** Wire the SDK translate seat; pass undefined to restore the document-language pick. */
|
||||
export function setRuntimeTranslate(t: ((key: SshKey, values?: TranslateValues) => string) | undefined): void {
|
||||
runtimeT = t
|
||||
}
|
||||
|
||||
/** Translate a key with optional {name} template params (current language). */
|
||||
export function tt(key: SshKey, values?: TranslateValues): string {
|
||||
if (runtimeT !== undefined) return runtimeT(key, values)
|
||||
return t(dictionary(), key, values)
|
||||
}
|
||||
|
||||
|
||||
@@ -36,6 +36,14 @@ export interface SidebarEntryOptions {
|
||||
label(): string
|
||||
/** Optional localized tooltip (title attribute). */
|
||||
tooltip?(): string
|
||||
/**
|
||||
* Optional locale-change subscription: re-applies label / aria-label /
|
||||
* tooltip whenever the active locale changes. Plain-DOM rows would
|
||||
* otherwise keep the label captured at mount; pass the SDK locale runtime
|
||||
* subscription (ctx.locale.subscribe) so the row follows the language
|
||||
* switch without a reload.
|
||||
*/
|
||||
refresh?: { subscribe(listener: () => void): () => void }
|
||||
/** Click action (open/toggle the owning panel). */
|
||||
onToggle(): void
|
||||
/** Family-block position: 'before' inserts ahead of sibling plugin rows, 'after' behind them. */
|
||||
@@ -76,7 +84,7 @@ function newSessionButton(root: HTMLElement): HTMLButtonElement | undefined {
|
||||
}
|
||||
|
||||
/** Build the entry row (a detached button; insert once the shell is up). */
|
||||
function createEntry(options: SidebarEntryOptions): HTMLButtonElement {
|
||||
function createEntry(options: SidebarEntryOptions): { entry: HTMLButtonElement; applyLabel: () => void } {
|
||||
const entry = document.createElement('button')
|
||||
entry.type = 'button'
|
||||
entry.setAttribute(options.rowAttribute, '')
|
||||
@@ -85,12 +93,20 @@ function createEntry(options: SidebarEntryOptions): HTMLButtonElement {
|
||||
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())
|
||||
entry.innerHTML = '<span class="' + (options.css['entryIcon'] ?? '') + '">' + options.icon
|
||||
+ '</span><span class="' + (options.css['entryLabel'] ?? '') + '">' + options.label() + '</span>'
|
||||
const labelSpan = document.createElement('span')
|
||||
labelSpan.className = options.css['entryLabel'] ?? ''
|
||||
const iconSpan = document.createElement('span')
|
||||
iconSpan.className = options.css['entryIcon'] ?? ''
|
||||
iconSpan.innerHTML = options.icon
|
||||
entry.append(iconSpan, labelSpan)
|
||||
const applyLabel = (): void => {
|
||||
entry.setAttribute('aria-label', options.label())
|
||||
if (options.tooltip !== undefined) entry.setAttribute('title', options.tooltip())
|
||||
labelSpan.textContent = options.label()
|
||||
}
|
||||
applyLabel()
|
||||
entry.addEventListener('click', options.onToggle)
|
||||
return entry
|
||||
return { entry, applyLabel }
|
||||
}
|
||||
|
||||
/** Re-insert the entry after the New Session row (before the browser region). */
|
||||
@@ -132,9 +148,18 @@ export function mountSidebarEntry(options: SidebarEntryOptions): () => void {
|
||||
if (typeof document !== 'undefined' && document.querySelector(options.rowSelector) !== null) {
|
||||
return () => {}
|
||||
}
|
||||
const entry = createEntry(options)
|
||||
const { entry, applyLabel } = createEntry(options)
|
||||
let root: HTMLElement | undefined
|
||||
let placed = false
|
||||
let unsubscribeRefresh: (() => void) | undefined
|
||||
if (options.refresh !== undefined) {
|
||||
try {
|
||||
unsubscribeRefresh = options.refresh.subscribe(applyLabel)
|
||||
} catch {
|
||||
// A throwing subscription must not break the mount; the label stays at
|
||||
// its initial value and the next reload resolves it again.
|
||||
}
|
||||
}
|
||||
|
||||
const tryPlace = (): void => {
|
||||
if (root !== undefined && !root.isConnected) {
|
||||
@@ -202,6 +227,7 @@ export function mountSidebarEntry(options: SidebarEntryOptions): () => void {
|
||||
return () => {
|
||||
waitObserver.disconnect()
|
||||
rootObserver.disconnect()
|
||||
unsubscribeRefresh?.()
|
||||
unsubscribeActive?.()
|
||||
entry.remove()
|
||||
}
|
||||
|
||||
@@ -19,13 +19,18 @@ export const ENTRY_SELECTOR = '[data-dsh-ssh-entry]'
|
||||
/** Inline terminal glyph sized to the shell's current sidebar navigation icons. */
|
||||
const ICON = '<svg viewBox="0 0 16 16" width="18" height="18" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="1.75" y="2.25" width="12.5" height="11.5" rx="1.75"/><path d="M4.25 5.25l2.75 2.75-2.75 2.75"/><path d="M8.5 10.75h3.25"/></svg>'
|
||||
|
||||
/** Locale-change subscription the shared core asks for (ctx.locale.subscribe shape). */
|
||||
export interface LocaleRefreshSource { subscribe(listener: () => void): () => void }
|
||||
|
||||
/**
|
||||
* Mount the sidebar entry, waiting for the shell to render and self-healing
|
||||
* on later React re-renders.
|
||||
* @param controller - the panel controller the entry toggles.
|
||||
* @param locale - locale-change source; when given, re-applies the label on
|
||||
* a Language switch (the plain-DOM row otherwise keeps the mount-time copy).
|
||||
* @returns disposer removing the entry and its observers.
|
||||
*/
|
||||
export function mountSidebarEntry(controller: PanelController): () => void {
|
||||
export function mountSidebarEntry(controller: PanelController, locale?: LocaleRefreshSource): () => void {
|
||||
return mountSharedSidebarEntry({
|
||||
rowAttribute: 'data-dsh-ssh-entry',
|
||||
rowSelector: ENTRY_SELECTOR,
|
||||
@@ -34,6 +39,7 @@ export function mountSidebarEntry(controller: PanelController): () => void {
|
||||
css,
|
||||
label: () => tt('entry.label'),
|
||||
tooltip: () => tt('entry.tooltip'),
|
||||
refresh: locale === undefined ? undefined : { subscribe: (listener) => locale.subscribe(listener) },
|
||||
onToggle: () => { controller.toggle() },
|
||||
position: 'after',
|
||||
familySelectors: ['[data-dsh-taskboard-entry]', '[data-dsh-ssh-entry]'],
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { setRuntimeTranslate, tt } from '../src/client/panel/helpers.ts'
|
||||
|
||||
describe('dsh-ssh localized tt runtime wiring', () => {
|
||||
afterEach(() => { setRuntimeTranslate(undefined) })
|
||||
|
||||
it('prefers the wired SDK translate seat (reads the active locale at call time)', () => {
|
||||
setRuntimeTranslate((key) => `RU(${key})`)
|
||||
expect(tt('entry.label')).toBe('RU(entry.label)')
|
||||
})
|
||||
|
||||
it('falls back to the document-language dictionary when unwired', () => {
|
||||
const label = tt('entry.label')
|
||||
expect(label.startsWith('RU(')).toBe(false)
|
||||
expect(label.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('passes template params through to the wired seat', () => {
|
||||
setRuntimeTranslate((key, values) => `${key}:${String(values?.count)}`)
|
||||
expect(tt('hosts.group.count', { count: 2 })).toBe('hosts.group.count:2')
|
||||
})
|
||||
})
|
||||
@@ -17,6 +17,7 @@ describe('mountSidebarEntry L2 semantic attributes (#506)', () => {
|
||||
setAttribute: vi.fn(),
|
||||
addEventListener: vi.fn(),
|
||||
remove: vi.fn(),
|
||||
append: vi.fn(),
|
||||
}
|
||||
vi.stubGlobal('document', {
|
||||
querySelector: () => null,
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
import { createRoot, type Root } from 'react-dom/client'
|
||||
import type { BoardController } from '../core/controller.ts'
|
||||
import { TaskBoard } from './board/TaskBoard.tsx'
|
||||
import type { LocaleRefreshSource } from './sidebar-entry.ts'
|
||||
import css from './board.module.css'
|
||||
|
||||
/** The injected board container (kept in the DOM, hidden when inactive). */
|
||||
@@ -37,11 +38,19 @@ function conversationColumn(): HTMLElement | undefined {
|
||||
* Mount the board React tree into the center column and bind its visibility
|
||||
* to the controller's boardOpen state.
|
||||
* @param controller - the board controller driving the view.
|
||||
* @param locale - locale-change source; when given, re-renders a mounted board
|
||||
* on a Language switch.
|
||||
* @returns disposer unmounting the tree and restoring the column.
|
||||
*/
|
||||
export function mountBoard(controller: BoardController): () => void {
|
||||
export function mountBoard(controller: BoardController, locale?: LocaleRefreshSource): () => void {
|
||||
let root: Root | undefined
|
||||
let container: HTMLDivElement | undefined
|
||||
let unsubscribeLocale: (() => void) | undefined
|
||||
try {
|
||||
unsubscribeLocale = locale?.subscribe(() => {
|
||||
if (root !== undefined) root.render(<TaskBoard controller={controller} />)
|
||||
})
|
||||
} catch { /* locale service absent: the board follows its next natural re-render */ }
|
||||
|
||||
const ensure = (): void => {
|
||||
if (container !== undefined) {
|
||||
@@ -106,6 +115,7 @@ export function mountBoard(controller: BoardController): () => void {
|
||||
document.removeEventListener(ACTIVATE_EVENT, onOtherActivate)
|
||||
waitObserver.disconnect()
|
||||
unsubscribe()
|
||||
unsubscribeLocale?.()
|
||||
document.documentElement.removeAttribute(ACTIVE_ATTR)
|
||||
root?.unmount()
|
||||
root = undefined
|
||||
|
||||
@@ -27,7 +27,7 @@ import { claimTaskboardApply, releaseTaskboardApply } from './apply-guard.ts'
|
||||
import { mountBoard } from './board-mount.tsx'
|
||||
import { mountSidebarEntry } from './sidebar-entry.ts'
|
||||
import { TaskBoardSettingsCard, TaskBoardSettingsCardController, type TaskBoardSettings } from './TaskBoardSettingsCard.tsx'
|
||||
import { en, zh, type TaskBoardKey } from './locales.ts'
|
||||
import { en, zh, setRuntimeTranslate, type TaskBoardKey } from './locales.ts'
|
||||
import { HttpTaskBoardHostTransport } from './host-api.ts'
|
||||
import { reportDailyHeartbeat } from './telemetry.ts'
|
||||
|
||||
@@ -157,6 +157,12 @@ export function apply(ctx: ClientContext): void {
|
||||
}
|
||||
}, 'task-board: dictionaries')
|
||||
|
||||
// Wire the SDK translate seat into the module-level t (sidebar row and
|
||||
// other plain-DOM callers): reads the active locale at call time, so they
|
||||
// follow the Language setting without a reload. The register effect above
|
||||
// guarantees the dictionaries exist before the first read.
|
||||
try { setRuntimeTranslate(ctx.locale.bind(NS)) } catch { /* locale missing: document-language fallback stays */ }
|
||||
|
||||
// Plugin configuration card: one staged form over the `task-board` settings
|
||||
// namespace, contributed to the Web UI plugin group.
|
||||
const binder = ctx.get('webUiSettings') ?? ctx.settingsScope
|
||||
@@ -246,8 +252,8 @@ export function apply(ctx: ClientContext): void {
|
||||
void pushPresetOptions()
|
||||
disposers.push(ctx.on('connection/reset', () => { void pushPresetOptions() }))
|
||||
try {
|
||||
disposers.push(mountSidebarEntry(controller))
|
||||
disposers.push(mountBoard(controller))
|
||||
disposers.push(mountSidebarEntry(controller, ctx.locale))
|
||||
disposers.push(mountBoard(controller, ctx.locale))
|
||||
} catch (error) {
|
||||
// DOM failures degrade the board, never the GUI.
|
||||
console.error('[dsh-task-board] mount failed:', error)
|
||||
|
||||
@@ -307,8 +307,23 @@ export function dictionary(): Record<TaskBoardKey, string> {
|
||||
return lang.toLowerCase().startsWith('en') ? en : zh
|
||||
}
|
||||
|
||||
/**
|
||||
* SDK translate seat wired by the browser apply() once ctx.locale is bound
|
||||
* (setRuntimeTranslate). When present it reads the ACTIVE locale at call
|
||||
* time, so plain-DOM surfaces (sidebar row, toggles) follow a runtime
|
||||
* language switch; the document-language pick above stays only as the
|
||||
* unwired fallback (locale service absent, module-scope early callers).
|
||||
*/
|
||||
let runtimeT: ((key: TaskBoardKey, params?: Record<string, string>) => string) | undefined
|
||||
|
||||
/** Wire the SDK translate seat; pass undefined to restore the document-language pick. */
|
||||
export function setRuntimeTranslate(t: ((key: TaskBoardKey, params?: Record<string, string>) => string) | undefined): void {
|
||||
runtimeT = t
|
||||
}
|
||||
|
||||
/** Translate a key with optional {name} template params. */
|
||||
export function t(key: TaskBoardKey, params?: Record<string, string>): string {
|
||||
if (runtimeT !== undefined) return runtimeT(key, params)
|
||||
let text: string = dictionary()[key]
|
||||
if (params !== undefined) {
|
||||
for (const [name, value] of Object.entries(params)) {
|
||||
|
||||
@@ -36,6 +36,14 @@ export interface SidebarEntryOptions {
|
||||
label(): string
|
||||
/** Optional localized tooltip (title attribute). */
|
||||
tooltip?(): string
|
||||
/**
|
||||
* Optional locale-change subscription: re-applies label / aria-label /
|
||||
* tooltip whenever the active locale changes. Plain-DOM rows would
|
||||
* otherwise keep the label captured at mount; pass the SDK locale runtime
|
||||
* subscription (ctx.locale.subscribe) so the row follows the language
|
||||
* switch without a reload.
|
||||
*/
|
||||
refresh?: { subscribe(listener: () => void): () => void }
|
||||
/** Click action (open/toggle the owning panel). */
|
||||
onToggle(): void
|
||||
/** Family-block position: 'before' inserts ahead of sibling plugin rows, 'after' behind them. */
|
||||
@@ -76,7 +84,7 @@ function newSessionButton(root: HTMLElement): HTMLButtonElement | undefined {
|
||||
}
|
||||
|
||||
/** Build the entry row (a detached button; insert once the shell is up). */
|
||||
function createEntry(options: SidebarEntryOptions): HTMLButtonElement {
|
||||
function createEntry(options: SidebarEntryOptions): { entry: HTMLButtonElement; applyLabel: () => void } {
|
||||
const entry = document.createElement('button')
|
||||
entry.type = 'button'
|
||||
entry.setAttribute(options.rowAttribute, '')
|
||||
@@ -85,12 +93,20 @@ function createEntry(options: SidebarEntryOptions): HTMLButtonElement {
|
||||
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())
|
||||
entry.innerHTML = '<span class="' + (options.css['entryIcon'] ?? '') + '">' + options.icon
|
||||
+ '</span><span class="' + (options.css['entryLabel'] ?? '') + '">' + options.label() + '</span>'
|
||||
const labelSpan = document.createElement('span')
|
||||
labelSpan.className = options.css['entryLabel'] ?? ''
|
||||
const iconSpan = document.createElement('span')
|
||||
iconSpan.className = options.css['entryIcon'] ?? ''
|
||||
iconSpan.innerHTML = options.icon
|
||||
entry.append(iconSpan, labelSpan)
|
||||
const applyLabel = (): void => {
|
||||
entry.setAttribute('aria-label', options.label())
|
||||
if (options.tooltip !== undefined) entry.setAttribute('title', options.tooltip())
|
||||
labelSpan.textContent = options.label()
|
||||
}
|
||||
applyLabel()
|
||||
entry.addEventListener('click', options.onToggle)
|
||||
return entry
|
||||
return { entry, applyLabel }
|
||||
}
|
||||
|
||||
/** Re-insert the entry after the New Session row (before the browser region). */
|
||||
@@ -132,9 +148,18 @@ export function mountSidebarEntry(options: SidebarEntryOptions): () => void {
|
||||
if (typeof document !== 'undefined' && document.querySelector(options.rowSelector) !== null) {
|
||||
return () => {}
|
||||
}
|
||||
const entry = createEntry(options)
|
||||
const { entry, applyLabel } = createEntry(options)
|
||||
let root: HTMLElement | undefined
|
||||
let placed = false
|
||||
let unsubscribeRefresh: (() => void) | undefined
|
||||
if (options.refresh !== undefined) {
|
||||
try {
|
||||
unsubscribeRefresh = options.refresh.subscribe(applyLabel)
|
||||
} catch {
|
||||
// A throwing subscription must not break the mount; the label stays at
|
||||
// its initial value and the next reload resolves it again.
|
||||
}
|
||||
}
|
||||
|
||||
const tryPlace = (): void => {
|
||||
if (root !== undefined && !root.isConnected) {
|
||||
@@ -202,6 +227,7 @@ export function mountSidebarEntry(options: SidebarEntryOptions): () => void {
|
||||
return () => {
|
||||
waitObserver.disconnect()
|
||||
rootObserver.disconnect()
|
||||
unsubscribeRefresh?.()
|
||||
unsubscribeActive?.()
|
||||
entry.remove()
|
||||
}
|
||||
|
||||
@@ -22,13 +22,18 @@ export const ENTRY_SELECTOR = '[data-dsh-taskboard-entry]'
|
||||
/** Inline icon normalized to the shell's 18px navigation glyph size. */
|
||||
const ICON = '<svg viewBox="0 0 16 16" width="18" height="18" fill="none" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="2" y="2.5" width="12" height="11" rx="1.5"/><path d="M2 6.5h12M6.5 6.5v7"/></svg>'
|
||||
|
||||
/** Locale-change subscription the shared core asks for (ctx.locale.subscribe shape). */
|
||||
export interface LocaleRefreshSource { subscribe(listener: () => void): () => void }
|
||||
|
||||
/**
|
||||
* Mount the sidebar entry, waiting for the shell to render and self-healing
|
||||
* on later React re-renders.
|
||||
* @param controller - the board controller the entry toggles.
|
||||
* @param locale - locale-change source; when given, re-applies the label on
|
||||
* a Language switch (the plain-DOM row otherwise keeps the mount-time copy).
|
||||
* @returns disposer removing the entry and its observers.
|
||||
*/
|
||||
export function mountSidebarEntry(controller: BoardController): () => void {
|
||||
export function mountSidebarEntry(controller: BoardController, locale?: LocaleRefreshSource): () => void {
|
||||
return mountSharedSidebarEntry({
|
||||
rowAttribute: 'data-dsh-taskboard-entry',
|
||||
rowSelector: ENTRY_SELECTOR,
|
||||
@@ -36,6 +41,7 @@ export function mountSidebarEntry(controller: BoardController): () => void {
|
||||
icon: ICON,
|
||||
css,
|
||||
label: () => t('entry.label'),
|
||||
refresh: locale === undefined ? undefined : { subscribe: (listener) => locale.subscribe(listener) },
|
||||
onToggle: () => { controller.toggleBoard() },
|
||||
position: 'before',
|
||||
familySelectors: ['[data-dsh-taskboard-entry]', '[data-dsh-ssh-entry]'],
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { t, setRuntimeTranslate } from '../src/client/locales.ts'
|
||||
|
||||
describe('task-board localized t runtime wiring', () => {
|
||||
afterEach(() => { setRuntimeTranslate(undefined) })
|
||||
|
||||
it('prefers the wired SDK translate seat (reads the active locale at call time)', () => {
|
||||
setRuntimeTranslate((key) => `RU(${key})`)
|
||||
expect(t('entry.label')).toBe('RU(entry.label)')
|
||||
})
|
||||
|
||||
it('falls back to the document-language dictionary when unwired', () => {
|
||||
const label = t('entry.label')
|
||||
expect(label.startsWith('RU(')).toBe(false)
|
||||
expect(label.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('passes template params through to the wired seat', () => {
|
||||
setRuntimeTranslate((key, params) => `${key}:${String(params?.count)}`)
|
||||
expect(t('board.archiveView', { count: '3' })).toBe('board.archiveView:3')
|
||||
})
|
||||
})
|
||||
@@ -34,6 +34,7 @@ describe('mountSidebarEntry DOM idempotency', () => {
|
||||
setAttribute: vi.fn(),
|
||||
addEventListener: vi.fn(),
|
||||
remove: vi.fn(),
|
||||
append: vi.fn(),
|
||||
}
|
||||
const createElement = vi.fn(() => entryEl)
|
||||
vi.stubGlobal('document', {
|
||||
@@ -55,7 +56,8 @@ describe('mountSidebarEntry DOM idempotency', () => {
|
||||
|
||||
const dispose = mountSidebarEntry(controller)
|
||||
|
||||
expect(createElement).toHaveBeenCalledTimes(1)
|
||||
// createElement now builds the row plus its two spans (icon/label).
|
||||
expect(createElement).toHaveBeenCalledTimes(3)
|
||||
dispose()
|
||||
expect(entryEl.remove).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
@@ -66,6 +68,7 @@ describe('mountSidebarEntry DOM idempotency', () => {
|
||||
setAttribute: vi.fn(),
|
||||
addEventListener: vi.fn(),
|
||||
remove: vi.fn(),
|
||||
append: vi.fn(),
|
||||
}
|
||||
vi.stubGlobal('document', {
|
||||
querySelector: () => null,
|
||||
|
||||
@@ -35,6 +35,14 @@ export interface SidebarEntryOptions {
|
||||
label(): string
|
||||
/** Optional localized tooltip (title attribute). */
|
||||
tooltip?(): string
|
||||
/**
|
||||
* Optional locale-change subscription: re-applies label / aria-label /
|
||||
* tooltip whenever the active locale changes. Plain-DOM rows would
|
||||
* otherwise keep the label captured at mount; pass the SDK locale runtime
|
||||
* subscription (ctx.locale.subscribe) so the row follows the language
|
||||
* switch without a reload.
|
||||
*/
|
||||
refresh?: { subscribe(listener: () => void): () => void }
|
||||
/** Click action (open/toggle the owning panel). */
|
||||
onToggle(): void
|
||||
/** Family-block position: 'before' inserts ahead of sibling plugin rows, 'after' behind them. */
|
||||
@@ -75,7 +83,7 @@ function newSessionButton(root: HTMLElement): HTMLButtonElement | undefined {
|
||||
}
|
||||
|
||||
/** Build the entry row (a detached button; insert once the shell is up). */
|
||||
function createEntry(options: SidebarEntryOptions): HTMLButtonElement {
|
||||
function createEntry(options: SidebarEntryOptions): { entry: HTMLButtonElement; applyLabel: () => void } {
|
||||
const entry = document.createElement('button')
|
||||
entry.type = 'button'
|
||||
entry.setAttribute(options.rowAttribute, '')
|
||||
@@ -84,12 +92,20 @@ function createEntry(options: SidebarEntryOptions): HTMLButtonElement {
|
||||
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())
|
||||
entry.innerHTML = '<span class="' + (options.css['entryIcon'] ?? '') + '">' + options.icon
|
||||
+ '</span><span class="' + (options.css['entryLabel'] ?? '') + '">' + options.label() + '</span>'
|
||||
const labelSpan = document.createElement('span')
|
||||
labelSpan.className = options.css['entryLabel'] ?? ''
|
||||
const iconSpan = document.createElement('span')
|
||||
iconSpan.className = options.css['entryIcon'] ?? ''
|
||||
iconSpan.innerHTML = options.icon
|
||||
entry.append(iconSpan, labelSpan)
|
||||
const applyLabel = (): void => {
|
||||
entry.setAttribute('aria-label', options.label())
|
||||
if (options.tooltip !== undefined) entry.setAttribute('title', options.tooltip())
|
||||
labelSpan.textContent = options.label()
|
||||
}
|
||||
applyLabel()
|
||||
entry.addEventListener('click', options.onToggle)
|
||||
return entry
|
||||
return { entry, applyLabel }
|
||||
}
|
||||
|
||||
/** Re-insert the entry after the New Session row (before the browser region). */
|
||||
@@ -131,9 +147,18 @@ export function mountSidebarEntry(options: SidebarEntryOptions): () => void {
|
||||
if (typeof document !== 'undefined' && document.querySelector(options.rowSelector) !== null) {
|
||||
return () => {}
|
||||
}
|
||||
const entry = createEntry(options)
|
||||
const { entry, applyLabel } = createEntry(options)
|
||||
let root: HTMLElement | undefined
|
||||
let placed = false
|
||||
let unsubscribeRefresh: (() => void) | undefined
|
||||
if (options.refresh !== undefined) {
|
||||
try {
|
||||
unsubscribeRefresh = options.refresh.subscribe(applyLabel)
|
||||
} catch {
|
||||
// A throwing subscription must not break the mount; the label stays at
|
||||
// its initial value and the next reload resolves it again.
|
||||
}
|
||||
}
|
||||
|
||||
const tryPlace = (): void => {
|
||||
if (root !== undefined && !root.isConnected) {
|
||||
@@ -201,6 +226,7 @@ export function mountSidebarEntry(options: SidebarEntryOptions): () => void {
|
||||
return () => {
|
||||
waitObserver.disconnect()
|
||||
rootObserver.disconnect()
|
||||
unsubscribeRefresh?.()
|
||||
unsubscribeActive?.()
|
||||
entry.remove()
|
||||
}
|
||||
|
||||
@@ -9,9 +9,12 @@ interface FakeElement {
|
||||
nextElementSibling: FakeElement | undefined
|
||||
isConnected: boolean
|
||||
innerHTML: string
|
||||
textContent: string
|
||||
className: string
|
||||
attrs: Record<string, string>
|
||||
removed: boolean
|
||||
append(...children: FakeElement[]): void
|
||||
appendChild(child: FakeElement): void
|
||||
setAttribute(name: string, value: string): void
|
||||
insertBefore(child: FakeElement, anchor?: FakeElement): void
|
||||
remove(): void
|
||||
@@ -34,13 +37,19 @@ function makeElement(tag: string, children: FakeElement[] = []): FakeElement {
|
||||
nextElementSibling: undefined,
|
||||
isConnected: true,
|
||||
innerHTML: '',
|
||||
textContent: '',
|
||||
className: '',
|
||||
attrs: {},
|
||||
removed: false,
|
||||
append(...children) { for (const child of children) element.appendChild(child) },
|
||||
setAttribute(name, value) {
|
||||
element.attrs[name] = value
|
||||
if (name.startsWith('data-')) element.dataset[name.slice('data-'.length)] = value
|
||||
},
|
||||
appendChild(child) {
|
||||
element.children.push(child)
|
||||
child.parentElement = element
|
||||
},
|
||||
insertBefore(child, anchor) {
|
||||
const index = anchor === undefined ? element.children.length : element.children.indexOf(anchor)
|
||||
element.children.splice(index < 0 ? element.children.length : index, 0, child)
|
||||
@@ -113,16 +122,19 @@ describe('shared sidebar-entry core', () => {
|
||||
css: { entry: 'entry-css', entryIcon: 'icon-css', entryLabel: 'label-css' },
|
||||
onToggle: () => { toggled += 1 },
|
||||
}))
|
||||
expect(created).toHaveLength(1)
|
||||
expect(created[0]!.className).toBe('entry-css')
|
||||
expect(created[0]!.innerHTML).toContain('icon-css')
|
||||
expect(created[0]!.innerHTML).toContain('label-css')
|
||||
expect(created[0]!.innerHTML).toContain('<svg/>')
|
||||
expect(created[0]!.innerHTML).toContain('X')
|
||||
created[0]!.click()
|
||||
const entry = created[0]!
|
||||
expect(entry!.className).toBe('entry-css')
|
||||
expect(entry!.attrs['aria-label']).toBe('X')
|
||||
const iconSpan = entry!.children[0]
|
||||
const labelSpan = entry!.children[1]
|
||||
expect(iconSpan!.className).toBe('icon-css')
|
||||
expect(iconSpan!.innerHTML).toContain('<svg/>')
|
||||
expect(labelSpan!.className).toBe('label-css')
|
||||
expect(labelSpan!.textContent).toBe('X')
|
||||
entry!.click()
|
||||
expect(toggled).toBe(1)
|
||||
dispose()
|
||||
expect(created[0]!.removed).toBe(true)
|
||||
expect(entry!.removed).toBe(true)
|
||||
})
|
||||
|
||||
it('outputs the L2 semantic attributes only when the plugin option is set (#506)', () => {
|
||||
@@ -155,6 +167,30 @@ describe('shared sidebar-entry core', () => {
|
||||
expect(dispose()).toBeUndefined()
|
||||
})
|
||||
|
||||
it('re-applies the label through the refresh subscription and unsubscribes on dispose', () => {
|
||||
installShell()
|
||||
const listeners = new Set<() => void>()
|
||||
let label = 'X'
|
||||
const created: FakeElement[] = []
|
||||
stubDocument(false, created)
|
||||
const dispose = mountSidebarEntry(options({
|
||||
refresh: {
|
||||
subscribe: (listener) => { listeners.add(listener); return () => listeners.delete(listener) },
|
||||
},
|
||||
label: () => label,
|
||||
}))
|
||||
const entry = created[0]!
|
||||
expect(entry!.attrs['aria-label']).toBe('X')
|
||||
const labelSpan = entry!.children[1]!
|
||||
expect(labelSpan.textContent).toBe('X')
|
||||
label = 'Рус'
|
||||
for (const listener of listeners) listener()
|
||||
expect(entry!.attrs['aria-label']).toBe('Рус')
|
||||
expect(labelSpan.textContent).toBe('Рус')
|
||||
dispose()
|
||||
expect(listeners.size).toBe(0)
|
||||
})
|
||||
|
||||
it('highlights the row while the active state is open and clears on close', () => {
|
||||
installShell()
|
||||
let open = true
|
||||
|
||||
Reference in New Issue
Block a user