Skip to content

Commit

Permalink
fix(unhead): support normalising style
Browse files Browse the repository at this point in the history
Fixes #324
  • Loading branch information
harlan-zw committed Mar 11, 2024
1 parent 641e74b commit 8e07d35
Show file tree
Hide file tree
Showing 5 changed files with 149 additions and 28 deletions.
70 changes: 52 additions & 18 deletions docs/content/1.usage/2.guides/3.class-attr.md
Original file line number Diff line number Diff line change
@@ -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 `<html>` and `<body>` 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 `<html>` or `<body>`, 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,
}
}
})
```
19 changes: 11 additions & 8 deletions packages/shared/src/normalise.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,26 +47,29 @@ export async function normaliseTag<T extends HeadTag>(tagName: T['tag'], input:
: tag
}

export function normaliseClassProp(v: Required<Required<Head>['htmlAttrs']['class']>) {

export function normaliseStyleClassProps<T extends 'class' | 'style'>(key: T, v: Required<Required<Head>['htmlAttrs']['class']> | Required<Required<Head>['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<T extends HeadTag>(props: T['props'], virtual?: boolean): Promise<T['props']> {
// 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
Expand Down
8 changes: 7 additions & 1 deletion packages/vue/src/types/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,19 @@ export interface HtmlAttr extends Omit<BaseHtmlAttr, 'class'> {
class?: MaybeArray<MaybeComputedRef<string>> | Record<string, MaybeComputedRef<boolean>>
}

export interface BodyAttr extends Omit<BaseBodyAttr, 'class'> {
export interface BodyAttr extends Omit<BaseBodyAttr, 'class' | 'style'> {
/**
* 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<MaybeComputedRef<string>> | Record<string, MaybeComputedRef<boolean>>
/**
* 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<MaybeComputedRef<string>> | Record<string, MaybeComputedRef<string | boolean>>
}

export type Title = MaybeComputedRef<_Title>
Expand Down
2 changes: 1 addition & 1 deletion test/unhead/ssr/tagDuplicateStrategy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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""`,
)
})
})
78 changes: 78 additions & 0 deletions test/vue/dom/styles.test.ts
Original file line number Diff line number Diff line change
@@ -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;')
})
})

0 comments on commit 8e07d35

Please sign in to comment.