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');
+ });
+ });
+});