Modules

Widget

Named widget and tab areas - modules contribute widgets (<WidgetArea>) and tabs (<WidgetTabs>), a hook gates them.

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.

Requires@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

ComponentPropsSlotsPurpose
<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>emptyRenders 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:

UseWhen
<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=.
PageTabsThe 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.

ExportPurpose
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)
  })
})
Filtering only hides UI. Always enforce permissions on the server for the data a widget loads.

Hooks

HookWhereWhenPayload
widgets:extendNuxt (build)extendWidgets registers widgets(area: string, widgets: RegisteredWidget[])
widgets:resolvedNuxt (build)Once, after every module has set up(areas: Record<string, RegisteredWidget[]>)
tabs:extendNuxt (build)extendTabs registers tabs(area: string, tabs: RegisteredTab[])
tabs:resolvedNuxt (build)Once, after every module has set up(areas: Record<string, RegisteredTab[]>)
widgets:filterNuxt appAn area's widgets are resolved, before rendering{ area: string, widgets: WidgetMeta[], context?: Record<string, unknown> }; reassign or filter ctx.widgets
tabs:filterNuxt appAn area's tabs are resolved, before rendering{ area: string, tabs: TabMeta[], context?: Record<string, unknown> }; reassign or filter ctx.tabs
Copyright © 2026