@remit/ui 0.0.125 → 0.0.127

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,6 +1,6 @@
1
1
  {
2
2
  "name": "@remit/ui",
3
- "version": "0.0.125",
3
+ "version": "0.0.127",
4
4
  "type": "module",
5
5
  "files": [
6
6
  "src"
@@ -2,7 +2,7 @@ import assert from "node:assert/strict";
2
2
  import { describe, it } from "node:test";
3
3
  import { createElement } from "react";
4
4
  import { renderToString } from "react-dom/server";
5
- import { ComposeFormShell, composeModeLabels } from "./compose-form-shell.js";
5
+ import { ComposeFormShell } from "./compose-form-shell.js";
6
6
 
7
7
  describe("ComposeFormShell", () => {
8
8
  it("renders header, body and action bar slots in order", () => {
@@ -103,11 +103,4 @@ describe("ComposeFormShell", () => {
103
103
  );
104
104
  assert.doesNotMatch(html, /sticky/);
105
105
  });
106
-
107
- it("exposes mode labels for every compose mode", () => {
108
- assert.equal(composeModeLabels.new, "New Message");
109
- assert.equal(composeModeLabels.reply, "Reply");
110
- assert.equal(composeModeLabels.reply_all, "Reply All");
111
- assert.equal(composeModeLabels.forward, "Forward");
112
- });
113
106
  });
@@ -1,14 +1,5 @@
1
1
  import type { ReactNode } from "react";
2
2
 
3
- export type ComposeMode = "new" | "reply" | "reply_all" | "forward";
4
-
5
- export const composeModeLabels: Record<ComposeMode, string> = {
6
- new: "New Message",
7
- reply: "Reply",
8
- reply_all: "Reply All",
9
- forward: "Forward",
10
- };
11
-
12
3
  /**
13
4
  * How the surface gets its height. `fill` takes the one its container hands it
14
5
  * and scrolls the body inside that, so the action bar is pinned to the bottom
@@ -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
- function Live({ startExpanded }: { startExpanded: boolean }) {
57
- const [draft, setDraft] = useState(seed);
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-wrap items-center gap-2">
78
- <Input
79
- type="date"
80
- value={draft.date}
81
- aria-label="Date"
82
- onChange={(e) => setField({ draft, onChange }, "date", e.target.value)}
83
- className={cn("w-40", fieldHeight)}
84
- />
85
- {draft.allDay ? (
86
- <span className="text-sm text-fg-muted">All day</span>
87
- ) : (
88
- <>
89
- <Input
90
- type="time"
91
- value={draft.startTime}
92
- aria-label="Start time"
93
- onChange={(e) =>
94
- setField({ draft, onChange }, "startTime", e.target.value)
95
- }
96
- className={cn("w-28", fieldHeight)}
97
- />
98
- <span className="text-sm text-fg-subtle">to</span>
99
- <Input
100
- type="time"
101
- value={draft.endTime}
102
- aria-label="End time"
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 }, "endTime", e.target.value)
145
+ setField({ draft, onChange }, "allDay", e.target.checked)
105
146
  }
106
- className={cn("w-28", fieldHeight)}
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}
package/src/index.ts CHANGED
@@ -178,9 +178,7 @@ export { ComposeBodySkeleton } from "./components/compose-body-skeleton.js";
178
178
  export {
179
179
  ComposeFormShell,
180
180
  type ComposeFormShellProps,
181
- type ComposeMode,
182
181
  type ComposeShellLayout,
183
- composeModeLabels,
184
182
  } from "./components/compose-form-shell.js";
185
183
  export {
186
184
  ComposeHeader,
@@ -240,6 +238,7 @@ export {
240
238
  EventRepeatField,
241
239
  EventTitleField,
242
240
  EventWhenField,
241
+ endsBeforeStart,
243
242
  } from "./components/event-editor.js";
244
243
  export {
245
244
  EventEditorPane,