diff --git a/apps/storybook/src/ui/classname-input.stories.tsx b/apps/storybook/src/ui/classname-input.stories.tsx new file mode 100644 index 0000000..6c35869 --- /dev/null +++ b/apps/storybook/src/ui/classname-input.stories.tsx @@ -0,0 +1,15 @@ +import React, { useState } from 'react'; +import { ClassNameInput } from '@music163/tango-ui'; + +export default { + title: 'UI/ClassNameInput', +}; + +export function Basic() { + return ; +} + +export function Controlled() { + const [value, setValue] = useState(''); + return ; +} diff --git a/packages/designer/src/setters/classname-setter.tsx b/packages/designer/src/setters/classname-setter.tsx new file mode 100644 index 0000000..a3a960a --- /dev/null +++ b/packages/designer/src/setters/classname-setter.tsx @@ -0,0 +1,7 @@ +import { FormItemComponentProps } from '@music163/tango-setting-form'; +import { ClassNameInput } from '@music163/tango-ui'; +import React from 'react'; + +export function ClassNameSetter({ value, onChange }: FormItemComponentProps) { + return ; +} diff --git a/packages/designer/src/setters/index.ts b/packages/designer/src/setters/index.ts index 667f3ee..99ea223 100644 --- a/packages/designer/src/setters/index.ts +++ b/packages/designer/src/setters/index.ts @@ -26,6 +26,7 @@ import { FlexDirectionSetter, } from './style-setter'; import { ChoiceSetter } from './choice-setter'; +import { ClassNameSetter } from './classname-setter'; import { isValidExpressionCode } from '@music163/tango-core'; const codeValidate: IFormItemCreateOptions['validate'] = (value, field) => { @@ -53,6 +54,10 @@ export const BUILT_IN_SETTERS: IFormItemCreateOptions[] = [ type: 'code', validate: codeValidate, }, + { + name: 'classNameSetter', + component: ClassNameSetter, + }, { name: 'radioGroupSetter', alias: ['choiceSetter'], diff --git a/packages/ui/src/classname-input.tsx b/packages/ui/src/classname-input.tsx new file mode 100644 index 0000000..78bc2f4 --- /dev/null +++ b/packages/ui/src/classname-input.tsx @@ -0,0 +1,312 @@ +import React, { useState, useRef, KeyboardEvent, ChangeEvent, useEffect } from 'react'; +import { Dropdown, Menu, Tag } from 'antd'; +import styled from 'styled-components'; + +const InputWrapper = styled.div` + display: flex; + flex-wrap: wrap; + align-items: center; + padding: 4px 11px; + border: 1px solid #d9d9d9; + border-radius: 2px; + min-height: 32px; + &:hover { + border-color: #40a9ff; + } + &:focus-within { + border-color: #40a9ff; + box-shadow: 0 0 0 2px rgba(24, 144, 255, 0.2); + } +`; + +const Input = styled.input` + flex: 1; + border: none; + outline: none; + padding: 0; + font-size: 14px; + min-width: 50px; + height: 24px; + line-height: 24px; +`; + +const StyledTag = styled(Tag)` + margin: 2px 4px 2px 0; +`; + +// Tailwind CSS 基础类名列表 +const tailwindClasses = [ + // 布局 + 'container', + 'flex', + 'grid', + 'block', + 'inline', + 'inline-block', + 'hidden', + // 弹性布局 + 'flex-row', + 'flex-col', + 'flex-wrap', + 'flex-nowrap', + 'justify-start', + 'justify-end', + 'justify-center', + 'justify-between', + 'justify-around', + 'items-start', + 'items-end', + 'items-center', + 'items-baseline', + 'items-stretch', + // 网格布局 + 'grid-cols-1', + 'grid-cols-2', + 'grid-cols-3', + 'grid-cols-4', + 'grid-cols-5', + 'grid-cols-6', + 'grid-cols-12', + // 间距 + 'p-0', + 'p-1', + 'p-2', + 'p-3', + 'p-4', + 'p-5', + 'p-6', + 'p-8', + 'p-10', + 'p-12', + 'p-16', + 'p-20', + 'm-0', + 'm-1', + 'm-2', + 'm-3', + 'm-4', + 'm-5', + 'm-6', + 'm-8', + 'm-10', + 'm-12', + 'm-16', + 'm-20', + // 尺寸 + 'w-full', + 'w-auto', + 'w-1/2', + 'w-1/3', + 'w-2/3', + 'w-1/4', + 'w-3/4', + 'h-full', + 'h-auto', + 'h-screen', + // 字体 + 'text-xs', + 'text-sm', + 'text-base', + 'text-lg', + 'text-xl', + 'text-2xl', + 'text-3xl', + 'text-4xl', + 'text-5xl', + 'font-thin', + 'font-light', + 'font-normal', + 'font-medium', + 'font-semibold', + 'font-bold', + 'font-extrabold', + // 文本颜色 + 'text-black', + 'text-white', + 'text-gray-100', + 'text-gray-200', + 'text-gray-300', + 'text-gray-400', + 'text-gray-500', + 'text-red-500', + 'text-blue-500', + 'text-green-500', + 'text-yellow-500', + 'text-purple-500', + 'text-pink-500', + // 背景颜色 + 'bg-transparent', + 'bg-black', + 'bg-white', + 'bg-gray-100', + 'bg-gray-200', + 'bg-gray-300', + 'bg-gray-400', + 'bg-gray-500', + 'bg-red-500', + 'bg-blue-500', + 'bg-green-500', + 'bg-yellow-500', + 'bg-purple-500', + 'bg-pink-500', + // 边框 + 'border', + 'border-0', + 'border-2', + 'border-4', + 'border-8', + 'border-black', + 'border-white', + 'border-gray-300', + 'border-gray-400', + 'border-gray-500', + // 圆角 + 'rounded-none', + 'rounded-sm', + 'rounded', + 'rounded-lg', + 'rounded-full', + // 阴影 + 'shadow-sm', + 'shadow', + 'shadow-md', + 'shadow-lg', + 'shadow-xl', + 'shadow-2xl', + 'shadow-none', + // 不透明度 + 'opacity-0', + 'opacity-25', + 'opacity-50', + 'opacity-75', + 'opacity-100', +]; + +interface ClassNameInputProps { + value?: string; + defaultValue?: string; + onChange?: (value: string) => void; +} + +export function ClassNameInput({ value, defaultValue, onChange }: ClassNameInputProps) { + const [inputValue, setInputValue] = useState(''); + const [suggestions, setSuggestions] = useState([]); + const [highlightedIndex, setHighlightedIndex] = useState(-1); + const inputRef = useRef(null); + + const [internalValue, setInternalValue] = useState(defaultValue || ''); + const isControlled = value !== undefined; + const classNames = (isControlled ? value : internalValue).split(' ').filter(Boolean); + + useEffect(() => { + if (isControlled) { + setInternalValue(value); + } + }, [isControlled, value]); + + const isValidClassName = (className: string) => { + return /^[a-zA-Z0-9_-]+(?::[a-zA-Z0-9_-]+)*$/.test(className); + }; + + const updateValue = (newClassNames: string[]) => { + const newValue = newClassNames.join(' '); + if (isControlled) { + onChange?.(newValue); + } else { + setInternalValue(newValue); + onChange?.(newValue); + } + }; + + const handleInputKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Enter') { + event.preventDefault(); + if (highlightedIndex >= 0 && highlightedIndex < suggestions.length) { + addClassName(suggestions[highlightedIndex]); + } else { + addClassName(inputValue.trim()); + } + } else if (event.key === 'ArrowDown') { + event.preventDefault(); + setHighlightedIndex((prev) => (prev < suggestions.length - 1 ? prev + 1 : 0)); + } else if (event.key === 'ArrowUp') { + event.preventDefault(); + setHighlightedIndex((prev) => (prev > 0 ? prev - 1 : suggestions.length - 1)); + } else if (event.key === 'Backspace' && inputValue === '' && classNames.length > 0) { + event.preventDefault(); + const newClassNames = [...classNames]; + newClassNames.pop(); + updateValue(newClassNames); + } + }; + + const handleInputChange = (event: ChangeEvent) => { + const input = event.target.value; + setInputValue(input); + if (input) { + const matchedSuggestions = tailwindClasses + .filter( + (className) => + className.toLowerCase().includes(input.toLowerCase()) && + !classNames.includes(className), + ) + .slice(0, 10); + setSuggestions(matchedSuggestions); + setHighlightedIndex(-1); + } else { + setSuggestions([]); + } + }; + + const addClassName = (className: string) => { + if (className && !classNames.includes(className) && isValidClassName(className)) { + updateValue([...classNames, className]); + setInputValue(''); + setSuggestions([]); + setHighlightedIndex(-1); + } + }; + + const removeClassName = (removedTag: string) => { + const newClassNames = classNames.filter((tag) => tag !== removedTag); + updateValue(newClassNames); + }; + + const handleSuggestionClick = (suggestion: string) => { + addClassName(suggestion); + }; + + const menu = ( + + {suggestions.map((suggestion, index) => ( + handleSuggestionClick(suggestion)} + className={index === highlightedIndex ? 'ant-dropdown-menu-item-active' : ''} + > + {suggestion} + + ))} + + ); + + return ( + 0} placement="bottomLeft"> + inputRef.current?.focus()}> + {classNames.map((className) => ( + removeClassName(className)}> + {className} + + ))} + + + + ); +} diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts index 7e7833a..dd08a3e 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -26,3 +26,4 @@ export * from './tag-select'; export * from './popover'; export * from './drag-panel'; export * from './context-action'; +export * from './classname-input';