Skip to content

Commit 55602fc

Browse files
authored
fix: add classNameSetter and classNameInput (#207)
* feat: add basic classNameInput * fix: add classNameSetter
1 parent a3c0e46 commit 55602fc

File tree

5 files changed

+340
-0
lines changed

5 files changed

+340
-0
lines changed

Diff for: apps/storybook/src/ui/classname-input.stories.tsx

+15
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import React, { useState } from 'react';
2+
import { ClassNameInput } from '@music163/tango-ui';
3+
4+
export default {
5+
title: 'UI/ClassNameInput',
6+
};
7+
8+
export function Basic() {
9+
return <ClassNameInput defaultValue="hello world" onChange={console.log} />;
10+
}
11+
12+
export function Controlled() {
13+
const [value, setValue] = useState('');
14+
return <ClassNameInput value={value} onChange={setValue} />;
15+
}

Diff for: packages/designer/src/setters/classname-setter.tsx

+7
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
import { FormItemComponentProps } from '@music163/tango-setting-form';
2+
import { ClassNameInput } from '@music163/tango-ui';
3+
import React from 'react';
4+
5+
export function ClassNameSetter({ value, onChange }: FormItemComponentProps<string>) {
6+
return <ClassNameInput value={value} onChange={onChange} />;
7+
}

Diff for: packages/designer/src/setters/index.ts

+5
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import {
2626
FlexDirectionSetter,
2727
} from './style-setter';
2828
import { ChoiceSetter } from './choice-setter';
29+
import { ClassNameSetter } from './classname-setter';
2930
import { isValidExpressionCode } from '@music163/tango-core';
3031

3132
const codeValidate: IFormItemCreateOptions['validate'] = (value, field) => {
@@ -53,6 +54,10 @@ export const BUILT_IN_SETTERS: IFormItemCreateOptions[] = [
5354
type: 'code',
5455
validate: codeValidate,
5556
},
57+
{
58+
name: 'classNameSetter',
59+
component: ClassNameSetter,
60+
},
5661
{
5762
name: 'radioGroupSetter',
5863
alias: ['choiceSetter'],

Diff for: packages/ui/src/classname-input.tsx

+312
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,312 @@
1+
import React, { useState, useRef, KeyboardEvent, ChangeEvent, useEffect } from 'react';
2+
import { Dropdown, Menu, Tag } from 'antd';
3+
import styled from 'styled-components';
4+
5+
const InputWrapper = styled.div`
6+
display: flex;
7+
flex-wrap: wrap;
8+
align-items: center;
9+
padding: 4px 11px;
10+
border: 1px solid #d9d9d9;
11+
border-radius: 2px;
12+
min-height: 32px;
13+
&:hover {
14+
border-color: #40a9ff;
15+
}
16+
&:focus-within {
17+
border-color: #40a9ff;
18+
box-shadow: 0 0 0 2px rgba(24, 144, 255, 0.2);
19+
}
20+
`;
21+
22+
const Input = styled.input`
23+
flex: 1;
24+
border: none;
25+
outline: none;
26+
padding: 0;
27+
font-size: 14px;
28+
min-width: 50px;
29+
height: 24px;
30+
line-height: 24px;
31+
`;
32+
33+
const StyledTag = styled(Tag)`
34+
margin: 2px 4px 2px 0;
35+
`;
36+
37+
// Tailwind CSS 基础类名列表
38+
const tailwindClasses = [
39+
// 布局
40+
'container',
41+
'flex',
42+
'grid',
43+
'block',
44+
'inline',
45+
'inline-block',
46+
'hidden',
47+
// 弹性布局
48+
'flex-row',
49+
'flex-col',
50+
'flex-wrap',
51+
'flex-nowrap',
52+
'justify-start',
53+
'justify-end',
54+
'justify-center',
55+
'justify-between',
56+
'justify-around',
57+
'items-start',
58+
'items-end',
59+
'items-center',
60+
'items-baseline',
61+
'items-stretch',
62+
// 网格布局
63+
'grid-cols-1',
64+
'grid-cols-2',
65+
'grid-cols-3',
66+
'grid-cols-4',
67+
'grid-cols-5',
68+
'grid-cols-6',
69+
'grid-cols-12',
70+
// 间距
71+
'p-0',
72+
'p-1',
73+
'p-2',
74+
'p-3',
75+
'p-4',
76+
'p-5',
77+
'p-6',
78+
'p-8',
79+
'p-10',
80+
'p-12',
81+
'p-16',
82+
'p-20',
83+
'm-0',
84+
'm-1',
85+
'm-2',
86+
'm-3',
87+
'm-4',
88+
'm-5',
89+
'm-6',
90+
'm-8',
91+
'm-10',
92+
'm-12',
93+
'm-16',
94+
'm-20',
95+
// 尺寸
96+
'w-full',
97+
'w-auto',
98+
'w-1/2',
99+
'w-1/3',
100+
'w-2/3',
101+
'w-1/4',
102+
'w-3/4',
103+
'h-full',
104+
'h-auto',
105+
'h-screen',
106+
// 字体
107+
'text-xs',
108+
'text-sm',
109+
'text-base',
110+
'text-lg',
111+
'text-xl',
112+
'text-2xl',
113+
'text-3xl',
114+
'text-4xl',
115+
'text-5xl',
116+
'font-thin',
117+
'font-light',
118+
'font-normal',
119+
'font-medium',
120+
'font-semibold',
121+
'font-bold',
122+
'font-extrabold',
123+
// 文本颜色
124+
'text-black',
125+
'text-white',
126+
'text-gray-100',
127+
'text-gray-200',
128+
'text-gray-300',
129+
'text-gray-400',
130+
'text-gray-500',
131+
'text-red-500',
132+
'text-blue-500',
133+
'text-green-500',
134+
'text-yellow-500',
135+
'text-purple-500',
136+
'text-pink-500',
137+
// 背景颜色
138+
'bg-transparent',
139+
'bg-black',
140+
'bg-white',
141+
'bg-gray-100',
142+
'bg-gray-200',
143+
'bg-gray-300',
144+
'bg-gray-400',
145+
'bg-gray-500',
146+
'bg-red-500',
147+
'bg-blue-500',
148+
'bg-green-500',
149+
'bg-yellow-500',
150+
'bg-purple-500',
151+
'bg-pink-500',
152+
// 边框
153+
'border',
154+
'border-0',
155+
'border-2',
156+
'border-4',
157+
'border-8',
158+
'border-black',
159+
'border-white',
160+
'border-gray-300',
161+
'border-gray-400',
162+
'border-gray-500',
163+
// 圆角
164+
'rounded-none',
165+
'rounded-sm',
166+
'rounded',
167+
'rounded-lg',
168+
'rounded-full',
169+
// 阴影
170+
'shadow-sm',
171+
'shadow',
172+
'shadow-md',
173+
'shadow-lg',
174+
'shadow-xl',
175+
'shadow-2xl',
176+
'shadow-none',
177+
// 不透明度
178+
'opacity-0',
179+
'opacity-25',
180+
'opacity-50',
181+
'opacity-75',
182+
'opacity-100',
183+
];
184+
185+
interface ClassNameInputProps {
186+
value?: string;
187+
defaultValue?: string;
188+
onChange?: (value: string) => void;
189+
}
190+
191+
export function ClassNameInput({ value, defaultValue, onChange }: ClassNameInputProps) {
192+
const [inputValue, setInputValue] = useState('');
193+
const [suggestions, setSuggestions] = useState<string[]>([]);
194+
const [highlightedIndex, setHighlightedIndex] = useState(-1);
195+
const inputRef = useRef<HTMLInputElement>(null);
196+
197+
const [internalValue, setInternalValue] = useState(defaultValue || '');
198+
const isControlled = value !== undefined;
199+
const classNames = (isControlled ? value : internalValue).split(' ').filter(Boolean);
200+
201+
useEffect(() => {
202+
if (isControlled) {
203+
setInternalValue(value);
204+
}
205+
}, [isControlled, value]);
206+
207+
const isValidClassName = (className: string) => {
208+
return /^[a-zA-Z0-9_-]+(?::[a-zA-Z0-9_-]+)*$/.test(className);
209+
};
210+
211+
const updateValue = (newClassNames: string[]) => {
212+
const newValue = newClassNames.join(' ');
213+
if (isControlled) {
214+
onChange?.(newValue);
215+
} else {
216+
setInternalValue(newValue);
217+
onChange?.(newValue);
218+
}
219+
};
220+
221+
const handleInputKeyDown = (event: KeyboardEvent<HTMLInputElement>) => {
222+
if (event.key === 'Enter') {
223+
event.preventDefault();
224+
if (highlightedIndex >= 0 && highlightedIndex < suggestions.length) {
225+
addClassName(suggestions[highlightedIndex]);
226+
} else {
227+
addClassName(inputValue.trim());
228+
}
229+
} else if (event.key === 'ArrowDown') {
230+
event.preventDefault();
231+
setHighlightedIndex((prev) => (prev < suggestions.length - 1 ? prev + 1 : 0));
232+
} else if (event.key === 'ArrowUp') {
233+
event.preventDefault();
234+
setHighlightedIndex((prev) => (prev > 0 ? prev - 1 : suggestions.length - 1));
235+
} else if (event.key === 'Backspace' && inputValue === '' && classNames.length > 0) {
236+
event.preventDefault();
237+
const newClassNames = [...classNames];
238+
newClassNames.pop();
239+
updateValue(newClassNames);
240+
}
241+
};
242+
243+
const handleInputChange = (event: ChangeEvent<HTMLInputElement>) => {
244+
const input = event.target.value;
245+
setInputValue(input);
246+
if (input) {
247+
const matchedSuggestions = tailwindClasses
248+
.filter(
249+
(className) =>
250+
className.toLowerCase().includes(input.toLowerCase()) &&
251+
!classNames.includes(className),
252+
)
253+
.slice(0, 10);
254+
setSuggestions(matchedSuggestions);
255+
setHighlightedIndex(-1);
256+
} else {
257+
setSuggestions([]);
258+
}
259+
};
260+
261+
const addClassName = (className: string) => {
262+
if (className && !classNames.includes(className) && isValidClassName(className)) {
263+
updateValue([...classNames, className]);
264+
setInputValue('');
265+
setSuggestions([]);
266+
setHighlightedIndex(-1);
267+
}
268+
};
269+
270+
const removeClassName = (removedTag: string) => {
271+
const newClassNames = classNames.filter((tag) => tag !== removedTag);
272+
updateValue(newClassNames);
273+
};
274+
275+
const handleSuggestionClick = (suggestion: string) => {
276+
addClassName(suggestion);
277+
};
278+
279+
const menu = (
280+
<Menu>
281+
{suggestions.map((suggestion, index) => (
282+
<Menu.Item
283+
key={index}
284+
onClick={() => handleSuggestionClick(suggestion)}
285+
className={index === highlightedIndex ? 'ant-dropdown-menu-item-active' : ''}
286+
>
287+
{suggestion}
288+
</Menu.Item>
289+
))}
290+
</Menu>
291+
);
292+
293+
return (
294+
<Dropdown overlay={menu} visible={suggestions.length > 0} placement="bottomLeft">
295+
<InputWrapper onClick={() => inputRef.current?.focus()}>
296+
{classNames.map((className) => (
297+
<StyledTag key={className} closable onClose={() => removeClassName(className)}>
298+
{className}
299+
</StyledTag>
300+
))}
301+
<Input
302+
ref={inputRef}
303+
type="text"
304+
value={inputValue}
305+
onKeyDown={handleInputKeyDown}
306+
onChange={handleInputChange}
307+
placeholder={classNames.length === 0 ? '输入 class 名称' : ''}
308+
/>
309+
</InputWrapper>
310+
</Dropdown>
311+
);
312+
}

Diff for: packages/ui/src/index.ts

+1
Original file line numberDiff line numberDiff line change
@@ -26,3 +26,4 @@ export * from './tag-select';
2626
export * from './popover';
2727
export * from './drag-panel';
2828
export * from './context-action';
29+
export * from './classname-input';

0 commit comments

Comments
 (0)