From f6a49a14851ff70ab0a0f67094b02dc6638b4b45 Mon Sep 17 00:00:00 2001 From: Jay-Kunaico Date: Tue, 20 Aug 2024 16:30:05 -0400 Subject: [PATCH 1/2] Added signals for tracking inputs --- .changeset/brave-carpets-teach.md | 5 +++ apps/docs/src/routes/examples/test.tsx | 25 +++++++-------- libs/components/src/otp/otp-context.ts | 6 ++-- libs/components/src/otp/otp-item.tsx | 27 ++++++++++++----- libs/components/src/otp/otp-native-input.tsx | 7 +++-- libs/components/src/otp/otp-root.tsx | 32 ++++++++++++++------ 6 files changed, 65 insertions(+), 37 deletions(-) create mode 100644 .changeset/brave-carpets-teach.md diff --git a/.changeset/brave-carpets-teach.md b/.changeset/brave-carpets-teach.md new file mode 100644 index 00000000..fae66c92 --- /dev/null +++ b/.changeset/brave-carpets-teach.md @@ -0,0 +1,5 @@ +--- +"@kunai-consulting/qwik-components": patch +--- + +Added signals to track the total number of input fields present and if all fields are filled. Applied invisible style to hiddenInput diff --git a/apps/docs/src/routes/examples/test.tsx b/apps/docs/src/routes/examples/test.tsx index 2ad6d353..74728156 100644 --- a/apps/docs/src/routes/examples/test.tsx +++ b/apps/docs/src/routes/examples/test.tsx @@ -1,14 +1,14 @@ -import { type PropsOf, component$ } from "@builder.io/qwik"; -import type { DocumentHead } from "@builder.io/qwik-city"; -import { VisuallyHidden } from "~/utils/visually-hidden"; -import { Otp } from "@kunai-consulting/qwik-components"; +import { type PropsOf, component$ } from '@builder.io/qwik'; +import type { DocumentHead } from '@builder.io/qwik-city'; +import { VisuallyHidden } from '~/utils/visually-hidden'; +import { Otp } from '@kunai-consulting/qwik-components'; export const head: DocumentHead = { - title: "Qwik Design System", + title: 'Qwik Design System', meta: [ { - name: "description", - content: "Qwik Design System", + name: 'description', + content: 'Qwik Design System', }, ], }; @@ -31,20 +31,17 @@ export default component$(() => { - - - +
- {Array.from({ length: 6 }, (_, index) => { + {Array.from({ length: 4 }, (_, index) => { const uniqueKey = `otp-${index}-${Date.now()}`; return ( @@ -75,7 +72,7 @@ export default component$(() => { ); }); -const InformationCircle = component$((props: PropsOf<"svg">) => { +const InformationCircle = component$((props: PropsOf<'svg'>) => { return ( ; activeIndexSig: Signal; nativeInputRef: Signal; + numItemsSig: Signal; + fullEntrySig: Signal; } -export const OTPContextId = createContextId("OTPContext"); +export const OTPContextId = createContextId('OTPContext'); diff --git a/libs/components/src/otp/otp-item.tsx b/libs/components/src/otp/otp-item.tsx index f30fe11c..db51090b 100644 --- a/libs/components/src/otp/otp-item.tsx +++ b/libs/components/src/otp/otp-item.tsx @@ -3,30 +3,36 @@ import { Slot, component$, createContextId, + useComputed$, useContext, useContextProvider, - useSignal -} from "@builder.io/qwik"; -import { OTPContextId } from "./otp-context"; + useSignal, +} from '@builder.io/qwik'; +import { OTPContextId } from './otp-context'; interface ItemContextType { index: number; } type OTPProps = { _index?: number; -} & PropsOf<"div">; +} & PropsOf<'div'>; -export const itemContextId = createContextId("qd-otp-item"); +export const itemContextId = createContextId('qd-otp-item'); export const OtpItem = component$(({ _index = 0, ...props }: OTPProps) => { const context = useContext(OTPContextId); const itemRef = useSignal(); const isFocused = useSignal(false); useContextProvider(itemContextId, { index: _index }); - const itemValue = context.value.value[_index] || ""; + const itemValue = context.value.value[_index] || ''; + const isFullEntry = useComputed$( + () => _index === context.numItemsSig.value - 1 + ); if (_index === undefined) { - throw new Error("Qwik UI: Otp Item must have an index. This is a bug in Qwik UI"); + throw new Error( + 'Qwik UI: Otp Item must have an index. This is a bug in Qwik UI' + ); } return ( @@ -34,7 +40,12 @@ export const OtpItem = component$(({ _index = 0, ...props }: OTPProps) => { {...props} ref={itemRef} data-qui-otp-item={_index} - data-highlighted={context.activeIndexSig.value === _index ? "" : undefined} + data-highlighted={ + context.activeIndexSig.value === _index || + (isFullEntry.value && context.fullEntrySig.value) + ? '' + : undefined + } onFocus$={() => { context.activeIndexSig.value = _index; isFocused.value = true; diff --git a/libs/components/src/otp/otp-native-input.tsx b/libs/components/src/otp/otp-native-input.tsx index bd312274..42e04ba4 100644 --- a/libs/components/src/otp/otp-native-input.tsx +++ b/libs/components/src/otp/otp-native-input.tsx @@ -1,6 +1,6 @@ // File: otp-native-input.tsx -import { component$, useContext } from "@builder.io/qwik"; -import { OTPContextId } from "./otp-context"; +import { component$, useContext } from '@builder.io/qwik'; +import { OTPContextId } from './otp-context'; export const OtpNativeInput = component$(() => { const context = useContext(OTPContextId); @@ -18,8 +18,9 @@ export const OtpNativeInput = component$(() => { context.value.value = input.value; context.activeIndexSig.value = input.value.length; }} - maxLength={6} + maxLength={context.numItemsSig.value} aria-label="Enter your OTP" + class="invisible" /> ); }); diff --git a/libs/components/src/otp/otp-root.tsx b/libs/components/src/otp/otp-root.tsx index bb9b7b44..0e5a8fa8 100644 --- a/libs/components/src/otp/otp-root.tsx +++ b/libs/components/src/otp/otp-root.tsx @@ -2,37 +2,49 @@ import { type PropsOf, Slot, component$, + useComputed$, useContextProvider, - useSignal -} from "@builder.io/qwik"; -import { findComponent, processChildren } from "../../utils/inline-component"; -import { OTPContextId } from "./otp-context"; -import { OtpItem } from "./otp-item"; - -type OtpRootProps = PropsOf<"div">; + useSignal, +} from '@builder.io/qwik'; +import { findComponent, processChildren } from '../../utils/inline-component'; +import { OTPContextId } from './otp-context'; +import { OtpItem } from './otp-item'; + +type OtpRootProps = PropsOf<'div'> & { + _numItems?: number; +}; export const OtpRoot = ({ children }: OtpRootProps) => { let currItemIndex = 0; + let numItems = 0; findComponent(OtpItem, (itemProps) => { itemProps._index = currItemIndex; currItemIndex++; + numItems = currItemIndex; }); processChildren(children); - return {children}; + return {children}; }; export const OtpBase = component$((props: OtpRootProps) => { - const value = useSignal(""); + const value = useSignal(''); const activeIndex = useSignal(0); const nativeInputRef = useSignal(); + const numItemsSig = useComputed$(() => props._numItems || 0); + + const fullEntrySig = useComputed$( + () => value.value.length === numItemsSig.value + ); const context = { value: value, activeIndexSig: activeIndex, - nativeInputRef: nativeInputRef + nativeInputRef: nativeInputRef, + numItemsSig, + fullEntrySig, }; useContextProvider(OTPContextId, context); From 580634c21173901cb700b89a11ae03ce6b7f2190 Mon Sep 17 00:00:00 2001 From: Jay-Kunaico Date: Wed, 21 Aug 2024 16:20:02 -0400 Subject: [PATCH 2/2] Updated focus state and added test --- .changeset/ninety-impalas-prove.md | 5 ++ apps/docs/src/routes/examples/test.tsx | 2 +- apps/docs/src/routes/otp/examples/hero.tsx | 95 ++++++++++++++++++++ libs/components/src/otp/otp-caret.tsx | 12 +-- libs/components/src/otp/otp-context.ts | 1 + libs/components/src/otp/otp-item.tsx | 11 +-- libs/components/src/otp/otp-native-input.tsx | 14 ++- libs/components/src/otp/otp-root.tsx | 2 + libs/components/src/otp/otp-test.test.ts | 20 +++++ 9 files changed, 148 insertions(+), 14 deletions(-) create mode 100644 .changeset/ninety-impalas-prove.md create mode 100644 apps/docs/src/routes/otp/examples/hero.tsx create mode 100644 libs/components/src/otp/otp-test.test.ts diff --git a/.changeset/ninety-impalas-prove.md b/.changeset/ninety-impalas-prove.md new file mode 100644 index 00000000..bfd0b911 --- /dev/null +++ b/.changeset/ninety-impalas-prove.md @@ -0,0 +1,5 @@ +--- +"@kunai-consulting/qwik-components": patch +--- + +Updated focus state, added test diff --git a/apps/docs/src/routes/examples/test.tsx b/apps/docs/src/routes/examples/test.tsx index 74728156..c809ec12 100644 --- a/apps/docs/src/routes/examples/test.tsx +++ b/apps/docs/src/routes/examples/test.tsx @@ -31,7 +31,7 @@ export default component$(() => {
- +
{Array.from({ length: 4 }, (_, index) => { diff --git a/apps/docs/src/routes/otp/examples/hero.tsx b/apps/docs/src/routes/otp/examples/hero.tsx new file mode 100644 index 00000000..c809ec12 --- /dev/null +++ b/apps/docs/src/routes/otp/examples/hero.tsx @@ -0,0 +1,95 @@ +import { type PropsOf, component$ } from '@builder.io/qwik'; +import type { DocumentHead } from '@builder.io/qwik-city'; +import { VisuallyHidden } from '~/utils/visually-hidden'; +import { Otp } from '@kunai-consulting/qwik-components'; + +export const head: DocumentHead = { + title: 'Qwik Design System', + meta: [ + { + name: 'description', + content: 'Qwik Design System', + }, + ], +}; + +export default component$(() => { + return ( +
+
+
+
+ +
+
+ Two-step verification +
+
+ A verification code has been sent to your email. Please enter the + code below to verify this device. +
+
+ + + + +
+ {Array.from({ length: 4 }, (_, index) => { + const uniqueKey = `otp-${index}-${Date.now()}`; + + return ( + + + | + + + ); + })} +
+
+ + This is a trusted device, don't ask again +
+
+
+ +
+
+
+ ); +}); + +const InformationCircle = component$((props: PropsOf<'svg'>) => { + return ( + + Information Circle + + + ); +}); diff --git a/libs/components/src/otp/otp-caret.tsx b/libs/components/src/otp/otp-caret.tsx index 952c426b..5efe28e0 100644 --- a/libs/components/src/otp/otp-caret.tsx +++ b/libs/components/src/otp/otp-caret.tsx @@ -1,11 +1,13 @@ -import { type PropsOf, Slot, component$, useContext } from "@builder.io/qwik"; -import { OTPContextId } from "./otp-context"; -import { itemContextId } from "./otp-item"; +import { type PropsOf, Slot, component$, useContext } from '@builder.io/qwik'; +import { OTPContextId } from './otp-context'; +import { itemContextId } from './otp-item'; -export const OtpCaret = component$(({ ...props }: PropsOf<"span">) => { +export const OtpCaret = component$(({ ...props }: PropsOf<'span'>) => { const itemContext = useContext(itemContextId); const context = useContext(OTPContextId); - const showCaret = context.activeIndexSig.value === itemContext.index; + const showCaret = + context.activeIndexSig.value === itemContext.index && + context.isFocusedSig.value; return {showCaret && }; }); diff --git a/libs/components/src/otp/otp-context.ts b/libs/components/src/otp/otp-context.ts index 95b80f13..cae152da 100644 --- a/libs/components/src/otp/otp-context.ts +++ b/libs/components/src/otp/otp-context.ts @@ -6,6 +6,7 @@ export interface OTPContext { nativeInputRef: Signal; numItemsSig: Signal; fullEntrySig: Signal; + isFocusedSig: Signal; } export const OTPContextId = createContextId('OTPContext'); diff --git a/libs/components/src/otp/otp-item.tsx b/libs/components/src/otp/otp-item.tsx index db51090b..cdcd9f1e 100644 --- a/libs/components/src/otp/otp-item.tsx +++ b/libs/components/src/otp/otp-item.tsx @@ -21,9 +21,7 @@ export const itemContextId = createContextId('qd-otp-item'); export const OtpItem = component$(({ _index = 0, ...props }: OTPProps) => { const context = useContext(OTPContextId); const itemRef = useSignal(); - const isFocused = useSignal(false); useContextProvider(itemContextId, { index: _index }); - const itemValue = context.value.value[_index] || ''; const isFullEntry = useComputed$( () => _index === context.numItemsSig.value - 1 @@ -41,14 +39,17 @@ export const OtpItem = component$(({ _index = 0, ...props }: OTPProps) => { ref={itemRef} data-qui-otp-item={_index} data-highlighted={ - context.activeIndexSig.value === _index || - (isFullEntry.value && context.fullEntrySig.value) + (context.isFocusedSig.value && + context.activeIndexSig.value === _index) || + (isFullEntry.value && + context.fullEntrySig.value && + !context.isFocusedSig.value === false) ? '' : undefined } onFocus$={() => { context.activeIndexSig.value = _index; - isFocused.value = true; + context.isFocusedSig.value = true; context.nativeInputRef.value?.focus(); }} > diff --git a/libs/components/src/otp/otp-native-input.tsx b/libs/components/src/otp/otp-native-input.tsx index 42e04ba4..dc63e11d 100644 --- a/libs/components/src/otp/otp-native-input.tsx +++ b/libs/components/src/otp/otp-native-input.tsx @@ -1,18 +1,27 @@ // File: otp-native-input.tsx -import { component$, useContext } from '@builder.io/qwik'; +import { component$, type PropsOf, useContext } from '@builder.io/qwik'; import { OTPContextId } from './otp-context'; -export const OtpNativeInput = component$(() => { +interface OtpNativeInputProps extends PropsOf<'input'> {} + +export const OtpNativeInput = component$((props: OtpNativeInputProps) => { const context = useContext(OTPContextId); return ( { + context.isFocusedSig.value = true; + }} + onBlur$={() => { + context.isFocusedSig.value = false; + }} onInput$={(event: InputEvent) => { const input = event.target as HTMLInputElement; context.value.value = input.value; @@ -20,7 +29,6 @@ export const OtpNativeInput = component$(() => { }} maxLength={context.numItemsSig.value} aria-label="Enter your OTP" - class="invisible" /> ); }); diff --git a/libs/components/src/otp/otp-root.tsx b/libs/components/src/otp/otp-root.tsx index 0e5a8fa8..6203c968 100644 --- a/libs/components/src/otp/otp-root.tsx +++ b/libs/components/src/otp/otp-root.tsx @@ -34,6 +34,7 @@ export const OtpBase = component$((props: OtpRootProps) => { const activeIndex = useSignal(0); const nativeInputRef = useSignal(); const numItemsSig = useComputed$(() => props._numItems || 0); + const isFocusedSig = useSignal(false); const fullEntrySig = useComputed$( () => value.value.length === numItemsSig.value @@ -45,6 +46,7 @@ export const OtpBase = component$((props: OtpRootProps) => { nativeInputRef: nativeInputRef, numItemsSig, fullEntrySig, + isFocusedSig, }; useContextProvider(OTPContextId, context); diff --git a/libs/components/src/otp/otp-test.test.ts b/libs/components/src/otp/otp-test.test.ts new file mode 100644 index 00000000..e04a90ec --- /dev/null +++ b/libs/components/src/otp/otp-test.test.ts @@ -0,0 +1,20 @@ +import { test, expect } from '@playwright/test'; + +test.describe('OTP Component Tests', () => { + test('should allow input into OTP fields and verify focus management', async ({ + page, + }) => { + await page.goto('http://localhost:6174/otp/hero'); + + const inputs = page.locator('input[data-qui-otp-native-input]'); + + await inputs.focus(); + // assumes 4 inputs + await inputs.fill('1234'); + expect(inputs).toHaveValue('1234'); + + // assumes 4 inputs + const lastOtpItem = page.locator('div[data-qui-otp-item="3"]'); + await expect(lastOtpItem).toHaveAttribute('data-highlighted', ''); + }); +});