@unofficialbox/box-open-elements 0.2.0 → 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 (44) hide show
  1. package/README.md +1 -0
  2. package/dist/components/actions/button.d.ts +15 -0
  3. package/dist/components/actions/button.js +107 -3
  4. package/dist/components/actions/menu.d.ts +10 -0
  5. package/dist/components/actions/menu.js +68 -11
  6. package/dist/components/collections/table.d.ts +53 -0
  7. package/dist/components/collections/table.js +323 -0
  8. package/dist/components/collections/thumbnail-card.d.ts +19 -0
  9. package/dist/components/collections/thumbnail-card.js +210 -0
  10. package/dist/components/feedback/badgeable.d.ts +14 -0
  11. package/dist/components/feedback/badgeable.js +84 -0
  12. package/dist/components/forms/date-field.d.ts +1 -0
  13. package/dist/components/forms/date-field.js +7 -2
  14. package/dist/components/forms/pill-selector-dropdown.d.ts +11 -0
  15. package/dist/components/forms/pill-selector-dropdown.js +127 -0
  16. package/dist/components/forms/search-field.d.ts +1 -0
  17. package/dist/components/forms/search-field.js +7 -2
  18. package/dist/components/forms/select.d.ts +1 -0
  19. package/dist/components/forms/select.js +7 -2
  20. package/dist/components/forms/text-field.d.ts +1 -0
  21. package/dist/components/forms/text-field.js +8 -2
  22. package/dist/components/navigation/breadcrumb.d.ts +30 -0
  23. package/dist/components/navigation/breadcrumb.js +190 -0
  24. package/dist/components/navigation/tabs.d.ts +4 -0
  25. package/dist/components/navigation/tabs.js +53 -5
  26. package/dist/components/overlays/context-menu.d.ts +42 -0
  27. package/dist/components/overlays/context-menu.js +260 -0
  28. package/dist/components/overlays/dialog.d.ts +8 -0
  29. package/dist/components/overlays/dialog.js +45 -2
  30. package/dist/components/overlays/popover.d.ts +9 -1
  31. package/dist/components/overlays/popover.js +37 -22
  32. package/dist/core/form-associated.d.ts +32 -0
  33. package/dist/core/form-associated.js +114 -0
  34. package/dist/core/index.d.ts +1 -1
  35. package/dist/core/index.js +1 -1
  36. package/dist/foundations/overlay/index.d.ts +2 -0
  37. package/dist/foundations/overlay/index.js +1 -0
  38. package/dist/foundations/overlay/positioning.d.ts +74 -0
  39. package/dist/foundations/overlay/positioning.js +158 -0
  40. package/dist/index.d.ts +7 -0
  41. package/dist/index.js +7 -0
  42. package/dist/patterns/content-explorer/adapters/table.js +8 -1
  43. package/dist/patterns/search/filter-bar.js +12 -6
  44. package/package.json +1 -1
package/README.md CHANGED
@@ -116,6 +116,7 @@ The docs index is at [docs/README.md](./docs/README.md). The most important entr
116
116
  - [Taxonomy](./docs/taxonomy.md)
117
117
  - [Architecture](./docs/architecture.md)
118
118
  - [API Guidelines](./docs/api-guidelines.md)
119
+ - [Using with React, Angular, Vue, and Svelte](./docs/integration/frameworks.md)
119
120
  - [Design Tokens](./docs/foundations/tokens.md)
120
121
  - [Content Explorer](./docs/patterns/content-explorer.md)
121
122
  - [Box Server Integration](./docs/integration/box-server.md)
@@ -1,7 +1,20 @@
1
1
  import { BaseElement } from "../../core/index.js";
2
+ export type ButtonType = "button" | "submit" | "reset";
2
3
  export declare class BoxButtonElement extends BaseElement {
4
+ static formAssociated: boolean;
3
5
  static get observedAttributes(): string[];
4
6
  private buttonEl;
7
+ private spinnerEl;
8
+ private iconEl;
9
+ private labelEl;
10
+ private iconSlot;
11
+ private readonly internals;
12
+ constructor();
13
+ /** Loading state: shows a spinner and blocks activation (stays focusable). */
14
+ get isLoading(): boolean;
15
+ set isLoading(value: boolean);
16
+ get type(): ButtonType;
17
+ set type(value: ButtonType);
5
18
  get disabled(): boolean;
6
19
  set disabled(value: boolean);
7
20
  get label(): string;
@@ -11,6 +24,8 @@ export declare class BoxButtonElement extends BaseElement {
11
24
  get tone(): string;
12
25
  set tone(value: string);
13
26
  protected renderTemplate(): void;
27
+ protected setupListeners(): void;
28
+ private syncIconVisibility;
14
29
  protected update(): void;
15
30
  }
16
31
  export declare const defineBoxButtonElement: (tagName?: string) => typeof BoxButtonElement;
@@ -120,12 +120,79 @@ const buttonStyles = `
120
120
  opacity: ${boeControl.disabledOpacity};
121
121
  box-shadow: none;
122
122
  }
123
+
124
+ button[aria-busy="true"] {
125
+ cursor: default;
126
+ }
127
+
128
+ /* Leading icon slot — hidden when nothing is slotted (kept off the flex gap). */
129
+ [part="icon"] {
130
+ display: inline-flex;
131
+ width: 16px;
132
+ height: 16px;
133
+ }
134
+
135
+ [part="icon"][hidden] {
136
+ display: none;
137
+ }
138
+
139
+ [part="icon"] ::slotted(svg) {
140
+ width: 16px;
141
+ height: 16px;
142
+ }
143
+
144
+ /* Inline loading spinner, shown while is-loading. */
145
+ [part="spinner"] {
146
+ width: 14px;
147
+ height: 14px;
148
+ border-radius: 50%;
149
+ border: 2px solid currentColor;
150
+ border-top-color: transparent;
151
+ animation: boe-btn-spin 0.7s linear infinite;
152
+ }
153
+
154
+ [part="spinner"][hidden] {
155
+ display: none;
156
+ }
157
+
158
+ @keyframes boe-btn-spin {
159
+ to { transform: rotate(360deg); }
160
+ }
161
+
162
+ @media (prefers-reduced-motion: reduce) {
163
+ [part="spinner"] { animation-duration: 1.6s; }
164
+ }
123
165
  `;
124
166
  export class BoxButtonElement extends BaseElement {
167
+ static formAssociated = true;
125
168
  static get observedAttributes() {
126
- return ["disabled", "label", "size", "tone"];
169
+ return ["disabled", "label", "size", "tone", "is-loading", "type"];
127
170
  }
128
171
  buttonEl;
172
+ spinnerEl;
173
+ iconEl;
174
+ labelEl;
175
+ iconSlot;
176
+ internals;
177
+ constructor() {
178
+ super();
179
+ // Form association lets type="submit"/"reset" drive the owning form.
180
+ this.internals = this.attachInternals();
181
+ }
182
+ /** Loading state: shows a spinner and blocks activation (stays focusable). */
183
+ get isLoading() {
184
+ return this.hasAttribute("is-loading");
185
+ }
186
+ set isLoading(value) {
187
+ this.toggleAttribute("is-loading", Boolean(value));
188
+ }
189
+ get type() {
190
+ const type = this.getAttribute("type");
191
+ return type === "submit" || type === "reset" ? type : "button";
192
+ }
193
+ set type(value) {
194
+ this.setAttribute("type", value);
195
+ }
129
196
  get disabled() {
130
197
  return this.hasAttribute("disabled");
131
198
  }
@@ -161,9 +228,40 @@ export class BoxButtonElement extends BaseElement {
161
228
  }
162
229
  this.shadowRoot.innerHTML = `
163
230
  <style>${buttonStyles}</style>
164
- <button type="button" part="button"></button>
231
+ <button type="button" part="button">
232
+ <span part="spinner" aria-hidden="true" hidden></span>
233
+ <span part="icon" hidden><slot name="icon"></slot></span>
234
+ <span part="label"></span>
235
+ </button>
165
236
  `;
166
237
  this.buttonEl = this.shadowRoot.querySelector('[part="button"]');
238
+ this.spinnerEl = this.shadowRoot.querySelector('[part="spinner"]');
239
+ this.iconEl = this.shadowRoot.querySelector('[part="icon"]');
240
+ this.labelEl = this.shadowRoot.querySelector('[part="label"]');
241
+ this.iconSlot = this.iconEl.querySelector("slot");
242
+ }
243
+ setupListeners() {
244
+ this.buttonEl.addEventListener("click", event => {
245
+ if (this.disabled || this.isLoading) {
246
+ event.preventDefault();
247
+ event.stopImmediatePropagation();
248
+ return;
249
+ }
250
+ // Drive the owning form for submit/reset (custom elements don't do this
251
+ // natively). Prefer the form-associated form; fall back to the nearest
252
+ // ancestor form so it works without full ElementInternals.form support.
253
+ const form = this.internals.form ?? this.closest("form");
254
+ if (this.type === "submit") {
255
+ form?.requestSubmit();
256
+ }
257
+ else if (this.type === "reset") {
258
+ form?.reset();
259
+ }
260
+ });
261
+ this.iconSlot.addEventListener("slotchange", () => this.syncIconVisibility());
262
+ }
263
+ syncIconVisibility() {
264
+ this.iconEl.hidden = this.iconSlot.assignedNodes({ flatten: true }).length === 0;
167
265
  }
168
266
  update() {
169
267
  if (!this.buttonEl) {
@@ -171,13 +269,19 @@ export class BoxButtonElement extends BaseElement {
171
269
  }
172
270
  this.buttonEl.dataset.tone = this.tone;
173
271
  this.buttonEl.dataset.size = this.size;
272
+ this.labelEl.textContent = this.label;
273
+ const busy = this.isLoading;
274
+ this.spinnerEl.hidden = !busy;
275
+ this.buttonEl.setAttribute("aria-busy", String(busy));
276
+ // A loading button stays focusable but inert; a disabled one is fully off.
174
277
  if (this.disabled) {
175
278
  this.buttonEl.setAttribute("disabled", "");
176
279
  }
177
280
  else {
178
281
  this.buttonEl.removeAttribute("disabled");
179
282
  }
180
- this.buttonEl.textContent = this.label;
283
+ this.buttonEl.setAttribute("aria-disabled", String(this.disabled || busy));
284
+ this.syncIconVisibility();
181
285
  }
182
286
  }
183
287
  export const defineBoxButtonElement = (tagName = DEFAULT_TAG_NAME) => {
@@ -3,6 +3,14 @@ type BoxMenuItem = {
3
3
  disabled?: boolean;
4
4
  id: string;
5
5
  label: string;
6
+ /** Render a divider before this item. */
7
+ separator?: boolean;
8
+ /** Render a non-interactive section header instead of a menuitem. */
9
+ header?: boolean;
10
+ /** Render as a link (navigates on activate). */
11
+ href?: string;
12
+ /** Checkable item — renders menuitemcheckbox with aria-checked. */
13
+ checked?: boolean;
6
14
  };
7
15
  export declare class BoxMenuElement extends BaseElement {
8
16
  static get observedAttributes(): string[];
@@ -13,6 +21,8 @@ export declare class BoxMenuElement extends BaseElement {
13
21
  set label(value: string);
14
22
  get items(): BoxMenuItem[];
15
23
  set items(value: BoxMenuItem[]);
24
+ /** Render one item: header, separator + item, link, checkable, or plain. */
25
+ private renderItem;
16
26
  protected renderTemplate(): void;
17
27
  protected setupListeners(): void;
18
28
  private enabledMenuItems;
@@ -1,6 +1,6 @@
1
1
  import { BaseElement } from "../../core/index.js";
2
2
  import { applyRovingTabindex, focusRovingItem, nextRovingIndex, } from "../../foundations/a11y/index.js";
3
- import { boeControl, boeOverlay } from "../../foundations/geometry/index.js";
3
+ import { boeControl, boeOverlay, boeSpace } 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
6
  const DEFAULT_TAG_NAME = "box-menu";
@@ -62,6 +62,41 @@ const menuStyles = `
62
62
  [part="menu-item"]:disabled {
63
63
  opacity: ${boeControl.disabledOpacity};
64
64
  }
65
+
66
+ /* Link items keep the menu-item chrome but render as anchors. */
67
+ a[part="menu-item"] {
68
+ display: flex;
69
+ align-items: center;
70
+ text-decoration: none;
71
+ }
72
+
73
+ [part="menu-separator"] {
74
+ height: 1px;
75
+ margin: ${boeSpace[1]} 0;
76
+ background: var(--boe-token-stroke-stroke, #e8e8e8);
77
+ }
78
+
79
+ [part="menu-header"] {
80
+ padding: ${boeSpace[1]} ${boeSpace[2]};
81
+ font-size: 11px;
82
+ font-weight: 700;
83
+ letter-spacing: 0.06em;
84
+ text-transform: uppercase;
85
+ color: var(--boe-token-text-text-secondary, #6f6f6f);
86
+ }
87
+
88
+ /* Check indicator for checkable items. */
89
+ [part="menu-item"][role="menuitemcheckbox"]::before {
90
+ content: "";
91
+ display: inline-block;
92
+ width: 1em;
93
+ margin-inline-end: ${boeSpace[1]};
94
+ flex-shrink: 0;
95
+ }
96
+
97
+ [part="menu-item"][aria-checked="true"]::before {
98
+ content: "✓";
99
+ }
65
100
  `;
66
101
  export class BoxMenuElement extends BaseElement {
67
102
  static get observedAttributes() {
@@ -101,6 +136,27 @@ export class BoxMenuElement extends BaseElement {
101
136
  set items(value) {
102
137
  this.setAttribute("items", JSON.stringify(value));
103
138
  }
139
+ /** Render one item: header, separator + item, link, checkable, or plain. */
140
+ renderItem(item) {
141
+ const separator = item.separator ? `<div part="menu-separator" role="separator"></div>` : "";
142
+ if (item.header) {
143
+ return `${separator}<div part="menu-header" role="presentation">${escapeHtml(item.label)}</div>`;
144
+ }
145
+ const disabled = this.disabled || item.disabled;
146
+ const id = escapeHtml(item.id);
147
+ const label = escapeHtml(item.label);
148
+ // Checkable item — role=menuitemcheckbox with aria-checked.
149
+ if (typeof item.checked === "boolean") {
150
+ const checkedAttr = item.checked ? ' aria-checked="true"' : ' aria-checked="false"';
151
+ return `${separator}<button type="button" part="menu-item" role="menuitemcheckbox" data-item-id="${id}"${checkedAttr} ${disabled ? "disabled" : ""}>${label}</button>`;
152
+ }
153
+ // Link item — an anchor styled as a menuitem.
154
+ if (item.href) {
155
+ const aria = disabled ? ' aria-disabled="true"' : "";
156
+ return `${separator}<a part="menu-item" role="menuitem" data-item-id="${id}" href="${escapeHtml(item.href)}" tabindex="-1"${aria}>${label}</a>`;
157
+ }
158
+ return `${separator}<button type="button" part="menu-item" role="menuitem" data-item-id="${id}" ${disabled ? "disabled" : ""}>${label}</button>`;
159
+ }
104
160
  renderTemplate() {
105
161
  if (!this.shadowRoot) {
106
162
  return;
@@ -113,15 +169,22 @@ export class BoxMenuElement extends BaseElement {
113
169
  }
114
170
  setupListeners() {
115
171
  this.menuEl.addEventListener("click", event => {
116
- const button = event.target?.closest('[part="menu-item"]');
117
- if (!button || !this.menuEl.contains(button) || button.disabled) {
172
+ const el = event.target?.closest('[part="menu-item"]');
173
+ // Anchors have no `.disabled`; both use aria-disabled / native disabled.
174
+ const isDisabled = el?.hasAttribute("disabled") || el?.getAttribute("aria-disabled") === "true";
175
+ if (!el || !this.menuEl.contains(el) || isDisabled) {
118
176
  return;
119
177
  }
120
- const itemId = button.getAttribute("data-item-id");
178
+ const itemId = el.getAttribute("data-item-id");
121
179
  const item = this.items.find(entry => entry.id === itemId);
122
180
  if (!item || this.disabled || item.disabled) {
123
181
  return;
124
182
  }
183
+ // Checkable items toggle their aria-checked optimistically; the host owns
184
+ // the source of truth and can re-render from the event.
185
+ if (typeof item.checked === "boolean") {
186
+ el.setAttribute("aria-checked", String(!item.checked));
187
+ }
125
188
  this.dispatchEvent(new CustomEvent("item-selected", {
126
189
  bubbles: true,
127
190
  composed: true,
@@ -157,13 +220,7 @@ export class BoxMenuElement extends BaseElement {
157
220
  return;
158
221
  }
159
222
  this.menuEl.setAttribute("aria-label", this.label);
160
- this.menuEl.innerHTML = this.items
161
- .map(item => `
162
- <button type="button" part="menu-item" role="menuitem" data-item-id="${escapeHtml(item.id)}" ${this.disabled || item.disabled ? "disabled" : ""}>
163
- ${escapeHtml(item.label)}
164
- </button>
165
- `)
166
- .join("");
223
+ this.menuEl.innerHTML = this.items.map(item => this.renderItem(item)).join("");
167
224
  const buttons = this.enabledMenuItems();
168
225
  applyRovingTabindex(buttons, 0);
169
226
  }
@@ -0,0 +1,53 @@
1
+ import { BaseElement } from "../../core/index.js";
2
+ export interface TableColumn {
3
+ key: string;
4
+ label: string;
5
+ /** Cross-axis alignment of the column's cells. */
6
+ align?: "start" | "end" | "center";
7
+ /** Mark the column sortable — clicking its header emits `sort`. */
8
+ sortable?: boolean;
9
+ }
10
+ export interface TableRow {
11
+ id: string;
12
+ /** Cell text keyed by column key, or positional strings matching `columns`. */
13
+ cells: Record<string, string> | string[];
14
+ }
15
+ export type TableSelectionMode = "none" | "single" | "multiple";
16
+ /**
17
+ * A semantic, selectable data table — box-ui-elements `Table` + `makeSelectable`.
18
+ * Rows and columns come from JSON. Selection supports single / Ctrl-click toggle
19
+ * / Shift-click range, plus keyboard (Arrow, Shift+Arrow to extend, Space to
20
+ * toggle, Ctrl/Cmd+A select-all, Escape clear). Sorting is host-owned: sortable
21
+ * headers emit `sort` and the host reorders `rows`.
22
+ */
23
+ export declare class BoxTableElement extends BaseElement {
24
+ static get observedAttributes(): string[];
25
+ private bodyEl;
26
+ private headEl;
27
+ private selected;
28
+ private anchorIndex;
29
+ get columns(): TableColumn[];
30
+ set columns(value: TableColumn[]);
31
+ get rows(): TableRow[];
32
+ set rows(value: TableRow[]);
33
+ get selectionMode(): TableSelectionMode;
34
+ set selectionMode(value: TableSelectionMode);
35
+ get selectedIds(): string[];
36
+ set selectedIds(value: string[]);
37
+ get label(): string;
38
+ set label(value: string);
39
+ private parseJson;
40
+ private cellText;
41
+ protected renderTemplate(): void;
42
+ protected setupListeners(): void;
43
+ private rowElements;
44
+ private onBodyKeydown;
45
+ private toggleSort;
46
+ private activateRow;
47
+ private selectRange;
48
+ private selectAll;
49
+ private setSelection;
50
+ private applySelectionState;
51
+ protected update(): void;
52
+ }
53
+ export declare const defineBoxTableElement: (tagName?: string) => typeof BoxTableElement;