fix: show the focus outline on menu items, grid cells and other roving-focus controls

This commit is contained in:
Linus Rath
2026-10-29 19:38:44 +01:00
parent 6ca53f24cd
commit 1111dd6709
73 changed files with 226 additions and 142 deletions
+2 -2
View File
@@ -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" />
)}
+1 -1
View File
@@ -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 ? (
+1 -1
View File
@@ -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
+2 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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;
+1 -1
View File
@@ -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,
);
+1 -1
View File
@@ -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. */
+1 -1
View File
@@ -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 {
+1 -1
View File
@@ -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(); }}
/>
+1 -1
View File
@@ -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
+7 -7
View File
@@ -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>
+3 -3
View File
@@ -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
+1 -1
View File
@@ -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>
+1 -1
View File
@@ -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>
);
+1 -1
View File
@@ -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">
+1 -1
View File
@@ -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(
+1 -1
View File
@@ -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",
)}
+4 -4
View File
@@ -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>
+1 -1
View File
@@ -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",
+1 -1
View File
@@ -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>
+1 -1
View File
@@ -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>
+2 -2
View File
@@ -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}
+1 -1
View File
@@ -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>
+1 -1
View File
@@ -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',
+1 -1
View File
@@ -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",
+2 -2
View File
@@ -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>
+1 -1
View File
@@ -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,
)}
>
+2 -2
View File
@@ -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",
)}
/>
+1 -1
View File
@@ -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",
)}
+1 -1
View File
@@ -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",
)}
>
+3 -3
View File
@@ -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>
+2 -2
View File
@@ -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 && (
+1 -1
View File
@@ -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>
+1 -1
View File
@@ -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",
+2 -2
View File
@@ -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" />
+1 -1
View File
@@ -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" />
+2 -2
View File
@@ -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" />
+3 -3
View File
@@ -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>
)}
+1 -1
View File
@@ -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');
});
});
+1 -1
View File
@@ -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 }}
+1 -1
View File
@@ -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 ? (
+1 -1
View File
@@ -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"
+4 -4
View File
@@ -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",
)}
>
+1 -1
View File
@@ -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>
+4 -4
View File
@@ -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")}
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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>}
+4 -4
View File
@@ -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} />
+2 -2
View File
@@ -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) => (
+2 -2
View File
@@ -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>
+1 -1
View File
@@ -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:
+1 -1
View File
@@ -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
)}
+1 -1
View File
@@ -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\)/);
});
});
+1 -1
View File
@@ -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'],
+2 -2
View File
@@ -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;
+14 -14
View File
@@ -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)' }}