176 lines
6.5 KiB
TypeScript
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);
|
|
}
|