Skip to content

Commit d734a3d

Browse files
SoliEstreclaude
andcommitted
feat: playground 강결합 데모 (단일 공유 intent 패널) + sidebar count 배지
승인된 시나리오 A 구현 — 알림·인박스 패널, 모든 컴포넌트가 단일 공유 intent {sidebarActive, counts, notifCount, darkMode} 에 참여: - estreuv-message-list (playground 예제 컴포넌트, 라이브러리 아님): sidebarActive 폴더 필터 + 읽음→counts 재계산→requestIntentUpdate (event-up) + onShow/onHide 수신 시뮬 타이머 - estreuv-sidebar-item: 옵션 count 배지 / estreuv-sidebar: counts 맵 prop-down (label 별) / estreuv-notif-count-tile: 라이브 intent.notifCount 반영 (onShow 뿐 아니라) ← 라이브러리 0.3.0 기능 - staticDoc: 갤러리 → 패널 레이아웃(상단 clock·notif·dark-mode + 사이드바 폴더 + 메시지 리스트). 콘솔 가이드 강결합 루프로 갱신 - main.js: 초기 intent sidebarActive=Inbox / contract 테스트 새 구조 갱신 강결합 루프: 폴더 클릭·메시지 읽음·수신 → event-up→intent→prop-down 으로 사이드바 배지+상단 unread+리스트 동시 반응. estreuv 38/38, I1 716·I3 4.48KB, contract·docs 빌드 그린. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 77a3c38 commit d734a3d

9 files changed

Lines changed: 214 additions & 60 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"estreuv": minor
3+
---
4+
5+
`estreuv-sidebar-item` gains an optional `count` badge, and `estreuv-sidebar` propagates a `counts` map (by item label) to its items via prop-down. `estreuv-notif-count-tile` now reflects `intent.notifCount` live (not only on `onShow`). Together these enable strongly-coupled, single-source-of-truth UIs (e.g., folder unread badges that update from shared intent).

packages/estreuv/src/notif-count-tile.js

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,13 @@ export class NotifCountTile extends EstreUVElement {
8383

8484
willUpdate(changedProperties) {
8585
super.willUpdate?.(changedProperties);
86+
// 라이브 intent → count 동기 (prop-down). onShow 뿐 아니라 intent 가 바뀔 때마다 반영.
87+
if (changedProperties.has('intent')) {
88+
const fromIntent = this.intent?.notifCount ?? this.intent?.data?.notifCount;
89+
if (fromIntent != null && Number(fromIntent) !== Number(this.count)) {
90+
this.count = Number(fromIntent);
91+
}
92+
}
8693
if (changedProperties.has('count')) {
8794
const n = Number(this.count);
8895
this._numCount = Number.isFinite(n) && n >= 0 ? Math.floor(n) : 0;

packages/estreuv/src/sidebar-item.js

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@ export class EstreuvSidebarItem extends EstreUVElement {
2626
active: { type: Boolean, reflect: true },
2727
/** compact (사이드바 collapsed 시) — 사이드바가 prop-down */
2828
compact: { type: Boolean, reflect: true },
29+
/** 선택적 배지 카운트 (사이드바가 counts 맵에서 prop-down). 0/없으면 숨김 */
30+
count: { type: Number },
2931
// label · icon 은 applyAliases 가 추가
3032
};
3133

@@ -48,13 +50,21 @@ export class EstreuvSidebarItem extends EstreUVElement {
4850
:host(:hover) a { background: rgba(127,127,127,0.12); }
4951
:host([active]) a { background: rgba(127,127,127,0.22); font-weight: 700; }
5052
.icon { width: 1.1em; text-align: center; flex: 0 0 auto; }
53+
.label-text { flex: 1; }
54+
.badge {
55+
min-width: 18px; padding: 0 6px; height: 18px; border-radius: 9px;
56+
background: var(--estreuv-badge, #e5484d); color: #fff;
57+
font-size: 0.7rem; font-weight: 700;
58+
display: inline-flex; align-items: center; justify-content: center; flex: 0 0 auto;
59+
}
5160
:host([compact]) .label-text { display: none; }
5261
`;
5362

5463
constructor() {
5564
super();
5665
this.active = false;
5766
this.compact = false;
67+
this.count = 0;
5868
this.label = '';
5969
this.icon = '•';
6070
this._everShownFromArticle = false;
@@ -73,6 +83,7 @@ export class EstreuvSidebarItem extends EstreUVElement {
7383
<a @click=${() => this._activate()} title=${this.label}>
7484
<span class="icon" aria-hidden="true">${this.icon}</span>
7585
<span class="label-text">${this.label}</span>
86+
${this.count > 0 ? html`<span class="badge">${this.count}</span>` : ''}
7687
</a>
7788
`;
7889
}

packages/estreuv/src/sidebar.js

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,8 @@ export class EstreuvSidebar extends EstreUVElement {
2727
collapsed: { type: Boolean, reflect: true },
2828
/** 현재 active 항목 라벨 — 항목들에게 prop-down */
2929
activeLabel: { type: String, attribute: 'active-label' },
30+
/** 라벨별 배지 카운트 맵 {label: n} — 항목들에게 count prop-down */
31+
counts: { type: Object },
3032
// title 은 applyAliases 가 추가
3133
};
3234

@@ -62,6 +64,7 @@ export class EstreuvSidebar extends EstreUVElement {
6264
super();
6365
this.collapsed = false;
6466
this.activeLabel = '';
67+
this.counts = {};
6568
this.title = 'Menu';
6669
}
6770

@@ -79,15 +82,24 @@ export class EstreuvSidebar extends EstreUVElement {
7982
/** collapsed / activeLabel 변경을 슬롯된 항목들에 prop-down (양방향 race 없음 — owner 단방향) */
8083
_propagateToItems() {
8184
const items = this.querySelectorAll('estreuv-sidebar-item');
85+
const counts = this.counts ?? {};
8286
items.forEach((item) => {
8387
item.compact = this.collapsed;
8488
item.active = item.label === this.activeLabel;
89+
if (item.label in counts) item.count = counts[item.label];
8590
});
8691
}
8792

8893
updated(changedProperties) {
8994
super.updated?.(changedProperties);
90-
if (changedProperties.has('collapsed') || changedProperties.has('activeLabel')) {
95+
// 라이브 intent 변경(prop-down) → counts/active 동기 (메시지 읽음 등으로 갱신될 때)
96+
if (changedProperties.has('intent')) {
97+
const intent = this.intent ?? {};
98+
if (intent.counts != null) this.counts = intent.counts;
99+
if (intent.sidebarActive != null) this.activeLabel = String(intent.sidebarActive);
100+
}
101+
if (changedProperties.has('collapsed') || changedProperties.has('activeLabel')
102+
|| changedProperties.has('counts')) {
91103
this._propagateToItems();
92104
}
93105
}
@@ -130,6 +142,7 @@ export class EstreuvSidebar extends EstreUVElement {
130142
const intent = this.intent ?? {};
131143
if (intent.sidebarCollapsed != null) this.collapsed = !!intent.sidebarCollapsed;
132144
if (intent.sidebarActive != null) this.activeLabel = String(intent.sidebarActive);
145+
if (intent.counts != null) this.counts = intent.counts;
133146
}
134147
}
135148

packages/playground/scripts/estreuv-tiles.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@ import 'estreuv/notif-count-tile.js';
1515
// 사이드바 prototype — nested 컨테이너 + 중첩 lifecycle
1616
import 'estreuv/sidebar.js';
1717
import 'estreuv/sidebar-item.js';
18+
// playground 예제 컴포넌트 (라이브러리 아님) — 강결합 데모의 메시지 리스트
19+
import './message-list.js';
1820

1921
// 콘솔 디버깅용 helper 노출 (page handler 도 이걸 씀)
2022
import * as _intent from 'estreuv/intent-context.js';

packages/playground/scripts/main.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ class HomePageHandler extends EstrePageHandler {
3333
// intent provider 부착 — article root 확정 시점
3434
const articleRoot = handle?.article ?? handle?.element ?? document.querySelector('[data-article-id="main"][data-static="1"]');
3535
if (articleRoot && window._estreuv && !_estreuvIntent) {
36-
_estreuvIntent = window._estreuv.intent.provideIntent(articleRoot, { step: "home", data: { _bootMs: Date.now() } });
36+
_estreuvIntent = window._estreuv.intent.provideIntent(articleRoot, { step: "home", sidebarActive: "Inbox", counts: {}, notifCount: 0, data: { _bootMs: Date.now() } });
3737
window._spikeIntent = _estreuvIntent; // 콘솔 호환 (spike-test 와 동일 이름 유지)
3838
_estreuvLifecycle = window._estreuv.bridge.wireArticle(articleRoot);
3939
window._spikeLifecycle = _estreuvLifecycle;
Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
/**
2+
* <estreuv-message-list> — playground 예제 컴포넌트 (라이브러리 본체 아님).
3+
*
4+
* 강결합 데모의 중심: 메시지 데이터를 보유하고, 공유 intent 의 `sidebarActive`(활성 폴더)로
5+
* 필터해 렌더한다. 메시지 "읽음" → 폴더별 미읽음 counts 재계산 → `requestIntentUpdate({counts,
6+
* notifCount})` (event-up). 그러면 owner article 이 intent 를 갱신 → 사이드바 배지 + notif 타일이
7+
* 동시에 prop-down 반응 (단일 소스). onShow 에 "수신 시뮬" 타이머 시작 / onHide 정지 (lifecycle).
8+
*
9+
* EstreUV 를 npm 의존으로 쓰는 실사용자 컴포넌트 작성 예시 — importmap 으로 estreuv/lit 해소.
10+
*/
11+
import { EstreUVElement } from 'estreuv';
12+
import { html, css } from 'lit';
13+
14+
const FOLDERS = ['Inbox', 'Archive', 'Trash'];
15+
let _seq = 200;
16+
const mk = (from, sub, folder, read = false) => ({ id: ++_seq, from, sub, folder, read });
17+
18+
export class EstreuvMessageList extends EstreUVElement {
19+
static properties = {
20+
...EstreUVElement.properties,
21+
_messages: { state: true },
22+
};
23+
24+
static styles = css`
25+
:host { display: block; flex: 1; min-width: 0; }
26+
.empty { padding: 56px 20px; text-align: center; opacity: 0.55; }
27+
.msg {
28+
display: flex; gap: 12px; padding: 11px 14px; border-radius: 12px;
29+
cursor: pointer; border: 1px solid transparent;
30+
}
31+
.msg:hover { background: rgba(127,127,127,0.10); }
32+
.av {
33+
width: 34px; height: 34px; border-radius: 50%; flex: none;
34+
display: flex; align-items: center; justify-content: center; font-weight: 700;
35+
background: rgba(127,127,127,0.15);
36+
}
37+
.from { font-weight: 600; font-size: 0.95rem; }
38+
.sub { opacity: 0.6; font-size: 0.85rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
39+
.msg.read { opacity: 0.5; }
40+
.msg.unread .from::before {
41+
content: ""; display: inline-block; width: 8px; height: 8px; border-radius: 50%;
42+
background: var(--estreuv-tile-color, #2a8c82); margin-right: 7px; vertical-align: middle;
43+
}
44+
`;
45+
46+
constructor() {
47+
super();
48+
this._messages = [
49+
mk('Estre CI', 'Release workflow: estreuv published', 'Inbox'),
50+
mk('SoliEstre', 'Re: docs 사이트 VitePress 구성', 'Inbox'),
51+
mk('GitHub', '[EstreUV.js] CI passed on main', 'Inbox'),
52+
mk('npm', 'Weekly download report', 'Archive', true),
53+
mk('Antigravity', 'Browser verification PASS', 'Archive', true),
54+
mk('notice', 'deprecated reminder', 'Trash', true),
55+
];
56+
this._timer = null;
57+
}
58+
59+
get activeFolder() { return this.intent?.sidebarActive ?? 'Inbox'; }
60+
61+
/** 폴더별 미읽음 counts {Inbox,Archive,Trash} + 총합을 intent 로 위임 (event-up) */
62+
_publishCounts() {
63+
const counts = Object.fromEntries(FOLDERS.map(f => [f, 0]));
64+
this._messages.forEach(m => { if (!m.read) counts[m.folder]++; });
65+
const notifCount = Object.values(counts).reduce((a, b) => a + b, 0);
66+
this.requestIntentUpdate({ counts, notifCount });
67+
}
68+
69+
_read(m) {
70+
if (m.read) return;
71+
m.read = true;
72+
this._messages = [...this._messages]; // reactive
73+
this._publishCounts();
74+
}
75+
76+
/** 외부(또는 타이머)에서 수신 시뮬 — Inbox 에 새 메시지 */
77+
simulateIncoming() {
78+
const samples = [
79+
['Estrelle', 'New build artifact ready'],
80+
['Show GN', 'New comment on your post'],
81+
['create-estreuv', 'npx scaffold tested'],
82+
];
83+
const s = samples[Math.floor(Math.random() * samples.length)];
84+
this._messages = [mk(s[0], s[1], 'Inbox'), ...this._messages];
85+
this._publishCounts();
86+
}
87+
88+
render() {
89+
const msgs = this._messages.filter(m => m.folder === this.activeFolder);
90+
if (!msgs.length) return html`<div class="empty">이 폴더에 메시지가 없습니다</div>`;
91+
return html`${msgs.map(m => html`
92+
<div class="msg ${m.read ? 'read' : 'unread'}" @click=${() => this._read(m)}>
93+
<div class="av">${(m.from[0] || '?').toUpperCase()}</div>
94+
<div style="flex:1;min-width:0">
95+
<div class="from">${m.from}</div>
96+
<div class="sub">${m.sub}</div>
97+
</div>
98+
</div>`)}`;
99+
}
100+
101+
firstUpdated() { this._publishCounts(); }
102+
103+
// ─── EstreUI lifecycle — 수신 시뮬 타이머를 가시성에 결속 ───
104+
onShow(handle) {
105+
super.onShow(handle);
106+
if (!this._timer) this._timer = setInterval(() => this.simulateIncoming(), 8000);
107+
}
108+
onHide(handle) {
109+
super.onHide(handle);
110+
if (this._timer) { clearInterval(this._timer); this._timer = null; }
111+
}
112+
disconnectedCallback() {
113+
if (this._timer) { clearInterval(this._timer); this._timer = null; }
114+
super.disconnectedCallback();
115+
}
116+
}
117+
118+
customElements.define('estreuv-message-list', EstreuvMessageList);

0 commit comments

Comments
 (0)