File size: 5,157 Bytes
f0743f4
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
import { Search, X } from 'lucide-react';
import React, { useState, useMemo, useCallback, useRef } from 'react';
import { cn } from '~/utils';

/** This is a generic that can be added to Menu and Select components */
export default function MultiSearch({
  value,
  onChange,
  placeholder,
  className = '',
}: {
  value: string | null;
  onChange: (filter: string) => void;
  placeholder?: string;
  className?: string;
}) {
  const inputRef = useRef<HTMLInputElement>(null);

  const onChangeHandler: React.ChangeEventHandler<HTMLInputElement> = useCallback(
    (e) => onChange(e.target.value),
    [onChange],
  );

  const clearSearch = () => {
    onChange('');
    setTimeout(() => {
      inputRef.current?.focus();
    }, 0);
  };

  return (
    <div
      className={cn(
        'focus:to-surface-primary/50 group sticky left-0 top-0 z-10 flex h-12 items-center gap-2 bg-gradient-to-b from-surface-tertiary-alt from-65% to-transparent px-3 py-2 text-text-primary transition-colors duration-300 focus:bg-gradient-to-b focus:from-surface-primary',
        className,
      )}
    >
      <Search
        className="h-4 w-4 text-text-secondary-alt transition-colors duration-300"
        aria-hidden={'true'}
      />
      <input
        ref={inputRef}
        type="text"
        value={value ?? ''}
        onChange={onChangeHandler}
        placeholder={String(placeholder ?? 'Search...')}
        aria-label="Search Model"
        className="flex-1 rounded-md border-none bg-transparent px-2.5 py-2 text-sm placeholder-text-secondary focus:outline-none focus:ring-1 focus:ring-ring-primary"
      />
      <button
        className={cn(
          'relative flex h-5 w-5 items-center justify-end rounded-md text-text-secondary-alt',
          (value?.length ?? 0) ? 'cursor-pointer opacity-100' : 'hidden',
        )}
        aria-label={'Clear search'}
        onClick={clearSearch}
        tabIndex={0}
      >
        <X
          aria-hidden={'true'}
          className={cn(
            'text-text-secondary-alt',
            (value?.length ?? 0) ? 'cursor-pointer opacity-100' : 'opacity-0',
          )}
        />
      </button>
    </div>
  );
}

/**
 * Helper function that will take a multiSearch input
 * @param node
 */
function defaultGetStringKey(node: unknown): string {
  if (typeof node === 'string') {
    // BUGFIX: Detect psedeo separators and make sure they don't appear in the list when filtering items
    // it makes sure (for the most part) that the model name starts and ends with dashes
    // The long-term fix here would be to enable seperators (model groupings) but there's no
    // feature mocks for such a thing yet
    if (node.startsWith('---') && node.endsWith('---')) {
      return '';
    }

    return node.toUpperCase();
  }
  // This should be a noop, but it's here for redundancy
  return '';
}

/**
 * Hook for conditionally making a multi-element list component into a sortable component
 * Returns a RenderNode for search input when search functionality is available
 * @param availableOptions
 * @param placeholder
 * @param getTextKeyOverride
 * @param className - Additional classnames to add to the search container
 * @param disabled - If the search should be disabled
 * @returns
 */
export function useMultiSearch<OptionsType extends unknown[]>({
  availableOptions = [] as unknown as OptionsType,
  placeholder,
  getTextKeyOverride,
  className,
  disabled = false,
}: {
  availableOptions?: OptionsType;
  placeholder?: string;
  getTextKeyOverride?: (node: OptionsType[0]) => string;
  className?: string;
  disabled?: boolean;
}): [OptionsType, React.ReactNode] {
  const [filterValue, setFilterValue] = useState<string | null>(null);

  // We conditionally show the search when there's more than 10 elements in the menu
  const shouldShowSearch = availableOptions.length > 10 && !disabled;

  // Define the helper function used to enable search
  // If this is invalidly described, we will assume developer error - tf. avoid rendering
  const getTextKeyHelper = getTextKeyOverride || defaultGetStringKey;

  // Iterate said options
  const filteredOptions = useMemo(() => {
    const currentFilter = filterValue ?? '';
    if (!shouldShowSearch || !currentFilter || !availableOptions.length) {
      // Don't render if available options aren't present, there's no filter active
      return availableOptions;
    }
    // Filter through the values, using a simple text-based search
    // nothing too fancy, but we can add a better search algo later if we need
    const upperFilterValue = currentFilter.toUpperCase();

    return availableOptions.filter((value) =>
      getTextKeyHelper(value).includes(upperFilterValue),
    ) as OptionsType;
  }, [availableOptions, getTextKeyHelper, filterValue, shouldShowSearch]);

  const onSearchChange = useCallback(
    (nextFilterValue: string) => setFilterValue(nextFilterValue),
    [],
  );

  const searchRender = shouldShowSearch ? (
    <MultiSearch
      value={filterValue}
      className={className}
      onChange={onSearchChange}
      placeholder={placeholder}
    />
  ) : null;

  return [filteredOptions, searchRender];
}