@unofficialbox/box-open-elements 0.13.0 → 0.15.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.
@@ -0,0 +1,39 @@
1
+ import { BaseElement } from "../../core/index.js";
2
+ /**
3
+ * A row of independent controls, with the keyboard behaviour a toolbar owes.
4
+ *
5
+ * ```html
6
+ * <box-toolbar label="Document actions">
7
+ * <button type="button">Share</button>
8
+ * <button type="button">Download</button>
9
+ * </box-toolbar>
10
+ * ```
11
+ *
12
+ * The controls are yours — this contributes `role="toolbar"`, the accessible
13
+ * name, and roving tabindex, so the group is one tab stop and the arrow keys
14
+ * move within it.
15
+ *
16
+ * Not to be confused with `box-button-group`, which is a `radiogroup`: that one
17
+ * is for picking exactly one of a set, and its children answer to a shared
18
+ * value. A toolbar's controls are independent of each other.
19
+ */
20
+ export declare class Toolbar extends BaseElement {
21
+ static readonly tagName: string;
22
+ static get observedAttributes(): string[];
23
+ private listenersBound;
24
+ private controlObserver;
25
+ /** Accessible name for the group, announced before its controls. */
26
+ get label(): string;
27
+ set label(next: string);
28
+ /** `horizontal` (default) or `vertical`; picks which arrow keys navigate. */
29
+ get orientation(): string;
30
+ set orientation(next: string);
31
+ /** The controls this toolbar is managing, in document order. */
32
+ get controls(): HTMLElement[];
33
+ /** Every focusable control, disabled ones included. */
34
+ private focusableElements;
35
+ disconnectedCallback(): void;
36
+ protected renderTemplate(): void;
37
+ protected setupListeners(): void;
38
+ protected update(): void;
39
+ }
@@ -0,0 +1,170 @@
1
+ import { BaseElement } from "../../core/index.js";
2
+ import { applyRovingTabindex, handleRovingKeydown } from "../../foundations/a11y/index.js";
3
+ import { boePanel } from "../../foundations/geometry/index.js";
4
+ const DEFAULT_TAG_NAME = "box-toolbar";
5
+ const ORIENTATIONS = new Set(["horizontal", "vertical"]);
6
+ /**
7
+ * Controls a toolbar will manage. Deliberately the natively focusable ones:
8
+ * roving tabindex works by moving `tabindex` between elements, which only
9
+ * focuses what the browser already considers focusable. A custom element host
10
+ * is not focusable unless it says so, so wrapping one in a toolbar requires it
11
+ * to carry its own `tabindex`.
12
+ */
13
+ const FOCUSABLE = "button, a[href], input, select, textarea, [tabindex]";
14
+ const toolbarStyles = `
15
+ :host {
16
+ display: block;
17
+ color: inherit;
18
+ font: inherit;
19
+ }
20
+
21
+ [part="toolbar"] {
22
+ display: flex;
23
+ flex-wrap: wrap;
24
+ align-items: center;
25
+ gap: ${boePanel.gap};
26
+ }
27
+
28
+ :host([orientation="vertical"]) [part="toolbar"] {
29
+ flex-direction: column;
30
+ align-items: stretch;
31
+ flex-wrap: nowrap;
32
+ }
33
+ `;
34
+ /**
35
+ * A row of independent controls, with the keyboard behaviour a toolbar owes.
36
+ *
37
+ * ```html
38
+ * <box-toolbar label="Document actions">
39
+ * <button type="button">Share</button>
40
+ * <button type="button">Download</button>
41
+ * </box-toolbar>
42
+ * ```
43
+ *
44
+ * The controls are yours — this contributes `role="toolbar"`, the accessible
45
+ * name, and roving tabindex, so the group is one tab stop and the arrow keys
46
+ * move within it.
47
+ *
48
+ * Not to be confused with `box-button-group`, which is a `radiogroup`: that one
49
+ * is for picking exactly one of a set, and its children answer to a shared
50
+ * value. A toolbar's controls are independent of each other.
51
+ */
52
+ export class Toolbar extends BaseElement {
53
+ static tagName = DEFAULT_TAG_NAME;
54
+ static get observedAttributes() {
55
+ return ["label", "orientation"];
56
+ }
57
+ listenersBound = false;
58
+ controlObserver = null;
59
+ /** Accessible name for the group, announced before its controls. */
60
+ get label() {
61
+ return this.getAttribute("label") ?? "";
62
+ }
63
+ set label(next) {
64
+ this.setAttribute("label", next);
65
+ }
66
+ /** `horizontal` (default) or `vertical`; picks which arrow keys navigate. */
67
+ get orientation() {
68
+ const raw = this.getAttribute("orientation");
69
+ return raw && ORIENTATIONS.has(raw) ? raw : "horizontal";
70
+ }
71
+ set orientation(next) {
72
+ this.setAttribute("orientation", next);
73
+ }
74
+ /** The controls this toolbar is managing, in document order. */
75
+ get controls() {
76
+ return this.focusableElements().filter(element => !element.hasAttribute("disabled"));
77
+ }
78
+ /** Every focusable control, disabled ones included. */
79
+ focusableElements() {
80
+ const slot = this.shadowRoot?.querySelector("slot");
81
+ if (!slot) {
82
+ return [];
83
+ }
84
+ return slot
85
+ .assignedElements()
86
+ .flatMap(element => element.matches(FOCUSABLE)
87
+ ? [element]
88
+ : Array.from(element.querySelectorAll(FOCUSABLE)));
89
+ }
90
+ disconnectedCallback() {
91
+ this.controlObserver?.disconnect();
92
+ this.controlObserver = null;
93
+ this.listenersBound = false;
94
+ }
95
+ renderTemplate() {
96
+ if (!this.shadowRoot) {
97
+ return;
98
+ }
99
+ this.shadowRoot.innerHTML = `
100
+ <style>${toolbarStyles}</style>
101
+ <div part="toolbar" role="toolbar"><slot></slot></div>
102
+ `;
103
+ this.listenersBound = false;
104
+ }
105
+ setupListeners() {
106
+ if (!this.shadowRoot || this.listenersBound) {
107
+ return;
108
+ }
109
+ this.listenersBound = true;
110
+ // A toolbar's controls are the host's children, so the set changes without
111
+ // this element re-rendering. Without this the tab stops describe whatever
112
+ // was there when it first rendered.
113
+ this.shadowRoot.querySelector("slot")?.addEventListener("slotchange", () => {
114
+ this.update();
115
+ });
116
+ // `slotchange` covers assigned children being swapped, but not a control
117
+ // deep in a wrapper, and not `disabled` toggling — the two ways a host
118
+ // routinely changes which controls are live.
119
+ this.controlObserver ??= new MutationObserver(() => {
120
+ this.update();
121
+ });
122
+ this.controlObserver.observe(this, {
123
+ attributeFilter: ["disabled"],
124
+ attributes: true,
125
+ childList: true,
126
+ subtree: true,
127
+ });
128
+ this.shadowRoot.querySelector('[part="toolbar"]')?.addEventListener("keydown", event => {
129
+ handleRovingKeydown(event, this.controls, {
130
+ orientation: this.orientation,
131
+ });
132
+ });
133
+ // Clicking a control makes it the tab stop, so returning by Tab lands where
134
+ // the reader last was rather than back at the first control.
135
+ this.addEventListener("focusin", event => {
136
+ const index = this.controls.indexOf(event.target);
137
+ if (index >= 0) {
138
+ applyRovingTabindex(this.controls, index);
139
+ }
140
+ });
141
+ }
142
+ update() {
143
+ if (!this.shadowRoot) {
144
+ return;
145
+ }
146
+ const toolbar = this.shadowRoot.querySelector('[part="toolbar"]');
147
+ toolbar?.setAttribute("aria-orientation", this.orientation);
148
+ if (this.label) {
149
+ toolbar?.setAttribute("aria-label", this.label);
150
+ }
151
+ else {
152
+ toolbar?.removeAttribute("aria-label");
153
+ }
154
+ const controls = this.controls;
155
+ // Keep whichever control already holds the tab stop, so a re-render for an
156
+ // unrelated reason does not send focus back to the start.
157
+ const active = controls.findIndex(control => control.tabIndex === 0);
158
+ // Disabled controls are excluded from the rotation, so roving tabindex
159
+ // never touches them — and a button's default tabIndex is 0. Left alone,
160
+ // one re-enabled at runtime (which is the norm: a Clear Selection that
161
+ // wakes when something is selected) becomes a second tab stop the arrow
162
+ // keys cannot reach. Claim every focusable control first, then hand the
163
+ // stop to one.
164
+ for (const element of this.focusableElements()) {
165
+ element.tabIndex = -1;
166
+ }
167
+ applyRovingTabindex(controls, active >= 0 ? active : 0);
168
+ }
169
+ }
170
+ Toolbar.register();
@@ -28,9 +28,25 @@ export declare class FormattedNumber extends FormattedValue {
28
28
  /** ISO 4217 code, required by `Intl` when the style is `currency`. */
29
29
  get currency(): string;
30
30
  set currency(next: string);
31
- /** A sanctioned unit identifier, e.g. `megabyte`, when the style is `unit`. */
31
+ /**
32
+ * A sanctioned unit identifier, e.g. `megabyte`, when the style is `unit`.
33
+ *
34
+ * `Intl` accepts a closed list and throws on anything else, so this is not a
35
+ * free-text label — "widgets" will not work, and the component falls back to
36
+ * a plain number rather than losing the value over it.
37
+ */
32
38
  get unit(): string;
33
39
  set unit(next: string);
40
+ /**
41
+ * How wide to write the unit: `short` (2.5 MB), `narrow` (2.5MB) or `long`
42
+ * (2.5 megabytes).
43
+ *
44
+ * `Intl` defaults to `short`, which is what a table wants. `long` is for
45
+ * prose, where an abbreviation a reader has to expand in their head is worse
46
+ * than the words.
47
+ */
48
+ get unitDisplay(): string;
49
+ set unitDisplay(next: string);
34
50
  protected formatted(): {
35
51
  text: string;
36
52
  } | null;
@@ -2,6 +2,7 @@ import { FormattedValue } from "./formatted-value.js";
2
2
  import { parseNumber } from "../../foundations/format/index.js";
3
3
  const DEFAULT_TAG_NAME = "box-formatted-number";
4
4
  const NUMBER_STYLES = new Set(["decimal", "currency", "percent", "unit"]);
5
+ const UNIT_DISPLAYS = new Set(["short", "narrow", "long"]);
5
6
  /**
6
7
  * A number, rendered in the reader's locale.
7
8
  *
@@ -29,6 +30,7 @@ export class FormattedNumber extends FormattedValue {
29
30
  "maximum-fraction-digits",
30
31
  "minimum-fraction-digits",
31
32
  "unit",
33
+ "unit-display",
32
34
  "value",
33
35
  ];
34
36
  }
@@ -54,13 +56,34 @@ export class FormattedNumber extends FormattedValue {
54
56
  set currency(next) {
55
57
  this.setAttribute("currency", next);
56
58
  }
57
- /** A sanctioned unit identifier, e.g. `megabyte`, when the style is `unit`. */
59
+ /**
60
+ * A sanctioned unit identifier, e.g. `megabyte`, when the style is `unit`.
61
+ *
62
+ * `Intl` accepts a closed list and throws on anything else, so this is not a
63
+ * free-text label — "widgets" will not work, and the component falls back to
64
+ * a plain number rather than losing the value over it.
65
+ */
58
66
  get unit() {
59
67
  return this.getAttribute("unit") ?? "";
60
68
  }
61
69
  set unit(next) {
62
70
  this.setAttribute("unit", next);
63
71
  }
72
+ /**
73
+ * How wide to write the unit: `short` (2.5 MB), `narrow` (2.5MB) or `long`
74
+ * (2.5 megabytes).
75
+ *
76
+ * `Intl` defaults to `short`, which is what a table wants. `long` is for
77
+ * prose, where an abbreviation a reader has to expand in their head is worse
78
+ * than the words.
79
+ */
80
+ get unitDisplay() {
81
+ const raw = this.getAttribute("unit-display");
82
+ return raw && UNIT_DISPLAYS.has(raw) ? raw : "short";
83
+ }
84
+ set unitDisplay(next) {
85
+ this.setAttribute("unit-display", next);
86
+ }
64
87
  formatted() {
65
88
  const numeric = parseNumber(this.value);
66
89
  if (numeric === null) {
@@ -83,6 +106,7 @@ export class FormattedNumber extends FormattedValue {
83
106
  }
84
107
  else {
85
108
  options.unit = this.unit;
109
+ options.unitDisplay = this.unitDisplay;
86
110
  }
87
111
  }
88
112
  const min = parseNumber(this.getAttribute("minimum-fraction-digits"));
@@ -0,0 +1 @@
1
+ export * from "../components/actions/toolbar.js";
@@ -0,0 +1 @@
1
+ export * from "../components/actions/toolbar.js";
package/dist/index.d.ts CHANGED
@@ -19,6 +19,7 @@ export * from "./components/actions/link-button.js";
19
19
  export * from "./components/actions/menu.js";
20
20
  export * from "./components/actions/segmented-control.js";
21
21
  export * from "./components/actions/menu-item.js";
22
+ export * from "./components/actions/toolbar.js";
22
23
  export * from "./components/collections/card.js";
23
24
  export * from "./components/collections/carousel.js";
24
25
  export * from "./components/collections/datalist-item.js";
package/dist/index.js CHANGED
@@ -19,6 +19,7 @@ export * from "./components/actions/link-button.js";
19
19
  export * from "./components/actions/menu.js";
20
20
  export * from "./components/actions/segmented-control.js";
21
21
  export * from "./components/actions/menu-item.js";
22
+ export * from "./components/actions/toolbar.js";
22
23
  // Components: collections
23
24
  export * from "./components/collections/card.js";
24
25
  export * from "./components/collections/carousel.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@unofficialbox/box-open-elements",
3
- "version": "0.13.0",
3
+ "version": "0.15.0",
4
4
  "description": "Box Open Elements: an open, framework-agnostic design system and web component library for Box-style experiences",
5
5
  "license": "MIT",
6
6
  "homepage": "https://unofficialbox.github.io/box-open-elements",