@unofficialbox/box-open-elements 0.11.0 → 0.13.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/feedback/alert.d.ts +2 -0
- package/dist/components/feedback/alert.js +41 -8
- package/dist/components/feedback/indicator.d.ts +34 -0
- package/dist/components/feedback/indicator.js +167 -0
- package/dist/components/feedback/nudge.js +0 -1
- package/dist/components/feedback/toast.d.ts +4 -5
- package/dist/components/feedback/toast.js +19 -46
- package/dist/components/feedback/tone.d.ts +18 -0
- package/dist/components/feedback/tone.js +23 -0
- package/dist/components/forms/calendar.d.ts +25 -0
- package/dist/components/forms/calendar.js +94 -2
- package/dist/components/forms/tile-group.d.ts +58 -0
- package/dist/components/forms/tile-group.js +280 -0
- package/dist/components/output/code-block.d.ts +49 -0
- package/dist/components/output/code-block.js +236 -0
- package/dist/components/output/formatted-date.d.ts +38 -0
- package/dist/components/output/formatted-date.js +90 -0
- package/dist/components/output/formatted-file-size.d.ts +31 -0
- package/dist/components/output/formatted-file-size.js +61 -0
- package/dist/components/output/formatted-number.d.ts +37 -0
- package/dist/components/output/formatted-number.js +106 -0
- package/dist/components/output/formatted-value.d.ts +44 -0
- package/dist/components/output/formatted-value.js +100 -0
- package/dist/components/output/index.d.ts +6 -0
- package/dist/components/output/index.js +9 -0
- package/dist/components/output/relative-time.d.ts +41 -0
- package/dist/components/output/relative-time.js +82 -0
- package/dist/element-maps.d.ts +17 -1
- package/dist/entries/code-block.d.ts +1 -0
- package/dist/entries/code-block.js +1 -0
- package/dist/entries/comment-thread.d.ts +1 -0
- package/dist/entries/comment-thread.js +1 -0
- package/dist/entries/formatted-date.d.ts +1 -0
- package/dist/entries/formatted-date.js +1 -0
- package/dist/entries/formatted-file-size.d.ts +1 -0
- package/dist/entries/formatted-file-size.js +1 -0
- package/dist/entries/formatted-number.d.ts +1 -0
- package/dist/entries/formatted-number.js +1 -0
- package/dist/entries/indicator.d.ts +1 -0
- package/dist/entries/indicator.js +1 -0
- package/dist/entries/relative-time.d.ts +1 -0
- package/dist/entries/relative-time.js +1 -0
- package/dist/entries/tile-group.d.ts +1 -0
- package/dist/entries/tile-group.js +1 -0
- package/dist/foundations/format/format.d.ts +82 -0
- package/dist/foundations/format/format.js +122 -0
- package/dist/foundations/format/index.d.ts +1 -0
- package/dist/foundations/format/index.js +3 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.js +12 -0
- package/dist/patterns/comments/comment-thread.d.ts +79 -0
- package/dist/patterns/comments/comment-thread.js +561 -0
- package/dist/patterns/comments/index.d.ts +2 -0
- package/dist/patterns/comments/index.js +5 -0
- package/dist/patterns/comments/types.d.ts +39 -0
- package/dist/patterns/comments/types.js +8 -0
- package/dist/patterns/preview/annotation-thread.d.ts +67 -48
- package/dist/patterns/preview/annotation-thread.js +96 -440
- package/package.json +1 -1
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
import { BaseElement } from "../../core/index.js";
|
|
2
|
+
import { boeRadius } from "../../foundations/geometry/index.js";
|
|
3
|
+
import { boeFocusVisibleStyles } from "../../foundations/tokens/index.js";
|
|
4
|
+
const DEFAULT_TAG_NAME = "box-code-block";
|
|
5
|
+
const COPY_ICON = `<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6" focusable="false"><rect x="5.5" y="5.5" width="8" height="8" rx="1.5"/><path d="M10.5 3.5v-1a1 1 0 00-1-1h-7a1 1 0 00-1 1v7a1 1 0 001 1h1"/></svg>`;
|
|
6
|
+
const COPIED_ICON = `<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" focusable="false"><path d="M3 8.5l3.2 3.2L13 5"/></svg>`;
|
|
7
|
+
/** How long the button stays in its confirmed state. */
|
|
8
|
+
const COPIED_RESET_MS = 1600;
|
|
9
|
+
const codeStyles = `
|
|
10
|
+
:host {
|
|
11
|
+
display: block;
|
|
12
|
+
color: inherit;
|
|
13
|
+
font: inherit;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
:host([hidden]) {
|
|
17
|
+
display: none;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
[part="block"] {
|
|
21
|
+
position: relative;
|
|
22
|
+
border-radius: ${boeRadius.large};
|
|
23
|
+
background: var(--boe-token-surface-surface-secondary, #f4f4f4);
|
|
24
|
+
color: var(--boe-token-text-text, #222222);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
[part="pre"] {
|
|
28
|
+
margin: 0;
|
|
29
|
+
padding: 0.85rem 3rem 0.85rem 0.95rem;
|
|
30
|
+
overflow-x: auto;
|
|
31
|
+
font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace;
|
|
32
|
+
font-size: 0.82rem;
|
|
33
|
+
line-height: 1.55;
|
|
34
|
+
/* Code is read as written: wrapping a long line silently changes what the
|
|
35
|
+
reader believes the source says, so it scrolls instead. The container is
|
|
36
|
+
what scrolls, never the page. */
|
|
37
|
+
white-space: pre;
|
|
38
|
+
tab-size: 2;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/* Opt-in wrapping for prose-like content — a long URL or a shell command that
|
|
42
|
+
has no meaningful column structure. */
|
|
43
|
+
:host([wrap]) [part="pre"] {
|
|
44
|
+
white-space: pre-wrap;
|
|
45
|
+
overflow-wrap: anywhere;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
:host([inline]) {
|
|
49
|
+
display: inline-block;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
:host([inline]) [part="pre"] {
|
|
53
|
+
padding: 0.1rem 0.4rem;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
:host([inline]) [part="copy"] {
|
|
57
|
+
display: none;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
[part="copy"] {
|
|
61
|
+
appearance: none;
|
|
62
|
+
position: absolute;
|
|
63
|
+
inset-block-start: 0.45rem;
|
|
64
|
+
inset-inline-end: 0.45rem;
|
|
65
|
+
display: inline-flex;
|
|
66
|
+
align-items: center;
|
|
67
|
+
justify-content: center;
|
|
68
|
+
inline-size: 1.75rem;
|
|
69
|
+
block-size: 1.75rem;
|
|
70
|
+
padding: 0.35rem;
|
|
71
|
+
border: 1px solid color-mix(in srgb, var(--boe-token-stroke-stroke, #e8e8e8) 70%, transparent);
|
|
72
|
+
border-radius: ${boeRadius.med};
|
|
73
|
+
background: var(--boe-token-surface-surface, #ffffff);
|
|
74
|
+
color: var(--boe-token-text-text-secondary, #6f6f6f);
|
|
75
|
+
cursor: pointer;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
[part="copy"] svg {
|
|
79
|
+
display: block;
|
|
80
|
+
inline-size: 100%;
|
|
81
|
+
block-size: 100%;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
[part="copy"]:hover:not(:disabled) {
|
|
85
|
+
background: var(--boe-token-surface-surface-hover, #f4f4f4);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
[part="copy"][data-copied="true"] {
|
|
89
|
+
color: var(--boe-token-surface-status-surface-success, #26c281);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
${boeFocusVisibleStyles('[part="copy"]')}
|
|
93
|
+
`;
|
|
94
|
+
/**
|
|
95
|
+
* A block of code, rendered as written.
|
|
96
|
+
*
|
|
97
|
+
* ```html
|
|
98
|
+
* <box-code-block code="npm i @unofficialbox/box-open-elements"></box-code-block>
|
|
99
|
+
* ```
|
|
100
|
+
*
|
|
101
|
+
* The code is set as `textContent`, never as markup — a snippet is the most
|
|
102
|
+
* likely string on any page to contain angle brackets, and rendering it as HTML
|
|
103
|
+
* would both corrupt what the reader sees and hand an injection point to
|
|
104
|
+
* whatever produced the snippet.
|
|
105
|
+
*
|
|
106
|
+
* There is no syntax highlighting, deliberately. Doing it properly means
|
|
107
|
+
* shipping a grammar per language, and doing it improperly means mis-colouring
|
|
108
|
+
* code that a reader is trying to trust. A host that needs highlighting can
|
|
109
|
+
* slot pre-rendered markup into the default slot instead.
|
|
110
|
+
*
|
|
111
|
+
* Long lines scroll rather than wrap: a wrapped line silently changes what the
|
|
112
|
+
* reader believes the source says. `wrap` opts into wrapping where the content
|
|
113
|
+
* has no meaningful column structure.
|
|
114
|
+
*/
|
|
115
|
+
export class CodeBlock extends BaseElement {
|
|
116
|
+
static tagName = DEFAULT_TAG_NAME;
|
|
117
|
+
static get observedAttributes() {
|
|
118
|
+
return ["code", "copy-label", "language"];
|
|
119
|
+
}
|
|
120
|
+
preEl;
|
|
121
|
+
codeEl;
|
|
122
|
+
copyEl;
|
|
123
|
+
slotEl;
|
|
124
|
+
resetTimer = null;
|
|
125
|
+
/** The source to render. */
|
|
126
|
+
get code() {
|
|
127
|
+
return this.getAttribute("code") ?? "";
|
|
128
|
+
}
|
|
129
|
+
set code(value) {
|
|
130
|
+
this.setAttribute("code", value);
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* The language, surfaced as a `data-language` hook and in the code element's
|
|
134
|
+
* class the way `highlight.js` and friends expect. Nothing here reads it.
|
|
135
|
+
*/
|
|
136
|
+
get language() {
|
|
137
|
+
return this.getAttribute("language") ?? "";
|
|
138
|
+
}
|
|
139
|
+
set language(value) {
|
|
140
|
+
this.setAttribute("language", value);
|
|
141
|
+
}
|
|
142
|
+
/** Accessible name for the copy button. */
|
|
143
|
+
get copyLabel() {
|
|
144
|
+
return this.getAttribute("copy-label") ?? "Copy code";
|
|
145
|
+
}
|
|
146
|
+
set copyLabel(value) {
|
|
147
|
+
this.setAttribute("copy-label", value);
|
|
148
|
+
}
|
|
149
|
+
async copy() {
|
|
150
|
+
const text = this.code || this.textContent?.trim() || "";
|
|
151
|
+
if (!text) {
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
try {
|
|
155
|
+
await navigator.clipboard.writeText(text);
|
|
156
|
+
this.markCopied(true);
|
|
157
|
+
}
|
|
158
|
+
catch {
|
|
159
|
+
// Clipboard access is refused in plenty of ordinary situations — an
|
|
160
|
+
// insecure origin, a denied permission, a browser that gates it behind a
|
|
161
|
+
// user gesture it did not see. The button reports failure by simply not
|
|
162
|
+
// confirming, rather than throwing into the host's console.
|
|
163
|
+
this.markCopied(false);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
markCopied(copied) {
|
|
167
|
+
this.copyEl.dataset.copied = copied ? "true" : "false";
|
|
168
|
+
this.copyEl.innerHTML = copied ? COPIED_ICON : COPY_ICON;
|
|
169
|
+
this.dispatchEvent(new CustomEvent("code-copied", { bubbles: true, composed: true, detail: { copied } }));
|
|
170
|
+
if (this.resetTimer) {
|
|
171
|
+
clearTimeout(this.resetTimer);
|
|
172
|
+
this.resetTimer = null;
|
|
173
|
+
}
|
|
174
|
+
if (copied) {
|
|
175
|
+
this.resetTimer = setTimeout(() => {
|
|
176
|
+
this.copyEl.dataset.copied = "false";
|
|
177
|
+
this.copyEl.innerHTML = COPY_ICON;
|
|
178
|
+
this.resetTimer = null;
|
|
179
|
+
}, COPIED_RESET_MS);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
disconnectedCallback() {
|
|
183
|
+
if (this.resetTimer) {
|
|
184
|
+
clearTimeout(this.resetTimer);
|
|
185
|
+
this.resetTimer = null;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
renderTemplate() {
|
|
189
|
+
if (!this.shadowRoot) {
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
this.shadowRoot.innerHTML = `
|
|
193
|
+
<style>${codeStyles}</style>
|
|
194
|
+
<div part="block">
|
|
195
|
+
<pre part="pre"><code part="code"><slot></slot></code></pre>
|
|
196
|
+
<button type="button" part="copy" data-copied="false"></button>
|
|
197
|
+
</div>
|
|
198
|
+
`;
|
|
199
|
+
this.preEl = this.shadowRoot.querySelector('[part="pre"]');
|
|
200
|
+
this.codeEl = this.shadowRoot.querySelector('[part="code"]');
|
|
201
|
+
this.copyEl = this.shadowRoot.querySelector('[part="copy"]');
|
|
202
|
+
this.slotEl = this.shadowRoot.querySelector("slot");
|
|
203
|
+
this.copyEl.innerHTML = COPY_ICON;
|
|
204
|
+
}
|
|
205
|
+
setupListeners() {
|
|
206
|
+
this.copyEl.addEventListener("click", () => {
|
|
207
|
+
void this.copy();
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
update() {
|
|
211
|
+
if (!this.preEl) {
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
const code = this.code;
|
|
215
|
+
if (code) {
|
|
216
|
+
// textContent, never innerHTML: a snippet is the string most likely on
|
|
217
|
+
// any page to contain angle brackets.
|
|
218
|
+
this.slotEl.textContent = code;
|
|
219
|
+
}
|
|
220
|
+
else {
|
|
221
|
+
this.slotEl.textContent = "";
|
|
222
|
+
}
|
|
223
|
+
const language = this.language;
|
|
224
|
+
if (language) {
|
|
225
|
+
this.codeEl.setAttribute("data-language", language);
|
|
226
|
+
this.codeEl.setAttribute("class", `language-${language}`);
|
|
227
|
+
}
|
|
228
|
+
else {
|
|
229
|
+
this.codeEl.removeAttribute("data-language");
|
|
230
|
+
this.codeEl.removeAttribute("class");
|
|
231
|
+
}
|
|
232
|
+
this.copyEl.setAttribute("aria-label", this.copyLabel);
|
|
233
|
+
this.copyEl.title = this.copyLabel;
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
CodeBlock.register();
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { FormattedValue } from "./formatted-value.js";
|
|
2
|
+
/**
|
|
3
|
+
* A date or time, rendered in the reader's locale.
|
|
4
|
+
*
|
|
5
|
+
* ```html
|
|
6
|
+
* <box-formatted-date value="2026-08-25T14:30:00Z" date-style="medium" time-style="short"></box-formatted-date>
|
|
7
|
+
* ```
|
|
8
|
+
*
|
|
9
|
+
* Renders into `<time datetime="…">`, so the exact instant stays available to
|
|
10
|
+
* anything reading the document rather than looking at it — a crawler, a
|
|
11
|
+
* calendar extension, a screen reader offering "copy date". The visible text is
|
|
12
|
+
* the reader's format; the attribute is always ISO 8601.
|
|
13
|
+
*
|
|
14
|
+
* With neither `date-style` nor `time-style` set, the date alone is shown at
|
|
15
|
+
* medium width. That is the common case in a file list, and defaulting to a
|
|
16
|
+
* timestamp would put a time nobody asked for beside every filename.
|
|
17
|
+
*/
|
|
18
|
+
export declare class FormattedDate extends FormattedValue {
|
|
19
|
+
static readonly tagName: string;
|
|
20
|
+
static get observedAttributes(): string[];
|
|
21
|
+
protected usesTimeElement(): boolean;
|
|
22
|
+
/** The instant, as ISO 8601 or epoch milliseconds. */
|
|
23
|
+
get value(): string;
|
|
24
|
+
set value(next: string);
|
|
25
|
+
/** Date width, mirroring `Intl.DateTimeFormat`'s `dateStyle`. */
|
|
26
|
+
get dateStyle(): string;
|
|
27
|
+
set dateStyle(next: string);
|
|
28
|
+
/** Time width. Absent renders no time at all. */
|
|
29
|
+
get timeStyle(): string;
|
|
30
|
+
set timeStyle(next: string);
|
|
31
|
+
/** IANA zone, e.g. `America/New_York`. Absent uses the host's. */
|
|
32
|
+
get timeZone(): string;
|
|
33
|
+
set timeZone(next: string);
|
|
34
|
+
protected formatted(): {
|
|
35
|
+
machine: string;
|
|
36
|
+
text: string;
|
|
37
|
+
} | null;
|
|
38
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { FormattedValue } from "./formatted-value.js";
|
|
2
|
+
import { parseInstant, resolveDateStyle } from "../../foundations/format/index.js";
|
|
3
|
+
const DEFAULT_TAG_NAME = "box-formatted-date";
|
|
4
|
+
/**
|
|
5
|
+
* A date or time, rendered in the reader's locale.
|
|
6
|
+
*
|
|
7
|
+
* ```html
|
|
8
|
+
* <box-formatted-date value="2026-08-25T14:30:00Z" date-style="medium" time-style="short"></box-formatted-date>
|
|
9
|
+
* ```
|
|
10
|
+
*
|
|
11
|
+
* Renders into `<time datetime="…">`, so the exact instant stays available to
|
|
12
|
+
* anything reading the document rather than looking at it — a crawler, a
|
|
13
|
+
* calendar extension, a screen reader offering "copy date". The visible text is
|
|
14
|
+
* the reader's format; the attribute is always ISO 8601.
|
|
15
|
+
*
|
|
16
|
+
* With neither `date-style` nor `time-style` set, the date alone is shown at
|
|
17
|
+
* medium width. That is the common case in a file list, and defaulting to a
|
|
18
|
+
* timestamp would put a time nobody asked for beside every filename.
|
|
19
|
+
*/
|
|
20
|
+
export class FormattedDate extends FormattedValue {
|
|
21
|
+
static tagName = DEFAULT_TAG_NAME;
|
|
22
|
+
static get observedAttributes() {
|
|
23
|
+
return ["date-style", "locale", "time-style", "time-zone", "value"];
|
|
24
|
+
}
|
|
25
|
+
usesTimeElement() {
|
|
26
|
+
return true;
|
|
27
|
+
}
|
|
28
|
+
/** The instant, as ISO 8601 or epoch milliseconds. */
|
|
29
|
+
get value() {
|
|
30
|
+
return this.getAttribute("value") ?? "";
|
|
31
|
+
}
|
|
32
|
+
set value(next) {
|
|
33
|
+
this.setAttribute("value", next);
|
|
34
|
+
}
|
|
35
|
+
/** Date width, mirroring `Intl.DateTimeFormat`'s `dateStyle`. */
|
|
36
|
+
get dateStyle() {
|
|
37
|
+
return this.getAttribute("date-style") ?? "";
|
|
38
|
+
}
|
|
39
|
+
set dateStyle(next) {
|
|
40
|
+
this.setAttribute("date-style", next);
|
|
41
|
+
}
|
|
42
|
+
/** Time width. Absent renders no time at all. */
|
|
43
|
+
get timeStyle() {
|
|
44
|
+
return this.getAttribute("time-style") ?? "";
|
|
45
|
+
}
|
|
46
|
+
set timeStyle(next) {
|
|
47
|
+
this.setAttribute("time-style", next);
|
|
48
|
+
}
|
|
49
|
+
/** IANA zone, e.g. `America/New_York`. Absent uses the host's. */
|
|
50
|
+
get timeZone() {
|
|
51
|
+
return this.getAttribute("time-zone") ?? "";
|
|
52
|
+
}
|
|
53
|
+
set timeZone(next) {
|
|
54
|
+
this.setAttribute("time-zone", next);
|
|
55
|
+
}
|
|
56
|
+
formatted() {
|
|
57
|
+
const instant = parseInstant(this.value);
|
|
58
|
+
if (!instant) {
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
const dateStyle = resolveDateStyle(this.dateStyle);
|
|
62
|
+
const timeStyle = resolveDateStyle(this.timeStyle);
|
|
63
|
+
const options = {};
|
|
64
|
+
// Only one of the two being absent is normal; both absent means the caller
|
|
65
|
+
// wants a plain date, so medium is supplied rather than leaving Intl to
|
|
66
|
+
// fall back to a bare numeric date.
|
|
67
|
+
if (dateStyle || !timeStyle) {
|
|
68
|
+
options.dateStyle = dateStyle ?? "medium";
|
|
69
|
+
}
|
|
70
|
+
if (timeStyle) {
|
|
71
|
+
options.timeStyle = timeStyle;
|
|
72
|
+
}
|
|
73
|
+
if (this.timeZone) {
|
|
74
|
+
options.timeZone = this.timeZone;
|
|
75
|
+
}
|
|
76
|
+
try {
|
|
77
|
+
return {
|
|
78
|
+
machine: instant.toISOString(),
|
|
79
|
+
text: new Intl.DateTimeFormat(this.locale, options).format(instant),
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
catch {
|
|
83
|
+
// An unknown locale or time zone throws a RangeError. The instant is
|
|
84
|
+
// still good, so fall back to ISO rather than hiding a real value over a
|
|
85
|
+
// bad option.
|
|
86
|
+
return { machine: instant.toISOString(), text: instant.toISOString() };
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
FormattedDate.register();
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { FormattedValue } from "./formatted-value.js";
|
|
2
|
+
/**
|
|
3
|
+
* A byte count, rendered as a readable size.
|
|
4
|
+
*
|
|
5
|
+
* ```html
|
|
6
|
+
* <box-formatted-file-size value="2517630"></box-formatted-file-size>
|
|
7
|
+
* ```
|
|
8
|
+
*
|
|
9
|
+
* The magnitude goes through `Intl.NumberFormat`, so a German reader sees
|
|
10
|
+
* "2,52 MB" rather than "2.52 MB". That is the reason this exists as a
|
|
11
|
+
* component rather than a `toFixed` call in each consumer: the decimal
|
|
12
|
+
* separator is not the same everywhere, and a file size is one of the most
|
|
13
|
+
* frequently rendered values in a content platform.
|
|
14
|
+
*
|
|
15
|
+
* Defaults to decimal units (powers of 1000, `kB`/`MB`), matching what the Box
|
|
16
|
+
* product reports. `units="binary"` switches to powers of 1024 with IEC names
|
|
17
|
+
* (`KiB`/`MiB`) for hosts that need to agree with a filesystem instead.
|
|
18
|
+
*/
|
|
19
|
+
export declare class FormattedFileSize extends FormattedValue {
|
|
20
|
+
static readonly tagName: string;
|
|
21
|
+
static get observedAttributes(): string[];
|
|
22
|
+
/** The size in bytes. */
|
|
23
|
+
get value(): string;
|
|
24
|
+
set value(next: string);
|
|
25
|
+
/** `decimal` (default) or `binary`. */
|
|
26
|
+
get units(): string;
|
|
27
|
+
set units(next: string);
|
|
28
|
+
protected formatted(): {
|
|
29
|
+
text: string;
|
|
30
|
+
} | null;
|
|
31
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { FormattedValue } from "./formatted-value.js";
|
|
2
|
+
import { parseNumber, resolveByteUnits, resolveFileSizeParts, } from "../../foundations/format/index.js";
|
|
3
|
+
const DEFAULT_TAG_NAME = "box-formatted-file-size";
|
|
4
|
+
/**
|
|
5
|
+
* A byte count, rendered as a readable size.
|
|
6
|
+
*
|
|
7
|
+
* ```html
|
|
8
|
+
* <box-formatted-file-size value="2517630"></box-formatted-file-size>
|
|
9
|
+
* ```
|
|
10
|
+
*
|
|
11
|
+
* The magnitude goes through `Intl.NumberFormat`, so a German reader sees
|
|
12
|
+
* "2,52 MB" rather than "2.52 MB". That is the reason this exists as a
|
|
13
|
+
* component rather than a `toFixed` call in each consumer: the decimal
|
|
14
|
+
* separator is not the same everywhere, and a file size is one of the most
|
|
15
|
+
* frequently rendered values in a content platform.
|
|
16
|
+
*
|
|
17
|
+
* Defaults to decimal units (powers of 1000, `kB`/`MB`), matching what the Box
|
|
18
|
+
* product reports. `units="binary"` switches to powers of 1024 with IEC names
|
|
19
|
+
* (`KiB`/`MiB`) for hosts that need to agree with a filesystem instead.
|
|
20
|
+
*/
|
|
21
|
+
export class FormattedFileSize extends FormattedValue {
|
|
22
|
+
static tagName = DEFAULT_TAG_NAME;
|
|
23
|
+
static get observedAttributes() {
|
|
24
|
+
return ["locale", "maximum-fraction-digits", "units", "value"];
|
|
25
|
+
}
|
|
26
|
+
/** The size in bytes. */
|
|
27
|
+
get value() {
|
|
28
|
+
return this.getAttribute("value") ?? "";
|
|
29
|
+
}
|
|
30
|
+
set value(next) {
|
|
31
|
+
this.setAttribute("value", next);
|
|
32
|
+
}
|
|
33
|
+
/** `decimal` (default) or `binary`. */
|
|
34
|
+
get units() {
|
|
35
|
+
return resolveByteUnits(this.getAttribute("units"));
|
|
36
|
+
}
|
|
37
|
+
set units(next) {
|
|
38
|
+
this.setAttribute("units", next);
|
|
39
|
+
}
|
|
40
|
+
formatted() {
|
|
41
|
+
const bytes = parseNumber(this.value);
|
|
42
|
+
if (bytes === null) {
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
const { suffix, value } = resolveFileSizeParts(bytes, resolveByteUnits(this.units));
|
|
46
|
+
const requested = parseNumber(this.getAttribute("maximum-fraction-digits"));
|
|
47
|
+
// Whole bytes have no fractional part worth showing; above that, one
|
|
48
|
+
// decimal is the convention every file browser uses — "2.5 MB", not
|
|
49
|
+
// "2.517 MB", which is precision no reader asked for.
|
|
50
|
+
const maximumFractionDigits = requested ?? (suffix === "B" ? 0 : 1);
|
|
51
|
+
try {
|
|
52
|
+
return {
|
|
53
|
+
text: `${new Intl.NumberFormat(this.locale, { maximumFractionDigits }).format(value)} ${suffix}`,
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
return { text: `${String(value)} ${suffix}` };
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
FormattedFileSize.register();
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { FormattedValue } from "./formatted-value.js";
|
|
2
|
+
/**
|
|
3
|
+
* A number, rendered in the reader's locale.
|
|
4
|
+
*
|
|
5
|
+
* ```html
|
|
6
|
+
* <box-formatted-number value="1234.5" format-style="currency" currency="USD"></box-formatted-number>
|
|
7
|
+
* ```
|
|
8
|
+
*
|
|
9
|
+
* The attribute is `format-style` rather than `style`, because `style` is a
|
|
10
|
+
* global HTML attribute: setting it would put CSS on the host and be silently
|
|
11
|
+
* ignored as a formatting instruction. Naming it after the `Intl` option would
|
|
12
|
+
* be tidier right up until the first host wrote `style="currency"` and watched
|
|
13
|
+
* nothing happen.
|
|
14
|
+
*
|
|
15
|
+
* `percent` follows `Intl`, which multiplies by 100: pass `0.42` to render
|
|
16
|
+
* "42%". That surprises people, so it is worth stating rather than quietly
|
|
17
|
+
* doubling the convention.
|
|
18
|
+
*/
|
|
19
|
+
export declare class FormattedNumber extends FormattedValue {
|
|
20
|
+
static readonly tagName: string;
|
|
21
|
+
static get observedAttributes(): string[];
|
|
22
|
+
/** The number to render. */
|
|
23
|
+
get value(): string;
|
|
24
|
+
set value(next: string);
|
|
25
|
+
/** `decimal` (default), `currency`, `percent` or `unit`. */
|
|
26
|
+
get formatStyle(): string;
|
|
27
|
+
set formatStyle(next: string);
|
|
28
|
+
/** ISO 4217 code, required by `Intl` when the style is `currency`. */
|
|
29
|
+
get currency(): string;
|
|
30
|
+
set currency(next: string);
|
|
31
|
+
/** A sanctioned unit identifier, e.g. `megabyte`, when the style is `unit`. */
|
|
32
|
+
get unit(): string;
|
|
33
|
+
set unit(next: string);
|
|
34
|
+
protected formatted(): {
|
|
35
|
+
text: string;
|
|
36
|
+
} | null;
|
|
37
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { FormattedValue } from "./formatted-value.js";
|
|
2
|
+
import { parseNumber } from "../../foundations/format/index.js";
|
|
3
|
+
const DEFAULT_TAG_NAME = "box-formatted-number";
|
|
4
|
+
const NUMBER_STYLES = new Set(["decimal", "currency", "percent", "unit"]);
|
|
5
|
+
/**
|
|
6
|
+
* A number, rendered in the reader's locale.
|
|
7
|
+
*
|
|
8
|
+
* ```html
|
|
9
|
+
* <box-formatted-number value="1234.5" format-style="currency" currency="USD"></box-formatted-number>
|
|
10
|
+
* ```
|
|
11
|
+
*
|
|
12
|
+
* The attribute is `format-style` rather than `style`, because `style` is a
|
|
13
|
+
* global HTML attribute: setting it would put CSS on the host and be silently
|
|
14
|
+
* ignored as a formatting instruction. Naming it after the `Intl` option would
|
|
15
|
+
* be tidier right up until the first host wrote `style="currency"` and watched
|
|
16
|
+
* nothing happen.
|
|
17
|
+
*
|
|
18
|
+
* `percent` follows `Intl`, which multiplies by 100: pass `0.42` to render
|
|
19
|
+
* "42%". That surprises people, so it is worth stating rather than quietly
|
|
20
|
+
* doubling the convention.
|
|
21
|
+
*/
|
|
22
|
+
export class FormattedNumber extends FormattedValue {
|
|
23
|
+
static tagName = DEFAULT_TAG_NAME;
|
|
24
|
+
static get observedAttributes() {
|
|
25
|
+
return [
|
|
26
|
+
"currency",
|
|
27
|
+
"format-style",
|
|
28
|
+
"locale",
|
|
29
|
+
"maximum-fraction-digits",
|
|
30
|
+
"minimum-fraction-digits",
|
|
31
|
+
"unit",
|
|
32
|
+
"value",
|
|
33
|
+
];
|
|
34
|
+
}
|
|
35
|
+
/** The number to render. */
|
|
36
|
+
get value() {
|
|
37
|
+
return this.getAttribute("value") ?? "";
|
|
38
|
+
}
|
|
39
|
+
set value(next) {
|
|
40
|
+
this.setAttribute("value", next);
|
|
41
|
+
}
|
|
42
|
+
/** `decimal` (default), `currency`, `percent` or `unit`. */
|
|
43
|
+
get formatStyle() {
|
|
44
|
+
const raw = this.getAttribute("format-style");
|
|
45
|
+
return raw && NUMBER_STYLES.has(raw) ? raw : "decimal";
|
|
46
|
+
}
|
|
47
|
+
set formatStyle(next) {
|
|
48
|
+
this.setAttribute("format-style", next);
|
|
49
|
+
}
|
|
50
|
+
/** ISO 4217 code, required by `Intl` when the style is `currency`. */
|
|
51
|
+
get currency() {
|
|
52
|
+
return this.getAttribute("currency") ?? "";
|
|
53
|
+
}
|
|
54
|
+
set currency(next) {
|
|
55
|
+
this.setAttribute("currency", next);
|
|
56
|
+
}
|
|
57
|
+
/** A sanctioned unit identifier, e.g. `megabyte`, when the style is `unit`. */
|
|
58
|
+
get unit() {
|
|
59
|
+
return this.getAttribute("unit") ?? "";
|
|
60
|
+
}
|
|
61
|
+
set unit(next) {
|
|
62
|
+
this.setAttribute("unit", next);
|
|
63
|
+
}
|
|
64
|
+
formatted() {
|
|
65
|
+
const numeric = parseNumber(this.value);
|
|
66
|
+
if (numeric === null) {
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
const options = { style: this.formatStyle };
|
|
70
|
+
if (this.formatStyle === "currency") {
|
|
71
|
+
if (!this.currency) {
|
|
72
|
+
// Intl throws without one. Falling back to a plain decimal keeps a real
|
|
73
|
+
// number on screen instead of hiding it over a missing attribute.
|
|
74
|
+
options.style = "decimal";
|
|
75
|
+
}
|
|
76
|
+
else {
|
|
77
|
+
options.currency = this.currency;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
if (this.formatStyle === "unit") {
|
|
81
|
+
if (!this.unit) {
|
|
82
|
+
options.style = "decimal";
|
|
83
|
+
}
|
|
84
|
+
else {
|
|
85
|
+
options.unit = this.unit;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
const min = parseNumber(this.getAttribute("minimum-fraction-digits"));
|
|
89
|
+
const max = parseNumber(this.getAttribute("maximum-fraction-digits"));
|
|
90
|
+
if (min !== null) {
|
|
91
|
+
options.minimumFractionDigits = min;
|
|
92
|
+
}
|
|
93
|
+
if (max !== null) {
|
|
94
|
+
options.maximumFractionDigits = max;
|
|
95
|
+
}
|
|
96
|
+
try {
|
|
97
|
+
return { text: new Intl.NumberFormat(this.locale, options).format(numeric) };
|
|
98
|
+
}
|
|
99
|
+
catch {
|
|
100
|
+
// A bad currency code, unit, or digit range throws. The number is real,
|
|
101
|
+
// so render it plainly rather than losing it.
|
|
102
|
+
return { text: String(numeric) };
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
FormattedNumber.register();
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { BaseElement } from "../../core/index.js";
|
|
2
|
+
/**
|
|
3
|
+
* Shared behaviour for the read-only `box-formatted-*` renderers.
|
|
4
|
+
*
|
|
5
|
+
* These components render a typed value and nothing else: no interaction, no
|
|
6
|
+
* state, no events. What they do share is the awkward part — resolving a
|
|
7
|
+
* locale, deciding what a broken value looks like, and keeping a
|
|
8
|
+
* machine-readable form beside the human one.
|
|
9
|
+
*
|
|
10
|
+
* **A value that cannot be formatted hides the element.** Not `Invalid Date`,
|
|
11
|
+
* not `NaN`, not the raw string echoed back. A malformed value is a host bug,
|
|
12
|
+
* and rendering its wreckage puts the bug in front of a reader who can do
|
|
13
|
+
* nothing about it. The host is left with an empty inline box, which is what a
|
|
14
|
+
* missing value should look like anyway.
|
|
15
|
+
*/
|
|
16
|
+
export declare abstract class FormattedValue extends BaseElement {
|
|
17
|
+
protected valueEl: HTMLElement;
|
|
18
|
+
/**
|
|
19
|
+
* The formatted text, plus an optional machine-readable form for the
|
|
20
|
+
* `datetime` attribute. `null` means the value could not be formatted.
|
|
21
|
+
*/
|
|
22
|
+
protected abstract formatted(): {
|
|
23
|
+
machine?: string;
|
|
24
|
+
text: string;
|
|
25
|
+
} | null;
|
|
26
|
+
/**
|
|
27
|
+
* Whether to render into `<time>` rather than `<span>`.
|
|
28
|
+
*
|
|
29
|
+
* Only the instant-valued renderers should; `<time>` on a file size would be
|
|
30
|
+
* a lie to anything reading the document semantically.
|
|
31
|
+
*/
|
|
32
|
+
protected usesTimeElement(): boolean;
|
|
33
|
+
/** Extra styles a subclass wants appended. */
|
|
34
|
+
protected valueStyles(): string;
|
|
35
|
+
/**
|
|
36
|
+
* The locale to format in. Absent means the host's own — deliberately
|
|
37
|
+
* `undefined` rather than a hardcoded tag.
|
|
38
|
+
*/
|
|
39
|
+
get locale(): string | undefined;
|
|
40
|
+
set locale(value: string | undefined);
|
|
41
|
+
protected renderTemplate(): void;
|
|
42
|
+
protected setupListeners(): void;
|
|
43
|
+
protected update(): void;
|
|
44
|
+
}
|