mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 00:12:39 +00:00
fix: show the focus outline on menu items, grid cells and other roving-focus controls
This commit is contained in:
@@ -126,7 +126,7 @@ function VersionBadge() {
|
||||
|
||||
const triggerClass = cn(
|
||||
"peer text-center text-xs transition-colors rounded-sm underline-offset-2 hover:underline",
|
||||
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
||||
"focus-visible:outline-hidden",
|
||||
triggerColor,
|
||||
);
|
||||
|
||||
@@ -1686,7 +1686,7 @@ function LoginPageContent() {
|
||||
onChange={(e) => setRememberMe(e.target.checked)}
|
||||
className="peer sr-only"
|
||||
/>
|
||||
<span className="flex items-center justify-center w-[18px] h-[18px] rounded-[5px] border border-border/80 bg-muted/40 peer-checked:bg-primary peer-checked:border-primary peer-focus-visible:ring-2 peer-focus-visible:ring-ring peer-focus-visible:ring-offset-2 peer-focus-visible:ring-offset-background transition-all duration-200">
|
||||
<span className="flex items-center justify-center w-[18px] h-[18px] rounded-[5px] border border-border/80 bg-muted/40 peer-checked:bg-primary peer-checked:border-primary peer-focus-visible:outline-2 peer-focus-visible:outline-offset-2 peer-focus-visible:outline-[var(--focus-ring-color)] transition-all duration-200">
|
||||
{rememberMe && (
|
||||
<Check className="w-3 h-3 text-primary-foreground" stroke={3} aria-hidden="true" />
|
||||
)}
|
||||
|
||||
@@ -639,7 +639,7 @@ function ExtensionCard({
|
||||
/>
|
||||
</AdminLink>
|
||||
)}
|
||||
<AdminLink href={previewHref} className="block p-4 focus:outline-none focus-visible:ring-2 focus-visible:ring-ring/40 rounded-lg">
|
||||
<AdminLink href={previewHref} className="block p-4 focus:outline-hidden rounded-lg">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="w-10 h-10 rounded-md bg-muted flex items-center justify-center shrink-0 overflow-hidden">
|
||||
{extension.iconUrl ? (
|
||||
|
||||
@@ -78,7 +78,7 @@ export default function AdminLoginPage() {
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground transition-all duration-200 placeholder:text-muted-foreground hover:border-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:border-ring"
|
||||
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground transition-all duration-200 placeholder:text-muted-foreground hover:border-muted-foreground focus-visible:outline-hidden focus-visible:border-ring"
|
||||
placeholder="Enter admin password"
|
||||
required
|
||||
autoFocus
|
||||
|
||||
@@ -1667,7 +1667,7 @@ function Input({
|
||||
placeholder={placeholder}
|
||||
required={required}
|
||||
autoFocus={autoFocus}
|
||||
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-hidden"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1680,7 +1680,7 @@ function Select({ value, onChange, options }: { value: string; onChange: (v: str
|
||||
aria-describedby={field?.hintId}
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus-visible:outline-hidden"
|
||||
>
|
||||
{options.map((o) => (
|
||||
<option key={o.value} value={o.value}>
|
||||
|
||||
+15
-9
@@ -103,14 +103,18 @@
|
||||
* One outline for every focusable element, drawn as a real outline rather
|
||||
* than a box-shadow ring: forced-colors mode removes box-shadows, and an
|
||||
* outline also keeps its width at any zoom. It outranks the utilities that
|
||||
* used to switch the outline off (outline-none plus a ring). Elements that
|
||||
* only take focus from script (tabindex="-1": lists, panes, dialog bodies)
|
||||
* keep no outline unless "always show focus" is on.
|
||||
* used to switch the outline off (outline-none plus a ring).
|
||||
*
|
||||
* Containers that only take focus from script (tabindex="-1" on a list, a
|
||||
* pane, a dialog, <main>, a heading focused after navigation) skip it: the
|
||||
* focus there is a starting point, not a control. Controls with tabindex="-1"
|
||||
* keep it, because roving-focus widgets (menu items, options, grid cells,
|
||||
* tabs, rows) are focused while their tabindex is -1.
|
||||
*/
|
||||
:root {
|
||||
--focus-ring-width: 2px;
|
||||
--focus-ring-offset: 2px;
|
||||
--focus-ring-color: var(--color-primary);
|
||||
--focus-ring-color: var(--color-ring);
|
||||
}
|
||||
:root[data-focus-ring="thick"] {
|
||||
--focus-ring-width: 3px;
|
||||
@@ -119,13 +123,15 @@
|
||||
--focus-ring-width: 5px;
|
||||
--focus-ring-offset: 3px;
|
||||
}
|
||||
:focus-visible:not([tabindex="-1"]),
|
||||
:root[data-focus-always="on"] :focus:not([tabindex="-1"]) {
|
||||
:focus-visible:not([tabindex="-1"]:not(a[href], button, input, select, textarea, summary, [contenteditable="true"], [role="button"], [role="link"], [role="menuitem"], [role="menuitemradio"], [role="menuitemcheckbox"], [role="option"], [role="gridcell"], [role="row"], [role="treeitem"], [role="tab"], [role="radio"], [role="checkbox"], [role="switch"], [role="slider"], [role="spinbutton"], [role="columnheader"], [role="rowheader"])),
|
||||
:root[data-focus-always="on"] :focus:not([tabindex="-1"]:not(a[href], button, input, select, textarea, summary, [contenteditable="true"], [role="button"], [role="link"], [role="menuitem"], [role="menuitemradio"], [role="menuitemcheckbox"], [role="option"], [role="gridcell"], [role="row"], [role="treeitem"], [role="tab"], [role="radio"], [role="checkbox"], [role="switch"], [role="slider"], [role="spinbutton"], [role="columnheader"], [role="rowheader"])) {
|
||||
outline: var(--focus-ring-width) solid var(--focus-ring-color) !important;
|
||||
outline-offset: var(--focus-ring-offset) !important;
|
||||
}
|
||||
/* Inside a scroll container a positive offset is clipped; draw it inside. */
|
||||
:is([role="option"], [role="row"], [role="treeitem"], [role="gridcell"], [role="menuitem"], [role="menuitemradio"], [role="menuitemcheckbox"], [role="tab"]):focus-visible:not([tabindex="-1"]) {
|
||||
/* Inside a scroll container a positive offset is clipped; draw it inside.
|
||||
`focus-inset` does the same for any control that sits flush against the
|
||||
edge of a clipping container. */
|
||||
:is([role="option"], [role="row"], [role="treeitem"], [role="gridcell"], [role="menuitem"], [role="menuitemradio"], [role="menuitemcheckbox"], [role="tab"], .focus-inset):focus-visible {
|
||||
outline-offset: calc(var(--focus-ring-width) * -1) !important;
|
||||
}
|
||||
/* The focused element scrolls clear of sticky headers and the bottom bar. */
|
||||
@@ -249,7 +255,7 @@ html.dark[data-contrast="more"] {
|
||||
/* ---------- Forced colours (Windows contrast themes) ---------- */
|
||||
|
||||
@media (forced-colors: active) {
|
||||
:focus-visible:not([tabindex="-1"]) {
|
||||
:focus-visible:not([tabindex="-1"]:not(a[href], button, input, select, textarea, summary, [contenteditable="true"], [role="button"], [role="link"], [role="menuitem"], [role="menuitemradio"], [role="menuitemcheckbox"], [role="option"], [role="gridcell"], [role="row"], [role="treeitem"], [role="tab"], [role="radio"], [role="checkbox"], [role="switch"], [role="slider"], [role="spinbutton"], [role="columnheader"], [role="rowheader"])) {
|
||||
outline-color: Highlight !important;
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -26,7 +26,7 @@
|
||||
|
||||
--color-border: #e2e8f0;
|
||||
--color-input: #e2e8f0;
|
||||
--color-ring: #94a3b8;
|
||||
--color-ring: #2563eb;
|
||||
--color-background: #ffffff;
|
||||
--color-foreground: #0f172a;
|
||||
--color-primary: #3b82f6;
|
||||
|
||||
@@ -21,7 +21,7 @@ interface AdminButtonLinkProps extends AnchorHTMLAttributes<HTMLAnchorElement> {
|
||||
export function AdminButtonLink({ href, variant = 'outline', external, className, children, ...rest }: AdminButtonLinkProps) {
|
||||
const classes = cn(
|
||||
'inline-flex h-9 items-center justify-center gap-2 rounded-md px-3 text-sm font-medium transition-all duration-200',
|
||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background',
|
||||
'focus-visible:outline-hidden',
|
||||
VARIANTS[variant],
|
||||
className,
|
||||
);
|
||||
|
||||
@@ -17,7 +17,7 @@ export type ConfigSource = 'admin' | 'env' | 'default';
|
||||
|
||||
export const inputClass =
|
||||
'h-9 rounded-md border border-input bg-background px-3 text-sm text-foreground placeholder:text-muted-foreground ' +
|
||||
'hover:border-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:border-ring ' +
|
||||
'hover:border-muted-foreground focus-visible:outline-hidden focus-visible:border-ring ' +
|
||||
'disabled:cursor-not-allowed disabled:opacity-50 transition-colors';
|
||||
|
||||
export function SourceBadge({ source, label }: { source?: string; label?: string }) {
|
||||
|
||||
@@ -142,7 +142,7 @@ export function ExternalMailSection({ draft }: { draft: OrgPolicyDraft }) {
|
||||
onBlur={(e) => commitDomains(e.target.value)}
|
||||
placeholder="partner.com"
|
||||
spellCheck={false}
|
||||
className="w-full rounded-md border border-input bg-background px-3 py-2 font-mono text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
className="w-full rounded-md border border-input bg-background px-3 py-2 font-mono text-sm text-foreground placeholder:text-muted-foreground focus:outline-hidden"
|
||||
/>
|
||||
{rejected.length > 0 && (
|
||||
<p className="text-xs text-destructive" role="status" data-testid="org-external-domains-rejected">
|
||||
|
||||
@@ -52,7 +52,7 @@ export function RecoverySection({ draft }: { draft: OrgPolicyDraft }) {
|
||||
if (!Number.isFinite(parsed)) return;
|
||||
draft.setOrgValue('recovery', 'days', Math.min(MAX_DAYS, Math.max(0, parsed)));
|
||||
}}
|
||||
className="h-9 w-28 rounded-md border border-input bg-background px-3 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
className="h-9 w-28 rounded-md border border-input bg-background px-3 text-sm text-foreground placeholder:text-muted-foreground focus:outline-hidden"
|
||||
/>
|
||||
<span className="text-sm text-muted-foreground">days</span>
|
||||
</div>
|
||||
|
||||
@@ -72,7 +72,7 @@ export function SessionsSection({ draft }: { draft: OrgPolicyDraft }) {
|
||||
if (!Number.isFinite(parsed)) return;
|
||||
draft.setOrgValue('session', field.key, Math.min(field.max, Math.max(0, parsed)));
|
||||
}}
|
||||
className="h-9 w-28 rounded-md border border-input bg-background px-3 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
className="h-9 w-28 rounded-md border border-input bg-background px-3 text-sm text-foreground placeholder:text-muted-foreground focus:outline-hidden"
|
||||
/>
|
||||
<span className="text-sm text-muted-foreground">{field.unit}</span>
|
||||
</div>
|
||||
|
||||
@@ -22,8 +22,8 @@ import { Term } from '@/components/ui/term';
|
||||
|
||||
const textareaClass =
|
||||
'block w-full rounded-md border border-input bg-background px-3 py-2 font-mono text-xs text-foreground ' +
|
||||
'placeholder:text-muted-foreground hover:border-muted-foreground focus-visible:outline-none focus-visible:ring-2 ' +
|
||||
'focus-visible:ring-ring focus-visible:border-ring';
|
||||
'placeholder:text-muted-foreground hover:border-muted-foreground focus-visible:outline-hidden ' +
|
||||
'focus-visible:border-ring';
|
||||
const labelClass = 'mb-1 block text-sm font-medium text-foreground';
|
||||
const hintClass = 'mt-1 text-xs text-muted-foreground';
|
||||
|
||||
|
||||
@@ -13,8 +13,8 @@ import type { OrgPolicyDraft } from '../use-org-policy-draft';
|
||||
|
||||
const inputClass =
|
||||
'block w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground ' +
|
||||
'placeholder:text-muted-foreground hover:border-muted-foreground focus-visible:outline-none focus-visible:ring-2 ' +
|
||||
'focus-visible:ring-ring focus-visible:border-ring';
|
||||
'placeholder:text-muted-foreground hover:border-muted-foreground focus-visible:outline-hidden ' +
|
||||
'focus-visible:border-ring';
|
||||
const labelClass = 'mb-1 block text-xs font-medium text-foreground';
|
||||
|
||||
/** Same cap as the sanitiser (organisation-policy.ts): more are not saved. */
|
||||
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
type CalendarT = ReturnType<typeof useTranslations>;
|
||||
|
||||
const selectClass =
|
||||
"rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring";
|
||||
"rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-hidden";
|
||||
|
||||
/** "15 minutes before", plus "by email" for email alerts. */
|
||||
export function formatAlertRowLabel(row: Omit<AlertRow, "id">, t: CalendarT): string {
|
||||
|
||||
@@ -128,7 +128,7 @@ export function CalendarSearchResults({
|
||||
onClick={(e) => onSelectEvent(ev, e.currentTarget.getBoundingClientRect())}
|
||||
onContextMenu={onContextMenuEvent ? (e) => onContextMenuEvent(e, ev) : undefined}
|
||||
onKeyDown={handleRowKeyDown}
|
||||
className="w-full flex items-start gap-3 px-2 sm:px-4 rounded-md hover:bg-muted/60 focus-visible:bg-muted/60 outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring transition-colors text-start"
|
||||
className="w-full flex items-start gap-3 px-2 sm:px-4 rounded-md hover:bg-muted/60 focus-visible:bg-muted/60 outline-none focus-inset transition-colors text-start"
|
||||
style={{ paddingBlock: "var(--density-item-py)" }}
|
||||
>
|
||||
<span className="w-24 sm:w-32 flex-shrink-0 text-sm leading-tight">
|
||||
|
||||
@@ -92,7 +92,7 @@ export function CreateCalendarModal({ client, onClose }: CreateCalendarModalProp
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder={t("name_placeholder")}
|
||||
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-hidden"
|
||||
disabled={isSubmitting}
|
||||
onKeyDown={(e) => { if (e.key === "Enter" && isValid) handleSubmit(); }}
|
||||
/>
|
||||
|
||||
@@ -652,7 +652,7 @@ export function EventDetailPopover({
|
||||
placeholder={t("detail.add_note")}
|
||||
rows={2}
|
||||
autoFocus
|
||||
className="w-full rounded-md border border-input bg-muted/30 px-3 py-2 text-sm resize-none focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
className="w-full rounded-md border border-input bg-muted/30 px-3 py-2 text-sm resize-none focus:outline-hidden"
|
||||
/>
|
||||
<div className="flex justify-end gap-1.5">
|
||||
<Button
|
||||
|
||||
@@ -1241,7 +1241,7 @@ export function EventModal({
|
||||
placeholder={t("form.description")}
|
||||
rows={3}
|
||||
maxLength={10000}
|
||||
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm resize-none focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm resize-none focus:outline-hidden"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1352,7 +1352,7 @@ export function EventModal({
|
||||
type="date"
|
||||
value={startDate}
|
||||
onChange={(e) => handleStartDateChange(e.target.value)}
|
||||
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-hidden"
|
||||
/>
|
||||
</div>
|
||||
{!allDay && (
|
||||
@@ -1363,7 +1363,7 @@ export function EventModal({
|
||||
type="time"
|
||||
value={startTime}
|
||||
onChange={(e) => handleStartTimeChange(e.target.value)}
|
||||
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-hidden"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@@ -1374,7 +1374,7 @@ export function EventModal({
|
||||
type="date"
|
||||
value={endDate}
|
||||
onChange={(e) => setEndDate(e.target.value)}
|
||||
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-hidden"
|
||||
/>
|
||||
</div>
|
||||
{!allDay && (
|
||||
@@ -1385,7 +1385,7 @@ export function EventModal({
|
||||
type="time"
|
||||
value={endTime}
|
||||
onChange={(e) => setEndTime(e.target.value)}
|
||||
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-hidden"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@@ -1418,7 +1418,7 @@ export function EventModal({
|
||||
id={labelFor("calendar_select")}
|
||||
value={calendarId}
|
||||
onChange={(e) => setCalendarId(e.target.value)}
|
||||
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-hidden"
|
||||
>
|
||||
{selectableCalendars.map((cal) => (
|
||||
<option key={cal.id} value={cal.id}>
|
||||
@@ -1446,7 +1446,7 @@ export function EventModal({
|
||||
setShowRecurrenceEditor(false);
|
||||
}
|
||||
}}
|
||||
className="flex-1 min-w-0 rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
className="flex-1 min-w-0 rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-hidden"
|
||||
>
|
||||
<option value="none">{t("recurrence.none")}</option>
|
||||
<option value="daily">{t("recurrence.daily")}</option>
|
||||
|
||||
@@ -295,7 +295,7 @@ export function ICalImportModal({ calendars, client, onClose, initialUrl }: ICal
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
className={`w-full flex flex-col items-center justify-center border-2 border-dashed rounded-lg p-8 cursor-pointer transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring ${
|
||||
className={`w-full flex flex-col items-center justify-center border-2 border-dashed rounded-lg p-8 cursor-pointer transition-colors focus-visible:outline-hidden ${
|
||||
isDragging
|
||||
? "border-primary bg-primary/5"
|
||||
: "border-border hover:border-primary/50 hover:bg-muted/50"
|
||||
@@ -328,7 +328,7 @@ export function ICalImportModal({ calendars, client, onClose, initialUrl }: ICal
|
||||
onChange={(e) => setUrlInput(e.target.value)}
|
||||
placeholder={t("url_placeholder")}
|
||||
aria-label={t("tab_url")}
|
||||
className="flex-1 rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
className="flex-1 rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-hidden"
|
||||
onKeyDown={(e) => { if (e.key === "Enter") handleUrlFetch(); }}
|
||||
/>
|
||||
<Button
|
||||
@@ -418,7 +418,7 @@ export function ICalImportModal({ calendars, client, onClose, initialUrl }: ICal
|
||||
id={targetId}
|
||||
value={calendarId}
|
||||
onChange={(e) => setCalendarId(e.target.value)}
|
||||
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-hidden"
|
||||
>
|
||||
{calendars.map((cal) => (
|
||||
<option key={cal.id} value={cal.id}>
|
||||
|
||||
@@ -94,7 +94,7 @@ export function ICalSubscriptionModal({ client, onClose, editSubscription, initi
|
||||
? url !== editSubscription!.url || name !== editSubscription!.name || color !== editSubscription!.color || refreshInterval !== editSubscription!.refreshInterval
|
||||
: url.trim().length > 0 || name.trim().length > 0;
|
||||
|
||||
const fieldClass = "w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring";
|
||||
const fieldClass = "w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-hidden";
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
|
||||
@@ -198,7 +198,7 @@ export function QuickCreatePopover({
|
||||
value={draft.calendarId}
|
||||
onChange={(e) => onChange({ ...draft, calendarId: e.target.value })}
|
||||
aria-label={t("form.calendar_select")}
|
||||
className="flex-1 min-w-0 rounded-md border border-input bg-background px-3 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
className="flex-1 min-w-0 rounded-md border border-input bg-background px-3 py-1.5 text-sm focus:outline-hidden"
|
||||
>
|
||||
{calendars.map((c) => (
|
||||
<option key={c.id} value={c.id}>{c.name}</option>
|
||||
|
||||
@@ -54,7 +54,7 @@ export function QuickEventInput({ top, onSubmit, onCancel }: QuickEventInputProp
|
||||
placeholder={t("quick_create.placeholder")}
|
||||
aria-label={t("quick_create.aria_label")}
|
||||
maxLength={500}
|
||||
className="w-full px-2 py-1 text-xs rounded border border-primary bg-primary/10 text-foreground shadow-sm focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
className="w-full px-2 py-1 text-xs rounded border border-primary bg-primary/10 text-foreground shadow-sm focus:outline-hidden"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -238,7 +238,7 @@ export function RecurrenceEditor({ rule, eventStart, onSave, onCancel }: Recurre
|
||||
|
||||
const summary = buildRecurrenceSummary(buildRule(), t, locale);
|
||||
|
||||
const selectCls = "rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring";
|
||||
const selectCls = "rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-hidden";
|
||||
|
||||
return (
|
||||
<div className="mt-2 rounded-md border border-border bg-muted/20 p-3 space-y-3">
|
||||
|
||||
@@ -230,7 +230,7 @@ export function TaskListView({
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => { e.stopPropagation(); onSelectTask(task); }}
|
||||
className="flex-1 min-w-0 text-start rounded-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
className="flex-1 min-w-0 text-start rounded-sm focus-visible:outline-hidden"
|
||||
>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span className={cn(
|
||||
|
||||
@@ -250,7 +250,7 @@ export function TaskModal({
|
||||
placeholder={t("tasks.description_placeholder")}
|
||||
aria-label={t("form.description")}
|
||||
rows={3}
|
||||
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring resize-none"
|
||||
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-hidden resize-none"
|
||||
/>
|
||||
|
||||
{/* Due Date */}
|
||||
|
||||
@@ -58,7 +58,7 @@ export function AddressBookPickerDialog({ isOpen, onClose, addressBooks, current
|
||||
onClick={() => setSelected(book.id)}
|
||||
onDoubleClick={() => { if (!isCurrent) { onPick(book); onClose(); } }}
|
||||
className={cn(
|
||||
"w-full flex items-center gap-2 px-3 h-9 text-sm text-start outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring",
|
||||
"w-full flex items-center gap-2 px-3 h-9 text-sm text-start outline-none focus-inset",
|
||||
isSelected ? "bg-selection" : "hover:bg-muted",
|
||||
isCurrent && "text-muted-foreground",
|
||||
)}
|
||||
|
||||
@@ -207,7 +207,7 @@ function Select({ value, onChange, children, className, id, "aria-label": ariaLa
|
||||
onChange={onChange}
|
||||
className={cn(
|
||||
"text-sm bg-transparent border border-input rounded-md px-2.5 py-2 text-foreground",
|
||||
"focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-1",
|
||||
"focus:outline-hidden",
|
||||
"hover:border-muted-foreground/50 transition-colors",
|
||||
className
|
||||
)}
|
||||
@@ -726,7 +726,7 @@ export function ContactForm({ contact, addressBooks, allKeywords, defaultAddress
|
||||
type="button"
|
||||
onClick={() => photoInputRef.current?.click()}
|
||||
disabled={photoUploading}
|
||||
className="relative group rounded-full focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:opacity-60"
|
||||
className="relative group rounded-full focus:outline-hidden disabled:opacity-60"
|
||||
title={t("upload_photo")}
|
||||
aria-label={t("upload_photo")}
|
||||
>
|
||||
@@ -780,7 +780,7 @@ export function ContactForm({ contact, addressBooks, allKeywords, defaultAddress
|
||||
aria-label={t("section_address_book")}
|
||||
value={selectedBookId}
|
||||
onChange={(e) => setSelectedBookId(e.target.value)}
|
||||
className="w-full px-3 py-2 rounded-md border border-border bg-background text-sm focus:outline-none focus:ring-2 focus:ring-primary/50"
|
||||
className="w-full px-3 py-2 rounded-md border border-border bg-background text-sm focus:outline-hidden"
|
||||
>
|
||||
<option value="">{t("select_address_book")}</option>
|
||||
{addressBooks.map((book) => (
|
||||
@@ -1217,7 +1217,7 @@ export function ContactForm({ contact, addressBooks, allKeywords, defaultAddress
|
||||
onChange={(e) => setNote(e.target.value)}
|
||||
placeholder={t("note_placeholder")}
|
||||
aria-label={t("note")}
|
||||
className="w-full min-h-[100px] rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground resize-y outline-none focus:ring-2 focus:ring-ring"
|
||||
className="w-full min-h-[100px] rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground resize-y outline-none"
|
||||
/>
|
||||
</FormSection>
|
||||
|
||||
|
||||
@@ -78,7 +78,7 @@ export function ContactListItem({ contact, isSelected, isChecked, hasSelection,
|
||||
onClick={onClick}
|
||||
onContextMenu={onContextMenu ? (e) => onContextMenu(e, contact) : undefined}
|
||||
className={cn(
|
||||
"w-full flex items-center cursor-pointer select-none transition-all duration-200 border-b border-border outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring",
|
||||
"w-full flex items-center cursor-pointer select-none transition-all duration-200 border-b border-border outline-none focus-inset",
|
||||
isSelected
|
||||
? "bg-selection shadow-sm"
|
||||
: "bg-background hover:bg-muted hover:shadow-sm",
|
||||
|
||||
@@ -808,7 +808,7 @@ function SidebarRow({
|
||||
}
|
||||
}}
|
||||
aria-current={isActive ? "true" : undefined}
|
||||
className="flex flex-1 min-w-0 items-center gap-2 self-stretch text-start rounded-sm outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring"
|
||||
className="flex flex-1 min-w-0 items-center gap-2 self-stretch text-start rounded-sm outline-none focus-inset"
|
||||
>
|
||||
{icon}
|
||||
<span className="truncate" title={label}>{label}</span>
|
||||
|
||||
@@ -4421,7 +4421,7 @@ export function EmailComposer({
|
||||
focusBody();
|
||||
}
|
||||
}}
|
||||
className="flex-1 border-0 focus-visible:ring-0 h-8 px-0 text-sm"
|
||||
className="flex-1 border-0 h-8 px-0 text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -560,7 +560,7 @@ export function ContactSidebarPanel({
|
||||
<div className="px-4 pb-5 flex flex-col gap-2">
|
||||
<MailtoLink
|
||||
to={primaryEmail}
|
||||
className="inline-flex items-center justify-center gap-2 h-9 px-3 rounded-md text-sm font-medium bg-primary text-primary-foreground hover:bg-primary/90 shadow-sm hover:shadow transition-all duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
|
||||
className="inline-flex items-center justify-center gap-2 h-9 px-3 rounded-md text-sm font-medium bg-primary text-primary-foreground hover:bg-primary/90 shadow-sm hover:shadow transition-all duration-200 focus-visible:outline-hidden"
|
||||
title={t('contact_sidebar.action_email_title')}
|
||||
>
|
||||
<Send className="w-4 h-4" />
|
||||
@@ -6394,7 +6394,7 @@ export function EmailViewer({
|
||||
placeholder={t('quick_reply_placeholder')}
|
||||
className={cn(
|
||||
"w-full px-3 py-2 text-sm border border-border bg-background text-foreground rounded-lg",
|
||||
"hover:border-accent focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary transition-all",
|
||||
"hover:border-accent focus:border-primary focus:outline-hidden transition-all",
|
||||
"resize-none"
|
||||
)}
|
||||
rows={isQuickReplyFocused || quickReplyText ? 3 : 2}
|
||||
|
||||
@@ -138,7 +138,7 @@ export function ExternalSenderChip({ from, accountId, className, interactive = t
|
||||
setOpen(false);
|
||||
}
|
||||
}}
|
||||
className={cn(chipClass, 'cursor-pointer hover:bg-warning/20 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring')}
|
||||
className={cn(chipClass, 'cursor-pointer hover:bg-warning/20 focus-visible:outline-hidden')}
|
||||
>
|
||||
{content}
|
||||
</button>
|
||||
|
||||
@@ -143,7 +143,7 @@ export function MessageListTabs({ panelId }: { panelId: string }) {
|
||||
className={cn(
|
||||
'relative flex items-center gap-1.5 px-3.5 py-2.5 text-sm whitespace-nowrap select-none',
|
||||
'border-b-2 -mb-px rounded-t-md transition-colors duration-150',
|
||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset',
|
||||
'focus-visible:outline-hidden focus-inset',
|
||||
isActive
|
||||
? 'border-primary text-foreground font-medium'
|
||||
: 'border-transparent text-muted-foreground hover:text-foreground hover:bg-muted/40',
|
||||
|
||||
@@ -72,7 +72,7 @@ export function PluginListFilters({
|
||||
onClick={() => setListFilterActive(filter.key)}
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1 h-7 max-w-[12rem] px-2.5 rounded-full text-xs border transition-colors",
|
||||
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
||||
"focus-visible:outline-hidden",
|
||||
on
|
||||
? "bg-primary/10 text-primary border-primary/30 hover:bg-primary/15"
|
||||
: "border-border text-muted-foreground hover:bg-muted hover:text-foreground",
|
||||
|
||||
@@ -120,7 +120,7 @@ export function ScheduleSendDialog({
|
||||
type="button"
|
||||
onClick={() => scheduleAt(formatLocalDateTimeInput(preset.date))}
|
||||
disabled={disabled}
|
||||
className="flex items-center justify-between gap-3 rounded-md px-3 py-2 max-md:min-h-11 text-start text-sm hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-50"
|
||||
className="flex items-center justify-between gap-3 rounded-md px-3 py-2 max-md:min-h-11 text-start text-sm hover:bg-muted focus-visible:outline-hidden disabled:opacity-50"
|
||||
>
|
||||
<span>{t(`schedule_preset.${preset.key}`)}</span>
|
||||
<span className="text-muted-foreground">
|
||||
@@ -139,7 +139,7 @@ export function ScheduleSendDialog({
|
||||
setValue(e.target.value);
|
||||
setError("");
|
||||
}}
|
||||
className={cn(error && "border-destructive focus-visible:ring-destructive")}
|
||||
className={cn(error && "border-destructive")}
|
||||
/>
|
||||
{error && <p role="alert" className="mt-2 text-sm text-destructive">{error}</p>}
|
||||
</Dialog>
|
||||
|
||||
@@ -42,7 +42,7 @@ export function SelectableAvatar({
|
||||
}}
|
||||
className={cn(
|
||||
"group/select relative shrink-0 rounded-full outline-none",
|
||||
"focus-visible:ring-2 focus-visible:ring-primary/60",
|
||||
"",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -163,7 +163,7 @@ export function TagPicker({
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
placeholder={t("tag_filter_placeholder")}
|
||||
aria-label={t("tag_filter_placeholder")}
|
||||
className="w-full ps-8 pe-2 py-1 text-sm bg-muted border border-border rounded-md focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
className="w-full ps-8 pe-2 py-1 text-sm bg-muted border border-border rounded-md focus:outline-hidden"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@@ -202,7 +202,7 @@ export function TagPicker({
|
||||
aria-label={t("new_tag_placeholder")}
|
||||
aria-invalid={!!newError}
|
||||
className={cn(
|
||||
"w-full px-2 py-1 text-sm bg-background border rounded-md focus:outline-none focus:ring-2 focus:ring-ring",
|
||||
"w-full px-2 py-1 text-sm bg-background border rounded-md focus:outline-hidden",
|
||||
newError ? "border-destructive" : "border-border",
|
||||
)}
|
||||
/>
|
||||
|
||||
@@ -152,7 +152,7 @@ export function FolderPickerDialog({
|
||||
onClick={() => setSelected(row.id)}
|
||||
onDoubleClick={() => { if (row.id !== current) { onPick(row.id === ROOT ? null : row.id); onClose(); } }}
|
||||
className={cn(
|
||||
"flex items-center gap-1.5 pe-3 h-9 text-sm cursor-default select-none outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring",
|
||||
"flex items-center gap-1.5 pe-3 h-9 text-sm cursor-default select-none outline-none focus-inset",
|
||||
isSelected ? "bg-selection" : "hover:bg-muted",
|
||||
isCurrent && "text-muted-foreground",
|
||||
)}
|
||||
|
||||
@@ -140,7 +140,7 @@ export function NewFileDialog({ isOpen, onClose, onSubmit, formats, validate }:
|
||||
onClick={() => pick(ext)}
|
||||
className={cn(
|
||||
"flex flex-col items-center gap-1 rounded-md border px-2 py-3 text-center transition-colors",
|
||||
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
||||
"focus-visible:outline-hidden",
|
||||
selected === ext ? "border-primary bg-primary/10" : "border-border hover:bg-muted",
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -446,7 +446,7 @@ export function FilterRuleModal({
|
||||
};
|
||||
|
||||
const selectClass =
|
||||
"px-2.5 py-1.5 text-sm rounded-md bg-muted border border-border text-foreground focus:outline-none focus:ring-2 focus:ring-ring transition-colors duration-150 cursor-pointer hover:border-muted-foreground";
|
||||
"px-2.5 py-1.5 text-sm rounded-md bg-muted border border-border text-foreground focus:outline-hidden transition-colors duration-150 cursor-pointer hover:border-muted-foreground";
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -862,7 +862,7 @@ export function FilterRuleModal({
|
||||
max={PERIOD_INPUT_MAX}
|
||||
value={periodStart}
|
||||
onChange={(e) => { setPeriodStart(e.target.value); setHalfFilledPeriod(false); }}
|
||||
className="px-3 py-1.5 text-sm rounded-md bg-muted border border-border text-foreground focus:outline-none focus:ring-2 focus:ring-ring transition-colors duration-150 hover:border-muted-foreground"
|
||||
className="px-3 py-1.5 text-sm rounded-md bg-muted border border-border text-foreground focus:outline-hidden transition-colors duration-150 hover:border-muted-foreground"
|
||||
/>
|
||||
</label>
|
||||
<label className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
@@ -873,7 +873,7 @@ export function FilterRuleModal({
|
||||
max={PERIOD_INPUT_MAX}
|
||||
value={periodEnd}
|
||||
onChange={(e) => { setPeriodEnd(e.target.value); setHalfFilledPeriod(false); }}
|
||||
className="px-3 py-1.5 text-sm rounded-md bg-muted border border-border text-foreground focus:outline-none focus:ring-2 focus:ring-ring transition-colors duration-150 hover:border-muted-foreground"
|
||||
className="px-3 py-1.5 text-sm rounded-md bg-muted border border-border text-foreground focus:outline-hidden transition-colors duration-150 hover:border-muted-foreground"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
@@ -317,7 +317,7 @@ export function IdentityForm({ identity, onSave, onCancel, onDirtyChange }: Iden
|
||||
disabled={isSubmitting}
|
||||
aria-label={t('text_signature_label')}
|
||||
aria-describedby="identity-text-sig-counter"
|
||||
className="flex w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground transition-all duration-200 placeholder:text-muted-foreground hover:border-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:border-ring disabled:cursor-not-allowed disabled:opacity-50"
|
||||
className="flex w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground transition-all duration-200 placeholder:text-muted-foreground hover:border-muted-foreground focus-visible:outline-hidden focus-visible:border-ring disabled:cursor-not-allowed disabled:opacity-50"
|
||||
/>
|
||||
<SignatureByteCounter id="identity-text-sig-counter" value={formData.textSignature || ''} />
|
||||
{org.signature && (
|
||||
@@ -340,7 +340,7 @@ export function IdentityForm({ identity, onSave, onCancel, onDirtyChange }: Iden
|
||||
disabled={isSubmitting}
|
||||
aria-label={t('html_signature_label')}
|
||||
aria-describedby="identity-html-sig-counter"
|
||||
className="flex w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground font-mono transition-all duration-200 placeholder:text-muted-foreground hover:border-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:border-ring disabled:cursor-not-allowed disabled:opacity-50"
|
||||
className="flex w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground font-mono transition-all duration-200 placeholder:text-muted-foreground hover:border-muted-foreground focus-visible:outline-hidden focus-visible:border-ring disabled:cursor-not-allowed disabled:opacity-50"
|
||||
/>
|
||||
<SignatureByteCounter id="identity-html-sig-counter" value={formData.htmlSignature || ''} />
|
||||
{formData.htmlSignature && (
|
||||
|
||||
@@ -17,7 +17,7 @@ export function SkipLink({ targetId, children }: { targetId?: string; children:
|
||||
const target = targetId ? document.getElementById(targetId) : document.querySelector<HTMLElement>("main");
|
||||
if (target) focusTemporarily(target);
|
||||
}}
|
||||
className="sr-only focus:not-sr-only focus:fixed focus:top-2 focus:start-2 focus:z-[70] focus:rounded-md focus:bg-background focus:px-3 focus:py-2 focus:text-sm focus:font-medium focus:text-foreground focus:shadow-md focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
className="sr-only focus:not-sr-only focus:fixed focus:top-2 focus:start-2 focus:z-[70] focus:rounded-md focus:bg-background focus:px-3 focus:py-2 focus:text-sm focus:font-medium focus:text-foreground focus:shadow-md focus:outline-hidden"
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
|
||||
@@ -166,7 +166,7 @@ export function ResizeHandle({ onResizeStart, onResize, onResizeEnd, onDoubleCli
|
||||
// Centered over the seam with negative margins so the handle adds no
|
||||
// net layout space (panes sit flush) while staying draggable.
|
||||
"flex-shrink-0 hover:bg-primary/30 active:bg-primary/50 transition-colors relative group z-10",
|
||||
"focus-visible:outline-none focus-visible:bg-primary/40 focus-visible:ring-2 focus-visible:ring-primary/50",
|
||||
"focus-visible:outline-hidden focus-visible:bg-primary/40",
|
||||
// touch-none keeps the browser from turning a touch drag into a
|
||||
// scroll and cancelling the pointer.
|
||||
"touch-none",
|
||||
|
||||
@@ -286,7 +286,7 @@ function SidebarRowCounts({
|
||||
e.stopPropagation();
|
||||
onUnreadClick();
|
||||
}}
|
||||
className={cn(unreadClass, "cursor-pointer hover:underline rounded-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring")}
|
||||
className={cn(unreadClass, "cursor-pointer hover:underline rounded-sm focus-visible:outline-hidden")}
|
||||
title={t('unread_filter', { count: unreadCount })}
|
||||
aria-label={t('unread_filter', { count: unreadCount })}
|
||||
>
|
||||
@@ -613,7 +613,7 @@ function SidebarSectionHeader({
|
||||
data-section-name={label}
|
||||
data-expanded={expanded ? 'true' : 'false'}
|
||||
aria-expanded={expanded}
|
||||
className="flex flex-1 min-w-0 items-center text-start rounded-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
className="flex flex-1 min-w-0 items-center text-start rounded-sm focus-visible:outline-hidden"
|
||||
>
|
||||
{expanded ? (
|
||||
<ChevronDown className="w-3.5 h-3.5 text-muted-foreground flex-shrink-0" />
|
||||
|
||||
@@ -4772,7 +4772,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
|
||||
e.preventDefault();
|
||||
document.getElementById('message-list')?.focus();
|
||||
}}
|
||||
className="sr-only focus:not-sr-only focus:fixed focus:top-2 focus:start-2 focus:z-[70] focus:rounded-md focus:bg-background focus:px-3 focus:py-2 focus:text-sm focus:font-medium focus:text-foreground focus:shadow-md focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
className="sr-only focus:not-sr-only focus:fixed focus:top-2 focus:start-2 focus:z-[70] focus:rounded-md focus:bg-background focus:px-3 focus:py-2 focus:text-sm focus:font-medium focus:text-foreground focus:shadow-md focus:outline-hidden"
|
||||
>
|
||||
{t('email_list.skip_to_list')}
|
||||
</a>
|
||||
|
||||
@@ -126,7 +126,7 @@ export function NotificationPanel({ onClose, className, sheet = false }: { onClo
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={() => markRead(entry.id)}
|
||||
className="-mt-1 mb-1 ms-[38px] inline-flex items-center gap-1 rounded text-xs font-medium text-primary hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
className="-mt-1 mb-1 ms-[38px] inline-flex items-center gap-1 rounded text-xs font-medium text-primary hover:underline focus-visible:outline-hidden"
|
||||
>
|
||||
{entry.action.label}
|
||||
<ExternalLink className="w-3 h-3" aria-hidden="true" />
|
||||
|
||||
@@ -171,7 +171,7 @@ export function PluginActions({ surface, context, variant = 'buttons', mailboxRo
|
||||
onClick={(e) => { e.stopPropagation(); void run(action); }}
|
||||
className={cn(
|
||||
'relative inline-flex h-8 items-center gap-1.5 rounded-md px-2 text-sm text-muted-foreground transition-colors',
|
||||
'hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
|
||||
'hover:bg-muted hover:text-foreground focus-visible:outline-hidden',
|
||||
'disabled:opacity-50',
|
||||
action.badge && 'bg-primary/10 text-primary hover:bg-primary/10 hover:text-primary',
|
||||
action.danger && 'text-destructive hover:bg-destructive/10 hover:text-destructive',
|
||||
@@ -210,7 +210,7 @@ function PluginActionsOverflow({ surface, context, mailboxRole, mimeType, classN
|
||||
onClick={(e) => { e.stopPropagation(); openContextMenu(e, true); }}
|
||||
className={cn(
|
||||
'inline-flex h-8 items-center rounded-md px-2 text-muted-foreground transition-colors',
|
||||
'hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
|
||||
'hover:bg-muted hover:text-foreground focus-visible:outline-hidden',
|
||||
)}
|
||||
>
|
||||
<MoreVertical className="h-4 w-4" />
|
||||
|
||||
@@ -127,7 +127,7 @@ export function PluginConnectDialog() {
|
||||
value={server}
|
||||
placeholder={t('server_hint')}
|
||||
onChange={(e) => setServer(e.target.value)}
|
||||
className="w-full rounded-md border border-input bg-background px-2.5 py-2 text-sm text-foreground outline-none focus:ring-2 focus:ring-ring"
|
||||
className="w-full rounded-md border border-input bg-background px-2.5 py-2 text-sm text-foreground outline-none"
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
@@ -139,7 +139,7 @@ export function PluginConnectDialog() {
|
||||
value={username}
|
||||
autoComplete="username"
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
className="w-full rounded-md border border-input bg-background px-2.5 py-2 text-sm text-foreground outline-none focus:ring-2 focus:ring-ring"
|
||||
className="w-full rounded-md border border-input bg-background px-2.5 py-2 text-sm text-foreground outline-none"
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
@@ -151,7 +151,7 @@ export function PluginConnectDialog() {
|
||||
value={secret}
|
||||
autoComplete="off"
|
||||
onChange={(e) => setSecret(e.target.value)}
|
||||
className="w-full rounded-md border border-input bg-background px-2.5 py-2 text-sm text-foreground outline-none focus:ring-2 focus:ring-ring"
|
||||
className="w-full rounded-md border border-input bg-background px-2.5 py-2 text-sm text-foreground outline-none"
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
|
||||
@@ -30,7 +30,7 @@ const DATE_PRESETS = [
|
||||
const SIZE_PRESETS_MB = [1, 5, 10, 25] as const;
|
||||
|
||||
export const SELECT_CLASS =
|
||||
"w-full h-8 text-sm rounded-md border border-input bg-background px-2 text-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-1";
|
||||
"w-full h-8 text-sm rounded-md border border-input bg-background px-2 text-foreground focus:outline-hidden";
|
||||
|
||||
/** `days` before today as the yyyy-mm-dd a date input holds, in local time. */
|
||||
export function daysAgo(days: number, now = new Date()): string {
|
||||
|
||||
@@ -56,14 +56,17 @@ describe('Backup & reset', () => {
|
||||
});
|
||||
|
||||
// SET-25: the About card in the settings sidebar is named by what it shows,
|
||||
// not a hard-coded English "About", and shows the focus ring.
|
||||
// not a hard-coded English "About". Its focus is the global outline from
|
||||
// app/accessibility.css, so it carries no box-shadow ring of its own that
|
||||
// would draw a second ring (and vanish in forced colours).
|
||||
describe('About card', () => {
|
||||
it('has a visible focus ring and no hard-coded English name', async () => {
|
||||
it('leaves the focus to the global outline and has no hard-coded English name', async () => {
|
||||
const { AboutFooter } = await import('../about-data-settings');
|
||||
render(<AboutFooter />);
|
||||
const button = screen.getByTestId('settings-about').querySelector('button') as HTMLButtonElement;
|
||||
expect(button.getAttribute('aria-label')).toBeNull();
|
||||
expect(button.className).toContain('focus-visible:ring-2');
|
||||
expect(button.className).not.toMatch(/focus(-visible)?:ring/);
|
||||
expect(button.className).not.toContain('outline-none');
|
||||
expect(button.textContent).toContain('about.title');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -90,7 +90,7 @@ export function AboutFooter() {
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleLogoClick}
|
||||
className="flex items-center gap-3 flex-1 min-w-0 text-start rounded-md focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring group/about cursor-pointer"
|
||||
className="flex items-center gap-3 flex-1 min-w-0 text-start rounded-md focus-visible:outline-hidden group/about cursor-pointer"
|
||||
>
|
||||
<img
|
||||
src={logoLight}
|
||||
|
||||
@@ -593,7 +593,7 @@ function CredentialSection({ icon: Icon, i18nNamespace, entries, onCreate, onRem
|
||||
onChange={(e) => setAllowedIpsRaw(e.target.value)}
|
||||
placeholder={t('app_passwords.allowed_ips_placeholder')}
|
||||
rows={2}
|
||||
className="w-full text-xs font-mono px-3 py-2 rounded-md border border-border bg-background focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
className="w-full text-xs font-mono px-3 py-2 rounded-md border border-border bg-background focus:outline-hidden"
|
||||
/>
|
||||
<p className="text-[10px] text-muted-foreground mt-1">{t('app_passwords.allowed_ips_hint')}</p>
|
||||
</div>
|
||||
@@ -808,7 +808,7 @@ function PublicKeysSection() {
|
||||
placeholder={tk('key_placeholder')}
|
||||
rows={3}
|
||||
required
|
||||
className="w-full text-xs font-mono px-3 py-2 rounded-md border border-border bg-background focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
className="w-full text-xs font-mono px-3 py-2 rounded-md border border-border bg-background focus:outline-hidden"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
@@ -833,7 +833,7 @@ function PublicKeysSection() {
|
||||
id={labelFor("algorithm_label")}
|
||||
value={selectedAlgorithm}
|
||||
onChange={(e) => setSelectedAlgorithm(e.target.value as 'Aes128' | 'Aes256')}
|
||||
className="w-full text-xs px-3 py-1.5 rounded-md border border-border bg-background focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
className="w-full text-xs px-3 py-1.5 rounded-md border border-border bg-background focus:outline-hidden"
|
||||
>
|
||||
<option value="Aes256">AES-256</option>
|
||||
<option value="Aes128">AES-128</option>
|
||||
@@ -846,7 +846,7 @@ function PublicKeysSection() {
|
||||
type="checkbox"
|
||||
checked={encryptOnAppend}
|
||||
onChange={(e) => setEncryptOnAppend(e.target.checked)}
|
||||
className="rounded border-border text-primary focus:ring-ring"
|
||||
className="rounded border-border text-primary"
|
||||
/>
|
||||
{t('encryption.encrypt_on_append')}
|
||||
</label>
|
||||
@@ -856,7 +856,7 @@ function PublicKeysSection() {
|
||||
type="checkbox"
|
||||
checked={allowSpamTraining}
|
||||
onChange={(e) => setAllowSpamTraining(e.target.checked)}
|
||||
className="rounded border-border text-primary focus:ring-ring"
|
||||
className="rounded border-border text-primary"
|
||||
/>
|
||||
{t('encryption.allow_spam_training')}
|
||||
</label>
|
||||
@@ -977,12 +977,12 @@ function EmailClientSection() {
|
||||
type="text"
|
||||
readOnly
|
||||
value={jmapUsername}
|
||||
className="py-2 px-3 block w-full bg-background border border-border border-e-transparent rounded-s-lg text-sm text-foreground focus:z-10 focus:border-ring focus:ring-ring"
|
||||
className="py-2 px-3 block w-full bg-background border border-border border-e-transparent rounded-s-lg text-sm text-foreground focus:z-10 focus:border-ring"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCopy}
|
||||
className="h-[38px] px-3 shrink-0 inline-flex items-center gap-1.5 rounded-e-lg border border-border bg-muted text-sm text-muted-foreground hover:bg-accent hover:text-accent-foreground focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
className="h-[38px] px-3 shrink-0 inline-flex items-center gap-1.5 rounded-e-lg border border-border bg-muted text-sm text-muted-foreground hover:bg-accent hover:text-accent-foreground focus:outline-hidden"
|
||||
>
|
||||
{copied ? <Check className="size-3.5" /> : <Copy className="size-3.5" />}
|
||||
{copied ? t('email_client.copied') : t('email_client.copy')}
|
||||
@@ -1456,7 +1456,7 @@ export function LinkDeviceSection() {
|
||||
dir="ltr"
|
||||
value={signInLink}
|
||||
onFocus={(e) => e.currentTarget.select()}
|
||||
className="py-2 px-3 block w-full bg-background border border-border rounded-md text-xs font-mono text-foreground focus:border-ring focus:ring-ring"
|
||||
className="py-2 px-3 block w-full bg-background border border-border rounded-md text-xs font-mono text-foreground focus:border-ring"
|
||||
/>
|
||||
<Button type="button" variant="outline" size="sm" onClick={handleCopyLink}>
|
||||
{copied ? <Check className="w-3 h-3 me-1" /> : <Copy className="w-3 h-3 me-1" />}
|
||||
|
||||
@@ -50,7 +50,7 @@ function AddressBookEditRow({
|
||||
if (e.key === "Enter" && isValid) onSave(name.trim());
|
||||
if (e.key === "Escape") onCancel();
|
||||
}}
|
||||
className="w-full px-3 py-1.5 text-sm rounded-md border border-border bg-background text-foreground focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
className="w-full px-3 py-1.5 text-sm rounded-md border border-border bg-background text-foreground focus:outline-hidden"
|
||||
autoFocus
|
||||
disabled={isLoading}
|
||||
/>
|
||||
|
||||
@@ -70,7 +70,7 @@ function CalendarColorPicker({
|
||||
type="button"
|
||||
onClick={() => onChange(color)}
|
||||
className={cn(
|
||||
"w-6 h-6 rounded-full transition-transform hover:scale-110 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
||||
"w-6 h-6 rounded-full transition-transform hover:scale-110 focus-visible:outline-hidden",
|
||||
value === color && "ring-2 ring-offset-2 ring-offset-background ring-foreground"
|
||||
)}
|
||||
style={{ backgroundColor: color }}
|
||||
@@ -142,7 +142,7 @@ export function CalendarEditForm({
|
||||
if (e.key === 'Escape') onCancel();
|
||||
}}
|
||||
placeholder={t('name_placeholder')}
|
||||
className="w-full px-3 py-1.5 text-sm rounded-md border border-border bg-background text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
className="w-full px-3 py-1.5 text-sm rounded-md border border-border bg-background text-foreground placeholder:text-muted-foreground focus:outline-hidden"
|
||||
autoFocus
|
||||
disabled={isLoading}
|
||||
/>
|
||||
@@ -696,7 +696,7 @@ export function CalendarManagementSettings() {
|
||||
type="button"
|
||||
onClick={() => setColorPickerId(colorPickerId === cal.id ? null : cal.id)}
|
||||
className={cn(
|
||||
"block w-5 h-5 rounded-full shrink-0 transition-transform hover:scale-110 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
||||
"block w-5 h-5 rounded-full shrink-0 transition-transform hover:scale-110 focus-visible:outline-hidden",
|
||||
TOUCH_HIT_AREA,
|
||||
)}
|
||||
style={{ backgroundColor: color }}
|
||||
|
||||
@@ -195,7 +195,7 @@ export function ComposingSettings() {
|
||||
value={newKeyword}
|
||||
onChange={(e) => setNewKeyword(e.target.value)}
|
||||
placeholder={t('attachment_reminder.add_placeholder')}
|
||||
className="flex-1 min-w-0 px-2 py-1 text-sm bg-background border border-border rounded-md focus:outline-none focus:ring-1 focus:ring-ring"
|
||||
className="flex-1 min-w-0 px-2 py-1 text-sm bg-background border border-border rounded-md focus:outline-hidden"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
|
||||
@@ -234,7 +234,7 @@ export function DelegateDialog({
|
||||
onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); void lookUp(); } }}
|
||||
placeholder={t("search_placeholder")}
|
||||
aria-label={t("person")}
|
||||
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-hidden"
|
||||
ref={searchRef}
|
||||
/>
|
||||
{directory === null ? (
|
||||
|
||||
@@ -84,7 +84,7 @@ function TemplateEditor({
|
||||
placeholder={placeholder}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
spellCheck={false}
|
||||
className="flex-1 px-3 py-1.5 text-sm rounded-md bg-muted border border-border text-foreground font-mono focus:outline-none focus:ring-2 focus:ring-ring transition-colors duration-150"
|
||||
className="flex-1 px-3 py-1.5 text-sm rounded-md bg-muted border border-border text-foreground font-mono focus:outline-hidden transition-colors duration-150"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -151,7 +151,7 @@ function SortableFolderRow({ id, title, children }: { id: string; title: string;
|
||||
type="button"
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
className="flex items-center px-1 text-muted-foreground/40 hover:text-foreground cursor-grab active:cursor-grabbing touch-none rounded-md focus:outline-none focus:ring-2 focus:ring-ring flex-shrink-0"
|
||||
className="flex items-center px-1 text-muted-foreground/40 hover:text-foreground cursor-grab active:cursor-grabbing touch-none rounded-md focus:outline-hidden flex-shrink-0"
|
||||
title={title}
|
||||
aria-label={title}
|
||||
>
|
||||
@@ -542,7 +542,7 @@ export function FolderSettings() {
|
||||
}
|
||||
}}
|
||||
placeholder={parentId ? t('subfolder_name') : t('new_folder_name')}
|
||||
className="flex-1 px-2 py-1 text-sm rounded border border-border bg-background text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
className="flex-1 px-2 py-1 text-sm rounded border border-border bg-background text-foreground placeholder:text-muted-foreground focus:outline-hidden"
|
||||
autoFocus
|
||||
disabled={isLoading}
|
||||
/>
|
||||
@@ -592,7 +592,7 @@ export function FolderSettings() {
|
||||
if (e.key === 'Enter') handleRename(mb.id);
|
||||
if (e.key === 'Escape') cancelEdit();
|
||||
}}
|
||||
className="flex-1 px-2 py-1 text-sm rounded border border-border bg-background text-foreground focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
className="flex-1 px-2 py-1 text-sm rounded border border-border bg-background text-foreground focus:outline-hidden"
|
||||
autoFocus
|
||||
disabled={isLoading}
|
||||
/>
|
||||
@@ -643,7 +643,7 @@ export function FolderSettings() {
|
||||
onMouseDown={(e) => { if (e.shiftKey) e.preventDefault(); }}
|
||||
onClick={(e) => handleSelectClick(mb.id, e)}
|
||||
className={cn(
|
||||
"p-0.5 rounded flex-shrink-0 transition-colors hover:bg-accent focus:outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
||||
"p-0.5 rounded flex-shrink-0 transition-colors hover:bg-accent focus:outline-hidden",
|
||||
isSelected ? "text-primary" : "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -103,7 +103,7 @@ export function EmailAddressesSection() {
|
||||
}}
|
||||
aria-label={tEmail('sub_address_delimiter.custom_input_label')}
|
||||
placeholder={DEFAULT_CUSTOM_DELIMITER}
|
||||
className="w-16 px-2 py-1 text-sm font-mono text-center bg-background border border-border rounded-md focus:outline-none focus:ring-1 focus:ring-ring"
|
||||
className="w-16 px-2 py-1 text-sm font-mono text-center bg-background border border-border rounded-md focus:outline-hidden"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -54,7 +54,7 @@ export function KeywordColorPicker({
|
||||
type="button"
|
||||
onClick={() => onChange(colorKey)}
|
||||
className={cn(
|
||||
"w-6 h-6 rounded-full transition-transform hover:scale-110 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
||||
"w-6 h-6 rounded-full transition-transform hover:scale-110 focus-visible:outline-hidden",
|
||||
KEYWORD_PALETTE[colorKey].dot,
|
||||
value === colorKey && "ring-2 ring-offset-2 ring-offset-background ring-foreground"
|
||||
)}
|
||||
@@ -247,7 +247,7 @@ function KeywordEditForm({
|
||||
value={label}
|
||||
onChange={(e) => setLabel(e.target.value)}
|
||||
disabled={isLocked}
|
||||
className="w-full px-2.5 py-1.5 text-sm rounded-md border border-border bg-background focus:outline-none focus:ring-2 focus:ring-ring disabled:opacity-60"
|
||||
className="w-full px-2.5 py-1.5 text-sm rounded-md border border-border bg-background focus:outline-hidden disabled:opacity-60"
|
||||
placeholder={t("label_placeholder")}
|
||||
autoFocus
|
||||
maxLength={30}
|
||||
@@ -336,7 +336,7 @@ function UnrecognizedKeywordRow({
|
||||
type="button"
|
||||
onClick={() => setPickingColor((open) => !open)}
|
||||
className={cn(
|
||||
"w-5 h-5 shrink-0 rounded-full transition-transform hover:scale-110 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
||||
"w-5 h-5 shrink-0 rounded-full transition-transform hover:scale-110 focus-visible:outline-hidden",
|
||||
KEYWORD_PALETTE[draft.color]?.dot ?? KEYWORD_PALETTE.gray.dot,
|
||||
)}
|
||||
aria-label={t("color_field")}
|
||||
@@ -346,7 +346,7 @@ function UnrecognizedKeywordRow({
|
||||
type="text"
|
||||
value={draft.label}
|
||||
onChange={(e) => onChange({ ...draft, label: e.target.value })}
|
||||
className="min-w-0 flex-1 px-2.5 py-1.5 text-sm rounded-md border border-border bg-background focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
className="min-w-0 flex-1 px-2.5 py-1.5 text-sm rounded-md border border-border bg-background focus:outline-hidden"
|
||||
placeholder={t("label_placeholder")}
|
||||
maxLength={30}
|
||||
aria-label={t("label_field")}
|
||||
|
||||
@@ -378,7 +378,7 @@ export function Select({ value, onChange, options, disabled, className, ariaLabe
|
||||
aria-labelledby={ariaLabel ? undefined : labelledBy}
|
||||
dir="auto"
|
||||
className={cn(
|
||||
"px-3 py-1.5 text-sm rounded-md bg-muted border border-border text-foreground focus:outline-none focus:ring-2 focus:ring-ring transition-colors duration-150 pointer-coarse:min-h-11",
|
||||
"px-3 py-1.5 text-sm rounded-md bg-muted border border-border text-foreground focus:outline-hidden transition-colors duration-150 pointer-coarse:min-h-11",
|
||||
disabled ? "opacity-60 cursor-not-allowed" : "cursor-pointer hover:border-muted-foreground",
|
||||
className
|
||||
)}
|
||||
|
||||
@@ -198,7 +198,7 @@ export function ShareCollectionDialog({
|
||||
value={preset}
|
||||
onChange={(e) => handleSetRights(principalId, e.target.value as RolePreset)}
|
||||
disabled={savingId === principalId}
|
||||
className="appearance-none rounded-md border border-input bg-background ps-3 pe-8 py-1.5 text-xs focus:outline-none focus:ring-2 focus:ring-ring disabled:opacity-50"
|
||||
className="appearance-none rounded-md border border-input bg-background ps-3 pe-8 py-1.5 text-xs focus:outline-hidden disabled:opacity-50"
|
||||
>
|
||||
{presetOptions.map((p) => (
|
||||
<option key={p} value={p}>{t(`preset.${p}`)}</option>
|
||||
@@ -246,7 +246,7 @@ export function ShareCollectionDialog({
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder={t("search_placeholder")}
|
||||
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-hidden"
|
||||
autoFocus
|
||||
/>
|
||||
{/* The list below is partial or empty for a reason the user
|
||||
|
||||
@@ -96,7 +96,7 @@ function SharedTagForm({
|
||||
if (e.key === "Enter") save();
|
||||
if (e.key === "Escape") { e.stopPropagation(); onCancel(); }
|
||||
}}
|
||||
className="w-full rounded-md border border-border bg-background px-2.5 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
className="w-full rounded-md border border-border bg-background px-2.5 py-1.5 text-sm focus:outline-hidden"
|
||||
/>
|
||||
{duplicate && <p className="mt-1 text-xs text-destructive">{t("exists")}</p>}
|
||||
{tooLong && <p className="mt-1 text-xs text-destructive">{t("too_long")}</p>}
|
||||
|
||||
@@ -58,7 +58,7 @@ function utcToLocalDatetime(utcIso: string): string {
|
||||
}
|
||||
|
||||
const DATE_FIELD =
|
||||
'px-3 py-1.5 text-sm rounded-md bg-muted border border-border text-foreground focus:outline-none focus:ring-2 focus:ring-ring transition-colors duration-150 hover:border-muted-foreground';
|
||||
'px-3 py-1.5 text-sm rounded-md bg-muted border border-border text-foreground focus:outline-hidden transition-colors duration-150 hover:border-muted-foreground';
|
||||
|
||||
// The latest moment the date fields offer: a later year has no room in the
|
||||
// four digits a stored date has.
|
||||
@@ -492,7 +492,7 @@ export function VacationSettings() {
|
||||
onChange={(e) => changeTyped(setLocalSubject)(e.target.value)}
|
||||
placeholder={t('message.subject_placeholder')}
|
||||
aria-invalid={oversize.subject}
|
||||
className="w-64 px-3 py-1.5 text-sm rounded-md bg-muted border border-border text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring transition-colors duration-150 hover:border-muted-foreground"
|
||||
className="w-64 px-3 py-1.5 text-sm rounded-md bg-muted border border-border text-foreground placeholder:text-muted-foreground focus:outline-hidden transition-colors duration-150 hover:border-muted-foreground"
|
||||
/>
|
||||
</SettingItem>
|
||||
<FieldMessage text={oversize.subject && sizeLimits ? t('warnings.subject_too_long', { max: sizeLimits.subject }) : null} />
|
||||
@@ -509,7 +509,7 @@ export function VacationSettings() {
|
||||
onChange={(e) => changeTyped(setLocalTextBody)(e.target.value)}
|
||||
placeholder={t('message.body_placeholder')}
|
||||
rows={6}
|
||||
className="w-full px-3 py-2 text-sm rounded-md bg-muted border border-border text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring transition-colors duration-150 hover:border-muted-foreground resize-y"
|
||||
className="w-full px-3 py-2 text-sm rounded-md bg-muted border border-border text-foreground placeholder:text-muted-foreground focus:outline-hidden transition-colors duration-150 hover:border-muted-foreground resize-y"
|
||||
/>
|
||||
<FieldMessage text={bodyWarning} warning={!oversize.body} />
|
||||
</div>
|
||||
@@ -594,7 +594,7 @@ export function VacationSettings() {
|
||||
placeholder={t('forward.to_placeholder')}
|
||||
aria-label={t('forward.to_label')}
|
||||
aria-invalid={forwardInvalid}
|
||||
className="w-64 max-w-full px-3 py-1.5 text-sm rounded-md bg-muted border border-border text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring transition-colors duration-150 hover:border-muted-foreground"
|
||||
className="w-64 max-w-full px-3 py-1.5 text-sm rounded-md bg-muted border border-border text-foreground placeholder:text-muted-foreground focus:outline-hidden transition-colors duration-150 hover:border-muted-foreground"
|
||||
/>
|
||||
</SettingItem>
|
||||
<FieldMessage text={forwardInvalid ? t('warnings.forward_address') : null} />
|
||||
|
||||
@@ -204,7 +204,7 @@ export function TemplateForm({ template, initialData, onSave, onCancel, shared =
|
||||
onChange={(e) => setBody(e.target.value)}
|
||||
placeholder={tSettings('body_placeholder')}
|
||||
rows={6}
|
||||
className="mt-1 w-full rounded-md border border-border bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary resize-y"
|
||||
className="mt-1 w-full rounded-md border border-border bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:outline-hidden resize-y"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
@@ -256,7 +256,7 @@ export function TemplateForm({ template, initialData, onSave, onCancel, shared =
|
||||
id={labelFor("identity")}
|
||||
value={identityId}
|
||||
onChange={(e) => setIdentityId(e.target.value)}
|
||||
className="mt-1 w-full px-3 py-2 text-sm rounded-md bg-background border border-border text-foreground focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
className="mt-1 w-full px-3 py-2 text-sm rounded-md bg-background border border-border text-foreground focus:outline-hidden"
|
||||
>
|
||||
<option value="">{tSettings('default_identity')}</option>
|
||||
{identities.map((id) => (
|
||||
|
||||
@@ -171,7 +171,7 @@ export function TrustedSendersModal({ isOpen, onClose }: TrustedSendersModalProp
|
||||
}
|
||||
}}
|
||||
className={cn(
|
||||
"flex-1 min-w-0 px-3 py-2 text-sm bg-background border rounded-md focus:outline-none focus:ring-2 focus:ring-primary/50",
|
||||
"flex-1 min-w-0 px-3 py-2 text-sm bg-background border rounded-md focus:outline-hidden",
|
||||
emailError ? "border-destructive" : "border-border"
|
||||
)}
|
||||
/>
|
||||
@@ -213,7 +213,7 @@ export function TrustedSendersModal({ isOpen, onClose }: TrustedSendersModalProp
|
||||
aria-label={t("search_placeholder")}
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="w-full ps-9 pe-3 py-2 text-sm bg-muted border border-border rounded-md focus:outline-none focus:ring-2 focus:ring-primary/50"
|
||||
className="w-full ps-9 pe-3 py-2 text-sm bg-muted border border-border rounded-md focus:outline-hidden"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -39,7 +39,7 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
className={cn(
|
||||
"inline-flex items-center justify-center rounded-md font-medium transition-all duration-200",
|
||||
TOUCH_HIT_AREA,
|
||||
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
|
||||
"focus-visible:outline-hidden",
|
||||
"disabled:pointer-events-none disabled:opacity-50",
|
||||
{
|
||||
default:
|
||||
|
||||
@@ -14,7 +14,7 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||
"file:border-0 file:bg-transparent file:text-sm file:font-medium",
|
||||
"placeholder:text-muted-foreground",
|
||||
"hover:border-muted-foreground",
|
||||
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:border-ring",
|
||||
"focus-visible:outline-hidden focus-visible:border-ring",
|
||||
"disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
|
||||
@@ -170,7 +170,7 @@ export function LanguageSwitcher({ className }: { className?: string }) {
|
||||
type="button"
|
||||
ref={buttonRef}
|
||||
onClick={toggle}
|
||||
className="flex items-center gap-2 px-3 py-1.5 text-sm rounded-md bg-muted border border-border text-foreground hover:border-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring transition-colors duration-150 cursor-pointer w-full"
|
||||
className="flex items-center gap-2 px-3 py-1.5 text-sm rounded-md bg-muted border border-border text-foreground hover:border-muted-foreground focus:outline-hidden transition-colors duration-150 cursor-pointer w-full"
|
||||
aria-haspopup="listbox"
|
||||
aria-expanded={open}
|
||||
>
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* The global focus outline in app/accessibility.css.
|
||||
*
|
||||
* jsdom does not apply stylesheets or match :focus-visible, so the test takes
|
||||
* the selectors of the outline rule, drops the focus pseudo-class and checks
|
||||
* which elements the rest of each selector matches.
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { readFileSync } from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
const css = readFileSync(path.join(process.cwd(), 'app', 'accessibility.css'), 'utf8');
|
||||
|
||||
/** Selectors of the first top-level rule whose body contains `declaration`. */
|
||||
function selectorsOf(declaration: string): string[] {
|
||||
const at = css.indexOf(declaration);
|
||||
expect(at).toBeGreaterThan(-1);
|
||||
const open = css.lastIndexOf('{', at);
|
||||
const prevClose = css.lastIndexOf('}', open);
|
||||
const prelude = css.slice(prevClose + 1, open).replace(/\/\*[\s\S]*?\*\//g, '');
|
||||
// Split on top-level commas only (the :not() lists have commas too).
|
||||
const out: string[] = [];
|
||||
let depth = 0;
|
||||
let cur = '';
|
||||
for (const ch of prelude) {
|
||||
if (ch === '(') depth++;
|
||||
if (ch === ')') depth--;
|
||||
if (ch === ',' && depth === 0) {
|
||||
out.push(cur.trim());
|
||||
cur = '';
|
||||
} else cur += ch;
|
||||
}
|
||||
out.push(cur.trim());
|
||||
return out.filter(Boolean);
|
||||
}
|
||||
|
||||
const outlined = selectorsOf('outline: var(--focus-ring-width) solid var(--focus-ring-color)')
|
||||
.find((s) => s.startsWith(':focus-visible'))!
|
||||
.replace(/^:focus-visible/, '*');
|
||||
|
||||
function el(html: string): Element {
|
||||
const host = document.createElement('div');
|
||||
host.innerHTML = html;
|
||||
return host.firstElementChild!;
|
||||
}
|
||||
|
||||
describe('global focus outline', () => {
|
||||
it.each([
|
||||
['a menu item', '<div role="menuitem" tabindex="-1">Reply</div>'],
|
||||
['a listbox option', '<div role="option" tabindex="-1">Inbox</div>'],
|
||||
['a grid cell', '<div role="gridcell" tabindex="-1">12</div>'],
|
||||
['a tab', '<button role="tab" tabindex="-1">All</button>'],
|
||||
['a row', '<div role="row" tabindex="-1"></div>'],
|
||||
['a tree item', '<div role="treeitem" tabindex="-1">Work</div>'],
|
||||
['a button in a toolbar', '<button tabindex="-1">Bold</button>'],
|
||||
['a link', '<a href="#x" tabindex="-1">Open</a>'],
|
||||
['a plain button', '<button>Send</button>'],
|
||||
])('draws on %s, even with tabindex -1', (_name, html) => {
|
||||
expect(el(html).matches(outlined)).toBe(true);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['a message list container', '<div id="message-list" tabindex="-1"></div>'],
|
||||
['a dialog', '<div role="dialog" tabindex="-1"></div>'],
|
||||
['main', '<main tabindex="-1"></main>'],
|
||||
['a heading focused after navigation', '<h1 tabindex="-1">Inbox</h1>'],
|
||||
['a menu container', '<div role="menu" tabindex="-1"></div>'],
|
||||
])('skips %s that only takes focus from script', (_name, html) => {
|
||||
expect(el(html).matches(outlined)).toBe(false);
|
||||
});
|
||||
|
||||
it('takes its colour from the ring token, which themes keep at 3:1', () => {
|
||||
expect(css).toMatch(/--focus-ring-color:\s*var\(--color-ring\)/);
|
||||
});
|
||||
});
|
||||
@@ -48,7 +48,7 @@ describe('plugin UI kit components', () => {
|
||||
// Each kit component that can't reuse an app component copies one; the
|
||||
// copy must still be there, word for word.
|
||||
const copies: [string, string][] = [
|
||||
['components/settings/settings-section.tsx', 'px-3 py-1.5 text-sm rounded-md bg-muted border border-border text-foreground focus:outline-none focus:ring-2 focus:ring-ring transition-colors duration-150'],
|
||||
['components/settings/settings-section.tsx', 'px-3 py-1.5 text-sm rounded-md bg-muted border border-border text-foreground focus:outline-hidden'],
|
||||
['components/settings/settings-section.tsx', 'inline-flex h-6 w-11 shrink-0 items-center rounded-full transition-colors duration-150'],
|
||||
['components/settings/settings-section.tsx', 'bg-muted ring-1 ring-inset ring-muted-foreground'],
|
||||
['components/settings/settings-section.tsx', 'inline-block h-4 w-4 transform rounded-full transition-transform duration-150'],
|
||||
|
||||
@@ -87,7 +87,7 @@ const nordCSS = `
|
||||
:root {
|
||||
--color-border: #d8dee9;
|
||||
--color-input: #d8dee9;
|
||||
--color-ring: #81a1c1;
|
||||
--color-ring: #5e81ac;
|
||||
--color-background: #eceff4;
|
||||
--color-foreground: #2e3440;
|
||||
--color-primary: #5e81ac;
|
||||
@@ -341,7 +341,7 @@ const elasticCSS = `
|
||||
:root {
|
||||
--color-border: #dddddd;
|
||||
--color-input: #ced4da;
|
||||
--color-ring: #37beff;
|
||||
--color-ring: #0b7dc1;
|
||||
--color-background: #ffffff;
|
||||
--color-foreground: #27353a;
|
||||
--color-primary: #37beff;
|
||||
|
||||
@@ -134,7 +134,7 @@ export const Textarea = React.forwardRef<HTMLTextAreaElement, React.TextareaHTML
|
||||
return (
|
||||
<textarea
|
||||
ref={ref}
|
||||
className={cn('bk flex w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground transition-all duration-200 placeholder:text-muted-foreground hover:border-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:border-ring disabled:cursor-not-allowed disabled:opacity-50', className)}
|
||||
className={cn('bk flex w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground transition-all duration-200 placeholder:text-muted-foreground hover:border-muted-foreground focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring focus-visible:border-ring disabled:cursor-not-allowed disabled:opacity-50', className)}
|
||||
{...rest}
|
||||
/>
|
||||
);
|
||||
@@ -153,7 +153,7 @@ export function Select({ options, children, onChange, disabled, block = false, c
|
||||
return (
|
||||
<select
|
||||
className={cn(
|
||||
'bk px-3 py-1.5 text-sm rounded-md bg-muted border border-border text-foreground focus:outline-none focus:ring-2 focus:ring-ring transition-colors duration-150',
|
||||
'bk px-3 py-1.5 text-sm rounded-md bg-muted border border-border text-foreground focus:outline-hidden focus:ring-2 focus:ring-ring transition-colors duration-150',
|
||||
disabled ? 'opacity-60 cursor-not-allowed' : 'cursor-pointer hover:border-muted-foreground',
|
||||
block && 'w-full',
|
||||
className,
|
||||
@@ -525,7 +525,7 @@ export function Combobox({
|
||||
toggleMultipleOption(opt);
|
||||
}}
|
||||
aria-label={kitString('remove_named', { name: String(opt.label) })}
|
||||
className="text-muted-foreground hover:text-foreground p-0.5 rounded focus:outline-none"
|
||||
className="text-muted-foreground hover:text-foreground p-0.5 rounded focus:outline-hidden"
|
||||
>
|
||||
<X className="w-3 h-3" aria-hidden="true" />
|
||||
</button>
|
||||
@@ -585,7 +585,7 @@ export function Combobox({
|
||||
}}
|
||||
onClick={handleClear}
|
||||
aria-label={kitString('clear')}
|
||||
className="p-1 rounded hover:text-foreground focus:outline-none"
|
||||
className="p-1 rounded hover:text-foreground focus:outline-hidden"
|
||||
>
|
||||
<X className="w-3.5 h-3.5" aria-hidden="true" />
|
||||
</button>
|
||||
@@ -600,7 +600,7 @@ export function Combobox({
|
||||
}}
|
||||
onClick={handleToggle}
|
||||
aria-label={isOpen ? kitString('close') : kitString('open')}
|
||||
className="p-1 rounded hover:text-foreground focus:outline-none disabled:opacity-50"
|
||||
className="p-1 rounded hover:text-foreground focus:outline-hidden disabled:opacity-50"
|
||||
>
|
||||
{isOpen ? <ChevronUp className="w-3.5 h-3.5" aria-hidden="true" /> : <ChevronDown className="w-3.5 h-3.5" aria-hidden="true" />}
|
||||
</button>
|
||||
@@ -614,7 +614,7 @@ export function Combobox({
|
||||
role="listbox"
|
||||
aria-multiselectable={multiple || undefined}
|
||||
aria-label={ariaLabel ?? placeholder}
|
||||
className="absolute z-50 mt-1 max-h-60 w-full overflow-auto rounded-md border border-border bg-popover text-popover-foreground shadow-md p-1 focus:outline-none"
|
||||
className="absolute z-50 mt-1 max-h-60 w-full overflow-auto rounded-md border border-border bg-popover text-popover-foreground shadow-md p-1 focus:outline-hidden"
|
||||
>
|
||||
{filteredOptions.length === 0 ? (
|
||||
<div className="px-2 py-2 text-xs text-muted-foreground text-center">
|
||||
@@ -679,7 +679,7 @@ export function Checkbox({ checked, onChange, label, hint, disabled = false, id,
|
||||
disabled={disabled}
|
||||
onClick={() => onChange?.(!checked)}
|
||||
className={cn(
|
||||
'bk mt-0.5 flex h-4 w-4 shrink-0 items-center justify-center rounded-sm border transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-50',
|
||||
'bk mt-0.5 flex h-4 w-4 shrink-0 items-center justify-center rounded-sm border transition-colors focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-50',
|
||||
checked ? 'border-primary bg-primary text-primary-foreground' : 'border-muted-foreground/50 bg-background',
|
||||
)}
|
||||
>
|
||||
@@ -710,7 +710,7 @@ export function Switch({ checked, onChange, disabled = false, label, id }: { che
|
||||
disabled={disabled}
|
||||
onClick={() => onChange?.(!checked)}
|
||||
className={cn(
|
||||
'bk relative inline-flex h-6 w-11 shrink-0 items-center rounded-full transition-colors duration-150 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
|
||||
'bk relative inline-flex h-6 w-11 shrink-0 items-center rounded-full transition-colors duration-150 focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring',
|
||||
checked ? 'bg-primary' : 'bg-muted ring-1 ring-inset ring-muted-foreground',
|
||||
disabled ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer',
|
||||
)}
|
||||
@@ -737,7 +737,7 @@ export function Segmented({ value, options, onChange, label }: { value: string;
|
||||
aria-checked={o.value === value}
|
||||
onClick={() => onChange?.(o.value)}
|
||||
className={cn(
|
||||
'px-3 py-1.5 text-xs rounded-md transition-colors duration-150 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
|
||||
'px-3 py-1.5 text-xs rounded-md transition-colors duration-150 focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring',
|
||||
o.value === value ? 'bg-primary text-primary-foreground font-medium' : 'bg-muted hover:bg-accent text-foreground',
|
||||
)}
|
||||
>
|
||||
@@ -945,7 +945,7 @@ export function Tabs({ value, tabs, onChange, label }: { value: string; tabs: {
|
||||
aria-selected={active}
|
||||
onClick={() => onChange(tab.id)}
|
||||
className={cn(
|
||||
'flex items-center gap-1.5 px-4 py-2 text-sm font-medium border-b-2 -mb-px whitespace-nowrap transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset',
|
||||
'flex items-center gap-1.5 px-4 py-2 text-sm font-medium border-b-2 -mb-px whitespace-nowrap transition-colors focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset',
|
||||
active ? 'border-primary text-primary' : 'border-transparent text-muted-foreground hover:text-foreground',
|
||||
)}
|
||||
>
|
||||
@@ -1116,7 +1116,7 @@ export function RowAction({ icon, label, onClick, danger = false }: { icon: stri
|
||||
onClick={onClick}
|
||||
title={label}
|
||||
aria-label={label}
|
||||
className={cn('bk p-1.5 rounded-md text-muted-foreground hover:text-foreground hover:bg-foreground/10 transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring', danger && 'hover:text-destructive')}
|
||||
className={cn('bk p-1.5 rounded-md text-muted-foreground hover:text-foreground hover:bg-foreground/10 transition-all focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring', danger && 'hover:text-destructive')}
|
||||
>
|
||||
<Icon name={icon} />
|
||||
</button>
|
||||
@@ -1152,7 +1152,7 @@ export function Chip({ icon, label, detail, onClick, onRemove, removeLabel, titl
|
||||
);
|
||||
const box = 'bk inline-flex items-center gap-1.5 px-2.5 py-1.5 bg-muted/60 rounded-md border border-border/50 flex-shrink-0 max-w-full';
|
||||
if (onClick && !onRemove) {
|
||||
return <button type="button" title={title} onClick={onClick} className={cn(box, 'hover:bg-muted transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring')}>{body}</button>;
|
||||
return <button type="button" title={title} onClick={onClick} className={cn(box, 'hover:bg-muted transition-colors focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring')}>{body}</button>;
|
||||
}
|
||||
return (
|
||||
<span className={box} title={title}>
|
||||
@@ -1226,7 +1226,7 @@ export function BarButton({ icon, children, onClick, active = false, busy = fals
|
||||
disabled={disabled || busy}
|
||||
aria-pressed={active || undefined}
|
||||
className={cn(
|
||||
'bk inline-flex items-center gap-1.5 text-sm px-3 py-1.5 rounded-md border transition-colors min-h-[36px] disabled:opacity-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
|
||||
'bk inline-flex items-center gap-1.5 text-sm px-3 py-1.5 rounded-md border transition-colors min-h-[36px] disabled:opacity-50 focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring',
|
||||
active ? 'bg-primary/10 text-primary border-primary/30 font-medium' : 'text-muted-foreground hover:text-foreground border-border hover:bg-muted',
|
||||
)}
|
||||
>
|
||||
@@ -1293,7 +1293,7 @@ export function SidebarRow({ icon, label, detail, onClick, title, ariaLabel }: {
|
||||
{...(onClick ? { type: 'button' as const, onClick } : {})}
|
||||
title={title}
|
||||
aria-label={ariaLabel}
|
||||
className={cn('bk w-full flex items-center gap-2 ps-[28px] pe-3 text-sm text-foreground text-start border-s-2 border-transparent transition-colors duration-150', onClick && 'hover:bg-muted/50 focus-visible:outline-none focus-visible:bg-muted/50')}
|
||||
className={cn('bk w-full flex items-center gap-2 ps-[28px] pe-3 text-sm text-foreground text-start border-s-2 border-transparent transition-colors duration-150', onClick && 'hover:bg-muted/50 focus-visible:outline-hidden focus-visible:bg-muted/50')}
|
||||
// Folder rows are 44px tall on phones (max-lg); the frame's own
|
||||
// viewport can't tell, so the page passes the height in.
|
||||
style={{ paddingBlock: 'var(--density-sidebar-py)', minHeight: 'var(--touch-row-min-h, 0px)' }}
|
||||
|
||||
Reference in New Issue
Block a user