Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions .changeset/area-visibility-derived-from-items.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
---
"@object-ui/layout": minor
---

`AppSchemaRenderer` now derives area visibility from the items inside the
area, closing the visible-but-empty regression the spec 17.0.0 area-key
retirement left behind (objectui#3311, option C of the recorded ruling).

Spec 17.0.0 retired the authorable area-level `visible` /
`requiredPermissions` (`AREA_VISIBLE_RETIRED` /
`AREA_REQUIRED_PERMISSIONS_RETIRED`) — an area is a layout grouping, not an
access boundary — and objectui followed in #3315 by deleting the area
switcher's filter. Correct on the contract, but it changed the navigation
surface: an area whose items are **all** gated away used to disappear from
the switcher and instead rendered as a selectable, empty area.

## What changed

- **Area visibility is now derived, not authored.** An area appears in the
switcher iff at least one of its navigation items survives the exact
item-level guards `NavigationRenderer` applies: the `visible` expression,
`requiredPermissions`, the `requiresObject` / `requiresService` runtime
capability gates, and — for `action` items — the presence of an `onAction`
dispatcher (framework#4509: without one they are not rendered, so they
cannot carry an area either). Separators never count; a `group` counts only
through its children.
- **The active area is elected among visible areas only.** A fully gated
first area is no longer auto-activated, and when a gating change hides the
currently active area the shell re-elects the first visible one. A gating
change that merely *reveals* an area never yanks the user away from where
they are.
- **An area with no items at all derives the same way**: no visible item →
hidden. (Boundary recorded in objectui#3311.)
- New export `hasVisibleNavigationItems(items, options)` from
`@object-ui/layout` — the predicate behind the derivation, usable by other
shells that render their own area switchers.

No authorable key is involved anywhere: the platform's `.strict()` area
object still rejects the retired keys, and the derivation — computed from the
same guards that decide what renders — cannot disagree with the rendered
navigation, so there is nothing for a metadata author to get wrong.
82 changes: 59 additions & 23 deletions packages/layout/src/AppSchemaRenderer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ import { menuItemToNavigationItem } from '@object-ui/types';
import { AppShell, type AppShellBranding } from './AppShell';
import {
NavigationRenderer,
hasVisibleNavigationItems,
resolveIcon,
resolveLabel,
type VisibilityEvaluator,
Expand Down Expand Up @@ -121,17 +122,24 @@ export interface AppSchemaRendererProps {
// ---------------------------------------------------------------------------

/**
* Areas are NOT gated here any more. `@objectstack/spec` 17.0.0 retired
* `visible` and `requiredPermissions` at area level
* (`AREA_VISIBLE_RETIRED` / `AREA_REQUIRED_PERMISSIONS_RETIRED`): an area is a
* layout grouping, not an access boundary, so gating belongs on the navigation
* ITEM — which `NavigationRenderer` still enforces, via the same `evalVis` /
* `checkPerm` this component used to apply one level up. The spec's area object
* is `.strict()`, so no v17-valid app can carry the retired keys and this
* filter had become unreachable for every app the platform accepts.
* Renders the switcher for the areas the current user can still see.
*
* Consequence worth knowing: an area whose items are all gated away now renders
* as a visible-but-empty area rather than disappearing from the switcher.
* Areas carry no authorable gate of their own: `@objectstack/spec` 17.0.0
* retired `visible` and `requiredPermissions` at area level
* (`AREA_VISIBLE_RETIRED` / `AREA_REQUIRED_PERMISSIONS_RETIRED`) — an area is
* a layout grouping, not an access boundary, so gating belongs on the
* navigation ITEM, which `NavigationRenderer` still enforces via the same
* `evalVis` / `checkPerm` this component used to apply one level up. The
* spec's area object is `.strict()`, so no v17-valid app can carry the
* retired keys.
*
* Area visibility is instead DERIVED (objectui#3311): `AppSchemaRenderer`
* lists an area here iff `hasVisibleNavigationItems` finds at least one item
* in it that survives the item-level guards. An area whose items are all
* gated away disappears from the switcher — the same UX the retired keys used
* to produce — without resurrecting any authorable key for the platform's
* strict schema to reject. An area with no items at all derives the same way
* (no visible item → hidden).
*/
function AreaSwitcher({
areas,
Expand Down Expand Up @@ -272,6 +280,7 @@ function InternalSidebar({
sidebarHeader,
sidebarFooter,
sidebarExtra,
visibleAreas,
activeAreaId,
setActiveAreaId,
resolvedNavigation,
Expand All @@ -290,6 +299,8 @@ function InternalSidebar({
sidebarHeader?: React.ReactNode;
sidebarFooter?: React.ReactNode;
sidebarExtra?: React.ReactNode;
/** Areas with at least one visible item — derived, not authored (#3311). */
visibleAreas: NavigationArea[];
activeAreaId: string | null;
setActiveAreaId: (id: string) => void;
resolvedNavigation: NavigationItem[];
Expand All @@ -300,7 +311,6 @@ function InternalSidebar({
onReorder?: (reorderedItems: NavigationItem[]) => void;
}) {
const Icon = resolveIcon(schema.logo);
const areas = schema.areas ?? [];
const [searchQuery, setSearchQuery] = useState('');

return (
Expand Down Expand Up @@ -349,10 +359,11 @@ function InternalSidebar({
</SidebarHeader>

<SidebarContent>
{/* Area Switcher */}
{areas.length > 1 && activeAreaId && (
{/* Area Switcher — only areas with a visible item, and only when
there is more than one of them left to switch between (#3311) */}
{visibleAreas.length > 1 && activeAreaId && (
<AreaSwitcher
areas={areas}
areas={visibleAreas}
activeAreaId={activeAreaId}
onAreaChange={setActiveAreaId}
/>
Expand Down Expand Up @@ -393,7 +404,9 @@ function InternalSidebar({
* Responsibilities:
* - Reads `name`, `title`, `description`, `logo`, `favicon` for branding
* - Renders sidebar navigation from `navigation` or `areas[].navigation`
* - Area switcher when multiple `areas` are defined
* - Area switcher when multiple areas are VISIBLE — area visibility is
* derived from the items inside, not authored (objectui#3311): an area
* whose items are all gated away is hidden and never auto-activated
* - Mobile modes: `drawer` (sheet overlay, default), `bottom_nav` (fixed
* bottom bar), `hamburger` (collapsed sidebar)
* - Evaluates `visible` expressions and `requiredPermissions` on every item
Expand Down Expand Up @@ -448,24 +461,46 @@ export function AppSchemaRenderer({
const flatNavigation = schema.navigation ?? legacyNavigation;

// --- Area management ---
//
// Area visibility is DERIVED from the items inside (objectui#3311): an area
// is visible iff at least one of its navigation items survives the same
// item-level guards `NavigationRenderer` applies (`visible`,
// `requiredPermissions`, runtime capabilities, action-dispatcher presence).
// Spec 17.0.0 retired the authorable area-level keys; this derivation
// restores the "fully gated area disappears" UX without any authorable key.
// The active area is elected among the VISIBLE areas only, so the user is
// never landed in — or stranded on — an area that renders nothing.
const areas = schema.areas ?? [];
const visibleAreas = areas.filter((area) =>
hasVisibleNavigationItems(area.navigation, {
evaluateVisibility: evalVis,
checkPermission: checkPerm,
checkCapability: checkCap,
hasActionHandler: !!onAction,
}),
);
const [activeAreaId, setActiveAreaId] = useState<string | null>(
() => areas.length > 0 ? areas[0].id : null,
() => visibleAreas.length > 0 ? visibleAreas[0].id : null,
);

const areaIds = areas.map((a) => a.id).join(',');
const visibleAreaIds = visibleAreas.map((a) => a.id).join(',');

useEffect(() => {
if (areas.length > 0) {
if (visibleAreas.length > 0) {
setActiveAreaId((prev) =>
areas.some((a) => a.id === prev) ? prev : areas[0].id,
visibleAreas.some((a) => a.id === prev) ? prev : visibleAreas[0].id,
);
} else {
setActiveAreaId(null);
}
}, [schema.name, areaIds]);

const activeArea = areas.find((a) => a.id === activeAreaId);
}, [schema.name, visibleAreaIds]);

// Resolve the EFFECTIVE active area at render time rather than trusting the
// state: when a gating change hides the currently active area, the effect
// above re-elects on the next tick — this fallback keeps the in-between
// frame from rendering the hidden area's (empty) navigation.
const activeArea =
visibleAreas.find((a) => a.id === activeAreaId) ?? visibleAreas[0];
const resolvedNavigation: NavigationItem[] = activeArea?.navigation ?? flatNavigation;

// --- Branding ---
Expand All @@ -487,7 +522,8 @@ export function AppSchemaRenderer({
sidebarHeader={sidebarHeader}
sidebarFooter={sidebarFooter}
sidebarExtra={sidebarExtra}
activeAreaId={activeAreaId}
visibleAreas={visibleAreas}
activeAreaId={activeArea?.id ?? null}
setActiveAreaId={setActiveAreaId}
resolvedNavigation={resolvedNavigation}
enableSearch={enableSearch}
Expand Down
74 changes: 74 additions & 0 deletions packages/layout/src/NavigationRenderer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -342,6 +342,80 @@ const defaultPermission: PermissionChecker = () => true;

const defaultCapability: CapabilityChecker = () => true;

// ---------------------------------------------------------------------------
// Derived area visibility (objectui#3311)
// ---------------------------------------------------------------------------

/** Guard callbacks for {@link hasVisibleNavigationItems}. */
export interface NavigationVisibilityOptions {
/** Evaluator for item `visible` expressions. Defaults to always-visible. */
evaluateVisibility?: VisibilityEvaluator;
/** Checker for item `requiredPermissions`. Defaults to always-permitted. */
checkPermission?: PermissionChecker;
/** Checker for `requiresObject` / `requiresService`. Defaults to pass. */
checkCapability?: CapabilityChecker;
/**
* Whether the host wires an `onAction` dispatcher. Without one, `action`
* items are not rendered at all (framework#4509 — a nav entry that looks
* clickable and silently does nothing is worse than an absent one), so
* they cannot carry an area's visibility either. Defaults to `false`,
* matching a renderer with no `onAction` prop.
*/
hasActionHandler?: boolean;
}

/**
* Whether a navigation tree contains at least one item that would actually
* render under the given guards — the exact guards `NavigationItemRenderer`
* applies per item: the `visible` expression, `requiredPermissions`, the
* `requiresObject` / `requiresService` runtime-capability gates, and (for
* `action` items) the presence of an action dispatcher.
*
* Non-content nodes never count: a `separator` is a visual divider, and a
* `group` counts only through its children — a group whose children are all
* gated away contributes nothing a user can navigate to.
*
* This is the predicate behind DERIVED area visibility (objectui#3311).
* `@objectstack/spec` 17.0.0 retired the authorable area-level `visible` /
* `requiredPermissions` (`AREA_VISIBLE_RETIRED` /
* `AREA_REQUIRED_PERMISSIONS_RETIRED`): an area is a layout grouping, not an
* access boundary. What replaces those keys is not a new key but this
* derivation: an area is visible iff something inside it is. Because it is
* computed from the same guards that decide what renders, it can never
* disagree with the rendered navigation — and there is nothing for a
* metadata author to get wrong. An area with no items at all derives the
* same way (no visible item → hidden).
*/
export function hasVisibleNavigationItems(
items: NavigationItem[],
options: NavigationVisibilityOptions = {},
): boolean {
const {
evaluateVisibility = defaultVisibility,
checkPermission = defaultPermission,
checkCapability = defaultCapability,
hasActionHandler = false,
} = options;

for (const item of items) {
// Same guard order as NavigationItemRenderer.
if (!evaluateVisibility(item.visible)) continue;
if (item.requiredPermissions?.length && !checkPermission(item.requiredPermissions)) continue;
if (item.requiresObject && !checkCapability('object', item.requiresObject)) continue;
if (item.requiresService && !checkCapability('service', item.requiresService)) continue;

if (item.type === 'separator') continue;
if (item.type === 'group') {
if (hasVisibleNavigationItems(item.children ?? [], options)) return true;
continue;
}
if (item.type === 'action' && !hasActionHandler) continue;

return true;
}
return false;
}

// ---------------------------------------------------------------------------
// Internal helper: resolve href from NavigationItem
// ---------------------------------------------------------------------------
Expand Down
Loading
Loading