@unofficialbox/box-open-elements 0.2.1 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (87) hide show
  1. package/dist/components/actions/button.d.ts +15 -0
  2. package/dist/components/actions/button.js +107 -3
  3. package/dist/components/actions/link-button.d.ts +9 -0
  4. package/dist/components/actions/link-button.js +55 -4
  5. package/dist/components/actions/menu.d.ts +10 -0
  6. package/dist/components/actions/menu.js +68 -11
  7. package/dist/components/collections/carousel.d.ts +7 -0
  8. package/dist/components/collections/carousel.js +71 -4
  9. package/dist/components/collections/datalist-item.d.ts +6 -0
  10. package/dist/components/collections/datalist-item.js +39 -1
  11. package/dist/components/collections/draggable-list.js +1 -1
  12. package/dist/components/collections/grid-view.js +7 -3
  13. package/dist/components/collections/table.d.ts +53 -0
  14. package/dist/components/collections/table.js +323 -0
  15. package/dist/components/collections/thumbnail-card.d.ts +19 -0
  16. package/dist/components/collections/thumbnail-card.js +210 -0
  17. package/dist/components/feedback/alert.d.ts +2 -0
  18. package/dist/components/feedback/alert.js +30 -1
  19. package/dist/components/feedback/badge.d.ts +10 -0
  20. package/dist/components/feedback/badge.js +70 -4
  21. package/dist/components/feedback/badgeable.d.ts +14 -0
  22. package/dist/components/feedback/badgeable.js +84 -0
  23. package/dist/components/feedback/chip.d.ts +5 -0
  24. package/dist/components/feedback/chip.js +74 -2
  25. package/dist/components/feedback/error-mask.d.ts +2 -0
  26. package/dist/components/feedback/error-mask.js +23 -0
  27. package/dist/components/feedback/help-text.js +7 -0
  28. package/dist/components/feedback/spinner.d.ts +4 -0
  29. package/dist/components/feedback/spinner.js +30 -2
  30. package/dist/components/feedback/toast.d.ts +5 -0
  31. package/dist/components/feedback/toast.js +59 -11
  32. package/dist/components/forms/category-selector.d.ts +11 -0
  33. package/dist/components/forms/category-selector.js +211 -17
  34. package/dist/components/forms/checkbox.d.ts +6 -0
  35. package/dist/components/forms/checkbox.js +58 -1
  36. package/dist/components/forms/combobox.d.ts +30 -1
  37. package/dist/components/forms/combobox.js +348 -44
  38. package/dist/components/forms/date-field.d.ts +8 -0
  39. package/dist/components/forms/date-field.js +80 -3
  40. package/dist/components/forms/dropdown.d.ts +5 -0
  41. package/dist/components/forms/dropdown.js +32 -3
  42. package/dist/components/forms/pill-selector-dropdown.d.ts +11 -0
  43. package/dist/components/forms/pill-selector-dropdown.js +127 -0
  44. package/dist/components/forms/radio-group.d.ts +9 -8
  45. package/dist/components/forms/radio-group.js +43 -6
  46. package/dist/components/forms/search-field.d.ts +9 -0
  47. package/dist/components/forms/search-field.js +62 -15
  48. package/dist/components/forms/select.d.ts +19 -8
  49. package/dist/components/forms/select.js +110 -15
  50. package/dist/components/forms/text-field.d.ts +14 -0
  51. package/dist/components/forms/text-field.js +144 -3
  52. package/dist/components/forms/time-field.d.ts +11 -0
  53. package/dist/components/forms/time-field.js +51 -0
  54. package/dist/components/identity/avatar.d.ts +4 -0
  55. package/dist/components/identity/avatar.js +65 -1
  56. package/dist/components/identity/contact-datalist-item.d.ts +8 -0
  57. package/dist/components/identity/contact-datalist-item.js +71 -2
  58. package/dist/components/layout/nav-sidebar.js +23 -0
  59. package/dist/components/layout/sidebar-toggle-button.d.ts +3 -0
  60. package/dist/components/layout/sidebar-toggle-button.js +21 -2
  61. package/dist/components/navigation/accordion.d.ts +3 -0
  62. package/dist/components/navigation/accordion.js +17 -2
  63. package/dist/components/navigation/breadcrumb.d.ts +30 -0
  64. package/dist/components/navigation/breadcrumb.js +190 -0
  65. package/dist/components/navigation/tabs.d.ts +4 -0
  66. package/dist/components/navigation/tabs.js +53 -5
  67. package/dist/components/overlays/context-menu.d.ts +42 -0
  68. package/dist/components/overlays/context-menu.js +260 -0
  69. package/dist/components/overlays/dialog.d.ts +8 -0
  70. package/dist/components/overlays/dialog.js +45 -2
  71. package/dist/components/overlays/guide-tooltip.d.ts +51 -0
  72. package/dist/components/overlays/guide-tooltip.js +361 -0
  73. package/dist/components/overlays/popover.d.ts +9 -1
  74. package/dist/components/overlays/popover.js +37 -22
  75. package/dist/components/overlays/tooltip.d.ts +10 -0
  76. package/dist/components/overlays/tooltip.js +74 -11
  77. package/dist/core/form-associated.d.ts +32 -0
  78. package/dist/core/form-associated.js +114 -0
  79. package/dist/core/index.d.ts +1 -1
  80. package/dist/core/index.js +1 -1
  81. package/dist/foundations/overlay/index.d.ts +2 -0
  82. package/dist/foundations/overlay/index.js +1 -0
  83. package/dist/foundations/overlay/positioning.d.ts +74 -0
  84. package/dist/foundations/overlay/positioning.js +162 -0
  85. package/dist/index.d.ts +8 -0
  86. package/dist/index.js +8 -0
  87. package/package.json +1 -1
@@ -0,0 +1,361 @@
1
+ import { BaseElement } from "../../core/index.js";
2
+ import { boeControl, boeOverlay, boeRadius, boeSpace } from "../../foundations/geometry/index.js";
3
+ import { boeNeutralInteractiveStyles } from "../../foundations/tokens/index.js";
4
+ import { boeMotionDuration, boeMotionEasing } from "../../foundations/motion/index.js";
5
+ import { parsePlacement, trackAnchor, } from "../../foundations/overlay/index.js";
6
+ const DEFAULT_TAG_NAME = "box-guide-tooltip";
7
+ const guideTooltipStyles = `
8
+ :host {
9
+ display: contents;
10
+ color: inherit;
11
+ font: inherit;
12
+ }
13
+
14
+ /* Positioned by JS (foundations/overlay) as a viewport-fixed callout, so it
15
+ escapes ancestor overflow and flips/shifts to stay on-screen. */
16
+ [part="callout"] {
17
+ position: fixed;
18
+ inset-block-start: 0;
19
+ inset-inline-start: 0;
20
+ z-index: 60;
21
+ inline-size: max-content;
22
+ max-inline-size: min(20rem, calc(100vw - 2rem));
23
+ padding: ${boeSpace[4]};
24
+ border-radius: ${boeOverlay.radius};
25
+ border: 1px solid color-mix(in srgb, var(--boe-token-surface-surface-brand, #0061d5) 24%, transparent);
26
+ background: var(--boe-token-surface-tooltip-surface, #4e4e4e);
27
+ color: rgba(255, 255, 255, 0.95);
28
+ box-shadow: 0 18px 40px rgba(16, 24, 32, 0.28);
29
+ line-height: 1.5;
30
+ }
31
+
32
+ [part="callout"][hidden] {
33
+ display: none;
34
+ }
35
+
36
+ [part="close"] {
37
+ position: absolute;
38
+ inset-block-start: ${boeSpace[2]};
39
+ inset-inline-end: ${boeSpace[2]};
40
+ inline-size: 1.5rem;
41
+ block-size: 1.5rem;
42
+ display: inline-grid;
43
+ place-items: center;
44
+ padding: 0;
45
+ border: 0;
46
+ border-radius: ${boeRadius.med};
47
+ background: transparent;
48
+ color: rgba(255, 255, 255, 0.8);
49
+ font: inherit;
50
+ font-size: 1rem;
51
+ line-height: 1;
52
+ cursor: pointer;
53
+ transition: background ${boeMotionDuration.interactive} ${boeMotionEasing.standard};
54
+ }
55
+
56
+ [part="close"]:hover {
57
+ background: rgba(255, 255, 255, 0.14);
58
+ color: #ffffff;
59
+ }
60
+
61
+ [part="close"]:focus-visible {
62
+ outline: 2px solid #ffffff;
63
+ outline-offset: 1px;
64
+ }
65
+
66
+ [part="heading"] {
67
+ margin: 0 1.5rem 0.35rem 0;
68
+ font-size: 0.95rem;
69
+ font-weight: 700;
70
+ }
71
+
72
+ [part="heading"][hidden] {
73
+ display: none;
74
+ }
75
+
76
+ [part="body"] {
77
+ font-size: 0.86rem;
78
+ }
79
+
80
+ [part="footer"] {
81
+ display: flex;
82
+ align-items: center;
83
+ justify-content: space-between;
84
+ gap: ${boeSpace[3]};
85
+ margin-block-start: ${boeSpace[4]};
86
+ }
87
+
88
+ [part="step"] {
89
+ font-size: 0.76rem;
90
+ color: rgba(255, 255, 255, 0.7);
91
+ }
92
+
93
+ [part="step"][hidden] {
94
+ display: none;
95
+ }
96
+
97
+ [part="actions"] {
98
+ display: inline-flex;
99
+ gap: ${boeSpace[2]};
100
+ margin-inline-start: auto;
101
+ }
102
+
103
+ [part="back"],
104
+ [part="next"] {
105
+ appearance: none;
106
+ min-height: 1.9rem;
107
+ padding: 0 ${boeSpace[3]};
108
+ border-radius: ${boeRadius.control};
109
+ font: inherit;
110
+ font-size: 0.82rem;
111
+ font-weight: 700;
112
+ letter-spacing: ${boeControl.letterSpacing};
113
+ cursor: pointer;
114
+ transition: background ${boeMotionDuration.interactive} ${boeMotionEasing.standard}, border-color ${boeMotionDuration.interactive} ${boeMotionEasing.standard};
115
+ }
116
+
117
+ [part="back"] {
118
+ border: 1px solid rgba(255, 255, 255, 0.4);
119
+ background: transparent;
120
+ color: rgba(255, 255, 255, 0.92);
121
+ }
122
+
123
+ [part="back"]:hover:not(:disabled) {
124
+ border-color: #ffffff;
125
+ }
126
+
127
+ [part="back"][hidden] {
128
+ display: none;
129
+ }
130
+
131
+ [part="next"] {
132
+ border: 1px solid transparent;
133
+ background: var(--boe-token-surface-surface-brand, #0061d5);
134
+ color: var(--boe-token-text-text-on-brand, #ffffff);
135
+ }
136
+
137
+ [part="next"]:hover:not(:disabled) {
138
+ background: var(--boe-token-surface-surface-brand-hover, #006ae9);
139
+ }
140
+
141
+ [part="back"]:disabled,
142
+ [part="next"]:disabled {
143
+ opacity: 0.5;
144
+ cursor: not-allowed;
145
+ }
146
+
147
+ ${boeNeutralInteractiveStyles('[part="close"]')}
148
+ `;
149
+ /**
150
+ * A guided-tour callout that points at a target element and walks a user through
151
+ * a sequence of steps. Anchor it with `for` (the id of a target) or a slotted
152
+ * `anchor` element; it self-anchors to the host otherwise. It renders a
153
+ * `heading`, a body (default slot), a `step`/`total` indicator, and Back/Next/
154
+ * Close controls, emitting `next`, `back`, and `close` (each with `detail.step`)
155
+ * so the host can advance the tour (moving `for`/`step`). Positioned as a
156
+ * viewport-fixed overlay via `foundations/overlay`.
157
+ */
158
+ export class BoxGuideTooltipElement extends BaseElement {
159
+ static get observedAttributes() {
160
+ return [
161
+ "open",
162
+ "heading",
163
+ "placement",
164
+ "step",
165
+ "total",
166
+ "for",
167
+ "next-label",
168
+ "back-label",
169
+ "close-label",
170
+ ];
171
+ }
172
+ openValue = false;
173
+ positionCleanup = null;
174
+ headingId = `box-guide-heading-${Math.random().toString(36).slice(2, 10)}`;
175
+ calloutEl;
176
+ closeEl;
177
+ headingEl;
178
+ stepEl;
179
+ backEl;
180
+ nextEl;
181
+ anchorSlot;
182
+ get open() {
183
+ return this.openValue;
184
+ }
185
+ set open(value) {
186
+ const next = Boolean(value);
187
+ if (this.openValue === next) {
188
+ return;
189
+ }
190
+ this.openValue = next;
191
+ this.toggleAttribute("open", next);
192
+ this.dispatchEvent(new CustomEvent("open-changed", { bubbles: true, composed: true, detail: { open: next } }));
193
+ if (this.isRendered) {
194
+ this.update();
195
+ }
196
+ }
197
+ get heading() {
198
+ return this.getAttribute("heading") ?? "";
199
+ }
200
+ set heading(value) {
201
+ this.setAttribute("heading", value);
202
+ }
203
+ get placement() {
204
+ return this.getAttribute("placement") ?? "bottom-center";
205
+ }
206
+ set placement(value) {
207
+ this.setAttribute("placement", value);
208
+ }
209
+ resolvedPlacement() {
210
+ return parsePlacement(this.getAttribute("placement")) ?? { side: "bottom", align: "center" };
211
+ }
212
+ /** 1-based current step; paired with `total` for the "n of m" indicator. */
213
+ get step() {
214
+ const raw = Number(this.getAttribute("step"));
215
+ return Number.isFinite(raw) && raw > 0 ? Math.floor(raw) : 0;
216
+ }
217
+ set step(value) {
218
+ this.setAttribute("step", String(value));
219
+ }
220
+ get total() {
221
+ const raw = Number(this.getAttribute("total"));
222
+ return Number.isFinite(raw) && raw > 0 ? Math.floor(raw) : 0;
223
+ }
224
+ set total(value) {
225
+ this.setAttribute("total", String(value));
226
+ }
227
+ get htmlFor() {
228
+ return this.getAttribute("for") ?? "";
229
+ }
230
+ set htmlFor(value) {
231
+ this.setAttribute("for", value);
232
+ }
233
+ get nextLabel() {
234
+ const explicit = this.getAttribute("next-label");
235
+ if (explicit) {
236
+ return explicit;
237
+ }
238
+ // On the final step Next becomes a finish action.
239
+ return this.total > 0 && this.step >= this.total ? "Done" : "Next";
240
+ }
241
+ get backLabel() {
242
+ return this.getAttribute("back-label") ?? "Back";
243
+ }
244
+ attributeChangedCallback(name, oldValue, newValue) {
245
+ if (name === "open") {
246
+ this.openValue = this.hasAttribute("open");
247
+ }
248
+ super.attributeChangedCallback(name, oldValue, newValue);
249
+ }
250
+ show() {
251
+ this.open = true;
252
+ }
253
+ hide() {
254
+ this.open = false;
255
+ }
256
+ /** Resolve the element the callout points at. */
257
+ resolveAnchor() {
258
+ const forId = this.htmlFor;
259
+ if (forId) {
260
+ const root = this.getRootNode();
261
+ const found = root.getElementById?.(forId) ??
262
+ (this.ownerDocument ?? document).getElementById(forId);
263
+ if (found) {
264
+ return found;
265
+ }
266
+ }
267
+ const slotted = this.anchorSlot?.assignedElements({ flatten: true })[0];
268
+ return slotted ?? this;
269
+ }
270
+ stopTracking() {
271
+ this.positionCleanup?.();
272
+ this.positionCleanup = null;
273
+ }
274
+ disconnectedCallback() {
275
+ this.stopTracking();
276
+ super.disconnectedCallback?.();
277
+ }
278
+ renderTemplate() {
279
+ if (!this.shadowRoot) {
280
+ return;
281
+ }
282
+ this.shadowRoot.innerHTML = `
283
+ <style>${guideTooltipStyles}</style>
284
+ <slot name="anchor"></slot>
285
+ <div part="callout" role="dialog" aria-labelledby="${this.headingId}" hidden>
286
+ <button type="button" part="close" aria-label="Close">
287
+ <svg viewBox="0 0 16 16" aria-hidden="true" focusable="false"><path d="M4 4l8 8M12 4l-8 8" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/></svg>
288
+ </button>
289
+ <h2 part="heading" id="${this.headingId}" hidden></h2>
290
+ <div part="body"><slot></slot></div>
291
+ <div part="footer">
292
+ <span part="step" hidden></span>
293
+ <div part="actions">
294
+ <button type="button" part="back">Back</button>
295
+ <button type="button" part="next">Next</button>
296
+ </div>
297
+ </div>
298
+ </div>
299
+ `;
300
+ this.calloutEl = this.shadowRoot.querySelector('[part="callout"]');
301
+ this.closeEl = this.shadowRoot.querySelector('[part="close"]');
302
+ this.headingEl = this.shadowRoot.querySelector('[part="heading"]');
303
+ this.stepEl = this.shadowRoot.querySelector('[part="step"]');
304
+ this.backEl = this.shadowRoot.querySelector('[part="back"]');
305
+ this.nextEl = this.shadowRoot.querySelector('[part="next"]');
306
+ this.anchorSlot = this.shadowRoot.querySelector('slot[name="anchor"]');
307
+ }
308
+ emit(type) {
309
+ this.dispatchEvent(new CustomEvent(type, { bubbles: true, composed: true, detail: { step: this.step } }));
310
+ }
311
+ setupListeners() {
312
+ this.closeEl.addEventListener("click", () => {
313
+ this.emit("close");
314
+ this.hide();
315
+ });
316
+ this.backEl.addEventListener("click", () => this.emit("back"));
317
+ this.nextEl.addEventListener("click", () => this.emit("next"));
318
+ this.calloutEl.addEventListener("keydown", event => {
319
+ if (event.key === "Escape") {
320
+ event.preventDefault();
321
+ this.emit("close");
322
+ this.hide();
323
+ }
324
+ });
325
+ }
326
+ update() {
327
+ if (!this.calloutEl) {
328
+ return;
329
+ }
330
+ this.calloutEl.hidden = !this.openValue;
331
+ const heading = this.heading;
332
+ this.headingEl.hidden = !heading;
333
+ this.headingEl.textContent = heading;
334
+ const step = this.step;
335
+ const total = this.total;
336
+ const showStep = step > 0 && total > 0;
337
+ this.stepEl.hidden = !showStep;
338
+ this.stepEl.textContent = showStep ? `${step} of ${total}` : "";
339
+ // Back hides on the first step; Next label finishes on the last.
340
+ this.backEl.hidden = showStep && step <= 1;
341
+ this.backEl.textContent = this.backLabel;
342
+ this.nextEl.textContent = this.nextLabel;
343
+ this.stopTracking();
344
+ if (this.openValue) {
345
+ this.positionCleanup = trackAnchor(this.resolveAnchor(), this.calloutEl, {
346
+ placement: this.resolvedPlacement(),
347
+ offset: 10,
348
+ });
349
+ // Move focus into the callout for keyboard users.
350
+ queueMicrotask(() => this.nextEl?.focus());
351
+ }
352
+ }
353
+ }
354
+ export const defineBoxGuideTooltipElement = (tagName = DEFAULT_TAG_NAME) => {
355
+ const existingElement = customElements.get(tagName);
356
+ if (existingElement) {
357
+ return existingElement;
358
+ }
359
+ customElements.define(tagName, BoxGuideTooltipElement);
360
+ return BoxGuideTooltipElement;
361
+ };
@@ -1,5 +1,9 @@
1
1
  import { BaseElement } from "../../core/index.js";
2
- export type PopoverPlacement = "bottom" | "top" | "start" | "end";
2
+ /**
3
+ * Any side (`top`/`bottom`/`left`/`right`) with optional `-start`/`-center`/`-end`
4
+ * alignment, plus the legacy names (`start` = left, `end` = right).
5
+ */
6
+ export type PopoverPlacement = "top" | "bottom" | "left" | "right" | "start" | "end" | "top-start" | "top-center" | "top-end" | "bottom-start" | "bottom-center" | "bottom-end" | "left-start" | "left-center" | "left-end" | "right-start" | "right-center" | "right-end";
3
7
  export declare class BoxPopoverElement extends BaseElement {
4
8
  static get observedAttributes(): string[];
5
9
  private openValue;
@@ -7,6 +11,7 @@ export declare class BoxPopoverElement extends BaseElement {
7
11
  private documentListenersBound;
8
12
  private triggerEl;
9
13
  private surfaceEl;
14
+ private positionCleanup;
10
15
  private readonly focusRestore;
11
16
  private readonly onDocumentKeydown;
12
17
  private readonly onOutsidePointer;
@@ -31,5 +36,8 @@ export declare class BoxPopoverElement extends BaseElement {
31
36
  protected renderTemplate(): void;
32
37
  protected setupListeners(): void;
33
38
  protected update(): void;
39
+ /** Anchor the surface to the trigger and keep it positioned while open. */
40
+ private startPositioning;
41
+ private stopPositioning;
34
42
  }
35
43
  export declare const defineBoxPopoverElement: (tagName?: string) => typeof BoxPopoverElement;
@@ -1,15 +1,17 @@
1
1
  import { BaseElement } from "../../core/index.js";
2
2
  import { FocusRestore, getTabbableElements } from "../../foundations/a11y/index.js";
3
- import { boeControl, boeOverlay, boeRadius, boeSpace } from "../../foundations/geometry/index.js";
3
+ import { boeControl, boeOverlay, boeRadius } from "../../foundations/geometry/index.js";
4
4
  import { boeNeutralInteractiveStyles } from "../../foundations/tokens/index.js";
5
5
  import { boeMotionDuration, boeMotionEasing } from "../../foundations/motion/index.js";
6
+ import { parsePlacement, trackAnchor, } from "../../foundations/overlay/index.js";
6
7
  const DEFAULT_TAG_NAME = "box-popover";
8
+ /** The attribute value if it names a valid placement, else `"bottom"`. */
7
9
  const resolvePlacement = (value) => {
8
- if (value === "top" || value === "start" || value === "end" || value === "bottom") {
9
- return value;
10
- }
10
+ if (value && parsePlacement(value))
11
+ return value.trim().toLowerCase();
11
12
  return "bottom";
12
13
  };
14
+ const OVERLAY_OFFSET = 4;
13
15
  const popoverStyles = `
14
16
  :host {
15
17
  display: inline-block;
@@ -46,10 +48,13 @@ const popoverStyles = `
46
48
 
47
49
  ${boeNeutralInteractiveStyles('[part="trigger"]')}
48
50
 
51
+ /* Positioned by JS (foundations/overlay) as position: fixed in viewport
52
+ coordinates — so it escapes ancestor overflow and flips/shifts to stay in
53
+ view, the job a portal does in box-ui-elements. */
49
54
  [part="surface"] {
50
- position: absolute;
55
+ position: fixed;
51
56
  z-index: 30;
52
- inset-block-start: calc(100% + ${boeSpace[1]});
57
+ inset-block-start: 0;
53
58
  inset-inline-start: 0;
54
59
  width: min(360px, calc(100vw - 5rem));
55
60
  min-width: 200px;
@@ -62,22 +67,6 @@ const popoverStyles = `
62
67
  line-height: 1.5;
63
68
  }
64
69
 
65
- :host([placement="top"]) [part="surface"] {
66
- inset-block-start: auto;
67
- inset-block-end: calc(100% + ${boeSpace[1]});
68
- }
69
-
70
- :host([placement="start"]) [part="surface"] {
71
- inset-block-start: 0;
72
- inset-inline-start: auto;
73
- inset-inline-end: calc(100% + ${boeSpace[1]});
74
- }
75
-
76
- :host([placement="end"]) [part="surface"] {
77
- inset-block-start: 0;
78
- inset-inline-start: calc(100% + ${boeSpace[1]});
79
- }
80
-
81
70
  [part="surface"][hidden] {
82
71
  display: none;
83
72
  }
@@ -91,6 +80,7 @@ export class BoxPopoverElement extends BaseElement {
91
80
  documentListenersBound = false;
92
81
  triggerEl;
93
82
  surfaceEl;
83
+ positionCleanup = null;
94
84
  focusRestore = new FocusRestore();
95
85
  onDocumentKeydown = (event) => {
96
86
  if (!this.openValue || event.key !== "Escape") {
@@ -164,6 +154,7 @@ export class BoxPopoverElement extends BaseElement {
164
154
  }
165
155
  disconnectedCallback() {
166
156
  this.unbindDocumentListeners();
157
+ this.stopPositioning();
167
158
  }
168
159
  attributeChangedCallback(name, oldValue, newValue) {
169
160
  if (name === "open") {
@@ -297,6 +288,12 @@ export class BoxPopoverElement extends BaseElement {
297
288
  const justClosed = !this.openValue && this.wasOpen;
298
289
  this.surfaceEl.hidden = !this.openValue;
299
290
  this.syncDocumentListeners();
291
+ if (this.openValue) {
292
+ this.startPositioning();
293
+ }
294
+ else {
295
+ this.stopPositioning();
296
+ }
300
297
  if (justOpened) {
301
298
  this.focusRestore.capture(this.triggerEl);
302
299
  queueMicrotask(() => this.focusSurface());
@@ -306,6 +303,24 @@ export class BoxPopoverElement extends BaseElement {
306
303
  }
307
304
  this.wasOpen = this.openValue;
308
305
  }
306
+ /** Anchor the surface to the trigger and keep it positioned while open. */
307
+ startPositioning() {
308
+ if (this.positionCleanup) {
309
+ return;
310
+ }
311
+ const placement = parsePlacement(this.getAttribute("placement")) ?? { side: "bottom", align: "start" };
312
+ this.positionCleanup = trackAnchor(this.triggerEl, this.surfaceEl, {
313
+ placement,
314
+ offset: OVERLAY_OFFSET,
315
+ });
316
+ }
317
+ stopPositioning() {
318
+ this.positionCleanup?.();
319
+ this.positionCleanup = null;
320
+ this.surfaceEl.style.left = "";
321
+ this.surfaceEl.style.top = "";
322
+ this.surfaceEl.style.margin = "";
323
+ }
309
324
  }
310
325
  export const defineBoxPopoverElement = (tagName = DEFAULT_TAG_NAME) => {
311
326
  const existingElement = customElements.get(tagName);
@@ -1,17 +1,25 @@
1
1
  import { BaseElement } from "../../core/index.js";
2
+ export type TooltipTheme = "default" | "error" | "callout";
2
3
  export declare class BoxTooltipElement extends BaseElement {
3
4
  static get observedAttributes(): string[];
5
+ private positionCleanup;
4
6
  private openValue;
5
7
  private tooltipId;
6
8
  private triggerHostEl;
7
9
  private triggerSlot;
8
10
  private fallbackTriggerEl;
9
11
  private tooltipEl;
12
+ private labelEl;
10
13
  private describedTrigger;
11
14
  get label(): string;
12
15
  set label(value: string);
13
16
  get triggerLabel(): string;
14
17
  set triggerLabel(value: string);
18
+ get placement(): string;
19
+ set placement(value: string);
20
+ private resolvedPlacement;
21
+ get theme(): TooltipTheme;
22
+ set theme(value: TooltipTheme);
15
23
  get open(): boolean;
16
24
  set open(value: boolean);
17
25
  attributeChangedCallback(name: string, oldValue: string | null, newValue: string | null): void;
@@ -22,5 +30,7 @@ export declare class BoxTooltipElement extends BaseElement {
22
30
  protected renderTemplate(): void;
23
31
  protected setupListeners(): void;
24
32
  protected update(): void;
33
+ private stopTracking;
34
+ disconnectedCallback(): void;
25
35
  }
26
36
  export declare const defineBoxTooltipElement: (tagName?: string) => typeof BoxTooltipElement;
@@ -1,6 +1,7 @@
1
1
  import { BaseElement } from "../../core/index.js";
2
2
  import { boeRadius } from "../../foundations/geometry/index.js";
3
3
  import { boeNeutralInteractiveStyles } from "../../foundations/tokens/index.js";
4
+ import { parsePlacement, trackAnchor, } from "../../foundations/overlay/index.js";
4
5
  const DEFAULT_TAG_NAME = "box-tooltip";
5
6
  const DEFAULT_TRIGGER_LABEL = "More information";
6
7
  const tooltipStyles = `
@@ -37,13 +38,15 @@ const tooltipStyles = `
37
38
 
38
39
  ${boeNeutralInteractiveStyles('[part="trigger"]')}
39
40
 
41
+ /* Positioned by JS (foundations/overlay) as position: fixed, so it escapes
42
+ ancestor overflow and flips/shifts to stay in the viewport. */
40
43
  [part="tooltip"] {
41
- position: absolute;
42
- inset-block-start: calc(100% + 0.5rem);
43
- inset-inline-start: 50%;
44
- transform: translateX(-50%);
45
- z-index: 1;
46
- width: min(13.75rem, calc(100vw - 6rem));
44
+ position: fixed;
45
+ inset-block-start: 0;
46
+ inset-inline-start: 0;
47
+ z-index: 40;
48
+ width: max-content;
49
+ max-width: min(18rem, calc(100vw - 2rem));
47
50
  padding: 0.5rem 0.65rem;
48
51
  border-radius: ${boeRadius.xlarge};
49
52
  border: 1px solid color-mix(in srgb, var(--boe-token-surface-surface-brand, #0061d5) 18%, rgba(255, 255, 255, 0.08));
@@ -53,20 +56,42 @@ const tooltipStyles = `
53
56
  line-height: 1.45;
54
57
  }
55
58
 
59
+ /* Error theme — for validation hints. */
60
+ [part="tooltip"][data-theme="error"] {
61
+ background: var(--boe-token-surface-status-surface-error, #ed3757);
62
+ border-color: var(--boe-token-surface-status-surface-error, #ed3757);
63
+ color: #ffffff;
64
+ }
65
+
66
+ /* Callout theme — light surface for richer, longer content. */
67
+ [part="tooltip"][data-theme="callout"] {
68
+ background: var(--boe-token-surface-surface, #ffffff);
69
+ border-color: var(--boe-token-stroke-stroke, #e8e8e8);
70
+ color: var(--boe-token-text-text, #222222);
71
+ }
72
+
73
+ [part="tooltip"] ::slotted(img) {
74
+ max-width: 100%;
75
+ border-radius: ${boeRadius.med};
76
+ display: block;
77
+ }
78
+
56
79
  [part="tooltip"][hidden] {
57
80
  display: none;
58
81
  }
59
82
  `;
60
83
  export class BoxTooltipElement extends BaseElement {
61
84
  static get observedAttributes() {
62
- return ["label", "open", "trigger-label"];
85
+ return ["label", "open", "trigger-label", "placement", "theme"];
63
86
  }
87
+ positionCleanup = null;
64
88
  openValue = false;
65
89
  tooltipId = `box-tooltip-${Math.random().toString(36).slice(2, 10)}`;
66
90
  triggerHostEl;
67
91
  triggerSlot;
68
92
  fallbackTriggerEl;
69
93
  tooltipEl;
94
+ labelEl;
70
95
  describedTrigger = null;
71
96
  get label() {
72
97
  return this.getAttribute("label") ?? "Helpful context";
@@ -80,6 +105,22 @@ export class BoxTooltipElement extends BaseElement {
80
105
  set triggerLabel(value) {
81
106
  this.setAttribute("trigger-label", value);
82
107
  }
108
+ get placement() {
109
+ return this.getAttribute("placement") ?? "bottom-center";
110
+ }
111
+ set placement(value) {
112
+ this.setAttribute("placement", value);
113
+ }
114
+ resolvedPlacement() {
115
+ return parsePlacement(this.getAttribute("placement")) ?? { side: "bottom", align: "center" };
116
+ }
117
+ get theme() {
118
+ const value = this.getAttribute("theme");
119
+ return value === "error" || value === "callout" ? value : "default";
120
+ }
121
+ set theme(value) {
122
+ this.setAttribute("theme", value);
123
+ }
83
124
  get open() {
84
125
  return this.openValue;
85
126
  }
@@ -138,13 +179,14 @@ export class BoxTooltipElement extends BaseElement {
138
179
  <span part="trigger-host">
139
180
  <slot><button type="button" part="trigger">?</button></slot>
140
181
  </span>
141
- <div id="${this.tooltipId}" part="tooltip" role="tooltip" hidden></div>
182
+ <div id="${this.tooltipId}" part="tooltip" role="tooltip" hidden><span part="label"></span><slot name="content"></slot></div>
142
183
  </span>
143
184
  `;
144
185
  this.triggerHostEl = this.shadowRoot.querySelector('[part="trigger-host"]');
145
- this.triggerSlot = this.shadowRoot.querySelector("slot");
186
+ this.triggerSlot = this.shadowRoot.querySelector("slot:not([name])");
146
187
  this.fallbackTriggerEl = this.shadowRoot.querySelector('[part="trigger"]');
147
188
  this.tooltipEl = this.shadowRoot.querySelector('[part="tooltip"]');
189
+ this.labelEl = this.shadowRoot.querySelector('[part="label"]');
148
190
  }
149
191
  setupListeners() {
150
192
  this.triggerHostEl.addEventListener("mouseenter", () => this.show());
@@ -180,19 +222,40 @@ export class BoxTooltipElement extends BaseElement {
180
222
  if (!this.triggerHostEl || !this.tooltipEl || !this.triggerSlot) {
181
223
  return;
182
224
  }
183
- const label = this.label;
184
- this.tooltipEl.textContent = label;
225
+ this.labelEl.textContent = this.label;
226
+ const theme = this.theme;
227
+ if (theme === "default") {
228
+ this.tooltipEl.removeAttribute("data-theme");
229
+ }
230
+ else {
231
+ this.tooltipEl.setAttribute("data-theme", theme);
232
+ }
185
233
  this.tooltipEl.hidden = !this.openValue;
186
234
  const trigger = this.resolveDescribedTrigger();
187
235
  if (trigger === this.fallbackTriggerEl) {
188
236
  this.fallbackTriggerEl.setAttribute("aria-label", this.triggerLabel);
189
237
  }
190
238
  this.clearDescribedBy();
239
+ this.stopTracking();
191
240
  if (this.openValue) {
192
241
  trigger.setAttribute("aria-describedby", this.tooltipId);
193
242
  this.describedTrigger = trigger;
243
+ // Position as a fixed-coordinate overlay so it escapes ancestor overflow
244
+ // and flips/shifts to stay on-screen.
245
+ this.positionCleanup = trackAnchor(this.triggerHostEl, this.tooltipEl, {
246
+ placement: this.resolvedPlacement(),
247
+ offset: 8,
248
+ });
194
249
  }
195
250
  }
251
+ stopTracking() {
252
+ this.positionCleanup?.();
253
+ this.positionCleanup = null;
254
+ }
255
+ disconnectedCallback() {
256
+ this.stopTracking();
257
+ super.disconnectedCallback?.();
258
+ }
196
259
  }
197
260
  export const defineBoxTooltipElement = (tagName = DEFAULT_TAG_NAME) => {
198
261
  const existingElement = customElements.get(tagName);