@reliability-design/web 1.4.5 → 1.4.6

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 (42) hide show
  1. package/dist/components/rel-alert/rel-alert.ts +1 -1
  2. package/dist/components/rel-autocomplete/rel-autocomplete.ts +33 -2
  3. package/dist/components/rel-badge/rel-badge.ts +6 -1
  4. package/dist/components/rel-calendar/rel-calendar.ts +64 -18
  5. package/dist/components/rel-carousel/rel-carousel.ts +25 -2
  6. package/dist/components/rel-chip/rel-chip.ts +31 -20
  7. package/dist/components/rel-date-picker/date-parse.test.ts +116 -0
  8. package/dist/components/rel-date-picker/date-parse.ts +178 -0
  9. package/dist/components/rel-date-picker/rel-date-picker.ts +529 -90
  10. package/dist/components/rel-drawer/rel-drawer.ts +6 -3
  11. package/dist/components/rel-menu/rel-menu.ts +6 -0
  12. package/dist/components/rel-otp/rel-otp.ts +12 -2
  13. package/dist/components/rel-pagination/rel-pagination.ts +13 -0
  14. package/dist/components/rel-popover/rel-popover.ts +33 -2
  15. package/dist/components/rel-progress/rel-progress.ts +13 -4
  16. package/dist/components/rel-radio/README.md +6 -3
  17. package/dist/components/rel-radio/rel-radio.ts +236 -60
  18. package/dist/components/rel-range-picker/rel-range-picker.ts +452 -95
  19. package/dist/components/rel-segmented-button/rel-segmented-button.ts +5 -1
  20. package/dist/components/rel-select/rel-select.ts +44 -13
  21. package/dist/components/rel-sheet/rel-sheet.ts +163 -18
  22. package/dist/components/rel-sidebar/rel-sidebar.ts +7 -1
  23. package/dist/components/rel-skeleton/rel-skeleton.ts +9 -0
  24. package/dist/components/rel-slider/rel-slider.ts +6 -3
  25. package/dist/components/rel-snackbar/rel-snackbar.ts +20 -16
  26. package/dist/components/rel-stepper/rel-stepper.ts +3 -1
  27. package/dist/components/rel-switch/rel-switch.ts +3 -1
  28. package/dist/components/rel-textfield/rel-textfield.ts +263 -75
  29. package/dist/components/rel-time-picker/rel-time-picker.ts +450 -94
  30. package/dist/components/rel-time-picker/time-parse.test.ts +67 -0
  31. package/dist/components/rel-time-picker/time-parse.ts +85 -0
  32. package/dist/components/rel-toast/rel-toast.ts +4 -2
  33. package/dist/components/rel-tooltip/README.md +13 -5
  34. package/dist/components/rel-tooltip/rel-tooltip.ts +3 -0
  35. package/dist/components/rel-upload/rel-upload.ts +25 -0
  36. package/dist/index.d.mts +241 -11
  37. package/dist/index.d.ts +241 -11
  38. package/dist/index.js +2573 -598
  39. package/dist/index.js.map +1 -1
  40. package/dist/index.mjs +2614 -640
  41. package/dist/index.mjs.map +1 -1
  42. package/package.json +2 -2
@@ -0,0 +1,67 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { parseTimeInput } from "./time-parse";
4
+
5
+ const time = (hour: number, minute: number, second = 0) => ({
6
+ kind: "time" as const,
7
+ hour,
8
+ minute,
9
+ second,
10
+ });
11
+
12
+ test("now shortcut: n / now, any case, padded", () => {
13
+ assert.deepEqual(parseTimeInput("n"), { kind: "now" });
14
+ assert.deepEqual(parseTimeInput("N"), { kind: "now" });
15
+ assert.deepEqual(parseTimeInput(" now "), { kind: "now" });
16
+ });
17
+
18
+ test("bare hour", () => {
19
+ assert.deepEqual(parseTimeInput("9"), time(9, 0));
20
+ assert.deepEqual(parseTimeInput("09"), time(9, 0));
21
+ assert.deepEqual(parseTimeInput("17"), time(17, 0));
22
+ assert.deepEqual(parseTimeInput("0"), time(0, 0));
23
+ assert.deepEqual(parseTimeInput("23"), time(23, 0));
24
+ });
25
+
26
+ test("compact digits HMM / HHMM / HHMMSS", () => {
27
+ assert.deepEqual(parseTimeInput("930"), time(9, 30));
28
+ assert.deepEqual(parseTimeInput("0930"), time(9, 30));
29
+ assert.deepEqual(parseTimeInput("1705"), time(17, 5));
30
+ assert.deepEqual(parseTimeInput("093015"), time(9, 30, 15));
31
+ });
32
+
33
+ test("separated: colon, dot, space", () => {
34
+ assert.deepEqual(parseTimeInput("9:30"), time(9, 30));
35
+ assert.deepEqual(parseTimeInput("9.30"), time(9, 30));
36
+ assert.deepEqual(parseTimeInput("9 30"), time(9, 30));
37
+ assert.deepEqual(parseTimeInput("17:05:30"), time(17, 5, 30));
38
+ });
39
+
40
+ test("meridiem suffixes", () => {
41
+ assert.deepEqual(parseTimeInput("9 pm"), time(21, 0));
42
+ assert.deepEqual(parseTimeInput("9:30p"), time(21, 30));
43
+ assert.deepEqual(parseTimeInput("9:30 PM"), time(21, 30));
44
+ assert.deepEqual(parseTimeInput("9a"), time(9, 0));
45
+ assert.deepEqual(parseTimeInput("930pm"), time(21, 30));
46
+ });
47
+
48
+ test("12am → 0h, 12pm → 12h", () => {
49
+ assert.deepEqual(parseTimeInput("12am"), time(0, 0));
50
+ assert.deepEqual(parseTimeInput("12 pm"), time(12, 0));
51
+ assert.deepEqual(parseTimeInput("12:30 am"), time(0, 30));
52
+ });
53
+
54
+ test("invalid input rejected", () => {
55
+ assert.equal(parseTimeInput(""), null);
56
+ assert.equal(parseTimeInput(" "), null);
57
+ assert.equal(parseTimeInput("asdf"), null);
58
+ assert.equal(parseTimeInput("25:00"), null);
59
+ assert.equal(parseTimeInput("9:70"), null);
60
+ assert.equal(parseTimeInput("9:30:70"), null);
61
+ assert.equal(parseTimeInput("24"), null);
62
+ assert.equal(parseTimeInput("13pm"), null); // 12h clock with meridiem
63
+ assert.equal(parseTimeInput("0pm"), null);
64
+ assert.equal(parseTimeInput("12345"), null); // 5 digits is ambiguous
65
+ assert.equal(parseTimeInput("9:30:15:10"), null);
66
+ assert.equal(parseTimeInput("pm"), null);
67
+ });
@@ -0,0 +1,85 @@
1
+ /**
2
+ * Free-form time parsing for rel-time-picker's typed keyboard entry.
3
+ *
4
+ * Pure (no DOM, no Date.now) so it is unit-testable in Node. The component
5
+ * owns range checks (minTime/maxTime, minuteStep snapping) and the "now"
6
+ * clock — this module only turns text into a time or a shortcut intent.
7
+ *
8
+ * Accepted input (case/whitespace-insensitive):
9
+ * "9" → 9:00 "930" → 9:30
10
+ * "0930" → 9:30 "093015" → 9:30:15
11
+ * "9:30" → 9:30 "9.30" → 9:30
12
+ * "9 30" → 9:30 "17:05:30" → 17:05:30
13
+ * "9 pm" → 21:00 "9:30p" → 21:30
14
+ * "12am" → 0:00 "12 pm" → 12:00
15
+ * "n", "now" → the Now shortcut
16
+ */
17
+
18
+ export type TimeInputResult =
19
+ | { kind: "time"; hour: number; minute: number; second: number }
20
+ | { kind: "now" }
21
+ | null;
22
+
23
+ export function parseTimeInput(text: string): TimeInputResult {
24
+ let input = text.trim().toLowerCase();
25
+ if (!input) return null;
26
+
27
+ if (input === "n" || input === "now") return { kind: "now" };
28
+
29
+ // Meridiem suffix: "am"/"pm"/"a"/"p", optionally space-separated.
30
+ let meridiem: "am" | "pm" | null = null;
31
+ const meridiemMatch = input.match(/\s*(am|pm|a|p)\.?$/);
32
+ if (meridiemMatch) {
33
+ meridiem = meridiemMatch[1].startsWith("a") ? "am" : "pm";
34
+ input = input.slice(0, meridiemMatch.index).trim();
35
+ }
36
+ if (!input) return null;
37
+
38
+ let hour: number;
39
+ let minute = 0;
40
+ let second = 0;
41
+
42
+ if (/^\d+$/.test(input)) {
43
+ // Bare digits: H, HH, HMM, HHMM, HHMMSS.
44
+ switch (input.length) {
45
+ case 1:
46
+ case 2:
47
+ hour = parseInt(input, 10);
48
+ break;
49
+ case 3:
50
+ hour = parseInt(input.slice(0, 1), 10);
51
+ minute = parseInt(input.slice(1), 10);
52
+ break;
53
+ case 4:
54
+ hour = parseInt(input.slice(0, 2), 10);
55
+ minute = parseInt(input.slice(2), 10);
56
+ break;
57
+ case 6:
58
+ hour = parseInt(input.slice(0, 2), 10);
59
+ minute = parseInt(input.slice(2, 4), 10);
60
+ second = parseInt(input.slice(4), 10);
61
+ break;
62
+ default:
63
+ return null;
64
+ }
65
+ } else {
66
+ // Separated parts: "H:MM", "H.MM", "H MM", "HH:MM:SS".
67
+ const parts = input.split(/[:.\s]+/);
68
+ if (parts.length < 2 || parts.length > 3) return null;
69
+ if (!parts.every((p) => /^\d{1,2}$/.test(p))) return null;
70
+ hour = parseInt(parts[0], 10);
71
+ minute = parseInt(parts[1], 10);
72
+ if (parts.length === 3) second = parseInt(parts[2], 10);
73
+ }
74
+
75
+ if (meridiem) {
76
+ // 12-hour clock: hour must be 1–12; 12am → 0, 12pm → 12.
77
+ if (hour < 1 || hour > 12) return null;
78
+ hour = meridiem === "am" ? hour % 12 : (hour % 12) + 12;
79
+ } else if (hour > 23) {
80
+ return null;
81
+ }
82
+ if (minute > 59 || second > 59) return null;
83
+
84
+ return { kind: "time", hour, minute, second };
85
+ }
@@ -1,5 +1,6 @@
1
1
  import { LitElement, html, css } from "lit";
2
2
  import { customElement, property, state } from "lit/decorators.js";
3
+ import { announce } from "../../utils/announce";
3
4
 
4
5
  export type ToastType = "default" | "error";
5
6
 
@@ -35,7 +36,7 @@ export class RelToast extends LitElement {
35
36
  align-items: center;
36
37
  gap: var(--rel-spacing-xs);
37
38
  background-color: var(--rel-bg-neutral-inverse-1);
38
- color: var(--rel-text-inverse);
39
+ color: var(--rel-text-inverse);
39
40
  border-radius: var(--rel-border-radius-sm);
40
41
  padding: var(--rel-spacing-sm) var(--rel-spacing-md);
41
42
  box-shadow: var(--rel-shadows-shadow-300);
@@ -135,6 +136,7 @@ export class RelToast extends LitElement {
135
136
  if (!changed.has('show')) return;
136
137
  if (this.show) {
137
138
  this.visible = true;
139
+ announce(this.message, this.type === "error" ? "assertive" : "polite");
138
140
  if (this._dismissTimer) clearTimeout(this._dismissTimer);
139
141
  if (this.duration > 0) {
140
142
  this._dismissTimer = setTimeout(() => this.close(), this.duration);
@@ -185,7 +187,7 @@ export class RelToast extends LitElement {
185
187
 
186
188
  render() {
187
189
  return html`
188
- <div part="base" class="toast ${this.type} ${this.visible ? "" : "hidden"}" role="status" aria-live="polite">
190
+ <div part="base" class="toast ${this.type} ${this.visible ? "" : "hidden"}" role="status" aria-live="polite" aria-atomic="true">
189
191
  <span part="label" class="toast-message">${this.message}</span>
190
192
 
191
193
  ${this.actionLabel
@@ -65,12 +65,19 @@ None exposed.
65
65
  - `--rel-spacing-sm` - Horizontal padding
66
66
 
67
67
  ### Color Tokens
68
- - `--rel-bg-neutral-inverse-1` - Background color (dark gray)
69
- - `--rel-bg-surface` - Text color
68
+ - `--rel-bg-neutral-inverse-2` - Background color (near-black in light theme, white in dark theme)
69
+ - `--rel-text-inverse` - Text color (white in light theme, near-black in dark theme)
70
+ - `--rel-colors-dark-alpha-3` - Elevation shadow color (Figma `shadow/sm`: 0 2px 4px at 10% black; applied via `drop-shadow` so the tail casts the same shadow as the bubble)
70
71
 
71
72
  ### Border Tokens
72
73
  - `--rel-border-radius-sm` - Border radius
73
74
 
75
+ ### Theming
76
+ Colors use inverse semantic tokens, so the tooltip adapts automatically when
77
+ `data-theme="dark"` (or `.dark`) is set on an ancestor — no page refresh needed.
78
+ Dark-theme rendering (white bubble, dark text) is unchanged by the elevation
79
+ shadow, which is effectively invisible on dark backgrounds.
80
+
74
81
  ## Accessibility
75
82
  - Uses `role="tooltip"` on the tooltip bubble
76
83
  - `aria-hidden="true"` on tooltip (content is supplemental, not critical)
@@ -78,11 +85,12 @@ None exposed.
78
85
  - For critical information, consider alternative patterns (e.g., visible labels or expandable sections)
79
86
 
80
87
  ### Keyboard Interactions
81
- - No keyboard interactions (hover-only)
82
- - For keyboard accessibility, ensure slotted content is focusable and consider showing tooltip on focus
88
+ - Tooltip shows on `:focus-within` as well as hover, so keyboard users see it when the slotted trigger receives focus (ensure the trigger is focusable)
89
+ - `Escape` dismisses the tooltip (APG); it re-arms once hover/focus leaves and returns
90
+ - Keyboard-triggered tooltips use the same theme-aware styling as hover-triggered ones
83
91
 
84
92
  ### Focus Behavior
85
- - Tooltip activation is hover-only by default
93
+ - Tooltip activates on hover or focus within the trigger
86
94
  - Does not interfere with focus on slotted content
87
95
 
88
96
  ## Notes / Edge Cases
@@ -43,6 +43,9 @@ export class RelTooltip extends LitElement {
43
43
  pointer-events: none;
44
44
  transition: opacity 0.2s ease;
45
45
  overflow: visible;
46
+ /* Figma shadow/sm; drop-shadow (not box-shadow) so the border-drawn
47
+ tail casts the same shadow as the bubble. */
48
+ filter: drop-shadow(0px 2px 4px var(--rel-colors-dark-alpha-3, rgba(11, 11, 11, 0.1)));
46
49
  }
47
50
 
48
51
  /* Figma Type=Multi Line: wrap inside a bounded bubble */
@@ -3,6 +3,7 @@ import { customElement, property, state, query } from "lit/decorators.js";
3
3
  import "@reliability-design/icons/rel-icon";
4
4
  import "../rel-alert/rel-alert";
5
5
  import "../rel-progress/rel-progress";
6
+ import { announce } from "../../utils/announce";
6
7
 
7
8
  /** Per-file lifecycle state. Mirrors the Figma "Upload File Row" variants 1:1. */
8
9
  export type UploadStatus = "ready" | "uploading" | "success" | "error";
@@ -143,6 +144,8 @@ export class RelUpload extends LitElement {
143
144
  private aborter?: AbortController;
144
145
  private delayTimer?: ReturnType<typeof setTimeout>;
145
146
  private lastSignature = "";
147
+ private lastHeading = "";
148
+ private lastAlertText = "";
146
149
 
147
150
  static styles = css`
148
151
  :host {
@@ -601,6 +604,13 @@ export class RelUpload extends LitElement {
601
604
  if (rejected.length) this.emit("rel-invalid", { rejected });
602
605
  this.emit("rel-files-added", { added, rejected });
603
606
  this.emit("rel-change", { files: this.files });
607
+ const accepted = added.length - rejected.length;
608
+ if (accepted > 0) {
609
+ announce(`${accepted} file${accepted === 1 ? "" : "s"} added`);
610
+ }
611
+ if (rejected.length > 0) {
612
+ announce(`${rejected.length} file${rejected.length === 1 ? "" : "s"} rejected`, "assertive");
613
+ }
604
614
  this.notify();
605
615
  }
606
616
 
@@ -727,6 +737,7 @@ export class RelUpload extends LitElement {
727
737
  this.files = this.files.filter((f) => f.id !== id);
728
738
  this.emit("rel-remove", { id, file: target?.file });
729
739
  this.emit("rel-change", { files: this.files });
740
+ if (target) announce(`${target.name} removed`);
730
741
  this.notify();
731
742
  }
732
743
 
@@ -738,6 +749,7 @@ export class RelUpload extends LitElement {
738
749
  this.delayed = false;
739
750
  if (this.inputEl) this.inputEl.value = "";
740
751
  this.emit("rel-change", { files: this.files });
752
+ announce("File list cleared");
741
753
  this.notify();
742
754
  }
743
755
 
@@ -803,6 +815,19 @@ export class RelUpload extends LitElement {
803
815
 
804
816
  updated() {
805
817
  this.notify();
818
+ const heading = this.files.length ? this.listHeading : "";
819
+ if (heading !== this.lastHeading) {
820
+ this.lastHeading = heading;
821
+ if (heading) {
822
+ announce(heading, this.phase === "failed" ? "assertive" : "polite");
823
+ }
824
+ }
825
+ const alert = this.alertContent;
826
+ const alertText = alert ? `${alert.message1}. ${alert.message2}` : "";
827
+ if (alertText !== this.lastAlertText) {
828
+ this.lastAlertText = alertText;
829
+ if (alertText) announce(alertText, "assertive");
830
+ }
806
831
  }
807
832
 
808
833
  // ── render ────────────────────────────────────────────────────────────────