Use Sift for username suggestions during signup (#11051)
This commit is contained in:
@@ -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
|
||||
|
||||
+14
-18
@@ -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
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
|
||||
Reference in New Issue
Block a user