Skip to content

Commit

Permalink
Add TextArea component (#400)
Browse files Browse the repository at this point in the history
* TextField component

Co-Authored-By: Tyler Krys <[email protected]>

* fork Text Field component into Text Area, replacing <Input> with <TextArea>

* stage TextArea on vite

* remove unused size styles

* group textfield and textarea together in storybook

* add error state story

* front matter for textarea docs

* add demo form to vite app

* add min-height to input field

* first pass at adding character counter

* style tweak to fix accessibility vio

* move error state icon out of input field and into error message slot

* fix typo in iconError class name

* add new stories for TextArea

* rework structure and styling of description and char count

* removes an empty div if neither maxLength nor description are present

* new icon implementation to fix typescript error

* rework positioning logic for description and counter slots

* CSS cleanup

* Revert "add demo form to vite app"

This reverts commit 30b1a3e.

* Revert "group textfield and textarea together in storybook"

This reverts commit 2152c26.

* Revert "TextField component"

This reverts commit 7f43af0.

* Add work in progress TextArea maxLength counter idea

* add comment to new charCount function

* add missing colour styles and docs note on placeholder text

* expand storybook docs

* reorder stories and add isRequired case

* syntax cleanup

* add warning icon as a react component

* changes to warning icon SVG

* change icon to SvgExclamationIcon

* fix naming error in SVG URL

* bump storybook to 8.2.5

* correct typo

* fix errors in static SVG

* remove unnecessary styling from react SVG

* icon styling

* update textarea story settings

* clean up description field styling

* reimplement error icon using react component

* clean up styling and fix spacing issue

* Revert "bump storybook to 8.2.5"

This reverts commit 187086f.

* simplify styling of required label

* remove hardcoded IDs from SVG

* update default SVG ID

---------

Co-authored-by: Tyler Krys <[email protected]>
  • Loading branch information
mkernohanbc and ty2k authored Jul 22, 2024
1 parent 8491977 commit 847942e
Show file tree
Hide file tree
Showing 13 changed files with 463 additions and 1 deletion.
2 changes: 2 additions & 0 deletions packages/react-components/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
SelectPage,
TagGroupPage,
TooltipPage,
TextAreaPage,
TextFieldPage,
} from "@/pages";

Expand Down Expand Up @@ -148,6 +149,7 @@ function App() {
<SelectPage />
<TagGroupPage />
<TooltipPage />
<TextAreaPage />
<TextFieldPage />
</main>
<Footer />
Expand Down
13 changes: 13 additions & 0 deletions packages/react-components/src/assets/icon-exclamation.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
// This icon is available as a plain SVG at src/assets/icon-exclamation.svg
export default function SvgExclamationIcon({ id = "exclamation-icon" }) {
return (
<svg
id={id}
width="20"
height="20"
viewBox="0 0 20 20"
xmlns="http://www.w3.org/2000/svg"
>
<g>
<path
d="M17.835 15.0312C18.335 15.9062 17.71 17 16.6787 17H3.33499C2.30374 17 1.67874 15.9062 2.14749 15.0312L8.83499 3.65625C9.11624 3.21875 9.55373 3 10.0225 3C10.46 3 10.8975 3.21875 11.1787 3.65625L17.835 15.0312ZM3.64749 15.5H16.3663L9.99123 4.65625L3.64749 15.5ZM10.0225 12.5625C10.5537 12.5625 10.9912 13 10.9912 13.5312C10.9912 14.0625 10.5537 14.5 10.0225 14.5C9.45998 14.5 9.02249 14.0625 9.02249 13.5312C9.02249 13 9.45998 12.5625 10.0225 12.5625ZM9.27249 7.75C9.27249 7.34375 9.58498 7 10.0225 7C10.4287 7 10.7725 7.34375 10.7725 7.75V10.75C10.7725 11.1875 10.4287 11.5 10.0225 11.5C9.58498 11.5 9.27249 11.1875 9.27249 10.75V7.75Z"
fill="currentColor"
/>
</g>
</svg>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { default } from "./SvgExclamationIcon";
129 changes: 129 additions & 0 deletions packages/react-components/src/components/TextArea/TextArea.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
.bcds-react-aria-TextArea {
display: inline-flex;
flex-direction: column;
/* Hacks for `stretch`: https://caniuse.com/mdn-css_properties_max-width_stretch */
align-self: stretch;
max-width: -moz-available;
max-width: -webkit-fill-available;
}

/* Styles for the text label above the input field */
.bcds-react-aria-TextArea--Label {
font: var(--typography-regular-small-body);
color: var(--typography-color-primary);
}

.bcds-react-aria-TextArea--Label > .required {
color: var(--typography-color-secondary);
padding: var(--layout-padding-none) var(--layout-padding-xsmall);
}

/* Styles for the description and character count slot below the input field */
.bcds-react-aria-TextArea--Description {
display: grid;
gap: var(--layout-margin-medium);
font: var(--typography-regular-small-body);
color: var(--typography-color-secondary);
}

/* Description label */
.bcds-react-aria-TextArea--Description > .text {
justify-self: flex-start;
grid-row: 1;
}

/* Character counter */
.bcds-react-aria-TextArea--Description > .counter {
justify-self: flex-end;
grid-row: 1;
}

/* Styles for the input field container */
.bcds-react-aria-TextArea--Container {
color: var(--typography-color-primary);
display: flex;
flex-direction: row;
align-items: center;
gap: var(--layout-margin-small);
background: var(--surface-color-forms-default);
box-sizing: border-box;
border: var(--layout-border-width-small) solid
var(--surface-color-border-default);
border-radius: var(--layout-border-radius-medium);
padding: var(--layout-padding-small) 12px;
margin: var(--layout-margin-small) var(--layout-margin-none);
}

/* Text input field */
.bcds-react-aria-TextArea--Input {
font: var(--typography-regular-body);
padding: var(--layout-padding-none);
color: var(--typography-color-primary);
border: none;
flex-grow: 1;
}

.bcds-rect-aria-TextArea--Input::placeholder {
color: var(--typography-color-placeholder);
}

/* Hover and focus states */
.bcds-react-aria-TextArea--Container
> .bcds-react-aria-TextArea--Input[data-focused] {
outline: none;
}

.bcds-react-aria-TextArea--Container:focus-within {
border-radius: var(--layout-border-radius-large);
border: var(--layout-border-width-small) solid
var(--surface-color-border-active);
outline: solid var(--layout-border-width-medium)
var(--surface-color-border-active);
outline-offset: var(--layout-margin-hair);
}

.bcds-react-aria-TextArea--Container:hover {
border-color: var(--surface-color-border-dark);
}

/* Disabled and invalid states */
.bcds-react-aria-TextArea[data-disabled]
> .bcds-react-aria-TextArea--Container {
background: var(--surface-color-forms-disabled);
cursor: not-allowed;
}

.bcds-react-aria-TextArea--Input[data-disabled] {
cursor: not-allowed;
}

.bcds-react-aria-TextArea[data-invalid] > .bcds-react-aria-TextArea--Container {
border-radius: var(--layout-border-radius-medium);
border: 1px solid var(--support-border-color-danger);
background: var(--surface-color-forms-default);
}

.bcds-react-aria-TextArea[data-readonly]
> .bcds-react-aria-TextArea--Container {
background: var(--surface-color-forms-disabled);
}

.bcds-react-aria-TextArea[data-readonly]
> .bcds-react-aria-TextArea--Container
> .bcds-react-aria-TextArea--Input {
background: var(--surface-color-forms-disabled);
}

/* Styles for the error message slot */
.bcds-react-aria-TextArea--Error {
display: flex;
font: var(--typography-regular-small-body);
color: var(--typography-color-danger);
}

.bcds-react-aria-TextArea--Error > svg {
padding-right: var(--layout-padding-xsmall);
color: var(--icons-color-danger);
width: var(--icons-size-medium);
height: var(--icons-size-medium);
}
82 changes: 82 additions & 0 deletions packages/react-components/src/components/TextArea/TextArea.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import { useState } from "react";
import {
TextField as ReactAriaTextField,
TextFieldProps as ReactAriaTextFieldProps,
TextArea as ReactAriaTextArea,
Label,
FieldError,
Text,
ValidationResult,
} from "react-aria-components";

import "./TextArea.css";
import SvgExclamationIcon from "../SvgExclamationIcon";

export interface TextAreaProps extends ReactAriaTextFieldProps {
/* Sets text label above text input field */
label?: string;
/* Sets optional description text below text input field */
description?: string;
/* Used for data validation and error handling */
errorMessage?: string | ((validation: ValidationResult) => string);
}

export default function TextArea({
label,
description,
errorMessage,
maxLength,
onChange,
value,
...props
}: TextAreaProps) {
// handler for character counter, displayed when a maxLength is set
const [charCount, setCharCount] = useState(value ? value.length : 0);

function handleChange(text: string) {
if (onChange) onChange(text);
setCharCount(text.length);
}

return (
<ReactAriaTextField className={`bcds-react-aria-TextArea`} {...props}>
{({ isRequired, isInvalid }) => (
<>
{label && (
<Label className="bcds-react-aria-TextArea--Label">
{label}
{isRequired && <span className="required">(required)</span>}
</Label>
)}
<div className="bcds-react-aria-TextArea--Container">
<ReactAriaTextArea
className="bcds-react-aria-TextArea--Input"
value={value}
onChange={(e) => handleChange(e.target.value)}
/>
</div>
{description || maxLength ? (
<div className="bcds-react-aria-TextArea--Description">
{description && (
<Text slot="description" className="text">
{description}
</Text>
)}
{maxLength && (
<div className="counter">
{charCount}/{maxLength}
</div>
)}
</div>
) : null}
{isInvalid && (
<div className="bcds-react-aria-TextArea--Error">
<SvgExclamationIcon />
<FieldError>{errorMessage}</FieldError>
</div>
)}
</>
)}
</ReactAriaTextField>
);
}
2 changes: 2 additions & 0 deletions packages/react-components/src/components/TextArea/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export { default } from "./TextArea";
export type { TextAreaProps } from "./TextArea";
1 change: 1 addition & 0 deletions packages/react-components/src/components/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,5 +8,6 @@ export { default as SvgBcLogo } from "./SvgBcLogo";
export { default as Tag } from "./Tag";
export { default as TagGroup } from "./TagGroup";
export { default as TagList } from "./TagList";
export { default as TextArea } from "./TextArea";
export { default as TextField } from "./TextField";
export { default as Tooltip, TooltipTrigger } from "./Tooltip";
33 changes: 33 additions & 0 deletions packages/react-components/src/pages/TextArea/TextArea.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { useState } from "react";

import { TextArea } from "@/components";

export default function TextAreaPage() {
const [text, setText] = useState("initial text state");

function handleTextChange(text: string) {
console.log("new text value: ", text);

setText(text);
}

return (
<>
<h2>TextArea</h2>
<div>
<p>
<code>text</code>: {text}{" "}
</p>
<TextArea
label={"Controlled"}
value={text}
onChange={handleTextChange}
maxLength={500}
/>
</div>
<div>
<TextArea label={"Uncontrolled"} maxLength={500} defaultValue="Hello" />
</div>
</>
);
}
3 changes: 3 additions & 0 deletions packages/react-components/src/pages/TextArea/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import TextAreaPage from "./TextArea";

export default TextAreaPage;
3 changes: 2 additions & 1 deletion packages/react-components/src/pages/index.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import ButtonPage from "./Button";
import SelectPage from "./Select";
import TagGroupPage from "./TagGroup";
import TextAreaPage from "./TextArea";
import TextFieldPage from "./TextField";
import TooltipPage from "./Tooltip";

export { ButtonPage, SelectPage, TagGroupPage, TextFieldPage, TooltipPage };
export { ButtonPage, SelectPage, TagGroupPage, TextAreaPage, TextFieldPage, TooltipPage };
74 changes: 74 additions & 0 deletions packages/react-components/src/stories/TextArea.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
{/* TextArea.mdx */}

import {
Canvas,
Controls,
Meta,
Primary,
Source,
Story,
Subtitle,
} from "@storybook/blocks";

import * as TextAreaStories from "./TextArea.stories";

<Meta of={TextAreaStories} />

# Text Area

<Subtitle>
The text area component enables a user to enter multiple lines of plain text
into an interface or form.
</Subtitle>

<Source
code={`import { TextArea } from "@bcgov/design-system-react-components"; `}
language="typescript"
/>
## Usage and resources

Learn more about working with the text area component:

- [Usage and best practice guidance](https://www2.gov.bc.ca/gov/content/digital/design-system/components/text-area)
- [View the text field component in Figma](#)

This component is based on [React Aria TextField](https://react-spectrum.adobe.com/react-aria/TextField.html#multi-line).

### Validation

Default and custom data validation support is built in, using the [React Aria FieldError subcomponent](https://react-spectrum.adobe.com/react-aria/forms.html#validation).

### Placeholder

As a general rule, the `placeholder` attribute should not be used on text fields. While `placeholder` is technically supported, the use of 'ghost text' has [serious accessibility and usability issues](https://developer.mozilla.org/en-US/docs/Web/CSS/::placeholder#accessibility_concerns).

## Controls

<Primary of={TextAreaStories} />
<Controls of={TextAreaStories.TextAreaTemplate} />

## Configuration

### Maximum length

The `maxLength` prop can be used to limit the amount of text a user can input. When `maxLength` is passed, a character counter renders below the input field:

<Canvas of={TextAreaStories.TextAreaWithMaxLength} />

### States

Use `isRequired` to make a text area mandatory. A secondary label is shown above the input field:

<Canvas of={TextAreaStories.RequiredTextArea} />

Use `isDisabled` to disable a text area. A disabled text area cannot be focused or interacted with:

<Canvas of={TextAreaStories.DisabledTextArea} />

Use `isReadOnly` to lock a text area to its current value. A read-only text area can be focused and copied, but cannot be edited:

<Canvas of={TextAreaStories.ReadOnlyTextArea} />

The `isInvalid` prop should be set programmatically when an input is invalid. It renders an error icon in the input field, changes the border colour and displays an `errorMessage` (using the FieldError subcomponent):

<Canvas of={TextAreaStories.TextAreaError} />
Loading

0 comments on commit 847942e

Please sign in to comment.