Skip to content
Open
65 changes: 63 additions & 2 deletions apps/examples/ui-test-app/src/tests/CodeInput.test.tsx
Original file line number Diff line number Diff line change
@@ -1,17 +1,21 @@
import { jest } from "@jest/globals";
import { CodeInput } from "@lightsparkdev/ui/components/CodeInput/CodeInput";
import { fireEvent, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { render } from "./render";

describe("CodeInput", () => {
beforeEach(() => {
const mockClipboardReadWithoutNumbers = jest.fn(() =>
Promise.resolve("sdkjfnsd"),
);
Object.assign(navigator, {
clipboard: {
// `userEvent.setup()` may install `navigator.clipboard` as a getter-only prop.
// Use `defineProperty` so this mock is resilient regardless of that setup.
Object.defineProperty(navigator, "clipboard", {
value: {
readText: mockClipboardReadWithoutNumbers,
},
configurable: true,
});
});

Expand Down Expand Up @@ -134,4 +138,61 @@ describe("CodeInput", () => {
expect(inputFields[3]).toHaveValue(null);
expect(inputFields[2]).toHaveFocus();
});

it("redirects focus to first empty input when clicking on empty input in unified variant", async () => {
const user = userEvent.setup();
render(<CodeInput codeLength={6} variant="unified" />);
const inputFields = screen.getAllByRole("textbox");
expect(inputFields).toHaveLength(6);

// Enter some digits in the first two positions
fireEvent.keyDown(inputFields[0], { key: "1" });
fireEvent.keyDown(inputFields[1], { key: "2" });

// Now focus should be on the third input (index 2)
expect(inputFields[2]).toHaveFocus();

// Simulate clicking on the 5th input (index 4) - an empty position
// onMouseDown should redirect focus to the first empty input (index 2)
await user.click(inputFields[4]);
expect(inputFields[2]).toHaveFocus();
});

it("allows clicking on any filled input in unified variant", async () => {
const user = userEvent.setup();
render(<CodeInput codeLength={6} variant="unified" />);
const inputFields = screen.getAllByRole("textbox");

// Enter some digits
fireEvent.keyDown(inputFields[0], { key: "1" });
fireEvent.keyDown(inputFields[1], { key: "2" });
fireEvent.keyDown(inputFields[2], { key: "3" });

// Focus should be on the 4th input (index 3)
expect(inputFields[3]).toHaveFocus();

// Clicking on a filled input (index 1) should work normally - no redirect
await user.click(inputFields[1]);
expect(inputFields[1]).toHaveFocus();

// Can also click on index 0
await user.click(inputFields[0]);
expect(inputFields[0]).toHaveFocus();

// Can also click on index 2
await user.click(inputFields[2]);
expect(inputFields[2]).toHaveFocus();
});

it("focuses first input when all inputs are empty in unified variant", () => {
render(<CodeInput codeLength={6} variant="unified" />);
const inputFields = screen.getAllByRole("textbox");

// Blur the auto-focused first input
fireEvent.blur(inputFields[0]);

// Click on a middle input when all are empty
fireEvent.mouseDown(inputFields[3]);
expect(inputFields[0]).toHaveFocus();
});
});
20 changes: 20 additions & 0 deletions packages/core/src/utils/currency.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ export const CurrencyUnit = {
XAF: "XAF",
MWK: "MWK",
RWF: "RWF",
ZMW: "ZMW",
USDT: "USDT",
USDC: "USDC",

Expand Down Expand Up @@ -110,6 +111,7 @@ const standardUnitConversionObj = {
[CurrencyUnit.XAF]: (v: number) => v,
[CurrencyUnit.MWK]: (v: number) => v,
[CurrencyUnit.RWF]: (v: number) => v,
[CurrencyUnit.ZMW]: (v: number) => v,
[CurrencyUnit.USDT]: (v: number) => v,
[CurrencyUnit.USDC]: (v: number) => v,
};
Expand Down Expand Up @@ -161,6 +163,7 @@ const CONVERSION_MAP = {
[CurrencyUnit.XAF]: toBitcoinConversion,
[CurrencyUnit.MWK]: toBitcoinConversion,
[CurrencyUnit.RWF]: toBitcoinConversion,
[CurrencyUnit.ZMW]: toBitcoinConversion,
[CurrencyUnit.USDT]: toBitcoinConversion,
[CurrencyUnit.USDC]: toBitcoinConversion,
},
Expand Down Expand Up @@ -196,6 +199,7 @@ const CONVERSION_MAP = {
[CurrencyUnit.XAF]: toMicrobitcoinConversion,
[CurrencyUnit.MWK]: toMicrobitcoinConversion,
[CurrencyUnit.RWF]: toMicrobitcoinConversion,
[CurrencyUnit.ZMW]: toMicrobitcoinConversion,
[CurrencyUnit.USDT]: toMicrobitcoinConversion,
[CurrencyUnit.USDC]: toMicrobitcoinConversion,
},
Expand Down Expand Up @@ -231,6 +235,7 @@ const CONVERSION_MAP = {
[CurrencyUnit.XAF]: toMillibitcoinConversion,
[CurrencyUnit.MWK]: toMillibitcoinConversion,
[CurrencyUnit.RWF]: toMillibitcoinConversion,
[CurrencyUnit.ZMW]: toMillibitcoinConversion,
[CurrencyUnit.USDT]: toMillibitcoinConversion,
[CurrencyUnit.USDC]: toMillibitcoinConversion,
},
Expand Down Expand Up @@ -266,6 +271,7 @@ const CONVERSION_MAP = {
[CurrencyUnit.XAF]: toMillisatoshiConversion,
[CurrencyUnit.MWK]: toMillisatoshiConversion,
[CurrencyUnit.RWF]: toMillisatoshiConversion,
[CurrencyUnit.ZMW]: toMillisatoshiConversion,
[CurrencyUnit.USDT]: toMillisatoshiConversion,
[CurrencyUnit.USDC]: toMillisatoshiConversion,
},
Expand Down Expand Up @@ -301,6 +307,7 @@ const CONVERSION_MAP = {
[CurrencyUnit.XAF]: toNanobitcoinConversion,
[CurrencyUnit.MWK]: toNanobitcoinConversion,
[CurrencyUnit.RWF]: toNanobitcoinConversion,
[CurrencyUnit.ZMW]: toNanobitcoinConversion,
[CurrencyUnit.USDT]: toNanobitcoinConversion,
[CurrencyUnit.USDC]: toNanobitcoinConversion,
},
Expand Down Expand Up @@ -336,6 +343,7 @@ const CONVERSION_MAP = {
[CurrencyUnit.XAF]: toSatoshiConversion,
[CurrencyUnit.MWK]: toSatoshiConversion,
[CurrencyUnit.RWF]: toSatoshiConversion,
[CurrencyUnit.ZMW]: toSatoshiConversion,
[CurrencyUnit.USDT]: toSatoshiConversion,
[CurrencyUnit.USDC]: toSatoshiConversion,
},
Expand Down Expand Up @@ -364,6 +372,7 @@ const CONVERSION_MAP = {
[CurrencyUnit.XAF]: standardUnitConversionObj,
[CurrencyUnit.MWK]: standardUnitConversionObj,
[CurrencyUnit.RWF]: standardUnitConversionObj,
[CurrencyUnit.ZMW]: standardUnitConversionObj,
[CurrencyUnit.USDT]: standardUnitConversionObj,
[CurrencyUnit.USDC]: standardUnitConversionObj,
};
Expand Down Expand Up @@ -452,6 +461,7 @@ export type CurrencyMap = {
[CurrencyUnit.XAF]: number;
[CurrencyUnit.MWK]: number;
[CurrencyUnit.RWF]: number;
[CurrencyUnit.ZMW]: number;
[CurrencyUnit.USDT]: number;
[CurrencyUnit.USDC]: number;
[CurrencyUnit.FUTURE_VALUE]: number;
Expand Down Expand Up @@ -490,6 +500,7 @@ export type CurrencyMap = {
[CurrencyUnit.XAF]: string;
[CurrencyUnit.MWK]: string;
[CurrencyUnit.RWF]: string;
[CurrencyUnit.ZMW]: string;
[CurrencyUnit.USDT]: string;
[CurrencyUnit.USDC]: string;
[CurrencyUnit.FUTURE_VALUE]: string;
Expand Down Expand Up @@ -709,6 +720,7 @@ function convertCurrencyAmountValues(
xaf: CurrencyUnit.XAF,
mwk: CurrencyUnit.MWK,
rwf: CurrencyUnit.RWF,
zmw: CurrencyUnit.ZMW,
mibtc: CurrencyUnit.MICROBITCOIN,
mlbtc: CurrencyUnit.MILLIBITCOIN,
nbtc: CurrencyUnit.NANOBITCOIN,
Expand Down Expand Up @@ -792,6 +804,7 @@ export function mapCurrencyAmount(
xaf,
mwk,
rwf,
zmw,
usdt,
usdc,
} = convertCurrencyAmountValues(unit, value, unitsPerBtc, conversionOverride);
Expand Down Expand Up @@ -825,6 +838,7 @@ export function mapCurrencyAmount(
[CurrencyUnit.XAF]: xaf,
[CurrencyUnit.MWK]: mwk,
[CurrencyUnit.RWF]: rwf,
[CurrencyUnit.ZMW]: zmw,
[CurrencyUnit.MICROBITCOIN]: mibtc,
[CurrencyUnit.MILLIBITCOIN]: mlbtc,
[CurrencyUnit.NANOBITCOIN]: nbtc,
Expand Down Expand Up @@ -956,6 +970,10 @@ export function mapCurrencyAmount(
value: rwf,
unit: CurrencyUnit.RWF,
}),
[CurrencyUnit.ZMW]: formatCurrencyStr({
value: zmw,
unit: CurrencyUnit.ZMW,
}),
[CurrencyUnit.USDT]: formatCurrencyStr({
value: usdt,
unit: CurrencyUnit.USDT,
Expand Down Expand Up @@ -1086,6 +1104,8 @@ export const abbrCurrencyUnit = (unit: CurrencyUnitType) => {
return "MWK";
case CurrencyUnit.RWF:
return "RWF";
case CurrencyUnit.ZMW:
return "ZMW";
}
return "Unsupported CurrencyUnit";
};
Expand Down
1 change: 1 addition & 0 deletions packages/ui/src/components/CardForm/CardForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -460,6 +460,7 @@ const CardFormContentFull = styled.div<{ paddingBottom?: number | undefined }>`
flex-direction: column;
align-self: center;
height: 100%;
width: 100%;
padding-bottom: ${({ paddingBottom }) => paddingBottom ?? 0}px;
`;

Expand Down
9 changes: 9 additions & 0 deletions packages/ui/src/components/CardPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ type Props = {
maxContentWidth?: number;
rightContent?: React.ReactNode;
preHeaderContent?: React.ReactNode;
headerRightContent?: React.ReactNode;
expandRight?: boolean;
id?: string;
};
Expand All @@ -43,6 +44,9 @@ export function CardPage(props: Props) {
<Heading type="h1" m0>
{props.title}
</Heading>
{props.headerRightContent && (
<CardPageHeaderRight>{props.headerRightContent}</CardPageHeaderRight>
)}
</CardPageHeader>
) : null;

Expand Down Expand Up @@ -350,6 +354,11 @@ const CardPageHeader = styled.div<{ headerMarginBottom?: number }>`
}
`;

const CardPageHeaderRight = styled.div`
display: flex;
align-items: center;
`;

export const CardPageContent = styled.div<CardPageContentProps>`
${({
maxContentWidth,
Expand Down
27 changes: 26 additions & 1 deletion packages/ui/src/components/CodeInput/CodeInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -331,6 +331,27 @@ export function CodeInput({

const inputsPerGroup = Math.ceil(codeLength / 2);

/**
* When clicking on the unified code input container, handle focus appropriately
* Uses onMouseDown instead of onClick because mousedown fires before focus,
* allowing us to prevent the default focus behavior and redirect to the correct input.
*/
const onContainerMouseDown = useCallback(
(event: React.MouseEvent<HTMLDivElement>) => {
const target = event.target as HTMLInputElement;
const isClickingFilledInput =
target.tagName === "INPUT" && inputState[target.id]?.value !== "";
if (!isClickingFilledInput) {
event.preventDefault();
const firstEmptyIndex = codeFromInputState(inputState).length;
const targetIndex =
firstEmptyIndex < codeLength ? firstEmptyIndex : codeLength - 1;
getRef(getInputId(targetIndex), inputRefs)?.focus();
}
},
[codeLength, getInputId, inputState, inputRefs],
);

const inputs = [];
for (let i = 0; i < codeLength; i += 1) {
const inputId = getInputId(i);
Expand Down Expand Up @@ -413,7 +434,11 @@ export function CodeInput({
}

if (variant === "unified") {
return <UnifiedCodeInputContainer>{inputs}</UnifiedCodeInputContainer>;
return (
<UnifiedCodeInputContainer onMouseDown={onContainerMouseDown}>
{inputs}
</UnifiedCodeInputContainer>
);
}

return (
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import styled from "@emotion/styled";
import { Spacing } from "../../styles/tokens/spacing.js";
import { ButtonSelector } from "../Button.js";

export const AppliedButtonsContainer = styled.div`
margin-top: ${Spacing.px.sm};
display: flex;
gap: ${Spacing.px.xs};
flex-wrap: wrap;

${ButtonSelector()} {
max-width: 100%;
}
`;
10 changes: 1 addition & 9 deletions packages/ui/src/components/DataManagerTable/EnumFilter.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,8 @@
import styled from "@emotion/styled";
import { ensureArray } from "@lightsparkdev/core";
import { Spacing } from "../../styles/tokens/spacing.js";
import { z } from "../../styles/z-index.js";
import { Button } from "../Button.js";
import Select from "../Select.js";
import { AppliedButtonsContainer } from "./AppliedButtonsContainer.js";
import { Filter, type FilterState } from "./Filter.js";
import { FilterType, type EnumFilterValue } from "./filters.js";

Expand Down Expand Up @@ -105,10 +104,3 @@ export const EnumFilter = ({
</>
);
};

const AppliedButtonsContainer = styled.div`
margin-top: ${Spacing.px.sm};
display: flex;
gap: ${Spacing.px.xs};
flex-wrap: wrap;
`;
10 changes: 1 addition & 9 deletions packages/ui/src/components/DataManagerTable/IdFilter.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import styled from "@emotion/styled";
import { Spacing } from "../../styles/tokens/spacing.js";
import { Button } from "../Button.js";
import { TextInput } from "../TextInput.js";
import { AppliedButtonsContainer } from "./AppliedButtonsContainer.js";
import { Filter, type FilterState } from "./Filter.js";
import { FilterType } from "./filters.js";

Expand Down Expand Up @@ -148,10 +147,3 @@ export const IdFilter = ({
</>
);
};

const AppliedButtonsContainer = styled.div`
margin-top: ${Spacing.px.sm};
display: flex;
gap: ${Spacing.px.xs};
flex-wrap: wrap;
`;
10 changes: 1 addition & 9 deletions packages/ui/src/components/DataManagerTable/StringFilter.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import styled from "@emotion/styled";
import { Spacing } from "../../styles/tokens/spacing.js";
import { Button } from "../Button.js";
import { TextInput } from "../TextInput.js";
import { AppliedButtonsContainer } from "./AppliedButtonsContainer.js";
import { Filter, type FilterState } from "./Filter.js";
import { FilterType } from "./filters.js";

Expand Down Expand Up @@ -82,10 +81,3 @@ export const StringFilter = ({
</>
);
};

const AppliedButtonsContainer = styled.div`
margin-top: ${Spacing.px.sm};
display: flex;
gap: ${Spacing.px.xs};
flex-wrap: wrap;
`;
18 changes: 18 additions & 0 deletions packages/ui/src/icons/central/BankSolid.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
export function BankSolid() {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="100%"
height="100%"
viewBox="0 0 24 24"
fill="none"
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M12.3672 1.38184L2.36719 6.38184V9.99987H4.36719V16H3.64714L1.98047 21H22.7553L21.0887 16H20.3672V9.99987H22.3672V6.38184L12.3672 1.38184ZM18.3672 9.99987H16.3672V16H18.3672V9.99987ZM14.3672 16V9.99987H10.3672V16H14.3672ZM8.36719 16V9.99987H6.36719V16H8.36719Z"
fill="currentColor"
/>
</svg>
);
}
Loading