goobs-frontend 0.9.11 → 0.9.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "goobs-frontend",
3
- "version": "0.9.11",
3
+ "version": "0.9.12",
4
4
  "type": "module",
5
5
  "description": "A comprehensive React-based libary that extends the functionality of Material-UI",
6
6
  "license": "MIT",
@@ -1,17 +1,33 @@
1
1
  'use client'
2
2
 
3
- import React, { useState, useEffect, SyntheticEvent } from 'react'
3
+ import React, { useState, useEffect, SyntheticEvent, useRef } from 'react'
4
4
  import {
5
5
  Autocomplete,
6
6
  InputLabel,
7
7
  OutlinedInput,
8
8
  FormHelperText,
9
9
  FormControl,
10
+ Box,
11
+ Tabs,
12
+ Tab,
13
+ styled as muiStyled,
14
+ Popper,
10
15
  } from '@mui/material'
11
16
  import { styled } from '@mui/material/styles'
12
17
  import { black, white } from '../../../../styles/palette'
13
18
  import Typography from '../../../Typography'
14
19
  import ArrowDropDownIcon from '@mui/icons-material/ArrowDropDown'
20
+ import HistoryIcon from '@mui/icons-material/History'
21
+ import SearchIcon from '@mui/icons-material/Search'
22
+ import { unstable_useEnhancedEffect as useEnhancedEffect } from '@mui/utils'
23
+
24
+ // Define a history item type with timestamp
25
+ interface HistoryItem {
26
+ text: string
27
+ timestamp: Date
28
+ // Optional formatted date string for display
29
+ formattedDate?: string
30
+ }
15
31
 
16
32
  export interface DropdownOption {
17
33
  value: string
@@ -44,10 +60,12 @@ export interface SearchableDropdownProps {
44
60
  placeholder?: string
45
61
  disabled?: boolean
46
62
  width?: string
47
- // Added style property to allow additional styling (e.g., marginBottom)
48
63
  style?: React.CSSProperties
49
- // New variant property to determine display style
50
64
  variant?: 'simple' | 'complex'
65
+ // Update search history type to support timestamps
66
+ searchHistory?: HistoryItem[] | string[]
67
+ onSearch?: (searchTerm: string, timestamp?: Date) => void
68
+ maxHistoryItems?: number
51
69
  }
52
70
 
53
71
  const StyledFormControl = styled(FormControl)<{ width?: string }>(
@@ -99,6 +117,26 @@ interface StyledAutocompleteProps {
99
117
  variant?: 'simple' | 'complex'
100
118
  }
101
119
 
120
+ // Styled tab component for the dropdown footer
121
+ const StyledTab = muiStyled(Tab)(() => ({
122
+ minHeight: '36px',
123
+ fontSize: '12px',
124
+ padding: '6px 12px',
125
+ color: black.main,
126
+ '&.Mui-selected': {
127
+ color: black.main,
128
+ fontWeight: 'bold',
129
+ },
130
+ }))
131
+
132
+ const StyledTabs = muiStyled(Tabs)(() => ({
133
+ minHeight: '36px',
134
+ borderTop: `1px solid ${black.light}`,
135
+ '& .MuiTabs-indicator': {
136
+ backgroundColor: black.main,
137
+ },
138
+ }))
139
+
102
140
  const StyledAutocomplete = styled(
103
141
  Autocomplete<DropdownOption, false, false, true>
104
142
  )<StyledAutocompleteProps>(props => {
@@ -236,10 +274,138 @@ const SearchableDropdown: React.FC<SearchableDropdownProps> = ({
236
274
  width,
237
275
  style,
238
276
  variant = 'simple', // Default to simple variant
277
+ searchHistory = [], // Default to empty array
278
+ onSearch,
279
+ maxHistoryItems = 5, // Default to showing 5 history items
239
280
  }) => {
240
281
  const [value, setValue] = useState<DropdownOption | string | null>(null)
241
282
  const [inputValue, setInputValue] = useState('')
242
283
  const [isFocused, setIsFocused] = useState(false)
284
+ // Update local history state to support timestamps
285
+ const [localHistory, setLocalHistory] = useState<HistoryItem[]>([])
286
+
287
+ // Add state for active tab - 0 for All Options, 1 for History
288
+ const [activeTab, setActiveTab] = useState<number>(0)
289
+
290
+ // Use ref to track if we've done initial history setup to avoid loops
291
+ const initializedRef = React.useRef(false)
292
+
293
+ // Always use controlled open state
294
+ const [isOpen, setIsOpen] = useState(false)
295
+
296
+ // Reference to the tab container
297
+ const tabsRef = useRef<HTMLDivElement | null>(null)
298
+
299
+ // Ref for the input/autocomplete
300
+ const autocompleteRef = useRef<HTMLDivElement>(null)
301
+
302
+ // Use enhanced effect to handle tab clicks without closing dropdown
303
+ useEnhancedEffect(() => {
304
+ // If tabs aren't mounted yet, do nothing
305
+ if (!tabsRef.current) return
306
+
307
+ const tabsElement = tabsRef.current
308
+
309
+ // Prevent any click inside the tabs from bubbling up
310
+ const preventClose = (e: MouseEvent) => {
311
+ e.preventDefault()
312
+ e.stopPropagation()
313
+ }
314
+
315
+ // Add click handler to the tabs element
316
+ tabsElement.addEventListener('mousedown', preventClose, true)
317
+ tabsElement.addEventListener('click', preventClose, true)
318
+
319
+ // Cleanup
320
+ return () => {
321
+ tabsElement.removeEventListener('mousedown', preventClose, true)
322
+ tabsElement.removeEventListener('click', preventClose, true)
323
+ }
324
+ }, [tabsRef.current])
325
+
326
+ // Function to format date for display
327
+ const formatDate = (date: Date): string => {
328
+ const now = new Date()
329
+ const diff = now.getTime() - date.getTime()
330
+
331
+ // If less than a minute ago
332
+ if (diff < 60 * 1000) {
333
+ return 'Just now'
334
+ }
335
+
336
+ // If less than an hour ago
337
+ if (diff < 60 * 60 * 1000) {
338
+ const minutes = Math.floor(diff / (60 * 1000))
339
+ return `${minutes} ${minutes === 1 ? 'minute' : 'minutes'} ago`
340
+ }
341
+
342
+ // If less than a day ago
343
+ if (diff < 24 * 60 * 60 * 1000) {
344
+ const hours = Math.floor(diff / (60 * 60 * 1000))
345
+ return `${hours} ${hours === 1 ? 'hour' : 'hours'} ago`
346
+ }
347
+
348
+ // If less than a week ago
349
+ if (diff < 7 * 24 * 60 * 60 * 1000) {
350
+ const days = Math.floor(diff / (24 * 60 * 60 * 1000))
351
+ return `${days} ${days === 1 ? 'day' : 'days'} ago`
352
+ }
353
+
354
+ // Otherwise, format as date
355
+ return date.toLocaleDateString(undefined, {
356
+ year: 'numeric',
357
+ month: 'short',
358
+ day: 'numeric',
359
+ })
360
+ }
361
+
362
+ // Initialize history with timestamps if using simple strings
363
+ useEffect(() => {
364
+ if (!initializedRef.current && searchHistory && searchHistory.length > 0) {
365
+ // Check if searchHistory items are strings or already HistoryItems
366
+ if (typeof searchHistory[0] === 'string') {
367
+ const historyWithTimestamps = (searchHistory as string[]).map(
368
+ (text, index) => {
369
+ // Create timestamps with slight variations so older items are truly older
370
+ const timestamp = new Date()
371
+ timestamp.setMinutes(timestamp.getMinutes() - (index + 1))
372
+
373
+ return {
374
+ text,
375
+ timestamp,
376
+ formattedDate: formatDate(timestamp),
377
+ }
378
+ }
379
+ )
380
+ setLocalHistory(historyWithTimestamps)
381
+ } else {
382
+ // Already the right format, just update formatted dates
383
+ const formattedHistory = (searchHistory as HistoryItem[]).map(item => ({
384
+ ...item,
385
+ formattedDate: formatDate(item.timestamp),
386
+ }))
387
+ setLocalHistory(formattedHistory)
388
+ }
389
+
390
+ initializedRef.current = true
391
+ }
392
+ }, [searchHistory])
393
+
394
+ // Refresh formatted dates every minute for "minutes ago" style timestamps
395
+ useEffect(() => {
396
+ const intervalId = setInterval(() => {
397
+ if (localHistory.length > 0) {
398
+ setLocalHistory(prev =>
399
+ prev.map(item => ({
400
+ ...item,
401
+ formattedDate: formatDate(item.timestamp),
402
+ }))
403
+ )
404
+ }
405
+ }, 60000) // Update every minute
406
+
407
+ return () => clearInterval(intervalId)
408
+ }, [localHistory.length])
243
409
 
244
410
  useEffect(() => {
245
411
  const defaultOption = options.find(option => option.value === defaultValue)
@@ -260,6 +426,26 @@ const SearchableDropdown: React.FC<SearchableDropdownProps> = ({
260
426
  setValue(newValue)
261
427
  setInputValue(newValue)
262
428
  onChange?.(null)
429
+
430
+ // Add to search history if it's a string input
431
+ if (newValue.trim()) {
432
+ const now = new Date()
433
+
434
+ if (onSearch) {
435
+ onSearch(newValue.trim(), now)
436
+ } else {
437
+ // Only update local history if we're not using onSearch callback
438
+ setLocalHistory(prev => {
439
+ const newItem = {
440
+ text: newValue.trim(),
441
+ timestamp: now,
442
+ formattedDate: formatDate(now),
443
+ }
444
+ const filteredPrev = prev.filter(h => h.text !== newItem.text)
445
+ return [newItem, ...filteredPrev].slice(0, maxHistoryItems)
446
+ })
447
+ }
448
+ }
263
449
  } else {
264
450
  setValue(newValue)
265
451
  if (newValue) {
@@ -268,6 +454,24 @@ const SearchableDropdown: React.FC<SearchableDropdownProps> = ({
268
454
  newValue.value.replace(/_/g, ' ').slice(1)
269
455
  setInputValue(displayText)
270
456
  onChange?.(newValue)
457
+
458
+ // Add to search history
459
+ const now = new Date()
460
+
461
+ if (onSearch) {
462
+ onSearch(displayText, now)
463
+ } else if (newValue.value.trim()) {
464
+ // Only update local history if we're not using onSearch callback
465
+ setLocalHistory(prev => {
466
+ const newItem = {
467
+ text: displayText,
468
+ timestamp: now,
469
+ formattedDate: formatDate(now),
470
+ }
471
+ const filteredPrev = prev.filter(h => h.text !== newItem.text)
472
+ return [newItem, ...filteredPrev].slice(0, maxHistoryItems)
473
+ })
474
+ }
271
475
  } else {
272
476
  setInputValue('')
273
477
  onChange?.(null)
@@ -275,6 +479,34 @@ const SearchableDropdown: React.FC<SearchableDropdownProps> = ({
275
479
  }
276
480
  }
277
481
 
482
+ // Handler for when the input is submitted (e.g., Enter key)
483
+ const handleInputSubmit = () => {
484
+ if (inputValue.trim()) {
485
+ const now = new Date()
486
+
487
+ if (onSearch) {
488
+ onSearch(inputValue.trim(), now)
489
+ } else {
490
+ // Only update local history if we're not using onSearch callback
491
+ setLocalHistory(prev => {
492
+ const newItem = {
493
+ text: inputValue.trim(),
494
+ timestamp: now,
495
+ formattedDate: formatDate(now),
496
+ }
497
+ const filteredPrev = prev.filter(h => h.text !== newItem.text)
498
+ return [newItem, ...filteredPrev].slice(0, maxHistoryItems)
499
+ })
500
+ }
501
+ }
502
+ }
503
+
504
+ const handleKeyDown = (event: React.KeyboardEvent) => {
505
+ if (event.key === 'Enter') {
506
+ handleInputSubmit()
507
+ }
508
+ }
509
+
278
510
  const handleFocus = () => {
279
511
  setIsFocused(true)
280
512
  }
@@ -283,10 +515,238 @@ const SearchableDropdown: React.FC<SearchableDropdownProps> = ({
283
515
  if (!value && !inputValue) {
284
516
  setIsFocused(false)
285
517
  }
518
+ // Save search when blurring
519
+ if (inputValue.trim()) {
520
+ handleInputSubmit()
521
+ }
522
+ }
523
+
524
+ // Function to handle tab change
525
+ const handleTabChange = (_event: React.SyntheticEvent, newValue: number) => {
526
+ // Set tab value
527
+ setActiveTab(newValue)
528
+
529
+ // Keep the dropdown open when switching tabs
530
+ setIsOpen(true)
531
+
532
+ // Prevent any bubbling that might close the dropdown
533
+ _event.preventDefault()
534
+ _event.stopPropagation()
286
535
  }
287
536
 
288
537
  const labelId = `${name}-label`
289
538
 
539
+ // Use a combined history that prioritizes prop history when available but falls back to local history
540
+ const combinedHistory = React.useMemo(() => {
541
+ if (searchHistory?.length) {
542
+ // Convert string[] to HistoryItem[] if needed
543
+ if (typeof searchHistory[0] === 'string') {
544
+ return (searchHistory as string[]).map((text, index) => {
545
+ // Create timestamps with slight variations
546
+ const timestamp = new Date()
547
+ timestamp.setMinutes(timestamp.getMinutes() - (index + 1))
548
+
549
+ return {
550
+ text,
551
+ timestamp,
552
+ formattedDate: formatDate(timestamp),
553
+ }
554
+ })
555
+ }
556
+
557
+ // Already HistoryItem[] format
558
+ return searchHistory as HistoryItem[]
559
+ }
560
+
561
+ return localHistory
562
+ }, [searchHistory, localHistory])
563
+
564
+ // Create a combined options array based on active tab and input value
565
+ const getFilteredOptions = React.useCallback(() => {
566
+ const currentInputVal = inputValue.trim()
567
+
568
+ // HISTORY TAB
569
+ if (activeTab === 1) {
570
+ if (combinedHistory.length === 0) {
571
+ // Show a placeholder message if no history
572
+ return [
573
+ {
574
+ value: 'No search history',
575
+ uniqueKey: 'no-history',
576
+ attribute1: 'Try searching for something first',
577
+ },
578
+ ]
579
+ }
580
+
581
+ // Map history items to dropdown options
582
+ return combinedHistory.map(item => {
583
+ // Check if this history item matches any of the original options
584
+ const matchingOption = options.find(
585
+ opt =>
586
+ opt.value.toLowerCase() === item.text.toLowerCase() ||
587
+ (
588
+ opt.value.replace(/_/g, ' ').charAt(0).toUpperCase() +
589
+ opt.value.replace(/_/g, ' ').slice(1)
590
+ ).toLowerCase() === item.text.toLowerCase()
591
+ )
592
+
593
+ if (matchingOption) {
594
+ // If we have a matching original option, use its attributes
595
+ return {
596
+ value: item.text,
597
+ uniqueKey: `history-${item.text}`,
598
+ attribute1: 'Search History',
599
+ attribute2: item.formattedDate || formatDate(item.timestamp),
600
+ attribute3: matchingOption.attribute1,
601
+ attribute4: matchingOption.attribute2,
602
+ attribute5: matchingOption.attribute3,
603
+ attribute6: matchingOption.attribute4,
604
+ }
605
+ } else {
606
+ // Otherwise just use the basic history item with timestamp
607
+ return {
608
+ value: item.text,
609
+ uniqueKey: `history-${item.text}`,
610
+ attribute1: 'Search History',
611
+ attribute2: item.formattedDate || formatDate(item.timestamp),
612
+ }
613
+ }
614
+ })
615
+ }
616
+
617
+ // ALL OPTIONS TAB (activeTab === 0)
618
+ // If input is empty, return all options
619
+ if (!currentInputVal) {
620
+ return options
621
+ }
622
+
623
+ // Filter options based on current input
624
+ const filteredOpts = options.filter(opt =>
625
+ opt.value.toLowerCase().includes(currentInputVal.toLowerCase())
626
+ )
627
+
628
+ // If no matches found, add the current input as a custom option
629
+ if (filteredOpts.length === 0) {
630
+ return [
631
+ {
632
+ value: currentInputVal,
633
+ uniqueKey: `current-${currentInputVal}`,
634
+ attribute1: 'Search',
635
+ },
636
+ ]
637
+ }
638
+
639
+ // Check if the input exactly matches any option
640
+ const exactMatch = filteredOpts.some(
641
+ opt => opt.value.toLowerCase() === currentInputVal.toLowerCase()
642
+ )
643
+
644
+ // If no exact match, add the current input as the first option
645
+ if (!exactMatch) {
646
+ return [
647
+ {
648
+ value: currentInputVal,
649
+ uniqueKey: `current-${currentInputVal}`,
650
+ attribute1: 'Search',
651
+ },
652
+ ...filteredOpts,
653
+ ]
654
+ }
655
+
656
+ return filteredOpts
657
+ }, [inputValue, combinedHistory, options, activeTab])
658
+
659
+ // Create the footer component for the dropdown with tabs
660
+ const ListboxFooter = React.forwardRef<HTMLDivElement>((_, ref) => (
661
+ <Box
662
+ ref={(node: HTMLDivElement | null) => {
663
+ // Set both refs
664
+ if (ref) {
665
+ if (typeof ref === 'function') {
666
+ ref(node)
667
+ } else {
668
+ ref.current = node
669
+ }
670
+ }
671
+ tabsRef.current = node
672
+ }}
673
+ sx={{
674
+ position: 'sticky',
675
+ bottom: 0,
676
+ backgroundColor: white.main,
677
+ zIndex: 2,
678
+ borderTop: `1px solid ${black.light}`,
679
+ }}
680
+ onClick={e => {
681
+ e.preventDefault()
682
+ e.stopPropagation()
683
+ }}
684
+ onMouseDown={e => {
685
+ e.preventDefault()
686
+ e.stopPropagation()
687
+ }}
688
+ >
689
+ <StyledTabs
690
+ value={activeTab}
691
+ onChange={handleTabChange}
692
+ centered
693
+ sx={{
694
+ pointerEvents: 'all',
695
+ }}
696
+ >
697
+ <StyledTab
698
+ icon={<SearchIcon fontSize="small" />}
699
+ label="ALL OPTIONS"
700
+ iconPosition="start"
701
+ sx={{
702
+ pointerEvents: 'all',
703
+ }}
704
+ />
705
+ <StyledTab
706
+ icon={<HistoryIcon fontSize="small" />}
707
+ label="HISTORY"
708
+ iconPosition="start"
709
+ sx={{
710
+ pointerEvents: 'all',
711
+ }}
712
+ />
713
+ </StyledTabs>
714
+ </Box>
715
+ ))
716
+
717
+ // Add display name to ListboxFooter
718
+ ListboxFooter.displayName = 'ListboxFooter'
719
+
720
+ // Custom Listbox component that adds the footer
721
+ const CustomListbox = React.forwardRef<
722
+ HTMLElement,
723
+ React.HTMLAttributes<HTMLElement>
724
+ >((props, ref) => {
725
+ const { children, ...other } = props
726
+ return (
727
+ <div
728
+ ref={ref as React.Ref<HTMLDivElement>}
729
+ onClick={e => {
730
+ e.stopPropagation()
731
+ return false
732
+ }}
733
+ style={{ pointerEvents: 'auto' }}
734
+ >
735
+ <ul {...other}>{children}</ul>
736
+ <ListboxFooter />
737
+ </div>
738
+ )
739
+ })
740
+
741
+ // Add display name to CustomListbox
742
+ CustomListbox.displayName = 'CustomListbox'
743
+
744
+ // Memoize the filtered options to prevent recreation on every render
745
+ const filteredOptions = React.useMemo(
746
+ () => getFilteredOptions(),
747
+ [getFilteredOptions]
748
+ )
749
+
290
750
  return (
291
751
  <StyledFormControl
292
752
  error={error}
@@ -308,7 +768,7 @@ const SearchableDropdown: React.FC<SearchableDropdownProps> = ({
308
768
  </StyledInputLabel>
309
769
  <StyledAutocomplete
310
770
  id={name}
311
- options={options}
771
+ options={filteredOptions}
312
772
  freeSolo
313
773
  value={value}
314
774
  onChange={handleChange}
@@ -321,6 +781,18 @@ const SearchableDropdown: React.FC<SearchableDropdownProps> = ({
321
781
  onFocus={handleFocus}
322
782
  onBlur={handleBlur}
323
783
  forcePopupIcon
784
+ open={isOpen}
785
+ onOpen={() => {
786
+ setIsOpen(true)
787
+ }}
788
+ onClose={event => {
789
+ // Don't close when clicking tabs
790
+ if (tabsRef.current?.contains(event.target as Node)) {
791
+ return
792
+ }
793
+
794
+ setIsOpen(false)
795
+ }}
324
796
  popupIcon={
325
797
  <ArrowDropDownIcon
326
798
  sx={{ color: disabled ? 'rgba(0, 0, 0, 0.38)' : black.main }}
@@ -328,8 +800,69 @@ const SearchableDropdown: React.FC<SearchableDropdownProps> = ({
328
800
  }
329
801
  disablePortal={false}
330
802
  ListboxProps={{
331
- style: { maxHeight: '300px', overflowY: 'auto' },
803
+ style: {
804
+ maxHeight: '300px',
805
+ overflowY: 'auto',
806
+ pointerEvents: 'all',
807
+ },
808
+ }}
809
+ ListboxComponent={CustomListbox}
810
+ componentsProps={{
811
+ popper: {
812
+ onClick: e => {
813
+ // Prevent clicks in the popper from closing the dropdown
814
+ e.stopPropagation()
815
+ },
816
+ style: {
817
+ pointerEvents: 'all',
818
+ // Ensure clicks inside the popper don't close it
819
+ inset: '0px auto auto 0px',
820
+ zIndex: 9999,
821
+ },
822
+ },
823
+ paper: {
824
+ onClick: e => {
825
+ // Prevent clicks on the paper from closing the dropdown
826
+ e.stopPropagation()
827
+ },
828
+ style: { pointerEvents: 'all' },
829
+ },
332
830
  }}
831
+ ref={autocompleteRef}
832
+ PopperComponent={props => (
833
+ <Popper
834
+ {...props}
835
+ onClick={e => {
836
+ e.stopPropagation()
837
+ }}
838
+ style={{
839
+ ...props.style,
840
+ pointerEvents: 'all',
841
+ zIndex: 9999,
842
+ }}
843
+ modifiers={[
844
+ {
845
+ name: 'preventOverflow',
846
+ enabled: true,
847
+ options: {
848
+ altAxis: true,
849
+ altBoundary: true,
850
+ tether: true,
851
+ rootBoundary: 'document',
852
+ padding: 8,
853
+ },
854
+ },
855
+ {
856
+ name: 'offset',
857
+ options: {
858
+ offset: [0, 0],
859
+ },
860
+ },
861
+ ]}
862
+ >
863
+ {props.children}
864
+ </Popper>
865
+ )}
333
866
  disabled={disabled}
334
867
  backgroundcolor={backgroundcolor}
335
868
  outlinecolor={outlinecolor}
@@ -337,10 +870,7 @@ const SearchableDropdown: React.FC<SearchableDropdownProps> = ({
337
870
  inputfontcolor={inputfontcolor}
338
871
  placeholdercolor={placeholdercolor}
339
872
  variant={variant}
340
- filterOptions={(opts, state) => {
341
- const input = state.inputValue.toLowerCase()
342
- return opts.filter(o => o.value.toLowerCase().includes(input))
343
- }}
873
+ filterOptions={opts => opts} // We're handling filtering ourselves
344
874
  getOptionLabel={(option: DropdownOption | string) => {
345
875
  if (typeof option === 'string') {
346
876
  return option
@@ -373,24 +903,63 @@ const SearchableDropdown: React.FC<SearchableDropdownProps> = ({
373
903
  // Use the uniqueKey prop if available, otherwise fall back to the provided key
374
904
  const optionKey = option.uniqueKey || key
375
905
 
906
+ // Check if this is a history item
907
+ const isHistoryItem = option.uniqueKey?.startsWith('history-')
908
+ const isCurrentInput = option.uniqueKey?.startsWith('current-')
909
+ const isNoHistoryPlaceholder = option.uniqueKey === 'no-history'
910
+
376
911
  return (
377
912
  <li key={optionKey} {...restLiProps} style={liStyle}>
378
913
  {/* Main value - both variants */}
379
914
  <Typography
380
915
  fontvariant="merriparagraph"
381
- text={option.value.replace(/_/g, ' ')}
916
+ text={
917
+ isNoHistoryPlaceholder
918
+ ? option.value
919
+ : isCurrentInput
920
+ ? `Search: "${option.value}"`
921
+ : isHistoryItem
922
+ ? `History: ${option.value.replace(/_/g, ' ')}`
923
+ : option.value.replace(/_/g, ' ')
924
+ }
382
925
  fontcolor={black.main}
383
926
  sx={{
384
927
  fontSize: '14px',
385
- fontWeight: variant === 'complex' ? '500' : 'normal',
928
+ fontWeight: isCurrentInput
929
+ ? '500'
930
+ : isHistoryItem
931
+ ? '400'
932
+ : variant === 'complex'
933
+ ? '500'
934
+ : 'normal',
935
+ fontStyle: isHistoryItem ? 'italic' : 'normal',
386
936
  lineHeight: '20px',
387
937
  width: '100%',
388
938
  textAlign: 'left',
389
939
  }}
390
940
  />
391
941
 
942
+ {/* For history items, show the timestamp */}
943
+ {isHistoryItem && option.attribute2 && (
944
+ <Typography
945
+ fontvariant="merriparagraph"
946
+ text={option.attribute2}
947
+ fontcolor="rgba(0, 0, 0, 0.6)"
948
+ sx={{
949
+ fontSize: '11px',
950
+ lineHeight: '14px',
951
+ width: '100%',
952
+ textAlign: 'left',
953
+ fontStyle: 'italic',
954
+ }}
955
+ />
956
+ )}
957
+
392
958
  {/* For simple variant - show attribute1 and attribute2 on one line */}
393
959
  {variant === 'simple' &&
960
+ !isHistoryItem &&
961
+ !isCurrentInput &&
962
+ !isNoHistoryPlaceholder &&
394
963
  (option.attribute1 || option.attribute2) && (
395
964
  <Typography
396
965
  fontvariant="merriparagraph"
@@ -408,26 +977,68 @@ const SearchableDropdown: React.FC<SearchableDropdownProps> = ({
408
977
  )}
409
978
 
410
979
  {/* For complex variant - show attributes on separate lines */}
411
- {variant === 'complex' && (
412
- <>
413
- {/* First line of attributes */}
414
- {(option.attribute1 || option.attribute2) && (
415
- <Typography
416
- fontvariant="merriparagraph"
417
- text={[option.attribute1, option.attribute2]
418
- .filter(Boolean)
419
- .join(' | ')}
420
- fontcolor="rgba(0, 0, 0, 0.6)"
421
- sx={{
422
- fontSize: '12px',
423
- lineHeight: '16px',
424
- width: '100%',
425
- textAlign: 'left',
426
- }}
427
- />
428
- )}
980
+ {variant === 'complex' &&
981
+ !isHistoryItem &&
982
+ !isCurrentInput &&
983
+ !isNoHistoryPlaceholder && (
984
+ <>
985
+ {/* First line of attributes */}
986
+ {(option.attribute1 || option.attribute2) && (
987
+ <Typography
988
+ fontvariant="merriparagraph"
989
+ text={[option.attribute1, option.attribute2]
990
+ .filter(Boolean)
991
+ .join(' | ')}
992
+ fontcolor="rgba(0, 0, 0, 0.6)"
993
+ sx={{
994
+ fontSize: '12px',
995
+ lineHeight: '16px',
996
+ width: '100%',
997
+ textAlign: 'left',
998
+ }}
999
+ />
1000
+ )}
1001
+
1002
+ {/* Second line of attributes */}
1003
+ {(option.attribute3 || option.attribute4) && (
1004
+ <Typography
1005
+ fontvariant="merriparagraph"
1006
+ text={[option.attribute3, option.attribute4]
1007
+ .filter(Boolean)
1008
+ .join(' | ')}
1009
+ fontcolor="rgba(0, 0, 0, 0.6)"
1010
+ sx={{
1011
+ fontSize: '12px',
1012
+ lineHeight: '16px',
1013
+ width: '100%',
1014
+ textAlign: 'left',
1015
+ }}
1016
+ />
1017
+ )}
429
1018
 
430
- {/* Second line of attributes */}
1019
+ {/* Third line of attributes */}
1020
+ {(option.attribute5 || option.attribute6) && (
1021
+ <Typography
1022
+ fontvariant="merriparagraph"
1023
+ text={[option.attribute5, option.attribute6]
1024
+ .filter(Boolean)
1025
+ .join(' | ')}
1026
+ fontcolor="rgba(0, 0, 0, 0.6)"
1027
+ sx={{
1028
+ fontSize: '12px',
1029
+ lineHeight: '16px',
1030
+ width: '100%',
1031
+ textAlign: 'left',
1032
+ }}
1033
+ />
1034
+ )}
1035
+ </>
1036
+ )}
1037
+
1038
+ {/* For history items, show additional attributes from original options */}
1039
+ {isHistoryItem && variant === 'complex' && (
1040
+ <>
1041
+ {/* Show attribute3/4 as first additional line for history */}
431
1042
  {(option.attribute3 || option.attribute4) && (
432
1043
  <Typography
433
1044
  fontvariant="merriparagraph"
@@ -440,11 +1051,12 @@ const SearchableDropdown: React.FC<SearchableDropdownProps> = ({
440
1051
  lineHeight: '16px',
441
1052
  width: '100%',
442
1053
  textAlign: 'left',
1054
+ fontStyle: 'italic',
443
1055
  }}
444
1056
  />
445
1057
  )}
446
1058
 
447
- {/* Third line of attributes */}
1059
+ {/* Show attribute5/6 as second additional line for history */}
448
1060
  {(option.attribute5 || option.attribute6) && (
449
1061
  <Typography
450
1062
  fontvariant="merriparagraph"
@@ -457,11 +1069,43 @@ const SearchableDropdown: React.FC<SearchableDropdownProps> = ({
457
1069
  lineHeight: '16px',
458
1070
  width: '100%',
459
1071
  textAlign: 'left',
1072
+ fontStyle: 'italic',
460
1073
  }}
461
1074
  />
462
1075
  )}
463
1076
  </>
464
1077
  )}
1078
+
1079
+ {/* For current input search, show a simpler display */}
1080
+ {isCurrentInput && option.attribute1 && (
1081
+ <Typography
1082
+ fontvariant="merriparagraph"
1083
+ text={option.attribute1}
1084
+ fontcolor="rgba(0, 0, 0, 0.6)"
1085
+ sx={{
1086
+ fontSize: '12px',
1087
+ lineHeight: '16px',
1088
+ width: '100%',
1089
+ textAlign: 'left',
1090
+ }}
1091
+ />
1092
+ )}
1093
+
1094
+ {/* For no history placeholder, show the instructions */}
1095
+ {isNoHistoryPlaceholder && option.attribute1 && (
1096
+ <Typography
1097
+ fontvariant="merriparagraph"
1098
+ text={option.attribute1}
1099
+ fontcolor="rgba(0, 0, 0, 0.6)"
1100
+ sx={{
1101
+ fontSize: '12px',
1102
+ lineHeight: '16px',
1103
+ width: '100%',
1104
+ textAlign: 'left',
1105
+ fontStyle: 'italic',
1106
+ }}
1107
+ />
1108
+ )}
465
1109
  </li>
466
1110
  )
467
1111
  }}
@@ -471,6 +1115,7 @@ const SearchableDropdown: React.FC<SearchableDropdownProps> = ({
471
1115
  inputProps={{
472
1116
  ...params.inputProps,
473
1117
  'aria-labelledby': labelId,
1118
+ onKeyDown: handleKeyDown,
474
1119
  }}
475
1120
  placeholder={placeholder}
476
1121
  error={error}
@@ -580,3 +580,167 @@ export const AllAttributesDisplay: Story = {
580
580
  expect(hasAllLevels).toBe(true)
581
581
  },
582
582
  }
583
+
584
+ /**
585
+ * 15) With Search History
586
+ * Uses userEvent => keep `async`.
587
+ */
588
+ export const WithSearchHistory: Story = {
589
+ args: {
590
+ label: 'Search with History',
591
+ options: complexSampleOptions,
592
+ placeholder: 'Search with history...',
593
+ variant: 'complex',
594
+ // Provide pre-populated search history
595
+ searchHistory: [
596
+ { text: 'apple', timestamp: new Date(Date.now() - 5 * 60000) }, // 5 minutes ago
597
+ { text: 'broccoli', timestamp: new Date(Date.now() - 60 * 60000) }, // 1 hour ago
598
+ {
599
+ text: 'custom search',
600
+ timestamp: new Date(Date.now() - 24 * 60 * 60000),
601
+ }, // 1 day ago
602
+ ],
603
+ maxHistoryItems: 5,
604
+ },
605
+ play: async ({ canvasElement }) => {
606
+ const canvas = within(canvasElement)
607
+
608
+ // Open the dropdown
609
+ const input = canvas.getByRole('combobox')
610
+ await userEvent.click(input)
611
+
612
+ // Switch to history tab
613
+ const historyTab = canvas.getByRole('tab', { name: /HISTORY/i })
614
+ await userEvent.click(historyTab)
615
+
616
+ // Get dropdown options after switching to history tab
617
+ const listboxItems = getDropdownOptions()
618
+
619
+ // Check if history items are displayed
620
+ const hasAppleHistory = listboxItems.some(
621
+ item => item.textContent && item.textContent.includes('apple')
622
+ )
623
+
624
+ const hasTimestamp = listboxItems.some(
625
+ item =>
626
+ item.textContent &&
627
+ (item.textContent.includes('minutes ago') ||
628
+ item.textContent.includes('hour ago') ||
629
+ item.textContent.includes('day ago'))
630
+ )
631
+
632
+ expect(hasAppleHistory).toBe(true)
633
+ expect(hasTimestamp).toBe(true)
634
+
635
+ // Try entering a new search term
636
+ await userEvent.clear(input)
637
+ await userEvent.type(input, 'new search')
638
+ await userEvent.tab() // Blur to trigger adding to history
639
+
640
+ // Reopen and check history tab again
641
+ await userEvent.click(input)
642
+ await userEvent.click(historyTab)
643
+
644
+ // Should now include our new search
645
+ const updatedListboxItems = getDropdownOptions()
646
+ const hasNewSearch = updatedListboxItems.some(
647
+ item => item.textContent && item.textContent.includes('new search')
648
+ )
649
+
650
+ expect(hasNewSearch).toBe(true)
651
+ },
652
+ }
653
+
654
+ /**
655
+ * 16) With Search Callback
656
+ * Uses userEvent => keep `async`.
657
+ */
658
+ export const WithSearchCallback: Story = {
659
+ args: {
660
+ label: 'Search with Callback',
661
+ options: sampleOptions,
662
+ placeholder: 'Search with callback...',
663
+ variant: 'simple',
664
+ // This callback will be triggered when searches are performed
665
+ onSearch: (searchTerm: string, timestamp?: Date) => {
666
+ console.log(
667
+ `Search term: ${searchTerm}, Time: ${timestamp?.toISOString() || 'No timestamp'}`
668
+ )
669
+ },
670
+ },
671
+ play: async ({ canvasElement }) => {
672
+ const canvas = within(canvasElement)
673
+
674
+ // Perform a search
675
+ const input = canvas.getByRole('combobox')
676
+ await userEvent.click(input)
677
+ await userEvent.type(input, 'test search')
678
+ await userEvent.tab() // Blur to trigger search
679
+
680
+ // Since we can't easily verify the callback in Storybook tests,
681
+ // we'll just ensure the component functions correctly
682
+ expect(input).toHaveValue('test search')
683
+ },
684
+ }
685
+
686
+ /**
687
+ * 17) Tab Navigation
688
+ * Uses userEvent => keep `async`.
689
+ */
690
+ export const TabNavigation: Story = {
691
+ args: {
692
+ label: 'Tab Navigation Demo',
693
+ options: sampleOptions,
694
+ placeholder: 'Explore tabs...',
695
+ variant: 'simple',
696
+ // Provide some search history
697
+ searchHistory: [
698
+ 'previous search one',
699
+ 'previous search two',
700
+ 'previous search three',
701
+ ],
702
+ maxHistoryItems: 10,
703
+ },
704
+ play: async ({ canvasElement }) => {
705
+ const canvas = within(canvasElement)
706
+
707
+ // Open the dropdown
708
+ const input = canvas.getByRole('combobox')
709
+ await userEvent.click(input)
710
+
711
+ // First check that we're on the default "ALL OPTIONS" tab
712
+ // and can see the regular options
713
+ const allOptionsTab = canvas.getByRole('tab', { name: /ALL OPTIONS/i })
714
+ expect(allOptionsTab).toHaveAttribute('aria-selected', 'true')
715
+
716
+ // We should see the original options
717
+ const initialListboxItems = getDropdownOptions()
718
+ const hasAppleOption = initialListboxItems.some(
719
+ item => item.textContent && item.textContent.includes('apple')
720
+ )
721
+ expect(hasAppleOption).toBe(true)
722
+
723
+ // Now switch to the HISTORY tab
724
+ const historyTab = canvas.getByRole('tab', { name: /HISTORY/i })
725
+ await userEvent.click(historyTab)
726
+ expect(historyTab).toHaveAttribute('aria-selected', 'true')
727
+
728
+ // Now we should see the history items
729
+ const historyListboxItems = getDropdownOptions()
730
+ const hasHistoryItems = historyListboxItems.some(
731
+ item => item.textContent && item.textContent.includes('previous search')
732
+ )
733
+ expect(hasHistoryItems).toBe(true)
734
+
735
+ // Now switch back to ALL OPTIONS tab
736
+ await userEvent.click(allOptionsTab)
737
+ expect(allOptionsTab).toHaveAttribute('aria-selected', 'true')
738
+
739
+ // We should see the original options again
740
+ const finalListboxItems = getDropdownOptions()
741
+ const hasAppleOptionAgain = finalListboxItems.some(
742
+ item => item.textContent && item.textContent.includes('apple')
743
+ )
744
+ expect(hasAppleOptionAgain).toBe(true)
745
+ },
746
+ }