@remit/ui 0.0.155 → 0.0.157
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 +5 -2
- package/src/calendar.css +4 -0
- package/src/components/agenda-composer.render.test.ts +196 -0
- package/src/components/agenda-composer.stories.tsx +225 -0
- package/src/components/agenda-composer.tsx +283 -0
- package/src/components/agenda-flow.render.test.ts +217 -0
- package/src/components/agenda-flow.stories.tsx +215 -0
- package/src/components/agenda-flow.tsx +887 -0
- package/src/components/agenda-panels.render.test.ts +252 -0
- package/src/components/agenda-panels.stories.tsx +207 -0
- package/src/components/agenda-panels.tsx +421 -0
- package/src/components/calendar-event-chip-content.tsx +102 -0
- package/src/components/calendar-event-chip.stories.tsx +5 -7
- package/src/components/calendar-event-chip.tsx +47 -47
- package/src/components/calendar-grid.render.test.ts +309 -0
- package/src/components/calendar-grid.stories.tsx +203 -0
- package/src/components/calendar-grid.tsx +370 -0
- package/src/components/calendar-types.ts +85 -0
- package/src/index.ts +82 -0
- package/src/lib/agenda-time.test.ts +539 -0
- package/src/lib/agenda-time.ts +505 -0
- package/src/lib/calendar-event-shell.ts +55 -0
- package/src/lib/calendar-slot-pick.test.ts +140 -0
- package/src/lib/calendar-slot-pick.ts +65 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@remit/ui",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.157",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"files": [
|
|
6
6
|
"src"
|
|
@@ -9,7 +9,8 @@
|
|
|
9
9
|
".": "./src/index.ts",
|
|
10
10
|
"./rich-text": "./src/rich-text.ts",
|
|
11
11
|
"./spellcheck-worker": "./src/components/rich-text-spellcheck-worker-provider.ts",
|
|
12
|
-
"./tokens.css": "./src/tokens.css"
|
|
12
|
+
"./tokens.css": "./src/tokens.css",
|
|
13
|
+
"./calendar.css": "./src/calendar.css"
|
|
13
14
|
},
|
|
14
15
|
"scripts": {
|
|
15
16
|
"test": "npm run test:typecheck && npm run test:run",
|
|
@@ -20,6 +21,7 @@
|
|
|
20
21
|
"@fontsource-variable/geist": "^5",
|
|
21
22
|
"@fontsource-variable/hanken-grotesk": "^5",
|
|
22
23
|
"@fontsource-variable/inter": "^5",
|
|
24
|
+
"@fullcalendar/react": "^7.0.2",
|
|
23
25
|
"@lexical/code-core": "0.49.0",
|
|
24
26
|
"@lexical/html": "0.49.0",
|
|
25
27
|
"@lexical/link": "0.49.0",
|
|
@@ -40,6 +42,7 @@
|
|
|
40
42
|
"react-resizable-panels": "^2.1.9",
|
|
41
43
|
"react-simple-pull-to-refresh": "^1.3.4",
|
|
42
44
|
"tailwind-merge": "^2",
|
|
45
|
+
"temporal-polyfill": "^1.0.1",
|
|
43
46
|
"tldts": "^7.4.9"
|
|
44
47
|
},
|
|
45
48
|
"peerDependencies": {
|
package/src/calendar.css
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
/* The structural sheet `CalendarGrid`'s engine needs — positioning and nothing
|
|
2
|
+
else; it ships no theme. Every colour, border and size the grid draws comes
|
|
3
|
+
from tokens.css instead. An app that renders the grid imports this beside it. */
|
|
4
|
+
@import "@fullcalendar/react/skeleton.css";
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Typing is the create path, so the field is never off screen and the reading
|
|
3
|
+
* of the sentence is shown back with the words each part came from. Where the
|
|
4
|
+
* sentence has two honest readings the reader is asked, and that question is a
|
|
5
|
+
* control rather than a note — both are asserted here.
|
|
6
|
+
*/
|
|
7
|
+
import "@remit/test-dom";
|
|
8
|
+
import assert from "node:assert/strict";
|
|
9
|
+
import { describe, it } from "node:test";
|
|
10
|
+
import { createElement } from "react";
|
|
11
|
+
import { renderToString } from "react-dom/server";
|
|
12
|
+
import {
|
|
13
|
+
AgendaComposer,
|
|
14
|
+
type AgendaComposerProps,
|
|
15
|
+
AgendaPhraseField,
|
|
16
|
+
PhraseReading,
|
|
17
|
+
} from "./agenda-composer.js";
|
|
18
|
+
import type {
|
|
19
|
+
AgendaParse,
|
|
20
|
+
CalendarDescriptor,
|
|
21
|
+
EventDraft,
|
|
22
|
+
} from "./calendar-types.js";
|
|
23
|
+
|
|
24
|
+
const calendars: CalendarDescriptor[] = [
|
|
25
|
+
{
|
|
26
|
+
id: "c1",
|
|
27
|
+
accountId: "a1",
|
|
28
|
+
accountLabel: "Work",
|
|
29
|
+
name: "Northwind",
|
|
30
|
+
color: "cal-1",
|
|
31
|
+
},
|
|
32
|
+
];
|
|
33
|
+
|
|
34
|
+
const draft: EventDraft = {
|
|
35
|
+
title: "Lunch with Jane",
|
|
36
|
+
date: "2026-06-12",
|
|
37
|
+
startTime: "13:00",
|
|
38
|
+
endTime: "14:00",
|
|
39
|
+
allDay: false,
|
|
40
|
+
calendarId: "c1",
|
|
41
|
+
location: "",
|
|
42
|
+
guests: "Jane",
|
|
43
|
+
notes: "",
|
|
44
|
+
repeat: "",
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
const parse: AgendaParse = {
|
|
48
|
+
title: "Lunch with Jane",
|
|
49
|
+
date: "2026-06-12",
|
|
50
|
+
dateText: "friday",
|
|
51
|
+
startTime: "13:00",
|
|
52
|
+
startTimeText: "1pm",
|
|
53
|
+
endTime: "14:00",
|
|
54
|
+
durationMinutes: 60,
|
|
55
|
+
durationText: "",
|
|
56
|
+
attendees: ["Jane"],
|
|
57
|
+
attendeesText: "with Jane",
|
|
58
|
+
location: "",
|
|
59
|
+
locationText: "",
|
|
60
|
+
repeat: "",
|
|
61
|
+
repeatText: "",
|
|
62
|
+
assumptions: ["An hour, because the sentence never said."],
|
|
63
|
+
unresolved: [],
|
|
64
|
+
choices: [],
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
const ambiguous: AgendaParse = {
|
|
68
|
+
...parse,
|
|
69
|
+
unresolved: ["No place given."],
|
|
70
|
+
choices: [
|
|
71
|
+
{
|
|
72
|
+
id: "which_friday",
|
|
73
|
+
question: "Which Friday?",
|
|
74
|
+
source: "friday",
|
|
75
|
+
options: [
|
|
76
|
+
{ id: "this", label: "12 June", date: "2026-06-12", startTime: "" },
|
|
77
|
+
{ id: "next", label: "19 June", date: "2026-06-19", startTime: "" },
|
|
78
|
+
],
|
|
79
|
+
chosenId: "this",
|
|
80
|
+
},
|
|
81
|
+
],
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
const base: AgendaComposerProps = {
|
|
85
|
+
phrase: "lunch with Jane friday 1pm",
|
|
86
|
+
onPhraseChange: () => {},
|
|
87
|
+
parse,
|
|
88
|
+
picks: {},
|
|
89
|
+
onPick: () => {},
|
|
90
|
+
draft,
|
|
91
|
+
onDraftChange: () => {},
|
|
92
|
+
calendars,
|
|
93
|
+
expanded: false,
|
|
94
|
+
onToggleExpanded: () => {},
|
|
95
|
+
onSave: () => {},
|
|
96
|
+
onCancel: () => {},
|
|
97
|
+
open: true,
|
|
98
|
+
onOpen: () => {},
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
const render = (props: Partial<AgendaComposerProps> = {}) =>
|
|
102
|
+
renderToString(createElement(AgendaComposer, { ...base, ...props }));
|
|
103
|
+
|
|
104
|
+
describe("AgendaPhraseField", () => {
|
|
105
|
+
it("names the field rather than leaving the placeholder to do it", () => {
|
|
106
|
+
const html = renderToString(
|
|
107
|
+
createElement(AgendaPhraseField, {
|
|
108
|
+
phrase: "",
|
|
109
|
+
onPhraseChange: () => {},
|
|
110
|
+
onOpen: () => {},
|
|
111
|
+
onCommit: () => {},
|
|
112
|
+
}),
|
|
113
|
+
);
|
|
114
|
+
assert.match(html, /aria-label="Describe the event"/);
|
|
115
|
+
assert.match(html, /placeholder="lunch with Jane friday 1pm"/);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it("grows the field where a finger has to hit it", () => {
|
|
119
|
+
const html = renderToString(
|
|
120
|
+
createElement(AgendaPhraseField, {
|
|
121
|
+
phrase: "",
|
|
122
|
+
onPhraseChange: () => {},
|
|
123
|
+
onOpen: () => {},
|
|
124
|
+
onCommit: () => {},
|
|
125
|
+
touch: true,
|
|
126
|
+
}),
|
|
127
|
+
);
|
|
128
|
+
assert.match(html, /min-h-11/);
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
describe("AgendaComposer", () => {
|
|
133
|
+
it("folds the form away until the reader opens it", () => {
|
|
134
|
+
assert.doesNotMatch(render({ open: false }), /Northwind/);
|
|
135
|
+
assert.match(render(), /Northwind/);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it("shows the reading back with the words each part came from", () => {
|
|
139
|
+
const html = render();
|
|
140
|
+
assert.match(html, /When/);
|
|
141
|
+
assert.match(html, /friday 1pm/);
|
|
142
|
+
assert.match(html, /Fri 12 Jun 13:00 – 14:00/);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it("keeps the reading off screen while there is nothing to read", () => {
|
|
146
|
+
const html = render({ phrase: " " });
|
|
147
|
+
assert.doesNotMatch(html, /An hour, because the sentence never said./);
|
|
148
|
+
assert.doesNotMatch(html, /bg-accent-2-soft/);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it("says a date on its own when the sentence gave no clock", () => {
|
|
152
|
+
const html = renderToString(
|
|
153
|
+
createElement(PhraseReading, {
|
|
154
|
+
parse: { ...parse, startTime: "", startTimeText: "" },
|
|
155
|
+
picks: {},
|
|
156
|
+
onPick: () => {},
|
|
157
|
+
}),
|
|
158
|
+
);
|
|
159
|
+
assert.match(html, /Fri 12 Jun/);
|
|
160
|
+
assert.doesNotMatch(html, /13:00/);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it("asks about a reading it could not settle instead of choosing", () => {
|
|
164
|
+
const html = render({ parse: ambiguous });
|
|
165
|
+
assert.match(html, /Which Friday\?/);
|
|
166
|
+
assert.match(html, /12 June/);
|
|
167
|
+
assert.match(html, /19 June/);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it("says which reading is currently applied", () => {
|
|
171
|
+
const html = render({ parse: ambiguous });
|
|
172
|
+
assert.match(html, /aria-pressed="true"/);
|
|
173
|
+
assert.match(html, /aria-pressed="false"/);
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it("lets an answer override the reading the parser chose", () => {
|
|
177
|
+
const html = renderToString(
|
|
178
|
+
createElement(PhraseReading, {
|
|
179
|
+
parse: ambiguous,
|
|
180
|
+
picks: { which_friday: "next" },
|
|
181
|
+
onPick: () => {},
|
|
182
|
+
}),
|
|
183
|
+
);
|
|
184
|
+
const chosen = html.indexOf('aria-pressed="true"');
|
|
185
|
+
assert.ok(chosen > html.indexOf("12 June"), "the second option is pressed");
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it("separates what it assumed from what the sentence never said", () => {
|
|
189
|
+
assert.match(render(), /An hour, because the sentence never said\./);
|
|
190
|
+
assert.match(render({ parse: ambiguous }), /No place given\./);
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
it("names the guests the sentence carried", () => {
|
|
194
|
+
assert.match(render(), /with Jane/);
|
|
195
|
+
});
|
|
196
|
+
});
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/react-vite";
|
|
2
|
+
import { useState } from "react";
|
|
3
|
+
import { AgendaComposer, AgendaPhraseField } from "./agenda-composer.js";
|
|
4
|
+
import type {
|
|
5
|
+
AgendaParse,
|
|
6
|
+
CalendarDescriptor,
|
|
7
|
+
ChoicePicks,
|
|
8
|
+
EventDraft,
|
|
9
|
+
} from "./calendar-types.js";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Correcting the machine happens before the event exists. The sentence is read
|
|
13
|
+
* back with the words each part came from, and where it has two honest
|
|
14
|
+
* readings the composer asks rather than choosing.
|
|
15
|
+
*/
|
|
16
|
+
const meta: Meta<typeof AgendaComposer> = {
|
|
17
|
+
title: "Calendar/Agenda composer",
|
|
18
|
+
component: AgendaComposer,
|
|
19
|
+
parameters: { layout: "padded" },
|
|
20
|
+
decorators: [
|
|
21
|
+
(Story) => (
|
|
22
|
+
<div className="max-w-96">
|
|
23
|
+
<Story />
|
|
24
|
+
</div>
|
|
25
|
+
),
|
|
26
|
+
],
|
|
27
|
+
};
|
|
28
|
+
export default meta;
|
|
29
|
+
|
|
30
|
+
type Story = StoryObj<typeof AgendaComposer>;
|
|
31
|
+
|
|
32
|
+
const calendars: CalendarDescriptor[] = [
|
|
33
|
+
{
|
|
34
|
+
id: "work",
|
|
35
|
+
accountId: "a1",
|
|
36
|
+
accountLabel: "Work",
|
|
37
|
+
name: "Northwind",
|
|
38
|
+
color: "cal-1",
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
id: "personal",
|
|
42
|
+
accountId: "a2",
|
|
43
|
+
accountLabel: "Personal",
|
|
44
|
+
name: "Family",
|
|
45
|
+
color: "cal-3",
|
|
46
|
+
},
|
|
47
|
+
];
|
|
48
|
+
|
|
49
|
+
const draft: EventDraft = {
|
|
50
|
+
title: "Lunch with Jane",
|
|
51
|
+
date: "2026-06-12",
|
|
52
|
+
startTime: "13:00",
|
|
53
|
+
endTime: "14:00",
|
|
54
|
+
allDay: false,
|
|
55
|
+
calendarId: "work",
|
|
56
|
+
location: "",
|
|
57
|
+
guests: "Jane",
|
|
58
|
+
notes: "",
|
|
59
|
+
repeat: "",
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
const parse: AgendaParse = {
|
|
63
|
+
title: "Lunch with Jane",
|
|
64
|
+
date: "2026-06-12",
|
|
65
|
+
dateText: "friday",
|
|
66
|
+
startTime: "13:00",
|
|
67
|
+
startTimeText: "1pm",
|
|
68
|
+
endTime: "14:00",
|
|
69
|
+
durationMinutes: 60,
|
|
70
|
+
durationText: "",
|
|
71
|
+
attendees: ["Jane"],
|
|
72
|
+
attendeesText: "with Jane",
|
|
73
|
+
location: "",
|
|
74
|
+
locationText: "",
|
|
75
|
+
repeat: "",
|
|
76
|
+
repeatText: "",
|
|
77
|
+
assumptions: ["An hour long, because the sentence never said."],
|
|
78
|
+
unresolved: [],
|
|
79
|
+
choices: [],
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
const repeating: AgendaParse = {
|
|
83
|
+
...parse,
|
|
84
|
+
title: "Standup",
|
|
85
|
+
startTime: "09:30",
|
|
86
|
+
endTime: "09:45",
|
|
87
|
+
startTimeText: "9:30",
|
|
88
|
+
dateText: "every weekday",
|
|
89
|
+
repeat: "Every weekday",
|
|
90
|
+
repeatText: "every weekday",
|
|
91
|
+
attendees: [],
|
|
92
|
+
attendeesText: "",
|
|
93
|
+
assumptions: ["Fifteen minutes, because the sentence never said."],
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
const ambiguous: AgendaParse = {
|
|
97
|
+
...parse,
|
|
98
|
+
title: "Coffee with Marcus",
|
|
99
|
+
startTime: "08:00",
|
|
100
|
+
startTimeText: "at 8",
|
|
101
|
+
endTime: "09:00",
|
|
102
|
+
attendees: ["Marcus"],
|
|
103
|
+
attendeesText: "with Marcus",
|
|
104
|
+
unresolved: ["No place given."],
|
|
105
|
+
choices: [
|
|
106
|
+
{
|
|
107
|
+
id: "which_eight",
|
|
108
|
+
question: "Eight in the morning or eight at night?",
|
|
109
|
+
source: "at 8",
|
|
110
|
+
options: [
|
|
111
|
+
{ id: "am", label: "08:00", date: "", startTime: "08:00" },
|
|
112
|
+
{ id: "pm", label: "20:00", date: "", startTime: "20:00" },
|
|
113
|
+
],
|
|
114
|
+
chosenId: "am",
|
|
115
|
+
},
|
|
116
|
+
],
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
const base = {
|
|
120
|
+
onPhraseChange: () => {},
|
|
121
|
+
picks: {} as ChoicePicks,
|
|
122
|
+
onPick: () => {},
|
|
123
|
+
draft,
|
|
124
|
+
onDraftChange: () => {},
|
|
125
|
+
calendars,
|
|
126
|
+
expanded: false,
|
|
127
|
+
onToggleExpanded: () => {},
|
|
128
|
+
onSave: () => {},
|
|
129
|
+
onCancel: () => {},
|
|
130
|
+
onOpen: () => {},
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
/** The field on its own — where the composer starts every time. */
|
|
134
|
+
export const FieldOnly: Story = {
|
|
135
|
+
render: () => (
|
|
136
|
+
<AgendaPhraseField
|
|
137
|
+
phrase=""
|
|
138
|
+
onPhraseChange={() => {}}
|
|
139
|
+
onOpen={() => {}}
|
|
140
|
+
onCommit={() => {}}
|
|
141
|
+
/>
|
|
142
|
+
),
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
/** A sentence that read cleanly, with the reading shown back above the form. */
|
|
146
|
+
export const Read: Story = {
|
|
147
|
+
args: {
|
|
148
|
+
...base,
|
|
149
|
+
phrase: "lunch with Jane friday 1pm",
|
|
150
|
+
parse,
|
|
151
|
+
open: true,
|
|
152
|
+
},
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
/** A rule the sentence carried, named as a rule rather than as one morning. */
|
|
156
|
+
export const Repeating: Story = {
|
|
157
|
+
args: {
|
|
158
|
+
...base,
|
|
159
|
+
phrase: "standup every weekday 9:30",
|
|
160
|
+
parse: repeating,
|
|
161
|
+
draft: { ...draft, title: "Standup", repeat: "Every weekday" },
|
|
162
|
+
open: true,
|
|
163
|
+
},
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
/** Two honest readings: the question is a control, and the answer is one tap. */
|
|
167
|
+
export const Ambiguous: Story = {
|
|
168
|
+
args: {
|
|
169
|
+
...base,
|
|
170
|
+
phrase: "coffee with Marcus at 8",
|
|
171
|
+
parse: ambiguous,
|
|
172
|
+
draft: { ...draft, title: "Coffee with Marcus", startTime: "08:00" },
|
|
173
|
+
open: true,
|
|
174
|
+
},
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
/** Folded away until there is something to correct. */
|
|
178
|
+
export const Folded: Story = {
|
|
179
|
+
args: {
|
|
180
|
+
...base,
|
|
181
|
+
phrase: "lunch with Jane friday 1pm",
|
|
182
|
+
parse,
|
|
183
|
+
open: false,
|
|
184
|
+
},
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
/** Grown for a phone, where the form is the whole screen. */
|
|
188
|
+
export const Touch: Story = {
|
|
189
|
+
args: {
|
|
190
|
+
...base,
|
|
191
|
+
phrase: "coffee with Marcus at 8",
|
|
192
|
+
parse: ambiguous,
|
|
193
|
+
open: true,
|
|
194
|
+
touch: true,
|
|
195
|
+
},
|
|
196
|
+
};
|
|
197
|
+
|
|
198
|
+
/** Answering the question moves the reading; nothing is settled behind you. */
|
|
199
|
+
export const Interactive: Story = {
|
|
200
|
+
render: () => {
|
|
201
|
+
const [picks, setPicks] = useState<ChoicePicks>({});
|
|
202
|
+
const [phrase, setPhrase] = useState("coffee with Marcus at 8");
|
|
203
|
+
const [open, setOpen] = useState(true);
|
|
204
|
+
const [expanded, setExpanded] = useState(false);
|
|
205
|
+
const [current, setCurrent] = useState(draft);
|
|
206
|
+
return (
|
|
207
|
+
<AgendaComposer
|
|
208
|
+
{...base}
|
|
209
|
+
phrase={phrase}
|
|
210
|
+
onPhraseChange={setPhrase}
|
|
211
|
+
parse={ambiguous}
|
|
212
|
+
picks={picks}
|
|
213
|
+
onPick={(choiceId, optionId) =>
|
|
214
|
+
setPicks((previous) => ({ ...previous, [choiceId]: optionId }))
|
|
215
|
+
}
|
|
216
|
+
draft={current}
|
|
217
|
+
onDraftChange={setCurrent}
|
|
218
|
+
expanded={expanded}
|
|
219
|
+
onToggleExpanded={() => setExpanded((value) => !value)}
|
|
220
|
+
open={open}
|
|
221
|
+
onOpen={() => setOpen(true)}
|
|
222
|
+
/>
|
|
223
|
+
);
|
|
224
|
+
},
|
|
225
|
+
};
|