nexus-shared 2.0.0 → 3.1.0

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 (176) hide show
  1. package/CHANGELOG.md +446 -0
  2. package/LICENSE +21 -21
  3. package/README.md +1084 -903
  4. package/dist/Client.Index.d.ts +14 -0
  5. package/dist/Client.Index.js +14 -0
  6. package/dist/Components/Chats/Chat.d.ts +28 -0
  7. package/dist/Components/Chats/Chat.js +19 -0
  8. package/dist/Components/Chats/ChatButton.d.ts +26 -0
  9. package/dist/Components/Chats/ChatButton.js +41 -0
  10. package/dist/Components/Chats/ChatComposer.d.ts +41 -0
  11. package/dist/Components/Chats/ChatComposer.js +177 -0
  12. package/dist/Components/Chats/ChatConversations.d.ts +35 -0
  13. package/dist/Components/Chats/ChatConversations.js +38 -0
  14. package/dist/Components/Chats/ChatPanel.d.ts +66 -0
  15. package/dist/Components/Chats/ChatPanel.js +94 -0
  16. package/dist/Components/Chats/ChatParts.d.ts +87 -0
  17. package/dist/Components/Chats/ChatParts.js +96 -0
  18. package/dist/Components/Chats/ChatThread.d.ts +22 -0
  19. package/dist/Components/Chats/ChatThread.js +96 -0
  20. package/dist/Components/Documents/Button.d.ts +6 -1
  21. package/dist/Components/Documents/Button.js +2 -2
  22. package/dist/Components/Documents/Menu.js +44 -28
  23. package/dist/Components/Documents/SplitButton.d.ts +3 -1
  24. package/dist/Components/Documents/SplitButton.js +12 -8
  25. package/dist/Components/Documents/TabButtons.d.ts +29 -8
  26. package/dist/Components/Documents/TabButtons.js +24 -5
  27. package/dist/Components/Forms/ApiForm.d.ts +6 -4
  28. package/dist/Components/Forms/ApiForm.js +15 -14
  29. package/dist/Components/Forms/Crud.js +342 -71
  30. package/dist/Components/Forms/CrudParts.d.ts +5 -1
  31. package/dist/Components/Forms/CrudParts.js +37 -5
  32. package/dist/Components/Forms/ExcelImport.d.ts +58 -0
  33. package/dist/Components/Forms/ExcelImport.js +319 -0
  34. package/dist/Components/Forms/Form.js +9 -13
  35. package/dist/Components/Forms/SubmitForm.d.ts +11 -2
  36. package/dist/Components/Forms/SubmitForm.js +23 -12
  37. package/dist/Components/Inputs/DateTimePicker.js +2 -1
  38. package/dist/Components/Inputs/DropdownParts.d.ts +1 -1
  39. package/dist/Components/Inputs/DropdownParts.js +4 -6
  40. package/dist/Components/Inputs/GroupForm.js +4 -2
  41. package/dist/Components/Inputs/InputRenderer.d.ts +2 -0
  42. package/dist/Components/Inputs/InputRenderer.js +1 -1
  43. package/dist/Components/Inputs/NumberBox.js +24 -2
  44. package/dist/Components/Inputs/ReadOnlyNotice.js +2 -1
  45. package/dist/Components/Inputs/RowsInput.d.ts +12 -1
  46. package/dist/Components/Inputs/RowsInput.js +55 -6
  47. package/dist/Components/Inputs/Slider.js +4 -1
  48. package/dist/Components/Inputs/TabularForm.d.ts +7 -3
  49. package/dist/Components/Inputs/TabularForm.js +117 -20
  50. package/dist/Components/Inputs/TimePanel.js +4 -3
  51. package/dist/Components/Inputs/TimePicker.js +2 -1
  52. package/dist/Components/Layouts/Popup.js +1 -4
  53. package/dist/Components/Layouts/ThemePicker.d.ts +20 -3
  54. package/dist/Components/Layouts/ThemePicker.js +77 -6
  55. package/dist/Components/Layouts/ThemeScript.d.ts +4 -2
  56. package/dist/Components/Layouts/ThemeScript.js +4 -2
  57. package/dist/Components/Layouts/ThemeSwitcher.d.ts +6 -3
  58. package/dist/Components/Layouts/ThemeSwitcher.js +39 -18
  59. package/dist/Components/Viewers/Avatar.d.ts +21 -0
  60. package/dist/Components/Viewers/Avatar.js +21 -0
  61. package/dist/Components/Viewers/DataTable.js +262 -60
  62. package/dist/Components/Viewers/DataTableColumns.d.ts +32 -0
  63. package/dist/Components/Viewers/DataTableColumns.js +200 -0
  64. package/dist/Components/Viewers/DataTableFilters.d.ts +21 -0
  65. package/dist/Components/Viewers/DataTableFilters.js +68 -0
  66. package/dist/Components/Viewers/DataTableParts.d.ts +8 -4
  67. package/dist/Components/Viewers/DataTableParts.js +24 -12
  68. package/dist/Helpers/AnimationHelpers.d.ts +11 -0
  69. package/dist/Helpers/AnimationHelpers.js +20 -2
  70. package/dist/Helpers/ApiClient.d.ts +22 -0
  71. package/dist/Helpers/ApiClient.js +4 -1
  72. package/dist/Helpers/ApiFormHelpers.d.ts +27 -4
  73. package/dist/Helpers/ApiFormHelpers.js +73 -9
  74. package/dist/Helpers/ApiModules.d.ts +0 -4
  75. package/dist/Helpers/ApiModules.js +1 -0
  76. package/dist/Helpers/ApiResponses.d.ts +14 -4
  77. package/dist/Helpers/ApiResponses.js +116 -25
  78. package/dist/Helpers/ApiRoutes.d.ts +0 -10
  79. package/dist/Helpers/ApiRoutes.js +2 -10
  80. package/dist/Helpers/ChatBackend.d.ts +134 -0
  81. package/dist/Helpers/ChatBackend.js +332 -0
  82. package/dist/Helpers/ChatHelpers.d.ts +189 -0
  83. package/dist/Helpers/ChatHelpers.js +486 -0
  84. package/dist/Helpers/ChatHooks.d.ts +81 -0
  85. package/dist/Helpers/ChatHooks.js +175 -0
  86. package/dist/Helpers/ChatStore.d.ts +132 -0
  87. package/dist/Helpers/ChatStore.js +394 -0
  88. package/dist/Helpers/ChatSummaries.d.ts +39 -0
  89. package/dist/Helpers/ChatSummaries.js +118 -0
  90. package/dist/Helpers/CrudBackend.d.ts +54 -9
  91. package/dist/Helpers/CrudBackend.js +103 -6
  92. package/dist/Helpers/CrudHelpers.d.ts +39 -4
  93. package/dist/Helpers/CrudHelpers.js +76 -11
  94. package/dist/Helpers/ExcelBackend.d.ts +106 -0
  95. package/dist/Helpers/ExcelBackend.js +296 -0
  96. package/dist/Helpers/ExcelHelpers.d.ts +126 -0
  97. package/dist/Helpers/ExcelHelpers.js +344 -0
  98. package/dist/Helpers/FormStore.d.ts +11 -5
  99. package/dist/Helpers/FormStore.js +44 -6
  100. package/dist/Helpers/MessageBuilder.js +1 -2
  101. package/dist/Helpers/NumberHelpers.d.ts +7 -1
  102. package/dist/Helpers/NumberHelpers.js +41 -3
  103. package/dist/Helpers/PopoverHelpers.d.ts +46 -9
  104. package/dist/Helpers/PopoverHelpers.js +163 -24
  105. package/dist/Helpers/PopoverHooks.d.ts +24 -0
  106. package/dist/Helpers/PopoverHooks.js +62 -0
  107. package/dist/Helpers/RowsStore.d.ts +14 -3
  108. package/dist/Helpers/RowsStore.js +37 -3
  109. package/dist/Helpers/TableColumns.d.ts +7 -0
  110. package/dist/Helpers/TableColumns.js +41 -13
  111. package/dist/Helpers/TableExport.js +12 -12
  112. package/dist/Helpers/TableFilters.d.ts +25 -0
  113. package/dist/Helpers/TableFilters.js +162 -0
  114. package/dist/Helpers/TableHelpers.d.ts +15 -4
  115. package/dist/Helpers/TableHelpers.js +28 -4
  116. package/dist/Helpers/TableStore.d.ts +11 -3
  117. package/dist/Helpers/TableStore.js +16 -5
  118. package/dist/Helpers/ThemeHelpers.d.ts +21 -4
  119. package/dist/Helpers/ThemeHelpers.js +46 -9
  120. package/dist/Helpers/UserPreferences.d.ts +14 -0
  121. package/dist/Helpers/UserPreferences.js +99 -0
  122. package/dist/Interfaces/ApiInterfaces.d.ts +29 -4
  123. package/dist/Interfaces/ApiInterfaces.js +16 -10
  124. package/dist/Interfaces/ChatInterfaces.d.ts +240 -0
  125. package/dist/Interfaces/ChatInterfaces.js +1 -0
  126. package/dist/Interfaces/CrudInterfaces.d.ts +92 -12
  127. package/dist/Interfaces/ExcelInterfaces.d.ts +227 -0
  128. package/dist/Interfaces/ExcelInterfaces.js +45 -0
  129. package/dist/Interfaces/FormInterfaces.d.ts +85 -7
  130. package/dist/Interfaces/MessageInterfaces.d.ts +1 -1
  131. package/dist/Interfaces/PreferenceInterfaces.d.ts +43 -0
  132. package/dist/Interfaces/PreferenceInterfaces.js +28 -0
  133. package/dist/Interfaces/TableInterfaces.d.ts +116 -9
  134. package/dist/Interfaces/ThemeInterfaces.d.ts +25 -0
  135. package/dist/Interfaces/ThemeInterfaces.js +15 -0
  136. package/dist/Services/BrowserApi.d.ts +18 -1
  137. package/dist/Services/BrowserApi.js +18 -0
  138. package/dist/Services/ChatApi.d.ts +37 -0
  139. package/dist/Services/ChatApi.js +98 -0
  140. package/dist/Services/ExcelApi.d.ts +48 -0
  141. package/dist/Services/ExcelApi.js +196 -0
  142. package/dist/Services/ServerApi.d.ts +6 -1
  143. package/dist/Services/ServerApi.js +3 -0
  144. package/dist/Services/ThemeService.d.ts +18 -2
  145. package/dist/Services/ThemeService.js +80 -16
  146. package/dist/Shared.Index.d.ts +10 -0
  147. package/dist/Shared.Index.js +10 -0
  148. package/package.json +85 -82
  149. package/src/Styles/Nexus.Avatar.css +79 -0
  150. package/src/Styles/Nexus.Base.css +117 -70
  151. package/src/Styles/Nexus.Button.css +180 -151
  152. package/src/Styles/Nexus.Calendar.css +273 -275
  153. package/src/Styles/Nexus.Chat.css +1443 -0
  154. package/src/Styles/Nexus.Choice.css +343 -345
  155. package/src/Styles/Nexus.Crud.css +168 -84
  156. package/src/Styles/Nexus.DateTime.css +81 -81
  157. package/src/Styles/Nexus.Dropdown.css +725 -774
  158. package/src/Styles/Nexus.Editor.css +295 -297
  159. package/src/Styles/Nexus.Excel.css +389 -0
  160. package/src/Styles/Nexus.Form.css +279 -298
  161. package/src/Styles/Nexus.Index.css +37 -34
  162. package/src/Styles/Nexus.Input.css +602 -637
  163. package/src/Styles/Nexus.Markdown.css +237 -265
  164. package/src/Styles/Nexus.Menu.css +237 -249
  165. package/src/Styles/Nexus.Popup.css +724 -731
  166. package/src/Styles/Nexus.RichText.css +2 -32
  167. package/src/Styles/Nexus.Rows.css +637 -566
  168. package/src/Styles/Nexus.Slider.css +276 -278
  169. package/src/Styles/Nexus.Tab.Buttons.css +432 -364
  170. package/src/Styles/Nexus.Table.css +1386 -1014
  171. package/src/Styles/Nexus.Theme.Picker.css +286 -159
  172. package/src/Styles/Nexus.Themes.css +194 -194
  173. package/src/Styles/Nexus.Time.css +144 -147
  174. package/src/Styles/Nexus.Toast.css +263 -265
  175. package/src/Styles/Nexus.Tokens.css +89 -72
  176. package/src/Styles/Nexus.Tree.css +431 -435
@@ -0,0 +1,94 @@
1
+ "use client";
2
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
3
+ import { iconArrowLeft, iconChevronDown, iconPlus, iconUserPlus, iconUsers, iconX, InlineIcon } from "nexus-icons";
4
+ import { useEffect, useMemo, useState } from "react";
5
+ import { chatConversationName, chatScopeKey, pickChatConversation } from "../../Helpers/ChatHelpers.js";
6
+ import { chatDomainLabel } from "../../Helpers/ChatBackend.js";
7
+ import { useChat, useChatPlace } from "../../Helpers/ChatHooks.js";
8
+ import { cx } from "../../Helpers/ClassHelpers.js";
9
+ import { useBrowserTimeZone, useDatePreferences } from "../../Helpers/DatePreferences.js";
10
+ import { Button } from "../Documents/Button.js";
11
+ import { Dropdown } from "../Inputs/Dropdown.js";
12
+ import { ChatConversationList, ChatNewForm } from "./ChatConversations.js";
13
+ import { ChatParticipantList } from "./ChatParts.js";
14
+ import { ChatComposer } from "./ChatComposer.js";
15
+ import { ChatThread } from "./ChatThread.js";
16
+ /** What a record is called when the page does not say: the domain's name, when the app named it, and the record. */
17
+ function defaultTitle(scope) {
18
+ const label = chatDomainLabel(scope.domain);
19
+ return label ? `${label} · #${String(scope.parentId)}` : `Discussion · #${String(scope.parentId)}`;
20
+ }
21
+ /** A record's conversations, drawn in full. */
22
+ export function ChatPanel({ scope, source, sourceFor, place: placeSource, conversations: givenConversations, conversationId: givenConversationId, onConversationChange, title, description, summary, participants, searchParticipants, people, files = false, replies = true, mentions = true, mentionHref, header = true, height, enabled = true, pageSize, pollInterval, onSummary, footer, className, }) {
23
+ // A page that handed over one conversation's source is not asking about any others.
24
+ const single = source !== undefined;
25
+ const place = useChatPlace({ scope, place: placeSource, conversations: givenConversations, enabled: enabled && !single });
26
+ const [view, setView] = useState("thread");
27
+ const [chosenId, setChosenId] = useState(givenConversationId ?? null);
28
+ const [replyTo, setReplyTo] = useState(null);
29
+ const [asideOpen, setAsideOpen] = useState(false);
30
+ const [adding, setAdding] = useState([]);
31
+ useEffect(() => {
32
+ if (givenConversationId !== undefined)
33
+ setChosenId(givenConversationId);
34
+ }, [givenConversationId]);
35
+ // Which conversation is open: the page's, else the one picked out of the list — unread first, then most recent.
36
+ const current = useMemo(() => {
37
+ if (single)
38
+ return undefined;
39
+ const held = place.conversations;
40
+ const chosen = chosenId === null ? undefined : held.find(one => String(one.id) === String(chosenId));
41
+ return chosen ?? pickChatConversation(held);
42
+ }, [single, place.conversations, chosenId]);
43
+ // One conversation's messages: the page's own source, else one made for whichever conversation is open.
44
+ const openSource = current && sourceFor ? sourceFor(current) : undefined;
45
+ const { store, state } = useChat({
46
+ scope,
47
+ source: source ?? openSource,
48
+ ...(current && { conversationId: current.id }),
49
+ pageSize,
50
+ pollInterval,
51
+ summary,
52
+ participants: current?.participants ?? participants,
53
+ enabled: enabled && view === "thread" && (single || current !== undefined),
54
+ });
55
+ const preferences = useDatePreferences();
56
+ const zone = useBrowserTimeZone();
57
+ const time = useMemo(() => ({ ...preferences, ...(zone && { timeZone: zone }) }), [preferences, zone]);
58
+ const search = searchParticipants ?? store.searchParticipants;
59
+ const canAdd = state.features.addParticipants && (current?.type ?? "group") === "group";
60
+ const showPeople = people ?? (state.participants.length > 0 || state.features.participants || searchParticipants !== undefined);
61
+ const many = !single && place.conversations.length > 1;
62
+ // Unsent text belongs to one conversation, so the key holds the record, the reader, and which conversation it is.
63
+ const draftKey = `${chatScopeKey(scope)}:${String(scope.userId)}${current ? `:${String(current.id)}` : ""}`;
64
+ const name = current ? chatConversationName(current, scope.userId) : null;
65
+ // The record's counts reach the page after the render that changed them, never during it.
66
+ useEffect(() => {
67
+ onSummary?.(state.summary);
68
+ }, [onSummary, state.summary]);
69
+ // Once a conversation is open it stays open. Without this the panel would pick again whenever the list changed, and
70
+ // reading this one — which clears its unread count — would hand the panel to whichever conversation is unread next.
71
+ useEffect(() => {
72
+ if (current && chosenId === null)
73
+ setChosenId(current.id);
74
+ }, [current, chosenId]);
75
+ // A conversation's own counts go back into the list, so the switch shows what is waiting in the others. Only once
76
+ // its first page has arrived: a store that has loaded nothing yet holds zeros, which would wipe what the list knows.
77
+ useEffect(() => {
78
+ if (current && state.loaded)
79
+ place.apply(current.id, state.summary);
80
+ }, [current, place, state.loaded, state.summary]);
81
+ function open(conversation) {
82
+ setChosenId(conversation.id);
83
+ setView("thread");
84
+ setAsideOpen(false);
85
+ onConversationChange?.(conversation);
86
+ }
87
+ const style = height === undefined ? undefined : { "--nx-chat-height": typeof height === "number" ? `${height}px` : height };
88
+ const empty = !single && !place.loading && place.conversations.length === 0;
89
+ return (_jsxs("div", { className: cx("nx-chat-panel", asideOpen && "nx-chat-panel--with-aside", className), style: style, children: [header && (_jsxs("header", { className: "nx-chat-panel__bar", children: [view !== "thread" && (_jsx("button", { type: "button", className: "nx-chat-panel__back", onClick: () => setView("thread"), "aria-label": "Back to the conversation", title: "Back to the conversation", children: _jsx(InlineIcon, { icon: iconArrowLeft }) })), _jsxs("div", { className: "nx-chat-panel__heading", children: [view === "list" ? (_jsx("span", { className: "nx-chat-panel__title", children: "Conversations" })) : view === "new" ? (_jsx("span", { className: "nx-chat-panel__title", children: "New chat" })) : many ? (_jsxs("button", { type: "button", className: "nx-chat-panel__switch", onClick: () => setView("list"), "aria-haspopup": "true", children: [_jsx("span", { className: "nx-chat-panel__title", children: name }), _jsx(InlineIcon, { icon: iconChevronDown }), _jsxs("span", { className: "nx-chat-panel__of", children: ["of ", place.conversations.length] })] })) : (_jsx("span", { className: "nx-chat-panel__title", children: name ?? title ?? defaultTitle(scope) })), view === "thread" && description && _jsx("span", { className: "nx-chat-panel__description", children: description })] }), view === "thread" && !single && place.canCreate && !many && (_jsx("button", { type: "button", className: "nx-chat-panel__people", onClick: () => setView("new"), "aria-label": "New chat", title: "New chat", children: _jsx(InlineIcon, { icon: iconPlus }) })), view === "thread" && showPeople && (_jsxs("button", { type: "button", className: cx("nx-chat-panel__people", asideOpen && "is-open"), onClick: () => setAsideOpen(shown => !shown), "aria-pressed": asideOpen, "aria-label": asideOpen ? "Hide the people" : "Show the people", title: asideOpen ? "Hide the people" : "Show the people", children: [_jsx(InlineIcon, { icon: iconUsers }), state.participants.length > 0 && _jsx("span", { children: state.participants.length })] }))] })), _jsxs("div", { className: "nx-chat-panel__body", children: [view === "list" && (_jsx(ChatConversationList, { conversations: place.conversations, currentId: current?.id ?? null, userId: scope.userId, time: time, loading: place.loading, error: place.error, onRetry: () => void place.reload(), onPick: open, onNew: place.canCreate ? () => setView("new") : undefined })), view === "new" && _jsx(ChatNewForm, { place: place, searchParticipants: searchParticipants, onStarted: open, onCancel: () => setView("thread") }), view === "thread" && empty && (_jsx(ChatConversationList, { conversations: [], userId: scope.userId, time: time, loading: place.loading, error: place.error, onRetry: () => void place.reload(), onPick: open, onNew: place.canCreate ? () => setView("new") : undefined, empty: "Nobody has written about this record yet." })), view === "thread" && !empty && (_jsxs(_Fragment, { children: [_jsx(ChatThread, { store: store, state: state, userId: scope.userId, replies: replies, onReply: setReplyTo, marksRead: enabled }), showPeople && asideOpen && (_jsxs("aside", { className: "nx-chat-panel__aside", "aria-label": "People in this conversation", children: [_jsxs("div", { className: "nx-chat-aside__bar", children: [_jsx("span", { children: "People" }), _jsx("button", { type: "button", onClick: () => setAsideOpen(false), "aria-label": "Hide the people", children: _jsx(InlineIcon, { icon: iconX }) })] }), _jsx(ChatParticipantList, { participants: state.participants, userId: scope.userId }), canAdd && (_jsxs("div", { className: "nx-chat-aside__add", children: [_jsx(Dropdown, { multiple: true, name: "nx-chat-add-people", label: "Add people", placeholder: "Search by name", size: "sm", value: adding, valueKey: "id", labelKey: "name", descriptionKey: "role", loadOptions: search, isOptionInactive: (person) => person.inactive === true, onValueChange: (values) => setAdding(values) }), _jsx(Button, { size: "sm", variant: "secondary", icon: _jsx(InlineIcon, { icon: iconUserPlus }), disabled: adding.length === 0, onClick: () => {
90
+ const chosen = adding;
91
+ setAdding([]);
92
+ void store.addParticipants(chosen);
93
+ }, children: "Add" })] }))] }))] }))] }), view === "thread" && !empty && (_jsx(ChatComposer, { store: store, state: state, draftKey: draftKey, files: files, replyTo: replyTo, onCancelReply: () => setReplyTo(null), autoFocus: enabled, mentions: mentions ? state.participants : undefined, mentionHref: mentionHref })), footer && _jsx("div", { className: "nx-chat-panel__footer", children: footer })] }));
94
+ }
@@ -0,0 +1,87 @@
1
+ import type { ReactNode } from "react";
2
+ import { type ChatTimeOptions } from "../../Helpers/ChatHelpers.ts";
3
+ import type { ChatAttachment, ChatId, ChatMessage, ChatParticipant } from "../../Interfaces/ChatInterfaces.ts";
4
+ /** The shared `Avatar`, under the name the chat has always exported it by. */
5
+ export declare function ChatAvatar({ name, src, size }: {
6
+ name: string;
7
+ src?: string | null;
8
+ size?: "sm" | "md";
9
+ }): import("react").JSX.Element;
10
+ /** The day a run of messages was written: "Today", "Yesterday", or the date in the user's calendar. */
11
+ export declare function ChatDayDivider({ label }: {
12
+ label: string;
13
+ }): import("react").JSX.Element;
14
+ /** Where reading stopped last time. It stays where it was put, so it does not slide away as the thread is read. */
15
+ export declare function ChatUnreadLine({ count, label }: {
16
+ count?: number;
17
+ label?: string;
18
+ }): import("react").JSX.Element;
19
+ /**
20
+ * How far a message of this user's has got, in the one place the time sits: a turning spinner while it is on its way,
21
+ * one tick once the server has it, two once it has reached somebody, and two in the accent once it has been read. The
22
+ * mark is the same size in every state, so the line under a message does not shift as it settles.
23
+ */
24
+ export declare function ChatReadMark({ message, participants, userId }: {
25
+ message: ChatMessage;
26
+ participants: readonly ChatParticipant[];
27
+ userId: ChatId;
28
+ }): import("react").JSX.Element;
29
+ /** Files on a message: the name, the size, and a link when the file has a URL. */
30
+ export declare function ChatAttachments({ attachments }: {
31
+ attachments: readonly ChatAttachment[];
32
+ }): import("react").JSX.Element;
33
+ /** The message a reply answers, above the reply itself. */
34
+ export declare function ChatQuote({ message, onOpen }: {
35
+ message: ChatMessage | undefined;
36
+ onOpen?: (id: ChatId) => void;
37
+ }): import("react").JSX.Element;
38
+ /** A note the system wrote, not a person: no side, no avatar, no actions. */
39
+ export declare function ChatSystemNote({ message, time }: {
40
+ message: ChatMessage;
41
+ time: ChatTimeOptions;
42
+ }): import("react").JSX.Element;
43
+ export interface ChatMessageProps {
44
+ message: ChatMessage;
45
+ /** This user wrote it: it sits on the right. */
46
+ mine: boolean;
47
+ /** The first of a run: it carries the author's name. */
48
+ first: boolean;
49
+ /** The last of a run: it carries the time and the read mark. */
50
+ last: boolean;
51
+ time: ChatTimeOptions;
52
+ participants: readonly ChatParticipant[];
53
+ userId: ChatId;
54
+ /** The message this one answers, when it is held. */
55
+ quoted?: ChatMessage;
56
+ onReply?: (message: ChatMessage) => void;
57
+ onRemove?: (message: ChatMessage) => void;
58
+ onRetry?: (clientId: string) => void;
59
+ onDiscard?: (clientId: string) => void;
60
+ onOpenQuoted?: (id: ChatId) => void;
61
+ }
62
+ /**
63
+ * One message. Its own messages sit on the right in the primary tint, everyone else's on the left on a surface. The
64
+ * actions are one row of icon buttons that appear on hover or keyboard focus — and never move the message, so a long
65
+ * thread does not jump under the pointer.
66
+ */
67
+ export declare function ChatMessageItem({ message, mine, first, last, time, participants, userId, quoted, onReply, onRemove, onRetry, onDiscard, onOpenQuoted }: ChatMessageProps): import("react").JSX.Element;
68
+ /** Everyone in a conversation, with what each one is here. */
69
+ export declare function ChatParticipantList({ participants, userId, empty }: {
70
+ participants: readonly ChatParticipant[];
71
+ userId: ChatId;
72
+ empty?: string;
73
+ }): import("react").JSX.Element;
74
+ /** A thread with nothing in it yet. */
75
+ export declare function ChatEmpty({ message, hint }: {
76
+ message?: string;
77
+ hint?: ReactNode;
78
+ }): import("react").JSX.Element;
79
+ /** Something that did not load, with a way to try again. */
80
+ export declare function ChatFailure({ message, onRetry }: {
81
+ message: string;
82
+ onRetry?: () => void;
83
+ }): import("react").JSX.Element;
84
+ /** Messages on their way in. */
85
+ export declare function ChatLoading({ label }: {
86
+ label?: string;
87
+ }): import("react").JSX.Element;
@@ -0,0 +1,96 @@
1
+ "use client";
2
+ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { iconAlertCircle, iconCheck, iconChecks, iconCornerUpLeft, iconFile, iconLoader2, iconRefresh, iconTrash, iconX, InlineIcon } from "nexus-icons";
4
+ import { cx } from "../../Helpers/ClassHelpers.js";
5
+ import { chatDeliveryState, chatFileSize, chatTimeLabel, sameChatId, CHAT_DELIVERY_LABELS } from "../../Helpers/ChatHelpers.js";
6
+ import { markdownToPlainText } from "../../Helpers/MarkdownHelpers.js";
7
+ import { Avatar } from "../Viewers/Avatar.js";
8
+ import { MarkdownView } from "../Viewers/MarkdownView.js";
9
+ /*
10
+ * The pieces a chat is drawn from: the avatar, one message, the lines that break a thread into days and into read and
11
+ * unread, and the participants. Each one draws what it is given and nothing else — the state is in `ChatStore` — so
12
+ * the same pieces serve the panel, a page-wide chat, and a demo with messages in memory.
13
+ */
14
+ /** The shared `Avatar`, under the name the chat has always exported it by. */
15
+ export function ChatAvatar({ name, src, size = "md" }) {
16
+ return _jsx(Avatar, { name: name, src: src, size: size });
17
+ }
18
+ /** The day a run of messages was written: "Today", "Yesterday", or the date in the user's calendar. */
19
+ export function ChatDayDivider({ label }) {
20
+ return (_jsx("div", { className: "nx-chat-day", role: "separator", children: _jsx("span", { children: label }) }));
21
+ }
22
+ /** Where reading stopped last time. It stays where it was put, so it does not slide away as the thread is read. */
23
+ export function ChatUnreadLine({ count, label = "New messages" }) {
24
+ return (_jsx("div", { className: "nx-chat-unread-line", role: "separator", children: _jsx("span", { children: count && count > 0 ? `${label} · ${count}` : label }) }));
25
+ }
26
+ /**
27
+ * How far a message of this user's has got, in the one place the time sits: a turning spinner while it is on its way,
28
+ * one tick once the server has it, two once it has reached somebody, and two in the accent once it has been read. The
29
+ * mark is the same size in every state, so the line under a message does not shift as it settles.
30
+ */
31
+ export function ChatReadMark({ message, participants, userId }) {
32
+ const state = chatDeliveryState(message, userId, participants);
33
+ const read = (message.readBy ?? []).filter(id => !sameChatId(id, userId));
34
+ const label = state === "read" && read.length > 1 ? `Read by ${read.length}` : CHAT_DELIVERY_LABELS[state];
35
+ const icon = state === "sending" ? iconLoader2 : state === "failed" ? iconAlertCircle : state === "sent" ? iconCheck : iconChecks;
36
+ // The word is on the wrapper, so it is both the tooltip on hover and the name a screen reader reads for the mark.
37
+ return (_jsx("span", { className: cx("nx-chat-mark", `nx-chat-mark--${state}`, (state === "read" || state === "read-all") && "nx-chat-mark--seen", state === "sending" && "nx-spin"), role: "img", "aria-label": label, title: label, "data-state": state, children: _jsx(InlineIcon, { icon: icon }) }));
38
+ }
39
+ /** Files on a message: the name, the size, and a link when the file has a URL. */
40
+ export function ChatAttachments({ attachments }) {
41
+ return (_jsx("ul", { className: "nx-chat-files", children: attachments.map((file, index) => {
42
+ const size = chatFileSize(file.size);
43
+ const body = (_jsxs(_Fragment, { children: [_jsx(InlineIcon, { icon: iconFile }), _jsx("span", { className: "nx-chat-file__name", children: file.name }), size && _jsx("span", { className: "nx-chat-file__size", children: size })] }));
44
+ return (_jsx("li", { className: "nx-chat-file", children: file.url ? (_jsx("a", { href: file.url, target: "_blank", rel: "noreferrer", children: body })) : (_jsx("span", { children: body })) }, `${String(file.id ?? file.name)}-${index}`));
45
+ }) }));
46
+ }
47
+ /** The message a reply answers, above the reply itself. */
48
+ export function ChatQuote({ message, onOpen }) {
49
+ if (!message)
50
+ return _jsx("div", { className: "nx-chat-quote nx-chat-quote--gone", children: "The message this answers is no longer here." });
51
+ const body = (_jsxs(_Fragment, { children: [_jsx("span", { className: "nx-chat-quote__author", children: message.authorName || "Unknown" }), _jsx("span", { className: "nx-chat-quote__body", children: markdownToPlainText(message.body) })] }));
52
+ if (!onOpen)
53
+ return _jsx("div", { className: "nx-chat-quote", children: body });
54
+ return (_jsx("button", { type: "button", className: "nx-chat-quote", onClick: () => onOpen(message.id), children: body }));
55
+ }
56
+ /** A note the system wrote, not a person: no side, no avatar, no actions. */
57
+ export function ChatSystemNote({ message, time }) {
58
+ return (_jsxs("div", { className: "nx-chat-note", children: [_jsx("span", { children: markdownToPlainText(message.body) }), _jsx("time", { dateTime: message.createdAt, children: chatTimeLabel(message.createdAt, time) })] }));
59
+ }
60
+ /**
61
+ * One message. Its own messages sit on the right in the primary tint, everyone else's on the left on a surface. The
62
+ * actions are one row of icon buttons that appear on hover or keyboard focus — and never move the message, so a long
63
+ * thread does not jump under the pointer.
64
+ */
65
+ export function ChatMessageItem({ message, mine, first, last, time, participants, userId, quoted, onReply, onRemove, onRetry, onDiscard, onOpenQuoted }) {
66
+ const failed = message.status === "failed";
67
+ const waiting = message.status === "sending";
68
+ const actions = [];
69
+ if (onReply && !failed && !waiting)
70
+ actions.push(_jsx(ChatAction, { icon: iconCornerUpLeft, label: "Reply", onClick: () => onReply(message) }, "reply"));
71
+ if (onRemove && mine && !failed && !waiting)
72
+ actions.push(_jsx(ChatAction, { icon: iconTrash, label: "Delete", tone: "danger", onClick: () => onRemove(message) }, "remove"));
73
+ return (_jsxs("div", { className: cx("nx-chat-msg", mine ? "nx-chat-msg--mine" : "nx-chat-msg--theirs", first && "nx-chat-msg--first", last && "nx-chat-msg--last", failed && "nx-chat-msg--failed"), children: [_jsxs("div", { className: "nx-chat-msg__bubble", children: [first && !mine && _jsx("span", { className: "nx-chat-msg__author", children: message.authorName || "Unknown" }), message.replyToId !== null && message.replyToId !== undefined && _jsx(ChatQuote, { message: quoted, onOpen: onOpenQuoted }), message.body && _jsx(MarkdownView, { className: "nx-chat-msg__body", value: message.body }), message.attachments && message.attachments.length > 0 && _jsx(ChatAttachments, { attachments: message.attachments }), _jsxs("span", { className: "nx-chat-msg__meta", children: [message.editedAt && _jsx("span", { className: "nx-chat-msg__edited", children: "edited" }), _jsx("time", { dateTime: message.createdAt, children: chatTimeLabel(message.createdAt, time) }), mine && _jsx(ChatReadMark, { message: message, participants: participants, userId: userId })] }), actions.length > 0 && _jsx("span", { className: "nx-chat-msg__actions", children: actions })] }), failed && (_jsxs("div", { className: "nx-chat-msg__failure", children: [_jsx(InlineIcon, { icon: iconAlertCircle }), _jsx("span", { children: message.error || "Not sent." }), message.clientId && onRetry && (_jsxs("button", { type: "button", onClick: () => onRetry(message.clientId), children: [_jsx(InlineIcon, { icon: iconRefresh }), " Retry"] })), message.clientId && onDiscard && (_jsxs("button", { type: "button", onClick: () => onDiscard(message.clientId), children: [_jsx(InlineIcon, { icon: iconX }), " Discard"] }))] }))] }));
74
+ }
75
+ /** One icon button in a message's action row. */
76
+ function ChatAction({ icon, label, tone, onClick }) {
77
+ return (_jsx("button", { type: "button", className: cx("nx-chat-msg__action", tone === "danger" && "nx-chat-msg__action--danger"), onClick: onClick, "aria-label": label, title: label, children: _jsx(InlineIcon, { icon: icon }) }));
78
+ }
79
+ /** Everyone in a conversation, with what each one is here. */
80
+ export function ChatParticipantList({ participants, userId, empty = "Nobody else yet." }) {
81
+ if (participants.length === 0)
82
+ return _jsx("p", { className: "nx-chat-aside__empty", children: empty });
83
+ return (_jsx("ul", { className: "nx-chat-people", children: participants.map(person => (_jsxs("li", { className: cx("nx-chat-person", person.inactive && "nx-chat-person--inactive"), children: [_jsx(ChatAvatar, { name: person.name, src: person.avatar, size: "sm" }), _jsxs("span", { className: "nx-chat-person__text", children: [_jsxs("span", { className: "nx-chat-person__name", children: [person.name, sameChatId(person.id, userId) && _jsx("span", { className: "nx-chat-person__you", children: "you" })] }), (person.role || person.readOnly) && _jsx("span", { className: "nx-chat-person__role", children: person.readOnly ? [person.role, "reads only"].filter(Boolean).join(" · ") : person.role })] })] }, String(person.id)))) }));
84
+ }
85
+ /** A thread with nothing in it yet. */
86
+ export function ChatEmpty({ message = "No messages yet.", hint }) {
87
+ return (_jsxs("div", { className: "nx-chat-empty", children: [_jsx("p", { children: message }), hint && _jsx("p", { className: "nx-chat-empty__hint", children: hint })] }));
88
+ }
89
+ /** Something that did not load, with a way to try again. */
90
+ export function ChatFailure({ message, onRetry }) {
91
+ return (_jsxs("div", { className: "nx-chat-failure", role: "alert", children: [_jsx(InlineIcon, { icon: iconAlertCircle }), _jsx("span", { children: message }), onRetry && (_jsxs("button", { type: "button", onClick: onRetry, children: [_jsx(InlineIcon, { icon: iconRefresh }), " Try again"] }))] }));
92
+ }
93
+ /** Messages on their way in. */
94
+ export function ChatLoading({ label = "Loading…" }) {
95
+ return (_jsxs("div", { className: "nx-chat-loading", "aria-live": "polite", children: [_jsx(InlineIcon, { icon: iconLoader2, className: "nx-spin" }), _jsx("span", { children: label })] }));
96
+ }
@@ -0,0 +1,22 @@
1
+ import { type ChatTimeOptions } from "../../Helpers/ChatHelpers.ts";
2
+ import type { ChatSnapshot, ChatStore } from "../../Helpers/ChatStore.ts";
3
+ import type { ChatId, ChatMessage } from "../../Interfaces/ChatInterfaces.ts";
4
+ export interface ChatThreadProps {
5
+ store: ChatStore;
6
+ state: ChatSnapshot;
7
+ /** Who is reading: their messages sit on the right. */
8
+ userId: ChatId;
9
+ /** The user's date settings and zone. Default: their own, from local storage. */
10
+ time?: ChatTimeOptions;
11
+ /** A message can be answered. Default true. */
12
+ replies?: boolean;
13
+ /** A reply was started: the panel puts the message in the composer. */
14
+ onReply?: (message: ChatMessage) => void;
15
+ /** Reading the newest message marks the conversation read. Default true. */
16
+ marksRead?: boolean;
17
+ /** Shown when there is not a single message. */
18
+ emptyMessage?: string;
19
+ className?: string;
20
+ }
21
+ /** The messages of a conversation, in days and runs, with the lines that break them up. */
22
+ export declare function ChatThread({ store, state, userId, time, replies, onReply, marksRead, emptyMessage, className }: ChatThreadProps): import("react").JSX.Element;
@@ -0,0 +1,96 @@
1
+ "use client";
2
+ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { iconArrowDown, iconMessageCircle, InlineIcon } from "nexus-icons";
4
+ import { Fragment, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
5
+ import { smoothScrollAllowed } from "../../Helpers/AnimationHelpers.js";
6
+ import { cx } from "../../Helpers/ClassHelpers.js";
7
+ import { chatBadgeText, chatMessageKey, groupChatMessages, sameChatId } from "../../Helpers/ChatHelpers.js";
8
+ import { useBrowserTimeZone, useDatePreferences } from "../../Helpers/DatePreferences.js";
9
+ import { Button } from "../Documents/Button.js";
10
+ import { ChatAvatar, ChatDayDivider, ChatEmpty, ChatFailure, ChatLoading, ChatMessageItem, ChatSystemNote, ChatUnreadLine } from "./ChatParts.js";
11
+ /*
12
+ * The thread: the conversation itself, scrolling. It opens at the newest message, keeps itself at the bottom while the
13
+ * reader is already there, and when they are not, it leaves the scroll alone and offers a button down to the newest
14
+ * with the number of messages that arrived. Reaching the top loads the page above and holds the reader's place.
15
+ *
16
+ * Reading marks the conversation read, but only once the newest message is actually on screen: a panel opened and left
17
+ * scrolled up has not been read.
18
+ */
19
+ /** How close to an edge counts as being at it. */
20
+ const EDGE = 32;
21
+ /** How near the top loading the page above starts. */
22
+ const TOP_EDGE = 120;
23
+ /** The messages of a conversation, in days and runs, with the lines that break them up. */
24
+ export function ChatThread({ store, state, userId, time, replies = true, onReply, marksRead = true, emptyMessage, className }) {
25
+ const preferences = useDatePreferences();
26
+ const zone = useBrowserTimeZone();
27
+ const settings = useMemo(() => ({ ...preferences, ...(zone && { timeZone: zone }), ...time }), [preferences, zone, time]);
28
+ const listRef = useRef(null);
29
+ const [atBottom, setAtBottom] = useState(true);
30
+ const [missed, setMissed] = useState(0);
31
+ // What the scroll looked like before a page of older messages arrived, so the reader's place can be given back.
32
+ const anchor = useRef(null);
33
+ const lastKey = useRef(null);
34
+ const count = useRef(0);
35
+ const days = useMemo(() => groupChatMessages(state.messages, { userId, unreadFrom: state.unreadFrom, ...settings }), [state.messages, state.unreadFrom, userId, settings]);
36
+ const byId = useMemo(() => new Map(state.messages.map(message => [String(message.id), message])), [state.messages]);
37
+ const scrollToEnd = useCallback((smooth) => {
38
+ const list = listRef.current;
39
+ if (!list)
40
+ return;
41
+ // Smooth only when the visitor and the system both allow motion; otherwise it jumps.
42
+ list.scrollTo({ top: list.scrollHeight, behavior: smooth && smoothScrollAllowed() ? "smooth" : "auto" });
43
+ }, []);
44
+ // New messages: stay at the bottom when already there, otherwise hold the place and count what came in.
45
+ useLayoutEffect(() => {
46
+ const list = listRef.current;
47
+ if (!list)
48
+ return;
49
+ if (anchor.current) {
50
+ // A page of older messages: the scroll keeps the message it was on, rather than jumping to the new top.
51
+ list.scrollTop = anchor.current.top + (list.scrollHeight - anchor.current.height);
52
+ anchor.current = null;
53
+ count.current = state.messages.length;
54
+ return;
55
+ }
56
+ const newest = state.messages[state.messages.length - 1];
57
+ const key = newest ? chatMessageKey(newest) : null;
58
+ const grew = state.messages.length > count.current;
59
+ const changed = key !== lastKey.current;
60
+ count.current = state.messages.length;
61
+ lastKey.current = key;
62
+ if (!grew || !changed)
63
+ return;
64
+ if (atBottom || sameChatId(newest?.authorId, userId))
65
+ scrollToEnd(lastKey.current !== null && missed === 0);
66
+ else
67
+ setMissed(value => value + 1);
68
+ // `missed` is read, not depended on: a second message must not re-run the first one's scroll.
69
+ // eslint-disable-next-line react-hooks/exhaustive-deps
70
+ }, [state.messages, atBottom, userId, scrollToEnd]);
71
+ // The conversation counts as read once its newest message has been on screen.
72
+ useEffect(() => {
73
+ if (!marksRead || !atBottom || state.loading || state.summary.unreadCount === 0)
74
+ return;
75
+ void store.markRead();
76
+ }, [marksRead, atBottom, state.loading, state.summary.unreadCount, store]);
77
+ const onScroll = useCallback(() => {
78
+ const list = listRef.current;
79
+ if (!list)
80
+ return;
81
+ const bottom = list.scrollHeight - list.scrollTop - list.clientHeight <= EDGE;
82
+ setAtBottom(bottom);
83
+ if (bottom)
84
+ setMissed(0);
85
+ if (list.scrollTop <= TOP_EDGE && state.hasMore && !state.loadingOlder) {
86
+ anchor.current = { height: list.scrollHeight, top: list.scrollTop };
87
+ void store.loadOlder();
88
+ }
89
+ }, [state.hasMore, state.loadingOlder, store]);
90
+ const quiet = !state.loading && state.messages.length === 0;
91
+ return (_jsxs("div", { className: cx("nx-chat-thread", className), children: [_jsxs("div", { className: "nx-chat-thread__list", ref: listRef, onScroll: onScroll, role: "log", "aria-live": "polite", "aria-relevant": "additions", tabIndex: 0, children: [state.loadingOlder && _jsx(ChatLoading, { label: "Loading earlier messages\u2026" }), !state.loadingOlder && state.hasMore && (_jsx("div", { className: "nx-chat-thread__older", children: _jsx(Button, { size: "sm", variant: "ghost", onClick: () => void store.loadOlder(), children: "Earlier messages" }) })), state.loading && state.messages.length === 0 && _jsx(ChatLoading, { label: "Loading the conversation\u2026" }), state.error && _jsx(ChatFailure, { message: state.error, onRetry: () => void store.refresh() }), quiet && !state.error && _jsx(ChatEmpty, { message: emptyMessage ?? "No messages yet.", hint: _jsxs(_Fragment, { children: [_jsx(InlineIcon, { icon: iconMessageCircle }), " Write the first one below."] }) }), days.map(day => (_jsxs("section", { className: "nx-chat-thread__day", children: [_jsx(ChatDayDivider, { label: day.label }), day.runs.map((run, runIndex) => (_jsxs(Fragment, { children: [run.startsUnread && _jsx(ChatUnreadLine, { count: state.summary.unreadCount }), _jsx("div", { className: cx("nx-chat-run", run.mine && "nx-chat-run--mine", run.system && "nx-chat-run--system"), children: run.system ? (run.messages.map(message => _jsx(ChatSystemNote, { message: message, time: settings }, chatMessageKey(message)))) : (_jsxs(_Fragment, { children: [!run.mine && _jsx(ChatAvatarColumn, { name: run.authorName, src: run.authorAvatar }), _jsx("div", { className: "nx-chat-run__messages", children: run.messages.map((message, index) => (_jsx(ChatMessageItem, { message: message, mine: run.mine, first: index === 0, last: index === run.messages.length - 1, time: settings, participants: state.participants, userId: userId, quoted: message.replyToId ? byId.get(String(message.replyToId)) : undefined, onReply: replies && onReply ? onReply : undefined, onRemove: run.mine && state.features.remove ? message => void store.remove(message.id) : undefined, onRetry: store.retry, onDiscard: store.discard }, chatMessageKey(message)))) })] })) })] }, `${day.date}-${runIndex}`)))] }, day.date)))] }), !atBottom && (_jsxs("button", { type: "button", className: "nx-chat-thread__jump", onClick: () => scrollToEnd(true), "aria-label": missed > 0 ? `${missed} new, go to the newest` : "Go to the newest message", children: [_jsx(InlineIcon, { icon: iconArrowDown }), missed > 0 && _jsx("span", { className: "nx-chat-thread__jump-count", children: chatBadgeText(missed) })] }))] }));
92
+ }
93
+ /** The avatar beside a run of somebody else's messages, kept out of the bubbles so they line up under each other. */
94
+ function ChatAvatarColumn({ name, src }) {
95
+ return (_jsx("div", { className: "nx-chat-run__avatar", children: _jsx(ChatAvatar, { name: name, src: src }) }));
96
+ }
@@ -21,9 +21,14 @@ export interface ButtonProps extends ComponentPropsWithRef<"button"> {
21
21
  loading?: boolean;
22
22
  /** Fills the width of its container. */
23
23
  block?: boolean;
24
+ /**
25
+ * A small dot on the corner. `true` is the red dot of something new, such as unread notifications; `"primary"` marks
26
+ * a state that is on, such as filters.
27
+ */
28
+ dot?: boolean | "primary";
24
29
  }
25
30
  /**
26
31
  * A button. Without children it becomes a square icon button, which then needs an `aria-label`.
27
32
  * Has no client state, so it renders in server and client components alike.
28
33
  */
29
- export declare function Button({ variant, size, hoverTone, icon, iconEnd, loading, block, type, disabled, className, children, ...rest }: ButtonProps): import("react").JSX.Element;
34
+ export declare function Button({ variant, size, hoverTone, icon, iconEnd, loading, block, dot, type, disabled, className, children, ...rest }: ButtonProps): import("react").JSX.Element;
@@ -5,7 +5,7 @@ import { cx } from "../../Helpers/ClassHelpers.js";
5
5
  * A button. Without children it becomes a square icon button, which then needs an `aria-label`.
6
6
  * Has no client state, so it renders in server and client components alike.
7
7
  */
8
- export function Button({ variant = "secondary", size = "md", hoverTone, icon, iconEnd, loading = false, block = false, type = "button", disabled, className, children, ...rest }) {
8
+ export function Button({ variant = "secondary", size = "md", hoverTone, icon, iconEnd, loading = false, block = false, dot = false, type = "button", disabled, className, children, ...rest }) {
9
9
  const hasLabel = children !== undefined && children !== null && children !== false;
10
- return (_jsxs("button", { type: type, className: cx("nx-btn", `nx-btn--${variant}`, `nx-btn--${size}`, hoverTone && `nx-btn--hover-${hoverTone}`, block && "nx-btn--block", !hasLabel && "nx-btn--icon", className), disabled: disabled || loading, "aria-busy": loading || undefined, ...rest, children: [loading ? _jsx(InlineIcon, { icon: iconLoader2, className: "nx-spin" }) : icon, hasLabel && _jsx("span", { className: "nx-btn__label", children: children }), iconEnd] }));
10
+ return (_jsxs("button", { type: type, className: cx("nx-btn", `nx-btn--${variant}`, `nx-btn--${size}`, hoverTone && `nx-btn--hover-${hoverTone}`, block && "nx-btn--block", !hasLabel && "nx-btn--icon", className), disabled: disabled || loading, "aria-busy": loading || undefined, ...rest, children: [loading ? _jsx(InlineIcon, { icon: iconLoader2, className: "nx-spin" }) : icon, hasLabel && _jsx("span", { className: "nx-btn__label", children: children }), iconEnd, dot && _jsx("span", { className: "nx-btn__dot", "data-tone": dot === true ? undefined : dot, "aria-hidden": "true" })] }));
11
11
  }
@@ -3,7 +3,7 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
3
3
  import { iconCheck, InlineIcon } from "nexus-icons";
4
4
  import { cloneElement, useId, useLayoutEffect, useRef, useState, } from "react";
5
5
  import { cx } from "../../Helpers/ClassHelpers.js";
6
- import { followAnchor, isPopoverOpen, placeAtPoint, placeUnderAnchor } from "../../Helpers/PopoverHelpers.js";
6
+ import { followAnchor, isPopoverOpen, placeAtPoint, placeUnderAnchor, showPanel } from "../../Helpers/PopoverHelpers.js";
7
7
  export function isMenuItem(entry) {
8
8
  return "value" in entry;
9
9
  }
@@ -12,10 +12,16 @@ const ITEM_ROLE = {
12
12
  radio: "menuitemradio",
13
13
  checkbox: "menuitemcheckbox",
14
14
  };
15
- const ENABLED_ITEM = '.nx-menu__item:not([aria-disabled="true"])';
15
+ // A row, and a control an app put in a heading (the admin's account card has the picture there). The arrow keys walk
16
+ // both: Tab shuts a menu, so a control inside one is reachable no other way.
17
+ const ENABLED_ITEM = '.nx-menu__item:not([aria-disabled="true"]), .nx-menu__heading [role="menuitem"]:not([aria-disabled="true"])';
16
18
  function enabledItems(panel) {
17
19
  return Array.from(panel.querySelectorAll(ENABLED_ITEM));
18
20
  }
21
+ /** Where a menu opened from the keyboard starts: its first row, never a control in a heading above the rows. */
22
+ function firstRowIndex(panel) {
23
+ return Math.max(0, enabledItems(panel).findIndex(item => item.classList.contains("nx-menu__item")));
24
+ }
19
25
  /** Focuses the item at `index`, counting from the end when negative and wrapping past either end. */
20
26
  function focusItemAt(panel, index) {
21
27
  const items = enabledItems(panel);
@@ -87,7 +93,8 @@ export function MenuPanel({ id, items, onSelect, kind = "menu", size = "md", clo
87
93
  event.preventDefault();
88
94
  }
89
95
  // Focus follows the pointer, so the arrow keys carry on from the item under it. Only a pointer that moved counts:
90
- // a menu opened from the keyboard under a resting pointer keeps the focus on its first item.
96
+ // a menu opened from the keyboard under a resting pointer keeps the focus on its first item. It never scrolls the
97
+ // list: an item half under the edge would pull the list up under the pointer, and the heading out of sight with it.
91
98
  const pointerRef = useRef({ x: 0, y: 0 });
92
99
  function onPointerMove(event) {
93
100
  const last = pointerRef.current;
@@ -96,30 +103,37 @@ export function MenuPanel({ id, items, onSelect, kind = "menu", size = "md", clo
96
103
  pointerRef.current = { x: event.clientX, y: event.clientY };
97
104
  const item = event.currentTarget;
98
105
  if (item.getAttribute("aria-disabled") !== "true" && document.activeElement !== item)
99
- item.focus();
106
+ item.focus({ preventScroll: true });
107
+ }
108
+ // A control in a heading is chosen like a row, so it closes the menu like one.
109
+ function onClick(event) {
110
+ if (closeOnSelect && event.target.closest('.nx-menu__heading [role="menuitem"]'))
111
+ hide(event.currentTarget);
100
112
  }
101
113
  const role = ITEM_ROLE[kind];
102
114
  // When any item has an icon, every item keeps the icon column, so the labels line up.
103
115
  const hasIcons = items.some(entry => isMenuItem(entry) && entry.icon);
104
- return (_jsx("div", { ref: ref, id: id, popover: "auto", role: "menu", tabIndex: -1, className: cx("nx-popover", "nx-menu", `nx-menu--${size}`, className), onToggle: onToggle, onKeyDown: onKeyDown, ...labelling, children: items.map((entry, index) => {
105
- if (!isMenuItem(entry)) {
106
- if ("separator" in entry)
107
- return _jsx("div", { role: "separator", className: "nx-menu__separator" }, index);
108
- return (_jsx("div", { role: "presentation", className: "nx-menu__heading", children: entry.heading }, index));
109
- }
110
- const item = entry;
111
- const content = (_jsxs(_Fragment, { children: [kind !== "menu" && _jsx("span", { className: "nx-menu__check", children: item.checked && _jsx(InlineIcon, { icon: iconCheck }) }), hasIcons && _jsx("span", { className: "nx-menu__icon", children: item.icon }), _jsxs("span", { className: "nx-menu__body", children: [_jsx("span", { className: "nx-menu__label", children: item.label }), item.description && _jsx("span", { className: "nx-menu__description", children: item.description })] }), item.shortcut && _jsx("kbd", { className: "nx-menu__shortcut", children: item.shortcut })] }));
112
- const shared = {
113
- role,
114
- className: cx("nx-menu__item", item.tone === "danger" && "nx-menu__item--danger", item.checked && "nx-menu__item--checked"),
115
- tabIndex: -1,
116
- title: item.title,
117
- "aria-disabled": item.disabled || undefined,
118
- "aria-checked": kind === "menu" ? undefined : Boolean(item.checked),
119
- onPointerMove,
120
- };
121
- return item.href && !item.disabled ? (_jsx("a", { ...shared, href: item.href, target: item.target, rel: item.target === "_blank" ? "noopener" : undefined, onClick: event => choose(item, event), children: content }, item.value)) : (_jsx("button", { ...shared, type: "button", onClick: event => choose(item, event), children: content }, item.value));
122
- }) }));
116
+ return (_jsx("div", { ref: ref, id: id, popover: "auto", role: "menu", tabIndex: -1, className: cx("nx-popover", "nx-menu", `nx-menu--${size}`, className), onToggle: onToggle, onKeyDown: onKeyDown, onClick: onClick, ...labelling, children: _jsx("div", { role: "none", className: "nx-menu__list nx-popover__scroll", children: items.map((entry, index) => {
117
+ // Items are keyed by their value, so a separator keyed by its index alone collided with an item whose value
118
+ // was that number ("5" in a menu of projects). The prefix keeps the two apart.
119
+ if (!isMenuItem(entry)) {
120
+ if ("separator" in entry)
121
+ return _jsx("div", { role: "separator", className: "nx-menu__separator" }, `nx-at-${index}`);
122
+ return (_jsx("div", { role: "presentation", className: "nx-menu__heading", children: entry.heading }, `nx-at-${index}`));
123
+ }
124
+ const item = entry;
125
+ const content = (_jsxs(_Fragment, { children: [kind !== "menu" && _jsx("span", { className: "nx-menu__check", children: item.checked && _jsx(InlineIcon, { icon: iconCheck }) }), hasIcons && _jsx("span", { className: "nx-menu__icon", children: item.icon }), _jsxs("span", { className: "nx-menu__body", children: [_jsx("span", { className: "nx-menu__label", children: item.label }), item.description && _jsx("span", { className: "nx-menu__description", children: item.description })] }), item.shortcut && _jsx("kbd", { className: "nx-menu__shortcut", children: item.shortcut })] }));
126
+ const shared = {
127
+ role,
128
+ className: cx("nx-menu__item", item.tone === "danger" && "nx-menu__item--danger", item.checked && "nx-menu__item--checked"),
129
+ tabIndex: -1,
130
+ title: item.title,
131
+ "aria-disabled": item.disabled || undefined,
132
+ "aria-checked": kind === "menu" ? undefined : Boolean(item.checked),
133
+ onPointerMove,
134
+ };
135
+ return item.href && !item.disabled ? (_jsx("a", { ...shared, href: item.href, target: item.target, rel: item.target === "_blank" ? "noopener" : undefined, onClick: event => choose(item, event), children: content }, item.value)) : (_jsx("button", { ...shared, type: "button", onClick: event => choose(item, event), children: content }, item.value));
136
+ }) }) }));
123
137
  }
124
138
  /**
125
139
  * A dropdown menu: a button that opens a list of actions in a native popover under it (above it when there is no
@@ -149,12 +163,12 @@ export function Menu({ children, align = "start", "aria-label": ariaLabel, onOpe
149
163
  const panel = panelRef.current;
150
164
  if (!canShow(panel) || isPopoverOpen(panel))
151
165
  return;
152
- panel.showPopover();
166
+ showPanel(panel);
153
167
  place();
154
168
  if (focus === "panel")
155
169
  panel.focus();
156
170
  else
157
- focusItemAt(panel, focus === "first" ? 0 : -1);
171
+ focusItemAt(panel, focus === "first" ? firstRowIndex(panel) : -1);
158
172
  }
159
173
  // The panel is fixed, so it follows the button while the page scrolls or the window changes size.
160
174
  useLayoutEffect(() => {
@@ -212,14 +226,16 @@ export function ContextMenu({ children, "aria-label": ariaLabel = "Context menu"
212
226
  const panelId = useId();
213
227
  const panelRef = useRef(null);
214
228
  // When the last press happened: a contextmenu event right after one comes from the mouse or a long touch.
215
- const pressedAtRef = useRef(0);
229
+ // When a pointer was last pressed. Never pressed has to be long ago, not zero: zero is 1.5 seconds after the page
230
+ // loaded, and a context menu opened from the keyboard in that moment would be placed as a pointer's.
231
+ const pressedAtRef = useRef(Number.NEGATIVE_INFINITY);
216
232
  function onContextMenu(event) {
217
233
  const panel = panelRef.current;
218
234
  if (disabled || !canShow(panel))
219
235
  return;
220
236
  event.preventDefault();
221
237
  hide(panel);
222
- panel.showPopover();
238
+ showPanel(panel);
223
239
  const fromPointer = performance.now() - pressedAtRef.current < 1500;
224
240
  if (fromPointer) {
225
241
  placeAtPoint(panel, event.clientX, event.clientY);
@@ -229,7 +245,7 @@ export function ContextMenu({ children, "aria-label": ariaLabel = "Context menu"
229
245
  // From the keyboard (Shift+F10, the Menu key): browsers report the event at the element or at 0,0, so the
230
246
  // menu opens under the focused element like a dropdown (above it when there is no room), on its first item.
231
247
  placeUnderAnchor(panel, event.target.getBoundingClientRect());
232
- focusItemAt(panel, 0);
248
+ focusItemAt(panel, firstRowIndex(panel));
233
249
  }
234
250
  }
235
251
  return (_jsxs("div", { className: "nx-context-target", onPointerDown: () => {
@@ -29,6 +29,8 @@ export interface SplitButtonProps<T extends string = string> {
29
29
  storageKey?: string;
30
30
  /** Picking an action in the menu also runs it. Default false: it only becomes the main button, as on GitHub. */
31
31
  runOnSelect?: boolean;
32
+ /** The main button never changes: the menu holds other ways to do it, each run when picked, with no check mark. */
33
+ fixed?: boolean;
32
34
  /** Default `primary`. */
33
35
  variant?: ButtonVariant;
34
36
  /** Default `md`. */
@@ -61,4 +63,4 @@ export interface SplitButtonProps<T extends string = string> {
61
63
  * { value: "draft", label: "Create draft pull request", description: "Cannot be merged until marked ready" },
62
64
  * ]} />
63
65
  */
64
- export declare function SplitButton<T extends string>({ options, onAction, defaultValue, value, onValueChange, storageKey, runOnSelect, variant, size, loading, disabled, block, type, form, name, menuLabel, align, className, style, }: SplitButtonProps<T>): import("react").JSX.Element | null;
66
+ export declare function SplitButton<T extends string>({ options, onAction, defaultValue, value, onValueChange, storageKey, runOnSelect, fixed, variant, size, loading, disabled, block, type, form, name, menuLabel, align, className, style, }: SplitButtonProps<T>): import("react").JSX.Element | null;