Site builder: security & data-loss hardening + unified asset picker + audit backlog #3
@@ -1,5 +1,6 @@
|
||||
import React, { useCallback } from 'react';
|
||||
import { useEditor } from '@craftjs/core';
|
||||
import { clickableProps } from '../../utils/a11y';
|
||||
|
||||
interface LayerNodeProps {
|
||||
nodeId: string;
|
||||
@@ -17,8 +18,7 @@ const LayerNode: React.FC<LayerNodeProps> = ({ nodeId, depth }) => {
|
||||
};
|
||||
});
|
||||
|
||||
const handleClick = useCallback((e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
const handleActivate = useCallback(() => {
|
||||
actions.selectNode(nodeId);
|
||||
}, [actions, nodeId]);
|
||||
|
||||
@@ -38,7 +38,7 @@ const LayerNode: React.FC<LayerNodeProps> = ({ nodeId, depth }) => {
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
onClick={handleClick}
|
||||
{...clickableProps(handleActivate)}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React, { useState } from 'react';
|
||||
import { usePages } from '../../state/PageContext';
|
||||
import { clickableProps } from '../../utils/a11y';
|
||||
|
||||
export const PagesPanel: React.FC = () => {
|
||||
const {
|
||||
@@ -286,7 +287,7 @@ export const PagesPanel: React.FC = () => {
|
||||
) : (
|
||||
/* Normal page item */
|
||||
<div
|
||||
onClick={() => switchPage(page.id)}
|
||||
{...clickableProps(() => switchPage(page.id))}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
TemplateCategory,
|
||||
} from '../../templates';
|
||||
import { componentResolver } from '../../components/resolver';
|
||||
import { clickableProps } from '../../utils/a11y';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
@@ -392,7 +393,7 @@ const TemplateCard: React.FC<{
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={onSelect}
|
||||
{...clickableProps(onSelect)}
|
||||
onMouseEnter={() => setHovered(true)}
|
||||
onMouseLeave={() => setHovered(false)}
|
||||
style={{
|
||||
|
||||
@@ -123,7 +123,7 @@ export const TopBar: React.FC<TopBarProps> = ({ device, onDeviceChange }) => {
|
||||
<nav className="topbar">
|
||||
<div className="topbar-left">
|
||||
{isWHP && (
|
||||
<a href={whpConfig!.backUrl} className="topbar-btn back-btn">
|
||||
<a href={whpConfig!.backUrl} className="topbar-btn back-btn" aria-label="Back to Panel">
|
||||
<i className="fa fa-arrow-left" /> Back to Panel
|
||||
</a>
|
||||
)}
|
||||
@@ -141,6 +141,8 @@ export const TopBar: React.FC<TopBarProps> = ({ device, onDeviceChange }) => {
|
||||
className={`device-btn ${device === d ? 'active' : ''}`}
|
||||
onClick={() => onDeviceChange(d)}
|
||||
title={d.charAt(0).toUpperCase() + d.slice(1)}
|
||||
aria-label={d.charAt(0).toUpperCase() + d.slice(1)}
|
||||
aria-pressed={device === d}
|
||||
>
|
||||
<i className={`fa ${d === 'desktop' ? 'fa-desktop' : d === 'tablet' ? 'fa-tablet' : 'fa-mobile'}`} />
|
||||
</button>
|
||||
@@ -149,20 +151,20 @@ export const TopBar: React.FC<TopBarProps> = ({ device, onDeviceChange }) => {
|
||||
</div>
|
||||
|
||||
<div className="topbar-right">
|
||||
<button className="topbar-btn" onClick={() => actions.history.undo()} disabled={!canUndo} title="Undo">
|
||||
<button className="topbar-btn" onClick={() => actions.history.undo()} disabled={!canUndo} title="Undo" aria-label="Undo">
|
||||
<i className="fa fa-undo" />
|
||||
</button>
|
||||
<button className="topbar-btn" onClick={() => actions.history.redo()} disabled={!canRedo} title="Redo">
|
||||
<button className="topbar-btn" onClick={() => actions.history.redo()} disabled={!canRedo} title="Redo" aria-label="Redo">
|
||||
<i className="fa fa-repeat" />
|
||||
</button>
|
||||
<span className="topbar-divider" />
|
||||
<button className="topbar-btn" title="Templates" onClick={() => setTemplateModalOpen(true)}>
|
||||
<button className="topbar-btn" title="Templates" aria-label="Templates" onClick={() => setTemplateModalOpen(true)}>
|
||||
<i className="fa fa-th-large" /> Templates
|
||||
</button>
|
||||
<button className="topbar-btn" title="Custom Head Code" onClick={() => setHeadCodeModalOpen(true)}>
|
||||
<button className="topbar-btn" title="Custom Head Code" aria-label="Custom Head Code" onClick={() => setHeadCodeModalOpen(true)}>
|
||||
<i className="fa fa-code" /> Code
|
||||
</button>
|
||||
<button className="topbar-btn" title="Preview" onClick={() => {
|
||||
<button className="topbar-btn" title="Preview" aria-label="Preview" onClick={() => {
|
||||
try {
|
||||
const serialized = query.serialize();
|
||||
import('../../utils/html-export').then(({ exportToHtml, exportBodyHtml }) => {
|
||||
@@ -255,6 +257,7 @@ export const TopBar: React.FC<TopBarProps> = ({ device, onDeviceChange }) => {
|
||||
onClick={handleSave}
|
||||
disabled={saveStatus === 'saving'}
|
||||
title="Save Draft"
|
||||
aria-label="Save Draft"
|
||||
>
|
||||
{saveStatus === 'saving' ? (
|
||||
<><i className="fa fa-spinner fa-spin" /> Saving...</>
|
||||
@@ -269,6 +272,7 @@ export const TopBar: React.FC<TopBarProps> = ({ device, onDeviceChange }) => {
|
||||
onClick={handlePublish}
|
||||
disabled={publishStatus === 'publishing' || saveStatus === 'saving'}
|
||||
title="Publish to live site"
|
||||
aria-label="Publish to live site"
|
||||
>
|
||||
{publishStatus === 'publishing' ? (
|
||||
<><i className="fa fa-spinner fa-spin" /> Publishing...</>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React, { useState, useCallback, useRef, useEffect, CSSProperties } from 'react';
|
||||
import { uploadAsset, listAssets, Asset } from '../utils/assets';
|
||||
import { clickableProps } from '../utils/a11y';
|
||||
|
||||
export interface AssetPickerProps {
|
||||
value: string;
|
||||
@@ -192,7 +193,7 @@ export const AssetPicker: React.FC<AssetPickerProps> = ({
|
||||
{browserAssets.map((asset) => (
|
||||
<div
|
||||
key={asset.name}
|
||||
onClick={() => handleSelectAsset(asset)}
|
||||
{...clickableProps(() => handleSelectAsset(asset))}
|
||||
data-testid={`asset-picker-thumb-${asset.name}`}
|
||||
style={{ cursor: 'pointer', borderRadius: 4, overflow: 'hidden', border: '2px solid transparent', aspectRatio: '1' }}
|
||||
>
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { describe, test, expect, vi } from 'vitest';
|
||||
import { clickableProps } from './a11y';
|
||||
|
||||
function makeKeyEvent(key: string) {
|
||||
return {
|
||||
key,
|
||||
preventDefault: vi.fn(),
|
||||
} as unknown as Parameters<ReturnType<typeof clickableProps>['onKeyDown']>[0];
|
||||
}
|
||||
|
||||
describe('clickableProps', () => {
|
||||
test('sets role=button and tabIndex=0', () => {
|
||||
const props = clickableProps(() => {});
|
||||
expect(props.role).toBe('button');
|
||||
expect(props.tabIndex).toBe(0);
|
||||
});
|
||||
|
||||
test('onClick invokes the callback', () => {
|
||||
const onActivate = vi.fn();
|
||||
const props = clickableProps(onActivate);
|
||||
props.onClick();
|
||||
expect(onActivate).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('onKeyDown invokes the callback and preventDefaults on Enter', () => {
|
||||
const onActivate = vi.fn();
|
||||
const props = clickableProps(onActivate);
|
||||
const e = makeKeyEvent('Enter');
|
||||
props.onKeyDown(e);
|
||||
expect(onActivate).toHaveBeenCalledTimes(1);
|
||||
expect(e.preventDefault).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('onKeyDown invokes the callback and preventDefaults on Space', () => {
|
||||
const onActivate = vi.fn();
|
||||
const props = clickableProps(onActivate);
|
||||
const e = makeKeyEvent(' ');
|
||||
props.onKeyDown(e);
|
||||
expect(onActivate).toHaveBeenCalledTimes(1);
|
||||
expect(e.preventDefault).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('onKeyDown ignores other keys', () => {
|
||||
const onActivate = vi.fn();
|
||||
const props = clickableProps(onActivate);
|
||||
const e = makeKeyEvent('Tab');
|
||||
props.onKeyDown(e);
|
||||
expect(onActivate).not.toHaveBeenCalled();
|
||||
expect(e.preventDefault).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,30 @@
|
||||
import type { KeyboardEvent } from 'react';
|
||||
|
||||
export interface ClickableProps {
|
||||
role: 'button';
|
||||
tabIndex: number;
|
||||
onClick: () => void;
|
||||
onKeyDown: (e: KeyboardEvent) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Props to spread onto a non-interactive element (typically a `<div>`) that
|
||||
* acts like a button (e.g. a clickable list row or grid tile), so it
|
||||
* becomes keyboard-operable: `role="button"`, a tab stop, and Enter/Space
|
||||
* activation in addition to the existing click behavior.
|
||||
*
|
||||
* Usage: `<div {...clickableProps(() => doThing())}>...</div>`
|
||||
*/
|
||||
export function clickableProps(onActivate: () => void): ClickableProps {
|
||||
return {
|
||||
role: 'button',
|
||||
tabIndex: 0,
|
||||
onClick: onActivate,
|
||||
onKeyDown: (e: KeyboardEvent) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
onActivate();
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user