This repository was archived by the owner on Oct 26, 2026. It is now read-only.
Repository navigation
Commit 7728c67
fix(layout): the mobile tab bar draws its tabs in the sidebar's order and shows an entry's badge (objectui#11395) (#11401)
Fixes #11395
Clause-②: no
## What changes
`AppSchemaRenderer` with `mobileNavMode: 'bottom_nav'` drew its tab bar
in authored position and never read `order`. Two entries written `Zeta`
(`order: 2`) then `Ypsilon` (`order: 1`) came out `Ypsilon, Zeta` in the
sidebar and `Zeta, Ypsilon` on the bar. With more than five entries, the
five-tab cap kept the wrong five. The bar now sorts each level with the
sidebar's own comparator before it flattens. It also draws an entry's
`badge` / `badgeVariant`, which it used to drop.
- New internal module `packages/layout/src/navOrder.ts` exports
`byNavOrder`. It is the rule the sidebar already used: `(a.order ?? 0) -
(b.order ?? 0)`. `index.ts` does not re-export the module.
- `NavigationRenderer.tsx`: the two sort sites import it (the top level
in `NavigationRenderer`, and each group's children in
`NavigationItemRenderer`). The only other change in the file is that
import.
- `AppSchemaRenderer.tsx`: `MobileBottomNav`'s `collectLeaves` sorts
each level with `byNavOrder` before it walks that level. The guard and
the five-tab cap then run in the sidebar's reading order. Each tab also
draws `badge` with the same `Badge` primitive and the same variant as
its sidebar row.
- Pins: `AppSchemaRenderer.mobileTabOrder-11395.test.tsx` (13 tests,
through the real `AppSchemaRenderer`).
- `.changeset/11395-mobile-tab-bar-order.md`: `@object-ui/layout` patch.
## Premises checked first (the dispatch's Zone 2)
1. **Reproduced.** At base `d59f11c0`, before the fix, the real
`AppSchemaRenderer` drew `Zeta, Ypsilon` on the bar and `Ypsilon, Zeta`
in the sidebar. All four ordering pins failed on their bar assertion.
The sidebar assertion before each one passed, and so did the
authored-order control.
2. **The two sort sites were identical:** both did `.slice().sort(...)`
with the comparator body `(a.order ?? 0) - (b.order ?? 0)`. The shared
comparator takes that exact rule, so the sidebar's order does not
change. The edges pin covers a missing `order` (it reads as 0) and a tie
(authored order is kept). Its sidebar half passed with the old inline
comparator and still passes with the extracted one.
3. **The enumeration:** see the table below.
4. **Checked in the built `dist`:** `exports` holds only `"."`. The
`export { ... }` statement of `dist/index.js` lists 17 names, none of
them the comparator. `dist/index.d.ts`, `dist/NavigationRenderer.d.ts`
and `dist/AppSchemaRenderer.d.ts` do not mention `navOrder` or
`byNavOrder`. One nuance: the per-file declaration emit also writes a
standalone `dist/navOrder.d.ts`. It ships inside `dist`, but nothing in
the entry graph reaches it, and the `exports` map blocks a direct import
of it.
## The enumeration: every per-entry input the sidebar reads
| input | verdict | pinned in |
|:--|:--|:--|
| `order` | shared: `byNavOrder` (this PR) | this PR's test file |
| the guard: `visible`, `requiredPermissions`, `requiresObject` /
`requiresService`, a `doc` entry's target, the action dispatcher |
shared: `hasVisibleNavigationItems` |
`AppSchemaRenderer.mobileTabGuard-11362.test.tsx` |
| `type: 'action'` | shared: drawn as a button that hands the item to
`onAction` | same file |
| href, `external`, the lit tab | shared: `resolveHref` /
`resolveActiveNavItem`. Inside `AppSchemaRenderer`, neither surface gets
a template context | `AppSchemaRenderer.mobileTabHref-11211.test.tsx` |
| label | shared: `resolveNavItemLabel`, with the same absent `t`,
target-label resolver and locale | this PR's test file |
| icon | shared: `resolveIcon` | this PR's test file |
| `badge` / `badgeVariant` | **divergent, repaired here:** drawn with
the same `Badge` and the same variant | this PR's test file |
| drag-reorder | shared through `order`: the sidebar passes the new
positions to the host's `onReorder` as `order` values | follows from the
order pins |
| `type: 'separator'` | bar-specific: a rule between rows, not a
destination, so no tab and no slot | this PR's test file |
| `type: 'group'` | bar-specific: a disclosure, not a destination. Its
label is not a tab, its children are | this PR's test file |
| `expanded` / `defaultOpen`, and the auto-collapse of long groups |
bar-specific: this is the sidebar's open state for a disclosure the bar
does not have. A collapsed group's children stay reachable in the
sidebar, so they stay tabs | this PR's test file |
| `pinned` with `enablePinning` | bar-specific: Favorites is a second
path to entries already in the tree. On a five-slot bar it would spend
two slots on one destination, so the bar draws each entry once, in its
tree position | this PR's test file |
| the sidebar's search box (`enableSearch`) | bar-specific: it narrows
the sidebar while the user types in it. The bar has no search box | this
PR's test file |
The badge is the one member the enumeration found divergent. A real
producer exists: objectstack's
`examples/app-showcase/src/ui/apps/index.ts` writes `badge: 'NEW',
badgeVariant: 'secondary'` on a nav entry. The repair touches only
`AppSchemaRenderer.tsx`. The bar reads the two declared keys with the
same `Badge` primitive. When `badgeVariant` is absent, `Badge` uses its
own default variant, `default`, which is also the sidebar's default. The
badge comes last in the tab's DOM, so the tab's accessible name reads in
the same order as the sidebar row.
The enumeration was derived once, by reading `NavigationRenderer` and
`NavigationItemRenderer`. Nothing re-derives it, and the test file's
header says so.
## Ablation
Run from committed state (`b57bad22`, whose layout files match HEAD byte
for byte), using objectstack's `scripts/ablation-replace.mjs` in wrap
mode with its restore:
- **Leg A, the bar's sort removed** (anchor `for (const item of
list.slice().sort(byNavOrder)) {` replaced by `for (const item of list)
{`; anchor count 1 to 0; blob `58003377` to `4a828954`): **4 failed, 9
passed.** The four are the card's table, the six-entry case, the edges
case and the grouped case. The authored-order control and every
enumeration pin stay green. Restore verified: the blob equals HEAD's and
`git diff HEAD` is empty.
- **Leg B, the bar's badge removed** (anchor `{item.badge != null && (`;
blob `58003377` to `7566a633`): **1 failed, 12 passed.** The failure is
the badge pin (`expected undefined to be 'secondary'`). Restore verified
the same way.
- The pins import the component from `src` with relative imports, so
there is no build or `dist` leg.
## Tests and gates, at HEAD `5555b94d` (after merging main `6e9c8d27`)
- `pnpm --filter '@object-ui/layout^...' build`: exit 0 (9 projects).
- `pnpm --filter @object-ui/layout build`: exit 0. `type-check`: exit 0.
The test tsconfig's file list includes the new test file.
- `pnpm exec vitest run packages/layout/`: 34 files, 341 tests passed.
- Every suite outside layout that imports `AppSchemaRenderer`,
`NavigationRenderer` or `@object-ui/layout` (found with `git grep`,
app-shell and types included): 27 files, 893 tests passed.
- `pnpm --filter @object-ui/layout lint`: exit 0 with 0 errors. The two
edited source files show the same warnings as their base blobs, rule by
rule, and the two new files show none. Type-aware linting is off for all
four files.
- All exit 0: `check:new-line-citations` (0 new), `check:control-bytes`,
`check-changeset-presence`, `changeset:check`,
`check-changeset-overwrite`, `check-changeset-fixed`,
`check:self-import`, `check:phantom-deps`, `check-type-check-coverage`,
`check:test-path-roots`, `check-vi-mock-override-shape`,
`check-vi-mock-specifiers`, `check:esm-specifiers`,
`check:side-effects-array`, `check:published-tsconfig-exclude`, and
`check:published-dist` (39 packages, 0 tooling artifacts).
- `check-governed-queue-guard --test` on the five paths: NOT GOVERNED.
- NOT MEASURED: `check:unreferenced-sources` exits 0, but it covers only
`packages/components`, so it says nothing about the new layout module.
The repo-wide `pnpm lint` and the full `pnpm test` belong to CI.
## Acceptance notes
- `dist/navOrder.d.ts` is emitted standalone (vite-plugin-dts writes one
declaration per file) and ships inside `dist`. Nothing in the entry
graph reaches it, and the `exports` map has only `"."`. Recorded here,
not filed.
- `check:unreferenced-sources` does not cover `@object-ui/layout`: its
`COVERED_PACKAGES` names only `packages/components`. Recorded here, not
filed.
- No doc page describes the bar's order or badge, so there was nothing
in the docs to correct.
- `AppSchemaRenderer` still gives neither surface a `t`, a target-label
resolver or a locale, so the two surfaces keep agreeing on labels. This
PR leaves that as it was.
Session: `https://claude.ai/code/session_01JG2jy8a9su7ia4Hx7zxv42`
---
_Generated by [Claude
Code](https://claude.ai/code/session_01JG2jy8a9su7ia4Hx7zxv42)_
---------
Co-authored-by: Claude <noreply@anthropic.com>1 parent 6aa029b commit 7728c67
5 files changed
Lines changed: 412 additions & 7 deletions
File tree
- .changeset
- packages/layout/src
- __tests__
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
34 | 34 | | |
35 | 35 | | |
36 | 36 | | |
| 37 | + | |
37 | 38 | | |
38 | 39 | | |
39 | 40 | | |
| |||
55 | 56 | | |
56 | 57 | | |
57 | 58 | | |
| 59 | + | |
| 60 | + | |
58 | 61 | | |
59 | 62 | | |
60 | 63 | | |
| |||
296 | 299 | | |
297 | 300 | | |
298 | 301 | | |
| 302 | + | |
| 303 | + | |
| 304 | + | |
| 305 | + | |
| 306 | + | |
| 307 | + | |
| 308 | + | |
299 | 309 | | |
300 | 310 | | |
301 | | - | |
| 311 | + | |
302 | 312 | | |
303 | 313 | | |
304 | 314 | | |
| |||
333 | 343 | | |
334 | 344 | | |
335 | 345 | | |
336 | | - | |
| 346 | + | |
337 | 347 | | |
338 | 348 | | |
339 | 349 | | |
| |||
343 | 353 | | |
344 | 354 | | |
345 | 355 | | |
| 356 | + | |
| 357 | + | |
| 358 | + | |
| 359 | + | |
| 360 | + | |
| 361 | + | |
| 362 | + | |
| 363 | + | |
| 364 | + | |
| 365 | + | |
| 366 | + | |
| 367 | + | |
346 | 368 | | |
347 | 369 | | |
348 | 370 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
71 | 71 | | |
72 | 72 | | |
73 | 73 | | |
| 74 | + | |
| 75 | + | |
| 76 | + | |
74 | 77 | | |
75 | 78 | | |
76 | 79 | | |
| |||
1275 | 1278 | | |
1276 | 1279 | | |
1277 | 1280 | | |
1278 | | - | |
1279 | | - | |
1280 | | - | |
| 1281 | + | |
1281 | 1282 | | |
1282 | 1283 | | |
1283 | 1284 | | |
| |||
1546 | 1547 | | |
1547 | 1548 | | |
1548 | 1549 | | |
1549 | | - | |
1550 | | - | |
| 1550 | + | |
| 1551 | + | |
1551 | 1552 | | |
1552 | 1553 | | |
1553 | 1554 | | |
| |||
0 commit comments