@micha.bigler/ui-core-micha 3.6.0 → 3.8.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 +7 -0
- package/DESIGN.md +9 -1
- package/README.md +2 -0
- package/WORK_ORDERS.md +8 -0
- package/dist/auth/UserMenu.js +23 -1
- package/dist/components/EmptyState.js +17 -0
- package/dist/components/UserListComponent.js +2 -1
- package/dist/i18n/authTranslations.js +196 -0
- package/dist/i18n/uiCoreTranslations.js +19 -0
- package/dist/index.js +2 -1
- package/dist/messaging/Composer.js +4 -2
- package/dist/messaging/MessageBubble.js +2 -1
- package/dist/messaging/ReadTicks.js +5 -3
- package/dist/messaging/linkifyText.js +39 -0
- package/dist/pages/PasswordInvitePage.js +23 -8
- package/dist/pages/SignUpPage.js +4 -4
- package/dist/theme/createAppTheme.js +62 -9
- package/dist/theme/kitSxRegistry.js +5 -1
- package/dist/theme/tokens.js +1 -1
- package/package.json +1 -1
- package/src/auth/UserMenu.jsx +26 -1
- package/src/components/EmptyState.jsx +38 -0
- package/src/components/UserListComponent.jsx +2 -0
- package/src/i18n/authTranslations.ts +197 -0
- package/src/i18n/uiCoreTranslations.js +10 -0
- package/src/index.js +2 -1
- package/src/messaging/Composer.jsx +7 -3
- package/src/messaging/MessageBubble.jsx +2 -1
- package/src/messaging/ReadTicks.jsx +4 -3
- package/src/messaging/linkifyText.jsx +37 -0
- package/src/pages/PasswordInvitePage.jsx +36 -7
- package/src/pages/SignUpPage.jsx +79 -68
- package/src/theme/createAppTheme.js +68 -4
- package/src/theme/kitSxRegistry.js +9 -1
- package/src/theme/tokens.js +1 -1
- package/tests/EmptyState.test.jsx +62 -0
- package/tests/PasswordInvitePage.test.jsx +135 -0
- package/tests/SignUpPage.test.jsx +15 -2
- package/tests/UserListComponent.test.jsx +85 -0
- package/tests/UserMenu.test.jsx +40 -4
- package/tests/createAppThemePageContrast.test.js +108 -0
- package/tests/i18nGermanVariant.test.js +65 -0
- package/tests/messagingComposer.test.jsx +25 -0
- package/tests/messagingLinkify.test.jsx +51 -0
- package/tests/messagingReadTicksTooltip.test.jsx +36 -0
- package/tests/userScreenTranslations.test.jsx +165 -0
- package/work-orders/UCM-AUTH-9.md +207 -0
- package/work-orders/UCM-I18N-4.md +226 -0
- package/work-orders/UCM-MSG-20.md +192 -0
- package/work-orders/UCM-PRIM-2.md +114 -0
- package/work-orders/UCM-SHELL-7.md +134 -0
- package/work-orders/UCM-THEME-13.md +69 -0
- package/work-orders/UCM-THEME-14.md +142 -0
- package/work-orders/UCM-THEME-15.md +356 -0
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import React from 'react';
|
|
3
|
+
import { Link } from '@mui/material';
|
|
4
|
+
const HTTP_URL_PATTERN = /(https?:\/\/[^\s<>"']+)/g;
|
|
5
|
+
function splitTrailingPunctuation(rawUrl) {
|
|
6
|
+
let url = rawUrl;
|
|
7
|
+
let trailing = '';
|
|
8
|
+
while (/[.,!?]$/.test(url)) {
|
|
9
|
+
trailing = `${url.slice(-1)}${trailing}`;
|
|
10
|
+
url = url.slice(0, -1);
|
|
11
|
+
}
|
|
12
|
+
const openingParentheses = (url.match(/\(/g) || []).length;
|
|
13
|
+
const closingParentheses = (url.match(/\)/g) || []).length;
|
|
14
|
+
if (closingParentheses > openingParentheses) {
|
|
15
|
+
trailing = `)${trailing}`;
|
|
16
|
+
url = url.slice(0, -1);
|
|
17
|
+
}
|
|
18
|
+
return { url, trailing };
|
|
19
|
+
}
|
|
20
|
+
export function linkifyText(text) {
|
|
21
|
+
var _a;
|
|
22
|
+
const value = String(text !== null && text !== void 0 ? text : '');
|
|
23
|
+
const parts = [];
|
|
24
|
+
let lastIndex = 0;
|
|
25
|
+
for (const match of value.matchAll(HTTP_URL_PATTERN)) {
|
|
26
|
+
const rawUrl = match[0];
|
|
27
|
+
const start = (_a = match.index) !== null && _a !== void 0 ? _a : 0;
|
|
28
|
+
if (start > lastIndex)
|
|
29
|
+
parts.push(value.slice(lastIndex, start));
|
|
30
|
+
const { url, trailing } = splitTrailingPunctuation(rawUrl);
|
|
31
|
+
parts.push(_jsx(Link, { href: url, target: "_blank", rel: "noopener noreferrer", children: url }, `url-${start}`));
|
|
32
|
+
if (trailing)
|
|
33
|
+
parts.push(trailing);
|
|
34
|
+
lastIndex = start + rawUrl.length;
|
|
35
|
+
}
|
|
36
|
+
if (lastIndex < value.length)
|
|
37
|
+
parts.push(value.slice(lastIndex));
|
|
38
|
+
return parts.length ? parts : [value];
|
|
39
|
+
}
|
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
2
|
// src/auth/pages/PasswordInvitePage.jsx
|
|
3
3
|
import React, { useState, useEffect } from 'react';
|
|
4
4
|
import { useNavigate, useParams, useLocation } from 'react-router-dom';
|
|
5
5
|
import { Helmet } from 'react-helmet';
|
|
6
|
-
import { Typography } from '@mui/material';
|
|
6
|
+
import { Button, Typography } from '@mui/material';
|
|
7
7
|
import { useTranslation } from 'react-i18next';
|
|
8
8
|
import { NarrowPage } from '../layout/PageLayout';
|
|
9
9
|
import { PasswordSetForm } from '../components/PasswordSetForm';
|
|
@@ -17,7 +17,9 @@ export function PasswordInvitePage() {
|
|
|
17
17
|
const nextPath = searchParams.get('next');
|
|
18
18
|
const [submitting, setSubmitting] = useState(false);
|
|
19
19
|
const [errorKey, setErrorKey] = useState(null);
|
|
20
|
+
const [submitErrorKey, setSubmitErrorKey] = useState(null);
|
|
20
21
|
const [successKey, setSuccessKey] = useState(null);
|
|
22
|
+
const [continueTarget, setContinueTarget] = useState(null);
|
|
21
23
|
const [checked, setChecked] = useState(false);
|
|
22
24
|
// Unterscheidung Invite-Link vs klassischer Reset
|
|
23
25
|
const isInvite = location.pathname.startsWith('/invite/');
|
|
@@ -47,12 +49,12 @@ export function PasswordInvitePage() {
|
|
|
47
49
|
check();
|
|
48
50
|
}, [uid, token]);
|
|
49
51
|
const handleSubmit = async (newPassword) => {
|
|
52
|
+
var _a, _b, _c;
|
|
50
53
|
if (!uid || !token) {
|
|
51
|
-
setErrorKey('Auth.RESET_LINK_INVALID');
|
|
52
54
|
return;
|
|
53
55
|
}
|
|
54
56
|
setSubmitting(true);
|
|
55
|
-
|
|
57
|
+
setSubmitErrorKey(null);
|
|
56
58
|
setSuccessKey(null);
|
|
57
59
|
try {
|
|
58
60
|
await setNewPassword(uid, token, newPassword);
|
|
@@ -80,17 +82,30 @@ export function PasswordInvitePage() {
|
|
|
80
82
|
safeNextPath = parsed.pathname + parsed.search + parsed.hash;
|
|
81
83
|
}
|
|
82
84
|
}
|
|
83
|
-
catch (
|
|
85
|
+
catch (_d) {
|
|
84
86
|
// fall through to default redirect
|
|
85
87
|
}
|
|
86
88
|
}
|
|
87
89
|
const target = safeNextPath
|
|
88
90
|
? `/login?next=${encodeURIComponent(safeNextPath)}`
|
|
89
91
|
: '/login';
|
|
90
|
-
|
|
92
|
+
setContinueTarget(target);
|
|
91
93
|
}
|
|
92
94
|
catch (err) {
|
|
93
|
-
|
|
95
|
+
// Told apart by CODE, not by the presence of `messages` (a submission
|
|
96
|
+
// can fail with a link problem too, e.g. the token expires in the gap
|
|
97
|
+
// between the page's initial verifyResetToken check and this submit) —
|
|
98
|
+
// only a password rejection keeps the form; a link problem must still
|
|
99
|
+
// end in the same invalid-link state the initial check produces.
|
|
100
|
+
if (err.code === 'Auth.RESET_LINK_INVALID') {
|
|
101
|
+
setErrorKey(err.code);
|
|
102
|
+
}
|
|
103
|
+
else {
|
|
104
|
+
const responseMessages = ((_a = err.raw) === null || _a === void 0 ? void 0 : _a.messages) || ((_c = (_b = err.response) === null || _b === void 0 ? void 0 : _b.data) === null || _c === void 0 ? void 0 : _c.messages);
|
|
105
|
+
setSubmitErrorKey(Array.isArray(responseMessages) && responseMessages.length > 0
|
|
106
|
+
? responseMessages.join(' ')
|
|
107
|
+
: t(err.code || 'Auth.RESET_PASSWORD_FAILED'));
|
|
108
|
+
}
|
|
94
109
|
}
|
|
95
110
|
finally {
|
|
96
111
|
setSubmitting(false);
|
|
@@ -100,5 +115,5 @@ export function PasswordInvitePage() {
|
|
|
100
115
|
if (!checked && !errorKey) {
|
|
101
116
|
return (_jsx(NarrowPage, { title: t('Auth.PAGE_CHECKING_LINK_TITLE'), children: _jsx(Typography, { children: t('Auth.PAGE_CHECKING_LINK_TEXT') }) }));
|
|
102
117
|
}
|
|
103
|
-
return (_jsxs(NarrowPage, { title: t(pageTitleKey), subtitle: t(pageSubtitleKey), children: [_jsx(Helmet, { children: _jsxs("title", { children: [t('App.NAME'), " \u2013 ", t(pageTitleKey)] }) }), errorKey && (_jsx(Typography, { color: "error", gutterBottom: true, children: t(errorKey) })), successKey && (_jsx(Typography, { color: "primary", gutterBottom: true, children: t(successKey) })), !successKey && !errorKey && (_jsx(PasswordSetForm, { onSubmit: handleSubmit, submitting: submitting }))] }));
|
|
118
|
+
return (_jsxs(NarrowPage, { title: t(pageTitleKey), subtitle: t(pageSubtitleKey), children: [_jsx(Helmet, { children: _jsxs("title", { children: [t('App.NAME'), " \u2013 ", t(pageTitleKey)] }) }), errorKey && (_jsx(Typography, { color: "error", gutterBottom: true, children: t(errorKey) })), successKey && (_jsxs(_Fragment, { children: [_jsx(Typography, { color: "primary", gutterBottom: true, children: t(successKey) }), _jsx(Button, { variant: "contained", onClick: () => navigate(continueTarget), children: t('Auth.SIGNUP_GO_TO_LOGIN') })] })), submitErrorKey && !successKey && (_jsx(Typography, { color: "error", gutterBottom: true, children: submitErrorKey })), !successKey && !errorKey && (_jsx(PasswordSetForm, { onSubmit: handleSubmit, submitting: submitting }))] }));
|
|
104
119
|
}
|
package/dist/pages/SignUpPage.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
|
|
1
|
+
import { jsxs as _jsxs, jsx as _jsx, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
2
|
import React, { useContext, useEffect, useMemo, useRef, useState, } from 'react';
|
|
3
3
|
import { useNavigate, useLocation } from 'react-router-dom';
|
|
4
4
|
import { Alert, Box, Button, Stack, TextField, Typography, } from '@mui/material';
|
|
@@ -117,7 +117,7 @@ export function SignUpPage() {
|
|
|
117
117
|
const handleGoToLogin = () => {
|
|
118
118
|
navigate('/login');
|
|
119
119
|
};
|
|
120
|
-
return (_jsxs(NarrowPage, { title: t('Auth.LOGIN_SIGNUP_BUTTON'), subtitle: pageSubtitle, children: [_jsx(Helmet, { children: _jsxs("title", { children: [t('App.NAME'), " \u2013 ", t('Auth.LOGIN_SIGNUP_BUTTON')] }) }), successKey && (_jsx(Alert, { severity: "success", sx: SIGN_UP_PAGE_ALERT_SX, children: t(successKey, { email }) })), errorKey && (_jsx(Alert, { severity: "error", sx: SIGN_UP_PAGE_ALERT_SX, children: t(errorKey, t('Auth.INVITE_FAILED', 'Could not complete signup.')) })), signupModes.length > 1 && (_jsx(Stack, { spacing: 1, sx: { mb: 2 }, children: _jsx(Stack, { spacing: 1, children: signupModes.map((entry) => (_jsx(Button, { variant: mode === entry ? 'contained' : 'outlined', onClick: () => setMode(entry), disabled: submitting, fullWidth: true, children: t(MODE_LABELS[entry] || entry, entry) }, entry))) }) })), _jsxs(Box, { component: "form", onSubmit: handleSubmit, sx: { display: 'flex', flexDirection: 'column', gap: 2 }, children: [_jsx(TextField, { label: t('Auth.EMAIL_LABEL'), type: "email", required: true, fullWidth: true, value: email, onChange: (e) => setEmail(e.target.value), disabled: submitting }), mode === 'self_signup_access_code' && (_jsx(TextField, { label: t('Auth.ACCESS_CODE_LABEL'), type: "text", required: true, fullWidth: true, value: accessCode, onChange: (e) => setAccessCode(e.target.value), disabled: submitting })), turnstileRequired && (_jsx(TurnstileWidget, { ref: turnstileWidgetRef, siteKey: authMethods.turnstile_site_key, onToken: setTurnstileToken })), _jsx(Button, { type: "submit", variant: "contained", disabled: submitting ||
|
|
121
|
-
|
|
122
|
-
|
|
120
|
+
return (_jsxs(NarrowPage, { title: t('Auth.LOGIN_SIGNUP_BUTTON'), subtitle: pageSubtitle, children: [_jsx(Helmet, { children: _jsxs("title", { children: [t('App.NAME'), " \u2013 ", t('Auth.LOGIN_SIGNUP_BUTTON')] }) }), signupModes.length === 0 ? (_jsx(Alert, { severity: "info", sx: SIGN_UP_PAGE_ALERT_SX, children: t('Auth.SELF_SIGNUP_DISABLED', 'Self sign-up is currently disabled.') })) : (_jsxs(_Fragment, { children: [successKey && (_jsx(Alert, { severity: "success", sx: SIGN_UP_PAGE_ALERT_SX, children: t(successKey, { email }) })), errorKey && (_jsx(Alert, { severity: "error", sx: SIGN_UP_PAGE_ALERT_SX, children: t(errorKey, t('Auth.INVITE_FAILED', 'Could not complete signup.')) })), signupModes.length > 1 && (_jsx(Stack, { spacing: 1, sx: { mb: 2 }, children: _jsx(Stack, { spacing: 1, children: signupModes.map((entry) => (_jsx(Button, { variant: mode === entry ? 'contained' : 'outlined', onClick: () => setMode(entry), disabled: submitting, fullWidth: true, children: t(MODE_LABELS[entry] || entry, entry) }, entry))) }) })), _jsxs(Box, { component: "form", onSubmit: handleSubmit, sx: { display: 'flex', flexDirection: 'column', gap: 2 }, children: [_jsx(TextField, { label: t('Auth.EMAIL_LABEL'), type: "email", required: true, fullWidth: true, value: email, onChange: (e) => setEmail(e.target.value), disabled: submitting }), mode === 'self_signup_access_code' && (_jsx(TextField, { label: t('Auth.ACCESS_CODE_LABEL'), type: "text", required: true, fullWidth: true, value: accessCode, onChange: (e) => setAccessCode(e.target.value), disabled: submitting })), turnstileRequired && (_jsx(TurnstileWidget, { ref: turnstileWidgetRef, siteKey: authMethods.turnstile_site_key, onToken: setTurnstileToken })), _jsx(Button, { type: "submit", variant: "contained", disabled: submitting || (turnstileRequired && !turnstileToken), children: submitting
|
|
121
|
+
? t('Auth.SIGNUP_SUBMITTING')
|
|
122
|
+
: t('Auth.SIGNUP_SUBMIT') })] })] })), _jsx(Box, { sx: { mt: 3 }, children: _jsxs(Typography, { variant: "body2", children: [t('Auth.SIGNUP_ALREADY_HAVE_ACCOUNT'), ' ', _jsx(Button, { onClick: handleGoToLogin, variant: "text", size: "small", children: t('Auth.SIGNUP_GO_TO_LOGIN') })] }) })] }));
|
|
123
123
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import './fonts';
|
|
2
2
|
import { createTheme, darken, getContrastRatio } from '@mui/material/styles';
|
|
3
|
-
import { BASELINE_INTENTIONAL_DEFAULT_EXEMPTIONS, BASELINE_PALETTE, BASELINE_STATIC, MOTION, } from './tokens';
|
|
3
|
+
import { BASELINE_INTENTIONAL_DEFAULT_EXEMPTIONS, BASELINE_PALETTE, BASELINE_STATIC, MOTION, withMainShades, } from './tokens';
|
|
4
|
+
import { calculateContrastRatio } from './themeCompleteness';
|
|
4
5
|
function findFunction(value, path) {
|
|
5
6
|
if (typeof value === 'function')
|
|
6
7
|
return path;
|
|
@@ -40,6 +41,41 @@ function deriveFocusColour(primary, surfaces) {
|
|
|
40
41
|
}
|
|
41
42
|
return focus;
|
|
42
43
|
}
|
|
44
|
+
function clearsPageContrast(colour, surfaces, threshold) {
|
|
45
|
+
return surfaces.every((surface) => {
|
|
46
|
+
const ratio = calculateContrastRatio(colour, surface);
|
|
47
|
+
return Number.isFinite(ratio) && ratio >= threshold;
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
function deriveDarkenedPageColour(original, surfaces, threshold) {
|
|
51
|
+
if (clearsPageContrast(original, surfaces, threshold))
|
|
52
|
+
return original;
|
|
53
|
+
let colour = original;
|
|
54
|
+
for (let coefficient = 0.05; !clearsPageContrast(colour, surfaces, threshold); coefficient += 0.05) {
|
|
55
|
+
colour = darken(original, Math.min(coefficient, 0.9));
|
|
56
|
+
if (coefficient >= 0.9)
|
|
57
|
+
break;
|
|
58
|
+
}
|
|
59
|
+
return colour;
|
|
60
|
+
}
|
|
61
|
+
const RGBA_PATTERN = /^rgba\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d*\.?\d+)\s*\)$/;
|
|
62
|
+
function deriveAlphaPageColour(original, surfaces, threshold) {
|
|
63
|
+
if (clearsPageContrast(original, surfaces, threshold))
|
|
64
|
+
return original;
|
|
65
|
+
const match = original.match(RGBA_PATTERN);
|
|
66
|
+
if (!match)
|
|
67
|
+
return original;
|
|
68
|
+
const [, red, green, blue, alphaText] = match;
|
|
69
|
+
let alpha = Number(alphaText);
|
|
70
|
+
let colour = original;
|
|
71
|
+
while (alpha < 1) {
|
|
72
|
+
alpha = Math.min(1, Number((alpha + 0.05).toFixed(2)));
|
|
73
|
+
colour = `rgba(${red}, ${green}, ${blue}, ${alpha})`;
|
|
74
|
+
if (clearsPageContrast(colour, surfaces, threshold))
|
|
75
|
+
return colour;
|
|
76
|
+
}
|
|
77
|
+
return colour;
|
|
78
|
+
}
|
|
43
79
|
function createPaletteAwareComponents(palette) {
|
|
44
80
|
const stateTransition = `border-color ${MOTION.duration.fast}ms ${MOTION.easing.state}, color ${MOTION.duration.fast}ms ${MOTION.easing.state}, background-color ${MOTION.duration.fast}ms ${MOTION.easing.state}`;
|
|
45
81
|
const autofill = {
|
|
@@ -151,21 +187,38 @@ function createPaletteAwareComponents(palette) {
|
|
|
151
187
|
* objects are created, so runtime app/site overrides cannot leave stale values.
|
|
152
188
|
*/
|
|
153
189
|
export function createAppTheme(appConfig = {}) {
|
|
154
|
-
var _a, _b;
|
|
190
|
+
var _a, _b, _c, _d, _e, _f;
|
|
155
191
|
if (!((_a = appConfig === null || appConfig === void 0 ? void 0 : appConfig.palette) === null || _a === void 0 ? void 0 : _a.primary)) {
|
|
156
192
|
throw new TypeError('createAppTheme: appConfig.palette.primary is required.');
|
|
157
193
|
}
|
|
158
194
|
validateStyleOverrides(appConfig.components);
|
|
159
195
|
const paletteTheme = createTheme({ palette: appConfig.palette }, { palette: BASELINE_PALETTE }, { palette: appConfig.palette });
|
|
160
196
|
const finalPalette = paletteTheme.palette;
|
|
161
|
-
const
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
197
|
+
const contrastSurfaces = ['#FFFFFF', finalPalette.background.default];
|
|
198
|
+
const derivedStatusEntries = {};
|
|
199
|
+
for (const status of ['success', 'warning', 'error', 'info']) {
|
|
200
|
+
if (((_c = (_b = appConfig.palette) === null || _b === void 0 ? void 0 : _b[status]) === null || _c === void 0 ? void 0 : _c.main) !== undefined)
|
|
201
|
+
continue;
|
|
202
|
+
const current = finalPalette[status];
|
|
203
|
+
const main = deriveDarkenedPageColour(current.main, contrastSurfaces, 4.5);
|
|
204
|
+
if (main !== current.main) {
|
|
205
|
+
derivedStatusEntries[status] = Object.assign(Object.assign({}, current), withMainShades(main, current.contrastText));
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
const derivedControlBorderEntries = {};
|
|
209
|
+
for (const state of ['main', 'hover', 'error']) {
|
|
210
|
+
if (((_e = (_d = appConfig.palette) === null || _d === void 0 ? void 0 : _d.controlBorder) === null || _e === void 0 ? void 0 : _e[state]) !== undefined)
|
|
211
|
+
continue;
|
|
212
|
+
const current = finalPalette.controlBorder[state];
|
|
213
|
+
const derived = state === 'error'
|
|
214
|
+
? deriveDarkenedPageColour(current, contrastSurfaces, 3)
|
|
215
|
+
: deriveAlphaPageColour(current, contrastSurfaces, 3);
|
|
216
|
+
if (derived !== current)
|
|
217
|
+
derivedControlBorderEntries[state] = derived;
|
|
218
|
+
}
|
|
219
|
+
const computedPalette = Object.assign(Object.assign({}, derivedStatusEntries), { controlBorder: Object.assign(Object.assign(Object.assign({}, finalPalette.controlBorder), derivedControlBorderEntries), { focus: deriveFocusColour(finalPalette.primary.main, contrastSurfaces) }) });
|
|
167
220
|
const resolvedPalette = Object.assign(Object.assign({}, finalPalette), computedPalette);
|
|
168
|
-
const fontFamily = ((
|
|
221
|
+
const fontFamily = ((_f = appConfig.typography) === null || _f === void 0 ? void 0 : _f.fontFamily) || BASELINE_STATIC.typography.fontFamily;
|
|
169
222
|
return createTheme(Object.assign(Object.assign({}, BASELINE_STATIC), { typography: Object.assign(Object.assign({}, BASELINE_STATIC.typography), { fontFamily }), fontLoading: Object.assign(Object.assign({}, BASELINE_STATIC.fontLoading), { weights: [...BASELINE_STATIC.fontLoading.weights] }), palette: resolvedPalette, themeCompleteness: {
|
|
170
223
|
baseline: true,
|
|
171
224
|
exemptions: BASELINE_INTENTIONAL_DEFAULT_EXEMPTIONS.map((exemption) => (Object.assign({}, exemption))),
|
|
@@ -20,7 +20,7 @@ import { CHART_FRAME_ALERT_SX, CHART_FRAME_ROOT_SX } from '../components/charts/
|
|
|
20
20
|
import { PAGE_LAYOUT_CONTAINER_SX } from '../layout/PageLayout';
|
|
21
21
|
import { SECTION_NAV_DRAWER_PAPER_SX, SECTION_NAV_GROUP_PAPER_SX, } from '../layout/SectionNav';
|
|
22
22
|
import { ATTACHMENT_LIST_ALERT_SX, ATTACHMENT_LIST_LIGHTBOX_ACTION_SX, } from '../messaging/AttachmentList';
|
|
23
|
-
import { COMPOSER_EMOJI_BUTTON_SX } from '../messaging/Composer';
|
|
23
|
+
import { COMPOSER_EMOJI_BUTTON_SX, COMPOSER_MESSAGE_FIELD_SX, COMPOSER_SEND_BUTTON_SX, } from '../messaging/Composer';
|
|
24
24
|
import { DIRECT_MESSAGE_LAUNCHER_ALERT_SX } from '../messaging/DirectMessageLauncher';
|
|
25
25
|
import { MESSAGE_BUBBLE_ACTION_SX, MESSAGE_BUBBLE_ROOT_SX } from '../messaging/MessageBubble';
|
|
26
26
|
import { POLL_CARD_CHECKBOX_SX, POLL_CARD_FORM_CONTROL_SX, POLL_CARD_ROOT_SX, } from '../messaging/PollCard';
|
|
@@ -51,6 +51,8 @@ const SX_EXPORTS = {
|
|
|
51
51
|
CHART_FRAME_ALERT_SX,
|
|
52
52
|
CHART_FRAME_ROOT_SX,
|
|
53
53
|
COMPOSER_EMOJI_BUTTON_SX,
|
|
54
|
+
COMPOSER_MESSAGE_FIELD_SX,
|
|
55
|
+
COMPOSER_SEND_BUTTON_SX,
|
|
54
56
|
DIRECT_MESSAGE_LAUNCHER_ALERT_SX,
|
|
55
57
|
LOGIN_FORM_DIVIDER_SX,
|
|
56
58
|
LOGIN_PAGE_ALERT_SX,
|
|
@@ -177,6 +179,8 @@ export const KIT_COMPONENT_SX_REGISTRY = [
|
|
|
177
179
|
entry('AttachmentList.lightboxAction', 'MuiIconButton', ATTACHMENT_LIST_LIGHTBOX_ACTION_SX),
|
|
178
180
|
entry('AttachmentList.alert', 'MuiAlert', ATTACHMENT_LIST_ALERT_SX),
|
|
179
181
|
entry('Composer.emojiAction', 'MuiIconButton', COMPOSER_EMOJI_BUTTON_SX),
|
|
182
|
+
entry('Composer.messageField', 'MuiTextField', COMPOSER_MESSAGE_FIELD_SX),
|
|
183
|
+
entry('Composer.sendAction', 'MuiButton', COMPOSER_SEND_BUTTON_SX),
|
|
180
184
|
entry('DirectMessageLauncher.alert', 'MuiAlert', DIRECT_MESSAGE_LAUNCHER_ALERT_SX),
|
|
181
185
|
entry('MessageBubble.root', 'MuiPaper', MESSAGE_BUBBLE_ROOT_SX),
|
|
182
186
|
entry('MessageBubble.action', 'MuiIconButton', MESSAGE_BUBBLE_ACTION_SX),
|
package/dist/theme/tokens.js
CHANGED
|
@@ -7,7 +7,7 @@ import { darken, lighten } from '@mui/material/styles';
|
|
|
7
7
|
// though `text`/`fill`/`bg` are set correctly, so every status channel MUI
|
|
8
8
|
// recognises (success/warning/error/info) must carry its own explicit
|
|
9
9
|
// main/light/dark/contrastText, computed the same way augmentColor would.
|
|
10
|
-
const withMainShades = (main, contrastText) => ({
|
|
10
|
+
export const withMainShades = (main, contrastText) => ({
|
|
11
11
|
main,
|
|
12
12
|
light: lighten(main, 0.2),
|
|
13
13
|
dark: darken(main, 0.3),
|
package/package.json
CHANGED
package/src/auth/UserMenu.jsx
CHANGED
|
@@ -9,9 +9,11 @@ import {
|
|
|
9
9
|
MenuItem,
|
|
10
10
|
Typography,
|
|
11
11
|
} from '@mui/material';
|
|
12
|
+
import { useTheme } from '@mui/material/styles';
|
|
12
13
|
import { useTranslation } from 'react-i18next';
|
|
13
14
|
|
|
14
15
|
import { AuthContext } from './AuthContext';
|
|
16
|
+
import { calculateContrastRatio } from '../theme';
|
|
15
17
|
|
|
16
18
|
function getInitials(user) {
|
|
17
19
|
if (!user) return '';
|
|
@@ -30,8 +32,22 @@ function getDisplayName(user) {
|
|
|
30
32
|
export function UserMenu({ resolveLink, items = [], profileLink = '/account', avatarSrc }) {
|
|
31
33
|
const { t } = useTranslation();
|
|
32
34
|
const { user, logout } = useContext(AuthContext);
|
|
35
|
+
const theme = useTheme();
|
|
33
36
|
const [anchorEl, setAnchorEl] = useState(null);
|
|
34
37
|
const open = Boolean(anchorEl);
|
|
38
|
+
const avatarBackground = theme.palette.primary.main;
|
|
39
|
+
// True white/black (theme.palette.common.*, always present on any MUI theme,
|
|
40
|
+
// never a source-level literal), not background.paper/ink.primary: contrast
|
|
41
|
+
// against true white and true black multiplies to a constant 21 regardless
|
|
42
|
+
// of the background's luminance, so max(contrastWhite, contrastBlack) is
|
|
43
|
+
// mathematically guaranteed to be >= sqrt(21) =~ 4.58 for ANY primary.main a
|
|
44
|
+
// consumer sets -- a near-black token like ink.primary (#212529, luminance
|
|
45
|
+
// ~0.018, not 0) does not carry that guarantee and left a real gap for a
|
|
46
|
+
// medium-luminance primary (caught by ui_reviewer, neither required test
|
|
47
|
+
// exercised the fallback branch that gap lived in).
|
|
48
|
+
const avatarText = calculateContrastRatio(theme.palette.common.white, avatarBackground) >= 4.5
|
|
49
|
+
? theme.palette.common.white
|
|
50
|
+
: theme.palette.common.black;
|
|
35
51
|
|
|
36
52
|
const handleClose = () => setAnchorEl(null);
|
|
37
53
|
|
|
@@ -57,7 +73,16 @@ export function UserMenu({ resolveLink, items = [], profileLink = '/account', av
|
|
|
57
73
|
color="inherit"
|
|
58
74
|
onClick={(event) => setAnchorEl(event.currentTarget)}
|
|
59
75
|
>
|
|
60
|
-
<Avatar
|
|
76
|
+
<Avatar
|
|
77
|
+
src={avatarSrc}
|
|
78
|
+
sx={{
|
|
79
|
+
width: 32,
|
|
80
|
+
height: 32,
|
|
81
|
+
fontSize: '0.8rem',
|
|
82
|
+
bgcolor: avatarBackground,
|
|
83
|
+
color: avatarText,
|
|
84
|
+
}}
|
|
85
|
+
>
|
|
61
86
|
{avatarSrc ? null : getInitials(user)}
|
|
62
87
|
</Avatar>
|
|
63
88
|
</IconButton>
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { Box, Button, Typography } from '@mui/material';
|
|
2
|
+
import { cloneElement } from 'react';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* A shared, compact presentation for an empty section: optional decorative icon,
|
|
6
|
+
* title, supporting description, and an optional outlined action.
|
|
7
|
+
*/
|
|
8
|
+
export default function EmptyState({ icon, title, description, action, sx }) {
|
|
9
|
+
return (
|
|
10
|
+
<Box
|
|
11
|
+
sx={[{
|
|
12
|
+
display: 'flex',
|
|
13
|
+
flexDirection: 'column',
|
|
14
|
+
alignItems: 'center',
|
|
15
|
+
gap: 1,
|
|
16
|
+
p: 2,
|
|
17
|
+
textAlign: 'center',
|
|
18
|
+
}, sx]}
|
|
19
|
+
>
|
|
20
|
+
{icon && (
|
|
21
|
+
<Box sx={{ color: 'text.secondary', display: 'flex' }}>
|
|
22
|
+
{cloneElement(icon, { 'aria-hidden': true, focusable: false })}
|
|
23
|
+
</Box>
|
|
24
|
+
)}
|
|
25
|
+
<Typography component="p">{title}</Typography>
|
|
26
|
+
{description && (
|
|
27
|
+
<Typography component="p" variant="body2" color="text.secondary">
|
|
28
|
+
{description}
|
|
29
|
+
</Typography>
|
|
30
|
+
)}
|
|
31
|
+
{action && (
|
|
32
|
+
<Button type="button" variant="outlined" onClick={action.onClick}>
|
|
33
|
+
{action.label}
|
|
34
|
+
</Button>
|
|
35
|
+
)}
|
|
36
|
+
</Box>
|
|
37
|
+
);
|
|
38
|
+
}
|
|
@@ -118,6 +118,7 @@ export function UserListComponent({
|
|
|
118
118
|
await updateUserRole(userId, newRole);
|
|
119
119
|
}
|
|
120
120
|
await loadUsers();
|
|
121
|
+
alert(t('UserList.ROLE_UPDATE_SUCCESS', 'Role updated.'));
|
|
121
122
|
} catch (err) {
|
|
122
123
|
alert(t(err.code || 'Auth.USER_ROLE_UPDATE_FAILED'));
|
|
123
124
|
}
|
|
@@ -588,6 +589,7 @@ export function UserListComponent({
|
|
|
588
589
|
setPageSize(Number(event.target.value));
|
|
589
590
|
setPage(0);
|
|
590
591
|
}}
|
|
592
|
+
labelRowsPerPage={t('UserList.ROWS_PER_PAGE', 'Rows per page:')}
|
|
591
593
|
rowsPerPageOptions={[10, 25, 50, 100]}
|
|
592
594
|
/>
|
|
593
595
|
</Box>
|