diff --git a/sass/color/_functions.scss b/sass/color/_functions.scss index 70b5f121..3156229f 100644 --- a/sass/color/_functions.scss +++ b/sass/color/_functions.scss @@ -104,11 +104,7 @@ $_enhanced-accessibility: false; $result, ( $variant: map.get($shade, 'hsl'), - '#{$variant}-contrast': - text-contrast( - $background: map.get($shade, 'raw'), - $contrast: 'AA', - ), + '#{$variant}-contrast': adaptive-contrast(#{var(--ig-#{$name}-#{$variant})}), '#{$variant}-raw': map.get($shade, 'raw'), ) ); @@ -189,18 +185,17 @@ $_enhanced-accessibility: false; $s: #{var(--ig-#{$color}-#{$variant})}; $contrast: if(meta.type-of($variant) == string, string.index($variant, 'contrast'), false); $_alpha: if($opacity, $opacity, 1); - $_hsl-alpha: hsl(from $s h s l / $_alpha); - $_mix-alpha: color-mix(in oklch, $s #{$_alpha * 100%}, transparent); + $_relative-color: if($opacity, hsl(from $s h s l / $opacity), $s); @if $palette { $s: map.get($palette, #{$color}); $base: map.get($s, #{$variant}); - $raw: if($contrast, map.get($s, #{$variant}-contrast), map.get($s, #{$variant}-raw)); + $raw: map.get($s, #{$variant}-raw); - @return if($raw and $variant != '500', rgba($raw, $_alpha), rgba($base, $_alpha)); + @return if($contrast, $_relative-color, if($raw and $variant != '500', rgba($raw, $_alpha), $base)); } - @return if($contrast, $_mix-alpha, $_hsl-alpha); + @return $_relative-color; } /// Retrieves a contrast text color for a given color variant from a color palette. @@ -221,6 +216,24 @@ $_enhanced-accessibility: false; @return color($palette, $color, #{$variant}-contrast, $opacity); } +/// Returns a CSS runtime calculated relative color(black or white) for a given color. +/// @access public +/// @group Color +/// @param {Color} $color - The base color used in the calculation. +/// @returns {string} - Returns a relative syntax OKLCH color where the lightness is adjusted +/// based on the specified contrast level, resulting in either black or white. +/// @example scss +/// .my-component { +/// --bg: #09f; +/// background: var(--bg); +/// color: adaptive-contrast(var(--bg)); +/// } +@function adaptive-contrast($color) { + $fn: meta.get-function('color', $css: true); + + @return hsl(from meta.call($fn, from $color var(--y-contrast)) h 0 l); +} + /// Returns a contrast color for a passed color. /// @access public /// @group Color diff --git a/sass/color/_mixins.scss b/sass/color/_mixins.scss index e03c44ae..ba5bce7e 100644 --- a/sass/color/_mixins.scss +++ b/sass/color/_mixins.scss @@ -29,6 +29,42 @@ $_added: () !default; } } +/// Sets up CSS custom properties for WCAG contrast calculations. +/// These properties are used to determine the appropriate text color contrast +/// based on WCAG accessibility guidelines. +/// @access public +/// @group Color +/// @param {String} $level ['aa'] - WCAG contrast level ('a', 'aa', or 'aaa') +/// @example scss - Using the mixin with default AA level +/// .my-component { +/// @include adaptive-contrast(); +/// } +/// @example scss - Using the mixin with AAA level +/// .my-component { +/// @include adaptive-contrast('aaa'); +/// } +/// @example scss - Generated CSS custom properties +/// :root { +/// --ig-wcag-a: 0.31; // Level A threshold +/// --ig-wcag-aa: 0.185; // Level AA threshold +/// --ig-wcag-aaa: 0.178; // Level AAA threshold +/// --ig-contrast-level: var(--ig-wcag-aa); +/// --y: clamp(0, (y / var(--ig-contrast-level) - 1) * -infinity, 1); +/// --y-contrast: xyz-d65 var(--y) var(--y) var(--y); +/// } +@mixin adaptive-contrast($level: 'aa') { + $scope: if(is-root(), ':root', '&'); + + #{$scope} { + --ig-wcag-a: 0.31; + --ig-wcag-aa: 0.185; + --ig-wcag-aaa: 0.178; + --ig-contrast-level: var(--ig-wcag-#{$level}); + --y: clamp(0, (y / var(--ig-contrast-level) - 1) * -infinity, 1); + --y-contrast: xyz-d65 var(--y) var(--y) var(--y); + } +} + /// Generates CSS variables for a given palette. /// @access public /// @group Palettes @@ -38,9 +74,13 @@ $_added: () !default; /// $palette: palette($primary: red, $secondary: blue, $gray: #000); /// @include palette($palette); /// @require {function} is-root -@mixin palette($palette, $contrast: true) { +@mixin palette($palette, $contrast: true, $contrast-level: 'aa') { $scope: if(is-root(), ':root', '&'); + @if $contrast { + @include adaptive-contrast($contrast-level); + } + #{$scope} { @each $color, $shades in map.remove($palette, '_meta') { @each $shade, $value in $shades { diff --git a/test/_color.spec.scss b/test/_color.spec.scss index 9eb8d4bc..5b71962c 100644 --- a/test/_color.spec.scss +++ b/test/_color.spec.scss @@ -25,18 +25,14 @@ $_palette: palette( $info: $_info, $warn: $_warn, $error: $_error, - $variant: 'material' + $variant: 'material', ); @include describe('Color') { @include describe('base') { - @include it('should calculate the contrast ratio between two colors') { - @include assert-equal(contrast($_primary, $_secondary), 1.19); - } - @include it('should mix two colors to produce an opaque color') { - @include assert-equal(to-opaque(rgba(255, 255, 255, .32), #fff), #fff); - @include assert-equal(to-opaque(rgba(233, 233, 233, .32), rgba(255, 255, 255, 0)), #f7f7f7); + @include assert-equal(to-opaque(rgba(255, 255, 255, 0.32), #fff), #fff); + @include assert-equal(to-opaque(rgba(233, 233, 233, 0.32), rgba(255, 255, 255, 0)), #f7f7f7); } @include it('converts a color to a list of HSL values') { @@ -57,6 +53,35 @@ $_palette: palette( } @include describe('contrast') { + $fn: meta.get-function('color', $css: true); + + @include it('should return an adaptive contrast color from a hex value') { + $color: #09f; + + @include assert-equal( + adaptive-contrast($color), + hsl(from meta.call($fn, from #09f var(--y-contrast)) h 0 l) + ); + } + + @include it('should return an adaptive contrast color from an hsl value') { + $color: hsl(204deg 100% 50%); + + @include assert-equal( + adaptive-contrast($color), + hsl(from meta.call($fn, from hsl(204deg 100% 50%) var(--y-contrast)) h 0 l) + ); + } + + @include it('should return an adaptive contrast color from a CSS variable value') { + $color: var(--ig-primary-500); + + @include assert-equal( + adaptive-contrast($color), + hsl(from meta.call($fn, from var(--ig-primary-500) var(--y-contrast)) h 0 l) + ); + } + @include it('should return the passed background value if no valid colors are provided') { $value: 'not a color'; @@ -125,36 +150,44 @@ $_palette: palette( $value: color(); @include assert-equal(type-of($value), string); - @include assert-equal($value, hsl(from (var(--ig-primary-500)) h s l / 1)); + @include assert-equal($value, var(--ig-primary-500)); } @include it('should return a shade as CSS variable w/ color as only argument') { - $value: color($color: secondary); + $value: color( + $color: secondary, + ); @include assert-equal(type-of($value), string); - @include assert-equal($value, hsl(from (var(--ig-secondary-500)) h s l / 1)); + @include assert-equal($value, var(--ig-secondary-500)); } @include it('should return a shade of type string as CSS var w/ color and variant as only arguments') { - $value: color($color: secondary, $variant: 'A400'); + $value: color( + $color: secondary, + $variant: 'A400', + ); @include assert-equal(type-of($value), string); - @include assert-equal($value, hsl(from (var(--ig-secondary-A400)) h s l / 1)); + @include assert-equal($value, var(--ig-secondary-A400)); } - @include it('should return a contrast shade of type color w/ palette as only argument') { - $value: contrast-color($_palette, $opacity: .5); - $expected: rgba(0 0 0 / .5); + @include it('should return a contrast shade w/ palette as only argument') { + $value: contrast-color($_palette, $opacity: 0.5); + $expected: hsl(from var(--ig-primary-500-contrast) h s l / 0.5); - @include assert-equal(type-of($value), color); @include assert-equal($expected, $value); } @include it('should return a contrast shade of type string as CSS var w/ color and variant as only arguments') { - $value: contrast-color($color: secondary, $variant: 'A400', $opacity: .25); + $value: contrast-color( + $color: secondary, + $variant: 'A400', + $opacity: 0.25, + ); @include assert-equal(type-of($value), string); - @include assert-equal($value, color-mix(in oklch, var(--ig-secondary-A400-contrast) 25%, transparent)); + @include assert-equal($value, hsl(from var(--ig-secondary-A400-contrast) h s l / 0.25)); } @include it('should retrieve colors from a palette regadless of type of key') { @@ -163,9 +196,15 @@ $_palette: palette( @include assert-true(color($_palette, 'primary', '500')); @include assert-equal(color($_palette, 'primary', '500'), $_primary); @include assert-true(contrast-color($_palette, primary, 500)); - @include assert-equal(contrast-color($_palette, primary, 500), black); + @include assert-equal( + contrast-color($_palette, primary, 500), + var(--ig-primary-500-contrast) + ); @include assert-true(contrast-color($_palette, 'primary', '500')); - @include assert-equal(contrast-color($_palette, 'primary', '500'), black); + @include assert-equal( + contrast-color($_palette, 'primary', '500'), + var(--ig-primary-500-contrast) + ); } @include it('should generate an HSL color shade from a given base color') { @@ -174,7 +213,7 @@ $_palette: palette( $shade: shade($color, $_primary, $variant, null); $expected: ( raw: hsl(204deg 100% 44.5%), - hsl: #{hsl(from var(--ig-primary-500) h calc(s * 1.26) calc(l * 0.89))} + hsl: #{hsl(from var(--ig-primary-500) h calc(s * 1.26) calc(l * 0.89))}, ); @include assert-equal($shade, $expected); @@ -187,7 +226,7 @@ $_palette: palette( $shade: shade($color, null, $variant, $surface); $expected: ( raw: hsl(0deg 0% 98%), - hsl: #{hsl(from var(--ig-gray-500) h s 98%)} + hsl: #{hsl(from var(--ig-gray-500) h s 98%)}, ); // $surface is bright, return a darker shade of gray @@ -195,10 +234,12 @@ $_palette: palette( $surface: #444; $shade: shade($color, null, $variant, $surface); - $expected: #{var(--ig-#{$color}-h), var(--ig-#{$color}-s), 13%}; + $expected: #{var(--ig-#{$color}-h), + var(--ig-#{$color}-s), + 13%}; $expected: ( raw: hsl(0deg 0% 13%), - hsl: #{hsl(from var(--ig-gray-500) h s 13%)} + hsl: #{hsl(from var(--ig-gray-500) h s 13%)}, ); // $surface is dark, return a lighter shade of gray @@ -237,11 +278,11 @@ $_palette: palette( @include contains($selector: false) { :root { @each $color, $shades in map.remove($IPalette, '_meta') { - @each $shade in $shades { - $value: map.get($_palette, $color, $shade); + @each $shade in $shades { + $value: map.get($_palette, $color, $shade); - --ig-#{$color}-#{$shade}: #{$value}; - } + --ig-#{$color}-#{$shade}: #{$value}; + } } } } @@ -285,11 +326,11 @@ $_palette: palette( @include contains($selector: false) { :root { @each $color, $shades in map.remove($IPalette, '_meta') { - @each $shade in $shades { - $value: map.get($_palette, $color, $shade); + @each $shade in $shades { + $value: map.get($_palette, $color, $shade); - --ig-#{$color}-#{$shade}: #{$value}; - } + --ig-#{$color}-#{$shade}: #{$value}; + } } } } @@ -300,7 +341,7 @@ $_palette: palette( $_palette: mocks.$handmade-palette; $_ref: color(null, primary, 800); - @include assert-equal($_ref, hsl(from var(--ig-primary-800) h s l / 1)); + @include assert-equal($_ref, var(--ig-primary-800)); @include assert() { @include output() { @@ -316,5 +357,24 @@ $_palette: palette( @include it('should convert a color to a list of HSL values') { @include assert-equal(to-hsl(black), (0deg, 0%, 0%)); } + + @include it('should include all necessarry CSS custom properties for adaptive contrast to work') { + @include assert() { + @include output($selector: false) { + @include adaptive-contrast('aaa'); + } + + @include contains($selector: false) { + :root { + --ig-wcag-a: 0.31; + --ig-wcag-aa: 0.185; + --ig-wcag-aaa: 0.178; + --ig-contrast-level: var(--ig-wcag-aaa); + --y: clamp(0, (y / var(--ig-contrast-level) - 1) * -infinity, 1); + --y-contrast: xyz-d65 var(--y) var(--y) var(--y); + } + } + } + } } } diff --git a/test/_themes.spec.scss b/test/_themes.spec.scss index 16bfdd6b..672ac001 100644 --- a/test/_themes.spec.scss +++ b/test/_themes.spec.scss @@ -102,10 +102,10 @@ $schema: ( @include it('should output theme maps from schema definitions') { $theme: ( type: 'light', - background: hsl(from var(--ig-primary-400) h s l / 1), + background: var(--ig-primary-400), hover-background: hsl(from var(--ig-secondary-700) h s l / .26), - foreground: color-mix(in oklch, var(--ig-primary-400-contrast) 100%, transparent), - hover-foreground: color-mix(in oklch, var(--ig-secondary-700-contrast) 100%, transparent), + foreground: var(--ig-primary-400-contrast), + hover-foreground: var(--ig-secondary-700-contrast), border-style: solid, border-radius: .125rem, brushes: var(--chart-brushes),