Lexical: Added form complex/tab ui support

This commit is contained in:
Dan Brown 2024-07-28 12:48:58 +01:00
parent c8f6b7e0d6
commit ce8c9dd079
No known key found for this signature in database
GPG Key ID: 46D9F943C24A2EF9
5 changed files with 209 additions and 31 deletions

View File

@ -2,8 +2,7 @@
## In progress
- Update forms to allow panels (Media)
- Will be used for table forms also.
//
## Main Todo
@ -23,6 +22,7 @@
- Image gallery integration for form
- Drawing gallery integration
- Support media src conversions (https://github.com/tinymce/tinymce/blob/release/6.6/modules/tinymce/src/plugins/media/main/ts/core/UrlPatterns.ts)
- Media resize support (like images)
## Bugs

View File

@ -1,4 +1,4 @@
import {EditorFormDefinition, EditorSelectFormFieldDefinition} from "../framework/forms";
import {EditorFormDefinition, EditorFormTabs, EditorSelectFormFieldDefinition} from "../framework/forms";
import {EditorUiContext} from "../framework/core";
import {$createLinkNode} from "@lexical/link";
import {$createTextNode, $getSelection, LexicalNode} from "lexical";
@ -133,25 +133,40 @@ export const media: EditorFormDefinition = {
},
fields: [
{
label: 'Source',
name: 'src',
type: 'text',
},
{
label: 'Width',
name: 'width',
type: 'text',
},
{
label: 'Height',
name: 'height',
type: 'text',
},
// TODO - Tabbed interface to separate this option
{
label: 'Paste your embed code below:',
name: 'embed',
type: 'textarea',
build() {
return new EditorFormTabs([
{
label: 'General',
contents: [
{
label: 'Source',
name: 'src',
type: 'text',
},
{
label: 'Width',
name: 'width',
type: 'text',
},
{
label: 'Height',
name: 'height',
type: 'text',
},
],
},
{
label: 'Embed',
contents: [
{
label: 'Paste your embed code below:',
name: 'embed',
type: 'textarea',
},
],
}
])
}
},
],
};

View File

@ -18,6 +18,14 @@ export type EditorUiContext = {
options: Record<string, any>; // General user options which may be used by sub elements
};
export interface EditorUiBuilderDefinition {
build: () => EditorUiElement;
}
export function isUiBuilderDefinition(object: any): object is EditorUiBuilderDefinition {
return 'build' in object;
}
export abstract class EditorUiElement {
protected dom: HTMLElement|null = null;
private context: EditorUiContext|null = null;

View File

@ -1,5 +1,12 @@
import {EditorUiContext, EditorUiElement, EditorContainerUiElement} from "./core";
import {
EditorUiContext,
EditorUiElement,
EditorContainerUiElement,
EditorUiBuilderDefinition,
isUiBuilderDefinition
} from "./core";
import {el} from "../../helpers";
import {uniqueId} from "../../../services/util";
export interface EditorFormFieldDefinition {
label: string;
@ -12,10 +19,15 @@ export interface EditorSelectFormFieldDefinition extends EditorFormFieldDefiniti
valuesByLabel: Record<string, string>
}
interface EditorFormTabDefinition {
label: string;
contents: EditorFormFieldDefinition[];
}
export interface EditorFormDefinition {
submitText: string;
action: (formData: FormData, context: EditorUiContext) => Promise<boolean>;
fields: EditorFormFieldDefinition[];
fields: (EditorFormFieldDefinition|EditorUiBuilderDefinition)[];
}
export class EditorFormField extends EditorUiElement {
@ -62,7 +74,14 @@ export class EditorForm extends EditorContainerUiElement {
protected onCancel: null|(() => void) = null;
constructor(definition: EditorFormDefinition) {
super(definition.fields.map(fieldDefinition => new EditorFormField(fieldDefinition)));
let children: (EditorFormField|EditorUiElement)[] = definition.fields.map(fieldDefinition => {
if (isUiBuilderDefinition(fieldDefinition)) {
return fieldDefinition.build();
}
return new EditorFormField(fieldDefinition)
});
super(children);
this.definition = definition;
}
@ -80,13 +99,23 @@ export class EditorForm extends EditorContainerUiElement {
}
protected getFieldByName(name: string): EditorFormField|null {
for (const child of this.children as EditorFormField[]) {
if (child.getName() === name) {
return child;
}
}
return null;
const search = (children: EditorUiElement[]): EditorFormField|null => {
for (const child of children) {
if (child instanceof EditorFormField && child.getName() === name) {
return child;
} else if (child instanceof EditorContainerUiElement) {
const matchingChild = search(child.getChildren());
if (matchingChild) {
return matchingChild;
}
}
}
return null;
};
return search(this.getChildren());
}
protected buildDOM(): HTMLElement {
@ -114,3 +143,90 @@ export class EditorForm extends EditorContainerUiElement {
return form;
}
}
export class EditorFormTab extends EditorContainerUiElement {
protected definition: EditorFormTabDefinition;
protected fields: EditorFormField[];
protected id: string;
constructor(definition: EditorFormTabDefinition) {
const fields = definition.contents.map(fieldDef => new EditorFormField(fieldDef));
super(fields);
this.definition = definition;
this.fields = fields;
this.id = uniqueId();
}
public getLabel(): string {
return this.getContext().translate(this.definition.label);
}
public getId(): string {
return this.id;
}
protected buildDOM(): HTMLElement {
return el(
'div',
{
class: 'editor-form-tab-content',
role: 'tabpanel',
id: `editor-tabpanel-${this.id}`,
'aria-labelledby': `editor-tab-${this.id}`,
},
this.fields.map(f => f.getDOMElement())
);
}
}
export class EditorFormTabs extends EditorContainerUiElement {
protected definitions: EditorFormTabDefinition[] = [];
protected tabs: EditorFormTab[] = [];
constructor(definitions: EditorFormTabDefinition[]) {
const tabs: EditorFormTab[] = definitions.map(d => new EditorFormTab(d));
super(tabs);
this.definitions = definitions;
this.tabs = tabs;
}
protected buildDOM(): HTMLElement {
const controls: HTMLElement[] = [];
const contents: HTMLElement[] = [];
const selectTab = (tabIndex: number) => {
for (let i = 0; i < controls.length; i++) {
controls[i].setAttribute('aria-selected', (i === tabIndex) ? 'true' : 'false');
}
for (let i = 0; i < contents.length; i++) {
contents[i].hidden = !(i === tabIndex);
}
};
for (const tab of this.tabs) {
const button = el('button', {
class: 'editor-form-tab-control',
type: 'button',
role: 'tab',
id: `editor-tab-${tab.getId()}`,
'aria-controls': `editor-tabpanel-${tab.getId()}`
}, [tab.getLabel()]);
contents.push(tab.getDOMElement());
controls.push(button);
button.addEventListener('click', event => {
selectTab(controls.indexOf(button));
});
}
selectTab(0);
return el('div', {class: 'editor-form-tab-container'}, [
el('div', {class: 'editor-form-tab-controls'}, controls),
el('div', {class: 'editor-form-tab-contents'}, contents),
]);
}
}

View File

@ -398,6 +398,45 @@ textarea.editor-form-field-input {
box-shadow: inset 0 0 2px rgba(0, 0, 0, 0.1);
}
}
.editor-form-tab-container {
display: flex;
flex-direction: row;
gap: 2rem;
}
.editor-form-tab-controls {
display: flex;
flex-direction: column;
align-items: stretch;
gap: .25rem;
}
.editor-form-tab-control {
font-weight: bold;
font-size: 14px;
color: #444;
border-bottom: 2px solid transparent;
position: relative;
cursor: pointer;
padding: .25rem .5rem;
text-align: start;
&[aria-selected="true"] {
border-color: var(--editor-color-primary);
color: var(--editor-color-primary);
}
&[aria-selected="true"]:after, &:hover:after {
background-color: var(--editor-color-primary);
opacity: .15;
content: '';
display: block;
position: absolute;
left: 0;
top: 0;
width: 100%;
height: 100%;
}
}
.editor-form-tab-contents {
width: 360px;
}
// Editor theme styles
.editor-theme-bold {