Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions dashboard/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { ThemeToggle } from './components/ThemeToggle';
import { MobileNavDrawer, NAV_ITEMS, type Tab } from './components/MobileNavDrawer';
import { ToastProvider } from './context/ToastContext';
import { useTheme } from './hooks/useTheme';
import { useIsMobileNav } from './hooks/useMediaQuery';
import { DeliveryHeatmap } from './components/DeliveryHeatmap';
import { useEventStore } from './store/eventStore';
import { SyncStatus } from './components/SyncStatus';
Expand All @@ -31,6 +32,7 @@ export function App() {
return 'explorer';
});
const [drawerOpen, setDrawerOpen] = useState(false);
const isMobileNav = useIsMobileNav();
const { theme, toggleTheme } = useTheme();
const events = useEventStore((state) => state.events);
const tabListRef = useRef<HTMLDivElement>(null);
Expand All @@ -53,6 +55,14 @@ export function App() {
return () => window.removeEventListener('hashchange', handleHashChange);
}, []);

// Close mobile drawer when the viewport crosses into desktop layout so
// hidden overlay/focus-trap state does not linger after a resize (#681).
useEffect(() => {
if (!isMobileNav && drawerOpen) {
setDrawerOpen(false);
}
}, [isMobileNav, drawerOpen]);

const handleTabKeyDown = useCallback((e: KeyboardEvent<HTMLDivElement>) => {
const tabs = Array.from(
tabListRef.current?.querySelectorAll<HTMLButtonElement>('[role="tab"]') ?? [],
Expand Down
37 changes: 31 additions & 6 deletions dashboard/src/components/EventCard.test.tsx
Original file line number Diff line number Diff line change
@@ -1,20 +1,23 @@
import { render, fireEvent } from '@testing-library/react';
import { render, fireEvent, screen } from '@testing-library/react';
import { axe, toHaveNoViolations } from 'jest-axe';
import { EventCard } from './EventCard';
import type { BlockchainEvent } from '../types/event';

expect.extend(toHaveNoViolations);

const LONG_HASH =
'abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef';

const mockEvent: BlockchainEvent = {
eventId: 'evt-1',
eventId: 'evt-1-very-long-identifier-that-should-not-overflow-the-layout',
type: 'TaskCreated',
eventName: 'TaskCreated',
ledger: 12345,
contractAddress: 'GABCDEF1234567890ABCDEF1234567890ABCDEF12',
receivedAt: Date.now(),
value: '100',
txHash: 'abcdef1234567890',
topic: [],
value: '{"message":"long payload content that must wrap on narrow viewports without horizontal scroll"}',
txHash: LONG_HASH,
topic: ['topic-with-a-very-long-name-that-needs-wrapping-on-mobile'],
} as BlockchainEvent;

test('clickable EventCard has no accessibility violations', async () => {
Expand All @@ -35,4 +38,26 @@ test('activates on Space key, not just Enter', () => {

fireEvent.keyDown(card, { key: 'Enter' });
expect(onClick).toHaveBeenCalledTimes(2);
});
});

describe('EventCard mobile detail layout (#680)', () => {
const breakpoints = [375, 390, 414, 600] as const;

afterEach(() => {
document.documentElement.style.width = '';
});

it.each(breakpoints)('keeps expanded details readable at %spx without full hash overflow', (width) => {
document.documentElement.style.width = `${width}px`;

const { container } = render(<EventCard event={mockEvent} variant="expanded" />);

expect(container.querySelector('.event-card--expanded')).toBeTruthy();
expect(screen.getByTitle(LONG_HASH)).toBeInTheDocument();
// Shortened display, not the raw 64+ char hash as the only text node
expect(screen.queryByText(LONG_HASH)).not.toBeInTheDocument();
expect(screen.getByRole('button', { name: /copy tx hash/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /copy contract address/i })).toBeInTheDocument();
expect(container.querySelector('.event-card__payload')).toHaveTextContent(/long payload content/i);
});
});
26 changes: 17 additions & 9 deletions dashboard/src/components/EventCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,15 @@ function handleActivationKey(onClick: (e: BlockchainEvent) => void, event: Block
};
}

function IdValue({ value, label }: { value: string; label: string }) {
return (
<dd className="event-card__id-value" title={value}>
<span className="event-card__id-text">{shortenAddress(value)}</span>
<CopyButton value={value} label={label} size="xs" />
</dd>
);
}

function CompactCard({ event, onClick }: { event: BlockchainEvent; onClick?: (e: BlockchainEvent) => void }) {
const displayName = event.eventName ?? event.type;
const badgeClass = getEventBadgeClass(event.eventName);
Expand Down Expand Up @@ -132,7 +141,9 @@ function CompactCard({ event, onClick }: { event: BlockchainEvent; onClick?: (e:
</span>
</div>
<div className="event-card__details">
<span>Value: {event.value}</span>
<span className="event-card__value-preview" title={event.value}>
Value: {event.value}
</span>
{event.txHash && (
<span title={event.txHash}>Tx: {shortenAddress(event.txHash)}</span>
)}
Expand Down Expand Up @@ -165,13 +176,13 @@ function ExpandedCard({ event, onClick }: { event: BlockchainEvent; onClick?: (e
<dl className="event-card__fields">
<div className="event-card__field">
<dt>Contract</dt>
<dd title={event.contractAddress}>{event.contractAddress}</dd>
<IdValue value={event.contractAddress} label="contract address" />
</div>

{event.txHash && (
<div className="event-card__field">
<dt>Tx Hash</dt>
<dd title={event.txHash}>{event.txHash}</dd>
<IdValue value={event.txHash} label="tx hash" />
</div>
)}

Expand All @@ -182,7 +193,7 @@ function ExpandedCard({ event, onClick }: { event: BlockchainEvent; onClick?: (e

<div className="event-card__field">
<dt>Value</dt>
<dd>{event.value}</dd>
<dd className="event-card__payload">{event.value}</dd>
</div>

{event.topic.length > 0 && (
Expand All @@ -205,10 +216,7 @@ function ExpandedCard({ event, onClick }: { event: BlockchainEvent; onClick?: (e

<div className="event-card__field">
<dt>Event ID</dt>
<dd className="event-card__id">
{event.eventId}
<CopyButton value={event.eventId} label="event ID" size="xs" />
</dd>
<IdValue value={event.eventId} label="event ID" />
</div>
</dl>
</div>
Expand All @@ -235,4 +243,4 @@ export const EventCard = memo(function EventCard({
}

return <CompactCard event={event} onClick={onClick} />;
});
});
51 changes: 51 additions & 0 deletions dashboard/src/components/FormField.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { render, screen } from '@testing-library/react';
import { axe, toHaveNoViolations } from 'jest-axe';
import { FormField, FormInput, getFormFieldA11yProps } from './FormField';

expect.extend(toHaveNoViolations);

describe('FormField (#678)', () => {
it('associates the error message with the control for screen readers', () => {
render(
<FormField id="email" label="Email address" error="Email address: enter a valid email.">
<FormInput fieldId="email" type="email" error="Email address: enter a valid email." />
</FormField>,
);

const input = screen.getByLabelText('Email address');
expect(input).toHaveAttribute('aria-invalid', 'true');
expect(input).toHaveAttribute('aria-describedby', 'email-error');
expect(screen.getByRole('alert')).toHaveTextContent('Email address: enter a valid email.');
expect(screen.getByRole('alert')).toHaveAttribute('id', 'email-error');
});

it('marks the field invalid without relying on colour alone', () => {
const { container } = render(
<FormField id="handle" label="Telegram handle" error="Telegram handle: invalid format.">
<FormInput fieldId="handle" error="Telegram handle: invalid format." />
</FormField>,
);

expect(container.querySelector('.form-field--invalid')).toBeTruthy();
expect(container.querySelector('[data-invalid="true"]')).toBeTruthy();
expect(container.querySelector('.form-field__error-icon')).toHaveTextContent('!');
expect(screen.getByRole('alert')).toBeInTheDocument();
});

it('exposes a11y props that name the affected field', () => {
expect(getFormFieldA11yProps('name', 'Name: required')).toEqual({
id: 'name',
'aria-invalid': true,
'aria-describedby': 'name-error',
});
});

it('has no accessibility violations when showing an error', async () => {
const { container } = render(
<FormField id="body" label="Body" error="Body: enter template content.">
<FormInput fieldId="body" error="Body: enter template content." />
</FormField>,
);
expect(await axe(container)).toHaveNoViolations();
});
});
136 changes: 136 additions & 0 deletions dashboard/src/components/FormField.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
import type { InputHTMLAttributes, ReactNode, SelectHTMLAttributes, TextareaHTMLAttributes } from 'react';

export interface FormFieldErrorProps {
id: string;
children: ReactNode;
}

/** Accessible inline validation message associated with a control (#678). */
export function FormFieldError({ id, children }: FormFieldErrorProps) {
return (
<p id={id} className="form-field__error" role="alert">
<span className="form-field__error-icon" aria-hidden="true">
!
</span>
<span className="form-field__error-text">{children}</span>
</p>
);
}

export interface FormFieldProps {
id: string;
label: string;
error?: string | null;
hint?: string;
required?: boolean;
children: ReactNode;
className?: string;
}

/**
* Standard form field wrapper: label + control slot + optional hint/error.
* Errors are wired via aria-describedby / aria-invalid on the control
* (use getFormFieldA11yProps) so messaging is not colour-only (#678).
*/
export function FormField({
id,
label,
error,
hint,
required,
children,
className,
}: FormFieldProps) {
const errorId = `${id}-error`;
const hintId = `${id}-hint`;
const invalid = Boolean(error);

return (
<div
className={`form-field${invalid ? ' form-field--invalid' : ''}${className ? ` ${className}` : ''}`}
data-invalid={invalid || undefined}
>
<label htmlFor={id} className="form-field__label">
{label}
{required ? (
<span className="form-field__required" aria-hidden="true">
*
</span>
) : null}
</label>
{children}
{hint && !error ? (
<p id={hintId} className="form-field__hint">
{hint}
</p>
) : null}
{error ? <FormFieldError id={errorId}>{error}</FormFieldError> : null}
</div>
);
}

/** Aria props to spread onto the associated input/select/textarea. */
export function getFormFieldA11yProps(id: string, error?: string | null, hint?: string) {
const describedBy: string[] = [];
if (error) describedBy.push(`${id}-error`);
else if (hint) describedBy.push(`${id}-hint`);

return {
id,
'aria-invalid': Boolean(error) || undefined,
'aria-describedby': describedBy.length > 0 ? describedBy.join(' ') : undefined,
} as const;
}

export type FormInputProps = InputHTMLAttributes<HTMLInputElement> & {
fieldId: string;
error?: string | null;
hint?: string;
};

export function FormInput({ fieldId, error, hint, className, ...rest }: FormInputProps) {
const a11y = getFormFieldA11yProps(fieldId, error, hint);
return (
<input
{...rest}
{...a11y}
className={`form-field__control${error ? ' form-field__control--invalid' : ''}${className ? ` ${className}` : ''}`}
/>
);
}

export type FormTextareaProps = TextareaHTMLAttributes<HTMLTextAreaElement> & {
fieldId: string;
error?: string | null;
hint?: string;
};

export function FormTextarea({ fieldId, error, hint, className, ...rest }: FormTextareaProps) {
const a11y = getFormFieldA11yProps(fieldId, error, hint);
return (
<textarea
{...rest}
{...a11y}
className={`form-field__control${error ? ' form-field__control--invalid' : ''}${className ? ` ${className}` : ''}`}
/>
);
}

export type FormSelectProps = SelectHTMLAttributes<HTMLSelectElement> & {
fieldId: string;
error?: string | null;
hint?: string;
};

export function FormSelect({ fieldId, error, hint, className, children, ...rest }: FormSelectProps) {
const a11y = getFormFieldA11yProps(fieldId, error, hint);
return (
<select
{...rest}
{...a11y}
className={`form-field__control${error ? ' form-field__control--invalid' : ''}${className ? ` ${className}` : ''}`}
>
{children}
</select>
);
}
34 changes: 34 additions & 0 deletions dashboard/src/components/NotificationDetailsDrawer.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,40 @@ describe('NotificationDetailsDrawer', () => {
expect(copyButtons.length).toBeGreaterThanOrEqual(1);
});

it('renders payload section with copy action for long values (#680)', () => {
const longPayload =
'{"detail":"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"}';
const notification = makeNotification({
value: longPayload,
txHash: 'abcdef1234567890abcdef1234567890abcdef12',
});

const { container } = render(
<NotificationDetailsDrawer
isOpen={true}
notification={notification}
onClose={() => {}}
/>
);

expect(screen.getByText('Payload')).toBeInTheDocument();
expect(container.querySelector('.drawer__payload')).toHaveTextContent(longPayload);
expect(container.querySelector('.drawer__row--stack')).toBeTruthy();
});

it('keeps close and copy actions available in the drawer chrome', () => {
render(
<NotificationDetailsDrawer
isOpen={true}
notification={makeNotification()}
onClose={() => {}}
/>
);

expect(screen.getByRole('button', { name: 'Close drawer' })).toBeInTheDocument();
expect(screen.getAllByRole('button', { name: 'Copy' }).length).toBeGreaterThan(0);
});

it('does not render Notification ID row when relatedNotificationId is absent', () => {
const notification = makeNotification();
const onClose = jest.fn();
Expand Down
Loading