@iyulab/modern-app 0.35.0 → 0.36.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,35 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.36.0] - 2026-10-06
4
+
5
+ ### Added
6
+
7
+ - **`type: 'menu'` sidebar items — a button that opens a popup menu.** `{ type: 'menu', label, icon,
8
+ items: [{ label, icon, onClick, disabled }] }`. The shell draws the trigger and the menu together and
9
+ owns what only it knows: the menu opens beside the sidebar and downwards on the full-width mobile
10
+ panel; the trigger carries `aria-haspopup="menu"` and `aria-expanded`; opening moves focus into the
11
+ menu, and Escape or choosing an entry closes it and returns focus to the trigger. Entries take
12
+ `requirePermission`/`requireAnyPermission`, and a menu left with no entries is dropped.
13
+ - The sidebar config types (`SidebarItem`, `SidebarLinkConfig`, `SidebarMenuConfig`, …, `SidebarState`)
14
+ are exported from the main entry too — they were only on `/react`, so a TypeScript app without React
15
+ could not name them.
16
+
17
+ ### Removed (breaking)
18
+
19
+ - **`SidebarButtonConfig.id`.** It was meant for anchoring a `u-popover` you place outside the layout,
20
+ but the button renders inside the layout's shadow root, where `for="#id"` never reaches — it never
21
+ worked. Use `type: 'menu'` for a menu, or `type: 'html'` with the trigger and the popover in one
22
+ template for any other flyout.
23
+
24
+ ## [0.35.1] - 2026-10-06
25
+
26
+ ### Fixed
27
+
28
+ - **`u-wizard`'s `step-change`, `u-master-detail-layout`'s `detail-close` and `u-sidebar-layout`'s
29
+ `overlay-close` are typed for TypeScript listeners** (`WizardEventMap`, `MasterDetailLayoutEventMap`,
30
+ `SidebarLayoutEventMap`, exported). `wizard.addEventListener('step-change', (e) => e.detail.to)` no
31
+ longer needs a cast; the React `onStepChange`/`onOverlayClose` props use the same types.
32
+
3
33
  ## [0.35.0] - 2026-10-06
4
34
 
5
35
  ### Changed
@@ -86,6 +86,20 @@ export declare class MasterDetailLayout extends StyledElement<ElementParts> {
86
86
  private handleDetailSlotChange;
87
87
  render(): import('lit-html').TemplateResult<1>;
88
88
  }
89
+ /** Events `<u-master-detail-layout>` dispatches — bubbling and composed. */
90
+ export interface MasterDetailLayoutEventMap {
91
+ /** The detail pane's close button was pressed (narrow width, overlay mode). Clearing the `detail` slot is yours. */
92
+ 'detail-close': CustomEvent<null>;
93
+ }
94
+ /** Typed listeners for {@link MasterDetailLayoutEventMap} — element-scoped, the DOM's own pattern (`HTMLMediaElementEventMap`). */
95
+ export interface MasterDetailLayout {
96
+ addEventListener<K extends keyof MasterDetailLayoutEventMap>(type: K, listener: (this: MasterDetailLayout, ev: MasterDetailLayoutEventMap[K]) => unknown, options?: boolean | AddEventListenerOptions): void;
97
+ addEventListener<K extends keyof HTMLElementEventMap>(type: K, listener: (this: MasterDetailLayout, ev: HTMLElementEventMap[K]) => unknown, options?: boolean | AddEventListenerOptions): void;
98
+ addEventListener(type: string, listener: EventListenerOrEventListenerObject, options?: boolean | AddEventListenerOptions): void;
99
+ removeEventListener<K extends keyof MasterDetailLayoutEventMap>(type: K, listener: (this: MasterDetailLayout, ev: MasterDetailLayoutEventMap[K]) => unknown, options?: boolean | EventListenerOptions): void;
100
+ removeEventListener<K extends keyof HTMLElementEventMap>(type: K, listener: (this: MasterDetailLayout, ev: HTMLElementEventMap[K]) => unknown, options?: boolean | EventListenerOptions): void;
101
+ removeEventListener(type: string, listener: EventListenerOrEventListenerObject, options?: boolean | EventListenerOptions): void;
102
+ }
89
103
  declare global {
90
104
  interface HTMLElementTagNameMap {
91
105
  'u-master-detail-layout': MasterDetailLayout;
@@ -6,9 +6,6 @@ type ElementParts = 'host' | 'base' | 'icon' | 'label';
6
6
  /** 버튼 항목 구성 */
7
7
  export interface SidebarButtonConfig extends SidebarPermissionGuard {
8
8
  type: 'button';
9
- /** 렌더된 `<u-sidebar-button>` 호스트에 그대로 전달된다 — `u-popover[for="#id"]` 같은
10
- * 외부 앵커링에 쓴다. */
11
- id?: string;
12
9
  icon?: string;
13
10
  /** `icon`을 어느 등록 라이브러리에서 찾을지. 미지정 시 `u-icon`의 기본 URL 경로로
14
11
  * 해석된다(`IconRegistry.register()`로 등록한 이름 있는 세트를 쓰려면 지정해야 함). */
@@ -30,6 +27,12 @@ export declare class SidebarButton extends StyledElement<ElementParts> {
30
27
  lib?: string;
31
28
  /** 버튼 텍스트 라벨 */
32
29
  label?: string | DirectiveResult;
30
+ /** 이 버튼이 여는 팝업의 종류 — 안쪽 `<button>` 의 `aria-haspopup` 으로 간다(셸의 `type: 'menu'` 트리거). */
31
+ haspopup?: 'menu';
32
+ /** 팝업이 열려 있는가 — `haspopup` 과 함께 `aria-expanded` 로 간다. 팝업이 없는 버튼에는 붙이지 않는다. */
33
+ expanded: boolean;
34
+ /** 포커스는 안쪽 `<button>` 으로 — 호스트는 포커스를 받지 않는다(팝업을 닫을 때 셸이 이것으로 되돌린다). */
35
+ focus(options?: FocusOptions): void;
33
36
  render(): import('lit-html').TemplateResult<1>;
34
37
  }
35
38
  export {};
@@ -6,17 +6,23 @@ import { styles as i } from "./SidebarButton.styles.js";
6
6
  import { html as a } from "lit";
7
7
  import { customElement as o, property as s } from "lit/decorators.js";
8
8
  import "@iyulab/components/dist/components/icon/UIcon.js";
9
+ import { ifDefined as c } from "lit/directives/if-defined.js";
9
10
  //#region src/components/SidebarButton.ts
10
- var c = class extends n {
11
+ var l = class extends n {
11
12
  constructor(...e) {
12
- super(...e), this.compact = !1;
13
+ super(...e), this.compact = !1, this.expanded = !1;
13
14
  }
14
15
  static {
15
16
  this.styles = [super.styles, i];
16
17
  }
18
+ focus(e) {
19
+ this.renderRoot.querySelector("button")?.focus(e);
20
+ }
17
21
  render() {
18
22
  return a`
19
- <button part="base" ?compact=${this.compact}>
23
+ <button part="base" ?compact=${this.compact}
24
+ aria-haspopup=${c(this.haspopup)}
25
+ aria-expanded=${c(this.haspopup ? String(this.expanded) : void 0)}>
20
26
  <u-icon part="icon"
21
27
  .lib=${this.lib}
22
28
  .name=${this.icon}
@@ -32,6 +38,6 @@ var c = class extends n {
32
38
  t([s({
33
39
  type: Boolean,
34
40
  reflect: !0
35
- }), e("design:type", Object)], c.prototype, "compact", void 0), t([s({ type: String }), e("design:type", String)], c.prototype, "icon", void 0), t([s({ type: String }), e("design:type", String)], c.prototype, "lib", void 0), t([s({ type: String }), e("design:type", Object)], c.prototype, "label", void 0), c = t([o("u-sidebar-button")], c);
41
+ }), e("design:type", Object)], l.prototype, "compact", void 0), t([s({ type: String }), e("design:type", String)], l.prototype, "icon", void 0), t([s({ type: String }), e("design:type", String)], l.prototype, "lib", void 0), t([s({ type: String }), e("design:type", Object)], l.prototype, "label", void 0), t([s({ type: String }), e("design:type", String)], l.prototype, "haspopup", void 0), t([s({ type: Boolean }), e("design:type", Object)], l.prototype, "expanded", void 0), l = t([o("u-sidebar-button")], l);
36
42
  //#endregion
37
- export { c as SidebarButton };
43
+ export { l as SidebarButton };
@@ -89,6 +89,20 @@ export declare class Wizard extends StyledElement<ElementParts> {
89
89
  private renderStep;
90
90
  render(): import('lit-html').TemplateResult<1>;
91
91
  }
92
+ /** Events `<u-wizard>` dispatches — bubbling and composed. */
93
+ export interface WizardEventMap {
94
+ /** Cancelable: `preventDefault()` keeps the wizard on `from` (validation, unsaved work). */
95
+ 'step-change': CustomEvent<WizardStepChangeDetail>;
96
+ }
97
+ /** Typed listeners for {@link WizardEventMap} — element-scoped, the DOM's own pattern (`HTMLMediaElementEventMap`). */
98
+ export interface Wizard {
99
+ addEventListener<K extends keyof WizardEventMap>(type: K, listener: (this: Wizard, ev: WizardEventMap[K]) => unknown, options?: boolean | AddEventListenerOptions): void;
100
+ addEventListener<K extends keyof HTMLElementEventMap>(type: K, listener: (this: Wizard, ev: HTMLElementEventMap[K]) => unknown, options?: boolean | AddEventListenerOptions): void;
101
+ addEventListener(type: string, listener: EventListenerOrEventListenerObject, options?: boolean | AddEventListenerOptions): void;
102
+ removeEventListener<K extends keyof WizardEventMap>(type: K, listener: (this: Wizard, ev: WizardEventMap[K]) => unknown, options?: boolean | EventListenerOptions): void;
103
+ removeEventListener<K extends keyof HTMLElementEventMap>(type: K, listener: (this: Wizard, ev: HTMLElementEventMap[K]) => unknown, options?: boolean | EventListenerOptions): void;
104
+ removeEventListener(type: string, listener: EventListenerOrEventListenerObject, options?: boolean | EventListenerOptions): void;
105
+ }
92
106
  declare global {
93
107
  interface HTMLElementTagNameMap {
94
108
  'u-wizard': Wizard;
package/dist/index.d.ts CHANGED
@@ -3,7 +3,8 @@ 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
+ export type { SidebarIconsConfig, SidebarLayoutConfig, SidebarItem, SidebarLogoConfig, SidebarLinkConfig, SidebarSectionConfig, SidebarGroupConfig, SidebarButtonConfig, SidebarMenuConfig, SidebarMenuItemConfig, SidebarHtmlConfig, SidebarState, } from './layouts/SidebarLayout.types';
7
+ export type { SidebarLayoutEventMap } from './layouts/SidebarLayout.js';
7
8
  export { filterSidebarItems } from './layouts/filterSidebarItems.js';
8
9
  export { PageHeader } from './components/PageHeader.js';
9
10
  export { GroupBox } from './components/GroupBox.js';
@@ -12,8 +13,9 @@ export { InfoField, isBlank } from './components/InfoField.js';
12
13
  export { EmptyState } from './components/EmptyState.js';
13
14
  export { ActionBar } from './components/ActionBar.js';
14
15
  export { MasterDetailLayout } from './components/MasterDetailLayout.js';
16
+ export type { MasterDetailLayoutEventMap } from './components/MasterDetailLayout.js';
15
17
  export { Wizard } from './components/Wizard.js';
16
- export type { WizardStep, WizardStepState, WizardStepChangeDetail } from './components/Wizard.js';
18
+ export type { WizardStep, WizardStepState, WizardStepChangeDetail, WizardEventMap } from './components/Wizard.js';
17
19
  export { translate } from './translate.js';
18
20
  export { modernAppLocale, registerLocale, setDefaultLocale, getLocaleStrings, getDefaultLocale, } from './internals/locale.js';
19
21
  export type { ModernAppLocaleStrings, ModernAppMessageKey } from './internals/locale.js';
@@ -19,6 +19,11 @@ export declare class SidebarLayout extends StyledElement<SidebarParts> {
19
19
  progressBarEl: UProgressBar;
20
20
  /** overlay 슬롯 배정 상태 — CSS `:has()`로는 알 수 없다(`internals/slotted.ts` 참조). */
21
21
  private hasOverlay;
22
+ /** 열린 `type: 'menu'` 팝업의 트리거 id — 트리거의 `aria-expanded` 가 읽는다. */
23
+ private openMenu;
24
+ /** `type: 'menu'` 항목마다 고정된 트리거 id — 팝오버의 `for` 가 같은 섀도 루트 안에서 찾는다. */
25
+ private readonly menuIds;
26
+ private static menuSeq;
22
27
  /** notice 슬롯 배정 상태 — 비었으면 스택 자체가 자리를 차지하지 않는다. */
23
28
  private hasNotice;
24
29
  /**
@@ -57,6 +62,19 @@ export declare class SidebarLayout extends StyledElement<SidebarParts> {
57
62
  private readonly unsizedWarnKey;
58
63
  private warnIfUnsized;
59
64
  render(): typeof nothing | import('lit-html').TemplateResult<1>;
65
+ /**
66
+ * `type: 'menu'` — 트리거와 팝업을 **같은 섀도 루트** 에 함께 그린다(팝오버의 `for` 는 섀도 경계를 넘지 못한다 —
67
+ * 종전 `SidebarButtonConfig.id` 가 약속하고 지키지 못한 자리). 배치는 셸 상태가 정한다: 모바일 패널은 화면 폭을
68
+ * 거의 다 차지해 옆에 자리가 없으므로 아래로, 그 밖에는 사이드바 옆으로.
69
+ */
70
+ private renderMenu;
71
+ /** 항목을 고르면 그 동작을 부르고 닫는다 — 포커스는 닫힘 처리(`handleMenuHide`)가 트리거로 되돌린다. */
72
+ private handleMenuPick;
73
+ /**
74
+ * 닫힐 때 포커스가 팝업 안에 있었다면 트리거로 돌린다(APG 메뉴 버튼 — Escape · 항목 선택). 바깥을 눌러 닫힌
75
+ * 경우에는 포커스가 이미 그 바깥 자리로 갔으므로 건드리지 않는다.
76
+ */
77
+ private handleMenuHide;
60
78
  /** 사이드바 아이템 렌더링 */
61
79
  private renderItem;
62
80
  /** 현재 경로와 패턴 매칭 여부 확인 */
@@ -156,6 +174,20 @@ export declare class SidebarLayout extends StyledElement<SidebarParts> {
156
174
  /** 화면 크기 변경에 따른 사이드바 상태 업데이트 */
157
175
  private handleScreenResize;
158
176
  }
177
+ /** Events `<u-sidebar-layout>` dispatches — bubbling and composed. */
178
+ export interface SidebarLayoutEventMap {
179
+ /** The overlay's close button was pressed, or Escape with focus inside the panel. Emptying the `overlay` slot is yours. */
180
+ 'overlay-close': CustomEvent<null>;
181
+ }
182
+ /** Typed listeners for {@link SidebarLayoutEventMap} — element-scoped, the DOM's own pattern (`HTMLMediaElementEventMap`). */
183
+ export interface SidebarLayout {
184
+ addEventListener<K extends keyof SidebarLayoutEventMap>(type: K, listener: (this: SidebarLayout, ev: SidebarLayoutEventMap[K]) => unknown, options?: boolean | AddEventListenerOptions): void;
185
+ addEventListener<K extends keyof HTMLElementEventMap>(type: K, listener: (this: SidebarLayout, ev: HTMLElementEventMap[K]) => unknown, options?: boolean | AddEventListenerOptions): void;
186
+ addEventListener(type: string, listener: EventListenerOrEventListenerObject, options?: boolean | AddEventListenerOptions): void;
187
+ removeEventListener<K extends keyof SidebarLayoutEventMap>(type: K, listener: (this: SidebarLayout, ev: SidebarLayoutEventMap[K]) => unknown, options?: boolean | EventListenerOptions): void;
188
+ removeEventListener<K extends keyof HTMLElementEventMap>(type: K, listener: (this: SidebarLayout, ev: HTMLElementEventMap[K]) => unknown, options?: boolean | EventListenerOptions): void;
189
+ removeEventListener(type: string, listener: EventListenerOrEventListenerObject, options?: boolean | EventListenerOptions): void;
190
+ }
159
191
  declare global {
160
192
  interface HTMLElementTagNameMap {
161
193
  'u-sidebar-layout': SidebarLayout;
@@ -17,20 +17,22 @@ import "@iyulab/components/dist/components/button/UButton.js";
17
17
  import "@iyulab/components/dist/components/icon/UIcon.js";
18
18
  import { unsafeHTML as h } from "lit/directives/unsafe-html.js";
19
19
  import { repeat as g } from "lit/directives/repeat.js";
20
- import { ifDefined as _ } from "lit/directives/if-defined.js";
21
- import { createDevWarner as v } from "@iyulab/components/dist/utilities/devWarning.js";
22
- import { isFocusCandidate as y, querySelectorDeep as b } from "@iyulab/components/dist/utilities/elements.js";
23
- import { OverlayManager as x } from "@iyulab/components/dist/utilities/OverlayManager.js";
24
- import { UProgressBar as S } from "@iyulab/components/dist/components/progress-bar/UProgressBar.js";
20
+ import "@iyulab/components/dist/components/popover/UPopover.js";
21
+ import "@iyulab/components/dist/components/menu/UMenu.js";
22
+ import "@iyulab/components/dist/components/menu-item/UMenuItem.js";
23
+ import { createDevWarner as _ } from "@iyulab/components/dist/utilities/devWarning.js";
24
+ import { isFocusCandidate as v, querySelectorDeep as y } from "@iyulab/components/dist/utilities/elements.js";
25
+ import { OverlayManager as b } from "@iyulab/components/dist/utilities/OverlayManager.js";
26
+ import { UProgressBar as x } from "@iyulab/components/dist/components/progress-bar/UProgressBar.js";
25
27
  //#region src/layouts/SidebarLayout.ts
26
- var C, w = v("@iyulab/modern-app"), T = 0;
27
- function E(e) {
28
+ var S, C = _("@iyulab/modern-app"), w = 0;
29
+ function T(e) {
28
30
  let t = e.tagName;
29
31
  if (t === "INPUT" || t === "TEXTAREA" || t === "SELECT" || e.isContentEditable) return !0;
30
32
  let n = e.getAttribute("role");
31
33
  return n === "textbox" || n === "searchbox" || n === "combobox" || n === "spinbutton";
32
34
  }
33
- var D = /* @__PURE__ */ new Set([
35
+ var E = /* @__PURE__ */ new Set([
34
36
  "button",
35
37
  "link",
36
38
  "checkbox",
@@ -43,27 +45,27 @@ var D = /* @__PURE__ */ new Set([
43
45
  "tab",
44
46
  "treeitem"
45
47
  ]);
46
- function O(e) {
48
+ function D(e) {
47
49
  let t = e.tagName;
48
- return t === "BUTTON" || t === "SUMMARY" || t === "A" && e.hasAttribute("href") ? !0 : D.has(e.getAttribute("role") ?? "");
50
+ return t === "BUTTON" || t === "SUMMARY" || t === "A" && e.hasAttribute("href") ? !0 : E.has(e.getAttribute("role") ?? "");
49
51
  }
50
- function k(e) {
52
+ function O(e) {
51
53
  let t = getComputedStyle(e);
52
54
  return (t.overflowY === "auto" || t.overflowY === "scroll") && e.scrollHeight > e.clientHeight;
53
55
  }
54
- function A(e, t) {
56
+ function k(e, t) {
55
57
  for (let n of e) if (n instanceof HTMLElement) {
56
- if (k(n)) return n === t;
58
+ if (O(n)) return n === t;
57
59
  if (n === t) break;
58
60
  }
59
61
  return !0;
60
62
  }
61
- function j() {
63
+ function A() {
62
64
  let e = document.activeElement;
63
65
  for (; e?.shadowRoot?.activeElement;) e = e.shadowRoot.activeElement;
64
66
  return e;
65
67
  }
66
- function M(e, t) {
68
+ function j(e, t) {
67
69
  let n = t;
68
70
  for (; n;) {
69
71
  if (n === e) return !0;
@@ -71,12 +73,12 @@ function M(e, t) {
71
73
  }
72
74
  return !1;
73
75
  }
74
- var N = class extends i {
76
+ var M = class extends i {
75
77
  static {
76
- C = this;
78
+ S = this;
77
79
  }
78
80
  constructor(...t) {
79
- super(...t), this.state = "default", this.counts = {}, this.locale = "", this.hasOverlay = !1, this.hasNotice = !1, this.overlayTrigger = null, this.internals = typeof this.attachInternals == "function" ? this.attachInternals() : void 0, this.context = null, this.unsizedWarnKey = `unsized:${++T}`, 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 = () => {
81
+ super(...t), this.state = "default", this.counts = {}, this.locale = "", this.hasOverlay = !1, this.openMenu = null, this.menuIds = /* @__PURE__ */ new WeakMap(), this.hasNotice = !1, this.overlayTrigger = null, this.internals = typeof this.attachInternals == "function" ? this.attachInternals() : void 0, this.context = null, this.unsizedWarnKey = `unsized:${++w}`, 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 = () => {
80
82
  let t = this.screenSize ?? e.screen ?? "large";
81
83
  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);
82
84
  }, this.handleBackdropClick = () => {
@@ -102,7 +104,7 @@ var N = class extends i {
102
104
  }, this.handleOverlaySlotChange = (e) => {
103
105
  let t = o(e.target);
104
106
  if (t && !this.hasOverlay) {
105
- let e = j();
107
+ let e = A();
106
108
  this.overlayTrigger = e && e !== document.body ? e : null;
107
109
  }
108
110
  this.hasOverlay = t;
@@ -116,9 +118,9 @@ var N = class extends i {
116
118
  }, this._handleMainKeydown = (e) => {
117
119
  if (e.defaultPrevented) return;
118
120
  let t = e.composedPath()[0];
119
- if (t instanceof HTMLElement && E(t) || e.key === " " && t instanceof HTMLElement && O(t)) return;
121
+ if (t instanceof HTMLElement && T(t) || e.key === " " && t instanceof HTMLElement && D(t)) return;
120
122
  let n = this.shadowRoot?.querySelector(".main");
121
- if (!n || !A(e.composedPath(), n)) return;
123
+ if (!n || !k(e.composedPath(), n)) return;
122
124
  let r = n.clientHeight;
123
125
  switch (e.key) {
124
126
  case " ":
@@ -151,6 +153,9 @@ var N = class extends i {
151
153
  static {
152
154
  this.styles = [super.styles, c];
153
155
  }
156
+ static {
157
+ this.menuSeq = 0;
158
+ }
154
159
  get mainElement() {
155
160
  return this.shadowRoot?.querySelector(".main") ?? null;
156
161
  }
@@ -158,13 +163,13 @@ var N = class extends i {
158
163
  super.connectedCallback(), window.addEventListener("route-begin", this.handleRouteBegin), window.addEventListener("route-done", this.handleRouteDone), window.addEventListener("route-progress", this.handleRouteProgress), window.addEventListener("route-error", this.handleRouteError), window.addEventListener("screen-resize", this.handleScreenResize);
159
164
  }
160
165
  disconnectedCallback() {
161
- window.removeEventListener("route-begin", this.handleRouteBegin), window.removeEventListener("route-done", this.handleRouteDone), window.removeEventListener("route-progress", this.handleRouteProgress), window.removeEventListener("route-error", this.handleRouteError), window.removeEventListener("screen-resize", this.handleScreenResize), x.closeLayer(this), super.disconnectedCallback();
166
+ window.removeEventListener("route-begin", this.handleRouteBegin), window.removeEventListener("route-done", this.handleRouteDone), window.removeEventListener("route-progress", this.handleRouteProgress), window.removeEventListener("route-error", this.handleRouteError), window.removeEventListener("screen-resize", this.handleScreenResize), b.closeLayer(this), super.disconnectedCallback();
162
167
  }
163
168
  willUpdate(e) {
164
169
  super.willUpdate(e), e.has("config") && (this.styles = this.config?.styles);
165
170
  }
166
171
  updated(e) {
167
- super.updated(e), e.has("hasOverlay") && (this.internals?.states?.[this.hasOverlay ? "add" : "delete"]("overlay"), this.hasOverlay ? (x.openLayer(this, this.handleOverlayEscape), this.focusOverlay()) : e.get("hasOverlay") === !0 && (x.closeLayer(this), this.restoreOverlayFocus()));
172
+ super.updated(e), e.has("hasOverlay") && (this.internals?.states?.[this.hasOverlay ? "add" : "delete"]("overlay"), this.hasOverlay ? (b.openLayer(this, this.handleOverlayEscape), this.focusOverlay()) : e.get("hasOverlay") === !0 && (b.closeLayer(this), this.restoreOverlayFocus()));
168
173
  }
169
174
  firstUpdated(e) {
170
175
  super.firstUpdated(e), this.warnIfUnsized();
@@ -173,7 +178,7 @@ var N = class extends i {
173
178
  process.env.NODE_ENV !== "production" && requestAnimationFrame(() => {
174
179
  if (!this.isConnected) return;
175
180
  let e = this.getBoundingClientRect().height;
176
- e >= 200 || w(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).`);
181
+ e >= 200 || C(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).`);
177
182
  });
178
183
  }
179
184
  render() {
@@ -260,6 +265,46 @@ var N = class extends i {
260
265
  ></div>
261
266
  ` : u;
262
267
  }
268
+ renderMenu(e) {
269
+ let t = this.menuIds.get(e);
270
+ t || (t = `u-sidebar-menu-${++S.menuSeq}`, this.menuIds.set(e, t));
271
+ let n = t, r = this.state === "mobile" || this.state === "mobile-open" ? "bottom-start" : "right-start";
272
+ return l`
273
+ <u-sidebar-button
274
+ id=${n}
275
+ haspopup="menu"
276
+ .expanded=${this.openMenu === n}
277
+ ?compact=${this.state === "slim"}
278
+ .icon="${e.icon}"
279
+ .lib="${e.lib}"
280
+ .label="${e.label}"
281
+ ></u-sidebar-button>
282
+ <u-popover for="#${n}" placement=${r} autofocus
283
+ @show=${() => {
284
+ this.openMenu = n;
285
+ }}
286
+ @hide=${(e) => this.handleMenuHide(e, n)}
287
+ @pick=${(t) => this.handleMenuPick(t, e)}>
288
+ <u-menu>
289
+ ${g(e.items, (e, t) => t, (e, t) => l`
290
+ <u-menu-item value=${String(t)} ?disabled=${e.disabled === !0}>
291
+ ${e.icon ? l`<u-icon slot="prefix" .lib=${e.lib} .name=${e.icon}></u-icon>` : u}
292
+ ${e.label}
293
+ </u-menu-item>
294
+ `)}
295
+ </u-menu>
296
+ </u-popover>
297
+ `;
298
+ }
299
+ handleMenuPick(e, t) {
300
+ let n = t.items[Number(e.detail.value)];
301
+ n && !n.disabled && (e.currentTarget.hide(), n.onClick?.(e));
302
+ }
303
+ handleMenuHide(e, t) {
304
+ this.openMenu === t && (this.openMenu = null);
305
+ let n = e.currentTarget, r = A();
306
+ r && j(n, r) && this.shadowRoot?.querySelector(`#${t}`)?.focus();
307
+ }
263
308
  renderItem(e) {
264
309
  if (!e) return u;
265
310
  if (e.type === "html") {
@@ -268,7 +313,6 @@ var N = class extends i {
268
313
  }
269
314
  if (e.type === "button") return l`
270
315
  <u-sidebar-button
271
- id=${_(e.id)}
272
316
  ?compact=${this.state === "slim"}
273
317
  .icon="${e.icon}"
274
318
  .lib="${e.lib}"
@@ -277,6 +321,7 @@ var N = class extends i {
277
321
  @click="${e.onClick}"
278
322
  ></u-sidebar-button>
279
323
  `;
324
+ if (e.type === "menu") return this.renderMenu(e);
280
325
  if (e.type === "link") {
281
326
  let t = this.isMatchedLink(e.pattern || e.href);
282
327
  return l`
@@ -331,7 +376,7 @@ var N = class extends i {
331
376
  };
332
377
  }
333
378
  icon(e) {
334
- return this.config?.icons?.[e] ?? C.DEFAULT_ICONS[e];
379
+ return this.config?.icons?.[e] ?? S.DEFAULT_ICONS[e];
335
380
  }
336
381
  renderLogo() {
337
382
  let e = this.config?.logo, t = this.config?.title ?? "Home";
@@ -368,14 +413,14 @@ var N = class extends i {
368
413
  async focusRoute(e) {
369
414
  let t = this.routeContent();
370
415
  if (await Promise.all(t.map((e) => e.updateComplete)), this.hasOverlay || !e.isConnected) return;
371
- let n = j();
372
- n && t.some((e) => M(e, n)) || this.focusContentStart(t, e);
416
+ let n = A();
417
+ n && t.some((e) => j(e, n)) || this.focusContentStart(t, e);
373
418
  }
374
419
  focusContentStart(e, t) {
375
- let n = b(e, "[autofocus]", y);
420
+ let n = y(e, "[autofocus]", v);
376
421
  n?.focus();
377
- let r = j();
378
- n && r && M(n, r) || t.focus({ preventScroll: !0 });
422
+ let r = A();
423
+ n && r && j(n, r) || t.focus({ preventScroll: !0 });
379
424
  }
380
425
  overlayPanels() {
381
426
  return (this.shadowRoot?.querySelector("slot[name=\"overlay\"]"))?.assignedElements({ flatten: !0 }) ?? [];
@@ -383,19 +428,19 @@ var N = class extends i {
383
428
  async focusOverlay() {
384
429
  let e = this.overlayPanels();
385
430
  if (await Promise.all(e.map((e) => e.updateComplete)), !this.hasOverlay) return;
386
- let t = j();
387
- t && e.some((e) => M(e, t)) || (b(e, "[autofocus]", y) ?? b(e, "input, select, textarea, u-input, u-textarea, u-select, u-checkbox, u-radio, u-switch, u-slider", y) ?? this.shadowRoot?.querySelector(".overlay-close"))?.focus();
431
+ let t = A();
432
+ t && e.some((e) => j(e, t)) || (y(e, "[autofocus]", v) ?? y(e, "input, select, textarea, u-input, u-textarea, u-select, u-checkbox, u-radio, u-switch, u-slider", v) ?? this.shadowRoot?.querySelector(".overlay-close"))?.focus();
388
433
  }
389
434
  restoreOverlayFocus() {
390
435
  let e = this.overlayTrigger;
391
436
  this.overlayTrigger = null;
392
- let t = j();
437
+ let t = A();
393
438
  t && t !== document.body || (e?.isConnected ? e.focus() : this.mainElement?.focus({ preventScroll: !0 }));
394
439
  }
395
440
  };
396
441
  r([f({
397
442
  type: String,
398
443
  reflect: !0
399
- }), n("design:type", Object)], N.prototype, "state", void 0), r([f({ type: Object }), n("design:type", Object)], N.prototype, "config", void 0), r([f({ type: Object }), n("design:type", typeof Record > "u" ? Object : Record)], N.prototype, "counts", void 0), r([f({ type: String }), n("design:type", Object)], N.prototype, "locale", void 0), r([p("u-progress-bar"), n("design:type", S === void 0 ? Object : S)], N.prototype, "progressBarEl", void 0), r([m(), n("design:type", Object)], N.prototype, "hasOverlay", void 0), r([m(), n("design:type", Object)], N.prototype, "hasNotice", void 0), r([m(), n("design:type", Object)], N.prototype, "context", void 0), N = C = r([d("u-sidebar-layout")], N);
444
+ }), n("design:type", Object)], M.prototype, "state", void 0), r([f({ type: Object }), n("design:type", Object)], M.prototype, "config", void 0), r([f({ type: Object }), n("design:type", typeof Record > "u" ? Object : Record)], M.prototype, "counts", void 0), r([f({ type: String }), n("design:type", Object)], M.prototype, "locale", void 0), r([p("u-progress-bar"), n("design:type", x === void 0 ? Object : x)], M.prototype, "progressBarEl", void 0), r([m(), n("design:type", Object)], M.prototype, "hasOverlay", void 0), r([m(), n("design:type", Object)], M.prototype, "openMenu", void 0), r([m(), n("design:type", Object)], M.prototype, "hasNotice", void 0), r([m(), n("design:type", Object)], M.prototype, "context", void 0), M = S = r([d("u-sidebar-layout")], M);
400
445
  //#endregion
401
- export { N as SidebarLayout };
446
+ export { M as SidebarLayout };
@@ -1,4 +1,5 @@
1
1
  import { TemplateResult } from 'lit';
2
+ import { DirectiveResult } from 'lit/directive.js';
2
3
  import { RouteContext } from '@iyulab/router';
3
4
  import { StyleMap } from '../internals/StyledElement';
4
5
  import { SidebarLinkConfig } from '../components/SidebarLink';
@@ -58,8 +59,33 @@ export interface SidebarLogoImage {
58
59
  export type SidebarLogoRenderer = (state: SidebarState) => TemplateResult<1> | HTMLElement | string;
59
60
  /** 최상단 앱 로고 설정: 아이콘명(문자열, 기존 동작) | 이미지 | 커스텀 렌더 함수 */
60
61
  export type SidebarLogoConfig = string | SidebarLogoImage | SidebarLogoRenderer;
61
- /** union: section | group | link | button */
62
- export type SidebarItem = (SidebarLinkConfig | SidebarSectionConfig | SidebarGroupConfig | SidebarButtonConfig | SidebarHtmlConfig);
62
+ /** One entry of a `type: 'menu'` popup. */
63
+ export interface SidebarMenuItemConfig extends SidebarPermissionGuard {
64
+ label: string | DirectiveResult;
65
+ icon?: string;
66
+ /** Icon library for `icon` (see `SidebarButtonConfig.lib`). */
67
+ lib?: string;
68
+ disabled?: boolean;
69
+ /** Runs when the entry is chosen (click, Enter or Space). The menu closes and focus returns to its trigger. */
70
+ onClick?: (event?: Event) => void;
71
+ }
72
+ /**
73
+ * A sidebar button that opens a popup menu — the flyout counterpart of `type: 'group'` (which expands in
74
+ * place). The shell draws the trigger and the menu together and owns what only it knows: the menu opens
75
+ * sideways beside the sidebar and downwards when the sidebar is the full-width mobile panel. It sets
76
+ * `aria-haspopup`/`aria-expanded` on the trigger, moves focus into the menu when it opens, and returns
77
+ * focus to the trigger on Escape or after an entry is chosen.
78
+ */
79
+ export interface SidebarMenuConfig extends SidebarPermissionGuard {
80
+ type: 'menu';
81
+ /** The trigger's label — also its accessible name when the sidebar is slim. */
82
+ label: string | DirectiveResult;
83
+ icon?: string;
84
+ lib?: string;
85
+ items: SidebarMenuItemConfig[];
86
+ }
87
+ /** union: section | group | link | button | menu | html */
88
+ export type SidebarItem = (SidebarLinkConfig | SidebarSectionConfig | SidebarGroupConfig | SidebarButtonConfig | SidebarMenuConfig | SidebarHtmlConfig);
63
89
  /** 사이드바 레이아웃 전체 설정 */
64
90
  export interface SidebarLayoutConfig {
65
91
  type: 'sidebar';
@@ -4,6 +4,7 @@ import { SidebarItem } from './SidebarLayout.types';
4
4
  *
5
5
  * - 각 항목의 `requirePermission`/`requireAnyPermission` 를 `hasPermission` 으로 검사해 탈락 항목 제거.
6
6
  * - `section`/`group` 은 자식(`items`)을 재귀 필터하고, **자식이 모두 걸러지면 컨테이너째 제거**한다.
7
+ * - `menu` 는 팝업 항목(`items`)을 같은 가드로 거르고, 남는 항목이 없으면 트리거째 제거한다.
7
8
  * - `hasPermission` 이 없으면(undefined) 원본을 그대로 반환한다(필터링 없음).
8
9
  *
9
10
  * 원본 배열/항목을 변형하지 않고 새 배열/얕은 복제본을 반환한다(순수).
@@ -13,6 +13,13 @@ function t(n, r) {
13
13
  ...r,
14
14
  items: e
15
15
  });
16
+ } else if (r.type === "menu") {
17
+ let t = r.items.filter((t) => e(t, i));
18
+ if (t.length === 0) continue;
19
+ a.push({
20
+ ...r,
21
+ items: t
22
+ });
16
23
  } else a.push(r);
17
24
  }
18
25
  return a;
package/dist/react.d.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import { default as React } from 'react';
2
2
  import { EventName } from '@lit/react';
3
- import { SidebarLayout as SidebarLayoutElement } from './layouts/SidebarLayout.js';
4
- import { Wizard as WizardElement, WizardStepChangeDetail } from './components/Wizard.js';
3
+ import { SidebarLayout as SidebarLayoutElement, SidebarLayoutEventMap } from './layouts/SidebarLayout.js';
4
+ import { Wizard as WizardElement, WizardEventMap } from './components/Wizard.js';
5
5
  import { GroupBoxLevel } from './components/GroupBox.js';
6
6
  import { InfoFieldFormat, InfoFieldSize, InfoFieldTrend, InfoFieldTone } from './components/InfoField.js';
7
7
  export { ScreenObserver } from './internals/ScreenObserver.js';
@@ -12,7 +12,7 @@ export type { ScreenSize, ScreenObserverConfig, ScreenResizeEvent } from './inte
12
12
  * React 18 (JSX attributes stringify), so this wrapper keeps `config` working on both.
13
13
  */
14
14
  export declare const SidebarLayout: import('@lit/react').ReactWebComponent<SidebarLayoutElement, {
15
- onOverlayClose: EventName<CustomEvent>;
15
+ onOverlayClose: EventName<SidebarLayoutEventMap["overlay-close"]>;
16
16
  }>;
17
17
  export type SidebarLayoutProps = React.ComponentProps<typeof SidebarLayout>;
18
18
  /**
@@ -23,11 +23,11 @@ export type SidebarLayoutProps = React.ComponentProps<typeof SidebarLayout>;
23
23
  * wrapper keeps working on React 18 the same way it does for `SidebarLayout`'s `config`.
24
24
  */
25
25
  export declare const Wizard: import('@lit/react').ReactWebComponent<WizardElement, {
26
- onStepChange: EventName<CustomEvent<WizardStepChangeDetail>>;
26
+ onStepChange: EventName<WizardEventMap["step-change"]>;
27
27
  }>;
28
28
  export type WizardProps = React.ComponentProps<typeof Wizard>;
29
29
  export type { SidebarLayoutElement, WizardElement };
30
- export type { SidebarLayoutConfig, SidebarItem, SidebarLogoConfig, SidebarLinkConfig, SidebarSectionConfig, SidebarGroupConfig, SidebarButtonConfig, SidebarHtmlConfig, } from './layouts/SidebarLayout.types.js';
30
+ export type { SidebarLayoutConfig, SidebarItem, SidebarLogoConfig, SidebarLinkConfig, SidebarSectionConfig, SidebarGroupConfig, SidebarButtonConfig, SidebarMenuConfig, SidebarMenuItemConfig, SidebarHtmlConfig, } from './layouts/SidebarLayout.types.js';
31
31
  export type { WizardStep, WizardStepState, WizardStepChangeDetail } from './components/Wizard.js';
32
32
  declare module 'react' {
33
33
  namespace JSX {
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.35.0",
4
+ "version": "0.36.0",
5
5
  "keywords": [
6
6
  "iyulab",
7
7
  "web-framework",
@@ -176,7 +176,6 @@ Action button — triggers a callback instead of navigating.
176
176
  ```typescript
177
177
  interface SidebarButtonConfig {
178
178
  type: 'button';
179
- id?: string;
180
179
  icon?: string;
181
180
  lib?: string;
182
181
  label: string | DirectiveResult;
@@ -191,11 +190,54 @@ Example:
191
190
  { type: 'button', icon: 'logout', label: 'Sign Out', onClick: () => auth.signOut() }
192
191
  ```
193
192
 
194
- `id` is passed straight through to the rendered `<u-sidebar-button>` host. **It does not enable
195
- anchoring a `u-popover` you place outside the layout** — the button lives inside
196
- `<u-sidebar-layout>`'s own shadow root, and `querySelector`/`for="#id"` never crosses a shadow
197
- boundary. If you need a popover anchored to a sidebar item, use `type: 'html'` and assemble both
198
- inside the same template — see [popup-style submenus](#popup-style-submenus-u-popover) below.
193
+ For a button that opens a menu, use `type: 'menu'` below — not a button plus your own popover.
194
+
195
+ ---
196
+
197
+ ### `SidebarMenuConfig` — `type: 'menu'`
198
+
199
+ A sidebar button that opens a popup menu — the flyout counterpart of `type: 'group'` (which expands
200
+ in place).
201
+
202
+ ```typescript
203
+ interface SidebarMenuConfig {
204
+ type: 'menu';
205
+ label: string | DirectiveResult; // the trigger's label (its accessible name when slim)
206
+ icon?: string;
207
+ lib?: string;
208
+ items: SidebarMenuItemConfig[];
209
+ }
210
+
211
+ interface SidebarMenuItemConfig {
212
+ label: string | DirectiveResult;
213
+ icon?: string;
214
+ lib?: string;
215
+ disabled?: boolean;
216
+ onClick?: (event?: Event) => void; // runs when chosen; the menu closes
217
+ }
218
+ ```
219
+
220
+ Example:
221
+
222
+ ```typescript
223
+ {
224
+ type: 'menu', icon: 'three-dots', label: 'More',
225
+ items: [
226
+ { icon: 'download', label: 'Export', onClick: () => exportAll() },
227
+ { icon: 'gear', label: 'Settings', onClick: () => app.navigate('/settings') },
228
+ ],
229
+ }
230
+ ```
231
+
232
+ The shell owns what only it knows:
233
+
234
+ - **Placement** — beside the sidebar, and downwards when the sidebar is the full-width mobile panel
235
+ (`mobile`/`mobile-open`), where there is no room beside it.
236
+ - **Keyboard and focus** — the trigger carries `aria-haspopup="menu"` and `aria-expanded`; opening
237
+ moves focus to the first entry; Escape or choosing an entry closes the menu and returns focus to
238
+ the trigger.
239
+ - **Permissions** — each entry takes `requirePermission`/`requireAnyPermission`; with
240
+ `hasPermission` set, a menu left with no entries is dropped.
199
241
 
200
242
  ---
201
243
 
@@ -229,41 +271,24 @@ Example:
229
271
 
230
272
  ---
231
273
 
232
- ### Popup-style submenus (`u-popover`)
274
+ ### A custom flyout (`type: 'html'`)
233
275
 
234
- For a submenu that flies out from a sidebar item (rather than expanding in place like
235
- `SidebarGroupConfig`), assemble a `u-popover` and its trigger together inside a single
236
- `type: 'html'` item — both then live in the sidebar layout's own shadow root, which is required
237
- for `for="#id"` anchoring to resolve (see the `id` note above).
276
+ `type: 'menu'` covers a list of actions. For anything else that flies out of the sidebar (a form, a
277
+ card), assemble the trigger and a `u-popover` inside one `type: 'html'` item — both must live in the
278
+ sidebar's shadow root, because `for="#id"` never crosses a shadow boundary.
238
279
 
239
280
  ```typescript
240
281
  {
241
282
  type: 'html',
242
- render: (state) => html`
243
- <u-sidebar-button id="more-trigger" icon="three-dots" label="More"></u-sidebar-button>
244
- <u-popover for="#more-trigger" placement=${state.startsWith('mobile') ? 'bottom-start' : 'right-start'}>
245
- <u-menu>
246
- <u-menu-item @click=${doA}>Action A</u-menu-item>
247
- <u-menu-item @click=${doB}>Action B</u-menu-item>
248
- </u-menu>
249
- </u-popover>
283
+ render: () => html`
284
+ <u-sidebar-button id="quick-note" icon="pencil" label="Note"></u-sidebar-button>
285
+ <u-popover for="#quick-note" placement="right-start">…</u-popover>
250
286
  `,
251
287
  }
252
288
  ```
253
289
 
254
- **A fixed `placement` is safe as of `@iyulab/components@1.37.1`.** On `mobile`/`mobile-open`
255
- the sidebar widens to occupy nearly the full screen, so a sideways placement (`right-start`,
256
- the natural desktop flyout) has room on neither side. `flip()` now falls back **across the**
257
- **axis** in exactly that case, landing the popover vertically instead of off-screen.
258
-
259
- ⚠ Against an older `components` it did render off-screen and invisible — `flip()` only ever
260
- considered the opposite side on the same axis, found no room there either, and gave up. If you
261
- pin below `1.37.1`, keep choosing `placement` from `state` as the snippet above does.
262
-
263
- Choosing from `state` is still reasonable when you want to *decide* the direction rather than
264
- let `flip()` pick it. Either way this is not a `strategy="absolute"` vs `"fixed"` distinction —
265
- switching strategy changes nothing. Both behaviours are pinned in
266
- `tests/browser/sidebar-popover-submenu.browser.test.ts`.
290
+ A fixed sideways `placement` stays on screen on the mobile panel as of `@iyulab/components@1.37.1`
291
+ (`flip()` falls back across the axis); `tests/browser/sidebar-popover-submenu.browser.test.ts` pins it.
267
292
 
268
293
  ---
269
294