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/src/stories/Tasty.docs.mdx b/src/stories/Tasty.docs.mdx index e946993f7..b13afef7a 100644 --- a/src/stories/Tasty.docs.mdx +++ b/src/stories/Tasty.docs.mdx @@ -50,6 +50,77 @@ const PrimaryButton = tasty(Button, { ``` +#### Extending vs. Replacing State Maps + +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' } + +// ✅ Extend — no '' key, parent states preserved +const MyButton = tasty(Button, { + styles: { + fill: { + 'loading': '#yellow', // append new state + 'disabled': '#gray.20', // override existing state in place + }, + }, +}); + +// Replace — has '' key, parent states dropped +const MyButton = tasty(Button, { + styles: { + fill: { + '': '#red', + 'hovered': '#blue', + }, + }, +}); +``` + +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: { + 'loading': '#yellow', + disabled: '@inherit', +} + +// Replace mode: cherry-pick disabled from parent +fill: { + '': '#red', + disabled: '@inherit', +} +``` + +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` + +```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/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/states/index.ts b/src/tasty/states/index.ts index 4b5dda222..56fd929ad 100644 --- a/src/tasty/states/index.ts +++ b/src/tasty/states/index.ts @@ -53,6 +53,9 @@ const BUILTIN_STATES = new Set([ '@keyframes', '@properties', '@supports', + // @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 +68,7 @@ const RESERVED_PREFIXES = [ '@keyframes', '@properties', '@supports', + '@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..6e7456175 --- /dev/null +++ b/src/tasty/utils/merge-styles.test.ts @@ -0,0 +1,603 @@ +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 mode — state map without default key', () => { + 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: { + '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: { + 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: { + 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: { + hovered: '#blue', + }, + } as Styles); + + expect(result.fill).toEqual({ + '': '#purple', + hovered: '#blue', + }); + }); + + it('should handle boolean parent value', () => { + const result = mergeStyles( + { fill: true } as Styles, + { + fill: { + 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: { + hovered: '#blue', + }, + } as Styles, + ); + + expect(result.fill).toEqual({ + hovered: '#blue', + }); + }); + + it('should handle number parent value', () => { + const result = mergeStyles( + { opacity: 1 } as Styles, + { + opacity: { + hovered: 0.8, + }, + } as Styles, + ); + + expect(result.opacity).toEqual({ + '': 1, + 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', () => { + 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(); + }); + + 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 (extend mode)', () => { + 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: { + '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: { + '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: { + 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', () => { + const result = mergeStyles(parentStyles, { + fill: { + 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(); + }); + }); + + describe('extend mode 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: { + loading: '#dark-gray', + }, + }, + } as Styles, + ); + + const iconStyles = result.Icon as any; + expect(iconStyles.color).toEqual({ + '': '#gray', + disabled: '#light-gray', + 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', () => { + 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: { + 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: { + pressed: '#green', + }, + } as Styles; + const level2: Styles = { + fill: { + 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: { + pressed: '#green', + disabled: '@inherit', + }, + } as Styles; + const level2: Styles = { + fill: { + 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 mode with no parent value', () => { + it('should strip @inherit when no parent exists', () => { + const result = mergeStyles({ padding: '2x' }, { + fill: { + hovered: '#blue', + disabled: '@inherit', + }, + } as Styles); + + 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: { + hovered: '#blue', + pressed: null, + }, + } as Styles); + + expect(result.fill).toEqual({ hovered: '#blue' }); + }); + + it('should handle extend with all entries removed via null', () => { + const parentStyles: Styles = { + fill: { + '': '#white', + hovered: '#blue', + }, + }; + + const result = mergeStyles(parentStyles, { + fill: { + '': null, + hovered: null, + }, + } as Styles); + + expect(result.fill).toEqual({}); + }); + }); + + describe('replace mode within sub-elements', () => { + it('should replace state maps inside sub-element blocks', () => { + const result = mergeStyles( + { + Icon: { + color: { + '': '#gray', + disabled: '#light-gray', + hovered: '#dark-gray', + }, + }, + } as Styles, + { + Icon: { + color: { + '': '#blue', + disabled: '@inherit', + }, + }, + } as Styles, + ); + + const iconStyles = result.Icon as any; + 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 442d2d7bd..af57a17c2 100644 --- a/src/tasty/utils/merge-styles.ts +++ b/src/tasty/utils/merge-styles.ts @@ -1,6 +1,183 @@ import { isSelector } from '../pipeline'; import { Styles, StylesWithoutSelectors } from '../styles/types'; +import { isDevEnv } from './is-dev-env'; + +const devMode = isDevEnv(); + +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); +} + +/** + * 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 normalizeToStateMap(value: unknown): Record | null { + if (isStateMap(value)) return value as Record; + if (value != null && value !== false) return { '': value }; + return null; +} + +/** + * Resolve a child state map against a parent value. + * + * 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 + * - `false` value → tombstone, persists through all layers, blocks recipe + */ +function resolveStateMap( + parentValue: unknown, + childMap: Record, +): Record { + 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(childMap)) { + const val = childMap[key]; + if (val === null || val === INHERIT_VALUE) continue; + result[key] = val; + } + return result; + } + + 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(childMap)) { + const val = childMap[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); + } + } + + // 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(childMap)) { + if (inheritKeys.has(key)) { + result[key] = parentMap[key]; + } 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]; + } + } + + return result; +} + +/** + * 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, + 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 (isStateMap(val)) { + merged[key] = resolveStateMap( + parent ? parent[key] : undefined, + 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 +191,44 @@ 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) { 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: state maps, null, undefined + for (const key of Object.keys(newStyles)) { + if (isSelector(key)) continue; + + const newValue = newStyles[key]; + + if (newValue === undefined) { + if (key in styles) { + resultStyles[key] = styles[key]; + } else { + delete resultStyles[key]; + } + } else if (newValue === null) { + delete resultStyles[key]; + } else if (isStateMap(newValue)) { + (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 70c024863..3821bb17b 100644 --- a/src/tasty/utils/styles.ts +++ b/src/tasty/utils/styles.ts @@ -35,7 +35,7 @@ export function normalizeColorTokenValue( } export type StyleValueStateMap = { - [key: string]: StyleValue; + [key: string]: StyleValue | '@inherit'; }; /** 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/**/*"] }