@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 +1 -1
- package/src/components/calendar-types.ts +25 -0
- package/src/components/event-suggestion-card.render.test.ts +42 -1
- package/src/components/event-suggestion-card.stories.tsx +73 -1
- package/src/components/event-suggestion-card.tsx +134 -9
- package/src/components/event-suggestion-card.zone-gate.test.ts +328 -0
- package/src/components/folder-tree-picker.tsx +14 -0
- package/src/components/popover-menu.stories.tsx +46 -1
- package/src/components/popover-menu.tsx +21 -3
- package/src/index.ts +6 -0
package/package.json
CHANGED
|
@@ -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
|
|
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 {
|
|
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
|
-
|
|
11
|
-
|
|
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
|
-
<
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
</
|
|
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 {
|
|
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
|
|
57
|
-
*
|
|
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
|
|
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,
|