@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 60+ components built on Radix UI + Tailwind CSS v4.
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 (17)
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.159",
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.159",
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.159",
141
+ "@djangocfg/i18n": "^2.1.161",
142
142
  "@djangocfg/playground": "workspace:*",
143
- "@djangocfg/typescript-config": "^2.1.159",
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",
@@ -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
  );