Skip to content

Commit 56e2f15

Browse files
mperrotticolebemis
andauthored
TextInputWithTokens - adds the ability to hide text input tokens after 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 <[email protected]> Co-authored-by: Cole Bemis <[email protected]>
1 parent da56604 commit 56e2f15

9 files changed

+802
-9
lines changed

.changeset/tiny-ghosts-repeat.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@primer/components': minor
3+
---
4+
5+
Add the ability to truncate tokens in the TextInputWithToken component when the input is not focused

docs/content/TextInputWithTokens.mdx

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ render(BasicExample)
4848
| preventTokenWrapping | `boolean` | `false` | Optional. Whether tokens should render inline horizontally. By default, tokens wrap to new lines. |
4949
| size | `TokenSizeKeys` | `extralarge` | Optional. The size of the tokens |
5050
| hideTokenRemoveButtons | `boolean` | `false` | Optional. Whether the remove buttons should be rendered in the tokens |
51+
| visibleTokenCount | `number` | `undefined` | Optional. The number of tokens to display before truncating |
5152

5253
## Adding and removing tokens
5354

@@ -95,3 +96,116 @@ const UsingIssueLabelTokens = () => {
9596

9697
render(<UsingIssueLabelTokens />)
9798
```
99+
100+
## Dealing with long lists of tokens
101+
102+
By default, all tokens will be visible when the component is rendered.
103+
104+
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.
105+
106+
### Hide and show tokens
107+
108+
```javascript live noinline
109+
const VisibleTokenCountExample = () => {
110+
const [tokens, setTokens] = React.useState([
111+
{text: 'zero', id: 0},
112+
{text: 'one', id: 1},
113+
{text: 'two', id: 2},
114+
{text: 'three', id: 3}
115+
])
116+
const onTokenRemove = tokenId => {
117+
setTokens(tokens.filter(token => token.id !== tokenId))
118+
}
119+
120+
return (
121+
<Box maxWidth="500px">
122+
<Box as="label" display="block" htmlFor="inputWithTokens-basic">
123+
Tokens truncated after 2
124+
</Box>
125+
<TextInputWithTokens
126+
visibleTokenCount={2}
127+
block
128+
tokens={tokens}
129+
onTokenRemove={onTokenRemove}
130+
id="inputWithTokens-basic"
131+
/>
132+
</Box>
133+
)
134+
}
135+
136+
render(VisibleTokenCountExample)
137+
```
138+
139+
### Render tokens on a single line
140+
141+
```javascript live noinline
142+
const PreventTokenWrappingExample = () => {
143+
const [tokens, setTokens] = React.useState([
144+
{text: 'zero', id: 0},
145+
{text: 'one', id: 1},
146+
{text: 'two', id: 2},
147+
{text: 'three', id: 3},
148+
{text: 'four', id: 4},
149+
{text: 'five', id: 5},
150+
{text: 'six', id: 6},
151+
{text: 'seven', id: 7}
152+
])
153+
const onTokenRemove = tokenId => {
154+
setTokens(tokens.filter(token => token.id !== tokenId))
155+
}
156+
157+
return (
158+
<Box maxWidth="500px">
159+
<Box as="label" display="block" htmlFor="inputWithTokens-basic">
160+
Tokens on one line
161+
</Box>
162+
<TextInputWithTokens
163+
preventTokenWrapping
164+
block
165+
tokens={tokens}
166+
onTokenRemove={onTokenRemove}
167+
id="inputWithTokens-basic"
168+
/>
169+
</Box>
170+
)
171+
}
172+
173+
render(PreventTokenWrappingExample)
174+
```
175+
176+
### Set a maximum height for the input
177+
178+
```javascript live noinline
179+
const MaxHeightExample = () => {
180+
const [tokens, setTokens] = React.useState([
181+
{text: 'zero', id: 0},
182+
{text: 'one', id: 1},
183+
{text: 'two', id: 2},
184+
{text: 'three', id: 3},
185+
{text: 'four', id: 4},
186+
{text: 'five', id: 5},
187+
{text: 'six', id: 6},
188+
{text: 'seven', id: 7}
189+
])
190+
const onTokenRemove = tokenId => {
191+
setTokens(tokens.filter(token => token.id !== tokenId))
192+
}
193+
194+
return (
195+
<Box maxWidth="500px">
196+
<Box as="label" display="block" htmlFor="inputWithTokens-basic">
197+
Tokens restricted to a max height
198+
</Box>
199+
<TextInputWithTokens
200+
maxHeight="50px"
201+
block
202+
tokens={tokens}
203+
onTokenRemove={onTokenRemove}
204+
id="inputWithTokens-basic"
205+
/>
206+
</Box>
207+
)
208+
}
209+
210+
render(MaxHeightExample)
211+
```

src/TextInputWithTokens.tsx

Lines changed: 64 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import React, {FocusEventHandler, KeyboardEventHandler, RefObject, useRef, useState} from 'react'
1+
import React, {FocusEventHandler, KeyboardEventHandler, MouseEventHandler, RefObject, useRef, useState} from 'react'
22
import {omit} from '@styled-system/props'
33
import {FocusKeys} from './behaviors/focusZone'
44
import {useCombinedRefs} from './hooks/useCombinedRefs'
@@ -11,6 +11,7 @@ import {useProvidedRefOrCreate} from './hooks'
1111
import UnstyledTextInput from './_UnstyledTextInput'
1212
import TextInputWrapper from './_TextInputWrapper'
1313
import Box from './Box'
14+
import Text from './Text'
1415
import {isFocusable} from './utils/iterateFocusableElements'
1516

1617
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -48,8 +49,19 @@ type TextInputWithTokensInternalProps<TokenComponentType extends AnyReactCompone
4849
* Whether the remove buttons should be rendered in the tokens
4950
*/
5051
hideTokenRemoveButtons?: boolean
52+
/**
53+
* The number of tokens to display before truncating
54+
*/
55+
visibleTokenCount?: number
5156
} & TextInputProps
5257

58+
const overflowCountFontSizeMap: Record<TokenSizeKeys, number> = {
59+
small: 0,
60+
medium: 1,
61+
large: 1,
62+
extralarge: 2
63+
}
64+
5365
// using forwardRef is important so that other components (ex. Autocomplete) can use the ref
5466
function TextInputWithTokensInnerComponent<TokenComponentType extends AnyReactComponent>(
5567
{
@@ -71,18 +83,20 @@ function TextInputWithTokensInnerComponent<TokenComponentType extends AnyReactCo
7183
minWidth: minWidthProp,
7284
maxWidth: maxWidthProp,
7385
variant: variantProp,
86+
visibleTokenCount,
7487
...rest
7588
}: TextInputWithTokensInternalProps<TokenComponentType> & {
7689
selectedTokenIndex: number | undefined
7790
setSelectedTokenIndex: React.Dispatch<React.SetStateAction<number | undefined>>
7891
},
7992
externalRef: React.ForwardedRef<HTMLInputElement>
8093
) {
81-
const {onFocus, onKeyDown, ...inputPropsRest} = omit(rest)
94+
const {onBlur, onFocus, onKeyDown, ...inputPropsRest} = omit(rest)
8295
const ref = useProvidedRefOrCreate<HTMLInputElement>(externalRef as React.RefObject<HTMLInputElement>)
8396
const localInputRef = useRef<HTMLInputElement>(null)
8497
const combinedInputRef = useCombinedRefs(localInputRef, ref)
8598
const [selectedTokenIndex, setSelectedTokenIndex] = useState<number | undefined>()
99+
const [tokensAreTruncated, setTokensAreTruncated] = useState<boolean>(Boolean(visibleTokenCount))
86100
const {containerRef} = useFocusZone(
87101
{
88102
focusOutBehavior: 'wrap',
@@ -144,18 +158,42 @@ function TextInputWithTokensInnerComponent<TokenComponentType extends AnyReactCo
144158

145159
const handleTokenBlur: FocusEventHandler = () => {
146160
setSelectedTokenIndex(undefined)
161+
162+
// HACK: wait a tick and check the focused element before hiding truncated tokens
163+
// this prevents the tokens from hiding when the user is moving focus between tokens,
164+
// but still hides the tokens when the user blurs the token by tabbing out or clicking somewhere else on the page
165+
setTimeout(() => {
166+
if (!containerRef.current?.contains(document.activeElement) && visibleTokenCount) {
167+
setTokensAreTruncated(true)
168+
}
169+
}, 0)
147170
}
148171

149-
const handleTokenKeyUp: KeyboardEventHandler = e => {
150-
if (e.key === 'Escape') {
172+
const handleTokenKeyUp: KeyboardEventHandler = event => {
173+
if (event.key === 'Escape') {
151174
ref.current?.focus()
152175
}
153176
}
154177

155-
const handleInputFocus: FocusEventHandler = e => {
156-
onFocus && onFocus(e)
178+
const handleInputFocus: FocusEventHandler = event => {
179+
onFocus && onFocus(event)
157180
setSelectedTokenIndex(undefined)
181+
visibleTokenCount && setTokensAreTruncated(false)
182+
}
183+
184+
const handleInputBlur: FocusEventHandler = event => {
185+
onBlur && onBlur(event)
186+
187+
// HACK: wait a tick and check the focused element before hiding truncated tokens
188+
// this prevents the tokens from hiding when the user is moving focus from the input to a token,
189+
// but still hides the tokens when the user blurs the input by tabbing out or clicking somewhere else on the page
190+
setTimeout(() => {
191+
if (!containerRef.current?.contains(document.activeElement) && visibleTokenCount) {
192+
setTokensAreTruncated(true)
193+
}
194+
}, 0)
158195
}
196+
159197
const handleInputKeyDown: KeyboardEventHandler = e => {
160198
if (onKeyDown) {
161199
onKeyDown(e)
@@ -187,6 +225,16 @@ function TextInputWithTokensInnerComponent<TokenComponentType extends AnyReactCo
187225
}
188226
}
189227

228+
const focusInput: MouseEventHandler = () => {
229+
combinedInputRef.current?.focus()
230+
}
231+
232+
const preventTokenClickPropagation: MouseEventHandler = event => {
233+
event.stopPropagation()
234+
}
235+
236+
const visibleTokens = tokensAreTruncated ? tokens.slice(0, visibleTokenCount) : tokens
237+
190238
return (
191239
<TextInputWrapper
192240
block={block}
@@ -199,6 +247,7 @@ function TextInputWithTokensInnerComponent<TokenComponentType extends AnyReactCo
199247
minWidth={minWidthProp}
200248
maxWidth={maxWidthProp}
201249
variant={variantProp}
250+
onClick={focusInput}
202251
sx={{
203252
...(block
204253
? {
@@ -251,19 +300,21 @@ function TextInputWithTokensInnerComponent<TokenComponentType extends AnyReactCo
251300
ref={combinedInputRef}
252301
disabled={disabled}
253302
onFocus={handleInputFocus}
303+
onBlur={handleInputBlur}
254304
onKeyDown={handleInputKeyDown}
255305
type="text"
256306
sx={{height: '100%'}}
257307
{...inputPropsRest}
258308
/>
259309
</Box>
260-
{tokens.length && TokenComponent
261-
? tokens.map(({id, ...tokenRest}, i) => (
310+
{TokenComponent
311+
? visibleTokens.map(({id, ...tokenRest}, i) => (
262312
<TokenComponent
263313
key={id}
264314
onFocus={handleTokenFocus(i)}
265315
onBlur={handleTokenBlur}
266316
onKeyUp={handleTokenKeyUp}
317+
onClick={preventTokenClickPropagation}
267318
isSelected={selectedTokenIndex === i}
268319
onRemove={() => {
269320
handleTokenRemove(id)
@@ -275,6 +326,11 @@ function TextInputWithTokensInnerComponent<TokenComponentType extends AnyReactCo
275326
/>
276327
))
277328
: null}
329+
{tokensAreTruncated ? (
330+
<Text color="fg.muted" fontSize={size && overflowCountFontSizeMap[size]}>
331+
+{tokens.length - visibleTokens.length}
332+
</Text>
333+
) : null}
278334
</Box>
279335
</TextInputWrapper>
280336
)

src/_TextInputWrapper.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ const TextInputWrapper = styled.span<StyledWrapperProps>`
4545
border-radius: ${get('radii.2')};
4646
outline: none;
4747
box-shadow: ${get('shadows.primer.shadow.inset')};
48+
cursor: text;
4849
4950
${props => {
5051
if (props.hasIcon) {

0 commit comments

Comments
 (0)