-
Notifications
You must be signed in to change notification settings - Fork 25
SearchBar & OverflowStack #981
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: develop
Are you sure you want to change the base?
Changes from all commits
05a64ec
500b6ec
17d4125
2db2b16
29d0183
9e3327e
0374c56
045c32c
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Large diffs are not rendered by default.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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<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
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
||
| 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<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); | ||
| } | ||
|
|
@@ -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); | ||
| } | ||
|
|
||
| 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; | ||
| } |
There was a problem hiding this comment.
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