diff --git a/Source/PivotViewer/PivotViewer.css b/Source/PivotViewer/PivotViewer.css index c6290b2d..c265c0ad 100644 --- a/Source/PivotViewer/PivotViewer.css +++ b/Source/PivotViewer/PivotViewer.css @@ -1,4 +1,7 @@ .pivot-viewer { + /* The height every toolbar control on the left of the bar shares, so the filter button and + the item count line up as one group. */ + --pv-toolbar-control-size: 2.25rem; position: relative; /* CRITICAL: Don't set explicit height - let parent control it via container constraints */ display: flex; @@ -311,22 +314,30 @@ gap: 1rem; } -.pv-toolbar h1 { +/* A span rather than a heading: the host owns its page's heading levels, and a bare `h1` + inherited whatever heading size the consuming application had set. */ +.pv-title { margin: 0; font-size: 1.1rem; + font-weight: 600; + line-height: 1.2; letter-spacing: -0.01em; white-space: nowrap; } +/* Sized to the filter button beside it, so the two read as one group whether or not a title + separates them. */ .pv-count { - margin-left: 1rem; display: inline-flex; align-items: center; + justify-content: center; gap: 0.35rem; - padding: 0.3rem 0.65rem; + height: var(--pv-toolbar-control-size); + padding: 0 0.65rem; border-radius: 999px; background: var(--cratis-highlight-bg); font-size: 0.8rem; + line-height: 1; color: var(--cratis-text-color); } @@ -358,8 +369,8 @@ appearance: none; border: none; border-radius: 0.5rem; - width: 2.25rem; - height: 2.25rem; + width: var(--pv-toolbar-control-size); + height: var(--pv-toolbar-control-size); display: flex; align-items: center; justify-content: center; @@ -608,6 +619,10 @@ var(--cratis-primary-500), var(--cratis-surface-ground) ); + /* Panning stays with the browser, pinching comes to us: without this a touch device + resolves a two-finger gesture as its own page zoom and never delivers the moves the + viewer needs to scale the cards. */ + touch-action: pan-x pan-y; } .pv-groups-grouped { diff --git a/Source/PivotViewer/PivotViewer.tsx b/Source/PivotViewer/PivotViewer.tsx index 3341a1d6..c6cd8caf 100644 --- a/Source/PivotViewer/PivotViewer.tsx +++ b/Source/PivotViewer/PivotViewer.tsx @@ -18,7 +18,7 @@ import { import { PivotViewerMain } from './components/PivotViewerMain'; import { FilterPanelContainer } from './components/FilterPanelContainer'; import { ToolbarContainer } from './components/ToolbarContainer'; -import { usePanning, useWheelZoom, useFilterOptions } from './hooks'; +import { usePanning, useWheelZoom, useZoomScrollAnchor, useFilterOptions } from './hooks'; import { useContainerDimensions } from './hooks/useContainerDimensions'; import type { ViewMode } from './components/Toolbar'; import { useFieldExtractors } from './hooks/useFieldExtractors'; @@ -59,6 +59,7 @@ export function PivotViewer({ detailRenderer, getItemId, searchFields, + title, className, emptyContent, isLoading = false, @@ -125,6 +126,7 @@ export function PivotViewer({ ); useWheelZoom(containerRef, zoomLevel, setZoomLevel); + useZoomScrollAnchor(containerRef, spacerRef, viewMode); // Track container dimensions for responsive layout const containerDimensions = useContainerDimensions(containerRef, isLoading); @@ -438,6 +440,7 @@ export function PivotViewer({ activeDimensionKey={activeDimensionKey} dimensions={dimensions} activeFilterCount={activeFilterCount} + title={title} onFiltersToggle={() => setFiltersOpen((prev) => !prev)} onViewModeChange={setViewMode} onZoomIn={handleZoomIn} diff --git a/Source/PivotViewer/components/Toolbar.tsx b/Source/PivotViewer/components/Toolbar.tsx index 3a5f7f6b..f2674c36 100644 --- a/Source/PivotViewer/components/Toolbar.tsx +++ b/Source/PivotViewer/components/Toolbar.tsx @@ -16,6 +16,8 @@ export interface ToolbarProps { activeDimensionKey: string; dimensions: PivotDimension[]; activeFilterCount: number; + /** Heading shown at the left of the toolbar. Omitted entirely when not given. */ + title?: string; onFiltersToggle: () => void; onViewModeChange: (mode: ViewMode) => void; onZoomIn: () => void; @@ -36,6 +38,7 @@ export function Toolbar({ activeDimensionKey, dimensions, activeFilterCount, + title, onFiltersToggle, onViewModeChange, onZoomIn, @@ -102,7 +105,7 @@ export function Toolbar({ )} )} -

Pivot Viewer

+ {title && {title}} {filteredCount} events
diff --git a/Source/PivotViewer/components/pivot/constants.ts b/Source/PivotViewer/components/pivot/constants.ts index bfadecdc..66cf15ea 100644 --- a/Source/PivotViewer/components/pivot/constants.ts +++ b/Source/PivotViewer/components/pivot/constants.ts @@ -42,6 +42,14 @@ export interface CardSprite { lastTitle?: string; lastLabels?: string; lastValues?: string; + // The text and width the fitted title and values were last measured against, so a card + // only re-measures when what it shows, or the room it has, actually changed. + lastFittedSource?: string; + lastFittedWidth?: number; + /** The title as it fits the card, ellipsized when the full text does not. */ + fittedTitle?: string; + /** The value column as it fits the card, ellipsized line by line. */ + fittedValues?: string; } export default {}; diff --git a/Source/PivotViewer/components/pivot/ellipsize.ts b/Source/PivotViewer/components/pivot/ellipsize.ts new file mode 100644 index 00000000..5b6e7ff3 --- /dev/null +++ b/Source/PivotViewer/components/pivot/ellipsize.ts @@ -0,0 +1,60 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +/** Measures how wide `candidate` renders, in the same units as the budget it is compared against. */ +export type MeasureText = (candidate: string) => number; + +/** The character appended to text that had to be cut short. */ +export const ellipsis = '…'; + +/** + * Shortens one line to the longest prefix that still fits `maxWidth`, marking the cut with an + * ellipsis. Text that already fits is returned unchanged. + * + * Card text is drawn to a canvas with word wrapping off, so anything wider than the card simply + * kept painting past its edge and over the card beside it. Measuring is the only way to know + * where to cut — a glyph's width depends on the font, so counting characters cannot tell you. + * + * The search is binary rather than a character-at-a-time walk: measuring is the expensive part, + * and this runs for every visible card. + * + * @param text The line to fit. + * @param maxWidth The width budget. A budget of zero or less leaves the text untouched, since + * there is no room to render anything meaningful and a bare ellipsis says less than a clipped word. + * @param measure Measures a candidate string. + * @returns The text, shortened and suffixed with an ellipsis only if it did not fit. + */ +export function ellipsizeLine(text: string, maxWidth: number, measure: MeasureText): string { + if (maxWidth <= 0 || text.length === 0) return text; + if (measure(text) <= maxWidth) return text; + + let fits = 0; + let tooWide = text.length; + while (fits < tooWide) { + const middle = Math.ceil((fits + tooWide) / 2); + if (measure(text.slice(0, middle) + ellipsis) <= maxWidth) { + fits = middle; + } else { + tooWide = middle - 1; + } + } + + return text.slice(0, fits) + ellipsis; +} + +/** + * Applies {@link ellipsizeLine} to every line of a block of text, so a multi-line value column + * is fitted line by line rather than as one string. + * + * @param text The block to fit, newline separated. + * @param maxWidth The width budget for a single line. + * @param measure Measures a candidate string. + * @returns The block, with each line shortened only if it did not fit. + */ +export function ellipsizeBlock(text: string, maxWidth: number, measure: MeasureText): string { + if (!text.includes('\n')) return ellipsizeLine(text, maxWidth, measure); + return text + .split('\n') + .map(line => ellipsizeLine(line, maxWidth, measure)) + .join('\n'); +} diff --git a/Source/PivotViewer/components/pivot/sprites.ts b/Source/PivotViewer/components/pivot/sprites.ts index 7ae6a65a..97071302 100644 --- a/Source/PivotViewer/components/pivot/sprites.ts +++ b/Source/PivotViewer/components/pivot/sprites.ts @@ -4,6 +4,14 @@ import * as PIXI from 'pixi.js'; import { CARD_GAP, CARD_PADDING, CARD_RADIUS } from './constants'; import type { CardSprite, CardColors } from './constants'; +import { ellipsizeBlock, ellipsizeLine } from './ellipsize'; + +/** How far the value column is inset from the card's text origin, matching where it is drawn. */ +const VALUES_INSET = 65; + +/** Measures a candidate string with the style the sprite draws it in. */ +const measureWith = (candidate: string, style: PIXI.TextStyle) => + PIXI.CanvasTextMetrics.measureText(candidate, style).width; const spritePool: CardSprite[] = []; @@ -341,9 +349,9 @@ export function updateCardContent( const colors = cardColors; const cardData = cardRenderer(item); - const titleDisplay = cardData.title; + const rawTitle = cardData.title; const labelsText = (cardData.labels || []).join('\n'); - const valuesText = (cardData.values || []).join('\n'); + const rawValues = (cardData.values || []).join('\n'); const colorsChanged = sprite.lastCardColors !== colors; // Ensure text objects exist before using them @@ -351,6 +359,27 @@ export function updateCardContent( if (!sprite.labelsText || sprite.labelsText.destroyed) return; if (!sprite.valuesText || sprite.valuesText.destroyed) return; + // The title and the value column are drawn with word wrapping off, so without a budget they + // paint straight past the card and over its neighbour. Both start at a known inset, so the + // room each has is the card's inner width less where it begins. + const innerWidth = cardWidth - CARD_GAP - CARD_PADDING * 2; + const fitSource = `${rawTitle}\u0000${rawValues}`; + if (sprite.lastFittedSource !== fitSource || sprite.lastFittedWidth !== innerWidth) { + sprite.lastFittedSource = fitSource; + sprite.lastFittedWidth = innerWidth; + sprite.fittedTitle = ellipsizeLine( + rawTitle, + innerWidth, + candidate => measureWith(candidate, sprite.titleText.style)); + sprite.fittedValues = ellipsizeBlock( + rawValues, + innerWidth - VALUES_INSET, + candidate => measureWith(candidate, sprite.valuesText.style)); + } + + const titleDisplay = sprite.fittedTitle ?? rawTitle; + const valuesText = sprite.fittedValues ?? rawValues; + if (sprite.lastTitle !== titleDisplay) { sprite.titleText.text = titleDisplay; sprite.lastTitle = titleDisplay; diff --git a/Source/PivotViewer/for_ellipsizeBlock/when_every_line_fits.ts b/Source/PivotViewer/for_ellipsizeBlock/when_every_line_fits.ts new file mode 100644 index 00000000..1ef0a3d9 --- /dev/null +++ b/Source/PivotViewer/for_ellipsizeBlock/when_every_line_fits.ts @@ -0,0 +1,18 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { ellipsizeBlock } from '../components/pivot/ellipsize'; +import { characterWidth, fixedWidth } from '../for_ellipsizeLine/given/a_measurer'; + +describe('when every line fits', () => { + const block = '66%\n170'; + let result: string; + + beforeEach(() => { + result = ellipsizeBlock(block, 6 * characterWidth, fixedWidth); + }); + + it('should return the block unchanged', () => { + result.should.equal(block); + }); +}); diff --git a/Source/PivotViewer/for_ellipsizeBlock/when_one_line_is_too_wide.ts b/Source/PivotViewer/for_ellipsizeBlock/when_one_line_is_too_wide.ts new file mode 100644 index 00000000..58b32e9f --- /dev/null +++ b/Source/PivotViewer/for_ellipsizeBlock/when_one_line_is_too_wide.ts @@ -0,0 +1,21 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { ellipsis, ellipsizeBlock } from '../components/pivot/ellipsize'; +import { characterWidth, fixedWidth } from '../for_ellipsizeLine/given/a_measurer'; + +describe('when one line is too wide', () => { + let result: string; + + beforeEach(() => { + result = ellipsizeBlock('66%\nAnswerPayrollQuery', 6 * characterWidth, fixedWidth); + }); + + it('should leave the line that fits alone', () => { + result.split('\n')[0].should.equal('66%'); + }); + + it('should shorten only the line that does not fit', () => { + result.split('\n')[1].should.equal('Answe' + ellipsis); + }); +}); diff --git a/Source/PivotViewer/for_ellipsizeLine/given/a_measurer.ts b/Source/PivotViewer/for_ellipsizeLine/given/a_measurer.ts new file mode 100644 index 00000000..336c0d42 --- /dev/null +++ b/Source/PivotViewer/for_ellipsizeLine/given/a_measurer.ts @@ -0,0 +1,13 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import type { MeasureText } from '../../components/pivot/ellipsize'; + +/** Width of one character in the fake font every spec measures with. */ +export const characterWidth = 10; + +/** + * Measures as a fixed-width font would, so a budget can be expressed as a character count and the + * expected cut is obvious from the spec. + */ +export const fixedWidth: MeasureText = candidate => candidate.length * characterWidth; diff --git a/Source/PivotViewer/for_ellipsizeLine/when_only_the_ellipsis_fits.ts b/Source/PivotViewer/for_ellipsizeLine/when_only_the_ellipsis_fits.ts new file mode 100644 index 00000000..f7f46844 --- /dev/null +++ b/Source/PivotViewer/for_ellipsizeLine/when_only_the_ellipsis_fits.ts @@ -0,0 +1,17 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { ellipsis, ellipsizeLine } from '../components/pivot/ellipsize'; +import { characterWidth, fixedWidth } from './given/a_measurer'; + +describe('when only the ellipsis fits', () => { + let result: string; + + beforeEach(() => { + result = ellipsizeLine('AnswerPayrollQuery', characterWidth, fixedWidth); + }); + + it('should return just the ellipsis', () => { + result.should.equal(ellipsis); + }); +}); diff --git a/Source/PivotViewer/for_ellipsizeLine/when_the_budget_is_zero.ts b/Source/PivotViewer/for_ellipsizeLine/when_the_budget_is_zero.ts new file mode 100644 index 00000000..0c18e5d6 --- /dev/null +++ b/Source/PivotViewer/for_ellipsizeLine/when_the_budget_is_zero.ts @@ -0,0 +1,17 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { ellipsizeLine } from '../components/pivot/ellipsize'; +import { fixedWidth } from './given/a_measurer'; + +describe('when the budget is zero', () => { + let result: string; + + beforeEach(() => { + result = ellipsizeLine('Ledger', 0, fixedWidth); + }); + + it('should return the text unchanged', () => { + result.should.equal('Ledger'); + }); +}); diff --git a/Source/PivotViewer/for_ellipsizeLine/when_the_text_fits.ts b/Source/PivotViewer/for_ellipsizeLine/when_the_text_fits.ts new file mode 100644 index 00000000..1f5080be --- /dev/null +++ b/Source/PivotViewer/for_ellipsizeLine/when_the_text_fits.ts @@ -0,0 +1,17 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { ellipsizeLine } from '../components/pivot/ellipsize'; +import { characterWidth, fixedWidth } from './given/a_measurer'; + +describe('when the text fits', () => { + let result: string; + + beforeEach(() => { + result = ellipsizeLine('Ledger', 6 * characterWidth, fixedWidth); + }); + + it('should return the text unchanged', () => { + result.should.equal('Ledger'); + }); +}); diff --git a/Source/PivotViewer/for_ellipsizeLine/when_the_text_is_empty.ts b/Source/PivotViewer/for_ellipsizeLine/when_the_text_is_empty.ts new file mode 100644 index 00000000..a980359d --- /dev/null +++ b/Source/PivotViewer/for_ellipsizeLine/when_the_text_is_empty.ts @@ -0,0 +1,17 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { ellipsizeLine } from '../components/pivot/ellipsize'; +import { characterWidth, fixedWidth } from './given/a_measurer'; + +describe('when the text is empty', () => { + let result: string; + + beforeEach(() => { + result = ellipsizeLine('', 4 * characterWidth, fixedWidth); + }); + + it('should return an empty string', () => { + result.should.equal(''); + }); +}); diff --git a/Source/PivotViewer/for_ellipsizeLine/when_the_text_is_wider_than_the_budget.ts b/Source/PivotViewer/for_ellipsizeLine/when_the_text_is_wider_than_the_budget.ts new file mode 100644 index 00000000..596654b0 --- /dev/null +++ b/Source/PivotViewer/for_ellipsizeLine/when_the_text_is_wider_than_the_budget.ts @@ -0,0 +1,25 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { ellipsis, ellipsizeLine } from '../components/pivot/ellipsize'; +import { characterWidth, fixedWidth } from './given/a_measurer'; + +describe('when the text is wider than the budget', () => { + let result: string; + + beforeEach(() => { + result = ellipsizeLine('AnswerPayrollQuery', 8 * characterWidth, fixedWidth); + }); + + it('should end with an ellipsis', () => { + result.endsWith(ellipsis).should.be.true; + }); + + it('should fit within the budget', () => { + fixedWidth(result).should.be.at.most(8 * characterWidth); + }); + + it('should keep as much of the text as fits', () => { + result.should.equal('AnswerP' + ellipsis); + }); +}); diff --git a/Source/PivotViewer/hooks/index.ts b/Source/PivotViewer/hooks/index.ts index 4041d088..d691a589 100644 --- a/Source/PivotViewer/hooks/index.ts +++ b/Source/PivotViewer/hooks/index.ts @@ -8,6 +8,7 @@ export { useFilterOptions } from './useFilterOptions'; export { useZoomState } from './useZoomState'; export { usePanning } from './usePanning'; export { useWheelZoom } from './useWheelZoom'; +export { useZoomScrollAnchor } from './useZoomScrollAnchor'; export { useFilterPanelDrag } from './useFilterPanelDrag'; export { useSelectedItem } from './useSelectedItem'; export * from './usePivotEngine'; diff --git a/Source/PivotViewer/hooks/useWheelZoom.ts b/Source/PivotViewer/hooks/useWheelZoom.ts index f1615e47..6c9073d7 100644 --- a/Source/PivotViewer/hooks/useWheelZoom.ts +++ b/Source/PivotViewer/hooks/useWheelZoom.ts @@ -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 { useCallback, useEffect } from 'react'; +import { useCallback, useEffect, useState } from 'react'; import { ZOOM_MIN, ZOOM_MAX } from '../utils/utils'; export function useWheelZoom( @@ -53,8 +53,19 @@ export function useWheelZoom( } }, [zoomLevel, setZoomLevel, containerRef]); + // The viewport this binds to mounts a render or more after the hook first runs - it sits + // behind the loading gate, inside a child component - and a ref object keeps the same identity + // forever, so an effect keyed on the ref alone bound to nothing and never retried. Pinching did + // nothing until something else happened to change `zoomLevel` and re-run the effect, which + // meant zooming with the toolbar first was the only way to make the gesture start working. + // Comparing the node after every render picks it up the moment it appears, and lets go again + // if it is unmounted. + const [container, setContainer] = useState(null); + useEffect(() => { + if (containerRef.current !== container) setContainer(containerRef.current); + }); + useEffect(() => { - const container = containerRef.current; if (!container) return; container.addEventListener('wheel', handleWheel, { passive: false }); @@ -113,12 +124,14 @@ export function useWheelZoom( container.addEventListener('touchstart', handleTouchStart, { passive: true }); container.addEventListener('touchmove', handleTouchMove, { passive: false }); container.addEventListener('touchend', handleTouchEnd, { passive: true }); + container.addEventListener('touchcancel', handleTouchEnd, { passive: true }); return () => { container.removeEventListener('wheel', handleWheel); container.removeEventListener('touchstart', handleTouchStart); container.removeEventListener('touchmove', handleTouchMove); container.removeEventListener('touchend', handleTouchEnd); + container.removeEventListener('touchcancel', handleTouchEnd); }; - }, [handleWheel, zoomLevel, setZoomLevel, containerRef]); + }, [container, handleWheel, zoomLevel, setZoomLevel]); } diff --git a/Source/PivotViewer/hooks/useZoomScrollAnchor.ts b/Source/PivotViewer/hooks/useZoomScrollAnchor.ts new file mode 100644 index 00000000..43794d93 --- /dev/null +++ b/Source/PivotViewer/hooks/useZoomScrollAnchor.ts @@ -0,0 +1,93 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { useEffect, useRef, useState } from 'react'; + +/** + * Keeps the content the viewport is looking at in view when the scrollable area is resized by a + * zoom change. + * + * Zooming resizes the content underneath a scroll offset that stays where it was, so without this + * the view lands somewhere unrelated to what the user was looking at. Zooming all the way out is + * the worst case: the content then fits the viewport, the scroll range collapses to nothing, and + * zooming back in leaves the offset at zero. + * + * In grouped mode that is not merely disorienting - it looks broken. Groups are drawn from the + * bottom up, so the top of the scrollable area is empty for every group shorter than the tallest + * one, and an offset of zero shows a blank canvas with no clue that the cards are below. Grouped + * mode therefore holds its distance from the *bottom*, which is where its content is anchored; + * every other mode holds the centre. + * + * This watches the spacer that defines the scrollable area rather than the zoom level, because the + * spacer is what actually resizes - reacting to the zoom directly would read the extent before it + * had been laid out. The wheel handler keeps its own cursor-anchored adjustment for the gesture in + * progress; this covers the zoom paths that have no anchor of their own, such as the toolbar's + * buttons, slider and reset. + * + * @param containerRef The scrolling viewport. + * @param spacerRef The element whose size defines the scrollable area. + * @param viewMode The active view mode. + */ +export function useZoomScrollAnchor( + containerRef: React.RefObject, + spacerRef: React.RefObject, + viewMode: string, +) { + const [spacer, setSpacer] = useState(null); + const previousExtent = useRef<{ width: number; height: number } | null>(null); + const viewModeRef = useRef(viewMode); + viewModeRef.current = viewMode; + + // The spacer mounts inside a child, a render or more after this hook first runs, and a ref + // object never changes identity - so the element is picked up by comparing it after a render + // rather than by depending on the ref. + useEffect(() => { + if (spacerRef.current !== spacer) setSpacer(spacerRef.current); + }); + + useEffect(() => { + const container = containerRef.current; + if (!container || !spacer) return; + + const anchor = (previous: { width: number; height: number }) => { + const height = spacer.offsetHeight; + const width = spacer.offsetWidth; + if (previous.height <= 0 || previous.width <= 0) return; + if (previous.height === height && previous.width === width) return; + + const maxScrollTop = Math.max(0, height - container.clientHeight); + const maxScrollLeft = Math.max(0, width - container.clientWidth); + + const horizontalRatio = width / previous.width; + const anchoredLeft = + (container.scrollLeft + container.clientWidth / 2) * horizontalRatio + - container.clientWidth / 2; + container.scrollLeft = Math.min(maxScrollLeft, Math.max(0, anchoredLeft)); + + const verticalRatio = height / previous.height; + if (viewModeRef.current === 'grouped') { + const previousMaxScrollTop = Math.max(0, previous.height - container.clientHeight); + const gapFromBottom = Math.max(0, previousMaxScrollTop - container.scrollTop); + container.scrollTop = Math.min( + maxScrollTop, + Math.max(0, maxScrollTop - gapFromBottom * verticalRatio)); + } else { + const anchoredTop = + (container.scrollTop + container.clientHeight / 2) * verticalRatio + - container.clientHeight / 2; + container.scrollTop = Math.min(maxScrollTop, Math.max(0, anchoredTop)); + } + }; + + previousExtent.current ??= { width: spacer.offsetWidth, height: spacer.offsetHeight }; + + const observer = new ResizeObserver(() => { + const previous = previousExtent.current; + previousExtent.current = { width: spacer.offsetWidth, height: spacer.offsetHeight }; + if (previous) anchor(previous); + }); + observer.observe(spacer); + + return () => observer.disconnect(); + }, [containerRef, spacer]); +} diff --git a/Source/PivotViewer/types.ts b/Source/PivotViewer/types.ts index 10035022..89e7d29b 100644 --- a/Source/PivotViewer/types.ts +++ b/Source/PivotViewer/types.ts @@ -162,6 +162,13 @@ export interface PivotViewerProps { getItemId?: (item: TItem, index: number) => string | number; /** Property accessors defining which fields are searchable. */ searchFields?: PropertyAccessor[]; + /** + * Optional heading shown at the left of the toolbar. + * + * Omit it when the host already names the view - the heading collapses entirely and the + * item count sits directly beside the filter button. + */ + title?: string; /** Optional CSS class name to apply to the root element. */ className?: string; /** Content to display when no items match the current filters/search. */