this repo has no description
2
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: animated suggestions box for mobile

+215 -76
+215 -76
mast-react-vite/src/components/ui/action-parser.tsx
··· 7 7 import { Project } from "@/components/ui/project"; 8 8 import { Calendar as CalendarComponent } from "@/components/ui/calendar"; 9 9 import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; 10 + import { motion, AnimatePresence } from "motion/react"; 10 11 11 12 export interface InputProps 12 13 extends React.InputHTMLAttributes<HTMLInputElement> { ··· 24 25 const [showShadow, setShowShadow] = React.useState(false); 25 26 const [desktopCalendarOpen, setDesktopCalendarOpen] = React.useState(false); 26 27 const [mobileCalendarOpen, setMobileCalendarOpen] = React.useState(false); 28 + const [activeSuggestionType, setActiveSuggestionType] = React.useState<string | null>(null); 29 + const [showActiveButton, setShowActiveButton] = React.useState(false); 30 + const [isExiting, setIsExiting] = React.useState(false); 27 31 28 32 // Create refs for the suggestion containers and inputs 29 33 const suggestionsRef = React.useRef<HTMLDivElement>(null); ··· 119 123 setTimeout(() => focusInput(), 0); 120 124 }; 121 125 126 + const exitActiveMode = () => { 127 + setIsExiting(true); 128 + // First, fade back in the regular buttons and move active button back 129 + setTimeout(() => { 130 + // After animation completes, hide active button and reset states 131 + setShowActiveButton(false); 132 + setActiveSuggestionType(null); 133 + setSuggestions([]); 134 + setIsExiting(false); 135 + }, 300); // Match animation duration 136 + }; 137 + 122 138 const checkScrollShadow = (element: HTMLDivElement | null) => { 123 139 if (!element) return; 124 140 const hasMoreContent = element.scrollWidth > element.clientWidth; ··· 164 180 } 165 181 166 182 setSuggestions(tags); 183 + setActiveSuggestionType('#'); 184 + setShowActiveButton(true); 167 185 } 168 186 else if (lastWord.startsWith('+')) { 169 187 // Extract the partial project (text after the +) ··· 188 206 } 189 207 190 208 setSuggestions(projects); 209 + setActiveSuggestionType('+'); 210 + setShowActiveButton(true); 191 211 } 192 212 else { 193 - setSuggestions([]); 213 + if (showActiveButton) { 214 + exitActiveMode(); 215 + } else { 216 + setSuggestions([]); 217 + setActiveSuggestionType(null); 218 + setShowActiveButton(false); 219 + } 194 220 } 195 221 } catch (error) { 196 222 console.error("Error fetching suggestions:", error); 197 - setSuggestions([]); 223 + if (showActiveButton) { 224 + exitActiveMode(); 225 + } else { 226 + setSuggestions([]); 227 + setActiveSuggestionType(null); 228 + setShowActiveButton(false); 229 + } 198 230 } 199 231 }; 200 232 ··· 359 391 onScroll={handleSuggestionsScroll} 360 392 > 361 393 {suggestions.map((suggestion, index) => { 362 - const trimmedValue = (value as string || '').trim(); 363 - const words = trimmedValue.split(/\s+/); 364 - const lastWord = words[words.length - 1] || ''; 394 + const trimmedValue = (value as string || '').trim(); 395 + const words = trimmedValue.split(/\s+/); 396 + const lastWord = words[words.length - 1] || ''; 397 + 398 + const handleClick = () => { 399 + // Replace just the last word with the suggestion 400 + if (activeSuggestionType === '#') { 401 + words[words.length - 1] = `#${suggestion}`; 402 + } else if (activeSuggestionType === '+') { 403 + words[words.length - 1] = `+${suggestion}`; 404 + } 405 + 406 + const newValue = words.join(' '); 407 + props.onChange?.({ target: { value: newValue } } as any); 408 + 409 + // Reset suggestion state to trigger animation 410 + setSuggestions([]); 411 + setActiveSuggestionType(null); 412 + setShowActiveButton(false); 365 413 366 - const handleClick = () => { 367 - // Replace just the last word with the suggestion 368 - if (lastWord.startsWith('#')) { 369 - words[words.length - 1] = `#${suggestion}`; 370 - } else if (lastWord.startsWith('+')) { 371 - words[words.length - 1] = `+${suggestion}`; 372 - } 373 - 374 - const newValue = words.join(' '); 375 - props.onChange?.({ target: { value: newValue } } as any); 376 - setTimeout(() => focusInput(), 0); 377 - }; 414 + setTimeout(() => focusInput(), 0); 415 + }; 378 416 379 - // Return Tag or Project component based on input type 380 - return lastWord.startsWith('#') ? ( 417 + // Return Tag or Project component based on activeSuggestionType 418 + return activeSuggestionType === '#' ? ( 381 419 <Tag 382 420 key={index} 383 421 tag={suggestion} 384 422 onClick={handleClick} 385 423 /> 386 - ) : lastWord.startsWith('+') ? ( 424 + ) : activeSuggestionType === '+' ? ( 387 425 <Project 388 426 key={index} 389 427 name={suggestion} ··· 403 441 })} 404 442 </div> 405 443 </> 406 - )}</div> 444 + )}</div> 407 445 408 446 </div> 409 447 <Button ··· 480 518 )} 481 519 ref={mobileInputRef} 482 520 value={value} 521 + onKeyDown={(e) => { 522 + if (e.key === ' ' || e.key === 'Escape') { 523 + if (showActiveButton) { 524 + exitActiveMode(); 525 + } else { 526 + setSuggestions([]); 527 + setActiveSuggestionType(null); 528 + setShowActiveButton(false); 529 + } 530 + } 531 + props.onKeyDown?.(e); 532 + }} 483 533 {...props} 484 534 /> 485 535 </div> ··· 489 539 490 540 {/* Second line (non-interactive) with submit button */} 491 541 <div className="flex-1 min-h-[36px] border border-t-0 rounded-b-md bg-background px-3 py-2 text-sm text-muted-foreground flex items-center justify-between group-focus-within:border-ring"> 492 - <div className="flex space-x-3 items-center flex-grow overflow-hidden"> 493 - <Button 494 - variant="ghost" 495 - size="sm" 496 - onClick={() => { 497 - const trimmedVal = value && (value as string).length > 0 ? (value as string).trim() + " " : ""; 498 - const newValue = `${trimmedVal}#`; 499 - props.onChange?.({ target: { value: newValue } } as any); 500 - setTimeout(() => focusInput(), 0) 542 + <div className="relative flex items-center flex-grow overflow-hidden"> 543 + {/* All buttons as motion.divs that always exist */} 544 + <motion.div 545 + className="flex space-x-3 items-center text-foreground" 546 + animate={{ 547 + width: showActiveButton && !isExiting ? 44 : 'auto' // Shrink to just fit active button when suggestions showing 501 548 }} 502 - className="h-8 px-2 text-muted-foreground hover:text-foreground flex-shrink-0" 549 + transition={{ duration: 0.3, ease: "easeOut" }} 503 550 > 504 - # 505 - </Button> 506 - <Button 507 - variant="ghost" 508 - size="sm" 509 - onClick={() => { 510 - const trimmedVal = value && (value as string).length > 0 ? (value as string).trim() + " " : ""; 511 - const newValue = `${trimmedVal}+`; 512 - props.onChange?.({ target: { value: newValue } } as any); 513 - setTimeout(() => focusInput(), 0) 514 - }} 515 - className="h-8 px-2 text-muted-foreground hover:text-foreground flex-shrink-0" 516 - > 517 - + 518 - </Button> 519 - <Popover open={mobileCalendarOpen} onOpenChange={setMobileCalendarOpen}> 520 - <PopoverTrigger asChild> 551 + {/* # Button */} 552 + <motion.div 553 + animate={{ 554 + opacity: showActiveButton && activeSuggestionType === '#' ? 555 + 1 : // Active # button always stays visible 556 + (showActiveButton && !isExiting ? 0 : 1), // Other buttons fade when not active 557 + x: 0, // # button stays in place since it's already first 558 + backgroundColor: showActiveButton && activeSuggestionType === '#' && !isExiting ? 559 + 'hsl(var(--accent))' : 'hsl(var(--background))', 560 + borderRadius: 6 561 + }} 562 + transition={{ duration: 0.3, ease: "easeOut" }} 563 + className="flex items-center text-foreground" 564 + > 565 + <Button 566 + variant="ghost" 567 + size="sm" 568 + onClick={() => { 569 + if (showActiveButton && !isExiting) return; 570 + const trimmedVal = value && (value as string).length > 0 ? (value as string).trim() + " " : ""; 571 + const newValue = `${trimmedVal}#`; 572 + props.onChange?.({ target: { value: newValue } } as any); 573 + setTimeout(() => focusInput(), 0) 574 + }} 575 + className={cn( 576 + "h-8 px-2 flex-shrink-0", 577 + showActiveButton && activeSuggestionType === '#' && !isExiting 578 + ? "text-foreground hover:text-foreground" 579 + : "text-muted-foreground hover:text-foreground" 580 + )} 581 + > 582 + # 583 + </Button> 584 + </motion.div> 585 + 586 + {/* + Button */} 587 + <motion.div 588 + animate={{ 589 + opacity: showActiveButton && activeSuggestionType === '+' ? 590 + 1 : // Active + button always stays visible 591 + (showActiveButton && !isExiting ? 0 : 1), // Other buttons fade when not active 592 + x: showActiveButton && activeSuggestionType === '+' && !isExiting ? -36 : 0, // Move to first position 593 + backgroundColor: showActiveButton && activeSuggestionType === '+' && !isExiting ? 594 + 'hsl(var(--accent))' : 'hsl(var(--background))', 595 + borderRadius: 6 596 + }} 597 + transition={{ duration: 0.3, ease: "easeOut" }} 598 + className="flex items-center text-foreground" 599 + > 600 + <Button 601 + variant="ghost" 602 + size="sm" 603 + onClick={() => { 604 + if (showActiveButton && !isExiting) return; 605 + const trimmedVal = value && (value as string).length > 0 ? (value as string).trim() + " " : ""; 606 + const newValue = `${trimmedVal}+`; 607 + props.onChange?.({ target: { value: newValue } } as any); 608 + setTimeout(() => focusInput(), 0) 609 + }} 610 + className={cn( 611 + "h-8 px-2 flex-shrink-0", 612 + showActiveButton && activeSuggestionType === '+' && !isExiting 613 + ? "text-foreground hover:text-foreground" 614 + : "text-muted-foreground hover:text-foreground" 615 + )} 616 + > 617 + + 618 + </Button> 619 + </motion.div> 620 + 621 + {/* Calendar Button */} 622 + <motion.div 623 + animate={{ 624 + opacity: (showActiveButton && !isExiting) ? 0 : 1 625 + }} 626 + transition={{ duration: 0.15, ease: "easeOut" }} 627 + className="flex items-center" 628 + style={{ 629 + pointerEvents: (showActiveButton && !isExiting) ? 'none' : 'auto' 630 + }} 631 + > 632 + <Popover open={mobileCalendarOpen} onOpenChange={setMobileCalendarOpen}> 633 + <PopoverTrigger asChild> 634 + <Button 635 + variant="ghost" 636 + size="sm" 637 + className="h-8 px-2 text-muted-foreground hover:text-foreground flex-shrink-0" 638 + disabled={showActiveButton && !isExiting} 639 + > 640 + <Calendar /> 641 + </Button> 642 + </PopoverTrigger> 643 + <PopoverContent className="dark w-auto p-0 border bg-popover text-popover-foreground" align="start"> 644 + <CalendarComponent 645 + mode="single" 646 + selected={undefined} 647 + onSelect={(date) => handleDateSelect(date, false)} 648 + initialFocus 649 + /> 650 + </PopoverContent> 651 + </Popover> 652 + </motion.div> 653 + 654 + {/* Flag Button */} 655 + <motion.div 656 + animate={{ 657 + opacity: (showActiveButton && !isExiting) ? 0 : 1 658 + }} 659 + transition={{ duration: 0.15, ease: "easeOut" }} 660 + className="flex items-center" 661 + style={{ 662 + pointerEvents: (showActiveButton && !isExiting) ? 'none' : 'auto' 663 + }} 664 + > 521 665 <Button 522 666 variant="ghost" 523 667 size="sm" 668 + onClick={handlePriorityToggle} 524 669 className="h-8 px-2 text-muted-foreground hover:text-foreground flex-shrink-0" 670 + disabled={showActiveButton && !isExiting} 525 671 > 526 - <Calendar /> 672 + <Flag /> 527 673 </Button> 528 - </PopoverTrigger> 529 - <PopoverContent className="dark w-auto p-0 border bg-popover text-popover-foreground" align="start"> 530 - <CalendarComponent 531 - mode="single" 532 - selected={undefined} 533 - onSelect={(date) => handleDateSelect(date, false)} 534 - initialFocus 535 - /> 536 - </PopoverContent> 537 - </Popover> 538 - <Button 539 - variant="ghost" 540 - size="sm" 541 - onClick={handlePriorityToggle} 542 - className="h-8 px-2 text-muted-foreground hover:text-foreground flex-shrink-0" 543 - > 544 - <Flag /> 545 - </Button> 546 - <div className="h-7 flex-1 min-w-0 relative"> 547 - {suggestions.length > 0 && ( 674 + </motion.div> 675 + </motion.div> 676 + 677 + <div className="h-7 flex-1 min-w-0 relative"> 678 + {suggestions.length > 0 && showActiveButton && ( 548 679 <> 549 680 {showShadow && ( 550 681 <div className="absolute right-0 top-0 bottom-0 w-8 pointer-events-none bg-gradient-to-r from-transparent to-background z-10 shadow-sm" /> 551 682 )} 552 - <div 553 - className="flex space-x-2 overflow-x-auto w-full" 683 + <motion.div 684 + className="flex space-x-2 overflow-x-auto w-full" 685 + animate={{ 686 + opacity: isExiting ? 0 : 1 687 + }} 688 + transition={{ duration: 0.3, ease: "easeOut" }} 554 689 ref={mobileSuggestionsRef} 555 690 onScroll={handleSuggestionsScroll} 556 691 > ··· 569 704 570 705 const newValue = words.join(' '); 571 706 props.onChange?.({ target: { value: newValue } } as any); 707 + 708 + // Reset suggestion state 709 + exitActiveMode(); 572 710 setTimeout(() => focusInput(), 0); 573 711 }; 574 712 575 - // Return Tag or Project component based on input type 576 - return lastWord.startsWith('#') ? ( 713 + // Return Tag or Project component based on activeSuggestionType 714 + return activeSuggestionType === '#' ? ( 577 715 <Tag 578 716 key={index} 579 717 tag={suggestion} 580 718 onClick={handleClick} 581 719 /> 582 - ) : lastWord.startsWith('+') ? ( 720 + ) : activeSuggestionType === '+' ? ( 583 721 <Project 584 722 key={index} 585 723 name={suggestion} ··· 597 735 </Button> 598 736 ); 599 737 })} 738 + </motion.div> 739 + </> 740 + )} 600 741 </div> 601 - </> 602 - )}</div> 603 742 604 743 </div> 605 744 <Button