@unofficialbox/box-open-elements 0.12.0 → 0.14.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 +53 -0
  19. package/dist/components/output/formatted-number.js +130 -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
@@ -4,7 +4,9 @@ export declare class Alert extends BaseElement {
4
4
  static get observedAttributes(): string[];
5
5
  private openValue;
6
6
  private alertEl;
7
+ private iconEl;
7
8
  private toneLabelEl;
9
+ private renderedTone;
8
10
  private titleEl;
9
11
  private messageEl;
10
12
  private richSlot;
@@ -1,5 +1,5 @@
1
1
  import { BaseElement } from "../../core/index.js";
2
- import { toneAccessibleLabel } from "./tone.js";
2
+ import { toneAccessibleLabel, toneIcon } from "./tone.js";
3
3
  import { boeRadius, boeSpace } from "../../foundations/geometry/index.js";
4
4
  import { boeNeutralInteractiveStyles } from "../../foundations/tokens/index.js";
5
5
  import { boeMotionDuration, boeMotionEasing } from "../../foundations/motion/index.js";
@@ -15,7 +15,13 @@ const alertStyles = `
15
15
  display: none;
16
16
  }
17
17
 
18
+ /* The accent colours the glyph only. The tinted backgrounds below are pinned
19
+ by the colour conformance manifest against upstream box-ui-elements and are
20
+ deliberately not derived from it; upstream has no glyph, so adding one
21
+ costs no conformance. */
18
22
  [part="alert"] {
23
+ --alert-accent: var(--boe-token-text-text-secondary, #6f6f6f);
24
+
19
25
  display: flex;
20
26
  align-items: start;
21
27
  justify-content: space-between;
@@ -29,23 +35,44 @@ const alertStyles = `
29
35
  }
30
36
 
31
37
  [part="alert"][data-tone="info"] {
38
+ --alert-accent: var(--boe-token-surface-surface-brand, #0061d5);
32
39
  background: color-mix(in srgb, var(--boe-token-surface-surface-brand, #0061d5) 10%, #fff);
33
40
  }
34
41
 
35
42
  [part="alert"][data-tone="success"] {
43
+ --alert-accent: var(--boe-token-surface-status-surface-success, #26c281);
36
44
  background: color-mix(in srgb, var(--boe-token-surface-status-surface-success, #26c281) 10%, #fff);
37
45
  }
38
46
 
39
47
  [part="alert"][data-tone="error"] {
48
+ --alert-accent: var(--boe-token-surface-status-surface-error, #ed3757);
40
49
  background: color-mix(in srgb, var(--boe-token-surface-status-surface-error, #ed3757) 10%, #fff);
41
50
  }
42
51
 
43
52
  [part="alert"][data-tone="warning"],
44
53
  [part="alert"][data-tone="inprogress"] {
54
+ --alert-accent: var(--boe-token-surface-status-surface-inprogress, #f5b31b);
45
55
  background: color-mix(in srgb, var(--boe-token-surface-status-surface-inprogress, #f5b31b) 10%, #fff);
46
56
  }
47
57
 
58
+ [part="icon"] {
59
+ flex: 0 0 auto;
60
+ inline-size: 20px;
61
+ block-size: 20px;
62
+ /* Full strength: with only a 10% tint behind it, the glyph is where the
63
+ tone actually reads. */
64
+ color: var(--alert-accent);
65
+ }
66
+
67
+ [part="icon"] svg {
68
+ display: block;
69
+ inline-size: 100%;
70
+ block-size: 100%;
71
+ }
72
+
48
73
  [part="content"] {
74
+ flex: 1 1 auto;
75
+ min-inline-size: 0;
49
76
  display: grid;
50
77
  gap: ${boeSpace[1]};
51
78
  line-height: 1.45;
@@ -123,7 +150,9 @@ export class Alert extends BaseElement {
123
150
  }
124
151
  openValue = true;
125
152
  alertEl;
153
+ iconEl;
126
154
  toneLabelEl;
155
+ renderedTone = null;
127
156
  titleEl;
128
157
  messageEl;
129
158
  richSlot;
@@ -196,6 +225,7 @@ export class Alert extends BaseElement {
196
225
  this.shadowRoot.innerHTML = `
197
226
  <style>${alertStyles}</style>
198
227
  <div part="alert" role="status" aria-live="polite">
228
+ <span part="icon" aria-hidden="true"></span>
199
229
  <div part="content">
200
230
  <span part="tone-label" class="sr-only"></span>
201
231
  <h2 part="title" id="alert-title" hidden></h2>
@@ -206,6 +236,7 @@ export class Alert extends BaseElement {
206
236
  </div>
207
237
  `;
208
238
  this.alertEl = this.shadowRoot.querySelector('[part="alert"]');
239
+ this.iconEl = this.shadowRoot.querySelector('[part="icon"]');
209
240
  this.toneLabelEl = this.shadowRoot.querySelector('[part="tone-label"]');
210
241
  this.titleEl = this.shadowRoot.querySelector('[part="title"]');
211
242
  this.messageEl = this.shadowRoot.querySelector('[part~="description"]');
@@ -237,7 +268,14 @@ export class Alert extends BaseElement {
237
268
  return;
238
269
  }
239
270
  this.alertEl.dataset.tone = this.tone;
240
- this.toneLabelEl.textContent = toneAccessibleLabel(this.tone);
271
+ const tone = this.tone;
272
+ this.toneLabelEl.textContent = toneAccessibleLabel(tone);
273
+ // Literal markup keyed by tone, so re-writing it on every update would be
274
+ // wasted work — and would restart any transition on the glyph.
275
+ if (tone !== this.renderedTone) {
276
+ this.renderedTone = tone;
277
+ this.iconEl.innerHTML = toneIcon(tone);
278
+ }
241
279
  if (this.heading) {
242
280
  this.alertEl.setAttribute("aria-labelledby", "alert-title");
243
281
  this.alertEl.removeAttribute("aria-label");
@@ -0,0 +1,34 @@
1
+ import { BaseElement } from "../../core/index.js";
2
+ /**
3
+ * A status mark, for dense lists where a full badge would not fit.
4
+ *
5
+ * ```html
6
+ * <box-indicator tone="success" label="Signed"></box-indicator>
7
+ * ```
8
+ *
9
+ * Distinct in shape as well as colour, so a status column survives being read
10
+ * by someone who cannot separate green from amber — the same rule the alert and
11
+ * toast glyphs follow.
12
+ *
13
+ * When there is no visible `label` the tone is still stated for assistive
14
+ * technology, because a bare dot otherwise announces nothing at all. A host
15
+ * whose surrounding text already says "Signed" should pass that as the label
16
+ * rather than leaving a reader to infer it from a colour.
17
+ */
18
+ export declare class Indicator extends BaseElement {
19
+ static readonly tagName: string;
20
+ static get observedAttributes(): string[];
21
+ private shapeEl;
22
+ private labelEl;
23
+ private toneLabelEl;
24
+ private renderedTone;
25
+ /** The status. Unknown tones render the neutral disc. */
26
+ get tone(): string;
27
+ set tone(value: string);
28
+ /** Visible text beside the mark. Optional. */
29
+ get label(): string;
30
+ set label(value: string);
31
+ protected renderTemplate(): void;
32
+ protected setupListeners(): void;
33
+ protected update(): void;
34
+ }
@@ -0,0 +1,167 @@
1
+ import { BaseElement } from "../../core/index.js";
2
+ import { toneAccessibleLabel } from "./tone.js";
3
+ const DEFAULT_TAG_NAME = "box-indicator";
4
+ /**
5
+ * Indicator shapes, one per tone.
6
+ *
7
+ * The shape is the point. A row of coloured dots is unreadable to anyone who
8
+ * cannot separate the colours, and status is exactly the information a reader
9
+ * most needs from a dense list. Carbon ships both an icon and a shape
10
+ * indicator for this reason; here the shape *is* the indicator, and colour is
11
+ * the redundant channel rather than the only one.
12
+ */
13
+ const INDICATOR_SHAPES = {
14
+ // A filled disc: the neutral resting state.
15
+ info: `<svg viewBox="0 0 12 12" fill="currentColor" focusable="false"><circle cx="6" cy="6" r="5"/></svg>`,
16
+ // A disc with a tick cut into it, matching the success glyph on alert/toast.
17
+ success: `<svg viewBox="0 0 12 12" fill="currentColor" focusable="false"><path d="M6 1a5 5 0 100 10A5 5 0 006 1zm2.5 3.6L5.6 8a.7.7 0 01-1 0L3.5 6.8a.7.7 0 111-1l.6.7 2.4-2.8a.7.7 0 011 .9z"/></svg>`,
18
+ // A triangle, distinguishable from a disc at a glance and at small sizes.
19
+ warning: `<svg viewBox="0 0 12 12" fill="currentColor" focusable="false"><path d="M5.5 1.4 0.5 10a.6.6 0 00.5.9h10a.6.6 0 00.5-.9l-5-8.6a.6.6 0 00-1 0z"/></svg>`,
20
+ // A diamond: a square is too close to a disc once antialiased at 10px.
21
+ error: `<svg viewBox="0 0 12 12" fill="currentColor" focusable="false"><path d="M6 0.6 11.4 6 6 11.4.6 6z"/></svg>`,
22
+ // A hollow ring: nothing has happened yet, so nothing is filled in.
23
+ pending: `<svg viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="2" focusable="false"><circle cx="6" cy="6" r="4"/></svg>`,
24
+ };
25
+ /** The shape for a tone, falling back to the neutral disc. */
26
+ const indicatorShape = (tone) => INDICATOR_SHAPES[tone] ?? INDICATOR_SHAPES.info;
27
+ const indicatorStyles = `
28
+ :host {
29
+ display: inline-flex;
30
+ align-items: center;
31
+ gap: 0.4rem;
32
+ color: inherit;
33
+ font: inherit;
34
+ }
35
+
36
+ :host([hidden]) {
37
+ display: none;
38
+ }
39
+
40
+ [part="shape"] {
41
+ flex: 0 0 auto;
42
+ inline-size: 0.62rem;
43
+ block-size: 0.62rem;
44
+ color: var(--boe-token-text-text-secondary, #6f6f6f);
45
+ }
46
+
47
+ [part="shape"] svg {
48
+ display: block;
49
+ inline-size: 100%;
50
+ block-size: 100%;
51
+ }
52
+
53
+ :host([tone="info"]) [part="shape"] {
54
+ color: var(--boe-token-surface-surface-brand, #0061d5);
55
+ }
56
+
57
+ :host([tone="success"]) [part="shape"] {
58
+ color: var(--boe-token-surface-status-surface-success, #26c281);
59
+ }
60
+
61
+ :host([tone="error"]) [part="shape"] {
62
+ color: var(--boe-token-surface-status-surface-error, #ed3757);
63
+ }
64
+
65
+ :host([tone="warning"]) [part="shape"] {
66
+ color: var(--boe-token-surface-status-surface-inprogress, #f5b31b);
67
+ }
68
+
69
+ [part="label"] {
70
+ min-inline-size: 0;
71
+ overflow-wrap: anywhere;
72
+ }
73
+
74
+ /* With no label the dot stands alone, and an empty span would still take a
75
+ line box and the flex gap beside it. */
76
+ [part="label"]:empty {
77
+ display: none;
78
+ }
79
+
80
+ .sr-only {
81
+ position: absolute;
82
+ inline-size: 1px;
83
+ block-size: 1px;
84
+ padding: 0;
85
+ margin: -1px;
86
+ overflow: hidden;
87
+ clip-path: inset(50%);
88
+ white-space: nowrap;
89
+ border: 0;
90
+ }
91
+ `;
92
+ /**
93
+ * A status mark, for dense lists where a full badge would not fit.
94
+ *
95
+ * ```html
96
+ * <box-indicator tone="success" label="Signed"></box-indicator>
97
+ * ```
98
+ *
99
+ * Distinct in shape as well as colour, so a status column survives being read
100
+ * by someone who cannot separate green from amber — the same rule the alert and
101
+ * toast glyphs follow.
102
+ *
103
+ * When there is no visible `label` the tone is still stated for assistive
104
+ * technology, because a bare dot otherwise announces nothing at all. A host
105
+ * whose surrounding text already says "Signed" should pass that as the label
106
+ * rather than leaving a reader to infer it from a colour.
107
+ */
108
+ export class Indicator extends BaseElement {
109
+ static tagName = DEFAULT_TAG_NAME;
110
+ static get observedAttributes() {
111
+ return ["label", "tone"];
112
+ }
113
+ shapeEl;
114
+ labelEl;
115
+ toneLabelEl;
116
+ renderedTone = null;
117
+ /** The status. Unknown tones render the neutral disc. */
118
+ get tone() {
119
+ return this.getAttribute("tone") ?? "info";
120
+ }
121
+ set tone(value) {
122
+ this.setAttribute("tone", value);
123
+ }
124
+ /** Visible text beside the mark. Optional. */
125
+ get label() {
126
+ return this.getAttribute("label") ?? "";
127
+ }
128
+ set label(value) {
129
+ if (!value) {
130
+ this.removeAttribute("label");
131
+ return;
132
+ }
133
+ this.setAttribute("label", value);
134
+ }
135
+ renderTemplate() {
136
+ if (!this.shadowRoot) {
137
+ return;
138
+ }
139
+ this.shadowRoot.innerHTML = `
140
+ <style>${indicatorStyles}</style>
141
+ <span part="shape" aria-hidden="true"></span>
142
+ <span part="tone-label" class="sr-only"></span>
143
+ <span part="label"></span>
144
+ `;
145
+ this.shapeEl = this.shadowRoot.querySelector('[part="shape"]');
146
+ this.toneLabelEl = this.shadowRoot.querySelector('[part="tone-label"]');
147
+ this.labelEl = this.shadowRoot.querySelector('[part="label"]');
148
+ }
149
+ setupListeners() {
150
+ // Display only.
151
+ }
152
+ update() {
153
+ if (!this.shapeEl) {
154
+ return;
155
+ }
156
+ const tone = this.tone;
157
+ if (tone !== this.renderedTone) {
158
+ this.renderedTone = tone;
159
+ this.shapeEl.innerHTML = indicatorShape(tone);
160
+ }
161
+ this.labelEl.textContent = this.label;
162
+ // Only speak the tone when no visible label carries it; otherwise a reader
163
+ // hears "Success Signed" where the screen says "Signed".
164
+ this.toneLabelEl.textContent = this.label ? "" : toneAccessibleLabel(tone);
165
+ }
166
+ }
167
+ Indicator.register();
@@ -1,5 +1,5 @@
1
1
  import { BaseElement } from "../../core/index.js";
2
- import { toneAccessibleLabel } from "./tone.js";
2
+ import { toneAccessibleLabel, toneIcon } from "./tone.js";
3
3
  import { boeRadius, boeSpace } from "../../foundations/geometry/index.js";
4
4
  import { boeFocusVisibleStyles } from "../../foundations/tokens/index.js";
5
5
  import { boeMotionDuration, boeMotionEasing } from "../../foundations/motion/index.js";
@@ -7,22 +7,6 @@ const DEFAULT_TAG_NAME = "box-toast";
7
7
  const TOAST_MODES = new Set(["dismissible", "sticky"]);
8
8
  /** Narrow an author-supplied mode, falling back to `dismissible`. */
9
9
  export const resolveToastMode = (value) => TOAST_MODES.has(value) ? value : "dismissible";
10
- /**
11
- * Status glyphs, one per tone.
12
- *
13
- * A toast is read at a glance and often out of the corner of an eye, so the
14
- * shape has to carry the meaning before the colour does — a round tick and a
15
- * warning triangle are distinguishable to a reader who cannot separate green
16
- * from amber. Literal markup, never author input, so innerHTML is safe here.
17
- */
18
- const TONE_ICONS = {
19
- info: `<svg viewBox="0 0 20 20" fill="currentColor" focusable="false"><path d="M10 2a8 8 0 100 16 8 8 0 000-16zm0 3.4a1.15 1.15 0 110 2.3 1.15 1.15 0 010-2.3zM11.1 15H8.9V9.3h2.2V15z"/></svg>`,
20
- success: `<svg viewBox="0 0 20 20" fill="currentColor" focusable="false"><path d="M10 2a8 8 0 100 16 8 8 0 000-16zm4.06 5.86-5 5.2a1 1 0 01-1.44 0L5.94 11.3A1 1 0 117.38 9.9l1.04 1.08 4.28-4.52a1 1 0 011.36 1.38z"/></svg>`,
21
- warning: `<svg viewBox="0 0 20 20" fill="currentColor" focusable="false"><path d="M9.13 2.6 1.4 15.9a1 1 0 00.87 1.5h15.46a1 1 0 00.87-1.5L10.87 2.6a1 1 0 00-1.74 0zM11.1 15.4H8.9v-2.2h2.2v2.2zm0-3.4H8.9V7.6h2.2V12z"/></svg>`,
22
- error: `<svg viewBox="0 0 20 20" fill="currentColor" focusable="false"><path d="M10 2a8 8 0 100 16 8 8 0 000-16zm0 2c1.29 0 2.48.41 3.46 1.1L5.1 13.46A6 6 0 0110 4zm0 12c-1.29 0-2.48-.41-3.46-1.1l8.36-8.36A6 6 0 0110 16z"/></svg>`,
23
- };
24
- /** The tone glyph, falling back to the info mark for an unknown tone. */
25
- const toneIcon = (tone) => TONE_ICONS[tone] ?? TONE_ICONS.info;
26
10
  const DISMISS_ICON = `<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" focusable="false"><path d="M5.5 5.5l9 9M14.5 5.5l-9 9"/></svg>`;
27
11
  const toastStyles = `
28
12
  :host {
@@ -5,6 +5,10 @@
5
5
  * readers, and both must say the *same* words: a reader who hears "In progress"
6
6
  * from an alert should not hear "Inprogress" from a toast reporting the same
7
7
  * thing. One definition is what keeps them in step.
8
+ *
9
+ * The glyphs live here for the same reason. Both surfaces render them, and a
10
+ * success tick that differs between an alert and a toast reporting the same
11
+ * outcome is a defect a reader would notice before either team did.
8
12
  */
9
13
  /**
10
14
  * The tone as a word, for assistive technology.
@@ -14,3 +18,17 @@
14
18
  * a host that invents a tone should still get something spoken.
15
19
  */
16
20
  export declare const toneAccessibleLabel: (tone: string) => string;
21
+ /**
22
+ * Status glyphs, one per tone.
23
+ *
24
+ * These surfaces are read at a glance, often out of the corner of an eye, so
25
+ * the shape has to carry the meaning before the colour does — a round tick and
26
+ * a warning triangle are distinguishable to a reader who cannot separate green
27
+ * from amber. That matters most where the fill is faint: `box-alert` tints its
28
+ * background by only 10%, which on its own is a weak signal.
29
+ *
30
+ * Literal markup, never author input, so innerHTML is safe here.
31
+ */
32
+ export declare const TONE_ICONS: Record<string, string>;
33
+ /** The tone glyph, falling back to the info mark for an unknown tone. */
34
+ export declare const toneIcon: (tone: string) => string;
@@ -5,6 +5,10 @@
5
5
  * readers, and both must say the *same* words: a reader who hears "In progress"
6
6
  * from an alert should not hear "Inprogress" from a toast reporting the same
7
7
  * thing. One definition is what keeps them in step.
8
+ *
9
+ * The glyphs live here for the same reason. Both surfaces render them, and a
10
+ * success tick that differs between an alert and a toast reporting the same
11
+ * outcome is a defect a reader would notice before either team did.
8
12
  */
9
13
  /**
10
14
  * The tone as a word, for assistive technology.
@@ -29,3 +33,22 @@ export const toneAccessibleLabel = (tone) => {
29
33
  return tone.charAt(0).toUpperCase() + tone.slice(1);
30
34
  }
31
35
  };
36
+ /**
37
+ * Status glyphs, one per tone.
38
+ *
39
+ * These surfaces are read at a glance, often out of the corner of an eye, so
40
+ * the shape has to carry the meaning before the colour does — a round tick and
41
+ * a warning triangle are distinguishable to a reader who cannot separate green
42
+ * from amber. That matters most where the fill is faint: `box-alert` tints its
43
+ * background by only 10%, which on its own is a weak signal.
44
+ *
45
+ * Literal markup, never author input, so innerHTML is safe here.
46
+ */
47
+ export const TONE_ICONS = {
48
+ info: `<svg viewBox="0 0 20 20" fill="currentColor" focusable="false"><path d="M10 2a8 8 0 100 16 8 8 0 000-16zm0 3.4a1.15 1.15 0 110 2.3 1.15 1.15 0 010-2.3zM11.1 15H8.9V9.3h2.2V15z"/></svg>`,
49
+ success: `<svg viewBox="0 0 20 20" fill="currentColor" focusable="false"><path d="M10 2a8 8 0 100 16 8 8 0 000-16zm4.06 5.86-5 5.2a1 1 0 01-1.44 0L5.94 11.3A1 1 0 117.38 9.9l1.04 1.08 4.28-4.52a1 1 0 011.36 1.38z"/></svg>`,
50
+ warning: `<svg viewBox="0 0 20 20" fill="currentColor" focusable="false"><path d="M9.13 2.6 1.4 15.9a1 1 0 00.87 1.5h15.46a1 1 0 00.87-1.5L10.87 2.6a1 1 0 00-1.74 0zM11.1 15.4H8.9v-2.2h2.2v2.2zm0-3.4H8.9V7.6h2.2V12z"/></svg>`,
51
+ error: `<svg viewBox="0 0 20 20" fill="currentColor" focusable="false"><path d="M10 2a8 8 0 100 16 8 8 0 000-16zm0 2c1.29 0 2.48.41 3.46 1.1L5.1 13.46A6 6 0 0110 4zm0 12c-1.29 0-2.48-.41-3.46-1.1l8.36-8.36A6 6 0 0110 16z"/></svg>`,
52
+ };
53
+ /** The tone glyph, falling back to the info mark for an unknown tone. */
54
+ export const toneIcon = (tone) => TONE_ICONS[tone] ?? TONE_ICONS.info;
@@ -27,6 +27,31 @@ export declare class Calendar extends BaseElement {
27
27
  private isOutOfRange;
28
28
  private goToMonth;
29
29
  private shiftMonth;
30
+ /**
31
+ * `single` (default) or `range`.
32
+ *
33
+ * Range mode uses `start`/`end` rather than overloading `value`: a single
34
+ * attribute holding two dates would have to invent a separator, and every
35
+ * host would then have to parse it back out.
36
+ */
37
+ get mode(): string;
38
+ set mode(next: string);
39
+ /** First date of the range, ISO. */
40
+ get start(): string;
41
+ set start(next: string);
42
+ /** Last date of the range, ISO. Empty while only one end has been picked. */
43
+ get end(): string;
44
+ set end(next: string);
45
+ /**
46
+ * Advance the range by one click.
47
+ *
48
+ * Three states, cycling: nothing chosen picks a start; a start alone closes
49
+ * the range; a complete range starts over. Picking an earlier day as the
50
+ * second click swaps the ends rather than rejecting it — a reader dragging
51
+ * backwards through a calendar means the same thing as dragging forwards,
52
+ * and refusing it would be pedantry.
53
+ */
54
+ private selectRange;
30
55
  private selectDate;
31
56
  /** Move roving focus by `delta` days, following into the neighbouring month if needed. */
32
57
  private moveActive;
@@ -172,6 +172,15 @@ const calendarStyles = `
172
172
  background: var(--boe-token-surface-surface-brand-hover, #006ae9);
173
173
  }
174
174
 
175
+ /* The span between the endpoints. A tint rather than the brand fill, so the
176
+ two chosen dates stay the things the eye lands on — the middle is context,
177
+ not a selection the reader made. */
178
+ [part~="day"][data-in-range="true"]:not([data-selected="true"]) {
179
+ background: var(--boe-token-surface-item-surface-selected, #f2f7fd);
180
+ border-color: transparent;
181
+ color: var(--boe-token-text-text, #222222);
182
+ }
183
+
175
184
  [part~="day"]:focus-visible {
176
185
  outline: none;
177
186
  box-shadow: 0 0 0 3px color-mix(in srgb, var(--boe-token-surface-surface-brand, #0061d5) 24%, transparent);
@@ -185,7 +194,7 @@ const calendarStyles = `
185
194
  export class Calendar extends BaseElement {
186
195
  static tagName = DEFAULT_TAG_NAME;
187
196
  static get observedAttributes() {
188
- return ["disabled", "max", "min", "month", "today", "value"];
197
+ return ["disabled", "end", "max", "min", "mode", "month", "start", "today", "value"];
189
198
  }
190
199
  /** The date the roving tabindex currently points at. */
191
200
  activeDate = null;
@@ -283,11 +292,83 @@ export class Calendar extends BaseElement {
283
292
  const next = new Date(Date.UTC(y, m - 1 + delta, 1));
284
293
  this.goToMonth(next.getUTCFullYear(), next.getUTCMonth() + 1);
285
294
  }
295
+ /**
296
+ * `single` (default) or `range`.
297
+ *
298
+ * Range mode uses `start`/`end` rather than overloading `value`: a single
299
+ * attribute holding two dates would have to invent a separator, and every
300
+ * host would then have to parse it back out.
301
+ */
302
+ get mode() {
303
+ return this.getAttribute("mode") === "range" ? "range" : "single";
304
+ }
305
+ set mode(next) {
306
+ this.setAttribute("mode", next);
307
+ }
308
+ /** First date of the range, ISO. */
309
+ get start() {
310
+ return this.getAttribute("start") ?? "";
311
+ }
312
+ set start(next) {
313
+ if (!next) {
314
+ this.removeAttribute("start");
315
+ return;
316
+ }
317
+ this.setAttribute("start", next);
318
+ }
319
+ /** Last date of the range, ISO. Empty while only one end has been picked. */
320
+ get end() {
321
+ return this.getAttribute("end") ?? "";
322
+ }
323
+ set end(next) {
324
+ if (!next) {
325
+ this.removeAttribute("end");
326
+ return;
327
+ }
328
+ this.setAttribute("end", next);
329
+ }
330
+ /**
331
+ * Advance the range by one click.
332
+ *
333
+ * Three states, cycling: nothing chosen picks a start; a start alone closes
334
+ * the range; a complete range starts over. Picking an earlier day as the
335
+ * second click swaps the ends rather than rejecting it — a reader dragging
336
+ * backwards through a calendar means the same thing as dragging forwards,
337
+ * and refusing it would be pedantry.
338
+ */
339
+ selectRange(iso) {
340
+ const start = this.start;
341
+ const end = this.end;
342
+ if (!start || end) {
343
+ this.start = iso;
344
+ this.end = "";
345
+ }
346
+ else if (iso < start) {
347
+ this.end = start;
348
+ this.start = iso;
349
+ }
350
+ else {
351
+ this.end = iso;
352
+ }
353
+ this.dispatchEvent(new CustomEvent("range-changed", {
354
+ bubbles: true,
355
+ composed: true,
356
+ detail: { end: this.end, start: this.start },
357
+ }));
358
+ }
286
359
  selectDate(date) {
287
360
  const iso = toISO(date);
288
361
  if (this.disabled || this.isOutOfRange(iso)) {
289
362
  return;
290
363
  }
364
+ if (this.mode === "range") {
365
+ this.selectRange(iso);
366
+ this.setAttribute("month", `${date.y}-${pad(date.m)}`);
367
+ this.activeDate = date;
368
+ this.update();
369
+ this.focusActive();
370
+ return;
371
+ }
291
372
  this.setAttribute("value", iso);
292
373
  // Keep the grid on the month of the chosen day.
293
374
  this.setAttribute("month", `${date.y}-${pad(date.m)}`);
@@ -446,6 +527,9 @@ export class Calendar extends BaseElement {
446
527
  }
447
528
  const { y, m } = this.displayedMonth();
448
529
  const selected = parseISO(this.value);
530
+ const isRange = this.mode === "range";
531
+ const rangeStart = isRange ? this.start : "";
532
+ const rangeEnd = isRange ? this.end : "";
449
533
  const active = this.resolveActiveDate();
450
534
  const activeIso = toISO({ y, m, d: clampDay(y, m, active.d) });
451
535
  const todayIso = toISO(this.resolveToday());
@@ -459,7 +543,14 @@ export class Calendar extends BaseElement {
459
543
  }
460
544
  for (let day = 1; day <= total; day += 1) {
461
545
  const iso = toISO({ y, m, d: day });
462
- const isSelected = selected ? toISO(selected) === iso : false;
546
+ const isSelected = isRange
547
+ ? iso === rangeStart || iso === rangeEnd
548
+ : selected
549
+ ? toISO(selected) === iso
550
+ : false;
551
+ // Strictly between the ends: the endpoints are "selected", not "in range",
552
+ // so they keep the brand fill rather than the tint.
553
+ const inRange = isRange && rangeStart !== "" && rangeEnd !== "" && iso > rangeStart && iso < rangeEnd;
463
554
  const isToday = iso === todayIso;
464
555
  const isActive = iso === activeIso;
465
556
  const outOfRange = this.isOutOfRange(iso);
@@ -470,6 +561,7 @@ export class Calendar extends BaseElement {
470
561
  role="gridcell"
471
562
  data-date="${iso}"
472
563
  data-selected="${isSelected ? "true" : "false"}"
564
+ data-in-range="${inRange ? "true" : "false"}"
473
565
  data-today="${isToday ? "true" : "false"}"
474
566
  tabindex="${isActive && !disabled ? "0" : "-1"}"
475
567
  aria-selected="${isSelected ? "true" : "false"}"
@@ -0,0 +1,58 @@
1
+ import { BaseElement } from "../../core/index.js";
2
+ /** One choice. `description` is the second line; `disabled` greys it out. */
3
+ export interface TileOption {
4
+ description?: string;
5
+ disabled?: boolean;
6
+ id: string;
7
+ label: string;
8
+ }
9
+ /** Attribute payloads are author input — validate every record. */
10
+ export declare const isTileOptionRecord: (value: unknown) => value is TileOption;
11
+ /**
12
+ * Choose one option, or several, as cards rather than radios.
13
+ *
14
+ * ```html
15
+ * <box-tile-group name="plan" options='[{"id":"team","label":"Team"}]'></box-tile-group>
16
+ * ```
17
+ *
18
+ * Each tile wraps a real `<input type="radio">` or `<input type="checkbox">`,
19
+ * visually hidden but present in the tab order and the accessibility tree. That
20
+ * is the whole design: grouping, arrow-key navigation between radios, the
21
+ * roving focus rules, and form participation all come from the platform rather
22
+ * than being reimplemented on `<div>`s and then subtly diverging from what a
23
+ * screen reader user expects.
24
+ *
25
+ * Use it where the choice deserves explaining — a plan, a permission level, a
26
+ * retention policy — and a bare radio label would not have room. Where the
27
+ * options are one word each, radios are smaller and better.
28
+ */
29
+ export declare class TileGroup extends BaseElement {
30
+ static readonly tagName: string;
31
+ static get observedAttributes(): string[];
32
+ private groupEl;
33
+ private legendEl;
34
+ private optionsSignature;
35
+ /** The choices, as JSON. */
36
+ get options(): TileOption[];
37
+ set options(value: TileOption[]);
38
+ /** Several selections rather than one. Switches radios for checkboxes. */
39
+ get multiple(): boolean;
40
+ set multiple(value: boolean);
41
+ /** The radio group name. Required for radios to behave as one group. */
42
+ get name(): string;
43
+ set name(value: string);
44
+ /** Selected ids, comma-separated. A single id when not `multiple`. */
45
+ get value(): string;
46
+ set value(next: string);
47
+ /** Selected ids as an array, which is what a host actually wants. */
48
+ get selected(): string[];
49
+ get legend(): string;
50
+ set legend(value: string);
51
+ private optionsKey;
52
+ private rebuild;
53
+ private syncSelection;
54
+ private readSelection;
55
+ protected renderTemplate(): void;
56
+ protected setupListeners(): void;
57
+ protected update(): void;
58
+ }