Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
534 changes: 534 additions & 0 deletions app-typescript/components/OverflowStack.tsx

Large diffs are not rendered by default.

153 changes: 136 additions & 17 deletions app-typescript/components/SearchBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Comment on lines +11 to +13
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we can use discriminated union here to make the interface cleaner and the component easier to use I think. Maybe we could even separate the core of the component from these two types of search and make two. Let me know if you can implement it with Cursor, I can also implement it

onSubmit?(value: string | number): void;
}

Expand All @@ -20,31 +23,122 @@ interface IState {
}

export class SearchBar extends React.PureComponent<IProps, IState> {
private inputRef: any;
private inputRef: React.RefObject<HTMLDivElement>;
private searchInputRef: React.RefObject<HTMLInputElement>;
private searchTimeoutId: ReturnType<typeof setTimeout> | 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,
Comment on lines +34 to +37
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we can keep the existing more explicit code style here. Also the difference between || and ?? is that || will take any falsy value - i.e. "" and undefined counts as falsy in the case of ||, which might not be the intended behaviour. same goes for expressions using ? :

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 || ''});
Copy link

Copilot AI Nov 6, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Component state update uses potentially inconsistent value.

Copilot uses AI. Check for mistakes.
}
}

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() {
Expand All @@ -60,13 +154,19 @@ export class SearchBar extends React.PureComponent<IProps, IState> {
<label className="sd-searchbar__icon"></label>
<input
id="search-input"
ref={(input: any) => 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);
}
Expand All @@ -78,29 +178,48 @@ export class SearchBar extends React.PureComponent<IProps, IState> {
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 && (
<button
className="sd-searchbar__cancel"
onClick={() => {
this.setState({inputValue: ''});
setTimeout(() => {
// Clear any pending debounced search
if (this.searchTimeoutId) {
clearTimeout(this.searchTimeoutId);
this.searchTimeoutId = null;
}

this.setState({inputValue: ''}, () => {
if (this.props.onSubmit) {
this.props.onSubmit(this.state.inputValue);
this.props.onSubmit('');
}
});
}}
>
<Icon name="remove-sign" />
</button>
)}
{this.state.inputValue && (
{this.state.inputValue && !this.props.hideSearchButton && !this.props.searchOnType && (
<button
id="sd-searchbar__search-btn"
className={`sd-searchbar__search-btn ${this.state.keyDown ? 'sd-searchbar__search-btn--active' : ''}`}
onClick={() => {
// Clear any pending debounced search when button is clicked
if (this.searchTimeoutId) {
clearTimeout(this.searchTimeoutId);
this.searchTimeoutId = null;
}

if (this.props.onSubmit) {
this.props.onSubmit(this.state.inputValue);
}
Expand Down
1 change: 1 addition & 0 deletions app-typescript/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@ export {ThemeSelector} from './components/ThemeSelector';
export {DropZone} from './components/DropZone';
export {CreateButton} from './components/CreateButton';
export {SearchBar} from './components/SearchBar';
export {OverflowStack} from './components/OverflowStack';
export {WithSizeObserver} from './components/WithSizeObserver';
export {SvgIconIllustration} from './components/SvgIconIllustration';
export {IllustrationButton} from './components/IllustrationButton';
Expand Down
1 change: 1 addition & 0 deletions app/styles/app.scss
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@
@import 'components/card-item';
@import 'components/sd-grid-item';
@import 'components/sd-searchbar';
@import 'components/overflow-stack';
@import 'components/sd-collapse-box';
@import 'components/sd-photo-preview';
@import 'components/sd-media-carousel';
Expand Down
120 changes: 120 additions & 0 deletions app/styles/components/_overflow-stack.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
// Overflow Stack Component
// A generic component for stacking items with overflow indicator

.overflow-stack {
display: inline-flex;
align-items: center;
position: relative;

// Gap variations (for non-overlapping items)
&.overflow-stack--gap-compact {
gap: var(--gap-0-5); // 4px
}

&.overflow-stack--gap-loose {
gap: var(--gap-1); // 8px
}

&.overflow-stack--gap-none {
gap: 0; // no gap
}

// Overlapping mode (like avatars)
&.overflow-stack--overlap {
gap: 0;

.overflow-stack__item {
margin-inline-start: calc(var(--space--1) * -1);
transition: transform 0.2s ease, z-index 0.2s ease;
position: relative;
z-index: 1;

&:first-child {
margin-inline-start: 0;
}

&:hover {
transform: translateY(-2px);
z-index: 10;
> * {
outline: 2px solid var(--color-surface-base);
}
}
}

.overflow-stack__indicator {
margin-inline-start: calc(var(--space--1) * -1);
z-index: 0;

&:hover {
transform: translateY(-2px);
z-index: 10;
}
}
}
}

.overflow-stack__item {
display: inline-flex;
align-items: center;
}

.overflow-stack__indicator {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 3.2rem;
height: 3.2rem;
padding: 0 var(--space--0-5);
background-color: var(--sd-colour-primary);
color: var(--white);
border: none;
border-radius: var(--border-radius--medium);
font-size: 1.3rem;
font-weight: 500;
cursor: pointer;
transition: background-color 0.2s ease, transform 0.2s ease;
position: relative;

&:hover {
background-color: var(--sd-colour-primary--darker);
}

&:active {
transform: scale(0.95);
}

&:focus-visible {
outline: 2px solid var(--sd-colour-primary);
outline-offset: 2px;
}
}

.overflow-stack__indicator-content {
font-size: var(--text-size--small);
color: var(--color-text-muted);
line-height: 1;
display: inline-flex;
align-items: center;
justify-content: center;
white-space: nowrap;
padding: var(--space--0-5);
}

.overflow-stack__popover {
background-color: var(--color-dropdown-menu-Bg);
border-radius: var(--b-radius--large);
padding: var(--space--1-5);
box-shadow: var(--sd-shadow__dropdown);
display: flex;
flex-direction: column;
min-width: max-content;
overflow-y: auto;
}

.overflow-stack__popover-item {
display: flex;
align-items: center;
padding-block: var(--space--0-5);
transition: all 0.2s ease;
}
10 changes: 10 additions & 0 deletions examples/pages/components/Index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,8 @@ import DropZoneDoc from './DropZone';
import CreateButtonDoc from './CreateButton';
import TagInputDocs from './TagInputDocs';
import DragHandleDocs from './DragHandleDocs';
import SearchBarDoc from './SearchBar';
import OverflowStackDoc from './OverflowStack';

import * as Playgrounds from '../playgrounds/react-playgrounds/Index';
import {SelectWithTemplateDocs} from './SelectWithTemplate';
Expand Down Expand Up @@ -137,6 +139,10 @@ const pages: IPages = {
name: 'Avatar',
component: AvatarDoc,
},
'overflow-stack': {
name: 'Overflow Stack',
component: OverflowStackDoc,
},
tooltips: {
name: 'Tooltips',
component: TooltipDoc,
Expand Down Expand Up @@ -375,6 +381,10 @@ const pages: IPages = {
name: 'CreateButton',
component: CreateButtonDoc,
},
'search-bar': {
name: 'SearchBar',
component: SearchBarDoc,
},
},
},
generalComponents: {
Expand Down
Loading