├── src ├── lib │ └── utils.ts ├── main.tsx ├── hooks │ └── use-mobile.tsx ├── components │ ├── ui │ │ ├── popover.tsx │ │ ├── tabs.tsx │ │ ├── button.tsx │ │ ├── code-block.tsx │ │ ├── drawer.tsx │ │ └── cascader.tsx │ └── theme-provider.tsx ├── index.css └── App.tsx ├── tsconfig.json ├── .gitignore ├── vite.config.ts ├── index.html ├── components.json ├── eslint.config.js ├── tsconfig.node.json ├── tsconfig.app.json ├── registry.json ├── public ├── r │ ├── registry.json │ └── cascader.json ├── vite.svg └── favicon.svg ├── LICENSE ├── package.json └── README.md /src/lib/utils.ts: -------------------------------------------------------------------------------- 1 | import { clsx, type ClassValue } from "clsx" 2 | import { twMerge } from "tailwind-merge" 3 | 4 | export function cn(...inputs: ClassValue[]) { 5 | return twMerge(clsx(inputs)) 6 | } 7 | -------------------------------------------------------------------------------- /src/main.tsx: -------------------------------------------------------------------------------- 1 | import { StrictMode } from 'react' 2 | import { createRoot } from 'react-dom/client' 3 | import './index.css' 4 | import App from './App.tsx' 5 | 6 | createRoot(document.getElementById('root')!).render( 7 | 8 | 9 | , 10 | ) 11 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "files": [], 3 | "references": [ 4 | { "path": "./tsconfig.app.json" }, 5 | { "path": "./tsconfig.node.json" } 6 | ], 7 | "compilerOptions": { 8 | "baseUrl": ".", 9 | "paths": { 10 | "@/*": ["./src/*"] 11 | } 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import path from "path"; 2 | import tailwindcss from "@tailwindcss/vite"; 3 | import { defineConfig } from "vite"; 4 | import react from "@vitejs/plugin-react"; 5 | 6 | // https://vite.dev/config/ 7 | export default defineConfig({ 8 | plugins: [react(), tailwindcss()], 9 | resolve: { 10 | alias: { 11 | "@": path.resolve(__dirname, "./src"), 12 | }, 13 | }, 14 | }); 15 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | Cascader-ShadCN 9 | 10 | 11 | 12 |
13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /components.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://ui.shadcn.com/schema.json", 3 | "style": "new-york", 4 | "rsc": false, 5 | "tsx": true, 6 | "tailwind": { 7 | "config": "", 8 | "css": "src/index.css", 9 | "baseColor": "neutral", 10 | "cssVariables": true, 11 | "prefix": "" 12 | }, 13 | "iconLibrary": "lucide", 14 | "aliases": { 15 | "components": "@/components", 16 | "utils": "@/lib/utils", 17 | "ui": "@/components/ui", 18 | "lib": "@/lib", 19 | "hooks": "@/hooks" 20 | }, 21 | "registries": { 22 | "@aceternity": "https://ui.aceternity.com/registry/{name}.json" 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/hooks/use-mobile.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import * as React from "react"; 4 | 5 | const MOBILE_BREAKPOINT = 768; 6 | 7 | export function useIsMobile() { 8 | const [isMobile, setIsMobile] = React.useState( 9 | undefined 10 | ); 11 | 12 | React.useEffect(() => { 13 | const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`); 14 | const onChange = () => { 15 | setIsMobile(window.innerWidth < MOBILE_BREAKPOINT); 16 | }; 17 | mql.addEventListener("change", onChange); 18 | setIsMobile(window.innerWidth < MOBILE_BREAKPOINT); 19 | return () => mql.removeEventListener("change", onChange); 20 | }, []); 21 | 22 | return !!isMobile; 23 | } 24 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | import js from '@eslint/js' 2 | import globals from 'globals' 3 | import reactHooks from 'eslint-plugin-react-hooks' 4 | import reactRefresh from 'eslint-plugin-react-refresh' 5 | import tseslint from 'typescript-eslint' 6 | import { defineConfig, globalIgnores } from 'eslint/config' 7 | 8 | export default defineConfig([ 9 | globalIgnores(['dist']), 10 | { 11 | files: ['**/*.{ts,tsx}'], 12 | extends: [ 13 | js.configs.recommended, 14 | tseslint.configs.recommended, 15 | reactHooks.configs.flat.recommended, 16 | reactRefresh.configs.vite, 17 | ], 18 | languageOptions: { 19 | ecmaVersion: 2020, 20 | globals: globals.browser, 21 | }, 22 | }, 23 | ]) 24 | -------------------------------------------------------------------------------- /tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", 4 | "target": "ES2023", 5 | "lib": ["ES2023"], 6 | "module": "ESNext", 7 | "types": ["node"], 8 | "skipLibCheck": true, 9 | 10 | /* Bundler mode */ 11 | "moduleResolution": "bundler", 12 | "allowImportingTsExtensions": true, 13 | "verbatimModuleSyntax": true, 14 | "moduleDetection": "force", 15 | "noEmit": true, 16 | 17 | /* Linting */ 18 | "strict": true, 19 | "noUnusedLocals": true, 20 | "noUnusedParameters": true, 21 | "erasableSyntaxOnly": true, 22 | "noFallthroughCasesInSwitch": true, 23 | "noUncheckedSideEffectImports": true 24 | }, 25 | "include": ["vite.config.ts"] 26 | } 27 | -------------------------------------------------------------------------------- /tsconfig.app.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", 4 | "target": "ES2022", 5 | "useDefineForClassFields": true, 6 | "lib": ["ES2022", "DOM", "DOM.Iterable"], 7 | "module": "ESNext", 8 | "types": ["vite/client"], 9 | "skipLibCheck": true, 10 | 11 | /* Bundler mode */ 12 | "moduleResolution": "bundler", 13 | "allowImportingTsExtensions": true, 14 | "verbatimModuleSyntax": true, 15 | "moduleDetection": "force", 16 | "noEmit": true, 17 | "jsx": "react-jsx", 18 | 19 | /* Linting */ 20 | "strict": true, 21 | "noUnusedLocals": true, 22 | "noUnusedParameters": true, 23 | "erasableSyntaxOnly": true, 24 | "noFallthroughCasesInSwitch": true, 25 | "noUncheckedSideEffectImports": true, 26 | "baseUrl": ".", 27 | "paths": { 28 | "@/*": ["./src/*"] 29 | } 30 | }, 31 | "include": ["src"] 32 | } 33 | -------------------------------------------------------------------------------- /registry.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://ui.shadcn.com/schema/registry.json", 3 | "name": "cascader", 4 | "homepage": "https://github.com/Ademking/cascader-shadcn", 5 | "items": [ 6 | { 7 | "name": "cascader", 8 | "type": "registry:component", 9 | "title": "Cascader", 10 | "description": "A cascading dropdown menu component for selecting hierarchical data like locations, categories, or organizational structures.", 11 | "registryDependencies": [ 12 | "popover", 13 | "drawer", 14 | "use-mobile" 15 | ], 16 | "dependencies": [ 17 | "lucide-react" 18 | ], 19 | "files": [ 20 | { 21 | "path": "src/components/ui/cascader.tsx", 22 | "type": "registry:component" 23 | }, 24 | { 25 | "path": "src/hooks/use-mobile.tsx", 26 | "type": "registry:hook" 27 | } 28 | ] 29 | } 30 | ] 31 | } -------------------------------------------------------------------------------- /public/r/registry.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://ui.shadcn.com/schema/registry.json", 3 | "name": "cascader", 4 | "homepage": "https://github.com/Ademking/cascader-shadcn", 5 | "items": [ 6 | { 7 | "name": "cascader", 8 | "type": "registry:component", 9 | "title": "Cascader", 10 | "description": "A cascading dropdown menu component for selecting hierarchical data like locations, categories, or organizational structures.", 11 | "registryDependencies": [ 12 | "popover", 13 | "drawer", 14 | "use-mobile" 15 | ], 16 | "dependencies": [ 17 | "lucide-react" 18 | ], 19 | "files": [ 20 | { 21 | "path": "src/components/ui/cascader.tsx", 22 | "type": "registry:component" 23 | }, 24 | { 25 | "path": "src/hooks/use-mobile.tsx", 26 | "type": "registry:hook" 27 | } 28 | ] 29 | } 30 | ] 31 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Adem Kouki 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /public/vite.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "cascader-shadcn", 3 | "private": true, 4 | "version": "0.0.0", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "vite", 8 | "build": "tsc -b && vite build", 9 | "lint": "eslint .", 10 | "preview": "vite preview", 11 | "registry:build": "shadcn build", 12 | "deploy": "npm run build && npm run registry:build && surge ./dist --domain cascader-shadcn.surge.sh" 13 | }, 14 | "dependencies": { 15 | "@radix-ui/react-dialog": "^1.1.15", 16 | "@radix-ui/react-popover": "^1.1.15", 17 | "@radix-ui/react-slot": "^1.2.4", 18 | "@radix-ui/react-tabs": "^1.1.13", 19 | "@tabler/icons-react": "^3.35.0", 20 | "@tailwindcss/vite": "^4.1.17", 21 | "class-variance-authority": "^0.7.1", 22 | "clsx": "^2.1.1", 23 | "lucide-react": "^0.555.0", 24 | "motion": "^12.23.25", 25 | "react": "^19.2.0", 26 | "react-dom": "^19.2.0", 27 | "react-syntax-highlighter": "^16.1.0", 28 | "shadcn": "^3.5.1", 29 | "tailwind-merge": "^3.4.0", 30 | "tailwindcss": "^4.1.17", 31 | "vaul": "^1.1.2" 32 | }, 33 | "devDependencies": { 34 | "@eslint/js": "^9.39.1", 35 | "@types/node": "^24.10.1", 36 | "@types/react": "^19.2.5", 37 | "@types/react-dom": "^19.2.3", 38 | "@types/react-syntax-highlighter": "^15.5.13", 39 | "@vitejs/plugin-react": "^5.1.1", 40 | "eslint": "^9.39.1", 41 | "eslint-plugin-react-hooks": "^7.0.1", 42 | "eslint-plugin-react-refresh": "^0.4.24", 43 | "globals": "^16.5.0", 44 | "tw-animate-css": "^1.4.0", 45 | "typescript": "~5.9.3", 46 | "typescript-eslint": "^8.46.4", 47 | "vite": "^7.2.4" 48 | } 49 | } -------------------------------------------------------------------------------- /src/components/ui/popover.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as PopoverPrimitive from "@radix-ui/react-popover" 5 | 6 | import { cn } from "@/lib/utils" 7 | 8 | function Popover({ 9 | ...props 10 | }: React.ComponentProps) { 11 | return 12 | } 13 | 14 | function PopoverTrigger({ 15 | ...props 16 | }: React.ComponentProps) { 17 | return 18 | } 19 | 20 | function PopoverContent({ 21 | className, 22 | align = "center", 23 | sideOffset = 4, 24 | ...props 25 | }: React.ComponentProps) { 26 | return ( 27 | 28 | 38 | 39 | ) 40 | } 41 | 42 | function PopoverAnchor({ 43 | ...props 44 | }: React.ComponentProps) { 45 | return 46 | } 47 | 48 | export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor } 49 | -------------------------------------------------------------------------------- /src/components/theme-provider.tsx: -------------------------------------------------------------------------------- 1 | import { createContext, useContext, useEffect, useState } from "react"; 2 | 3 | type Theme = "dark" | "light" | "system"; 4 | 5 | type ThemeProviderProps = { 6 | children: React.ReactNode; 7 | defaultTheme?: Theme; 8 | storageKey?: string; 9 | }; 10 | 11 | type ThemeProviderState = { 12 | theme: Theme; 13 | setTheme: (theme: Theme) => void; 14 | }; 15 | 16 | const initialState: ThemeProviderState = { 17 | theme: "system", 18 | setTheme: () => null, 19 | }; 20 | 21 | const ThemeProviderContext = createContext(initialState); 22 | 23 | export function ThemeProvider({ 24 | children, 25 | defaultTheme = "system", 26 | storageKey = "vite-ui-theme", 27 | ...props 28 | }: ThemeProviderProps) { 29 | const [theme, setTheme] = useState( 30 | () => (localStorage.getItem(storageKey) as Theme) || defaultTheme 31 | ); 32 | 33 | useEffect(() => { 34 | const root = window.document.documentElement; 35 | 36 | root.classList.remove("light", "dark"); 37 | 38 | if (theme === "system") { 39 | const systemTheme = window.matchMedia("(prefers-color-scheme: dark)") 40 | .matches 41 | ? "dark" 42 | : "light"; 43 | 44 | root.classList.add(systemTheme); 45 | return; 46 | } 47 | 48 | root.classList.add(theme); 49 | }, [theme]); 50 | 51 | const value = { 52 | theme, 53 | setTheme: (theme: Theme) => { 54 | localStorage.setItem(storageKey, theme); 55 | setTheme(theme); 56 | }, 57 | }; 58 | 59 | return ( 60 | 61 | {children} 62 | 63 | ); 64 | } 65 | 66 | export const useTheme = () => { 67 | const context = useContext(ThemeProviderContext); 68 | 69 | if (context === undefined) 70 | throw new Error("useTheme must be used within a ThemeProvider"); 71 | 72 | return context; 73 | }; 74 | -------------------------------------------------------------------------------- /src/components/ui/tabs.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import * as TabsPrimitive from "@radix-ui/react-tabs" 3 | 4 | import { cn } from "@/lib/utils" 5 | 6 | function Tabs({ 7 | className, 8 | ...props 9 | }: React.ComponentProps) { 10 | return ( 11 | 16 | ) 17 | } 18 | 19 | function TabsList({ 20 | className, 21 | ...props 22 | }: React.ComponentProps) { 23 | return ( 24 | 32 | ) 33 | } 34 | 35 | function TabsTrigger({ 36 | className, 37 | ...props 38 | }: React.ComponentProps) { 39 | return ( 40 | 48 | ) 49 | } 50 | 51 | function TabsContent({ 52 | className, 53 | ...props 54 | }: React.ComponentProps) { 55 | return ( 56 | 61 | ) 62 | } 63 | 64 | export { Tabs, TabsList, TabsTrigger, TabsContent } 65 | -------------------------------------------------------------------------------- /src/components/ui/button.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { Slot } from "@radix-ui/react-slot" 3 | import { cva, type VariantProps } from "class-variance-authority" 4 | 5 | import { cn } from "@/lib/utils" 6 | 7 | const buttonVariants = cva( 8 | "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", 9 | { 10 | variants: { 11 | variant: { 12 | default: "bg-primary text-primary-foreground hover:bg-primary/90", 13 | destructive: 14 | "bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60", 15 | outline: 16 | "border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50", 17 | secondary: 18 | "bg-secondary text-secondary-foreground hover:bg-secondary/80", 19 | ghost: 20 | "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50", 21 | link: "text-primary underline-offset-4 hover:underline", 22 | }, 23 | size: { 24 | default: "h-9 px-4 py-2 has-[>svg]:px-3", 25 | sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5", 26 | lg: "h-10 rounded-md px-6 has-[>svg]:px-4", 27 | icon: "size-9", 28 | "icon-sm": "size-8", 29 | "icon-lg": "size-10", 30 | }, 31 | }, 32 | defaultVariants: { 33 | variant: "default", 34 | size: "default", 35 | }, 36 | } 37 | ) 38 | 39 | function Button({ 40 | className, 41 | variant, 42 | size, 43 | asChild = false, 44 | ...props 45 | }: React.ComponentProps<"button"> & 46 | VariantProps & { 47 | asChild?: boolean 48 | }) { 49 | const Comp = asChild ? Slot : "button" 50 | 51 | return ( 52 | 57 | ) 58 | } 59 | 60 | export { Button, buttonVariants } 61 | -------------------------------------------------------------------------------- /public/favicon.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/components/ui/code-block.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import React from "react"; 3 | import { Prism as SyntaxHighlighter } from "react-syntax-highlighter"; 4 | import { atomDark } from "react-syntax-highlighter/dist/cjs/styles/prism"; 5 | import { IconCheck, IconCopy } from "@tabler/icons-react"; 6 | 7 | type CodeBlockProps = { 8 | language: string; 9 | filename: string; 10 | highlightLines?: number[]; 11 | } & ( 12 | | { 13 | code: string; 14 | tabs?: never; 15 | } 16 | | { 17 | code?: never; 18 | tabs: Array<{ 19 | name: string; 20 | code: string; 21 | language?: string; 22 | highlightLines?: number[]; 23 | }>; 24 | } 25 | ); 26 | 27 | export const CodeBlock = ({ 28 | language, 29 | filename, 30 | code, 31 | highlightLines = [], 32 | tabs = [], 33 | }: CodeBlockProps) => { 34 | const [copied, setCopied] = React.useState(false); 35 | const [activeTab, setActiveTab] = React.useState(0); 36 | 37 | const tabsExist = tabs.length > 0; 38 | 39 | const copyToClipboard = async () => { 40 | const textToCopy = tabsExist ? tabs[activeTab].code : code; 41 | if (textToCopy) { 42 | await navigator.clipboard.writeText(textToCopy); 43 | setCopied(true); 44 | setTimeout(() => setCopied(false), 2000); 45 | } 46 | }; 47 | 48 | const activeCode = tabsExist ? tabs[activeTab].code : code; 49 | const activeLanguage = tabsExist 50 | ? tabs[activeTab].language || language 51 | : language; 52 | const activeHighlightLines = tabsExist 53 | ? tabs[activeTab].highlightLines || [] 54 | : highlightLines; 55 | 56 | return ( 57 |
58 |
59 | {tabsExist && ( 60 |
61 | {tabs.map((tab, index) => ( 62 | 73 | ))} 74 |
75 | )} 76 | {!tabsExist && filename && ( 77 |
78 |
{filename}
79 | 85 |
86 | )} 87 |
88 | ({ 100 | style: { 101 | backgroundColor: activeHighlightLines.includes(lineNumber) 102 | ? "rgba(255,255,255,0.1)" 103 | : "transparent", 104 | display: "block", 105 | width: "100%", 106 | }, 107 | })} 108 | PreTag="div" 109 | > 110 | {String(activeCode)} 111 | 112 |
113 | ); 114 | }; 115 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 | 3 |
4 | 5 | # Cascader-Shadcn 6 | 7 | A cascading dropdown component for selecting hierarchical data such as locations, categories, or organizational structures. 8 | 9 | **Inspired by:** Cascader components from [**Ant Design**](https://ant.design/components/cascader/) and [**React Suite**](https://rsuitejs.com/components/cascader/) 10 | 11 | --- 12 | 13 | ## Features 14 | 15 | * Hierarchical cascading menu 16 | * Click or hover expansion 17 | * Supports icons and custom labels 18 | * Custom display rendering 19 | * Disable per option or entire component 20 | * Shadcn-compatible + Tailwind-friendly 21 | 22 | --- 23 | 24 | ## Installation 25 | 26 | ### **Using Shadcn CLI** 27 | 28 | ```bash 29 | npx shadcn@latest add https://cascader-shadcn.surge.sh/r/cascader.json 30 | ``` 31 | 32 | ### **Manual Installation** 33 | 34 | Copy the [Cascader.tsx](src/components/ui/cascader.tsx) component from the repository into your Shadcn components directory. 35 | 36 | --- 37 | 38 | ## Usage 39 | 40 | ### **Example.jsx** 41 | 42 | ```jsx 43 | import { Cascader } from "@/components/ui/cascader" 44 | 45 | const options = [ 46 | { 47 | value: "usa", 48 | label: "USA", 49 | children: [ 50 | { 51 | value: "new_york", 52 | label: "New York", 53 | children: [ 54 | { value: "statue_of_liberty", label: "Statue of Liberty" }, 55 | ], 56 | }, 57 | ], 58 | }, 59 | ] 60 | 61 | export function MyComponent() { 62 | return ( 63 | { 66 | console.log(value, selectedOptions) 67 | }} 68 | placeholder="Select location" 69 | /> 70 | ) 71 | } 72 | ``` 73 | 74 | ## API Reference 75 | 76 | ### **CascaderOption** 77 | 78 | | Property | Type | Description | 79 | | ----------- | ---------------- | ------------------------------------------- | 80 | | `value` | string | Unique option identifier | 81 | | `label` | React.ReactNode | Display label (text or component) | 82 | | `textLabel` | string | Text label for display; fallback to `value` | 83 | | `disabled` | boolean | Whether this option is disabled | 84 | | `children` | CascaderOption[] | Nested options | 85 | 86 | --- 87 | 88 | ### **Cascader Props** 89 | 90 | | Prop | Type | Default | Description | 91 | | ---------------- | ------------------------------ | ----------------- | -------------------------------- | 92 | | `options` | CascaderOption[] | — | Cascader data options | 93 | | `value` | string[] | — | Controlled selected value | 94 | | `defaultValue` | string[] | — | Initial selected value | 95 | | `onChange` | (value, options) => void | — | Triggered when selection changes | 96 | | `placeholder` | string | `"Please select"` | Placeholder text | 97 | | `disabled` | boolean | `false` | Disable the component | 98 | | `allowClear` | boolean | `true` | Show clear button | 99 | | `expandTrigger` | `"click" \| "hover"` | `"click"` | How nested options expand | 100 | | `displayRender` | (labels, options) => ReactNode | — | Custom display function | 101 | | `className` | string | — | Trigger className | 102 | | `popupClassName` | string | — | Dropdown className | 103 | 104 | --- 105 | 106 | ## Author 107 | 108 | Built With 🍪 by [**Adem Kouki**](https://github.com/Ademking) 109 | 110 | ## Licence 111 | 112 | MIT 113 | -------------------------------------------------------------------------------- /src/index.css: -------------------------------------------------------------------------------- 1 | @import "tailwindcss"; 2 | @import "tw-animate-css"; 3 | 4 | @custom-variant dark (&:is(.dark *)); 5 | 6 | @theme inline { 7 | --radius-sm: calc(var(--radius) - 4px); 8 | --radius-md: calc(var(--radius) - 2px); 9 | --radius-lg: var(--radius); 10 | --radius-xl: calc(var(--radius) + 4px); 11 | --color-background: var(--background); 12 | --color-foreground: var(--foreground); 13 | --color-card: var(--card); 14 | --color-card-foreground: var(--card-foreground); 15 | --color-popover: var(--popover); 16 | --color-popover-foreground: var(--popover-foreground); 17 | --color-primary: var(--primary); 18 | --color-primary-foreground: var(--primary-foreground); 19 | --color-secondary: var(--secondary); 20 | --color-secondary-foreground: var(--secondary-foreground); 21 | --color-muted: var(--muted); 22 | --color-muted-foreground: var(--muted-foreground); 23 | --color-accent: var(--accent); 24 | --color-accent-foreground: var(--accent-foreground); 25 | --color-destructive: var(--destructive); 26 | --color-border: var(--border); 27 | --color-input: var(--input); 28 | --color-ring: var(--ring); 29 | --color-chart-1: var(--chart-1); 30 | --color-chart-2: var(--chart-2); 31 | --color-chart-3: var(--chart-3); 32 | --color-chart-4: var(--chart-4); 33 | --color-chart-5: var(--chart-5); 34 | --color-sidebar: var(--sidebar); 35 | --color-sidebar-foreground: var(--sidebar-foreground); 36 | --color-sidebar-primary: var(--sidebar-primary); 37 | --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); 38 | --color-sidebar-accent: var(--sidebar-accent); 39 | --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); 40 | --color-sidebar-border: var(--sidebar-border); 41 | --color-sidebar-ring: var(--sidebar-ring); 42 | } 43 | 44 | :root { 45 | --radius: 0.625rem; 46 | --background: oklch(1 0 0); 47 | --foreground: oklch(0.145 0 0); 48 | --card: oklch(1 0 0); 49 | --card-foreground: oklch(0.145 0 0); 50 | --popover: oklch(1 0 0); 51 | --popover-foreground: oklch(0.145 0 0); 52 | --primary: oklch(0.205 0 0); 53 | --primary-foreground: oklch(0.985 0 0); 54 | --secondary: oklch(0.97 0 0); 55 | --secondary-foreground: oklch(0.205 0 0); 56 | --muted: oklch(0.97 0 0); 57 | --muted-foreground: oklch(0.556 0 0); 58 | --accent: oklch(0.97 0 0); 59 | --accent-foreground: oklch(0.205 0 0); 60 | --destructive: oklch(0.577 0.245 27.325); 61 | --border: oklch(0.922 0 0); 62 | --input: oklch(0.922 0 0); 63 | --ring: oklch(0.708 0 0); 64 | --chart-1: oklch(0.646 0.222 41.116); 65 | --chart-2: oklch(0.6 0.118 184.704); 66 | --chart-3: oklch(0.398 0.07 227.392); 67 | --chart-4: oklch(0.828 0.189 84.429); 68 | --chart-5: oklch(0.769 0.188 70.08); 69 | --sidebar: oklch(0.985 0 0); 70 | --sidebar-foreground: oklch(0.145 0 0); 71 | --sidebar-primary: oklch(0.205 0 0); 72 | --sidebar-primary-foreground: oklch(0.985 0 0); 73 | --sidebar-accent: oklch(0.97 0 0); 74 | --sidebar-accent-foreground: oklch(0.205 0 0); 75 | --sidebar-border: oklch(0.922 0 0); 76 | --sidebar-ring: oklch(0.708 0 0); 77 | } 78 | 79 | .dark { 80 | --background: oklch(0.145 0 0); 81 | --foreground: oklch(0.985 0 0); 82 | --card: oklch(0.205 0 0); 83 | --card-foreground: oklch(0.985 0 0); 84 | --popover: oklch(0.205 0 0); 85 | --popover-foreground: oklch(0.985 0 0); 86 | --primary: oklch(0.922 0 0); 87 | --primary-foreground: oklch(0.205 0 0); 88 | --secondary: oklch(0.269 0 0); 89 | --secondary-foreground: oklch(0.985 0 0); 90 | --muted: oklch(0.269 0 0); 91 | --muted-foreground: oklch(0.708 0 0); 92 | --accent: oklch(0.269 0 0); 93 | --accent-foreground: oklch(0.985 0 0); 94 | --destructive: oklch(0.704 0.191 22.216); 95 | --border: oklch(1 0 0 / 10%); 96 | --input: oklch(1 0 0 / 15%); 97 | --ring: oklch(0.556 0 0); 98 | --chart-1: oklch(0.488 0.243 264.376); 99 | --chart-2: oklch(0.696 0.17 162.48); 100 | --chart-3: oklch(0.769 0.188 70.08); 101 | --chart-4: oklch(0.627 0.265 303.9); 102 | --chart-5: oklch(0.645 0.246 16.439); 103 | --sidebar: oklch(0.205 0 0); 104 | --sidebar-foreground: oklch(0.985 0 0); 105 | --sidebar-primary: oklch(0.488 0.243 264.376); 106 | --sidebar-primary-foreground: oklch(0.985 0 0); 107 | --sidebar-accent: oklch(0.269 0 0); 108 | --sidebar-accent-foreground: oklch(0.985 0 0); 109 | --sidebar-border: oklch(1 0 0 / 10%); 110 | --sidebar-ring: oklch(0.556 0 0); 111 | } 112 | 113 | @layer base { 114 | * { 115 | @apply border-border outline-ring/50; 116 | } 117 | body { 118 | @apply bg-background text-foreground; 119 | } 120 | } 121 | -------------------------------------------------------------------------------- /src/components/ui/drawer.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { Drawer as DrawerPrimitive } from "vaul" 3 | 4 | import { cn } from "@/lib/utils" 5 | 6 | function Drawer({ 7 | ...props 8 | }: React.ComponentProps) { 9 | return 10 | } 11 | 12 | function DrawerTrigger({ 13 | ...props 14 | }: React.ComponentProps) { 15 | return 16 | } 17 | 18 | function DrawerPortal({ 19 | ...props 20 | }: React.ComponentProps) { 21 | return 22 | } 23 | 24 | function DrawerClose({ 25 | ...props 26 | }: React.ComponentProps) { 27 | return 28 | } 29 | 30 | function DrawerOverlay({ 31 | className, 32 | ...props 33 | }: React.ComponentProps) { 34 | return ( 35 | 43 | ) 44 | } 45 | 46 | function DrawerContent({ 47 | className, 48 | children, 49 | ...props 50 | }: React.ComponentProps) { 51 | return ( 52 | 53 | 54 | 66 |
67 | {children} 68 | 69 | 70 | ) 71 | } 72 | 73 | function DrawerHeader({ className, ...props }: React.ComponentProps<"div">) { 74 | return ( 75 |
83 | ) 84 | } 85 | 86 | function DrawerFooter({ className, ...props }: React.ComponentProps<"div">) { 87 | return ( 88 |
93 | ) 94 | } 95 | 96 | function DrawerTitle({ 97 | className, 98 | ...props 99 | }: React.ComponentProps) { 100 | return ( 101 | 106 | ) 107 | } 108 | 109 | function DrawerDescription({ 110 | className, 111 | ...props 112 | }: React.ComponentProps) { 113 | return ( 114 | 119 | ) 120 | } 121 | 122 | export { 123 | Drawer, 124 | DrawerPortal, 125 | DrawerOverlay, 126 | DrawerTrigger, 127 | DrawerClose, 128 | DrawerContent, 129 | DrawerHeader, 130 | DrawerFooter, 131 | DrawerTitle, 132 | DrawerDescription, 133 | } 134 | -------------------------------------------------------------------------------- /src/components/ui/cascader.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import * as React from "react"; 4 | import { ChevronRight, ChevronDown, X } from "lucide-react"; 5 | import { cn } from "@/lib/utils"; 6 | import { 7 | Popover, 8 | PopoverContent, 9 | PopoverTrigger, 10 | } from "@/components/ui/popover"; 11 | import { 12 | Drawer, 13 | DrawerContent, 14 | DrawerHeader, 15 | DrawerTitle, 16 | DrawerTrigger, 17 | } from "@/components/ui/drawer"; 18 | import { useIsMobile } from "@/hooks/use-mobile"; 19 | 20 | export interface CascaderOption { 21 | value: string; 22 | label: React.ReactNode; 23 | textLabel?: string; 24 | disabled?: boolean; 25 | children?: CascaderOption[]; 26 | } 27 | 28 | export interface CascaderProps { 29 | options: CascaderOption[]; 30 | value?: string[]; 31 | defaultValue?: string[]; 32 | onChange?: (value: string[], selectedOptions: CascaderOption[]) => void; 33 | placeholder?: string; 34 | disabled?: boolean; 35 | allowClear?: boolean; 36 | className?: string; 37 | popupClassName?: string; 38 | expandTrigger?: "click" | "hover"; 39 | displayRender?: ( 40 | labels: string[], 41 | selectedOptions: CascaderOption[] 42 | ) => React.ReactNode; 43 | } 44 | 45 | function getStringLabel(option: CascaderOption): string { 46 | if (option.textLabel) return option.textLabel; 47 | if (typeof option.label === "string") return option.label; 48 | return option.value; 49 | } 50 | 51 | export function Cascader({ 52 | options, 53 | value, 54 | defaultValue, 55 | onChange, 56 | placeholder = "Please select", 57 | disabled = false, 58 | allowClear = true, 59 | className, 60 | popupClassName, 61 | expandTrigger = "click", 62 | displayRender, 63 | }: CascaderProps) { 64 | const [open, setOpen] = React.useState(false); 65 | const [internalValue, setInternalValue] = React.useState( 66 | defaultValue || [] 67 | ); 68 | const [expandedPath, setExpandedPath] = React.useState([]); 69 | const [focusedColumn, setFocusedColumn] = React.useState(0); 70 | const [focusedIndex, setFocusedIndex] = React.useState(0); 71 | const isMobile = useIsMobile(); 72 | const scrollContainerRef = React.useRef(null); 73 | const columnRefs = React.useRef>(new Map()); 74 | 75 | const selectedValue = value !== undefined ? value : internalValue; 76 | 77 | const getColumns = React.useCallback(() => { 78 | const columns: CascaderOption[][] = [options]; 79 | let currentOptions = options; 80 | 81 | for (const val of expandedPath) { 82 | const found = currentOptions.find((opt) => opt.value === val); 83 | if (found?.children) { 84 | columns.push(found.children); 85 | currentOptions = found.children; 86 | } else { 87 | break; 88 | } 89 | } 90 | 91 | return columns; 92 | }, [options, expandedPath]); 93 | 94 | const getSelectedOptions = React.useCallback( 95 | (vals: string[]): CascaderOption[] => { 96 | const result: CascaderOption[] = []; 97 | let currentOptions = options; 98 | 99 | for (const val of vals) { 100 | const found = currentOptions.find((opt) => opt.value === val); 101 | if (found) { 102 | result.push(found); 103 | currentOptions = found.children || []; 104 | } else { 105 | break; 106 | } 107 | } 108 | 109 | return result; 110 | }, 111 | [options] 112 | ); 113 | 114 | const selectedOptions = getSelectedOptions(selectedValue); 115 | const displayLabels = selectedOptions.map((opt) => getStringLabel(opt)); 116 | 117 | const handleSelect = (option: CascaderOption, columnIndex: number) => { 118 | if (option.disabled) return; 119 | 120 | const newPath = [...expandedPath.slice(0, columnIndex), option.value]; 121 | 122 | if (option.children && option.children.length > 0) { 123 | setExpandedPath(newPath); 124 | setFocusedColumn(columnIndex + 1); 125 | setFocusedIndex(0); 126 | setTimeout(() => { 127 | if (scrollContainerRef.current) { 128 | scrollContainerRef.current.scrollTo({ 129 | left: scrollContainerRef.current.scrollWidth, 130 | behavior: "smooth", 131 | }); 132 | } 133 | const key = `${columnIndex + 1}-0`; 134 | columnRefs.current.get(key)?.focus(); 135 | }, 50); 136 | } else { 137 | const newSelectedOptions = getSelectedOptions(newPath); 138 | if (value === undefined) { 139 | setInternalValue(newPath); 140 | } 141 | onChange?.(newPath, newSelectedOptions); 142 | setOpen(false); 143 | setExpandedPath([]); 144 | } 145 | }; 146 | 147 | const handleExpand = (option: CascaderOption, columnIndex: number) => { 148 | if (option.disabled) return; 149 | const newPath = [...expandedPath.slice(0, columnIndex), option.value]; 150 | setExpandedPath(newPath); 151 | setTimeout(() => { 152 | if (scrollContainerRef.current) { 153 | scrollContainerRef.current.scrollTo({ 154 | left: scrollContainerRef.current.scrollWidth, 155 | behavior: "smooth", 156 | }); 157 | } 158 | }, 50); 159 | }; 160 | 161 | const handleClear = (e: React.MouseEvent) => { 162 | e.preventDefault(); 163 | e.stopPropagation(); 164 | if (value === undefined) { 165 | setInternalValue([]); 166 | } 167 | onChange?.([], []); 168 | setExpandedPath([]); 169 | setOpen(false); 170 | }; 171 | 172 | const handleKeyDown = ( 173 | e: React.KeyboardEvent, 174 | option: CascaderOption, 175 | columnIndex: number, 176 | itemIndex: number, 177 | columns: CascaderOption[][] // Pass columns as parameter 178 | ) => { 179 | const column = columns[columnIndex]; // Use columns parameter instead of options 180 | const hasChildren = option.children && option.children.length > 0; 181 | 182 | switch (e.key) { 183 | case "ArrowDown": 184 | e.preventDefault(); 185 | if (itemIndex < column.length - 1) { 186 | const nextIndex = itemIndex + 1; 187 | setFocusedIndex(nextIndex); 188 | const key = `${columnIndex}-${nextIndex}`; 189 | columnRefs.current.get(key)?.focus(); 190 | } 191 | break; 192 | 193 | case "ArrowUp": 194 | e.preventDefault(); 195 | if (itemIndex > 0) { 196 | const prevIndex = itemIndex - 1; 197 | setFocusedIndex(prevIndex); 198 | const key = `${columnIndex}-${prevIndex}`; 199 | columnRefs.current.get(key)?.focus(); 200 | } 201 | break; 202 | 203 | case "ArrowRight": 204 | case "Enter": 205 | e.preventDefault(); 206 | if (!option.disabled) { 207 | if (hasChildren) { 208 | handleSelect(option, columnIndex); 209 | } else if (e.key === "Enter") { 210 | handleSelect(option, columnIndex); 211 | } 212 | } 213 | break; 214 | 215 | case "ArrowLeft": 216 | case "Backspace": 217 | e.preventDefault(); 218 | if (columnIndex > 0) { 219 | const newPath = expandedPath.slice(0, columnIndex - 1); 220 | setExpandedPath(newPath); 221 | setFocusedColumn(columnIndex - 1); 222 | const parentColumn = columns[columnIndex - 1]; // Use columns parameter 223 | const parentValue = expandedPath[columnIndex - 1]; 224 | const parentIndex = parentColumn.findIndex( 225 | (opt) => opt.value === parentValue 226 | ); 227 | setFocusedIndex(parentIndex >= 0 ? parentIndex : 0); 228 | setTimeout(() => { 229 | const key = `${columnIndex - 1}-${ 230 | parentIndex >= 0 ? parentIndex : 0 231 | }`; 232 | columnRefs.current.get(key)?.focus(); 233 | }, 50); 234 | } 235 | break; 236 | 237 | case "Escape": 238 | e.preventDefault(); 239 | setOpen(false); 240 | setExpandedPath([]); 241 | break; 242 | 243 | case "Tab": 244 | if ( 245 | !e.shiftKey && 246 | hasChildren && 247 | expandedPath[columnIndex] === option.value 248 | ) { 249 | e.preventDefault(); 250 | setFocusedColumn(columnIndex + 1); 251 | setFocusedIndex(0); 252 | const key = `${columnIndex + 1}-0`; 253 | columnRefs.current.get(key)?.focus(); 254 | } else if (e.shiftKey && columnIndex > 0) { 255 | e.preventDefault(); 256 | const parentColumn = columns[columnIndex - 1]; // Use columns parameter 257 | const parentValue = expandedPath[columnIndex - 1]; 258 | const parentIndex = parentColumn.findIndex( 259 | (opt) => opt.value === parentValue 260 | ); 261 | setFocusedColumn(columnIndex - 1); 262 | setFocusedIndex(parentIndex >= 0 ? parentIndex : 0); 263 | const key = `${columnIndex - 1}-${ 264 | parentIndex >= 0 ? parentIndex : 0 265 | }`; 266 | columnRefs.current.get(key)?.focus(); 267 | } 268 | break; 269 | } 270 | }; 271 | 272 | const displayValue = 273 | displayLabels.length > 0 274 | ? displayRender 275 | ? displayRender(displayLabels, selectedOptions) 276 | : displayLabels.join(" / ") 277 | : null; 278 | 279 | const triggerElement = ( 280 |
{ 296 | if (e.key === "Enter" || e.key === " ") { 297 | e.preventDefault(); 298 | if (!disabled) setOpen(!open); 299 | } 300 | }} 301 | > 302 | 303 | {displayValue || placeholder} 304 | 305 |
306 | {allowClear && displayValue && !disabled && ( 307 | 312 | )} 313 |
315 |
316 | ); 317 | 318 | const columns = getColumns(); 319 | 320 | const columnsContent = ( 321 |
327 | {columns.map( 328 | ( 329 | column, 330 | columnIndex // Iterate over columns instead of options 331 | ) => ( 332 |
341 | {column.map((option, itemIndex) => { 342 | const isExpanded = expandedPath[columnIndex] === option.value; 343 | const isSelected = selectedValue[columnIndex] === option.value; 344 | const hasChildren = option.children && option.children.length > 0; 345 | const isFocused = 346 | focusedColumn === columnIndex && focusedIndex === itemIndex; 347 | const refKey = `${columnIndex}-${itemIndex}`; 348 | 349 | return ( 350 |
{ 353 | if (el) { 354 | columnRefs.current.set(refKey, el); 355 | } else { 356 | columnRefs.current.delete(refKey); 357 | } 358 | }} 359 | role="option" 360 | aria-selected={isSelected} 361 | aria-disabled={option.disabled} 362 | aria-expanded={hasChildren ? isExpanded : undefined} 363 | tabIndex={isFocused && open ? 0 : -1} 364 | className={cn( 365 | "flex items-center justify-between px-3 py-1.5 cursor-pointer text-sm", 366 | "hover:bg-accent hover:text-accent-foreground", 367 | "focus:bg-accent focus:text-accent-foreground focus:outline-none", 368 | isSelected && "bg-accent text-accent-foreground", 369 | isExpanded && "bg-accent/50", 370 | option.disabled && "opacity-50 cursor-not-allowed" 371 | )} 372 | onClick={() => handleSelect(option, columnIndex)} 373 | onKeyDown={(e) => 374 | handleKeyDown(e, option, columnIndex, itemIndex, columns) 375 | } // Pass columns 376 | onMouseEnter={() => { 377 | if (expandTrigger === "hover" && hasChildren) { 378 | handleExpand(option, columnIndex); 379 | } 380 | }} 381 | onFocus={() => { 382 | setFocusedColumn(columnIndex); 383 | setFocusedIndex(itemIndex); 384 | }} 385 | > 386 | {option.label} 387 | {hasChildren && ( 388 |
394 | ); 395 | })} 396 |
397 | ) 398 | )} 399 |
400 | ); 401 | 402 | const handleOpenChange = (newOpen: boolean) => { 403 | setOpen(newOpen); 404 | if (newOpen) { 405 | setExpandedPath( 406 | selectedValue.slice(0, -1).length > 0 407 | ? selectedValue.slice(0, -1) 408 | : selectedValue 409 | ); 410 | setFocusedColumn(0); 411 | setFocusedIndex(0); 412 | setTimeout(() => { 413 | const key = `0-0`; 414 | columnRefs.current.get(key)?.focus(); 415 | }, 50); 416 | } else { 417 | setExpandedPath([]); 418 | } 419 | }; 420 | 421 | if (isMobile) { 422 | return ( 423 | 424 | 425 | {triggerElement} 426 | 427 | 428 | 429 | 430 | {placeholder} 431 | 432 | 433 |
{columnsContent}
434 |
435 |
436 | ); 437 | } 438 | 439 | return ( 440 | 441 | 442 | {triggerElement} 443 | 444 | 448 | {columnsContent} 449 | 450 | 451 | ); 452 | } 453 | -------------------------------------------------------------------------------- /public/r/cascader.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://ui.shadcn.com/schema/registry-item.json", 3 | "name": "cascader", 4 | "type": "registry:component", 5 | "title": "Cascader", 6 | "description": "A cascading dropdown menu component for selecting hierarchical data like locations, categories, or organizational structures.", 7 | "dependencies": [ 8 | "lucide-react" 9 | ], 10 | "registryDependencies": [ 11 | "popover", 12 | "drawer", 13 | "use-mobile" 14 | ], 15 | "files": [ 16 | { 17 | "path": "src/components/ui/cascader.tsx", 18 | "content": "\"use client\";\r\n\r\nimport * as React from \"react\";\r\nimport { ChevronRight, ChevronDown, X } from \"lucide-react\";\r\nimport { cn } from \"@/lib/utils\";\r\nimport {\r\n Popover,\r\n PopoverContent,\r\n PopoverTrigger,\r\n} from \"@/components/ui/popover\";\r\nimport {\r\n Drawer,\r\n DrawerContent,\r\n DrawerHeader,\r\n DrawerTitle,\r\n DrawerTrigger,\r\n} from \"@/components/ui/drawer\";\r\nimport { useIsMobile } from \"@/hooks/use-mobile\";\r\n\r\nexport interface CascaderOption {\r\n value: string;\r\n label: React.ReactNode;\r\n textLabel?: string;\r\n disabled?: boolean;\r\n children?: CascaderOption[];\r\n}\r\n\r\nexport interface CascaderProps {\r\n options: CascaderOption[];\r\n value?: string[];\r\n defaultValue?: string[];\r\n onChange?: (value: string[], selectedOptions: CascaderOption[]) => void;\r\n placeholder?: string;\r\n disabled?: boolean;\r\n allowClear?: boolean;\r\n className?: string;\r\n popupClassName?: string;\r\n expandTrigger?: \"click\" | \"hover\";\r\n displayRender?: (\r\n labels: string[],\r\n selectedOptions: CascaderOption[]\r\n ) => React.ReactNode;\r\n}\r\n\r\nfunction getStringLabel(option: CascaderOption): string {\r\n if (option.textLabel) return option.textLabel;\r\n if (typeof option.label === \"string\") return option.label;\r\n return option.value;\r\n}\r\n\r\nexport function Cascader({\r\n options,\r\n value,\r\n defaultValue,\r\n onChange,\r\n placeholder = \"Please select\",\r\n disabled = false,\r\n allowClear = true,\r\n className,\r\n popupClassName,\r\n expandTrigger = \"click\",\r\n displayRender,\r\n}: CascaderProps) {\r\n const [open, setOpen] = React.useState(false);\r\n const [internalValue, setInternalValue] = React.useState(\r\n defaultValue || []\r\n );\r\n const [expandedPath, setExpandedPath] = React.useState([]);\r\n const [focusedColumn, setFocusedColumn] = React.useState(0);\r\n const [focusedIndex, setFocusedIndex] = React.useState(0);\r\n const isMobile = useIsMobile();\r\n const scrollContainerRef = React.useRef(null);\r\n const columnRefs = React.useRef>(new Map());\r\n\r\n const selectedValue = value !== undefined ? value : internalValue;\r\n\r\n const getColumns = React.useCallback(() => {\r\n const columns: CascaderOption[][] = [options];\r\n let currentOptions = options;\r\n\r\n for (const val of expandedPath) {\r\n const found = currentOptions.find((opt) => opt.value === val);\r\n if (found?.children) {\r\n columns.push(found.children);\r\n currentOptions = found.children;\r\n } else {\r\n break;\r\n }\r\n }\r\n\r\n return columns;\r\n }, [options, expandedPath]);\r\n\r\n const getSelectedOptions = React.useCallback(\r\n (vals: string[]): CascaderOption[] => {\r\n const result: CascaderOption[] = [];\r\n let currentOptions = options;\r\n\r\n for (const val of vals) {\r\n const found = currentOptions.find((opt) => opt.value === val);\r\n if (found) {\r\n result.push(found);\r\n currentOptions = found.children || [];\r\n } else {\r\n break;\r\n }\r\n }\r\n\r\n return result;\r\n },\r\n [options]\r\n );\r\n\r\n const selectedOptions = getSelectedOptions(selectedValue);\r\n const displayLabels = selectedOptions.map((opt) => getStringLabel(opt));\r\n\r\n const handleSelect = (option: CascaderOption, columnIndex: number) => {\r\n if (option.disabled) return;\r\n\r\n const newPath = [...expandedPath.slice(0, columnIndex), option.value];\r\n\r\n if (option.children && option.children.length > 0) {\r\n setExpandedPath(newPath);\r\n setFocusedColumn(columnIndex + 1);\r\n setFocusedIndex(0);\r\n setTimeout(() => {\r\n if (scrollContainerRef.current) {\r\n scrollContainerRef.current.scrollTo({\r\n left: scrollContainerRef.current.scrollWidth,\r\n behavior: \"smooth\",\r\n });\r\n }\r\n const key = `${columnIndex + 1}-0`;\r\n columnRefs.current.get(key)?.focus();\r\n }, 50);\r\n } else {\r\n const newSelectedOptions = getSelectedOptions(newPath);\r\n if (value === undefined) {\r\n setInternalValue(newPath);\r\n }\r\n onChange?.(newPath, newSelectedOptions);\r\n setOpen(false);\r\n setExpandedPath([]);\r\n }\r\n };\r\n\r\n const handleExpand = (option: CascaderOption, columnIndex: number) => {\r\n if (option.disabled) return;\r\n const newPath = [...expandedPath.slice(0, columnIndex), option.value];\r\n setExpandedPath(newPath);\r\n setTimeout(() => {\r\n if (scrollContainerRef.current) {\r\n scrollContainerRef.current.scrollTo({\r\n left: scrollContainerRef.current.scrollWidth,\r\n behavior: \"smooth\",\r\n });\r\n }\r\n }, 50);\r\n };\r\n\r\n const handleClear = (e: React.MouseEvent) => {\r\n e.preventDefault();\r\n e.stopPropagation();\r\n if (value === undefined) {\r\n setInternalValue([]);\r\n }\r\n onChange?.([], []);\r\n setExpandedPath([]);\r\n setOpen(false);\r\n };\r\n\r\n const handleKeyDown = (\r\n e: React.KeyboardEvent,\r\n option: CascaderOption,\r\n columnIndex: number,\r\n itemIndex: number,\r\n columns: CascaderOption[][] // Pass columns as parameter\r\n ) => {\r\n const column = columns[columnIndex]; // Use columns parameter instead of options\r\n const hasChildren = option.children && option.children.length > 0;\r\n\r\n switch (e.key) {\r\n case \"ArrowDown\":\r\n e.preventDefault();\r\n if (itemIndex < column.length - 1) {\r\n const nextIndex = itemIndex + 1;\r\n setFocusedIndex(nextIndex);\r\n const key = `${columnIndex}-${nextIndex}`;\r\n columnRefs.current.get(key)?.focus();\r\n }\r\n break;\r\n\r\n case \"ArrowUp\":\r\n e.preventDefault();\r\n if (itemIndex > 0) {\r\n const prevIndex = itemIndex - 1;\r\n setFocusedIndex(prevIndex);\r\n const key = `${columnIndex}-${prevIndex}`;\r\n columnRefs.current.get(key)?.focus();\r\n }\r\n break;\r\n\r\n case \"ArrowRight\":\r\n case \"Enter\":\r\n e.preventDefault();\r\n if (!option.disabled) {\r\n if (hasChildren) {\r\n handleSelect(option, columnIndex);\r\n } else if (e.key === \"Enter\") {\r\n handleSelect(option, columnIndex);\r\n }\r\n }\r\n break;\r\n\r\n case \"ArrowLeft\":\r\n case \"Backspace\":\r\n e.preventDefault();\r\n if (columnIndex > 0) {\r\n const newPath = expandedPath.slice(0, columnIndex - 1);\r\n setExpandedPath(newPath);\r\n setFocusedColumn(columnIndex - 1);\r\n const parentColumn = columns[columnIndex - 1]; // Use columns parameter\r\n const parentValue = expandedPath[columnIndex - 1];\r\n const parentIndex = parentColumn.findIndex(\r\n (opt) => opt.value === parentValue\r\n );\r\n setFocusedIndex(parentIndex >= 0 ? parentIndex : 0);\r\n setTimeout(() => {\r\n const key = `${columnIndex - 1}-${\r\n parentIndex >= 0 ? parentIndex : 0\r\n }`;\r\n columnRefs.current.get(key)?.focus();\r\n }, 50);\r\n }\r\n break;\r\n\r\n case \"Escape\":\r\n e.preventDefault();\r\n setOpen(false);\r\n setExpandedPath([]);\r\n break;\r\n\r\n case \"Tab\":\r\n if (\r\n !e.shiftKey &&\r\n hasChildren &&\r\n expandedPath[columnIndex] === option.value\r\n ) {\r\n e.preventDefault();\r\n setFocusedColumn(columnIndex + 1);\r\n setFocusedIndex(0);\r\n const key = `${columnIndex + 1}-0`;\r\n columnRefs.current.get(key)?.focus();\r\n } else if (e.shiftKey && columnIndex > 0) {\r\n e.preventDefault();\r\n const parentColumn = columns[columnIndex - 1]; // Use columns parameter\r\n const parentValue = expandedPath[columnIndex - 1];\r\n const parentIndex = parentColumn.findIndex(\r\n (opt) => opt.value === parentValue\r\n );\r\n setFocusedColumn(columnIndex - 1);\r\n setFocusedIndex(parentIndex >= 0 ? parentIndex : 0);\r\n const key = `${columnIndex - 1}-${\r\n parentIndex >= 0 ? parentIndex : 0\r\n }`;\r\n columnRefs.current.get(key)?.focus();\r\n }\r\n break;\r\n }\r\n };\r\n\r\n const displayValue =\r\n displayLabels.length > 0\r\n ? displayRender\r\n ? displayRender(displayLabels, selectedOptions)\r\n : displayLabels.join(\" / \")\r\n : null;\r\n\r\n const triggerElement = (\r\n {\r\n if (e.key === \"Enter\" || e.key === \" \") {\r\n e.preventDefault();\r\n if (!disabled) setOpen(!open);\r\n }\r\n }}\r\n >\r\n \r\n {displayValue || placeholder}\r\n \r\n
\r\n {allowClear && displayValue && !disabled && (\r\n \r\n )}\r\n \r\n
\r\n
\r\n );\r\n\r\n const columns = getColumns();\r\n\r\n const columnsContent = (\r\n \r\n {columns.map(\r\n (\r\n column,\r\n columnIndex // Iterate over columns instead of options\r\n ) => (\r\n \r\n {column.map((option, itemIndex) => {\r\n const isExpanded = expandedPath[columnIndex] === option.value;\r\n const isSelected = selectedValue[columnIndex] === option.value;\r\n const hasChildren = option.children && option.children.length > 0;\r\n const isFocused =\r\n focusedColumn === columnIndex && focusedIndex === itemIndex;\r\n const refKey = `${columnIndex}-${itemIndex}`;\r\n\r\n return (\r\n {\r\n if (el) {\r\n columnRefs.current.set(refKey, el);\r\n } else {\r\n columnRefs.current.delete(refKey);\r\n }\r\n }}\r\n role=\"option\"\r\n aria-selected={isSelected}\r\n aria-disabled={option.disabled}\r\n aria-expanded={hasChildren ? isExpanded : undefined}\r\n tabIndex={isFocused && open ? 0 : -1}\r\n className={cn(\r\n \"flex items-center justify-between px-3 py-1.5 cursor-pointer text-sm\",\r\n \"hover:bg-accent hover:text-accent-foreground\",\r\n \"focus:bg-accent focus:text-accent-foreground focus:outline-none\",\r\n isSelected && \"bg-accent text-accent-foreground\",\r\n isExpanded && \"bg-accent/50\",\r\n option.disabled && \"opacity-50 cursor-not-allowed\"\r\n )}\r\n onClick={() => handleSelect(option, columnIndex)}\r\n onKeyDown={(e) =>\r\n handleKeyDown(e, option, columnIndex, itemIndex, columns)\r\n } // Pass columns\r\n onMouseEnter={() => {\r\n if (expandTrigger === \"hover\" && hasChildren) {\r\n handleExpand(option, columnIndex);\r\n }\r\n }}\r\n onFocus={() => {\r\n setFocusedColumn(columnIndex);\r\n setFocusedIndex(itemIndex);\r\n }}\r\n >\r\n {option.label}\r\n {hasChildren && (\r\n \r\n )}\r\n
\r\n );\r\n })}\r\n
\r\n )\r\n )}\r\n \r\n );\r\n\r\n const handleOpenChange = (newOpen: boolean) => {\r\n setOpen(newOpen);\r\n if (newOpen) {\r\n setExpandedPath(\r\n selectedValue.slice(0, -1).length > 0\r\n ? selectedValue.slice(0, -1)\r\n : selectedValue\r\n );\r\n setFocusedColumn(0);\r\n setFocusedIndex(0);\r\n setTimeout(() => {\r\n const key = `0-0`;\r\n columnRefs.current.get(key)?.focus();\r\n }, 50);\r\n } else {\r\n setExpandedPath([]);\r\n }\r\n };\r\n\r\n if (isMobile) {\r\n return (\r\n \r\n \r\n {triggerElement}\r\n \r\n \r\n \r\n \r\n {placeholder}\r\n \r\n \r\n
{columnsContent}
\r\n
\r\n
\r\n );\r\n }\r\n\r\n return (\r\n \r\n \r\n {triggerElement}\r\n \r\n \r\n {columnsContent}\r\n \r\n \r\n );\r\n}\r\n", 19 | "type": "registry:component" 20 | }, 21 | { 22 | "path": "src/hooks/use-mobile.tsx", 23 | "content": "\"use client\";\r\n\r\nimport * as React from \"react\";\r\n\r\nconst MOBILE_BREAKPOINT = 768;\r\n\r\nexport function useIsMobile() {\r\n const [isMobile, setIsMobile] = React.useState(\r\n undefined\r\n );\r\n\r\n React.useEffect(() => {\r\n const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`);\r\n const onChange = () => {\r\n setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);\r\n };\r\n mql.addEventListener(\"change\", onChange);\r\n setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);\r\n return () => mql.removeEventListener(\"change\", onChange);\r\n }, []);\r\n\r\n return !!isMobile;\r\n}\r\n", 24 | "type": "registry:hook" 25 | } 26 | ] 27 | } -------------------------------------------------------------------------------- /src/App.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import type React from "react"; 4 | 5 | import { useState } from "react"; 6 | import { Cascader, type CascaderOption } from "@/components/ui/cascader"; 7 | import { 8 | MapPin, 9 | Building2, 10 | Waves, 11 | Cookie, 12 | Phone, 13 | ToolCase, 14 | PersonStanding, 15 | Footprints, 16 | Laptop, 17 | TruckElectricIcon, 18 | Venus, 19 | Shirt, 20 | Smartphone, 21 | Github, 22 | } from "lucide-react"; 23 | 24 | import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; 25 | import { ThemeProvider } from "./components/theme-provider"; 26 | import { CodeBlock } from "./components/ui/code-block"; 27 | 28 | const options: CascaderOption[] = [ 29 | { 30 | value: "usa", 31 | label: "USA", 32 | children: [ 33 | { 34 | value: "new_york", 35 | label: "New York", 36 | children: [{ value: "statue_of_liberty", label: "Statue of Liberty" }], 37 | }, 38 | { 39 | value: "san_francisco", 40 | label: "San Francisco", 41 | children: [{ value: "golden_gate", label: "Golden Gate Bridge" }], 42 | }, 43 | ], 44 | }, 45 | { 46 | value: "france", 47 | label: "France", 48 | children: [ 49 | { 50 | value: "paris", 51 | label: "Paris", 52 | children: [{ value: "eiffel_tower", label: "Eiffel Tower" }], 53 | }, 54 | { 55 | value: "lyon", 56 | label: "Lyon", 57 | children: [ 58 | { value: "basilica_of_fourviere", label: "Basilica of Fourvière" }, 59 | ], 60 | }, 61 | ], 62 | }, 63 | ]; 64 | 65 | const disabledOptions: CascaderOption[] = [ 66 | { 67 | value: "usa", 68 | label: "USA", 69 | children: [ 70 | { 71 | value: "new_york", 72 | label: "New York", 73 | disabled: true, 74 | children: [{ value: "statue_of_liberty", label: "Statue of Liberty" }], 75 | }, 76 | { 77 | value: "san_francisco", 78 | label: "San Francisco", 79 | children: [{ value: "golden_gate", label: "Golden Gate Bridge" }], 80 | }, 81 | ], 82 | }, 83 | { 84 | value: "france", 85 | label: "France", 86 | disabled: true, 87 | children: [ 88 | { 89 | value: "paris", 90 | label: "Paris", 91 | children: [{ value: "eiffel_tower", label: "Eiffel Tower" }], 92 | }, 93 | ], 94 | }, 95 | ]; 96 | 97 | const optionsWithIcons: CascaderOption[] = [ 98 | { 99 | value: "usa", 100 | label: ( 101 | 102 | 103 | USA 104 | 105 | ), 106 | textLabel: "USA", 107 | children: [ 108 | { 109 | value: "new_york", 110 | label: ( 111 | 112 | 113 | New York 114 | 115 | ), 116 | textLabel: "New York", 117 | children: [ 118 | { 119 | value: "statue_of_liberty", 120 | label: ( 121 | 122 | 123 | Statue of Liberty 124 | 125 | ), 126 | textLabel: "Statue of Liberty", 127 | }, 128 | ], 129 | }, 130 | ], 131 | }, 132 | { 133 | value: "france", 134 | label: ( 135 | 136 | 137 | France 138 | 139 | ), 140 | textLabel: "France", 141 | children: [ 142 | { 143 | value: "paris", 144 | label: ( 145 | 146 | 147 | Paris 148 | 149 | ), 150 | textLabel: "Paris", 151 | children: [ 152 | { 153 | value: "eiffel_tower", 154 | label: ( 155 | 156 | 157 | Eiffel Tower 158 | 159 | ), 160 | textLabel: "Eiffel Tower", 161 | }, 162 | ], 163 | }, 164 | ], 165 | }, 166 | ]; 167 | 168 | const demoOptions: CascaderOption[] = [ 169 | { 170 | value: "electronics", 171 | label: ( 172 | 173 | Electronics 174 | 175 | ), 176 | textLabel: "Electronics", 177 | children: [ 178 | { 179 | value: "computers", 180 | label: ( 181 | 182 | Computers 183 | 184 | ), 185 | textLabel: "Computers", 186 | children: [ 187 | { 188 | value: "laptops", 189 | label: "Laptops", 190 | children: [ 191 | { value: "gaming_laptops", label: "Gaming Laptops" }, 192 | { value: "ultrabooks", label: "Ultrabooks" }, 193 | { value: "2in1", label: "2-in-1 Laptops" }, 194 | ], 195 | }, 196 | { 197 | value: "desktops", 198 | label: "Desktops", 199 | children: [ 200 | { value: "all_in_one", label: "All-in-One" }, 201 | { value: "gaming_desktops", label: "Gaming Desktops" }, 202 | { value: "workstations", label: "Workstations" }, 203 | ], 204 | }, 205 | ], 206 | }, 207 | { 208 | value: "phones", 209 | label: ( 210 | 211 | Phones 212 | 213 | ), 214 | textLabel: "Phones", 215 | children: [ 216 | { 217 | value: "smartphones", 218 | label: ( 219 | 220 | Smartphones 221 | 222 | ), 223 | textLabel: "Smartphones", 224 | children: [ 225 | { value: "android", label: "Android" }, 226 | { value: "ios", label: "iOS" }, 227 | ], 228 | }, 229 | { 230 | value: "accessories", 231 | label: ( 232 | 233 | Accessories 234 | 235 | ), 236 | textLabel: "Accessories", 237 | children: [ 238 | { value: "chargers", label: "Chargers" }, 239 | { value: "cases", label: "Cases" }, 240 | { value: "headphones", label: "Headphones" }, 241 | ], 242 | }, 243 | ], 244 | }, 245 | ], 246 | }, 247 | { 248 | value: "fashion", 249 | label: ( 250 | 251 | Fashion 252 | 253 | ), 254 | textLabel: "Fashion", 255 | children: [ 256 | { 257 | value: "men", 258 | label: ( 259 | 260 | Men 261 | 262 | ), 263 | textLabel: "Men", 264 | children: [ 265 | { 266 | value: "clothing", 267 | label: "Clothing", 268 | children: [ 269 | { value: "shirts", label: "Shirts" }, 270 | { value: "pants", label: "Pants" }, 271 | { value: "jackets", label: "Jackets" }, 272 | ], 273 | }, 274 | { 275 | value: "shoes", 276 | label: ( 277 | 278 | Shoes 279 | 280 | ), 281 | textLabel: "Shoes", 282 | children: [ 283 | { value: "sneakers", label: "Sneakers" }, 284 | { value: "boots", label: "Boots" }, 285 | { value: "formal", label: "Formal Shoes" }, 286 | ], 287 | }, 288 | ], 289 | }, 290 | { 291 | value: "women", 292 | label: ( 293 | 294 | Women 295 | 296 | ), 297 | textLabel: "Women", 298 | children: [ 299 | { 300 | value: "clothing", 301 | label: "Clothing", 302 | children: [ 303 | { value: "dresses", label: "Dresses" }, 304 | { value: "skirts", label: "Skirts" }, 305 | { value: "blouses", label: "Blouses" }, 306 | ], 307 | }, 308 | { 309 | value: "shoes", 310 | label: ( 311 | 312 | Shoes 313 | 314 | ), 315 | textLabel: "Shoes", 316 | children: [ 317 | { value: "heels", label: "Heels" }, 318 | { value: "flats", label: "Flats" }, 319 | { value: "sandals", label: "Sandals" }, 320 | ], 321 | }, 322 | ], 323 | }, 324 | ], 325 | }, 326 | ]; 327 | 328 | function ExampleCard({ 329 | title, 330 | description, 331 | children, 332 | code, 333 | }: { 334 | title: string; 335 | description?: string; 336 | children: React.ReactNode; 337 | code: string; 338 | }) { 339 | return ( 340 |
341 |
342 |

{title}

343 | {description && ( 344 |

{description}

345 | )} 346 |
347 | 348 | 349 | 353 | Preview 354 | 355 | 359 | Code 360 | 361 | 362 | 363 | {children} 364 | 365 | 366 | 367 | 368 | 369 |
370 | ); 371 | } 372 | 373 | export default function DocsPage() { 374 | const [controlledValue, setControlledValue] = useState([]); 375 | 376 | return ( 377 | 378 |
379 |
380 | {/* Header */} 381 |
382 |

383 | Cascader-Shadcn 384 | 395 | 396 | Ademking/cascader-shadcn 397 | 398 |

399 | 400 |

401 | A cascading dropdown menu component for selecting hierarchical 402 | data like locations, categories, or organizational structures. 403 |

404 | 405 |

406 | Inspired by the Cascader components from{" "} 407 | 411 | Ant Design 412 | {" "} 413 | and{" "} 414 | 418 | React Suite 419 | 420 |

421 |
422 | 423 |
424 | 430 |
431 | 432 | {/* Installation */} 433 |
434 |

435 | Installation 436 |

437 | 438 | 439 | 440 | 444 | CLI 445 | 446 | 450 | Manual 451 | 452 | 453 | 454 | 459 | 460 | 461 | 462 |

463 | Copy/paste a new component at components/ui/cascader.tsx 464 |

465 | void; 492 | placeholder?: string; 493 | disabled?: boolean; 494 | allowClear?: boolean; 495 | className?: string; 496 | popupClassName?: string; 497 | expandTrigger?: "click" | "hover"; 498 | displayRender?: ( 499 | labels: string[], 500 | selectedOptions: CascaderOption[] 501 | ) => React.ReactNode; 502 | } 503 | 504 | function getStringLabel(option: CascaderOption): string { 505 | if (option.textLabel) return option.textLabel; 506 | if (typeof option.label === "string") return option.label; 507 | return option.value; 508 | } 509 | 510 | export function Cascader({ 511 | options, 512 | value, 513 | defaultValue, 514 | onChange, 515 | placeholder = "Please select", 516 | disabled = false, 517 | allowClear = true, 518 | className, 519 | popupClassName, 520 | expandTrigger = "click", 521 | displayRender, 522 | }: CascaderProps) { 523 | const [open, setOpen] = React.useState(false); 524 | const [internalValue, setInternalValue] = React.useState( 525 | defaultValue || [] 526 | ); 527 | const [expandedPath, setExpandedPath] = React.useState([]); 528 | 529 | const selectedValue = value !== undefined ? value : internalValue; 530 | 531 | // Get the columns to display based on expanded path 532 | const getColumns = React.useCallback(() => { 533 | const columns: CascaderOption[][] = [options]; 534 | let currentOptions = options; 535 | 536 | for (const val of expandedPath) { 537 | const found = currentOptions.find((opt) => opt.value === val); 538 | if (found?.children) { 539 | columns.push(found.children); 540 | currentOptions = found.children; 541 | } else { 542 | break; 543 | } 544 | } 545 | 546 | return columns; 547 | }, [options, expandedPath]); 548 | 549 | // Get selected options chain from value 550 | const getSelectedOptions = React.useCallback( 551 | (vals: string[]): CascaderOption[] => { 552 | const result: CascaderOption[] = []; 553 | let currentOptions = options; 554 | 555 | for (const val of vals) { 556 | const found = currentOptions.find((opt) => opt.value === val); 557 | if (found) { 558 | result.push(found); 559 | currentOptions = found.children || []; 560 | } else { 561 | break; 562 | } 563 | } 564 | 565 | return result; 566 | }, 567 | [options] 568 | ); 569 | 570 | const selectedOptions = getSelectedOptions(selectedValue); 571 | const displayLabels = selectedOptions.map((opt) => getStringLabel(opt)); 572 | 573 | const handleSelect = (option: CascaderOption, columnIndex: number) => { 574 | if (option.disabled) return; 575 | 576 | const newPath = [...expandedPath.slice(0, columnIndex), option.value]; 577 | 578 | if (option.children && option.children.length > 0) { 579 | // Has children, expand to show next column 580 | setExpandedPath(newPath); 581 | } else { 582 | // Leaf node, complete selection 583 | const newSelectedOptions = getSelectedOptions(newPath); 584 | if (value === undefined) { 585 | setInternalValue(newPath); 586 | } 587 | onChange?.(newPath, newSelectedOptions); 588 | setOpen(false); 589 | setExpandedPath([]); 590 | } 591 | }; 592 | 593 | const handleExpand = (option: CascaderOption, columnIndex: number) => { 594 | if (option.disabled) return; 595 | const newPath = [...expandedPath.slice(0, columnIndex), option.value]; 596 | setExpandedPath(newPath); 597 | }; 598 | 599 | const handleClear = (e: React.MouseEvent) => { 600 | e.preventDefault(); 601 | e.stopPropagation(); 602 | if (value === undefined) { 603 | setInternalValue([]); 604 | } 605 | onChange?.([], []); 606 | setExpandedPath([]); 607 | setOpen(false); 608 | }; 609 | 610 | const columns = getColumns(); 611 | 612 | // Reset expanded path when opening 613 | const handleOpenChange = (newOpen: boolean) => { 614 | setOpen(newOpen); 615 | if (newOpen) { 616 | // Initialize expanded path based on current selected value 617 | setExpandedPath( 618 | selectedValue.slice(0, -1).length > 0 619 | ? selectedValue.slice(0, -1) 620 | : selectedValue 621 | ); 622 | } else { 623 | setExpandedPath([]); 624 | } 625 | }; 626 | 627 | const displayValue = 628 | displayLabels.length > 0 629 | ? displayRender 630 | ? displayRender(displayLabels, selectedOptions) 631 | : displayLabels.join(" / ") 632 | : null; 633 | 634 | return ( 635 | 636 | 637 |
{ 652 | if (e.key === "Enter" || e.key === " ") { 653 | e.preventDefault(); 654 | if (!disabled) setOpen(!open); 655 | } 656 | }} 657 | > 658 | 659 | {displayValue || placeholder} 660 | 661 |
662 | {allowClear && displayValue && !disabled && ( 663 | 667 | )} 668 | 669 |
670 |
671 |
672 | 676 |
677 | {columns.map((column, columnIndex) => ( 678 |
685 | {column.map((option) => { 686 | const isExpanded = expandedPath[columnIndex] === option.value; 687 | const isSelected = selectedValue[columnIndex] === option.value; 688 | const hasChildren = 689 | option.children && option.children.length > 0; 690 | 691 | return ( 692 |
handleSelect(option, columnIndex)} 702 | onMouseEnter={() => { 703 | if (expandTrigger === "hover" && hasChildren) { 704 | handleExpand(option, columnIndex); 705 | } 706 | }} 707 | > 708 | {option.label} 709 | {hasChildren && ( 710 | 711 | )} 712 |
713 | ); 714 | })} 715 |
716 | ))} 717 |
718 |
719 |
720 | ); 721 | } 722 | `} 723 | /> 724 |
725 |
726 |
727 | 728 | {/* Usage */} 729 |
730 |

Usage

731 | { 757 | console.log(value, selectedOptions) 758 | }} 759 | placeholder="Select location" 760 | /> 761 | ) 762 | }`} 763 | /> 764 |
765 | 766 | {/* Examples */} 767 |
768 |

Examples

769 | 770 | { 776 | console.log(value, selectedOptions) 777 | }} 778 | placeholder="Please select" 779 | />`} 780 | > 781 | console.log("Selected:", val, opts)} 784 | placeholder="Please select" 785 | /> 786 | 787 | 788 | `} 796 | > 797 | 802 | 803 | 804 | ([]) 808 | 809 | setValue(val)} 813 | placeholder="Please select" 814 | /> 815 |

Selected: {value.join(" / ") || "None"}

`} 816 | > 817 |
818 | setControlledValue(val)} 822 | placeholder="Please select" 823 | /> 824 |

825 | Selected:{" "} 826 | {controlledValue.length > 0 827 | ? controlledValue.join(" / ") 828 | : "None"} 829 |

830 |
831 |
832 | 833 | `} 841 | > 842 | 847 | 848 | 849 | 859 | 860 | USA 861 | 862 | ), 863 | textLabel: "USA", // Used for display 864 | children: [ 865 | { 866 | value: "new_york", 867 | label: ( 868 | 869 | 870 | New York 871 | 872 | ), 873 | textLabel: "New York", 874 | children: [ 875 | { 876 | value: "statue_of_liberty", 877 | label: ( 878 | 879 | 880 | Statue of Liberty 881 | 882 | ), 883 | textLabel: "Statue of Liberty", 884 | }, 885 | ], 886 | }, 887 | ], 888 | }, 889 | ] 890 | 891 | `} 895 | > 896 | 900 | 901 | 902 | labels.join(" > ")} 908 | placeholder="Custom display" 909 | />`} 910 | > 911 | labels.join(" > ")} 914 | placeholder="Custom display" 915 | /> 916 | 917 | 918 | `} 943 | > 944 | 948 | 949 | 950 | `} 959 | > 960 | 966 | 967 | 968 | `} 976 | > 977 | 982 | 983 |
984 | 985 | {/* API Reference */} 986 |
987 |

988 | API Reference 989 |

990 | 991 |
992 |

CascaderOption

993 |
994 | 995 | 996 | 997 | 998 | 999 | 1000 | 1001 | 1002 | 1003 | 1004 | 1005 | 1008 | 1011 | 1012 | 1013 | 1014 | 1017 | 1020 | 1021 | 1022 | 1023 | 1026 | 1030 | 1031 | 1032 | 1033 | 1036 | 1039 | 1040 | 1041 | 1042 | 1045 | 1048 | 1049 | 1050 |
PropertyTypeDescription
value 1006 | string 1007 | 1009 | Unique identifier for the option 1010 |
label 1015 | React.ReactNode 1016 | 1018 | Display content for the option (string or component) 1019 |
textLabel 1024 | string 1025 | 1027 | String label for display rendering. Falls back to value 1028 | if not provided. 1029 |
disabled 1034 | boolean 1035 | 1037 | Whether the option is disabled 1038 |
children 1043 | CascaderOption[] 1044 | 1046 | Nested child options 1047 |
1051 |
1052 |
1053 | 1054 |
1055 |

Cascader Props

1056 |
1057 | 1058 | 1059 | 1060 | 1061 | 1062 | 1063 | 1064 | 1065 | 1066 | 1067 | 1068 | 1069 | 1072 | 1075 | 1078 | 1079 | 1080 | 1081 | 1084 | 1087 | 1090 | 1091 | 1092 | 1093 | 1096 | 1099 | 1102 | 1103 | 1104 | 1105 | 1108 | 1111 | 1114 | 1115 | 1116 | 1117 | 1120 | 1123 | 1126 | 1127 | 1128 | 1129 | 1132 | 1135 | 1138 | 1139 | 1140 | 1141 | 1144 | 1147 | 1150 | 1151 | 1152 | 1153 | 1156 | 1159 | 1162 | 1163 | 1164 | 1165 | 1168 | 1171 | 1174 | 1175 | 1176 | 1177 | 1180 | 1183 | 1186 | 1187 | 1188 | 1189 | 1192 | 1195 | 1198 | 1199 | 1200 |
PropTypeDefaultDescription
options 1070 | CascaderOption[] 1071 | 1073 | - 1074 | 1076 | The data options for the cascader 1077 |
value 1082 | string[] 1083 | 1085 | - 1086 | 1088 | Controlled selected value 1089 |
defaultValue 1094 | string[] 1095 | 1097 | - 1098 | 1100 | Initial selected value 1101 |
onChange 1106 | (value, options) => void 1107 | 1109 | - 1110 | 1112 | Callback when selection changes 1113 |
placeholder 1118 | string 1119 | 1121 | "Please select" 1122 | 1124 | Placeholder text 1125 |
disabled 1130 | boolean 1131 | 1133 | false 1134 | 1136 | Disable the cascader 1137 |
allowClear 1142 | boolean 1143 | 1145 | true 1146 | 1148 | Show clear button 1149 |
expandTrigger 1154 | "click" | "hover" 1155 | 1157 | "click" 1158 | 1160 | How to expand child options 1161 |
displayRender 1166 | (labels, options) => ReactNode 1167 | 1169 | - 1170 | 1172 | Custom render for display 1173 |
className 1178 | string 1179 | 1181 | - 1182 | 1184 | Custom class for trigger 1185 |
popupClassName 1190 | string 1191 | 1193 | - 1194 | 1196 | Custom class for dropdown 1197 |
1201 |
1202 |
1203 |
1204 | 1205 | {/* Footer */} 1206 | 1217 |
1218 |
1219 |
1220 | ); 1221 | } 1222 | --------------------------------------------------------------------------------