Session Timeout
This is the thing every large organisation builds itself, and gets wrong. Either no warning comes, and the user loses a half-completed application form. Or the dialog steals focus in the middle of a sentence. Or the screen reader reads the countdown every single second.
You write the dialog and the text yourself, in the language the app uses. The component tracks activity, opens the dialog, fills in the number and announces the countdown.
// main.ts, runs once when the app startsimport { defineLtSessionTimeout } from "@lo-tek/interfaces/session-timeout"import "@lo-tek/interfaces/tokens.css"import "@lo-tek/interfaces/session-timeout.css"
defineLtSessionTimeout()How to use it
Section titled “How to use it”Here the session is set to five seconds, so you do not have to wait 25 minutes. Stop touching the keyboard and mouse, and the warning appears.
<!-- The stylesheets the page needs. Without a build step it is `<link>`, not `import`: a browser does not resolve a package name. --><link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@lo-tek/interfaces@0.36.0/src/tokens/tokens.css"><link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@lo-tek/interfaces@0.36.0/src/components/frame/session-timeout/session-timeout.css"><!-- Or everything in one file: https://cdn.jsdelivr.net/npm/@lo-tek/interfaces@0.36.0/dist/lotek.css --><lt-session-timeout class="lt-session-timeout" warn-at="1500" expires-at="1800" data-ignore-morph> <dialog class="lt-session-timeout__dialog" role="alertdialog" aria-labelledby="session-title"> <h2 class="lt-session-timeout__title" id="session-title">You will soon be logged out</h2> <p class="lt-session-timeout__text"> We will log you out in <span class="lt-session-timeout__count" aria-hidden="true"></span> to protect your information. </p> <span class="lt-sr-only" role="status"></span> <form method="dialog" class="lt-session-timeout__actions"> <button class="lt-button" value="extend">Stay logged in</button> <button class="lt-button" data-variant="secondary" value="logout">Log out now</button> </form> </dialog></lt-session-timeout>
<!-- Once for the whole app, in the page template. Not once per component. --><script type="module"> import { defineLtSessionTimeout } from "https://cdn.jsdelivr.net/npm/@lo-tek/interfaces@0.36.0/dist/components/frame/session-timeout/lt-session-timeout.js"
defineLtSessionTimeout()</script>
<script type="module"> const session = document.querySelector("lt-session-timeout")
session.addEventListener("session-extend", async () => { await fetch("/api/extend-session", { method: "POST" }) })
session.addEventListener("session-expired", () => { location.href = "/log-in?reason=expired" })</script>// src/lotek.d.ts, gives <lt-session-timeout> types in JSXimport "@lo-tek/interfaces/react-jsx"import "@lo-tek/interfaces/session-timeout.css"import { useEffect, useId, useRef } from "react"import { lt } from "@lo-tek/interfaces/react"import { defineLtSessionTimeout } from "@lo-tek/interfaces/session-timeout"import "@lo-tek/interfaces/tokens.css"
defineLtSessionTimeout()
export function SessionWarning() { const ref = useRef<HTMLElement>(null) const session = lt.sessionTimeout({ titleId: useId(), warnAt: 1500, expiresAt: 1800 })
useEffect(() => { const element = ref.current if (!element) return
const extend = () => fetch("/api/extend-session", { method: "POST" }) const expired = () => { location.href = "/log-in?reason=expired" }
element.addEventListener("session-extend", extend) element.addEventListener("session-expired", expired) return () => { element.removeEventListener("session-extend", extend) element.removeEventListener("session-expired", expired) } }, [])
return ( <lt-session-timeout ref={ref} {...session.host}> <dialog {...session.dialog}> <h2 {...session.title}>You will soon be logged out</h2> <p {...session.text}> We will log you out in <span {...session.count} /> to protect your information. </p> <span {...session.live} /> <form {...session.actions}> <button {...session.extend}>Stay logged in</button> <button {...session.logout}>Log out now</button> </form> </dialog> </lt-session-timeout> )}The text is ordinary JSX, so it can come from t() in i18next or from any other setup. See Translation.
<!-- Datastar itself. Without it the `data-` attributes below do nothing. --><script type="module" src="https://cdn.jsdelivr.net/gh/starfederation/datastar@v1.0.4/bundles/datastar.js"></script>
<!-- The stylesheets the page needs. Without a build step it is `<link>`, not `import`: a browser does not resolve a package name. --><link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@lo-tek/interfaces@0.36.0/src/tokens/tokens.css"><link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@lo-tek/interfaces@0.36.0/src/components/frame/session-timeout/session-timeout.css"><!-- Or everything in one file: https://cdn.jsdelivr.net/npm/@lo-tek/interfaces@0.36.0/dist/lotek.css -->
<!-- Once for the whole app, in the page template. Not once per component. --><script type="module"> import { defineLtSessionTimeout } from "https://cdn.jsdelivr.net/npm/@lo-tek/interfaces@0.36.0/dist/components/frame/session-timeout/lt-session-timeout.js"
defineLtSessionTimeout()</script>
<lt-session-timeout class="lt-session-timeout" warn-at="1500" expires-at="1800" data-ignore-morph data-on:session-extend="@post('/api/extend-session')" data-on:session-expired="window.location = '/log-in?reason=expired'"> <dialog class="lt-session-timeout__dialog" role="alertdialog" aria-labelledby="session-title"> <h2 class="lt-session-timeout__title" id="session-title">You will soon be logged out</h2> <p class="lt-session-timeout__text"> We will log you out in <span class="lt-session-timeout__count" aria-hidden="true"></span> to protect your information. </p> <span class="lt-sr-only" role="status"></span> <form method="dialog" class="lt-session-timeout__actions"> <button class="lt-button" value="extend">Stay logged in</button> <button class="lt-button" data-variant="secondary" value="logout">Log out now</button> </form> </dialog></lt-session-timeout>data-ignore-morph tells the morph to leave the warning alone. Without it the morph removes open and the number while the dialog is in the top layer. The component puts open back, and the number returns on the next tick, but with the attribute the number is there all the time.
Why you write the dialog yourself
Section titled “Why you write the dialog yourself”The text in the warning belongs to the app. It should be in the language the app uses, with the words the app uses about logging in, and come from the translation setup the app already has. If the text were in the component, it could not be translated at all. See Translation.
The component finds the parts by what has to be there anyway:
| Part | Recognised by |
|---|---|
| The dialog | The first <dialog> inside the element |
| The number | .lt-session-timeout__count. Is empty, and the component fills it in |
| The announcement | [role=status]. Is empty, and the component fills it in |
| The buttons | Buttons in a <form method="dialog"> |
The buttons are ordinary buttons. The browser closes the dialog and sets returnValue to the button’s value, without script. value="logout" logs out. Everything else, Escape and the button with value="extend" included, means the user wants to continue.
If the class lt-session-timeout__dialog, role="alertdialog" or a name on the dialog is missing, the component fills them in. The name comes from the first heading, which gets an id if it has none. What the renderer wrote stays.
The countdown is the client’s clock, and changes depending on when the user last touched the keyboard. That is why the builder function sets data-ignore-morph, so a patch from the server leaves the warning alone. If a patch still removes open from a dialog that is in the top layer, the component puts it back. See Markup and behaviour.
Attributes
Section titled “Attributes”| Attribute | Meaning | Default |
|---|---|---|
warn-at | Seconds without activity before the warning appears | 1500 (25 minutes) |
expires-at | Seconds without activity before the session is over | 1800 (30 minutes) |
activity-interval | Seconds between each session-activity while the user is active. At least 1 | 60 |
Activity is keystrokes, clicks and scrolling, also scrolling in a table or a panel. Scrolling also counts when a screen reader in browse mode scrolls the page, since it does not send keystrokes to the page. This means that scrolling done by code, like a log that follows along, also keeps the clock going. While the dialog is open, activity does not count, otherwise a random mouse click would extend the session without the user meaning to.
If warn-at or expires-at is not a number greater than or equal to zero, the default is used, and if warn-at is not less than expires-at, the warning never appears. The component warns about both in the console.
After session-expired and session-logout the component stands still until extend() or reset() is called. Without that, an app that did not navigate away got a new dialog and a new session-expired every expires-at seconds, for a session that was already gone.
Events
Section titled “Events”| Event | When | What you do |
|---|---|---|
session-warn | The dialog opens | Usually nothing |
session-extend | The user chooses to continue, with the button or Escape | Ask the server to extend the session |
session-logout | The user chooses to log out | Send the user to the logout |
session-expired | The time is up | Send the user to the login |
session-activity | The user is active, at most once per activity-interval | Ask the server to keep the session alive |
The component does not know how your session is extended. It tells you, you make the call.
Listen for session-activity if the server session is only extended by calls to the server. The clock here is reset by activity in the browser. A user typing in a long form without a single call therefore never gets the warning, while the server session expires, and the submission fails. The event comes at the start of an interval and at the end if the user has done something since, so the server is never more than one interval behind.
Several tabs do not share a clock. Each tab counts its own activity, so a tab the user is not using shows the warning and sends session-expired while the user is working in another. If the tabs should follow each other, let them tell each other and call reset(), both when the user is active and when the user chooses to continue in the warning. A tab that is already logged out should not be started again:
const channel = new BroadcastChannel("session")let loggedOut = falsefor (const name of ["session-activity", "session-extend"]) { session.addEventListener(name, () => channel.postMessage("active"))}for (const name of ["session-expired", "session-logout"]) { session.addEventListener(name, () => (loggedOut = true))}channel.addEventListener("message", () => { if (!loggedOut) session.reset()})Methods
Section titled “Methods”| Method | What it does |
|---|---|
extend() | Resets the clock, closes the warning and sends session-extend |
reset() | Resets the clock and closes the warning without sending anything. For an app that has already extended the session itself, as when an autosave went through |
extendSession(element) and resetSession(element) from @lo-tek/interfaces/session-timeout do the same, but first wait for the element to be registered.
Classes you can style
Section titled “Classes you can style”| Class | What it is |
|---|---|
.lt-session-timeout | The host element. Is display: contents and takes no space |
.lt-session-timeout__dialog | The dialog itself |
.lt-session-timeout__title | The heading |
.lt-session-timeout__text | The paragraph with the countdown in it |
.lt-session-timeout__count | The number that counts down |
.lt-session-timeout__actions | The form with the buttons |
Shape and size are read from component variables, with the system’s value as the fallback:
| Variable | Fallback |
|---|---|
--lt-session-timeout-width | 26rem |
--lt-session-timeout-padding | var(--lt-spacing-5) |
--lt-session-timeout-radius | var(--lt-spacing-1) |
.lt-session-timeout__dialog { --lt-session-timeout-width: 32rem;}Accessibility
Section titled “Accessibility”The dialog is a real <dialog> opened with showModal(), so the focus trap, Escape and the top layer come from the browser. It has role="alertdialog", because it interrupts something the user did not ask to be interrupted in.
Three things are done on purpose, and all three are reasons this is hard to get right yourself:
- Focus returns to where the user was. Without that, the next keystroke starts at the top of the page, in the middle of a form.
- The countdown is not announced every second. The number on the screen has
aria-hidden, and the announcement speaks at two minutes, one minute, 30 seconds and 10 seconds. An announcement every second makes the dialog unusable. - The announcement is in the page’s language. It is your paragraph, with the number replaced by a spoken duration. The duration is written with the browser’s
Intlfrom thelangof the page, so it says “1 minute and 30 seconds” on an English page and “1 minutt og 30 sekunder” on a Norwegian one.
Put the button that extends first. It is the one most people want, and it should be the first thing the keyboard reaches.