Site builder: security & data-loss hardening + unified asset picker + audit backlog #3
@@ -1,5 +1,6 @@
|
|||||||
import React, { useCallback } from 'react';
|
import React, { useCallback } from 'react';
|
||||||
import { useEditor } from '@craftjs/core';
|
import { useEditor } from '@craftjs/core';
|
||||||
|
import { clickableProps } from '../../utils/a11y';
|
||||||
|
|
||||||
interface LayerNodeProps {
|
interface LayerNodeProps {
|
||||||
nodeId: string;
|
nodeId: string;
|
||||||
@@ -17,8 +18,7 @@ const LayerNode: React.FC<LayerNodeProps> = ({ nodeId, depth }) => {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
const handleClick = useCallback((e: React.MouseEvent) => {
|
const handleActivate = useCallback(() => {
|
||||||
e.stopPropagation();
|
|
||||||
actions.selectNode(nodeId);
|
actions.selectNode(nodeId);
|
||||||
}, [actions, nodeId]);
|
}, [actions, nodeId]);
|
||||||
|
|
||||||
@@ -38,7 +38,7 @@ const LayerNode: React.FC<LayerNodeProps> = ({ nodeId, depth }) => {
|
|||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div
|
<div
|
||||||
onClick={handleClick}
|
{...clickableProps(handleActivate)}
|
||||||
style={{
|
style={{
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { usePages } from '../../state/PageContext';
|
import { usePages } from '../../state/PageContext';
|
||||||
|
import { clickableProps } from '../../utils/a11y';
|
||||||
|
|
||||||
export const PagesPanel: React.FC = () => {
|
export const PagesPanel: React.FC = () => {
|
||||||
const {
|
const {
|
||||||
@@ -286,7 +287,7 @@ export const PagesPanel: React.FC = () => {
|
|||||||
) : (
|
) : (
|
||||||
/* Normal page item */
|
/* Normal page item */
|
||||||
<div
|
<div
|
||||||
onClick={() => switchPage(page.id)}
|
{...clickableProps(() => switchPage(page.id))}
|
||||||
style={{
|
style={{
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
TemplateCategory,
|
TemplateCategory,
|
||||||
} from '../../templates';
|
} from '../../templates';
|
||||||
import { componentResolver } from '../../components/resolver';
|
import { componentResolver } from '../../components/resolver';
|
||||||
|
import { clickableProps } from '../../utils/a11y';
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Types
|
// Types
|
||||||
@@ -392,7 +393,7 @@ const TemplateCard: React.FC<{
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
onClick={onSelect}
|
{...clickableProps(onSelect)}
|
||||||
onMouseEnter={() => setHovered(true)}
|
onMouseEnter={() => setHovered(true)}
|
||||||
onMouseLeave={() => setHovered(false)}
|
onMouseLeave={() => setHovered(false)}
|
||||||
style={{
|
style={{
|
||||||
|
|||||||
@@ -123,7 +123,7 @@ export const TopBar: React.FC<TopBarProps> = ({ device, onDeviceChange }) => {
|
|||||||
<nav className="topbar">
|
<nav className="topbar">
|
||||||
<div className="topbar-left">
|
<div className="topbar-left">
|
||||||
{isWHP && (
|
{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
|
<i className="fa fa-arrow-left" /> Back to Panel
|
||||||
</a>
|
</a>
|
||||||
)}
|
)}
|
||||||
@@ -141,6 +141,8 @@ export const TopBar: React.FC<TopBarProps> = ({ device, onDeviceChange }) => {
|
|||||||
className={`device-btn ${device === d ? 'active' : ''}`}
|
className={`device-btn ${device === d ? 'active' : ''}`}
|
||||||
onClick={() => onDeviceChange(d)}
|
onClick={() => onDeviceChange(d)}
|
||||||
title={d.charAt(0).toUpperCase() + d.slice(1)}
|
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'}`} />
|
<i className={`fa ${d === 'desktop' ? 'fa-desktop' : d === 'tablet' ? 'fa-tablet' : 'fa-mobile'}`} />
|
||||||
</button>
|
</button>
|
||||||
@@ -149,20 +151,20 @@ export const TopBar: React.FC<TopBarProps> = ({ device, onDeviceChange }) => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="topbar-right">
|
<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" />
|
<i className="fa fa-undo" />
|
||||||
</button>
|
</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" />
|
<i className="fa fa-repeat" />
|
||||||
</button>
|
</button>
|
||||||
<span className="topbar-divider" />
|
<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
|
<i className="fa fa-th-large" /> Templates
|
||||||
</button>
|
</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
|
<i className="fa fa-code" /> Code
|
||||||
</button>
|
</button>
|
||||||
<button className="topbar-btn" title="Preview" onClick={() => {
|
<button className="topbar-btn" title="Preview" aria-label="Preview" onClick={() => {
|
||||||
try {
|
try {
|
||||||
const serialized = query.serialize();
|
const serialized = query.serialize();
|
||||||
import('../../utils/html-export').then(({ exportToHtml, exportBodyHtml }) => {
|
import('../../utils/html-export').then(({ exportToHtml, exportBodyHtml }) => {
|
||||||
@@ -255,6 +257,7 @@ export const TopBar: React.FC<TopBarProps> = ({ device, onDeviceChange }) => {
|
|||||||
onClick={handleSave}
|
onClick={handleSave}
|
||||||
disabled={saveStatus === 'saving'}
|
disabled={saveStatus === 'saving'}
|
||||||
title="Save Draft"
|
title="Save Draft"
|
||||||
|
aria-label="Save Draft"
|
||||||
>
|
>
|
||||||
{saveStatus === 'saving' ? (
|
{saveStatus === 'saving' ? (
|
||||||
<><i className="fa fa-spinner fa-spin" /> Saving...</>
|
<><i className="fa fa-spinner fa-spin" /> Saving...</>
|
||||||
@@ -269,6 +272,7 @@ export const TopBar: React.FC<TopBarProps> = ({ device, onDeviceChange }) => {
|
|||||||
onClick={handlePublish}
|
onClick={handlePublish}
|
||||||
disabled={publishStatus === 'publishing' || saveStatus === 'saving'}
|
disabled={publishStatus === 'publishing' || saveStatus === 'saving'}
|
||||||
title="Publish to live site"
|
title="Publish to live site"
|
||||||
|
aria-label="Publish to live site"
|
||||||
>
|
>
|
||||||
{publishStatus === 'publishing' ? (
|
{publishStatus === 'publishing' ? (
|
||||||
<><i className="fa fa-spinner fa-spin" /> Publishing...</>
|
<><i className="fa fa-spinner fa-spin" /> Publishing...</>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import React, { useState, useCallback, useRef, useEffect, CSSProperties } from 'react';
|
import React, { useState, useCallback, useRef, useEffect, CSSProperties } from 'react';
|
||||||
import { uploadAsset, listAssets, Asset } from '../utils/assets';
|
import { uploadAsset, listAssets, Asset } from '../utils/assets';
|
||||||
|
import { clickableProps } from '../utils/a11y';
|
||||||
|
|
||||||
export interface AssetPickerProps {
|
export interface AssetPickerProps {
|
||||||
value: string;
|
value: string;
|
||||||
@@ -192,7 +193,7 @@ export const AssetPicker: React.FC<AssetPickerProps> = ({
|
|||||||
{browserAssets.map((asset) => (
|
{browserAssets.map((asset) => (
|
||||||
<div
|
<div
|
||||||
key={asset.name}
|
key={asset.name}
|
||||||
onClick={() => handleSelectAsset(asset)}
|
{...clickableProps(() => handleSelectAsset(asset))}
|
||||||
data-testid={`asset-picker-thumb-${asset.name}`}
|
data-testid={`asset-picker-thumb-${asset.name}`}
|
||||||
style={{ cursor: 'pointer', borderRadius: 4, overflow: 'hidden', border: '2px solid transparent', aspectRatio: '1' }}
|
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