CnTabsWidget
A widget that holds other widgets, one per tab.
Why it exists
A case detail page that carries notes, files, related records, sub-records, mail and appointments renders six cards, each with its own header and its own Actions menu, stacked down the page. All six describe one record, and only one of them is being read at a time.
CnTabsWidget puts them behind a tab strip. The strip takes over the two things the cards were each doing separately: it shows the title, and it draws the card.
What the children give up
A child renders through CnDetailWidgetHost with chrome="bare", so it draws no header and no border of its own. Its content fills the panel exactly as it filled its card. The tab label is what the card header used to say.
The Actions menu moves out of the panels and into the tab bar, beside the strip rather than inside it, and rebinds to whichever child is showing. One menu, always acting on what you are looking at. Refresh reaches that child's own fetch over cn:widget:refresh, not a sibling's.
The menu renders through the strip's #nav-end slot, which is a sibling of the role="tablist" element. Anything nested inside a tablist is announced as one of the tabs, so a screen-reader user counting six tabs would otherwise hear seven.
What the open panel lends to the menu
Some widgets carry items that live only in their own overflow menu. CnObjectDataWidget carries two: Metadata, and the full edit dialog. A panel draws no header, so suppressing its menu left both with nowhere to go. Metadata had no other home anywhere. Edit had a near neighbour, the record Edit button in a detail page header, but that opens the form the page configures rather than the field subset the widget declares.
The open panel now publishes those items and the strip renders them under the built-in Refresh and Request a feature. Switch tabs and they go with the panel, because the strip keeps them per widget id. A visited tab stays mounted, so more than one panel publishes at a time and only the open one's items are offered.
The strip renders the items. It does not rebuild them. Each one carries a callback that reaches the widget that published it, so Edit still opens that widget's dialog with that widget's overrides, include and exclude applied, and still saves through that widget's own path. Rebuilding the dialog on the strip would drop all of it.
A widget that keeps its own header publishes nothing, which is every surface except a tab panel. The channel itself is an internal module, src/utils/panelActions.js, and it documents the shape a widget publishes.
Without the Actions menu
The strip draws one Actions menu beside the tabs. A page that draws none, as the boards do, turns it off on the tabs widget's content:
{
"id": "receivables-tabs",
"type": "tabs",
"content": {
"showActions": false,
"tabs": [{ "widgetId": "open-invoices", "label": "Open balance" }]
}
}
The whole menu goes, including the items an open panel lends it (a data widget's Metadata and Edit). Without the key the menu stays as it is.
Usage
Reference it from a manifest placement. label and icon are optional and fall back to the child widget's own title and icon, so the common case is a list of widgetIds:
{
"id": "case-panels",
"type": "tabs",
"content": {
"ariaLabel": "Case details",
"tabs": [
{ "widgetId": "case-notes", "label": "Notes" },
{ "widgetId": "case-files", "label": "Files" },
{ "widgetId": "case-related" },
{ "widgetId": "case-sub-cases" }
]
}
}
The open panel's header link
A widget that declares headerLink keeps it inside a tab. A panel draws no card header, so the strip draws the open tab's link beside it, before the Actions menu, and switches it with the tab. A tab whose widget declares none shows no link. The link is read from headerLink on the widget definition, else from its content (content.headerLink, or viewAllRoute). For a stack, the first widget's link counts; each card below it draws its own link in its card header.
{
"id": "open-invoices",
"type": "object-table",
"headerLink": { "label": "All sales invoices", "route": "AccountsReceivable" }
}
A route is a router link (with optional params and query), an href a plain anchor, and the label goes through the app's translations. Nothing changes on the tabs widget itself.
Counts, and tabs under More
A tab can show a count, and a strip can keep to a few tabs and put the rest under a "More" menu:
{
"id": "case-panels",
"type": "tabs",
"content": {
"ariaLabel": "Case details",
"maxVisibleTabs": 5,
"hideEmpty": true,
"moreLabel": "More",
"tabs": [
{ "widgetId": "case-overview", "label": "Overview" },
{ "widgetId": "case-files", "label": "Documents", "countField": "documents" },
{ "widgetId": "case-contact", "label": "Contact", "countField": "messages" },
{ "widgetId": "case-tasks", "label": "Tasks", "count": 1 },
{ "widgetId": "case-history", "label": "History" },
{ "widgetId": "case-archiving", "label": "Archiving", "overflow": true }
]
}
}
| Key | On | What it does |
|---|---|---|
count | a tab | A number shown after the label. |
countField | a tab | Reads the count off the record. A list counts its items, a number is used as is, a missing field counts as 0. |
countFrom | a tab | Counts a list on the server when the record does not carry the number: "widget" counts the child widget's own list (its content.register, content.schema and content.filter, with @objectId and other tokens resolved), an object with register, schema and an optional filter names the list. One request per tab (_limit=1, the response's total), again when the record changes or the page refreshes. count and countField win over it; a failed count shows no number. |
overflow | a tab | Lists the tab under "More". |
maxVisibleTabs | content | The most tabs the strip shows. Later tabs go under "More". A tab that is already there does not use up a place. |
hideEmpty | content | Moves a tab whose count is 0 under "More". A tab without a count is never moved. |
moreLabel | content | Names the menu. Defaults to "More". |
The strip opens on the first tab that is in it. None of these keys is required, and a strip that sets none renders as before.
Tab titles are separately configurable for a reason: "Files and attachments" reads fine on a card and is too long once six tabs share the width. CnTabsWidgetForm edits them in Buildiq edit mode.
A stack of widgets in one tab
A tab can hold several widgets with widgetIds instead of widgetId. The
Overview of a person or organisation page shows the record's card, then its
open cases, then its latest contact moments, and only on that tab:
{ "widgetIds": ["person-card", "person-cases", "person-moments"], "label": "Overview" }
By default the widgets stand one under the other. To lay them out in a grid,
for example four KPI tiles in a row, give the tab columns (each widget takes
that share of a 12-column row) and/or layout, the page grid's width key per
widget (gridWidth, of 12; a widget it does not name takes the columns share,
or the whole row). Widgets wrap onto the next row, and below 700px the stack is
vertical again:
{ "widgetIds": ["kpi-a", "kpi-b", "kpi-c", "kpi-d", "list"], "label": "Overview", "columns": 4,
"layout": [{ "widgetId": "list", "gridWidth": 12 }] }
Every widget renders as a titled card without its own Actions menu (the strip's
Actions button serves the tab). In the board look the panel draws no frame of
its own: the first card joins the open tab and the next ones stand 20px apart.
An id that names no widget is skipped. Without label the tab takes the first
widget's title. countFrom: "widget" counts the first widget of the stack that
has a list. A widgetId tab renders as before.
Props
| Prop | Type | Default | Description |
|---|---|---|---|
content | Object | {} | The widget config: { tabs, ariaLabel, variant? }. variant: "segmented" draws the strip as a pill switch (CnTabs' segmented variant); the default is the line strip. tabs[] entries are { widgetId, label?, icon? } or { widgetIds, label?, icon?, columns?, layout? }, or a bare widget-id string. |
availableWidgets | Array | [] | Every widget definition on the surface, so content.tabs[] can reference siblings by id. |
objectId | String | Number | '' | The bound record's id. |
objectData | Object | null | The loaded record, or null while it is still being fetched. |
objectType | String | '' | The resolved object-type slug. |
schemaObject | Object | null | The resolved JSON Schema, needed by a data child. |
register | String | Object | '' | OpenRegister register slug of the surface. |
schema | String | Object | '' | OpenRegister schema slug of the surface. |
store | Object | null | The effective object store. |
surface | String | 'detail-page' | Rendering surface forwarded to integration children (AD-19). |
integrationContext | Object | null | Object context forwarded to integration children. |
cnRegistry | Object | {} | The consumer's component registry, for custom child widget types. |
showActions | Boolean | true | false drops the strip's Actions menu, the open panel's own items included. From a manifest: content.showActions. |
showRefresh | Boolean | true | Show Refresh in the hoisted Actions menu. |
showRequestFeature | Boolean | true | Show Request a feature in the hoisted Actions menu. |
showDocumentation | Boolean | true | Show Documentation in the hoisted Actions menu. |
documentationUrl | String | '' | Documentation URL for the hoisted Actions menu. |
Events
| Event | Payload | Description |
|---|---|---|
geo-saved | object | Re-emitted from a geo child that saved a geometry. |
open-integration | string | Re-emitted from a related child asking to open an integration. |
select-object | object | Re-emitted from a related child whose object row was clicked; payload is the raw object. |
Notes
- Panels are lazy, and stay mounted. Each panel is a
CnTabwithlazy, so a child mounts when its tab is first opened. Six eager panels that each fetch onmounted()would fire six requests on page load to answer five questions nobody asked. Once opened, a panel stays mounted, so switching back never refetches. - A tab naming a widget that does not exist still renders, and its panel says which id did not resolve.
content.tabs[]is hand-authored config, and a typo that silently removes a tab is a typo nobody finds. - A tabs widget cannot hold another tabs widget. Two rows of tabs give the reader no way to tell which row owns the panel, so the config form filters containers out of the picker.
See also
CnTabsandCnTabfor the strip itselfCnDetailWidgetHostfor how a child is renderedCnTabsWidgetFormfor the config form
OpenRegister reliance
heavy: this component cannot work without OpenRegister. 0 direct references, 127 files reached through imports.
Through: CnActionsMenu, CnAnalyticsReportPicker, CnAuditTrailCard, CnBookmarkPicker, CnCalendarEventCreate, CnCalendarEventPicker, CnChildRecordsField, CnCollectivePageCreate, CnCollectivePagePicker, CnContactPicker, CnCospendCreate, CnCospendPicker, CnDataTable, CnDeckCardCreate, CnDeckCardPicker, CnDetailWidgetHost, CnEmailPicker, CnFilesBrowser, CnFilesCard, CnFkResolveCell, CnFlowOperationPicker, CnFormDialog, CnIndexPage, CnIntegrationWidget, CnMapPoiPicker, CnMapWidget, CnNavCardGrid, CnNoteComposer, CnNotesCard, CnObjectDataWidget, CnObjectFilesWidget, CnObjectGeoWidget, CnObjectMetadataWidget, CnObjectSidebar, CnOpenProjectCreate, CnOpenProjectPicker, CnPhotoAlbumPicker, CnPollPicker, CnRelatedObjectsWidget, CnResourceSelect, CnTagsCard, CnTalkRoomPicker, CnTasksCard, CnTimeTrackerCreate, CnTimeTrackerPicker, CnTimelineWidget, CnTranslatedBadge, CnVersionHistory, CnWidgetObjectTable, CnXwikiPageCreate, CnXwikiPagePicker.
See the reliance overview for the filterable list.
Props
| Name | Type | Required | Default | Description |
|---|---|---|---|---|
content | { tabs?: Array<{widgetId?: string, widgetIds?: string[], label?: string, icon?: string, count?: number, countField?: string, countFrom?: ('widget'|{register: string, schema: string, filter?: object}), overflow?: boolean, columns?: number, layout?: Array<{widgetId: string, gridWidth: number}>}>, ariaLabel?: string, maxVisibleTabs?: number, hideEmpty?: boolean, moreLabel?: string, variant?: ('line'|'segmented') } | \{\} | The widget's config: { tabs, ariaLabel }. tabs[] entries are { widgetId, label?, icon?, count?, countField?, overflow? }. label and icon fall back to the referenced widget's own title and icon. count is a number shown after the label; countField reads it off the record instead (a list counts its items). countFrom counts a list on the server: "widget" counts the child widget's own list (content.register, schema, filter, tokens such as @objectId resolved), an object { register, schema, filter? } names the list; count and countField win over it. overflow: true lists the tab under "More". A tab with widgetIds can lay them out in a grid instead of one under the other: columns: 4 gives each a quarter of the row, and layout: [{ widgetId, gridWidth }] sets a width (of 12) per widget, as the page grid does. Widgets wrap onto the next row. maxVisibleTabs caps the strip: later tabs go under "More". hideEmpty: true moves a tab whose count is 0 there too. moreLabel names that menu. variant: "segmented" draws the strip as a pill switch (CnTabs' segmented variant); line is the default. | |
availableWidgets | object[] | [] | Every widget definition available on the surface, for content.tabs[] to reference by id. The surface passes its whole list rather than the resolved children because a tab may name a widget that does not exist, and this component has to be able to say so. | |
objectId | string|number | '' | The bound record's id. | |
objectData | object | null | The loaded record, or null while it is still being fetched. | |
objectType | string | '' | The resolved object-type slug. | |
schemaObject | object | null | The resolved JSON Schema object, needed by a data child. | |
register | string|object | '' | OpenRegister register slug of the surface. | |
schema | string|object | '' | OpenRegister schema slug of the surface. | |
store | object | null | The effective object store. | |
surface | string | 'detail-page' | Rendering surface forwarded to integration children (AD-19). | |
integrationContext | object | null | Object context forwarded to integration children. | |
cnRegistry | object | \{\} | The consumer's component registry, for custom child widget types. | |
showActions | boolean | true | Whether the strip draws its Actions menu at all. false drops the whole menu, the open panel's own items included, for a board that draws none. Manifest: content.showActions on the tabs widget. | |
showRefresh | boolean | true | Show the Refresh entry in the hoisted Actions menu. | |
showRequestFeature | boolean | true | Show the Request-a-feature entry in the hoisted Actions menu. | |
showDocumentation | boolean | true | Show the Documentation entry in the hoisted Actions menu. | |
documentationUrl | string | '' | Documentation URL for the hoisted Actions menu. |
Events
| Name | Payload | Description |
|---|---|---|
geo-saved | — | Re-emitted from a geo child that saved a geometry. |
open-integration | — | Re-emitted from a related child asking to open an integration. |
select-object | — | Re-emitted from a related child whose object row was clicked. Payload is the raw object. |
What is new on a tab
A tab that declares neither count nor countField shows what is new on it, from the object's @self.unreadCounts[<unreadKey>] (OpenRegister object-read-state, returned on the detail read). unreadKey defaults to the tab's id, else its widgetId. A key absent from the map, or 0, shows no badge. A tab with its own count or countField keeps it. Opening a tab marks nothing: the backend tracks one moment per record.
{ "tabs": [{ "widgetId": "files" }, { "id": "documents", "widgetId": "docs", "unreadKey": "files" }] }
Hiding a tab: visibleWhen
A tab entry can carry visibleWhen, the shared predicate every other surface uses ({ field, op, value }, or { source, field: "@total", op, value }). A tab whose condition is false is absent from the strip, not empty. A tab without the key always renders.
{ "tabs": [
{ "id": "overview", "widgetId": "case-overview" },
{ "id": "participants", "widgetId": "case-roles",
"visibleWhen": { "source": { "register": "dossiq", "schema": "role", "filter": { "case": "@objectId" } }, "field": "@total", "op": "gt", "value": 0 } },
{ "id": "decision", "widgetId": "case-decision",
"visibleWhen": { "field": "decision", "op": "neq", "value": null } }
] }
- Local form (a
fieldof the page object): decided on every change of the object. While the object is still loading the tab shows disabled. - Source form (
sourcewith@total): counted once on mount, when the record changes, and again when a write is reported on the page (acn:page:refreshorcn:widget:refreshbroadcast, which the object-list widgets send after they save). While the count is pending the tab shows disabled with a spinner, so the strip does not jump; a refresh keeps the last answer until the new one arrives. - The active tab is always visible. When the active tab goes, the first visible tab takes over and, if the page was opened with a route hash, the hash follows. A hash naming a hidden tab opens the first visible tab, without an error; a hash naming a visible tab (
#decision, the tab'sid) opens it.
The board look
In the board look the widget draws no tab icons and no "More" menu, draws its Actions button as a secondary button, and names the tab list from the page's tabsLabel (content.ariaLabel still wins). A tab holding the record's activity (an audit-trail or timeline widget, or the activity integration) is labelled "History" unless the tab sets a label, and is moved to the end of the strip whatever its place in content.tabs.