@micha.bigler/ui-core-micha 2.34.0 → 2.36.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 (37) hide show
  1. package/WORK_ORDERS.md +17 -3
  2. package/dev/entries.jsx +79 -0
  3. package/dev/main.jsx +7 -1
  4. package/dist/components/MFAComponent.js +7 -6
  5. package/dist/components/SocialLoginButtons.js +2 -1
  6. package/dist/components/charts/ChartFrame.js +3 -1
  7. package/dist/components/charts/exportChart.js +2 -2
  8. package/dist/i18n/authTranslations.js +24 -0
  9. package/dist/i18n/sectionNavTranslations.js +20 -0
  10. package/dist/index.js +3 -1
  11. package/dist/layout/SectionNav.js +138 -0
  12. package/dist/pages/AccountPage.js +44 -2
  13. package/dist/theme/index.js +1 -1
  14. package/dist/theme/kitSxRegistry.js +8 -1
  15. package/dist/theme/themeCompleteness.js +218 -14
  16. package/package.json +1 -1
  17. package/src/components/MFAComponent.jsx +7 -6
  18. package/src/components/SocialLoginButtons.jsx +2 -1
  19. package/src/components/charts/ChartFrame.jsx +7 -1
  20. package/src/components/charts/exportChart.js +2 -2
  21. package/src/i18n/authTranslations.ts +24 -0
  22. package/src/i18n/sectionNavTranslations.ts +20 -0
  23. package/src/index.js +3 -0
  24. package/src/layout/SectionNav.jsx +299 -0
  25. package/src/pages/AccountPage.jsx +55 -14
  26. package/src/theme/index.js +1 -0
  27. package/src/theme/kitSxRegistry.js +11 -1
  28. package/src/theme/themeCompleteness.js +237 -14
  29. package/tests/AccountPage.test.jsx +193 -0
  30. package/tests/ChartFrame.test.jsx +5 -0
  31. package/tests/SectionNav.test.jsx +274 -0
  32. package/tests/themeCompleteness.test.js +156 -4
  33. package/work-orders/AUTH-4.md +183 -0
  34. package/work-orders/I18N-1.md +254 -0
  35. package/work-orders/SHELL-5.md +345 -0
  36. package/work-orders/THEME-5.md +341 -0
  37. package/work-orders/assets/AUTH-4-user-list-card.html +407 -0
@@ -0,0 +1,299 @@
1
+ import { useId, useState } from 'react';
2
+ import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown';
3
+ import {
4
+ Box,
5
+ ButtonBase,
6
+ Drawer,
7
+ List,
8
+ ListItemButton,
9
+ ListItemText,
10
+ Paper,
11
+ Stack,
12
+ Typography,
13
+ } from '@mui/material';
14
+ import { useTheme } from '@mui/material/styles';
15
+ import { useTranslation } from 'react-i18next';
16
+
17
+ const defaultZIndex = (theme) => theme.zIndex.drawer + 3;
18
+
19
+ export const SECTION_NAV_DRAWER_PAPER_SX = {
20
+ borderTopLeftRadius: 18,
21
+ borderTopRightRadius: 18,
22
+ maxHeight: '78dvh',
23
+ p: 2,
24
+ pb: 'max(16px, env(safe-area-inset-bottom))',
25
+ };
26
+
27
+ export const SECTION_NAV_GROUP_PAPER_SX = {
28
+ overflow: 'hidden',
29
+ };
30
+
31
+ const SECTION_NAV_GRID_SX = {
32
+ display: 'grid',
33
+ gap: 4,
34
+ alignItems: 'start',
35
+ };
36
+
37
+ const SECTION_NAV_SIDEBAR_SX = {
38
+ position: 'sticky',
39
+ top: 'var(--SectionNav-headerOffset)',
40
+ alignSelf: 'start',
41
+ };
42
+
43
+ const SECTION_NAV_CONTENT_SX = {
44
+ minWidth: 0,
45
+ };
46
+
47
+ const SECTION_NAV_GROUP_HEADER_SX = {
48
+ px: 2,
49
+ py: 1.5,
50
+ borderBottom: '1px solid',
51
+ borderColor: 'divider',
52
+ };
53
+
54
+ const SECTION_NAV_TRIGGER_SX = {
55
+ width: '100%',
56
+ minHeight: 56,
57
+ mb: 2,
58
+ px: 1.75,
59
+ py: 1,
60
+ display: 'flex',
61
+ alignItems: 'center',
62
+ justifyContent: 'space-between',
63
+ gap: 2,
64
+ textAlign: 'left',
65
+ border: '1px solid',
66
+ borderColor: 'divider',
67
+ borderRadius: 1,
68
+ backgroundColor: 'background.paper',
69
+ boxShadow: 1,
70
+ '&:hover': {
71
+ backgroundColor: 'action.hover',
72
+ },
73
+ '&.Mui-focusVisible': {
74
+ outline: '2px solid',
75
+ outlineColor: 'primary.main',
76
+ outlineOffset: 2,
77
+ },
78
+ };
79
+
80
+ const SECTION_NAV_TRIGGER_TEXT_SX = {
81
+ minWidth: 0,
82
+ display: 'flex',
83
+ flexDirection: 'column',
84
+ };
85
+
86
+ const SECTION_NAV_TRIGGER_VALUE_SX = {
87
+ overflow: 'hidden',
88
+ textOverflow: 'ellipsis',
89
+ whiteSpace: 'nowrap',
90
+ };
91
+
92
+ const SECTION_NAV_CHEVRON_SX = {
93
+ color: 'text.secondary',
94
+ transition: 'transform 0.18s ease',
95
+ flex: 'none',
96
+ };
97
+
98
+ const SECTION_NAV_DRAWER_TITLE_SX = {
99
+ mb: 2,
100
+ };
101
+
102
+ const SECTION_NAV_DRAWER_SCROLL_SX = {
103
+ overflowY: 'auto',
104
+ };
105
+
106
+ function SectionNavList({
107
+ groups,
108
+ activeKey,
109
+ rememberedKey,
110
+ overviewItem,
111
+ onSelect,
112
+ rememberedLabel,
113
+ }) {
114
+ const headingId = useId();
115
+
116
+ return (
117
+ <Stack spacing={2}>
118
+ {overviewItem && (
119
+ <Paper variant="outlined" sx={SECTION_NAV_GROUP_PAPER_SX}>
120
+ <List disablePadding>
121
+ <ListItemButton
122
+ selected={activeKey == null}
123
+ aria-current={activeKey == null ? 'page' : undefined}
124
+ onClick={() => onSelect?.(null)}
125
+ >
126
+ <ListItemText primary={overviewItem.label} />
127
+ </ListItemButton>
128
+ </List>
129
+ </Paper>
130
+ )}
131
+
132
+ {groups.map((group, groupIndex) => {
133
+ const groupHeadingId = `${headingId}-${groupIndex}`;
134
+
135
+ return (
136
+ <Paper key={group.key} variant="outlined" sx={SECTION_NAV_GROUP_PAPER_SX}>
137
+ <Box id={groupHeadingId} sx={SECTION_NAV_GROUP_HEADER_SX}>
138
+ <Typography variant="subtitle2" fontWeight={700}>
139
+ {group.label}
140
+ </Typography>
141
+ </Box>
142
+ <List disablePadding aria-labelledby={groupHeadingId}>
143
+ {group.items.map((item) => {
144
+ const selected = item.key === activeKey;
145
+ return (
146
+ <ListItemButton
147
+ key={item.key}
148
+ selected={selected}
149
+ aria-current={selected ? 'page' : undefined}
150
+ onClick={() => onSelect?.(item.key)}
151
+ >
152
+ <ListItemText
153
+ primary={item.label}
154
+ secondary={!selected && item.key === rememberedKey ? rememberedLabel : null}
155
+ />
156
+ </ListItemButton>
157
+ );
158
+ })}
159
+ </List>
160
+ </Paper>
161
+ );
162
+ })}
163
+ </Stack>
164
+ );
165
+ }
166
+
167
+ export function SectionNav({
168
+ mode,
169
+ groups,
170
+ activeKey = null,
171
+ onSelect,
172
+ open,
173
+ onOpen,
174
+ onClose,
175
+ title,
176
+ triggerEyebrow,
177
+ overviewItem,
178
+ rememberedKey,
179
+ headerOffset = 24,
180
+ // SHELL-3's bottom bar uses drawer + 2, so the drawer must stay above it.
181
+ zIndex = defaultZIndex,
182
+ sidebarWidth = 280,
183
+ children,
184
+ }) {
185
+ const { t } = useTranslation();
186
+ const theme = useTheme();
187
+ const drawerId = useId();
188
+ const [internalOpen, setInternalOpen] = useState(false);
189
+ const isControlled = open !== undefined;
190
+ const drawerOpen = isControlled ? open : internalOpen;
191
+ const resolvedTitle = title ?? t('SectionNav.TITLE');
192
+ const resolvedEyebrow = triggerEyebrow ?? t('SectionNav.TRIGGER_EYEBROW');
193
+ const rememberedLabel = t('SectionNav.LAST_OPENED');
194
+ const resolvedZIndex = typeof zIndex === 'function' ? zIndex(theme) : zIndex;
195
+ const resolvedHeaderOffset = typeof headerOffset === 'number' ? `${headerOffset}px` : headerOffset;
196
+ const activeItem = activeKey == null
197
+ ? overviewItem
198
+ : groups.flatMap((group) => group.items).find((item) => item.key === activeKey);
199
+
200
+ const handleTriggerClick = () => {
201
+ if (isControlled) {
202
+ onOpen?.();
203
+ } else {
204
+ setInternalOpen(true);
205
+ }
206
+ };
207
+
208
+ const handleDrawerClose = () => {
209
+ if (!isControlled) setInternalOpen(false);
210
+ onClose?.();
211
+ };
212
+
213
+ const handleDrawerSelect = (key) => {
214
+ onSelect?.(key);
215
+ handleDrawerClose();
216
+ };
217
+
218
+ if (mode === 'desktop') {
219
+ return (
220
+ <Box
221
+ sx={{ ...SECTION_NAV_GRID_SX, gridTemplateColumns: `${sidebarWidth}px minmax(0, 1fr)` }}
222
+ data-section-nav-mode="desktop"
223
+ >
224
+ <Box
225
+ component="nav"
226
+ aria-label={resolvedTitle}
227
+ sx={SECTION_NAV_SIDEBAR_SX}
228
+ style={{ '--SectionNav-headerOffset': resolvedHeaderOffset }}
229
+ >
230
+ <SectionNavList
231
+ groups={groups}
232
+ activeKey={activeKey}
233
+ rememberedKey={rememberedKey}
234
+ overviewItem={overviewItem}
235
+ onSelect={onSelect}
236
+ rememberedLabel={rememberedLabel}
237
+ />
238
+ </Box>
239
+ <Box sx={SECTION_NAV_CONTENT_SX}>{children}</Box>
240
+ </Box>
241
+ );
242
+ }
243
+
244
+ return (
245
+ <>
246
+ <ButtonBase
247
+ sx={SECTION_NAV_TRIGGER_SX}
248
+ onClick={handleTriggerClick}
249
+ aria-haspopup="dialog"
250
+ aria-expanded={drawerOpen}
251
+ aria-controls={drawerId}
252
+ >
253
+ <Box sx={SECTION_NAV_TRIGGER_TEXT_SX}>
254
+ <Typography variant="caption" color="text.secondary">
255
+ {resolvedEyebrow}
256
+ </Typography>
257
+ <Typography variant="subtitle1" sx={SECTION_NAV_TRIGGER_VALUE_SX}>
258
+ {activeItem?.label ?? resolvedTitle}
259
+ </Typography>
260
+ </Box>
261
+ <KeyboardArrowDownIcon
262
+ aria-hidden="true"
263
+ sx={SECTION_NAV_CHEVRON_SX}
264
+ style={{ transform: drawerOpen ? 'rotate(180deg)' : 'rotate(0deg)' }}
265
+ />
266
+ </ButtonBase>
267
+
268
+ <Box sx={SECTION_NAV_CONTENT_SX}>{children}</Box>
269
+
270
+ <Drawer
271
+ anchor="bottom"
272
+ open={drawerOpen}
273
+ onClose={handleDrawerClose}
274
+ style={{ zIndex: resolvedZIndex }}
275
+ PaperProps={{
276
+ id: drawerId,
277
+ role: 'dialog',
278
+ 'aria-modal': true,
279
+ 'aria-labelledby': `${drawerId}-title`,
280
+ sx: SECTION_NAV_DRAWER_PAPER_SX,
281
+ }}
282
+ >
283
+ <Typography id={`${drawerId}-title`} variant="h6" sx={SECTION_NAV_DRAWER_TITLE_SX}>
284
+ {resolvedTitle}
285
+ </Typography>
286
+ <Box sx={SECTION_NAV_DRAWER_SCROLL_SX}>
287
+ <SectionNavList
288
+ groups={groups}
289
+ activeKey={activeKey}
290
+ rememberedKey={rememberedKey}
291
+ overviewItem={overviewItem}
292
+ onSelect={handleDrawerSelect}
293
+ rememberedLabel={rememberedLabel}
294
+ />
295
+ </Box>
296
+ </Drawer>
297
+ </>
298
+ );
299
+ }
@@ -2,15 +2,15 @@ import React, { useContext, useEffect, useMemo, useState } from 'react';
2
2
  import { Helmet } from 'react-helmet';
3
3
  import { useSearchParams } from 'react-router-dom';
4
4
  import {
5
- Tabs,
6
- Tab,
7
5
  Box,
8
6
  Typography,
9
7
  Alert,
10
8
  CircularProgress,
11
9
  Paper,
12
10
  Stack,
11
+ useMediaQuery,
13
12
  } from '@mui/material';
13
+ import { useTheme } from '@mui/material/styles';
14
14
  import { useTranslation } from 'react-i18next';
15
15
 
16
16
  // Internal context
@@ -20,6 +20,7 @@ export const ACCOUNT_PAGE_SECTION_PAPER_SX = { p: 2.5 };
20
20
 
21
21
  // Falls die Komponenten noch lokal sind:
22
22
  import { WidePage } from '../layout/PageLayout';
23
+ import { SectionNav } from '../layout/SectionNav';
23
24
  import { ProfileComponent } from '../components/ProfileComponent';
24
25
  import { SecurityComponent } from '../components/SecurityComponent';
25
26
  import { UserListComponent } from '../components/UserListComponent';
@@ -57,6 +58,8 @@ export function AccountPage({
57
58
  const [searchParams, setSearchParams] = useSearchParams();
58
59
  const [authPolicy, setAuthPolicy] = useState(null);
59
60
  const [authPolicyError, setAuthPolicyError] = useState('');
61
+ const theme = useTheme();
62
+ const isMobile = useMediaQuery(theme.breakpoints.down('md'));
60
63
 
61
64
  // 1. URL State Management
62
65
  const currentTabRaw = searchParams.get('tab') || 'profile';
@@ -159,6 +162,49 @@ export function AccountPage({
159
162
  return list;
160
163
  }, [user, perms, t, isSuperUser, extraTabs, canViewUsers, canViewInvite]);
161
164
 
165
+ const groups = useMemo(() => {
166
+ const groupDefinitions = [
167
+ {
168
+ key: 'my-account',
169
+ label: t('Account.GROUP_MY_ACCOUNT', 'My account'),
170
+ values: new Set(['profile', 'security']),
171
+ },
172
+ {
173
+ key: 'management',
174
+ label: t('Account.GROUP_MANAGEMENT', 'Management'),
175
+ values: new Set(['users', 'invite']),
176
+ },
177
+ {
178
+ key: 'help',
179
+ label: t('Account.GROUP_HELP', 'Help'),
180
+ values: new Set(['support']),
181
+ },
182
+ ];
183
+ const assignedValues = new Set(
184
+ groupDefinitions.flatMap((group) => [...group.values]),
185
+ );
186
+ const mappedGroups = groupDefinitions.map((group) => ({
187
+ key: group.key,
188
+ label: group.label,
189
+ items: tabs
190
+ .filter((tab) => group.values.has(tab.value))
191
+ .map((tab) => ({ key: tab.value, label: tab.label })),
192
+ }));
193
+ const extraItems = tabs
194
+ .filter((tab) => !assignedValues.has(tab.value))
195
+ .map((tab) => ({ key: tab.value, label: tab.label }));
196
+
197
+ if (extraItems.length > 0) {
198
+ mappedGroups.push({
199
+ key: 'more',
200
+ label: t('Account.GROUP_MORE', 'More'),
201
+ items: extraItems,
202
+ });
203
+ }
204
+
205
+ return mappedGroups.filter((group) => group.items.length > 0);
206
+ }, [tabs, t]);
207
+
162
208
  // 4. Loading & Auth Checks
163
209
  if (loading) {
164
210
  return (
@@ -193,19 +239,13 @@ export function AccountPage({
193
239
  <title>{t('Account.PAGE_TITLE', 'Account')} – {user.email}</title>
194
240
  </Helmet>
195
241
 
196
- <Tabs
197
- value={safeTab}
198
- onChange={handleTabChange}
199
- variant="scrollable"
200
- scrollButtons="auto"
201
- sx={{ mb: 3, borderBottom: 1, borderColor: 'divider' }}
242
+ <SectionNav
243
+ mode={isMobile ? 'mobile' : 'desktop'}
244
+ groups={groups}
245
+ activeKey={safeTab}
246
+ onSelect={(key) => handleTabChange(null, key)}
202
247
  >
203
- {tabs.map((tab) => (
204
- <Tab key={tab.value} label={tab.label} value={tab.value} />
205
- ))}
206
- </Tabs>
207
-
208
- {/* --- TAB CONTENT --- */}
248
+ {/* --- TAB CONTENT --- */}
209
249
 
210
250
  {safeTab === 'profile' && (
211
251
  <Box sx={{ mt: 2 }}>
@@ -347,6 +387,7 @@ export function AccountPage({
347
387
  {activeExtraTab.render?.({ user, perms, isSuperUser, t })}
348
388
  </Box>
349
389
  )}
390
+ </SectionNav>
350
391
  </WidePage>
351
392
  );
352
393
  }
@@ -3,6 +3,7 @@ export {
3
3
  assertThemeComplete,
4
4
  assertKitSxDisjoint,
5
5
  calculateContrastRatio,
6
+ reportOffPaletteColours,
6
7
  reportKitSxBypasses,
7
8
  reportThemeAdoption,
8
9
  THEME_COMPLETENESS_SURFACES,
@@ -56,6 +56,10 @@ import {
56
56
  } from '../components/UserListComponent';
57
57
  import { CHART_FRAME_ALERT_SX, CHART_FRAME_ROOT_SX } from '../components/charts/ChartFrame';
58
58
  import { PAGE_LAYOUT_CONTAINER_SX } from '../layout/PageLayout';
59
+ import {
60
+ SECTION_NAV_DRAWER_PAPER_SX,
61
+ SECTION_NAV_GROUP_PAPER_SX,
62
+ } from '../layout/SectionNav';
59
63
  import {
60
64
  ATTACHMENT_LIST_ALERT_SX,
61
65
  ATTACHMENT_LIST_LIGHTBOX_ACTION_SX,
@@ -142,6 +146,8 @@ const SX_EXPORTS = {
142
146
  REGISTRATION_METHODS_INFO_SX,
143
147
  SECURITY_ALERT_SX,
144
148
  SECURITY_DIVIDER_SX,
149
+ SECTION_NAV_DRAWER_PAPER_SX,
150
+ SECTION_NAV_GROUP_PAPER_SX,
145
151
  SIGNUP_CONFIRM_PAGE_ALERT_SX,
146
152
  SIGN_UP_PAGE_ALERT_SX,
147
153
  SUPPORT_RECOVERY_AGENT_BUTTON_SX,
@@ -161,10 +167,11 @@ const SX_EXPORT_NAMES = new Map(
161
167
  Object.entries(SX_EXPORTS).map(([name, sx]) => [sx, name]),
162
168
  );
163
169
 
164
- const entry = (component, muiComponent, sx) => ({
170
+ const entry = (component, muiComponent, sx, slots) => ({
165
171
  component,
166
172
  muiComponent,
167
173
  sx,
174
+ slots,
168
175
  exportName: SX_EXPORT_NAMES.get(sx),
169
176
  });
170
177
 
@@ -219,6 +226,9 @@ export const KIT_COMPONENT_SX_REGISTRY = [
219
226
  entry('ChartFrame.root', 'MuiPaper', CHART_FRAME_ROOT_SX),
220
227
  entry('ChartFrame.alert', 'MuiAlert', CHART_FRAME_ALERT_SX),
221
228
  entry('PageLayout.container', 'MuiContainer', PAGE_LAYOUT_CONTAINER_SX),
229
+ entry('SectionNav.drawerPaper', 'MuiDrawer', SECTION_NAV_DRAWER_PAPER_SX, ['paper']),
230
+ entry('SectionNav.drawerPaperBase', 'MuiPaper', SECTION_NAV_DRAWER_PAPER_SX),
231
+ entry('SectionNav.groupPaper', 'MuiPaper', SECTION_NAV_GROUP_PAPER_SX),
222
232
  entry('AttachmentList.lightboxAction', 'MuiIconButton', ATTACHMENT_LIST_LIGHTBOX_ACTION_SX),
223
233
  entry('AttachmentList.alert', 'MuiAlert', ATTACHMENT_LIST_ALERT_SX),
224
234
  entry('Composer.emojiAction', 'MuiIconButton', COMPOSER_EMOJI_BUTTON_SX),