From 80144a4092b6394d27376ba5d5bd09363c775bee Mon Sep 17 00:00:00 2001 From: Rodrigo Verdiani Date: Sat, 18 Apr 2026 14:49:24 -0300 Subject: [PATCH] feat: enhance responsive layout and add filter functionality with new components --- package-lock.json | 131 +- package.json | 7 +- src/components/Pagination.tsx | 88 +- src/components/ui/command.tsx | 184 +++ src/components/ui/multi-select.tsx | 1059 +++++++++++++++++ src/components/ui/popover.tsx | 87 ++ src/components/ui/sheet.tsx | 119 ++ src/contexts/UIStateContext.tsx | 2 +- .../home/components/FilterSidebar.tsx | 285 ++--- src/features/home/components/MangaGrid.tsx | 2 +- src/hooks/useDynamicPageSize.ts | 8 +- src/pages/Home.tsx | 96 +- 12 files changed, 1804 insertions(+), 264 deletions(-) create mode 100644 src/components/ui/command.tsx create mode 100644 src/components/ui/multi-select.tsx create mode 100644 src/components/ui/popover.tsx create mode 100644 src/components/ui/sheet.tsx diff --git a/package-lock.json b/package-lock.json index a65c15b..13bd900 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,8 +14,9 @@ "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-label": "^2.1.7", + "@radix-ui/react-popover": "^1.1.15", "@radix-ui/react-select": "^2.2.6", - "@radix-ui/react-separator": "^1.1.7", + "@radix-ui/react-separator": "^1.1.8", "@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-switch": "^1.2.6", "@radix-ui/react-tabs": "^1.1.13", @@ -24,6 +25,7 @@ "axios": "^1.13.1", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "cmdk": "^1.1.1", "lucide-react": "^0.548.0", "next-themes": "^0.4.6", "orval": "^7.13.2", @@ -34,7 +36,7 @@ "react-hook-form": "^7.65.0", "react-router": "^7.9.5", "sonner": "^2.0.7", - "tailwind-merge": "^3.3.1", + "tailwind-merge": "^3.5.0", "tailwindcss": "^4.1.16", "use-debounce": "^10.0.6", "zod": "^4.1.12" @@ -42,7 +44,7 @@ "devDependencies": { "@biomejs/biome": "2.4.9", "@eslint/js": "^9.36.0", - "@types/node": "^24.9.2", + "@types/node": "^24.12.2", "@types/react": "^19.1.16", "@types/react-dom": "^19.1.9", "@vitejs/plugin-react": "^5.0.4", @@ -2662,12 +2664,12 @@ } }, "node_modules/@radix-ui/react-separator": { - "version": "1.1.7", - "resolved": "https://registry.npmjs.org/@radix-ui/react-separator/-/react-separator-1.1.7.tgz", - "integrity": "sha512-0HEb8R9E8A+jZjvmFCy/J4xhbXy3TV+9XSnGJ3KvTtjlIUy/YQ/p6UYZvi7YbeoeXdyU9+Y3scizK6hkY37baA==", + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/@radix-ui/react-separator/-/react-separator-1.1.8.tgz", + "integrity": "sha512-sDvqVY4itsKwwSMEe0jtKgfTh+72Sy3gPmQpjqcQneqQ4PFmr/1I0YA+2/puilhggCe2gJcx5EBAYFkWkdpa5g==", "license": "MIT", "dependencies": { - "@radix-ui/react-primitive": "2.1.3" + "@radix-ui/react-primitive": "2.1.4" }, "peerDependencies": { "@types/react": "*", @@ -2684,6 +2686,47 @@ } } }, + "node_modules/@radix-ui/react-separator/node_modules/@radix-ui/react-primitive": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.4.tgz", + "integrity": "sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-separator/node_modules/@radix-ui/react-slot": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.4.tgz", + "integrity": "sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-slider": { "version": "1.3.6", "resolved": "https://registry.npmjs.org/@radix-ui/react-slider/-/react-slider-1.3.6.tgz", @@ -2911,6 +2954,29 @@ } } }, + "node_modules/@radix-ui/react-toolbar/node_modules/@radix-ui/react-separator": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-separator/-/react-separator-1.1.7.tgz", + "integrity": "sha512-0HEb8R9E8A+jZjvmFCy/J4xhbXy3TV+9XSnGJ3KvTtjlIUy/YQ/p6UYZvi7YbeoeXdyU9+Y3scizK6hkY37baA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-tooltip": { "version": "1.2.8", "resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.2.8.tgz", @@ -4333,9 +4399,9 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "24.9.2", - "resolved": "https://registry.npmjs.org/@types/node/-/node-24.9.2.tgz", - "integrity": "sha512-uWN8YqxXxqFMX2RqGOrumsKeti4LlmIMIyV0lgut4jx7KQBcBiW6vkDtIBvHnHIquwNfJhk8v2OtmO8zXWHfPA==", + "version": "24.12.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.12.2.tgz", + "integrity": "sha512-A1sre26ke7HDIuY/M23nd9gfB+nrmhtYyMINbjI1zHJxYteKR6qSMX56FsmjMcDb3SMcjJg5BiRRgOCC/yBD0g==", "license": "MIT", "dependencies": { "undici-types": "~7.16.0" @@ -5124,6 +5190,22 @@ "node": ">=6" } }, + "node_modules/cmdk": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cmdk/-/cmdk-1.1.1.tgz", + "integrity": "sha512-Vsv7kFaXm+ptHDMZ7izaRsP70GgrW9NBNGswt9OZaVBLlE0SNpDq8eu/VGXyF9r7M0azK3Wy7OlYXsuyYLFzHg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "^1.1.1", + "@radix-ui/react-dialog": "^1.1.6", + "@radix-ui/react-id": "^1.1.0", + "@radix-ui/react-primitive": "^2.0.2" + }, + "peerDependencies": { + "react": "^18 || ^19 || ^19.0.0-rc", + "react-dom": "^18 || ^19 || ^19.0.0-rc" + } + }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -8159,6 +8241,29 @@ } } }, + "node_modules/radix-ui/node_modules/@radix-ui/react-separator": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-separator/-/react-separator-1.1.7.tgz", + "integrity": "sha512-0HEb8R9E8A+jZjvmFCy/J4xhbXy3TV+9XSnGJ3KvTtjlIUy/YQ/p6UYZvi7YbeoeXdyU9+Y3scizK6hkY37baA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/react": { "version": "19.2.0", "resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz", @@ -8960,9 +9065,9 @@ } }, "node_modules/tailwind-merge": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.3.1.tgz", - "integrity": "sha512-gBXpgUm/3rp1lMZZrM/w7D8GKqshif0zAymAhbCyIt8KMe+0v9DQ7cdYLR4FHH/cKpdTXb+A/tKKU3eolfsI+g==", + "version": "3.5.0", + "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.5.0.tgz", + "integrity": "sha512-I8K9wewnVDkL1NTGoqWmVEIlUcB9gFriAEkXkfCjX5ib8ezGxtR3xD7iZIxrfArjEsH7F1CHD4RFUtxefdqV/A==", "license": "MIT", "funding": { "type": "github", diff --git a/package.json b/package.json index 03583a6..7f937cd 100644 --- a/package.json +++ b/package.json @@ -16,8 +16,9 @@ "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-label": "^2.1.7", + "@radix-ui/react-popover": "^1.1.15", "@radix-ui/react-select": "^2.2.6", - "@radix-ui/react-separator": "^1.1.7", + "@radix-ui/react-separator": "^1.1.8", "@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-switch": "^1.2.6", "@radix-ui/react-tabs": "^1.1.13", @@ -26,6 +27,7 @@ "axios": "^1.13.1", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "cmdk": "^1.1.1", "lucide-react": "^0.548.0", "next-themes": "^0.4.6", "orval": "^7.13.2", @@ -36,7 +38,7 @@ "react-hook-form": "^7.65.0", "react-router": "^7.9.5", "sonner": "^2.0.7", - "tailwind-merge": "^3.3.1", + "tailwind-merge": "^3.5.0", "tailwindcss": "^4.1.16", "use-debounce": "^10.0.6", "zod": "^4.1.12" @@ -44,7 +46,6 @@ "devDependencies": { "@biomejs/biome": "2.4.9", "@eslint/js": "^9.36.0", - "@types/node": "^24.9.2", "@types/react": "^19.1.16", "@types/react-dom": "^19.1.9", "@vitejs/plugin-react": "^5.0.4", diff --git a/src/components/Pagination.tsx b/src/components/Pagination.tsx index cfae130..4cffefe 100644 --- a/src/components/Pagination.tsx +++ b/src/components/Pagination.tsx @@ -1,5 +1,6 @@ import { ChevronLeft, ChevronRight } from "lucide-react"; import { Button } from "@/components/ui/button"; +import useWindowDimensions from "@/hooks/useWindowDimensions"; interface PaginationProps { currentPage: number; @@ -7,11 +8,43 @@ interface PaginationProps { onPageChange: (page: number) => void; } -export const Pagination = ({ - currentPage, - totalPages, - onPageChange, -}: PaginationProps) => { +export const Pagination = ({ currentPage, totalPages, onPageChange }: PaginationProps) => { + const { width } = useWindowDimensions(); + + const prevButton = ( + + ); + + const nextButton = ( + + ); + + if (width < 768) { + return ( +
+ {prevButton} + + Page {currentPage} of {totalPages} + + {nextButton} +
+ ); + } + const getPageNumbers = () => { const pages: (number | string)[] = []; const showEllipsis = totalPages > 7; @@ -20,45 +53,20 @@ export const Pagination = ({ return Array.from({ length: totalPages }, (_, i) => i + 1); } - // Always show first page pages.push(1); - - if (currentPage > 3) { - pages.push("..."); - } - - // Show pages around current page - for ( - let i = Math.max(2, currentPage - 1); - i <= Math.min(totalPages - 1, currentPage + 1); - i++ - ) { + if (currentPage > 3) pages.push("..."); + for (let i = Math.max(2, currentPage - 1); i <= Math.min(totalPages - 1, currentPage + 1); i++) { pages.push(i); } - - if (currentPage < totalPages - 2) { - pages.push("..."); - } - - // Always show last page - if (totalPages > 1) { - pages.push(totalPages); - } + if (currentPage < totalPages - 2) pages.push("..."); + if (totalPages > 1) pages.push(totalPages); return pages; }; return (
- - + {prevButton} {getPageNumbers().map((page, index) => (
{page === "..." ? ( @@ -74,15 +82,7 @@ export const Pagination = ({ )}
))} - - + {nextButton}
); }; diff --git a/src/components/ui/command.tsx b/src/components/ui/command.tsx new file mode 100644 index 0000000..8fe3ccb --- /dev/null +++ b/src/components/ui/command.tsx @@ -0,0 +1,184 @@ +"use client" + +import * as React from "react" +import { Command as CommandPrimitive } from "cmdk" +import { SearchIcon } from "lucide-react" + +import { cn } from "@/lib/utils" +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog" + +function Command({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function CommandDialog({ + title = "Command Palette", + description = "Search for a command to run...", + children, + className, + showCloseButton = true, + ...props +}: React.ComponentProps & { + title?: string + description?: string + className?: string + showCloseButton?: boolean +}) { + return ( + + + {title} + {description} + + + + {children} + + + + ) +} + +function CommandInput({ + className, + ...props +}: React.ComponentProps) { + return ( +
+ + +
+ ) +} + +function CommandList({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function CommandEmpty({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function CommandGroup({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function CommandSeparator({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function CommandItem({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function CommandShortcut({ + className, + ...props +}: React.ComponentProps<"span">) { + return ( + + ) +} + +export { + Command, + CommandDialog, + CommandInput, + CommandList, + CommandEmpty, + CommandGroup, + CommandItem, + CommandShortcut, + CommandSeparator, +} diff --git a/src/components/ui/multi-select.tsx b/src/components/ui/multi-select.tsx new file mode 100644 index 0000000..3111b87 --- /dev/null +++ b/src/components/ui/multi-select.tsx @@ -0,0 +1,1059 @@ +import * as React from "react"; +import {cva, type VariantProps} from "class-variance-authority"; +import {CheckIcon, ChevronDown, XCircle, XIcon,} from "lucide-react"; + +import {cn} from "@/lib/utils"; +import {Separator} from "@/components/ui/separator"; +import {Button} from "@/components/ui/button"; +import {Badge} from "@/components/ui/badge"; +import {Popover, PopoverContent, PopoverTrigger,} from "@/components/ui/popover"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, + CommandSeparator, +} from "@/components/ui/command"; + +const multiSelectVariants = cva("m-1 transition-all duration-300 ease-in-out", { + variants: { + variant: { + default: "border-foreground/10 text-foreground bg-card hover:bg-card/80", + secondary: + "border-foreground/10 bg-secondary text-secondary-foreground hover:bg-secondary/80", + destructive: + "border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80", + inverted: "inverted", + }, + }, + defaultVariants: { + variant: "default", + }, +}); + +/** + * Option interface for MultiSelect component + */ +interface MultiSelectOption { + /** The text to display for the option. */ + label: string; + /** The unique value associated with the option. */ + value: string; + /** Optional icon component to display alongside the option. */ + icon?: React.ComponentType<{ className?: string }>; + /** Whether this option is disabled */ + disabled?: boolean; + /** Custom styling for the option */ + style?: { + /** Custom badge color */ + badgeColor?: string; + /** Custom icon color */ + iconColor?: string; + /** Gradient background for badge */ + gradient?: string; + }; +} + +/** + * Group interface for organizing options + */ +interface MultiSelectGroup { + /** Group heading */ + heading: string; + /** Options in this group */ + options: MultiSelectOption[]; +} + +/** + * Props for MultiSelect component + */ +interface MultiSelectProps + extends Omit< + React.ButtonHTMLAttributes, + "animationConfig" + >, + VariantProps { + /** + * An array of option objects or groups to be displayed in the multi-select component. + */ + options: MultiSelectOption[] | MultiSelectGroup[]; + /** + * Callback function triggered when the selected values change. + * Receives an array of the new selected values. + */ + onValueChange: (value: string[]) => void; + + /** The default selected values when the component mounts. */ + defaultValue?: string[]; + + /** + * Placeholder text to be displayed when no values are selected. + * Optional, defaults to "Select options". + */ + placeholder?: string; + + /** + * Maximum number of items to display. Extra selected items will be summarized. + * Optional, defaults to 3. + */ + maxCount?: number; + + /** + * The modality of the popover. When set to true, interaction with outside elements + * will be disabled and only popover content will be visible to screen readers. + * Optional, defaults to false. + */ + modalPopover?: boolean; + + /** + * If true, renders the multi-select component as a child of another component. + * Optional, defaults to false. + */ + asChild?: boolean; + + /** + * Additional class names to apply custom styles to the multi-select component. + * Optional, can be used to add custom styles. + */ + className?: string; + + /** + * If true, disables the select all functionality. + * Optional, defaults to false. + */ + hideSelectAll?: boolean; + + /** + * If true, shows search functionality in the popover. + * If false, hides the search input completely. + * Optional, defaults to true. + */ + searchable?: boolean; + + /** + * Custom empty state message when no options match search. + * Optional, defaults to "No results found." + */ + emptyIndicator?: React.ReactNode; + + /** + * If true, allows the component to grow and shrink with its content. + * If false, uses fixed width behavior. + * Optional, defaults to false. + */ + autoSize?: boolean; + + /** + * If true, shows badges in a single line with horizontal scroll. + * If false, badges wrap to multiple lines. + * Optional, defaults to false. + */ + singleLine?: boolean; + + /** + * Custom CSS class for the popover content. + * Optional, can be used to customize popover appearance. + */ + popoverClassName?: string; + + /** + * If true, disables the component completely. + * Optional, defaults to false. + */ + disabled?: boolean; + + /** + * Responsive configuration for different screen sizes. + * Allows customizing maxCount and other properties based on viewport. + * Can be boolean true for default responsive behavior or an object for custom configuration. + */ + responsive?: + | boolean + | { + /** Configuration for mobile devices (< 640px) */ + mobile?: { + maxCount?: number; + hideIcons?: boolean; + compactMode?: boolean; + }; + /** Configuration for tablet devices (640px - 1024px) */ + tablet?: { + maxCount?: number; + hideIcons?: boolean; + compactMode?: boolean; + }; + /** Configuration for desktop devices (> 1024px) */ + desktop?: { + maxCount?: number; + hideIcons?: boolean; + compactMode?: boolean; + }; + }; + + /** + * Minimum width for the component. + * Optional, defaults to auto-sizing based on content. + * When set, component will not shrink below this width. + */ + minWidth?: string; + + /** + * Maximum width for the component. + * Optional, defaults to 100% of container. + * Component will not exceed container boundaries. + */ + maxWidth?: string; + + /** + * If true, automatically removes duplicate options based on their value. + * Optional, defaults to false (shows warning in dev mode instead). + */ + deduplicateOptions?: boolean; + + /** + * If true, the component will reset its internal state when defaultValue changes. + * Useful for React Hook Form integration and form reset functionality. + * Optional, defaults to true. + */ + resetOnDefaultValueChange?: boolean; + + /** + * If true, automatically closes the popover after selecting an option. + * Useful for single-selection-like behavior or mobile UX. + * Optional, defaults to false. + */ + closeOnSelect?: boolean; +} + +/** + * Imperative methods exposed through ref + */ +export interface MultiSelectRef { + /** + * Programmatically reset the component to its default value + */ + reset: () => void; + /** + * Get current selected values + */ + getSelectedValues: () => string[]; + /** + * Set selected values programmatically + */ + setSelectedValues: (values: string[]) => void; + /** + * Clear all selected values + */ + clear: () => void; + /** + * Focus the component + */ + focus: () => void; +} + +export const MultiSelect = React.forwardRef( + ( + { + options, + onValueChange, + variant, + defaultValue = [], + placeholder = "Select options", + maxCount = 3, + modalPopover = false, + asChild = false, + className, + hideSelectAll = false, + searchable = true, + emptyIndicator, + autoSize = false, + singleLine = false, + popoverClassName, + disabled = false, + responsive, + minWidth, + maxWidth, + deduplicateOptions = false, + resetOnDefaultValueChange = true, + closeOnSelect = false, + ...props + }, + ref + ) => { + const [selectedValues, setSelectedValues] = + React.useState(defaultValue); + const [isPopoverOpen, setIsPopoverOpen] = React.useState(false); + const [searchValue, setSearchValue] = React.useState(""); + + const [politeMessage, setPoliteMessage] = React.useState(""); + const [assertiveMessage, setAssertiveMessage] = React.useState(""); + const prevSelectedCount = React.useRef(selectedValues.length); + const prevIsOpen = React.useRef(isPopoverOpen); + const prevSearchValue = React.useRef(searchValue); + + const announce = React.useCallback( + (message: string, priority: "polite" | "assertive" = "polite") => { + if (priority === "assertive") { + setAssertiveMessage(message); + setTimeout(() => setAssertiveMessage(""), 100); + } else { + setPoliteMessage(message); + setTimeout(() => setPoliteMessage(""), 100); + } + }, + [] + ); + + const multiSelectId = React.useId(); + const listboxId = `${multiSelectId}-listbox`; + const triggerDescriptionId = `${multiSelectId}-description`; + const selectedCountId = `${multiSelectId}-count`; + + const prevDefaultValueRef = React.useRef(defaultValue); + + const isGroupedOptions = React.useCallback( + ( + opts: MultiSelectOption[] | MultiSelectGroup[] + ): opts is MultiSelectGroup[] => { + return opts.length > 0 && "heading" in opts[0]; + }, + [] + ); + + const arraysEqual = React.useCallback( + (a: string[], b: string[]): boolean => { + if (a.length !== b.length) return false; + const sortedA = [...a].sort(); + const sortedB = [...b].sort(); + return sortedA.every((val, index) => val === sortedB[index]); + }, + [] + ); + + const resetToDefault = React.useCallback(() => { + setSelectedValues(defaultValue); + setIsPopoverOpen(false); + setSearchValue(""); + onValueChange(defaultValue); + }, [defaultValue, onValueChange]); + + const buttonRef = React.useRef(null); + + React.useImperativeHandle( + ref, + () => ({ + reset: resetToDefault, + getSelectedValues: () => selectedValues, + setSelectedValues: (values: string[]) => { + setSelectedValues(values); + onValueChange(values); + }, + clear: () => { + setSelectedValues([]); + onValueChange([]); + }, + focus: () => { + if (buttonRef.current) { + buttonRef.current.focus(); + const originalOutline = buttonRef.current.style.outline; + const originalOutlineOffset = buttonRef.current.style.outlineOffset; + buttonRef.current.style.outline = "2px solid hsl(var(--ring))"; + buttonRef.current.style.outlineOffset = "2px"; + setTimeout(() => { + if (buttonRef.current) { + buttonRef.current.style.outline = originalOutline; + buttonRef.current.style.outlineOffset = originalOutlineOffset; + } + }, 1000); + } + }, + }), + [resetToDefault, selectedValues, onValueChange] + ); + + const [screenSize, setScreenSize] = React.useState< + "mobile" | "tablet" | "desktop" + >("desktop"); + + React.useEffect(() => { + if (typeof window === "undefined") return; + const handleResize = () => { + const width = window.innerWidth; + if (width < 640) { + setScreenSize("mobile"); + } else if (width < 1024) { + setScreenSize("tablet"); + } else { + setScreenSize("desktop"); + } + }; + handleResize(); + window.addEventListener("resize", handleResize); + return () => { + if (typeof window !== "undefined") { + window.removeEventListener("resize", handleResize); + } + }; + }, []); + + const getResponsiveSettings = () => { + if (!responsive) { + return { + maxCount: maxCount, + hideIcons: false, + compactMode: false, + }; + } + if (responsive === true) { + const defaultResponsive = { + mobile: { maxCount: 2, hideIcons: false, compactMode: true }, + tablet: { maxCount: 4, hideIcons: false, compactMode: false }, + desktop: { maxCount: 6, hideIcons: false, compactMode: false }, + }; + const currentSettings = defaultResponsive[screenSize]; + return { + maxCount: currentSettings?.maxCount ?? maxCount, + hideIcons: currentSettings?.hideIcons ?? false, + compactMode: currentSettings?.compactMode ?? false, + }; + } + const currentSettings = responsive[screenSize]; + return { + maxCount: currentSettings?.maxCount ?? maxCount, + hideIcons: currentSettings?.hideIcons ?? false, + compactMode: currentSettings?.compactMode ?? false, + }; + }; + + const responsiveSettings = getResponsiveSettings(); + + const getAllOptions = React.useCallback((): MultiSelectOption[] => { + if (options.length === 0) return []; + let allOptions: MultiSelectOption[]; + if (isGroupedOptions(options)) { + allOptions = options.flatMap((group) => group.options); + } else { + allOptions = options; + } + const valueSet = new Set(); + const uniqueOptions: MultiSelectOption[] = []; + allOptions.forEach((option) => { + if (valueSet.has(option.value)) { + if (!deduplicateOptions) { + uniqueOptions.push(option); + } + } else { + valueSet.add(option.value); + uniqueOptions.push(option); + } + }); + return deduplicateOptions ? uniqueOptions : allOptions; + }, [options, deduplicateOptions, isGroupedOptions]); + + const getOptionByValue = React.useCallback( + (value: string): MultiSelectOption | undefined => { + return getAllOptions().find((option) => option.value === value); + }, + [getAllOptions] + ); + + const filteredOptions = React.useMemo(() => { + if (!searchable || !searchValue) return options; + if (options.length === 0) return []; + if (isGroupedOptions(options)) { + return options + .map((group) => ({ + ...group, + options: group.options.filter( + (option) => + option.label + .toLowerCase() + .includes(searchValue.toLowerCase()) || + option.value.toLowerCase().includes(searchValue.toLowerCase()) + ), + })) + .filter((group) => group.options.length > 0); + } + return options.filter( + (option) => + option.label.toLowerCase().includes(searchValue.toLowerCase()) || + option.value.toLowerCase().includes(searchValue.toLowerCase()) + ); + }, [options, searchValue, searchable, isGroupedOptions]); + + const handleInputKeyDown = ( + event: React.KeyboardEvent + ) => { + if (event.key === "Enter") { + setIsPopoverOpen(true); + } else if (event.key === "Backspace" && !event.currentTarget.value) { + const newSelectedValues = [...selectedValues]; + newSelectedValues.pop(); + setSelectedValues(newSelectedValues); + onValueChange(newSelectedValues); + } + }; + + const toggleOption = (optionValue: string) => { + if (disabled) return; + const option = getOptionByValue(optionValue); + if (option?.disabled) return; + const newSelectedValues = selectedValues.includes(optionValue) + ? selectedValues.filter((value) => value !== optionValue) + : [...selectedValues, optionValue]; + setSelectedValues(newSelectedValues); + onValueChange(newSelectedValues); + if (closeOnSelect) { + setIsPopoverOpen(false); + } + }; + + const handleClear = () => { + if (disabled) return; + setSelectedValues([]); + onValueChange([]); + }; + + const handleTogglePopover = () => { + if (disabled) return; + setIsPopoverOpen((prev) => !prev); + }; + + const clearExtraOptions = () => { + if (disabled) return; + const newSelectedValues = selectedValues.slice( + 0, + responsiveSettings.maxCount + ); + setSelectedValues(newSelectedValues); + onValueChange(newSelectedValues); + }; + + const toggleAll = () => { + if (disabled) return; + const allOptions = getAllOptions().filter((option) => !option.disabled); + if (selectedValues.length === allOptions.length) { + handleClear(); + } else { + const allValues = allOptions.map((option) => option.value); + setSelectedValues(allValues); + onValueChange(allValues); + } + + if (closeOnSelect) { + setIsPopoverOpen(false); + } + }; + + React.useEffect(() => { + if (!resetOnDefaultValueChange) return; + const prevDefaultValue = prevDefaultValueRef.current; + if (!arraysEqual(prevDefaultValue, defaultValue)) { + if (!arraysEqual(selectedValues, defaultValue)) { + setSelectedValues(defaultValue); + } + prevDefaultValueRef.current = [...defaultValue]; + } + }, [defaultValue, selectedValues, arraysEqual, resetOnDefaultValueChange]); + + const getWidthConstraints = () => { + const defaultMinWidth = screenSize === "mobile" ? "0px" : "200px"; + const effectiveMinWidth = minWidth || defaultMinWidth; + const effectiveMaxWidth = maxWidth || "100%"; + return { + minWidth: effectiveMinWidth, + maxWidth: effectiveMaxWidth, + width: autoSize ? "auto" : "100%", + }; + }; + + const widthConstraints = getWidthConstraints(); + + React.useEffect(() => { + if (!isPopoverOpen) { + setSearchValue(""); + } + }, [isPopoverOpen]); + + React.useEffect(() => { + const selectedCount = selectedValues.length; + const allOptions = getAllOptions(); + const totalOptions = allOptions.filter((opt) => !opt.disabled).length; + if (selectedCount !== prevSelectedCount.current) { + const diff = selectedCount - prevSelectedCount.current; + if (diff > 0) { + const addedItems = selectedValues.slice(-diff); + const addedLabels = addedItems + .map( + (value) => allOptions.find((opt) => opt.value === value)?.label + ) + .filter(Boolean); + + if (addedLabels.length === 1) { + announce( + `${addedLabels[0]} selected. ${selectedCount} of ${totalOptions} options selected.` + ); + } else { + announce( + `${addedLabels.length} options selected. ${selectedCount} of ${totalOptions} total selected.` + ); + } + } else if (diff < 0) { + announce( + `Option removed. ${selectedCount} of ${totalOptions} options selected.` + ); + } + prevSelectedCount.current = selectedCount; + } + + if (isPopoverOpen !== prevIsOpen.current) { + if (isPopoverOpen) { + announce( + `Dropdown opened. ${totalOptions} options available. Use arrow keys to navigate.` + ); + } else { + announce("Dropdown closed."); + } + prevIsOpen.current = isPopoverOpen; + } + + if ( + searchValue !== prevSearchValue.current && + searchValue !== undefined + ) { + if (searchValue && isPopoverOpen) { + const filteredCount = allOptions.filter( + (opt) => + opt.label.toLowerCase().includes(searchValue.toLowerCase()) || + opt.value.toLowerCase().includes(searchValue.toLowerCase()) + ).length; + + announce( + `${filteredCount} option${ + filteredCount === 1 ? "" : "s" + } found for "${searchValue}"` + ); + } + prevSearchValue.current = searchValue; + } + }, [selectedValues, isPopoverOpen, searchValue, announce, getAllOptions]); + + return ( + <> +
+
+ {politeMessage} +
+
+ {assertiveMessage} +
+
+ + +
+ Multi-select dropdown. Use arrow keys to navigate, Enter to select, + and Escape to close. +
+
+ {selectedValues.length === 0 + ? "No options selected" + : `${selectedValues.length} option${ + selectedValues.length === 1 ? "" : "s" + } selected: ${selectedValues + .map((value) => getOptionByValue(value)?.label) + .filter(Boolean) + .join(", ")}`} +
+ + + + + setIsPopoverOpen(false)}> + + {searchable && ( + + )} + {searchable && ( +
+ Type to filter options. Use arrow keys to navigate results. +
+ )} + + + {emptyIndicator || "No results found."} + {" "} + {!hideSelectAll && !searchValue && ( + + !opt.disabled).length + } + aria-label={`Select all ${ + getAllOptions().length + } options`} + className="cursor-pointer"> +
!opt.disabled) + .length + ? "bg-primary text-primary-foreground" + : "opacity-50 [&_svg]:invisible" + )} + aria-hidden="true"> + +
+ + (Select All + {getAllOptions().length > 20 + ? ` - ${getAllOptions().length} options` + : ""} + ) + +
+
+ )} + {isGroupedOptions(filteredOptions) ? ( + filteredOptions.map((group) => ( + + {group.options.map((option) => { + const isSelected = selectedValues.includes( + option.value + ); + return ( + toggleOption(option.value)} + role="option" + aria-selected={isSelected} + aria-disabled={option.disabled} + aria-label={`${option.label}${ + isSelected ? ", selected" : ", not selected" + }${option.disabled ? ", disabled" : ""}`} + className={cn( + "cursor-pointer", + option.disabled && "opacity-50 cursor-not-allowed" + )} + disabled={option.disabled}> + + {option.icon && ( + + ); + })} + + )) + ) : ( + + {filteredOptions.map((option) => { + const isSelected = selectedValues.includes(option.value); + return ( + toggleOption(option.value)} + role="option" + aria-selected={isSelected} + aria-disabled={option.disabled} + aria-label={`${option.label}${ + isSelected ? ", selected" : ", not selected" + }${option.disabled ? ", disabled" : ""}`} + className={cn( + "cursor-pointer", + option.disabled && "opacity-50 cursor-not-allowed" + )} + disabled={option.disabled}> + + {option.icon && ( + + ); + })} + + )} + + +
+ {selectedValues.length > 0 && ( + <> + + Clear + + + + )} + setIsPopoverOpen(false)} + className="flex-1 justify-center cursor-pointer max-w-full"> + Close + +
+
+
+
+
+
+ + ); + } +); + +MultiSelect.displayName = "MultiSelect"; +export type { MultiSelectOption, MultiSelectGroup, MultiSelectProps }; \ No newline at end of file diff --git a/src/components/ui/popover.tsx b/src/components/ui/popover.tsx new file mode 100644 index 0000000..4182075 --- /dev/null +++ b/src/components/ui/popover.tsx @@ -0,0 +1,87 @@ +import * as React from "react" +import { Popover as PopoverPrimitive } from "radix-ui" + +import { cn } from "@/lib/utils" + +function Popover({ + ...props +}: React.ComponentProps) { + return +} + +function PopoverTrigger({ + ...props +}: React.ComponentProps) { + return +} + +function PopoverContent({ + className, + align = "center", + sideOffset = 4, + ...props +}: React.ComponentProps) { + return ( + + + + ) +} + +function PopoverAnchor({ + ...props +}: React.ComponentProps) { + return +} + +function PopoverHeader({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function PopoverTitle({ className, ...props }: React.ComponentProps<"h2">) { + return ( +
+ ) +} + +function PopoverDescription({ + className, + ...props +}: React.ComponentProps<"p">) { + return ( +

+ ) +} + +export { + Popover, + PopoverTrigger, + PopoverContent, + PopoverAnchor, + PopoverHeader, + PopoverTitle, + PopoverDescription, +} diff --git a/src/components/ui/sheet.tsx b/src/components/ui/sheet.tsx new file mode 100644 index 0000000..0b72aa3 --- /dev/null +++ b/src/components/ui/sheet.tsx @@ -0,0 +1,119 @@ +import * as DialogPrimitive from "@radix-ui/react-dialog"; +import { XIcon } from "lucide-react"; +import type * as React from "react"; +import { cn } from "@/lib/utils"; + +function Sheet({ ...props }: React.ComponentProps) { + return ; +} + +function SheetTrigger({ ...props }: React.ComponentProps) { + return ; +} + +function SheetClose({ ...props }: React.ComponentProps) { + return ; +} + +function SheetPortal({ ...props }: React.ComponentProps) { + return ; +} + +function SheetOverlay({ className, ...props }: React.ComponentProps) { + return ( + + ); +} + +function SheetContent({ + className, + children, + side = "left", + showCloseButton = true, + ...props +}: React.ComponentProps & { + side?: "top" | "right" | "bottom" | "left"; + showCloseButton?: boolean; +}) { + const sideClasses = { + top: "inset-x-0 top-0 border-b data-[state=open]:slide-in-from-top data-[state=closed]:slide-out-to-top", + bottom: "inset-x-0 bottom-0 border-t data-[state=open]:slide-in-from-bottom data-[state=closed]:slide-out-to-bottom", + left: "inset-y-0 left-0 h-full w-3/4 border-r data-[state=open]:slide-in-from-left data-[state=closed]:slide-out-to-left sm:max-w-sm", + right: "inset-y-0 right-0 h-full w-3/4 border-l data-[state=open]:slide-in-from-right data-[state=closed]:slide-out-to-right sm:max-w-sm", + }; + + return ( + + + + {children} + {showCloseButton && ( + + + Close + + )} + + + ); +} + +function SheetHeader({ className, ...props }: React.ComponentProps<"div">) { + return ( +

+ ); +} + +function SheetTitle({ className, ...props }: React.ComponentProps) { + return ( + + ); +} + +function SheetDescription({ className, ...props }: React.ComponentProps) { + return ( + + ); +} + +export { + Sheet, + SheetClose, + SheetContent, + SheetDescription, + SheetHeader, + SheetOverlay, + SheetPortal, + SheetTitle, + SheetTrigger, +}; diff --git a/src/contexts/UIStateContext.tsx b/src/contexts/UIStateContext.tsx index 7c4361a..f40f19e 100644 --- a/src/contexts/UIStateContext.tsx +++ b/src/contexts/UIStateContext.tsx @@ -55,7 +55,7 @@ export const UIStateProvider = ({ children }: { children: ReactNode }) => { const [showAdultContent, setShowAdultContent] = useState(false); const [sortOption, setSortOption] = useState("title-asc"); const [searchText, setSearchText] = useState(""); - const [isSidebarOpen, setIsSidebarOpen] = useState(true); + const [isSidebarOpen, setIsSidebarOpen] = useState(() => window.innerWidth >= 768); useEffect(() => { setShowAdultContent(isAuthenticated); diff --git a/src/features/home/components/FilterSidebar.tsx b/src/features/home/components/FilterSidebar.tsx index 919222f..4bbb067 100644 --- a/src/features/home/components/FilterSidebar.tsx +++ b/src/features/home/components/FilterSidebar.tsx @@ -1,13 +1,13 @@ -import { PanelLeftClose, Star, X } from "lucide-react"; +import { PanelLeftClose, Star } from "lucide-react"; import { useGetGenres } from "@/api/generated/catalog/catalog.ts"; -import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Separator } from "@/components/ui/separator.tsx"; import { Skeleton } from "@/components/ui/skeleton.tsx"; import { Switch } from "@/components/ui/switch.tsx"; import { useAuth } from "@/contexts/AuthContext.tsx"; +import {MultiSelect} from "@/components/ui/multi-select.tsx"; -interface FilterSidebarProps { +export interface FilterContentProps { selectedGenres: number[]; selectedStatus: string[]; minRating: number; @@ -31,7 +31,7 @@ const RATINGS = [ { label: "All Ratings", value: 0 }, ]; -export function FilterSidebar({ +export function FilterContent({ selectedGenres, selectedStatus, minRating, @@ -44,26 +44,10 @@ export function FilterSidebar({ onShowAdultContentChange, onHide, isDisabled = false, -}: FilterSidebarProps) { +}: FilterContentProps) { const { data: genresData, isPending: isPendingGenres } = useGetGenres(); const { isAuthenticated } = useAuth(); - const toggleGenre = (genre: number) => { - if (selectedGenres.includes(genre)) { - onGenresChange(selectedGenres.filter((g) => g !== genre)); - } else { - onGenresChange([...selectedGenres, genre]); - } - }; - - const toggleStatus = (status: string) => { - if (selectedStatus.includes(status)) { - onStatusChange(selectedStatus.filter((s) => s !== status)); - } else { - onStatusChange([...selectedStatus, status]); - } - }; - const clearAllFilters = () => { onGenresChange([]); onStatusChange([]); @@ -79,162 +63,137 @@ export function FilterSidebar({ userFavorites; return ( -