diff --git a/README.md b/README.md index fe9074c..94faab5 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ Working with CSV files shouldn’t be a chore. With CSV, you get: - **Clipboard Actions:** Use Ctrl/Cmd + C or X, or right-click a selection and choose Copy or Cut, to copy properly quoted data to the system clipboard. Right-click Paste inserts clipboard data into the grid. Ordinary cell text is copied without surrounding quotes. CSV escaping still preserves separators, line breaks, literal quotes, surrounding whitespace, and empty cells. Clipboard shortcuts in Find, Replace, and cell text edits keep their normal text-editing behavior. - **Grid Paste Support:** Paste copied ranges directly into the selected cell/range with `Ctrl/Cmd + V` in selection mode. - **Robust Data Handling:** Leveraging the power of [Papa Parse](https://www.papaparse.com/), the extension handles complex CSV structures, special characters, and various data types gracefully. -- **Theme-Optimized Interface:** Whether you prefer light or dark mode, CSV automatically adapts its styles for an optimal viewing experience. +- **Theme-Optimized Interface:** CSV adapts to light, dark, and high-contrast themes. High-contrast themes add clear borders, selection and Find outlines, and theme-aware menus and Find/Replace controls. Cell colors keep the selected datatype palette or explicit theme-foreground mode in every theme. --- diff --git a/images/high-contrast-narrow.jpg b/images/high-contrast-narrow.jpg new file mode 100644 index 0000000..0d71b1f Binary files /dev/null and b/images/high-contrast-narrow.jpg differ diff --git a/media/high-contrast.css b/media/high-contrast.css new file mode 100644 index 0000000..c2c5ee7 --- /dev/null +++ b/media/high-contrast.css @@ -0,0 +1,116 @@ +/* Accessibility chrome is limited to high-contrast themes. Cell foregrounds + continue to follow the selected datatype palette or explicit theme mode. */ +body:is(.vscode-high-contrast, .vscode-high-contrast-light) { + color: var(--vscode-editor-foreground); + background-color: var(--vscode-editor-background); + --csv-contrast-border: var(--vscode-contrastBorder, var(--vscode-editorWidget-border, var(--vscode-panel-border, var(--vscode-editor-foreground)))); +} + +body:is(.vscode-high-contrast, .vscode-high-contrast-light) :is(td, th) { + border-color: var(--csv-contrast-border) !important; +} + +body:is(.vscode-high-contrast, .vscode-high-contrast-light) th { + background-color: var(--vscode-editor-background) !important; +} + +/* Outlines identify state without covering datatype colors with a bright fill. + Reset background images as well, including the opaque frozen-pane layers. */ +body:is(.vscode-high-contrast, .vscode-high-contrast-light) :is(td, th).selected { + background: var(--vscode-editor-background) !important; + outline: 1px solid var(--vscode-focusBorder, var(--csv-contrast-border)); + outline-offset: -1px; +} + +body:is(.vscode-high-contrast, .vscode-high-contrast-light) :is(td, th).highlight { + background: var(--vscode-editor-background) !important; + outline: 1px solid var(--vscode-editor-findMatchHighlightBorder, var(--csv-contrast-border)); + outline-offset: -1px; +} + +body:is(.vscode-high-contrast, .vscode-high-contrast-light) :is(td, th).active-match { + background: var(--vscode-editor-background) !important; + outline: 2px solid var(--vscode-editor-findMatchBorder, var(--vscode-focusBorder)); + outline-offset: -2px; +} + +body:is(.vscode-high-contrast, .vscode-high-contrast-light) .csv-link { + color: var(--vscode-textLink-foreground, var(--vscode-editor-foreground)); +} + +body:is(.vscode-high-contrast, .vscode-high-contrast-light) .csv-link:hover { + color: var(--vscode-textLink-activeForeground, var(--vscode-textLink-foreground)); +} + +body:is(.vscode-high-contrast, .vscode-high-contrast-light) .selected .csv-link { + color: inherit; +} + +body:is(.vscode-high-contrast, .vscode-high-contrast-light) #contextMenu { + color: var(--vscode-menu-foreground, var(--vscode-editor-foreground)); + background-color: var(--vscode-menu-background, var(--vscode-editor-background)); + border-color: var(--csv-contrast-border); +} + +body:is(.vscode-high-contrast, .vscode-high-contrast-light) #contextMenu div { + border-color: var(--csv-contrast-border) !important; +} + +body:is(.vscode-high-contrast, .vscode-high-contrast-light) #contextMenu div:hover { + color: var(--vscode-menu-selectionForeground, var(--vscode-editor-foreground)); + background-color: var(--vscode-menu-selectionBackground, var(--vscode-editor-background)); + outline: 1px solid var(--vscode-menu-selectionBorder, var(--csv-contrast-border)); +} + +body:is(.vscode-high-contrast, .vscode-high-contrast-light) #findReplaceWidget { + color: var(--vscode-editorWidget-foreground, var(--vscode-editor-foreground)); + background-color: var(--vscode-editorWidget-background, var(--vscode-editor-background)); + border-color: var(--csv-contrast-border); +} + +body:is(.vscode-high-contrast, .vscode-high-contrast-light) #findReplaceWidget :is(.fr-gutter, .fr-overflow-menu) { + background-color: var(--vscode-editorWidget-background, var(--vscode-editor-background)); + border-color: var(--csv-contrast-border); +} + +body:is(.vscode-high-contrast, .vscode-high-contrast-light) #findReplaceWidget .fr-input { + color: var(--vscode-input-foreground, var(--vscode-editor-foreground)); + background-color: var(--vscode-input-background, var(--vscode-editor-background)); + border-color: var(--vscode-input-border, var(--csv-contrast-border)); +} + +body:is(.vscode-high-contrast, .vscode-high-contrast-light) #findReplaceWidget .fr-input::placeholder { + color: var(--vscode-input-placeholderForeground, var(--vscode-editor-foreground)); +} + +body:is(.vscode-high-contrast, .vscode-high-contrast-light) #findReplaceWidget :is(.fr-status, button) { + color: var(--vscode-editorWidget-foreground, var(--vscode-editor-foreground)); +} + +body:is(.vscode-high-contrast, .vscode-high-contrast-light) #findReplaceWidget button:hover { + color: var(--vscode-toolbar-hoverForeground, var(--vscode-editor-foreground)); + background-color: var(--vscode-toolbar-hoverBackground, var(--vscode-editor-background)); +} + +body:is(.vscode-high-contrast, .vscode-high-contrast-light) #findReplaceWidget .fr-toggle-btn[aria-pressed="true"] { + color: var(--vscode-inputOption-activeForeground, var(--vscode-editor-foreground)); + background-color: var(--vscode-inputOption-activeBackground, var(--vscode-editor-background)); + box-shadow: inset 0 -2px 0 var(--vscode-inputOption-activeBorder, var(--csv-contrast-border)); +} + +body:is(.vscode-high-contrast, .vscode-high-contrast-light) #findReplaceWidget button[disabled] { + color: var(--vscode-disabledForeground, var(--vscode-editor-foreground)); +} + +body:is(.vscode-high-contrast, .vscode-high-contrast-light) #findReplaceWidget :is(button, input):focus-visible { + outline: 2px solid var(--vscode-focusBorder, var(--csv-contrast-border)); + outline-offset: -2px; +} + +body:is(.vscode-high-contrast, .vscode-high-contrast-light) #findReplaceWidget .fr-divider { + background-color: var(--csv-contrast-border); +} + +body:is(.vscode-high-contrast, .vscode-high-contrast-light) #findReplaceWidget .fr-inline-toggles { + border-color: var(--csv-contrast-border); +} diff --git a/src/CsvEditorProvider.ts b/src/CsvEditorProvider.ts index a1484e4..614055a 100644 --- a/src/CsvEditorProvider.ts +++ b/src/CsvEditorProvider.ts @@ -791,10 +791,10 @@ class CsvEditorController { const rawValue = row[cIdx] || ''; const safe = this.formatCellContent(rawValue, state.clickableLinks); const titleAttr = this.getMultilineCellTitleAttr(rawValue); - cells += `${safe}`; + cells += `${safe}`; } const idxCell = state.addSerialIndex - ? `${displayIdx}` + ? `${displayIdx}` : ''; return `${idxCell}${cells}`; }).join(''); @@ -812,9 +812,9 @@ class CsvEditorController { const virtualAbs = state.startAbs + state.allRowsCount; const displayIdx = state.allRowsCount + 1; const idxCell = state.addSerialIndex - ? `${displayIdx}` + ? `${displayIdx}` : ''; - const dataCells = Array.from({ length: state.numColumns }, (_, i) => ``).join(''); + const dataCells = Array.from({ length: state.numColumns }, (_, i) => ``).join(''); return { html: `${idxCell}${dataCells}`, nextStart: -1, done: true }; } @@ -1482,7 +1482,8 @@ class CsvEditorController { const totalRows = data.length; const offset = Math.min(Math.max(0, hiddenRows), totalRows); - const isDark = vscode.window.activeColorTheme.kind === vscode.ColorThemeKind.Dark; + const themeKind = vscode.window.activeColorTheme.kind; + const isDark = themeKind === vscode.ColorThemeKind.Dark || themeKind === vscode.ColorThemeKind.HighContrast; let headerRow: string[] = []; let bodyData: string[][] = []; if (totalRows === 0 || offset >= totalRows) { @@ -1506,7 +1507,7 @@ class CsvEditorController { : (columnColorPalette === 'warm' ? 'warm' : 'default'); const columnColors = useThemeForeground ? Array.from({ length: numColumns }, () => 'var(--vscode-editor-foreground)') - : columnTypes.map((type, i) => this.getColumnColor(type, isDark, i, palette)); + : columnTypes.map((type, i) => `var(--csv-column-${i}, ${this.getColumnColor(type, isDark, i, palette)})`); const columnWidths = this.computeColumnWidths(visibleForWidth); const BASE_CHUNK_ROWS = 1000; @@ -1546,11 +1547,11 @@ class CsvEditorController { const rawValue = row[cIdx] || ''; const safe = this.formatCellContent(rawValue, clickableLinks); const titleAttr = this.getMultilineCellTitleAttr(rawValue); - cells += `${safe}`; + cells += `${safe}`; } return `${ - addSerialIndex ? `${displayIdx}` : '' + addSerialIndex ? `${displayIdx}` : '' }${cells}`; }).join(''); @@ -1559,41 +1560,44 @@ class CsvEditorController { } } + // Body classes change in place; inherited variables update every mounted or + // later-loaded cell without overriding the user's mode or reloading a draft. const colorCss = useThemeForeground ? '' - : columnColors.map((hex, i) => `td[data-col="${i}"], th[data-col="${i}"] { color: ${hex}; }`).join(''); + : `body:is(.vscode-light, .vscode-high-contrast-light) { ${columnTypes.map((type, i) => `--csv-column-${i}: ${this.getColumnColor(type, false, i, palette)};`).join('')} }` + + `body:is(.vscode-dark, .vscode-high-contrast) { ${columnTypes.map((type, i) => `--csv-column-${i}: ${this.getColumnColor(type, true, i, palette)};`).join('')} }`; let tableHtml = ``; if (headerFlag) { tableHtml += `${ addSerialIndex - ? `` + ? `` : '' }`; for (let i = 0; i < numColumns; i++) { const safe = this.formatCellContent(headerRow[i] || '', clickableLinks); - tableHtml += ``; + tableHtml += ``; } tableHtml += ``; const initialBodyRows = chunked ? allRows.slice(0, chunkRows) : allRows; initialBodyRows.forEach((row, r) => { tableHtml += `${ addSerialIndex - ? `` + ? `` : '' }`; for (let i = 0; i < numColumns; i++) { const rawValue = row[i] || ''; const safe = this.formatCellContent(rawValue, clickableLinks); const titleAttr = this.getMultilineCellTitleAttr(rawValue); - tableHtml += ``; + tableHtml += ``; } tableHtml += ``; }); if (!chunked && includeTrailingEmptyRow) { const virtualAbs = offset + 1 + initialBodyRows.length; - const idxCell = addSerialIndex ? `` : ''; - const dataCells = Array.from({ length: numColumns }, (_, i) => ``).join(''); + const idxCell = addSerialIndex ? `` : ''; + const dataCells = Array.from({ length: numColumns }, (_, i) => ``).join(''); tableHtml += `${idxCell}${dataCells}`; } tableHtml += ``; @@ -1603,22 +1607,22 @@ class CsvEditorController { nonHeaderRows.forEach((row, r) => { tableHtml += `${ addSerialIndex - ? `` + ? `` : '' }`; for (let i = 0; i < numColumns; i++) { const rawValue = row[i] || ''; const safe = this.formatCellContent(rawValue, clickableLinks); const titleAttr = this.getMultilineCellTitleAttr(rawValue); - tableHtml += ``; + tableHtml += ``; } tableHtml += ``; }); if (!chunked && includeTrailingEmptyRow) { const virtualAbs = offset + nonHeaderRows.length; const displayIdx = nonHeaderRows.length + 1; - const idxCell = addSerialIndex ? `` : ''; - const dataCells = Array.from({ length: numColumns }, (_, i) => ``).join(''); + const idxCell = addSerialIndex ? `` : ''; + const dataCells = Array.from({ length: numColumns }, (_, i) => ``).join(''); tableHtml += `${idxCell}${dataCells}`; } tableHtml += ``; @@ -1630,8 +1634,8 @@ class CsvEditorController { const startAbs = headerFlag ? offset + 1 : offset; const virtualAbs = startAbs + allRowsCount; const displayIdx = allRowsCount + 1; - const idxCell = addSerialIndex ? `` : ''; - const dataCells = Array.from({ length: numColumns }, (_, i) => ``).join(''); + const idxCell = addSerialIndex ? `` : ''; + const dataCells = Array.from({ length: numColumns }, (_, i) => ``).join(''); const vrow = `${idxCell}${dataCells}`; chunks.push(vrow); } else if (nextChunkStart === -1) { @@ -1712,12 +1716,17 @@ class CsvEditorController { columnSizes: Record; }): string { const { webview, nonce, fontFamily, fontSize, cellPadding, separator, tableHtml, chunksJson, extraColumnColorCss, nextChunkStart, hasRemoteChunks, mouseWheelZoomEnabled, mouseWheelZoomInvert, columnSizes } = args; - const isDark = vscode.window.activeColorTheme.kind === vscode.ColorThemeKind.Dark; + const themeKind = vscode.window.activeColorTheme.kind; + const isDark = themeKind === vscode.ColorThemeKind.Dark || themeKind === vscode.ColorThemeKind.HighContrast; // Build script URI using file path for compatibility (older APIs may lack Uri.joinPath) const scriptUri = webview.asWebviewUri( vscode.Uri.file(path.join(this.context.extensionPath, 'media', 'main.js')) ); + const contrastStyleUri = webview.asWebviewUri( + vscode.Uri.file(path.join(this.context.extensionPath, 'media', 'high-contrast.css')) + ); + // Safe separator transport (assumes single character; see assumptions) const sepCode = (separator && separator.length > 0) ? separator.codePointAt(0)! : ','.codePointAt(0)!; @@ -1730,18 +1739,41 @@ class CsvEditorController { CSV +
diff --git a/src/test/high-contrast.test.ts b/src/test/high-contrast.test.ts new file mode 100644 index 0000000..15575fb --- /dev/null +++ b/src/test/high-contrast.test.ts @@ -0,0 +1,269 @@ +import assert from 'assert'; +import fs from 'fs'; +import path from 'path'; +import Module from 'module'; +import Papa from 'papaparse'; +import { describe, it } from 'node:test'; +const { JSDOM } = require('jsdom'); + +const theme = { kind: 1 }; +const kinds = { Light: 1, Dark: 2, HighContrast: 3, HighContrastLight: 4 }; +const themeClasses = ['vscode-light', 'vscode-dark', 'vscode-high-contrast', 'vscode-high-contrast-light']; +const settings: Record = {}; +const originalRequire = Module.prototype.require; +Module.prototype.require = function (id: string) { + if (id === 'vscode') { + return { + ColorThemeKind: kinds, + window: { activeColorTheme: theme }, + Uri: { file: (filePath: string) => ({ fsPath: filePath, toString: () => `test-resource:${filePath}` }) }, + workspace: { + getConfiguration: () => ({ get: (key: string, fallback: unknown) => key === 'maxFileSizeMB' ? 0 : (settings[key] ?? fallback) }), + onDidChangeTextDocument: () => ({ dispose() {} }), + onWillSaveTextDocument: () => ({ dispose() {} }) + } + }; + } + return originalRequire.apply(this, arguments as any); +}; +import { CsvEditorProvider } from '../CsvEditorProvider'; +Module.prototype.require = originalRequire; + +const accessibilityCss = fs.readFileSync(path.join(process.cwd(), 'media', 'high-contrast.css'), 'utf8'); +const palettes = ['default', 'cool', 'warm'] as const; +const modes = ['type', 'theme'] as const; +const rows = [ + ['Boolean', 'Date', 'Float', 'Integer', 'String', 'Empty'], + ['true', '2026-03-04', '2.5', '12', 'alpha', ''], + ['false', '2026-04-05', '3.5', '24', 'beta', ''] +]; + +async function render(options: { + rows?: string[][]; kind?: number; palette?: typeof palettes[number]; mode?: typeof modes[number]; + separator?: string; header?: boolean; hiddenRows?: number; +} = {}) { + theme.kind = options.kind ?? kinds.Light; + settings.columnColorMode = options.mode ?? 'type'; + settings.columnColorPalette = options.palette ?? 'default'; + const separator = options.separator ?? ','; + const uri = { fsPath: `/fixture.${separator === '\t' ? 'tsv' : 'csv'}`, toString: () => `vscode-remote://fixture/table.${separator === '\t' ? 'tsv' : 'csv'}` }; + const values: Record = {}; + const context = { + extensionPath: process.cwd(), + workspaceState: { + get: (key: string, fallback: unknown) => values[key] ?? fallback, + update: async (key: string, value: unknown) => { values[key] = value; } + } + }; + await CsvEditorProvider.setHeaderForUri(context as any, uri as any, options.header ?? true); + await CsvEditorProvider.setHiddenRowsForUri(context as any, uri as any, options.hiddenRows ?? 0); + let receive: (message: unknown) => Promise = async () => {}; + const messages: any[] = []; + const webview = { + html: '', options: {}, cspSource: 'test-resource:', + asWebviewUri: (resource: any) => resource.toString(), + postMessage(message: unknown) { messages.push(message); return Promise.resolve(true); }, + onDidReceiveMessage(listener: typeof receive) { receive = listener; } + }; + const panel = { title: 'fixture', active: false, webview, onDidChangeViewState() {}, onDidDispose() {} }; + const text = Papa.unparse(options.rows ?? rows, { delimiter: separator }); + await new CsvEditorProvider(context as any).resolveCustomTextEditor({ uri, version: 1, getText: () => text } as any, panel as any, {} as any); + const dom = new JSDOM(webview.html); + const style = dom.window.document.createElement('style'); + style.textContent = accessibilityCss; + dom.window.document.head.append(style); + dom.window.document.body.className = themeClasses[theme.kind - 1]; + dom.window.document.body.style.setProperty('--vscode-editor-foreground', '#13579b'); + dom.window.document.body.style.setProperty('--vscode-editor-background', '#102030'); + return { dom, html: webview.html, messages, receive: (message: unknown) => receive(message) }; +} + +// jsdom does not resolve custom properties. Resolve the browser's declared +// cascade here, then compare visible values rather than implementation var names. +function declared(element: any, property: string): string { + let result = ''; + let important = false; + for (const sheet of Array.from(element.ownerDocument.styleSheets) as any[]) { + for (const rule of Array.from(sheet.cssRules) as any[]) { + if (!rule.selectorText || !element.matches(rule.selectorText)) { continue; } + const source = property === 'background-color' && !rule.style.getPropertyValue(property) ? 'background' : property; + const value = rule.style.getPropertyValue(source); + const priority = rule.style.getPropertyPriority(source) === 'important'; + if (value && (!important || priority)) { result = value; important = priority; } + } + } + const inline = element.style.getPropertyValue(property); + if (inline && (!important || element.style.getPropertyPriority(property) === 'important')) { result = inline; } + return result; +} + +function resolved(element: any, value: string): string { + let output = value; + for (let pass = 0; pass < 12 && output.includes('var('); pass++) { + const next = output.replace(/var\((--[\w-]+)(?:,\s*([^()]*))?\)/g, (_match: string, name: string, fallback = '') => { + let ancestor = element; + while (ancestor) { + const variable = declared(ancestor, name); + if (variable) { return variable; } + ancestor = ancestor.parentElement; + } + return fallback; + }); + if (next === output) { break; } + output = next; + } + assert.ok(!output.includes('var('), `unresolved value: ${output}`); + return output.trim().toLowerCase(); +} + +function color(cell: any): string { return resolved(cell, declared(cell, 'color')); } +function dataCells(document: any): any[] { return Array.from(document.querySelectorAll('[data-col]:not([data-col="-1"])')); } + +function assertCellColors(document: any, expected: string[], mode: typeof modes[number]) { + for (const cell of dataCells(document)) { + const col = Number(cell.dataset.col); + assert.strictEqual(color(cell), mode === 'theme' ? '#13579b' : expected[col], `${cell.tagName} source row ${cell.dataset.row}, column ${col}`); + } +} + +describe('High-contrast rendering', () => { + it('preserves all selected palettes and both color modes across header, body and append cells in CSV and TSV', async () => { + const firstPaletteColors = new Map(); + for (const separator of [',', '\t']) { + for (const palette of palettes) { + for (const mode of modes) { + for (const [ordinary, contrast] of [[kinds.Light, kinds.HighContrastLight], [kinds.Dark, kinds.HighContrast]]) { + const base = await render({ kind: ordinary, palette, mode, separator }); + const expected = Array.from(base.dom.window.document.querySelectorAll('thead [data-col]') as any[]).map(color); + const hc = await render({ kind: contrast, palette, mode, separator }); + assertCellColors(base.dom.window.document, expected, mode); + assertCellColors(hc.dom.window.document, expected, mode); + assert.strictEqual(dataCells(hc.dom.window.document).length, rows[0].length * (rows.length + 1)); + if (mode === 'type') { + assert.ok(expected.every(value => /^#[0-9a-f]{3,6}$/.test(value)), 'type mode retains literal palette colors'); + firstPaletteColors.set(`${ordinary}:${palette}`, expected); + } + base.dom.window.close(); + hc.dom.window.close(); + } + } + } + } + for (const kind of [kinds.Light, kinds.Dark]) { + assert.notDeepStrictEqual(firstPaletteColors.get(`${kind}:default`), firstPaletteColors.get(`${kind}:cool`)); + assert.notDeepStrictEqual(firstPaletteColors.get(`${kind}:cool`), firstPaletteColors.get(`${kind}:warm`)); + } + assert.notDeepStrictEqual(firstPaletteColors.get(`${kinds.Light}:default`), firstPaletteColors.get(`${kinds.Dark}:default`)); + }); + + it('keeps the chosen palette or theme mode in serialized and remote chunks, including their final append row', async () => { + const longRows = [['Name', 'Count'], ...Array.from({ length: 1001 }, (_, index) => ['hello', String(index + 10)])]; + for (const kind of [kinds.HighContrast, kinds.HighContrastLight]) { + for (const palette of palettes) { + for (const mode of modes) { + const view = await render({ rows: longRows, kind, palette, mode, separator: '\t' }); + const document = view.dom.window.document; + const expected = Array.from(document.querySelectorAll('thead [data-col]') as any[]).map(color); + const serialized = CsvEditorProvider.__test.generateTableAndChunksRaw(longRows, true, true, 0, true, mode, palette); + assert.ok(serialized.chunks.length > 0); + const serializedBody = document.createElement('tbody'); + serializedBody.innerHTML = serialized.chunks.join(''); + document.querySelector('table').append(serializedBody); + assertCellColors(document, expected, mode); + serializedBody.remove(); + await view.receive({ type: 'requestChunk', start: 1000, requestId: 'palette' }); + const chunk = view.messages.find(message => message.type === 'chunkData'); + assert.ok(chunk?.html, 'remote URI provider returns requested rows'); + document.querySelector('tbody').insertAdjacentHTML('beforeend', chunk.html); + if (!chunk.done) { + await view.receive({ type: 'requestChunk', start: chunk.nextStart, requestId: 'append' }); + const append = view.messages.find(message => message.requestId === 'append'); + assert.ok(append?.done); + document.querySelector('tbody').insertAdjacentHTML('beforeend', append.html); + } + assert.ok(document.querySelector('[data-row="1002"][data-col="0"]'), 'final virtual row is mounted'); + assertCellColors(document, expected, mode); + view.dom.window.close(); + } + } + } + }); + + it('preserves header-off and metadata-offset data colors rather than reclassifying the visible rows', async () => { + for (const header of [true, false]) { + const view = await render({ rows: [['metadata'], ...rows], kind: kinds.HighContrastLight, palette: 'warm', header, hiddenRows: 1 }); + const document = view.dom.window.document; + assert.strictEqual(!!document.querySelector('thead'), header); + assert.ok(!document.querySelector('[data-row="0"]')); + const first = document.querySelector('[data-row="1"][data-col="0"]'); + const expected = dataCells(document).filter(cell => Number(cell.dataset.row) === 1).map(color); + assert.ok(first); + assertCellColors(document, expected, 'type'); + view.dom.window.close(); + } + }); + + it('loads the packaged accessibility stylesheet after generated table styles', async () => { + const view = await render(); + const link = ``; + assert.ok(view.html.includes(link)); + assert.ok(view.html.indexOf(link) > view.html.indexOf('')); + assert.ok(fs.existsSync(path.join(process.cwd(), 'media', 'high-contrast.css'))); + const extensionSource = fs.readFileSync(path.join(process.cwd(), 'src', 'extension.ts'), 'utf8'); + assert.ok(!extensionSource.includes('onDidChangeActiveColorTheme'), 'theme changes must not force-reload an active draft'); + view.dom.window.close(); + }); + + it('leaves ordinary parent112 chrome colors unchanged and scopes accessibility overrides to HC', async () => { + const view = await render(); + const document = view.dom.window.document; + const th = document.querySelector('th[data-col="0"]'); + const cell = document.querySelector('td[data-col="0"]'); + const menu = document.querySelector('#contextMenu'); + const link = document.createElement('a'); + link.className = 'csv-link'; + cell.append(link); + const cases = [ + { name: 'vscode-light', header: '#ffffff', border: '#ccc', selected: '#cce0ff', highlight: '#fefefe', active: '#ffffcc', link: '#0066cc', menu: '#ffffff' }, + { name: 'vscode-dark', header: '#1e1e1e', border: '#555', selected: '#333333', highlight: '#2a2a2a', active: '#444444', link: '#6cb6ff', menu: '#2d2d2d' } + ]; + const externalRules = Array.from(document.styleSheets[document.styleSheets.length - 1].cssRules) as any[]; + const elements = [document.body, th, cell, link, menu, ...Array.from(document.querySelectorAll('#findReplaceWidget, #findReplaceWidget *'))]; + for (const expected of cases) { + document.body.className = expected.name; + for (const state of ['', 'selected', 'highlight', 'active-match']) { + cell.className = state; + for (const element of elements) { + assert.ok(!externalRules.some(rule => element.matches(rule.selectorText)), `${expected.name} ${element.tagName}.${state} must not match accessibility CSS`); + } + } + cell.className = ''; + assert.strictEqual(resolved(th, declared(th, 'background-color')), expected.header); + assert.ok(resolved(cell, declared(cell, 'border')).endsWith(expected.border)); + assert.strictEqual(color(link), expected.link); + assert.strictEqual(resolved(menu, declared(menu, 'background')), expected.menu); + for (const [state, background] of [['selected', expected.selected], ['highlight', expected.highlight], ['active-match', expected.active]]) { + cell.className = state; + assert.strictEqual(resolved(cell, declared(cell, 'background-color')), background); + } + assert.strictEqual(declared(document.querySelector('#findReplaceWidget'), 'background'), '#171717'); + assert.strictEqual(declared(document.querySelector('.fr-input'), 'background'), '#1c1c1c'); + } + for (const kind of ['vscode-high-contrast', 'vscode-high-contrast-light']) { + document.body.className = kind; + for (const target of [th, cell]) { + for (const state of ['', 'selected', 'highlight', 'active-match']) { + target.className = state; + const matching = externalRules.filter(rule => target.matches(rule.selectorText)); + assert.ok(matching.length > 0); + assert.ok(matching.every(rule => !rule.style.getPropertyValue('color')), `${kind} ${target.tagName}.${state} retains its configured foreground`); + if (state) { + assert.ok(matching.some(rule => rule.style.getPropertyValue('outline')), `${state} remains visibly distinguishable`); + assert.strictEqual(resolved(target, declared(target, 'background-color')), '#102030'); + } + } + } + } + view.dom.window.close(); + }); +}); diff --git a/src/test/theme-transition.test.ts b/src/test/theme-transition.test.ts new file mode 100644 index 0000000..9bc1787 --- /dev/null +++ b/src/test/theme-transition.test.ts @@ -0,0 +1,124 @@ +import assert from 'assert'; +import fs from 'fs'; +import path from 'path'; +import Module from 'module'; +import { it } from 'node:test'; +const { JSDOM } = require('jsdom'); + +const kinds = { Light: 1, Dark: 2, HighContrast: 3, HighContrastLight: 4 }; +const theme = { kind: kinds.HighContrast }; +const settings: Record = {}; +const originalRequire = Module.prototype.require; +Module.prototype.require = function (id: string) { + if (id === 'vscode') { + return { + ColorThemeKind: kinds, window: { activeColorTheme: theme }, + Uri: { file: (filePath: string) => ({ fsPath: filePath, toString: () => `test:${filePath}` }) }, + workspace: { + getConfiguration: () => ({ get: (key: string, fallback: unknown) => key === 'maxFileSizeMB' ? 0 : (settings[key] ?? fallback) }), + onDidChangeTextDocument: () => ({ dispose() {} }), onWillSaveTextDocument: () => ({ dispose() {} }) + } + }; + } + return originalRequire.apply(this, arguments as any); +}; +import { CsvEditorProvider } from '../CsvEditorProvider'; +Module.prototype.require = originalRequire; + +async function createLiveView(mode: string, palette: string) { + settings.columnColorMode = mode; + settings.columnColorPalette = palette; + const uri = { fsPath: '/draft.csv', toString: () => 'file:///draft.csv' }; + const values: Record = {}; + const context = { + extensionPath: process.cwd(), workspaceState: { + get: (key: string, fallback: unknown) => values[key] ?? fallback, + update: async (key: string, value: unknown) => { values[key] = value; } + } + }; + await CsvEditorProvider.setHeaderForUri(context as any, uri as any, true); + const webview = { html: '', options: {}, cspSource: 'test:', asWebviewUri: (resource: any) => resource.toString(), postMessage() {}, onDidReceiveMessage() {} }; + const panel = { title: 'draft.csv', active: true, webview, onDidChangeViewState() {}, onDidDispose() {} }; + const text = 'Name,Count\nalpha,12\nbeta,24'; + const document = { uri, version: 1, getText: () => text }; + await new CsvEditorProvider(context as any).resolveCustomTextEditor(document as any, panel as any, {} as any); + const dom = new JSDOM(webview.html, { runScripts: 'outside-only', pretendToBeVisual: true }); + const style = dom.window.document.createElement('style'); + style.textContent = fs.readFileSync(path.join(process.cwd(), 'media', 'high-contrast.css'), 'utf8'); + dom.window.document.head.append(style); + const messages: any[] = []; + let state = {}; + dom.window.acquireVsCodeApi = () => ({ postMessage: (message: unknown) => messages.push(message), getState: () => state, setState: (value: object) => { state = value; } }); + dom.window.HTMLElement.prototype.scrollIntoView = () => {}; + dom.window.IntersectionObserver = class { observe() {} }; + dom.window.eval(`${fs.readFileSync(path.join(process.cwd(), 'media', 'main.js'), 'utf8')}\nwindow.themeTestApi = { editCell, setSingleSelection };`); + return { dom, messages, document }; +} + +// Resolve only inherited custom-property values: jsdom leaves CSS var() colors +// unevaluated, while the declarations and real browser DOM remain executable. +function cellColor(cell: any): string { + const document = cell.ownerDocument; + const variables = new Map([['--vscode-editor-foreground', '#13579b']]); + for (const sheet of Array.from(document.styleSheets) as any[]) { + for (const rule of Array.from(sheet.cssRules) as any[]) { + if (rule.selectorText && document.body.matches(rule.selectorText)) { + for (let index = 0; index < rule.style.length; index++) { + const property = rule.style[index]; + if (property.startsWith('--')) { variables.set(property, rule.style.getPropertyValue(property)); } + } + } + } + } + return cell.style.color.replace(/var\((--[\w-]+)(?:,\s*([^()]*))?\)/g, (_: string, name: string, fallback = '') => variables.get(name) ?? fallback).trim().toLowerCase(); +} + +it('changes all four theme classes without replacing, committing or recoloring an active multiline draft outside the chosen mode', async () => { + for (const palette of ['default', 'cool', 'warm']) { + for (const mode of ['type', 'theme']) { + const { dom, messages, document: owner } = await createLiveView(mode, palette); + const { document } = dom.window; + const cell = document.querySelector('[data-row="1"][data-col="0"]'); + const header = document.querySelector('th[data-col="0"]'); + const append = document.querySelector('[data-row="3"][data-col="0"]'); + dom.window.themeTestApi.setSingleSelection(cell); + dom.window.themeTestApi.editCell(cell); + cell.textContent = 'unsaved first line\nsecond line'; + const selection = dom.window.getSelection(); + const range = document.createRange(); + range.setStart(cell.firstChild, 8); + range.collapse(true); + selection.removeAllRanges(); + selection.addRange(range); + assert.strictEqual(document.activeElement, cell); + const colors = new Map(); + messages.length = 0; + for (const themeClass of ['vscode-high-contrast', 'vscode-light', 'vscode-high-contrast-light', 'vscode-dark', 'vscode-light']) { + document.body.className = themeClass; + const visibleColor = cellColor(cell); + colors.set(themeClass, visibleColor); + assert.strictEqual(cellColor(header), visibleColor); + assert.strictEqual(cellColor(append), visibleColor); + assert.strictEqual(document.querySelector('[data-row="1"][data-col="0"]'), cell); + assert.strictEqual(cell.textContent, 'unsaved first line\nsecond line'); + assert.strictEqual(cell.getAttribute('contenteditable'), 'true'); + assert.strictEqual(document.activeElement, cell); + assert.strictEqual(selection.anchorNode, cell.firstChild); + assert.strictEqual(selection.anchorOffset, 8); + assert.ok(cell.classList.contains('editing')); + assert.ok(!messages.some(message => ['editCell', 'save', 'saveAs'].includes(message.type)), 'class changes do not commit or save a draft'); + assert.strictEqual(owner.getText(), 'Name,Count\nalpha,12\nbeta,24'); + assert.strictEqual(owner.version, 1); + } + assert.strictEqual(colors.get('vscode-high-contrast'), colors.get('vscode-dark')); + assert.strictEqual(colors.get('vscode-high-contrast-light'), colors.get('vscode-light')); + if (mode === 'type') { + assert.notStrictEqual(colors.get('vscode-light'), colors.get('vscode-dark')); + assert.ok(Array.from(colors.values()).every(value => /^#[0-9a-f]{6}$/.test(value))); + } else { + assert.ok(Array.from(colors.values()).every(value => value === '#13579b')); + } + dom.window.close(); + } + } +});
${safe}${safe}
${r + 1}${r + 1}${safe}${safe}
${initialBodyRows.length + 1}${initialBodyRows.length + 1}
${r + 1}${r + 1}${safe}${safe}
${displayIdx}${displayIdx}
${displayIdx}${displayIdx}