Files
tools/vue/toolkit/src/composables/component/createReusableTemplate/index.ts
T
robonen ab6d8f6ce0
Publish to NPM / Check version changes and publish (push) Failing after 10m34s
build: bump new versions
2026-06-18 02:57:03 +07:00

176 lines
6.5 KiB
TypeScript

import type { ComponentObjectPropsOptions, DefineComponent, Slot } from 'vue';
import { camelize, defineComponent, shallowRef } from 'vue';
/**
* Map of slot name -> slot props object (or `undefined` for prop-less slots).
* The inner `Record<string, any>` is the idiomatic "any slot-props shape" bound:
* interface-typed slot props (which lack an implicit index signature) must satisfy
* it, so `Record<string, unknown>` would wrongly reject legitimate callers.
*/
type SlotPropsMap = Record<string, Record<string, any> | undefined>;
/** Turn a {@link SlotPropsMap} into a record of typed `Slot`s */
type GenerateSlotsFromSlotMap<T extends SlotPropsMap>
= { [K in keyof T]: Slot<T[K]> };
// `Bindings extends Record<string, any>` is the idiomatic "any object shape" bound,
// matching Vue/Reka's own component-binding generics: an interface-typed `Bindings`
// must satisfy it, which `Record<string, unknown>` would reject. Applies to every
// `extends Record<string, any>` constraint in this file.
export type DefineTemplateComponent<Bindings extends Record<string, any>, Slots extends SlotPropsMap>
= DefineComponent & (new () => {
$slots: {
// Slot render fn: returns `any` to match Vue's own `Slot` return type.
default: (_: Bindings & { $slots: GenerateSlotsFromSlotMap<Slots> }) => any;
};
});
export type ReuseTemplateComponent<Bindings extends Record<string, any>, Slots extends SlotPropsMap>
= DefineComponent<Bindings> & (new () => { $slots: GenerateSlotsFromSlotMap<Slots> });
/**
* The pair returned by {@link createReusableTemplate}. Usable both as a tuple
* (`const [Define, Reuse] = ...`) and as an object (`const { define, reuse } = ...`).
*/
export type ReusableTemplatePair<Bindings extends Record<string, any>, Slots extends SlotPropsMap>
= [DefineTemplateComponent<Bindings, Slots>, ReuseTemplateComponent<Bindings, Slots>] & {
define: DefineTemplateComponent<Bindings, Slots>;
reuse: ReuseTemplateComponent<Bindings, Slots>;
};
export interface CreateReusableTemplateOptions<Props extends Record<string, any>> {
/**
* Inherit attrs from the reuse component onto its single root vnode.
*
* @default true
*/
inheritAttrs?: boolean;
/**
* Name used for the define/reuse components (helpful in Vue devtools).
*
* @default 'ReusableTemplate'
*/
name?: string;
/**
* Props definition for the reuse component. When provided, bindings are taken
* from typed props instead of raw (camelized) attrs.
*/
props?: ComponentObjectPropsOptions<Props>;
}
/** Re-key an attrs object so every key is camelCased */
function keysToCamelCase(obj: Record<string, unknown>): Record<string, unknown> {
const result: Record<string, unknown> = {};
for (const key in obj)
result[camelize(key)] = obj[key];
return result;
}
/**
* Wrap a `{ define, reuse }` object so it can also be destructured as the tuple
* `[define, reuse]`. Avoids a runtime dependency on `@vueuse/shared`.
*/
function makePair<
Bindings extends Record<string, any>,
Slots extends SlotPropsMap,
>(
define: DefineTemplateComponent<Bindings, Slots>,
reuse: ReuseTemplateComponent<Bindings, Slots>,
): ReusableTemplatePair<Bindings, Slots> {
const pair = [define, reuse] as unknown as ReusableTemplatePair<Bindings, Slots>;
pair.define = define;
pair.reuse = reuse;
return pair;
}
/**
* @name createReusableTemplate
* @category Component
* @description Define a template once and reuse it multiple times within the
* same component. Returns a `[DefineTemplate, ReuseTemplate]` pair (also
* destructurable as `{ define, reuse }`). The template captured by
* `DefineTemplate`'s default slot is rendered wherever `ReuseTemplate` appears,
* receiving its props/attrs as slot bindings. Supports a generic for typed
* bindings, typed slots, custom `props`, and `inheritAttrs`.
*
* Render-only and fully SSR-safe — it never touches `window`/`document`. The pair
* is created lazily and shares a single `shallowRef` for the captured render
* function, so there are no watchers and no per-render allocations beyond the
* vnode itself.
*
* @param {CreateReusableTemplateOptions<Bindings>} [options] - `name`, `inheritAttrs`, and `props`
* @returns {ReusableTemplatePair<Bindings, Slots>} A `[define, reuse]` tuple, also accessible as `{ define, reuse }`
*
* @example
* const [DefineTemplate, ReuseTemplate] = createReusableTemplate();
* // Template:
* // <DefineTemplate><span>Hello</span></DefineTemplate>
* // <ReuseTemplate /> <ReuseTemplate />
*
* @example
* // Typed bindings + custom props
* const [DefineItem, ReuseItem] = createReusableTemplate<{ label: string }>();
* // <DefineItem v-slot="{ label }">{{ label }}</DefineItem>
* // <ReuseItem label="A" /> <ReuseItem label="B" />
*
* @since 0.0.14
*/
export function createReusableTemplate<
Bindings extends Record<string, any>,
Slots extends SlotPropsMap = Record<'default', undefined>,
>(
options: CreateReusableTemplateOptions<Bindings> = {},
): ReusableTemplatePair<Bindings, Slots> {
const {
inheritAttrs = true,
name = 'ReusableTemplate',
props,
} = options;
// Shared captured render fn — no watchers, single allocation.
const render = shallowRef<Slot | undefined>();
const define = defineComponent({
name: `${name}.define`,
setup(_, { slots }) {
return () => {
render.value = slots.default;
};
},
}) as unknown as DefineTemplateComponent<Bindings, Slots>;
const reuse = defineComponent({
name: `${name}.reuse`,
inheritAttrs,
props,
setup(reuseProps, { attrs, slots }) {
return () => {
if (!render.value) {
// Local cast so the dev-only guard type-checks without @types/node and stays
// tree-shakeable in production builds (where NODE_ENV is statically replaced).
const nodeEnv = (globalThis as { process?: { env?: { NODE_ENV?: string } } }).process?.env?.NODE_ENV;
if (nodeEnv !== 'production')
throw new Error('[createReusableTemplate] Failed to find the template definition. Did you render the Define component before the Reuse component?');
return undefined;
}
const vnode = render.value({
...(props === undefined ? keysToCamelCase(attrs) : reuseProps),
$slots: slots,
});
// When inheriting attrs onto a single root, unwrap the fragment so Vue
// can merge the reuse component's attrs onto that root vnode.
return inheritAttrs && vnode?.length === 1 ? vnode[0] : vnode;
};
},
}) as unknown as ReuseTemplateComponent<Bindings, Slots>;
return makePair(define, reuse);
}