@xmesh/system-design 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (175) hide show
  1. package/README.md +472 -0
  2. package/assets/brand-lockup-dark.svg +9 -0
  3. package/assets/brand-lockup-light.svg +9 -0
  4. package/assets/brand-mark.svg +9 -0
  5. package/colors_and_type.css +11 -0
  6. package/dist/lit/components/alert/index.css +201 -0
  7. package/dist/lit/components/alert/index.d.ts +25 -0
  8. package/dist/lit/components/alert/index.js +191 -0
  9. package/dist/lit/components/app-bar/index.css +80 -0
  10. package/dist/lit/components/app-bar/index.d.ts +19 -0
  11. package/dist/lit/components/app-bar/index.js +120 -0
  12. package/dist/lit/components/artifact/index.css +166 -0
  13. package/dist/lit/components/artifact/index.d.ts +37 -0
  14. package/dist/lit/components/artifact/index.js +294 -0
  15. package/dist/lit/components/autocomplete/index.css +171 -0
  16. package/dist/lit/components/autocomplete/index.d.ts +47 -0
  17. package/dist/lit/components/autocomplete/index.js +404 -0
  18. package/dist/lit/components/avatar/index.css +62 -0
  19. package/dist/lit/components/avatar/index.d.ts +19 -0
  20. package/dist/lit/components/avatar/index.js +112 -0
  21. package/dist/lit/components/avatar-group/index.css +60 -0
  22. package/dist/lit/components/avatar-group/index.d.ts +19 -0
  23. package/dist/lit/components/avatar-group/index.js +97 -0
  24. package/dist/lit/components/badge/index.css +72 -0
  25. package/dist/lit/components/badge/index.d.ts +18 -0
  26. package/dist/lit/components/badge/index.js +115 -0
  27. package/dist/lit/components/brand-mark/index.css +109 -0
  28. package/dist/lit/components/brand-mark/index.d.ts +24 -0
  29. package/dist/lit/components/brand-mark/index.js +116 -0
  30. package/dist/lit/components/breadcrumbs/index.css +91 -0
  31. package/dist/lit/components/breadcrumbs/index.d.ts +19 -0
  32. package/dist/lit/components/breadcrumbs/index.js +104 -0
  33. package/dist/lit/components/bubble/index.css +182 -0
  34. package/dist/lit/components/bubble/index.d.ts +72 -0
  35. package/dist/lit/components/bubble/index.js +617 -0
  36. package/dist/lit/components/button/index.css +342 -0
  37. package/dist/lit/components/button/index.d.ts +32 -0
  38. package/dist/lit/components/button/index.js +202 -0
  39. package/dist/lit/components/card/index.css +99 -0
  40. package/dist/lit/components/card/index.d.ts +20 -0
  41. package/dist/lit/components/card/index.js +133 -0
  42. package/dist/lit/components/chat/index.css +292 -0
  43. package/dist/lit/components/chat/index.d.ts +74 -0
  44. package/dist/lit/components/chat/index.js +589 -0
  45. package/dist/lit/components/checkbox/index.css +126 -0
  46. package/dist/lit/components/checkbox/index.d.ts +21 -0
  47. package/dist/lit/components/checkbox/index.js +138 -0
  48. package/dist/lit/components/chip/index.css +145 -0
  49. package/dist/lit/components/chip/index.d.ts +30 -0
  50. package/dist/lit/components/chip/index.js +230 -0
  51. package/dist/lit/components/chip-group/index.css +19 -0
  52. package/dist/lit/components/chip-group/index.d.ts +24 -0
  53. package/dist/lit/components/chip-group/index.js +171 -0
  54. package/dist/lit/components/code/index.css +42 -0
  55. package/dist/lit/components/code/index.d.ts +12 -0
  56. package/dist/lit/components/code/index.js +68 -0
  57. package/dist/lit/components/composer/index.css +548 -0
  58. package/dist/lit/components/composer/index.d.ts +67 -0
  59. package/dist/lit/components/composer/index.js +713 -0
  60. package/dist/lit/components/data-table/index.css +166 -0
  61. package/dist/lit/components/data-table/index.d.ts +55 -0
  62. package/dist/lit/components/data-table/index.js +390 -0
  63. package/dist/lit/components/dialog/index.css +124 -0
  64. package/dist/lit/components/dialog/index.d.ts +24 -0
  65. package/dist/lit/components/dialog/index.js +199 -0
  66. package/dist/lit/components/divider/index.css +27 -0
  67. package/dist/lit/components/divider/index.d.ts +13 -0
  68. package/dist/lit/components/divider/index.js +67 -0
  69. package/dist/lit/components/empty-state/index.css +69 -0
  70. package/dist/lit/components/empty-state/index.d.ts +21 -0
  71. package/dist/lit/components/empty-state/index.js +123 -0
  72. package/dist/lit/components/expansion-panel/index.css +120 -0
  73. package/dist/lit/components/expansion-panel/index.d.ts +22 -0
  74. package/dist/lit/components/expansion-panel/index.js +174 -0
  75. package/dist/lit/components/field/index.css +223 -0
  76. package/dist/lit/components/field/index.d.ts +106 -0
  77. package/dist/lit/components/field/index.js +388 -0
  78. package/dist/lit/components/file-input/index.css +257 -0
  79. package/dist/lit/components/file-input/index.d.ts +30 -0
  80. package/dist/lit/components/file-input/index.js +298 -0
  81. package/dist/lit/components/form/index.css +29 -0
  82. package/dist/lit/components/form/index.d.ts +38 -0
  83. package/dist/lit/components/form/index.js +192 -0
  84. package/dist/lit/components/grid/index.css +53 -0
  85. package/dist/lit/components/grid/index.d.ts +14 -0
  86. package/dist/lit/components/grid/index.js +82 -0
  87. package/dist/lit/components/kbd/index.css +35 -0
  88. package/dist/lit/components/kbd/index.d.ts +11 -0
  89. package/dist/lit/components/kbd/index.js +43 -0
  90. package/dist/lit/components/list/index.css +15 -0
  91. package/dist/lit/components/list/index.d.ts +28 -0
  92. package/dist/lit/components/list/index.js +188 -0
  93. package/dist/lit/components/list-item/index.css +119 -0
  94. package/dist/lit/components/list-item/index.d.ts +20 -0
  95. package/dist/lit/components/list-item/index.js +127 -0
  96. package/dist/lit/components/menu/index.css +94 -0
  97. package/dist/lit/components/menu/index.d.ts +47 -0
  98. package/dist/lit/components/menu/index.js +386 -0
  99. package/dist/lit/components/navigation-drawer/index.css +114 -0
  100. package/dist/lit/components/navigation-drawer/index.d.ts +29 -0
  101. package/dist/lit/components/navigation-drawer/index.js +218 -0
  102. package/dist/lit/components/overlay/index.css +171 -0
  103. package/dist/lit/components/overlay/index.d.ts +65 -0
  104. package/dist/lit/components/overlay/index.js +566 -0
  105. package/dist/lit/components/pagination/index.css +102 -0
  106. package/dist/lit/components/pagination/index.d.ts +22 -0
  107. package/dist/lit/components/pagination/index.js +184 -0
  108. package/dist/lit/components/primitives/index.css +504 -0
  109. package/dist/lit/components/primitives/index.d.ts +25 -0
  110. package/dist/lit/components/primitives/index.js +283 -0
  111. package/dist/lit/components/progress/index.css +143 -0
  112. package/dist/lit/components/progress/index.d.ts +23 -0
  113. package/dist/lit/components/progress/index.js +180 -0
  114. package/dist/lit/components/radio-group/index.css +178 -0
  115. package/dist/lit/components/radio-group/index.d.ts +35 -0
  116. package/dist/lit/components/radio-group/index.js +292 -0
  117. package/dist/lit/components/select/index.css +151 -0
  118. package/dist/lit/components/select/index.d.ts +50 -0
  119. package/dist/lit/components/select/index.js +390 -0
  120. package/dist/lit/components/sidebar-item/index.css +133 -0
  121. package/dist/lit/components/sidebar-item/index.d.ts +20 -0
  122. package/dist/lit/components/sidebar-item/index.js +105 -0
  123. package/dist/lit/components/skeleton/index.css +81 -0
  124. package/dist/lit/components/skeleton/index.d.ts +19 -0
  125. package/dist/lit/components/skeleton/index.js +119 -0
  126. package/dist/lit/components/slider/index.css +171 -0
  127. package/dist/lit/components/slider/index.d.ts +36 -0
  128. package/dist/lit/components/slider/index.js +302 -0
  129. package/dist/lit/components/snackbar/index.css +279 -0
  130. package/dist/lit/components/snackbar/index.d.ts +33 -0
  131. package/dist/lit/components/snackbar/index.js +195 -0
  132. package/dist/lit/components/stack/index.css +41 -0
  133. package/dist/lit/components/stack/index.d.ts +20 -0
  134. package/dist/lit/components/stack/index.js +103 -0
  135. package/dist/lit/components/switch/index.css +126 -0
  136. package/dist/lit/components/switch/index.d.ts +17 -0
  137. package/dist/lit/components/switch/index.js +116 -0
  138. package/dist/lit/components/table/index.css +85 -0
  139. package/dist/lit/components/table/index.d.ts +25 -0
  140. package/dist/lit/components/table/index.js +139 -0
  141. package/dist/lit/components/tabs/index.css +116 -0
  142. package/dist/lit/components/tabs/index.d.ts +49 -0
  143. package/dist/lit/components/tabs/index.js +320 -0
  144. package/dist/lit/components/text-field/index.css +90 -0
  145. package/dist/lit/components/text-field/index.d.ts +17 -0
  146. package/dist/lit/components/text-field/index.js +101 -0
  147. package/dist/lit/components/textarea/index.css +55 -0
  148. package/dist/lit/components/textarea/index.d.ts +26 -0
  149. package/dist/lit/components/textarea/index.js +124 -0
  150. package/dist/lit/components/tooltip/index.css +37 -0
  151. package/dist/lit/components/tooltip/index.d.ts +31 -0
  152. package/dist/lit/components/tooltip/index.js +196 -0
  153. package/dist/lit/components/validation/index.css +386 -0
  154. package/dist/lit/components/validation/index.d.ts +45 -0
  155. package/dist/lit/components/validation/index.js +318 -0
  156. package/dist/lit/index.d.ts +50 -0
  157. package/dist/lit/index.js +59 -0
  158. package/package.json +81 -0
  159. package/styles/README.md +346 -0
  160. package/styles/_elevation.css +24 -0
  161. package/styles/_fonts.css +6 -0
  162. package/styles/_layout.css +37 -0
  163. package/styles/_primitives.css +154 -0
  164. package/styles/_scroll.css +75 -0
  165. package/styles/_semantic.css +146 -0
  166. package/styles/_space.css +61 -0
  167. package/styles/_type.css +139 -0
  168. package/styles/_xmesh-extensions.css +232 -0
  169. package/styles/index.css +44 -0
  170. package/styles/md3/_color.css +102 -0
  171. package/styles/md3/_elevation.css +26 -0
  172. package/styles/md3/_motion.css +35 -0
  173. package/styles/md3/_shape.css +22 -0
  174. package/styles/md3/_state.css +22 -0
  175. package/styles/md3/_type.css +111 -0
@@ -0,0 +1,566 @@
1
+ /*
2
+ overlay/index.ts — <xm-overlay>, the platform-native overlay foundation.
3
+
4
+ ┌──────────────────────────────────────────────────────────────────────┐
5
+ │ INTERNAL FOUNDATION (FR-141 / AD-5 / AD-12). │
6
+ │ <xm-overlay> is not a catalog component. It is the single primitive │
7
+ │ that xm-dialog, xm-menu, xm-tooltip, xm-select, and xm-autocomplete │
8
+ │ COMPOSE so none of them hand-roll divergent stacking, focus-trap, or │
9
+ │ positioning. Consumers drive it through the PUBLIC API below — they │
10
+ │ never query or style its shadow root, and it never reaches into theirs │
11
+ │ (AD-12, one-way deps: overlay → tokens, overlay → primitives). │
12
+ └──────────────────────────────────────────────────────────────────────┘
13
+
14
+ ── Why platform-native only (the dependency decision, RESOLVED — do not
15
+ re-litigate: OQ-C → AD-5 / FR-141a) ────────────────────────────────
16
+ • modal → native <dialog> + showModal() (focus-trap + background
17
+ `inert` for FREE; we do NOT re-implement a tab cycle)
18
+ • non-modal → Popover API (popover="manual", top layer)
19
+ • positioning→ CSS anchor() / @position-try (anchored to the consumer's
20
+ trigger via a per-instance anchor-name)
21
+ • scroll-lock→ native (lock <html> overflow while a modal is open)
22
+ No positioning or focus-trap dependency is bundled. Target is modern
23
+ evergreen browsers (anchor()/@position-try is Baseline-newly-available:
24
+ Chrome 125+, Firefox 132+, Safari 26.0+).
25
+
26
+ ── Cross-shadow anchoring (the crux, FR-141) ──────────────────────────
27
+ CSS `anchor-name` is TREE-SCOPED: a popover in this overlay's shadow root
28
+ can only resolve an anchor-name set on an element in the SAME tree. A
29
+ consumer's trigger lives in ANOTHER shadow root, so native anchor() can't
30
+ see it. When the anchor is cross-root (the common case) the overlay pins a
31
+ fixed top/left computed from the trigger's getBoundingClientRect() — a
32
+ built-in, DEPENDENCY-FREE fallback (NOT Floating UI). Same-root anchors
33
+ keep the pure-CSS anchor() path. Floating UI remains the heavier escape
34
+ hatch for deep-nested-menu / virtual-row cases only.
35
+
36
+ ── Escape hatch (OFF by default, AD-5a / NFR-16) ──────────────────────
37
+ A consumer that hits a genuine native edge case — anchor() unavailable,
38
+ a deep multi-level menu, or virtual-list rows — MAY load Floating UI for
39
+ THAT component only:
40
+
41
+ import { computePosition, flip, shift } from
42
+ "https://esm.sh/@floating-ui/dom@1"; // per-consumer escape hatch
43
+
44
+ …and feed the computed top/left to its <xm-overlay> via inline style on
45
+ a slotted positioner. Such an overlay MUST still declare a `tier` and
46
+ ride the tier ordering below — it must NEVER invent its own z-index.
47
+ The DEFAULT build imports NO Floating UI. (Search this file: there is
48
+ no `@floating-ui` import — that is intentional.)
49
+
50
+ ── PUBLIC API (what consumers compose against) ────────────────────────
51
+ Attributes / properties
52
+ open boolean (reflect) — declarative open state. Setting it
53
+ true opens, false closes. Also driven
54
+ imperatively by show()/hide().
55
+ mode "modal" | "non-modal" (default "modal")
56
+ modal → <dialog>.showModal() (scrim +
57
+ focus-trap + inert); non-modal → popover.
58
+ tier "tooltip"|"menu"|"dialog" (default "dialog")
59
+ fixed stacking tier (see below).
60
+ placement "top"|"bottom"|"left"|"right"|"top-start"|"bottom-start"|…
61
+ (default "bottom-start") anchored
62
+ placement for non-modal overlays.
63
+ .anchor HTMLElement | null (PROPERTY, not attribute) the trigger
64
+ to position a non-modal overlay against
65
+ (CSS anchor()). May live in another
66
+ shadow root.
67
+ .opener HTMLElement | null (PROPERTY) element focus is restored to
68
+ on close. Defaults to .anchor, else the
69
+ activeElement at open time. Restores
70
+ cross-shadow-root.
71
+ label string — accessible name for the overlay region.
72
+
73
+ Methods
74
+ show() open the overlay (idempotent).
75
+ hide(reason?: OverlayCloseReason) close it (idempotent).
76
+ toggle() flip open state.
77
+
78
+ Events (bubbles + composed, tier (b) per the shared contract)
79
+ xm-overlay-open detail: { tier, mode }
80
+ xm-overlay-close detail: { tier, mode, reason } reason ∈
81
+ "escape" | "backdrop" | "api" | "ancestor-closed"
82
+ A consumer maps xm-overlay-close → its own close/dismiss event.
83
+
84
+ Slots
85
+ default overlay content header leading region
86
+ footer trailing region (action row)
87
+
88
+ ── Stacking tier (fixed, last-opened-wins within a tier — AD-5a) ──────
89
+ tooltip < menu / select-listbox < dialog. A dialog always paints above a
90
+ menu; a tooltip never covers a menu. The native top layer already gives
91
+ last-opened-wins WITHIN one primitive class, but a tooltip popover and a
92
+ dialog also share the top layer, so tier order is enforced explicitly via
93
+ `--xm-overlay-z-*` applied to the host (popovers) — modal <dialog>s sit in
94
+ the top layer above all popovers by spec, which matches `dialog` being the
95
+ highest tier.
96
+
97
+ ── Innermost-Esc + focus restore (AD-5a / NFR-14) ─────────────────────
98
+ Esc closes the INNERMOST overlay only: the keydown handler calls
99
+ stopPropagation() the moment it handles Esc, so one keypress never closes
100
+ two layers (a popover opened inside a modal closes the popover, not the
101
+ dialog). Closing a modal closes its descendant popovers (tracked via the
102
+ opener chain). Focus restores to each layer's opener, innermost-first.
103
+
104
+ ── Tokens (AD-1) ─────────────────────────────────────────────────────
105
+ Scrim var(--md-sys-color-scrim) with alpha at the use site (CSS).
106
+ Surface inverse-surface family + inverse-on-surface ink (the card
107
+ stack; AD-13). Elevation level3 (menus/popovers) / level4–5
108
+ (modals) per DESIGN.md. No blur / backdrop-filter; no gradient.
109
+
110
+ ── shadow-DOM CSS link + BEM (AD-2/3/4) ──────────────────────
111
+ Lit from lit (never `import "lit"`); CSS <link>
112
+ inside the shadow root via the built-file-relative new URL(...); BEM block
113
+ `overlay`, registered in scripts/check-bem.sh STRICT_BLOCKS.
114
+ */
115
+ var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
116
+ var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
117
+ if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
118
+ else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
119
+ return c > 3 && r && Object.defineProperty(target, key, r), r;
120
+ };
121
+ import { LitElement, html } from "lit";
122
+ import { customElement, property, query } from "lit/decorators.js";
123
+ // Resolve CSS relative to the *built* file:
124
+ // lit/build/components/overlay/index.js → ../overlay/index.css.
125
+ const OVERLAY_CSS = new URL("../overlay/index.css", import.meta.url).href;
126
+ let overlaySeq = 0;
127
+ // Live stack of every open overlay, in open order (last = innermost). Used to
128
+ // route Esc to the innermost layer and to cascade-close descendant popovers
129
+ // when an ancestor (modal) closes.
130
+ const OPEN_STACK = [];
131
+ let XmOverlay = class XmOverlay extends LitElement {
132
+ constructor() {
133
+ super(...arguments);
134
+ this.open = false;
135
+ this.mode = "modal";
136
+ this.tier = "dialog";
137
+ this.placement = "bottom-start";
138
+ this.label = "";
139
+ /** Trigger element to anchor a non-modal overlay against (CSS anchor()).
140
+ A property, not an attribute — it is an element reference, possibly from
141
+ another shadow root. */
142
+ this.anchor = null;
143
+ /** Element focus is restored to on close. Defaults to `anchor`, else the
144
+ document's deepest activeElement at open time. */
145
+ this.opener = null;
146
+ this._id = `xm-overlay-${++overlaySeq}`;
147
+ this._anchorName = `--xm-overlay-anchor-${overlaySeq}`;
148
+ this._restoreFocusTo = null;
149
+ this._scrollLocked = false;
150
+ this._wasOpen = false;
151
+ // Bumped on every _activate so a stale deferred (updateComplete) callback from
152
+ // a rapid open→close→open within one microtask doesn't double-fire open/show.
153
+ this._activationSeq = 0;
154
+ // ── Esc routing (innermost-only) ────────────────────────────────────
155
+ this._onKeydown = (event) => {
156
+ if (event.key !== "Escape")
157
+ return;
158
+ // Only the innermost open overlay reacts. A capturing listener fires once
159
+ // per keypress globally; we self-elect by being the last in OPEN_STACK.
160
+ const innermost = OPEN_STACK[OPEN_STACK.length - 1];
161
+ if (innermost !== this)
162
+ return;
163
+ // We are the innermost: handle and STOP so one Esc never closes two
164
+ // layers. preventDefault also blocks the native <dialog> cancel path so
165
+ // we own the close (and its reason/focus restore) uniformly.
166
+ event.preventDefault();
167
+ event.stopPropagation();
168
+ this._deactivate("escape");
169
+ };
170
+ // Native <dialog> emits `cancel` on Esc before `close`. We drive Esc through
171
+ // the capturing handler above, so suppress the native path to avoid a
172
+ // double-close (and so non-innermost dialogs never self-cancel).
173
+ this._onDialogCancel = (event) => {
174
+ event.preventDefault();
175
+ };
176
+ this._onDialogClose = () => {
177
+ // Reached only if something closed the <dialog> outside our API (e.g. form
178
+ // method=dialog). Sync our state.
179
+ if (this._wasOpen)
180
+ this._deactivate("api");
181
+ };
182
+ // Light-dismiss for modal: a click on the backdrop (the <dialog> element
183
+ // itself, outside the surface) closes it. Like Esc, only the innermost open
184
+ // overlay dismisses — clicking the dialog backdrop while a menu opened from
185
+ // inside it is open dismisses the menu first, not the whole dialog.
186
+ this._onDialogClick = (event) => {
187
+ if (event.target !== this._dialog)
188
+ return;
189
+ const innermost = OPEN_STACK[OPEN_STACK.length - 1];
190
+ if (innermost !== this) {
191
+ innermost?._deactivate("backdrop");
192
+ return;
193
+ }
194
+ this._deactivate("backdrop");
195
+ };
196
+ this._reposition = () => {
197
+ if (this._wasOpen && this.mode === "non-modal")
198
+ this._pinToAnchor();
199
+ };
200
+ }
201
+ // The trigger that opened us lives in another shadow root; delegatesFocus
202
+ // keeps the overlay itself out of the tab order while letting content focus.
203
+ static { this.shadowRootOptions = {
204
+ ...LitElement.shadowRootOptions,
205
+ delegatesFocus: true,
206
+ }; }
207
+ render() {
208
+ const isModal = this.mode === "modal";
209
+ const cls = `overlay overlay--tier-${this.tier} overlay--${isModal ? "modal" : "non-modal"}`;
210
+ // The visible surface — shared between the modal <dialog> and the
211
+ // non-modal popover so the card chrome is authored once.
212
+ const surface = html `
213
+ <div class="overlay__surface" part="surface">
214
+ <slot name="header"></slot>
215
+ <div class="overlay__body"><slot></slot></div>
216
+ <slot name="footer"></slot>
217
+ </div>
218
+ `;
219
+ return html `
220
+ <link rel="stylesheet" href="${OVERLAY_CSS}" />
221
+ <style>
222
+ :host {
223
+ display: contents;
224
+ }
225
+ /* Per-instance positioning glue (anchor-name + tier z-index) is set
226
+ inline so it can carry the runtime anchor reference; the rest of
227
+ the chrome lives in index.css. */
228
+ .overlay--non-modal .overlay__popover {
229
+ z-index: var(${this._tierZVar()});
230
+ position-anchor: var(--xm-overlay-anchor, ${this._anchorName});
231
+ }
232
+ .overlay--non-modal .overlay__popover.overlay__popover--pinned {
233
+ position-anchor: none;
234
+ }
235
+ </style>
236
+
237
+ ${isModal
238
+ ? html `
239
+ <dialog
240
+ id="${this._id}"
241
+ class="${cls} overlay__dialog"
242
+ @close=${this._onDialogClose}
243
+ @cancel=${this._onDialogCancel}
244
+ @click=${this._onDialogClick}
245
+ >
246
+ ${surface}
247
+ </dialog>
248
+ `
249
+ : html `
250
+ <div
251
+ id="${this._id}"
252
+ class="${cls} overlay__popover"
253
+ popover="manual"
254
+ >
255
+ ${surface}
256
+ </div>
257
+ `}
258
+ `;
259
+ }
260
+ updated(changed) {
261
+ if (changed.has("open")) {
262
+ if (this.open && !this._wasOpen)
263
+ this._activate();
264
+ else if (!this.open && this._wasOpen)
265
+ this._deactivate("api");
266
+ }
267
+ if (this.open && (changed.has("anchor") || changed.has("placement"))) {
268
+ this._applyAnchor();
269
+ }
270
+ }
271
+ disconnectedCallback() {
272
+ super.disconnectedCallback();
273
+ if (this._wasOpen)
274
+ this._deactivate("api");
275
+ document.removeEventListener("keydown", this._onKeydown, true);
276
+ }
277
+ // ── Public imperative API ───────────────────────────────────────────
278
+ show() {
279
+ this.open = true;
280
+ }
281
+ hide(reason = "api") {
282
+ if (!this._wasOpen) {
283
+ this.open = false;
284
+ return;
285
+ }
286
+ // Route through _deactivate so the close reason is preserved on the event;
287
+ // the reflected `open` is cleared there.
288
+ this._deactivate(reason);
289
+ }
290
+ toggle() {
291
+ this.open = !this.open;
292
+ }
293
+ // ── Activation ──────────────────────────────────────────────────────
294
+ _activate() {
295
+ this._wasOpen = true;
296
+ // Capture the element to return focus to BEFORE we move focus into the
297
+ // overlay — but ONLY when this overlay actually moves focus in. A modal
298
+ // (showModal) traps focus, and a consumer that passed an explicit `opener`
299
+ // is managing focus; both want it restored on close. A bare non-modal
300
+ // popover with no opener (e.g. a tooltip shown on hover) never moves focus,
301
+ // so it must NOT yank focus to the anchor on close (that would steal focus).
302
+ this._restoreFocusTo =
303
+ this.mode === "modal"
304
+ ? this.opener ?? this._deepActiveElement()
305
+ : this.opener ?? null;
306
+ OPEN_STACK.push(this);
307
+ // One capturing keydown listener on the document handles Esc for the whole
308
+ // stack; it dispatches to the innermost overlay and stops there.
309
+ document.addEventListener("keydown", this._onKeydown, true);
310
+ const activation = ++this._activationSeq;
311
+ // Wait for the dialog/popover element to exist in the shadow root.
312
+ this.updateComplete.then(() => {
313
+ // A newer activation (open→close→open within the microtask) superseded
314
+ // this one — bail so show()/showPopover() and the open event fire once.
315
+ if (activation !== this._activationSeq)
316
+ return;
317
+ if (!this.open)
318
+ return;
319
+ if (this.mode === "modal") {
320
+ const dlg = this._dialog;
321
+ if (dlg && !dlg.open) {
322
+ dlg.showModal(); // focus-trap + background inert, for free
323
+ this._lockScroll();
324
+ }
325
+ }
326
+ else {
327
+ const pop = this._popover;
328
+ if (pop && !pop.matches(":popover-open")) {
329
+ this._applyAnchor();
330
+ try {
331
+ pop.showPopover();
332
+ }
333
+ catch {
334
+ // Popover may already be open (race) — ignore.
335
+ }
336
+ // Re-pin once the popover is laid out so offsetWidth/Height are real
337
+ // (the cross-root fallback needs the rendered size to place + clamp).
338
+ if (pop.classList.contains("overlay__popover--pinned")) {
339
+ this._pinToAnchor();
340
+ }
341
+ }
342
+ }
343
+ this.dispatchEvent(new CustomEvent("xm-overlay-open", {
344
+ bubbles: true,
345
+ composed: true,
346
+ detail: { tier: this.tier, mode: this.mode },
347
+ }));
348
+ });
349
+ }
350
+ _deactivate(reason) {
351
+ if (!this._wasOpen)
352
+ return;
353
+ this._wasOpen = false;
354
+ const idx = OPEN_STACK.indexOf(this);
355
+ if (idx !== -1)
356
+ OPEN_STACK.splice(idx, 1);
357
+ // Each open overlay registers its OWN bound listener, so each must remove
358
+ // its own — removing only when the stack empties leaks the inner layers'
359
+ // listeners (and double-fires them on re-open).
360
+ document.removeEventListener("keydown", this._onKeydown, true);
361
+ window.removeEventListener("scroll", this._reposition, true);
362
+ window.removeEventListener("resize", this._reposition);
363
+ // Cascade: closing a modal closes any popover opened from inside it. Walk
364
+ // the stack for descendants whose opener resolves to inside this overlay.
365
+ if (this.mode === "modal") {
366
+ for (const other of [...OPEN_STACK]) {
367
+ if (this.contains(other) || this._ownsOpener(other)) {
368
+ other._deactivate("ancestor-closed");
369
+ }
370
+ }
371
+ }
372
+ if (this.mode === "modal") {
373
+ const dlg = this._dialog;
374
+ if (dlg?.open)
375
+ dlg.close();
376
+ this._unlockScroll();
377
+ }
378
+ else {
379
+ const pop = this._popover;
380
+ if (pop?.matches(":popover-open")) {
381
+ try {
382
+ pop.hidePopover();
383
+ }
384
+ catch {
385
+ // Already hidden — ignore.
386
+ }
387
+ }
388
+ }
389
+ this.open = false;
390
+ // Innermost-first focus restore: restore to this layer's opener. Because
391
+ // the stack unwinds innermost-first (Esc hits the innermost first), each
392
+ // layer restores in turn. Works cross-shadow-root: we hold the element ref.
393
+ this._restoreFocus();
394
+ this.dispatchEvent(new CustomEvent("xm-overlay-close", {
395
+ bubbles: true,
396
+ composed: true,
397
+ detail: { tier: this.tier, mode: this.mode, reason },
398
+ }));
399
+ }
400
+ // ── Anchored positioning (CSS anchor()) ─────────────────────────────
401
+ _applyAnchor() {
402
+ const pop = this._popover;
403
+ if (!pop)
404
+ return;
405
+ pop.dataset["placement"] = this.placement;
406
+ if (!this.anchor)
407
+ return;
408
+ // CSS anchor-name is TREE-SCOPED: a popover in this overlay's shadow root
409
+ // can only resolve an anchor-name set on an element in the SAME tree. When
410
+ // the consumer's trigger lives in another shadow root (the common case),
411
+ // native anchor() can't see it — so we pin a fixed top/left computed from
412
+ // the trigger's bounding rect (no dependency). Same-root anchors keep the
413
+ // pure-CSS anchor() path.
414
+ if (this._sameTreeScope(this.anchor)) {
415
+ pop.classList.remove("overlay__popover--pinned");
416
+ this.anchor.style.setProperty("anchor-name", this._anchorName);
417
+ pop.style.setProperty("--xm-overlay-anchor", this._anchorName);
418
+ }
419
+ else {
420
+ pop.classList.add("overlay__popover--pinned");
421
+ this._pinToAnchor();
422
+ // Keep the pin glued to the trigger while open (scroll/resize).
423
+ window.addEventListener("scroll", this._reposition, true);
424
+ window.addEventListener("resize", this._reposition);
425
+ }
426
+ }
427
+ // The popover lives in this element's shadow root; an anchor is in the same
428
+ // tree scope only if its root node is this shadow root (it never is for a
429
+ // consumer trigger) — so in practice this is the consumer-supplied-same-root
430
+ // escape valve. Kept as an explicit check so the native path is taken
431
+ // whenever it actually can resolve.
432
+ _sameTreeScope(anchor) {
433
+ return anchor.getRootNode() === this.renderRoot;
434
+ }
435
+ _pinToAnchor() {
436
+ const pop = this._popover;
437
+ if (!pop || !this.anchor)
438
+ return;
439
+ const a = this.anchor.getBoundingClientRect();
440
+ // A display:none / zero-size anchor returns an all-zero rect — pinning to it
441
+ // would land the popover in the top-left corner. Hide instead of mispinning.
442
+ if (a.width === 0 && a.height === 0) {
443
+ pop.style.setProperty("visibility", "hidden");
444
+ return;
445
+ }
446
+ // The anchor is fully off-screen — clamping would detach the popover and
447
+ // leave it floating at a viewport edge with no trigger. Hide it; a later
448
+ // scroll/resize re-runs this and reveals it once the anchor returns.
449
+ if (a.bottom <= 0 ||
450
+ a.top >= window.innerHeight ||
451
+ a.right <= 0 ||
452
+ a.left >= window.innerWidth) {
453
+ pop.style.setProperty("visibility", "hidden");
454
+ return;
455
+ }
456
+ pop.style.removeProperty("visibility");
457
+ const gap = 4;
458
+ const w = pop.offsetWidth;
459
+ const h = pop.offsetHeight;
460
+ const place = this.placement;
461
+ const main = place.split("-")[0];
462
+ const align = place.includes("-") ? place.split("-")[1] : "center";
463
+ let top;
464
+ let left;
465
+ // Cross-axis alignment for a given edge length: start | end | center.
466
+ const alignAxis = (start, end, size) => align === "end" ? end - size : align === "start" ? start : start + (end - start) / 2 - size / 2;
467
+ if (main === "top") {
468
+ top = a.top - h - gap;
469
+ left = alignAxis(a.left, a.right, w);
470
+ }
471
+ else if (main === "left") {
472
+ left = a.left - w - gap;
473
+ top = alignAxis(a.top, a.bottom, h);
474
+ }
475
+ else if (main === "right") {
476
+ left = a.right + gap;
477
+ top = alignAxis(a.top, a.bottom, h);
478
+ }
479
+ else {
480
+ // bottom (default)
481
+ top = a.bottom + gap;
482
+ left = alignAxis(a.left, a.right, w);
483
+ }
484
+ // Clamp into the viewport so a popover near an edge stays visible.
485
+ const margin = 8;
486
+ left = Math.max(margin, Math.min(left, window.innerWidth - w - margin));
487
+ top = Math.max(margin, Math.min(top, window.innerHeight - h - margin));
488
+ pop.style.setProperty("--xm-overlay-pin-top", `${Math.round(top)}px`);
489
+ pop.style.setProperty("--xm-overlay-pin-left", `${Math.round(left)}px`);
490
+ }
491
+ // ── Scroll lock (native, no library) ────────────────────────────────
492
+ _lockScroll() {
493
+ if (this._scrollLocked)
494
+ return;
495
+ this._scrollLocked = true;
496
+ document.documentElement.style.setProperty("--xm-overlay-scroll-lock", "hidden");
497
+ document.documentElement.style.overflow = "hidden";
498
+ }
499
+ _unlockScroll() {
500
+ if (!this._scrollLocked)
501
+ return;
502
+ this._scrollLocked = false;
503
+ // Only release if no other modal still holds the lock.
504
+ const stillModal = OPEN_STACK.some((o) => o.mode === "modal");
505
+ if (!stillModal) {
506
+ document.documentElement.style.removeProperty("overflow");
507
+ document.documentElement.style.removeProperty("--xm-overlay-scroll-lock");
508
+ }
509
+ }
510
+ // ── Focus helpers (cross-shadow-root) ───────────────────────────────
511
+ _restoreFocus() {
512
+ const target = this._restoreFocusTo;
513
+ this._restoreFocusTo = null;
514
+ if (target && typeof target.focus === "function" && target.isConnected) {
515
+ target.focus();
516
+ }
517
+ }
518
+ // Pierce shadow roots to find the truly focused element so restore works
519
+ // when the trigger sits in a nested shadow tree.
520
+ _deepActiveElement() {
521
+ let active = document.activeElement;
522
+ while (active?.shadowRoot?.activeElement) {
523
+ active = active.shadowRoot.activeElement;
524
+ }
525
+ return active instanceof HTMLElement ? active : null;
526
+ }
527
+ _ownsOpener(other) {
528
+ const op = other.opener ?? other.anchor;
529
+ return op != null && this.contains(op);
530
+ }
531
+ _tierZVar() {
532
+ // Tier → z-index custom property. Modal <dialog>s ride the native top
533
+ // layer above all popovers; this var orders popovers among themselves so
534
+ // a menu sits above a tooltip. A FUI-positioned overlay reuses these.
535
+ return this.tier === "tooltip"
536
+ ? "--xm-overlay-z-tooltip"
537
+ : this.tier === "menu"
538
+ ? "--xm-overlay-z-menu"
539
+ : "--xm-overlay-z-dialog";
540
+ }
541
+ };
542
+ __decorate([
543
+ property({ type: Boolean, reflect: true })
544
+ ], XmOverlay.prototype, "open", void 0);
545
+ __decorate([
546
+ property({ type: String, reflect: true })
547
+ ], XmOverlay.prototype, "mode", void 0);
548
+ __decorate([
549
+ property({ type: String, reflect: true })
550
+ ], XmOverlay.prototype, "tier", void 0);
551
+ __decorate([
552
+ property({ type: String, reflect: true })
553
+ ], XmOverlay.prototype, "placement", void 0);
554
+ __decorate([
555
+ property({ type: String })
556
+ ], XmOverlay.prototype, "label", void 0);
557
+ __decorate([
558
+ query(".overlay__dialog")
559
+ ], XmOverlay.prototype, "_dialog", void 0);
560
+ __decorate([
561
+ query(".overlay__popover")
562
+ ], XmOverlay.prototype, "_popover", void 0);
563
+ XmOverlay = __decorate([
564
+ customElement("xm-overlay")
565
+ ], XmOverlay);
566
+ export { XmOverlay };
@@ -0,0 +1,102 @@
1
+ /* ============================================
2
+ <xm-pagination> — numbered page controls.
3
+
4
+ Surface / ink (AD-13): a transparent control row tracing its host surface.
5
+ Idle controls take var(--md-sys-color-on-surface-variant) ink; the CURRENT
6
+ page is a coral chip — var(--md-sys-color-primary) fill + var(--md-sys-color-on-primary)
7
+ ink (matched to the coral fill). Hairline 1px borders only (2px is reserved
8
+ for drag-active, not used here). Disabled edges use the shared reduced-
9
+ emphasis treatment — no error color.
10
+
11
+ BEM: block `pagination`; elements `__item` `__page` `__prev` `__next`
12
+ `__ellipsis`; modifiers `__page--current`, size `--xs|sm|md|lg`.
13
+ ============================================ */
14
+
15
+ .pagination {
16
+ display: inline-flex;
17
+ align-items: center;
18
+ gap: var(--s-1);
19
+ }
20
+
21
+ /* ---------- Shared control chrome ---------- */
22
+ .pagination__item {
23
+ appearance: none;
24
+ display: inline-flex;
25
+ align-items: center;
26
+ justify-content: center;
27
+ box-sizing: border-box;
28
+ border: 1px solid var(--md-sys-color-outline-variant);
29
+ border-radius: var(--md-sys-shape-corner-button);
30
+ background: transparent;
31
+ color: var(--md-sys-color-on-surface-variant);
32
+ font-family: var(--md-sys-typescale-label-large-font);
33
+ font-weight: 600;
34
+ cursor: pointer;
35
+ user-select: none;
36
+ transition:
37
+ background var(--md-sys-motion-duration-short3) var(--md-sys-motion-easing-standard),
38
+ color var(--md-sys-motion-duration-short3) var(--md-sys-motion-easing-standard),
39
+ border-color var(--md-sys-motion-duration-short3) var(--md-sys-motion-easing-standard),
40
+ box-shadow var(--md-sys-motion-duration-short3) var(--md-sys-motion-easing-standard);
41
+ }
42
+ .pagination__item:hover {
43
+ background: color-mix(
44
+ in oklab,
45
+ var(--md-sys-color-on-surface) var(--md-sys-state-hover-state-layer-opacity),
46
+ transparent
47
+ );
48
+ color: var(--md-sys-color-on-surface);
49
+ border-color: var(--md-sys-color-outline);
50
+ }
51
+ .pagination__item:focus {
52
+ outline: none;
53
+ }
54
+ .pagination__item:focus-visible {
55
+ outline: none;
56
+ box-shadow: var(--xm-state-focus-ring);
57
+ }
58
+ .pagination__item svg {
59
+ flex-shrink: 0;
60
+ }
61
+
62
+ /* ---------- Sizes — one shared control height per size ---------- */
63
+ .pagination--xs .pagination__item { min-width: 22px; height: 22px; padding: 0 var(--s-1); font-size: var(--md-sys-typescale-label-small-size); }
64
+ .pagination--sm .pagination__item { min-width: 26px; height: 26px; padding: 0 var(--s-2); font-size: var(--md-sys-typescale-body-small-size); }
65
+ .pagination--md .pagination__item { min-width: 32px; height: 32px; padding: 0 var(--s-2); font-size: var(--md-sys-typescale-body-medium-size); }
66
+ .pagination--lg .pagination__item { min-width: 42px; height: 42px; padding: 0 var(--s-3); font-size: var(--md-sys-typescale-title-small-size); }
67
+
68
+ /* ---------- Current page — coral chip ----------
69
+ The page digit is text on the coral fill, so it uses --xm-color-primary-fill
70
+ (the darker coral) for WCAG AA, not the lighter --md-sys-color-primary. */
71
+ .pagination__page--current {
72
+ background: var(--xm-color-primary-fill);
73
+ border-color: var(--xm-color-primary-fill);
74
+ color: var(--md-sys-color-on-primary);
75
+ }
76
+ .pagination__page--current:hover {
77
+ background: var(--xm-color-primary-fill);
78
+ border-color: var(--xm-color-primary-fill);
79
+ color: var(--md-sys-color-on-primary);
80
+ }
81
+
82
+ /* ---------- Ellipsis ---------- */
83
+ .pagination__ellipsis {
84
+ display: inline-flex;
85
+ align-items: center;
86
+ justify-content: center;
87
+ min-width: 24px;
88
+ color: var(--md-sys-color-on-surface-variant);
89
+ user-select: none;
90
+ }
91
+
92
+ /* ---------- Disabled edges — shared reduced emphasis ---------- */
93
+ .pagination__item[disabled] {
94
+ cursor: not-allowed;
95
+ color: var(--xm-color-on-surface-disabled);
96
+ box-shadow: none;
97
+ }
98
+ .pagination__item[disabled]:hover {
99
+ background: transparent;
100
+ color: var(--xm-color-on-surface-disabled);
101
+ border-color: var(--md-sys-color-outline-variant);
102
+ }