@unofficialbox/box-open-elements 0.15.0 → 0.16.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/output/formatted-duration.d.ts +55 -0
- package/dist/components/output/formatted-duration.js +131 -0
- package/dist/entries/formatted-duration.d.ts +1 -0
- package/dist/entries/formatted-duration.js +1 -0
- package/dist/foundations/format/format.d.ts +34 -0
- package/dist/foundations/format/format.js +99 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/package.json +1 -1
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { FormattedValue } from "./formatted-value.js";
|
|
2
|
+
/**
|
|
3
|
+
* How long something takes or lasts, in the reader's locale.
|
|
4
|
+
*
|
|
5
|
+
* ```html
|
|
6
|
+
* <box-formatted-duration value="5400"></box-formatted-duration>
|
|
7
|
+
* ```
|
|
8
|
+
*
|
|
9
|
+
* `value` is a count of seconds or an ISO 8601 duration (`PT1H30M`), because
|
|
10
|
+
* hosts have both: an API field is usually a number, while `<time datetime>`
|
|
11
|
+
* wants the ISO form. Whichever comes in, the ISO form goes back out in
|
|
12
|
+
* `datetime`, so the exact quantity survives for anything reading the document
|
|
13
|
+
* rather than looking at it.
|
|
14
|
+
*
|
|
15
|
+
* Not to be confused with `box-relative-time`, which renders *when* something
|
|
16
|
+
* happened relative to now ("3 days ago"). This renders *how long* — a
|
|
17
|
+
* retention period, an SLA, a processing time — and carries no direction.
|
|
18
|
+
*
|
|
19
|
+
* `Intl.DurationFormat` does the rendering where the browser has it. Where it
|
|
20
|
+
* does not, the same output is composed from `Intl.NumberFormat`'s unit style
|
|
21
|
+
* and `Intl.ListFormat`, both long supported; the two paths were compared
|
|
22
|
+
* across styles and locales and agree.
|
|
23
|
+
*/
|
|
24
|
+
export declare class FormattedDuration extends FormattedValue {
|
|
25
|
+
static readonly tagName: string;
|
|
26
|
+
static get observedAttributes(): string[];
|
|
27
|
+
/** Seconds, or an ISO 8601 duration such as `PT1H30M`. */
|
|
28
|
+
get value(): string;
|
|
29
|
+
set value(next: string);
|
|
30
|
+
/**
|
|
31
|
+
* `short` (default, `1 hr, 30 min`), `long` (`1 hour, 30 minutes`) or
|
|
32
|
+
* `narrow` (`1h 30m`).
|
|
33
|
+
*
|
|
34
|
+
* Named `format-style` rather than `style` because `style` is a global HTML
|
|
35
|
+
* attribute: naming it after the `Intl` option would put CSS on the host and
|
|
36
|
+
* be silently ignored as a formatting instruction.
|
|
37
|
+
*/
|
|
38
|
+
get formatStyle(): string;
|
|
39
|
+
set formatStyle(next: string);
|
|
40
|
+
/**
|
|
41
|
+
* How many units to show, default 2.
|
|
42
|
+
*
|
|
43
|
+
* A duration carries more precision than a reader wants: 1 day, 3 hours, 12
|
|
44
|
+
* minutes and 8 seconds is a number to scan past, not to read. Two units is
|
|
45
|
+
* enough to convey magnitude.
|
|
46
|
+
*/
|
|
47
|
+
get maxUnits(): number;
|
|
48
|
+
set maxUnits(next: number);
|
|
49
|
+
protected usesTimeElement(): boolean;
|
|
50
|
+
protected formatted(): {
|
|
51
|
+
machine?: string;
|
|
52
|
+
text: string;
|
|
53
|
+
} | null;
|
|
54
|
+
private render;
|
|
55
|
+
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { FormattedValue } from "./formatted-value.js";
|
|
2
|
+
import { parseDuration, resolveDurationParts, toIsoDuration, } from "../../foundations/format/index.js";
|
|
3
|
+
const DEFAULT_TAG_NAME = "box-formatted-duration";
|
|
4
|
+
/** Whether the split came out as a plain zero, which `Intl` renders as nothing. */
|
|
5
|
+
const isZero = (parts) => Object.values(parts).every(amount => amount === 0);
|
|
6
|
+
const DURATION_STYLES = new Set(["long", "short", "narrow"]);
|
|
7
|
+
/** `Intl` names duration fields in the plural; the parts are singular. */
|
|
8
|
+
const INTL_FIELD = {
|
|
9
|
+
day: "days",
|
|
10
|
+
hour: "hours",
|
|
11
|
+
minute: "minutes",
|
|
12
|
+
second: "seconds",
|
|
13
|
+
};
|
|
14
|
+
/** The unit identifiers `Intl.NumberFormat` uses, for the fallback path. */
|
|
15
|
+
const NUMBER_UNIT = {
|
|
16
|
+
day: "day",
|
|
17
|
+
hour: "hour",
|
|
18
|
+
minute: "minute",
|
|
19
|
+
second: "second",
|
|
20
|
+
};
|
|
21
|
+
/**
|
|
22
|
+
* How long something takes or lasts, in the reader's locale.
|
|
23
|
+
*
|
|
24
|
+
* ```html
|
|
25
|
+
* <box-formatted-duration value="5400"></box-formatted-duration>
|
|
26
|
+
* ```
|
|
27
|
+
*
|
|
28
|
+
* `value` is a count of seconds or an ISO 8601 duration (`PT1H30M`), because
|
|
29
|
+
* hosts have both: an API field is usually a number, while `<time datetime>`
|
|
30
|
+
* wants the ISO form. Whichever comes in, the ISO form goes back out in
|
|
31
|
+
* `datetime`, so the exact quantity survives for anything reading the document
|
|
32
|
+
* rather than looking at it.
|
|
33
|
+
*
|
|
34
|
+
* Not to be confused with `box-relative-time`, which renders *when* something
|
|
35
|
+
* happened relative to now ("3 days ago"). This renders *how long* — a
|
|
36
|
+
* retention period, an SLA, a processing time — and carries no direction.
|
|
37
|
+
*
|
|
38
|
+
* `Intl.DurationFormat` does the rendering where the browser has it. Where it
|
|
39
|
+
* does not, the same output is composed from `Intl.NumberFormat`'s unit style
|
|
40
|
+
* and `Intl.ListFormat`, both long supported; the two paths were compared
|
|
41
|
+
* across styles and locales and agree.
|
|
42
|
+
*/
|
|
43
|
+
export class FormattedDuration extends FormattedValue {
|
|
44
|
+
static tagName = DEFAULT_TAG_NAME;
|
|
45
|
+
static get observedAttributes() {
|
|
46
|
+
return ["format-style", "locale", "max-units", "value"];
|
|
47
|
+
}
|
|
48
|
+
/** Seconds, or an ISO 8601 duration such as `PT1H30M`. */
|
|
49
|
+
get value() {
|
|
50
|
+
return this.getAttribute("value") ?? "";
|
|
51
|
+
}
|
|
52
|
+
set value(next) {
|
|
53
|
+
this.setAttribute("value", next);
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* `short` (default, `1 hr, 30 min`), `long` (`1 hour, 30 minutes`) or
|
|
57
|
+
* `narrow` (`1h 30m`).
|
|
58
|
+
*
|
|
59
|
+
* Named `format-style` rather than `style` because `style` is a global HTML
|
|
60
|
+
* attribute: naming it after the `Intl` option would put CSS on the host and
|
|
61
|
+
* be silently ignored as a formatting instruction.
|
|
62
|
+
*/
|
|
63
|
+
get formatStyle() {
|
|
64
|
+
const raw = this.getAttribute("format-style");
|
|
65
|
+
return raw && DURATION_STYLES.has(raw) ? raw : "short";
|
|
66
|
+
}
|
|
67
|
+
set formatStyle(next) {
|
|
68
|
+
this.setAttribute("format-style", next);
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* How many units to show, default 2.
|
|
72
|
+
*
|
|
73
|
+
* A duration carries more precision than a reader wants: 1 day, 3 hours, 12
|
|
74
|
+
* minutes and 8 seconds is a number to scan past, not to read. Two units is
|
|
75
|
+
* enough to convey magnitude.
|
|
76
|
+
*/
|
|
77
|
+
get maxUnits() {
|
|
78
|
+
const raw = Number(this.getAttribute("max-units"));
|
|
79
|
+
return Number.isFinite(raw) && raw >= 1 ? Math.trunc(raw) : 2;
|
|
80
|
+
}
|
|
81
|
+
set maxUnits(next) {
|
|
82
|
+
this.setAttribute("max-units", String(next));
|
|
83
|
+
}
|
|
84
|
+
usesTimeElement() {
|
|
85
|
+
// A duration is a valid `datetime` value, so `<time>` is honest here.
|
|
86
|
+
return true;
|
|
87
|
+
}
|
|
88
|
+
formatted() {
|
|
89
|
+
const seconds = parseDuration(this.value);
|
|
90
|
+
if (seconds === null) {
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
const parts = resolveDurationParts(seconds, this.maxUnits);
|
|
94
|
+
const machine = toIsoDuration(parts);
|
|
95
|
+
const style = this.formatStyle;
|
|
96
|
+
try {
|
|
97
|
+
return { machine, text: this.render(parts, style) };
|
|
98
|
+
}
|
|
99
|
+
catch {
|
|
100
|
+
// A locale Intl rejects, or a runtime missing both paths. The duration is
|
|
101
|
+
// real, so fall back to the machine form rather than losing it.
|
|
102
|
+
return { machine, text: machine };
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
render(parts, style) {
|
|
106
|
+
const durationFormat = Intl.DurationFormat;
|
|
107
|
+
if (durationFormat) {
|
|
108
|
+
const input = Object.fromEntries(Object.entries(parts).map(([unit, amount]) => [INTL_FIELD[unit], amount]));
|
|
109
|
+
// A zero duration otherwise formats to the empty string, leaving a visible
|
|
110
|
+
// element with no text in it — zero is a real answer. Scoped to that case:
|
|
111
|
+
// asking for it always appends "0 sec" to every other duration in
|
|
112
|
+
// browsers that honour the option, which Node's Intl quietly does not.
|
|
113
|
+
const options = isZero(parts)
|
|
114
|
+
? { secondsDisplay: "always", style }
|
|
115
|
+
: { style };
|
|
116
|
+
return new durationFormat(this.locale, options).format(input);
|
|
117
|
+
}
|
|
118
|
+
const unitDisplay = style === "long" ? "long" : style === "narrow" ? "narrow" : "short";
|
|
119
|
+
const pieces = Object.entries(parts).map(([unit, amount]) => new Intl.NumberFormat(this.locale, {
|
|
120
|
+
style: "unit",
|
|
121
|
+
unit: NUMBER_UNIT[unit],
|
|
122
|
+
unitDisplay,
|
|
123
|
+
}).format(amount));
|
|
124
|
+
// `narrow` runs the units together (`1h 30m`); the wider styles take the
|
|
125
|
+
// locale's own unit-list joiner, which is what DurationFormat uses too.
|
|
126
|
+
return style === "narrow"
|
|
127
|
+
? pieces.join(" ")
|
|
128
|
+
: new Intl.ListFormat(this.locale, { style: "long", type: "unit" }).format(pieces);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
FormattedDuration.register();
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from "../components/output/formatted-duration.js";
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from "../components/output/formatted-duration.js";
|
|
@@ -80,3 +80,37 @@ export interface BoeFileSizeParts {
|
|
|
80
80
|
* is reported whole.
|
|
81
81
|
*/
|
|
82
82
|
export declare const resolveFileSizeParts: (bytes: number, units?: BoeByteUnits) => BoeFileSizeParts;
|
|
83
|
+
/** The duration units a formatted duration can be broken into, largest first. */
|
|
84
|
+
export type BoeDurationUnit = "day" | "hour" | "minute" | "second";
|
|
85
|
+
/** A duration split into whole units, ready for `Intl` to render. */
|
|
86
|
+
export type BoeDurationParts = Partial<Record<BoeDurationUnit, number>>;
|
|
87
|
+
/**
|
|
88
|
+
* Parse a duration to whole seconds.
|
|
89
|
+
*
|
|
90
|
+
* Accepts a count of seconds (a number, or a numeric string) or an ISO 8601
|
|
91
|
+
* duration such as `PT1H30M`, because hosts have both: an API field is usually
|
|
92
|
+
* a number, while `<time datetime>` wants the ISO form. Weeks, months and years
|
|
93
|
+
* are deliberately not accepted — `P1M` is ambiguous between a month and a
|
|
94
|
+
* minute in the calendar sense, and a month is not a fixed number of seconds,
|
|
95
|
+
* so rendering one as though it were would be a lie about how long the duration
|
|
96
|
+
* actually is.
|
|
97
|
+
*
|
|
98
|
+
* Negative and unparseable input is `null`. A duration is an elapsed quantity,
|
|
99
|
+
* so a negative one is a host bug rather than a direction — `box-relative-time`
|
|
100
|
+
* is the component that expresses "ago".
|
|
101
|
+
*/
|
|
102
|
+
export declare const parseDuration: (value: string | number | null | undefined) => number | null;
|
|
103
|
+
/**
|
|
104
|
+
* Split whole seconds into at most `maxUnits` consecutive units.
|
|
105
|
+
*
|
|
106
|
+
* Consecutive, not merely the largest: 90061 seconds is "1 day, 1 hour" rather
|
|
107
|
+
* than "1 day, 1 minute", because a reader scanning a duration wants its
|
|
108
|
+
* magnitude, and skipping an empty unit to reach a smaller non-empty one
|
|
109
|
+
* overstates the precision.
|
|
110
|
+
*
|
|
111
|
+
* A duration of zero returns whole seconds rather than nothing, so "0 sec"
|
|
112
|
+
* renders instead of the element disappearing — zero is a real answer.
|
|
113
|
+
*/
|
|
114
|
+
export declare const resolveDurationParts: (totalSeconds: number, maxUnits?: number) => BoeDurationParts;
|
|
115
|
+
/** Render a parts object back to an ISO 8601 duration, for `<time datetime>`. */
|
|
116
|
+
export declare const toIsoDuration: (parts: BoeDurationParts) => string;
|
|
@@ -120,3 +120,102 @@ export const resolveFileSizeParts = (bytes, units = "decimal") => {
|
|
|
120
120
|
const exponent = Math.min(Math.floor(Math.log(magnitude) / Math.log(base)), suffixes.length - 1);
|
|
121
121
|
return { suffix: suffixes[exponent], value: bytes / base ** exponent };
|
|
122
122
|
};
|
|
123
|
+
const ISO_DURATION = /^P(?:(\d+(?:\.\d+)?)D)?(?:T(?:(\d+(?:\.\d+)?)H)?(?:(\d+(?:\.\d+)?)M)?(?:(\d+(?:\.\d+)?)S)?)?$/;
|
|
124
|
+
const SECONDS_PER = {
|
|
125
|
+
day: 86_400,
|
|
126
|
+
hour: 3_600,
|
|
127
|
+
minute: 60,
|
|
128
|
+
second: 1,
|
|
129
|
+
};
|
|
130
|
+
const DURATION_ORDER = ["day", "hour", "minute", "second"];
|
|
131
|
+
/**
|
|
132
|
+
* Parse a duration to whole seconds.
|
|
133
|
+
*
|
|
134
|
+
* Accepts a count of seconds (a number, or a numeric string) or an ISO 8601
|
|
135
|
+
* duration such as `PT1H30M`, because hosts have both: an API field is usually
|
|
136
|
+
* a number, while `<time datetime>` wants the ISO form. Weeks, months and years
|
|
137
|
+
* are deliberately not accepted — `P1M` is ambiguous between a month and a
|
|
138
|
+
* minute in the calendar sense, and a month is not a fixed number of seconds,
|
|
139
|
+
* so rendering one as though it were would be a lie about how long the duration
|
|
140
|
+
* actually is.
|
|
141
|
+
*
|
|
142
|
+
* Negative and unparseable input is `null`. A duration is an elapsed quantity,
|
|
143
|
+
* so a negative one is a host bug rather than a direction — `box-relative-time`
|
|
144
|
+
* is the component that expresses "ago".
|
|
145
|
+
*/
|
|
146
|
+
export const parseDuration = (value) => {
|
|
147
|
+
if (value == null || value === "") {
|
|
148
|
+
return null;
|
|
149
|
+
}
|
|
150
|
+
if (typeof value === "number") {
|
|
151
|
+
return Number.isFinite(value) && value >= 0 ? Math.round(value) : null;
|
|
152
|
+
}
|
|
153
|
+
const trimmed = value.trim();
|
|
154
|
+
if (/^\d+(\.\d+)?$/.test(trimmed)) {
|
|
155
|
+
return Math.round(Number(trimmed));
|
|
156
|
+
}
|
|
157
|
+
const match = ISO_DURATION.exec(trimmed);
|
|
158
|
+
if (!match || trimmed === "P" || trimmed === "PT") {
|
|
159
|
+
return null;
|
|
160
|
+
}
|
|
161
|
+
const [, days, hours, minutes, seconds] = match;
|
|
162
|
+
const total = Number(days ?? 0) * SECONDS_PER.day +
|
|
163
|
+
Number(hours ?? 0) * SECONDS_PER.hour +
|
|
164
|
+
Number(minutes ?? 0) * SECONDS_PER.minute +
|
|
165
|
+
Number(seconds ?? 0);
|
|
166
|
+
return Number.isFinite(total) ? Math.round(total) : null;
|
|
167
|
+
};
|
|
168
|
+
/**
|
|
169
|
+
* Split whole seconds into at most `maxUnits` consecutive units.
|
|
170
|
+
*
|
|
171
|
+
* Consecutive, not merely the largest: 90061 seconds is "1 day, 1 hour" rather
|
|
172
|
+
* than "1 day, 1 minute", because a reader scanning a duration wants its
|
|
173
|
+
* magnitude, and skipping an empty unit to reach a smaller non-empty one
|
|
174
|
+
* overstates the precision.
|
|
175
|
+
*
|
|
176
|
+
* A duration of zero returns whole seconds rather than nothing, so "0 sec"
|
|
177
|
+
* renders instead of the element disappearing — zero is a real answer.
|
|
178
|
+
*/
|
|
179
|
+
export const resolveDurationParts = (totalSeconds, maxUnits = 2) => {
|
|
180
|
+
const limit = Math.max(1, Math.trunc(maxUnits));
|
|
181
|
+
if (totalSeconds <= 0) {
|
|
182
|
+
return { second: 0 };
|
|
183
|
+
}
|
|
184
|
+
const collected = [];
|
|
185
|
+
let remaining = Math.round(totalSeconds);
|
|
186
|
+
let started = false;
|
|
187
|
+
for (const unit of DURATION_ORDER) {
|
|
188
|
+
const size = SECONDS_PER[unit];
|
|
189
|
+
const amount = Math.floor(remaining / size);
|
|
190
|
+
if (!started && amount === 0) {
|
|
191
|
+
continue;
|
|
192
|
+
}
|
|
193
|
+
started = true;
|
|
194
|
+
collected.push([unit, amount]);
|
|
195
|
+
remaining -= amount * size;
|
|
196
|
+
if (collected.length === limit) {
|
|
197
|
+
break;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
// Trailing zeroes are noise: an exact hour is "1 hr", not "1 hr, 0 min". Only
|
|
201
|
+
// trailing ones go — an interior zero is load-bearing, since "1 day, 0 hr" as
|
|
202
|
+
// the first two units of 1d 0h 5m would otherwise become "1 day, 5 min" and
|
|
203
|
+
// claim a precision the split does not have.
|
|
204
|
+
while (collected.length > 1 && collected[collected.length - 1][1] === 0) {
|
|
205
|
+
collected.pop();
|
|
206
|
+
}
|
|
207
|
+
return Object.fromEntries(collected);
|
|
208
|
+
};
|
|
209
|
+
/** Render a parts object back to an ISO 8601 duration, for `<time datetime>`. */
|
|
210
|
+
export const toIsoDuration = (parts) => {
|
|
211
|
+
const date = parts.day ? `${parts.day}D` : "";
|
|
212
|
+
const time = [
|
|
213
|
+
parts.hour ? `${parts.hour}H` : "",
|
|
214
|
+
parts.minute ? `${parts.minute}M` : "",
|
|
215
|
+
parts.second ? `${parts.second}S` : "",
|
|
216
|
+
].join("");
|
|
217
|
+
if (!date && !time) {
|
|
218
|
+
return "PT0S";
|
|
219
|
+
}
|
|
220
|
+
return `P${date}${time ? `T${time}` : ""}`;
|
|
221
|
+
};
|
package/dist/index.d.ts
CHANGED
|
@@ -89,6 +89,7 @@ export * from "./components/output/code-block.js";
|
|
|
89
89
|
export * from "./components/output/formatted-date.js";
|
|
90
90
|
export * from "./components/output/relative-time.js";
|
|
91
91
|
export * from "./components/output/formatted-number.js";
|
|
92
|
+
export * from "./components/output/formatted-duration.js";
|
|
92
93
|
export * from "./components/output/formatted-file-size.js";
|
|
93
94
|
export * from "./components/layout/app-shell.js";
|
|
94
95
|
export * from "./components/layout/divider.js";
|
package/dist/index.js
CHANGED
|
@@ -97,6 +97,7 @@ export * from "./components/output/code-block.js";
|
|
|
97
97
|
export * from "./components/output/formatted-date.js";
|
|
98
98
|
export * from "./components/output/relative-time.js";
|
|
99
99
|
export * from "./components/output/formatted-number.js";
|
|
100
|
+
export * from "./components/output/formatted-duration.js";
|
|
100
101
|
export * from "./components/output/formatted-file-size.js";
|
|
101
102
|
// Components: layout
|
|
102
103
|
export * from "./components/layout/app-shell.js";
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@unofficialbox/box-open-elements",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.16.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",
|