@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
@@ -1,7 +1,8 @@
1
- import { FormAssociatedElement, boeFormFieldErrorStyles, formErrorMessageMarkup, } from "../../core/index.js";
2
- import { boeMotionDuration, boeMotionEasing } from "../../foundations/motion/index.js";
1
+ import { FormAssociatedElement, boeFormFieldErrorStyles, boeFormFieldSupportStyles, formDescriptionMarkup, formErrorMessageMarkup, } from "../../core/index.js";
2
+ import { boeMotionDuration, boeMotionEasing, boeReducedMotionStyles, } from "../../foundations/motion/index.js";
3
3
  import { boeRadius } from "../../foundations/geometry/index.js";
4
4
  const DEFAULT_TAG_NAME = "box-search-field";
5
+ const searchSpinnerMarkup = '<span part="spinner" aria-hidden="true"></span>';
5
6
  const searchFieldStyles = `
6
7
  :host {
7
8
  display: block;
@@ -121,21 +122,47 @@ const searchFieldStyles = `
121
122
  cursor: not-allowed;
122
123
  }
123
124
 
125
+ [part="submit"] [part="spinner"] {
126
+ display: inline-block;
127
+ inline-size: 0.95rem;
128
+ block-size: 0.95rem;
129
+ border-radius: 999px;
130
+ border: 2px solid color-mix(in srgb, currentColor 35%, transparent);
131
+ border-top-color: currentColor;
132
+ animation: boe-search-spin ${boeMotionDuration.spin} ${boeMotionEasing.linear} infinite;
133
+ }
134
+
135
+ @keyframes boe-search-spin {
136
+ to { transform: rotate(360deg); }
137
+ }
138
+
139
+ ${boeReducedMotionStyles('[part="submit"] [part="spinner"]', "animation-duration: 1.6s;")}
140
+
124
141
  ${boeFormFieldErrorStyles}
142
+ ${boeFormFieldSupportStyles}
125
143
  `;
126
144
  export class BoxSearchFieldElement extends FormAssociatedElement {
127
145
  static get observedAttributes() {
128
146
  return [
129
- ...FormAssociatedElement.formObservedAttributes,
147
+ ...FormAssociatedElement.fieldObservedAttributes,
130
148
  "disabled",
131
149
  "label",
132
150
  "placeholder",
151
+ "loading",
133
152
  "value",
134
153
  ];
135
154
  }
155
+ /** Shows a spinner in the submit button while a search runs asynchronously. */
156
+ get loading() {
157
+ return this.hasAttribute("loading");
158
+ }
159
+ set loading(value) {
160
+ this.toggleAttribute("loading", Boolean(value));
161
+ }
136
162
  valueInternal = "";
137
163
  inputEl;
138
164
  labelEl;
165
+ descriptionEl;
139
166
  submitEl;
140
167
  clearEl;
141
168
  errorEl;
@@ -206,6 +233,7 @@ export class BoxSearchFieldElement extends FormAssociatedElement {
206
233
  <style>${searchFieldStyles}</style>
207
234
  <label part="field">
208
235
  <span part="label"></span>
236
+ ${formDescriptionMarkup()}
209
237
  <div part="input-shell">
210
238
  <input type="search" part="input" />
211
239
  <button type="button" part="submit">Search</button>
@@ -215,6 +243,7 @@ export class BoxSearchFieldElement extends FormAssociatedElement {
215
243
  </label>
216
244
  `;
217
245
  this.labelEl = this.shadowRoot.querySelector('[part="label"]');
246
+ this.descriptionEl = this.shadowRoot.querySelector('[part="description"]');
218
247
  this.inputEl = this.shadowRoot.querySelector('[part="input"]');
219
248
  this.submitEl = this.shadowRoot.querySelector('[part="submit"]');
220
249
  this.clearEl = this.shadowRoot.querySelector('[part="clear"]');
@@ -241,27 +270,32 @@ export class BoxSearchFieldElement extends FormAssociatedElement {
241
270
  return;
242
271
  }
243
272
  if (event.key === "Enter") {
244
- this.dispatchEvent(new CustomEvent("search", {
245
- bubbles: true,
246
- composed: true,
247
- detail: { value: this.valueInternal },
248
- }));
273
+ this.submitSearch();
249
274
  }
250
275
  });
251
276
  this.submitEl.addEventListener("click", () => {
252
277
  if (this.disabled) {
253
278
  return;
254
279
  }
255
- this.dispatchEvent(new CustomEvent("search", {
256
- bubbles: true,
257
- composed: true,
258
- detail: { value: this.valueInternal },
259
- }));
280
+ this.submitSearch();
260
281
  });
261
282
  this.clearEl.addEventListener("click", () => {
262
283
  this.clear();
263
284
  });
264
285
  }
286
+ /**
287
+ * Emit `search` and, when placed inside a form, request its submission — so a
288
+ * search field behaves like a native search input on Enter / submit-click.
289
+ */
290
+ submitSearch() {
291
+ this.dispatchEvent(new CustomEvent("search", {
292
+ bubbles: true,
293
+ composed: true,
294
+ detail: { value: this.valueInternal },
295
+ }));
296
+ const form = this.internals.form ?? this.closest("form");
297
+ form?.requestSubmit();
298
+ }
265
299
  update() {
266
300
  if (!this.inputEl || !this.labelEl || !this.submitEl || !this.clearEl || !this.errorEl) {
267
301
  return;
@@ -273,13 +307,26 @@ export class BoxSearchFieldElement extends FormAssociatedElement {
273
307
  }
274
308
  if (this.disabled) {
275
309
  this.inputEl.setAttribute("disabled", "");
276
- this.submitEl.setAttribute("disabled", "");
277
310
  }
278
311
  else {
279
312
  this.inputEl.removeAttribute("disabled");
280
- this.submitEl.removeAttribute("disabled");
313
+ }
314
+ // Loading: show a spinner in the submit button and disable it; else the
315
+ // submit label, disabled only when the whole field is disabled.
316
+ if (this.loading) {
317
+ this.submitEl.innerHTML = searchSpinnerMarkup;
318
+ this.submitEl.setAttribute("aria-label", "Searching");
319
+ this.submitEl.setAttribute("aria-busy", "true");
320
+ this.submitEl.disabled = true;
321
+ }
322
+ else {
323
+ this.submitEl.textContent = "Search";
324
+ this.submitEl.removeAttribute("aria-label");
325
+ this.submitEl.removeAttribute("aria-busy");
326
+ this.submitEl.disabled = this.disabled;
281
327
  }
282
328
  this.clearEl.disabled = this.disabled || this.valueInternal.length === 0;
329
+ this.applyFieldSupport(this.labelEl, this.inputEl, this.descriptionEl);
283
330
  this.applyInvalidState(this.inputEl, this.errorEl);
284
331
  }
285
332
  }
@@ -1,30 +1,41 @@
1
1
  import { FormAssociatedElement } from "../../core/index.js";
2
2
  import type { FormValue } from "../../core/index.js";
3
+ type SelectOption = {
4
+ label: string;
5
+ value: string;
6
+ disabled?: boolean;
7
+ group?: string;
8
+ };
3
9
  export declare class BoxSelectElement extends FormAssociatedElement {
4
10
  static get observedAttributes(): string[];
5
11
  private valueInternal;
12
+ private valuesInternal;
6
13
  private selectEl;
7
14
  private labelEl;
15
+ private descriptionEl;
8
16
  private errorEl;
17
+ /** Allow selecting several options (native multi-select list box). */
18
+ get multiple(): boolean;
19
+ set multiple(value: boolean);
9
20
  get value(): string;
10
21
  set value(nextValue: string);
22
+ /** Selected values (canonical when `multiple`; single-element otherwise). */
23
+ get values(): string[];
24
+ set values(next: string[]);
11
25
  get disabled(): boolean;
12
26
  set disabled(value: boolean);
13
27
  get label(): string;
14
28
  set label(value: string);
15
- get options(): Array<{
16
- label: string;
17
- value: string;
18
- }>;
19
- set options(value: Array<{
20
- label: string;
21
- value: string;
22
- }>);
29
+ get options(): SelectOption[];
30
+ set options(value: SelectOption[]);
23
31
  attributeChangedCallback(name: string, oldValue: string | null, newValue: string | null): void;
24
32
  protected getFormValue(): FormValue;
25
33
  protected restoreFormValue(value: FormValue): void;
26
34
  protected renderTemplate(): void;
27
35
  protected setupListeners(): void;
36
+ /** Build `<option>`/`<optgroup>` markup, grouping options that carry `group`. */
37
+ private optionsMarkup;
28
38
  protected update(): void;
29
39
  }
30
40
  export declare const defineBoxSelectElement: (tagName?: string) => typeof BoxSelectElement;
41
+ export {};
@@ -1,4 +1,4 @@
1
- import { FormAssociatedElement, boeFormFieldErrorStyles, formErrorMessageMarkup, } from "../../core/index.js";
1
+ import { FormAssociatedElement, boeFormFieldErrorStyles, boeFormFieldSupportStyles, formDataFromNamedValues, formDescriptionMarkup, formErrorMessageMarkup, stringValuesFromFormValue, } from "../../core/index.js";
2
2
  import { boeControl, boeRadius, boeSpace } from "../../foundations/geometry/index.js";
3
3
  const DEFAULT_TAG_NAME = "box-select";
4
4
  const escapeHtml = (value) => value
@@ -63,32 +63,71 @@ const selectStyles = `
63
63
  cursor: not-allowed;
64
64
  }
65
65
 
66
+ /* Multi-select renders as a native list box: drop the single-select chevron
67
+ and fixed height, let it show several rows. */
68
+ [part="select"][multiple] {
69
+ min-height: auto;
70
+ padding: 4px;
71
+ background-image: none;
72
+ cursor: default;
73
+ }
74
+
75
+ [part="select"][multiple] option {
76
+ padding: 3px 6px;
77
+ border-radius: ${boeRadius.size};
78
+ }
79
+
66
80
  ${boeFormFieldErrorStyles}
81
+ ${boeFormFieldSupportStyles}
67
82
  `;
68
83
  export class BoxSelectElement extends FormAssociatedElement {
69
84
  static get observedAttributes() {
70
85
  return [
71
- ...FormAssociatedElement.formObservedAttributes,
86
+ ...FormAssociatedElement.fieldObservedAttributes,
72
87
  "disabled",
73
88
  "label",
89
+ "multiple",
74
90
  "options",
75
91
  "value",
76
92
  ];
77
93
  }
78
94
  valueInternal = "";
95
+ valuesInternal = [];
79
96
  selectEl;
80
97
  labelEl;
98
+ descriptionEl;
81
99
  errorEl;
100
+ /** Allow selecting several options (native multi-select list box). */
101
+ get multiple() {
102
+ return this.hasAttribute("multiple");
103
+ }
104
+ set multiple(value) {
105
+ this.toggleAttribute("multiple", Boolean(value));
106
+ }
82
107
  get value() {
83
108
  return this.valueInternal;
84
109
  }
85
110
  set value(nextValue) {
86
111
  this.valueInternal = nextValue;
112
+ this.valuesInternal = nextValue ? [nextValue] : [];
87
113
  this.setAttribute("value", nextValue);
88
114
  if (this.isRendered) {
89
115
  this.update();
90
116
  }
91
117
  }
118
+ /** Selected values (canonical when `multiple`; single-element otherwise). */
119
+ get values() {
120
+ return [...this.valuesInternal];
121
+ }
122
+ set values(next) {
123
+ this.valuesInternal = Array.isArray(next) ? [...next] : [];
124
+ this.valueInternal = this.valuesInternal[0] ?? "";
125
+ this.setAttribute("value", this.valueInternal);
126
+ this.syncFormAssociation();
127
+ if (this.isRendered) {
128
+ this.update();
129
+ }
130
+ }
92
131
  get disabled() {
93
132
  return this.hasAttribute("disabled");
94
133
  }
@@ -125,16 +164,30 @@ export class BoxSelectElement extends FormAssociatedElement {
125
164
  attributeChangedCallback(name, oldValue, newValue) {
126
165
  if (name === "value") {
127
166
  this.valueInternal = this.getAttribute("value") ?? "";
167
+ if (!this.multiple) {
168
+ this.valuesInternal = this.valueInternal ? [this.valueInternal] : [];
169
+ }
128
170
  }
129
171
  super.attributeChangedCallback(name, oldValue, newValue);
130
172
  }
131
173
  getFormValue() {
174
+ if (this.multiple) {
175
+ return formDataFromNamedValues(this.name, this.valuesInternal);
176
+ }
132
177
  return this.valueInternal;
133
178
  }
134
179
  restoreFormValue(value) {
135
- const next = typeof value === "string" ? value : "";
136
- this.valueInternal = next;
137
- this.setAttribute("value", next);
180
+ if (this.multiple) {
181
+ this.valuesInternal = stringValuesFromFormValue(value, this.name);
182
+ this.valueInternal = this.valuesInternal[0] ?? "";
183
+ this.setAttribute("value", this.valueInternal);
184
+ }
185
+ else {
186
+ const next = typeof value === "string" ? value : "";
187
+ this.valueInternal = next;
188
+ this.valuesInternal = next ? [next] : [];
189
+ this.setAttribute("value", next);
190
+ }
138
191
  if (this.isRendered) {
139
192
  this.update();
140
193
  }
@@ -147,44 +200,86 @@ export class BoxSelectElement extends FormAssociatedElement {
147
200
  <style>${selectStyles}</style>
148
201
  <label part="field">
149
202
  <span part="label"></span>
203
+ ${formDescriptionMarkup()}
150
204
  <select part="select"></select>
151
205
  ${formErrorMessageMarkup()}
152
206
  </label>
153
207
  `;
154
208
  this.labelEl = this.shadowRoot.querySelector('[part="label"]');
209
+ this.descriptionEl = this.shadowRoot.querySelector('[part="description"]');
155
210
  this.selectEl = this.shadowRoot.querySelector('[part="select"]');
156
211
  this.errorEl = this.shadowRoot.querySelector('[part="error-message"]');
157
212
  }
158
213
  setupListeners() {
159
214
  this.selectEl.addEventListener("change", event => {
160
- const nextValue = event.currentTarget.value;
161
- this.valueInternal = nextValue;
162
- this.setAttribute("value", nextValue);
215
+ const select = event.currentTarget;
216
+ if (this.multiple) {
217
+ this.valuesInternal = Array.from(select.selectedOptions).map(option => option.value);
218
+ this.valueInternal = this.valuesInternal[0] ?? "";
219
+ }
220
+ else {
221
+ this.valueInternal = select.value;
222
+ this.valuesInternal = this.valueInternal ? [this.valueInternal] : [];
223
+ }
224
+ this.setAttribute("value", this.valueInternal);
163
225
  this.syncFormAssociation();
164
226
  this.dispatchEvent(new CustomEvent("value-changed", {
165
227
  bubbles: true,
166
228
  composed: true,
167
- detail: { value: nextValue },
229
+ detail: this.multiple
230
+ ? { value: this.valueInternal, values: this.values }
231
+ : { value: this.valueInternal },
168
232
  }));
169
233
  });
170
234
  }
235
+ /** Build `<option>`/`<optgroup>` markup, grouping options that carry `group`. */
236
+ optionsMarkup() {
237
+ const renderOption = (option) => `<option value="${escapeHtml(option.value)}"${option.disabled ? " disabled" : ""}>${escapeHtml(option.label)}</option>`;
238
+ const options = this.options;
239
+ let markup = "";
240
+ let index = 0;
241
+ while (index < options.length) {
242
+ const group = options[index].group;
243
+ if (!group) {
244
+ markup += renderOption(options[index]);
245
+ index += 1;
246
+ continue;
247
+ }
248
+ // Consume the contiguous run sharing this group label into one optgroup.
249
+ let run = "";
250
+ while (index < options.length && options[index].group === group) {
251
+ run += renderOption(options[index]);
252
+ index += 1;
253
+ }
254
+ markup += `<optgroup label="${escapeHtml(group)}">${run}</optgroup>`;
255
+ }
256
+ return markup;
257
+ }
171
258
  update() {
172
259
  if (!this.selectEl || !this.labelEl || !this.errorEl) {
173
260
  return;
174
261
  }
175
262
  this.labelEl.textContent = this.label;
176
- // Rebuild options (the list itself may change)
177
- this.selectEl.innerHTML = this.options
178
- .map(option => `<option value="${escapeHtml(option.value)}">${escapeHtml(option.label)}</option>`)
179
- .join("");
180
- // Patch selected value and disabled after rebuilding options
181
- this.selectEl.value = this.valueInternal;
263
+ // Rebuild options (the list itself may change), grouping by `group`.
264
+ this.selectEl.multiple = this.multiple;
265
+ this.selectEl.innerHTML = this.optionsMarkup();
266
+ // Patch selected value(s) and disabled after rebuilding options.
267
+ if (this.multiple) {
268
+ const selected = new Set(this.valuesInternal);
269
+ Array.from(this.selectEl.options).forEach(option => {
270
+ option.selected = selected.has(option.value);
271
+ });
272
+ }
273
+ else {
274
+ this.selectEl.value = this.valueInternal;
275
+ }
182
276
  if (this.disabled) {
183
277
  this.selectEl.setAttribute("disabled", "");
184
278
  }
185
279
  else {
186
280
  this.selectEl.removeAttribute("disabled");
187
281
  }
282
+ this.applyFieldSupport(this.labelEl, this.selectEl, this.descriptionEl);
188
283
  this.applyInvalidState(this.selectEl, this.errorEl);
189
284
  }
190
285
  }
@@ -5,7 +5,20 @@ export declare class BoxTextFieldElement extends FormAssociatedElement {
5
5
  private valueInternal;
6
6
  private inputEl;
7
7
  private labelEl;
8
+ private descriptionEl;
8
9
  private errorEl;
10
+ private controlEl;
11
+ private iconSlot;
12
+ private statusEl;
13
+ /** Input type passthrough (text/email/tel/url/password/search/number). */
14
+ get type(): string;
15
+ set type(value: string);
16
+ /** Shows a trailing spinner (e.g. while validating/looking up asynchronously). */
17
+ get loading(): boolean;
18
+ set loading(value: boolean);
19
+ /** Shows a trailing success check (explicit valid affirmation). */
20
+ get valid(): boolean;
21
+ set valid(value: boolean);
9
22
  get value(): string;
10
23
  set value(nextValue: string);
11
24
  get disabled(): boolean;
@@ -19,6 +32,7 @@ export declare class BoxTextFieldElement extends FormAssociatedElement {
19
32
  protected restoreFormValue(value: FormValue): void;
20
33
  protected renderTemplate(): void;
21
34
  protected setupListeners(): void;
35
+ private syncIconSlot;
22
36
  protected update(): void;
23
37
  }
24
38
  export declare const defineBoxTextFieldElement: (tagName?: string) => typeof BoxTextFieldElement;
@@ -1,6 +1,10 @@
1
- import { FormAssociatedElement, boeFormFieldErrorStyles, formErrorMessageMarkup, } from "../../core/index.js";
1
+ import { FormAssociatedElement, boeFormFieldErrorStyles, boeFormFieldSupportStyles, formDescriptionMarkup, formErrorMessageMarkup, } from "../../core/index.js";
2
2
  import { boeControl, boeInputControlStyles, boeSpace } from "../../foundations/geometry/index.js";
3
+ import { boeMotionDuration, boeMotionEasing, boeReducedMotionStyles, } from "../../foundations/motion/index.js";
3
4
  const DEFAULT_TAG_NAME = "box-text-field";
5
+ const VALID_INPUT_TYPES = new Set(["text", "email", "tel", "url", "password", "search", "number"]);
6
+ const spinnerMarkup = '<span part="spinner" aria-hidden="true"></span>';
7
+ const validMarkup = '<svg part="valid-icon" viewBox="0 0 16 16" aria-hidden="true"><path d="M6.5 11L3 7.5l1-1 2.5 2.5L12 3.5l1 1z" fill="currentColor"/></svg>';
4
8
  /** Input chrome tracks BUE `@mixin box-inputs` (`src/styles/_inputs.scss`). */
5
9
  const textFieldStyles = `
6
10
  :host {
@@ -33,22 +37,124 @@ const textFieldStyles = `
33
37
  color: var(--boe-token-text-text-placeholder, #909090);
34
38
  }
35
39
 
40
+ /* Control wrapper holds the input plus optional leading icon and trailing
41
+ status. With no adornments it is layout-neutral (default renders unchanged). */
42
+ [part="control"] {
43
+ position: relative;
44
+ display: block;
45
+ }
46
+
47
+ [part="icon"] {
48
+ position: absolute;
49
+ inset-block-start: 50%;
50
+ inset-inline-start: 0.6rem;
51
+ transform: translateY(-50%);
52
+ display: inline-flex;
53
+ align-items: center;
54
+ color: var(--boe-token-text-text-secondary, #6f6f6f);
55
+ pointer-events: none;
56
+ }
57
+
58
+ [part="icon"]:not(.has-content) {
59
+ display: none;
60
+ }
61
+
62
+ [part="icon"]::slotted(*) {
63
+ inline-size: 1rem;
64
+ block-size: 1rem;
65
+ display: block;
66
+ }
67
+
68
+ [part="control"][data-has-icon="true"] [part="input"] {
69
+ padding-inline-start: 2.1rem;
70
+ }
71
+
72
+ [part="status"] {
73
+ position: absolute;
74
+ inset-block-start: 50%;
75
+ inset-inline-end: 0.65rem;
76
+ transform: translateY(-50%);
77
+ display: none;
78
+ align-items: center;
79
+ }
80
+
81
+ [part="control"][data-status="loading"] [part="status"],
82
+ [part="control"][data-status="valid"] [part="status"] {
83
+ display: inline-flex;
84
+ }
85
+
86
+ [part="control"][data-status="loading"] [part="input"],
87
+ [part="control"][data-status="valid"] [part="input"] {
88
+ padding-inline-end: 2.2rem;
89
+ }
90
+
91
+ [part="spinner"] {
92
+ inline-size: 1rem;
93
+ block-size: 1rem;
94
+ border-radius: 999px;
95
+ border: 2px solid color-mix(in srgb, var(--boe-token-surface-surface-brand, #0061d5) 20%, transparent);
96
+ border-top-color: var(--boe-token-surface-surface-brand, #0061d5);
97
+ animation: boe-text-field-spin ${boeMotionDuration.spin} ${boeMotionEasing.linear} infinite;
98
+ }
99
+
100
+ @keyframes boe-text-field-spin {
101
+ to { transform: rotate(360deg); }
102
+ }
103
+
104
+ ${boeReducedMotionStyles('[part="spinner"]', "animation-duration: 1.6s;")}
105
+
106
+ [part="valid-icon"] {
107
+ inline-size: 1.05rem;
108
+ block-size: 1.05rem;
109
+ color: var(--boe-token-surface-status-surface-success, #26c281);
110
+ }
111
+
36
112
  ${boeFormFieldErrorStyles}
113
+ ${boeFormFieldSupportStyles}
37
114
  `;
38
115
  export class BoxTextFieldElement extends FormAssociatedElement {
39
116
  static get observedAttributes() {
40
117
  return [
41
- ...FormAssociatedElement.formObservedAttributes,
118
+ ...FormAssociatedElement.fieldObservedAttributes,
42
119
  "disabled",
43
120
  "label",
44
121
  "placeholder",
122
+ "type",
123
+ "loading",
124
+ "valid",
45
125
  "value",
46
126
  ];
47
127
  }
48
128
  valueInternal = "";
49
129
  inputEl;
50
130
  labelEl;
131
+ descriptionEl;
51
132
  errorEl;
133
+ controlEl;
134
+ iconSlot;
135
+ statusEl;
136
+ /** Input type passthrough (text/email/tel/url/password/search/number). */
137
+ get type() {
138
+ const value = this.getAttribute("type");
139
+ return value && VALID_INPUT_TYPES.has(value) ? value : "text";
140
+ }
141
+ set type(value) {
142
+ this.setAttribute("type", value);
143
+ }
144
+ /** Shows a trailing spinner (e.g. while validating/looking up asynchronously). */
145
+ get loading() {
146
+ return this.hasAttribute("loading");
147
+ }
148
+ set loading(value) {
149
+ this.toggleAttribute("loading", Boolean(value));
150
+ }
151
+ /** Shows a trailing success check (explicit valid affirmation). */
152
+ get valid() {
153
+ return this.hasAttribute("valid");
154
+ }
155
+ set valid(value) {
156
+ this.toggleAttribute("valid", Boolean(value));
157
+ }
52
158
  get value() {
53
159
  return this.valueInternal;
54
160
  }
@@ -102,15 +208,25 @@ export class BoxTextFieldElement extends FormAssociatedElement {
102
208
  <style>${textFieldStyles}</style>
103
209
  <label part="field">
104
210
  <span part="label"></span>
105
- <input type="text" part="input" />
211
+ ${formDescriptionMarkup()}
212
+ <span part="control">
213
+ <slot name="icon" part="icon"></slot>
214
+ <input type="text" part="input" />
215
+ <span part="status" aria-hidden="true"></span>
216
+ </span>
106
217
  ${formErrorMessageMarkup()}
107
218
  </label>
108
219
  `;
109
220
  this.labelEl = this.shadowRoot.querySelector('[part="label"]');
221
+ this.descriptionEl = this.shadowRoot.querySelector('[part="description"]');
110
222
  this.inputEl = this.shadowRoot.querySelector('[part="input"]');
111
223
  this.errorEl = this.shadowRoot.querySelector('[part="error-message"]');
224
+ this.controlEl = this.shadowRoot.querySelector('[part="control"]');
225
+ this.iconSlot = this.shadowRoot.querySelector('slot[name="icon"]');
226
+ this.statusEl = this.shadowRoot.querySelector('[part="status"]');
112
227
  }
113
228
  setupListeners() {
229
+ this.iconSlot.addEventListener("slotchange", () => this.syncIconSlot());
114
230
  this.inputEl.addEventListener("input", event => {
115
231
  const nextValue = event.currentTarget.value;
116
232
  this.valueInternal = nextValue;
@@ -123,12 +239,35 @@ export class BoxTextFieldElement extends FormAssociatedElement {
123
239
  }));
124
240
  });
125
241
  }
242
+ syncIconSlot() {
243
+ if (!this.iconSlot || !this.controlEl) {
244
+ return;
245
+ }
246
+ const hasContent = this.iconSlot.assignedNodes({ flatten: true }).length > 0;
247
+ this.iconSlot.classList.toggle("has-content", hasContent);
248
+ this.controlEl.dataset.hasIcon = hasContent ? "true" : "false";
249
+ }
126
250
  update() {
127
251
  if (!this.inputEl || !this.labelEl || !this.errorEl) {
128
252
  return;
129
253
  }
130
254
  this.labelEl.textContent = this.label;
255
+ this.inputEl.type = this.type;
131
256
  this.inputEl.placeholder = this.placeholder;
257
+ this.syncIconSlot();
258
+ // Trailing status: loading spinner takes precedence over the valid check.
259
+ if (this.loading) {
260
+ this.controlEl.dataset.status = "loading";
261
+ this.statusEl.innerHTML = spinnerMarkup;
262
+ }
263
+ else if (this.valid) {
264
+ this.controlEl.dataset.status = "valid";
265
+ this.statusEl.innerHTML = validMarkup;
266
+ }
267
+ else {
268
+ this.controlEl.removeAttribute("data-status");
269
+ this.statusEl.innerHTML = "";
270
+ }
132
271
  if (this.shadowRoot?.activeElement !== this.inputEl) {
133
272
  this.inputEl.value = this.valueInternal;
134
273
  }
@@ -138,6 +277,8 @@ export class BoxTextFieldElement extends FormAssociatedElement {
138
277
  else {
139
278
  this.inputEl.removeAttribute("disabled");
140
279
  }
280
+ // After the label text is (re)set, since it clears the required mark.
281
+ this.applyFieldSupport(this.labelEl, this.inputEl, this.descriptionEl);
141
282
  this.applyInvalidState(this.inputEl, this.errorEl);
142
283
  }
143
284
  }
@@ -21,6 +21,17 @@ export declare class BoxTimeFieldElement extends FormAssociatedElement {
21
21
  attributeChangedCallback(name: string, oldValue: string | null, newValue: string | null): void;
22
22
  protected getFormValue(): FormValue;
23
23
  protected restoreFormValue(value: FormValue): void;
24
+ /**
25
+ * Parse a human time string in 24-hour (`13:30`) or 12-hour (`1:30 PM`,
26
+ * `1 pm`) form to canonical `HH:MM`. Returns `""` for empty input and `null`
27
+ * when it cannot be parsed.
28
+ */
29
+ static parseTime(raw: string): string | null;
30
+ /**
31
+ * Set the value from a human 12h/24h string. On success updates the value and
32
+ * returns true; on a parse failure emits `parse-error` and returns false.
33
+ */
34
+ setTimeString(value: string): boolean;
24
35
  private syncValue;
25
36
  protected renderTemplate(): void;
26
37
  protected setupListeners(): void;