@remit/ui 0.0.125 → 0.0.126
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/package.json
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
2
|
import { describe, it } from "node:test";
|
|
3
|
+
import { JSDOM } from "jsdom";
|
|
3
4
|
import { createElement } from "react";
|
|
4
5
|
import { renderToString } from "react-dom/server";
|
|
5
6
|
import type { CalendarDescriptor, EventDraft } from "./calendar-types.js";
|
|
@@ -49,6 +50,21 @@ const render = (props: Partial<Parameters<typeof EventEditor>[0]>) =>
|
|
|
49
50
|
}),
|
|
50
51
|
);
|
|
51
52
|
|
|
53
|
+
/** The same render read as a document, so no assertion depends on attribute order. */
|
|
54
|
+
const parse = (props: Partial<Parameters<typeof EventEditor>[0]>) =>
|
|
55
|
+
JSDOM.fragment(render(props));
|
|
56
|
+
|
|
57
|
+
const field = (dom: DocumentFragment, label: string) =>
|
|
58
|
+
dom.querySelector<HTMLInputElement>(`input[aria-label="${label}"]`);
|
|
59
|
+
|
|
60
|
+
const saveControl = (dom: DocumentFragment, label = "Add") =>
|
|
61
|
+
[...dom.querySelectorAll("button")].find(
|
|
62
|
+
(button) => button.textContent === label,
|
|
63
|
+
);
|
|
64
|
+
|
|
65
|
+
const alertText = (dom: DocumentFragment) =>
|
|
66
|
+
dom.querySelector('[role="alert"]')?.textContent ?? "";
|
|
67
|
+
|
|
52
68
|
describe("EventEditor", () => {
|
|
53
69
|
it("folds everything but title, when and calendar", () => {
|
|
54
70
|
const html = render({});
|
|
@@ -80,4 +96,39 @@ describe("EventEditor", () => {
|
|
|
80
96
|
it("names the save action the caller asked for", () => {
|
|
81
97
|
assert.match(render({ saveLabel: "Save changes" }), /Save changes/);
|
|
82
98
|
});
|
|
99
|
+
|
|
100
|
+
it("says so and holds the save when the end is before the start", () => {
|
|
101
|
+
const dom = parse({
|
|
102
|
+
draft: { ...draft, startTime: "23:00", endTime: "01:00" },
|
|
103
|
+
});
|
|
104
|
+
assert.match(alertText(dom), /Ends before it starts/);
|
|
105
|
+
assert.equal(saveControl(dom)?.disabled, true);
|
|
106
|
+
assert.equal(
|
|
107
|
+
field(dom, "Start time")?.getAttribute("aria-invalid"),
|
|
108
|
+
"true",
|
|
109
|
+
);
|
|
110
|
+
assert.equal(field(dom, "End time")?.getAttribute("aria-invalid"), "true");
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it("keeps what was typed rather than swapping the two fields", () => {
|
|
114
|
+
const dom = parse({
|
|
115
|
+
draft: { ...draft, startTime: "23:00", endTime: "01:00" },
|
|
116
|
+
});
|
|
117
|
+
assert.equal(field(dom, "Start time")?.value, "23:00");
|
|
118
|
+
assert.equal(field(dom, "End time")?.value, "01:00");
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it("lets a normal range save", () => {
|
|
122
|
+
const dom = parse({});
|
|
123
|
+
assert.equal(alertText(dom), "");
|
|
124
|
+
assert.equal(saveControl(dom)?.disabled, false);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it("has no range to reject once the entry is all day", () => {
|
|
128
|
+
const dom = parse({
|
|
129
|
+
draft: { ...draft, startTime: "23:00", endTime: "01:00", allDay: true },
|
|
130
|
+
});
|
|
131
|
+
assert.equal(alertText(dom), "");
|
|
132
|
+
assert.equal(saveControl(dom)?.disabled, false);
|
|
133
|
+
});
|
|
83
134
|
});
|
|
@@ -53,8 +53,22 @@ const seed: EventDraft = {
|
|
|
53
53
|
repeat: "",
|
|
54
54
|
};
|
|
55
55
|
|
|
56
|
-
|
|
57
|
-
|
|
56
|
+
/** 23:00 to 01:00, which one date cannot hold. */
|
|
57
|
+
const backwards: EventDraft = {
|
|
58
|
+
...seed,
|
|
59
|
+
title: "Release window",
|
|
60
|
+
startTime: "23:00",
|
|
61
|
+
endTime: "01:00",
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
function Live({
|
|
65
|
+
startExpanded,
|
|
66
|
+
seed: initial = seed,
|
|
67
|
+
}: {
|
|
68
|
+
startExpanded: boolean;
|
|
69
|
+
seed?: EventDraft;
|
|
70
|
+
}) {
|
|
71
|
+
const [draft, setDraft] = useState(initial);
|
|
58
72
|
const [expanded, setExpanded] = useState(startExpanded);
|
|
59
73
|
return (
|
|
60
74
|
<div className="max-w-sm rounded-lg border border-line bg-surface-raised p-4">
|
|
@@ -76,6 +90,22 @@ export const Folded: Story = { render: () => <Live startExpanded={false} /> };
|
|
|
76
90
|
/** Everything the folded form was hiding. */
|
|
77
91
|
export const Unfolded: Story = { render: () => <Live startExpanded /> };
|
|
78
92
|
|
|
93
|
+
/**
|
|
94
|
+
* An end before the start is one date read backwards, not a night that runs
|
|
95
|
+
* over. The form keeps what was typed, names the problem under the fields and
|
|
96
|
+
* holds the save until it is fixed.
|
|
97
|
+
*/
|
|
98
|
+
export const EndBeforeStart: Story = {
|
|
99
|
+
render: () => <Live startExpanded={false} seed={backwards} />,
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
/** All day takes the clock fields away, so there is no range left to reject. */
|
|
103
|
+
export const AllDay: Story = {
|
|
104
|
+
render: () => (
|
|
105
|
+
<Live startExpanded={false} seed={{ ...backwards, allDay: true }} />
|
|
106
|
+
),
|
|
107
|
+
};
|
|
108
|
+
|
|
79
109
|
/** The same form sized for a bottom sheet: every control a thumb target. */
|
|
80
110
|
export const Touch: Story = {
|
|
81
111
|
render: () => {
|
|
@@ -71,53 +71,90 @@ export function EventTitleField({
|
|
|
71
71
|
);
|
|
72
72
|
}
|
|
73
73
|
|
|
74
|
+
/**
|
|
75
|
+
* A draft carries one date, so an end before the start is not a night that runs
|
|
76
|
+
* over — it is a span the calendar reads backwards. The form says so and refuses
|
|
77
|
+
* to save it rather than swapping the two fields behind whoever typed them.
|
|
78
|
+
*/
|
|
79
|
+
export function endsBeforeStart(draft: EventDraft): boolean {
|
|
80
|
+
if (draft.allDay) return false;
|
|
81
|
+
if (draft.startTime === "" || draft.endTime === "") return false;
|
|
82
|
+
return draft.endTime < draft.startTime;
|
|
83
|
+
}
|
|
84
|
+
|
|
74
85
|
export function EventWhenField({ draft, onChange, touch }: EventFieldProps) {
|
|
75
86
|
const fieldHeight = touch ? "min-h-11" : "";
|
|
87
|
+
const backwards = endsBeforeStart(draft);
|
|
88
|
+
const messageId = useId();
|
|
76
89
|
return (
|
|
77
|
-
<div className="flex flex-
|
|
78
|
-
<
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
<
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
90
|
+
<div className="flex flex-col gap-1.5">
|
|
91
|
+
<div className="flex flex-wrap items-center gap-2">
|
|
92
|
+
<Input
|
|
93
|
+
type="date"
|
|
94
|
+
value={draft.date}
|
|
95
|
+
aria-label="Date"
|
|
96
|
+
onChange={(e) =>
|
|
97
|
+
setField({ draft, onChange }, "date", e.target.value)
|
|
98
|
+
}
|
|
99
|
+
className={cn("w-40", fieldHeight)}
|
|
100
|
+
/>
|
|
101
|
+
{draft.allDay ? (
|
|
102
|
+
<span className="text-sm text-fg-muted">All day</span>
|
|
103
|
+
) : (
|
|
104
|
+
<>
|
|
105
|
+
<Input
|
|
106
|
+
type="time"
|
|
107
|
+
value={draft.startTime}
|
|
108
|
+
aria-label="Start time"
|
|
109
|
+
max={draft.endTime === "" ? undefined : draft.endTime}
|
|
110
|
+
aria-invalid={backwards}
|
|
111
|
+
aria-describedby={backwards ? messageId : undefined}
|
|
112
|
+
onChange={(e) =>
|
|
113
|
+
setField({ draft, onChange }, "startTime", e.target.value)
|
|
114
|
+
}
|
|
115
|
+
className={cn(
|
|
116
|
+
"w-28",
|
|
117
|
+
fieldHeight,
|
|
118
|
+
backwards && "border-danger/60",
|
|
119
|
+
)}
|
|
120
|
+
/>
|
|
121
|
+
<span className="text-sm text-fg-subtle">to</span>
|
|
122
|
+
<Input
|
|
123
|
+
type="time"
|
|
124
|
+
value={draft.endTime}
|
|
125
|
+
aria-label="End time"
|
|
126
|
+
min={draft.startTime === "" ? undefined : draft.startTime}
|
|
127
|
+
aria-invalid={backwards}
|
|
128
|
+
aria-describedby={backwards ? messageId : undefined}
|
|
129
|
+
onChange={(e) =>
|
|
130
|
+
setField({ draft, onChange }, "endTime", e.target.value)
|
|
131
|
+
}
|
|
132
|
+
className={cn(
|
|
133
|
+
"w-28",
|
|
134
|
+
fieldHeight,
|
|
135
|
+
backwards && "border-danger/60",
|
|
136
|
+
)}
|
|
137
|
+
/>
|
|
138
|
+
</>
|
|
139
|
+
)}
|
|
140
|
+
<label className="flex items-center gap-2 text-sm text-fg-muted">
|
|
141
|
+
<input
|
|
142
|
+
type="checkbox"
|
|
143
|
+
checked={draft.allDay}
|
|
103
144
|
onChange={(e) =>
|
|
104
|
-
setField({ draft, onChange }, "
|
|
145
|
+
setField({ draft, onChange }, "allDay", e.target.checked)
|
|
105
146
|
}
|
|
106
|
-
className=
|
|
147
|
+
className="size-4 accent-current"
|
|
107
148
|
/>
|
|
108
|
-
|
|
149
|
+
All day
|
|
150
|
+
</label>
|
|
151
|
+
</div>
|
|
152
|
+
{backwards && (
|
|
153
|
+
<p id={messageId} className="text-xs text-danger" role="alert">
|
|
154
|
+
Ends before it starts. Move the end past {draft.startTime}, or start
|
|
155
|
+
earlier.
|
|
156
|
+
</p>
|
|
109
157
|
)}
|
|
110
|
-
<label className="flex items-center gap-2 text-sm text-fg-muted">
|
|
111
|
-
<input
|
|
112
|
-
type="checkbox"
|
|
113
|
-
checked={draft.allDay}
|
|
114
|
-
onChange={(e) =>
|
|
115
|
-
setField({ draft, onChange }, "allDay", e.target.checked)
|
|
116
|
-
}
|
|
117
|
-
className="size-4 accent-current"
|
|
118
|
-
/>
|
|
119
|
-
All day
|
|
120
|
-
</label>
|
|
121
158
|
</div>
|
|
122
159
|
);
|
|
123
160
|
}
|
|
@@ -294,6 +331,7 @@ export function EventEditor({
|
|
|
294
331
|
variant="primary"
|
|
295
332
|
size={touch || roomy ? "md" : "sm"}
|
|
296
333
|
onClick={onSave}
|
|
334
|
+
disabled={endsBeforeStart(draft)}
|
|
297
335
|
className={touch ? "min-h-11 flex-1" : ""}
|
|
298
336
|
>
|
|
299
337
|
{saveLabel}
|