@frigear-nu/widget 0.0.0-stage → 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,3 +1,5 @@
1
- # Temporary Holding Version
1
+ # @frigear-nu/widget
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Named widget areas that modules contribute Vue components to and hosts render with `<WidgetArea>`; `extendWidgets` at build time, ordering, lazy loading and a `widgets:filter` hook for gating. Modelled on `@frigear-nu/menu`.
4
+
5
+ See the [documentation](../../apps/docs/content/4.modules/19.widget.md) for usage.
@@ -0,0 +1,73 @@
1
+ import * as _nuxt_schema from '@nuxt/schema';
2
+
3
+ /**
4
+ * A widget: a Vue component a module contributes to a named area (e.g. `admin.dashboard`). The data is
5
+ * presentation-free - the host decides how an area is laid out - and serializable, so the registry can be
6
+ * generated at build time. Components are loaded lazily, only when their area is rendered.
7
+ */
8
+ type WidgetDefinition = {
9
+ /** Stable identifier within an area; derived from the component file name when omitted. */
10
+ id?: string;
11
+ /** Resolved path of the widget's Vue component (e.g. `resolver.resolve('./runtime/app/widgets/Foo.vue')`). */
12
+ component: string;
13
+ /** Lower numbers render first. Widgets without an `order` render last, in registration order. */
14
+ order?: number;
15
+ /** How many grid columns the widget spans in the default `<WidgetArea>` layout (1-4). Defaults to 1. */
16
+ span?: 1 | 2 | 3 | 4;
17
+ /** Serializable props passed to the component. */
18
+ props?: Record<string, unknown>;
19
+ /**
20
+ * A free-form requirement (a role, an ability name, a feature flag...) that a `widgets:filter` listener can
21
+ * act on to hide the widget. The widget module itself never interprets it.
22
+ */
23
+ requires?: string;
24
+ tags?: string[];
25
+ meta?: Record<string, unknown>;
26
+ };
27
+ /** A registered widget with its area and a guaranteed `id`. */
28
+ type RegisteredWidget = WidgetDefinition & {
29
+ id: string;
30
+ area: string;
31
+ };
32
+ /** What `useWidgets` returns: everything but the component, which is resolved by `<WidgetArea>`. */
33
+ type WidgetMeta = Omit<RegisteredWidget, 'component'>;
34
+ type ModuleOptions = Record<string, never>;
35
+ /**
36
+ * A tab: a Vue component a module contributes to a named area (e.g. `admin.users`) that the host renders with
37
+ * `<WidgetTabs>`. Tabs live in their own registry, separate from widgets, with the same ordering and gating.
38
+ */
39
+ type TabDefinition = {
40
+ /** Stable identifier within an area; derived from the component file name when omitted. Used as the `?tab=` value. */
41
+ id?: string;
42
+ /** Resolved path of the tab's Vue component. */
43
+ component: string;
44
+ /** Lower numbers render first. Tabs without an `order` render last, in registration order. */
45
+ order?: number;
46
+ /** Serializable props passed to the component, next to the host's runtime `context`. */
47
+ props?: Record<string, unknown>;
48
+ /** Plain label, used when `labelKey` is missing or can't be translated. Defaults to the id. */
49
+ label?: string;
50
+ /** i18n key for the label, translated at render time when `@nuxtjs/i18n` is installed. */
51
+ labelKey?: string;
52
+ /** Icon name shown next to the label (e.g. `i-lucide-credit-card`). */
53
+ icon?: string;
54
+ /** A free-form requirement that a `tabs:filter` listener can act on; the widget module never interprets it. */
55
+ requires?: string;
56
+ tags?: string[];
57
+ meta?: Record<string, unknown>;
58
+ };
59
+ /** A registered tab with its area and a guaranteed `id`. */
60
+ type RegisteredTab = TabDefinition & {
61
+ id: string;
62
+ area: string;
63
+ };
64
+ /** What `useTabs` returns: everything but the component, with the label already translated. */
65
+ type TabMeta = Omit<RegisteredTab, 'component' | 'labelKey' | 'label'> & {
66
+ label: string;
67
+ labelKey?: string;
68
+ };
69
+
70
+ declare const _default: _nuxt_schema.NuxtModule<ModuleOptions, ModuleOptions, false>;
71
+
72
+ export { _default as default };
73
+ export type { ModuleOptions, RegisteredTab, RegisteredWidget, TabDefinition, TabMeta, WidgetDefinition, WidgetMeta };
@@ -0,0 +1,9 @@
1
+ {
2
+ "name": "@frigear-nu/widget",
3
+ "configKey": "widgets",
4
+ "version": "0.0.1",
5
+ "builder": {
6
+ "@nuxt/module-builder": "1.0.3",
7
+ "unbuild": "unknown"
8
+ }
9
+ }
@@ -0,0 +1,54 @@
1
+ import { defineNuxtModule, createResolver, addImports, addComponent, addTypeTemplate } from '@nuxt/kit';
2
+ import { registerModule, registerTailwindSource, defineVirtualRegistry } from '@frigear-nu/module-kit/utils';
3
+ import { sortWidgets } from '../dist/runtime/utils/sort.js';
4
+
5
+ const module$1 = defineNuxtModule({
6
+ meta: { name: "@frigear-nu/widget", configKey: "widgets" },
7
+ setup(_options, nuxt) {
8
+ registerModule("@frigear-nu/widget", nuxt);
9
+ const resolver = createResolver(import.meta.url);
10
+ registerTailwindSource(nuxt, resolver.resolve("./runtime/app"));
11
+ addImports([
12
+ { name: "useWidgets", from: resolver.resolve("./runtime/app/composables/useWidgets") },
13
+ { name: "useTabs", from: resolver.resolve("./runtime/app/composables/useTabs") }
14
+ ]);
15
+ addComponent({ name: "WidgetArea", filePath: resolver.resolve("./runtime/app/components/WidgetArea.vue") });
16
+ addComponent({ name: "WidgetTabs", filePath: resolver.resolve("./runtime/app/components/WidgetTabs.vue") });
17
+ addTypeTemplate({
18
+ filename: "types/widgets-registry.d.ts",
19
+ getContents: () => `declare module '#widgets/registry' {
20
+ import type { Component } from 'vue'
21
+ import type { WidgetMeta } from ${JSON.stringify(resolver.resolve("./types"))}
22
+ export const widgets: Record<string, Array<WidgetMeta & { component: () => Promise<{ default: Component }> }>>
23
+ }
24
+ declare module '#widgets/tabs' {
25
+ import type { Component } from 'vue'
26
+ import type { RegisteredTab } from ${JSON.stringify(resolver.resolve("./types"))}
27
+ export const tabs: Record<string, Array<Omit<RegisteredTab, 'component'> & { component: () => Promise<{ default: Component }> }>>
28
+ }
29
+ `
30
+ });
31
+ const lazyEntries = (areas) => Object.entries(areas).map(([area, entries]) => {
32
+ const items = sortWidgets(entries).map(({ component, ...meta }) => `{ ...${JSON.stringify(meta)}, component: () => import(${JSON.stringify(component)}) }`);
33
+ return `${JSON.stringify(area)}: [${items.join(", ")}]`;
34
+ }).join(", ");
35
+ defineVirtualRegistry(nuxt, {
36
+ filename: "#widgets/registry.mjs",
37
+ alias: "#widgets/registry",
38
+ collect: () => nuxt.options.widgets?._extensions ?? {},
39
+ contents: (areas) => `export const widgets = { ${lazyEntries(areas)} }
40
+ `,
41
+ resolved: (areas) => nuxt.callHook("widgets:resolved", areas)
42
+ });
43
+ defineVirtualRegistry(nuxt, {
44
+ filename: "#widgets/tabs.mjs",
45
+ alias: "#widgets/tabs",
46
+ collect: () => nuxt.options.widgets?._tabs ?? {},
47
+ contents: (areas) => `export const tabs = { ${lazyEntries(areas)} }
48
+ `,
49
+ resolved: (areas) => nuxt.callHook("tabs:resolved", areas)
50
+ });
51
+ }
52
+ });
53
+
54
+ export { module$1 as default };
@@ -0,0 +1,23 @@
1
+ type __VLS_Props = {
2
+ name: string;
3
+ context?: Record<string, unknown>;
4
+ };
5
+ declare var __VLS_1: {
6
+ widget: any;
7
+ component: import("vue").Component | undefined;
8
+ context: Record<string, unknown> | undefined;
9
+ }, __VLS_8: {};
10
+ type __VLS_Slots = {} & {
11
+ item?: (props: typeof __VLS_1) => any;
12
+ } & {
13
+ empty?: (props: typeof __VLS_8) => any;
14
+ };
15
+ declare const __VLS_base: import("vue").DefineComponent<__VLS_Props, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {}, string, import("vue").PublicProps, Readonly<__VLS_Props> & Readonly<{}>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, false, {}, any>;
16
+ declare const __VLS_export: __VLS_WithSlots<typeof __VLS_base, __VLS_Slots>;
17
+ declare const _default: typeof __VLS_export;
18
+ export default _default;
19
+ type __VLS_WithSlots<T, S> = T & {
20
+ new (): {
21
+ $slots: S;
22
+ };
23
+ };
@@ -0,0 +1,38 @@
1
+ <script setup>
2
+ import { getWidgetComponent } from "../utils/components";
3
+ const props = defineProps({
4
+ name: { type: String, required: true },
5
+ context: { type: Object, required: false }
6
+ });
7
+ const { data: widgets } = await useWidgets(props.name, () => props.context);
8
+ </script>
9
+
10
+ <template>
11
+ <div
12
+ v-if="widgets?.length"
13
+ class="grid grid-cols-[repeat(auto-fit,minmax(16rem,1fr))] gap-4 md:grid-cols-4 md:[&>*]:[grid-column:span_var(--widget-span,1)]"
14
+ >
15
+ <template
16
+ v-for="widget in widgets"
17
+ :key="widget.id"
18
+ >
19
+ <slot
20
+ name="item"
21
+ :widget="widget"
22
+ :component="getWidgetComponent(name, widget.id)"
23
+ :context="context"
24
+ >
25
+ <div :style="{ '--widget-span': widget.span ?? 1 }">
26
+ <component
27
+ :is="getWidgetComponent(name, widget.id)"
28
+ v-bind="{ ...widget.props, ...context }"
29
+ />
30
+ </div>
31
+ </slot>
32
+ </template>
33
+ </div>
34
+ <slot
35
+ v-else
36
+ name="empty"
37
+ />
38
+ </template>
@@ -0,0 +1,23 @@
1
+ type __VLS_Props = {
2
+ name: string;
3
+ context?: Record<string, unknown>;
4
+ };
5
+ declare var __VLS_1: {
6
+ widget: any;
7
+ component: import("vue").Component | undefined;
8
+ context: Record<string, unknown> | undefined;
9
+ }, __VLS_8: {};
10
+ type __VLS_Slots = {} & {
11
+ item?: (props: typeof __VLS_1) => any;
12
+ } & {
13
+ empty?: (props: typeof __VLS_8) => any;
14
+ };
15
+ declare const __VLS_base: import("vue").DefineComponent<__VLS_Props, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {}, string, import("vue").PublicProps, Readonly<__VLS_Props> & Readonly<{}>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, false, {}, any>;
16
+ declare const __VLS_export: __VLS_WithSlots<typeof __VLS_base, __VLS_Slots>;
17
+ declare const _default: typeof __VLS_export;
18
+ export default _default;
19
+ type __VLS_WithSlots<T, S> = T & {
20
+ new (): {
21
+ $slots: S;
22
+ };
23
+ };
@@ -0,0 +1,21 @@
1
+ type __VLS_Props = {
2
+ name: string;
3
+ context?: Record<string, unknown>;
4
+ };
5
+ declare var __VLS_13: {};
6
+ type __VLS_Slots = {} & {
7
+ empty?: (props: typeof __VLS_13) => any;
8
+ };
9
+ declare const __VLS_base: import("vue").DefineComponent<__VLS_Props, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {
10
+ refresh: () => any;
11
+ }, string, import("vue").PublicProps, Readonly<__VLS_Props> & Readonly<{
12
+ onRefresh?: (() => any) | undefined;
13
+ }>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, false, {}, any>;
14
+ declare const __VLS_export: __VLS_WithSlots<typeof __VLS_base, __VLS_Slots>;
15
+ declare const _default: typeof __VLS_export;
16
+ export default _default;
17
+ type __VLS_WithSlots<T, S> = T & {
18
+ new (): {
19
+ $slots: S;
20
+ };
21
+ };
@@ -0,0 +1,82 @@
1
+ <script setup>
2
+ import { getTabComponent } from "../utils/tabs";
3
+ const props = defineProps({
4
+ name: { type: String, required: true },
5
+ context: { type: Object, required: false }
6
+ });
7
+ const emit = defineEmits(["refresh"]);
8
+ const route = useRoute();
9
+ const { data: tabs } = await useTabs(props.name, () => props.context);
10
+ const selected = ref(typeof route.query.tab === "string" ? route.query.tab : void 0);
11
+ watch(() => route.query.tab, (tab) => {
12
+ if (typeof tab === "string") selected.value = tab;
13
+ });
14
+ const active = computed(() => tabs.value?.find((tab) => tab.id === selected.value) ?? tabs.value?.[0]);
15
+ function select(id) {
16
+ selected.value = id;
17
+ if (!import.meta.client) return;
18
+ const url = new URL(window.location.href);
19
+ url.searchParams.set("tab", id);
20
+ window.history.replaceState(window.history.state, "", url);
21
+ }
22
+ function onKeydown(event, index) {
23
+ const list = tabs.value ?? [];
24
+ const step = event.key === "ArrowRight" ? 1 : event.key === "ArrowLeft" ? -1 : 0;
25
+ if (!step || !list.length) return;
26
+ event.preventDefault();
27
+ const next = list[(index + step + list.length) % list.length];
28
+ select(next.id);
29
+ nextTick(() => document.getElementById(`tab-${props.name}-${next.id}`)?.focus());
30
+ }
31
+ </script>
32
+
33
+ <template>
34
+ <div
35
+ v-if="tabs?.length"
36
+ class="flex flex-col gap-6"
37
+ >
38
+ <div
39
+ role="tablist"
40
+ class="flex gap-1 overflow-x-auto overflow-y-hidden border-b border-default"
41
+ >
42
+ <button
43
+ v-for="(tab, index) in tabs"
44
+ :id="`tab-${name}-${tab.id}`"
45
+ :key="tab.id"
46
+ type="button"
47
+ role="tab"
48
+ :aria-selected="tab.id === active?.id"
49
+ :aria-controls="`tabpanel-${name}`"
50
+ :tabindex="tab.id === active?.id ? 0 : -1"
51
+ class="inline-flex shrink-0 items-center gap-2 border-b-2 px-3 py-2 text-sm font-medium transition-colors"
52
+ :class="tab.id === active?.id ? 'border-primary text-highlighted' : 'border-transparent text-muted hover:text-default'"
53
+ @click="select(tab.id)"
54
+ @keydown="onKeydown($event, index)"
55
+ >
56
+ <UIcon
57
+ v-if="tab.icon"
58
+ :name="tab.icon"
59
+ class="size-4"
60
+ />
61
+ {{ tab.label }}
62
+ </button>
63
+ </div>
64
+ <div
65
+ v-if="active"
66
+ :id="`tabpanel-${name}`"
67
+ role="tabpanel"
68
+ :aria-labelledby="`tab-${name}-${active.id}`"
69
+ >
70
+ <component
71
+ :is="getTabComponent(name, active.id)"
72
+ :key="active.id"
73
+ v-bind="{ ...active.props, ...context }"
74
+ @refresh="emit('refresh')"
75
+ />
76
+ </div>
77
+ </div>
78
+ <slot
79
+ v-else
80
+ name="empty"
81
+ />
82
+ </template>
@@ -0,0 +1,21 @@
1
+ type __VLS_Props = {
2
+ name: string;
3
+ context?: Record<string, unknown>;
4
+ };
5
+ declare var __VLS_13: {};
6
+ type __VLS_Slots = {} & {
7
+ empty?: (props: typeof __VLS_13) => any;
8
+ };
9
+ declare const __VLS_base: import("vue").DefineComponent<__VLS_Props, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {
10
+ refresh: () => any;
11
+ }, string, import("vue").PublicProps, Readonly<__VLS_Props> & Readonly<{
12
+ onRefresh?: (() => any) | undefined;
13
+ }>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, false, {}, any>;
14
+ declare const __VLS_export: __VLS_WithSlots<typeof __VLS_base, __VLS_Slots>;
15
+ declare const _default: typeof __VLS_export;
16
+ export default _default;
17
+ type __VLS_WithSlots<T, S> = T & {
18
+ new (): {
19
+ $slots: S;
20
+ };
21
+ };
@@ -0,0 +1,9 @@
1
+ import { type MaybeRefOrGetter } from 'vue';
2
+ import type { TabMeta } from '../../../types.js';
3
+ /**
4
+ * The tabs registered for an area, ordered, with labels translated (`labelKey`, falling back to `label`, then
5
+ * the id) and passed through the `tabs:filter` hook, as plain data (the component itself is resolved by
6
+ * `<WidgetTabs>`). `context` is whatever the host renders the area for (e.g. `{ user }`) and is handed to the
7
+ * filter listeners. Same return shape as `useAsyncData`.
8
+ */
9
+ export declare function useTabs(area: string, context?: MaybeRefOrGetter<Record<string, unknown> | undefined>): import("nuxt/app").AsyncData<TabMeta[] | undefined, import("nuxt/app").NuxtError<unknown> | undefined>;
@@ -0,0 +1,19 @@
1
+ import { computed, toValue } from "vue";
2
+ import { useAsyncData, useNuxtApp } from "#imports";
3
+ import { tabs as registry } from "#widgets/tabs";
4
+ import { sortWidgets } from "../../utils/sort.js";
5
+ export function useTabs(area, context) {
6
+ const nuxtApp = useNuxtApp();
7
+ const i18n = nuxtApp.$i18n;
8
+ const locale = computed(() => i18n?.locale?.value);
9
+ return useAsyncData(`tabs:${area}`, async () => {
10
+ const tabs = sortWidgets(registry[area] ?? []).map(({ component: _component, label, labelKey, ...meta }) => ({
11
+ ...meta,
12
+ labelKey,
13
+ label: (labelKey && i18n?.te?.(labelKey) ? i18n.t?.(labelKey) : void 0) ?? label ?? meta.id
14
+ }));
15
+ const ctx = { area, tabs, context: toValue(context) };
16
+ await nuxtApp.callHook("tabs:filter", ctx);
17
+ return ctx.tabs;
18
+ }, { watch: [locale] });
19
+ }
@@ -0,0 +1,8 @@
1
+ import { type MaybeRefOrGetter } from 'vue';
2
+ import type { WidgetMeta } from '../../../types.js';
3
+ /**
4
+ * The widgets registered for an area, ordered and passed through the `widgets:filter` hook, as plain data
5
+ * (the component itself is resolved by `<WidgetArea>`). `context` is whatever the host renders the area for
6
+ * (e.g. `{ user }`) and is handed to the filter listeners. Same return shape as `useAsyncData`.
7
+ */
8
+ export declare function useWidgets(area: string, context?: MaybeRefOrGetter<Record<string, unknown> | undefined>): import("nuxt/app").AsyncData<WidgetMeta[] | undefined, import("nuxt/app").NuxtError<unknown> | undefined>;
@@ -0,0 +1,13 @@
1
+ import { toValue } from "vue";
2
+ import { useAsyncData, useNuxtApp } from "#imports";
3
+ import { widgets as registry } from "#widgets/registry";
4
+ import { sortWidgets } from "../../utils/sort.js";
5
+ export function useWidgets(area, context) {
6
+ const nuxtApp = useNuxtApp();
7
+ return useAsyncData(`widgets:${area}`, async () => {
8
+ const widgets = sortWidgets((registry[area] ?? []).map(({ component: _component, ...meta }) => meta));
9
+ const ctx = { area, widgets, context: toValue(context) };
10
+ await nuxtApp.callHook("widgets:filter", ctx);
11
+ return ctx.widgets;
12
+ });
13
+ }
@@ -0,0 +1,3 @@
1
+ import { type Component } from 'vue';
2
+ /** The (lazily loaded) component of a registered widget. */
3
+ export declare function getWidgetComponent(area: string, id: string): Component | undefined;
@@ -0,0 +1,12 @@
1
+ import { defineAsyncComponent, markRaw } from "vue";
2
+ import { widgets as registry } from "#widgets/registry";
3
+ const cache = /* @__PURE__ */ new Map();
4
+ export function getWidgetComponent(area, id) {
5
+ const key = `${area}::${id}`;
6
+ if (!cache.has(key)) {
7
+ const widget = (registry[area] ?? []).find((item) => item.id === id);
8
+ if (!widget) return void 0;
9
+ cache.set(key, markRaw(defineAsyncComponent(widget.component)));
10
+ }
11
+ return cache.get(key);
12
+ }
@@ -0,0 +1,3 @@
1
+ import { type Component } from 'vue';
2
+ /** The (lazily loaded) component of a registered tab. */
3
+ export declare function getTabComponent(area: string, id: string): Component | undefined;
@@ -0,0 +1,13 @@
1
+ import { defineAsyncComponent, markRaw } from "vue";
2
+ import { tabs as registry } from "#widgets/tabs";
3
+ const cache = /* @__PURE__ */ new Map();
4
+ export function getTabComponent(area, id) {
5
+ const key = `${area}::${id}`;
6
+ if (!cache.has(key)) {
7
+ const tabs = registry[area] ?? [];
8
+ const tab = tabs.find((item) => item.id === id);
9
+ if (!tab) return void 0;
10
+ cache.set(key, markRaw(defineAsyncComponent(tab.component)));
11
+ }
12
+ return cache.get(key);
13
+ }
@@ -0,0 +1,4 @@
1
+ export { extendWidgets, deriveWidgetId, sortWidgets } from './widget.js';
2
+ export type { WidgetDefinition, RegisteredWidget } from './widget.js';
3
+ export { extendTabs, deriveTabId } from './tab.js';
4
+ export type { TabDefinition, RegisteredTab } from './tab.js';
@@ -0,0 +1,2 @@
1
+ export { extendWidgets, deriveWidgetId, sortWidgets } from "./widget.js";
2
+ export { extendTabs, deriveTabId } from "./tab.js";
@@ -0,0 +1,4 @@
1
+ /** Lower `order` first; widgets without one last, keeping registration order. */
2
+ export declare function sortWidgets<T extends {
3
+ order?: number;
4
+ }>(widgets: T[]): T[];
@@ -0,0 +1,8 @@
1
+ export function sortWidgets(widgets) {
2
+ return [...widgets].sort((a, b) => {
3
+ if (a.order === void 0 && b.order === void 0) return 0;
4
+ if (a.order === void 0) return 1;
5
+ if (b.order === void 0) return -1;
6
+ return a.order - b.order;
7
+ });
8
+ }
@@ -0,0 +1,24 @@
1
+ import type { Nuxt } from '@nuxt/schema';
2
+ import type { TabDefinition } from '../../types.js';
3
+ export type { TabDefinition, RegisteredTab } from '../../types.js';
4
+ /** Id for a tab without one: its component's file name, e.g. `Payments` for `.../Payments.vue`. */
5
+ export declare function deriveTabId(tab: Pick<TabDefinition, 'id' | 'component'>): string;
6
+ /**
7
+ * Build-time API for modules to contribute tabs to a named area, without the host wiring anything. Call it
8
+ * once during a module's own `setup()`; registration order between modules doesn't matter. Tabs are kept apart
9
+ * from widgets, so the same area name can hold both.
10
+ *
11
+ * ```ts
12
+ * import { extendTabs } from '@frigear-nu/widget/utils'
13
+ *
14
+ * extendTabs(nuxt, 'admin.users', {
15
+ * component: resolver.resolve('./runtime/app/tabs/Payments.vue'),
16
+ * labelKey: 'admin.tabs.payments',
17
+ * label: 'Payments',
18
+ * order: 20,
19
+ * })
20
+ * ```
21
+ *
22
+ * A tab registered again under the same `id` in an area replaces the earlier one.
23
+ */
24
+ export declare function extendTabs(nuxt: Nuxt, area: string, tabs: TabDefinition | TabDefinition[]): void;
@@ -0,0 +1,16 @@
1
+ import { basename, extname } from "node:path";
2
+ export function deriveTabId(tab) {
3
+ return tab.id ?? basename(tab.component, extname(tab.component));
4
+ }
5
+ export function extendTabs(nuxt, area, tabs) {
6
+ const normalized = (Array.isArray(tabs) ? tabs : [tabs]).map((tab) => ({ ...tab, id: deriveTabId(tab), area }));
7
+ nuxt.options.widgets ||= {};
8
+ const registry = nuxt.options.widgets._tabs ||= {};
9
+ const list = registry[area] ||= [];
10
+ for (const tab of normalized) {
11
+ const existing = list.findIndex((item) => item.id === tab.id);
12
+ if (existing === -1) list.push(tab);
13
+ else list[existing] = tab;
14
+ }
15
+ nuxt.callHook("tabs:extend", area, normalized);
16
+ }
@@ -0,0 +1,24 @@
1
+ import type { Nuxt } from '@nuxt/schema';
2
+ import type { WidgetDefinition } from '../../types.js';
3
+ import { sortWidgets } from './sort.js';
4
+ export type { WidgetDefinition, RegisteredWidget } from '../../types.js';
5
+ export { sortWidgets };
6
+ /** Id for a widget without one: its component's file name, e.g. `NewMembers` for `.../NewMembers.vue`. */
7
+ export declare function deriveWidgetId(widget: Pick<WidgetDefinition, 'id' | 'component'>): string;
8
+ /**
9
+ * Build-time API for modules to contribute widgets to a named area, without the host wiring anything. Call it
10
+ * once during a module's own `setup()`; registration order between modules doesn't matter.
11
+ *
12
+ * ```ts
13
+ * import { extendWidgets } from '@frigear-nu/widget/utils'
14
+ *
15
+ * extendWidgets(nuxt, 'admin.dashboard', {
16
+ * component: resolver.resolve('./runtime/app/widgets/NewMembers.vue'),
17
+ * order: 10,
18
+ * })
19
+ * ```
20
+ *
21
+ * A widget registered again under the same `id` in an area replaces the earlier one, so a host can override a
22
+ * module's widget by registering its own after it.
23
+ */
24
+ export declare function extendWidgets(nuxt: Nuxt, area: string, widgets: WidgetDefinition | WidgetDefinition[]): void;
@@ -0,0 +1,18 @@
1
+ import { basename, extname } from "node:path";
2
+ import { sortWidgets } from "./sort.js";
3
+ export { sortWidgets };
4
+ export function deriveWidgetId(widget) {
5
+ return widget.id ?? basename(widget.component, extname(widget.component));
6
+ }
7
+ export function extendWidgets(nuxt, area, widgets) {
8
+ const normalized = (Array.isArray(widgets) ? widgets : [widgets]).map((widget) => ({ ...widget, id: deriveWidgetId(widget), area }));
9
+ nuxt.options.widgets ||= {};
10
+ const extensions = nuxt.options.widgets._extensions ||= {};
11
+ const list = extensions[area] ||= [];
12
+ for (const widget of normalized) {
13
+ const existing = list.findIndex((item) => item.id === widget.id);
14
+ if (existing === -1) list.push(widget);
15
+ else list[existing] = widget;
16
+ }
17
+ nuxt.callHook("widgets:extend", area, normalized);
18
+ }
@@ -0,0 +1,3 @@
1
+ export { default } from './module.mjs'
2
+
3
+ export { type ModuleOptions, type RegisteredTab, type RegisteredWidget, type TabDefinition, type TabMeta, type WidgetDefinition, type WidgetMeta } from './module.mjs'
package/package.json CHANGED
@@ -1,6 +1,70 @@
1
1
  {
2
2
  "name": "@frigear-nu/widget",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "0.0.1",
4
+ "description": "Named widget areas - modules contribute widgets (extendWidgets) and hosts render them with <WidgetArea>, with ordering and a filter hook for gating.",
5
+ "homepage": "https://docs.frigear.dev/modules/widget",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/frigear-nu/frigear-nu.git",
9
+ "directory": "packages/widget"
10
+ },
11
+ "license": "MIT",
12
+ "type": "module",
13
+ "main": "./dist/module.mjs",
14
+ "exports": {
15
+ ".": {
16
+ "types": "./dist/types.d.mts",
17
+ "import": "./dist/module.mjs",
18
+ "default": "./dist/module.mjs"
19
+ },
20
+ "./utils": {
21
+ "types": "./dist/runtime/utils/index.d.ts",
22
+ "import": "./dist/runtime/utils/index.js",
23
+ "default": "./dist/runtime/utils/index.js"
24
+ }
25
+ },
26
+ "files": [
27
+ "dist"
28
+ ],
29
+ "workspaces": [
30
+ "playground"
31
+ ],
32
+ "dependencies": {
33
+ "@nuxt/kit": "^4.6.0",
34
+ "@frigear-nu/module-kit": "0.1.6"
35
+ },
36
+ "devDependencies": {
37
+ "@nuxt/devtools": "^3.4.2",
38
+ "@nuxt/eslint-config": "^1.17.0",
39
+ "@nuxt/module-builder": "^1.0.3",
40
+ "@nuxt/schema": "^4.6.0",
41
+ "@nuxt/test-utils": "^4.3.2",
42
+ "@types/node": "latest",
43
+ "changelogen": "^0.6.2",
44
+ "eslint": "^10.10.0",
45
+ "nuxt": "^4.6.1",
46
+ "typescript": "^6.0.3",
47
+ "vitest": "^5.0.1",
48
+ "vue-tsc": "^3.3.11"
49
+ },
50
+ "scripts": {
51
+ "postinstall": "nuxt-module-build build --stub && nuxt prepare",
52
+ "build": "nuxt-module-build build",
53
+ "dev": "npm run dev:prepare && nuxt dev playground",
54
+ "dev:build": "nuxt build playground",
55
+ "dev:prepare": "nuxt-module-build build --stub && nuxt-module-build prepare && nuxt prepare playground",
56
+ "release": "npm run lint && npm run test && npm run prepack && changelogen --release && npm publish && git push --follow-tags",
57
+ "lint": "eslint .",
58
+ "lint:fix": "eslint . --fix",
59
+ "test": "vitest run",
60
+ "test:watch": "vitest watch",
61
+ "test:types": "vue-tsc --noEmit && cd playground && vue-tsc --noEmit"
62
+ },
63
+ "typesVersions": {
64
+ "*": {
65
+ ".": [
66
+ "./dist/types.d.mts"
67
+ ]
68
+ }
69
+ }
6
70
  }