feat: mobile drag-to-trash to remove draft starter chips
Lift both starter groups into one DndContext so a trash drop-zone is reachable from either group's drag. On mobile the trash replaces the + button while dragging (color-only over feedback, no resize); dropping a chip on it removes that starter. Hide the hover-x on mobile since there is no hover. Desktop keeps the hover-x + add button unchanged.
This commit is contained in:
@@ -5,7 +5,9 @@ import {
|
|||||||
TouchSensor,
|
TouchSensor,
|
||||||
useSensor,
|
useSensor,
|
||||||
useSensors,
|
useSensors,
|
||||||
|
useDroppable,
|
||||||
closestCenter,
|
closestCenter,
|
||||||
|
MeasuringStrategy,
|
||||||
type DragEndEvent,
|
type DragEndEvent,
|
||||||
} from '@dnd-kit/core';
|
} from '@dnd-kit/core';
|
||||||
import { SortableContext, useSortable, rectSortingStrategy } from '@dnd-kit/sortable';
|
import { SortableContext, useSortable, rectSortingStrategy } from '@dnd-kit/sortable';
|
||||||
@@ -28,13 +30,13 @@ import {
|
|||||||
} from '@/components/ui/dialog';
|
} from '@/components/ui/dialog';
|
||||||
import { useI18n } from '@/lib/i18n';
|
import { useI18n } from '@/lib/i18n';
|
||||||
import { useThemeSystem } from '@/contexts/useThemeSystem';
|
import { useThemeSystem } from '@/contexts/useThemeSystem';
|
||||||
|
import { useDeviceInfo } from '@/lib/device';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import {
|
import {
|
||||||
useDraftStarters,
|
useDraftStarters,
|
||||||
type ResolvedStarter,
|
type ResolvedStarter,
|
||||||
type PinnableItem,
|
type PinnableItem,
|
||||||
type PinnableSection,
|
type PinnableSection,
|
||||||
type StarterGroup,
|
|
||||||
} from './useDraftStarters';
|
} from './useDraftStarters';
|
||||||
|
|
||||||
type DraftPresetChipsProps = {
|
type DraftPresetChipsProps = {
|
||||||
@@ -44,6 +46,17 @@ type DraftPresetChipsProps = {
|
|||||||
className?: string;
|
className?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Droppable id for the mobile "drag a chip here to delete" target. Kept distinct
|
||||||
|
// from any chip id (which are `group:type:name`) so collisions never alias.
|
||||||
|
const TRASH_DROPPABLE_ID = '__draft-starter-trash__';
|
||||||
|
|
||||||
|
// Shared box for the round icon buttons in the "+" slot (add picker and the
|
||||||
|
// mobile trash drop-zone). Identical so swapping one for the other never shifts
|
||||||
|
// layout or changes the circle size. shrink-0 keeps it from being compressed by
|
||||||
|
// the wrapping flex row.
|
||||||
|
const ROUND_ICON_BUTTON_CLASS =
|
||||||
|
'inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-full border transition-colors';
|
||||||
|
|
||||||
const PICKER_SECTIONS: { key: PinnableSection; headingKey: 'chat.draftStarters.sectionBuiltIn' | 'chat.draftStarters.sectionCommands' | 'chat.draftStarters.sectionSkills' }[] = [
|
const PICKER_SECTIONS: { key: PinnableSection; headingKey: 'chat.draftStarters.sectionBuiltIn' | 'chat.draftStarters.sectionCommands' | 'chat.draftStarters.sectionSkills' }[] = [
|
||||||
{ key: 'built-in', headingKey: 'chat.draftStarters.sectionBuiltIn' },
|
{ key: 'built-in', headingKey: 'chat.draftStarters.sectionBuiltIn' },
|
||||||
{ key: 'command', headingKey: 'chat.draftStarters.sectionCommands' },
|
{ key: 'command', headingKey: 'chat.draftStarters.sectionCommands' },
|
||||||
@@ -54,7 +67,9 @@ const SortableChip: React.FC<{
|
|||||||
item: ResolvedStarter;
|
item: ResolvedStarter;
|
||||||
onSubmit: (text: string) => void;
|
onSubmit: (text: string) => void;
|
||||||
onRemove: () => void;
|
onRemove: () => void;
|
||||||
}> = ({ item, onSubmit, onRemove }) => {
|
/** Hide the per-chip hover "x" (mobile uses the trash drop-zone instead). */
|
||||||
|
hideRemove?: boolean;
|
||||||
|
}> = ({ item, onSubmit, onRemove, hideRemove }) => {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const { currentTheme } = useThemeSystem();
|
const { currentTheme } = useThemeSystem();
|
||||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: item.id });
|
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: item.id });
|
||||||
@@ -82,52 +97,70 @@ const SortableChip: React.FC<{
|
|||||||
<Icon name={item.icon} className="h-3.5 w-3.5 shrink-0 opacity-70 transition-opacity group-hover:opacity-100" />
|
<Icon name={item.icon} className="h-3.5 w-3.5 shrink-0 opacity-70 transition-opacity group-hover:opacity-100" />
|
||||||
<span className="whitespace-nowrap">{item.label}</span>
|
<span className="whitespace-nowrap">{item.label}</span>
|
||||||
</button>
|
</button>
|
||||||
<button
|
{hideRemove ? null : (
|
||||||
type="button"
|
<button
|
||||||
onClick={(e) => { e.stopPropagation(); onRemove(); }}
|
type="button"
|
||||||
aria-label={t('chat.draftStarters.remove')}
|
onClick={(e) => { e.stopPropagation(); onRemove(); }}
|
||||||
title={t('chat.draftStarters.remove')}
|
aria-label={t('chat.draftStarters.remove')}
|
||||||
className="absolute -right-1.5 -top-1.5 hidden h-4 w-4 items-center justify-center rounded-full border text-muted-foreground shadow-sm hover:text-foreground group-hover/chip:flex"
|
title={t('chat.draftStarters.remove')}
|
||||||
style={chipStyle}
|
className="absolute -right-1.5 -top-1.5 hidden h-4 w-4 items-center justify-center rounded-full border text-muted-foreground shadow-sm hover:text-foreground group-hover/chip:flex"
|
||||||
>
|
style={chipStyle}
|
||||||
<Icon name="close" className="h-2.5 w-2.5" />
|
>
|
||||||
</button>
|
<Icon name="close" className="h-2.5 w-2.5" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const StarterGroupRow: React.FC<{
|
const StarterGroup: React.FC<{
|
||||||
group: StarterGroup;
|
|
||||||
items: ResolvedStarter[];
|
items: ResolvedStarter[];
|
||||||
onSubmit: (text: string) => void;
|
onSubmit: (text: string) => void;
|
||||||
onRemove: (group: StarterGroup, ref: ResolvedStarter['ref']) => void;
|
onRemove: (item: ResolvedStarter) => void;
|
||||||
onReorder: (group: StarterGroup, fromId: string, toId: string) => void;
|
hideRemove?: boolean;
|
||||||
}> = ({ group, items, onSubmit, onRemove, onReorder }) => {
|
}> = ({ items, onSubmit, onRemove, hideRemove }) => (
|
||||||
const sensors = useSensors(
|
<SortableContext items={items.map((i) => i.id)} strategy={rectSortingStrategy}>
|
||||||
// Desktop: start dragging after a small move so a click still submits.
|
{items.map((item) => (
|
||||||
useSensor(MouseSensor, { activationConstraint: { distance: 8 } }),
|
<SortableChip
|
||||||
// Touch: long-press to drag (tap submits, a quick swipe scrolls instead).
|
key={item.id}
|
||||||
useSensor(TouchSensor, { activationConstraint: { delay: 200, tolerance: 6 } }),
|
item={item}
|
||||||
);
|
onSubmit={onSubmit}
|
||||||
const handleDragEnd = (event: DragEndEvent) => {
|
onRemove={() => onRemove(item)}
|
||||||
const { active, over } = event;
|
hideRemove={hideRemove}
|
||||||
if (over && active.id !== over.id) {
|
/>
|
||||||
onReorder(group, String(active.id), String(over.id));
|
))}
|
||||||
}
|
</SortableContext>
|
||||||
};
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mobile delete target. Sits in the "+" slot and is only mounted while a chip is
|
||||||
|
* being dragged; dropping a chip on it removes that starter. Styled to match the
|
||||||
|
* add ("+") button so the swap reads as the same affordance toggling purpose.
|
||||||
|
*/
|
||||||
|
const TrashDropZone: React.FC = () => {
|
||||||
|
const { t } = useI18n();
|
||||||
|
const { currentTheme } = useThemeSystem();
|
||||||
|
const { setNodeRef, isOver } = useDroppable({ id: TRASH_DROPPABLE_ID });
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
|
<button
|
||||||
<SortableContext items={items.map((i) => i.id)} strategy={rectSortingStrategy}>
|
type="button"
|
||||||
{items.map((item) => (
|
ref={setNodeRef}
|
||||||
<SortableChip
|
aria-label={t('chat.draftStarters.remove')}
|
||||||
key={item.id}
|
title={t('chat.draftStarters.remove')}
|
||||||
item={item}
|
// Same box as the "+" button so the swap never shifts layout; on-hover
|
||||||
onSubmit={onSubmit}
|
// feedback is color-only (no resize).
|
||||||
onRemove={() => onRemove(group, item.ref)}
|
className={cn(
|
||||||
/>
|
ROUND_ICON_BUTTON_CLASS,
|
||||||
))}
|
isOver ? 'border-destructive text-destructive' : 'text-muted-foreground',
|
||||||
</SortableContext>
|
)}
|
||||||
</DndContext>
|
style={{
|
||||||
|
backgroundColor: currentTheme?.colors?.surface?.elevated,
|
||||||
|
borderColor: isOver ? undefined : currentTheme?.colors?.interactive?.border,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icon name="delete-bin" className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -187,7 +220,7 @@ const AddStarterPicker: React.FC<{
|
|||||||
type="button"
|
type="button"
|
||||||
aria-label={t('chat.draftStarters.add')}
|
aria-label={t('chat.draftStarters.add')}
|
||||||
title={t('chat.draftStarters.add')}
|
title={t('chat.draftStarters.add')}
|
||||||
className="inline-flex h-7 w-7 items-center justify-center rounded-full border text-muted-foreground transition-colors hover:bg-[var(--interactive-hover)] hover:text-foreground"
|
className={cn(ROUND_ICON_BUTTON_CLASS, 'text-muted-foreground hover:bg-[var(--interactive-hover)] hover:text-foreground')}
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: currentTheme?.colors?.surface?.elevated,
|
backgroundColor: currentTheme?.colors?.surface?.elevated,
|
||||||
borderColor: currentTheme?.colors?.interactive?.border,
|
borderColor: currentTheme?.colors?.interactive?.border,
|
||||||
@@ -215,19 +248,86 @@ const AddStarterPicker: React.FC<{
|
|||||||
* global group then the project group (each reorderable within itself), plus a
|
* global group then the project group (each reorderable within itself), plus a
|
||||||
* "+" picker to pin existing commands/skills. The surface owns how a chip click
|
* "+" picker to pin existing commands/skills. The surface owns how a chip click
|
||||||
* is submitted via `onSubmit`.
|
* is submitted via `onSubmit`.
|
||||||
|
*
|
||||||
|
* Both groups share a single DndContext so the mobile trash drop-zone (which
|
||||||
|
* replaces the "+" while dragging) is reachable from either group's drag.
|
||||||
|
* Reorder is constrained to within a chip's own group; cross-group hovers are
|
||||||
|
* ignored.
|
||||||
*/
|
*/
|
||||||
export const DraftPresetChips: React.FC<DraftPresetChipsProps> = ({ onSubmit, className }) => {
|
export const DraftPresetChips: React.FC<DraftPresetChipsProps> = ({ onSubmit, className }) => {
|
||||||
const { global, project, pinnable, ensureLoaded, addStarter, removeStarter, reorder } = useDraftStarters();
|
const { global, project, pinnable, ensureLoaded, addStarter, removeStarter, reorder } = useDraftStarters();
|
||||||
|
const { isMobile } = useDeviceInfo();
|
||||||
|
const [isDragging, setIsDragging] = React.useState(false);
|
||||||
|
|
||||||
|
const sensors = useSensors(
|
||||||
|
// Desktop: start dragging after a small move so a click still submits.
|
||||||
|
useSensor(MouseSensor, { activationConstraint: { distance: 8 } }),
|
||||||
|
// Touch: long-press to drag (tap submits, a quick swipe scrolls instead).
|
||||||
|
useSensor(TouchSensor, { activationConstraint: { delay: 200, tolerance: 6 } }),
|
||||||
|
);
|
||||||
|
|
||||||
|
const chipById = React.useCallback(
|
||||||
|
(id: string): ResolvedStarter | undefined =>
|
||||||
|
global.find((i) => i.id === id) ?? project.find((i) => i.id === id),
|
||||||
|
[global, project],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleDragStart = () => setIsDragging(true);
|
||||||
|
const handleDragCancel = () => setIsDragging(false);
|
||||||
|
const handleDragEnd = (event: DragEndEvent) => {
|
||||||
|
setIsDragging(false);
|
||||||
|
const { active, over } = event;
|
||||||
|
if (!over) return;
|
||||||
|
const activeId = String(active.id);
|
||||||
|
const chip = chipById(activeId);
|
||||||
|
if (!chip) return;
|
||||||
|
if (String(over.id) === TRASH_DROPPABLE_ID) {
|
||||||
|
removeStarter(chip.group, chip.ref);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const overId = String(over.id);
|
||||||
|
if (activeId === overId) return;
|
||||||
|
const overChip = chipById(overId);
|
||||||
|
// Reorder only within the same group; ignore cross-group hovers.
|
||||||
|
if (overChip && overChip.group === chip.group) {
|
||||||
|
reorder(chip.group, activeId, overId);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn('flex flex-wrap items-center justify-center gap-2', className)}>
|
<DndContext
|
||||||
{global.length > 0 ? (
|
sensors={sensors}
|
||||||
<StarterGroupRow group="global" items={global} onSubmit={onSubmit} onRemove={removeStarter} onReorder={reorder} />
|
collisionDetection={closestCenter}
|
||||||
) : null}
|
// The trash drop-zone mounts on drag start, so re-measure droppables
|
||||||
{project.length > 0 ? (
|
// while dragging or it never registers a rect to drop onto.
|
||||||
<StarterGroupRow group="project" items={project} onSubmit={onSubmit} onRemove={removeStarter} onReorder={reorder} />
|
measuring={{ droppable: { strategy: MeasuringStrategy.Always } }}
|
||||||
) : null}
|
onDragStart={handleDragStart}
|
||||||
<AddStarterPicker pinnable={pinnable} onOpen={ensureLoaded} onAdd={addStarter} />
|
onDragCancel={handleDragCancel}
|
||||||
</div>
|
onDragEnd={handleDragEnd}
|
||||||
|
>
|
||||||
|
<div className={cn('flex flex-wrap items-center justify-center gap-2', className)}>
|
||||||
|
{global.length > 0 ? (
|
||||||
|
<StarterGroup
|
||||||
|
items={global}
|
||||||
|
onSubmit={onSubmit}
|
||||||
|
onRemove={(item) => removeStarter('global', item.ref)}
|
||||||
|
hideRemove={isMobile}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{project.length > 0 ? (
|
||||||
|
<StarterGroup
|
||||||
|
items={project}
|
||||||
|
onSubmit={onSubmit}
|
||||||
|
onRemove={(item) => removeStarter('project', item.ref)}
|
||||||
|
hideRemove={isMobile}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{isMobile && isDragging ? (
|
||||||
|
<TrashDropZone />
|
||||||
|
) : (
|
||||||
|
<AddStarterPicker pinnable={pinnable} onOpen={ensureLoaded} onAdd={addStarter} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</DndContext>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user