Skip to content

Instantly share code, notes, and snippets.

@CodeMyUI
Last active May 25, 2020 23:51
Show Gist options
  • Save CodeMyUI/37d0c5590d27481efbef86d7d319ea90 to your computer and use it in GitHub Desktop.
Save CodeMyUI/37d0c5590d27481efbef86d7d319ea90 to your computer and use it in GitHub Desktop.
Password Modal with Finite State Machine
<form id="app" class="ui-modal" data-state="idle" autocomplete="off">
<div class="ui-icon">
<div class="ui-lock"></div>
</div>
<div class="ui-title">This link is password-protected</div>
<div class="ui-subtitle">
<span data-show="idle">
Please enter the password to view this link.
</span>
<span data-show="validating">
Validating...
</span>
<span data-show="error" class="ui-error">
Invalid password
</span>
<span data-show="success">
<a class="ui-link" href="https://xstate.js.org" target="_blank">xstate.js.org</a>
</span>
</div>
<div class="ui-password">
<input type="password" name="" id="" class="ui-password-input" placeholder="the password is password" />
</div>
<button class="ui-submit">Submit</button>
<button class="ui-reset" type="button" title="Reset"></button>
</form>
console.clear();
const elApp = document.querySelector("#app");
const elButton = document.querySelector(".ui-submit");
const elPassword = document.querySelector(".ui-password-input");
const elReset = document.querySelector(".ui-reset");
const context = {
password: ""
};
const actions = {
assignPassword: XState.assign({
password: (_, event) => event.value
}),
validatePassword: ctx => {
setTimeout(() => {
if (ctx.password === "password") {
send("VALID");
} else {
send("INVALID");
}
}, 2000);
},
clearPassword: XState.assign({
password: () => {
elPassword.value = '';
return '';
}
})
};
const passwordMachine = XState.Machine(
{
initial: "idle",
context,
states: {
idle: {
entry: "clearPassword",
on: {
SUBMIT: { target: "validating", cond: "passwordEntered" },
CHANGE: {
target: "idle",
actions: "assignPassword",
internal: true // this prevents onEntry from running again
}
},
initial: 'normal',
states: {
normal: {},
error: {
after: {
2000: "normal"
}
}
}
},
validating: {
onEntry: "validatePassword",
on: {
VALID: "success",
INVALID: "idle.error"
}
},
success: {}
},
on: {
RESET: ".idle"
}
},
{
actions,
guards: {
passwordEntered: ctx => {
return ctx.password && ctx.password.length
}
}
}
);
let state = passwordMachine.initialState;
function activate(state) {
elApp.dataset.state = state.toStrings().join(' ');
document.querySelectorAll("[data-active]").forEach(el => {
el.removeAttribute("data-active");
});
document.querySelectorAll(`[data-show~="${state.value}"]`).forEach(el => {
el.setAttribute("data-active", true);
});
}
const interpreter = XState
.interpret(passwordMachine)
.onTransition(activate)
.start();
activate(state);
const { send } = interpreter;
elButton.addEventListener("click", () => send("SUBMIT"));
elPassword.addEventListener("input", e =>
send({ type: "CHANGE", value: e.target.value })
);
elApp.addEventListener("submit", e => {
e.preventDefault();
send("SUBMIT");
});
elReset.addEventListener("click", () => send("RESET"));
<script src="https://unpkg.com/xstate@next/dist/xstate.js"></script>
$easing: cubic-bezier(.5, 0, .5, 1);
*, *:before, *:after {
transition: all .5s $easing;
transition-property: transform, opacity, background-color, border-color;
transition-delay: 0s;
}
#app {
&:after {
content: attr(data-state);
position: absolute;
top: 100%;
margin-top: 1rem;
font-size: 5vmin;
background: rgba(black, 0.5);
color: white;
padding: 2vmin 5vmin;
border-radius: 5vmin;
}
}
[data-show] {
opacity: 0;
&[data-active] {
opacity: 1;
}
}
[data-state~="idle"] {
animation: reset 1s $easing both;
.ui-icon {
--bg: #E3E6F9;
--color: var(--color-primary);
}
.ui-password {
&:before {
background-color: var(--color-primary);
transform: translateX(-100%);
}
&:focus-within:before {
transform: none;
}
}
}
[data-state~="validating"] {
.ui-icon {
--bg: #E3E6F9;
--color: var(--color-primary);
}
.ui-password {
&:before {
transform-origin: left center;
background-color: var(--color-primary);
animation: password-validating 1s infinite;
}
}
.ui-submit {
opacity: .5;
pointer-events: none;
}
}
[data-state~="idle.error"] {
.ui-icon {
background-color: #FAD0D8;
--color: var(--color-error);
animation: icon-error 1s cubic-bezier(.5, 0, .5, 1) both;
}
.ui-password {
&:before {
animation: slide-right .5s $easing both;
background-color: #E2294E;
}
}
}
[data-state~="success"] {
.ui-icon {
--bg: var(--color-success);
--color: var(--color-success);
&:before {
animation: icon-bg-success .5s ease-out both;
}
}
.ui-password {
&:before {
animation: slide-right .5s $easing both;
background-color: var(--color-success);
}
}
}
[data-state]:not([data-state~="idle"]) {
.ui-password-input {
pointer-events: none;
cursor: not-allowed;
opacity: 0.5;
}
.ui-submit {
opacity: 0.5;
}
}
@keyframes password-validating {
from {
transform: translateX(-100%) scaleX(.5);
}
to {
transform: translateX(100%) scaleX(.5);
}
}
@keyframes slide-right {
from {
transform: translateX(-100%);
}
to {
transform: none;
}
}
@keyframes icon-error {
from, 85%, to {
transform: none;
}
20%, 50% {
transform-origin: right center;
transform: translateX(-30%) scaleX(1.1);
}
35%, 65% {
transform-origin: left center;
transform: translateX(30%) scaleX(1.1);
}
}
@keyframes icon-bg-success {
from {
transform: scale(1);
opacity: .5;
}
to {
transform: scale(4);
opacity: 0;
}
}
.ui-modal {
--color-primary: #5A52FF;
--color-error: #E0294C;
--color-success: #0DBE65;
background-color: #fff;
padding: 2rem 4rem;
border-radius: .5rem;
display: flex;
flex-direction: column;
justify-content: flex-start;
align-items: center;
box-shadow: 0 1rem 2rem rgba(black, 0.1)
}
.ui-icon {
height: 3rem;
width: 3rem;
border-radius: 50%;
margin-bottom: 1rem;
&:before {
content: '';
position: absolute;
display: block;
top: 0;
left: 0;
width: 100%;
height: 100%;
border-radius: inherit;
background: var(--bg);
will-change: transform;
}
> .ui-lock {
height: 100%;
width: 100%;
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
display: flex;
justify-content: center;
align-items: center;
&:before, &:after {
content: '';
position: absolute;
}
&:after {
background-color: var(--color);
height: 25%;
width: 45%;
transform: translateY(50%);
border-radius: 2px;
}
&:before {
width: 30%;
height: 50%;
border-radius: 1rem;
border: 4px solid var(--color);
}
}
}
.ui-title {
font-size: 1rem;
line-height: 2rem;
}
.ui-subtitle {
font-size: .75rem;
height: 1rem;
margin-bottom: 1rem;
display: flex;
justify-content: center;
align-items: center;
color: #9A9CA2;
> span {
line-height: 1rem;
position: absolute;
white-space: nowrap;
&.ui-error {
font-weight: bold;
color: var(--color-error);
}
}
}
.ui-password {
appearance: none;
background: none;
border: none;
padding-bottom: 2px;
margin-bottom: 2rem;
overflow: hidden;
&:before, &:after {
content: '';
position: absolute;
height: 2px;
width: 100%;
bottom: 0;
left: 0;
z-index: 1;
}
&:after {
background-color: #E8E9F0;
z-index: 0;
}
}
.ui-password-input {
appearance: none;
background: none;
border: none;
height: 2rem;
width: 15rem;
&:focus {
outline: none;
}
}
.ui-submit {
appearance: none;
padding: 0 1.5rem;
height: 2rem;
border-radius: .5rem;
font-size: 0.75rem;
color: white;
background-color: var(--color-primary);
&:active {
transform: scale(0.9);
transition-duration: .2s;
}
&:focus {
outline: none;
}
}
.ui-link {
color: var(--color-primary);
text-decoration: none;
}
.ui-reset {
appearance: none;
background: none;
border: none;
position: absolute;
top: 0;
right: 0;
padding: 1rem;
&:before {
content: 'x';
color: #AAAFBD;
font-weight: bold;
font-size: 1.5rem;
}
&:focus {
outline: none;
}
}
// -=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=
body {
display: flex;
justify-content: center;
align-items: center;
background-color: #CED0E0;
}
html, body {
width: 100%;
height: 100%;
margin: 0;
padding: 0;
font-size: 18px;
}
*, *:before, *:after {
box-sizing: border-box;
position: relative;
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment