From 05a64ec1429ec5accef2178ae5d455295b6a9628 Mon Sep 17 00:00:00 2001 From: FritzSF Date: Tue, 4 Nov 2025 14:10:58 +0100 Subject: [PATCH 1/8] doc page pix --- app/styles/_reboot.scss | 10 +++------- examples/pages/components/utilities/TextUtilities.tsx | 2 +- 2 files changed, 4 insertions(+), 8 deletions(-) diff --git a/app/styles/_reboot.scss b/app/styles/_reboot.scss index 5701b711..fd621d72 100644 --- a/app/styles/_reboot.scss +++ b/app/styles/_reboot.scss @@ -11,12 +11,13 @@ box-sizing: border-box; text-align: start; } -// Set box-sizing globally to handle padding and border widths +// Set box-sizing globally to handle padding and border widths *::before, *::after { box-sizing: inherit; } + // remove deafult browser focus :focus, select:focus, @@ -30,11 +31,6 @@ html { -webkit-tap-highlight-color: rgba(0, 0, 0, 0); } -// $baseFontSize: 1.3rem; -// $baseFontFamily: "Roboto", Helvetica, Arial, sans-serif; -// $baseLineHeight: 1.8rem; -// $altFontFamily: Georgia, "Times New Roman", Times, serif; - body { font-family: var(--font-primary) ; font-size: var(--font-size-base); @@ -66,4 +62,4 @@ button { button { color:inherit; -} \ No newline at end of file +} diff --git a/examples/pages/components/utilities/TextUtilities.tsx b/examples/pages/components/utilities/TextUtilities.tsx index e4550d45..b9d48007 100644 --- a/examples/pages/components/utilities/TextUtilities.tsx +++ b/examples/pages/components/utilities/TextUtilities.tsx @@ -27,7 +27,7 @@ class TextUtilitiesDoc extends React.Component { text-2xs - {'font-size: var(--text-size-x-small);'}{' '} + {'font-size: var(--text-size-xx-small);'}{' '} /* 10px */ From 17d41252759edc577778722e5a2f0906d9fde444 Mon Sep 17 00:00:00 2001 From: FritzSF Date: Tue, 4 Nov 2025 16:03:05 +0100 Subject: [PATCH 2/8] SearchBar and OverflowStack components --- app-typescript/components/OverflowStack.tsx | 534 ++++++++++++++++ app-typescript/components/SearchBar.tsx | 153 ++++- app-typescript/index.ts | 1 + app/styles/app.scss | 1 + app/styles/components/_overflow-stack.scss | 120 ++++ app/styles/components/_sd-searchbar.scss | 38 +- examples/pages/components/Index.tsx | 10 + examples/pages/components/OverflowStack.tsx | 662 ++++++++++++++++++++ examples/pages/components/SearchBar.tsx | 435 +++++++++++++ 9 files changed, 1911 insertions(+), 43 deletions(-) create mode 100644 app-typescript/components/OverflowStack.tsx create mode 100644 app/styles/components/_overflow-stack.scss create mode 100644 examples/pages/components/OverflowStack.tsx create mode 100644 examples/pages/components/SearchBar.tsx diff --git a/app-typescript/components/OverflowStack.tsx b/app-typescript/components/OverflowStack.tsx new file mode 100644 index 00000000..9e7ce7f4 --- /dev/null +++ b/app-typescript/components/OverflowStack.tsx @@ -0,0 +1,534 @@ +import * as React from 'react'; +import classNames from 'classnames'; +import {WithPopover} from './WithPopover'; +import {HeadlessButton} from './HeadlessButton'; +import {IconButton} from './IconButton'; + +// Base props shared by both simple and data-driven APIs +interface IPropsOverflowStackBase { + /** + * Maximum number of items to show inline; defaults to 4 + * If exceeded, "+1"/"+2"/"+n" button will be shown + * Only used when overflow='fixed' + */ + max?: number | 'show-all'; + + /** + * Overflow behavior + * 'fixed': Uses the max prop to determine visible items (default) + * 'auto': Dynamically calculates how many items fit based on available space + */ + overflow?: 'fixed' | 'auto'; + + /** + * Gap between items; defaults to 'small' (var(--gap-0-5)) + * small: 4px, medium: 8px, large: 12px + */ + gap?: 'compact' | 'loose' | 'none'; + + /** + * Whether items should overlap (like avatars) + * When true, items will have negative margin and expand on hover + */ + overlap?: boolean; + + /** + * Show only hidden items in popover (true) or all items (false) + * Defaults to false (shows all items) + */ + showOnlyHiddenInPopover?: boolean; + + /** + * Style of the overflow indicator + * 'count': Shows "+N" with the number of hidden items (default) + * 'dots': Shows a dots icon without the count + */ + indicatorStyle?: 'count' | 'dots'; + + /** + * Custom render function for the "+N" indicator button + * If not provided, a default button will be rendered + */ + renderIndicator?: (count: number) => React.ReactNode; + + /** + * Custom onClick handler for the "+N" button + * If provided, popover will not be shown automatically + */ + onIndicatorClick?: () => void; + + /** + * Border radius for the indicator button + * Defaults to 'full' + */ + indicatorRadius?: 'x-small' | 'small' | 'medium' | 'full'; + + /** + * Additional className for the container + */ + className?: string; +} + +// Simple API: Pre-rendered React nodes +interface IPropsOverflowStackSimple extends IPropsOverflowStackBase { + /** + * Array of items to display in the stack + */ + items: Array; + + /** + * Custom render function for items in the popover + * If not provided, items will be rendered as-is + */ + renderPopoverItem?: (item: React.ReactNode, index: number) => React.ReactNode; + + // Data-driven props should not be used with simple API + itemsData?: never; + renderVisibleItem?: never; + renderHiddenItem?: never; +} + +// Data-driven API: Separate render functions for visible and popover items +interface IPropsOverflowStackData extends IPropsOverflowStackBase { + /** + * Array of data objects to render + * Use this with renderVisibleItem and renderHiddenItem for different rendering in stack vs popover + */ + itemsData: Array; + + /** + * Render function for items visible in the stack + */ + renderVisibleItem: (data: T, index: number) => React.ReactNode; + + /** + * Render function for items in the popover + * If not provided, renderVisibleItem will be used + */ + renderHiddenItem?: (data: T, index: number) => React.ReactNode; + + // Simple API props should not be used with data-driven API + items?: never; + renderPopoverItem?: never; +} + +export type IPropsOverflowStack = IPropsOverflowStackSimple | IPropsOverflowStackData; + +interface IStateOverflowStack { + /** + * Number of visible items when using auto overflow + */ + visibleCount: number; +} + +interface IPopoverContentProps { + isDataDriven: boolean; + propsRef: {current: IPropsOverflowStack | null}; + max: number; + showOnlyHiddenInPopover: boolean; + closePopup(): void; +} + +class PopoverContent extends React.Component> { + render() { + const {isDataDriven, propsRef, max, showOnlyHiddenInPopover} = this.props; + const props = propsRef.current; + + if (!props) { + return null; + } + + const defaultPopoverItem = (item: React.ReactNode, index: number) => ( +
+ {item} +
+ ); + + if (isDataDriven) { + const dataProps = props as IPropsOverflowStackData; + const dataToShow = showOnlyHiddenInPopover ? dataProps.itemsData.slice(max) : dataProps.itemsData; + const renderFn = dataProps.renderHiddenItem || dataProps.renderVisibleItem; + + return ( +
+ {dataToShow.map((data, index) => ( +
+ {renderFn(data, showOnlyHiddenInPopover ? index + max : index)} +
+ ))} +
+ ); + } else { + const simpleProps = props as IPropsOverflowStackSimple; + const itemsToShow = showOnlyHiddenInPopover ? simpleProps.items.slice(max) : simpleProps.items; + + return ( +
+ {itemsToShow.map((item, index) => + simpleProps.renderPopoverItem + ? simpleProps.renderPopoverItem(item, showOnlyHiddenInPopover ? index + max : index) + : defaultPopoverItem(item, showOnlyHiddenInPopover ? index + max : index), + )} +
+ ); + } + } +} + +// Constant styles to avoid recreation +const HIDDEN_ITEM_STYLE: React.CSSProperties = { + position: 'absolute', + visibility: 'hidden', + pointerEvents: 'none', + whiteSpace: 'nowrap', +}; + +export class OverflowStack extends React.PureComponent, IStateOverflowStack> { + private containerRef = React.createRef(); + private itemRefs: Array> = []; + private indicatorRef = React.createRef(); + private resizeObserver: ResizeObserver | null = null; + private resizeTimeout: number | null = null; + private popoverContentRef = React.createRef>(); + private currentPropsRef: {current: IPropsOverflowStack | null} = {current: null}; + + constructor(props: IPropsOverflowStack) { + super(props); + const itemCount = this.getItemCount(props); + this.state = { + visibleCount: itemCount, + }; + // Initialize refs for all items + this.itemRefs = Array.from({length: itemCount}, () => React.createRef()); + // Initialize props ref with current props + this.currentPropsRef.current = props; + } + + private getItemCount(props: IPropsOverflowStack): number { + if ('itemsData' in props && props.itemsData) { + return props.itemsData.length; + } + return props.items?.length || 0; + } + + componentDidMount() { + if (this.props.overflow === 'auto') { + this.setupResizeObserver(); + // Use requestAnimationFrame to ensure DOM is painted before measuring + requestAnimationFrame(() => { + this.calculateVisibleItems(); + }); + } + } + + componentDidUpdate(prevProps: IPropsOverflowStack) { + const prevItemCount = this.getItemCount(prevProps); + const currentItemCount = this.getItemCount(this.props); + + // Update the ref with current props for popover access + this.currentPropsRef.current = this.props; + + // Force popover to re-render with new data if it's open + if (this.popoverContentRef.current) { + this.popoverContentRef.current.forceUpdate(); + } + + if (this.props.overflow === 'auto') { + // If items changed, recreate refs and recalculate + if (prevItemCount !== currentItemCount) { + this.itemRefs = Array.from({length: currentItemCount}, () => React.createRef()); + // Force re-render to apply new refs, then calculate + this.setState({visibleCount: currentItemCount}, () => { + requestAnimationFrame(() => { + this.calculateVisibleItems(); + }); + }); + } else { + requestAnimationFrame(() => { + this.calculateVisibleItems(); + }); + } + } + + // Setup or cleanup observer based on overflow mode + if (prevProps.overflow !== this.props.overflow) { + if (this.props.overflow === 'auto') { + this.setupResizeObserver(); + requestAnimationFrame(() => { + this.calculateVisibleItems(); + }); + } else { + this.cleanupResizeObserver(); + } + } + } + + componentWillUnmount() { + this.cleanupResizeObserver(); + if (this.resizeTimeout) { + window.clearTimeout(this.resizeTimeout); + } + } + + private setupResizeObserver() { + if (this.resizeObserver || typeof ResizeObserver === 'undefined') { + return; + } + + this.resizeObserver = new ResizeObserver(() => { + // Debounce the calculation to avoid excessive updates + if (this.resizeTimeout) { + window.clearTimeout(this.resizeTimeout); + } + this.resizeTimeout = window.setTimeout(() => { + this.calculateVisibleItems(); + }, 50); + }); + + if (this.containerRef.current) { + this.resizeObserver.observe(this.containerRef.current); + } + } + + private cleanupResizeObserver() { + if (this.resizeObserver) { + this.resizeObserver.disconnect(); + this.resizeObserver = null; + } + } + + private getGapSize(): number { + const {gap = 'compact', overlap = false} = this.props; + + if (overlap) { + return -8; // Overlapping items have negative margin + } + + switch (gap) { + case 'compact': + return 4; + case 'loose': + return 8; + case 'none': + return 0; + default: + return 4; + } + } + + private calculateVisibleItems() { + if (!this.containerRef.current || this.props.overflow !== 'auto') { + return; + } + + const containerWidth = this.containerRef.current.offsetWidth; + + // If container has no width yet, skip calculation + if (containerWidth === 0) { + return; + } + + const gapSize = this.getGapSize(); + const indicatorWidth = this.indicatorRef.current?.offsetWidth || 40; + const totalItemCount = this.getItemCount(this.props); + + let totalWidth = 0; + let visibleCount = 0; + + // Calculate how many items fit + for (let i = 0; i < this.itemRefs.length; i++) { + const itemRef = this.itemRefs[i]; + if (!itemRef.current) { + visibleCount = totalItemCount; + break; + } + + const itemWidth = itemRef.current.offsetWidth; + if (itemWidth === 0) { + visibleCount = totalItemCount; + break; + } + + const widthWithGap = itemWidth + (i > 0 ? gapSize : 0); + const needsIndicator = i < totalItemCount - 1; + const availableWidth = containerWidth - (needsIndicator ? indicatorWidth + gapSize : 0); + + if (totalWidth + widthWithGap <= availableWidth) { + totalWidth += widthWithGap; + visibleCount = i + 1; + } else { + break; + } + } + + // Ensure at least 1 item is visible if there are items + if (visibleCount === 0 && totalItemCount > 0) { + visibleCount = 1; + } + + if (this.state.visibleCount !== visibleCount) { + this.setState({visibleCount}); + } + } + + render() { + const { + gap = 'compact', + overlap = false, + overflow = 'fixed', + showOnlyHiddenInPopover = false, + indicatorStyle = 'count', + renderIndicator, + onIndicatorClick, + indicatorRadius = 'full', + className, + } = this.props; + + // Determine if using data-driven API or simple API + const isDataDriven = 'itemsData' in this.props && this.props.itemsData !== undefined; + const itemCount = this.getItemCount(this.props); + + // Determine max based on overflow mode + const max: number = (() => { + if (overflow === 'auto') { + return this.state.visibleCount; + } else if (this.props.max === 'show-all') { + return itemCount; + } else if (this.props.max == null) { + return 4; + } else { + return this.props.max; + } + })(); + + const itemsOverLimit = itemCount - max; + const indicatorCount = itemsOverLimit; + + const defaultIndicator = (count: number) => +{count}; + + // Helper to render visible items (in stack) + const renderVisibleItems = () => { + const renderItemWrapper = (content: React.ReactNode, index: number) => { + const isVisible = index < max; + return ( +
+ {content} +
+ ); + }; + + if (isDataDriven) { + const props = this.props as IPropsOverflowStackData; + const itemsToRender = overflow === 'auto' ? props.itemsData : props.itemsData.slice(0, max); + return itemsToRender.map((data, index) => + renderItemWrapper(props.renderVisibleItem(data, index), index), + ); + } else { + const props = this.props as IPropsOverflowStackSimple; + const itemsToRender = overflow === 'auto' ? props.items : props.items.slice(0, max); + return itemsToRender.map((item, index) => renderItemWrapper(item, index)); + } + }; + + const renderIndicatorButton = (onToggle: (ref: HTMLElement) => void) => { + const ariaLabel = showOnlyHiddenInPopover + ? `Show ${indicatorCount} hidden items` + : `Show ${indicatorCount} more items`; + + const handleClick = (event: React.MouseEvent) => { + event.stopPropagation(); + if (onIndicatorClick == null) { + onToggle(event.currentTarget as HTMLElement); + } else { + onIndicatorClick(); + } + }; + + let indicator: React.ReactNode; + + if (renderIndicator) { + indicator = ( + + {renderIndicator(indicatorCount)} + + ); + } else if (indicatorStyle === 'dots') { + indicator = ; + } else { + indicator = ( + + {defaultIndicator(indicatorCount)} + + ); + } + + // Only wrap in a div with ref for auto mode (needed for width measurement) + return overflow === 'auto' ? ( +
+ {indicator} +
+ ) : ( + indicator + ); + }; + + // Create a component that WithPopover will instantiate + const PopoverComponent = (popoverProps: {closePopup(): void}) => ( + + ref={this.popoverContentRef} + isDataDriven={isDataDriven} + propsRef={this.currentPropsRef} + max={max} + showOnlyHiddenInPopover={showOnlyHiddenInPopover} + closePopup={popoverProps.closePopup} + /> + ); + + return ( + + {(onToggle) => { + const stackContent = ( +
+ {renderVisibleItems()} + {itemsOverLimit > 0 && renderIndicatorButton(onToggle)} +
+ ); + + // Wrap in a full-width container for auto mode + if (overflow === 'auto') { + return
{stackContent}
; + } + + return stackContent; + }} +
+ ); + } +} diff --git a/app-typescript/components/SearchBar.tsx b/app-typescript/components/SearchBar.tsx index ccdeb669..d0278fd0 100644 --- a/app-typescript/components/SearchBar.tsx +++ b/app-typescript/components/SearchBar.tsx @@ -4,10 +4,13 @@ import {Icon} from './Icon'; interface IProps { value?: string; - type?: 'expanded' | 'collapsed' | 'boxed'; + type?: 'expanded' | 'collapsed'; placeholder: string; focused?: boolean; boxed?: boolean; + hideSearchButton?: boolean; // Hide the internal search button (useful when triggering search externally) + searchOnType?: boolean; // Enable automatic search while typing (debounced) + searchDelay?: number; // Delay in milliseconds for searchOnType (default: 300) onSubmit?(value: string | number): void; } @@ -20,31 +23,122 @@ interface IState { } export class SearchBar extends React.PureComponent { - private inputRef: any; + private inputRef: React.RefObject; + private searchInputRef: React.RefObject; + private searchTimeoutId: ReturnType | null = null; + private mouseDownHandler: ((event: MouseEvent) => void) | null = null; + constructor(props: IProps) { super(props); this.state = { - inputValue: this.props.value ? this.props.value : '', - focused: this.props.focused ? this.props.focused : false, - type: this.props.type ? this.props.type : 'expanded', - boxed: this.props.boxed ? this.props.boxed : false, + inputValue: this.props.value || '', + focused: this.props.focused || false, + type: this.props.type || 'expanded', + boxed: this.props.boxed || false, keyDown: false, }; this.inputRef = React.createRef(); + this.searchInputRef = React.createRef(); } - componentDidUpdate(prevProps: any) { + // Debounced search handler for searchOnType + private handleDebouncedSearch = (value: string) => { + if (this.searchTimeoutId) { + clearTimeout(this.searchTimeoutId); + } + + // Require at least 3 characters before triggering search, or allow empty string to clear + if (value.length > 0 && value.length < 3) { + return; + } + + const delay = this.props.searchDelay || 300; + this.searchTimeoutId = setTimeout(() => { + if (this.props.onSubmit) { + this.props.onSubmit(value); + } + this.searchTimeoutId = null; + }, delay); + }; + + // Public method to trigger search externally + public triggerSearch = () => { + // Clear any pending debounced search + if (this.searchTimeoutId) { + clearTimeout(this.searchTimeoutId); + this.searchTimeoutId = null; + } + + if (this.props.onSubmit) { + this.props.onSubmit(this.state.inputValue); + } + }; + + // Public method to clear search externally + public clearSearch = () => { + // Clear any pending debounced search + if (this.searchTimeoutId) { + clearTimeout(this.searchTimeoutId); + this.searchTimeoutId = null; + } + + this.setState({inputValue: ''}, () => { + if (this.props.onSubmit) { + this.props.onSubmit(''); + } + }); + }; + + // Public method to focus the input externally + public focus = () => { + if (this.searchInputRef.current) { + this.searchInputRef.current.focus(); + this.setState({focused: true}); + } + }; + + // Public method to set value externally + public setValue = (value: string) => { + this.setState({inputValue: value}); + + // If searchOnType is enabled, trigger debounced search + if (this.props.searchOnType) { + this.handleDebouncedSearch(value); + } + }; + + componentDidUpdate(prevProps: IProps) { if (prevProps.value !== this.props.value) { - this.setState({inputValue: this.props.value}); + this.setState({inputValue: this.props.value || ''}); } } componentDidMount = () => { - document.addEventListener('mousedown', (event) => { - if (this.inputRef.current && !this.inputRef.current.contains(event.target)) { + this.mouseDownHandler = (event: MouseEvent) => { + if (this.inputRef.current && !this.inputRef.current.contains(event.target as Node)) { this.setState({focused: false}); } - }); + }; + + document.addEventListener('mousedown', this.mouseDownHandler); + + // Auto-focus if focused prop is true + if (this.props.focused && this.searchInputRef.current) { + this.searchInputRef.current.focus(); + } + }; + + componentWillUnmount = () => { + // Cleanup: clear timeout and remove event listener + if (this.searchTimeoutId) { + clearTimeout(this.searchTimeoutId); + this.searchTimeoutId = null; + } + + if (this.mouseDownHandler) { + document.removeEventListener('mousedown', this.mouseDownHandler); + this.mouseDownHandler = null; + } }; render() { @@ -60,13 +154,19 @@ export class SearchBar extends React.PureComponent { input && this.props.focused && input.focus()} + ref={this.searchInputRef} className="sd-searchbar__input" type="text" placeholder={this.props.placeholder} value={this.state.inputValue} onKeyPress={(event) => { if (event.key === 'Enter') { + // Clear any pending debounced search when Enter is pressed + if (this.searchTimeoutId) { + clearTimeout(this.searchTimeoutId); + this.searchTimeoutId = null; + } + if (this.props.onSubmit) { this.props.onSubmit(this.state.inputValue); } @@ -78,17 +178,30 @@ export class SearchBar extends React.PureComponent { this.setState({keyDown: false}); } }} - onChange={(event) => this.setState({inputValue: event.target.value})} + onChange={(event) => { + const value = event.target.value; + this.setState({inputValue: value}); + + // Trigger debounced search if searchOnType is enabled + if (this.props.searchOnType) { + this.handleDebouncedSearch(value); + } + }} onFocus={() => this.setState({focused: true})} /> {this.state.inputValue && ( )} - {this.state.inputValue && ( + {this.state.inputValue && !this.props.hideSearchButton && !this.props.searchOnType && (