@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.
@@ -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.15.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",