Skip to content
This repository was archived by the owner on Oct 26, 2026. It is now read-only.

Commit 7728c67

Browse files
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

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
---
2+
'@object-ui/layout': patch
3+
---
4+
5+
fix(layout): the mobile tab bar draws its tabs in the sidebar's order, and shows an entry's badge
6+
7+
`AppSchemaRenderer` with `mobileNavMode: 'bottom_nav'` drew its tab bar in authored
8+
position and never read `order`. Two entries written `Zeta` (`order: 2`) then
9+
`Ypsilon` (`order: 1`) came out `Ypsilon, Zeta` in the sidebar and `Zeta, Ypsilon` on
10+
the bar. With more than five entries, the five-tab cap kept the first five by authored
11+
position rather than the five the sidebar lists first.
12+
13+
The bar now sorts each level with the sidebar's own comparator before it flattens:
14+
first the top level, then each group's children. The guard and the five-tab cap run
15+
after the sort, so the five tabs are the first five entries the sidebar draws. The
16+
comparator lives in one internal module, and the sidebar's two sort sites use it too.
17+
It is not exported, so the package's public API does not change. An entry with no
18+
`order` still sorts as `0`, and entries with equal `order` keep their authored order,
19+
as in the sidebar before.
20+
21+
The bar also dropped an entry's `badge` and `badgeVariant`. It now draws them on the
22+
tab with the same `Badge` component and the same variant the sidebar row uses.

‎packages/layout/src/AppSchemaRenderer.tsx‎

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ import {
3434
SidebarGroupLabel,
3535
SidebarGroupContent,
3636
SidebarInput,
37+
Badge,
3738
} from '@object-ui/components';
3839
import type { AppComponentSchema, NavigationItem, NavigationEntryItem, NavigationArea } from '@object-ui/types';
3940
import { menuItemToNavigationItem } from '@object-ui/types';
@@ -55,6 +56,8 @@ import {
5556
type CapabilityChecker,
5657
type DocTargetChecker,
5758
} from './NavigationRenderer';
59+
// Internal module, not re-exported by `index.ts` (objectui#11395).
60+
import { byNavOrder } from './navOrder';
5861

5962
// ---------------------------------------------------------------------------
6063
// Types
@@ -296,9 +299,16 @@ function MobileBottomNav({
296299
// wired. A gated group therefore takes its children with it, as it does in
297300
// the sidebar, and the guard runs before the five-tab cap, so a hidden entry
298301
// never takes a slot. ⛔ No copy of the guard here.
302+
//
303+
// objectui#11395: each level is sorted by `byNavOrder` before it is walked —
304+
// the top level, then each group's children as the walk reaches them — which
305+
// is the sidebar's one comparator at the sidebar's two sort sites. The tabs
306+
// therefore come out in the sidebar's reading order, and the guard and the cap
307+
// run after the sort, so the five tabs are the sidebar's first five drawn
308+
// entries. ⛔ No copy of the sort here.
299309
const collectLeaves = (list: NavigationItem[]): NavigationEntryItem[] => {
300310
const out: NavigationEntryItem[] = [];
301-
for (const item of list) {
311+
for (const item of list.slice().sort(byNavOrder)) {
302312
if (!hasVisibleNavigationItems([item], guards)) continue;
303313
if (item.type === 'separator') continue;
304314
if (item.type === 'group') {
@@ -333,7 +343,7 @@ function MobileBottomNav({
333343
{leaves.map((item) => {
334344
const NavIcon = resolveIcon(item.icon);
335345
const { href, external } = resolveHref(item, basePath);
336-
const className = `flex flex-col items-center gap-0.5 px-2 py-1.5 transition-colors min-w-[44px] min-h-[44px] justify-center ${
346+
const className = `relative flex flex-col items-center gap-0.5 px-2 py-1.5 transition-colors min-w-[44px] min-h-[44px] justify-center ${
337347
item.id === activeId ? 'text-primary' : 'text-muted-foreground hover:text-foreground'
338348
}`;
339349
const content = (
@@ -343,6 +353,18 @@ function MobileBottomNav({
343353
absent since objectui#9868, and this host has no metadata, so it shows the
344354
same machine-name backstop its `NavigationRenderer` does. */}
345355
<span className="text-[10px] truncate max-w-[60px]">{resolveNavItemLabel(item)}</span>
356+
{/* objectui#11395: the spec-declared `badge` and `badgeVariant`, drawn when the
357+
sidebar row draws them, with the same `Badge` and the same variant (an absent
358+
`badgeVariant` is `Badge`'s own default, which is the sidebar's). Placed over
359+
the icon's corner; last in the DOM so the tab reads in the sidebar row's order. */}
360+
{item.badge != null && (
361+
<Badge
362+
variant={item.badgeVariant}
363+
className="absolute top-0.5 left-1/2 ml-1.5 px-1 py-0 text-[9px] leading-tight"
364+
>
365+
{item.badge}
366+
</Badge>
367+
)}
346368
</>
347369
);
348370

‎packages/layout/src/NavigationRenderer.tsx‎

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,9 @@ import type { NavigationItem, KeyedI18nLabel } from '@object-ui/types';
7171
// the spec's resolver reads the INLINE locale map — neither accepts the other's
7272
// shape.
7373
import { resolveI18nLabel as resolveInlineI18nLabel } from '@objectstack/spec/ui';
74+
// Internal module, not re-exported by `index.ts` (objectui#11395). ⛔ Do not
75+
// re-export `byNavOrder` from this file: `index.ts` re-exports it whole.
76+
import { byNavOrder } from './navOrder';
7477

7578
// ---------------------------------------------------------------------------
7679
// Types
@@ -1275,9 +1278,7 @@ function NavigationItemRenderer({
12751278

12761279
// --- Group (collapsible) ---
12771280
if (item.type === 'group') {
1278-
const children = (item.children ?? [])
1279-
.slice()
1280-
.sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
1281+
const children = (item.children ?? []).slice().sort(byNavOrder);
12811282

12821283
// A group survives only through its children (`73a3c89af`). Without
12831284
// this the group's own label rendered as a disclosure that opens onto
@@ -1546,8 +1547,8 @@ export function NavigationRenderer({
15461547
[filteredItems, evalVis, checkPerm, checkCap, checkDocTarget],
15471548
);
15481549

1549-
// --- Sort top-level items by order ---
1550-
const sorted = filteredItems.slice().sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
1550+
// --- Sort top-level items by order --- (the one comparator the tab bar uses too, objectui#11395)
1551+
const sorted = filteredItems.slice().sort(byNavOrder);
15511552

15521553
// --- Drag-reorder sensors ---
15531554
const sensors = useSensors(

0 commit comments

Comments
 (0)