@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.
- package/CHANGELOG.md +5 -1
- package/dist/browser.esm.js +11 -10
- package/dist/browser.esm.js.map +1 -1
- package/dist/browser.umd.js +11 -10
- package/dist/browser.umd.js.map +1 -1
- package/docs/content/TextInputWithTokens.mdx +114 -0
- package/lib/TextInputWithTokens.d.ts +4 -0
- package/lib/TextInputWithTokens.js +61 -8
- package/lib/_TextInputWrapper.js +1 -1
- package/lib/__tests__/TextInputWithTokens.test.js +149 -9
- package/lib/stories/TextInputWithTokens.stories.js +18 -1
- package/lib-esm/TextInputWithTokens.d.ts +4 -0
- package/lib-esm/TextInputWithTokens.js +60 -8
- package/lib-esm/_TextInputWrapper.js +1 -1
- package/lib-esm/__tests__/TextInputWithTokens.test.js +142 -9
- package/lib-esm/stories/TextInputWithTokens.stories.js +14 -0
- package/package.json +1 -1
- package/src/TextInputWithTokens.tsx +64 -8
- package/src/_TextInputWrapper.tsx +1 -0
- package/src/__tests__/TextInputWithTokens.test.tsx +133 -1
- package/src/__tests__/__snapshots__/Autocomplete.test.tsx.snap +7 -0
- package/src/__tests__/__snapshots__/TextInput.test.tsx.snap +6 -0
- package/src/__tests__/__snapshots__/TextInputWithTokens.test.tsx.snap +463 -0
- package/src/stories/TextInputWithTokens.stories.tsx +9 -0
- package/stats.html +1 -1
@@ -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
|
-
|
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 =
|
110
|
-
if (
|
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 =
|
118
|
-
onFocus && onFocus(
|
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))),
|
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,
|
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$
|
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$
|
242
|
-
expect((_document$
|
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$
|
460
|
+
var _document$activeEleme14;
|
328
461
|
|
329
|
-
expect((_document$
|
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$
|
484
|
+
var _document$activeEleme15;
|
352
485
|
|
353
|
-
expect((_document$
|
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,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 =
|
150
|
-
if (
|
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 =
|
156
|
-
onFocus && onFocus(
|
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
|
-
{
|
261
|
-
?
|
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
|
)
|