diff --git a/components/color-picker/ColorPicker.tsx b/components/color-picker/ColorPicker.tsx new file mode 100644 index 0000000000..15b977ca3a --- /dev/null +++ b/components/color-picker/ColorPicker.tsx @@ -0,0 +1,259 @@ +import type { CSSProperties, ComputedRef, ExtractPropTypes, PropType } from 'vue'; +import type { HsbaColorType } from '../vc-color-picker'; +import type { PopoverProps } from '../popover'; +import type { Color } from './color'; +import type { PanelRender } from './ColorPickerPanel'; + +import { computed, defineComponent, shallowRef } from 'vue'; + +import useConfigInject from '../config-provider/hooks/useConfigInject'; +import Popover from '../popover'; +import theme from '../theme'; +import { generateColor } from './util'; +import PropTypes from '../_util/vue-types'; +import useMergedState from '../_util/hooks/useMergedState'; +import classNames from '../_util/classNames'; +import ColorPickerPanel from './ColorPickerPanel'; + +import ColorTrigger from './components/ColorTrigger'; +import useColorState from './hooks/useColorState'; +import useStyle from './style'; + +import type { + ColorFormat, + ColorPickerBaseProps, + PresetsItem, + TriggerPlacement, + TriggerType, +} from './interface'; +import type { VueNode } from '../_util/type'; +import { useCompactItemContext } from '../space/Compact'; + +const colorPickerProps = () => ({ + value: { + type: [String, Object] as PropType, + default: undefined, + }, + defaultValue: { + type: [String, Object] as PropType, + default: undefined, + }, + open: PropTypes.looseBool, + disabled: PropTypes.looseBool, + placement: { + type: String as PropType, + default: 'bottomLeft', + }, + trigger: { + type: String as PropType, + default: 'click', + }, + format: { + type: String as PropType<'hex' | 'hsb' | 'rgb'>, + default: 'hex', + }, + allowClear: { + type: Boolean as PropType, + default: false, + }, + presets: { + type: Array as PropType, + default: undefined, + }, + arrow: { + type: [Boolean, Object] as PropType, + default: true, + }, + styles: { + type: Object as PropType<{ popup?: CSSProperties; popupOverlayInner?: StyleSheet }>, + default: () => ({}), + }, + rootClassName: PropTypes.string, + onOpenChange: { + type: Function as PropType<(open: boolean) => void>, + default: () => {}, + }, + onFormatChange: { + type: Function as PropType<(format: ColorFormat) => void>, + default: () => {}, + }, + onChange: { + type: Function as PropType<(value: Color, hex: string) => void>, + default: () => {}, + }, + getPopupContainer: { + type: Function as PropType, + default: undefined, + }, + autoAdjustOverflow: { + type: Boolean as PropType, + default: true, + }, + onChangeComplete: { + type: Function as PropType<(value: Color) => void>, + default: () => {}, + }, + showText: { + type: [Boolean, Function] as PropType VueNode)>, + default: false, + }, + + size: PropTypes.oneOf(['small', 'middle', 'large']), + + destroyTooltipOnHide: PropTypes.looseBool, + + panelRender: { + type: Function as PropType, + default: undefined, + }, +}); + +export type ColorPickerProps = Partial>>; + +const ColorPicker = defineComponent({ + name: 'AColorPicker', + inheritAttrs: false, + props: colorPickerProps(), + setup(props, { slots, attrs, emit }) { + const { prefixCls, getPopupContainer, direction } = useConfigInject('color-picker', props); + const { token } = theme.useToken(); + const value = computed(() => props.value); + const [colorValue, setColorValue] = useColorState(token.value.colorPrimary, { + value, + defaultValue: props.defaultValue, + }); + + const open = computed(() => props.open); + const [popupOpen, setPopupOpen] = useMergedState(false, { + value: open, + postState: openData => !props.disabled && openData, + onChange: props.onOpenChange, + }); + const format = computed(() => props.format); + + const [formatValue, setFormatValue] = useMergedState(props.format, { + value: format, + onChange: props.onFormatChange, + }); + + // ===================== Style ===================== + const { compactSize } = useCompactItemContext(prefixCls, direction); + const mergedSize = computed(() => props.size || compactSize.value); + const [wrapSSR, hashId] = useStyle(prefixCls); + + const rtlCls = computed(() => ({ [`${prefixCls.value}-rtl`]: direction.value })); + const mergeCls = computed(() => { + const mergeRootCls = classNames(props.rootClassName, rtlCls.value); + return classNames( + { + [`${prefixCls.value}-sm`]: mergedSize.value === 'small', + [`${prefixCls.value}-lg`]: mergedSize.value === 'large', + }, + mergeRootCls, + hashId.value, + ); + }); + + const mergePopupCls = computed(() => classNames(prefixCls.value, rtlCls.value)); + + const colorCleared = shallowRef(false); + + const popupAllowCloseRef = shallowRef(true); + + const handleChange = (data: Color, type?: HsbaColorType, pickColor?: boolean) => { + let color: Color = generateColor(data); + const isNull = value.value === null || (!value.value && props.defaultValue === null); + if (colorCleared.value || isNull) { + colorCleared.value = false; + const hsba = color.toHsb(); + // ignore alpha slider + if (colorValue.value.toHsb().a === 0 && type !== 'alpha') { + hsba.a = 1; + color = generateColor(hsba); + } + } + if (pickColor) { + popupAllowCloseRef.value = false; + } + + setColorValue(color); + emit('update:value', color, color.toHexString()); + emit('change', color, color.toHexString()); + }; + + const handleClear = () => { + colorCleared.value = true; + emit('clear'); + }; + const handleChangeComplete = color => { + popupAllowCloseRef.value = true; + emit('changeComplete', generateColor(color)); + }; + + const onFormatChange = (format: ColorFormat) => { + setFormatValue(format); + emit('formatChange', format); + }; + const popoverProps: ComputedRef = computed(() => ({ + open: popupOpen.value, + trigger: props.trigger, + placement: props.placement, + arrow: props.arrow, + rootClassName: props.rootClassName, + getPopupContainer: getPopupContainer.value, + autoAdjustOverflow: props.autoAdjustOverflow, + destroyTooltipOnHide: props.destroyTooltipOnHide, + })); + const colorBaseProps: ComputedRef = computed(() => ({ + prefixCls: prefixCls.value, + color: colorValue.value, + allowClear: props.allowClear, + colorCleared: colorCleared.value, + disabled: props.disabled, + presets: props.presets, + format: formatValue.value, + panelRender: props.panelRender, + onFormatChange, + onChangeComplete: handleChangeComplete, + })); + return () => { + return wrapSSR( + { + if (popupAllowCloseRef.value) { + setPopupOpen(visible); + } + }} + content={ + + } + overlayClassName={mergePopupCls.value} + {...popoverProps.value} + > + {slots.children?.() || ( + + )} + , + ); + }; + }, +}); + +export default ColorPicker; diff --git a/components/color-picker/ColorPickerPanel.tsx b/components/color-picker/ColorPickerPanel.tsx new file mode 100644 index 0000000000..e6980c5f87 --- /dev/null +++ b/components/color-picker/ColorPickerPanel.tsx @@ -0,0 +1,72 @@ +import type { HsbaColorType } from '../vc-color-picker'; +import type { ColorPickerBaseProps } from './interface'; +import type { Color } from './color'; + +import { computed, defineComponent, provide } from 'vue'; +import { PanelPickerContext, PanelPresetsContext } from './context'; +import Divider from '../divider'; +import PanelPicker from './components/PanelPicker'; +import PanelPresets from './components/PanelPresets'; +import type { VueNode } from '../_util/type'; + +export type PanelRender = ( + innerPanel: VueNode, + { + components, + }: { + components: { Picker: typeof PanelPicker; Presets: typeof PanelPresets }; + }, +) => VueNode; + +interface ColorPickerPanelProps extends ColorPickerBaseProps { + onChange?: (value?: Color, type?: HsbaColorType) => void; + onClear?: (clear?: boolean) => void; + panelRender?: PanelRender; +} + +const ColorPickerPanel = defineComponent({ + name: 'ColorPickerPanel', + inheritAttrs: false, + props: ['prefixCls', 'presets', 'onChange', 'onClear', 'color', 'panelRender'], + setup(props: ColorPickerPanelProps, { attrs }) { + const colorPickerPanelPrefixCls = computed(() => `${props.prefixCls}-inner-content`); + // ==== Inject props === + const panelPickerProps = computed(() => ({ + prefixCls: props.prefixCls, + value: props.color, + onChange: props.onChange, + onClear: props.onClear, + ...attrs, + })); + const panelPresetsProps = computed(() => ({ + prefixCls: props.prefixCls, + value: props.color, + presets: props.presets, + onChange: props.onChange, + })); + // ==== Inject === + provide(PanelPickerContext, panelPickerProps); + provide(PanelPresetsContext, panelPresetsProps); + // ==== Render === + const innerPanel = computed(() => ( + <> + + {Array.isArray(props.presets) && ( + + )} + + + )); + return () => ( +
+ {typeof props.panelRender === 'function' + ? props.panelRender(innerPanel.value, { + components: { Picker: PanelPicker, Presets: PanelPresets } as any, + }) + : innerPanel.value} +
+ ); + }, +}); + +export default ColorPickerPanel; diff --git a/components/color-picker/__tests__/__snapshots__/demo.test.js.snap b/components/color-picker/__tests__/__snapshots__/demo.test.js.snap new file mode 100644 index 0000000000..610a760235 --- /dev/null +++ b/components/color-picker/__tests__/__snapshots__/demo.test.js.snap @@ -0,0 +1,295 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`renders ./components/color-picker/demo/allowClear.vue correctly 1`] = ` +
+
+
+
+ +
+`; + +exports[`renders ./components/color-picker/demo/base.vue correctly 1`] = ` +
+
+
+
+ +
+`; + +exports[`renders ./components/color-picker/demo/change-completed.vue correctly 1`] = ` +
+
+
+
+ +
+`; + +exports[`renders ./components/color-picker/demo/controlled.vue correctly 1`] = ` +
+
+
+
+ +
+`; + +exports[`renders ./components/color-picker/demo/disabled.vue correctly 1`] = ` +
+
+
+
+ +
+`; + +exports[`renders ./components/color-picker/demo/format.vue correctly 1`] = ` +
+
+
+
+
+
+
+
+
+
+ +
+ +
+
+ +
+
HEX: #1677ff
+
+ +
+
+
+ +
+
+
+
+
+
+
+
+
+ +
+ +
+
+ +
+
HSB: "hsb(215, 91%, 100%)"
+
+ +
+
+
+ +
+
+
+
+
+
+
+
+
+ +
+ +
+
+ +
+
RGB: "rgb(22, 119, 255)"
+
+ +
+
+
+ +
+`; + +exports[`renders ./components/color-picker/demo/panel-render.vue correctly 1`] = ` +
+
+
+
+
Add title:
+ +
+
+
+
+
+
+ +
+ +
+
+ +
+
+
+ +
+
+
+
Horizontal layout:
+ +
+
+
+
+
+
+ +
+ +
+
+ +
+
+
+ +
+`; + +exports[`renders ./components/color-picker/demo/presets.vue correctly 1`] = ` +
+
+
+
+ +
+`; + +exports[`renders ./components/color-picker/demo/size.vue correctly 1`] = ` +
+
+
+
+
+
+
+
+ +
+ +
+ +
+
+
+
+
+ +
+ +
+ +
+
+
+
+
+ +
+ +
+ +
+
+ +
+
+
+
+
+
+
+
#1677FF
+
+ +
+ +
+
+
+
+
+
#1677FF
+
+ +
+ +
+
+
+
+
+
#1677FF
+
+ +
+ +
+
+ +
+`; + +exports[`renders ./components/color-picker/demo/text-render.vue correctly 1`] = ` +
+
+
+
+
+
+
#1677FF
+
+ +
+ +
+
+
+
+
+
Custom Text (#1677ff)
+
+ +
+ +
+
+
+
+
+
+
+ +
+ +
+`; + +exports[`renders ./components/color-picker/demo/trigger-event.vue correctly 1`] = ` +
+
+
+
+ +
+`; diff --git a/components/color-picker/__tests__/demo.test.js b/components/color-picker/__tests__/demo.test.js new file mode 100644 index 0000000000..131bad76c9 --- /dev/null +++ b/components/color-picker/__tests__/demo.test.js @@ -0,0 +1,3 @@ +import demoTest from '../../../tests/shared/demoTest'; + +demoTest('color-picker'); diff --git a/components/color-picker/__tests__/index.test.tsx b/components/color-picker/__tests__/index.test.tsx new file mode 100644 index 0000000000..57a9c2ae09 --- /dev/null +++ b/components/color-picker/__tests__/index.test.tsx @@ -0,0 +1,84 @@ +import { mount } from '@vue/test-utils'; +import ColorPicker from '..'; +import type { ColorPickerProps } from '..'; +import { sleep } from '../../../tests/utils'; + +describe('base ColorPickr', () => { + const wrapper = mount(ColorPicker); + beforeEach(() => { + document.body.innerHTML = ''; + }); + it('default dom', () => { + expect(wrapper.html()).toMatchInlineSnapshot(` +
+
+
+
+ +
+ `); + }); + it('default color', () => { + expect(wrapper.find('.ant-color-picker-color-block-inner').attributes()).toEqual({ + class: 'ant-color-picker-color-block-inner', + style: 'background: rgb(22, 119, 255);', + }); + }); + + it('Should component disabled work', () => { + const props: ColorPickerProps = { + disabled: true, + }; + const wrapper = mount(ColorPicker, { + props, + }); + + expect( + wrapper.find('.ant-color-picker-trigger').classes('ant-color-picker-trigger-disabled'), + ).toBe(true); + }); + + it('Should component defaultValue work', () => { + const props: ColorPickerProps = { + defaultValue: '#000', + }; + const wrapper = mount(ColorPicker, { + props, + }); + expect(wrapper.find('.ant-color-picker-color-block-inner').attributes('style')).toEqual( + 'background: rgb(0, 0, 0);', + ); + }); + + it('fire trigger to show panel', async () => { + const wrapper = mount(ColorPicker, { + sync: false, + props: { + value: '#1677FF', + }, + }); + await wrapper.find('.ant-color-picker-trigger').trigger('click'); + expect( + wrapper.find('.ant-color-picker-trigger').classes('ant-color-picker-trigger-active'), + ).toBe(true); + await sleep(); + const hexInput = document.querySelector( + '.ant-color-picker-hex-input input', + ) as HTMLInputElement; + expect(hexInput?.value).toBe('1677FF'); + const handler = document.querySelector('.ant-color-picker-handler'); + expect(handler?.getAttribute('style')).toBe('background-color: rgb(22, 119, 255);'); + }); + + it('Should Encoding formats, support HEX, HSB, RGB.', async () => { + const wrapper = mount(ColorPicker, { + props: { + format: 'hsb', + value: 'hsb(215, 91%, 100%)', + }, + }); + await wrapper.find('.ant-color-picker-trigger').trigger('click'); + await sleep(); + expect(document.querySelector('.ant-select-selection-item')?.textContent).toBe('HSB'); + }); +}); diff --git a/components/color-picker/color.ts b/components/color-picker/color.ts new file mode 100644 index 0000000000..766c9c3920 --- /dev/null +++ b/components/color-picker/color.ts @@ -0,0 +1,48 @@ +/* eslint-disable class-methods-use-this */ +import type { ColorGenInput } from '../vc-color-picker'; +import { Color as RcColor } from '../vc-color-picker'; + +export const toHexFormat = (value?: string, alpha?: boolean) => + value?.replace(/[^\w/]/gi, '').slice(0, alpha ? 8 : 6) || ''; + +export const getHex = (value?: string, alpha?: boolean) => (value ? toHexFormat(value, alpha) : ''); + +export type Color = Pick< + RcColor, + 'toHsb' | 'toHsbString' | 'toHex' | 'toHexString' | 'toRgb' | 'toRgbString' +>; + +export class ColorFactory { + /** Original Color object */ + private metaColor: RcColor; + + constructor(color: ColorGenInput) { + this.metaColor = new RcColor(color as ColorGenInput); + } + + toHsb() { + return this.metaColor.toHsb(); + } + + toHsbString() { + return this.metaColor.toHsbString(); + } + + toHex() { + return getHex(this.toHexString(), this.metaColor.getAlpha() < 1); + } + + toHexString() { + return this.metaColor.getAlpha() === 1 + ? this.metaColor.toHexString() + : this.metaColor.toHex8String(); + } + + toRgb() { + return this.metaColor.toRgb(); + } + + toRgbString() { + return this.metaColor.toRgbString(); + } +} diff --git a/components/color-picker/components/ColorAlphaInput.tsx b/components/color-picker/components/ColorAlphaInput.tsx new file mode 100644 index 0000000000..90a4428055 --- /dev/null +++ b/components/color-picker/components/ColorAlphaInput.tsx @@ -0,0 +1,54 @@ +import type { Color } from '../color'; +import type { ColorPickerBaseProps } from '../interface'; + +import { computed, defineComponent, ref, watch } from 'vue'; + +import { generateColor, getAlphaColor } from '../util'; +import ColorSteppers from './ColorSteppers'; + +interface ColorAlphaInputProps extends Pick { + value?: Color; + onChange?: (value: Color) => void; +} +const ColorAlphaInput = defineComponent({ + name: 'ColorAlphaInput', + props: ['prefixCls', 'value', 'onChange'], + setup(props: ColorAlphaInputProps) { + const colorAlphaInputPrefixCls = computed(() => `${props.prefixCls}-alpha-input`); + const alphaValue = ref(generateColor(props.value || '#000')); + watch( + () => props.value, + val => { + if (val) { + alphaValue.value = generateColor(val); + } + }, + { + immediate: true, + }, + ); + const handleAlphaChange = (step: number) => { + const hsba = alphaValue.value.toHsb(); + hsba.a = (step || 0) / 100; + const genColor = generateColor(hsba); + if (!props.value) { + alphaValue.value = genColor; + } + props.onChange?.(genColor); + }; + + return () => ( + `${step}%`} + class={colorAlphaInputPrefixCls.value} + onChange={handleAlphaChange} + min={0} + max={100} + /> + ); + }, +}); + +export default ColorAlphaInput; diff --git a/components/color-picker/components/ColorClear.tsx b/components/color-picker/components/ColorClear.tsx new file mode 100644 index 0000000000..94f69b1c49 --- /dev/null +++ b/components/color-picker/components/ColorClear.tsx @@ -0,0 +1,28 @@ +import type { ColorPickerBaseProps } from '../interface'; +import type { Color } from '../color'; + +import { defineComponent } from 'vue'; + +import { generateColor } from '../util'; + +interface ColorClearProps extends Pick { + value?: Color; + colorCleared?: boolean; + onChange?: (value: Color) => void; +} +const ColorClear = defineComponent({ + name: 'ColorClear', + props: ['prefixCls', 'value', 'onChange', 'colorCleared'], + setup(props: ColorClearProps) { + const handleClick = () => { + if (props.value && !props.colorCleared) { + const hsba = props.value.toHsb(); + hsba.a = 0; + const genColor = generateColor(hsba); + props.onChange?.(genColor); + } + }; + return () =>
; + }, +}); +export default ColorClear; diff --git a/components/color-picker/components/ColorHexInput.tsx b/components/color-picker/components/ColorHexInput.tsx new file mode 100644 index 0000000000..9d51aaae01 --- /dev/null +++ b/components/color-picker/components/ColorHexInput.tsx @@ -0,0 +1,54 @@ +import type { Color } from '../color'; +import type { ColorPickerBaseProps } from '../interface'; + +import { computed, defineComponent, ref, watch } from 'vue'; + +import Input from '../../input'; + +import { generateColor, toHexFormat } from '../util'; + +interface ColorHexInputProps extends Pick { + value?: Color; + onChange?: (value: Color) => void; +} + +const hexReg = /(^#[\da-f]{6}$)|(^#[\da-f]{8}$)/i; +const isHexString = (hex?: string) => hexReg.test(`#${hex}`); +const ColorHexInput = defineComponent({ + name: 'ColorHexInput', + props: ['prefixCls', 'value', 'onChange'], + setup(props: ColorHexInputProps) { + const colorHexInputPrefixCls = computed(() => `${props.prefixCls}-hex-input`); + const hexValue = ref(props.value?.toHex()); + watch( + () => props.value, + val => { + const hex = val?.toHex(); + if (isHexString(hex) && val) { + hexValue.value = toHexFormat(hex); + } + }, + { + immediate: true, + }, + ); + const handleHexChange = e => { + const originValue = e.target.value; + hexValue.value = toHexFormat(originValue); + if (isHexString(toHexFormat(originValue, true))) { + props.onChange?.(generateColor(originValue)); + } + }; + return () => ( + + ); + }, +}); + +export default ColorHexInput; diff --git a/components/color-picker/components/ColorHsbInput.tsx b/components/color-picker/components/ColorHsbInput.tsx new file mode 100644 index 0000000000..78ee7ac011 --- /dev/null +++ b/components/color-picker/components/ColorHsbInput.tsx @@ -0,0 +1,76 @@ +import type { HSB } from '../../vc-color-picker'; +import type { Color } from '../color'; +import type { ColorPickerBaseProps } from '../interface'; + +import { computed, defineComponent, ref, watch } from 'vue'; + +import { getRoundNumber } from '../../vc-color-picker/color'; +import { generateColor } from '../util'; + +import ColorSteppers from './ColorSteppers'; + +interface ColorHsbInputProps extends Pick { + value?: Color; + onChange?: (value: Color) => void; +} +const ColorHsbInput = defineComponent({ + name: 'ColorHsbInput', + props: ['prefixCls', 'value', 'onChange'], + setup(props: ColorHsbInputProps) { + const colorHsbInputPrefixCls = computed(() => `${props.prefixCls}-hsb-input`); + const hsbValue = ref(generateColor(props.value || '#000')); + watch( + () => props.value, + val => { + if (val) { + hsbValue.value = generateColor(val || '#000'); + } + }, + { + immediate: true, + }, + ); + const handleHsbChange = (step: number, type: keyof HSB) => { + const hsb = hsbValue.value.toHsb(); + hsb[type] = type === 'h' ? step : (step || 0) / 100; + const genColor = generateColor(hsb); + if (!props.value) { + hsbValue.value = genColor; + } + props.onChange?.(genColor); + }; + return () => ( +
+ getRoundNumber(step || 0).toString()} + onChange={step => handleHsbChange(Number(step), 'h')} + /> + `${getRoundNumber(step || 0)}%`} + onChange={step => handleHsbChange(Number(step), 's')} + /> + `${getRoundNumber(step || 0)}%`} + onChange={step => handleHsbChange(Number(step), 'b')} + /> +
+ ); + }, +}); + +export default ColorHsbInput; diff --git a/components/color-picker/components/ColorInput.tsx b/components/color-picker/components/ColorInput.tsx new file mode 100644 index 0000000000..f9567a6635 --- /dev/null +++ b/components/color-picker/components/ColorInput.tsx @@ -0,0 +1,79 @@ +import type { VNode } from 'vue'; +import type { Color } from '../color'; +import type { ColorPickerBaseProps } from '../interface'; + +import { computed, defineComponent } from 'vue'; + +import useMergedState from '../../_util/hooks/useMergedState'; + +import Select from '../../select'; + +import { ColorFormat } from '../interface'; +import ColorAlphaInput from './ColorAlphaInput'; +import ColorHexInput from './ColorHexInput'; +import ColorHsbInput from './ColorHsbInput'; +import ColorRgbInput from './ColorRgbInput'; + +interface ColorInputProps + extends Pick { + value?: Color; + onChange?: (value: Color) => void; +} + +const selectOptions = [ColorFormat.hex, ColorFormat.hsb, ColorFormat.rgb].map(format => ({ + value: format, + label: format.toLocaleUpperCase(), +})); + +const ColorInput = defineComponent({ + name: 'ColorInput', + props: ['prefixCls', 'format', 'onFormatChange', 'value', 'onChange'], + setup(props: ColorInputProps) { + const [colorFormat, setColorFormat] = useMergedState(props.format, { + onChange: props.onFormatChange, + }); + + const colorInputPrefixCls = computed(() => `${props.prefixCls}-input`); + const handleFormatChange = (newFormat: ColorFormat) => { + setColorFormat(newFormat); + }; + const steppersNode = computed(() => { + const inputProps = { + value: props.value, + prefixCls: props.prefixCls, + onChange: props.onChange, + }; + switch (colorFormat.value) { + case ColorFormat.hsb: + return ; + case ColorFormat.rgb: + return ; + case ColorFormat.hex: + default: + return ; + } + }); + return () => ( +
+