Widget
Named widget areas, modelled on @frigear-nu/menu. An area (admin.dashboard, home.sidebar,
...) is a named slot in a page. Modules contribute widgets (Vue components) to it at build time and the host
renders the area wherever it likes; neither side knows about the other. The data is presentation-free and the
components are loaded lazily, so an area only ships the widgets it renders. Tabs are a second, separate registry with
the same ordering, replacement and gating.
@frigear-nu/module-kit. No other module is needed; <WidgetTabs> draws tab icons
with Nuxt UI's UIcon, and @nuxtjs/i18n is optional for translating tab labels.The module has no options (widgets is only a config key for internal state).Quick setup
export default defineNuxtConfig({
modules: ['@frigear-nu/widget'],
})
<WidgetArea name="admin.dashboard">
<template #empty>Nothing to show yet.</template>
</WidgetArea>
Options
None.
App
Components
| Component | Props | Slots | Purpose |
|---|---|---|---|
<WidgetArea> | name: string, context?: Record<string, unknown>, gridClass?: string (replaces the default grid classes of the wrapper) | empty, item with { widget: WidgetMeta, component, context } | Renders every widget of the area in a responsive grid. Use item to lay widgets out yourself. |
<WidgetTabs> | name: string, context?: Record<string, unknown> | empty | Renders the area's tabs as a tab list with the active tab's component below. Emits refresh. |
context is whatever the host renders the area for (for example { user }). Each widget or tab component receives its
static props, then every entry of context as a prop, and the context reaches the filter hooks as ctx.context.
<WidgetTabs> keeps the active tab in the ?tab= query parameter (updated in place, without a navigation), supports
arrow-key navigation, and re-emits a tab's refresh event so the host can reload its data.
WidgetTabs or PageTabs
<WidgetTabs> is drawn exactly like PageTabs from @frigear-nu/ui: the same bordered
strip with an underline highlight, scrolling sideways on narrow screens. A detail page looks the same whichever of the
two it uses. The difference is who decides the sections:
| Use | When |
|---|---|
<WidgetTabs> | Other modules should be able to add sections: the page names an area (for example admin.users) and renders whatever is registered. The active tab lives in ?tab=. |
PageTabs | The page owns its sections and nobody else adds to them, such as a form's builder, settings and access. Link mode (items with to) for sections that are routes, state mode (v-model) for the rest. |
See UX patterns.
Composables
Auto-imported. Both return the shape of useAsyncData: the registered entries as plain data, ordered and passed through
the filter hook (the component itself is resolved by the components above). context may be a ref or getter.
useWidgets(area: string, context?: MaybeRefOrGetter<Record<string, unknown> | undefined>): AsyncData<WidgetMeta[]>
useTabs(area: string, context?: MaybeRefOrGetter<Record<string, unknown> | undefined>): AsyncData<TabMeta[]> // labels already translated
Contributing from a module
Call extendWidgets or extendTabs once in a module's setup(); registration order between modules does not matter.
An entry registered again under the same id in an area replaces the earlier one, which is how a host overrides a
module's widget or tab. The id defaults to the component's file name.
import { createResolver, defineNuxtModule } from '@nuxt/kit'
import { extendWidgets, extendTabs } from '@frigear-nu/widget/utils'
export default defineNuxtModule({
setup(_options, nuxt) {
const resolver = createResolver(import.meta.url)
extendWidgets(nuxt, 'admin.dashboard', {
component: resolver.resolve('./runtime/app/widgets/NewMembers.vue'),
order: 10,
span: 2,
props: { days: 30 },
requires: 'admin',
})
extendTabs(nuxt, 'admin.users', {
id: 'payments',
component: resolver.resolve('./runtime/app/tabs/Payments.vue'),
order: 20,
label: 'Payments',
labelKey: 'payments.admin.tab',
icon: 'i-lucide-credit-card',
requires: 'billing',
})
},
})
type WidgetDefinition = {
id?: string // unique within an area; default: component file name
component: string // resolved path of the Vue component
order?: number // lower first; unordered last, in registration order
span?: 1 | 2 | 3 | 4 // grid columns in the default layout (default 1)
props?: Record<string, unknown> // serializable
requires?: string // free-form, for the widgets:filter hook; never interpreted by the module
tags?: string[]
meta?: Record<string, unknown>
}
type TabDefinition = {
id?: string // default: component file name; also the `?tab=` value
component: string
order?: number
props?: Record<string, unknown>
label?: string // plain fallback label; default: the id
labelKey?: string // i18n key, translated at render time with @nuxtjs/i18n
icon?: string // for example 'i-lucide-credit-card'
requires?: string // free-form, for the tabs:filter hook
tags?: string[]
meta?: Record<string, unknown>
}
type RegisteredWidget = WidgetDefinition & { id: string, area: string }
type RegisteredTab = TabDefinition & { id: string, area: string }
type WidgetMeta = Omit<RegisteredWidget, 'component'> // what useWidgets returns
type TabMeta = Omit<RegisteredTab, 'component' | 'labelKey' | 'label'> & { label: string, labelKey?: string } // what useTabs returns
Utils
@frigear-nu/widget/utils is framework-agnostic; the types above are also exported from @frigear-nu/widget/types.
| Export | Purpose |
|---|---|
extendWidgets(nuxt, area, widgets) | Register one or more widgets into an area (call from a module's setup()). |
extendTabs(nuxt, area, tabs) | Register one or more tabs into an area. |
deriveWidgetId(widget), deriveTabId(tab) | The id an entry gets: its id, else the component file name. |
sortWidgets(list) | Order by order ascending; entries without one last, keeping registration order. |
Gating
The module never interprets requires. Listen to the filter hooks and drop what the current visitor should not see:
export default defineNuxtPlugin((nuxtApp) => {
nuxtApp.hook('widgets:filter', (ctx) => {
const role = useUserSession().user.value?.role
ctx.widgets = ctx.widgets.filter(widget => !widget.requires || widget.requires === role)
})
})
Hooks
| Hook | Where | When | Payload |
|---|---|---|---|
widgets:extend | Nuxt (build) | extendWidgets registers widgets | (area: string, widgets: RegisteredWidget[]) |
widgets:resolved | Nuxt (build) | Once, after every module has set up | (areas: Record<string, RegisteredWidget[]>) |
tabs:extend | Nuxt (build) | extendTabs registers tabs | (area: string, tabs: RegisteredTab[]) |
tabs:resolved | Nuxt (build) | Once, after every module has set up | (areas: Record<string, RegisteredTab[]>) |
widgets:filter | Nuxt app | An area's widgets are resolved, before rendering | { area: string, widgets: WidgetMeta[], context?: Record<string, unknown> }; reassign or filter ctx.widgets |
tabs:filter | Nuxt app | An area's tabs are resolved, before rendering | { area: string, tabs: TabMeta[], context?: Record<string, unknown> }; reassign or filter ctx.tabs |