Skip to content

Commit f35b6e9

Browse files
authored
feat: refactor list component (#65)
Refactor List component for better readability and maintainability.
1 parent 48d4d04 commit f35b6e9

File tree

2 files changed

+177
-87
lines changed

2 files changed

+177
-87
lines changed
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@matechat/react": patch:feat
3+
---
4+
5+
Refactor `List` component in `list.tsx` to improve code structure and maintainability.

src/list.tsx

Lines changed: 172 additions & 87 deletions
Original file line numberDiff line numberDiff line change
@@ -1,112 +1,197 @@
11
import clsx from "clsx";
22
import { twMerge } from "tailwind-merge";
33

4-
export type SelectOptionsType = SelectOption[] | OptionGroup[];
4+
export type UnknownRecord = Record<string, unknown>;
55

6-
export interface SelectOption {
7-
label?: string;
8-
value?: string;
9-
className?: string;
6+
export type GroupData = UnknownRecord & {
107
[key: string]: unknown;
11-
}
8+
};
129

13-
export interface OptionGroup {
14-
label?: string;
10+
export type OptionData = UnknownRecord & {
1511
[key: string]: unknown;
12+
};
13+
14+
export type SelectItemOptionsType = OptionData[] | GroupData[];
15+
16+
interface ListChangeTargetOptions {
17+
name: string;
18+
id: string;
19+
value: unknown;
1620
}
1721

18-
export interface ListProps extends React.ComponentProps<"div"> {
19-
value: string | undefined;
20-
options: SelectOptionsType | undefined;
21-
className?: string;
22-
optionGroupChildren?: string;
23-
optionGroupLabel?: string;
24-
optionGroupTemplate?: (group: OptionGroup) => React.ReactNode;
25-
optionLabel?: string;
26-
optionValue?: string;
27-
onSelected?: (value: string) => void;
22+
interface ListChangeEvent {
23+
originalEvent: React.SyntheticEvent;
24+
value: unknown;
25+
stopPropagation(): void;
26+
preventDefault(): void;
27+
target: ListChangeTargetOptions;
2828
}
2929

30-
export function List({
31-
value,
32-
options,
30+
export interface ListProps
31+
extends Omit<
32+
React.DetailedHTMLProps<
33+
React.HTMLAttributes<HTMLDivElement>,
34+
HTMLDivElement
35+
>,
36+
"onChange"
37+
> {
38+
listBoxStyle?: React.CSSProperties | undefined;
39+
listGroupStyle?: React.CSSProperties | undefined;
40+
listItemStyle?: React.CSSProperties | undefined;
41+
listStyle?: React.CSSProperties | undefined;
42+
optionGroupChildren?: string | undefined;
43+
optionGroupLabel?: string | undefined;
44+
optionLabel: string;
45+
options?: SelectItemOptionsType | undefined;
46+
value?: string | null;
47+
onChange?: (event: ListChangeEvent) => void;
48+
}
49+
50+
interface OptionGroupItem {
51+
optionGroup: GroupData;
52+
group: boolean;
53+
index: number;
54+
label: unknown;
55+
}
56+
57+
export type MixedOptionArray = Array<OptionGroupItem | OptionData>;
58+
59+
export const List = ({
3360
className,
34-
optionGroupChildren = "items",
35-
optionGroupLabel = "label",
36-
optionGroupTemplate,
37-
optionLabel = "label",
38-
optionValue = "value",
39-
onSelected,
61+
listBoxStyle,
62+
listGroupStyle,
63+
listItemStyle,
64+
listStyle,
65+
optionGroupChildren,
66+
optionGroupLabel,
67+
optionLabel,
68+
options,
69+
value,
70+
onChange,
4071
...props
41-
}: ListProps) {
42-
const renderOption = (option: SelectOption) => (
43-
<button
44-
type="button"
45-
tabIndex={-1}
46-
data-slot="list-item"
47-
key={
48-
(option[optionValue] as string | number) ??
49-
(option[optionLabel] as string | number)
50-
}
51-
className={twMerge(
52-
clsx(
53-
"cursor-pointer px-4 py-2 text-sm hover:bg-blue-300 block w-full text-left",
54-
value === option[optionValue] &&
55-
"bg-blue-500 hover:bg-blue-500 font-semibold",
56-
),
57-
)}
58-
onClick={() => onSelected?.(option[optionValue] as string)}
59-
onKeyDown={(e) => {
60-
if (e.key === "Enter" || e.key === " ") {
61-
e.preventDefault();
62-
onSelected?.(option[optionValue] as string);
72+
}: ListProps) => {
73+
const handleSelect = (
74+
event: React.MouseEvent<HTMLElement> | React.KeyboardEvent<HTMLElement>,
75+
option: UnknownRecord,
76+
) => {
77+
const changeEvent: ListChangeEvent = {
78+
originalEvent: event,
79+
value: option[optionLabel],
80+
stopPropagation: () => event.stopPropagation(),
81+
preventDefault: () => event.preventDefault(),
82+
target: {
83+
name: option.label?.toString() || "",
84+
id: option.value?.toString() || "",
85+
value: option[optionLabel],
86+
},
87+
};
88+
onChange?.(changeEvent);
89+
};
90+
91+
const flatOptions = (options: GroupData[] | undefined) => {
92+
return (options || []).reduce(
93+
(result: MixedOptionArray, option: GroupData, index: number) => {
94+
result.push({
95+
optionGroup: option,
96+
group: true,
97+
index,
98+
label: option[optionGroupLabel as keyof GroupData],
99+
});
100+
101+
const groupChildren = optionGroupChildren
102+
? option[optionGroupChildren as keyof GroupData]
103+
: null;
104+
105+
if (Array.isArray(groupChildren)) {
106+
groupChildren.forEach((o: OptionData) => result.push(o));
63107
}
64-
}}
65-
>
66-
{option[optionLabel] as React.ReactNode}
67-
</button>
68-
);
108+
return result;
109+
},
110+
[] as MixedOptionArray,
111+
);
112+
};
113+
114+
const createItem = (option: OptionData | OptionGroupItem, index: number) => {
115+
const isOptionGroupItem = (
116+
item: OptionData | OptionGroupItem,
117+
): item is OptionGroupItem => {
118+
return "group" in item && item.group === true;
119+
};
120+
121+
if (isOptionGroupItem(option)) {
122+
const groupContent =
123+
option.optionGroup[optionGroupLabel as keyof GroupData];
124+
const key = `group_${index}_${String(groupContent)}`;
125+
126+
return (
127+
<li
128+
key={key}
129+
role-slot="list-group"
130+
aria-label={String(groupContent || "")}
131+
className={clsx("font-bold dark:text-gray-400 py-2 px-4 text-sm")}
132+
style={listGroupStyle}
133+
>
134+
{String(groupContent || " ")}
135+
</li>
136+
);
137+
}
138+
139+
const optionData = option as OptionData;
140+
const optionKey = optionData[optionLabel as keyof OptionData];
141+
const key = `option_${index}_${String(optionKey)}`;
142+
143+
const handleClick = (e: React.MouseEvent<HTMLElement>) => {
144+
handleSelect(e, optionData);
145+
};
69146

70-
const renderGroup = (group: OptionGroup) => {
71-
const children = (group?.[optionGroupChildren] as SelectOption[]) ?? [];
147+
const optionValue = optionData.value;
148+
const isSelected = String(optionValue) === String(value);
72149

73150
return (
74-
<div
75-
key={
76-
(group[optionGroupLabel] as string | number) ??
77-
(group.code as string | number)
78-
}
151+
<li
152+
key={key}
153+
role-slot="list-item"
154+
className={twMerge(
155+
clsx("py-2 px-4 hover:bg-gray-200 text-gray-600 cursor-pointer", {
156+
"bg-blue-500 text-white hover:bg-blue-600": isSelected,
157+
}),
158+
)}
159+
style={listItemStyle}
160+
onClick={handleClick}
161+
onKeyDown={(e) => {
162+
if (e.key === "Enter" || e.key === " ") {
163+
e.preventDefault();
164+
handleSelect(
165+
e as
166+
| React.MouseEvent<HTMLElement>
167+
| React.KeyboardEvent<HTMLElement>,
168+
optionData,
169+
);
170+
}
171+
}}
79172
>
80-
<div
81-
data-slot="list-label"
82-
className="px-3 py-2 bg-gray-100 font-medium text-sm flex items-center gap-2"
83-
>
84-
{optionGroupTemplate
85-
? (optionGroupTemplate(group) as React.ReactNode)
86-
: (group[optionGroupLabel] as React.ReactNode)}
87-
</div>
88-
{children.map(renderOption)}
89-
</div>
173+
{String(optionKey || "")}
174+
</li>
175+
);
176+
};
177+
178+
const createList = () => {
179+
const finalOptions = optionGroupLabel
180+
? flatOptions(options as GroupData[] | undefined)
181+
: (options as OptionData[]);
182+
183+
return (
184+
<ul role-slot="listbox" style={listBoxStyle}>
185+
{finalOptions?.map((option, index) => createItem(option, index))}
186+
</ul>
90187
);
91188
};
92189

93-
const isGrouped =
94-
Array.isArray(options) &&
95-
options.length > 0 &&
96-
typeof options[0] === "object" &&
97-
optionGroupChildren &&
98-
Array.isArray(options[0][optionGroupChildren]);
190+
const list = createList();
99191

100192
return (
101-
<div
102-
data-slot="list"
103-
className={twMerge(clsx("w-full max-w-xs overflow-y-auto", className))}
104-
{...props}
105-
>
106-
{Array.isArray(options) &&
107-
(isGrouped
108-
? (options as OptionGroup[]).map(renderGroup)
109-
: (options as SelectOption[]).map(renderOption))}
193+
<div style={listStyle} data-slot="list" {...props}>
194+
{list}
110195
</div>
111196
);
112-
}
197+
};

0 commit comments

Comments
 (0)