From a078d60d0db173d1bdb0f7b46f93f6aa91c8b1a6 Mon Sep 17 00:00:00 2001 From: Michael Jordan Date: Tue, 17 Nov 2020 18:02:08 -0500 Subject: [PATCH 1/4] fix(#1281): HexColorField accessibility updates --- .../@react-aria/color/src/useHexColorField.ts | 6 ++ .../color/test/useHexColorField.test.js | 11 ++-- .../color/stories/HexColorField.stories.tsx | 49 +++++++++++---- .../color/test/HexColorField.test.js | 4 +- packages/@react-stately/color/src/Color.ts | 59 +++++++++++++++++++ .../@react-stately/color/test/Color.test.js | 57 ++++++++++++++++++ 6 files changed, 168 insertions(+), 18 deletions(-) diff --git a/packages/@react-aria/color/src/useHexColorField.ts b/packages/@react-aria/color/src/useHexColorField.ts index 89d3c65f0ff..40674db610c 100644 --- a/packages/@react-aria/color/src/useHexColorField.ts +++ b/packages/@react-aria/color/src/useHexColorField.ts @@ -88,6 +88,12 @@ export function useHexColorField( return { labelProps, inputFieldProps: mergeProps(inputProps, spinButtonProps, { + role: 'textbox', + 'aria-valuemax': null, + 'aria-valuemin': null, + 'aria-valuenow': null, + 'aria-valuetext': null, + value: inputProps.value || spinButtonProps['aria-valuetext'] || '', autoCorrect: 'off', onBlur: commitInputValue, onWheel diff --git a/packages/@react-aria/color/test/useHexColorField.test.js b/packages/@react-aria/color/test/useHexColorField.test.js index b0db940a3a9..95851b02f77 100644 --- a/packages/@react-aria/color/test/useHexColorField.test.js +++ b/packages/@react-aria/color/test/useHexColorField.test.js @@ -36,11 +36,11 @@ describe('useHexColorField', function () { expect(inputFieldProps.autoComplete).toBe('off'); expect(inputFieldProps.autoCorrect).toBe('off'); expect(inputFieldProps.id).toBeTruthy(); - expect(inputFieldProps.role).toBe('spinbutton'); + expect(inputFieldProps.role).toBe('textbox'); expect(inputFieldProps['aria-valuenow']).toBeNull(); expect(inputFieldProps['aria-valuetext']).toBeNull(); - expect(inputFieldProps['aria-valuemin']).toBe(0x000000); - expect(inputFieldProps['aria-valuemax']).toBe(0xFFFFFF); + expect(inputFieldProps['aria-valuemin']).toBeNull(); + expect(inputFieldProps['aria-valuemax']).toBeNull(); expect(inputFieldProps['aria-required']).toBeNull(); expect(inputFieldProps['aria-disabled']).toBeNull(); expect(inputFieldProps['aria-readonly']).toBeNull(); @@ -52,8 +52,9 @@ describe('useHexColorField', function () { it('should return props for colorValue provided', function () { let colorValue = new Color('#ff88a0'); let {inputFieldProps} = renderHexColorFieldHook({}, {colorValue}); - expect(inputFieldProps['aria-valuenow']).toBe(colorValue.toHexInt()); - expect(inputFieldProps['aria-valuetext']).toBe('#FF88A0'); + expect(inputFieldProps['aria-valuenow']).toBeNull(); + expect(inputFieldProps['aria-valuetext']).toBeNull(); + expect(inputFieldProps['value']).toBe('#FF88A0'); }); it('should return props for label', function () { diff --git a/packages/@react-spectrum/color/stories/HexColorField.stories.tsx b/packages/@react-spectrum/color/stories/HexColorField.stories.tsx index 35120e85544..b31f52431cf 100644 --- a/packages/@react-spectrum/color/stories/HexColorField.stories.tsx +++ b/packages/@react-spectrum/color/stories/HexColorField.stories.tsx @@ -13,12 +13,14 @@ import {action} from '@storybook/addon-actions'; import {ActionButton} from '@react-spectrum/button'; import {Color} from '@react-stately/color'; +import {Content, View} from '@react-spectrum/view'; import {Dialog, DialogTrigger} from '@react-spectrum/dialog'; import {Flex} from '@react-spectrum/layout'; import {HexColorField} from '../'; import React, {useState} from 'react'; import {storiesOf} from '@storybook/react'; -import {View} from '@react-spectrum/view'; +import {useId} from '@react-aria/utils'; +import {VisuallyHidden} from '@react-aria/visually-hidden'; storiesOf('HexColorField', module) .add( @@ -71,7 +73,7 @@ storiesOf('HexColorField', module) ) ) @@ -81,29 +83,46 @@ storiesOf('HexColorField', module) ); function HexColorFieldPopover(props: any = {}) { - let [color, setColor] = useState(props.value || null); + let [color, setColor] = useState(props.value ? new Color(props.value) : null); let colorString = color ? color.toString('hex') : ''; + + function getForegroundColorString(color: Color) { + if (!color) { + return 'currentColor'; + } + + const black = new Color('#000000'); + const white = new Color('#FFFFFF'); + + return color.contrast(white) > color.contrast(black) + ? white.toString('hex') + : black.toString('hex'); + } + return ( {colorString} - - + background: colorString, + color: getForegroundColorString(color) + }}> + {colorString} + + + {render({ ...props, + autoFocus: true, value: color, onChange: (newColor: Color) => { setColor(newColor); if (props.onChange) { props.onChange(newColor); } } })} - + ); @@ -116,13 +135,21 @@ function ControlledHexColorField(props: any = {}) { if (props.onChange) { props.onChange(color); } }; let style = color ? {backgroundColor: color.toString('rgb')} : {}; + let id = useId(); return ( - + + + + {color ? color.toString('hex') : ''} + + + ); } diff --git a/packages/@react-spectrum/color/test/HexColorField.test.js b/packages/@react-spectrum/color/test/HexColorField.test.js index d16bf2177c0..c0d5a458b1b 100644 --- a/packages/@react-spectrum/color/test/HexColorField.test.js +++ b/packages/@react-spectrum/color/test/HexColorField.test.js @@ -40,7 +40,7 @@ describe('HexColorField', function () { let hexColorField = getByLabelText('Primary Color'); let label = getByText('Primary Color'); expect(hexColorField).toBeInTheDocument(); - expect(getByRole('spinbutton')).toBe(hexColorField); + expect(getByRole('textbox')).toBe(hexColorField); expect(hexColorField).toHaveAttribute('type', 'text'); expect(hexColorField).toHaveAttribute('autocomplete', 'off'); expect(hexColorField).toHaveAttribute('autocorrect', 'off'); @@ -64,7 +64,7 @@ describe('HexColorField', function () { it('should allow placeholder', function () { let {getByPlaceholderText, getByRole} = renderComponent({placeholder: 'Enter a color'}); - expect(getByRole('spinbutton')).toBe(getByPlaceholderText('Enter a color')); + expect(getByRole('textbox')).toBe(getByPlaceholderText('Enter a color')); }); it('should show valid validation state', function () { diff --git a/packages/@react-stately/color/src/Color.ts b/packages/@react-stately/color/src/Color.ts index 1cea91cae67..549a9139d3d 100644 --- a/packages/@react-stately/color/src/Color.ts +++ b/packages/@react-stately/color/src/Color.ts @@ -91,6 +91,65 @@ export class Color { throw new Error('Unsupported color channel: ' + channel); } + alphaBlend(color1: Color, color2: Color) { + let r1: number, g1: number, b1: number, a1: number; + let r2: number, g2: number, b2: number; + + r1 = color1.getChannelValue('red'); + g1 = color1.getChannelValue('green'); + b1 = color1.getChannelValue('blue'); + a1 = color1.getChannelValue('alpha'); + + r2 = color2.getChannelValue('red'); + g2 = color2.getChannelValue('green'); + b2 = color2.getChannelValue('blue'); + + let r3 = r2 + (r1 - r2) * a1; + let g3 = g2 + (g1 - g2) * a1; + let b3 = b2 + (b1 - b2) * a1; + + return new Color(`rgb(${r3}, ${g3}, ${b3})`); + } + + luminance() { + let a = [this.getChannelValue('red'), this.getChannelValue('green'), this.getChannelValue('blue')].map((v) => { + v /= 255; + return v <= 0.03928 + ? v / 12.92 + : Math.pow((v + 0.055) / 1.055, 2.4); + }); + return (a[0] * 0.2126) + (a[1] * 0.7152) + (a[2] * 0.0722); + } + + contrast(comparisonColor: Color): number { + let colorLum = this.alphaBlend(this, comparisonColor).luminance(); + let comparisonColorLum = comparisonColor.luminance(); + + if (colorLum > comparisonColorLum) { + return (colorLum + 0.05) / (comparisonColorLum + 0.05); + } + + return (comparisonColorLum + 0.05) / (colorLum + 0.05); + } + + wcagComplianceLevel(comparisonColor: Color): string { + let contrastRatio = this.contrast(comparisonColor); + if (contrastRatio > 7) { + return 'AAA'; + } + + return (contrastRatio > 4.5) ? 'AA' : ''; + } + + isDark() { + var yiq = (this.getChannelValue('red') * 299 + this.getChannelValue('green') * 587 + this.getChannelValue('blue') * 114) / 1000; + return yiq < 128; + } + + isLight() { + return !this.isDark(); + } + static getRange(channel: ColorChannel) { switch (channel) { case 'hue': diff --git a/packages/@react-stately/color/test/Color.test.js b/packages/@react-stately/color/test/Color.test.js index 50181b2833d..0b88bfb642d 100644 --- a/packages/@react-stately/color/test/Color.test.js +++ b/packages/@react-stately/color/test/Color.test.js @@ -162,4 +162,61 @@ describe('Color', function () { expect(newColor.getChannelValue('lightness')).toBe(color.getChannelValue('lightness')); expect(newColor.getChannelValue('alpha')).toBe(color.getChannelValue('alpha')); }); + + it('luminance', () => { + const color1 = new Color('#757575'); + const color2 = new Color('#767676'); + const black = new Color('#000000'); + const white = new Color('#FFFFFF'); + + expect(black.luminance()).toEqual(0); + expect(white.luminance()).toEqual(1); + + expect(color1.luminance() < color2.luminance()).toBe(true); + }); + + it('contrast', () => { + let color1 = new Color('#757575'); + let color2 = new Color('#767676'); + let black = new Color('#000000'); + let white = new Color('#FFFFFF'); + + expect(color1.contrast(white) > color1.contrast(black)).toBe(true); + expect(color2.contrast(white) < color2.contrast(black)).toBe(true); + + color1 = new Color('rgba(0, 0, 0, 0.53)'); + color2 = new Color('rgba(0, 0, 0, 0.54)'); + + expect(Math.round(color1.contrast(white) * 100) / 100).toBe(4.42); + expect(Math.round(color2.contrast(white) * 100) / 100).toBe(4.59); + }); + + it('wcagComplianceLevel', () => { + let color = new Color('rgba(0, 0, 0, 0.66)'); + let white = new Color('#FFFFFF'); + + expect(color.wcagComplianceLevel(white)).toBe('AAA'); + + color = new Color('rgba(0, 0, 0, 0.54)'); + + expect(color.wcagComplianceLevel(white)).toBe('AA'); + + color = new Color('rgba(0, 0, 0, 0.53)'); + + expect(color.wcagComplianceLevel(white)).toBe(''); + + color = new Color('#767676'); + + expect(color.wcagComplianceLevel(white)).toBe('AA'); + + color = new Color('#777'); + + expect(color.wcagComplianceLevel(white)).toBe(''); + + color = new Color('#595959'); + expect(color.wcagComplianceLevel(white)).toBe('AAA'); + + color = new Color('#5A5A5A'); + expect(color.wcagComplianceLevel(white)).toBe('AA'); + }); }); From 75841488f146624efca92df9923d4647cd2ecc1d Mon Sep 17 00:00:00 2001 From: Michael Jordan Date: Wed, 18 Nov 2020 09:09:08 -0500 Subject: [PATCH 2/4] fix(#1281): Color: remove WCAG compliance utility method --- packages/@react-stately/color/src/Color.ts | 9 ------ .../@react-stately/color/test/Color.test.js | 29 ------------------- 2 files changed, 38 deletions(-) diff --git a/packages/@react-stately/color/src/Color.ts b/packages/@react-stately/color/src/Color.ts index 549a9139d3d..abe6fb5a759 100644 --- a/packages/@react-stately/color/src/Color.ts +++ b/packages/@react-stately/color/src/Color.ts @@ -132,15 +132,6 @@ export class Color { return (comparisonColorLum + 0.05) / (colorLum + 0.05); } - wcagComplianceLevel(comparisonColor: Color): string { - let contrastRatio = this.contrast(comparisonColor); - if (contrastRatio > 7) { - return 'AAA'; - } - - return (contrastRatio > 4.5) ? 'AA' : ''; - } - isDark() { var yiq = (this.getChannelValue('red') * 299 + this.getChannelValue('green') * 587 + this.getChannelValue('blue') * 114) / 1000; return yiq < 128; diff --git a/packages/@react-stately/color/test/Color.test.js b/packages/@react-stately/color/test/Color.test.js index 0b88bfb642d..01a9b8e3966 100644 --- a/packages/@react-stately/color/test/Color.test.js +++ b/packages/@react-stately/color/test/Color.test.js @@ -190,33 +190,4 @@ describe('Color', function () { expect(Math.round(color1.contrast(white) * 100) / 100).toBe(4.42); expect(Math.round(color2.contrast(white) * 100) / 100).toBe(4.59); }); - - it('wcagComplianceLevel', () => { - let color = new Color('rgba(0, 0, 0, 0.66)'); - let white = new Color('#FFFFFF'); - - expect(color.wcagComplianceLevel(white)).toBe('AAA'); - - color = new Color('rgba(0, 0, 0, 0.54)'); - - expect(color.wcagComplianceLevel(white)).toBe('AA'); - - color = new Color('rgba(0, 0, 0, 0.53)'); - - expect(color.wcagComplianceLevel(white)).toBe(''); - - color = new Color('#767676'); - - expect(color.wcagComplianceLevel(white)).toBe('AA'); - - color = new Color('#777'); - - expect(color.wcagComplianceLevel(white)).toBe(''); - - color = new Color('#595959'); - expect(color.wcagComplianceLevel(white)).toBe('AAA'); - - color = new Color('#5A5A5A'); - expect(color.wcagComplianceLevel(white)).toBe('AA'); - }); }); From 7f40823aed673575abd65225e2dc611777c578a5 Mon Sep 17 00:00:00 2001 From: Michael Jordan Date: Wed, 9 Dec 2020 16:37:23 -0500 Subject: [PATCH 3/4] fix(#1285): remove unused Color APIs --- packages/@react-stately/color/src/Color.ts | 9 --------- 1 file changed, 9 deletions(-) diff --git a/packages/@react-stately/color/src/Color.ts b/packages/@react-stately/color/src/Color.ts index abe6fb5a759..50c199922ef 100644 --- a/packages/@react-stately/color/src/Color.ts +++ b/packages/@react-stately/color/src/Color.ts @@ -132,15 +132,6 @@ export class Color { return (comparisonColorLum + 0.05) / (colorLum + 0.05); } - isDark() { - var yiq = (this.getChannelValue('red') * 299 + this.getChannelValue('green') * 587 + this.getChannelValue('blue') * 114) / 1000; - return yiq < 128; - } - - isLight() { - return !this.isDark(); - } - static getRange(channel: ColorChannel) { switch (channel) { case 'hue': From 14d6503ef0129f027f8e8221a58d44158a7ef4bc Mon Sep 17 00:00:00 2001 From: Devon Govett Date: Fri, 22 Jan 2021 19:50:20 -0500 Subject: [PATCH 4/4] Remove popover example --- .../@react-aria/color/src/useHexColorField.ts | 3 +- .../color/test/useHexColorField.test.js | 2 +- .../color/stories/HexColorField.stories.tsx | 64 +------------------ 3 files changed, 3 insertions(+), 66 deletions(-) diff --git a/packages/@react-aria/color/src/useHexColorField.ts b/packages/@react-aria/color/src/useHexColorField.ts index 40674db610c..23bf3dbbce7 100644 --- a/packages/@react-aria/color/src/useHexColorField.ts +++ b/packages/@react-aria/color/src/useHexColorField.ts @@ -36,7 +36,7 @@ export function useHexColorField( isReadOnly, isRequired } = props; - + let { colorValue, inputValue, @@ -93,7 +93,6 @@ export function useHexColorField( 'aria-valuemin': null, 'aria-valuenow': null, 'aria-valuetext': null, - value: inputProps.value || spinButtonProps['aria-valuetext'] || '', autoCorrect: 'off', onBlur: commitInputValue, onWheel diff --git a/packages/@react-aria/color/test/useHexColorField.test.js b/packages/@react-aria/color/test/useHexColorField.test.js index 526ec30e174..ebf404efbdc 100644 --- a/packages/@react-aria/color/test/useHexColorField.test.js +++ b/packages/@react-aria/color/test/useHexColorField.test.js @@ -51,7 +51,7 @@ describe('useHexColorField', function () { it('should return props for colorValue provided', function () { let colorValue = parseColor('#ff88a0'); - let {inputFieldProps} = renderHexColorFieldHook({}, {colorValue}); + let {inputFieldProps} = renderHexColorFieldHook({}, {colorValue, inputValue: colorValue.toString('hex')}); expect(inputFieldProps['aria-valuenow']).toBeNull(); expect(inputFieldProps['aria-valuetext']).toBeNull(); expect(inputFieldProps['value']).toBe('#FF88A0'); diff --git a/packages/@react-spectrum/color/stories/HexColorField.stories.tsx b/packages/@react-spectrum/color/stories/HexColorField.stories.tsx index 8206e840a57..4853960f292 100644 --- a/packages/@react-spectrum/color/stories/HexColorField.stories.tsx +++ b/packages/@react-spectrum/color/stories/HexColorField.stories.tsx @@ -11,15 +11,13 @@ */ import {action} from '@storybook/addon-actions'; -import {ActionButton} from '@react-spectrum/button'; import {Color} from '@react-types/color'; -import {Content, View} from '@react-spectrum/view'; -import {Dialog, DialogTrigger} from '@react-spectrum/dialog'; import {Flex} from '@react-spectrum/layout'; import {HexColorField} from '../'; import React, {useState} from 'react'; import {storiesOf} from '@storybook/react'; import {useId} from '@react-aria/utils'; +import {View} from '@react-spectrum/view'; import {VisuallyHidden} from '@react-aria/visually-hidden'; storiesOf('HexColorField', module) @@ -66,68 +64,8 @@ storiesOf('HexColorField', module) value="#FF00AA" onChange={action('change')} /> ) - ) - .add( - 'as a popover', - () => ( - - ) - ) - .add( - 'as a popover, defaults only', - () => ); -function HexColorFieldPopover(props: any = {}) { - let [color, setColor] = useState(props.value ? new Color(props.value) : null); - let colorString = color ? color.toString('hex') : ''; - - function getForegroundColorString(color: Color) { - if (!color) { - return 'currentColor'; - } - - const black = new Color('#000000'); - const white = new Color('#FFFFFF'); - - return color.contrast(white) > color.contrast(black) - ? white.toString('hex') - : black.toString('hex'); - } - - return ( - - - {colorString} - - - - {render({ - ...props, - autoFocus: true, - value: color, - onChange: (newColor: Color) => { - setColor(newColor); - if (props.onChange) { props.onChange(newColor); } - } - })} - - - - ); -} - function ControlledHexColorField(props: any = {}) { let [color, setColor] = useState(props.value || null); let onChange = (color: Color) => {