@primer/components 31.0.2-rc.c7dafefb → 31.1.0-rc.71d00800

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -10,9 +10,16 @@ import { useProvidedRefOrCreate } from './hooks';
10
10
  import UnstyledTextInput from './_UnstyledTextInput';
11
11
  import TextInputWrapper from './_TextInputWrapper';
12
12
  import Box from './Box';
13
+ import Text from './Text';
13
14
  import { isFocusable } from './utils/iterateFocusableElements'; // eslint-disable-next-line @typescript-eslint/no-explicit-any
14
15
 
15
- // using forwardRef is important so that other components (ex. Autocomplete) can use the ref
16
+ const overflowCountFontSizeMap = {
17
+ small: 0,
18
+ medium: 1,
19
+ large: 1,
20
+ extralarge: 2
21
+ }; // using forwardRef is important so that other components (ex. Autocomplete) can use the ref
22
+
16
23
  function TextInputWithTokensInnerComponent({
17
24
  icon: IconComponent,
18
25
  contrast,
@@ -32,9 +39,11 @@ function TextInputWithTokensInnerComponent({
32
39
  minWidth: minWidthProp,
33
40
  maxWidth: maxWidthProp,
34
41
  variant: variantProp,
42
+ visibleTokenCount,
35
43
  ...rest
36
44
  }, externalRef) {
37
45
  const {
46
+ onBlur,
38
47
  onFocus,
39
48
  onKeyDown,
40
49
  ...inputPropsRest
@@ -43,6 +52,7 @@ function TextInputWithTokensInnerComponent({
43
52
  const localInputRef = useRef(null);
44
53
  const combinedInputRef = useCombinedRefs(localInputRef, ref);
45
54
  const [selectedTokenIndex, setSelectedTokenIndex] = useState();
55
+ const [tokensAreTruncated, setTokensAreTruncated] = useState(Boolean(visibleTokenCount));
46
56
  const {
47
57
  containerRef
48
58
  } = useFocusZone({
@@ -103,20 +113,45 @@ function TextInputWithTokensInnerComponent({
103
113
  };
104
114
 
105
115
  const handleTokenBlur = () => {
106
- setSelectedTokenIndex(undefined);
116
+ setSelectedTokenIndex(undefined); // HACK: wait a tick and check the focused element before hiding truncated tokens
117
+ // this prevents the tokens from hiding when the user is moving focus between tokens,
118
+ // but still hides the tokens when the user blurs the token by tabbing out or clicking somewhere else on the page
119
+
120
+ setTimeout(() => {
121
+ var _containerRef$current4;
122
+
123
+ if (!((_containerRef$current4 = containerRef.current) !== null && _containerRef$current4 !== void 0 && _containerRef$current4.contains(document.activeElement)) && visibleTokenCount) {
124
+ setTokensAreTruncated(true);
125
+ }
126
+ }, 0);
107
127
  };
108
128
 
109
- const handleTokenKeyUp = e => {
110
- if (e.key === 'Escape') {
129
+ const handleTokenKeyUp = event => {
130
+ if (event.key === 'Escape') {
111
131
  var _ref$current2;
112
132
 
113
133
  (_ref$current2 = ref.current) === null || _ref$current2 === void 0 ? void 0 : _ref$current2.focus();
114
134
  }
115
135
  };
116
136
 
117
- const handleInputFocus = e => {
118
- onFocus && onFocus(e);
137
+ const handleInputFocus = event => {
138
+ onFocus && onFocus(event);
119
139
  setSelectedTokenIndex(undefined);
140
+ visibleTokenCount && setTokensAreTruncated(false);
141
+ };
142
+
143
+ const handleInputBlur = event => {
144
+ onBlur && onBlur(event); // HACK: wait a tick and check the focused element before hiding truncated tokens
145
+ // this prevents the tokens from hiding when the user is moving focus from the input to a token,
146
+ // but still hides the tokens when the user blurs the input by tabbing out or clicking somewhere else on the page
147
+
148
+ setTimeout(() => {
149
+ var _containerRef$current5;
150
+
151
+ if (!((_containerRef$current5 = containerRef.current) !== null && _containerRef$current5 !== void 0 && _containerRef$current5.contains(document.activeElement)) && visibleTokenCount) {
152
+ setTokensAreTruncated(true);
153
+ }
154
+ }, 0);
120
155
  };
121
156
 
122
157
  const handleInputKeyDown = e => {
@@ -154,6 +189,17 @@ function TextInputWithTokensInnerComponent({
154
189
  }
155
190
  };
156
191
 
192
+ const focusInput = () => {
193
+ var _combinedInputRef$cur;
194
+
195
+ (_combinedInputRef$cur = combinedInputRef.current) === null || _combinedInputRef$cur === void 0 ? void 0 : _combinedInputRef$cur.focus();
196
+ };
197
+
198
+ const preventTokenClickPropagation = event => {
199
+ event.stopPropagation();
200
+ };
201
+
202
+ const visibleTokens = tokensAreTruncated ? tokens.slice(0, visibleTokenCount) : tokens;
157
203
  return /*#__PURE__*/React.createElement(TextInputWrapper, {
158
204
  block: block,
159
205
  className: className,
@@ -165,6 +211,7 @@ function TextInputWithTokensInnerComponent({
165
211
  minWidth: minWidthProp,
166
212
  maxWidth: maxWidthProp,
167
213
  variant: variantProp,
214
+ onClick: focusInput,
168
215
  sx: { ...(block ? {
169
216
  display: 'flex',
170
217
  width: '100%'
@@ -204,12 +251,13 @@ function TextInputWithTokensInnerComponent({
204
251
  ref: combinedInputRef,
205
252
  disabled: disabled,
206
253
  onFocus: handleInputFocus,
254
+ onBlur: handleInputBlur,
207
255
  onKeyDown: handleInputKeyDown,
208
256
  type: "text",
209
257
  sx: {
210
258
  height: '100%'
211
259
  }
212
- }, inputPropsRest))), tokens.length && TokenComponent ? tokens.map(({
260
+ }, inputPropsRest))), TokenComponent ? visibleTokens.map(({
213
261
  id,
214
262
  ...tokenRest
215
263
  }, i) => /*#__PURE__*/React.createElement(TokenComponent, _extends({
@@ -217,6 +265,7 @@ function TextInputWithTokensInnerComponent({
217
265
  onFocus: handleTokenFocus(i),
218
266
  onBlur: handleTokenBlur,
219
267
  onKeyUp: handleTokenKeyUp,
268
+ onClick: preventTokenClickPropagation,
220
269
  isSelected: selectedTokenIndex === i,
221
270
  onRemove: () => {
222
271
  handleTokenRemove(id);
@@ -224,7 +273,10 @@ function TextInputWithTokensInnerComponent({
224
273
  hideRemoveButton: hideTokenRemoveButtons,
225
274
  size: size,
226
275
  tabIndex: 0
227
- }, tokenRest))) : null));
276
+ }, tokenRest))) : null, tokensAreTruncated ? /*#__PURE__*/React.createElement(Text, {
277
+ color: "fg.muted",
278
+ fontSize: size && overflowCountFontSizeMap[size]
279
+ }, "+", tokens.length - visibleTokens.length) : null));
228
280
  }
229
281
 
230
282
  TextInputWithTokensInnerComponent.displayName = "TextInputWithTokensInnerComponent";
@@ -21,7 +21,7 @@ const sizeVariants = variant({
21
21
  const TextInputWrapper = styled.span.withConfig({
22
22
  displayName: "_TextInputWrapper__TextInputWrapper",
23
23
  componentId: "sc-5vfcis-0"
24
- })(["display:inline-flex;align-items:stretch;min-height:34px;font-size:", ";line-height:20px;color:", ";vertical-align:middle;background-repeat:no-repeat;background-position:right 8px center;border:1px solid ", ";border-radius:", ";outline:none;box-shadow:", ";", " .TextInput-icon{align-self:center;color:", ";margin:0 ", ";flex-shrink:0;}&:focus-within{border-color:", ";box-shadow:", ";}", " ", " ", " @media (min-width:", "){font-size:", ";}", " ", " ", " ", " ", ";"], get('fontSizes.1'), get('colors.fg.default'), get('colors.border.default'), get('radii.2'), get('shadows.primer.shadow.inset'), props => {
24
+ })(["display:inline-flex;align-items:stretch;min-height:34px;font-size:", ";line-height:20px;color:", ";vertical-align:middle;background-repeat:no-repeat;background-position:right 8px center;border:1px solid ", ";border-radius:", ";outline:none;box-shadow:", ";cursor:text;", " .TextInput-icon{align-self:center;color:", ";margin:0 ", ";flex-shrink:0;}&:focus-within{border-color:", ";box-shadow:", ";}", " ", " ", " @media (min-width:", "){font-size:", ";}", " ", " ", " ", " ", ";"], get('fontSizes.1'), get('colors.fg.default'), get('colors.border.default'), get('radii.2'), get('shadows.primer.shadow.inset'), props => {
25
25
  if (props.hasIcon) {
26
26
  return css(["padding:0;"]);
27
27
  } else {
@@ -2,7 +2,7 @@ function _extends() { _extends = Object.assign || function (target) { for (var i
2
2
 
3
3
  import React from 'react';
4
4
  import { render } from '../utils/testing';
5
- import { render as HTMLRender, cleanup, fireEvent } from '@testing-library/react';
5
+ import { render as HTMLRender, fireEvent, act, cleanup } from '@testing-library/react';
6
6
  import { axe, toHaveNoViolations } from 'jest-axe';
7
7
  import 'babel-polyfill';
8
8
  import { tokenSizes } from '../Token/TokenBase';
@@ -46,8 +46,18 @@ const LabelledTextInputWithTokens = ({
46
46
  tokens: tokens,
47
47
  onTokenRemove: onTokenRemove,
48
48
  id: "tokenInput"
49
- }, rest)));
49
+ }, rest))); // describe('axe test', () => {
50
+ // it('should have no axe violations', async () => {
51
+ // const onRemoveMock = jest.fn()
52
+ // const {container} = HTMLRender(<LabelledTextInputWithTokens tokens={mockTokens} onTokenRemove={onRemoveMock} />)
53
+ // const results = await axe(container)
54
+ // expect(results).toHaveNoViolations()
55
+ // cleanup()
56
+ // })
57
+ // })
50
58
 
59
+
60
+ jest.useFakeTimers();
51
61
  describe('TextInputWithTokens', () => {
52
62
  it('renders without tokens', () => {
53
63
  const onRemoveMock = jest.fn();
@@ -115,6 +125,14 @@ describe('TextInputWithTokens', () => {
115
125
  onTokenRemove: onRemoveMock
116
126
  }))).toMatchSnapshot();
117
127
  });
128
+ it('renders a truncated set of tokens', () => {
129
+ const onRemoveMock = jest.fn();
130
+ expect(render( /*#__PURE__*/React.createElement(TextInputWithTokens, {
131
+ tokens: mockTokens,
132
+ onTokenRemove: onRemoveMock,
133
+ visibleTokenCount: 2
134
+ }))).toMatchSnapshot();
135
+ });
118
136
  it('focuses the previous token when keying ArrowLeft', () => {
119
137
  var _document$activeEleme, _document$activeEleme2;
120
138
 
@@ -220,8 +238,123 @@ describe('TextInputWithTokens', () => {
220
238
  expect((_document$activeEleme8 = document.activeElement) === null || _document$activeEleme8 === void 0 ? void 0 : _document$activeEleme8.id).not.toEqual(lastTokenNode.id);
221
239
  expect((_document$activeEleme9 = document.activeElement) === null || _document$activeEleme9 === void 0 ? void 0 : _document$activeEleme9.id).toEqual(inputNode.id);
222
240
  });
241
+ it('does not focus the input when clicking a token', () => {
242
+ var _document$activeEleme10;
243
+
244
+ const onRemoveMock = jest.fn();
245
+ const {
246
+ getByLabelText,
247
+ getByText
248
+ } = HTMLRender( /*#__PURE__*/React.createElement(LabelledTextInputWithTokens, {
249
+ tokens: mockTokens,
250
+ onTokenRemove: onRemoveMock,
251
+ visibleTokenCount: 2
252
+ }));
253
+ const inputNode = getByLabelText('Tokens');
254
+ const tokenNode = getByText(mockTokens[0].text);
255
+ expect(document.activeElement).not.toEqual(inputNode.id);
256
+ fireEvent.click(tokenNode);
257
+ expect((_document$activeEleme10 = document.activeElement) === null || _document$activeEleme10 === void 0 ? void 0 : _document$activeEleme10.id).not.toEqual(inputNode.id);
258
+ });
259
+ it('focuses the input when clicking somewhere in the component besides the tokens', () => {
260
+ var _document$activeEleme11;
261
+
262
+ const onRemoveMock = jest.fn();
263
+ const {
264
+ getByLabelText,
265
+ getByText
266
+ } = HTMLRender( /*#__PURE__*/React.createElement(LabelledTextInputWithTokens, {
267
+ tokens: mockTokens,
268
+ onTokenRemove: onRemoveMock,
269
+ visibleTokenCount: 2
270
+ }));
271
+ const inputNode = getByLabelText('Tokens');
272
+ const truncatedTokenCount = getByText('+6');
273
+ expect(document.activeElement).not.toEqual(inputNode.id);
274
+ fireEvent.click(truncatedTokenCount);
275
+ expect((_document$activeEleme11 = document.activeElement) === null || _document$activeEleme11 === void 0 ? void 0 : _document$activeEleme11.id).toEqual(inputNode.id);
276
+ });
277
+ it('shows all tokens when the input is focused and hides them when it is blurred (when visibleTokenCount is set)', () => {
278
+ const onRemoveMock = jest.fn();
279
+ const visibleTokenCount = 2;
280
+ const {
281
+ getByLabelText,
282
+ getByText
283
+ } = HTMLRender( /*#__PURE__*/React.createElement(React.Fragment, null, /*#__PURE__*/React.createElement(LabelledTextInputWithTokens, {
284
+ tokens: mockTokens,
285
+ onTokenRemove: onRemoveMock,
286
+ visibleTokenCount: visibleTokenCount
287
+ }), /*#__PURE__*/React.createElement("button", {
288
+ id: "focusableOutsideComponent"
289
+ }, "Focus me")));
290
+ const inputNode = getByLabelText('Tokens');
291
+ const focusableOutsideComponentNode = getByText('Focus me');
292
+ const allTokenLabels = mockTokens.map(token => token.text);
293
+ const truncatedTokenCountNode = getByText('+6');
294
+ act(() => {
295
+ jest.runAllTimers();
296
+ fireEvent.focus(inputNode);
297
+ });
298
+ setTimeout(() => {
299
+ for (const tokenLabel of allTokenLabels) {
300
+ const tokenNode = getByText(tokenLabel);
301
+ expect(tokenNode).toBeDefined();
302
+ }
303
+ }, 0);
304
+ act(() => {
305
+ jest.runAllTimers(); // onBlur isn't called on input unless we specifically fire the "blur" event
306
+ // eslint-disable-next-line github/no-blur
307
+
308
+ fireEvent.blur(inputNode);
309
+ fireEvent.focus(focusableOutsideComponentNode);
310
+ });
311
+ setTimeout(() => {
312
+ expect(truncatedTokenCountNode).toBeDefined();
313
+
314
+ for (const tokenLabel of allTokenLabels) {
315
+ const tokenNode = getByText(tokenLabel);
316
+
317
+ if (allTokenLabels.indexOf(tokenLabel) > visibleTokenCount) {
318
+ // eslint-disable-next-line jest/no-conditional-expect
319
+ expect(tokenNode).toBeDefined();
320
+ } else {
321
+ // eslint-disable-next-line jest/no-conditional-expect
322
+ expect(tokenNode).not.toBeDefined();
323
+ }
324
+ }
325
+ }, 0);
326
+ jest.useRealTimers();
327
+ });
328
+ it('does not hide tokens when blurring the input to focus within the component (when visibleTokenCount is set)', () => {
329
+ const onRemoveMock = jest.fn();
330
+ const visibleTokenCount = 2;
331
+ const {
332
+ getByLabelText,
333
+ getByText
334
+ } = HTMLRender( /*#__PURE__*/React.createElement(React.Fragment, null, /*#__PURE__*/React.createElement(LabelledTextInputWithTokens, {
335
+ tokens: mockTokens,
336
+ onTokenRemove: onRemoveMock,
337
+ visibleTokenCount: visibleTokenCount
338
+ }), /*#__PURE__*/React.createElement("button", {
339
+ id: "focusableOutsideComponent"
340
+ }, "Focus me")));
341
+ const inputNode = getByLabelText('Tokens');
342
+ const firstTokenNode = getByText(mockTokens[visibleTokenCount - 1].text);
343
+ const allTokenLabels = mockTokens.map(token => token.text);
344
+ const truncatedTokenCountNode = getByText('+6');
345
+ act(() => {
346
+ fireEvent.focus(inputNode);
347
+ fireEvent.focus(firstTokenNode);
348
+ });
349
+ expect(truncatedTokenCountNode).toBeDefined();
350
+
351
+ for (const tokenLabel of allTokenLabels) {
352
+ const tokenNode = getByText(tokenLabel);
353
+ expect(tokenNode).toBeDefined();
354
+ }
355
+ });
223
356
  it('focuses the first token when keying ArrowRight in the input', () => {
224
- var _document$activeEleme10, _document$activeEleme11;
357
+ var _document$activeEleme12, _document$activeEleme13;
225
358
 
226
359
  const onRemoveMock = jest.fn();
227
360
  const {
@@ -238,8 +371,8 @@ describe('TextInputWithTokens', () => {
238
371
  fireEvent.keyDown(inputNode, {
239
372
  key: 'ArrowRight'
240
373
  });
241
- expect((_document$activeEleme10 = document.activeElement) === null || _document$activeEleme10 === void 0 ? void 0 : _document$activeEleme10.id).not.toEqual(inputNode.id);
242
- expect((_document$activeEleme11 = document.activeElement) === null || _document$activeEleme11 === void 0 ? void 0 : _document$activeEleme11.id).toEqual(firstTokenNode.id);
374
+ expect((_document$activeEleme12 = document.activeElement) === null || _document$activeEleme12 === void 0 ? void 0 : _document$activeEleme12.id).not.toEqual(inputNode.id);
375
+ expect((_document$activeEleme13 = document.activeElement) === null || _document$activeEleme13 === void 0 ? void 0 : _document$activeEleme13.id).toEqual(firstTokenNode.id);
243
376
  });
244
377
  it('calls onTokenRemove on the last token when keying Backspace in an empty input', () => {
245
378
  const onRemoveMock = jest.fn();
@@ -324,9 +457,9 @@ describe('TextInputWithTokens', () => {
324
457
  });
325
458
  jest.runAllTimers();
326
459
  setTimeout(() => {
327
- var _document$activeEleme12;
460
+ var _document$activeEleme14;
328
461
 
329
- expect((_document$activeEleme12 = document.activeElement) === null || _document$activeEleme12 === void 0 ? void 0 : _document$activeEleme12.textContent).toBe(mockTokens[1].text);
462
+ expect((_document$activeEleme14 = document.activeElement) === null || _document$activeEleme14 === void 0 ? void 0 : _document$activeEleme14.textContent).toBe(mockTokens[1].text);
330
463
  }, 0);
331
464
  jest.useRealTimers();
332
465
  });
@@ -348,9 +481,9 @@ describe('TextInputWithTokens', () => {
348
481
  });
349
482
  jest.runAllTimers();
350
483
  setTimeout(() => {
351
- var _document$activeEleme13;
484
+ var _document$activeEleme15;
352
485
 
353
- expect((_document$activeEleme13 = document.activeElement) === null || _document$activeEleme13 === void 0 ? void 0 : _document$activeEleme13.id).toBe(inputNode.id);
486
+ expect((_document$activeEleme15 = document.activeElement) === null || _document$activeEleme15 === void 0 ? void 0 : _document$activeEleme15.id).toBe(inputNode.id);
354
487
  }, 0);
355
488
  jest.useRealTimers();
356
489
  });
@@ -171,6 +171,20 @@ export const TokenRemoveButtonsHidden = () => {
171
171
  });
172
172
  };
173
173
  TokenRemoveButtonsHidden.displayName = "TokenRemoveButtonsHidden";
174
+ export const WithVisibleTokenCount = () => {
175
+ const [tokens, setTokens] = useState([...mockTokens].slice(0, 5));
176
+
177
+ const onTokenRemove = tokenId => {
178
+ setTokens(tokens.filter(token => token.id !== tokenId));
179
+ };
180
+
181
+ return /*#__PURE__*/React.createElement(TextInputWithTokens, {
182
+ tokens: tokens,
183
+ onTokenRemove: onTokenRemove,
184
+ visibleTokenCount: 2
185
+ });
186
+ };
187
+ WithVisibleTokenCount.displayName = "WithVisibleTokenCount";
174
188
  export const Unstyled = () => {
175
189
  const [tokens, setTokens] = useState([...mockTokens].slice(0, 2));
176
190
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@primer/components",
3
- "version": "31.0.2-rc.c7dafefb",
3
+ "version": "31.1.0-rc.71d00800",
4
4
  "description": "Primer react components",
5
5
  "main": "lib/index.js",
6
6
  "module": "lib-esm/index.js",
@@ -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'
2
2
  import {omit} from '@styled-system/props'
3
3
  import {FocusKeys} from './behaviors/focusZone'
4
4
  import {useCombinedRefs} from './hooks/useCombinedRefs'
@@ -11,6 +11,7 @@ import {useProvidedRefOrCreate} from './hooks'
11
11
  import UnstyledTextInput from './_UnstyledTextInput'
12
12
  import TextInputWrapper from './_TextInputWrapper'
13
13
  import Box from './Box'
14
+ import Text from './Text'
14
15
  import {isFocusable} from './utils/iterateFocusableElements'
15
16
 
16
17
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -48,8 +49,19 @@ type TextInputWithTokensInternalProps<TokenComponentType extends AnyReactCompone
48
49
  * Whether the remove buttons should be rendered in the tokens
49
50
  */
50
51
  hideTokenRemoveButtons?: boolean
52
+ /**
53
+ * The number of tokens to display before truncating
54
+ */
55
+ visibleTokenCount?: number
51
56
  } & TextInputProps
52
57
 
58
+ const overflowCountFontSizeMap: Record<TokenSizeKeys, number> = {
59
+ small: 0,
60
+ medium: 1,
61
+ large: 1,
62
+ extralarge: 2
63
+ }
64
+
53
65
  // using forwardRef is important so that other components (ex. Autocomplete) can use the ref
54
66
  function TextInputWithTokensInnerComponent<TokenComponentType extends AnyReactComponent>(
55
67
  {
@@ -71,6 +83,7 @@ function TextInputWithTokensInnerComponent<TokenComponentType extends AnyReactCo
71
83
  minWidth: minWidthProp,
72
84
  maxWidth: maxWidthProp,
73
85
  variant: variantProp,
86
+ visibleTokenCount,
74
87
  ...rest
75
88
  }: TextInputWithTokensInternalProps<TokenComponentType> & {
76
89
  selectedTokenIndex: number | undefined
@@ -78,11 +91,12 @@ function TextInputWithTokensInnerComponent<TokenComponentType extends AnyReactCo
78
91
  },
79
92
  externalRef: React.ForwardedRef<HTMLInputElement>
80
93
  ) {
81
- const {onFocus, onKeyDown, ...inputPropsRest} = omit(rest)
94
+ const {onBlur, onFocus, onKeyDown, ...inputPropsRest} = omit(rest)
82
95
  const ref = useProvidedRefOrCreate<HTMLInputElement>(externalRef as React.RefObject<HTMLInputElement>)
83
96
  const localInputRef = useRef<HTMLInputElement>(null)
84
97
  const combinedInputRef = useCombinedRefs(localInputRef, ref)
85
98
  const [selectedTokenIndex, setSelectedTokenIndex] = useState<number | undefined>()
99
+ const [tokensAreTruncated, setTokensAreTruncated] = useState<boolean>(Boolean(visibleTokenCount))
86
100
  const {containerRef} = useFocusZone(
87
101
  {
88
102
  focusOutBehavior: 'wrap',
@@ -144,18 +158,42 @@ function TextInputWithTokensInnerComponent<TokenComponentType extends AnyReactCo
144
158
 
145
159
  const handleTokenBlur: FocusEventHandler = () => {
146
160
  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)
147
170
  }
148
171
 
149
- const handleTokenKeyUp: KeyboardEventHandler = e => {
150
- if (e.key === 'Escape') {
172
+ const handleTokenKeyUp: KeyboardEventHandler = event => {
173
+ if (event.key === 'Escape') {
151
174
  ref.current?.focus()
152
175
  }
153
176
  }
154
177
 
155
- const handleInputFocus: FocusEventHandler = e => {
156
- onFocus && onFocus(e)
178
+ const handleInputFocus: FocusEventHandler = event => {
179
+ onFocus && onFocus(event)
157
180
  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)
158
195
  }
196
+
159
197
  const handleInputKeyDown: KeyboardEventHandler = e => {
160
198
  if (onKeyDown) {
161
199
  onKeyDown(e)
@@ -187,6 +225,16 @@ function TextInputWithTokensInnerComponent<TokenComponentType extends AnyReactCo
187
225
  }
188
226
  }
189
227
 
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
+
190
238
  return (
191
239
  <TextInputWrapper
192
240
  block={block}
@@ -199,6 +247,7 @@ function TextInputWithTokensInnerComponent<TokenComponentType extends AnyReactCo
199
247
  minWidth={minWidthProp}
200
248
  maxWidth={maxWidthProp}
201
249
  variant={variantProp}
250
+ onClick={focusInput}
202
251
  sx={{
203
252
  ...(block
204
253
  ? {
@@ -251,19 +300,21 @@ function TextInputWithTokensInnerComponent<TokenComponentType extends AnyReactCo
251
300
  ref={combinedInputRef}
252
301
  disabled={disabled}
253
302
  onFocus={handleInputFocus}
303
+ onBlur={handleInputBlur}
254
304
  onKeyDown={handleInputKeyDown}
255
305
  type="text"
256
306
  sx={{height: '100%'}}
257
307
  {...inputPropsRest}
258
308
  />
259
309
  </Box>
260
- {tokens.length && TokenComponent
261
- ? tokens.map(({id, ...tokenRest}, i) => (
310
+ {TokenComponent
311
+ ? visibleTokens.map(({id, ...tokenRest}, i) => (
262
312
  <TokenComponent
263
313
  key={id}
264
314
  onFocus={handleTokenFocus(i)}
265
315
  onBlur={handleTokenBlur}
266
316
  onKeyUp={handleTokenKeyUp}
317
+ onClick={preventTokenClickPropagation}
267
318
  isSelected={selectedTokenIndex === i}
268
319
  onRemove={() => {
269
320
  handleTokenRemove(id)
@@ -275,6 +326,11 @@ function TextInputWithTokensInnerComponent<TokenComponentType extends AnyReactCo
275
326
  />
276
327
  ))
277
328
  : null}
329
+ {tokensAreTruncated ? (
330
+ <Text color="fg.muted" fontSize={size && overflowCountFontSizeMap[size]}>
331
+ +{tokens.length - visibleTokens.length}
332
+ </Text>
333
+ ) : null}
278
334
  </Box>
279
335
  </TextInputWrapper>
280
336
  )
@@ -45,6 +45,7 @@ const TextInputWrapper = styled.span<StyledWrapperProps>`
45
45
  border-radius: ${get('radii.2')};
46
46
  outline: none;
47
47
  box-shadow: ${get('shadows.primer.shadow.inset')};
48
+ cursor: text;
48
49
 
49
50
  ${props => {
50
51
  if (props.hasIcon) {