-
Notifications
You must be signed in to change notification settings - Fork 155
Feature/cell expand side sheet #115
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
6 commits
Select commit
Hold shift + click to select a range
5d07a8b
Add cell expand side sheet with sources
MMeteorL 702f32d
Fix lucide-react module-not-found by using inline SVGs
MMeteorL 247e882
Fix perf regression, simplify state shape, remove dead CSS
MMeteorL 0449877
Address CodeRabbit review: XSS guard, timer cleanup, a11y, lint fixes
MMeteorL 5963207
Merge branch 'main' into feature/cell-expand-side-sheet
MMeteorL 86a1613
Merge branch 'main' into feature/cell-expand-side-sheet
simantak-dabhade File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,228 @@ | ||
| "use client"; | ||
|
|
||
| import { useEffect, useRef, useState, useCallback } from "react"; | ||
| import type { DatasetColumn } from "@/components/table/types"; | ||
|
|
||
| function IconX() { | ||
| return ( | ||
| <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"> | ||
| <path d="M18 6 6 18"/><path d="m6 6 12 12"/> | ||
| </svg> | ||
| ); | ||
| } | ||
| function IconCopy() { | ||
| return ( | ||
| <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"> | ||
| <rect width="14" height="14" x="8" y="8" rx="2" ry="2"/><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/> | ||
| </svg> | ||
| ); | ||
| } | ||
| function IconCheck() { | ||
| return ( | ||
| <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"> | ||
| <path d="M20 6 9 17l-5-5"/> | ||
| </svg> | ||
| ); | ||
| } | ||
| function IconExternalLink() { | ||
| return ( | ||
| <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"> | ||
| <path d="M15 3h6v6"/><path d="M10 14 21 3"/><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/> | ||
| </svg> | ||
| ); | ||
| } | ||
|
|
||
| /* ------------------------------------------------------------------ */ | ||
| /* Shell */ | ||
| /* ------------------------------------------------------------------ */ | ||
|
|
||
| interface SideSheetProps { | ||
| open: boolean; | ||
| onClose: () => void; | ||
| children: React.ReactNode; | ||
| } | ||
|
|
||
| export function SideSheet({ open, onClose, children }: SideSheetProps) { | ||
| const panelRef = useRef<HTMLDivElement>(null); | ||
|
|
||
| // Lock body scroll while open. | ||
| useEffect(() => { | ||
| if (!open) return; | ||
| const prev = document.body.style.overflow; | ||
| document.body.style.overflow = "hidden"; | ||
| return () => { document.body.style.overflow = prev; }; | ||
| }, [open]); | ||
|
|
||
| // Keyboard: Escape closes, Tab stays trapped inside. | ||
| useEffect(() => { | ||
| if (!open || !panelRef.current) return; | ||
| const panel = panelRef.current; | ||
|
|
||
| const focusable = panel.querySelectorAll<HTMLElement>( | ||
| 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])', | ||
| ); | ||
| focusable[0]?.focus(); | ||
|
|
||
| function onKey(e: KeyboardEvent) { | ||
| if (e.key === "Escape") { onClose(); return; } | ||
| if (e.key !== "Tab" || focusable.length === 0) return; | ||
| const first = focusable[0]; | ||
| const last = focusable[focusable.length - 1]; | ||
| if (e.shiftKey && document.activeElement === first) { | ||
| e.preventDefault(); last.focus(); | ||
| } else if (!e.shiftKey && document.activeElement === last) { | ||
| e.preventDefault(); first.focus(); | ||
| } | ||
| } | ||
|
|
||
| panel.addEventListener("keydown", onKey); | ||
| return () => panel.removeEventListener("keydown", onKey); | ||
| }, [open, onClose]); | ||
|
|
||
| if (!open) return null; | ||
|
|
||
| return ( | ||
| <div className="fixed inset-0 z-50 flex items-center justify-center p-4" role="dialog" aria-modal="true"> | ||
| {/* Backdrop */} | ||
| <div | ||
| className="absolute inset-0 bg-foreground/20 backdrop-blur-sm" | ||
| onClick={onClose} | ||
| /> | ||
| {/* Modal */} | ||
| <div | ||
| ref={panelRef} | ||
| aria-labelledby="cell-detail-title" | ||
| className="relative w-full max-w-lg max-h-[80vh] bg-surface border border-border rounded-xl shadow-2xl flex flex-col" | ||
| > | ||
| <div className="flex items-center justify-between px-5 py-4 border-b border-border shrink-0"> | ||
| <h2 id="cell-detail-title" className="text-sm font-semibold text-foreground">Cell Detail</h2> | ||
| <button | ||
| onClick={onClose} | ||
| className="inline-flex items-center justify-center h-7 w-7 text-muted hover:text-foreground transition-colors rounded" | ||
| aria-label="Close" | ||
| > | ||
| <IconX /> | ||
| </button> | ||
| </div> | ||
| <div className="flex-1 overflow-y-auto p-5">{children}</div> | ||
| </div> | ||
| </div> | ||
| ); | ||
| } | ||
|
|
||
| /* ------------------------------------------------------------------ */ | ||
| /* Content */ | ||
| /* ------------------------------------------------------------------ */ | ||
|
|
||
| interface CellDetailProps { | ||
| column: DatasetColumn; | ||
| value: unknown; | ||
| /** Row-level sources stored by the populate agent. */ | ||
| sources?: string[]; | ||
| } | ||
|
|
||
| function isValidHttpUrl(src: string): boolean { | ||
| try { | ||
| const { protocol } = new URL(src); | ||
| return protocol === "http:" || protocol === "https:"; | ||
| } catch { | ||
| return false; | ||
| } | ||
| } | ||
|
|
||
| export function CellDetail({ column, value, sources }: CellDetailProps) { | ||
| const [copied, setCopied] = useState(false); | ||
| const copyTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null); | ||
| const displayValue = value == null || value === "" ? "—" : String(value); | ||
|
|
||
| // Clear any pending timer when the component unmounts to avoid calling | ||
| // setCopied on an already-gone component. | ||
| useEffect(() => { | ||
| return () => { | ||
| if (copyTimerRef.current != null) clearTimeout(copyTimerRef.current); | ||
| }; | ||
| }, []); | ||
|
|
||
| const handleCopy = useCallback(async () => { | ||
| try { | ||
| await navigator.clipboard.writeText(value == null ? "" : String(value)); | ||
| setCopied(true); | ||
| if (copyTimerRef.current != null) clearTimeout(copyTimerRef.current); | ||
| copyTimerRef.current = setTimeout(() => setCopied(false), 2000); | ||
| } catch { | ||
| // Clipboard API unavailable (e.g. non-HTTPS dev); silently ignore. | ||
| } | ||
| }, [value]); | ||
|
|
||
| return ( | ||
| <div className="space-y-6"> | ||
| {/* Column name + description */} | ||
| <div> | ||
| <p className="text-sm font-semibold text-foreground">{column.name}</p> | ||
| {column.description && ( | ||
| <p className="text-xs text-muted mt-0.5">{column.description}</p> | ||
| )} | ||
| </div> | ||
|
|
||
| {/* Value */} | ||
| <div> | ||
| <div className="flex items-center justify-between mb-1.5"> | ||
| <p className="text-[11px] font-medium text-muted uppercase tracking-wide"> | ||
| Value <span className="normal-case">({column.type})</span> | ||
| </p> | ||
| <button | ||
| type="button" | ||
| onClick={() => { void handleCopy(); }} | ||
| className="inline-flex items-center gap-1.5 text-[11px] font-medium text-muted hover:text-foreground transition-colors" | ||
| aria-label="Copy value" | ||
| > | ||
| {copied | ||
| ? <><span className="text-green-500"><IconCheck /></span><span className="text-green-500">Copied</span></> | ||
| : <><IconCopy /><span>Copy</span></> | ||
| } | ||
| </button> | ||
| </div> | ||
| <div | ||
| className="rounded-lg border border-border bg-background px-4 py-3" | ||
| data-ph-mask-text="true" | ||
| > | ||
| <p className="text-sm text-foreground break-all whitespace-pre-wrap"> | ||
| {displayValue} | ||
| </p> | ||
| </div> | ||
| </div> | ||
|
|
||
| {/* Sources */} | ||
| {sources && sources.length > 0 && ( | ||
| <div> | ||
| <p className="text-[11px] font-medium text-muted uppercase tracking-wide mb-1.5"> | ||
| Sources | ||
| </p> | ||
| <ul className="space-y-1.5"> | ||
| {sources.map((src, i) => ( | ||
| <li key={src || i}> | ||
| {isValidHttpUrl(src) ? ( | ||
| <a | ||
| href={src} | ||
| target="_blank" | ||
| rel="noopener noreferrer" | ||
| className="flex items-start gap-1.5 text-xs text-link hover:underline break-all" | ||
| data-ph-mask-text="true" | ||
| > | ||
| <span className="mt-0.5 shrink-0"><IconExternalLink /></span> | ||
| {src} | ||
| </a> | ||
| ) : ( | ||
| <span className="flex items-start gap-1.5 text-xs text-muted break-all" data-ph-mask-text="true"> | ||
| <span className="mt-0.5 shrink-0"><IconExternalLink /></span> | ||
| {src} | ||
| </span> | ||
| )} | ||
| </li> | ||
| ))} | ||
| </ul> | ||
| </div> | ||
| )} | ||
| </div> | ||
| ); | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.