Skip to content

Commit 2347c9b

Browse files
committed
Merge branch 'frontend-responsive-account-selector'
2 parents db5fecb + 3d087ae commit 2347c9b

File tree

6 files changed

+316
-148
lines changed

6 files changed

+316
-148
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
- Add "Change device password" functionality (in Settings)
1616
- Add icons for CTA and action buttons in account page
1717
- Restructure "Manage device" tab in settings
18+
- Responsive account selector (Marketplace)
1819

1920
## v4.49.0
2021
- Bundle BitBox02 Nova firmware version v9.24.0

frontends/web/src/components/dropdown/dropdown.tsx

Lines changed: 75 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ import Select, {
77
OptionProps,
88
DropdownIndicatorProps,
99
MultiValueProps,
10+
GroupProps,
11+
GroupHeadingProps,
1012
Props as ReactSelectProps,
1113
ActionMeta,
1214
} from 'react-select';
@@ -19,11 +21,31 @@ export type TOption<T = any> = {
1921
value: T;
2022
};
2123

22-
type SelectProps<T = any, IsMulti extends boolean = false> = Omit<
24+
export type TGroupedOption<T, TExtra = object, TOptionExt = object> = {
25+
label: string;
26+
options: (TOption<T> & TOptionExt)[];
27+
} & TExtra;
28+
29+
export const isGroupedOptions = <T, >(
30+
options: TOption<T>[] | TGroupedOption<T>[] | undefined
31+
): options is TGroupedOption<T>[] => {
32+
if (!options || options.length === 0) {
33+
return false;
34+
}
35+
const firstOption = options[0];
36+
if (!firstOption) {
37+
return false;
38+
}
39+
return 'options' in firstOption && Array.isArray((firstOption as TGroupedOption<T>).options);
40+
};
41+
42+
type SelectProps<T, IsMulti extends boolean = false, TExtra = object, TOptionExt = object> = Omit<
2343
ReactSelectProps<TOption<T>>,
24-
'onChange'
44+
'onChange' | 'options'
2545
> & {
26-
renderOptions?: (selectedItem: TOption<T>, isSelectedValue: boolean) => ReactNode; // Function to render options and selected value for single dropdown
46+
options?: TOption<T>[] | TGroupedOption<T, TExtra, TOptionExt>[];
47+
renderOptions?: (selectedItem: TOption<T> & TOptionExt, isSelectedValue: boolean) => ReactNode;
48+
renderGroupHeader?: (group: TGroupedOption<T, TExtra, TOptionExt>) => ReactNode;
2749
onChange: (
2850
newValue: IsMulti extends true ? TOption<T>[] : TOption<T>,
2951
actionMeta: ActionMeta<TOption<T>>
@@ -87,37 +109,50 @@ const CustomMultiValue = ({ index, getValue }: MultiValueProps<TOption>) => {
87109
);
88110
};
89111

90-
export const Dropdown = <T, IsMulti extends boolean = false>({
112+
const Group = (props: GroupProps<TOption>) => (
113+
<div>
114+
<components.Group {...props} />
115+
</div>
116+
);
117+
118+
const createGroupHeading = <T, TExtra = object, TOptionExt = object>(
119+
renderGroupHeader?: (group: TGroupedOption<T, TExtra, TOptionExt>) => ReactNode
120+
) => (props: GroupHeadingProps<TOption<T>>) => {
121+
if (renderGroupHeader) {
122+
return (
123+
<div className={styles.groupHeader}>
124+
{renderGroupHeader(props.data as unknown as TGroupedOption<T, TExtra, TOptionExt>)}
125+
</div>
126+
);
127+
}
128+
return <components.GroupHeading {...props} />;
129+
};
130+
131+
export const Dropdown = <T, IsMulti extends boolean = false, TExtra = object, TOptionExt = object>({
91132
classNamePrefix = 'react-select',
92133
renderOptions,
134+
renderGroupHeader,
93135
className,
94136
onChange,
95137
title = '',
96138
mobileFullScreen = false,
97139
isOpen,
98140
onOpenChange,
99141
mobileTriggerComponent,
142+
options,
100143
...props
101-
}: SelectProps<T, IsMulti>) => {
144+
}: SelectProps<T, IsMulti, TExtra, TOptionExt>) => {
102145
const isMobile = useMediaQuery('(max-width: 768px)');
146+
const isGrouped = isGroupedOptions(options);
103147

104148
if (isMobile && mobileFullScreen) {
105-
const options: TOption<T>[] = props.options
106-
? (props.options as TOption<T>[]).filter(
107-
(option): option is TOption<T> =>
108-
option !== null &&
109-
typeof option === 'object' &&
110-
'value' in option &&
111-
'label' in option
112-
)
113-
: [];
114-
115149
return (
116150
<MobileFullscreenSelector
117151
title={title}
118152
options={options}
119153
renderOptions={renderOptions || (() => null)}
120-
value={props.value as any}
154+
renderGroupHeader={renderGroupHeader}
155+
value={props.value as IsMulti extends true ? TOption<T>[] : TOption<T>}
121156
onSelect={onChange}
122157
isMulti={props.isMulti}
123158
isOpen={isOpen}
@@ -127,6 +162,28 @@ export const Dropdown = <T, IsMulti extends boolean = false>({
127162
);
128163
}
129164

165+
const componentOverrides: ReactSelectProps<TOption<T>>['components'] = {
166+
DropdownIndicator,
167+
SingleValue: (singleValueProps: SingleValueProps<TOption<T>>) =>
168+
singleValueProps.isMulti ? undefined : (
169+
<SelectSingleValue
170+
{...singleValueProps}
171+
selectProps={{ ...singleValueProps.selectProps, renderOptions }}
172+
/>
173+
),
174+
Option: (optionProps: OptionProps<TOption<T>>) => (
175+
<Option {...optionProps} selectProps={{ ...optionProps.selectProps, renderOptions }} />
176+
),
177+
MultiValue: props.isMulti ? CustomMultiValue : undefined,
178+
IndicatorSeparator: () => null,
179+
MultiValueRemove: () => null,
180+
};
181+
182+
if (isGrouped) {
183+
componentOverrides.Group = Group;
184+
componentOverrides.GroupHeading = createGroupHeading<T, TExtra, TOptionExt>(renderGroupHeader);
185+
}
186+
130187
return (
131188
<Select
132189
className={`
@@ -136,14 +193,8 @@ export const Dropdown = <T, IsMulti extends boolean = false>({
136193
classNamePrefix={classNamePrefix}
137194
isClearable={false}
138195
hideSelectedOptions={false}
139-
components={{
140-
DropdownIndicator,
141-
SingleValue: (props) => props.isMulti ? undefined : <SelectSingleValue {...props} selectProps={{ ...props.selectProps, renderOptions }} />,
142-
Option: (props) => <Option {...props} selectProps={{ ...props.selectProps, renderOptions }} />,
143-
MultiValue: props.isMulti ? CustomMultiValue : undefined, // uses MultiValue only for multi-select
144-
IndicatorSeparator: () => null,
145-
MultiValueRemove: () => null,
146-
}}
196+
options={options}
197+
components={componentOverrides}
147198
onChange={(selected, actionMeta) => {
148199
const handleChange = props.isMulti
149200
? (onChange as (value: TOption<T>[], actionMeta: ActionMeta<TOption<T>>) => void)
@@ -154,4 +205,3 @@ export const Dropdown = <T, IsMulti extends boolean = false>({
154205
/>
155206
);
156207
};
157-

frontends/web/src/components/dropdown/mobile-fullscreen-selector.module.css

Lines changed: 23 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -59,13 +59,13 @@
5959
border: none;
6060
color: var(--color-default);
6161
cursor: pointer;
62-
padding-left: 0;
6362
margin-right: var(--space-quarter);
63+
padding-left: 0;
6464
}
6565

6666
.backButtonIcon {
67-
width: 24px;
6867
height: 24px;
68+
width: 24px;
6969
}
7070

7171
.fullscreenTitle {
@@ -92,11 +92,11 @@ input.searchInput {
9292
background-color: var(--background-secondary);
9393
border: 1px solid var(--background-secondary);
9494
border-radius: 4px;
95-
height: auto;
9695
color: var(--color-default);
9796
font-size: var(--size-default);
98-
width: 100%;
97+
height: auto;
9998
padding: var(--space-quarter) var(--space-half);
99+
width: 100%;
100100
}
101101

102102
input.searchInput:focus {
@@ -109,6 +109,18 @@ input.searchInput:focus {
109109
padding: 0;
110110
}
111111

112+
.group {
113+
padding-bottom: var(--space-quarter);
114+
}
115+
116+
.groupHeader {
117+
align-items: center;
118+
display: flex;
119+
font-weight: 500;
120+
padding: var(--space-half);
121+
padding-bottom: var(--space-quarter);
122+
}
123+
112124
.optionItem {
113125
align-items: center;
114126
background: none;
@@ -123,6 +135,10 @@ input.searchInput:focus {
123135
width: 100%;
124136
}
125137

138+
.optionItem:hover {
139+
background-color: var(--background-custom-select-hover);
140+
}
141+
126142
.selectedOption {
127143
background-color: var(--background-custom-select-selected);
128144
}
@@ -132,15 +148,15 @@ input.searchInput:focus {
132148
}
133149

134150
.noOptions {
135-
padding: var(--space-half);
136-
text-align: center;
137151
color: var(--color-secondary);
138152
font-size: var(--size-default);
153+
padding: var(--space-half);
154+
text-align: center;
139155
}
140156

141157
@media screen and (max-width: 560px) {
142158
.mobileSelectorTrigger {
143159
padding-top: var(--space-quarter);
144160
text-align: start;
145161
}
146-
}
162+
}

frontends/web/src/components/dropdown/mobile-fullscreen-selector.tsx

Lines changed: 75 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -3,15 +3,16 @@
33
import { ReactNode, useState, useEffect } from 'react';
44
import { useTranslation } from 'react-i18next';
55
import { ActionMeta } from 'react-select';
6-
import { TOption } from './dropdown';
6+
import { TOption, TGroupedOption, isGroupedOptions } from './dropdown';
77
import { ChevronLeftDark } from '@/components/icon';
88
import { UseBackButton } from '@/hooks/backbutton';
99
import styles from './mobile-fullscreen-selector.module.css';
1010

11-
type Props<T, IsMulti extends boolean = false> = {
11+
type Props<T, IsMulti extends boolean = false, TExtra = object, TOptionExt = object> = {
1212
title: string;
13-
options: TOption<T>[];
14-
renderOptions: (option: TOption<T>, isSelectedValue: boolean) => ReactNode;
13+
options?: TOption<T>[] | TGroupedOption<T, TExtra, TOptionExt>[];
14+
renderOptions: (option: TOption<T> & TOptionExt, isSelectedValue: boolean) => ReactNode;
15+
renderGroupHeader?: (group: TGroupedOption<T, TExtra, TOptionExt>) => ReactNode;
1516
value: IsMulti extends true ? TOption<T>[] : TOption<T>;
1617
onSelect: (newValue: IsMulti extends true ? TOption<T>[] : TOption<T>, actionMeta: ActionMeta<TOption<T>>) => void;
1718
isMulti?: boolean;
@@ -20,21 +21,23 @@ type Props<T, IsMulti extends boolean = false> = {
2021
triggerComponent?: ReactNode | ((props: { onClick: () => void }) => ReactNode);
2122
};
2223

23-
export const MobileFullscreenSelector = <T, IsMulti extends boolean = false>({
24+
export const MobileFullscreenSelector = <T, IsMulti extends boolean = false, TExtra = object, TOptionExt = object>({
2425
title,
2526
options,
2627
renderOptions,
28+
renderGroupHeader,
2729
value,
2830
onSelect,
2931
isMulti,
3032
isOpen: controlledIsOpen,
3133
onOpenChange,
3234
triggerComponent,
33-
}: Props<T, IsMulti>) => {
35+
}: Props<T, IsMulti, TExtra, TOptionExt>) => {
3436
const [localIsOpen, setLocalIsOpen] = useState(false);
3537
const [searchText, setSearchText] = useState('');
3638
const { t } = useTranslation();
3739
const isOpen = controlledIsOpen !== undefined ? controlledIsOpen : localIsOpen;
40+
const isGrouped = isGroupedOptions(options);
3841

3942
useEffect(() => {
4043
if (!isOpen) {
@@ -87,12 +90,71 @@ export const MobileFullscreenSelector = <T, IsMulti extends boolean = false>({
8790

8891
const displayValue = isMulti
8992
? (value as TOption<T>[]).map(v => v.label).reverse().join(', ')
90-
: (value as TOption<T>).label;
93+
: (value as TOption<T>)?.label || '';
9194

92-
const filteredOptions = options.filter(option =>
93-
option.label.toLowerCase().includes(searchText.toLowerCase())
95+
const getFilteredOptions = () => {
96+
if (!options) {
97+
return [];
98+
}
99+
const searchLower = searchText.toLowerCase();
100+
101+
if (isGrouped) {
102+
return (options as TGroupedOption<T, TExtra, TOptionExt>[])
103+
.map(group => ({
104+
...group,
105+
options: group.options.filter(opt =>
106+
opt.label.toLowerCase().includes(searchLower)
107+
),
108+
}))
109+
.filter(group => group.options.length > 0);
110+
}
111+
112+
return (options as (TOption<T> & TOptionExt)[]).filter(opt =>
113+
opt.label.toLowerCase().includes(searchLower)
114+
);
115+
};
116+
117+
const filteredOptions = getFilteredOptions();
118+
119+
const renderFlatOptions = (flatOptions: (TOption<T> & TOptionExt)[]) => (
120+
flatOptions.map((option) => (
121+
<button
122+
key={JSON.stringify(option.value)}
123+
className={`
124+
${styles.optionItem || ''}
125+
${isSelected(option) ? styles.selectedOption || '' : ''}`
126+
}
127+
onClick={(e) => handleSelect(option, e)}
128+
>
129+
<div className={styles.optionContent}>{renderOptions(option, false)}</div>
130+
</button>
131+
))
94132
);
95133

134+
const renderGroupedOptions = (groupedOptions: TGroupedOption<T, TExtra, TOptionExt>[]) => (
135+
groupedOptions.map((group) => (
136+
<div key={group.label} className={styles.group}>
137+
<div className={styles.groupHeader}>
138+
{renderGroupHeader ? renderGroupHeader(group) : <span>{group.label}</span>}
139+
</div>
140+
{group.options.map((option) => (
141+
<button
142+
key={JSON.stringify(option.value)}
143+
type="button"
144+
className={`${styles.optionItem || ''} ${isSelected(option) ? styles.selectedOption || '' : ''}`}
145+
onClick={(e) => handleSelect(option, e)}
146+
>
147+
<div className={styles.optionContent}>{renderOptions(option, false)}</div>
148+
</button>
149+
))}
150+
</div>
151+
))
152+
);
153+
154+
const hasResults = isGrouped
155+
? (filteredOptions as TGroupedOption<T, TExtra, TOptionExt>[]).length > 0
156+
: (filteredOptions as (TOption<T> & TOptionExt)[]).length > 0;
157+
96158
return (
97159
<div className={styles.dropdownContainer}>
98160
<UseBackButton handler={() => {
@@ -147,21 +209,10 @@ export const MobileFullscreenSelector = <T, IsMulti extends boolean = false>({
147209
</div>
148210

149211
<div className={styles.optionsList}>
150-
{filteredOptions.length > 0 ? (
151-
filteredOptions.map((option) => (
152-
<button
153-
key={JSON.stringify(option.value)}
154-
className={`
155-
${styles.optionItem || ''}
156-
${isSelected(option) ?
157-
styles.selectedOption || ''
158-
: ''}`
159-
}
160-
onClick={(e) => handleSelect(option, e)}
161-
>
162-
<div className={styles.optionContent}>{renderOptions(option, false)}</div>
163-
</button>
164-
))
212+
{hasResults ? (
213+
isGrouped
214+
? renderGroupedOptions(filteredOptions as TGroupedOption<T, TExtra, TOptionExt>[])
215+
: renderFlatOptions(filteredOptions as (TOption<T> & TOptionExt)[])
165216
) : (
166217
<div className={styles.noOptions}>{t('generic.noOptions')}</div>
167218
)}

0 commit comments

Comments
 (0)