Props
The Combobox is built using a composition of the Popover and the Command components.
See installation instructions for the Popover and the Command components.
Examples
Basic
Lazy Loading
Sử dụng hook useLazyLoading để load dữ liệu theo trang và hỗ trợ tìm kiếm.
Multiple Select
Sử dụng hook useComboboxMultiple để tính toán số lượng badge hiển thị và hỗ trợ chọn nhiều options.
Multiple Select Resizable
With Form
Hooks
useLazyLoading
Hook để xử lý lazy loading cho combobox với khả năng tìm kiếm và phân trang.
import { provinces } from '@/components/common/props/mock-data';
import { useState, useEffect, useRef } from 'react';
import { useDebouncedCallback } from 'use-debounce';
interface Option {
label: string;
value: string;
}
interface FetchOptionsParams {
search: string;
page: number;
pageSize: number;
}
interface FetchOptionsResult {
items: Option[];
total: number;
hasMore: boolean;
}
interface UseLazyLoadingOptions {
pageSize?: number;
debounceMs?: number;
scrollThreshold?: number;
fetchOptions: (params: FetchOptionsParams) => Promise<FetchOptionsResult>;
delayMs?: number;
}
interface UseLazyLoadingReturn {
// States
options: Option[];
loading: boolean;
totalCount: number;
initialLoading: boolean;
error: string | null;
page: number;
hasMore: boolean;
searchTerm: string;
// Refs
listRef: React.RefObject<HTMLDivElement>;
// Handlers
handleScroll: (e: React.UIEvent<HTMLDivElement>) => void;
handleSearch: (term: string) => void;
// Utilities
resetSearch: () => void;
clearError: () => void;
}
export const useLazyLoading = ({
pageSize = 10,
debounceMs = 300,
scrollThreshold = 20,
fetchOptions: fetchOptionsFn,
delayMs = 2000,
}: UseLazyLoadingOptions): UseLazyLoadingReturn => {
const [options, setOptions] = useState<Option[]>([]);
const [page, setPage] = useState(1);
const [hasMore, setHasMore] = useState(true);
const [loading, setLoading] = useState(false);
const [totalCount, setTotalCount] = useState(0);
const [initialLoading, setInitialLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [searchTerm, setSearchTerm] = useState('');
const listRef = useRef<HTMLDivElement | null>(null);
// Load dữ liệu ban đầu khi hook mount
useEffect(() => {
handleFetchOptions('', 1);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const handleFetchOptions = async (
search: string,
pageNumber: number,
append = false
) => {
if (loading) return;
setLoading(true);
setError(null);
try {
// Delay (có thể config)
if (delayMs > 0) {
await new Promise((resolve) => setTimeout(resolve, delayMs));
}
// Gọi function fetch từ bên ngoài
const result = await fetchOptionsFn({
search,
page: pageNumber,
pageSize,
});
setOptions((prev) =>
append ? [...prev, ...result.items] : result.items
);
setHasMore(result.hasMore);
setTotalCount(provinces.length);
} catch (err) {
console.error('Lỗi khi fetch:', err);
setError('Có lỗi xảy ra khi tải dữ liệu');
} finally {
setLoading(false);
setInitialLoading(false);
}
};
const handleScroll = (e: React.UIEvent<HTMLDivElement>) => {
const { scrollTop, scrollHeight, clientHeight } = e.currentTarget;
if (
scrollTop + clientHeight >= scrollHeight - scrollThreshold &&
hasMore &&
!loading
) {
const nextPage = page + 1;
setPage(nextPage);
handleFetchOptions(searchTerm, nextPage, true);
}
};
const handleSearch = useDebouncedCallback((term) => {
setSearchTerm(term);
setPage(1);
setError(null);
handleFetchOptions(term, 1);
}, debounceMs);
const resetSearch = () => {
setSearchTerm('');
setPage(1);
setError(null);
handleFetchOptions('', 1);
};
const clearError = () => {
setError(null);
};
return {
// States
options,
loading,
initialLoading,
error,
page,
hasMore,
searchTerm,
totalCount,
// Refs
listRef: listRef as React.RefObject<HTMLDivElement>,
// Handlers
handleScroll,
handleSearch,
// Utilities
resetSearch,
clearError,
};
};Props:
pageSize(optional): Số lượng items mỗi trang (default: 10)debounceMs(optional): Thời gian debounce cho search (default: 300ms)scrollThreshold(optional): Khoảng cách từ cuối để trigger load more (default: 20px)fetchOptions: Function để fetch dữ liệudelayMs(optional): Thời gian delay giả lập (default: 2000ms)
Returns:
options: Danh sách options hiện tạiloading: Trạng thái đang load thêm dữ liệuinitialLoading: Trạng thái load lần đầuerror: Thông báo lỗilistRef: Ref cho scroll containerhandleScroll: Handler cho scroll eventhandleSearch: Handler cho search với debounceresetSearch: Reset về trạng thái ban đầuclearError: Xóa lỗitotalCount: Tổng số itemspage: Trang hiện tạihasMore: Còn dữ liệu để load khôngsearchTerm: Từ khóa tìm kiếm hiện tại
useComboboxMultiple
Hook để tính toán số lượng badge có thể hiển thị trong multiple select combobox.
import { useState, useCallback, useEffect, RefObject } from 'react';
interface IOptionType {
code: string;
name: string;
}
export const useComboboxMultiple = (
selected: IOptionType[],
buttonRef: RefObject<HTMLButtonElement | null>,
options?: {
allOption?: IOptionType;
totalCount?: number;
}
) => {
const [visibleCount, setVisibleCount] = useState(0);
// Tính toán width của badge
const calculateBadgeWidth = useCallback(
(text: string) => {
const canvas = document.createElement('canvas');
const context = canvas.getContext('2d');
if (!context) return 0;
// Font size và padding tương tự như trong CSS
context.font = '14px system-ui'; // text-sm font-normal
const textWidth = context.measureText(text).width;
// Giới hạn text width tối đa là 120px (max-w-[120px])
const limitedTextWidth = Math.min(textWidth, 120);
// X icon: 16px (size-4)
// Gap giữa text và X icon: 4px (gap-1)
// Border và padding của badge: 8px (left) + 8px (right)
let totalWidth = limitedTextWidth + 16 + 4 + 16;
// Nếu có avatar thì thêm width của avatar và gap
if (options?.hasAvatar) {
totalWidth += 16 + 4; // Avatar width + gap
}
return Math.ceil(totalWidth);
},
[options?.hasAvatar]
);
// Tính toán số badge có thể hiển thị
const calculateVisibleBadges = useCallback(() => {
if (!buttonRef.current || selected.length === 0) {
setVisibleCount(0);
return;
}
const buttonWidth = buttonRef.current.offsetWidth;
const iconWidth = 24; // ChevronDown icon width
const gap = 4; // gap-1 = 4px
const padding = 16; // Button padding left + right
const margin = 8; // mr-2 = 8px
// Thử không có counter trước
let availableWidth = buttonWidth - iconWidth - gap - padding - margin;
let currentWidth = 0;
let count = 0;
for (let i = 0; i < selected.length; i++) {
const badgeWidth = calculateBadgeWidth(selected[i].name);
if (currentWidth + badgeWidth + gap <= availableWidth) {
currentWidth += badgeWidth + gap;
count++;
} else {
break;
}
}
// Nếu tất cả badge đều vừa thì không cần counter
if (count === selected.length) {
setVisibleCount(count);
return;
}
// Nếu không vừa hết, thử với counter (ngắn hơn: "+X" thay vì "+X more")
const counterWidth = 40; // Giảm từ 80px xuống 40px vì chỉ còn "+X"
availableWidth =
buttonWidth - iconWidth - gap - padding - margin - counterWidth;
currentWidth = 0;
count = 0;
for (let i = 0; i < selected.length; i++) {
const badgeWidth = calculateBadgeWidth(selected[i].name);
if (currentWidth + badgeWidth + gap <= availableWidth) {
currentWidth += badgeWidth + gap;
count++;
} else {
break;
}
}
setVisibleCount(count);
}, [selected, calculateBadgeWidth, buttonRef]);
// Recalculate khi selected thay đổi hoặc component mount
useEffect(() => {
calculateVisibleBadges();
}, [selected, calculateVisibleBadges]);
// Recalculate khi window resize
useEffect(() => {
const handleResize = () => {
calculateVisibleBadges();
};
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, [calculateVisibleBadges]);
// Logic hiển thị badge với "Tất cả" option
const isAllSelected =
options?.allOption && options?.totalCount
? selected.some((item) => item.code === options.allOption!.code) ||
selected.length === options.totalCount
: false;
const isPartiallySelected = selected.length > 0 && !isAllSelected;
// Logic hiển thị badge
const shouldShowAllBadge = isAllSelected;
const displayBadges =
shouldShowAllBadge && options?.allOption ? [options.allOption] : selected;
const displayVisibleBadges = displayBadges.slice(0, visibleCount);
const displayHiddenCount = displayBadges.length - displayVisibleBadges.length;
return {
isAllSelected,
isPartiallySelected,
displayVisibleBadges,
displayHiddenCount,
};
};Props:
selected: Mảng các options đã được chọnbuttonRef: Ref của HTML button elementoptions(optional): Object chứaallOptionvàtotalCount
Returns:
isAllSelected: Trạng thái tất cả options được chọnisPartiallySelected: Trạng thái một phần options được chọndisplayVisibleBadges: Mảng các options hiển thịdisplayHiddenCount: Số lượng badge ẩn đi
Last updated on