Use Sift for username suggestions during signup (#11051)

This commit is contained in:
DS Boyce
2026-07-07 05:37:49 -07:00
committed by GitHub
parent f9b1200404
commit bbdfe05ada
5 changed files with 152 additions and 26 deletions
-5
View File
@@ -1430,11 +1430,6 @@
"count": 1
}
},
"src/screens/Signup/StepHandle/index.tsx": {
"@typescript-eslint/no-misused-promises": {
"count": 1
}
},
"src/screens/Signup/StepInfo/Policies.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 1
@@ -1,25 +1,23 @@
import Animated, {Easing, FadeInDown, FadeOut} from 'react-native-reanimated'
import {type ComAtprotoTempCheckHandleAvailability} from '@atproto/api'
import {msg} from '@lingui/core/macro'
import {useLingui} from '@lingui/react'
import {Trans} from '@lingui/react/macro'
import {Trans, useLingui} from '@lingui/react/macro'
import {atoms as a, native, useTheme} from '#/alf'
import {borderRadius} from '#/alf/tokens'
import {Button} from '#/components/Button'
import {Text} from '#/components/Typography'
import {type HandleSuggestionsProps} from './shared'
/**
* Native: suggestions render inline beneath the availability error, animating
* in with the surrounding requirement stack. See index.tsx for the web variant,
* which floats them in a Sift dropdown anchored to the input.
*/
export function HandleSuggestions({
suggestions,
onSelect,
}: {
suggestions: ComAtprotoTempCheckHandleAvailability.Suggestion[]
onSelect: (
suggestions: ComAtprotoTempCheckHandleAvailability.Suggestion,
) => void
}) {
}: HandleSuggestionsProps) {
const t = useTheme()
const {_} = useLingui()
const {t: l} = useLingui()
return (
<Animated.View
@@ -39,13 +37,11 @@ export function HandleSuggestions({
]}>
{suggestions.map((suggestion, index) => (
<Button
label={_(
msg({
message: `Select ${suggestion.handle}`,
comment: `Accessibility label for a username suggestion in the account creation flow`,
}),
)}
key={index}
label={l({
message: `Select ${suggestion.handle}`,
comment: `Accessibility label for a username suggestion in the account creation flow`,
})}
key={suggestion.handle}
onPress={() => onSelect(suggestion)}
hoverStyle={[t.atoms.bg_contrast_25]}
style={[
@@ -0,0 +1,94 @@
import {type ComAtprotoTempCheckHandleAvailability} from '@atproto/api'
import {Sift, SiftItem} from '@bsky.app/sift'
import {Trans, useLingui} from '@lingui/react/macro'
import {atoms as a, useTheme} from '#/alf'
import {Portal} from '#/components/Portal'
import {Text} from '#/components/Typography'
import {type HandleSuggestionsProps} from './shared'
type Suggestion = ComAtprotoTempCheckHandleAvailability.Suggestion & {
key: string
}
/**
* Web: suggestions float in a Sift dropdown anchored to the handle input (see
* StepHandle/index.tsx for the anchor wiring). Rendering through a Portal as an
* absolutely-positioned popover means the requirements and Back/Next buttons
* below the input don't shift as suggestions appear and disappear. See
* index.native.tsx for the inline native variant.
*/
export function HandleSuggestions({
suggestions,
onSelect,
sift,
}: HandleSuggestionsProps) {
const t = useTheme()
/*
* Sift keys its list rows by `item.key`; the suggestion handle is unique
* within a result set, so use it as the key.
*/
const data: Suggestion[] = suggestions.map(s => ({...s, key: s.handle}))
return (
<Portal>
<Sift<Suggestion>
sift={sift}
data={data}
onSelect={onSelect}
outerStyle={[a.rounded_sm, a.w_full, t.atoms.shadow_sm]}
innerStyle={[
a.overflow_hidden,
a.rounded_sm,
a.border,
t.atoms.border_contrast_low,
t.atoms.bg,
a.w_full,
]}
render={props => <SuggestionRow {...props} />}
/>
</Portal>
)
}
function SuggestionRow({
active,
isLast,
props,
item,
}: {
active: boolean
isLast: boolean
props: {role: string; 'aria-selected': boolean; onPress: () => void}
item: Suggestion
}) {
const t = useTheme()
const {t: l} = useLingui()
return (
<SiftItem
{...props}
aria-label={l({
message: `Select ${item.handle}`,
comment: `Accessibility label for a username suggestion in the account creation flow`,
})}
style={s => [
a.w_full,
a.flex_row,
a.align_center,
a.justify_between,
a.p_md,
!isLast && a.border_b,
t.atoms.border_contrast_low,
(active || s.hovered || s.pressed) && t.atoms.bg_contrast_25,
]}>
<Text style={[a.text_md]}>{item.handle}</Text>
<Text style={[a.text_sm, {color: t.palette.positive_700}]}>
<Trans comment="Shown next to an available username suggestion in the account creation flow">
Available
</Trans>
</Text>
</SiftItem>
)
}
@@ -0,0 +1,15 @@
import {type ComAtprotoTempCheckHandleAvailability} from '@atproto/api'
import {type UseSiftReturn} from '@bsky.app/sift'
export type HandleSuggestionsProps = {
suggestions: ComAtprotoTempCheckHandleAvailability.Suggestion[]
onSelect: (
suggestion: ComAtprotoTempCheckHandleAvailability.Suggestion,
) => void
/**
* Web only: the Sift instance shared with the handle input. It carries the
* anchor/positioning refs and keyboard bindings for the floating dropdown.
* Ignored on native, which renders the suggestions inline.
*/
sift: UseSiftReturn
}
+29 -3
View File
@@ -6,6 +6,7 @@ import Animated, {
LayoutAnimationConfig,
LinearTransition,
} from 'react-native-reanimated'
import {useSift} from '@bsky.app/sift'
import {msg} from '@lingui/core/macro'
import {useLingui} from '@lingui/react'
import {Plural, Trans} from '@lingui/react/macro'
@@ -28,6 +29,7 @@ import {At_Stroke2_Corner0_Rounded as AtIcon} from '#/components/icons/At'
import {Check_Stroke2_Corner0_Rounded as CheckIcon} from '#/components/icons/Check'
import {Text} from '#/components/Typography'
import {useAnalytics} from '#/analytics'
import {IS_WEB} from '#/env'
import {BackNextButtons} from '../BackNextButtons'
import {HandleSuggestions} from './HandleSuggestions'
@@ -39,6 +41,13 @@ export function StepHandle() {
const [draftValue, setDraftValue] = useState(state.handle)
const isNextLoading = useThrottledValue(state.isLoading, 500)
/*
* Web anchors a floating Sift dropdown of suggestions to the input; native
* renders them inline and ignores this. `offset` leaves a small gap below the
* anchor, matching the inline `mt_xs` spacing.
*/
const sift = useSift({offset: a.p_xs.padding, placement: 'bottom-start'})
const validCheck = validateServiceHandle(draftValue, state.userDomain)
const {
@@ -137,13 +146,28 @@ export function StepHandle() {
!validCheck.hyphenStartOrEnd ||
!validCheck.totalLength
/*
* Web-only Sift wiring. The anchor is the input section, whose bottom edge in
* the taken-and-valid state sits right below the availability error, so the
* floating dropdown lands beneath it. The input ref feeds Sift's keyboard
* handling and the combobox a11y props describe the typeahead relationship.
* Native ignores all of this and renders suggestions inline.
*/
const {ref: inputAnchorRef, ...comboboxProps} = sift.targetProps
return (
<>
<View style={[a.gap_sm, a.pt_lg, a.z_10]}>
<View
collapsable={false}
ref={IS_WEB ? sift.refs.setAnchor : undefined}
onLayout={IS_WEB ? () => void sift.updatePosition() : undefined}
style={[a.gap_sm, a.pt_lg, a.z_10]}>
<View>
<TextField.Root isInvalid={textFieldInvalid}>
<TextField.Icon icon={AtIcon} />
<TextField.Input
{...(IS_WEB ? comboboxProps : {})}
inputRef={IS_WEB ? inputAnchorRef : undefined}
testID="handleInput"
onChangeText={val => {
if (state.error) {
@@ -173,7 +197,8 @@ export function StepHandle() {
</TextField.Root>
</View>
<LayoutAnimationConfig skipEntering skipExiting>
<View style={[a.gap_xs]}>
{/* Reserve space for one line of text to avoid layout shift. */}
<View style={[a.gap_xs, {minHeight: 21}]}>
{state.error && (
<Requirement>
<RequirementText>{state.error}</RequirementText>
@@ -192,6 +217,7 @@ export function StepHandle() {
{isHandleAvailable.suggestions &&
isHandleAvailable.suggestions.length > 0 && (
<HandleSuggestions
sift={sift}
suggestions={isHandleAvailable.suggestions}
onSelect={suggestion => {
setDraftValue(
@@ -246,7 +272,7 @@ export function StepHandle() {
isLoading={isNextLoading}
isNextDisabled={isNextDisabled}
onBackPress={onBackPress}
onNextPress={onNextPress}
onNextPress={() => void onNextPress()}
/>
</Animated.View>
</>