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.
Files changed (77) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/LICENSE +21 -0
  3. package/NOTICE +32 -0
  4. package/README.md +282 -0
  5. package/dist/components/AddressFields.js +83 -0
  6. package/dist/components/AuthCard.js +94 -0
  7. package/dist/components/AuthSplash.js +88 -0
  8. package/dist/components/BrandHeader.js +81 -0
  9. package/dist/components/BrandLink.js +22 -0
  10. package/dist/components/BrandMark.js +69 -0
  11. package/dist/components/BrandProvider.js +14 -0
  12. package/dist/components/Breadcrumbs.js +62 -0
  13. package/dist/components/CardHead.js +46 -0
  14. package/dist/components/ConfirmProvider.js +77 -0
  15. package/dist/components/DashboardLayout.js +645 -0
  16. package/dist/components/DataTable.js +546 -0
  17. package/dist/components/DetailRows.js +42 -0
  18. package/dist/components/EmptyNote.js +28 -0
  19. package/dist/components/Eyebrow.js +21 -0
  20. package/dist/components/HeadingHelp.js +38 -0
  21. package/dist/components/HeadingLevel.js +18 -0
  22. package/dist/components/IconTile.js +42 -0
  23. package/dist/components/ImagePreviewDialog.js +86 -0
  24. package/dist/components/LabeledField.js +156 -0
  25. package/dist/components/LanguageSwitcher.js +43 -0
  26. package/dist/components/ListRow.js +59 -0
  27. package/dist/components/ModeToggle.js +35 -0
  28. package/dist/components/PageHeader.js +65 -0
  29. package/dist/components/PasswordField.js +43 -0
  30. package/dist/components/PublicLayout.js +27 -0
  31. package/dist/components/PublicNav.js +17 -0
  32. package/dist/components/RouteMessage.js +31 -0
  33. package/dist/components/RowList.js +10 -0
  34. package/dist/components/SafeHtml.js +57 -0
  35. package/dist/components/SaveState.js +13 -0
  36. package/dist/components/SectionPanel.js +48 -0
  37. package/dist/components/SectionTabs.js +139 -0
  38. package/dist/components/SiteNav.js +130 -0
  39. package/dist/components/Skeletons.js +63 -0
  40. package/dist/components/Sortable.js +81 -0
  41. package/dist/components/StatTile.js +66 -0
  42. package/dist/components/StatusChip.js +17 -0
  43. package/dist/components/StatusDot.js +42 -0
  44. package/dist/components/SubCard.js +30 -0
  45. package/dist/components/TabbedCard.js +20 -0
  46. package/dist/components/TableFilters.js +247 -0
  47. package/dist/components/ToastProvider.js +65 -0
  48. package/dist/eslint/chipRules.mjs +65 -0
  49. package/dist/hooks/useDocumentTitle.js +19 -0
  50. package/dist/hooks/useOverflowTabs.js +53 -0
  51. package/dist/hooks/useUnsavedChanges.js +46 -0
  52. package/dist/i18n/loadMessages.js +15 -0
  53. package/dist/i18n/mergeMessages.js +13 -0
  54. package/dist/i18n/messages/en.json +103 -0
  55. package/dist/i18n/messages/es.json +103 -0
  56. package/dist/i18n/messages/zh.json +103 -0
  57. package/dist/lib/authCard.js +4 -0
  58. package/dist/lib/chipTone.js +46 -0
  59. package/dist/lib/countryRegionData.js +29 -0
  60. package/dist/lib/countryRegionData.json +1 -0
  61. package/dist/lib/navLock.js +22 -0
  62. package/dist/lib/navTabSx.js +78 -0
  63. package/dist/lib/orgTime.js +243 -0
  64. package/dist/lib/pageHtml.js +139 -0
  65. package/dist/lib/safeNext.js +13 -0
  66. package/dist/lib/sanitizeHtml.js +36 -0
  67. package/dist/lib/slugify.js +7 -0
  68. package/dist/lib/tableFilter.js +147 -0
  69. package/dist/lib/tableStyle.js +87 -0
  70. package/dist/test/jsdomShims.js +53 -0
  71. package/dist/test/themeContract.js +178 -0
  72. package/dist/theme/OrgThemeRegistry.js +71 -0
  73. package/dist/theme/ThemeRegistry.js +17 -0
  74. package/dist/theme/berryNavRow.js +21 -0
  75. package/dist/theme/createSiteTheme.js +974 -0
  76. package/dist/theme/themes.js +438 -0
  77. 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
+ };