@protobase/ui 0.21.7 → 0.21.9
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/app.js +1 -0
- package/dist/app/charts/chart-view.js +3 -1
- package/dist/app/comments/comment-body.js +13 -0
- package/dist/app/comments/comment-composer.js +149 -0
- package/dist/app/comments/comments-section.js +78 -0
- package/dist/app/comments/mention-text.js +45 -0
- package/dist/app/comments/use-mentionable.js +18 -0
- package/dist/app/comments/use-post-comment.js +15 -0
- package/dist/app/comments/use-record-comments.js +24 -0
- package/dist/app/current-address.d.ts +4 -3
- package/dist/app/current-address.js +1 -0
- package/dist/app/displays/calendar-controls.js +70 -0
- package/dist/app/displays/calendar-day.js +33 -0
- package/dist/app/displays/calendar-display.js +175 -0
- package/dist/app/displays/calendar-dnd.js +27 -0
- package/dist/app/displays/calendar-event.js +73 -0
- package/dist/app/displays/calendar-range.js +129 -0
- package/dist/app/displays/list-displays.js +7 -1
- package/dist/app/displays/use-calendar-move.js +68 -0
- package/dist/app/displays/use-calendar-rows.js +35 -0
- package/dist/app/embedded-list/embedded-list.js +25 -21
- package/dist/app/embedded-list/embedded-row-targets.js +65 -0
- package/dist/app/export/cells.js +6 -4
- package/dist/app/field-editor.js +13 -1
- package/dist/app/import/convert.js +2 -12
- package/dist/app/import/locale-formats.js +2 -1
- package/dist/app/incoming-card.js +35 -0
- package/dist/app/incoming-relations.js +36 -0
- package/dist/app/list-chart.js +3 -1
- package/dist/app/list-page.js +16 -5
- package/dist/app/pages/field-value.js +9 -1
- package/dist/app/pages/record-actions.js +34 -16
- package/dist/app/pages/use-run-action.js +8 -6
- package/dist/app/record-page.js +50 -28
- package/dist/app/related-opens.js +22 -0
- package/dist/app/related-section.js +4 -1
- package/dist/app/runtime/runtime-status.js +5 -4
- package/dist/app/search/navigation-hits.js +45 -0
- package/dist/app/search/use-global-search.js +13 -4
- package/dist/app/use-app-address.d.ts +3 -1
- package/dist/app/use-app-address.js +8 -9
- package/dist/app/use-incoming-counts.js +38 -0
- package/dist/app/use-related-records.js +3 -10
- package/dist/app-shell/app-shell.js +9 -2
- package/dist/app-shell/index.d.ts +1 -0
- package/dist/app-shell/logo.d.ts +5 -1
- package/dist/app-shell/logo.js +10 -3
- package/dist/app-shell/sidebar.js +3 -2
- package/dist/app-shell/top-bar-slot.js +11 -0
- package/dist/app-shell/top-bar.d.ts +3 -0
- package/dist/app-shell/top-bar.js +27 -19
- package/dist/auth/user-to-shell.js +3 -1
- package/dist/data/api-provider.d.ts +20 -0
- package/dist/data/query-keys.js +15 -0
- package/dist/data-grid/cells.js +8 -1
- package/dist/data-grid/grid-header.js +17 -16
- package/dist/data-grid/header-menu.js +16 -6
- package/dist/data-grid/row-menu.js +10 -40
- package/dist/format/date.js +52 -6
- package/dist/format/number-text.js +16 -0
- package/dist/format/zone.js +62 -0
- package/dist/live/columns-from-model.js +2 -0
- package/dist/live/contact-link.js +25 -0
- package/dist/live/contact-links.js +42 -0
- package/dist/live/display-value.js +14 -4
- package/dist/live/field-values.js +20 -9
- package/dist/live/filter-string.js +30 -30
- package/dist/package.js +1 -1
- package/dist/primitives/popover-menu.js +56 -0
- package/dist/primitives/segmented.js +8 -3
- package/dist/primitives/textarea.d.ts +4 -3
- package/dist/primitives/textarea.js +9 -4
- package/dist/record-view/action-menu.js +59 -0
- package/dist/record-view/record-header.js +2 -6
- package/dist/record-view/record-sidebar.js +2 -30
- package/dist/shell/assets/app-B_L-OYQJ.js +69 -0
- package/dist/shell/assets/export-worker-BoAr3vuF.js +2 -0
- package/dist/shell/assets/{index-CQc-60lQ.js → index-CRNWPM01.js} +1 -1
- package/dist/shell/assets/index-CzqIlZPP.css +2 -0
- package/dist/shell/assets/{maplibre-BvtgTvfx.js → maplibre-DZlOrjmh.js} +1 -1
- package/dist/shell/assets/{markdown-content-CJ8g5iKO.js → markdown-content-DT_18T89.js} +1 -1
- package/dist/shell/assets/{record-map-DSXYVoqB.js → record-map-Bmns3gJf.js} +1 -1
- package/dist/shell/assets/{records-map-cU_FAK7w.js → records-map-Ci3qFCDs.js} +1 -1
- package/dist/shell/assets/{shared-protobase-ui-Bc0CoTXD.js → shared-protobase-ui-DPgVYrS4.js} +1 -1
- package/dist/shell/index.html +4 -4
- package/dist/shell/protobase.shell.json +1 -1
- package/package.json +4 -4
- package/dist/shell/assets/app-BbVdHzEY.js +0 -69
- package/dist/shell/assets/export-worker-CVd5saLq.js +0 -2
- package/dist/shell/assets/index-C1fzTv_X.css +0 -2
|
@@ -1,16 +1,20 @@
|
|
|
1
|
+
import { viewerZone, zoneLabel } from "../format/zone.js";
|
|
1
2
|
import { formatDate, formatDateTime } from "../format/date.js";
|
|
2
3
|
import { fileShown } from "./file-values.js";
|
|
3
4
|
import { enumLabel } from "@protobase/schema";
|
|
4
5
|
//#region src/live/display-value.ts
|
|
5
6
|
const affixed = (text, hints) => `${hints.prefix ?? ""}${text}${hints.suffix ?? ""}`;
|
|
6
|
-
/**
|
|
7
|
-
|
|
7
|
+
/**
|
|
8
|
+
* A read-only rendering of a stored value, following the view's prefix, suffix, decimals, format and value label hints; a
|
|
9
|
+
* timestamp on the clock of `zone`, the viewer's unless given.
|
|
10
|
+
*/
|
|
11
|
+
const displayValue = (field, value, hints = {}, zone = viewerZone()) => {
|
|
8
12
|
if (value === null || value === void 0 || value === "") return "—";
|
|
9
13
|
switch (field.type) {
|
|
10
14
|
case "boolean": return value ? "Yes" : "No";
|
|
11
15
|
case "file": return fileShown(value)?.name ?? "—";
|
|
12
16
|
case "enum": return enumLabel(String(value), hints.valueLabels);
|
|
13
|
-
case "timestamp": return formatDateTime(Date.parse(String(value)));
|
|
17
|
+
case "timestamp": return formatDateTime(Date.parse(String(value)), zone);
|
|
14
18
|
case "date": return formatDate(Date.parse(String(value)));
|
|
15
19
|
case "decimal": {
|
|
16
20
|
const text = hints.decimals === void 0 ? String(value) : Number(value).toFixed(hints.decimals);
|
|
@@ -25,5 +29,11 @@ const displayValue = (field, value, hints = {}) => {
|
|
|
25
29
|
default: return typeof value === "object" ? JSON.stringify(value) : String(value);
|
|
26
30
|
}
|
|
27
31
|
};
|
|
32
|
+
/** The hover label of a shown value: the time zone a timestamp is shown in, nothing for other values. */
|
|
33
|
+
const valueTitle = (field, value, zone = viewerZone()) => {
|
|
34
|
+
if (field.type !== "timestamp" || typeof value !== "string") return void 0;
|
|
35
|
+
const ms = Date.parse(value);
|
|
36
|
+
return Number.isNaN(ms) ? void 0 : `Shown in ${zoneLabel(ms, zone)}`;
|
|
37
|
+
};
|
|
28
38
|
//#endregion
|
|
29
|
-
export { displayValue };
|
|
39
|
+
export { displayValue, valueTitle };
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { localeDecimal, parseNumberText } from "../format/number-text.js";
|
|
1
2
|
import { fileDiffers, filePatchValue } from "./file-values.js";
|
|
2
3
|
//#region src/live/field-values.ts
|
|
3
4
|
/** What the editor's text input holds for a stored value. */
|
|
@@ -14,32 +15,42 @@ const textEdited = /* @__PURE__ */ new Set([
|
|
|
14
15
|
const isEditable = (field) => !field.readOnly && field.type !== "json" && field.type !== "uuid" && field.type !== "timestamp" && field.type !== "relation";
|
|
15
16
|
/** A field a record's form cannot leave empty: written through the API, never null and no boolean, which always holds a value. */
|
|
16
17
|
const isRequiredOnEdit = (field) => !field.readOnly && !field.nullable && field.type !== "boolean";
|
|
17
|
-
|
|
18
|
-
|
|
18
|
+
const numeric = /* @__PURE__ */ new Set([
|
|
19
|
+
"integer",
|
|
20
|
+
"bigint",
|
|
21
|
+
"decimal"
|
|
22
|
+
]);
|
|
23
|
+
const numberText = (text, decimal) => parseNumberText(text, decimal) ?? text;
|
|
24
|
+
/**
|
|
25
|
+
* The JSON value to send for an edited field: integers become numbers, empty optional fields become null, and numbers
|
|
26
|
+
* typed with the `decimal` separator of the browser's language are read as such.
|
|
27
|
+
*/
|
|
28
|
+
const toPatchValue = (field, draft, decimal = localeDecimal()) => {
|
|
19
29
|
if (field.type === "boolean") return Boolean(draft);
|
|
20
30
|
if (field.type === "file") return filePatchValue(draft);
|
|
21
|
-
const
|
|
22
|
-
if (
|
|
31
|
+
const typed = String(draft).trim();
|
|
32
|
+
if (typed === "") return field.nullable ? null : "";
|
|
33
|
+
const text = numeric.has(field.type) ? numberText(typed, decimal) : typed;
|
|
23
34
|
if (field.type === "integer") return Number(text);
|
|
24
35
|
if (field.type === "relation") return typeof draft === "number" ? draft : text;
|
|
25
36
|
if (textEdited.has(field.type) || field.type === "enum") return text;
|
|
26
37
|
throw new Error(`Not implemented: editing ${field.type} fields`);
|
|
27
38
|
};
|
|
28
39
|
/** The value a draft stands for, as the API gives one: null when empty, a boolean or number where the field holds one. */
|
|
29
|
-
const fromDraft = (field, draft) => {
|
|
40
|
+
const fromDraft = (field, draft, decimal = localeDecimal()) => {
|
|
30
41
|
if (field.type === "boolean") return Boolean(draft);
|
|
31
42
|
if (String(draft ?? "").trim() === "") return null;
|
|
32
|
-
return toPatchValue(field, draft);
|
|
43
|
+
return toPatchValue(field, draft, decimal);
|
|
33
44
|
};
|
|
34
45
|
/** The draft for a value, as the editor holds it: a boolean for a boolean field, else its text. */
|
|
35
46
|
const toFieldDraft = (field, value) => field.type === "boolean" ? Boolean(value) : toDraft(value);
|
|
36
|
-
/** Whether the draft differs from what the server holds, comparing numbers by value
|
|
37
|
-
const differs = (field, draft, stored) => {
|
|
47
|
+
/** Whether the draft differs from what the server holds, comparing numbers by value, as typed with `decimal`. */
|
|
48
|
+
const differs = (field, draft, stored, decimal = localeDecimal()) => {
|
|
38
49
|
if (field.type === "boolean") return Boolean(draft) !== Boolean(stored);
|
|
39
50
|
if (field.type === "file") return fileDiffers(draft, stored);
|
|
40
51
|
const a = String(draft ?? "").trim();
|
|
41
52
|
const b = toDraft(stored);
|
|
42
|
-
if (field.type === "decimal" || field.type === "integer") return a === "" || b === "" ? a !== b : Number(a) !== Number(b);
|
|
53
|
+
if (field.type === "decimal" || field.type === "integer") return a === "" || b === "" ? a !== b : Number(numberText(a, decimal)) !== Number(b);
|
|
43
54
|
return a !== b;
|
|
44
55
|
};
|
|
45
56
|
//#endregion
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { dayIn, startOfDayIn, viewerZone } from "../format/zone.js";
|
|
1
2
|
import { createWhere, parseFilter, printFilter } from "@protobase/schema";
|
|
2
3
|
//#region src/live/filter-string.ts
|
|
3
4
|
const w = createWhere();
|
|
@@ -20,18 +21,16 @@ const relative = {
|
|
|
20
21
|
}
|
|
21
22
|
};
|
|
22
23
|
const iso = (ms) => new Date(ms).toISOString().replace(".000Z", "Z");
|
|
23
|
-
/**
|
|
24
|
-
const periodStart = (preset, now) => {
|
|
25
|
-
const
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
if (preset === "
|
|
31
|
-
if (preset === "
|
|
32
|
-
|
|
33
|
-
if (preset === "yesterday") return iso(Date.UTC(year, month, day - 1));
|
|
34
|
-
return iso(Date.UTC(year, month, day));
|
|
24
|
+
/** The first day of the period a calendar preset starts at, as `YYYY-MM-DD` on the clock of `zone`. */
|
|
25
|
+
const periodStart = (preset, now, zone) => {
|
|
26
|
+
const today = dayIn(now.getTime(), zone);
|
|
27
|
+
const [year, month] = [Number(today.slice(0, 4)), Number(today.slice(5, 7)) - 1];
|
|
28
|
+
const day = (ms) => new Date(ms).toISOString().slice(0, 10);
|
|
29
|
+
if (preset === "year") return day(Date.UTC(year, 0, 1));
|
|
30
|
+
if (preset === "quarter") return day(Date.UTC(year, Math.floor(month / 3) * 3, 1));
|
|
31
|
+
if (preset === "month") return day(Date.UTC(year, month, 1));
|
|
32
|
+
if (preset === "yesterday") return day(Date.parse(`${today}T00:00:00Z`) - 864e5);
|
|
33
|
+
return today;
|
|
35
34
|
};
|
|
36
35
|
const calendarPresets = [
|
|
37
36
|
"year",
|
|
@@ -40,23 +39,24 @@ const calendarPresets = [
|
|
|
40
39
|
"yesterday",
|
|
41
40
|
"today"
|
|
42
41
|
];
|
|
43
|
-
/**
|
|
44
|
-
const
|
|
42
|
+
/** The literal a day starts at: the plain date for a date field, its first instant in `zone` for a timestamp field. */
|
|
43
|
+
const dayLiteral = (day, kind, zone) => kind === "date" ? day : iso(startOfDayIn(day, zone));
|
|
44
|
+
const instant = (day, kind, zone) => kind === "date" ? {
|
|
45
45
|
kind: "string",
|
|
46
|
-
value:
|
|
46
|
+
value: dayLiteral(day, kind, zone),
|
|
47
47
|
span
|
|
48
48
|
} : {
|
|
49
49
|
kind: "timestamp",
|
|
50
|
-
value,
|
|
50
|
+
value: dayLiteral(day, kind, zone),
|
|
51
51
|
span
|
|
52
52
|
};
|
|
53
|
-
const dateClauses = (field, preset, now, kind) => {
|
|
53
|
+
const dateClauses = (field, preset, now, kind, zone) => {
|
|
54
54
|
if (preset in relative) return [w.gte(field, w.ago(preset))];
|
|
55
|
-
const from = w.gte(field, instant(periodStart(preset, now), kind));
|
|
56
|
-
return preset === "yesterday" ? [from, w.lt(field, instant(periodStart("today", now), kind))] : [from];
|
|
55
|
+
const from = w.gte(field, instant(periodStart(preset, now, zone), kind, zone));
|
|
56
|
+
return preset === "yesterday" ? [from, w.lt(field, instant(periodStart("today", now, zone), kind, zone))] : [from];
|
|
57
57
|
};
|
|
58
|
-
/** The panel's state as filter clauses, in a fixed order so the text is stable
|
|
59
|
-
const stateToClauses = (config, state, now = /* @__PURE__ */ new Date()) => {
|
|
58
|
+
/** The panel's state as filter clauses, in a fixed order so the text is stable; calendar presets start at midnight in `zone`. */
|
|
59
|
+
const stateToClauses = (config, state, now = /* @__PURE__ */ new Date(), zone = viewerZone()) => {
|
|
60
60
|
const facets = config.facets.flatMap((group) => {
|
|
61
61
|
const values = state.facets[group.id] ?? [];
|
|
62
62
|
if (values.length === 0) return [];
|
|
@@ -70,7 +70,7 @@ const stateToClauses = (config, state, now = /* @__PURE__ */ new Date()) => {
|
|
|
70
70
|
const span = state.ranges?.[range.id];
|
|
71
71
|
return span ? [w.gte(range.id, span[0]), w.lte(range.id, span[1])] : [];
|
|
72
72
|
});
|
|
73
|
-
const date = config.dateField && state.date ? dateClauses(config.dateField, state.date, now, config.dateKind) : [];
|
|
73
|
+
const date = config.dateField && state.date ? dateClauses(config.dateField, state.date, now, config.dateKind, zone) : [];
|
|
74
74
|
const toggles = (config.toggles ?? []).filter((toggle) => state.toggles.includes(toggle.id)).map((toggle) => w.eq(toggle.id, true));
|
|
75
75
|
return [
|
|
76
76
|
...facets,
|
|
@@ -80,7 +80,7 @@ const stateToClauses = (config, state, now = /* @__PURE__ */ new Date()) => {
|
|
|
80
80
|
];
|
|
81
81
|
};
|
|
82
82
|
const clausesToText = (clauses) => clauses.length === 0 ? "" : printFilter(w.and(...clauses));
|
|
83
|
-
const stateToText = (config, state, extras = [], now = /* @__PURE__ */ new Date()) => clausesToText([...stateToClauses(config, state, now), ...extras]);
|
|
83
|
+
const stateToText = (config, state, extras = [], now = /* @__PURE__ */ new Date(), zone = viewerZone()) => clausesToText([...stateToClauses(config, state, now, zone), ...extras]);
|
|
84
84
|
const flatten = (expr) => expr.kind === "and" ? expr.args.flatMap(flatten) : [expr];
|
|
85
85
|
const fieldName = (expr) => expr.field.path.join(".");
|
|
86
86
|
const isTrue = (expr) => expr.kind === "compare" && expr.op === "=" && expr.value.kind === "boolean" && expr.value.value;
|
|
@@ -95,20 +95,20 @@ const flagsChosen = (clause, groupOf) => {
|
|
|
95
95
|
} : void 0;
|
|
96
96
|
};
|
|
97
97
|
const literalText = (value) => value.kind === "number" ? String(value.raw) : value.kind === "string" ? String(value.value) : void 0;
|
|
98
|
-
const presetOf = (clause, now) => {
|
|
98
|
+
const presetOf = (clause, now, zone) => {
|
|
99
99
|
const v = clause.value;
|
|
100
100
|
if (v.kind === "now" && v.offset?.sign === "-") {
|
|
101
101
|
const { amount, unit } = v.offset.duration;
|
|
102
102
|
return Object.entries(relative).find(([, d]) => d.amount === amount && d.unit === unit)?.[0];
|
|
103
103
|
}
|
|
104
|
-
if (v.kind === "timestamp") return calendarPresets.find((preset) => periodStart(preset, now) === v.value);
|
|
105
|
-
if (v.kind === "string") return calendarPresets.find((preset) => periodStart(preset, now
|
|
104
|
+
if (v.kind === "timestamp") return calendarPresets.find((preset) => dayLiteral(periodStart(preset, now, zone), "timestamp", zone) === v.value);
|
|
105
|
+
if (v.kind === "string") return calendarPresets.find((preset) => periodStart(preset, now, zone) === v.value);
|
|
106
106
|
};
|
|
107
107
|
/**
|
|
108
108
|
* The inverse of `stateToText`: clauses that match a widget fill the panel state, everything else
|
|
109
109
|
* (search text, hand-written conditions) is returned as `extras` and survives the round trip.
|
|
110
110
|
*/
|
|
111
|
-
const textToState = (config, text, now = /* @__PURE__ */ new Date()) => {
|
|
111
|
+
const textToState = (config, text, now = /* @__PURE__ */ new Date(), zone = viewerZone()) => {
|
|
112
112
|
const parsed = parseFilter(text);
|
|
113
113
|
if (!parsed.ok) return {
|
|
114
114
|
ok: false,
|
|
@@ -131,10 +131,10 @@ const textToState = (config, text, now = /* @__PURE__ */ new Date()) => {
|
|
|
131
131
|
if (flags && !state.facets[flags.group]) state.facets[flags.group] = flags.fields;
|
|
132
132
|
else if (clause.kind === "compare" && facetIds.has(fieldName(clause)) && clause.op === "=" && literalText(clause.value) !== void 0) state.facets[fieldName(clause)] = [...state.facets[fieldName(clause)] ?? [], literalText(clause.value)];
|
|
133
133
|
else if (clause.kind === "in" && facetIds.has(fieldName(clause)) && clause.values.every((value) => literalText(value) !== void 0)) state.facets[fieldName(clause)] = [...state.facets[fieldName(clause)] ?? [], ...clause.values.map((value) => literalText(value))];
|
|
134
|
-
else if (clause.kind === "compare" && config.dateField === fieldName(clause) && clause.op === ">=" && presetOf(clause, now) && !state.date) {
|
|
135
|
-
state.date = presetOf(clause, now);
|
|
134
|
+
else if (clause.kind === "compare" && config.dateField === fieldName(clause) && clause.op === ">=" && presetOf(clause, now, zone) && !state.date) {
|
|
135
|
+
state.date = presetOf(clause, now, zone);
|
|
136
136
|
dateClause = clause;
|
|
137
|
-
} else if (clause.kind === "compare" && config.dateField === fieldName(clause) && clause.op === "<" && (clause.value.kind === "timestamp" || clause.value.kind === "string") && periodStart("today", now)
|
|
137
|
+
} else if (clause.kind === "compare" && config.dateField === fieldName(clause) && clause.op === "<" && (clause.value.kind === "timestamp" || clause.value.kind === "string") && clause.value.value === dayLiteral(periodStart("today", now, zone), clause.value.kind === "string" ? "date" : "timestamp", zone)) todayEnd = clause;
|
|
138
138
|
else if (clause.kind === "compare" && toggleIds.has(fieldName(clause)) && clause.op === "=" && clause.value.kind === "boolean" && clause.value.value) state.toggles.push(fieldName(clause));
|
|
139
139
|
else if (clause.kind === "compare" && rangeIds.has(fieldName(clause)) && (clause.op === ">=" || clause.op === "<=") && clause.value.kind === "number") {
|
|
140
140
|
const range = bounds.get(fieldName(clause)) ?? { clauses: [] };
|
package/dist/package.js
CHANGED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { useEffect, useLayoutEffect, useRef, useState } from "react";
|
|
2
|
+
//#region src/primitives/popover-menu.ts
|
|
3
|
+
const gap = 4;
|
|
4
|
+
/** Below the button, or above it when the viewport has no room underneath; its right edge at the button's. */
|
|
5
|
+
const placeMenu = (button, menuHeight) => {
|
|
6
|
+
const below = button.bottom + gap;
|
|
7
|
+
return {
|
|
8
|
+
top: below + menuHeight > window.innerHeight - gap ? Math.max(gap, button.top - gap - menuHeight) : below,
|
|
9
|
+
right: window.innerWidth - button.right
|
|
10
|
+
};
|
|
11
|
+
};
|
|
12
|
+
/**
|
|
13
|
+
* A menu opened by a button and drawn fixed, portalled to the body, under or over the button: it closes on a click
|
|
14
|
+
* outside, Escape, and a scroll or resize, which would move the button away from it. `place` is undefined until the
|
|
15
|
+
* menu is measured, so it is drawn hidden for that first frame.
|
|
16
|
+
*/
|
|
17
|
+
const usePopoverMenu = () => {
|
|
18
|
+
const [open, setOpen] = useState(false);
|
|
19
|
+
const [place, setPlace] = useState();
|
|
20
|
+
const button = useRef(null);
|
|
21
|
+
const menu = useRef(null);
|
|
22
|
+
useLayoutEffect(() => {
|
|
23
|
+
if (!open || !button.current || !menu.current) {
|
|
24
|
+
setPlace(void 0);
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
setPlace(placeMenu(button.current.getBoundingClientRect(), menu.current.offsetHeight));
|
|
28
|
+
}, [open]);
|
|
29
|
+
useEffect(() => {
|
|
30
|
+
if (!open) return;
|
|
31
|
+
const inside = (target) => button.current?.contains(target) || menu.current?.contains(target);
|
|
32
|
+
const close = (event) => {
|
|
33
|
+
if (event instanceof KeyboardEvent ? event.key === "Escape" : !inside(event.target)) setOpen(false);
|
|
34
|
+
};
|
|
35
|
+
const dismiss = () => setOpen(false);
|
|
36
|
+
document.addEventListener("mousedown", close);
|
|
37
|
+
document.addEventListener("keydown", close);
|
|
38
|
+
document.addEventListener("scroll", dismiss, true);
|
|
39
|
+
window.addEventListener("resize", dismiss);
|
|
40
|
+
return () => {
|
|
41
|
+
document.removeEventListener("mousedown", close);
|
|
42
|
+
document.removeEventListener("keydown", close);
|
|
43
|
+
document.removeEventListener("scroll", dismiss, true);
|
|
44
|
+
window.removeEventListener("resize", dismiss);
|
|
45
|
+
};
|
|
46
|
+
}, [open]);
|
|
47
|
+
return {
|
|
48
|
+
open,
|
|
49
|
+
setOpen,
|
|
50
|
+
place,
|
|
51
|
+
button,
|
|
52
|
+
menu
|
|
53
|
+
};
|
|
54
|
+
};
|
|
55
|
+
//#endregion
|
|
56
|
+
export { usePopoverMenu };
|
|
@@ -11,10 +11,15 @@ const sizes = {
|
|
|
11
11
|
option: "px-3 text-body"
|
|
12
12
|
}
|
|
13
13
|
};
|
|
14
|
+
/** A segmented switch's classes, for a row of links that looks like one: the group, and each option, `active` the chosen. */
|
|
15
|
+
const segmentedClass = {
|
|
16
|
+
group: (size = "sm") => cn("inline-flex", sizes[size].group, "rounded-md bg-muted p-0.5"),
|
|
17
|
+
option: (active, size = "sm") => cn("inline-flex items-center gap-1.5 rounded", sizes[size].option, "font-medium transition-colors", active ? "bg-background text-foreground shadow-sm" : "text-muted-foreground hover:text-foreground")
|
|
18
|
+
};
|
|
14
19
|
const Segmented = ({ value, options, onChange, label, size = "sm", iconOnly = false }) => /* @__PURE__ */ jsx("span", {
|
|
15
20
|
role: "group",
|
|
16
21
|
"aria-label": label,
|
|
17
|
-
className:
|
|
22
|
+
className: segmentedClass.group(size),
|
|
18
23
|
children: options.map((option) => /* @__PURE__ */ jsxs("button", {
|
|
19
24
|
type: "button",
|
|
20
25
|
"aria-pressed": option.value === value,
|
|
@@ -23,9 +28,9 @@ const Segmented = ({ value, options, onChange, label, size = "sm", iconOnly = fa
|
|
|
23
28
|
title: option.label
|
|
24
29
|
},
|
|
25
30
|
onClick: () => onChange(option.value),
|
|
26
|
-
className:
|
|
31
|
+
className: segmentedClass.option(option.value === value, size),
|
|
27
32
|
children: [option.icon && /* @__PURE__ */ jsx(option.icon, { className: "size-3.5" }), !iconOnly && option.label]
|
|
28
33
|
}, option.value))
|
|
29
34
|
});
|
|
30
35
|
//#endregion
|
|
31
|
-
export { Segmented };
|
|
36
|
+
export { Segmented, segmentedClass };
|
|
@@ -1,9 +1,10 @@
|
|
|
1
|
-
import { TextareaHTMLAttributes } from "react";
|
|
1
|
+
import { Ref, TextareaHTMLAttributes } from "react";
|
|
2
2
|
//#region src/primitives/textarea.d.ts
|
|
3
3
|
export type TextareaProps = Omit<TextareaHTMLAttributes<HTMLTextAreaElement>, 'value'> & {
|
|
4
4
|
value: string;
|
|
5
5
|
invalid?: boolean;
|
|
6
|
+
ref?: Ref<HTMLTextAreaElement>;
|
|
6
7
|
};
|
|
7
|
-
/** A text box of several lines that grows with its text instead of scrolling inside. */
|
|
8
|
-
export declare const Textarea: ({ invalid, className, rows, value, ...rest }: TextareaProps) => import("react").JSX.Element;
|
|
8
|
+
/** A text box of several lines that grows with its text instead of scrolling inside; `ref` reaches the box too. */
|
|
9
|
+
export declare const Textarea: ({ invalid, className, rows, value, ref: given, ...rest }: TextareaProps) => import("react").JSX.Element;
|
|
9
10
|
//#endregion
|
|
@@ -1,15 +1,20 @@
|
|
|
1
1
|
import { cn } from "../lib/cn.js";
|
|
2
2
|
import { useFieldControl } from "./field-control.js";
|
|
3
|
-
import { useEffect, useLayoutEffect, useRef } from "react";
|
|
3
|
+
import { useCallback, useEffect, useLayoutEffect, useRef } from "react";
|
|
4
4
|
import { jsx } from "react/jsx-runtime";
|
|
5
5
|
//#region src/primitives/textarea.tsx
|
|
6
6
|
const fit = (box) => {
|
|
7
7
|
box.style.height = "auto";
|
|
8
8
|
box.style.height = `${box.scrollHeight + box.offsetHeight - box.clientHeight}px`;
|
|
9
9
|
};
|
|
10
|
-
/** A text box of several lines that grows with its text instead of scrolling inside. */
|
|
11
|
-
const Textarea = ({ invalid, className, rows = 3, value, ...rest }) => {
|
|
10
|
+
/** A text box of several lines that grows with its text instead of scrolling inside; `ref` reaches the box too. */
|
|
11
|
+
const Textarea = ({ invalid, className, rows = 3, value, ref: given, ...rest }) => {
|
|
12
12
|
const ref = useRef(null);
|
|
13
|
+
const setRef = useCallback((box) => {
|
|
14
|
+
ref.current = box;
|
|
15
|
+
if (typeof given === "function") given(box);
|
|
16
|
+
else if (given) given.current = box;
|
|
17
|
+
}, [given]);
|
|
13
18
|
const control = useFieldControl();
|
|
14
19
|
useLayoutEffect(() => {
|
|
15
20
|
if (ref.current) fit(ref.current);
|
|
@@ -27,7 +32,7 @@ const Textarea = ({ invalid, className, rows = 3, value, ...rest }) => {
|
|
|
27
32
|
return () => observer.disconnect();
|
|
28
33
|
}, []);
|
|
29
34
|
return /* @__PURE__ */ jsx("textarea", {
|
|
30
|
-
ref,
|
|
35
|
+
ref: setRef,
|
|
31
36
|
rows,
|
|
32
37
|
value,
|
|
33
38
|
className: cn("block w-full resize-none overflow-hidden rounded-md border bg-background px-2.5 py-1.5 text-body text-foreground shadow-sm outline-none placeholder:text-faint-foreground focus:border-primary focus:ring-2 focus:ring-primary/20", invalid ? "border-danger" : "border-border-strong", className),
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { cn } from "../lib/cn.js";
|
|
2
|
+
import { usePopoverMenu } from "../primitives/popover-menu.js";
|
|
3
|
+
import { MoreVertical } from "lucide-react";
|
|
4
|
+
import { Fragment, jsx, jsxs } from "react/jsx-runtime";
|
|
5
|
+
import { createPortal } from "react-dom";
|
|
6
|
+
//#region src/record-view/action-menu.tsx
|
|
7
|
+
const itemClass = "flex w-full items-center gap-2 px-3 py-2.5 text-left hover:bg-muted disabled:cursor-default disabled:opacity-60";
|
|
8
|
+
/**
|
|
9
|
+
* The actions of an action rail as a menu behind a "..." button, as a mobile screen's top bar holds them: each item the
|
|
10
|
+
* rail's, a link where the rail draws one, disabled with its reason when it cannot run. `className` styles the button.
|
|
11
|
+
*/
|
|
12
|
+
const ActionMenu = ({ actions, label = "Actions", className }) => {
|
|
13
|
+
const { open, setOpen, place, button, menu } = usePopoverMenu();
|
|
14
|
+
if (actions.length === 0) return null;
|
|
15
|
+
return /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx("button", {
|
|
16
|
+
ref: button,
|
|
17
|
+
type: "button",
|
|
18
|
+
"aria-label": label,
|
|
19
|
+
"aria-haspopup": "menu",
|
|
20
|
+
"aria-expanded": open,
|
|
21
|
+
onClick: () => setOpen(!open),
|
|
22
|
+
className,
|
|
23
|
+
children: /* @__PURE__ */ jsx(MoreVertical, { className: "size-5" })
|
|
24
|
+
}), open && createPortal(/* @__PURE__ */ jsx("ul", {
|
|
25
|
+
ref: menu,
|
|
26
|
+
role: "menu",
|
|
27
|
+
"aria-label": label,
|
|
28
|
+
style: place ?? { visibility: "hidden" },
|
|
29
|
+
className: "fixed z-50 min-w-48 rounded-md border bg-background py-1 shadow-pop",
|
|
30
|
+
children: actions.map(({ name, icon: Icon, onRun, blocked, running, tone, render }) => {
|
|
31
|
+
const children = /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx(Icon, {
|
|
32
|
+
className: cn("size-4 shrink-0", tone === "danger" ? "text-danger-text" : "text-muted-foreground"),
|
|
33
|
+
"aria-hidden": true
|
|
34
|
+
}), /* @__PURE__ */ jsx("span", {
|
|
35
|
+
className: cn("font-medium", tone === "danger" && "text-danger-text"),
|
|
36
|
+
children: name
|
|
37
|
+
})] });
|
|
38
|
+
return /* @__PURE__ */ jsx("li", {
|
|
39
|
+
role: "none",
|
|
40
|
+
onClick: () => setOpen(false),
|
|
41
|
+
children: render ? render({
|
|
42
|
+
className: itemClass,
|
|
43
|
+
children,
|
|
44
|
+
role: "menuitem"
|
|
45
|
+
}) : /* @__PURE__ */ jsx("button", {
|
|
46
|
+
type: "button",
|
|
47
|
+
role: "menuitem",
|
|
48
|
+
onClick: onRun,
|
|
49
|
+
disabled: Boolean(blocked) || running,
|
|
50
|
+
title: blocked,
|
|
51
|
+
className: itemClass,
|
|
52
|
+
children
|
|
53
|
+
})
|
|
54
|
+
}, name);
|
|
55
|
+
})
|
|
56
|
+
}), document.body)] });
|
|
57
|
+
};
|
|
58
|
+
//#endregion
|
|
59
|
+
export { ActionMenu };
|
|
@@ -2,11 +2,7 @@ import { AvatarStack } from "../primitives/avatar.js";
|
|
|
2
2
|
import { Badge } from "../primitives/badge.js";
|
|
3
3
|
import { Fragment, jsx, jsxs } from "react/jsx-runtime";
|
|
4
4
|
//#region src/record-view/record-header.tsx
|
|
5
|
-
const RecordHeader = ({ collection, title,
|
|
6
|
-
if (titleHidden && !collection && !status && !lastEditedBy && presence.length === 0 && !actions) return /* @__PURE__ */ jsx("h1", {
|
|
7
|
-
className: "sr-only",
|
|
8
|
-
children: title
|
|
9
|
-
});
|
|
5
|
+
const RecordHeader = ({ collection, title, status, lastEditedBy, presence = [], actions }) => {
|
|
10
6
|
return /* @__PURE__ */ jsxs("header", {
|
|
11
7
|
className: "flex flex-col gap-4 border-b border-border pb-6 md:flex-row md:items-end md:justify-between md:gap-6",
|
|
12
8
|
children: [/* @__PURE__ */ jsxs("div", { children: [
|
|
@@ -17,7 +13,7 @@ const RecordHeader = ({ collection, title, titleHidden, status, lastEditedBy, pr
|
|
|
17
13
|
/* @__PURE__ */ jsxs("div", {
|
|
18
14
|
className: "flex flex-wrap items-center gap-x-3 gap-y-1",
|
|
19
15
|
children: [/* @__PURE__ */ jsx("h1", {
|
|
20
|
-
className:
|
|
16
|
+
className: "text-xl font-semibold tracking-tight text-foreground md:text-2xl",
|
|
21
17
|
children: title
|
|
22
18
|
}), status && /* @__PURE__ */ jsx(Badge, {
|
|
23
19
|
tone: status.tone,
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import
|
|
1
|
+
import "lucide-react";
|
|
2
2
|
import { jsx, jsxs } from "react/jsx-runtime";
|
|
3
3
|
//#region src/record-view/record-sidebar.tsx
|
|
4
4
|
const MetadataList = ({ rows }) => /* @__PURE__ */ jsx("dl", {
|
|
@@ -14,34 +14,6 @@ const MetadataList = ({ rows }) => /* @__PURE__ */ jsx("dl", {
|
|
|
14
14
|
})]
|
|
15
15
|
}, row.label))
|
|
16
16
|
});
|
|
17
|
-
const RelatedRecords = ({ records, onNavigate }) => /* @__PURE__ */ jsx("ul", {
|
|
18
|
-
className: "-mx-2",
|
|
19
|
-
children: records.map((record) => /* @__PURE__ */ jsx("li", { children: /* @__PURE__ */ jsxs("a", {
|
|
20
|
-
href: record.href ?? "#",
|
|
21
|
-
onClick: (event) => {
|
|
22
|
-
event.preventDefault();
|
|
23
|
-
if (record.href) onNavigate?.(record.href);
|
|
24
|
-
},
|
|
25
|
-
className: "group flex items-center justify-between gap-3 rounded-md px-2 py-2 hover:bg-muted",
|
|
26
|
-
children: [/* @__PURE__ */ jsxs("span", {
|
|
27
|
-
className: "min-w-0",
|
|
28
|
-
children: [
|
|
29
|
-
/* @__PURE__ */ jsx("span", {
|
|
30
|
-
className: "block text-[11px] uppercase tracking-wide text-faint-foreground",
|
|
31
|
-
children: record.kind
|
|
32
|
-
}),
|
|
33
|
-
/* @__PURE__ */ jsx("span", {
|
|
34
|
-
className: "block truncate font-medium text-primary-text",
|
|
35
|
-
children: record.label
|
|
36
|
-
}),
|
|
37
|
-
record.meta && /* @__PURE__ */ jsx("span", {
|
|
38
|
-
className: "block text-xs text-muted-foreground",
|
|
39
|
-
children: record.meta
|
|
40
|
-
})
|
|
41
|
-
]
|
|
42
|
-
}), /* @__PURE__ */ jsx(ChevronRight, { className: "size-4 shrink-0 text-faint-foreground group-hover:text-foreground" })]
|
|
43
|
-
}) }, record.label))
|
|
44
|
-
});
|
|
45
17
|
const SidebarCard = ({ title, children }) => /* @__PURE__ */ jsxs("div", {
|
|
46
18
|
className: "rounded-lg border border-border bg-background p-4",
|
|
47
19
|
children: [/* @__PURE__ */ jsx("h3", {
|
|
@@ -55,4 +27,4 @@ const MetadataCard = ({ title, rows }) => rows.length === 0 ? null : /* @__PURE_
|
|
|
55
27
|
children: /* @__PURE__ */ jsx(MetadataList, { rows })
|
|
56
28
|
});
|
|
57
29
|
//#endregion
|
|
58
|
-
export { MetadataCard, MetadataList,
|
|
30
|
+
export { MetadataCard, MetadataList, SidebarCard };
|