@unofficialbox/box-open-elements 0.2.1 → 0.3.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 (41) 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/menu.d.ts +10 -0
  4. package/dist/components/actions/menu.js +68 -11
  5. package/dist/components/collections/table.d.ts +53 -0
  6. package/dist/components/collections/table.js +323 -0
  7. package/dist/components/collections/thumbnail-card.d.ts +19 -0
  8. package/dist/components/collections/thumbnail-card.js +210 -0
  9. package/dist/components/feedback/badgeable.d.ts +14 -0
  10. package/dist/components/feedback/badgeable.js +84 -0
  11. package/dist/components/forms/date-field.d.ts +1 -0
  12. package/dist/components/forms/date-field.js +7 -2
  13. package/dist/components/forms/pill-selector-dropdown.d.ts +11 -0
  14. package/dist/components/forms/pill-selector-dropdown.js +127 -0
  15. package/dist/components/forms/search-field.d.ts +1 -0
  16. package/dist/components/forms/search-field.js +7 -2
  17. package/dist/components/forms/select.d.ts +1 -0
  18. package/dist/components/forms/select.js +7 -2
  19. package/dist/components/forms/text-field.d.ts +1 -0
  20. package/dist/components/forms/text-field.js +8 -2
  21. package/dist/components/navigation/breadcrumb.d.ts +30 -0
  22. package/dist/components/navigation/breadcrumb.js +190 -0
  23. package/dist/components/navigation/tabs.d.ts +4 -0
  24. package/dist/components/navigation/tabs.js +53 -5
  25. package/dist/components/overlays/context-menu.d.ts +42 -0
  26. package/dist/components/overlays/context-menu.js +260 -0
  27. package/dist/components/overlays/dialog.d.ts +8 -0
  28. package/dist/components/overlays/dialog.js +45 -2
  29. package/dist/components/overlays/popover.d.ts +9 -1
  30. package/dist/components/overlays/popover.js +37 -22
  31. package/dist/core/form-associated.d.ts +32 -0
  32. package/dist/core/form-associated.js +114 -0
  33. package/dist/core/index.d.ts +1 -1
  34. package/dist/core/index.js +1 -1
  35. package/dist/foundations/overlay/index.d.ts +2 -0
  36. package/dist/foundations/overlay/index.js +1 -0
  37. package/dist/foundations/overlay/positioning.d.ts +74 -0
  38. package/dist/foundations/overlay/positioning.js +158 -0
  39. package/dist/index.d.ts +7 -0
  40. package/dist/index.js +7 -0
  41. package/package.json +1 -1
@@ -0,0 +1,260 @@
1
+ import { BaseElement } from "../../core/index.js";
2
+ import { FocusRestore, nextRovingIndex } from "../../foundations/a11y/index.js";
3
+ import { boeOverlay, boeRadius, boeSpace } from "../../foundations/geometry/index.js";
4
+ import { resolvePosition } from "../../foundations/overlay/index.js";
5
+ const DEFAULT_TAG_NAME = "box-context-menu";
6
+ const escapeHtml = (value) => value
7
+ .replaceAll("&", "&")
8
+ .replaceAll("<", "&lt;")
9
+ .replaceAll(">", "&gt;")
10
+ .replaceAll('"', "&quot;");
11
+ const contextMenuStyles = `
12
+ :host {
13
+ display: inline-block;
14
+ color: inherit;
15
+ font: inherit;
16
+ }
17
+
18
+ [part="target"] {
19
+ display: contents;
20
+ }
21
+
22
+ [part="surface"] {
23
+ position: fixed;
24
+ z-index: 40;
25
+ inset-block-start: 0;
26
+ inset-inline-start: 0;
27
+ min-width: 180px;
28
+ max-width: min(320px, 90vw);
29
+ margin: 0;
30
+ padding: ${boeSpace[1]};
31
+ border: ${boeOverlay.border};
32
+ border-radius: ${boeOverlay.radius};
33
+ background: var(--boe-token-surface-surface, #ffffff);
34
+ box-shadow: ${boeOverlay.shadow};
35
+ display: grid;
36
+ gap: 1px;
37
+ }
38
+
39
+ [part="surface"][hidden] {
40
+ display: none;
41
+ }
42
+
43
+ [part="item"] {
44
+ appearance: none;
45
+ border: none;
46
+ background: none;
47
+ font: inherit;
48
+ text-align: start;
49
+ width: 100%;
50
+ min-height: ${boeOverlay.itemMinHeight};
51
+ padding: ${boeSpace[1]} ${boeSpace[3]};
52
+ border-radius: ${boeOverlay.itemRadius};
53
+ color: var(--boe-token-text-text, #222222);
54
+ cursor: pointer;
55
+ }
56
+
57
+ [part="item"]:hover:not(:disabled),
58
+ [part="item"]:focus-visible {
59
+ background: var(--boe-token-surface-surface-secondary, #f4f4f4);
60
+ outline: none;
61
+ }
62
+
63
+ [part="item"]:focus-visible {
64
+ box-shadow: inset 0 0 0 2px var(--boe-token-surface-surface-brand, #0061d5);
65
+ }
66
+
67
+ [part="item"]:disabled {
68
+ opacity: 0.4;
69
+ cursor: not-allowed;
70
+ }
71
+
72
+ [part="divider"] {
73
+ height: 1px;
74
+ margin: ${boeSpace[1]} 0;
75
+ background: var(--boe-token-stroke-stroke, #e8e8e8);
76
+ border-radius: ${boeRadius.size};
77
+ }
78
+ `;
79
+ /**
80
+ * Right-click context menu — box-ui-elements `ContextMenu`. The default slot is
81
+ * the target area; right-clicking (or Shift+F10 / the ContextMenu key) opens a
82
+ * menu at the pointer, positioned with `foundations/overlay` so it stays in the
83
+ * viewport. Full keyboard menu: arrows, Home/End, Enter/Space, Escape.
84
+ */
85
+ export class BoxContextMenuElement extends BaseElement {
86
+ static get observedAttributes() {
87
+ return ["items", "disabled"];
88
+ }
89
+ openValue = false;
90
+ targetEl;
91
+ surfaceEl;
92
+ focusRestore = new FocusRestore();
93
+ documentBound = false;
94
+ onOutside = (event) => {
95
+ if (!this.openValue)
96
+ return;
97
+ const path = typeof event.composedPath === "function" ? event.composedPath() : [];
98
+ if (!path.some(node => node === this.surfaceEl))
99
+ this.close();
100
+ };
101
+ onReposition = () => {
102
+ if (this.openValue)
103
+ this.close();
104
+ };
105
+ get items() {
106
+ const raw = this.getAttribute("items");
107
+ if (!raw)
108
+ return [];
109
+ try {
110
+ const parsed = JSON.parse(raw);
111
+ return Array.isArray(parsed) ? parsed : [];
112
+ }
113
+ catch {
114
+ return [];
115
+ }
116
+ }
117
+ set items(value) {
118
+ this.setAttribute("items", JSON.stringify(value));
119
+ }
120
+ get disabled() {
121
+ return this.hasAttribute("disabled");
122
+ }
123
+ set disabled(value) {
124
+ this.toggleAttribute("disabled", Boolean(value));
125
+ }
126
+ get open() {
127
+ return this.openValue;
128
+ }
129
+ disconnectedCallback() {
130
+ this.unbindDocument();
131
+ }
132
+ renderTemplate() {
133
+ if (!this.shadowRoot)
134
+ return;
135
+ this.shadowRoot.innerHTML = `
136
+ <style>${contextMenuStyles}</style>
137
+ <div part="target"><slot></slot></div>
138
+ <div part="surface" role="menu" hidden></div>
139
+ `;
140
+ this.targetEl = this.shadowRoot.querySelector('[part="target"]');
141
+ this.surfaceEl = this.shadowRoot.querySelector('[part="surface"]');
142
+ }
143
+ setupListeners() {
144
+ this.addEventListener("contextmenu", event => {
145
+ if (this.disabled || this.items.length === 0)
146
+ return;
147
+ event.preventDefault();
148
+ this.openAt(event.clientX, event.clientY);
149
+ });
150
+ // Keyboard opener: Shift+F10 or the ContextMenu key on the focused target.
151
+ this.addEventListener("keydown", event => {
152
+ if (this.disabled)
153
+ return;
154
+ const isOpener = event.key === "ContextMenu" || (event.shiftKey && event.key === "F10");
155
+ if (isOpener && this.items.length) {
156
+ event.preventDefault();
157
+ const rect = this.getBoundingClientRect();
158
+ this.openAt(rect.left, rect.bottom);
159
+ }
160
+ });
161
+ this.surfaceEl.addEventListener("click", event => {
162
+ const item = event.target.closest('[part="item"]');
163
+ if (!item || item.disabled)
164
+ return;
165
+ this.selectItem(item.dataset.id ?? "");
166
+ });
167
+ this.surfaceEl.addEventListener("keydown", event => this.onMenuKeydown(event));
168
+ }
169
+ enabledItems() {
170
+ return Array.from(this.surfaceEl.querySelectorAll('[part="item"]:not(:disabled)'));
171
+ }
172
+ onMenuKeydown(event) {
173
+ if (event.key === "Escape") {
174
+ event.preventDefault();
175
+ this.close();
176
+ return;
177
+ }
178
+ const items = this.enabledItems();
179
+ if (items.length === 0)
180
+ return;
181
+ const current = items.indexOf(event.target);
182
+ if (event.key === "Enter" || event.key === " ") {
183
+ event.preventDefault();
184
+ event.target.click();
185
+ return;
186
+ }
187
+ const next = nextRovingIndex(event.key, Math.max(current, 0), items.length, { orientation: "vertical" });
188
+ if (next !== null && next !== current) {
189
+ event.preventDefault();
190
+ items[next].focus();
191
+ }
192
+ }
193
+ openAt(clientX, clientY) {
194
+ this.renderItems();
195
+ this.surfaceEl.hidden = false;
196
+ const { width, height } = this.surfaceEl.getBoundingClientRect();
197
+ const { x, y } = resolvePosition({ top: clientY, left: clientX, width: 0, height: 0 }, { width, height }, { width: window.innerWidth, height: window.innerHeight }, { placement: { side: "bottom", align: "start" }, offset: 0 });
198
+ this.surfaceEl.style.left = `${Math.round(x)}px`;
199
+ this.surfaceEl.style.top = `${Math.round(y)}px`;
200
+ this.openValue = true;
201
+ this.bindDocument();
202
+ this.dispatchEvent(new CustomEvent("open-changed", { bubbles: true, composed: true, detail: { open: true } }));
203
+ this.focusRestore.capture(document.activeElement);
204
+ queueMicrotask(() => this.enabledItems()[0]?.focus());
205
+ }
206
+ close() {
207
+ if (!this.openValue)
208
+ return;
209
+ this.openValue = false;
210
+ this.surfaceEl.hidden = true;
211
+ this.unbindDocument();
212
+ this.dispatchEvent(new CustomEvent("open-changed", { bubbles: true, composed: true, detail: { open: false } }));
213
+ this.focusRestore.restore();
214
+ }
215
+ selectItem(id) {
216
+ const item = this.items.find(entry => entry.id === id);
217
+ if (!item || item.disabled)
218
+ return;
219
+ this.dispatchEvent(new CustomEvent("item-selected", { bubbles: true, composed: true, detail: item }));
220
+ this.close();
221
+ }
222
+ renderItems() {
223
+ this.surfaceEl.innerHTML = this.items
224
+ .map(item => {
225
+ const divider = item.separator ? `<div part="divider" role="separator"></div>` : "";
226
+ const disabled = item.disabled ? " disabled" : "";
227
+ return `${divider}<button type="button" part="item" role="menuitem" data-id="${escapeHtml(item.id)}"${disabled}>${escapeHtml(item.label)}</button>`;
228
+ })
229
+ .join("");
230
+ }
231
+ bindDocument() {
232
+ if (this.documentBound)
233
+ return;
234
+ document.addEventListener("pointerdown", this.onOutside, true);
235
+ document.addEventListener("scroll", this.onReposition, true);
236
+ window.addEventListener("resize", this.onReposition);
237
+ this.documentBound = true;
238
+ }
239
+ unbindDocument() {
240
+ if (!this.documentBound)
241
+ return;
242
+ document.removeEventListener("pointerdown", this.onOutside, true);
243
+ document.removeEventListener("scroll", this.onReposition, true);
244
+ window.removeEventListener("resize", this.onReposition);
245
+ this.documentBound = false;
246
+ }
247
+ update() {
248
+ // The menu content is rendered on open; nothing to sync while closed.
249
+ if (this.openValue)
250
+ this.renderItems();
251
+ }
252
+ }
253
+ export const defineBoxContextMenuElement = (tagName = DEFAULT_TAG_NAME) => {
254
+ const existingElement = customElements.get(tagName);
255
+ if (existingElement) {
256
+ return existingElement;
257
+ }
258
+ customElements.define(tagName, BoxContextMenuElement);
259
+ return BoxContextMenuElement;
260
+ };
@@ -3,6 +3,8 @@ export declare class BoxDialogElement extends BaseElement {
3
3
  static get observedAttributes(): string[];
4
4
  private openValue;
5
5
  private wasOpen;
6
+ private scrollLocked;
7
+ private previousBodyOverflow;
6
8
  private hostEl;
7
9
  private titleEl;
8
10
  private descriptionEl;
@@ -16,6 +18,12 @@ export declare class BoxDialogElement extends BaseElement {
16
18
  set description(value: string);
17
19
  get confirmLabel(): string;
18
20
  set confirmLabel(value: string);
21
+ /** Modal size — box-ui-elements Modal sizes. Default `medium`. */
22
+ get size(): "small" | "medium" | "large" | "fullscreen";
23
+ set size(value: "small" | "medium" | "large" | "fullscreen");
24
+ /** Lock/unlock background page scroll while the modal is open. */
25
+ private setScrollLock;
26
+ disconnectedCallback(): void;
19
27
  attributeChangedCallback(name: string, oldValue: string | null, newValue: string | null): void;
20
28
  show(): void;
21
29
  close(): void;
@@ -31,6 +31,16 @@ const dialogStyles = `
31
31
  color: var(--boe-token-text-text, #222222);
32
32
  }
33
33
 
34
+ /* Size options — box-ui-elements Modal sizes. Default (medium) is the base. */
35
+ [part="dialog"][data-size="small"] { width: min(380px, calc(100vw - 3rem)); }
36
+ [part="dialog"][data-size="large"] { width: min(760px, calc(100vw - 3rem)); }
37
+ [part="dialog"][data-size="fullscreen"] {
38
+ width: calc(100vw - 2rem);
39
+ height: calc(100vh - 2rem);
40
+ max-width: none;
41
+ border-radius: ${boeRadius.large};
42
+ }
43
+
34
44
  [part="header"] h2 {
35
45
  margin: 0;
36
46
  font: inherit;
@@ -96,10 +106,12 @@ const dialogStyles = `
96
106
  `;
97
107
  export class BoxDialogElement extends BaseElement {
98
108
  static get observedAttributes() {
99
- return ["confirm-label", "description", "heading", "open"];
109
+ return ["confirm-label", "description", "heading", "open", "size"];
100
110
  }
101
111
  openValue = false;
102
112
  wasOpen = false;
113
+ scrollLocked = false;
114
+ previousBodyOverflow = "";
103
115
  hostEl;
104
116
  titleEl = null;
105
117
  descriptionEl = null;
@@ -140,6 +152,31 @@ export class BoxDialogElement extends BaseElement {
140
152
  set confirmLabel(value) {
141
153
  this.setAttribute("confirm-label", value);
142
154
  }
155
+ /** Modal size — box-ui-elements Modal sizes. Default `medium`. */
156
+ get size() {
157
+ const size = this.getAttribute("size");
158
+ return size === "small" || size === "large" || size === "fullscreen" ? size : "medium";
159
+ }
160
+ set size(value) {
161
+ this.setAttribute("size", value);
162
+ }
163
+ /** Lock/unlock background page scroll while the modal is open. */
164
+ setScrollLock(locked) {
165
+ if (locked === this.scrollLocked) {
166
+ return;
167
+ }
168
+ this.scrollLocked = locked;
169
+ if (locked) {
170
+ this.previousBodyOverflow = document.body.style.overflow;
171
+ document.body.style.overflow = "hidden";
172
+ }
173
+ else {
174
+ document.body.style.overflow = this.previousBodyOverflow;
175
+ }
176
+ }
177
+ disconnectedCallback() {
178
+ this.setScrollLock(false);
179
+ }
143
180
  attributeChangedCallback(name, oldValue, newValue) {
144
181
  if (name === "open") {
145
182
  this.openValue = this.hasAttribute("open");
@@ -203,6 +240,7 @@ export class BoxDialogElement extends BaseElement {
203
240
  }
204
241
  if (!this.openValue) {
205
242
  const wasOpen = this.wasOpen;
243
+ this.setScrollLock(false);
206
244
  this.hostEl.innerHTML = "";
207
245
  this.titleEl = null;
208
246
  this.descriptionEl = null;
@@ -218,11 +256,12 @@ export class BoxDialogElement extends BaseElement {
218
256
  if (justOpened) {
219
257
  this.focusRestore.capture();
220
258
  }
259
+ this.setScrollLock(true);
221
260
  if (!this.hostEl.querySelector('[part="dialog"]')) {
222
261
  this.hostEl.innerHTML = `
223
262
  <style>${dialogStyles}</style>
224
263
  <div part="backdrop">
225
- <section part="dialog" role="dialog" aria-modal="true" tabindex="-1" aria-labelledby="dialog-title">
264
+ <section part="dialog" role="dialog" aria-modal="true" tabindex="-1" aria-labelledby="dialog-title" data-size="${this.size}">
226
265
  <header part="header">
227
266
  <h2 id="dialog-title"></h2>
228
267
  </header>
@@ -239,6 +278,10 @@ export class BoxDialogElement extends BaseElement {
239
278
  this.titleEl = this.hostEl.querySelector("#dialog-title");
240
279
  this.descriptionEl = this.hostEl.querySelector('[part="description"]');
241
280
  this.confirmEl = this.hostEl.querySelector('[part="confirm"]');
281
+ const dialogEl = this.hostEl.querySelector('[part="dialog"]');
282
+ if (dialogEl) {
283
+ dialogEl.dataset.size = this.size;
284
+ }
242
285
  if (this.titleEl) {
243
286
  this.titleEl.textContent = this.heading;
244
287
  }
@@ -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);
@@ -29,10 +29,26 @@ export declare const rangeFromFormValue: (value: FormValue, name: string, fallba
29
29
  export declare const boeFormFieldErrorStyles = "\n :host([invalid]) [part=\"input\"],\n :host([invalid]) [part=\"textarea\"],\n :host([invalid]) [part=\"select\"],\n :host([invalid]) [part=\"control\"],\n :host([invalid]) [part=\"editor\"] {\n border-color: var(--boe-token-surface-status-surface-error, #ed3757);\n }\n\n :host([invalid]) [part=\"input\"]:focus-visible,\n :host([invalid]) [part=\"textarea\"]:focus-visible,\n :host([invalid]) [part=\"select\"]:focus-visible,\n :host([invalid]) [part=\"control\"]:focus-visible {\n border-color: var(--boe-token-surface-status-surface-error, #ed3757);\n box-shadow: 0 0 0 3px color-mix(in srgb, var(--boe-token-surface-status-surface-error, #ed3757) 22%, transparent);\n }\n\n [part=\"error-message\"] {\n margin: 0;\n color: var(--boe-token-surface-status-surface-error, #ed3757);\n font-size: 0.82rem;\n line-height: 1.35;\n }\n\n [part=\"error-message\"][hidden] {\n display: none;\n }\n";
30
30
  export declare const FORM_ERROR_MESSAGE_ID = "boe-field-error";
31
31
  export declare const formErrorMessageMarkup: () => string;
32
+ export declare const FORM_DESCRIPTION_ID = "boe-field-description";
33
+ /** Optional help text rendered under the label (shown only when `description` is set). */
34
+ export declare const formDescriptionMarkup: () => string;
35
+ /**
36
+ * Shared chrome for the field-feature set: help-text `description`, the
37
+ * `required` indicator, and the visually-hidden `hide-label` treatment.
38
+ * Include alongside `boeFormFieldErrorStyles` in a field's stylesheet.
39
+ */
40
+ export declare const boeFormFieldSupportStyles = "\n [part=\"description\"] {\n margin: 0;\n color: var(--boe-token-text-text-secondary, #6f6f6f);\n font-size: 0.82rem;\n line-height: 1.35;\n }\n\n [part=\"description\"][hidden] {\n display: none;\n }\n\n [part=\"label\"] .boe-required-mark {\n margin-inline-start: 2px;\n color: var(--boe-token-surface-status-surface-error, #ed3757);\n }\n\n :host([hide-label]) [part=\"label\"] {\n position: absolute;\n width: 1px;\n height: 1px;\n padding: 0;\n margin: -1px;\n overflow: hidden;\n clip: rect(0, 0, 0, 0);\n white-space: nowrap;\n border: 0;\n }\n";
32
41
  export declare abstract class FormAssociatedElement extends BaseElement {
33
42
  static formAssociated: boolean;
34
43
  /** Attributes every form control should observe (merge into subclass lists). */
35
44
  static readonly formObservedAttributes: readonly ["name", "invalid", "error-message"];
45
+ /**
46
+ * `formObservedAttributes` plus the shared field-feature attributes
47
+ * (`required`, `description`, `hide-label`). A field that renders a label +
48
+ * `formDescriptionMarkup()` and calls `applyFieldSupport()` should observe
49
+ * these instead.
50
+ */
51
+ static readonly fieldObservedAttributes: readonly ["name", "invalid", "error-message", "required", "description", "hide-label"];
36
52
  /** ElementInternals for form association (public for tests / advanced hosts). */
37
53
  readonly internals: ElementInternals;
38
54
  private defaultFormValue;
@@ -44,6 +60,14 @@ export declare abstract class FormAssociatedElement extends BaseElement {
44
60
  set invalid(value: boolean);
45
61
  get errorMessage(): string;
46
62
  set errorMessage(value: string);
63
+ get required(): boolean;
64
+ set required(value: boolean);
65
+ /** Help text rendered under the label (empty string hides it). */
66
+ get description(): string;
67
+ set description(value: string);
68
+ /** Keep the label for assistive tech but hide it visually. */
69
+ get hideLabel(): boolean;
70
+ set hideLabel(value: boolean);
47
71
  /** Value reported to the owning form (`null` omits the control). */
48
72
  protected abstract getFormValue(): FormValue;
49
73
  /** Restore a value from form reset / autocomplete. */
@@ -60,4 +84,12 @@ export declare abstract class FormAssociatedElement extends BaseElement {
60
84
  protected applyInvalidState(control: HTMLElement | null | undefined, messageEl: HTMLElement | null | undefined): void;
61
85
  /** Apply invalid ARIA to every focusable control in a multi-option field. */
62
86
  protected applyInvalidStateToControls(controls: Iterable<HTMLElement | null | undefined>, messageEl: HTMLElement | null | undefined): void;
87
+ /**
88
+ * Apply the shared field features to a rendered field: the `required`
89
+ * indicator + `aria-required` on the control, the `description` help text +
90
+ * `aria-describedby`, and the `hide-label` visually-hidden treatment (handled
91
+ * in CSS via `:host([hide-label])`). Call after setting the label's text
92
+ * content each update, since setting `textContent` clears the appended mark.
93
+ */
94
+ protected applyFieldSupport(labelEl: HTMLElement | null | undefined, control: HTMLElement | null | undefined, descriptionEl: HTMLElement | null | undefined): void;
63
95
  }