diff --git a/package.json b/package.json index b0b608f013..9739e5e37d 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,7 @@ "lint:fix": "biome check --write && foundry run eslint . --ext .js,.jsx,.ts,.tsx --fix", "lint:ci": "biome ci && foundry run eslint . --ext .js,.jsx,.ts,.tsx --quiet ", "lint:css": "foundry run stylelint '**/*.css'", + "lint:css:fix": "foundry run stylelint '**/*.css' --fix", "dev": "npm run docs:start", "docs": "npm run docs:start", "docs:start": "storybook dev -p 6006", @@ -95,4 +96,4 @@ "vitest": "^2.0.3", "vitest-github-actions-reporter": "^0.11.1" } -} +} \ No newline at end of file diff --git a/packages/circuit-ui/components/ColorInput/ColorInput.module.css b/packages/circuit-ui/components/ColorInput/ColorInput.module.css index 34f19ca918..d07521564e 100644 --- a/packages/circuit-ui/components/ColorInput/ColorInput.module.css +++ b/packages/circuit-ui/components/ColorInput/ColorInput.module.css @@ -1,36 +1,76 @@ -.suffix { - overflow: hidden; - border-top-right-radius: var(--cui-border-radius-byte); - border-bottom-right-radius: var(--cui-border-radius-byte); - pointer-events: auto !important; - border: none; - border-left: 1px solid var(--cui-border-normal); +.wrapper { + position: relative; + display: flex; +} + +.picker { width: var(--cui-spacings-exa); height: var(--cui-spacings-exa); - position: absolute; - top: 0; - right: 0; - box-shadow: none; + border-top-left-radius: var(--cui-border-radius-byte); + border-bottom-left-radius: var(--cui-border-radius-byte); + box-shadow: 0 0 0 1px var(--cui-border-normal); } -.prefix { - display: flex; - align-items: center; - justify-content: center; - line-height: var(--cui-spacings-mega); +.picker:hover { + background: var(--cui-bg-normal-hovered); + box-shadow: 0 0 0 1px var(--cui-border-normal-hovered); } -.colorInput { - opacity: 0; - width: var(--cui-spacings-exa); - height: var(--cui-spacings-exa); +.picker:focus-within { + background: var(--cui-bg-normal-pressed); + box-shadow: 0 0 0 1px var(--cui-border-normal-pressed); +} + +.color-input { + width: var(--cui-spacings-giga); + height: var(--cui-spacings-giga); + padding: 0; + margin: var(--cui-spacings-kilo); + appearance: none; border: none; - box-shadow: none; + border-radius: 6px; + outline: none; + box-shadow: 0 0 0 1px var(--cui-border-normal); +} + +.color-input::-moz-color-swatch { + border: none; +} + +.color-input::-webkit-color-swatch-wrapper { padding: 0; + border-radius: 0; +} + +.color-input::-webkit-color-swatch { + border: none; +} + +.picker:hover .color-input { + box-shadow: 0 0 0 1px var(--cui-border-normal-hovered); +} + +.picker:focus-within .color-input { + box-shadow: 0 0 0 1px var(--cui-border-normal-pressed); +} + +.symbol { + position: absolute; + top: 0; + left: var(--cui-spacings-exa); + display: grid; + place-items: center center; + width: var(--cui-spacings-giga); + height: var(--cui-spacings-exa); + font-family: var(--cui-font-stack-mono); + color: var(--cui-fg-subtle); } .input { + padding-left: var(--cui-spacings-giga); font-family: var(--cui-font-stack-mono); + border-top-left-radius: 0; + border-bottom-left-radius: 0; } .input::placeholder { @@ -40,7 +80,3 @@ .colorpick { display: inline-block; } - -.colorpick:focus-within input { - box-shadow: 0 0 0 2px var(--cui-border-accent); -} \ No newline at end of file diff --git a/packages/circuit-ui/components/ColorInput/ColorInput.spec.tsx b/packages/circuit-ui/components/ColorInput/ColorInput.spec.tsx index e323c5f449..6ed9b6aafc 100644 --- a/packages/circuit-ui/components/ColorInput/ColorInput.spec.tsx +++ b/packages/circuit-ui/components/ColorInput/ColorInput.spec.tsx @@ -16,7 +16,7 @@ import { describe, expect, it } from 'vitest'; import { createRef } from 'react'; -import { render, axe } from '../../util/test-utils.js'; +import { render, axe, screen } from '../../util/test-utils.js'; import type { InputElement } from '../Input/index.js'; import { ColorInput } from './ColorInput.js'; @@ -36,4 +36,29 @@ describe('ColorInput', () => { const actual = await axe(container); expect(actual).toHaveNoViolations(); }); + + describe('Labeling', () => { + const HEX_SYMBOL = '#'; + + it('should have the currency symbol as part of its accessible description', () => { + render(); + expect(screen.getByRole('textbox')).toHaveAccessibleDescription( + HEX_SYMBOL, + ); + }); + + it('should accept a custom description via aria-describedby', () => { + const customDescription = 'Custom description'; + const customDescriptionId = 'customDescriptionId'; + render( + <> + {customDescription} + + , + ); + expect(screen.getByRole('textbox')).toHaveAccessibleDescription( + `${HEX_SYMBOL} ${customDescription}`, + ); + }); + }); }); diff --git a/packages/circuit-ui/components/ColorInput/ColorInput.tsx b/packages/circuit-ui/components/ColorInput/ColorInput.tsx index 7f22e1b0c4..0a00951521 100644 --- a/packages/circuit-ui/components/ColorInput/ColorInput.tsx +++ b/packages/circuit-ui/components/ColorInput/ColorInput.tsx @@ -17,18 +17,17 @@ import { forwardRef, - useCallback, - useEffect, useId, useRef, useState, type ChangeEventHandler, } from 'react'; -import { Input, type InputElement, type InputProps } from '../Input/index.js'; +import type { InputElement, InputProps } from '../Input/index.js'; import { clsx } from '../../styles/clsx.js'; -import { FieldLabel, FieldLabelText } from '../Field/index.js'; +import { FieldLabel, FieldLabelText, FieldWrapper } from '../Field/index.js'; import { applyMultipleRefs } from '../../util/refs.js'; +import classes from '../Input/Input.module.css'; import styles from './ColorInput.module.css'; @@ -43,7 +42,6 @@ export interface ColorInputProps | 'maxLength' | 'pattern' | 'renderPrefix' - | 'renderSuffix' | 'as' > { /** @@ -67,15 +65,41 @@ export interface ColorInputProps export const ColorInput = forwardRef( ( - { onChange, className, value, defaultValue, pickerLabel, ...props }, + { + onChange, + className, + value, + defaultValue, + pickerLabel, + readOnly, + label, + hasWarning, + hideLabel, + optionalLabel, + required, + style, + disabled, + invalid, + 'renderSuffix': RenderSuffix, + 'aria-describedby': descriptionId, + id, + ...props + }, ref, ) => { const [currentColor, setCurrentColor] = useState( defaultValue, ); - const colorDisplayRef = useRef(null); const colorPickerRef = useRef(null); const pickerId = useId(); + const hexSymbolId = useId(); + const inputFallbackId = useId(); + const inputId = id || inputFallbackId; + const descriptionIds = clsx(hexSymbolId, descriptionId); + + const suffix = RenderSuffix && ; + + const hasSuffix = Boolean(suffix); const onPickerColorChange: ChangeEventHandler = (e) => { setCurrentColor(e.target.value); @@ -91,50 +115,56 @@ export const ColorInput = forwardRef( setCurrentColor(`#${e.target.value}`); }; - useEffect(() => { - if (colorDisplayRef.current && currentColor) { - colorDisplayRef.current.style.backgroundColor = currentColor; - } - }, [currentColor]); + // render suffix only once, otherwise if it gets re-rendered on color change + // the native color-picker widget might get mistakenly dismissed by the browser - const renderSuffix = useCallback( - () => ( -
- + return ( + + + + +
+ + + + # +
- ), - [], - ); - - return ( - ( -
- # -
- )} - renderSuffix={renderSuffix} - value={currentColor ? currentColor.replace('#', '') : undefined} - inputClassName={styles.input} - maxLength={6} - pattern="[0-9a-f]{3,6}" - onChange={onInputChange} - {...props} - /> +
); }, );