diff --git a/packages/tailwindcss/src/plugin-api.test.ts b/packages/tailwindcss/src/plugin-api.test.ts index 1063f1e90fe6..e3b5042bb70a 100644 --- a/packages/tailwindcss/src/plugin-api.test.ts +++ b/packages/tailwindcss/src/plugin-api.test.ts @@ -1157,6 +1157,1018 @@ describe('theme', async () => { }) }) +describe('addVariant', () => { + test('addVariant with string selector', async () => { + let { build } = await compile( + css` + @plugin "my-plugin"; + @layer utilities { + @tailwind utilities; + } + `, + { + loadPlugin: async () => { + return ({ addVariant }: PluginAPI) => { + addVariant('hocus', '&:hover, &:focus') + } + }, + }, + ) + let compiled = build(['hocus:underline', 'group-hocus:flex']) + + expect(optimizeCss(compiled).trim()).toMatchInlineSnapshot(` + "@layer utilities { + .group-hocus\\:flex:is(:is(:where(.group):hover, :where(.group):focus) *) { + display: flex; + } + + .hocus\\:underline:hover, .hocus\\:underline:focus { + text-decoration-line: underline; + } + }" + `) + }) + + test('addVariant with array of selectors', async () => { + let { build } = await compile( + css` + @plugin "my-plugin"; + @layer utilities { + @tailwind utilities; + } + `, + { + loadPlugin: async () => { + return ({ addVariant }: PluginAPI) => { + addVariant('hocus', ['&:hover', '&:focus']) + } + }, + }, + ) + + let compiled = build(['hocus:underline', 'group-hocus:flex']) + + expect(optimizeCss(compiled).trim()).toMatchInlineSnapshot(` + "@layer utilities { + .group-hocus\\:flex:is(:where(.group):hover *), .group-hocus\\:flex:is(:where(.group):focus *) { + display: flex; + } + + .hocus\\:underline:hover, .hocus\\:underline:focus { + text-decoration-line: underline; + } + }" + `) + }) + + test('addVariant with object syntax and @slot', async () => { + let { build } = await compile( + css` + @plugin "my-plugin"; + @layer utilities { + @tailwind utilities; + } + `, + { + loadPlugin: async () => { + return ({ addVariant }: PluginAPI) => { + addVariant('hocus', { + '&:hover': '@slot', + '&:focus': '@slot', + }) + } + }, + }, + ) + let compiled = build(['hocus:underline', 'group-hocus:flex']) + + expect(optimizeCss(compiled).trim()).toMatchInlineSnapshot(` + "@layer utilities { + .group-hocus\\:flex:is(:where(.group):hover *), .group-hocus\\:flex:is(:where(.group):focus *) { + display: flex; + } + + .hocus\\:underline:hover, .hocus\\:underline:focus { + text-decoration-line: underline; + } + }" + `) + }) + + test('addVariant with object syntax, media, nesting and multiple @slot', async () => { + let { build } = await compile( + css` + @plugin "my-plugin"; + @layer utilities { + @tailwind utilities; + } + `, + { + loadPlugin: async () => { + return ({ addVariant }: PluginAPI) => { + addVariant('hocus', { + '@media (hover: hover)': { + '&:hover': '@slot', + }, + '&:focus': '@slot', + }) + } + }, + }, + ) + let compiled = build(['hocus:underline', 'group-hocus:flex']) + + expect(optimizeCss(compiled).trim()).toMatchInlineSnapshot(` + "@layer utilities { + @media (hover: hover) { + .group-hocus\\:flex:is(:where(.group):hover *) { + display: flex; + } + } + + .group-hocus\\:flex:is(:where(.group):focus *) { + display: flex; + } + + @media (hover: hover) { + .hocus\\:underline:hover { + text-decoration-line: underline; + } + } + + .hocus\\:underline:focus { + text-decoration-line: underline; + } + }" + `) + }) + + test('@slot is preserved when used as a custom property value', async () => { + let { build } = await compile( + css` + @plugin "my-plugin"; + @layer utilities { + @tailwind utilities; + } + `, + { + loadPlugin: async () => { + return ({ addVariant }: PluginAPI) => { + addVariant('hocus', { + '&': { + '--custom-property': '@slot', + '&:hover': '@slot', + '&:focus': '@slot', + }, + }) + } + }, + }, + ) + let compiled = build(['hocus:underline']) + + expect(optimizeCss(compiled).trim()).toMatchInlineSnapshot(` + "@layer utilities { + .hocus\\:underline { + --custom-property: @slot; + } + + .hocus\\:underline:hover, .hocus\\:underline:focus { + text-decoration-line: underline; + } + }" + `) + }) +}) + +describe('matchVariant', () => { + test('partial arbitrary variants', async () => { + let { build } = await compile( + css` + @plugin "my-plugin"; + @layer utilities { + @tailwind utilities; + } + `, + { + loadPlugin: async () => { + return ({ matchVariant }: PluginAPI) => { + matchVariant('potato', (flavor) => `.potato-${flavor} &`) + } + }, + }, + ) + let compiled = build(['potato-[yellow]:underline', 'potato-[baked]:flex']) + + expect(optimizeCss(compiled).trim()).toMatchInlineSnapshot(` + "@layer utilities { + .potato-yellow .potato-\\[yellow\\]\\:underline { + text-decoration-line: underline; + } + + .potato-baked .potato-\\[baked\\]\\:flex { + display: flex; + } + }" + `) + }) + + test('partial arbitrary variants with at-rules', async () => { + let { build } = await compile( + css` + @plugin "my-plugin"; + @layer utilities { + @tailwind utilities; + } + `, + { + loadPlugin: async () => { + return ({ matchVariant }: PluginAPI) => { + matchVariant('potato', (flavor) => `@media (potato: ${flavor})`) + } + }, + }, + ) + let compiled = build(['potato-[yellow]:underline', 'potato-[baked]:flex']) + + expect(optimizeCss(compiled).trim()).toMatchInlineSnapshot(` + "@layer utilities { + @media (potato: yellow) { + .potato-\\[yellow\\]\\:underline { + text-decoration-line: underline; + } + } + + @media (potato: baked) { + .potato-\\[baked\\]\\:flex { + display: flex; + } + } + }" + `) + }) + + test('partial arbitrary variants with at-rules and placeholder', async () => { + let { build } = await compile( + css` + @plugin "my-plugin"; + @layer utilities { + @tailwind utilities; + } + `, + { + loadPlugin: async () => { + return ({ matchVariant }: PluginAPI) => { + matchVariant('potato', (flavor) => `@media (potato: ${flavor}) { &:potato }`) + } + }, + }, + ) + let compiled = build(['potato-[yellow]:underline', 'potato-[baked]:flex']) + + expect(optimizeCss(compiled).trim()).toMatchInlineSnapshot(` + "@layer utilities { + @media (potato: yellow) { + .potato-\\[yellow\\]\\:underline:potato { + text-decoration-line: underline; + } + } + + @media (potato: baked) { + .potato-\\[baked\\]\\:flex:potato { + display: flex; + } + } + }" + `) + }) + + test('partial arbitrary variants with default values', async () => { + let { build } = await compile( + css` + @plugin "my-plugin"; + @layer utilities { + @tailwind utilities; + } + `, + { + loadPlugin: async () => { + return ({ matchVariant }: PluginAPI) => { + matchVariant('tooltip', (side) => `&${side}`, { + values: { + bottom: '[data-location="bottom"]', + top: '[data-location="top"]', + }, + }) + } + }, + }, + ) + let compiled = build(['tooltip-bottom:underline', 'tooltip-top:flex']) + + expect(optimizeCss(compiled).trim()).toMatchInlineSnapshot(` + "@layer utilities { + .tooltip-bottom\\:underline[data-location="bottom"] { + text-decoration-line: underline; + } + + .tooltip-top\\:flex[data-location="top"] { + display: flex; + } + }" + `) + }) + + test('matched variant values maintain the sort order they are registered in', async () => { + let { build } = await compile( + css` + @plugin "my-plugin"; + @layer utilities { + @tailwind utilities; + } + `, + { + loadPlugin: async () => { + return ({ matchVariant }: PluginAPI) => { + matchVariant('alphabet', (side) => `&${side}`, { + values: { + a: '[data-value="a"]', + b: '[data-value="b"]', + c: '[data-value="c"]', + d: '[data-value="d"]', + }, + }) + } + }, + }, + ) + let compiled = build([ + 'alphabet-c:underline', + 'alphabet-a:underline', + 'alphabet-d:underline', + 'alphabet-b:underline', + ]) + + expect(optimizeCss(compiled).trim()).toMatchInlineSnapshot(` + "@layer utilities { + .alphabet-a\\:underline[data-value="a"] { + text-decoration-line: underline; + } + + .alphabet-b\\:underline[data-value="b"] { + text-decoration-line: underline; + } + + .alphabet-c\\:underline[data-value="c"] { + text-decoration-line: underline; + } + + .alphabet-d\\:underline[data-value="d"] { + text-decoration-line: underline; + } + }" + `) + }) + + test.skip('matchVariant can return an array of format strings from the function', () => { + let config = { + content: [ + { + raw: html`
`, + }, + ], + corePlugins: { preflight: false }, + plugins: [ + ({ matchVariant }) => { + matchVariant('test', (selector) => + selector.split(',').map((selector) => `&.${selector} > *`), + ) + }, + ], + } + + let input = css` + @tailwind utilities; + ` + + return run(input, config).then((result) => { + expect(result.css).toMatchFormattedCss(css` + .test-\[a\,b\,c\]\:underline.a > *, + .test-\[a\,b\,c\]\:underline.b > *, + .test-\[a\,b\,c\]\:underline.c > * { + text-decoration-line: underline; + } + `) + }) + }) + + test.skip('should be possible to sort variants', () => { + let config = { + content: [ + { + raw: html` +
+
+
+ `, + }, + ], + corePlugins: { preflight: false }, + plugins: [ + ({ matchVariant }) => { + matchVariant('testmin', (value) => `@media (min-width: ${value})`, { + sort(a, z) { + return parseInt(a.value) - parseInt(z.value) + }, + }) + }, + ], + } + + let input = css` + @tailwind utilities; + ` + + return run(input, config).then((result) => { + expect(result.css).toMatchFormattedCss(css` + @media (min-width: 500px) { + .testmin-\[500px\]\:underline { + text-decoration-line: underline; + } + } + @media (min-width: 700px) { + .testmin-\[700px\]\:italic { + font-style: italic; + } + } + `) + }) + }) + + test.skip('should be possible to compare arbitrary variants and hardcoded variants', () => { + let config = { + content: [ + { + raw: html` +
+
+
+ `, + }, + ], + corePlugins: { preflight: false }, + plugins: [ + ({ matchVariant }) => { + matchVariant('testmin', (value) => `@media (min-width: ${value})`, { + values: { + example: '600px', + }, + sort(a, z) { + return parseInt(a.value) - parseInt(z.value) + }, + }) + }, + ], + } + + let input = css` + @tailwind utilities; + ` + + return run(input, config).then((result) => { + expect(result.css).toMatchFormattedCss(css` + @media (min-width: 500px) { + .testmin-\[500px\]\:italic { + font-style: italic; + } + } + @media (min-width: 600px) { + .testmin-example\:italic { + font-style: italic; + } + } + @media (min-width: 700px) { + .testmin-\[700px\]\:italic { + font-style: italic; + } + } + `) + }) + }) + + test.skip('should be possible to sort stacked arbitrary variants correctly', () => { + let config = { + content: [ + { + raw: html` +
+ +
+ +
+ +
+ +
+
+ `, + }, + ], + corePlugins: { preflight: false }, + plugins: [ + ({ matchVariant }) => { + matchVariant('testmin', (value) => `@media (min-width: ${value})`, { + sort(a, z) { + return parseInt(a.value) - parseInt(z.value) + }, + }) + + matchVariant('testmax', (value) => `@media (max-width: ${value})`, { + sort(a, z) { + return parseInt(z.value) - parseInt(a.value) + }, + }) + }, + ], + } + + let input = css` + @tailwind utilities; + ` + + return run(input, config).then((result) => { + expect(result.css).toMatchFormattedCss(css` + @media (min-width: 100px) { + @media (max-width: 400px) { + .testmin-\[100px\]\:testmax-\[400px\]\:underline { + text-decoration-line: underline; + } + } + @media (max-width: 350px) { + .testmin-\[100px\]\:testmax-\[350px\]\:underline { + text-decoration-line: underline; + } + } + @media (max-width: 300px) { + .testmin-\[100px\]\:testmax-\[300px\]\:underline { + text-decoration-line: underline; + } + } + } + @media (min-width: 150px) { + @media (max-width: 400px) { + .testmin-\[150px\]\:testmax-\[400px\]\:underline { + text-decoration-line: underline; + } + } + } + `) + }) + }) + + test.skip('should maintain sort from other variants, if sort functions of arbitrary variants return 0', () => { + let config = { + content: [ + { + raw: html` +
+
+
+
+ `, + }, + ], + corePlugins: { preflight: false }, + plugins: [ + ({ matchVariant }) => { + matchVariant('testmin', (value) => `@media (min-width: ${value})`, { + sort(a, z) { + return parseInt(a.value) - parseInt(z.value) + }, + }) + + matchVariant('testmax', (value) => `@media (max-width: ${value})`, { + sort(a, z) { + return parseInt(z.value) - parseInt(a.value) + }, + }) + }, + ], + } + + let input = css` + @tailwind utilities; + ` + + return run(input, config).then((result) => { + expect(result.css).toMatchFormattedCss(css` + @media (min-width: 100px) { + @media (max-width: 200px) { + .testmin-\[100px\]\:testmax-\[200px\]\:hover\:underline:hover, + .testmin-\[100px\]\:testmax-\[200px\]\:focus\:underline:focus { + text-decoration-line: underline; + } + } + } + `) + }) + }) + + test.skip('should sort arbitrary variants left to right (1)', () => { + let config = { + content: [ + { + raw: html` +
+
+
+
+
+
+ `, + }, + ], + corePlugins: { preflight: false }, + plugins: [ + ({ matchVariant }) => { + matchVariant('testmin', (value) => `@media (min-width: ${value})`, { + sort(a, z) { + return parseInt(a.value) - parseInt(z.value) + }, + }) + matchVariant('testmax', (value) => `@media (max-width: ${value})`, { + sort(a, z) { + return parseInt(z.value) - parseInt(a.value) + }, + }) + }, + ], + } + + let input = css` + @tailwind utilities; + ` + + return run(input, config).then((result) => { + expect(result.css).toMatchFormattedCss(css` + @media (min-width: 100px) { + @media (max-width: 400px) { + .testmin-\[100px\]\:testmax-\[400px\]\:underline { + text-decoration-line: underline; + } + } + @media (max-width: 300px) { + .testmin-\[100px\]\:testmax-\[300px\]\:underline { + text-decoration-line: underline; + } + } + } + @media (min-width: 200px) { + @media (max-width: 400px) { + .testmin-\[200px\]\:testmax-\[400px\]\:underline { + text-decoration-line: underline; + } + } + @media (max-width: 300px) { + .testmin-\[200px\]\:testmax-\[300px\]\:underline { + text-decoration-line: underline; + } + } + } + `) + }) + }) + + test.skip('should sort arbitrary variants left to right (2)', () => { + let config = { + content: [ + { + raw: html` +
+
+
+
+
+
+ `, + }, + ], + corePlugins: { preflight: false }, + plugins: [ + ({ matchVariant }) => { + matchVariant('testmin', (value) => `@media (min-width: ${value})`, { + sort(a, z) { + return parseInt(a.value) - parseInt(z.value) + }, + }) + matchVariant('testmax', (value) => `@media (max-width: ${value})`, { + sort(a, z) { + return parseInt(z.value) - parseInt(a.value) + }, + }) + }, + ], + } + + let input = css` + @tailwind utilities; + ` + + return run(input, config).then((result) => { + expect(result.css).toMatchFormattedCss(css` + @media (max-width: 400px) { + @media (min-width: 100px) { + .testmax-\[400px\]\:testmin-\[100px\]\:underline { + text-decoration-line: underline; + } + } + @media (min-width: 200px) { + .testmax-\[400px\]\:testmin-\[200px\]\:underline { + text-decoration-line: underline; + } + } + } + + @media (max-width: 300px) { + @media (min-width: 100px) { + .testmax-\[300px\]\:testmin-\[100px\]\:underline { + text-decoration-line: underline; + } + } + @media (min-width: 200px) { + .testmax-\[300px\]\:testmin-\[200px\]\:underline { + text-decoration-line: underline; + } + } + } + `) + }) + }) + + test.skip('should guarantee that we are not passing values from other variants to the wrong function', () => { + let config = { + content: [ + { + raw: html` +
+
+
+
+
+
+ `, + }, + ], + corePlugins: { preflight: false }, + plugins: [ + ({ matchVariant }) => { + matchVariant('testmin', (value) => `@media (min-width: ${value})`, { + sort(a, z) { + let lookup = ['100px', '200px'] + if (lookup.indexOf(a.value) === -1 || lookup.indexOf(z.value) === -1) { + throw new Error('We are seeing values that should not be there!') + } + return lookup.indexOf(a.value) - lookup.indexOf(z.value) + }, + }) + matchVariant('testmax', (value) => `@media (max-width: ${value})`, { + sort(a, z) { + let lookup = ['300px', '400px'] + if (lookup.indexOf(a.value) === -1 || lookup.indexOf(z.value) === -1) { + throw new Error('We are seeing values that should not be there!') + } + return lookup.indexOf(z.value) - lookup.indexOf(a.value) + }, + }) + }, + ], + } + + let input = css` + @tailwind utilities; + ` + + return run(input, config).then((result) => { + expect(result.css).toMatchFormattedCss(css` + @media (min-width: 100px) { + @media (max-width: 400px) { + .testmin-\[100px\]\:testmax-\[400px\]\:underline { + text-decoration-line: underline; + } + } + + @media (max-width: 300px) { + .testmin-\[100px\]\:testmax-\[300px\]\:underline { + text-decoration-line: underline; + } + } + } + + @media (min-width: 200px) { + @media (max-width: 400px) { + .testmin-\[200px\]\:testmax-\[400px\]\:underline { + text-decoration-line: underline; + } + } + + @media (max-width: 300px) { + .testmin-\[200px\]\:testmax-\[300px\]\:underline { + text-decoration-line: underline; + } + } + } + `) + }) + }) + + test.skip('should default to the DEFAULT value for variants', () => { + let config = { + content: [ + { + raw: html` +
+
+
+ `, + }, + ], + corePlugins: { preflight: false }, + plugins: [ + ({ matchVariant }) => { + matchVariant('foo', (value) => `.foo${value} &`, { + values: { + DEFAULT: '.bar', + }, + }) + }, + ], + } + + let input = css` + @tailwind utilities; + ` + + return run(input, config).then((result) => { + expect(result.css).toMatchFormattedCss(css` + .foo.bar .foo\:underline { + text-decoration-line: underline; + } + `) + }) + }) + + test.skip('should not generate anything if the matchVariant does not have a DEFAULT value configured', () => { + let config = { + content: [ + { + raw: html` +
+
+
+ `, + }, + ], + corePlugins: { preflight: false }, + plugins: [ + ({ matchVariant }) => { + matchVariant('foo', (value) => `.foo${value} &`) + }, + ], + } + + let input = css` + @tailwind utilities; + ` + + return run(input, config).then((result) => { + expect(result.css).toMatchFormattedCss(css``) + }) + }) + + test.skip('should be possible to use `null` as a DEFAULT value', () => { + let config = { + content: [ + { + raw: html` +
+
+
+ `, + }, + ], + corePlugins: { preflight: false }, + plugins: [ + ({ matchVariant }) => { + matchVariant('foo', (value) => `.foo${value === null ? '-good' : '-bad'} &`, { + values: { DEFAULT: null }, + }) + }, + ], + } + + let input = css` + @tailwind utilities; + ` + + return run(input, config).then((result) => { + expect(result.css).toMatchFormattedCss(css` + .foo-good .foo\:underline { + text-decoration-line: underline; + } + `) + }) + }) + + test.skip('should be possible to use `undefined` as a DEFAULT value', () => { + let config = { + content: [ + { + raw: html` +
+
+
+ `, + }, + ], + corePlugins: { preflight: false }, + plugins: [ + ({ matchVariant }) => { + matchVariant('foo', (value) => `.foo${value === undefined ? '-good' : '-bad'} &`, { + values: { DEFAULT: undefined }, + }) + }, + ], + } + + let input = css` + @tailwind utilities; + ` + + return run(input, config).then((result) => { + expect(result.css).toMatchFormattedCss(css` + .foo-good .foo\:underline { + text-decoration-line: underline; + } + `) + }) + }) + + test.skip('should be possible to use `undefined` as a DEFAULT value', () => { + let config = { + content: [ + { + raw: html` +
+
+
+ `, + }, + ], + corePlugins: { preflight: false }, + plugins: [ + ({ matchVariant }) => { + matchVariant('foo', (value) => `.foo${value === undefined ? '-good' : '-bad'} &`, { + values: { DEFAULT: undefined }, + }) + }, + ], + } + + let input = css` + @tailwind utilities; + ` + + return run(input, config).then((result) => { + expect(result.css).toMatchFormattedCss(css` + .foo-good .foo\:underline { + text-decoration-line: underline; + } + `) + }) + }) + + test.skip('should not break things', () => { + let config = {} + + let context = createContext(resolveConfig(config)) + let [[, fn]] = context.variantMap.get('group') + + let format + + expect( + fn({ + format(input) { + format = input + }, + }), + ).toBe(undefined) + + expect(format).toBe(':merge(.group) &') + }) +}) + describe('addUtilities()', () => { test('custom static utility', async () => { let compiled = await compile( diff --git a/packages/tailwindcss/src/plugin-api.ts b/packages/tailwindcss/src/plugin-api.ts index 54027dfa3a13..6816ae44546e 100644 --- a/packages/tailwindcss/src/plugin-api.ts +++ b/packages/tailwindcss/src/plugin-api.ts @@ -1,5 +1,5 @@ import { substituteAtApply } from './apply' -import { decl, rule, type AstNode } from './ast' +import { decl, rule, walk, type AstNode } from './ast' import type { Candidate, NamedUtilityValue } from './candidate' import { applyConfigToTheme } from './compat/apply-config-to-theme' import { createCompatConfig } from './compat/config/create-compat-config' @@ -7,6 +7,7 @@ import { resolveConfig, type ConfigFile } from './compat/config/resolve-config' import type { ResolvedConfig, UserConfig } from './compat/config/types' import { darkModePlugin } from './compat/dark-mode' import { createThemeFn } from './compat/plugin-functions' +import * as CSS from './css-parser' import type { DesignSystem } from './design-system' import { withAlpha, withNegative } from './utilities' import { inferDataType } from './utils/infer-data-type' @@ -25,7 +26,19 @@ export type Plugin = PluginFn | PluginWithConfig | PluginWithOptions export type PluginAPI = { addBase(base: CssInJs): void + addVariant(name: string, variant: string | string[] | CssInJs): void + matchVariant( + name: string, + cb: (value: T | string, extra: { modifier: string | null }) => string | string[], + options?: { + values?: Record + sort?( + a: { value: T | string; modifier: string | null }, + b: { value: T | string; modifier: string | null }, + ): number + }, + ): void addUtilities( utilities: Record | Record[], @@ -96,6 +109,66 @@ function buildPluginApi( designSystem.variants.fromAst(name, objectToAst(variant)) } }, + matchVariant(name, fn, options) { + function resolveVariantValue( + value: string, + modifier: string | null, + nodes: AstNode[], + ): AstNode[] { + let resolved = fn(value, { modifier }) + return (typeof resolved === 'string' ? [resolved] : resolved).flatMap((r) => { + if (r.includes('{')) { + let ast = CSS.parse(r) + walk(ast, (node, { replaceWith }) => { + if (node.kind === 'declaration' && node.property === '&' && node.value === name) { + replaceWith(rule(`&:${name}`, nodes)) + return + } + }) + return ast + } else { + return rule(r, nodes) + } + }) + } + + let defaultOptionKeys = Object.keys(options?.values ?? {}) + designSystem.variants.group( + () => { + designSystem.variants.functional(name, (ruleNodes, variant) => { + if (!variant.value || variant.modifier) return null + + if (variant.value.kind === 'arbitrary') { + ruleNodes.nodes = resolveVariantValue( + variant.value.value, + variant.modifier, + ruleNodes.nodes, + ) + } else if (variant.value.kind === 'named' && options?.values) { + let defaultValue = options.values[variant.value.value] + if (typeof defaultValue !== 'string') { + return + } + + ruleNodes.nodes = resolveVariantValue(defaultValue, null, ruleNodes.nodes) + } + }) + }, + (a, z) => { + if (a.kind !== 'functional' || z.kind !== 'functional') { + return 0 + } + if (!a.value || !z.value) { + return 0 + } + + let aOrder = defaultOptionKeys.indexOf(a.value.value) ?? 0 + let zOrder = defaultOptionKeys.indexOf(z.value.value) ?? 0 + + return aOrder - zOrder + }, + ) + }, addUtilities(utilities) { utilities = Array.isArray(utilities) ? utilities : [utilities]