diff --git a/packages/@adobe/spectrum-css-temp/components/colorarea/index.css b/packages/@adobe/spectrum-css-temp/components/colorarea/index.css new file mode 100644 index 00000000000..3b750639bfe --- /dev/null +++ b/packages/@adobe/spectrum-css-temp/components/colorarea/index.css @@ -0,0 +1,71 @@ +.spectrum-ColorArea { + position: relative; + display: inline-block; + inline-size: var(--spectrum-colorarea-default-width); + block-size: var(--spectrum-colorarea-default-height); + min-inline-size: var(--spectrum-colorarea-min-width); + min-block-size: var(--spectrum-colorarea-min-height); + + border-radius: var(--spectrum-colorarea-border-radius); + + cursor: default; + + user-select: none; + + &.is-focused, + &.focus-ring { + z-index: 2; + + .spectrum-ColorArea-handle { + /* Bigger handle when focused */ + width: calc(var(--spectrum-colorhandle-size) * 2); + height: calc(var(--spectrum-colorhandle-size) * 2); + + margin-left: calc(-1 * var(--spectrum-colorhandle-size)); + margin-top: calc(-1 * var(--spectrum-colorhandle-size)); + } + } + + &:focus-within { + z-index: 2; + } + + &.is-disabled { + pointer-events: none; + } + + /* the floating inset box shadow must be a separate element since won't take it */ + &:before { + content: ''; + z-index: 1; + position: absolute; + top: 0; + left: 0; + bottom: 0; + right: 0; + border-radius: var(--spectrum-colorarea-border-radius); + } +} + +.spectrum-ColorArea-handle { + left: 0; + top: 0; +} + +.spectrum-ColorArea-gradient { + width: 100%; + height: 100%; + border-radius: var(--spectrum-colorarea-border-radius); +} + +.spectrum-ColorArea-slider { + opacity: 0.0001; + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + z-index: 0; + margin: 0; + pointer-events: none; +} diff --git a/packages/@adobe/spectrum-css-temp/components/colorarea/skin.css b/packages/@adobe/spectrum-css-temp/components/colorarea/skin.css new file mode 100644 index 00000000000..e558a2e2542 --- /dev/null +++ b/packages/@adobe/spectrum-css-temp/components/colorarea/skin.css @@ -0,0 +1,36 @@ +.spectrum-ColorArea { + &:before { + box-shadow: inset 0 0 0 var(--spectrum-colorarea-border-size) var(--spectrum-colorarea-border-color); + } + } + .spectrum-ColorArea-gradient { + forced-color-adjust: none; + } + .spectrum-ColorHandle-color { + forced-color-adjust: none; + } + + .spectrum-ColorArea { + &.is-disabled { + background: var(--spectrum-colorarea-fill-color-disabled); + + &:before { + box-shadow: inset 0 0 0 var(--spectrum-colorarea-border-size) var(--spectrum-colorarea-border-color-disabled); + } + + .spectrum-ColorArea-gradient { + display: none; + } + } + } + + @media (forced-colors: active) { + .spectrum-ColorArea { + --spectrum-colorarea-fill-color-disabled : GrayText; + } + .spectrum-ColorArea { + &.is-disabled { + forced-color-adjust: none; + } + } + } \ No newline at end of file diff --git a/packages/@adobe/spectrum-css-temp/components/colorarea/vars.css b/packages/@adobe/spectrum-css-temp/components/colorarea/vars.css new file mode 100644 index 00000000000..f0681ac2aaf --- /dev/null +++ b/packages/@adobe/spectrum-css-temp/components/colorarea/vars.css @@ -0,0 +1,14 @@ +/* + * Copyright 2021 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +@import './index.css'; +@import './skin.css'; diff --git a/packages/@react-aria/color/intl/ar-AE.json b/packages/@react-aria/color/intl/ar-AE.json new file mode 100644 index 00000000000..e3adbfc6707 --- /dev/null +++ b/packages/@react-aria/color/intl/ar-AE.json @@ -0,0 +1,5 @@ +{ + "twoDimensionalSlider": "شريط تمرير ثنائي الأبعاد", + "colorNameAndValue": "{name}: {value}", + "x/y": "{x} / {y}" +} diff --git a/packages/@react-aria/color/intl/bg-BG.json b/packages/@react-aria/color/intl/bg-BG.json new file mode 100644 index 00000000000..8299f4af79a --- /dev/null +++ b/packages/@react-aria/color/intl/bg-BG.json @@ -0,0 +1,5 @@ +{ + "twoDimensionalSlider": "2D плъзгач", + "colorNameAndValue": "{name}: {value}", + "x/y": "{x} / {y}" +} diff --git a/packages/@react-aria/color/intl/cs-CZ.json b/packages/@react-aria/color/intl/cs-CZ.json new file mode 100644 index 00000000000..d676773682e --- /dev/null +++ b/packages/@react-aria/color/intl/cs-CZ.json @@ -0,0 +1,5 @@ +{ + "twoDimensionalSlider": "2D posuvník", + "colorNameAndValue": "{name}: {value}", + "x/y": "{x} / {y}" +} diff --git a/packages/@react-aria/color/intl/da-DK.json b/packages/@react-aria/color/intl/da-DK.json new file mode 100644 index 00000000000..02bf3d40a29 --- /dev/null +++ b/packages/@react-aria/color/intl/da-DK.json @@ -0,0 +1,5 @@ +{ + "twoDimensionalSlider": "2D-skyder", + "colorNameAndValue": "{name}: {value}", + "x/y": "{x} / {y}" +} diff --git a/packages/@react-aria/color/intl/de-DE.json b/packages/@react-aria/color/intl/de-DE.json new file mode 100644 index 00000000000..0face0024ec --- /dev/null +++ b/packages/@react-aria/color/intl/de-DE.json @@ -0,0 +1,5 @@ +{ + "twoDimensionalSlider": "2D-Schieberegler", + "colorNameAndValue": "{name}: {value}", + "x/y": "{x} / {y}" +} diff --git a/packages/@react-aria/color/intl/el-GR.json b/packages/@react-aria/color/intl/el-GR.json new file mode 100644 index 00000000000..754e53316a3 --- /dev/null +++ b/packages/@react-aria/color/intl/el-GR.json @@ -0,0 +1,5 @@ +{ + "twoDimensionalSlider": "Ρυθμιστικό 2D", + "colorNameAndValue": "{name}: {value}", + "x/y": "{x} / {y}" +} diff --git a/packages/@react-aria/color/intl/en-US.json b/packages/@react-aria/color/intl/en-US.json new file mode 100644 index 00000000000..c3d72686b82 --- /dev/null +++ b/packages/@react-aria/color/intl/en-US.json @@ -0,0 +1,5 @@ +{ + "twoDimensionalSlider": "2D slider", + "colorNameAndValue": "{name}: {value}", + "x/y": "{x} / {y}" +} diff --git a/packages/@react-aria/color/intl/es-ES.json b/packages/@react-aria/color/intl/es-ES.json new file mode 100644 index 00000000000..ee0bd0f7a58 --- /dev/null +++ b/packages/@react-aria/color/intl/es-ES.json @@ -0,0 +1,5 @@ +{ + "twoDimensionalSlider": "Control deslizante en 2D", + "colorNameAndValue": "{name}: {value}", + "x/y": "{x} / {y}" +} diff --git a/packages/@react-aria/color/intl/et-EE.json b/packages/@react-aria/color/intl/et-EE.json new file mode 100644 index 00000000000..2859d654c80 --- /dev/null +++ b/packages/@react-aria/color/intl/et-EE.json @@ -0,0 +1,5 @@ +{ + "twoDimensionalSlider": "2D-liugur", + "colorNameAndValue": "{name}: {value}", + "x/y": "{x} / {y}" +} diff --git a/packages/@react-aria/color/intl/fi-FI.json b/packages/@react-aria/color/intl/fi-FI.json new file mode 100644 index 00000000000..ca3b11689ce --- /dev/null +++ b/packages/@react-aria/color/intl/fi-FI.json @@ -0,0 +1,5 @@ +{ + "twoDimensionalSlider": "2D-liukusäädin", + "colorNameAndValue": "{name}: {value}", + "x/y": "{x} / {y}" +} diff --git a/packages/@react-aria/color/intl/fr-FR.json b/packages/@react-aria/color/intl/fr-FR.json new file mode 100644 index 00000000000..29fc2c866e1 --- /dev/null +++ b/packages/@react-aria/color/intl/fr-FR.json @@ -0,0 +1,5 @@ +{ + "twoDimensionalSlider": "Curseur 2D", + "colorNameAndValue": "{name}: {value}", + "x/y": "{x} / {y}" +} diff --git a/packages/@react-aria/color/intl/he-IL.json b/packages/@react-aria/color/intl/he-IL.json new file mode 100644 index 00000000000..d3affd743a4 --- /dev/null +++ b/packages/@react-aria/color/intl/he-IL.json @@ -0,0 +1,5 @@ +{ + "twoDimensionalSlider": "מחוון דו-ממדי", + "colorNameAndValue": "{name}: {value}", + "x/y": "{x} / {y}" +} diff --git a/packages/@react-aria/color/intl/hr-HR.json b/packages/@react-aria/color/intl/hr-HR.json new file mode 100644 index 00000000000..1eed1913084 --- /dev/null +++ b/packages/@react-aria/color/intl/hr-HR.json @@ -0,0 +1,5 @@ +{ + "twoDimensionalSlider": "2D kliznik", + "colorNameAndValue": "{name}: {value}", + "x/y": "{x} / {y}" +} diff --git a/packages/@react-aria/color/intl/hu-HU.json b/packages/@react-aria/color/intl/hu-HU.json new file mode 100644 index 00000000000..efed81ba033 --- /dev/null +++ b/packages/@react-aria/color/intl/hu-HU.json @@ -0,0 +1,5 @@ +{ + "twoDimensionalSlider": "2D csúszka", + "colorNameAndValue": "{name}: {value}", + "x/y": "{x} / {y}" +} diff --git a/packages/@react-aria/color/intl/it-IT.json b/packages/@react-aria/color/intl/it-IT.json new file mode 100644 index 00000000000..561daf2fb94 --- /dev/null +++ b/packages/@react-aria/color/intl/it-IT.json @@ -0,0 +1,5 @@ +{ + "twoDimensionalSlider": "Dispositivo di scorrimento 2D", + "colorNameAndValue": "{name}: {value}", + "x/y": "{x} / {y}" +} diff --git a/packages/@react-aria/color/intl/ja-JP.json b/packages/@react-aria/color/intl/ja-JP.json new file mode 100644 index 00000000000..76f16b8bccb --- /dev/null +++ b/packages/@react-aria/color/intl/ja-JP.json @@ -0,0 +1,5 @@ +{ + "twoDimensionalSlider": "2D スライダー", + "colorNameAndValue": "{name}: {value}", + "x/y": "{x} / {y}" +} diff --git a/packages/@react-aria/color/intl/ko-KR.json b/packages/@react-aria/color/intl/ko-KR.json new file mode 100644 index 00000000000..58e94a866c3 --- /dev/null +++ b/packages/@react-aria/color/intl/ko-KR.json @@ -0,0 +1,5 @@ +{ + "twoDimensionalSlider": "2D 슬라이더", + "colorNameAndValue": "{name}: {value}", + "x/y": "{x} / {y}" +} diff --git a/packages/@react-aria/color/intl/lt-LT.json b/packages/@react-aria/color/intl/lt-LT.json new file mode 100644 index 00000000000..d13d94a0663 --- /dev/null +++ b/packages/@react-aria/color/intl/lt-LT.json @@ -0,0 +1,5 @@ +{ + "twoDimensionalSlider": "2D slankiklis", + "colorNameAndValue": "{name}: {value}", + "x/y": "{x} / {y}" +} diff --git a/packages/@react-aria/color/intl/lv-LV.json b/packages/@react-aria/color/intl/lv-LV.json new file mode 100644 index 00000000000..402af4930f8 --- /dev/null +++ b/packages/@react-aria/color/intl/lv-LV.json @@ -0,0 +1,5 @@ +{ + "twoDimensionalSlider": "Plaknes slīdnis", + "colorNameAndValue": "{name}: {value}", + "x/y": "{x} / {y}" +} diff --git a/packages/@react-aria/color/intl/nb-NO.json b/packages/@react-aria/color/intl/nb-NO.json new file mode 100644 index 00000000000..b3a77570622 --- /dev/null +++ b/packages/@react-aria/color/intl/nb-NO.json @@ -0,0 +1,5 @@ +{ + "twoDimensionalSlider": "2D-glidebryter", + "colorNameAndValue": "{name}: {value}", + "x/y": "{x} / {y}" +} diff --git a/packages/@react-aria/color/intl/nl-NL.json b/packages/@react-aria/color/intl/nl-NL.json new file mode 100644 index 00000000000..e2baafafd32 --- /dev/null +++ b/packages/@react-aria/color/intl/nl-NL.json @@ -0,0 +1,5 @@ +{ + "twoDimensionalSlider": "2D-schuifregelaar", + "colorNameAndValue": "{name}: {value}", + "x/y": "{x} / {y}" +} diff --git a/packages/@react-aria/color/intl/pl-PL.json b/packages/@react-aria/color/intl/pl-PL.json new file mode 100644 index 00000000000..4053c546cf3 --- /dev/null +++ b/packages/@react-aria/color/intl/pl-PL.json @@ -0,0 +1,5 @@ +{ + "twoDimensionalSlider": "Suwak 2D", + "colorNameAndValue": "{name}: {value}", + "x/y": "{x} / {y}" +} diff --git a/packages/@react-aria/color/intl/pt-BR.json b/packages/@react-aria/color/intl/pt-BR.json new file mode 100644 index 00000000000..550bbb505bc --- /dev/null +++ b/packages/@react-aria/color/intl/pt-BR.json @@ -0,0 +1,5 @@ +{ + "twoDimensionalSlider": "Controle deslizante 2D", + "colorNameAndValue": "{name}: {value}", + "x/y": "{x} / {y}" +} diff --git a/packages/@react-aria/color/intl/pt-PT.json b/packages/@react-aria/color/intl/pt-PT.json new file mode 100644 index 00000000000..33d9da707d2 --- /dev/null +++ b/packages/@react-aria/color/intl/pt-PT.json @@ -0,0 +1,5 @@ +{ + "twoDimensionalSlider": "Controlo de deslize 2D", + "colorNameAndValue": "{name}: {value}", + "x/y": "{x} / {y}" +} diff --git a/packages/@react-aria/color/intl/ro-RO.json b/packages/@react-aria/color/intl/ro-RO.json new file mode 100644 index 00000000000..90eba883c3c --- /dev/null +++ b/packages/@react-aria/color/intl/ro-RO.json @@ -0,0 +1,5 @@ +{ + "twoDimensionalSlider": "Cursor 2D", + "colorNameAndValue": "{name}: {value}", + "x/y": "{x} / {y}" +} diff --git a/packages/@react-aria/color/intl/ru-RU.json b/packages/@react-aria/color/intl/ru-RU.json new file mode 100644 index 00000000000..52bd24d769e --- /dev/null +++ b/packages/@react-aria/color/intl/ru-RU.json @@ -0,0 +1,5 @@ +{ + "twoDimensionalSlider": "Двумерный ползунок", + "colorNameAndValue": "{name}: {value}", + "x/y": "{x} / {y}" +} diff --git a/packages/@react-aria/color/intl/sk-SK.json b/packages/@react-aria/color/intl/sk-SK.json new file mode 100644 index 00000000000..8aa9d695694 --- /dev/null +++ b/packages/@react-aria/color/intl/sk-SK.json @@ -0,0 +1,5 @@ +{ + "twoDimensionalSlider": "2D jazdec", + "colorNameAndValue": "{name}: {value}", + "x/y": "{x} / {y}" +} diff --git a/packages/@react-aria/color/intl/sl-SI.json b/packages/@react-aria/color/intl/sl-SI.json new file mode 100644 index 00000000000..02845036219 --- /dev/null +++ b/packages/@react-aria/color/intl/sl-SI.json @@ -0,0 +1,5 @@ +{ + "twoDimensionalSlider": "2D-drsnik", + "colorNameAndValue": "{name}: {value}", + "x/y": "{x} / {y}" +} diff --git a/packages/@react-aria/color/intl/sr-SP.json b/packages/@react-aria/color/intl/sr-SP.json new file mode 100644 index 00000000000..a4b9bc4b6cb --- /dev/null +++ b/packages/@react-aria/color/intl/sr-SP.json @@ -0,0 +1,5 @@ +{ + "twoDimensionalSlider": "2D клизач", + "colorNameAndValue": "{name}: {value}", + "x/y": "{x} / {y}" +} diff --git a/packages/@react-aria/color/intl/sv-SE.json b/packages/@react-aria/color/intl/sv-SE.json new file mode 100644 index 00000000000..b0732a46bcf --- /dev/null +++ b/packages/@react-aria/color/intl/sv-SE.json @@ -0,0 +1,5 @@ +{ + "twoDimensionalSlider": "2D-reglage", + "colorNameAndValue": "{name}: {value}", + "x/y": "{x} / {y}" +} diff --git a/packages/@react-aria/color/intl/tr-TR.json b/packages/@react-aria/color/intl/tr-TR.json new file mode 100644 index 00000000000..0a69975643a --- /dev/null +++ b/packages/@react-aria/color/intl/tr-TR.json @@ -0,0 +1,5 @@ +{ + "twoDimensionalSlider": "2B slayt gösterisi", + "colorNameAndValue": "{name}: {value}", + "x/y": "{x} / {y}" +} diff --git a/packages/@react-aria/color/intl/uk-UA.json b/packages/@react-aria/color/intl/uk-UA.json new file mode 100644 index 00000000000..6bd3ac3b2bc --- /dev/null +++ b/packages/@react-aria/color/intl/uk-UA.json @@ -0,0 +1,5 @@ +{ + "twoDimensionalSlider": "Повзунок 2D", + "colorNameAndValue": "{name}: {value}", + "x/y": "{x} / {y}" +} diff --git a/packages/@react-aria/color/intl/zh-CN.json b/packages/@react-aria/color/intl/zh-CN.json new file mode 100644 index 00000000000..171ba64dcb0 --- /dev/null +++ b/packages/@react-aria/color/intl/zh-CN.json @@ -0,0 +1,5 @@ +{ + "twoDimensionalSlider": "2D 滑块", + "colorNameAndValue": "{name}: {value}", + "x/y": "{x} / {y}" +} diff --git a/packages/@react-aria/color/intl/zh-TW.json b/packages/@react-aria/color/intl/zh-TW.json new file mode 100644 index 00000000000..d8c87cc8bd8 --- /dev/null +++ b/packages/@react-aria/color/intl/zh-TW.json @@ -0,0 +1,5 @@ +{ + "twoDimensionalSlider": "2D 滑桿", + "colorNameAndValue": "{name}: {value}", + "x/y": "{x} / {y}" +} diff --git a/packages/@react-aria/color/package.json b/packages/@react-aria/color/package.json index 261861d3a1e..69d4110ef3e 100644 --- a/packages/@react-aria/color/package.json +++ b/packages/@react-aria/color/package.json @@ -18,16 +18,18 @@ }, "dependencies": { "@babel/runtime": "^7.6.2", - "@react-aria/spinbutton": "^3.0.3", - "@react-aria/textfield": "^3.5.2", - "@react-aria/i18n": "^3.3.6", - "@react-aria/interactions": "^3.8.1", - "@react-aria/slider": "^3.0.5", - "@react-aria/utils": "^3.11.2", + "@internationalized/message": "^3.0.2", + "@react-aria/i18n": "^3.3.3", + "@react-aria/interactions": "^3.7.0", + "@react-aria/slider": "^3.0.3", + "@react-aria/spinbutton": "^3.0.1", + "@react-aria/textfield": "^3.5.0", + "@react-aria/utils": "^3.11.0", + "@react-aria/visually-hidden": "^3.2.3", "@react-stately/color": "3.0.0-beta.7", "@react-types/color": "3.0.0-beta.5", - "@react-types/shared": "^3.11.1", - "@react-types/slider": "^3.0.4" + "@react-types/shared": "^3.10.1", + "@react-types/slider": "^3.0.2" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0-rc.1", diff --git a/packages/@react-aria/color/src/index.ts b/packages/@react-aria/color/src/index.ts index dd98ffece47..64b356d1a53 100644 --- a/packages/@react-aria/color/src/index.ts +++ b/packages/@react-aria/color/src/index.ts @@ -10,6 +10,7 @@ * governing permissions and limitations under the License. */ +export * from './useColorArea'; export * from './useColorSlider'; export * from './useColorWheel'; export * from './useColorField'; diff --git a/packages/@react-aria/color/src/useColorArea.ts b/packages/@react-aria/color/src/useColorArea.ts new file mode 100644 index 00000000000..db31620e441 --- /dev/null +++ b/packages/@react-aria/color/src/useColorArea.ts @@ -0,0 +1,381 @@ +/* + * Copyright 2020 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import {AriaColorAreaProps} from '@react-types/color'; +import {ColorAreaState} from '@react-stately/color'; +import {focusWithoutScrolling, isAndroid, isIOS, mergeProps, useGlobalListeners, useLabels} from '@react-aria/utils'; +// @ts-ignore +import intlMessages from '../intl/*.json'; +import React, {ChangeEvent, HTMLAttributes, InputHTMLAttributes, RefObject, useCallback, useRef} from 'react'; +import {useKeyboard, useMove} from '@react-aria/interactions'; +import {useLocale, useMessageFormatter} from '@react-aria/i18n'; +import {useVisuallyHidden} from '@react-aria/visually-hidden'; + +interface ColorAreaAria { + /** Props for the color area container element. */ + colorAreaProps: HTMLAttributes, + /** Props for the color area gradient foreground element. */ + gradientProps: HTMLAttributes, + /** Props for the thumb element. */ + thumbProps: HTMLAttributes, + /** Props for the visually hidden horizontal range input element. */ + xInputProps: InputHTMLAttributes, + /** Props for the visually hidden vertical range input element. */ + yInputProps: InputHTMLAttributes +} + +/** + * Provides the behavior and accessibility implementation for a color wheel component. + * Color wheels allow users to adjust the hue of an HSL or HSB color value on a circular track. + */ +export function useColorArea(props: AriaColorAreaProps, state: ColorAreaState, inputXRef: RefObject, inputYRef: RefObject, containerRef: RefObject): ColorAreaAria { + let { + isDisabled + } = props; + let formatMessage = useMessageFormatter(intlMessages); + + let {addGlobalListener, removeGlobalListener} = useGlobalListeners(); + + let {direction, locale} = useLocale(); + + let focusedInputRef = useRef(null); + + let focusInput = useCallback((inputRef:RefObject = inputXRef) => { + if (inputRef.current) { + focusWithoutScrolling(inputRef.current); + } + }, [inputXRef]); + + let stateRef = useRef(null); + stateRef.current = state; + let {xChannel, yChannel} = stateRef.current.channels; + let xChannelStep = stateRef.current.xChannelStep; + let yChannelStep = stateRef.current.xChannelStep; + + let currentPosition = useRef<{x: number, y: number}>(null); + + let {keyboardProps} = useKeyboard({ + onKeyDown(e) { + // these are the cases that useMove doesn't handle + if (!/^(PageUp|PageDown|Home|End)$/.test(e.key)) { + e.continuePropagation(); + return; + } + // same handling as useMove, don't need to stop propagation, useKeyboard will do that for us + e.preventDefault(); + // remember to set this and unset it so that onChangeEnd is fired + stateRef.current.setDragging(true); + switch (e.key) { + case 'PageUp': + stateRef.current.incrementY(stateRef.current.yChannelPageStep); + focusedInputRef.current = inputYRef.current; + break; + case 'PageDown': + stateRef.current.decrementY(stateRef.current.yChannelPageStep); + focusedInputRef.current = inputYRef.current; + break; + case 'Home': + direction === 'rtl' ? stateRef.current.incrementX(stateRef.current.xChannelPageStep) : stateRef.current.decrementX(stateRef.current.xChannelPageStep); + focusedInputRef.current = inputXRef.current; + break; + case 'End': + direction === 'rtl' ? stateRef.current.decrementX(stateRef.current.xChannelPageStep) : stateRef.current.incrementX(stateRef.current.xChannelPageStep); + focusedInputRef.current = inputXRef.current; + break; + } + stateRef.current.setDragging(false); + if (focusedInputRef.current) { + focusInput(focusedInputRef.current ? focusedInputRef : inputXRef); + focusedInputRef.current = undefined; + } + } + }); + + let moveHandler = { + onMoveStart() { + currentPosition.current = null; + stateRef.current.setDragging(true); + }, + onMove({deltaX, deltaY, pointerType, shiftKey}) { + let { + incrementX, + decrementX, + incrementY, + decrementY, + xChannelPageStep, + xChannelStep, + yChannelPageStep, + yChannelStep, + getThumbPosition, + setColorFromPoint + } = stateRef.current; + if (currentPosition.current == null) { + currentPosition.current = getThumbPosition(); + } + let {width, height} = containerRef.current.getBoundingClientRect(); + if (pointerType === 'keyboard') { + let deltaXValue = shiftKey && xChannelPageStep > xChannelStep ? xChannelPageStep : xChannelStep; + let deltaYValue = shiftKey && yChannelPageStep > yChannelStep ? yChannelPageStep : yChannelStep; + if ((deltaX > 0 && direction === 'ltr') || (deltaX < 0 && direction === 'rtl')) { + incrementX(deltaXValue); + } else if ((deltaX < 0 && direction === 'ltr') || (deltaX > 0 && direction === 'rtl')) { + decrementX(deltaXValue); + } else if (deltaY > 0) { + decrementY(deltaYValue); + } else if (deltaY < 0) { + incrementY(deltaYValue); + } + // set the focused input based on which axis has the greater delta + focusedInputRef.current = (deltaX !== 0 || deltaY !== 0) && Math.abs(deltaY) > Math.abs(deltaX) ? inputYRef.current : inputXRef.current; + } else { + currentPosition.current.x += (direction === 'rtl' ? -1 : 1) * deltaX / width ; + currentPosition.current.y += deltaY / height; + setColorFromPoint(currentPosition.current.x, currentPosition.current.y); + } + }, + onMoveEnd() { + isOnColorArea.current = undefined; + stateRef.current.setDragging(false); + focusInput(focusedInputRef.current ? focusedInputRef : inputXRef); + focusedInputRef.current = undefined; + } + }; + let {moveProps: movePropsThumb} = useMove(moveHandler); + + let currentPointer = useRef(undefined); + let isOnColorArea = useRef(false); + let {moveProps: movePropsContainer} = useMove({ + onMoveStart() { + if (isOnColorArea.current) { + moveHandler.onMoveStart(); + } + }, + onMove(e) { + if (isOnColorArea.current) { + moveHandler.onMove(e); + } + }, + onMoveEnd() { + if (isOnColorArea.current) { + moveHandler.onMoveEnd(); + } + } + }); + + let onThumbDown = (id: number | null) => { + if (!state.isDragging) { + currentPointer.current = id; + focusInput(); + state.setDragging(true); + if (typeof PointerEvent !== 'undefined') { + addGlobalListener(window, 'pointerup', onThumbUp, false); + } else { + addGlobalListener(window, 'mouseup', onThumbUp, false); + addGlobalListener(window, 'touchend', onThumbUp, false); + } + } + }; + + let onThumbUp = (e) => { + let id = e.pointerId ?? e.changedTouches?.[0].identifier; + if (id === currentPointer.current) { + focusInput(); + state.setDragging(false); + currentPointer.current = undefined; + isOnColorArea.current = false; + + if (typeof PointerEvent !== 'undefined') { + removeGlobalListener(window, 'pointerup', onThumbUp, false); + } else { + removeGlobalListener(window, 'mouseup', onThumbUp, false); + removeGlobalListener(window, 'touchend', onThumbUp, false); + } + } + }; + + let onColorAreaDown = (colorArea: Element, id: number | null, clientX: number, clientY: number) => { + let rect = colorArea.getBoundingClientRect(); + let {width, height} = rect; + let x = (clientX - rect.x) / width; + let y = (clientY - rect.y) / height; + if (direction === 'rtl') { + x = 1 - x; + } + if (x >= 0 && x <= 1 && y >= 0 && y <= 1 && !state.isDragging && currentPointer.current === undefined) { + isOnColorArea.current = true; + currentPointer.current = id; + state.setColorFromPoint(x, y); + + focusInput(); + state.setDragging(true); + + if (typeof PointerEvent !== 'undefined') { + addGlobalListener(window, 'pointerup', onColorAreaUp, false); + } else { + addGlobalListener(window, 'mouseup', onColorAreaUp, false); + addGlobalListener(window, 'touchend', onColorAreaUp, false); + } + } + }; + + let onColorAreaUp = (e) => { + let id = e.pointerId ?? e.changedTouches?.[0].identifier; + if (isOnColorArea.current && id === currentPointer.current) { + isOnColorArea.current = false; + currentPointer.current = undefined; + state.setDragging(false); + focusInput(); + + if (typeof PointerEvent !== 'undefined') { + removeGlobalListener(window, 'pointerup', onColorAreaUp, false); + } else { + removeGlobalListener(window, 'mouseup', onColorAreaUp, false); + removeGlobalListener(window, 'touchend', onColorAreaUp, false); + } + } + }; + + let colorAreaInteractions = isDisabled ? {} : mergeProps({ + ...(typeof PointerEvent !== 'undefined' ? { + onPointerDown: (e: React.PointerEvent) => { + if (e.pointerType === 'mouse' && (e.button !== 0 || e.altKey || e.ctrlKey || e.metaKey)) { + return; + } + onColorAreaDown(e.currentTarget, e.pointerId, e.clientX, e.clientY); + }} : { + onMouseDown: (e: React.MouseEvent) => { + if (e.button !== 0 || e.altKey || e.ctrlKey || e.metaKey) { + return; + } + onColorAreaDown(e.currentTarget, undefined, e.clientX, e.clientY); + }, + onTouchStart: (e: React.TouchEvent) => { + onColorAreaDown(e.currentTarget, e.changedTouches[0].identifier, e.changedTouches[0].clientX, e.changedTouches[0].clientY); + } + }) + }, movePropsContainer); + + let thumbInteractions = isDisabled ? {} : mergeProps({ + ...(typeof PointerEvent !== 'undefined' ? { + onPointerDown: (e: React.PointerEvent) => { + if (e.pointerType === 'mouse' && (e.button !== 0 || e.altKey || e.ctrlKey || e.metaKey)) { + return; + } + onThumbDown(e.pointerId); + }} : { + onMouseDown: (e: React.MouseEvent) => { + if (e.button !== 0 || e.altKey || e.ctrlKey || e.metaKey) { + return; + } + onThumbDown(undefined); + }, + onTouchStart: (e: React.TouchEvent) => { + onThumbDown(e.changedTouches[0].identifier); + } + }) + }, keyboardProps, movePropsThumb); + + let isMobile = isIOS() || isAndroid(); + + let xInputLabellingProps = useLabels({ + ...props, + 'aria-label': isMobile ? state.value.getChannelName(xChannel, locale) : formatMessage('x/y', {x: state.value.getChannelName(xChannel, locale), y: state.value.getChannelName(yChannel, locale)}) + }); + + let yInputLabellingProps = useLabels({ + ...props, + 'aria-label': isMobile ? state.value.getChannelName(yChannel, locale) : formatMessage('x/y', {x: state.value.getChannelName(xChannel, locale), y: state.value.getChannelName(yChannel, locale)}) + }); + + let colorAriaLabellingProps = useLabels(props); + + let getValueTitle = () => [ + formatMessage('colorNameAndValue', {name: state.value.getChannelName('red', locale), value: state.value.formatChannelValue('red', locale)}), + formatMessage('colorNameAndValue', {name: state.value.getChannelName('green', locale), value: state.value.formatChannelValue('green', locale)}), + formatMessage('colorNameAndValue', {name: state.value.getChannelName('blue', locale), value: state.value.formatChannelValue('blue', locale)}) + ].join(', '); + + let ariaRoleDescription = isMobile ? null : formatMessage('twoDimensionalSlider'); + + let {visuallyHiddenProps} = useVisuallyHidden({style: { + opacity: '0.0001', + width: '100%', + height: '100%', + pointerEvents: 'none' + }}); + + return { + colorAreaProps: { + ...colorAriaLabellingProps, + ...colorAreaInteractions, + role: 'group' + }, + gradientProps: { + role: 'presentation' + }, + thumbProps: { + ...thumbInteractions, + role: 'presentation' + }, + xInputProps: { + ...xInputLabellingProps, + ...visuallyHiddenProps, + type: 'range', + min: state.value.getChannelRange(xChannel).minValue, + max: state.value.getChannelRange(xChannel).maxValue, + step: xChannelStep, + 'aria-roledescription': ariaRoleDescription, + 'aria-valuetext': ( + isMobile ? + formatMessage('colorNameAndValue', {name: state.value.getChannelName(xChannel, locale), value: state.value.formatChannelValue(xChannel, locale)}) + : + [ + formatMessage('colorNameAndValue', {name: state.value.getChannelName(xChannel, locale), value: state.value.formatChannelValue(xChannel, locale)}), + formatMessage('colorNameAndValue', {name: state.value.getChannelName(yChannel, locale), value: state.value.formatChannelValue(yChannel, locale)}) + ].join(', ') + ), + title: getValueTitle(), + disabled: isDisabled, + value: state.value.getChannelValue(xChannel), + tabIndex: 0, + onChange: (e: ChangeEvent) => { + state.setXValue(parseFloat(e.target.value)); + } + }, + yInputProps: { + ...yInputLabellingProps, + ...visuallyHiddenProps, + type: 'range', + min: state.value.getChannelRange(yChannel).minValue, + max: state.value.getChannelRange(yChannel).maxValue, + step: yChannelStep, + 'aria-roledescription': ariaRoleDescription, + 'aria-valuetext': ( + isMobile ? + formatMessage('colorNameAndValue', {name: state.value.getChannelName(yChannel, locale), value: state.value.formatChannelValue(yChannel, locale)}) + : + [ + formatMessage('colorNameAndValue', {name: state.value.getChannelName(yChannel, locale), value: state.value.formatChannelValue(yChannel, locale)}), + formatMessage('colorNameAndValue', {name: state.value.getChannelName(xChannel, locale), value: state.value.formatChannelValue(xChannel, locale)}) + ].join(', ') + ), + 'aria-orientation': 'vertical', + title: getValueTitle(), + disabled: isDisabled, + value: state.value.getChannelValue(yChannel), + tabIndex: -1, + onChange: (e: ChangeEvent) => { + state.setYValue(parseFloat(e.target.value)); + } + } + }; +} diff --git a/packages/@react-aria/color/src/useColorWheel.ts b/packages/@react-aria/color/src/useColorWheel.ts index 3c2b705cc9d..7d9389ba8c4 100644 --- a/packages/@react-aria/color/src/useColorWheel.ts +++ b/packages/@react-aria/color/src/useColorWheel.ts @@ -33,8 +33,6 @@ interface ColorWheelAria { inputProps: InputHTMLAttributes } -const PAGE_MIN_STEP_SIZE = 6; - /** * Provides the behavior and accessibility implementation for a color wheel component. * Color wheels allow users to adjust the hue of an HSL or HSB color value on a circular track. @@ -62,12 +60,38 @@ export function useColorWheel(props: ColorWheelAriaProps, state: ColorWheelState stateRef.current = state; let currentPosition = useRef<{x: number, y: number}>(null); + + let {keyboardProps} = useKeyboard({ + onKeyDown(e) { + // these are the cases that useMove doesn't handle + if (!/^(PageUp|PageDown)$/.test(e.key)) { + e.continuePropagation(); + return; + } + // same handling as useMove, don't need to stop propagation, useKeyboard will do that for us + e.preventDefault(); + // remember to set this and unset it so that onChangeEnd is fired + stateRef.current.setDragging(true); + switch (e.key) { + case 'PageUp': + e.preventDefault(); + state.increment(stateRef.current.pageStep); + break; + case 'PageDown': + e.preventDefault(); + state.decrement(stateRef.current.pageStep); + break; + } + stateRef.current.setDragging(false); + } + }); + let moveHandler = { onMoveStart() { currentPosition.current = null; state.setDragging(true); }, - onMove({deltaX, deltaY, pointerType}) { + onMove({deltaX, deltaY, pointerType, shiftKey}) { if (currentPosition.current == null) { currentPosition.current = stateRef.current.getThumbPosition(thumbRadius); } @@ -75,9 +99,9 @@ export function useColorWheel(props: ColorWheelAriaProps, state: ColorWheelState currentPosition.current.y += deltaY; if (pointerType === 'keyboard') { if (deltaX > 0 || deltaY < 0) { - state.increment(); + state.increment(shiftKey ? stateRef.current.pageStep : stateRef.current.step); } else if (deltaX < 0 || deltaY > 0) { - state.decrement(); + state.decrement(shiftKey ? stateRef.current.pageStep : stateRef.current.step); } } else { stateRef.current.setHueFromPoint(currentPosition.current.x, currentPosition.current.y, thumbRadius); @@ -183,21 +207,6 @@ export function useColorWheel(props: ColorWheelAriaProps, state: ColorWheelState } }; - let {keyboardProps} = useKeyboard({ - onKeyDown(e) { - switch (e.key) { - case 'PageUp': - e.preventDefault(); - state.increment(PAGE_MIN_STEP_SIZE); - break; - case 'PageDown': - e.preventDefault(); - state.decrement(PAGE_MIN_STEP_SIZE); - break; - } - } - }); - let trackInteractions = isDisabled ? {} : mergeProps({ ...(typeof PointerEvent !== 'undefined' ? { onPointerDown: (e: React.PointerEvent) => { @@ -234,7 +243,7 @@ export function useColorWheel(props: ColorWheelAriaProps, state: ColorWheelState onTouchStart: (e: React.TouchEvent) => { onThumbDown(e.changedTouches[0].identifier); } - }, movePropsThumb, keyboardProps); + }, keyboardProps, movePropsThumb); let {x, y} = state.getThumbPosition(thumbRadius); // Provide a default aria-label if none is given diff --git a/packages/@react-aria/color/test/useColorWheel.test.tsx b/packages/@react-aria/color/test/useColorWheel.test.tsx index ca81041c702..86f4897cfdc 100644 --- a/packages/@react-aria/color/test/useColorWheel.test.tsx +++ b/packages/@react-aria/color/test/useColorWheel.test.tsx @@ -51,7 +51,7 @@ function ColorWheel(props: ColorWheelProps) { describe('useColorWheel', () => { let onChangeSpy = jest.fn(); - + beforeAll(() => { // @ts-ignore jest.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => cb()); diff --git a/packages/@react-spectrum/color/src/ColorArea.tsx b/packages/@react-spectrum/color/src/ColorArea.tsx new file mode 100644 index 00000000000..b649967cfa8 --- /dev/null +++ b/packages/@react-spectrum/color/src/ColorArea.tsx @@ -0,0 +1,202 @@ +/* + * Copyright 2021 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import {classNames, dimensionValue, useFocusableRef, useStyleProps} from '@react-spectrum/utils'; +import {ColorThumb} from './ColorThumb'; +import {FocusableRef} from '@react-types/shared'; +import {mergeProps} from '@react-aria/utils'; +import React, {CSSProperties, ReactElement, useRef} from 'react'; +import {SpectrumColorAreaProps} from '@react-types/color'; +import styles from '@adobe/spectrum-css-temp/components/colorarea/vars.css'; +import {useColorArea} from '@react-aria/color'; +import {useColorAreaState} from '@react-stately/color'; +import {useFocusRing} from '@react-aria/focus'; +import {useLocale} from '@react-aria/i18n'; +import {useProviderProps} from '@react-spectrum/provider'; + +function ColorArea(props: SpectrumColorAreaProps, ref: FocusableRef) { + props = useProviderProps(props); + + let {isDisabled} = props; + let size = props.size && dimensionValue(props.size); + let {styleProps} = useStyleProps(props); + + let xInputRef = useRef(null); + let yInputRef = useRef(null); + let containerRef = useFocusableRef(ref, xInputRef); + + let state = useColorAreaState(props); + + let {channels: {xChannel, zChannel}} = state; + let { + colorAreaProps, + gradientProps, + xInputProps, + yInputProps, + thumbProps + } = useColorArea(props, state, xInputRef, yInputRef, containerRef); + let {direction} = useLocale(); + let {colorAreaStyleProps, gradientStyleProps, thumbStyleProps} = useGradients({direction, state, xChannel, zChannel, isDisabled: props.isDisabled}); + + let {focusProps, isFocusVisible} = useFocusRing(); + + return ( +
+
+ +
+ + +
+
+
+ ); +} + +let _ColorArea = React.forwardRef(ColorArea) as (props: SpectrumColorAreaProps & {ref?: FocusableRef}) => ReactElement; +export {_ColorArea as ColorArea}; + +interface Gradients { + colorAreaStyleProps: { + style: CSSProperties + }, + gradientStyleProps: { + style: CSSProperties + }, + thumbStyleProps: { + style: CSSProperties + } +} + +// this function looks scary, but it's actually pretty quick, just generates some strings +function useGradients({direction, state, zChannel, xChannel, isDisabled}): Gradients { + let orientation = ['top', direction === 'rtl' ? 'left' : 'right']; + let dir = false; + let background = {colorAreaStyles: {}, gradientStyles: {}}; + let zValue = state.value.getChannelValue(zChannel); + let maskImage; + if (!isDisabled) { + switch (zChannel) { + case 'red': { + dir = xChannel === 'green'; + maskImage = `linear-gradient(to ${orientation[Number(!dir)]}, transparent, #000)`; + background.colorAreaStyles = { + /* the background represents the green channel as a linear gradient from min to max, + with the blue channel minimized, adjusted by the red channel value. */ + backgroundImage: `linear-gradient(to ${orientation[Number(dir)]},rgb(${zValue},0,0),rgb(${zValue},255,0))` + }; + background.gradientStyles = { + /* the foreground represents the green channel as a linear gradient from min to max, + with the blue channel maximized, adjusted by the red channel value. */ + backgroundImage: `linear-gradient(to ${orientation[Number(dir)]},rgb(${zValue},0,255),rgb(${zValue},255,255))`, + /* the foreground gradient is masked by a perpendicular linear gradient from black to white */ + 'WebkitMaskImage': maskImage, + maskImage + }; + break; + } + case 'green': { + dir = xChannel === 'red'; + maskImage = `linear-gradient(to ${orientation[Number(!dir)]}, transparent, #000)`; + background.colorAreaStyles = { + /* the background represents the red channel as a linear gradient from min to max, + with the blue channel minimized, adjusted by the green channel value. */ + backgroundImage: `linear-gradient(to ${orientation[Number(dir)]},rgb(0,${zValue},0),rgb(255,${zValue},0))` + }; + background.gradientStyles = { + /* the foreground represents the red channel as a linear gradient from min to max, + with the blue channel maximized, adjusted by the green channel value. */ + backgroundImage: `linear-gradient(to ${orientation[Number(dir)]},rgb(0,${zValue},255),rgb(255,${zValue},255))`, + /* the foreground gradient is masked by a perpendicular linear gradient from black to white */ + 'WebkitMaskImage': maskImage, + maskImage + }; + break; + } + case 'blue': { + dir = xChannel === 'red'; + maskImage = `linear-gradient(to ${orientation[Number(!dir)]}, transparent, #000)`; + background.colorAreaStyles = { + /* the background represents the red channel as a linear gradient from min to max, + with the green channel minimized, adjusted by the blue channel value. */ + backgroundImage: `linear-gradient(to ${orientation[Number(dir)]},rgb(0,0,${zValue}),rgb(255,0,${zValue}))` + }; + background.gradientStyles = { + /* the foreground represents the red channel as a linear gradient from min to max, + with the green channel maximized, adjusted by the blue channel value. */ + backgroundImage: `linear-gradient(to ${orientation[Number(dir)]},rgb(0,255,${zValue}),rgb(255,255,${zValue}))`, + /* the foreground gradient is masked by a perpendicular linear gradient from black to white */ + 'WebkitMaskImage': maskImage, + maskImage + }; + break; + } + } + } + + let {x, y} = state.getThumbPosition(); + + if (direction === 'rtl') { + x = 1 - x; + } + + return { + colorAreaStyleProps: { + style: { + position: 'relative', + touchAction: 'none', + ...background.colorAreaStyles + } + }, + gradientStyleProps: { + style: { + touchAction: 'none', + ...background.gradientStyles + } + }, + thumbStyleProps: { + style: { + position: 'absolute', + left: `${x * 100}%`, + top: `${y * 100}%`, + transform: 'translate(0%, 0%)', + touchAction: 'none' + } + } + }; +} diff --git a/packages/@react-spectrum/color/src/index.ts b/packages/@react-spectrum/color/src/index.ts index 54569da6a5d..d87f973282f 100644 --- a/packages/@react-spectrum/color/src/index.ts +++ b/packages/@react-spectrum/color/src/index.ts @@ -12,6 +12,7 @@ /// +export * from './ColorArea'; export * from './ColorWheel'; export * from './ColorSlider'; export * from './ColorField'; diff --git a/packages/@react-spectrum/color/stories/ColorArea.stories.tsx b/packages/@react-spectrum/color/stories/ColorArea.stories.tsx new file mode 100644 index 00000000000..a1e70542bff --- /dev/null +++ b/packages/@react-spectrum/color/stories/ColorArea.stories.tsx @@ -0,0 +1,122 @@ +/* + * Copyright 2020 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import {action} from '@storybook/addon-actions'; +import {ColorArea, ColorSlider} from '../'; +import {ColorChannel, SpectrumColorAreaProps} from '@react-types/color'; +import {Flex} from '@adobe/react-spectrum'; +import {Meta, Story} from '@storybook/react'; +import {parseColor} from '@react-stately/color'; +import React, {useState} from 'react'; +import {Text} from '@react-spectrum/text'; + + +const meta: Meta = { + title: 'ColorArea', + component: ColorArea +}; + +export default meta; + +const Template: Story = (args) => ( + +); + +let RGB: Set = new Set(['red', 'green', 'blue']); +let difference = (a, b): Set => new Set([...a].filter(x => !b.has(x))); + +function ColorAreaExample(props: SpectrumColorAreaProps) { + let {xChannel, yChannel, isDisabled} = props; + let channels = new Set([xChannel, yChannel]); + let zChannel: ColorChannel = difference(RGB, channels).keys().next().value; + let [color, setColor] = useState(props.defaultValue || parseColor('#ff00ff')); + return (
+ + + { + if (props.onChange) { + props.onChange(e); + } + setColor(e); + }} /> + { + if (props.onChange) { + props.onChange(e); + } + setColor(e); + }} + onChangeEnd={props.onChangeEnd} + channel={zChannel} + isDisabled={isDisabled} /> + + +
+ {color.toString('hex')} + + +
); +} + +export let XBlueYGreen = Template.bind({}); +XBlueYGreen.storyName = 'RGB xChannel="blue", yChannel="green"'; +XBlueYGreen.args = {xChannel: 'blue', yChannel: 'green', onChange: action('onChange'), onChangeEnd: action('onChangeEnd')}; + +export let XGreenYBlue = Template.bind({}); +XGreenYBlue.storyName = 'RGB xChannel="green", yChannel="blue"'; +XGreenYBlue.args = {...XBlueYGreen.args, xChannel: 'green', yChannel: 'blue'}; + +export let XBlueYRed = Template.bind({}); +XBlueYRed.storyName = 'RGB xChannel="blue", yChannel="red"'; +XBlueYRed.args = {...XBlueYGreen.args, xChannel: 'blue', yChannel: 'red'}; + +export let XRedYBlue = Template.bind({}); +XRedYBlue.storyName = 'GB xChannel="red", yChannel="blue"'; +XRedYBlue.args = {...XBlueYGreen.args, xChannel: 'red', yChannel: 'blue'}; + +export let XRedYGreen = Template.bind({}); +XRedYGreen.storyName = 'RGB xChannel="red", yChannel="green"'; +XRedYGreen.args = {...XBlueYGreen.args, xChannel: 'red', yChannel: 'green'}; + +export let XGreenYRed = Template.bind({}); +XGreenYRed.storyName = 'RGB xChannel="green", yChannel="red"'; +XGreenYRed.args = {...XBlueYGreen.args, xChannel: 'green', yChannel: 'red'}; + +export let XBlueYGreenStep16 = Template.bind({}); +XBlueYGreenStep16.storyName = 'RGB xChannel="blue", yChannel="green", step="16"'; +XBlueYGreenStep16.args = {...XBlueYGreen.args, xChannelStep: 16, yChannelStep: 16}; + +export let XBlueYGreenPageStep32 = Template.bind({}); +XBlueYGreenPageStep32.storyName = 'RGB xChannel="blue", yChannel="green", pageStep="32"'; +XBlueYGreenPageStep32.args = {...XBlueYGreen.args, xChannelPageStep: 32, yChannelPageStep: 32}; + +/* TODO: what does a disabled color area look like? */ +export let XBlueYGreenisDisabled = Template.bind({}); +XBlueYGreenisDisabled.storyName = 'RGB xChannel="blue", yChannel="green", isDisabled'; +XBlueYGreenisDisabled.args = {...XBlueYGreen.args, isDisabled: true}; + +/* TODO: how do we visually label and how to do we aria-label */ +export let XBlueYGreenAriaLabelled = Template.bind({}); +XBlueYGreenAriaLabelled.storyName = 'RGB xChannel="blue", yChannel="green", aria-label="foo"'; +XBlueYGreenAriaLabelled.args = {...XBlueYGreen.args, label: undefined, ariaLabel: 'foo'}; + +export let XBlueYGreenSize3000 = Template.bind({}); +XBlueYGreenSize3000.storyName = 'RGB xChannel="blue", yChannel="green", size="size-3000"'; +XBlueYGreenSize3000.args = {...XBlueYGreen.args, size: 'size-3000'}; + +export let XBlueYGreenSize600 = Template.bind({}); +XBlueYGreenSize600.storyName = 'RGB xChannel="blue", yChannel="green", size="size-600"'; +XBlueYGreenSize600.args = {...XBlueYGreen.args, size: 'size-600'}; diff --git a/packages/@react-spectrum/color/test/ColorArea.test.tsx b/packages/@react-spectrum/color/test/ColorArea.test.tsx new file mode 100644 index 00000000000..a9ef7a834b9 --- /dev/null +++ b/packages/@react-spectrum/color/test/ColorArea.test.tsx @@ -0,0 +1,563 @@ +/* + * Copyright 2020 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import {ColorArea} from '../'; +import {XBlueYGreen as DefaultColorArea} from '../stories/ColorArea.stories'; +import {defaultTheme} from '@adobe/react-spectrum'; +import {fireEvent, render} from '@testing-library/react'; +import {installMouseEvent, installPointerEvent} from '@react-spectrum/test-utils'; +import {parseColor} from '@react-stately/color'; +import {Provider} from '@react-spectrum/provider'; +import React from 'react'; +import userEvent from '@testing-library/user-event'; + +const SIZE = 160; +const CENTER = SIZE / 2; +const THUMB_RADIUS = 68; + +const getBoundingClientRect = () => ({ + width: SIZE, height: SIZE, + x: 0, y: 0, + top: 0, left: 0, + bottom: SIZE, right: SIZE, + toJSON() { return this; } +}); + +describe('ColorArea', () => { + let onChangeSpy = jest.fn(); + let onChangeEndSpy = jest.fn(); + + beforeAll(() => { + jest.spyOn(window.HTMLElement.prototype, 'offsetWidth', 'get').mockImplementation(() => SIZE); + // @ts-ignore + jest.useFakeTimers('modern'); + }); + afterAll(() => { + // @ts-ignore + jest.useRealTimers(); + }); + + afterEach(() => { + // for restoreTextSelection + jest.runAllTimers(); + onChangeSpy.mockClear(); + onChangeEndSpy.mockClear(); + }); + + // get group corresponds to the index returned by getAllByRole('group') + describe.each` + Name | Component | groupIndex + ${'Controlled'} | ${DefaultColorArea} | ${1} + ${'Uncontrolled'} | ${ColorArea} | ${0} + `('$Name', ({Component, groupIndex}) => { + describe('attributes', () => { + it('sets input props', () => { + let {getAllByRole} = render(); + let sliders = getAllByRole('slider'); + + let [xSlider, ySlider] = sliders; + + expect(xSlider).toHaveAttribute('type', 'range'); + expect(xSlider).toHaveAttribute('aria-label', 'Blue / Green'); + expect(xSlider).toHaveAttribute('min', '0'); + expect(xSlider).toHaveAttribute('max', '255'); + expect(xSlider).toHaveAttribute('step', '1'); + expect(xSlider).toHaveAttribute('aria-valuetext', 'Blue: 255, Green: 0'); + + expect(ySlider).toHaveAttribute('type', 'range'); + expect(ySlider).toHaveAttribute('aria-label', 'Blue / Green'); + expect(ySlider).toHaveAttribute('min', '0'); + expect(ySlider).toHaveAttribute('max', '255'); + expect(ySlider).toHaveAttribute('step', '1'); + expect(ySlider).toHaveAttribute('aria-valuetext', 'Green: 0, Blue: 255'); + }); + + it('disabled', () => { + let {getAllByRole} = render(
+ + + +
); + let sliders = getAllByRole('slider'); + let [buttonA, buttonB] = getAllByRole('button'); + sliders.forEach(slider => { + expect(slider).toHaveAttribute('disabled'); + }); + + userEvent.tab(); + expect(document.activeElement).toBe(buttonA); + userEvent.tab(); + expect(document.activeElement).toBe(buttonB); + userEvent.tab({shift: true}); + expect(document.activeElement).toBe(buttonA); + }); + + // TODO: don't know how to do this yet + describe.skip('labelling', () => { + it('should support a custom aria-label', () => { + let {getAllByRole} = render(); + let slider = getAllByRole('slider'); + + expect(slider).toHaveAttribute('aria-label', 'Color hue'); + expect(slider).not.toHaveAttribute('aria-labelledby'); + }); + + it('should support a custom aria-labelledby', () => { + let {getAllByRole} = render(); + let slider = getAllByRole('slider'); + + expect(slider).not.toHaveAttribute('aria-label'); + expect(slider).toHaveAttribute('aria-labelledby', 'label-id'); + }); + }); + }); + + describe('behaviors', () => { + let pressKey = (element, options) => { + fireEvent.keyDown(element, options); + fireEvent.keyUp(element, options); + }; + describe('keyboard events', () => { + it.each` + Name | props | actions | result + ${'left/right'} | ${{defaultValue: parseColor('#ff00ff')}} | ${{forward: (elem) => pressKey(elem, {key: 'ArrowLeft'}), backward: (elem) => pressKey(elem, {key: 'ArrowRight'})}} | ${parseColor('#ff00fe')} + ${'up/down'} | ${{defaultValue: parseColor('#ff00ff')}} | ${{forward: (elem) => pressKey(elem, {key: 'ArrowUp'}), backward: (elem) => pressKey(elem, {key: 'ArrowDown'})}} | ${parseColor('#ff01ff')} + ${'shiftleft/shiftright'} | ${{defaultValue: parseColor('#f000f0')}} | ${{forward: (elem) => pressKey(elem, {key: 'ArrowLeft', shiftKey: true}), backward: (elem) => pressKey(elem, {key: 'ArrowRight', shiftKey: true})}} | ${parseColor('#f000e0')} + ${'shiftup/shiftdown'} | ${{defaultValue: parseColor('#f000f0')}} | ${{forward: (elem) => pressKey(elem, {key: 'ArrowUp', shiftKey: true}), backward: (elem) => pressKey(elem, {key: 'ArrowDown', shiftKey: true})}} | ${parseColor('#f010f0')} + ${'pageup/pagedown'} | ${{defaultValue: parseColor('#f000f0')}} | ${{forward: (elem) => pressKey(elem, {key: 'PageUp'}), backward: (elem) => pressKey(elem, {key: 'PageDown'})}} | ${parseColor('#f010f0')} + ${'home/end'} | ${{defaultValue: parseColor('#f000f0')}} | ${{forward: (elem) => pressKey(elem, {key: 'Home'}), backward: (elem) => pressKey(elem, {key: 'End'})}} | ${parseColor('#f000e0')} + `('$Name', ({props, actions: {forward, backward}, result}) => { + let {getAllByRole} = render( + + ); + let sliders = getAllByRole('slider'); + userEvent.tab(); + + forward(sliders[0]); + expect(onChangeSpy).toHaveBeenCalledTimes(1); + expect(onChangeSpy.mock.calls[0][0].toString('rgba')).toBe(result.toString('rgba')); + expect(onChangeEndSpy).toHaveBeenCalledTimes(1); + expect(onChangeEndSpy.mock.calls[0][0].toString('rgba')).toBe(result.toString('rgba')); + + backward(sliders[0]); + expect(onChangeSpy).toHaveBeenCalledTimes(2); + expect(onChangeSpy.mock.calls[1][0].toString('rgba')).toBe(props.defaultValue.toString('rgba')); + expect(onChangeEndSpy).toHaveBeenCalledTimes(2); + expect(onChangeEndSpy.mock.calls[1][0].toString('rgba')).toBe(props.defaultValue.toString('rgba')); + }); + + it.each` + Name | props | actions | result + ${'left/right'} | ${{defaultValue: parseColor('#ff00ff')}} | ${{forward: (elem) => pressKey(elem, {key: 'ArrowRight'}), backward: (elem) => pressKey(elem, {key: 'ArrowLeft'})}} | ${parseColor('#ff00fe')} + ${'up/down'} | ${{defaultValue: parseColor('#ff00ff')}} | ${{forward: (elem) => pressKey(elem, {key: 'ArrowUp'}), backward: (elem) => pressKey(elem, {key: 'ArrowDown'})}} | ${parseColor('#ff01ff')} + ${'shiftleft/shiftright'} | ${{defaultValue: parseColor('#f000f0')}} | ${{forward: (elem) => pressKey(elem, {key: 'ArrowRight', shiftKey: true}), backward: (elem) => pressKey(elem, {key: 'ArrowLeft', shiftKey: true})}} | ${parseColor('#f000e0')} + ${'shiftup/shiftdown'} | ${{defaultValue: parseColor('#f000f0')}} | ${{forward: (elem) => pressKey(elem, {key: 'ArrowUp', shiftKey: true}), backward: (elem) => pressKey(elem, {key: 'ArrowDown', shiftKey: true})}} | ${parseColor('#f010f0')} + ${'pageup/pagedown'} | ${{defaultValue: parseColor('#f000f0')}} | ${{forward: (elem) => pressKey(elem, {key: 'PageUp'}), backward: (elem) => pressKey(elem, {key: 'PageDown'})}} | ${parseColor('#f010f0')} + ${'home/end'} | ${{defaultValue: parseColor('#f000f0')}} | ${{forward: (elem) => pressKey(elem, {key: 'End'}), backward: (elem) => pressKey(elem, {key: 'Home'})}} | ${parseColor('#f000e0')} + `('$Name RTL', ({props, actions: {forward, backward}, result}) => { + let {getAllByRole} = render( + + + + ); + let sliders = getAllByRole('slider'); + userEvent.tab(); + + forward(sliders[0]); + expect(onChangeSpy).toHaveBeenCalledTimes(1); + expect(onChangeSpy.mock.calls[0][0].toString('rgba')).toBe(result.toString('rgba')); + expect(onChangeEndSpy).toHaveBeenCalledTimes(1); + expect(onChangeEndSpy.mock.calls[0][0].toString('rgba')).toBe(result.toString('rgba')); + + backward(sliders[0]); + expect(onChangeSpy).toHaveBeenCalledTimes(2); + expect(onChangeSpy.mock.calls[1][0].toString('rgba')).toBe(props.defaultValue.toString('rgba')); + expect(onChangeEndSpy).toHaveBeenCalledTimes(2); + expect(onChangeEndSpy.mock.calls[1][0].toString('rgba')).toBe(props.defaultValue.toString('rgba')); + }); + + it('no events when disabled', () => { + let defaultColor = parseColor('#ff00ff'); + let {getAllByRole, getByRole} = render(
+ + +
); + let buttonA = getByRole('button'); + let sliders = getAllByRole('slider'); + userEvent.tab(); + expect(buttonA).toBe(document.activeElement); + + pressKey(sliders[0], {key: 'LeftArrow'}); + expect(onChangeSpy).not.toHaveBeenCalled(); + expect(onChangeEndSpy).not.toHaveBeenCalled(); + pressKey(sliders[0], {key: 'RightArrow'}); + expect(onChangeSpy).not.toHaveBeenCalled(); + expect(onChangeEndSpy).not.toHaveBeenCalled(); + }); + + it.each` + Name | props | actions | result + ${'left/right'} | ${{defaultValue: parseColor('#ff00ff')}} | ${{forward: (elem) => pressKey(elem, {key: 'ArrowLeft'}), backward: (elem) => pressKey(elem, {key: 'ArrowRight'})}} | ${parseColor('#ff00f0')} + ${'up/down'} | ${{defaultValue: parseColor('#ff00ff')}} | ${{forward: (elem) => pressKey(elem, {key: 'ArrowUp'}), backward: (elem) => pressKey(elem, {key: 'ArrowDown'})}} | ${parseColor('#ff0fff')} + `('$Name with step', ({props, actions: {forward, backward}, result}) => { + let {getAllByRole} = render( + + ); + let sliders = getAllByRole('slider'); + userEvent.tab(); + + forward(sliders[0]); + expect(onChangeSpy).toHaveBeenCalledTimes(1); + expect(onChangeSpy.mock.calls[0][0].toString('rgba')).toBe(result.toString('rgba')); + expect(onChangeEndSpy).toHaveBeenCalledTimes(1); + expect(onChangeEndSpy.mock.calls[0][0].toString('rgba')).toBe(result.toString('rgba')); + + backward(sliders[0]); + expect(onChangeSpy).toHaveBeenCalledTimes(2); + expect(onChangeSpy.mock.calls[1][0].toString('rgba')).toBe(props.defaultValue.toString('rgba')); + expect(onChangeEndSpy).toHaveBeenCalledTimes(2); + expect(onChangeEndSpy.mock.calls[1][0].toString('rgba')).toBe(props.defaultValue.toString('rgba')); + }); + }); + + describe.each` + type | prepare | actions + ${'Mouse Events'} | ${installMouseEvent} | ${[ + (el, {pageX, pageY}) => fireEvent.mouseDown(el, {button: 0, pageX, pageY, clientX: pageX, clientY: pageY}), + (el, {pageX, pageY}) => fireEvent.mouseMove(el, {button: 0, pageX, pageY, clientX: pageX, clientY: pageY}), + (el, {pageX, pageY}) => fireEvent.mouseUp(el, {button: 0, pageX, pageY, clientX: pageX, clientY: pageY}) + ]} + ${'Pointer Events'} | ${installPointerEvent}| ${[ + (el, {pageX, pageY}) => fireEvent.pointerDown(el, {button: 0, pointerId: 1, pageX, pageY, clientX: pageX, clientY: pageY}), + (el, {pageX, pageY}) => fireEvent.pointerMove(el, {button: 0, pointerId: 1, pageX, pageY, clientX: pageX, clientY: pageY}), + (el, {pageX, pageY}) => fireEvent.pointerUp(el, {button: 0, pointerId: 1, pageX, pageY, clientX: pageX, clientY: pageY}) + ]} + ${'Touch Events'} | ${() => {}} | ${[ + (el, {pageX, pageY}) => fireEvent.touchStart(el, {changedTouches: [{identifier: 1, pageX, pageY, clientX: pageX, clientY: pageY}]}), + (el, {pageX, pageY}) => fireEvent.touchMove(el, {changedTouches: [{identifier: 1, pageX, pageY, clientX: pageX, clientY: pageY}]}), + (el, {pageX, pageY}) => fireEvent.touchEnd(el, {changedTouches: [{identifier: 1, pageX, pageY, clientX: pageX, clientY: pageY}]}) + ]} + `('$type', ({actions: [start, move, end], prepare}) => { + prepare(); + + it('clicking on the area chooses the color at that point', () => { + let defaultColor = parseColor('#ff00ff'); + let {getAllByRole} = render( + + ); + let sliders = getAllByRole('slider'); + let groups = getAllByRole('group'); + let container = groups[groupIndex]; + container.getBoundingClientRect = getBoundingClientRect; + + expect(document.activeElement).not.toBe(sliders[0]); + start(container, {pageX: CENTER + THUMB_RADIUS, pageY: CENTER}); + expect(onChangeSpy).toHaveBeenCalledTimes(1); + expect(onChangeEndSpy).toHaveBeenCalledTimes(0); + expect(onChangeSpy.mock.calls[0][0].toString('rgba')).toBe(parseColor('#ff80EC').toString('rgba')); + expect(document.activeElement).toBe(sliders[0]); + + end(container, {pageX: CENTER + THUMB_RADIUS, pageY: CENTER}); + expect(onChangeSpy).toHaveBeenCalledTimes(1); + expect(onChangeEndSpy).toHaveBeenCalledTimes(1); + expect(onChangeEndSpy.mock.calls[0][0].toString('rgba')).toBe(parseColor('#ff80EC').toString('rgba')); + expect(document.activeElement).toBe(sliders[0]); + }); + + it('dragging the thumb works', () => { + let defaultColor = parseColor('#ff00ff'); + let {getAllByRole} = render( + + ); + let sliders = getAllByRole('slider'); + let groups = getAllByRole('group'); + let thumb = sliders[0].parentElement; + let container = groups[groupIndex]; + container.getBoundingClientRect = getBoundingClientRect; + + expect(document.activeElement).not.toBe(sliders[0]); + start(thumb, {pageX: CENTER + THUMB_RADIUS, pageY: CENTER}); + expect(onChangeSpy).toHaveBeenCalledTimes(0); + expect(onChangeEndSpy).toHaveBeenCalledTimes(0); + expect(document.activeElement).toBe(sliders[0]); + + move(thumb, {pageX: CENTER, pageY: CENTER + THUMB_RADIUS}); + expect(onChangeSpy).toHaveBeenCalledTimes(1); + expect(onChangeSpy.mock.calls[0][0].toString('rgba')).toBe(parseColor('#ff0093').toString('rgba')); + expect(onChangeEndSpy).toHaveBeenCalledTimes(0); + expect(document.activeElement).toBe(sliders[0]); + + end(thumb, {pageX: CENTER, pageY: CENTER + THUMB_RADIUS}); + expect(onChangeSpy).toHaveBeenCalledTimes(1); + expect(onChangeEndSpy).toHaveBeenCalledTimes(1); + expect(onChangeEndSpy.mock.calls[0][0].toString('rgba')).toBe(parseColor('#ff0093').toString('rgba')); + expect(document.activeElement).toBe(sliders[0]); + }); + + it('dragging the thumb doesn\'t works when disabled', () => { + let defaultColor = parseColor('#ff00ff'); + let {getAllByRole} = render( + + ); + let sliders = getAllByRole('slider'); + let groups = getAllByRole('group'); + let thumb = sliders[0].parentElement; + let container = groups[groupIndex]; + container.getBoundingClientRect = getBoundingClientRect; + + expect(document.activeElement).not.toBe(sliders[0]); + start(thumb, {pageX: CENTER + THUMB_RADIUS, pageY: CENTER}); + expect(onChangeSpy).toHaveBeenCalledTimes(0); + + move(thumb, {pageX: CENTER, pageY: CENTER + THUMB_RADIUS}); + expect(onChangeSpy).toHaveBeenCalledTimes(0); + + end(thumb, {pageX: CENTER, pageY: CENTER + THUMB_RADIUS}); + expect(onChangeSpy).toHaveBeenCalledTimes(0); + expect(onChangeEndSpy).toHaveBeenCalledTimes(0); + }); + + // TODO: Should it? + it('dragging the thumb respects the step', () => { + let defaultColor = parseColor('#ff00ff'); + let {getAllByRole} = render( + + ); + let sliders = getAllByRole('slider'); + let groups = getAllByRole('group'); + let thumb = sliders[0].parentElement; + let container = groups[groupIndex]; + container.getBoundingClientRect = getBoundingClientRect; + + start(thumb, {pageX: CENTER + THUMB_RADIUS, pageY: CENTER}); + expect(onChangeSpy).toHaveBeenCalledTimes(0); + + move(thumb, {pageX: CENTER, pageY: CENTER + THUMB_RADIUS}); + expect(onChangeSpy).toHaveBeenCalledTimes(1); + expect(onChangeSpy.mock.calls[0][0].toString('rgba')).toBe(parseColor('#ff0090').toString('rgba')); + + end(thumb, {pageX: CENTER, pageY: CENTER + THUMB_RADIUS}); + expect(onChangeSpy).toHaveBeenCalledTimes(1); + }); + + it('clicking and dragging on the track works', () => { + let defaultColor = parseColor('#ff00ff'); + let {getAllByRole} = render( + + ); + let sliders = getAllByRole('slider'); + let groups = getAllByRole('group'); + let container = groups[groupIndex]; + container.getBoundingClientRect = getBoundingClientRect; + + expect(document.activeElement).not.toBe(sliders[0]); + start(container, {pageX: CENTER + THUMB_RADIUS, pageY: CENTER}); + expect(onChangeSpy).toHaveBeenCalledTimes(1); + expect(onChangeEndSpy).toHaveBeenCalledTimes(0); + expect(onChangeSpy.mock.calls[0][0].toString('rgba')).toBe(parseColor('#ff80EC').toString('rgba')); + expect(document.activeElement).toBe(sliders[0]); + + move(container, {pageX: CENTER, pageY: CENTER + THUMB_RADIUS}); + expect(onChangeSpy).toHaveBeenCalledTimes(2); + expect(onChangeSpy.mock.calls[1][0].toString('rgba')).toBe(parseColor('#ff1480').toString('rgba')); + expect(document.activeElement).toBe(sliders[0]); + + end(container, {pageX: CENTER, pageY: CENTER + THUMB_RADIUS}); + expect(onChangeSpy).toHaveBeenCalledTimes(2); + expect(onChangeEndSpy).toHaveBeenCalledTimes(1); + expect(onChangeEndSpy.mock.calls[0][0].toString('rgba')).toBe(parseColor('#ff1480').toString('rgba')); + expect(document.activeElement).toBe(sliders[0]); + }); + + it('clicking and dragging on the track doesn\'t work when disabled', () => { + let defaultColor = parseColor('#ff00ff'); + let {getAllByRole} = render( + + ); + let sliders = getAllByRole('slider'); + let groups = getAllByRole('group'); + let container = groups[groupIndex]; + container.getBoundingClientRect = getBoundingClientRect; + + expect(document.activeElement).not.toBe(sliders[0]); + start(container, {pageX: CENTER, pageY: CENTER + THUMB_RADIUS}); + expect(onChangeSpy).toHaveBeenCalledTimes(0); + expect(document.activeElement).not.toBe(sliders[0]); + + move(container, {pageX: CENTER - THUMB_RADIUS, pageY: CENTER}); + expect(onChangeSpy).toHaveBeenCalledTimes(0); + expect(document.activeElement).not.toBe(sliders[0]); + + end(container, {pageX: CENTER - THUMB_RADIUS, pageY: CENTER}); + expect(onChangeSpy).toHaveBeenCalledTimes(0); + expect(document.activeElement).not.toBe(sliders[0]); + }); + + it('clicking and dragging on the track respects the step', () => { + let defaultColor = parseColor('#ff00ff'); + let {getAllByRole} = render( + + ); + let groups = getAllByRole('group'); + let container = groups[groupIndex]; + container.getBoundingClientRect = getBoundingClientRect; + + start(container, {pageX: CENTER + THUMB_RADIUS, pageY: CENTER}); + expect(onChangeSpy).toHaveBeenCalledTimes(1); + + move(container, {pageX: CENTER, pageY: CENTER + THUMB_RADIUS}); + expect(onChangeSpy).toHaveBeenCalledTimes(2); + expect(onChangeSpy.mock.calls[0][0].toString('rgba')).toBe(parseColor('#ff80f0').toString('rgba')); + + end(container, {pageX: CENTER, pageY: CENTER + THUMB_RADIUS}); + expect(onChangeSpy).toHaveBeenCalledTimes(2); + }); + }); + }); + }); + describe('defaults uncontrolled', () => { + it('sets input props', () => { + let {getAllByRole} = render(); + let sliders = getAllByRole('slider'); + + let [xSlider, ySlider] = sliders; + + expect(xSlider).toHaveAttribute('type', 'range'); + expect(xSlider).toHaveAttribute('aria-label', 'Blue / Green'); + expect(xSlider).toHaveAttribute('min', '0'); + expect(xSlider).toHaveAttribute('max', '255'); + expect(xSlider).toHaveAttribute('step', '1'); + expect(xSlider).toHaveAttribute('aria-valuetext', 'Blue: 255, Green: 255'); + + expect(ySlider).toHaveAttribute('type', 'range'); + expect(ySlider).toHaveAttribute('aria-label', 'Blue / Green'); + expect(ySlider).toHaveAttribute('min', '0'); + expect(ySlider).toHaveAttribute('max', '255'); + expect(ySlider).toHaveAttribute('step', '1'); + expect(ySlider).toHaveAttribute('aria-valuetext', 'Green: 255, Blue: 255'); + }); + + it('the slider is focusable', () => { + let {getAllByRole} = render(
+ + + +
); + let sliders = getAllByRole('slider'); + let [buttonA, buttonB] = getAllByRole('button'); + + userEvent.tab(); + expect(document.activeElement).toBe(buttonA); + userEvent.tab(); + expect(document.activeElement).toBe(sliders[0]); + userEvent.tab(); + expect(document.activeElement).toBe(buttonB); + userEvent.tab({shift: true}); + expect(document.activeElement).toBe(sliders[0]); + }); + }); + describe('full implementation controlled', () => { + it('sets input props', () => { + let {getAllByRole, getByLabelText} = render(); + let sliders = getAllByRole('slider'); + + expect(sliders.length).toBe(3); + let [xSlider, ySlider, zSlider] = sliders; + + expect(xSlider).toHaveAttribute('type', 'range'); + expect(xSlider).toHaveAttribute('aria-label', 'Blue / Green'); + expect(xSlider).toHaveAttribute('min', '0'); + expect(xSlider).toHaveAttribute('max', '255'); + expect(xSlider).toHaveAttribute('step', '1'); + expect(xSlider).toHaveAttribute('aria-valuetext', 'Blue: 255, Green: 0'); + + expect(ySlider).toHaveAttribute('type', 'range'); + expect(ySlider).toHaveAttribute('aria-label', 'Blue / Green'); + expect(ySlider).toHaveAttribute('min', '0'); + expect(ySlider).toHaveAttribute('max', '255'); + expect(ySlider).toHaveAttribute('step', '1'); + expect(ySlider).toHaveAttribute('aria-valuetext', 'Green: 0, Blue: 255'); + + let redSlider = getByLabelText('Red', {selector: 'input'}); + expect(zSlider).toHaveAttribute('type', 'range'); + expect(zSlider).not.toHaveAttribute('aria-label'); + expect(zSlider).toBe(redSlider); + expect(zSlider).toHaveAttribute('min', '0'); + expect(zSlider).toHaveAttribute('max', '255'); + expect(zSlider).toHaveAttribute('step', '1'); + expect(zSlider).toHaveAttribute('aria-valuetext', '255'); + }); + + it('the slider is focusable', () => { + let {getAllByRole} = render(
+ + + +
); + let sliders = getAllByRole('slider'); + let [buttonA, buttonB] = getAllByRole('button'); + + userEvent.tab(); + expect(document.activeElement).toBe(buttonA); + userEvent.tab(); + expect(document.activeElement).toBe(sliders[0]); + userEvent.tab(); + expect(document.activeElement).toBe(sliders[2]); + userEvent.tab(); + expect(document.activeElement).toBe(buttonB); + userEvent.tab({shift: true}); + expect(document.activeElement).toBe(sliders[2]); + }); + }); +}); diff --git a/packages/@react-spectrum/color/test/ColorWheel.test.tsx b/packages/@react-spectrum/color/test/ColorWheel.test.tsx index 86194eb88be..abf317f6766 100644 --- a/packages/@react-spectrum/color/test/ColorWheel.test.tsx +++ b/packages/@react-spectrum/color/test/ColorWheel.test.tsx @@ -34,11 +34,6 @@ describe('ColorWheel', () => { let onChangeSpy = jest.fn(); let onChangeEndSpy = jest.fn(); - afterEach(() => { - onChangeSpy.mockClear(); - onChangeEndSpy.mockClear(); - }); - beforeAll(() => { jest.spyOn(window.HTMLElement.prototype, 'offsetWidth', 'get').mockImplementation(() => SIZE); // @ts-ignore @@ -50,6 +45,8 @@ describe('ColorWheel', () => { afterEach(() => { // for restoreTextSelection jest.runAllTimers(); + onChangeSpy.mockClear(); + onChangeEndSpy.mockClear(); }); it('sets input props', () => { @@ -163,16 +160,60 @@ describe('ColorWheel', () => { it('respects step', () => { let defaultColor = parseColor('hsl(0, 100%, 50%)'); - let {getByRole} = render(); + let {getByRole} = render(); let slider = getByRole('slider'); act(() => {slider.focus();}); fireEvent.keyDown(slider, {key: 'Right'}); expect(onChangeSpy).toHaveBeenCalledTimes(1); expect(onChangeSpy.mock.calls[0][0].toString('hsla')).toBe(defaultColor.withChannelValue('hue', 45).toString('hsla')); + expect(onChangeEndSpy).toHaveBeenCalledTimes(1); + expect(onChangeEndSpy.mock.calls[0][0].toString('hsla')).toBe(defaultColor.withChannelValue('hue', 45).toString('hsla')); fireEvent.keyDown(slider, {key: 'Left'}); expect(onChangeSpy).toHaveBeenCalledTimes(2); expect(onChangeSpy.mock.calls[1][0].toString('hsla')).toBe(defaultColor.withChannelValue('hue', 0).toString('hsla')); + expect(onChangeEndSpy).toHaveBeenCalledTimes(2); + expect(onChangeEndSpy.mock.calls[1][0].toString('hsla')).toBe(defaultColor.withChannelValue('hue', 0).toString('hsla')); + }); + + it('respects page steps', () => { + let defaultColor = parseColor('hsl(0, 100%, 50%)'); + let {getByRole} = render(); + let slider = getByRole('slider'); + act(() => {slider.focus();}); + + fireEvent.keyDown(slider, {key: 'PageUp'}); + fireEvent.keyUp(slider, {key: 'PageUp'}); + expect(onChangeSpy).toHaveBeenCalledTimes(1); + expect(onChangeSpy.mock.calls[0][0].toString('hsla')).toBe(defaultColor.withChannelValue('hue', 6).toString('hsla')); + expect(onChangeEndSpy).toHaveBeenCalledTimes(1); + expect(onChangeEndSpy.mock.calls[0][0].toString('hsla')).toBe(defaultColor.withChannelValue('hue', 6).toString('hsla')); + fireEvent.keyDown(slider, {key: 'PageDown'}); + fireEvent.keyUp(slider, {key: 'PageDown'}); + expect(onChangeSpy).toHaveBeenCalledTimes(2); + expect(onChangeSpy.mock.calls[1][0].toString('hsla')).toBe(defaultColor.withChannelValue('hue', 0).toString('hsla')); + expect(onChangeEndSpy).toHaveBeenCalledTimes(2); + expect(onChangeEndSpy.mock.calls[1][0].toString('hsla')).toBe(defaultColor.withChannelValue('hue', 0).toString('hsla')); + }); + + it('respects page steps from shift arrow', () => { + let defaultColor = parseColor('hsl(0, 100%, 50%)'); + let {getByRole} = render(); + let slider = getByRole('slider'); + act(() => {slider.focus();}); + + fireEvent.keyDown(slider, {key: 'Right', shiftKey: true}); + fireEvent.keyUp(slider, {key: 'Right', shiftKey: true}); + expect(onChangeSpy).toHaveBeenCalledTimes(1); + expect(onChangeSpy.mock.calls[0][0].toString('hsla')).toBe(defaultColor.withChannelValue('hue', 6).toString('hsla')); + expect(onChangeEndSpy).toHaveBeenCalledTimes(1); + expect(onChangeEndSpy.mock.calls[0][0].toString('hsla')).toBe(defaultColor.withChannelValue('hue', 6).toString('hsla')); + fireEvent.keyDown(slider, {key: 'Left', shiftKey: true}); + fireEvent.keyUp(slider, {key: 'Left', shiftKey: true}); + expect(onChangeSpy).toHaveBeenCalledTimes(2); + expect(onChangeSpy.mock.calls[1][0].toString('hsla')).toBe(defaultColor.withChannelValue('hue', 0).toString('hsla')); + expect(onChangeEndSpy).toHaveBeenCalledTimes(2); + expect(onChangeEndSpy.mock.calls[1][0].toString('hsla')).toBe(defaultColor.withChannelValue('hue', 0).toString('hsla')); }); }); diff --git a/packages/@react-stately/color/package.json b/packages/@react-stately/color/package.json index 16fa1f82d86..c81f120784f 100644 --- a/packages/@react-stately/color/package.json +++ b/packages/@react-stately/color/package.json @@ -18,12 +18,14 @@ }, "dependencies": { "@babel/runtime": "^7.6.2", - "@react-stately/slider": "^3.0.5", - "@react-stately/utils": "^3.4.1", + "@internationalized/message": "^3.0.2", + "@internationalized/number": "^3.0.3", + "@react-aria/utils": "^3.9.0", + "@react-stately/slider": "^3.0.3", + "@react-stately/utils": "^3.3.0", "@react-types/color": "3.0.0-beta.5", - "@react-types/numberfield": "^3.1.2", - "@internationalized/message": "^3.0.5", - "@internationalized/number": "^3.0.5" + "@react-types/numberfield": "^3.1.0", + "@react-types/shared": "^3.9.0" }, "peerDependencies": { "react": "^16.8.0" diff --git a/packages/@react-stately/color/src/Color.ts b/packages/@react-stately/color/src/Color.ts index 0ce2f435367..cf01ce86fff 100644 --- a/packages/@react-stately/color/src/Color.ts +++ b/packages/@react-stately/color/src/Color.ts @@ -29,6 +29,14 @@ export function parseColor(value: string): IColor { throw new Error('Invalid color value: ' + value); } +export function normalizeColor(v: string | IColor) { + if (typeof v === 'string') { + return parseColor(v); + } else { + return v; + } +} + abstract class Color implements IColor { abstract toFormat(format: ColorFormat): IColor; abstract toString(format: ColorFormat | 'css'): string; @@ -229,9 +237,9 @@ class RGBColor extends Color { case 'red': case 'green': case 'blue': - return {minValue: 0, maxValue: 255, step: 1}; + return {minValue: 0, maxValue: 255, step: 1, pageSize: 0x10}; case 'alpha': - return {minValue: 0, maxValue: 1, step: 0.01}; + return {minValue: 0, maxValue: 1, step: 0.01, pageSize: 0.1}; default: throw new Error('Unknown color channel: ' + channel); } @@ -356,12 +364,12 @@ class HSBColor extends Color { getChannelRange(channel: ColorChannel): ColorChannelRange { switch (channel) { case 'hue': - return {minValue: 0, maxValue: 360, step: 1}; + return {minValue: 0, maxValue: 360, step: 1, pageSize: 15}; case 'saturation': case 'brightness': - return {minValue: 0, maxValue: 100, step: 1}; + return {minValue: 0, maxValue: 100, step: 1, pageSize: 10}; case 'alpha': - return {minValue: 0, maxValue: 1, step: 0.01}; + return {minValue: 0, maxValue: 1, step: 0.01, pageSize: 0.1}; default: throw new Error('Unknown color channel: ' + channel); } @@ -491,12 +499,12 @@ class HSLColor extends Color { getChannelRange(channel: ColorChannel): ColorChannelRange { switch (channel) { case 'hue': - return {minValue: 0, maxValue: 360, step: 1}; + return {minValue: 0, maxValue: 360, step: 1, pageSize: 15}; case 'saturation': case 'lightness': - return {minValue: 0, maxValue: 100, step: 1}; + return {minValue: 0, maxValue: 100, step: 1, pageSize: 10}; case 'alpha': - return {minValue: 0, maxValue: 1, step: 0.01}; + return {minValue: 0, maxValue: 1, step: 0.01, pageSize: 0.1}; default: throw new Error('Unknown color channel: ' + channel); } diff --git a/packages/@react-stately/color/src/index.ts b/packages/@react-stately/color/src/index.ts index a8336f72515..0721490e23f 100644 --- a/packages/@react-stately/color/src/index.ts +++ b/packages/@react-stately/color/src/index.ts @@ -11,6 +11,7 @@ */ export * from './Color'; +export * from './useColorAreaState'; export * from './useColorSliderState'; export * from './useColorWheelState'; export * from './useColorFieldState'; diff --git a/packages/@react-stately/color/src/useColorAreaState.ts b/packages/@react-stately/color/src/useColorAreaState.ts new file mode 100644 index 00000000000..1a82f98ea73 --- /dev/null +++ b/packages/@react-stately/color/src/useColorAreaState.ts @@ -0,0 +1,223 @@ +/* + * Copyright 2020 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import {clamp, snapValueToStep} from '@react-aria/utils'; +import {Color, ColorAreaProps, ColorChannel} from '@react-types/color'; +import {normalizeColor, parseColor} from './Color'; +import {useControlledState} from '@react-stately/utils'; +import {useMemo, useRef, useState} from 'react'; + +export interface ColorAreaState { + /** The current color value displayed by the color area. */ + readonly value: Color, + /** Sets the current color value. If a string is passed, it will be parsed to a Color. */ + setValue(value: string | Color): void, + + /** The current value of the horizontal axis channel displayed by the color area. */ + xValue: number, + /** Sets the value for the horizontal axis channel displayed by the color area, and triggers `onChange`. */ + setXValue(value: number): void, + + /** The current value of the vertical axis channel displayed by the color area. */ + yValue: number, + /** Sets the value for the vertical axis channel displayed by the color area, and triggers `onChange`. */ + setYValue(value: number): void, + + /** Sets the x and y channels of the current color value based on a percentage of the width and height of the color area, and triggers `onChange`. */ + setColorFromPoint(x: number, y: number): void, + /** Returns the coordinates of the thumb relative to the upper left corner of the color area as a percentage. */ + getThumbPosition(): {x: number, y: number}, + + /** Increments the value of the horizontal axis channel by the channel step or page amount. */ + incrementX(stepSize?: number): void, + /** Decrements the value of the horizontal axis channel by the channel step or page amount. */ + decrementX(stepSize?: number): void, + + /** Increments the value of the vertical axis channel by the channel step or page amount. */ + incrementY(stepSize?: number): void, + /** Decrements the value of the vertical axis channel by the channel step or page amount. */ + decrementY(stepSize?: number): void, + + /** Whether the color area is currently being dragged. */ + readonly isDragging: boolean, + /** Sets whether the color area is being dragged. */ + setDragging(value: boolean): void, + + /** Returns the xChannel, yChannel and zChannel names based on the color value. */ + channels: {xChannel: ColorChannel, yChannel: ColorChannel, zChannel: ColorChannel}, + xChannelStep: number, + yChannelStep: number, + xChannelPageStep: number, + yChannelPageStep: number, + + /** Returns the color that should be displayed in the color area thumb instead of `value`. */ + getDisplayColor(): Color +} + +const DEFAULT_COLOR = parseColor('#ffffff'); +const RGBSet: Set = new Set(['red', 'green', 'blue']); +let difference = (a: Set, b: Set): Set => new Set([...a].filter(x => !b.has(x))); + +/** + * Provides state management for a color area component. + * Color area allows users to adjust two channels of an HSL, HSB or RGB color value against a two-dimensional gradient background. + */ +export function useColorAreaState(props: ColorAreaProps): ColorAreaState { + // TODO: docs say the step props should be one, but should it be two different values? + let {value, defaultValue, xChannel, yChannel, onChange, onChangeEnd, xChannelStep, yChannelStep} = props; + + if (!value && !defaultValue) { + defaultValue = DEFAULT_COLOR; + } + + let [color, setColor] = useControlledState(value && normalizeColor(value), defaultValue && normalizeColor(defaultValue), onChange); + let valueRef = useRef(color); + valueRef.current = color; + + let channels = useMemo(() => { + if (!xChannel) { + switch (yChannel) { + case 'red': + case 'green': + xChannel = 'blue'; + break; + case 'blue': + xChannel = 'red'; + break; + default: + xChannel = 'blue'; + yChannel = 'green'; + } + } else if (!yChannel) { + switch (xChannel) { + case 'red': + yChannel = 'green'; + break; + case 'blue': + yChannel = 'red'; + break; + default: + xChannel = 'blue'; + yChannel = 'green'; + } + } + let xyChannels: Set = new Set([xChannel, yChannel]); + let zChannel = difference(RGBSet, xyChannels).values().next().value; + + return {xChannel, yChannel, zChannel}; + }, [xChannel, yChannel]); + + let xChannelRange = color.getChannelRange(channels.xChannel); + let yChannelRange = color.getChannelRange(channels.yChannel); + let {minValue: minValueX, maxValue: maxValueX, step: stepX, pageSize: pageSizeX} = xChannelRange; + let {minValue: minValueY, maxValue: maxValueY, step: stepY, pageSize: pageSizeY} = yChannelRange; + + if (isNaN(xChannelStep)) { + xChannelStep = stepX; + } + + if (isNaN(yChannelStep)) { + yChannelStep = stepY; + } + + let xChannelPageStep = Math.max(pageSizeX, xChannelStep); + let yChannelPageStep = Math.max(pageSizeY, yChannelStep); + + let [isDragging, setDragging] = useState(false); + let isDraggingRef = useRef(false).current; + + let xValue = color.getChannelValue(channels.xChannel); + let yValue = color.getChannelValue(channels.yChannel); + let setXValue = (v: number) => { + if (v === xValue) { + return; + } + valueRef.current = color.withChannelValue(channels.xChannel, v); + setColor(valueRef.current); + }; + let setYValue = (v: number) => { + if (v === yValue) { + return; + } + valueRef.current = color.withChannelValue(channels.yChannel, v); + setColor(valueRef.current); + }; + + return { + channels, + xChannelStep, + yChannelStep, + xChannelPageStep, + yChannelPageStep, + value: color, + setValue(value) { + let c = normalizeColor(value); + valueRef.current = c; + setColor(c); + }, + xValue, + setXValue, + yValue, + setYValue, + setColorFromPoint(x: number, y: number) { + let {minValue: minValueX, maxValue: maxValueX} = color.getChannelRange(channels.xChannel); + let {minValue: minValueY, maxValue: maxValueY} = color.getChannelRange(channels.yChannel); + let newXValue = minValueX + clamp(x, 0, 1) * (maxValueX - minValueX); + let newYValue = minValueY + (1 - clamp(y, 0, 1)) * (maxValueY - minValueY); + let newColor:Color; + if (newXValue !== xValue) { + // Round new value to multiple of step, clamp value between min and max + newXValue = snapValueToStep(newXValue, minValueX, maxValueX, xChannelStep); + newColor = color.withChannelValue(channels.xChannel, newXValue); + } + if (newYValue !== yValue) { + // Round new value to multiple of step, clamp value between min and max + newYValue = snapValueToStep(newYValue, minValueY, maxValueY, yChannelStep); + newColor = (newColor || color).withChannelValue(channels.yChannel, newYValue); + } + if (newColor) { + setColor(newColor); + } + }, + getThumbPosition() { + let x = (xValue - minValueX) / (maxValueX - minValueX); + let y = 1 - (yValue - minValueY) / (maxValueY - minValueY); + return {x, y}; + }, + incrementX(stepSize) { + setXValue(snapValueToStep(xValue + stepSize, minValueX, maxValueX, stepSize)); + }, + incrementY(stepSize) { + setYValue(snapValueToStep(yValue + stepSize, minValueY, maxValueY, stepSize)); + }, + decrementX(stepSize) { + setXValue(snapValueToStep(xValue - stepSize, minValueX, maxValueX, stepSize)); + }, + decrementY(stepSize) { + setYValue(snapValueToStep(yValue - stepSize, minValueY, maxValueY, stepSize)); + }, + setDragging(isDragging) { + let wasDragging = isDraggingRef; + isDraggingRef = isDragging; + + if (onChangeEnd && !isDragging && wasDragging) { + onChangeEnd(valueRef.current); + } + + setDragging(isDragging); + }, + isDragging, + getDisplayColor() { + return color.withChannelValue('alpha', 1); + } + }; +} diff --git a/packages/@react-stately/color/src/useColorSliderState.ts b/packages/@react-stately/color/src/useColorSliderState.ts index 3f1f427fcc7..d6ce9e8ccd0 100644 --- a/packages/@react-stately/color/src/useColorSliderState.ts +++ b/packages/@react-stately/color/src/useColorSliderState.ts @@ -11,7 +11,7 @@ */ import {Color, ColorSliderProps} from '@react-types/color'; -import {parseColor} from './Color'; +import {normalizeColor, parseColor} from './Color'; import {SliderState, useSliderState} from '@react-stately/slider'; import {useControlledState} from '@react-stately/utils'; @@ -30,14 +30,6 @@ interface ColorSliderStateOptions extends ColorSliderProps { locale: string } -function normalizeColor(v: string | Color) { - if (typeof v === 'string') { - return parseColor(v); - } else { - return v; - } -} - /** * Provides state management for a color slider component. * Color sliders allow users to adjust an individual channel of a color value. diff --git a/packages/@react-stately/color/src/useColorWheelState.ts b/packages/@react-stately/color/src/useColorWheelState.ts index ef6d07b03b3..a43167f58c8 100644 --- a/packages/@react-stately/color/src/useColorWheelState.ts +++ b/packages/@react-stately/color/src/useColorWheelState.ts @@ -11,7 +11,7 @@ */ import {Color, ColorWheelProps} from '@react-types/color'; -import {parseColor} from './Color'; +import {normalizeColor, parseColor} from './Color'; import {useControlledState} from '@react-stately/utils'; import {useRef, useState} from 'react'; @@ -32,24 +32,18 @@ export interface ColorWheelState { getThumbPosition(radius: number): {x: number, y: number}, /** Increments the hue by the given amount (defaults to 1). */ - increment(minStepSize?: number): void, + increment(stepSize?: number): void, /** Decrements the hue by the given amount (defaults to 1). */ - decrement(minStepSize?: number): void, + decrement(stepSize?: number): void, /** Whether the color wheel is currently being dragged. */ readonly isDragging: boolean, /** Sets whether the color wheel is being dragged. */ setDragging(value: boolean): void, /** Returns the color that should be displayed in the color wheel instead of `value`. */ - getDisplayColor(): Color -} - -function normalizeColor(v: string | Color) { - if (typeof v === 'string') { - return parseColor(v); - } else { - return v; - } + getDisplayColor(): Color, + step: number, + pageStep: number } const DEFAULT_COLOR = parseColor('hsl(0, 100%, 50%)'); @@ -91,6 +85,7 @@ function cartesianToAngle(x: number, y: number, radius: number): number { let deg = radToDeg(Math.atan2(y / radius, x / radius)); return (deg + 360) % 360; } +const PAGE_MIN_STEP_SIZE = 6; /** * Provides state management for a color wheel component. @@ -124,8 +119,11 @@ export function useColorWheelState(props: ColorWheelProps): ColorWheelState { } } + let pageStep = PAGE_MIN_STEP_SIZE; return { value, + step, + pageStep, setValue(v) { let color = normalizeColor(v); valueRef.current = color; @@ -139,16 +137,16 @@ export function useColorWheelState(props: ColorWheelProps): ColorWheelState { getThumbPosition(radius) { return angleToCartesian(value.getChannelValue('hue'), radius); }, - increment(minStepSize: number = 0) { - let newValue = hue + Math.max(minStepSize, step); + increment(stepSize) { + let newValue = hue + Math.max(stepSize, step); if (newValue > 360) { // Make sure you can always get back to 0. newValue = 0; } setHue(newValue); }, - decrement(minStepSize: number = 0) { - let s = Math.max(minStepSize, step); + decrement(stepSize) { + let s = Math.max(stepSize, step); if (hue === 0) { // We can't just subtract step because this might be the case: // |(previous step) - 0| < step size diff --git a/packages/@react-types/color/src/index.d.ts b/packages/@react-types/color/src/index.d.ts index 44f013081d4..d84e0c1380a 100644 --- a/packages/@react-types/color/src/index.d.ts +++ b/packages/@react-types/color/src/index.d.ts @@ -40,7 +40,9 @@ export interface ColorChannelRange { /** The maximum value of the color channel. */ maxValue: number, /** The step value of the color channel, used when incrementing and decrementing. */ - step: number + step: number, + /** The page step value of the color channel, used when incrementing and decrementing. */ + pageSize: number } /** Represents a color value. */ @@ -134,3 +136,27 @@ export interface SpectrumColorSliderProps extends AriaColorSliderProps, StylePro /** Whether the value label is displayed. True by default if there is a label, false by default if not. */ showValueLabel?: boolean } + +export interface ColorAreaProps extends ValueBase { + /** Color channel for the horizontal axis. */ + xChannel?: ColorChannel, + /** Color channel for the vertical axis. */ + yChannel?: ColorChannel, + /** Whether the ColorArea is disabled. */ + isDisabled?: boolean, + /** Handler that is called when the value changes, as the user drags. */ + onChange?: (value: Color) => void, + /** Handler that is called when the user stops dragging. */ + onChangeEnd?: (value: Color) => void, + /** The step value for the xChannel. */ + xChannelStep?: number, + /** The step value for the yChannel. */ + yChannelStep?: number +} + +export interface AriaColorAreaProps extends ColorAreaProps, DOMProps, AriaLabelingProps {} + +export interface SpectrumColorAreaProps extends AriaColorAreaProps, Omit { + /** Size of the Color Area. */ + size?: DimensionValue +} diff --git a/packages/@react-types/shared/src/events.d.ts b/packages/@react-types/shared/src/events.d.ts index 10940e73358..d5015d28942 100644 --- a/packages/@react-types/shared/src/events.d.ts +++ b/packages/@react-types/shared/src/events.d.ts @@ -133,6 +133,7 @@ export interface MoveMoveEvent extends BaseMoveEvent { deltaX: number, /** The amount moved in the Y direction since the last event. */ deltaY: number + } export interface MoveEndEvent extends BaseMoveEvent {