59e995d0b5
Composables, tests, category barrels, and README for @robonen/vue.
146 lines
4.4 KiB
TypeScript
146 lines
4.4 KiB
TypeScript
import { computed, ref, toValue, watch } from 'vue';
|
|
import type { ComputedRef, MaybeRef, MaybeRefOrGetter, Ref } from 'vue';
|
|
import { isFunction, isString } from '@robonen/stdlib';
|
|
import { defaultDocument } from '@/types';
|
|
import type { ConfigurableDocument } from '@/types';
|
|
import { useMutationObserver } from '@/composables/elements/useMutationObserver';
|
|
import { tryOnScopeDispose } from '@/composables/lifecycle/tryOnScopeDispose';
|
|
|
|
export interface UseTitleOptionsBase extends ConfigurableDocument {
|
|
/**
|
|
* Observe the `<title>` element for external changes and sync them back to the ref.
|
|
* Ignored when `titleTemplate` is provided, to avoid a write/observe feedback loop.
|
|
*
|
|
* @default false
|
|
*/
|
|
observe?: boolean;
|
|
|
|
/**
|
|
* Template used to format the title. Every `%s` is replaced with the value.
|
|
*
|
|
* @default '%s'
|
|
*/
|
|
titleTemplate?: MaybeRef<string> | ((title: string) => string);
|
|
|
|
/**
|
|
* Restore the original document title when the active scope is disposed.
|
|
* Pass a function to compute the title to restore, or `false` to keep the
|
|
* last value in place.
|
|
*
|
|
* @default false
|
|
*/
|
|
restoreOnUnmount?: false | ((originalTitle: string, currentTitle: string) => string | null | undefined);
|
|
}
|
|
|
|
export type UseTitleOptions = UseTitleOptionsBase;
|
|
|
|
export type UseTitleReturn = Ref<string | null | undefined> | ComputedRef<string | null | undefined>;
|
|
|
|
/**
|
|
* @name useTitle
|
|
* @category Browser
|
|
* @description Reactive `document.title`. Pass a getter to derive the title from
|
|
* other reactive state (returns a read-only ref), or a plain value/ref for two-way binding.
|
|
*
|
|
* @param {MaybeRefOrGetter<string | null | undefined>} [newTitle] Initial title (getter source returns a read-only ref)
|
|
* @param {UseTitleOptions} [options={}] Options
|
|
* @returns {UseTitleReturn} A ref bound to the document title (read-only when a getter source is passed)
|
|
*
|
|
* @example
|
|
* const title = useTitle();
|
|
* title.value = 'New title';
|
|
*
|
|
* @example
|
|
* useTitle('Dashboard', { titleTemplate: '%s | My App' });
|
|
*
|
|
* @example
|
|
* // Derive from reactive state (read-only result)
|
|
* useTitle(() => `Inbox (${count.value})`);
|
|
*
|
|
* @example
|
|
* // Restore the previous title when the component unmounts
|
|
* useTitle('Checkout', { restoreOnUnmount: (original) => original });
|
|
*
|
|
* @since 0.0.15
|
|
*/
|
|
export function useTitle(
|
|
newTitle: () => string | null | undefined,
|
|
options?: UseTitleOptions,
|
|
): ComputedRef<string | null | undefined>;
|
|
export function useTitle(
|
|
newTitle?: MaybeRef<string | null | undefined>,
|
|
options?: UseTitleOptions,
|
|
): Ref<string | null | undefined>;
|
|
export function useTitle(
|
|
newTitle: MaybeRefOrGetter<string | null | undefined> = null,
|
|
options: UseTitleOptions = {},
|
|
): UseTitleReturn {
|
|
const {
|
|
document = defaultDocument,
|
|
observe = false,
|
|
titleTemplate = '%s',
|
|
restoreOnUnmount = false,
|
|
} = options;
|
|
|
|
const originalTitle = document?.title ?? '';
|
|
const hasTemplate = 'titleTemplate' in options;
|
|
|
|
const isReadonly = isFunction(newTitle);
|
|
|
|
const title = ref<string | null | undefined>(toValue(newTitle) ?? document?.title ?? null);
|
|
|
|
const format = (value: string): string => {
|
|
if (!hasTemplate)
|
|
return value;
|
|
|
|
return isFunction(titleTemplate)
|
|
? titleTemplate(value)
|
|
: toValue(titleTemplate).split('%s').join(value);
|
|
};
|
|
|
|
watch(
|
|
title,
|
|
(value, oldValue) => {
|
|
if (value !== oldValue && document)
|
|
document.title = format(isString(value) ? value : '');
|
|
},
|
|
{ immediate: true },
|
|
);
|
|
|
|
// Keep a read-only ref in sync when the getter source changes
|
|
if (isReadonly) {
|
|
watch(
|
|
() => toValue(newTitle),
|
|
(value) => {
|
|
title.value = value;
|
|
},
|
|
);
|
|
}
|
|
|
|
// Observing only makes sense without a template, otherwise the formatted
|
|
// write would feed back through the observer.
|
|
if (observe && !hasTemplate && document && !isReadonly) {
|
|
useMutationObserver(
|
|
document.head?.querySelector('title'),
|
|
() => {
|
|
if (document && document.title !== title.value)
|
|
title.value = document.title;
|
|
},
|
|
{ childList: true },
|
|
);
|
|
}
|
|
|
|
if (restoreOnUnmount) {
|
|
tryOnScopeDispose(() => {
|
|
const restored = restoreOnUnmount(originalTitle, isString(title.value) ? title.value : '');
|
|
if (restored !== null && restored !== undefined && document)
|
|
document.title = restored;
|
|
});
|
|
}
|
|
|
|
if (isReadonly)
|
|
return computed(() => title.value);
|
|
|
|
return title;
|
|
}
|