Polish Visual Editor command ribbon
This commit is contained in:
parent
60273d7528
commit
24f04b9fc3
10
ROADMAP.md
10
ROADMAP.md
@ -28,7 +28,7 @@ The slice files remain the detailed implementation plans. When an older list con
|
||||
| [Slice 7a](SLICE7a.md) | Local authentication, RBAC, and application publishing | Complete | Implementation, security validation, manual acceptance, cleanup, and explicit sign-off passed |
|
||||
| [Slice 7b](SLICE7b.md) | Browser-based first-run administrator setup | Complete | Fresh-install setup, takeover prevention, recovery validation, manual acceptance, and explicit sign-off passed |
|
||||
| [Slice 7c](SLICE7c.md) | Multi-page applications and variable scope | Complete | Page authoring, scoped runtime, deep links, security validation, and explicit sign-off |
|
||||
| [Slice 7d](SLICE7d.md) | Visual Editor professional command ribbon | Planned | Ribbon implementation, responsive/accessibility validation, manual visual acceptance, and explicit sign-off |
|
||||
| [Slice 7d](SLICE7d.md) | Visual Editor professional command ribbon | Complete | None |
|
||||
| [Slice 8](SLICE8.md) | Documentation and release packaging | Partial | Unified roadmap complete; broader documentation ownership, reconciliation, guides, and release packaging remain |
|
||||
| [Slice 9](SLICE9.md) | OIDC and enterprise SSO | Post-MVP | Begins after Slice 7a; provider and provisioning decisions remain |
|
||||
|
||||
@ -278,10 +278,10 @@ Controlled orchestration is not required in the v0.1.0 demonstration. Execution
|
||||
|
||||
### Slice 7d — Visual Editor command ribbon
|
||||
|
||||
- [ ] Replace dense native-looking Visual Editor command rows with a grouped professional ribbon.
|
||||
- [ ] Distinguish project, page, page-settings, lifecycle, primary, disabled, and destructive controls.
|
||||
- [ ] Preserve Slice 7c behavior while improving desktop responsiveness, keyboard access, focus, and visual hierarchy.
|
||||
- [ ] Complete automated regression and accessibility checks plus manual visual acceptance and explicit sign-off.
|
||||
- [x] Replace dense native-looking Visual Editor command rows with a grouped professional ribbon.
|
||||
- [x] Distinguish project, page, page-settings, lifecycle, primary, disabled, and destructive controls.
|
||||
- [x] Preserve Slice 7c behavior while improving desktop responsiveness, keyboard access, focus, and visual hierarchy.
|
||||
- [x] Complete automated regression and accessibility checks plus manual visual acceptance and explicit sign-off.
|
||||
|
||||
## 8. Documentation, Packaging, and Release
|
||||
|
||||
|
||||
@ -26,7 +26,7 @@ This matrix maps every release-critical requirement area in `docs/REQUIREMENTS.m
|
||||
| R16 | Immutable standalone published applications with public/authenticated visibility and server-owned published action execution | Slice 7a | Server-snapshot integration coverage and accepted public/restricted publishing workflows | Accepted |
|
||||
| R17 | A fresh installation creates its initial administrator through a secure browser first-run flow without requiring Docker commands | Slice 7b | Atomic setup/security integration tests, frontend setup/password tests, recovery verification, and accepted manual workflow | Accepted |
|
||||
| R18 | Authored and published applications support multiple deep-linked pages with page-local components and explicit global/page runtime-variable scope | Slice 7c | Schema/editor/runtime/publishing tests, multi-page browser E2E, security validation, and manual acceptance | Accepted |
|
||||
| R19 | The Visual Editor presents project, page, page-setting, and lifecycle commands in a professional, accessible, responsive command ribbon | Slice 7d | Ribbon component/regression/accessibility checks and manual visual acceptance | Planned |
|
||||
| R19 | The Visual Editor presents project, page, page-setting, and lifecycle commands in a professional, accessible, responsive command ribbon | Slice 7d | 28 frontend suites / 528 tests, production build, and accepted manual visual validation | Accepted |
|
||||
|
||||
## Approved acceptance workflows
|
||||
|
||||
|
||||
@ -11,6 +11,15 @@
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.toolbarRibbon {
|
||||
min-height: 52px;
|
||||
height: auto;
|
||||
padding-top: 7px;
|
||||
padding-bottom: 7px;
|
||||
background: linear-gradient(180deg, #202832 0%, #171d24 100%);
|
||||
box-shadow: inset 0 -1px 0 rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
|
||||
.nameArea {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@ -40,6 +49,15 @@
|
||||
|
||||
.nameButton:hover {
|
||||
border-color: #484f58;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.nameButton:focus-visible,
|
||||
.btn:focus-visible,
|
||||
.pickerClose:focus-visible,
|
||||
.pickerItem:focus-visible {
|
||||
outline: 2px solid #79c0ff;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.nameInput {
|
||||
@ -77,16 +95,27 @@
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.toolbarRibbon .actions {
|
||||
padding-left: 14px;
|
||||
border-left: 1px solid #3b4652;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 5px 12px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
min-height: 32px;
|
||||
padding: 6px 13px;
|
||||
background: #21262d;
|
||||
border: 1px solid #30363d;
|
||||
border-radius: 4px;
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: #c9d1d9;
|
||||
cursor: pointer;
|
||||
transition: background 0.1s, border-color 0.1s;
|
||||
box-shadow: 0 1px 1px rgba(0, 0, 0, 0.18);
|
||||
transition: background 0.12s, border-color 0.12s, transform 0.12s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@ -95,14 +124,22 @@
|
||||
border-color: #8b949e;
|
||||
}
|
||||
|
||||
.btn:active:not(:disabled) { transform: translateY(1px); }
|
||||
|
||||
.btnIcon {
|
||||
font-size: 14px;
|
||||
line-height: 1;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btnPrimary {
|
||||
background: #1f6feb;
|
||||
border-color: #1f6feb;
|
||||
background: linear-gradient(180deg, #2f81f7 0%, #1f6feb 100%);
|
||||
border-color: #438ef7;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
|
||||
@ -2,7 +2,7 @@ import React, { useEffect, useState } from 'react';
|
||||
import { useProject } from '../../context/ProjectContext';
|
||||
import styles from './ProjectToolbar.module.css';
|
||||
|
||||
function ProjectToolbar(): React.ReactElement {
|
||||
function ProjectToolbar({ variant = 'default' }: { variant?: 'default' | 'ribbon' }): React.ReactElement {
|
||||
const {
|
||||
projectRowId,
|
||||
projectName,
|
||||
@ -61,7 +61,7 @@ function ProjectToolbar(): React.ReactElement {
|
||||
return (
|
||||
<>
|
||||
{/* ── Toolbar strip ─────────────────────────────────────────── */}
|
||||
<div className={styles.toolbar}>
|
||||
<div className={[styles.toolbar, variant === 'ribbon' ? styles.toolbarRibbon : ''].join(' ')} aria-label="Project commands">
|
||||
{/* Project name — click to rename */}
|
||||
<div className={styles.nameArea}>
|
||||
{editingName ? (
|
||||
@ -97,7 +97,7 @@ function ProjectToolbar(): React.ReactElement {
|
||||
disabled={isLoading || isSaving}
|
||||
title="Create a new project (current unsaved changes will be lost)"
|
||||
>
|
||||
New
|
||||
<span className={styles.btnIcon} aria-hidden="true">+</span> New
|
||||
</button>
|
||||
|
||||
{/* Save */}
|
||||
@ -107,7 +107,7 @@ function ProjectToolbar(): React.ReactElement {
|
||||
disabled={isSaving || isLoading}
|
||||
title={projectRowId === null ? 'Save project to backend' : 'Update project on backend'}
|
||||
>
|
||||
{isSaving ? 'Saving…' : (projectRowId === null ? 'Save' : 'Update')}
|
||||
<span className={styles.btnIcon} aria-hidden="true">✓</span> {isSaving ? 'Saving…' : (projectRowId === null ? 'Save' : 'Update')}
|
||||
</button>
|
||||
|
||||
{/* Load */}
|
||||
@ -117,7 +117,7 @@ function ProjectToolbar(): React.ReactElement {
|
||||
disabled={isLoading || isSaving}
|
||||
title="Load an existing project from the backend"
|
||||
>
|
||||
{isLoading ? 'Loading…' : 'Load'}
|
||||
<span className={styles.btnIcon} aria-hidden="true">↥</span> {isLoading ? 'Loading…' : 'Load'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -40,4 +40,19 @@ describe('PageEventEditor', () => {
|
||||
});
|
||||
expect(onChange).toHaveBeenCalledWith([]);
|
||||
});
|
||||
|
||||
test('configures a distinct onEnter lifecycle action', () => {
|
||||
const onChange = jest.fn();
|
||||
act(() => root.render(<PageEventEditor actions={actions} events={[{ event: 'onLoad', actionId: 'action_a' }]} onChange={onChange} />));
|
||||
const select = container.querySelector<HTMLSelectElement>('#page-onenter-action')!;
|
||||
expect(select.getAttribute('aria-label')).toBe('On enter action');
|
||||
act(() => {
|
||||
Object.getOwnPropertyDescriptor(HTMLSelectElement.prototype, 'value')!.set!.call(select, 'action_b');
|
||||
select.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
});
|
||||
expect(onChange).toHaveBeenCalledWith([
|
||||
{ event: 'onLoad', actionId: 'action_a' },
|
||||
{ event: 'onEnter', actionId: 'action_b' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@ -24,7 +24,7 @@ export default function PageEventEditor({ events, actions, onChange }: Props): R
|
||||
{actions.map((action) => <option key={action.id} value={action.id}>{action.name} ({action.id})</option>)}
|
||||
</select>
|
||||
<label className={styles.pageEventLabel} htmlFor="page-onenter-action">On enter</label>
|
||||
<select id="page-onenter-action" className={styles.pageEventSelect} value={enterActionId} onChange={event => setEnter(event.target.value)}>
|
||||
<select id="page-onenter-action" aria-label="On enter action" className={styles.pageEventSelect} value={enterActionId} onChange={event => setEnter(event.target.value)}>
|
||||
<option value="">No action</option>
|
||||
{actions.map(action => <option key={action.id} value={action.id}>{action.name} ({action.id})</option>)}
|
||||
</select>
|
||||
|
||||
74
frontend/src/components/VisualEditor/PageManager.test.tsx
Normal file
74
frontend/src/components/VisualEditor/PageManager.test.tsx
Normal file
@ -0,0 +1,74 @@
|
||||
import React, { act } from 'react';
|
||||
import { createRoot, type Root } from 'react-dom/client';
|
||||
import PageManager from './PageManager';
|
||||
import { useProject } from '../../context/ProjectContext';
|
||||
|
||||
jest.mock('../../context/ProjectContext', () => ({ useProject: jest.fn() }));
|
||||
|
||||
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
const mockedUseProject = useProject as jest.MockedFunction<typeof useProject>;
|
||||
|
||||
describe('Visual Editor page command ribbon', () => {
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
const commands = {
|
||||
setActivePage: jest.fn(), addPage: jest.fn(), duplicatePage: jest.fn(),
|
||||
updatePage: jest.fn(), movePage: jest.fn(), deletePage: jest.fn(), setDefaultPage: jest.fn(),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
mockedUseProject.mockReturnValue({
|
||||
doc: {
|
||||
schemaVersion: '0.1.0',
|
||||
project: {
|
||||
id: 'project', name: 'Ribbon test', actions: [], bindings: [], variables: {},
|
||||
settings: { defaultPageId: 'home' },
|
||||
pages: [
|
||||
{ id: 'home', name: 'Home', slug: 'home', order: 0, showInNavigation: true, components: [] },
|
||||
{ id: 'details', name: 'Details', slug: 'details', order: 1, showInNavigation: true, components: [] },
|
||||
],
|
||||
},
|
||||
},
|
||||
activePageId: 'home',
|
||||
...commands,
|
||||
} as unknown as ReturnType<typeof useProject>);
|
||||
});
|
||||
|
||||
afterEach(() => { act(() => root.unmount()); container.remove(); });
|
||||
|
||||
test('presents clearly named command groups and page state', () => {
|
||||
act(() => root.render(<PageManager events={[]} actions={[]} onEventsChange={jest.fn()} />));
|
||||
const ribbon = container.querySelector('[aria-label="Visual Editor command ribbon"]');
|
||||
expect(ribbon).not.toBeNull();
|
||||
expect(ribbon?.textContent).toContain('Pages');
|
||||
expect(ribbon?.textContent).toContain('Page settings');
|
||||
expect(ribbon?.textContent).toContain('Lifecycle');
|
||||
expect(ribbon?.textContent).toContain('Page actions');
|
||||
expect(container.querySelector<HTMLSelectElement>('[aria-label="Active page"]')?.value).toBe('home');
|
||||
expect(Array.from(container.querySelectorAll('button')).find(button => button.textContent?.includes('Default page'))?.disabled).toBe(true);
|
||||
expect(container.querySelector<HTMLButtonElement>('[aria-label="Move page left"]')?.disabled).toBe(true);
|
||||
expect(container.querySelector<HTMLButtonElement>('[aria-label="Move page right"]')?.disabled).toBe(false);
|
||||
});
|
||||
|
||||
test('wires page commands and fields to existing canonical operations', () => {
|
||||
act(() => root.render(<PageManager events={[]} actions={[]} onEventsChange={jest.fn()} />));
|
||||
const buttons = Array.from(container.querySelectorAll('button'));
|
||||
act(() => buttons.find(button => button.textContent?.includes('Add page'))?.click());
|
||||
act(() => buttons.find(button => button.textContent?.includes('Duplicate'))?.click());
|
||||
act(() => container.querySelector<HTMLButtonElement>('[aria-label="Move page right"]')?.click());
|
||||
expect(commands.addPage).toHaveBeenCalledTimes(1);
|
||||
expect(commands.duplicatePage).toHaveBeenCalledWith('home');
|
||||
expect(commands.movePage).toHaveBeenCalledWith('home', 1);
|
||||
|
||||
const name = container.querySelector<HTMLInputElement>('[aria-label="Page name"]')!;
|
||||
act(() => {
|
||||
Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')!.set!.call(name, 'Start');
|
||||
name.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
});
|
||||
expect(commands.updatePage).toHaveBeenCalledWith('home', { name: 'Start' });
|
||||
});
|
||||
});
|
||||
@ -1,8 +1,83 @@
|
||||
import React from 'react';
|
||||
import { useProject } from '../../context/ProjectContext';
|
||||
import type { ComponentEvent, RestAction } from '../../types/project';
|
||||
import PageEventEditor from './PageEventEditor';
|
||||
import styles from './VisualEditor.module.css';
|
||||
|
||||
export default function PageManager():React.ReactElement{
|
||||
const{doc,activePageId,setActivePage,addPage,duplicatePage,updatePage,movePage,deletePage,setDefaultPage}=useProject();
|
||||
const pages=[...doc.project.pages].sort((a,b)=>(a.order??0)-(b.order??0));
|
||||
return <section aria-label="Project pages" style={{padding:'10px 12px',borderBottom:'1px solid #d0d7de',background:'#f6f8fa'}}><div style={{display:'flex',gap:8,alignItems:'center',flexWrap:'wrap'}}><strong>Pages</strong><select aria-label="Active page" value={activePageId} onChange={e=>setActivePage(e.target.value)}>{pages.map(page=><option key={page.id} value={page.id}>{page.name}{doc.project.settings.defaultPageId===page.id||(!doc.project.settings.defaultPageId&&pages[0].id===page.id)?' (default)':''}</option>)}</select><button type="button" onClick={addPage}>+ Add page</button><button type="button" onClick={()=>duplicatePage(activePageId)}>Duplicate</button><button type="button" disabled={pages.findIndex(p=>p.id===activePageId)===0} onClick={()=>movePage(activePageId,-1)}>Move left</button><button type="button" disabled={pages.findIndex(p=>p.id===activePageId)===pages.length-1} onClick={()=>movePage(activePageId,1)}>Move right</button><button type="button" onClick={()=>setDefaultPage(activePageId)}>Set default</button><button type="button" disabled={pages.length===1} onClick={()=>{const page=pages.find(p=>p.id===activePageId);if(page&&window.confirm(`Delete page "${page.name}"? Page components and page-scoped variables will be removed.`))deletePage(activePageId)}}>Delete page</button></div>{pages.filter(page=>page.id===activePageId).map(page=><div key={page.id} style={{display:'flex',gap:10,alignItems:'center',marginTop:10,flexWrap:'wrap'}}><label>Name <input value={page.name} onChange={e=>updatePage(page.id,{name:e.target.value})}/></label><label>URL slug <input pattern="[a-z0-9]+(?:-[a-z0-9]+)*" value={page.slug??page.name.toLowerCase().replace(/[^a-z0-9]+/g,'-').replace(/^-|-$/g,'')} onChange={e=>updatePage(page.id,{slug:e.target.value})}/></label><label><input type="checkbox" checked={page.showInNavigation!==false} onChange={e=>updatePage(page.id,{showInNavigation:e.target.checked})}/> Show in navigation</label><code>{page.id}</code></div>)}</section>;
|
||||
type Props = {
|
||||
events: ComponentEvent[] | undefined;
|
||||
actions: RestAction[];
|
||||
onEventsChange: (events: ComponentEvent[]) => void;
|
||||
};
|
||||
|
||||
function CommandIcon({ children }: { children: React.ReactNode }): React.ReactElement {
|
||||
return <span className={styles.commandIcon} aria-hidden="true">{children}</span>;
|
||||
}
|
||||
|
||||
export default function PageManager({ events, actions, onEventsChange }: Props): React.ReactElement {
|
||||
const {
|
||||
doc, activePageId, setActivePage, addPage, duplicatePage,
|
||||
updatePage, movePage, deletePage, setDefaultPage,
|
||||
} = useProject();
|
||||
const pages = [...doc.project.pages].sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
|
||||
const activeIndex = pages.findIndex((page) => page.id === activePageId);
|
||||
const activePage = pages[activeIndex] ?? pages[0];
|
||||
const defaultPageId = doc.project.settings.defaultPageId ?? pages[0]?.id;
|
||||
const isDefault = activePage?.id === defaultPageId;
|
||||
|
||||
if (!activePage) return <></>;
|
||||
|
||||
const confirmDelete = () => {
|
||||
if (window.confirm(`Delete page "${activePage.name}"? Page components and page-scoped variables will be removed.`)) {
|
||||
deletePage(activePage.id);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<section className={styles.commandRibbon} aria-label="Visual Editor command ribbon">
|
||||
<div className={styles.ribbonGroups}>
|
||||
<div className={[styles.ribbonGroup, styles.pagesGroup].join(' ')} role="group" aria-labelledby="pages-group-label">
|
||||
<div className={styles.ribbonGroupBody}>
|
||||
<label className={styles.pagePickerLabel}>
|
||||
<span className={styles.srOnly}>Active page</span>
|
||||
<select className={styles.pagePicker} aria-label="Active page" value={activePageId} onChange={(event) => setActivePage(event.target.value)}>
|
||||
{pages.map((page) => <option key={page.id} value={page.id}>{page.name}{defaultPageId === page.id ? ' (default)' : ''}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<button type="button" className={styles.commandButton} onClick={addPage}><CommandIcon>+</CommandIcon><span>Add page</span></button>
|
||||
<button type="button" className={styles.commandButton} onClick={() => duplicatePage(activePageId)}><CommandIcon>⧉</CommandIcon><span>Duplicate</span></button>
|
||||
<div className={styles.commandPair} aria-label="Reorder page">
|
||||
<button type="button" className={styles.iconButton} disabled={activeIndex === 0} onClick={() => movePage(activePageId, -1)} title={activeIndex === 0 ? 'This page is already first' : 'Move page left'} aria-label="Move page left">←</button>
|
||||
<button type="button" className={styles.iconButton} disabled={activeIndex === pages.length - 1} onClick={() => movePage(activePageId, 1)} title={activeIndex === pages.length - 1 ? 'This page is already last' : 'Move page right'} aria-label="Move page right">→</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.ribbonGroupLabel} id="pages-group-label">Pages</div>
|
||||
</div>
|
||||
|
||||
<div className={[styles.ribbonGroup, styles.settingsGroup].join(' ')} role="group" aria-labelledby="settings-group-label">
|
||||
<div className={styles.ribbonGroupBody}>
|
||||
<label className={styles.ribbonField}><span>Name</span><input aria-label="Page name" value={activePage.name} onChange={(event) => updatePage(activePage.id, { name: event.target.value })} /></label>
|
||||
<label className={styles.ribbonField}><span>URL slug</span><input aria-label="Page URL slug" pattern="[a-z0-9]+(?:-[a-z0-9]+)*" value={activePage.slug ?? activePage.name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '')} onChange={(event) => updatePage(activePage.id, { slug: event.target.value })} /></label>
|
||||
<div className={styles.pageSettingActions}>
|
||||
<button type="button" className={[styles.commandButton, isDefault ? styles.commandButtonSelected : ''].join(' ')} disabled={isDefault} onClick={() => setDefaultPage(activePageId)} title={isDefault ? 'This is the default page' : 'Open this page when the application starts'}><CommandIcon>★</CommandIcon><span>{isDefault ? 'Default page' : 'Set default'}</span></button>
|
||||
<label className={styles.toggleControl}><input type="checkbox" checked={activePage.showInNavigation !== false} onChange={(event) => updatePage(activePage.id, { showInNavigation: event.target.checked })} /><span className={styles.toggleTrack} aria-hidden="true" /><span>In navigation</span></label>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.ribbonGroupLabel} id="settings-group-label">Page settings <code>{activePage.id}</code></div>
|
||||
</div>
|
||||
|
||||
<div className={[styles.ribbonGroup, styles.lifecycleGroup].join(' ')} role="group" aria-labelledby="lifecycle-group-label">
|
||||
<div className={styles.ribbonGroupBody}><PageEventEditor events={events} actions={actions} onChange={onEventsChange} /></div>
|
||||
<div className={styles.ribbonGroupLabel} id="lifecycle-group-label">Lifecycle</div>
|
||||
</div>
|
||||
|
||||
<div className={[styles.ribbonGroup, styles.dangerGroup].join(' ')} role="group" aria-labelledby="danger-group-label">
|
||||
<div className={styles.ribbonGroupBody}>
|
||||
<button type="button" className={styles.dangerCommand} disabled={pages.length === 1} onClick={confirmDelete} title={pages.length === 1 ? 'A project must contain at least one page' : `Delete ${activePage.name}`}><CommandIcon>×</CommandIcon><span>Delete page</span></button>
|
||||
</div>
|
||||
<div className={styles.ribbonGroupLabel} id="danger-group-label">Page actions</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@ -22,13 +22,286 @@
|
||||
color: #8b949e;
|
||||
}
|
||||
|
||||
/* ── Visual Editor command ribbon ────────────────────────────────── */
|
||||
|
||||
.commandRibbon {
|
||||
--ribbon-border: #cbd5e1;
|
||||
--ribbon-control-border: #b8c3d1;
|
||||
--ribbon-focus: #2563eb;
|
||||
flex-shrink: 0;
|
||||
padding: 8px 10px 7px;
|
||||
overflow-x: hidden;
|
||||
background: linear-gradient(180deg, #f8fafc 0%, #eef2f7 100%);
|
||||
border-bottom: 1px solid var(--ribbon-border);
|
||||
box-shadow: 0 2px 5px rgba(15, 23, 42, 0.07);
|
||||
}
|
||||
|
||||
.ribbonGroups {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: stretch;
|
||||
gap: 0;
|
||||
row-gap: 8px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.ribbonGroup {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
min-height: 76px;
|
||||
padding: 0 12px;
|
||||
border-right: 1px solid var(--ribbon-border);
|
||||
}
|
||||
|
||||
.ribbonGroup:first-child { padding-left: 2px; }
|
||||
.ribbonGroup:last-child { border-right: 0; }
|
||||
|
||||
.ribbonGroupBody {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.ribbonGroupLabel {
|
||||
min-height: 17px;
|
||||
padding-top: 4px;
|
||||
color: #64748b;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.055em;
|
||||
line-height: 1;
|
||||
text-align: center;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.ribbonGroupLabel code {
|
||||
margin-left: 5px;
|
||||
color: #94a3b8;
|
||||
font-size: 9px;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0;
|
||||
text-transform: none;
|
||||
}
|
||||
|
||||
.pagesGroup { min-width: 420px; }
|
||||
.settingsGroup { min-width: 555px; }
|
||||
.lifecycleGroup { min-width: 360px; }
|
||||
.dangerGroup { min-width: 112px; }
|
||||
|
||||
.pagePicker {
|
||||
width: 158px;
|
||||
min-height: 34px;
|
||||
padding: 6px 30px 6px 10px;
|
||||
border: 1px solid #8da2ba;
|
||||
border-radius: 6px;
|
||||
background: #ffffff;
|
||||
color: #172033;
|
||||
font-size: 12px;
|
||||
font-weight: 650;
|
||||
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.08);
|
||||
}
|
||||
|
||||
.commandButton,
|
||||
.iconButton,
|
||||
.dangerCommand {
|
||||
border: 1px solid var(--ribbon-control-border);
|
||||
border-radius: 6px;
|
||||
background: linear-gradient(180deg, #ffffff 0%, #f1f5f9 100%);
|
||||
color: #243247;
|
||||
font: inherit;
|
||||
font-size: 11px;
|
||||
font-weight: 650;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.08);
|
||||
transition: border-color 120ms ease, background 120ms ease, box-shadow 120ms ease, transform 120ms ease;
|
||||
}
|
||||
|
||||
.commandButton {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 5px;
|
||||
min-height: 34px;
|
||||
padding: 6px 9px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.commandIcon {
|
||||
color: #47627f;
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.commandButton:hover:not(:disabled),
|
||||
.iconButton:hover:not(:disabled) {
|
||||
border-color: #6b8db3;
|
||||
background: #ffffff;
|
||||
box-shadow: 0 2px 5px rgba(37, 99, 235, 0.12);
|
||||
}
|
||||
|
||||
.commandButton:active:not(:disabled),
|
||||
.iconButton:active:not(:disabled),
|
||||
.dangerCommand:active:not(:disabled) { transform: translateY(1px); }
|
||||
|
||||
.commandButton:disabled,
|
||||
.iconButton:disabled,
|
||||
.dangerCommand:disabled {
|
||||
border-color: #d7dee8;
|
||||
background: #eef2f6;
|
||||
color: #99a5b5;
|
||||
cursor: not-allowed;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.commandButtonSelected,
|
||||
.commandButtonSelected:disabled {
|
||||
border-color: #93c5fd;
|
||||
background: #dbeafe;
|
||||
color: #1d4ed8;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.commandButtonSelected .commandIcon { color: #d97706; }
|
||||
|
||||
.commandPair {
|
||||
display: inline-flex;
|
||||
overflow: hidden;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.iconButton {
|
||||
width: 31px;
|
||||
min-height: 34px;
|
||||
border-radius: 0;
|
||||
font-size: 16px;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.iconButton + .iconButton { margin-left: -1px; }
|
||||
.iconButton:first-child { border-radius: 6px 0 0 6px; }
|
||||
.iconButton:last-child { border-radius: 0 6px 6px 0; }
|
||||
|
||||
.ribbonField {
|
||||
display: grid;
|
||||
gap: 3px;
|
||||
color: #64748b;
|
||||
font-size: 9px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.ribbonField input,
|
||||
.pageEventSelect {
|
||||
box-sizing: border-box;
|
||||
height: 32px;
|
||||
padding: 5px 8px;
|
||||
border: 1px solid var(--ribbon-control-border);
|
||||
border-radius: 5px;
|
||||
background: #ffffff;
|
||||
color: #172033;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
outline: none;
|
||||
box-shadow: inset 0 1px 1px rgba(15, 23, 42, 0.035);
|
||||
}
|
||||
|
||||
.ribbonField input { width: 145px; }
|
||||
|
||||
.pageSettingActions {
|
||||
display: grid;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.pageSettingActions .commandButton { min-height: 28px; padding-block: 3px; }
|
||||
|
||||
.toggleControl {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
color: #475569;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.toggleControl input {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
overflow: hidden;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.toggleTrack {
|
||||
position: relative;
|
||||
width: 26px;
|
||||
height: 14px;
|
||||
border-radius: 999px;
|
||||
background: #94a3b8;
|
||||
transition: background 120ms ease;
|
||||
}
|
||||
|
||||
.toggleTrack::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
left: 2px;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
background: #ffffff;
|
||||
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.35);
|
||||
transition: transform 120ms ease;
|
||||
}
|
||||
|
||||
.toggleControl input:checked + .toggleTrack { background: #2563eb; }
|
||||
.toggleControl input:checked + .toggleTrack::after { transform: translateX(12px); }
|
||||
|
||||
.dangerCommand {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-height: 34px;
|
||||
padding: 6px 10px;
|
||||
border-color: #fecaca;
|
||||
background: #fff7f7;
|
||||
color: #b4232c;
|
||||
}
|
||||
|
||||
.dangerCommand .commandIcon { color: #cf222e; }
|
||||
.dangerCommand:hover:not(:disabled) { border-color: #e5484d; background: #fff0f0; }
|
||||
|
||||
.commandRibbon :is(button, select, input):focus-visible,
|
||||
.toggleControl input:focus-visible + .toggleTrack {
|
||||
outline: 2px solid var(--ribbon-focus);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.srOnly {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
/* ── Editor info bar (below project toolbar) ─────────────────────── */
|
||||
|
||||
.editorBar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
height: 36px;
|
||||
height: 30px;
|
||||
padding: 0 16px;
|
||||
background: #f7f8fa;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
@ -56,9 +329,10 @@
|
||||
}
|
||||
|
||||
.pageEventEditor {
|
||||
display: flex;
|
||||
display: grid;
|
||||
grid-template-columns: auto 142px;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
gap: 5px 7px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
@ -79,14 +353,25 @@
|
||||
}
|
||||
|
||||
.pageEventSelect {
|
||||
width: 190px;
|
||||
width: 142px;
|
||||
min-width: 0;
|
||||
padding: 2px 6px;
|
||||
border: 1px solid #d0d7de;
|
||||
border-radius: 4px;
|
||||
background: #ffffff;
|
||||
color: #1f2328;
|
||||
font-size: 11px;
|
||||
padding: 5px 7px;
|
||||
}
|
||||
|
||||
@media (max-width: 1180px) {
|
||||
.ribbonGroup { padding-inline: 9px; }
|
||||
.settingsGroup { min-width: 520px; }
|
||||
}
|
||||
|
||||
@media (max-width: 780px) {
|
||||
.commandRibbon { padding-inline: 7px; }
|
||||
.ribbonGroupLabel code { display: none; }
|
||||
.commandButton span:last-child,
|
||||
.dangerCommand span:last-child { display: none; }
|
||||
.commandButton,
|
||||
.dangerCommand { width: 34px; padding-inline: 5px; }
|
||||
.pagesGroup { min-width: 320px; }
|
||||
.settingsGroup { min-width: 500px; }
|
||||
}
|
||||
|
||||
/* ── Body: palette | canvas | info ───────────────────────────────── */
|
||||
|
||||
@ -6,7 +6,6 @@ import { useProject } from '../../context/ProjectContext';
|
||||
import styles from './VisualEditor.module.css';
|
||||
import type { ComponentStyle, ComponentType, DropdownOption, TableColumn, TableRow } from '../../types/project';
|
||||
import ButtonEventEditor from './ButtonEventEditor';
|
||||
import PageEventEditor from './PageEventEditor';
|
||||
import ComponentDeleteDialog from './ComponentDeleteDialog';
|
||||
import { findComponentReferences, type PendingComponentDeletion } from './componentDeletionUtils';
|
||||
import ValidationSummary from '../ValidationSummary';
|
||||
@ -492,19 +491,14 @@ function VisualEditor(): React.ReactElement {
|
||||
return (
|
||||
<div className={styles.editor}>
|
||||
{/* ── Project toolbar (New / Save / Load) ───────────────────── */}
|
||||
<ProjectToolbar />
|
||||
<PageManager />
|
||||
<ProjectToolbar variant="ribbon" />
|
||||
<PageManager events={activePage.events} actions={doc.project.actions} onEventsChange={updatePageEvents} />
|
||||
<ValidationSummary />
|
||||
|
||||
{/* ── Editor toolbar (page info) ────────────────────────────── */}
|
||||
<div className={styles.editorBar}>
|
||||
<span className={styles.projectName}>{doc.project.name}</span>
|
||||
<span className={styles.pageName}>{activePage.name}</span>
|
||||
<PageEventEditor
|
||||
events={activePage.events}
|
||||
actions={doc.project.actions}
|
||||
onChange={updatePageEvents}
|
||||
/>
|
||||
<span className={styles.componentCount}>
|
||||
{activePage.components.length} component
|
||||
{activePage.components.length !== 1 ? 's' : ''}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user