diff --git a/.gitignore b/.gitignore index 03a5e8505..c72435ed6 100644 --- a/.gitignore +++ b/.gitignore @@ -34,3 +34,4 @@ cursor # catch all for temporary outputs tmp/ +.agents/ diff --git a/packages/react-spectrum-charts-s2/src/Chart.css b/packages/react-spectrum-charts-s2/src/Chart.css index 287e40db0..8c086b770 100644 --- a/packages/react-spectrum-charts-s2/src/Chart.css +++ b/packages/react-spectrum-charts-s2/src/Chart.css @@ -81,6 +81,42 @@ this removes transitions in the vega tooltip font-weight: 400; line-height: 1.5; } +/* Collapse the S2 Dialog's default min-height so the action bar fits its content tightly. + S2 component styles are in @layer, so un-layered rules here take precedence without !important. */ +.rsc-action-bar { + min-block-size: 0; + max-inline-size: 800px; +} +.rsc-action-bar-drag-handle { + width: 4px; + align-self: stretch; + min-block-size: 20px; + background-color: var(--spectrum-gray-500, #b3b3b3); + border-radius: 2px; + cursor: grab; + flex-shrink: 0; + touch-action: none; +} +.rsc-action-bar-drag-handle:active { + cursor: grabbing; +} +.rsc-action-bar--emphasized { + background-color: var(--spectrum-accent-background-color-default, #0d66d0); + border-color: transparent; +} +.rsc-action-bar--emphasized .rsc-action-bar-drag-handle { + background-color: rgba(255, 255, 255, 0.5); +} +.rsc-action-bar-overflow { + position: absolute; + bottom: calc(100% + 4px); + right: 0; + display: flex; + flex-direction: column; + padding: 4px; + white-space: nowrap; + z-index: 1; +} .rsc-container { position: relative; width: auto; diff --git a/packages/react-spectrum-charts-s2/src/RscChart.tsx b/packages/react-spectrum-charts-s2/src/RscChart.tsx index ddd28237e..2d019b78e 100644 --- a/packages/react-spectrum-charts-s2/src/RscChart.tsx +++ b/packages/react-spectrum-charts-s2/src/RscChart.tsx @@ -9,7 +9,7 @@ * OF ANY KIND, either express or implied. See the License for the specific language * governing permissions and limitations under the License. */ -import { RefObject, Ref, useCallback, useEffect, useMemo, useState } from 'react'; +import { FC, PointerEvent, RefObject, Ref, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; import { ActionButton, Dialog, DialogTrigger, View as SpectrumView } from '@adobe/react-spectrum'; import { View as VegaView } from 'vega'; @@ -19,6 +19,7 @@ import { ChartHandle, Datum, SymbolSize, getChartConfig } from '@spectrum-charts import './Chart.css'; import { VegaChart } from './VegaChart'; import { useChartContext } from './context/RscChartContext'; +import useActionBars, { ActionBarDetail } from './hooks/useActionBars'; import useChartImperativeHandle from './hooks/useChartImperativeHandle'; import { useChartInteractions } from './hooks/useChartInteractions'; import usePopovers, { PopoverDetail } from './hooks/usePopovers'; @@ -35,6 +36,14 @@ interface ChartDialogProps { specSignalNames: ReadonlySet; } +interface ChartActionBarDialogProps { + actionBar: ActionBarDetail; + idKey: string; + setIsPopoverOpen: (isOpen: boolean) => void; + specSignalNames: ReadonlySet; + targetElement: RefObject; +} + export const RscChart = ({ ref, ...props }: RscChartProps & { ref?: Ref }) => { const { backgroundColor, @@ -105,6 +114,7 @@ export const RscChart = ({ ref, ...props }: RscChartProps & { ref?: Ref ))} + {actionBars.map((ab) => ( + + ))} ); }; RscChart.displayName = 'RscChart'; +const ChartActionBarDialog: FC = ({ + actionBar, + idKey, + setIsPopoverOpen, + specSignalNames, + targetElement, +}) => { + const { chartView, selectedData, selectedDataName } = useChartContext(); + const [isOpen, setIsOpen] = useState(false); + const [renderDatum, setRenderDatum] = useState(null); + const [overflowOpen, setOverflowOpen] = useState(false); + const containerRef = useRef(null); + const dragStartRef = useRef<{ pointerX: number; pointerY: number; startLeft: number; startTop: number } | null>(null); + const { chartActionBarProps, name } = actionBar; + const { children, isEmphasized, maxActions: maxActionsProp, onClearSelection } = chartActionBarProps; + const MAX_ACTIONS = maxActionsProp ?? 4; + const [visibleCount, setVisibleCount] = useState(MAX_ACTIONS); + + const closeActionBar = useCallback(() => { + setIsOpen(false); + setOverflowOpen(false); + setIsPopoverOpen(false); + onClearSelection?.(); + if (chartView.current) { + const componentName = selectedDataName.current; + selectedData.current = null; + selectedDataName.current = ''; + if (componentName) { + clearHoverSignals(chartView.current, componentName, specSignalNames); + } + setSelectedSignals({ idKey, selectedData: null, view: chartView.current }); + chartView.current.run(); + } + }, [chartView, idKey, onClearSelection, selectedData, selectedDataName, setIsPopoverOpen, specSignalNames]); + + const allActions = renderDatum && renderDatum[COMPONENT_NAME] === name + ? (children?.(renderDatum, closeActionBar) ?? []) + : []; + const visibleActions = allActions.slice(0, visibleCount); + const overflowActions = allActions.slice(visibleCount); + + // Position the bar above the anchor point before the browser paints. + useLayoutEffect(() => { + if (!isOpen || !containerRef.current || !targetElement.current) return; + const anchorRect = targetElement.current.getBoundingClientRect(); + const barHeight = containerRef.current.offsetHeight; + containerRef.current.style.left = `${anchorRect.left}px`; + containerRef.current.style.top = `${anchorRect.top - barHeight - 8}px`; + }, [isOpen, targetElement]); + + // Space-based overflow: reduce visible count when bar content exceeds its max-inline-size. + // Runs after each render until the bar no longer overflows or visibleCount hits 1. + useLayoutEffect(() => { + if (!containerRef.current || visibleCount <= 1) return; + if (containerRef.current.scrollWidth > containerRef.current.clientWidth) { + setVisibleCount((v) => v - 1); + } + }, [visibleCount, renderDatum]); + + // Re-evaluate on window resize (more aggressive on smaller viewports). + useEffect(() => { + if (!isOpen) return; + const handleResize = () => setVisibleCount(MAX_ACTIONS); + window.addEventListener('resize', handleResize); + return () => window.removeEventListener('resize', handleResize); + }, [isOpen, MAX_ACTIONS]); + + // Reset overflow state when bar opens at a new point. + useEffect(() => { + setVisibleCount(MAX_ACTIONS); + setOverflowOpen(false); + }, [renderDatum, MAX_ACTIONS]); + + // Close when clicking outside the bar (overflow panel is inside containerRef so it's excluded). + useEffect(() => { + if (!isOpen) return; + const handleClickOutside = (e: MouseEvent) => { + if (containerRef.current && !containerRef.current.contains(e.target as Node)) { + closeActionBar(); + } + }; + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); + }, [isOpen, closeActionBar]); + + const handleDragStart = useCallback((e: PointerEvent) => { + e.currentTarget.setPointerCapture(e.pointerId); + const rect = containerRef.current?.getBoundingClientRect(); + if (rect) { + dragStartRef.current = { pointerX: e.clientX, pointerY: e.clientY, startLeft: rect.left, startTop: rect.top }; + } + }, []); + + // Mutate DOM directly during drag to avoid React re-render lag. + const handleDragMove = useCallback((e: PointerEvent) => { + if (!dragStartRef.current || !containerRef.current) return; + const dx = e.clientX - dragStartRef.current.pointerX; + const dy = e.clientY - dragStartRef.current.pointerY; + containerRef.current.style.left = `${dragStartRef.current.startLeft + dx}px`; + containerRef.current.style.top = `${dragStartRef.current.startTop + dy}px`; + }, []); + + const handleDragEnd = useCallback(() => { + dragStartRef.current = null; + }, []); + + return ( + <> +