diff --git a/.histoire/vite.config.ts b/.histoire/vite.config.ts index 77b985df57..e910bdcdf1 100644 --- a/.histoire/vite.config.ts +++ b/.histoire/vite.config.ts @@ -28,6 +28,7 @@ export default defineConfig({ tree: { groups: [ { title: 'Components', include: _file => true }, + { title: 'Compounds', id: 'compounds' }, { id: 'utilities', title: 'Utilities' }, ], }, diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts index d897efd250..03d42c5879 100644 --- a/docs/.vitepress/config.ts +++ b/docs/.vitepress/config.ts @@ -162,6 +162,19 @@ export default defineConfig({ { text: 'Toggle Group', link: '/docs/components/toggle-group' }, ], }, + { + text: 'Color', + items: [ + { text: `Color Area ${BadgeHTML('Alpha', true)}`, link: '/docs/components/color-area' }, + { text: `Color Field ${BadgeHTML('Alpha', true)}`, link: '/docs/components/color-field' }, + { text: `Color Slider ${BadgeHTML('Alpha', true)}`, link: '/docs/components/color-slider' }, + { text: `Color Swatch ${BadgeHTML('Alpha', true)}`, link: '/docs/components/color-swatch' }, + { + text: `Color Swatch Picker ${BadgeHTML('Alpha', true)}`, + link: '/docs/components/color-swatch-picker', + }, + ], + }, { text: 'Dates', items: [ @@ -314,6 +327,12 @@ export default defineConfig({ { text: 'Checkbox Group', link: '/examples/checkbox-group' }, ], }, + { + text: 'Color', + items: [ + { text: 'Color Picker', link: '/examples/color-picker' }, + ], + }, { text: 'Combobox', items: [ diff --git a/docs/components/demo/ColorArea/tailwind/index.vue b/docs/components/demo/ColorArea/tailwind/index.vue new file mode 100644 index 0000000000..6551bbee9e --- /dev/null +++ b/docs/components/demo/ColorArea/tailwind/index.vue @@ -0,0 +1,31 @@ + + + diff --git a/docs/components/demo/ColorField/tailwind/index.vue b/docs/components/demo/ColorField/tailwind/index.vue new file mode 100644 index 0000000000..216865e1a4 --- /dev/null +++ b/docs/components/demo/ColorField/tailwind/index.vue @@ -0,0 +1,35 @@ + + + diff --git a/docs/components/demo/ColorSlider/tailwind/index.vue b/docs/components/demo/ColorSlider/tailwind/index.vue new file mode 100644 index 0000000000..2b6a458898 --- /dev/null +++ b/docs/components/demo/ColorSlider/tailwind/index.vue @@ -0,0 +1,37 @@ + + + diff --git a/docs/components/demo/ColorSwatch/css/index.vue b/docs/components/demo/ColorSwatch/css/index.vue new file mode 100644 index 0000000000..b0413f57b7 --- /dev/null +++ b/docs/components/demo/ColorSwatch/css/index.vue @@ -0,0 +1,12 @@ + + + diff --git a/docs/components/demo/ColorSwatch/css/styles.css b/docs/components/demo/ColorSwatch/css/styles.css new file mode 100644 index 0000000000..1cf37ca441 --- /dev/null +++ b/docs/components/demo/ColorSwatch/css/styles.css @@ -0,0 +1,11 @@ +.swatch { + width: 32px; + height: 32px; + border-radius: 4px; + box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.15); + flex-shrink: 0; +} + +.dark .swatch { + box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.15); +} \ No newline at end of file diff --git a/docs/components/demo/ColorSwatch/tailwind/index.vue b/docs/components/demo/ColorSwatch/tailwind/index.vue new file mode 100644 index 0000000000..89f9048993 --- /dev/null +++ b/docs/components/demo/ColorSwatch/tailwind/index.vue @@ -0,0 +1,11 @@ + + + diff --git a/docs/components/demo/ColorSwatch/tailwind/tailwind.config.js b/docs/components/demo/ColorSwatch/tailwind/tailwind.config.js new file mode 100644 index 0000000000..fe691bdd5f --- /dev/null +++ b/docs/components/demo/ColorSwatch/tailwind/tailwind.config.js @@ -0,0 +1,6 @@ +/** @type {import('tailwindcss').Config} */ +module.exports = { + content: ['./**/*.vue'], + theme: {}, + plugins: [], +} diff --git a/docs/components/demo/ColorSwatchPicker/css/index.vue b/docs/components/demo/ColorSwatchPicker/css/index.vue new file mode 100644 index 0000000000..67a8f1d194 --- /dev/null +++ b/docs/components/demo/ColorSwatchPicker/css/index.vue @@ -0,0 +1,42 @@ + + + diff --git a/docs/components/demo/ColorSwatchPicker/css/styles.css b/docs/components/demo/ColorSwatchPicker/css/styles.css new file mode 100644 index 0000000000..c927379b69 --- /dev/null +++ b/docs/components/demo/ColorSwatchPicker/css/styles.css @@ -0,0 +1,32 @@ +.root { + display: flex; + gap: 8px; +} + +.item { + position: relative; +} + +.swatch { + height: 32px; + width: 32px; + border-radius: 4px; + box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.15); + flex-shrink: 0; + cursor: pointer; +} + +.dark .swatch { + box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.15); +} + +.indicator { + position: absolute; + bottom: 4px; + right: 4px; + color: white; +} + +.swatch[data-color-contrast='dark'] ~ .indicator { + color: black; +} \ No newline at end of file diff --git a/docs/components/demo/ColorSwatchPicker/tailwind/index.vue b/docs/components/demo/ColorSwatchPicker/tailwind/index.vue new file mode 100644 index 0000000000..cccced8106 --- /dev/null +++ b/docs/components/demo/ColorSwatchPicker/tailwind/index.vue @@ -0,0 +1,41 @@ + + + diff --git a/docs/components/demo/ColorSwatchPicker/tailwind/tailwind.config.js b/docs/components/demo/ColorSwatchPicker/tailwind/tailwind.config.js new file mode 100644 index 0000000000..fe691bdd5f --- /dev/null +++ b/docs/components/demo/ColorSwatchPicker/tailwind/tailwind.config.js @@ -0,0 +1,6 @@ +/** @type {import('tailwindcss').Config} */ +module.exports = { + content: ['./**/*.vue'], + theme: {}, + plugins: [], +} diff --git a/docs/components/examples/ColorPicker/index.vue b/docs/components/examples/ColorPicker/index.vue new file mode 100644 index 0000000000..a906b9a859 --- /dev/null +++ b/docs/components/examples/ColorPicker/index.vue @@ -0,0 +1,192 @@ + + + + + diff --git a/docs/content/docs/components/color-area.md b/docs/content/docs/components/color-area.md new file mode 100644 index 0000000000..8a9e82f322 --- /dev/null +++ b/docs/content/docs/components/color-area.md @@ -0,0 +1,184 @@ +--- + +title: Color Area +description: A two-dimensional area for selecting color values in a specific color space. +name: color-area +--- + +# Color Area + + +A two-dimensional area control that allows users to select color values by interacting with a visual gradient area. Supports multiple color spaces and configurable channels for each axis. + + + + +## Features + + + +## Installation + +Install the component from your command line. + + + +## Anatomy + +Import all parts and piece them together. + +```vue + + + +``` + +## API Reference + +### ColorAreaRoot + +The root component that provides the color area context and state management. + + + +### ColorAreaArea + +The interactive area component where users can select color values by clicking or dragging. + + + +### ColorAreaThumb + +The draggable thumb component that indicates the current position in the color area. + + + +## Examples + +### HSL Saturation/Lightness + +A common use case for color area is selecting saturation and lightness in HSL color space. + +```vue + + + +``` + +### RGB Red/Green Selector + +Using RGB color space with red and green channels. + +```vue + + + +``` + +### Disabled State + +Disable interaction with the color area. + +```vue + + + +``` + +## Accessibility + +### Keyboard Interactions + +| Key | Description | +| --- | --- | +| `ArrowLeft` | Decreases the x-axis channel value by one step. | +| `ArrowRight` | Increases the x-axis channel value by one step. | +| `ArrowUp` | Increases the y-axis channel value by one step. | +| `ArrowDown` | Decreases the y-axis channel value by one step. | +| `Shift + ArrowKey` | Changes values by 10 steps at a time. | +| `PageUp` | Increases the y-axis channel value by a larger step. | +| `PageDown` | Decreases the y-axis channel value by a larger step. | +| `Home` | Jumps left (decreases x-axis value). | +| `End` | Jumps right (increases x-axis value). | diff --git a/docs/content/docs/components/color-field.md b/docs/content/docs/components/color-field.md new file mode 100644 index 0000000000..0362d8f9d5 --- /dev/null +++ b/docs/content/docs/components/color-field.md @@ -0,0 +1,182 @@ +--- + +title: Color Field +description: An input field for entering and editing color values in various formats. +name: color-field +--- + +# Color Field + + +An input field component for entering color values. Supports hex color input or individual color channel values with keyboard navigation and wheel interaction. + + + + +## Features + + + +## Installation + +Install the component from your command line. + + + +## Anatomy + +Import all parts and piece them together. + +```vue + + + +``` + +## API Reference + +### ColorFieldRoot + +The root component that provides the color field context and state management. + + + +### ColorFieldInput + +The input element for entering color values. + + + +## Examples + +### Hex Color Input + +Basic hex color input field. + +```vue + + + +``` + +### Channel Input + +Input for a specific color channel (e.g., hue). + +```vue + + + +``` + +### With Wheel Control Disabled + +Prevent changing values with mouse wheel. + +```vue + + + +``` + +### Read-only Mode + +Display the color value without allowing edits. + +```vue + + + +``` + +## Accessibility + +### Keyboard Interactions + +| Key | Description | +| --- | --- | +| `ArrowUp` | Increases the value by one step. | +| `ArrowDown` | Decreases the value by one step. | +| `PageUp` | Increases the value by 10 steps. | +| `PageDown` | Decreases the value by 10 steps. | +| `Home` | Sets the value to minimum. | +| `End` | Sets the value to maximum. | +| `Enter` | Commits the current input value. | diff --git a/docs/content/docs/components/color-slider.md b/docs/content/docs/components/color-slider.md new file mode 100644 index 0000000000..77dded245c --- /dev/null +++ b/docs/content/docs/components/color-slider.md @@ -0,0 +1,253 @@ +--- + +title: Color Slider +description: A slider control for adjusting individual color channels. +name: color-slider +--- + +# Color Slider + + +A slider component for adjusting individual color channels. Supports all color channels including hue, saturation, lightness, brightness, red, green, blue, and alpha with automatic gradient backgrounds. + + + + +## Features + + + +## Installation + +Install the component from your command line. + + + +## Anatomy + +Import all parts and piece them together. + +```vue + + + +``` + +## API Reference + +### ColorSliderRoot + +The root component that provides the slider functionality and context. + + + +### ColorSliderTrack + +The track component that displays the color gradient background. + + + +### ColorSliderThumb + +The draggable thumb component for selecting values. + + + +## Examples + +### Hue Slider + +A horizontal hue slider in HSL color space. + +```vue + + + +``` + +### Alpha Channel + +Slider for adjusting the alpha (opacity) channel. + +```vue + + + +``` + +### Vertical Orientation + +A vertical slider for space-constrained layouts. + +```vue + + + +``` + +### RGB Channel Sliders + +Sliders for individual RGB channels. + +```vue + + + +``` + +### Custom Step Value + +Use a custom step increment for finer or coarser control. + +```vue + + + +``` + +## Accessibility + +### Keyboard Interactions + +| Key | Description | +| --- | --- | +| `ArrowLeft` | Decreases the value in horizontal orientation. | +| `ArrowRight` | Increases the value in horizontal orientation. | +| `ArrowUp` | Increases the value in vertical orientation. | +| `ArrowDown` | Decreases the value in vertical orientation. | +| `PageUp` | Increases the value by a larger step. | +| `PageDown` | Decreases the value by a larger step. | +| `Home` | Sets the value to minimum. | +| `End` | Sets the value to maximum. | diff --git a/docs/content/docs/components/color-swatch-picker.md b/docs/content/docs/components/color-swatch-picker.md new file mode 100644 index 0000000000..092b1da05f --- /dev/null +++ b/docs/content/docs/components/color-swatch-picker.md @@ -0,0 +1,90 @@ +--- + +title: Color Swatch Picker +description: A component that allows users to select from a set of predefined colors, often used for themes or branding. +name: color-swatch-picker +--- + +# Color Swatch Picker + + +A component that allows users to select from a set of predefined colors, often used for themes or branding. + + + + +## Features + + + +## Installation + +Install the component from your command line. + + + +## Anatomy + +Import all parts and piece them together. + +```vue + + + +``` + +## API Reference + +### ColorSwatchPickerRoot + +The main component that displays a color swatch picker. + + + +### ColorSwatchPickerItem + +The item that represents a selectable color swatch. + + + +### ColorSwatchPickerItemSwatch + +The component that displays the color swatch within an item. + + + +### ColorSwatchPickerItemIndicator + +The component that indicates the selected color swatch within an item. + + diff --git a/docs/content/docs/components/color-swatch.md b/docs/content/docs/components/color-swatch.md new file mode 100644 index 0000000000..e91c869807 --- /dev/null +++ b/docs/content/docs/components/color-swatch.md @@ -0,0 +1,54 @@ +--- + +title: Color Swatch +description: A predefined color block that allows users to quickly select commonly used or brand-specific colors. +name: color-swatch +--- + +# Color Swatch + + +Displays a color swatch, which can be used to represent colors in a UI. + + + + +## Features + + + +## Installation + +Install the component from your command line. + + + +## Anatomy + +Import all parts and piece them together. + +```vue + + + +``` + +## API Reference + +### ColorSwatch + +The main component that displays a color swatch. + + diff --git a/docs/content/docs/guides/migration.md b/docs/content/docs/guides/migration.md index 6524d050f4..b5c4e56f6b 100644 --- a/docs/content/docs/guides/migration.md +++ b/docs/content/docs/guides/migration.md @@ -182,9 +182,9 @@ If you are using Nuxt, you need to update your module import. ```ts // nuxt.config.ts export default defineNuxtConfig({ - modules: [ - 'radix-vue/nuxt' - 'reka-ui/nuxt' - ], + modules: [ + 'radix-vue/nuxt', // [!code --] + 'reka-ui/nuxt', // [!code ++] + ], }) ``` diff --git a/docs/content/examples/color-picker.md b/docs/content/examples/color-picker.md new file mode 100644 index 0000000000..16393cec6e --- /dev/null +++ b/docs/content/examples/color-picker.md @@ -0,0 +1,37 @@ +--- +title: Color Picker +tags: + - ColorArea + - ColorField + - ColorSlider + - ColorSwatch + - Popover +--- + +# Color Picker + + + +A comprehensive color picker component that combines ColorArea, ColorSlider, ColorField, and ColorSwatch inside a Popover. This example demonstrates how to build a complete color selection interface with 2D color area, hue and alpha sliders, and input fields for precise color value entry. + + + + + + + + + +### Color Picker with Popover + +This example shows how to combine multiple color components to create a full-featured color picker: + +- **ColorSwatch** - Displays the current color in the trigger button +- **Popover** - Contains the color picker interface +- **ColorArea** - 2D area for selecting saturation and lightness +- **ColorSlider** - Sliders for hue and alpha channels +- **ColorField** - Input fields for hex and HSL values + +The components are synchronized using a shared Color object, with `normalizeColor` and `colorToString` utilities from reka-ui for color conversion. + + diff --git a/docs/content/meta/ColorAreaArea.md b/docs/content/meta/ColorAreaArea.md new file mode 100644 index 0000000000..56be565bfc --- /dev/null +++ b/docs/content/meta/ColorAreaArea.md @@ -0,0 +1,17 @@ + + + diff --git a/docs/content/meta/ColorAreaRoot.md b/docs/content/meta/ColorAreaRoot.md new file mode 100644 index 0000000000..3b10c59e7a --- /dev/null +++ b/docs/content/meta/ColorAreaRoot.md @@ -0,0 +1,113 @@ + + + + + + + diff --git a/docs/content/meta/ColorAreaThumb.md b/docs/content/meta/ColorAreaThumb.md new file mode 100644 index 0000000000..13d9bfa4df --- /dev/null +++ b/docs/content/meta/ColorAreaThumb.md @@ -0,0 +1,17 @@ + + + diff --git a/docs/content/meta/ColorFieldInput.md b/docs/content/meta/ColorFieldInput.md new file mode 100644 index 0000000000..f6a1d078b6 --- /dev/null +++ b/docs/content/meta/ColorFieldInput.md @@ -0,0 +1,17 @@ + + + diff --git a/docs/content/meta/ColorFieldRoot.md b/docs/content/meta/ColorFieldRoot.md new file mode 100644 index 0000000000..3f11803136 --- /dev/null +++ b/docs/content/meta/ColorFieldRoot.md @@ -0,0 +1,107 @@ + + + + + diff --git a/docs/content/meta/ColorSliderRoot.md b/docs/content/meta/ColorSliderRoot.md new file mode 100644 index 0000000000..abbb17080d --- /dev/null +++ b/docs/content/meta/ColorSliderRoot.md @@ -0,0 +1,111 @@ + + + + + diff --git a/docs/content/meta/ColorSliderThumb.md b/docs/content/meta/ColorSliderThumb.md new file mode 100644 index 0000000000..f5b20082cb --- /dev/null +++ b/docs/content/meta/ColorSliderThumb.md @@ -0,0 +1,30 @@ + + + + + diff --git a/docs/content/meta/ColorSliderTrack.md b/docs/content/meta/ColorSliderTrack.md new file mode 100644 index 0000000000..13d9bfa4df --- /dev/null +++ b/docs/content/meta/ColorSliderTrack.md @@ -0,0 +1,17 @@ + + + diff --git a/docs/content/meta/ColorSwatch.md b/docs/content/meta/ColorSwatch.md new file mode 100644 index 0000000000..e7ca2c4a12 --- /dev/null +++ b/docs/content/meta/ColorSwatch.md @@ -0,0 +1,43 @@ + + + + + diff --git a/docs/content/meta/ColorSwatchPickerItem.md b/docs/content/meta/ColorSwatchPickerItem.md new file mode 100644 index 0000000000..91f55d03fa --- /dev/null +++ b/docs/content/meta/ColorSwatchPickerItem.md @@ -0,0 +1,37 @@ + + + + + diff --git a/docs/content/meta/ColorSwatchPickerItemIndicator.md b/docs/content/meta/ColorSwatchPickerItemIndicator.md new file mode 100644 index 0000000000..56be565bfc --- /dev/null +++ b/docs/content/meta/ColorSwatchPickerItemIndicator.md @@ -0,0 +1,17 @@ + + + diff --git a/docs/content/meta/ColorSwatchPickerItemSwatch.md b/docs/content/meta/ColorSwatchPickerItemSwatch.md new file mode 100644 index 0000000000..a08b218b16 --- /dev/null +++ b/docs/content/meta/ColorSwatchPickerItemSwatch.md @@ -0,0 +1,23 @@ + + + diff --git a/docs/content/meta/ColorSwatchPickerRoot.md b/docs/content/meta/ColorSwatchPickerRoot.md new file mode 100644 index 0000000000..62644d1c93 --- /dev/null +++ b/docs/content/meta/ColorSwatchPickerRoot.md @@ -0,0 +1,112 @@ + + + + + + + diff --git a/docs/content/meta/NumberFieldRoot.md b/docs/content/meta/NumberFieldRoot.md index fcd8cf0a4a..07411875d2 100644 --- a/docs/content/meta/NumberFieldRoot.md +++ b/docs/content/meta/NumberFieldRoot.md @@ -133,5 +133,10 @@ 'name': 'textValue', 'description': '', 'type': 'string' + }, + { + 'name': 'readonly', + 'description': '', + 'type': 'boolean' } ]" /> diff --git a/packages/core/.gitignore b/packages/core/.gitignore index 993a8d3132..9413cbd799 100644 --- a/packages/core/.gitignore +++ b/packages/core/.gitignore @@ -9,10 +9,10 @@ lerna-debug.log* node_modules .DS_Store -dist dist-ssr coverage *.local +dist /cypress/videos/ /cypress/screenshots/ diff --git a/packages/core/constant/components.ts b/packages/core/constant/components.ts index f45dab6020..6a822b734c 100644 --- a/packages/core/constant/components.ts +++ b/packages/core/constant/components.ts @@ -75,6 +75,34 @@ export const components = { 'CollapsibleContent', ] as const, + colorArea: [ + 'ColorAreaRoot', + 'ColorAreaArea', + 'ColorAreaThumb', + ] as const, + + colorField: [ + 'ColorFieldRoot', + 'ColorFieldInput', + ] as const, + + colorSlider: [ + 'ColorSliderRoot', + 'ColorSliderTrack', + 'ColorSliderThumb', + ] as const, + + colorSwatch: [ + 'ColorSwatch', + ] as const, + + colorSwatchPicker: [ + 'ColorSwatchPickerRoot', + 'ColorSwatchPickerItem', + 'ColorSwatchPickerItemSwatch', + 'ColorSwatchPickerItemIndicator', + ] as const, + combobox: [ 'ComboboxRoot', 'ComboboxInput', diff --git a/packages/core/src/ColorArea/ColorArea.test.ts b/packages/core/src/ColorArea/ColorArea.test.ts new file mode 100644 index 0000000000..de5cd5cab6 --- /dev/null +++ b/packages/core/src/ColorArea/ColorArea.test.ts @@ -0,0 +1,504 @@ +import type { VueWrapper } from '@vue/test-utils' +import { mount } from '@vue/test-utils' +import { beforeEach, describe, expect, it } from 'vitest' +import { axe } from 'vitest-axe' +import ColorArea from './story/_ColorArea.vue' + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +/** Returns the x-channel value from aria-valuenow on the slider thumb. */ +function getXValue(wrapper: VueWrapper): number { + return Number(wrapper.find('[role="slider"]').attributes('aria-valuenow')) +} + +/** + * Parses the y-channel value from aria-valuetext. + * Format: "Saturation 50, Lightness 42" + */ +function getYValue(wrapper: VueWrapper, channelName: string): number { + const text = wrapper.find('[role="slider"]').attributes('aria-valuetext') ?? '' + const match = text.match(new RegExp(`${channelName} (\\d+)`)) + return match ? Number(match[1]) : NaN +} + +/** Triggers a keydown on the color area (role=application). */ +async function keydown(wrapper: VueWrapper, key: string, opts: Record = {}) { + await wrapper.find('[role="application"]').trigger('keydown', { key, ...opts }) +} + +// ─── Accessibility ──────────────────────────────────────────────────────────── + +describe('colorArea accessibility', () => { + let wrapper: VueWrapper + + beforeEach(() => { + wrapper = mount(ColorArea, { + props: { + defaultValue: '#bf40bf', // hsl(300, ~50%, 50%) + colorSpace: 'hsl', + xChannel: 'saturation', + yChannel: 'lightness', + }, + }) + }) + + it('passes axe accessibility tests', async () => { + expect( + await axe(wrapper.element, { rules: { label: { enabled: false } } }), + ).toHaveNoViolations() + }) + + it('root has role="group"', () => { + expect(wrapper.find('[role="group"]').exists()).toBe(true) + }) + + it('area has role="application" and aria-roledescription="Color picker"', () => { + const area = wrapper.find('[role="application"]') + expect(area.exists()).toBe(true) + expect(area.attributes('aria-roledescription')).toBe('Color picker') + }) + + it('thumb has role="slider" and aria-roledescription="Color thumb"', () => { + const thumb = wrapper.find('[role="slider"]') + expect(thumb.exists()).toBe(true) + expect(thumb.attributes('aria-roledescription')).toBe('Color thumb') + }) + + it('thumb aria-label names both channels', () => { + expect(wrapper.find('[role="slider"]').attributes('aria-label')).toBe('Saturation, Lightness') + }) +}) + +// ─── HSL Saturation(x) / Lightness(y) ──────────────────────────────────────── + +describe('colorArea HSL Saturation(x) / Lightness(y)', () => { + // #bf40bf ≈ hsl(300, 50%, 50%) — saturation=50, lightness=50 + let wrapper: VueWrapper + + beforeEach(() => { + wrapper = mount(ColorArea, { + props: { + defaultValue: '#bf40bf', + colorSpace: 'hsl', + xChannel: 'saturation', + yChannel: 'lightness', + }, + }) + }) + + describe('initial ARIA values', () => { + it('aria-valuenow = 50 (saturation)', () => { + expect(getXValue(wrapper)).toBe(50) + }) + + it('aria-valuemin = 0, aria-valuemax = 100', () => { + const thumb = wrapper.find('[role="slider"]') + expect(Number(thumb.attributes('aria-valuemin'))).toBe(0) + expect(Number(thumb.attributes('aria-valuemax'))).toBe(100) + }) + + it('aria-valuetext reports both channels', () => { + expect(wrapper.find('[role="slider"]').attributes('aria-valuetext')).toBe( + 'Saturation 50, Lightness 50', + ) + }) + + it('thumb is focusable (tabindex=0)', () => { + expect(wrapper.find('[role="slider"]').attributes('tabindex')).toBe('0') + }) + }) + + describe('arrowRight / ArrowLeft — x-axis (saturation ±1)', () => { + it('arrowRight increments saturation by 1, lightness unchanged', async () => { + await keydown(wrapper, 'ArrowRight') + expect(getXValue(wrapper)).toBe(51) + expect(getYValue(wrapper, 'Lightness')).toBe(50) + }) + + it('arrowLeft decrements saturation by 1, lightness unchanged', async () => { + await keydown(wrapper, 'ArrowLeft') + expect(getXValue(wrapper)).toBe(49) + expect(getYValue(wrapper, 'Lightness')).toBe(50) + }) + + it('shift+ArrowRight increments saturation by 10', async () => { + await keydown(wrapper, 'ArrowRight', { shiftKey: true }) + expect(getXValue(wrapper)).toBe(60) + expect(getYValue(wrapper, 'Lightness')).toBe(50) + }) + + it('shift+ArrowLeft decrements saturation by 10', async () => { + await keydown(wrapper, 'ArrowLeft', { shiftKey: true }) + expect(getXValue(wrapper)).toBe(40) + expect(getYValue(wrapper, 'Lightness')).toBe(50) + }) + }) + + describe('arrowUp / ArrowDown — y-axis (lightness ±1)', () => { + it('arrowUp increments lightness by 1, saturation unchanged', async () => { + await keydown(wrapper, 'ArrowUp') + expect(getXValue(wrapper)).toBe(50) + expect(getYValue(wrapper, 'Lightness')).toBe(51) + }) + + it('arrowDown decrements lightness by 1, saturation unchanged', async () => { + await keydown(wrapper, 'ArrowDown') + expect(getXValue(wrapper)).toBe(50) + expect(getYValue(wrapper, 'Lightness')).toBe(49) + }) + + it('shift+ArrowUp increments lightness by 10', async () => { + await keydown(wrapper, 'ArrowUp', { shiftKey: true }) + expect(getXValue(wrapper)).toBe(50) + expect(getYValue(wrapper, 'Lightness')).toBe(60) + }) + + it('shift+ArrowDown decrements lightness by 10', async () => { + await keydown(wrapper, 'ArrowDown', { shiftKey: true }) + expect(getXValue(wrapper)).toBe(50) + expect(getYValue(wrapper, 'Lightness')).toBe(40) + }) + }) + + describe('pageUp / PageDown — large y-axis jump (±10)', () => { + it('pageUp increments lightness by 10, saturation unchanged', async () => { + await keydown(wrapper, 'PageUp') + expect(getYValue(wrapper, 'Lightness')).toBe(60) + expect(getXValue(wrapper)).toBe(50) + }) + + it('pageDown decrements lightness by 10, saturation unchanged', async () => { + await keydown(wrapper, 'PageDown') + expect(getYValue(wrapper, 'Lightness')).toBe(40) + expect(getXValue(wrapper)).toBe(50) + }) + }) + + describe('home / End — large x-axis jump (±10)', () => { + it('home decrements saturation by 10, lightness unchanged', async () => { + await keydown(wrapper, 'Home') + expect(getXValue(wrapper)).toBe(40) + expect(getYValue(wrapper, 'Lightness')).toBe(50) + }) + + it('end increments saturation by 10, lightness unchanged', async () => { + await keydown(wrapper, 'End') + expect(getXValue(wrapper)).toBe(60) + expect(getYValue(wrapper, 'Lightness')).toBe(50) + }) + }) + + describe('boundary clamping', () => { + it('saturation cannot exceed 100', async () => { + // #ff0000 = hsl(0, 100%, 50%) — saturation is at max + const w = mount(ColorArea, { + props: { defaultValue: '#ff0000', colorSpace: 'hsl', xChannel: 'saturation', yChannel: 'lightness' }, + }) + await keydown(w, 'ArrowRight') + expect(getXValue(w)).toBe(100) + }) + + it('saturation cannot go below 0', async () => { + // #808080 = hsl(0, 0%, 50%) — saturation is at min + const w = mount(ColorArea, { + props: { defaultValue: '#808080', colorSpace: 'hsl', xChannel: 'saturation', yChannel: 'lightness' }, + }) + await keydown(w, 'ArrowLeft') + expect(getXValue(w)).toBe(0) + }) + + it('lightness cannot exceed 100', async () => { + // #ffffff = hsl(*, 0%, 100%) — lightness is at max + const w = mount(ColorArea, { + props: { defaultValue: '#ffffff', colorSpace: 'hsl', xChannel: 'saturation', yChannel: 'lightness' }, + }) + await keydown(w, 'ArrowUp') + expect(getYValue(w, 'Lightness')).toBe(100) + }) + + it('lightness cannot go below 0', async () => { + // #000000 = hsl(*, 0%, 0%) — lightness is at min + const w = mount(ColorArea, { + props: { defaultValue: '#000000', colorSpace: 'hsl', xChannel: 'saturation', yChannel: 'lightness' }, + }) + await keydown(w, 'ArrowDown') + expect(getYValue(w, 'Lightness')).toBe(0) + }) + + it('home at saturation=0 clamps to 0 (no underflow)', async () => { + const w = mount(ColorArea, { + props: { defaultValue: '#808080', colorSpace: 'hsl', xChannel: 'saturation', yChannel: 'lightness' }, + }) + await keydown(w, 'Home') + expect(getXValue(w)).toBe(0) + }) + + it('end at saturation=100 clamps to 100 (no overflow)', async () => { + const w = mount(ColorArea, { + props: { defaultValue: '#ff0000', colorSpace: 'hsl', xChannel: 'saturation', yChannel: 'lightness' }, + }) + await keydown(w, 'End') + expect(getXValue(w)).toBe(100) + }) + }) + + describe('disabled state', () => { + let w: VueWrapper + + beforeEach(() => { + w = mount(ColorArea, { + props: { + defaultValue: '#bf40bf', + colorSpace: 'hsl', + xChannel: 'saturation', + yChannel: 'lightness', + disabled: true, + }, + }) + }) + + it('area has data-disabled=""', () => { + expect(w.find('[role="application"]').attributes('data-disabled')).toBe('') + }) + + it('area has aria-disabled="true"', () => { + expect(w.find('[role="application"]').attributes('aria-disabled')).toBe('true') + }) + + it('thumb has data-disabled="" and no tabindex', () => { + const thumb = w.find('[role="slider"]') + expect(thumb.attributes('data-disabled')).toBe('') + expect(thumb.attributes('tabindex')).toBeUndefined() + }) + + it('arrowRight does not change saturation', async () => { + const before = getXValue(w) + await keydown(w, 'ArrowRight') + expect(getXValue(w)).toBe(before) + }) + + it('arrowUp does not change lightness', async () => { + const before = getYValue(w, 'Lightness') + await keydown(w, 'ArrowUp') + expect(getYValue(w, 'Lightness')).toBe(before) + }) + }) +}) + +// ─── HSL Hue(x) / Saturation(y) — default channels ─────────────────────────── + +describe('colorArea HSL Hue(x) / Saturation(y) — default channels', () => { + // No colorSpace/xChannel/yChannel passed → defaults: hsl / hue / saturation + let wrapper: VueWrapper + + beforeEach(() => { + // #ff0000 = hsl(0, 100%, 50%) — hue=0, saturation=100 + wrapper = mount(ColorArea, { + props: { defaultValue: '#ff0000' }, + }) + }) + + it('aria-label names hue and saturation', () => { + expect(wrapper.find('[role="slider"]').attributes('aria-label')).toBe('Hue, Saturation') + }) + + it('aria-valuenow = 0 (hue)', () => { + expect(getXValue(wrapper)).toBe(0) + }) + + it('aria-valuemin = 0, aria-valuemax = 360 (hue range)', () => { + const thumb = wrapper.find('[role="slider"]') + expect(Number(thumb.attributes('aria-valuemin'))).toBe(0) + expect(Number(thumb.attributes('aria-valuemax'))).toBe(360) + }) + + it('aria-valuetext reports hue and saturation', () => { + expect(wrapper.find('[role="slider"]').attributes('aria-valuetext')).toBe( + 'Hue 0, Saturation 100', + ) + }) + + it('arrowRight increments hue by 1', async () => { + await keydown(wrapper, 'ArrowRight') + expect(getXValue(wrapper)).toBe(1) + expect(getYValue(wrapper, 'Saturation')).toBe(100) + }) + + it('hue cannot go below 0 (clamped at min)', async () => { + await keydown(wrapper, 'ArrowLeft') // hue=0, ArrowLeft tries -1 → clamped to 0 + expect(getXValue(wrapper)).toBe(0) + }) + + it('arrowUp increments saturation by 1, hue unchanged', async () => { + // saturation already at 100; test with a lower saturation color + const w = mount(ColorArea, { props: { defaultValue: '#804040' } }) // hsl(0, ~33%, 38%) approx + const initHue = getXValue(w) + await keydown(w, 'ArrowUp') + expect(getXValue(w)).toBe(initHue) // hue unchanged + }) +}) + +// ─── HSB Saturation(x) / Brightness(y) ─────────────────────────────────────── + +describe('colorArea HSB Saturation(x) / Brightness(y)', () => { + // #800080 ≈ hsb(300, 100%, 50%) + let wrapper: VueWrapper + + beforeEach(() => { + wrapper = mount(ColorArea, { + props: { + defaultValue: '#800080', + colorSpace: 'hsb', + xChannel: 'saturation', + yChannel: 'brightness', + }, + }) + }) + + it('aria-label names saturation and brightness', () => { + expect(wrapper.find('[role="slider"]').attributes('aria-label')).toBe('Saturation, Brightness') + }) + + it('initial saturation = 100', () => { + expect(getXValue(wrapper)).toBe(100) + }) + + it('initial brightness = 50', () => { + expect(getYValue(wrapper, 'Brightness')).toBe(50) + }) + + it('aria-valuemin = 0, aria-valuemax = 100', () => { + const thumb = wrapper.find('[role="slider"]') + expect(Number(thumb.attributes('aria-valuemin'))).toBe(0) + expect(Number(thumb.attributes('aria-valuemax'))).toBe(100) + }) + + it('arrowLeft decrements saturation by 1', async () => { + await keydown(wrapper, 'ArrowLeft') + expect(getXValue(wrapper)).toBe(99) + expect(getYValue(wrapper, 'Brightness')).toBe(50) + }) + + it('arrowLeft 5× decrements saturation by 5', async () => { + for (let i = 0; i < 5; i++) await keydown(wrapper, 'ArrowLeft') + expect(getXValue(wrapper)).toBe(95) + expect(getYValue(wrapper, 'Brightness')).toBe(50) + }) + + it('arrowDown decrements brightness by 1', async () => { + await keydown(wrapper, 'ArrowDown') + expect(getXValue(wrapper)).toBe(100) + expect(getYValue(wrapper, 'Brightness')).toBe(49) + }) + + it('arrowUp increments brightness by 1', async () => { + await keydown(wrapper, 'ArrowUp') + expect(getXValue(wrapper)).toBe(100) + expect(getYValue(wrapper, 'Brightness')).toBe(51) + }) + + it('saturation cannot exceed 100 (already at max)', async () => { + await keydown(wrapper, 'ArrowRight') + expect(getXValue(wrapper)).toBe(100) + }) + + it('brightness cannot exceed 100', async () => { + // Start at full brightness + const w = mount(ColorArea, { + props: { defaultValue: '#ff00ff', colorSpace: 'hsb', xChannel: 'saturation', yChannel: 'brightness' }, + }) + // #ff00ff = hsb(300, 100%, 100%) + await keydown(w, 'ArrowUp') + expect(getYValue(w, 'Brightness')).toBe(100) + }) +}) + +// ─── RGB Red(x) / Green(y) ──────────────────────────────────────────────────── + +describe('colorArea RGB Red(x) / Green(y)', () => { + // #7f007f = rgb(127, 0, 127) + let wrapper: VueWrapper + + beforeEach(() => { + wrapper = mount(ColorArea, { + props: { + defaultValue: '#7f007f', + colorSpace: 'rgb', + xChannel: 'red', + yChannel: 'green', + }, + }) + }) + + it('aria-label names red and green', () => { + expect(wrapper.find('[role="slider"]').attributes('aria-label')).toBe('Red, Green') + }) + + it('aria-valuenow = 127 (red)', () => { + expect(getXValue(wrapper)).toBe(127) + }) + + it('aria-valuemin = 0, aria-valuemax = 255 (RGB range)', () => { + const thumb = wrapper.find('[role="slider"]') + expect(Number(thumb.attributes('aria-valuemin'))).toBe(0) + expect(Number(thumb.attributes('aria-valuemax'))).toBe(255) + }) + + it('initial green = 0', () => { + expect(getYValue(wrapper, 'Green')).toBe(0) + }) + + it('aria-valuetext reports red and green', () => { + expect(wrapper.find('[role="slider"]').attributes('aria-valuetext')).toBe('Red 127, Green 0') + }) + + it('arrowRight increments red by 1, green unchanged', async () => { + await keydown(wrapper, 'ArrowRight') + expect(getXValue(wrapper)).toBe(128) + expect(getYValue(wrapper, 'Green')).toBe(0) + }) + + it('arrowLeft decrements red by 1', async () => { + await keydown(wrapper, 'ArrowLeft') + expect(getXValue(wrapper)).toBe(126) + }) + + it('arrowUp increments green by 1, red unchanged', async () => { + await keydown(wrapper, 'ArrowUp') + expect(getXValue(wrapper)).toBe(127) + expect(getYValue(wrapper, 'Green')).toBe(1) + }) + + it('arrowDown at green=0 stays at 0 (clamp)', async () => { + await keydown(wrapper, 'ArrowDown') + expect(getYValue(wrapper, 'Green')).toBe(0) + }) + + it('shift+ArrowRight increments red by 10', async () => { + await keydown(wrapper, 'ArrowRight', { shiftKey: true }) + expect(getXValue(wrapper)).toBe(137) + }) + + it('shift+ArrowUp increments green by 10', async () => { + await keydown(wrapper, 'ArrowUp', { shiftKey: true }) + expect(getYValue(wrapper, 'Green')).toBe(10) + }) + + it('red cannot exceed 255', async () => { + // #ff00ff = rgb(255, 0, 255) — red at max + const w = mount(ColorArea, { + props: { defaultValue: '#ff00ff', colorSpace: 'rgb', xChannel: 'red', yChannel: 'green' }, + }) + await keydown(w, 'ArrowRight') + expect(getXValue(w)).toBe(255) + }) + + it('red cannot go below 0', async () => { + // #0000ff = rgb(0, 0, 255) — red at min + const w = mount(ColorArea, { + props: { defaultValue: '#0000ff', colorSpace: 'rgb', xChannel: 'red', yChannel: 'green' }, + }) + await keydown(w, 'ArrowLeft') + expect(getXValue(w)).toBe(0) + }) +}) diff --git a/packages/core/src/ColorArea/ColorAreaArea.vue b/packages/core/src/ColorArea/ColorAreaArea.vue new file mode 100644 index 0000000000..451e5dc59e --- /dev/null +++ b/packages/core/src/ColorArea/ColorAreaArea.vue @@ -0,0 +1,139 @@ + + + + + diff --git a/packages/core/src/ColorArea/ColorAreaRoot.vue b/packages/core/src/ColorArea/ColorAreaRoot.vue new file mode 100644 index 0000000000..a0dda664bd --- /dev/null +++ b/packages/core/src/ColorArea/ColorAreaRoot.vue @@ -0,0 +1,251 @@ + + + + + diff --git a/packages/core/src/ColorArea/ColorAreaThumb.vue b/packages/core/src/ColorArea/ColorAreaThumb.vue new file mode 100644 index 0000000000..352dc1a20c --- /dev/null +++ b/packages/core/src/ColorArea/ColorAreaThumb.vue @@ -0,0 +1,70 @@ + + + + + diff --git a/packages/core/src/ColorArea/index.ts b/packages/core/src/ColorArea/index.ts new file mode 100644 index 0000000000..855ca1c1d6 --- /dev/null +++ b/packages/core/src/ColorArea/index.ts @@ -0,0 +1,14 @@ +export { + default as ColorAreaArea, + type ColorAreaAreaProps, +} from './ColorAreaArea.vue' +export { + default as ColorAreaRoot, + type ColorAreaRootEmits, + type ColorAreaRootProps, + injectColorAreaRootContext, +} from './ColorAreaRoot.vue' +export { + default as ColorAreaThumb, + type ColorAreaThumbProps, +} from './ColorAreaThumb.vue' diff --git a/packages/core/src/ColorArea/story/ColorAreaDemo.story.vue b/packages/core/src/ColorArea/story/ColorAreaDemo.story.vue new file mode 100644 index 0000000000..6e7d4dd99e --- /dev/null +++ b/packages/core/src/ColorArea/story/ColorAreaDemo.story.vue @@ -0,0 +1,69 @@ + + + diff --git a/packages/core/src/ColorArea/story/_ColorArea.vue b/packages/core/src/ColorArea/story/_ColorArea.vue new file mode 100644 index 0000000000..1284164fc5 --- /dev/null +++ b/packages/core/src/ColorArea/story/_ColorArea.vue @@ -0,0 +1,44 @@ + + + diff --git a/packages/core/src/ColorArea/utils.ts b/packages/core/src/ColorArea/utils.ts new file mode 100644 index 0000000000..682802f7f8 --- /dev/null +++ b/packages/core/src/ColorArea/utils.ts @@ -0,0 +1,24 @@ +import { clamp } from '@/shared' + +// https://github.com/tmcw-up-for-adoption/simple-linear-scale/blob/master/index.js +export function linearScale(input: readonly [number, number], output: readonly [number, number]) { + return (value: number) => { + if (input[0] === input[1] || output[0] === output[1]) + return output[0] + const ratio = (output[1] - output[0]) / (input[1] - input[0]) + return output[0] + ratio * (value - input[0]) + } +} + +export function convertValueToPercentage(value: number, min: number, max: number) { + if (max === min) + return 0 + const maxSteps = max - min + const percentPerStep = 100 / maxSteps + const percentage = percentPerStep * (value - min) + return clamp(percentage, 0, 100) +} + +export function getThumbPosition(percentage: number): string { + return `${percentage}%` +} diff --git a/packages/core/src/ColorField/ColorField.test.ts b/packages/core/src/ColorField/ColorField.test.ts new file mode 100644 index 0000000000..e18db6adc7 --- /dev/null +++ b/packages/core/src/ColorField/ColorField.test.ts @@ -0,0 +1,341 @@ +import type { VueWrapper } from '@vue/test-utils' +import { mount } from '@vue/test-utils' +import { beforeEach, describe, expect, it } from 'vitest' +import { axe } from 'vitest-axe' +import ColorField from './story/_ColorField.vue' + +describe('given default ColorField (hex mode)', () => { + let wrapper: VueWrapper> + + beforeEach(() => { + wrapper = mount(ColorField, { + props: { + defaultValue: '#ff0000', + }, + }) + }) + + it('should pass axe accessibility tests', async () => { + expect(await axe(wrapper.element, { + rules: { + label: { enabled: false }, + }, + })).toHaveNoViolations() + }) + + it('should render input with hex value', () => { + const input = wrapper.find('input') + expect(input.exists()).toBe(true) + expect(input.element.value).toBe('#ff0000') + }) + + it('should update value on input', async () => { + const input = wrapper.find('input') + await input.setValue('#00ff00') + await input.trigger('blur') + // Value should be updated after blur + expect(wrapper.emitted()).toBeDefined() + }) + + it('should accept valid hex colors', async () => { + const input = wrapper.find('input') + await input.setValue('#00ff00') + await input.trigger('blur') + // Valid color should be accepted + expect(wrapper.emitted()).toBeDefined() + }) + + describe('when disabled', () => { + beforeEach(async () => { + await wrapper.setProps({ disabled: true }) + }) + + it('should have disabled attribute', () => { + const input = wrapper.find('input') + expect(input.attributes('disabled')).toBeDefined() + }) + + it('should have data-disabled attribute', () => { + const input = wrapper.find('input') + expect(input.attributes('data-disabled')).toBe('') + }) + }) + + describe('when readonly', () => { + beforeEach(async () => { + await wrapper.setProps({ readonly: true }) + }) + + it('should have readonly attribute', () => { + const input = wrapper.find('input') + expect(input.attributes('readonly')).toBeDefined() + }) + }) +}) + +describe('given ColorField in channel mode', () => { + it('should render hue value', () => { + const wrapper = mount(ColorField, { + props: { + defaultValue: '#ff0000', + channel: 'hue', + colorSpace: 'hsl', + }, + }) + const input = wrapper.find('input') + expect(input.element.value).toBe('0') + }) + + it('should render saturation value', () => { + const wrapper = mount(ColorField, { + props: { + defaultValue: '#7f007f', + channel: 'saturation', + colorSpace: 'hsl', + }, + }) + const input = wrapper.find('input') + expect(input.element.value).toBe('100') + }) + + it('should render lightness value', () => { + const wrapper = mount(ColorField, { + props: { + defaultValue: '#bf40bf', + channel: 'lightness', + colorSpace: 'hsl', + }, + }) + const input = wrapper.find('input') + expect(input.element.value).toBe('50') + }) + + it('should render alpha value', () => { + const wrapper = mount(ColorField, { + props: { + defaultValue: 'rgba(255, 0, 0, 0.5)', + channel: 'alpha', + colorSpace: 'hsl', + }, + }) + const input = wrapper.find('input') + expect(input.element.value).toBe('50') + }) + + it('should have numeric inputmode', () => { + const wrapper = mount(ColorField, { + props: { + defaultValue: '#ff0000', + channel: 'hue', + colorSpace: 'hsl', + }, + }) + const input = wrapper.find('input') + expect(input.attributes('inputmode')).toBe('numeric') + }) +}) + +describe('given ColorField with placeholder', () => { + it('should show placeholder when empty', () => { + const wrapper = mount(ColorField, { + props: { + placeholder: 'Enter color', + }, + }) + const input = wrapper.find('input') + expect(input.attributes('placeholder')).toBe('Enter color') + }) +}) + +describe('keyboard interactions', () => { + describe('channel mode', () => { + it('should increment on ArrowUp', async () => { + const wrapper = mount(ColorField, { + props: { + defaultValue: '#ff0000', + channel: 'hue', + colorSpace: 'hsl', + }, + }) + const input = wrapper.find('input') + expect(input.element.value).toBe('0') + await input.trigger('keydown', { key: 'ArrowUp' }) + expect(input.element.value).toBe('1') + }) + + it('should decrement on ArrowDown', async () => { + const wrapper = mount(ColorField, { + props: { + defaultValue: '#bf40bf', + channel: 'lightness', + colorSpace: 'hsl', + }, + }) + const input = wrapper.find('input') + expect(input.element.value).toBe('50') + await input.trigger('keydown', { key: 'ArrowDown' }) + expect(input.element.value).toBe('49') + }) + + it('should jump to max on End key', async () => { + const wrapper = mount(ColorField, { + props: { + defaultValue: '#ff0000', + channel: 'red', + colorSpace: 'rgb', + }, + }) + const input = wrapper.find('input') + await input.trigger('keydown', { key: 'End' }) + expect(input.element.value).toBe('255') + }) + + it('should jump to min on Home key', async () => { + const wrapper = mount(ColorField, { + props: { + defaultValue: '#ff0000', + channel: 'red', + colorSpace: 'rgb', + }, + }) + const input = wrapper.find('input') + await input.trigger('keydown', { key: 'Home' }) + expect(input.element.value).toBe('0') + }) + + it('should step by page amount on PageUp/PageDown', async () => { + const wrapper = mount(ColorField, { + props: { + defaultValue: '#bf40bf', + channel: 'lightness', + colorSpace: 'hsl', + }, + }) + const input = wrapper.find('input') + expect(input.element.value).toBe('50') + await input.trigger('keydown', { key: 'PageUp' }) + expect(input.element.value).toBe('60') + await input.trigger('keydown', { key: 'PageDown' }) + expect(input.element.value).toBe('50') + }) + + it('should respect custom step prop', async () => { + const wrapper = mount(ColorField, { + props: { + defaultValue: '#ff0000', + channel: 'hue', + colorSpace: 'hsl', + step: 10, + }, + }) + const input = wrapper.find('input') + expect(input.element.value).toBe('0') + await input.trigger('keydown', { key: 'ArrowUp' }) + expect(input.element.value).toBe('10') + }) + + it('should clamp at channel boundaries', async () => { + const wrapper = mount(ColorField, { + props: { + defaultValue: '#ff0000', + channel: 'hue', + colorSpace: 'hsl', + }, + }) + const input = wrapper.find('input') + expect(input.element.value).toBe('0') + await input.trigger('keydown', { key: 'ArrowDown' }) + // Should clamp to 0 + expect(input.element.value).toBe('0') + }) + }) + + describe('hex mode', () => { + it('should increment hex value on ArrowUp', async () => { + const wrapper = mount(ColorField, { + props: { + defaultValue: '#000000', + }, + }) + const input = wrapper.find('input') + expect(input.element.value).toBe('#000000') + await input.trigger('keydown', { key: 'ArrowUp' }) + expect(input.element.value).toBe('#000001') + }) + + it('should decrement hex value on ArrowDown', async () => { + const wrapper = mount(ColorField, { + props: { + defaultValue: '#000002', + }, + }) + const input = wrapper.find('input') + await input.trigger('keydown', { key: 'ArrowDown' }) + expect(input.element.value).toBe('#000001') + }) + + it('should jump to #ffffff on End', async () => { + const wrapper = mount(ColorField, { + props: { + defaultValue: '#000000', + }, + }) + const input = wrapper.find('input') + await input.trigger('keydown', { key: 'End' }) + expect(input.element.value).toBe('#ffffff') + }) + + it('should jump to #000000 on Home', async () => { + const wrapper = mount(ColorField, { + props: { + defaultValue: '#ff0000', + }, + }) + const input = wrapper.find('input') + await input.trigger('keydown', { key: 'Home' }) + expect(input.element.value).toBe('#000000') + }) + }) + + it('should commit on Enter key', async () => { + const wrapper = mount(ColorField, { + props: { + defaultValue: '#ff0000', + }, + }) + const input = wrapper.find('input') + await input.setValue('#00ff00') + await input.trigger('keydown', { key: 'Enter' }) + expect(input.element.value).toBe('#00ff00') + }) + + it('should not increment when disabled', async () => { + const wrapper = mount(ColorField, { + props: { + defaultValue: '#ff0000', + channel: 'hue', + colorSpace: 'hsl', + disabled: true, + }, + }) + const input = wrapper.find('input') + expect(input.element.value).toBe('0') + await input.trigger('keydown', { key: 'ArrowUp' }) + expect(input.element.value).toBe('0') + }) + + it('should not increment when readonly', async () => { + const wrapper = mount(ColorField, { + props: { + defaultValue: '#ff0000', + channel: 'hue', + colorSpace: 'hsl', + readonly: true, + }, + }) + const input = wrapper.find('input') + expect(input.element.value).toBe('0') + await input.trigger('keydown', { key: 'ArrowUp' }) + expect(input.element.value).toBe('0') + }) +}) diff --git a/packages/core/src/ColorField/ColorFieldInput.vue b/packages/core/src/ColorField/ColorFieldInput.vue new file mode 100644 index 0000000000..205842fc9c --- /dev/null +++ b/packages/core/src/ColorField/ColorFieldInput.vue @@ -0,0 +1,133 @@ + + + + + diff --git a/packages/core/src/ColorField/ColorFieldRoot.vue b/packages/core/src/ColorField/ColorFieldRoot.vue new file mode 100644 index 0000000000..65ee1a5c7c --- /dev/null +++ b/packages/core/src/ColorField/ColorFieldRoot.vue @@ -0,0 +1,326 @@ + + + + + diff --git a/packages/core/src/ColorField/index.ts b/packages/core/src/ColorField/index.ts new file mode 100644 index 0000000000..003f3bff02 --- /dev/null +++ b/packages/core/src/ColorField/index.ts @@ -0,0 +1,10 @@ +export { + default as ColorFieldInput, + type ColorFieldInputProps, +} from './ColorFieldInput.vue' +export { + default as ColorFieldRoot, + type ColorFieldRootEmits, + type ColorFieldRootProps, + injectColorFieldRootContext, +} from './ColorFieldRoot.vue' diff --git a/packages/core/src/ColorField/story/ColorFieldDemo.story.vue b/packages/core/src/ColorField/story/ColorFieldDemo.story.vue new file mode 100644 index 0000000000..83709019e3 --- /dev/null +++ b/packages/core/src/ColorField/story/ColorFieldDemo.story.vue @@ -0,0 +1,155 @@ + + + diff --git a/packages/core/src/ColorField/story/_ColorField.vue b/packages/core/src/ColorField/story/_ColorField.vue new file mode 100644 index 0000000000..4ff65da7fc --- /dev/null +++ b/packages/core/src/ColorField/story/_ColorField.vue @@ -0,0 +1,50 @@ + + + diff --git a/packages/core/src/ColorPicker/story/ColorPickerDemo.story.vue b/packages/core/src/ColorPicker/story/ColorPickerDemo.story.vue new file mode 100644 index 0000000000..9e354910a1 --- /dev/null +++ b/packages/core/src/ColorPicker/story/ColorPickerDemo.story.vue @@ -0,0 +1,31 @@ + + + diff --git a/packages/core/src/ColorPicker/story/_ColorPicker.vue b/packages/core/src/ColorPicker/story/_ColorPicker.vue new file mode 100644 index 0000000000..f5198e9e07 --- /dev/null +++ b/packages/core/src/ColorPicker/story/_ColorPicker.vue @@ -0,0 +1,149 @@ + + + diff --git a/packages/core/src/ColorSlider/ColorSlider.test.ts b/packages/core/src/ColorSlider/ColorSlider.test.ts new file mode 100644 index 0000000000..87fbac119d --- /dev/null +++ b/packages/core/src/ColorSlider/ColorSlider.test.ts @@ -0,0 +1,213 @@ +import type { VueWrapper } from '@vue/test-utils' +import { mount } from '@vue/test-utils' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { axe } from 'vitest-axe' +import ColorSlider from './story/_ColorSlider.vue' + +globalThis.ResizeObserver = class ResizeObserver { + observe() {} + unobserve() {} + disconnect() {} +} +window.HTMLElement.prototype.scrollIntoView = vi.fn() +window.HTMLElement.prototype.hasPointerCapture = vi.fn().mockImplementation(id => id) +window.HTMLElement.prototype.releasePointerCapture = vi.fn() +window.HTMLElement.prototype.setPointerCapture = vi.fn() + +describe('given default ColorSlider', () => { + let wrapper: VueWrapper> + + beforeEach(() => { + wrapper = mount(ColorSlider, { + props: { + defaultValue: '#ff0000', + channel: 'hue', + colorSpace: 'hsl', + }, + }) + }) + + it('should pass axe accessibility tests', async () => { + expect(await axe(wrapper.element, { + rules: { + label: { enabled: false }, + }, + })).toHaveNoViolations() + }) + + it('should render with initial value', () => { + const thumb = wrapper.find('[role="slider"]') + expect(thumb.exists()).toBe(true) + expect(thumb.attributes('aria-valuemin')).toBe('0') + expect(thumb.attributes('aria-valuemax')).toBe('360') + }) + + it('should have correct aria attributes', () => { + const thumb = wrapper.find('[role="slider"]') + expect(thumb.attributes('aria-label')).toBe('Hue') + expect(thumb.attributes('aria-orientation')).toBe('horizontal') + }) + + describe('when disabled', () => { + beforeEach(async () => { + await wrapper.setProps({ disabled: true }) + }) + + it('should not have tabindex when disabled', () => { + const thumb = wrapper.find('[role="slider"]') + expect(thumb.attributes('tabindex')).toBeUndefined() + }) + + it('should have data-disabled attribute', () => { + const thumb = wrapper.find('[role="slider"]') + expect(thumb.attributes('data-disabled')).toBe('') + }) + }) + + describe('keyboard navigation', () => { + it('should increment on ArrowRight', async () => { + const thumb = wrapper.find('[role="slider"]') + const initialValue = Number(thumb.attributes('aria-valuenow')) + await thumb.trigger('keydown', { key: 'ArrowRight' }) + const newValue = Number(thumb.attributes('aria-valuenow')) + // Due to hex↔hsl round-trip precision, check that value increased + expect(newValue).toBeGreaterThan(initialValue) + }) + + it('should decrement on ArrowLeft', async () => { + const thumb = wrapper.find('[role="slider"]') + // First increment to have room to decrement + await thumb.trigger('keydown', { key: 'ArrowRight' }) + const afterIncrement = Number(thumb.attributes('aria-valuenow')) + await thumb.trigger('keydown', { key: 'ArrowLeft' }) + const afterDecrement = Number(thumb.attributes('aria-valuenow')) + expect(afterDecrement).toBeLessThan(afterIncrement) + }) + + it('should jump to min on Home key', async () => { + const w = mount(ColorSlider, { + props: { + defaultValue: '#7f007f', + channel: 'red', + colorSpace: 'rgb', + }, + }) + const thumb = w.find('[role="slider"]') + await thumb.trigger('keydown', { key: 'Home' }) + expect(thumb.attributes('aria-valuenow')).toBe('0') + }) + }) +}) + +describe('given different channels', () => { + it('should render saturation slider', () => { + const wrapper = mount(ColorSlider, { + props: { + defaultValue: '#7f007f', + channel: 'saturation', + colorSpace: 'hsl', + }, + }) + const thumb = wrapper.find('[role="slider"]') + expect(thumb.attributes('aria-label')).toBe('Saturation') + expect(thumb.attributes('aria-valuemax')).toBe('100') + }) + + it('should render lightness slider', () => { + const wrapper = mount(ColorSlider, { + props: { + defaultValue: '#7f007f', + channel: 'lightness', + colorSpace: 'hsl', + }, + }) + const thumb = wrapper.find('[role="slider"]') + expect(thumb.attributes('aria-label')).toBe('Lightness') + expect(thumb.attributes('aria-valuemax')).toBe('100') + }) + + it('should render alpha slider', () => { + const wrapper = mount(ColorSlider, { + props: { + defaultValue: 'rgba(127, 0, 127, 0.5)', + channel: 'alpha', + colorSpace: 'hsl', + }, + }) + const thumb = wrapper.find('[role="slider"]') + expect(thumb.attributes('aria-label')).toBe('Alpha') + expect(thumb.attributes('aria-valuemax')).toBe('100') + }) + + it('should render red slider with RGB range', () => { + const wrapper = mount(ColorSlider, { + props: { + defaultValue: '#7f007f', + channel: 'red', + colorSpace: 'rgb', + }, + }) + const thumb = wrapper.find('[role="slider"]') + expect(thumb.attributes('aria-label')).toBe('Red') + expect(thumb.attributes('aria-valuemax')).toBe('255') + }) +}) + +describe('given vertical orientation', () => { + it('should have vertical aria-orientation', () => { + const wrapper = mount(ColorSlider, { + props: { + defaultValue: '#ff0000', + channel: 'hue', + colorSpace: 'hsl', + orientation: 'vertical', + }, + }) + const thumb = wrapper.find('[role="slider"]') + expect(thumb.attributes('aria-orientation')).toBe('vertical') + }) +}) + +describe('alpha channel aria-valuetext', () => { + it('should display correct percentage for alpha', () => { + const wrapper = mount(ColorSlider, { + props: { + defaultValue: 'rgba(127, 0, 127, 0.5)', + channel: 'alpha', + colorSpace: 'hsl', + }, + }) + const thumb = wrapper.find('[role="slider"]') + expect(thumb.attributes('aria-valuetext')).toBe('50%') + }) + + it('should display 100% for full alpha', () => { + const wrapper = mount(ColorSlider, { + props: { + defaultValue: '#ff0000', + channel: 'alpha', + colorSpace: 'hsl', + }, + }) + const thumb = wrapper.find('[role="slider"]') + expect(thumb.attributes('aria-valuetext')).toBe('100%') + }) +}) + +describe('custom step prop', () => { + it('should use custom step value', () => { + const wrapper = mount(ColorSlider, { + props: { + defaultValue: '#ff0000', + channel: 'hue', + colorSpace: 'hsl', + step: 10, + }, + }) + const thumb = wrapper.find('[role="slider"]') + expect(thumb.exists()).toBe(true) + // The SliderRoot should receive step=10 + expect(thumb.attributes('aria-valuemin')).toBe('0') + expect(thumb.attributes('aria-valuemax')).toBe('360') + }) +}) diff --git a/packages/core/src/ColorSlider/ColorSliderRoot.vue b/packages/core/src/ColorSlider/ColorSliderRoot.vue new file mode 100644 index 0000000000..0913415618 --- /dev/null +++ b/packages/core/src/ColorSlider/ColorSliderRoot.vue @@ -0,0 +1,224 @@ + + + + + diff --git a/packages/core/src/ColorSlider/ColorSliderThumb.vue b/packages/core/src/ColorSlider/ColorSliderThumb.vue new file mode 100644 index 0000000000..40bd637554 --- /dev/null +++ b/packages/core/src/ColorSlider/ColorSliderThumb.vue @@ -0,0 +1,54 @@ + + + + + diff --git a/packages/core/src/ColorSlider/ColorSliderTrack.vue b/packages/core/src/ColorSlider/ColorSliderTrack.vue new file mode 100644 index 0000000000..2bb7702a54 --- /dev/null +++ b/packages/core/src/ColorSlider/ColorSliderTrack.vue @@ -0,0 +1,36 @@ + + + + + diff --git a/packages/core/src/ColorSlider/index.ts b/packages/core/src/ColorSlider/index.ts new file mode 100644 index 0000000000..a3c4d550ab --- /dev/null +++ b/packages/core/src/ColorSlider/index.ts @@ -0,0 +1,14 @@ +export { + default as ColorSliderRoot, + type ColorSliderRootEmits, + type ColorSliderRootProps, + injectColorSliderRootContext, +} from './ColorSliderRoot.vue' +export { + default as ColorSliderThumb, + type ColorSliderThumbProps, +} from './ColorSliderThumb.vue' +export { + default as ColorSliderTrack, + type ColorSliderTrackProps, +} from './ColorSliderTrack.vue' diff --git a/packages/core/src/ColorSlider/story/ColorSliderDemo.story.vue b/packages/core/src/ColorSlider/story/ColorSliderDemo.story.vue new file mode 100644 index 0000000000..2cd6485cb6 --- /dev/null +++ b/packages/core/src/ColorSlider/story/ColorSliderDemo.story.vue @@ -0,0 +1,135 @@ + + + diff --git a/packages/core/src/ColorSlider/story/_ColorSlider.vue b/packages/core/src/ColorSlider/story/_ColorSlider.vue new file mode 100644 index 0000000000..4de72a40ce --- /dev/null +++ b/packages/core/src/ColorSlider/story/_ColorSlider.vue @@ -0,0 +1,55 @@ + + + diff --git a/packages/core/src/ColorSlider/story/_ColorSliderCompound.vue b/packages/core/src/ColorSlider/story/_ColorSliderCompound.vue new file mode 100644 index 0000000000..1ff66cce14 --- /dev/null +++ b/packages/core/src/ColorSlider/story/_ColorSliderCompound.vue @@ -0,0 +1,76 @@ + + + diff --git a/packages/core/src/ColorSlider/utils.ts b/packages/core/src/ColorSlider/utils.ts new file mode 100644 index 0000000000..9c697eac3c --- /dev/null +++ b/packages/core/src/ColorSlider/utils.ts @@ -0,0 +1,39 @@ +import { clamp } from '@/shared' + +export const PAGE_KEYS = ['PageUp', 'PageDown'] +export const ARROW_KEYS = ['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'] + +export type SlideDirection = 'from-left' | 'from-right' | 'from-bottom' | 'from-top' + +export const BACK_KEYS: Record = { + 'from-left': ['Home', 'PageDown', 'ArrowDown', 'ArrowLeft'], + 'from-right': ['Home', 'PageDown', 'ArrowDown', 'ArrowRight'], + 'from-bottom': ['Home', 'PageDown', 'ArrowDown', 'ArrowLeft'], + 'from-top': ['Home', 'PageUp', 'ArrowUp', 'ArrowLeft'], +} + +// https://github.com/tmcw-up-for-adoption/simple-linear-scale/blob/master/index.js +export function linearScale(input: readonly [number, number], output: readonly [number, number]) { + return (value: number) => { + if (input[0] === input[1] || output[0] === output[1]) + return output[0] + const ratio = (output[1] - output[0]) / (input[1] - input[0]) + return output[0] + ratio * (value - input[0]) + } +} + +export function convertValueToPercentage(value: number, min: number, max: number) { + if (max === min) + return 0 + const maxSteps = max - min + const percentPerStep = 100 / maxSteps + const percentage = percentPerStep * (value - min) + return clamp(percentage, 0, 100) +} + +export function getThumbPosition(percentage: number, orientation: 'horizontal' | 'vertical'): string { + if (orientation === 'horizontal') { + return `calc(${percentage}% - var(--reka-slider-thumb-size, 16px) / 2)` + } + return `calc(${100 - percentage}% - var(--reka-slider-thumb-size, 16px) / 2)` +} diff --git a/packages/core/src/ColorSwatch/ColorSwatch.test.ts b/packages/core/src/ColorSwatch/ColorSwatch.test.ts new file mode 100644 index 0000000000..d79371a185 --- /dev/null +++ b/packages/core/src/ColorSwatch/ColorSwatch.test.ts @@ -0,0 +1,149 @@ +import { mount } from '@vue/test-utils' +import { describe, expect, it } from 'vitest' +import { axe } from 'vitest-axe' +import { ColorSwatch } from '.' + +describe('colorSwatch', () => { + describe('given a default ColorSwatch', () => { + it('should pass axe accessibility tests', async () => { + const wrapper = mount(ColorSwatch, { + props: { color: '#E5484D' }, + attachTo: document.body, + }) + expect(await axe(wrapper.element)).toHaveNoViolations() + }) + + it('should render with role="img"', () => { + const wrapper = mount(ColorSwatch, { + props: { color: '#E5484D' }, + }) + expect(wrapper.attributes('role')).toBe('img') + }) + + it('should render with aria-roledescription="color swatch"', () => { + const wrapper = mount(ColorSwatch, { + props: { color: '#E5484D' }, + }) + expect(wrapper.attributes('aria-roledescription')).toBe('color swatch') + }) + }) + + describe('aria-label', () => { + it('should derive color name as aria-label for hex color', () => { + const wrapper = mount(ColorSwatch, { + props: { color: '#ff0000' }, + }) + const label = wrapper.attributes('aria-label') + expect(label).toBeTruthy() + expect(label).not.toBe('#ff0000') + }) + + it('should use custom label when provided', () => { + const wrapper = mount(ColorSwatch, { + props: { color: '#E5484D', label: 'Brand Red' }, + }) + expect(wrapper.attributes('aria-label')).toBe('Brand Red') + }) + + it('should show "transparent" for alpha=0 color', () => { + const wrapper = mount(ColorSwatch, { + props: { color: '#ff000000' }, + }) + expect(wrapper.attributes('aria-label')).toBe('transparent') + }) + + it('should show "transparent" for empty color', () => { + const wrapper = mount(ColorSwatch, { + props: { color: '' }, + }) + expect(wrapper.attributes('aria-label')).toBe('transparent') + }) + + it('should show "transparent" when no color prop provided', () => { + const wrapper = mount(ColorSwatch) + expect(wrapper.attributes('aria-label')).toBe('transparent') + }) + }) + + describe('color object support', () => { + it('should accept Color object', () => { + const wrapper = mount(ColorSwatch, { + props: { + color: { space: 'hsl' as const, h: 120, s: 50, l: 50, alpha: 1 }, + }, + }) + expect(wrapper.element).toBeTruthy() + expect(wrapper.attributes('aria-label')).toBeTruthy() + }) + }) + + describe('data attributes', () => { + it('should set data-no-color when alpha is 0', () => { + const wrapper = mount(ColorSwatch, { + props: { color: '#ff000000' }, + }) + expect(wrapper.attributes('data-no-color')).toBe('') + }) + + it('should set data-no-color when no color', () => { + const wrapper = mount(ColorSwatch, { + props: { color: '' }, + }) + expect(wrapper.attributes('data-no-color')).toBe('') + }) + + it('should not set data-no-color for opaque color', () => { + const wrapper = mount(ColorSwatch, { + props: { color: '#E5484D' }, + }) + expect(wrapper.attributes('data-no-color')).toBeUndefined() + }) + + it('should set data-color-contrast to light for dark colors', () => { + const wrapper = mount(ColorSwatch, { + props: { color: '#000000' }, + }) + // "light" means text should be light on this dark background + expect(wrapper.attributes('data-color-contrast')).toBe('light') + }) + + it('should set data-color-contrast to dark for bright colors', () => { + const wrapper = mount(ColorSwatch, { + props: { color: '#ffffff' }, + }) + // "dark" means text should be dark on this light background + expect(wrapper.attributes('data-color-contrast')).toBe('dark') + }) + }) + + describe('cSS variables', () => { + it('should set --reka-color-swatch-color', () => { + const wrapper = mount(ColorSwatch, { + props: { color: '#E5484D' }, + }) + expect(wrapper.attributes('style')).toContain('--reka-color-swatch-color: #E5484D') + }) + + it('should set --reka-color-swatch-alpha', () => { + const wrapper = mount(ColorSwatch, { + props: { color: '#E5484D' }, + }) + expect(wrapper.attributes('style')).toContain('--reka-color-swatch-alpha: 1') + }) + }) + + describe('slot', () => { + it('should expose color and alpha via slot', () => { + const wrapper = mount(ColorSwatch, { + props: { color: '#E5484D' }, + slots: { + default: ({ color, alpha }: { color: string, alpha: number }) => { + return `${color}-${alpha}` + }, + }, + }) + expect(wrapper.text()).toContain('#E5484D') + expect(wrapper.text()).toContain('1') + }) + }) +}) diff --git a/packages/core/src/ColorSwatch/ColorSwatch.vue b/packages/core/src/ColorSwatch/ColorSwatch.vue new file mode 100644 index 0000000000..565d121be8 --- /dev/null +++ b/packages/core/src/ColorSwatch/ColorSwatch.vue @@ -0,0 +1,97 @@ + + + + + diff --git a/packages/core/src/ColorSwatch/index.ts b/packages/core/src/ColorSwatch/index.ts new file mode 100644 index 0000000000..db424fa21e --- /dev/null +++ b/packages/core/src/ColorSwatch/index.ts @@ -0,0 +1,4 @@ +export { + default as ColorSwatch, + type ColorSwatchProps, +} from './ColorSwatch.vue' diff --git a/packages/core/src/ColorSwatch/story/ColorSwatchDemo.story.vue b/packages/core/src/ColorSwatch/story/ColorSwatchDemo.story.vue new file mode 100644 index 0000000000..8128566266 --- /dev/null +++ b/packages/core/src/ColorSwatch/story/ColorSwatchDemo.story.vue @@ -0,0 +1,86 @@ + + + diff --git a/packages/core/src/ColorSwatch/story/_ColorSwatch.vue b/packages/core/src/ColorSwatch/story/_ColorSwatch.vue new file mode 100644 index 0000000000..702b184046 --- /dev/null +++ b/packages/core/src/ColorSwatch/story/_ColorSwatch.vue @@ -0,0 +1,15 @@ + + + diff --git a/packages/core/src/ColorSwatchPicker/ColorSwatchPicker.test.ts b/packages/core/src/ColorSwatchPicker/ColorSwatchPicker.test.ts new file mode 100644 index 0000000000..a75e2246a0 --- /dev/null +++ b/packages/core/src/ColorSwatchPicker/ColorSwatchPicker.test.ts @@ -0,0 +1,76 @@ +import type { DOMWrapper, VueWrapper } from '@vue/test-utils' +import { mount } from '@vue/test-utils' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { axe } from 'vitest-axe' +import ColorSwatchPicker from './story/_ColorSwatchPicker.vue' + +describe('given default ColorSwatchPicker', () => { + let wrapper: VueWrapper> + let content: DOMWrapper + let items: DOMWrapper[] + + beforeEach(() => { + document.body.innerHTML = '' + wrapper = mount(ColorSwatchPicker, { attachTo: document.body }) + content = wrapper.find('[role=listbox]') + items = wrapper.findAll('[role=option]') + }) + + afterEach(() => { + wrapper.unmount() + }) + + it('should render the component', () => { + expect(wrapper.exists()).toBe(true) + expect(content.exists()).toBe(true) + expect(items.length).toBeGreaterThan(0) + }) + + it('should have no accessibility violations', async () => { + const results = await axe(wrapper.element) + expect(results).toHaveNoViolations() + }) + + it('should render items with data-color attributes', () => { + expect(items[0].attributes('data-color')).toBe('#E5484D') + expect(items[1].attributes('data-color')).toBe('#D6409F') + }) + + it('should set CSS variable on items', () => { + expect(items[0].attributes('style')).toContain('--reka-color-swatch-picker-item-color: #E5484D') + }) + + it('should have aria-label on items with color name', () => { + for (const item of items) { + expect(item.attributes('aria-label')).toBeTruthy() + } + }) + + describe('selection', () => { + it('should select an item on click', async () => { + await items[0].trigger('click') + expect(items[0].attributes('data-state')).toBe('checked') + }) + + it('should update selection when clicking a different item', async () => { + await items[0].trigger('click') + expect(items[0].attributes('data-state')).toBe('checked') + + await items[2].trigger('click') + expect(items[2].attributes('data-state')).toBe('checked') + expect(items[0].attributes('data-state')).toBe('unchecked') + }) + }) + + describe('keyboard navigation', () => { + it('should have horizontal orientation for keyboard navigation', () => { + expect(content.attributes('aria-orientation')).toBe('horizontal') + }) + + it('should have role=option on items', () => { + for (const item of items) { + expect(item.attributes('role')).toBe('option') + } + }) + }) +}) diff --git a/packages/core/src/ColorSwatchPicker/ColorSwatchPickerItem.vue b/packages/core/src/ColorSwatchPicker/ColorSwatchPickerItem.vue new file mode 100644 index 0000000000..0537da4b8b --- /dev/null +++ b/packages/core/src/ColorSwatchPicker/ColorSwatchPickerItem.vue @@ -0,0 +1,60 @@ + + + + + diff --git a/packages/core/src/ColorSwatchPicker/ColorSwatchPickerItemIndicator.vue b/packages/core/src/ColorSwatchPicker/ColorSwatchPickerItemIndicator.vue new file mode 100644 index 0000000000..7294b6935e --- /dev/null +++ b/packages/core/src/ColorSwatchPicker/ColorSwatchPickerItemIndicator.vue @@ -0,0 +1,17 @@ + + + + + diff --git a/packages/core/src/ColorSwatchPicker/ColorSwatchPickerItemSwatch.vue b/packages/core/src/ColorSwatchPicker/ColorSwatchPickerItemSwatch.vue new file mode 100644 index 0000000000..189daffa4d --- /dev/null +++ b/packages/core/src/ColorSwatchPicker/ColorSwatchPickerItemSwatch.vue @@ -0,0 +1,21 @@ + + + + + diff --git a/packages/core/src/ColorSwatchPicker/ColorSwatchPickerRoot.vue b/packages/core/src/ColorSwatchPicker/ColorSwatchPickerRoot.vue new file mode 100644 index 0000000000..0275fc7ada --- /dev/null +++ b/packages/core/src/ColorSwatchPicker/ColorSwatchPickerRoot.vue @@ -0,0 +1,50 @@ + + + + + diff --git a/packages/core/src/ColorSwatchPicker/index.ts b/packages/core/src/ColorSwatchPicker/index.ts new file mode 100644 index 0000000000..9320ca4703 --- /dev/null +++ b/packages/core/src/ColorSwatchPicker/index.ts @@ -0,0 +1,17 @@ +export { + default as ColorSwatchPickerItem, + type ColorSwatchPickerItemProps, +} from './ColorSwatchPickerItem.vue' +export { + default as ColorSwatchPickerItemIndicator, + type ColorSwatchPickerItemIndicatorProps, +} from './ColorSwatchPickerItemIndicator.vue' +export { + default as ColorSwatchPickerItemSwatch, + type ColorSwatchPickerItemSwatchProps, +} from './ColorSwatchPickerItemSwatch.vue' +export { + default as ColorSwatchPickerRoot, + type ColorSwatchPickerRootEmits, + type ColorSwatchPickerRootProps, +} from './ColorSwatchPickerRoot.vue' diff --git a/packages/core/src/ColorSwatchPicker/story/ColorSwatchPickerDemo.story.vue b/packages/core/src/ColorSwatchPicker/story/ColorSwatchPickerDemo.story.vue new file mode 100644 index 0000000000..94028ac379 --- /dev/null +++ b/packages/core/src/ColorSwatchPicker/story/ColorSwatchPickerDemo.story.vue @@ -0,0 +1,134 @@ + + + diff --git a/packages/core/src/ColorSwatchPicker/story/_ColorSwatchPicker.vue b/packages/core/src/ColorSwatchPicker/story/_ColorSwatchPicker.vue new file mode 100644 index 0000000000..945f96c365 --- /dev/null +++ b/packages/core/src/ColorSwatchPicker/story/_ColorSwatchPicker.vue @@ -0,0 +1,40 @@ + + + diff --git a/packages/core/src/NumberField/NumberFieldRoot.vue b/packages/core/src/NumberField/NumberFieldRoot.vue index 6f7337ee09..066c9c3e7f 100644 --- a/packages/core/src/NumberField/NumberFieldRoot.vue +++ b/packages/core/src/NumberField/NumberFieldRoot.vue @@ -205,10 +205,10 @@ provideNumberFieldRootContext({ inputEl, onInputElement: el => inputEl.value = el, textValue, + readonly, validate, applyInputValue, disabled, - readonly, disableWheelChange, invertWheelChange, max, @@ -232,6 +232,7 @@ provideNumberFieldRootContext({ - + diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 67eebf126b..8772cc5d2c 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -6,6 +6,11 @@ export * from './Avatar' export * from './Calendar' export * from './Checkbox' export * from './Collapsible' +export * from './ColorArea' +export * from './ColorField' +export * from './ColorSlider' +export * from './ColorSwatch' +export * from './ColorSwatchPicker' export * from './Combobox' // utilities export * from './ConfigProvider' @@ -59,6 +64,36 @@ export { useStateMachine, withDefault, } from './shared' +// Color utilities +export { + type Color, + type ColorChannel, + type ColorFormat, + type ColorSpace, + colorToHex, + colorToHsb, + colorToHsl, + colorToRgb, + colorToString, + convertToHsb, + convertToHsl, + convertToRgb, + getAreaBackgroundStyle, + getAreaGradient, + getChannelName, + getChannelRange, + getChannelValue, + getSliderBackgroundStyle, + getSliderGradient, + type HSBColor, + type HSLColor, + isValidColor, + normalizeColor, + parseColor, + type RGBColor, + setChannelValue, + setChannelValues, +} from './shared/color' export { type AcceptableValue, type GenericComponentInstance, diff --git a/packages/core/src/shared/color/channel.ts b/packages/core/src/shared/color/channel.ts new file mode 100644 index 0000000000..4b52e8417f --- /dev/null +++ b/packages/core/src/shared/color/channel.ts @@ -0,0 +1,424 @@ +import type { ChannelRange, Color, ColorChannel, ColorSpace, HSBColor, HSLColor, RGBColor } from './types' +import { convertToHsb, convertToHsl, convertToRgb } from './convert' + +/** + * Gets the range (min, max, step) for a color channel. + */ +export function getChannelRange(channel: ColorChannel): ChannelRange { + switch (channel) { + case 'hue': + return { min: 0, max: 360, step: 1 } + case 'saturation': + case 'lightness': + case 'brightness': + case 'alpha': + return { min: 0, max: 100, step: 1 } + case 'red': + case 'green': + case 'blue': + return { min: 0, max: 255, step: 1 } + default: + throw new Error(`Unknown channel: ${channel}`) + } +} + +/** + * Gets the display name for a channel. + */ +export function getChannelName(channel: ColorChannel): string { + const names: Record = { + red: 'Red', + green: 'Green', + blue: 'Blue', + hue: 'Hue', + saturation: 'Saturation', + lightness: 'Lightness', + brightness: 'Brightness', + alpha: 'Alpha', + } + return names[channel] ?? channel +} + +/** + * Gets the value of a specific channel from a color. + * Avoids conversion if the color is already in the target color space. + */ +export function getChannelValue(color: Color, channel: ColorChannel): number { + switch (channel) { + case 'red': + return color.space === 'rgb' ? (color as RGBColor).r : convertToRgb(color).r + case 'green': + return color.space === 'rgb' ? (color as RGBColor).g : convertToRgb(color).g + case 'blue': + return color.space === 'rgb' ? (color as RGBColor).b : convertToRgb(color).b + case 'hue': + return color.space === 'hsl' ? (color as HSLColor).h : convertToHsl(color).h + case 'saturation': + if (color.space === 'hsl') + return (color as HSLColor).s + if (color.space === 'hsb') + return (color as HSBColor).s + return convertToHsl(color).s + case 'lightness': + return color.space === 'hsl' ? (color as HSLColor).l : convertToHsl(color).l + case 'brightness': + return color.space === 'hsb' ? (color as HSBColor).b : convertToHsb(color).b + case 'alpha': + return color.alpha * 100 + default: + throw new Error(`Unknown channel: ${channel}`) + } +} + +/** + * Sets a channel value on a color, returning a new color. + * The returned color maintains the original color space. + */ +export function setChannelValue(color: Color, channel: ColorChannel, value: number): Color { + const range = getChannelRange(channel) + const clamped = Math.max(range.min, Math.min(range.max, value)) + + // Alpha is handled the same across all spaces + if (channel === 'alpha') { + return { ...color, alpha: clamped / 100 } as Color + } + + // For RGB channels, convert to RGB, set, then convert back + if (channel === 'red' || channel === 'green' || channel === 'blue') { + const rgb = convertToRgb(color) + const newRgb: RGBColor = { + ...rgb, + [channel === 'red' ? 'r' : channel === 'green' ? 'g' : 'b']: clamped, + } + return convertFromRgb(newRgb, color.space) + } + + // For HSL channels + if (channel === 'hue' || channel === 'lightness') { + const hsl = convertToHsl(color) + const newHsl: HSLColor = { + ...hsl, + [channel === 'hue' ? 'h' : 'l']: clamped, + } + return convertFromHsl(newHsl, color.space) + } + + // For saturation, respect the color's native space (HSB vs HSL) + if (channel === 'saturation') { + if (color.space === 'hsb') { + const hsb = convertToHsb(color) + const newHsb: HSBColor = { ...hsb, s: clamped } + return convertFromHsb(newHsb, color.space) + } + const hsl = convertToHsl(color) + const newHsl: HSLColor = { ...hsl, s: clamped } + return convertFromHsl(newHsl, color.space) + } + + // For brightness (HSB-only) + if (channel === 'brightness') { + const hsb = convertToHsb(color) + const newHsb: HSBColor = { ...hsb, b: clamped } + return convertFromHsb(newHsb, color.space) + } + + throw new Error(`Unknown channel: ${channel}`) +} + +/** + * Sets multiple channel values at once, preserving exact values. + * Useful when updating 2D color areas where both channels change simultaneously. + */ +export function setChannelValues( + color: Color, + channels: Array<{ channel: ColorChannel, value: number }>, +): Color { + if (channels.length === 0) + return color + if (channels.length === 1) { + return setChannelValue(color, channels[0].channel, channels[0].value) + } + + // Determine the target color space based on all channels + const channelNames = channels.map(c => c.channel) + const hasBrightness = channelNames.includes('brightness') + const hasLightness = channelNames.includes('lightness') + const hasRgb = channelNames.some(c => c === 'red' || c === 'green' || c === 'blue') + + let workingColor: Color + + if (hasRgb) { + workingColor = convertToRgb(color) + } + else if (hasBrightness) { + // HSB mode + workingColor = convertToHsb(color) + } + else if (hasLightness) { + // HSL mode + workingColor = convertToHsl(color) + } + else { + // Default to HSL for hue/saturation + workingColor = convertToHsl(color) + } + + // Apply all channel values + for (const { channel, value } of channels) { + const range = getChannelRange(channel) + const clamped = Math.max(range.min, Math.min(range.max, value)) + + if (channel === 'alpha') { + workingColor = { ...workingColor, alpha: clamped / 100 } + } + else if (workingColor.space === 'rgb' && (channel === 'red' || channel === 'green' || channel === 'blue')) { + const key = channel === 'red' ? 'r' : channel === 'green' ? 'g' : 'b' + workingColor = { ...workingColor, [key]: clamped } as RGBColor + } + else if (workingColor.space === 'hsl' && (channel === 'hue' || channel === 'saturation' || channel === 'lightness')) { + const key = channel === 'hue' ? 'h' : channel === 'saturation' ? 's' : 'l' + workingColor = { ...workingColor, [key]: clamped } as HSLColor + } + else if (workingColor.space === 'hsb' && (channel === 'hue' || channel === 'saturation' || channel === 'brightness')) { + const key = channel === 'hue' ? 'h' : channel === 'saturation' ? 's' : 'b' + workingColor = { ...workingColor, [key]: clamped } as HSBColor + } + } + + // For color areas, keep the working color space to preserve precision + // Only convert back if we're in single-channel mode (channels.length === 1) + // In multi-channel mode (like 2D color area), the working color space is more appropriate + if (channels.length === 1 && workingColor.space !== color.space) { + if (color.space === 'rgb') + return convertToRgb(workingColor) + if (color.space === 'hsl') + return convertToHsl(workingColor) + if (color.space === 'hsb') + return convertToHsb(workingColor) + } + + return workingColor +} + +/** + * Converts an RGB color to a specific color space. + */ +function convertFromRgb(rgb: RGBColor, targetSpace: ColorSpace): Color { + if (targetSpace === 'rgb') { + return rgb + } + if (targetSpace === 'hsl') { + return rgbToHsl(rgb) + } + if (targetSpace === 'hsb') { + return rgbToHsb(rgb) + } + throw new Error(`Unknown color space: ${targetSpace}`) +} + +/** + * Converts an HSL color to a specific color space. + */ +function convertFromHsl(hsl: HSLColor, targetSpace: ColorSpace): Color { + if (targetSpace === 'hsl') { + return hsl + } + const rgb = hslToRgb(hsl) + if (targetSpace === 'rgb') { + return rgb + } + if (targetSpace === 'hsb') { + return rgbToHsb(rgb) + } + throw new Error(`Unknown color space: ${targetSpace}`) +} + +/** + * Converts an HSB color to a specific color space. + */ +function convertFromHsb(hsb: HSBColor, targetSpace: ColorSpace): Color { + if (targetSpace === 'hsb') { + return hsb + } + const rgb = hsbToRgb(hsb) + if (targetSpace === 'rgb') { + return rgb + } + if (targetSpace === 'hsl') { + return rgbToHsl(rgb) + } + throw new Error(`Unknown color space: ${targetSpace}`) +} + +// Conversion helpers (duplicated from convert.ts to avoid circular deps) +function rgbToHsl(rgb: RGBColor): HSLColor { + const r = rgb.r / 255 + const g = rgb.g / 255 + const b = rgb.b / 255 + + const max = Math.max(r, g, b) + const min = Math.min(r, g, b) + let h = 0 + let s = 0 + const l = (max + min) / 2 + + if (max !== min) { + const d = max - min + s = l > 0.5 ? d / (2 - max - min) : d / (max + min) + switch (max) { + case r: + h = (g - b) / d + (g < b ? 6 : 0) + break + case g: + h = (b - r) / d + 2 + break + case b: + h = (r - g) / d + 4 + break + } + h /= 6 + } + + return { + space: 'hsl', + h: h * 360, + s: s * 100, + l: l * 100, + alpha: rgb.alpha, + } +} + +function hslToRgb(hsl: HSLColor): RGBColor { + const h = hsl.h / 360 + const s = hsl.s / 100 + const l = hsl.l / 100 + + let r: number, g: number, b: number + + if (s === 0) { + r = g = b = l + } + else { + const hue2rgb = (p: number, q: number, t: number) => { + if (t < 0) + t += 1 + if (t > 1) + t -= 1 + if (t < 1 / 6) + return p + (q - p) * 6 * t + if (t < 1 / 2) + return q + if (t < 2 / 3) + return p + (q - p) * (2 / 3 - t) * 6 + return p + } + + const q = l < 0.5 ? l * (1 + s) : l + s - l * s + const p = 2 * l - q + r = hue2rgb(p, q, h + 1 / 3) + g = hue2rgb(p, q, h) + b = hue2rgb(p, q, h - 1 / 3) + } + + return { + space: 'rgb', + r: r * 255, + g: g * 255, + b: b * 255, + alpha: hsl.alpha, + } +} + +function rgbToHsb(rgb: RGBColor): HSBColor { + const r = rgb.r / 255 + const g = rgb.g / 255 + const b = rgb.b / 255 + + const max = Math.max(r, g, b) + const min = Math.min(r, g, b) + const d = max - min + + let h = 0 + const s = max === 0 ? 0 : d / max + const v = max + + if (max !== min) { + switch (max) { + case r: + h = (g - b) / d + (g < b ? 6 : 0) + break + case g: + h = (b - r) / d + 2 + break + case b: + h = (r - g) / d + 4 + break + } + h /= 6 + } + + return { + space: 'hsb', + h: h * 360, + s: s * 100, + b: v * 100, + alpha: rgb.alpha, + } +} + +function hsbToRgb(hsb: HSBColor): RGBColor { + const h = hsb.h / 360 + const s = hsb.s / 100 + const v = hsb.b / 100 + + let r = 0 + let g = 0 + let b = 0 + + const i = Math.floor(h * 6) + const f = h * 6 - i + const p = v * (1 - s) + const q = v * (1 - f * s) + const t = v * (1 - (1 - f) * s) + + switch (i % 6) { + case 0: + r = v + g = t + b = p + break + case 1: + r = q + g = v + b = p + break + case 2: + r = p + g = v + b = t + break + case 3: + r = p + g = q + b = v + break + case 4: + r = t + g = p + b = v + break + case 5: + r = v + g = p + b = q + break + } + + return { + space: 'rgb', + r: r * 255, + g: g * 255, + b: b * 255, + alpha: hsb.alpha, + } +} diff --git a/packages/core/src/shared/color/convert.ts b/packages/core/src/shared/color/convert.ts new file mode 100644 index 0000000000..755483388c --- /dev/null +++ b/packages/core/src/shared/color/convert.ts @@ -0,0 +1,293 @@ +import type { Color, ColorFormat, HSBColor, HSLColor, RGBColor } from './types' + +/** + * Converts a Color object to a string representation. + */ +export function colorToString(color: Color, format: ColorFormat = 'hex'): string { + switch (format) { + case 'hex': + return colorToHex(color) + case 'rgb': + return colorToRgb(color) + case 'hsl': + return colorToHsl(color) + case 'hsb': + return colorToHsb(color) + default: + throw new Error(`Unknown format: ${format}`) + } +} + +/** + * Converts any color to hex string. + */ +export function colorToHex(color: Color): string { + const rgb = color.space === 'rgb' ? color : convertToRgb(color) + const toHex = (n: number) => Math.round(n).toString(16).padStart(2, '0') + + if (rgb.alpha < 1) { + const alpha = Math.round(rgb.alpha * 255).toString(16).padStart(2, '0') + return `#${toHex(rgb.r)}${toHex(rgb.g)}${toHex(rgb.b)}${alpha}` + } + + return `#${toHex(rgb.r)}${toHex(rgb.g)}${toHex(rgb.b)}` +} + +/** + * Converts any color to rgb() string. + */ +export function colorToRgb(color: Color): string { + const rgb = color.space === 'rgb' ? color : convertToRgb(color) + const { r, g, b, alpha } = rgb + + if (alpha < 1) { + return `rgba(${Math.round(r)}, ${Math.round(g)}, ${Math.round(b)}, ${alpha})` + } + return `rgb(${Math.round(r)}, ${Math.round(g)}, ${Math.round(b)})` +} + +/** + * Converts any color to hsl() string. + */ +export function colorToHsl(color: Color): string { + const hsl = color.space === 'hsl' ? color : convertToHsl(color) + const { h, s, l, alpha } = hsl + + if (alpha < 1) { + return `hsla(${Math.round(h)}, ${Math.round(s)}%, ${Math.round(l)}%, ${alpha})` + } + return `hsl(${Math.round(h)}, ${Math.round(s)}%, ${Math.round(l)}%)` +} + +/** + * Converts any color to hsb() string. + */ +export function colorToHsb(color: Color): string { + const hsb = color.space === 'hsb' ? color : convertToHsb(color) + const { h, s, b, alpha } = hsb + + if (alpha < 1) { + return `hsba(${Math.round(h)}, ${Math.round(s)}%, ${Math.round(b)}%, ${alpha})` + } + return `hsb(${Math.round(h)}, ${Math.round(s)}%, ${Math.round(b)}%)` +} + +/** + * Converts any color to RGB color space. + */ +export function convertToRgb(color: Color): RGBColor { + if (color.space === 'rgb') { + return color + } + if (color.space === 'hsl') { + return hslToRgb(color) + } + if (color.space === 'hsb') { + return hsbToRgb(color) + } + throw new Error(`Unknown color space: ${(color as Color).space}`) +} + +/** + * Converts any color to HSL color space. + */ +export function convertToHsl(color: Color): HSLColor { + if (color.space === 'hsl') { + return color + } + return rgbToHsl(convertToRgb(color)) +} + +/** + * Converts any color to HSB color space. + */ +export function convertToHsb(color: Color): HSBColor { + if (color.space === 'hsb') { + return color + } + return rgbToHsb(convertToRgb(color)) +} + +/** + * Converts RGB to HSL. + */ +function rgbToHsl(rgb: RGBColor): HSLColor { + const r = rgb.r / 255 + const g = rgb.g / 255 + const b = rgb.b / 255 + + const max = Math.max(r, g, b) + const min = Math.min(r, g, b) + let h = 0 + let s = 0 + const l = (max + min) / 2 + + if (max !== min) { + const d = max - min + s = l > 0.5 ? d / (2 - max - min) : d / (max + min) + switch (max) { + case r: + h = (g - b) / d + (g < b ? 6 : 0) + break + case g: + h = (b - r) / d + 2 + break + case b: + h = (r - g) / d + 4 + break + } + h /= 6 + } + + return { + space: 'hsl', + h: h * 360, + s: s * 100, + l: l * 100, + alpha: rgb.alpha, + } +} + +/** + * Converts HSL to RGB. + */ +function hslToRgb(hsl: HSLColor): RGBColor { + const h = hsl.h / 360 + const s = hsl.s / 100 + const l = hsl.l / 100 + + let r: number, g: number, b: number + + if (s === 0) { + r = g = b = l + } + else { + const hue2rgb = (p: number, q: number, t: number) => { + if (t < 0) + t += 1 + if (t > 1) + t -= 1 + if (t < 1 / 6) + return p + (q - p) * 6 * t + if (t < 1 / 2) + return q + if (t < 2 / 3) + return p + (q - p) * (2 / 3 - t) * 6 + return p + } + + const q = l < 0.5 ? l * (1 + s) : l + s - l * s + const p = 2 * l - q + r = hue2rgb(p, q, h + 1 / 3) + g = hue2rgb(p, q, h) + b = hue2rgb(p, q, h - 1 / 3) + } + + return { + space: 'rgb', + r: r * 255, + g: g * 255, + b: b * 255, + alpha: hsl.alpha, + } +} + +/** + * Converts RGB to HSB. + */ +function rgbToHsb(rgb: RGBColor): HSBColor { + const r = rgb.r / 255 + const g = rgb.g / 255 + const b = rgb.b / 255 + + const max = Math.max(r, g, b) + const min = Math.min(r, g, b) + const d = max - min + + let h = 0 + const s = max === 0 ? 0 : d / max + const v = max + + if (max !== min) { + switch (max) { + case r: + h = (g - b) / d + (g < b ? 6 : 0) + break + case g: + h = (b - r) / d + 2 + break + case b: + h = (r - g) / d + 4 + break + } + h /= 6 + } + + return { + space: 'hsb', + h: h * 360, + s: s * 100, + b: v * 100, + alpha: rgb.alpha, + } +} + +/** + * Converts HSB to RGB. + */ +function hsbToRgb(hsb: HSBColor): RGBColor { + const h = hsb.h / 360 + const s = hsb.s / 100 + const v = hsb.b / 100 + + let r = 0 + let g = 0 + let b = 0 + + const i = Math.floor(h * 6) + const f = h * 6 - i + const p = v * (1 - s) + const q = v * (1 - f * s) + const t = v * (1 - (1 - f) * s) + + switch (i % 6) { + case 0: + r = v + g = t + b = p + break + case 1: + r = q + g = v + b = p + break + case 2: + r = p + g = v + b = t + break + case 3: + r = p + g = q + b = v + break + case 4: + r = t + g = p + b = v + break + case 5: + r = v + g = p + b = q + break + } + + return { + space: 'rgb', + r: r * 255, + g: g * 255, + b: b * 255, + alpha: hsb.alpha, + } +} diff --git a/packages/core/src/shared/color/gradient.test.ts b/packages/core/src/shared/color/gradient.test.ts new file mode 100644 index 0000000000..4e0b863b92 --- /dev/null +++ b/packages/core/src/shared/color/gradient.test.ts @@ -0,0 +1,331 @@ +import { describe, expect, it } from 'vitest' +import { getAreaBackgroundStyle, getSliderGradient } from './gradient' +import { parseColor } from './parse' + +// ───────────────────────────────────────────────────────────────────────────── +// getSliderGradient +// ───────────────────────────────────────────────────────────────────────────── + +describe('getSliderGradient', () => { + describe('hue channel', () => { + it('should return full rainbow spectrum', () => { + const red = parseColor('#ff0000') + const gradient = getSliderGradient(red, 'hue', 'hsl') + expect(gradient).toContain('#ff0000') // red + expect(gradient).toContain('#ffff00') // yellow + expect(gradient).toContain('#00ff00') // green + expect(gradient).toContain('#00ffff') // cyan + expect(gradient).toContain('#0000ff') // blue + expect(gradient).toContain('#ff00ff') // magenta + }) + + it('should use linear-gradient', () => { + const red = parseColor('#ff0000') + expect(getSliderGradient(red, 'hue', 'hsl')).toContain('linear-gradient') + }) + }) + + describe('saturation channel', () => { + it('should return gray-to-color gradient for HSL', () => { + const red = parseColor('hsl(0, 100%, 50%)') + const gradient = getSliderGradient(red, 'saturation', 'hsl') + expect(gradient).toContain('linear-gradient') + expect(gradient).toContain('hsl(0, 0%, 50%)') // desaturated = gray + expect(gradient).toContain('hsl(0, 100%, 50%)') // full red + }) + + it('should return gray-to-color gradient for HSB', () => { + const red = parseColor('hsb(0, 100%, 100%)') + const gradient = getSliderGradient(red, 'saturation', 'hsb') + expect(gradient).toContain('linear-gradient') + expect(gradient).toContain('hsl(0, 0%, 50%)') + expect(gradient).toContain('hsl(0, 100%, 50%)') + }) + }) + + describe('lightness channel', () => { + it('should span from black through color to white', () => { + const red = parseColor('hsl(0, 100%, 50%)') + const gradient = getSliderGradient(red, 'lightness', 'hsl') + expect(gradient).toContain('hsl(0, 100%, 0%)') // black + expect(gradient).toContain('hsl(0, 100%, 50%)') // pure red + expect(gradient).toContain('hsl(0, 100%, 100%)') // white + }) + }) + + describe('brightness channel', () => { + it('should span from black to full-brightness color', () => { + const red = parseColor('hsb(0, 100%, 100%)') + const gradient = getSliderGradient(red, 'brightness', 'hsb') + expect(gradient).toContain('rgb(0, 0, 0)') // black at B=0 + expect(gradient).toContain('rgb(255, 0, 0)') // pure red at B=100 + }) + }) + + describe('rGB channels', () => { + it('should return red gradient holding g/b constant', () => { + const color = parseColor('#808080') // rgb(128, 128, 128) + const gradient = getSliderGradient(color, 'red', 'rgb') + expect(gradient).toContain('rgb(0, 128, 128)') // r=0 + expect(gradient).toContain('rgb(255, 128, 128)') // r=255 + }) + + it('should return green gradient holding r/b constant', () => { + const color = parseColor('#808080') + const gradient = getSliderGradient(color, 'green', 'rgb') + expect(gradient).toContain('rgb(128, 0, 128)') // g=0 + expect(gradient).toContain('rgb(128, 255, 128)') // g=255 + }) + + it('should return blue gradient holding r/g constant', () => { + const color = parseColor('#808080') + const gradient = getSliderGradient(color, 'blue', 'rgb') + expect(gradient).toContain('rgb(128, 128, 0)') // b=0 + expect(gradient).toContain('rgb(128, 128, 255)') // b=255 + }) + }) +}) + +// ───────────────────────────────────────────────────────────────────────────── +// getAreaBackgroundStyle — Hue-based pickers (no blend mode, transparent stacking) +// ───────────────────────────────────────────────────────────────────────────── + +describe('getAreaBackgroundStyle', () => { + describe('hue(x) / Saturation(y) — HSL', () => { + const color = parseColor('hsl(180, 100%, 50%)') + + it('includes the full hue rainbow as bottom layer', () => { + const style = getAreaBackgroundStyle(color, 'hue', 'saturation', 'hsl') + expect(style.backgroundImage).toContain( + 'linear-gradient(to right, #ff0000, #ffff00, #00ff00, #00ffff, #0000ff, #ff00ff, #ff0000)', + ) + }) + + it('overlays transparent-to-gray fade (top=full sat, bottom=desaturated)', () => { + const style = getAreaBackgroundStyle(color, 'hue', 'saturation', 'hsl') + // gray = hsl(H, 0%, 50%) = rgb(128,128,128) — independent of hue + expect(style.backgroundImage).toContain('linear-gradient(to bottom, transparent, rgb(128, 128, 128))') + }) + + it('does not use any CSS blend mode', () => { + const style = getAreaBackgroundStyle(color, 'hue', 'saturation', 'hsl') + expect(style.backgroundBlendMode).toBeUndefined() + }) + + it('does not set a solid background color', () => { + const style = getAreaBackgroundStyle(color, 'hue', 'saturation', 'hsl') + expect(style.backgroundColor).toBeUndefined() + }) + + it('gray stop is rgb(128,128,128) regardless of hue (S=0, L=50 is always neutral)', () => { + const red = parseColor('hsl(0, 100%, 50%)') + const green = parseColor('hsl(120, 100%, 50%)') + expect(getAreaBackgroundStyle(red, 'hue', 'saturation', 'hsl').backgroundImage).toContain('rgb(128, 128, 128)') + expect(getAreaBackgroundStyle(green, 'hue', 'saturation', 'hsl').backgroundImage).toContain('rgb(128, 128, 128)') + }) + }) + + describe('hue(x) / Lightness(y) — HSL', () => { + const color = parseColor('hsl(180, 100%, 50%)') + + it('includes the full hue rainbow as bottom layer', () => { + const style = getAreaBackgroundStyle(color, 'hue', 'lightness', 'hsl') + expect(style.backgroundImage).toContain( + 'linear-gradient(to right, #ff0000, #ffff00, #00ff00, #00ffff, #0000ff, #ff00ff, #ff0000)', + ) + }) + + it('overlays black → transparent → white for the lightness axis', () => { + const style = getAreaBackgroundStyle(color, 'hue', 'lightness', 'hsl') + // L=0 → black (bottom), L=50 → transparent (shows hue), L=100 → white (top) + expect(style.backgroundImage).toContain('linear-gradient(to top, #000000, transparent, #ffffff)') + }) + + it('does not use any CSS blend mode', () => { + const style = getAreaBackgroundStyle(color, 'hue', 'lightness', 'hsl') + expect(style.backgroundBlendMode).toBeUndefined() + }) + + it('does not set a solid background color', () => { + const style = getAreaBackgroundStyle(color, 'hue', 'lightness', 'hsl') + expect(style.backgroundColor).toBeUndefined() + }) + }) + + describe('hue(x) / Brightness(y) — HSB', () => { + const color = parseColor('hsb(180, 100%, 100%)') + + it('includes the full hue rainbow as bottom layer', () => { + const style = getAreaBackgroundStyle(color, 'hue', 'brightness', 'hsb') + expect(style.backgroundImage).toContain( + 'linear-gradient(to right, #ff0000, #ffff00, #00ff00, #00ffff, #0000ff, #ff00ff, #ff0000)', + ) + }) + + it('overlays black → transparent for the brightness axis', () => { + const style = getAreaBackgroundStyle(color, 'hue', 'brightness', 'hsb') + // B=0 → black (bottom), B=100 → transparent (shows hue, top) + expect(style.backgroundImage).toContain('linear-gradient(to top, #000000, transparent)') + }) + + it('does not use any CSS blend mode', () => { + const style = getAreaBackgroundStyle(color, 'hue', 'brightness', 'hsb') + expect(style.backgroundBlendMode).toBeUndefined() + }) + + it('does not set a solid background color', () => { + const style = getAreaBackgroundStyle(color, 'hue', 'brightness', 'hsb') + expect(style.backgroundColor).toBeUndefined() + }) + }) + + // ───────────────────────────────────────────────────────────────────────── + // Saturation-based pickers + // ───────────────────────────────────────────────────────────────────────── + + describe('saturation(x) / Lightness(y) — HSL', () => { + it('uses pure hue (S=100, L=50) as the background color', () => { + const red = parseColor('hsl(0, 100%, 50%)') + const style = getAreaBackgroundStyle(red, 'saturation', 'lightness', 'hsl') + expect(style.backgroundColor).toBe('rgb(255, 0, 0)') + }) + + it('saturation gradient starts at gray (#808080) — NOT white', () => { + const red = parseColor('hsl(0, 100%, 50%)') + const style = getAreaBackgroundStyle(red, 'saturation', 'lightness', 'hsl') + // S=0 at L=50 is gray (rgb(128,128,128)), not white + expect(style.backgroundImage).toContain('linear-gradient(to right, rgb(128, 128, 128), transparent)') + }) + + it('lightness gradient spans black → transparent → white', () => { + const red = parseColor('hsl(0, 100%, 50%)') + const style = getAreaBackgroundStyle(red, 'saturation', 'lightness', 'hsl') + // L=0 → black, L=50 → transparent (pure hue), L=100 → white + expect(style.backgroundImage).toContain('linear-gradient(to top, #000000, transparent, #ffffff)') + }) + + it('background color reflects the input hue', () => { + const green = parseColor('hsl(120, 100%, 50%)') + const style = getAreaBackgroundStyle(green, 'saturation', 'lightness', 'hsl') + expect(style.backgroundColor).toBe('rgb(0, 255, 0)') + }) + + it('gray stop is always rgb(128,128,128) regardless of hue', () => { + const blue = parseColor('hsl(240, 100%, 50%)') + const style = getAreaBackgroundStyle(blue, 'saturation', 'lightness', 'hsl') + expect(style.backgroundImage).toContain('rgb(128, 128, 128)') + }) + }) + + describe('saturation(x) / Brightness(y) — HSB', () => { + it('uses pure hue (S=100, B=100) as the background color', () => { + const red = parseColor('hsb(0, 100%, 100%)') + const style = getAreaBackgroundStyle(red, 'saturation', 'brightness', 'hsb') + expect(style.backgroundColor).toBe('rgb(255, 0, 0)') + }) + + it('saturation gradient starts at white (S=0 + B=100 = white in HSB)', () => { + const red = parseColor('hsb(0, 100%, 100%)') + const style = getAreaBackgroundStyle(red, 'saturation', 'brightness', 'hsb') + expect(style.backgroundImage).toContain('linear-gradient(to right, #ffffff, transparent)') + }) + + it('brightness gradient fades black → transparent', () => { + const red = parseColor('hsb(0, 100%, 100%)') + const style = getAreaBackgroundStyle(red, 'saturation', 'brightness', 'hsb') + expect(style.backgroundImage).toContain('linear-gradient(to top, #000000, transparent)') + }) + + it('background color reflects the input hue', () => { + const cyan = parseColor('hsb(180, 100%, 100%)') + const style = getAreaBackgroundStyle(cyan, 'saturation', 'brightness', 'hsb') + expect(style.backgroundColor).toBe('rgb(0, 255, 255)') + }) + }) + + // ───────────────────────────────────────────────────────────────────────── + // RGB 2-D pickers — screen blend mode, z-channel background + // ───────────────────────────────────────────────────────────────────────── + + describe('rGB Red(x) / Green(y)', () => { + const purple = parseColor('#7f007f') // rgb(127, 0, 127) + + it('uses screen blend mode', () => { + expect(getAreaBackgroundStyle(purple, 'red', 'green', 'rgb').backgroundBlendMode).toBe('screen') + }) + + it('background is the constant blue z-channel value', () => { + expect(getAreaBackgroundStyle(purple, 'red', 'green', 'rgb').backgroundColor).toBe('rgb(0, 0, 127)') + }) + + it('horizontal gradient: black → full red (x-axis)', () => { + const style = getAreaBackgroundStyle(purple, 'red', 'green', 'rgb') + expect(style.backgroundImage).toContain('linear-gradient(to right, rgb(0, 0, 0), rgb(255, 0, 0))') + }) + + it('vertical gradient: black at bottom → full green at top (y-axis, correct direction)', () => { + const style = getAreaBackgroundStyle(purple, 'red', 'green', 'rgb') + expect(style.backgroundImage).toContain('linear-gradient(to top, rgb(0, 0, 0), rgb(0, 255, 0))') + }) + + it('background is black when z-channel (blue) = 0', () => { + const red = parseColor('#ff0000') // blue=0 + expect(getAreaBackgroundStyle(red, 'red', 'green', 'rgb').backgroundColor).toBe('rgb(0, 0, 0)') + }) + }) + + describe('rGB Red(x) / Blue(y)', () => { + const purple = parseColor('#7f007f') + + it('uses screen blend mode', () => { + expect(getAreaBackgroundStyle(purple, 'red', 'blue', 'rgb').backgroundBlendMode).toBe('screen') + }) + + it('background is the constant green z-channel (0 here)', () => { + expect(getAreaBackgroundStyle(purple, 'red', 'blue', 'rgb').backgroundColor).toBe('rgb(0, 0, 0)') + }) + + it('horizontal gradient: black → full red', () => { + const style = getAreaBackgroundStyle(purple, 'red', 'blue', 'rgb') + expect(style.backgroundImage).toContain('linear-gradient(to right, rgb(0, 0, 0), rgb(255, 0, 0))') + }) + + it('vertical gradient: black at bottom → full blue at top', () => { + const style = getAreaBackgroundStyle(purple, 'red', 'blue', 'rgb') + expect(style.backgroundImage).toContain('linear-gradient(to top, rgb(0, 0, 0), rgb(0, 0, 255))') + }) + }) + + describe('rGB Green(x) / Blue(y)', () => { + const purple = parseColor('#7f007f') // rgb(127, 0, 127) — red=127 + + it('uses screen blend mode', () => { + expect(getAreaBackgroundStyle(purple, 'green', 'blue', 'rgb').backgroundBlendMode).toBe('screen') + }) + + it('background is the constant red z-channel', () => { + expect(getAreaBackgroundStyle(purple, 'green', 'blue', 'rgb').backgroundColor).toBe('rgb(127, 0, 0)') + }) + + it('horizontal gradient: black → full green', () => { + const style = getAreaBackgroundStyle(purple, 'green', 'blue', 'rgb') + expect(style.backgroundImage).toContain('linear-gradient(to right, rgb(0, 0, 0), rgb(0, 255, 0))') + }) + + it('vertical gradient: black at bottom → full blue at top', () => { + const style = getAreaBackgroundStyle(purple, 'green', 'blue', 'rgb') + expect(style.backgroundImage).toContain('linear-gradient(to top, rgb(0, 0, 0), rgb(0, 0, 255))') + }) + }) + + describe('rGB swapped axes — Green(x) / Red(y)', () => { + it('assigns x-gradient horizontally and y-gradient vertically', () => { + const purple = parseColor('#7f007f') + const style = getAreaBackgroundStyle(purple, 'green', 'red', 'rgb') + + expect(style.backgroundColor).toBe('rgb(0, 0, 127)') // z=blue=127 + expect(style.backgroundImage).toContain('linear-gradient(to right, rgb(0, 0, 0), rgb(0, 255, 0))') // x=green + expect(style.backgroundImage).toContain('linear-gradient(to top, rgb(0, 0, 0), rgb(255, 0, 0))') // y=red + }) + }) +}) diff --git a/packages/core/src/shared/color/gradient.ts b/packages/core/src/shared/color/gradient.ts new file mode 100644 index 0000000000..30776d1e03 --- /dev/null +++ b/packages/core/src/shared/color/gradient.ts @@ -0,0 +1,347 @@ +import type { Color, ColorChannel, ColorSpace, RGBColor } from './types' +import { colorToString, convertToHsb, convertToHsl, convertToRgb } from './convert' + +/** + * Generates a CSS gradient for a color slider track. + */ +export function getSliderGradient( + color: Color, + channel: ColorChannel, + colorSpace: ColorSpace = color.space as ColorSpace, +): string { + const hsl = convertToHsl(color) + const hsb = convertToHsb(color) + + switch (channel) { + case 'hue': + return getHueGradient() + case 'saturation': + return getSaturationGradient(hsl, colorSpace) + case 'lightness': + return getLightnessGradient(hsl) + case 'brightness': + return getBrightnessGradient(hsb) + case 'red': + return getRedGradient(color) + case 'green': + return getGreenGradient(color) + case 'blue': + return getBlueGradient(color) + case 'alpha': + return getAlphaGradient(color) + default: + return '' + } +} + +/** + * Generates a CSS gradient for a color area (2D picker). + */ +export function getAreaGradient( + color: Color, + xChannel: ColorChannel, + yChannel: ColorChannel, + colorSpace: ColorSpace = color.space as ColorSpace, +): { background: string, gradientX: string, gradientY: string } { + const hsl = convertToHsl(color) + const hsb = convertToHsb(color) + + // Determine which gradient layers to apply based on channels + const gradientX = getChannelGradientForArea(color, xChannel, colorSpace, 'x') + const gradientY = getChannelGradientForArea(color, yChannel, colorSpace, 'y') + + // Background is the color with both channels at max + const bgColor = getAreaBackgroundColor(color, xChannel, yChannel, colorSpace) + + return { + background: bgColor, + gradientX, + gradientY, + } +} + +function getHueGradient(): string { + return 'linear-gradient(to right, #ff0000, #ffff00, #00ff00, #00ffff, #0000ff, #ff00ff, #ff0000)' +} + +function getSaturationGradient(hsl: { h: number, s: number, l: number, alpha: number }, colorSpace: ColorSpace): string { + const start = colorToString({ space: 'hsl', h: hsl.h, s: 0, l: colorSpace === 'hsl' ? hsl.l : 50, alpha: 1 }, 'hsl') + const end = colorToString({ space: 'hsl', h: hsl.h, s: 100, l: colorSpace === 'hsl' ? hsl.l : 50, alpha: 1 }, 'hsl') + return `linear-gradient(to right, ${start}, ${end})` +} + +function getLightnessGradient(hsl: { h: number, s: number, l: number, alpha: number }): string { + const start = colorToString({ space: 'hsl', h: hsl.h, s: hsl.s, l: 0, alpha: 1 }, 'hsl') + const mid = colorToString({ space: 'hsl', h: hsl.h, s: hsl.s, l: 50, alpha: 1 }, 'hsl') + const end = colorToString({ space: 'hsl', h: hsl.h, s: hsl.s, l: 100, alpha: 1 }, 'hsl') + return `linear-gradient(to right, ${start}, ${mid}, ${end})` +} + +function getBrightnessGradient(hsb: { h: number, s: number, b: number, alpha: number }): string { + const start = colorToString({ space: 'hsb', h: hsb.h, s: hsb.s, b: 0, alpha: 1 }, 'rgb') + const end = colorToString({ space: 'hsb', h: hsb.h, s: hsb.s, b: 100, alpha: 1 }, 'rgb') + return `linear-gradient(to right, ${start}, ${end})` +} + +function getRedGradient(color: Color): string { + const { g, b, alpha } = color.space === 'rgb' ? color : { g: 128, b: 128, alpha: 1 } + const start = colorToString({ space: 'rgb', r: 0, g, b, alpha: 1 }, 'rgb') + const end = colorToString({ space: 'rgb', r: 255, g, b, alpha: 1 }, 'rgb') + return `linear-gradient(to right, ${start}, ${end})` +} + +function getGreenGradient(color: Color): string { + const { r, b, alpha } = color.space === 'rgb' ? color : { r: 128, b: 128, alpha: 1 } + const start = colorToString({ space: 'rgb', r, g: 0, b, alpha: 1 }, 'rgb') + const end = colorToString({ space: 'rgb', r, g: 255, b, alpha: 1 }, 'rgb') + return `linear-gradient(to right, ${start}, ${end})` +} + +function getBlueGradient(color: Color): string { + const { r, g, alpha } = color.space === 'rgb' ? color : { r: 128, g: 128, alpha: 1 } + const start = colorToString({ space: 'rgb', r, g, b: 0, alpha: 1 }, 'rgb') + const end = colorToString({ space: 'rgb', r, g, b: 255, alpha: 1 }, 'rgb') + return `linear-gradient(to right, ${start}, ${end})` +} + +// Checkerboard pattern used behind alpha gradients to visualize transparency +const CHECKERBOARD_LAYERS = [ + 'linear-gradient(45deg, #ccc 25%, transparent 25%)', + 'linear-gradient(-45deg, #ccc 25%, transparent 25%)', + 'linear-gradient(45deg, transparent 75%, #ccc 75%)', + 'linear-gradient(-45deg, transparent 75%, #ccc 75%)', +].join(', ') + +function getAlphaGradient(color: Color): string { + const { r, g, b } = color.space === 'rgb' ? color : convertToRgb(color) + const solidRgb = `rgb(${Math.round(r)}, ${Math.round(g)}, ${Math.round(b)})` + return `linear-gradient(to right, transparent, ${solidRgb}), ${CHECKERBOARD_LAYERS}` +} + +function getChannelGradientForArea( + color: Color, + channel: ColorChannel, + colorSpace: ColorSpace, + axis: 'x' | 'y', +): string { + const direction = axis === 'x' ? 'to right' : 'to top' + const hsl = convertToHsl(color) + const hsb = convertToHsb(color) + + switch (channel) { + case 'saturation': { + if (colorSpace === 'hsb') { + // For HSB: White to transparent (overlay on pure hue) + // Left side (0% sat) = white, Right side (100% sat) = transparent (shows pure hue) + return `linear-gradient(${direction}, rgba(255, 255, 255, 1), rgba(255, 255, 255, 0))` + } + // For HSL: White to full color + const fullColor = colorToString({ space: 'hsl', h: hsl.h, s: 100, l: 50, alpha: 1 }, 'rgb') + return `linear-gradient(${direction}, #ffffff, ${fullColor})` + } + case 'lightness': { + // White -> color -> black + const mid = colorToString({ space: 'hsl', h: hsl.h, s: hsl.s, l: 50, alpha: 1 }, 'rgb') + return `linear-gradient(${direction}, #000000, ${mid}, #ffffff)` + } + case 'brightness': { + // For HSB: Transparent to black (overlay on pure hue) + // Top (100% brightness) = transparent (shows pure hue), Bottom (0% brightness) = black + return `linear-gradient(${direction}, rgba(0, 0, 0, 0), rgba(0, 0, 0, 1))` + } + default: + return '' + } +} + +function getAreaBackgroundColor( + color: Color, + xChannel: ColorChannel, + yChannel: ColorChannel, + colorSpace: ColorSpace, +): string { + const hsl = convertToHsl(color) + const hsb = convertToHsb(color) + + // For HSL saturation/lightness area, show pure hue + if (colorSpace === 'hsl' && xChannel === 'saturation' && yChannel === 'lightness') { + return colorToString({ space: 'hsl', h: hsl.h, s: 100, l: 50, alpha: 1 }, 'rgb') + } + + // For HSB saturation/brightness area, show pure hue + if (colorSpace === 'hsb' && xChannel === 'saturation' && yChannel === 'brightness') { + return colorToString({ space: 'hsb', h: hsb.h, s: 100, b: 100, alpha: 1 }, 'rgb') + } + + // Default to white + return '#ffffff' +} + +/** + * Gets the CSS background style for a color area. + */ +export function getAreaBackgroundStyle( + color: Color, + xChannel: ColorChannel, + yChannel: ColorChannel, + colorSpace: ColorSpace = color.space as ColorSpace, +): Record { + const hsl = convertToHsl(color) + const hsb = convertToHsb(color) + + // Hue-based color picker (Hue/Saturation, Hue/Lightness, Hue/Brightness) + // Shows full rainbow spectrum horizontally + if (xChannel === 'hue') { + // Full hue gradient as background (rainbow spectrum) + const hueGradient = 'linear-gradient(to right, #ff0000, #ffff00, #00ff00, #00ffff, #0000ff, #ff00ff, #ff0000)' + + if (yChannel === 'saturation') { + // Vertical: gray at bottom (0% sat) fading to transparent at top (100% sat shows hue) + // No blend mode needed — transparent stacking is correct + const desatColor = colorToString({ space: 'hsl', h: hsl.h, s: 0, l: hsl.l, alpha: 1 }, 'rgb') + return { + backgroundImage: `linear-gradient(to bottom, transparent, ${desatColor}), ${hueGradient}`, + } + } + + if (yChannel === 'lightness') { + // Vertical: black at bottom (L=0) → transparent at middle (L=50, shows hue) → white at top (L=100) + return { + backgroundImage: `linear-gradient(to top, #000000, transparent, #ffffff), ${hueGradient}`, + } + } + + if (yChannel === 'brightness') { + // Vertical: black at bottom (B=0) → transparent at top (B=100, shows hue) + return { + backgroundImage: `linear-gradient(to top, #000000, transparent), ${hueGradient}`, + } + } + } + + // Classic color picker gradients for saturation-based pickers + if (xChannel === 'saturation' && (yChannel === 'lightness' || yChannel === 'brightness')) { + if (colorSpace === 'hsl') { + // HSL: Base is pure hue at 50% lightness + // Saturation x-axis: gray (S=0) at left fading to transparent (shows pure hue at S=100) + // Lightness y-axis: black at bottom (L=0), transparent at middle (L=50), white at top (L=100) + const hueColor = colorToString({ space: 'hsl', h: hsl.h, s: 100, l: 50, alpha: 1 }, 'rgb') + const grayColor = colorToString({ space: 'hsl', h: hsl.h, s: 0, l: 50, alpha: 1 }, 'rgb') + const satGradient = `linear-gradient(to right, ${grayColor}, transparent)` + const lightGradient = `linear-gradient(to top, #000000, transparent, #ffffff)` + + return { + backgroundColor: hueColor, + backgroundImage: `${lightGradient}, ${satGradient}`, + } + } + + if (colorSpace === 'hsb') { + // HSB: Base is pure hue (full sat, full brightness) + // Top edge: white (0% sat) → pure hue (100% sat) + // Bottom edge: always black (0% brightness) + const hueColor = colorToString({ space: 'hsb', h: hsb.h, s: 100, b: 100, alpha: 1 }, 'rgb') + // White to transparent (left to right) + const satGradient = `linear-gradient(to right, #ffffff, transparent)` + // Black gradient from bottom up + const brightGradient = `linear-gradient(to top, #000000, transparent)` + + return { + backgroundColor: hueColor, + backgroundImage: `${brightGradient}, ${satGradient}`, + } + } + } + + // RGB color picker (Red/Green, Red/Blue, Green/Blue) + // Uses screen blend mode to combine gradients additively (like React Spectrum) + // Formula: 1 - (1 - a) * (1 - b), effectively adds RGB values + if (colorSpace === 'rgb' + && (xChannel === 'red' || xChannel === 'green' || xChannel === 'blue') + && (yChannel === 'red' || yChannel === 'green' || yChannel === 'blue')) { + const rgb = convertToRgb(color) + + // Get the constant channel (z-channel - the one NOT x or y) + const allChannels = ['red', 'green', 'blue'] as const + const varyingChannels = [xChannel, yChannel] + const constantChannel = allChannels.find(c => !varyingChannels.includes(c))! + const constantValue = rgb[constantChannel === 'red' ? 'r' : constantChannel === 'green' ? 'g' : 'b'] + + // Create the three layers for screen blend mode: + // 1. X gradient: black to full-X-color + // 2. Y gradient: black to full-Y-color + // 3. Background: black with Z channel set + + // X gradient: black (0,0,0) → full X (255,0,0) for red, etc. + const xColorStart: RGBColor = { space: 'rgb', r: 0, g: 0, b: 0, alpha: 1 } + const xColorEnd: RGBColor = { + space: 'rgb', + r: xChannel === 'red' ? 255 : 0, + g: xChannel === 'green' ? 255 : 0, + b: xChannel === 'blue' ? 255 : 0, + alpha: 1, + } + const xGradient = `linear-gradient(to right, ${colorToString(xColorStart, 'rgb')}, ${colorToString(xColorEnd, 'rgb')})` + + // Y gradient: black (0,0,0) → full Y (0,255,0) for green, etc. + const yColorEnd: RGBColor = { + space: 'rgb', + r: yChannel === 'red' ? 255 : 0, + g: yChannel === 'green' ? 255 : 0, + b: yChannel === 'blue' ? 255 : 0, + alpha: 1, + } + const yGradient = `linear-gradient(to top, ${colorToString(xColorStart, 'rgb')}, ${colorToString(yColorEnd, 'rgb')})` + + // Background: black with constant Z channel value + const bgColor: RGBColor = { + space: 'rgb', + r: constantChannel === 'red' ? constantValue : 0, + g: constantChannel === 'green' ? constantValue : 0, + b: constantChannel === 'blue' ? constantValue : 0, + alpha: 1, + } + + return { + backgroundColor: colorToString(bgColor, 'rgb'), + backgroundImage: `${yGradient}, ${xGradient}`, + backgroundBlendMode: 'screen', + } + } + + // Fallback for other combinations + const { background, gradientX, gradientY } = getAreaGradient(color, xChannel, yChannel, colorSpace) + const gradients: string[] = [] + if (gradientY) + gradients.push(gradientY) + if (gradientX) + gradients.push(gradientX) + + return { + backgroundColor: background, + backgroundImage: gradients.join(', '), + } +} + +/** + * Gets the CSS background for a slider track. + */ +export function getSliderBackgroundStyle( + color: Color, + channel: ColorChannel, + colorSpace: ColorSpace = color.space as ColorSpace, +): Record { + const gradient = getSliderGradient(color, channel, colorSpace) + + if (channel === 'alpha') { + return { + background: gradient, + backgroundSize: '100% 100%, 8px 8px, 8px 8px, 8px 8px, 8px 8px', + backgroundPosition: '0 0, 0 0, 0 4px, 4px -4px, -4px 0px', + } + } + + return { + background: gradient, + } +} diff --git a/packages/core/src/shared/color/index.ts b/packages/core/src/shared/color/index.ts new file mode 100644 index 0000000000..373b5ee3cd --- /dev/null +++ b/packages/core/src/shared/color/index.ts @@ -0,0 +1,35 @@ +// Channel operations +export { getChannelName, getChannelRange, getChannelValue, setChannelValue, setChannelValues } from './channel' + +// Conversion +export { + colorToHex, + colorToHsb, + colorToHsl, + colorToRgb, + colorToString, + convertToHsb, + convertToHsl, + convertToRgb, +} from './convert' + +// Gradients +export { getAreaBackgroundStyle, getAreaGradient, getSliderBackgroundStyle, getSliderGradient } from './gradient' + +// Parsing +export { isValidColor, normalizeColor, parseColor } from './parse' + +// Types +export type { + ChannelRange, + Color, + ColorChannel, + ColorFormat, + ColorSpace, + HSBColor, + HSLColor, + RGBColor, +} from './types' + +// Legacy utilities (keeping for backwards compatibility) +export { getColorContrast, getColorName, hexToHSL, hexToRGB } from './utils' diff --git a/packages/core/src/shared/color/parse.ts b/packages/core/src/shared/color/parse.ts new file mode 100644 index 0000000000..ebb7c5e534 --- /dev/null +++ b/packages/core/src/shared/color/parse.ts @@ -0,0 +1,140 @@ +import type { Color, HSBColor, HSLColor, RGBColor } from './types' + +/** + * Parses a color string into a Color object. + * Supports hex (#rrggbb, #rgb), rgb(), hsl(), and hsb()/hsv() formats. + */ +export function parseColor(value: string): Color { + const trimmed = value.trim().toLowerCase() + + // Hex format + if (trimmed.startsWith('#')) { + return parseHex(trimmed) + } + + // rgb() format + if (trimmed.startsWith('rgb')) { + return parseRgb(trimmed) + } + + // hsl() format + if (trimmed.startsWith('hsl')) { + return parseHsl(trimmed) + } + + // hsb() or hsv() format + if (trimmed.startsWith('hsb') || trimmed.startsWith('hsv')) { + return parseHsb(trimmed) + } + + throw new Error(`Unable to parse color: ${value}`) +} + +function parseHex(hex: string): RGBColor { + let normalized = hex.slice(1) + + // Validate hex format (3, 6, or 8 hex digits) + if (!/^[0-9A-F]{3}$/i.test(normalized) && !/^[0-9A-F]{6}$/i.test(normalized) && !/^[0-9A-F]{8}$/i.test(normalized)) { + throw new Error(`Invalid hex color: ${hex}. Expected format: #RGB, #RRGGBB, or #RRGGBBAA`) + } + + // Expand shorthand (e.g., #f00 -> #ff0000) + if (normalized.length === 3) { + normalized = normalized.split('').map(c => c + c).join('') + } + + // Handle 6-digit hex + if (normalized.length === 6) { + const bigint = parseInt(normalized, 16) + return { + space: 'rgb', + r: (bigint >> 16) & 255, + g: (bigint >> 8) & 255, + b: bigint & 255, + alpha: 1, + } + } + + // Handle 8-digit hex (with alpha) + if (normalized.length === 8) { + const bigint = parseInt(normalized, 16) + return { + space: 'rgb', + r: (bigint >> 24) & 255, + g: (bigint >> 16) & 255, + b: (bigint >> 8) & 255, + alpha: (bigint & 255) / 255, + } + } + + throw new Error(`Invalid hex color: ${hex}`) +} + +function parseRgb(rgb: string): RGBColor { + const match = rgb.match(/rgba?\(\s*([\d.]+)\s*,\s*([\d.]+)\s*,\s*([\d.]+)\s*(?:,\s*([\d.]+)\s*)?\)/) + if (!match) { + throw new Error(`Invalid RGB color: ${rgb}`) + } + + return { + space: 'rgb', + r: parseFloat(match[1]), + g: parseFloat(match[2]), + b: parseFloat(match[3]), + alpha: match[4] ? parseFloat(match[4]) : 1, + } +} + +function parseHsl(hsl: string): HSLColor { + const match = hsl.match(/hsla?\(\s*([\d.]+)\s*,\s*([\d.]+)%\s*,\s*([\d.]+)%\s*(?:,\s*([\d.]+)\s*)?\)/) + if (!match) { + throw new Error(`Invalid HSL color: ${hsl}`) + } + + return { + space: 'hsl', + h: parseFloat(match[1]), + s: parseFloat(match[2]), + l: parseFloat(match[3]), + alpha: match[4] ? parseFloat(match[4]) : 1, + } +} + +function parseHsb(hsb: string): HSBColor { + const match = hsb.match(/hsb[av]?\(\s*([\d.]+)\s*,\s*([\d.]+)%?\s*,\s*([\d.]+)%?\s*(?:,\s*([\d.]+)\s*)?\)/) + if (!match) { + throw new Error(`Invalid HSB color: ${hsb}`) + } + + return { + space: 'hsb', + h: parseFloat(match[1]), + s: parseFloat(match[2]), + b: parseFloat(match[3]), + alpha: match[4] ? parseFloat(match[4]) : 1, + } +} + +/** + * Normalizes a value to a Color object. + * If already a Color, returns it. If a string, parses it. + */ +export function normalizeColor(value: string | Color): Color { + if (typeof value === 'string') { + return parseColor(value) + } + return value +} + +/** + * Checks if a string is a valid color. + */ +export function isValidColor(value: string): boolean { + try { + parseColor(value) + return true + } + catch { + return false + } +} diff --git a/packages/core/src/shared/color/types.ts b/packages/core/src/shared/color/types.ts new file mode 100644 index 0000000000..c1f0700b87 --- /dev/null +++ b/packages/core/src/shared/color/types.ts @@ -0,0 +1,40 @@ +export type ColorSpace = 'rgb' | 'hsl' | 'hsb' + +export interface RGBColor { + space: 'rgb' + r: number + g: number + b: number + alpha: number +} + +export interface HSLColor { + space: 'hsl' + h: number + s: number + l: number + alpha: number +} + +export interface HSBColor { + space: 'hsb' + h: number + s: number + b: number + alpha: number +} + +export type Color = RGBColor | HSLColor | HSBColor + +export type RGBChannel = 'red' | 'green' | 'blue' | 'alpha' +export type HSLChannel = 'hue' | 'saturation' | 'lightness' | 'alpha' +export type HSBChannel = 'hue' | 'saturation' | 'brightness' | 'alpha' +export type ColorChannel = RGBChannel | HSLChannel | HSBChannel + +export interface ChannelRange { + min: number + max: number + step: number +} + +export type ColorFormat = 'hex' | 'rgb' | 'hsl' | 'hsb' diff --git a/packages/core/src/shared/color/utils.test.ts b/packages/core/src/shared/color/utils.test.ts new file mode 100644 index 0000000000..07171dd186 --- /dev/null +++ b/packages/core/src/shared/color/utils.test.ts @@ -0,0 +1,361 @@ +import type { HSBColor, HSLColor } from './types' +import { describe, expect, it } from 'vitest' +import { + colorToHex, + colorToHsl, + colorToRgb, + convertToHsb, + convertToHsl, + convertToRgb, + getChannelRange, + getChannelValue, + isValidColor, + normalizeColor, + parseColor, + setChannelValue, + setChannelValues, +} from './index' + +describe('color utilities', () => { + describe('parseColor', () => { + it('should parse hex colors', () => { + expect(parseColor('#ff0000')).toEqual({ + space: 'rgb', + r: 255, + g: 0, + b: 0, + alpha: 1, + }) + expect(parseColor('#00ff00')).toEqual({ + space: 'rgb', + r: 0, + g: 255, + b: 0, + alpha: 1, + }) + expect(parseColor('#0000ff')).toEqual({ + space: 'rgb', + r: 0, + g: 0, + b: 255, + alpha: 1, + }) + }) + + it('should parse shorthand hex colors', () => { + expect(parseColor('#f00')).toEqual({ + space: 'rgb', + r: 255, + g: 0, + b: 0, + alpha: 1, + }) + }) + + it('should parse hex with alpha', () => { + expect(parseColor('#ff000080')).toEqual({ + space: 'rgb', + r: 255, + g: 0, + b: 0, + alpha: expect.closeTo(0.5, 0.01), + }) + }) + + it('should parse rgb() colors', () => { + expect(parseColor('rgb(255, 128, 64)')).toEqual({ + space: 'rgb', + r: 255, + g: 128, + b: 64, + alpha: 1, + }) + }) + + it('should parse rgba() colors', () => { + expect(parseColor('rgba(255, 128, 64, 0.5)')).toEqual({ + space: 'rgb', + r: 255, + g: 128, + b: 64, + alpha: 0.5, + }) + }) + + it('should parse hsl() colors', () => { + expect(parseColor('hsl(120, 50%, 50%)')).toEqual({ + space: 'hsl', + h: 120, + s: 50, + l: 50, + alpha: 1, + }) + }) + + it('should parse hsla() colors', () => { + expect(parseColor('hsla(120, 50%, 50%, 0.5)')).toEqual({ + space: 'hsl', + h: 120, + s: 50, + l: 50, + alpha: 0.5, + }) + }) + + it('should throw for invalid colors', () => { + expect(() => parseColor('not a color')).toThrow() + expect(() => parseColor('#gggggg')).toThrow() + }) + }) + + describe('isValidColor', () => { + it('should return true for valid colors', () => { + expect(isValidColor('#ff0000')).toBe(true) + expect(isValidColor('rgb(255, 0, 0)')).toBe(true) + expect(isValidColor('hsl(0, 100%, 50%)')).toBe(true) + }) + + it('should return false for invalid colors', () => { + expect(isValidColor('not a color')).toBe(false) + expect(isValidColor('#gggggg')).toBe(false) + }) + }) + + describe('normalizeColor', () => { + it('should parse string colors', () => { + const result = normalizeColor('#ff0000') + expect(result.space).toBe('rgb') + expect(result).toEqual(parseColor('#ff0000')) + }) + + it('should return color objects as-is', () => { + const color = { space: 'hsl' as const, h: 120, s: 50, l: 50, alpha: 1 } + expect(normalizeColor(color)).toBe(color) + }) + }) + + describe('colorToHex', () => { + it('should convert RGB to hex', () => { + const color = { space: 'rgb' as const, r: 255, g: 128, b: 64, alpha: 1 } + expect(colorToHex(color)).toBe('#ff8040') + }) + + it('should convert HSL to hex', () => { + const color = { space: 'hsl' as const, h: 0, s: 100, l: 50, alpha: 1 } + expect(colorToHex(color)).toBe('#ff0000') + }) + + it('should include alpha for transparent colors', () => { + const color = { space: 'rgb' as const, r: 255, g: 0, b: 0, alpha: 0.5 } + expect(colorToHex(color)).toBe('#ff000080') + }) + }) + + describe('colorToRgb', () => { + it('should format RGB color', () => { + const color = { space: 'rgb' as const, r: 255, g: 128, b: 64, alpha: 1 } + expect(colorToRgb(color)).toBe('rgb(255, 128, 64)') + }) + + it('should format RGBA for transparent colors', () => { + const color = { space: 'rgb' as const, r: 255, g: 0, b: 0, alpha: 0.5 } + expect(colorToRgb(color)).toBe('rgba(255, 0, 0, 0.5)') + }) + }) + + describe('colorToHsl', () => { + it('should format HSL color', () => { + const color = { space: 'hsl' as const, h: 120, s: 50, l: 50, alpha: 1 } + expect(colorToHsl(color)).toBe('hsl(120, 50%, 50%)') + }) + + it('should format HSLA for transparent colors', () => { + const color = { space: 'hsl' as const, h: 0, s: 100, l: 50, alpha: 0.5 } + expect(colorToHsl(color)).toBe('hsla(0, 100%, 50%, 0.5)') + }) + }) + + describe('convertToRgb', () => { + it('should convert HSL to RGB', () => { + const hsl = { space: 'hsl' as const, h: 0, s: 100, l: 50, alpha: 1 } + const rgb = convertToRgb(hsl) + expect(rgb.space).toBe('rgb') + expect(rgb.r).toBeCloseTo(255, 0) + expect(rgb.g).toBeCloseTo(0, 0) + expect(rgb.b).toBeCloseTo(0, 0) + }) + + it('should preserve existing RGB', () => { + const rgb = { space: 'rgb' as const, r: 128, g: 64, b: 32, alpha: 1 } + expect(convertToRgb(rgb)).toBe(rgb) + }) + }) + + describe('convertToHsl', () => { + it('should convert RGB to HSL', () => { + const rgb = { space: 'rgb' as const, r: 255, g: 0, b: 0, alpha: 1 } + const hsl = convertToHsl(rgb) + expect(hsl.space).toBe('hsl') + expect(hsl.h).toBeCloseTo(0, 0) + expect(hsl.s).toBeCloseTo(100, 0) + expect(hsl.l).toBeCloseTo(50, 0) + }) + + it('should preserve existing HSL', () => { + const hsl = { space: 'hsl' as const, h: 120, s: 50, l: 50, alpha: 1 } + expect(convertToHsl(hsl)).toBe(hsl) + }) + }) + + describe('convertToHsb', () => { + it('should convert RGB to HSB', () => { + const rgb = { space: 'rgb' as const, r: 255, g: 0, b: 0, alpha: 1 } + const hsb = convertToHsb(rgb) + expect(hsb.space).toBe('hsb') + expect(hsb.h).toBeCloseTo(0, 0) + expect(hsb.s).toBeCloseTo(100, 0) + }) + + it('should preserve existing HSB', () => { + const hsb = { space: 'hsb' as const, h: 120, s: 50, b: 75, alpha: 1 } + expect(convertToHsb(hsb)).toBe(hsb) + }) + }) + + describe('getChannelRange', () => { + it('should return correct ranges for hue', () => { + expect(getChannelRange('hue')).toEqual({ min: 0, max: 360, step: 1 }) + }) + + it('should return correct ranges for saturation/lightness/brightness/alpha', () => { + expect(getChannelRange('saturation')).toEqual({ min: 0, max: 100, step: 1 }) + expect(getChannelRange('lightness')).toEqual({ min: 0, max: 100, step: 1 }) + expect(getChannelRange('brightness')).toEqual({ min: 0, max: 100, step: 1 }) + expect(getChannelRange('alpha')).toEqual({ min: 0, max: 100, step: 1 }) + }) + + it('should return correct ranges for RGB channels', () => { + expect(getChannelRange('red')).toEqual({ min: 0, max: 255, step: 1 }) + expect(getChannelRange('green')).toEqual({ min: 0, max: 255, step: 1 }) + expect(getChannelRange('blue')).toEqual({ min: 0, max: 255, step: 1 }) + }) + }) + + describe('getChannelValue', () => { + it('should get RGB values', () => { + const rgb = { space: 'rgb' as const, r: 100, g: 150, b: 200, alpha: 1 } + expect(getChannelValue(rgb, 'red')).toBe(100) + expect(getChannelValue(rgb, 'green')).toBe(150) + expect(getChannelValue(rgb, 'blue')).toBe(200) + }) + + it('should get HSL values', () => { + const hsl = { space: 'hsl' as const, h: 120, s: 50, l: 50, alpha: 1 } + expect(getChannelValue(hsl, 'hue')).toBe(120) + expect(getChannelValue(hsl, 'saturation')).toBe(50) + expect(getChannelValue(hsl, 'lightness')).toBe(50) + }) + + it('should get alpha as percentage', () => { + const rgb = { space: 'rgb' as const, r: 100, g: 150, b: 200, alpha: 0.5 } + expect(getChannelValue(rgb, 'alpha')).toBe(50) + }) + }) + + describe('getChannelValue - color space specific', () => { + it('should get HSL saturation directly from HSL color', () => { + const hsl: HSLColor = { space: 'hsl', h: 120, s: 50, l: 50, alpha: 1 } + expect(getChannelValue(hsl, 'saturation')).toBe(50) + }) + + it('should get HSB saturation directly from HSB color', () => { + const hsb: HSBColor = { space: 'hsb', h: 120, s: 50, b: 50, alpha: 1 } + expect(getChannelValue(hsb, 'saturation')).toBe(50) + }) + + it('should get HSB brightness directly from HSB color', () => { + const hsb: HSBColor = { space: 'hsb', h: 120, s: 50, b: 75, alpha: 1 } + expect(getChannelValue(hsb, 'brightness')).toBe(75) + }) + }) + + describe('setChannelValues', () => { + it('should set HSB saturation and brightness without cross-contamination', () => { + // Start with HSB color + const hsb: HSBColor = { space: 'hsb', h: 0, s: 0, b: 50, alpha: 1 } + + // Set saturation to 10, brightness stays at 50 + const result = setChannelValues(hsb, [ + { channel: 'saturation', value: 10 }, + { channel: 'brightness', value: 50 }, + ]) + + expect(result.space).toBe('hsb') + expect(getChannelValue(result, 'saturation')).toBe(10) + expect(getChannelValue(result, 'brightness')).toBe(50) + }) + + it('should return HSB color when starting from RGB with saturation/brightness channels', () => { + // Start with RGB color (like from hex) + const rgb: RGBColor = { space: 'rgb', r: 128, g: 0, b: 128, alpha: 1 } + + // Set saturation to 99, brightness to 50 + const result = setChannelValues(rgb, [ + { channel: 'saturation', value: 99 }, + { channel: 'brightness', value: 50 }, + ]) + + // Should return HSB (not convert back to RGB) + expect(result.space).toBe('hsb') + expect(getChannelValue(result, 'saturation')).toBe(99) + expect(getChannelValue(result, 'brightness')).toBe(50) + }) + + it('should update only saturation when brightness unchanged', () => { + const hsb: HSBColor = { space: 'hsb', h: 0, s: 0, b: 50, alpha: 1 } + + // Increase saturation to 10 + const result = setChannelValues(hsb, [ + { channel: 'saturation', value: 10 }, + { channel: 'brightness', value: 50 }, + ]) + + expect(getChannelValue(result, 'saturation')).toBe(10) + expect(getChannelValue(result, 'brightness')).toBe(50) + }) + }) + + describe('setChannelValue', () => { + it('should set RGB values', () => { + const rgb = { space: 'rgb' as const, r: 100, g: 150, b: 200, alpha: 1 } + const result = setChannelValue(rgb, 'red', 255) + expect(result.space).toBe('rgb') + expect((result as any).r).toBe(255) + }) + + it('should set HSL values preserving color space', () => { + const hsl = { space: 'hsl' as const, h: 120, s: 50, l: 50, alpha: 1 } + const result = setChannelValue(hsl, 'hue', 180) + expect(result.space).toBe('hsl') + expect((result as any).h).toBe(180) + }) + + it('should set alpha value', () => { + const rgb = { space: 'rgb' as const, r: 100, g: 150, b: 200, alpha: 1 } + const result = setChannelValue(rgb, 'alpha', 50) + expect(result.alpha).toBe(0.5) + }) + + it('should clamp values to valid range', () => { + const hsl = { space: 'hsl' as const, h: 120, s: 50, l: 50, alpha: 1 } + const result = setChannelValue(hsl, 'hue', 500) + expect((result as any).h).toBe(360) + }) + + it('should maintain color space when setting cross-space channels', () => { + const hsl = { space: 'hsl' as const, h: 120, s: 50, l: 50, alpha: 1 } + const result = setChannelValue(hsl, 'red', 255) + expect(result.space).toBe('hsl') + }) + }) +}) diff --git a/packages/core/src/shared/color/utils.ts b/packages/core/src/shared/color/utils.ts new file mode 100644 index 0000000000..6feb5a6e6e --- /dev/null +++ b/packages/core/src/shared/color/utils.ts @@ -0,0 +1,143 @@ +/** + * Converts a hex color string to RGB (Red, Green, Blue). + * @param hex Hex color string (e.g., "#ff5733" or "#f53") + * @returns An object containing red, green, and blue values (0-255). + */ +export function hexToRGB(hex: string): { r: number, g: number, b: number } { + hex = hex.replace(/^#/, '') + + // Validate hex format (3 or 6 hex digits) + if (!/^[0-9A-F]{6}$/i.test(hex) && !/^[0-9A-F]{3}$/i.test(hex)) { + throw new Error(`Invalid hex color: ${hex}. Expected format: #RGB or #RRGGBB`) + } + + // Handle shorthand hex (e.g., "#FFF" -> "#FFFFFF") + if (hex.length === 3) { + hex = hex.split('').map(c => c + c).join('') + } + + const bigint = parseInt(hex, 16) + const r = (bigint >> 16) & 255 + const g = (bigint >> 8) & 255 + const b = bigint & 255 + return { r, g, b } +} + +/** + * Converts a hex color string to HSL (Hue, Saturation, Lightness). + * @param hex Hex color string (e.g., "#ff5733") + * @returns An object containing hue (0-360), saturation (0-100), and lightness (0-100) values. + */ +export function hexToHSL(hex: string): { h: number, s: number, l: number } { + let { r, g, b } = hexToRGB(hex) + + r /= 255 + g /= 255 + b /= 255 + + const max = Math.max(r, g, b) + const min = Math.min(r, g, b) + let h: number + let s: number + let l: number = (max + min) / 2 + + if (max === min) { + h = s = 0 // achromatic + l *= 100 // Scale l to 0-100 for consistency with chromatic case + } + else { + const d = max - min + s = l > 0.5 ? d / (2 - max - min) : d / (max + min) + if (max === r) { + h = (g - b) / d + (g < b ? 6 : 0) + } + else if (max === g) { + h = (b - r) / d + 2 + } + else { + h = (r - g) / d + 4 + } + h /= 6 + h *= 360 + s *= 100 + l *= 100 + } + + return { h, s, l } +} + +/** + * Converts a hex color string to a human-readable color name. + * @param hex Hex color string (e.g., "#ff5733") + * @returns A human-readable color name based on the hue, saturation, and lightness. + */ +export function getColorName(hex: string) { + const { h, s, l } = hexToHSL(hex) + + // Handle achromatic colors (low saturation) + // Using 0-100 scale for all comparisons + if (s < 10) { + if (l < 10) + return 'black' + if (l > 95) + return 'white' + if (l < 20) + return 'very dark gray' + if (l < 35) + return 'dark gray' + if (l < 65) + return 'gray' + if (l < 80) + return 'light gray' + return 'very light gray' + } + + // Determine base color by hue + let baseName + if (h < 15 || h >= 345) + baseName = 'red' + else if (h < 45) + baseName = 'orange' + else if (h < 75) + baseName = 'yellow' + else if (h < 105) + baseName = 'yellow-green' + else if (h < 135) + baseName = 'green' + else if (h < 165) + baseName = 'green-cyan' + else if (h < 195) + baseName = 'cyan' + else if (h < 225) + baseName = 'blue' + else if (h < 255) + baseName = 'blue-violet' + else if (h < 285) + baseName = 'violet' + else if (h < 315) + baseName = 'magenta' + else baseName = 'red-magenta' + + // Add descriptors based on saturation and lightness + // Using 0-100 scale for all comparisons + const descriptors = [] + if (s > 80) + descriptors.push('vibrant') + else if (s < 30) + descriptors.push('muted') + + if (l > 80) + descriptors.push('light') + else if (l < 30) + descriptors.push('dark') + + return descriptors.length > 0 + ? `${descriptors.join(' ')} ${baseName}` + : baseName +} + +export function getColorContrast(hex: string): 'light' | 'dark' { + const { r, g, b } = hexToRGB(hex) + const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255 + return luminance > 0.5 ? 'dark' : 'light' +} diff --git a/packages/core/src/shared/date/useDateField.ts b/packages/core/src/shared/date/useDateField.ts index 44e02becb4..7a51731df6 100644 --- a/packages/core/src/shared/date/useDateField.ts +++ b/packages/core/src/shared/date/useDateField.ts @@ -371,11 +371,11 @@ export function useDateField(props: UseDateFieldProps) { } if (prev === null) { - /** - * If the user types a 0 as the first number, we want - * to keep track of that so that when they type the next - * number, we can move to the next segment. - */ + /** + * If the user types a 0 as the first number, we want + * to keep track of that so that when they type the next + * number, we can move to the next segment. + */ if (num === 0) { props.lastKeyZero.value = true @@ -389,7 +389,7 @@ export function useDateField(props: UseDateFieldProps) { */ if (props.lastKeyZero.value || num > maxStart) { - // move to next + // move to next moveToNext = true } props.lastKeyZero.value = false @@ -417,13 +417,13 @@ export function useDateField(props: UseDateFieldProps) { */ if (digits === 2 || total > max) { - /** - * As we're doing elsewhere, we're checking if the number is greater - * than the max start digit (0-3 in most months), and if so, we're - * going to move to the next segment. - */ + /** + * As we're doing elsewhere, we're checking if the number is greater + * than the max start digit (0-3 in most months), and if so, we're + * going to move to the next segment. + */ if (num > maxStart || total > max) { - // move to next + // move to next moveToNext = true } return { value: num, moveToNext } @@ -449,11 +449,11 @@ export function useDateField(props: UseDateFieldProps) { } if (prev === null) { - /** - * If the user types a 0 as the first number, we want - * to keep track of that so that when they type the next - * number, we can move to the next segment. - */ + /** + * If the user types a 0 as the first number, we want + * to keep track of that so that when they type the next + * number, we can move to the next segment. + */ if (num === 0) { props.lastKeyZero.value = true @@ -467,7 +467,7 @@ export function useDateField(props: UseDateFieldProps) { */ if (props.lastKeyZero.value || num > maxStart) { - // move to next + // move to next moveToNext = true } props.lastKeyZero.value = false @@ -496,13 +496,13 @@ export function useDateField(props: UseDateFieldProps) { */ if (digits === 2 || total > max) { - /** - * As we're doing elsewhere, we're checking if the number is greater - * than the max start digit (0-3 in most months), and if so, we're - * going to move to the next segment. - */ + /** + * As we're doing elsewhere, we're checking if the number is greater + * than the max start digit (0-3 in most months), and if so, we're + * going to move to the next segment. + */ if (num > maxStart) { - // move to next + // move to next moveToNext = true } return { value: num, moveToNext } @@ -528,11 +528,11 @@ export function useDateField(props: UseDateFieldProps) { } if (prev === null) { - /** - * If the user types a 0 as the first number, we want - * to keep track of that so that when they type the next - * number, we can move to the next segment. - */ + /** + * If the user types a 0 as the first number, we want + * to keep track of that so that when they type the next + * number, we can move to the next segment. + */ if (num === 0) { props.lastKeyZero.value = true @@ -546,7 +546,7 @@ export function useDateField(props: UseDateFieldProps) { */ if (props.lastKeyZero.value || num > maxStart) { - // move to next + // move to next moveToNext = true } props.lastKeyZero.value = false @@ -575,13 +575,13 @@ export function useDateField(props: UseDateFieldProps) { */ if (digits === 2 || total > max) { - /** - * As we're doing elsewhere, we're checking if the number is greater - * than the max start digit (0-3 in most months), and if so, we're - * going to move to the next segment. - */ + /** + * As we're doing elsewhere, we're checking if the number is greater + * than the max start digit (0-3 in most months), and if so, we're + * going to move to the next segment. + */ if (num > maxStart) { - // move to next + // move to next moveToNext = true } return { value: num, moveToNext } @@ -874,8 +874,6 @@ export function useDateField(props: UseDateFieldProps) { function handleSegmentKeydown(e: KeyboardEvent) { const disabled = props.disabled.value const readonly = props.readonly.value - if (e.key !== kbd.TAB) - e.preventDefault() if (disabled || readonly) return @@ -887,7 +885,7 @@ export function useDateField(props: UseDateFieldProps) { minute: handleMinuteSegmentKeydown, second: handleSecondSegmentKeydown, dayPeriod: handleDayPeriodSegmentKeydown, - timeZoneName: () => {}, + timeZoneName: () => { }, } as const segmentKeydownHandlers[props.part as keyof typeof segmentKeydownHandlers](e) diff --git a/packages/core/src/shared/macro.ts b/packages/core/src/shared/macro.ts new file mode 100644 index 0000000000..c2c2433184 --- /dev/null +++ b/packages/core/src/shared/macro.ts @@ -0,0 +1,11 @@ +export function isCut(event: KeyboardEvent) { + return (event.key === 'x' || event.key === 'X') && (event.ctrlKey || event.metaKey) +} + +export function isCopy(event: KeyboardEvent) { + return (event.key === 'c' || event.key === 'C') && (event.ctrlKey || event.metaKey) +} + +export function isPaste(event: KeyboardEvent) { + return (event.key === 'v' || event.key === 'V') && (event.ctrlKey || event.metaKey) +} diff --git a/packages/core/vitest.setup.ts b/packages/core/vitest.setup.ts index dbba1b7f8b..8b0c9e0265 100644 --- a/packages/core/vitest.setup.ts +++ b/packages/core/vitest.setup.ts @@ -18,4 +18,12 @@ configureAxe({ beforeAll(() => { window.HTMLElement.prototype.scrollIntoView = vi.fn() + + // jsdom throws "Not implemented" when getComputedStyle receives a + // non-empty pseudo-element string. axe-core calls it this way during + // accessibility audits. Strip the pseudo-element arg so jsdom returns + // the element's computed styles instead of throwing. + const originalGetComputedStyle = window.getComputedStyle + window.getComputedStyle = (elt: Element, pseudoElt?: string | null) => + originalGetComputedStyle(elt) })