Skip to content

Commit

Permalink
TextInputWithTokens - adds the ability to hide text input tokens afte…
Browse files Browse the repository at this point in the history
…r a certain number (#1523)

* adds the ability to hide text input tokens after a certain number

* fixes truncation label bug and updates React docs

* adds changeset

* updates snapshots

* Update .changeset/tiny-ghosts-repeat.md

Co-authored-by: Cole Bemis <colebemis@github.com>

Co-authored-by: Cole Bemis <colebemis@github.com>
  • Loading branch information
mperrotti and colebemis committed Oct 27, 2021
1 parent da56604 commit 56e2f15
Show file tree
Hide file tree
Showing 9 changed files with 802 additions and 9 deletions.
5 changes: 5 additions & 0 deletions .changeset/tiny-ghosts-repeat.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@primer/components': minor
---

Add the ability to truncate tokens in the TextInputWithToken component when the input is not focused
114 changes: 114 additions & 0 deletions docs/content/TextInputWithTokens.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ render(BasicExample)
| preventTokenWrapping | `boolean` | `false` | Optional. Whether tokens should render inline horizontally. By default, tokens wrap to new lines. |
| size | `TokenSizeKeys` | `extralarge` | Optional. The size of the tokens |
| hideTokenRemoveButtons | `boolean` | `false` | Optional. Whether the remove buttons should be rendered in the tokens |
| visibleTokenCount | `number` | `undefined` | Optional. The number of tokens to display before truncating |

## Adding and removing tokens

Expand Down Expand Up @@ -95,3 +96,116 @@ const UsingIssueLabelTokens = () => {

render(<UsingIssueLabelTokens />)
```

## Dealing with long lists of tokens

By default, all tokens will be visible when the component is rendered.

If the component is being used in an area where it's height needs to be constrained, there are options to limit the height of the input.

### Hide and show tokens

```javascript live noinline
const VisibleTokenCountExample = () => {
const [tokens, setTokens] = React.useState([
{text: 'zero', id: 0},
{text: 'one', id: 1},
{text: 'two', id: 2},
{text: 'three', id: 3}
])
const onTokenRemove = tokenId => {
setTokens(tokens.filter(token => token.id !== tokenId))
}

return (
<Box maxWidth="500px">
<Box as="label" display="block" htmlFor="inputWithTokens-basic">
Tokens truncated after 2
</Box>
<TextInputWithTokens
visibleTokenCount={2}
block
tokens={tokens}
onTokenRemove={onTokenRemove}
id="inputWithTokens-basic"
/>
</Box>
)
}

render(VisibleTokenCountExample)
```

### Render tokens on a single line

```javascript live noinline
const PreventTokenWrappingExample = () => {
const [tokens, setTokens] = React.useState([
{text: 'zero', id: 0},
{text: 'one', id: 1},
{text: 'two', id: 2},
{text: 'three', id: 3},
{text: 'four', id: 4},
{text: 'five', id: 5},
{text: 'six', id: 6},
{text: 'seven', id: 7}
])
const onTokenRemove = tokenId => {
setTokens(tokens.filter(token => token.id !== tokenId))
}

return (
<Box maxWidth="500px">
<Box as="label" display="block" htmlFor="inputWithTokens-basic">
Tokens on one line
</Box>
<TextInputWithTokens
preventTokenWrapping
block
tokens={tokens}
onTokenRemove={onTokenRemove}
id="inputWithTokens-basic"
/>
</Box>
)
}

render(PreventTokenWrappingExample)
```

### Set a maximum height for the input

```javascript live noinline
const MaxHeightExample = () => {
const [tokens, setTokens] = React.useState([
{text: 'zero', id: 0},
{text: 'one', id: 1},
{text: 'two', id: 2},
{text: 'three', id: 3},
{text: 'four', id: 4},
{text: 'five', id: 5},
{text: 'six', id: 6},
{text: 'seven', id: 7}
])
const onTokenRemove = tokenId => {
setTokens(tokens.filter(token => token.id !== tokenId))
}

return (
<Box maxWidth="500px">
<Box as="label" display="block" htmlFor="inputWithTokens-basic">
Tokens restricted to a max height
</Box>
<TextInputWithTokens
maxHeight="50px"
block
tokens={tokens}
onTokenRemove={onTokenRemove}
id="inputWithTokens-basic"
/>
</Box>
)
}

render(MaxHeightExample)
```
72 changes: 64 additions & 8 deletions src/TextInputWithTokens.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import React, {FocusEventHandler, KeyboardEventHandler, RefObject, useRef, useState} from 'react'
import React, {FocusEventHandler, KeyboardEventHandler, MouseEventHandler, RefObject, useRef, useState} from 'react'
import {omit} from '@styled-system/props'
import {FocusKeys} from './behaviors/focusZone'
import {useCombinedRefs} from './hooks/useCombinedRefs'
Expand All @@ -11,6 +11,7 @@ import {useProvidedRefOrCreate} from './hooks'
import UnstyledTextInput from './_UnstyledTextInput'
import TextInputWrapper from './_TextInputWrapper'
import Box from './Box'
import Text from './Text'
import {isFocusable} from './utils/iterateFocusableElements'

// eslint-disable-next-line @typescript-eslint/no-explicit-any
Expand Down Expand Up @@ -48,8 +49,19 @@ type TextInputWithTokensInternalProps<TokenComponentType extends AnyReactCompone
* Whether the remove buttons should be rendered in the tokens
*/
hideTokenRemoveButtons?: boolean
/**
* The number of tokens to display before truncating
*/
visibleTokenCount?: number
} & TextInputProps

const overflowCountFontSizeMap: Record<TokenSizeKeys, number> = {
small: 0,
medium: 1,
large: 1,
extralarge: 2
}

// using forwardRef is important so that other components (ex. Autocomplete) can use the ref
function TextInputWithTokensInnerComponent<TokenComponentType extends AnyReactComponent>(
{
Expand All @@ -71,18 +83,20 @@ function TextInputWithTokensInnerComponent<TokenComponentType extends AnyReactCo
minWidth: minWidthProp,
maxWidth: maxWidthProp,
variant: variantProp,
visibleTokenCount,
...rest
}: TextInputWithTokensInternalProps<TokenComponentType> & {
selectedTokenIndex: number | undefined
setSelectedTokenIndex: React.Dispatch<React.SetStateAction<number | undefined>>
},
externalRef: React.ForwardedRef<HTMLInputElement>
) {
const {onFocus, onKeyDown, ...inputPropsRest} = omit(rest)
const {onBlur, onFocus, onKeyDown, ...inputPropsRest} = omit(rest)
const ref = useProvidedRefOrCreate<HTMLInputElement>(externalRef as React.RefObject<HTMLInputElement>)
const localInputRef = useRef<HTMLInputElement>(null)
const combinedInputRef = useCombinedRefs(localInputRef, ref)
const [selectedTokenIndex, setSelectedTokenIndex] = useState<number | undefined>()
const [tokensAreTruncated, setTokensAreTruncated] = useState<boolean>(Boolean(visibleTokenCount))
const {containerRef} = useFocusZone(
{
focusOutBehavior: 'wrap',
Expand Down Expand Up @@ -144,18 +158,42 @@ function TextInputWithTokensInnerComponent<TokenComponentType extends AnyReactCo

const handleTokenBlur: FocusEventHandler = () => {
setSelectedTokenIndex(undefined)

// HACK: wait a tick and check the focused element before hiding truncated tokens
// this prevents the tokens from hiding when the user is moving focus between tokens,
// but still hides the tokens when the user blurs the token by tabbing out or clicking somewhere else on the page
setTimeout(() => {
if (!containerRef.current?.contains(document.activeElement) && visibleTokenCount) {
setTokensAreTruncated(true)
}
}, 0)
}

const handleTokenKeyUp: KeyboardEventHandler = e => {
if (e.key === 'Escape') {
const handleTokenKeyUp: KeyboardEventHandler = event => {
if (event.key === 'Escape') {
ref.current?.focus()
}
}

const handleInputFocus: FocusEventHandler = e => {
onFocus && onFocus(e)
const handleInputFocus: FocusEventHandler = event => {
onFocus && onFocus(event)
setSelectedTokenIndex(undefined)
visibleTokenCount && setTokensAreTruncated(false)
}

const handleInputBlur: FocusEventHandler = event => {
onBlur && onBlur(event)

// HACK: wait a tick and check the focused element before hiding truncated tokens
// this prevents the tokens from hiding when the user is moving focus from the input to a token,
// but still hides the tokens when the user blurs the input by tabbing out or clicking somewhere else on the page
setTimeout(() => {
if (!containerRef.current?.contains(document.activeElement) && visibleTokenCount) {
setTokensAreTruncated(true)
}
}, 0)
}

const handleInputKeyDown: KeyboardEventHandler = e => {
if (onKeyDown) {
onKeyDown(e)
Expand Down Expand Up @@ -187,6 +225,16 @@ function TextInputWithTokensInnerComponent<TokenComponentType extends AnyReactCo
}
}

const focusInput: MouseEventHandler = () => {
combinedInputRef.current?.focus()
}

const preventTokenClickPropagation: MouseEventHandler = event => {
event.stopPropagation()
}

const visibleTokens = tokensAreTruncated ? tokens.slice(0, visibleTokenCount) : tokens

return (
<TextInputWrapper
block={block}
Expand All @@ -199,6 +247,7 @@ function TextInputWithTokensInnerComponent<TokenComponentType extends AnyReactCo
minWidth={minWidthProp}
maxWidth={maxWidthProp}
variant={variantProp}
onClick={focusInput}
sx={{
...(block
? {
Expand Down Expand Up @@ -251,19 +300,21 @@ function TextInputWithTokensInnerComponent<TokenComponentType extends AnyReactCo
ref={combinedInputRef}
disabled={disabled}
onFocus={handleInputFocus}
onBlur={handleInputBlur}
onKeyDown={handleInputKeyDown}
type="text"
sx={{height: '100%'}}
{...inputPropsRest}
/>
</Box>
{tokens.length && TokenComponent
? tokens.map(({id, ...tokenRest}, i) => (
{TokenComponent
? visibleTokens.map(({id, ...tokenRest}, i) => (
<TokenComponent
key={id}
onFocus={handleTokenFocus(i)}
onBlur={handleTokenBlur}
onKeyUp={handleTokenKeyUp}
onClick={preventTokenClickPropagation}
isSelected={selectedTokenIndex === i}
onRemove={() => {
handleTokenRemove(id)
Expand All @@ -275,6 +326,11 @@ function TextInputWithTokensInnerComponent<TokenComponentType extends AnyReactCo
/>
))
: null}
{tokensAreTruncated ? (
<Text color="fg.muted" fontSize={size && overflowCountFontSizeMap[size]}>
+{tokens.length - visibleTokens.length}
</Text>
) : null}
</Box>
</TextInputWrapper>
)
Expand Down
1 change: 1 addition & 0 deletions src/_TextInputWrapper.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ const TextInputWrapper = styled.span<StyledWrapperProps>`
border-radius: ${get('radii.2')};
outline: none;
box-shadow: ${get('shadows.primer.shadow.inset')};
cursor: text;
${props => {
if (props.hasIcon) {
Expand Down
Loading

0 comments on commit 56e2f15

Please sign in to comment.