@@ -223,6 +223,22 @@ export interface KanbanBoardProps {
223223 * (objectui#12082) — so no card offers a drag that would be refused.
224224 */
225225 cardsMovable ?: boolean
226+ /**
227+ * The per-column drop gate (#25933): whether a card that sits in
228+ * `fromColumnId` may be dropped into `toColumnId`. Absent, every column takes
229+ * every card, as before. `ObjectKanban` derives it from the object's
230+ * blocking `state_machine` rule on the grouped field, so a move the server is
231+ * bound to refuse is never offered:
232+ *
233+ * - while a card is dragged, every column it may not enter is dimmed
234+ * (`data-kanban-drop-blocked`) and draws no drop highlight;
235+ * - a drop into such a column is refused here, before the mover runs: the
236+ * card stays where it was and `onCardMove` is not called.
237+ *
238+ * The card's own column is never asked — a same-column drop moves nothing.
239+ * The server still refuses an invalid move, and the mover's rollback stays.
240+ */
241+ canDropCard ?: ( fromColumnId : string , toColumnId : string ) => boolean
226242}
227243
228244/**
@@ -680,6 +696,7 @@ function KanbanColumnView({
680696 suppressEmptyPlaceholder,
681697 countsAreWindowed,
682698 collapsed,
699+ dropBlocked,
683700 onToggleCollapse,
684701} : {
685702 column : KanbanColumn
@@ -707,6 +724,12 @@ function KanbanColumnView({
707724 * The board owns that resolution; this component only paints it.
708725 */
709726 collapsed ?: boolean
727+ /**
728+ * The card being dragged may not enter this lane (#25933,
729+ * {@link KanbanBoardProps.canDropCard}): the lane is dimmed and draws no
730+ * drop highlight. A drop here is refused by the board's drag-end handler.
731+ */
732+ dropBlocked ?: boolean
710733 /**
711734 * Toggles the lane's collapse. Present only on a lane that DECLARED
712735 * `collapsed: true`, and its presence is what puts the disclosure in the
@@ -716,12 +739,14 @@ function KanbanColumnView({
716739} ) {
717740 const { t } = useKanbanT ( )
718741 const safeCards = cards || [ ] ;
719- const { setNodeRef, isOver } = useSortable ( {
742+ const { setNodeRef, isOver : pointerOver } = useSortable ( {
720743 id : column . id ,
721744 data : {
722745 type : "column" ,
723746 } ,
724747 } )
748+ // A lane the dragged card may not enter never claims the drop (#25933).
749+ const isOver = pointerOver && ! dropBlocked
725750
726751 // objectui#12619 — a lane the board pages for itself carries the server's
727752 // count of its records and a "Load more" while it holds more than it drew.
@@ -755,6 +780,8 @@ function KanbanColumnView({
755780 ref = { setNodeRef }
756781 role = "group"
757782 aria-label = { column . title }
783+ aria-disabled = { dropBlocked || undefined }
784+ data-kanban-drop-blocked = { dropBlocked ? "" : undefined }
758785 style = { box . style }
759786 className = { cn (
760787 "relative flex flex-col rounded-xl border border-border/60 bg-muted/15 snap-start max-h-full min-h-0 transition-all duration-200 shadow-sm hover:shadow-md overflow-hidden" ,
@@ -764,6 +791,7 @@ function KanbanColumnView({
764791 // drop. This is critical for empty columns where there's no card
765792 // gap-indicator from SortableContext to show drop position.
766793 isOver && "ring-2 ring-primary/60 bg-primary/5" ,
794+ dropBlocked && "opacity-40" ,
767795 column . className
768796 ) }
769797 >
@@ -879,27 +907,27 @@ function moveOutcomeSentence(outcome: unknown, movedSentence: string): string |
879907 return movedSentence
880908}
881909
882- export default function KanbanBoard ( { columns, onCardMove, onCardClick, className, quickAdd, onQuickAdd, coverImageField, conditionalFormatting, objectFields, swimlaneField, countsAreWindowed, cardsMovable = true } : KanbanBoardProps ) {
910+ export default function KanbanBoard ( { columns, onCardMove, onCardClick, className, quickAdd, onQuickAdd, coverImageField, conditionalFormatting, objectFields, swimlaneField, countsAreWindowed, cardsMovable = true , canDropCard } : KanbanBoardProps ) {
883911 const hasDnd = useHasDndProvider ( )
884912
885913 if ( hasDnd ) {
886914 return (
887915 < KanbanCardsMovable . Provider value = { cardsMovable } >
888916 < DndBridge >
889- { ( dnd ) => < KanbanBoardInner columns = { columns } onCardMove = { onCardMove } onCardClick = { onCardClick } className = { className } dnd = { dnd } quickAdd = { quickAdd } onQuickAdd = { onQuickAdd } coverImageField = { coverImageField } conditionalFormatting = { conditionalFormatting } objectFields = { objectFields } swimlaneField = { swimlaneField } countsAreWindowed = { countsAreWindowed } /> }
917+ { ( dnd ) => < KanbanBoardInner columns = { columns } onCardMove = { onCardMove } onCardClick = { onCardClick } className = { className } dnd = { dnd } quickAdd = { quickAdd } onQuickAdd = { onQuickAdd } coverImageField = { coverImageField } conditionalFormatting = { conditionalFormatting } objectFields = { objectFields } swimlaneField = { swimlaneField } countsAreWindowed = { countsAreWindowed } canDropCard = { canDropCard } /> }
890918 </ DndBridge >
891919 </ KanbanCardsMovable . Provider >
892920 )
893921 }
894922
895923 return (
896924 < KanbanCardsMovable . Provider value = { cardsMovable } >
897- < KanbanBoardInner columns = { columns } onCardMove = { onCardMove } onCardClick = { onCardClick } className = { className } dnd = { null } quickAdd = { quickAdd } onQuickAdd = { onQuickAdd } coverImageField = { coverImageField } conditionalFormatting = { conditionalFormatting } objectFields = { objectFields } swimlaneField = { swimlaneField } countsAreWindowed = { countsAreWindowed } />
925+ < KanbanBoardInner columns = { columns } onCardMove = { onCardMove } onCardClick = { onCardClick } className = { className } dnd = { null } quickAdd = { quickAdd } onQuickAdd = { onQuickAdd } coverImageField = { coverImageField } conditionalFormatting = { conditionalFormatting } objectFields = { objectFields } swimlaneField = { swimlaneField } countsAreWindowed = { countsAreWindowed } canDropCard = { canDropCard } />
898926 </ KanbanCardsMovable . Provider >
899927 )
900928}
901929
902- function KanbanBoardInner ( { columns, onCardMove, onCardClick, className, dnd, quickAdd, onQuickAdd, coverImageField : _coverImageField , conditionalFormatting, objectFields, swimlaneField, countsAreWindowed } : KanbanBoardProps & { dnd : ReturnType < typeof useDnd > | null } ) {
930+ function KanbanBoardInner ( { columns, onCardMove, onCardClick, className, dnd, quickAdd, onQuickAdd, coverImageField : _coverImageField , conditionalFormatting, objectFields, swimlaneField, countsAreWindowed, canDropCard } : KanbanBoardProps & { dnd : ReturnType < typeof useDnd > | null } ) {
903931 const { t } = useKanbanT ( )
904932 const [ activeCard , setActiveCard ] = React . useState < KanbanCard | null > ( null )
905933
@@ -1066,10 +1094,23 @@ function KanbanBoardInner({ columns, onCardMove, onCardClick, className, dnd, qu
10661094 } )
10671095 )
10681096
1097+ /**
1098+ * The column the dragged card left, for the drop gate (#25933). Held only
1099+ * while a drag is live; `null` otherwise, which leaves every lane open.
1100+ */
1101+ const [ dragFromColumnId , setDragFromColumnId ] = React . useState < string | null > ( null )
1102+ /** Whether the dragged card may NOT enter `columnId` — see {@link KanbanBoardProps.canDropCard}. */
1103+ const isDropBlocked = ( columnId : string ) : boolean =>
1104+ canDropCard != null &&
1105+ dragFromColumnId != null &&
1106+ String ( columnId ) !== String ( dragFromColumnId ) &&
1107+ ! canDropCard ( String ( dragFromColumnId ) , String ( columnId ) )
1108+
10691109 const handleDragStart = ( event : DragStartEvent ) => {
10701110 const { active } = event
10711111 const card = findCard ( active . id as string )
10721112 setActiveCard ( card )
1113+ setDragFromColumnId ( card ? findColumnByCardId ( card . id ) ?. id ?? null : null )
10731114
10741115 // Bridge to ObjectUI spec DnD system
10751116 if ( dnd && card ) {
@@ -1110,9 +1151,18 @@ function KanbanBoardInner({ columns, onCardMove, onCardClick, className, dnd, qu
11101151 }
11111152 }
11121153
1154+ // A cancelled drag (Escape, or the pointer lost) moves nothing; it only has
1155+ // to put the lanes the drop gate dimmed back to normal.
1156+ const handleDragCancel = ( ) => {
1157+ setActiveCard ( null )
1158+ setDragFromColumnId ( null )
1159+ if ( dnd ) dnd . endDrag ( )
1160+ }
1161+
11131162 const handleDragEnd = ( event : DragEndEvent ) => {
11141163 const { active, over } = event
11151164 setActiveCard ( null )
1165+ setDragFromColumnId ( null )
11161166
11171167 if ( ! over ) {
11181168 if ( dnd ) dnd . endDrag ( )
@@ -1135,6 +1185,18 @@ function KanbanBoardInner({ columns, onCardMove, onCardClick, className, dnd, qu
11351185 return
11361186 }
11371187
1188+ if (
1189+ activeColumn . id !== overColumn . id &&
1190+ canDropCard != null &&
1191+ ! canDropCard ( String ( activeColumn . id ) , String ( overColumn . id ) )
1192+ ) {
1193+ // A lane the drop gate closed (#25933): refused here, before any
1194+ // optimistic move or write — the card stays in its column, and the
1195+ // mover is not asked for a move the server is bound to refuse.
1196+ if ( dnd ) dnd . endDrag ( )
1197+ return
1198+ }
1199+
11381200 if ( activeColumn . id === overColumn . id ) {
11391201 // Same-column drop: the board does NOTHING, deliberately (objectui#8826).
11401202 //
@@ -1334,6 +1396,7 @@ function KanbanBoardInner({ columns, onCardMove, onCardClick, className, dnd, qu
13341396 collisionDetection = { closestCorners }
13351397 onDragStart = { handleDragStart }
13361398 onDragEnd = { handleDragEnd }
1399+ onDragCancel = { handleDragCancel }
13371400 accessibility = { dndAccessibility }
13381401 >
13391402 < div ref = { boardRef } className = "flex flex-col min-w-0 min-h-0 h-full" >
@@ -1571,11 +1634,14 @@ function KanbanBoardInner({ columns, onCardMove, onCardClick, className, dnd, qu
15711634 // misalignment objectui#8797 measured, self-inflicted.
15721635 const collapsed = isColumnCollapsed ( col )
15731636 const box = laneBox ( columnInlineStyle , collapsed )
1637+ const dropBlocked = isDropBlocked ( col . id )
15741638 return (
15751639 < div
15761640 key = { col . id }
15771641 style = { box . style }
1578- className = { cn ( box . className , "min-h-[60px] rounded-md bg-card/20 p-2" ) }
1642+ aria-disabled = { dropBlocked || undefined }
1643+ data-kanban-drop-blocked = { dropBlocked ? "" : undefined }
1644+ className = { cn ( box . className , "min-h-[60px] rounded-md bg-card/20 p-2" , dropBlocked && "opacity-40" ) }
15791645 >
15801646 { ! collapsed && (
15811647 < SortableContext items = { laneCards . map ( c => c . id ) } strategy = { verticalListSortingStrategy } >
@@ -1602,6 +1668,7 @@ function KanbanBoardInner({ columns, onCardMove, onCardClick, className, dnd, qu
16021668 < KanbanColumnView
16031669 key = { column . id }
16041670 column = { column }
1671+ dropBlocked = { isDropBlocked ( column . id ) }
16051672 cards = { column . cards }
16061673 onCardClick = { onCardClick }
16071674 quickAdd = { quickAdd }
0 commit comments