diff --git a/src/index.ts b/src/index.ts index b09e72a0..8a02d10a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -34,3 +34,6 @@ export { useSessionStorageValue } from './useSessionStorageValue'; // Sensor export { useResizeObserver, IUseResizeObserverCallback } from './useResizeObserver'; + +// Dom +export { useTitle, IUseTitleOptions } from './useTitle'; diff --git a/src/useTitle.ts b/src/useTitle.ts new file mode 100644 index 00000000..ab2afd06 --- /dev/null +++ b/src/useTitle.ts @@ -0,0 +1,40 @@ +import { useEffect, useRef } from 'react'; +import { isBrowser } from './util/const'; +import { useUnmountEffect } from './useUnmountEffect'; +import { useSyncedRef } from './useSyncedRef'; + +export interface IUseTitleOptions { + /** + * Function that processes title, useful to prefix or suffix the title. + * @param title + */ + wrapper: (title: string) => string; + + /** + * Restore title that was before component mount on unmount. + */ + restoreOnUnmount: boolean; +} + +/** + * Sets title of the page. + * + * @param title Title to set, if wrapper option is set, it will be passed through wrapper function. + * @param options Options object. + */ +export function useTitle(title: string, options: Partial = {}): void { + const titleRef = useRef(isBrowser ? document.title : ''); + const optionsRef = useSyncedRef(options); + + // it is safe not to check isBrowser here, as effects are not invoked in SSR + useEffect(() => { + document.title = options.wrapper ? options.wrapper(title) : title; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [title, options.wrapper]); + + useUnmountEffect(() => { + if (optionsRef.current.restoreOnUnmount) { + document.title = titleRef.current; + } + }); +} diff --git a/stories/Dom/useTitle.stories.tsx b/stories/Dom/useTitle.stories.tsx new file mode 100644 index 00000000..497eb478 --- /dev/null +++ b/stories/Dom/useTitle.stories.tsx @@ -0,0 +1,26 @@ +import React from 'react'; +import { useTitle, useToggle } from '../../src'; + +export const Example: React.FC = () => { + const [mounted, toggleMounted] = useToggle(false); + + const titleWrapper = (title: string) => `@react-hookz/web is ${title}`; + + const ChildComponent: React.FC = () => { + useTitle('awesome!', { + wrapper: titleWrapper, + restoreOnUnmount: true, + }); + + return
Child component is mounted
; + }; + + return ( +
+ While child component is mounted, document will have custom title and it will be reset to + default when component is unmounted.
+ + {mounted && } +
+ ); +}; diff --git a/stories/Dom/useTitle.story.mdx b/stories/Dom/useTitle.story.mdx new file mode 100644 index 00000000..95b064d2 --- /dev/null +++ b/stories/Dom/useTitle.story.mdx @@ -0,0 +1,42 @@ +import { Canvas, Meta, Story } from '@storybook/addon-docs/blocks'; +import { Example } from './useTitle.stories'; + + + +# useTitle + +Sets title of the page. + +> If `restoreOnUnmount` option is true the hook will restore title to the state it was on component +> mount. + +> `wrapper` option is useful to prefix or suffix the title all over the page. + +##### Example + + + + + +> Due to storybook structure, above example is changing the title of an iframe but not the browser +> window. We are working on solving the problem. + +## Reference + +```ts +export interface IUseTitleOptions { + wrapper: (title: string) => string; + restoreOnUnmount: boolean; +} + +function useTitle(title: string, options: Partial = {}): void; +``` + +#### Arguments + +- **title** _`string`_ - title string to set. +- **options** _`object`_ - Hook options: +- **wrapper** _`(title: string) => string`_ _(default: undefined)_ - Function that receives new + title and returns wrapped title. +- **restoreOnUnmount** _`boolean`_ _(default: undefined)_ - Whether to restore page title on + component unmount. diff --git a/tests/dom/useTitle.test.ts b/tests/dom/useTitle.test.ts new file mode 100644 index 00000000..4cd290b8 --- /dev/null +++ b/tests/dom/useTitle.test.ts @@ -0,0 +1,57 @@ +import { renderHook } from '@testing-library/react-hooks/dom'; +import { useTitle } from '../../src'; + +describe('useTitle', () => { + it('should be defined', () => { + expect(useTitle).toBeDefined(); + }); + + it('should render', () => { + const { result } = renderHook(() => useTitle('some title')); + expect(result.error).toBeUndefined(); + }); + + it('should unmount without errors', () => { + const { unmount, result } = renderHook(() => + useTitle('some title', { restoreOnUnmount: true }) + ); + + unmount(); + expect(result.error).toBeUndefined(); + }); + + it('should set given string to the document title', () => { + renderHook(() => useTitle('foo')); + expect(document.title).toBe('foo'); + }); + + it('should pass title through wrapper', () => { + const wrapper = (str: string) => `${str} bar`; + renderHook(() => useTitle('foo', { wrapper })); + expect(document.title).toBe('foo bar'); + }); + + it('should update title if title or wrapper changed', () => { + let wrapperSpy = (str: string) => `${str} bar`; + const { rerender } = renderHook(({ title, wrapper }) => useTitle(title, { wrapper }), { + initialProps: { title: 'foo', wrapper: wrapperSpy }, + }); + expect(document.title).toBe('foo bar'); + + rerender({ title: 'bar', wrapper: wrapperSpy }); + expect(document.title).toBe('bar bar'); + + wrapperSpy = (str: string) => `${str} baz`; + rerender({ title: 'bar', wrapper: wrapperSpy }); + expect(document.title).toBe('bar baz'); + }); + + it('should set previous title in case `restoreOnUnmount` options is truthy', () => { + document.title = 'bar'; + const { unmount } = renderHook(() => useTitle('foo', { restoreOnUnmount: true })); + expect(document.title).toBe('foo'); + + unmount(); + expect(document.title).toBe('bar'); + }); +}); diff --git a/tests/ssr/useTitle.test.ts b/tests/ssr/useTitle.test.ts new file mode 100644 index 00000000..5ae52bb3 --- /dev/null +++ b/tests/ssr/useTitle.test.ts @@ -0,0 +1,22 @@ +import { renderHook } from '@testing-library/react-hooks/server'; +import { useTitle } from '../../src'; + +describe('useTitle', () => { + it('should be defined', () => { + expect(useTitle).toBeDefined(); + }); + + it('should render', () => { + const { result } = renderHook(() => useTitle('some title')); + expect(result.error).toBeUndefined(); + }); + + it('should unmount without errors', () => { + const { unmount, result } = renderHook(() => + useTitle('some title', { restoreOnUnmount: true }) + ); + + unmount(); + expect(result.error).toBeUndefined(); + }); +});