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",
},