Skip to content

Commit

Permalink
feat(shared): tag editor component
Browse files Browse the repository at this point in the history
  • Loading branch information
JohnsonMao committed Nov 23, 2024
1 parent b9725a5 commit 732cbff
Show file tree
Hide file tree
Showing 5 changed files with 208 additions and 109 deletions.
93 changes: 0 additions & 93 deletions components/Group/Form/Fields/TagsField.jsx

This file was deleted.

4 changes: 2 additions & 2 deletions components/Group/Form/Fields/index.jsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { useId } from 'react';
import TagEditor from '@/shared/components/TagEditor';
import AreaCheckbox from './AreaCheckbox';
import Select from './Select';
import TagsField from './TagsField';
import TextField from './TextField';
import Upload from './Upload';
import Wrapper from './Wrapper';
Expand Down Expand Up @@ -30,7 +30,7 @@ const Fields = {
CheckboxGroup: withWrapper(CheckboxGroup),
DateRadio: withWrapper(DateRadio),
Select: withWrapper(Select),
TagsField: withWrapper(TagsField),
TagsField: withWrapper(TagEditor),
TextField: withWrapper(TextField),
Upload: withWrapper(Upload),
};
Expand Down
2 changes: 1 addition & 1 deletion components/Profile/Edit/Edit.styled.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ export const StyledGroup = styled.div`
justify-content: center;
align-items: flex-start;
margin-top: ${({ mt = '20' }) => `${mt}px`};
input {
.MuiInputBase-input {
padding: 17px 16px 12px;
}
`;
Expand Down
19 changes: 6 additions & 13 deletions components/Profile/Edit/index.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ import {
import { MobileDatePicker } from '@mui/x-date-pickers/MobileDatePicker';
import { LocalizationProvider } from '@mui/x-date-pickers/LocalizationProvider';
import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs';
import Fields from '@/components/Group/Form/Fields';
import TagEditor from '@/shared/components/TagEditor';
import ErrorMessage from './ErrorMessage';

import TheAvator from './TheAvator';
Expand Down Expand Up @@ -66,6 +66,7 @@ function EditPage() {
} = useEditProfile();

const user = useSelector((state) => state.user);
const { tags } = useSelector((state) => state.partners);

useEffect(() => {
if (user._id) {
Expand Down Expand Up @@ -452,26 +453,18 @@ function EditPage() {
/>
</StyledGroup>
<StyledGroup>
<Typography sx={{ fontWeight: 500 }}>標籤</Typography>
<Fields.TagsField
<Typography sx={{ fontWeight: 500, mb: '6px' }}>標籤</Typography>
<TagEditor
name="tagList"
value={userState.tagList}
tagOptions={tags}
helperText="可以是學習領域、興趣等等的標籤,例如:音樂創作、程式語言、電繪、社會議題。"
control={{
setRef: (name, element) => setRef(name, element),
onChange: ({ target }) =>
onChangeHandler({ key: target.name, value: target.value }),
}}
/>
<Typography
sx={{
color: '#92989A',
fontWeight: 400,
fontSize: '14px',
mt: '2px',
}}
>
可以是學習領域、興趣等等的標籤,例如:音樂創作、程式語言、電繪、社會議題。
</Typography>
<ErrorMessage errText={errors.tagList} />
</StyledGroup>

Expand Down
199 changes: 199 additions & 0 deletions shared/components/TagEditor.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,199 @@
import { useEffect, useId, useRef, useState } from "react";
import ClearIcon from '@mui/icons-material/Clear';
import { cn } from "@/utils/cn";

function Item({ children, onClick }) {
return (
<li>
<button
type="button"
className={cn(
'flex items-center justify-between w-full',
'text-left px-4 py-2 focus-within:bg-primary-lightest focus-within:outline-primary-lighter',
)}
onClick={() => onClick(children)}
>
{children}
<div className="text-xs text-primary-base">新增</div>
</button>
</li>
);
}

function TagEditor({ name, helperText, value = [], control, tagOptions }) {
const id = useId();
const [input, setInput] = useState('');
const [error, setError] = useState('');
const isComposing = useRef(false);
const inputRef = useRef(null);
const tagOptionsRef = useRef(null);
const tagOptionsFocusIndex = useRef(-1);
const filteredTagOptions = tagOptions.filter((tag) => input && tag.includes(input));
const hasTagOptions = Array.isArray(filteredTagOptions) && filteredTagOptions.length;

const handleChange = (e) => {
const _value = e.target.value;
if (_value.length > 8) setError('標籤最多 8 個字');
else setError('');
setInput(_value);
};

const handleAddTag = (tag) => {
if (!tag) return;
if (error) return;
setInput('');
inputRef.current.focus();
if (value.includes(tag)) return;
control.onChange({
target: {
name,
value: [...value, tag],
},
});
};

const handleDelete = (tag) => () => {
control.onChange({
target: {
name,
value: value.filter((t) => t !== tag),
},
});
};

const handleKeyDown = (e) => {
switch (e.keyCode) {
case 13: {
if (isComposing.current) return;
handleAddTag(input.trim());
break;
}
case 8: {
if (isComposing.current || input || !value.length) return;
const lastTag = value[value.length - 1];
setInput(lastTag);
handleDelete(lastTag)();
break;
}
default:
break;
}
};

const handleBlur = () => {
tagOptionsFocusIndex.current = -1;
setTimeout(() => {
if (
tagOptionsRef.current.contains(document.activeElement) ||
inputRef.current.contains(document.activeElement)
) {
return;
}
setInput('');
setError('');
}, 100);
};

const handleComposition = (action) => () => {
isComposing.current = action;
};

useEffect(() => {
control.setRef?.(name, inputRef.current);
}, [control.setRef, name]);

useEffect(() => {
const handleWindowKeyDown = (e) => {
const buttons = tagOptionsRef.current.querySelectorAll('button');

switch (e.keyCode) {
case 38: {
e.preventDefault();
if (tagOptionsFocusIndex.current < 1) {
inputRef.current.focus();
return;
}
tagOptionsFocusIndex.current -= 1;
buttons[tagOptionsFocusIndex.current].focus();
break;
}
case 40: {
e.preventDefault();
if (tagOptionsFocusIndex.current >= buttons.length - 1) return;
tagOptionsFocusIndex.current += 1;
buttons[tagOptionsFocusIndex.current].focus();
break;
}
default:
if (e.keyCode === 13) return;
inputRef.current.focus();
break;
}
};
window.addEventListener('keydown', handleWindowKeyDown);
return () => window.removeEventListener('keydown', handleWindowKeyDown);
}, [hasTagOptions]);

return (
<>
<label
htmlFor={id}
className={cn(
'relative flex flex-wrap items-center pl-3 py-1.5 gap-1.5 w-full text-sm',
'rounded border border-solid border-basic-200',
'outline outline-transparent focus-within:outline-primary-base',
)}
onBlur={handleBlur}
>
{value.map((tag) => (
<div
key={tag}
className="flex items-center gap-0.5 px-2 py-0.5 rounded-md bg-primary-lightest"
>
<div className="whitespace-nowrap">
{tag}
</div>
<button
type="button"
className="text-basic-300"
onClick={handleDelete(tag)}
>
<ClearIcon />
</button>
</div>
))}
<input
id={id}
ref={inputRef}
className="px-2 py-0.5 min-w-[var(--min-width)] flex-1 outline-none rounded"
style={{ '--min-width': `${input.length + 3}em` }}
value={input}
onChange={handleChange}
onKeyDown={handleKeyDown}
onCompositionStart={handleComposition(true)}
onCompositionEnd={handleComposition(false)}
/>
<ul
ref={tagOptionsRef}
className={cn(
'absolute top-full inset-x-0 mt-1',
'border border-basic-200 rounded-md shadow bg-white',
'transition-[transform,opacity] origin-top opacity-100 scale-y-100',
!(hasTagOptions || input) && 'opacity-0 scale-y-0',
error && 'opacity-0 scale-y-0',
)}
>
{hasTagOptions ? (
filteredTagOptions.map((tag) => <Item key={tag} onClick={handleAddTag}>{tag}</Item>)
) : (
<Item onClick={handleAddTag}>{input}</Item>
)}
</ul>
</label>
<div className="mt-2 text-xs text-basic-400">{helperText}</div>
<div className="text-alert">{error}</div>
</>
);
}

export default TagEditor;

0 comments on commit 732cbff

Please sign in to comment.