+
+
{title && (
{title}
@@ -93,6 +95,28 @@ function getSlideTransition(tabletModal?: boolean) {
};
}
+function getBottomPadding(noPadding?: boolean): string {
+ return noPadding
+ ? "env(safe-area-inset-bottom,0px)"
+ : "calc(env(safe-area-inset-bottom,0px) + 1.5rem)";
+}
+
+function getDialogHeight({
+ tall,
+ isCapacitor,
+}: {
+ readonly tall?: boolean | undefined;
+ readonly isCapacitor: boolean;
+}): string {
+ const viewportHeight = "min(100vh, 100svh)";
+
+ if (tall && !isCapacitor) {
+ return `calc(${viewportHeight} - 4rem)`;
+ }
+
+ return `calc(${viewportHeight} - 10rem)`;
+}
+
export default function MobileWrapperDialog({
title,
isOpen,
@@ -108,6 +132,8 @@ export default function MobileWrapperDialog({
allowOverflow,
maxWidthClass,
zIndexClassName = "tw-z-[1010]",
+ headerClassName,
+ mobileCloseButtonClassName,
dismissible = true,
}: {
readonly title?: string | undefined;
@@ -124,6 +150,8 @@ export default function MobileWrapperDialog({
readonly allowOverflow?: boolean | undefined;
readonly maxWidthClass?: string | undefined;
readonly zIndexClassName?: string | undefined;
+ readonly headerClassName?: string | undefined;
+ readonly mobileCloseButtonClassName?: string | undefined;
readonly dismissible?: boolean | undefined;
}) {
const { isCapacitor, isIos } = useCapacitor();
@@ -133,17 +161,11 @@ export default function MobileWrapperDialog({
}
};
- const bottomPadding = noPadding
- ? "env(safe-area-inset-bottom,0px)"
- : "calc(env(safe-area-inset-bottom,0px) + 1.5rem)";
-
- const viewportHeight = "min(100vh, 100svh)";
- const getHeight = () => {
- if (tall && !isCapacitor) {
- return `calc(${viewportHeight} - 4rem)`;
- }
- return `calc(${viewportHeight} - 10rem)`;
- };
+ const bottomPadding = getBottomPadding(noPadding);
+ const dialogHeight = getDialogHeight({
+ tall,
+ isCapacitor,
+ });
const panelClassNames = clsx(
"mobile-wrapper-dialog tw-pointer-events-auto tw-relative tw-w-screen",
@@ -216,7 +238,12 @@ export default function MobileWrapperDialog({
tabletModal && "md:tw-hidden"
)}
>
-
+
)}
@@ -230,8 +257,8 @@ export default function MobileWrapperDialog({
)}
style={{
...(fixedHeight
- ? { height: getHeight() }
- : { maxHeight: getHeight() }),
+ ? { height: dialogHeight }
+ : { maxHeight: dialogHeight }),
}}
>
{children}
diff --git a/components/react-query-wrapper/ReactQueryWrapper.tsx b/components/react-query-wrapper/ReactQueryWrapper.tsx
index cdf0a7d694..314dc763a2 100644
--- a/components/react-query-wrapper/ReactQueryWrapper.tsx
+++ b/components/react-query-wrapper/ReactQueryWrapper.tsx
@@ -95,7 +95,7 @@ export enum QueryKey {
WAVES_PUBLIC = "WAVES_PUBLIC",
WAVES_SEARCH = "WAVES_SEARCH",
WAVE = "WAVE",
- WAVE_CURATION_GROUPS = "WAVE_CURATION_GROUPS",
+ WAVE_CURATIONS = "WAVE_CURATIONS",
WAVE_LOGS = "WAVE_LOGS",
WAVE_VOTERS = "WAVE_VOTERS",
WAVE_FOLLOWERS = "WAVE_FOLLOWERS",
diff --git a/components/utils/button/PrimaryButton.tsx b/components/utils/button/PrimaryButton.tsx
index c6a1644a5e..75d00dbeb2 100644
--- a/components/utils/button/PrimaryButton.tsx
+++ b/components/utils/button/PrimaryButton.tsx
@@ -21,11 +21,15 @@ export default function PrimaryButton({
disabled={disabled || loading}
type="button"
title={title}
- className={`tw-whitespace-nowrap tw-text-sm tw-font-semibold tw-flex tw-items-center tw-rounded-lg tw-bg-iron-200 ${padding} tw-text-iron-950 focus:tw-outline-none focus:tw-ring-1 focus:tw-ring-inset tw-border-0 tw-ring-1 tw-ring-inset tw-ring-white hover:tw-bg-iron-300 hover:tw-ring-iron-300 focus:tw-z-10 tw-transition tw-duration-300 tw-ease-out tw-justify-center tw-gap-x-1.5 ${
+ className={`tw-flex tw-items-center tw-whitespace-nowrap tw-rounded-lg tw-bg-iron-200 tw-text-sm tw-font-semibold ${padding} tw-justify-center tw-gap-x-1.5 tw-border-0 tw-text-iron-950 tw-ring-1 tw-ring-inset tw-ring-white tw-transition tw-duration-300 tw-ease-out hover:tw-bg-iron-300 hover:tw-ring-iron-300 focus:tw-z-10 focus:tw-outline-none focus:tw-ring-1 focus:tw-ring-inset ${
disabled || loading ? "tw-cursor-not-allowed tw-opacity-50" : ""
}`}
>
- {loading &&
}
+ {loading && (
+
+
+
+ )}
{children}
);
diff --git a/components/utils/input/identity/IdentitySearch.tsx b/components/utils/input/identity/IdentitySearch.tsx
index 58a80df719..60970d0397 100644
--- a/components/utils/input/identity/IdentitySearch.tsx
+++ b/components/utils/input/identity/IdentitySearch.tsx
@@ -2,7 +2,7 @@
import { useQuery } from "@tanstack/react-query";
import type { KeyboardEvent } from "react";
-import { useEffect, useRef, useState, useId } from "react";
+import { useEffect, useMemo, useRef, useState, useId } from "react";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import {
faCircleExclamation,
@@ -71,9 +71,10 @@ export default function IdentitySearch({
[IdentitySearchSize.MD]: "tw-text-md",
};
- const ICON_CLASSES: Record
= {
- [IdentitySearchSize.SM]: "tw-top-3",
- [IdentitySearchSize.MD]: "tw-top-3.5",
+ const ICON_TOP_CLASS = "tw-top-3.5";
+ const SEARCH_ICON_SIZE_CLASSES: Record = {
+ [IdentitySearchSize.SM]: "tw-h-4 tw-w-4",
+ [IdentitySearchSize.MD]: "tw-h-5 tw-w-5",
};
const inputId = useId();
@@ -112,13 +113,45 @@ export default function IdentitySearch({
}),
enabled: !!debouncedValue && debouncedValue.length >= MIN_SEARCH_LENGTH,
});
+ const searchResults = useMemo(() => data ?? [], [data]);
const [isOpen, setIsOpen] = useState(false);
- const [highlightedIndex, setHighlightedIndex] = useState(null);
+ const [manualHighlightedIndex, setManualHighlightedIndex] = useState<
+ number | null
+ >(null);
const [highlightedOptionId, setHighlightedOptionId] = useState<
string | undefined
>(undefined);
- const [shouldSubmit, setShouldSubmit] = useState(false);
+ const selectedResultIndex = useMemo(() => {
+ if (searchResults.length === 0 || !identity) {
+ return null;
+ }
+
+ const matchingIndex = searchResults.findIndex((profile) => {
+ const value = getSelectableIdentity(profile);
+ return value?.toLowerCase() === identity.toLowerCase();
+ });
+
+ return matchingIndex >= 0 ? matchingIndex : null;
+ }, [identity, searchResults]);
+
+ const effectiveHighlightedIndex = useMemo(() => {
+ if (!isOpen || searchResults.length === 0) {
+ return null;
+ }
+
+ if (manualHighlightedIndex !== null) {
+ return Math.min(manualHighlightedIndex, searchResults.length - 1);
+ }
+
+ return selectedResultIndex;
+ }, [isOpen, manualHighlightedIndex, searchResults.length, selectedResultIndex]);
+
+ const closeDropdown = () => {
+ setIsOpen(false);
+ setManualHighlightedIndex(null);
+ };
+
const onValueChange = (
newValue: string | null,
options?: {
@@ -134,8 +167,7 @@ export default function IdentitySearch({
baseResolvedDisplayValue: resolvedDisplayValue,
preservedResolvedValues: [newValue, options?.displayValue ?? null],
});
- setIsOpen(false);
- setHighlightedIndex(null);
+ closeDropdown();
};
const onFocusChange = (newV: boolean) => {
@@ -144,8 +176,7 @@ export default function IdentitySearch({
setIsOpen(len >= MIN_SEARCH_LENGTH);
return;
}
- setIsOpen(false);
- setHighlightedIndex(null);
+ closeDropdown();
};
const onSearchCriteriaChange = (newV: string | null) => {
@@ -160,7 +191,7 @@ export default function IdentitySearch({
setIdentity(null);
onSelectionChange?.(null);
}
- setHighlightedIndex(null);
+ setManualHighlightedIndex(null);
};
const selectProfile = (profile: CommunityMemberMinimal) => {
@@ -177,8 +208,8 @@ export default function IdentitySearch({
};
const wrapperRef = useRef(null);
- useClickAway(wrapperRef, () => setIsOpen(false));
- useKeyPressEvent("Escape", () => setIsOpen(false));
+ useClickAway(wrapperRef, closeDropdown);
+ useKeyPressEvent("Escape", closeDropdown);
const inputRef = useRef(null);
const shouldAutoFocus = useRef(autoFocus);
@@ -188,89 +219,46 @@ export default function IdentitySearch({
}
}, []);
- useEffect(() => {
- if (!shouldSubmit) {
- return;
- }
-
- const formElement = inputRef.current?.form;
- if (formElement) {
- formElement.requestSubmit();
- }
- setShouldSubmit(false);
- }, [shouldSubmit]);
-
const handleArrowNavigation = (event: KeyboardEvent) => {
- if (!data?.length) {
+ const resultCount = searchResults.length;
+ if (resultCount === 0) {
return;
}
- const maxIndex = data.length - 1;
+ const maxIndex = resultCount - 1;
+ const currentHighlightIndex = effectiveHighlightedIndex;
if (event.key === "ArrowDown") {
event.preventDefault();
setIsOpen(true);
- setHighlightedIndex((current) => {
- if (current === null || current >= maxIndex) {
- return 0;
- }
- return current + 1;
- });
+ setManualHighlightedIndex(
+ currentHighlightIndex === null || currentHighlightIndex >= maxIndex
+ ? 0
+ : currentHighlightIndex + 1
+ );
return;
}
if (event.key === "ArrowUp") {
event.preventDefault();
setIsOpen(true);
- setHighlightedIndex((current) => {
- if (current === null || current <= 0) {
- return maxIndex;
- }
- return current - 1;
- });
+ setManualHighlightedIndex(
+ currentHighlightIndex === null || currentHighlightIndex <= 0
+ ? maxIndex
+ : currentHighlightIndex - 1
+ );
return;
}
- if (event.key === "Enter" && highlightedIndex !== null) {
+ if (event.key === "Enter" && effectiveHighlightedIndex !== null) {
event.preventDefault();
- const profile = data[highlightedIndex];
- if (profile) {
- if (selectProfile(profile)) {
- setShouldSubmit(true);
- }
+ const profile = searchResults[effectiveHighlightedIndex];
+ if (profile !== undefined && selectProfile(profile)) {
+ inputRef.current?.form?.requestSubmit();
}
}
};
- useEffect(() => {
- if (!isOpen) {
- setHighlightedIndex(null);
- }
- }, [isOpen]);
-
- useEffect(() => {
- if (!data?.length) {
- setHighlightedIndex(null);
- return;
- }
-
- if (identity) {
- const matchingIndex = data.findIndex((profile) => {
- const value = getSelectableIdentity(profile);
- return value?.toLowerCase() === identity.toLowerCase();
- });
-
- if (matchingIndex >= 0) {
- setHighlightedIndex(matchingIndex);
- return;
- }
- }
-
- setHighlightedIndex((current) =>
- current === null ? null : Math.min(current, data.length - 1)
- );
- }, [data, identity]);
-
const hasIdentity = identity !== null && identity.length > 0;
return (
@@ -301,7 +289,7 @@ export default function IdentitySearch({
error
? "tw-caret-error tw-ring-error focus:tw-border-error focus:tw-ring-error"
: "tw-caret-primary-400 tw-ring-iron-700 hover:tw-ring-iron-650 focus:tw-border-blue-500 focus:tw-ring-primary-400"
- } tw-peer tw-form-input tw-block tw-w-full tw-appearance-none tw-rounded-lg tw-border-0 tw-border-iron-700 tw-bg-iron-900 tw-pl-10 tw-pr-4 tw-text-base tw-font-medium tw-shadow-sm tw-ring-1 tw-ring-inset tw-transition tw-duration-300 tw-ease-out placeholder:tw-text-iron-500 focus:tw-outline-none focus:tw-ring-1 focus:tw-ring-inset ${
+ } tw-peer tw-form-input tw-block tw-w-full tw-appearance-none tw-rounded-lg tw-border-0 tw-border-iron-700 tw-bg-iron-900 tw-pl-9 tw-pr-4 tw-text-base tw-font-medium tw-shadow-sm tw-ring-1 tw-ring-inset tw-transition tw-duration-300 tw-ease-out placeholder:tw-text-iron-500 focus:tw-outline-none focus:tw-ring-1 focus:tw-ring-inset ${
searchCriteria
? "tw-text-primary-400 focus:tw-text-white"
: "tw-text-white"
@@ -309,7 +297,7 @@ export default function IdentitySearch({
placeholder=" "
/>
{clearable && hasIdentity && (
@@ -317,9 +305,12 @@ export default function IdentitySearch({
type="button"
aria-label="Clear identity"
onClick={() => onValueChange(null)}
- className={`${ICON_CLASSES[size]} tw-absolute tw-right-3 tw-flex tw-h-5 tw-w-5 tw-cursor-pointer tw-items-center tw-justify-center tw-border-0 tw-bg-transparent tw-p-0 tw-text-iron-400 tw-transition tw-duration-300 tw-ease-out hover:tw-text-error focus:tw-outline-none focus:tw-ring-0`}
+ className={`${ICON_TOP_CLASS} tw-absolute tw-right-3 tw-flex tw-h-5 tw-w-5 tw-cursor-pointer tw-items-center tw-justify-center tw-border-0 tw-bg-transparent tw-p-0 tw-text-iron-400 tw-transition tw-duration-300 tw-ease-out hover:tw-text-error focus:tw-outline-none focus:tw-ring-0`}
>
-
+
)}
@@ -336,8 +327,8 @@ export default function IdentitySearch({
open={isOpen}
selected={identity}
searchCriteria={searchCriteria}
- profiles={data ?? []}
- highlightedIndex={highlightedIndex}
+ profiles={searchResults}
+ highlightedIndex={effectiveHighlightedIndex}
listboxId={listboxId}
listClassName={dropdownListClassName}
onHighlightedOptionIdChange={setHighlightedOptionId}
diff --git a/components/utils/select-group/SelectGroupModal.tsx b/components/utils/select-group/SelectGroupModal.tsx
index 7db4904cbf..d4617dc5b5 100644
--- a/components/utils/select-group/SelectGroupModal.tsx
+++ b/components/utils/select-group/SelectGroupModal.tsx
@@ -36,11 +36,11 @@ export default function SelectGroupModal({
useKeyPressEvent("Escape", onClose);
return createPortal(
-
+
-
-
+
+
void;
+ readonly onClear?: (() => void) | undefined;
+}
+
+export default function SelectGroupModalCard({
+ group,
+ isSelected,
+ onSelect,
+ onClear,
+}: SelectGroupModalCardProps) {
+ const avatarAccentStart =
+ group.created_by.banner1_color ??
+ getRandomColorWithSeed(group.created_by.handle ?? "");
+ const avatarAccentEnd =
+ group.created_by.banner2_color ??
+ getRandomColorWithSeed(group.created_by.handle ?? "");
+ const creatorIdentity =
+ group.created_by.handle ?? group.created_by.primary_address;
+ const timeAgo = getTimeAgo(new Date(group.created_at).getTime());
+ const avatarFallbackLabel = creatorIdentity.charAt(0).toUpperCase();
+ const selectionIndicator =
+ isSelected && onClear ? (
+
+ ) : (
+
+ {isSelected && (
+
+ )}
+
+ );
+
+ return (
+
+
onSelect(group)}
+ onKeyDown={(event) => {
+ if (event.key === "Enter" || event.key === " ") {
+ event.preventDefault();
+ onSelect(group);
+ }
+ }}
+ className={`tw-group tw-relative tw-flex tw-cursor-pointer tw-items-center tw-justify-between tw-gap-3 tw-rounded-xl tw-border tw-border-solid tw-p-3 tw-no-underline tw-outline-none tw-transition-all tw-duration-200 ${
+ isSelected
+ ? "tw-border-white/20 tw-bg-iron-900 tw-shadow-[0_0_0_1px_rgba(255,255,255,0.04),0_10px_24px_rgba(0,0,0,0.28),0_0_18px_rgba(255,255,255,0.06)]"
+ : "tw-border-white/[0.06] tw-bg-iron-950 hover:tw-border-white/10 hover:tw-bg-iron-900/60"
+ } focus-visible:tw-border-white/30 focus-visible:tw-bg-iron-900 focus-visible:tw-outline-none focus-visible:tw-ring-1 focus-visible:tw-ring-white/30`}
+ >
+
+
+ {group.created_by.pfp ? (
+
+ ) : (
+ <>
+
+
{avatarFallbackLabel}
+ >
+ )}
+
+
+
+
+ {group.name}
+
+
+ {group.created_by.handle ? (
+
{
+ event.stopPropagation();
+ }}
+ className={`tw-inline-block tw-max-w-[8rem] tw-truncate tw-font-medium tw-no-underline tw-transition-colors ${
+ isSelected
+ ? "tw-text-iron-300"
+ : "tw-text-iron-400 group-hover:tw-text-iron-300"
+ }`}
+ >
+ {group.created_by.handle}
+
+ ) : (
+
+ {creatorIdentity}
+
+ )}
+ {timeAgo && (
+ <>
+
+ ·
+
+
Created {timeAgo}
+ >
+ )}
+
+
+
+
+
+ {selectionIndicator}
+
+
+
+ );
+}
diff --git a/components/utils/select-group/SelectGroupModalItems.tsx b/components/utils/select-group/SelectGroupModalItems.tsx
index 06a18564b3..fd2d9d0757 100644
--- a/components/utils/select-group/SelectGroupModalItems.tsx
+++ b/components/utils/select-group/SelectGroupModalItems.tsx
@@ -2,35 +2,52 @@ import type { ApiGroupFull } from "@/generated/models/ApiGroupFull";
import CircleLoader, {
CircleLoaderSize,
} from "@/components/distribution-plan-tool/common/CircleLoader";
-import GroupItem from "@/components/groups/select/item/GroupItem";
+import SelectGroupModalCard from "./SelectGroupModalCard";
export default function SelectGroupModalItems({
groups,
+ selectedGroupId,
loading,
onGroupSelect,
+ onGroupClear,
+ emptyStateMessage = "No groups found.",
}: {
readonly groups: ApiGroupFull[];
+ readonly selectedGroupId?: string | null | undefined;
readonly loading: boolean;
readonly onGroupSelect: (group: ApiGroupFull) => void;
+ readonly onGroupClear?: (() => void) | undefined;
+ readonly emptyStateMessage?: string | undefined;
}) {
if (loading) {
return (
-
-
+
+
+
+ );
+ }
+
+ if (groups.length === 0) {
+ return (
+
+
+ {emptyStateMessage}
+
);
}
return (
-
+
{groups.map((group) => (
- onGroupSelect(group)}
+ isSelected={selectedGroupId === group.id}
+ onSelect={onGroupSelect}
+ onClear={onGroupClear}
/>
))}
-
+
);
}
diff --git a/components/utils/select-group/SelectGroupModalSearch.tsx b/components/utils/select-group/SelectGroupModalSearch.tsx
index 3a906c7efe..74f82253d8 100644
--- a/components/utils/select-group/SelectGroupModalSearch.tsx
+++ b/components/utils/select-group/SelectGroupModalSearch.tsx
@@ -1,22 +1,31 @@
-import IdentitySearch, { IdentitySearchSize } from "../input/identity/IdentitySearch";
+import IdentitySearch, {
+ IdentitySearchSize,
+} from "../input/identity/IdentitySearch";
import SelectGroupModalSearchName from "./SelectGroupModalSearchName";
-
export default function SelectGroupModalSearch({
groupName,
groupUser,
onUserSelect,
onFilterNameSearch,
+ showIdentitySearch = true,
}: {
readonly groupName: string | null;
readonly groupUser: string | null;
readonly onUserSelect: (value: string | null) => void;
readonly onFilterNameSearch: (value: string | null) => void;
+ readonly showIdentitySearch?: boolean | undefined;
}) {
return (
-
+
-
+ {showIdentitySearch && (
+
+ )}
void;
+ readonly autoFocus?: boolean | undefined;
}) {
return (