Skip to Content
247 Components v1.0 is released 🎉
Components
Combobox

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

This is the language that will be used in the dashboard.

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ệu
  • delayMs (optional): Thời gian delay giả lập (default: 2000ms)

Returns:

  • options: Danh sách options hiện tại
  • loading: Trạng thái đang load thêm dữ liệu
  • initialLoading: Trạng thái load lần đầu
  • error: Thông báo lỗi
  • listRef: Ref cho scroll container
  • handleScroll: Handler cho scroll event
  • handleSearch: Handler cho search với debounce
  • resetSearch: Reset về trạng thái ban đầu
  • clearError: Xóa lỗi
  • totalCount: Tổng số items
  • page: Trang hiện tại
  • hasMore: Còn dữ liệu để load không
  • searchTerm: 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ọn
  • buttonRef: Ref của HTML button element
  • options (optional): Object chứa allOptiontotalCount

Returns:

  • isAllSelected: Trạng thái tất cả options được chọn
  • isPartiallySelected: Trạng thái một phần options được chọn
  • displayVisibleBadges: Mảng các options hiển thị
  • displayHiddenCount: Số lượng badge ẩn đi
Last updated on