Kalıplar
Oturum süresi ve tek kullanımlık kod
Oturum bitmeden uyarmayı ve tek kullanımlık kod girişini erişilebilir biçimde tasarlamayı anlatır.
Süreli oturum ve tek kullanımlık kod, kullanıcıya zaman baskısı getirir. Bu kalıp uyarının ne zaman geldiğini, süreyi nasıl duyurduğunu ve kod alanının nasıl davrandığını belirler.
Ne zaman kullanılır
Bölüm başlığı “Ne zaman kullanılır”- Boşta kalınca sona eren her oturumda: bitmeden uyarın.
- SMS veya e-posta ile gelen tek kullanımlık kod girişinde.
- Süresi kullanıcıya bağlı olmayan, WCAG 2.2.1 kapsamındaki her zaman sınırında.
Canlı örnek
Bölüm başlığı “Canlı örnek”Oturum uyarısı 125 saniyeden başlar: 5 saniye sonra kapatılamayan bir pencere açılır ve 120, 60, 30 saniyede duyurulur. Uzatın: süre tam oturuma döner ve bir Toast görünür.
import { Button, Dialog, ProgressBar, ResultState, Stack, Text, useToast } from '@bigbang/ui';import { useEffect, useRef, useState } from 'react';import { type Lang, LangProvider } from '../lang';
const copy = { tr: { heading: 'Oturumunuz sona ermek üzere', remaining: 'Kalan süre', body: 'Devam etmek için oturumu uzatın. Girdiğiniz bilgiler korunur.', extend: 'Oturumu uzat', signOut: 'Çıkış yap', active: 'Oturumunuz açık.', extended: 'Oturum 15 dakika uzatıldı', expiredTitle: 'Oturum süresi doldu', expired: 'Devam etmek için tekrar giriş yapın. Gönderilmemiş girdiğiniz bilgiler korunur.', signedOut: 'Çıkış yaptınız.', announce: (time: string) => `Oturumunuz ${time} sonra sona erecek`, format: (m: number, s: number) => (m > 0 ? `${m} dk ${s} sn` : `${s} sn`), human: (m: number, s: number) => [m > 0 ? `${m} dakika` : '', s > 0 || m === 0 ? `${s} saniye` : ''].filter(Boolean).join(' '), }, en: { heading: 'Your session is about to end', remaining: 'Time left', body: 'Extend the session to continue. Your input is kept.', extend: 'Stay signed in', signOut: 'Sign out', active: 'Your session is active.', extended: 'Session extended by 15 minutes', expiredTitle: 'The session has timed out', expired: 'Sign in again to continue. Input you have not sent is kept.', signedOut: 'You are signed out.', announce: (time: string) => `Your session ends in ${time}`, format: (m: number, s: number) => (m > 0 ? `${m} min ${s} s` : `${s} s`), human: (m: number, s: number) => [m > 0 ? `${m} ${m === 1 ? 'minute' : 'minutes'}` : '', s > 0 || m === 0 ? `${s} ${s === 1 ? 'second' : 'seconds'}` : ''].filter(Boolean).join(' '), },};
/** Seconds before the end at which the warning appears (WCAG 2.2.1: at least 20 s to respond; 120 s here). */export const WARN_AT = 120;/** Remaining times, in seconds, that are announced. The visible countdown is not a live region. */export const ANNOUNCE_AT = [120, 60, 30] as const;/** Length of a full session in seconds. */export const SESSION_SECONDS = 900;
export const formatRemaining = (seconds: number, lang: Lang) => copy[lang].format(Math.floor(seconds / 60), seconds % 60);
/** The spoken form of the remaining time, such as "1 dakika 30 saniye". */export const humanRemaining = (seconds: number, lang: Lang) => copy[lang].human(Math.floor(seconds / 60), seconds % 60);
function SessionCountdownDemo({ lang, startAt }: { lang: Lang; startAt: number }) { const t = copy[lang]; const toast = useToast(); const [remaining, setRemaining] = useState(startAt); const [signedOut, setSignedOut] = useState(false); const [announcement, setAnnouncement] = useState(''); const lastBucketRef = useRef<number | null>(null); const extendRef = useRef<HTMLButtonElement>(null);
useEffect(() => { const timer = setInterval(() => setRemaining((value) => Math.max(0, value - 1)), 1000); return () => clearInterval(timer); }, []);
// Announce when the remaining time enters a new threshold: 120, 60 or 30 s. useEffect(() => { if (remaining <= 0) return; const bucket = [...ANNOUNCE_AT].reverse().find((limit) => remaining <= limit) ?? null; if (bucket !== null && bucket !== lastBucketRef.current) { lastBucketRef.current = bucket; setAnnouncement(t.announce(formatRemaining(remaining, lang))); } }, [remaining, lang, t]);
const extend = () => { setRemaining(SESSION_SECONDS); lastBucketRef.current = null; setAnnouncement(''); toast.show({ title: t.extended, tone: 'success' }); };
const expired = remaining <= 0; const warning = !expired && !signedOut && remaining <= WARN_AT;
return ( <Stack gap="md"> {/* A blocking dialog: it interrupts, ignores Escape and outside presses, and asks for one of two actions. */} <Dialog open={warning} dismissible={false} size="sm" title={t.heading} description={t.body} initialFocus={extendRef} footer={ <> <Button variant="secondary" onClick={() => setSignedOut(true)}> {t.signOut} </Button> <Button ref={extendRef} onClick={extend}> {t.extend} </Button> </> } > <div data-part="session-warning"> <ProgressBar label={t.remaining} value={remaining} max={WARN_AT} showValue={false} valueText={humanRemaining(remaining, lang)} description={formatRemaining(remaining, lang)} tone={remaining <= 30 ? 'danger' : 'brand'} /> {/* The visible countdown is not a live region; this one speaks at 120, 60 and 30 s only. */} <div role="status" aria-live="polite" className="sr-only"> {announcement} </div> </div> </Dialog> {!warning && !expired && !signedOut ? <Text tone="subtle">{t.active}</Text> : null} {expired && !signedOut ? <ResultState tone="warning" headingLevel={3} focusOnMount title={t.expiredTitle} description={t.expired} /> : null} {signedOut ? <Text>{t.signedOut}</Text> : null} </Stack> );}
/** * A blocking dialog that appears 120 s before the session ends, with the remaining time in a progress bar. * It announces at 120, 60 and 30 s only; "Stay signed in" resets the full session and shows a toast. */export function SessionCountdown({ lang = 'tr', startAt = 125 }: { lang?: Lang; startAt?: number }) { return ( <LangProvider lang={lang}> <div className="w-full max-w-md"> <SessionCountdownDemo lang={lang} startAt={startAt} /> </div> </LangProvider> );}İkinci örnek 31 saniyeden başlar; son eşik 1 saniye sonra gelir.
Kod girişinde “123 456” yapıştırın, yanlış kod girin ve 30 saniyelik bekleme süresini izleyin. Deneme kodu 123456’dır.
import { Button, InlineAlert, Stack, Text, TextInput } from '@bigbang/ui';import { useEffect, useState, type ClipboardEvent, type FormEvent } from 'react';import { type Lang, LangProvider } from '../lang';
const copy = { tr: { label: 'Doğrulama kodu', description: 'Telefonunuza gönderilen 6 haneli kodu girin', short: 'Kod 6 haneli olmalı', wrong: (left: number) => `Kod hatalı. ${left} deneme hakkınız kaldı.`, noAttempts: 'Deneme hakkınız bitti. Yeni kod isteyin.', expired: 'Kodun süresi doldu. Yeni kod isteyin.', verify: 'Doğrula', resend: 'Kodu tekrar gönder', resendWait: (seconds: number) => `Kodu tekrar gönder (${seconds} sn)`, resent: 'Yeni kod gönderildi', verified: 'Kod doğrulandı', hint: 'Deneme kodu: 123456', }, en: { label: 'Verification code', description: 'Enter the 6-digit code sent to your phone', short: 'The code must be 6 digits', wrong: (left: number) => `Wrong code. You have ${left} ${left === 1 ? 'attempt' : 'attempts'} left.`, noAttempts: 'You have no attempts left. Request a new code.', expired: 'The code has expired. Request a new code.', verify: 'Verify', resend: 'Resend code', resendWait: (seconds: number) => `Resend code (${seconds} s)`, resent: 'A new code was sent', verified: 'Code verified', hint: 'Sample code: 123456', },};
export const CODE_LENGTH = 6;export const RESEND_COOLDOWN = 30;export const CODE_LIFETIME = 180;export const MAX_ATTEMPTS = 3;
/** Keep digits only, so a pasted "123 456" or "123-456" becomes "123456". */export const digitsOnly = (value: string) => value.replace(/\D/g, '').slice(0, CODE_LENGTH);
/** * One single-line input for a one-time code. It strips non-digits (also from pasted text), * counts wrong attempts, expires after 180 s and allows a resend after a 30 s cooldown. */export function OneTimeCode({ lang = 'tr', correctCode = '123456' }: { lang?: Lang; correctCode?: string }) { const t = copy[lang]; const [value, setValue] = useState(''); const [error, setError] = useState(''); const [attempts, setAttempts] = useState(MAX_ATTEMPTS); const [cooldown, setCooldown] = useState(RESEND_COOLDOWN); const [lifetime, setLifetime] = useState(CODE_LIFETIME); const [notice, setNotice] = useState<'resent' | 'verified' | null>(null); const [done, setDone] = useState(false);
useEffect(() => { const timer = setInterval(() => { setCooldown((seconds) => Math.max(0, seconds - 1)); setLifetime((seconds) => Math.max(0, seconds - 1)); }, 1000); return () => clearInterval(timer); }, []);
const expired = lifetime <= 0; const shownError = done ? '' : expired ? t.expired : error;
const onPaste = (event: ClipboardEvent<HTMLInputElement>) => { // maxLength would cut "123 456" at 6 characters, spaces included; clean the text first. event.preventDefault(); setValue(digitsOnly(event.clipboardData.getData('text'))); setError(''); };
const onSubmit = (event: FormEvent) => { event.preventDefault(); if (expired) return; setNotice(null); if (value.length !== CODE_LENGTH) { setError(t.short); } else if (attempts <= 0) { setError(t.noAttempts); } else if (value !== correctCode) { const left = attempts - 1; setAttempts(left); setError(left > 0 ? t.wrong(left) : t.noAttempts); } else { setError(''); setDone(true); setNotice('verified'); } };
const resend = () => { setValue(''); setError(''); setAttempts(MAX_ATTEMPTS); setCooldown(RESEND_COOLDOWN); setLifetime(CODE_LIFETIME); setNotice('resent'); };
return ( <LangProvider lang={lang}> <form noValidate onSubmit={onSubmit} className="w-full max-w-md"> <Stack gap="lg"> <TextInput label={t.label} description={t.description} name="one-time-code" inputMode="numeric" autoComplete="one-time-code" maxLength={CODE_LENGTH} pattern="[0-9]*" value={value} onValueChange={(next) => { setValue(digitsOnly(next)); setError(''); setNotice(null); }} onPaste={onPaste} invalid={Boolean(shownError)} /> {/* The message sits under the field in an InlineAlert; `invalid` marks the input. It is announced when it appears. */} {shownError ? ( <InlineAlert tone="danger" announce> {shownError} </InlineAlert> ) : null} {notice ? ( <InlineAlert tone={notice === 'verified' ? 'success' : 'info'} announce> {notice === 'verified' ? t.verified : t.resent} </InlineAlert> ) : null} <div className="flex flex-wrap gap-sm"> <Button type="submit" disabled={done}> {t.verify} </Button> <Button variant="tertiary" disabled={cooldown > 0} focusableWhenDisabled onClick={resend}> {cooldown > 0 ? t.resendWait(cooldown) : t.resend} </Button> </div> <Text size="sm" tone="subtle"> {t.hint} </Text> </Stack> </form> </LangProvider> );}Kurallar
Bölüm başlığı “Kurallar”| Kural | Değer |
|---|---|
| Uyarı zamanı | Bitişten 120 sn önce. Kullanıcıya yanıt için en az 20 sn verilir (WCAG 2.2.1). |
| Uyarı yüzeyi | Kapatılamayan Dialog (dismissible={false}): Escape ve dışarı basma kapatmaz. |
| Süre uzatma | Tek basit eylemle: birincil “Oturumu uzat”. İkincil “Çıkış yap”. Odak “Oturumu uzat”ta başlar. |
| Süre gösterimi | ProgressBar kalan süreyi gösterir; görünen metin “1 dk 40 sn”, okunan değer valueText: “1 dakika 40 saniye”. Sayaç canlı bölge değildir. |
| Duyuru | Yalnızca 120, 60 ve 30 sn’de; her saniye duyurulmaz. |
| Süre dolunca | ResultState ile “Oturum süresi doldu” gösterilir; odak başlığa gider. Gönderilmemiş girdiyi mümkünse koruyun. |
| Kod alanı | Tek TextInput: inputMode="numeric", autoComplete="one-time-code", maxLength={6}, pattern="[0-9]*". |
| Hata ve bilgi | Alan altında InlineAlert: yanlış kod danger, yeniden gönderme info, doğrulama success; hepsi görününce duyurulur. |
| Yapıştırma | Boşluk ve harfler ayıklanır: “123 456” → “123456”. |
| Yeniden gönder | 30 sn bekleme; bu sürede focusableWhenDisabled düğme “Kodu tekrar gönder (30 sn)” yazar. |
| Kod ömrü | 180 sn. Dolunca açık bir mesaj gösterilir. |
| Deneme hakkı | 3. Yanlış kodda kalan hak yazılır. |
Davranış
Bölüm başlığı “Davranış”- Oturumun bitmesine 120 sn kala kapatılamayan bir Dialog açılır: başlık,
ProgressBarile kalan süre, “Çıkış yap” ve “Oturumu uzat”. Odak “Oturumu uzat” düğmesine gider. - Pencere kalan süreyi saniyede bir günceller. Escape ve dışarı basma pencereyi kapatmaz; kullanıcı iki eylemden birini seçer.
- “Oturumu uzat” süreyi tam oturuma (15 dakika) döndürür, pencere kapanır ve “Oturum 15 dakika uzatıldı” Toast’ı görünür.
- “Çıkış yap” oturumu kapatır. Süre dolarsa pencere kapanır ve
ResultState“Oturum süresi doldu” der; gönderilmemiş girdiyi mümkünse koruyun. - Kod girişinde kullanıcı kodu yazar veya yapıştırır; “Doğrula” ile denetlenir. Eksik kodda “Kod 6 haneli olmalı” görünür.
- Yanlış kod kalan hakkı azaltır ve hata
InlineAlertile duyurulur. Bekleme süresi bitince “Kodu tekrar gönder” etkinleşir; yeni kod süreyi ve hakkı sıfırlar, “Yeni kod gönderildi” bilgisi görünür.
Erişilebilirlik
Bölüm başlığı “Erişilebilirlik”- Uyarı bir modal Dialog’dur: odak pencerede kalır, arkadaki sayfa devre dışıdır. Kapatılamaz; çünkü kullanıcı yanıt vermelidir.
ProgressBarkalan süreyivalueTextile okur: “1 dakika 30 saniye”. Görünen sayaç canlı bölge değildir; ayrı birrole="status"bölgesi yalnızca 120, 60 ve 30 sn’de konuşur.- Süre uzatma tek düğmedir ve odak ondadır; kullanıcı en az 20 sn içinde yanıt verebilir (WCAG 2.2.1).
- Süre dolunca pencere kapanır; odak
ResultStatebaşlığına taşınır, kullanıcı yerini kaybetmez. - Kod alanı tek bir girdidir; parola yöneticisi ve SMS otomatik doldurma
autoComplete="one-time-code"ile çalışır. - Yeniden gönder düğmesi bekleme sırasında odak alır ve
aria-disabled="true"taşır; kalan süre metinde yazar. - Yanlış kod alanı
aria-invalidile işaretler; mesaj alanın altındakiInlineAlert’te okunur (“Hata: …”) ve görününce duyurulur.
İçerik
Bölüm başlığı “İçerik”- Başlık durumu söyler: “Oturumunuz sona ermek üzere”.
- Düğmeler eylemi söyler: “Oturumu uzat”, “Çıkış yap”.
- Süre “1 dk 40 sn” biçimindedir; ondalık veya saat biçimi kullanmayın.
- Duyuru metni: “Oturumunuz 1 dk 0 sn sonra sona erecek”.
- Kod açıklaması: “Telefonunuza gönderilen 6 haneli kodu girin”.
- Hatalar: “Kod 6 haneli olmalı”, “Kod hatalı. 2 deneme hakkınız kaldı.”, “Kodun süresi doldu. Yeni kod isteyin.”
Hareket
Bölüm başlığı “Hareket”| Öğe | Hareket | Preset |
|---|---|---|
| Uyarı penceresi | Bileşenin kendi açılış ve kapanış hareketi | yerleşik |
| Süre çubuğu | Dolgu bileşenin kendi hareketiyle küçülür | yerleşik |
| Toast, InlineAlert | Bileşenin kendi giriş hareketi | yerleşik |
| Düğmeler | Basınca 0,97 ölçek | motion.press |
Hareketi azaltma tercihinde kaymalar kalkar, yalnızca solma kalır.
Kullanılan bileşenler
Bölüm başlığı “Kullanılan bileşenler”Dialog, ProgressBar, ResultState, Toast, InlineAlert, Button, Stack, Text, TextInput. İlgili kalıp: Formlar ve doğrulama.
Sıradaki bileşen: OtpInput aynı tek girdinin üzerinde bölümlü kutular çizer.