Merged PR 223933: PeoplePicker: Accessibility / Explore By Touch
This introduces a new accessibility helper class for `ExploreByTouch`. This class allows us to create nodes and virtual views for the spans inside of `PeoplePickerTextView` so that they are discoverable in `ExploreByTouch` mode and `TalkBack`. I added one api entry point to customize the accessibility text to keep it simple - this is now in a separate file. Also fixed a drag and drop bug - it was too sensitive to touch. **Future improvements / fixes:** 1. ~~Fix bugs for dropdown list virtual views - these were existing before this PR - and even before I started messing with dropdown height / layout when I added the "Search Directory" button~~ 2. ~~Instead of ignoring the text view label (currently we mark it as `android:importantForAccessibility="no"`, it should be hover-able, and tapping to select should focus the edit box. This is what Gmail does - Outlook ignores the label. I think this feature will make the fields easier to navigate and to select - sometimes it's hard to select the field when it's full of persona spans.~~ 3. I need to do some more investigation on how to handle "deselection", but I have the feeling it might tie into future work that exposes click events for `PersonaChip` Related work items: #678124
This commit is contained in:
Родитель
92c4b14e20
Коммит
4b7130f879
|
@ -8,6 +8,7 @@ import android.os.Bundle
|
|||
import android.support.design.widget.Snackbar
|
||||
import android.view.ViewGroup
|
||||
import android.widget.LinearLayout
|
||||
import com.microsoft.officeuifabric.peoplepicker.PeoplePickerAccessibilityTextProvider
|
||||
import com.microsoft.officeuifabric.peoplepicker.PeoplePickerPersonaChipClickStyle
|
||||
import com.microsoft.officeuifabric.peoplepicker.PeoplePickerView
|
||||
import com.microsoft.officeuifabric.persona.IPersona
|
||||
|
@ -30,6 +31,7 @@ class PeoplePickerViewActivity : DemoActivity() {
|
|||
super.onCreate(savedInstanceState)
|
||||
|
||||
samplePersonas = createPersonaList(this)
|
||||
val accessibilityTextProvider = getAccessibilityTextProvider()
|
||||
|
||||
// Use attributes to set personaChipClickStyle and label
|
||||
|
||||
|
@ -53,11 +55,13 @@ class PeoplePickerViewActivity : DemoActivity() {
|
|||
people_picker_select.onCreatePersona = { name, email ->
|
||||
createCustomPersona(this, name, email)
|
||||
}
|
||||
people_picker_select.accessibilityTextProvider = accessibilityTextProvider
|
||||
|
||||
people_picker_select_deselect.availablePersonas = samplePersonas
|
||||
val selectDeselectPickedPersonas = arrayListOf(samplePersonas[2])
|
||||
people_picker_select_deselect.pickedPersonas = selectDeselectPickedPersonas
|
||||
people_picker_select_deselect.allowPersonaChipDragAndDrop = true
|
||||
people_picker_select_deselect.accessibilityTextProvider = accessibilityTextProvider
|
||||
|
||||
// Use code to set personaChipClickStyle and label
|
||||
|
||||
|
@ -82,6 +86,16 @@ class PeoplePickerViewActivity : DemoActivity() {
|
|||
)
|
||||
}
|
||||
|
||||
private fun getAccessibilityTextProvider() = object : PeoplePickerAccessibilityTextProvider(resources) {
|
||||
override fun getPersonaQuantityText(personas: ArrayList<IPersona>): String {
|
||||
return resources.getQuantityString(
|
||||
R.plurals.people_picker_accessibility_text_view_example,
|
||||
personas.size,
|
||||
personas.size
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun setupPeoplePickerView(
|
||||
labelText: String,
|
||||
availablePersonas: ArrayList<IPersona> = ArrayList(),
|
||||
|
|
|
@ -37,6 +37,11 @@
|
|||
<string name="people_picker_picked_personas_listener">Personas Listener</string>
|
||||
<string name="people_picker_suggestions_listener">Suggestions Listener</string>
|
||||
|
||||
<plurals name="people_picker_accessibility_text_view_example">
|
||||
<item quantity="one">%1$s recipient</item>
|
||||
<item quantity="other">%1$s recipients</item>
|
||||
</plurals>
|
||||
|
||||
<!--Persona-->
|
||||
<string name="persona_email_allan_munger">amunger@microsoft.com</string>
|
||||
<string name="persona_email_amanda_brady">abrady@microsoft.com</string>
|
||||
|
|
|
@ -0,0 +1,40 @@
|
|||
/**
|
||||
* Copyright © 2018 Microsoft Corporation. All rights reserved.
|
||||
*/
|
||||
|
||||
package com.microsoft.officeuifabric.peoplepicker
|
||||
|
||||
import android.content.res.Resources
|
||||
import com.microsoft.officeuifabric.R
|
||||
import com.microsoft.officeuifabric.persona.IPersona
|
||||
|
||||
/**
|
||||
* Customizes text announced by the screen reader for PeoplePickerTextView.
|
||||
*/
|
||||
open class PeoplePickerAccessibilityTextProvider(val resources: Resources) {
|
||||
/**
|
||||
* This text announces when the popup opens showing the list of suggested personas.
|
||||
*/
|
||||
open fun getPersonaSuggestionsOpenedText(personas: ArrayList<IPersona>): String =
|
||||
resources.getQuantityString(
|
||||
R.plurals.people_picker_accessibility_suggestions_opened,
|
||||
personas.size,
|
||||
personas.size
|
||||
)
|
||||
|
||||
/**
|
||||
* This text announces how many personas are in the currently focused PeoplePickerTextView.
|
||||
*/
|
||||
open fun getPersonaQuantityText(personas: ArrayList<IPersona>): String =
|
||||
resources.getQuantityString(
|
||||
R.plurals.people_picker_accessibility_text_view,
|
||||
personas.size,
|
||||
personas.size
|
||||
)
|
||||
|
||||
/**
|
||||
* This content description is announced any time a specific persona has focus or receives an event.
|
||||
*/
|
||||
open fun getPersonaDescription(persona: IPersona): String =
|
||||
if (persona.name.isNotEmpty()) persona.name else persona.email
|
||||
}
|
|
@ -8,8 +8,12 @@ import android.content.ClipData
|
|||
import android.content.ClipDescription
|
||||
import android.content.Context
|
||||
import android.graphics.Rect
|
||||
import android.os.Handler
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.support.v4.content.ContextCompat
|
||||
import android.support.v4.view.ViewCompat
|
||||
import android.support.v4.view.accessibility.AccessibilityNodeInfoCompat
|
||||
import android.support.v4.widget.ExploreByTouchHelper
|
||||
import android.text.InputFilter
|
||||
import android.text.SpannableString
|
||||
import android.text.Spanned
|
||||
|
@ -25,6 +29,7 @@ import android.view.MotionEvent
|
|||
import android.view.View
|
||||
import android.view.ViewConfiguration
|
||||
import android.view.accessibility.AccessibilityEvent
|
||||
import android.view.accessibility.AccessibilityNodeInfo
|
||||
import android.view.inputmethod.InputMethodManager
|
||||
import com.microsoft.officeuifabric.R
|
||||
import com.microsoft.officeuifabric.persona.IPersona
|
||||
|
@ -40,6 +45,8 @@ import com.tokenautocomplete.TokenCompleteTextView
|
|||
* Functionality we add in addition to [TokenCompleteTextView]'s functionality includes:
|
||||
* - Hiding the cursor when a token is selected
|
||||
* - Styling the [CountSpan]
|
||||
* - Drag and drop option
|
||||
* - Accessibility
|
||||
*
|
||||
* TODO Known issues:
|
||||
* - Using backspace to delete a selected token does not work if other text is entered in the input;
|
||||
|
@ -52,13 +59,17 @@ import com.tokenautocomplete.TokenCompleteTextView
|
|||
* -setTokenLimit is not working as intended. Need to debug this and add the public property back into the api.
|
||||
*
|
||||
* TODO Future work:
|
||||
* - Improve accessibility with something like the [TokenCompleteTextViewTouchHelper] class.
|
||||
* - Limit what appears in the long click context menu.
|
||||
* - Baseline align chips with other text.
|
||||
* - Improve vertical spacing for chips.
|
||||
* - Add click api for persona chip and relevant accessibility events, including handling deselection
|
||||
* - Announce already selected persona chips, may be related to this ^
|
||||
*/
|
||||
internal class PeoplePickerTextView : TokenCompleteTextView<IPersona> {
|
||||
companion object {
|
||||
// Max number of personas the screen reader will announce on focus.
|
||||
private const val MAX_PERSONAS_TO_READ = 3
|
||||
|
||||
// Removes constraints to the input field
|
||||
private val noFilters = arrayOfNulls<InputFilter>(0)
|
||||
// Constrains changes that can be made to the input field to none
|
||||
|
@ -77,12 +88,45 @@ internal class PeoplePickerTextView : TokenCompleteTextView<IPersona> {
|
|||
* Flag for enabling Drag and Drop persona chips.
|
||||
*/
|
||||
var allowPersonaChipDragAndDrop: Boolean = false
|
||||
/**
|
||||
* Store the hint so that we can control when it is announced for accessibility
|
||||
*/
|
||||
var valueHint: CharSequence = ""
|
||||
set(value) {
|
||||
field = value
|
||||
hint = value
|
||||
}
|
||||
|
||||
lateinit var onCreatePersona: (name: String, email: String) -> IPersona
|
||||
|
||||
private val countSpan: CountSpan?
|
||||
get() = text.getSpans(0, text.length, CountSpan::class.java).firstOrNull()
|
||||
val countSpanStart: Int
|
||||
get() = text.indexOfFirst { it == '+' }
|
||||
private val countSpanEnd: Int
|
||||
get() = text.length
|
||||
|
||||
private val accessibilityTouchHelper = AccessibilityTouchHelper(this)
|
||||
private var blockedMovementMethod: MovementMethod? = null
|
||||
// Keep track of persona selection for accessibility events
|
||||
private var selectedPersona: IPersona? = null
|
||||
set(value) {
|
||||
field = value
|
||||
if (value != null)
|
||||
blockInput()
|
||||
else
|
||||
unblockInput()
|
||||
}
|
||||
private var shouldAnnouncePersonaAddition: Boolean = false
|
||||
private var shouldAnnouncePersonaRemoval: Boolean = true
|
||||
private var searchConstraint: CharSequence = ""
|
||||
|
||||
init {
|
||||
ViewCompat.setAccessibilityDelegate(this, accessibilityTouchHelper)
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O)
|
||||
importantForAutofill = View.IMPORTANT_FOR_AUTOFILL_NO_EXCLUDE_DESCENDANTS
|
||||
|
||||
super.setTokenListener(TokenListener(this))
|
||||
}
|
||||
|
||||
// @JvmOverloads does not work in this scenario due to parameter defaults
|
||||
constructor(context: Context) : super(context)
|
||||
|
@ -100,11 +144,10 @@ internal class PeoplePickerTextView : TokenCompleteTextView<IPersona> {
|
|||
}
|
||||
|
||||
override fun onSelected(selected: Boolean) {
|
||||
if (selected) {
|
||||
blockInput()
|
||||
} else {
|
||||
unblockInput()
|
||||
}
|
||||
if (selected)
|
||||
selectedPersona = `object`
|
||||
else
|
||||
selectedPersona = null
|
||||
}
|
||||
}
|
||||
view.setPersona(`object`)
|
||||
|
@ -119,49 +162,62 @@ internal class PeoplePickerTextView : TokenCompleteTextView<IPersona> {
|
|||
}
|
||||
|
||||
override fun performCollapse(hasFocus: Boolean) {
|
||||
if (getOriginalCountSpan() == null)
|
||||
removeCountSpanText()
|
||||
|
||||
super.performCollapse(hasFocus)
|
||||
|
||||
// Replace the CountSpan with a custom styled span
|
||||
val countSpan = countSpan
|
||||
if (countSpan == null) {
|
||||
removeReplacementCountSpan()
|
||||
} else {
|
||||
val countSpanStart = text.indexOfLast { it == '+' }
|
||||
val countSpanEnd = text.length
|
||||
text.removeSpan(countSpan)
|
||||
val replacementCountSpan = SpannableString(countSpan.text)
|
||||
replacementCountSpan.setSpan(
|
||||
TextAppearanceSpan(context, R.style.TextAppearance_UIFabric_PeoplePickerCountSpan),
|
||||
0,
|
||||
replacementCountSpan.length,
|
||||
Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
|
||||
)
|
||||
text.replace(countSpanStart, countSpanEnd, replacementCountSpan)
|
||||
}
|
||||
// Remove viewPadding for single line to fix jittery virtual view bounds in ExploreByTouch.
|
||||
if (!hasFocus())
|
||||
setPadding(0, 0, 0, 0)
|
||||
else
|
||||
setPadding(0, resources.getDimension(R.dimen.uifabric_people_picker_text_view_padding).toInt(), 0, resources.getDimension(R.dimen.uifabric_people_picker_text_view_padding).toInt())
|
||||
|
||||
updateCountSpanStyle()
|
||||
}
|
||||
|
||||
override fun onFocusChanged(hasFocus: Boolean, direction: Int, previous: Rect?) {
|
||||
super.onFocusChanged(hasFocus, direction, previous)
|
||||
|
||||
val inputmethodManager: InputMethodManager = context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
|
||||
if (hasFocus) {
|
||||
// Soft keyboard does not always show up without this
|
||||
Handler().post {
|
||||
inputmethodManager.showSoftInput(this, InputMethodManager.SHOW_IMPLICIT)
|
||||
val inputMethodManager: InputMethodManager = context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
|
||||
// Soft keyboard does not always show up without this
|
||||
if (hasFocus)
|
||||
post {
|
||||
inputMethodManager.showSoftInput(this, InputMethodManager.SHOW_IMPLICIT)
|
||||
}
|
||||
} else {
|
||||
inputmethodManager.hideSoftInputFromWindow(windowToken, InputMethodManager.HIDE_IMPLICIT_ONLY);
|
||||
}
|
||||
else
|
||||
inputMethodManager.hideSoftInputFromWindow(windowToken, InputMethodManager.HIDE_IMPLICIT_ONLY)
|
||||
}
|
||||
|
||||
override fun onSelectionChanged(selStart: Int, selEnd: Int) {
|
||||
// super.onSelectionChanged is buggy, but we still need the accessibility event from the super super call.
|
||||
sendAccessibilityEvent(AccessibilityEvent.TYPE_VIEW_TEXT_SELECTION_CHANGED)
|
||||
// This fixes buggy cursor position in accessibility mode.
|
||||
setSelection(text.length)
|
||||
}
|
||||
|
||||
override fun onTextChanged(text: CharSequence?, start: Int, lengthBefore: Int, lengthAfter: Int) {
|
||||
super.onTextChanged(text, start, lengthBefore, lengthAfter)
|
||||
unblockInput()
|
||||
selectedPersona = null
|
||||
|
||||
if (lengthAfter > lengthBefore || lengthAfter < lengthBefore && !text.isNullOrEmpty())
|
||||
setupSearchConstraint(text)
|
||||
}
|
||||
|
||||
override fun replaceText(text: CharSequence?) {
|
||||
shouldAnnouncePersonaAddition = true
|
||||
super.replaceText(text)
|
||||
}
|
||||
|
||||
override fun canDeleteSelection(beforeLength: Int): Boolean {
|
||||
// This method is called from keyboard events so any token removed would be coming from the user.
|
||||
shouldAnnouncePersonaRemoval = true
|
||||
return super.canDeleteSelection(beforeLength)
|
||||
}
|
||||
|
||||
override fun removeObject(`object`: IPersona?) {
|
||||
shouldAnnouncePersonaRemoval = false
|
||||
super.removeObject(`object`)
|
||||
}
|
||||
|
||||
internal fun removeObjects(personas: List<IPersona>?) {
|
||||
|
@ -174,13 +230,42 @@ internal class PeoplePickerTextView : TokenCompleteTextView<IPersona> {
|
|||
i++
|
||||
}
|
||||
|
||||
removeReplacementCountSpan()
|
||||
removeCountSpanText()
|
||||
}
|
||||
|
||||
private fun removeReplacementCountSpan() {
|
||||
val replacementCountSpanStart = text.indexOfFirst { it == '+' }
|
||||
if (replacementCountSpanStart > -1)
|
||||
text.delete(replacementCountSpanStart, text.length)
|
||||
private fun setupSearchConstraint(text: CharSequence?) {
|
||||
accessibilityTouchHelper.invalidateRoot()
|
||||
val personaSpanEnd = text?.indexOfLast { it == ',' }?.plus(1) ?: -1
|
||||
searchConstraint = when {
|
||||
// Ignore the count span
|
||||
countSpanStart != -1 -> ""
|
||||
// If we have personas, we'll also have comma tokenizers to remove from the text
|
||||
personaSpanEnd > 0 -> text?.removeRange(text.indexOfFirst { it == ',' }, personaSpanEnd)?.trim() ?: ""
|
||||
// Any other characters will be used as the search constraint to perform filtering.
|
||||
else -> text ?: ""
|
||||
}
|
||||
// This keeps the entered text accessibility focused as the user types, which makes the suggested personas list the next focusable view.
|
||||
accessibilityTouchHelper.sendEventForVirtualView(objects.size, AccessibilityEvent.TYPE_VIEW_ACCESSIBILITY_FOCUSED)
|
||||
}
|
||||
|
||||
private fun updateCountSpanStyle() {
|
||||
val originalCountSpan = getOriginalCountSpan() ?: return
|
||||
|
||||
text.removeSpan(originalCountSpan)
|
||||
val replacementCountSpan = SpannableString(originalCountSpan.text)
|
||||
replacementCountSpan.setSpan(
|
||||
TextAppearanceSpan(context, R.style.TextAppearance_UIFabric_PeoplePickerCountSpan),
|
||||
0,
|
||||
replacementCountSpan.length,
|
||||
Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
|
||||
)
|
||||
text.replace(countSpanStart, countSpanEnd, replacementCountSpan)
|
||||
}
|
||||
|
||||
private fun removeCountSpanText() {
|
||||
val countSpanStart = countSpanStart
|
||||
if (countSpanStart > -1)
|
||||
text.delete(countSpanStart, countSpanEnd)
|
||||
}
|
||||
|
||||
private fun isEmailValid(email: CharSequence): Boolean = Patterns.EMAIL_ADDRESS.matcher(email).matches()
|
||||
|
@ -203,6 +288,35 @@ internal class PeoplePickerTextView : TokenCompleteTextView<IPersona> {
|
|||
movementMethod = blockedMovementMethod
|
||||
}
|
||||
|
||||
private fun getPersonaSpans(start: Int = 0, end: Int = text.length): Array<TokenCompleteTextView<IPersona>.TokenImageSpan> =
|
||||
text.getSpans(start, end, TokenImageSpan::class.java) as Array<TokenCompleteTextView<IPersona>.TokenImageSpan>
|
||||
|
||||
private fun getOriginalCountSpan(): CountSpan? =
|
||||
text.getSpans(0, text.length, CountSpan::class.java).firstOrNull()
|
||||
|
||||
private fun getSpanForPersona(persona: Any): TokenImageSpan? =
|
||||
getPersonaSpans().firstOrNull { it.token === persona }
|
||||
|
||||
// Token listener
|
||||
|
||||
private var tokenListener: TokenCompleteTextView.TokenListener<IPersona>? = null
|
||||
|
||||
override fun setTokenListener(l: TokenCompleteTextView.TokenListener<IPersona>?) {
|
||||
tokenListener = l
|
||||
}
|
||||
|
||||
private class TokenListener(val view: PeoplePickerTextView) : TokenCompleteTextView.TokenListener<IPersona> {
|
||||
override fun onTokenAdded(token: IPersona) {
|
||||
view.tokenListener?.onTokenAdded(token)
|
||||
view.announcePersonaAdded(token)
|
||||
}
|
||||
|
||||
override fun onTokenRemoved(token: IPersona) {
|
||||
view.tokenListener?.onTokenRemoved(token)
|
||||
view.announcePersonaRemoved(token)
|
||||
}
|
||||
}
|
||||
|
||||
// Drag and drop
|
||||
|
||||
private var isDraggingPersonaChip: Boolean = false
|
||||
|
@ -213,7 +327,7 @@ internal class PeoplePickerTextView : TokenCompleteTextView<IPersona> {
|
|||
override fun onTouchEvent(event: MotionEvent): Boolean {
|
||||
var handled = false
|
||||
|
||||
if (personaChipClickStyle == TokenClickStyle.None)
|
||||
if (personaChipClickStyle == PeoplePickerPersonaChipClickStyle.None)
|
||||
handled = super.onTouchEvent(event)
|
||||
|
||||
val touchedPersonaSpan = getPersonaSpanAt(event.x, event.y)
|
||||
|
@ -221,8 +335,6 @@ internal class PeoplePickerTextView : TokenCompleteTextView<IPersona> {
|
|||
if (touchedPersonaSpan != null) {
|
||||
when (event.actionMasked) {
|
||||
MotionEvent.ACTION_DOWN -> {
|
||||
if (!isFocused)
|
||||
requestFocus()
|
||||
if (allowPersonaChipDragAndDrop) {
|
||||
initialTouchedPersonaSpan = touchedPersonaSpan
|
||||
firstTouchX = event.x
|
||||
|
@ -246,6 +358,8 @@ internal class PeoplePickerTextView : TokenCompleteTextView<IPersona> {
|
|||
MotionEvent.ACTION_UP -> {
|
||||
if (isFocused && text != null && initialTouchedPersonaSpan == touchedPersonaSpan)
|
||||
touchedPersonaSpan.onClick()
|
||||
if (!isFocused)
|
||||
requestFocus()
|
||||
initialTouchedPersonaSpan = null
|
||||
handled = true
|
||||
}
|
||||
|
@ -256,7 +370,7 @@ internal class PeoplePickerTextView : TokenCompleteTextView<IPersona> {
|
|||
}
|
||||
}
|
||||
|
||||
if (!handled && personaChipClickStyle != TokenClickStyle.None)
|
||||
if (!handled && personaChipClickStyle != PeoplePickerPersonaChipClickStyle.None)
|
||||
handled = super.onTouchEvent(event)
|
||||
|
||||
return handled
|
||||
|
@ -324,16 +438,14 @@ internal class PeoplePickerTextView : TokenCompleteTextView<IPersona> {
|
|||
}
|
||||
|
||||
private fun getPersonaSpanAt(x: Float, y: Float): TokenImageSpan? {
|
||||
if (TextUtils.isEmpty(text))
|
||||
if (text.isEmpty())
|
||||
return null
|
||||
|
||||
val offset = getOffsetForPosition(x, y)
|
||||
if (offset == -1)
|
||||
return null
|
||||
|
||||
val personaSpans = text.getSpans(offset, offset, TokenImageSpan::class.java)
|
||||
as Array<TokenCompleteTextView<IPersona>.TokenImageSpan>
|
||||
return personaSpans.firstOrNull()
|
||||
return getPersonaSpans(offset, offset).firstOrNull()
|
||||
}
|
||||
|
||||
private fun addPersonaFromDragEvent(event: DragEvent): Boolean {
|
||||
|
@ -350,4 +462,296 @@ internal class PeoplePickerTextView : TokenCompleteTextView<IPersona> {
|
|||
|
||||
return true
|
||||
}
|
||||
|
||||
// Accessibility
|
||||
|
||||
private var customAccessibilityTextProvider: PeoplePickerAccessibilityTextProvider? = null
|
||||
private val defaultAccessibilityTextProvider = PeoplePickerAccessibilityTextProvider(resources)
|
||||
val accessibilityTextProvider: PeoplePickerAccessibilityTextProvider
|
||||
get() = customAccessibilityTextProvider ?: defaultAccessibilityTextProvider
|
||||
|
||||
fun setAccessibilityTextProvider(accessibilityTextProvider: PeoplePickerAccessibilityTextProvider?) {
|
||||
customAccessibilityTextProvider = accessibilityTextProvider
|
||||
}
|
||||
|
||||
override fun dispatchHoverEvent(motionEvent: MotionEvent): Boolean {
|
||||
// Accessibility first
|
||||
return if (accessibilityTouchHelper.dispatchHoverEvent(motionEvent))
|
||||
true
|
||||
else
|
||||
super.dispatchHoverEvent(motionEvent)
|
||||
}
|
||||
|
||||
private fun announcePersonaAdded(persona: IPersona) {
|
||||
accessibilityTouchHelper.invalidateRoot()
|
||||
|
||||
val replacedText = if (searchConstraint.isNotEmpty())
|
||||
"${resources.getString(R.string.people_picker_accessibility_replaced, searchConstraint)} "
|
||||
else
|
||||
""
|
||||
|
||||
// We only want to announce when a persona was added by a user.
|
||||
// If text has been replaced in the text editor and a token was added, the user added a token.
|
||||
if (shouldAnnouncePersonaAddition) {
|
||||
announceForAccessibility("$replacedText ${getAnnouncementText(
|
||||
persona,
|
||||
R.string.people_picker_accessibility_persona_added
|
||||
)}")
|
||||
}
|
||||
}
|
||||
|
||||
private fun announcePersonaRemoved(persona: IPersona) {
|
||||
accessibilityTouchHelper.invalidateRoot()
|
||||
|
||||
// We only want to announce when a persona was removed by a user.
|
||||
if (shouldAnnouncePersonaRemoval) {
|
||||
announceForAccessibility(getAnnouncementText(
|
||||
persona,
|
||||
R.string.people_picker_accessibility_persona_removed
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
private fun getAnnouncementText(persona: IPersona, stringResourceId: Int): CharSequence =
|
||||
resources.getString(stringResourceId, accessibilityTextProvider.getPersonaDescription(persona))
|
||||
|
||||
private fun positionIsInsidePersonaBounds(x: Float, y: Float, personaSpan: TokenImageSpan?): Boolean =
|
||||
getBoundsForPersonaSpan(personaSpan).contains(x.toInt(), y.toInt())
|
||||
|
||||
private fun positionIsInsideSearchConstraintBounds(x: Float, y: Float): Boolean {
|
||||
if (searchConstraint.isNotEmpty())
|
||||
return getBoundsForSearchConstraint().contains(x.toInt(), y.toInt())
|
||||
return false
|
||||
}
|
||||
|
||||
private fun getBoundsForSearchConstraint(): Rect {
|
||||
val start = text.indexOf(searchConstraint[0])
|
||||
val end = text.length
|
||||
return calculateBounds(start, end, resources.getDimension(R.dimen.uifabric_people_picker_accessibility_search_constraint_extra_space).toInt())
|
||||
}
|
||||
|
||||
private fun getBoundsForPersonaSpan(personaSpan: TokenImageSpan? = null): Rect {
|
||||
val start = text.getSpanStart(personaSpan)
|
||||
val end = text.getSpanEnd(personaSpan)
|
||||
return calculateBounds(start, end)
|
||||
}
|
||||
|
||||
private fun calculateBounds(start: Int, end: Int, extraSpaceForLegibility: Int = 0): Rect {
|
||||
val line = layout.getLineForOffset(end)
|
||||
// Persona spans increase line height. Without them, we need to make the virtual view bound bottom lower.
|
||||
val bounds = Rect(
|
||||
layout.getPrimaryHorizontal(start).toInt() - extraSpaceForLegibility,
|
||||
layout.getLineTop(line),
|
||||
layout.getPrimaryHorizontal(end).toInt() + extraSpaceForLegibility,
|
||||
if (getPersonaSpans().isEmpty()) bottom else layout.getLineBottom(line)
|
||||
)
|
||||
bounds.offset(paddingLeft, paddingTop)
|
||||
return bounds
|
||||
}
|
||||
|
||||
private fun setHint() {
|
||||
if (!isFocused)
|
||||
// If the edit box is not focused, there is no event that requires a hint.
|
||||
hint = ""
|
||||
else
|
||||
hint = valueHint
|
||||
}
|
||||
|
||||
private inner class AccessibilityTouchHelper(host: View) : ExploreByTouchHelper(host) {
|
||||
// Host
|
||||
|
||||
val peoplePickerTextViewBounds = Rect(0, 0, width, height)
|
||||
|
||||
override fun onInitializeAccessibilityNodeInfo(host: View, info: AccessibilityNodeInfoCompat) {
|
||||
super.onInitializeAccessibilityNodeInfo(host, info)
|
||||
setHint()
|
||||
setInfoText(info)
|
||||
}
|
||||
|
||||
override fun onPopulateAccessibilityEvent(host: View?, event: AccessibilityEvent?) {
|
||||
super.onPopulateAccessibilityEvent(host, event)
|
||||
/**
|
||||
* The CommaTokenizer is confusing in the screen reader.
|
||||
* This overrides announcements that include the CommaTokenizer.
|
||||
* We handle cases for replaced text and persona spans added / removed through callbacks.
|
||||
*/
|
||||
if (event?.eventType == AccessibilityEvent.TYPE_VIEW_TEXT_CHANGED)
|
||||
event.text.clear()
|
||||
}
|
||||
|
||||
private fun setInfoText(info: AccessibilityNodeInfoCompat) {
|
||||
val personas = objects
|
||||
if (personas == null || personas.isEmpty())
|
||||
return
|
||||
|
||||
var infoText = ""
|
||||
// Read all of the personas if the list of personas in the field is short
|
||||
// Otherwise, read how many personas are in the field
|
||||
if (personas.size <= MAX_PERSONAS_TO_READ)
|
||||
infoText += personas.map { accessibilityTextProvider.getPersonaDescription(it) }.joinToString { it }
|
||||
else
|
||||
infoText = accessibilityTextProvider.getPersonaQuantityText(personas as ArrayList<IPersona>)
|
||||
|
||||
info.text = infoText +
|
||||
// Also ready any entered text in the field
|
||||
if (searchConstraint.isNotEmpty())
|
||||
", $searchConstraint"
|
||||
else
|
||||
""
|
||||
}
|
||||
|
||||
// Virtual views
|
||||
|
||||
override fun getVirtualViewAt(x: Float, y: Float): Int {
|
||||
if (objects == null || objects.size == 0)
|
||||
return ExploreByTouchHelper.INVALID_ID
|
||||
|
||||
val offset = getOffsetForPosition(x, y)
|
||||
if (offset != -1) {
|
||||
val personaSpan = getPersonaSpans(offset, offset).firstOrNull()
|
||||
if (personaSpan != null && positionIsInsidePersonaBounds(x, y, personaSpan) && isFocused)
|
||||
return objects.indexOf(personaSpan.token)
|
||||
else if (searchConstraint.isNotEmpty() && positionIsInsideSearchConstraintBounds(x, y))
|
||||
return objects.size
|
||||
else if (peoplePickerTextViewBounds.contains(x.toInt(), y.toInt())) {
|
||||
sendAccessibilityEvent(AccessibilityEvent.TYPE_VIEW_ACCESSIBILITY_FOCUSED)
|
||||
return ExploreByTouchHelper.HOST_ID
|
||||
}
|
||||
}
|
||||
|
||||
return ExploreByTouchHelper.INVALID_ID
|
||||
}
|
||||
|
||||
override fun getVisibleVirtualViews(virtualViewIds: MutableList<Int>) {
|
||||
virtualViewIds.clear()
|
||||
|
||||
if (objects == null || objects.size == 0 || !isFocused)
|
||||
return
|
||||
|
||||
for (i in objects.indices)
|
||||
virtualViewIds.add(i)
|
||||
|
||||
if (searchConstraint.isNotEmpty())
|
||||
virtualViewIds.add(objects.size)
|
||||
}
|
||||
|
||||
override fun onPopulateEventForVirtualView(virtualViewId: Int, event: AccessibilityEvent) {
|
||||
if (objects == null || virtualViewId >= objects.size) {
|
||||
// The content description is mandatory.
|
||||
event.contentDescription = ""
|
||||
return
|
||||
}
|
||||
|
||||
if (!isFocused) {
|
||||
// Only respond to events for persona chips if the edit box is focused.
|
||||
// Without this the user still gets haptic feedback when hovering over a persona chip.
|
||||
event.recycle()
|
||||
event.contentDescription = ""
|
||||
return
|
||||
}
|
||||
|
||||
if (virtualViewId == objects.size) {
|
||||
event.contentDescription = searchConstraint
|
||||
return
|
||||
}
|
||||
|
||||
val persona = objects[virtualViewId]
|
||||
val personaSpan = getSpanForPersona(persona)
|
||||
if (personaSpan != null)
|
||||
event.contentDescription = accessibilityTextProvider.getPersonaDescription(persona)
|
||||
|
||||
if (event.eventType == AccessibilityEvent.TYPE_VIEW_SELECTED)
|
||||
event.contentDescription = String.format(
|
||||
resources.getString(R.string.people_picker_accessibility_selected_persona),
|
||||
event.contentDescription
|
||||
)
|
||||
}
|
||||
|
||||
override fun onPopulateNodeForVirtualView(virtualViewId: Int, node: AccessibilityNodeInfoCompat) {
|
||||
if (objects == null || virtualViewId > objects.size) {
|
||||
// the content description & the bounds are mandatory.
|
||||
node.contentDescription = ""
|
||||
node.setBoundsInParent(peoplePickerTextViewBounds)
|
||||
return
|
||||
}
|
||||
|
||||
if (!isFocused) {
|
||||
// Only populate nodes for persona chips if the edit box is focused.
|
||||
node.recycle()
|
||||
node.contentDescription = ""
|
||||
node.setBoundsInParent(peoplePickerTextViewBounds)
|
||||
return
|
||||
}
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
|
||||
val clickAction = AccessibilityNodeInfoCompat.AccessibilityActionCompat(
|
||||
AccessibilityNodeInfoCompat.ACTION_CLICK,
|
||||
resources.getString(R.string.people_picker_accessibility_select_persona)
|
||||
)
|
||||
node.addAction(clickAction)
|
||||
} else {
|
||||
node.addAction(AccessibilityNodeInfoCompat.ACTION_CLICK)
|
||||
}
|
||||
|
||||
if (virtualViewId == objects.size) {
|
||||
if (searchConstraint.isNotEmpty()){
|
||||
node.contentDescription = searchConstraint
|
||||
node.setBoundsInParent(getBoundsForSearchConstraint())
|
||||
} else {
|
||||
node.contentDescription = ""
|
||||
node.setBoundsInParent(peoplePickerTextViewBounds)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
val persona = objects[virtualViewId]
|
||||
val personaSpan = getSpanForPersona(persona)
|
||||
if (personaSpan != null) {
|
||||
if (node.isAccessibilityFocused)
|
||||
node.contentDescription = accessibilityTextProvider.getPersonaDescription(persona)
|
||||
else
|
||||
node.contentDescription = ""
|
||||
node.setBoundsInParent(getBoundsForPersonaSpan(personaSpan))
|
||||
}
|
||||
}
|
||||
|
||||
override fun onPerformActionForVirtualView(virtualViewId: Int, action: Int, arguments: Bundle?): Boolean {
|
||||
if (objects == null || virtualViewId >= objects.size)
|
||||
return false
|
||||
|
||||
if (AccessibilityNodeInfo.ACTION_CLICK == action) {
|
||||
val persona = objects[virtualViewId]
|
||||
val personaSpan = getSpanForPersona(persona)
|
||||
if (personaSpan != null) {
|
||||
personaSpan.onClick()
|
||||
onPersonaSpanAccessibilityClick(personaSpan)
|
||||
shouldAnnouncePersonaRemoval = true
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
private fun onPersonaSpanAccessibilityClick(personaSpan: TokenImageSpan) {
|
||||
val personaSpanIndex = getPersonaSpans().indexOf(personaSpan)
|
||||
when (personaChipClickStyle) {
|
||||
PeoplePickerPersonaChipClickStyle.Select, PeoplePickerPersonaChipClickStyle.SelectDeselect -> {
|
||||
if (selectedPersona != null && selectedPersona == personaSpan.token) {
|
||||
invalidateVirtualView(personaSpanIndex)
|
||||
sendEventForVirtualView(personaSpanIndex, AccessibilityEvent.TYPE_VIEW_CLICKED)
|
||||
sendEventForVirtualView(personaSpanIndex, AccessibilityEvent.TYPE_VIEW_SELECTED)
|
||||
} else {
|
||||
sendEventForVirtualView(personaSpanIndex, AccessibilityEvent.TYPE_VIEW_CLICKED)
|
||||
if (personaChipClickStyle == PeoplePickerPersonaChipClickStyle.Select && personaSpanIndex == -1)
|
||||
invalidateRoot()
|
||||
}
|
||||
}
|
||||
PeoplePickerPersonaChipClickStyle.Delete -> {
|
||||
sendEventForVirtualView(personaSpanIndex, AccessibilityEvent.TYPE_VIEW_CLICKED)
|
||||
sendEventForVirtualView(personaSpanIndex, AccessibilityEvent.TYPE_VIEW_ACCESSIBILITY_FOCUS_CLEARED)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -22,6 +22,10 @@ import java.util.*
|
|||
* so we use an [ArrayAdapter] to generate the views instead of a [RecyclerView.Adapter].
|
||||
*/
|
||||
internal class PeoplePickerTextViewAdapter : ArrayAdapter<IPersona>, Filterable {
|
||||
private enum class ViewType {
|
||||
PERSONA, SEARCH_DIRECTORY
|
||||
}
|
||||
|
||||
/**
|
||||
* Collection of [Persona] objects that hold data to create the [PersonaView]s
|
||||
*/
|
||||
|
@ -53,9 +57,12 @@ internal class PeoplePickerTextViewAdapter : ArrayAdapter<IPersona>, Filterable
|
|||
private var searchDirectoryView: View? = null
|
||||
set(value) {
|
||||
field = value
|
||||
searchDirectoryTextView = value?.people_picker_search_directory_text
|
||||
searchDirectoryView?.post {
|
||||
// We set this in a post so that the we get the correct instance of the text view.
|
||||
// This assumes that the first view is the correct view.
|
||||
searchDirectoryTextView = value?.people_picker_search_directory_text
|
||||
}
|
||||
value?.setOnClickListener(onSearchDirectoryButtonClicked)
|
||||
updateSearchDirectoryText()
|
||||
}
|
||||
private var searchDirectoryTextView: TextView? = null
|
||||
set(value) {
|
||||
|
@ -78,32 +85,38 @@ internal class PeoplePickerTextViewAdapter : ArrayAdapter<IPersona>, Filterable
|
|||
|
||||
override fun getFilter(): Filter = filter
|
||||
|
||||
override fun getView(position: Int, convertView: View?, parent: ViewGroup?): View {
|
||||
return if (isSearchDirectoryButtonPosition(position))
|
||||
getSearchDirectoryView(parent)
|
||||
override fun getItemViewType(position: Int): Int {
|
||||
return if (position < personas.size)
|
||||
ViewType.PERSONA.ordinal
|
||||
else
|
||||
getPersonaView(convertView, position, parent)
|
||||
ViewType.SEARCH_DIRECTORY.ordinal
|
||||
}
|
||||
|
||||
override fun getViewTypeCount(): Int = ViewType.values().size
|
||||
|
||||
override fun getView(position: Int, convertView: View?, parent: ViewGroup?): View {
|
||||
return when (getItemViewType(position)) {
|
||||
ViewType.PERSONA.ordinal -> getPersonaView(position, convertView, parent)
|
||||
ViewType.SEARCH_DIRECTORY.ordinal -> getSearchDirectoryView(convertView, parent)
|
||||
else -> throw IllegalStateException("ViewType expected")
|
||||
}
|
||||
}
|
||||
|
||||
private fun isSearchDirectoryButtonPosition(position: Int): Boolean = showSearchDirectoryButton && position == personas.size
|
||||
|
||||
private fun getPersonaView(convertView: View?, position: Int, parent: ViewGroup?): View {
|
||||
private fun getPersonaView(position: Int, convertView: View?, parent: ViewGroup?): View {
|
||||
val view = convertView as? PersonaView ?: PersonaView(context)
|
||||
view.layoutParams = LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT)
|
||||
view.avatarSize = AvatarSize.LARGE
|
||||
view.setPersona(personas[position])
|
||||
listView = parent as? ListView
|
||||
return view
|
||||
}
|
||||
|
||||
private fun getSearchDirectoryView(parent: ViewGroup?): View {
|
||||
var view = searchDirectoryView
|
||||
return if (view == null) {
|
||||
view = LayoutInflater.from(context).inflate(R.layout.people_picker_search_directory, parent, false)
|
||||
searchDirectoryView = view
|
||||
view
|
||||
} else
|
||||
view
|
||||
private fun getSearchDirectoryView(convertView: View?, parent: ViewGroup?): View {
|
||||
// Need to use the convertView, otherwise accessibility focus breaks. Also more efficient.
|
||||
val view = convertView ?: LayoutInflater.from(context).inflate(R.layout.people_picker_search_directory, parent, false)
|
||||
searchDirectoryView = view
|
||||
return view
|
||||
}
|
||||
|
||||
private fun updateSearchDirectoryText() {
|
||||
|
|
|
@ -10,6 +10,7 @@ import android.graphics.Rect
|
|||
import android.util.AttributeSet
|
||||
import android.view.View.OnClickListener
|
||||
import android.view.ViewGroup
|
||||
import android.view.accessibility.AccessibilityEvent
|
||||
import android.widget.Filter
|
||||
import android.widget.TextView
|
||||
import com.microsoft.officeuifabric.R
|
||||
|
@ -42,7 +43,7 @@ class PeoplePickerView : TemplateView {
|
|||
/**
|
||||
* [valueHint] is important for accessibility but will not be displayed.
|
||||
*/
|
||||
var valueHint: String = context.getString(R.string.people_picker_default_hint)
|
||||
var valueHint: String = context.getString(R.string.people_picker_accessibility_default_hint)
|
||||
set(value) {
|
||||
if (field == value)
|
||||
return
|
||||
|
@ -133,6 +134,17 @@ class PeoplePickerView : TemplateView {
|
|||
field = value
|
||||
updateViews()
|
||||
}
|
||||
/**
|
||||
* Customizes text announced by the screen reader.
|
||||
* If there is no custom accessibility text, we use default text.
|
||||
*/
|
||||
var accessibilityTextProvider: PeoplePickerAccessibilityTextProvider? = null
|
||||
set(value) {
|
||||
if (field == value)
|
||||
return
|
||||
field = value
|
||||
updateViews()
|
||||
}
|
||||
|
||||
/**
|
||||
* Callback to use your own [IPersona] object in place of our default [Persona].
|
||||
|
@ -182,7 +194,7 @@ class PeoplePickerView : TemplateView {
|
|||
constructor(context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0) : super(context, attrs, defStyleAttr) {
|
||||
val styledAttrs = context.obtainStyledAttributes(attrs, R.styleable.PeoplePickerView)
|
||||
label = styledAttrs.getString(R.styleable.PeoplePickerView_label) ?: ""
|
||||
valueHint = styledAttrs.getString(R.styleable.PeoplePickerView_valueHint) ?: ""
|
||||
valueHint = styledAttrs.getString(R.styleable.PeoplePickerView_valueHint) ?: context.getString(R.string.people_picker_accessibility_default_hint)
|
||||
val personaChipClickStyleOrdinal = styledAttrs.getInt(R.styleable.PeoplePickerView_personaChipClickStyle, PeoplePickerPersonaChipClickStyle.Select.ordinal)
|
||||
personaChipClickStyle = PeoplePickerPersonaChipClickStyle.values()[personaChipClickStyleOrdinal]
|
||||
|
||||
|
@ -210,34 +222,47 @@ class PeoplePickerView : TemplateView {
|
|||
|
||||
updatePersonaChips()
|
||||
updateViews()
|
||||
addLabelClickListenerForAccessibility()
|
||||
|
||||
super.onTemplateLoaded()
|
||||
}
|
||||
|
||||
private fun updatePersonaChips() {
|
||||
peoplePickerTextView?.let {
|
||||
it.removeObjects(it.objects)
|
||||
for (persona in pickedPersonas)
|
||||
it.addObject(persona)
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateViews() {
|
||||
labelTextView?.text = label
|
||||
peoplePickerTextView?.apply {
|
||||
valueHint = this@PeoplePickerView.valueHint
|
||||
allowCollapse(allowCollapse)
|
||||
allowDuplicates(allowDuplicatePersonaChips)
|
||||
threshold = characterThreshold
|
||||
setAdapter(peoplePickerTextViewAdapter)
|
||||
personaChipClickStyle = this@PeoplePickerView.personaChipClickStyle
|
||||
hint = valueHint
|
||||
allowPersonaChipDragAndDrop = this@PeoplePickerView.allowPersonaChipDragAndDrop
|
||||
onCreatePersona = ::createPersona
|
||||
setAccessibilityTextProvider(this@PeoplePickerView.accessibilityTextProvider)
|
||||
}
|
||||
peoplePickerTextViewAdapter?.showSearchDirectoryButton = showSearchDirectoryButton
|
||||
}
|
||||
|
||||
private fun updatePersonaChips() {
|
||||
peoplePickerTextView?.removeObjects(peoplePickerTextView?.objects)
|
||||
for (persona in pickedPersonas)
|
||||
peoplePickerTextView?.addObject(persona)
|
||||
private fun addLabelClickListenerForAccessibility() {
|
||||
labelTextView?.setOnClickListener {
|
||||
val accessibilityNodeInfo = it?.createAccessibilityNodeInfo()
|
||||
if (accessibilityNodeInfo?.isAccessibilityFocused == true) {
|
||||
peoplePickerTextView?.requestFocus()
|
||||
peoplePickerTextView?.sendAccessibilityEvent(AccessibilityEvent.TYPE_VIEW_ACCESSIBILITY_FOCUSED)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun createPersona(name: String, email: String): IPersona {
|
||||
return onCreatePersona?.invoke(name, email) ?: Persona(name, email)
|
||||
}
|
||||
private fun createPersona(name: String, email: String): IPersona =
|
||||
onCreatePersona?.invoke(name, email) ?: Persona(name, email)
|
||||
|
||||
// Dropdown
|
||||
|
||||
|
@ -322,14 +347,21 @@ class PeoplePickerView : TemplateView {
|
|||
|
||||
override fun publishResults(constraint: CharSequence?, results: Filter.FilterResults) {
|
||||
val listener = view.personaSuggestionsListener
|
||||
val accessibilityTextProvider = view.peoplePickerTextView?.accessibilityTextProvider
|
||||
val countSpanStart = view.peoplePickerTextView?.countSpanStart
|
||||
if (listener != null) {
|
||||
listener.onGetSuggestedPersonas(constraint, view.availablePersonas, view.pickedPersonas) {
|
||||
view.post {
|
||||
view.peoplePickerTextViewAdapter?.personas = it
|
||||
if (constraint != null && countSpanStart == -1)
|
||||
view.announceForAccessibility(accessibilityTextProvider?.getPersonaSuggestionsOpenedText(it))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
view.peoplePickerTextViewAdapter?.personas = results.values as ArrayList<IPersona>
|
||||
val personas = results.values as ArrayList<IPersona>
|
||||
view.peoplePickerTextViewAdapter?.personas = personas
|
||||
if (constraint != null && countSpanStart == -1)
|
||||
view.announceForAccessibility(accessibilityTextProvider?.getPersonaSuggestionsOpenedText(personas))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -18,13 +18,13 @@
|
|||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginEnd="@dimen/uifabric_persona_label_spacing"
|
||||
android:importantForAccessibility="no"
|
||||
android:labelFor="@id/people_picker_text_view"
|
||||
android:gravity="center_vertical"
|
||||
android:minHeight="@dimen/uifabric_min_touch_size"
|
||||
android:textAppearance="@style/TextAppearance.UIFabric.PeoplePickerLabel"
|
||||
tools:text="@string/people_picker_sample_label" />
|
||||
|
||||
<!--Hint is for accessibility, but we don't want to see it-->
|
||||
<!--Hint is for accessibility, but we don't want to see it. Hint is set programmatically.-->
|
||||
<com.microsoft.officeuifabric.peoplepicker.PeoplePickerTextView
|
||||
android:id="@+id/people_picker_text_view"
|
||||
android:layout_width="match_parent"
|
||||
|
|
|
@ -40,12 +40,13 @@
|
|||
<dimen name="uifabric_number_picker_padding_right">8dp</dimen>
|
||||
|
||||
<!--PeoplePicker-->
|
||||
<dimen name="uifabric_people_picker_popup_elevation">0dp</dimen>
|
||||
<dimen name="uifabric_people_picker_accessibility_search_constraint_extra_space">5dp</dimen>
|
||||
<dimen name="uifabric_people_picker_dropdown_vertical_offset">0dp</dimen>
|
||||
<dimen name="uifabric_people_picker_text_view_padding">6dp</dimen>
|
||||
<dimen name="uifabric_people_picker_popup_elevation">0dp</dimen>
|
||||
<dimen name="uifabric_people_picker_search_directory_min_height">56dp</dimen>
|
||||
<dimen name="uifabric_people_picker_search_directory_padding_horizontal">@dimen/uifabric_content_inset</dimen>
|
||||
<dimen name="uifabric_people_picker_search_directory_padding_vertical">12dp</dimen>
|
||||
<dimen name="uifabric_people_picker_text_view_padding">6dp</dimen>
|
||||
|
||||
<!--Persona-->
|
||||
<dimen name="uifabric_persona_horizontal_spacing">@dimen/uifabric_content_inset</dimen>
|
||||
|
|
|
@ -75,10 +75,27 @@
|
|||
<!-- *** PeoplePicker *** -->
|
||||
|
||||
<string name="people_picker_sample_label">Label</string>
|
||||
<string name="people_picker_default_hint">Choose a persona from the list</string>
|
||||
<string name="people_picker_search_directory">Search Directory</string>
|
||||
<string name="people_picker_search_progress">Searching…</string>
|
||||
|
||||
<!--accessibility-->
|
||||
<string name="people_picker_accessibility_default_hint">Enter a name or email</string>
|
||||
<string name="people_picker_accessibility_select_persona">Select</string>
|
||||
<string name="people_picker_accessibility_selected_persona">Selected %s</string>
|
||||
<string name="people_picker_accessibility_persona_added">%s added</string>
|
||||
<string name="people_picker_accessibility_persona_removed">%s removed</string>
|
||||
<string name="people_picker_accessibility_replaced">%s replaced,</string>
|
||||
|
||||
<plurals name="people_picker_accessibility_suggestions_opened">
|
||||
<item quantity="one">Suggestions list opened with %s person</item>
|
||||
<item quantity="other">Suggestions list opened with %s people</item>
|
||||
</plurals>
|
||||
|
||||
<plurals name="people_picker_accessibility_text_view">
|
||||
<item quantity="one">%s person</item>
|
||||
<item quantity="other">%s people</item>
|
||||
</plurals>
|
||||
|
||||
<!-- *** Persona *** -->
|
||||
|
||||
<string name="persona_title_placeholder">No name</string>
|
||||
|
|
Загрузка…
Ссылка в новой задаче