From b89fec36ec5101fd1b10272334e0d52a028d8ff4 Mon Sep 17 00:00:00 2001 From: Andrey Yamanov Date: Wed, 18 Feb 2026 15:55:06 +0100 Subject: [PATCH 1/4] feat(tasty): extend syntax --- src/stories/Tasty.docs.mdx | 57 +++ src/tasty/states/index.ts | 6 + src/tasty/utils/merge-styles.test.ts | 597 +++++++++++++++++++++++++++ src/tasty/utils/merge-styles.ts | 228 +++++++++- src/tasty/utils/styles.ts | 5 +- 5 files changed, 882 insertions(+), 11 deletions(-) create mode 100644 src/tasty/utils/merge-styles.test.ts diff --git a/src/stories/Tasty.docs.mdx b/src/stories/Tasty.docs.mdx index e946993f7..95a500f4b 100644 --- a/src/stories/Tasty.docs.mdx +++ b/src/stories/Tasty.docs.mdx @@ -50,6 +50,63 @@ const PrimaryButton = tasty(Button, { ``` +#### Extending State Maps with `@extend` + +When extending a component, style properties with state maps are **replaced** by default. Use `@extend` to merge with the parent's states instead: + +```jsx live=false +// Parent has: fill: { '': '#white', hovered: '#blue', disabled: '#gray' } + +// ❌ This replaces ALL parent states +const MyButton = tasty(Button, { + styles: { fill: { '': '#red' } }, +}); + +// ✅ This preserves parent states and adds/overrides +const MyButton = tasty(Button, { + styles: { + fill: { + '@extend': true, + 'loading': '#yellow', // append new state + 'disabled': '#gray.20', // override existing state in place + }, + }, +}); +``` + +Use `'@inherit'` to reposition a parent state (e.g., keep `disabled` as highest priority): + +```jsx live=false +fill: { + '@extend': true, + 'loading': '#yellow', + disabled: '@inherit', // moves disabled to end (highest CSS priority) +} +``` + +Use `null` inside `@extend` to remove a parent state: + +```jsx live=false +fill: { '@extend': true, pressed: null } // removes pressed from the result +``` + +#### Resetting Properties with `null` and `false` + +```jsx live=false +const SimpleButton = tasty(Button, { + styles: { + fill: null, // discard parent's fill, let recipe fill in + border: false, // no border at all (tombstone — blocks recipe too) + }, +}); +``` + +| Value | Meaning | Recipe fills in? | +|-------|---------|-----------------| +| `undefined` | Not provided — parent preserved | N/A | +| `null` | Intentional unset — parent discarded | Yes | +| `false` | Tombstone — blocks everything | No | + ### Essential Patterns ```jsx diff --git a/src/tasty/states/index.ts b/src/tasty/states/index.ts index 4b5dda222..796592fcb 100644 --- a/src/tasty/states/index.ts +++ b/src/tasty/states/index.ts @@ -53,6 +53,10 @@ const BUILTIN_STATES = new Set([ '@keyframes', '@properties', '@supports', + '@extend', + // @inherit is a value (not a key), but reserved here to prevent + // users from accidentally defining a state named '@inherit'. + '@inherit', ]); // Reserved prefixes that are built-in @@ -65,6 +69,8 @@ const RESERVED_PREFIXES = [ '@keyframes', '@properties', '@supports', + '@extend', + '@inherit', ]; // Global predefined states storage diff --git a/src/tasty/utils/merge-styles.test.ts b/src/tasty/utils/merge-styles.test.ts new file mode 100644 index 000000000..87ce7ef8c --- /dev/null +++ b/src/tasty/utils/merge-styles.test.ts @@ -0,0 +1,597 @@ +import { Styles } from '../styles/types'; + +import { mergeStyles } from './merge-styles'; + +describe('mergeStyles', () => { + describe('basic merging (existing behavior)', () => { + it('should merge flat styles', () => { + const result = mergeStyles( + { fill: '#white', padding: '2x' }, + { fill: '#blue', radius: '1r' }, + ); + expect(result).toEqual({ + fill: '#blue', + padding: '2x', + radius: '1r', + }); + }); + + it('should handle undefined/null inputs', () => { + expect(mergeStyles(undefined, { fill: '#blue' })).toEqual({ + fill: '#blue', + }); + expect(mergeStyles({ fill: '#blue' }, undefined)).toEqual({ + fill: '#blue', + }); + expect(mergeStyles(null, { fill: '#blue' })).toEqual({ + fill: '#blue', + }); + }); + + it('should merge multiple style objects', () => { + const result = mergeStyles( + { fill: '#white' }, + { padding: '2x' }, + { radius: '1r' }, + ); + expect(result).toEqual({ + fill: '#white', + padding: '2x', + radius: '1r', + }); + }); + + it('should deep merge sub-element styles', () => { + const result = mergeStyles( + { Title: { preset: 'h3', color: '#dark' } } as Styles, + { Title: { color: '#blue' } } as Styles, + ); + expect(result).toEqual({ + Title: { preset: 'h3', color: '#blue' }, + }); + }); + }); + + describe('sub-element null/undefined/false semantics', () => { + it('should keep parent sub-element when child is undefined', () => { + const result = mergeStyles( + { Title: { preset: 'h3' } } as Styles, + { Title: undefined } as Styles, + ); + expect(result).toEqual({ + Title: { preset: 'h3' }, + }); + }); + + it('should remove sub-element when child is null (recipe fills in)', () => { + const result = mergeStyles( + { Title: { preset: 'h3' } } as Styles, + { Title: null } as Styles, + ); + expect(result.Title).toBeUndefined(); + expect('Title' in result).toBe(false); + }); + + it('should delete sub-element when child is false', () => { + const result = mergeStyles( + { Title: { preset: 'h3' } } as Styles, + { Title: false } as Styles, + ); + expect(result.Title).toBeUndefined(); + expect('Title' in result).toBe(false); + }); + }); + + describe('regular property null/false semantics', () => { + it('should remove property when child is null', () => { + const result = mergeStyles({ fill: '#white', padding: '2x' }, { + fill: null, + } as Styles); + expect(result.padding).toBe('2x'); + expect('fill' in result).toBe(false); + }); + + it('should NOT remove property when child is undefined', () => { + const result = mergeStyles({ fill: '#white', padding: '2x' }, { + fill: undefined, + } as Styles); + expect(result.fill).toBe('#white'); + expect(result.padding).toBe('2x'); + }); + + it('should keep false as tombstone value', () => { + const result = mergeStyles({ fill: '#white' }, { fill: false } as Styles); + expect(result.fill).toBe(false); + }); + }); + + describe('@extend — state map merging', () => { + const parentStyles: Styles = { + fill: { + '': '#white #primary', + hovered: '#white #primary-text', + pressed: '#white #primary', + disabled: '#white #primary-disabled', + }, + }; + + it('should preserve parent states and append new states', () => { + const result = mergeStyles(parentStyles, { + fill: { + '@extend': true, + 'custom-state': '#custom', + }, + } as Styles); + + const keys = Object.keys(result.fill as object); + expect(keys).toEqual([ + '', + 'hovered', + 'pressed', + 'disabled', + 'custom-state', + ]); + expect((result.fill as any)['']).toBe('#white #primary'); + expect((result.fill as any).hovered).toBe('#white #primary-text'); + expect((result.fill as any).pressed).toBe('#white #primary'); + expect((result.fill as any).disabled).toBe('#white #primary-disabled'); + expect((result.fill as any)['custom-state']).toBe('#custom'); + }); + + it('should override an existing state in place', () => { + const result = mergeStyles(parentStyles, { + fill: { + '@extend': true, + disabled: '#gray.20', + }, + } as Styles); + + const keys = Object.keys(result.fill as object); + expect(keys).toEqual(['', 'hovered', 'pressed', 'disabled']); + expect((result.fill as any).disabled).toBe('#gray.20'); + expect((result.fill as any)['']).toBe('#white #primary'); + }); + + it('should remove a state with null', () => { + const result = mergeStyles(parentStyles, { + fill: { + '@extend': true, + pressed: null, + }, + } as Styles); + + const keys = Object.keys(result.fill as object); + expect(keys).toEqual(['', 'hovered', 'disabled']); + expect((result.fill as any).pressed).toBeUndefined(); + }); + + it('should normalize plain string parent to state map', () => { + const result = mergeStyles({ fill: '#purple' }, { + fill: { + '@extend': true, + hovered: '#blue', + }, + } as Styles); + + expect(result.fill).toEqual({ + '': '#purple', + hovered: '#blue', + }); + }); + + it('should work when parent has no value for the property', () => { + const result = mergeStyles({ padding: '2x' }, { + fill: { + '@extend': true, + '': '#white', + hovered: '#blue', + }, + } as Styles); + + expect(result.fill).toEqual({ + '': '#white', + hovered: '#blue', + }); + }); + + it('should strip @extend key from result', () => { + const result = mergeStyles(parentStyles, { + fill: { + '@extend': true, + 'custom-state': '#custom', + }, + } as Styles); + + expect((result.fill as any)['@extend']).toBeUndefined(); + }); + + it('should handle boolean parent value', () => { + const result = mergeStyles( + { fill: true } as Styles, + { + fill: { + '@extend': true, + hovered: '#blue', + }, + } as Styles, + ); + + expect(result.fill).toEqual({ + '': true, + hovered: '#blue', + }); + }); + + it('should handle false parent value (no parent to extend)', () => { + const result = mergeStyles( + { fill: false } as Styles, + { + fill: { + '@extend': true, + hovered: '#blue', + }, + } as Styles, + ); + + expect(result.fill).toEqual({ + hovered: '#blue', + }); + }); + + it('should handle number parent value', () => { + const result = mergeStyles( + { opacity: 1 } as Styles, + { + opacity: { + '@extend': true, + hovered: 0.8, + }, + } as Styles, + ); + + expect(result.opacity).toEqual({ + '': 1, + hovered: 0.8, + }); + }); + }); + + describe('@inherit — state repositioning', () => { + const parentStyles: Styles = { + fill: { + '': '#white #primary', + hovered: '#white #primary-text', + pressed: '#white #primary', + disabled: '#white #primary-disabled', + }, + }; + + it('should reposition a parent state to child order', () => { + const result = mergeStyles(parentStyles, { + fill: { + '@extend': true, + 'custom-state': '#custom', + disabled: '@inherit', + }, + } as Styles); + + const keys = Object.keys(result.fill as object); + expect(keys).toEqual([ + '', + 'hovered', + 'pressed', + 'custom-state', + 'disabled', + ]); + expect((result.fill as any).disabled).toBe('#white #primary-disabled'); + expect((result.fill as any)['custom-state']).toBe('#custom'); + }); + + it('should reposition multiple states', () => { + const result = mergeStyles(parentStyles, { + fill: { + '@extend': true, + 'custom-state': '#custom', + pressed: '@inherit', + disabled: '@inherit', + }, + } as Styles); + + const keys = Object.keys(result.fill as object); + expect(keys).toEqual([ + '', + 'hovered', + 'custom-state', + 'pressed', + 'disabled', + ]); + }); + + it('should preserve child declaration order for interleaved new and @inherit entries', () => { + const result = mergeStyles(parentStyles, { + fill: { + '@extend': true, + newA: '#a', + pressed: '@inherit', + newB: '#b', + disabled: '@inherit', + }, + } as Styles); + + const keys = Object.keys(result.fill as object); + expect(keys).toEqual([ + '', + 'hovered', + 'newA', + 'pressed', + 'newB', + 'disabled', + ]); + }); + + it('should skip @inherit for non-existent parent key (dev warning)', () => { + const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}); + + const result = mergeStyles(parentStyles, { + fill: { + '@extend': true, + nonexistent: '@inherit', + }, + } as Styles); + + const keys = Object.keys(result.fill as object); + expect(keys).toEqual(['', 'hovered', 'pressed', 'disabled']); + expect((result.fill as any).nonexistent).toBeUndefined(); + + warnSpy.mockRestore(); + }); + + it('should strip @inherit without @extend (treated as false)', () => { + const result = mergeStyles(parentStyles, { + fill: { + '': '#new', + disabled: '@inherit', + }, + } as Styles); + + expect((result.fill as any)['']).toBe('#new'); + expect((result.fill as any).disabled).toBeUndefined(); + expect('disabled' in (result.fill as object)).toBe(false); + }); + }); + + describe('@extend within sub-elements', () => { + it('should extend state maps inside sub-element blocks', () => { + const result = mergeStyles( + { + Icon: { + color: { + '': '#gray', + disabled: '#light-gray', + }, + }, + } as Styles, + { + Icon: { + color: { + '@extend': true, + loading: '#dark-gray', + }, + }, + } as Styles, + ); + + const iconStyles = result.Icon as any; + expect(iconStyles.color).toEqual({ + '': '#gray', + disabled: '#light-gray', + loading: '#dark-gray', + }); + }); + }); + + describe('combined @extend + @inherit + null', () => { + it('should handle extend + remove + reposition together', () => { + const parentStyles: Styles = { + fill: { + '': '#white', + hovered: '#blue', + pressed: '#green', + focused: '#purple', + disabled: '#gray', + }, + }; + + const result = mergeStyles(parentStyles, { + fill: { + '@extend': true, + pressed: null, + custom: '#custom', + disabled: '@inherit', + }, + } as Styles); + + const keys = Object.keys(result.fill as object); + expect(keys).toEqual(['', 'hovered', 'focused', 'custom', 'disabled']); + expect((result.fill as any).pressed).toBeUndefined(); + expect((result.fill as any).disabled).toBe('#gray'); + expect((result.fill as any).custom).toBe('#custom'); + }); + }); + + describe('chaining multiple merges', () => { + it('should support multi-level extension', () => { + const base: Styles = { + fill: { + '': '#white', + hovered: '#blue', + }, + }; + const level1: Styles = { + fill: { + '@extend': true, + pressed: '#green', + }, + } as Styles; + const level2: Styles = { + fill: { + '@extend': true, + disabled: '#gray', + }, + } as Styles; + + const result = mergeStyles(base, level1, level2); + + expect(Object.keys(result.fill as object)).toEqual([ + '', + 'hovered', + 'pressed', + 'disabled', + ]); + }); + + it('should support multi-level @inherit chaining', () => { + const base: Styles = { + fill: { + '': '#white', + hovered: '#blue', + disabled: '#gray', + }, + }; + const level1: Styles = { + fill: { + '@extend': true, + pressed: '#green', + disabled: '@inherit', + }, + } as Styles; + const level2: Styles = { + fill: { + '@extend': true, + loading: '#yellow', + disabled: '@inherit', + }, + } as Styles; + + const result = mergeStyles(base, level1, level2); + + const keys = Object.keys(result.fill as object); + expect(keys).toEqual(['', 'hovered', 'pressed', 'loading', 'disabled']); + expect((result.fill as any).disabled).toBe('#gray'); + }); + }); + + describe('@extend with no parent value', () => { + it('should strip @inherit when no parent exists', () => { + const result = mergeStyles({ padding: '2x' }, { + fill: { + '@extend': true, + '': '#white', + disabled: '@inherit', + }, + } as Styles); + + expect(result.fill).toEqual({ '': '#white' }); + expect((result.fill as any).disabled).toBeUndefined(); + }); + + it('should strip null entries when no parent exists', () => { + const result = mergeStyles({}, { + fill: { + '@extend': true, + '': '#white', + hovered: '#blue', + pressed: null, + }, + } as Styles); + + expect(result.fill).toEqual({ '': '#white', hovered: '#blue' }); + }); + + it('should handle @extend with all entries removed via null', () => { + const parentStyles: Styles = { + fill: { + '': '#white', + hovered: '#blue', + }, + }; + + const result = mergeStyles(parentStyles, { + fill: { + '@extend': true, + '': null, + hovered: null, + }, + } as Styles); + + expect(result.fill).toEqual({}); + }); + + it('should handle empty parent state map', () => { + const result = mergeStyles( + { fill: {} } as Styles, + { + fill: { + '@extend': true, + hovered: '#blue', + }, + } as Styles, + ); + + expect(result.fill).toEqual({ hovered: '#blue' }); + }); + }); + + describe('new sub-element with @extend properties', () => { + it('should resolve @extend inside a new sub-element (not in parent)', () => { + const result = mergeStyles({ fill: '#white' }, { + Icon: { + color: { + '@extend': true, + hovered: '#blue', + }, + }, + } as Styles); + + const iconStyles = result.Icon as any; + expect(iconStyles.color).toEqual({ hovered: '#blue' }); + expect(iconStyles.color['@extend']).toBeUndefined(); + }); + + it('should strip @inherit inside a new sub-element property without @extend', () => { + const result = mergeStyles({ fill: '#white' }, { + Icon: { + color: { + '': '#gray', + disabled: '@inherit', + }, + }, + } as Styles); + + const iconStyles = result.Icon as any; + expect(iconStyles.color).toEqual({ '': '#gray' }); + expect(iconStyles.color.disabled).toBeUndefined(); + }); + }); + + describe('@inherit stripped from non-@extend state maps', () => { + it('should strip @inherit from top-level state map without @extend', () => { + const result = mergeStyles({ fill: '#white' }, { + fill: { + '': '#new', + hovered: '#blue', + disabled: '@inherit', + }, + } as Styles); + + expect(result.fill).toEqual({ '': '#new', hovered: '#blue' }); + }); + + it('should pass through state map without @inherit unchanged', () => { + const stateMap = { '': '#white', hovered: '#blue' }; + const result = mergeStyles({}, { fill: stateMap } as Styles); + + expect(result.fill).toBe(stateMap); + }); + }); +}); diff --git a/src/tasty/utils/merge-styles.ts b/src/tasty/utils/merge-styles.ts index 442d2d7bd..015643439 100644 --- a/src/tasty/utils/merge-styles.ts +++ b/src/tasty/utils/merge-styles.ts @@ -1,6 +1,181 @@ import { isSelector } from '../pipeline'; import { Styles, StylesWithoutSelectors } from '../styles/types'; +import { isDevEnv } from './is-dev-env'; + +import type { StyleValueStateMap } from './styles'; + +const devMode = isDevEnv(); + +const EXTEND_KEY = '@extend'; +const INHERIT_VALUE = '@inherit'; + +/** + * Check if a value is a state map (object, not array). + */ +function isStateMap(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +/** + * Strip `@inherit` values from a state map that was used without `@extend: true`. + * Returns a new object if any values were stripped, otherwise the original. + */ +function stripInheritValues( + map: Record, +): Record { + let hasInherit = false; + for (const key of Object.keys(map)) { + if (map[key] === INHERIT_VALUE) { + hasInherit = true; + break; + } + } + if (!hasInherit) return map; + + const result: Record = {}; + for (const key of Object.keys(map)) { + if (map[key] !== INHERIT_VALUE) { + result[key] = map[key]; + } + } + return result; +} + +/** + * Check if a value is a state map object with `@extend: true`. + */ +function isExtendMap( + value: unknown, +): value is StyleValueStateMap & { '@extend': true } { + return ( + typeof value === 'object' && + value !== null && + !Array.isArray(value) && + (value as any)[EXTEND_KEY] === true + ); +} + +/** + * Resolve a state map with `@extend: true` against a parent value. + * + * - Shared keys: parent's position, child's value (override in place) + * - Parent-only keys: kept in original position and value + * - Child-only keys: appended at end (highest CSS priority) + * - `@inherit` value: reposition parent's value to this position in child order + * - `null` value: remove this state from the result + */ +function resolveExtendMap( + parentValue: unknown, + childMap: Record, +): Record { + const { [EXTEND_KEY]: _, ...childEntries } = childMap; + + // Normalize parent to state map + let parentMap: Record; + if ( + typeof parentValue === 'object' && + parentValue !== null && + !Array.isArray(parentValue) + ) { + parentMap = parentValue as Record; + } else if (parentValue != null && parentValue !== false) { + parentMap = { '': parentValue }; + } else { + // No parent to extend from — strip nulls and @inherit, return child entries + const result: Record = {}; + for (const key of Object.keys(childEntries)) { + const val = childEntries[key]; + if (val === null || val === INHERIT_VALUE) continue; + result[key] = val; + } + return result; + } + + // Classify child entries + const inheritKeys = new Set(); + const removeKeys = new Set(); + const overrideKeys = new Map(); + + for (const key of Object.keys(childEntries)) { + const val = childEntries[key]; + if (val === INHERIT_VALUE) { + if (key in parentMap) { + inheritKeys.add(key); + } else if (devMode) { + console.warn( + `[Tasty] @inherit used for state '${key}' that does not exist in the parent style map. Entry skipped.`, + ); + } + } else if (val === null) { + removeKeys.add(key); + } else if (key in parentMap) { + overrideKeys.set(key, val); + } + } + + // Build result: + // 1. Parent entries in order (skip removed, skip repositioned, apply overrides) + const result: Record = {}; + for (const key of Object.keys(parentMap)) { + if (removeKeys.has(key)) continue; + if (inheritKeys.has(key)) continue; + if (overrideKeys.has(key)) { + result[key] = overrideKeys.get(key); + } else { + result[key] = parentMap[key]; + } + } + + // 2. Append new + repositioned entries in child declaration order + for (const key of Object.keys(childEntries)) { + if (inheritKeys.has(key)) { + result[key] = parentMap[key]; + } else if ( + !removeKeys.has(key) && + !overrideKeys.has(key) && + childEntries[key] !== INHERIT_VALUE + ) { + result[key] = childEntries[key]; + } + } + + return result; +} + +/** + * Merge sub-element properties with @extend / null / undefined support. + */ +function mergeSubElementStyles( + parentSub: StylesWithoutSelectors | undefined, + childSub: StylesWithoutSelectors, +): StylesWithoutSelectors { + const parent = parentSub as Record | undefined; + const child = childSub as Record; + const merged: Record = { ...parent, ...child }; + + for (const key of Object.keys(child)) { + const val = child[key]; + + if (val === undefined) { + if (parent && key in parent) { + merged[key] = parent[key]; + } + } else if (val === null) { + delete merged[key]; + } else if (isExtendMap(val)) { + merged[key] = resolveExtendMap( + parent ? parent[key] : undefined, + val as Record, + ); + } else if (isStateMap(val)) { + merged[key] = stripInheritValues(val as Record); + } + } + + return merged as StylesWithoutSelectors; +} + export function mergeStyles(...objects: (Styles | undefined | null)[]): Styles { let styles: Styles = objects[0] ? { ...objects[0] } : {}; let pos = 1; @@ -14,18 +189,51 @@ export function mergeStyles(...objects: (Styles | undefined | null)[]): Styles { if (newStyles) { const resultStyles = { ...styles, ...newStyles }; - for (let key of selectorKeys) { - if (newStyles?.[key] === false) { - // Remove sub-element styles when explicitly set to false + // Collect all selector keys from both parent and child + const newSelectorKeys = Object.keys(newStyles).filter(isSelector); + const allSelectorKeys = new Set([...selectorKeys, ...newSelectorKeys]); + + for (const key of allSelectorKeys) { + const newValue = newStyles?.[key]; + + if (newValue === false || newValue === null) { delete resultStyles[key]; - } else if (newStyles?.[key] == null) { - // Nullish values (null/undefined) are ignored - restore original styles + } else if (newValue === undefined) { + // Not provided — keep parent's value resultStyles[key] = styles[key]; - } else if (newStyles?.[key]) { - resultStyles[key] = { - ...(styles[key] as StylesWithoutSelectors), - ...(newStyles[key] as StylesWithoutSelectors), - }; + } else if (newValue) { + resultStyles[key] = mergeSubElementStyles( + styles[key] as StylesWithoutSelectors, + newValue as StylesWithoutSelectors, + ); + } + } + + // Handle non-selector properties: @extend, null, undefined + for (const key of Object.keys(newStyles)) { + if (isSelector(key)) continue; + + const newValue = newStyles[key]; + + if (newValue === undefined) { + // Not provided — keep parent's value + if (key in styles) { + resultStyles[key] = styles[key]; + } else { + delete resultStyles[key]; + } + } else if (newValue === null) { + // Intentional unset — remove property, recipe fills in + delete resultStyles[key]; + } else if (isExtendMap(newValue)) { + (resultStyles as Record)[key] = resolveExtendMap( + styles[key], + newValue as Record, + ); + } else if (isStateMap(newValue)) { + (resultStyles as Record)[key] = stripInheritValues( + newValue as Record, + ); } } diff --git a/src/tasty/utils/styles.ts b/src/tasty/utils/styles.ts index 70c024863..6ed2f515b 100644 --- a/src/tasty/utils/styles.ts +++ b/src/tasty/utils/styles.ts @@ -35,7 +35,10 @@ export function normalizeColorTokenValue( } export type StyleValueStateMap = { - [key: string]: StyleValue; + [key: string]: StyleValue | '@inherit'; +} & { + /** Merge with parent's state map instead of replacing it. */ + '@extend'?: true; }; /** From a77829474ab60d3397184f33d05fe2318015a827 Mon Sep 17 00:00:00 2001 From: Andrey Yamanov Date: Wed, 18 Feb 2026 17:09:47 +0100 Subject: [PATCH 2/4] feat(tasty): extend syntax * 2 --- src/stories/Tasty.docs.mdx | 39 ++-- src/tasty/states/index.ts | 2 - src/tasty/utils/merge-styles.test.ts | 268 ++++++++++++++------------- src/tasty/utils/merge-styles.ts | 159 ++++++++-------- src/tasty/utils/styles.ts | 3 - 5 files changed, 243 insertions(+), 228 deletions(-) diff --git a/src/stories/Tasty.docs.mdx b/src/stories/Tasty.docs.mdx index 95a500f4b..864318660 100644 --- a/src/stories/Tasty.docs.mdx +++ b/src/stories/Tasty.docs.mdx @@ -50,44 +50,57 @@ const PrimaryButton = tasty(Button, { ``` -#### Extending State Maps with `@extend` +#### Extending vs. Replacing State Maps -When extending a component, style properties with state maps are **replaced** by default. Use `@extend` to merge with the parent's states instead: +When a style property uses a state map, the merge behavior depends on whether the child provides a `''` (default) key: + +- **No `''` key** — extend mode: parent states are preserved, child adds/overrides +- **Has `''` key** — replace mode: child defines everything from scratch ```jsx live=false // Parent has: fill: { '': '#white', hovered: '#blue', disabled: '#gray' } -// ❌ This replaces ALL parent states +// ✅ Extend — no '' key, parent states preserved const MyButton = tasty(Button, { - styles: { fill: { '': '#red' } }, + styles: { + fill: { + 'loading': '#yellow', // append new state + 'disabled': '#gray.20', // override existing state in place + }, + }, }); -// ✅ This preserves parent states and adds/overrides +// Replace — has '' key, parent states dropped const MyButton = tasty(Button, { styles: { fill: { - '@extend': true, - 'loading': '#yellow', // append new state - 'disabled': '#gray.20', // override existing state in place + '': '#red', + 'hovered': '#blue', }, }, }); ``` -Use `'@inherit'` to reposition a parent state (e.g., keep `disabled` as highest priority): +Use `'@inherit'` to pull a parent state value. In extend mode it repositions the state; in replace mode it cherry-picks it: ```jsx live=false +// Extend mode: reposition disabled to end (highest CSS priority) fill: { - '@extend': true, 'loading': '#yellow', - disabled: '@inherit', // moves disabled to end (highest CSS priority) + disabled: '@inherit', +} + +// Replace mode: cherry-pick disabled from parent +fill: { + '': '#red', + disabled: '@inherit', } ``` -Use `null` inside `@extend` to remove a parent state: +Use `null` inside a state map to remove a state: ```jsx live=false -fill: { '@extend': true, pressed: null } // removes pressed from the result +fill: { pressed: null } // removes pressed from the result ``` #### Resetting Properties with `null` and `false` diff --git a/src/tasty/states/index.ts b/src/tasty/states/index.ts index 796592fcb..56fd929ad 100644 --- a/src/tasty/states/index.ts +++ b/src/tasty/states/index.ts @@ -53,7 +53,6 @@ const BUILTIN_STATES = new Set([ '@keyframes', '@properties', '@supports', - '@extend', // @inherit is a value (not a key), but reserved here to prevent // users from accidentally defining a state named '@inherit'. '@inherit', @@ -69,7 +68,6 @@ const RESERVED_PREFIXES = [ '@keyframes', '@properties', '@supports', - '@extend', '@inherit', ]; diff --git a/src/tasty/utils/merge-styles.test.ts b/src/tasty/utils/merge-styles.test.ts index 87ce7ef8c..c9d8fb0a7 100644 --- a/src/tasty/utils/merge-styles.test.ts +++ b/src/tasty/utils/merge-styles.test.ts @@ -105,7 +105,7 @@ describe('mergeStyles', () => { }); }); - describe('@extend — state map merging', () => { + describe('extend mode — state map without default key', () => { const parentStyles: Styles = { fill: { '': '#white #primary', @@ -118,7 +118,6 @@ describe('mergeStyles', () => { it('should preserve parent states and append new states', () => { const result = mergeStyles(parentStyles, { fill: { - '@extend': true, 'custom-state': '#custom', }, } as Styles); @@ -141,7 +140,6 @@ describe('mergeStyles', () => { it('should override an existing state in place', () => { const result = mergeStyles(parentStyles, { fill: { - '@extend': true, disabled: '#gray.20', }, } as Styles); @@ -155,7 +153,6 @@ describe('mergeStyles', () => { it('should remove a state with null', () => { const result = mergeStyles(parentStyles, { fill: { - '@extend': true, pressed: null, }, } as Styles); @@ -168,7 +165,6 @@ describe('mergeStyles', () => { it('should normalize plain string parent to state map', () => { const result = mergeStyles({ fill: '#purple' }, { fill: { - '@extend': true, hovered: '#blue', }, } as Styles); @@ -179,38 +175,11 @@ describe('mergeStyles', () => { }); }); - it('should work when parent has no value for the property', () => { - const result = mergeStyles({ padding: '2x' }, { - fill: { - '@extend': true, - '': '#white', - hovered: '#blue', - }, - } as Styles); - - expect(result.fill).toEqual({ - '': '#white', - hovered: '#blue', - }); - }); - - it('should strip @extend key from result', () => { - const result = mergeStyles(parentStyles, { - fill: { - '@extend': true, - 'custom-state': '#custom', - }, - } as Styles); - - expect((result.fill as any)['@extend']).toBeUndefined(); - }); - it('should handle boolean parent value', () => { const result = mergeStyles( { fill: true } as Styles, { fill: { - '@extend': true, hovered: '#blue', }, } as Styles, @@ -227,7 +196,6 @@ describe('mergeStyles', () => { { fill: false } as Styles, { fill: { - '@extend': true, hovered: '#blue', }, } as Styles, @@ -243,7 +211,6 @@ describe('mergeStyles', () => { { opacity: 1 } as Styles, { opacity: { - '@extend': true, hovered: 0.8, }, } as Styles, @@ -254,9 +221,107 @@ describe('mergeStyles', () => { hovered: 0.8, }); }); + + it('should handle empty parent state map', () => { + const result = mergeStyles( + { fill: {} } as Styles, + { + fill: { + hovered: '#blue', + }, + } as Styles, + ); + + expect(result.fill).toEqual({ hovered: '#blue' }); + }); + }); + + describe('replace mode — state map with default key', () => { + const parentStyles: Styles = { + fill: { + '': '#white #primary', + hovered: '#white #primary-text', + pressed: '#white #primary', + disabled: '#white #primary-disabled', + }, + }; + + it('should replace all parent states', () => { + const result = mergeStyles(parentStyles, { + fill: { + '': '#red', + hovered: '#blue', + }, + } as Styles); + + const keys = Object.keys(result.fill as object); + expect(keys).toEqual(['', 'hovered']); + expect((result.fill as any)['']).toBe('#red'); + expect((result.fill as any).hovered).toBe('#blue'); + }); + + it('should cherry-pick parent states with @inherit', () => { + const result = mergeStyles(parentStyles, { + fill: { + '': '#red', + hovered: '#blue', + disabled: '@inherit', + }, + } as Styles); + + const keys = Object.keys(result.fill as object); + expect(keys).toEqual(['', 'hovered', 'disabled']); + expect((result.fill as any)['']).toBe('#red'); + expect((result.fill as any).hovered).toBe('#blue'); + expect((result.fill as any).disabled).toBe('#white #primary-disabled'); + }); + + it('should strip null entries', () => { + const result = mergeStyles(parentStyles, { + fill: { + '': '#red', + hovered: '#blue', + pressed: null, + }, + } as Styles); + + const keys = Object.keys(result.fill as object); + expect(keys).toEqual(['', 'hovered']); + }); + + it('should skip @inherit for non-existent parent key (dev warning)', () => { + const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}); + + const result = mergeStyles(parentStyles, { + fill: { + '': '#red', + nonexistent: '@inherit', + }, + } as Styles); + + const keys = Object.keys(result.fill as object); + expect(keys).toEqual(['']); + expect((result.fill as any).nonexistent).toBeUndefined(); + + warnSpy.mockRestore(); + }); + + it('should work when parent has no value for the property', () => { + const result = mergeStyles({ padding: '2x' }, { + fill: { + '': '#white', + hovered: '#blue', + }, + } as Styles); + + expect(result.fill).toEqual({ + '': '#white', + hovered: '#blue', + }); + }); }); - describe('@inherit — state repositioning', () => { + describe('@inherit — state repositioning (extend mode)', () => { const parentStyles: Styles = { fill: { '': '#white #primary', @@ -269,7 +334,6 @@ describe('mergeStyles', () => { it('should reposition a parent state to child order', () => { const result = mergeStyles(parentStyles, { fill: { - '@extend': true, 'custom-state': '#custom', disabled: '@inherit', }, @@ -290,7 +354,6 @@ describe('mergeStyles', () => { it('should reposition multiple states', () => { const result = mergeStyles(parentStyles, { fill: { - '@extend': true, 'custom-state': '#custom', pressed: '@inherit', disabled: '@inherit', @@ -310,7 +373,6 @@ describe('mergeStyles', () => { it('should preserve child declaration order for interleaved new and @inherit entries', () => { const result = mergeStyles(parentStyles, { fill: { - '@extend': true, newA: '#a', pressed: '@inherit', newB: '#b', @@ -334,7 +396,6 @@ describe('mergeStyles', () => { const result = mergeStyles(parentStyles, { fill: { - '@extend': true, nonexistent: '@inherit', }, } as Styles); @@ -345,22 +406,9 @@ describe('mergeStyles', () => { warnSpy.mockRestore(); }); - - it('should strip @inherit without @extend (treated as false)', () => { - const result = mergeStyles(parentStyles, { - fill: { - '': '#new', - disabled: '@inherit', - }, - } as Styles); - - expect((result.fill as any)['']).toBe('#new'); - expect((result.fill as any).disabled).toBeUndefined(); - expect('disabled' in (result.fill as object)).toBe(false); - }); }); - describe('@extend within sub-elements', () => { + describe('extend mode within sub-elements', () => { it('should extend state maps inside sub-element blocks', () => { const result = mergeStyles( { @@ -374,7 +422,6 @@ describe('mergeStyles', () => { { Icon: { color: { - '@extend': true, loading: '#dark-gray', }, }, @@ -388,9 +435,22 @@ describe('mergeStyles', () => { loading: '#dark-gray', }); }); + + it('should resolve @inherit inside a new sub-element (no parent property)', () => { + const result = mergeStyles({ fill: '#white' }, { + Icon: { + color: { + hovered: '#blue', + }, + }, + } as Styles); + + const iconStyles = result.Icon as any; + expect(iconStyles.color).toEqual({ hovered: '#blue' }); + }); }); - describe('combined @extend + @inherit + null', () => { + describe('combined extend + @inherit + null', () => { it('should handle extend + remove + reposition together', () => { const parentStyles: Styles = { fill: { @@ -404,7 +464,6 @@ describe('mergeStyles', () => { const result = mergeStyles(parentStyles, { fill: { - '@extend': true, pressed: null, custom: '#custom', disabled: '@inherit', @@ -429,13 +488,11 @@ describe('mergeStyles', () => { }; const level1: Styles = { fill: { - '@extend': true, pressed: '#green', }, } as Styles; const level2: Styles = { fill: { - '@extend': true, disabled: '#gray', }, } as Styles; @@ -460,14 +517,12 @@ describe('mergeStyles', () => { }; const level1: Styles = { fill: { - '@extend': true, pressed: '#green', disabled: '@inherit', }, } as Styles; const level2: Styles = { fill: { - '@extend': true, loading: '#yellow', disabled: '@inherit', }, @@ -481,34 +536,31 @@ describe('mergeStyles', () => { }); }); - describe('@extend with no parent value', () => { + describe('extend mode with no parent value', () => { it('should strip @inherit when no parent exists', () => { const result = mergeStyles({ padding: '2x' }, { fill: { - '@extend': true, - '': '#white', + hovered: '#blue', disabled: '@inherit', }, } as Styles); - expect(result.fill).toEqual({ '': '#white' }); + expect(result.fill).toEqual({ hovered: '#blue' }); expect((result.fill as any).disabled).toBeUndefined(); }); it('should strip null entries when no parent exists', () => { const result = mergeStyles({}, { fill: { - '@extend': true, - '': '#white', hovered: '#blue', pressed: null, }, } as Styles); - expect(result.fill).toEqual({ '': '#white', hovered: '#blue' }); + expect(result.fill).toEqual({ hovered: '#blue' }); }); - it('should handle @extend with all entries removed via null', () => { + it('should handle extend with all entries removed via null', () => { const parentStyles: Styles = { fill: { '': '#white', @@ -518,7 +570,6 @@ describe('mergeStyles', () => { const result = mergeStyles(parentStyles, { fill: { - '@extend': true, '': null, hovered: null, }, @@ -526,72 +577,35 @@ describe('mergeStyles', () => { expect(result.fill).toEqual({}); }); + }); - it('should handle empty parent state map', () => { + describe('replace mode within sub-elements', () => { + it('should replace state maps inside sub-element blocks', () => { const result = mergeStyles( - { fill: {} } as Styles, { - fill: { - '@extend': true, - hovered: '#blue', + Icon: { + color: { + '': '#gray', + disabled: '#light-gray', + hovered: '#dark-gray', + }, }, } as Styles, - ); - - expect(result.fill).toEqual({ hovered: '#blue' }); - }); - }); - - describe('new sub-element with @extend properties', () => { - it('should resolve @extend inside a new sub-element (not in parent)', () => { - const result = mergeStyles({ fill: '#white' }, { - Icon: { - color: { - '@extend': true, - hovered: '#blue', - }, - }, - } as Styles); - - const iconStyles = result.Icon as any; - expect(iconStyles.color).toEqual({ hovered: '#blue' }); - expect(iconStyles.color['@extend']).toBeUndefined(); - }); - - it('should strip @inherit inside a new sub-element property without @extend', () => { - const result = mergeStyles({ fill: '#white' }, { - Icon: { - color: { - '': '#gray', - disabled: '@inherit', + { + Icon: { + color: { + '': '#blue', + disabled: '@inherit', + }, }, - }, - } as Styles); + } as Styles, + ); const iconStyles = result.Icon as any; - expect(iconStyles.color).toEqual({ '': '#gray' }); - expect(iconStyles.color.disabled).toBeUndefined(); - }); - }); - - describe('@inherit stripped from non-@extend state maps', () => { - it('should strip @inherit from top-level state map without @extend', () => { - const result = mergeStyles({ fill: '#white' }, { - fill: { - '': '#new', - hovered: '#blue', - disabled: '@inherit', - }, - } as Styles); - - expect(result.fill).toEqual({ '': '#new', hovered: '#blue' }); - }); - - it('should pass through state map without @inherit unchanged', () => { - const stateMap = { '': '#white', hovered: '#blue' }; - const result = mergeStyles({}, { fill: stateMap } as Styles); - - expect(result.fill).toBe(stateMap); + expect(iconStyles.color).toEqual({ + '': '#blue', + disabled: '#light-gray', + }); }); }); }); diff --git a/src/tasty/utils/merge-styles.ts b/src/tasty/utils/merge-styles.ts index 015643439..4289906ca 100644 --- a/src/tasty/utils/merge-styles.ts +++ b/src/tasty/utils/merge-styles.ts @@ -3,11 +3,8 @@ import { Styles, StylesWithoutSelectors } from '../styles/types'; import { isDevEnv } from './is-dev-env'; -import type { StyleValueStateMap } from './styles'; - const devMode = isDevEnv(); -const EXTEND_KEY = '@extend'; const INHERIT_VALUE = '@inherit'; /** @@ -18,87 +15,66 @@ function isStateMap(value: unknown): value is Record { } /** - * Strip `@inherit` values from a state map that was used without `@extend: true`. - * Returns a new object if any values were stripped, otherwise the original. - */ -function stripInheritValues( - map: Record, -): Record { - let hasInherit = false; - for (const key of Object.keys(map)) { - if (map[key] === INHERIT_VALUE) { - hasInherit = true; - break; - } - } - if (!hasInherit) return map; - - const result: Record = {}; - for (const key of Object.keys(map)) { - if (map[key] !== INHERIT_VALUE) { - result[key] = map[key]; - } - } - return result; -} - -/** - * Check if a value is a state map object with `@extend: true`. + * Normalize a parent value to a state map. + * - Already a state map → return as-is + * - Non-null, non-false primitive → wrap as `{ '': value }` + * - null / undefined / false → return null (no parent to merge with) */ -function isExtendMap( - value: unknown, -): value is StyleValueStateMap & { '@extend': true } { - return ( - typeof value === 'object' && - value !== null && - !Array.isArray(value) && - (value as any)[EXTEND_KEY] === true - ); +function normalizeToStateMap(value: unknown): Record | null { + if (isStateMap(value)) return value as Record; + if (value != null && value !== false) return { '': value }; + return null; } /** - * Resolve a state map with `@extend: true` against a parent value. + * Resolve a child state map against a parent value. * - * - Shared keys: parent's position, child's value (override in place) - * - Parent-only keys: kept in original position and value - * - Child-only keys: appended at end (highest CSS priority) - * - `@inherit` value: reposition parent's value to this position in child order - * - `null` value: remove this state from the result + * Mode is determined by whether the child contains a `''` (default) key: + * - No `''` → extend mode: parent entries preserved, child adds/overrides/repositions + * - Has `''` → replace mode: child defines everything, `@inherit` cherry-picks from parent + * + * In both modes: + * - `@inherit` value → resolve from parent state map + * - `null` value → remove this state from the result */ -function resolveExtendMap( +function resolveStateMap( parentValue: unknown, childMap: Record, ): Record { - const { [EXTEND_KEY]: _, ...childEntries } = childMap; - - // Normalize parent to state map - let parentMap: Record; - if ( - typeof parentValue === 'object' && - parentValue !== null && - !Array.isArray(parentValue) - ) { - parentMap = parentValue as Record; - } else if (parentValue != null && parentValue !== false) { - parentMap = { '': parentValue }; - } else { - // No parent to extend from — strip nulls and @inherit, return child entries + const isExtend = !('' in childMap); + const parentMap = normalizeToStateMap(parentValue); + + if (!parentMap) { + // No parent to merge with — strip nulls and @inherit, return child entries const result: Record = {}; - for (const key of Object.keys(childEntries)) { - const val = childEntries[key]; + for (const key of Object.keys(childMap)) { + const val = childMap[key]; if (val === null || val === INHERIT_VALUE) continue; result[key] = val; } return result; } - // Classify child entries + if (isExtend) { + return resolveExtendMode(parentMap, childMap); + } + + return resolveReplaceMode(parentMap, childMap); +} + +/** + * Extend mode: parent entries are preserved, child entries add/override/reposition. + */ +function resolveExtendMode( + parentMap: Record, + childMap: Record, +): Record { const inheritKeys = new Set(); const removeKeys = new Set(); const overrideKeys = new Map(); - for (const key of Object.keys(childEntries)) { - const val = childEntries[key]; + for (const key of Object.keys(childMap)) { + const val = childMap[key]; if (val === INHERIT_VALUE) { if (key in parentMap) { inheritKeys.add(key); @@ -114,7 +90,6 @@ function resolveExtendMap( } } - // Build result: // 1. Parent entries in order (skip removed, skip repositioned, apply overrides) const result: Record = {}; for (const key of Object.keys(parentMap)) { @@ -128,15 +103,15 @@ function resolveExtendMap( } // 2. Append new + repositioned entries in child declaration order - for (const key of Object.keys(childEntries)) { + for (const key of Object.keys(childMap)) { if (inheritKeys.has(key)) { result[key] = parentMap[key]; } else if ( !removeKeys.has(key) && !overrideKeys.has(key) && - childEntries[key] !== INHERIT_VALUE + childMap[key] !== INHERIT_VALUE ) { - result[key] = childEntries[key]; + result[key] = childMap[key]; } } @@ -144,7 +119,34 @@ function resolveExtendMap( } /** - * Merge sub-element properties with @extend / null / undefined support. + * Replace mode: child entries define the result, `@inherit` pulls from parent. + */ +function resolveReplaceMode( + parentMap: Record, + childMap: Record, +): Record { + const result: Record = {}; + + for (const key of Object.keys(childMap)) { + const val = childMap[key]; + if (val === INHERIT_VALUE) { + if (key in parentMap) { + result[key] = parentMap[key]; + } else if (devMode) { + console.warn( + `[Tasty] @inherit used for state '${key}' that does not exist in the parent style map. Entry skipped.`, + ); + } + } else if (val !== null) { + result[key] = val; + } + } + + return result; +} + +/** + * Merge sub-element properties with state map / null / undefined support. */ function mergeSubElementStyles( parentSub: StylesWithoutSelectors | undefined, @@ -163,13 +165,11 @@ function mergeSubElementStyles( } } else if (val === null) { delete merged[key]; - } else if (isExtendMap(val)) { - merged[key] = resolveExtendMap( + } else if (isStateMap(val)) { + merged[key] = resolveStateMap( parent ? parent[key] : undefined, val as Record, ); - } else if (isStateMap(val)) { - merged[key] = stripInheritValues(val as Record); } } @@ -199,7 +199,6 @@ export function mergeStyles(...objects: (Styles | undefined | null)[]): Styles { if (newValue === false || newValue === null) { delete resultStyles[key]; } else if (newValue === undefined) { - // Not provided — keep parent's value resultStyles[key] = styles[key]; } else if (newValue) { resultStyles[key] = mergeSubElementStyles( @@ -209,29 +208,23 @@ export function mergeStyles(...objects: (Styles | undefined | null)[]): Styles { } } - // Handle non-selector properties: @extend, null, undefined + // Handle non-selector properties: state maps, null, undefined for (const key of Object.keys(newStyles)) { if (isSelector(key)) continue; const newValue = newStyles[key]; if (newValue === undefined) { - // Not provided — keep parent's value if (key in styles) { resultStyles[key] = styles[key]; } else { delete resultStyles[key]; } } else if (newValue === null) { - // Intentional unset — remove property, recipe fills in delete resultStyles[key]; - } else if (isExtendMap(newValue)) { - (resultStyles as Record)[key] = resolveExtendMap( - styles[key], - newValue as Record, - ); } else if (isStateMap(newValue)) { - (resultStyles as Record)[key] = stripInheritValues( + (resultStyles as Record)[key] = resolveStateMap( + styles[key], newValue as Record, ); } diff --git a/src/tasty/utils/styles.ts b/src/tasty/utils/styles.ts index 6ed2f515b..3821bb17b 100644 --- a/src/tasty/utils/styles.ts +++ b/src/tasty/utils/styles.ts @@ -36,9 +36,6 @@ export function normalizeColorTokenValue( export type StyleValueStateMap = { [key: string]: StyleValue | '@inherit'; -} & { - /** Merge with parent's state map instead of replacing it. */ - '@extend'?: true; }; /** From bac9da557a3085d62a52733c382094e431660740 Mon Sep 17 00:00:00 2001 From: Andrey Yamanov Date: Wed, 18 Feb 2026 17:43:02 +0100 Subject: [PATCH 3/4] feat(tasty): extend syntax * 3 --- src/stories/Tasty.docs.mdx | 3 +- src/tasty/STYLE-EXTEND-SPEC.md | 444 +++++++++++++++++++++++++++ src/tasty/utils/merge-styles.test.ts | 12 +- src/tasty/utils/merge-styles.ts | 2 + 4 files changed, 450 insertions(+), 11 deletions(-) create mode 100644 src/tasty/STYLE-EXTEND-SPEC.md diff --git a/src/stories/Tasty.docs.mdx b/src/stories/Tasty.docs.mdx index 864318660..b13afef7a 100644 --- a/src/stories/Tasty.docs.mdx +++ b/src/stories/Tasty.docs.mdx @@ -97,10 +97,11 @@ fill: { } ``` -Use `null` inside a state map to remove a state: +Use `null` inside a state map to remove a state, or `false` to block it entirely (tombstone): ```jsx live=false fill: { pressed: null } // removes pressed from the result +fill: { disabled: false } // tombstone — no CSS for disabled, blocks recipe too ``` #### Resetting Properties with `null` and `false` diff --git a/src/tasty/STYLE-EXTEND-SPEC.md b/src/tasty/STYLE-EXTEND-SPEC.md new file mode 100644 index 000000000..b512a6c83 --- /dev/null +++ b/src/tasty/STYLE-EXTEND-SPEC.md @@ -0,0 +1,444 @@ +# Style Extend Specification + +## Problem + +`mergeStyles` replaces style values wholesale. When a style property uses a state map (e.g., `fill: { '': '#white', hovered: '#blue', disabled: '#gray' }`), any extension via `tasty(Component, { styles: { fill: ... } })` **erases all parent states**. This forces consumers to duplicate the entire parent state map just to add one new state. + +Similarly, there is no way to "reset" a property back to a recipe-provided value when extending a component that already defines that property. + +## Design Constraints + +- All syntax must be **JSON-serializable** — plain strings, booleans, objects. No Symbols, no functions, no class instances. +- Must be compatible with `tastyStatic` (build-time extraction). +- Must add **near-zero overhead** on the hot path (when the feature is not used). +- Must work within sub-element style blocks (e.g., `Icon: { color: { ... } }`). + +## Primitives + +| Marker | Where | Meaning | +|---|---|---| +| State map **without** `''` key | `mergeStyles` | Extend mode — merge with parent's state map | +| State map **with** `''` key | `mergeStyles` | Replace mode — child defines everything | +| `'@inherit'` as a state value | `mergeStyles` (inside any state map) | Use parent's value for this key | +| `null` as a state value | `mergeStyles` (inside a state map) | Remove this state from the result | +| `false` as a state value | `mergeStyles` (inside a state map) | Tombstone — blocks recipe, produces no CSS for this state | +| `null` as a property value | `mergeStyles` | Intentional unset — discard parent's value, let recipe fill in | +| `false` as a regular property value | Survives all layers | Tombstone — blocks parent value AND recipe, produces no CSS | + +The mode is determined implicitly: if the child state map contains a `''` (default) key, it is in **replace mode** (the child covers the base case and defines everything). If the `''` key is absent, it is in **extend mode** (the child expects to add to the parent's states). + +`'@inherit'` works in both modes. In extend mode it repositions a parent state. In replace mode it cherry-picks a parent state into the child's result. + +--- + +## `null` vs `undefined` vs `false` — Value Semantics + +These three values have distinct meanings, applied consistently across both regular properties and sub-elements: + +| Value | Meaning | Recipe fills in? | CSS output | +|---|---|---|---| +| `undefined` | "I didn't provide this" — ignored, parent value is preserved | N/A (parent stays) | Parent's CSS | +| `null` | "I intentionally unset this" — parent value is discarded | Yes | Recipe's CSS (or none) | +| `false` | "I want nothing here" — stays as `false` through all layers | No (blocked) | None | + +`false` acts as a tombstone: it persists through `mergeStyles` and `resolveRecipes`, overriding the recipe value during resolution. The CSS generator treats `false` as a no-output value. + +### Behavior change for sub-elements + +The current `mergeStyles` uses loose equality (`== null`) for sub-element handling, treating both `null` and `undefined` identically. This spec changes sub-elements to use strict equality (`=== null`) to align with the property-level semantics above: + +- `SubElement: undefined` — ignored, parent's sub-element styles are preserved. +- `SubElement: null` — parent's sub-element styles are discarded (deleted from result). +- `SubElement: false` — parent's sub-element styles are discarded (deleted from result). + +Both `null` and `false` delete the sub-element from the merged result. Unlike regular properties, there is no tombstone distinction for sub-elements — `resolveRecipes` treats the absent key the same way regardless of which sentinel was used. + +--- + +## Extend Mode — State Map Without Default Key + +When the child state map does **not** contain a `''` key, `mergeStyles` enters extend mode. All parent states are preserved; the child adds, overrides, repositions, or removes individual states. + +### Basic usage + +```js +// Parent (e.g., Button variant styles) +fill: { + '': '#white #primary', + hovered: '#white #primary-text', + pressed: '#white #primary', + disabled: '#white #primary-disabled', +} + +// Extension (no '' key → extend mode) +fill: { + 'custom-state': '#custom', +} + +// Result: all parent states preserved, new state appended +fill: { + '': '#white #primary', + hovered: '#white #primary-text', + pressed: '#white #primary', + disabled: '#white #primary-disabled', + 'custom-state': '#custom', +} +``` + +### Merge rules + +- **Shared keys** — parent's position in the map, child's value (override in place). +- **Parent-only keys** — kept in their original position and value. +- **Child-only keys** — appended at the end (highest CSS specificity / priority). + +### Override a specific state + +```js +fill: { + disabled: '#gray.20', // override parent's disabled value, keeps its position +} +``` + +### Remove a state + +```js +fill: { + pressed: null, // remove parent's pressed state from the result +} +``` + +### Block a state with `false` + +`false` inside a state map acts as a tombstone — it persists through all layers and blocks the recipe from filling in: + +```js +fill: { + disabled: false, // no CSS output for disabled, recipe cannot override +} +``` + +### Extend when parent is a plain string + +When the parent value is not a state map (e.g., `fill: '#purple'`), it is automatically normalized to `{ '': parentValue }` before merging: + +```js +// Parent: fill: '#purple' +// Child: fill: { hovered: '#blue' } +// Result: fill: { '': '#purple', hovered: '#blue' } +``` + +### Extend when parent has no value for this property + +When the parent does not define the property at all, `@inherit` and `null` entries are stripped, and the remaining entries are used as-is: + +```js +// Parent: (no fill defined) +// Child: fill: { hovered: '#blue' } +// Result: fill: { hovered: '#blue' } +``` + +### Extend within sub-elements + +Works identically inside sub-element blocks: + +```js +styles: { + Icon: { + color: { + loading: '#gray', // no '' → extend parent's Icon.color + }, + }, +} +``` + +--- + +## Replace Mode — State Map With Default Key + +When the child state map **contains** a `''` key, `mergeStyles` enters replace mode. The child defines the complete state map; parent states are dropped unless explicitly pulled via `@inherit`. + +### Basic usage + +```js +// Parent +fill: { + '': '#white #primary', + hovered: '#white #primary-text', + pressed: '#white #primary', + disabled: '#white #primary-disabled', +} + +// Replacement (has '' key → replace mode) +fill: { + '': '#red', + hovered: '#blue', +} + +// Result: only child states +fill: { + '': '#red', + hovered: '#blue', +} +``` + +### Cherry-pick parent states with `@inherit` + +```js +fill: { + '': '#red', + hovered: '#blue', + disabled: '@inherit', // pull disabled value from parent +} + +// Result +fill: { + '': '#red', + hovered: '#blue', + disabled: '#white #primary-disabled', +} +``` + +--- + +## `@inherit` — Pull a Parent State + +### Syntax + +Use `'@inherit'` as the **value** for a state key. This tells `mergeStyles` to resolve the value from the parent's state map for this key. + +**In extend mode**, `@inherit` repositions the parent state: + +1. Remove it from its original position in the parent order. +2. Place it at **this position** in the child's entry order. + +```js +// Parent +fill: { + '': '#white #primary', + hovered: '#white #primary-text', + pressed: '#white #primary', + disabled: '#white #primary-disabled', +} + +// Extension: add custom-state, but keep disabled as highest priority +fill: { + 'custom-state': '#custom', + disabled: '@inherit', +} + +// Result +fill: { + '': '#white #primary', + hovered: '#white #primary-text', + pressed: '#white #primary', + 'custom-state': '#custom', + disabled: '#white #primary-disabled', // repositioned to end +} +``` + +**In replace mode**, `@inherit` cherry-picks a parent state into the child's result at the declared position. + +### Edge cases + +- `'@inherit'` for a key that doesn't exist in the parent — dev-mode warning, entry is skipped. +- `'@inherit'` with no parent value at all — silently stripped. + +--- + +## `null` — Reset to Recipe Layer + +### Problem it solves + +When extending a component, sometimes you want to discard the parent component's value for a property and fall back to whatever the recipe provides. Currently this is impossible — the parent's value always wins. + +### Simple reset + +Use `null` as the property value. `mergeStyles` treats `null` as an intentional unset — the parent's value is discarded. Later, `resolveRecipes` merges recipe values under component values, and since the property is absent, the recipe value fills in: + +```js +const MyButton = tasty(Button, { + styles: { + fill: null, // discard Button's fill, use recipe's fill instead + }, +}); +``` + +### Hard remove with `false` + +Use `false` to block the property entirely — the parent value is discarded AND the recipe cannot fill in: + +```js +const MyButton = tasty(Button, { + styles: { + fill: false, // no fill at all, recipe is blocked too + }, +}); +``` + +`false` persists through all layers as a tombstone value. When `resolveRecipes` builds `{ ...recipeValues, ...componentValues }`, `false` overwrites the recipe's value. The CSS generator then treats `false` as no-output. + +### Implementation in `mergeStyles` + +For regular properties (non-selectors), add a strict null check: + +``` +if value === null: + delete resultStyles[key] // remove from merged result +else if value === false: + resultStyles[key] = false // keep as tombstone +else: + existing spread behavior +``` + +For sub-elements (selectors), change from loose (`== null`) to strict equality: + +``` +if value === false OR value === null: + delete resultStyles[key] // remove sub-element +else if value === undefined: + resultStyles[key] = parentStyles[key] // keep parent's value +else: + deep merge (with state map / @inherit / null support inside properties) +``` + +### Implementation in `resolveRecipes` + +No changes needed. The existing `{ ...recipeValues, ...componentValues }` spread naturally handles both cases: + +- `null` was deleted by `mergeStyles`, so the recipe value fills in. +- `false` was kept by `mergeStyles`, so it overwrites the recipe value. + +--- + +## Combined Example + +Wrapping `Button` to add a "loading" visual state while preserving all existing states: + +```js +const LoadingButton = tasty(Button, { + styles: { + fill: { + loading: '#white #primary.60', + disabled: '@inherit', // keep disabled as highest priority + }, + border: { + loading: '#clear', + }, + cursor: { + loading: 'wait', + }, + }, +}); +``` + +Resetting a property to its recipe value: + +```js +const SimpleButton = tasty(Button, { + styles: { + fill: null, // use recipe's fill instead of Button's complex fill + border: false, // no border at all, even if recipe defines one + }, +}); +``` + +--- + +## Merge Algorithm + +### `mergeStyles` changes + +For each property in `newStyles`: + +**Sub-element keys (selectors):** + +Selector keys are collected from **both** parent and child styles to ensure new sub-elements are also processed. + +``` +if value === false OR value === null: + delete resultStyles[key] // remove sub-element +else if value === undefined: + resultStyles[key] = styles[key] // keep parent (unchanged from current behavior) +else: + deep merge (with state map / @inherit / null support inside properties) +``` + +**Regular properties (non-selectors):** + +``` +1. if value === undefined: + keep parent's value (or delete if parent has none) +2. else if value === null: + delete resultStyles[key] // unset, recipe fills in +3. else if value is a state map (object, not array): + a. determine mode: isExtend = !( '' in childMap ) + b. normalize parent to state map (string → { '': value }) + c. if isExtend (no '' key): + - collect all parent entries + - for each child entry: + - if value is '@inherit': mark parent key for repositioning + - if value is null: mark parent key for removal + - otherwise: mark as override (if key exists in parent) or append + - build result: + i. parent entries in original order (skip removed, skip repositioned) + ii. apply in-place overrides at their parent positions + iii. append new + repositioned entries interleaved in child declaration order + d. if replace (has '' key): + - iterate child entries in order: + - if value is '@inherit': resolve from parent + - if value is null: skip + - otherwise: pass through + e. if no parent value: strip null and @inherit entries, use rest as-is +4. otherwise: existing behavior (spread override, including false as tombstone) +``` + +### `resolveRecipes` changes + +No changes needed. The existing merge order `{ ...recipeValues, ...componentValues }` handles both cases naturally: + +- Properties deleted (via `null`) are absent from `componentValues`, so recipe values fill in. +- Properties set to `false` remain in `componentValues` and overwrite recipe values. + +--- + +## Performance + +| Path | Cost | When | +|---|---|---| +| No state map in any property | One `typeof` check per property (~3ns each) | Every `mergeStyles` call | +| State map detected | One `'' in childMap` check + one pass over entries | When state map is used | +| `@inherit` in child | One string comparison per child entry | Only when `@inherit` value is used | +| `null` check | One `=== null` per property | Every `mergeStyles` call | +| `false` tombstone | No extra cost (normal spread) | N/A | + +Hot path overhead for a 15-property style object: ~45ns (fifteen `typeof` checks + `=== null` comparisons). This is well within noise for a function that already does object spreads and key iteration. + +--- + +## Type Changes + +### `StyleValueStateMap` + +`'@inherit'` is scoped to `StyleValueStateMap` only — it is not part of `StyleValue`: + +```ts +export type StyleValueStateMap = { + [key: string]: StyleValue | '@inherit'; +}; +``` + +### `StyleValue` + +No changes — `'@inherit'` is not added here. It is only valid inside state maps. + +```ts +export type StyleValue = T | boolean | number | null | undefined; +``` + +### `Styles` index signature + +`null` is already allowed via `StyleValue`. `false` is already allowed via `boolean`. No changes needed to the top-level `Styles` type for the reset/tombstone behavior. + +### Special keys excluded from CSS generation + +`@inherit` values must be stripped before entering the style generation pipeline. They are consumed by `mergeStyles` during the merge phase and never reach the CSS generator. diff --git a/src/tasty/utils/merge-styles.test.ts b/src/tasty/utils/merge-styles.test.ts index c9d8fb0a7..6e7456175 100644 --- a/src/tasty/utils/merge-styles.test.ts +++ b/src/tasty/utils/merge-styles.test.ts @@ -289,9 +289,7 @@ describe('mergeStyles', () => { expect(keys).toEqual(['', 'hovered']); }); - it('should skip @inherit for non-existent parent key (dev warning)', () => { - const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}); - + it('should skip @inherit for non-existent parent key', () => { const result = mergeStyles(parentStyles, { fill: { '': '#red', @@ -302,8 +300,6 @@ describe('mergeStyles', () => { const keys = Object.keys(result.fill as object); expect(keys).toEqual(['']); expect((result.fill as any).nonexistent).toBeUndefined(); - - warnSpy.mockRestore(); }); it('should work when parent has no value for the property', () => { @@ -391,9 +387,7 @@ describe('mergeStyles', () => { ]); }); - it('should skip @inherit for non-existent parent key (dev warning)', () => { - const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}); - + it('should skip @inherit for non-existent parent key', () => { const result = mergeStyles(parentStyles, { fill: { nonexistent: '@inherit', @@ -403,8 +397,6 @@ describe('mergeStyles', () => { const keys = Object.keys(result.fill as object); expect(keys).toEqual(['', 'hovered', 'pressed', 'disabled']); expect((result.fill as any).nonexistent).toBeUndefined(); - - warnSpy.mockRestore(); }); }); diff --git a/src/tasty/utils/merge-styles.ts b/src/tasty/utils/merge-styles.ts index 4289906ca..af57a17c2 100644 --- a/src/tasty/utils/merge-styles.ts +++ b/src/tasty/utils/merge-styles.ts @@ -36,6 +36,7 @@ function normalizeToStateMap(value: unknown): Record | null { * In both modes: * - `@inherit` value → resolve from parent state map * - `null` value → remove this state from the result + * - `false` value → tombstone, persists through all layers, blocks recipe */ function resolveStateMap( parentValue: unknown, @@ -109,6 +110,7 @@ function resolveExtendMode( } else if ( !removeKeys.has(key) && !overrideKeys.has(key) && + // Skip @inherit for keys that weren't in the parent (already warned above) childMap[key] !== INHERIT_VALUE ) { result[key] = childMap[key]; From 85d43ab2c06bdf23f67a9c08a2cd7ac1c70eeb0f Mon Sep 17 00:00:00 2001 From: Andrey Yamanov Date: Thu, 19 Feb 2026 11:32:37 +0100 Subject: [PATCH 4/4] chore: update test config --- package.json | 2 +- tsconfig.json | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index aa71af71d..c4ddddd14 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,7 @@ "module": "./dist/index.js", "types": "./dist/index.d.ts", "sideEffects": false, - "packageManager": "pnpm@10.19.0", + "packageManager": "pnpm@10.30.0", "exports": { ".": { "import": "./dist/index.js", diff --git a/tsconfig.json b/tsconfig.json index 8adb750c3..56d212aa5 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -19,7 +19,8 @@ "preserveSymlinks": true, "noImplicitAny": false, "target": "es2022", - "noEmit": true + "noEmit": true, + "types": ["vitest/globals"] }, "include": ["src/**/*"] }