@remit/ui 0.0.144 → 0.0.145
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
|
@@ -54,6 +54,52 @@ describe("AttendeeRow", () => {
|
|
|
54
54
|
);
|
|
55
55
|
assert.doesNotMatch(html, /Organiser/);
|
|
56
56
|
});
|
|
57
|
+
|
|
58
|
+
it("renders exactly what it always did for a caller with nothing to open", () => {
|
|
59
|
+
const html = renderToString(
|
|
60
|
+
createElement(AttendeeRow, { attendee: attendees[1] }),
|
|
61
|
+
);
|
|
62
|
+
assert.match(html, /^<div class="flex min-h-9 items-center gap-2\.5">/);
|
|
63
|
+
assert.match(html, /<span class="min-w-0 flex-1">/);
|
|
64
|
+
assert.doesNotMatch(
|
|
65
|
+
html,
|
|
66
|
+
/<button|aria-expanded|w-full|text-left|relative/,
|
|
67
|
+
);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("becomes a control where a name leads somewhere", () => {
|
|
71
|
+
const html = renderToString(
|
|
72
|
+
createElement(AttendeeRow, {
|
|
73
|
+
attendee: attendees[0],
|
|
74
|
+
onActivate: () => undefined,
|
|
75
|
+
}),
|
|
76
|
+
);
|
|
77
|
+
assert.match(html, /<button/);
|
|
78
|
+
assert.match(html, /aria-expanded="false"/);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("says the row is the one standing open", () => {
|
|
82
|
+
const html = renderToString(
|
|
83
|
+
createElement(AttendeeRow, {
|
|
84
|
+
attendee: attendees[0],
|
|
85
|
+
active: true,
|
|
86
|
+
onActivate: () => undefined,
|
|
87
|
+
}),
|
|
88
|
+
);
|
|
89
|
+
assert.match(html, /aria-expanded="true"/);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("gives a thumb a row it can hit", () => {
|
|
93
|
+
const html = renderToString(
|
|
94
|
+
createElement(AttendeeRow, {
|
|
95
|
+
attendee: attendees[0],
|
|
96
|
+
onActivate: () => undefined,
|
|
97
|
+
touch: true,
|
|
98
|
+
}),
|
|
99
|
+
);
|
|
100
|
+
assert.match(html, /min-h-11/);
|
|
101
|
+
assert.doesNotMatch(html, /min-h-9/);
|
|
102
|
+
});
|
|
57
103
|
});
|
|
58
104
|
|
|
59
105
|
describe("AttendeeList", () => {
|
|
@@ -70,4 +116,65 @@ describe("AttendeeList", () => {
|
|
|
70
116
|
"",
|
|
71
117
|
);
|
|
72
118
|
});
|
|
119
|
+
|
|
120
|
+
it("leaves the markup it always had when nothing can be opened", () => {
|
|
121
|
+
const html = renderToString(createElement(AttendeeList, { attendees }));
|
|
122
|
+
assert.doesNotMatch(html, /<button|aria-expanded|w-full/);
|
|
123
|
+
assert.doesNotMatch(html, /<div>/);
|
|
124
|
+
assert.equal(
|
|
125
|
+
html.match(/<div class="flex min-h-9 items-center gap-2\.5">/g)?.length,
|
|
126
|
+
attendees.length,
|
|
127
|
+
);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it("makes every row a control when a guest can be opened", () => {
|
|
131
|
+
const html = renderToString(
|
|
132
|
+
createElement(AttendeeList, { attendees, onActivate: () => undefined }),
|
|
133
|
+
);
|
|
134
|
+
assert.equal(html.match(/<button/g)?.length, attendees.length);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it("puts the context under the guest it is about and under no other", () => {
|
|
138
|
+
const html = renderToString(
|
|
139
|
+
createElement(AttendeeList, {
|
|
140
|
+
attendees,
|
|
141
|
+
activeEmail: "dana@northwind.example",
|
|
142
|
+
onActivate: () => undefined,
|
|
143
|
+
renderContext: (attendee) =>
|
|
144
|
+
createElement("p", null, `Recent mail · ${attendee.name}`),
|
|
145
|
+
}),
|
|
146
|
+
);
|
|
147
|
+
assert.match(html, /Recent mail · Dana Okafor/);
|
|
148
|
+
assert.doesNotMatch(html, /Recent mail · Priya Natarajan/);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it("names the disclosure the open row controls", () => {
|
|
152
|
+
const html = renderToString(
|
|
153
|
+
createElement(AttendeeList, {
|
|
154
|
+
attendees,
|
|
155
|
+
activeEmail: "dana@northwind.example",
|
|
156
|
+
onActivate: () => undefined,
|
|
157
|
+
renderContext: (attendee) =>
|
|
158
|
+
createElement("p", null, `Recent mail · ${attendee.name}`),
|
|
159
|
+
}),
|
|
160
|
+
);
|
|
161
|
+
const controls = html.match(/aria-controls="([^"]+)"/g) ?? [];
|
|
162
|
+
assert.equal(controls.length, 1);
|
|
163
|
+
const id = /aria-controls="([^"]+)"/.exec(html)?.[1];
|
|
164
|
+
assert.ok(id);
|
|
165
|
+
assert.ok(html.includes(`<div id="${id}"`));
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it("opens no context for a guest who is not on the list", () => {
|
|
169
|
+
const html = renderToString(
|
|
170
|
+
createElement(AttendeeList, {
|
|
171
|
+
attendees,
|
|
172
|
+
activeEmail: "nobody@northwind.example",
|
|
173
|
+
onActivate: () => undefined,
|
|
174
|
+
renderContext: (attendee) =>
|
|
175
|
+
createElement("p", null, `Recent mail · ${attendee.name}`),
|
|
176
|
+
}),
|
|
177
|
+
);
|
|
178
|
+
assert.doesNotMatch(html, /Recent mail/);
|
|
179
|
+
});
|
|
73
180
|
});
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import type { Meta, StoryObj } from "@storybook/react-vite";
|
|
2
|
+
import { useState } from "react";
|
|
3
|
+
import { expect, userEvent, waitFor, within } from "storybook/test";
|
|
2
4
|
import { AttendeeList, RsvpBadge } from "./attendee-row.js";
|
|
3
5
|
import type { CalendarAttendee } from "./calendar-types.js";
|
|
4
6
|
|
|
@@ -56,6 +58,52 @@ export const List: Story = {
|
|
|
56
58
|
),
|
|
57
59
|
};
|
|
58
60
|
|
|
61
|
+
/**
|
|
62
|
+
* The same list where the surface has something to say about the person behind
|
|
63
|
+
* a row. A row is then a disclosure: it opens under the guest, the same
|
|
64
|
+
* activation closes it, and what opens is the caller's — the kit places it and
|
|
65
|
+
* knows nothing else about it.
|
|
66
|
+
*/
|
|
67
|
+
export const WithContext: Story = {
|
|
68
|
+
render: () => {
|
|
69
|
+
const [active, setActive] = useState("");
|
|
70
|
+
return (
|
|
71
|
+
<div className="max-w-sm rounded-lg border border-line bg-surface p-3">
|
|
72
|
+
<AttendeeList
|
|
73
|
+
attendees={attendees}
|
|
74
|
+
activeEmail={active}
|
|
75
|
+
onActivate={setActive}
|
|
76
|
+
renderContext={(attendee) => (
|
|
77
|
+
<p className="w-64 rounded-lg border border-line bg-surface-raised p-3 text-xs text-fg-muted">
|
|
78
|
+
{`Everything ${attendee.name} has written lately would go here.`}
|
|
79
|
+
</p>
|
|
80
|
+
)}
|
|
81
|
+
/>
|
|
82
|
+
</div>
|
|
83
|
+
);
|
|
84
|
+
},
|
|
85
|
+
play: async ({ canvasElement }) => {
|
|
86
|
+
const canvas = within(canvasElement);
|
|
87
|
+
const guest = canvas.getByRole("button", { name: /Aisha Khan/ });
|
|
88
|
+
|
|
89
|
+
await userEvent.click(guest);
|
|
90
|
+
|
|
91
|
+
const opened = await waitFor(() =>
|
|
92
|
+
canvas.getByText(/Everything Aisha Khan has written/),
|
|
93
|
+
);
|
|
94
|
+
await expect(guest).toHaveAttribute("aria-expanded", "true");
|
|
95
|
+
await expect(guest).toHaveAttribute(
|
|
96
|
+
"aria-controls",
|
|
97
|
+
opened.parentElement?.id,
|
|
98
|
+
);
|
|
99
|
+
|
|
100
|
+
await userEvent.click(opened);
|
|
101
|
+
|
|
102
|
+
await waitFor(() => expect(opened).toBeInTheDocument());
|
|
103
|
+
await expect(guest).toHaveAttribute("aria-expanded", "true");
|
|
104
|
+
},
|
|
105
|
+
};
|
|
106
|
+
|
|
59
107
|
export const EveryReply: Story = {
|
|
60
108
|
render: () => (
|
|
61
109
|
<div className="flex gap-4">
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { Check, Clock, Minus, X } from "lucide-react";
|
|
2
|
+
import { Fragment, type ReactNode, useId } from "react";
|
|
2
3
|
import { cn } from "../lib/cn.js";
|
|
3
4
|
import { Avatar } from "./avatar.js";
|
|
4
5
|
import type { CalendarAttendee, RsvpState } from "./calendar-types.js";
|
|
@@ -48,12 +49,31 @@ export function RsvpBadge({ rsvp, className }: RsvpBadgeProps) {
|
|
|
48
49
|
|
|
49
50
|
export interface AttendeeRowProps {
|
|
50
51
|
attendee: CalendarAttendee;
|
|
52
|
+
/** Marks the row whose context is open. */
|
|
53
|
+
active?: boolean;
|
|
54
|
+
/**
|
|
55
|
+
* Called with the guest to open and with "" to close, so a row that is
|
|
56
|
+
* already open closes on the next activation. Absent leaves the row inert,
|
|
57
|
+
* as today.
|
|
58
|
+
*/
|
|
59
|
+
onActivate?: (email: string) => void;
|
|
60
|
+
/** The disclosure this row opens, so the button can name what it controls. */
|
|
61
|
+
contextId?: string;
|
|
62
|
+
/** A thumb needs a bigger row than a pointer does. */
|
|
63
|
+
touch?: boolean;
|
|
51
64
|
className?: string;
|
|
52
65
|
}
|
|
53
66
|
|
|
54
|
-
export function AttendeeRow({
|
|
55
|
-
|
|
56
|
-
|
|
67
|
+
export function AttendeeRow({
|
|
68
|
+
attendee,
|
|
69
|
+
active = false,
|
|
70
|
+
onActivate,
|
|
71
|
+
contextId,
|
|
72
|
+
touch = false,
|
|
73
|
+
className,
|
|
74
|
+
}: AttendeeRowProps) {
|
|
75
|
+
const face = (
|
|
76
|
+
<>
|
|
57
77
|
<Avatar name={attendee.name} email={attendee.email} size="sm" />
|
|
58
78
|
<span className="min-w-0 flex-1">
|
|
59
79
|
<span className="block truncate text-sm text-fg">{attendee.name}</span>
|
|
@@ -62,17 +82,74 @@ export function AttendeeRow({ attendee, className }: AttendeeRowProps) {
|
|
|
62
82
|
)}
|
|
63
83
|
</span>
|
|
64
84
|
<RsvpBadge rsvp={attendee.rsvp} />
|
|
65
|
-
|
|
85
|
+
</>
|
|
86
|
+
);
|
|
87
|
+
|
|
88
|
+
if (!onActivate)
|
|
89
|
+
return (
|
|
90
|
+
<div className={cn("flex min-h-9 items-center gap-2.5", className)}>
|
|
91
|
+
{face}
|
|
92
|
+
</div>
|
|
93
|
+
);
|
|
94
|
+
|
|
95
|
+
return (
|
|
96
|
+
<button
|
|
97
|
+
type="button"
|
|
98
|
+
aria-expanded={active}
|
|
99
|
+
aria-controls={active && contextId !== undefined ? contextId : undefined}
|
|
100
|
+
onClick={() => onActivate(active ? "" : attendee.email)}
|
|
101
|
+
onKeyDown={(event) => {
|
|
102
|
+
if (event.key === "Escape") onActivate("");
|
|
103
|
+
}}
|
|
104
|
+
onBlur={(event) => {
|
|
105
|
+
if (!active) return;
|
|
106
|
+
// Only focus landing on something else is focus leaving the guest.
|
|
107
|
+
// Clicking the text inside the disclosure focuses nothing at all, and
|
|
108
|
+
// what the row opened is rendered beside it, so neither closes it.
|
|
109
|
+
if (event.relatedTarget === null) return;
|
|
110
|
+
if (event.currentTarget.parentElement?.contains(event.relatedTarget))
|
|
111
|
+
return;
|
|
112
|
+
onActivate("");
|
|
113
|
+
}}
|
|
114
|
+
className={cn(
|
|
115
|
+
"flex w-full items-center gap-2.5 rounded-md px-1.5 text-left outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
|
116
|
+
touch ? "min-h-11" : "min-h-9",
|
|
117
|
+
active ? "bg-accent-2-soft" : "hover:bg-surface-sunken",
|
|
118
|
+
className,
|
|
119
|
+
)}
|
|
120
|
+
>
|
|
121
|
+
{face}
|
|
122
|
+
</button>
|
|
66
123
|
);
|
|
67
124
|
}
|
|
68
125
|
|
|
69
126
|
export interface AttendeeListProps {
|
|
70
127
|
attendees: CalendarAttendee[];
|
|
128
|
+
/** The guest whose context is open. Empty for none. */
|
|
129
|
+
activeEmail?: string;
|
|
130
|
+
/** Called with a guest to open and with "" to close. */
|
|
131
|
+
onActivate?: (email: string) => void;
|
|
132
|
+
/** Rendered under the open row. The kit knows nothing about mail. */
|
|
133
|
+
renderContext?: (attendee: CalendarAttendee) => ReactNode;
|
|
134
|
+
touch?: boolean;
|
|
71
135
|
className?: string;
|
|
72
136
|
}
|
|
73
137
|
|
|
74
|
-
/**
|
|
75
|
-
|
|
138
|
+
/**
|
|
139
|
+
* The full guest list with a one-line tally above it. A row is a control only
|
|
140
|
+
* where the surface has something to say about the person behind it, and what
|
|
141
|
+
* it opens is a disclosure under the row rather than a card over the pane: the
|
|
142
|
+
* same gesture closes it, so do Escape and taking focus elsewhere.
|
|
143
|
+
*/
|
|
144
|
+
export function AttendeeList({
|
|
145
|
+
attendees,
|
|
146
|
+
activeEmail = "",
|
|
147
|
+
onActivate,
|
|
148
|
+
renderContext,
|
|
149
|
+
touch = false,
|
|
150
|
+
className,
|
|
151
|
+
}: AttendeeListProps) {
|
|
152
|
+
const listId = useId();
|
|
76
153
|
if (attendees.length === 0) return null;
|
|
77
154
|
const tally = (["accepted", "tentative", "declined", "noReply"] as const)
|
|
78
155
|
.map((state) => ({
|
|
@@ -88,9 +165,30 @@ export function AttendeeList({ attendees, className }: AttendeeListProps) {
|
|
|
88
165
|
<p className="pb-1 text-2xs uppercase tracking-wider text-fg-subtle">
|
|
89
166
|
{`${attendees.length} guests · ${tally}`}
|
|
90
167
|
</p>
|
|
91
|
-
{attendees.map((attendee) =>
|
|
92
|
-
|
|
93
|
-
|
|
168
|
+
{attendees.map((attendee, index) => {
|
|
169
|
+
const active = attendee.email === activeEmail;
|
|
170
|
+
const contextId = `${listId}-guest-${index}`;
|
|
171
|
+
const row = (
|
|
172
|
+
<AttendeeRow
|
|
173
|
+
attendee={attendee}
|
|
174
|
+
active={active}
|
|
175
|
+
onActivate={onActivate}
|
|
176
|
+
contextId={renderContext ? contextId : undefined}
|
|
177
|
+
touch={touch}
|
|
178
|
+
/>
|
|
179
|
+
);
|
|
180
|
+
if (!onActivate) return <Fragment key={attendee.email}>{row}</Fragment>;
|
|
181
|
+
return (
|
|
182
|
+
<div key={attendee.email}>
|
|
183
|
+
{row}
|
|
184
|
+
{active && renderContext && (
|
|
185
|
+
<div id={contextId} className="py-1">
|
|
186
|
+
{renderContext(attendee)}
|
|
187
|
+
</div>
|
|
188
|
+
)}
|
|
189
|
+
</div>
|
|
190
|
+
);
|
|
191
|
+
})}
|
|
94
192
|
</div>
|
|
95
193
|
);
|
|
96
194
|
}
|
|
@@ -101,4 +101,26 @@ describe("EventDetail", () => {
|
|
|
101
101
|
it("names the calendar and its account", () => {
|
|
102
102
|
assert.match(render({}), /Northwind/);
|
|
103
103
|
});
|
|
104
|
+
|
|
105
|
+
it("leaves the guest list inert where a name leads nowhere", () => {
|
|
106
|
+
assert.doesNotMatch(render({}), /aria-expanded/);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("opens the caller's context under the guest it is about", () => {
|
|
110
|
+
const html = renderToString(
|
|
111
|
+
createElement(EventDetail, {
|
|
112
|
+
event,
|
|
113
|
+
calendar,
|
|
114
|
+
whenText: "Wednesday 10 June · 10:00 – 11:30",
|
|
115
|
+
onEdit: () => undefined,
|
|
116
|
+
onDelete: () => undefined,
|
|
117
|
+
activeAttendee: "priya@northwind.example",
|
|
118
|
+
onActivateAttendee: () => undefined,
|
|
119
|
+
renderAttendeeContext: (attendee) =>
|
|
120
|
+
createElement("p", null, `Recent mail · ${attendee.name}`),
|
|
121
|
+
}),
|
|
122
|
+
);
|
|
123
|
+
assert.match(html, /aria-expanded="true"/);
|
|
124
|
+
assert.match(html, /Recent mail · Priya Natarajan/);
|
|
125
|
+
});
|
|
104
126
|
});
|
|
@@ -13,6 +13,7 @@ import { cn } from "../lib/cn.js";
|
|
|
13
13
|
import { AttendeeList, RsvpBadge } from "./attendee-row.js";
|
|
14
14
|
import { Button } from "./button.js";
|
|
15
15
|
import type {
|
|
16
|
+
CalendarAttendee,
|
|
16
17
|
CalendarDescriptor,
|
|
17
18
|
CalendarEventData,
|
|
18
19
|
} from "./calendar-types.js";
|
|
@@ -28,6 +29,14 @@ export interface EventDetailProps {
|
|
|
28
29
|
onOpenThread?: () => void;
|
|
29
30
|
/** Puts the pane back to whatever it shows with nothing selected. */
|
|
30
31
|
onClose?: () => void;
|
|
32
|
+
/** The guest whose context is open. Empty for none. */
|
|
33
|
+
activeAttendee?: string;
|
|
34
|
+
/** Called with a guest to open and with "" to close. Absent leaves the list inert. */
|
|
35
|
+
onActivateAttendee?: (email: string) => void;
|
|
36
|
+
/** Rendered under the open guest. The kit knows nothing about mail. */
|
|
37
|
+
renderAttendeeContext?: (attendee: CalendarAttendee) => React.ReactNode;
|
|
38
|
+
/** A thumb needs a bigger row than a pointer does. */
|
|
39
|
+
touch?: boolean;
|
|
31
40
|
/**
|
|
32
41
|
* `bare` drops the header and the scrolling frame, for a surface that already
|
|
33
42
|
* carries both — a full-screen flow whose footer holds the same actions.
|
|
@@ -64,6 +73,10 @@ export function EventDetail({
|
|
|
64
73
|
onDelete,
|
|
65
74
|
onOpenThread,
|
|
66
75
|
onClose,
|
|
76
|
+
activeAttendee = "",
|
|
77
|
+
onActivateAttendee,
|
|
78
|
+
renderAttendeeContext,
|
|
79
|
+
touch = false,
|
|
67
80
|
chrome = "header",
|
|
68
81
|
className,
|
|
69
82
|
}: EventDetailProps) {
|
|
@@ -128,7 +141,14 @@ export function EventDetail({
|
|
|
128
141
|
)}
|
|
129
142
|
|
|
130
143
|
{event.attendees.length > 0 && (
|
|
131
|
-
<AttendeeList
|
|
144
|
+
<AttendeeList
|
|
145
|
+
attendees={event.attendees}
|
|
146
|
+
activeEmail={activeAttendee}
|
|
147
|
+
onActivate={onActivateAttendee}
|
|
148
|
+
renderContext={renderAttendeeContext}
|
|
149
|
+
touch={touch}
|
|
150
|
+
className="mt-3"
|
|
151
|
+
/>
|
|
132
152
|
)}
|
|
133
153
|
|
|
134
154
|
{event.notes !== "" && (
|