Skip to content

Commit

Permalink
Component that renders a text input with tokens (#1489)
Browse files Browse the repository at this point in the history
* adds token components

* adds component that renders a text input with tokens

* Format token files with Prettier

* Update docs/content/Token.mdx

Co-authored-by: Cole Bemis <colebemis@github.com>

* Update src/Token/TokenBase.tsx

Co-authored-by: Cole Bemis <colebemis@github.com>

* addresses first batch of changes requested in code review

* Update src/stories/TextInputWithTokens.stories.tsx

Co-authored-by: Cole Bemis <colebemis@github.com>

* fixes issue label remove button in Storybook

* refactors TokenBase to do less inside the 'styled' function

* renames LabelToken to IssueLabelToken

* renames TokenProfile to ProfileToken

* allows link tokens to have clickable 'x', fixes keyboard nav for IssueLabelTokens, fixes IssueLabelToken import typo

* renames token size keys from abbreviated sizes to full word

* Update src/TextInputWithTokens.tsx

Co-authored-by: Cole Bemis <colebemis@github.com>

* Update src/TextInputWithTokens.tsx

Co-authored-by: Cole Bemis <colebemis@github.com>

* Update docs/content/TextInputWithTokens.mdx

Co-authored-by: Cole Bemis <colebemis@github.com>

* pulls in updates to Token components, changes 'tokenSizeVariant' prop to 'size'

* define type of 'tokens' prop based on the type of 'tokenComponent' prop

* runs lint:fix

* runs lint:fix

* updates comments in TextInputWithTokens

* changes IssueLabelToken's implementation of color transform to match Memex and dotcom

* updates package-lock

* rms tinycolor2 types

* improves Storybook stories

* improves Storybook stories, fixes maxHeight and preventTokenWrapping style bugs

* adds missing dependency to IssueLabelToken style object

* configure Storybook controls for token stories

* rms storybook-addon/knobs

* improve IssueLabelToken isSelected+focus style, fix lineHeight

* adds tests

* fixes ESLint errors

* Update src/Token/Token.tsx

Co-authored-by: Cole Bemis <colebemis@github.com>

* adds tests

* adds more tests, fixes ESLint errors

* removes Knobs tab from Storybook

* improve click target area for interactive tokens

* updates token size keys, renames Token test file

* updates docs

* updates snapshots

* updates default 'size' prop, updates docs

* fixes lint errors in docs mdx file

* reverts changes to TextInput

* Update docs/content/Token.mdx

Co-authored-by: Cole Bemis <colebemis@github.com>

* addresses more code review feedback

* Update docs/content/TextInputTokens.mdx

Co-authored-by: Cole Bemis <colebemis@github.com>

* Update src/index.ts

Co-authored-by: Cole Bemis <colebemis@github.com>

* updates snapshots for AvatarToken name change

* adds TextInputWithTokens to React docs sidebar

* updates textinputwithtokens snapshots

* renders prop tables in Token docs

* adds props table to TextInputWithTokens docs

* fixes bundlesize issue, improves cursor UX for non-interactive tokens

* updates snapshots

* Rename TextInputWithTokens markdown file

* Create thin-humans-tell.md

* Update thin-humans-tell.md

* Update src/Token/TokenBase.tsx

Co-authored-by: Cole Bemis <colebemis@github.com>

* updates token snapshots

* adds prop table to TextInputWithTokens docs page

* updates snapshots

* rm Token changeset

* adds TextInputWithTokens changeset

Co-authored-by: Cole Bemis <colebemis@github.com>
  • Loading branch information
mperrotti and colebemis committed Oct 13, 2021
1 parent 05ac5aa commit 273ef29
Show file tree
Hide file tree
Showing 11 changed files with 6,225 additions and 122 deletions.
5 changes: 5 additions & 0 deletions .changeset/stupid-panthers-guess.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@primer/components': minor
---

Add alpha `TextInputWithTokens` component
97 changes: 97 additions & 0 deletions docs/content/TextInputWithTokens.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
---
title: TextInputWithTokens
status: Alpha
source: https://github.com/primer/react/tree/main/src/TextInputWithTokens.tsx
---

import {Props} from '../src/props'
import {TextInputWithTokens} from '@primer/components'

A `TextInputWithTokens` component is used to show multiple values in one field.

It supports all of the features of a [TextInput](/TextInput) component, but it can render a list of [Tokens](/Token) next to the area a user types in.

## Basic Example

```javascript live noinline
const BasicExample = () => {
const [tokens, setTokens] = React.useState([
{text: 'zero', id: 0},
{text: 'one', id: 1},
{text: 'two', id: 2}
])
const onTokenRemove = tokenId => {
setTokens(tokens.filter(token => token.id !== tokenId))
}

return (
<>
<Box as="label" display="block" htmlFor="inputWithTokens-basic">
Basic example tokens
</Box>
<TextInputWithTokens tokens={tokens} onTokenRemove={onTokenRemove} id="inputWithTokens-basic" />
</>
)
}

render(BasicExample)
```

## Component Props

| Name | Type | Default | Description |
| :--------------------- | :------------------------------------ | :----------: | :------------------------------------------------------------------------------------------------------------------------ |
| tokens | `TokenProps[]` | `undefined` | Required. The array of tokens to render |
| onTokenRemove | `(tokenId: string \| number) => void` | `undefined` | Required. The function that gets called when a token is removed |
| tokenComponent | `React.ComponentType<any>` | `Token` | Optional. The component used to render each token |
| maxHeight | `React.CSSProperties['maxHeight']` | `undefined` | Optional. The maximum height of the component. If the content in the input exceeds this height, it will scroll vertically |
| preventTokenWrapping | `boolean` | `false` | Optional. Whether tokens should render inline horizontally. By default, tokens wrap to new lines. |
| size | `TokenSizeKeys` | `extralarge` | Optional. The size of the tokens |
| hideTokenRemoveButtons | `boolean` | `false` | Optional. Whether the remove buttons should be rendered in the tokens |

## Adding and removing tokens

The array passed to the `tokens` prop needs to be manually updated to add and remove tokens.

The function passed to the `onRemoveToken` prop is called when:

- Clicking the remove button in the token
- Pressing the `Backspace` key when the input is empty
- Selecting a token using the arrow keys or by clicking on a token and then pressing the `Backspace` key

There is no function that gets called to "add" a token, so the user needs to be provided with a UI to select tokens.

## Custom token rendering

By default, the `Token` component is used to render the tokens in the input. If this component does not make sense for the kinds of tokens you're rendering, you can pass a component to the `tokenComponent` prop

Example: a `TextInputWithTokens` that renders tokens as `IssueLabelToken`:

```javascript live noinline
const UsingIssueLabelTokens = () => {
const [tokens, setTokens] = React.useState([
{text: 'enhancement', id: 1, fillColor: '#a2eeef'},
{text: 'bug', id: 2, fillColor: '#d73a4a'},
{text: 'good first issue', id: 3, fillColor: '#0cf478'}
])
const onTokenRemove = tokenId => {
setTokens(tokens.filter(token => token.id !== tokenId))
}

return (
<>
<Box as="label" display="block" htmlFor="inputWithTokens-issueLabels">
Issue labels
</Box>
<TextInputWithTokens
tokenComponent={IssueLabelToken}
tokens={tokens}
onTokenRemove={onTokenRemove}
id="inputWithTokens-issueLabels"
/>
</>
)
}

render(<UsingIssueLabelTokens />)
```
2 changes: 2 additions & 0 deletions docs/src/@primer/gatsby-theme-doctocat/nav.yml
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,8 @@
url: /Text
- title: TextInput
url: /TextInput
- title: TextInputWithTokens
url: /TextInputWithTokens
- title: Timeline
url: /Timeline
- title: Token
Expand Down
130 changes: 8 additions & 122 deletions src/TextInput.tsx
Original file line number Diff line number Diff line change
@@ -1,134 +1,19 @@
import classnames from 'classnames'
import React from 'react'
import styled, {css} from 'styled-components'
import {maxWidth, MaxWidthProps, minWidth, MinWidthProps, variant, width, WidthProps} from 'styled-system'
import {get} from './constants'
import sx, {SxProp} from './sx'
import {ComponentProps, Merge} from './utils/types'
import UnstyledTextInput from './_UnstyledTextInput'
import TextInputWrapper from './_TextInputWrapper'

const sizeVariants = variant({
variants: {
small: {
minHeight: '28px',
px: 2,
py: '3px',
fontSize: 0,
lineHeight: '20px'
},
large: {
px: 2,
py: '10px',
fontSize: 3
}
}
})

const Input = styled.input`
border: 0;
font-size: inherit;
font-family: inherit;
background-color: transparent;
-webkit-appearance: none;
color: inherit;
width: 100%;
&:focus {
outline: 0;
}
`

type StyledWrapperProps = {
disabled?: boolean
hasIcon?: boolean
block?: boolean
contrast?: boolean
variant?: 'small' | 'large'
} & WidthProps &
MinWidthProps &
MaxWidthProps &
SxProp

const Wrapper = styled.span<StyledWrapperProps>`
display: inline-flex;
align-items: stretch;
min-height: 34px;
font-size: ${get('fontSizes.1')};
line-height: 20px;
color: ${get('colors.fg.default')};
vertical-align: middle;
background-repeat: no-repeat; // Repeat and position set for form states (success, error, etc)
background-position: right 8px center; // For form validation. This keeps images 8px from right and centered vertically.
border: 1px solid ${get('colors.border.default')};
border-radius: ${get('radii.2')};
outline: none;
box-shadow: ${get('shadows.primer.shadow.inset')};
${props => {
if (props.hasIcon) {
return css`
padding: 0;
`
} else {
return css`
padding: 6px 12px;
`
}
}}
.TextInput-icon {
align-self: center;
color: ${get('colors.fg.muted')};
margin: 0 ${get('space.2')};
flex-shrink: 0;
}
&:focus-within {
border-color: ${get('colors.accent.emphasis')};
box-shadow: ${get('shadows.primer.shadow.focus')};
}
${props =>
props.contrast &&
css`
background-color: ${get('colors.canvas.inset')};
`}
${props =>
props.disabled &&
css`
color: ${get('colors.fg.muted')};
background-color: ${get('colors.input.disabledBg')};
border-color: ${get('colors.border.default')};
`}
${props =>
props.block &&
css`
display: block;
width: 100%;
`}
// Ensures inputs don't zoom on mobile but are body-font size on desktop
@media (min-width: ${get('breakpoints.1')}) {
font-size: ${get('fontSizes.1')};
}
${width}
${minWidth}
${maxWidth}
${sizeVariants}
${sx};
`

// Props that are not passed through to Input:
type NonPassthroughProps = {
className?: string
icon?: React.ComponentType<{className?: string}>
} & Pick<
ComponentProps<typeof Wrapper>,
ComponentProps<typeof TextInputWrapper>,
'block' | 'contrast' | 'disabled' | 'sx' | 'theme' | 'width' | 'maxWidth' | 'minWidth' | 'variant'
>

// Note: using ComponentProps instead of ComponentPropsWithoutRef here would cause a type issue where `css` is a required prop.
type TextInputInternalProps = Merge<React.ComponentPropsWithoutRef<typeof Input>, NonPassthroughProps>
type TextInputInternalProps = Merge<React.ComponentPropsWithoutRef<typeof UnstyledTextInput>, NonPassthroughProps>

// using forwardRef is important so that other components (ex. SelectMenu) can autofocus the input
const TextInput = React.forwardRef<HTMLInputElement, TextInputInternalProps>(
Expand All @@ -151,8 +36,9 @@ const TextInput = React.forwardRef<HTMLInputElement, TextInputInternalProps>(
) => {
// this class is necessary to style FilterSearch, plz no touchy!
const wrapperClasses = classnames(className, 'TextInput-wrapper')

return (
<Wrapper
<TextInputWrapper
block={block}
className={wrapperClasses}
contrast={contrast}
Expand All @@ -166,8 +52,8 @@ const TextInput = React.forwardRef<HTMLInputElement, TextInputInternalProps>(
variant={variantProp}
>
{IconComponent && <IconComponent className="TextInput-icon" />}
<Input ref={ref} disabled={disabled} {...inputProps} />
</Wrapper>
<UnstyledTextInput ref={ref} disabled={disabled} {...inputProps} />
</TextInputWrapper>
)
}
)
Expand Down
Loading

0 comments on commit 273ef29

Please sign in to comment.