Skip to content

Commit 29a5115

Browse files
committed
feat: USimpleSheet theme 1급 지원 + data-theme 컨텍스트 다크 모드 (v0.6.0)
- theme 리플렉트 속성 추가 (light | dark) — per-element 지정, 전 브라우저 동작 - 다크 CSS를 단일 소스에서 3가지 컨텍스트로 생성: :host([theme=dark]) / :host-context([theme=dark]) / :host-context([data-theme=dark]) (셀렉터 리스트 결합 시 :host-context 미지원 브라우저가 전체 무효화하므로 개별 규칙) - vitest + happy-dom 테스트 인프라 추가 (ISSUE-20260610-usimplesheet-darkmode-inconsistent)
1 parent 6dafa45 commit 29a5115

5 files changed

Lines changed: 139 additions & 34 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,14 @@
11
# Changelog
22

3+
## [0.6.0] - 2026-06-11
4+
5+
### Added
6+
- `USimpleSheet`: `theme` 속성 1급 지원 (`'light' | 'dark'`, attribute 리플렉트) — per-element 테마 지정 가능, `:host-context` 미지원 브라우저(Firefox/Safari)에서도 동작 (ISSUE-20260610-usimplesheet-darkmode-inconsistent)
7+
- `USimpleSheet`: 조상 `data-theme="dark"` 컨텍스트에서도 다크 모드 적용 — Theme 유틸 없이 `data-theme`만 설정하는 앱에서 도구별 다크 적용이 비일관하던 문제 해소
8+
9+
### Changed
10+
- 다크 모드 CSS를 단일 소스에서 3가지 컨텍스트(`:host([theme="dark"])` / `:host-context([theme="dark"])` / `:host-context([data-theme="dark"])`) 개별 규칙으로 생성 — 셀렉터 리스트 결합 시 `:host-context` 미지원 브라우저가 리스트 전체를 무효화하는 문제 회피
11+
312
## [0.5.0] - 2026-06-10
413

514
### Added

‎package.json‎

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "@iyulab/data-components",
33
"description": "iyulab data visualization components",
4-
"version": "0.5.0",
4+
"version": "0.6.0",
55
"keywords": [
66
"iyulab",
77
"web-components",
@@ -51,7 +51,9 @@
5151
],
5252
"scripts": {
5353
"start": "vite",
54-
"build": "eslint && vite build"
54+
"build": "eslint && vite build",
55+
"test": "vitest run",
56+
"test:watch": "vitest"
5557
},
5658
"dependencies": {
5759
"@iyulab/components": "^1.0.6",
@@ -72,15 +74,17 @@
7274
"devDependencies": {
7375
"@eslint/js": "^9.39.4",
7476
"@lit/react": "^1.0.8",
75-
"@types/react": "^19.2.14",
7677
"@types/node": "^25.8.0",
78+
"@types/react": "^19.2.14",
7779
"eslint": "^9.39.4",
7880
"glob": "^13.0.6",
7981
"globals": "^17.6.0",
82+
"happy-dom": "^20.10.2",
8083
"react": "^19.2.6",
8184
"typescript": "^5.9.3",
8285
"typescript-eslint": "^8.59.3",
8386
"vite": "^8.0.13",
84-
"vite-plugin-dts": "^5.0.0"
87+
"vite-plugin-dts": "^5.0.0",
88+
"vitest": "^4.1.8"
8589
}
86-
}
90+
}

‎src/components/simple-sheet/USimpleSheet.styles.ts‎

Lines changed: 50 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
1-
import { css } from 'lit';
1+
import { css, unsafeCSS } from 'lit';
22

3-
export const styles = css`
3+
const baseStyles = css`
44
:host {
55
display: block;
66
width: 100%;
@@ -281,124 +281,145 @@ export const styles = css`
281281
cursor: default;
282282
}
283283
284-
/* ── Dark mode ──
285-
외부에서 --u-* CSS 변수가 제공되면 그 값을 사용하고,
286-
제공되지 않으면 자체 dark fallback 값을 적용한다. */
284+
`;
287285

288-
:host-context([theme="dark"]) .sheet-container {
286+
/* ── Dark mode ──
287+
외부에서 --u-* CSS 변수가 제공되면 그 값을 사용하고,
288+
제공되지 않으면 자체 dark fallback 값을 적용한다.
289+
290+
다크 규칙은 아래 3가지 컨텍스트에서 동일하게 적용된다:
291+
- :host([theme="dark"]) — 1급 theme 속성 (전 브라우저)
292+
- :host-context([theme="dark"]) — 조상 theme 속성, Theme 유틸 호환 (Chromium)
293+
- :host-context([data-theme="dark"]) — 조상 data-theme 속성만 쓰는 앱 (Chromium)
294+
295+
셀렉터 리스트로 결합하면 :host-context 미지원 브라우저(Firefox/Safari)가
296+
리스트 전체를 무효화하므로, 프리픽스별 개별 규칙으로 생성한다. */
297+
const DARK_PREFIXES = [
298+
':host([theme="dark"])',
299+
':host-context([theme="dark"])',
300+
':host-context([data-theme="dark"])',
301+
];
302+
303+
const darkRules = (host: string) => `
304+
${host} .sheet-container {
289305
background: var(--u-bg-color, #121212);
290306
border-color: var(--u-border-color, #3D3D3D);
291307
}
292308
293-
:host-context([theme="dark"]) .sheet-container:focus-within {
309+
${host} .sheet-container:focus-within {
294310
border-color: var(--u-blue-500, #6ba3e3);
295311
box-shadow: 0 0 0 2px var(--u-blue-100, #1e3a5f);
296312
}
297313
298-
:host-context([theme="dark"]) .corner {
314+
${host} .corner {
299315
background: var(--u-neutral-100, #121212);
300316
border-color: var(--u-border-color, #3D3D3D);
301317
}
302318
303-
:host-context([theme="dark"]) .corner:hover {
319+
${host} .corner:hover {
304320
background: var(--u-neutral-200, #1E1E1E);
305321
}
306322
307-
:host-context([theme="dark"]) .col-header {
323+
${host} .col-header {
308324
background: var(--u-neutral-100, #121212);
309325
color: var(--u-txt-color-weak, #8A8A8A);
310326
border-color: var(--u-border-color, #3D3D3D);
311327
}
312328
313-
:host-context([theme="dark"]) .col-header.col-selected {
329+
${host} .col-header.col-selected {
314330
background: var(--u-blue-100, #1e3a5f);
315331
color: var(--u-blue-800, #c2deff);
316332
}
317333
318-
:host-context([theme="dark"]) .row-num {
334+
${host} .row-num {
319335
background: var(--u-neutral-50, #0A0A0A);
320336
color: var(--u-txt-color-weak, #8A8A8A);
321337
border-right-color: var(--u-border-color, #3D3D3D);
322338
border-bottom-color: var(--u-border-color-weak, #2A2A2A);
323339
}
324340
325-
:host-context([theme="dark"]) .row-num.row-selected {
341+
${host} .row-num.row-selected {
326342
background: var(--u-blue-100, #1e3a5f);
327343
color: var(--u-blue-800, #c2deff);
328344
}
329345
330-
:host-context([theme="dark"]) .cell {
346+
${host} .cell {
331347
color: var(--u-txt-color, #D4D4D4);
332348
border-right-color: var(--u-border-color-weak, #2A2A2A);
333349
border-bottom-color: var(--u-border-color-weak, #2A2A2A);
334350
}
335351
336-
:host-context([theme="dark"]) .cell.cell-readonly {
352+
${host} .cell.cell-readonly {
337353
background: var(--u-neutral-100, #1E1E1E);
338354
color: var(--u-txt-color-weak, #8A8A8A);
339355
}
340356
341-
:host-context([theme="dark"]) .cell.cell-readonly.selected {
357+
${host} .cell.cell-readonly.selected {
342358
background: var(--u-neutral-200, #2A2A2A);
343359
}
344360
345-
:host-context([theme="dark"]) .cell.cell-readonly.anchor {
361+
${host} .cell.cell-readonly.anchor {
346362
background: var(--u-neutral-100, #1E1E1E);
347363
}
348364
349-
:host-context([theme="dark"]) .cell.cell-computed {
365+
${host} .cell.cell-computed {
350366
background: var(--u-blue-100, #1e3a5f);
351367
color: var(--u-txt-color, #D4D4D4);
352368
}
353369
354-
:host-context([theme="dark"]) .cell.cell-computed.selected {
370+
${host} .cell.cell-computed.selected {
355371
background: var(--u-blue-200, #2a4a6f);
356372
}
357373
358-
:host-context([theme="dark"]) .cell.cell-computed.anchor {
374+
${host} .cell.cell-computed.anchor {
359375
background: var(--u-blue-100, #1e3a5f);
360376
}
361377
362-
:host-context([theme="dark"]) .cell.selected {
378+
${host} .cell.selected {
363379
background: var(--u-blue-0, #0a1e3d);
364380
}
365381
366-
:host-context([theme="dark"]) .cell.anchor {
382+
${host} .cell.anchor {
367383
background: var(--u-bg-color, #121212);
368384
outline-color: var(--u-blue-500, #6ba3e3);
369385
}
370386
371-
:host-context([theme="dark"]) .resize-handle:hover::after {
387+
${host} .resize-handle:hover::after {
372388
background: var(--u-blue-500, #6ba3e3);
373389
}
374390
375-
:host-context([theme="dark"]) .cell-input {
391+
${host} .cell-input {
376392
background: var(--u-bg-color, #121212);
377393
color: var(--u-txt-color, #D4D4D4);
378394
outline-color: var(--u-blue-500, #6ba3e3);
379395
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.5);
380396
}
381397
382-
:host-context([theme="dark"]) .cell-dropdown {
398+
${host} .cell-dropdown {
383399
background: var(--u-bg-color, #121212);
384400
border-color: var(--u-border-color, #3D3D3D);
385401
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.5);
386402
}
387403
388-
:host-context([theme="dark"]) .dropdown-item {
404+
${host} .dropdown-item {
389405
color: var(--u-txt-color, #D4D4D4);
390406
}
391407
392-
:host-context([theme="dark"]) .dropdown-item:hover {
408+
${host} .dropdown-item:hover {
393409
background: var(--u-neutral-100, #1E1E1E);
394410
}
395411
396-
:host-context([theme="dark"]) .dropdown-item.highlighted {
412+
${host} .dropdown-item.highlighted {
397413
background: var(--u-blue-100, #1e3a5f);
398414
color: var(--u-blue-800, #c2deff);
399415
}
400416
401-
:host-context([theme="dark"]) .dropdown-empty {
417+
${host} .dropdown-empty {
402418
color: var(--u-txt-color-weak, #8A8A8A);
403419
}
404420
`;
421+
422+
export const styles = [
423+
baseStyles,
424+
unsafeCSS(DARK_PREFIXES.map(darkRules).join('\n')),
425+
];

‎src/components/simple-sheet/USimpleSheet.ts‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,12 @@ export class USimpleSheet extends UElement {
8080
/** 읽기 전용 모드 */
8181
@property({ type: Boolean }) readonly = false;
8282

83+
/**
84+
* 명시 테마. 'dark'이면 다크 스타일을 적용합니다.
85+
* 미설정 시 조상 컨텍스트의 theme/data-theme 속성에 따릅니다 (:host-context, Chromium 한정).
86+
*/
87+
@property({ type: String, reflect: true }) theme?: 'light' | 'dark';
88+
8389
@state() private _sel: SelectionRange | null = null;
8490
@state() private _editing: CellPos | null = null;
8591
@state() private _editVal = '';

‎tests/u-simple-sheet-theme.test.ts‎

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
// @vitest-environment happy-dom
2+
import { describe, it, expect } from 'vitest';
3+
import { USimpleSheet } from '../src/components/simple-sheet/USimpleSheet.js';
4+
import { styles } from '../src/components/simple-sheet/USimpleSheet.styles.js';
5+
6+
/**
7+
* USimpleSheet 테마 1급 지원 검증.
8+
*
9+
* 배경(ISSUE-20260610-usimplesheet-darkmode-inconsistent):
10+
* 다크 스타일이 :host-context([theme="dark"])에만 걸려 있어
11+
* (1) data-theme만 설정하는 앱에서는 다크가 적용되지 않고,
12+
* (2) :host-context 미지원 브라우저(Firefox/Safari)에서는 항상 미적용이며,
13+
* (3) 소비자가 per-element로 테마를 지정할 공식 수단이 없다.
14+
*/
15+
16+
function cssText(group: unknown): string {
17+
if (Array.isArray(group)) return group.map(cssText).join('\n');
18+
return String(group);
19+
}
20+
21+
const DARK_PREFIXES = [
22+
':host([theme="dark"])',
23+
':host-context([theme="dark"])',
24+
':host-context([data-theme="dark"])',
25+
];
26+
27+
function countOccurrences(text: string, token: string): number {
28+
return text.split(token).length - 1;
29+
}
30+
31+
describe('USimpleSheet 테마 지원', () => {
32+
it('theme 속성이 attribute로 리플렉트된다', async () => {
33+
const el = new USimpleSheet();
34+
document.body.appendChild(el);
35+
36+
el.theme = 'dark';
37+
await el.updateComplete;
38+
39+
expect(el.getAttribute('theme')).toBe('dark');
40+
el.remove();
41+
});
42+
43+
describe('다크 모드 CSS 계약', () => {
44+
const text = cssText(styles);
45+
46+
it.each(DARK_PREFIXES)('%s 프리픽스로 다크 규칙이 존재한다', (prefix) => {
47+
expect(text).toContain(`${prefix} .sheet-container`);
48+
expect(text).toContain(`${prefix} .cell`);
49+
expect(text).toContain(`${prefix} .dropdown-empty`);
50+
});
51+
52+
it('세 프리픽스의 규칙 수가 동일하다 (단일 소스에서 생성)', () => {
53+
const counts = DARK_PREFIXES.map(p => countOccurrences(text, `${p} `));
54+
expect(counts[0]).toBeGreaterThan(0);
55+
expect(counts[1]).toBe(counts[0]);
56+
expect(counts[2]).toBe(counts[0]);
57+
});
58+
59+
it('프리픽스들이 셀렉터 리스트로 결합되지 않는다 (:host-context 미지원 브라우저에서 리스트 전체 무효화 방지)', () => {
60+
// 같은 규칙 안에서 "prefix A ..., prefix B ..." 형태로 콤마 결합되면 안 된다.
61+
expect(text).not.toMatch(/:host\(\[theme="dark"\]\)[^{}]*,[^{}]*:host-context/);
62+
expect(text).not.toMatch(/:host-context\([^)]*\)[^{}]*,[^{}]*:host\(\[theme="dark"\]\)/);
63+
});
64+
});
65+
});

0 commit comments

Comments
 (0)