mui-atelier 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +20 -0
- package/LICENSE +21 -0
- package/NOTICE +32 -0
- package/README.md +282 -0
- package/dist/components/AddressFields.js +83 -0
- package/dist/components/AuthCard.js +94 -0
- package/dist/components/AuthSplash.js +88 -0
- package/dist/components/BrandHeader.js +81 -0
- package/dist/components/BrandLink.js +22 -0
- package/dist/components/BrandMark.js +69 -0
- package/dist/components/BrandProvider.js +14 -0
- package/dist/components/Breadcrumbs.js +62 -0
- package/dist/components/CardHead.js +46 -0
- package/dist/components/ConfirmProvider.js +77 -0
- package/dist/components/DashboardLayout.js +645 -0
- package/dist/components/DataTable.js +546 -0
- package/dist/components/DetailRows.js +42 -0
- package/dist/components/EmptyNote.js +28 -0
- package/dist/components/Eyebrow.js +21 -0
- package/dist/components/HeadingHelp.js +38 -0
- package/dist/components/HeadingLevel.js +18 -0
- package/dist/components/IconTile.js +42 -0
- package/dist/components/ImagePreviewDialog.js +86 -0
- package/dist/components/LabeledField.js +156 -0
- package/dist/components/LanguageSwitcher.js +43 -0
- package/dist/components/ListRow.js +59 -0
- package/dist/components/ModeToggle.js +35 -0
- package/dist/components/PageHeader.js +65 -0
- package/dist/components/PasswordField.js +43 -0
- package/dist/components/PublicLayout.js +27 -0
- package/dist/components/PublicNav.js +17 -0
- package/dist/components/RouteMessage.js +31 -0
- package/dist/components/RowList.js +10 -0
- package/dist/components/SafeHtml.js +57 -0
- package/dist/components/SaveState.js +13 -0
- package/dist/components/SectionPanel.js +48 -0
- package/dist/components/SectionTabs.js +139 -0
- package/dist/components/SiteNav.js +130 -0
- package/dist/components/Skeletons.js +63 -0
- package/dist/components/Sortable.js +81 -0
- package/dist/components/StatTile.js +66 -0
- package/dist/components/StatusChip.js +17 -0
- package/dist/components/StatusDot.js +42 -0
- package/dist/components/SubCard.js +30 -0
- package/dist/components/TabbedCard.js +20 -0
- package/dist/components/TableFilters.js +247 -0
- package/dist/components/ToastProvider.js +65 -0
- package/dist/eslint/chipRules.mjs +65 -0
- package/dist/hooks/useDocumentTitle.js +19 -0
- package/dist/hooks/useOverflowTabs.js +53 -0
- package/dist/hooks/useUnsavedChanges.js +46 -0
- package/dist/i18n/loadMessages.js +15 -0
- package/dist/i18n/mergeMessages.js +13 -0
- package/dist/i18n/messages/en.json +103 -0
- package/dist/i18n/messages/es.json +103 -0
- package/dist/i18n/messages/zh.json +103 -0
- package/dist/lib/authCard.js +4 -0
- package/dist/lib/chipTone.js +46 -0
- package/dist/lib/countryRegionData.js +29 -0
- package/dist/lib/countryRegionData.json +1 -0
- package/dist/lib/navLock.js +22 -0
- package/dist/lib/navTabSx.js +78 -0
- package/dist/lib/orgTime.js +243 -0
- package/dist/lib/pageHtml.js +139 -0
- package/dist/lib/safeNext.js +13 -0
- package/dist/lib/sanitizeHtml.js +36 -0
- package/dist/lib/slugify.js +7 -0
- package/dist/lib/tableFilter.js +147 -0
- package/dist/lib/tableStyle.js +87 -0
- package/dist/test/jsdomShims.js +53 -0
- package/dist/test/themeContract.js +178 -0
- package/dist/theme/OrgThemeRegistry.js +71 -0
- package/dist/theme/ThemeRegistry.js +17 -0
- package/dist/theme/berryNavRow.js +21 -0
- package/dist/theme/createSiteTheme.js +974 -0
- package/dist/theme/themes.js +438 -0
- package/package.json +118 -0
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { jsx, jsxs } from "react/jsx-runtime";
|
|
2
|
+
import Box from "@mui/material/Box";
|
|
3
|
+
import Stack from "@mui/material/Stack";
|
|
4
|
+
import Typography from "@mui/material/Typography";
|
|
5
|
+
function SubCard({ title, help, action = null, children, sx }) {
|
|
6
|
+
return /* @__PURE__ */ jsxs(
|
|
7
|
+
Box,
|
|
8
|
+
{
|
|
9
|
+
sx: [
|
|
10
|
+
// The theme's base corner, as a px string — a bare number here would
|
|
11
|
+
// MULTIPLY the theme's radius (DESIGN.md, the sx Radius Rule).
|
|
12
|
+
(t) => ({ border: "1px solid", borderColor: "divider", borderRadius: `${t.rt?.radii?.base ?? 8}px`, p: { xs: 2, sm: 2.5 } }),
|
|
13
|
+
...Array.isArray(sx) ? sx : [sx]
|
|
14
|
+
],
|
|
15
|
+
children: [
|
|
16
|
+
title || action ? /* @__PURE__ */ jsxs(Stack, { direction: "row", spacing: 1.5, sx: { alignItems: "flex-start", mb: 2 }, children: [
|
|
17
|
+
/* @__PURE__ */ jsxs(Box, { sx: { flex: 1, minWidth: 0 }, children: [
|
|
18
|
+
title ? /* @__PURE__ */ jsx(Typography, { variant: "subtitle2", component: "p", sx: { fontWeight: 600 }, children: title }) : null,
|
|
19
|
+
help ? /* @__PURE__ */ jsx(Typography, { variant: "caption", sx: { color: "text.secondary", display: "block", mt: 0.25 }, children: help }) : null
|
|
20
|
+
] }),
|
|
21
|
+
action
|
|
22
|
+
] }) : null,
|
|
23
|
+
children
|
|
24
|
+
]
|
|
25
|
+
}
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
export {
|
|
29
|
+
SubCard as default
|
|
30
|
+
};
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { Fragment, jsx, jsxs } from "react/jsx-runtime";
|
|
3
|
+
import Box from "@mui/material/Box";
|
|
4
|
+
import Card from "@mui/material/Card";
|
|
5
|
+
import Divider from "@mui/material/Divider";
|
|
6
|
+
import { useTheme } from "@mui/material/styles";
|
|
7
|
+
function TabbedCard({ head = null, tabs, children, sx }) {
|
|
8
|
+
const underline = useTheme().rt?.ui?.tabs === "underline";
|
|
9
|
+
return /* @__PURE__ */ jsxs(Card, { sx, children: [
|
|
10
|
+
head ? /* @__PURE__ */ jsx(Box, { sx: { p: { xs: 2, sm: 3 }, pb: { xs: 2, sm: 2.5 } }, children: head }) : null,
|
|
11
|
+
underline ? /* @__PURE__ */ jsx(Box, { sx: { "& .MuiTabs-list": { px: { xs: 2, sm: 3 } } }, children: tabs }) : /* @__PURE__ */ jsxs(Fragment, { children: [
|
|
12
|
+
/* @__PURE__ */ jsx(Box, { sx: { p: { xs: 1.5, sm: 2 }, pb: { xs: 1.5, sm: 2 } }, children: tabs }),
|
|
13
|
+
/* @__PURE__ */ jsx(Divider, {})
|
|
14
|
+
] }),
|
|
15
|
+
children ? /* @__PURE__ */ jsx(Box, { sx: { p: { xs: 2, sm: 3 } }, children }) : null
|
|
16
|
+
] });
|
|
17
|
+
}
|
|
18
|
+
export {
|
|
19
|
+
TabbedCard as default
|
|
20
|
+
};
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { Fragment, jsx, jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { useState } from "react";
|
|
4
|
+
import { useTranslations } from "next-intl";
|
|
5
|
+
import Badge from "@mui/material/Badge";
|
|
6
|
+
import Box from "@mui/material/Box";
|
|
7
|
+
import Button from "@mui/material/Button";
|
|
8
|
+
import Checkbox from "@mui/material/Checkbox";
|
|
9
|
+
import Chip from "@mui/material/Chip";
|
|
10
|
+
import Collapse from "@mui/material/Collapse";
|
|
11
|
+
import IconButton from "@mui/material/IconButton";
|
|
12
|
+
import InputAdornment from "@mui/material/InputAdornment";
|
|
13
|
+
import ListItemText from "@mui/material/ListItemText";
|
|
14
|
+
import MenuItem from "@mui/material/MenuItem";
|
|
15
|
+
import Stack from "@mui/material/Stack";
|
|
16
|
+
import TextField from "@mui/material/TextField";
|
|
17
|
+
import Typography from "@mui/material/Typography";
|
|
18
|
+
import CloseRoundedIcon from "@mui/icons-material/CloseRounded";
|
|
19
|
+
import FilterListRoundedIcon from "@mui/icons-material/FilterListRounded";
|
|
20
|
+
import SearchRoundedIcon from "@mui/icons-material/SearchRounded";
|
|
21
|
+
import { activeCount, filterableColumns, optionsFor } from "../lib/tableFilter.js";
|
|
22
|
+
import LabeledField, { LabeledSelect } from "./LabeledField.js";
|
|
23
|
+
const EMPTY_RANGE = { from: "", to: "" };
|
|
24
|
+
const CLEARED = { select: [], daterange: EMPTY_RANGE, number: EMPTY_RANGE, text: "" };
|
|
25
|
+
const isRangeValue = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
26
|
+
function TableFilters({
|
|
27
|
+
columns,
|
|
28
|
+
rows,
|
|
29
|
+
filters,
|
|
30
|
+
onChange,
|
|
31
|
+
searchPlaceholder,
|
|
32
|
+
// The free-text box that spans every column. On by default; a table whose
|
|
33
|
+
// narrowing is all dropdowns (see DataTable's `searchable`) turns it off so the
|
|
34
|
+
// bar is just its selects.
|
|
35
|
+
searchable = true,
|
|
36
|
+
resultCount,
|
|
37
|
+
totalCount,
|
|
38
|
+
// The card's own heading and its action, when the table IS the page's list
|
|
39
|
+
// (see DataTable's `title`). They ride in this row rather than in a header
|
|
40
|
+
// row of their own, so the card has one head, not two stacked bars.
|
|
41
|
+
lead = null,
|
|
42
|
+
trailing = null
|
|
43
|
+
}) {
|
|
44
|
+
const t = useTranslations("uikit.table");
|
|
45
|
+
const [open, setOpen] = useState(false);
|
|
46
|
+
const filterable = filterableColumns(columns);
|
|
47
|
+
const active = activeCount(filters);
|
|
48
|
+
const setQuery = (query) => onChange({ ...filters, query });
|
|
49
|
+
const setColumn = (id, value) => onChange({
|
|
50
|
+
...filters,
|
|
51
|
+
byColumn: { ...filters.byColumn, [id]: value }
|
|
52
|
+
});
|
|
53
|
+
const isSet = (v) => {
|
|
54
|
+
if (isRangeValue(v)) return v.from !== "" && v.from !== void 0 ? true : v.to !== "" && v.to !== void 0;
|
|
55
|
+
return Array.isArray(v) ? v.length > 0 : v !== void 0 && v !== null && v !== "";
|
|
56
|
+
};
|
|
57
|
+
const chips = filterable.flatMap((column) => {
|
|
58
|
+
const value = filters.byColumn?.[column.id];
|
|
59
|
+
if (!isSet(value)) return [];
|
|
60
|
+
let shown;
|
|
61
|
+
if (column.kind === "select") {
|
|
62
|
+
const options = optionsFor(column, rows);
|
|
63
|
+
const names = value.map((v) => options.find((o) => o.value === v)?.label ?? v);
|
|
64
|
+
shown = names.length > 3 ? t("countSelected", { count: names.length }) : names.join(", ");
|
|
65
|
+
} else if (column.kind === "number") {
|
|
66
|
+
shown = value.from !== "" && value.to !== "" ? t("numberBetween", { from: value.from, to: value.to }) : value.from !== "" ? t("numberFrom", { from: value.from }) : t("numberTo", { to: value.to });
|
|
67
|
+
} else if (column.kind === "daterange") {
|
|
68
|
+
shown = value.from && value.to ? t("dateBetween", { from: value.from, to: value.to }) : value.from ? t("dateFrom", { from: value.from }) : t("dateTo", { to: value.to });
|
|
69
|
+
} else {
|
|
70
|
+
shown = `\u201C${value}\u201D`;
|
|
71
|
+
}
|
|
72
|
+
return [{ id: column.id, label: `${column.header}: ${shown}`, kind: column.kind }];
|
|
73
|
+
});
|
|
74
|
+
return /* @__PURE__ */ jsxs(Box, { sx: { px: 2, py: 1.5 }, children: [
|
|
75
|
+
/* @__PURE__ */ jsxs(Stack, { direction: "row", spacing: 1.5, sx: { alignItems: "center", flexWrap: "wrap", rowGap: 1 }, children: [
|
|
76
|
+
lead,
|
|
77
|
+
searchable ? /* @__PURE__ */ jsx(
|
|
78
|
+
TextField,
|
|
79
|
+
{
|
|
80
|
+
size: "small",
|
|
81
|
+
type: "search",
|
|
82
|
+
value: filters.query ?? "",
|
|
83
|
+
onChange: (e) => setQuery(e.target.value),
|
|
84
|
+
placeholder: searchPlaceholder ?? t("search"),
|
|
85
|
+
slotProps: {
|
|
86
|
+
input: {
|
|
87
|
+
startAdornment: /* @__PURE__ */ jsx(InputAdornment, { position: "start", children: /* @__PURE__ */ jsx(SearchRoundedIcon, { fontSize: "small" }) }),
|
|
88
|
+
/*
|
|
89
|
+
* An explicit × rather than the browser's own, which Safari and
|
|
90
|
+
* Firefox do not draw at all. Clearing a search was otherwise
|
|
91
|
+
* select-all-and-delete — and the "Clear all" button beside it
|
|
92
|
+
* only appears once a per-COLUMN filter is set, so a table
|
|
93
|
+
* narrowed by the search box alone had no clear at all.
|
|
94
|
+
*/
|
|
95
|
+
endAdornment: (filters.query ?? "") !== "" ? /* @__PURE__ */ jsx(InputAdornment, { position: "end", children: /* @__PURE__ */ jsx(IconButton, { size: "small", edge: "end", "aria-label": t("clearSearch"), onClick: () => setQuery(""), children: /* @__PURE__ */ jsx(CloseRoundedIcon, { fontSize: "small" }) }) }) : null
|
|
96
|
+
},
|
|
97
|
+
htmlInput: { "aria-label": t("search") }
|
|
98
|
+
},
|
|
99
|
+
sx: {
|
|
100
|
+
width: { xs: "100%", sm: 320 },
|
|
101
|
+
// Chrome draws its own × for type=search; two is one too many.
|
|
102
|
+
"& input::-webkit-search-cancel-button": { display: "none" }
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
) : null,
|
|
106
|
+
filterable.length > 0 ? /* @__PURE__ */ jsx(Badge, { badgeContent: active, color: "primary", overlap: "circular", children: /* @__PURE__ */ jsx(
|
|
107
|
+
Button,
|
|
108
|
+
{
|
|
109
|
+
size: "small",
|
|
110
|
+
startIcon: /* @__PURE__ */ jsx(FilterListRoundedIcon, {}),
|
|
111
|
+
onClick: () => setOpen((v) => !v),
|
|
112
|
+
"aria-expanded": open,
|
|
113
|
+
children: open ? t("hideFilters") : t("filters")
|
|
114
|
+
}
|
|
115
|
+
) }) : null,
|
|
116
|
+
chips.map((chip) => /* @__PURE__ */ jsx(
|
|
117
|
+
Chip,
|
|
118
|
+
{
|
|
119
|
+
size: "small",
|
|
120
|
+
label: chip.label,
|
|
121
|
+
onDelete: () => setColumn(chip.id, CLEARED[chip.kind] ?? "")
|
|
122
|
+
},
|
|
123
|
+
chip.id
|
|
124
|
+
)),
|
|
125
|
+
/* @__PURE__ */ jsx(Box, { sx: { flexGrow: 1 } }),
|
|
126
|
+
active > 0 ? /* @__PURE__ */ jsxs(Fragment, { children: [
|
|
127
|
+
/* @__PURE__ */ jsx(Typography, { variant: "body2", sx: { color: "text.secondary" }, role: "status", "aria-live": "polite", children: t("countOf", { count: resultCount, total: totalCount }) }),
|
|
128
|
+
/* @__PURE__ */ jsx(Button, { size: "small", onClick: () => onChange({ query: "", byColumn: {} }), children: t("clearAll") })
|
|
129
|
+
] }) : null,
|
|
130
|
+
trailing
|
|
131
|
+
] }),
|
|
132
|
+
/* @__PURE__ */ jsx(Collapse, { in: open, unmountOnExit: true, children: /* @__PURE__ */ jsx(
|
|
133
|
+
Box,
|
|
134
|
+
{
|
|
135
|
+
sx: {
|
|
136
|
+
mt: 1.5,
|
|
137
|
+
pt: 1.5,
|
|
138
|
+
borderTop: 1,
|
|
139
|
+
borderColor: "divider",
|
|
140
|
+
display: "grid",
|
|
141
|
+
gap: 1.5,
|
|
142
|
+
gridTemplateColumns: { xs: "1fr", sm: "repeat(2, 1fr)", md: "repeat(3, 1fr)" }
|
|
143
|
+
},
|
|
144
|
+
children: filterable.map((column) => {
|
|
145
|
+
const value = filters.byColumn?.[column.id] ?? CLEARED[column.kind] ?? "";
|
|
146
|
+
if (column.kind === "select") {
|
|
147
|
+
const options = optionsFor(column, rows);
|
|
148
|
+
return /* @__PURE__ */ jsx(LabeledField, { label: column.header, children: ({ labelId }) => /* @__PURE__ */ jsx(
|
|
149
|
+
LabeledSelect,
|
|
150
|
+
{
|
|
151
|
+
labelId,
|
|
152
|
+
size: "small",
|
|
153
|
+
value: Array.isArray(value) ? value : [],
|
|
154
|
+
onChange: (e) => setColumn(column.id, e.target.value),
|
|
155
|
+
multiple: true,
|
|
156
|
+
renderValue: (picked) => picked.map((v) => options.find((o) => o.value === v)?.label ?? v).join(", "),
|
|
157
|
+
children: options.map((o) => (
|
|
158
|
+
// `disabled` is for an option that is coming rather than
|
|
159
|
+
// one that matches nothing today: the campaign recipients
|
|
160
|
+
// table offers Authors and Reviewers greyed out, because
|
|
161
|
+
// hiding them would leave an admin wondering whether the
|
|
162
|
+
// product can do it at all.
|
|
163
|
+
/* @__PURE__ */ jsxs(MenuItem, { value: o.value, disabled: Boolean(o.disabled), children: [
|
|
164
|
+
/* @__PURE__ */ jsx(Checkbox, { size: "small", checked: (Array.isArray(value) ? value : []).includes(o.value) }),
|
|
165
|
+
/* @__PURE__ */ jsx(ListItemText, { primary: o.label })
|
|
166
|
+
] }, o.value)
|
|
167
|
+
))
|
|
168
|
+
}
|
|
169
|
+
) }, column.id);
|
|
170
|
+
}
|
|
171
|
+
if (column.kind === "number") {
|
|
172
|
+
const range = isRangeValue(value) ? value : EMPTY_RANGE;
|
|
173
|
+
return /* @__PURE__ */ jsxs(Box, { sx: { display: "grid", gap: 1, gridTemplateColumns: "1fr 1fr" }, children: [
|
|
174
|
+
/* @__PURE__ */ jsx(LabeledField, { label: t("numberFromLabel", { column: column.header }), children: ({ id }) => /* @__PURE__ */ jsx(
|
|
175
|
+
TextField,
|
|
176
|
+
{
|
|
177
|
+
id,
|
|
178
|
+
type: "number",
|
|
179
|
+
size: "small",
|
|
180
|
+
fullWidth: true,
|
|
181
|
+
value: range.from ?? "",
|
|
182
|
+
onChange: (e) => setColumn(column.id, { ...range, from: e.target.value }),
|
|
183
|
+
slotProps: { htmlInput: { min: 0, inputMode: "numeric" } }
|
|
184
|
+
}
|
|
185
|
+
) }),
|
|
186
|
+
/* @__PURE__ */ jsx(LabeledField, { label: t("numberToLabel", { column: column.header }), children: ({ id }) => /* @__PURE__ */ jsx(
|
|
187
|
+
TextField,
|
|
188
|
+
{
|
|
189
|
+
id,
|
|
190
|
+
type: "number",
|
|
191
|
+
size: "small",
|
|
192
|
+
fullWidth: true,
|
|
193
|
+
value: range.to ?? "",
|
|
194
|
+
onChange: (e) => setColumn(column.id, { ...range, to: e.target.value }),
|
|
195
|
+
slotProps: { htmlInput: { min: 0, inputMode: "numeric" } }
|
|
196
|
+
}
|
|
197
|
+
) })
|
|
198
|
+
] }, column.id);
|
|
199
|
+
}
|
|
200
|
+
if (column.kind === "daterange") {
|
|
201
|
+
const range = isRangeValue(value) ? value : EMPTY_RANGE;
|
|
202
|
+
return /* @__PURE__ */ jsxs(Box, { sx: { display: "grid", gap: 1, gridTemplateColumns: "1fr 1fr" }, children: [
|
|
203
|
+
/* @__PURE__ */ jsx(LabeledField, { label: t("dateFromLabel", { column: column.header }), children: ({ id }) => /* @__PURE__ */ jsx(
|
|
204
|
+
TextField,
|
|
205
|
+
{
|
|
206
|
+
id,
|
|
207
|
+
type: "date",
|
|
208
|
+
size: "small",
|
|
209
|
+
fullWidth: true,
|
|
210
|
+
value: range.from ?? "",
|
|
211
|
+
onChange: (e) => setColumn(column.id, { ...range, from: e.target.value }),
|
|
212
|
+
slotProps: { inputLabel: { shrink: true } }
|
|
213
|
+
}
|
|
214
|
+
) }),
|
|
215
|
+
/* @__PURE__ */ jsx(LabeledField, { label: t("dateToLabel", { column: column.header }), children: ({ id }) => /* @__PURE__ */ jsx(
|
|
216
|
+
TextField,
|
|
217
|
+
{
|
|
218
|
+
id,
|
|
219
|
+
type: "date",
|
|
220
|
+
size: "small",
|
|
221
|
+
fullWidth: true,
|
|
222
|
+
value: range.to ?? "",
|
|
223
|
+
onChange: (e) => setColumn(column.id, { ...range, to: e.target.value }),
|
|
224
|
+
slotProps: { inputLabel: { shrink: true } }
|
|
225
|
+
}
|
|
226
|
+
) })
|
|
227
|
+
] }, column.id);
|
|
228
|
+
}
|
|
229
|
+
return /* @__PURE__ */ jsx(LabeledField, { label: column.header, children: ({ id }) => /* @__PURE__ */ jsx(
|
|
230
|
+
TextField,
|
|
231
|
+
{
|
|
232
|
+
id,
|
|
233
|
+
size: "small",
|
|
234
|
+
fullWidth: true,
|
|
235
|
+
value,
|
|
236
|
+
onChange: (e) => setColumn(column.id, e.target.value),
|
|
237
|
+
placeholder: column.match === "exact" ? t("exactPlaceholder") : t("containsPlaceholder")
|
|
238
|
+
}
|
|
239
|
+
) }, column.id);
|
|
240
|
+
})
|
|
241
|
+
}
|
|
242
|
+
) })
|
|
243
|
+
] });
|
|
244
|
+
}
|
|
245
|
+
export {
|
|
246
|
+
TableFilters as default
|
|
247
|
+
};
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx, jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { createContext, useCallback, useContext, useMemo, useRef, useState } from "react";
|
|
4
|
+
import Alert from "@mui/material/Alert";
|
|
5
|
+
import Snackbar from "@mui/material/Snackbar";
|
|
6
|
+
import { useOrgThemeScope } from "../theme/OrgThemeRegistry.js";
|
|
7
|
+
const ToastContext = createContext(null);
|
|
8
|
+
const NOOP = {
|
|
9
|
+
success: () => {
|
|
10
|
+
},
|
|
11
|
+
error: () => {
|
|
12
|
+
},
|
|
13
|
+
info: () => {
|
|
14
|
+
}
|
|
15
|
+
};
|
|
16
|
+
function useToast() {
|
|
17
|
+
return useContext(ToastContext) ?? NOOP;
|
|
18
|
+
}
|
|
19
|
+
const AUTO_HIDE = { success: 4e3, info: 5e3, error: 9e3 };
|
|
20
|
+
function ToastProvider({ children }) {
|
|
21
|
+
const [toast, setToast] = useState(null);
|
|
22
|
+
const themeScope = useOrgThemeScope();
|
|
23
|
+
const seq = useRef(0);
|
|
24
|
+
const show = useCallback((severity, message) => {
|
|
25
|
+
seq.current += 1;
|
|
26
|
+
setToast({ severity, message, key: seq.current });
|
|
27
|
+
}, []);
|
|
28
|
+
const api = useMemo(() => ({
|
|
29
|
+
success: (message) => show("success", message),
|
|
30
|
+
error: (message) => show("error", message),
|
|
31
|
+
info: (message) => show("info", message)
|
|
32
|
+
}), [show]);
|
|
33
|
+
return /* @__PURE__ */ jsxs(ToastContext.Provider, { value: api, children: [
|
|
34
|
+
children,
|
|
35
|
+
/* @__PURE__ */ jsx(
|
|
36
|
+
Snackbar,
|
|
37
|
+
{
|
|
38
|
+
open: toast !== null,
|
|
39
|
+
autoHideDuration: toast ? AUTO_HIDE[toast.severity] : null,
|
|
40
|
+
onClose: (_e, reason) => {
|
|
41
|
+
if (reason === "clickaway") return;
|
|
42
|
+
setToast(null);
|
|
43
|
+
},
|
|
44
|
+
anchorOrigin: { vertical: "bottom", horizontal: "center" },
|
|
45
|
+
className: themeScope,
|
|
46
|
+
children: /* @__PURE__ */ jsx(
|
|
47
|
+
Alert,
|
|
48
|
+
{
|
|
49
|
+
severity: toast?.severity ?? "success",
|
|
50
|
+
variant: "filled",
|
|
51
|
+
onClose: () => setToast(null),
|
|
52
|
+
...toast?.severity === "error" ? {} : { role: "status", "aria-live": "polite" },
|
|
53
|
+
sx: { width: "100%" },
|
|
54
|
+
children: toast?.message
|
|
55
|
+
}
|
|
56
|
+
)
|
|
57
|
+
},
|
|
58
|
+
toast?.key
|
|
59
|
+
)
|
|
60
|
+
] });
|
|
61
|
+
}
|
|
62
|
+
export {
|
|
63
|
+
ToastProvider as default,
|
|
64
|
+
useToast
|
|
65
|
+
};
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* A STATUS CHIP STATES A ROLE; THE THEME SUPPLIES THE COLOUR.
|
|
3
|
+
*
|
|
4
|
+
* `<Chip color="success">` is a palette chosen for every theme by a call site
|
|
5
|
+
* that can only see one of them. Use StatusChip with a `role`
|
|
6
|
+
* (progress | done | caution | bad | idle | tag) and let each theme answer.
|
|
7
|
+
*
|
|
8
|
+
* A LINT RULE rather than a review note because the drift is invisible: nothing
|
|
9
|
+
* throws, no test fails, and the chip just quietly wears somebody else's
|
|
10
|
+
* colour, sometimes two different ones for the same chip in adjacent cells.
|
|
11
|
+
*
|
|
12
|
+
* `default` is allowed — it asks for no colour at all, which an unstyled chip
|
|
13
|
+
* already is.
|
|
14
|
+
*
|
|
15
|
+
* This file is the ONE copy: an app's eslint config imports it (see
|
|
16
|
+
* `chipRules` below) rather than restating it.
|
|
17
|
+
*/
|
|
18
|
+
export const chipColorRule = {
|
|
19
|
+
selector: "JSXOpeningElement[name.name='Chip'] > JSXAttribute[name.name='color'][value.value!='default']",
|
|
20
|
+
message: 'Chips state a ROLE, not a colour: use StatusChip with role={progress|done|caution|bad|idle|tag}, and let the theme choose the colour.',
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
/*
|
|
24
|
+
* A DOTTED PALETTE PATH IN A `color` PROP IS DEAD TEXT.
|
|
25
|
+
*
|
|
26
|
+
* `<Typography color="text.secondary">` does not render secondary text. MUI
|
|
27
|
+
* reads palette NAMES from that prop ("primary", "textSecondary") and ignores a
|
|
28
|
+
* dotted path, and an unread prop is FORWARDED TO THE DOM rather than rejected
|
|
29
|
+
* — so nothing throws, no test fails, and the line quietly renders in
|
|
30
|
+
* text.primary. Write it as `sx={{ color: 'text.secondary' }}`, which goes
|
|
31
|
+
* through the sx palette resolver and actually applies.
|
|
32
|
+
*
|
|
33
|
+
* It is an easy mistake to repeat hundreds of times before anyone measures one
|
|
34
|
+
* in a browser: every "muted" caption written this way renders as body text.
|
|
35
|
+
* It looks plausible, which is why it survives — grey-ish next to a black
|
|
36
|
+
* heading reads as grey.
|
|
37
|
+
*
|
|
38
|
+
* Matched as a Literal ANYWHERE inside the attribute, so the conditional form
|
|
39
|
+
* (`color={value ? 'text.primary' : 'text.secondary'}`) is caught too. The sx
|
|
40
|
+
* system components are exempt: Box, Stack and Grid DO resolve a dotted path
|
|
41
|
+
* through the same system props, and passing one to them is correct.
|
|
42
|
+
*
|
|
43
|
+
* In `sx`, remember, the path must be complete: `color: 'error'` resolves to
|
|
44
|
+
* the palette OBJECT, where the prop accepted the bare name. It is 'error.main'.
|
|
45
|
+
*/
|
|
46
|
+
export const colorPathRule = {
|
|
47
|
+
selector: "JSXOpeningElement:not([name.name='Box']):not([name.name='Stack']):not([name.name='Grid']):not([name.name='Container']) > JSXAttribute[name.name='color'] Literal[value=/^[a-z][A-Za-z]*\\.[A-Za-z]+$/]",
|
|
48
|
+
message: "A dotted palette path in a `color` prop is ignored and rendered as text.primary. Use sx={{ color: '…' }} instead (and a COMPLETE path — 'error.main', not 'error').",
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* The flat-config block an app spreads into its eslint config:
|
|
53
|
+
*
|
|
54
|
+
* import { chipRules } from 'mui-atelier/eslint';
|
|
55
|
+
* export default defineConfig([...nextVitals, chipRules]);
|
|
56
|
+
*
|
|
57
|
+
* Both selectors live in ONE `no-restricted-syntax` entry on purpose: flat
|
|
58
|
+
* config REPLACES a rule's options rather than merging them, so a second block
|
|
59
|
+
* naming the same rule would silently switch the first one off. An app that has
|
|
60
|
+
* its own `no-restricted-syntax` selectors adds them to this same entry.
|
|
61
|
+
*/
|
|
62
|
+
export const chipRules = {
|
|
63
|
+
files: ['**/*.{js,jsx}'],
|
|
64
|
+
rules: { 'no-restricted-syntax': ['error', chipColorRule, colorPathRule] },
|
|
65
|
+
};
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { useEffect } from "react";
|
|
3
|
+
let appName = typeof document === "undefined" ? "" : document.title;
|
|
4
|
+
function baseName() {
|
|
5
|
+
if (!appName) appName = document.title;
|
|
6
|
+
return appName;
|
|
7
|
+
}
|
|
8
|
+
function useDocumentTitle(...parts) {
|
|
9
|
+
const key = parts.join("\0");
|
|
10
|
+
useEffect(() => {
|
|
11
|
+
if (typeof document === "undefined") return;
|
|
12
|
+
if (!parts[0]) return;
|
|
13
|
+
const suffix = baseName();
|
|
14
|
+
document.title = [...parts.filter(Boolean), suffix].filter(Boolean).join(" \xB7 ");
|
|
15
|
+
}, [key]);
|
|
16
|
+
}
|
|
17
|
+
export {
|
|
18
|
+
useDocumentTitle as default
|
|
19
|
+
};
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { useLayoutEffect, useRef, useState } from "react";
|
|
3
|
+
function useOverflowTabs(count, activeIndex) {
|
|
4
|
+
const containerRef = useRef(null);
|
|
5
|
+
const measureRef = useRef(null);
|
|
6
|
+
const [visibleCount, setVisibleCount] = useState(count);
|
|
7
|
+
useLayoutEffect(() => {
|
|
8
|
+
const container = containerRef.current;
|
|
9
|
+
const row = measureRef.current;
|
|
10
|
+
if (!container || !row) return void 0;
|
|
11
|
+
const fit = () => {
|
|
12
|
+
const available = container.clientWidth;
|
|
13
|
+
const children = Array.from(row.children);
|
|
14
|
+
const more = children[children.length - 1];
|
|
15
|
+
const tabs = children.slice(0, count);
|
|
16
|
+
if (available === 0 || tabs.length === 0) {
|
|
17
|
+
setVisibleCount(count);
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
const gap = parseFloat(getComputedStyle(row).columnGap || getComputedStyle(row).gap) || 0;
|
|
21
|
+
const widths = tabs.map((el) => el.offsetWidth);
|
|
22
|
+
const total = widths.reduce((sum, w) => sum + w, 0) + gap * (tabs.length - 1);
|
|
23
|
+
const chrome = row.offsetWidth - row.clientWidth;
|
|
24
|
+
if (total + chrome <= available) {
|
|
25
|
+
setVisibleCount(count);
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
const moreWidth = (more?.offsetWidth ?? 0) + gap;
|
|
29
|
+
const active = activeIndex >= 0 && activeIndex < count ? activeIndex : -1;
|
|
30
|
+
let used = moreWidth + chrome + (active >= 0 ? widths[active] + gap : 0);
|
|
31
|
+
let n = 0;
|
|
32
|
+
for (let i = 0; i < count; i += 1) {
|
|
33
|
+
if (i === active) {
|
|
34
|
+
n = i + 1;
|
|
35
|
+
continue;
|
|
36
|
+
}
|
|
37
|
+
const next = used + widths[i] + (i > 0 ? gap : 0);
|
|
38
|
+
if (next > available) break;
|
|
39
|
+
used = next;
|
|
40
|
+
n = i + 1;
|
|
41
|
+
}
|
|
42
|
+
setVisibleCount(Math.max(1, n));
|
|
43
|
+
};
|
|
44
|
+
fit();
|
|
45
|
+
const observer = new ResizeObserver(fit);
|
|
46
|
+
observer.observe(container);
|
|
47
|
+
return () => observer.disconnect();
|
|
48
|
+
}, [count, activeIndex]);
|
|
49
|
+
return { containerRef, measureRef, visibleCount: Math.min(visibleCount, count) };
|
|
50
|
+
}
|
|
51
|
+
export {
|
|
52
|
+
useOverflowTabs as default
|
|
53
|
+
};
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { useEffect, useRef } from "react";
|
|
3
|
+
function useUnsavedChanges(dirty, confirmDiscard, router) {
|
|
4
|
+
const state = useRef({ dirty, confirmDiscard, router });
|
|
5
|
+
useEffect(() => {
|
|
6
|
+
state.current = { dirty, confirmDiscard, router };
|
|
7
|
+
});
|
|
8
|
+
const confirmLeave = () => dirty ? confirmDiscard() : Promise.resolve(true);
|
|
9
|
+
useEffect(() => {
|
|
10
|
+
const handler = (e) => {
|
|
11
|
+
if (!state.current.dirty) return;
|
|
12
|
+
e.preventDefault();
|
|
13
|
+
e.returnValue = "";
|
|
14
|
+
};
|
|
15
|
+
window.addEventListener("beforeunload", handler);
|
|
16
|
+
return () => window.removeEventListener("beforeunload", handler);
|
|
17
|
+
}, []);
|
|
18
|
+
useEffect(() => {
|
|
19
|
+
const onClick = (e) => {
|
|
20
|
+
if (!state.current.dirty || !state.current.router) return;
|
|
21
|
+
if (e.defaultPrevented || e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
|
|
22
|
+
const a = e.target.closest?.("a[href]");
|
|
23
|
+
if (!a || a.target === "_blank" || a.hasAttribute("download")) return;
|
|
24
|
+
if (a.closest(".ProseMirror") || a.closest('[contenteditable="true"]')) return;
|
|
25
|
+
let url;
|
|
26
|
+
try {
|
|
27
|
+
url = new URL(a.href, window.location.href);
|
|
28
|
+
} catch {
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
if (url.origin !== window.location.origin) return;
|
|
32
|
+
if (url.pathname === window.location.pathname && url.search === window.location.search) return;
|
|
33
|
+
e.preventDefault();
|
|
34
|
+
e.stopPropagation();
|
|
35
|
+
state.current.confirmDiscard().then((ok) => {
|
|
36
|
+
if (ok) state.current.router.push(`${url.pathname}${url.search}${url.hash}`);
|
|
37
|
+
});
|
|
38
|
+
};
|
|
39
|
+
document.addEventListener("click", onClick, true);
|
|
40
|
+
return () => document.removeEventListener("click", onClick, true);
|
|
41
|
+
}, []);
|
|
42
|
+
return confirmLeave;
|
|
43
|
+
}
|
|
44
|
+
export {
|
|
45
|
+
useUnsavedChanges as default
|
|
46
|
+
};
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import en from "./messages/en.json";
|
|
2
|
+
import { mergeMessages } from "./mergeMessages.js";
|
|
3
|
+
const LOCALES = ["en", "es", "zh"];
|
|
4
|
+
const LOADERS = {
|
|
5
|
+
es: () => import("./messages/es.json"),
|
|
6
|
+
zh: () => import("./messages/zh.json")
|
|
7
|
+
};
|
|
8
|
+
async function loadUikitMessages(locale) {
|
|
9
|
+
const over = LOADERS[locale] ? (await LOADERS[locale]()).default : null;
|
|
10
|
+
return mergeMessages(en, over);
|
|
11
|
+
}
|
|
12
|
+
export {
|
|
13
|
+
LOCALES,
|
|
14
|
+
loadUikitMessages
|
|
15
|
+
};
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
function mergeMessages(base, over) {
|
|
2
|
+
if (!over) return base;
|
|
3
|
+
const out = { ...base };
|
|
4
|
+
for (const key of Object.keys(over)) {
|
|
5
|
+
const b = base ? base[key] : void 0;
|
|
6
|
+
const o = over[key];
|
|
7
|
+
out[key] = b && o && typeof b === "object" && typeof o === "object" && !Array.isArray(b) && !Array.isArray(o) ? mergeMessages(b, o) : o;
|
|
8
|
+
}
|
|
9
|
+
return out;
|
|
10
|
+
}
|
|
11
|
+
export {
|
|
12
|
+
mergeMessages
|
|
13
|
+
};
|