diff --git a/package-lock.json b/package-lock.json index b706478..0381604 100644 --- a/package-lock.json +++ b/package-lock.json @@ -82,7 +82,6 @@ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -2538,7 +2537,6 @@ "integrity": "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~7.18.0" } @@ -2563,7 +2561,6 @@ "integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "@types/prop-types": "*", "csstype": "^3.2.2" @@ -2575,7 +2572,6 @@ "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", "devOptional": true, "license": "MIT", - "peer": true, "peerDependencies": { "@types/react": "^18.0.0" } @@ -2625,7 +2621,6 @@ "integrity": "sha512-30ScMRHIAD33JJQkgfGW1t8CURZtjc2JpTrq5n2HFhOefbAhb7ucc7xJwdWcrEtqUIYJ73Nybpsggii6GtAHjA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.57.2", "@typescript-eslint/types": "8.57.2", @@ -2827,7 +2822,6 @@ "integrity": "sha512-krRIbvPK1ju1WBKIefiX+bngPs+odIQUtR7kymzPfo1POVw3jlF+nLkmexdSSd4UCbDcQn+wMBATOOmpBbqgKg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", "@typescript-eslint/scope-manager": "8.57.2", @@ -3173,7 +3167,6 @@ "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -3311,7 +3304,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -3686,7 +3678,6 @@ "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -4995,7 +4986,6 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -5095,7 +5085,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", "license": "MIT", - "peer": true, "dependencies": { "loose-envify": "^1.1.0" }, @@ -5108,7 +5097,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", "license": "MIT", - "peer": true, "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" @@ -5456,8 +5444,7 @@ "version": "4.2.2", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.2.tgz", "integrity": "sha512-KWBIxs1Xb6NoLdMVqhbhgwZf2PGBpPEiwOqgI4pFIYbNTfBXiKYyWoTsXgBQ9WFg/OlhnvHaY+AEpW7wSmFo2Q==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/tapable": { "version": "2.3.2", @@ -5526,7 +5513,6 @@ "integrity": "sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -5573,7 +5559,6 @@ "dev": true, "hasInstallScript": true, "license": "MIT", - "peer": true, "dependencies": { "napi-postinstall": "^0.3.0" }, @@ -5717,7 +5702,6 @@ "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz", "integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==", "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", diff --git a/src/components/course/Course.tsx b/src/components/course/Course.tsx index 6e714ce..ecc1f15 100644 --- a/src/components/course/Course.tsx +++ b/src/components/course/Course.tsx @@ -1,5 +1,6 @@ -import { useState } from "react"; +import { useState, type HTMLAttributes } from "react"; +import { useDragOperation } from "@dnd-kit/react"; import { useSortable } from "@dnd-kit/react/sortable"; import * as ContextMenu from "@radix-ui/react-context-menu"; import * as Popover from "@radix-ui/react-popover"; @@ -34,6 +35,15 @@ export default function Course({ }: CourseProps) { const dndType = `${variant}-course`; + // The in-flight node for a catalog drag is rendered by us (not dnd-kit's + // sortable ghost), so read the active drag operation and flag ourselves as + // the placeholder when our id matches the placeholderId stashed on the + // source in handleDragStart. + const operation = useDragOperation(); + const isCatalogPlaceholder = + operation?.source?.data?.type === "catalog-course" && + operation.source.data.placeholderId === id; + const { handleRef, ref, isDragging } = useSortable({ id, group, @@ -49,6 +59,7 @@ export default function Course({ ); @@ -59,6 +70,7 @@ export default function Course({ innerRef={ref} handleRef={handleRef} isDragging={isDragging} + isCatalogPlaceholder={isCatalogPlaceholder} course={course} semesterId={semesterId} /> @@ -69,14 +81,21 @@ type ViewProps = { innerRef?: (element: HTMLElement | null) => void; handleRef?: (element: HTMLElement | null) => void; isDragging: boolean; + isCatalogPlaceholder?: boolean; course: UserCourse; semesterId?: string | null; }; -function ToolboxCourseView({ innerRef, isDragging, course }: ViewProps) { +function ToolboxCourseView({ + innerRef, + isDragging, + isCatalogPlaceholder = false, + course, +}: ViewProps) { return (
@@ -86,13 +105,39 @@ function ToolboxCourseView({ innerRef, isDragging, course }: ViewProps) { ); } +// Catalog drags mount a real as the in-flight placeholder. dnd-kit +// doesn't know about that element, so we manually apply the same contract it +// uses for its own sortable placeholders (`data-dnd-placeholder="clone"` + +// `inert` + `aria-hidden` + `tabIndex=-1`). That lets the existing CSS rule +// (`[data-dnd-placeholder="clone"] { opacity: 0.5 !important }`) and `inert` +// handle the ghost appearance and interaction-suppression uniformly -- the +// same way the toolbox-originated placeholder works out of the box. +// `inert` typing wasn't added until React 19, hence the cast. +function getCatalogPlaceholderAttrs( + isCatalogPlaceholder: boolean, +): HTMLAttributes { + return ( + isCatalogPlaceholder + ? { + "data-dnd-placeholder": "clone", + inert: "", + "aria-hidden": true, + tabIndex: -1, + } + : {} + ) as HTMLAttributes; +} + function PlannerCourseView({ innerRef, handleRef, isDragging, + isCatalogPlaceholder = false, course, semesterId, }: ViewProps) { + const placeholderAttrs = getCatalogPlaceholderAttrs(isCatalogPlaceholder); + const isGhost = isDragging || isCatalogPlaceholder; const [isDrawerOpen, setIsDrawerOpen] = useState(false); const menuOptions = usePlannerCourse({ course, @@ -118,6 +163,7 @@ function PlannerCourseView({
{ if ( typeof window !== "undefined" && @@ -130,11 +176,11 @@ function PlannerCourseView({ className={cn( "relative flex justify-between bg-darkblue rounded-2xl text-carpipink gap-4 px-2 py-3", "hover:shadow-lg", - isDragging ? "cursor-grabbing" : "cursor-grab", + isGhost ? "cursor-grabbing" : "cursor-grab", )} >
- @@ -161,7 +207,11 @@ function PlannerCourseView({ onOpenChange={setPopoverOpen} > - diff --git a/src/features/catalog/components/CatalogCourse.tsx b/src/features/catalog/components/CatalogCourse.tsx index 38f971b..72ae187 100644 --- a/src/features/catalog/components/CatalogCourse.tsx +++ b/src/features/catalog/components/CatalogCourse.tsx @@ -1,12 +1,16 @@ -import { useState } from "react"; +import { useState, useRef } from "react"; +import { useDraggable } from "@dnd-kit/react"; import { motion } from "framer-motion"; import { IoAdd } from "react-icons/io5"; +import { MdDragIndicator } from "react-icons/md"; +import { v4 as uuidv4 } from "uuid"; import CourseBadge from "@/components/course/CourseBadge"; import CourseLabel from "@/components/course/CourseLabel"; import Tag from "@/components/Tag"; import { useCourseWorkspace } from "@/core/workspace/useCourseWorkspace"; +import useIsDesktop from "@/lib/hooks/useIsDesktop"; import { useCourseFilters } from "@/lib/stores/useFilterStore"; import { APICourse } from "@/lib/types"; @@ -18,6 +22,16 @@ const Course: React.FC = ({ course }) => { const { addCourseToToolbox, getCourseCount } = useCourseWorkspace(); const { attrFilters, semFilters } = useCourseFilters(course); + const isDesktop = useIsDesktop(); + const idRef = useRef(uuidv4()); + + const { ref, isDragging } = useDraggable({ + id: idRef.current, + type: "catalog-course", + data: { type: "catalog-course", course }, + disabled: !isDesktop, + }); + const [isOpen, setIsOpen] = useState(false); const toggleOpen = (e: React.MouseEvent) => { const target = e.target as HTMLElement; @@ -26,7 +40,8 @@ const Course: React.FC = ({ course }) => { return (
= ({ course }) => { />
-
- -
- {attrFilters.map((attr, index) => { - return ; - })} - {semFilters?.map((semester, index) => { - return ; - })} +
+ {isDesktop && ( + + )} +
+ +
+ {attrFilters.map((attr, index) => { + return ; + })} + {semFilters?.map((semester, index) => { + return ; + })} +
-
+
e.stopPropagation()} + > { e.stopPropagation(); @@ -81,6 +108,8 @@ interface AddButtonProps { const AddButton: React.FC = ({ addCourse }) => { return (