Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@
"@bem-react/classname": "^1.6.0",
"@gravity-ui/date-utils": "^2.1.0",
"@gravity-ui/icons": "^2.11.0",
"fuzzy-search": "^3.2.1",
"lodash": "^4.17.21",
"universal-cookie": "^7.2.0"
},
Expand Down Expand Up @@ -88,6 +89,7 @@
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.1.0",
"@testing-library/user-event": "^14.5.2",
"@types/fuzzy-search": "^2.1.5",
"@types/jest": "^29.5.14",
"@types/lodash": "^4.17.14",
"@types/react": "^18.3.18",
Expand Down
25 changes: 25 additions & 0 deletions src/components/TokenizedInput/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
## TokenizedInput

This component is for writing queries/filters and working with them as tokens. Here, a token is an expression (for example, for the format `key = value` the token would be `User = Ivan`). A distinguishing feature is full keyboard and mouse support (including clicking suggestions).

### Useful notes when using the component

- The component is modular; each part can be replaced if needed
- `<Wrapper />` — wrapper that handles key presses; must return `children`
- `<TokenList />` — list component that renders an array of tokens
- `<Token />` — token component; renders token fields; two variants: `regular` — a standalone full token, and `new` — a new token (looks like a single line and must be the only one)
- `<Field />` — token field component
- `<Suggestions />` — suggestions component
- For convenience there are shared hooks `useTokenizedInput` and `useTokenizedInputComponents`, and specific hooks `useTokenizedInputWrapper`, `useTokenizedInputList`, `useTokenizedInputNewToken`, `useTokenizedInputRegularToken`, `useTokenizedInputField`, `useTokenizedInputSuggestions` for the matching components. There is also `tokenizedInputUtils` with utilities.

### Hotkeys

- `Cmd + Arrow` (Mac) / `Ctrl + Alt + Arrow` (Win/Linux) — move between tokens
- `Option + Arrow` (Mac) / `Ctrl + Arrow` (Win/Linux) — move between token fields
- `Cmd + Backspace` (Mac) / `Ctrl + Alt + Backspace` (Win/Linux) — delete the current token
- `Cmd + Z` (Mac) / `Ctrl + Z` (Win/Linux) — undo
- `Cmd + Shift + Z` (Mac) / `Ctrl + Y` or `Ctrl + Shift + Z` (Win/Linux) — redo
- `Escape` — close the suggestions menu; press again to remove focus
- `Cmd + I` (Mac) / `Ctrl + I` (Win/Linux) — open the suggestions menu
- `Cmd + Enter` (Mac) / `Ctrl + Enter` (Win/Linux) — finish the current token and go to the next (when the suggestions menu is closed)
- `Enter` — select a suggestion / finish the current token and go to the next (when the suggestions menu is closed)
295 changes: 295 additions & 0 deletions src/components/TokenizedInput/TokenizedInput.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,295 @@
/* stylelint-disable declaration-no-important */

.gc-tokenized-input {
&__wrapper {
position: relative;

display: flex;
gap: 2px;

width: 100%;
padding: 2px;
padding-inline-end: 34px;

border: 1px solid var(--g-color-line-generic);
border-radius: var(--g-border-radius-l);
background-color: var(--g-color-base-background);

&,
* {
box-sizing: border-box !important;
}

&_disabled {
pointer-events: none;

background-color: var(--g-color-base-generic-accent-disabled);

.gc-tokenized-input__token-wrapper:not(.gc-tokenized-input__token-wrapper_new) {
background: none;
box-shadow:
inset 1px 1px 0 0 var(--g-color-base-generic-accent-disabled),
inset -1px -1px 0 0 var(--g-color-base-generic-accent-disabled);
}
}

&_focused {
border-color: var(--g-color-line-generic-active);
}
}

&__clear-button {
position: absolute;
inset-block-start: 0;
inset-inline-end: 0;

display: flex;
justify-content: center;
align-items: center;

height: 100%;
padding: 3px 8px;

cursor: pointer;

color: unset;
border: unset;
outline: none;
background-color: unset;
}

&__token-list {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 2px;

width: 100%;
}

&__token {
&-wrapper {
display: flex;
flex-wrap: nowrap;

max-width: 100%;
height: 24px;

border-radius: var(--g-border-radius-s);
background-color: var(--g-color-base-generic-accent-disabled);

&_new {
flex-grow: 1;

min-width: 300px;

background-color: transparent;
}

&_error {
border: 1px solid var(--g-color-text-danger);

.gc-tokenized-input__field-visible-span {
inset-block-start: -1px;
}
}
}

&-remove-button {
display: flex;
justify-content: center;
align-items: center;

height: 100%;
padding: 3px 8px;
padding-inline-start: 4px;

cursor: pointer;

color: unset;
border: unset;
border-start-end-radius: var(--g-border-radius-s);
border-end-end-radius: var(--g-border-radius-s);
outline: none;
background-color: unset;

&:hover {
background-color: var(--g-color-base-generic-hover);
}
}
}

&__field {
&-wrapper {
position: relative;

flex-shrink: 0;

min-width: 1ch;
max-width: 100%;
height: 100%;
padding: 3px 4px;

transition: color 0.2s;

&:last-of-type {
flex-shrink: 1;
overflow: hidden;
}

&:first-child {
padding-inline-start: 8px;

border-start-start-radius: var(--g-border-radius-s);
border-end-start-radius: var(--g-border-radius-s);

.gc-tokenized-input__field-input {
inset-inline-start: 8px;

width: calc(100% - 8px);
}
}

&:last-child {
padding-inline-end: 8px;

border-start-end-radius: var(--g-border-radius-s);
border-end-end-radius: var(--g-border-radius-s);

.gc-tokenized-input__field-input {
width: calc(100% - 8px);
}
}

&_empty {
flex-shrink: 1;

width: 100%;
}

&_focused {
background-color: var(--g-color-base-generic-hover);
}

&_hoverable {
&:hover {
background-color: var(--g-color-base-generic-hover);
}
}

&_hidden {
width: 0;
min-width: 0;
max-width: 0;
padding: 0;

&:last-child,
&:first-child {
padding: 0;
}

.gc-tokenized-input__field-input {
width: 0;
min-width: 0;
max-width: 0;
padding: 0;
}
}
}

&-input {
position: absolute;
inset-block-start: 0;
inset-inline-start: 4px;

width: calc(100% - 4px);
min-width: 2ch;
height: 100%;
padding: 0;

font-family: inherit;
font-size: inherit;
caret-color: var(--g-color-text-primary);

color: transparent !important;
border: none;
outline: none;
background: none;
font-feature-settings: none;
font-variant-ligatures: none;

&::placeholder {
color: var(--g-color-text-hint);
font-weight: normal;
}
}

&-visible-span {
position: relative;
display: inline-block;

min-width: 1ch;

white-space: pre;
font-feature-settings: none;
font-variant-ligatures: none;

text-overflow: ellipsis;
overflow: hidden;
width: 100%;

&_focused {
color: var(--g-color-text-primary);
}

&_placeholder {
color: transparent !important;
}
}

&-popup {
.g-popup__content {
min-width: unset;
max-width: unset;
}
}
}

&__suggestions-list {
&-wrapper {
display: flex;
overflow-y: auto;
flex-direction: column;

max-width: 600px;
min-height: 20px;
max-height: 200px;

&_loading {
width: 300px;
height: 20px;
overflow: hidden;
}

&_empty {
width: 300px;
overflow: hidden;
}

&_full-width {
width: 100%;
max-width: none;
}
}

&-item {
overflow-x: hidden;

width: 100%;
margin: 0;
padding: 2px 12px;

cursor: pointer;
}
}
}
Loading
Loading