Skip to content

Commit b5de067

Browse files
authored
Merge pull request #7152 from nextcloud-libraries/backport/6764/stable8
[stable8] feat(NcThemeProvider): allow to override the current theme for parts of the UI
2 parents dfef9fe + d617210 commit b5de067

File tree

7 files changed

+298
-5
lines changed

7 files changed

+298
-5
lines changed
Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
<!--
2+
- SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
3+
- SPDX-License-Identifier: AGPL-3.0-or-later
4+
-->
5+
6+
<docs>
7+
This components allows to enforce a color theme on its content,
8+
for example enforce the content to be always rendered in dark theme regardless of browser or user config.
9+
10+
### Example
11+
```vue
12+
<template>
13+
<NcThemeProvider class="wrapper" :dark="userTheme === 'dark'">
14+
<div class="controls">
15+
<fieldset>
16+
<legend>
17+
User theme
18+
</legend>
19+
<div class="controls__select">
20+
<NcCheckboxRadioSwitch
21+
v-model="userTheme"
22+
type="radio"
23+
value="dark">
24+
Dark
25+
</NcCheckboxRadioSwitch>
26+
<NcCheckboxRadioSwitch
27+
v-model="userTheme"
28+
type="radio"
29+
value="light">
30+
Light
31+
</NcCheckboxRadioSwitch>
32+
</div>
33+
</fieldset>
34+
<fieldset>
35+
<legend>
36+
NcThemeProvider theme
37+
</legend>
38+
<div class="controls__select">
39+
<NcCheckboxRadioSwitch
40+
v-model="providerTheme"
41+
type="radio"
42+
value="default">
43+
None (default)
44+
</NcCheckboxRadioSwitch>
45+
<NcCheckboxRadioSwitch
46+
v-model="providerTheme"
47+
type="radio"
48+
value="dark">
49+
Dark
50+
</NcCheckboxRadioSwitch>
51+
<NcCheckboxRadioSwitch
52+
v-model="providerTheme"
53+
value="light"
54+
type="radio">
55+
Light
56+
</NcCheckboxRadioSwitch>
57+
</div>
58+
</fieldset>
59+
</div>
60+
<p class="theme-preview">
61+
This is shown in user theme
62+
</p>
63+
<NcThemeProvider
64+
:dark="providerTheme === 'dark'"
65+
:light="providerTheme === 'light'">
66+
<div class="theme-preview">
67+
This is shown in the overridden theme.
68+
</div>
69+
</NcThemeProvider>
70+
</NcThemeProvider>
71+
</template>
72+
<script>
73+
export default {
74+
data() {
75+
return {
76+
userTheme: 'light',
77+
providerTheme: 'default',
78+
}
79+
}
80+
}
81+
</script>
82+
<style scoped>
83+
.wrapper {
84+
background-color: var(--color-main-background);
85+
color: var(--color-main-text);
86+
}
87+
88+
.controls {
89+
display: flex;
90+
flex-wrap: wrap;
91+
gap: 2lh;
92+
}
93+
94+
.controls__select {
95+
display: flex;
96+
flex-direction: row;
97+
gap: var(--default-grid-baseline);
98+
}
99+
100+
legend {
101+
width: 100%;
102+
text-align: center;
103+
}
104+
105+
.theme-preview {
106+
background-color: var(--color-main-background);
107+
color: var(--color-main-text);
108+
display: flex;
109+
align-items: center;
110+
justify-content: center;
111+
margin-top: 0.5lh;
112+
min-height: 2lh;
113+
}
114+
</style>
115+
```
116+
</docs>
117+
118+
<script setup>
119+
import { computed, provide } from 'vue'
120+
import { INJECTION_KEY_THEME } from '../../composables/useIsDarkTheme/constants.ts'
121+
122+
const props = defineProps({
123+
/**
124+
* Enforce the dark theme for the content.
125+
*/
126+
dark: {
127+
type: Boolean,
128+
default: false,
129+
},
130+
131+
/**
132+
* Enforce the light theme for the content
133+
*/
134+
light: {
135+
type: Boolean,
136+
default: false,
137+
},
138+
})
139+
140+
const theme = computed(() => {
141+
if (props.dark) {
142+
return 'dark'
143+
} else if (props.light) {
144+
return 'light'
145+
}
146+
return ''
147+
})
148+
provide(INJECTION_KEY_THEME, theme)
149+
</script>
150+
151+
<template>
152+
<div :[`data-theme-${theme}`]="theme">
153+
<slot />
154+
</div>
155+
</template>
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
/*!
2+
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
3+
* SPDX-License-Identifier: AGPL-3.0-or-later
4+
*/
5+
export { default } from './NcThemeProvider.vue'

src/components/index.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,7 @@ export { default as NcSettingsSection } from './NcSettingsSection/index.js'
7979
export { default as NcSettingsSelectGroup } from './NcSettingsSelectGroup/index.js'
8080
export { default as NcTextArea } from './NcTextArea/index.js'
8181
export { default as NcTextField } from './NcTextField/index.js'
82+
export { default as NcThemeProvider } from './NcThemeProvider/index.js'
8283
export { default as NcTimezonePicker } from './NcTimezonePicker/index.js'
8384
export { default as NcUserBubble } from './NcUserBubble/index.js'
8485
export { default as NcUserStatusIcon } from './NcUserStatusIcon/index.js'
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
/*!
2+
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
3+
* SPDX-License-Identifier: AGPL-3.0-or-later
4+
*/
5+
6+
import type { ComputedRef, InjectionKey } from 'vue'
7+
8+
/**
9+
* Enforced dark / light theme state
10+
*
11+
* @private
12+
*/
13+
export const INJECTION_KEY_THEME: InjectionKey<ComputedRef<'light' | 'dark' | ''>> = Symbol.for('nc:theme:enforced')

src/composables/useIsDarkTheme/index.ts

Lines changed: 25 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,14 @@
1-
/**
1+
/*!
22
* SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
33
* SPDX-License-Identifier: AGPL-3.0-or-later
44
*/
55

66
import type { DeepReadonly, Ref } from 'vue'
7-
import { ref, readonly, watch } from 'vue'
7+
88
import { createSharedComposable, usePreferredDark, useMutationObserver } from '@vueuse/core'
9+
import { ref, readonly, watch, inject, computed } from 'vue'
910
import { checkIfDarkTheme } from '../../functions/isDarkTheme/index.ts'
11+
import { INJECTION_KEY_THEME } from './constants.ts'
1012

1113
/**
1214
* Check whether the dark theme is enabled on a specific element.
@@ -32,9 +34,28 @@ export function useIsDarkThemeElement(el: HTMLElement = document.body): DeepRead
3234
return readonly(isDarkTheme)
3335
}
3436

37+
/**
38+
* The real shared composable of the dark theme state.
39+
* We need to wrap this to allow to react to injected theme changes.
40+
*/
41+
const useInternalIsDarkTheme = createSharedComposable(() => useIsDarkThemeElement())
42+
3543
/**
3644
* Shared composable to check whether the dark theme is enabled on the page.
45+
*
3746
* Reacts on body data-theme-* attributes change and system theme change.
38-
* @return {DeepReadonly<Ref<boolean>>} - computed boolean whether the dark theme is enabled
47+
* As well as any enforced theme by the `NcThemeProvider`.
48+
*
49+
* @return Computed boolean whether the dark theme is enabled
3950
*/
40-
export const useIsDarkTheme = createSharedComposable(() => useIsDarkThemeElement())
51+
export function useIsDarkTheme(): DeepReadonly<Ref<boolean>> {
52+
const isDarkTheme = useInternalIsDarkTheme()
53+
const enforcedTheme = inject(INJECTION_KEY_THEME)
54+
55+
return computed(() => {
56+
if (enforcedTheme?.value) {
57+
return enforcedTheme.value === 'dark'
58+
}
59+
return isDarkTheme.value
60+
})
61+
}

styleguide/assets/additional.css

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,9 @@
33
* SPDX-License-Identifier: AGPL-3.0-or-later
44
*/
55
@import 'default.css';
6-
@import 'server.css';
6+
@import 'light.css';
77
@import 'dark.css';
8+
@import 'server.css';
89

910
div[data-preview] * {
1011
box-sizing: border-box;

styleguide/assets/light.css

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
/** SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors */
2+
/** SPDX-License-Identifier: AGPL-3.0-or-later */
3+
[data-theme-light] {
4+
--color-main-background:#ffffff;
5+
--color-main-background-rgb:255,255,255;
6+
--color-main-background-translucent:rgba(var(--color-main-background-rgb), .97);
7+
--color-main-background-blur:rgba(var(--color-main-background-rgb), .8);
8+
--filter-background-blur:blur(25px);
9+
--gradient-main-background:var(--color-main-background) 0%, var(--color-main-background-translucent) 85%, transparent 100%;
10+
--color-background-hover:#f5f5f5;
11+
--color-background-dark:#ededed;
12+
--color-background-darker:#dbdbdb;
13+
--color-placeholder-light:#e6e6e6;
14+
--color-placeholder-dark:#cccccc;
15+
--color-main-text:#222222;
16+
--color-text-maxcontrast:#6b6b6b;
17+
--color-text-maxcontrast-default:#6b6b6b;
18+
--color-text-maxcontrast-background-blur:#595959;
19+
--color-text-light:var(--color-main-text);
20+
--color-text-lighter:var(--color-text-maxcontrast);
21+
--color-scrollbar:var(--color-border-maxcontrast) transparent;
22+
--color-error:#DB0606;
23+
--color-error-rgb:219,6,6;
24+
--color-error-hover:#df2525;
25+
--color-error-text:#c20505;
26+
--color-warning:#A37200;
27+
--color-warning-rgb:163,114,0;
28+
--color-warning-hover:#8a6000;
29+
--color-warning-text:#7f5900;
30+
--color-success:#2d7b41;
31+
--color-success-rgb:45,123,65;
32+
--color-success-hover:#428854;
33+
--color-success-text:#286c39;
34+
--color-info:#0071ad;
35+
--color-info-rgb:0,113,173;
36+
--color-info-hover:#197fb5;
37+
--color-info-text:#006499;
38+
--color-favorite:#A37200;
39+
--color-loading-light:#cccccc;
40+
--color-loading-dark:#444444;
41+
--color-box-shadow-rgb:77,77,77;
42+
--color-box-shadow:rgba(var(--color-box-shadow-rgb), 0.5);
43+
--color-border:#ededed;
44+
--color-border-dark:#dbdbdb;
45+
--color-border-maxcontrast:#7d7d7d;
46+
--font-face:system-ui, -apple-system, "Segoe UI", Roboto, Oxygen-Sans, Cantarell, Ubuntu, "Helvetica Neue", "Noto Sans", "Liberation Sans", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
47+
--default-font-size:15px;
48+
--font-size-small:13px;
49+
--default-line-height:1.5;
50+
--animation-quick:100ms;
51+
--animation-slow:300ms;
52+
--border-width-input:1px;
53+
--border-width-input-focused:2px;
54+
--border-radius-small:4px;
55+
--border-radius-element:8px;
56+
--border-radius-container:12px;
57+
--border-radius-container-large:16px;
58+
--border-radius:var(--border-radius-small);
59+
--border-radius-large:var(--border-radius-element);
60+
--border-radius-rounded:28px;
61+
--border-radius-pill:100px;
62+
--default-clickable-area:34px;
63+
--clickable-area-large:48px;
64+
--clickable-area-small:24px;
65+
--default-grid-baseline:4px;
66+
--header-height:50px;
67+
--header-menu-item-height:44px;
68+
--navigation-width:300px;
69+
--sidebar-min-width:300px;
70+
--sidebar-max-width:500px;
71+
--body-container-radius:var(--border-radius-container-large);
72+
--body-container-margin:calc(var(--default-grid-baseline) * 2);
73+
--body-height:calc(100% - env(safe-area-inset-bottom) - var(--header-height) - var(--body-container-margin));
74+
--breakpoint-mobile:1024px;
75+
--background-invert-if-dark:no;
76+
--background-invert-if-bright:invert(100%);
77+
--background-image-invert-if-bright:no;
78+
--primary-invert-if-bright:no;
79+
--primary-invert-if-dark:invert(100%);
80+
--color-primary:#00679e;
81+
--color-primary-text:#ffffff;
82+
--color-primary-hover:#3285b1;
83+
--color-primary-light:#e5eff5;
84+
--color-primary-light-text:#00293f;
85+
--color-primary-light-hover:#dbe4ea;
86+
--color-primary-element:#00679e;
87+
--color-primary-element-hover:#005a8a;
88+
--color-primary-element-text:#ffffff;
89+
--color-primary-element-text-dark:#f5f5f5;
90+
--color-primary-element-light:#e5eff5;
91+
--color-primary-element-light-hover:#dbe4ea;
92+
--color-primary-element-light-text:#00293f;
93+
--gradient-primary-background:linear-gradient(40deg, var(--color-primary) 0%, var(--color-primary-hover) 100%);
94+
--color-background-plain:#00679e;
95+
--color-background-plain-text:#ffffff;
96+
--image-background: url('./img/background/jenna-kim-the-globe.webp');
97+
}

0 commit comments

Comments
 (0)