diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts index 6a01653e49..328ed332e5 100644 --- a/docs/.vitepress/config.ts +++ b/docs/.vitepress/config.ts @@ -157,6 +157,16 @@ export default defineConfig({ { text: 'Toggle Group', link: '/docs/components/toggle-group' }, ], }, + { + text: 'Color', + items: [ + { text: 'Color Swatch', link: '/docs/components/color-swatch' }, + { + text: 'Color Swatch Picker', + link: '/docs/components/color-swatch-picker', + }, + ], + }, { text: 'Dates', items: [ 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/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/meta/ColorSwatch.md b/docs/content/meta/ColorSwatch.md new file mode 100644 index 0000000000..6ff30b9e57 --- /dev/null +++ b/docs/content/meta/ColorSwatch.md @@ -0,0 +1,38 @@ + + + + + diff --git a/docs/content/meta/ColorSwatchPickerItem.md b/docs/content/meta/ColorSwatchPickerItem.md new file mode 100644 index 0000000000..965b0b595c --- /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..5df63e84f6 --- /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..701ee1d389 --- /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..55c54316ae --- /dev/null +++ b/docs/content/meta/ColorSwatchPickerRoot.md @@ -0,0 +1,112 @@ + + + + + + + diff --git a/packages/core/constant/components.ts b/packages/core/constant/components.ts index e1f1ac71a8..80fa341e35 100644 --- a/packages/core/constant/components.ts +++ b/packages/core/constant/components.ts @@ -56,6 +56,17 @@ export const components = { 'CollapsibleContent', ] as const, + colorSwatch: [ + 'ColorSwatch', + ] as const, + + colorSwatchPicker: [ + 'ColorSwatchPickerRoot', + 'ColorSwatchPickerItem', + 'ColorSwatchPickerItemSwatch', + 'ColorSwatchPickerItemIndicator', + ] as const, + combobox: [ 'ComboboxRoot', 'ComboboxInput', diff --git a/packages/core/src/ColorSwatch/ColorSwatch.test.ts b/packages/core/src/ColorSwatch/ColorSwatch.test.ts new file mode 100644 index 0000000000..4a24d77de3 --- /dev/null +++ b/packages/core/src/ColorSwatch/ColorSwatch.test.ts @@ -0,0 +1,16 @@ +import { mount } from '@vue/test-utils' +import { describe, expect, it } from 'vitest' +import { axe } from 'vitest-axe' +import ColorSwatch from './story/_ColorSwatch.vue' + +describe('given a default ColorSwatch', () => { + it('should pass axe accessibility tests', async () => { + const wrapper = mount(ColorSwatch, { attachTo: document.body }) + expect(await axe(wrapper.element)).toHaveNoViolations() + }) + + it('should render ColorSwatch', () => { + const wrapper = mount(ColorSwatch) + expect(wrapper.element).toBeTruthy() + }) +}) diff --git a/packages/core/src/ColorSwatch/ColorSwatch.vue b/packages/core/src/ColorSwatch/ColorSwatch.vue new file mode 100644 index 0000000000..4d465c8ae9 --- /dev/null +++ b/packages/core/src/ColorSwatch/ColorSwatch.vue @@ -0,0 +1,68 @@ + + + + + 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/_ColorSwatch.vue b/packages/core/src/ColorSwatch/story/_ColorSwatch.vue new file mode 100644 index 0000000000..b9eb415a17 --- /dev/null +++ b/packages/core/src/ColorSwatch/story/_ColorSwatch.vue @@ -0,0 +1,16 @@ + + + diff --git a/packages/core/src/ColorSwatchPicker/ColorSwatchPicker.test.ts b/packages/core/src/ColorSwatchPicker/ColorSwatchPicker.test.ts new file mode 100644 index 0000000000..6798ee7ee5 --- /dev/null +++ b/packages/core/src/ColorSwatchPicker/ColorSwatchPicker.test.ts @@ -0,0 +1,29 @@ +import type { DOMWrapper, VueWrapper } from '@vue/test-utils' +import { mount } from '@vue/test-utils' +import { 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]') + }) + + 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() + }) +}) diff --git a/packages/core/src/ColorSwatchPicker/ColorSwatchPickerItem.vue b/packages/core/src/ColorSwatchPicker/ColorSwatchPickerItem.vue new file mode 100644 index 0000000000..53ed0e3092 --- /dev/null +++ b/packages/core/src/ColorSwatchPicker/ColorSwatchPickerItem.vue @@ -0,0 +1,48 @@ + + + + + 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..42d3423560 --- /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/_ColorSwatchPicker.vue b/packages/core/src/ColorSwatchPicker/story/_ColorSwatchPicker.vue new file mode 100644 index 0000000000..65f562fc6f --- /dev/null +++ b/packages/core/src/ColorSwatchPicker/story/_ColorSwatchPicker.vue @@ -0,0 +1,37 @@ + + + diff --git a/packages/core/src/color/index.ts b/packages/core/src/color/index.ts new file mode 100644 index 0000000000..9c56149efa --- /dev/null +++ b/packages/core/src/color/index.ts @@ -0,0 +1 @@ +export * from './utils' diff --git a/packages/core/src/color/utils.ts b/packages/core/src/color/utils.ts new file mode 100644 index 0000000000..51ee2265dc --- /dev/null +++ b/packages/core/src/color/utils.ts @@ -0,0 +1,129 @@ +/** + * Converts a hex color string to RGB (Red, Green, Blue). + * @param hex + * @returns + */ +export function hexToRGB(hex: string): { r: number, g: number, b: number } { + hex = hex.replace(/^#/, '') + 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, saturation, and lightness 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 + } + 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) + if (s < 0.1) { + if (l < 0.1) + return 'black' + if (l > 0.95) + return 'white' + if (l < 0.2) + return 'very dark gray' + if (l < 0.35) + return 'dark gray' + if (l < 0.65) + return 'gray' + if (l < 0.8) + 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 + const descriptors = [] + if (s > 0.8) + descriptors.push('vibrant') + else if (s < 0.3) + descriptors.push('muted') + + if (l > 0.8) + descriptors.push('light') + else if (l < 0.3) + 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/index.ts b/packages/core/src/index.ts index 348691136c..5546c9b3f7 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -5,6 +5,8 @@ export * from './Avatar' export * from './Calendar' export * from './Checkbox' export * from './Collapsible' +export * from './ColorSwatch' +export * from './ColorSwatchPicker' export * from './Combobox' // utilities export * from './ConfigProvider'