feat(color-picker): color-picker component (#383)

* feat(color-picker): color-picker component
This commit is contained in:
GaoNeng 2023-08-16 11:30:24 +08:00 committed by GitHub
parent c1d78fda3b
commit 4c25c74c36
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
36 changed files with 1211 additions and 6 deletions

View File

@ -0,0 +1,40 @@
<template>
<tiny-color-picker @confirm="onConfirm" @cancel="onCacnel" alpha />
</template>
<script>
import { ColorPicker, Notify } from '@opentiny/vue'
import { ref } from 'vue'
export default {
components: {
TinyColorPicker: ColorPicker,
},
setup() {
const color = ref('#66ccff')
/**
* @param {string} hex #rrggbb
*/
const onConfirm = (hex) => {
Notify({
type: 'success',
position: 'top-right',
title: '用户点击了选择',
message: hex
})
}
const onCacnel = () => {
Notify({
type: 'warning',
position: 'top-right',
title: '用户选择了取消'
})
}
return {
color,
onConfirm,
onCacnel
}
}
}
</script>

View File

@ -0,0 +1,14 @@
<template>
<div>
<tiny-color-picker v-model="color" />
</div>
</template>
<script lang="jsx">
import {ColorPicker} from '@opentiny/vue';
export default {
components: {
TinyColorPicker: ColorPicker
}
}
</script>

View File

@ -0,0 +1,20 @@
<template>
<tiny-color-picker v-model="color" visible />
</template>
<script>
import { ColorPicker } from '@opentiny/vue'
import { ref } from 'vue'
export default {
components: {
TinyColorPicker: ColorPicker
},
setup() {
const color = ref('#66ccff')
return {
color,
}
}
}
</script>

View File

@ -0,0 +1,28 @@
<template>
<tiny-color-picker v-model="color" />
<tiny-button @click="changeColor">
切换
</tiny-button>
</template>
<script>
import { ColorPicker, Button } from '@opentiny/vue'
import { ref } from 'vue'
export default {
components: {
TinyColorPicker: ColorPicker,
TinyButton: Button
},
setup() {
const color = ref('#66ccff')
const changeColor = () => {
color.value = color.value === '#66ccff' ? '#000' : '#66ccff'
}
return {
color,
changeColor
}
}
}
</script>

View File

@ -0,0 +1,40 @@
<template>
<tiny-color-picker @confirm="onConfirm" @cancel="onCacnel" />
</template>
<script>
import { ColorPicker, Notify } from '@opentiny/vue'
import { ref } from 'vue'
export default {
components: {
TinyColorPicker: ColorPicker,
},
setup() {
const color = ref('#66ccff')
/**
* @param {string} hex #rrggbb
*/
const onConfirm = (hex) => {
Notify({
type: 'success',
position: 'top-right',
title: '用户点击了选择',
message: hex
})
}
const onCacnel = () => {
Notify({
type: 'warning',
position: 'top-right',
title: '用户选择了取消'
})
}
return {
color,
onConfirm,
onCacnel
}
}
}
</script>

View File

@ -0,0 +1,7 @@
---
title: ColorPicker 色彩选择器
---
# ColorPicker 色彩选择器
<div>ColorPicker 色彩选择器</div>

View File

@ -0,0 +1,7 @@
---
title: ColorPicker
---
# ColorPicker
<div>ColorPicker</div>

View File

@ -0,0 +1,103 @@
export default {
column: '2',
owner: '',
demos: [
{
'demoId': 'basic-usage',
'name': { 'zh-CN': '基本用法', 'en-US': 'Basic Usage' },
'desc': { 'zh-CN': '详细用法参考如下示例', 'en-US': 'For details, see the following example.' },
'codeFiles': ['base.vue']
},
{
'demoId': 'event',
'name': { 'zh-CN': '事件触发', 'en-US': 'event' },
'desc': { 'zh-CN': '点击确认是将会触发confirm事件, 取消时触发cancel事件', 'en-US': 'When click confirm will trigger confirm event. When click outside or cancel will trigger cancel event' },
'codeFiles': ['event.vue']
},
{
'demoId': 'enable-alpha',
'name': { 'zh-CN': 'Alpha', 'en-US': 'Alpha' },
'desc': { 'zh-CN': 'Alpha选择', 'en-US': 'Alpha select.' },
'codeFiles': ['alpha.vue']
},
{
'demoId': 'default-visible',
'name': { 'zh-CN': '默认显示', 'en-US': 'default-visible' },
'desc': {
'zh-CN': '当visible为true时, 将会默认显示color-select. visible是响应式的, 所以你可以强制控制color-select的状态。当visible切换的时候, 会触发cancel事件',
'en-US': 'If visible is true the <code>color-select</code> will show. The visible prop is reactive so you can force change <code>color-select</code> show or not. When change visible will trigger cancel event'
},
'codeFiles': ['default-visible.vue']
},
{
'demoId': 'dynamic-color-change',
'name': { 'zh-CN': '颜色动态切换', 'en-US': 'dynamic-color-change' },
'desc': {
'zh-CN': '可以动态切换color属性, 以满足各种需求',
'en-US': 'Can dynamically switch color attributes to meet various needs'
},
'codeFiles': ['dynamic-color-change.vue']
},
],
apis: [
{
'name': 'color-picker',
'type': 'component',
'properties': [
{
'name': 'modelValue',
'type': 'String',
'defaultValue': 'transparent',
desc: {
'zh-CN': '默认颜色',
'en-US': 'default color'
},
demoId: 'basic-usage'
},
{
name: 'visible',
type: 'boolean',
defaultValue: 'false',
desc: {
'zh-CN': '是否默认显示color-select',
'en-US': 'Is color select displayed by default'
},
demoId: 'default-visible'
},
{
name: 'alpha',
type: 'boolean',
defaultValue: 'false',
desc: {
'zh-CN': '是否启用alpha选择',
'en-US': 'enable alpha select or not'
},
demoId: 'enable-alpha'
}
],
'events': [
{
name: 'confirm',
type: '(hex:string) => void',
defaultValue: '',
desc: {
'zh-CN': '按下确认时触发该事件',
'en-US': 'When click confirm will trigger confirm event'
},
demoId: 'event'
},
{
name: 'cancel',
type: '()=>void',
defaultValue: '',
desc: {
'zh-CN': '按下取消或点击外部的时触发该事件',
'en-US': 'When click cancel or click out-side will trigger cancel event'
},
demoId: 'event'
}
],
'slots': []
}
]
}

View File

@ -106,7 +106,8 @@ export const cmpMenus = [
{ 'nameCn': '滑块', 'name': 'Slider', 'key': 'slider' },
{ 'nameCn': '开关', 'name': 'Switch', 'key': 'switch' },
{ 'nameCn': '时间选择器', 'name': 'TimePicker', 'key': 'time-picker' },
{ 'nameCn': '时间选择', 'name': 'TimeSelect', 'key': 'time-select' }
{ 'nameCn': '时间选择', 'name': 'TimeSelect', 'key': 'time-select' },
{ 'nameCn': '颜色选择器', 'name': 'ColorPicker', 'key': 'color-picker' }
]
},
{

View File

@ -112,6 +112,7 @@
},
"dependencies": {
"@vue/composition-api": "1.2.2",
"color": "^4.2.3",
"cropperjs": "1.5.12",
"crypto-js": "4.1.1",
"echarts": "5.4.1",
@ -125,6 +126,7 @@
"@antfu/eslint-config": "^0.38.6",
"@commitlint/cli": "^17.3.0",
"@commitlint/config-conventional": "^17.3.0",
"@types/color": "^3.0.3",
"@types/eslint": "^8.4.10",
"@types/node": "^18.11.18",
"@types/shelljs": "^0.8.12",
@ -135,19 +137,19 @@
"@vue/tsconfig": "^0.4.0",
"depcheck": "1.4.3",
"eslint": "^8.31.0",
"eslint-config-prettier": "^8.8.0",
"eslint-plugin-prettier": "^5.0.0",
"fast-glob": "^3.2.12",
"fs-extra": "^11.1.0",
"gulp": "^4.0.2",
"gulp-autoprefixer": "^7.0.1",
"gulp-clean-css": "^4.2.0",
"gulp-less": "^5.0.0",
"gulp-svg-inline": "^1.0.1",
"gulp-transform": "^3.0.5",
"minimist": "^1.2.8",
"eslint-config-prettier": "^8.8.0",
"eslint-plugin-prettier": "^5.0.0",
"fs-extra": "^11.1.0",
"lerna": "^6.4.0",
"lint-staged": "^13.0.3",
"minimist": "^1.2.8",
"node-xlsx": "^0.21.0",
"nx": "^15.4.5",
"prettier": "^3.0.0",

View File

@ -602,6 +602,11 @@
"pc"
]
},
"ColorPicker": {
"path": "vue/src/color-picker/index.ts",
"type": "component",
"exclude": false
},
"ColumnListGroup": {
"path": "vue/src/column-list-group/index.ts",
"type": "component",

View File

@ -0,0 +1,25 @@
import type { IColorPickerRef } from "@/types";
export const calcLeftByAlpha = (wrapper: HTMLElement, thumb: HTMLElement, alpha: number) => {
return Math.round(
(alpha * (wrapper.offsetWidth - thumb.offsetWidth / 2)) / 100
)
}
export const updateThumb = (alpha: number, thumb: HTMLElement, wrapper: HTMLElement) => {
thumb.style.left = `${calcLeftByAlpha(wrapper, thumb, alpha)}px`
}
export const onDrag = (
event: MouseEvent, bar: IColorPickerRef<HTMLElement>, thumb: IColorPickerRef<HTMLElement>,
alpha: IColorPickerRef<number>
) => {
const rect = bar.value.getBoundingClientRect()
const { clientX } = event
let left = clientX - rect.left
left = Math.max(thumb.value.offsetWidth / 2, left)
left = Math.min(left, rect.width - thumb.value.offsetWidth / 2)
alpha.value = Math.round(
((left - thumb.value.offsetWidth / 2) / (rect.width - thumb.value.offsetWidth)) * 100
)
}

View File

@ -0,0 +1,53 @@
import {IColorPickerRef as Ref} from '@/types';
import Color from '../utils/color'
import { draggable } from '../utils/use-drag'
import { onDrag, updateThumb } from '.'
export const api = ['state', 'color', 'slider', 'alphaWrapper', 'alphaThumb']
export const renderless = (props, context, { emit }) => {
const hex = props.color
const color = new Color(hex, props.alpha)
const [rr, gg, bb] = color.getRGB()
const r = context.ref(rr)
const g = context.ref(gg)
const b = context.ref(bb)
const slider: Ref<HTMLElement> = context.ref()
const alphaWrapper: Ref<HTMLElement> = context.ref()
const alphaThumb: Ref<HTMLElement> = context.ref()
const alpha = context.ref(color.get('a'))
context.watch(() => props.color, (hex: string) => {
color.reset(hex)
const [rr, gg, bb] = color.getRGB()
r.value = rr
g.value = gg
b.value = bb
})
context.watch(alpha, (newAlpha) => {
updateThumb(newAlpha, alphaThumb.value, alphaWrapper.value)
emit('alpha-update', alpha.value)
})
const background = context.computed(() => {
return `linear-gradient(to right, rgba(${r.value}, ${g.value}, ${b.value}, 0) 0%, rgba(${r.value}, ${g.value}, ${b.value}, 1) 100%)`
})
const state = context.reactive({
background,
hex
})
const api = {
state,
color: props.color,
slider,
alphaWrapper,
alphaThumb,
}
context.onMounted(() => {
updateThumb(alpha.value, alphaThumb.value, slider.value)
draggable(slider.value, {
drag(event) {
onDrag(event as MouseEvent, slider, alphaThumb, alpha)
}
})
})
return api
}

View File

@ -0,0 +1,43 @@
import { IColorPickerRef } from '@/types'
import type Color from '../utils/color'
export const setPosition = (el: HTMLElement, x: number, y: number) => {
el.style.top = `${y}px`
el.style.left = `${x}px`
}
export const getXBySaturation = (s: number, width: number) => (s * width) / 100
export const getYByLight = (l: number, height: number) => ((100 - l) * height) / 100
export const updatePosition = (event: MouseEvent | TouchEvent, rect: DOMRect, cursor: IColorPickerRef<HTMLElement>) => {
let x = (event as MouseEvent).clientX - rect.left
let y = (event as MouseEvent).clientY - rect.top
x = Math.max(0, x)
x = Math.min(x, rect.width)
y = Math.max(0, y)
y = Math.min(y, rect.height)
setPosition(cursor.value, x - 1 / 2 * cursor.value.offsetWidth, y - 1 / 2 * cursor.value.offsetWidth)
return { x, y }
}
export const calcSaturation = (x: number, width: number) => (x / width)
export const calcBrightness = (y: number, height: number) => 100 - (y / height) * 100
export const getThumbTop = (wrapper: HTMLElement, thumb: HTMLElement, hue: number) => {
return Math.round(
(hue * (wrapper.offsetHeight - thumb.offsetHeight / 2)) / 360
)
}
export const resetCursor = (
s: number, v: number,
wrapper: IColorPickerRef<HTMLElement>,
cursor: IColorPickerRef<HTMLElement>,
thumb: IColorPickerRef<HTMLElement>,
color: Color, h: IColorPickerRef<number>
) => {
const { width, height } = wrapper.value.getBoundingClientRect()
const x = getXBySaturation(s, width) - 1 / 2 * cursor.value.offsetWidth
const y = getYByLight(v, height) - 1 / 2 * cursor.value.offsetWidth
setPosition(cursor.value, x < 0 ? 0 : x, y < 0 ? 0 : y)
const thummbTop = getThumbTop(wrapper.value, thumb.value, color.get('h'))
thumb.value.style.top = `${thummbTop}px`
h.value = color.get('h')
}

View File

@ -0,0 +1,76 @@
import {IColorPickerRef as Ref} from '@/types';
import { draggable } from '../utils/use-drag'
import Color from '../utils/color'
import {
calcBrightness,
calcSaturation,
updatePosition,
getThumbTop,
resetCursor,
} from './index'
export const api = ['state', 'cursor', 'wrapper', 'bar', 'thumb']
export const renderless = (props, context, { emit }) => {
const cursor: Ref<HTMLElement> = context.ref()
const wrapper: Ref<HTMLElement> = context.ref()
const thumb: Ref<HTMLElement> = context.ref()
const bar: Ref<HTMLElement> = context.ref()
const color = new Color(props.color)
const h = context.ref(color.get('h'))
const background = context.computed(() => {
return `hsl(${h.value}deg, 100%, 50%)`
})
const state = context.reactive({
background
})
const api = { state, cursor, wrapper, bar, thumb }
context.watch(() => props.color, (newColor) => {
color.reset(newColor)
resetCursor(color.get('s'), color.get('v'), wrapper, cursor, thumb, color, h)
})
context.onMounted(() => {
const thumbTop = getThumbTop(wrapper.value, thumb.value, h.value)
thumb.value.style.top = `${thumbTop}px`
resetCursor(
color.get('s'),
color.get('v'),
wrapper,
cursor,
thumb,
color,
h
)
draggable(wrapper.value, {
drag(event) {
const rect = wrapper.value.getBoundingClientRect()
const { x, y } = updatePosition(event, rect, cursor)
color.set({
s: calcSaturation(x, rect.width) * 100,
v: calcBrightness(y, rect.height)
})
emit('sv-update', {
s: color.get('s'),
v: color.get('v')
})
},
})
draggable(bar.value, {
drag(event) {
const e = event as MouseEvent
const rect = bar.value.getBoundingClientRect()
let top = e.clientY - rect.top
top = Math.min(top, rect.height - thumb.value.offsetHeight / 2)
top = Math.max(thumb.value.offsetHeight / 2, top)
thumb.value.style.top = `${top}px`
h.value = Math.round(
((top - thumb.value.offsetHeight / 2) /
(rect.height - thumb.value.offsetHeight)) *
360
)
emit('hue-update', h.value)
}
})
})
return api
}

View File

@ -0,0 +1,52 @@
import {IColorPickerRef} from '@/types';
import type Color from './utils/color'
export const onConfirm = (
hex: IColorPickerRef<string>, triggerBg: IColorPickerRef<string>,
res: IColorPickerRef<string>, emit, isShow: IColorPickerRef<boolean>
) => {
return () => {
hex.value = res.value
triggerBg.value = res.value
emit('confirm', res.value)
isShow.value = false
}
}
export const onCancel = (
res: IColorPickerRef<string>, triggerBg: IColorPickerRef<string>, emit, isShow: IColorPickerRef<boolean>
) => {
return () => {
res.value = triggerBg.value
if (isShow.value){
emit('cancel')
}
isShow.value = false
}
}
export const onColorUpdate = (color: Color, res: IColorPickerRef<string>) => {
res.value = color.getHex()
}
export const onHSVUpdate = (color: Color, res: IColorPickerRef<string>, hex: IColorPickerRef<string>) => {
return {
onHueUpdate: (hue: number) => {
color.set({ h: hue })
onColorUpdate(color, res)
hex.value = color.getHex()
},
onSVUpdate: ({ s, v }: { s: number; v: number }) => {
color.set({ s, v })
onColorUpdate(color, res)
}
}
}
export const onAlphaUpdate = (color: Color, res: IColorPickerRef<string>) => {
return {
update: (alpha: number) => {
color.set({ a: alpha })
onColorUpdate(color, res)
}
}
}

View File

@ -0,0 +1,105 @@
import { hsv, rgb } from 'color'
function hexToRgb(hex: string) {
let r = parseInt(hex.substring(1, 3), 16)
let g = parseInt(hex.substring(3, 5), 16)
let b = parseInt(hex.substring(5, 7), 16)
let a = parseInt(hex.slice(7), 16) / 255
return { r, g, b, a: a * 100 }
}
const normalizeHexColor = (color: string) => {
let normalizedColor: string = color.replace('#', '')
if (normalizedColor.length === 3) {
normalizedColor = normalizedColor.split('').map(char => char + char).join('')
}
normalizedColor = normalizedColor.padEnd(6, '0')
const r = parseInt(normalizedColor.substr(0, 2), 16)
const g = parseInt(normalizedColor.substr(2, 2), 16)
const b = parseInt(normalizedColor.substr(4, 2), 16)
let a = 255
if (normalizedColor.length === 8) {
a = parseInt(normalizedColor.slice(6), 16)
}
const hexR = ('0' + r.toString(16)).slice(-2)
const hexG = ('0' + g.toString(16)).slice(-2)
const hexB = ('0' + b.toString(16)).slice(-2)
const alpha = ('0' + a.toString(16)).slice(-2)
return `#${hexR}${hexG}${hexB}${alpha}`
}
export type Format = 'rgb' | 'rgba' | 'hsl' | 'hsla'
export default class Color {
private hex = '#000'
private h = 0
private s = 0
private v = 0
private a = 100
private enableAlpha = false
constructor(value: string, alpha = false) {
this.reset(value)
this.enableAlpha = alpha
}
reset(hex: string) {
if (this.hex === 'transparent') {
this.h = 0
this.s = 0
this.v = 0
this.a = 0
return
}
this.hex = normalizeHexColor(hex)
const { r, g, b, a } = hexToRgb(this.hex)
const { h, s, v } = rgb([r, g, b, a]).hsv().object()
this.h = h
this.s = s
this.v = v
this.a = a
}
set({ h, s, v, a }: { h?: number; s?: number; v?: number; a?: number }) {
this.h = h ?? this.h
this.s = s ?? this.s
this.v = v ?? this.v
this.a = a ?? this.a
}
/**
*
* @returns [R,G,B]
*/
getRGB() {
return hsv(this.h, this.s, this.v).rgb().array()
}
getHex() {
if (!this.enableAlpha) {
return hsv(this.h, this.s, this.v).hex().toString()
}
return hsv(this.h, this.s, this.v, this.a / 100).hexa().toString()
}
/**
*
* @returns [h,s,l]
*/
getHSL() {
return hsv(this.h, this.s, this.v).hsl().unitArray()
}
getHSV() {
return {
h: this.h,
s: this.s,
v: this.v,
a: this.a
}
}
get(key: 'h' | 's' | 'v' | 'a') {
return this[key]
}
}

View File

@ -0,0 +1,43 @@
let isDragging = false
export interface DraggableOptions {
drag?: (event: MouseEvent | TouchEvent) => void
start?: (event: MouseEvent | TouchEvent) => void
end?: (event: MouseEvent | TouchEvent) => void
}
export function draggable(element: HTMLElement, options: DraggableOptions) {
const moveFn = function (event: MouseEvent | TouchEvent) {
options.drag?.(event)
}
const upFn = function (event: MouseEvent | TouchEvent) {
document.removeEventListener('mousemove', moveFn)
document.removeEventListener('mouseup', upFn)
document.removeEventListener('touchmove', moveFn)
document.removeEventListener('touchend', upFn)
document.onselectstart = null
document.ondragstart = null
isDragging = false
options.end?.(event)
}
const downFn = function (event: MouseEvent | TouchEvent) {
if (isDragging) return
event.preventDefault()
document.onselectstart = () => false
document.ondragstart = () => false
document.addEventListener('mousemove', moveFn)
document.addEventListener('mouseup', upFn)
document.addEventListener('touchmove', moveFn)
document.addEventListener('touchend', upFn)
isDragging = true
options.start?.(event)
}
element.addEventListener('mousedown', downFn)
element.addEventListener('touchstart', downFn)
}

View File

@ -0,0 +1,64 @@
import {IColorPickerRef as Ref} from '@/types';
import Color from './utils/color'
import { onConfirm, onCancel, onHSVUpdate, onAlphaUpdate } from '.'
export const api = [
'state',
'changeVisible',
'cursor',
'onColorUpdate',
'onHueUpdate',
'onSVUpdate',
'onConfirm',
'onCancel',
'onAlphaUpdate',
'alpha'
]
export const renderless = (
props,
context,
{ emit }
) => {
const { modelValue, visible } = context.toRefs(props)
const hex = context.ref(modelValue.value ?? 'transparent')
const res = context.ref(modelValue.value ?? 'transparent')
const triggerBg = context.ref(modelValue.value ?? 'transparent')
const isShow = context.ref(visible?.value ?? false)
const cursor: Ref<HTMLElement> = context.ref()
const changeVisible = (state: boolean) => {
isShow.value = state
}
const color = new Color(hex.value, props.alpha)
const state = context.reactive({
isShow,
hex,
color,
triggerBg,
defaultValue: modelValue,
res
})
context.watch(modelValue, (newValue) => {
hex.value = newValue
res.value = newValue
triggerBg.value = newValue
color.reset(hex.value)
})
context.watch(visible, (visible) => {
isShow.value = visible
})
const { onHueUpdate, onSVUpdate } = onHSVUpdate(color, res, hex)
const { update } = onAlphaUpdate(color, res)
const api = {
state,
changeVisible,
onHueUpdate,
onSVUpdate,
onConfirm: onConfirm(hex, triggerBg, res, emit, isShow),
onCancel: onCancel(res, triggerBg, emit, isShow),
onAlphaUpdate: update,
cursor,
alpha: props.alpha
}
return api
}

View File

@ -0,0 +1 @@
export type IColorPickerRef<T> = {value: T}

View File

@ -203,3 +203,4 @@ export * from './wheel.type'
export * from './wizard.type'
export * from './year-range.type'
export * from './year-table.type'
export * from './color-picker.type'

View File

@ -0,0 +1,129 @@
@import '../custom.less';
@import './vars.less';
@colorPickerPrefix: ~'@{css-prefix}color-picker';
.@{colorPickerPrefix} {
.component-css-vars-colorpicker();
&__trigger {
position: relative;
width: 32px;
height: 32px;
border-radius: var(--ti-color-picker-border-radius-sm);
border: var(--ti-color-picker-border-weight) solid var(--ti-color-picker-border-color);
box-sizing: content-box;
padding: var(--ti-color-picker-spacing);
cursor: pointer;
.component-css-vars-colorpicker();
.@{colorPickerPrefix}__inner {
display: flex;
width: 100%;
height: 100%;
align-items: center;
justify-content: center;
border-radius: var(--ti-color-picker-border-radius-sm);
background: var(--ti-color-picker-background);
}
.@{colorPickerPrefix}__wrapper {
display: flex;
flex-direction: column;
position: absolute;
max-width: 300px;
z-index: var(--ti-color-picker__select__wrapper-zindex);
margin-top: var(--ti-color-picker-spacing);
background: var(--ti-color-picker__wrapper-bg);
gap: var(--ti-color-picker-spacing);
padding: var(--ti-color-picker-spacing-2x);
box-shadow: var(--ti-color-picker-shadow);
&__tools {
display: flex;
.tiny-input {
flex: 1 0 0;
}
.tiny-button-group {
flex: 0 0 auto;
}
}
&__inner {
display: flex;
gap: 12px;
&__color-select {
width: 280px;
height: 180px;
position: relative;
.white {
background: linear-gradient(to right, #fff, rgba(255, 255, 255, 0));
}
.black {
background: linear-gradient(to top, #000, rgba(0, 0, 0, 0));
}
.white,
.black {
position: absolute;
inset: 0;
}
.cursor {
position: absolute;
width: 12px;
height: 12px;
border-radius: 100%;
border: 1px solid white;
background: rgba(0, 0, 0, .15);
box-shadow: inset 0 0 1px 1px #0000004d, 0 0 1px 2px #0006;
}
}
&__hue-select {
position: relative;
width: 18px;
border-radius: var(--ti-color-picker-border-radius-xs);
background: linear-gradient(to bottom, #f00 0%, #ff0 17%, #0f0 33%, #0ff 50%, #00f 67%, #f0f 83%, #f00 100%);
div {
position: absolute;
top: 0;
width: 100%;
height: 4px;
background: #fff;
box-shadow: 0 0 2px rgba(0, 0, 0, .6);
border-radius: 1px;
}
}
}
&__alpha {
position: relative;
width: 100%;
height: 20px;
border-radius: var(--ti-color-picker-border-radius-xs);
margin-top: var(--ti-color-picker-spacing-2x);
&__slider {
width: 100%;
height: 100%;
}
&__thumb {
width: 4px;
height: 100%;
position: absolute;
top: 0;
left: 0;
background: #fff;
box-shadow: 0 0 2px rgba(0, 0, 0, .6);
border-radius: 1px;
}
}
}
}
}

View File

@ -0,0 +1,13 @@
.component-css-vars-colorpicker() {
--ti-color-picker-background: var(--ti-common-color-transparent);
--ti-color-picker-border-color: var(--ti-base-color-common-2);
--ti-color-picker-border-weight: var(--ti-common-border-weight-normal);
--ti-color-picker-border-radius-xs: var(--ti-common-border-radius-normal);
--ti-color-picker-border-radius-sm: var(--ti-common-border-radius-1);
--ti-color-picker-border-radius: var(--ti-common-border-radius-2);
--ti-color-picker-spacing: var(--ti-common-space-base);
--ti-color-picker-spacing-2x: var(--ti-common-space-2x);
--ti-color-picker-shadow: var(--ti-common-shadow-2-down);
--ti-color-picker__wrapper-zindex: 1000;
--ti-color-picker__wrapper-bg: var(--ti-common-color-bg-white-emphasize);
}

View File

@ -82,6 +82,10 @@ export default {
total: 'Total',
value: 'Value'
},
colorPicker: {
confirm: 'Ok',
cancel: 'Cancel'
},
creditCardForm: {
submit: 'Submit'
},

View File

@ -82,6 +82,10 @@ export default {
total: '总计',
value: '数值'
},
colorPicker: {
confirm: '选择',
cancel: '取消'
},
creditCardForm: {
submit: '提交'
},

View File

@ -39,6 +39,7 @@ import Checkbox from '@opentiny/vue-checkbox'
import CheckboxGroup from '@opentiny/vue-checkbox-group'
import Collapse from '@opentiny/vue-collapse'
import CollapseItem from '@opentiny/vue-collapse-item'
import ColorPicker from '@opentiny/vue-color-picker'
import ColumnListGroup from '@opentiny/vue-column-list-group'
import ColumnListItem from '@opentiny/vue-column-list-item'
import ConfigProvider from '@opentiny/vue-config-provider'
@ -143,6 +144,7 @@ const components = [
CheckboxGroup,
Collapse,
CollapseItem,
ColorPicker,
ColumnListGroup,
ColumnListItem,
ConfigProvider,
@ -269,6 +271,7 @@ export {
CheckboxGroup,
Collapse,
CollapseItem,
ColorPicker,
ColumnListGroup,
ColumnListItem,
ConfigProvider,
@ -373,6 +376,7 @@ export default {
CheckboxGroup,
Collapse,
CollapseItem,
ColorPicker,
ColumnListGroup,
ColumnListItem,
ConfigProvider,

View File

@ -87,6 +87,7 @@
"@opentiny/vue-collapse": "workspace:~",
"@opentiny/vue-collapse-item": "workspace:~",
"@opentiny/vue-collapse-transition": "workspace:~",
"@opentiny/vue-color-picker": "workspace:~",
"@opentiny/vue-column-list-group": "workspace:~",
"@opentiny/vue-column-list-item": "workspace:~",
"@opentiny/vue-company": "workspace:~",

View File

@ -0,0 +1,60 @@
import { mountPcMode } from '@opentiny-internal/vue-test-utils'
import { expect, test, describe } from 'vitest'
import ColorPicker from '@opentiny/vue-color-picker'
describe('PC Mode', () => {
const mount = mountPcMode
describe('default color', () => {
test('static', () => {
const wrapper = mount(ColorPicker, {
props: {
modelValue: '#66ccff',
}
})
expect(wrapper.classes()).toContain('tiny-color-picker__trigger')
expect(wrapper.find('div .tiny-color-picker__inner').attributes().style).toContain('102, 204, 255')
})
test('dynmaic', async () => {
const wrapper = mount(ColorPicker, {
props: {
modelValue: '#66ccff',
},
})
expect(wrapper.find('div .tiny-color-picker__inner').attributes().style).toContain('102, 204, 255')
await wrapper.setProps({ modelValue: '#000' })
expect(wrapper.find('div .tiny-color-picker__inner').attributes().style).not.toContain('102, 204, 255')
})
})
test('should show color-select wrapper when visible is true', () => {
const wrapper = mount(ColorPicker, {
props: {
modelValue: '#66ccff',
visible: true
},
})
expect(wrapper.findAll('button').length).not.toBe(0)
test('should hidden when click trigger, even if visible is true', async () => {
await wrapper.trigger('click')
expect(wrapper.findAll('button').length).toBe(0)
})
})
test('should show color-select wrapper when click', async () => {
const wrapper = mount(ColorPicker, {
props: {
modelValue: '#66ccff'
},
})
await wrapper.trigger('click')
expect(wrapper.findAll('button').length).not.toBe(0)
await wrapper.trigger('click')
expect(wrapper.findAll('button').length).toBe(0)
})
test('should not be throw when v-model is undefined', () => {
const wrapper = mount(ColorPicker, {
props: {
visible: false
}
})
expect(wrapper.find('div .tiny-color-picker__inner').attributes().style).toContain('transparent')
})
})

View File

@ -0,0 +1,24 @@
import ColorPicker from './src/index'
import '@opentiny/vue-theme/color-picker/index.less'
import { version } from './package.json'
ColorPicker.model = {
prop: 'modelValue',
event: 'update:modelValue'
}
/* istanbul ignore next */
ColorPicker.install = function (Vue) {
Vue.component(ColorPicker.name, ColorPicker)
}
ColorPicker.version = version
/* istanbul ignore next */
if (process.env.BUILD_TARGET === 'runtime') {
if (typeof window !== 'undefined' && window.Vue) {
ColorPicker.install(window.Vue)
}
}
export default ColorPicker

View File

@ -0,0 +1,21 @@
{
"name": "@opentiny/vue-color-picker",
"version": "5.8.0",
"description": "",
"main": "lib/index.js",
"module": "index.ts",
"sideEffects": false,
"dependencies": {
"@opentiny/vue-common": "workspace:~",
"@opentiny/vue-renderless": "workspace:~",
"@opentiny/vue-input": "workspace:~",
"@opentiny/vue-option": "workspace:~",
"@opentiny/vue-button": "workspace:~",
"@opentiny/vue-locale": "workspace:~"
},
"devDependencies": {
"@opentiny-internal/vue-test-utils": "workspace:*",
"vitest": "^0.31.0"
},
"license": "MIT"
}

View File

@ -0,0 +1,34 @@
<template>
<div class="tiny-color-picker__wrapper__alpha" ref="alphaWrapper">
<div
class="tiny-color-picker__wrapper__alpha__slider" :style="{
background: state.background
}"
ref="slider"
></div>
<div
class="tiny-color-picker__wrapper__alpha__thumb" :style="{
top: 0,
left: 0
}"
ref="alphaThumb"
></div>
</div>
</template>
<script>
import { defineComponent, setup } from '@opentiny/vue-common'
import { renderless, api } from '@opentiny/vue-renderless/color-picker/alpha-select/vue'
export default defineComponent({
emits: ['alpha-update'],
props: {
color: {
type: String
}
},
setup(props, context) {
return setup({ props, context, renderless, api })
},
})
</script>

View File

@ -0,0 +1,37 @@
<template>
<div class="tiny-color-picker__wrapper__inner">
<div
class="tiny-color-picker__wrapper__inner__color-select" ref="wrapper" :style="{
background: state.background,
}"
>
<div class="white"></div>
<div class="black"></div>
<div class="cursor" ref="cursor"></div>
</div>
<div class="tiny-color-picker__wrapper__inner__hue-select" ref="bar">
<div ref="thumb"></div>
</div>
</div>
</template>
<script>
import { defineComponent, setup } from '@opentiny/vue-common'
import { renderless, api } from '@opentiny/vue-renderless/color-picker/color-select/vue'
import '@opentiny/vue-theme/color-picker/index.less'
export default defineComponent({
emits: ['hue-update', 'sv-update'],
props: {
color: {
type: String
},
alpha: {
type: Boolean
}
},
setup(props, context) {
return setup({ props, context, renderless, api })
},
})
</script>

View File

@ -0,0 +1,21 @@
import { $props, $setup, $prefix, defineComponent } from '@opentiny/vue-common'
import template from 'virtual-template?pc|mobile'
const $constants = {}
export default defineComponent({
name: $prefix + 'ColorPicker',
props: {
...$props,
_constants: {
type: Object,
default: () => $constants
},
modelValue: String,
visible: Boolean,
alpha: Boolean
},
setup(props, context) {
return $setup({ props, context, template })
}
})

View File

@ -0,0 +1,16 @@
<template>
<div></div>
</template>
<script>
import { renderless, api } from '@opentiny/vue-renderless/color-picker/vue'
import { props, setup, defineComponent } from '@opentiny/vue-common'
export default defineComponent({
emits: ['update:modelValue'],
props: [...props, 'modelValue'],
setup(props, context) {
return setup({ props, context, renderless, api })
}
})
</script>

View File

@ -0,0 +1,63 @@
<template>
<div class="tiny-color-picker__trigger" v-clickoutside="onCancel" @click="() => changeVisible(!state.isShow)">
<div
class="tiny-color-picker__inner" :style="{
background: state.triggerBg ?? ''
}"
>
<IconChevronDown />
</div>
<Transition name="tiny-zoom-in-top">
<div class="tiny-color-picker__wrapper" @click.stop v-if="state.isShow">
<color-select
@hue-update="onHueUpdate"
@sv-update="onSVUpdate"
:color="state.hex"
/>
<alpha-select :color="state.res" @alpha-update="onAlphaUpdate" v-if="alpha" />
<div class="tiny-color-picker__wrapper__tools">
<tiny-input v-model="state.res" />
<tiny-button-group>
<tiny-button type="text" @click="onCancel">
{{ t('ui.colorPicker.cancel') }}
</tiny-button>
<tiny-button @click="onConfirm">
{{ t('ui.colorPicker.confirm') }}
</tiny-button>
</tiny-button-group>
</div>
</div>
</Transition>
</div>
</template>
<script>
import { renderless, api } from '@opentiny/vue-renderless/color-picker/vue'
import { props, setup, defineComponent, directive } from '@opentiny/vue-common'
import { IconChevronDown } from '@opentiny/vue-icon'
import colorSelect from './components/color-select.vue'
import alphaSelect from './components/alpha-select.vue'
import Button from '@opentiny/vue-button'
import ButtonGroup from '@opentiny/vue-button-group'
import Input from '@opentiny/vue-input'
import Clickoutside from '@opentiny/vue-renderless/common/deps/clickoutside'
import '@opentiny/vue-theme/color-picker/index.less'
import { language } from '@opentiny/vue-locale'
export default defineComponent({
emits: ['update:modelValue', 'confirm', 'cancel'],
props: [...props, 'modelValue', 'visible', 'alpha'],
components: {
IconChevronDown: IconChevronDown(),
ColorSelect: colorSelect,
AlphaSelect: alphaSelect,
TinyButton: Button,
TinyButtonGroup: ButtonGroup,
TinyInput: Input
},
directives: directive({ Clickoutside }),
setup(props, context) {
return setup({ props, context, renderless, api, extendOptions: { language } })
}
})
</script>

View File

@ -0,0 +1,44 @@
let isDragging = false
export interface DraggableOptions {
drag?: (event: MouseEvent | TouchEvent) => void
start?: (event: MouseEvent | TouchEvent) => void
end?: (event: MouseEvent | TouchEvent) => void
}
export function draggable(element: HTMLElement, options: DraggableOptions) {
const moveFn = function (event: MouseEvent | TouchEvent) {
options.drag?.(event)
}
const upFn = function (event: MouseEvent | TouchEvent) {
document.removeEventListener('mousemove', moveFn)
document.removeEventListener('mouseup', upFn)
document.removeEventListener('touchmove', moveFn)
document.removeEventListener('touchend', upFn)
document.onselectstart = null
document.ondragstart = null
isDragging = false
options.end?.(event)
}
const downFn = function (event: MouseEvent | TouchEvent) {
if (isDragging) return
event.preventDefault()
document.onselectstart = () => false
document.ondragstart = () => false
document.addEventListener('mousemove', moveFn)
document.addEventListener('mouseup', upFn)
document.addEventListener('touchmove', moveFn)
document.addEventListener('touchend', upFn)
isDragging = true
options.start?.(event)
}
element.addEventListener('mousedown', downFn)
element.addEventListener('touchstart', downFn)
}