Use Sift for username suggestions during signup (#11051)
This commit is contained in:
@@ -1430,11 +1430,6 @@
|
|||||||
"count": 1
|
"count": 1
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"src/screens/Signup/StepHandle/index.tsx": {
|
|
||||||
"@typescript-eslint/no-misused-promises": {
|
|
||||||
"count": 1
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"src/screens/Signup/StepInfo/Policies.tsx": {
|
"src/screens/Signup/StepInfo/Policies.tsx": {
|
||||||
"@typescript-eslint/no-explicit-any": {
|
"@typescript-eslint/no-explicit-any": {
|
||||||
"count": 1
|
"count": 1
|
||||||
|
|||||||
+14
-18
@@ -1,25 +1,23 @@
|
|||||||
import Animated, {Easing, FadeInDown, FadeOut} from 'react-native-reanimated'
|
import Animated, {Easing, FadeInDown, FadeOut} from 'react-native-reanimated'
|
||||||
import {type ComAtprotoTempCheckHandleAvailability} from '@atproto/api'
|
import {Trans, useLingui} from '@lingui/react/macro'
|
||||||
import {msg} from '@lingui/core/macro'
|
|
||||||
import {useLingui} from '@lingui/react'
|
|
||||||
import {Trans} from '@lingui/react/macro'
|
|
||||||
|
|
||||||
import {atoms as a, native, useTheme} from '#/alf'
|
import {atoms as a, native, useTheme} from '#/alf'
|
||||||
import {borderRadius} from '#/alf/tokens'
|
import {borderRadius} from '#/alf/tokens'
|
||||||
import {Button} from '#/components/Button'
|
import {Button} from '#/components/Button'
|
||||||
import {Text} from '#/components/Typography'
|
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({
|
export function HandleSuggestions({
|
||||||
suggestions,
|
suggestions,
|
||||||
onSelect,
|
onSelect,
|
||||||
}: {
|
}: HandleSuggestionsProps) {
|
||||||
suggestions: ComAtprotoTempCheckHandleAvailability.Suggestion[]
|
|
||||||
onSelect: (
|
|
||||||
suggestions: ComAtprotoTempCheckHandleAvailability.Suggestion,
|
|
||||||
) => void
|
|
||||||
}) {
|
|
||||||
const t = useTheme()
|
const t = useTheme()
|
||||||
const {_} = useLingui()
|
const {t: l} = useLingui()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Animated.View
|
<Animated.View
|
||||||
@@ -39,13 +37,11 @@ export function HandleSuggestions({
|
|||||||
]}>
|
]}>
|
||||||
{suggestions.map((suggestion, index) => (
|
{suggestions.map((suggestion, index) => (
|
||||||
<Button
|
<Button
|
||||||
label={_(
|
label={l({
|
||||||
msg({
|
message: `Select ${suggestion.handle}`,
|
||||||
message: `Select ${suggestion.handle}`,
|
comment: `Accessibility label for a username suggestion in the account creation flow`,
|
||||||
comment: `Accessibility label for a username suggestion in the account creation flow`,
|
})}
|
||||||
}),
|
key={suggestion.handle}
|
||||||
)}
|
|
||||||
key={index}
|
|
||||||
onPress={() => onSelect(suggestion)}
|
onPress={() => onSelect(suggestion)}
|
||||||
hoverStyle={[t.atoms.bg_contrast_25]}
|
hoverStyle={[t.atoms.bg_contrast_25]}
|
||||||
style={[
|
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,
|
LayoutAnimationConfig,
|
||||||
LinearTransition,
|
LinearTransition,
|
||||||
} from 'react-native-reanimated'
|
} from 'react-native-reanimated'
|
||||||
|
import {useSift} from '@bsky.app/sift'
|
||||||
import {msg} from '@lingui/core/macro'
|
import {msg} from '@lingui/core/macro'
|
||||||
import {useLingui} from '@lingui/react'
|
import {useLingui} from '@lingui/react'
|
||||||
import {Plural, Trans} from '@lingui/react/macro'
|
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 {Check_Stroke2_Corner0_Rounded as CheckIcon} from '#/components/icons/Check'
|
||||||
import {Text} from '#/components/Typography'
|
import {Text} from '#/components/Typography'
|
||||||
import {useAnalytics} from '#/analytics'
|
import {useAnalytics} from '#/analytics'
|
||||||
|
import {IS_WEB} from '#/env'
|
||||||
import {BackNextButtons} from '../BackNextButtons'
|
import {BackNextButtons} from '../BackNextButtons'
|
||||||
import {HandleSuggestions} from './HandleSuggestions'
|
import {HandleSuggestions} from './HandleSuggestions'
|
||||||
|
|
||||||
@@ -39,6 +41,13 @@ export function StepHandle() {
|
|||||||
const [draftValue, setDraftValue] = useState(state.handle)
|
const [draftValue, setDraftValue] = useState(state.handle)
|
||||||
const isNextLoading = useThrottledValue(state.isLoading, 500)
|
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 validCheck = validateServiceHandle(draftValue, state.userDomain)
|
||||||
|
|
||||||
const {
|
const {
|
||||||
@@ -137,13 +146,28 @@ export function StepHandle() {
|
|||||||
!validCheck.hyphenStartOrEnd ||
|
!validCheck.hyphenStartOrEnd ||
|
||||||
!validCheck.totalLength
|
!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 (
|
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>
|
<View>
|
||||||
<TextField.Root isInvalid={textFieldInvalid}>
|
<TextField.Root isInvalid={textFieldInvalid}>
|
||||||
<TextField.Icon icon={AtIcon} />
|
<TextField.Icon icon={AtIcon} />
|
||||||
<TextField.Input
|
<TextField.Input
|
||||||
|
{...(IS_WEB ? comboboxProps : {})}
|
||||||
|
inputRef={IS_WEB ? inputAnchorRef : undefined}
|
||||||
testID="handleInput"
|
testID="handleInput"
|
||||||
onChangeText={val => {
|
onChangeText={val => {
|
||||||
if (state.error) {
|
if (state.error) {
|
||||||
@@ -173,7 +197,8 @@ export function StepHandle() {
|
|||||||
</TextField.Root>
|
</TextField.Root>
|
||||||
</View>
|
</View>
|
||||||
<LayoutAnimationConfig skipEntering skipExiting>
|
<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 && (
|
{state.error && (
|
||||||
<Requirement>
|
<Requirement>
|
||||||
<RequirementText>{state.error}</RequirementText>
|
<RequirementText>{state.error}</RequirementText>
|
||||||
@@ -192,6 +217,7 @@ export function StepHandle() {
|
|||||||
{isHandleAvailable.suggestions &&
|
{isHandleAvailable.suggestions &&
|
||||||
isHandleAvailable.suggestions.length > 0 && (
|
isHandleAvailable.suggestions.length > 0 && (
|
||||||
<HandleSuggestions
|
<HandleSuggestions
|
||||||
|
sift={sift}
|
||||||
suggestions={isHandleAvailable.suggestions}
|
suggestions={isHandleAvailable.suggestions}
|
||||||
onSelect={suggestion => {
|
onSelect={suggestion => {
|
||||||
setDraftValue(
|
setDraftValue(
|
||||||
@@ -246,7 +272,7 @@ export function StepHandle() {
|
|||||||
isLoading={isNextLoading}
|
isLoading={isNextLoading}
|
||||||
isNextDisabled={isNextDisabled}
|
isNextDisabled={isNextDisabled}
|
||||||
onBackPress={onBackPress}
|
onBackPress={onBackPress}
|
||||||
onNextPress={onNextPress}
|
onNextPress={() => void onNextPress()}
|
||||||
/>
|
/>
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
</>
|
</>
|
||||||
|
|||||||
Reference in New Issue
Block a user