From c3304154e2c9f9e8377289573f1f21f710c34275 Mon Sep 17 00:00:00 2001 From: wwsun Date: Sun, 8 Sep 2024 13:05:10 +0800 Subject: [PATCH 1/2] feat: add basic classNameInput --- .../src/ui/classname-input.stories.tsx | 10 + .../designer/src/setters/classname-setter.tsx | 27 +++ packages/ui/src/classname-input.tsx | 181 ++++++++++++++++++ packages/ui/src/index.ts | 1 + 4 files changed, 219 insertions(+) create mode 100644 apps/storybook/src/ui/classname-input.stories.tsx create mode 100644 packages/designer/src/setters/classname-setter.tsx create mode 100644 packages/ui/src/classname-input.tsx 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..250964c --- /dev/null +++ b/apps/storybook/src/ui/classname-input.stories.tsx @@ -0,0 +1,10 @@ +import React from 'react'; +import { ClassNameInput } from '@music163/tango-ui'; + +export default { + title: 'UI/ClassNameInput', +}; + +export function Basic() { + 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..bf05b27 --- /dev/null +++ b/packages/designer/src/setters/classname-setter.tsx @@ -0,0 +1,27 @@ +import React, { useState } from 'react'; + +function ClassNameInput() { + const [classNames, setClassNames] = useState([]); + + const handleInputChange = (event: React.ChangeEvent) => { + const input = event.target.value; + const classes = input.split(' ').filter((className) => className.trim() !== ''); + setClassNames(classes); + }; + + return ( +
+ +
+ 当前 class 列表: + {classNames.map((className, index) => ( + + {className} + + ))} +
+
+ ); +} + +export default ClassNameInput; diff --git a/packages/ui/src/classname-input.tsx b/packages/ui/src/classname-input.tsx new file mode 100644 index 0000000..f2ede2b --- /dev/null +++ b/packages/ui/src/classname-input.tsx @@ -0,0 +1,181 @@ +import React, { useState, useRef, KeyboardEvent, ChangeEvent, useEffect } from 'react'; +import styled from 'styled-components'; + +const InputWrapper = styled.div` + display: flex; + flex-wrap: wrap; + align-items: center; + padding: 4px; + border: 1px solid #d9d9d9; + border-radius: 4px; + min-height: 40px; + position: relative; +`; + +const ClassBadge = styled.span` + background-color: #ff4d4f; + color: white; + padding: 2px 8px; + margin: 2px; + border-radius: 16px; + font-size: 14px; +`; + +const Input = styled.input` + flex: 1; + border: none; + outline: none; + padding: 4px; + font-size: 16px; + min-width: 50px; +`; + +const SuggestionList = styled.ul` + position: absolute; + top: 100%; + left: 0; + right: 0; + background-color: white; + border: 1px solid #d9d9d9; + border-top: none; + list-style-type: none; + padding: 0; + margin: 0; + max-height: 200px; + overflow-y: auto; + z-index: 1; +`; + +const SuggestionItem = styled.li<{ isHighlighted: boolean }>` + padding: 8px; + cursor: pointer; + background-color: ${(props) => (props.isHighlighted ? '#e6f7ff' : 'transparent')}; + &:hover { + background-color: #f0f0f0; + } +`; + +// 这里只是一个简化的 Tailwind CSS 类名列表,实际使用时应该包含更多类名 +const tailwindClasses = [ + 'text-red-500', + 'bg-blue-300', + 'p-4', + 'm-2', + 'flex', + 'items-center', + 'justify-between', + 'rounded-lg', + 'shadow-md', + 'hover:bg-gray-100', + 'focus:outline-none', + 'transition', +]; + +export function ClassNameInput() { + const [classNames, setClassNames] = useState([]); + const [inputValue, setInputValue] = useState(''); + const [suggestions, setSuggestions] = useState([]); + const [highlightedIndex, setHighlightedIndex] = useState(-1); + const inputRef = useRef(null); + + const isValidClassName = (className: string) => { + return /^[a-zA-Z0-9_-]+(?::[a-zA-Z0-9_-]+)*$/.test(className); + }; + + 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(); + setClassNames(newClassNames); + } + }; + + const handleInputChange = (event: ChangeEvent) => { + const input = event.target.value; + setInputValue(input); + if (input) { + const matchedSuggestions = tailwindClasses.filter( + (className) => className.startsWith(input) && !classNames.includes(className), + ); + setSuggestions(matchedSuggestions); + setHighlightedIndex(-1); + } else { + setSuggestions([]); + } + }; + + const addClassName = (className: string) => { + if (className && !classNames.includes(className) && isValidClassName(className)) { + setClassNames([...classNames, className]); + setInputValue(''); + setSuggestions([]); + setHighlightedIndex(-1); + } + }; + + const removeClassName = (index: number) => { + setClassNames(classNames.filter((_, i) => i !== index)); + }; + + const handleSuggestionClick = (suggestion: string) => { + addClassName(suggestion); + }; + + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if (inputRef.current && !inputRef.current.contains(event.target as Node)) { + setSuggestions([]); + } + }; + + document.addEventListener('mousedown', handleClickOutside); + return () => { + document.removeEventListener('mousedown', handleClickOutside); + }; + }, []); + + return ( + inputRef.current?.focus()}> + {classNames.map((className, index) => ( + removeClassName(index)}> + {className} × + + ))} + + {suggestions.length > 0 && ( + + {suggestions.map((suggestion, index) => ( + handleSuggestionClick(suggestion)} + isHighlighted={index === highlightedIndex} + > + {suggestion} + + ))} + + )} + + ); +} 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'; From 53f59b5482c4cd89f1990629bbcd673ed057982a Mon Sep 17 00:00:00 2001 From: wwsun Date: Mon, 9 Sep 2024 15:32:22 +0800 Subject: [PATCH 2/2] fix: add classNameSetter --- .../src/ui/classname-input.stories.tsx | 9 +- .../designer/src/setters/classname-setter.tsx | 30 +- packages/designer/src/setters/index.ts | 5 + packages/ui/src/classname-input.tsx | 323 ++++++++++++------ 4 files changed, 244 insertions(+), 123 deletions(-) diff --git a/apps/storybook/src/ui/classname-input.stories.tsx b/apps/storybook/src/ui/classname-input.stories.tsx index 250964c..6c35869 100644 --- a/apps/storybook/src/ui/classname-input.stories.tsx +++ b/apps/storybook/src/ui/classname-input.stories.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { useState } from 'react'; import { ClassNameInput } from '@music163/tango-ui'; export default { @@ -6,5 +6,10 @@ export default { }; export function Basic() { - return ; + 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 index bf05b27..a3a960a 100644 --- a/packages/designer/src/setters/classname-setter.tsx +++ b/packages/designer/src/setters/classname-setter.tsx @@ -1,27 +1,7 @@ -import React, { useState } from 'react'; +import { FormItemComponentProps } from '@music163/tango-setting-form'; +import { ClassNameInput } from '@music163/tango-ui'; +import React from 'react'; -function ClassNameInput() { - const [classNames, setClassNames] = useState([]); - - const handleInputChange = (event: React.ChangeEvent) => { - const input = event.target.value; - const classes = input.split(' ').filter((className) => className.trim() !== ''); - setClassNames(classes); - }; - - return ( -
- -
- 当前 class 列表: - {classNames.map((className, index) => ( - - {className} - - ))} -
-
- ); +export function ClassNameSetter({ value, onChange }: FormItemComponentProps) { + return ; } - -export default ClassNameInput; 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 index f2ede2b..78bc2f4 100644 --- a/packages/ui/src/classname-input.tsx +++ b/packages/ui/src/classname-input.tsx @@ -1,87 +1,223 @@ 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; + padding: 4px 11px; border: 1px solid #d9d9d9; - border-radius: 4px; - min-height: 40px; - position: relative; -`; - -const ClassBadge = styled.span` - background-color: #ff4d4f; - color: white; - padding: 2px 8px; - margin: 2px; - border-radius: 16px; - font-size: 14px; + 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: 4px; - font-size: 16px; - min-width: 50px; -`; - -const SuggestionList = styled.ul` - position: absolute; - top: 100%; - left: 0; - right: 0; - background-color: white; - border: 1px solid #d9d9d9; - border-top: none; - list-style-type: none; padding: 0; - margin: 0; - max-height: 200px; - overflow-y: auto; - z-index: 1; + font-size: 14px; + min-width: 50px; + height: 24px; + line-height: 24px; `; -const SuggestionItem = styled.li<{ isHighlighted: boolean }>` - padding: 8px; - cursor: pointer; - background-color: ${(props) => (props.isHighlighted ? '#e6f7ff' : 'transparent')}; - &:hover { - background-color: #f0f0f0; - } +const StyledTag = styled(Tag)` + margin: 2px 4px 2px 0; `; -// 这里只是一个简化的 Tailwind CSS 类名列表,实际使用时应该包含更多类名 +// Tailwind CSS 基础类名列表 const tailwindClasses = [ - 'text-red-500', - 'bg-blue-300', - 'p-4', - 'm-2', + // 布局 + 'container', 'flex', - 'items-center', + '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', - 'hover:bg-gray-100', - 'focus:outline-none', - 'transition', + 'shadow-lg', + 'shadow-xl', + 'shadow-2xl', + 'shadow-none', + // 不透明度 + 'opacity-0', + 'opacity-25', + 'opacity-50', + 'opacity-75', + 'opacity-100', ]; -export function ClassNameInput() { - const [classNames, setClassNames] = useState([]); +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(); @@ -100,7 +236,7 @@ export function ClassNameInput() { event.preventDefault(); const newClassNames = [...classNames]; newClassNames.pop(); - setClassNames(newClassNames); + updateValue(newClassNames); } }; @@ -108,9 +244,13 @@ export function ClassNameInput() { const input = event.target.value; setInputValue(input); if (input) { - const matchedSuggestions = tailwindClasses.filter( - (className) => className.startsWith(input) && !classNames.includes(className), - ); + const matchedSuggestions = tailwindClasses + .filter( + (className) => + className.toLowerCase().includes(input.toLowerCase()) && + !classNames.includes(className), + ) + .slice(0, 10); setSuggestions(matchedSuggestions); setHighlightedIndex(-1); } else { @@ -120,62 +260,53 @@ export function ClassNameInput() { const addClassName = (className: string) => { if (className && !classNames.includes(className) && isValidClassName(className)) { - setClassNames([...classNames, className]); + updateValue([...classNames, className]); setInputValue(''); setSuggestions([]); setHighlightedIndex(-1); } }; - const removeClassName = (index: number) => { - setClassNames(classNames.filter((_, i) => i !== index)); + const removeClassName = (removedTag: string) => { + const newClassNames = classNames.filter((tag) => tag !== removedTag); + updateValue(newClassNames); }; const handleSuggestionClick = (suggestion: string) => { addClassName(suggestion); }; - useEffect(() => { - const handleClickOutside = (event: MouseEvent) => { - if (inputRef.current && !inputRef.current.contains(event.target as Node)) { - setSuggestions([]); - } - }; - - document.addEventListener('mousedown', handleClickOutside); - return () => { - document.removeEventListener('mousedown', handleClickOutside); - }; - }, []); + const menu = ( + + {suggestions.map((suggestion, index) => ( + handleSuggestionClick(suggestion)} + className={index === highlightedIndex ? 'ant-dropdown-menu-item-active' : ''} + > + {suggestion} + + ))} + + ); return ( - inputRef.current?.focus()}> - {classNames.map((className, index) => ( - removeClassName(index)}> - {className} × - - ))} - - {suggestions.length > 0 && ( - - {suggestions.map((suggestion, index) => ( - handleSuggestionClick(suggestion)} - isHighlighted={index === highlightedIndex} - > - {suggestion} - - ))} - - )} - + 0} placement="bottomLeft"> + inputRef.current?.focus()}> + {classNames.map((className) => ( + removeClassName(className)}> + {className} + + ))} + + + ); }