Skip to content

Commit

Permalink
feat: overall improvements (#1)
Browse files Browse the repository at this point in the history
* feat: improvements

* chore: update deps

* fix: use global name for script

* test: collect coverage only from module

* docs: add hid and globalName

* docs: mention uknown state

* feat: `<color-scheme>` component

* feat: always enable unknown on static mode for server plugin

* feat: enable unknown if req is not available

* chore: improve caveats section

Co-authored-by: Sébastien Chopin <seb@chopin.io>
  • Loading branch information
pi0 and atinux authored Apr 15, 2020
1 parent 3439d71 commit be3dd4b
Show file tree
Hide file tree
Showing 16 changed files with 312 additions and 161 deletions.
3 changes: 1 addition & 2 deletions .eslintignore
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,4 @@ dist
.nuxt
coverage

# Plugin
lib/templates/*
*.min.js
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,4 @@ node_modules
.DS_Store
coverage
dist
*.min.js
23 changes: 20 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,8 +54,9 @@ yarn add --dev @nuxtjs/color-mode
## Usage

It injects `$colorMode` helper with:
- `preference`: actual color-mode selected (can be `'system'`), update it to change the user prefered color mode
- `value`: useful to know what color mode has been detected when `$colorMode === 'system'`, you should not update it
- `preference`: Actual color-mode selected (can be `'system'`), update it to change the user prefered color mode
- `value`: Useful to know what color mode has been detected when `$colorMode === 'system'`, you should not update it
- `unknown`: Useful to know if during SSR or Generate, we need to render a placeholder

```vue
<template>
Expand Down Expand Up @@ -96,6 +97,9 @@ You can configure the module by providing the `colorMode` property in your `nuxt
colorMode: {
preference: 'system', // default value of $colorMode.preference
fallback: 'light', // fallback value if not system preference found
hid: 'nuxt-color-mode-script',
globalName: '__NUXT_COLOR_MODE__',
componentName: 'ColorScheme',
cookie: {
key: 'nuxt-color-mode',
options: {
Expand All @@ -111,7 +115,20 @@ Notes:

## Caveats

With `nuxt generate` and using `$colorMode` in your Vue template, you may expect a flash. This is due to the fact that we cannot know the user preferences when pre-rendering the page, it will directly set the `fallback` value (or `default` value if no set to `'system'`).
If you are doing SSR (`nuxt start` or `nuxt generate`) and if `$colorMode.preference` is set to `'system'`, using `$colorMode` in your Vue template will lead to a flash. This is due to the fact that we cannot know the user preferences when pre-rendering the page since they are detected on client-side.

You have to guard any rendering path which depends on `$colorMode` with `$colorMode.unknown` to render a placeholder or directory use our `<ColorScheme>` component.

***Example:**

```vue
<template>
<ColorScheme placeholder="..." tag="span">
Color mode: <b>{{ $colorMode.preference }}</b>
<span v-if="$colorMode.preference === 'system'">(<i>{{ $colorMode.value }}</i> mode detected)</span>
</ColorScheme>
</template>
```

## TailwindCSS Dark Mode

Expand Down
6 changes: 3 additions & 3 deletions example/components/ColorModePicker.vue
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,10 @@
</li>
</ul>
<p>
<client-only placeholder="..." placeholder-tag="span">
<color-scheme placeholder="..." tag="span">
Color mode: <b>{{ $colorMode.preference }}</b>
<span v-if="$colorMode.preference === 'system'">(<i>{{ $colorMode.value }}</i> mode detected)</span>
</client-only>
</color-scheme>
</p>
</div>
</template>
Expand All @@ -39,7 +39,7 @@ export default {
methods: {
getClasses (color) {
// Does not set classes on ssr preference is system (because we know them on client-side)
if (process.server && this.$colorMode.preference === 'system') {
if (this.$colorMode.unknown) {
return {}
}
return {
Expand Down
4 changes: 2 additions & 2 deletions jest.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@ module.exports = {
testEnvironment: 'node',
collectCoverage: true,
collectCoverageFrom: [
'lib/**/*.js',
'!lib/templates/**/*.js'
'lib/module.js',
'lib/utils.js'
],
moduleNameMapper: {
'^~/(.*)$': '<rootDir>/lib/$1',
Expand Down
47 changes: 21 additions & 26 deletions lib/module.js
Original file line number Diff line number Diff line change
@@ -1,12 +1,16 @@
const { readFile } = require('fs').promises
const { resolve } = require('path')
const defu = require('defu')
const template = require('lodash.template')
import { resolve } from 'path'
import { promises as fsp } from 'fs'
import defu from 'defu'
import template from 'lodash.template'
import { addTemplates } from './utils'

module.exports = async function (moduleOptions) {
export default async function (moduleOptions) {
const defaults = {
preference: 'system',
fallback: 'light',
hid: 'nuxt-color-mode-script',
globalName: '__NUXT_COLOR_MODE__',
componentName: 'ColorScheme',
cookie: {
key: 'nuxt-color-mode',
options: {
Expand All @@ -20,32 +24,23 @@ module.exports = async function (moduleOptions) {
...moduleOptions
}, defaults)

// Add plugin to inject $colorMode
this.addPlugin({
src: resolve(__dirname, 'templates', 'plugins', 'color-mode.client.js'),
fileName: 'plugins/color-mode.client.js',
options
})
this.addPlugin({
src: resolve(__dirname, 'templates', 'plugins', 'color-mode.server.js'),
fileName: 'plugins/color-mode.server.js',
options
})
// Add all templates
const templatesDir = resolve(__dirname, 'templates')
await addTemplates.call(this, templatesDir, 'color-mode', options)

// Add script to head to detect user or system preference before loading Nuxt (for SSR)
let script = await readFile(resolve(__dirname, 'templates', 'script.js'), 'utf-8')
script = template(script)({ options })
// minify script for production
if (!this.options.dev) {
const { minify } = require('terser')
script = minify(script).code
}
const scriptPath = resolve(__dirname, 'script.min.js')
const scriptT = await fsp.readFile(scriptPath, 'utf-8')
const script = template(scriptT)({ options })

this.options.head.script = this.options.head.script || []
this.options.head.script.push({
hid: 'nuxt-color-mode-script',
hid: options.hid,
innerHTML: script,
pbody: true
})
this.options.head.__dangerouslyDisableSanitizersByTagID = this.options.head.__dangerouslyDisableSanitizersByTagID || {}
this.options.head.__dangerouslyDisableSanitizersByTagID['nuxt-color-mode-script'] = ['innerHTML']

const serializeProp = '__dangerouslyDisableSanitizersByTagID'
this.options.head[serializeProp] = this.options.head[serializeProp] || {}
this.options.head[serializeProp][options.hid] = ['innerHTML']
}
71 changes: 71 additions & 0 deletions lib/script.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
// Add dark / light detection that runs before loading Nuxt.js

// Global variable minimizers
const w = window
const d = document
const de = d.documentElement

const knownColorSchemes = ['dark', 'light']

const preference = getCookie('<%= options.cookie.key %>') || '<%= options.preference %>'
const value = preference === 'system' ? getColorScheme() : preference

addClass(value)

w['<%= options.globalName %>'] = {
preference,
value,
getColorScheme,
addClass,
removeClass
}

function getCookie (name) {
const nameEQ = name + '='
const cookies = d.cookie.split(';')

for (let i = 0; i < cookies.length; i++) {
let cookie = cookies[i]
while (cookie.charAt(0) === ' ') {
cookie = cookie.substring(1, cookie.length)
}
if (cookie.indexOf(nameEQ) === 0) {
return cookie.substring(nameEQ.length, cookie.length)
}
}
return null
}

function addClass (value) {
const className = value + '-mode'
if (de.classList) {
de.classList.add(className)
} else {
de.className += ' ' + className
}
}

function removeClass (value) {
const className = value + '-mode'
if (de.classList) {
de.classList.remove(className)
} else {
de.className = de.className.replace(new RegExp(className, 'g'), '')
}
}

function prefersColorScheme (suffix) {
return w.matchMedia('(prefers-color-scheme' + suffix + ')')
}

function getColorScheme () {
if (w.matchMedia && prefersColorScheme('').media !== 'not all') {
for (const colorScheme of knownColorSchemes) {
if (prefersColorScheme(':' + colorScheme).matches) {
return colorScheme
}
}
}

return '<%= options.fallback %>'
}
17 changes: 17 additions & 0 deletions lib/templates/color-scheme.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
export default {
name: '<%= options.componentName %>',
functional: true,
render (createElement, context) {
const tag = context.tag || 'span'
const { $colorMode } = context.parent

if (!$colorMode.unknown) {
return createElement(tag, context.data, context.children)
}

return createElement('client-only', {
'placeholder-tag': tag,
...context.data
}, context.children)
}
}
75 changes: 75 additions & 0 deletions lib/templates/plugin.client.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import Vue from 'vue'
import { serialize } from 'cookie'
import colorSchemeComponent from './color-scheme'

Vue.component('<%= options.componentName %>', colorSchemeComponent)

const cookieKey = '<%= options.cookie.key %>'
const cookieOptions = JSON.parse('<%= JSON.stringify(options.cookie.options) %>')
const colorMode = window['<%= options.globalName %>']

export default function (ctx, inject) {
const $colorMode = new Vue({
data: {
preference: colorMode.preference,
value: colorMode.value,
unknown: false
},
watch: {
preference (preference) {
if (preference === 'system') {
this.value = colorMode.getColorScheme()
this._watchMedia()
} else {
this.value = preference
}

this._storePreference(preference)
},
value (newValue, oldValue) {
colorMode.removeClass(oldValue)
colorMode.addClass(newValue)
}
},
created () {
if (this.preference === 'system') {
this._watchMedia()
}
if (window.localStorage) {
this._watchStorageChange()
}
},
methods: {
_watchMedia () {
if (this._mediaWatcher || !window.matchMedia) {
return
}

this._darkWatcher = window.matchMedia('(prefers-color-scheme: dark)')
this._darkWatcher.addListener((e) => {
if (this.preference === 'system') {
this.value = colorMode.getColorScheme()
}
})
},
_watchStorageChange () {
window.addEventListener('storage', (e) => {
if (e.key === cookieKey) {
this.preference = e.newValue
}
})
},
_storePreference (preference) {
// Cookies for SSR
document.cookie = serialize(cookieKey, preference, cookieOptions)

// Local storage to sync with other tabs
if (window.localStorage) {
window.localStorage.setItem(cookieKey, preference)
}
}
}
})

inject('colorMode', $colorMode)
}
Original file line number Diff line number Diff line change
@@ -1,21 +1,26 @@
import Vue from 'vue'
import { parse } from 'cookie'

import colorSchemeComponent from './color-scheme'
const cookieKey = '<%= options.cookie.key %>'

Vue.component('<%= options.componentName %>', colorSchemeComponent)

export default function (ctx, inject) {
// Read from cookie
let preference = '<%= options.preference %>'

// Try to read from cookies
if (ctx.req) {
// Check if cookie exist, otherwise TypeError: argument str must be a string
const cookies = parse(ctx.req.headers.cookie || '')

if (cookies[cookieKey] && cookies[cookieKey] !== preference) {
if (cookies[cookieKey]) {
preference = cookies[cookieKey]
}
}

const colorMode = {
preference: preference,
value: preference
preference,
value: preference,
unknown: process.static || !ctx.req || preference === 'system'
}

inject('colorMode', colorMode)
Expand Down
Loading

0 comments on commit be3dd4b

Please sign in to comment.