diff --git a/apps/examples/ui-test-app/src/tests/CodeInput.test.tsx b/apps/examples/ui-test-app/src/tests/CodeInput.test.tsx index 249173fdc..e256b83ee 100644 --- a/apps/examples/ui-test-app/src/tests/CodeInput.test.tsx +++ b/apps/examples/ui-test-app/src/tests/CodeInput.test.tsx @@ -1,6 +1,7 @@ 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", () => { @@ -8,10 +9,13 @@ describe("CodeInput", () => { 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, }); }); @@ -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(); + 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(); + 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(); + 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(); + }); }); diff --git a/packages/core/src/utils/currency.ts b/packages/core/src/utils/currency.ts index 56b40a2a0..cc02068b9 100644 --- a/packages/core/src/utils/currency.ts +++ b/packages/core/src/utils/currency.ts @@ -43,6 +43,7 @@ export const CurrencyUnit = { XAF: "XAF", MWK: "MWK", RWF: "RWF", + ZMW: "ZMW", USDT: "USDT", USDC: "USDC", @@ -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, }; @@ -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, }, @@ -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, }, @@ -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, }, @@ -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, }, @@ -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, }, @@ -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, }, @@ -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, }; @@ -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; @@ -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; @@ -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, @@ -792,6 +804,7 @@ export function mapCurrencyAmount( xaf, mwk, rwf, + zmw, usdt, usdc, } = convertCurrencyAmountValues(unit, value, unitsPerBtc, conversionOverride); @@ -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, @@ -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, @@ -1086,6 +1104,8 @@ export const abbrCurrencyUnit = (unit: CurrencyUnitType) => { return "MWK"; case CurrencyUnit.RWF: return "RWF"; + case CurrencyUnit.ZMW: + return "ZMW"; } return "Unsupported CurrencyUnit"; }; diff --git a/packages/ui/src/components/CardForm/CardForm.tsx b/packages/ui/src/components/CardForm/CardForm.tsx index d9e512c39..1a3779a04 100644 --- a/packages/ui/src/components/CardForm/CardForm.tsx +++ b/packages/ui/src/components/CardForm/CardForm.tsx @@ -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; `; diff --git a/packages/ui/src/components/CardPage.tsx b/packages/ui/src/components/CardPage.tsx index 8846e4fdc..1957b5880 100644 --- a/packages/ui/src/components/CardPage.tsx +++ b/packages/ui/src/components/CardPage.tsx @@ -28,6 +28,7 @@ type Props = { maxContentWidth?: number; rightContent?: React.ReactNode; preHeaderContent?: React.ReactNode; + headerRightContent?: React.ReactNode; expandRight?: boolean; id?: string; }; @@ -43,6 +44,9 @@ export function CardPage(props: Props) { {props.title} + {props.headerRightContent && ( + {props.headerRightContent} + )} ) : null; @@ -350,6 +354,11 @@ const CardPageHeader = styled.div<{ headerMarginBottom?: number }>` } `; +const CardPageHeaderRight = styled.div` + display: flex; + align-items: center; +`; + export const CardPageContent = styled.div` ${({ maxContentWidth, diff --git a/packages/ui/src/components/CodeInput/CodeInput.tsx b/packages/ui/src/components/CodeInput/CodeInput.tsx index e5e38d02a..b6af4a32d 100644 --- a/packages/ui/src/components/CodeInput/CodeInput.tsx +++ b/packages/ui/src/components/CodeInput/CodeInput.tsx @@ -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) => { + 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); @@ -413,7 +434,11 @@ export function CodeInput({ } if (variant === "unified") { - return {inputs}; + return ( + + {inputs} + + ); } return ( diff --git a/packages/ui/src/components/DataManagerTable/AppliedButtonsContainer.tsx b/packages/ui/src/components/DataManagerTable/AppliedButtonsContainer.tsx new file mode 100644 index 000000000..780d7b8db --- /dev/null +++ b/packages/ui/src/components/DataManagerTable/AppliedButtonsContainer.tsx @@ -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%; + } +`; diff --git a/packages/ui/src/components/DataManagerTable/EnumFilter.tsx b/packages/ui/src/components/DataManagerTable/EnumFilter.tsx index 31e149ab6..6b435c91d 100644 --- a/packages/ui/src/components/DataManagerTable/EnumFilter.tsx +++ b/packages/ui/src/components/DataManagerTable/EnumFilter.tsx @@ -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"; @@ -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; -`; diff --git a/packages/ui/src/components/DataManagerTable/IdFilter.tsx b/packages/ui/src/components/DataManagerTable/IdFilter.tsx index ffd45f31d..d9a8d2819 100644 --- a/packages/ui/src/components/DataManagerTable/IdFilter.tsx +++ b/packages/ui/src/components/DataManagerTable/IdFilter.tsx @@ -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"; @@ -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; -`; diff --git a/packages/ui/src/components/DataManagerTable/StringFilter.tsx b/packages/ui/src/components/DataManagerTable/StringFilter.tsx index 79a9d842c..d6eb6666a 100644 --- a/packages/ui/src/components/DataManagerTable/StringFilter.tsx +++ b/packages/ui/src/components/DataManagerTable/StringFilter.tsx @@ -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"; @@ -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; -`; diff --git a/packages/ui/src/icons/central/BankSolid.tsx b/packages/ui/src/icons/central/BankSolid.tsx new file mode 100644 index 000000000..716f15e8b --- /dev/null +++ b/packages/ui/src/icons/central/BankSolid.tsx @@ -0,0 +1,18 @@ +export function BankSolid() { + return ( + + + + ); +} diff --git a/packages/ui/src/icons/central/index.tsx b/packages/ui/src/icons/central/index.tsx index 44f5028dd..fe01e739c 100644 --- a/packages/ui/src/icons/central/index.tsx +++ b/packages/ui/src/icons/central/index.tsx @@ -16,6 +16,7 @@ export { ArrowUpRight as CentralArrowUpRight } from "./ArrowUpRight.js"; export { At as CentralAt } from "./At.js"; export { Bank as CentralBank } from "./Bank.js"; export { BankBold as CentralBankBold } from "./BankBold.js"; +export { BankSolid as CentralBankSolid } from "./BankSolid.js"; export { BarsThree as CentralBarsThree } from "./BarsThree.js"; export { Bell as CentralBell } from "./Bell.js"; export { Bell2 as CentralBell2 } from "./Bell2.js";