Files
calctext/calcpad-web/src/components/MobileResultsTray.tsx
C. Cassel 0d38bd3108 feat(web): implement complete workspace with themes, tabs, sidebar, and mobile
Transform CalcText from a single-document calculator into a full workspace
application with multi-document support, theming, and responsive mobile experience.

- Theme system: 5 presets (Light, Dark, Matrix, Midnight, Warm) + accent colors
- Document model with localStorage persistence and auto-save
- Tab bar with keyboard shortcuts (Ctrl+N/W/Tab/1-9), rename, close
- Sidebar with search, recent, favorites, folders, templates, drag-and-drop
- 5 templates: Budget, Invoice, Unit Converter, Trip Planner, Loan Calculator
- Status bar with cursor position, engine status, dedication to Igor Cassel
- Results panel: type-specific colors, click-to-copy, error hints
- Format toolbar: H, B, I, //, color labels with live preview toggle
- Syntax highlighting using theme CSS variables
- Error hover tooltips
- Mobile: bottom results tray, sidebar drawer, touch targets, safe areas
- Docker multi-stage build (Rust WASM + Vite + Nginx)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 09:12:05 -04:00

106 lines
3.3 KiB
TypeScript

/**
* Mobile results tray — replaces the side panel on small screens.
* Collapsed: shows last result + drag handle (48px).
* Expanded: scrollable list of all results (40vh).
*/
import { useState, useCallback, useRef } from 'react'
import type { EngineLineResult } from '../engine/types.ts'
import '../styles/mobile-results-tray.css'
const DISPLAYABLE_TYPES = new Set([
'number', 'unitValue', 'currencyValue', 'dateTime', 'timeDelta', 'boolean',
])
interface MobileResultsTrayProps {
results: EngineLineResult[]
docLines: string[]
}
export function MobileResultsTray({ results, docLines }: MobileResultsTrayProps) {
const [expanded, setExpanded] = useState(false)
const [copiedIdx, setCopiedIdx] = useState<number | null>(null)
const startY = useRef<number | null>(null)
// Find last displayable result
let lastResult = ''
for (let i = results.length - 1; i >= 0; i--) {
if (DISPLAYABLE_TYPES.has(results[i].type) && results[i].display) {
lastResult = results[i].display
break
}
}
const handleCopy = useCallback((idx: number, rawValue: number | null, display: string) => {
const text = rawValue != null ? String(rawValue) : display
navigator.clipboard.writeText(text).then(() => {
setCopiedIdx(idx)
setTimeout(() => setCopiedIdx(null), 1200)
}).catch(() => {})
}, [])
// Touch swipe handling
const handleTouchStart = useCallback((e: React.TouchEvent) => {
startY.current = e.touches[0].clientY
}, [])
const handleTouchEnd = useCallback((e: React.TouchEvent) => {
if (startY.current === null) return
const deltaY = startY.current - e.changedTouches[0].clientY
startY.current = null
if (deltaY > 40) setExpanded(true) // swipe up
if (deltaY < -40) setExpanded(false) // swipe down
}, [])
// Build result items for expanded view
const resultItems = results.map((r, i) => {
if (!DISPLAYABLE_TYPES.has(r.type) || !r.display) return null
const expr = docLines[i]?.trim() ?? ''
const isCopied = copiedIdx === i
return (
<div
key={i}
className={`tray-result-item ${isCopied ? 'copied' : ''}`}
onClick={() => handleCopy(i, r.rawValue, r.display)}
>
<span className="tray-result-line">Ln {i + 1}</span>
<span className="tray-result-expr">{expr}</span>
<span className="tray-result-value">{isCopied ? 'Copied!' : r.display}</span>
</div>
)
}).filter(Boolean)
return (
<div
className={`mobile-results-tray ${expanded ? 'expanded' : ''}`}
onTouchStart={handleTouchStart}
onTouchEnd={handleTouchEnd}
>
{/* Drag handle + collapsed view */}
<div
className="tray-header"
onClick={() => setExpanded(prev => !prev)}
>
<div className="tray-drag-handle" />
{!expanded && (
<span className="tray-last-result">
{lastResult ? `Last: ${lastResult}` : 'No results'}
</span>
)}
{expanded && (
<span className="tray-last-result">{resultItems.length} results</span>
)}
</div>
{/* Expanded content */}
{expanded && (
<div className="tray-content">
{resultItems.length > 0 ? resultItems : (
<div className="tray-empty">No results yet</div>
)}
</div>
)}
</div>
)
}