@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.
- package/dist/components/rel-alert/rel-alert.ts +1 -1
- package/dist/components/rel-autocomplete/rel-autocomplete.ts +33 -2
- package/dist/components/rel-badge/rel-badge.ts +6 -1
- package/dist/components/rel-calendar/rel-calendar.ts +64 -18
- package/dist/components/rel-carousel/rel-carousel.ts +25 -2
- package/dist/components/rel-chip/rel-chip.ts +31 -20
- package/dist/components/rel-date-picker/date-parse.test.ts +116 -0
- package/dist/components/rel-date-picker/date-parse.ts +178 -0
- package/dist/components/rel-date-picker/rel-date-picker.ts +529 -90
- package/dist/components/rel-drawer/rel-drawer.ts +6 -3
- package/dist/components/rel-menu/rel-menu.ts +6 -0
- package/dist/components/rel-otp/rel-otp.ts +12 -2
- package/dist/components/rel-pagination/rel-pagination.ts +13 -0
- package/dist/components/rel-popover/rel-popover.ts +33 -2
- package/dist/components/rel-progress/rel-progress.ts +13 -4
- package/dist/components/rel-radio/README.md +6 -3
- package/dist/components/rel-radio/rel-radio.ts +236 -60
- package/dist/components/rel-range-picker/rel-range-picker.ts +452 -95
- package/dist/components/rel-segmented-button/rel-segmented-button.ts +5 -1
- package/dist/components/rel-select/rel-select.ts +44 -13
- package/dist/components/rel-sheet/rel-sheet.ts +163 -18
- package/dist/components/rel-sidebar/rel-sidebar.ts +7 -1
- package/dist/components/rel-skeleton/rel-skeleton.ts +9 -0
- package/dist/components/rel-slider/rel-slider.ts +6 -3
- package/dist/components/rel-snackbar/rel-snackbar.ts +20 -16
- package/dist/components/rel-stepper/rel-stepper.ts +3 -1
- package/dist/components/rel-switch/rel-switch.ts +3 -1
- package/dist/components/rel-textfield/rel-textfield.ts +263 -75
- package/dist/components/rel-time-picker/rel-time-picker.ts +450 -94
- package/dist/components/rel-time-picker/time-parse.test.ts +67 -0
- package/dist/components/rel-time-picker/time-parse.ts +85 -0
- package/dist/components/rel-toast/rel-toast.ts +4 -2
- package/dist/components/rel-tooltip/README.md +13 -5
- package/dist/components/rel-tooltip/rel-tooltip.ts +3 -0
- package/dist/components/rel-upload/rel-upload.ts +25 -0
- package/dist/index.d.mts +241 -11
- package/dist/index.d.ts +241 -11
- package/dist/index.js +2573 -598
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +2614 -640
- package/dist/index.mjs.map +1 -1
- 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-
|
|
69
|
-
- `--rel-
|
|
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
|
-
-
|
|
82
|
-
-
|
|
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
|
|
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 ────────────────────────────────────────────────────────────────
|