From deb4f0ff365ef1585e6cdfb3f16b5f040c0252c0 Mon Sep 17 00:00:00 2001 From: Eliran Hezkia Date: Mon, 19 Oct 2026 23:26:45 +0300 Subject: [PATCH] feat(interact): state looks on other elements of the key, and states code toggles A state effect now takes `elementStyles`: declarations the state sets on other elements of its key, each found by a selector inside the key's element, as an effect's `selector` is. The state still toggles once per trigger, however many elements it styles. A state effect in `config.effects` that no interaction or sequence references declares the look of a state code toggles (`toggleEffect`), named by its `effectId` or its entry's id. `generate()` writes it, and the runtime renders it when its key's element connects. Co-Authored-By: Claude Opus 5.5 --- packages/interact/repro/state-looks.html | 76 +++++++++++++ packages/interact/src/core/Interact.ts | 7 +- packages/interact/src/core/add.ts | 33 +++++- packages/interact/src/core/css.ts | 56 +++++++--- packages/interact/src/core/utilities.ts | 41 ++++++- packages/interact/src/types/config.ts | 9 +- packages/interact/src/types/effects.ts | 10 ++ packages/interact/src/types/external.ts | 1 + packages/interact/src/types/internal.ts | 3 + packages/interact/src/utils.ts | 40 +++---- packages/interact/test/stateLooks.spec.ts | 127 ++++++++++++++++++++++ 11 files changed, 362 insertions(+), 41 deletions(-) create mode 100644 packages/interact/repro/state-looks.html create mode 100644 packages/interact/test/stateLooks.spec.ts diff --git a/packages/interact/repro/state-looks.html b/packages/interact/repro/state-looks.html new file mode 100644 index 00000000..0520bd4d --- /dev/null +++ b/packages/interact/repro/state-looks.html @@ -0,0 +1,76 @@ + + + + + + State looks + + + + + +

Title

+
+ + + + diff --git a/packages/interact/src/core/Interact.ts b/packages/interact/src/core/Interact.ts index 2b393ef3..25b536e2 100644 --- a/packages/interact/src/core/Interact.ts +++ b/packages/interact/src/core/Interact.ts @@ -12,7 +12,7 @@ import { IInteractElement, InteractPlugin, } from '../types'; -import { getElementHash, getInterpolatedKey } from './utilities'; +import { getCodeStates, getElementHash, getInterpolatedKey } from './utilities'; import { getKeyframeSlots, getSlotKey } from './css'; import TRIGGER_TO_HANDLER_MODULE_MAP from '../handlers'; import { @@ -391,6 +391,7 @@ function _ensureInteractionEntry( sequences: {}, interactionIds: new Set(), selectors: new Set(), + codeStates: [], }; } @@ -573,6 +574,10 @@ function parseConfig(config: InteractConfig, useCustomElement: boolean = false): }); }); + getCodeStates(config).forEach((effect) => { + _ensureInteractionEntry(interactions, effect.key).codeStates.push(effect); + }); + return { effects: effectMap, sequences: sequenceMap, diff --git a/packages/interact/src/core/add.ts b/packages/interact/src/core/add.ts index 190bcb0e..58bb9089 100644 --- a/packages/interact/src/core/add.ts +++ b/packages/interact/src/core/add.ts @@ -843,6 +843,7 @@ function addInteraction( effectId: (effect as Effect).effectId!, transition: (effect as StateEffect).transition, transitionProperties: (effect as StateEffect).transitionProperties, + elementStyles: (effect as StateEffect).elementStyles, childSelector: getSelector(effect, { asCombinator: true, addItemFilter: true, @@ -928,7 +929,37 @@ export function add(controller: IInteractionController): boolean { hasEffects = addEffectsForTarget(key, controller, instance); } - return hasTriggers || hasEffects; + const { codeStates = [] } = instance.get(key) || {}; + codeStates.forEach((effect, index) => { + const { conditions = [] } = effect; + const mql = getMediaQuery(conditions, instance.dataCache.conditions); + + if (mql) { + instance.setupMediaQueryListener(`${key}::codeState::${index}`, mql, key, () => { + controller.update(); + }); + } + + if (!mql || mql.matches) { + controller.renderStyle( + createTransitionCSS({ + key, + effectId: effect.effectId!, + transition: (effect as StateEffect).transition, + transitionProperties: (effect as StateEffect).transitionProperties, + elementStyles: (effect as StateEffect).elementStyles, + childSelector: getSelector(effect, { + asCombinator: true, + addItemFilter: true, + useFirstChild: controller.useFirstChild, + }), + selectorCondition: getSelectorCondition(conditions, instance.dataCache.conditions), + }), + ); + } + }); + + return hasTriggers || hasEffects || codeStates.length > 0; } export function addListItems( diff --git a/packages/interact/src/core/css.ts b/packages/interact/src/core/css.ts index 524fe113..383eedcc 100644 --- a/packages/interact/src/core/css.ts +++ b/packages/interact/src/core/css.ts @@ -19,7 +19,7 @@ import { } from '../utils'; import { getSelector } from './Interact'; import { resolveEffectForCSS, resolveSequenceForCSS } from './resolvers'; -import { getElementHash } from './utilities'; +import { getCodeStates, getElementHash } from './utilities'; import { LIST_ANIMATION_PROPERTY_NAMES, LIST_PROPERTY_NAMES, @@ -385,15 +385,25 @@ function effectToCSS( wrote.transition = true; } - // adding state rule - rules.push({ - key, - media, - selectorCondition, - childSelector, - states: [effectId], - declarations: properties, - }); + // adding state rules: the target's, then the other elements' of the key + rules.push( + { + key, + media, + selectorCondition, + childSelector, + states: [effectId], + declarations: properties, + }, + ...(effect.elementStyles || []).map(({ selector, styleProperties }) => ({ + key, + media, + selectorCondition, + childSelector: selector, + states: [effectId], + declarations: getStateStyleProperties({ transition: { styleProperties } }), + })), + ); } else { // setting off animation custom properties declarations.push( @@ -619,13 +629,33 @@ function parseConfig( plugins, }; - const cssRules = config.interactions.flatMap((interaction, interactionIdx) => - parseInteraction(ctx, interaction, interactionIdx), - ); + const cssRules = [ + ...config.interactions.flatMap((interaction, interactionIdx) => + parseInteraction(ctx, interaction, interactionIdx), + ), + ...parseCodeStates(ctx), + ]; return { ctx, cssRules }; } +// the looks of the states code toggles, each as a state effect of an interaction of its own +function parseCodeStates(ctx: GenerateContext): CSSRuleData[] { + const visited = new Set(); + const trigger = { id: 'code-states', componentId: '' } as TriggerVariant; + const rules = getCodeStates(ctx.config).flatMap((effect) => { + const resolved = resolveEffectForCSS(effect, { key: effect.key } as Interaction, { + ...ctx.config, + effects: {}, + }); + return resolved ? parseEffect(ctx, resolved, trigger, visited) : []; + }); + + visited.forEach((targetHash) => endInteraction(ctx.targetsMap.get(targetHash)!)); + + return rules; +} + export function getSlotKey(interactionIndex: number, effectIndex: number, sequenceIndex?: number) { return [interactionIndex, sequenceIndex, effectIndex].filter((i) => i !== undefined).join(':'); } diff --git a/packages/interact/src/core/utilities.ts b/packages/interact/src/core/utilities.ts index f41a445a..f0042fe4 100644 --- a/packages/interact/src/core/utilities.ts +++ b/packages/interact/src/core/utilities.ts @@ -1,4 +1,12 @@ -import type { Interaction, ElementIdentifier, TimeAnimationTriggerType } from '../types'; +import type { + Effect, + EffectRef, + ElementIdentifier, + InteractConfig, + Interaction, + StateEffect, + TimeAnimationTriggerType, +} from '../types'; export function _processKeysForInterpolation(key: string) { return [...key.matchAll(/\[([-\w]+)]/g)].map(([_, _instanceKey]) => _instanceKey); @@ -31,3 +39,34 @@ export function getElementHash(elementIdentifier: ElementIdentifier): string { const { key, listContainer, listItemSelector, selector } = elementIdentifier; return `${key}\0${listContainer || ''}\0${listItemSelector || ''}\0${selector || ''}`; } + +/** + * The state effects of `config.effects` that no interaction or sequence references: states that + * code toggles (`toggleEffect`), which still need their look. Each is named by its `effectId`, else + * its entry's id. + */ +export function getCodeStates( + config: InteractConfig, +): (Effect & { key: string; effectId: string })[] { + const { effects = {}, interactions = [], sequences = {} } = config; + const referenced = new Set(); + const addReferences = (list: (Effect | EffectRef)[] = []) => + list.forEach(({ effectId }) => effectId && referenced.add(effectId)); + + Object.values(sequences).forEach((sequence) => addReferences(sequence.effects)); + interactions.forEach((interaction) => { + addReferences(interaction.effects); + interaction.sequences?.forEach((sequence) => { + if ('effects' in sequence) { + addReferences(sequence.effects); + } + }); + }); + + return Object.entries(effects).flatMap(([id, effect]) => { + const { transition, transitionProperties } = effect as StateEffect; + return !referenced.has(id) && effect.key && (transition || transitionProperties) + ? [{ ...effect, key: effect.key, effectId: effect.effectId || id }] + : []; + }); +} diff --git a/packages/interact/src/types/config.ts b/packages/interact/src/types/config.ts index a9354530..54de452d 100644 --- a/packages/interact/src/types/config.ts +++ b/packages/interact/src/types/config.ts @@ -1,5 +1,11 @@ import type { TriggerType, TriggerParams } from './triggers'; -import type { Effect, EffectRef, EffectProperty, TimeAnimationTriggerType } from './effects'; +import type { + Effect, + EffectRef, + EffectProperty, + StateEffect, + TimeAnimationTriggerType, +} from './effects'; import type { PluginFields } from './plugins'; export type Condition = { @@ -64,6 +70,7 @@ export type ResolvedEffect = ElementIdentifier & conditions: string[]; triggerType: TimeAnimationTriggerType; initial: boolean; + elementStyles?: StateEffect['elementStyles']; suffix?: string; // the effect's position in the config (see getSlotKey) slotKey?: string; diff --git a/packages/interact/src/types/effects.ts b/packages/interact/src/types/effects.ts index 4cf8a35e..bb581b35 100644 --- a/packages/interact/src/types/effects.ts +++ b/packages/interact/src/types/effects.ts @@ -68,10 +68,20 @@ export type StyleProperty = { export type TransitionProperty = StyleProperty & TransitionOptions; +/** + * Declarations a state sets on another element of its key, which `selector` finds the way an + * effect's `selector` does: inside the key's element. + */ +export type ElementStyle = { + selector: string; + styleProperties: StyleProperty[]; +}; + export type StateEffect = { key?: string; effectId?: string; stateAction?: StateAction; + elementStyles?: ElementStyle[]; } & { transition?: TransitionOptions & { styleProperties: StyleProperty[]; diff --git a/packages/interact/src/types/external.ts b/packages/interact/src/types/external.ts index bdb884ec..d3d8e6fe 100644 --- a/packages/interact/src/types/external.ts +++ b/packages/interact/src/types/external.ts @@ -19,6 +19,7 @@ export type { TransitionOptions, StyleProperty, TransitionProperty, + ElementStyle, StateEffect, EffectRef, Effect, diff --git a/packages/interact/src/types/internal.ts b/packages/interact/src/types/internal.ts index f6c5f263..383aa325 100644 --- a/packages/interact/src/types/internal.ts +++ b/packages/interact/src/types/internal.ts @@ -18,6 +18,8 @@ export type InteractCache = { sequences: Record; interactionIds: Set; selectors: Set; + // the key's states no interaction sets, for code to toggle + codeStates: Effect[]; }; }; }; @@ -27,6 +29,7 @@ export type CreateTransitionCSSParams = { effectId: string; transition?: StateEffect['transition']; transitionProperties?: TransitionProperty[]; + elementStyles?: StateEffect['elementStyles']; childSelector?: string; selectorCondition?: string; useFirstChild?: boolean; diff --git a/packages/interact/src/utils.ts b/packages/interact/src/utils.ts index 933c7585..25cdd541 100644 --- a/packages/interact/src/utils.ts +++ b/packages/interact/src/utils.ts @@ -101,6 +101,7 @@ export function createTransitionCSS({ effectId, transition, transitionProperties, + elementStyles = [], childSelector = '> :first-child', selectorCondition, }: CreateTransitionCSSParams): string[] { @@ -108,38 +109,29 @@ export function createTransitionCSS({ transition, transitionProperties, }); - - const styleProperties = getStateStyleProperties({ transition, transitionProperties }).map( - (property) => `${property.name}: ${property.value};`, - ); const escapedKey = key.replace(/"/g, "'"); - - // Build selectors, applying condition if present - const stateSelector = `:is(:state(${effectId}), :--${effectId}) ${childSelector}`; - const dataAttrSelector = `[data-interact-effect~="${effectId}"] ${childSelector}`; - - const finalStateSelector = selectorCondition - ? applySelectorCondition(stateSelector, selectorCondition) - : stateSelector; - const finalDataAttrSelector = selectorCondition - ? applySelectorCondition(dataAttrSelector, selectorCondition) - : dataAttrSelector; + const withCondition = (selector: string) => + selectorCondition ? applySelectorCondition(selector, selectorCondition) : selector; + + const stateRule = (selector: string, properties: StyleProperty[]) => `${withCondition( + `:is(:state(${effectId}), :--${effectId}) ${selector}`, + )}, + ${withCondition(`[data-interact-effect~="${effectId}"] ${selector}`)} { + ${properties.map((property) => `${property.name}: ${property.value};`).join(` + `)} + }`; const result = [ - `${finalStateSelector}, - ${finalDataAttrSelector} { - ${styleProperties.join(` - `)} - }`, + stateRule(childSelector, getStateStyleProperties({ transition, transitionProperties })), + ...elementStyles.map(({ selector, styleProperties }) => + stateRule(selector, getStateStyleProperties({ transition: { styleProperties } })), + ), ]; if (transitions.length) { const transitionSelector = `[data-interact-key="${escapedKey}"] ${childSelector}`; - const finalTransitionSelector = selectorCondition - ? applySelectorCondition(transitionSelector, selectorCondition) - : transitionSelector; - result.push(`@media (prefers-reduced-motion: no-preference) { ${finalTransitionSelector} { + result.push(`@media (prefers-reduced-motion: no-preference) { ${withCondition(transitionSelector)} { transition: ${transitions.join(', ')}; } }`); } diff --git a/packages/interact/test/stateLooks.spec.ts b/packages/interact/test/stateLooks.spec.ts new file mode 100644 index 00000000..70f20e2a --- /dev/null +++ b/packages/interact/test/stateLooks.spec.ts @@ -0,0 +1,127 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { Interact, add } from '../src/index'; +import { InteractionController } from '../src/core/InteractionController'; +import { _generate } from '../src/core/css'; +import { createTransitionCSS } from '../src/utils'; +import type { InteractConfig } from '../src/types'; + +const HOVERED = { name: 'transform', value: 'scale(1.05)' }; +const CHILDREN = { name: 'transition', value: 'all 0.3s ease 0s' }; + +const hoverConfig = (): InteractConfig => ({ + effects: {}, + interactions: [ + { + key: 'card', + trigger: 'hover', + effects: [ + { + key: 'card', + selector: '.card', + effectId: 'hovered', + transition: { styleProperties: [HOVERED] }, + elementStyles: [{ selector: '.card .title', styleProperties: [CHILDREN] }], + }, + ], + }, + ], +}); + +const stateRules = (config: InteractConfig, effectId: string) => + _generate(config).cssRules.filter(({ states }) => states?.includes(effectId)); + +describe('state looks on other elements of the key', () => { + it('generates a state rule for each element the state styles', () => { + expect( + stateRules(hoverConfig(), 'hovered').map(({ childSelector, declarations }) => ({ + childSelector, + declarations, + })), + ).toEqual([ + { childSelector: '.card', declarations: [HOVERED] }, + { childSelector: '.card .title', declarations: [CHILDREN] }, + ]); + }); + + it('renders them at runtime under the same state', () => { + const css = createTransitionCSS({ + key: 'card', + effectId: 'hovered', + transition: { styleProperties: [HOVERED] }, + elementStyles: [{ selector: '.card .title', styleProperties: [CHILDREN] }], + childSelector: '.card', + }).join('\n'); + + expect(css).toContain(':is(:state(hovered), :--hovered) .card .title'); + expect(css).toContain('[data-interact-effect~="hovered"] .card .title'); + expect(css).toMatch(/\.card \.title \{\s*transition: all 0\.3s ease 0s;/); + }); +}); + +describe('states code toggles', () => { + const codeStateConfig = (): InteractConfig => ({ + conditions: { mobile: { type: 'media', predicate: '(max-width: 750px)' } }, + effects: { + 'selected@mobile': { + key: 'card', + selector: '.card', + effectId: 'selected', + conditions: ['mobile'], + transition: { styleProperties: [HOVERED] }, + }, + hovered: { key: 'card', selector: '.card', transition: { styleProperties: [HOVERED] } }, + }, + interactions: [{ key: 'card', trigger: 'hover', effects: [{ effectId: 'hovered' }] }], + }); + + it('generates the look of a state no interaction references, by its effectId', () => { + const [rule] = stateRules(codeStateConfig(), 'selected'); + + expect(rule).toMatchObject({ + key: 'card', + childSelector: '.card', + media: '((max-width: 750px))', + }); + expect(rule.declarations).toEqual([HOVERED]); + }); + + it('leaves a referenced state effect to its interaction', () => { + expect(stateRules(codeStateConfig(), 'hovered')).toHaveLength(1); + }); + + describe('at runtime', () => { + let renderStyle: ReturnType; + + beforeEach(() => { + Object.defineProperty(window, 'matchMedia', { + writable: true, + configurable: true, + value: vi.fn().mockImplementation((media: string) => ({ + matches: true, + media, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + })), + }); + renderStyle = vi + .spyOn(InteractionController.prototype, 'renderStyle') + .mockImplementation(() => {}); + }); + + afterEach(() => { + renderStyle.mockRestore(); + Interact.destroy(); + }); + + it('renders the look when the key connects', () => { + Interact.create(codeStateConfig()); + const element = document.createElement('div'); + element.innerHTML = '
'; + + add(element, 'card'); + + const css = renderStyle.mock.calls.flatMap(([rules]) => rules as string[]).join('\n'); + expect(css).toContain(':is(:state(selected), :--selected) .card'); + }); + }); +});