Sign-in Set

部品 · フォーム

見られた数 338コピーされた数 0

MIT · コードの1行目に作者の名前が残ります

<!-- UI by @Lattice_Node — ludion.ai/p/auth-set (MIT) -->
<div data-ld-part="auth-set">
  <!-- Sign in -->
  <section class="screen" data-screen="sign-in" aria-labelledby="as-signin-title">
    <div class="pad">
      <header class="head">
        <h2 class="title" id="as-signin-title" tabindex="-1">Sign in</h2>
        <p class="lead">Welcome back. Enter your details to continue.</p>
      </header>
      <div class="providers">
        <p class="caption">Continue with</p>
        <div class="provider-row">
          <button class="provider" type="button"><span class="icon-slot" aria-hidden="true"></span><span class="vh">Continue with </span>GitHub</button>
          <button class="provider" type="button"><span class="icon-slot" aria-hidden="true"></span><span class="vh">Continue with </span>Google</button>
          <button class="provider" type="button"><span class="icon-slot" aria-hidden="true"></span><span class="vh">Sign in with a </span>Passkey</button>
        </div>
      </div>
    </div>

    <form class="form" data-form="sign-in" novalidate>
      <div class="pad fields">
        <div class="field">
          <label class="label" for="as-signin-email"><span class="num" aria-hidden="true">01</span>Email</label>
          <input class="input" id="as-signin-email" name="email" type="email" autocomplete="email" inputmode="email" spellcheck="false" required data-validate="email" aria-describedby="as-signin-email-error">
          <p class="error" id="as-signin-email-error" hidden></p>
        </div>
        <div class="field">
          <div class="label-row">
            <label class="label" for="as-signin-password"><span class="num" aria-hidden="true">02</span>Password</label>
            <button class="link small" type="button" data-go="reset">Forgot password?</button>
          </div>
          <div class="control">
            <input class="input has-reveal" id="as-signin-password" name="password" type="password" autocomplete="current-password" required data-validate="password" aria-describedby="as-signin-password-error as-signin-password-caps">
            <button class="reveal" type="button" aria-controls="as-signin-password"><span class="reveal-text">Show</span><span class="vh"> password</span></button>
          </div>
          <p class="caps" id="as-signin-password-caps" hidden>Caps Lock is on</p>
          <p class="error" id="as-signin-password-error" hidden></p>
        </div>
        <p class="aside">New here? <button class="link" type="button" data-go="sign-up">Create account</button></p>
      </div>
      <button class="submit" type="submit" data-busy="Signing in…"><span class="submit-text">Sign in</span><svg class="arrow" viewBox="0 0 24 24" aria-hidden="true"><path d="M3 12h17M14 6l6 6-6 6"/></svg></button>
    </form>
  </section>

  <!-- Create account -->
  <section class="screen" data-screen="sign-up" aria-labelledby="as-signup-title" hidden>
    <div class="pad">
      <header class="head">
        <h2 class="title" id="as-signup-title" tabindex="-1">Create account</h2>
        <p class="lead">It takes less than a minute.</p>
      </header>
      <div class="providers">
        <p class="caption">Continue with</p>
        <div class="provider-row">
          <button class="provider" type="button"><span class="icon-slot" aria-hidden="true"></span><span class="vh">Continue with </span>GitHub</button>
          <button class="provider" type="button"><span class="icon-slot" aria-hidden="true"></span><span class="vh">Continue with </span>Google</button>
        </div>
      </div>
    </div>

    <form class="form" data-form="sign-up" novalidate>
      <div class="pad fields">
        <div class="field">
          <label class="label" for="as-signup-email"><span class="num" aria-hidden="true">01</span>Email</label>
          <input class="input" id="as-signup-email" name="email" type="email" autocomplete="email" inputmode="email" spellcheck="false" required data-validate="email" aria-describedby="as-signup-email-error">
          <p class="error" id="as-signup-email-error" hidden></p>
        </div>
        <div class="field">
          <label class="label" for="as-signup-password"><span class="num" aria-hidden="true">02</span>Password</label>
          <div class="control">
            <input class="input has-reveal" id="as-signup-password" name="password" type="password" autocomplete="new-password" required data-validate="new-password" aria-describedby="as-signup-password-error as-signup-password-caps as-signup-rules">
            <button class="reveal" type="button" aria-controls="as-signup-password"><span class="reveal-text">Show</span><span class="vh"> password</span></button>
          </div>
          <p class="caps" id="as-signup-password-caps" hidden>Caps Lock is on</p>
          <p class="error" id="as-signup-password-error" hidden></p>
          <ul class="rules" id="as-signup-rules" aria-label="Password requirements">
            <li class="rule" data-rule="length"><span class="vh" data-rule-state>Not yet: </span>8+ characters</li>
            <li class="rule" data-rule="case"><span class="vh" data-rule-state>Not yet: </span>Upper &amp; lower case</li>
            <li class="rule" data-rule="number"><span class="vh" data-rule-state>Not yet: </span>One number</li>
          </ul>
        </div>
        <p class="aside">Already have an account? <button class="link" type="button" data-go="sign-in">Sign in</button></p>
      </div>
      <button class="submit" type="submit" data-busy="Creating account…"><span class="submit-text">Create account</span><svg class="arrow" viewBox="0 0 24 24" aria-hidden="true"><path d="M3 12h17M14 6l6 6-6 6"/></svg></button>
    </form>
  </section>

  <!-- Reset password -->
  <section class="screen" data-screen="reset" aria-labelledby="as-reset-title" hidden>
    <div class="pad">
      <header class="head">
        <h2 class="title" id="as-reset-title" tabindex="-1">Reset password</h2>
        <p class="lead">Enter the email you signed up with. We’ll send you a link to choose a new password.</p>
      </header>
    </div>

    <form class="form" data-form="reset" novalidate>
      <div class="pad fields">
        <div class="field">
          <label class="label" for="as-reset-email"><span class="num" aria-hidden="true">01</span>Email</label>
          <input class="input" id="as-reset-email" name="email" type="email" autocomplete="email" inputmode="email" spellcheck="false" required data-validate="email" aria-describedby="as-reset-email-error">
          <p class="error" id="as-reset-email-error" hidden></p>
        </div>
        <p class="aside"><button class="link" type="button" data-go="sign-in">Back to sign in</button></p>
      </div>
      <button class="submit" type="submit" data-busy="Sending link…"><span class="submit-text">Send reset link</span><svg class="arrow" viewBox="0 0 24 24" aria-hidden="true"><path d="M3 12h17M14 6l6 6-6 6"/></svg></button>
    </form>

    <div class="pad done" data-done tabindex="-1" hidden>
      <p class="done-text"><span class="done-mark" aria-hidden="true"></span>If an account exists for <strong data-email>your email</strong>, a reset link is on its way. It expires in 30 minutes.</p>
      <p class="aside"><button class="link" type="button" data-go="sign-in">Back to sign in</button></p>
    </div>
  </section>

  <!-- Verify code -->
  <section class="screen" data-screen="verify" aria-labelledby="as-verify-title" hidden>
    <div class="pad">
      <header class="head">
        <h2 class="title" id="as-verify-title" tabindex="-1">Check your email</h2>
        <p class="lead">We sent a 6-digit code to <strong data-email>your email</strong>.</p>
      </header>
    </div>

    <form class="form" data-form="verify" novalidate>
      <div class="pad fields">
        <fieldset class="code-field">
          <legend class="label"><span class="num" aria-hidden="true">01</span>Verification code</legend>
          <div class="code" data-code>
            <input class="digit" type="text" inputmode="numeric" autocomplete="one-time-code" pattern="[0-9]*" aria-label="Digit 1 of 6" aria-describedby="as-verify-error">
            <input class="digit" type="text" inputmode="numeric" autocomplete="off" pattern="[0-9]*" aria-label="Digit 2 of 6" aria-describedby="as-verify-error">
            <input class="digit" type="text" inputmode="numeric" autocomplete="off" pattern="[0-9]*" aria-label="Digit 3 of 6" aria-describedby="as-verify-error">
            <input class="digit" type="text" inputmode="numeric" autocomplete="off" pattern="[0-9]*" aria-label="Digit 4 of 6" aria-describedby="as-verify-error">
            <input class="digit" type="text" inputmode="numeric" autocomplete="off" pattern="[0-9]*" aria-label="Digit 5 of 6" aria-describedby="as-verify-error">
            <input class="digit" type="text" inputmode="numeric" autocomplete="off" pattern="[0-9]*" aria-label="Digit 6 of 6" aria-describedby="as-verify-error">
          </div>
          <p class="error" id="as-verify-error" hidden></p>
        </fieldset>
        <p class="aside">Didn’t get it? <button class="link" type="button" data-resend>Resend code</button></p>
        <p class="status" role="status" data-status></p>
        <p class="aside"><button class="link" type="button" data-go="sign-in">Back to sign in</button></p>
      </div>
      <button class="submit" type="submit" data-busy="Verifying…"><span class="submit-text">Verify</span><svg class="arrow" viewBox="0 0 24 24" aria-hidden="true"><path d="M3 12h17M14 6l6 6-6 6"/></svg></button>
    </form>

    <div class="pad done" data-done tabindex="-1" hidden>
      <p class="done-text"><span class="done-mark" aria-hidden="true"></span>You’re verified and signed in.</p>
      <p class="aside"><button class="link" type="button" data-go="sign-in">Back to sign in</button></p>
    </div>
  </section>
</div>
<style>
/* UI by @Lattice_Node — ludion.ai/p/auth-set (MIT) */
[data-ld-part="auth-set"] {
  /* 固定しないトークン:使い手の値を受け、なければ作者の値。色はどれも固定しない */
  --_bg: var(--ld-bg, #ecebe6);
  --_surface: var(--ld-surface, #fbfbf9);
  --_fg: var(--ld-fg, #111111);
  --_muted: var(--ld-muted, #66655f);
  --_accent: var(--ld-accent, #c8371a);
  --_radius: var(--ld-radius, 0px);
  --_font: var(--ld-font, "Helvetica Neue", Helvetica, Arial, sans-serif);
  --_space: var(--ld-space, 8px);
  --_shadow: var(--ld-shadow, 0 1px 2px rgb(17 17 17 / .06), 0 18px 40px rgb(17 17 17 / .08));
  --_motion: var(--ld-motion, 140ms);

  position: relative;
  box-sizing: border-box;
  width: min(100%, 400px);
  overflow: hidden;
  background: var(--_surface);
  border-radius: var(--_radius);
  box-shadow: var(--_shadow);
  font-family: var(--_font);
  font-size: 14px;
  line-height: 20px;
  color: var(--_fg);
  -webkit-font-smoothing: antialiased;
}

/* 左端の、強調色の細い縦線 */
[data-ld-part="auth-set"]::before {
  content: "";
  position: absolute;
  top: 0;
  bottom: 0;
  left: 0;
  z-index: 1;
  width: 3px;
  background: var(--_accent);
  pointer-events: none;
}

[data-ld-part="auth-set"] *,
[data-ld-part="auth-set"] *::before,
[data-ld-part="auth-set"] *::after {
  box-sizing: border-box;
}

[data-ld-part="auth-set"] [hidden] {
  display: none !important;
}

[data-ld-part="auth-set"] p {
  margin: 0;
}

/* 読み上げだけに渡す文字 */
[data-ld-part="auth-set"] .vh {
  position: absolute;
  width: 1px;
  height: 1px;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
}

/* 段の左右の余白。帯だけは端まで出す */
[data-ld-part="auth-set"] .pad {
  padding: 0 calc(var(--_space) * 4) 0 calc(var(--_space) * 4.5);
}

/* 見出し:大きく、太く、詰めて。下に細い罫線 */
[data-ld-part="auth-set"] .head {
  padding-top: calc(var(--_space) * 5);
}

[data-ld-part="auth-set"] .title {
  margin: 0;
  padding-bottom: calc(var(--_space) * 2);
  border-bottom: 1px solid var(--_fg);
  font-size: 48px;
  line-height: .95;
  font-weight: 800;
  letter-spacing: -.03em;
  overflow-wrap: break-word;
}

[data-ld-part="auth-set"] .title:focus {
  outline: none;
}

[data-ld-part="auth-set"] .lead {
  max-width: 30ch;
  margin-top: calc(var(--_space) * 1.5);
  color: var(--_muted);
}

[data-ld-part="auth-set"] .lead strong {
  color: var(--_fg);
  font-weight: 600;
  overflow-wrap: anywhere;
}

/* 外のアカウント:高さを抑えた横一列 */
[data-ld-part="auth-set"] .providers {
  display: grid;
  gap: calc(var(--_space) * 1);
  margin-top: calc(var(--_space) * 3.5);
}

[data-ld-part="auth-set"] .caption,
[data-ld-part="auth-set"] .label {
  font-size: 11px;
  line-height: 16px;
  font-weight: 600;
  letter-spacing: .16em;
  text-transform: uppercase;
}

[data-ld-part="auth-set"] .caption {
  color: var(--_muted);
}

[data-ld-part="auth-set"] .provider-row {
  display: flex;
  gap: calc(var(--_space) * .75);
}

[data-ld-part="auth-set"] .provider {
  display: inline-flex;
  flex: 1 1 0;
  align-items: center;
  justify-content: center;
  gap: calc(var(--_space) * .75);
  min-width: 0;
  height: 32px;
  margin: 0;
  padding: 0 calc(var(--_space) * 1);
  border: 1px solid color-mix(in oklab, var(--_fg) 28%, transparent);
  border-radius: var(--_radius);
  background: transparent;
  color: var(--_fg);
  font: 600 12px/1 var(--_font);
  letter-spacing: .02em;
  white-space: nowrap;
  cursor: pointer;
  transition: background var(--_motion) ease, border-color var(--_motion) ease;
}

[data-ld-part="auth-set"] .provider:hover {
  border-color: var(--_fg);
  background: color-mix(in oklab, var(--_fg) 5%, transparent);
}

[data-ld-part="auth-set"] .icon-slot {
  flex: none;
  width: 12px;
  height: 12px;
  border-radius: calc(var(--_radius) * .5);
  background: color-mix(in oklab, var(--_muted) 30%, transparent);
}

/* フォーム */
[data-ld-part="auth-set"] .form {
  margin: 0;
}

[data-ld-part="auth-set"] .fields {
  display: grid;
  gap: calc(var(--_space) * 3);
  margin-top: calc(var(--_space) * 3.5);
  padding-bottom: calc(var(--_space) * 3.5);
}

[data-ld-part="auth-set"] .field {
  display: grid;
  gap: calc(var(--_space) * .5);
}

[data-ld-part="auth-set"] .label-row {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: calc(var(--_space) * 1);
}

[data-ld-part="auth-set"] .label {
  display: flex;
  align-items: baseline;
  gap: calc(var(--_space) * 1);
  padding: 0;
}

[data-ld-part="auth-set"] .num {
  color: var(--_accent);
  font-variant-numeric: tabular-nums;
  letter-spacing: .04em;
}

[data-ld-part="auth-set"] .control {
  position: relative;
}

/* 入力欄は下線だけ。フォーカスで線が太くなり、強調色に変わる */
[data-ld-part="auth-set"] .input,
[data-ld-part="auth-set"] .digit {
  display: block;
  width: 100%;
  height: 44px;
  margin: 0;
  padding: 0;
  border: 0;
  border-bottom: 1px solid color-mix(in oklab, var(--_fg) 55%, transparent);
  border-radius: 0;
  background: transparent;
  color: var(--_fg);
  font: 400 18px/24px var(--_font);
  caret-color: var(--_accent);
  transition: border-color var(--_motion) ease;
}

[data-ld-part="auth-set"] .input.has-reveal {
  padding-right: calc(var(--_space) * 7);
}

[data-ld-part="auth-set"] .input:hover,
[data-ld-part="auth-set"] .digit:hover {
  border-bottom-color: var(--_fg);
}

[data-ld-part="auth-set"] .input:focus,
[data-ld-part="auth-set"] .digit:focus {
  outline: none;
  border-bottom-color: var(--_accent);
  background: linear-gradient(var(--_accent), var(--_accent)) bottom / 100% 1px no-repeat;
}

/* エラー:色の token がないので、太い線と印で知らせる */
[data-ld-part="auth-set"] .input[aria-invalid="true"],
[data-ld-part="auth-set"] .digit[aria-invalid="true"] {
  border-bottom-color: var(--_fg);
  background: linear-gradient(var(--_fg), var(--_fg)) bottom / 100% 1px no-repeat;
}

[data-ld-part="auth-set"] .input[aria-invalid="true"]:focus,
[data-ld-part="auth-set"] .digit[aria-invalid="true"]:focus {
  border-bottom-color: var(--_accent);
  background: linear-gradient(var(--_accent), var(--_accent)) bottom / 100% 1px no-repeat;
}

[data-ld-part="auth-set"] .error {
  display: flex;
  align-items: flex-start;
  gap: calc(var(--_space) * .75);
  padding-top: calc(var(--_space) * .75);
  font-size: 13px;
  line-height: 18px;
  font-weight: 600;
  color: var(--_fg);
}

[data-ld-part="auth-set"] .error::before {
  content: "!";
  display: grid;
  place-items: center;
  flex: none;
  width: 16px;
  height: 16px;
  margin-top: 1px;
  border-radius: 50%;
  background: var(--_fg);
  color: var(--_surface);
  font-size: 11px;
  line-height: 1;
  font-weight: 800;
}

/* Caps Lock の知らせ */
[data-ld-part="auth-set"] .caps {
  padding-top: calc(var(--_space) * .75);
  font-size: 11px;
  line-height: 16px;
  font-weight: 600;
  letter-spacing: .12em;
  text-transform: uppercase;
  color: var(--_muted);
}

/* 表示と非表示 */
[data-ld-part="auth-set"] .reveal {
  position: absolute;
  top: 50%;
  right: 0;
  height: 32px;
  margin: 0;
  padding: 0 0 0 calc(var(--_space) * 1);
  border: 0;
  border-radius: 0;
  background: transparent;
  color: var(--_muted);
  font: 600 11px/1 var(--_font);
  letter-spacing: .16em;
  text-transform: uppercase;
  cursor: pointer;
  transform: translateY(-50%);
}

[data-ld-part="auth-set"] .reveal:hover {
  color: var(--_fg);
}

/* パスワードの条件:小さな四角が、満たすと埋まる */
[data-ld-part="auth-set"] .rules {
  display: flex;
  flex-wrap: wrap;
  gap: calc(var(--_space) * .75) calc(var(--_space) * 2);
  margin: 0;
  padding: calc(var(--_space) * 1.25) 0 0;
  list-style: none;
}

[data-ld-part="auth-set"] .rule {
  display: flex;
  align-items: center;
  gap: calc(var(--_space) * .75);
  font-size: 12px;
  line-height: 16px;
  color: var(--_muted);
  transition: color var(--_motion) ease;
}

[data-ld-part="auth-set"] .rule::before {
  content: "";
  flex: none;
  width: 9px;
  height: 9px;
  border: 1px solid color-mix(in oklab, var(--_muted) 80%, transparent);
  border-radius: 0;
  transition: background var(--_motion) ease, border-color var(--_motion) ease;
}

[data-ld-part="auth-set"] .rule[data-met] {
  color: var(--_fg);
}

[data-ld-part="auth-set"] .rule[data-met]::before {
  border-color: var(--_accent);
  background: var(--_accent);
}

/* 小さな文とリンク */
[data-ld-part="auth-set"] .aside {
  font-size: 13px;
  line-height: 18px;
  color: var(--_muted);
}

[data-ld-part="auth-set"] .link {
  margin: 0;
  padding: 0;
  border: 0;
  background: none;
  color: var(--_fg);
  font: inherit;
  font-weight: 600;
  text-decoration: underline;
  text-decoration-thickness: 1px;
  text-decoration-color: var(--_accent);
  text-underline-offset: 3px;
  cursor: pointer;
}

[data-ld-part="auth-set"] .link:hover {
  text-decoration-thickness: 2px;
}

[data-ld-part="auth-set"] .link.small {
  font-size: 12px;
  font-weight: 500;
}

[data-ld-part="auth-set"] .status {
  font-size: 13px;
  line-height: 18px;
  font-weight: 600;
  color: var(--_fg);
}

[data-ld-part="auth-set"] .status:empty {
  display: none;
}

/* 送信:カードの下端いっぱいの帯。右端に矢印 */
[data-ld-part="auth-set"] .submit {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: calc(var(--_space) * 1.5);
  width: 100%;
  min-height: 64px;
  margin: 0;
  padding: calc(var(--_space) * 1.5) calc(var(--_space) * 4) calc(var(--_space) * 1.5) calc(var(--_space) * 4.5);
  border: 0;
  border-radius: 0;
  background: var(--_accent);
  color: var(--_surface);
  font: 700 17px/22px var(--_font);
  letter-spacing: -.01em;
  text-align: left;
  cursor: pointer;
  transition: background var(--_motion) ease;
}

[data-ld-part="auth-set"] .submit:hover {
  background: color-mix(in oklab, var(--_accent) 88%, black);
}

[data-ld-part="auth-set"] .arrow {
  flex: none;
  width: 24px;
  height: 24px;
  fill: none;
  stroke: currentColor;
  stroke-width: 1.75;
  stroke-linecap: square;
  transition: transform var(--_motion) ease;
}

[data-ld-part="auth-set"] .submit:hover .arrow {
  transform: translateX(4px);
}

[data-ld-part="auth-set"] .submit:disabled {
  cursor: progress;
  background: color-mix(in oklab, var(--_accent) 88%, black);
}

/* 送信中は、矢印の場所で回る */
[data-ld-part="auth-set"] .submit[aria-busy="true"] .arrow {
  display: none;
}

[data-ld-part="auth-set"] .submit[aria-busy="true"]::after {
  content: "";
  flex: none;
  width: 18px;
  height: 18px;
  margin-right: calc(var(--_space) * .375);
  border: 2px solid color-mix(in oklab, var(--_surface) 35%, transparent);
  border-top-color: var(--_surface);
  border-radius: 50%;
  animation: auth-set-spin calc(var(--_motion) * 5) linear infinite;
}

@keyframes auth-set-spin {
  to { transform: rotate(1turn); }
}

/* 6桁:下線の枠が6つ */
[data-ld-part="auth-set"] .code-field {
  display: grid;
  gap: calc(var(--_space) * .5);
  min-width: 0;
  margin: 0;
  padding: 0;
  border: 0;
}

[data-ld-part="auth-set"] .code {
  display: grid;
  grid-template-columns: repeat(6, minmax(0, 1fr));
  gap: calc(var(--_space) * 1.5);
  margin-top: calc(var(--_space) * .5);
}

[data-ld-part="auth-set"] .digit {
  height: 56px;
  text-align: center;
  font-size: 30px;
  line-height: 1;
  font-weight: 700;
  font-variant-numeric: tabular-nums;
}

/* 送ったあと */
[data-ld-part="auth-set"] .done {
  display: grid;
  gap: calc(var(--_space) * 2.5);
  margin-top: calc(var(--_space) * 3.5);
  padding-bottom: calc(var(--_space) * 5);
}

[data-ld-part="auth-set"] .done:focus {
  outline: none;
}

[data-ld-part="auth-set"] .done-text {
  font-size: 16px;
  line-height: 22px;
}

[data-ld-part="auth-set"] .done-text strong {
  overflow-wrap: anywhere;
}

[data-ld-part="auth-set"] .done-mark {
  display: inline-block;
  width: 10px;
  height: 10px;
  margin-right: calc(var(--_space) * 1);
  background: var(--_accent);
}

/* キーボードのフォーカス */
[data-ld-part="auth-set"] .provider:focus-visible,
[data-ld-part="auth-set"] .reveal:focus-visible,
[data-ld-part="auth-set"] .link:focus-visible {
  outline: 2px solid var(--_accent);
  outline-offset: 2px;
}

[data-ld-part="auth-set"] .submit:focus-visible {
  outline: 2px solid var(--_surface);
  outline-offset: -6px;
}

@media (max-width: 420px) {
  [data-ld-part="auth-set"] .pad {
    padding: 0 calc(var(--_space) * 2.5) 0 calc(var(--_space) * 3);
  }
  [data-ld-part="auth-set"] .submit {
    padding-right: calc(var(--_space) * 2.5);
    padding-left: calc(var(--_space) * 3);
  }
  [data-ld-part="auth-set"] .title {
    font-size: 40px;
  }
}

@media (prefers-reduced-motion: reduce) {
  [data-ld-part="auth-set"] *,
  [data-ld-part="auth-set"] *::before,
  [data-ld-part="auth-set"] *::after {
    transition: none !important;
    animation-duration: calc(var(--_motion) * 20) !important;
  }
}
</style>
<script type="module">
/* UI by @Lattice_Node — ludion.ai/p/auth-set (MIT) */
// part.js — ESモジュール。rootの中だけを触る
// 4つの画面(Sign in、Create account、Reset password、Verify code)を1枚のカードの中で切り替える。
// エラーは、入力欄を離れたときか送信したときだけ出す。打っている最中には新しく出さない。

const EMAIL = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
const WAIT_MS = 800;

const RULES = {
  length: (v) => v.length >= 8,
  case: (v) => /[a-z]/.test(v) && /[A-Z]/.test(v),
  number: (v) => /\d/.test(v),
};

function messageFor(input) {
  const value = input.value;
  switch (input.dataset.validate) {
    case 'email':
      if (!value.trim()) return 'Enter your email address.';
      if (!EMAIL.test(value.trim())) return 'Enter an email address like name@example.com.';
      return '';
    case 'password':
      return value ? '' : 'Enter your password.';
    case 'new-password':
      if (!value) return 'Create a password.';
      return Object.values(RULES).every((rule) => rule(value)) ? '' : 'Your password doesn’t meet every requirement yet.';
    default:
      return '';
  }
}

function mount(root) {
  const cleanups = [];
  const timers = new Set();
  const on = (el, type, fn) => {
    el.addEventListener(type, fn);
    cleanups.push(() => el.removeEventListener(type, fn));
  };
  const later = (fn, ms) => {
    const t = setTimeout(() => {
      timers.delete(t);
      fn();
    }, ms);
    timers.add(t);
  };
  const byId = (id) => root.querySelector(`[id="${id}"]`);

  const screens = [...root.querySelectorAll('[data-screen]')];
  const screen = (name) => root.querySelector(`[data-screen="${name}"]`);

  // ---- エラー
  const errorFor = (input) => byId(input.getAttribute('aria-describedby').split(' ')[0]);
  const setError = (input, message) => {
    const error = errorFor(input);
    error.textContent = message;
    error.hidden = !message;
    if (message) input.setAttribute('aria-invalid', 'true');
    else input.removeAttribute('aria-invalid');
  };

  // ---- 画面の行き来
  const digits = [...root.querySelectorAll('.digit')];
  const codeError = byId('as-verify-error');
  const verifyStatus = root.querySelector('[data-status]');

  const clearCode = () => {
    for (const d of digits) {
      d.value = '';
      d.removeAttribute('aria-invalid');
    }
    codeError.textContent = '';
    codeError.hidden = true;
    verifyStatus.textContent = '';
  };

  const go = (name, focusTarget) => {
    for (const s of screens) s.hidden = s.dataset.screen !== name;
    const target = screen(name);
    // 前に送った画面は、フォームに戻しておく
    const form = target.querySelector('form');
    const done = target.querySelector('[data-done]');
    if (form) form.hidden = false;
    if (done) done.hidden = true;
    if (name === 'verify') clearCode();
    (focusTarget ?? target.querySelector('.title')).focus();
  };

  for (const link of root.querySelectorAll('[data-go]')) {
    on(link, 'click', () => go(link.dataset.go));
  }

  // リンクやボタンを押し下げた瞬間に入力欄から離れると、エラーの文が出てボタンが下へずれ、
  // 指を離す位置がボタンの外になってクリックが成り立たない。押し下げではフォーカスを動かさない。
  for (const button of root.querySelectorAll('[data-go], [data-resend], .reveal, .submit')) {
    on(button, 'mousedown', (event) => event.preventDefault());
  }

  // ---- 入力欄:離れたときに確かめる。打っている間は、直ったエラーを消すだけ
  for (const input of root.querySelectorAll('[data-validate]')) {
    on(input, 'input', () => {
      input.dataset.dirty = 'true';
      if (input.hasAttribute('aria-invalid') && !messageFor(input)) setError(input, '');
    });
    on(input, 'blur', () => {
      if (input.dataset.dirty === 'true') setError(input, messageFor(input));
    });
  }

  // ---- パスワード:表示と非表示、Caps Lock
  for (const button of root.querySelectorAll('.reveal')) {
    const input = byId(button.getAttribute('aria-controls'));
    const text = button.querySelector('.reveal-text');
    on(button, 'click', () => {
      const showing = input.type === 'text';
      input.type = showing ? 'password' : 'text';
      text.textContent = showing ? 'Show' : 'Hide';
    });
  }

  for (const input of root.querySelectorAll('.has-reveal')) {
    const caps = byId(`${input.id}-caps`);
    const update = (event) => {
      caps.hidden = !(event.getModifierState && event.getModifierState('CapsLock'));
    };
    on(input, 'keydown', update);
    on(input, 'keyup', update);
    on(input, 'blur', () => {
      caps.hidden = true;
    });
  }

  // ---- 新しいパスワードの条件:満たした順に、その場でチェック
  const newPassword = byId('as-signup-password');
  const rules = [...root.querySelectorAll('[data-rule]')];
  on(newPassword, 'input', () => {
    for (const li of rules) {
      const met = RULES[li.dataset.rule](newPassword.value);
      li.toggleAttribute('data-met', met);
      li.querySelector('[data-rule-state]').textContent = met ? 'Done: ' : 'Not yet: ';
    }
  });

  // ---- 6桁の枠
  const codeComplete = () => digits.every((d) => /^\d$/.test(d.value));
  const fill = (from, text) => {
    const chars = text.replace(/\D/g, '').slice(0, digits.length - from).split('');
    chars.forEach((c, i) => {
      digits[from + i].value = c;
    });
    const next = Math.min(from + chars.length, digits.length - 1);
    digits[next].focus();
    digits[next].select();
    if (codeComplete()) {
      codeError.hidden = true;
      codeError.textContent = '';
      for (const d of digits) d.removeAttribute('aria-invalid');
    }
  };

  digits.forEach((box, i) => {
    on(box, 'focus', () => box.select());
    on(box, 'keydown', (event) => {
      if (/^\d$/.test(event.key)) {
        event.preventDefault();
        fill(i, event.key);
      } else if (event.key === 'Backspace') {
        event.preventDefault();
        if (box.value) box.value = '';
        else if (i > 0) {
          digits[i - 1].value = '';
          digits[i - 1].focus();
        }
      } else if (event.key === 'ArrowLeft' && i > 0) {
        event.preventDefault();
        digits[i - 1].focus();
      } else if (event.key === 'ArrowRight' && i < digits.length - 1) {
        event.preventDefault();
        digits[i + 1].focus();
      }
    });
    // スマホのキーボードや自動入力は keydown を通らないので、input でも受ける
    on(box, 'input', () => {
      const value = box.value.replace(/\D/g, '');
      if (!value) {
        box.value = '';
        return;
      }
      fill(i, value);
    });
    on(box, 'paste', (event) => {
      event.preventDefault();
      const text = (event.clipboardData && event.clipboardData.getData('text')) || '';
      if (/\d/.test(text)) fill(0, text);
    });
  });

  // 6つの枠の外へ出たとき、途中まで打っていたら知らせる
  const showCodeError = () => {
    codeError.textContent = 'Enter all 6 digits.';
    codeError.hidden = false;
    for (const d of digits) d.setAttribute('aria-invalid', 'true');
  };
  const code = root.querySelector('[data-code]');
  on(code, 'focusout', (event) => {
    if (code.contains(event.relatedTarget)) return;
    if (digits.some((d) => d.value) && !codeComplete()) showCodeError();
  });

  // ---- 送信:約0.8秒の見せかけの待ち。二度押しできない
  const busy = (form, onDone) => {
    const button = form.querySelector('.submit');
    const text = button.querySelector('.submit-text');
    const idle = text.textContent;
    button.disabled = true;
    button.setAttribute('aria-busy', 'true');
    text.textContent = button.dataset.busy;
    later(() => {
      button.disabled = false;
      button.removeAttribute('aria-busy');
      text.textContent = idle;
      onDone();
    }, WAIT_MS);
  };

  const validateForm = (form) => {
    let first = null;
    for (const input of form.querySelectorAll('[data-validate]')) {
      const message = messageFor(input);
      setError(input, message);
      if (message && !first) first = input;
    }
    return first;
  };

  const emailOf = (form) => form.querySelector('[type="email"]').value.trim();
  const showEmail = (name, email) => {
    for (const el of screen(name).querySelectorAll('[data-email]')) el.textContent = email;
  };

  const finish = (section) => {
    section.querySelector('form').hidden = true;
    const done = section.querySelector('[data-done]');
    done.hidden = false;
    done.focus();
  };

  // 送信は、送信ボタンの click で受ける。Ludion の iframe は sandbox="allow-scripts" だけで
  // allow-forms がないので、submit イベントが来ない。Enter で送ったときも、ブラウザが送信ボタンを click する。
  // click を止めればフォームは送られないが、ふつうのページに貼られたときのために submit も止めておく。
  for (const form of root.querySelectorAll('form')) {
    const button = form.querySelector('.submit');
    on(button, 'click', (event) => {
      event.preventDefault();
      send(form);
    });
    on(form, 'submit', (event) => {
      event.preventDefault();
      send(form);
    });
  }

  function send(form) {
    if (form.querySelector('.submit').disabled) return;
    const kind = form.dataset.form;

    if (kind === 'verify') {
      if (!codeComplete()) {
        showCodeError();
        (digits.find((d) => !d.value) ?? digits[0]).focus();
        return;
      }
      busy(form, () => finish(screen('verify')));
      return;
    }

    const invalid = validateForm(form);
    if (invalid) {
      invalid.focus();
      return;
    }
    const email = emailOf(form);
    if (kind === 'reset') {
      busy(form, () => {
        showEmail('reset', email);
        finish(screen('reset'));
      });
    } else {
      busy(form, () => {
        showEmail('verify', email);
        go('verify', digits[0]);
      });
    }
  }

  // ---- コードの再送
  on(root.querySelector('[data-resend]'), 'click', () => {
    verifyStatus.textContent = 'We sent a new code.';
  });

  return () => {
    for (const off of cleanups) off();
    for (const t of timers) clearTimeout(t);
    timers.clear();
  };
}
mount(document.querySelector('[data-ld-part="auth-set"]'));
</script>

@Lattice_Node のほかの作品