mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-20 15:22:59 +00:00
feat(ui): redesign user menu and notification panel (#691)
* feat(ui): redesign user menu and notification panel per design audit
Align both floating surfaces with the stack-view design language: cyan
signal rails, identity headers, strip containers, day-banded streams,
severity rails, Instrument Serif for hero words, and tracked-mono for
metadata. Extract the notifications popover out of EditorLayout into its
own component and introduce a reusable SegmentedControl primitive for
3-way radiogroups (Auto/Light/Dark theme, All/Unread/Alerts filter).
* fix(ui): pin accessible names on avatar and bell buttons
After the redesign the avatar button's text content became the user's
initials and the bell button's text became the unread count, which
displaced the title-based accessible names and broke
getByRole('button', { name: /profile/i }). Set aria-label explicitly on
both triggers and mark the bell badge aria-hidden so screen readers and
role-based selectors see stable names regardless of dynamic content.
This commit is contained in:
@@ -0,0 +1,115 @@
|
||||
import { useRef } from 'react';
|
||||
import type { KeyboardEvent } from 'react';
|
||||
import type { LucideIcon } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export interface SegmentedControlOption<T extends string> {
|
||||
value: T;
|
||||
label: string;
|
||||
icon?: LucideIcon;
|
||||
badge?: string | number;
|
||||
}
|
||||
|
||||
interface SegmentedControlProps<T extends string> {
|
||||
value: T;
|
||||
options: SegmentedControlOption<T>[];
|
||||
onChange: (next: T) => void;
|
||||
ariaLabel?: string;
|
||||
iconOnly?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function SegmentedControl<T extends string>({
|
||||
value,
|
||||
options,
|
||||
onChange,
|
||||
ariaLabel,
|
||||
iconOnly,
|
||||
className,
|
||||
}: SegmentedControlProps<T>) {
|
||||
const buttonsRef = useRef<(HTMLButtonElement | null)[]>([]);
|
||||
|
||||
const focusIndex = (index: number) => {
|
||||
const clamped = (index + options.length) % options.length;
|
||||
const target = buttonsRef.current[clamped];
|
||||
const opt = options[clamped];
|
||||
if (target && opt) {
|
||||
target.focus();
|
||||
onChange(opt.value);
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent<HTMLButtonElement>, current: number) => {
|
||||
if (event.key === 'ArrowRight' || event.key === 'ArrowDown') {
|
||||
event.preventDefault();
|
||||
focusIndex(current + 1);
|
||||
} else if (event.key === 'ArrowLeft' || event.key === 'ArrowUp') {
|
||||
event.preventDefault();
|
||||
focusIndex(current - 1);
|
||||
} else if (event.key === 'Home') {
|
||||
event.preventDefault();
|
||||
focusIndex(0);
|
||||
} else if (event.key === 'End') {
|
||||
event.preventDefault();
|
||||
focusIndex(options.length - 1);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
role="radiogroup"
|
||||
aria-label={ariaLabel}
|
||||
className={cn(
|
||||
'inline-flex items-center rounded-md border border-card-border bg-card p-0.5',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{options.map((opt, index) => {
|
||||
const active = value === opt.value;
|
||||
const Icon = opt.icon;
|
||||
const hasBadge = opt.badge !== undefined && opt.badge !== '';
|
||||
const a11yLabel = iconOnly
|
||||
? opt.label
|
||||
: hasBadge
|
||||
? `${opt.label}, ${opt.badge}`
|
||||
: undefined;
|
||||
return (
|
||||
<button
|
||||
key={opt.value}
|
||||
ref={(el) => {
|
||||
buttonsRef.current[index] = el;
|
||||
}}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={active}
|
||||
aria-label={a11yLabel}
|
||||
title={iconOnly ? opt.label : undefined}
|
||||
tabIndex={active ? 0 : -1}
|
||||
onClick={() => onChange(opt.value)}
|
||||
onKeyDown={(e) => handleKeyDown(e, index)}
|
||||
className={cn(
|
||||
'flex items-center gap-1.5 rounded px-2.5 py-1 font-mono text-[10px] uppercase tracking-[0.14em] transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand/50',
|
||||
active
|
||||
? 'bg-brand/10 text-brand'
|
||||
: 'text-stat-subtitle hover:text-stat-value',
|
||||
)}
|
||||
>
|
||||
{Icon ? <Icon className="h-3.5 w-3.5" strokeWidth={1.5} /> : null}
|
||||
{iconOnly ? null : <span>{opt.label}</span>}
|
||||
{hasBadge ? (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
'ml-0.5 rounded-sm px-1 text-[9px] tabular-nums',
|
||||
active ? 'bg-brand/20 text-brand' : 'bg-muted text-stat-subtitle',
|
||||
)}
|
||||
>
|
||||
{opt.badge}
|
||||
</span>
|
||||
) : null}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user