@iyulab/modern-app 0.8.1 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. package/CHANGELOG.md +141 -0
  2. package/dist/components/ActionBar.d.ts +45 -0
  3. package/dist/components/ActionBar.js +32 -0
  4. package/dist/components/ActionBar.styles.d.ts +1 -0
  5. package/dist/components/ActionBar.styles.js +65 -0
  6. package/dist/components/EmptyState.d.ts +52 -0
  7. package/dist/components/EmptyState.js +47 -0
  8. package/dist/components/EmptyState.styles.d.ts +1 -0
  9. package/dist/components/EmptyState.styles.js +51 -0
  10. package/dist/components/GroupBox.d.ts +43 -0
  11. package/dist/components/GroupBox.js +33 -0
  12. package/dist/components/GroupBox.styles.d.ts +1 -0
  13. package/dist/components/GroupBox.styles.js +90 -0
  14. package/dist/components/InfoField.d.ts +45 -0
  15. package/dist/components/InfoField.js +33 -0
  16. package/dist/components/InfoField.styles.d.ts +1 -0
  17. package/dist/components/InfoField.styles.js +40 -0
  18. package/dist/components/InfoSection.d.ts +31 -0
  19. package/dist/components/InfoSection.js +23 -0
  20. package/dist/components/InfoSection.styles.d.ts +1 -0
  21. package/dist/components/InfoSection.styles.js +18 -0
  22. package/dist/components/PageHeader.d.ts +53 -0
  23. package/dist/components/PageHeader.js +44 -0
  24. package/dist/components/PageHeader.styles.d.ts +1 -0
  25. package/dist/components/PageHeader.styles.js +98 -0
  26. package/dist/index.d.ts +8 -0
  27. package/dist/index.js +9 -2
  28. package/dist/internals/locale.d.ts +46 -0
  29. package/dist/internals/locale.js +29 -0
  30. package/dist/internals/slotted.d.ts +13 -0
  31. package/dist/internals/slotted.js +6 -0
  32. package/dist/layouts/SidebarLayout.js +6 -6
  33. package/dist/layouts/SidebarLayout.styles.js +11 -2
  34. package/package.json +2 -2
package/CHANGELOG.md CHANGED
@@ -1,5 +1,146 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.10.0] - 2026-08-04
4
+
5
+ ### Added
6
+
7
+ - **`u-action-bar` — 상세·편집 화면의 푸터 액션 바.** 초안 R4 의 잔여였다.
8
+
9
+ ```html
10
+ <u-action-bar sticky>
11
+ <u-button slot="danger" color="danger" variant="ghost">삭제</u-button>
12
+ <u-button variant="ghost">취소</u-button>
13
+ <u-button color="primary">저장</u-button>
14
+ </u-action-bar>
15
+ ```
16
+
17
+ **위험 액션은 왼쪽 끝, 주 액션은 오른쪽 끝** — 같은 무리에 두면 «저장»을 누르려다
18
+ «삭제»를 누른다. **거리가 안전장치**이므로 이 분리는 취향이 아니라 기능이다.
19
+ 좁아지면 두 무리가 각자 한 줄을 갖고 **주 액션이 위로** 온다(엄지 위치에 위험 액션을
20
+ 두지 않기 위해서다). `sticky` 로 스크롤 컨테이너 하단에 고정한다.
21
+
22
+ ### Changed
23
+
24
+ - 🔴**접힘 기준이 «화면»에서 «자기 폭»으로 바뀌었다** — `u-page-header` · `u-group-box`.
25
+
26
+ 종전에는 `@media (max-width: …)` 였고, 그러면 이 프리미티브들이 **자기가 얼마나 좁은지와
27
+ 무관하게** 접혔다: 사이드바가 열려 본문이 500px 인 1280px 화면에서는 접히지 않고, 넓은
28
+ 본문을 가진 좁은 화면에서는 불필요하게 접혔다. 컴포넌트가 스스로 판단할 수 있는 것은
29
+ **자기 컨테이너 폭**뿐이다 ⇒ `@container` 로 옮겼다(`container-type: inline-size`).
30
+
31
+ ⚠**시각 변화가 있을 수 있다** — 좁은 컬럼 안의 페이지 헤더는 이제 접히고(종전에는 화면이
32
+ 넓으면 접히지 않았다), 넓은 본문을 가진 태블릿에서는 접히지 않는다(종전에는 접혔다).
33
+
34
+ - **`u-group-box` 의 헤더 툴바가 좁을 때 제목 아래로 내려간다**(컨테이너 480px 이하).
35
+ 제목과 툴바가 한 줄에 남으면 제목은 잘리고 입력은 손가락보다 좁아진다 — 초안 R4 가
36
+ *"좁은 화면에서 접히는 규칙을 라이브러리가 정한다"* 로 요구한 자리다.
37
+
38
+
39
+ ## [0.9.0] - 2026-08-03
40
+
41
+ > **LOB 레이아웃 프리미티브 신설.** 이 패키지는 종전에 **셸 바깥 껍데기**(사이드바·라우팅·
42
+ > 테마·토스트)만 갖고 있었고 **셸 안쪽(페이지 본문)에 대한 제공물은 0개**였다. 그래서 소비앱이
43
+ > 화면마다 같은 구조를 손으로 다시 짰다 — 실측된 형태로 **라벨-값 그리드 41곳 · 카드 30여 곳**.
44
+ > 손으로 짜면 제목 크기·여백·열 수가 화면마다 조금씩 달라지고, 그 차이는 화면을 옮겨 다니는
45
+ > 사용자에게 *"제품이 하나로 만들어지지 않았다"* 로 읽힌다.
46
+
47
+ ### Added
48
+
49
+ - **`u-page-header`** — 제목 + 상태 배지 슬롯 + 우측 액션 슬롯 + 뒤로가기. 좁은 화면에서
50
+ 액션이 아래로 내려가며 **왼쪽 정렬**이 된다(오른쪽 유지는 손가락이 닿기 어려운 쪽으로 몰린다).
51
+ - **`u-group-box`** — 제목이 붙은 카드. `u-card` 는 면만 주는데, LOB 에서 반복되는 것은
52
+ *면 + 제목 + 우측 액션* 이라는 **묶음**이다. `divider`·`flush` 속성.
53
+ - **`u-info-section`** / **`u-info-field`** — 읽기 전용 라벨-값 그리드.
54
+ ★열 수를 **컨테이너 폭**이 정한다(`auto-fit`). 미디어 쿼리로 고정하면 사이드 패널 같은
55
+ 좁은 컨테이너 안에서 화면 폭 기준으로 계산돼 칸이 짓눌린다.
56
+ 🔴**"아직 없음"과 "0"은 다른 사실이다** — `null`·`undefined`·빈 문자열은 `—`, `0`·`false`·`'0'`
57
+ 은 값 그대로. 규칙을 사람이 기억하는 방식으로 두면 반드시 어긋난다(실제로 *"부수 0인 주문"*
58
+ 과 *"부수가 아직 안 정해진 주문"* 이 똑같이 `—` 로 보인 소비앱이 있었다).
59
+ 숫자는 `numeric` 으로 우정렬 + `tabular-nums` — 자릿수가 세로로 맞아야 크기 비교가 눈으로 된다.
60
+ - **`u-empty-state`** — 🔴**`no-data`(아직 없음)와 `no-results`(조건에 안 맞음)를 가른다.**
61
+ 같은 문구로 보여 주면 사용자는 필터가 걸려 있는 줄 모르고 *"데이터가 사라졌다"* 로 읽는다.
62
+ `--empty-state-icon-size` 훅.
63
+
64
+ - 🆕**로케일 레지스트리** — `registerLocale` · `setDefaultLocale` · `getLocaleStrings`.
65
+
66
+ ```ts
67
+ registerLocale('ko', { back: '뒤로', noResultsTitle: '조건에 맞는 결과가 없습니다' });
68
+ setDefaultLocale('ko');
69
+ ```
70
+
71
+ 🔴**기본값은 영어다.** 이 패키지는 3층 구분에서 **「범용」**이다(`components` = 중립
72
+ 프리미티브 / **`modern-app` = 범용** / `enterprise` = 이유랩 하우스 스타일).
73
+ 범용 층이 특정 언어를 기본값으로 가지면 **그 언어를 쓰지 않는 소비자를 배제**하며,
74
+ 그것은 이 리포가 `components` 에 대해 기각한 것과 **같은 형태**다.
75
+ ⚠**언어는 미감보다 강하게 배제한다** — 어색한 기본값은 어색할 뿐이지만 읽을 수 없는
76
+ 기본값은 쓸 수 없다.
77
+
78
+ ★**초판(미게시)의 `u-empty-state`·`u-page-header` 는 한국어 기본값을 갖고 있었다.**
79
+ 로케일 표준(*영어 기본 + 레지스트리*)은 이 리포가 **채택했지만 강제한 적이 없었고**,
80
+ 그래서 채택 이후에 만들어진 컴포넌트가 그대로 들어왔다. 회귀 테스트가
81
+ **`src/**` 문자열 리터럴에 한글 0건**을 지킨다(주석·JSDoc 은 대상 아님 — 이 리포의
82
+ 내부 문서 언어는 한국어다). 부분 등록은 영어와 병합되고, `ko` 등록이 `ko-KR` 에도
83
+ 걸린다(소비자는 `ko` 를 등록하고 브라우저는 `ko-KR` 을 준다).
84
+
85
+ ### Docs
86
+
87
+ - 🆕`docs/lob-primitives.md` — 카탈로그 · 오버라이드 경로 · **아직 만들지 않은 것과 그 이유**.
88
+ - ★**등급 정책을 명시했다.** 이 패키지에는 `enterprise` 같은 카탈로그·등급 문서가 없었다.
89
+ 프리미티브를 다섯 개 얹으면서 **정책을 조용히 만들어 버리는 것**을 피하려고 적었다 —
90
+ *"현대적 앱이 보통 제공하는 것"* 이면 들어오되, **오버라이드 경로(`part`+slot)와 토큰 전용**
91
+ 둘을 요구한다.
92
+ - ➡**`EmptyState` 소유권을 `@iyulab/enterprise` 카탈로그에서 이관받았다.** 빈 상태는 프레임워크
93
+ 중립이 맞고, 그쪽 L2 는 React 전용이다. 같은 표면을 두 패키지가 각각 만들면 **소비자가 둘 중
94
+ 무엇을 쓸지 매번 고르게 되고, 그 비용은 만드는 쪽이 아니라 쓰는 쪽이 낸다.**
95
+
96
+ ### 🔴 실브라우저 검증에서 잡힌 것 — 소스는 멀쩡했다
97
+
98
+ - **`:not(:has(*))` 로 빈 슬롯 래퍼를 접으려던 것이 동작하지 않았다.** `<slot>` **자신이
99
+ 자식 요소**라 `:has(*)` 가 항상 참이다. 소스 검사도 jsdom 도 통과시킨다 — 브라우저에서만
100
+ 드러난다. ⇒ 배정 상태를 `slotchange` 로 추적한다(`internals/slotted.ts`).
101
+ - **`render()` 안의 `this.querySelector()`** 는 **자식이 늦게 붙으면 못 본다.** 소비자가
102
+ `requestUpdate()` 를 부를 이유가 없으므로 헤더가 사라진 채로 남는다. ⇒ 같은 방식으로 교체.
103
+ 헤더는 이제 **DOM 에서 빼지 않고 접는다** — 슬롯이 렌더되지 않으면 `slotchange` 가 영영 오지
104
+ 않아 나중에 붙는 자식을 못 보기 때문이다.
105
+
106
+ ### 계약 테스트
107
+
108
+ `tests/browser/lob-primitives.browser.test.ts` — **실제로 렌더해** 검증한다(23건).
109
+ `0` 이 `0` 으로 · 빈 값이 `—` 로 DOM 에 나오는가, 빈 슬롯 래퍼가 정말 접히는가,
110
+ 좁은/넓은 컨테이너에서 열 수가 바뀌는가. ★`isBlank()` 단위 테스트는 **술어**를 증명할 뿐
111
+ 컴포넌트를 증명하지 않는다 — 이 파일이 그 간격을 메운다.
112
+
113
+ `tests/lob-primitives.test.ts` — 색·크기 리터럴 0(예외는 헤어라인 `1px solid` 하나, 이유 명시) ·
114
+ `part` 노출 · `u-` 접두어 · export · **빈 값 대 0** · 빈 상태 두 variant 의 문구 분리.
115
+
116
+ ### 요구 사항
117
+
118
+ `@iyulab/components` **1.21.0 이상**(타입 스케일 · 여백/반경 상단 축) — `dependencies` 상향.
119
+ ⚠**폴백 리터럴은 아직 1.20.0 시트 값이다.** `tests/token-fallbacks.test.ts` 는 **설치된 시트**와
120
+ 대조하므로, 1.21.0 을 설치하면 그 테스트가 빨개진다 — **회귀가 아니라 갱신 부채**다.
121
+ 업그레이드 시 `node scripts/token-fallbacks.mjs` 로 다시 굽는다.
122
+ ⚠**1.20.0 이하에서는 폴백 값으로 렌더된다** — 깨지지는 않지만 *"조용히 예전 모양"* 이 된다.
123
+ 시트 부재 내성과 같은 기제다.
124
+
125
+ ## [0.8.2] - 2026-08-03
126
+
127
+ ### Fixed
128
+
129
+ - **`SidebarLayout` 토글 버튼에 테두리가 남던 문제** — 토글러는 `<u-button>` 을 variant 없이
130
+ 쓰고 있었고, `@iyulab/components` 의 기본값은 `solid` 이라 내부 버튼이
131
+ `--btn-border-color: var(--btn-color)` 로 **자기 테두리를 그렸다.**
132
+
133
+ ⚠**시트의 `.toggler { border: none }` 으로는 지워지지 않는다** — 그 규칙은 `<u-button>`
134
+ **호스트**에만 닿고, 선을 그리는 것은 그 섀도 루트 안의 `<button>` 이다. 그래서 호스트를
135
+ 재면 `0px` 로 보이고(테스트도 그렇게 통과한다) 화면에는 선이 남는다.
136
+
137
+ ⇒ 마크업에 `variant="ghost"` 를 명시해 컴포넌트가 스스로 투명하게 그리도록 했다
138
+ (실측: 내부 버튼 테두리 `1px solid <주색>` → `1px solid transparent`).
139
+ 중복이 된 `.toggler` 의 `border`·`background` 선언은 걷어냈다. `color: inherit` 는
140
+ 헤더 색을 따르게 하려는 의도이므로 유지한다.
141
+
142
+ 주색을 진하게 잡은 소비자일수록 눈에 띄던 자리다 — 테두리가 그 주색으로 그려졌다.
143
+
3
144
  ## [0.8.1] - 2026-08-02
4
145
 
5
146
  ### Fixed
@@ -0,0 +1,45 @@
1
+ import { StyledElement } from '../internals/StyledElement.js';
2
+ type ElementParts = 'host' | 'danger' | 'main';
3
+ /**
4
+ * 푸터 액션 바 — 상세·편집 화면 맨 아래의 같은 골격.
5
+ *
6
+ * ★**왜 이것이 라이브러리에 있나**: 화면마다 손으로 짜면 ⑴«저장»의 위치가 화면마다
7
+ * 달라지고 ⑵위험 액션(삭제)이 주 액션 **옆에** 놓여 오조작을 부르고 ⑶긴 폼에서
8
+ * 버튼을 찾아 끝까지 스크롤해야 한다. 셋 다 개별로는 사소해 보이지만 **오조작은
9
+ * 되돌릴 수 없다.**
10
+ *
11
+ * ```html
12
+ * <u-action-bar sticky>
13
+ * <u-button slot="danger" color="danger" variant="ghost">삭제</u-button>
14
+ * <u-button variant="ghost">취소</u-button>
15
+ * <u-button color="primary">저장</u-button>
16
+ * </u-action-bar>
17
+ * ```
18
+ *
19
+ * 배치 규칙: **위험 액션은 왼쪽 끝, 주 액션은 오른쪽 끝** — 거리가 안전장치다.
20
+ * 좁아지면 두 무리가 각자 한 줄을 갖고 **주 액션이 위로** 온다(엄지 위치에 위험 액션을
21
+ * 두지 않기 위해서다).
22
+ *
23
+ * 오버라이드: `part`(host·danger·main) + slot 치환.
24
+ */
25
+ export declare class ActionBar extends StyledElement<ElementParts> {
26
+ static styles: import('lit').CSSResultGroup[];
27
+ /**
28
+ * 화면 하단에 고정한다(`position: sticky`).
29
+ * ⚠스크롤 컨테이너 안에서만 의미가 있다 — 부모가 `overflow: auto` 여야 한다.
30
+ */
31
+ sticky: boolean;
32
+ /**
33
+ * 위험 액션 슬롯 배정 상태.
34
+ * ★CSS `:has()` 로는 알 수 없다 — `<slot>` 자신이 자식이라 항상 참이다
35
+ * (`internals/slotted.ts` 참조). 빈 래퍼가 남으면 주 액션이 가운데로 밀린다.
36
+ */
37
+ private hasDanger;
38
+ render(): import('lit-html').TemplateResult<1>;
39
+ }
40
+ declare global {
41
+ interface HTMLElementTagNameMap {
42
+ 'u-action-bar': ActionBar;
43
+ }
44
+ }
45
+ export {};
@@ -0,0 +1,32 @@
1
+ import e from "../_virtual/_@oxc-project_runtime@0.142.0/helpers/esm/decorateMetadata.js";
2
+ import t from "../_virtual/_@oxc-project_runtime@0.142.0/helpers/esm/decorate.js";
3
+ import { StyledElement as n } from "../internals/StyledElement.js";
4
+ import { slotHasContent as r } from "../internals/slotted.js";
5
+ import { styles as i } from "./ActionBar.styles.js";
6
+ import { html as a } from "lit";
7
+ import { customElement as o, property as s, state as c } from "lit/decorators.js";
8
+ //#region src/components/ActionBar.ts
9
+ var l = class extends n {
10
+ constructor(...e) {
11
+ super(...e), this.sticky = !1, this.hasDanger = !1;
12
+ }
13
+ static {
14
+ this.styles = [super.styles, i];
15
+ }
16
+ render() {
17
+ return a`
18
+ <div class="danger ${this.hasDanger ? "" : "empty"}" part="danger">
19
+ <slot name="danger"
20
+ @slotchange=${(e) => this.hasDanger = r(e.target)}
21
+ ></slot>
22
+ </div>
23
+ <div class="main" part="main"><slot></slot></div>
24
+ `;
25
+ }
26
+ };
27
+ t([s({
28
+ type: Boolean,
29
+ reflect: !0
30
+ }), e("design:type", Object)], l.prototype, "sticky", void 0), t([c(), e("design:type", Object)], l.prototype, "hasDanger", void 0), l = t([o("u-action-bar")], l);
31
+ //#endregion
32
+ export { l as ActionBar };
@@ -0,0 +1 @@
1
+ export declare const styles: import('lit').CSSResult;
@@ -0,0 +1,65 @@
1
+ import { css as e } from "lit";
2
+ //#region src/components/ActionBar.styles.ts
3
+ var t = e`
4
+ :host {
5
+ display: flex;
6
+ align-items: center;
7
+ gap: var(--u-space-sm, 8px);
8
+ padding: var(--u-space-md, 12px) 0;
9
+ /* 자기 폭으로 접힌다 — 아래 질의의 기준. 화면 폭이 아니라 자기 상황을 본다.
10
+ ⚠**컨테이너는 «자손»을 위한 것이다** — @container 안에서 :host 자신을 겨냥하면
11
+ 그 질의는 이 호스트가 아니라 **바깥의 다른 컨테이너**를 본다(실측으로 밟았다:
12
+ 360px 에서 접히지 않았다). 그래서 줄바꿈 자체는 여기서 항상 허용하고, 좁을 때의
13
+ «폭 채우기»만 자손 규칙으로 건다. */
14
+ flex-wrap: wrap;
15
+ container-type: inline-size;
16
+ }
17
+
18
+ /* 위험 액션(삭제 등)은 **왼쪽 끝**, 주 액션은 오른쪽 끝.
19
+ ★같은 무리에 두면 «저장»을 누르려다 «삭제»를 누른다 — 거리가 안전장치다.
20
+ 초안(R4)이 *"위험 액션은 시각적으로 분리"* 로 요구한 것이 이 배치다. */
21
+ .danger {
22
+ display: flex;
23
+ align-items: center;
24
+ gap: var(--u-space-sm, 8px);
25
+ margin-right: auto;
26
+ }
27
+ /* 빈 슬롯 래퍼는 접는다 — 슬롯 배정은 slotchange 로 추적한다(internals/slotted.ts).
28
+ ⚠CSS :has() 로는 못 한다: <slot> 자신이 자식이라 :has(*) 가 항상 참이다. */
29
+ .danger.empty {
30
+ display: none;
31
+ }
32
+
33
+ .main {
34
+ display: flex;
35
+ align-items: center;
36
+ gap: var(--u-space-sm, 8px);
37
+ margin-left: auto;
38
+ }
39
+
40
+ /* 화면 하단에 고정 — 긴 폼에서 저장 버튼을 찾아 스크롤하지 않아도 된다. */
41
+ :host([sticky]) {
42
+ position: sticky;
43
+ bottom: 0;
44
+ background-color: var(--u-bg-color, #FFFFFF);
45
+ border-top: 1px solid var(--u-border-color-weak, #EEEEEE);
46
+ padding: var(--u-space-md, 12px);
47
+ margin: 0 calc(-1 * var(--u-space-md, 12px));
48
+ }
49
+
50
+ /* 좁을 때: 두 무리가 각자 한 줄을 차지하고 주 액션이 위로 온다.
51
+ ⚠주 액션을 아래에 두면 위험 액션이 엄지 위치에 온다 — 순서를 뒤집는 이유다. */
52
+ @container (max-width: 420px) {
53
+ .main {
54
+ order: -1;
55
+ width: 100%;
56
+ margin-left: 0;
57
+ }
58
+ .danger {
59
+ width: 100%;
60
+ margin-right: 0;
61
+ }
62
+ }
63
+ `;
64
+ //#endregion
65
+ export { t as styles };
@@ -0,0 +1,52 @@
1
+ import { StyledElement } from '../internals/StyledElement.js';
2
+ type ElementParts = 'host' | 'icon' | 'title' | 'description' | 'actions';
3
+ /**
4
+ * 빈 상태 — 목록·검색 결과가 비었을 때.
5
+ *
6
+ * 🔴★**"데이터가 없다"와 "검색 결과가 없다"는 다른 사실이다.**
7
+ * 전자는 *"아직 만들지 않았다"* 이고 다음 행동은 **만들기**다.
8
+ * 후자는 *"조건에 맞는 것이 없다"* 이고 다음 행동은 **조건 바꾸기**다.
9
+ * 같은 문구로 보여 주면 사용자는 필터가 걸려 있는 줄 모르고 *"데이터가 사라졌다"* 로 읽는다.
10
+ * ⇒ `variant` 로 가르고, 기본 문구가 각각 다르다.
11
+ *
12
+ * ```html
13
+ * <u-empty-state variant="no-data" title="아직 주문이 없습니다">
14
+ * <u-button slot="actions">주문 등록</u-button>
15
+ * </u-empty-state>
16
+ *
17
+ * <u-empty-state variant="no-results"></u-empty-state>
18
+ * ```
19
+ *
20
+ * ⚠**기본 문구는 영어다** — 이 패키지는 범용 층이라 특정 언어를 기본값으로 가질 수 없다.
21
+ * 한국어는 `registerLocale('ko', …)` 로 소비자가 등록하고, 화면별로 덮으려면
22
+ * `title`·`description` 을 준다.
23
+ *
24
+ * ★이 줄은 «한국어다»라고 적혀 있었고 **같은 파일의 `locale` 프로퍼티 주석이 «영어다»라고
25
+ * 말하고 있었다** — 로케일 이주(0.9.0) 때 클래스 주석만 낡은 것이다. 이 JSDoc 은
26
+ * `dist/**.d.ts` 로 게시되므로, 소비자는 **사실과 반대인 문장**을 읽고 있었다.
27
+ */
28
+ export declare class EmptyState extends StyledElement<ElementParts> {
29
+ static styles: import('lit').CSSResultGroup[];
30
+ /** `no-data` = 아직 없음 / `no-results` = 조건에 맞는 것이 없음. */
31
+ variant: 'no-data' | 'no-results';
32
+ /** 제목. 비우면 variant 기본 문구. */
33
+ title: string;
34
+ /** 보조 설명. 비우면 variant 기본 문구. */
35
+ description: string;
36
+ /**
37
+ * 언어 태그. 비우면 `setDefaultLocale()` 값, 그것도 없으면 **영어**.
38
+ * ⚠기본 문구는 영어다 — 이 패키지는 범용 층이라 특정 언어를 기본값으로 가질 수 없다.
39
+ * 한국어는 `registerLocale('ko', …)` 로 소비자가 등록한다.
40
+ */
41
+ locale: string;
42
+ /** 액션 슬롯 배정 상태 — CSS `:has()` 로는 알 수 없다(`internals/slotted.ts` 참조). */
43
+ private hasActions;
44
+ private get defaults();
45
+ render(): import('lit-html').TemplateResult<1>;
46
+ }
47
+ declare global {
48
+ interface HTMLElementTagNameMap {
49
+ 'u-empty-state': EmptyState;
50
+ }
51
+ }
52
+ export {};
@@ -0,0 +1,47 @@
1
+ import e from "../_virtual/_@oxc-project_runtime@0.142.0/helpers/esm/decorateMetadata.js";
2
+ import t from "../_virtual/_@oxc-project_runtime@0.142.0/helpers/esm/decorate.js";
3
+ import { StyledElement as n } from "../internals/StyledElement.js";
4
+ import { getLocaleStrings as r } from "../internals/locale.js";
5
+ import { slotHasContent as i } from "../internals/slotted.js";
6
+ import { styles as a } from "./EmptyState.styles.js";
7
+ import { html as o, nothing as s } from "lit";
8
+ import { customElement as c, property as l, state as u } from "lit/decorators.js";
9
+ //#region src/components/EmptyState.ts
10
+ var d = class extends n {
11
+ constructor(...e) {
12
+ super(...e), this.variant = "no-data", this.title = "", this.description = "", this.locale = "", this.hasActions = !1;
13
+ }
14
+ static {
15
+ this.styles = [super.styles, a];
16
+ }
17
+ get defaults() {
18
+ let e = r(this.locale || void 0);
19
+ return this.variant === "no-results" ? {
20
+ icon: "🔍",
21
+ title: e.noResultsTitle,
22
+ description: e.noResultsDescription
23
+ } : {
24
+ icon: "📄",
25
+ title: e.noDataTitle,
26
+ description: e.noDataDescription
27
+ };
28
+ }
29
+ render() {
30
+ let e = this.defaults, t = this.description || e.description;
31
+ return o`
32
+ <div class="icon" part="icon" aria-hidden="true">
33
+ <slot name="icon">${e.icon}</slot>
34
+ </div>
35
+ <p class="title" part="title">${this.title || e.title}</p>
36
+ ${t ? o`<p class="description" part="description">${t}</p>` : s}
37
+ <div class="actions ${this.hasActions ? "" : "empty"}" part="actions">
38
+ <slot name="actions"
39
+ @slotchange=${(e) => this.hasActions = i(e.target)}
40
+ ></slot>
41
+ </div>
42
+ `;
43
+ }
44
+ };
45
+ t([l({ type: String }), e("design:type", String)], d.prototype, "variant", void 0), t([l({ type: String }), e("design:type", Object)], d.prototype, "title", void 0), t([l({ type: String }), e("design:type", Object)], d.prototype, "description", void 0), t([l({ type: String }), e("design:type", Object)], d.prototype, "locale", void 0), t([u(), e("design:type", Object)], d.prototype, "hasActions", void 0), d = t([c("u-empty-state")], d);
46
+ //#endregion
47
+ export { d as EmptyState };
@@ -0,0 +1 @@
1
+ export declare const styles: import('lit').CSSResult;
@@ -0,0 +1,51 @@
1
+ import { css as e } from "lit";
2
+ //#region src/components/EmptyState.styles.ts
3
+ var t = e`
4
+ :host {
5
+ display: flex;
6
+ flex-direction: column;
7
+ align-items: center;
8
+ text-align: center;
9
+ padding: var(--u-space-4xl, 40px) var(--u-space-xl, 20px);
10
+ color: var(--u-txt-color-weak, #757575);
11
+ }
12
+
13
+ .icon {
14
+ /* 타입 스케일 밖이다 — 글자가 아니라 장식이라서. 대신 소비자 훅을 연다
15
+ (아이콘을 SVG 로 갈아 끼우는 소비자가 크기를 맞출 수 있어야 한다). */
16
+ font-size: var(--empty-state-icon-size, 32px);
17
+ line-height: 1;
18
+ margin-bottom: var(--u-space-md, 12px);
19
+ opacity: 0.7;
20
+ }
21
+
22
+ .title {
23
+ margin: 0;
24
+ font-size: var(--u-text-subtitle-size, 16px);
25
+ font-weight: var(--u-text-subtitle-weight, 600);
26
+ line-height: var(--u-text-subtitle-leading, 1.5);
27
+ /* 제목은 본문 색이다 — 빈 상태 전체를 흐리게 만들면 "로딩 중"으로 오독된다. */
28
+ color: var(--u-txt-color, #212121);
29
+ }
30
+
31
+ .description {
32
+ margin: var(--u-space-2xs, 4px) 0 0;
33
+ font-size: var(--u-text-caption-size, 12px);
34
+ line-height: var(--u-text-caption-leading, 1.5);
35
+ }
36
+
37
+ .actions {
38
+ display: flex;
39
+ gap: var(--u-space-sm, 8px);
40
+ margin-top: var(--u-space-lg, 16px);
41
+ }
42
+ /* 빈 슬롯 래퍼는 접는다.
43
+ ⚠**CSS ':has()' 로는 못 한다** — <slot> 자신이 자식 요소라 ':has(*)' 가 항상 참이다
44
+ (실브라우저 테스트로 확인했다. 소스 검사와 jsdom 은 둘 다 통과시킨다).
45
+ 배정 상태는 'slotchange' 로 추적해 '.empty' 클래스로 내려온다 — internals/slotted.ts */
46
+ .actions.empty {
47
+ display: none;
48
+ }
49
+ `;
50
+ //#endregion
51
+ export { t as styles };
@@ -0,0 +1,43 @@
1
+ import { StyledElement } from '../internals/StyledElement.js';
2
+ type ElementParts = 'host' | 'header' | 'title' | 'actions' | 'body';
3
+ /**
4
+ * 그룹 박스 — 제목이 붙은 카드. LOB 상세 화면의 기본 단위다.
5
+ *
6
+ * ★**왜 `u-card` 가 아니라 이것인가**: `u-card` 는 면(surface)만 준다. LOB 화면에서 반복되는
7
+ * 것은 *면 + 제목 + 우측 액션 슬롯* 이라는 **묶음**이고, 그 묶음을 소비자가 매번 조립하면
8
+ * 제목 크기·여백·구분선이 화면마다 달라진다. 카드 30여 곳을 손으로 그리던 소비앱에서
9
+ * 실제로 그렇게 갈라졌다.
10
+ *
11
+ * ```html
12
+ * <u-group-box title="수금">
13
+ * <a slot="actions" href="/receivables">미수금·결제</a>
14
+ * …본문…
15
+ * </u-group-box>
16
+ * ```
17
+ *
18
+ * 오버라이드: `part`(host·header·title·actions·body) + slot 치환.
19
+ * `divider` 속성으로 제목과 본문 사이 구분선을 켠다(기본 꺼짐 — 선이 많으면 화면이 시끄럽다).
20
+ */
21
+ export declare class GroupBox extends StyledElement<ElementParts> {
22
+ static styles: import('lit').CSSResultGroup[];
23
+ /** 카드 제목. 비우면 헤더 자체를 렌더하지 않는다. */
24
+ title: string;
25
+ /** 제목과 본문 사이에 구분선을 넣는다. */
26
+ divider: boolean;
27
+ /** 본문 여백을 없앤다 — 표를 카드 가장자리까지 붙일 때. */
28
+ flush: boolean;
29
+ /**
30
+ * 액션 슬롯 배정 상태.
31
+ * ★종전에는 'render()' 안에서 'this.querySelector()' 로 라이트 DOM 을 읽었다. 그것은
32
+ * **자식이 늦게 붙으면 못 본다** — 소비자가 'requestUpdate()' 를 부르지 않는 한
33
+ * 헤더가 통째로 사라진 채로 남는다. 슬롯 배정은 'slotchange' 가 알려 준다.
34
+ */
35
+ private hasActions;
36
+ render(): import('lit-html').TemplateResult<1>;
37
+ }
38
+ declare global {
39
+ interface HTMLElementTagNameMap {
40
+ 'u-group-box': GroupBox;
41
+ }
42
+ }
43
+ export {};
@@ -0,0 +1,33 @@
1
+ import e from "../_virtual/_@oxc-project_runtime@0.142.0/helpers/esm/decorateMetadata.js";
2
+ import t from "../_virtual/_@oxc-project_runtime@0.142.0/helpers/esm/decorate.js";
3
+ import { StyledElement as n } from "../internals/StyledElement.js";
4
+ import { slotHasContent as r } from "../internals/slotted.js";
5
+ import { styles as i } from "./GroupBox.styles.js";
6
+ import { html as a } from "lit";
7
+ import { customElement as o, property as s, state as c } from "lit/decorators.js";
8
+ //#region src/components/GroupBox.ts
9
+ var l = class extends n {
10
+ constructor(...e) {
11
+ super(...e), this.title = "", this.divider = !1, this.flush = !1, this.hasActions = !1;
12
+ }
13
+ static {
14
+ this.styles = [super.styles, i];
15
+ }
16
+ render() {
17
+ let e = !!this.title || this.hasActions;
18
+ return a`
19
+ <div class="header ${this.divider ? "divider" : ""} ${e ? "" : "empty"}" part="header">
20
+ <h3 class="title" part="title">${this.title}</h3>
21
+ <div class="actions ${this.hasActions ? "" : "empty"}" part="actions">
22
+ <slot name="actions"
23
+ @slotchange=${(e) => this.hasActions = r(e.target)}
24
+ ></slot>
25
+ </div>
26
+ </div>
27
+ <div class="body ${this.flush ? "flush" : ""}" part="body"><slot></slot></div>
28
+ `;
29
+ }
30
+ };
31
+ t([s({ type: String }), e("design:type", Object)], l.prototype, "title", void 0), t([s({ type: Boolean }), e("design:type", Object)], l.prototype, "divider", void 0), t([s({ type: Boolean }), e("design:type", Object)], l.prototype, "flush", void 0), t([c(), e("design:type", Object)], l.prototype, "hasActions", void 0), l = t([o("u-group-box")], l);
32
+ //#endregion
33
+ export { l as GroupBox };
@@ -0,0 +1 @@
1
+ export declare const styles: import('lit').CSSResult;
@@ -0,0 +1,90 @@
1
+ import { css as e } from "lit";
2
+ //#region src/components/GroupBox.styles.ts
3
+ var t = e`
4
+ :host {
5
+ display: block;
6
+ /* 아래 @container 질의의 기준 — 카드는 «화면»이 아니라 «자기 폭»으로 접힌다.
7
+ 사이드바가 열린 1280px 화면의 좁은 본문에서도, 넓은 본문을 가진 태블릿에서도
8
+ 같은 판단을 하려면 기준이 자기 폭이어야 한다. */
9
+ container-type: inline-size;
10
+ background-color: var(--u-panel-bg-color, #FFFFFF);
11
+ border: 1px solid var(--u-border-color-weak, #EEEEEE);
12
+ /* 면(surface) 단 — 컨트롤과 같은 반경을 쓰면 큰 사각형이 각져 보인다. */
13
+ border-radius: var(--u-radius-2xl, 12px);
14
+ box-shadow: var(--u-shadow-sm, 0 1px 3px rgba(0, 0, 0, 0.08), 0 1px 1px rgba(0, 0, 0, 0.04));
15
+ }
16
+
17
+ .header {
18
+ display: flex;
19
+ align-items: center;
20
+ gap: var(--u-space-sm, 8px);
21
+ padding: var(--u-space-xl, 20px) var(--u-space-xl, 20px) 0;
22
+ }
23
+ /* 제목도 액션도 없으면 헤더를 접는다. ★DOM 에서 빼지 않고 접는 이유 =
24
+ 슬롯 배정을 'slotchange' 로 알아야 하는데, 슬롯이 렌더되지 않으면 그 이벤트가
25
+ 영영 오지 않는다(자식을 나중에 붙이는 소비자가 헤더를 못 얻는다). */
26
+ .header.empty {
27
+ display: none;
28
+ }
29
+
30
+ .header.divider {
31
+ padding-bottom: var(--u-space-md, 12px);
32
+ border-bottom: 1px solid var(--u-border-color-weak, #EEEEEE);
33
+ }
34
+
35
+ .title {
36
+ margin: 0;
37
+ flex: 1 1 auto;
38
+ min-width: 0;
39
+ font-size: var(--u-text-subtitle-size, 16px);
40
+ font-weight: var(--u-text-subtitle-weight, 600);
41
+ line-height: var(--u-text-subtitle-leading, 1.5);
42
+ letter-spacing: var(--u-text-subtitle-tracking, 0);
43
+ color: var(--u-txt-color, #212121);
44
+ }
45
+
46
+ .actions {
47
+ flex: 0 0 auto;
48
+ display: flex;
49
+ align-items: center;
50
+ gap: var(--u-space-sm, 8px);
51
+ font-size: var(--u-text-label-size, 13px);
52
+ font-weight: var(--u-text-label-weight, 600);
53
+ }
54
+ /* 빈 슬롯 래퍼는 접는다.
55
+ ⚠**CSS ':has()' 로는 못 한다** — <slot> 자신이 자식 요소라 ':has(*)' 가 항상 참이다
56
+ (실브라우저 테스트로 확인했다. 소스 검사와 jsdom 은 둘 다 통과시킨다).
57
+ 배정 상태는 'slotchange' 로 추적해 '.empty' 클래스로 내려온다 — internals/slotted.ts */
58
+ .actions.empty {
59
+ display: none;
60
+ }
61
+
62
+ .body {
63
+ padding: var(--u-space-xl, 20px);
64
+ }
65
+
66
+ /* 좁을 때: 헤더의 툴바(검색·필터)가 제목 아래로 내려가고 폭을 채운다.
67
+ ⚠**제목과 툴바가 한 줄에 남으면 둘 다 읽히지 않는다** — 제목은 잘리고 입력은
68
+ 손가락보다 좁아진다. 초안(R4)이 *"좁은 화면에서 접히는 규칙을 라이브러리가 정한다"*
69
+ 로 요구한 것이 이 자리다. 480px 는 «제목 + 입력»이 함께 설 수 있는 하한이다. */
70
+ @container (max-width: 480px) {
71
+ .header {
72
+ flex-wrap: wrap;
73
+ }
74
+ .actions {
75
+ width: 100%;
76
+ }
77
+ }
78
+ /* 표를 카드 가장자리까지 붙일 때. 헤더가 있으면 위 여백만 남긴다 —
79
+ 0 으로 만들면 제목과 표가 붙어 읽기 어려워진다. */
80
+ .body.flush {
81
+ padding: 0;
82
+ }
83
+ /* 헤더가 접혀 있으면 위 여백도 없다 — 인접 선택자는 'display:none' 을 보지 못하므로
84
+ 클래스로 갈라야 한다. */
85
+ .header:not(.empty) + .body.flush {
86
+ padding-top: var(--u-space-md, 12px);
87
+ }
88
+ `;
89
+ //#endregion
90
+ export { t as styles };
@@ -0,0 +1,45 @@
1
+ import { StyledElement } from '../internals/StyledElement.js';
2
+ type ElementParts = 'host' | 'label' | 'value';
3
+ /** 값이 "아직 없음"인가 — `0` 과 `false` 는 **값이다**. */
4
+ export declare function isBlank(v: unknown): boolean;
5
+ /**
6
+ * 정보 필드 — 읽기 전용 라벨-값 한 쌍.
7
+ *
8
+ * 🔴★**"아직 없음"과 "0"은 다른 사실이다.**
9
+ * `null`·`undefined`·빈 문자열 → **`—`**
10
+ * `0`·`false`·`'0'` → **그 값 그대로**
11
+ *
12
+ * ⚠이 규칙을 사람이 기억하는 방식으로 두면 **반드시 어긋난다.** 실제로 한 소비앱에서
13
+ * *"부수가 0인 주문"* 과 *"부수가 아직 안 정해진 주문"* 이 화면에서 똑같이 `—` 로 보였고,
14
+ * 그 둘은 업무적으로 전혀 다른 상태였다. 그래서 규칙을 컴포넌트가 소유한다.
15
+ *
16
+ * ```html
17
+ * <u-info-field label="부수" .value=${order.quantity} numeric></u-info-field>
18
+ * <u-info-field label="거래처">동서인쇄</u-info-field> <!-- 슬롯이 value 를 이긴다 -->
19
+ * ```
20
+ */
21
+ export declare class InfoField extends StyledElement<ElementParts> {
22
+ static styles: import('lit').CSSResultGroup[];
23
+ /** 필드 이름. */
24
+ label: string;
25
+ /**
26
+ * 값. `null`/`undefined`/빈 문자열이면 `blank` 문구로 대체된다.
27
+ * ⚠`0`·`false` 는 **대체되지 않는다** — 값이기 때문이다.
28
+ */
29
+ value?: unknown;
30
+ /** "아직 없음"을 나타낼 문구. */
31
+ blank: string;
32
+ /**
33
+ * 숫자 값 — 우정렬 + 고정폭 숫자(`tabular-nums`).
34
+ * ★자릿수가 세로로 맞아야 크기 비교가 눈으로 된다. LOB 화면은 금액·수량이 절반이다.
35
+ */
36
+ numeric: boolean;
37
+ private get hasSlotted();
38
+ render(): import('lit-html').TemplateResult<1>;
39
+ }
40
+ declare global {
41
+ interface HTMLElementTagNameMap {
42
+ 'u-info-field': InfoField;
43
+ }
44
+ }
45
+ export {};
@@ -0,0 +1,33 @@
1
+ import e from "../_virtual/_@oxc-project_runtime@0.142.0/helpers/esm/decorateMetadata.js";
2
+ import t from "../_virtual/_@oxc-project_runtime@0.142.0/helpers/esm/decorate.js";
3
+ import { StyledElement as n } from "../internals/StyledElement.js";
4
+ import { styles as r } from "./InfoField.styles.js";
5
+ import { html as i } from "lit";
6
+ import { customElement as a, property as o } from "lit/decorators.js";
7
+ //#region src/components/InfoField.ts
8
+ function s(e) {
9
+ return e == null || typeof e == "string" && e.trim() === "";
10
+ }
11
+ var c = class extends n {
12
+ constructor(...e) {
13
+ super(...e), this.label = "", this.blank = "—", this.numeric = !1;
14
+ }
15
+ static {
16
+ this.styles = [super.styles, r];
17
+ }
18
+ get hasSlotted() {
19
+ return this.childNodes.length > 0 && [...this.childNodes].some((e) => e.nodeType !== Node.TEXT_NODE || (e.textContent ?? "").trim() !== "");
20
+ }
21
+ render() {
22
+ let e = !this.hasSlotted && s(this.value);
23
+ return i`
24
+ <div class="label" part="label">${this.label}</div>
25
+ <div class="value ${this.numeric ? "numeric" : ""} ${e ? "blank" : ""}" part="value">
26
+ ${this.hasSlotted ? i`<slot></slot>` : e ? this.blank : String(this.value)}
27
+ </div>
28
+ `;
29
+ }
30
+ };
31
+ t([o({ type: String }), e("design:type", Object)], c.prototype, "label", void 0), t([o({ attribute: !1 }), e("design:type", Object)], c.prototype, "value", void 0), t([o({ type: String }), e("design:type", Object)], c.prototype, "blank", void 0), t([o({ type: Boolean }), e("design:type", Object)], c.prototype, "numeric", void 0), c = t([a("u-info-field")], c);
32
+ //#endregion
33
+ export { c as InfoField, s as isBlank };
@@ -0,0 +1 @@
1
+ export declare const styles: import('lit').CSSResult;
@@ -0,0 +1,40 @@
1
+ import { css as e } from "lit";
2
+ //#region src/components/InfoField.styles.ts
3
+ var t = e`
4
+ :host {
5
+ display: block;
6
+ min-width: 0;
7
+ }
8
+
9
+ .label {
10
+ font-size: var(--u-text-caption-size, 12px);
11
+ font-weight: var(--u-text-caption-weight, 400);
12
+ line-height: var(--u-text-caption-leading, 1.5);
13
+ color: var(--u-txt-color-weak, #757575);
14
+ margin-bottom: var(--u-space-3xs, 2px);
15
+ }
16
+
17
+ .value {
18
+ font-size: var(--u-text-body-size, 14px);
19
+ font-weight: var(--u-text-label-weight, 600);
20
+ line-height: var(--u-text-body-leading, 1.6);
21
+ color: var(--u-txt-color, #212121);
22
+ /* 긴 값(주소·비고)이 그리드 열을 밀어내지 않게 한다. */
23
+ overflow-wrap: anywhere;
24
+ }
25
+
26
+ .value.numeric {
27
+ text-align: right;
28
+ font-variant-numeric: tabular-nums;
29
+ }
30
+
31
+ /* "아직 없음"은 값보다 약하게 — 다만 **읽을 수 있어야 한다.**
32
+ ⚠더 흐리게 만들고 싶은 유혹이 있는데, 이 자리가 안 읽히면 사용자는 빈 칸인지
33
+ 로딩 중인지 구별하지 못한다. */
34
+ .value.blank {
35
+ color: var(--u-txt-color-weak, #757575);
36
+ font-weight: var(--u-text-body-weight, 400);
37
+ }
38
+ `;
39
+ //#endregion
40
+ export { t as styles };
@@ -0,0 +1,31 @@
1
+ import { StyledElement } from '../internals/StyledElement.js';
2
+ type ElementParts = 'host' | 'grid';
3
+ /**
4
+ * 정보 섹션 — `u-info-field` 들의 반응형 그리드.
5
+ *
6
+ * ★**열 수를 소비자가 세지 않는다.** 손으로 짠 라벨-값 그리드의 실패는 대부분
7
+ * *"열 수를 화면마다 다르게 하드코딩"* 이다 — 그러면 태블릿에서만 칸이 어긋나고,
8
+ * 그 화면을 아무도 안 열어 봐서 오래 남는다. 여기서는 **컨테이너 폭**이 열 수를 정한다
9
+ * (미디어 쿼리가 아니라 `auto-fit` — 사이드 패널 안에 들어가도 맞는다).
10
+ *
11
+ * ```html
12
+ * <u-info-section>
13
+ * <u-info-field label="파트" value="일반"></u-info-field>
14
+ * <u-info-field label="부수" .value=${0} numeric></u-info-field>
15
+ * </u-info-section>
16
+ * ```
17
+ *
18
+ * `min` 으로 한 칸의 최소 폭을 조절한다(기본 160px). 라벨이 긴 도메인은 값을 올린다.
19
+ */
20
+ export declare class InfoSection extends StyledElement<ElementParts> {
21
+ static styles: import('lit').CSSResultGroup[];
22
+ /** 한 칸의 최소 폭(px). 이 값보다 좁아지면 열 수가 줄어든다. */
23
+ min: number;
24
+ render(): import('lit-html').TemplateResult<1>;
25
+ }
26
+ declare global {
27
+ interface HTMLElementTagNameMap {
28
+ 'u-info-section': InfoSection;
29
+ }
30
+ }
31
+ export {};
@@ -0,0 +1,23 @@
1
+ import e from "../_virtual/_@oxc-project_runtime@0.142.0/helpers/esm/decorateMetadata.js";
2
+ import t from "../_virtual/_@oxc-project_runtime@0.142.0/helpers/esm/decorate.js";
3
+ import { StyledElement as n } from "../internals/StyledElement.js";
4
+ import { styles as r } from "./InfoSection.styles.js";
5
+ import { html as i } from "lit";
6
+ import { customElement as a, property as o } from "lit/decorators.js";
7
+ //#region src/components/InfoSection.ts
8
+ var s = class extends n {
9
+ constructor(...e) {
10
+ super(...e), this.min = 160;
11
+ }
12
+ static {
13
+ this.styles = [super.styles, r];
14
+ }
15
+ render() {
16
+ return i`
17
+ <div class="grid" part="grid" style="--info-min: ${this.min}px"><slot></slot></div>
18
+ `;
19
+ }
20
+ };
21
+ t([o({ type: Number }), e("design:type", Object)], s.prototype, "min", void 0), s = t([a("u-info-section")], s);
22
+ //#endregion
23
+ export { s as InfoSection };
@@ -0,0 +1 @@
1
+ export declare const styles: import('lit').CSSResult;
@@ -0,0 +1,18 @@
1
+ import { css as e } from "lit";
2
+ //#region src/components/InfoSection.styles.ts
3
+ var t = e`
4
+ :host {
5
+ display: block;
6
+ }
7
+
8
+ /* ★'auto-fit' + 'minmax' — **컨테이너 폭**이 열 수를 정한다.
9
+ 미디어 쿼리로 열 수를 고정하면 사이드 패널(좁은 컨테이너) 안에서 화면 폭 기준으로
10
+ 계산돼 칸이 짓눌린다. 이 방식은 어디에 놓여도 자기 폭을 본다. */
11
+ .grid {
12
+ display: grid;
13
+ grid-template-columns: repeat(auto-fit, minmax(var(--info-min, 160px), 1fr));
14
+ gap: var(--u-space-lg, 16px) var(--u-space-xl, 20px);
15
+ }
16
+ `;
17
+ //#endregion
18
+ export { t as styles };
@@ -0,0 +1,53 @@
1
+ import { StyledElement } from '../internals/StyledElement.js';
2
+ type ElementParts = 'host' | 'back' | 'heading' | 'title' | 'subtitle' | 'status' | 'actions';
3
+ /**
4
+ * 페이지 헤더 — 모든 LOB 화면 최상단의 같은 골격.
5
+ *
6
+ * ★**왜 이것이 라이브러리에 있나**: 이 골격은 화면마다 손으로 다시 짜인다. 손으로 짜면
7
+ * ⑴제목 크기가 화면마다 달라지고 ⑵액션 정렬이 미묘하게 어긋나고 ⑶좁은 화면 규칙이
8
+ * 빠진다. 셋 다 개별로는 사소해 보이지만, 화면을 옮겨 다니는 사용자에게는 *"제품이
9
+ * 하나로 만들어지지 않았다"* 로 읽힌다.
10
+ *
11
+ * ```html
12
+ * <u-page-header title="주문 G-2026-I-0629" subtitle="2026-02-24 접수" back="/orders">
13
+ * <span slot="status"><!-- 배지 --></span>
14
+ * <span slot="actions"><!-- 버튼 --></span>
15
+ * </u-page-header>
16
+ * ```
17
+ *
18
+ * 오버라이드: `part`(host·back·heading·title·subtitle·status·actions) + slot 치환.
19
+ * 값은 전부 토큰을 경유한다 — 리터럴을 두면 소비자가 밀도를 바꿀 수 없다.
20
+ */
21
+ export declare class PageHeader extends StyledElement<ElementParts> {
22
+ static styles: import('lit').CSSResultGroup[];
23
+ /** 페이지 제목. 타입 스케일의 `display` 단을 쓴다. */
24
+ title: string;
25
+ /** 제목 아래 보조 설명. 없으면 렌더하지 않는다. */
26
+ subtitle?: string;
27
+ /**
28
+ * 뒤로가기 링크 주소. 주면 제목 왼쪽에 `← 목록` 형태로 나온다.
29
+ * ⚠텍스트는 `backLabel` 또는 locale 레지스트리로 바꾼다.
30
+ */
31
+ back?: string;
32
+ /**
33
+ * 뒤로가기 링크 문구. 비우면 locale 레지스트리 값, 그것도 없으면 **영어**(`Back`).
34
+ * ⚠이 패키지는 범용 층이라 특정 언어를 기본값으로 가질 수 없다 —
35
+ * 한국어는 `registerLocale('ko', { back: '뒤로' })` 로 소비자가 등록한다.
36
+ */
37
+ backLabel: string;
38
+ /** 언어 태그. 비우면 `setDefaultLocale()` 값, 그것도 없으면 영어. */
39
+ locale: string;
40
+ /**
41
+ * 슬롯 배정 상태. ★CSS 로는 알 수 없다 — `<slot>` 자신이 자식이라 `:has(*)` 가
42
+ * 항상 참이다(실브라우저로 확인). 빈 배지 자리가 남으면 제목 위치가 화면마다 달라진다.
43
+ */
44
+ private hasStatus;
45
+ private hasActions;
46
+ render(): import('lit-html').TemplateResult<1>;
47
+ }
48
+ declare global {
49
+ interface HTMLElementTagNameMap {
50
+ 'u-page-header': PageHeader;
51
+ }
52
+ }
53
+ export {};
@@ -0,0 +1,44 @@
1
+ import e from "../_virtual/_@oxc-project_runtime@0.142.0/helpers/esm/decorateMetadata.js";
2
+ import t from "../_virtual/_@oxc-project_runtime@0.142.0/helpers/esm/decorate.js";
3
+ import { StyledElement as n } from "../internals/StyledElement.js";
4
+ import { getLocaleStrings as r } from "../internals/locale.js";
5
+ import { slotHasContent as i } from "../internals/slotted.js";
6
+ import { styles as a } from "./PageHeader.styles.js";
7
+ import { html as o, nothing as s } from "lit";
8
+ import { customElement as c, property as l, state as u } from "lit/decorators.js";
9
+ //#region src/components/PageHeader.ts
10
+ var d = class extends n {
11
+ constructor(...e) {
12
+ super(...e), this.title = "", this.backLabel = "", this.locale = "", this.hasStatus = !1, this.hasActions = !1;
13
+ }
14
+ static {
15
+ this.styles = [super.styles, a];
16
+ }
17
+ render() {
18
+ return o`
19
+ ${this.back ? o`<a class="back" part="back" href=${this.back}>${this.backLabel || r(this.locale || void 0).back}</a>` : s}
20
+ <div class="heading" part="heading">
21
+ <div class="title-row">
22
+ <h1 class="title" part="title">${this.title}</h1>
23
+ <span class="status ${this.hasStatus ? "" : "empty"}" part="status">
24
+ <slot name="status"
25
+ @slotchange=${(e) => this.hasStatus = i(e.target)}
26
+ ></slot>
27
+ </span>
28
+ </div>
29
+ ${this.subtitle ? o`<p class="subtitle" part="subtitle">${this.subtitle}</p>` : s}
30
+ </div>
31
+ <div class="actions ${this.hasActions ? "" : "empty"}" part="actions">
32
+ <slot name="actions"
33
+ @slotchange=${(e) => this.hasActions = i(e.target)}
34
+ ></slot>
35
+ </div>
36
+ `;
37
+ }
38
+ };
39
+ t([l({ type: String }), e("design:type", Object)], d.prototype, "title", void 0), t([l({ type: String }), e("design:type", String)], d.prototype, "subtitle", void 0), t([l({ type: String }), e("design:type", String)], d.prototype, "back", void 0), t([l({
40
+ type: String,
41
+ attribute: "back-label"
42
+ }), e("design:type", Object)], d.prototype, "backLabel", void 0), t([l({ type: String }), e("design:type", Object)], d.prototype, "locale", void 0), t([u(), e("design:type", Object)], d.prototype, "hasStatus", void 0), t([u(), e("design:type", Object)], d.prototype, "hasActions", void 0), d = t([c("u-page-header")], d);
43
+ //#endregion
44
+ export { d as PageHeader };
@@ -0,0 +1 @@
1
+ export declare const styles: import('lit').CSSResult;
@@ -0,0 +1,98 @@
1
+ import { css as e } from "lit";
2
+ //#region src/components/PageHeader.styles.ts
3
+ var t = e`
4
+ :host {
5
+ display: flex;
6
+ align-items: flex-start;
7
+ gap: var(--u-space-md, 12px);
8
+ flex-wrap: wrap;
9
+ margin-bottom: var(--u-space-2xl, 24px);
10
+ /* 아래 @container 질의의 기준 — 이 컴포넌트는 «화면»이 아니라 «자기 폭»으로 접힌다. */
11
+ container-type: inline-size;
12
+ }
13
+
14
+ .heading {
15
+ flex: 1 1 auto;
16
+ min-width: 0; /* 긴 제목이 액션을 밀어내지 않게 — 줄바꿈이 아니라 축소가 먼저다 */
17
+ }
18
+
19
+ .title-row {
20
+ display: flex;
21
+ align-items: center;
22
+ gap: var(--u-space-sm, 8px);
23
+ flex-wrap: wrap;
24
+ }
25
+
26
+ .title {
27
+ margin: 0;
28
+ font-size: var(--u-text-display-size, 26px);
29
+ font-weight: var(--u-text-display-weight, 700);
30
+ line-height: var(--u-text-display-leading, 1.4);
31
+ letter-spacing: var(--u-text-display-tracking, -0.02em);
32
+ color: var(--u-txt-color, #212121);
33
+ }
34
+
35
+ .subtitle {
36
+ margin: var(--u-space-2xs, 4px) 0 0;
37
+ font-size: var(--u-text-caption-size, 12px);
38
+ font-weight: var(--u-text-caption-weight, 400);
39
+ line-height: var(--u-text-caption-leading, 1.5);
40
+ color: var(--u-txt-color-weak, #757575);
41
+ }
42
+
43
+ .back {
44
+ flex: 0 0 auto;
45
+ align-self: center;
46
+ font-size: var(--u-text-label-size, 13px);
47
+ font-weight: var(--u-text-label-weight, 600);
48
+ color: var(--u-link-txt-color, #1565C0);
49
+ text-decoration: none;
50
+ }
51
+ .back::before {
52
+ content: '←';
53
+ margin-right: var(--u-space-3xs, 2px);
54
+ }
55
+ .back:hover {
56
+ text-decoration: underline;
57
+ }
58
+
59
+ /* 상태 슬롯이 비어 있으면 자리를 차지하지 않는다 — 빈 배지 자리가 제목을 밀면
60
+ 같은 화면인데 제목 위치가 달라 보인다. */
61
+ /* 빈 슬롯 래퍼는 접는다.
62
+ ⚠**CSS ':has()' 로는 못 한다** — <slot> 자신이 자식 요소라 ':has(*)' 가 항상 참이다
63
+ (실브라우저 테스트로 확인했다. 소스 검사와 jsdom 은 둘 다 통과시킨다).
64
+ 배정 상태는 'slotchange' 로 추적해 '.empty' 클래스로 내려온다 — internals/slotted.ts */
65
+ .status.empty {
66
+ display: none;
67
+ }
68
+
69
+ .actions {
70
+ flex: 0 0 auto;
71
+ display: flex;
72
+ align-items: center;
73
+ gap: var(--u-space-sm, 8px);
74
+ margin-left: auto;
75
+ }
76
+ /* 빈 액션 래퍼는 접는다(슬롯 배정은 'slotchange' 로 추적 — internals/slotted.ts). */
77
+ .actions.empty {
78
+ display: none;
79
+ }
80
+
81
+ /* 좁을 때: 액션이 제목 아래로 내려가고 **왼쪽 정렬**이 된다.
82
+ ★오른쪽 정렬을 유지하면 손가락이 닿기 어려운 쪽으로 몰린다.
83
+
84
+ 🔴**«화면»이 아니라 «자기 폭»을 본다(@container).** 종전에는 @media 였고, 그러면
85
+ 이 프리미티브가 **자기가 얼마나 좁은지와 무관하게** 접혔다 — 셸 안에서 사이드바가 열려
86
+ 본문이 500px 인 1280px 화면에서는 접히지 않고, 반대로 넓은 본문을 가진 좁은 화면에서는
87
+ 불필요하게 접혔다. 컴포넌트가 스스로 판단할 수 있는 것은 **자기 컨테이너 폭**뿐이다.
88
+ ⚠container-type: inline-size 는 인라인 축 containment 를 만든다 — 이 호스트는 블록
89
+ 레벨이고 자식이 호스트 폭에 기대므로 무해하다(폭별 회귀 테스트가 감시한다). */
90
+ @container (max-width: 640px) {
91
+ .actions {
92
+ margin-left: 0;
93
+ width: 100%;
94
+ }
95
+ }
96
+ `;
97
+ //#endregion
98
+ export { t as styles };
package/dist/index.d.ts CHANGED
@@ -4,5 +4,13 @@ export type * from './types/AppOptions';
4
4
  export type * from './types/AuthConfig';
5
5
  export type { SidebarPermissionGuard } from './layouts/SidebarPermission';
6
6
  export { filterSidebarItems } from './layouts/filterSidebarItems.js';
7
+ export { PageHeader } from './components/PageHeader.js';
8
+ export { GroupBox } from './components/GroupBox.js';
9
+ export { InfoSection } from './components/InfoSection.js';
10
+ export { InfoField, isBlank } from './components/InfoField.js';
11
+ export { EmptyState } from './components/EmptyState.js';
12
+ export { ActionBar } from './components/ActionBar.js';
13
+ export { registerLocale, setDefaultLocale, getLocaleStrings, getDefaultLocale, } from './internals/locale.js';
14
+ export type { ModernAppLocaleStrings } from './internals/locale.js';
7
15
  export { app };
8
16
  export default app;
package/dist/index.js CHANGED
@@ -1,6 +1,13 @@
1
1
  import { app as e } from "./App.js";
2
2
  import { filterSidebarItems as t } from "./layouts/filterSidebarItems.js";
3
+ import { getDefaultLocale as n, getLocaleStrings as r, registerLocale as i, setDefaultLocale as a } from "./internals/locale.js";
4
+ import { PageHeader as o } from "./components/PageHeader.js";
5
+ import { GroupBox as s } from "./components/GroupBox.js";
6
+ import { InfoSection as c } from "./components/InfoSection.js";
7
+ import { InfoField as l, isBlank as u } from "./components/InfoField.js";
8
+ import { EmptyState as d } from "./components/EmptyState.js";
9
+ import { ActionBar as f } from "./components/ActionBar.js";
3
10
  //#region src/index.ts
4
- var n = e;
11
+ var p = e;
5
12
  //#endregion
6
- export { e as app, n as default, t as filterSidebarItems };
13
+ export { f as ActionBar, d as EmptyState, s as GroupBox, l as InfoField, c as InfoSection, o as PageHeader, e as app, p as default, t as filterSidebarItems, n as getDefaultLocale, r as getLocaleStrings, u as isBlank, i as registerLocale, a as setDefaultLocale };
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Lightweight locale registry for modern-app chrome strings.
3
+ *
4
+ * Scope: library-internal chrome strings only (empty-state copy, back label).
5
+ * NOT a general i18n framework — the app's own content is the app's business.
6
+ *
7
+ * ★**English is the built-in default.** This package sits in the *generic* layer
8
+ * (see the three-layer split: `components` = neutral primitives, `modern-app` =
9
+ * what a modern app usually provides, `enterprise` = iyulab house style).
10
+ * A generic layer that ships Korean defaults excludes every non-Korean consumer —
11
+ * the same objection this repo raised against baking a house aesthetic into
12
+ * `components`. Language excludes harder than aesthetics: an odd-looking default
13
+ * is awkward, an unreadable one is unusable.
14
+ *
15
+ * Mirrors the registry already established in `u-widgets` — one pattern, not two.
16
+ *
17
+ * registerLocale('ko', { back: '뒤로', noDataTitle: '아직 등록된 항목이 없습니다' });
18
+ * setDefaultLocale('ko');
19
+ */
20
+ export interface ModernAppLocaleStrings {
21
+ /** Back button label in the page header. */
22
+ back: string;
23
+ /** Empty state — nothing exists yet. */
24
+ noDataTitle: string;
25
+ noDataDescription: string;
26
+ /** Empty state — records exist but the current query matched none. */
27
+ noResultsTitle: string;
28
+ noResultsDescription: string;
29
+ }
30
+ /**
31
+ * Register a locale. Partial overrides are merged with the English defaults,
32
+ * so a consumer can translate one string without restating the rest.
33
+ */
34
+ export declare function registerLocale(lang: string, strings: Partial<ModernAppLocaleStrings>): void;
35
+ /** Set the locale used when a component does not specify one. */
36
+ export declare function setDefaultLocale(lang: string | undefined): void;
37
+ /**
38
+ * Resolve strings for a language tag.
39
+ *
40
+ * Order: exact match (`ko-KR`) → base language (`ko`) → English.
41
+ * ⚠The base-language step matters: consumers register `ko` but browsers report
42
+ * `ko-KR`, and without it every registration would silently miss.
43
+ */
44
+ export declare function getLocaleStrings(lang?: string): ModernAppLocaleStrings;
45
+ /** The English defaults — for tests and reference. */
46
+ export declare function getDefaultLocale(): ModernAppLocaleStrings;
@@ -0,0 +1,29 @@
1
+ //#region src/internals/locale.ts
2
+ var e = {
3
+ back: "Back",
4
+ noDataTitle: "Nothing here yet",
5
+ noDataDescription: "",
6
+ noResultsTitle: "No matching results",
7
+ noResultsDescription: "Try changing your search terms or filters."
8
+ }, t = /* @__PURE__ */ new Map(), n;
9
+ function r(n, r) {
10
+ t.set(n.toLowerCase(), {
11
+ ...e,
12
+ ...r
13
+ });
14
+ }
15
+ function i(e) {
16
+ n = e;
17
+ }
18
+ function a(r) {
19
+ let i = (r ?? n)?.toLowerCase();
20
+ if (!i) return e;
21
+ if (t.has(i)) return t.get(i);
22
+ let a = i.split("-")[0];
23
+ return a !== i && t.has(a) ? t.get(a) : e;
24
+ }
25
+ function o() {
26
+ return { ...e };
27
+ }
28
+ //#endregion
29
+ export { o as getDefaultLocale, a as getLocaleStrings, r as registerLocale, i as setDefaultLocale };
@@ -0,0 +1,13 @@
1
+ /**
2
+ * 슬롯에 **실제로 배정된 내용이 있는가**.
3
+ *
4
+ * ★**CSS 로는 알 수 없다.** `.wrapper:not(:has(*))` 로 빈 슬롯 래퍼를 접으려는 시도는
5
+ * 실패한다 — `<slot>` **자신이 자식 요소**이므로 `:has(*)` 가 항상 참이기 때문이다.
6
+ * 실브라우저 테스트로 확인했다(소스 검사와 jsdom 은 둘 다 통과시킨다).
7
+ * `::slotted()` 는 배정된 자식을 칠할 수는 있어도 **부모를 선택하지 못한다.**
8
+ * ⇒ 배정 상태는 `slotchange` 로 추적하는 수밖에 없다.
9
+ *
10
+ * 빈 텍스트 노드는 내용으로 치지 않는다 — 마크업을 여러 줄로 쓰면 공백 텍스트 노드가
11
+ * 생기고, 그것을 내용으로 세면 **줄바꿈 여부에 따라 레이아웃이 달라진다.**
12
+ */
13
+ export declare function slotHasContent(slot: HTMLSlotElement | null | undefined): boolean;
@@ -0,0 +1,6 @@
1
+ //#region src/internals/slotted.ts
2
+ function e(e) {
3
+ return e ? e.assignedNodes({ flatten: !0 }).some((e) => e.nodeType !== Node.TEXT_NODE || (e.textContent ?? "").trim() !== "") : !1;
4
+ }
5
+ //#endregion
6
+ export { e as slotHasContent };
@@ -97,10 +97,10 @@ var g = class extends i {
97
97
  <span class="title">
98
98
  ${this.config.title}
99
99
  </span>
100
- <u-button class="toggler"
100
+ <u-button class="toggler" variant="ghost"
101
101
  @click=${this.handleToggleButtonClick}>
102
- <u-icon
103
- lib="bootstrap"
102
+ <u-icon
103
+ lib="bootstrap"
104
104
  name=${this.state === "mobile-open" ? "x-lg" : "list"}
105
105
  ></u-icon>
106
106
  </u-button>
@@ -114,10 +114,10 @@ var g = class extends i {
114
114
  <span class="title" ?hidden=${this.state === "slim"}>
115
115
  ${this.config.title}
116
116
  </span>
117
- <u-button class="toggler"
117
+ <u-button class="toggler" variant="ghost"
118
118
  @click=${this.handleToggleButtonClick}>
119
- <u-icon
120
- lib="bootstrap"
119
+ <u-icon
120
+ lib="bootstrap"
121
121
  name="layout-sidebar"
122
122
  ></u-icon>
123
123
  </u-button>
@@ -40,12 +40,21 @@ var t = e`
40
40
  text-overflow: ellipsis;
41
41
  }
42
42
 
43
+ /*
44
+ * 토글러의 "테두리·배경 없음"은 마크업의 variant="ghost" 가 낸다.
45
+ *
46
+ * ★여기에 border/background 를 적는 것으로는 부족했다 — 이 규칙은 <u-button> **호스트**에만
47
+ * 닿고, 테두리를 실제로 그리는 것은 그 섀도 루트 안의 <button> 이다(solid 기본값이
48
+ * --btn-border-color: var(--btn-color) 로 그린다). 호스트만 재면 0px 이라 보이지도 않았다.
49
+ * variant 를 명시해 컴포넌트가 스스로 투명하게 그리도록 한다.
50
+ *
51
+ * color: inherit 는 유지한다 — 헤더 색을 따라가게 하려는 의도이고 ghost 의 기본 전경
52
+ * (--u-txt-color)보다 이 레이아웃의 뜻이 앞선다.
53
+ */
43
54
  .toggler {
44
55
  color: inherit;
45
56
  font-size: 20px;
46
57
  padding: 0px;
47
- border: none;
48
- background: transparent;
49
58
  }
50
59
  .toggler:hover {
51
60
  color: var(--u-txt-color-hover, #1565C0);
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@iyulab/modern-app",
3
3
  "description": "web-framework by iyulab based on lit-element",
4
- "version": "0.8.1",
4
+ "version": "0.10.0",
5
5
  "keywords": [
6
6
  "iyulab",
7
7
  "web-framework",
@@ -41,7 +41,7 @@
41
41
  "test:browser": "vitest run --project=browser"
42
42
  },
43
43
  "dependencies": {
44
- "@iyulab/components": "^1.8.0",
44
+ "@iyulab/components": "^1.21.0",
45
45
  "@iyulab/router": "^0.10.2",
46
46
  "i18next": "^26.3.6",
47
47
  "lit": "^3.3.3"