@djangocfg/ui-core 2.1.159 → 2.1.161
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# @djangocfg/ui-core
|
|
2
2
|
|
|
3
|
-
Pure React UI library with
|
|
3
|
+
Pure React UI library with 65+ components built on Radix UI + Tailwind CSS v4.
|
|
4
4
|
|
|
5
5
|
**No Next.js dependencies** — works with Electron, Vite, CRA, and any React environment.
|
|
6
6
|
|
|
@@ -24,8 +24,8 @@ pnpm add @djangocfg/ui-core
|
|
|
24
24
|
|
|
25
25
|
## Components (60+)
|
|
26
26
|
|
|
27
|
-
### Forms (
|
|
28
|
-
`Label` `Button` `ButtonLink` `Input` `Checkbox` `RadioGroup` `Select` `Textarea` `Switch` `Slider` `Combobox` `MultiSelect` `CountrySelect` `InputOTP` `PhoneInput` `Form` `Field`
|
|
27
|
+
### Forms (18)
|
|
28
|
+
`Label` `Button` `ButtonLink` `Input` `Checkbox` `RadioGroup` `Select` `Textarea` `Switch` `Slider` `Combobox` `MultiSelect` `CountrySelect` `LanguageSelect` `InputOTP` `PhoneInput` `Form` `Field`
|
|
29
29
|
|
|
30
30
|
### Layout (8)
|
|
31
31
|
`Card` `Separator` `Skeleton` `AspectRatio` `Sticky` `ScrollArea` `Resizable` `Section`
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@djangocfg/ui-core",
|
|
3
|
-
"version": "2.1.
|
|
3
|
+
"version": "2.1.161",
|
|
4
4
|
"description": "Pure React UI component library without Next.js dependencies - for Electron, Vite, CRA apps",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"ui-components",
|
|
@@ -76,7 +76,7 @@
|
|
|
76
76
|
"playground": "playground dev"
|
|
77
77
|
},
|
|
78
78
|
"peerDependencies": {
|
|
79
|
-
"@djangocfg/i18n": "^2.1.
|
|
79
|
+
"@djangocfg/i18n": "^2.1.161",
|
|
80
80
|
"react-device-detect": "^2.2.3",
|
|
81
81
|
"consola": "^3.4.2",
|
|
82
82
|
"lucide-react": "^0.545.0",
|
|
@@ -138,9 +138,9 @@
|
|
|
138
138
|
"vaul": "1.1.2"
|
|
139
139
|
},
|
|
140
140
|
"devDependencies": {
|
|
141
|
-
"@djangocfg/i18n": "^2.1.
|
|
141
|
+
"@djangocfg/i18n": "^2.1.161",
|
|
142
142
|
"@djangocfg/playground": "workspace:*",
|
|
143
|
-
"@djangocfg/typescript-config": "^2.1.
|
|
143
|
+
"@djangocfg/typescript-config": "^2.1.161",
|
|
144
144
|
"@types/node": "^24.7.2",
|
|
145
145
|
"@types/react": "^19.1.0",
|
|
146
146
|
"@types/react-dom": "^19.1.0",
|
package/src/components/index.ts
CHANGED
|
@@ -23,6 +23,8 @@ export { MultiSelectPro } from './multi-select-pro';
|
|
|
23
23
|
export type { MultiSelectProOption, MultiSelectProGroup, MultiSelectProProps, MultiSelectProRef, AnimationConfig, ResponsiveConfig } from './multi-select-pro';
|
|
24
24
|
export { CountrySelect, getEmojiFlag } from './country-select';
|
|
25
25
|
export type { CountrySelectProps, CountrySelectVariant, CountryOption, TCountryCode } from './country-select';
|
|
26
|
+
export { LanguageSelect } from './language-select';
|
|
27
|
+
export type { LanguageSelectProps, LanguageSelectVariant, LanguageOption, TLanguageCode } from './language-select';
|
|
26
28
|
export { MultiSelectProAsync } from './multi-select-pro/async';
|
|
27
29
|
export type { MultiSelectProAsyncProps } from './multi-select-pro/async';
|
|
28
30
|
export { Switch } from './switch';
|
|
@@ -36,7 +38,7 @@ export { Separator } from './separator';
|
|
|
36
38
|
export { Skeleton } from './skeleton';
|
|
37
39
|
export { AspectRatio } from './aspect-ratio';
|
|
38
40
|
export { ScrollArea, ScrollBar } from './scroll-area';
|
|
39
|
-
export type { ScrollAreaHandle, ScrollAreaProps } from './scroll-area';
|
|
41
|
+
export type { ScrollAreaHandle, ScrollAreaProps, ScrollAreaOrientation } from './scroll-area';
|
|
40
42
|
export { ResizableHandle, ResizablePanel, ResizablePanelGroup, useResizableDragging } from './resizable';
|
|
41
43
|
export type { ResizableHandleProps, ImperativePanelHandle } from './resizable';
|
|
42
44
|
export { Sticky } from './sticky';
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
import { useState } from 'react';
|
|
2
|
+
import { defineStory } from '@djangocfg/playground';
|
|
3
|
+
import { LanguageSelect, type TLanguageCode } from './language-select';
|
|
4
|
+
import { Label } from './label';
|
|
5
|
+
|
|
6
|
+
export default defineStory({
|
|
7
|
+
title: 'Core/LanguageSelect',
|
|
8
|
+
component: LanguageSelect,
|
|
9
|
+
description: 'Language selector. Supports dropdown and inline variants, single and multiple selection.',
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
// ============================================================================
|
|
13
|
+
// Dropdown Variants
|
|
14
|
+
// ============================================================================
|
|
15
|
+
|
|
16
|
+
export const SingleDropdown = () => {
|
|
17
|
+
const [value, setValue] = useState<string[]>([]);
|
|
18
|
+
|
|
19
|
+
return (
|
|
20
|
+
<div className="max-w-sm space-y-2">
|
|
21
|
+
<Label>Select language</Label>
|
|
22
|
+
<LanguageSelect
|
|
23
|
+
value={value}
|
|
24
|
+
onChange={setValue}
|
|
25
|
+
placeholder="Choose a language..."
|
|
26
|
+
/>
|
|
27
|
+
{value.length > 0 && (
|
|
28
|
+
<p className="text-sm text-muted-foreground">Selected: {value[0]}</p>
|
|
29
|
+
)}
|
|
30
|
+
</div>
|
|
31
|
+
);
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
export const MultipleDropdown = () => {
|
|
35
|
+
const [value, setValue] = useState<string[]>([]);
|
|
36
|
+
|
|
37
|
+
return (
|
|
38
|
+
<div className="max-w-sm space-y-2">
|
|
39
|
+
<Label>Select languages</Label>
|
|
40
|
+
<LanguageSelect
|
|
41
|
+
multiple
|
|
42
|
+
value={value}
|
|
43
|
+
onChange={setValue}
|
|
44
|
+
placeholder="Choose languages..."
|
|
45
|
+
/>
|
|
46
|
+
{value.length > 0 && (
|
|
47
|
+
<p className="text-sm text-muted-foreground">Selected: {value.join(', ')}</p>
|
|
48
|
+
)}
|
|
49
|
+
</div>
|
|
50
|
+
);
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
export const WithDefaultValue = () => {
|
|
54
|
+
const [value, setValue] = useState<string[]>(['en', 'es', 'fr']);
|
|
55
|
+
|
|
56
|
+
return (
|
|
57
|
+
<div className="max-w-sm space-y-2">
|
|
58
|
+
<Label>Preselected languages</Label>
|
|
59
|
+
<LanguageSelect
|
|
60
|
+
multiple
|
|
61
|
+
value={value}
|
|
62
|
+
onChange={setValue}
|
|
63
|
+
/>
|
|
64
|
+
</div>
|
|
65
|
+
);
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
export const WithNativeNames = () => {
|
|
69
|
+
const [value, setValue] = useState<string[]>([]);
|
|
70
|
+
|
|
71
|
+
return (
|
|
72
|
+
<div className="max-w-sm space-y-2">
|
|
73
|
+
<Label>With native names</Label>
|
|
74
|
+
<LanguageSelect
|
|
75
|
+
multiple
|
|
76
|
+
value={value}
|
|
77
|
+
onChange={setValue}
|
|
78
|
+
showNativeName
|
|
79
|
+
/>
|
|
80
|
+
</div>
|
|
81
|
+
);
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
// ============================================================================
|
|
85
|
+
// Inline Variants
|
|
86
|
+
// ============================================================================
|
|
87
|
+
|
|
88
|
+
export const InlineSingle = () => {
|
|
89
|
+
const [value, setValue] = useState<string[]>([]);
|
|
90
|
+
|
|
91
|
+
return (
|
|
92
|
+
<div className="max-w-sm space-y-2">
|
|
93
|
+
<Label>Select your language</Label>
|
|
94
|
+
<LanguageSelect
|
|
95
|
+
variant="inline"
|
|
96
|
+
value={value}
|
|
97
|
+
onChange={setValue}
|
|
98
|
+
maxHeight={250}
|
|
99
|
+
/>
|
|
100
|
+
{value.length > 0 && (
|
|
101
|
+
<p className="text-sm text-muted-foreground">Selected: {value[0]}</p>
|
|
102
|
+
)}
|
|
103
|
+
</div>
|
|
104
|
+
);
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
export const InlineMultiple = () => {
|
|
108
|
+
const [value, setValue] = useState<string[]>([]);
|
|
109
|
+
|
|
110
|
+
return (
|
|
111
|
+
<div className="max-w-sm space-y-2">
|
|
112
|
+
<Label>Select target languages</Label>
|
|
113
|
+
<LanguageSelect
|
|
114
|
+
variant="inline"
|
|
115
|
+
multiple
|
|
116
|
+
value={value}
|
|
117
|
+
onChange={setValue}
|
|
118
|
+
maxHeight={300}
|
|
119
|
+
/>
|
|
120
|
+
</div>
|
|
121
|
+
);
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
export const InlineWithNativeNames = () => {
|
|
125
|
+
const [value, setValue] = useState<string[]>(['ko', 'ja']);
|
|
126
|
+
|
|
127
|
+
return (
|
|
128
|
+
<div className="max-w-sm space-y-2">
|
|
129
|
+
<Label>Languages with native names</Label>
|
|
130
|
+
<LanguageSelect
|
|
131
|
+
variant="inline"
|
|
132
|
+
multiple
|
|
133
|
+
value={value}
|
|
134
|
+
onChange={setValue}
|
|
135
|
+
showNativeName
|
|
136
|
+
maxHeight={300}
|
|
137
|
+
/>
|
|
138
|
+
</div>
|
|
139
|
+
);
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
export const InlineNoSearch = () => {
|
|
143
|
+
const [value, setValue] = useState<string[]>([]);
|
|
144
|
+
|
|
145
|
+
return (
|
|
146
|
+
<div className="max-w-sm space-y-2">
|
|
147
|
+
<Label>Without search</Label>
|
|
148
|
+
<LanguageSelect
|
|
149
|
+
variant="inline"
|
|
150
|
+
multiple
|
|
151
|
+
value={value}
|
|
152
|
+
onChange={setValue}
|
|
153
|
+
showSearch={false}
|
|
154
|
+
maxHeight={200}
|
|
155
|
+
/>
|
|
156
|
+
</div>
|
|
157
|
+
);
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
// ============================================================================
|
|
161
|
+
// Filtered Languages
|
|
162
|
+
// ============================================================================
|
|
163
|
+
|
|
164
|
+
const EUROPEAN_LANGUAGES: TLanguageCode[] = ['en', 'de', 'fr', 'es', 'it', 'pt', 'nl', 'pl', 'sv', 'da', 'no', 'fi'];
|
|
165
|
+
const ASIAN_LANGUAGES: TLanguageCode[] = ['zh', 'ja', 'ko', 'vi', 'th', 'id', 'ms', 'tl'];
|
|
166
|
+
const TTS_SUPPORTED: TLanguageCode[] = ['en', 'es', 'fr', 'de', 'it', 'pt', 'ja', 'ko', 'zh', 'ru', 'ar', 'hi'];
|
|
167
|
+
|
|
168
|
+
export const FilteredEuropean = () => {
|
|
169
|
+
const [value, setValue] = useState<string[]>([]);
|
|
170
|
+
|
|
171
|
+
return (
|
|
172
|
+
<div className="max-w-sm space-y-2">
|
|
173
|
+
<Label>European Languages only</Label>
|
|
174
|
+
<LanguageSelect
|
|
175
|
+
variant="inline"
|
|
176
|
+
multiple
|
|
177
|
+
value={value}
|
|
178
|
+
onChange={setValue}
|
|
179
|
+
allowedLanguages={EUROPEAN_LANGUAGES}
|
|
180
|
+
maxHeight={300}
|
|
181
|
+
/>
|
|
182
|
+
</div>
|
|
183
|
+
);
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
export const FilteredAsian = () => {
|
|
187
|
+
const [value, setValue] = useState<string[]>([]);
|
|
188
|
+
|
|
189
|
+
return (
|
|
190
|
+
<div className="max-w-sm space-y-2">
|
|
191
|
+
<Label>Asian Languages</Label>
|
|
192
|
+
<LanguageSelect
|
|
193
|
+
multiple
|
|
194
|
+
value={value}
|
|
195
|
+
onChange={setValue}
|
|
196
|
+
allowedLanguages={ASIAN_LANGUAGES}
|
|
197
|
+
showNativeName
|
|
198
|
+
/>
|
|
199
|
+
</div>
|
|
200
|
+
);
|
|
201
|
+
};
|
|
202
|
+
|
|
203
|
+
export const TTSSupportedLanguages = () => {
|
|
204
|
+
const [value, setValue] = useState<string[]>(['en']);
|
|
205
|
+
|
|
206
|
+
return (
|
|
207
|
+
<div className="max-w-sm space-y-2">
|
|
208
|
+
<Label>TTS Supported Languages</Label>
|
|
209
|
+
<LanguageSelect
|
|
210
|
+
value={value}
|
|
211
|
+
onChange={setValue}
|
|
212
|
+
allowedLanguages={TTS_SUPPORTED}
|
|
213
|
+
showNativeName
|
|
214
|
+
/>
|
|
215
|
+
<p className="text-xs text-muted-foreground">
|
|
216
|
+
Only languages with TTS support are shown
|
|
217
|
+
</p>
|
|
218
|
+
</div>
|
|
219
|
+
);
|
|
220
|
+
};
|
|
221
|
+
|
|
222
|
+
// ============================================================================
|
|
223
|
+
// States
|
|
224
|
+
// ============================================================================
|
|
225
|
+
|
|
226
|
+
export const Disabled = () => (
|
|
227
|
+
<div className="max-w-sm space-y-4">
|
|
228
|
+
<div className="space-y-2">
|
|
229
|
+
<Label>Disabled dropdown</Label>
|
|
230
|
+
<LanguageSelect
|
|
231
|
+
value={['en']}
|
|
232
|
+
onChange={() => {}}
|
|
233
|
+
disabled
|
|
234
|
+
/>
|
|
235
|
+
</div>
|
|
236
|
+
<div className="space-y-2">
|
|
237
|
+
<Label>Disabled inline</Label>
|
|
238
|
+
<LanguageSelect
|
|
239
|
+
variant="inline"
|
|
240
|
+
multiple
|
|
241
|
+
value={['en', 'es']}
|
|
242
|
+
onChange={() => {}}
|
|
243
|
+
disabled
|
|
244
|
+
maxHeight={150}
|
|
245
|
+
/>
|
|
246
|
+
</div>
|
|
247
|
+
</div>
|
|
248
|
+
);
|
|
249
|
+
|
|
250
|
+
export const MaxDisplayBadges = () => {
|
|
251
|
+
const [value, setValue] = useState<string[]>(['en', 'es', 'fr', 'de', 'it', 'pt']);
|
|
252
|
+
|
|
253
|
+
return (
|
|
254
|
+
<div className="max-w-sm space-y-2">
|
|
255
|
+
<Label>Max 2 badges displayed</Label>
|
|
256
|
+
<LanguageSelect
|
|
257
|
+
multiple
|
|
258
|
+
value={value}
|
|
259
|
+
onChange={setValue}
|
|
260
|
+
maxDisplay={2}
|
|
261
|
+
/>
|
|
262
|
+
</div>
|
|
263
|
+
);
|
|
264
|
+
};
|
|
@@ -0,0 +1,406 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import { Check, ChevronsUpDown, Search, X } from 'lucide-react';
|
|
4
|
+
import * as React from 'react';
|
|
5
|
+
import { languages, type TLanguageCode } from 'countries-list';
|
|
6
|
+
|
|
7
|
+
import { cn } from '../lib/utils';
|
|
8
|
+
import { Badge } from './badge';
|
|
9
|
+
import { Button } from './button';
|
|
10
|
+
import { Checkbox } from './checkbox';
|
|
11
|
+
import {
|
|
12
|
+
Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList
|
|
13
|
+
} from './command';
|
|
14
|
+
import { Input } from './input';
|
|
15
|
+
import { Popover, PopoverContent, PopoverTrigger } from './popover';
|
|
16
|
+
import { ScrollArea } from './scroll-area';
|
|
17
|
+
|
|
18
|
+
export interface LanguageOption {
|
|
19
|
+
code: TLanguageCode;
|
|
20
|
+
name: string;
|
|
21
|
+
native: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export type LanguageSelectVariant = 'dropdown' | 'inline';
|
|
25
|
+
|
|
26
|
+
export interface LanguageSelectProps {
|
|
27
|
+
/** Selected language codes (ISO 639-1) */
|
|
28
|
+
value?: string[];
|
|
29
|
+
/** Callback when selection changes */
|
|
30
|
+
onChange?: (value: string[]) => void;
|
|
31
|
+
/** Allow multiple selection */
|
|
32
|
+
multiple?: boolean;
|
|
33
|
+
/** Display variant: dropdown (popover) or inline (scrollable list) */
|
|
34
|
+
variant?: LanguageSelectVariant;
|
|
35
|
+
/** Placeholder text (default: "Select language...") */
|
|
36
|
+
placeholder?: string;
|
|
37
|
+
/** Search placeholder text (default: "Search...") */
|
|
38
|
+
searchPlaceholder?: string;
|
|
39
|
+
/** Empty results text (default: "No languages found") */
|
|
40
|
+
emptyText?: string;
|
|
41
|
+
/** Additional CSS class */
|
|
42
|
+
className?: string;
|
|
43
|
+
/** Disable the component */
|
|
44
|
+
disabled?: boolean;
|
|
45
|
+
/** Max badges to display (for multiple dropdown mode) */
|
|
46
|
+
maxDisplay?: number;
|
|
47
|
+
/** Custom language name resolver (for i18n) */
|
|
48
|
+
getLanguageName?: (code: TLanguageCode) => string;
|
|
49
|
+
/** Show native name alongside translated name */
|
|
50
|
+
showNativeName?: boolean;
|
|
51
|
+
/** Filter to specific language codes */
|
|
52
|
+
allowedLanguages?: TLanguageCode[];
|
|
53
|
+
/** Exclude specific language codes */
|
|
54
|
+
excludedLanguages?: TLanguageCode[];
|
|
55
|
+
/** Max height for inline variant */
|
|
56
|
+
maxHeight?: number;
|
|
57
|
+
/** Show search input */
|
|
58
|
+
showSearch?: boolean;
|
|
59
|
+
/** Custom label for selected count (receives count as param). Example: (count) => `${count} selected` */
|
|
60
|
+
selectedCountLabel?: (count: number) => string;
|
|
61
|
+
/** Custom label for "more items" badge (receives count as param). Example: (count) => `+${count} more` */
|
|
62
|
+
moreItemsLabel?: (count: number) => string;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Language Select component
|
|
67
|
+
*
|
|
68
|
+
* Supports:
|
|
69
|
+
* - Single and multiple selection
|
|
70
|
+
* - Dropdown (popover) and inline (scrollable list) variants
|
|
71
|
+
* - Custom language name translations via getLanguageName prop
|
|
72
|
+
* - Language filtering via allowedLanguages/excludedLanguages
|
|
73
|
+
*
|
|
74
|
+
* Uses ISO 639-1 language codes.
|
|
75
|
+
*
|
|
76
|
+
* @example Single dropdown
|
|
77
|
+
* ```tsx
|
|
78
|
+
* <LanguageSelect
|
|
79
|
+
* value={language ? [language] : []}
|
|
80
|
+
* onChange={(codes) => setLanguage(codes[0])}
|
|
81
|
+
* />
|
|
82
|
+
* ```
|
|
83
|
+
*
|
|
84
|
+
* @example Multiple dropdown
|
|
85
|
+
* ```tsx
|
|
86
|
+
* <LanguageSelect
|
|
87
|
+
* multiple
|
|
88
|
+
* value={languages}
|
|
89
|
+
* onChange={setLanguages}
|
|
90
|
+
* />
|
|
91
|
+
* ```
|
|
92
|
+
*
|
|
93
|
+
* @example Inline list with checkboxes
|
|
94
|
+
* ```tsx
|
|
95
|
+
* <LanguageSelect
|
|
96
|
+
* variant="inline"
|
|
97
|
+
* multiple
|
|
98
|
+
* value={languages}
|
|
99
|
+
* onChange={setLanguages}
|
|
100
|
+
* maxHeight={300}
|
|
101
|
+
* />
|
|
102
|
+
* ```
|
|
103
|
+
*
|
|
104
|
+
* @example With i18n translations
|
|
105
|
+
* ```tsx
|
|
106
|
+
* <LanguageSelect
|
|
107
|
+
* value={value}
|
|
108
|
+
* onChange={onChange}
|
|
109
|
+
* getLanguageName={(code) => i18nLanguages.getName(code, locale)}
|
|
110
|
+
* />
|
|
111
|
+
* ```
|
|
112
|
+
*/
|
|
113
|
+
export function LanguageSelect({
|
|
114
|
+
value = [],
|
|
115
|
+
onChange,
|
|
116
|
+
multiple = false,
|
|
117
|
+
variant = 'dropdown',
|
|
118
|
+
placeholder,
|
|
119
|
+
searchPlaceholder,
|
|
120
|
+
emptyText,
|
|
121
|
+
className,
|
|
122
|
+
disabled = false,
|
|
123
|
+
maxDisplay = 3,
|
|
124
|
+
getLanguageName,
|
|
125
|
+
showNativeName = false,
|
|
126
|
+
allowedLanguages,
|
|
127
|
+
excludedLanguages,
|
|
128
|
+
maxHeight = 300,
|
|
129
|
+
showSearch = true,
|
|
130
|
+
selectedCountLabel = (count: number) => `${count} selected`,
|
|
131
|
+
moreItemsLabel = (count: number) => `+${count} more`,
|
|
132
|
+
}: LanguageSelectProps) {
|
|
133
|
+
const [open, setOpen] = React.useState(false)
|
|
134
|
+
const [search, setSearch] = React.useState("")
|
|
135
|
+
|
|
136
|
+
// Resolve defaults
|
|
137
|
+
const resolvedPlaceholder = placeholder ?? 'Select language...'
|
|
138
|
+
const resolvedSearchPlaceholder = searchPlaceholder ?? 'Search...'
|
|
139
|
+
const resolvedEmptyText = emptyText ?? 'No languages found'
|
|
140
|
+
|
|
141
|
+
// Build language options
|
|
142
|
+
const allLanguages = React.useMemo<LanguageOption[]>(() => {
|
|
143
|
+
let codes = Object.keys(languages) as TLanguageCode[];
|
|
144
|
+
|
|
145
|
+
// Apply filters
|
|
146
|
+
if (allowedLanguages?.length) {
|
|
147
|
+
codes = codes.filter(code => allowedLanguages.includes(code));
|
|
148
|
+
}
|
|
149
|
+
if (excludedLanguages?.length) {
|
|
150
|
+
codes = codes.filter(code => !excludedLanguages.includes(code));
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
return codes
|
|
154
|
+
.map((code) => ({
|
|
155
|
+
code,
|
|
156
|
+
name: getLanguageName?.(code) ?? languages[code].name,
|
|
157
|
+
native: languages[code].native,
|
|
158
|
+
}))
|
|
159
|
+
.sort((a, b) => a.name.localeCompare(b.name));
|
|
160
|
+
}, [getLanguageName, allowedLanguages, excludedLanguages]);
|
|
161
|
+
|
|
162
|
+
const selectedLanguages = React.useMemo(
|
|
163
|
+
() => allLanguages.filter((l) => value.includes(l.code)),
|
|
164
|
+
[allLanguages, value]
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
const filteredLanguages = React.useMemo(() => {
|
|
168
|
+
if (!search) return allLanguages
|
|
169
|
+
const searchLower = search.toLowerCase()
|
|
170
|
+
return allLanguages.filter(
|
|
171
|
+
(l) =>
|
|
172
|
+
l.name.toLowerCase().includes(searchLower) ||
|
|
173
|
+
l.native.toLowerCase().includes(searchLower) ||
|
|
174
|
+
l.code.toLowerCase().includes(searchLower)
|
|
175
|
+
)
|
|
176
|
+
}, [allLanguages, search])
|
|
177
|
+
|
|
178
|
+
const handleSelect = React.useCallback((code: string) => {
|
|
179
|
+
if (multiple) {
|
|
180
|
+
const newValue = value.includes(code)
|
|
181
|
+
? value.filter((v) => v !== code)
|
|
182
|
+
: [...value, code]
|
|
183
|
+
onChange?.(newValue)
|
|
184
|
+
} else {
|
|
185
|
+
onChange?.([code])
|
|
186
|
+
if (variant === 'dropdown') {
|
|
187
|
+
setOpen(false)
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
}, [multiple, value, onChange, variant])
|
|
191
|
+
|
|
192
|
+
const handleRemove = React.useCallback((code: string, e: React.MouseEvent) => {
|
|
193
|
+
e.stopPropagation()
|
|
194
|
+
onChange?.(value.filter((v) => v !== code))
|
|
195
|
+
}, [value, onChange])
|
|
196
|
+
|
|
197
|
+
// Inline variant
|
|
198
|
+
if (variant === 'inline') {
|
|
199
|
+
return (
|
|
200
|
+
<div className={cn("space-y-3", className)}>
|
|
201
|
+
{/* Search input */}
|
|
202
|
+
{showSearch && (
|
|
203
|
+
<div className="relative">
|
|
204
|
+
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
|
205
|
+
<Input
|
|
206
|
+
type="text"
|
|
207
|
+
placeholder={resolvedSearchPlaceholder}
|
|
208
|
+
value={search}
|
|
209
|
+
onChange={(e) => setSearch(e.target.value)}
|
|
210
|
+
className="pl-9"
|
|
211
|
+
disabled={disabled}
|
|
212
|
+
/>
|
|
213
|
+
</div>
|
|
214
|
+
)}
|
|
215
|
+
|
|
216
|
+
{/* Selected count */}
|
|
217
|
+
{multiple && selectedLanguages.length > 0 && (
|
|
218
|
+
<p className="text-sm text-muted-foreground">
|
|
219
|
+
{selectedCountLabel(selectedLanguages.length)}
|
|
220
|
+
</p>
|
|
221
|
+
)}
|
|
222
|
+
|
|
223
|
+
{/* Language list */}
|
|
224
|
+
<ScrollArea style={{ height: maxHeight }} className="rounded-md border">
|
|
225
|
+
<div className="p-1">
|
|
226
|
+
{filteredLanguages.length === 0 ? (
|
|
227
|
+
<p className="text-sm text-muted-foreground text-center py-4">
|
|
228
|
+
{resolvedEmptyText}
|
|
229
|
+
</p>
|
|
230
|
+
) : (
|
|
231
|
+
filteredLanguages.map((language) => {
|
|
232
|
+
const isSelected = value.includes(language.code);
|
|
233
|
+
return (
|
|
234
|
+
<label
|
|
235
|
+
key={language.code}
|
|
236
|
+
className={cn(
|
|
237
|
+
'flex items-center gap-3 px-3 py-2 rounded-md cursor-pointer',
|
|
238
|
+
'hover:bg-accent/50 transition-colors',
|
|
239
|
+
isSelected && 'bg-primary/5',
|
|
240
|
+
disabled && 'opacity-50 cursor-not-allowed',
|
|
241
|
+
)}
|
|
242
|
+
>
|
|
243
|
+
{multiple ? (
|
|
244
|
+
<Checkbox
|
|
245
|
+
checked={isSelected}
|
|
246
|
+
onCheckedChange={() => !disabled && handleSelect(language.code)}
|
|
247
|
+
disabled={disabled}
|
|
248
|
+
/>
|
|
249
|
+
) : (
|
|
250
|
+
<div className={cn(
|
|
251
|
+
"h-4 w-4 rounded-full border-2 flex items-center justify-center",
|
|
252
|
+
isSelected ? "border-primary bg-primary" : "border-muted-foreground"
|
|
253
|
+
)}>
|
|
254
|
+
{isSelected && <div className="h-2 w-2 rounded-full bg-primary-foreground" />}
|
|
255
|
+
</div>
|
|
256
|
+
)}
|
|
257
|
+
<div className="flex flex-col flex-1 min-w-0">
|
|
258
|
+
<span className={cn('text-sm', isSelected && 'font-medium')}>
|
|
259
|
+
{language.name}
|
|
260
|
+
</span>
|
|
261
|
+
{showNativeName && language.native !== language.name && (
|
|
262
|
+
<span className="text-xs text-muted-foreground">
|
|
263
|
+
{language.native}
|
|
264
|
+
</span>
|
|
265
|
+
)}
|
|
266
|
+
</div>
|
|
267
|
+
<span className="text-xs text-muted-foreground uppercase">
|
|
268
|
+
{language.code}
|
|
269
|
+
</span>
|
|
270
|
+
</label>
|
|
271
|
+
);
|
|
272
|
+
})
|
|
273
|
+
)}
|
|
274
|
+
</div>
|
|
275
|
+
</ScrollArea>
|
|
276
|
+
</div>
|
|
277
|
+
);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// Dropdown variant
|
|
281
|
+
const displayValue = React.useMemo(() => {
|
|
282
|
+
if (selectedLanguages.length === 0) {
|
|
283
|
+
return <span className="text-muted-foreground">{resolvedPlaceholder}</span>
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
if (!multiple && selectedLanguages.length === 1) {
|
|
287
|
+
const language = selectedLanguages[0]!;
|
|
288
|
+
return (
|
|
289
|
+
<div className="flex items-center gap-2">
|
|
290
|
+
<span className="text-xs text-muted-foreground uppercase">{language.code}</span>
|
|
291
|
+
<span>{language.name}</span>
|
|
292
|
+
</div>
|
|
293
|
+
);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
const displayed = selectedLanguages.slice(0, maxDisplay)
|
|
297
|
+
const remaining = selectedLanguages.length - maxDisplay
|
|
298
|
+
|
|
299
|
+
return (
|
|
300
|
+
<div className="flex flex-wrap gap-1">
|
|
301
|
+
{displayed.map((language) => (
|
|
302
|
+
<Badge
|
|
303
|
+
key={language.code}
|
|
304
|
+
variant="secondary"
|
|
305
|
+
className="mr-1 text-xs"
|
|
306
|
+
>
|
|
307
|
+
<span className="mr-1 text-muted-foreground uppercase">{language.code}</span>
|
|
308
|
+
{language.name}
|
|
309
|
+
<button
|
|
310
|
+
className="ml-1 rounded-full hover:bg-muted-foreground/20"
|
|
311
|
+
onClick={(e) => handleRemove(language.code, e)}
|
|
312
|
+
disabled={disabled}
|
|
313
|
+
aria-label={`Remove ${language.name}`}
|
|
314
|
+
>
|
|
315
|
+
<X className="h-3 w-3" />
|
|
316
|
+
</button>
|
|
317
|
+
</Badge>
|
|
318
|
+
))}
|
|
319
|
+
{remaining > 0 && (
|
|
320
|
+
<Badge variant="outline" className="text-xs">
|
|
321
|
+
{moreItemsLabel(remaining)}
|
|
322
|
+
</Badge>
|
|
323
|
+
)}
|
|
324
|
+
</div>
|
|
325
|
+
)
|
|
326
|
+
}, [selectedLanguages, maxDisplay, resolvedPlaceholder, disabled, multiple, handleRemove, moreItemsLabel])
|
|
327
|
+
|
|
328
|
+
return (
|
|
329
|
+
<Popover
|
|
330
|
+
open={open}
|
|
331
|
+
onOpenChange={(isOpen) => {
|
|
332
|
+
setOpen(isOpen)
|
|
333
|
+
if (!isOpen) {
|
|
334
|
+
setSearch("")
|
|
335
|
+
}
|
|
336
|
+
}}
|
|
337
|
+
>
|
|
338
|
+
<PopoverTrigger asChild>
|
|
339
|
+
<Button
|
|
340
|
+
variant="outline"
|
|
341
|
+
role="combobox"
|
|
342
|
+
aria-expanded={open}
|
|
343
|
+
className={cn(
|
|
344
|
+
"w-full justify-between min-h-10 h-auto py-2",
|
|
345
|
+
className
|
|
346
|
+
)}
|
|
347
|
+
disabled={disabled}
|
|
348
|
+
>
|
|
349
|
+
<div className="flex-1 text-left overflow-hidden">
|
|
350
|
+
{displayValue}
|
|
351
|
+
</div>
|
|
352
|
+
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
|
353
|
+
</Button>
|
|
354
|
+
</PopoverTrigger>
|
|
355
|
+
<PopoverContent className="w-[var(--radix-popover-trigger-width)] p-0" align="start">
|
|
356
|
+
<Command shouldFilter={false} className="flex flex-col">
|
|
357
|
+
<CommandInput
|
|
358
|
+
placeholder={resolvedSearchPlaceholder}
|
|
359
|
+
className="shrink-0"
|
|
360
|
+
value={search}
|
|
361
|
+
onValueChange={setSearch}
|
|
362
|
+
/>
|
|
363
|
+
<CommandList className="max-h-[300px] overflow-y-auto">
|
|
364
|
+
{filteredLanguages.length === 0 ? (
|
|
365
|
+
<CommandEmpty>{resolvedEmptyText}</CommandEmpty>
|
|
366
|
+
) : (
|
|
367
|
+
<CommandGroup>
|
|
368
|
+
{filteredLanguages.map((language) => {
|
|
369
|
+
const isSelected = value.includes(language.code)
|
|
370
|
+
return (
|
|
371
|
+
<CommandItem
|
|
372
|
+
key={language.code}
|
|
373
|
+
value={language.code}
|
|
374
|
+
onSelect={() => handleSelect(language.code)}
|
|
375
|
+
>
|
|
376
|
+
<Check
|
|
377
|
+
className={cn(
|
|
378
|
+
"mr-2 h-4 w-4 shrink-0",
|
|
379
|
+
isSelected ? "opacity-100" : "opacity-0"
|
|
380
|
+
)}
|
|
381
|
+
/>
|
|
382
|
+
<div className="flex flex-col flex-1 min-w-0">
|
|
383
|
+
<span className="truncate">{language.name}</span>
|
|
384
|
+
{showNativeName && language.native !== language.name && (
|
|
385
|
+
<span className="text-xs text-muted-foreground truncate">
|
|
386
|
+
{language.native}
|
|
387
|
+
</span>
|
|
388
|
+
)}
|
|
389
|
+
</div>
|
|
390
|
+
<span className="text-xs text-muted-foreground uppercase ml-2">
|
|
391
|
+
{language.code}
|
|
392
|
+
</span>
|
|
393
|
+
</CommandItem>
|
|
394
|
+
)
|
|
395
|
+
})}
|
|
396
|
+
</CommandGroup>
|
|
397
|
+
)}
|
|
398
|
+
</CommandList>
|
|
399
|
+
</Command>
|
|
400
|
+
</PopoverContent>
|
|
401
|
+
</Popover>
|
|
402
|
+
)
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
// Re-export types for convenience
|
|
406
|
+
export type { TLanguageCode } from 'countries-list';
|
|
@@ -36,7 +36,7 @@ export const Vertical = () => (
|
|
|
36
36
|
);
|
|
37
37
|
|
|
38
38
|
export const Horizontal = () => (
|
|
39
|
-
<ScrollArea className="w-96 whitespace-nowrap rounded-md border">
|
|
39
|
+
<ScrollArea className="w-96 whitespace-nowrap rounded-md border" orientation="horizontal">
|
|
40
40
|
<div className="flex w-max space-x-4 p-4">
|
|
41
41
|
{works.map((work) => (
|
|
42
42
|
<figure key={work.artist} className="shrink-0">
|
|
@@ -52,12 +52,11 @@ export const Horizontal = () => (
|
|
|
52
52
|
</figure>
|
|
53
53
|
))}
|
|
54
54
|
</div>
|
|
55
|
-
<ScrollBar orientation="horizontal" />
|
|
56
55
|
</ScrollArea>
|
|
57
56
|
);
|
|
58
57
|
|
|
59
58
|
export const Both = () => (
|
|
60
|
-
<ScrollArea className="h-72 w-72 rounded-md border">
|
|
59
|
+
<ScrollArea className="h-72 w-72 rounded-md border" orientation="both">
|
|
61
60
|
<div className="p-4">
|
|
62
61
|
{Array.from({ length: 20 }).map((_, i) => (
|
|
63
62
|
<div key={i} className="whitespace-nowrap py-2">
|
|
@@ -65,7 +64,6 @@ export const Both = () => (
|
|
|
65
64
|
</div>
|
|
66
65
|
))}
|
|
67
66
|
</div>
|
|
68
|
-
<ScrollBar orientation="horizontal" />
|
|
69
67
|
</ScrollArea>
|
|
70
68
|
);
|
|
71
69
|
|
|
@@ -110,3 +108,47 @@ export const Chat = () => (
|
|
|
110
108
|
</ScrollArea>
|
|
111
109
|
</div>
|
|
112
110
|
);
|
|
111
|
+
|
|
112
|
+
const filters = [
|
|
113
|
+
'All', 'Electronics', 'Clothing', 'Home & Garden', 'Sports', 'Books',
|
|
114
|
+
'Toys', 'Beauty', 'Automotive', 'Health', 'Food', 'Music', 'Movies'
|
|
115
|
+
];
|
|
116
|
+
|
|
117
|
+
export const HorizontalFilterTabs = () => (
|
|
118
|
+
<div className="max-w-sm">
|
|
119
|
+
<ScrollArea className="w-full" orientation="horizontal">
|
|
120
|
+
<div className="flex gap-2 pb-2">
|
|
121
|
+
{filters.map((filter) => (
|
|
122
|
+
<button
|
|
123
|
+
key={filter}
|
|
124
|
+
className="px-3 py-1.5 rounded-md text-xs font-medium whitespace-nowrap bg-muted text-muted-foreground hover:text-foreground transition-colors"
|
|
125
|
+
>
|
|
126
|
+
{filter}
|
|
127
|
+
</button>
|
|
128
|
+
))}
|
|
129
|
+
</div>
|
|
130
|
+
</ScrollArea>
|
|
131
|
+
<p className="text-xs text-muted-foreground mt-2">
|
|
132
|
+
Scroll horizontally to see more filters
|
|
133
|
+
</p>
|
|
134
|
+
</div>
|
|
135
|
+
);
|
|
136
|
+
|
|
137
|
+
export const HorizontalChips = () => (
|
|
138
|
+
<div className="max-w-xs">
|
|
139
|
+
<p className="text-sm font-medium mb-2">Selected tags:</p>
|
|
140
|
+
<ScrollArea className="w-full" orientation="horizontal">
|
|
141
|
+
<div className="flex gap-1 pb-2">
|
|
142
|
+
{['React', 'TypeScript', 'Tailwind', 'Next.js', 'Radix UI', 'Zustand', 'TanStack Query'].map((tag) => (
|
|
143
|
+
<span
|
|
144
|
+
key={tag}
|
|
145
|
+
className="inline-flex items-center gap-1 px-2 py-1 rounded-full text-xs bg-primary/10 text-primary whitespace-nowrap"
|
|
146
|
+
>
|
|
147
|
+
{tag}
|
|
148
|
+
<button className="hover:bg-primary/20 rounded-full p-0.5">×</button>
|
|
149
|
+
</span>
|
|
150
|
+
))}
|
|
151
|
+
</div>
|
|
152
|
+
</ScrollArea>
|
|
153
|
+
</div>
|
|
154
|
+
);
|
|
@@ -20,16 +20,20 @@ export interface ScrollAreaHandle {
|
|
|
20
20
|
getViewport: () => HTMLDivElement | null;
|
|
21
21
|
}
|
|
22
22
|
|
|
23
|
+
export type ScrollAreaOrientation = 'vertical' | 'horizontal' | 'both';
|
|
24
|
+
|
|
23
25
|
export interface ScrollAreaProps
|
|
24
26
|
extends React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root> {
|
|
25
27
|
/** Ref to access the viewport element directly */
|
|
26
28
|
viewportRef?: React.RefObject<HTMLDivElement | null>;
|
|
27
29
|
/** Additional className for the viewport */
|
|
28
30
|
viewportClassName?: string;
|
|
31
|
+
/** Scroll orientation: vertical (default), horizontal, or both */
|
|
32
|
+
orientation?: ScrollAreaOrientation;
|
|
29
33
|
}
|
|
30
34
|
|
|
31
35
|
const ScrollArea = React.forwardRef<ScrollAreaHandle, ScrollAreaProps>(
|
|
32
|
-
({ className, children, viewportRef, viewportClassName, ...props }, ref) => {
|
|
36
|
+
({ className, children, viewportRef, viewportClassName, orientation = 'vertical', ...props }, ref) => {
|
|
33
37
|
const internalViewportRef = React.useRef<HTMLDivElement>(null);
|
|
34
38
|
const actualViewportRef = viewportRef || internalViewportRef;
|
|
35
39
|
|
|
@@ -59,6 +63,9 @@ const ScrollArea = React.forwardRef<ScrollAreaHandle, ScrollAreaProps>(
|
|
|
59
63
|
getViewport: () => actualViewportRef.current,
|
|
60
64
|
}), [actualViewportRef]);
|
|
61
65
|
|
|
66
|
+
const showVerticalBar = orientation === 'vertical' || orientation === 'both';
|
|
67
|
+
const showHorizontalBar = orientation === 'horizontal' || orientation === 'both';
|
|
68
|
+
|
|
62
69
|
return (
|
|
63
70
|
<ScrollAreaPrimitive.Root
|
|
64
71
|
className={cn("relative overflow-hidden", className)}
|
|
@@ -70,7 +77,8 @@ const ScrollArea = React.forwardRef<ScrollAreaHandle, ScrollAreaProps>(
|
|
|
70
77
|
>
|
|
71
78
|
{children}
|
|
72
79
|
</ScrollAreaPrimitive.Viewport>
|
|
73
|
-
<ScrollBar />
|
|
80
|
+
{showVerticalBar && <ScrollBar orientation="vertical" />}
|
|
81
|
+
{showHorizontalBar && <ScrollBar orientation="horizontal" />}
|
|
74
82
|
<ScrollAreaPrimitive.Corner />
|
|
75
83
|
</ScrollAreaPrimitive.Root>
|
|
76
84
|
);
|