@protobase/ui 0.45.20 → 0.45.21

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.
Files changed (47) hide show
  1. package/dist/app/app.js +4 -1
  2. package/dist/app/pages/merge-ui.d.ts +1 -1
  3. package/dist/app/pages/merge-ui.js +5 -3
  4. package/dist/app/pages/project-ui.d.ts +5 -2
  5. package/dist/app/shell-bell.js +12 -0
  6. package/dist/auth/auth-provider.js +1 -1
  7. package/dist/data/api-provider.d.ts +18 -0
  8. package/dist/data/meta-data.d.ts +2 -0
  9. package/dist/data/query-keys.js +6 -0
  10. package/dist/data/use-meta.js +1 -0
  11. package/dist/index.d.ts +2 -1
  12. package/dist/index.js +3 -2
  13. package/dist/notifications/bell/messages.js +40 -0
  14. package/dist/notifications/bell/notification-bell.js +91 -0
  15. package/dist/notifications/bell/notification-item.js +75 -0
  16. package/dist/notifications/bell/notification-panel.js +71 -0
  17. package/dist/notifications/bell/notification-target.js +28 -0
  18. package/dist/notifications/bell/push-control.js +41 -0
  19. package/dist/notifications/bell/unread-label.js +12 -0
  20. package/dist/notifications/bell/use-inbox.d.ts +239 -0
  21. package/dist/notifications/bell/use-inbox.js +91 -0
  22. package/dist/notifications/push/use-push-notifications.d.ts +2 -1
  23. package/dist/notifications/push/use-push-notifications.js +7 -2
  24. package/dist/notifications/push/watch-permission.js +31 -0
  25. package/dist/package.js +1 -1
  26. package/dist/plugins/plugin-ui.js +2 -1
  27. package/dist/primitives/button.d.ts +4 -2
  28. package/dist/primitives/button.js +2 -1
  29. package/dist/primitives/popover-menu.js +14 -8
  30. package/dist/shell/assets/app-BRrWBdcb.js +9 -0
  31. package/dist/shell/assets/{index-CvTuaRIF.js → index-9vOkVIyi.js} +2 -2
  32. package/dist/shell/assets/index-BZXw3deq.css +2 -0
  33. package/dist/shell/assets/{records-map-DXlU-ssW.js → records-map-CXXkyoSM.js} +2 -2
  34. package/dist/shell/assets/shared-protobase-ui-C_KpcuOu.js +2 -0
  35. package/dist/shell/index.html +4 -4
  36. package/dist/shell/protobase.shell.json +1 -1
  37. package/dist/shell/protobase.sw.js +2 -2
  38. package/dist/shell-source-maps/assets/app-BRrWBdcb.js.map +1 -0
  39. package/dist/shell-source-maps/assets/{index-CvTuaRIF.js.map → index-9vOkVIyi.js.map} +1 -1
  40. package/dist/shell-source-maps/assets/{records-map-DXlU-ssW.js.map → records-map-CXXkyoSM.js.map} +1 -1
  41. package/dist/shell-source-maps/assets/shared-protobase-ui-C_KpcuOu.js.map +1 -0
  42. package/package.json +4 -4
  43. package/dist/shell/assets/app-1nDh53tv.js +0 -9
  44. package/dist/shell/assets/index-DVdwieyN.css +0 -2
  45. package/dist/shell/assets/shared-protobase-ui-CUeF-xz_.js +0 -2
  46. package/dist/shell-source-maps/assets/app-1nDh53tv.js.map +0 -1
  47. package/dist/shell-source-maps/assets/shared-protobase-ui-CUeF-xz_.js.map +0 -1
@@ -0,0 +1,239 @@
1
+ import { InboxNotification, InboxPage } from "@protobase/client";
2
+ import { InfiniteData } from "@tanstack/react-query";
3
+ //#region src/notifications/bell/use-inbox.d.ts
4
+ /**
5
+ * How many of the signed-in user's notifications are unread, at most 100 (show `99+` from there): read on load, every
6
+ * half minute while the page is visible, on a return to it, and after the user opens or marks one read. It stops
7
+ * reading once the app refuses the user their history (`historyRefused`).
8
+ */
9
+ export declare const useUnreadNotifications: ({ enabled }?: {
10
+ enabled?: boolean;
11
+ }) => import("@tanstack/react-query").UseQueryResult<number, Error>;
12
+ /**
13
+ * The signed-in user's notifications, newest first, a page at a time: `items` those loaded so far, `fetchNextPage`
14
+ * the next page while `hasNextPage`. Read only while `enabled`, such as while the panel is open.
15
+ */
16
+ export declare const useInbox: ({ enabled }?: {
17
+ enabled?: boolean;
18
+ }) => {
19
+ items: InboxNotification[];
20
+ data: InfiniteData<InboxPage, unknown>;
21
+ error: Error;
22
+ isError: true;
23
+ isPending: false;
24
+ isLoading: false;
25
+ isLoadingError: false;
26
+ isRefetchError: true;
27
+ isSuccess: false;
28
+ isPlaceholderData: false;
29
+ status: "error";
30
+ fetchNextPage: (options?: import("@tanstack/react-query").FetchNextPageOptions) => Promise<import("@tanstack/react-query").InfiniteQueryObserverResult<InfiniteData<InboxPage, unknown>, Error>>;
31
+ fetchPreviousPage: (options?: import("@tanstack/react-query").FetchPreviousPageOptions) => Promise<import("@tanstack/react-query").InfiniteQueryObserverResult<InfiniteData<InboxPage, unknown>, Error>>;
32
+ hasNextPage: boolean;
33
+ hasPreviousPage: boolean;
34
+ isFetchNextPageError: boolean;
35
+ isFetchingNextPage: boolean;
36
+ isFetchPreviousPageError: boolean;
37
+ isFetchingPreviousPage: boolean;
38
+ dataUpdatedAt: number;
39
+ errorUpdatedAt: number;
40
+ failureCount: number;
41
+ failureReason: Error | null;
42
+ errorUpdateCount: number;
43
+ isFetched: boolean;
44
+ isFetchedAfterMount: boolean;
45
+ isFetching: boolean;
46
+ isInitialLoading: boolean;
47
+ isPaused: boolean;
48
+ isRefetching: boolean;
49
+ isStale: boolean;
50
+ isEnabled: boolean;
51
+ refetch: (options?: import("@tanstack/react-query").RefetchOptions) => Promise<import("@tanstack/react-query").QueryObserverResult<InfiniteData<InboxPage, unknown>, Error>>;
52
+ fetchStatus: import("@tanstack/react-query").FetchStatus;
53
+ } | {
54
+ items: InboxNotification[];
55
+ data: InfiniteData<InboxPage, unknown>;
56
+ error: null;
57
+ isError: false;
58
+ isPending: false;
59
+ isLoading: false;
60
+ isLoadingError: false;
61
+ isRefetchError: false;
62
+ isFetchNextPageError: false;
63
+ isFetchPreviousPageError: false;
64
+ isSuccess: true;
65
+ isPlaceholderData: false;
66
+ status: "success";
67
+ fetchNextPage: (options?: import("@tanstack/react-query").FetchNextPageOptions) => Promise<import("@tanstack/react-query").InfiniteQueryObserverResult<InfiniteData<InboxPage, unknown>, Error>>;
68
+ fetchPreviousPage: (options?: import("@tanstack/react-query").FetchPreviousPageOptions) => Promise<import("@tanstack/react-query").InfiniteQueryObserverResult<InfiniteData<InboxPage, unknown>, Error>>;
69
+ hasNextPage: boolean;
70
+ hasPreviousPage: boolean;
71
+ isFetchingNextPage: boolean;
72
+ isFetchingPreviousPage: boolean;
73
+ dataUpdatedAt: number;
74
+ errorUpdatedAt: number;
75
+ failureCount: number;
76
+ failureReason: Error | null;
77
+ errorUpdateCount: number;
78
+ isFetched: boolean;
79
+ isFetchedAfterMount: boolean;
80
+ isFetching: boolean;
81
+ isInitialLoading: boolean;
82
+ isPaused: boolean;
83
+ isRefetching: boolean;
84
+ isStale: boolean;
85
+ isEnabled: boolean;
86
+ refetch: (options?: import("@tanstack/react-query").RefetchOptions) => Promise<import("@tanstack/react-query").QueryObserverResult<InfiniteData<InboxPage, unknown>, Error>>;
87
+ fetchStatus: import("@tanstack/react-query").FetchStatus;
88
+ } | {
89
+ items: InboxNotification[];
90
+ data: undefined;
91
+ error: Error;
92
+ isError: true;
93
+ isPending: false;
94
+ isLoading: false;
95
+ isLoadingError: true;
96
+ isRefetchError: false;
97
+ isFetchNextPageError: false;
98
+ isFetchPreviousPageError: false;
99
+ isSuccess: false;
100
+ isPlaceholderData: false;
101
+ status: "error";
102
+ fetchNextPage: (options?: import("@tanstack/react-query").FetchNextPageOptions) => Promise<import("@tanstack/react-query").InfiniteQueryObserverResult<InfiniteData<InboxPage, unknown>, Error>>;
103
+ fetchPreviousPage: (options?: import("@tanstack/react-query").FetchPreviousPageOptions) => Promise<import("@tanstack/react-query").InfiniteQueryObserverResult<InfiniteData<InboxPage, unknown>, Error>>;
104
+ hasNextPage: boolean;
105
+ hasPreviousPage: boolean;
106
+ isFetchingNextPage: boolean;
107
+ isFetchingPreviousPage: boolean;
108
+ dataUpdatedAt: number;
109
+ errorUpdatedAt: number;
110
+ failureCount: number;
111
+ failureReason: Error | null;
112
+ errorUpdateCount: number;
113
+ isFetched: boolean;
114
+ isFetchedAfterMount: boolean;
115
+ isFetching: boolean;
116
+ isInitialLoading: boolean;
117
+ isPaused: boolean;
118
+ isRefetching: boolean;
119
+ isStale: boolean;
120
+ isEnabled: boolean;
121
+ refetch: (options?: import("@tanstack/react-query").RefetchOptions) => Promise<import("@tanstack/react-query").QueryObserverResult<InfiniteData<InboxPage, unknown>, Error>>;
122
+ fetchStatus: import("@tanstack/react-query").FetchStatus;
123
+ } | {
124
+ items: InboxNotification[];
125
+ data: undefined;
126
+ error: null;
127
+ isError: false;
128
+ isPending: true;
129
+ isLoading: true;
130
+ isLoadingError: false;
131
+ isRefetchError: false;
132
+ isFetchNextPageError: false;
133
+ isFetchPreviousPageError: false;
134
+ isSuccess: false;
135
+ isPlaceholderData: false;
136
+ status: "pending";
137
+ fetchNextPage: (options?: import("@tanstack/react-query").FetchNextPageOptions) => Promise<import("@tanstack/react-query").InfiniteQueryObserverResult<InfiniteData<InboxPage, unknown>, Error>>;
138
+ fetchPreviousPage: (options?: import("@tanstack/react-query").FetchPreviousPageOptions) => Promise<import("@tanstack/react-query").InfiniteQueryObserverResult<InfiniteData<InboxPage, unknown>, Error>>;
139
+ hasNextPage: boolean;
140
+ hasPreviousPage: boolean;
141
+ isFetchingNextPage: boolean;
142
+ isFetchingPreviousPage: boolean;
143
+ dataUpdatedAt: number;
144
+ errorUpdatedAt: number;
145
+ failureCount: number;
146
+ failureReason: Error | null;
147
+ errorUpdateCount: number;
148
+ isFetched: boolean;
149
+ isFetchedAfterMount: boolean;
150
+ isFetching: boolean;
151
+ isInitialLoading: boolean;
152
+ isPaused: boolean;
153
+ isRefetching: boolean;
154
+ isStale: boolean;
155
+ isEnabled: boolean;
156
+ refetch: (options?: import("@tanstack/react-query").RefetchOptions) => Promise<import("@tanstack/react-query").QueryObserverResult<InfiniteData<InboxPage, unknown>, Error>>;
157
+ fetchStatus: import("@tanstack/react-query").FetchStatus;
158
+ } | {
159
+ items: InboxNotification[];
160
+ data: undefined;
161
+ error: null;
162
+ isError: false;
163
+ isPending: true;
164
+ isLoadingError: false;
165
+ isRefetchError: false;
166
+ isFetchNextPageError: false;
167
+ isFetchPreviousPageError: false;
168
+ isSuccess: false;
169
+ isPlaceholderData: false;
170
+ status: "pending";
171
+ fetchNextPage: (options?: import("@tanstack/react-query").FetchNextPageOptions) => Promise<import("@tanstack/react-query").InfiniteQueryObserverResult<InfiniteData<InboxPage, unknown>, Error>>;
172
+ fetchPreviousPage: (options?: import("@tanstack/react-query").FetchPreviousPageOptions) => Promise<import("@tanstack/react-query").InfiniteQueryObserverResult<InfiniteData<InboxPage, unknown>, Error>>;
173
+ hasNextPage: boolean;
174
+ hasPreviousPage: boolean;
175
+ isFetchingNextPage: boolean;
176
+ isFetchingPreviousPage: boolean;
177
+ dataUpdatedAt: number;
178
+ errorUpdatedAt: number;
179
+ failureCount: number;
180
+ failureReason: Error | null;
181
+ errorUpdateCount: number;
182
+ isFetched: boolean;
183
+ isFetchedAfterMount: boolean;
184
+ isFetching: boolean;
185
+ isLoading: boolean;
186
+ isInitialLoading: boolean;
187
+ isPaused: boolean;
188
+ isRefetching: boolean;
189
+ isStale: boolean;
190
+ isEnabled: boolean;
191
+ refetch: (options?: import("@tanstack/react-query").RefetchOptions) => Promise<import("@tanstack/react-query").QueryObserverResult<InfiniteData<InboxPage, unknown>, Error>>;
192
+ fetchStatus: import("@tanstack/react-query").FetchStatus;
193
+ } | {
194
+ items: InboxNotification[];
195
+ data: InfiniteData<InboxPage, unknown>;
196
+ isError: false;
197
+ error: null;
198
+ isPending: false;
199
+ isLoading: false;
200
+ isLoadingError: false;
201
+ isRefetchError: false;
202
+ isSuccess: true;
203
+ isPlaceholderData: true;
204
+ isFetchNextPageError: false;
205
+ isFetchPreviousPageError: false;
206
+ status: "success";
207
+ fetchNextPage: (options?: import("@tanstack/react-query").FetchNextPageOptions) => Promise<import("@tanstack/react-query").InfiniteQueryObserverResult<InfiniteData<InboxPage, unknown>, Error>>;
208
+ fetchPreviousPage: (options?: import("@tanstack/react-query").FetchPreviousPageOptions) => Promise<import("@tanstack/react-query").InfiniteQueryObserverResult<InfiniteData<InboxPage, unknown>, Error>>;
209
+ hasNextPage: boolean;
210
+ hasPreviousPage: boolean;
211
+ isFetchingNextPage: boolean;
212
+ isFetchingPreviousPage: boolean;
213
+ dataUpdatedAt: number;
214
+ errorUpdatedAt: number;
215
+ failureCount: number;
216
+ failureReason: Error | null;
217
+ errorUpdateCount: number;
218
+ isFetched: boolean;
219
+ isFetchedAfterMount: boolean;
220
+ isFetching: boolean;
221
+ isInitialLoading: boolean;
222
+ isPaused: boolean;
223
+ isRefetching: boolean;
224
+ isStale: boolean;
225
+ isEnabled: boolean;
226
+ refetch: (options?: import("@tanstack/react-query").RefetchOptions) => Promise<import("@tanstack/react-query").QueryObserverResult<InfiniteData<InboxPage, unknown>, Error>>;
227
+ fetchStatus: import("@tanstack/react-query").FetchStatus;
228
+ };
229
+ /**
230
+ * Marks notifications read: `markRead(item)` one, as opening it does, and `markAllRead(newest)` all of them up to the
231
+ * newest one the user was shown, so one that arrives meanwhile stays unread. The panel and the count show it at once,
232
+ * and are read again once the server has it.
233
+ */
234
+ export declare const useMarkNotificationsRead: () => {
235
+ markRead: (item: InboxNotification) => void;
236
+ markAllRead: import("@tanstack/react-query").UseMutateFunction<void, Error, InboxNotification, void>;
237
+ markingAll: boolean;
238
+ };
239
+ //#endregion
@@ -0,0 +1,91 @@
1
+ import { keys } from "../../data/query-keys.js";
2
+ import { useClient } from "../../data/api-provider.js";
3
+ import { useCallback } from "react";
4
+ import { isApiError } from "@protobase/client";
5
+ import { useInfiniteQuery, useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
6
+ //#region src/notifications/bell/use-inbox.ts
7
+ const unreadRefreshMs = 3e4;
8
+ /** Whether the app keeps the history from the user, as from a session its own sign-in does not mark as theirs. */
9
+ const historyRefused = (error) => isApiError(error) && error.status === 403;
10
+ /**
11
+ * How many of the signed-in user's notifications are unread, at most 100 (show `99+` from there): read on load, every
12
+ * half minute while the page is visible, on a return to it, and after the user opens or marks one read. It stops
13
+ * reading once the app refuses the user their history (`historyRefused`).
14
+ */
15
+ const useUnreadNotifications = ({ enabled = true } = {}) => {
16
+ const client = useClient();
17
+ return useQuery({
18
+ queryKey: keys.inboxUnread,
19
+ queryFn: () => client.notifications.unread(),
20
+ refetchInterval: (query) => historyRefused(query.state.error) ? false : unreadRefreshMs,
21
+ enabled
22
+ });
23
+ };
24
+ /**
25
+ * The signed-in user's notifications, newest first, a page at a time: `items` those loaded so far, `fetchNextPage`
26
+ * the next page while `hasNextPage`. Read only while `enabled`, such as while the panel is open.
27
+ */
28
+ const useInbox = ({ enabled = true } = {}) => {
29
+ const client = useClient();
30
+ const query = useInfiniteQuery({
31
+ queryKey: keys.inboxPages,
32
+ queryFn: ({ pageParam }) => client.notifications.list(pageParam === "" ? {} : { cursor: pageParam }),
33
+ initialPageParam: "",
34
+ getNextPageParam: (page) => page.next ?? void 0,
35
+ enabled
36
+ });
37
+ const items = query.data?.pages.flatMap((page) => page.items) ?? [];
38
+ return {
39
+ ...query,
40
+ items
41
+ };
42
+ };
43
+ const showRead = (queryClient, read) => {
44
+ const now = (/* @__PURE__ */ new Date()).toISOString();
45
+ let marked = 0;
46
+ queryClient.setQueryData(keys.inboxPages, (data) => data && {
47
+ ...data,
48
+ pages: data.pages.map((page) => ({
49
+ ...page,
50
+ items: page.items.map((item) => {
51
+ if (item.readAt !== null || !read(item)) return item;
52
+ marked += 1;
53
+ return {
54
+ ...item,
55
+ readAt: now
56
+ };
57
+ })
58
+ }))
59
+ });
60
+ queryClient.setQueryData(keys.inboxUnread, (count) => count === void 0 ? count : Math.max(0, count - marked));
61
+ };
62
+ /**
63
+ * Marks notifications read: `markRead(item)` one, as opening it does, and `markAllRead(newest)` all of them up to the
64
+ * newest one the user was shown, so one that arrives meanwhile stays unread. The panel and the count show it at once,
65
+ * and are read again once the server has it.
66
+ */
67
+ const useMarkNotificationsRead = () => {
68
+ const client = useClient();
69
+ const queryClient = useQueryClient();
70
+ const settled = () => queryClient.invalidateQueries({ queryKey: keys.inbox });
71
+ const one = useMutation({
72
+ mutationFn: (item) => client.notifications.markRead(item.id),
73
+ onMutate: (item) => showRead(queryClient, (shown) => shown.id === item.id),
74
+ onSettled: settled
75
+ });
76
+ const all = useMutation({
77
+ mutationFn: (newest) => client.notifications.markAllRead(newest.createdAt),
78
+ onMutate: (newest) => showRead(queryClient, (shown) => shown.createdAt <= newest.createdAt),
79
+ onSettled: settled
80
+ });
81
+ const markOne = one.mutate;
82
+ return {
83
+ markRead: useCallback((item) => {
84
+ if (item.readAt === null) markOne(item);
85
+ }, [markOne]),
86
+ markAllRead: all.mutate,
87
+ markingAll: all.isPending
88
+ };
89
+ };
90
+ //#endregion
91
+ export { historyRefused, useInbox, useMarkNotificationsRead, useUnreadNotifications };
@@ -5,7 +5,8 @@ import { PushState } from "./web-push.js";
5
5
  * then `unsupported` (hide the button: no Web Push here, such as a tab on iPhone and iPad, or the app has no VAPID key),
6
6
  * `off`, `on` or `blocked` (the user refused notifications in the browser's settings). Call `turnOn` straight from a
7
7
  * click: the browser asks for permission only then, and without a click by someone signed in as themselves nothing
8
- * happens. `turnOff` unsubscribes this browser and removes its device.
8
+ * happens. `turnOff` unsubscribes this browser and removes its device. `state` is read again on a return to the page
9
+ * and when the browser says the permission changed.
9
10
  */
10
11
  export declare const usePushNotifications: () => {
11
12
  state: "loading" | PushState;
@@ -1,4 +1,5 @@
1
1
  import { useAuth } from "../../auth/auth-provider.js";
2
+ import { watchNotificationPermission } from "./watch-permission.js";
2
3
  import { useCallback, useEffect, useState } from "react";
3
4
  //#region src/notifications/push/use-push-notifications.ts
4
5
  /**
@@ -6,7 +7,8 @@ import { useCallback, useEffect, useState } from "react";
6
7
  * then `unsupported` (hide the button: no Web Push here, such as a tab on iPhone and iPad, or the app has no VAPID key),
7
8
  * `off`, `on` or `blocked` (the user refused notifications in the browser's settings). Call `turnOn` straight from a
8
9
  * click: the browser asks for permission only then, and without a click by someone signed in as themselves nothing
9
- * happens. `turnOff` unsubscribes this browser and removes its device.
10
+ * happens. `turnOff` unsubscribes this browser and removes its device. `state` is read again on a return to the page
11
+ * and when the browser says the permission changed.
10
12
  */
11
13
  const usePushNotifications = () => {
12
14
  const { push, state: auth } = useAuth();
@@ -14,9 +16,12 @@ const usePushNotifications = () => {
14
16
  const [state, setState] = useState("loading");
15
17
  useEffect(() => {
16
18
  let current = true;
17
- push.state().then((read) => current && setState(read));
19
+ const read = () => void push.state().then((next) => current && setState(next));
20
+ read();
21
+ const stop = typeof window === "undefined" ? void 0 : watchNotificationPermission(read);
18
22
  return () => {
19
23
  current = false;
24
+ stop?.();
20
25
  };
21
26
  }, [push, userId]);
22
27
  return {
@@ -0,0 +1,31 @@
1
+ //#region src/notifications/push/watch-permission.ts
2
+ /**
3
+ * Calls `onChange` whenever the page's notification permission may have changed until the returned function is called:
4
+ * the Permissions API's change event, as when the user allows notifications from the address bar, and a return to the
5
+ * page, as from the browser's or the phone's settings, which also covers a browser whose Permissions API does not know
6
+ * notifications.
7
+ */
8
+ const watchNotificationPermission = (onChange, win = window) => {
9
+ let status;
10
+ let stopped = false;
11
+ const visible = () => {
12
+ if (win.document.visibilityState === "visible") onChange();
13
+ };
14
+ win.addEventListener("focus", onChange);
15
+ win.document.addEventListener("visibilitychange", visible);
16
+ if ("permissions" in win.navigator) win.navigator.permissions.query({ name: "notifications" }).then((answer) => {
17
+ if (stopped) return;
18
+ status = answer;
19
+ answer.addEventListener("change", onChange);
20
+ }, (error) => {
21
+ if (!(error instanceof TypeError)) throw error;
22
+ });
23
+ return () => {
24
+ stopped = true;
25
+ win.removeEventListener("focus", onChange);
26
+ win.document.removeEventListener("visibilitychange", visible);
27
+ status?.removeEventListener("change", onChange);
28
+ };
29
+ };
30
+ //#endregion
31
+ export { watchNotificationPermission };
package/dist/package.js CHANGED
@@ -1,5 +1,5 @@
1
1
  //#region package.json
2
- var version = "0.45.20";
2
+ var version = "0.45.21";
3
3
  var protobase = { "pluginApi": 1 };
4
4
  //#endregion
5
5
  export { protobase, version };
@@ -32,7 +32,8 @@ const pluginUi = (plugin, module) => {
32
32
  const shell = ui.shell && {
33
33
  ...ui.shell.actions && { actions: within(plugin, ui.shell.actions, "shell.actions") },
34
34
  ...ui.shell.rightPanel && { rightPanel: within(plugin, ui.shell.rightPanel, "shell.rightPanel") },
35
- ...ui.shell.logo && { logo: within(plugin, ui.shell.logo, "shell.logo") }
35
+ ...ui.shell.logo && { logo: within(plugin, ui.shell.logo, "shell.logo") },
36
+ ...ui.shell.bell && { bell: within(plugin, ui.shell.bell, "shell.bell") }
36
37
  };
37
38
  const beforeSignIn = ui.beforeSignIn && within(plugin, ui.beforeSignIn, "beforeSignIn");
38
39
  return {
@@ -1,9 +1,11 @@
1
- import { ButtonHTMLAttributes } from "react";
1
+ import { ButtonHTMLAttributes, Ref } from "react";
2
2
  //#region src/primitives/button.d.ts
3
3
  export type ButtonProps = ButtonHTMLAttributes<HTMLButtonElement> & {
4
4
  variant?: 'primary' | 'secondary' | 'ghost' | 'danger';
5
- size?: 'sm' | 'md';
5
+ /** `icon` is a square of the height of `sm`, for a button with an icon alone. */
6
+ size?: 'sm' | 'md' | 'icon';
6
7
  loading?: boolean;
8
+ ref?: Ref<HTMLButtonElement>;
7
9
  };
8
10
  /** A button's classes, for a link that looks like one. */
9
11
  export declare const buttonClass: ({ variant, size }?: Pick<ButtonProps, "variant" | "size">) => string;
@@ -10,7 +10,8 @@ const variants = {
10
10
  };
11
11
  const sizes = {
12
12
  sm: "h-7 px-2.5 text-body gap-1.5",
13
- md: "h-8 px-3 text-body gap-2"
13
+ md: "h-8 px-3 text-body gap-2",
14
+ icon: "size-7 text-body"
14
15
  };
15
16
  /** A button's classes, for a link that looks like one. */
16
17
  const buttonClass = ({ variant = "secondary", size = "md" } = {}) => cn("inline-flex shrink-0 items-center justify-center rounded-md font-medium transition-colors focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary disabled:opacity-60 disabled:cursor-default", variants[variant], sizes[size]);
@@ -3,19 +3,21 @@ import { useEffect, useLayoutEffect, useRef, useState } from "react";
3
3
  const gap = 4;
4
4
  /**
5
5
  * Below the button, or above it when the viewport has no room underneath; its right edge at the button's, or further
6
- * right when that would push its left edge off the viewport, as for a button near the left edge.
6
+ * right when that would push its left edge off the viewport, as for a button near the left edge. `width` is where a
7
+ * fixed element's `right` counts from.
7
8
  */
8
- const placeMenu = (button, menu) => {
9
+ const placeMenu = (button, menu, width) => {
9
10
  const below = button.bottom + gap;
10
11
  return {
11
12
  top: below + menu.height > window.innerHeight - gap ? Math.max(gap, button.top - gap - menu.height) : below,
12
- right: Math.max(gap, Math.min(window.innerWidth - button.right, window.innerWidth - gap - menu.width))
13
+ right: Math.max(gap, Math.min(width - button.right, width - gap - menu.width))
13
14
  };
14
15
  };
15
16
  /**
16
17
  * A menu opened by a button and drawn fixed, portalled to the body, under or over the button: it closes on a click
17
- * outside, Escape, and a scroll or resize, which would move the button away from it. `place` is undefined until the
18
- * menu is measured, so it is drawn hidden for that first frame.
18
+ * outside, Escape, and a scroll or resize, which would move the button away from it; a scroll inside the menu keeps it.
19
+ * `place` is undefined until the menu is measured, so it is drawn hidden for that first frame. The menu is a `ul` unless
20
+ * `T` names another element, such as a panel's `div`.
19
21
  */
20
22
  const usePopoverMenu = () => {
21
23
  const [open, setOpen] = useState(false);
@@ -27,18 +29,22 @@ const usePopoverMenu = () => {
27
29
  setPlace(void 0);
28
30
  return;
29
31
  }
32
+ menu.current.style.right = "0px";
33
+ const width = menu.current.getBoundingClientRect().right;
30
34
  setPlace(placeMenu(button.current.getBoundingClientRect(), {
31
35
  width: menu.current.offsetWidth,
32
36
  height: menu.current.offsetHeight
33
- }));
37
+ }, width));
34
38
  }, [open]);
35
39
  useEffect(() => {
36
40
  if (!open) return;
37
- const inside = (target) => button.current?.contains(target) || menu.current?.contains(target);
41
+ const inside = (target) => target instanceof Node && Boolean(button.current?.contains(target) || menu.current?.contains(target));
38
42
  const close = (event) => {
39
43
  if (event instanceof KeyboardEvent ? event.key === "Escape" : !inside(event.target)) setOpen(false);
40
44
  };
41
- const dismiss = () => setOpen(false);
45
+ const dismiss = (event) => {
46
+ if (!inside(event.target)) setOpen(false);
47
+ };
42
48
  document.addEventListener("mousedown", close);
43
49
  document.addEventListener("keydown", close);
44
50
  document.addEventListener("scroll", dismiss, true);