diff --git a/packages/spark_css/CHANGELOG.md b/packages/spark_css/CHANGELOG.md index cd3ed6f..5df5a0f 100644 --- a/packages/spark_css/CHANGELOG.md +++ b/packages/spark_css/CHANGELOG.md @@ -8,6 +8,8 @@ - **Feat**: Added `CssGridTemplateColumns` sealed class for `grid-template-columns` with `.none`, `.subgrid`, `.tracks()`, `.repeat()`, `.autoFill()`, `.autoFit()`, plus `.variable()`, `.raw()`, and `.global()`. - **Feat**: Added `CssTrackSize` sealed class for individual grid track sizes with `.fr()`, `.length()`, `.minmax()`, `.fitContent()`, and `.raw()`. - **Feat**: Added `accent-color` CSS property support via `CssColor? accentColor` parameter in `Style.typed()`. +- **Feat**: Added `CssAnimation` sealed class for the `animation` shorthand property with `.none`, single animation (name, duration, timingFunction, delay, iterationCount, direction, fillMode, playState), `.multiple()`, plus `.variable()`, `.raw()`, and `.global()` escape hatches. +- **Feat**: Added `CssAnimationDirection`, `CssAnimationFillMode`, `CssAnimationPlayState`, and `CssAnimationIterationCount` sealed classes for animation sub-properties. Reuses existing `CssDuration` and `CssTimingFunction` from `CssTransition`. ### Changed diff --git a/packages/spark_css/lib/src/css_types/css_animation.dart b/packages/spark_css/lib/src/css_types/css_animation.dart new file mode 100644 index 0000000..7c657ce --- /dev/null +++ b/packages/spark_css/lib/src/css_types/css_animation.dart @@ -0,0 +1,349 @@ +import 'css_transition.dart'; +import 'css_value.dart'; + +/// CSS animation-direction values. +sealed class CssAnimationDirection implements CssValue { + const CssAnimationDirection._(); + + static const CssAnimationDirection normal = _CssAnimationDirectionKeyword( + 'normal', + ); + static const CssAnimationDirection reverse = _CssAnimationDirectionKeyword( + 'reverse', + ); + static const CssAnimationDirection alternate = _CssAnimationDirectionKeyword( + 'alternate', + ); + static const CssAnimationDirection alternateReverse = + _CssAnimationDirectionKeyword('alternate-reverse'); + + /// CSS variable reference. + factory CssAnimationDirection.variable(String varName) = + _CssAnimationDirectionVariable; + + /// Raw CSS value escape hatch. + factory CssAnimationDirection.raw(String value) = _CssAnimationDirectionRaw; + + /// Global keyword (inherit, initial, unset, revert). + factory CssAnimationDirection.global(CssGlobal global) = + _CssAnimationDirectionGlobal; +} + +final class _CssAnimationDirectionKeyword extends CssAnimationDirection { + final String keyword; + const _CssAnimationDirectionKeyword(this.keyword) : super._(); + + @override + String toCss() => keyword; +} + +final class _CssAnimationDirectionVariable extends CssAnimationDirection { + final String varName; + const _CssAnimationDirectionVariable(this.varName) : super._(); + + @override + String toCss() => 'var(--$varName)'; +} + +final class _CssAnimationDirectionRaw extends CssAnimationDirection { + final String value; + const _CssAnimationDirectionRaw(this.value) : super._(); + + @override + String toCss() => value; +} + +final class _CssAnimationDirectionGlobal extends CssAnimationDirection { + final CssGlobal global; + const _CssAnimationDirectionGlobal(this.global) : super._(); + + @override + String toCss() => global.toCss(); +} + +/// CSS animation-fill-mode values. +sealed class CssAnimationFillMode implements CssValue { + const CssAnimationFillMode._(); + + static const CssAnimationFillMode none = _CssAnimationFillModeKeyword('none'); + static const CssAnimationFillMode forwards = _CssAnimationFillModeKeyword( + 'forwards', + ); + static const CssAnimationFillMode backwards = _CssAnimationFillModeKeyword( + 'backwards', + ); + static const CssAnimationFillMode both = _CssAnimationFillModeKeyword('both'); + + /// CSS variable reference. + factory CssAnimationFillMode.variable(String varName) = + _CssAnimationFillModeVariable; + + /// Raw CSS value escape hatch. + factory CssAnimationFillMode.raw(String value) = _CssAnimationFillModeRaw; + + /// Global keyword (inherit, initial, unset, revert). + factory CssAnimationFillMode.global(CssGlobal global) = + _CssAnimationFillModeGlobal; +} + +final class _CssAnimationFillModeKeyword extends CssAnimationFillMode { + final String keyword; + const _CssAnimationFillModeKeyword(this.keyword) : super._(); + + @override + String toCss() => keyword; +} + +final class _CssAnimationFillModeVariable extends CssAnimationFillMode { + final String varName; + const _CssAnimationFillModeVariable(this.varName) : super._(); + + @override + String toCss() => 'var(--$varName)'; +} + +final class _CssAnimationFillModeRaw extends CssAnimationFillMode { + final String value; + const _CssAnimationFillModeRaw(this.value) : super._(); + + @override + String toCss() => value; +} + +final class _CssAnimationFillModeGlobal extends CssAnimationFillMode { + final CssGlobal global; + const _CssAnimationFillModeGlobal(this.global) : super._(); + + @override + String toCss() => global.toCss(); +} + +/// CSS animation-play-state values. +sealed class CssAnimationPlayState implements CssValue { + const CssAnimationPlayState._(); + + static const CssAnimationPlayState running = _CssAnimationPlayStateKeyword( + 'running', + ); + static const CssAnimationPlayState paused = _CssAnimationPlayStateKeyword( + 'paused', + ); + + /// CSS variable reference. + factory CssAnimationPlayState.variable(String varName) = + _CssAnimationPlayStateVariable; + + /// Raw CSS value escape hatch. + factory CssAnimationPlayState.raw(String value) = _CssAnimationPlayStateRaw; + + /// Global keyword (inherit, initial, unset, revert). + factory CssAnimationPlayState.global(CssGlobal global) = + _CssAnimationPlayStateGlobal; +} + +final class _CssAnimationPlayStateKeyword extends CssAnimationPlayState { + final String keyword; + const _CssAnimationPlayStateKeyword(this.keyword) : super._(); + + @override + String toCss() => keyword; +} + +final class _CssAnimationPlayStateVariable extends CssAnimationPlayState { + final String varName; + const _CssAnimationPlayStateVariable(this.varName) : super._(); + + @override + String toCss() => 'var(--$varName)'; +} + +final class _CssAnimationPlayStateRaw extends CssAnimationPlayState { + final String value; + const _CssAnimationPlayStateRaw(this.value) : super._(); + + @override + String toCss() => value; +} + +final class _CssAnimationPlayStateGlobal extends CssAnimationPlayState { + final CssGlobal global; + const _CssAnimationPlayStateGlobal(this.global) : super._(); + + @override + String toCss() => global.toCss(); +} + +/// CSS animation-iteration-count values. +sealed class CssAnimationIterationCount implements CssValue { + const CssAnimationIterationCount._(); + + static const CssAnimationIterationCount infinite = + _CssAnimationIterationCountKeyword('infinite'); + + /// Numeric iteration count. + factory CssAnimationIterationCount.count(num value) = + _CssAnimationIterationCountNumber; + + /// CSS variable reference. + factory CssAnimationIterationCount.variable(String varName) = + _CssAnimationIterationCountVariable; + + /// Raw CSS value escape hatch. + factory CssAnimationIterationCount.raw(String value) = + _CssAnimationIterationCountRaw; + + /// Global keyword (inherit, initial, unset, revert). + factory CssAnimationIterationCount.global(CssGlobal global) = + _CssAnimationIterationCountGlobal; +} + +final class _CssAnimationIterationCountKeyword + extends CssAnimationIterationCount { + final String keyword; + const _CssAnimationIterationCountKeyword(this.keyword) : super._(); + + @override + String toCss() => keyword; +} + +final class _CssAnimationIterationCountNumber + extends CssAnimationIterationCount { + final num value; + const _CssAnimationIterationCountNumber(this.value) : super._(); + + @override + String toCss() => '$value'; +} + +final class _CssAnimationIterationCountVariable + extends CssAnimationIterationCount { + final String varName; + const _CssAnimationIterationCountVariable(this.varName) : super._(); + + @override + String toCss() => 'var(--$varName)'; +} + +final class _CssAnimationIterationCountRaw extends CssAnimationIterationCount { + final String value; + const _CssAnimationIterationCountRaw(this.value) : super._(); + + @override + String toCss() => value; +} + +final class _CssAnimationIterationCountGlobal + extends CssAnimationIterationCount { + final CssGlobal global; + const _CssAnimationIterationCountGlobal(this.global) : super._(); + + @override + String toCss() => global.toCss(); +} + +/// CSS animation shorthand value. +sealed class CssAnimation implements CssValue { + const CssAnimation._(); + + static const CssAnimation none = _CssAnimationKeyword('none'); + + /// Single animation. + factory CssAnimation({ + required String name, + CssDuration? duration, + CssTimingFunction? timingFunction, + CssDuration? delay, + CssAnimationIterationCount? iterationCount, + CssAnimationDirection? direction, + CssAnimationFillMode? fillMode, + CssAnimationPlayState? playState, + }) = _CssAnimationSingle; + + /// Multiple animations. + factory CssAnimation.multiple(List animations) = + _CssAnimationMultiple; + + /// CSS variable reference. + factory CssAnimation.variable(String varName) = _CssAnimationVariable; + + /// Raw CSS value escape hatch. + factory CssAnimation.raw(String value) = _CssAnimationRaw; + + /// Global keyword (inherit, initial, unset, revert). + factory CssAnimation.global(CssGlobal global) = _CssAnimationGlobal; +} + +final class _CssAnimationKeyword extends CssAnimation { + final String keyword; + const _CssAnimationKeyword(this.keyword) : super._(); + + @override + String toCss() => keyword; +} + +final class _CssAnimationSingle extends CssAnimation { + final String name; + final CssDuration? duration; + final CssTimingFunction? timingFunction; + final CssDuration? delay; + final CssAnimationIterationCount? iterationCount; + final CssAnimationDirection? direction; + final CssAnimationFillMode? fillMode; + final CssAnimationPlayState? playState; + + const _CssAnimationSingle({ + required this.name, + this.duration, + this.timingFunction, + this.delay, + this.iterationCount, + this.direction, + this.fillMode, + this.playState, + }) : super._(); + + @override + String toCss() { + final parts = [name]; + if (duration != null) parts.add(duration!.toCss()); + if (timingFunction != null) parts.add(timingFunction!.toCss()); + if (delay != null) parts.add(delay!.toCss()); + if (iterationCount != null) parts.add(iterationCount!.toCss()); + if (direction != null) parts.add(direction!.toCss()); + if (fillMode != null) parts.add(fillMode!.toCss()); + if (playState != null) parts.add(playState!.toCss()); + return parts.join(' '); + } +} + +final class _CssAnimationMultiple extends CssAnimation { + final List animations; + const _CssAnimationMultiple(this.animations) : super._(); + + @override + String toCss() => animations.map((a) => a.toCss()).join(', '); +} + +final class _CssAnimationVariable extends CssAnimation { + final String varName; + const _CssAnimationVariable(this.varName) : super._(); + + @override + String toCss() => 'var(--$varName)'; +} + +final class _CssAnimationRaw extends CssAnimation { + final String value; + const _CssAnimationRaw(this.value) : super._(); + + @override + String toCss() => value; +} + +final class _CssAnimationGlobal extends CssAnimation { + final CssGlobal global; + const _CssAnimationGlobal(this.global) : super._(); + + @override + String toCss() => global.toCss(); +} diff --git a/packages/spark_css/lib/src/css_types/css_types.dart b/packages/spark_css/lib/src/css_types/css_types.dart index 59b4313..bd5d281 100644 --- a/packages/spark_css/lib/src/css_types/css_types.dart +++ b/packages/spark_css/lib/src/css_types/css_types.dart @@ -1,6 +1,7 @@ /// CSS type system for type-safe style declarations. library; +export 'css_animation.dart'; export 'css_angle.dart'; export 'css_background.dart'; export 'css_background_attachment.dart'; diff --git a/packages/spark_css/lib/src/style.dart b/packages/spark_css/lib/src/style.dart index 613a021..d66784f 100644 --- a/packages/spark_css/lib/src/style.dart +++ b/packages/spark_css/lib/src/style.dart @@ -312,6 +312,7 @@ class Style implements CssStyle { CssBackgroundOrigin? backgroundOrigin, CssBackgroundAttachment? backgroundAttachment, // Effects + CssAnimation? animation, CssTransition? transition, CssTransform? transform, // Background shorthand @@ -456,6 +457,7 @@ class Style implements CssStyle { } // Effects + if (animation != null) _properties['animation'] = animation.toCss(); if (transition != null) _properties['transition'] = transition.toCss(); if (transform != null) _properties['transform'] = transform.toCss(); diff --git a/packages/spark_css/test/css_animation_test.dart b/packages/spark_css/test/css_animation_test.dart new file mode 100644 index 0000000..bc382c1 --- /dev/null +++ b/packages/spark_css/test/css_animation_test.dart @@ -0,0 +1,176 @@ +import 'package:spark_css/spark_css.dart'; +import 'package:test/test.dart'; + +void main() { + group('CssAnimationDirection', () { + test('keywords output correct CSS', () { + expect(CssAnimationDirection.normal.toCss(), equals('normal')); + expect(CssAnimationDirection.reverse.toCss(), equals('reverse')); + expect(CssAnimationDirection.alternate.toCss(), equals('alternate')); + expect( + CssAnimationDirection.alternateReverse.toCss(), + equals('alternate-reverse'), + ); + }); + test('variable outputs correct CSS', () { + expect( + CssAnimationDirection.variable('dir').toCss(), + equals('var(--dir)'), + ); + }); + test('raw outputs value as-is', () { + expect(CssAnimationDirection.raw('normal').toCss(), equals('normal')); + }); + test('global outputs correct CSS', () { + expect( + CssAnimationDirection.global(CssGlobal.inherit).toCss(), + equals('inherit'), + ); + }); + }); + + group('CssAnimationFillMode', () { + test('keywords output correct CSS', () { + expect(CssAnimationFillMode.none.toCss(), equals('none')); + expect(CssAnimationFillMode.forwards.toCss(), equals('forwards')); + expect(CssAnimationFillMode.backwards.toCss(), equals('backwards')); + expect(CssAnimationFillMode.both.toCss(), equals('both')); + }); + test('variable outputs correct CSS', () { + expect( + CssAnimationFillMode.variable('fill').toCss(), + equals('var(--fill)'), + ); + }); + test('raw outputs value as-is', () { + expect(CssAnimationFillMode.raw('both').toCss(), equals('both')); + }); + test('global outputs correct CSS', () { + expect( + CssAnimationFillMode.global(CssGlobal.initial).toCss(), + equals('initial'), + ); + }); + }); + + group('CssAnimationPlayState', () { + test('keywords output correct CSS', () { + expect(CssAnimationPlayState.running.toCss(), equals('running')); + expect(CssAnimationPlayState.paused.toCss(), equals('paused')); + }); + test('variable outputs correct CSS', () { + expect( + CssAnimationPlayState.variable('state').toCss(), + equals('var(--state)'), + ); + }); + test('raw outputs value as-is', () { + expect(CssAnimationPlayState.raw('paused').toCss(), equals('paused')); + }); + test('global outputs correct CSS', () { + expect( + CssAnimationPlayState.global(CssGlobal.unset).toCss(), + equals('unset'), + ); + }); + }); + + group('CssAnimationIterationCount', () { + test('infinite outputs correct CSS', () { + expect(CssAnimationIterationCount.infinite.toCss(), equals('infinite')); + }); + test('count outputs correct CSS', () { + expect(CssAnimationIterationCount.count(3).toCss(), equals('3')); + expect(CssAnimationIterationCount.count(1.5).toCss(), equals('1.5')); + }); + test('variable outputs correct CSS', () { + expect( + CssAnimationIterationCount.variable('count').toCss(), + equals('var(--count)'), + ); + }); + test('raw outputs value as-is', () { + expect( + CssAnimationIterationCount.raw('infinite').toCss(), + equals('infinite'), + ); + }); + test('global outputs correct CSS', () { + expect( + CssAnimationIterationCount.global(CssGlobal.revert).toCss(), + equals('revert'), + ); + }); + }); + + group('CssAnimation', () { + test('none outputs correct CSS', () { + expect(CssAnimation.none.toCss(), equals('none')); + }); + test('name-only outputs correct CSS', () { + expect(CssAnimation(name: 'fadeIn').toCss(), equals('fadeIn')); + }); + test('single with all properties outputs correct CSS', () { + expect( + CssAnimation( + name: 'slideIn', + duration: CssDuration.s(1), + timingFunction: CssTimingFunction.easeInOut, + delay: CssDuration.ms(500), + iterationCount: CssAnimationIterationCount.infinite, + direction: CssAnimationDirection.alternate, + fillMode: CssAnimationFillMode.forwards, + playState: CssAnimationPlayState.running, + ).toCss(), + equals( + 'slideIn 1s ease-in-out 500ms infinite alternate forwards running', + ), + ); + }); + test('single with partial properties outputs correct CSS', () { + expect( + CssAnimation( + name: 'fadeIn', + duration: CssDuration.s(0.3), + fillMode: CssAnimationFillMode.forwards, + ).toCss(), + equals('fadeIn 0.3s forwards'), + ); + }); + test('multiple outputs correct CSS', () { + expect( + CssAnimation.multiple([ + CssAnimation(name: 'fadeIn', duration: CssDuration.s(1)), + CssAnimation( + name: 'slideUp', + duration: CssDuration.ms(500), + fillMode: CssAnimationFillMode.both, + ), + ]).toCss(), + equals('fadeIn 1s, slideUp 500ms both'), + ); + }); + test('variable outputs correct CSS', () { + expect(CssAnimation.variable('anim').toCss(), equals('var(--anim)')); + }); + test('raw outputs value as-is', () { + expect( + CssAnimation.raw('fadeIn 1s ease-in-out').toCss(), + equals('fadeIn 1s ease-in-out'), + ); + }); + test('global outputs correct CSS', () { + expect(CssAnimation.global(CssGlobal.inherit).toCss(), equals('inherit')); + }); + test('Style.typed renders animation property', () { + final style = Style.typed( + animation: CssAnimation( + name: 'fadeIn', + duration: CssDuration.s(1), + fillMode: CssAnimationFillMode.forwards, + ), + ); + expect(style.toCss(), contains('animation: fadeIn 1s forwards;')); + }); + }); +}