@micha.bigler/ui-core-micha 2.36.0 → 2.37.1
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/README.md +4 -0
- package/WORK_ORDERS.md +3 -2
- package/dist/index.js +8 -0
- package/dist/layout/SectionNav.js +16 -1
- package/package.json +1 -1
- package/src/index.js +18 -0
- package/src/layout/SectionNav.jsx +38 -21
- package/tests/SectionNav.test.jsx +49 -0
- package/tests/i18nAggregate.test.js +121 -0
- package/work-orders/SHELL-6.md +210 -0
package/README.md
CHANGED
|
@@ -5,3 +5,7 @@
|
|
|
5
5
|
Run `pnpm dev` to open the local Vite harness. It mounts entries with the package's MUI theme, `react-i18next`, a memory router, and fixture-backed REST/realtime transport. Use the controls to change theme and viewport.
|
|
6
6
|
|
|
7
7
|
To add an entry, create a small component in `dev/entries.jsx` and append it to `entries`. It may be a complete surface or one standalone component. Keep fixtures and transport behaviour in `dev/`, never in `src/`: the harness is not part of the published package.
|
|
8
|
+
|
|
9
|
+
## Internationalization
|
|
10
|
+
|
|
11
|
+
Spread `uiCoreTranslations` into the default i18n namespace as the supported aggregate; per-feature translation exports remain available to selective apps that accept responsibility for pairing every adopted feature with its bundle.
|
package/WORK_ORDERS.md
CHANGED
|
@@ -72,8 +72,9 @@ Two chunks. **Chunk 1** (Part 1 + Part 2 of the Envelope): extended `SPACING_SHO
|
|
|
72
72
|
**Operator decision: fix both** (of three offered options: widen-and-fix / exempt-with-reason / narrow-the-scanner). Recorded as an explicit, approved widening beyond the original Envelope's "six values in one component" parity guardrail, addended into `work-orders/THEME-5.md` before chunk 2 ran. **Chunk 2**: (8) `SocialLoginButtons.jsx`'s Google-icon border `1px solid rgba(0,0,0,0.2)` → `divider`; (9) `exportChart.js`'s Canvas 2D `context.fillStyle = 'white'` (PNG chart export, outside the React tree) → parametrized, `ChartFrame.jsx` now passes `theme.palette.background.paper` in via `useTheme()`.
|
|
73
73
|
|
|
74
74
|
**Orchestrator's own verification:** scoped tests (`themeCompleteness`, `createAppTheme`, `MobileBottomNav`, `ChartFrame`) 65/65 green, `tsc -p tsconfig.build.json --noEmit` clean. Independent `reviewer` (Sonnet) + `ui_reviewer` (Sonnet) spawned concurrently against the real, complete two-chunk diff — **this was the only independent review that actually ran; no prior pass exists despite what the uncommitted Notiz briefly claimed.** Both reviewers: **no blocking code findings.** `reviewer`: two P3 notes, not fixed — the print-document masking regex only matches the first `printWindow.document.write(...)` call in a file (currently safe, only one exists; latent fragility if a second is ever added); `exportChartPng`'s new `backgroundColour` parameter has no default (low risk, not exported from `src/index.js`, single caller always supplies it — a footgun only if the function gains a second caller or goes public). `ui_reviewer`: confirmed all nine role-based mappings resolve against real `tokens.js` values and are visually reasonable; flagged the Google-icon border change (`rgba(0,0,0,0.2)` → `divider`, roughly halving visible contrast) as the single largest visual delta of the nine and worth a live look given the operator's explicit no-rendered-gate decision for this WO — not blocking, no rendered gate was added, consistent with that decision. Version `2.34.0` → `2.35.0` (minor, new exported API). No rendered/prototype gate, per the operator's explicit decision — this note is the substitute, all nine changed values listed from-and-to above. DS-18 follow-ups. |
|
|
75
|
-
| SHELL-5 | Promote the secondary-navigation shell | Promotes jg-ferien's `SectionSwitcher` (grouped sidebar + bottom drawer) into the kit as the level *below* the bottom bar, adds the trigger bar that supplies the missing affordance, and makes ucm's own `AccountPage` its first consumer by replacing its horizontal `Tabs` strip. Look and feel follow jg by operator decision. Full spec: [work-orders/SHELL-5.md](work-orders/SHELL-5.md). | 2026-08-11 | done |
|
|
75
|
+
| SHELL-5 | Promote the secondary-navigation shell | Promotes jg-ferien's `SectionSwitcher` (grouped sidebar + bottom drawer) into the kit as the level *below* the bottom bar, adds the trigger bar that supplies the missing affordance, and makes ucm's own `AccountPage` its first consumer by replacing its horizontal `Tabs` strip. Look and feel follow jg by operator decision. Full spec: [work-orders/SHELL-5.md](work-orders/SHELL-5.md). | 2026-08-11 | done | b283c33 | DS-11. Supersedes `SHELL-2` (now `dropped`); prototype `assets/SHELL-2-account-navigation.html` kept as the composition spec. Each jg-specific concept was made optional and `AccountPage` proves it optional: no `overviewItem` (no "Übersicht" concept), no `rememberedKey`, `APP_HEADER_HEIGHT` → `headerOffset` (default `24`), `zIndex.drawer + 3` kept as default (`SHELL-3`'s bar sits at `+2`, ordering is meaningful), and the 280px two-column grid moved from the consumer into the component. The trigger bar is the one net-new piece (jg has none) — new full-width 56px bar, first thing rendered on mobile, opens a bottom drawer. `AccountPage`'s flat permission-computed `tabs` list bucketed into four fixed groups per the prototype ("Mein Konto"/"Verwaltung"/"Hilfe"/trailing "Weitere" catch-all for ungrouped `extraTabs`), new `Account.GROUP_*` i18n keys; `?tab=` contract and all permission-gating logic byte-for-byte unchanged. New component-owned chrome i18n (`src/i18n/sectionNavTranslations.ts`, all 4 locales) resolves the trap jg's `t("SectionSwitcher.TITLE")` had — no fallback, would have rendered a raw key. **Codex left the diff uncommitted and did not touch this file, as instructed** (the third consecutive WO where the explicit warning about the prior invalid-review pattern held). **Concurrent-session note:** an unrelated `I18N-1` session was uncommitted in the same working tree at the same time (`README.md`, `tests/i18nAggregate.test.js`, an aggregate-translations addition to `src/index.js`) — separated by hunk-level staging (`git add -p`) before this commit; only `SectionNav`'s two export lines in `src/index.js` are in this commit, I18N-1's own hunks were left for its own session to land. **Orchestrator's own verification:** scoped tests green (52/52 after fixes, see below), `tsc` clean. Independent `reviewer` + `ui_reviewer` (Sonnet, both mandatory — new logic and a prototype in scope) ran concurrently. **`reviewer` found two real P2s, both fixed:** the drawer's `zIndex` was set via BOTH an exported/registered `sx` object and a separate inline `style` prop on the same element — `style` always wins, so the registered `sx` value was dead code, tested but never actually reaching the DOM; removed the redundant `SECTION_NAV_DRAWER_SX` entirely (and its now-pointless `kitSxRegistry.js` entry), kept only the working `style`-based zIndex. The "optional controlled" `open`/`onClose` API (an Orchestrator design resolution for reconciling jg's controlled drawer with the new internally-triggered one) was incomplete — no `onOpen` callback existed, so a controlled consumer's trigger click was silently a no-op; added `onOpen`, fixed, added a regression test (`AccountPage` itself is unaffected, it uses uncontrolled mode). **`ui_reviewer` found one real P2, fixed:** the mobile trigger's current-section text used `fontWeight={700}`, matching neither the theme's `subtitle1` (600) nor the prototype's `.trigger-value` (16.5px/600) — an undeclared deviation that made it visually indistinguishable from the bold group headers; removed the override. One P3 left as documented, not fixed: `headerOffset` defaults to `24` vs. the prototype's `16` — a defensible generalisation (the component no longer assumes a fixed app-header height the way jg's original did; no consumer today is sensitive to the exact value). **Rendered verification:** the Browser pane could not composite frames in this session (same recurring environment limitation as `SHELL-3`/`SHELL-4`) — DOM/computed-style substitution via the test suite's own assertions (grid columns, drawer dimensions, z-index, theme-token resolution under two themes) plus static composition comparison against the prototype by both independent reviewers, one of which (`ui_reviewer`) is exactly the review type this repo relies on for design fidelity when a real render isn't obtainable. **Follow-ups named, unchanged:** jg migrates onto this and deletes its own drawer (operator requirement, separate WO); the users table is `AUTH-4`, deliberately not here. |
|
|
76
76
|
| AUTH-4 | The account users table is a desktop table shown unchanged on a phone | Measured 2026-08-11 in cockpit at `innerWidth` 411: `UserListComponent`'s table renders **1053px inside a 325px container** — 728px of overflow. The container has `overflow-x: auto` and **the document does not overflow**, so this is not a broken layout; it is six fixed-width desktop columns (Email 220, Name 180, New 90, Successful Login 163, Role 180, Actions 220) on a phone, with Role and Actions entirely outside the view. | 2026-08-11 | planned | | Split out of DS-11 by operator decision because its fix class differs from the navigation shell's. **No Envelope yet — its WHAT is undecided** and needs a design round: column priority, a card layout below `md`, or a sticky first column with horizontal scroll are three different answers with different costs. `SHELL-2` measured 1045px on 2026-08-08 and it is 1053px today (+8px), so the number is stable — cell padding is already the baseline's `10px 16px`, meaning `THEME-4`/`THEME-5` did not move the rendered density. Reached via `AccountPage` → the `users` tab, which **at least ten apps mount**, so any fix lands estate-wide. Prefix note: `AUTH-*` because the account screen's own surfaces belong to that stream, not because this is authentication. |
|
|
77
|
-
| I18N-1 | One export, so a consumer cannot half-register the kit's strings | ucm exports its UI strings as **six separate bundles** that each app must spread into its own i18n resources by hand. Adopting a component without also adding its bundle renders **a raw key as visible UI text** — no type error, no runtime error. Ship a `uiCoreTranslations` aggregate so that cannot happen, and so a future ucm feature is carried automatically instead of requiring twelve apps to notice. Full order [work-orders/I18N-1.md](work-orders/I18N-1.md). | 2026-08-11 |
|
|
77
|
+
| I18N-1 | One export, so a consumer cannot half-register the kit's strings | ucm exports its UI strings as **six separate bundles** that each app must spread into its own i18n resources by hand. Adopting a component without also adding its bundle renders **a raw key as visible UI text** — no type error, no runtime error. Ship a `uiCoreTranslations` aggregate so that cannot happen, and so a future ucm feature is carried automatically instead of requiring twelve apps to notice. Full order [work-orders/I18N-1.md](work-orders/I18N-1.md). | 2026-08-11 | done | a6d322d | Tier 3 (shared-core + new public export). **Shipped as `2.37.0`, not the planned `2.36.0`** — the concurrent `SHELL-5` release claimed `2.36.0` first; sequenced behind it (see below), no other change to scope. **Landed with seven bundles, not six** — `SHELL-5` (same day, same repo) added a seventh `sectionNavTranslations`, already picked up by the directory-enumerated ratchet before this WO's own commit, so the aggregate never had a six-bundle gap in practice. Codex implemented (`uiCoreTranslations` export + directory-ratcheted `tests/i18nAggregate.test.js` + README line); left the diff uncommitted as instructed. **Concurrent-session collision, real one:** `SHELL-5` was landing in the same working tree at the same time and touched `src/index.js` too; held this WO uncommitted until `SHELL-5`'s commit (`b283c33`) reached `main`, then re-diffed — clean, only this WO's seven-import/aggregate hunk remained. **Test #3 blocked Codex for a real reason, resolved by the operator, not routed around:** the required "every value keeps its `{de,fr,en,sw}` shape" test failed because `messagingTranslations`'s 116 keys have **no `sw` value at all**, a pre-existing gap, not something the merge caused — confirmed directly (`grep -c "'sw':" src/i18n/messagingTranslations.ts` → 0). Operator chose to scope the test down to `{de,fr,en}` for now with `sw` backfill tracked as a follow-up (comment in `tests/i18nAggregate.test.js` records this); key/value fidelity and the collision check are unaffected by the scope-down. **Bundle-size delta measured** (feeds `I18N-2`): +28,943 B raw / +7,327 B gzip for an auth-only app switching from `authTranslations` alone to the (then six-bundle) aggregate. **Independent `reviewer` + `ui_reviewer` (Sonnet, both mandatory, concurrent): no findings from either.** `reviewer` additionally confirmed the seven bundles are namespace-disjoint by prefix so spread order can't shadow a value, and that the ratchet's `fs.readdirSync` + `import.meta.glob` targets stay in sync (mismatch throws explicitly). Scoped tests green: 23/23 (`i18nAggregate`, `authTranslations`, `messagingExports`, `notificationsExports`). No rendered verification — the diff renders nothing. **`fitness-monitor/FM-14` is the still-open acceptance condition**, executed in another repo: fitness-monitor keeps rendering raw `ChartFrame.*` keys until it lands. **Two apps hit by the same mechanism, evidence not hypothesis:** (1) cockpit's `UI-3` adopted `UserMenu` without `userMenuTranslations` and shipped a header button reading the literal `UserMenu.TITLE` — **both independent static reviews were clean**, it was caught only by the rendered click-through, and only by DOM inspection at that since screenshots were unavailable; (2) **fitness-monitor is defective right now** — it imports `ChartFrame` (`BodyHistoryPage.jsx:7`, used `:295`/`:445`) but registers only `authTranslations`, so `ChartFrame.jsx:74`'s `t('ChartFrame.LOADING')` and its empty/error siblings render raw keys. It survived because those are transient edge states. **Estate survey, 2026-08-11:** twelve apps register ucm bundles — jg-ferien all six, cockpit four, hram three, **fitness-monitor one (defective)**, and eight auth-only apps that are correct solely because they have not adopted a second component yet. That is a coincidence with an expiry date, not a safety property. **The merge is safe:** 504 keys across the six bundles with **zero collisions**, verified by enumeration; every key is namespace-prefixed. **The part that makes the fix permanent is the ratchet test** — the aggregate must be asserted against the bundle list **read from `src/i18n/`**, not a hand-written list, so a future bundle that is not folded in fails. Without it the aggregate is correct today and rots exactly as the six exports did. Per-feature exports stay (removing them would break twelve apps for no gain); double-spreading during migration is harmless, which is why the zero-collision property is a test and not a comment. **Rollout is constrained by the one-repo rule** — a WO implements in one repository, so the twelve consumers cannot be migrated from here: `fitness-monitor/FM-14` is written, mandatory and blocked on this release, and **fitness-monitor renders raw keys until it lands** (the operator chose to route the fix through this rollout rather than patch it separately, so it could not be lost between two WOs; the delay is the cost and is recorded rather than left implicit). The other eleven are correct today — prevention, not repair — and ride along when each is next touched. Self-registration via `addResourceBundle` is **deliberately out of scope**: feasible (ucm components use `useTranslation()` against the consumer's own instance and ucm owns no instance), but all twelve consumers flatten into the default namespace with dotted keys, and registering at render time risks a first-paint flash of the raw key — replacing an invisible defect with a visible one. It is `I18N-2`, framed as a bundle-size optimisation rather than a correctness fix, because this WO already closes the hole. |
|
|
78
78
|
| I18N-2 | Self-registering component strings, if the bundle cost justifies it | Have ucm components register their own strings on the consumer's i18n instance (`addResourceBundle` on the instance from `useTranslation()`) instead of relying on the consumer spreading a bundle. Would remove the last of the consumer's i18n obligations and stop auth-only apps shipping strings they never render. | 2026-08-11 | planned | | Tier 3 (shared-core, changes how every consumer resolves strings). **Deliberately NOT a correctness WO** — `I18N-1`'s aggregate closes the defect class; this is a bundle-size optimisation and is scheduled on that basis alone. **Its input is a measurement `I18N-1` must produce**: the built size delta an auth-only app takes on by switching to the aggregate. Source-side that is ~30 KB of strings (the five non-auth bundles are 188 keys against auth's 316), but a source figure is not the answer and a guess here would make this decision unfalsifiable. **Two known hazards, both to be resolved before authoring:** (1) all twelve consumers flatten everything into i18next's DEFAULT namespace with flat dotted keys, so the registration shape has to match exactly or key resolution changes under twelve apps at once; (2) registering during render risks a first-paint flash of the raw key — which would trade an invisible defect for a visible one, the opposite of the point. Feasibility is established: ucm components use `useTranslation()` against the consumer's instance and ucm owns no i18next instance of its own, so there is a real instance to register against. |
|
|
79
79
|
| AUTH-4 | The account user list on a phone | Gives `UserListComponent` a card layout below `md` — one card per user, email as the title, the two boolean columns as labelled chips, role select and delete in a divided footer, plus a sort control that replaces the column headers the table loses. Above `md` the table is byte-identical. Prototype: [assets/AUTH-4-user-list-card.html](work-orders/assets/AUTH-4-user-list-card.html). Full spec: [work-orders/AUTH-4.md](work-orders/AUTH-4.md). | 2026-08-11 | planned | | Split out of DS-11 by operator decision; fix class chosen by operator (card list, over sticky-first-column and priority-hide). Envelope only (Approval Gate #1 pending). **Authored and parked by operator decision 2026-08-11 — not queued.** It is built on demand, not as SHELL-5's successor; a fresh session should not dispatch it without the operator saying so. When it does run, it runs **after** `SHELL-5` (same screen, different half). The point of this row is that the measurements above are already done, so nobody re-measures. Prototype in scope, so the two-width rendered gate applies. **Measured 2026-08-11 in cockpit** at `innerWidth` 411 (the mobile preset gives 411, not 375): table **1053px inside a 325px container**, container `overflow-x: auto` and **the document does not overflow** — so it is not a broken layout but a desktop table on a phone, with Role and Actions, the two things the screen exists for, entirely outside the view. **The cheap fix was tested and rejected:** 253px of the width is `New` (90) + `Successful Login` (163), both default-on props that `AccountPage` already forwards and cockpit simply left on — but the four remaining columns are 800px against 325px, so configuration cannot fix it. Column defaults deliberately NOT flipped: that would be a silent estate-wide change. SHELL-2's 1045px from 2026-08-08 still holds (+8px), so THEME-4/5 did not move the density. **Four prototype decisions are load-bearing:** email as title with a muted placeholder when the name is absent (verified: a 47-char address wraps to two lines in a 357px card without overflow); the icon-only flags become labelled chips **because a Tooltip is unreachable on a touch pointer**, so that data is effectively absent today; role and delete in a footer behind a divider with the full 44px target; and a **sort control**, because a card list has no column headers — without it the mobile view silently loses a desktop capability. `extraColumns` render as label/value rows with **no new required field**; `extraRowActions` beyond three must collapse into an overflow menu. Reached via `AccountPage`, which **at least ten apps mount**, and ucm has no staging net. |
|
|
80
|
+
| SHELL-6 | A controlled `SectionNav` with no way to open must not render a trigger | In `mode="mobile"` the built-in trigger renders unconditionally (`SectionNav.jsx:246`), while `handleTriggerClick` calls `onOpen?.()` — so a consumer driving `open` itself without `onOpen` gets a button that is visible, focusable and does nothing. Render the trigger only when a click can open the drawer: uncontrolled, or controlled **and** `onOpen` supplied. Plus the component's **render matrix** written into its doc comment. Full spec: [work-orders/SHELL-6.md](work-orders/SHELL-6.md). | 2026-08-11 | done | <pending> | **Unblocks `jg-ferien/NAV-37`** (`blocked`, `c627d7b`), same shape as `SHELL-3`→`SHELL-4`. **Operator chose the semantics fix over a `showTrigger` prop** — a prop would leave the dead button as the default for every controlled consumer who has not heard of it, turning a defect into a configuration option; a focusable control that performs no action is wrong on its own terms. Fix: `const canOpenTrigger = !isControlled || Boolean(onOpen)`, wrapping only the `<ButtonBase>` trigger — `children` and `<Drawer>` keep rendering unconditionally in every case. Render-matrix doc comment added above the component per the WO's own framing ("not optional documentation; it is the artefact whose absence caused the defect"). `AccountPage`, the only shipped consumer, is uncontrolled and renders identically. Version `2.37.0` → `2.37.1` (patch, defect fix, no API change). **Codex left the diff uncommitted and did not touch this file, as instructed** — fourth consecutive WO where the explicit warning held. **Required test 1's non-vacuity confirmed**: Codex's own landing note states the new regression test was run against the pre-fix code and failed (dead trigger still in the DOM), then passed after the one-line fix — the Orchestrator did not re-verify this specific claim by checking out the prior commit, but both independent reviewers separately read the old-vs-new JSX and found the claim plausible/consistent. **Orchestrator's own verification:** scoped tests 47/47 green, `tsc` clean. Independent `reviewer` + `ui_reviewer` (Sonnet, both mandatory) ran concurrently — **both came back clean, no findings, no fix required.** `reviewer` confirmed the condition is keyed correctly (not on `open`'s value, avoiding the trap the WO's own test 3 exists to catch), `children`/`Drawer` stay unconditional, desktop path untouched, and the render-matrix comment matches the Envelope's table exactly. `ui_reviewer` confirmed the trigger's removal is genuine (conditional JSX unmount, not a CSS-hidden trick left in the accessibility tree/tab order) and no restyle occurred as a side effect; noted two non-blocking residual observations, not fixed: a hypothetical layout-shift if a consumer dynamically toggled `onOpen` on/off at runtime (no current or planned consumer does this), and `resolvedEyebrow`/`resolvedTitle` computed unconditionally even when unused (harmless). No rendered gate needed per the WO's own reasoning — the change is a DOM absence fact, not an appearance, and the test suite is the correct instrument. |
|
package/dist/index.js
CHANGED
|
@@ -1,4 +1,11 @@
|
|
|
1
1
|
// index.js (Entry Point deiner Library)
|
|
2
|
+
import { authTranslations } from './i18n/authTranslations';
|
|
3
|
+
import { chartsTranslations } from './i18n/chartsTranslations';
|
|
4
|
+
import { messagingTranslations } from './i18n/messagingTranslations';
|
|
5
|
+
import { notificationsTranslations } from './i18n/notificationsTranslations';
|
|
6
|
+
import { onboardingTranslations } from './i18n/onboardingTranslations';
|
|
7
|
+
import { sectionNavTranslations } from './i18n/sectionNavTranslations';
|
|
8
|
+
import { userMenuTranslations } from './i18n/userMenuTranslations';
|
|
2
9
|
// --- 0. Theme ---
|
|
3
10
|
export { createAppTheme, assertThemeComplete, assertKitSxDisjoint, calculateContrastRatio, KIT_COMPONENT_SX_REGISTRY, reportOffPaletteColours, reportKitSxBypasses, reportThemeAdoption, THEME_COMPLETENESS_SURFACES, } from './theme';
|
|
4
11
|
// --- 1. Auth Context (Essentiell für den Wrapper) ---
|
|
@@ -81,3 +88,4 @@ export { notificationsTranslations } from './i18n/notificationsTranslations';
|
|
|
81
88
|
export { onboardingTranslations } from './i18n/onboardingTranslations';
|
|
82
89
|
export { chartsTranslations } from './i18n/chartsTranslations';
|
|
83
90
|
export { messagingTranslations } from './i18n/messagingTranslations';
|
|
91
|
+
export const uiCoreTranslations = Object.assign(Object.assign(Object.assign(Object.assign(Object.assign(Object.assign(Object.assign({}, authTranslations), chartsTranslations), messagingTranslations), notificationsTranslations), onboardingTranslations), sectionNavTranslations), userMenuTranslations);
|
|
@@ -90,6 +90,20 @@ function SectionNavList({ groups, activeKey, rememberedKey, overviewItem, onSele
|
|
|
90
90
|
}) })] }, group.key));
|
|
91
91
|
})] }));
|
|
92
92
|
}
|
|
93
|
+
/**
|
|
94
|
+
* Render matrix:
|
|
95
|
+
*
|
|
96
|
+
* | `mode` | `open` | `onOpen` | trigger | sidebar | `children` | drawer |
|
|
97
|
+
* |-----------|------------------------|----------|---------|---------|------------------|---------------------|
|
|
98
|
+
* | `desktop` | ignored | ignored | — | yes | yes, in the grid | — |
|
|
99
|
+
* | `mobile` | absent (uncontrolled) | ignored | yes | — | yes | yes, internal state |
|
|
100
|
+
* | `mobile` | given | given | yes | — | yes | caller-driven |
|
|
101
|
+
* | `mobile` | given | absent | no | — | yes | caller-driven |
|
|
102
|
+
*
|
|
103
|
+
* Optional parts: without `overviewItem`, no overview entry or empty Paper is rendered;
|
|
104
|
+
* without `rememberedKey`, no secondary line is rendered; without `title` or
|
|
105
|
+
* `triggerEyebrow`, `t('SectionNav.TITLE')` or `t('SectionNav.TRIGGER_EYEBROW')` is used.
|
|
106
|
+
*/
|
|
93
107
|
export function SectionNav({ mode, groups, activeKey = null, onSelect, open, onOpen, onClose, title, triggerEyebrow, overviewItem, rememberedKey, headerOffset = 24,
|
|
94
108
|
// SHELL-3's bottom bar uses drawer + 2, so the drawer must stay above it.
|
|
95
109
|
zIndex = defaultZIndex, sidebarWidth = 280, children, }) {
|
|
@@ -100,6 +114,7 @@ zIndex = defaultZIndex, sidebarWidth = 280, children, }) {
|
|
|
100
114
|
const [internalOpen, setInternalOpen] = useState(false);
|
|
101
115
|
const isControlled = open !== undefined;
|
|
102
116
|
const drawerOpen = isControlled ? open : internalOpen;
|
|
117
|
+
const canOpenTrigger = !isControlled || Boolean(onOpen);
|
|
103
118
|
const resolvedTitle = title !== null && title !== void 0 ? title : t('SectionNav.TITLE');
|
|
104
119
|
const resolvedEyebrow = triggerEyebrow !== null && triggerEyebrow !== void 0 ? triggerEyebrow : t('SectionNav.TRIGGER_EYEBROW');
|
|
105
120
|
const rememberedLabel = t('SectionNav.LAST_OPENED');
|
|
@@ -128,7 +143,7 @@ zIndex = defaultZIndex, sidebarWidth = 280, children, }) {
|
|
|
128
143
|
if (mode === 'desktop') {
|
|
129
144
|
return (_jsxs(Box, { sx: Object.assign(Object.assign({}, SECTION_NAV_GRID_SX), { gridTemplateColumns: `${sidebarWidth}px minmax(0, 1fr)` }), "data-section-nav-mode": "desktop", children: [_jsx(Box, { component: "nav", "aria-label": resolvedTitle, sx: SECTION_NAV_SIDEBAR_SX, style: { '--SectionNav-headerOffset': resolvedHeaderOffset }, children: _jsx(SectionNavList, { groups: groups, activeKey: activeKey, rememberedKey: rememberedKey, overviewItem: overviewItem, onSelect: onSelect, rememberedLabel: rememberedLabel }) }), _jsx(Box, { sx: SECTION_NAV_CONTENT_SX, children: children })] }));
|
|
130
145
|
}
|
|
131
|
-
return (_jsxs(_Fragment, { children: [_jsxs(ButtonBase, { sx: SECTION_NAV_TRIGGER_SX, onClick: handleTriggerClick, "aria-haspopup": "dialog", "aria-expanded": drawerOpen, "aria-controls": drawerId, children: [_jsxs(Box, { sx: SECTION_NAV_TRIGGER_TEXT_SX, children: [_jsx(Typography, { variant: "caption", color: "text.secondary", children: resolvedEyebrow }), _jsx(Typography, { variant: "subtitle1", sx: SECTION_NAV_TRIGGER_VALUE_SX, children: (_a = activeItem === null || activeItem === void 0 ? void 0 : activeItem.label) !== null && _a !== void 0 ? _a : resolvedTitle })] }), _jsx(KeyboardArrowDownIcon, { "aria-hidden": "true", sx: SECTION_NAV_CHEVRON_SX, style: { transform: drawerOpen ? 'rotate(180deg)' : 'rotate(0deg)' } })] }), _jsx(Box, { sx: SECTION_NAV_CONTENT_SX, children: children }), _jsxs(Drawer, { anchor: "bottom", open: drawerOpen, onClose: handleDrawerClose, style: { zIndex: resolvedZIndex }, PaperProps: {
|
|
146
|
+
return (_jsxs(_Fragment, { children: [canOpenTrigger && (_jsxs(ButtonBase, { sx: SECTION_NAV_TRIGGER_SX, onClick: handleTriggerClick, "aria-haspopup": "dialog", "aria-expanded": drawerOpen, "aria-controls": drawerId, children: [_jsxs(Box, { sx: SECTION_NAV_TRIGGER_TEXT_SX, children: [_jsx(Typography, { variant: "caption", color: "text.secondary", children: resolvedEyebrow }), _jsx(Typography, { variant: "subtitle1", sx: SECTION_NAV_TRIGGER_VALUE_SX, children: (_a = activeItem === null || activeItem === void 0 ? void 0 : activeItem.label) !== null && _a !== void 0 ? _a : resolvedTitle })] }), _jsx(KeyboardArrowDownIcon, { "aria-hidden": "true", sx: SECTION_NAV_CHEVRON_SX, style: { transform: drawerOpen ? 'rotate(180deg)' : 'rotate(0deg)' } })] })), _jsx(Box, { sx: SECTION_NAV_CONTENT_SX, children: children }), _jsxs(Drawer, { anchor: "bottom", open: drawerOpen, onClose: handleDrawerClose, style: { zIndex: resolvedZIndex }, PaperProps: {
|
|
132
147
|
id: drawerId,
|
|
133
148
|
role: 'dialog',
|
|
134
149
|
'aria-modal': true,
|
package/package.json
CHANGED
package/src/index.js
CHANGED
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
// index.js (Entry Point deiner Library)
|
|
2
2
|
|
|
3
|
+
import { authTranslations } from './i18n/authTranslations';
|
|
4
|
+
import { chartsTranslations } from './i18n/chartsTranslations';
|
|
5
|
+
import { messagingTranslations } from './i18n/messagingTranslations';
|
|
6
|
+
import { notificationsTranslations } from './i18n/notificationsTranslations';
|
|
7
|
+
import { onboardingTranslations } from './i18n/onboardingTranslations';
|
|
8
|
+
import { sectionNavTranslations } from './i18n/sectionNavTranslations';
|
|
9
|
+
import { userMenuTranslations } from './i18n/userMenuTranslations';
|
|
10
|
+
|
|
3
11
|
// --- 0. Theme ---
|
|
4
12
|
export {
|
|
5
13
|
createAppTheme,
|
|
@@ -125,3 +133,13 @@ export { notificationsTranslations } from './i18n/notificationsTranslations';
|
|
|
125
133
|
export { onboardingTranslations } from './i18n/onboardingTranslations';
|
|
126
134
|
export { chartsTranslations } from './i18n/chartsTranslations';
|
|
127
135
|
export { messagingTranslations } from './i18n/messagingTranslations';
|
|
136
|
+
|
|
137
|
+
export const uiCoreTranslations = {
|
|
138
|
+
...authTranslations,
|
|
139
|
+
...chartsTranslations,
|
|
140
|
+
...messagingTranslations,
|
|
141
|
+
...notificationsTranslations,
|
|
142
|
+
...onboardingTranslations,
|
|
143
|
+
...sectionNavTranslations,
|
|
144
|
+
...userMenuTranslations,
|
|
145
|
+
};
|
|
@@ -164,6 +164,20 @@ function SectionNavList({
|
|
|
164
164
|
);
|
|
165
165
|
}
|
|
166
166
|
|
|
167
|
+
/**
|
|
168
|
+
* Render matrix:
|
|
169
|
+
*
|
|
170
|
+
* | `mode` | `open` | `onOpen` | trigger | sidebar | `children` | drawer |
|
|
171
|
+
* |-----------|------------------------|----------|---------|---------|------------------|---------------------|
|
|
172
|
+
* | `desktop` | ignored | ignored | — | yes | yes, in the grid | — |
|
|
173
|
+
* | `mobile` | absent (uncontrolled) | ignored | yes | — | yes | yes, internal state |
|
|
174
|
+
* | `mobile` | given | given | yes | — | yes | caller-driven |
|
|
175
|
+
* | `mobile` | given | absent | no | — | yes | caller-driven |
|
|
176
|
+
*
|
|
177
|
+
* Optional parts: without `overviewItem`, no overview entry or empty Paper is rendered;
|
|
178
|
+
* without `rememberedKey`, no secondary line is rendered; without `title` or
|
|
179
|
+
* `triggerEyebrow`, `t('SectionNav.TITLE')` or `t('SectionNav.TRIGGER_EYEBROW')` is used.
|
|
180
|
+
*/
|
|
167
181
|
export function SectionNav({
|
|
168
182
|
mode,
|
|
169
183
|
groups,
|
|
@@ -188,6 +202,7 @@ export function SectionNav({
|
|
|
188
202
|
const [internalOpen, setInternalOpen] = useState(false);
|
|
189
203
|
const isControlled = open !== undefined;
|
|
190
204
|
const drawerOpen = isControlled ? open : internalOpen;
|
|
205
|
+
const canOpenTrigger = !isControlled || Boolean(onOpen);
|
|
191
206
|
const resolvedTitle = title ?? t('SectionNav.TITLE');
|
|
192
207
|
const resolvedEyebrow = triggerEyebrow ?? t('SectionNav.TRIGGER_EYEBROW');
|
|
193
208
|
const rememberedLabel = t('SectionNav.LAST_OPENED');
|
|
@@ -243,27 +258,29 @@ export function SectionNav({
|
|
|
243
258
|
|
|
244
259
|
return (
|
|
245
260
|
<>
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
<
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
261
|
+
{canOpenTrigger && (
|
|
262
|
+
<ButtonBase
|
|
263
|
+
sx={SECTION_NAV_TRIGGER_SX}
|
|
264
|
+
onClick={handleTriggerClick}
|
|
265
|
+
aria-haspopup="dialog"
|
|
266
|
+
aria-expanded={drawerOpen}
|
|
267
|
+
aria-controls={drawerId}
|
|
268
|
+
>
|
|
269
|
+
<Box sx={SECTION_NAV_TRIGGER_TEXT_SX}>
|
|
270
|
+
<Typography variant="caption" color="text.secondary">
|
|
271
|
+
{resolvedEyebrow}
|
|
272
|
+
</Typography>
|
|
273
|
+
<Typography variant="subtitle1" sx={SECTION_NAV_TRIGGER_VALUE_SX}>
|
|
274
|
+
{activeItem?.label ?? resolvedTitle}
|
|
275
|
+
</Typography>
|
|
276
|
+
</Box>
|
|
277
|
+
<KeyboardArrowDownIcon
|
|
278
|
+
aria-hidden="true"
|
|
279
|
+
sx={SECTION_NAV_CHEVRON_SX}
|
|
280
|
+
style={{ transform: drawerOpen ? 'rotate(180deg)' : 'rotate(0deg)' }}
|
|
281
|
+
/>
|
|
282
|
+
</ButtonBase>
|
|
283
|
+
)}
|
|
267
284
|
|
|
268
285
|
<Box sx={SECTION_NAV_CONTENT_SX}>{children}</Box>
|
|
269
286
|
|
|
@@ -147,6 +147,7 @@ describe('SectionNav', () => {
|
|
|
147
147
|
renderNav({ mode: 'mobile', onSelect });
|
|
148
148
|
|
|
149
149
|
const trigger = screen.getByRole('button', { name: /Section Profile/i });
|
|
150
|
+
expect(trigger).toBeTruthy();
|
|
150
151
|
expect(trigger.getAttribute('aria-expanded')).toBe('false');
|
|
151
152
|
fireEvent.click(trigger);
|
|
152
153
|
expect(trigger.getAttribute('aria-expanded')).toBe('true');
|
|
@@ -164,6 +165,7 @@ describe('SectionNav', () => {
|
|
|
164
165
|
const { rerender } = renderNav({ mode: 'mobile', open: false, onOpen, onClose });
|
|
165
166
|
|
|
166
167
|
const trigger = screen.getByRole('button', { name: /Section Profile/i });
|
|
168
|
+
expect(trigger).toBeTruthy();
|
|
167
169
|
fireEvent.click(trigger);
|
|
168
170
|
|
|
169
171
|
expect(onOpen).toHaveBeenCalledOnce();
|
|
@@ -189,6 +191,53 @@ describe('SectionNav', () => {
|
|
|
189
191
|
expect(screen.getByRole('button', { name: 'Security' })).toBeTruthy();
|
|
190
192
|
});
|
|
191
193
|
|
|
194
|
+
it('omits a dead controlled trigger while keeping children and the caller-driven drawer', () => {
|
|
195
|
+
const { rerender } = renderNav({ mode: 'mobile', open: false });
|
|
196
|
+
|
|
197
|
+
expect(screen.queryByRole('button', { name: /Section Profile/i })).toBeNull();
|
|
198
|
+
expect(screen.getByTestId('section-content')).toBeTruthy();
|
|
199
|
+
expect(screen.queryByRole('dialog', { name: 'Switch section' })).toBeNull();
|
|
200
|
+
|
|
201
|
+
rerender(
|
|
202
|
+
<ThemeProvider theme={theme}>
|
|
203
|
+
<SectionNav
|
|
204
|
+
mode="mobile"
|
|
205
|
+
groups={groups}
|
|
206
|
+
activeKey="profile"
|
|
207
|
+
onSelect={vi.fn()}
|
|
208
|
+
open
|
|
209
|
+
>
|
|
210
|
+
<div data-testid="section-content">Section content</div>
|
|
211
|
+
</SectionNav>
|
|
212
|
+
</ThemeProvider>,
|
|
213
|
+
);
|
|
214
|
+
|
|
215
|
+
expect(screen.queryByRole('button', { name: /Section Profile/i })).toBeNull();
|
|
216
|
+
expect(screen.getByTestId('section-content')).toBeTruthy();
|
|
217
|
+
expect(screen.getByRole('dialog', { name: 'Switch section' })).toBeTruthy();
|
|
218
|
+
expect(screen.getByRole('button', { name: 'Security' })).toBeTruthy();
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
it('keeps the desktop sidebar and children for every open/onOpen combination', () => {
|
|
222
|
+
const propCombinations = [
|
|
223
|
+
{},
|
|
224
|
+
{ open: false, onOpen: vi.fn() },
|
|
225
|
+
{ open: false },
|
|
226
|
+
];
|
|
227
|
+
|
|
228
|
+
propCombinations.forEach((props) => {
|
|
229
|
+
const { container } = renderNav(props);
|
|
230
|
+
|
|
231
|
+
expect(container.querySelector('[data-section-nav-mode="desktop"]')).toBeTruthy();
|
|
232
|
+
expect(container.querySelector('nav')).toBeTruthy();
|
|
233
|
+
expect(screen.getByTestId('section-content')).toBeTruthy();
|
|
234
|
+
expect(screen.queryByRole('button', { name: /Section Profile/i })).toBeNull();
|
|
235
|
+
expect(document.querySelector('.MuiDrawer-root')).toBeNull();
|
|
236
|
+
|
|
237
|
+
cleanup();
|
|
238
|
+
});
|
|
239
|
+
});
|
|
240
|
+
|
|
192
241
|
it('renders the desktop sidebar and children in a two-column grid, while mobile renders a trigger and no sidebar', () => {
|
|
193
242
|
const { container } = renderNav({ sidebarWidth: 312 });
|
|
194
243
|
const desktopRoot = container.querySelector('[data-section-nav-mode="desktop"]');
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
|
|
4
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
5
|
+
|
|
6
|
+
vi.mock('@mui/icons-material', () => ({
|
|
7
|
+
Archive: () => null,
|
|
8
|
+
Campaign: () => null,
|
|
9
|
+
Groups: () => null,
|
|
10
|
+
MoreVert: () => null,
|
|
11
|
+
NotificationsOff: () => null,
|
|
12
|
+
Person: () => null,
|
|
13
|
+
}));
|
|
14
|
+
|
|
15
|
+
import {
|
|
16
|
+
authTranslations as publicAuthTranslations,
|
|
17
|
+
chartsTranslations as publicChartsTranslations,
|
|
18
|
+
messagingTranslations as publicMessagingTranslations,
|
|
19
|
+
notificationsTranslations as publicNotificationsTranslations,
|
|
20
|
+
onboardingTranslations as publicOnboardingTranslations,
|
|
21
|
+
sectionNavTranslations as publicSectionNavTranslations,
|
|
22
|
+
uiCoreTranslations,
|
|
23
|
+
userMenuTranslations as publicUserMenuTranslations,
|
|
24
|
+
} from '../src/index';
|
|
25
|
+
|
|
26
|
+
const I18N_DIRECTORY = path.resolve('src/i18n');
|
|
27
|
+
// Scoped to the three locales every bundle already carries. `messagingTranslations`'s 116 keys
|
|
28
|
+
// have no `sw` value yet (pre-existing gap, out of I18N-1's scope — see the Notiz on the
|
|
29
|
+
// I18N-1 register row for the tracked follow-up to backfill it and restore `sw` here).
|
|
30
|
+
const REQUIRED_LOCALES = ['de', 'en', 'fr'];
|
|
31
|
+
const bundleImporters = import.meta.glob('../src/i18n/*.ts');
|
|
32
|
+
const PUBLIC_BUNDLES = {
|
|
33
|
+
authTranslations: publicAuthTranslations,
|
|
34
|
+
chartsTranslations: publicChartsTranslations,
|
|
35
|
+
messagingTranslations: publicMessagingTranslations,
|
|
36
|
+
notificationsTranslations: publicNotificationsTranslations,
|
|
37
|
+
onboardingTranslations: publicOnboardingTranslations,
|
|
38
|
+
sectionNavTranslations: publicSectionNavTranslations,
|
|
39
|
+
userMenuTranslations: publicUserMenuTranslations,
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
function bundleFileNames() {
|
|
43
|
+
return fs.readdirSync(I18N_DIRECTORY, { withFileTypes: true })
|
|
44
|
+
.filter((entry) => entry.isFile() && entry.name.endsWith('.ts'))
|
|
45
|
+
.map((entry) => entry.name)
|
|
46
|
+
.sort();
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async function loadBundles() {
|
|
50
|
+
return Promise.all(bundleFileNames().map(async (fileName) => {
|
|
51
|
+
const modulePath = `../src/i18n/${fileName}`;
|
|
52
|
+
const importBundle = bundleImporters[modulePath];
|
|
53
|
+
if (!importBundle) {
|
|
54
|
+
throw new Error(`${fileName} was enumerated from src/i18n but has no module importer`);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const bundleModule = await importBundle();
|
|
58
|
+
const translationExports = Object.entries(bundleModule)
|
|
59
|
+
.filter(([exportName]) => exportName.endsWith('Translations'));
|
|
60
|
+
|
|
61
|
+
if (Object.keys(bundleModule).length !== 1 || translationExports.length !== 1) {
|
|
62
|
+
throw new Error(`${fileName} must export exactly one *Translations bundle`);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const [[exportName, translations]] = translationExports;
|
|
66
|
+
return { exportName, fileName, translations };
|
|
67
|
+
}));
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
describe('uiCoreTranslations', () => {
|
|
71
|
+
it('contains every directory-enumerated bundle key with its original value', async () => {
|
|
72
|
+
const bundles = await loadBundles();
|
|
73
|
+
const sourceKeys = [];
|
|
74
|
+
|
|
75
|
+
for (const { fileName, translations } of bundles) {
|
|
76
|
+
for (const [key, value] of Object.entries(translations)) {
|
|
77
|
+
sourceKeys.push(key);
|
|
78
|
+
expect(
|
|
79
|
+
Object.hasOwn(uiCoreTranslations, key),
|
|
80
|
+
`${key} from ${fileName} is missing from the public aggregate`,
|
|
81
|
+
).toBe(true);
|
|
82
|
+
expect(uiCoreTranslations[key]).toEqual(value);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
expect(Object.keys(uiCoreTranslations).sort()).toEqual(sourceKeys.sort());
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('does not allow duplicate keys across source bundles', async () => {
|
|
90
|
+
const owners = new Map();
|
|
91
|
+
const collisions = [];
|
|
92
|
+
|
|
93
|
+
for (const { fileName, translations } of await loadBundles()) {
|
|
94
|
+
for (const key of Object.keys(translations)) {
|
|
95
|
+
if (owners.has(key)) {
|
|
96
|
+
collisions.push(`${key}: ${owners.get(key)} and ${fileName}`);
|
|
97
|
+
} else {
|
|
98
|
+
owners.set(key, fileName);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
expect(collisions, `Translation key collisions:\n${collisions.join('\n')}`).toEqual([]);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it('preserves at least the required locale shape for every aggregate value', () => {
|
|
107
|
+
for (const [key, value] of Object.entries(uiCoreTranslations)) {
|
|
108
|
+
for (const locale of REQUIRED_LOCALES) {
|
|
109
|
+
expect(typeof value[locale], `${key}.${locale} must be a string`).toBe('string');
|
|
110
|
+
expect(value[locale].trim(), `${key}.${locale} must not be empty`).not.toBe('');
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it('keeps every per-feature bundle available unchanged from the package root', async () => {
|
|
116
|
+
for (const { exportName, translations } of await loadBundles()) {
|
|
117
|
+
expect(PUBLIC_BUNDLES[exportName], `${exportName} must remain a public export`)
|
|
118
|
+
.toBe(translations);
|
|
119
|
+
}
|
|
120
|
+
});
|
|
121
|
+
});
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
# SHELL-6 — A controlled `SectionNav` with no way to open must not render a trigger
|
|
2
|
+
|
|
3
|
+
**Target repo:** `ui-core-micha` (branch `main` — this repo has no `develop`)
|
|
4
|
+
**Tier:** 3 (shared core)
|
|
5
|
+
**Review:** independent `reviewer` **and** `ui_reviewer` — both mandatory, concurrent
|
|
6
|
+
**Version target:** the next **patch** after the published version. A defect fix, no API change, no
|
|
7
|
+
consumer relying on the current behaviour. Read the published version at implementation time.
|
|
8
|
+
**Unblocks:** `jg-ferien/NAV-37`, currently `blocked` (`c627d7b`)
|
|
9
|
+
**Follows:** `SHELL-5` (`2.36.0`)
|
|
10
|
+
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
## A. Envelope
|
|
14
|
+
|
|
15
|
+
### Goal
|
|
16
|
+
|
|
17
|
+
In `mode="mobile"`, `SectionNav` renders its built-in trigger **unconditionally**
|
|
18
|
+
(`src/layout/SectionNav.jsx:246`). In controlled mode `handleTriggerClick` calls `onOpen?.()`
|
|
19
|
+
(`:200-206`), so a consumer that drives `open` itself and passes no `onOpen` gets a button that is
|
|
20
|
+
**visible, focusable and does nothing**.
|
|
21
|
+
|
|
22
|
+
Make the trigger render only when it can actually open the drawer.
|
|
23
|
+
|
|
24
|
+
### This is a defect, not a missing configuration
|
|
25
|
+
|
|
26
|
+
A control that receives focus and performs no action is wrong on its own terms — it is an
|
|
27
|
+
accessibility defect independent of any consumer's wishes. That is why the fix is the **semantics**
|
|
28
|
+
rather than a `showTrigger` prop: a prop would make the dead button the default for every controlled
|
|
29
|
+
consumer who has not heard of the prop, turning a defect into a configuration option.
|
|
30
|
+
|
|
31
|
+
**Nothing today relies on the current behaviour.** `AccountPage`, the only shipped consumer, is
|
|
32
|
+
**uncontrolled** (it passes no `open`), so its trigger keeps rendering unchanged. `jg-ferien` is the
|
|
33
|
+
consumer that hit this and is blocked on it.
|
|
34
|
+
|
|
35
|
+
### How it shipped, and the rule that follows
|
|
36
|
+
|
|
37
|
+
`SHELL-5`'s Envelope specified that the component *has* a trigger and never said *when it renders*.
|
|
38
|
+
The implementation made it unconditional — correctly, given the spec. `NAV-37` then assumed that
|
|
39
|
+
omitting `onOpen` suppresses it; that assumption appears nowhere in `SHELL-5`.
|
|
40
|
+
|
|
41
|
+
**The uncovered cell and the untested cell were the same cell.** Every existing mobile test is either
|
|
42
|
+
uncontrolled (`:192`, `:210`) or controlled *with* `onOpen` (`:161`) — verified. So this fix breaks no
|
|
43
|
+
existing test, which is the point: the case was never specified, therefore never tested, therefore
|
|
44
|
+
shipped.
|
|
45
|
+
|
|
46
|
+
**Hence the second deliverable below: write the render matrix down.** A prop table plus a scope
|
|
47
|
+
narrative reads complete and is not.
|
|
48
|
+
|
|
49
|
+
### Scope
|
|
50
|
+
|
|
51
|
+
**1. The fix.** In the mobile return path, render the `<ButtonBase>` trigger only when a click can do
|
|
52
|
+
something: uncontrolled (the component owns `internalOpen`), or controlled **and** `onOpen` supplied.
|
|
53
|
+
`children` and the `<Drawer>` keep rendering in every case — `jg-ferien` needs exactly that: its own
|
|
54
|
+
triggers, the shared drawer, its content.
|
|
55
|
+
|
|
56
|
+
**2. The render matrix, in the component's own doc comment.** This is not optional documentation; it
|
|
57
|
+
is the artefact whose absence caused the defect.
|
|
58
|
+
|
|
59
|
+
| `mode` | `open` | `onOpen` | trigger | sidebar | `children` | drawer |
|
|
60
|
+
|---|---|---|---|---|---|---|
|
|
61
|
+
| `desktop` | ignored | ignored | — | yes | yes, in the grid | — |
|
|
62
|
+
| `mobile` | absent (uncontrolled) | ignored | **yes** | — | yes | yes, internal state |
|
|
63
|
+
| `mobile` | given | given | **yes** | — | yes | caller-driven |
|
|
64
|
+
| `mobile` | given | **absent** | **no** ← this WO | — | yes | caller-driven |
|
|
65
|
+
|
|
66
|
+
Plus the optional-part cells, all already correct and worth recording so they stay that way:
|
|
67
|
+
`overviewItem` absent → no overview entry and no empty `Paper`; `rememberedKey` absent → no secondary
|
|
68
|
+
line; `title` absent → `t('SectionNav.TITLE')`; `triggerEyebrow` absent → `t('SectionNav.TRIGGER_EYEBROW')`.
|
|
69
|
+
|
|
70
|
+
### Non-goals / do not touch
|
|
71
|
+
|
|
72
|
+
- **No new prop.** Not `showTrigger`, not `hideTrigger`. The operator considered and rejected both:
|
|
73
|
+
they would leave the dead button as the default. A consumer wanting "controlled **with** `onOpen` but
|
|
74
|
+
my own trigger too" has no representation after this WO — that case has no consumer today and adding
|
|
75
|
+
a prop for it is the speculative generality the YAGNI guardrail rejects.
|
|
76
|
+
- **The desktop path** — untouched, including the grid, `sidebarWidth` and `headerOffset`.
|
|
77
|
+
- The drawer, its `zIndex` resolution, its safe-area padding, `SectionNavList`, and every label or
|
|
78
|
+
i18n key.
|
|
79
|
+
- `AccountPage` — no change; it is uncontrolled and keeps its trigger.
|
|
80
|
+
- No baseline token change, no `kitSxRegistry` change. `SECTION_NAV_TRIGGER_SX` stays registered:
|
|
81
|
+
`THEME-4`'s check is about key disjointness, not about whether an element renders.
|
|
82
|
+
|
|
83
|
+
### Risks
|
|
84
|
+
|
|
85
|
+
- **It is a behaviour change to a shipped component**, which is why "no consumer relies on it" is
|
|
86
|
+
stated above rather than assumed. If a consumer outside this repo passes `open` without `onOpen` and
|
|
87
|
+
wants the trigger, it breaks for them — verified as nobody today.
|
|
88
|
+
- **No staging net.** A version bump on `main` publishes; the two reviews are the only gate.
|
|
89
|
+
- **The condition must be `onOpen` supplied, not `open` supplied.** Keying it on the wrong prop would
|
|
90
|
+
remove the trigger from the uncontrolled case as well, which is `AccountPage`'s only affordance.
|
|
91
|
+
Test 3 exists for that.
|
|
92
|
+
|
|
93
|
+
### Required tests to WRITE
|
|
94
|
+
|
|
95
|
+
Extend `tests/SectionNav.test.jsx`.
|
|
96
|
+
|
|
97
|
+
1. **The fix, with non-vacuity.** `mode="mobile"` + `open` + **no** `onOpen` → **no** trigger button
|
|
98
|
+
in the DOM, while `children` still render and the drawer still responds to `open`. **This test must
|
|
99
|
+
fail against the current published version** — say so in the WO's landing note, since a fix whose
|
|
100
|
+
test passes before the fix is not a fix.
|
|
101
|
+
2. Controlled **with** `onOpen` → trigger renders, clicking calls `onOpen`. (The existing `:161`
|
|
102
|
+
already covers the call; assert the rendering explicitly.)
|
|
103
|
+
3. **Uncontrolled** → trigger renders and clicking opens the internal drawer. This is the guard against
|
|
104
|
+
keying the condition on the wrong prop.
|
|
105
|
+
4. Desktop is unchanged in all three prop combinations.
|
|
106
|
+
5. `THEME-4`'s shadowing check still returns no findings.
|
|
107
|
+
|
|
108
|
+
Plus `tsc -p tsconfig.build.json --noEmit` clean. **No full-suite run** — affected set is
|
|
109
|
+
`tests/SectionNav.test.jsx` and the theme spec touched by test 5.
|
|
110
|
+
|
|
111
|
+
### Verification
|
|
112
|
+
|
|
113
|
+
The change is the **absence** of an element in one prop combination, so the rendered gate has little to
|
|
114
|
+
add beyond the tests: assert the DOM, and confirm `AccountPage`'s own trigger still renders at 375 px.
|
|
115
|
+
If capture is unavailable, DOM inspection is the declared substitution — and here it is not a
|
|
116
|
+
fallback but the appropriate instrument, because "no button" is a DOM fact, not an appearance.
|
|
117
|
+
|
|
118
|
+
### Parity guardrail
|
|
119
|
+
|
|
120
|
+
`AccountPage` renders identically. The only intended change is that a controlled consumer without
|
|
121
|
+
`onOpen` no longer gets a dead button.
|
|
122
|
+
|
|
123
|
+
---
|
|
124
|
+
|
|
125
|
+
## B. Implementation map
|
|
126
|
+
|
|
127
|
+
*Filled by the Orchestrator on `git pull` — see `AGENTS.md` → "Work Order".*
|
|
128
|
+
|
|
129
|
+
### Execution directive (read this first)
|
|
130
|
+
|
|
131
|
+
> **If you are the implementer reading this work order as your own specification: this section is
|
|
132
|
+
> NOT addressed to you.** It tells the Orchestrator how to invoke you. **You ARE that invocation —
|
|
133
|
+
> do NOT shell out to `codex exec`.**
|
|
134
|
+
>
|
|
135
|
+
> Implement through `codex exec` in the background — invoked directly via Bash (never the
|
|
136
|
+
> `debugger`/`*_coder` Agent wrappers) with BOTH flags `--skip-git-repo-check` and
|
|
137
|
+
> `--dangerously-bypass-approvals-and-sandbox`. **Pass the WO via stdin, not as a positional
|
|
138
|
+
> argument.** Fallback to direct Claude implementation only on Codex quota/rate-limit/non-zero exit —
|
|
139
|
+
> and that flips authorship, so both reviewers stay mandatory either way.
|
|
140
|
+
>
|
|
141
|
+
> **Do NOT edit `WORK_ORDERS.md`**, and before your own review read `git log origin/main..HEAD` plus
|
|
142
|
+
> `git status`. Check the working tree first: `NAV-37`'s rejected CSS workaround lives uncommitted in
|
|
143
|
+
> `jg-ferien`, and other sessions have been active in this repo throughout this strand.
|
|
144
|
+
|
|
145
|
+
### Context package
|
|
146
|
+
|
|
147
|
+
**Precondition confirmed:** `SHELL-5` landed and published (`b283c33`/`ada72be`); `I18N-1` also landed
|
|
148
|
+
independently in the interim (`a6d322d`). Clean starting tree, no implementer commit on top.
|
|
149
|
+
|
|
150
|
+
**Current file state** (`src/layout/SectionNav.jsx`, verified — line numbers drifted a little from
|
|
151
|
+
the WO's own `:246`/`:200-206` estimate since `SHELL-5`'s own review-fix pass, but the structure is
|
|
152
|
+
identical):
|
|
153
|
+
```js
|
|
154
|
+
// :188-190
|
|
155
|
+
const [internalOpen, setInternalOpen] = useState(false);
|
|
156
|
+
const isControlled = open !== undefined;
|
|
157
|
+
const drawerOpen = isControlled ? open : internalOpen;
|
|
158
|
+
// :200-206
|
|
159
|
+
const handleTriggerClick = () => {
|
|
160
|
+
if (isControlled) {
|
|
161
|
+
onOpen?.();
|
|
162
|
+
} else {
|
|
163
|
+
setInternalOpen(true);
|
|
164
|
+
}
|
|
165
|
+
};
|
|
166
|
+
// :244-266, the mobile return path
|
|
167
|
+
return (
|
|
168
|
+
<>
|
|
169
|
+
<ButtonBase sx={SECTION_NAV_TRIGGER_SX} onClick={handleTriggerClick} ...>
|
|
170
|
+
...
|
|
171
|
+
</ButtonBase>
|
|
172
|
+
<Box sx={SECTION_NAV_CONTENT_SX}>{children}</Box>
|
|
173
|
+
<Drawer ...>...</Drawer>
|
|
174
|
+
</>
|
|
175
|
+
);
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
**The fix, precisely:** add `const canOpenTrigger = !isControlled || Boolean(onOpen);` and wrap only
|
|
179
|
+
the `<ButtonBase>` block in `{canOpenTrigger && (...)}`. `children` and `<Drawer>` stay unconditional
|
|
180
|
+
— do not touch their rendering. Do not key the condition on `open` (the WO's own Risks section flags
|
|
181
|
+
this exact mistake as the guard test 3 exists for) — the condition is about whether a click can do
|
|
182
|
+
anything, which depends on `isControlled`/`onOpen`, never on the current `open` value itself.
|
|
183
|
+
|
|
184
|
+
**The render-matrix doc comment (scope item 2):** add a JSDoc-style comment directly above
|
|
185
|
+
`export function SectionNav(...)` reproducing the table from the Envelope (`mode`/`open`/`onOpen` →
|
|
186
|
+
trigger/sidebar/children/drawer), plus the "optional-part cells" paragraph (`overviewItem` absent →
|
|
187
|
+
no overview entry; `rememberedKey` absent → no secondary line; `title`/`triggerEyebrow` absent →
|
|
188
|
+
their `t()` fallbacks). This is a required deliverable per the WO's own framing ("not optional
|
|
189
|
+
documentation; it is the artefact whose absence caused the defect"), not a nice-to-have.
|
|
190
|
+
|
|
191
|
+
**Test file:** `tests/SectionNav.test.jsx` already has a controlled-mode test from `SHELL-5`'s own
|
|
192
|
+
review-fix round (`'defers to the caller in controlled mode: the trigger calls onOpen, not internal
|
|
193
|
+
state'`, roughly `:145-175` now) — that test passes `onOpen`, so it exercises the WO's "controlled
|
|
194
|
+
with `onOpen`" cell, not the new "controlled without `onOpen`" cell this WO fixes. Add the four new
|
|
195
|
+
tests as siblings of it, following its existing render helper (`renderNav`) and prop-passing style.
|
|
196
|
+
Required test 1 (non-vacuity) needs to be run against a build BEFORE the fix lands to confirm it
|
|
197
|
+
fails — since this is a live working tree with the pre-fix code already checked out, this is simply:
|
|
198
|
+
write the test first, run it once to confirm it fails, then apply the one-line fix and confirm it
|
|
199
|
+
passes. State this sequence happened in the register note.
|
|
200
|
+
|
|
201
|
+
**Invariants:** no new prop (`showTrigger`/`hideTrigger` explicitly rejected by the WO); desktop path,
|
|
202
|
+
`Drawer`, `zIndex`, safe-area padding, `SectionNavList`, all i18n keys, `AccountPage` itself, and
|
|
203
|
+
`kitSxRegistry.js` all untouched. `SECTION_NAV_TRIGGER_SX` stays registered even though the element
|
|
204
|
+
it styles now sometimes doesn't render — `THEME-4`'s check is about key disjointness, not element
|
|
205
|
+
presence, so this is a non-issue, not a discrepancy to resolve.
|
|
206
|
+
|
|
207
|
+
### Mini-handover
|
|
208
|
+
|
|
209
|
+
Repo: `ui-core-micha` (`C:\Users\biglmi\Documents\webapps\ui-core-micha`), branch `main`.
|
|
210
|
+
WO: `work-orders/SHELL-6.md`. Unblocks `jg-ferien/NAV-37`. Follow `orchestrate-codex`.
|