@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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remit/ui",
3
- "version": "0.0.144",
3
+ "version": "0.0.145",
4
4
  "type": "module",
5
5
  "files": [
6
6
  "src"
@@ -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({ attendee, className }: AttendeeRowProps) {
55
- return (
56
- <div className={cn("flex min-h-9 items-center gap-2.5", className)}>
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
- </div>
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
- /** The full guest list with a one-line tally above it. */
75
- export function AttendeeList({ attendees, className }: AttendeeListProps) {
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
- <AttendeeRow key={attendee.email} attendee={attendee} />
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 attendees={event.attendees} className="mt-3" />
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 !== "" && (