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

Add design polish to TextField, SearchField, and Select #2072

Closed
wants to merge 22 commits into from
Closed
Show file tree
Hide file tree
Changes from 19 commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
6f42d6e
fix: fix textfield error hover
jordankoschei-okta Dec 12, 2023
f0d6509
feat: add code for manual resizing of multiline textfields
jordankoschei-okta Dec 12, 2023
39a45fa
fix: adjust TextField height
jordankoschei-okta Dec 12, 2023
c532948
feat: add SearchField filled variant
jordankoschei-okta Dec 12, 2023
18b2f83
feat: add delete to Select chips
jordankoschei-okta Dec 12, 2023
3b5012d
fix: update types in handleDelete
jordankoschei-okta Dec 14, 2023
62045a7
Merge branch 'main' into jk/polish-inputs-2
jordankoschei-okta Jan 11, 2024
28fff02
refactor: update the Select
jordankoschei-okta Jan 16, 2024
9047f3e
refactor: move styles from sx to components
jordankoschei-okta Jan 17, 2024
44f70d8
fix: set large padding for full-width icon buttons
jordankoschei-okta Jan 29, 2024
a614e36
Merge branch 'main' into jk/polish-inputs-2
jordankoschei-okta Jan 29, 2024
b13aebe
refactor: move styles to styled
jordankoschei-okta Jan 29, 2024
f86f685
Merge branch 'main' into jk/polish-inputs-2
jordankoschei-okta Jan 30, 2024
18f6c5f
fix: update spacing on the placeholder chips
jordankoschei-okta Jan 30, 2024
fcc026d
Merge branch 'main' into jk/polish-inputs-2
jordankoschei-okta Feb 13, 2024
993913e
Merge branch 'main' into jk/polish-inputs-2
jordankoschei-okta Feb 14, 2024
b289148
Update packages/odyssey-react-mui/src/Select.tsx
jordankoschei-okta Feb 15, 2024
654cf4f
refactor: convert function to subcomponent
jordankoschei-okta Feb 20, 2024
3ff252b
refactor: kg feedback
jordankoschei-okta Feb 20, 2024
1729e1b
fix: update styled with shouldForwardProp
jordankoschei-okta Feb 20, 2024
fa44c58
Merge branch 'main' into jk/polish-inputs-2
jordankoschei-okta Feb 20, 2024
94942a5
Merge branch 'main' into jk/polish-inputs-2
jordankoschei-okta Feb 21, 2024
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
10 changes: 10 additions & 0 deletions packages/odyssey-react-mui/src/SearchField.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ import { FieldComponentProps } from "./FieldComponentProps";
import type { HtmlProps } from "./HtmlProps";
import { getControlState, useInputValues } from "./inputUtils";

export const searchVariantValues = ["outline", "filled"] as const;

export type SearchFieldProps = {
/**
* This prop helps users to fill forms faster, especially on mobile devices.
Expand Down Expand Up @@ -81,6 +83,10 @@ export type SearchFieldProps = {
* The value of the `input` element, to use when controlled.
*/
value?: string;
/**
* Whether the SearchField has a gray or white background
*/
variant?: (typeof searchVariantValues)[number];
} & Pick<
FieldComponentProps,
"ariaDescribedBy" | "id" | "isDisabled" | "name" | "isFullWidth"
Expand Down Expand Up @@ -108,6 +114,7 @@ const SearchField = forwardRef<HTMLInputElement, SearchFieldProps>(
testId,
translate,
value,
variant = "outline",
jordankoschei-okta marked this conversation as resolved.
Show resolved Hide resolved
},
ref
) => {
Expand Down Expand Up @@ -162,6 +169,8 @@ const SearchField = forwardRef<HTMLInputElement, SearchFieldProps>(
)
}
id={id}
data-ods-type="search"
data-ods-variant={variant}
name={nameOverride ?? id}
onBlur={onBlur}
onChange={onChange}
Expand Down Expand Up @@ -193,6 +202,7 @@ const SearchField = forwardRef<HTMLInputElement, SearchFieldProps>(
tabIndex,
testId,
translate,
variant,
]
);

Expand Down
227 changes: 159 additions & 68 deletions packages/odyssey-react-mui/src/Select.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,19 +20,20 @@ import {
useImperativeHandle,
} from "react";
import {
Box,
Box as MuiBox,
Checkbox as MuiCheckbox,
Chip,
Chip as MuiChip,
ListItemSecondaryAction,
ListSubheader,
MenuItem,
MenuItem as MuiMenuItem,
Select as MuiSelect,
SelectChangeEvent,
} from "@mui/material";
jordankoschei-okta marked this conversation as resolved.
Show resolved Hide resolved
import { SelectProps as MuiSelectProps } from "@mui/material";

import { Field } from "./Field";
import { FieldComponentProps } from "./FieldComponentProps";
import { CheckIcon } from "./icons.generated";
import { CheckIcon, CloseCircleFilledIcon } from "./icons.generated";
import type { HtmlProps } from "./HtmlProps";
import {
ComponentControlledState,
Expand All @@ -41,13 +42,60 @@ import {
getControlState,
} from "./inputUtils";
import { normalizedKey } from "./useNormalizedKey";
import styled from "@emotion/styled";
import {
useOdysseyDesignTokens,
DesignTokens,
} from "./OdysseyDesignTokensContext";

export type SelectOption = {
text: string;
type?: "heading" | "option";
value?: string;
};

const SelectContainer = styled.div`
position: relative;
width: 100%;
display: flex;
`;

const ChipsPositioningContainer = styled.div<{
odysseyDesignTokens: DesignTokens;
}>`
display: flex;
align-items: center;
position: absolute;
top: ${({ odysseyDesignTokens }) => odysseyDesignTokens.Spacing0};
right: ${({ odysseyDesignTokens }) => odysseyDesignTokens.Spacing5};
bottom: ${({ odysseyDesignTokens }) => odysseyDesignTokens.Spacing0};
left: ${({ odysseyDesignTokens }) => odysseyDesignTokens.Spacing1};
jordankoschei-okta marked this conversation as resolved.
Show resolved Hide resolved
margin-inline-start: ${({ odysseyDesignTokens }) =>
odysseyDesignTokens.BorderWidthMain};
opacity: 1;
pointer-events: none;
`;

const NonInteractiveIcon = styled(CloseCircleFilledIcon)<{
odysseyDesignTokens: DesignTokens;
}>`
font-size: 1em;
margin-inline-start: ${({ odysseyDesignTokens }) =>
odysseyDesignTokens.Spacing2};
margin-inline-end: -${({ odysseyDesignTokens }) => odysseyDesignTokens.Spacing1};
`;

const ChipsInnerContainer = styled(MuiBox)<{
isInteractive?: boolean;
odysseyDesignTokens: DesignTokens;
}>`
display: flex;
flex-wrap: wrap;
gap: ${({ odysseyDesignTokens }) => odysseyDesignTokens.Spacing1};
pointer-events: ${({ isInteractive }) => (isInteractive ? "auto" : "none")};
opacity: ${({ isInteractive }) => (isInteractive ? 1 : 0)};
`;

export type SelectValueType<HasMultipleChoices> =
HasMultipleChoices extends true ? string[] : string;

Expand Down Expand Up @@ -168,6 +216,7 @@ const Select = <
controlledStateRef.current === CONTROLLED ? value : defaultValue
);
const localInputRef = useRef<HTMLSelectElement>(null);
const odysseyDesignTokens = useOdysseyDesignTokens();

useImperativeHandle(
inputRef,
Expand Down Expand Up @@ -208,6 +257,23 @@ const Select = <
[onChangeProp]
);

const removeSelection = useCallback(
(itemToRemove: string) => {
if (Array.isArray(internalSelectedValues)) {
const newValue = internalSelectedValues.filter(
(item: string) => item !== itemToRemove
);

const syntheticEvent = {
target: { value: newValue },
} as SelectChangeEvent<Value>;

onChange(syntheticEvent, null);
}
},
[internalSelectedValues, onChange]
);

// Normalize the options array to accommodate the various
// data types that might be passed
const normalizedOptions = useMemo(
Expand All @@ -232,38 +298,52 @@ const Select = <
[options]
);

const renderValue = useCallback(
(selected: Value) => {
// If the selected value isn't an array, then we don't need to display
// chips and should fall back to the default render behavior
if (typeof selected === "string") {
return undefined;
}

// Convert the selected options array into <Chip>s
const renderedChips = selected
.map((item: string) => {
const selectedOption = normalizedOptions.find(
(option) => option.value === item
);

if (!selectedOption) {
return null;
const Chips = ({
selection,
isInteractive,
}: {
selection: string[];
isInteractive: boolean;
}) => (
<ChipsInnerContainer
isInteractive={isInteractive}
odysseyDesignTokens={odysseyDesignTokens}
>
{selection.map((item: string) => (
<MuiChip
key={item}
label={
<>
{item}
{!isInteractive &&
controlledStateRef.current === CONTROLLED &&
hasMultipleChoices && (
<NonInteractiveIcon
odysseyDesignTokens={odysseyDesignTokens}
/>
)}
</>
}

return <Chip key={item} label={selectedOption.text} />;
})
.filter(Boolean);

if (renderedChips.length === 0) {
return null;
}

// We need the <Box> to surround the <Chip>s for
// proper styling
return <Box>{renderedChips}</Box>;
},
[normalizedOptions]
tabIndex={-1}
onDelete={
isInteractive
? controlledStateRef.current === CONTROLLED
? () => removeSelection(item)
: undefined
: undefined
}
deleteIcon={
<CloseCircleFilledIcon
sx={{ pointerEvents: "auto" }}
// We need to stop event propagation on mouse down to prevent the deletion
// from being blocked by the Select list opening, and also ensure that
// the pointerEvent is registered even when the parent's are not
onMouseDown={(event) => event.stopPropagation()}
/>
}
/>
))}
</ChipsInnerContainer>
);

// Convert the options into the ReactNode children
Expand All @@ -272,21 +352,22 @@ const Select = <
() =>
normalizedOptions.map((option, index) => {
if (option.type === "heading") {
return <ListSubheader key={option.text}>{option.text}</ListSubheader>;
return (
<ListSubheader key={option.text}> {option.text} </ListSubheader>
);
}

const isSelected = hasMultipleChoices
? internalSelectedValues?.includes(option.value)
: internalSelectedValues === option.value;

return (
<MenuItem
<MuiMenuItem
key={normalizedKey(option.text, index.toString())}
value={option.value}
selected={isSelected}
>
{hasMultipleChoices && (
<MuiCheckbox
checked={
option.value !== undefined &&
internalSelectedValues?.includes(option.value)
}
/>
)}
{hasMultipleChoices && <MuiCheckbox checked={isSelected} />}
{option.text}
{!hasMultipleChoices &&
(internalSelectedValues?.includes(option.value) ||
Expand All @@ -295,45 +376,55 @@ const Select = <
<CheckIcon />
</ListItemSecondaryAction>
)}
</MenuItem>
</MuiMenuItem>
);
}),
[hasMultipleChoices, normalizedOptions, internalSelectedValues]
);

const renderFieldComponent = useCallback(
({ ariaDescribedBy, errorMessageElementId, id, labelElementId }) => (
<MuiSelect
{...inputValues}
aria-describedby={ariaDescribedBy}
aria-errormessage={errorMessageElementId}
children={children}
data-se={testId}
displayEmpty={
inputValues?.value === "" || inputValues?.defaultValue === ""
}
id={id}
inputProps={{ "data-se": testId }}
inputRef={localInputRef}
labelId={labelElementId}
multiple={hasMultipleChoices}
name={nameOverride ?? id}
onBlur={onBlur}
onChange={onChange}
onFocus={onFocus}
renderValue={hasMultipleChoices ? renderValue : undefined}
translate={translate}
/>
<SelectContainer>
<MuiSelect
{...inputValues}
aria-describedby={ariaDescribedBy}
aria-errormessage={errorMessageElementId}
children={children}
id={id}
inputProps={{ "data-se": testId }}
inputRef={localInputRef}
labelId={labelElementId}
multiple={hasMultipleChoices}
name={nameOverride ?? id}
onBlur={onBlur}
onChange={onChange}
onFocus={onFocus}
renderValue={
hasMultipleChoices
? (value: Value) =>
Array.isArray(value) && (
<Chips selection={value} isInteractive={false} />
)
: undefined
}
translate={translate}
/>
{hasMultipleChoices && Array.isArray(value) && (
<ChipsPositioningContainer odysseyDesignTokens={odysseyDesignTokens}>
<Chips selection={value} isInteractive={true} />
</ChipsPositioningContainer>
)}
</SelectContainer>
),
[
children,
inputValues,
hasMultipleChoices,
normalizedOptions,
nameOverride,
onBlur,
onChange,
onFocus,
renderValue,
testId,
translate,
]
Expand Down
Loading