Skip to content

Commit b01845f

Browse files
authored
Merge pull request #495 from acelaya-forks/feature/tailwind-tags-autocomplete
Create TagAutoComplete component
2 parents 2c43332 + 5738601 commit b01845f

24 files changed

+731
-146
lines changed

CHANGELOG.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,23 @@ All notable changes to this project will be documented in this file.
44

55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org).
66

7+
## [0.9.11] - 2025-06-14
8+
### Added
9+
* Add tailwind-based `TagsAutocomplete` component.
10+
11+
### Changed
12+
* *Nothing*
13+
14+
### Deprecated
15+
* *Nothing*
16+
17+
### Removed
18+
* *Nothing*
19+
20+
### Fixed
21+
* *Nothing*
22+
23+
724
## [0.9.10] - 2025-06-11
825
### Added
926
* *Nothing*

dev/tailwind/form/InputsPage.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ export const InputsPage: FC = () => {
4848
<Input placeholder="Error input" feedback="error" />
4949
<Input placeholder="Disabled input" disabled />
5050
<Input placeholder="Readonly input" readOnly />
51+
<Input placeholder="Unstyled input" variant="unstyled" />
5152
<LabelledInput label="Labelled input" helpText="This is the help text under the input" />
5253
<LabelledInput label="Error labelled input" error="This input is invalid!" />
5354
<Input placeholder="Large input" size="lg" />

dev/tailwind/form/SearchComboboxPage.tsx

Lines changed: 64 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import type { FC } from 'react';
22
import { useCallback, useState } from 'react';
33
import { useTimeout } from '../../../src';
4-
import { SearchCombobox } from '../../../src/tailwind';
4+
import { SearchCombobox, TagsAutocomplete } from '../../../src/tailwind';
55

66
const colors = [
77
{ name: 'red', value: '#FF0000' },
@@ -33,7 +33,7 @@ const ColorItem: FC<typeof colors[number]> = ({ name, value }) => (
3333
</div>
3434
);
3535

36-
export const SearchComboboxPage: FC = () => {
36+
const SyncSearch: FC = () => {
3737
const [syncSearchResults, setSyncSearchResults] = useState<Map<string, typeof colors[number]>>();
3838
const [syncSelectedItem, setSyncSelectedItem] = useState<typeof colors[number]>();
3939
const onSyncSearch = useCallback((searchTerm: string) => {
@@ -49,6 +49,26 @@ export const SearchComboboxPage: FC = () => {
4949
));
5050
}, []);
5151

52+
return (
53+
<div className="tw:flex tw:flex-col tw:gap-y-2">
54+
<h2>Sync search</h2>
55+
<SearchCombobox
56+
onSearch={onSyncSearch}
57+
onSelectSearchResult={setSyncSelectedItem}
58+
renderSearchResult={(color) => <ColorItem {...color} />}
59+
searchResults={syncSearchResults}
60+
placeholder="Search colors synchronously..."
61+
/>
62+
{syncSelectedItem && (
63+
<div className="tw:flex tw:gap-3">
64+
Last selected color is: <ColorItem {...syncSelectedItem} />
65+
</div>
66+
)}
67+
</div>
68+
);
69+
};
70+
71+
const AsyncSearch: FC = () => {
5272
const { setTimeout } = useTimeout(1000);
5373
const [asyncSearchResults, setAsyncSearchResults] = useState<Map<string, typeof colors[number]>>();
5474
const [asyncSelectedItem, setAsyncSelectedItem] = useState<typeof colors[number]>();
@@ -70,38 +90,51 @@ export const SearchComboboxPage: FC = () => {
7090
});
7191
}, [setTimeout]);
7292

93+
return (
94+
<div className="tw:flex tw:flex-col tw:gap-y-2">
95+
<h2>Async search</h2>
96+
<SearchCombobox
97+
onSearch={onAsyncSearch}
98+
onSelectSearchResult={setAsyncSelectedItem}
99+
renderSearchResult={(color) => <ColorItem {...color} />}
100+
searchResults={asyncSearchResults}
101+
placeholder="Search colors asynchronously..."
102+
loading={asyncLoading}
103+
/>
104+
{asyncSelectedItem && (
105+
<div className="tw:flex tw:gap-3">
106+
Last selected color is: <ColorItem {...asyncSelectedItem} />
107+
</div>
108+
)}
109+
</div>
110+
);
111+
};
112+
113+
const TagsAutocompleteExample: FC<{ immutable: boolean }> = ({ immutable }) => {
114+
const [selectedTags, setSelectedTags] = useState(immutable ? [] : ['blue', 'yellow']);
115+
116+
return (
117+
<TagsAutocomplete
118+
tags={colors.map(({ name }) => name)}
119+
selectedTags={selectedTags}
120+
onTagsChange={setSelectedTags}
121+
getColorForTag={(tag) => colors.find(({ name }) => name === tag)?.value ?? '#99A1AF'}
122+
placeholder={immutable ? 'Select tags from list...' : 'Select or add tags...'}
123+
immutable={immutable}
124+
/>
125+
);
126+
};
127+
128+
export const SearchComboboxPage: FC = () => {
73129
return (
74130
<div className="tw:flex tw:flex-col tw:gap-y-4">
131+
<SyncSearch />
132+
<AsyncSearch />
133+
75134
<div className="tw:flex tw:flex-col tw:gap-y-2">
76-
<h2>Sync search</h2>
77-
<SearchCombobox
78-
onSearch={onSyncSearch}
79-
onSelectSearchResult={setSyncSelectedItem}
80-
renderSearchResult={(color) => <ColorItem {...color} />}
81-
searchResults={syncSearchResults}
82-
placeholder="Search colors synchronously..."
83-
/>
84-
{syncSelectedItem && (
85-
<div className="tw:flex tw:gap-3">
86-
Last selected color is: <ColorItem {...syncSelectedItem} />
87-
</div>
88-
)}
89-
</div>
90-
<div className="tw:flex tw:flex-col tw:gap-y-2">
91-
<h2>Async search</h2>
92-
<SearchCombobox
93-
onSearch={onAsyncSearch}
94-
onSelectSearchResult={setAsyncSelectedItem}
95-
renderSearchResult={(color) => <ColorItem {...color} />}
96-
searchResults={asyncSearchResults}
97-
placeholder="Search colors asynchronously..."
98-
loading={asyncLoading}
99-
/>
100-
{asyncSelectedItem && (
101-
<div className="tw:flex tw:gap-3">
102-
Last selected color is: <ColorItem {...asyncSelectedItem} />
103-
</div>
104-
)}
135+
<h2>Tags autocomplete</h2>
136+
<TagsAutocompleteExample immutable={false} />
137+
<TagsAutocompleteExample immutable={true} />
105138
</div>
106139
</div>
107140
);

src/tailwind/form/CloseButton.tsx

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,28 +3,33 @@ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
33
import clsx from 'clsx';
44
import type { HTMLProps } from 'react';
55
import { forwardRef } from 'react';
6+
import type { Size } from '../types';
67

78
export type CloseButtonProps = {
89
label?: string;
910
onClick?: HTMLProps<HTMLButtonElement>['onClick'];
1011
className?: string;
12+
size?: Size;
13+
solid?: boolean;
1114
};
1215

1316
export const CloseButton = forwardRef<HTMLButtonElement, CloseButtonProps>((
14-
{ onClick, className, label = 'Close' },
17+
{ onClick, className, label = 'Close', size = 'lg', solid },
1518
ref,
1619
) => (
1720
<button
1821
ref={ref}
1922
type="button"
2023
onClick={onClick}
2124
className={clsx(
22-
'tw:opacity-50 tw:highlight:opacity-80 tw:transition-opacity',
2325
'tw:rounded-md tw:focus-ring tw:cursor-pointer',
26+
{
27+
'tw:opacity-50 tw:highlight:opacity-80 tw:transition-opacity': !solid,
28+
},
2429
className,
2530
)}
2631
aria-label={label}
2732
>
28-
<FontAwesomeIcon icon={faClose} size="xl" />
33+
<FontAwesomeIcon icon={faClose} size={size === 'lg' ? 'xl' : size === 'md' ? 'lg' : undefined} />
2934
</button>
3035
));

src/tailwind/form/Input.tsx

Lines changed: 27 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,13 @@ import type { Size } from '../types';
55

66
export type BaseInputProps = {
77
size?: Size;
8-
feedback?: 'error',
8+
feedback?: 'error';
9+
10+
/**
11+
* Whether the input should have an opinionated style or not. Defaults to 'default'.
12+
* An unstyled input can be useful to wrap or customize.
13+
*/
14+
variant?: 'default' | 'unstyled';
915
};
1016

1117
export type InputProps =
@@ -17,6 +23,7 @@ export const Input = forwardRef<HTMLInputElement, InputProps>(({
1723
borderless = false,
1824
size = 'md',
1925
feedback,
26+
variant = 'default',
2027
className,
2128
disabled,
2229
...rest
@@ -25,27 +32,27 @@ export const Input = forwardRef<HTMLInputElement, InputProps>(({
2532
<input
2633
ref={ref}
2734
className={clsx(
28-
'tw:w-full',
29-
{
30-
'tw:focus-ring': !feedback,
31-
'tw:focus-ring-danger': feedback === 'error',
32-
},
33-
{
35+
'tw:outline-none',
36+
variant === 'default' && [
37+
'tw:w-full',
38+
{
39+
'tw:focus-ring': !feedback,
40+
'tw:focus-ring-danger': feedback === 'error',
41+
42+
'tw:px-2 tw:py-1 tw:text-sm': size === 'sm',
43+
'tw:px-3 tw:py-1.5': size === 'md',
44+
'tw:px-4 tw:py-2 tw:text-xl': size === 'lg',
3445

35-
'tw:px-2 tw:py-1 tw:text-sm': size === 'sm',
36-
'tw:px-3 tw:py-1.5': size === 'md',
37-
'tw:px-4 tw:py-2 tw:text-xl': size === 'lg',
38-
},
39-
{
40-
'tw:rounded-md tw:border': !borderless,
41-
'tw:border-lm-input-border tw:dark:border-dm-input-border': !borderless && !feedback,
42-
'tw:border-danger': !borderless && feedback === 'error',
46+
'tw:rounded-md tw:border': !borderless,
47+
'tw:border-lm-input-border tw:dark:border-dm-input-border': !borderless && !feedback,
48+
'tw:border-danger': !borderless && feedback === 'error',
4349

44-
'tw:bg-lm-disabled-input tw:dark:bg-dm-disabled-input': disabled,
45-
'tw:bg-lm-primary tw:dark:bg-dm-primary': !disabled,
46-
// Use different background color when rendered inside a card
47-
'tw:group-[&]/card:bg-lm-input tw:group-[&]/card:dark:bg-dm-input': !disabled,
48-
},
50+
'tw:bg-lm-disabled-input tw:dark:bg-dm-disabled-input': disabled,
51+
'tw:bg-lm-primary tw:dark:bg-dm-primary': !disabled,
52+
// Use different background color when rendered inside a card
53+
'tw:group-[&]/card:bg-lm-input tw:group-[&]/card:dark:bg-dm-input': !disabled,
54+
},
55+
],
4956
className,
5057
)}
5158
disabled={disabled}

src/tailwind/form/SearchCombobox.tsx

Lines changed: 22 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import clsx from 'clsx';
2-
import type { ReactNode } from 'react';
3-
import { useCallback, useId, useMemo , useRef,useState } from 'react';
2+
import type { ForwardedRef, ReactNode } from 'react';
3+
import { forwardRef , useCallback, useId, useImperativeHandle , useMemo , useRef,useState } from 'react';
44
import { Listbox } from '../content';
55
import type { SearchInputProps } from './SearchInput';
66
import { SearchInput } from './SearchInput';
@@ -28,6 +28,11 @@ export type SearchComboboxProps<Item> = BaseInputProps & {
2828
* Defaults to `full`.
2929
*/
3030
listboxSpan?: 'full' | 'auto';
31+
32+
/** Classes to add to the wrapping container */
33+
containerClassName?: string;
34+
/** Classes to add to the listbox */
35+
listboxClassName?: string;
3136
};
3237

3338
/**
@@ -36,20 +41,24 @@ export type SearchComboboxProps<Item> = BaseInputProps & {
3641
* The main difference is that the input is used only to search in the listbox, and once an item is selected, the input
3742
* is cleared and the listbox is closed.
3843
*/
39-
export function SearchCombobox<Item>({
44+
function SearchComboboxInner<Item>({
4045
searchResults,
4146
onSearch,
4247
onSelectSearchResult,
4348
renderSearchResult,
4449
size = 'md', // SearchInput defaults its size to 'lg'. Change it to 'md'
4550
listboxSpan = 'full',
4651
onFocus,
52+
containerClassName,
53+
listboxClassName,
4754
...rest
48-
}: SearchComboboxProps<Item>) {
49-
const searchInputRef = useRef<HTMLInputElement>(null);
55+
}: SearchComboboxProps<Item>, ref: ForwardedRef<HTMLInputElement>) {
5056
const listboxId = useId();
5157
const [activeKey, setActiveKey] = useState<string>();
5258

59+
const searchInputRef = useRef<HTMLInputElement>(null);
60+
useImperativeHandle(ref, () => searchInputRef.current!);
61+
5362
// The active key is undefined while the listbox is closed. While open, we use the explicitly set activeKey, or the
5463
// first key of the search results.
5564
const currentlyActiveKey = useMemo(
@@ -61,11 +70,11 @@ export function SearchCombobox<Item>({
6170
onSelectSearchResult(item);
6271
onSearch('');
6372
searchInputRef.current!.value = '';
64-
}, [onSearch, onSelectSearchResult]);
73+
}, [onSearch, onSelectSearchResult, searchInputRef]);
6574

6675
return (
6776
<div
68-
className="tw:relative"
77+
className={clsx('tw:relative', containerClassName)}
6978
onBlur={(e) => {
7079
// Clears search when focus is moving away of this container, so that the listbox is closed.
7180
if (!e.currentTarget.contains(e.relatedTarget)) {
@@ -102,9 +111,10 @@ export function SearchCombobox<Item>({
102111
className={clsx(
103112
'tw:absolute tw:top-full tw:mt-1 tw:z-10',
104113
{
105-
'tw:min-w-60 tw:max-w-full': listboxSpan === 'auto',
114+
'tw:min-w-60': listboxSpan === 'auto',
106115
'tw:w-full': listboxSpan === 'full',
107116
},
117+
listboxClassName,
108118
)}
109119
aria-label="Matching items"
110120
noItemsMessage="No results found matching search"
@@ -113,3 +123,7 @@ export function SearchCombobox<Item>({
113123
</div>
114124
);
115125
}
126+
127+
export const SearchCombobox = forwardRef(SearchComboboxInner) as <T>(
128+
props: SearchComboboxProps<T> & { ref?: ForwardedRef<HTMLInputElement> }
129+
) => ReturnType<typeof SearchComboboxInner>;

0 commit comments

Comments
 (0)