@protobase/ui 0.53.3 → 0.53.5
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/dist/app/change-confirm/asks-to-confirm.js +10 -0
- package/dist/app/change-confirm/messages.js +16 -0
- package/dist/app/change-confirm/use-change-confirm.js +51 -0
- package/dist/app/comments/comment-body.js +5 -8
- package/dist/app/comments/comment-composer.js +29 -114
- package/dist/app/comments/messages.js +0 -4
- package/dist/app/field-editor.js +15 -6
- package/dist/app/list-edit/cell-field-editor.js +3 -1
- package/dist/app/list-page.js +1 -0
- package/dist/app-shell/page-header.js +34 -10
- package/dist/chat/chat-day.d.ts +6 -0
- package/dist/chat/chat-day.js +17 -0
- package/dist/chat/chat-divider.d.ts +15 -0
- package/dist/chat/chat-divider.js +25 -0
- package/dist/chat/chat-message.d.ts +26 -4
- package/dist/chat/chat-message.js +65 -5
- package/dist/chat/chat-thread.d.ts +13 -3
- package/dist/chat/chat-thread.js +85 -16
- package/dist/chat/composer.d.ts +12 -4
- package/dist/chat/composer.js +30 -15
- package/dist/chat/index.d.ts +4 -2
- package/dist/chat/messages.js +10 -2
- package/dist/chat/thread-scroll.js +15 -0
- package/dist/data/query-keys.js +0 -7
- package/dist/index.d.ts +10 -3
- package/dist/index.js +6 -1
- package/dist/mentions/index.d.ts +5 -0
- package/dist/mentions/mention-box.d.ts +27 -0
- package/dist/mentions/mention-box.js +125 -0
- package/dist/mentions/mention-text.d.ts +9 -0
- package/dist/{app/comments → mentions}/mention-text.js +1 -1
- package/dist/mentions/mentioned-text.d.ts +9 -0
- package/dist/mentions/mentioned-text.js +13 -0
- package/dist/mentions/messages.js +16 -0
- package/dist/mentions/people-list.js +26 -0
- package/dist/mentions/use-people-search.d.ts +5 -0
- package/dist/mentions/use-people-search.js +26 -0
- package/dist/package.js +1 -1
- package/dist/shell/assets/app-Bro4-Qj6.js +9 -0
- package/dist/shell/assets/{index-CymocR26.css → index-BX1N2n2W.css} +1 -1
- package/dist/shell/assets/{index-DuPd6UFq.js → index-vxLWACI4.js} +2 -2
- package/dist/shell/assets/{records-map-D74AlE1K.js → records-map-HVtj4Vna.js} +2 -2
- package/dist/shell/assets/shared-protobase-ui-BXqStot-.js +2 -0
- package/dist/shell/index.html +4 -4
- package/dist/shell/protobase.shell.json +1 -1
- package/dist/shell/protobase.sw.js +2 -2
- package/dist/shell-source-maps/assets/app-Bro4-Qj6.js.map +1 -0
- package/dist/shell-source-maps/assets/{index-DuPd6UFq.js.map → index-vxLWACI4.js.map} +1 -1
- package/dist/shell-source-maps/assets/{records-map-D74AlE1K.js.map → records-map-HVtj4Vna.js.map} +1 -1
- package/dist/shell-source-maps/assets/{shared-protobase-ui-DyYG0cfq.js.map → shared-protobase-ui-BXqStot-.js.map} +1 -1
- package/package.json +4 -4
- package/dist/app/comments/use-mentionable.js +0 -18
- package/dist/shell/assets/app-BgvLGqeW.js +0 -9
- package/dist/shell/assets/shared-protobase-ui-DyYG0cfq.js +0 -2
- package/dist/shell-source-maps/assets/app-BgvLGqeW.js.map +0 -1
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { differs } from "../../live/field-values.js";
|
|
2
|
+
//#region src/app/change-confirm/asks-to-confirm.ts
|
|
3
|
+
const draftOf = (next) => typeof next === "object" && next !== null && "key" in next ? next.key : next;
|
|
4
|
+
/**
|
|
5
|
+
* Whether a change to a field with `.confirm()` asks first: when it moves the value away from the stored one while the
|
|
6
|
+
* value on screen is still that one, so a text asks at its first key and not at every one after.
|
|
7
|
+
*/
|
|
8
|
+
const asksToConfirm = (field, hints, value, stored, next) => hints?.confirm !== void 0 && !differs(field, value, stored) && differs(field, draftOf(next), stored);
|
|
9
|
+
//#endregion
|
|
10
|
+
export { asksToConfirm };
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { defineMessages } from "../../locale/messages.js";
|
|
2
|
+
//#region src/app/change-confirm/messages.ts
|
|
3
|
+
const text = defineMessages({
|
|
4
|
+
en: {
|
|
5
|
+
title: (label) => `Change ${label}?`,
|
|
6
|
+
change: "Change",
|
|
7
|
+
cancel: "Cancel"
|
|
8
|
+
},
|
|
9
|
+
nl: {
|
|
10
|
+
title: (label) => `${label} wijzigen?`,
|
|
11
|
+
change: "Wijzigen",
|
|
12
|
+
cancel: "Annuleren"
|
|
13
|
+
}
|
|
14
|
+
});
|
|
15
|
+
//#endregion
|
|
16
|
+
export { text };
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { Button } from "../../primitives/button.js";
|
|
2
|
+
import { Dialog } from "../../primitives/dialog.js";
|
|
3
|
+
import { humanize } from "../../live/naming.js";
|
|
4
|
+
import { asksToConfirm } from "./asks-to-confirm.js";
|
|
5
|
+
import { text } from "./messages.js";
|
|
6
|
+
import { useState } from "react";
|
|
7
|
+
import { Fragment as Fragment$1, jsx, jsxs } from "react/jsx-runtime";
|
|
8
|
+
import { createPortal } from "react-dom";
|
|
9
|
+
//#region src/app/change-confirm/use-change-confirm.tsx
|
|
10
|
+
/**
|
|
11
|
+
* Whether `node` sits in the question a change asks. The dialog is portalled to the body, so a list cell does not clip
|
|
12
|
+
* it, while its events still bubble through the editor that asked it.
|
|
13
|
+
*/
|
|
14
|
+
const inChangeConfirm = (node) => node instanceof Element && node.closest("[data-change-confirm]") !== null;
|
|
15
|
+
/**
|
|
16
|
+
* The field's `onChange`, asking the view's `.confirm()` question before it hands on a change that `asksToConfirm`
|
|
17
|
+
* picks out, and the dialog that asks it. Cancelling drops the change, so the field keeps its value.
|
|
18
|
+
*/
|
|
19
|
+
const useChangeConfirm = ({ field, hints, value, stored, onChange }) => {
|
|
20
|
+
const [pending, setPending] = useState();
|
|
21
|
+
const change = (next) => {
|
|
22
|
+
if (asksToConfirm(field, hints, value, stored, next)) setPending({ next });
|
|
23
|
+
else onChange(next);
|
|
24
|
+
};
|
|
25
|
+
const cancel = () => setPending(void 0);
|
|
26
|
+
const confirm = () => {
|
|
27
|
+
setPending(void 0);
|
|
28
|
+
onChange(pending.next);
|
|
29
|
+
};
|
|
30
|
+
return {
|
|
31
|
+
onChange: change,
|
|
32
|
+
dialog: pending && createPortal(/* @__PURE__ */ jsx("div", {
|
|
33
|
+
"data-change-confirm": "",
|
|
34
|
+
children: /* @__PURE__ */ jsx(Dialog, {
|
|
35
|
+
title: text("title", hints?.label ?? humanize(field.name)),
|
|
36
|
+
description: hints?.confirm,
|
|
37
|
+
onClose: cancel,
|
|
38
|
+
actions: /* @__PURE__ */ jsxs(Fragment$1, { children: [/* @__PURE__ */ jsx(Button, {
|
|
39
|
+
onClick: cancel,
|
|
40
|
+
children: text("cancel")
|
|
41
|
+
}), /* @__PURE__ */ jsx(Button, {
|
|
42
|
+
variant: "primary",
|
|
43
|
+
onClick: confirm,
|
|
44
|
+
children: text("change")
|
|
45
|
+
})] })
|
|
46
|
+
})
|
|
47
|
+
}), document.body)
|
|
48
|
+
};
|
|
49
|
+
};
|
|
50
|
+
//#endregion
|
|
51
|
+
export { inChangeConfirm, useChangeConfirm };
|
|
@@ -1,13 +1,10 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { jsx
|
|
1
|
+
import { MentionedText } from "../../mentions/mentioned-text.js";
|
|
2
|
+
import { jsx } from "react/jsx-runtime";
|
|
3
3
|
//#region src/app/comments/comment-body.tsx
|
|
4
4
|
/** A comment's text as written, line breaks kept, each `@{name}` of someone it mentions picked out. */
|
|
5
|
-
const CommentBody = ({ comment }) => /* @__PURE__ */ jsx(
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
className: "font-medium text-primary",
|
|
9
|
-
children: ["@", part.mention]
|
|
10
|
-
}, index) : part.text)
|
|
5
|
+
const CommentBody = ({ comment }) => /* @__PURE__ */ jsx(MentionedText, {
|
|
6
|
+
text: comment.body,
|
|
7
|
+
mentions: comment.mentions
|
|
11
8
|
});
|
|
12
9
|
//#endregion
|
|
13
10
|
export { CommentBody };
|
|
@@ -1,70 +1,37 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { useClient } from "../../data/api-provider.js";
|
|
2
2
|
import { Button } from "../../primitives/button.js";
|
|
3
3
|
import { useToast } from "../../toasts/toast-provider.js";
|
|
4
4
|
import { refusalReason } from "../displays/board-columns.js";
|
|
5
5
|
import { useNavigationGuard } from "../router.js";
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
8
|
-
import { activeMention, insertMention, mentionedIds } from "./mention-text.js";
|
|
9
|
-
import { useMentionable } from "./use-mentionable.js";
|
|
6
|
+
import { mentionedIds } from "../../mentions/mention-text.js";
|
|
7
|
+
import { MentionBox, emptyDraft } from "../../mentions/mention-box.js";
|
|
10
8
|
import { usePostComment } from "./use-post-comment.js";
|
|
11
9
|
import { text } from "./messages.js";
|
|
12
|
-
import {
|
|
10
|
+
import { useCallback, useState } from "react";
|
|
13
11
|
import { jsx, jsxs } from "react/jsx-runtime";
|
|
14
12
|
//#region src/app/comments/comment-composer.tsx
|
|
15
|
-
/** The most people the list offers; few enough that it never needs a scrollbar of its own. */
|
|
16
|
-
const shownPeople = 8;
|
|
17
13
|
/**
|
|
18
|
-
* The box a comment is written in. Typing `@` lists the people who may read the record by the name typed after it
|
|
19
|
-
*
|
|
14
|
+
* The box a comment is written in. Typing `@` lists the people who may read the record by the name typed after it
|
|
15
|
+
* (see `MentionBox`). ⌘/Ctrl+Enter or the button posts.
|
|
20
16
|
*/
|
|
21
17
|
const CommentComposer = ({ resource, recordKey }) => {
|
|
22
|
-
const [draft, setDraft] = useState(
|
|
23
|
-
const
|
|
24
|
-
const [focused, setFocused] = useState(false);
|
|
25
|
-
const [picked, setPicked] = useState([]);
|
|
26
|
-
const [active, setActive] = useState(0);
|
|
27
|
-
const [closed, setClosed] = useState();
|
|
28
|
-
const box = useRef(null);
|
|
29
|
-
const nextCaret = useRef(void 0);
|
|
30
|
-
const listId = useId();
|
|
18
|
+
const [draft, setDraft] = useState(emptyDraft);
|
|
19
|
+
const client = useClient();
|
|
31
20
|
const toast = useToast();
|
|
32
21
|
const post = usePostComment(resource, recordKey);
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
const optionId = (index) => `${listId}-${index}`;
|
|
40
|
-
useEffect(() => setActive(0), [mention?.query]);
|
|
41
|
-
const mentionStart = mention?.start;
|
|
42
|
-
useEffect(() => {
|
|
43
|
-
if (mentionStart === void 0) setClosed(void 0);
|
|
44
|
-
}, [mentionStart]);
|
|
45
|
-
useLayoutEffect(() => {
|
|
46
|
-
if (nextCaret.current === void 0 || !box.current) return;
|
|
47
|
-
box.current.setSelectionRange(nextCaret.current, nextCaret.current);
|
|
48
|
-
setCaret(nextCaret.current);
|
|
49
|
-
nextCaret.current = void 0;
|
|
50
|
-
}, [draft]);
|
|
51
|
-
const pick = (user) => {
|
|
52
|
-
if (!mention) return;
|
|
53
|
-
const next = insertMention(draft, mention, user.name);
|
|
54
|
-
nextCaret.current = next.caret;
|
|
55
|
-
setDraft(next.text);
|
|
56
|
-
setPicked((current) => [...current, user]);
|
|
57
|
-
};
|
|
22
|
+
const searchPeople = useCallback((query) => client.mentionable(resource, recordKey, query), [
|
|
23
|
+
client,
|
|
24
|
+
resource,
|
|
25
|
+
recordKey
|
|
26
|
+
]);
|
|
27
|
+
useNavigationGuard(draft.text.trim() !== "" || post.isPending, "input-lost");
|
|
58
28
|
const submit = () => {
|
|
59
|
-
if (draft.trim() === "" || post.isPending) return;
|
|
29
|
+
if (draft.text.trim() === "" || post.isPending) return;
|
|
60
30
|
post.mutate({
|
|
61
|
-
body: draft,
|
|
62
|
-
mentions: mentionedIds(draft, picked)
|
|
31
|
+
body: draft.text,
|
|
32
|
+
mentions: mentionedIds(draft.text, draft.picked)
|
|
63
33
|
}, {
|
|
64
|
-
onSuccess: () =>
|
|
65
|
-
setDraft("");
|
|
66
|
-
setPicked([]);
|
|
67
|
-
},
|
|
34
|
+
onSuccess: () => setDraft(emptyDraft),
|
|
68
35
|
onError: (error) => toast.show({
|
|
69
36
|
state: "error",
|
|
70
37
|
title: text("couldNotPost"),
|
|
@@ -73,75 +40,23 @@ const CommentComposer = ({ resource, recordKey }) => {
|
|
|
73
40
|
});
|
|
74
41
|
};
|
|
75
42
|
const onKeyDown = (event) => {
|
|
76
|
-
if (event.key
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
return;
|
|
80
|
-
}
|
|
81
|
-
if (!open) return;
|
|
82
|
-
const target = menuKeyTarget(event.key, active, options.length);
|
|
83
|
-
if (target !== void 0 && (event.key === "ArrowDown" || event.key === "ArrowUp")) {
|
|
84
|
-
event.preventDefault();
|
|
85
|
-
setActive(target);
|
|
86
|
-
return;
|
|
87
|
-
}
|
|
88
|
-
if ((event.key === "Enter" || event.key === "Tab") && options[active]) {
|
|
89
|
-
event.preventDefault();
|
|
90
|
-
pick(options[active]);
|
|
91
|
-
return;
|
|
92
|
-
}
|
|
93
|
-
if (event.key === "Escape") {
|
|
94
|
-
event.preventDefault();
|
|
95
|
-
event.stopPropagation();
|
|
96
|
-
setClosed(mention.start);
|
|
97
|
-
}
|
|
43
|
+
if (event.key !== "Enter" || !(event.metaKey || event.ctrlKey)) return;
|
|
44
|
+
event.preventDefault();
|
|
45
|
+
submit();
|
|
98
46
|
};
|
|
99
47
|
return /* @__PURE__ */ jsxs("div", {
|
|
100
48
|
className: "flex flex-col gap-2",
|
|
101
|
-
children: [/* @__PURE__ */
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
"aria-autocomplete": "list",
|
|
109
|
-
"aria-expanded": listed,
|
|
110
|
-
"aria-controls": listId,
|
|
111
|
-
"aria-activedescendant": listed && options[active] ? optionId(active) : void 0,
|
|
112
|
-
placeholder: text("placeholder"),
|
|
113
|
-
value: draft,
|
|
114
|
-
onChange: (event) => {
|
|
115
|
-
setDraft(event.target.value);
|
|
116
|
-
setCaret(event.target.selectionStart);
|
|
117
|
-
},
|
|
118
|
-
onSelect: (event) => setCaret(event.currentTarget.selectionStart),
|
|
119
|
-
onFocus: () => setFocused(true),
|
|
120
|
-
onBlur: () => setFocused(false),
|
|
121
|
-
onKeyDown
|
|
122
|
-
}), listed && /* @__PURE__ */ jsxs("ul", {
|
|
123
|
-
id: listId,
|
|
124
|
-
role: "listbox",
|
|
125
|
-
"aria-label": text("peopleToMention"),
|
|
126
|
-
className: "absolute z-30 mt-1 w-full max-w-sm rounded-md border bg-background py-1 shadow-pop",
|
|
127
|
-
onMouseDown: (event) => event.preventDefault(),
|
|
128
|
-
children: [options.map((user, index) => /* @__PURE__ */ jsx("li", {
|
|
129
|
-
id: optionId(index),
|
|
130
|
-
role: "option",
|
|
131
|
-
"aria-selected": index === active,
|
|
132
|
-
className: cn("cursor-pointer truncate px-3 py-1.5", index === active && "bg-muted"),
|
|
133
|
-
onMouseMove: () => index !== active && setActive(index),
|
|
134
|
-
onClick: () => pick(user),
|
|
135
|
-
children: user.name
|
|
136
|
-
}, user.id)), options.length === 0 && /* @__PURE__ */ jsx("li", {
|
|
137
|
-
className: "px-3 py-1.5 text-muted-foreground",
|
|
138
|
-
children: text("noOneFound")
|
|
139
|
-
})]
|
|
140
|
-
})]
|
|
49
|
+
children: [/* @__PURE__ */ jsx(MentionBox, {
|
|
50
|
+
"aria-label": text("comment"),
|
|
51
|
+
placeholder: text("placeholder"),
|
|
52
|
+
value: draft,
|
|
53
|
+
onChange: setDraft,
|
|
54
|
+
searchPeople,
|
|
55
|
+
onKeyDown
|
|
141
56
|
}), /* @__PURE__ */ jsx("div", { children: /* @__PURE__ */ jsx(Button, {
|
|
142
57
|
variant: "primary",
|
|
143
58
|
size: "sm",
|
|
144
|
-
disabled: draft.trim() === "",
|
|
59
|
+
disabled: draft.text.trim() === "",
|
|
145
60
|
loading: post.isPending,
|
|
146
61
|
onClick: submit,
|
|
147
62
|
children: text("comment")
|
|
@@ -5,8 +5,6 @@ const text = defineMessages({
|
|
|
5
5
|
couldNotPost: "Could not post the comment",
|
|
6
6
|
comment: "Comment",
|
|
7
7
|
placeholder: "Write a comment, @ to mention someone",
|
|
8
|
-
peopleToMention: "People to mention",
|
|
9
|
-
noOneFound: "No one found",
|
|
10
8
|
couldNotLoad: "Could not load the comments",
|
|
11
9
|
showEarlier: "Show earlier comments",
|
|
12
10
|
noComments: "No comments yet."
|
|
@@ -15,8 +13,6 @@ const text = defineMessages({
|
|
|
15
13
|
couldNotPost: "Kon de opmerking niet plaatsen",
|
|
16
14
|
comment: "Opmerking",
|
|
17
15
|
placeholder: "Schrijf een opmerking, @ om iemand te noemen",
|
|
18
|
-
peopleToMention: "Mensen om te noemen",
|
|
19
|
-
noOneFound: "Niemand gevonden",
|
|
20
16
|
couldNotLoad: "Kon de opmerkingen niet laden",
|
|
21
17
|
showEarlier: "Eerdere opmerkingen tonen",
|
|
22
18
|
noComments: "Nog geen opmerkingen."
|
package/dist/app/field-editor.js
CHANGED
|
@@ -20,8 +20,9 @@ import { Markdown } from "../markdown/markdown.js";
|
|
|
20
20
|
import { MarkdownEditor } from "../markdown/markdown-editor.js";
|
|
21
21
|
import { numeric, numericInputProps } from "./numeric-input.js";
|
|
22
22
|
import { ContactLink } from "../live/contact-link.js";
|
|
23
|
+
import { useChangeConfirm } from "./change-confirm/use-change-confirm.js";
|
|
23
24
|
import { ArrowUpRight } from "lucide-react";
|
|
24
|
-
import { jsx, jsxs } from "react/jsx-runtime";
|
|
25
|
+
import { Fragment, jsx, jsxs } from "react/jsx-runtime";
|
|
25
26
|
import { enumLabel } from "@protobase/schema";
|
|
26
27
|
//#region src/app/field-editor.tsx
|
|
27
28
|
const BuiltInEditor = ({ field, hints, value, stored, relationLabel, relationTo, relationPicker, invalid, locked, placeholder, reveal, resource, onChange }) => {
|
|
@@ -150,11 +151,7 @@ const BuiltInEditor = ({ field, hints, value, stored, relationLabel, relationTo,
|
|
|
150
151
|
onChange: (event) => onChange(event.target.value)
|
|
151
152
|
});
|
|
152
153
|
};
|
|
153
|
-
|
|
154
|
-
* A field on a form: its field type's editor when the view chooses one that has an editor and the field may change,
|
|
155
|
-
* its display when it may not, and the built-in input or value otherwise, and when the field type throws.
|
|
156
|
-
*/
|
|
157
|
-
const FieldEditor = (props) => {
|
|
154
|
+
const ChosenEditor = (props) => {
|
|
158
155
|
const { field, hints, value, stored, invalid, locked, onChange } = props;
|
|
159
156
|
const shown = useShownWidget(field, hints);
|
|
160
157
|
const builtIn = /* @__PURE__ */ jsx(BuiltInEditor, { ...props });
|
|
@@ -177,5 +174,17 @@ const FieldEditor = (props) => {
|
|
|
177
174
|
fallback: builtIn
|
|
178
175
|
});
|
|
179
176
|
};
|
|
177
|
+
/**
|
|
178
|
+
* A field on a form: its field type's editor when the view chooses one that has an editor and the field may change,
|
|
179
|
+
* its display when it may not, and the built-in input or value otherwise, and when the field type throws. A field with
|
|
180
|
+
* `.confirm()` asks its question before its value first changes.
|
|
181
|
+
*/
|
|
182
|
+
const FieldEditor = (props) => {
|
|
183
|
+
const confirm = useChangeConfirm(props);
|
|
184
|
+
return /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx(ChosenEditor, {
|
|
185
|
+
...props,
|
|
186
|
+
onChange: confirm.onChange
|
|
187
|
+
}), confirm.dialog] });
|
|
188
|
+
};
|
|
180
189
|
//#endregion
|
|
181
190
|
export { FieldEditor };
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { differs, toFieldDraft, toPatchValue } from "../../live/field-values.js";
|
|
2
|
+
import { inChangeConfirm } from "../change-confirm/use-change-confirm.js";
|
|
2
3
|
import { FieldEditor } from "../field-editor.js";
|
|
3
4
|
import { useEffect, useRef, useState } from "react";
|
|
4
5
|
import { jsx } from "react/jsx-runtime";
|
|
@@ -23,6 +24,7 @@ const CellFieldEditor = ({ field, hints, resource, stored, onSave, close }) => {
|
|
|
23
24
|
close(move);
|
|
24
25
|
};
|
|
25
26
|
const onKeyDown = (event) => {
|
|
27
|
+
if (inChangeConfirm(event.target)) return;
|
|
26
28
|
if (event.key === "Enter") {
|
|
27
29
|
event.preventDefault();
|
|
28
30
|
finish(true);
|
|
@@ -36,7 +38,7 @@ const CellFieldEditor = ({ field, hints, resource, stored, onSave, close }) => {
|
|
|
36
38
|
}
|
|
37
39
|
};
|
|
38
40
|
const onBlur = (event) => {
|
|
39
|
-
if (!ref.current?.contains(event.relatedTarget)) finish(true);
|
|
41
|
+
if (!ref.current?.contains(event.relatedTarget) && !inChangeConfirm(event.relatedTarget)) finish(true);
|
|
40
42
|
};
|
|
41
43
|
const onChange = (next) => {
|
|
42
44
|
setDraft(next);
|
package/dist/app/list-page.js
CHANGED
|
@@ -492,6 +492,7 @@ const LoadedList = ({ model, address, scope, heading }) => {
|
|
|
492
492
|
/* @__PURE__ */ jsx(PageHeader, {
|
|
493
493
|
title: heading?.title ?? plural,
|
|
494
494
|
subtitle: heading ? heading.subtitle : view?.help,
|
|
495
|
+
subtitleBelow: true,
|
|
495
496
|
actions: /* @__PURE__ */ jsxs(Fragment$1, { children: [
|
|
496
497
|
displaySwitch(!desktop),
|
|
497
498
|
sortButton,
|
|
@@ -1,19 +1,43 @@
|
|
|
1
1
|
import { jsx, jsxs } from "react/jsx-runtime";
|
|
2
2
|
//#region src/app-shell/page-header.tsx
|
|
3
|
-
const PageHeader = ({ title, subtitle, actions }) =>
|
|
4
|
-
|
|
3
|
+
const PageHeader = ({ title, subtitle, actions, subtitleBelow }) => {
|
|
4
|
+
if (subtitleBelow) return /* @__PURE__ */ jsx(StackedPageHeader, {
|
|
5
|
+
title,
|
|
6
|
+
subtitle,
|
|
7
|
+
actions
|
|
8
|
+
});
|
|
9
|
+
return /* @__PURE__ */ jsxs("div", {
|
|
10
|
+
className: "flex flex-col gap-x-4 gap-y-2 md:flex-row md:items-end md:justify-between",
|
|
11
|
+
children: [/* @__PURE__ */ jsxs("div", {
|
|
12
|
+
className: "min-w-0 break-words md:flex-1",
|
|
13
|
+
children: [/* @__PURE__ */ jsx("h1", {
|
|
14
|
+
className: "text-xl font-semibold tracking-tight",
|
|
15
|
+
children: title
|
|
16
|
+
}), subtitle && /* @__PURE__ */ jsx("p", {
|
|
17
|
+
className: "text-muted-foreground",
|
|
18
|
+
children: subtitle
|
|
19
|
+
})]
|
|
20
|
+
}), actions && /* @__PURE__ */ jsx("div", {
|
|
21
|
+
className: "flex flex-wrap items-center gap-2 md:justify-end",
|
|
22
|
+
children: actions
|
|
23
|
+
})]
|
|
24
|
+
});
|
|
25
|
+
};
|
|
26
|
+
/** The title and actions in one row, which wraps the actions below the title when it runs out of room, then the subtitle. */
|
|
27
|
+
const StackedPageHeader = ({ title, subtitle, actions }) => /* @__PURE__ */ jsxs("div", {
|
|
28
|
+
className: "flex flex-col gap-2",
|
|
5
29
|
children: [/* @__PURE__ */ jsxs("div", {
|
|
6
|
-
className: "
|
|
30
|
+
className: "flex flex-wrap items-center justify-between gap-x-4 gap-y-2 md:flex-nowrap",
|
|
7
31
|
children: [/* @__PURE__ */ jsx("h1", {
|
|
8
|
-
className: "text-xl font-semibold tracking-tight",
|
|
32
|
+
className: "min-w-0 break-words text-xl font-semibold tracking-tight md:flex-1",
|
|
9
33
|
children: title
|
|
10
|
-
}),
|
|
11
|
-
className: "
|
|
12
|
-
children:
|
|
34
|
+
}), actions && /* @__PURE__ */ jsx("div", {
|
|
35
|
+
className: "flex flex-wrap items-center gap-2 md:justify-end",
|
|
36
|
+
children: actions
|
|
13
37
|
})]
|
|
14
|
-
}),
|
|
15
|
-
className: "
|
|
16
|
-
children:
|
|
38
|
+
}), subtitle && /* @__PURE__ */ jsx("p", {
|
|
39
|
+
className: "break-words text-muted-foreground",
|
|
40
|
+
children: subtitle
|
|
17
41
|
})]
|
|
18
42
|
});
|
|
19
43
|
//#endregion
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
//#region src/chat/chat-day.d.ts
|
|
2
|
+
/** Whether two ISO timestamps fall on the same day in `zone`, the viewer's unless given: a day divider goes between two messages that do not. */
|
|
3
|
+
export declare const sameChatDay: (a: string, b: string, zone?: string) => boolean;
|
|
4
|
+
/** The label of the divider above a day's first message, sent at `time`: "Today", "Yesterday" or "Mon, 26 Oct 2026", as the viewer's calendar in `zone` has it. */
|
|
5
|
+
export declare const chatDayLabel: (time: string, now?: number, zone?: string) => string;
|
|
6
|
+
//#endregion
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { dayIn, viewerZone } from "../format/zone.js";
|
|
2
|
+
import { formatWeekdayDate } from "../format/date.js";
|
|
3
|
+
import { text } from "./messages.js";
|
|
4
|
+
//#region src/chat/chat-day.ts
|
|
5
|
+
const dayBefore = (day) => (/* @__PURE__ */ new Date(Date.parse(`${day}T00:00:00Z`) - 864e5)).toISOString().slice(0, 10);
|
|
6
|
+
/** Whether two ISO timestamps fall on the same day in `zone`, the viewer's unless given: a day divider goes between two messages that do not. */
|
|
7
|
+
const sameChatDay = (a, b, zone = viewerZone()) => dayIn(Date.parse(a), zone) === dayIn(Date.parse(b), zone);
|
|
8
|
+
/** The label of the divider above a day's first message, sent at `time`: "Today", "Yesterday" or "Mon, 26 Oct 2026", as the viewer's calendar in `zone` has it. */
|
|
9
|
+
const chatDayLabel = (time, now = Date.now(), zone = viewerZone()) => {
|
|
10
|
+
const day = dayIn(Date.parse(time), zone);
|
|
11
|
+
const today = dayIn(now, zone);
|
|
12
|
+
if (day === today) return text("today");
|
|
13
|
+
if (day === dayBefore(today)) return text("yesterday");
|
|
14
|
+
return formatWeekdayDate(Date.parse(`${day}T00:00:00Z`));
|
|
15
|
+
};
|
|
16
|
+
//#endregion
|
|
17
|
+
export { chatDayLabel, sameChatDay };
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
//#region src/chat/chat-divider.d.ts
|
|
2
|
+
export type ChatDividerProps =
|
|
3
|
+
/** A muted line between the messages of two days, labelled with the later one, such as by `chatDayLabel`. */
|
|
4
|
+
{
|
|
5
|
+
tone?: 'day';
|
|
6
|
+
label: string;
|
|
7
|
+
} |
|
|
8
|
+
/** A line above the first message the reader has not seen, "New messages" unless labelled otherwise. */
|
|
9
|
+
{
|
|
10
|
+
tone: 'new';
|
|
11
|
+
label?: string;
|
|
12
|
+
};
|
|
13
|
+
/** A line across a chat with a label in its middle, between the messages of two days or above the new ones. */
|
|
14
|
+
export declare const ChatDivider: ({ tone, label }: ChatDividerProps) => import("react").JSX.Element;
|
|
15
|
+
//#endregion
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { cn } from "../lib/cn.js";
|
|
2
|
+
import { text } from "./messages.js";
|
|
3
|
+
import { jsx, jsxs } from "react/jsx-runtime";
|
|
4
|
+
//#region src/chat/chat-divider.tsx
|
|
5
|
+
/** A line across a chat with a label in its middle, between the messages of two days or above the new ones. */
|
|
6
|
+
const ChatDivider = ({ tone = "day", label }) => {
|
|
7
|
+
const shown = label ?? text("newMessages");
|
|
8
|
+
const line = cn("h-px flex-1", tone === "new" ? "bg-danger" : "bg-border");
|
|
9
|
+
return /* @__PURE__ */ jsxs("div", {
|
|
10
|
+
role: "separator",
|
|
11
|
+
"aria-label": shown,
|
|
12
|
+
"data-tone": tone,
|
|
13
|
+
className: "flex items-center gap-3 text-xs font-medium",
|
|
14
|
+
children: [
|
|
15
|
+
/* @__PURE__ */ jsx("span", { className: line }),
|
|
16
|
+
/* @__PURE__ */ jsx("span", {
|
|
17
|
+
className: tone === "new" ? "text-danger" : "text-muted-foreground",
|
|
18
|
+
children: shown
|
|
19
|
+
}),
|
|
20
|
+
/* @__PURE__ */ jsx("span", { className: line })
|
|
21
|
+
]
|
|
22
|
+
});
|
|
23
|
+
};
|
|
24
|
+
//#endregion
|
|
25
|
+
export { ChatDivider };
|
|
@@ -1,8 +1,30 @@
|
|
|
1
1
|
import { ReactNode } from "react";
|
|
2
2
|
//#region src/chat/chat-message.d.ts
|
|
3
|
-
/**
|
|
4
|
-
export
|
|
5
|
-
|
|
3
|
+
/** Who wrote a message of a chat between people: `id` gives their avatar its colour, `name` its initials. */
|
|
4
|
+
export type ChatAuthor = {
|
|
5
|
+
id: string;
|
|
6
|
+
name?: string;
|
|
7
|
+
};
|
|
8
|
+
type Common = {
|
|
6
9
|
children: ReactNode;
|
|
7
|
-
|
|
10
|
+
/** A small line under the message, such as whether it was read. */
|
|
11
|
+
footer?: ReactNode;
|
|
12
|
+
};
|
|
13
|
+
export type ChatMessageProps = Common & ({
|
|
14
|
+
/** An assistant chat's side: the user's message as a bubble on the right, the other side's as plain content. */
|
|
15
|
+
from: 'user' | 'assistant';
|
|
16
|
+
author?: never;
|
|
17
|
+
} | {
|
|
18
|
+
/** A chat between people: the message on the left under its author's name and time, beside their avatar. */
|
|
19
|
+
author: ChatAuthor;
|
|
20
|
+
from?: never;
|
|
21
|
+
/** When it was sent, as an ISO timestamp, shown as the time of day. */
|
|
22
|
+
time?: string;
|
|
23
|
+
/** Drawn instead of the author's initials, such as their photo. */
|
|
24
|
+
avatar?: ReactNode;
|
|
25
|
+
/** It follows a message by the same author, so it leaves out their avatar, name and time. */
|
|
26
|
+
continued?: boolean;
|
|
27
|
+
});
|
|
28
|
+
/** One message of a chat, cards included: an assistant chat's user or assistant side, or a person's message with `author`. */
|
|
29
|
+
export declare const ChatMessage: (props: ChatMessageProps) => import("react").JSX.Element;
|
|
8
30
|
//#endregion
|
|
@@ -1,11 +1,71 @@
|
|
|
1
1
|
import { cn } from "../lib/cn.js";
|
|
2
|
-
import {
|
|
2
|
+
import { formatDateTime, formatTime } from "../format/date.js";
|
|
3
|
+
import { Avatar } from "../primitives/avatar.js";
|
|
4
|
+
import { hueOf, initialsOf } from "../auth/user-to-shell.js";
|
|
5
|
+
import { jsx, jsxs } from "react/jsx-runtime";
|
|
3
6
|
//#region src/chat/chat-message.tsx
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
"data-from": from,
|
|
7
|
-
className: cn("flex min-w-0 flex-col gap-2 whitespace-pre-wrap break-words", from === "user" ? "ml-8 self-end rounded-lg rounded-br-sm bg-primary px-3 py-2 text-primary-foreground shadow-sm" : "mr-4 text-foreground"),
|
|
7
|
+
const Footer = ({ children, className }) => /* @__PURE__ */ jsx("p", {
|
|
8
|
+
className: cn("text-xs text-muted-foreground", className),
|
|
8
9
|
children
|
|
9
10
|
});
|
|
11
|
+
const Sent = ({ time }) => {
|
|
12
|
+
const at = Date.parse(time);
|
|
13
|
+
return /* @__PURE__ */ jsx("time", {
|
|
14
|
+
dateTime: time,
|
|
15
|
+
title: formatDateTime(at),
|
|
16
|
+
className: "text-xs text-muted-foreground",
|
|
17
|
+
children: formatTime(at)
|
|
18
|
+
});
|
|
19
|
+
};
|
|
20
|
+
/** One message of a chat, cards included: an assistant chat's user or assistant side, or a person's message with `author`. */
|
|
21
|
+
const ChatMessage = (props) => {
|
|
22
|
+
const { children, footer } = props;
|
|
23
|
+
if (props.author) {
|
|
24
|
+
const { author, time, avatar, continued } = props;
|
|
25
|
+
const name = author.name ?? author.id;
|
|
26
|
+
return /* @__PURE__ */ jsxs("div", {
|
|
27
|
+
"data-from": "person",
|
|
28
|
+
className: cn("flex min-w-0 gap-3", continued && "-mt-3"),
|
|
29
|
+
children: [/* @__PURE__ */ jsx("div", {
|
|
30
|
+
className: "w-8 shrink-0",
|
|
31
|
+
children: !continued && (avatar ?? /* @__PURE__ */ jsx(Avatar, {
|
|
32
|
+
initials: initialsOf(name) || "?",
|
|
33
|
+
hue: hueOf(author.id),
|
|
34
|
+
size: "md",
|
|
35
|
+
className: "ring-0"
|
|
36
|
+
}))
|
|
37
|
+
}), /* @__PURE__ */ jsxs("div", {
|
|
38
|
+
className: "flex min-w-0 flex-1 flex-col gap-0.5",
|
|
39
|
+
children: [
|
|
40
|
+
!continued && /* @__PURE__ */ jsxs("p", {
|
|
41
|
+
className: "flex items-baseline gap-2",
|
|
42
|
+
children: [/* @__PURE__ */ jsx("span", {
|
|
43
|
+
className: "font-medium text-foreground",
|
|
44
|
+
children: name
|
|
45
|
+
}), time && /* @__PURE__ */ jsx(Sent, { time })]
|
|
46
|
+
}),
|
|
47
|
+
/* @__PURE__ */ jsx("div", {
|
|
48
|
+
className: "flex min-w-0 flex-col gap-2 whitespace-pre-wrap break-words text-foreground",
|
|
49
|
+
children
|
|
50
|
+
}),
|
|
51
|
+
footer && /* @__PURE__ */ jsx(Footer, { children: footer })
|
|
52
|
+
]
|
|
53
|
+
})]
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
const bubble = /* @__PURE__ */ jsx("div", {
|
|
57
|
+
"data-from": props.from,
|
|
58
|
+
className: cn("flex min-w-0 flex-col gap-2 whitespace-pre-wrap break-words", props.from === "user" ? "ml-8 self-end rounded-lg rounded-br-sm bg-primary px-3 py-2 text-primary-foreground shadow-sm" : "mr-4 text-foreground"),
|
|
59
|
+
children
|
|
60
|
+
});
|
|
61
|
+
if (!footer) return bubble;
|
|
62
|
+
return /* @__PURE__ */ jsxs("div", {
|
|
63
|
+
className: cn("flex min-w-0 flex-col gap-1", props.from === "user" && "items-end"),
|
|
64
|
+
children: [bubble, /* @__PURE__ */ jsx(Footer, {
|
|
65
|
+
className: props.from === "user" ? "text-right" : void 0,
|
|
66
|
+
children: footer
|
|
67
|
+
})]
|
|
68
|
+
});
|
|
69
|
+
};
|
|
10
70
|
//#endregion
|
|
11
71
|
export { ChatMessage };
|
|
@@ -4,9 +4,19 @@ export type ChatThreadProps = {
|
|
|
4
4
|
children: ReactNode;
|
|
5
5
|
/** Shown centred while the chat is empty. */
|
|
6
6
|
empty?: ReactNode;
|
|
7
|
-
/**
|
|
7
|
+
/** The number of messages, which changes whenever the chat grows. */
|
|
8
8
|
length: number;
|
|
9
|
+
/** Loads the messages before the first, offered as a button at the top while given. */
|
|
10
|
+
onLoadEarlier?: () => void;
|
|
11
|
+
/** The earlier messages are on their way. */
|
|
12
|
+
loadingEarlier?: boolean;
|
|
13
|
+
/** Changes when the reader sends a message, such as a count of their sends: each change goes to the end wherever they were, and the message that arrives next follows. */
|
|
14
|
+
followKey?: string | number;
|
|
9
15
|
};
|
|
10
|
-
/**
|
|
11
|
-
|
|
16
|
+
/**
|
|
17
|
+
* The scrolling list of a chat's messages. A reader at the end follows new messages; one who scrolled back stays where
|
|
18
|
+
* they are until `followKey` changes, and earlier messages loaded above keep them on the message they were reading. It
|
|
19
|
+
* scrolls inside itself, not with the page, since a chat keeps its composer in view beneath it.
|
|
20
|
+
*/
|
|
21
|
+
export declare const ChatThread: ({ children, empty, length, onLoadEarlier, loadingEarlier, followKey }: ChatThreadProps) => import("react").JSX.Element;
|
|
12
22
|
//#endregion
|