Skip to content

Commit 7b8f78c

Browse files
fix(ui): focus bug w/ popvoer
1 parent 31ab9be commit 7b8f78c

File tree

1 file changed

+98
-53
lines changed
  • invokeai/frontend/web/src/common/components/Picker

1 file changed

+98
-53
lines changed

invokeai/frontend/web/src/common/components/Picker/Picker.tsx

Lines changed: 98 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ import { NO_DRAG_CLASS, NO_WHEEL_CLASS } from 'features/nodes/types/constants';
1717
import type { AnyStore, ReadableAtom, Task, WritableAtom } from 'nanostores';
1818
import { atom, computed } from 'nanostores';
1919
import type { StoreValues } from 'nanostores/computed';
20-
import type { ChangeEvent, PropsWithChildren, RefObject } from 'react';
20+
import type { ChangeEvent, MouseEventHandler, PropsWithChildren, RefObject } from 'react';
2121
import React, {
2222
createContext,
2323
useCallback,
@@ -29,7 +29,12 @@ import React, {
2929
useState,
3030
} from 'react';
3131
import { useTranslation } from 'react-i18next';
32-
import { PiArrowsInLineVerticalBold, PiArrowsOutLineVerticalBold, PiXBold } from 'react-icons/pi';
32+
import {
33+
PiArrowCounterClockwiseBold,
34+
PiArrowsInLineVerticalBold,
35+
PiArrowsOutLineVerticalBold,
36+
PiXBold,
37+
} from 'react-icons/pi';
3338
import { assert } from 'tsafe';
3439
import { useDebounce } from 'use-debounce';
3540

@@ -194,7 +199,7 @@ type PickerProps<T extends object> = {
194199
};
195200

196201
export type PickerContextState<T extends object> = {
197-
optionsOrGroups: T[] | Group<T>[];
202+
$optionsOrGroups: WritableAtom<T[] | Group<T>[]>;
198203
$groupStatusMap: WritableAtom<GroupStatusMap>;
199204
$compactView: WritableAtom<boolean>;
200205
$activeOptionId: WritableAtom<string | undefined>;
@@ -490,19 +495,16 @@ export const Picker = typedMemo(<T extends object>(props: PickerProps<T>) => {
490495
NextToSearchBar,
491496
searchable,
492497
} = props;
493-
const [$activeOptionId] = useState(() => {
494-
const initialValue = getFirstOptionId(optionsOrGroups, getOptionId);
495-
return atom<string | undefined>(initialValue);
496-
});
497498
const rootRef = useRef<HTMLDivElement>(null);
498499
const inputRef = useRef<HTMLInputElement>(null);
499500
const { $groupStatusMap, $areAllGroupsDisabled, toggleGroup } = useTogglableGroups(optionsOrGroups);
501+
const $activeOptionId = useAtom(getFirstOptionId(optionsOrGroups, getOptionId));
500502
const $compactView = useAtom(true);
501-
const $filteredOptions = useAtom<T[] | Group<T>[]>([]);
502-
const $flattenedFilteredOptions = useComputed([$filteredOptions], (filteredOptions) =>
503-
flattenOptions(filteredOptions)
504-
);
505-
const $totalOptionCount = useComputed([$filteredOptions], (optionsOrGroups) => {
503+
const $optionsOrGroups = useAtom(optionsOrGroups);
504+
useEffect(() => {
505+
$optionsOrGroups.set(optionsOrGroups);
506+
}, [optionsOrGroups, $optionsOrGroups]);
507+
const $totalOptionCount = useComputed([$optionsOrGroups], (optionsOrGroups) => {
506508
let count = 0;
507509
for (const optionOrGroup of optionsOrGroups) {
508510
if (isGroup(optionOrGroup)) {
@@ -513,6 +515,10 @@ export const Picker = typedMemo(<T extends object>(props: PickerProps<T>) => {
513515
}
514516
return count;
515517
});
518+
const $filteredOptions = useAtom<T[] | Group<T>[]>([]);
519+
const $flattenedFilteredOptions = useComputed([$filteredOptions], (filteredOptions) =>
520+
flattenOptions(filteredOptions)
521+
);
516522
const $hasOptions = useComputed([$totalOptionCount], (count) => count > 0);
517523
const $filteredOptionsCount = useComputed([$flattenedFilteredOptions], (options) => options.length);
518524
const $hasFilteredOptions = useComputed([$filteredOptionsCount], (count) => count > 0);
@@ -543,7 +549,7 @@ export const Picker = typedMemo(<T extends object>(props: PickerProps<T>) => {
543549
const ctx = useMemo(
544550
() =>
545551
({
546-
optionsOrGroups,
552+
$optionsOrGroups,
547553
$groupStatusMap,
548554
$compactView,
549555
$activeOptionId,
@@ -570,7 +576,7 @@ export const Picker = typedMemo(<T extends object>(props: PickerProps<T>) => {
570576
$selectedItemId,
571577
}) satisfies PickerContextState<T>,
572578
[
573-
optionsOrGroups,
579+
$optionsOrGroups,
574580
$groupStatusMap,
575581
$compactView,
576582
$activeOptionId,
@@ -616,7 +622,7 @@ Picker.displayName = 'Picker';
616622

617623
const PickerSyncer = typedMemo(<T extends object>() => {
618624
const {
619-
optionsOrGroups,
625+
$optionsOrGroups,
620626
$searchTerm,
621627
$activeOptionId,
622628
$groupStatusMap,
@@ -629,6 +635,7 @@ const PickerSyncer = typedMemo(<T extends object>() => {
629635
const searchTerm = useStore($searchTerm);
630636
const groupStatusMap = useStore($groupStatusMap);
631637
const areAllGroupsDisabled = useStore($areAllGroupsDisabled);
638+
const optionsOrGroups = useStore($optionsOrGroups);
632639
const [debouncedSearchTerm] = useDebounce(searchTerm, 300);
633640

634641
useEffect(() => {
@@ -714,13 +721,27 @@ const NoMatchesFallback = typedMemo(<T extends object>() => {
714721
NoMatchesFallback.displayName = 'NoMatchesFallback';
715722

716723
const PickerSearchBar = typedMemo(<T extends object>() => {
717-
const { optionsOrGroups, inputRef, $searchTerm, $totalOptionCount, searchPlaceholder, NextToSearchBar } =
718-
usePickerContext<T>();
724+
const { NextToSearchBar } = usePickerContext<T>();
725+
726+
return (
727+
<Flex flexDir="column" w="full" gap={2}>
728+
<Flex gap={2} alignItems="center">
729+
<SearchInput />
730+
{NextToSearchBar}
731+
<CompactViewToggleButton />
732+
</Flex>
733+
<GroupToggleButtons />
734+
</Flex>
735+
);
736+
});
737+
PickerSearchBar.displayName = 'PickerSearchBar';
738+
739+
const SearchInput = typedMemo(<T extends object>() => {
740+
const { inputRef, $totalOptionCount, $searchTerm, searchPlaceholder } = usePickerContext<T>();
719741
const { t } = useTranslation();
720742
const searchTerm = useStore($searchTerm);
721743
const totalOptionCount = useStore($totalOptionCount);
722744
const placeholder = searchPlaceholder ?? t('common.search');
723-
724745
const resetSearchTerm = useCallback(() => {
725746
$searchTerm.set('');
726747
inputRef.current?.focus();
@@ -732,53 +753,77 @@ const PickerSearchBar = typedMemo(<T extends object>() => {
732753
},
733754
[$searchTerm]
734755
);
735-
736-
const groups = useMemo(() => {
756+
return (
757+
<InputGroup>
758+
<Input ref={inputRef} value={searchTerm} onChange={onChangeSearchTerm} placeholder={placeholder} />
759+
{searchTerm && (
760+
<InputRightElement h="full" pe={2}>
761+
<IconButton
762+
onClick={resetSearchTerm}
763+
size="sm"
764+
variant="link"
765+
aria-label={t('common.clear')}
766+
tooltip={t('common.clear')}
767+
icon={<PiXBold />}
768+
isDisabled={totalOptionCount === 0}
769+
disabled={false}
770+
/>
771+
</InputRightElement>
772+
)}
773+
</InputGroup>
774+
);
775+
});
776+
SearchInput.displayName = 'SearchInput';
777+
const GroupToggleButtons = typedMemo(<T extends object>() => {
778+
const { $optionsOrGroups, $groupStatusMap, $areAllGroupsDisabled } = usePickerContext<T>();
779+
const { t } = useTranslation();
780+
const $groups = useComputed([$optionsOrGroups], (optionsOrGroups) => {
737781
const _groups: Group<T>[] = [];
738782
for (const optionOrGroup of optionsOrGroups) {
739783
if (isGroup(optionOrGroup)) {
740784
_groups.push(optionOrGroup);
741785
}
742786
}
743787
return _groups;
744-
}, [optionsOrGroups]);
788+
});
789+
const groups = useStore($groups);
790+
const areAllGroupsDisabled = useStore($areAllGroupsDisabled);
791+
792+
const onClick = useCallback<MouseEventHandler>(() => {
793+
const newMap: GroupStatusMap = {};
794+
for (const { id } of groups) {
795+
newMap[id] = false;
796+
}
797+
$groupStatusMap.set(newMap);
798+
}, [$groupStatusMap, groups]);
799+
800+
if (!groups.length) {
801+
return null;
802+
}
745803

746804
return (
747-
<Flex flexDir="column" w="full" gap={2}>
748-
<Flex gap={2} alignItems="center">
749-
<InputGroup>
750-
<Input
751-
ref={inputRef}
752-
value={searchTerm}
753-
onChange={onChangeSearchTerm}
754-
placeholder={placeholder}
755-
isDisabled={totalOptionCount === 0}
756-
/>
757-
{searchTerm && (
758-
<InputRightElement h="full" pe={2}>
759-
<IconButton
760-
onClick={resetSearchTerm}
761-
size="sm"
762-
variant="link"
763-
aria-label={t('common.clear')}
764-
tooltip={t('common.clear')}
765-
icon={<PiXBold />}
766-
/>
767-
</InputRightElement>
768-
)}
769-
</InputGroup>
770-
{NextToSearchBar}
771-
<CompactViewToggleButton />
772-
</Flex>
773-
<Flex gap={2} alignItems="center">
774-
{groups.map((group) => (
775-
<GroupToggleButton key={group.id} group={group} />
776-
))}
777-
</Flex>
805+
<Flex gap={2} alignItems="center">
806+
{groups.map((group) => (
807+
<GroupToggleButton key={group.id} group={group} />
808+
))}
809+
<Spacer />
810+
<IconButton
811+
icon={<PiArrowCounterClockwiseBold />}
812+
aria-label={t('common.reset')}
813+
tooltip={t('common.reset')}
814+
size="sm"
815+
variant="link"
816+
alignSelf="stretch"
817+
onClick={onClick}
818+
// When a focused element is disabled, it blurs. This closes the popover. Fake the disabled state to prevent this.
819+
// See: https://github.com/chakra-ui/chakra-ui/issues/7965
820+
opacity={areAllGroupsDisabled ? 0.5 : undefined}
821+
pointerEvents={areAllGroupsDisabled ? 'none' : undefined}
822+
/>
778823
</Flex>
779824
);
780825
});
781-
PickerSearchBar.displayName = 'PickerSearchBar';
826+
GroupToggleButtons.displayName = 'GroupToggleButtons';
782827

783828
const CompactViewToggleButton = typedMemo(<T extends object>() => {
784829
const { t } = useTranslation();

0 commit comments

Comments
 (0)