diff --git a/docs/guides/upgrade-guide.md b/docs/guides/upgrade-guide.md index bcc7bb595b..05c1f36ac6 100644 --- a/docs/guides/upgrade-guide.md +++ b/docs/guides/upgrade-guide.md @@ -266,6 +266,23 @@ type: embed ``` +### ColorPicker + +```js +--- +type: embed +--- + + +``` + ### Flex Gap styling now uses `sharedTokens.legacySpacing`. diff --git a/packages/__docs__/src/App/index.tsx b/packages/__docs__/src/App/index.tsx index a39e59e328..e8c8c9b04f 100644 --- a/packages/__docs__/src/App/index.tsx +++ b/packages/__docs__/src/App/index.tsx @@ -569,9 +569,10 @@ class App extends Component { renderThemeSelect() { const allThemeKeys = Object.keys(this.state.docsData!.themes) - const showRebrandThemes = - this.state.showMinorVersionSelector && - this.state.selectedMinorVersion !== 'v11_6' + // const showRebrandThemes = + // this.state.showMinorVersionSelector && + // this.state.selectedMinorVersion !== 'v11_6' + const showRebrandThemes = true // TODO temp workaround const themeKeys = showRebrandThemes ? allThemeKeys : allThemeKeys.filter((key) => !key.startsWith('rebrand')) diff --git a/packages/ui-color-picker/package.json b/packages/ui-color-picker/package.json index 7c09a01806..b157233e06 100644 --- a/packages/ui-color-picker/package.json +++ b/packages/ui-color-picker/package.json @@ -82,18 +82,18 @@ "default": "./es/exports/a.js" }, "./v11_7": { - "src": "./src/exports/a.ts", - "types": "./types/exports/a.d.ts", - "import": "./es/exports/a.js", - "require": "./lib/exports/a.js", - "default": "./es/exports/a.js" + "src": "./src/exports/b.ts", + "types": "./types/exports/b.d.ts", + "import": "./es/exports/b.js", + "require": "./lib/exports/b.js", + "default": "./es/exports/b.js" }, "./latest": { - "src": "./src/exports/a.ts", - "types": "./types/exports/a.d.ts", - "import": "./es/exports/a.js", - "require": "./lib/exports/a.js", - "default": "./es/exports/a.js" + "src": "./src/exports/b.ts", + "types": "./types/exports/b.d.ts", + "import": "./es/exports/b.js", + "require": "./lib/exports/b.js", + "default": "./es/exports/b.js" } } } diff --git a/packages/ui-color-picker/src/ColorContrast/v1/index.tsx b/packages/ui-color-picker/src/ColorContrast/v1/index.tsx index edfabbe162..23c6d237c9 100644 --- a/packages/ui-color-picker/src/ColorContrast/v1/index.tsx +++ b/packages/ui-color-picker/src/ColorContrast/v1/index.tsx @@ -32,9 +32,9 @@ import { } from '@instructure/ui-color-utils' import { withStyleLegacy as withStyle } from '@instructure/emotion' -import { Text } from '@instructure/ui-text/latest' -import { Heading } from '@instructure/ui-heading/latest' -import { Pill } from '@instructure/ui-pill/latest' +import { Text } from '@instructure/ui-text/v11_6' +import { Heading } from '@instructure/ui-heading/v11_6' +import { Pill } from '@instructure/ui-pill/v11_6' import ColorIndicator from '../../ColorIndicator/v1' @@ -117,7 +117,7 @@ class ColorContrast extends Component { {description}
- + {pass ? successLabel : failureLabel}
diff --git a/packages/ui-color-picker/src/ColorContrast/v2/ColorContrast.test.tsx b/packages/ui-color-picker/src/ColorContrast/v2/ColorContrast.test.tsx new file mode 100644 index 0000000000..1683903924 --- /dev/null +++ b/packages/ui-color-picker/src/ColorContrast/v2/ColorContrast.test.tsx @@ -0,0 +1,263 @@ +/* + * The MIT License (MIT) + * + * Copyright (c) 2015 - present Instructure, Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import { render, screen } from '@testing-library/react' +import { vi } from 'vitest' +import '@testing-library/jest-dom' +import { runAxeCheck } from '@instructure/ui-axe-check' +import { contrast } from '@instructure/ui-color-utils' + +import { ColorContrast } from './' + +const testColors = { + firstColor: '#FF0000', + secondColor: '#FFFF00' +} + +const testLabels = { + label: 'Color Contrast Ratio', + successLabel: 'PASS', + failureLabel: 'FAIL', + normalTextLabel: 'Normal text', + largeTextLabel: 'Large text', + graphicsTextLabel: 'Graphics text', + firstColorLabel: 'Background', + secondColorLabel: 'Foreground' +} + +type ContrastStatus = 'FAIL' | 'PASS' + +describe('', () => { + describe('elementRef prop', () => { + it('should provide ref', async () => { + const elementRef = vi.fn() + const { container } = render( + + ) + + expect(elementRef).toHaveBeenCalledWith(container.firstChild) + }) + }) + + describe('labels are displayed:', () => { + Object.entries(testLabels).forEach(([label, text]) => { + it(label, async () => { + const { container } = render( + + ) + + expect(container).toHaveTextContent(text) + }) + }) + }) + + describe('labelLevel prop', () => { + it('should render label as div when labelLevel is not provided', async () => { + render() + + const heading = screen.queryByRole('heading', { + name: testLabels.label + }) + expect(heading).not.toBeInTheDocument() + + const labelText = screen.getByText(testLabels.label) + expect(labelText).toBeInTheDocument() + expect(labelText.tagName.toLowerCase()).toBe('div') + }) + + it('should render label as Heading when labelLevel is provided', async () => { + render() + + const heading = screen.getByRole('heading', { + name: testLabels.label, + level: 2 + }) + expect(heading).toBeInTheDocument() + }) + + it('should render correct heading level', async () => { + const { rerender } = render( + + ) + + let heading = screen.getByRole('heading', { + name: testLabels.label, + level: 3 + }) + expect(heading).toBeInTheDocument() + + rerender( + + ) + + heading = screen.getByRole('heading', { + name: testLabels.label, + level: 1 + }) + expect(heading).toBeInTheDocument() + }) + }) + + describe('should calculate contrast correctly', () => { + it('on opaque colors', async () => { + const color1 = '#fff' + const color2 = '#088' + + const { container } = render( + + ) + const contrastResult = contrast(color1, color2, 2) + + expect(container).toHaveTextContent(contrastResult + ':1') + }) + + it('on transparent colors', async () => { + const color1 = '#fff' + const color2 = '#00888880' + + const { container } = render( + + ) + + // this is the result of a complicated "blended color" calculation + // in the component, not simple `contrast()` check + expect(container).toHaveTextContent('2:1') + }) + }) + + describe('withoutColorPreview prop', () => { + it('should be false by default, should display preview', async () => { + const { container } = render( + + ) + + const preview = container.querySelector( + "[class$='-colorContrast__colorPreview']" + ) + expect(preview).toBeInTheDocument() + }) + + it('should hide preview', async () => { + const { container } = render( + + ) + + const preview = container.querySelector( + "[class$='-colorContrast__colorPreview']" + ) + expect(preview).not.toBeInTheDocument() + }) + }) + + describe('contrast check', () => { + const checkContrastPills = ( + title: string, + firstColor: string, + secondColor: string, + expectedResult: { + normal: ContrastStatus + large: ContrastStatus + graphics: ContrastStatus + } + ) => { + describe(title, () => { + it(`normal text should ${expectedResult.normal.toLowerCase()}`, async () => { + const { container } = render( + + ) + + expect(container).toHaveTextContent(expectedResult.normal) + }) + + it(`large text should ${expectedResult.large.toLowerCase()}`, async () => { + const { container } = render( + + ) + + expect(container).toHaveTextContent(expectedResult.large) + }) + + it(`graphics should ${expectedResult.graphics.toLowerCase()}`, async () => { + const { container } = render( + + ) + expect(container).toHaveTextContent(expectedResult.graphics) + }) + }) + } + + checkContrastPills('on x < 3 contrast', '#fff', '#aaa', { + normal: 'FAIL', + large: 'FAIL', + graphics: 'FAIL' + }) + + checkContrastPills('on small 3 < x < 4.5 contrast', '#fff', '#0c89bf', { + normal: 'FAIL', + large: 'PASS', + graphics: 'PASS' + }) + + checkContrastPills('on small x > 4.5 contrast', '#fff', '#333', { + normal: 'PASS', + large: 'PASS', + graphics: 'PASS' + }) + }) + + describe('should be accessible', () => { + it('a11y', async () => { + const { container } = render( + + ) + const axeCheck = await runAxeCheck(container) + + expect(axeCheck).toBe(true) + }) + }) +}) diff --git a/packages/ui-color-picker/src/ColorContrast/v2/README.md b/packages/ui-color-picker/src/ColorContrast/v2/README.md new file mode 100644 index 0000000000..d56dc8a806 --- /dev/null +++ b/packages/ui-color-picker/src/ColorContrast/v2/README.md @@ -0,0 +1,104 @@ +--- +describes: ColorContrast +--- + +A component for displaying color contrast between two colors. It will perform checks according to the [WCAG 2.1 standard](https://webaim.org/articles/contrast/#ratio), determining if a given contrast ratio is acceptable for `normal`, `large` or `graphics` texts. `normal` needs to be `4.5`, the other two `3`. + +### Color Contrast + +```js +--- +type: example +--- + +``` + +### In-line Color setting + +```js +--- +type: example +--- + const Example = () => { + const [selectedForeGround, setSelectedForeGround] = useState('#0CBF94') + const [selectedBackGround, setSelectedBackGround] = useState('#35423A') + const [validationLevel, setValidationLevel] = useState('AA') + + return ( +
+ setValidationLevel(value)} + name="example1" + defaultValue="AA" + description="validationLevel" + > + + + + + setSelectedBackGround(selectedBackGround) + } + /> + + setSelectedForeGround(selectedForeGround) + } + /> +
+ console.log(contrastData)} + /> +
+ ) + } + + render() +``` diff --git a/packages/ui-color-picker/src/ColorContrast/v1/__tests__/ColorContrast.test.tsx b/packages/ui-color-picker/src/ColorContrast/v2/__tests__/ColorContrast.test.tsx similarity index 100% rename from packages/ui-color-picker/src/ColorContrast/v1/__tests__/ColorContrast.test.tsx rename to packages/ui-color-picker/src/ColorContrast/v2/__tests__/ColorContrast.test.tsx diff --git a/packages/ui-color-picker/src/ColorContrast/v2/index.tsx b/packages/ui-color-picker/src/ColorContrast/v2/index.tsx new file mode 100644 index 0000000000..6a6120c23f --- /dev/null +++ b/packages/ui-color-picker/src/ColorContrast/v2/index.tsx @@ -0,0 +1,236 @@ +/* + * The MIT License (MIT) + * + * Copyright (c) 2015 - present Instructure, Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import { Component } from 'react' + +import { omitProps } from '@instructure/ui-react-utils' +import { error } from '@instructure/console' +import { + contrastWithAlpha, + validateContrast +} from '@instructure/ui-color-utils' +import { withStyle } from '@instructure/emotion' + +import { Text } from '@instructure/ui-text/latest' +import { Heading } from '@instructure/ui-heading/latest' +import { Pill } from '@instructure/ui-pill/latest' + +import ColorIndicator from '../../ColorIndicator/v2' + +import { allowedProps } from './props' +import type { ColorContrastProps, ColorContrastState } from './props' + +import generateStyle from './styles' + +/** +--- +category: components +--- +**/ +@withStyle(generateStyle) +class ColorContrast extends Component { + static allowedProps = allowedProps + static readonly componentId = 'ColorContrast' + + static defaultProps = { + withoutColorPreview: false, + validationLevel: 'AA' + } + + constructor(props: ColorContrastProps) { + super(props) + + this.state = { + contrast: 1, + isValidNormalText: false, + isValidLargeText: false, + isValidGraphicsText: false + } + } + + ref: HTMLDivElement | null = null + + handleRef = (el: HTMLDivElement | null) => { + const { elementRef } = this.props + + this.ref = el + + if (typeof elementRef === 'function') { + elementRef(el) + } + } + + componentDidMount() { + this.props.makeStyles?.() + this.calcState() + } + + componentDidUpdate(prevProps: ColorContrastProps) { + this.props.makeStyles?.() + if ( + prevProps?.firstColor !== this.props?.firstColor || + prevProps?.secondColor !== this.props?.secondColor || + prevProps?.validationLevel !== this.props?.validationLevel + ) { + const newState = this.calcState() + + this.props?.onContrastChange?.({ + contrast: newState.contrast, + isValidNormalText: newState.isValidNormalText, + isValidLargeText: newState.isValidLargeText, + isValidGraphicsText: newState.isValidGraphicsText, + firstColor: this.props.firstColor, + secondColor: this.props.secondColor + }) + } + } + + renderStatus = (pass: boolean, description: string) => { + const { successLabel, failureLabel, styles } = this.props + return ( +
+
+ {description} +
+
+ + {pass ? successLabel : failureLabel} + +
+
+ ) + } + + renderColorIndicator = (color: string, label: string) => ( + <> +
+
+ +
+
+ +
+
{label}
+
{color}
+
+ + ) + + renderPreview() { + const { + styles, + withoutColorPreview, + firstColor, + secondColor, + firstColorLabel, + secondColorLabel + } = this.props + + if (withoutColorPreview) { + return null + } + + if (!firstColorLabel || !secondColorLabel) { + error( + false, + 'When `withoutColorPreview` is not set to true, the properties `firstColorLabel` and `secondColorLabel` are required!' + ) + } + + return ( + !withoutColorPreview && ( +
+
+ {this.renderColorIndicator(firstColor, firstColorLabel || '')} +
+
+ {this.renderColorIndicator(secondColor, secondColorLabel || '')} +
+
+ ) + ) + } + + calcState() { + const contrast = contrastWithAlpha( + this.props.firstColor, + this.props.secondColor + ) + const newState = { + contrast, + ...validateContrast(contrast, this.props.validationLevel) + } + this.setState(newState) + return newState + } + + render() { + const { + styles, + label, + labelLevel, + normalTextLabel, + largeTextLabel, + graphicsTextLabel + } = this.props + + const { + contrast, + isValidNormalText, + isValidLargeText, + isValidGraphicsText + } = this.state + + return ( +
+
+ {labelLevel ? ( + + {label} + + ) : ( + + {label} + + )} +
+ {contrast}:1 + {this.renderPreview()} + {this.renderStatus(isValidNormalText, normalTextLabel)} + {this.renderStatus(isValidLargeText, largeTextLabel)} + {this.renderStatus(isValidGraphicsText, graphicsTextLabel)} +
+ ) + } +} + +export { ColorContrast } +export default ColorContrast diff --git a/packages/ui-color-picker/src/ColorContrast/v2/props.ts b/packages/ui-color-picker/src/ColorContrast/v2/props.ts new file mode 100644 index 0000000000..c8abd09b60 --- /dev/null +++ b/packages/ui-color-picker/src/ColorContrast/v2/props.ts @@ -0,0 +1,181 @@ +/* + * The MIT License (MIT) + * + * Copyright (c) 2015 - present Instructure, Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import React from 'react' +import type { WithStyleProps, ComponentStyle } from '@instructure/emotion' +import type { + OtherHTMLAttributes, + ColorContrastTheme +} from '@instructure/shared-types' + +type HeadingLevel = U + +type ColorContrastOwnProps = { + /** + * Provides a reference to the component's underlying html element. + */ + elementRef?: (element: Element | null) => void + /** + * Text of the failure indicator (Suggested english text: FAIL) + */ + failureLabel: string + /** + * The first color to compare (HEX code) + */ + firstColor: string + /** + * The name of the first color which will be compared + */ + firstColorLabel?: string + /** + * Text of the third check (Suggested english text: Graphics text) + */ + graphicsTextLabel: string + /** + * Label of the component + */ + label: string + /** + * The heading level for the label. If provided, the label will be rendered as a `` instead of ``. + */ + labelLevel?: HeadingLevel<'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6'> + /** + * Text of the second check (Suggested english text: Large text) + */ + largeTextLabel: string + /** + * Text of the first check (Suggested english text: Normal text) + */ + normalTextLabel: string + /** + * The second color to compare (HEX code) + */ + secondColor: string + /** + * The name of the second color which will be compared + */ + secondColorLabel?: string + /** + * Text of the success indicator (Suggested english text: PASS) + */ + successLabel: string + /** + * Toggles the color preview part of the component. + * + * If true, firstColorLabel and secondColorLabel is not necessary. + * Otherwise, it is required. + */ + withoutColorPreview?: boolean + /** + * Triggers a callback whenever the contrast changes, due to a changing color input. + * Communicates the contrast and the success/fail state of the contrast, depending on + * the situation: + * + * isValidNormalText true if at least 4.5:1 + * + * isValidLargeText true if at least 3:1 + * + * isValidGraphicsText true if at least 3:1 + */ + onContrastChange?: (conrastData: { + contrast: number + isValidNormalText: boolean + isValidLargeText: boolean + isValidGraphicsText: boolean + firstColor: string + secondColor: string + }) => null + /** + * According to WCAG 2.2 + * + * AA level (https://www.w3.org/TR/WCAG22/#contrast-minimum) + * + * text: 4.5:1 + * + * large text: 3:1 + * + * non-text: 3:1 (https://www.w3.org/TR/WCAG22/#non-text-contrast) + * + * + * AAA level (https://www.w3.org/TR/WCAG22/#contrast-enhanced) + * + * text: 7:1 + * + * large text: 4.5:1 + * + * non-text: 3:1 (https://www.w3.org/TR/WCAG22/#non-text-contrast) + */ + validationLevel?: 'AA' | 'AAA' +} + +type PropKeys = keyof ColorContrastOwnProps + +type AllowedPropKeys = Readonly> + +type ColorContrastProps = ColorContrastOwnProps & + WithStyleProps & + OtherHTMLAttributes + +type ColorContrastStyle = ComponentStyle< + | 'colorContrast' + | 'successDescription' + | 'failureDescription' + | 'statusWrapper' + | 'colorIndicator' + | 'statusIndicatorWrapper' + | 'colorIndicatorLabel' + | 'pickedColorHex' + | 'colorPreview' + | 'firstColorPreview' + | 'secondColorPreview' + | 'label' + | 'onContrastChange' + | 'validationLevel' +> +const allowedProps: AllowedPropKeys = [ + 'elementRef', + 'failureLabel', + 'firstColor', + 'firstColorLabel', + 'graphicsTextLabel', + 'withoutColorPreview', + 'label', + 'labelLevel', + 'largeTextLabel', + 'normalTextLabel', + 'secondColor', + 'secondColorLabel', + 'successLabel', + 'onContrastChange', + 'validationLevel' +] + +type ColorContrastState = { + contrast: number + isValidNormalText: boolean + isValidLargeText: boolean + isValidGraphicsText: boolean +} +export type { ColorContrastProps, ColorContrastStyle, ColorContrastState } +export { allowedProps } diff --git a/packages/ui-color-picker/src/ColorContrast/v2/styles.ts b/packages/ui-color-picker/src/ColorContrast/v2/styles.ts new file mode 100644 index 0000000000..fc85bce39c --- /dev/null +++ b/packages/ui-color-picker/src/ColorContrast/v2/styles.ts @@ -0,0 +1,108 @@ +/* + * The MIT License (MIT) + * + * Copyright (c) 2015 - present Instructure, Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import type { NewComponentTypes } from '@instructure/ui-themes' +import type { ColorContrastProps } from './props' +/** + * --- + * private: true + * --- + * Generates the style object from the theme and provided additional information + * @param {Object} componentTheme The theme variable object. + * @param {Object} props the props of the component, the style is applied to + * @param {Object} state the state of the component, the style is applied to + * @return {Object} The final style object, which will be used in the component + */ +const generateStyle = ( + componentTheme: NewComponentTypes['ColorContrast'], + props: ColorContrastProps +) => { + const statusDescriptionStyle = (pass: boolean) => ({ + label: pass + ? 'colorContrast__successDescription' + : 'colorContrast__failureDescription', + flex: 1, + color: pass ? componentTheme.successColor : componentTheme.failureColor + }) + + return { + colorContrast: { + label: 'colorContrast', + width: componentTheme.width, + fontFamily: componentTheme.fontFamily, + fontWeight: componentTheme.fontWeight, + lineHeight: componentTheme.lineHeight, + fontSize: componentTheme.fontSize + }, + statusWrapper: { + label: 'colorContrast__statusWrapper', + width: '100%', + display: 'flex', + marginBottom: componentTheme.statusWrapperBottomMargin + }, + successDescription: statusDescriptionStyle(true), + failureDescription: statusDescriptionStyle(false), + colorIndicator: { + marginInlineEnd: componentTheme.colorIndicatorRightMargin + }, + statusIndicatorWrapper: { + label: 'colorContrast__statusIndicatorWrapper', + flex: 1, + display: 'flex', + flexDirection: 'row-reverse' + }, + colorIndicatorLabel: { + label: 'colorContrast__colorIndicatorLabel', + wordBreak: 'break-all', + color: componentTheme.colorIndicatorLabelColor + }, + pickedColorHex: { + label: 'colorContrast__pickedColorHex', + color: componentTheme.pickedHexColor + }, + colorPreview: { + label: 'colorContrast__colorPreview', + display: 'flex', + width: '100%', + marginBottom: componentTheme.colorPreviewBottomMargin, + marginTop: componentTheme.colorPreviewTopMargin + }, + firstColorPreview: { + label: 'colorContrast__firstColorPreview', + display: 'flex', + flex: 1 + }, + secondColorPreview: { + label: 'colorContrast__secondColorPreview', + display: 'flex' + }, + label: { + label: 'colorContrast__label', + marginBottom: componentTheme.labelBottomMargin, + ...(props.labelLevel && { fontWeight: 'bold' }) + } + } +} + +export default generateStyle diff --git a/packages/ui-color-picker/src/ColorIndicator/v2/README.md b/packages/ui-color-picker/src/ColorIndicator/v2/README.md new file mode 100644 index 0000000000..4e55631036 --- /dev/null +++ b/packages/ui-color-picker/src/ColorIndicator/v2/README.md @@ -0,0 +1,76 @@ +--- +describes: ColorIndicator +--- + +A component displaying a circle with checkerboard background capable of displaying colors + +### Color Indicator + +```js +--- +type: example +--- + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +``` + +### Shapes + +`ColorIndicator` can have a `shape` prop. It is either a `circle` which is the default, or a `rectangle` + +```js +--- +type: example +--- + + + + + + + + +``` + +### Color Button Pattern + +```js +--- +type: example +--- + + + +``` diff --git a/packages/ui-color-picker/src/ColorIndicator/v1/__tests__/ColorIndicator.test.tsx b/packages/ui-color-picker/src/ColorIndicator/v2/__tests__/ColorIndicator.test.tsx similarity index 100% rename from packages/ui-color-picker/src/ColorIndicator/v1/__tests__/ColorIndicator.test.tsx rename to packages/ui-color-picker/src/ColorIndicator/v2/__tests__/ColorIndicator.test.tsx diff --git a/packages/ui-color-picker/src/ColorIndicator/v2/index.tsx b/packages/ui-color-picker/src/ColorIndicator/v2/index.tsx new file mode 100644 index 0000000000..469c1d1dfe --- /dev/null +++ b/packages/ui-color-picker/src/ColorIndicator/v2/index.tsx @@ -0,0 +1,84 @@ +/* + * The MIT License (MIT) + * + * Copyright (c) 2015 - present Instructure, Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import { Component } from 'react' + +import { omitProps } from '@instructure/ui-react-utils' +import { withStyle } from '@instructure/emotion' + +import generateStyle from './styles' + +import type { ColorIndicatorProps } from './props' +import { allowedProps } from './props' + +/** +--- +category: components +--- +**/ +@withStyle(generateStyle) +class ColorIndicator extends Component { + static allowedProps = allowedProps + static readonly componentId = 'ColorIndicator' + + static defaultProps = { + shape: 'circle' + } + + ref: HTMLDivElement | null = null + + componentDidMount() { + this.props.makeStyles?.() + } + + componentDidUpdate() { + this.props.makeStyles?.() + } + + handleRef = (el: HTMLDivElement | null) => { + const { elementRef } = this.props + + this.ref = el + + if (typeof elementRef === 'function') { + elementRef(el) + } + } + + render() { + const { styles } = this.props + + return ( +
+ ) + } +} + +export default ColorIndicator +export { ColorIndicator } diff --git a/packages/ui-color-picker/src/ColorIndicator/v2/props.ts b/packages/ui-color-picker/src/ColorIndicator/v2/props.ts new file mode 100644 index 0000000000..d404dae017 --- /dev/null +++ b/packages/ui-color-picker/src/ColorIndicator/v2/props.ts @@ -0,0 +1,58 @@ +/* + * The MIT License (MIT) + * + * Copyright (c) 2015 - present Instructure, Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import type { WithStyleProps, ComponentStyle } from '@instructure/emotion' +import type { + OtherHTMLAttributes, + ColorIndicatorTheme +} from '@instructure/shared-types' + +type ColorIndicatorOwnProps = { + /** + * Valid CSS color string. E.g.: #555, rgba(55,55,55,1). It can accept empty strings + */ + color?: string + /** + * Provides a reference to the `ColorIndicator`'s underlying html element. + */ + elementRef?: (element: Element | null) => void + /** + * Sets the shape of the indicator. Either a circle or a rectangle + */ + shape?: 'circle' | 'rectangle' +} + +type PropKeys = keyof ColorIndicatorOwnProps + +type AllowedPropKeys = Readonly> + +type ColorIndicatorProps = ColorIndicatorOwnProps & + WithStyleProps & + OtherHTMLAttributes + +type ColorIndicatorStyle = ComponentStyle<'colorIndicator'> +const allowedProps: AllowedPropKeys = ['color', 'elementRef', 'shape'] + +export type { ColorIndicatorProps, ColorIndicatorStyle } +export { allowedProps } diff --git a/packages/ui-color-picker/src/ColorIndicator/v2/styles.ts b/packages/ui-color-picker/src/ColorIndicator/v2/styles.ts new file mode 100644 index 0000000000..6b6c4eb931 --- /dev/null +++ b/packages/ui-color-picker/src/ColorIndicator/v2/styles.ts @@ -0,0 +1,89 @@ +/* + * The MIT License (MIT) + * + * Copyright (c) 2015 - present Instructure, Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import type { ColorIndicatorProps, ColorIndicatorStyle } from './props' +import type { NewComponentTypes } from '@instructure/ui-themes' +import type { RGBAType } from '@instructure/ui-color-utils' +import conversions from '@instructure/ui-color-utils' +import { isValid } from '@instructure/ui-color-utils' + +const calcBlendedColor = (c1: RGBAType, c2: RGBAType) => { + // 0.4 as decided by design + const c2Alpha = c2.a * 0.4 + const c1Alpha = 1 - c2Alpha + const alpha = 1 - c1Alpha * (1 - c1Alpha) + + return `rgba( + ${(c2.r * c2Alpha) / alpha + (c1.r * c1Alpha * (1 - c2Alpha)) / alpha}, + ${(c2.g * c2Alpha) / alpha + (c1.g * c1Alpha * (1 - c2Alpha)) / alpha}, + ${(c2.b * c2Alpha) / alpha + (c1.b * c1Alpha * (1 - c2Alpha)) / alpha}, + ${c2.a < 0.6 ? 0.6 : c2.a})` +} +/** + * --- + * private: true + * --- + * Generates the style object from the theme and provided additional information + * @param {Object} componentTheme The theme variable object. + * @param {Object} props the props of the component, the style is applied to + * @param {Object} state the state of the component, the style is applied to + * @return {Object} The final style object, which will be used in the component + */ +const generateStyle = ( + componentTheme: NewComponentTypes['ColorIndicator'], + props: ColorIndicatorProps +): ColorIndicatorStyle => { + const { color, shape } = props + + return { + colorIndicator: { + label: 'colorIndicator', + width: + shape === 'rectangle' + ? componentTheme.rectangleIndicatorSize + : componentTheme.circleIndicatorSize, + height: + shape === 'rectangle' + ? componentTheme.rectangleIndicatorSize + : componentTheme.circleIndicatorSize, + borderRadius: + shape === 'rectangle' + ? componentTheme.rectangularIndicatorBorderRadius + : componentTheme.circleIndicatorSize, + boxSizing: 'border-box', + borderWidth: componentTheme.borderWidth, + boxShadow: color ? `inset 0 0 0 1.5rem ${color}` : 'none', + borderStyle: 'solid', + backgroundImage: componentTheme.backgroundImage, + backgroundSize: componentTheme.backgroundSize, + backgroundPosition: componentTheme.backgroundPosition, + borderColor: calcBlendedColor( + conversions.colorToRGB(componentTheme.colorIndicatorBorderColor), + conversions.colorToRGB(isValid(color!) ? color! : '#fff') + ) + } + } +} + +export default generateStyle diff --git a/packages/ui-color-picker/src/ColorMixer/v1/ColorPalette/index.tsx b/packages/ui-color-picker/src/ColorMixer/v1/ColorPalette/index.tsx index 1a8d7948ee..15f7ed8fb0 100644 --- a/packages/ui-color-picker/src/ColorMixer/v1/ColorPalette/index.tsx +++ b/packages/ui-color-picker/src/ColorMixer/v1/ColorPalette/index.tsx @@ -28,8 +28,8 @@ import { withStyleLegacy as withStyle } from '@instructure/emotion' import { addEventListener } from '@instructure/ui-dom-utils' import type { HSVType } from '@instructure/ui-color-utils' -import { View } from '@instructure/ui-view/latest' -import type { ViewOwnProps } from '@instructure/ui-view/latest' +import { View } from '@instructure/ui-view/v11_6' +import type { ViewOwnProps } from '@instructure/ui-view/v11_6' import { px } from '@instructure/ui-utils' import { withDeterministicId } from '@instructure/ui-react-utils' diff --git a/packages/ui-color-picker/src/ColorMixer/v1/RGBAInput/index.tsx b/packages/ui-color-picker/src/ColorMixer/v1/RGBAInput/index.tsx index d63ce98664..965bf9b53f 100644 --- a/packages/ui-color-picker/src/ColorMixer/v1/RGBAInput/index.tsx +++ b/packages/ui-color-picker/src/ColorMixer/v1/RGBAInput/index.tsx @@ -28,7 +28,7 @@ import { withStyleLegacy as withStyle } from '@instructure/emotion' import shallowCompare from '../utils/shallowCompare' import { ScreenReaderContent } from '@instructure/ui-a11y-content' -import { TextInput } from '@instructure/ui-text-input/latest' +import { TextInput } from '@instructure/ui-text-input/v11_6' import { allowedProps } from './props' import type { RGBAInputProps, RGBAInputState } from './props' diff --git a/packages/ui-color-picker/src/ColorMixer/v1/Slider/index.tsx b/packages/ui-color-picker/src/ColorMixer/v1/Slider/index.tsx index 2bb627b72f..3f888cd202 100644 --- a/packages/ui-color-picker/src/ColorMixer/v1/Slider/index.tsx +++ b/packages/ui-color-picker/src/ColorMixer/v1/Slider/index.tsx @@ -27,8 +27,8 @@ import { Component } from 'react' import { addEventListener } from '@instructure/ui-dom-utils' import { withStyleLegacy as withStyle } from '@instructure/emotion' -import { View } from '@instructure/ui-view/latest' -import type { ViewOwnProps } from '@instructure/ui-view/latest' +import { View } from '@instructure/ui-view/v11_6' +import type { ViewOwnProps } from '@instructure/ui-view/v11_6' import { allowedProps } from './props' import type { SliderProps, SliderStyleProps } from './props' diff --git a/packages/ui-color-picker/src/ColorMixer/v2/ColorPalette/index.tsx b/packages/ui-color-picker/src/ColorMixer/v2/ColorPalette/index.tsx new file mode 100644 index 0000000000..387522bab6 --- /dev/null +++ b/packages/ui-color-picker/src/ColorMixer/v2/ColorPalette/index.tsx @@ -0,0 +1,263 @@ +/* + * The MIT License (MIT) + * + * Copyright (c) 2015 - present Instructure, Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyrigfht notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import { Component } from 'react' + +import { withStyle } from '@instructure/emotion' +import { addEventListener } from '@instructure/ui-dom-utils' +import type { HSVType } from '@instructure/ui-color-utils' + +import { View } from '@instructure/ui-view/latest' +import type { ViewOwnProps } from '@instructure/ui-view/latest' +import { px } from '@instructure/ui-utils' +import { withDeterministicId } from '@instructure/ui-react-utils' + +import shallowCompare from '../utils/shallowCompare' + +import { allowedProps } from './props' +import type { ColorPaletteProps, ColorPaletteState } from './props' + +import generateStyle from './styles' + +/** +--- +private: true +--- +**/ +@withDeterministicId() +@withStyle(generateStyle,'Palette') +class ColorPalette extends Component { + static allowedProps = allowedProps + static readonly componentId = 'ColorMixer.Palette' + + constructor(props: ColorPaletteProps) { + super(props) + this.state = { + colorPosition: { x: 0, y: 0 } + } + this._id = props.deterministicId!('ColorMixer__Palette') + } + private readonly _id: string + ref: Element | null = null + private _paletteRef: HTMLDivElement | null = null + private _mouseMoveListener?: { remove(): void } + private _mouseUpListener?: { remove(): void } + private _paletteOffset = px(this.props.styles?.paletteOffset as string) + + handleRef = (el: Element | null) => { + const { elementRef } = this.props + + this.ref = el + + if (typeof elementRef === 'function') { + elementRef(el) + } + } + + componentDidMount() { + this.props.makeStyles?.(this.state) + this.setState({ + colorPosition: this.calcPositionFromColor(this.props.color) + }) + } + + componentDidUpdate(prevProps: ColorPaletteProps) { + this.props.makeStyles?.(this.state) + + if (shallowCompare(prevProps.color, this.props.color)) { + this.setState({ + colorPosition: this.calcPositionFromColor(this.props.color) + }) + } + } + componentWillUnmount() { + this.removeEventListeners() + } + + get paletteWidth() { + return this._paletteRef!.getBoundingClientRect().width - this._paletteOffset + } + + get paletteHeight() { + return ( + this._paletteRef!.getBoundingClientRect().height - this._paletteOffset + ) + } + calcSaturation = (position: number) => + Math.round((position / this.paletteWidth) * 100) / 100 + calcLuminance = (position: number) => + Math.round(((this.paletteHeight - position) / this.paletteHeight) * 100) / + 100 + + calcPositionFromColor(hsv: HSVType) { + const { s, v } = hsv + const x = s * this.paletteWidth + const y = (1 - v) * this.paletteHeight + return { x, y } + } + + handlePaletteMouseDown(e: React.MouseEvent) { + // Prevent selection outside palette during dragging the indicator + e.preventDefault() + + // Restore focus since preventDefault() blocks automatic focus on mouse event + if (e.currentTarget instanceof HTMLElement) { + e.currentTarget.focus() + } + + this.handleChange(e) + + this._mouseMoveListener = addEventListener( + window, + 'mousemove', + this.handleChange + ) + this._mouseUpListener = addEventListener( + window, + 'mouseup', + this.handleMouseUp + ) + } + + handleMouseUp = () => { + this.removeEventListeners() + } + + removeEventListeners() { + this._mouseMoveListener?.remove() + this._mouseUpListener?.remove() + } + + calcColorPosition(clientX: number, clientY: number) { + const { x, y } = this._paletteRef!.getBoundingClientRect() + + return this.applyBoundaries(clientX - x, clientY - y) + } + + applyBoundaries(x: number, y: number) { + let newXPosition = x + let newYPosition = y + if (x > this.paletteWidth) { + newXPosition = this.paletteWidth + } + if (x < 0) { + newXPosition = 0 + } + if (y > this.paletteHeight) { + newYPosition = this.paletteHeight + } + if (y < 0) { + newYPosition = 0 + } + return { newXPosition, newYPosition } + } + + handleChange = (e: React.MouseEvent) => { + if (this.props.disabled) return + const { clientX, clientY } = e + const { newXPosition, newYPosition } = this.calcColorPosition( + clientX, + clientY + ) + this.setState({ + colorPosition: { x: newXPosition, y: newYPosition } + }) + + this.props.onChange({ + h: this.props.hue, + s: this.calcSaturation(newXPosition), + v: this.calcLuminance(newYPosition) + }) + } + + handleKeyDown(e: React.KeyboardEvent) { + if (this.props.disabled) return + const { key } = e + if (key === 'Tab') return + e.preventDefault() + let deltaX = 0 + let deltaY = 0 + if (key === 'ArrowLeft' || key === 'a') { + deltaX = -2 + } + if (key === 'ArrowRight' || key === 'd') { + deltaX = 2 + } + if (key === 'ArrowUp' || key === 'w') { + deltaY = -2 + } + if (key === 'ArrowDown' || key === 's') { + deltaY = 2 + } + + const { newXPosition, newYPosition } = this.applyBoundaries( + this.state.colorPosition.x + deltaX, + this.state.colorPosition.y + deltaY + ) + + this.setState({ + colorPosition: { x: newXPosition, y: newYPosition } + }) + this.props.onChange({ + h: this.props.hue, + s: this.calcSaturation(newXPosition), + v: this.calcLuminance(newYPosition) + }) + } + + render() { + return ( + this.handleKeyDown(e)} + onMouseDown={(e) => this.handlePaletteMouseDown(e)} + aria-label={this.props.navigationExplanationScreenReaderLabel} + id={this._id} + role="button" + > +
+ {this.props.disabled && ( +
+ )} +
{ + this._paletteRef = ref + }} + /> + + ) + } +} + +export default ColorPalette diff --git a/packages/ui-color-picker/src/ColorMixer/v2/ColorPalette/props.ts b/packages/ui-color-picker/src/ColorMixer/v2/ColorPalette/props.ts new file mode 100644 index 0000000000..4ad7985376 --- /dev/null +++ b/packages/ui-color-picker/src/ColorMixer/v2/ColorPalette/props.ts @@ -0,0 +1,75 @@ +/* + * The MIT License (MIT) + * + * Copyright (c) 2015 - present Instructure, Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import type { WithStyleProps, ComponentStyle } from '@instructure/emotion' + +import type { + OtherHTMLAttributes, + ColorMixerPaletteTheme +} from '@instructure/shared-types' +import type { HSVType } from '@instructure/ui-color-utils' +import type { WithDeterministicIdProps } from '@instructure/ui-react-utils' + +type ColorPaletteOwnProps = { + disabled?: boolean + hue: number + color: HSVType + width: number + height: number + indicatorRadius: number + onChange: (rgb: HSVType) => void + elementRef?: (element: Element | null) => void + navigationExplanationScreenReaderLabel: string +} + +type ColorPaletteState = { + colorPosition: { x: number; y: number } +} + +type PropKeys = keyof ColorPaletteOwnProps + +type AllowedPropKeys = Readonly> + +type ColorPaletteProps = ColorPaletteOwnProps & + WithStyleProps & + OtherHTMLAttributes & + WithDeterministicIdProps + +type ColorPaletteStyle = ComponentStyle< + 'ColorPalette' | 'indicator' | 'palette' | 'disabledOverlay' | 'paletteOffset' +> +const allowedProps: AllowedPropKeys = [ + 'disabled', + 'hue', + 'color', + 'width', + 'height', + 'indicatorRadius', + 'onChange', + 'elementRef', + 'navigationExplanationScreenReaderLabel' +] + +export type { ColorPaletteProps, ColorPaletteState, ColorPaletteStyle } +export { allowedProps } diff --git a/packages/ui-color-picker/src/ColorMixer/v2/ColorPalette/styles.ts b/packages/ui-color-picker/src/ColorMixer/v2/ColorPalette/styles.ts new file mode 100644 index 0000000000..2517c1f8f3 --- /dev/null +++ b/packages/ui-color-picker/src/ColorMixer/v2/ColorPalette/styles.ts @@ -0,0 +1,96 @@ +/* + * The MIT License (MIT) + * + * Copyright (c) 2015 - present Instructure, Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import type { + ColorPaletteStyle, + ColorPaletteProps, + ColorPaletteState +} from './props' +import type { NewComponentTypes, SharedTokens } from '@instructure/ui-themes' +import { px } from '@instructure/ui-utils' + +/** + * --- + * private: true + * --- + * Generates the style object from the theme and provided additional information + * @param {Object} componentTheme The theme variable object. + * @param {Object} props the props of the component, the style is applied to + * @param {Object} state the state of the component, the style is applied to + * @return {Object} The final style object, which will be used in the component + */ +const generateStyle = ( + componentTheme: NewComponentTypes['Palette'], + props: ColorPaletteProps, + _sharedTokens: SharedTokens, + state: ColorPaletteState +): ColorPaletteStyle => { + return { + ColorPalette: { + label: 'ColorPalette' + }, + indicator: { + label: 'ColorPalette__indicator', + width: `${props.indicatorRadius / 8}rem`, + height: `${props.indicatorRadius / 8}rem`, + borderRadius: `${props.indicatorRadius / 8}rem`, + background: componentTheme.whiteColor, + position: 'absolute', + borderStyle: 'solid', + borderWidth: componentTheme.indicatorBorderWidth, + borderColor: componentTheme.indicatorBorderColor, + top: `${ + state?.colorPosition?.y - px(`${props.indicatorRadius / 16}rem`) + }px`, + left: `${ + state?.colorPosition?.x - px(`${props.indicatorRadius / 16}rem`) + }px` + }, + palette: { + label: 'ColorPalette__palette', + width: `${props.width / 16}rem`, + height: `${props.height / 16}rem`, + borderRadius: componentTheme.paletteBorderRadius, + borderStyle: 'solid', + borderWidth: componentTheme.paletteBorderWidth, + boxSizing: 'border-box', + borderColor: componentTheme.colorIndicatorBorderColor, + background: `linear-gradient(to bottom, rgba(0,0,0,0), rgba(0,0,0,1)), + linear-gradient(to right, white, hsl(${props.hue},100%,50%))` + }, + disabledOverlay: { + label: 'ColorPalette__disabledOverlay', + background: 'rgba(255,255,255,.5)', + zIndex: componentTheme.disabledOverlayZIndex, + width: `${props.width / 16 + 1}rem`, + height: `${props.height / 16 + 1}rem`, + position: 'absolute', + top: '-.5rem', + left: '-.5rem' + }, + paletteOffset: componentTheme.paletteOffset + } +} + +export default generateStyle diff --git a/packages/ui-color-picker/src/ColorMixer/v2/README.md b/packages/ui-color-picker/src/ColorMixer/v2/README.md new file mode 100644 index 0000000000..bc7c7aca41 --- /dev/null +++ b/packages/ui-color-picker/src/ColorMixer/v2/README.md @@ -0,0 +1,76 @@ +--- +describes: ColorMixer +--- + +A component for selecting a color. It lets pick from a palette manually or type in an RGBA color. + +### Color Mixer + +```js +--- +type: example +--- + const Example = () => { + const [value, setValue] = useState('#328DCFC2') + + return ( +
+
+ setValue(value)} + rgbRedInputScreenReaderLabel="Input field for red" + rgbGreenInputScreenReaderLabel="Input field for green" + rgbBlueInputScreenReaderLabel="Input field for blue" + rgbAlphaInputScreenReaderLabel="Input field for alpha" + colorSliderNavigationExplanationScreenReaderLabel={`You are on a color slider. To navigate the slider left or right, use the 'A' and 'D' buttons respectively`} + alphaSliderNavigationExplanationScreenReaderLabel={`You are on an alpha slider. To navigate the slider left or right, use the 'A' and 'D' buttons respectively`} + colorPaletteNavigationExplanationScreenReaderLabel={`You are on a color palette. To navigate on the palette up, left, down or right, use the 'W', 'A', 'S' and 'D' buttons respectively`} + /> +
+ {value} +
+
+
+ +
+
+ ) + } + + render() +``` + +### Disabled Color Mixer + +```js +--- +type: example +--- + {}} + rgbRedInputScreenReaderLabel='Input field for red' + rgbGreenInputScreenReaderLabel='Input field for green' + rgbBlueInputScreenReaderLabel='Input field for blue' + rgbAlphaInputScreenReaderLabel='Input field for alpha' + colorSliderNavigationExplanationScreenReaderLabel={`You are on a color slider. To navigate the slider left or right, use the 'A' and 'D' buttons respectively`} + alphaSliderNavigationExplanationScreenReaderLabel={`You are on an alpha slider. To navigate the slider left or right, use the 'A' and 'D' buttons respectively`} + colorPaletteNavigationExplanationScreenReaderLabel={`You are on a color palette. To navigate on the palette up, left, down or right, use the 'W', 'A', 'S' and 'D' buttons respectively`} +/> + +``` diff --git a/packages/ui-color-picker/src/ColorMixer/v2/RGBAInput/index.tsx b/packages/ui-color-picker/src/ColorMixer/v2/RGBAInput/index.tsx new file mode 100644 index 0000000000..e764e613fd --- /dev/null +++ b/packages/ui-color-picker/src/ColorMixer/v2/RGBAInput/index.tsx @@ -0,0 +1,169 @@ +/* + * The MIT License (MIT) + * + * Copyright (c) 2015 - present Instructure, Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import { Component } from 'react' + +import { withStyle } from '@instructure/emotion' +import shallowCompare from '../utils/shallowCompare' + +import { ScreenReaderContent } from '@instructure/ui-a11y-content' +import { TextInput } from '@instructure/ui-text-input/latest' + +import { allowedProps } from './props' +import type { RGBAInputProps, RGBAInputState } from './props' + +import generateStyle from './styles' + +/** +--- +private: true +--- +**/ +@withStyle(generateStyle, 'RgbaInput') +class RGBAInput extends Component { + static allowedProps = allowedProps + static readonly componentId = 'ColorMixer.RGBAInput' + + static defaultProps = { + withAlpha: false + } + + constructor(props: RGBAInputProps) { + super(props) + this.state = { + value: props.value + } + } + + ref: HTMLDivElement | null = null + + handleRef = (el: HTMLDivElement | null) => { + const { elementRef } = this.props + + this.ref = el + + if (typeof elementRef === 'function') { + elementRef(el) + } + } + + componentDidMount() { + this.props.makeStyles?.() + } + + componentDidUpdate(prevProps: RGBAInputProps) { + this.props.makeStyles?.() + if ( + shallowCompare(prevProps.value, this.props.value) || + shallowCompare(this.state.value, this.props.value) + ) { + this.setState({ + value: this.props.value + }) + } + } + + handleChange(type: string, e: React.ChangeEvent) { + const upperLimit = type === 'a' ? 100 : 255 + const newValue = + type === 'a' ? Number(e.target.value) / 100 : Number(e.target.value) + const newColor = { ...this.props.value, [type]: newValue } + + if (!isNaN(Number(newValue)) && newValue >= 0 && newValue <= upperLimit) { + this.setState({ value: newColor }) + this.props.onChange(newColor) + return + } + } + + render() { + const { styles, disabled, label, withAlpha } = this.props + + return ( +
+ {label &&
{label}
} +
+ + this.handleChange('r', e)} + renderLabel={ + + {this.props.rgbRedInputScreenReaderLabel} + + } + /> + + + this.handleChange('g', e)} + renderLabel={ + + {this.props.rgbGreenInputScreenReaderLabel} + + } + /> + + + this.handleChange('b', e)} + renderLabel={ + + {this.props.rgbBlueInputScreenReaderLabel} + + } + /> + + {withAlpha && ( + + this.handleChange('a', e)} + renderAfterInput={ + + } + renderLabel={ + + {this.props.rgbAlphaInputScreenReaderLabel} + + } + /> + + )} +
+
+ ) + } +} + +export default RGBAInput diff --git a/packages/ui-color-picker/src/ColorMixer/v2/RGBAInput/props.ts b/packages/ui-color-picker/src/ColorMixer/v2/RGBAInput/props.ts new file mode 100644 index 0000000000..a815a47fb2 --- /dev/null +++ b/packages/ui-color-picker/src/ColorMixer/v2/RGBAInput/props.ts @@ -0,0 +1,77 @@ +/* + * The MIT License (MIT) + * + * Copyright (c) 2015 - present Instructure, Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import type { WithStyleProps, ComponentStyle } from '@instructure/emotion' + +import type { + OtherHTMLAttributes, + ColorMixerRGBAInputTheme +} from '@instructure/shared-types' +import type { RGBAType } from '@instructure/ui-color-utils' + +type RGBAInputOwnProps = { + disabled?: boolean + label?: string + width: number + value: RGBAType + onChange: (rgba: RGBAType) => void + withAlpha?: boolean + rgbRedInputScreenReaderLabel: string + rgbGreenInputScreenReaderLabel: string + rgbBlueInputScreenReaderLabel: string + rgbAlphaInputScreenReaderLabel: string + elementRef?: (element: Element | null) => void +} + +type RGBAInputState = { + value: RGBAType +} + +type PropKeys = keyof RGBAInputOwnProps + +type AllowedPropKeys = Readonly> + +type RGBAInputProps = RGBAInputOwnProps & + WithStyleProps & + OtherHTMLAttributes + +type RGBAInputStyle = ComponentStyle< + 'RGBAInput' | 'label' | 'inputContainer' | 'rgbInput' | 'aInput' +> +const allowedProps: AllowedPropKeys = [ + 'disabled', + 'label', + 'width', + 'value', + 'onChange', + 'withAlpha', + 'rgbRedInputScreenReaderLabel', + 'rgbGreenInputScreenReaderLabel', + 'rgbBlueInputScreenReaderLabel', + 'rgbAlphaInputScreenReaderLabel', + 'elementRef' +] + +export type { RGBAInputProps, RGBAInputState, RGBAInputStyle } +export { allowedProps } diff --git a/packages/ui-color-picker/src/ColorMixer/v2/RGBAInput/styles.ts b/packages/ui-color-picker/src/ColorMixer/v2/RGBAInput/styles.ts new file mode 100644 index 0000000000..dc3e55d98e --- /dev/null +++ b/packages/ui-color-picker/src/ColorMixer/v2/RGBAInput/styles.ts @@ -0,0 +1,70 @@ +/* + * The MIT License (MIT) + * + * Copyright (c) 2015 - present Instructure, Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import type { RGBAInputStyle, RGBAInputProps } from './props' +import type { NewComponentTypes } from '@instructure/ui-themes' + +/** + * --- + * private: true + * --- + * Generates the style object from the theme and provided additional information + * @param {Object} componentTheme The theme variable object. + * @param {Object} props the props of the component, the style is applied to + * @param {Object} state the state of the component, the style is applied to + * @return {Object} The final style object, which will be used in the component + */ +const generateStyle = ( + componentTheme: NewComponentTypes['RgbaInput'], + props: RGBAInputProps +): RGBAInputStyle => { + return { + RGBAInput: { + label: 'RGBAInput', + marginTop: componentTheme.rgbaInputTopMargin, + width: `${props.width / 16}rem` + }, + label: { + label: 'RGBAInput__label', + fontWeight: componentTheme.labelFontWeight, + color: componentTheme.rgbaInputLabel + }, + inputContainer: { + label: 'RGBAInput__inputContainer', + display: 'flex', + marginTop: componentTheme.inputContainerTopMargin + }, + rgbInput: { + label: 'RGBAInput__rgbInput', + marginInlineEnd: componentTheme.tgbInputTopMargin, + flex: 7 + }, + aInput: { + label: 'RGBAInput__aInput', + flex: 10 + } + } +} + +export default generateStyle diff --git a/packages/ui-color-picker/src/ColorMixer/v2/Slider/index.tsx b/packages/ui-color-picker/src/ColorMixer/v2/Slider/index.tsx new file mode 100644 index 0000000000..1d8c98d14b --- /dev/null +++ b/packages/ui-color-picker/src/ColorMixer/v2/Slider/index.tsx @@ -0,0 +1,245 @@ +/* + * The MIT License (MIT) + * + * Copyright (c) 2015 - present Instructure, Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import { Component } from 'react' + +import { addEventListener } from '@instructure/ui-dom-utils' +import { withStyle } from '@instructure/emotion' + +import { View } from '@instructure/ui-view/latest' +import type { ViewOwnProps } from '@instructure/ui-view/latest' + +import { allowedProps } from './props' +import type { SliderProps, SliderStyleProps } from './props' + +import generateStyle from './styles' + +/** +--- +private: true +--- +**/ +@withStyle(generateStyle, 'Slider') +class Slider extends Component { + static allowedProps = allowedProps + static readonly componentId = 'ColorMixer.Slider' + + static defaultProps = { + isColorSlider: false + } + + ref: Element | null = null + private _sliderRef: HTMLDivElement | null = null + private _mouseMoveListener?: { remove(): void } + private _mouseUpListener?: { remove(): void } + + handleRef = (el: Element | null) => { + const { elementRef } = this.props + + this.ref = el + + if (typeof elementRef === 'function') { + elementRef(el) + } + } + + componentDidMount() { + this.props.makeStyles?.(this.makeStylesProps) + } + + componentDidUpdate() { + this.props.makeStyles?.(this.makeStylesProps) + } + + componentWillUnmount() { + this.removeEventListeners() + } + + removeEventListeners() { + this._mouseMoveListener?.remove() + this._mouseUpListener?.remove() + } + + get sliderPositionFromValue() { + return this.calcSliderPositionFromValue(this.props.value) + } + + get roundedValue() { + const { value, maxValue } = this.props + + if (maxValue <= 1) { + return Math.round(value * 100) + } else { + return Math.round(value) + } + } + + get makeStylesProps(): SliderStyleProps { + return { + sliderPositionFromValue: this.sliderPositionFromValue + } + } + + handleMouseDown(e: React.MouseEvent) { + // Prevent selection outside palette during dragging the indicator + e.preventDefault() + + // Restore focus since preventDefault() blocks automatic focus on mouse event + if (e.currentTarget instanceof HTMLElement) { + e.currentTarget.focus() + } + + this.handleChange(e) + + this._mouseMoveListener = addEventListener( + window, + 'mousemove', + this.handleChange + ) + this._mouseUpListener = addEventListener( + window, + 'mouseup', + this.handleMouseUp + ) + } + + handleChange = (e: React.MouseEvent) => { + if (this.props.disabled) return + const { clientX } = e + const newPosition = this.calcSliderPositionFromCursorPosition( + clientX, + this._sliderRef! + ) + + this.props.onChange(this.calcValueFromSliderPosition(newPosition)) + } + + handleMouseUp = () => { + this.removeEventListeners() + } + + applyBoundaries(x: number) { + if (x > this.props.width) return this.props.width + if (x < 0) return 0 + return x + } + + calcSliderPositionFromCursorPosition = ( + clientX: number, + _sliderRef: HTMLDivElement + ) => { + if (this.props.isColorSlider) { + const { x } = _sliderRef.getBoundingClientRect() + const newPosition = clientX - x + return newPosition < 0 + ? 0 + : newPosition > this.props.width + ? this.props.width - 1 + : newPosition + } else { + const { x } = _sliderRef.getBoundingClientRect() + return clientX - x + } + } + + calcSliderPositionFromValue = (value: number) => { + if (this.props.isColorSlider) { + return (value / 360) * this.props.width + } else { + return this.props.width - (1 - value) * this.props.width + } + } + + calcValueFromSliderPosition = (position: number) => { + if (this.props.isColorSlider) { + return (position / this.props.width) * 360 + } else { + const positionWithBoundaries = + position < 0 + ? 0 + : position > this.props.width + ? this.props.width + : position + return Math.round((positionWithBoundaries * 100) / this.props.width) + } + } + + handleKeyDown(e: React.KeyboardEvent) { + const { key } = e + if (key === 'Tab') return + e.preventDefault() + let deltaX = 0 + if (key === 'ArrowLeft' || key === 'a') { + deltaX = -2 + } + if (key === 'ArrowRight' || key === 'd') { + deltaX = 2 + } + + const newPosition = this.applyBoundaries( + this.sliderPositionFromValue + deltaX + ) + this.props.onChange(this.calcValueFromSliderPosition(newPosition)) + } + + render() { + return ( + this.handleKeyDown(e)} + onMouseDown={(e) => this.handleMouseDown(e)} + tabIndex={this.props.disabled ? undefined : 0} + aria-label={this.props.navigationExplanationScreenReaderLabel} + role="slider" + aria-valuemin={this.props.minValue} + aria-valuemax={this.props.maxValue} + aria-valuenow={this.roundedValue} + > +
+ {this.props.disabled && ( +
+ )} +
{ + this._sliderRef = ref + }} + css={this.props.styles?.sliderBackground} + > +
+
+ + ) + } +} + +export default Slider diff --git a/packages/ui-color-picker/src/ColorMixer/v2/Slider/props.ts b/packages/ui-color-picker/src/ColorMixer/v2/Slider/props.ts new file mode 100644 index 0000000000..0a12bec596 --- /dev/null +++ b/packages/ui-color-picker/src/ColorMixer/v2/Slider/props.ts @@ -0,0 +1,77 @@ +/* + * The MIT License (MIT) + * + * Copyright (c) 2015 - present Instructure, Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import type { WithStyleProps, ComponentStyle } from '@instructure/emotion' +import type { + OtherHTMLAttributes, + ColorMixerSliderTheme +} from '@instructure/shared-types' + +type SliderOwnProps = { + isColorSlider?: boolean + onChange: (position: number) => void + width: number + value: number + minValue: number + maxValue: number + indicatorRadius: number + height: number + elementRef?: (element: Element | null) => void + navigationExplanationScreenReaderLabel: string +} + +type PropKeys = keyof SliderOwnProps + +type AllowedPropKeys = Readonly> + +type SliderProps = SliderOwnProps & + WithStyleProps & + OtherHTMLAttributes + +type SliderStyleProps = { + sliderPositionFromValue: number +} + +type SliderStyle = ComponentStyle< + | 'colorSlider' + | 'indicator' + | 'slider' + | 'sliderBackground' + | 'disabledOverlay' +> +const allowedProps: AllowedPropKeys = [ + 'isColorSlider', + 'onChange', + 'width', + 'value', + 'minValue', + 'maxValue', + 'indicatorRadius', + 'height', + 'elementRef', + 'navigationExplanationScreenReaderLabel' +] + +export type { SliderProps, SliderStyle, SliderStyleProps } +export { allowedProps } diff --git a/packages/ui-color-picker/src/ColorMixer/v2/Slider/styles.ts b/packages/ui-color-picker/src/ColorMixer/v2/Slider/styles.ts new file mode 100644 index 0000000000..c23cac1ccd --- /dev/null +++ b/packages/ui-color-picker/src/ColorMixer/v2/Slider/styles.ts @@ -0,0 +1,115 @@ +/* + * The MIT License (MIT) + * + * Copyright (c) 2015 - present Instructure, Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import type { NewComponentTypes, SharedTokens } from '@instructure/ui-themes' +import type { SliderStyle, SliderProps, SliderStyleProps } from './props' + +/** + * --- + * private: true + * --- + * Generates the style object from the theme and provided additional information + * @param {Object} componentTheme The theme variable object. + * @param {Object} props the props of the component, the style is applied to + * @param {Object} state the state of the component, the style is applied to + * @return {Object} The final style object, which will be used in the component + */ +const generateStyle = ( + componentTheme: NewComponentTypes['Slider'], + props: SliderProps, + _sharedTokens: SharedTokens, + state: SliderStyleProps +): SliderStyle => { + const sliderBackground = props.isColorSlider + ? { + background: + 'linear-gradient(to right, #ff0000 0%, #ffff00 17%, #00ff00 33%, #00ffff 50%, #0000ff 67%, #ff00ff 83%, #ff0000 100%)' + } + : { + background: componentTheme.checkerboardBackgroundImage, + backgroundSize: componentTheme.checkerboardBackgroundSize, + backgroundPosition: componentTheme.checkerboardBackgroundPosition + } + + return { + colorSlider: { + label: 'colorMixerSlider' + }, + indicator: { + label: 'colorMixerSlider__indicator', + width: `${props.indicatorRadius / 8}rem`, + height: `${props.indicatorRadius / 8}rem`, + borderRadius: `${props.indicatorRadius / 8}rem`, + background: 'white', + position: 'absolute', + borderStyle: 'solid', + borderWidth: componentTheme.indicatorBorderWidth, + borderColor: componentTheme.indicatorBorderColor, + top: `-0.1875rem`, + left: `${ + (state?.sliderPositionFromValue - props.indicatorRadius) / 16 + }rem`, + zIndex: componentTheme.indicatorZIndex + }, + + sliderBackground: { + label: 'colorMixerSlider__sliderBackground', + borderRadius: `${props.height / 16}rem`, + width: `${props.width / 16}rem`, + height: `${props.height / 16}rem`, + boxSizing: 'border-box', + ...sliderBackground + }, + slider: { + label: 'colorMixerSlider__slider', + width: `${props.width / 16}rem`, + height: `${props.height / 16}rem`, + background: props.isColorSlider + ? 'transparent' + : `linear-gradient(to right, rgba(255,0,0,0), ${props.color?.slice( + 0, + -2 + )})`, + borderRadius: `${props.height / 16}rem`, + + boxSizing: 'border-box', + + borderStyle: 'solid', + borderColor: componentTheme.colorIndicatorBorderColor, + borderWidth: componentTheme.sliderBorderWidth + }, + disabledOverlay: { + label: 'colorMixerSlider__disabledOverlay', + background: 'rgba(255,255,255,.5)', + zIndex: componentTheme.disabledOverlayZIndex, + width: `${props.width / 16 + 1}rem`, + height: `${props.height / 16 + 1}rem`, + position: 'absolute', + top: '-.5rem', + left: '-.5rem' + } + } +} + +export default generateStyle diff --git a/packages/ui-color-picker/src/ColorMixer/v1/__tests__/ColorMixer.test.tsx b/packages/ui-color-picker/src/ColorMixer/v2/__tests__/ColorMixer.test.tsx similarity index 100% rename from packages/ui-color-picker/src/ColorMixer/v1/__tests__/ColorMixer.test.tsx rename to packages/ui-color-picker/src/ColorMixer/v2/__tests__/ColorMixer.test.tsx diff --git a/packages/ui-color-picker/src/ColorMixer/v2/index.tsx b/packages/ui-color-picker/src/ColorMixer/v2/index.tsx new file mode 100644 index 0000000000..8e57ba6da8 --- /dev/null +++ b/packages/ui-color-picker/src/ColorMixer/v2/index.tsx @@ -0,0 +1,218 @@ +/* + * The MIT License (MIT) + * + * Copyright (c) 2015 - present Instructure, Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import { Component } from 'react' + +import { withStyle } from '@instructure/emotion' +import { omitProps } from '@instructure/ui-react-utils' +import { isValid } from '@instructure/ui-color-utils' +import conversions from '@instructure/ui-color-utils' +import { logWarn as warn } from '@instructure/console' +import type { HSVType } from '@instructure/ui-color-utils' +import ColorPalette from './ColorPalette' +import Slider from './Slider' +import RGBAInput from './RGBAInput' + +import type { ColorMixerProps, ColorMixerState } from './props' +import { allowedProps } from './props' +import generateStyle from './styles' + +/** +--- +category: components +--- +**/ +@withStyle(generateStyle) +class ColorMixer extends Component { + static allowedProps = allowedProps + static readonly componentId = 'ColorMixer' + + static defaultProps = { + value: '#000', + withAlpha: false, + disabled: false + } + + constructor(props: ColorMixerProps) { + super(props) + this.state = { + h: 0, + s: 0, + v: 0, + a: 1 + } + } + + ref: HTMLDivElement | null = null + + private _width = 272 + private _paletteHeight = 160 + private _sliderHeight = 8 + private _sliderIndicatorRadius = 6 + private _paletteIndicatorRadius = 6 + + handleRef = (el: HTMLDivElement | null) => { + const { elementRef } = this.props + + this.ref = el + + if (typeof elementRef === 'function') { + elementRef(el) + } + } + + componentDidMount() { + this.props.makeStyles?.() + warn( + isValid(this.props.value!), + `[ColorMixer] The passed color value is not valid.` + ) + this.setState({ + ...conversions.colorToHsva(this.props.value!) + }) + } + + componentDidUpdate(prevProps: ColorMixerProps, prevState: ColorMixerState) { + this.props.makeStyles?.() + const { h, s, v, a } = this.state + if ( + prevState.h !== h || + prevState.s !== s || + prevState.v !== v || + prevState.a !== a + ) { + this.props.onChange(conversions.colorToHex8({ h, s, v, a })) + } + if ( + prevProps.value !== this.props.value && + conversions.colorToHex8({ h, s, v, a }) !== this.props.value + ) { + this.setState({ + ...conversions.colorToHsva(this.props.value!) + }) + } + } + + render() { + const { + disabled, + styles, + withAlpha, + rgbRedInputScreenReaderLabel, + rgbGreenInputScreenReaderLabel, + rgbBlueInputScreenReaderLabel, + rgbAlphaInputScreenReaderLabel, + colorSliderNavigationExplanationScreenReaderLabel, + alphaSliderNavigationExplanationScreenReaderLabel, + colorPaletteNavigationExplanationScreenReaderLabel + } = this.props + + const { h, s, v, a } = this.state + + return ( +
+ + { + this.setState({ s: color.s, v: color.v }) + }} + navigationExplanationScreenReaderLabel={ + colorPaletteNavigationExplanationScreenReaderLabel + } + /> + { + this.setState({ h: hue }) + }} + navigationExplanationScreenReaderLabel={ + colorSliderNavigationExplanationScreenReaderLabel + } + /> + {withAlpha && ( + this.setState({ a: opacity / 100 })} + navigationExplanationScreenReaderLabel={ + alphaSliderNavigationExplanationScreenReaderLabel + } + /> + )} + + + this.setState({ ...conversions.colorToHsva(color) }) + } + withAlpha={withAlpha} + rgbRedInputScreenReaderLabel={rgbRedInputScreenReaderLabel} + rgbGreenInputScreenReaderLabel={rgbGreenInputScreenReaderLabel} + rgbBlueInputScreenReaderLabel={rgbBlueInputScreenReaderLabel} + rgbAlphaInputScreenReaderLabel={rgbAlphaInputScreenReaderLabel} + /> +
+ ) + } +} + +export { ColorMixer } +export default ColorMixer diff --git a/packages/ui-color-picker/src/ColorMixer/v2/props.ts b/packages/ui-color-picker/src/ColorMixer/v2/props.ts new file mode 100644 index 0000000000..6bd8bcdb2e --- /dev/null +++ b/packages/ui-color-picker/src/ColorMixer/v2/props.ts @@ -0,0 +1,116 @@ +/* + * The MIT License (MIT) + * + * Copyright (c) 2015 - present Instructure, Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import type { WithStyleProps, ComponentStyle } from '@instructure/emotion' +import type { OtherHTMLAttributes } from '@instructure/shared-types' + +type ColorMixerOwnProps = { + /** + * Makes the component uninteractable + */ + disabled?: boolean + /** + * Provides a reference to the component's underlying html element. + */ + elementRef?: (element: Element | null) => void + /** + * Gets called each time the color changes + */ + onChange: (hex: string) => void + /** + * Sets the value of the component. If changes, the color changes inside the component as well + */ + value?: string + /** + * Toggles alpha. If true, alpha slider will appear + */ + withAlpha?: boolean + /** + * screenReaderLabel for the RGBA input's red input field + */ + rgbRedInputScreenReaderLabel: string + /** + * screenReaderLabel for the RGBA input's green input field + */ + rgbGreenInputScreenReaderLabel: string + /** + * screenReaderLabel for the RGBA input's blue input field + */ + rgbBlueInputScreenReaderLabel: string + /** + * screenReaderLabel for the RGBA input's alpha input field + */ + rgbAlphaInputScreenReaderLabel: string + /** + * screenReaderLabel for the color slider. It should explain how to navigate the slider + * with the keyboard ('A' for left, 'D' for right) + */ + colorSliderNavigationExplanationScreenReaderLabel: string + /** + * screenReaderLabel for the alpha slider. It should explain how to navigate the slider + * with the keyboard ('A' for left, 'D' for right) + */ + alphaSliderNavigationExplanationScreenReaderLabel: string + /** + * screenReaderLabel for the color palette. It should explain how to navigate the palette + * with the keyboard ('W' for up, 'A' for left, 'S' for down and 'D' for right) + */ + colorPaletteNavigationExplanationScreenReaderLabel: string +} + +type ColorMixerState = { + h: number + s: number + v: number + a: number +} + +type PropKeys = keyof ColorMixerOwnProps + +type AllowedPropKeys = Readonly> + +type ColorMixerProps = ColorMixerOwnProps & + WithStyleProps & + OtherHTMLAttributes + +type ColorMixerStyle = ComponentStyle< + 'colorMixer' | 'sliderAndPaletteContainer' +> +const allowedProps: AllowedPropKeys = [ + 'disabled', + 'elementRef', + 'value', + 'onChange', + 'withAlpha', + 'rgbRedInputScreenReaderLabel', + 'rgbGreenInputScreenReaderLabel', + 'rgbBlueInputScreenReaderLabel', + 'rgbAlphaInputScreenReaderLabel', + 'colorSliderNavigationExplanationScreenReaderLabel', + 'alphaSliderNavigationExplanationScreenReaderLabel', + 'colorPaletteNavigationExplanationScreenReaderLabel' +] + +export type { ColorMixerProps, ColorMixerStyle, ColorMixerState } +export { allowedProps } diff --git a/packages/ui-color-picker/src/ColorMixer/v2/styles.ts b/packages/ui-color-picker/src/ColorMixer/v2/styles.ts new file mode 100644 index 0000000000..1b51ae52f4 --- /dev/null +++ b/packages/ui-color-picker/src/ColorMixer/v2/styles.ts @@ -0,0 +1,51 @@ +/* + * The MIT License (MIT) + * + * Copyright (c) 2015 - present Instructure, Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import type { ColorMixerStyle } from './props' + +/** + * --- + * private: true + * --- + * Generates the style object from the theme and provided additional information + * @param {Object} componentTheme The theme variable object. + * @param {Object} props the props of the component, the style is applied to + * @param {Object} state the state of the component, the style is applied to + * @return {Object} The final style object, which will be used in the component + */ +const generateStyle = (): ColorMixerStyle => { + return { + colorMixer: { + label: 'colorMixer', + width: '17rem' + }, + sliderAndPaletteContainer: { + label: 'colorMixer__sliderAndPaletteContainer', + display: 'flex', + flexDirection: 'column' + } + } +} + +export default generateStyle diff --git a/packages/ui-color-picker/src/ColorMixer/v2/utils/shallowCompare.ts b/packages/ui-color-picker/src/ColorMixer/v2/utils/shallowCompare.ts new file mode 100644 index 0000000000..0b560a1c38 --- /dev/null +++ b/packages/ui-color-picker/src/ColorMixer/v2/utils/shallowCompare.ts @@ -0,0 +1,35 @@ +/* + * The MIT License (MIT) + * + * Copyright (c) 2015 - present Instructure, Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +const shallowCompare = ( + newObj: Record, + prevObj: Record +) => { + for (const key in newObj) { + if (newObj[key] !== prevObj[key]) return true + } + return false +} + +export default shallowCompare diff --git a/packages/ui-color-picker/src/ColorPicker/v1/index.tsx b/packages/ui-color-picker/src/ColorPicker/v1/index.tsx index b2744d4579..de613cef66 100644 --- a/packages/ui-color-picker/src/ColorPicker/v1/index.tsx +++ b/packages/ui-color-picker/src/ColorPicker/v1/index.tsx @@ -32,17 +32,17 @@ import { warn, error } from '@instructure/console' import { omitProps } from '@instructure/ui-react-utils' import { isValid, contrast as getContrast } from '@instructure/ui-color-utils' import conversions from '@instructure/ui-color-utils' -import { TextInput } from '@instructure/ui-text-input/latest' -import { Tooltip } from '@instructure/ui-tooltip' -import { Button, IconButton } from '@instructure/ui-buttons/latest' -import { Popover } from '@instructure/ui-popover/latest' +import { TextInput } from '@instructure/ui-text-input/v11_6' +import { Tooltip } from '@instructure/ui-tooltip/v11_6' +import { Button, IconButton } from '@instructure/ui-buttons/v11_6' +import { Popover } from '@instructure/ui-popover/v11_6' import { IconCheckDarkLine, IconWarningLine, IconTroubleLine, IconInfoLine } from '@instructure/ui-icons' -import type { FormMessage } from '@instructure/ui-form-field/latest' +import type { FormMessage } from '@instructure/ui-form-field/v11_6' import ColorIndicator from '../../ColorIndicator/v1' import ColorMixer from '../../ColorMixer/v1' diff --git a/packages/ui-color-picker/src/ColorPicker/v2/ColorPicker.test.tsx b/packages/ui-color-picker/src/ColorPicker/v2/ColorPicker.test.tsx new file mode 100644 index 0000000000..d0ef618600 --- /dev/null +++ b/packages/ui-color-picker/src/ColorPicker/v2/ColorPicker.test.tsx @@ -0,0 +1,618 @@ +/* + * The MIT License (MIT) + * + * Copyright (c) 2015 - present Instructure, Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import { render, screen, waitFor, fireEvent } from '@testing-library/react' +import { userEvent } from '@testing-library/user-event' +import { vi } from 'vitest' +import '@testing-library/jest-dom' + +import { runAxeCheck } from '@instructure/ui-axe-check' +import conversions from '@instructure/ui-color-utils' + +import type { ColorPickerProps } from '../v2/props' +import { ContrastStrength } from '../v2/props' +import { ColorPicker } from '../v2' + +const SimpleExample = (props: Partial) => { + return ( + + ) +} + +describe('', () => { + let consoleErrorMock: ReturnType + let consoleWarningMock: ReturnType + + beforeEach(() => { + // Mocking console to prevent test output pollution and expect for messages + consoleErrorMock = vi + .spyOn(console, 'error') + .mockImplementation(() => {}) as any + consoleWarningMock = vi + .spyOn(console, 'warn') + .mockImplementation(() => {}) as any + }) + + afterEach(() => { + consoleErrorMock.mockRestore() + consoleWarningMock.mockRestore() + }) + + describe('simple input mode', () => { + it('should render correctly', async () => { + const { container } = render() + expect(container.firstChild).toBeInTheDocument() + }) + + it('should work controlled', async () => { + const color = '#FFF' + const onChange = vi.fn() + + const { rerender } = render( + + ) + + const input = screen.getByRole('textbox') + expect(input).toHaveValue('FFF') + + // set new value + rerender() + + const inputUpdated = screen.getByRole('textbox') + expect(inputUpdated).toHaveValue('FFF555') + }) + + it('should accept 3 digit hex code', async () => { + const color = '0CB' + render() + + const input = screen.getByRole('textbox') + + await userEvent.type(input, color) + fireEvent.blur(input) + + await waitFor(() => { + expect(input).toHaveValue(color) + }) + }) + + it('should accept 6 digit hex code', async () => { + const color = '0CBF2D' + render() + + const input = screen.getByRole('textbox') + + await userEvent.type(input, color) + fireEvent.blur(input) + + await waitFor(() => { + expect(input).toHaveValue(color) + }) + }) + + it('should not accept not valid hex code', async () => { + const color = 'WWWZZZ' + render() + + const input = screen.getByRole('textbox') + + await userEvent.type(input, color) + fireEvent.blur(input) + + await waitFor(() => { + expect(input).not.toHaveValue(color) + }) + }) + + it('should not allow more than 6 characters', async () => { + const color = '0CBF2D1234567' + render() + + const input = screen.getByRole('textbox') + + await userEvent.type(input, color) + fireEvent.blur(input) + + await waitFor(() => { + expect(input).toHaveValue('0CBF2D') + }) + }) + + it('should not allow input when disabled', async () => { + render() + + const input = screen.getByRole('textbox') + expect(input).toHaveAttribute('disabled') + }) + + for (const contrastStrength of [ + 'min', + 'mid', + 'max' + ] as ContrastStrength[]) { + it(`should check contrast correctly when color has enough contrast [contrastStrength=${contrastStrength}]`, async () => { + //oxford in canvas color palette, should be valid with all contrast strenght checkers + const colorToCheck = '394B58' + const { container } = render( + + ) + const input = screen.getByRole('textbox') + + await userEvent.type(input, colorToCheck) + fireEvent.blur(input) + + await waitFor(() => { + expect(input).toHaveValue(colorToCheck) + + const successIconWrapper = container.querySelector( + 'div[class$="-colorPicker__successIcon"]' + ) + const successIcon = container.querySelector( + 'svg[name="Check"]' + ) + + expect(successIconWrapper).toBeInTheDocument() + expect(successIcon).toBeInTheDocument() + }) + }) + + it(`should check contrast correctly when color does not have enough contrast [contrastStrength=${contrastStrength}, isStrict=false]`, async () => { + //porcelain in canvas color palette, it should be failing even the min check + const colorToCheck = 'F5F5F5' + const { container } = render( + + ) + const input = screen.getByRole('textbox') + + await userEvent.type(input, colorToCheck) + fireEvent.blur(input) + + await waitFor(() => { + expect(input).toHaveValue(colorToCheck) + + const warningIconWrapper = container.querySelector( + 'div[class$="-colorPicker__errorIcons"]' + ) + const warningIcon = container.querySelector('svg[name="CircleAlert"]') + + expect(warningIconWrapper).toBeInTheDocument() + expect(warningIcon).toBeInTheDocument() + }) + }) + + it(`should check contrast correctly when color does not have enough contrast [contrastStrength=${contrastStrength}, isStrict=true]`, async () => { + //porcelain in canvas color palette, it should be failing even the min check + const colorToCheck = 'F5F5F5' + const { container } = render( + + ) + const input = screen.getByRole('textbox') + + await userEvent.type(input, colorToCheck) + fireEvent.blur(input) + + await waitFor(() => { + expect(input).toHaveValue(colorToCheck) + + const errorIconWrapper = container.querySelector( + 'div[class$="-colorPicker__errorIcons"]' + ) + const errorIcon = container.querySelector('svg[name="CircleX"]') + + expect(errorIconWrapper).toBeInTheDocument() + expect(errorIcon).toBeInTheDocument() + }) + }) + + it(`should display success message when contrast is met [contrastStrength=${contrastStrength}]`, async () => { + const colorToCheck = '394B58' + render( + [ + { type: 'success', text: 'I am a contrast success message' } + ] + }} + /> + ) + const input = screen.getByRole('textbox') + + await userEvent.type(input, colorToCheck) + fireEvent.blur(input) + + await waitFor(() => { + const successMessage = screen.getByText( + 'I am a contrast success message' + ) + + expect(input).toHaveValue(colorToCheck) + expect(successMessage).toBeInTheDocument() + }) + }) + + it(`should display error message when contrast is not met [contrastStrength=${contrastStrength}, isStrict=false]`, async () => { + const colorToCheck = 'F5F5F5' + render( + [ + { type: 'error', text: 'I am a contrast warning message' } + ] + }} + /> + ) + const input = screen.getByRole('textbox') + + await userEvent.type(input, colorToCheck) + fireEvent.blur(input) + + await waitFor(() => { + const warningMessage = screen.getByText( + 'I am a contrast warning message' + ) + + expect(input).toHaveValue(colorToCheck) + expect(warningMessage).toBeInTheDocument() + }) + }) + + it(`should display error message when contrast is not met [contrastStrength=${contrastStrength}, isStrict=true]`, async () => { + const colorToCheck = 'F5F5F5' + render( + [ + { type: 'error', text: 'I am a contrast error message' } + ] + }} + /> + ) + const input = screen.getByRole('textbox') + + await userEvent.type(input, colorToCheck) + fireEvent.blur(input) + + await waitFor(() => { + const errorMessage = screen.getByText('I am a contrast error message') + + expect(input).toHaveValue(colorToCheck) + expect(errorMessage).toBeInTheDocument() + }) + }) + } + + it('should call onChange', async () => { + const onChange = vi.fn() + render() + + const input = screen.getByRole('textbox') + + fireEvent.change(input, { target: { value: 'FFF' } }) + fireEvent.blur(input) + + await waitFor(() => { + expect(onChange).toHaveBeenLastCalledWith('#FFF') + }) + }) + + it('should display message when ColorPicker is a required field', async () => { + render( + [ + { type: 'error', text: 'I am an invalid color message' } + ]} + renderIsRequiredMessage={() => [ + { type: 'error', text: 'I am a required message' } + ]} + /> + ) + const input = screen.getByRole('textbox') + + fireEvent.focus(input) + fireEvent.blur(input) + + await waitFor(() => { + const requiredMessage = screen.getByText('I am a required message') + + expect(requiredMessage).toBeInTheDocument() + }) + }) + + it('should display message when color is invalid', async () => { + render( + [ + { type: 'error', text: 'I am an invalid color message' } + ]} + /> + ) + const input = screen.getByRole('textbox') + + await userEvent.type(input, 'F') + fireEvent.blur(input) + + await waitFor(() => { + const errorMessage = screen.getByText('I am an invalid color message') + + expect(errorMessage).toBeInTheDocument() + }) + }) + + it('should provide an inputRef prop', async () => { + const inputRef = vi.fn() + render() + const input = screen.getByRole('textbox') + + expect(inputRef).toHaveBeenCalledWith(input) + }) + }) + + describe('complex mode', () => { + it('should display trigger button', async () => { + const { container } = render( + + ) + const buttonWrapper = container.querySelector( + 'div[class$="-colorPicker__colorMixerButtonWrapper"]' + ) + const button = screen.getByRole('button') + + expect(buttonWrapper).toBeInTheDocument() + expect(button).toBeInTheDocument() + }) + + it('should open popover when trigger is clicked', async () => { + render( + + ) + const trigger = screen.getByRole('button') + + expect(trigger).toBeInTheDocument() + expect(trigger).toHaveAttribute('aria-expanded', 'false') + + fireEvent.click(trigger) + + await waitFor(() => { + const buttons = screen.getAllByRole('button') + const popoverContent = document.querySelector( + 'div[class$="-colorPicker__popoverContent"]' + ) + + expect(trigger).toHaveAttribute('aria-expanded', 'true') + expect(popoverContent).toBeInTheDocument() + + expect(buttons.length).toBe(2) + expect(buttons[0]).toHaveTextContent('close') + expect(buttons[1]).toHaveTextContent('add') + }) + }) + + it('should display the color mixer', async () => { + render( + + ) + const trigger = screen.getByRole('button') + + fireEvent.click(trigger) + + await waitFor(() => { + const redInput = screen.getByLabelText('Red input') + const blueInput = screen.getByLabelText('Blue input') + const greenInput = screen.getByLabelText('Green input') + + expect(redInput).toBeInTheDocument() + expect(blueInput).toBeInTheDocument() + expect(greenInput).toBeInTheDocument() + }) + }) + + it('should display the correct color in the colormixer when the input is prefilled', async () => { + const color = '0374B5' + render( + + ) + const input = screen.getByRole('textbox') + const trigger = screen.getByRole('button') + + await userEvent.type(input, color) + fireEvent.blur(input) + fireEvent.click(trigger) + + await waitFor(() => { + const redInput = screen.getByLabelText('Red input') as HTMLInputElement + const blueInput = screen.getByLabelText( + 'Blue input' + ) as HTMLInputElement + const greenInput = screen.getByLabelText( + 'Green input' + ) as HTMLInputElement + const convertedColor = conversions.colorToRGB(`#${color}`) + + const actualColor = { + r: parseInt(redInput.value), + g: parseInt(greenInput.value), + b: parseInt(blueInput.value), + a: 1 + } + + expect(convertedColor).toStrictEqual(actualColor) + }) + }) + + it('should trigger onChange when selected color is added from colorMixer', async () => { + const onChange = vi.fn() + const rgb = { r: 131, g: 6, b: 25, a: 1 } + render( + + ) + + const trigger = screen.getByRole('button') + + fireEvent.click(trigger) + + await waitFor(() => { + const addBtn = screen.getByRole('button', { name: 'add' }) + const redInput = screen.getByLabelText('Red input') as HTMLInputElement + const greenInput = screen.getByLabelText( + 'Green input' + ) as HTMLInputElement + const blueInput = screen.getByLabelText( + 'Blue input' + ) as HTMLInputElement + + fireEvent.change(redInput, { target: { value: `${rgb.r}` } }) + fireEvent.change(greenInput, { target: { value: `${rgb.g}` } }) + fireEvent.change(blueInput, { target: { value: `${rgb.b}` } }) + + fireEvent.click(addBtn) + + expect(onChange).toHaveBeenCalledWith(conversions.color2hex(rgb)) + }) + }) + }) + + describe('custom popover mode', () => { + it('should throw warning if children and settings object are passed too', async () => { + render( + + {() =>
} +
+ ) + + await waitFor(() => { + expect(consoleWarningMock.mock.calls[0][0]).toEqual( + expect.stringContaining( + 'Warning: You should either use children, colorMixerSettings or neither, not both. In this case, the colorMixerSettings will be ignored.' + ) + ) + }) + }) + + it('should display trigger button', async () => { + render({() =>
}
) + + const trigger = screen.getByRole('button') + expect(trigger).toBeInTheDocument() + expect(trigger).toHaveAttribute('data-popover-trigger', 'true') + }) + }) + + describe('should be accessible', () => { + it('a11y', async () => { + const { container } = render() + const axeCheck = await runAxeCheck(container) + + expect(axeCheck).toBe(true) + }) + }) +}) diff --git a/packages/ui-color-picker/src/ColorPicker/v2/README.md b/packages/ui-color-picker/src/ColorPicker/v2/README.md new file mode 100644 index 0000000000..fe2e7f64c8 --- /dev/null +++ b/packages/ui-color-picker/src/ColorPicker/v2/README.md @@ -0,0 +1,410 @@ +--- +describes: ColorPicker +--- + +The `ColorPicker` is a versatile component that can be used to select colors and check their contrast ratios. It has 2 modes of operation: + +- In the simple, color input mode, it lets the user enter hex codes. It will display the color, validate the hex and check the contrast. +- In the more complex, color picker mode, the same functionality is available as for the simpler mode, but there is the option to pick a color from a visual color mixer component or from any other method added in a popover. + +The component can be either `uncontrolled` or `controlled`. If the `onChange` and `value` props are used, it will behave in a `controlled` manner, otherwise `uncontrolled`. + +### ColorPicker with default popover content + +```js +--- +type: example +--- + +``` + +### ColorPicker with custom popover content + +```js +--- +type: example +--- + const Example = () => { + const [value, setValue] = useState('') + + const renderPopoverContent = (value, onChange, handleAdd, handleClose) => ( +
+
+ +
+ +
+ +
+ + + + +
+ ) + + return ( +
+ { + setValue(val) + }} + label="Color Input" + tooltip="This is an example" + placeholderText="Enter HEX" + popoverButtonScreenReaderLabel="Open color mixer popover" + > + {renderPopoverContent} + +
+ ) + } + + render() +``` + +### Complex Color input example + +```js +--- +type: example +--- + const Example = () => { + const [value, setValue] = useState('') + const [withCheckContrast, setWithCheckContrast] = useState(undefined) + const [contrastStrength, setContrastStrength] = useState('mid') + const [isStrict, setIsStrict] = useState(false) + const [disabled, setDisabled] = useState(false) + const [isRequired, setIsRequired] = useState(false) + const [withAlpha, setWithAlpha] = useState(false) + const [contrastAgainst, setContrastAgainst] = useState('#ffffff') + const [colors, setColors] = useState([ + '#ffffff', + '#0CBF94', + '#0C89BF00', + '#BF0C6D', + '#BF8D0C', + '#ff0000', + '#576A66', + '#35423A', + '#35423F' + ]) + + return ( + + setValue(val)} + value={value} + placeholderText="Enter HEX" + label="Color Input" + tooltip="This is an example" + disabled={disabled} + isRequired={isRequired} + withAlpha={withAlpha} + popoverButtonScreenReaderLabel="Open color mixer popover" + checkContrast={ + withCheckContrast && { + isStrict, + contrastStrength, + contrastAgainst, + renderContrastErrorMessage: (contrast, minContrast) => [ + { + type: 'error', + text: `Not high enough contrast. Minimum required is ${minContrast}:1, current is ${contrast}:1` + } + ] + } + } + renderInvalidColorMessage={(hexCode) => [ + { + type: 'error', + text: `Not valid hex color. It should be either 3, 6 or 8 character long.` + } + ]} + renderIsRequiredMessage={() => [ + { + type: 'error', + text: `This field is required, please enter a valid hex code` + } + ]} + /> +
+ + setIsRequired(!isRequired)} + /> + { + setWithAlpha(!withAlpha) + setValue(value.slice(0, 6)) + }} + /> + + setDisabled(!disabled)} + /> + + setWithCheckContrast(!withCheckContrast)} + /> + + {withCheckContrast && ( + + setIsStrict(!isStrict)} + /> + setContrastStrength(strength)} + > + + + + + + setContrastAgainst(val)} + /> + + )} + +
+ ) + } + + render() +``` + +### Uncontrolled Color Input + +```js +--- +type: example +--- +
+ [ + { type: "success", text: "I am a contrast success message" }, + ], + renderContrastErrorMessage: () => [ + { type: "error", text: "I am a contrast warning message" }, + ], + }} + renderMessages={() => [ + { type: "hint", text: "I can display anything, at any time" }, + ]} + renderInvalidColorMessage={() => [ + { type: "error", text: "I am an invalid color message" }, + ]} + renderIsRequiredMessage={() => [ + { type: "error", text: "I am a required message" }, + ]} + placeholderText="Enter HEX" + /> +
+ +``` + +### Scrollable Popover Content + +When the ColorPicker popover contains tall content (e.g., ColorMixer + ColorPreset + ColorContrast), the component automatically calculates the available viewport space and makes the popover scrollable. + +The `popoverMaxHeight` prop can be used to set a custom maximum height for the popover content. By default, it's set to `'100vh'`, but the component dynamically adjusts this based on the available space to ensure the popover fits within the viewport and remains scrollable. + +```js +--- +type: example +--- + +``` diff --git a/packages/ui-color-picker/src/ColorPicker/v1/__tests__/ColorPicker.test.tsx b/packages/ui-color-picker/src/ColorPicker/v2/__tests__/ColorPicker.test.tsx similarity index 99% rename from packages/ui-color-picker/src/ColorPicker/v1/__tests__/ColorPicker.test.tsx rename to packages/ui-color-picker/src/ColorPicker/v2/__tests__/ColorPicker.test.tsx index 83f34cd6ce..1dea189df1 100644 --- a/packages/ui-color-picker/src/ColorPicker/v1/__tests__/ColorPicker.test.tsx +++ b/packages/ui-color-picker/src/ColorPicker/v2/__tests__/ColorPicker.test.tsx @@ -180,7 +180,7 @@ describe('', () => { 'div[class$="-colorPicker__successIcon"]' ) const successIcon = container.querySelector( - 'svg[name="IconCheckDark"]' + 'svg[name="Check"]' ) expect(successIconWrapper).toBeInTheDocument() @@ -210,7 +210,7 @@ describe('', () => { const warningIconWrapper = container.querySelector( 'div[class$="-colorPicker__errorIcons"]' ) - const warningIcon = container.querySelector('svg[name="IconWarning"]') + const warningIcon = container.querySelector('svg[name="CircleAlert"]') expect(warningIconWrapper).toBeInTheDocument() expect(warningIcon).toBeInTheDocument() @@ -239,7 +239,7 @@ describe('', () => { const errorIconWrapper = container.querySelector( 'div[class$="-colorPicker__errorIcons"]' ) - const errorIcon = container.querySelector('svg[name="IconTrouble"]') + const errorIcon = container.querySelector('svg[name="CircleX"]') expect(errorIconWrapper).toBeInTheDocument() expect(errorIcon).toBeInTheDocument() diff --git a/packages/ui-color-picker/src/ColorPicker/v2/index.tsx b/packages/ui-color-picker/src/ColorPicker/v2/index.tsx new file mode 100644 index 0000000000..8ac02b0cde --- /dev/null +++ b/packages/ui-color-picker/src/ColorPicker/v2/index.tsx @@ -0,0 +1,742 @@ +/* + * The MIT License (MIT) + * + * Copyright (c) 2015 - present Instructure, Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import { Component } from 'react' + +import { + withStyle, + InstUISettingsProvider +} from '@instructure/emotion' +import { warn, error } from '@instructure/console' +import { omitProps } from '@instructure/ui-react-utils' +import { isValid, contrast as getContrast } from '@instructure/ui-color-utils' +import conversions from '@instructure/ui-color-utils' +import { TextInput } from '@instructure/ui-text-input/latest' +import { Tooltip } from '@instructure/ui-tooltip/latest' +import { Button, IconButton } from '@instructure/ui-buttons/latest' +import { Popover } from '@instructure/ui-popover/latest' +import { + CheckInstUIIcon, + CircleXInstUIIcon, + AlertCircleInstUIIcon, + InfoInstUIIcon +} from '@instructure/ui-icons' +import type { FormMessage } from '@instructure/ui-form-field/latest' + +import ColorIndicator from '../../ColorIndicator/v2' +import ColorMixer from '../../ColorMixer/v2' +import ColorContrast from '../../ColorContrast/v2' +import ColorPreset from '../../ColorPreset/v2' + +import generateStyle from './styles' + +import { allowedProps } from './props' +import type { + ColorPickerProps, + ColorPickerState, + ContrastStrength +} from './props' + +const acceptedCharactersForHEX = [ + '0', + '1', + '2', + '3', + '4', + '5', + '6', + '7', + '8', + '9', + 'a', + 'A', + 'b', + 'B', + 'c', + 'C', + 'd', + 'D', + 'e', + 'E', + 'f', + 'F', + null +] + +/** +--- +category: components +--- +**/ +@withStyle(generateStyle) +class ColorPicker extends Component { + static allowedProps = allowedProps + static readonly componentId = 'ColorPicker' + + static defaultProps = { + disabled: false, + withAlpha: false, + width: '22.5rem', + popoverMaxHeight: '100vh' + } + + constructor(props: ColorPickerProps) { + super(props) + + this.state = { + hexCode: '', + showHelperErrorMessages: false, + openColorPicker: false, + mixedColor: '', + labelHeight: 0, + calculatedPopoverMaxHeight: undefined, + isHeightCalculated: false + } + } + + ref: Element | null = null + + handleRef = (el: Element | null) => { + const { elementRef } = this.props + + this.ref = el + + if (typeof elementRef === 'function') { + elementRef(el) + } + } + + inputContainerRef: Element | null = null + + handleInputContainerRef = (el: Element | null) => { + this.inputContainerRef = el + + if (el) { + // Defer measurement until after layout is complete and CSS-in-JS styles are applied + requestAnimationFrame(() => { + this.setLabelHeight() + }) + } + } + + popoverContentRef: HTMLDivElement | null = null + + handlePopoverContentRef = (el: HTMLDivElement | null) => { + this.popoverContentRef = el + } + + setLabelHeight = () => { + if (this.inputContainerRef) { + this.setState({ + labelHeight: + this.inputContainerRef.getBoundingClientRect().y - + (this.inputContainerRef.parentElement?.getBoundingClientRect().y || 0) + }) + } + } + + // Calculate the maximum height the popover can have without extending beyond + // the viewport. This enables scrolling when the ColorPicker's content (all + // color mixing controls, presets, and contrast checker) would otherwise exceed + // the available viewport space. Without this calculation, the popover would + // render off-screen on smaller viewports. + handlePopoverPositioned = (position?: { placement?: string }) => { + if (this.popoverContentRef) { + // Double requestAnimationFrame ensures measurements happen after all child components + // (ColorMixer, ColorPreset, ColorContrast) complete their mount lifecycle and Emotion + // finishes injecting CSS-in-JS styles. A single rAF was insufficient as styles are + // injected dynamically in componentDidMount(). This timing issue only manifested when + // StrictMode was disabled, since StrictMode's double-rendering provided an accidental + // second measurement pass. + requestAnimationFrame(() => { + // First frame: DOM structure is laid out + requestAnimationFrame(() => { + // Second frame: styles injected, child components mounted, dimensions stable + if (!this.popoverContentRef) return + + const rect = this.popoverContentRef.getBoundingClientRect() + const viewportHeight = window.innerHeight + + // Detect if popover is positioned above (top) or below (bottom) the trigger. + // The Position component provides placement strings like "top center" or "bottom center". + const placement = position?.placement || '' + const isPositionedAbove = placement.startsWith('top') + + let availableHeight: number + + if (isPositionedAbove) { + // When opening upward: available space is from viewport top to popover bottom. + // This is the space where the popover can expand within the viewport. + availableHeight = rect.top + rect.height - 16 + } else { + // When opening downward: available space is from popover top to viewport bottom. + // Subtract a small buffer (16px) for padding/margin. + availableHeight = viewportHeight - rect.top - 16 + } + + const propMaxHeight = this.props.popoverMaxHeight + let calculatedMaxHeight = `${Math.max(100, availableHeight)}px` + + // If prop specifies a maxHeight, respect it as an additional constraint + if (propMaxHeight && propMaxHeight !== '100vh') { + calculatedMaxHeight = propMaxHeight + } + + this.setState({ + calculatedPopoverMaxHeight: calculatedMaxHeight, + isHeightCalculated: true + }) + }) + }) + } + } + + componentDidMount() { + this.props.makeStyles?.({ ...this.state, isSimple: this.isSimple }) + this.checkSettings() + + this.props.value && this.setState({ hexCode: this.props.value?.slice(1) }) + } + + componentDidUpdate(prevProps: ColorPickerProps) { + this.props.makeStyles?.({ ...this.state, isSimple: this.isSimple }) + + if (prevProps.value !== this.props.value) { + this.setState({ + showHelperErrorMessages: false, + hexCode: this.props.value?.slice(1) || '' + }) + } + this.checkSettings() + } + + checkSettings = () => { + if (this.props.children && this.props.colorMixerSettings) { + warn( + false, + 'You should either use children, colorMixerSettings or neither, not both. In this case, the colorMixerSettings will be ignored.', + '' + ) + } + + if (this.props.value && typeof this.props.onChange !== 'function') { + error( + false, + 'You provided a `value` prop on ColorPicker, which will render a controlled component. Please provide an `onChange` handler.' + ) + } + } + + get renderMode() { + if (this.props.children) { + return 'customPopover' + } + if (this.props.colorMixerSettings) { + return 'defaultPopover' + } + return 'simple' + } + + get isSimple() { + return this.renderMode === 'simple' + } + get isDefaultPopover() { + return this.renderMode === 'defaultPopover' + } + get isCustomPopover() { + return this.renderMode === 'customPopover' + } + + get mixedColorWithStrippedAlpha() { + const { mixedColor } = this.state + return mixedColor.length === 8 && mixedColor.slice(-2) === 'FF' + ? mixedColor.slice(0, -2) + : mixedColor + } + + getMinContrast(strength: ContrastStrength) { + return { min: 3, mid: 4.5, max: 7 }[strength] + } + + renderMessages() { + const { hexCode, showHelperErrorMessages } = this.state + + const isValidHex = isValid(hexCode) + const { + checkContrast, + renderMessages, + renderInvalidColorMessage, + renderIsRequiredMessage, + isRequired + } = this.props + const contrast = isValidHex + ? getContrast( + this.props.checkContrast?.contrastAgainst || '#fff', + hexCode, + 2 + ) + : undefined + const contrastStrength = checkContrast?.contrastStrength + ? checkContrast.contrastStrength + : 'mid' + + const minContrast = this.getMinContrast(contrastStrength) + let invalidColorMessages: FormMessage[] = [] + let isRequiredMessages: FormMessage[] = [] + let generalMessages: FormMessage[] = [] + let contrastMessages: FormMessage[] = [] + + if (checkContrast && contrast) { + const { renderContrastSuccessMessage, renderContrastErrorMessage } = + checkContrast + + if (contrast < minContrast) { + contrastMessages = + typeof renderContrastErrorMessage === 'function' + ? renderContrastErrorMessage(contrast, minContrast) + : checkContrast.isStrict + ? [{ type: 'error', text: '' }] + : [] + } else if (typeof renderContrastSuccessMessage === 'function') { + contrastMessages = renderContrastSuccessMessage(contrast, minContrast) + } + } + if ( + showHelperErrorMessages && + hexCode !== '' && + !isValidHex && + typeof renderInvalidColorMessage === 'function' + ) { + invalidColorMessages = renderInvalidColorMessage(hexCode) + } + + if (isRequired && showHelperErrorMessages && hexCode === '') { + isRequiredMessages = + typeof renderIsRequiredMessage === 'function' + ? renderIsRequiredMessage() + : [{ type: 'error', text: '' }] + } + if (typeof renderMessages === 'function') { + generalMessages = renderMessages( + hexCode, + isValidHex, + minContrast, + contrast + ) + } + + return [ + ...invalidColorMessages, + ...isRequiredMessages, + ...generalMessages, + ...contrastMessages + ] + } + + renderBeforeInput() { + const { styles } = this.props + return ( +
+ {this.isSimple && } +
#
+
+ ) + } + + renderAfterInput() { + const { checkContrast, styles } = this.props + const { hexCode } = this.state + + if (checkContrast && isValid(hexCode)) { + const contrast = getContrast( + checkContrast.contrastAgainst || '#fff', + hexCode, + 2 + ) + const minContrast = this.getMinContrast( + checkContrast.contrastStrength ? checkContrast.contrastStrength : 'mid' + ) + + if (minContrast >= contrast) { + return ( +
+ {checkContrast.isStrict ? : } +
+ ) + } + return ( +
+ +
+ ) + } + return null + } + + handleOnChange(_event: React.ChangeEvent, value: string) { + const { onChange } = this.props + + if ( + value.length > (this.props.withAlpha ? 8 : 6) || + value + .split('') + .find((char) => !acceptedCharactersForHEX.includes(char)) !== undefined + ) { + return + } + + if (typeof onChange === 'function') { + onChange(`#${value}`) + } else { + this.setState({ + showHelperErrorMessages: false, + hexCode: value + }) + } + } + + handleOnPaste(event: React.ClipboardEvent) { + const pasted = event.clipboardData.getData('Text') + let toPaste = pasted + if (pasted[0] && pasted[0] === '#') { + toPaste = pasted.slice(1) + } + + if ( + (this.props.withAlpha && toPaste.length > 8) || + (!this.props.withAlpha && toPaste.length > 6) + ) { + return event.preventDefault() + } + + const newHex = `${this.state.hexCode}${toPaste}` + if (isValid(newHex)) { + if (typeof this.props.onChange === 'function') { + this.props.onChange(`#${newHex}`) + } else { + this.setState({ + showHelperErrorMessages: false, + hexCode: newHex + }) + } + } + + event.preventDefault() + } + + handleOnBlur() { + this.setState({ showHelperErrorMessages: true }) + } + + renderLabel() { + const { label, tooltip, styles } = this.props + + return tooltip ? ( + + {label} + + + {tooltip}}> + } + /> + + + + + ) : ( + label + ) + } + + renderPopover = () => ( + + + + } + isShowingContent={this.state.openColorPicker} + onShowContent={() => { + this.setState({ + openColorPicker: true, + mixedColor: this.state.hexCode, + calculatedPopoverMaxHeight: undefined, + isHeightCalculated: false + }) + }} + onHideContent={() => { + this.setState({ openColorPicker: false }) + }} + on="click" + screenReaderLabel={this.props.popoverScreenReaderLabel} + shouldContainFocus + shouldReturnFocus + shouldCloseOnDocumentClick + offsetY="10rem" + onPositioned={this.handlePopoverPositioned} + onPositionChanged={this.handlePopoverPositioned} + > +
+ {this.isDefaultPopover + ? this.renderDefaultPopoverContent() + : this.renderCustomPopoverContent()} +
+
+ ) + + renderCustomPopoverContent = () => { + const { children, onChange } = this.props + + return ( +
+ {typeof children === 'function' && + children( + `#${this.mixedColorWithStrippedAlpha}`, + (newColor: string) => { + this.setState({ + mixedColor: conversions.colorToHex8(newColor).slice(1) + }) + }, + () => { + this.setState({ + openColorPicker: false, + hexCode: this.mixedColorWithStrippedAlpha + }) + onChange?.(`#${this.mixedColorWithStrippedAlpha}`) + }, + () => + this.setState({ + openColorPicker: false, + mixedColor: this.state.hexCode, + calculatedPopoverMaxHeight: undefined, + isHeightCalculated: false + }) + )} +
+ ) + } + + renderDefaultPopoverContent = () => ( + <> +
+ {this.props?.colorMixerSettings?.colorMixer && ( + + this.setState({ + mixedColor: conversions.colorToHex8(newColor).slice(1) + }) + } + withAlpha={this.props.colorMixerSettings.colorMixer.withAlpha} + rgbRedInputScreenReaderLabel={ + this.props.colorMixerSettings.colorMixer + .rgbRedInputScreenReaderLabel + } + rgbGreenInputScreenReaderLabel={ + this.props.colorMixerSettings.colorMixer + .rgbGreenInputScreenReaderLabel + } + rgbBlueInputScreenReaderLabel={ + this.props.colorMixerSettings.colorMixer + .rgbBlueInputScreenReaderLabel + } + rgbAlphaInputScreenReaderLabel={ + this.props.colorMixerSettings.colorMixer + .rgbAlphaInputScreenReaderLabel + } + colorSliderNavigationExplanationScreenReaderLabel={ + this.props.colorMixerSettings!.colorMixer + .colorSliderNavigationExplanationScreenReaderLabel + } + alphaSliderNavigationExplanationScreenReaderLabel={ + this.props.colorMixerSettings!.colorMixer + .alphaSliderNavigationExplanationScreenReaderLabel + } + colorPaletteNavigationExplanationScreenReaderLabel={ + this.props.colorMixerSettings!.colorMixer + .colorPaletteNavigationExplanationScreenReaderLabel + } + /> + )} + {this.props?.colorMixerSettings?.colorPreset && ( +
+ + this.setState({ mixedColor: color.slice(1) }) + } + /> +
+ )} + {this.props?.colorMixerSettings?.colorContrast && ( +
+ +
+ )} +
+
+ + +
+ + ) + + render() { + const { disabled, isRequired, placeholderText, width, id, inputRef } = + this.props + + return ( +
+ this.renderLabel()} + display="inline-block" + width={width} + placeholder={placeholderText} + themeOverride={{ padding: '' }} + renderAfterInput={this.renderAfterInput()} + renderBeforeInput={this.renderBeforeInput()} + inputContainerRef={this.handleInputContainerRef} + value={this.state.hexCode} + onChange={(event, value) => this.handleOnChange(event, value)} + onPaste={(event) => this.handleOnPaste(event)} + onBlur={() => this.handleOnBlur()} + messages={this.renderMessages()} + inputRef={inputRef} + /> + {!this.isSimple && ( +
0 ? 'flex-start' : 'flex-end', + paddingTop: this.state.labelHeight + }} + > +
+ {this.renderPopover()} +
+
+ )} +
+ ) + } +} + +export { ColorPicker } +export default ColorPicker diff --git a/packages/ui-color-picker/src/ColorPicker/v2/props.ts b/packages/ui-color-picker/src/ColorPicker/v2/props.ts new file mode 100644 index 0000000000..acd94ad886 --- /dev/null +++ b/packages/ui-color-picker/src/ColorPicker/v2/props.ts @@ -0,0 +1,304 @@ +/* + * The MIT License (MIT) + * + * Copyright (c) 2015 - present Instructure, Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import React from 'react' +import type { FormMessage } from '@instructure/ui-form-field' +import type { + WithStyleProps, + ComponentStyle, + Spacing +} from '@instructure/emotion' +import type { + ColorPickerTheme, + OtherHTMLAttributes +} from '@instructure/shared-types' +type ContrastStrength = 'min' | 'mid' | 'max' + +type ColorPickerOwnProps = { + /** + * Configures the contrast checker. If not provided, there will be no checking. + * + * + * isStrict: if it's true, it will display an error if false, a warning + * + * contrastStrength: can be one of ('min','mid','max'), which translates to 3:1, 4.5:1, 7:1 contrast, defalts to 'mid' + * + * contrastAgainst: is the color which the component checks the contrast against. Accepts hex, defaults to #ffffff + * + * renderContrastSuccessMessage: if set and the contrast is high enough, it will display the message + * + * renderContrastErrorMessage: if set and the contrast is not high enough, it will display the message + * + * FormMessage[]: Array of objects with shape: `{ + * text: ReactNode, + * type: One of: ['newError', 'error', 'hint', 'success', 'screenreader-only'] + * }` + */ + checkContrast?: { + isStrict?: boolean + contrastStrength?: ContrastStrength + contrastAgainst?: string + renderContrastSuccessMessage?: ( + contrast: number, + minContrast: number + ) => FormMessage[] + renderContrastErrorMessage?: ( + contrast: number, + minContrast: number + ) => FormMessage[] + } + + /** + * If set, the default popover will appear for the picker. Those components whose corresponding keys aren't provided (e.g. `colorMixer`, `colorPreset` or `colorContrast`) + * will not be rendered. + */ + colorMixerSettings?: { + popoverAddButtonLabel: string + popoverCloseButtonLabel: string + colorMixer?: { + withAlpha?: boolean + rgbRedInputScreenReaderLabel: string + rgbGreenInputScreenReaderLabel: string + rgbBlueInputScreenReaderLabel: string + rgbAlphaInputScreenReaderLabel: string + colorSliderNavigationExplanationScreenReaderLabel: string + alphaSliderNavigationExplanationScreenReaderLabel: string + colorPaletteNavigationExplanationScreenReaderLabel: string + } + colorPreset?: { + colors: Array + label: string + } + colorContrast?: { + firstColor: string + label: string + successLabel: string + failureLabel: string + normalTextLabel: string + largeTextLabel: string + graphicsTextLabel: string + firstColorLabel: string + secondColorLabel: string + onContrastChange?: (conrastData: { + contrast: number + isValidNormalText: boolean + isValidLargeText: boolean + isValidGraphicsText: boolean + firstColor: string + secondColor: string + }) => null + } + } + + /** + * If a child function is provided, the component will render it to the popover. + */ + children?: ( + value: string, + onChange: (hex: string) => void, + handleAdd: () => void, + handleClose: () => void + ) => React.ReactNode + + /** + * Sets the input to disabled state + */ + disabled?: boolean + + /** + * provides a reference to the underlying html root element + */ + elementRef?: (element: Element | null) => void + + /** + * If true, it will display a red error ring or a message after a blur event and remove it after a change event + */ + isRequired?: boolean + + /** + * The label of the component + */ + label: string + + /** + * If 'value' is set, this must be set. It'll be called on every change + */ + onChange?: (value: string) => void + + /** + * Placeholder for the input field + */ + placeholderText: string + + /** + * If set, it will set the popover's max height. + * Useful when the popover is too big + */ + popoverMaxHeight?: string + + /** + * Sets the ScreenReaderLabel for the popover + */ + popoverScreenReaderLabel?: string + + /** + * Sets the ScreenReaderLabel for the popover Button + */ + popoverButtonScreenReaderLabel?: string + + /** + * If set and the hex is invalid, it will display the message after a blur event and remove it after a change event + * + * FormMessage[]: Array of objects with shape: `{ + * text: ReactNode, + * type: One of: ['newError', 'error', 'hint', 'success', 'screenreader-only'] + * }` + */ + renderInvalidColorMessage?: (hexCode: string) => FormMessage[] + + /** + * If set, isRequired is true and the input is empty, it will display the message after a blur event and remove it after a change event + * + * FormMessage[]: Array of objects with shape: `{ + * text: ReactNode, + * type: One of: ['newError', 'error', 'hint', 'success', 'screenreader-only'] + * }` + */ + renderIsRequiredMessage?: () => FormMessage[] + + /** + * If set, it will display the message it returns + * + * FormMessage[]: Array of objects with shape: `{ + * text: ReactNode, + * type: One of: ['newError', 'error', 'hint', 'success', 'screenreader-only'] + * }` + */ + renderMessages?: ( + hexCode: string, + isValidHex: boolean, + minContrast: number, + contrast?: number + ) => FormMessage[] + + /** + * If set, an info icon with a tooltip will be displayed + */ + tooltip?: React.ReactNode + + /** + * The id of the text input. One is generated if not supplied. + */ + id?: string + + /** + * If set, the component will behave as controlled + */ + value?: string + + /** + * The width of the input. + */ + width?: string + + /** + * If true, alpha slider will be rendered. Defaults to false + */ + withAlpha?: boolean + + /** + * Margin around the component. Accepts a `Spacing` token. See token values and example usage in [this guide](https://instructure.design/#layout-spacing). + */ + margin?: Spacing + + /** + * A function that provides a reference to the input element + */ + inputRef?: (inputElement: HTMLInputElement | null) => void +} + +type ColorPickerState = { + hexCode: string + showHelperErrorMessages: boolean + openColorPicker: boolean + mixedColor: string + labelHeight: number + calculatedPopoverMaxHeight: string | undefined + isHeightCalculated: boolean +} + +type PropKeys = keyof ColorPickerOwnProps + +type AllowedPropKeys = Readonly> + +type ColorPickerProps = ColorPickerOwnProps & + WithStyleProps & + OtherHTMLAttributes + +type ColorPickerStyle = ComponentStyle< + | 'colorPicker' + | 'simpleColorContainer' + | 'hashMarkContainer' + | 'errorIcons' + | 'successIcon' + | 'label' + | 'popoverContent' + | 'popoverContentBlock' + | 'popoverFooter' + | 'colorMixerButtonContainer' + | 'popoverContentContainer' + | 'colorMixerButtonWrapper' +> +const allowedProps: AllowedPropKeys = [ + 'id', + 'checkContrast', + 'colorMixerSettings', + 'children', + 'disabled', + 'elementRef', + 'isRequired', + 'label', + 'onChange', + 'placeholderText', + 'popoverScreenReaderLabel', + 'popoverButtonScreenReaderLabel', + 'popoverMaxHeight', + 'renderInvalidColorMessage', + 'renderIsRequiredMessage', + 'renderMessages', + 'tooltip', + 'value', + 'width', + 'withAlpha', + 'margin', + 'inputRef' +] + +export type { + ColorPickerProps, + ColorPickerStyle, + ColorPickerState, + ContrastStrength +} +export { allowedProps } diff --git a/packages/ui-color-picker/src/ColorPicker/v2/styles.ts b/packages/ui-color-picker/src/ColorPicker/v2/styles.ts new file mode 100644 index 0000000000..6989239227 --- /dev/null +++ b/packages/ui-color-picker/src/ColorPicker/v2/styles.ts @@ -0,0 +1,141 @@ +/* + * The MIT License (MIT) + * + * Copyright (c) 2015 - present Instructure, Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import { calcSpacingFromShorthand } from '@instructure/emotion' +import type { NewComponentTypes, SharedTokens } from '@instructure/ui-themes' + +import type { + ColorPickerProps, + ColorPickerState, + ColorPickerStyle +} from './props' + +/** + * --- + * private: true + * --- + * Generates the style object from the theme and provided additional information + * @param {Object} componentTheme The theme variable object. + * @param {Object} props the props of the component, the style is applied to + * @param {Object} state the state of the component, the style is applied to + * @return {Object} The final style object, which will be used in the component + */ +const generateStyle = ( + componentTheme: NewComponentTypes['ColorPicker'], + props: ColorPickerProps, + sharedTokens: SharedTokens, + state: ColorPickerState & { isSimple: boolean } +): ColorPickerStyle => { + const { hashMarkColor } = componentTheme + const { popoverMaxHeight, margin } = props + const { isSimple, calculatedPopoverMaxHeight } = state + + const cssMargin = calcSpacingFromShorthand(margin, { + ...sharedTokens.spacing, + ...sharedTokens.legacy.spacing + }) + return { + colorPicker: { + label: 'colorPicker', + display: 'flex', + margin: cssMargin + }, + simpleColorContainer: { + label: 'colorPicker__simpleColorContainer', + display: 'flex', + paddingLeft: componentTheme.simpleColorContainerLeftPadding, + alignItems: 'center' + }, + hashMarkContainer: { + label: 'colorPicker__hashMarkContainer', + color: hashMarkColor, + display: 'inline-block', + fontSize: '1rem', + lineHeight: componentTheme.hashMarkContainerLineHeight, + ...(isSimple + ? { + paddingInlineStart: componentTheme.hashMarkContainerLeftPadding, + paddingInlineEnd: componentTheme.hashMarkContainerRightPadding + } + : {}) + }, + errorIcons: { + label: 'colorPicker__errorIcons', + display: 'flex', + paddingInlineEnd: componentTheme.errorIconsRightPadding + }, + successIcon: { + label: 'colorPicker__successIcon', + display: 'flex', + paddingInlineEnd: componentTheme.successIconRightPadding + }, + label: { + label: 'colorPicker__label', + marginInlineEnd: componentTheme.labelRightMargin + }, + popoverContent: { + label: 'colorPicker__popoverContent', + padding: componentTheme.popoverContentPadding + }, + popoverContentBlock: { + label: 'colorPicker__popoverContentBlock', + borderTop: 'solid', + borderWidth: componentTheme.popoverContentBlockBorderWidth, + borderColor: componentTheme.popoverSeparatorColor, + marginTop: componentTheme.popoverContentBlockTopMargin, + marginBottom: componentTheme.popoverContentBlockBottomMargin, + paddingTop: componentTheme.popoverContentBlockTopPadding + }, + popoverFooter: { + label: 'colorPicker__popoverFooter', + backgroundColor: componentTheme.popoverFooterColor, + display: 'flex', + justifyContent: 'flex-end', + padding: componentTheme.popoverFooterPadding, + borderTop: `solid ${componentTheme.popoverFooterTopBorderWidth}`, + borderColor: componentTheme.popoverSeparatorColor + }, + colorMixerButtonContainer: { + label: 'colorPicker__colorMixerButtonContainer', + marginInlineStart: componentTheme.colorMixerButtonContainerLeftMargin + }, + popoverContentContainer: { + label: 'colorPicker__popoverContentContainer', + maxHeight: calculatedPopoverMaxHeight || popoverMaxHeight || '100vh', + overflowY: 'auto', + overflowX: 'hidden', + scrollbarGutter: 'stable', + display: 'flex', + flexDirection: 'column', + opacity: state.isHeightCalculated ? 1 : 0, + transition: 'opacity 150ms ease-in' + }, + colorMixerButtonWrapper: { + label: 'colorPicker__colorMixerButtonWrapper', + position: 'static' + } + } +} + +export default generateStyle diff --git a/packages/ui-color-picker/src/ColorPreset/v1/index.tsx b/packages/ui-color-picker/src/ColorPreset/v1/index.tsx index 5b30752fd2..7fb56407c3 100644 --- a/packages/ui-color-picker/src/ColorPreset/v1/index.tsx +++ b/packages/ui-color-picker/src/ColorPreset/v1/index.tsx @@ -28,13 +28,13 @@ import { withStyleLegacy as withStyle } from '@instructure/emotion' import { omitProps } from '@instructure/ui-react-utils' import conversions from '@instructure/ui-color-utils' -import { IconButton, Button } from '@instructure/ui-buttons/latest' -import { View } from '@instructure/ui-view/latest' -import { Tooltip } from '@instructure/ui-tooltip/latest' -import { Popover } from '@instructure/ui-popover/latest' -import { Text } from '@instructure/ui-text/latest' -import { Drilldown } from '@instructure/ui-drilldown/latest' -import type { DrilldownProps } from '@instructure/ui-drilldown/latest' +import { IconButton, Button } from '@instructure/ui-buttons/v11_6' +import { View } from '@instructure/ui-view/v11_6' +import { Tooltip } from '@instructure/ui-tooltip/v11_6' +import { Popover } from '@instructure/ui-popover/v11_6' +import { Text } from '@instructure/ui-text/v11_6' +import { Drilldown } from '@instructure/ui-drilldown/v11_6' +import type { DrilldownProps } from '@instructure/ui-drilldown/v11_6' import { IconAddLine, IconCheckDarkSolid } from '@instructure/ui-icons' import { ColorIndicator } from '../../ColorIndicator/v1' diff --git a/packages/ui-color-picker/src/ColorPreset/v2/README.md b/packages/ui-color-picker/src/ColorPreset/v2/README.md new file mode 100644 index 0000000000..129c250508 --- /dev/null +++ b/packages/ui-color-picker/src/ColorPreset/v2/README.md @@ -0,0 +1,112 @@ +--- +describes: ColorPreset +--- + +A component for picking a color from a list of colors. Supports adding new colors either programmatically through the `colors` prop, or manually with the built in color picker. + +### Color Preset + +```js +--- +type: example +--- + const Example = () => { + const [selected, setSelected] = useState('') + const [colors, setColors] = useState([ + '#ffffff', + '#0CBF94', + '#0C89BF00', + '#BF0C6D', + '#BF8D0C', + '#ff0000', + '#576A66', + '#35423A', + '#35423F' + ]) + + return ( +
+ { + return `color with hex code ${hexCode}${ + isSelected ? ' selected' : '' + }` + }} + /> +
+ ) + } + + render() +``` + +### Color Preset (with addition, deletion) + +```js +--- +type: example +--- + const Example = () => { + const [selected, setSelected] = useState('') + const [colors, setColors] = useState([ + '#ffffff', + '#0CBF94', + '#0C89BF00', + '#BF0C6D', + '#BF8D0C', + '#ff0000', + '#576A66', + '#35423A', + '#35423F' + ]) + + return ( +
+ { + return `color with hex code ${hexCode}${ + isSelected ? ' selected' : '' + }` + }} + /> +
+ ) + } + + render() +``` diff --git a/packages/ui-color-picker/src/ColorPreset/v1/__tests__/ColorPreset.test.tsx b/packages/ui-color-picker/src/ColorPreset/v2/__tests__/ColorPreset.test.tsx similarity index 100% rename from packages/ui-color-picker/src/ColorPreset/v1/__tests__/ColorPreset.test.tsx rename to packages/ui-color-picker/src/ColorPreset/v2/__tests__/ColorPreset.test.tsx diff --git a/packages/ui-color-picker/src/ColorPreset/v2/index.tsx b/packages/ui-color-picker/src/ColorPreset/v2/index.tsx new file mode 100644 index 0000000000..1ee04c8b28 --- /dev/null +++ b/packages/ui-color-picker/src/ColorPreset/v2/index.tsx @@ -0,0 +1,370 @@ +/* + * The MIT License (MIT) + * + * Copyright (c) 2015 - present Instructure, Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import { Component } from 'react' + +import { withStyle } from '@instructure/emotion' +import { omitProps } from '@instructure/ui-react-utils' +import conversions from '@instructure/ui-color-utils' + +import { IconButton, Button } from '@instructure/ui-buttons/latest' +import { View } from '@instructure/ui-view/latest' +import { Tooltip } from '@instructure/ui-tooltip/latest' +import { Popover } from '@instructure/ui-popover/latest' +import { Text } from '@instructure/ui-text/latest' +import { Drilldown } from '@instructure/ui-drilldown/latest' +import type { DrilldownProps } from '@instructure/ui-drilldown/latest' +import { PlusInstUIIcon, CheckInstUIIcon } from '@instructure/ui-icons' + +import { ColorIndicator } from '../../ColorIndicator/v2' +import { ColorMixer } from '../../ColorMixer/v2' +import { ColorContrast } from '../../ColorContrast/v2' + +import generateStyle from './styles' + +import type { ColorPresetProps, ColorPresetState } from './props' +import { allowedProps } from './props' + +/** +--- +category: components +--- +**/ +@withStyle(generateStyle) +class ColorPreset extends Component { + static allowedProps = allowedProps + static readonly componentId = 'ColorPreset' + + static defaultProps = { + disabled: false + } + + constructor(props: ColorPresetProps) { + super(props) + + this.state = { + openEditor: false, + openAddNew: false, + newColor: { r: 51, g: 99, b: 42, a: 1 } + } + } + + ref: HTMLDivElement | null = null + + handleRef = (el: HTMLDivElement | null) => { + const { elementRef } = this.props + + this.ref = el + + if (typeof elementRef === 'function') { + elementRef(el) + } + } + + componentDidMount() { + this.props.makeStyles?.() + } + + componentDidUpdate() { + this.props.makeStyles?.() + } + + get isModifiable() { + return typeof this.props.colorMixerSettings?.onPresetChange === 'function' + } + + isSelectedColor(color: string) { + const { selected } = this.props + return ( + !!selected && + conversions.colorToHex8(selected) === conversions.colorToHex8(color) + ) + } + + onMenuItemSelected = + (color: string): DrilldownProps['onSelect'] => + (_e, args) => { + if (args.value === 'select') { + this.props.onSelect(color) + } + if (args.value === 'remove') { + this.props?.colorMixerSettings?.onPresetChange( + this.props.colors.filter((clr) => clr !== color) + ) + } + } + + renderAddNewPresetButton = () => ( + + + + +
+ } + isShowingContent={this.state.openAddNew} + onShowContent={() => { + if (this.props.disabled) return + this.setState({ openAddNew: true }) + }} + onHideContent={() => { + this.setState({ openAddNew: false }) + }} + on="click" + screenReaderLabel={this.props.popoverScreenReaderLabel} + shouldContainFocus + shouldReturnFocus + shouldCloseOnDocumentClick + offsetY={16} + mountNode={() => document.getElementById('main')} + > +
+ + this.setState({ newColor: conversions.colorToRGB(newColor) }) + } + withAlpha={this.props?.colorMixerSettings?.colorMixer?.withAlpha} + rgbRedInputScreenReaderLabel={ + this.props.colorMixerSettings!.colorMixer + .rgbRedInputScreenReaderLabel + } + rgbGreenInputScreenReaderLabel={ + this.props.colorMixerSettings!.colorMixer + .rgbGreenInputScreenReaderLabel + } + rgbBlueInputScreenReaderLabel={ + this.props.colorMixerSettings!.colorMixer + .rgbBlueInputScreenReaderLabel + } + rgbAlphaInputScreenReaderLabel={ + this.props.colorMixerSettings!.colorMixer + .rgbAlphaInputScreenReaderLabel + } + colorSliderNavigationExplanationScreenReaderLabel={ + this.props.colorMixerSettings!.colorMixer + .colorSliderNavigationExplanationScreenReaderLabel + } + alphaSliderNavigationExplanationScreenReaderLabel={ + this.props.colorMixerSettings!.colorMixer + .alphaSliderNavigationExplanationScreenReaderLabel + } + colorPaletteNavigationExplanationScreenReaderLabel={ + this.props.colorMixerSettings!.colorMixer + .colorPaletteNavigationExplanationScreenReaderLabel + } + /> + {this.props?.colorMixerSettings?.colorContrast && ( +
+ +
+ )} +
+
+ + +
+ + ) + + renderColorIndicator = (color: string, selectOnClick?: boolean) => { + const indicatorBase = this.renderIndicatorBase(color, selectOnClick) + return this.props.disabled + ? indicatorBase + : this.renderIndicatorTooltip(indicatorBase, color) + } + + renderIndicatorBase = (color: string, selectOnClick?: boolean) => { + const hexCode = color + const isSelected = this.isSelectedColor(color) + + const screenReaderLabel = + typeof this.props.colorScreenReaderLabel === 'function' + ? this.props.colorScreenReaderLabel(hexCode, isSelected) + : hexCode + + return ( + this.props.onSelect(color) } + : {})} + > +
+ + {this.isSelectedColor(color) && ( +
+ +
+ )} +
+
+ ) + } + + renderIndicatorTooltip = (child: React.ReactElement, color: string) => { + return ( + {color}
} + elementRef={(element) => { + if ( + element && + element.firstElementChild instanceof HTMLButtonElement + ) { + // The tooltip and the button's aria-label has the same text content. This is redundant for screenreaders. + // Aria-describedby is removed to bypass reading the tooltip twice + element.firstElementChild.removeAttribute('aria-describedby') + } + }} + > + {child} + + ) + } + + renderSettingsMenu = (color: string, index: number) => ( + + + + {this.props.colorMixerSettings!.selectColorLabel} + + + {this.props.colorMixerSettings!.removeColorLabel} + + + + ) + + render() { + const { styles, label, colors } = this.props + + if (!this.isModifiable && colors.length === 0) { + // if there is no preset and no ability to add new, + // there is no point of rendering the component + return null + } + + return ( +
+ {label && ( +
+ {label} +
+ )} + + {this.isModifiable && this.renderAddNewPresetButton()} + + {colors.map((color, index) => { + return this.isModifiable ? ( + this.renderSettingsMenu(color, index) + ) : ( +
+ {this.renderColorIndicator(color, true)} +
+ ) + })} +
+ ) + } +} + +export { ColorPreset } +export default ColorPreset diff --git a/packages/ui-color-picker/src/ColorPreset/v2/props.ts b/packages/ui-color-picker/src/ColorPreset/v2/props.ts new file mode 100644 index 0000000000..31e132d0ab --- /dev/null +++ b/packages/ui-color-picker/src/ColorPreset/v2/props.ts @@ -0,0 +1,160 @@ +/* + * The MIT License (MIT) + * + * Copyright (c) 2015 - present Instructure, Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import type { WithStyleProps, ComponentStyle } from '@instructure/emotion' +import type { + OtherHTMLAttributes, + ColorPresetTheme +} from '@instructure/shared-types' +import type { RGBAType } from '@instructure/ui-color-utils' + +type ContrastStrength = 'min' | 'mid' | 'max' + +type ColorPresetOwnProps = { + /** + * Array of HEX strings which are the preset colors. Supports 8 character HEX (with alpha) + */ + colors: Array + /** + * Makes the component uninteractable + */ + disabled?: boolean + /** + * Provides a reference to the component's underlying html element. + */ + elementRef?: (element: Element | null) => void + /** + * Label text of the component + */ + label?: string + + /** + * If set, a `plus` button will appear for the preset. Those components whose corresponding keys aren't provided (`colorMixer` or `colorContrast`) + * will not be rendered. + * The `onPresetChange` function gets called when a color gets added or removed from the preset list. + * It will be called with the new list of colors + */ + colorMixerSettings?: { + /** + * screenReaderLabel for the add new preset button + */ + addNewPresetButtonScreenReaderLabel: string + selectColorLabel: string + removeColorLabel: string + onPresetChange: (colors: ColorPresetOwnProps['colors']) => void + popoverAddButtonLabel: string + popoverCloseButtonLabel: string + maxHeight?: string + colorMixer: { + withAlpha?: boolean + rgbRedInputScreenReaderLabel: string + rgbGreenInputScreenReaderLabel: string + rgbBlueInputScreenReaderLabel: string + rgbAlphaInputScreenReaderLabel: string + colorSliderNavigationExplanationScreenReaderLabel: string + alphaSliderNavigationExplanationScreenReaderLabel: string + colorPaletteNavigationExplanationScreenReaderLabel: string + } + colorContrast?: { + firstColor: string + label: string + successLabel: string + failureLabel: string + normalTextLabel: string + largeTextLabel: string + graphicsTextLabel: string + firstColorLabel: string + secondColorLabel: string + } + } + /** + * The function gets called when a color gets selected + */ + onSelect: (selected: string) => void + /** + * Sets the ScreenReaderLabel for the popover + */ + popoverScreenReaderLabel?: string + /** + * The currently selected HEX string + */ + selected?: string + /** + * A function for formatting the text provided to screen readers about the color. + * + * @param {string} hexCode - The hexadecimal color code (e.g., "#FFFFFF") of the current color option. Provided by the component - treat as read-only. + * + * @param {boolean} isSelected - Indicates whether this color is currently selected. Provided by the component - treat as read-only. + * + * Sets the aria-label attribute of the color. + * + * If not set, aria-label defaults to the hex code of the color. + */ + colorScreenReaderLabel?: (hexCode: string, isSelected: boolean) => string +} + +type ColorPresetState = { + openEditor: boolean | string + openAddNew: boolean + newColor: RGBAType +} + +type PropKeys = keyof ColorPresetOwnProps + +type AllowedPropKeys = Readonly> + +type ColorPresetProps = ColorPresetOwnProps & + WithStyleProps & + OtherHTMLAttributes + +type ColorPresetStyle = ComponentStyle< + | 'colorPreset' + | 'addNewPresetButton' + | 'selectedIndicator' + | 'popoverContent' + | 'popoverDivider' + | 'popoverFooter' + | 'label' + | 'popoverContrastBlock' +> + +const allowedProps: AllowedPropKeys = [ + 'colors', + 'disabled', + 'elementRef', + 'label', + 'colorMixerSettings', + 'onSelect', + 'popoverScreenReaderLabel', + 'selected', + 'colorScreenReaderLabel' +] + +export type { + ColorPresetProps, + ColorPresetStyle, + ColorPresetState, + ContrastStrength +} +export { allowedProps } diff --git a/packages/ui-color-picker/src/ColorPreset/v2/styles.ts b/packages/ui-color-picker/src/ColorPreset/v2/styles.ts new file mode 100644 index 0000000000..11cc12c0bb --- /dev/null +++ b/packages/ui-color-picker/src/ColorPreset/v2/styles.ts @@ -0,0 +1,112 @@ +/* + * The MIT License (MIT) + * + * Copyright (c) 2015 - present Instructure, Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import type { NewComponentTypes } from '@instructure/ui-themes' +import type { ColorPresetProps } from './props' +/** + * --- + * private: true + * --- + * Generates the style object from the theme and provided additional information + * @param {Object} componentTheme The theme variable object. + * @param {Object} props the props of the component, the style is applied to + * @param {Object} state the state of the component, the style is applied to + * @return {Object} The final style object, which will be used in the component + */ +const generateStyle = ( + componentTheme: NewComponentTypes['ColorPreset'], + props: ColorPresetProps +) => { + const { colorMixerSettings, disabled } = props + return { + colorPreset: { + label: 'colorPreset', + display: 'flex', + flexWrap: 'wrap', + width: '17rem', + ...(disabled && { + opacity: 0.5 + }) + }, + addNewPresetButton: { + label: 'colorPreset__addNewPresetButton', + width: '2.375rem', + height: '2.375rem', + margin: componentTheme.xxSmallSpacing + }, + selectedIndicator: { + label: 'colorPreset__selectedIndicator', + width: '1.25rem', + height: '1.25rem', + borderStyle: 'solid', + borderColor: componentTheme.selectedIndicatorBorderColor, + borderWidth: componentTheme.smallBorder, + borderRadius: '1.25rem', + boxSizing: 'border-box', + position: 'relative', + insetInlineStart: '1.5rem', + bottom: '2.75rem', + backgroundColor: componentTheme.selectedIndicatorBackgroundColor, + display: 'flex', + alignItems: 'center', + justifyContent: 'center' + }, + popoverContent: { + label: 'colorPreset__popoverContent', + padding: componentTheme.smallSpacing, + maxHeight: colorMixerSettings?.maxHeight || '100vh', + overflow: 'auto' + }, + popoverDivider: { + label: 'colorPreset__popoverDivider', + borderTop: 'solid', + borderWidth: componentTheme.smallBorder, + borderColor: componentTheme.popoverDividerColor, + margin: `${componentTheme.smallSpacing} 0 ${componentTheme.smallSpacing} 0` + }, + popoverContrastBlock: { + label: 'colorPreset__popoverContrastBlock', + borderTop: 'solid', + borderWidth: componentTheme.smallBorder, + borderColor: componentTheme.popoverDividerColor, + marginTop: componentTheme.popoverContentBlockTopMargin, + marginBottom: componentTheme.popoverContentBlockBottomMargin, + paddingTop: componentTheme.popoverContentBlockTopPadding + }, + popoverFooter: { + label: 'colorPreset__popoverFooter', + backgroundColor: componentTheme.popoverFooterColor, + display: 'flex', + flexDirection: 'row-reverse', + padding: componentTheme.smallSpacing + }, + label: { + label: 'colorPreset__label', + width: '100%', + margin: componentTheme.xxSmallSpacing + } + } +} + +export default generateStyle diff --git a/packages/ui-color-picker/src/exports/b.ts b/packages/ui-color-picker/src/exports/b.ts new file mode 100644 index 0000000000..e4a7411699 --- /dev/null +++ b/packages/ui-color-picker/src/exports/b.ts @@ -0,0 +1,34 @@ +/* + * The MIT License (MIT) + * + * Copyright (c) 2015 - present Instructure, Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +export { ColorPicker } from '../ColorPicker/v2' +export { ColorMixer } from '../ColorMixer/v2' +export { ColorPreset } from '../ColorPreset/v2' +export { ColorContrast } from '../ColorContrast/v2' +export { ColorIndicator } from '../ColorIndicator/v2' + +export type { ColorPickerProps } from '../ColorPicker/v2/props' +export type { ColorMixerProps } from '../ColorMixer/v2/props' +export type { ColorPresetProps } from '../ColorPreset/v2/props' +export type { ColorContrastProps } from '../ColorContrast/v2/props' +export type { ColorIndicatorProps } from '../ColorIndicator/v2/props'