diff --git a/packages/auth/src/ui/css.ts b/packages/auth/src/ui/css.ts index dacb86b0..78778f55 100644 --- a/packages/auth/src/ui/css.ts +++ b/packages/auth/src/ui/css.ts @@ -565,9 +565,10 @@ form:has([data-component='input']:invalid) [data-component='button']:hover { } -/* A wrong answer shakes the field it came from, once. Every error is a new - page, so an animation that runs on load runs exactly when one arrives — - no script needed to trigger it. */ +/* A wrong answer shakes the field it came from, once, and rings it red while + it does. Every error is a new page, so an animation that runs on load runs + exactly when one arrives — no script needed to trigger it. \`segments.ts\` + plays the same two when a code field refuses a character. */ @keyframes shake { 0%, 100% { translate: 0; } 15%, 55% { translate: -0.5rem; } @@ -575,8 +576,18 @@ form:has([data-component='input']:invalid) [data-component='button']:hover { 90% { translate: -0.2rem; } } +/* No end frame, so it fades back to whatever border the field has now. */ +@keyframes alarm { + 0%, 60% { + border-color: var(--color-red-600); + box-shadow: 0 0 0 1px var(--color-red-600); + } +} + form:has([data-component='form-alert'][data-color='danger']) [data-component='input'] { - animation: shake 400ms ease-in-out; + animation: + shake 400ms ease-in-out, + alarm 1s ease-out; } [data-component='input'][data-variant='code']::placeholder { @@ -615,7 +626,11 @@ form:has([data-component='form-alert'][data-color='danger']) [data-component='in scale: 1; } - form:has([data-component='form-alert'][data-color='danger']) [data-component='input'], + /* The red stays: it is the message. Only the movement goes. */ + form:has([data-component='form-alert'][data-color='danger']) [data-component='input'] { + animation: alarm 1s ease-out; + } + [data-component='input'][data-variant='box'][data-active]:empty::after { animation: none; } diff --git a/packages/auth/src/ui/segments.ts b/packages/auth/src/ui/segments.ts index 0485f38d..6553fcb6 100644 --- a/packages/auth/src/ui/segments.ts +++ b/packages/auth/src/ui/segments.ts @@ -64,6 +64,34 @@ export default `(() => { if (real.selectionStart !== end || real.selectionEnd !== end) real.setSelectionRange(end, end); }; + // A refused character gets the same answer as a wrong code: the boxes + // shake and ring red. Silently dropping it looked exactly like a field + // that does not work. Played through script rather than a class, because + // the error page may already be running the stylesheet's own shake on + // these boxes, and toggling a class cannot restart an animation that is + // still named the same. + const refuse = () => { + const red = getComputedStyle(root).getPropertyValue('--color-red-600').trim() || 'red'; + const ring = { borderColor: red, boxShadow: '0 0 0 1px ' + red }; + const still = window.matchMedia('(prefers-reduced-motion: reduce)').matches; + boxes.forEach((box) => { + box.animate([ring, Object.assign({ offset: 0.6 }, ring)], { duration: 1000, easing: 'ease-out' }); + if (!still) + box.animate( + [ + { translate: '0' }, + { translate: '-0.5rem', offset: 0.15 }, + { translate: '0.5rem', offset: 0.35 }, + { translate: '-0.5rem', offset: 0.55 }, + { translate: '0.5rem', offset: 0.75 }, + { translate: '-0.2rem', offset: 0.9 }, + { translate: '0' } + ], + { duration: 400, easing: 'ease-in-out' } + ); + }); + }; + const render = () => { const value = real.value; const focused = document.activeElement === real; @@ -75,6 +103,11 @@ export default `(() => { }; real.addEventListener('input', () => { + // Separators and spaces are part of how a code is written, not a + // mistake; anything else that \`clean\` drops is. + const written = real.value.replace(/[\\s-]/g, ''); + const allowed = numeric ? /^[0-9]*$/ : /^[0-9a-zA-Z]*$/; + if (!allowed.test(written)) refuse(); const cleaned = clean(real.value); if (cleaned !== real.value) real.value = cleaned; toEnd();