@primer/components 31.0.2-rc.1e80de40 → 31.0.2-rc.95622264

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.
@@ -9,9 +9,17 @@ import Token from './Token/Token';
9
9
  import { useProvidedRefOrCreate } from './hooks';
10
10
  import UnstyledTextInput from './_UnstyledTextInput';
11
11
  import TextInputWrapper from './_TextInputWrapper';
12
- import Box from './Box'; // eslint-disable-next-line @typescript-eslint/no-explicit-any
12
+ import Box from './Box';
13
+ import Text from './Text';
14
+ import { isFocusable } from './utils/iterateFocusableElements'; // eslint-disable-next-line @typescript-eslint/no-explicit-any
15
+
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
13
22
 
14
- // using forwardRef is important so that other components (ex. Autocomplete) can use the ref
15
23
  function TextInputWithTokensInnerComponent({
16
24
  icon: IconComponent,
17
25
  contrast,
@@ -31,9 +39,11 @@ function TextInputWithTokensInnerComponent({
31
39
  minWidth: minWidthProp,
32
40
  maxWidth: maxWidthProp,
33
41
  variant: variantProp,
42
+ visibleTokenCount,
34
43
  ...rest
35
44
  }, externalRef) {
36
45
  const {
46
+ onBlur,
37
47
  onFocus,
38
48
  onKeyDown,
39
49
  ...inputPropsRest
@@ -42,6 +52,7 @@ function TextInputWithTokensInnerComponent({
42
52
  const localInputRef = useRef(null);
43
53
  const combinedInputRef = useCombinedRefs(localInputRef, ref);
44
54
  const [selectedTokenIndex, setSelectedTokenIndex] = useState();
55
+ const [tokensAreTruncated, setTokensAreTruncated] = useState(Boolean(visibleTokenCount));
45
56
  const {
46
57
  containerRef
47
58
  } = useFocusZone({
@@ -76,14 +87,25 @@ function TextInputWithTokensInnerComponent({
76
87
  }, [selectedTokenIndex]);
77
88
 
78
89
  const handleTokenRemove = tokenId => {
79
- onTokenRemove(tokenId);
90
+ onTokenRemove(tokenId); // HACK: wait a tick for the the token node to be removed from the DOM
80
91
 
81
- if (selectedTokenIndex) {
82
- var _containerRef$current2;
92
+ setTimeout(() => {
93
+ var _containerRef$current2, _containerRef$current3;
83
94
 
84
- const nextElementToFocus = (_containerRef$current2 = containerRef.current) === null || _containerRef$current2 === void 0 ? void 0 : _containerRef$current2.children[selectedTokenIndex];
85
- nextElementToFocus.focus();
86
- }
95
+ const nextElementToFocus = (_containerRef$current2 = containerRef.current) === null || _containerRef$current2 === void 0 ? void 0 : _containerRef$current2.children[selectedTokenIndex || 0]; // when removing the first token by keying "Backspace" or "Delete",
96
+ // `nextFocusableElement` is the div that wraps the input
97
+
98
+ const firstFocusable = nextElementToFocus && isFocusable(nextElementToFocus) ? nextElementToFocus : Array.from(((_containerRef$current3 = containerRef.current) === null || _containerRef$current3 === void 0 ? void 0 : _containerRef$current3.children) || []).find(el => isFocusable(el));
99
+
100
+ if (firstFocusable) {
101
+ firstFocusable.focus();
102
+ } else {
103
+ var _ref$current;
104
+
105
+ // if there are no tokens left, focus the input
106
+ (_ref$current = ref.current) === null || _ref$current === void 0 ? void 0 : _ref$current.focus();
107
+ }
108
+ }, 0);
87
109
  };
88
110
 
89
111
  const handleTokenFocus = tokenIndex => () => {
@@ -91,30 +113,55 @@ function TextInputWithTokensInnerComponent({
91
113
  };
92
114
 
93
115
  const handleTokenBlur = () => {
94
- 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);
95
127
  };
96
128
 
97
- const handleTokenKeyUp = e => {
98
- if (e.key === 'Escape') {
99
- var _ref$current;
129
+ const handleTokenKeyUp = event => {
130
+ if (event.key === 'Escape') {
131
+ var _ref$current2;
100
132
 
101
- (_ref$current = ref.current) === null || _ref$current === void 0 ? void 0 : _ref$current.focus();
133
+ (_ref$current2 = ref.current) === null || _ref$current2 === void 0 ? void 0 : _ref$current2.focus();
102
134
  }
103
135
  };
104
136
 
105
- const handleInputFocus = e => {
106
- onFocus && onFocus(e);
137
+ const handleInputFocus = event => {
138
+ onFocus && onFocus(event);
107
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);
108
155
  };
109
156
 
110
157
  const handleInputKeyDown = e => {
111
- var _ref$current2;
158
+ var _ref$current3;
112
159
 
113
160
  if (onKeyDown) {
114
161
  onKeyDown(e);
115
162
  }
116
163
 
117
- if ((_ref$current2 = ref.current) !== null && _ref$current2 !== void 0 && _ref$current2.value) {
164
+ if ((_ref$current3 = ref.current) !== null && _ref$current3 !== void 0 && _ref$current3.value) {
118
165
  return;
119
166
  }
120
167
 
@@ -135,13 +182,24 @@ function TextInputWithTokensInnerComponent({
135
182
 
136
183
 
137
184
  setTimeout(() => {
138
- var _ref$current3;
185
+ var _ref$current4;
139
186
 
140
- (_ref$current3 = ref.current) === null || _ref$current3 === void 0 ? void 0 : _ref$current3.select();
187
+ (_ref$current4 = ref.current) === null || _ref$current4 === void 0 ? void 0 : _ref$current4.select();
141
188
  }, 0);
142
189
  }
143
190
  };
144
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;
145
203
  return /*#__PURE__*/React.createElement(TextInputWrapper, {
146
204
  block: block,
147
205
  className: className,
@@ -153,6 +211,7 @@ function TextInputWithTokensInnerComponent({
153
211
  minWidth: minWidthProp,
154
212
  maxWidth: maxWidthProp,
155
213
  variant: variantProp,
214
+ onClick: focusInput,
156
215
  sx: { ...(block ? {
157
216
  display: 'flex',
158
217
  width: '100%'
@@ -192,12 +251,13 @@ function TextInputWithTokensInnerComponent({
192
251
  ref: combinedInputRef,
193
252
  disabled: disabled,
194
253
  onFocus: handleInputFocus,
254
+ onBlur: handleInputBlur,
195
255
  onKeyDown: handleInputKeyDown,
196
256
  type: "text",
197
257
  sx: {
198
258
  height: '100%'
199
259
  }
200
- }, inputPropsRest))), tokens.length && TokenComponent ? tokens.map(({
260
+ }, inputPropsRest))), TokenComponent ? visibleTokens.map(({
201
261
  id,
202
262
  ...tokenRest
203
263
  }, i) => /*#__PURE__*/React.createElement(TokenComponent, _extends({
@@ -205,6 +265,7 @@ function TextInputWithTokensInnerComponent({
205
265
  onFocus: handleTokenFocus(i),
206
266
  onBlur: handleTokenBlur,
207
267
  onKeyUp: handleTokenKeyUp,
268
+ onClick: preventTokenClickPropagation,
208
269
  isSelected: selectedTokenIndex === i,
209
270
  onRemove: () => {
210
271
  handleTokenRemove(id);
@@ -212,7 +273,10 @@ function TextInputWithTokensInnerComponent({
212
273
  hideRemoveButton: hideTokenRemoveButtons,
213
274
  size: size,
214
275
  tabIndex: 0
215
- }, 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));
216
280
  }
217
281
 
218
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();
@@ -308,6 +441,52 @@ describe('TextInputWithTokens', () => {
308
441
  });
309
442
  expect(onRemoveMock).toHaveBeenCalledWith(mockTokens[4].id);
310
443
  });
444
+ it('moves focus to the next token when removing the first token', () => {
445
+ jest.useFakeTimers();
446
+ const onRemoveMock = jest.fn();
447
+ const {
448
+ getByText
449
+ } = HTMLRender( /*#__PURE__*/React.createElement(TextInputWithTokens, {
450
+ tokens: [...mockTokens].slice(0, 2),
451
+ onTokenRemove: onRemoveMock
452
+ }));
453
+ const tokenNode = getByText(mockTokens[0].text);
454
+ fireEvent.focus(tokenNode);
455
+ fireEvent.keyDown(tokenNode, {
456
+ key: 'Backspace'
457
+ });
458
+ jest.runAllTimers();
459
+ setTimeout(() => {
460
+ var _document$activeEleme14;
461
+
462
+ expect((_document$activeEleme14 = document.activeElement) === null || _document$activeEleme14 === void 0 ? void 0 : _document$activeEleme14.textContent).toBe(mockTokens[1].text);
463
+ }, 0);
464
+ jest.useRealTimers();
465
+ });
466
+ it('moves focus to the input when the last token is removed', () => {
467
+ jest.useFakeTimers();
468
+ const onRemoveMock = jest.fn();
469
+ const {
470
+ getByText,
471
+ getByLabelText
472
+ } = HTMLRender( /*#__PURE__*/React.createElement(LabelledTextInputWithTokens, {
473
+ tokens: [mockTokens[0]],
474
+ onTokenRemove: onRemoveMock
475
+ }));
476
+ const tokenNode = getByText(mockTokens[0].text);
477
+ const inputNode = getByLabelText('Tokens');
478
+ fireEvent.focus(tokenNode);
479
+ fireEvent.keyDown(tokenNode, {
480
+ key: 'Backspace'
481
+ });
482
+ jest.runAllTimers();
483
+ setTimeout(() => {
484
+ var _document$activeEleme15;
485
+
486
+ expect((_document$activeEleme15 = document.activeElement) === null || _document$activeEleme15 === void 0 ? void 0 : _document$activeEleme15.id).toBe(inputNode.id);
487
+ }, 0);
488
+ jest.useRealTimers();
489
+ });
311
490
  it('calls onKeyDown', () => {
312
491
  const onRemoveMock = jest.fn();
313
492
  const onKeyDownMock = jest.fn();
@@ -53,7 +53,7 @@ const mockTokens = [{
53
53
  id: 21
54
54
  }];
55
55
  export const Default = () => {
56
- const [tokens, setTokens] = useState([...mockTokens].slice(0, 2));
56
+ const [tokens, setTokens] = useState([...mockTokens].slice(0, 3));
57
57
 
58
58
  const onTokenRemove = tokenId => {
59
59
  setTokens(tokens.filter(token => token.id !== tokenId));
@@ -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.1e80de40",
3
+ "version": "31.0.2-rc.95622264",
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,8 @@ 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'
15
+ import {isFocusable} from './utils/iterateFocusableElements'
14
16
 
15
17
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
16
18
  type AnyReactComponent = React.ComponentType<any>
@@ -47,8 +49,19 @@ type TextInputWithTokensInternalProps<TokenComponentType extends AnyReactCompone
47
49
  * Whether the remove buttons should be rendered in the tokens
48
50
  */
49
51
  hideTokenRemoveButtons?: boolean
52
+ /**
53
+ * The number of tokens to display before truncating
54
+ */
55
+ visibleTokenCount?: number
50
56
  } & TextInputProps
51
57
 
58
+ const overflowCountFontSizeMap: Record<TokenSizeKeys, number> = {
59
+ small: 0,
60
+ medium: 1,
61
+ large: 1,
62
+ extralarge: 2
63
+ }
64
+
52
65
  // using forwardRef is important so that other components (ex. Autocomplete) can use the ref
53
66
  function TextInputWithTokensInnerComponent<TokenComponentType extends AnyReactComponent>(
54
67
  {
@@ -70,6 +83,7 @@ function TextInputWithTokensInnerComponent<TokenComponentType extends AnyReactCo
70
83
  minWidth: minWidthProp,
71
84
  maxWidth: maxWidthProp,
72
85
  variant: variantProp,
86
+ visibleTokenCount,
73
87
  ...rest
74
88
  }: TextInputWithTokensInternalProps<TokenComponentType> & {
75
89
  selectedTokenIndex: number | undefined
@@ -77,11 +91,12 @@ function TextInputWithTokensInnerComponent<TokenComponentType extends AnyReactCo
77
91
  },
78
92
  externalRef: React.ForwardedRef<HTMLInputElement>
79
93
  ) {
80
- const {onFocus, onKeyDown, ...inputPropsRest} = omit(rest)
94
+ const {onBlur, onFocus, onKeyDown, ...inputPropsRest} = omit(rest)
81
95
  const ref = useProvidedRefOrCreate<HTMLInputElement>(externalRef as React.RefObject<HTMLInputElement>)
82
96
  const localInputRef = useRef<HTMLInputElement>(null)
83
97
  const combinedInputRef = useCombinedRefs(localInputRef, ref)
84
98
  const [selectedTokenIndex, setSelectedTokenIndex] = useState<number | undefined>()
99
+ const [tokensAreTruncated, setTokensAreTruncated] = useState<boolean>(Boolean(visibleTokenCount))
85
100
  const {containerRef} = useFocusZone(
86
101
  {
87
102
  focusOutBehavior: 'wrap',
@@ -117,10 +132,24 @@ function TextInputWithTokensInnerComponent<TokenComponentType extends AnyReactCo
117
132
  const handleTokenRemove = (tokenId: string | number) => {
118
133
  onTokenRemove(tokenId)
119
134
 
120
- if (selectedTokenIndex) {
121
- const nextElementToFocus = containerRef.current?.children[selectedTokenIndex] as HTMLElement
122
- nextElementToFocus.focus()
123
- }
135
+ // HACK: wait a tick for the the token node to be removed from the DOM
136
+ setTimeout(() => {
137
+ const nextElementToFocus = containerRef.current?.children[selectedTokenIndex || 0] as HTMLElement | undefined
138
+
139
+ // when removing the first token by keying "Backspace" or "Delete",
140
+ // `nextFocusableElement` is the div that wraps the input
141
+ const firstFocusable =
142
+ nextElementToFocus && isFocusable(nextElementToFocus)
143
+ ? nextElementToFocus
144
+ : (Array.from(containerRef.current?.children || []) as HTMLElement[]).find(el => isFocusable(el))
145
+
146
+ if (firstFocusable) {
147
+ firstFocusable.focus()
148
+ } else {
149
+ // if there are no tokens left, focus the input
150
+ ref.current?.focus()
151
+ }
152
+ }, 0)
124
153
  }
125
154
 
126
155
  const handleTokenFocus: (tokenIndex: number) => FocusEventHandler = tokenIndex => () => {
@@ -129,18 +158,42 @@ function TextInputWithTokensInnerComponent<TokenComponentType extends AnyReactCo
129
158
 
130
159
  const handleTokenBlur: FocusEventHandler = () => {
131
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)
132
170
  }
133
171
 
134
- const handleTokenKeyUp: KeyboardEventHandler = e => {
135
- if (e.key === 'Escape') {
172
+ const handleTokenKeyUp: KeyboardEventHandler = event => {
173
+ if (event.key === 'Escape') {
136
174
  ref.current?.focus()
137
175
  }
138
176
  }
139
177
 
140
- const handleInputFocus: FocusEventHandler = e => {
141
- onFocus && onFocus(e)
178
+ const handleInputFocus: FocusEventHandler = event => {
179
+ onFocus && onFocus(event)
142
180
  setSelectedTokenIndex(undefined)
181
+ visibleTokenCount && setTokensAreTruncated(false)
143
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)
195
+ }
196
+
144
197
  const handleInputKeyDown: KeyboardEventHandler = e => {
145
198
  if (onKeyDown) {
146
199
  onKeyDown(e)
@@ -172,6 +225,16 @@ function TextInputWithTokensInnerComponent<TokenComponentType extends AnyReactCo
172
225
  }
173
226
  }
174
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
+
175
238
  return (
176
239
  <TextInputWrapper
177
240
  block={block}
@@ -184,6 +247,7 @@ function TextInputWithTokensInnerComponent<TokenComponentType extends AnyReactCo
184
247
  minWidth={minWidthProp}
185
248
  maxWidth={maxWidthProp}
186
249
  variant={variantProp}
250
+ onClick={focusInput}
187
251
  sx={{
188
252
  ...(block
189
253
  ? {
@@ -236,19 +300,21 @@ function TextInputWithTokensInnerComponent<TokenComponentType extends AnyReactCo
236
300
  ref={combinedInputRef}
237
301
  disabled={disabled}
238
302
  onFocus={handleInputFocus}
303
+ onBlur={handleInputBlur}
239
304
  onKeyDown={handleInputKeyDown}
240
305
  type="text"
241
306
  sx={{height: '100%'}}
242
307
  {...inputPropsRest}
243
308
  />
244
309
  </Box>
245
- {tokens.length && TokenComponent
246
- ? tokens.map(({id, ...tokenRest}, i) => (
310
+ {TokenComponent
311
+ ? visibleTokens.map(({id, ...tokenRest}, i) => (
247
312
  <TokenComponent
248
313
  key={id}
249
314
  onFocus={handleTokenFocus(i)}
250
315
  onBlur={handleTokenBlur}
251
316
  onKeyUp={handleTokenKeyUp}
317
+ onClick={preventTokenClickPropagation}
252
318
  isSelected={selectedTokenIndex === i}
253
319
  onRemove={() => {
254
320
  handleTokenRemove(id)
@@ -260,6 +326,11 @@ function TextInputWithTokensInnerComponent<TokenComponentType extends AnyReactCo
260
326
  />
261
327
  ))
262
328
  : null}
329
+ {tokensAreTruncated ? (
330
+ <Text color="fg.muted" fontSize={size && overflowCountFontSizeMap[size]}>
331
+ +{tokens.length - visibleTokens.length}
332
+ </Text>
333
+ ) : null}
263
334
  </Box>
264
335
  </TextInputWrapper>
265
336
  )