@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.
- package/dist/components/actions/toolbar.d.ts +39 -0
- package/dist/components/actions/toolbar.js +170 -0
- package/dist/components/output/formatted-number.d.ts +17 -1
- package/dist/components/output/formatted-number.js +25 -1
- package/dist/entries/toolbar.d.ts +1 -0
- package/dist/entries/toolbar.js +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/package.json +1 -1
|
@@ -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
|
-
/**
|
|
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
|
-
/**
|
|
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.
|
|
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",
|