@protobase/ui 0.21.7 → 0.21.8

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 (50) hide show
  1. package/dist/app/comments/comment-body.js +13 -0
  2. package/dist/app/comments/comment-composer.js +149 -0
  3. package/dist/app/comments/comments-section.js +78 -0
  4. package/dist/app/comments/mention-text.js +45 -0
  5. package/dist/app/comments/use-mentionable.js +18 -0
  6. package/dist/app/comments/use-post-comment.js +15 -0
  7. package/dist/app/comments/use-record-comments.js +24 -0
  8. package/dist/app/current-address.d.ts +4 -3
  9. package/dist/app/current-address.js +1 -0
  10. package/dist/app/displays/calendar-controls.js +69 -0
  11. package/dist/app/displays/calendar-day.js +33 -0
  12. package/dist/app/displays/calendar-display.js +174 -0
  13. package/dist/app/displays/calendar-dnd.js +27 -0
  14. package/dist/app/displays/calendar-event.js +72 -0
  15. package/dist/app/displays/calendar-range.js +125 -0
  16. package/dist/app/displays/list-displays.js +7 -1
  17. package/dist/app/displays/use-calendar-move.js +68 -0
  18. package/dist/app/displays/use-calendar-rows.js +35 -0
  19. package/dist/app/embedded-list/embedded-list.js +25 -21
  20. package/dist/app/embedded-list/embedded-row-targets.js +65 -0
  21. package/dist/app/list-page.js +16 -5
  22. package/dist/app/pages/use-run-action.js +8 -6
  23. package/dist/app/record-page.js +24 -10
  24. package/dist/app/related-opens.js +22 -0
  25. package/dist/app/related-section.js +4 -1
  26. package/dist/app/use-app-address.d.ts +3 -1
  27. package/dist/app/use-app-address.js +8 -9
  28. package/dist/auth/user-to-shell.js +3 -1
  29. package/dist/data/api-provider.d.ts +20 -0
  30. package/dist/data/query-keys.js +15 -0
  31. package/dist/data-grid/row-menu.js +8 -1
  32. package/dist/format/date.js +27 -1
  33. package/dist/package.js +1 -1
  34. package/dist/primitives/segmented.js +8 -3
  35. package/dist/primitives/textarea.d.ts +4 -3
  36. package/dist/primitives/textarea.js +9 -4
  37. package/dist/shell/assets/app-C01lazBK.js +69 -0
  38. package/dist/shell/assets/{export-worker-CVd5saLq.js → export-worker-BnOpS_4Q.js} +1 -1
  39. package/dist/shell/assets/index-CBGigFxb.css +2 -0
  40. package/dist/shell/assets/{index-CQc-60lQ.js → index-xN6_oZU2.js} +1 -1
  41. package/dist/shell/assets/{maplibre-BvtgTvfx.js → maplibre-Ba9RapjY.js} +1 -1
  42. package/dist/shell/assets/{markdown-content-CJ8g5iKO.js → markdown-content-BUxrbovf.js} +1 -1
  43. package/dist/shell/assets/{record-map-DSXYVoqB.js → record-map-14zotZ2s.js} +1 -1
  44. package/dist/shell/assets/{records-map-cU_FAK7w.js → records-map-wi-0pXqF.js} +1 -1
  45. package/dist/shell/assets/{shared-protobase-ui-Bc0CoTXD.js → shared-protobase-ui-BVwhKZk9.js} +1 -1
  46. package/dist/shell/index.html +4 -4
  47. package/dist/shell/protobase.shell.json +1 -1
  48. package/package.json +4 -4
  49. package/dist/shell/assets/app-BbVdHzEY.js +0 -69
  50. package/dist/shell/assets/index-C1fzTv_X.css +0 -2
@@ -0,0 +1,13 @@
1
+ import { bodyParts } from "./mention-text.js";
2
+ import { jsx, jsxs } from "react/jsx-runtime";
3
+ //#region src/app/comments/comment-body.tsx
4
+ /** A comment's text as written, line breaks kept, each `@{name}` of someone it mentions picked out. */
5
+ const CommentBody = ({ comment }) => /* @__PURE__ */ jsx("p", {
6
+ className: "whitespace-pre-wrap break-words",
7
+ children: bodyParts(comment.body, comment.mentions).map((part, index) => "mention" in part ? /* @__PURE__ */ jsxs("span", {
8
+ className: "font-medium text-primary",
9
+ children: ["@", part.mention]
10
+ }, index) : part.text)
11
+ });
12
+ //#endregion
13
+ export { CommentBody };
@@ -0,0 +1,149 @@
1
+ import { cn } from "../../lib/cn.js";
2
+ import { Button } from "../../primitives/button.js";
3
+ import { useToast } from "../../toasts/toast-provider.js";
4
+ import { Textarea } from "../../primitives/textarea.js";
5
+ import { refusalReason } from "../displays/board-columns.js";
6
+ import { menuKeyTarget } from "../../primitives/menu-keys.js";
7
+ import { activeMention, insertMention, mentionedIds } from "./mention-text.js";
8
+ import { useMentionable } from "./use-mentionable.js";
9
+ import { usePostComment } from "./use-post-comment.js";
10
+ import { useEffect, useId, useLayoutEffect, useRef, useState } from "react";
11
+ import { jsx, jsxs } from "react/jsx-runtime";
12
+ //#region src/app/comments/comment-composer.tsx
13
+ /** The most people the list offers; few enough that it never needs a scrollbar of its own. */
14
+ const shownPeople = 8;
15
+ /**
16
+ * The box a comment is written in. Typing `@` lists the people who may read the record by the name typed after it:
17
+ * the arrows move, Enter or Tab picks, Escape closes. ⌘/Ctrl+Enter or the button posts.
18
+ */
19
+ const CommentComposer = ({ resource, recordKey }) => {
20
+ const [text, setText] = useState("");
21
+ const [caret, setCaret] = useState(0);
22
+ const [focused, setFocused] = useState(false);
23
+ const [picked, setPicked] = useState([]);
24
+ const [active, setActive] = useState(0);
25
+ const [closed, setClosed] = useState();
26
+ const box = useRef(null);
27
+ const nextCaret = useRef(void 0);
28
+ const listId = useId();
29
+ const toast = useToast();
30
+ const post = usePostComment(resource, recordKey);
31
+ const mention = activeMention(text, caret);
32
+ const open = focused && mention !== void 0 && mention.start !== closed;
33
+ const people = useMentionable(resource, recordKey, open ? mention.query : void 0);
34
+ const options = open ? (people.data ?? []).slice(0, shownPeople) : [];
35
+ const listed = open && people.data !== void 0;
36
+ const optionId = (index) => `${listId}-${index}`;
37
+ useEffect(() => setActive(0), [mention?.query]);
38
+ const mentionStart = mention?.start;
39
+ useEffect(() => {
40
+ if (mentionStart === void 0) setClosed(void 0);
41
+ }, [mentionStart]);
42
+ useLayoutEffect(() => {
43
+ if (nextCaret.current === void 0 || !box.current) return;
44
+ box.current.setSelectionRange(nextCaret.current, nextCaret.current);
45
+ setCaret(nextCaret.current);
46
+ nextCaret.current = void 0;
47
+ }, [text]);
48
+ const pick = (user) => {
49
+ if (!mention) return;
50
+ const next = insertMention(text, mention, user.name);
51
+ nextCaret.current = next.caret;
52
+ setText(next.text);
53
+ setPicked((current) => [...current, user]);
54
+ };
55
+ const submit = () => {
56
+ if (text.trim() === "" || post.isPending) return;
57
+ post.mutate({
58
+ body: text,
59
+ mentions: mentionedIds(text, picked)
60
+ }, {
61
+ onSuccess: () => {
62
+ setText("");
63
+ setPicked([]);
64
+ },
65
+ onError: (error) => toast.show({
66
+ state: "error",
67
+ title: "Could not post the comment",
68
+ description: refusalReason(error)
69
+ })
70
+ });
71
+ };
72
+ const onKeyDown = (event) => {
73
+ if (event.key === "Enter" && (event.metaKey || event.ctrlKey)) {
74
+ event.preventDefault();
75
+ submit();
76
+ return;
77
+ }
78
+ if (!open) return;
79
+ const target = menuKeyTarget(event.key, active, options.length);
80
+ if (target !== void 0 && (event.key === "ArrowDown" || event.key === "ArrowUp")) {
81
+ event.preventDefault();
82
+ setActive(target);
83
+ return;
84
+ }
85
+ if ((event.key === "Enter" || event.key === "Tab") && options[active]) {
86
+ event.preventDefault();
87
+ pick(options[active]);
88
+ return;
89
+ }
90
+ if (event.key === "Escape") {
91
+ event.preventDefault();
92
+ event.stopPropagation();
93
+ setClosed(mention.start);
94
+ }
95
+ };
96
+ return /* @__PURE__ */ jsxs("div", {
97
+ className: "flex flex-col gap-2",
98
+ children: [/* @__PURE__ */ jsxs("div", {
99
+ className: "relative",
100
+ children: [/* @__PURE__ */ jsx(Textarea, {
101
+ ref: box,
102
+ role: "combobox",
103
+ "aria-label": "Comment",
104
+ "aria-multiline": true,
105
+ "aria-autocomplete": "list",
106
+ "aria-expanded": listed,
107
+ "aria-controls": listId,
108
+ "aria-activedescendant": listed && options[active] ? optionId(active) : void 0,
109
+ placeholder: "Write a comment, @ to mention someone",
110
+ value: text,
111
+ onChange: (event) => {
112
+ setText(event.target.value);
113
+ setCaret(event.target.selectionStart);
114
+ },
115
+ onSelect: (event) => setCaret(event.currentTarget.selectionStart),
116
+ onFocus: () => setFocused(true),
117
+ onBlur: () => setFocused(false),
118
+ onKeyDown
119
+ }), listed && /* @__PURE__ */ jsxs("ul", {
120
+ id: listId,
121
+ role: "listbox",
122
+ "aria-label": "People to mention",
123
+ className: "absolute z-30 mt-1 w-full max-w-sm rounded-md border bg-background py-1 shadow-pop",
124
+ onMouseDown: (event) => event.preventDefault(),
125
+ children: [options.map((user, index) => /* @__PURE__ */ jsx("li", {
126
+ id: optionId(index),
127
+ role: "option",
128
+ "aria-selected": index === active,
129
+ className: cn("cursor-pointer truncate px-3 py-1.5", index === active && "bg-muted"),
130
+ onMouseMove: () => index !== active && setActive(index),
131
+ onClick: () => pick(user),
132
+ children: user.name
133
+ }, user.id)), options.length === 0 && /* @__PURE__ */ jsx("li", {
134
+ className: "px-3 py-1.5 text-muted-foreground",
135
+ children: "No one found"
136
+ })]
137
+ })]
138
+ }), /* @__PURE__ */ jsx("div", { children: /* @__PURE__ */ jsx(Button, {
139
+ variant: "primary",
140
+ size: "sm",
141
+ disabled: text.trim() === "",
142
+ loading: post.isPending,
143
+ onClick: submit,
144
+ children: "Comment"
145
+ }) })]
146
+ });
147
+ };
148
+ //#endregion
149
+ export { CommentComposer };
@@ -0,0 +1,78 @@
1
+ import { Skeleton } from "../../primitives/skeleton.js";
2
+ import { Avatar } from "../../primitives/avatar.js";
3
+ import { formatDateTime, formatRelativeTime } from "../../format/date.js";
4
+ import { Button } from "../../primitives/button.js";
5
+ import { hueOf, initialsOf } from "../../auth/user-to-shell.js";
6
+ import { Section } from "../../record-view/section.js";
7
+ import { ErrorBanner } from "../error-banner.js";
8
+ import { CommentBody } from "./comment-body.js";
9
+ import { CommentComposer } from "./comment-composer.js";
10
+ import { useRecordComments } from "./use-record-comments.js";
11
+ import { Fragment, jsx, jsxs } from "react/jsx-runtime";
12
+ //#region src/app/comments/comments-section.tsx
13
+ const Loading = () => /* @__PURE__ */ jsx("div", {
14
+ className: "flex flex-col gap-2",
15
+ children: Array.from({ length: 3 }, (_, index) => /* @__PURE__ */ jsx(Skeleton, { className: "h-4 w-full" }, index))
16
+ });
17
+ const Comment = ({ comment }) => {
18
+ const author = comment.author.name ?? comment.author.id;
19
+ const at = Date.parse(comment.createdAt);
20
+ return /* @__PURE__ */ jsxs("li", {
21
+ className: "flex gap-3 py-3 first:pt-0 last:pb-0",
22
+ children: [/* @__PURE__ */ jsx(Avatar, {
23
+ initials: initialsOf(author) || "?",
24
+ hue: hueOf(comment.author.id),
25
+ size: "md",
26
+ className: "ring-0"
27
+ }), /* @__PURE__ */ jsxs("div", {
28
+ className: "flex min-w-0 flex-1 flex-col gap-0.5",
29
+ children: [/* @__PURE__ */ jsxs("p", { children: [
30
+ /* @__PURE__ */ jsx("span", {
31
+ className: "font-medium text-foreground",
32
+ children: author
33
+ }),
34
+ " ",
35
+ /* @__PURE__ */ jsxs("time", {
36
+ dateTime: comment.createdAt,
37
+ title: formatDateTime(at),
38
+ className: "text-muted-foreground",
39
+ children: ["· ", formatRelativeTime(Math.max(0, Math.round((Date.now() - at) / 1e3)))]
40
+ })
41
+ ] }), /* @__PURE__ */ jsx(CommentBody, { comment })]
42
+ })]
43
+ });
44
+ };
45
+ /**
46
+ * The record's comments, oldest first with the newest just above the box, and Show earlier comments for those before;
47
+ * `canWrite` false, for a visitor who is not signed in, leaves out the box.
48
+ */
49
+ const CommentsSection = ({ item, resource, recordKey, canWrite }) => {
50
+ const comments = useRecordComments(resource, recordKey);
51
+ return /* @__PURE__ */ jsxs(Section, {
52
+ title: item.title,
53
+ children: [comments.isPending ? /* @__PURE__ */ jsx(Loading, {}) : comments.error ? /* @__PURE__ */ jsx(ErrorBanner, {
54
+ title: "Could not load the comments",
55
+ error: comments.error
56
+ }) : /* @__PURE__ */ jsxs(Fragment, { children: [comments.hasNextPage && /* @__PURE__ */ jsx(Button, {
57
+ size: "sm",
58
+ className: "mb-4",
59
+ disabled: comments.isFetchingNextPage,
60
+ onClick: () => void comments.fetchNextPage(),
61
+ children: "Show earlier comments"
62
+ }), comments.items.length === 0 ? /* @__PURE__ */ jsx("p", {
63
+ className: "text-muted-foreground",
64
+ children: "No comments yet."
65
+ }) : /* @__PURE__ */ jsx("ol", {
66
+ className: "divide-y divide-border",
67
+ children: comments.items.map((comment) => /* @__PURE__ */ jsx(Comment, { comment }, comment.id))
68
+ })] }), canWrite && /* @__PURE__ */ jsx("div", {
69
+ className: "mt-4",
70
+ children: /* @__PURE__ */ jsx(CommentComposer, {
71
+ resource,
72
+ recordKey
73
+ })
74
+ })]
75
+ });
76
+ };
77
+ //#endregion
78
+ export { CommentsSection };
@@ -0,0 +1,45 @@
1
+ //#region src/app/comments/mention-text.ts
2
+ /** The mention the caret is in: an `@` that starts the text or follows whitespace, with no whitespace up to the caret. */
3
+ const activeMention = (text, caret) => {
4
+ const match = /(?:^|\s)@([^\s@]*)$/.exec(text.slice(0, caret));
5
+ return match ? {
6
+ start: caret - match[1].length - 1,
7
+ query: match[1]
8
+ } : void 0;
9
+ };
10
+ /** The text with the active mention written out as `@{name} `, and the caret after it. */
11
+ const insertMention = (text, active, name) => {
12
+ const rest = text.slice(active.start + 1 + active.query.length);
13
+ const written = `@${name}${rest.startsWith(" ") ? "" : " "}`;
14
+ return {
15
+ text: `${text.slice(0, active.start)}${written}${rest}`,
16
+ caret: active.start + written.length + (rest.startsWith(" ") ? 1 : 0)
17
+ };
18
+ };
19
+ const escaped = (text) => text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
20
+ const mentionPattern = (names) => {
21
+ const unique = [...new Set(names)].filter(Boolean).sort((a, b) => b.length - a.length);
22
+ return unique.length === 0 ? void 0 : new RegExp(`(?<![\\p{L}\\p{N}_@])@(${unique.map(escaped).join("|")})(?![\\p{L}\\p{N}_])`, "gu");
23
+ };
24
+ /** The ids of the picked users whose `@{name}` is still in the text, each once. */
25
+ const mentionedIds = (text, picked) => {
26
+ const pattern = mentionPattern(picked.map((user) => user.name));
27
+ const written = new Set(pattern ? [...text.matchAll(pattern)].map((match) => match[1]) : []);
28
+ return [...new Set(picked.filter((user) => written.has(user.name)).map((user) => user.id))];
29
+ };
30
+ /** A comment's text split into plain text and the `@{name}` of each person it mentions whose name is known. */
31
+ const bodyParts = (body, mentions) => {
32
+ const pattern = mentionPattern(mentions.flatMap((user) => user.name ? [user.name] : []));
33
+ if (!pattern) return [{ text: body }];
34
+ const parts = [];
35
+ let at = 0;
36
+ for (const match of body.matchAll(pattern)) {
37
+ if (match.index > at) parts.push({ text: body.slice(at, match.index) });
38
+ parts.push({ mention: match[1] });
39
+ at = match.index + match[0].length;
40
+ }
41
+ if (at < body.length) parts.push({ text: body.slice(at) });
42
+ return parts;
43
+ };
44
+ //#endregion
45
+ export { activeMention, bodyParts, insertMention, mentionedIds };
@@ -0,0 +1,18 @@
1
+ import { keys } from "../../data/query-keys.js";
2
+ import { useClient } from "../../data/api-provider.js";
3
+ import { useDebounced } from "../../lib/use-debounced.js";
4
+ import { keepPreviousData, useQuery } from "@tanstack/react-query";
5
+ //#region src/app/comments/use-mentionable.ts
6
+ /** The people a comment on the record may mention whose name contains `query`, asked 150 ms after it last changed; none while `query` is `undefined`. */
7
+ const useMentionable = (resource, recordKey, query) => {
8
+ const client = useClient();
9
+ const settled = useDebounced(query, 150);
10
+ return useQuery({
11
+ queryKey: keys.mentionable(resource, recordKey, settled ?? ""),
12
+ queryFn: () => client.mentionable(resource, recordKey, settled ?? ""),
13
+ enabled: settled !== void 0,
14
+ placeholderData: keepPreviousData
15
+ });
16
+ };
17
+ //#endregion
18
+ export { useMentionable };
@@ -0,0 +1,15 @@
1
+ import { keys } from "../../data/query-keys.js";
2
+ import { useClient } from "../../data/api-provider.js";
3
+ import { useMutation, useQueryClient } from "@tanstack/react-query";
4
+ //#region src/app/comments/use-post-comment.ts
5
+ /** Posts a comment on the record, then loads its comments again so the new one shows. */
6
+ const usePostComment = (resource, recordKey) => {
7
+ const client = useClient();
8
+ const queryClient = useQueryClient();
9
+ return useMutation({
10
+ mutationFn: (input) => client.comment(resource, recordKey, input),
11
+ onSuccess: () => queryClient.invalidateQueries({ queryKey: keys.comments(resource, recordKey) })
12
+ });
13
+ };
14
+ //#endregion
15
+ export { usePostComment };
@@ -0,0 +1,24 @@
1
+ import { keys } from "../../data/query-keys.js";
2
+ import { useClient } from "../../data/api-provider.js";
3
+ import { useInfiniteQuery } from "@tanstack/react-query";
4
+ //#region src/app/comments/use-record-comments.ts
5
+ const pageSize = 20;
6
+ /** A record's comments, oldest first: the newest page, then each earlier one `fetchNextPage` loads, at the top. */
7
+ const useRecordComments = (resource, recordKey) => {
8
+ const client = useClient();
9
+ const query = useInfiniteQuery({
10
+ queryKey: keys.comments(resource, recordKey),
11
+ initialPageParam: "",
12
+ queryFn: ({ pageParam }) => client.comments(resource, recordKey, {
13
+ pageSize,
14
+ pageToken: pageParam
15
+ }),
16
+ getNextPageParam: (last) => last.nextPageToken || void 0
17
+ });
18
+ return {
19
+ ...query,
20
+ items: query.data?.pages.flatMap((page) => page.items).reverse() ?? []
21
+ };
22
+ };
23
+ //#endregion
24
+ export { useRecordComments };
@@ -1,8 +1,8 @@
1
- import { AppAddress, FilterLayout, MapView, ResourceModel, ViewModel } from "@protobase/schema";
1
+ import { AppAddress, CalendarView, FilterLayout, MapView, ResourceModel, ViewModel } from "@protobase/schema";
2
2
  //#region src/app/current-address.d.ts
3
3
  /**
4
- * A list as the app shows it: every value there, the view's default where the address names none; `map` and
5
- * `savedSearch`, the saved search it was opened from, have none.
4
+ * A list as the app shows it: every value there, the view's default where the address names none; `map`, `calendar`
5
+ * and `savedSearch`, the saved search it was opened from, have none.
6
6
  */
7
7
  export type ListAddress = {
8
8
  kind: 'list';
@@ -12,6 +12,7 @@ export type ListAddress = {
12
12
  orderBy: string;
13
13
  filterLayout: FilterLayout;
14
14
  map?: MapView;
15
+ calendar?: CalendarView;
15
16
  savedSearch?: string;
16
17
  };
17
18
  /** The address the app shows, as logical values. A record's key is in the text form the record API takes. */
@@ -56,6 +56,7 @@ const changedList = (read, change, defaults) => {
56
56
  ...differs("orderBy") && { orderBy: next.orderBy },
57
57
  ...differs("filterLayout") && { filterLayout: next.filterLayout },
58
58
  ...next.map && { map: next.map },
59
+ ...next.calendar && { calendar: next.calendar },
59
60
  ...next.savedSearch && { savedSearch: next.savedSearch }
60
61
  };
61
62
  };
@@ -0,0 +1,69 @@
1
+ import { segmentedClass } from "../../primitives/segmented.js";
2
+ import { formatDayRange, formatMonth } from "../../format/date.js";
3
+ import { buttonClass } from "../../primitives/button.js";
4
+ import { AppLink } from "../use-app-address.js";
5
+ import { calendarView, gridDays, stepView, todayView } from "./calendar-range.js";
6
+ import { ChevronLeft, ChevronRight } from "lucide-react";
7
+ import { jsx, jsxs } from "react/jsx-runtime";
8
+ //#region src/app/displays/calendar-controls.tsx
9
+ /** The heading of the month or week a calendar shows. */
10
+ const periodText = (view) => {
11
+ const days = gridDays(view);
12
+ const ms = (day) => Date.parse(`${day}T00:00:00Z`);
13
+ return view.scale === "month" ? formatMonth(ms(view.day)) : formatDayRange(ms(days[0]), ms(days.at(-1)));
14
+ };
15
+ /**
16
+ * The month or week a calendar shows, with the way to the one before, the one holding today, the one after, and the
17
+ * month or the week: links when the calendar's view is in the address, so each opens in a new tab, else buttons.
18
+ */
19
+ const CalendarControls = ({ view, now, linkTo, onView }) => {
20
+ const control = (to, className, label, children, current) => linkTo ? /* @__PURE__ */ jsx(AppLink, {
21
+ to: linkTo(to),
22
+ className,
23
+ "aria-label": label,
24
+ "aria-current": current ? "page" : void 0,
25
+ children
26
+ }) : /* @__PURE__ */ jsx("button", {
27
+ type: "button",
28
+ className,
29
+ "aria-label": label,
30
+ "aria-pressed": current,
31
+ onClick: () => onView?.(to),
32
+ children
33
+ });
34
+ const holdsToday = view.scale === "month" && new Date(now).toISOString().startsWith(view.day.slice(0, 7));
35
+ const scaled = (to) => to === "week" && holdsToday ? todayView("week", now) : calendarView({
36
+ scale: to,
37
+ day: view.day
38
+ }, now);
39
+ const scale = (to, label) => control(scaled(to), segmentedClass.option(view.scale === to), label, label, view.scale === to);
40
+ const unit = view.scale === "month" ? "month" : "week";
41
+ const square = buttonClass({
42
+ variant: "secondary",
43
+ size: "sm"
44
+ });
45
+ return /* @__PURE__ */ jsxs("div", {
46
+ className: "flex flex-wrap items-center justify-between gap-2",
47
+ children: [/* @__PURE__ */ jsx("h2", {
48
+ className: "text-sm font-medium",
49
+ children: periodText(view)
50
+ }), /* @__PURE__ */ jsxs("div", {
51
+ className: "flex items-center gap-2",
52
+ children: [/* @__PURE__ */ jsxs("div", {
53
+ className: "flex items-center gap-1",
54
+ children: [
55
+ control(stepView(view, -1), square, `Previous ${unit}`, /* @__PURE__ */ jsx(ChevronLeft, { className: "size-3.5" })),
56
+ control(todayView(view.scale, now), square, "Today", "Today"),
57
+ control(stepView(view, 1), square, `Next ${unit}`, /* @__PURE__ */ jsx(ChevronRight, { className: "size-3.5" }))
58
+ ]
59
+ }), /* @__PURE__ */ jsxs("span", {
60
+ role: "group",
61
+ "aria-label": "Calendar scale",
62
+ className: segmentedClass.group(),
63
+ children: [scale("month", "Month"), scale("week", "Week")]
64
+ })]
65
+ })]
66
+ });
67
+ };
68
+ //#endregion
69
+ export { CalendarControls, periodText };
@@ -0,0 +1,33 @@
1
+ import { cn } from "../../lib/cn.js";
2
+ import { formatWeekday, formatWeekdayDate } from "../../format/date.js";
3
+ import { dayId } from "./calendar-dnd.js";
4
+ import { jsx, jsxs } from "react/jsx-runtime";
5
+ import { useDroppable } from "@dnd-kit/core";
6
+ //#region src/app/displays/calendar-day.tsx
7
+ /** A day of a calendar, where an event is dropped: headed by its number, today marked, its events in start order. */
8
+ const CalendarDay = ({ day, count, children, more, inMonth, today, week }) => {
9
+ const { setNodeRef, isOver } = useDroppable({ id: dayId(day) });
10
+ const ms = Date.parse(`${day}T00:00:00Z`);
11
+ return /* @__PURE__ */ jsxs("section", {
12
+ ref: setNodeRef,
13
+ "aria-label": `${formatWeekdayDate(ms)}, ${count}`,
14
+ className: cn("flex min-w-0 flex-col gap-1 bg-background p-1", week ? "min-h-28 sm:min-h-64" : "min-h-24", !inMonth && "bg-surface", isOver && "ring-2 ring-inset ring-primary/30"),
15
+ children: [
16
+ /* @__PURE__ */ jsxs("header", {
17
+ className: cn("flex items-center gap-1 px-0.5 text-xs", inMonth ? "text-foreground" : "text-muted-foreground"),
18
+ children: [week && /* @__PURE__ */ jsx("span", {
19
+ className: "text-muted-foreground",
20
+ children: formatWeekday(ms)
21
+ }), /* @__PURE__ */ jsx("span", {
22
+ "aria-current": today ? "date" : void 0,
23
+ className: cn("inline-flex size-5 items-center justify-center rounded-full tabular-nums", today && "bg-primary font-medium text-primary-foreground"),
24
+ children: Number(day.slice(8))
25
+ })]
26
+ }),
27
+ children,
28
+ more
29
+ ]
30
+ });
31
+ };
32
+ //#endregion
33
+ export { CalendarDay };
@@ -0,0 +1,174 @@
1
+ import { cn } from "../../lib/cn.js";
2
+ import { Skeleton } from "../../primitives/skeleton.js";
3
+ import { formatDate, formatWeekday } from "../../format/date.js";
4
+ import { ErrorBanner } from "../error-banner.js";
5
+ import { usePermissions } from "../meta-gate.js";
6
+ import { AppLink } from "../use-app-address.js";
7
+ import { can, rowAllows } from "../permissions.js";
8
+ import { collision } from "./board-dnd.js";
9
+ import { byDay, calendarView, gridDays, rangeOf } from "./calendar-range.js";
10
+ import { CalendarControls } from "./calendar-controls.js";
11
+ import { dayCoordinates, dayId } from "./calendar-dnd.js";
12
+ import { CalendarDay } from "./calendar-day.js";
13
+ import { CalendarEvent, EventChip, eventTitle } from "./calendar-event.js";
14
+ import { useCalendarRows } from "./use-calendar-rows.js";
15
+ import { movedRows, useCalendarMove } from "./use-calendar-move.js";
16
+ import { useRef, useState } from "react";
17
+ import { jsx, jsxs } from "react/jsx-runtime";
18
+ import { DndContext, DragOverlay, KeyboardSensor, MouseSensor, TouchSensor, useSensor, useSensors } from "@dnd-kit/core";
19
+ //#region src/app/displays/calendar-display.tsx
20
+ /** The events a month's day shows before a link to its week, which shows them all. */
21
+ const monthDayLimit = 3;
22
+ /**
23
+ * The list's records on the days of a month or a week, loaded for those days alone through the list API; an event
24
+ * dragged, or moved with the keyboard, to another day saves the record with its dates moved by the same days.
25
+ */
26
+ const CalendarDisplay = ({ model, view, display, fields, filter, at, linkTo, now = Date.now() }) => {
27
+ const [local, setLocal] = useState();
28
+ const shown = calendarView(linkTo ? at : local, now);
29
+ const days = gridDays(shown);
30
+ const list = useCalendarRows({
31
+ model,
32
+ display,
33
+ range: rangeOf(shown),
34
+ fields,
35
+ filter
36
+ });
37
+ const { moves, move } = useCalendarMove(model, display);
38
+ const placed = byDay(movedRows(list.rows, moves), display, model, days);
39
+ const permissions = usePermissions(model.name);
40
+ const movable = can(permissions, "update") && [display.start, display.end].every((name) => name === void 0 || !model.fields[name]?.readOnly);
41
+ const [active, setActive] = useState();
42
+ const dragged = useRef(false);
43
+ const sensors = useSensors(useSensor(MouseSensor, { activationConstraint: { distance: 4 } }), useSensor(TouchSensor, { activationConstraint: {
44
+ delay: 250,
45
+ tolerance: 5
46
+ } }), useSensor(KeyboardSensor, {
47
+ keyboardCodes: {
48
+ start: ["Space"],
49
+ cancel: ["Escape"],
50
+ end: ["Space", "Enter"]
51
+ },
52
+ coordinateGetter: dayCoordinates
53
+ }));
54
+ const today = new Date(now).toISOString().slice(0, 10);
55
+ const week = shown.scale === "week";
56
+ const dayText = (id) => formatDate(Date.parse(`${String(id).slice(dayId("").length)}T00:00:00Z`));
57
+ const title = (picked) => eventTitle(model, view, display, picked.row);
58
+ const announcements = {
59
+ onDragStart: ({ active: picked }) => `Picked up ${title(picked.data.current)}.`,
60
+ onDragOver: ({ active: picked, over }) => `${title(picked.data.current)} is over ${over ? dayText(over.id) : "no day"}.`,
61
+ onDragEnd: ({ active: picked, over }) => over ? `${title(picked.data.current)} was moved to ${dayText(over.id)}.` : `${title(picked.data.current)} was put back.`,
62
+ onDragCancel: ({ active: picked }) => `Moving ${title(picked.data.current)} was cancelled.`
63
+ };
64
+ const release = () => {
65
+ setActive(void 0);
66
+ setTimeout(() => dragged.current = false);
67
+ };
68
+ const start = ({ active: picked }) => {
69
+ dragged.current = true;
70
+ setActive(picked.data.current);
71
+ };
72
+ const end = ({ active: picked, over }) => {
73
+ release();
74
+ const { row, day } = picked.data.current;
75
+ const target = over ? String(over.id).slice(dayId("").length) : void 0;
76
+ if (target && target !== day) move(row, day, target);
77
+ };
78
+ const more = (day, hidden) => {
79
+ const to = calendarView({
80
+ scale: "week",
81
+ day
82
+ }, now);
83
+ const text = `+${hidden} more`;
84
+ const className = "self-start rounded px-1 text-xs text-muted-foreground hover:text-foreground focus-visible:outline-2 focus-visible:outline-primary";
85
+ return linkTo ? /* @__PURE__ */ jsx(AppLink, {
86
+ to: linkTo(to),
87
+ className,
88
+ children: text
89
+ }) : /* @__PURE__ */ jsx("button", {
90
+ type: "button",
91
+ className,
92
+ onClick: () => setLocal(to),
93
+ children: text
94
+ });
95
+ };
96
+ return /* @__PURE__ */ jsxs("div", {
97
+ className: "flex min-w-0 flex-1 flex-col gap-3",
98
+ children: [
99
+ /* @__PURE__ */ jsx(CalendarControls, {
100
+ view: shown,
101
+ now,
102
+ linkTo,
103
+ onView: setLocal
104
+ }),
105
+ list.error && /* @__PURE__ */ jsx(ErrorBanner, {
106
+ title: "Could not load rows",
107
+ error: list.error
108
+ }),
109
+ list.capped && /* @__PURE__ */ jsxs("p", {
110
+ className: "text-xs text-muted-foreground",
111
+ children: [
112
+ "Showing the first ",
113
+ 500,
114
+ " records of this ",
115
+ shown.scale,
116
+ ". Narrow the filter to see the rest."
117
+ ]
118
+ }),
119
+ /* @__PURE__ */ jsxs(DndContext, {
120
+ sensors,
121
+ collisionDetection: collision,
122
+ onDragStart: start,
123
+ onDragEnd: end,
124
+ onDragCancel: release,
125
+ accessibility: {
126
+ announcements,
127
+ screenReaderInstructions: { draggable: "To move an event to another day, press Space, move it a day with the left and right arrow keys or a week with the up and down arrow keys, and press Space again to drop it there. Escape puts it back." }
128
+ },
129
+ children: [/* @__PURE__ */ jsxs("div", {
130
+ className: "overflow-hidden rounded-lg border border-border",
131
+ children: [!week && /* @__PURE__ */ jsx("div", {
132
+ "aria-hidden": true,
133
+ className: "grid grid-cols-7 gap-px border-b border-border bg-border",
134
+ children: days.slice(0, 7).map((day) => /* @__PURE__ */ jsx("span", {
135
+ className: "bg-surface px-1.5 py-1 text-xs font-medium text-muted-foreground",
136
+ children: formatWeekday(Date.parse(`${day}T00:00:00Z`))
137
+ }, day))
138
+ }), /* @__PURE__ */ jsx("div", {
139
+ className: cn("grid gap-px bg-border", week ? "grid-cols-1 sm:grid-cols-7" : "grid-cols-7"),
140
+ children: days.map((day) => {
141
+ const events = placed.get(day);
142
+ const visible = week ? events : events.slice(0, monthDayLimit);
143
+ return /* @__PURE__ */ jsx(CalendarDay, {
144
+ day,
145
+ count: events.length,
146
+ inMonth: week || day.startsWith(shown.day.slice(0, 7)),
147
+ today: day === today,
148
+ week,
149
+ more: visible.length < events.length && more(day, events.length - visible.length),
150
+ children: list.loading ? /* @__PURE__ */ jsx(Skeleton, { className: "h-4" }) : visible.map((row) => /* @__PURE__ */ jsx(CalendarEvent, {
151
+ model,
152
+ view,
153
+ display,
154
+ row,
155
+ day,
156
+ movable: movable && rowAllows(row.record, "update"),
157
+ dragged: () => dragged.current
158
+ }, row.key))
159
+ }, day);
160
+ })
161
+ })]
162
+ }), /* @__PURE__ */ jsx(DragOverlay, { children: active && /* @__PURE__ */ jsx(EventChip, {
163
+ model,
164
+ view,
165
+ display,
166
+ row: active.row,
167
+ day: active.day
168
+ }) })]
169
+ })
170
+ ]
171
+ });
172
+ };
173
+ //#endregion
174
+ export { CalendarDisplay };