Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Support multiline inputs for select fields #953

Merged
merged 2 commits into from
Sep 26, 2023
Merged
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
1 change: 1 addition & 0 deletions src/components/Grid/ResponsiveGrid.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,7 @@ function ResizableGridItem({ item, sortable }: { item: GridItem; sortable: boole
placeholder="Add text to effect element's height"
value={text}
onChange={(v) => setText(v ?? "")}
labelStyle="hidden"
/>
</div>
);
Expand Down
9 changes: 7 additions & 2 deletions src/components/Label.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ interface LabelProps {
// If set, it is recommended to wrap in an element with `position: relative;` set, as the label will have an absolute position.
hidden?: boolean;
contrast?: boolean;
multiline?: boolean;
}

/** An internal helper component for rendering form labels. */
Expand All @@ -25,9 +26,13 @@ export const Label = React.memo((props: LabelProps) => {
});

/** Used for showing labels within text fields. */
export function InlineLabel({ labelProps, label, contrast, ...others }: LabelProps) {
export function InlineLabel({ labelProps, label, contrast, multiline = false, ...others }: LabelProps) {
return (
<label {...labelProps} {...others} css={Css.smMd.nowrap.gray900.prPx(4).add("color", "currentColor").$}>
<label
{...labelProps}
{...others}
css={Css.smMd.nowrap.gray900.prPx(4).add("color", "currentColor").asc.if(multiline).asfs.pt1.$}
>
{label}:
</label>
);
Expand Down
2 changes: 1 addition & 1 deletion src/inputs/SelectField.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -326,7 +326,7 @@ export function InTable() {
}
const people: InternalUser[] = zeroTo(10).map((i) => ({
id: `iu:${i + 1}`,
name: `Test user ${i + 1}`.repeat((i % 2) + 1),
name: `Test user ${i + 1} `.repeat((i % 5) + 1),
}));
const rowData: Request[] = zeroTo(10).map((i) => ({
id: `r:${i + 1}`,
Expand Down
31 changes: 3 additions & 28 deletions src/inputs/TextAreaField.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { useLayoutEffect } from "@react-aria/utils";
import { useCallback, useRef } from "react";
import { useRef } from "react";
import { mergeProps, useTextField } from "react-aria";
import { resolveTooltip } from "src/components";
import { Only } from "src/Css";
import { useGrowingTextField } from "src/inputs/hooks/useGrowingTextField";
import { TextFieldBase } from "src/inputs/TextFieldBase";
import { BeamTextFieldProps, TextFieldXss } from "src/interfaces";
import { maybeCall } from "src/utils";
Expand Down Expand Up @@ -33,32 +33,7 @@ export function TextAreaField<X extends Only<TextFieldXss, X>>(props: TextAreaFi
const inputRef = useRef<HTMLTextAreaElement | null>(null);
const inputWrapRef = useRef<HTMLDivElement | null>(null);

// not in stately because this is so we know when to re-measure, which is a spectrum design
const onHeightChange = useCallback(() => {
const input = inputRef.current;
const inputWrap = inputWrapRef.current;
if (input && inputWrap) {
const prevAlignment = input.style.alignSelf;
input.style.alignSelf = "start";
input.style.height = "auto";
// Adding 2px to height avoids showing the scrollbar. This is to compensate for the border due to `box-sizing: border-box;`
inputWrap.style.height = `${input.scrollHeight + 2}px`;
// Set the textarea's height back to 100% so it takes up the full `inputWrap`
input.style.height = "100%";
input.style.alignSelf = prevAlignment;
}
}, [inputRef]);

useLayoutEffect(() => {
if (inputRef.current) {
// Temp hack until we can figure out a better way to ensure proper measurements when rendered through a portal (i.e. Modals)
if (inputRef.current.scrollHeight === 0) {
setTimeout(() => onHeightChange(), 0);
return;
}
onHeightChange();
}
}, [onHeightChange, value, inputRef]);
useGrowingTextField({ inputRef, inputWrapRef, value });

const { labelProps, inputProps } = useTextField(
{
Expand Down
18 changes: 9 additions & 9 deletions src/inputs/TextFieldBase.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -200,8 +200,8 @@ export function TextFieldBase<X extends Only<TextFieldXss, X>>(props: TextFieldB
data-readonly="true"
{...tid}
>
{!multiline && labelStyle === "inline" && label && (
<InlineLabel labelProps={labelProps} label={label} {...tid.label} />
{labelStyle === "inline" && label && (
<InlineLabel multiline={multiline} labelProps={labelProps} label={label} {...tid.label} />
)}
{multiline
? (inputProps.value as string | undefined)?.split("\n\n").map((p, i) => (
Expand All @@ -225,15 +225,15 @@ export function TextFieldBase<X extends Only<TextFieldXss, X>>(props: TextFieldB
...(showHover ? fieldStyles.hover : {}),
// Only show error styles if the field is not disabled, following the pattern that the error message is also hidden
...(errorMsg && !inputProps.disabled ? fieldStyles.error : {}),
...Css.if(multiline).aifs.px0.mhPx(textAreaMinHeight).$,
...Css.if(multiline).aifs.mhPx(textAreaMinHeight).$,
}}
{...hoverProps}
ref={inputWrapRef as any}
>
{!multiline && labelStyle === "inline" && label && (
<InlineLabel labelProps={labelProps} label={label} {...tid.label} />
{labelStyle === "inline" && label && (
<InlineLabel multiline={multiline} labelProps={labelProps} label={label} {...tid.label} />
)}
{!multiline && startAdornment && <span css={Css.df.aic.fs0.br4.pr1.$}>{startAdornment}</span>}
{startAdornment && <span css={Css.df.aic.asc.fs0.br4.pr1.$}>{startAdornment}</span>}
<ElementType
{...mergeProps(
inputProps,
Expand All @@ -247,7 +247,7 @@ export function TextFieldBase<X extends Only<TextFieldXss, X>>(props: TextFieldB
...fieldStyles.input,
...(inputProps.disabled ? fieldStyles.disabled : {}),
...(showHover ? fieldStyles.hover : {}),
...(multiline ? Css.h100.p1.add("resize", "none").$ : Css.truncate.$),
...(multiline ? Css.h100.py1.add("resize", "none").$ : Css.truncate.$),
...xss,
}}
{...tid}
Expand All @@ -264,11 +264,11 @@ export function TextFieldBase<X extends Only<TextFieldXss, X>>(props: TextFieldB
/>
)}
{errorInTooltip && errorMsg && !hideErrorMessage && (
<span css={Css.df.aic.pl1.fs0.$}>
<span css={Css.df.aic.asc.pl1.fs0.$}>
<Icon icon="error" color={Palette.Red600} tooltip={errorMsg} />
</span>
)}
{!multiline && endAdornment && <span css={Css.df.aic.pl1.fs0.$}>{endAdornment}</span>}
{endAdornment && <span css={Css.df.aic.asc.pl1.fs0.$}>{endAdornment}</span>}
</div>
),
})}
Expand Down
41 changes: 41 additions & 0 deletions src/inputs/hooks/useGrowingTextField.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { useLayoutEffect } from "@react-aria/utils";
import { MutableRefObject, useCallback } from "react";

interface GrowingTextFieldProps {
inputRef: MutableRefObject<HTMLTextAreaElement | HTMLInputElement | null>;
inputWrapRef: MutableRefObject<HTMLDivElement | null>;
value: number | string | readonly string[] | undefined;
disabled?: boolean;
}

export function useGrowingTextField({ inputRef, inputWrapRef, value, disabled }: GrowingTextFieldProps) {
// not in stately because this is so we know when to re-measure, which is a spectrum design
const onHeightChange = useCallback(() => {
if (disabled) return;
const input = inputRef.current;
const inputWrap = inputWrapRef.current;
if (input && inputWrap) {
const prevAlignment = input.style.alignSelf;
input.style.alignSelf = "start";
input.style.height = "auto";
// Adding 2px to height avoids showing the scrollbar. This is to compensate for the border due to `box-sizing: border-box;`
inputWrap.style.height = `${input.scrollHeight + 2}px`;
// Set the textarea's height back to 100% so it takes up the full `inputWrap`
input.style.height = "100%";
input.style.alignSelf = prevAlignment;
}
}, [inputRef, disabled, inputWrapRef]);

useLayoutEffect(() => {
if (disabled) return;

if (inputRef.current) {
// Temp hack until we can figure out a better way to ensure proper measurements when rendered through a portal (i.e. Modals)
if (inputRef.current.scrollHeight === 0) {
setTimeout(() => onHeightChange(), 0);
return;
}
onHeightChange();
}
}, [onHeightChange, value, inputRef, disabled]);
}
2 changes: 2 additions & 0 deletions src/inputs/internal/ComboBoxBase.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,8 @@ export interface ComboBoxBaseProps<O, V extends Value> extends BeamFocusableProp
*/
unsetLabel?: string;
hideErrorMessage?: boolean;
/* Allows input to wrap to multiple lines */
multiline?: boolean;
}

/**
Expand Down
35 changes: 33 additions & 2 deletions src/inputs/internal/ComboBoxInput.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,18 @@
import React, { InputHTMLAttributes, Key, LabelHTMLAttributes, MutableRefObject, ReactNode, useState } from "react";
import React, {
ChangeEvent,
InputHTMLAttributes,
Key,
LabelHTMLAttributes,
MutableRefObject,
ReactNode,
useState,
} from "react";
import { mergeProps } from "react-aria";
import { ComboBoxState } from "react-stately";
import { Icon } from "src/components";
import { PresentationFieldProps } from "src/components/PresentationContext";
import { PresentationFieldProps, usePresentationContext } from "src/components/PresentationContext";
import { Css } from "src/Css";
import { useGrowingTextField } from "src/inputs/hooks/useGrowingTextField";
import { TextFieldBase } from "src/inputs/TextFieldBase";
import { useTreeSelectFieldProvider } from "src/inputs/TreeSelectField/TreeSelectField";
import { isLeveledNode } from "src/inputs/TreeSelectField/utils";
Expand Down Expand Up @@ -36,6 +45,8 @@ interface ComboBoxInputProps<O, V extends Value> extends PresentationFieldProps
resetField: VoidFunction;
hideErrorMessage?: boolean;
isTree?: boolean;
/* Allows input to wrap to multiple lines */
multiline?: boolean;
}

export function ComboBoxInput<O, V extends Value>(props: ComboBoxInputProps<O, V>) {
Expand All @@ -57,9 +68,16 @@ export function ComboBoxInput<O, V extends Value>(props: ComboBoxInputProps<O, V
resetField,
isTree,
listBoxRef,
inputRef,
inputWrapRef,
multiline = false,
...otherProps
} = props;

const { wrap = false } = usePresentationContext();

// Allow the field to wrap whether the caller has explicitly set `multiline=true` or the `PresentationContext.wrap=true`
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice, looks great!

const allowWrap = wrap || multiline;
const { collapsedKeys, setCollapsedKeys } = useTreeSelectFieldProvider();

const [isFocused, setIsFocused] = useState(false);
Expand All @@ -69,9 +87,15 @@ export function ComboBoxInput<O, V extends Value>(props: ComboBoxInputProps<O, V
const showFieldDecoration =
(!isMultiSelect || (isMultiSelect && !isFocused)) && fieldDecoration && selectedOptions.length === 1;

const multilineProps = allowWrap ? { textAreaMinHeight: 0, multiline: true } : {};
useGrowingTextField({ disabled: !allowWrap, inputRef, inputWrapRef, value: inputProps.value });

return (
<TextFieldBase
{...otherProps}
{...multilineProps}
inputRef={inputRef}
inputWrapRef={inputWrapRef}
errorMsg={errorMsg}
contrast={contrast}
xss={otherProps.labelStyle !== "inline" && !inputProps.readOnly ? Css.fw5.$ : {}}
Expand Down Expand Up @@ -162,6 +186,13 @@ export function ComboBoxInput<O, V extends Value>(props: ComboBoxInputProps<O, V

inputProps.onKeyDown && inputProps.onKeyDown(e);
},
onChange: (e: ChangeEvent<HTMLInputElement>) => {
// Prevent user from entering any content that has new line characters.
const target = e.target as unknown as HTMLTextAreaElement;
target.value = target.value.replace(/[\n\r]/g, "");
// Call existing onChange handler if any.
inputProps.onChange && inputProps.onChange(e);
},
onBlur: (e: React.FocusEvent) => {
// Do not call onBlur if readOnly or interacting within the input wrapper (such as the menu trigger button), or anything within the listbox.
if (
Expand Down