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, + })); +} 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}
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 (