@unofficialbox/box-open-elements 0.12.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.
Files changed (47) hide show
  1. package/dist/components/feedback/alert.d.ts +2 -0
  2. package/dist/components/feedback/alert.js +40 -2
  3. package/dist/components/feedback/indicator.d.ts +34 -0
  4. package/dist/components/feedback/indicator.js +167 -0
  5. package/dist/components/feedback/toast.js +1 -17
  6. package/dist/components/feedback/tone.d.ts +18 -0
  7. package/dist/components/feedback/tone.js +23 -0
  8. package/dist/components/forms/calendar.d.ts +25 -0
  9. package/dist/components/forms/calendar.js +94 -2
  10. package/dist/components/forms/tile-group.d.ts +58 -0
  11. package/dist/components/forms/tile-group.js +280 -0
  12. package/dist/components/output/code-block.d.ts +49 -0
  13. package/dist/components/output/code-block.js +236 -0
  14. package/dist/components/output/formatted-date.d.ts +38 -0
  15. package/dist/components/output/formatted-date.js +90 -0
  16. package/dist/components/output/formatted-file-size.d.ts +31 -0
  17. package/dist/components/output/formatted-file-size.js +61 -0
  18. package/dist/components/output/formatted-number.d.ts +37 -0
  19. package/dist/components/output/formatted-number.js +106 -0
  20. package/dist/components/output/formatted-value.d.ts +44 -0
  21. package/dist/components/output/formatted-value.js +100 -0
  22. package/dist/components/output/index.d.ts +6 -0
  23. package/dist/components/output/index.js +9 -0
  24. package/dist/components/output/relative-time.d.ts +41 -0
  25. package/dist/components/output/relative-time.js +82 -0
  26. package/dist/element-maps.d.ts +15 -1
  27. package/dist/entries/code-block.d.ts +1 -0
  28. package/dist/entries/code-block.js +1 -0
  29. package/dist/entries/formatted-date.d.ts +1 -0
  30. package/dist/entries/formatted-date.js +1 -0
  31. package/dist/entries/formatted-file-size.d.ts +1 -0
  32. package/dist/entries/formatted-file-size.js +1 -0
  33. package/dist/entries/formatted-number.d.ts +1 -0
  34. package/dist/entries/formatted-number.js +1 -0
  35. package/dist/entries/indicator.d.ts +1 -0
  36. package/dist/entries/indicator.js +1 -0
  37. package/dist/entries/relative-time.d.ts +1 -0
  38. package/dist/entries/relative-time.js +1 -0
  39. package/dist/entries/tile-group.d.ts +1 -0
  40. package/dist/entries/tile-group.js +1 -0
  41. package/dist/foundations/format/format.d.ts +82 -0
  42. package/dist/foundations/format/format.js +122 -0
  43. package/dist/foundations/format/index.d.ts +1 -0
  44. package/dist/foundations/format/index.js +3 -0
  45. package/dist/index.d.ts +9 -0
  46. package/dist/index.js +11 -0
  47. package/package.json +1 -1
@@ -0,0 +1,280 @@
1
+ import { BaseElement } from "../../core/index.js";
2
+ import { boeRadius } from "../../foundations/geometry/index.js";
3
+ import { boeFocusRingShadow } from "../../foundations/tokens/index.js";
4
+ const DEFAULT_TAG_NAME = "box-tile-group";
5
+ const escapeHtml = (value) => value
6
+ .replaceAll("&", "&")
7
+ .replaceAll("<", "&lt;")
8
+ .replaceAll(">", "&gt;")
9
+ .replaceAll('"', "&quot;")
10
+ .replaceAll("'", "&#39;");
11
+ /** Attribute payloads are author input — validate every record. */
12
+ export const isTileOptionRecord = (value) => {
13
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
14
+ return false;
15
+ }
16
+ const item = value;
17
+ return typeof item.id === "string" && typeof item.label === "string";
18
+ };
19
+ const tileStyles = `
20
+ :host {
21
+ display: block;
22
+ color: inherit;
23
+ font: inherit;
24
+ }
25
+
26
+ :host([hidden]) {
27
+ display: none;
28
+ }
29
+
30
+ [part="group"] {
31
+ display: grid;
32
+ /* Tiles carry a label and often a description, so they need width to be
33
+ worth choosing over radios. Below the minimum they stack rather than
34
+ squeeze. */
35
+ grid-template-columns: repeat(auto-fit, minmax(min(14rem, 100%), 1fr));
36
+ gap: 0.6rem;
37
+ margin: 0;
38
+ padding: 0;
39
+ border: 0;
40
+ }
41
+
42
+ [part="legend"] {
43
+ padding: 0 0 0.5rem;
44
+ font-weight: 700;
45
+ color: var(--boe-token-text-text, #222222);
46
+ }
47
+
48
+ [part="legend"]:empty {
49
+ display: none;
50
+ }
51
+
52
+ [part="tile"] {
53
+ position: relative;
54
+ display: grid;
55
+ gap: 0.2rem;
56
+ padding: 0.75rem 0.85rem;
57
+ border: 1px solid color-mix(in srgb, var(--boe-token-stroke-stroke, #e8e8e8) 85%, transparent);
58
+ border-radius: ${boeRadius.large};
59
+ background: var(--boe-token-surface-surface, #ffffff);
60
+ cursor: pointer;
61
+ }
62
+
63
+ [part="tile"]:hover:not([data-disabled="true"]) {
64
+ background: var(--boe-token-surface-surface-hover, #f4f4f4);
65
+ }
66
+
67
+ [part="tile"][data-selected="true"] {
68
+ border-color: var(--boe-token-surface-surface-brand, #0061d5);
69
+ background: var(--boe-token-surface-item-surface-selected, #f2f7fd);
70
+ }
71
+
72
+ [part="tile"][data-disabled="true"] {
73
+ cursor: not-allowed;
74
+ opacity: 0.5;
75
+ }
76
+
77
+ /* The ring is drawn on the tile the native control sits inside, because the
78
+ control itself is visually hidden — without this, keyboard focus would be
79
+ invisible. */
80
+ [part="tile"]:has(input:focus-visible) {
81
+ box-shadow: ${boeFocusRingShadow};
82
+ }
83
+
84
+ /* Hidden from sight, not from the accessibility tree or the tab order: the
85
+ tile is a real radio or checkbox, so grouping, arrow-key behaviour and
86
+ form participation are the platform's rather than reimplemented. */
87
+ [part="control"] {
88
+ position: absolute;
89
+ inline-size: 1px;
90
+ block-size: 1px;
91
+ padding: 0;
92
+ margin: -1px;
93
+ overflow: hidden;
94
+ clip-path: inset(50%);
95
+ white-space: nowrap;
96
+ }
97
+
98
+ [part="tile-label"] {
99
+ font-weight: 600;
100
+ color: var(--boe-token-text-text, #222222);
101
+ }
102
+
103
+ [part="tile-description"] {
104
+ font-size: 0.82rem;
105
+ line-height: 1.45;
106
+ color: var(--boe-token-text-text-secondary, #6f6f6f);
107
+ }
108
+
109
+ [part="tile-description"]:empty {
110
+ display: none;
111
+ }
112
+ `;
113
+ /**
114
+ * Choose one option, or several, as cards rather than radios.
115
+ *
116
+ * ```html
117
+ * <box-tile-group name="plan" options='[{"id":"team","label":"Team"}]'></box-tile-group>
118
+ * ```
119
+ *
120
+ * Each tile wraps a real `<input type="radio">` or `<input type="checkbox">`,
121
+ * visually hidden but present in the tab order and the accessibility tree. That
122
+ * is the whole design: grouping, arrow-key navigation between radios, the
123
+ * roving focus rules, and form participation all come from the platform rather
124
+ * than being reimplemented on `<div>`s and then subtly diverging from what a
125
+ * screen reader user expects.
126
+ *
127
+ * Use it where the choice deserves explaining — a plan, a permission level, a
128
+ * retention policy — and a bare radio label would not have room. Where the
129
+ * options are one word each, radios are smaller and better.
130
+ */
131
+ export class TileGroup extends BaseElement {
132
+ static tagName = DEFAULT_TAG_NAME;
133
+ static get observedAttributes() {
134
+ return ["legend", "multiple", "name", "options", "value"];
135
+ }
136
+ groupEl;
137
+ legendEl;
138
+ optionsSignature = "";
139
+ /** The choices, as JSON. */
140
+ get options() {
141
+ const raw = this.getAttribute("options");
142
+ if (!raw) {
143
+ return [];
144
+ }
145
+ try {
146
+ const parsed = JSON.parse(raw);
147
+ return Array.isArray(parsed) && parsed.every(isTileOptionRecord)
148
+ ? parsed
149
+ : [];
150
+ }
151
+ catch {
152
+ return [];
153
+ }
154
+ }
155
+ set options(value) {
156
+ this.setAttribute("options", JSON.stringify(value));
157
+ }
158
+ /** Several selections rather than one. Switches radios for checkboxes. */
159
+ get multiple() {
160
+ return this.hasAttribute("multiple");
161
+ }
162
+ set multiple(value) {
163
+ this.toggleAttribute("multiple", value);
164
+ }
165
+ /** The radio group name. Required for radios to behave as one group. */
166
+ get name() {
167
+ return this.getAttribute("name") ?? "tile-group";
168
+ }
169
+ set name(value) {
170
+ this.setAttribute("name", value);
171
+ }
172
+ /** Selected ids, comma-separated. A single id when not `multiple`. */
173
+ get value() {
174
+ return this.getAttribute("value") ?? "";
175
+ }
176
+ set value(next) {
177
+ if (!next) {
178
+ this.removeAttribute("value");
179
+ return;
180
+ }
181
+ this.setAttribute("value", next);
182
+ }
183
+ /** Selected ids as an array, which is what a host actually wants. */
184
+ get selected() {
185
+ return this.value
186
+ .split(",")
187
+ .map(part => part.trim())
188
+ .filter(Boolean);
189
+ }
190
+ get legend() {
191
+ return this.getAttribute("legend") ?? "";
192
+ }
193
+ set legend(value) {
194
+ this.setAttribute("legend", value);
195
+ }
196
+ optionsKey() {
197
+ return `${this.getAttribute("options") ?? ""}|${String(this.multiple)}|${this.name}`;
198
+ }
199
+ rebuild() {
200
+ const type = this.multiple ? "checkbox" : "radio";
201
+ this.groupEl.innerHTML = this.options
202
+ .map(option => {
203
+ const id = escapeHtml(option.id);
204
+ return `
205
+ <label part="tile" data-option-id="${id}" data-selected="false" data-disabled="${option.disabled ? "true" : "false"}">
206
+ <input
207
+ part="control"
208
+ type="${type}"
209
+ name="${escapeHtml(this.name)}"
210
+ value="${id}"
211
+ ${option.disabled ? "disabled" : ""}
212
+ />
213
+ <span part="tile-label">${escapeHtml(option.label)}</span>
214
+ <span part="tile-description">${escapeHtml(option.description ?? "")}</span>
215
+ </label>
216
+ `;
217
+ })
218
+ .join("");
219
+ }
220
+ syncSelection() {
221
+ const selected = new Set(this.selected);
222
+ this.groupEl.querySelectorAll('[part="tile"]').forEach(tile => {
223
+ const id = tile.dataset.optionId ?? "";
224
+ const isSelected = selected.has(id);
225
+ tile.dataset.selected = isSelected ? "true" : "false";
226
+ const control = tile.querySelector('[part="control"]');
227
+ if (control) {
228
+ control.checked = isSelected;
229
+ }
230
+ });
231
+ }
232
+ readSelection() {
233
+ return Array.from(this.groupEl.querySelectorAll('[part="control"]'))
234
+ .filter(input => input.checked)
235
+ .map(input => input.value);
236
+ }
237
+ renderTemplate() {
238
+ if (!this.shadowRoot) {
239
+ return;
240
+ }
241
+ this.shadowRoot.innerHTML = `
242
+ <style>${tileStyles}</style>
243
+ <fieldset part="group">
244
+ <legend part="legend"></legend>
245
+ </fieldset>
246
+ `;
247
+ this.groupEl = this.shadowRoot.querySelector('[part="group"]');
248
+ this.legendEl = this.shadowRoot.querySelector('[part="legend"]');
249
+ }
250
+ setupListeners() {
251
+ this.groupEl.addEventListener("change", () => {
252
+ const next = this.readSelection();
253
+ this.value = next.join(",");
254
+ this.dispatchEvent(new CustomEvent("tile-change", {
255
+ bubbles: true,
256
+ composed: true,
257
+ detail: { selected: next },
258
+ }));
259
+ });
260
+ }
261
+ update() {
262
+ if (!this.groupEl) {
263
+ return;
264
+ }
265
+ const nextKey = this.optionsKey();
266
+ if (nextKey !== this.optionsSignature) {
267
+ this.optionsSignature = nextKey;
268
+ // The legend lives inside the fieldset and is wiped by the rebuild, so it
269
+ // is re-created rather than queried once at render time.
270
+ this.rebuild();
271
+ const legend = document.createElement("legend");
272
+ legend.setAttribute("part", "legend");
273
+ this.groupEl.prepend(legend);
274
+ this.legendEl = legend;
275
+ }
276
+ this.legendEl.textContent = this.legend;
277
+ this.syncSelection();
278
+ }
279
+ }
280
+ TileGroup.register();
@@ -0,0 +1,49 @@
1
+ import { BaseElement } from "../../core/index.js";
2
+ /**
3
+ * A block of code, rendered as written.
4
+ *
5
+ * ```html
6
+ * <box-code-block code="npm i @unofficialbox/box-open-elements"></box-code-block>
7
+ * ```
8
+ *
9
+ * The code is set as `textContent`, never as markup — a snippet is the most
10
+ * likely string on any page to contain angle brackets, and rendering it as HTML
11
+ * would both corrupt what the reader sees and hand an injection point to
12
+ * whatever produced the snippet.
13
+ *
14
+ * There is no syntax highlighting, deliberately. Doing it properly means
15
+ * shipping a grammar per language, and doing it improperly means mis-colouring
16
+ * code that a reader is trying to trust. A host that needs highlighting can
17
+ * slot pre-rendered markup into the default slot instead.
18
+ *
19
+ * Long lines scroll rather than wrap: a wrapped line silently changes what the
20
+ * reader believes the source says. `wrap` opts into wrapping where the content
21
+ * has no meaningful column structure.
22
+ */
23
+ export declare class CodeBlock extends BaseElement {
24
+ static readonly tagName: string;
25
+ static get observedAttributes(): string[];
26
+ private preEl;
27
+ private codeEl;
28
+ private copyEl;
29
+ private slotEl;
30
+ private resetTimer;
31
+ /** The source to render. */
32
+ get code(): string;
33
+ set code(value: string);
34
+ /**
35
+ * The language, surfaced as a `data-language` hook and in the code element's
36
+ * class the way `highlight.js` and friends expect. Nothing here reads it.
37
+ */
38
+ get language(): string;
39
+ set language(value: string);
40
+ /** Accessible name for the copy button. */
41
+ get copyLabel(): string;
42
+ set copyLabel(value: string);
43
+ private copy;
44
+ private markCopied;
45
+ disconnectedCallback(): void;
46
+ protected renderTemplate(): void;
47
+ protected setupListeners(): void;
48
+ protected update(): void;
49
+ }
@@ -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();