Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2,938 changes: 1,240 additions & 1,698 deletions Cargo.lock

Large diffs are not rendered by default.

10 changes: 10 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,16 @@ tracing = { version = "0.1", features = ["std"] }
dioxus = "0.7.8"
dioxus-rsx = "0.7.8"

[patch.crates-io]
dioxus = { path = "../selection/packages/dioxus" }
dioxus-rsx = { path = "../selection/packages/rsx" }
dioxus-ssr = { path = "../selection/packages/ssr" }

[patch."https://github.com/ealmloff/dioxus"]
dioxus = { path = "../selection/packages/dioxus" }
dioxus-signals = { path = "../selection/packages/signals" }
lazy-js-bundle = { path = "../selection/packages/lazy-js-bundle" }

[profile.release]
opt-level = "z"
debug = false
Expand Down
1 change: 1 addition & 0 deletions component.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
"preview/src/components/tabs",
"preview/src/components/dropdown_menu",
"preview/src/components/navbar",
"preview/src/components/otp",
"preview/src/components/form",
"preview/src/components/tooltip",
"preview/src/components/calendar",
Expand Down
328 changes: 328 additions & 0 deletions playwright/otp.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,328 @@
import { test, expect, type Locator, type Page } from '@playwright/test';

const previewUrl = process.env.PREVIEW_URL ?? 'http://127.0.0.1:8080';
const otpUrl = new URL('/component/?name=otp&', previewUrl).toString();
const nonAsciiOtpUrl = new URL('/component/?name=otp&variant=non_ascii&', previewUrl).toString();

async function waitForOtpLayout(page: Page) {
const input = page.getByRole('textbox', { name: 'One-time password' });
const frame = page.locator('#component-preview-frame').first();
await expect
.poll(async () => {
const [inputBox, frameBox] = await Promise.all([
input.boundingBox(),
frame.boundingBox(),
]);

if (!inputBox || !frameBox) {
return false;
}

return (
inputBox.width < 400 &&
frameBox.width < 900 &&
inputBox.x >= frameBox.x &&
inputBox.x + inputBox.width <= frameBox.x + frameBox.width
);
})
.toBe(true);
await input.scrollIntoViewIfNeeded();
await expect(input).toBeInViewport();
}

function otpSlot(otp: Locator, index: number) {
return otp.locator('[data-empty]').nth(index);
}

test('otp typing, backspace, and rejection', async ({ page }) => {
await page.goto(otpUrl, { timeout: 20 * 60 * 1000 });

const input = page.getByRole('textbox', { name: 'One-time password' });
await expect(input).toBeVisible();
await expect(input).toHaveCSS('opacity', '0');

await input.focus();
await page.keyboard.type('123456');
await expect(page.locator('#otp-value')).toHaveText('123456');

await page.keyboard.press('Backspace');
await expect(page.locator('#otp-value')).toHaveText('12345');

await page.keyboard.press('a');
await expect(page.locator('#otp-value')).toHaveText('12345');
});

test('otp cursor does not drift past typed length', async ({ page }) => {
await page.goto(otpUrl, { timeout: 20 * 60 * 1000 });

const input = page.getByRole('textbox', { name: 'One-time password' });
await input.focus();
await page.keyboard.type('12');
// Three right-arrows from end should be no-ops — cursor is already at end.
await page.keyboard.press('ArrowRight');
await page.keyboard.press('ArrowRight');
await page.keyboard.press('ArrowRight');
// The next typed digit goes at position 2, not somewhere past it.
await page.keyboard.type('3');
await expect(page.locator('#otp-value')).toHaveText('123');
});

test('otp ArrowLeft replaces the active slot', async ({ page }) => {
await page.goto(otpUrl, { timeout: 20 * 60 * 1000 });

const input = page.getByRole('textbox', { name: 'One-time password' });
await input.focus();
await page.keyboard.type('12');
await page.keyboard.press('ArrowLeft');
await page.keyboard.type('9');
await expect(page.locator('#otp-value')).toHaveText('19');
});

test('otp input events replace the active slot', async ({ page }) => {
await page.goto(otpUrl, { timeout: 20 * 60 * 1000 });

const input = page.getByRole('textbox', { name: 'One-time password' });
await input.focus();
await page.keyboard.type('12');
await page.keyboard.press('ArrowLeft');

// insertText simulates paste / IME / on-screen-keyboard input without a
// keydown event for the inserted text.
await page.keyboard.insertText('9');
await page.keyboard.insertText('8');
await expect(page.locator('#otp-value')).toHaveText('198');
});

test('otp does not let the native input grow past maxlength', async ({ page }) => {
await page.goto(otpUrl, { timeout: 20 * 60 * 1000 });

const input = page.getByRole('textbox', { name: 'One-time password' });
await input.focus();
await page.keyboard.type('1234567');
await expect(page.locator('#otp-value')).toHaveText('123457');
await expect(input).toHaveValue('123457');

await page.keyboard.press('Home');
await page.keyboard.type('9');
await expect(page.locator('#otp-value')).toHaveText('923457');
await expect(input).toHaveValue('923457');

await page.keyboard.press('End');
await page.keyboard.type('8');
await expect(page.locator('#otp-value')).toHaveText('923458');
await expect(input).toHaveValue('923458');
});

test('otp keeps visual focus visible at the end', async ({ page }) => {
await page.goto(otpUrl, { timeout: 20 * 60 * 1000 });

const input = page.getByRole('textbox', { name: 'One-time password' });
const otp = input.locator('xpath=..');
await input.focus();
await page.keyboard.type('123456');

await expect(otp.locator('[data-active="true"]')).toHaveCount(1);
await expect(otpSlot(otp, 5)).toHaveAttribute('data-active', 'true');

await page.keyboard.press('ArrowRight');
await page.keyboard.press('End');
await expect(otp.locator('[data-active="true"]')).toHaveCount(1);
await expect(otpSlot(otp, 5)).toHaveAttribute('data-active', 'true');
await expect(otpSlot(otp, 5)).toHaveCSS('border-left-width', '1px');
await expect(otpSlot(otp, 5)).toHaveCSS('border-left-style', 'solid');
});

test('otp renders its own selection highlight', async ({ page }) => {
await page.goto(otpUrl, { timeout: 20 * 60 * 1000 });

const input = page.getByRole('textbox', { name: 'One-time password' });
const otp = input.locator('xpath=..');
await input.focus();
await page.keyboard.type('123456');

await page.keyboard.press('ControlOrMeta+A');
await expect(otp.locator('[data-selected="true"]')).toHaveCount(6);

await page.keyboard.type('9');
await expect(page.locator('#otp-value')).toHaveText('9');
await expect(otp.locator('[data-selected="true"]')).toHaveCount(0);
});

test('otp pointer selection highlights slots', async ({ page }) => {
await page.goto(otpUrl, { timeout: 20 * 60 * 1000 });

const input = page.getByRole('textbox', { name: 'One-time password' });
const otp = input.locator('xpath=..');
await input.focus();
await page.keyboard.type('123456');
await input.evaluate((node: HTMLInputElement) => node.blur());
await expect(input).not.toBeFocused();
await waitForOtpLayout(page);

const start = await otpSlot(otp, 1).boundingBox();
const end = await otpSlot(otp, 4).boundingBox();
expect(start).not.toBeNull();
expect(end).not.toBeNull();

await page.mouse.move(start!.x + start!.width / 2 + 1, start!.y + start!.height / 2);
await page.mouse.down();
await page.mouse.move(end!.x + end!.width / 2 + 1, end!.y + end!.height / 2, { steps: 5 });
await page.mouse.up();

await expect(otp.locator('[data-selected="true"]')).toHaveCount(4);
await expect(otpSlot(otp, 1)).toHaveAttribute('data-selection-start', 'true');
await expect(otpSlot(otp, 4)).toHaveAttribute('data-selection-end', 'true');

await page.keyboard.type('9');
await expect(page.locator('#otp-value')).toHaveText('196');
});

test('otp backward pointer selection includes the slot under the pointer', async ({ page }) => {
await page.goto(otpUrl, { timeout: 20 * 60 * 1000 });

const input = page.getByRole('textbox', { name: 'One-time password' });
const otp = input.locator('xpath=..');
await input.focus();
await page.keyboard.type('123456');
await waitForOtpLayout(page);

const start = await otpSlot(otp, 4).boundingBox();
const end = await otpSlot(otp, 1).boundingBox();
expect(start).not.toBeNull();
expect(end).not.toBeNull();

await page.mouse.move(start!.x + start!.width / 2, start!.y + start!.height / 2);
await page.mouse.down();
await page.mouse.move(end!.x + end!.width / 2, end!.y + end!.height / 2, { steps: 5 });
await page.mouse.up();

await expect(otp.locator('[data-selected="true"]')).toHaveCount(4);
await expect(otpSlot(otp, 4)).toHaveAttribute('data-selected', 'true');
await expect(otpSlot(otp, 1)).toHaveAttribute('data-selection-start', 'true');
await expect(otpSlot(otp, 1)).toHaveCSS('border-left-width', '1px');
await expect(otpSlot(otp, 1)).toHaveCSS('border-left-style', 'solid');

await page.keyboard.type('9');
await expect(page.locator('#otp-value')).toHaveText('196');
});

test('otp copy cut and paste use the visible selection', async ({
page,
context,
browserName,
}) => {
test.skip(browserName !== 'chromium', 'Clipboard permissions are Chromium-only in Playwright.');

await context.grantPermissions(['clipboard-read', 'clipboard-write']);
await page.goto(otpUrl, { timeout: 20 * 60 * 1000 });

const input = page.getByRole('textbox', { name: 'One-time password' });
const otp = input.locator('xpath=..');
await input.focus();
await page.keyboard.type('123456');

await page.keyboard.press('ControlOrMeta+A');
await expect(otp.locator('[data-selected="true"]')).toHaveCount(6);

await page.keyboard.press('ControlOrMeta+C');
await expect.poll(() => page.evaluate(() => navigator.clipboard.readText())).toBe('123456');

await page.evaluate(() => navigator.clipboard.writeText('98'));
await page.keyboard.press('ControlOrMeta+V');
await expect(page.locator('#otp-value')).toHaveText('98');

await page.keyboard.press('ControlOrMeta+A');
await page.keyboard.press('ControlOrMeta+X');
await expect.poll(() => page.evaluate(() => navigator.clipboard.readText())).toBe('98');
await expect(page.locator('#otp-value')).toHaveText('');
});

test('otp paste fills all slots', async ({ page }) => {
await page.goto(otpUrl, { timeout: 20 * 60 * 1000 });

const input = page.getByRole('textbox', { name: 'One-time password' });
await input.focus();
// insertText simulates a paste / IME / on-screen-keyboard input event.
await page.keyboard.insertText('987654');
await expect(page.locator('#otp-value')).toHaveText('987654');
});

test('otp accepts emoji input in the non-ascii variant', async ({ page }) => {
await page.goto(nonAsciiOtpUrl, { timeout: 20 * 60 * 1000 });

const input = page.getByRole('textbox', { name: 'Emoji code' });
await expect(input).toBeVisible();
await input.focus();

for (const emoji of ['😀', '😃', '😄', '😁']) {
await page.keyboard.insertText(emoji);
}

const value = '😀😃😄😁';
await expect(page.locator('#otp-non-ascii-value')).toHaveText(value);
await expect(page.locator('#otp-non-ascii-complete')).toHaveText(value);
await expect(input).toHaveValue(value);
});

test('otp on_complete fires only when the value reaches maxlength', async ({ page }) => {
await page.goto(otpUrl, { timeout: 20 * 60 * 1000 });

const input = page.getByRole('textbox', { name: 'One-time password' });
const complete = page.locator('#otp-complete');

await input.focus();
await page.keyboard.type('12345');
await expect(page.locator('#otp-value')).toHaveText('12345');
await expect(complete).toHaveText('');

await page.keyboard.type('6');
await expect(complete).toHaveText('123456');

// Editing back below maxlength should not re-fire complete; the last value sticks.
await page.keyboard.press('Backspace');
await expect(page.locator('#otp-value')).toHaveText('12345');
await expect(complete).toHaveText('123456');
});

test('otp on_complete does not re-fire when editing a full buffer', async ({ page }) => {
await page.goto(otpUrl, { timeout: 20 * 60 * 1000 });

const input = page.getByRole('textbox', { name: 'One-time password' });
const complete = page.locator('#otp-complete');

await input.focus();
await page.keyboard.type('123456');
await expect(complete).toHaveText('123456');

// Move into the full buffer and type. The keydown handler replaces the active
// slot, keeping length at maxlength but changing the value. This is
// NOT a transition to maxlength, so on_complete must not fire again.
await page.keyboard.press('Home');
await page.keyboard.press('9');
await expect(page.locator('#otp-value')).toHaveText('923456');
await expect(complete).toHaveText('123456');
});

test('otp disabled state blocks input', async ({ page }) => {
await page.goto(otpUrl, { timeout: 20 * 60 * 1000 });

const input = page.getByRole('textbox', { name: 'One-time password' });
await page
.locator('#otp-toggle-disabled')
.evaluate((node: HTMLButtonElement) => node.click());
await expect(input).toBeDisabled();

// Typing into a disabled input is a no-op in the browser.
await input.focus({ timeout: 1000 }).catch(() => { /* focus may be refused while disabled */ });
await page.keyboard.type('123');
await expect(page.locator('#otp-value')).toHaveText('');

// Re-enable and confirm input works again.
await page
.locator('#otp-toggle-disabled')
.evaluate((node: HTMLButtonElement) => node.click());
await expect(input).toBeEnabled();
await input.focus();
await page.keyboard.type('123');
await expect(page.locator('#otp-value')).toHaveText('123');
});
2 changes: 1 addition & 1 deletion preview/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ rust-version = "1.88.0"
[dependencies]
dioxus = { workspace = true, features = ["router"] }
dioxus-code = { version = "0.1.0", default-features = false, features = ["macro", "lang-css"] }
dioxus-icons = "0.1.0"
dioxus-icons = { git = "https://github.com/ealmloff/dioxus-icons", branch = "bump-dioxus" }
dioxus-primitives.workspace = true
dioxus-i18n = { git = "https://github.com/ealmloff/dioxus-i18n", branch = "bump-dioxus" }
palette = "0.7.6"
Expand Down
1 change: 1 addition & 0 deletions preview/src/components/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,7 @@ examples!(
label,
menubar,
navbar,
otp[non_ascii],
pagination,
popover,
progress,
Expand Down
13 changes: 13 additions & 0 deletions preview/src/components/otp/component.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
{
"name": "otp",
"description": "An accessible, composable one-time-password input.",
"authors": ["Evan Almloff"],
"exclude": ["variants", "docs.md", "component.json"],
"cargoDependencies": [
{
"name": "dioxus-primitives",
"git": "https://github.com/DioxusLabs/components"
}
],
"globalAssets": ["../../../assets/dx-components-theme.css"]
}
Loading
Loading