From 802d07ce53e13839d700b9123248c59c66e57dce Mon Sep 17 00:00:00 2001 From: PraveenKumar yeruva Date: Tue, 18 Jul 2023 17:12:43 +0530 Subject: [PATCH] V2 Peoplepicker implementation (#445) * peoplepicker impl * Peoplepicker Impl * trailing content for ppl picker * activity changes * added peoplepicker remember saveable to save state * using textfield for pplPicker * removing textfield dependency * adding api doc * review changes and adding UI testcases * personachip accessibility * refactoring tokens file * removing unwanted files * ui test updated --------- Co-authored-by: PraveenKumar --- .../com/microsoft/fluentuidemo/UiTestSuite.kt | 1 + .../demos/V2PeoplePickerUITest.kt | 159 ++++++++ FluentUI.Demo/src/main/AndroidManifest.xml | 1 + .../java/com/microsoft/fluentuidemo/Demos.kt | 2 + .../demos/V2PeoplePickerActivity.kt | 258 +++++++++++++ .../fluentui/theme/token/ControlTokens.kt | 2 + .../token/controlTokens/PeoplePickerTokens.kt | 138 +++++++ .../token/controlTokens/PersonaChipTokens.kt | 8 + fluentui_peoplepicker/build.gradle | 20 + .../tokenized/peoplepicker/PeoplePicker.kt | 362 ++++++++++++++++++ .../fluentui/tokenized/persona/PersonaChip.kt | 17 +- 11 files changed, 966 insertions(+), 2 deletions(-) create mode 100644 FluentUI.Demo/src/androidTest/java/com/microsoft/fluentuidemo/demos/V2PeoplePickerUITest.kt create mode 100644 FluentUI.Demo/src/main/java/com/microsoft/fluentuidemo/demos/V2PeoplePickerActivity.kt create mode 100644 fluentui_core/src/main/java/com/microsoft/fluentui/theme/token/controlTokens/PeoplePickerTokens.kt create mode 100644 fluentui_peoplepicker/src/main/java/com/microsoft/fluentui/tokenized/peoplepicker/PeoplePicker.kt diff --git a/FluentUI.Demo/src/androidTest/java/com/microsoft/fluentuidemo/UiTestSuite.kt b/FluentUI.Demo/src/androidTest/java/com/microsoft/fluentuidemo/UiTestSuite.kt index d5f6b113..3c970329 100644 --- a/FluentUI.Demo/src/androidTest/java/com/microsoft/fluentuidemo/UiTestSuite.kt +++ b/FluentUI.Demo/src/androidTest/java/com/microsoft/fluentuidemo/UiTestSuite.kt @@ -18,6 +18,7 @@ import org.junit.runners.Suite V2DrawerActivityUITest::class, V2LabelUITest::class, V2ListItemActivityUITest::class, + V2PeoplePickerUITest::class, V2PersonaUITest::class, V2PersonaChipActivityUITest::class, V2PersonaListActivityUITest::class, diff --git a/FluentUI.Demo/src/androidTest/java/com/microsoft/fluentuidemo/demos/V2PeoplePickerUITest.kt b/FluentUI.Demo/src/androidTest/java/com/microsoft/fluentuidemo/demos/V2PeoplePickerUITest.kt new file mode 100644 index 00000000..aff18447 --- /dev/null +++ b/FluentUI.Demo/src/androidTest/java/com/microsoft/fluentuidemo/demos/V2PeoplePickerUITest.kt @@ -0,0 +1,159 @@ +package com.microsoft.fluentuidemo.demos + +import android.view.KeyEvent.KEYCODE_BACK +import androidx.compose.runtime.mutableStateOf +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.input.key.KeyEvent +import androidx.compose.ui.input.key.NativeKeyEvent +import androidx.compose.ui.input.key.nativeKeyCode +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.test.assertHasClickAction +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsFocused +import androidx.compose.ui.test.assertIsNotFocused +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.onRoot +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performKeyPress +import androidx.compose.ui.test.printToLog +import com.microsoft.fluentui.tokenized.peoplepicker.PeoplePicker +import com.microsoft.fluentui.tokenized.peoplepicker.PeoplePickerItemData +import com.microsoft.fluentui.tokenized.peoplepicker.rememberPeoplePickerItemDataList +import com.microsoft.fluentui.tokenized.persona.Person +import org.junit.Rule +import org.junit.Test + +class V2PeoplePickerUITest { + @get:Rule + val composeTestRule = createComposeRule() + + @Test + fun testV2PeoplePicker() { + composeTestRule.setContent { + PeoplePicker(modifier = Modifier.testTag("V2PeoplePicker"), onValueChange = { _, _ -> + + }) + } + composeTestRule.onRoot().printToLog("testPeoplePickerChip") + val peoplePicker = composeTestRule.onNodeWithTag("V2PeoplePicker") + peoplePicker.assertExists() + peoplePicker.assertIsDisplayed() + } + + @Test + fun testV2PeoplePickerTextField() { + composeTestRule.setContent { + PeoplePicker(modifier = Modifier.testTag("V2PeoplePicker"), onValueChange = { _, _ -> + + }) + } + composeTestRule.onRoot().printToLog("testPeoplePickerChip") + val textField = composeTestRule.onNodeWithTag("Text field") + textField.assertExists() + textField.assertIsDisplayed() + textField.assertIsNotFocused() + textField.performClick() + textField.assertIsFocused() + } + + @Test + fun testPeoplePickerChip() { + + composeTestRule.setContent { + PeoplePicker( + modifier = Modifier.testTag("V2PeoplePicker"), + onValueChange = { _, _ -> + }, + selectedPeopleList = mutableListOf( + PeoplePickerItemData( + Person("firstName"), + mutableStateOf(false) + ) + ), + ) + } + val peoplePickerChip = composeTestRule.onNodeWithContentDescription("firstName", true) + peoplePickerChip.assertExists() + peoplePickerChip.assertIsDisplayed() + peoplePickerChip.assertHasClickAction() + } + + @Test + fun testPeoplePickerChipActions() { + + composeTestRule.setContent { + var selectedPeopleList = rememberPeoplePickerItemDataList() + selectedPeopleList.add( + PeoplePickerItemData( + Person("firstName"), + mutableStateOf(false) + ) + ) + PeoplePicker( + modifier = Modifier.testTag("V2PeoplePicker"), + onValueChange = { _, _ -> + }, + selectedPeopleList = selectedPeopleList, + onChipClick = { + it.selected.value = !it.selected.value + }, + onChipCloseClick = { + selectedPeopleList.remove(it) + } + ) + } + + val peoplePickerChip = composeTestRule.onNodeWithText("firstName", true, useUnmergedTree = true) + peoplePickerChip.performClick() + val cancelButton = composeTestRule.onNodeWithContentDescription("Close", useUnmergedTree = true) + cancelButton.assertExists() + cancelButton.assertIsDisplayed() + cancelButton.assertHasClickAction() + cancelButton.performClick() + peoplePickerChip.assertDoesNotExist() + } + + @OptIn(ExperimentalComposeUiApi::class) + @Test + fun testPeoplePickerBackPress() { + composeTestRule.setContent { + var selectedPeopleList = rememberPeoplePickerItemDataList() + selectedPeopleList.add( + PeoplePickerItemData( + Person("firstName"), + mutableStateOf(false) + ) + ) + PeoplePicker( + modifier = Modifier.testTag("V2PeoplePicker"), + onValueChange = { _, _ -> + }, + selectedPeopleList = selectedPeopleList, + onBackPress = { _, it -> + selectedPeopleList.remove(it) + } + ) + } + val textField = composeTestRule.onNodeWithTag("Text field") + textField.assertExists() + textField.assertIsDisplayed() + val peoplePickerChip = composeTestRule.onNodeWithText("firstName", true, useUnmergedTree = true) + peoplePickerChip.assertExists() + peoplePickerChip.assertIsDisplayed() + textField.performClick() + textField.performKeyPress( + KeyEvent( + NativeKeyEvent( + KEYCODE_BACK, + Key.Backspace.nativeKeyCode + ) + ) + ) + peoplePickerChip.assertDoesNotExist() + } +} \ No newline at end of file diff --git a/FluentUI.Demo/src/main/AndroidManifest.xml b/FluentUI.Demo/src/main/AndroidManifest.xml index 0919efb0..012297bd 100644 --- a/FluentUI.Demo/src/main/AndroidManifest.xml +++ b/FluentUI.Demo/src/main/AndroidManifest.xml @@ -44,6 +44,7 @@ + diff --git a/FluentUI.Demo/src/main/java/com/microsoft/fluentuidemo/Demos.kt b/FluentUI.Demo/src/main/java/com/microsoft/fluentuidemo/Demos.kt index 37c0a480..751c4baf 100644 --- a/FluentUI.Demo/src/main/java/com/microsoft/fluentuidemo/Demos.kt +++ b/FluentUI.Demo/src/main/java/com/microsoft/fluentuidemo/Demos.kt @@ -25,6 +25,7 @@ const val V2DIALOG = "V2 Dialog" const val V2DRAWER = "V2 Drawer" const val V2LIST_ITEM = "V2 ListItem" const val V2MENU = "V2 Menu" +const val V2PEOPLE_PICKER = "V2 People Picker" const val V2PERSONA = "V2 Persona" const val V2PERSONA_CHIP = "V2 PersonaChip" const val V2PERSONA_LIST = "V2 PersonaList" @@ -82,6 +83,7 @@ val DEMOS = arrayListOf( Demo(V2LABEL, V2LabelActivity::class), Demo(V2LIST_ITEM, V2ListItemActivity::class), Demo(V2MENU, V2MenuActivity::class), + Demo(V2PEOPLE_PICKER, V2PeoplePickerActivity::class), Demo(V2PERSONA, V2PersonaActivity::class), Demo(V2PERSONA_CHIP, V2PersonaChipActivity::class), Demo(V2PERSONA_LIST, V2PersonaListActivity::class), diff --git a/FluentUI.Demo/src/main/java/com/microsoft/fluentuidemo/demos/V2PeoplePickerActivity.kt b/FluentUI.Demo/src/main/java/com/microsoft/fluentuidemo/demos/V2PeoplePickerActivity.kt new file mode 100644 index 00000000..b70ecc85 --- /dev/null +++ b/FluentUI.Demo/src/main/java/com/microsoft/fluentuidemo/demos/V2PeoplePickerActivity.kt @@ -0,0 +1,258 @@ +package com.microsoft.fluentuidemo.demos + +import android.os.Bundle +import android.view.LayoutInflater +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Person +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.microsoft.fluentui.theme.FluentTheme +import com.microsoft.fluentui.theme.token.FluentAliasTokens +import com.microsoft.fluentui.theme.token.Icon +import com.microsoft.fluentui.theme.token.controlTokens.AvatarStatus +import com.microsoft.fluentui.theme.token.controlTokens.PersonaChipStyle +import com.microsoft.fluentui.tokenized.controls.Label +import com.microsoft.fluentui.tokenized.notification.Snackbar +import com.microsoft.fluentui.tokenized.notification.SnackbarState +import com.microsoft.fluentui.tokenized.peoplepicker.PeoplePicker +import com.microsoft.fluentui.tokenized.peoplepicker.PeoplePickerItemData +import com.microsoft.fluentui.tokenized.peoplepicker.rememberPeoplePickerItemDataList +import com.microsoft.fluentui.tokenized.persona.AvatarGroup +import com.microsoft.fluentui.tokenized.persona.Group +import com.microsoft.fluentui.tokenized.persona.Person +import com.microsoft.fluentui.tokenized.persona.Persona +import com.microsoft.fluentui.tokenized.persona.PersonaList +import com.microsoft.fluentuidemo.DemoActivity +import com.microsoft.fluentuidemo.R +import com.microsoft.fluentuidemo.databinding.V2ActivityComposeBinding +import kotlinx.coroutines.launch + +class V2PeoplePickerActivity : DemoActivity() { + override val contentNeedsScrollableContainer: Boolean + get() = false + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + val v2ActivityComposeBinding = V2ActivityComposeBinding.inflate( + LayoutInflater.from(container.context), + container, + true + ) + v2ActivityComposeBinding.composeHere.setContent { + FluentTheme { + CreatePeoplePickerActivity() + } + } + } + + @Composable + private fun CreatePeoplePickerActivity() { + val people = mutableListOf( + Person( + "Allan", "Munger", + image = R.drawable.avatar_allan_munger, + email = "allan.munger@xyz.com", + isActive = true + ), + Person( + "Amanda", "Brady", + email = "amanda.brady@xyz.com", + isActive = false, status = AvatarStatus.Offline + ), + Person( + "Abhay", "Singh", + email = "abhay.singh@xyz.com", + isActive = true, status = AvatarStatus.DND, isOOO = true + ), + Person( + "Carlos", "Slathery", + email = "carlos.slathery@xyz.com", + isActive = false, status = AvatarStatus.Busy, isOOO = true + ), + Person( + "Celeste", "Burton", + email = "celeste.burton@xyz.com", + image = R.drawable.avatar_celeste_burton, + isActive = true, status = AvatarStatus.Away + ), + Person( + "Ankit", "Gupta", + email = "ankit.gupta@xyz.com", + isActive = true, status = AvatarStatus.Unknown + ), + Person( + "Miguel", "Garcia", + email = "miguel.garcia@xyz.com", + image = R.drawable.avatar_miguel_garcia, + isActive = true, status = AvatarStatus.Blocked + ) + ) + var selectedPeopleList = rememberPeoplePickerItemDataList() + + + val snackbarState = remember { SnackbarState() } + val scope = rememberCoroutineScope() + var suggested by rememberSaveable { mutableStateOf(people) } + var suggestedPersonaList = mutableListOf() + var selectedPersonList = mutableListOf() + var errorPeopleList = mutableListOf() + var assistiveText by rememberSaveable { mutableStateOf(true) } + var errorText by rememberSaveable { mutableStateOf(false) } + + Column { + Row(modifier = Modifier.padding(8.dp)) { + PeoplePicker( + onValueChange = { query, selectedPerson -> + scope.launch { + suggested = people.filter { + it.firstName.lowercase().contains(query.lowercase()) || + it.lastName.lowercase().contains(query.lowercase()) + } as MutableList + } + }, + selectedPeopleList = selectedPeopleList, + chipValidation = { + if (!it.email.isNullOrBlank()) { + if (it.email?.contains("@") == true) + PersonaChipStyle.Neutral + else { + errorText = true + errorPeopleList.add(it) + PersonaChipStyle.Danger + } + + } else { + errorText = true + errorPeopleList.add(it) + PersonaChipStyle.Danger + } + }, + modifier = Modifier.weight(1f), + onChipClick = { + scope.launch { + it.selected.value = !it.selected.value + snackbarState.showSnackbar("Clicked ${it.person.firstName} ${it.person.lastName}") + } + }, + onChipCloseClick = { + selectedPeopleList.remove(it) + run outer@{ + errorPeopleList.forEach { errorPerson -> + if (errorPerson == it.person) { + errorPeopleList.remove(errorPerson) + return@outer + } + } + } + if (errorPeopleList.isEmpty()) + errorText = false + }, + onTextEntered = { queryText -> + if (queryText.isNotBlank() && queryText.isNotEmpty()) { + selectedPeopleList.add( + PeoplePickerItemData( + Person(queryText, ""), + mutableStateOf(false) + ) + ) + } + }, + onBackPress = { queryText, it -> + if (queryText.isEmpty() && it != null) { + if (!it.selected.value) { + it.selected.value = !it.selected.value + } else { + selectedPeopleList.remove(it) + errorPeopleList.forEach { errorPerson -> + if (errorPerson == it.person) + errorPeopleList.remove(errorPerson) + } + if (errorPeopleList.isEmpty()) + errorText = false + } + } + }, + leadingAccessoryContent = { + Icon( + Icons.Filled.Person, + contentDescription = "Person", + modifier = Modifier + .size(24.dp) + ) + }, + trailingAccessoryContent = { + Icon( + Icons.Filled.Add, + contentDescription = "Add", + modifier = Modifier + .size(24.dp) + ) + }, + label = "People Picker", + searchHint = "Search People", + assistiveText = if (assistiveText) "This is a sample Assistive Text" else null, + errorString = if (errorText) "This is a sample Error text" else null, + ) + } + + suggested.forEach outer@{ + selectedPeopleList.forEach { selectedPerson -> + if (selectedPerson.person.email == it.email) { + return@outer + } + } + suggestedPersonaList.add( + Persona( + it, + "${it.firstName} ${it.lastName}", + subTitle = it.email, + onClick = { + selectedPeopleList.add(PeoplePickerItemData(it, mutableStateOf(false))) + scope.launch { + snackbarState.showSnackbar( + "Added ${it.firstName} ${it.lastName}", + enableDismiss = true + ) + } + } + ) + ) + } + + selectedPeopleList.forEach { + selectedPersonList.add(it.person) + } + Column { + PersonaList(personas = suggestedPersonaList, modifier = Modifier.padding(8.dp)) + Spacer(modifier = Modifier.height(8.dp)) + Label( + modifier = Modifier.padding(8.dp), + text = "Selected People from people picker", + textStyle = FluentAliasTokens.TypographyTokens.Body1 + ) + AvatarGroup(group = Group(selectedPersonList), modifier = Modifier.padding(8.dp)) + Box(Modifier.fillMaxHeight(), contentAlignment = Alignment.Center) { + Snackbar(snackbarState, Modifier.padding(bottom = 12.dp)) + } + } + } + } +} \ No newline at end of file diff --git a/fluentui_core/src/main/java/com/microsoft/fluentui/theme/token/ControlTokens.kt b/fluentui_core/src/main/java/com/microsoft/fluentui/theme/token/ControlTokens.kt index 04e5f1d0..4023da0a 100644 --- a/fluentui_core/src/main/java/com/microsoft/fluentui/theme/token/ControlTokens.kt +++ b/fluentui_core/src/main/java/com/microsoft/fluentui/theme/token/ControlTokens.kt @@ -53,6 +53,7 @@ open class ControlTokens : IControlTokens { LinearProgressIndicator, ListItem, Menu, + PeoplePicker, PersonaChip, PillButton, PillBar, @@ -97,6 +98,7 @@ open class ControlTokens : IControlTokens { ControlType.ListItem -> ListItemTokens() ControlType.Menu -> MenuTokens() ControlType.PersonaChip -> PersonaChipTokens() + ControlType.PeoplePicker -> PeoplePickerTokens() ControlType.PillButton -> PillButtonTokens() ControlType.PillBar -> PillBarTokens() ControlType.PillSwitch -> PillSwitchTokens() diff --git a/fluentui_core/src/main/java/com/microsoft/fluentui/theme/token/controlTokens/PeoplePickerTokens.kt b/fluentui_core/src/main/java/com/microsoft/fluentui/theme/token/controlTokens/PeoplePickerTokens.kt new file mode 100644 index 00000000..8a2f81f0 --- /dev/null +++ b/fluentui_core/src/main/java/com/microsoft/fluentui/theme/token/controlTokens/PeoplePickerTokens.kt @@ -0,0 +1,138 @@ +package com.microsoft.fluentui.theme.token.controlTokens + +import android.os.Parcelable +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.unit.Dp +import com.microsoft.fluentui.theme.FluentTheme +import com.microsoft.fluentui.theme.token.ControlInfo +import com.microsoft.fluentui.theme.token.FluentAliasTokens +import com.microsoft.fluentui.theme.token.FluentGlobalTokens +import com.microsoft.fluentui.theme.token.IControlToken +import kotlinx.parcelize.Parcelize + +data class PeoplePickerInfo( + val isStatusError: Boolean = false, + val isFocused: Boolean = false +) : ControlInfo + +@Parcelize +open class PeoplePickerTokens : IControlToken, Parcelable { + + @Composable + open fun backgroundBrush(peoplePickerInfo: PeoplePickerInfo): Brush { + return SolidColor( + FluentTheme.aliasTokens.neutralBackgroundColor[FluentAliasTokens.NeutralBackgroundColorTokens.Background1].value( + themeMode = FluentTheme.themeMode + ) + ) + } + + @Composable + open fun leadingAccessorySpacing(peoplePickerInfo: PeoplePickerInfo): Dp { + return FluentGlobalTokens.size(FluentGlobalTokens.SizeTokens.Size160) + } + + @Composable + open fun trailingAccessoryPadding(peoplePickerInfo: PeoplePickerInfo): PaddingValues { + return PaddingValues(end = FluentGlobalTokens.size(FluentGlobalTokens.SizeTokens.Size160)) + } + + @Composable + open fun chipSpacing(peoplePickerInfo: PeoplePickerInfo): Dp { + return FluentGlobalTokens.size(FluentGlobalTokens.SizeTokens.Size80) + } + + @Composable + open fun minHeight(peoplePickerInfo: PeoplePickerInfo): Dp { + return FluentGlobalTokens.size(FluentGlobalTokens.SizeTokens.Size480) + } + + @Composable + open fun textColor(peoplePickerInfo: PeoplePickerInfo): Color { + return FluentTheme.aliasTokens.neutralForegroundColor[FluentAliasTokens.NeutralForegroundColorTokens.Foreground1].value( + themeMode = FluentTheme.themeMode + ) + } + + @Composable + open fun textFieldTypography(peoplePickerInfo: PeoplePickerInfo): TextStyle { + return FluentTheme.aliasTokens.typography[FluentAliasTokens.TypographyTokens.Body1] + } + + @Composable + open fun cursorBrush(peoplePickerInfo: PeoplePickerInfo): Brush { + return SolidColor( + FluentTheme.aliasTokens.neutralForegroundColor[FluentAliasTokens.NeutralForegroundColorTokens.Foreground3].value( + themeMode = FluentTheme.themeMode + ) + ) + } + + @Composable + open fun labelTypography(peoplePickerInfo: PeoplePickerInfo): TextStyle { + return FluentTheme.aliasTokens.typography[FluentAliasTokens.TypographyTokens.Caption2] + } + + @Composable + open fun labelColor(peoplePickerInfo: PeoplePickerInfo): Color { + return if (peoplePickerInfo.isStatusError) + FluentTheme.aliasTokens.errorAndStatusColor[FluentAliasTokens.ErrorAndStatusColorTokens.DangerForeground1].value() + else if (peoplePickerInfo.isFocused) + FluentTheme.aliasTokens.brandForegroundColor[FluentAliasTokens.BrandForegroundColorTokens.BrandForeground1].value() + else + FluentTheme.aliasTokens.neutralForegroundColor[FluentAliasTokens.NeutralForegroundColorTokens.Foreground2].value() + + } + + @Composable + open fun assistiveTextColor(peoplePickerInfo: PeoplePickerInfo): Color { + return if (peoplePickerInfo.isStatusError) + FluentTheme.aliasTokens.errorAndStatusColor[FluentAliasTokens.ErrorAndStatusColorTokens.DangerForeground1].value() + else + FluentTheme.aliasTokens.neutralForegroundColor[FluentAliasTokens.NeutralForegroundColorTokens.Foreground2].value() + + } + + @Composable + open fun assistiveTextTypography(peoplePickerInfo: PeoplePickerInfo): TextStyle { + return FluentTheme.aliasTokens.typography[FluentAliasTokens.TypographyTokens.Caption2] + } + + @Composable + open fun assistiveTextPadding(peoplePickerInfo: PeoplePickerInfo): PaddingValues { + return PaddingValues( + top = FluentGlobalTokens.size(FluentGlobalTokens.SizeTokens.Size40), + bottom = FluentGlobalTokens.size(FluentGlobalTokens.SizeTokens.Size40) + ) + } + + @Composable + open fun hintColor(peoplePickerInfo: PeoplePickerInfo): Color { + return FluentTheme.aliasTokens.neutralForegroundColor[FluentAliasTokens.NeutralForegroundColorTokens.Foreground2].value() + } + + @Composable + open fun hintTextTypography(peoplePickerInfo: PeoplePickerInfo): TextStyle { + return FluentTheme.aliasTokens.typography[FluentAliasTokens.TypographyTokens.Body1] + } + + open fun strokeWidth(peoplePickerInfo: PeoplePickerInfo): Dp = + FluentGlobalTokens.strokeWidth(FluentGlobalTokens.StrokeWidthTokens.StrokeWidth05) + + @Composable + open fun dividerBrush(peoplePickerInfo: PeoplePickerInfo): Brush { + return SolidColor( + if (peoplePickerInfo.isStatusError) + FluentTheme.aliasTokens.errorAndStatusColor[FluentAliasTokens.ErrorAndStatusColorTokens.DangerForeground1].value() + else if (peoplePickerInfo.isFocused) + FluentTheme.aliasTokens.brandStroke[FluentAliasTokens.BrandStrokeColorTokens.BrandStroke1].value() + else + FluentTheme.aliasTokens.neutralStrokeColor[FluentAliasTokens.NeutralStrokeColorTokens.Stroke2].value() + ) + } +} \ No newline at end of file diff --git a/fluentui_core/src/main/java/com/microsoft/fluentui/theme/token/controlTokens/PersonaChipTokens.kt b/fluentui_core/src/main/java/com/microsoft/fluentui/theme/token/controlTokens/PersonaChipTokens.kt index 9d52bd0b..f22d6bc9 100644 --- a/fluentui_core/src/main/java/com/microsoft/fluentui/theme/token/controlTokens/PersonaChipTokens.kt +++ b/fluentui_core/src/main/java/com/microsoft/fluentui/theme/token/controlTokens/PersonaChipTokens.kt @@ -238,4 +238,12 @@ open class PersonaChipTokens : IControlToken, Parcelable { open fun avatarSize(personaChipInfo: PersonaChipControlInfo): AvatarSize { return AvatarSize.Size16 } + + @Composable + open fun maxHeight(personaChipInfo: PersonaChipControlInfo): Dp { + return when (personaChipInfo.size) { + Small -> FluentGlobalTokens.size(FluentGlobalTokens.SizeTokens.Size200) + Medium -> FluentGlobalTokens.size(FluentGlobalTokens.SizeTokens.Size240) + } + } } \ No newline at end of file diff --git a/fluentui_peoplepicker/build.gradle b/fluentui_peoplepicker/build.gradle index a4afe926..4931f1b2 100644 --- a/fluentui_peoplepicker/build.gradle +++ b/fluentui_peoplepicker/build.gradle @@ -21,6 +21,7 @@ android { } buildFeatures { viewBinding true + compose true } lintOptions { abortOnError false @@ -31,6 +32,20 @@ android { proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' } } + composeOptions { + kotlinCompilerExtensionVersion composeVersion + } + compileOptions { + sourceCompatibility JavaVersion.VERSION_1_8 + targetCompatibility JavaVersion.VERSION_1_8 + } + kotlinOptions { + jvmTarget = '1.8' + useIR = true + } + buildFeatures { + compose true + } testOptions { unitTests { includeAndroidResources = true @@ -59,6 +74,11 @@ dependencies { implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version" implementation "com.jakewharton.threetenabp:threetenabp:$threetenabpVersion" implementation "com.splitwise:tokenautocomplete:$tokenautocompleteVersion" + + implementation("androidx.compose.foundation:foundation:$composeVersion") + implementation("androidx.compose.runtime:runtime:$composeVersion") + implementation("androidx.compose.ui:ui:$composeVersion") + testImplementation "junit:junit:$junitVersion" androidTestImplementation "androidx.test.ext:junit:$extJunitVersion" androidTestImplementation "androidx.test.espresso:espresso-core:$espressoVersion" diff --git a/fluentui_peoplepicker/src/main/java/com/microsoft/fluentui/tokenized/peoplepicker/PeoplePicker.kt b/fluentui_peoplepicker/src/main/java/com/microsoft/fluentui/tokenized/peoplepicker/PeoplePicker.kt new file mode 100644 index 00000000..2394a81d --- /dev/null +++ b/fluentui_peoplepicker/src/main/java/com/microsoft/fluentui/tokenized/peoplepicker/PeoplePicker.kt @@ -0,0 +1,362 @@ +package com.microsoft.fluentui.tokenized.peoplepicker + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.requiredHeight +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.relocation.BringIntoViewRequester +import androidx.compose.foundation.relocation.bringIntoViewRequester +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.BasicText +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.Saver +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshots.SnapshotStateList +import androidx.compose.ui.Alignment +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.focus.onFocusEvent +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.input.key.key +import androidx.compose.ui.input.key.onKeyEvent +import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.style.TextDirection +import androidx.compose.ui.unit.LayoutDirection +import androidx.compose.ui.unit.dp +import com.microsoft.fluentui.theme.FluentTheme +import com.microsoft.fluentui.theme.token.ControlTokens +import com.microsoft.fluentui.theme.token.controlTokens.AvatarStatus +import com.microsoft.fluentui.theme.token.controlTokens.DividerInfo +import com.microsoft.fluentui.theme.token.controlTokens.DividerTokens +import com.microsoft.fluentui.theme.token.controlTokens.PeoplePickerInfo +import com.microsoft.fluentui.theme.token.controlTokens.PeoplePickerTokens +import com.microsoft.fluentui.theme.token.controlTokens.PersonaChipStyle +import com.microsoft.fluentui.tokenized.divider.Divider +import com.microsoft.fluentui.tokenized.persona.Person +import com.microsoft.fluentui.tokenized.persona.PersonaChip +import kotlinx.coroutines.launch + +/** + * API to create a customized PeoplePicker for users to add a list of PersonaChips + * + * Whenever the user edits the text or a new PersonaChip is added onValueChange is called with the most up to date data + * with which developer is expected to update their state. + * + * PeoplePicker uses [PeoplePickerItemData] to represent a PersonaChip. This is a wrapper around [Person]. + * + * Note: Use rememberPeoplePickerState function on selectedPeople list to create a rememberSaveable state for PeoplePicker. + * + * @param selectedPeopleList List of PersonaChips to be shown in PeoplePicker. + * @param onValueChange The callback that is triggered when the input service updates the text or [selectedPeopleList]. + * An updated text and List of selectedPeople comes as a parameter of the callback + * @param modifier Optional modifier for the TextField + * @param onBackPress The callback that is triggered when the back button is pressed. + * @param onChipClick The callback that is triggered when a PersonaChip is clicked. + * @param onChipCloseClick The callback that is triggered when the close button of a PersonaChip is clicked. + * Note: use this callback to show the cancel button for a persona chip. To disable/not show the close button leave this callback as null. + * @param chipValidation The callback that is triggered when a PersonaChip is added. This callback is used to validate + * the PersonaChip before adding it to the list of selectedPeople. + * @param onTextEntered The callback that is triggered when the user clicks done on the keyboard. + * @param leadingAccessoryContent The content to be placed towards the start of PeoplePicker. + * @param trailingAccessoryContent The content to be placed towards the end of PeoplePicker. + * @param label String which acts as a description for the PeoplePicker. + * @param assistiveText String which assists users with the PeoplePicker + * @param errorString String to describe the error. PeoplePicker goes in error mode if this is provided. + * @param searchHint String to be shown as hint when the PeoplePicker is in rest state. + * @param peoplePickerTokens Customization options for the PeoplePicker. + */ +@OptIn(ExperimentalComposeUiApi::class, ExperimentalFoundationApi::class) +@Composable +fun PeoplePicker( + selectedPeopleList: MutableList = mutableStateListOf(), + onValueChange: (String, MutableList) -> Unit, + modifier: Modifier = Modifier, + onBackPress: ((String, PeoplePickerItemData?) -> Unit)? = null, + onChipClick: ((PeoplePickerItemData) -> Unit)? = null, + onChipCloseClick: ((PeoplePickerItemData) -> Unit)? = null, + chipValidation: (Person) -> PersonaChipStyle = { PersonaChipStyle.Neutral }, + onTextEntered: ((String) -> Unit)? = null, + leadingAccessoryContent: (@Composable () -> Unit)? = null, + trailingAccessoryContent: (@Composable () -> Unit)? = null, + label: String? = null, + assistiveText: String? = null, + errorString: String? = null, + searchHint: String? = null, + peoplePickerTokens: PeoplePickerTokens? = null +) { + val themeID = + FluentTheme.themeID //Adding This only for recomposition in case of Token Updates. Unused otherwise. + val token = peoplePickerTokens + ?: FluentTheme.controlTokens.tokens[ControlTokens.ControlType.PeoplePicker] as PeoplePickerTokens + + var isFocused: Boolean by rememberSaveable { mutableStateOf(false) } + val peoplePickerInfo = PeoplePickerInfo( + isStatusError = !errorString.isNullOrBlank(), + isFocused = isFocused, + ) + val leadingAccessorySpacing = token.leadingAccessorySpacing(peoplePickerInfo = peoplePickerInfo) + val trailingAccessoryPadding = + token.trailingAccessoryPadding(peoplePickerInfo = peoplePickerInfo) + val chipSpacing = token.chipSpacing(peoplePickerInfo = peoplePickerInfo) + val textTypography = token.textFieldTypography(peoplePickerInfo) + val textColor = token.textColor(peoplePickerInfo = peoplePickerInfo) + val cursorBrush = token.cursorBrush(peoplePickerInfo = peoplePickerInfo) + val focusRequester = remember { FocusRequester() } + val bringIntoViewRequester = remember { BringIntoViewRequester() } + var queryText by rememberSaveable { mutableStateOf("") } + var selectedPeopleListSize by rememberSaveable { mutableStateOf(0) } + val scope = rememberCoroutineScope() + + Row( + modifier = modifier + .onFocusChanged { focusState -> + when { + focusState.isFocused -> { + focusRequester.requestFocus() + } + } + }, verticalAlignment = Alignment.CenterVertically + ) { + if (leadingAccessoryContent != null) { + leadingAccessoryContent() + Spacer(modifier = Modifier.width(leadingAccessorySpacing)) + } + Column { + if (!label.isNullOrBlank()) { + Spacer(Modifier.requiredHeight(12.dp)) + BasicText( + label, + style = token.labelTypography(peoplePickerInfo).merge( + TextStyle( + color = token.labelColor(peoplePickerInfo) + ) + ) + ) + } + Row( + modifier = Modifier + .fillMaxWidth() + .height(token.minHeight(peoplePickerInfo = peoplePickerInfo)), + verticalAlignment = Alignment.CenterVertically, + ) { + + Row( + modifier = Modifier + .weight(1f) + .horizontalScroll(rememberScrollState()) + .onKeyEvent { + if (it.key == Key.Backspace) { + if (onBackPress != null) { + onBackPress.invoke(queryText, selectedPeopleList.lastOrNull()) + onValueChange(queryText, selectedPeopleList) + } + } + true + }, + verticalAlignment = Alignment.CenterVertically, + ) { + if (selectedPeopleList.isNotEmpty()) { + selectedPeopleList.forEach { + PersonaChip(person = it.person, selected = it.selected.value, + onCloseClick = if (onChipCloseClick != null) { + { + onChipCloseClick.invoke(it) + onValueChange(queryText, selectedPeopleList) + } + } else { + null + }, + onClick = { + onChipClick?.invoke(it) + onValueChange(queryText, selectedPeopleList) + }, + style = chipValidation(it.person) + ) + Spacer(modifier = Modifier.width(chipSpacing)) + } + } + if (selectedPeopleListSize < selectedPeopleList.size) { + queryText = "" + onValueChange(queryText, selectedPeopleList) + } + selectedPeopleListSize = selectedPeopleList.size + BasicTextField( + value = queryText, + onValueChange = { + queryText = it + onValueChange(queryText, selectedPeopleList) + }, + singleLine = true, + keyboardOptions = KeyboardOptions(), + keyboardActions = KeyboardActions(onDone = { + if (onTextEntered != null) { + onTextEntered.invoke(queryText) + queryText = "" + onValueChange(queryText, selectedPeopleList) + } + }), + modifier = Modifier + .fillMaxWidth() + .focusRequester(focusRequester) + .bringIntoViewRequester(bringIntoViewRequester) + .onFocusEvent { focusState -> + when { + focusState.isFocused -> { + scope.launch { + bringIntoViewRequester.bringIntoView() + } + } + } + } + .semantics { contentDescription = searchHint ?: "" } + .testTag("Text field"), + textStyle = textTypography.merge( + TextStyle( + color = textColor, + textDirection = TextDirection.ContentOrLtr + ) + ), + decorationBox = @Composable { innerTextField -> + Box( + Modifier + .fillMaxWidth(), + contentAlignment = if (LocalLayoutDirection.current == LayoutDirection.Rtl) + Alignment.CenterEnd + else + Alignment.CenterStart + ) { + if (queryText.isEmpty()) { + BasicText( + searchHint ?: "", + style = token.hintTextTypography(peoplePickerInfo) + .merge( + TextStyle( + color = token.hintColor(peoplePickerInfo) + ) + ) + ) + } + + } + innerTextField() + }, + cursorBrush = cursorBrush + ) + } + if (trailingAccessoryContent != null) { + Box(modifier = Modifier.padding(trailingAccessoryPadding)) { + trailingAccessoryContent() + } + } + } + Divider( + height = token.strokeWidth(peoplePickerInfo), + dividerToken = object : DividerTokens() { + @Composable + override fun verticalPadding(dividerInfo: DividerInfo): PaddingValues { + return PaddingValues(0.dp) + } + + @Composable + override fun dividerBrush(dividerInfo: DividerInfo): Brush = + token.dividerBrush(peoplePickerInfo) + } + ) + if (!assistiveText.isNullOrBlank() || !errorString.isNullOrBlank()) { + BasicText( + errorString ?: assistiveText!!, + style = token.assistiveTextTypography(peoplePickerInfo).merge( + TextStyle( + color = token.assistiveTextColor(peoplePickerInfo) + ) + ), + modifier = Modifier.padding(token.assistiveTextPadding(peoplePickerInfo)) + ) + } + } + } +} + +data class PeoplePickerItemData( + val person: Person, + var selected: MutableState = mutableStateOf(false) +) + +@Composable +fun rememberPeoplePickerItemDataList( + initialValue: SnapshotStateList = mutableStateListOf(), +): SnapshotStateList { + return rememberSaveable( + saver = Saver( + save = { + var saved = mutableListOf>() + it.forEach { itemData -> + saved.add( + mapOf( + "selectedKey" to itemData.selected.value, + "firstName" to itemData.person.firstName, + "lastName" to itemData.person.lastName, + "email" to itemData.person.email, + "image" to itemData.person.image, + "imageBitmap" to itemData.person.imageBitmap, + "isActive" to itemData.person.isActive, + "isOOO" to itemData.person.isOOO, + "status" to itemData.person.status + ) + ) + } + saved + }, + restore = { restored -> + val list = mutableStateListOf() + restored.forEach { item -> + list.add( + PeoplePickerItemData( + person = Person( + firstName = item["firstName"] as String, + lastName = item["lastName"] as String, + email = item["email"] as String?, + image = item["image"] as Int?, + imageBitmap = item["imageBitmap"] as ImageBitmap?, + isActive = item["isActive"] as Boolean, + isOOO = item["isOOO"] as Boolean, + status = item["status"] as AvatarStatus + ), + selected = mutableStateOf(item["selectedKey"] as Boolean) + ) + ) + } + list + } + ) + ) { + initialValue + } +} \ No newline at end of file diff --git a/fluentui_persona/src/main/java/com/microsoft/fluentui/tokenized/persona/PersonaChip.kt b/fluentui_persona/src/main/java/com/microsoft/fluentui/tokenized/persona/PersonaChip.kt index 85e71b10..0f37018a 100644 --- a/fluentui_persona/src/main/java/com/microsoft/fluentui/tokenized/persona/PersonaChip.kt +++ b/fluentui_persona/src/main/java/com/microsoft/fluentui/tokenized/persona/PersonaChip.kt @@ -3,7 +3,12 @@ package com.microsoft.fluentui.tokenized.persona import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.layout.* +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.BasicText import androidx.compose.material.icons.Icons @@ -16,6 +21,8 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.clearAndSetSemantics +import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.text.TextStyle import androidx.compose.ui.unit.dp import com.microsoft.fluentui.persona.R @@ -83,17 +90,23 @@ fun PersonaChip( token.avatarToTextSpacing(personaChipInfo = personaChipInfo) val cornerRadius = token.cornerRadius(personaChipInfo = personaChipInfo) + val maxHeight = + token.maxHeight(personaChipInfo = personaChipInfo) Box( modifier = modifier .clip(RoundedCornerShape(cornerRadius)) .background(backgroundColor) + .heightIn(max = maxHeight) .clickable( enabled = enabled, onClick = onClick ?: {}, interactionSource = interactionSource, indication = rememberRipple() ) + .then(if (onCloseClick != null && selected) Modifier else Modifier.clearAndSetSemantics { + this.contentDescription = person.getLabel() + }) ) { Row( @@ -120,7 +133,7 @@ fun PersonaChip( tint = textColor ) } else { - Avatar(person = person, size = avatarSize) + Avatar(modifier = Modifier.clearAndSetSemantics { }, person = person, size = avatarSize) } } BasicText(