Allow configs to override default CSS theme values in theme() function provided to plugins and configs (#14359)
Previously, given the following CSS and configuration:
```css
/* app.css */
@theme default {
--font-size-base: 1.25rem;
--font-size-base--line-height: 1.5rem;
}
@tailwind utilities;
@config "./config.js";
```
```js
// config.js
export default {
theme: {
fontSize: {
// …
base: ['1rem', { lineHeight: '1.75rem' }],
},
// …
},
};
```
When a config or a plugin asked for the value of `theme(fontSize.base)`
like so:
```js
// config.js
export default {
theme: {
// …
typography: ({ theme }) => ({
css: {
'[class~="lead"]': {
fontSize: theme('fontSize.base')[0],
...theme('fontSize.base')[1],
},
}
}),
},
};
```
We would instead pull the values from the CSS theme even through they're
marked with `@theme default`. This would cause the incorrect font size
and line height to be used resulting in something like this (in the case
of the typography plugin with custom styles):
```css
.prose [class~="lead"] {
font-size: 1.25rem;
line-height: 1.5rem;
}
```
After this change we'll now pull the values from the appropriate place
(the config in this case) and the correct font size and line height will
be used:
```css
.prose [class~="lead"] {
font-size: 1rem;
line-height: 1.75rem;
}
```
This will work even when some values are overridden in the CSS theme:
```css
/* app.css */
@theme default {
--font-size-base: 1.25rem;
--font-size-base--line-height: 1.5rem;
}
@theme {
--font-size-base: 2rem;
}
@tailwind utilities;
@config "./config.js";
```
which would result in the following CSS:
```css
.prose [class~="lead"] {
font-size: 2rem;
line-height: 1.75rem;
}
```
---------
Co-authored-by: Adam Wathan <4323180+adamwathan@users.noreply.github.com>
Co-authored-by: Robin Malfait <malfait.robin@gmail.com>
Co-authored-by: Adam Wathan <adam.wathan@gmail.com>
This commit is contained in:
parent
ee35a1d047
commit
b1e22e121e
7 changed files with 409 additions and 34 deletions
|
|
@ -15,6 +15,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||
|
||||
- Ensure there is always CLI feedback on save even when no new classes were found ([#14351](https://github.com/tailwindlabs/tailwindcss/pull/14351))
|
||||
- Properly resolve `theme('someKey.DEFAULT')` when all `--some-key-*` keys have a suffix ([#14354](https://github.com/tailwindlabs/tailwindcss/pull/14354))
|
||||
- Make sure tuple theme values in JS configs take precedence over `@theme default` values ([#14359](https://github.com/tailwindlabs/tailwindcss/pull/14359))
|
||||
|
||||
## [4.0.0-alpha.23] - 2024-09-05
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { describe, test } from 'vitest'
|
||||
import { compile } from '..'
|
||||
import plugin from '../plugin'
|
||||
import { flattenColorPalette } from './flatten-color-palette'
|
||||
|
||||
const css = String.raw
|
||||
|
||||
|
|
@ -230,6 +231,272 @@ test('Variants in CSS overwrite variants from plugins', async ({ expect }) => {
|
|||
`)
|
||||
})
|
||||
|
||||
describe('theme callbacks', () => {
|
||||
test('tuple values from the config overwrite `@theme default` tuple-ish values from the CSS theme', async ({
|
||||
expect,
|
||||
}) => {
|
||||
let input = css`
|
||||
@theme default {
|
||||
--font-size-base: 0rem;
|
||||
--font-size-base--line-height: 1rem;
|
||||
--font-size-md: 0rem;
|
||||
--font-size-md--line-height: 1rem;
|
||||
--font-size-xl: 0rem;
|
||||
--font-size-xl--line-height: 1rem;
|
||||
}
|
||||
@theme {
|
||||
--font-size-base: 100rem;
|
||||
--font-size-md--line-height: 101rem;
|
||||
}
|
||||
@tailwind utilities;
|
||||
@config "./config.js";
|
||||
`
|
||||
|
||||
let compiler = await compile(input, {
|
||||
loadConfig: async () => ({
|
||||
theme: {
|
||||
extend: {
|
||||
fontSize: {
|
||||
base: ['200rem', { lineHeight: '201rem' }],
|
||||
md: ['200rem', { lineHeight: '201rem' }],
|
||||
xl: ['200rem', { lineHeight: '201rem' }],
|
||||
},
|
||||
|
||||
// Direct access
|
||||
lineHeight: ({ theme }) => ({
|
||||
base: theme('fontSize.base[1].lineHeight'),
|
||||
md: theme('fontSize.md[1].lineHeight'),
|
||||
xl: theme('fontSize.xl[1].lineHeight'),
|
||||
}),
|
||||
|
||||
// Tuple access
|
||||
typography: ({ theme }) => ({
|
||||
'[class~=lead-base]': {
|
||||
fontSize: theme('fontSize.base')[0],
|
||||
...theme('fontSize.base')[1],
|
||||
},
|
||||
'[class~=lead-md]': {
|
||||
fontSize: theme('fontSize.md')[0],
|
||||
...theme('fontSize.md')[1],
|
||||
},
|
||||
'[class~=lead-xl]': {
|
||||
fontSize: theme('fontSize.xl')[0],
|
||||
...theme('fontSize.xl')[1],
|
||||
},
|
||||
}),
|
||||
},
|
||||
},
|
||||
|
||||
plugins: [
|
||||
plugin(function ({ addUtilities, theme }) {
|
||||
addUtilities({
|
||||
'.prose': {
|
||||
...theme('typography'),
|
||||
},
|
||||
})
|
||||
}),
|
||||
],
|
||||
}),
|
||||
})
|
||||
|
||||
expect(compiler.build(['leading-base', 'leading-md', 'leading-xl', 'prose']))
|
||||
.toMatchInlineSnapshot(`
|
||||
":root {
|
||||
--font-size-base: 100rem;
|
||||
--font-size-md--line-height: 101rem;
|
||||
}
|
||||
.prose {
|
||||
[class~=lead-base] {
|
||||
font-size: 100rem;
|
||||
line-height: 201rem;
|
||||
}
|
||||
[class~=lead-md] {
|
||||
font-size: 200rem;
|
||||
line-height: 101rem;
|
||||
}
|
||||
[class~=lead-xl] {
|
||||
font-size: 200rem;
|
||||
line-height: 201rem;
|
||||
}
|
||||
}
|
||||
.leading-base {
|
||||
line-height: 201rem;
|
||||
}
|
||||
.leading-md {
|
||||
line-height: 101rem;
|
||||
}
|
||||
.leading-xl {
|
||||
line-height: 201rem;
|
||||
}
|
||||
"
|
||||
`)
|
||||
})
|
||||
})
|
||||
|
||||
describe('theme overrides order', () => {
|
||||
test('user theme > js config > default theme', async ({ expect }) => {
|
||||
let input = css`
|
||||
@theme default {
|
||||
--color-red: red;
|
||||
}
|
||||
@theme {
|
||||
--color-blue: blue;
|
||||
}
|
||||
@tailwind utilities;
|
||||
@config "./config.js";
|
||||
`
|
||||
|
||||
let compiler = await compile(input, {
|
||||
loadConfig: async () => ({
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
red: 'very-red',
|
||||
blue: 'very-blue',
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
})
|
||||
|
||||
expect(compiler.build(['bg-red', 'bg-blue'])).toMatchInlineSnapshot(`
|
||||
":root {
|
||||
--color-blue: blue;
|
||||
}
|
||||
.bg-blue {
|
||||
background-color: var(--color-blue, blue);
|
||||
}
|
||||
.bg-red {
|
||||
background-color: very-red;
|
||||
}
|
||||
"
|
||||
`)
|
||||
})
|
||||
|
||||
test('user theme > js config > default theme (with nested object)', async ({ expect }) => {
|
||||
let input = css`
|
||||
@theme default {
|
||||
--color-slate-100: #000100;
|
||||
--color-slate-200: #000200;
|
||||
--color-slate-300: #000300;
|
||||
}
|
||||
@theme {
|
||||
--color-slate-400: #100400;
|
||||
--color-slate-500: #100500;
|
||||
}
|
||||
@tailwind utilities;
|
||||
@config "./config.js";
|
||||
@plugin "./plugin.js";
|
||||
`
|
||||
|
||||
let compiler = await compile(input, {
|
||||
loadConfig: async () => ({
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
slate: {
|
||||
200: '#200200',
|
||||
400: '#200400',
|
||||
600: '#200600',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
|
||||
loadPlugin: async () => {
|
||||
return plugin(({ matchUtilities, theme }) => {
|
||||
matchUtilities(
|
||||
{
|
||||
'hover-bg': (value) => {
|
||||
return {
|
||||
'&:hover': {
|
||||
backgroundColor: value,
|
||||
},
|
||||
}
|
||||
},
|
||||
},
|
||||
{ values: flattenColorPalette(theme('colors')) },
|
||||
)
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
expect(
|
||||
compiler.build([
|
||||
'bg-slate-100',
|
||||
'bg-slate-200',
|
||||
'bg-slate-300',
|
||||
'bg-slate-400',
|
||||
'bg-slate-500',
|
||||
'bg-slate-600',
|
||||
'hover-bg-slate-100',
|
||||
'hover-bg-slate-200',
|
||||
'hover-bg-slate-300',
|
||||
'hover-bg-slate-400',
|
||||
'hover-bg-slate-500',
|
||||
'hover-bg-slate-600',
|
||||
]),
|
||||
).toMatchInlineSnapshot(`
|
||||
":root {
|
||||
--color-slate-100: #000100;
|
||||
--color-slate-300: #000300;
|
||||
--color-slate-400: #100400;
|
||||
--color-slate-500: #100500;
|
||||
}
|
||||
.bg-slate-100 {
|
||||
background-color: var(--color-slate-100, #000100);
|
||||
}
|
||||
.bg-slate-200 {
|
||||
background-color: #200200;
|
||||
}
|
||||
.bg-slate-300 {
|
||||
background-color: var(--color-slate-300, #000300);
|
||||
}
|
||||
.bg-slate-400 {
|
||||
background-color: var(--color-slate-400, #100400);
|
||||
}
|
||||
.bg-slate-500 {
|
||||
background-color: var(--color-slate-500, #100500);
|
||||
}
|
||||
.bg-slate-600 {
|
||||
background-color: #200600;
|
||||
}
|
||||
.hover-bg-slate-100 {
|
||||
&:hover {
|
||||
background-color: #000100;
|
||||
}
|
||||
}
|
||||
.hover-bg-slate-200 {
|
||||
&:hover {
|
||||
background-color: #200200;
|
||||
}
|
||||
}
|
||||
.hover-bg-slate-300 {
|
||||
&:hover {
|
||||
background-color: #000300;
|
||||
}
|
||||
}
|
||||
.hover-bg-slate-400 {
|
||||
&:hover {
|
||||
background-color: #100400;
|
||||
}
|
||||
}
|
||||
.hover-bg-slate-500 {
|
||||
&:hover {
|
||||
background-color: #100500;
|
||||
}
|
||||
}
|
||||
.hover-bg-slate-600 {
|
||||
&:hover {
|
||||
background-color: #200600;
|
||||
}
|
||||
}
|
||||
"
|
||||
`)
|
||||
})
|
||||
})
|
||||
|
||||
describe('default font family compatibility', () => {
|
||||
test('overriding `fontFamily.sans` sets `--default-font-family`', async ({ expect }) => {
|
||||
let input = css`
|
||||
|
|
|
|||
|
|
@ -10,7 +10,8 @@ export function isPlainObject<T>(value: T): value is T & Record<keyof T, unknown
|
|||
export function deepMerge<T extends object>(
|
||||
target: T,
|
||||
sources: (Partial<T> | null | undefined)[],
|
||||
customizer: (a: any, b: any) => any,
|
||||
customizer: (a: any, b: any, keypath: (keyof T)[]) => any,
|
||||
parentPath: (keyof T)[] = [],
|
||||
) {
|
||||
type Key = keyof T
|
||||
type Value = T[Key]
|
||||
|
|
@ -21,14 +22,20 @@ export function deepMerge<T extends object>(
|
|||
}
|
||||
|
||||
for (let k of Reflect.ownKeys(source) as Key[]) {
|
||||
let merged = customizer(target[k], source[k])
|
||||
let currentParentPath = [...parentPath, k]
|
||||
let merged = customizer(target[k], source[k], currentParentPath)
|
||||
|
||||
if (merged !== undefined) {
|
||||
target[k] = merged
|
||||
} else if (!isPlainObject(target[k]) || !isPlainObject(source[k])) {
|
||||
target[k] = source[k] as Value
|
||||
} else {
|
||||
target[k] = deepMerge({}, [target[k], source[k]], customizer) as Value
|
||||
target[k] = deepMerge(
|
||||
{},
|
||||
[target[k], source[k]],
|
||||
customizer,
|
||||
currentParentPath as any,
|
||||
) as Value
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
19
packages/tailwindcss/src/compat/flatten-color-palette.ts
Normal file
19
packages/tailwindcss/src/compat/flatten-color-palette.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
type Colors = {
|
||||
[key: string | number]: string | Colors
|
||||
}
|
||||
|
||||
export function flattenColorPalette(colors: Colors) {
|
||||
let result: Record<string, string> = {}
|
||||
|
||||
for (let [root, children] of Object.entries(colors ?? {})) {
|
||||
if (typeof children === 'object' && children !== null) {
|
||||
for (let [parent, value] of Object.entries(flattenColorPalette(children))) {
|
||||
result[`${root}${parent === 'DEFAULT' ? '' : `-${parent}`}`] = value
|
||||
}
|
||||
} else {
|
||||
result[root] = children
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
import type { DesignSystem } from '../design-system'
|
||||
import type { Theme, ThemeKey } from '../theme'
|
||||
import { ThemeOptions, type Theme, type ThemeKey } from '../theme'
|
||||
import { withAlpha } from '../utilities'
|
||||
import { DefaultMap } from '../utils/default-map'
|
||||
import { toKeyPath } from '../utils/to-key-path'
|
||||
|
|
@ -24,16 +24,75 @@ export function createThemeFn(
|
|||
|
||||
let resolvedValue = (() => {
|
||||
let keypath = toKeyPath(path)
|
||||
let cssValue = readFromCss(designSystem.theme, keypath)
|
||||
|
||||
if (typeof cssValue !== 'object') {
|
||||
return cssValue
|
||||
}
|
||||
let [cssValue, options] = readFromCss(designSystem.theme, keypath)
|
||||
|
||||
let configValue = resolveValue(get(configTheme() ?? {}, keypath) ?? null)
|
||||
|
||||
// Resolved to a primitive value.
|
||||
if (typeof cssValue !== 'object') {
|
||||
if (typeof options !== 'object' && options & ThemeOptions.DEFAULT) {
|
||||
return configValue ?? cssValue
|
||||
}
|
||||
|
||||
return cssValue
|
||||
}
|
||||
|
||||
//
|
||||
if (configValue !== null && typeof configValue === 'object' && !Array.isArray(configValue)) {
|
||||
return deepMerge({}, [configValue, cssValue], (_, b) => b)
|
||||
let configValueCopy: Record<string, unknown> & { __CSS_VALUES__?: Record<string, number> } =
|
||||
// We want to make sure that we don't mutate the original config
|
||||
// value. Ideally we use `structuredClone` here, but it's not possible
|
||||
// because it can contain functions.
|
||||
deepMerge({}, [configValue], (_, b) => b)
|
||||
|
||||
// There is no `cssValue`, which means we can back-fill it with values
|
||||
// from the `configValue`.
|
||||
if (cssValue === null && Object.hasOwn(configValue, '__CSS_VALUES__')) {
|
||||
let localCssValue: Record<string, unknown> = {}
|
||||
for (let key in configValue.__CSS_VALUES__) {
|
||||
localCssValue[key] = configValue[key]
|
||||
delete configValueCopy[key]
|
||||
}
|
||||
cssValue = localCssValue
|
||||
}
|
||||
|
||||
for (let key in cssValue) {
|
||||
if (key === '__CSS_VALUES__') continue
|
||||
|
||||
// If the value is coming from a default source (`@theme default`),
|
||||
// then we keep the value from the JS config (which is also a
|
||||
// default source, but wins over the built-in defaults).
|
||||
if (
|
||||
configValue?.__CSS_VALUES__?.[key] & ThemeOptions.DEFAULT &&
|
||||
get(configValueCopy, key.split('-')) !== undefined
|
||||
) {
|
||||
continue
|
||||
}
|
||||
|
||||
// CSS values from `@theme` win over values from the config
|
||||
configValueCopy[key] = cssValue[key]
|
||||
}
|
||||
|
||||
return configValueCopy
|
||||
}
|
||||
|
||||
// Handle the tuple case
|
||||
if (Array.isArray(cssValue) && Array.isArray(options) && Array.isArray(configValue)) {
|
||||
let base = cssValue[0]
|
||||
let extra = cssValue[1]
|
||||
|
||||
// Values from the config overwrite any default values from the CSS theme
|
||||
if (options[0] & ThemeOptions.DEFAULT) {
|
||||
base = configValue[0] ?? base
|
||||
}
|
||||
|
||||
for (let key of Object.keys(extra)) {
|
||||
if (options[1][key] & ThemeOptions.DEFAULT) {
|
||||
extra[key] = configValue[1][key] ?? extra[key]
|
||||
}
|
||||
}
|
||||
|
||||
return [base, extra]
|
||||
}
|
||||
|
||||
// Values from CSS take precedence over values from the config
|
||||
|
|
@ -49,11 +108,16 @@ export function createThemeFn(
|
|||
}
|
||||
}
|
||||
|
||||
function readFromCss(theme: Theme, path: string[]) {
|
||||
function readFromCss(
|
||||
theme: Theme,
|
||||
path: string[],
|
||||
):
|
||||
| [value: string | null | Record<string, unknown>, options: number]
|
||||
| [value: Record<string, unknown>, options: Record<string, number>] {
|
||||
// `--color-red-500` should resolve to the theme variable directly, no look up
|
||||
// and handling of nested objects is required.
|
||||
if (path.length === 1 && path[0].startsWith('--')) {
|
||||
return theme.get([path[0] as ThemeKey])
|
||||
return [theme.get([path[0] as ThemeKey]), theme.getOptions(path[0])] as const
|
||||
}
|
||||
|
||||
type ThemeValue =
|
||||
|
|
@ -80,18 +144,22 @@ function readFromCss(theme: Theme, path: string[]) {
|
|||
.join('-')
|
||||
|
||||
let map = new Map<string | null, ThemeValue>()
|
||||
let nested = new DefaultMap<string | null, Map<string, string>>(() => new Map())
|
||||
let nested = new DefaultMap<string | null, Map<string, [value: string, options: number]>>(
|
||||
() => new Map(),
|
||||
)
|
||||
|
||||
let ns = theme.namespace(`--${themeKey}` as any)
|
||||
|
||||
if (ns.size === 0) {
|
||||
return null
|
||||
return [null, ThemeOptions.NONE]
|
||||
}
|
||||
|
||||
let options = new Map()
|
||||
|
||||
for (let [key, value] of ns) {
|
||||
// Non-nested values can be set directly
|
||||
if (!key || !key.includes('--')) {
|
||||
map.set(key, value)
|
||||
options.set(key, theme.getOptions(!key ? `--${themeKey}` : `--${themeKey}-${key}`))
|
||||
continue
|
||||
}
|
||||
|
||||
|
|
@ -104,34 +172,38 @@ function readFromCss(theme: Theme, path: string[]) {
|
|||
// Make `nestedKey` camel case:
|
||||
nestedKey = nestedKey.replace(/-([a-z])/g, (_, a) => a.toUpperCase())
|
||||
|
||||
nested.get(mainKey === '' ? null : mainKey).set(nestedKey, value)
|
||||
nested
|
||||
.get(mainKey === '' ? null : mainKey)
|
||||
.set(nestedKey, [value, theme.getOptions(`--${themeKey}${key}`)])
|
||||
}
|
||||
|
||||
let baseOptions = theme.getOptions(`--${themeKey}`)
|
||||
for (let [key, extra] of nested) {
|
||||
let value = map.get(key)
|
||||
if (typeof value !== 'string') continue
|
||||
|
||||
map.set(key, [value, Object.fromEntries(extra)])
|
||||
let extraObj: Record<string, string> = {}
|
||||
let extraOptionsObj: Record<string, number> = {}
|
||||
|
||||
for (let [nestedKey, [nestedValue, nestedOptions]] of extra) {
|
||||
extraObj[nestedKey] = nestedValue
|
||||
extraOptionsObj[nestedKey] = nestedOptions
|
||||
}
|
||||
|
||||
map.set(key, [value, extraObj])
|
||||
options.set(key, [baseOptions, extraOptionsObj])
|
||||
}
|
||||
|
||||
// We have to turn the map into object-like structure for v3 compatibility
|
||||
let obj: Record<string, unknown> = {}
|
||||
let useNestedObjects = false // paths.some((path) => nestedKeys.has(path))
|
||||
let optionsObj: Record<string, number> = {}
|
||||
|
||||
for (let [key, value] of map) {
|
||||
key = key ?? 'DEFAULT'
|
||||
set(obj, [key ?? 'DEFAULT'], value)
|
||||
}
|
||||
|
||||
let path: string[] = []
|
||||
let splitIndex = key.indexOf('-')
|
||||
|
||||
if (useNestedObjects && splitIndex !== -1) {
|
||||
path.push(key.slice(0, splitIndex))
|
||||
path.push(key.slice(splitIndex + 1))
|
||||
} else {
|
||||
path.push(key)
|
||||
}
|
||||
|
||||
set(obj, path, value)
|
||||
for (let [key, value] of options) {
|
||||
set(optionsObj, [key ?? 'DEFAULT'], value)
|
||||
}
|
||||
|
||||
// If the request looked like `theme('animation.DEFAULT')` it would have been
|
||||
|
|
@ -139,17 +211,21 @@ function readFromCss(theme: Theme, path: string[]) {
|
|||
// the `DEFAULT` key from the list of possible values. If there is no
|
||||
// `DEFAULT` in the list, there is no match so return `null`.
|
||||
if (path[path.length - 1] === 'DEFAULT') {
|
||||
return obj?.DEFAULT ?? null
|
||||
return [(obj?.DEFAULT ?? null) as any, optionsObj.DEFAULT ?? ThemeOptions.NONE] as const
|
||||
}
|
||||
|
||||
// The request looked like `theme('animation.spin')` and was turned into a
|
||||
// lookup for `--animation-spin-*` which had only one entry which means it
|
||||
// should be returned directly.
|
||||
if ('DEFAULT' in obj && Object.keys(obj).length === 1) {
|
||||
return obj.DEFAULT
|
||||
return [obj.DEFAULT as any, optionsObj.DEFAULT ?? ThemeOptions.NONE] as const
|
||||
}
|
||||
|
||||
return obj
|
||||
// Attach the CSS values to the object for later use. This object could be
|
||||
// mutated by the user so we want to keep the original CSS values around.
|
||||
obj.__CSS_VALUES__ = optionsObj
|
||||
|
||||
return [obj, optionsObj] as const
|
||||
}
|
||||
|
||||
function get(obj: any, path: string[]) {
|
||||
|
|
|
|||
|
|
@ -524,6 +524,7 @@ describe('theme', async () => {
|
|||
})
|
||||
|
||||
expect(fn).toHaveBeenCalledWith({
|
||||
__CSS_VALUES__: { bounce: 2, spin: 2 },
|
||||
spin: 'spin 1s linear infinite',
|
||||
bounce: 'bounce 1s linear infinite',
|
||||
})
|
||||
|
|
|
|||
|
|
@ -66,7 +66,11 @@ export class Theme {
|
|||
}
|
||||
|
||||
hasDefault(key: string): boolean {
|
||||
return ((this.values.get(key)?.options ?? 0) & ThemeOptions.DEFAULT) === ThemeOptions.DEFAULT
|
||||
return (this.getOptions(key) & ThemeOptions.DEFAULT) === ThemeOptions.DEFAULT
|
||||
}
|
||||
|
||||
getOptions(key: string) {
|
||||
return this.values.get(key)?.options ?? ThemeOptions.NONE
|
||||
}
|
||||
|
||||
entries() {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue