@iyulab/modern-app 0.35.1 → 0.37.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,39 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.37.0] - 2026-10-06
4
+
5
+ ### Added
6
+
7
+ - **`u-timeline` and `u-timeline-item` — a status or activity history** for detail screens ("what
8
+ happened, and when"). Each entry is a marker on a rail, a `heading`, a `datetime` rendered as
9
+ `<time datetime>` and formatted for the active locale (a date alone as that calendar day, a
10
+ date-time with its time; the `time` slot replaces the text), and an optional body. `color` takes the
11
+ role colours of `u-tag`/`u-badge` — the marker is drawn with the role's fill, the colour on it and its
12
+ strong step as an edge, so a warning marker still shows on the page — and `icon` draws inside it. The
13
+ list is announced as a list. Parts: `rail` · `marker` · `line` · `content` · `header` · `heading` ·
14
+ `time` · `body`; knobs `--app-timeline-rail-width` and `--app-timeline-marker-size`.
15
+
16
+ ## [0.36.0] - 2026-10-06
17
+
18
+ ### Added
19
+
20
+ - **`type: 'menu'` sidebar items — a button that opens a popup menu.** `{ type: 'menu', label, icon,
21
+ items: [{ label, icon, onClick, disabled }] }`. The shell draws the trigger and the menu together and
22
+ owns what only it knows: the menu opens beside the sidebar and downwards on the full-width mobile
23
+ panel; the trigger carries `aria-haspopup="menu"` and `aria-expanded`; opening moves focus into the
24
+ menu, and Escape or choosing an entry closes it and returns focus to the trigger. Entries take
25
+ `requirePermission`/`requireAnyPermission`, and a menu left with no entries is dropped.
26
+ - The sidebar config types (`SidebarItem`, `SidebarLinkConfig`, `SidebarMenuConfig`, …, `SidebarState`)
27
+ are exported from the main entry too — they were only on `/react`, so a TypeScript app without React
28
+ could not name them.
29
+
30
+ ### Removed (breaking)
31
+
32
+ - **`SidebarButtonConfig.id`.** It was meant for anchoring a `u-popover` you place outside the layout,
33
+ but the button renders inside the layout's shadow root, where `for="#id"` never reaches — it never
34
+ worked. Use `type: 'menu'` for a menu, or `type: 'html'` with the trigger and the popover in one
35
+ template for any other flyout.
36
+
3
37
  ## [0.35.1] - 2026-10-06
4
38
 
5
39
  ### Fixed
@@ -6,8 +6,8 @@ import { slotHasContent as i } from "../internals/slotted.js";
6
6
  import { styles as a } from "./MasterDetailLayout.styles.js";
7
7
  import { html as o } from "lit";
8
8
  import { customElement as s, property as c, state as l } from "lit/decorators.js";
9
- import "@iyulab/components/dist/components/button/UButton.js";
10
9
  import "@iyulab/components/dist/components/icon/UIcon.js";
10
+ import "@iyulab/components/dist/components/button/UButton.js";
11
11
  //#region src/components/MasterDetailLayout.ts
12
12
  var u = class extends n {
13
13
  constructor(...e) {
@@ -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 };
@@ -0,0 +1,63 @@
1
+ import { StyledElement } from '../internals/StyledElement.js';
2
+ type TimelineParts = 'host';
3
+ type TimelineItemParts = 'host' | 'rail' | 'marker' | 'line' | 'content' | 'header' | 'heading' | 'time' | 'body';
4
+ /** The role colours `u-tag`/`u-badge` use — the marker's colour. */
5
+ export type TimelineItemColor = 'neutral' | 'primary' | 'info' | 'success' | 'warning' | 'danger';
6
+ /**
7
+ * A status or activity history — "what happened, and when" on a detail screen (order, ticket,
8
+ * approval). An ordered list of {@link TimelineItem}s, each a marker on a rail, a heading, a time
9
+ * and an optional body.
10
+ *
11
+ * ```html
12
+ * <u-timeline>
13
+ * <u-timeline-item heading="Paid" datetime="2026-10-06T09:30" color="success" icon="check">
14
+ * Bank transfer received.
15
+ * </u-timeline-item>
16
+ * <u-timeline-item heading="Ordered" datetime="2026-10-05T14:02"></u-timeline-item>
17
+ * </u-timeline>
18
+ * ```
19
+ *
20
+ * The items are shown in DOM order — put the newest first or last as your screen reads. The host is
21
+ * a list and each item a list item, so assistive technology announces how many entries there are.
22
+ */
23
+ export declare class Timeline extends StyledElement<TimelineParts> {
24
+ static styles: import('lit').CSSResultGroup[];
25
+ connectedCallback(): void;
26
+ render(): import('lit-html').TemplateResult<1>;
27
+ }
28
+ /**
29
+ * One entry of a {@link Timeline}.
30
+ *
31
+ * - `heading` names the event ("Shipped"); the `heading` slot takes richer content.
32
+ * - `datetime` is an ISO date or date-time. It is rendered as `<time datetime>` and formatted for the
33
+ * active locale (`Locale`) — a date alone as a date, a date-time with its time. The `time` slot
34
+ * replaces the text (a relative "2 hours ago", say); `datetime` still annotates it.
35
+ * - `color` tints the marker. ⚠Colour is never the only carrier — say the status in `heading` too
36
+ * (WCAG 1.4.1). `icon` (and `lib`) draws inside the marker.
37
+ */
38
+ export declare class TimelineItem extends StyledElement<TimelineItemParts> {
39
+ static styles: import('lit').CSSResultGroup[];
40
+ /** What happened. */
41
+ heading: string;
42
+ /** When — ISO `YYYY-MM-DD` or `YYYY-MM-DDTHH:mm[:ss]`. */
43
+ datetime: string;
44
+ /** Marker colour. Default `neutral`. */
45
+ color: TimelineItemColor;
46
+ /** Icon drawn inside the marker. */
47
+ icon?: string;
48
+ /** Icon library for `icon`. */
49
+ lib?: string;
50
+ /** Body slot assignment — CSS `:has()` cannot see slotted content (`internals/slotted.ts`). */
51
+ private hasBody;
52
+ connectedCallback(): void;
53
+ /** Locale text for `datetime` — the raw value when it is not a date the formatter can read. */
54
+ private get timeText();
55
+ render(): import('lit-html').TemplateResult<1>;
56
+ }
57
+ declare global {
58
+ interface HTMLElementTagNameMap {
59
+ 'u-timeline': Timeline;
60
+ 'u-timeline-item': TimelineItem;
61
+ }
62
+ }
63
+ export {};
@@ -0,0 +1,68 @@
1
+ import e from "../_virtual/_@oxc-project_runtime@0.152.0/helpers/esm/decorateMetadata.js";
2
+ import t from "../_virtual/_@oxc-project_runtime@0.152.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 { itemStyles as i, styles as a } from "./Timeline.styles.js";
6
+ import { html as o, nothing as s } from "lit";
7
+ import { customElement as c, property as l, state as u } from "lit/decorators.js";
8
+ import { formatDate as d } from "@iyulab/components/dist/utilities/format.js";
9
+ import "@iyulab/components/dist/components/icon/UIcon.js";
10
+ //#region src/components/Timeline.ts
11
+ var f = class extends n {
12
+ static {
13
+ this.styles = [super.styles, a];
14
+ }
15
+ connectedCallback() {
16
+ super.connectedCallback(), this.hasAttribute("role") || this.setAttribute("role", "list");
17
+ }
18
+ render() {
19
+ return o`<slot></slot>`;
20
+ }
21
+ };
22
+ f = t([c("u-timeline")], f);
23
+ var p = class extends n {
24
+ constructor(...e) {
25
+ super(...e), this.heading = "", this.datetime = "", this.color = "neutral", this.hasBody = !1;
26
+ }
27
+ static {
28
+ this.styles = [super.styles, i];
29
+ }
30
+ connectedCallback() {
31
+ super.connectedCallback(), this.hasAttribute("role") || this.setAttribute("role", "listitem");
32
+ }
33
+ get timeText() {
34
+ let e = this.datetime.trim();
35
+ if (!e) return "";
36
+ if (!e.includes("T")) return d(e, { dateStyle: "medium" });
37
+ let t = new Date(e);
38
+ return Number.isNaN(t.getTime()) ? e : d(t, {
39
+ dateStyle: "medium",
40
+ timeStyle: "short"
41
+ });
42
+ }
43
+ render() {
44
+ return o`
45
+ <div class="rail" part="rail" aria-hidden="true">
46
+ <span class="marker ${this.icon ? "with-icon" : ""}" part="marker">
47
+ ${this.icon ? o`<u-icon .lib=${this.lib} .name=${this.icon}></u-icon>` : s}
48
+ </span>
49
+ <span class="line" part="line"></span>
50
+ </div>
51
+ <div class="content" part="content">
52
+ <div class="header" part="header">
53
+ <span class="heading" part="heading"><slot name="heading">${this.heading}</slot></span>
54
+ ${this.datetime ? o`<time class="time" part="time" datetime=${this.datetime}><slot name="time">${this.timeText}</slot></time>` : o`<span class="time" part="time"><slot name="time"></slot></span>`}
55
+ </div>
56
+ <div class="body ${this.hasBody ? "" : "empty"}" part="body">
57
+ <slot @slotchange=${(e) => this.hasBody = r(e.target)}></slot>
58
+ </div>
59
+ </div>
60
+ `;
61
+ }
62
+ };
63
+ t([l({ type: String }), e("design:type", Object)], p.prototype, "heading", void 0), t([l({ type: String }), e("design:type", Object)], p.prototype, "datetime", void 0), t([l({
64
+ type: String,
65
+ reflect: !0
66
+ }), e("design:type", Object)], p.prototype, "color", void 0), t([l({ type: String }), e("design:type", String)], p.prototype, "icon", void 0), t([l({ type: String }), e("design:type", String)], p.prototype, "lib", void 0), t([u(), e("design:type", Object)], p.prototype, "hasBody", void 0), p = t([c("u-timeline-item")], p);
67
+ //#endregion
68
+ export { f as Timeline, p as TimelineItem };
@@ -0,0 +1,2 @@
1
+ export declare const styles: import('lit').CSSResult;
2
+ export declare const itemStyles: import('lit').CSSResult;
@@ -0,0 +1,125 @@
1
+ import { css as e } from "lit";
2
+ //#region src/components/Timeline.styles.ts
3
+ var t = e`
4
+ :host {
5
+ display: block;
6
+ }
7
+ `, n = e`
8
+ :host {
9
+ display: grid;
10
+ grid-template-columns: var(--app-timeline-rail-width, 24px) minmax(0, 1fr);
11
+ column-gap: var(--u-space-md, 12px);
12
+ }
13
+
14
+ /* The rail: a marker, then a line down to the next entry's marker. */
15
+ .rail {
16
+ display: flex;
17
+ flex-direction: column;
18
+ align-items: center;
19
+ }
20
+
21
+ .marker {
22
+ flex: none;
23
+ display: inline-flex;
24
+ align-items: center;
25
+ justify-content: center;
26
+ box-sizing: border-box;
27
+ width: var(--app-timeline-marker-size, 12px);
28
+ height: var(--app-timeline-marker-size, 12px);
29
+ /* Centred on the heading's first line (label size × leading). */
30
+ margin-top: calc((var(--u-text-label-size, 13px) * var(--u-text-label-leading, 1.5) - var(--app-timeline-marker-size, 12px)) / 2);
31
+ border-radius: 50%;
32
+ /* Fill and the colour on it come as a pair (the components contract — warning carries dark text,
33
+ not white). The edge is the strong step: a yellow fill alone is about 1.4:1 against the page. */
34
+ background-color: var(--_marker, var(--u-border-color-strong, #BDBDBD));
35
+ color: var(--_on-marker, var(--u-txt-color, #212121));
36
+ border: 1px solid var(--_edge, var(--u-border-color-strong, #BDBDBD));
37
+ }
38
+ .marker.with-icon {
39
+ width: var(--app-timeline-rail-width, 24px);
40
+ height: var(--app-timeline-rail-width, 24px);
41
+ margin-top: calc((var(--u-text-label-size, 13px) * var(--u-text-label-leading, 1.5) - var(--app-timeline-rail-width, 24px)) / 2);
42
+ font-size: var(--u-text-caption-size, 12px);
43
+ }
44
+
45
+ :host([color="primary"]) {
46
+ --_marker: var(--u-primary-color, #1976D2);
47
+ --_on-marker: var(--u-primary-txt-color, #FFFFFF);
48
+ --_edge: var(--u-primary-color-strong, #1565C0);
49
+ }
50
+ :host([color="info"]) {
51
+ --_marker: var(--u-info-color, #1976D2);
52
+ --_on-marker: var(--u-info-txt-color, #FFFFFF);
53
+ --_edge: var(--u-info-color-strong, #1565C0);
54
+ }
55
+ :host([color="success"]) {
56
+ --_marker: var(--u-success-color, #2E7D32);
57
+ --_on-marker: var(--u-success-txt-color, #FFFFFF);
58
+ --_edge: var(--u-success-color-strong, #1B5E20);
59
+ }
60
+ :host([color="warning"]) {
61
+ --_marker: var(--u-warning-color, #FDD835);
62
+ --_on-marker: var(--u-warning-txt-color, #000000);
63
+ --_edge: var(--u-warning-color-strong, #8A4A00);
64
+ }
65
+ :host([color="danger"]) {
66
+ --_marker: var(--u-danger-color, #D32F2F);
67
+ --_on-marker: var(--u-danger-txt-color, #FFFFFF);
68
+ --_edge: var(--u-danger-color-strong, #C62828);
69
+ }
70
+
71
+ .line {
72
+ flex: 1 1 auto;
73
+ min-height: var(--u-space-md, 12px);
74
+ margin-top: var(--u-space-2xs, 4px);
75
+ border-left: 1px solid var(--u-border-color, #E0E0E0);
76
+ }
77
+ /* The last entry has no next marker to reach. */
78
+ :host(:last-child) .line {
79
+ display: none;
80
+ }
81
+
82
+ .content {
83
+ min-width: 0;
84
+ padding-bottom: var(--u-space-lg, 16px);
85
+ }
86
+ :host(:last-child) .content {
87
+ padding-bottom: 0;
88
+ }
89
+
90
+ .header {
91
+ display: flex;
92
+ flex-wrap: wrap;
93
+ align-items: baseline;
94
+ column-gap: var(--u-space-sm, 8px);
95
+ }
96
+
97
+ .heading {
98
+ font-size: var(--u-text-label-size, 13px);
99
+ line-height: var(--u-text-label-leading, 1.5);
100
+ font-weight: var(--u-text-label-weight, 600);
101
+ color: var(--u-txt-color, #212121);
102
+ overflow-wrap: anywhere;
103
+ }
104
+
105
+ .time {
106
+ font-size: var(--u-text-caption-size, 12px);
107
+ color: var(--u-txt-color-weak, #616161);
108
+ font-variant-numeric: tabular-nums;
109
+ white-space: nowrap;
110
+ }
111
+
112
+ .body {
113
+ margin-top: var(--u-space-2xs, 4px);
114
+ font-size: var(--u-text-body-size, 14px);
115
+ line-height: var(--u-text-body-leading, 1.6);
116
+ color: var(--u-txt-color-weak, #616161);
117
+ overflow-wrap: anywhere;
118
+ }
119
+ /* No body content — no gap under the header. */
120
+ .body.empty {
121
+ display: none;
122
+ }
123
+ `;
124
+ //#endregion
125
+ export { n as itemStyles, t as styles };
package/dist/index.d.ts CHANGED
@@ -3,7 +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
+ export type { SidebarIconsConfig, SidebarLayoutConfig, SidebarItem, SidebarLogoConfig, SidebarLinkConfig, SidebarSectionConfig, SidebarGroupConfig, SidebarButtonConfig, SidebarMenuConfig, SidebarMenuItemConfig, SidebarHtmlConfig, SidebarState, } from './layouts/SidebarLayout.types';
7
7
  export type { SidebarLayoutEventMap } from './layouts/SidebarLayout.js';
8
8
  export { filterSidebarItems } from './layouts/filterSidebarItems.js';
9
9
  export { PageHeader } from './components/PageHeader.js';
@@ -11,6 +11,8 @@ export { GroupBox } from './components/GroupBox.js';
11
11
  export { InfoSection } from './components/InfoSection.js';
12
12
  export { InfoField, isBlank } from './components/InfoField.js';
13
13
  export { EmptyState } from './components/EmptyState.js';
14
+ export { Timeline, TimelineItem } from './components/Timeline.js';
15
+ export type { TimelineItemColor } from './components/Timeline.js';
14
16
  export { ActionBar } from './components/ActionBar.js';
15
17
  export { MasterDetailLayout } from './components/MasterDetailLayout.js';
16
18
  export type { MasterDetailLayoutEventMap } from './components/MasterDetailLayout.js';
package/dist/index.js CHANGED
@@ -7,11 +7,12 @@ import { GroupBox as l } from "./components/GroupBox.js";
7
7
  import { InfoSection as u } from "./components/InfoSection.js";
8
8
  import { InfoField as d, isBlank as f } from "./components/InfoField.js";
9
9
  import { EmptyState as p } from "./components/EmptyState.js";
10
- import { ActionBar as m } from "./components/ActionBar.js";
11
- import { MasterDetailLayout as h } from "./components/MasterDetailLayout.js";
12
- import { Wizard as g } from "./components/Wizard.js";
13
- import { translate as _ } from "./translate.js";
10
+ import { Timeline as m, TimelineItem as h } from "./components/Timeline.js";
11
+ import { ActionBar as g } from "./components/ActionBar.js";
12
+ import { MasterDetailLayout as _ } from "./components/MasterDetailLayout.js";
13
+ import { Wizard as v } from "./components/Wizard.js";
14
+ import { translate as y } from "./translate.js";
14
15
  //#region src/index.ts
15
- var v = t;
16
+ var b = t;
16
17
  //#endregion
17
- export { m as ActionBar, p as EmptyState, l as GroupBox, d as InfoField, u as InfoSection, h as MasterDetailLayout, c as PageHeader, e as ScreenObserver, g as Wizard, t as app, v as default, n as filterSidebarItems, r as getDefaultLocale, i as getLocaleStrings, f as isBlank, a as modernAppLocale, o as registerLocale, s as setDefaultLocale, _ as translate };
18
+ export { g as ActionBar, p as EmptyState, l as GroupBox, d as InfoField, u as InfoSection, _ as MasterDetailLayout, c as PageHeader, e as ScreenObserver, m as Timeline, h as TimelineItem, v as Wizard, t as app, b as default, n as filterSidebarItems, r as getDefaultLocale, i as getLocaleStrings, f as isBlank, a as modernAppLocale, o as registerLocale, s as setDefaultLocale, y as translate };
@@ -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
  /** 현재 경로와 패턴 매칭 여부 확인 */
@@ -13,24 +13,26 @@ import "../components/SidebarButton.js";
13
13
  import { styles as c } from "./SidebarLayout.styles.js";
14
14
  import { html as l, nothing as u } from "lit";
15
15
  import { customElement as d, property as f, query as p, state as m } from "lit/decorators.js";
16
- import "@iyulab/components/dist/components/button/UButton.js";
17
16
  import "@iyulab/components/dist/components/icon/UIcon.js";
17
+ import "@iyulab/components/dist/components/button/UButton.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;
@@ -0,0 +1,14 @@
1
+ import React from 'react';
2
+ import { Timeline as TimelineElement } from '@iyulab/modern-app/dist/components/Timeline.js';
3
+
4
+ type TimelineOwnProps = Omit<TimelineElement, keyof HTMLElement>;
5
+
6
+ export declare const Timeline: React.ForwardRefExoticComponent<
7
+ Partial<Omit<TimelineElement, Exclude<keyof React.HTMLAttributes<TimelineElement>, keyof TimelineOwnProps>>>
8
+ & Omit<React.HTMLAttributes<TimelineElement>, keyof TimelineOwnProps | never>
9
+ & React.RefAttributes<TimelineElement>
10
+ & {
11
+ }
12
+ >;
13
+
14
+ export type TimelineProps = React.ComponentProps<typeof Timeline>;
@@ -0,0 +1,10 @@
1
+ import React from 'react';
2
+ import { createComponent } from '@lit/react';
3
+ import { Timeline as TimelineElement } from '@iyulab/modern-app/dist/components/Timeline.js';
4
+
5
+ export const Timeline = createComponent({
6
+ react: React,
7
+ tagName: 'u-timeline',
8
+ elementClass: TimelineElement,
9
+ events: {},
10
+ });
@@ -1,4 +1,5 @@
1
1
  export * from '@iyulab/modern-app/dist/react.js';
2
+ export { Timeline, TimelineProps } from './Timeline';
2
3
  export { SidebarButton, SidebarButtonProps } from './SidebarButton';
3
4
  export { PageHeader, PageHeaderProps } from './PageHeader';
4
5
  export { MasterDetailLayout, MasterDetailLayoutProps } from './MasterDetailLayout';
@@ -1,4 +1,5 @@
1
1
  export * from '@iyulab/modern-app/dist/react.js';
2
+ export { Timeline } from './Timeline.js';
2
3
  export { SidebarButton } from './SidebarButton.js';
3
4
  export { PageHeader } from './PageHeader.js';
4
5
  export { MasterDetailLayout } from './MasterDetailLayout.js';
package/dist/react.d.ts CHANGED
@@ -3,6 +3,7 @@ import { EventName } from '@lit/react';
3
3
  import { SidebarLayout as SidebarLayoutElement, SidebarLayoutEventMap } from './layouts/SidebarLayout.js';
4
4
  import { Wizard as WizardElement, WizardEventMap } from './components/Wizard.js';
5
5
  import { GroupBoxLevel } from './components/GroupBox.js';
6
+ import { TimelineItemColor } from './components/Timeline.js';
6
7
  import { InfoFieldFormat, InfoFieldSize, InfoFieldTrend, InfoFieldTone } from './components/InfoField.js';
7
8
  export { ScreenObserver } from './internals/ScreenObserver.js';
8
9
  export type { ScreenSize, ScreenObserverConfig, ScreenResizeEvent } from './internals/ScreenObserver.js';
@@ -27,7 +28,7 @@ export declare const Wizard: import('@lit/react').ReactWebComponent<WizardElemen
27
28
  }>;
28
29
  export type WizardProps = React.ComponentProps<typeof Wizard>;
29
30
  export type { SidebarLayoutElement, WizardElement };
30
- export type { SidebarLayoutConfig, SidebarItem, SidebarLogoConfig, SidebarLinkConfig, SidebarSectionConfig, SidebarGroupConfig, SidebarButtonConfig, SidebarHtmlConfig, } from './layouts/SidebarLayout.types.js';
31
+ export type { SidebarLayoutConfig, SidebarItem, SidebarLogoConfig, SidebarLinkConfig, SidebarSectionConfig, SidebarGroupConfig, SidebarButtonConfig, SidebarMenuConfig, SidebarMenuItemConfig, SidebarHtmlConfig, } from './layouts/SidebarLayout.types.js';
31
32
  export type { WizardStep, WizardStepState, WizardStepChangeDetail } from './components/Wizard.js';
32
33
  declare module 'react' {
33
34
  namespace JSX {
@@ -69,6 +70,14 @@ declare module 'react' {
69
70
  'u-info-section': React.DetailedHTMLProps<React.HTMLAttributes<HTMLElement>, HTMLElement> & {
70
71
  min?: number | string;
71
72
  };
73
+ 'u-timeline': React.DetailedHTMLProps<React.HTMLAttributes<HTMLElement>, HTMLElement>;
74
+ 'u-timeline-item': React.DetailedHTMLProps<React.HTMLAttributes<HTMLElement>, HTMLElement> & {
75
+ heading?: string;
76
+ datetime?: string;
77
+ color?: TimelineItemColor;
78
+ icon?: string;
79
+ lib?: string;
80
+ };
72
81
  'u-master-detail-layout': React.DetailedHTMLProps<React.HTMLAttributes<HTMLElement>, HTMLElement> & {
73
82
  'master-size'?: string;
74
83
  'overlay-breakpoint'?: number | string;
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.1",
4
+ "version": "0.37.0",
5
5
  "keywords": [
6
6
  "iyulab",
7
7
  "web-framework",
@@ -261,6 +261,7 @@ re-assembled per screen — that is where a product stops looking like one produ
261
261
  - [`u-info-field`](./references/components/info-field.md) — Read-only label/value pair that keeps "not set" and "zero" distinct
262
262
  - [`u-empty-state`](./references/components/empty-state.md) — Empty list or empty search, worded differently because they are different facts
263
263
  - [`u-action-bar`](./references/components/action-bar.md) — Closing action row with a separated destructive group
264
+ - [`u-timeline`](./references/components/timeline.md) — Status or activity history: what happened, and when
264
265
 
265
266
  ---
266
267
 
@@ -0,0 +1,57 @@
1
+ # u-timeline
2
+
3
+ **Tags:** `u-timeline` · `u-timeline-item`
4
+
5
+ A status or activity history on a detail screen — "what happened, and when" (an order moving
6
+ through its states, a ticket's activity, an approval chain). Each entry is a marker on a rail, a
7
+ heading, a time and an optional body.
8
+
9
+ ```html
10
+ <u-timeline>
11
+ <u-timeline-item heading="Paid" datetime="2026-10-06T09:30" color="success" icon="check">
12
+ Bank transfer received.
13
+ </u-timeline-item>
14
+ <u-timeline-item heading="Shipped" datetime="2026-10-06T15:10" color="primary"></u-timeline-item>
15
+ <u-timeline-item heading="Ordered" datetime="2026-10-05"></u-timeline-item>
16
+ </u-timeline>
17
+ ```
18
+
19
+ Entries are shown in DOM order — put the newest first or last, as your screen reads. `u-timeline` is
20
+ a list and each entry a list item, so assistive technology announces the count.
21
+
22
+ ⚠ **Colour is never the only carrier.** Say the status in `heading` ("Rejected"), not only with
23
+ `color="danger"` (WCAG 1.4.1). The marker is decorative to assistive technology.
24
+
25
+ ## `u-timeline-item` — Slots
26
+
27
+ | Name | Description |
28
+ |------|-------------|
29
+ | (default) | Body — detail under the heading (who, a note, a link) |
30
+ | `heading` | Replaces the `heading` text with richer content |
31
+ | `time` | Replaces the formatted time text (e.g. a relative "2 hours ago"); `datetime` still annotates it |
32
+
33
+ ## `u-timeline-item` — Properties
34
+
35
+ | Property | Type | Default | Reflect | Description |
36
+ |----------|------|---------|---------|-------------|
37
+ | `heading` | `string` | `''` | | What happened |
38
+ | `datetime` | `string` | `''` | | ISO `YYYY-MM-DD` or `YYYY-MM-DDTHH:mm[:ss]`. Rendered as `<time datetime>`, formatted for the active locale — a date alone as a date (never shifted by a time zone), a date-time with its time |
39
+ | `color` | `'neutral'\|'primary'\|'info'\|'success'\|'warning'\|'danger'` | `'neutral'` | ✓ | Marker colour — the role colours of `u-tag`/`u-badge` |
40
+ | `icon` | `string` | — | | Icon drawn inside the marker (the marker grows to hold it) |
41
+ | `lib` | `string` | — | | Icon library for `icon` |
42
+
43
+ ## CSS Parts
44
+
45
+ | Element | Parts |
46
+ |---------|-------|
47
+ | `u-timeline` | `host` |
48
+ | `u-timeline-item` | `host` · `rail` · `marker` · `line` · `content` · `header` · `heading` · `time` · `body` |
49
+
50
+ ## CSS Custom Properties
51
+
52
+ | Property | Description |
53
+ |----------|-------------|
54
+ | `--app-timeline-rail-width` | Rail column width; also the marker size when it holds an icon (default 24px) |
55
+ | `--app-timeline-marker-size` | Plain marker size (default 12px) |
56
+ | `--u-{role}-color` / `-txt-color` / `-color-strong` | Marker fill, the icon colour on it, and its edge |
57
+ | `--u-text-label-*` · `--u-text-caption-*` · `--u-text-body-*` | Heading, time and body typography |
@@ -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