diff --git a/demo/SiteHeader.vue b/demo/SiteHeader.vue index 9c3b62ae4ef..9f2d3ebc433 100644 --- a/demo/SiteHeader.vue +++ b/demo/SiteHeader.vue @@ -10,7 +10,7 @@ import { useDocOptions, useFlattenedDocOptions, useLocaleName, - useThemeName + useThemeMode } from './store' import { renderMenuLabel } from './store/menu-options' import { i18n, useIsMobile, useIsTablet } from './utils/composables' @@ -32,6 +32,8 @@ const locales = { 'zh-CN': { dark: '深色', light: '浅色', + osTheme: '跟随系统', + theme: '主题', searchPlaceholder: '搜索', home: '首页', doc: '文档', @@ -45,6 +47,8 @@ const locales = { 'en-US': { dark: 'Dark', light: 'Light', + osTheme: 'System', + theme: 'Theme', searchPlaceholder: 'Search', home: 'Home', doc: 'Docs', @@ -68,17 +72,24 @@ export default defineComponent({ const router = useRouter() const mobilePopoverRef = ref(null) - const themeAndLocaleReg = /^(\/[^/]+){2}/ + const localeReg = /^\/[^/]+/ // i18n const { t } = i18n(locales) // theme - const themeNameRef = useThemeName() - const themeLabelMapRef = computed(() => ({ - dark: t('light'), - light: t('dark') - })) + const themePreferenceRef = useThemeMode() + const themeOptionsRef = computed(() => [ + { label: t('light'), value: 'light' }, + { label: t('dark'), value: 'dark' }, + { label: t('osTheme'), value: 'auto' } + ]) + const themePreferenceLabelRef = computed( + () => + themeOptionsRef.value.find( + option => option.value === themePreferenceRef.value + )?.label + ) // locale const localeNameRef = useLocaleName() @@ -89,17 +100,17 @@ export default defineComponent({ { key: 'home', label: t('home'), - path: themeAndLocaleReg.exec(route.path)[0] + path: localeReg.exec(route.path)[0] }, { key: 'doc', label: t('doc'), - path: `${themeAndLocaleReg.exec(route.path)[0]}/docs/introduction` + path: `${localeReg.exec(route.path)[0]}/docs/introduction` }, { key: 'component', label: t('component'), - path: `${themeAndLocaleReg.exec(route.path)[0]}/components/button` + path: `${localeReg.exec(route.path)[0]}/components/button` } ] }) @@ -120,7 +131,11 @@ export default defineComponent({ return [ { key: 'theme', - label: themeLabelMapRef.value[themeNameRef.value] + label: `${t('theme')}: ${themePreferenceLabelRef.value}`, + children: themeOptionsRef.value.map(option => ({ + key: `theme:${option.value}`, + label: option.label + })) }, { key: 'locale', @@ -129,18 +144,18 @@ export default defineComponent({ { key: 'home', label: t('home'), - path: themeAndLocaleReg.exec(route.path)[0] + path: localeReg.exec(route.path)[0] }, { key: 'doc', label: t('doc'), children: docOptionsRef.value, - path: `${themeAndLocaleReg.exec(route.path)[0]}/docs/introduction` + path: `${localeReg.exec(route.path)[0]}/docs/introduction` }, { key: 'component', label: t('component'), - path: `${themeAndLocaleReg.exec(route.path)[0]}/components/button`, + path: `${localeReg.exec(route.path)[0]}/components/button`, children: componentOptionsRef.value }, { @@ -155,8 +170,8 @@ export default defineComponent({ return findMenuValue(mobileMenuOptionsRef.value, route.path) }) function handleUpdateMobileMenu(value, { path }) { - if (value === 'theme') { - handleThemeUpdate() + if (value.startsWith('theme:')) { + themePreferenceRef.value = value.slice('theme:'.length) } else if (value === 'locale') { if (localeNameRef.value === 'zh-CN') { @@ -175,15 +190,6 @@ export default defineComponent({ mobilePopoverRef.value.setShow(false) } - function handleThemeUpdate() { - if (themeNameRef.value === 'dark') { - themeNameRef.value = 'light' - } - else { - themeNameRef.value = 'dark' - } - } - const localeLabelMap = { 'zh-CN': 'English', 'en-US': '中文' @@ -272,11 +278,11 @@ export default defineComponent({ const isMobileRef = useIsMobile() const isTabletRef = useIsTablet() function handleLogoClick() { - if (/^(\/[^/]+){2}$/.test(route.path)) { + if (/^\/[^/]+$/.test(route.path)) { message.info(t('alreadyHome')) return } - router.push(/^(\/[^/]+){2}/.exec(route.path)[0]) + router.push(localeReg.exec(route.path)[0]) } // responsive menu @@ -302,9 +308,9 @@ export default defineComponent({ isTablet: isTabletRef, repoUrl, // theme - theme: themeNameRef, - handleThemeUpdate, - themeLabelMap: themeLabelMapRef, + themePreference: themePreferenceRef, + themePreferenceLabel: themePreferenceLabelRef, + themeOptions: themeOptionsRef, // displayMode displayMode: displayModeRef, displayModeLabelMap, @@ -418,14 +424,16 @@ export default defineComponent({ > {{ localeLabelMap[locale] }} - - {{ themeLabelMap[theme] }} - + + {{ themePreferenceLabel }} + + { - const langAndThemeReg = /\/(zh-CN|en-US)\/(light|dark|os-theme)/g - // only theme & lang change do not restore the scroll status - if ( - value.replace(langAndThemeReg, '') - !== oldValue.replace(langAndThemeReg, '') - ) { + const langReg = /\/(zh-CN|en-US)/g + // only lang change do not restore the scroll status + if (value.replace(langReg, '') !== oldValue.replace(langReg, '')) { layoutInstRef.value.scrollTo(0, 0) } }) diff --git a/demo/routes/routes.js b/demo/routes/routes.js index c1984262585..2c36d9a7b1e 100644 --- a/demo/routes/routes.js +++ b/demo/routes/routes.js @@ -1031,41 +1031,55 @@ export const zhComponentRoutes = [ export const routes = [ { name: 'home', - path: '/:lang/:theme', + path: '/:lang(en-US|zh-CN)', component: () => import('../pages/home/index.vue') }, { name: 'enDocs', - path: '/en-US/:theme/docs', + path: '/en-US/docs', component: () => import('../pages/Layout.vue'), children: enDocRoutes }, { name: 'zhDocs', - path: '/zh-CN/:theme/docs', + path: '/zh-CN/docs', component: () => import('../pages/Layout.vue'), children: zhDocRoutes }, { name: 'enComponents', - path: '/en-US/:theme/components', + path: '/en-US/components', component: () => import('../pages/Layout.vue'), children: enComponentRoutes }, { name: 'zhComponents', - path: '/zh-CN/:theme/components', + path: '/zh-CN/components', component: () => import('../pages/Layout.vue'), children: zhComponentRoutes }, + { + // The theme used to be part of the path (`/zh-CN/os-theme/docs/...`). + // Old links are everywhere, so drop that segment and keep the rest. The + // theme in an old link is ignored: someone else's link shouldn't change + // the reader's own preference. + name: 'legacy-theme-path', + path: '/:lang(en-US|zh-CN)/:theme(light|dark|os-theme)/:rest(.*)*', + redirect: to => ({ + path: [`/${to.params.lang}`, ...[].concat(to.params.rest || [])].join( + '/' + ), + query: to.query, + hash: to.hash + }) + }, { name: 'not-found', path: '/:pathMatch(.*)*', redirect: { name: 'home', params: { - lang: navigator.language === 'zh-CN' ? 'zh-CN' : 'en-US', - theme: 'os-theme' + lang: navigator.language === 'zh-CN' ? 'zh-CN' : 'en-US' } } } diff --git a/demo/store/index.js b/demo/store/index.js index 3d85e334bb2..e97144e28b5 100644 --- a/demo/store/index.js +++ b/demo/store/index.js @@ -4,12 +4,12 @@ import { dateZhCN, enUS, NConfigProvider, - useOsTheme, zhCN } from 'naive-ui' import { useMemo } from 'vooks' import { computed, ref } from 'vue' import { TsConfigProvider } from '../../themes/tusimple/src' +import { useColorMode } from '../utils/color-mode' import { i18n, useIsMobile } from '../utils/composables' import hljs from './hljs' import { @@ -24,9 +24,18 @@ let localeNameRef = null // useMemo let dateLocaleRef = null // theme -const osThemeRef = useOsTheme() -let themeNameRef = null -let rawThemeNameRef = null // could be `os-theme` +// The theme is the reader's preference, not part of the page, so it lives in +// localStorage rather than the URL +const { mode: themeModeRef, state: themeStateRef } = useColorMode({ + storageKey: 'naive-ui-theme' +}) +// The theme in effect, `light` or `dark`; setting it stores an explicit pick +const themeNameRef = computed({ + get: () => themeStateRef.value, + set: (theme) => { + themeModeRef.value = theme + } +}) export function initRouter(_router, _route) { route = _route @@ -42,22 +51,6 @@ export function initRouter(_router, _route) { dateLocaleRef = useMemo(() => { return route.path.startsWith('/zh-CN') ? dateZhCN : dateEnUS }) - rawThemeNameRef = useMemo(() => route.params.theme) - themeNameRef = useMemo({ - get() { - switch (route.params.theme) { - case 'os-theme': - return osThemeRef.value - case 'dark': - return 'dark' - default: - return 'light' - } - }, - set(theme) { - router.push(changeThemeInPath(route.fullPath, theme)) - } - }) } // display mode @@ -94,14 +87,12 @@ const configProviderRef = computed(() => { // options const docOptionsRef = computed(() => createDocumentationMenuOptions({ - theme: rawThemeNameRef.value, lang: localeNameRef.value, mode: displayModeRef.value }) ) const componentOptionsRef = computed(() => createComponentMenuOptions({ - theme: rawThemeNameRef.value, lang: localeNameRef.value, mode: displayModeRef.value }) @@ -143,16 +134,9 @@ function changeLangInPath(path, lang) { return path.replace(langReg, `/${lang}/`) } -function changeThemeInPath(path, theme) { - const themeReg = /(^\/[^/]+\/)([^/]+)/ - return path.replace(themeReg, `$1${theme}`) -} - export function push(partialPath) { const { fullPath } = route - router.push( - fullPath.replace(/(^\/[^/]+\/[^/]+)((\/.*)|$)/, `$1${partialPath}`) - ) + router.push(fullPath.replace(/(^\/[^/]+)((\/.*)|$)/, `$1${partialPath}`)) } export function useDisplayMode() { @@ -167,6 +151,10 @@ export function useThemeName() { return themeNameRef } +export function useThemeMode() { + return themeModeRef +} + export function useDocOptions() { return docOptionsRef } diff --git a/demo/store/menu-options.js b/demo/store/menu-options.js index 75b706e1276..196c5d986c6 100644 --- a/demo/store/menu-options.js +++ b/demo/store/menu-options.js @@ -68,7 +68,7 @@ function appendCounts(item) { } } -function createItems(lang, theme, prefix, items) { +function createItems(lang, prefix, items) { const isZh = lang === 'zh-CN' const langKey = isZh ? 'zh' : 'en' return items.map((rawItem) => { @@ -78,19 +78,17 @@ function createItems(lang, theme, prefix, items) { label: rawItem[langKey] || rawItem.en, extra: renderItemExtra(rawItem, isZh), extraString: getItemExtraString(rawItem, isZh), - path: rawItem.path - ? `/${lang}/${theme}${prefix}${rawItem.path}` - : undefined + path: rawItem.path ? `/${lang}${prefix}${rawItem.path}` : undefined } if (rawItem.children) { - item.children = createItems(lang, theme, prefix, rawItem.children) + item.children = createItems(lang, prefix, rawItem.children) } return item }) } -export function createDocumentationMenuOptions({ lang, theme }) { - return createItems(lang, theme, '/docs', [ +export function createDocumentationMenuOptions({ lang }) { + return createItems(lang, '/docs', [ { en: 'Introduction', zh: '介绍', @@ -222,8 +220,8 @@ export function createDocumentationMenuOptions({ lang, theme }) { ]) } -export function createComponentMenuOptions({ lang, theme }) { - return createItems(lang, theme, '/components', [ +export function createComponentMenuOptions({ lang }) { + return createItems(lang, '/components', [ appendCounts({ zh: '通用组件', en: 'Common Components', diff --git a/demo/utils/color-mode.js b/demo/utils/color-mode.js new file mode 100644 index 00000000000..c3b02e1a960 --- /dev/null +++ b/demo/utils/color-mode.js @@ -0,0 +1,73 @@ +import { useOsTheme } from 'naive-ui' +import { computed, nextTick, ref, watchEffect } from 'vue' + +const MODES = ['light', 'dark', 'auto'] + +function readStorage(key) { + try { + return localStorage.getItem(key) + } + catch { + return null + } +} + +function writeStorage(key, value) { + try { + if (value === null) + localStorage.removeItem(key) + else localStorage.setItem(key, value) + } + catch {} +} + +// Turn transitions off while the colors change, so the page switches in one +// step instead of every component fading on its own +async function withoutTransitions(change) { + const style = document.createElement('style') + style.textContent = '*,*::before,*::after{transition:none!important}' + document.head.appendChild(style) + change() + await nextTick() + // Force a style recalc before the transitions come back + void getComputedStyle(document.body).opacity + style.remove() +} + +/** + * The reader's color mode, in the manner of VueUse's `useColorMode`. + * `mode` is what they picked (`light`, `dark` or `auto`, which follows the + * OS); `state` is the theme in effect. Only an explicit pick is stored, it is + * kept in step across tabs, and `color-scheme` on follows `state`. + * index.html applies the same rule before the first paint. + */ +export function useColorMode({ storageKey }) { + const read = () => { + const value = readStorage(storageKey) + return MODES.includes(value) ? value : 'auto' + } + const storedRef = ref(read()) + const osThemeRef = useOsTheme() + + window.addEventListener('storage', (e) => { + if (e.key === storageKey || e.key === null) + withoutTransitions(() => (storedRef.value = read())) + }) + + const mode = computed({ + get: () => storedRef.value, + set: (value) => { + writeStorage(storageKey, value === 'auto' ? null : value) + withoutTransitions(() => (storedRef.value = value)) + } + }) + const state = computed(() => + storedRef.value === 'auto' ? (osThemeRef.value ?? 'light') : storedRef.value + ) + + watchEffect(() => { + document.documentElement.style.colorScheme = state.value + }) + + return { mode, state } +} diff --git a/index.html b/index.html index 4fbc4546cb7..47c58a21688 100644 --- a/index.html +++ b/index.html @@ -21,6 +21,20 @@ content="width=device-width, initial-scale=1, maximum-scale=1" /> Naive UI +