@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.
Files changed (54) hide show
  1. package/CHANGELOG.md +7 -0
  2. package/DESIGN.md +9 -1
  3. package/README.md +2 -0
  4. package/WORK_ORDERS.md +8 -0
  5. package/dist/auth/UserMenu.js +23 -1
  6. package/dist/components/EmptyState.js +17 -0
  7. package/dist/components/UserListComponent.js +2 -1
  8. package/dist/i18n/authTranslations.js +196 -0
  9. package/dist/i18n/uiCoreTranslations.js +19 -0
  10. package/dist/index.js +2 -1
  11. package/dist/messaging/Composer.js +4 -2
  12. package/dist/messaging/MessageBubble.js +2 -1
  13. package/dist/messaging/ReadTicks.js +5 -3
  14. package/dist/messaging/linkifyText.js +39 -0
  15. package/dist/pages/PasswordInvitePage.js +23 -8
  16. package/dist/pages/SignUpPage.js +4 -4
  17. package/dist/theme/createAppTheme.js +62 -9
  18. package/dist/theme/kitSxRegistry.js +5 -1
  19. package/dist/theme/tokens.js +1 -1
  20. package/package.json +1 -1
  21. package/src/auth/UserMenu.jsx +26 -1
  22. package/src/components/EmptyState.jsx +38 -0
  23. package/src/components/UserListComponent.jsx +2 -0
  24. package/src/i18n/authTranslations.ts +197 -0
  25. package/src/i18n/uiCoreTranslations.js +10 -0
  26. package/src/index.js +2 -1
  27. package/src/messaging/Composer.jsx +7 -3
  28. package/src/messaging/MessageBubble.jsx +2 -1
  29. package/src/messaging/ReadTicks.jsx +4 -3
  30. package/src/messaging/linkifyText.jsx +37 -0
  31. package/src/pages/PasswordInvitePage.jsx +36 -7
  32. package/src/pages/SignUpPage.jsx +79 -68
  33. package/src/theme/createAppTheme.js +68 -4
  34. package/src/theme/kitSxRegistry.js +9 -1
  35. package/src/theme/tokens.js +1 -1
  36. package/tests/EmptyState.test.jsx +62 -0
  37. package/tests/PasswordInvitePage.test.jsx +135 -0
  38. package/tests/SignUpPage.test.jsx +15 -2
  39. package/tests/UserListComponent.test.jsx +85 -0
  40. package/tests/UserMenu.test.jsx +40 -4
  41. package/tests/createAppThemePageContrast.test.js +108 -0
  42. package/tests/i18nGermanVariant.test.js +65 -0
  43. package/tests/messagingComposer.test.jsx +25 -0
  44. package/tests/messagingLinkify.test.jsx +51 -0
  45. package/tests/messagingReadTicksTooltip.test.jsx +36 -0
  46. package/tests/userScreenTranslations.test.jsx +165 -0
  47. package/work-orders/UCM-AUTH-9.md +207 -0
  48. package/work-orders/UCM-I18N-4.md +226 -0
  49. package/work-orders/UCM-MSG-20.md +192 -0
  50. package/work-orders/UCM-PRIM-2.md +114 -0
  51. package/work-orders/UCM-SHELL-7.md +134 -0
  52. package/work-orders/UCM-THEME-13.md +69 -0
  53. package/work-orders/UCM-THEME-14.md +142 -0
  54. 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
- setErrorKey(null);
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 (_a) {
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
- navigate(target);
92
+ setContinueTarget(target);
91
93
  }
92
94
  catch (err) {
93
- setErrorKey(err.code || 'Auth.RESET_PASSWORD_FAILED');
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
  }
@@ -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 || signupModes.length === 0 || (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') })] }) })] }));
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 computedPalette = {
162
- controlBorder: Object.assign(Object.assign({}, finalPalette.controlBorder), { focus: deriveFocusColour(finalPalette.primary.main, [
163
- '#FFFFFF',
164
- finalPalette.background.default,
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 = ((_b = appConfig.typography) === null || _b === void 0 ? void 0 : _b.fontFamily) || BASELINE_STATIC.typography.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),
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@micha.bigler/ui-core-micha",
3
- "version": "3.6.0",
3
+ "version": "3.8.0",
4
4
  "main": "dist/index.js",
5
5
  "module": "dist/index.js",
6
6
  "sideEffects": [
@@ -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 src={avatarSrc} sx={{ width: 32, height: 32, fontSize: '0.8rem' }}>
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>