From 952312870114c260421e60a70ef519967b032ba5 Mon Sep 17 00:00:00 2001 From: Andrea Scartabelli Date: Tue, 6 Aug 2024 17:37:53 +0200 Subject: [PATCH] web-wallet: Add `ExclusiveChoice` component Resolves #2070 --- .../ExclusiveChoice/ExclusiveChoice.css | 15 + .../ExclusiveChoice/ExclusiveChoice.svelte | 50 +++ .../__tests__/ExclusiveChoice.spec.js | 112 ++++++ .../ExclusiveChoice.spec.js.snap | 325 ++++++++++++++++++ web-wallet/src/lib/dusk/components/index.js | 1 + .../dusk/string/__tests__/randomUUID.spec.js | 89 +++++ web-wallet/src/lib/dusk/string/index.js | 1 + web-wallet/src/lib/dusk/string/randomUUID.js | 11 + .../routes/components-showcase/+page.svelte | 2 + .../ExclusiveChoices.svelte | 25 ++ .../dusk-components/exclusive-choice.css | 37 ++ web-wallet/src/style/main.css | 4 +- web-wallet/vite.config.js | 12 +- 13 files changed, 681 insertions(+), 3 deletions(-) create mode 100644 web-wallet/src/lib/dusk/components/ExclusiveChoice/ExclusiveChoice.css create mode 100644 web-wallet/src/lib/dusk/components/ExclusiveChoice/ExclusiveChoice.svelte create mode 100644 web-wallet/src/lib/dusk/components/__tests__/ExclusiveChoice.spec.js create mode 100644 web-wallet/src/lib/dusk/components/__tests__/__snapshots__/ExclusiveChoice.spec.js.snap create mode 100644 web-wallet/src/lib/dusk/string/__tests__/randomUUID.spec.js create mode 100644 web-wallet/src/lib/dusk/string/randomUUID.js create mode 100644 web-wallet/src/routes/components-showcase/ExclusiveChoices.svelte create mode 100644 web-wallet/src/style/dusk-components/exclusive-choice.css diff --git a/web-wallet/src/lib/dusk/components/ExclusiveChoice/ExclusiveChoice.css b/web-wallet/src/lib/dusk/components/ExclusiveChoice/ExclusiveChoice.css new file mode 100644 index 0000000000..264a9f57e3 --- /dev/null +++ b/web-wallet/src/lib/dusk/components/ExclusiveChoice/ExclusiveChoice.css @@ -0,0 +1,15 @@ +.dusk-exclusive-choice { + display: inline-flex; + align-items: center; + justify-content: center; +} + +.dusk-exclusive-choice__label { + flex: 1; +} + +.dusk-exclusive-choice__radio { + position: absolute; + top: -9999px; + left: -9999px; +} diff --git a/web-wallet/src/lib/dusk/components/ExclusiveChoice/ExclusiveChoice.svelte b/web-wallet/src/lib/dusk/components/ExclusiveChoice/ExclusiveChoice.svelte new file mode 100644 index 0000000000..7182b27975 --- /dev/null +++ b/web-wallet/src/lib/dusk/components/ExclusiveChoice/ExclusiveChoice.svelte @@ -0,0 +1,50 @@ + + + + +
+ {#each options as option (option)} + {@const isStringOption = isString(option)} + {@const optionValue = isStringOption ? option : option.value} + {@const id = `${baseId}-${optionValue}`} + + + {/each} +
diff --git a/web-wallet/src/lib/dusk/components/__tests__/ExclusiveChoice.spec.js b/web-wallet/src/lib/dusk/components/__tests__/ExclusiveChoice.spec.js new file mode 100644 index 0000000000..4f7bc89ef0 --- /dev/null +++ b/web-wallet/src/lib/dusk/components/__tests__/ExclusiveChoice.spec.js @@ -0,0 +1,112 @@ +import { afterAll, afterEach, describe, expect, it, vi } from "vitest"; +import { cleanup, fireEvent, render } from "@testing-library/svelte"; + +import { ExclusiveChoice } from ".."; + +vi.mock("$lib/dusk/string", async (importOriginal) => { + /** @type {typeof import("$lib/dusk/string")} */ + const original = await importOriginal(); + + return { + ...original, + randomUUID: () => "some-generated-id", + }; +}); + +describe("ExclusiveChoice", () => { + const stringOptions = ["one", "two", "three", "four"]; + + /** @type {SelectOption[]} */ + const objectOptionsA = [ + { label: "one", value: "1" }, + { label: "two", value: "2" }, + { disabled: true, label: "three", value: "3" }, + { label: "four", value: "4" }, + ]; + + /** @type {SelectOption[]} */ + const objectOptionsB = [ + { value: "1" }, + { value: "2" }, + { value: "3" }, + { value: "4" }, + ]; + + const baseProps = { + options: objectOptionsA, + value: "2", + }; + const baseOptions = { + props: baseProps, + target: document.body, + }; + + afterEach(cleanup); + + afterAll(() => { + vi.doUnmock("$lib/dusk/string"); + }); + + it("should render the `ExclusiveChoice` component", () => { + const { container } = render(ExclusiveChoice, baseOptions); + + expect(container.firstChild).toMatchSnapshot(); + }); + + it("should accept a custom name for the radio elements", () => { + const props = { + ...baseProps, + name: "my-custom-name", + }; + const { container } = render(ExclusiveChoice, { ...baseOptions, props }); + + expect(container.firstChild).toMatchSnapshot(); + }); + + it("should accept an array of options object without labels and use the value as labels", () => { + const props = { + ...baseProps, + options: objectOptionsB, + }; + const { container } = render(ExclusiveChoice, { ...baseOptions, props }); + + expect(container.firstChild).toMatchSnapshot(); + }); + + it("should accept an array of string as options", () => { + const props = { + ...baseProps, + options: stringOptions, + }; + const { container } = render(ExclusiveChoice, { ...baseOptions, props }); + + expect(container.firstChild).toMatchSnapshot(); + }); + + it("should pass additional class names and attributes to the rendered element", () => { + const props = { + ...baseProps, + className: "foo bar", + id: "some-id", + }; + const { container } = render(ExclusiveChoice, { ...baseOptions, props }); + + expect(container.firstChild).toMatchSnapshot(); + }); + + it("should accept a change event handler", async () => { + const changeHandler = vi.fn(); + const { component, container } = render(ExclusiveChoice, baseOptions); + const target = /** @type {HTMLInputElement} */ ( + container.querySelector("input[value='4']") + ); + + component.$on("change", changeHandler); + + await fireEvent.click(target); + + expect(changeHandler).toHaveBeenCalledTimes(1); + expect(changeHandler).toHaveBeenCalledWith(expect.any(Event)); + expect(target.checked).toBe(true); + }); +}); diff --git a/web-wallet/src/lib/dusk/components/__tests__/__snapshots__/ExclusiveChoice.spec.js.snap b/web-wallet/src/lib/dusk/components/__tests__/__snapshots__/ExclusiveChoice.spec.js.snap new file mode 100644 index 0000000000..ed265fe9af --- /dev/null +++ b/web-wallet/src/lib/dusk/components/__tests__/__snapshots__/ExclusiveChoice.spec.js.snap @@ -0,0 +1,325 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`ExclusiveChoice > should accept a custom name for the radio elements 1`] = ` +
+ + + + + + + + + + + + +
+`; + +exports[`ExclusiveChoice > should accept an array of options object without labels and use the value as labels 1`] = ` +
+ + + + + + + + + + + + +
+`; + +exports[`ExclusiveChoice > should accept an array of string as options 1`] = ` +
+ + + + + + + + + + + + +
+`; + +exports[`ExclusiveChoice > should pass additional class names and attributes to the rendered element 1`] = ` +
+ + + + + + + + + + + + +
+`; + +exports[`ExclusiveChoice > should render the \`ExclusiveChoice\` component 1`] = ` +
+ + + + + + + + + + + + +
+`; diff --git a/web-wallet/src/lib/dusk/components/index.js b/web-wallet/src/lib/dusk/components/index.js index d48db96914..71cfa640da 100644 --- a/web-wallet/src/lib/dusk/components/index.js +++ b/web-wallet/src/lib/dusk/components/index.js @@ -7,6 +7,7 @@ export { default as Card } from "./Card/Card.svelte"; export { default as Checkbox } from "./Checkbox/Checkbox.svelte"; export { default as ErrorAlert } from "./ErrorAlert/ErrorAlert.svelte"; export { default as ErrorDetails } from "./ErrorDetails/ErrorDetails.svelte"; +export { default as ExclusiveChoice } from "./ExclusiveChoice/ExclusiveChoice.svelte"; export { default as Icon } from "./Icon/Icon.svelte"; export { default as Mnemonic } from "./Mnemonic/Mnemonic.svelte"; export { default as ProgressBar } from "./ProgressBar/ProgressBar.svelte"; diff --git a/web-wallet/src/lib/dusk/string/__tests__/randomUUID.spec.js b/web-wallet/src/lib/dusk/string/__tests__/randomUUID.spec.js new file mode 100644 index 0000000000..cbddb68293 --- /dev/null +++ b/web-wallet/src/lib/dusk/string/__tests__/randomUUID.spec.js @@ -0,0 +1,89 @@ +import { afterAll, afterEach, describe, expect, it, vi } from "vitest"; +import { randomUUID as nodeRandomUUID } from "crypto"; +import { range } from "lamb"; + +describe("randomUUID", () => { + const originalCrypto = global.crypto; + const uuidRE = + /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/; + + afterEach(() => { + vi.resetModules(); + }); + + describe("with crypto API", () => { + if (!originalCrypto) { + Object.defineProperty(global, "crypto", { + value: { randomUUID: nodeRandomUUID }, + writable: false, + }); + + afterAll(() => { + global.crypto = originalCrypto; + }); + } + + it("should generate a v4 random UUID using the native crypto API if present", async () => { + const { randomUUID } = await import(".."); + const uuid = randomUUID(); + + expect(uuid.length).toBe(36); + }); + }); + + describe("without crypto API", () => { + it("should be able to generate a v4 random UUID without the crypto API", async () => { + const cryptoSpy = originalCrypto + ? vi + .spyOn(global, "crypto", "get") + //@ts-ignore + .mockReturnValue(undefined) + : null; + + const { randomUUID } = await import(".."); + const generated = new Set(); + + range(0, 100, 1).forEach(() => { + const uuid = randomUUID(); + + expect(uuidRE.test(uuid)).toBe(true); + + generated.add(uuid); + }); + + expect(generated.size).toBe(100); + + cryptoSpy?.mockRestore(); + }); + + it("should be able to generate a v4 random UUID if the crypto API is present, but its `randomUUID` function doesn't", async () => { + let originalRandomUUID; + + if (originalCrypto) { + originalRandomUUID = global.crypto.randomUUID; + Object.defineProperty(global.crypto, "randomUUID", { + value: undefined, + }); + } + + const { randomUUID } = await import(".."); + const generated = new Set(); + + range(0, 100, 1).forEach(() => { + const uuid = randomUUID(); + + expect(uuidRE.test(uuid)).toBe(true); + + generated.add(uuid); + }); + + expect(generated.size).toBe(100); + + if (originalRandomUUID) { + Object.defineProperty(global.crypto, "randomUUID", { + value: originalRandomUUID, + }); + } + }); + }); +}); diff --git a/web-wallet/src/lib/dusk/string/index.js b/web-wallet/src/lib/dusk/string/index.js index 73ed19da2e..31a43c9fc7 100644 --- a/web-wallet/src/lib/dusk/string/index.js +++ b/web-wallet/src/lib/dusk/string/index.js @@ -2,4 +2,5 @@ export { default as calculateAdaptiveCharCount } from "./calculateAdaptiveCharCo export { default as hexStringToBytes } from "./hexStringToBytes"; export { default as makeClassName } from "./makeClassName"; export { default as middleEllipsis } from "./middleEllipsis"; +export { default as randomUUID } from "./randomUUID"; export { default as validateAddress } from "./validateAddress"; diff --git a/web-wallet/src/lib/dusk/string/randomUUID.js b/web-wallet/src/lib/dusk/string/randomUUID.js new file mode 100644 index 0000000000..38de945df1 --- /dev/null +++ b/web-wallet/src/lib/dusk/string/randomUUID.js @@ -0,0 +1,11 @@ +const randomUUID = crypto?.randomUUID + ? () => crypto.randomUUID() + : () => + "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => { + const r = (Math.random() * 16) | 0; + const v = c === "x" ? r : (r & 0x3) | 0x8; + + return v.toString(16); + }); + +export default randomUUID; diff --git a/web-wallet/src/routes/components-showcase/+page.svelte b/web-wallet/src/routes/components-showcase/+page.svelte index 59af1a7ff5..b991bf8dcd 100644 --- a/web-wallet/src/routes/components-showcase/+page.svelte +++ b/web-wallet/src/routes/components-showcase/+page.svelte @@ -8,6 +8,7 @@ import Buttons from "./Buttons.svelte"; import Cards from "./Cards.svelte"; import Checkboxes from "./Checkboxes.svelte"; + import ExclusiveChoices from "./ExclusiveChoices.svelte"; import ProgressBars from "./ProgressBars.svelte"; import Selects from "./Selects.svelte"; import Steppers from "./Steppers.svelte"; @@ -26,6 +27,7 @@ Buttons: Buttons, Cards: Cards, Checkboxes: Checkboxes, + "Exclusive Choices": ExclusiveChoices, "Progress bars": ProgressBars, Selects: Selects, Steppers: Steppers, diff --git a/web-wallet/src/routes/components-showcase/ExclusiveChoices.svelte b/web-wallet/src/routes/components-showcase/ExclusiveChoices.svelte new file mode 100644 index 0000000000..768c0e412e --- /dev/null +++ b/web-wallet/src/routes/components-showcase/ExclusiveChoices.svelte @@ -0,0 +1,25 @@ + + + + +
+ +
+ +The selected value is {selected} diff --git a/web-wallet/src/style/dusk-components/exclusive-choice.css b/web-wallet/src/style/dusk-components/exclusive-choice.css new file mode 100644 index 0000000000..afcbfe1286 --- /dev/null +++ b/web-wallet/src/style/dusk-components/exclusive-choice.css @@ -0,0 +1,37 @@ +.dusk-exclusive-choice { + border-radius: var(--control-border-radius-size); + border-color: var(--control-tertiary-border-color); + border-style: solid; + border-width: var(--control-border-size); + overflow: hidden; +} + +.dusk-exclusive-choice__label { + display: inline-block; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + font-family: inherit; + letter-spacing: inherit; + line-height: 1; + text-align: center; + text-transform: uppercase; + background-color: var(--control-tertiary-bg-color); + color: var(--control-tertiary-text-color); + padding: var(--control-small-padding); +} + +.dusk-exclusive-choice__radio:checked + .dusk-exclusive-choice__label { + background-color: var(--control-bg-color); + color: var(--control-text-color); +} + +.dusk-exclusive-choice__radio:enabled:not(:checked) + + .dusk-exclusive-choice__label:hover { + background-color: var(--control-tertiary-bg-color-hover); + color: var(--control-text-color-hover); +} + +.dusk-exclusive-choice__radio:disabled + .dusk-exclusive-choice__label { + opacity: 0.5; +} diff --git a/web-wallet/src/style/main.css b/web-wallet/src/style/main.css index 29c488a5eb..ff620c4135 100644 --- a/web-wallet/src/style/main.css +++ b/web-wallet/src/style/main.css @@ -9,6 +9,7 @@ @import url("./dusk-components/checkbox.css"); @import url("./dusk-components/error-alert.css"); @import url("./dusk-components/error-details.css"); +@import url("./dusk-components/exclusive-choice.css"); @import url("./dusk-components/mnemonic.css"); @import url("./dusk-components/icon.css"); @import url("./dusk-components/progress-bar.css"); @@ -112,7 +113,8 @@ svg { .dusk-switch[aria-disabled="false"], .dusk-textbox:read-write:enabled, *[role="menuitem"] - ):focus { + ):focus, +.dusk-exclusive-choice:focus-within { border-color: var(--secondary-color-variant-dark); box-shadow: inset 0 0 0 var(--control-border-size) var(--secondary-color-variant-dark); diff --git a/web-wallet/vite.config.js b/web-wallet/vite.config.js index 780cfd2a42..64861c573a 100644 --- a/web-wallet/vite.config.js +++ b/web-wallet/vite.config.js @@ -1,5 +1,10 @@ -// eslint-disable-next-line import/no-unresolved +/* eslint-disable import/no-unresolved */ + import { sveltekit } from "@sveltejs/kit/vite"; +import { coverageConfigDefaults } from "vitest/config"; + +/* eslint-enable import/no-unresolved */ + import { defineConfig, loadEnv } from "vite"; import basicSsl from "@vitejs/plugin-basic-ssl"; import { nodePolyfills } from "vite-plugin-node-polyfills"; @@ -72,7 +77,10 @@ export default defineConfig(({ mode }) => { alias: [{ find: /^svelte$/, replacement: "svelte/internal" }], coverage: { all: true, - exclude: ["**/*.d.ts", "src/routes/components-showcase/**"], + exclude: [ + "src/routes/components-showcase/**", + ...coverageConfigDefaults.exclude, + ], include: ["src/**"], provider: "istanbul", },