Skip to content

Commit

Permalink
Picker components (#777)
Browse files Browse the repository at this point in the history
* RadioPicker

* OrderPicker

* IncludeFailedPicker

* AssetPicker + PubKeyPicker

* Update hover color + expand transition speed

* Cleanup

* Tweaks and cleanup

* Cleanup
  • Loading branch information
quietbits authored Mar 14, 2024
1 parent b1d6fdf commit 263feb0
Show file tree
Hide file tree
Showing 11 changed files with 864 additions and 7 deletions.
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
"git-info": "rm -rf src/generated/ && mkdir src/generated/ && echo export default \"{\\\"commitHash\\\": \\\"$(git rev-parse --short HEAD)\\\", \\\"version\\\": \\\"$(git describe --tags --always)\\\"};\" > src/generated/gitInfo.ts"
},
"dependencies": {
"@stellar/design-system": "^2.0.0-beta.5",
"@stellar/design-system": "^2.0.0-beta.6",
"@tanstack/react-query": "^5.24.1",
"@tanstack/react-query-devtools": "^5.24.1",
"dompurify": "^3.0.9",
Expand All @@ -24,6 +24,7 @@
"next": "14.1.0",
"react": "^18",
"react-dom": "^18",
"stellar-sdk": "^11.2.2",
"zustand": "^4.5.1",
"zustand-querystring": "^0.0.19"
},
Expand Down
34 changes: 34 additions & 0 deletions src/components/ExpandBox/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { useLayoutEffect, useState } from "react";
import "./styles.scss";

export const ExpandBox = ({
children,
isExpanded,
}: {
children: React.ReactNode;
isExpanded: boolean;
}) => {
const [isOpen, setIsOpen] = useState(false);

// We need a bit of delay to enable overflow visible when the section is expanded
useLayoutEffect(() => {
if (isExpanded) {
const t = setTimeout(() => {
setIsOpen(true);
clearTimeout(t);
}, 200);
} else {
setIsOpen(false);
}
}, [isExpanded]);

return (
<div
className="ExpandBox"
data-is-expanded={isExpanded}
data-is-open={isOpen}
>
<div className="ExpandBox__inset">{children}</div>
</div>
);
};
19 changes: 19 additions & 0 deletions src/components/ExpandBox/styles.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
.ExpandBox {
display: grid;
grid-template-rows: 0fr;
transition: grid-template-rows 200ms ease-out;

&[data-is-expanded="true"] {
grid-template-rows: 1fr;
}

&[data-is-open="true"] {
.ExpandBox__inset {
overflow: visible;
}
}

&__inset {
overflow: hidden;
}
}
309 changes: 309 additions & 0 deletions src/components/FormElements/AssetPicker.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,309 @@
import { useState } from "react";
import { Input } from "@stellar/design-system";

import { ExpandBox } from "@/components/ExpandBox";
import { RadioPicker } from "@/components/RadioPicker";
import { PubKeyPicker } from "@/components/FormElements/PubKeyPicker";

import {
AssetObject,
AssetObjectValue,
AssetString,
AssetType,
} from "@/types/types";

type AssetPickerProps = (
| {
variant: "string";
value: string | undefined;
includeNone?: boolean;
includeNative?: undefined;
onChange: (
optionId: AssetType | undefined,
optionValue: string | undefined,
) => void;
}
| {
variant: "object";
value: AssetObjectValue | undefined;
includeNone?: undefined;
includeNative?: boolean;
onChange: (
optionId: AssetType | undefined,
optionValue: AssetObjectValue | undefined,
) => void;
}
) & {
id: string;
selectedOption: AssetType | undefined;
label: string;
labelSuffix?: string | React.ReactNode;
fitContent?: boolean;
};

export const AssetPicker = ({
id,
variant,
selectedOption,
label,
value,
includeNone,
includeNative = true,
onChange,
labelSuffix,
fitContent,
}: AssetPickerProps) => {
const initErrorState = {
code: "",
issuer: "",
};

const getInitialValue = () => {
if (variant === "string") {
const assetString = value?.split(":");
return {
type: undefined,
code: assetString?.[0] ?? "",
issuer: assetString?.[1] ?? "",
};
}

return {
type: value?.type,
code: value?.code ?? "",
issuer: value?.issuer ?? "",
};
};

const [stateValue, setStateValue] = useState(getInitialValue());
const [error, setError] = useState(initErrorState);

let stringOptions: AssetString[] = [
{
id: "native",
label: "Native",
value: "native",
},
{
id: "issued",
label: "Issued",
value: "",
},
];

if (includeNone) {
stringOptions = [
{
id: "none",
label: "None",
value: "",
},
...stringOptions,
];
}

let objectOptions: AssetObject[] = [
{
id: "credit_alphanum4",
label: "Alphanumeric 4",
value: {
type: "credit_alphanum4",
code: "",
issuer: "",
},
},
{
id: "credit_alphanum12",
label: "Alphanumeric 12",
value: {
type: "credit_alphanum12",
code: "",
issuer: "",
},
},
// TODO: add Liquidity Pool shares (for Change Trust operation)
];

if (includeNative) {
objectOptions = [
{
id: "native",
label: "Native",
value: {
type: "native",
code: "",
issuer: "",
},
},
...objectOptions,
];
}

// Extra helper function to make TypeScript happy to get the right type
const handleOnChange = (
id: AssetType | undefined,
value: string | AssetObjectValue | undefined,
) => {
if (!value) {
onChange(id, undefined);
}

if (variant === "string") {
onChange(id, value as string);
} else {
onChange(id, value as AssetObjectValue);
}
};

const handleOptionChange = (
optionId: AssetType | undefined,
optionValue: string | AssetObjectValue | undefined,
) => {
handleOnChange(optionId, optionValue);
setStateValue({ type: optionId, code: "", issuer: "" });
setError(initErrorState);
};

const validateCode = (code: string, assetType: AssetType | undefined) => {
if (!code) {
return "Asset code is required.";
}

let minLength;
let maxLength;

switch (assetType) {
case "credit_alphanum4":
minLength = 1;
maxLength = 4;
break;
case "credit_alphanum12":
minLength = 5;
maxLength = 12;
break;
default:
minLength = 1;
maxLength = 12;
}

if (!code.match(/^[a-zA-Z0-9]+$/g)) {
return "Asset code must consist of only letters and numbers.";
} else if (code.length < minLength || code.length > maxLength) {
return `Asset code must be between ${minLength} and ${maxLength} characters long.`;
}

return undefined;
};

const handleCodeError = (value: string) => {
const codeError = validateCode(value, stateValue.type);
setError({ ...error, code: codeError || "" });
return codeError;
};

return (
<div className="RadioPicker__inset">
<RadioPicker
id={id}
selectedOption={selectedOption}
label={label}
labelSuffix={labelSuffix}
onChange={handleOptionChange}
options={variant === "string" ? stringOptions : objectOptions}
fitContent={fitContent}
/>

<ExpandBox
isExpanded={Boolean(
selectedOption &&
["issued", "credit_alphanum4", "credit_alphanum12"].includes(
selectedOption,
),
)}
>
<AssetPickerFields
id={id}
code={{
value: stateValue.code,
onChange: (e: React.ChangeEvent<HTMLInputElement>) => {
setStateValue({ ...stateValue, code: e.target.value });
handleCodeError(e.target.value);
},
onBlur: (e) => {
const codeError = handleCodeError(e.target.value);

if (!codeError && stateValue.issuer) {
handleOnChange(
selectedOption,
variant === "string"
? `${stateValue.code}:${stateValue.issuer}`
: stateValue,
);
}
},
error: error.code,
}}
issuer={{
value: stateValue.issuer,
onChange: (value: string, issuerError: string) => {
setStateValue({ ...stateValue, issuer: value });
setError({ ...error, issuer: issuerError });
},
onBlur: (value, issuerError) => {
setError({ ...error, issuer: issuerError });

if (!issuerError && stateValue.code) {
handleOnChange(
selectedOption,
variant === "string"
? `${stateValue.code}:${value}`
: stateValue,
);
}
},
error: error.issuer,
}}
/>
</ExpandBox>
</div>
);
};

type AssetPickerFieldsProps = {
id: string;
code: {
value: string;
error: string;
onChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
onBlur: (e: React.ChangeEvent<HTMLInputElement>) => void;
};
issuer: {
value: string;
error: string;
onChange: (value: string, issuerError: string) => void;
onBlur: (value: string, issuerError: string) => void;
};
};

const AssetPickerFields = ({ id, code, issuer }: AssetPickerFieldsProps) => (
<div className="RadioPicker__inset">
<Input
id={`${id}-code`}
fieldSize="md"
label="Asset Code"
value={code.value}
onChange={code.onChange}
onBlur={code.onBlur}
error={code.error}
/>
<PubKeyPicker
id={`${id}-issuer`}
label="Issuer Account ID"
placeholder="Example: GCEXAMPLE5HWNK4AYSTEQ4UWDKHTCKADVS2AHF3UI2ZMO3DPUSM6Q4UG"
value={issuer.value}
onChange={issuer.onChange}
onBlur={issuer.onBlur}
error={issuer.error}
/>
</div>
);
Loading

0 comments on commit 263feb0

Please sign in to comment.