From 7d6ba4c4fc54b08fb89ffaa52298538b3c464b75 Mon Sep 17 00:00:00 2001 From: Einar Date: Mon, 10 Aug 2026 16:55:57 +0200 Subject: [PATCH 1/3] Stop a settled fan-out panel from clipping its tooltips MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The reveal is a clip-path wipe, so the clip has to exist while it animates — clip-path: none is discrete and snaps instead of interpolating. But clip-path clips every descendant, so leaving a zero inset in place after the wipe has landed slices off the hover tooltips of the outermost buttons, which paint outside the panel by design. Widening the settled inset is not a way out: the wipe front IS the inset travelling 100% -> 0, so bleeding it outward extends the travel and the reveal completes early (measured at ~95ms of a 350ms transition with a 32rem bleed). Release the clip once the reveal has landed instead, and put an interpolable inset back before a close begins, so both wipes keep their exact timing. Co-Authored-By: Claude Opus 5 (1M context) --- Source/Toolbar/Toolbar.css | 27 +++++++++++++++-- Source/Toolbar/ToolbarFanOutItem.tsx | 45 ++++++++++++++++++++++++---- 2 files changed, 65 insertions(+), 7 deletions(-) diff --git a/Source/Toolbar/Toolbar.css b/Source/Toolbar/Toolbar.css index fdd82d6..4689d8f 100644 --- a/Source/Toolbar/Toolbar.css +++ b/Source/Toolbar/Toolbar.css @@ -187,6 +187,22 @@ pointer-events: auto; } +/* + * Reveal complete. The clip-path did its job — the wipe cannot run without it, + * because `clip-path: none` is a discrete value that snaps instead of + * interpolating. But clip-path also clips every descendant, so leaving a zero + * inset in place slices off the hover tooltips of the outermost buttons, which + * paint outside the panel by design. + * + * ToolbarFanOutItem therefore adds this class once the reveal transition ends, + * and takes it off again (restoring an interpolable inset) before the close + * transition starts — so both wipes keep their exact timing and a settled panel + * clips nothing. + */ +.toolbar-fanout-panel--settled { + clip-path: none; +} + /* ── Toolbar folder ──────────────────────────────────────────────────────── */ .toolbar-folder-item { @@ -288,18 +304,25 @@ /* ── Toolbar slot transition (ToolbarGroup slot content) ─────────────────── */ /* - * Size-morphing container for slot content inside a ToolbarGroup. + * Size-morphing container for slot content inside a ToolbarGroup or ToolbarLayout. * Mirrors the .toolbar-section transition parameters for visual consistency — * the container resizes smoothly while content cross-fades. + * Overflow stays visible in the settled state so absolutely-positioned children + * (fan-out and folder panels, and the hover tooltips inside them) can escape the + * section's bounds; it is only clipped while outgoing content is fading out, so + * that content does not bleed past the container mid-transition. */ .toolbar-slot-section { position: relative; - overflow: hidden; transition: width 0.35s cubic-bezier(0.4, 0, 0.2, 1), height 0.35s cubic-bezier(0.4, 0, 0.2, 1); } +.toolbar-slot-section--transitioning { + overflow: hidden; +} + @keyframes toolbar-slot-fade-in { from { opacity: 0; } to { opacity: 1; } diff --git a/Source/Toolbar/ToolbarFanOutItem.tsx b/Source/Toolbar/ToolbarFanOutItem.tsx index 2fa6787..a381f78 100644 --- a/Source/Toolbar/ToolbarFanOutItem.tsx +++ b/Source/Toolbar/ToolbarFanOutItem.tsx @@ -1,7 +1,7 @@ // Copyright (c) Cratis. All rights reserved. // Licensed under the MIT license. See LICENSE file in the project root for full license information. -import { ReactNode, useEffect, useRef, useState } from 'react'; +import React, { ReactNode, useCallback, useEffect, useRef, useState } from 'react'; import { IconDisplay } from '../Common/Icon'; import type { Icon } from '../Common/Icon'; import { Tooltip } from '../Common/Tooltip'; @@ -43,10 +43,40 @@ export const ToolbarFanOutItem = ({ children, }: ToolbarFanOutItemProps) => { const [isExpanded, setIsExpanded] = useState(false); + const [isSettled, setIsSettled] = useState(false); const containerRef = useRef(null); + const panelRef = useRef(null); + + // A settled panel has `clip-path: none`, which cannot interpolate — closing + // straight from it would snap the panel shut instead of wiping it closed. + // Put an equivalent inset back and let the browser observe it (the forced + // reflow) before React removes the visible class, so the close transition + // has an interpolable starting value. + const collapse = useCallback(() => { + const panel = panelRef.current; + if (panel && panel.classList.contains('toolbar-fanout-panel--settled')) { + panel.style.setProperty('clip-path', 'inset(0 0 0 0 round 1rem)'); + panel.style.setProperty('transition', 'none'); + void panel.offsetWidth; + panel.style.removeProperty('transition'); + panel.style.removeProperty('clip-path'); + } + setIsSettled(false); + setIsExpanded(false); + }, []); const handleToggle = () => { - setIsExpanded(!isExpanded); + if (isExpanded) { + collapse(); + } else { + setIsExpanded(true); + } + }; + + const handleTransitionEnd = (event: React.TransitionEvent) => { + if (event.target === panelRef.current && event.propertyName === 'clip-path' && isExpanded) { + setIsSettled(true); + } }; // Close the fan-out when clicking outside @@ -55,7 +85,7 @@ export const ToolbarFanOutItem = ({ const handleClickOutside = (event: MouseEvent) => { if (containerRef.current && !containerRef.current.contains(event.target as Node)) { - setIsExpanded(false); + collapse(); } }; @@ -63,10 +93,11 @@ export const ToolbarFanOutItem = ({ return () => { document.removeEventListener('mousedown', handleClickOutside); }; - }, [isExpanded]); + }, [isExpanded, collapse]); const activeClass = isExpanded ? 'toolbar-button--active' : ''; const panelVisibleClass = isExpanded ? 'toolbar-fanout-panel--visible' : ''; + const panelSettledClass = isExpanded && isSettled ? 'toolbar-fanout-panel--settled' : ''; const directionClass = `toolbar-fanout-panel--${fanOutDirection}`; return ( @@ -82,7 +113,11 @@ export const ToolbarFanOutItem = ({ -
+
{children}
From 9b7b674adf4d473d5ef73013f37695df1953f16f Mon Sep 17 00:00:00 2001 From: Einar Date: Mon, 10 Aug 2026 16:55:57 +0200 Subject: [PATCH 2/3] Keep a settled ToolbarGroup slot section unclipped .toolbar-slot-section was permanently overflow:hidden, which clipped the fan-out and folder panels of any ToolbarGroup that hosts a slot. ToolbarLayout already worked around this with an inline overflow override; move that to a --transitioning modifier class so both hosts share it and the section is only clipped while outgoing content is fading out. Co-Authored-By: Claude Opus 5 (1M context) --- Source/Toolbar/ToolbarGroup.tsx | 8 +++++++- Source/Toolbar/ToolbarLayout.tsx | 8 ++++---- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/Source/Toolbar/ToolbarGroup.tsx b/Source/Toolbar/ToolbarGroup.tsx index 4350db5..451d25c 100644 --- a/Source/Toolbar/ToolbarGroup.tsx +++ b/Source/Toolbar/ToolbarGroup.tsx @@ -83,9 +83,15 @@ const SlotTransition = ({ slotName, flexClass }: { slotName: string; flexClass: if (current.length === 0 && exiting.length === 0) return null; + // The section is only clipped while outgoing content is fading out, so it + // doesn't bleed outside the container. Once the transition is complete the + // section must be overflow:visible so fan-out and folder panels (which are + // position:absolute children) can escape the slot section's bounds. + const transitioningClass = exiting.length > 0 ? 'toolbar-slot-section--transitioning' : ''; + return (
{/* Incoming content — fades in via @keyframes animation on mount */} diff --git a/Source/Toolbar/ToolbarLayout.tsx b/Source/Toolbar/ToolbarLayout.tsx index a1a5390..f03e448 100644 --- a/Source/Toolbar/ToolbarLayout.tsx +++ b/Source/Toolbar/ToolbarLayout.tsx @@ -61,16 +61,16 @@ const LayoutTransition = ({ items, flexClass }: { items: ReactNode[]; flexClass: if (current.length === 0 && exiting.length === 0) return null; - // overflow:hidden is only needed while outgoing content is fading out so it + // The section is only clipped while outgoing content is fading out, so it // doesn't bleed outside the container. Once the transition is complete the // section must be overflow:visible so fan-out and folder panels (which are // position:absolute children) can escape the slot section's bounds. - const isTransitioning = exiting.length > 0; + const transitioningClass = exiting.length > 0 ? 'toolbar-slot-section--transitioning' : ''; return (
Date: Thu, 13 Aug 2026 15:46:58 +0200 Subject: [PATCH 3/3] Let a range filter render buckets that were counted elsewhere RangeHistogramFilter could only count values the browser already held, so a range picker over a data set larger than the loaded page misrepresented what each range actually selects - and there was no way to feed it a server-side aggregation. numericRange now takes an optional histogram of pre-counted buckets, rendered as given and scaled against their own tallest bar. The counting itself moves out of the component into buildHistogram so both paths are covered by specs. values stays supported and unchanged for client-side counting. Co-Authored-By: Claude Opus 5 (1M context) --- Source/Filter/FilterPanel.tsx | 1 + Source/Filter/RangeHistogramFilter.tsx | 59 +++++++------------ .../when_buckets_were_counted_elsewhere.ts | 39 ++++++++++++ .../when_counting_values_in_the_browser.ts | 42 +++++++++++++ Source/Filter/index.ts | 4 +- Source/Filter/types.ts | 20 ++++++- Source/Filter/utils.ts | 48 ++++++++++++++- 7 files changed, 170 insertions(+), 43 deletions(-) create mode 100644 Source/Filter/for_buildHistogram/when_buckets_were_counted_elsewhere.ts create mode 100644 Source/Filter/for_buildHistogram/when_counting_values_in_the_browser.ts diff --git a/Source/Filter/FilterPanel.tsx b/Source/Filter/FilterPanel.tsx index 0d0333d..b385174 100644 --- a/Source/Filter/FilterPanel.tsx +++ b/Source/Filter/FilterPanel.tsx @@ -312,6 +312,7 @@ export function FilterPanel({ ) : isNumeric && filter.numericRange ? ( string; } -interface HistogramBucket { - start: number; - end: number; - count: number; - maxCount: number; -} - const defaultFormatValue = (value: number) => { if (Number.isInteger(value)) return value.toString(); return value.toFixed(1); @@ -32,6 +36,7 @@ const defaultFormatValue = (value: number) => { export function RangeHistogramFilter({ values, + histogram: providedHistogram, min, max, buckets = 20, @@ -44,7 +49,7 @@ export function RangeHistogramFilter({ const [dragStart, setDragStart] = useState<{ x: number; range: [number, number] } | null>(null); const numericValues = useMemo(() => { - return values + return (values ?? []) .map((v) => { if (typeof v === 'number') return v; if (v instanceof Date) return v.getTime(); @@ -54,34 +59,10 @@ export function RangeHistogramFilter({ .filter((v): v is number => v !== null); }, [values]); - const histogram = useMemo((): HistogramBucket[] => { - const range = max - min; - if (range <= 0 || numericValues.length === 0) { - return []; - } - - const bucketSize = range / buckets; - const bucketCounts: number[] = Array(buckets).fill(0); - - numericValues.forEach((value) => { - const bucketIndex = Math.min( - Math.floor((value - min) / bucketSize), - buckets - 1 - ); - if (bucketIndex >= 0 && bucketIndex < buckets) { - bucketCounts[bucketIndex]++; - } - }); - - const maxCount = Math.max(...bucketCounts, 1); - - return bucketCounts.map((count, i) => ({ - start: min + i * bucketSize, - end: min + (i + 1) * bucketSize, - count, - maxCount, - })); - }, [numericValues, min, max, buckets]); + const histogram = useMemo( + () => buildHistogram(numericValues, min, max, buckets, providedHistogram), + [providedHistogram, numericValues, min, max, buckets] + ); const currentRange = selectedRange ?? [min, max]; @@ -155,7 +136,7 @@ export function RangeHistogramFilter({ }; }, [isDragging, dragStart, min, max, onChange]); - const handleBarClick = (bucket: HistogramBucket) => { + const handleBarClick = (bucket: RenderedHistogramBucket) => { onChange([bucket.start, bucket.end]); }; diff --git a/Source/Filter/for_buildHistogram/when_buckets_were_counted_elsewhere.ts b/Source/Filter/for_buildHistogram/when_buckets_were_counted_elsewhere.ts new file mode 100644 index 0000000..c1a0275 --- /dev/null +++ b/Source/Filter/for_buildHistogram/when_buckets_were_counted_elsewhere.ts @@ -0,0 +1,39 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { buildHistogram } from '../utils'; +import type { HistogramBucket } from '../types'; + +/** + * Pre-counted buckets come from a source that can see more data than the browser holds - a server + * aggregating over a whole table, for instance. Recounting them against the loaded values would + * understate the totals and make the picker misrepresent what a range actually selects. + */ +describe('when buckets were counted elsewhere', () => { + const provided: HistogramBucket[] = [ + { start: 0, end: 5, count: 3000 }, + { start: 5, end: 10, count: 1000 }, + ]; + const result = buildHistogram([1, 2, 3], 0, 10, 20, provided); + + it('should render exactly the buckets it was given', () => { + result.should.have.lengthOf(2); + }); + + it('should keep the counts it was given rather than recounting the values', () => { + result[0].count.should.equal(3000); + result[1].count.should.equal(1000); + }); + + it('should ignore the requested bucket count', () => { + result.should.not.have.lengthOf(20); + }); + + it('should scale the bars against the tallest provided bucket', () => { + result.every((bucket) => bucket.maxCount === 3000).should.be.true; + }); + + it('should produce no buckets when given an empty set', () => { + buildHistogram([1, 2, 3], 0, 10, 20, []).should.have.lengthOf(0); + }); +}); diff --git a/Source/Filter/for_buildHistogram/when_counting_values_in_the_browser.ts b/Source/Filter/for_buildHistogram/when_counting_values_in_the_browser.ts new file mode 100644 index 0000000..6720b43 --- /dev/null +++ b/Source/Filter/for_buildHistogram/when_counting_values_in_the_browser.ts @@ -0,0 +1,42 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { buildHistogram } from '../utils'; + +describe('when counting values in the browser', () => { + const result = buildHistogram([0, 1, 5, 6, 7, 9], 0, 10, 2); + + it('should produce the requested number of buckets', () => { + result.should.have.lengthOf(2); + }); + + it('should span the range evenly', () => { + result[0].start.should.equal(0); + result[0].end.should.equal(5); + result[1].start.should.equal(5); + result[1].end.should.equal(10); + }); + + it('should count each value into its bucket', () => { + result[0].count.should.equal(2); + result[1].count.should.equal(4); + }); + + it('should report the tallest count so bars can size themselves', () => { + result.every((bucket) => bucket.maxCount === 4).should.be.true; + }); + + it('should place a value on the upper bound in the last bucket', () => { + buildHistogram([10], 0, 10, 2)[1].count.should.equal(1); + }); +}); + +describe('when there is nothing to count', () => { + it('should produce no buckets for an empty set of values', () => { + buildHistogram([], 0, 10, 5).should.have.lengthOf(0); + }); + + it('should produce no buckets when the range has no width', () => { + buildHistogram([5, 5], 5, 5, 5).should.have.lengthOf(0); + }); +}); diff --git a/Source/Filter/index.ts b/Source/Filter/index.ts index 8073dc5..86e48b4 100644 --- a/Source/Filter/index.ts +++ b/Source/Filter/index.ts @@ -9,12 +9,14 @@ export { RangeHistogramFilter } from './RangeHistogramFilter'; export type { RangeHistogramFilterProps } from './RangeHistogramFilter'; export { useFilterState } from './useFilterState'; export type { UseFilterStateResult } from './useFilterState'; -export { buildFilterValues, buildRangeValues } from './utils'; +export { buildFilterValues, buildRangeValues, buildHistogram } from './utils'; +export type { RenderedHistogramBucket } from './utils'; export type { FilterValue, FilterOption, FilterEditorProps, FilterDefinition, + HistogramBucket, FilterValues, RangeValues, CustomFilterValues, diff --git a/Source/Filter/types.ts b/Source/Filter/types.ts index 5ae4396..ba5f68f 100644 --- a/Source/Filter/types.ts +++ b/Source/Filter/types.ts @@ -15,6 +15,16 @@ export interface FilterEditorProps { onChange: (value: unknown) => void; } +/** One pre-counted bar of a range filter's histogram. */ +export interface HistogramBucket { + /** Inclusive start of the bucket, on the same scale as the filter's range. */ + start: number; + /** Exclusive end of the bucket, on the same scale as the filter's range. */ + end: number; + /** Number of items that fall inside the bucket. */ + count: number; +} + export interface FilterDefinition { key: string; label: string; @@ -27,8 +37,14 @@ export interface FilterDefinition { multi?: boolean; /** Pre-computed options for string/date filters. */ options?: FilterOption[]; - /** Numeric range data for 'number' type filters. */ - numericRange?: { min: number; max: number; values: FilterValue[] }; + /** + * Numeric range data for 'number' and 'date' type filters. + * + * Supply `values` to have the histogram counted in the browser, or `histogram` when the counts + * were produced elsewhere - by a server aggregating over more rows than are worth transferring, + * for instance. `histogram` wins when both are present. + */ + numericRange?: { min: number; max: number; values?: FilterValue[]; histogram?: HistogramBucket[] }; /** Number of histogram buckets. Defaults to 20. */ buckets?: number; /** Show an inline search box that filters the displayed options for this group. */ diff --git a/Source/Filter/utils.ts b/Source/Filter/utils.ts index d101468..f050e86 100644 --- a/Source/Filter/utils.ts +++ b/Source/Filter/utils.ts @@ -1,7 +1,12 @@ // Copyright (c) Cratis. All rights reserved. // Licensed under the MIT license. See LICENSE file in the project root for full license information. -import type { FilterDefinition, FilterValues, RangeValues } from './types'; +import type { FilterDefinition, FilterValues, HistogramBucket, RangeValues } from './types'; + +/** A histogram bucket with the tallest count in its set, so a bar can size itself. */ +export interface RenderedHistogramBucket extends HistogramBucket { + maxCount: number; +} /** Initialise the string/option selection map for all string/date filters. */ export function buildFilterValues(filters: FilterDefinition[] | undefined): FilterValues { @@ -24,3 +29,44 @@ export function buildRangeValues(filters: FilterDefinition[] | undefined): Range }); return state; } + +/** + * Build the bars a range filter renders. + * + * Pre-counted buckets are rendered as given - they come from a source that saw more data than the + * browser holds, so recounting them against the loaded values would understate the real totals. + * Otherwise the raw values are counted into `bucketCount` evenly sized buckets across the range. + */ +export function buildHistogram( + values: number[], + min: number, + max: number, + bucketCount: number, + provided?: HistogramBucket[] +): RenderedHistogramBucket[] { + if (provided !== undefined) { + if (provided.length === 0) return []; + const providedMax = Math.max(...provided.map((bucket) => bucket.count), 1); + return provided.map((bucket) => ({ ...bucket, maxCount: providedMax })); + } + + const range = max - min; + if (range <= 0 || values.length === 0) return []; + + const bucketSize = range / bucketCount; + const counts: number[] = Array(bucketCount).fill(0); + + values.forEach((value) => { + const index = Math.min(Math.floor((value - min) / bucketSize), bucketCount - 1); + if (index >= 0 && index < bucketCount) counts[index]++; + }); + + const maxCount = Math.max(...counts, 1); + + return counts.map((count, index) => ({ + start: min + index * bucketSize, + end: min + (index + 1) * bucketSize, + count, + maxCount, + })); +}