• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

code4recovery / tsml-ui / 19676298808

25 Nov 2025 04:14PM UTC coverage: 45.886%. First build
19676298808

Pull #493

github

web-flow
Merge 250853925 into 67cd046ca
Pull Request #493: allow mobile multi-select

440 of 1103 branches covered (39.89%)

Branch coverage included in aggregate %.

0 of 1 new or added line in 1 file covered. (0.0%)

603 of 1170 relevant lines covered (51.54%)

6.11 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

0.0
/src/components/Dropdown.tsx
1
import {
2
  Dispatch,
3
  Fragment,
4
  MouseEvent,
5
  SetStateAction,
6
  useState,
7
} from 'react';
8

9
import { useNavigate } from 'react-router-dom';
10
import { formatUrl, getIndexByKey, formatString as i18n } from '../helpers';
11
import {
12
  type Data,
13
  useData,
14
  useInput,
15
  useLocation,
16
  useSettings,
17
} from '../hooks';
18
import { dropdownButtonCss, dropdownCss } from '../styles';
19
import type { Index } from '../types';
20

21
export default function Dropdown({
22
  defaultValue,
23
  filter,
24
  open,
25
  setDropdown,
26
}: {
27
  defaultValue: string;
28
  filter: keyof Data['indexes'];
29
  open: boolean;
30
  setDropdown: Dispatch<SetStateAction<string | undefined>>;
31
}) {
32
  const { indexes } = useData();
×
33
  const navigate = useNavigate();
×
34
  const { settings, strings } = useSettings();
×
35
  const { input } = useInput();
×
36
  const { waitingForLocation } = useLocation();
×
37
  const options = indexes[filter];
×
38
  const values =
39
    filter === 'distance'
×
40
      ? input.distance
×
41
        ? [`${input.distance}`]
42
        : []
43
      : (input[filter as keyof typeof input] as string[]);
44
  const [expanded, setExpanded] = useState<string[]>([]);
×
45

46
  // handle expand toggle
47
  const toggleExpanded = (e: MouseEvent<HTMLButtonElement>, key: string) => {
×
48
    e.preventDefault();
×
49
    e.stopPropagation();
×
50
    if (!expanded.includes(key)) {
×
51
      setExpanded(expanded.concat(key));
×
52
    } else {
53
      setExpanded(expanded.filter(item => item !== key));
×
54
    }
55
  };
56

57
  // set filter: pass it up to parent
58
  const setFilter = (
×
59
    e: MouseEvent<HTMLButtonElement>,
60
    filter: keyof typeof indexes,
61
    value?: string
62
  ) => {
63
    e.preventDefault();
×
64

65
    if (filter === 'distance') {
×
66
      navigate(
×
67
        formatUrl(
68
          { ...input, distance: value ? parseInt(value) : undefined },
×
69
          settings
70
        )
71
      );
72
    } else {
73
      // add or remove from filters
74
      let currentValues = input[filter] as string[];
×
75

76
      if (value) {
×
77
        const index = currentValues.indexOf(value);
×
78
        // Multi-select behavior:
79
        // - With modifier key (cmd/ctrl): toggle multi-select
80
        // - When items already selected: toggle (allows mobile multi-select)
81
        // - Otherwise: single select
NEW
82
        if (e.metaKey || e.ctrlKey || currentValues.length > 0) {
×
83
          if (index === -1) {
×
84
            currentValues.push(value);
×
85
            currentValues.sort();
×
86
          } else {
87
            // Remove the value
88
            currentValues.splice(index, 1);
×
89
          }
90
        } else {
91
          // Single value, directly set the value
92
          currentValues = [value];
×
93
        }
94
      } else {
95
        // Remove the filter from search params if no value is provided
96
        currentValues = [];
×
97
      }
98
      navigate(formatUrl({ ...input, [filter]: currentValues }, settings));
×
99
    }
100
  };
101

102
  const renderDropdownItem = (
×
103
    { key, name, slugs, children }: Index,
104
    parentExpanded: boolean = true
×
105
  ) => {
106
    return !slugs.length ? null : (
×
107
      <Fragment key={key}>
108
        <div className="tsml-dropdown__item" data-active={values.includes(key)}>
109
          <button
110
            className="tsml-dropdown__button"
111
            onClick={e => setFilter(e, filter, key)}
×
112
            tabIndex={parentExpanded ? 0 : -1}
×
113
          >
114
            <span>{name}</span>
115
            <span
116
              aria-label={
117
                slugs.length === 1
×
118
                  ? strings.match_single
119
                  : i18n(strings.match_multiple, {
120
                      count: slugs.length,
121
                    })
122
              }
123
            >
124
              {slugs.length}
125
            </span>
126
          </button>
127
          {!!children?.length && (
×
128
            <button
129
              className="tsml-dropdown__expand"
130
              data-expanded={expanded.includes(key)}
131
              onClick={e => toggleExpanded(e, key)}
×
132
              aria-label={
133
                expanded.includes(key) ? strings.collapse : strings.expand
×
134
              }
135
            />
136
          )}
137
        </div>
138
        {!!children?.length && (
×
139
          <div
140
            className="tsml-dropdown__children"
141
            data-expanded={expanded.includes(key)}
142
          >
143
            {children.map(child =>
144
              renderDropdownItem(child, expanded.includes(key))
×
145
            )}
146
          </div>
147
        )}
148
      </Fragment>
149
    );
150
  };
151

152
  // separate section above the other items
153
  const special = {
×
154
    type: ['active', 'in-person', 'online'],
155
  };
156

157
  return (
×
158
    <div css={dropdownCss}>
159
      <button
160
        aria-expanded={open}
161
        css={dropdownButtonCss}
162
        disabled={filter === 'distance' && waitingForLocation}
×
163
        id={filter}
164
        onClick={e => {
165
          setDropdown(open ? undefined : filter);
×
166
          e.stopPropagation();
×
167
        }}
168
      >
169
        {values?.length && options?.length
×
170
          ? values.map(value => getIndexByKey(options, value)?.name).join(' + ')
×
171
          : defaultValue}
172
      </button>
173
      <div
174
        aria-labelledby={filter}
175
        className="tsml-dropdown"
176
        style={{ display: open ? 'block' : 'none' }}
×
177
      >
178
        <div data-active={!values.length} className="tsml-dropdown__item">
179
          <button
180
            className="tsml-dropdown__button"
181
            onClick={e => setFilter(e, filter, undefined)}
×
182
          >
183
            {defaultValue}
184
          </button>
185
        </div>
186
        {[
187
          options
188
            ?.filter(option =>
189
              special[filter as keyof typeof special]?.includes(option.key)
×
190
            )
191
            .sort(
192
              (a, b) =>
193
                special[filter as keyof typeof special]?.indexOf(a.key) -
×
194
                special[filter as keyof typeof special]?.indexOf(b.key)
195
            ),
196
          options?.filter(
197
            option =>
198
              !special[filter as keyof typeof special]?.includes(option.key)
×
199
          ),
200
        ]
201
          .filter(e => e.length)
×
202
          .map((group, index) => (
203
            <Fragment key={index}>
×
204
              <hr />
205
              {group.map(option => renderDropdownItem(option))}
×
206
            </Fragment>
207
          ))}
208
      </div>
209
    </div>
210
  );
211
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc