@remit/ui 0.0.145 → 0.0.147

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.145",
3
+ "version": "0.0.147",
4
4
  "type": "module",
5
5
  "files": [
6
6
  "src"
@@ -82,6 +82,26 @@ export interface CalendarEventData {
82
82
  status: "confirmed" | "tentative";
83
83
  }
84
84
 
85
+ /**
86
+ * One clock a zoneless time could be on. Present only when the source genuinely
87
+ * did not say which, and the reader is the one who settles it.
88
+ */
89
+ export interface ZoneOption {
90
+ /** IANA zone, and the identity of the choice. */
91
+ timeZone: string;
92
+ /** The time as this clock reads it — "20:25 in Lisbon". */
93
+ label: string;
94
+ /** What that means on the reader's own clock. */
95
+ note: string;
96
+ }
97
+
98
+ /**
99
+ * The clocks a zoneless time could be on. Never empty: a question with no
100
+ * answers is a card the reader can look at and never get past, so having no
101
+ * question to ask is the absent list, not the empty one.
102
+ */
103
+ export type ZoneOptions = readonly [ZoneOption, ...ZoneOption[]];
104
+
85
105
  /**
86
106
  * A candidate read out of mail. It is deliberately not a `CalendarEventData`:
87
107
  * nothing reaches the grid until a person confirms it, so a suggestion cannot
@@ -107,6 +127,11 @@ export interface EventSuggestion {
107
127
  suggestedCalendarId: string;
108
128
  timeZone: string;
109
129
  zoneCertainty: ZoneCertainty;
130
+ /**
131
+ * The clocks the time could be on. Absent when the source said which, which
132
+ * is the only case where `timeZone` above can be trusted on its own.
133
+ */
134
+ zoneOptions?: ZoneOptions;
110
135
  }
111
136
 
112
137
  /** Which instances of a series an edit applies to, chosen before the edit. */
@@ -23,11 +23,13 @@ const suggestion: EventSuggestion = {
23
23
  zoneCertainty: "explicit",
24
24
  };
25
25
 
26
- const render = (overrides: Partial<EventSuggestion>) =>
26
+ const render = (overrides: Partial<EventSuggestion>, zoneChoice?: string) =>
27
27
  renderToString(
28
28
  createElement(EventSuggestionCard, {
29
29
  suggestion: { ...suggestion, ...overrides },
30
30
  whenText: "Friday 19 June – Monday 22 June",
31
+ zoneChoice,
32
+ onZoneChoice: () => undefined,
31
33
  onAdd: () => undefined,
32
34
  onReview: () => undefined,
33
35
  onDismiss: () => undefined,
@@ -35,6 +37,21 @@ const render = (overrides: Partial<EventSuggestion>) =>
35
37
  }),
36
38
  );
37
39
 
40
+ const twoClocks: Partial<EventSuggestion> = {
41
+ zoneOptions: [
42
+ {
43
+ timeZone: "Europe/Lisbon",
44
+ label: "16:00 in Lisbon",
45
+ note: "17:00 on your own clock.",
46
+ },
47
+ {
48
+ timeZone: "Europe/Amsterdam",
49
+ label: "16:00 in Amsterdam",
50
+ note: "15:00 where she is.",
51
+ },
52
+ ],
53
+ };
54
+
38
55
  describe("EventSuggestionCard", () => {
39
56
  it("says it is a suggestion and not an event", () => {
40
57
  const html = render({});
@@ -63,4 +80,28 @@ describe("EventSuggestionCard", () => {
63
80
  it("needs a person to press Add", () => {
64
81
  assert.match(render({}), />Add</);
65
82
  });
83
+
84
+ it("asks nothing about the clock when the mail stated one", () => {
85
+ const html = render({});
86
+ assert.doesNotMatch(html, /Which clock is this on\?/);
87
+ assert.doesNotMatch(html, /Pick a clock first/);
88
+ assert.doesNotMatch(html, /aria-describedby/);
89
+ });
90
+
91
+ it("offers the clocks and dims Add while none is picked", () => {
92
+ const html = render(twoClocks);
93
+ assert.match(html, /Which clock is this on\?/);
94
+ assert.match(html, /16:00 in Lisbon/);
95
+ assert.match(html, /Pick a clock first/);
96
+ assert.match(html, /aria-describedby/);
97
+ assert.match(html, /opacity-55/);
98
+ });
99
+
100
+ it("undims Add once one of the clocks is picked", () => {
101
+ const html = render(twoClocks, "Europe/Lisbon");
102
+ assert.match(html, /aria-pressed="true"/);
103
+ assert.doesNotMatch(html, /Pick a clock first/);
104
+ assert.doesNotMatch(html, /aria-describedby/);
105
+ assert.doesNotMatch(html, /opacity-55/);
106
+ });
66
107
  });
@@ -1,5 +1,6 @@
1
1
  import type { Meta, StoryObj } from "@storybook/react-vite";
2
- import type { EventSuggestion } from "./calendar-types.js";
2
+ import { useState } from "react";
3
+ import type { EventSuggestion, ZoneOptions } from "./calendar-types.js";
3
4
  import { EventSuggestionCard } from "./event-suggestion-card.js";
4
5
 
5
6
  /**
@@ -81,6 +82,77 @@ export const WithAmbiguity: Story = {
81
82
  ),
82
83
  };
83
84
 
85
+ const twoClocks: ZoneOptions = [
86
+ {
87
+ timeZone: "Europe/Lisbon",
88
+ label: "16:00 in Lisbon",
89
+ note: "17:00 on your own clock. The hour she keeps.",
90
+ },
91
+ {
92
+ timeZone: "Europe/Amsterdam",
93
+ label: "16:00 in Amsterdam",
94
+ note: "15:00 where she is.",
95
+ },
96
+ ];
97
+
98
+ const call: EventSuggestion = {
99
+ ...base,
100
+ id: "s3",
101
+ title: "Kickoff call — Lisbon venue",
102
+ allDay: false,
103
+ location: "Meet link",
104
+ threadSubject: "Kickoff call on Wednesday at 16:00",
105
+ sender: "Rita Sousa",
106
+ confidence: 0.66,
107
+ ambiguity:
108
+ "Rita writes from Lisbon and names 16:00 without a clock. Lisbon runs an hour behind Amsterdam.",
109
+ timeZone: "",
110
+ zoneCertainty: "ambiguous",
111
+ zoneOptions: twoClocks,
112
+ };
113
+
114
+ function GatedCall({ picked }: { picked: string }) {
115
+ const [zoneChoice, setZoneChoice] = useState(picked);
116
+ const [carried, setCarried] = useState("");
117
+ return (
118
+ <div className="flex flex-col gap-2">
119
+ <EventSuggestionCard
120
+ suggestion={call}
121
+ whenText="Wednesday 17 June · 16:00 – 17:00"
122
+ zoneChoice={zoneChoice}
123
+ onZoneChoice={setZoneChoice}
124
+ onAdd={(timeZone) => setCarried(`Added on ${timeZone}`)}
125
+ onReview={(timeZone) => setCarried(`Editor opened on ${timeZone}`)}
126
+ onDismiss={() => {}}
127
+ onOpenThread={() => {}}
128
+ />
129
+ <p className="text-2xs text-fg-subtle">
130
+ {carried === "" ? "Nothing has left the card yet." : carried}
131
+ </p>
132
+ </div>
133
+ );
134
+ }
135
+
136
+ /**
137
+ * The mail printed an hour and never said whose clock it is on. Both ways out
138
+ * of the card are dimmed and stay dimmed until one is picked — an hour guessed
139
+ * wrong is a call missed, and an editor opened on that hour hides the guess
140
+ * behind a field the reader is invited to trust.
141
+ */
142
+ export const ZoneWeCannotDetermine: Story = {
143
+ name: "The zone we cannot determine",
144
+ render: () => <GatedCall picked="" />,
145
+ };
146
+
147
+ /**
148
+ * Answered. Both buttons are live, and each carries the clock that was named —
149
+ * Add books it, Change first opens on it.
150
+ */
151
+ export const ZonePicked: Story = {
152
+ name: "The clock is picked",
153
+ render: () => <GatedCall picked="Europe/Lisbon" />,
154
+ };
155
+
84
156
  /** The same card sized for a phone sheet. */
85
157
  export const Touch: Story = {
86
158
  render: () => (
@@ -1,16 +1,67 @@
1
- import { AlertTriangle, Mail, Plus, SlidersHorizontal, X } from "lucide-react";
1
+ import {
2
+ AlertTriangle,
3
+ Globe,
4
+ Mail,
5
+ Plus,
6
+ SlidersHorizontal,
7
+ X,
8
+ } from "lucide-react";
9
+ import { useId, useState } from "react";
2
10
  import { cn } from "../lib/cn.js";
11
+ import { BlockedReason } from "./blocked-reason.js";
3
12
  import { Button } from "./button.js";
4
13
  import type { EventSuggestion } from "./calendar-types.js";
5
14
 
15
+ export const ZONE_UNSETTLED_REASON =
16
+ "Pick a clock first. The mail never said, and the reader will not choose for you.";
17
+
18
+ /**
19
+ * Which zone the suggestion would be booked on. `timeZone` is empty when the
20
+ * source said it itself and the reader settled nothing.
21
+ */
22
+ export type ZoneSettlement =
23
+ | { settled: true; timeZone: string }
24
+ | { settled: false; reason: string };
25
+
26
+ /**
27
+ * A time nobody stated a zone for is an hour nobody stated. The choice has to
28
+ * come from the reader and it has to be one of the clocks offered — a stale or
29
+ * unrecognised choice leaves the suggestion unsettled rather than guessing, and
30
+ * nothing here reads the zone the browser happens to be in.
31
+ *
32
+ * This is the only place the rule is written. Every surface that offers the
33
+ * clocks — the card here, the seam's strip, the phone deck behind it — asks
34
+ * this, so none of them can disagree about whether a suggestion is answered.
35
+ */
36
+ export function settleZone(
37
+ suggestion: EventSuggestion,
38
+ zoneChoice: string,
39
+ ): ZoneSettlement {
40
+ const options = suggestion.zoneOptions;
41
+ if (options === undefined) return { settled: true, timeZone: "" };
42
+ if (options.some((option) => option.timeZone === zoneChoice))
43
+ return { settled: true, timeZone: zoneChoice };
44
+ return { settled: false, reason: ZONE_UNSETTLED_REASON };
45
+ }
46
+
6
47
  export interface EventSuggestionCardProps {
7
48
  suggestion: EventSuggestion;
8
49
  /** Already formatted by the caller. */
9
50
  whenText: string;
10
- onAdd: () => void;
11
- onReview: () => void;
51
+ /** The zone the reader settled on, empty when the mail already knew. */
52
+ onAdd: (timeZone: string) => void;
53
+ /**
54
+ * Correcting the reading first. It is handed the same settled zone Add is,
55
+ * and is refused on the same terms — an editor opened on an hour nobody has
56
+ * placed on a clock seeds the draft with the wrong time, which is the
57
+ * mistake Add is dimmed to prevent.
58
+ */
59
+ onReview: (timeZone: string) => void;
12
60
  onDismiss: () => void;
13
61
  onOpenThread: () => void;
62
+ /** The clock picked so far. Omit to let the card hold the choice itself. */
63
+ zoneChoice?: string;
64
+ onZoneChoice?: (timeZone: string) => void;
14
65
  touch?: boolean;
15
66
  className?: string;
16
67
  }
@@ -27,6 +78,11 @@ function confidenceText(confidence: number): string {
27
78
  * dashed card, and only a person pressing Add puts anything on the calendar —
28
79
  * a machine reading of a mail never becomes a provisional block that someone
29
80
  * has to notice and remove.
81
+ *
82
+ * Where the mail never said which clock a time is on, every way out of the card
83
+ * that carries the time — Add, and correcting it first — is dimmed until the
84
+ * reader picks one and refuses the press until then: booking the wrong hour is
85
+ * worse than asking, and so is opening an editor already filled with it.
30
86
  */
31
87
  export function EventSuggestionCard({
32
88
  suggestion,
@@ -35,11 +91,32 @@ export function EventSuggestionCard({
35
91
  onReview,
36
92
  onDismiss,
37
93
  onOpenThread,
94
+ zoneChoice,
95
+ onZoneChoice,
38
96
  touch,
39
97
  className,
40
98
  }: EventSuggestionCardProps) {
99
+ const [ownChoice, setOwnChoice] = useState("");
100
+ const [nudged, setNudged] = useState(false);
101
+ const reasonId = useId();
102
+ const choice = zoneChoice ?? ownChoice;
103
+ const pickZone = (timeZone: string) => {
104
+ setNudged(false);
105
+ if (onZoneChoice) onZoneChoice(timeZone);
106
+ else setOwnChoice(timeZone);
107
+ };
108
+ const settlement = settleZone(suggestion, choice);
109
+ const carryingTheZone = (act: (timeZone: string) => void) => () => {
110
+ if (!settlement.settled) {
111
+ setNudged(true);
112
+ return;
113
+ }
114
+ act(settlement.timeZone);
115
+ };
116
+
41
117
  return (
42
- <div
118
+ <article
119
+ aria-label={suggestion.title}
43
120
  className={cn(
44
121
  "flex flex-col gap-2 rounded-lg border border-dashed border-line-strong bg-surface-sunken p-3",
45
122
  className,
@@ -91,13 +168,57 @@ export function EventSuggestionCard({
91
168
  </span>
92
169
  </button>
93
170
 
171
+ {suggestion.zoneOptions && (
172
+ <div className="flex flex-col gap-1.5 rounded-md border border-warning/40 p-2">
173
+ <p className="flex items-center gap-1.5 text-2xs font-semibold text-warning">
174
+ <Globe className="size-3 shrink-0" aria-hidden />
175
+ Which clock is this on?
176
+ </p>
177
+ {suggestion.zoneOptions.map((option) => (
178
+ <button
179
+ key={option.timeZone}
180
+ type="button"
181
+ aria-pressed={choice === option.timeZone}
182
+ onClick={() => pickZone(option.timeZone)}
183
+ className={cn(
184
+ "rounded-md border px-2 py-1 text-left outline-none focus-visible:ring-2 focus-visible:ring-ring",
185
+ touch && "min-h-11",
186
+ choice === option.timeZone
187
+ ? "border-accent bg-accent-soft"
188
+ : "border-line bg-surface hover:border-line-strong",
189
+ )}
190
+ >
191
+ <span className="block text-xs font-medium text-fg">
192
+ {option.label}
193
+ </span>
194
+ <span className="block text-2xs text-fg-subtle">
195
+ {option.note}
196
+ </span>
197
+ </button>
198
+ ))}
199
+ </div>
200
+ )}
201
+
202
+ {!settlement.settled && (
203
+ <BlockedReason
204
+ id={reasonId}
205
+ reason={settlement.reason}
206
+ nudged={nudged}
207
+ className="text-2xs text-warning"
208
+ />
209
+ )}
210
+
94
211
  <div className="flex items-center gap-2">
95
212
  <Button
96
213
  variant="primary"
97
214
  size={touch ? "md" : "sm"}
98
215
  icon={<Plus className="size-3.5" />}
99
- onClick={onAdd}
100
- className={touch ? "min-h-11 flex-1" : ""}
216
+ onClick={carryingTheZone(onAdd)}
217
+ aria-describedby={settlement.settled ? undefined : reasonId}
218
+ className={cn(
219
+ touch && "min-h-11 flex-1",
220
+ !settlement.settled && "opacity-55",
221
+ )}
101
222
  >
102
223
  Add
103
224
  </Button>
@@ -105,12 +226,16 @@ export function EventSuggestionCard({
105
226
  variant="secondary"
106
227
  size={touch ? "md" : "sm"}
107
228
  icon={<SlidersHorizontal className="size-3.5" />}
108
- onClick={onReview}
109
- className={touch ? "min-h-11" : ""}
229
+ onClick={carryingTheZone(onReview)}
230
+ aria-describedby={settlement.settled ? undefined : reasonId}
231
+ className={cn(
232
+ touch && "min-h-11",
233
+ !settlement.settled && "opacity-55",
234
+ )}
110
235
  >
111
236
  Change first
112
237
  </Button>
113
238
  </div>
114
- </div>
239
+ </article>
115
240
  );
116
241
  }
@@ -0,0 +1,328 @@
1
+ /**
2
+ * The zone gate — mounted against jsdom rather than `renderToString`, because
3
+ * what has to be proven is that pressing either way out of the card does
4
+ * nothing while the clock is unsettled.
5
+ *
6
+ * Nothing here may read the zone the process happens to run in, so the cases
7
+ * offer clocks the ambient zone is never one of, assert the exact zone that
8
+ * comes back, and move the machine between zones themselves rather than leaving
9
+ * that to whoever runs the suite.
10
+ */
11
+ import assert from "node:assert/strict";
12
+ import { after, afterEach, before, beforeEach, describe, it } from "node:test";
13
+ import type { JSDOM } from "jsdom";
14
+ import { act, createElement } from "react";
15
+ import { createRoot, type Root } from "react-dom/client";
16
+ import type { EventSuggestion, ZoneOptions } from "./calendar-types.js";
17
+ import {
18
+ EventSuggestionCard,
19
+ settleZone,
20
+ ZONE_UNSETTLED_REASON,
21
+ } from "./event-suggestion-card.js";
22
+
23
+ const LISBON = "Europe/Lisbon";
24
+ const AUCKLAND = "Pacific/Auckland";
25
+
26
+ const suggestion: EventSuggestion = {
27
+ id: "sug_lisbon_call",
28
+ title: "Kickoff call — Lisbon venue",
29
+ start: "2026-06-17T16:00:00+02:00",
30
+ end: "2026-06-17T17:00:00+02:00",
31
+ allDay: false,
32
+ location: "Meet link",
33
+ threadId: "thr_lisbon_call",
34
+ threadSubject: "Kickoff call on Wednesday at 16:00",
35
+ sender: "Rita Sousa",
36
+ senderAddress: "rita@aldeia.example",
37
+ confidence: 0.66,
38
+ ambiguity: "",
39
+ suggestedCalendarId: "c5",
40
+ timeZone: "",
41
+ zoneCertainty: "ambiguous",
42
+ };
43
+
44
+ const zoneless: EventSuggestion = {
45
+ ...suggestion,
46
+ zoneOptions: [
47
+ { timeZone: LISBON, label: "16:00 in Lisbon", note: "17:00 for you." },
48
+ { timeZone: AUCKLAND, label: "16:00 in Auckland", note: "03:00 for you." },
49
+ ],
50
+ };
51
+
52
+ const stated: EventSuggestion = {
53
+ ...suggestion,
54
+ timeZone: LISBON,
55
+ zoneCertainty: "explicit",
56
+ };
57
+
58
+ let dom: JSDOM;
59
+ let container: HTMLElement;
60
+ let root: Root;
61
+
62
+ before(async () => {
63
+ const { JSDOM: JSDOMCtor } = await import("jsdom");
64
+ dom = new JSDOMCtor(
65
+ "<!doctype html><html><body><div id=root></div></body></html>",
66
+ { url: "http://localhost/", pretendToBeVisual: true },
67
+ );
68
+ globalThis.window = dom.window as unknown as typeof globalThis.window;
69
+ globalThis.document = dom.window.document;
70
+ globalThis.HTMLElement = dom.window.HTMLElement;
71
+ globalThis.Element = dom.window.Element;
72
+ globalThis.MouseEvent = dom.window.MouseEvent;
73
+ Object.defineProperty(globalThis, "navigator", {
74
+ value: dom.window.navigator,
75
+ configurable: true,
76
+ });
77
+ (
78
+ globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }
79
+ ).IS_REACT_ACT_ENVIRONMENT = true;
80
+ });
81
+
82
+ after(() => {
83
+ dom.window.close();
84
+ });
85
+
86
+ beforeEach(() => {
87
+ container = dom.window.document.getElementById(
88
+ "root",
89
+ ) as unknown as HTMLElement;
90
+ container.innerHTML = "";
91
+ root = createRoot(container);
92
+ });
93
+
94
+ afterEach(() => {
95
+ act(() => {
96
+ root.unmount();
97
+ });
98
+ });
99
+
100
+ interface Answers {
101
+ onAdd?: (timeZone: string) => void;
102
+ onReview?: (timeZone: string) => void;
103
+ }
104
+
105
+ function mount(entry: EventSuggestion, answers: Answers = {}) {
106
+ act(() => {
107
+ root.render(
108
+ createElement(EventSuggestionCard, {
109
+ suggestion: entry,
110
+ whenText: "Friday 19 June · 18:40 – 20:25",
111
+ onAdd: answers.onAdd ?? (() => undefined),
112
+ onReview: answers.onReview ?? (() => undefined),
113
+ onDismiss: () => undefined,
114
+ onOpenThread: () => undefined,
115
+ }),
116
+ );
117
+ });
118
+ }
119
+
120
+ function buttonNamed(text: string): HTMLElement {
121
+ const found = Array.from(container.querySelectorAll("button")).find(
122
+ (candidate) => candidate.textContent?.includes(text),
123
+ );
124
+ assert.ok(found, `no button reading ${text}`);
125
+ return found as unknown as HTMLElement;
126
+ }
127
+
128
+ function press(target: HTMLElement) {
129
+ act(() => {
130
+ target.dispatchEvent(new dom.window.MouseEvent("click", { bubbles: true }));
131
+ });
132
+ }
133
+
134
+ /** The paragraph the dimmed Add points at through `aria-describedby`. */
135
+ function describedReason(): HTMLElement | null {
136
+ const id = buttonNamed("Add").getAttribute("aria-describedby");
137
+ if (id === null) return null;
138
+ return dom.window.document.getElementById(id);
139
+ }
140
+
141
+ function announced(): string {
142
+ return Array.from(container.querySelectorAll('[role="status"]'))
143
+ .map((node) => node.textContent ?? "")
144
+ .join(" ");
145
+ }
146
+
147
+ describe("the zone gate", () => {
148
+ it("refuses Add until a clock is picked, and says why", () => {
149
+ const added: string[] = [];
150
+ mount(zoneless, { onAdd: (timeZone) => added.push(timeZone) });
151
+
152
+ assert.equal(announced(), "");
153
+ assert.equal(describedReason()?.textContent, ZONE_UNSETTLED_REASON);
154
+ assert.ok(describedReason()?.classList.contains("sr-only"));
155
+
156
+ press(buttonNamed("Add"));
157
+
158
+ assert.deepEqual(added, []);
159
+ assert.match(announced(), /Pick a clock first/);
160
+ assert.equal(describedReason()?.classList.contains("sr-only"), false);
161
+ });
162
+
163
+ it("adds on the clock that was picked, never on the ambient one", () => {
164
+ const added: string[] = [];
165
+ mount(zoneless, { onAdd: (timeZone) => added.push(timeZone) });
166
+
167
+ press(buttonNamed("16:00 in Auckland"));
168
+ press(buttonNamed("Add"));
169
+
170
+ assert.deepEqual(added, [AUCKLAND]);
171
+ assert.notEqual(
172
+ AUCKLAND,
173
+ Intl.DateTimeFormat().resolvedOptions().timeZone,
174
+ "the case only proves anything while the ambient zone is a different one",
175
+ );
176
+ });
177
+
178
+ it("settles nothing when the mail already stated the clock", () => {
179
+ const added: string[] = [];
180
+ mount(stated, { onAdd: (timeZone) => added.push(timeZone) });
181
+
182
+ press(buttonNamed("Add"));
183
+
184
+ assert.deepEqual(added, [""]);
185
+ assert.equal(announced(), "");
186
+ });
187
+ });
188
+
189
+ /**
190
+ * Correcting the reading first is the other way the time leaves the card, and
191
+ * an editor seeded with the unconverted hour books the wrong one just as
192
+ * quietly as Add would.
193
+ */
194
+ describe("the same gate on Change first", () => {
195
+ it("refuses Change first until a clock is picked, and says why", () => {
196
+ const reviewed: string[] = [];
197
+ mount(zoneless, { onReview: (timeZone) => reviewed.push(timeZone) });
198
+
199
+ const describes =
200
+ buttonNamed("Change first").getAttribute("aria-describedby");
201
+ assert.equal(
202
+ describes,
203
+ buttonNamed("Add").getAttribute("aria-describedby"),
204
+ );
205
+ assert.equal(
206
+ describes === null
207
+ ? null
208
+ : dom.window.document.getElementById(describes)?.textContent,
209
+ ZONE_UNSETTLED_REASON,
210
+ );
211
+
212
+ press(buttonNamed("Change first"));
213
+
214
+ assert.deepEqual(reviewed, []);
215
+ assert.match(announced(), /Pick a clock first/);
216
+ });
217
+
218
+ it("opens on the clock that was picked, never on an empty one", () => {
219
+ const reviewed: string[] = [];
220
+ mount(zoneless, { onReview: (timeZone) => reviewed.push(timeZone) });
221
+
222
+ press(buttonNamed("16:00 in Auckland"));
223
+ press(buttonNamed("Change first"));
224
+
225
+ assert.deepEqual(reviewed, [AUCKLAND]);
226
+ });
227
+
228
+ it("stays live when the mail already stated the clock", () => {
229
+ const reviewed: string[] = [];
230
+ mount(stated, { onReview: (timeZone) => reviewed.push(timeZone) });
231
+
232
+ press(buttonNamed("Change first"));
233
+
234
+ assert.deepEqual(reviewed, [""]);
235
+ assert.equal(
236
+ buttonNamed("Change first").getAttribute("aria-describedby"),
237
+ null,
238
+ );
239
+ });
240
+ });
241
+
242
+ describe("the clocks a suggestion offers", () => {
243
+ it("cannot be an empty list, which would ask with nothing to press", () => {
244
+ // @ts-expect-error
245
+ const none: ZoneOptions = [];
246
+ assert.equal(none.length, 0);
247
+ });
248
+
249
+ it("never asks the question without a clock to answer it with", () => {
250
+ mount(zoneless);
251
+ assert.ok(container.textContent?.includes("Which clock is this on?"));
252
+ assert.equal(container.querySelectorAll("[aria-pressed]").length, 2);
253
+
254
+ mount(stated);
255
+ assert.equal(
256
+ container.textContent?.includes("Which clock is this on?"),
257
+ false,
258
+ );
259
+ assert.equal(container.querySelectorAll("[aria-pressed]").length, 0);
260
+ });
261
+ });
262
+
263
+ /**
264
+ * The zones the machine might be in. The hour is what noon UTC reads as there,
265
+ * asserted so a Node that ignored the switch could not pass the body silently.
266
+ */
267
+ const ZONES = [
268
+ { name: "UTC", hourAtNoonUtc: 12 },
269
+ { name: "Europe/Amsterdam", hourAtNoonUtc: 14 },
270
+ { name: "Pacific/Kiritimati", hourAtNoonUtc: 2 },
271
+ ];
272
+
273
+ function inEveryZone(body: () => void): void {
274
+ const before = process.env.TZ;
275
+ try {
276
+ for (const zone of ZONES) {
277
+ process.env.TZ = zone.name;
278
+ assert.equal(
279
+ new Date("2026-06-17T12:00:00Z").getHours(),
280
+ zone.hourAtNoonUtc,
281
+ `the machine never moved to ${zone.name}`,
282
+ );
283
+ body();
284
+ }
285
+ } finally {
286
+ if (before === undefined) delete process.env.TZ;
287
+ else process.env.TZ = before;
288
+ }
289
+ }
290
+
291
+ describe("settleZone", () => {
292
+ it("settles a suggestion that carries no clocks to answer", () => {
293
+ inEveryZone(() => {
294
+ assert.deepEqual(settleZone(stated, ""), { settled: true, timeZone: "" });
295
+ });
296
+ });
297
+
298
+ it("holds a suggestion whose clock nobody has picked", () => {
299
+ inEveryZone(() => {
300
+ assert.deepEqual(settleZone(zoneless, ""), {
301
+ settled: false,
302
+ reason: ZONE_UNSETTLED_REASON,
303
+ });
304
+ });
305
+ });
306
+
307
+ it("holds a choice that is not one of the clocks offered", () => {
308
+ inEveryZone(() => {
309
+ assert.deepEqual(settleZone(zoneless, "Europe/Amsterdam"), {
310
+ settled: false,
311
+ reason: ZONE_UNSETTLED_REASON,
312
+ });
313
+ assert.deepEqual(settleZone(zoneless, "UTC"), {
314
+ settled: false,
315
+ reason: ZONE_UNSETTLED_REASON,
316
+ });
317
+ });
318
+ });
319
+
320
+ it("carries the picked clock through untouched", () => {
321
+ inEveryZone(() => {
322
+ assert.deepEqual(settleZone(zoneless, LISBON), {
323
+ settled: true,
324
+ timeZone: LISBON,
325
+ });
326
+ });
327
+ });
328
+ });
@@ -102,6 +102,12 @@ export interface FolderTreePickerProps {
102
102
  ) => Promise<FolderTreeNode>;
103
103
  /** Escape. The picker never owns its presentation, so it cannot close itself. */
104
104
  onCancel?: () => void;
105
+ /**
106
+ * Takes focus on mount. A picker that opens away from where it was triggered
107
+ * — a portalled popover — leaves the keyboard behind on the trigger, so Tab
108
+ * walks the surrounding toolbar instead of entering the tree.
109
+ */
110
+ autoFocusFilter?: boolean;
105
111
  /** The provider's hierarchy separator. */
106
112
  delimiter?: string;
107
113
  labels?: FolderTreePickerLabels;
@@ -147,6 +153,7 @@ export const FolderTreePicker = ({
147
153
  onCancel,
148
154
  delimiter = "/",
149
155
  labels,
156
+ autoFocusFilter = false,
150
157
  }: FolderTreePickerProps) => {
151
158
  const text = { ...defaultLabels, ...labels };
152
159
  const [query, setQuery] = useState("");
@@ -163,10 +170,16 @@ export const FolderTreePicker = ({
163
170
  const [focusedIndex, setFocusedIndex] = useState(-1);
164
171
 
165
172
  const rowRefs = useRef<Array<HTMLButtonElement | null>>([]);
173
+ const filterRef = useRef<HTMLInputElement>(null);
166
174
  const roving = useRef(false);
167
175
  const createAbort = useRef<AbortController | null>(null);
168
176
  useEffect(() => () => createAbort.current?.abort(), []);
169
177
 
178
+ useEffect(() => {
179
+ if (!autoFocusFilter) return;
180
+ filterRef.current?.focus();
181
+ }, [autoFocusFilter]);
182
+
170
183
  const trimmedQuery = query.trim().toLowerCase();
171
184
  const ordered = useMemo(
172
185
  () => orderFolderNodes(folders, delimiter),
@@ -413,6 +426,7 @@ export const FolderTreePicker = ({
413
426
  return (
414
427
  <div className="flex min-h-0 w-full min-w-0 flex-col">
415
428
  <Input
429
+ ref={filterRef}
416
430
  variant="inline"
417
431
  className="border-b border-line px-3 py-2"
418
432
  icon={<Search className="size-4" aria-hidden="true" />}
@@ -1,6 +1,7 @@
1
1
  import type { Meta, StoryObj } from "@storybook/react";
2
2
  import { Mail, MailOpen, Tag } from "lucide-react";
3
- import { PopoverMenu } from "./popover-menu.js";
3
+ import { useState } from "react";
4
+ import { PopoverMenu, PopoverMenuRow } from "./popover-menu.js";
4
5
 
5
6
  const meta: Meta<typeof PopoverMenu> = {
6
7
  title: "Kit/PopoverMenu",
@@ -78,6 +79,50 @@ export const ManyItems: Story = {
78
79
  },
79
80
  };
80
81
 
82
+ function GrowingMenu() {
83
+ const [rows, setRows] = useState(2);
84
+ return (
85
+ <div className="flex h-screen items-end justify-end p-4">
86
+ <PopoverMenu
87
+ triggerLabel="More actions"
88
+ items={Array.from({ length: rows }, (_, i) => ({
89
+ key: `label-${i}`,
90
+ label: `Label ${i + 1}`,
91
+ icon: <Tag className="size-4" />,
92
+ onSelect: () => undefined,
93
+ }))}
94
+ >
95
+ <PopoverMenuRow
96
+ label="Show more"
97
+ onSelect={() => setRows((current) => current + 10)}
98
+ />
99
+ </PopoverMenu>
100
+ </div>
101
+ );
102
+ }
103
+
104
+ /**
105
+ * A panel that grows after it has been placed — a list arriving from a fetch,
106
+ * a confirmation bar appearing on a pick. It stays anchored to its trigger as
107
+ * it grows, rather than keeping the position it was given at its opening size
108
+ * and running off the bottom of the screen.
109
+ */
110
+ export const GrowsAfterOpening: Story = {
111
+ name: "Panel grows after it opens",
112
+ parameters: { layout: "fullscreen" },
113
+ render: () => <GrowingMenu />,
114
+ play: async ({ canvasElement }) => {
115
+ canvasElement
116
+ .querySelector<HTMLButtonElement>('[aria-label="More actions"]')
117
+ ?.click();
118
+ await new Promise((resolve) => setTimeout(resolve, 60));
119
+ // The panel is portalled onto the body, so it is outside the canvas.
120
+ Array.from(document.body.querySelectorAll<HTMLButtonElement>("button"))
121
+ .find((button) => button.textContent?.trim() === "Show more")
122
+ ?.click();
123
+ },
124
+ };
125
+
81
126
  export const WithNestedPicker: Story = {
82
127
  args: {
83
128
  triggerLabel: "More actions",
@@ -53,8 +53,11 @@ function clampToViewport(
53
53
  /**
54
54
  * Measures the anchor and the panel's own size, then keeps the panel's fixed
55
55
  * position clamped to the viewport for as long as it is open — reset on every
56
- * resize and on scroll anywhere in the ancestor chain, since a fixed position
57
- * does not follow a scrolled anchor on its own.
56
+ * resize, on scroll anywhere in the ancestor chain (a fixed position does not
57
+ * follow a scrolled anchor on its own), and whenever the panel's own box
58
+ * changes. A panel placed once at the size it opened with runs off the bottom
59
+ * as soon as its content arrives: a loading line gives way to a full list, a
60
+ * confirmation bar appears on a pick, a filter shrinks it back.
58
61
  */
59
62
  function useAnchoredPlacement(
60
63
  panelRef: RefObject<HTMLElement | null>,
@@ -92,7 +95,14 @@ function useAnchoredPlacement(
92
95
  place();
93
96
  window.addEventListener("resize", place);
94
97
  window.addEventListener("scroll", place, true);
98
+ const panel = panelRef.current;
99
+ let observer: ResizeObserver | null = null;
100
+ if (panel && typeof ResizeObserver !== "undefined") {
101
+ observer = new ResizeObserver(place);
102
+ observer.observe(panel);
103
+ }
95
104
  return () => {
105
+ observer?.disconnect();
96
106
  window.removeEventListener("resize", place);
97
107
  window.removeEventListener("scroll", place, true);
98
108
  };
@@ -116,6 +126,11 @@ export interface PopoverMenuPortalProps {
116
126
  * page — the compose body, a card, anything with its own `overflow` — no
117
127
  * matter how high its `z-index` climbs; escaping that ancestor takes leaving
118
128
  * its DOM subtree, which only a portal does.
129
+ *
130
+ * The wrapper carries the menu layer's own `z-50`: as a body child it would
131
+ * otherwise stack by document order alone and lose to every fixed surface
132
+ * already on the page. `z-[60]` stays above it, for confirmation dialogs and
133
+ * error banners that must cover an open menu.
119
134
  */
120
135
  export function PopoverMenuPortal({
121
136
  open,
@@ -127,7 +142,10 @@ export function PopoverMenuPortal({
127
142
  const style = useAnchoredPlacement(panelRef, open, align, getAnchor);
128
143
  if (!open) return null;
129
144
  return createPortal(
130
- <div style={style ?? { position: "fixed", visibility: "hidden" }}>
145
+ <div
146
+ className="z-50"
147
+ style={style ?? { position: "fixed", visibility: "hidden" }}
148
+ >
131
149
  {children}
132
150
  </div>,
133
151
  document.body,
package/src/index.ts CHANGED
@@ -148,6 +148,8 @@ export {
148
148
  type RecurrenceScope,
149
149
  type RsvpState,
150
150
  type ZoneCertainty,
151
+ type ZoneOption,
152
+ type ZoneOptions,
151
153
  } from "./components/calendar-types.js";
152
154
  export {
153
155
  Card,
@@ -254,6 +256,9 @@ export {
254
256
  export {
255
257
  EventSuggestionCard,
256
258
  type EventSuggestionCardProps,
259
+ settleZone,
260
+ ZONE_UNSETTLED_REASON,
261
+ type ZoneSettlement,
257
262
  } from "./components/event-suggestion-card.js";
258
263
  export {
259
264
  FieldLabel,
@@ -488,6 +493,7 @@ export {
488
493
  type PopoverMenuItem,
489
494
  PopoverMenuPanel,
490
495
  type PopoverMenuPanelProps,
496
+ PopoverMenuPortal,
491
497
  type PopoverMenuProps,
492
498
  PopoverMenuRow,
493
499
  type PopoverMenuRowProps,