diff --git a/docs/content/1.usage/2.guides/3.class-attr.md b/docs/content/1.usage/2.guides/3.class-attr.md index f208e114..30eb0541 100644 --- a/docs/content/1.usage/2.guides/3.class-attr.md +++ b/docs/content/1.usage/2.guides/3.class-attr.md @@ -1,45 +1,79 @@ --- -title: Class Attribute -description: Learn how to use the class attribute with Unhead. +title: Class & Style Attributes +description: Style your pages by applying classes and styles to your `` and `` tags. --- -When using the `htmlAttrs` or `bodyAttrs` options, you can use the `class` attribute to add classes to the `html` or `body` elements. +When you need to style your page by adding classes or styles to the `` or ``, Unhead makes it easy by +providing object and array support for the `class` and `style` attributes. -```ts +## Static Classes & Styles + +If your classes or styles aren't going to change, you can provide them as a string. + +::code-block + +```ts [Html Attrs] useHead({ htmlAttrs: { - class: 'my-class', + class: 'my-class my-other-class', + style: 'background-color: red; color: white;' } }) ``` -For improved reactivity and merging support, you can provide the class as an object or an array. +```ts [Body Attrs] +useHead({ + bodyAttrs: { + class: 'my-class my-other-class', + style: 'background-color: red; color: white;' + } +}) +``` +:: -## Class Object +Tip: If you're server-side rendering and applying +it to your default layout, you can use [useServerHead](/api/useServerHead) for a minor performance improvement. -When providing class as an object, the key should be the class and the value will be whether the class should be added or not. +### Array Classes & Styles -```ts -const darkMode = false +Using manual separators for classes and styles can be a bit cumbersome, so Unhead allows you to use arrays for both. +```ts useHead({ htmlAttrs: { - class: { - // will be rendered - dark: darkMode, - // will not be rendered - light: !darkMode, - } + class: [ + 'my-class', + 'my-other-class' + ], + style: [ + 'background-color: red', + 'color: white' + ], } }) ``` -## Class Array +## Dynamic Classes & Styles + +For improved reactivity and merging support, you can provide the class as an object or an array. + +When providing class as an object, the key should be the class and the value will be whether the class should be added or not. ```ts +const darkMode = ref(false) + useHead({ htmlAttrs: { - class: ['my-class', 'my-other-class'], + class: { + // will be rendered + dark: () => darkMode, + // will not be rendered + light: () => !darkMode, + }, + style: { + // will not render when darkMode is false + 'background-color': () => darkMode ? 'rgba(0, 0, 0, 0.9)' : false, + } } }) ``` diff --git a/packages/shared/src/normalise.ts b/packages/shared/src/normalise.ts index 2c1ad555..a045d963 100644 --- a/packages/shared/src/normalise.ts +++ b/packages/shared/src/normalise.ts @@ -47,26 +47,29 @@ export async function normaliseTag(tagName: T['tag'], input: : tag } -export function normaliseClassProp(v: Required['htmlAttrs']['class']>) { + +export function normaliseStyleClassProps(key: T, v: Required['htmlAttrs']['class']> | Required['htmlAttrs']['style']>) { + const sep = key === 'class' ? ' ' : ';' if (typeof v === 'object' && !Array.isArray(v)) { - // @ts-expect-error untyped - v = Object.keys(v).filter(k => v[k]) + v = Object.entries(v) + .filter(([, v]) => v) + .map(([k, v]) => key === 'style' ? `${k}:${v}` : k) } // finally, check we don't have spaces, we may need to split again - return (Array.isArray(v) ? v.join(' ') : v as string) - .split(' ') + return (Array.isArray(v) ? v.join(sep) : v as string) + .split(sep) .filter(c => c.trim()) .filter(Boolean) - .join(' ') + .join(sep) } export async function normaliseProps(props: T['props'], virtual?: boolean): Promise { // handle boolean props, see https://html.spec.whatwg.org/#boolean-attributes for (const k of Object.keys(props)) { // class has special handling - if (k === 'class') { + if (['class', 'style'].includes(k)) { // @ts-expect-error untyped - props[k] = normaliseClassProp(props[k]) + props[k] = normaliseStyleClassProps(k, props[k]) continue } // first resolve any promises diff --git a/packages/vue/src/types/schema.ts b/packages/vue/src/types/schema.ts index c40de07d..e36e2e90 100644 --- a/packages/vue/src/types/schema.ts +++ b/packages/vue/src/types/schema.ts @@ -11,13 +11,19 @@ export interface HtmlAttr extends Omit { class?: MaybeArray> | Record> } -export interface BodyAttr extends Omit { +export interface BodyAttr extends Omit { /** * The class global attribute is a space-separated list of the case-sensitive classes of the element. * * @see https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes/class */ class?: MaybeArray> | Record> + /** + * The class global attribute is a space-separated list of the case-sensitive classes of the element. + * + * @see https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes/class + */ + style?: MaybeArray> | Record> } export type Title = MaybeComputedRef<_Title> diff --git a/test/unhead/ssr/tagDuplicateStrategy.test.ts b/test/unhead/ssr/tagDuplicateStrategy.test.ts index be954f04..a6a33689 100644 --- a/test/unhead/ssr/tagDuplicateStrategy.test.ts +++ b/test/unhead/ssr/tagDuplicateStrategy.test.ts @@ -41,7 +41,7 @@ describe('tagDuplicateStrategy', () => { const { htmlAttrs } = await renderSSRHead(head) expect(htmlAttrs).toMatchInlineSnapshot( - `" class="html-doc my-specific-page" style="color: red; background: green;""`, + `" class="html-doc my-specific-page" style="color: red; background: green""`, ) }) }) diff --git a/test/vue/dom/styles.test.ts b/test/vue/dom/styles.test.ts new file mode 100644 index 00000000..05f226e8 --- /dev/null +++ b/test/vue/dom/styles.test.ts @@ -0,0 +1,78 @@ +import { describe, it } from 'vitest' +import { createHead, setHeadInjectionHandler, useHead } from '@unhead/vue' +import { computed, ref } from 'vue' +import { renderDOMHead } from '@unhead/dom' +import { useDom } from '../../fixtures' + +describe('vue dom styles', () => { + it('empty style', async () => { + const dom = useDom() + + const head = createHead({ document: dom.window.document }) + setHeadInjectionHandler(() => head) + + const isNavActive = ref(false) + + useHead({ + bodyAttrs: { + style: computed(() => { + return isNavActive.value ? 'background-color: red' : '' + }), + }, + }) + + await renderDOMHead(head, { document: dom.window.document }) + expect(dom.window.document.body.getAttribute('style')).toEqual(null) + isNavActive.value = true + + // wait 100ms + await new Promise(resolve => setTimeout(resolve, 100)) + await renderDOMHead(head, { document: dom.window.document }) + expect(dom.window.document.body.getAttribute('style')).toEqual('background-color: red;') + }) + it('array style', async() => { + const dom = useDom() + + const head = createHead({ document: dom.window.document }) + setHeadInjectionHandler(() => head) + + useHead({ + bodyAttrs: { + style: [ + 'background-color: red', + 'color: white', + ], + }, + }) + + await renderDOMHead(head, { document: dom.window.document }) + + expect(dom.window.document.body.getAttribute('style')).toEqual(`background-color: red; color: white;`) + }) + it('object style', async () => { + const dom = useDom() + + const head = createHead({ document: dom.window.document }) + setHeadInjectionHandler(() => head) + + const isNavActive = ref(false) + + useHead({ + bodyAttrs: { + style: { + 'background-color': () => isNavActive.value ? 'red' : '', + }, + }, + }) + + await renderDOMHead(head, { document: dom.window.document }) + expect(dom.window.document.body.getAttribute('style')).toEqual(null) + + isNavActive.value = true + + // wait 100ms + await new Promise(resolve => setTimeout(resolve, 100)) + await renderDOMHead(head, { document: dom.window.document }) + expect(dom.window.document.body.getAttribute('style')).toEqual('background-color: red;') + }) +})