@iyulab/modern-app 0.23.0 → 0.25.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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,96 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.25.0] - 2026-09-23
4
+
5
+ ### Added
6
+
7
+ - **`u-info-field` takes a `unit` — `'건'`, `'%'`, `'h'` — drawn after the value one step below
8
+ it: label size, body weight, weak color.** A KPI figure almost always carries a unit, and
9
+ written into the value it was drawn at the `size="lg"` title step, as large and as bold as the
10
+ number; the only way back was restating size, weight and color by hand at every tile, and a tile
11
+ that missed one showed a unit as big as its figure. The unit is hidden while the value is blank
12
+ (`— 건` is not a reading) and follows slotted value content too. Exposed as `::part(unit)`.
13
+ - **`u-group-box` takes a `level` (`2`–`6`, default `3`) for its title's place in the document
14
+ outline.** The title was always an `h3`. A box placed directly under `u-page-header` (the
15
+ page's `h1`) — the usual spot on a detail screen — skipped a level and sat deeper than its sibling
16
+ sections, which screen-reader heading navigation exposes. The box cannot know its own depth, so
17
+ the page that composes it says so: `<u-group-box level="2">`. The level changes semantics only;
18
+ the title looks the same at every level. The default is unchanged.
19
+
20
+ ### Fixed
21
+
22
+ - 🔴 **`SidebarLayout`'s toggle follows the screen size it was told about, with or without
23
+ `app.load()`.** Size *changes* arrived as `screen-resize` events, but the toggle read the
24
+ `app.screen` singleton, which only exists when `app.load()` created the observer. Assembling the
25
+ shell yourself — as the `/react` entry point does, with `SidebarLayout` and `ScreenObserver` —
26
+ left it empty, so it fell back to `'large'`: on a phone-width screen, opening the menu switched to
27
+ the desktop sidebar squeezed beside the content instead of the mobile overlay, and the close glyph
28
+ never appeared. The layout now remembers the last size it received; `app.screen` remains a
29
+ fallback before the first event.
30
+
31
+ ### Changed
32
+
33
+ - **At `size="lg"`, a blank value is drawn at body size rather than the title step**, so `blank`
34
+ can carry a short reason when a figure cannot be computed. The value row keeps its height, so
35
+ tiles in one strip stay level.
36
+
37
+ - 🔴 **`u-info-field`'s `numeric` no longer right-aligns the value — it only sets tabular
38
+ figures.** The same applies to `format="number"` and `format="currency"`, which imply it. A
39
+ field is a label/value pair that owns one `u-info-section` grid cell; there are no
40
+ neighbouring figures in that cell to line up, so right-aligning only pushed the value to the far
41
+ side of the cell from its label. It was most visible in `size="lg"` KPI tiles — label at the
42
+ top left, value at the bottom right — and it was inconsistent: in one tile strip a currency
43
+ figure sat on the right while a plain count sat on the left. Tabular figures stay, so digits
44
+ keep their width as a value changes.
45
+
46
+ ⚠ **Visible change:** numeric values in info fields now start under their label. If a layout of
47
+ yours genuinely needs right-aligned figures, style the part:
48
+ `u-info-field::part(value) { text-align: end; }`.
49
+
50
+ ### Documentation
51
+
52
+ - The `u-info-field` reference describes `numeric` as tabular figures only, says why it no longer
53
+ aligns, and gives the `::part(value)` rule for layouts that need it.
54
+
55
+ ## [0.24.0] - 2026-09-22
56
+
57
+ ### Fixed
58
+
59
+ - **The shell draws its own chrome without reaching the network.** The mobile and sidebar
60
+ toggles were hard-coded to `lib="bootstrap"`, which `@iyulab/components` resolves over a CDN.
61
+ An app that registers its own bundled icons still could not reach those two, because the shell
62
+ never routed through that registration — so on a network-isolated deployment the toggles
63
+ stayed blank and a failing request repeated on every render. Working around it from outside
64
+ meant intercepting a globally registered library name, with an unclear blast radius. Both
65
+ toggles now resolve from the bundled internal set, as the overlay close button already did.
66
+ - That asymmetry was a small defect of its own: one shell drew two different close glyphs, a
67
+ CDN-backed one in the mobile header and a bundled one on the overlay. **The visible change is
68
+ a slightly different stroke on three controls.**
69
+
70
+ ### Added
71
+
72
+ - **`icons` config.** Overrides both the source and the individual names, per key. It takes
73
+ names as well as the library because the defaults are bundled-set names and another set is not
74
+ guaranteed to carry them — pointing the shell at your own icons needs both halves. See
75
+ `docs/configuration.md`.
76
+
77
+ ### Changed
78
+
79
+ - 🔴 **The `@iyulab/components` peer range is now `>=1.43.0`** (was `>=1.42.0`). The shell's
80
+ chrome depends on `menu-2` and `layout-sidebar` existing in that package's built-in set, and
81
+ those names arrive in **1.43.0**. The old range admitted 1.42.x, which would have let
82
+ `components@1.42.4` install alongside this version with no warning at all — and then the shell
83
+ icons would be blank whether or not the deployment had network access, which is a wider
84
+ failure than the one this release fixes. Publishing in the right order does not prevent that:
85
+ ordering is a property of how we release, while the range is what a consumer is allowed to
86
+ install.
87
+
88
+ ### Notes
89
+
90
+ - The regression test resolves each default name **against the bundle** rather than only
91
+ asserting which name the shell asks for. Asserting the name alone would let a typo blank the
92
+ icon with every assertion still passing.
93
+
3
94
  ## [0.23.0] - 2026-09-21
4
95
 
5
96
  ### Fixed
@@ -1,6 +1,8 @@
1
1
  import { StyledElement } from '../internals/StyledElement.js';
2
2
  import { default as React } from 'react';
3
3
  type ElementParts = 'host' | 'header' | 'title' | 'actions' | 'body';
4
+ /** 제목 단계 — 페이지 제목(h1)은 `u-page-header` 몫이라 2 부터다. */
5
+ export type GroupBoxLevel = 2 | 3 | 4 | 5 | 6;
4
6
  /**
5
7
  * 그룹 박스 — 제목이 붙은 카드. LOB 상세 화면의 기본 단위다.
6
8
  *
@@ -27,6 +29,15 @@ export declare class GroupBox extends StyledElement<ElementParts> {
27
29
  divider: boolean;
28
30
  /** 본문 여백을 없앤다 — 표를 카드 가장자리까지 붙일 때. */
29
31
  flush: boolean;
32
+ /**
33
+ * Heading level of the title in the document outline (`2`–`6`, default `3`).
34
+ *
35
+ * The box cannot know how deep it sits in the page, so whoever composes the page says so — a
36
+ * box placed directly under `u-page-header` (the page's `h1`) is usually `2`, and leaving it at
37
+ * `3` skips a level and puts it deeper than its sibling sections. This changes semantics only:
38
+ * the title keeps its visual size at every level. Out-of-range values fall back to `3`.
39
+ */
40
+ level: GroupBoxLevel;
30
41
  /**
31
42
  * 액션 슬롯 배정 상태.
32
43
  * ★종전에는 'render()' 안에서 'this.querySelector()' 로 라이트 DOM 을 읽었다. 그것은
@@ -48,6 +59,7 @@ declare module 'react' {
48
59
  title?: string;
49
60
  divider?: boolean;
50
61
  flush?: boolean;
62
+ level?: GroupBoxLevel;
51
63
  };
52
64
  }
53
65
  }
@@ -5,10 +5,17 @@ import { slotHasContent as r } from "../internals/slotted.js";
5
5
  import { styles as i } from "./GroupBox.styles.js";
6
6
  import { html as a } from "lit";
7
7
  import { customElement as o, property as s, state as c } from "lit/decorators.js";
8
+ import { html as l, literal as u } from "lit/static-html.js";
8
9
  //#region src/components/GroupBox.ts
9
- var l = class extends n {
10
+ var d = {
11
+ 2: u`h2`,
12
+ 3: u`h3`,
13
+ 4: u`h4`,
14
+ 5: u`h5`,
15
+ 6: u`h6`
16
+ }, f = class extends n {
10
17
  constructor(...e) {
11
- super(...e), this.title = "", this.divider = !1, this.flush = !1, this.hasActions = !1;
18
+ super(...e), this.title = "", this.divider = !1, this.flush = !1, this.level = 3, this.hasActions = !1;
12
19
  }
13
20
  static {
14
21
  this.styles = [super.styles, i];
@@ -17,7 +24,7 @@ var l = class extends n {
17
24
  let e = !!this.title || this.hasActions;
18
25
  return a`
19
26
  <div class="header ${this.divider ? "divider" : ""} ${e ? "" : "empty"}" part="header">
20
- <h3 class="title" part="title">${this.title}</h3>
27
+ ${l`<${d[this.level] ?? d[3]} class="title" part="title">${this.title}</${d[this.level] ?? d[3]}>`}
21
28
  <div class="actions ${this.hasActions ? "" : "empty"}" part="actions">
22
29
  <slot name="actions"
23
30
  @slotchange=${(e) => this.hasActions = r(e.target)}
@@ -28,6 +35,9 @@ var l = class extends n {
28
35
  `;
29
36
  }
30
37
  };
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);
38
+ t([s({ type: String }), e("design:type", Object)], f.prototype, "title", void 0), t([s({ type: Boolean }), e("design:type", Object)], f.prototype, "divider", void 0), t([s({ type: Boolean }), e("design:type", Object)], f.prototype, "flush", void 0), t([s({
39
+ type: Number,
40
+ reflect: !0
41
+ }), e("design:type", Object)], f.prototype, "level", void 0), t([c(), e("design:type", Object)], f.prototype, "hasActions", void 0), f = t([o("u-group-box")], f);
32
42
  //#endregion
33
- export { l as GroupBox };
43
+ export { f as GroupBox };
@@ -1,6 +1,6 @@
1
1
  import { StyledElement } from '../internals/StyledElement.js';
2
2
  import { default as React } from 'react';
3
- type ElementParts = 'host' | 'label' | 'value' | 'trend';
3
+ type ElementParts = 'host' | 'label' | 'value' | 'unit' | 'trend';
4
4
  export type InfoFieldFormat = 'number' | 'currency' | 'date';
5
5
  export type InfoFieldSize = 'default' | 'lg';
6
6
  export type InfoFieldTrend = 'up' | 'down' | 'flat';
@@ -22,6 +22,7 @@ export declare function isBlank(v: unknown): boolean;
22
22
  * <u-info-field label="부수" .value=${order.quantity} numeric></u-info-field>
23
23
  * <u-info-field label="거래처">동서인쇄</u-info-field> <!-- 슬롯이 value 를 이긴다 -->
24
24
  * <u-info-field label="합계" format="currency" currency="KRW" .value=${order.total}></u-info-field>
25
+ * <u-info-field label="미결 작업지시" size="lg" .value=${12} unit="건"></u-info-field>
25
26
  * ```
26
27
  */
27
28
  export declare class InfoField extends StyledElement<ElementParts> {
@@ -40,9 +41,12 @@ export declare class InfoField extends StyledElement<ElementParts> {
40
41
  /** "아직 없음"을 나타낼 문구. */
41
42
  blank: string;
42
43
  /**
43
- * 숫자 값 — 우정렬 + 고정폭 숫자(`tabular-nums`).
44
- * ★자릿수가 세로로 맞아야 크기 비교가 눈으로 된다. LOB 화면은 금액·수량이 절반이다.
45
- * `format` 이 `'number'`/`'currency'` 면 이 정렬이 자동으로 함의된다 — 따로 켤 필요 없다.
44
+ * Numeric value — renders with tabular figures (`tabular-nums`) so digits keep their width
45
+ * when the value changes. Implied by `format="number"`/`"currency"`.
46
+ *
47
+ * It does **not** change alignment: this is a label/value pair, not a table cell, so there are
48
+ * no neighbouring figures to line up and right-aligning only pushes the value away from its
49
+ * label. Where a layout does need right-aligned figures, style `::part(value)`.
46
50
  */
47
51
  numeric: boolean;
48
52
  /**
@@ -55,6 +59,12 @@ export declare class InfoField extends StyledElement<ElementParts> {
55
59
  * omitted, degrades to plain number formatting without a currency symbol (does not throw).
56
60
  */
57
61
  currency?: string;
62
+ /**
63
+ * Unit shown after the value — e.g. `'건'`, `'%'`, `'h'`. Drawn one step below the value (label
64
+ * size, body weight, weak color) so a KPI figure keeps its emphasis at `size="lg"`. Hidden while
65
+ * the value is blank: a placeholder has no unit. Follows slotted value content too.
66
+ */
67
+ unit?: string;
58
68
  /**
59
69
  * Display size. `'lg'` renders the value at the `title` type-scale step
60
70
  * (`--u-text-title-size`/`--u-text-title-weight`) — intended for dashboard KPI tiles
@@ -105,6 +115,7 @@ declare module 'react' {
105
115
  numeric?: boolean;
106
116
  format?: InfoFieldFormat;
107
117
  currency?: string;
118
+ unit?: string;
108
119
  size?: InfoFieldSize;
109
120
  trend?: InfoFieldTrend;
110
121
  /** 프로퍼티는 `trendLabel`이지만 Lit 기본 속성명 규칙(소문자화, kebab
@@ -26,12 +26,10 @@ var f = class extends n {
26
26
  return this.format === "currency" ? this.currency ? s(Number(this.value), this.currency) : l(Number(this.value)) : this.format === "number" ? l(Number(this.value)) : this.format === "date" ? c(this.value) : String(this.value);
27
27
  }
28
28
  render() {
29
- let e = !this.hasSlotted && d(this.value), t = this.numeric || this.format === "number" || this.format === "currency", n = this.trend !== void 0 || this.trendLabel !== void 0, r = this.tone ?? u(this.trend), a = this.trend === "up" ? "▲" : this.trend === "down" ? "▼" : "";
29
+ let e = !this.hasSlotted && d(this.value), t = this.numeric || this.format === "number" || this.format === "currency", n = this.trend !== void 0 || this.trendLabel !== void 0, r = this.tone ?? u(this.trend), a = this.trend === "up" ? "▲" : this.trend === "down" ? "▼" : "", o = !e && this.unit ? i`<span class="unit" part="unit">${this.unit}</span>` : "";
30
30
  return i`
31
31
  <div class="label" part="label">${this.label}</div>
32
- <div class="value ${t ? "numeric" : ""} ${e ? "blank" : ""} tone-${r}" part="value">
33
- ${this.hasSlotted ? i`<slot></slot>` : e ? this.blank : this.formatValue()}
34
- </div>
32
+ <div class="value ${t ? "numeric" : ""} ${e ? "blank" : ""} tone-${r}" part="value">${this.hasSlotted ? i`<slot></slot>` : e ? this.blank : this.formatValue()}${o}</div>
35
33
  ${n ? i`
36
34
  <div class="trend tone-${r}" part="trend">
37
35
  ${a ? i`<span aria-hidden="true">${a}</span> ` : ""}${this.trendLabel ?? ""}
@@ -40,7 +38,7 @@ var f = class extends n {
40
38
  `;
41
39
  }
42
40
  };
43
- t([o({ type: String }), e("design:type", Object)], f.prototype, "label", void 0), t([o({ type: String }), e("design:type", Object)], f.prototype, "value", void 0), t([o({ type: String }), e("design:type", Object)], f.prototype, "blank", void 0), t([o({ type: Boolean }), e("design:type", Object)], f.prototype, "numeric", void 0), t([o({ type: String }), e("design:type", Object)], f.prototype, "format", void 0), t([o({ type: String }), e("design:type", String)], f.prototype, "currency", void 0), t([o({
41
+ t([o({ type: String }), e("design:type", Object)], f.prototype, "label", void 0), t([o({ type: String }), e("design:type", Object)], f.prototype, "value", void 0), t([o({ type: String }), e("design:type", Object)], f.prototype, "blank", void 0), t([o({ type: Boolean }), e("design:type", Object)], f.prototype, "numeric", void 0), t([o({ type: String }), e("design:type", Object)], f.prototype, "format", void 0), t([o({ type: String }), e("design:type", String)], f.prototype, "currency", void 0), t([o({ type: String }), e("design:type", String)], f.prototype, "unit", void 0), t([o({
44
42
  type: String,
45
43
  reflect: !0
46
44
  }), e("design:type", Object)], f.prototype, "size", void 0), t([o({ type: String }), e("design:type", Object)], f.prototype, "trend", void 0), t([o({ type: String }), e("design:type", String)], f.prototype, "trendLabel", void 0), t([o({ type: String }), e("design:type", Object)], f.prototype, "tone", void 0), f = t([a("u-info-field")], f);
@@ -23,8 +23,10 @@ var t = e`
23
23
  overflow-wrap: anywhere;
24
24
  }
25
25
 
26
+ /* 고정폭 숫자만 — 정렬은 건드리지 않는다. 이 요소는 표의 칸이 아니라 라벨-값 한 쌍이라
27
+ 세로로 맞출 이웃 숫자가 없다. 우정렬하면 값이 라벨에서 칸 반대편으로 밀려날 뿐이다.
28
+ 표처럼 열을 맞춰야 하는 배치라면 소비자가 ::part(value) 로 정렬한다. */
26
29
  .value.numeric {
27
- text-align: right;
28
30
  font-variant-numeric: tabular-nums;
29
31
  }
30
32
 
@@ -41,9 +43,23 @@ var t = e`
41
43
  font-weight: var(--u-text-body-weight, 400);
42
44
  }
43
45
 
44
- /* size="lg" 보다 우선해야 한다 — blank 는 lg 모드에서도 계속 de-emphasize 상태를 유지해야 한다. */
46
+ /* size="lg" 보다 우선해야 한다 — blank 는 lg 모드에서도 계속 de-emphasize 상태를 유지해야 한다.
47
+ 크기도 본문 단으로 내린다: blank 문구가 «값을 낼 수 없는 사유» 문장일 수 있고, 제목 단에서는
48
+ 타일에 담기지 않는다. 대신 값 줄의 높이는 지켜 한 줄에 놓인 타일들의 높이가 갈리지 않게 한다
49
+ (lg 값 줄 = 제목 크기 × 본문 행간 — 위 .value 가 행간을 본문 단에 두기 때문이다). */
45
50
  :host([size="lg"]) .value.blank {
51
+ font-size: var(--u-text-body-size, 14px);
46
52
  font-weight: var(--u-text-body-weight, 400);
53
+ min-height: calc(var(--u-text-title-size, 20px) * var(--u-text-body-leading, 1.6));
54
+ }
55
+
56
+ /* 단위는 값보다 한 단 아래 — 크기·굵기·색 셋을 함께 낮춰야 KPI 숫자가 강조를 지킨다.
57
+ 이 세트를 소비자가 기억해 되돌리게 두면 한 곳이라도 빠진 자리에서 단위가 숫자만큼 커진다. */
58
+ .unit {
59
+ margin-inline-start: 0.25em;
60
+ font-size: var(--u-text-label-size, 13px);
61
+ font-weight: var(--u-text-body-weight, 400);
62
+ color: var(--u-txt-color-weak, #616161);
47
63
  }
48
64
 
49
65
  /* tone은 trend 유무와 무관하게 값 텍스트 자체에도 적용된다(neutral은 무규칙 — 기본
package/dist/index.d.ts CHANGED
@@ -3,6 +3,7 @@ export type * from './types/AppConfigs';
3
3
  export type * from './types/AppOptions';
4
4
  export type * from './types/AuthConfig';
5
5
  export type { SidebarPermissionGuard } from './layouts/SidebarPermission';
6
+ export type { SidebarIconsConfig } from './layouts/SidebarLayout.types';
6
7
  export { filterSidebarItems } from './layouts/filterSidebarItems.js';
7
8
  export { PageHeader } from './components/PageHeader.js';
8
9
  export { GroupBox } from './components/GroupBox.js';
@@ -55,7 +55,29 @@ export declare class SidebarLayout extends StyledElement<SidebarParts> {
55
55
  * 🔴로고는 홈(또는 `href`)으로 가는 **링크**다 — 종전엔 클릭만 받는 `img`·`span`·`u-icon` 이라 키보드로 닿지 않았고
56
56
  * 보조기술에 링크로 드러나지 않았다. `u-link` 는 `href` 가 없으면 basepath 로 SPA 이동하므로 종전 `app.navigate('')` 와 같다.
57
57
  */
58
+ /**
59
+ * 셸이 **자기 chrome 으로** 그리는 아이콘의 기본값 — 전부 `internal` 번들이라 네트워크를 타지 않는다.
60
+ *
61
+ * 🔴**0.24.0 이전에는 토글러 둘이 `bootstrap`(= jsdelivr CDN 조회)이었다.** 소비앱이 자기
62
+ * 아이콘을 빌드 시점에 전부 구워 등록해도 **셸이 쓰는 것만은 그 등록을 타지 않아**, 폐쇄망
63
+ * 배포에서 사이드바 토글이 빈 채로 남고 매번 실패하는 요청이 쌓였다. ⚠그리고 셸이 스스로
64
+ * 갈려 있었다 — 오버레이 닫기는 `internal`, 모바일 닫기는 `bootstrap` 이라 **한 셸이 서로
65
+ * 다른 X 를 둘 그렸다.**
66
+ */
67
+ private static readonly DEFAULT_ICONS;
68
+ /** 설정이 준 값이 있으면 그것을, 없으면 기본값을 돌려준다(키 단위 부분 오버라이드). */
69
+ private icon;
58
70
  private renderLogo;
71
+ /**
72
+ * 이 레이아웃이 마지막으로 받은 화면 크기.
73
+ *
74
+ * 크기 «전환» 은 `screen-resize` 이벤트로 받으므로 토글도 같은 출처를 읽어야 한다.
75
+ * 종전에는 토글이 싱글턴 `app.screen` 을 읽었는데, 그것은 `app.load()` 가 옵저버를 만들
76
+ * 때만 존재한다 — `/react` 처럼 레이아웃과 `ScreenObserver` 를 직접 조립하면 항상 비어
77
+ * `'large'` 로 떨어졌고, 모바일 폭에서 메뉴를 열면 데스크톱 사이드바가 펼쳐졌다.
78
+ * `app.screen` 은 이벤트를 아직 못 받았을 때의 폴백으로만 남긴다.
79
+ */
80
+ private screenSize?;
59
81
  /** 사이드바 토글 핸들러 */
60
82
  private handleToggleButtonClick;
61
83
  /**
@@ -21,28 +21,31 @@ import { ifDefined as _ } from "lit/directives/if-defined.js";
21
21
  import { createDevWarner as v } from "@iyulab/components/dist/utilities/devWarning.js";
22
22
  import { UProgressBar as y } from "@iyulab/components/dist/components/progress-bar/UProgressBar.js";
23
23
  //#region src/layouts/SidebarLayout.ts
24
- var b = v("@iyulab/modern-app"), x = 0;
25
- function S(e) {
24
+ var b, x = v("@iyulab/modern-app"), S = 0;
25
+ function C(e) {
26
26
  let t = e.tagName;
27
27
  if (t === "INPUT" || t === "TEXTAREA" || t === "SELECT" || e.isContentEditable) return !0;
28
28
  let n = e.getAttribute("role");
29
29
  return n === "textbox" || n === "searchbox" || n === "combobox" || n === "spinbutton";
30
30
  }
31
- function C(e) {
31
+ function w(e) {
32
32
  let t = getComputedStyle(e);
33
33
  return (t.overflowY === "auto" || t.overflowY === "scroll") && e.scrollHeight > e.clientHeight;
34
34
  }
35
- function w(e, t) {
35
+ function T(e, t) {
36
36
  for (let n of e) if (n instanceof HTMLElement) {
37
- if (C(n)) return n === t;
37
+ if (w(n)) return n === t;
38
38
  if (n === t) break;
39
39
  }
40
40
  return !0;
41
41
  }
42
- var T = class extends i {
42
+ var E = class extends i {
43
+ static {
44
+ b = this;
45
+ }
43
46
  constructor(...t) {
44
- super(...t), this.state = "default", this.locale = "", this.hasOverlay = !1, this.internals = typeof this.attachInternals == "function" ? this.attachInternals() : void 0, this.context = null, this.unsizedWarnKey = `unsized:${++x}`, this.isMatchedLink = (e) => !this.context || !e ? !1 : (e = typeof e == "string" ? new URLPattern(e, window.location.origin) : e, e.test(this.context.path, window.location.origin)), this.handleToggleButtonClick = () => {
45
- let t = e.screen ?? "large";
47
+ super(...t), this.state = "default", this.locale = "", this.hasOverlay = !1, this.internals = typeof this.attachInternals == "function" ? this.attachInternals() : void 0, this.context = null, this.unsizedWarnKey = `unsized:${++S}`, this.isMatchedLink = (e) => !this.context || !e ? !1 : (e = typeof e == "string" ? new URLPattern(e, window.location.origin) : e, e.test(this.context.path, window.location.origin)), this.handleToggleButtonClick = () => {
48
+ let t = this.screenSize ?? e.screen ?? "large";
46
49
  t === "large" ? this.state = this.state === "default" ? "slim" : "default" : t === "medium" ? this.state = this.state === "slim" ? "modal" : "slim" : t === "small" ? this.state = this.state === "mobile" ? "mobile-open" : "mobile" : console.warn("Unknown screen size:", t);
47
50
  }, this.handleBackdropClick = () => {
48
51
  this.state = "slim";
@@ -66,9 +69,9 @@ var T = class extends i {
66
69
  this.fire("overlay-close", { cancelable: !1 });
67
70
  }, this._handleMainKeydown = (e) => {
68
71
  let t = e.composedPath()[0];
69
- if (t instanceof HTMLElement && S(t)) return;
72
+ if (t instanceof HTMLElement && C(t)) return;
70
73
  let n = this.shadowRoot?.querySelector(".main");
71
- if (!n || !w(e.composedPath(), n)) return;
74
+ if (!n || !T(e.composedPath(), n)) return;
72
75
  let r = n.clientHeight;
73
76
  switch (e.key) {
74
77
  case " ":
@@ -95,7 +98,7 @@ var T = class extends i {
95
98
  e.preventDefault();
96
99
  }, this.handleScreenResize = (e) => {
97
100
  let t = e.detail.size;
98
- t === "large" ? this.state = "default" : t === "medium" ? this.state = "slim" : t === "small" ? this.state = "mobile" : console.warn("Unknown screen size:", t);
101
+ this.screenSize = t, t === "large" ? this.state = "default" : t === "medium" ? this.state = "slim" : t === "small" ? this.state = "mobile" : console.warn("Unknown screen size:", t);
99
102
  };
100
103
  }
101
104
  static {
@@ -123,7 +126,7 @@ var T = class extends i {
123
126
  process.env.NODE_ENV !== "production" && requestAnimationFrame(() => {
124
127
  if (!this.isConnected) return;
125
128
  let e = this.getBoundingClientRect().height;
126
- e >= 200 || b(this.unsizedWarnKey, `u-sidebar-layout is only ${Math.round(e)}px tall — its height: 100% found no sized ancestor, so the shell sits at its own chrome height and the route area has almost no room. Give the root element a height (e.g. #app { height: 100vh } — app.load() does this for document.body).`);
129
+ e >= 200 || x(this.unsizedWarnKey, `u-sidebar-layout is only ${Math.round(e)}px tall — its height: 100% found no sized ancestor, so the shell sits at its own chrome height and the route area has almost no room. Give the root element a height (e.g. #app { height: 100vh } — app.load() does this for document.body).`);
127
130
  });
128
131
  }
129
132
  render() {
@@ -138,8 +141,8 @@ var T = class extends i {
138
141
  aria-label=${a(this.locale || void 0).toggleMobileMenu}
139
142
  @click=${this.handleToggleButtonClick}>
140
143
  <u-icon
141
- lib="bootstrap"
142
- name=${this.state === "mobile-open" ? "x-lg" : "list"}
144
+ lib=${this.icon("lib")}
145
+ name=${this.state === "mobile-open" ? this.icon("close") : this.icon("menu")}
143
146
  ></u-icon>
144
147
  </u-button>
145
148
  </div>
@@ -156,8 +159,8 @@ var T = class extends i {
156
159
  aria-label=${a(this.locale || void 0).toggleSidebar}
157
160
  @click=${this.handleToggleButtonClick}>
158
161
  <u-icon
159
- lib="bootstrap"
160
- name="layout-sidebar"
162
+ lib=${this.icon("lib")}
163
+ name=${this.icon("sidebarToggle")}
161
164
  ></u-icon>
162
165
  </u-button>
163
166
  </div>
@@ -188,7 +191,7 @@ var T = class extends i {
188
191
  <u-button class="overlay-close" part="overlay-close" variant="ghost"
189
192
  aria-label=${a(this.locale || void 0).detailClose}
190
193
  @click=${this.handleOverlayClose}>
191
- <u-icon lib="internal" name="x"></u-icon>
194
+ <u-icon lib=${this.icon("lib")} name=${this.icon("overlayClose")}></u-icon>
192
195
  </u-button>
193
196
  <slot name="overlay" @slotchange=${this.handleOverlaySlotChange}></slot>
194
197
  </div>
@@ -260,6 +263,18 @@ var T = class extends i {
260
263
  }
261
264
  return u;
262
265
  }
266
+ static {
267
+ this.DEFAULT_ICONS = {
268
+ lib: "internal",
269
+ menu: "menu-2",
270
+ close: "x",
271
+ sidebarToggle: "layout-sidebar",
272
+ overlayClose: "x"
273
+ };
274
+ }
275
+ icon(e) {
276
+ return this.config?.icons?.[e] ?? b.DEFAULT_ICONS[e];
277
+ }
263
278
  renderLogo() {
264
279
  let e = this.config?.logo, t = this.config?.title ?? "Home";
265
280
  if (!e || typeof e == "string") return e ? l`
@@ -293,6 +308,6 @@ var T = class extends i {
293
308
  r([f({
294
309
  type: String,
295
310
  reflect: !0
296
- }), n("design:type", Object)], T.prototype, "state", void 0), r([f({ type: Object }), n("design:type", Object)], T.prototype, "config", void 0), r([f({ type: String }), n("design:type", Object)], T.prototype, "locale", void 0), r([p("u-progress-bar"), n("design:type", y === void 0 ? Object : y)], T.prototype, "progressBarEl", void 0), r([m(), n("design:type", Object)], T.prototype, "hasOverlay", void 0), r([m(), n("design:type", Object)], T.prototype, "context", void 0), T = r([d("u-sidebar-layout")], T);
311
+ }), n("design:type", Object)], E.prototype, "state", void 0), r([f({ type: Object }), n("design:type", Object)], E.prototype, "config", void 0), r([f({ type: String }), n("design:type", Object)], E.prototype, "locale", void 0), r([p("u-progress-bar"), n("design:type", y === void 0 ? Object : y)], E.prototype, "progressBarEl", void 0), r([m(), n("design:type", Object)], E.prototype, "hasOverlay", void 0), r([m(), n("design:type", Object)], E.prototype, "context", void 0), E = b = r([d("u-sidebar-layout")], E);
297
312
  //#endregion
298
- export { T as SidebarLayout };
313
+ export { E as SidebarLayout };
@@ -11,6 +11,34 @@ export type { SidebarLinkConfig } from '../components/SidebarLink';
11
11
  export type { SidebarSectionConfig } from '../components/SidebarSection';
12
12
  export type { SidebarGroupConfig } from '../components/SidebarGroup';
13
13
  export type { SidebarButtonConfig } from '../components/SidebarButton';
14
+ /**
15
+ * 셸이 **자기 chrome 으로** 그리는 아이콘의 출처와 이름. 지정한 키만 기본값을 대체한다.
16
+ *
17
+ * 🔴**기본값은 네트워크를 타지 않는다.** `internal` 은 `@iyulab/components` 가 빌드 시점에
18
+ * 굽는 번들이라, 폐쇄망·오프라인 배포에서도 셸 아이콘이 비지 않는다. 0.24.0 이전에는 셸의
19
+ * 토글러 둘이 `bootstrap`(= jsdelivr CDN 조회)을 썼고, 소비자가 자기 아이콘을 전부 번들로
20
+ * 구워 등록해도 **셸이 쓰는 것만은 그 등록을 타지 않았다.**
21
+ *
22
+ * ⚠**`lib` 만 바꿀 때는 이름도 함께 봐야 한다** — 기본 이름들은 `internal` 번들의 이름이고,
23
+ * 다른 lib 이 같은 이름을 갖는다는 보장이 없다. 자기 아이콘 세트로 갈아끼울 때는 보통
24
+ * `lib` 과 이름을 함께 준다:
25
+ *
26
+ * ```ts
27
+ * icons: { lib: 'app', menu: 'list', close: 'x-lg', sidebarToggle: 'layout-sidebar' }
28
+ * ```
29
+ */
30
+ export interface SidebarIconsConfig {
31
+ /** 아래 이름들을 해석할 아이콘 lib. 기본 `'internal'`(번들 — 네트워크 없음). */
32
+ lib?: string;
33
+ /** 모바일 헤더의 메뉴 «열기». 기본 `'menu-2'`. */
34
+ menu?: string;
35
+ /** 모바일 헤더의 메뉴 «닫기». 기본 `'x'`. */
36
+ close?: string;
37
+ /** 사이드바 접기/펼치기 토글. 기본 `'layout-sidebar'`. */
38
+ sidebarToggle?: string;
39
+ /** 오버레이 닫기 버튼. 기본 `'x'`. */
40
+ overlayClose?: string;
41
+ }
14
42
  /** 사이드바 레이아웃 컴포넌트의 요소(part) 타입 */
15
43
  export type SidebarParts = 'host' | 'mobile-header' | 'sidebar' | 'sidebar-header' | 'sidebar-main' | 'sidebar-footer' | 'main' | 'main-content' | 'progress' | 'overlay' | 'overlay-close';
16
44
  /** 사이드바 상태 타입 */
@@ -64,6 +92,11 @@ export interface SidebarLayoutConfig {
64
92
  * "아무것도 안 함" — Vue Router의 `scrollBehavior` 미지정 기본값과 동일하다(breaking 아님).
65
93
  */
66
94
  scrollBehavior?: (context: RouteContext, main: HTMLElement) => void;
95
+ /**
96
+ * 셸 chrome 아이콘의 출처·이름 오버라이드. 지정한 키만 기본값을 대체한다.
97
+ * 기본값은 전부 `internal` 번들이라 **네트워크를 타지 않는다** — `SidebarIconsConfig` 참조.
98
+ */
99
+ icons?: SidebarIconsConfig;
67
100
  /** 사이드바 스타일 맵 */
68
101
  styles?: StyleMap<SidebarParts>;
69
102
  }
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.23.0",
4
+ "version": "0.25.0",
5
5
  "keywords": [
6
6
  "iyulab",
7
7
  "web-framework",
@@ -64,7 +64,7 @@
64
64
  "lit": "^3.3.3"
65
65
  },
66
66
  "peerDependencies": {
67
- "@iyulab/components": ">=1.42.0",
67
+ "@iyulab/components": ">=1.43.0",
68
68
  "@lit/react": ">=1.0.8",
69
69
  "react": ">=18.0.0"
70
70
  },
@@ -32,6 +32,7 @@ a left edge without any consumer CSS.
32
32
  | `title` | `string` | `''` | | Header title |
33
33
  | `divider` | `boolean` | `false` | | Rule between header and body. Off by default — many rules make a screen noisy |
34
34
  | `flush` | `boolean` | `false` | | Remove body padding, for tables and lists that draw their own edges |
35
+ | `level` | `2|3|4|5|6` | `3` | ✓ | Heading level of the title in the document outline. Set `2` for a box directly under `u-page-header` (the page's `h1`), so the outline does not skip a level. Semantics only — the title looks the same at every level |
35
36
 
36
37
  ## CSS Parts
37
38
 
@@ -33,26 +33,52 @@ and those are different states of the business. So the component owns the rule.
33
33
  | `label` | `string` | `''` | | Field label |
34
34
  | `value` | `unknown` | — | | Value; property only (`attribute: false`) |
35
35
  | `blank` | `string` | `'—'` | | Placeholder for null/undefined/empty — **not** for `0` or `false` |
36
- | `numeric` | `boolean` | `false` | | Right-align with tabular figures so digits line up vertically |
36
+ | `numeric` | `boolean` | `false` | | Tabular figures, so digits keep their width as the value changes; implied by `format="number"`/`"currency"`. Does **not** change alignment |
37
37
  | `format` | `'number'\|'currency'\|'date'` | — | | Renders `value` through `@iyulab/components`' `formatNumber`/`formatCurrency`/`formatDate`; unset falls back to plain `String(value)` |
38
38
  | `currency` | `string` | — | | Currency code for `format="currency"` (e.g. `'KRW'`); omitted degrades to plain number formatting, does not throw |
39
+ | `unit` | `string` | — | | Unit after the value (e.g. `'건'`, `'%'`, `'h'`), drawn one step below it — label size, body weight, weak color. Hidden while the value is blank. Also follows slotted value content |
39
40
  | `size` | `'default'\|'lg'` | `'default'` | ✓ | `'lg'` renders the value at the `title` type-scale step — for dashboard KPI tiles composed inside `u-info-section` |
40
41
  | `trend` | `'up'\|'down'\|'flat'` | — | | Trend direction; renders a trend indicator when set, alongside `trendLabel`; pair with trendLabel for an accessible name — trend alone conveys direction by color only |
41
42
  | `trendLabel` | `string` | — | | Trend copy, e.g. `"+12% vs last month"` — wording is the consumer's responsibility |
42
43
  | `tone` | `'positive'\|'negative'\|'neutral'` | — | | Explicit tone override; resolves from `trend` when unset (`up→positive`, `down→negative`, `flat`/unset→`neutral`) but always wins over inference. Colors the value text itself, independent of `trend` — a static figure (e.g. a balance due) can be toned `negative` with no trend arrow |
43
44
 
44
- ⚠ `numeric` earns its keep in **tables**, where columns of figures are compared by eye. On a
45
- lone field it just pushes the value away from its label.
45
+ ⚠ `numeric` no longer right-aligns (it did before 0.25.0). A field is a label/value pair that owns
46
+ one grid cell, with no neighbouring figures to line up, so right-aligning only pushed the value
47
+ to the far side of the cell from its label — most visibly in `size="lg"` KPI tiles, and
48
+ inconsistently, because `format="currency"` implied it while a plain count did not. If a layout
49
+ genuinely needs right-aligned figures, style the part: `u-info-field::part(value) { text-align: end; }`.
46
50
 
47
51
  ```html
48
52
  <u-info-field label="Total" format="currency" currency="KRW" .value=${order.total}></u-info-field>
49
53
  ```
50
54
 
55
+ ### Units and KPI tiles
56
+
57
+ A KPI figure almost always carries a unit. Put it in `unit` rather than in the value: the value
58
+ is drawn at the `title` step in `size="lg"`, and a unit written into the value (or the slot) is
59
+ drawn that large and that bold too — undoing it by hand means restating size, weight and color at
60
+ every tile.
61
+
62
+ ```html
63
+ <u-info-field label="Open work orders" size="lg" .value=${openCount} unit="건"></u-info-field>
64
+ <u-info-field label="Availability" size="lg" format="number" .value=${98.2} unit="%"></u-info-field>
65
+ ```
66
+
67
+ At `size="lg"` a blank value is drawn at body size, not the title step, so `blank` can carry a
68
+ short reason when a figure cannot be computed — the row keeps its height, so tiles in one strip
69
+ stay level:
70
+
71
+ ```html
72
+ <u-info-field label="MTTR" size="lg" .value=${mttr} unit="h"
73
+ blank="No downtime recorded for the failures"></u-info-field>
74
+ ```
75
+
51
76
  ## CSS Parts
52
77
 
53
78
  | Part | Description |
54
79
  |------|-------------|
55
80
  | `label` · `value` · `trend` | The label, the value, and the (optional) trend indicator |
81
+ | `unit` | The unit, inside `value` (only when `unit` is set and the value is not blank) |
56
82
 
57
83
  ## CSS Custom Properties
58
84
 
@@ -66,5 +92,6 @@ lone field it just pushes the value away from its label.
66
92
  | `--u-text-body-weight` | Value font weight when `blank` |
67
93
  | `--u-txt-color` | Value color |
68
94
  | `--u-text-title-size` / `-weight` | Value typography when `size="lg"` |
95
+ | `--u-text-label-size` | Unit size |
69
96
  | `--u-success-color-strong` | `tone="positive"` color (value and trend) |
70
97
  | `--u-danger-color-strong` | `tone="negative"` color (value and trend) |
@@ -27,6 +27,9 @@ interface SidebarLayoutConfig {
27
27
  /** Called on `route-done` (before focus moves to the container) with the new route's `RouteContext` and the scroll container itself — implement reset/save/restore here. Unset does nothing (same default as Vue Router's unset `scrollBehavior`). Also reachable outside the hook via the element's `.mainElement` accessor. */
28
28
  scrollBehavior?: (context: RouteContext, main: HTMLElement) => void;
29
29
 
30
+ /** Shell chrome icon source and names — `{ lib?, menu?, close?, sidebarToggle?, overlayClose? }`, only the keys you set replace the defaults. Defaults come from the bundled `internal` set, so the shell needs no network to draw its own toggles; give `lib` **and** the names to point it at your own set. */
31
+ icons?: SidebarIconsConfig;
32
+
30
33
  /** Per-part style overrides (CSS custom properties / inline styles). */
31
34
  styles?: StyleMap<SidebarParts>;
32
35
  }