From 598164c94053aebd838804161491c973b4a68268 Mon Sep 17 00:00:00 2001 From: Kevin Chappell Date: Sat, 26 Oct 2024 23:01:30 -0700 Subject: [PATCH] feat: update component styles fix bug with dynamic column widths, performance improvements --- biome.json | 91 ++++--- src/lib/icons/formeo-sprite.svg | 2 +- src/lib/icons/icon-component-corner.svg | 11 + src/lib/icons/icon-email.svg | 4 + src/lib/icons/icon-handle-column.svg | 56 +++++ src/lib/icons/icon-handle-field.svg | 48 ++++ src/lib/icons/icon-handle-row.svg | 56 +++++ src/lib/js/common/dom.js | 227 +++++++----------- src/lib/js/common/helpers.mjs | 15 +- src/lib/js/common/utils/index.mjs | 2 - src/lib/js/common/utils/object.mjs | 117 +++------ src/lib/js/components/columns/column.js | 53 +--- .../js/components/columns/event-handlers.js | 225 +++++++++++++++++ src/lib/js/components/columns/events.js | 130 ---------- src/lib/js/components/columns/index.js | 2 +- src/lib/js/components/component.js | 131 +++++++--- src/lib/js/components/data.js | 11 +- src/lib/js/components/fields/field.js | 9 +- src/lib/js/components/fields/index.js | 2 +- src/lib/js/components/index.js | 22 +- src/lib/js/components/rows/index.js | 2 +- src/lib/js/components/rows/row.js | 147 +++++++----- src/lib/js/components/stages/stage.js | 4 +- src/lib/js/constants.js | 17 +- src/lib/js/renderer.js | 20 +- src/lib/sass/base/_animation.scss | 15 +- src/lib/sass/base/_colors.scss | 10 +- src/lib/sass/base/_icons.scss | 9 + src/lib/sass/base/_mixins.scss | 37 ++- src/lib/sass/base/_variables.scss | 7 +- src/lib/sass/base/rtl/_group-actions.scss | 72 +++--- src/lib/sass/base/rtl/_row.scss | 13 +- src/lib/sass/components/_column.scss | 184 ++++++++++---- src/lib/sass/components/_controls.scss | 4 + src/lib/sass/components/_field.scss | 98 +++++--- src/lib/sass/components/_group-actions.scss | 196 +++------------ src/lib/sass/components/_row.scss | 138 ++++++----- src/lib/sass/components/_stage.scss | 34 ++- src/lib/sass/components/component.scss | 60 ++++- src/lib/sass/formeo.scss | 27 +-- tools/generate-sprite.js | 2 +- 41 files changed, 1340 insertions(+), 970 deletions(-) create mode 100644 src/lib/icons/icon-component-corner.svg create mode 100644 src/lib/icons/icon-email.svg create mode 100644 src/lib/icons/icon-handle-column.svg create mode 100644 src/lib/icons/icon-handle-field.svg create mode 100644 src/lib/icons/icon-handle-row.svg create mode 100644 src/lib/js/components/columns/event-handlers.js delete mode 100644 src/lib/js/components/columns/events.js diff --git a/biome.json b/biome.json index e0a44483..7138fd9a 100644 --- a/biome.json +++ b/biome.json @@ -1,38 +1,57 @@ { - "$schema": "https://biomejs.dev/schemas/1.7.3/schema.json", - "organizeImports": { - "enabled": true - }, - "vcs": { - "clientKind": "git", - "enabled": true, - "useIgnoreFile": true - }, - "linter": { - "enabled": true, - "rules": { - "recommended": false - } - }, - "formatter": { - "enabled": true, - "formatWithErrors": false, - "ignore": [], - "indentStyle": "space", - "indentWidth": 2, - "lineEnding": "lf", - "lineWidth": 120 - }, - "javascript": { - "formatter": { - "quoteStyle": "single", - "semicolons": "asNeeded", - "arrowParentheses": "asNeeded" - } - }, - "json": { - "formatter": { - "indentStyle": "space" - } - } + "$schema": "./node_modules/@biomejs/biome/configuration_schema.json", + "files": { + "ignore": ["node_modules/**", "dist/**"] + }, + "organizeImports": { + "enabled": true + }, + "vcs": { + "clientKind": "git", + "enabled": true, + "useIgnoreFile": true + }, + "linter": { + "enabled": true, + "rules": { + "recommended": true, + "correctness": { + "noUndeclaredVariables": "error", + "noUnusedVariables": "error", + "useExhaustiveDependencies": "error" + }, + "suspicious": { + "noImplicitAnyLet": "error", + "noConsoleLog": "warn" + }, + "complexity": { + "useLiteralKeys": "error", + "noUselessFragments": "error" + }, + "style": { + "recommended": true + } + } + }, + "formatter": { + "enabled": true, + "formatWithErrors": false, + "ignore": [], + "indentStyle": "space", + "indentWidth": 2, + "lineEnding": "lf", + "lineWidth": 120 + }, + "javascript": { + "formatter": { + "quoteStyle": "single", + "semicolons": "asNeeded", + "arrowParentheses": "asNeeded" + } + }, + "json": { + "formatter": { + "indentStyle": "space" + } + } } diff --git a/src/lib/icons/formeo-sprite.svg b/src/lib/icons/formeo-sprite.svg index 0f35fa8a..4cc512e2 100644 --- a/src/lib/icons/formeo-sprite.svg +++ b/src/lib/icons/formeo-sprite.svg @@ -1 +1 @@ -image/svg+xmlimage/svg+xmlimage/svg+xmlimage/svg+xmlimage/svg+xmlimage/svg+xmlimage/svg+xmlimage/svg+xml \ No newline at end of file +image/svg+xmlimage/svg+xmlimage/svg+xmlimage/svg+xmlimage/svg+xmlimage/svg+xmlimage/svg+xmlimage/svg+xmlimage/svg+xmlimage/svg+xmlimage/svg+xml \ No newline at end of file diff --git a/src/lib/icons/icon-component-corner.svg b/src/lib/icons/icon-component-corner.svg new file mode 100644 index 00000000..568bbc84 --- /dev/null +++ b/src/lib/icons/icon-component-corner.svg @@ -0,0 +1,11 @@ + + + + + + + + diff --git a/src/lib/icons/icon-email.svg b/src/lib/icons/icon-email.svg new file mode 100644 index 00000000..aa3c9e01 --- /dev/null +++ b/src/lib/icons/icon-email.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/src/lib/icons/icon-handle-column.svg b/src/lib/icons/icon-handle-column.svg new file mode 100644 index 00000000..9e395976 --- /dev/null +++ b/src/lib/icons/icon-handle-column.svg @@ -0,0 +1,56 @@ + +image/svg+xml diff --git a/src/lib/icons/icon-handle-field.svg b/src/lib/icons/icon-handle-field.svg new file mode 100644 index 00000000..11a1e598 --- /dev/null +++ b/src/lib/icons/icon-handle-field.svg @@ -0,0 +1,48 @@ + +image/svg+xml diff --git a/src/lib/icons/icon-handle-row.svg b/src/lib/icons/icon-handle-row.svg new file mode 100644 index 00000000..a59f5b64 --- /dev/null +++ b/src/lib/icons/icon-handle-row.svg @@ -0,0 +1,56 @@ + +image/svg+xml diff --git a/src/lib/js/common/dom.js b/src/lib/js/common/dom.js index 016b0081..adc9901c 100644 --- a/src/lib/js/common/dom.js +++ b/src/lib/js/common/dom.js @@ -1,9 +1,8 @@ -import h, { indexOfNode, forEach } from './helpers.mjs' +import h, { forEach } from './helpers.mjs' import i18n from 'mi18n' -import events from './events.js' import animate from './animation.js' -import Components, { Stages, Columns } from '../components/index.js' -import { uuid, clone, numToPercent, componentType, merge } from './utils/index.mjs' +import Components from '../components/index.js' +import { uuid, componentType, merge } from './utils/index.mjs' import { ROW_CLASSNAME, STAGE_CLASSNAME, @@ -11,9 +10,18 @@ import { FIELD_CLASSNAME, CONTROL_GROUP_CLASSNAME, CHILD_CLASSNAME_MAP, - bsColRegExp, + iconPrefix, } from '../constants.js' +const iconFontTemplates = { + glyphicons: icon => ``, + 'font-awesome': icon => { + const [style, name] = icon.split(' ') + return `` + }, + fontello: icon => `${icon}`, +} + /** * General purpose markup utilities and generator. */ @@ -258,6 +266,38 @@ class DOM { }) } + get icons() { + if (this.iconSymbols) { + return this.iconSymbols + } + + const iconSymbolNodes = document.querySelectorAll('#formeo-sprite svg symbol') + + const createSvgIconConfig = symbolId => ({ + tag: 'svg', + attrs: { + className: `svg-icon ${symbolId}`, + }, + children: [ + { + tag: 'use', + attrs: { + 'xmlns:xlink': 'http://www.w3.org/1999/xlink', + 'xlink:href': `#${symbolId}`, + }, + }, + ], + }) + + this.iconSymbols = Array.from(iconSymbolNodes).reduce((acc, symbol) => { + const name = symbol.id.replace(iconPrefix, '') + acc[name] = dom.create(createSvgIconConfig(symbol.id)) + return acc + }, {}) + + return this.iconSymbols + } + /** * Create and SVG or font icon. * Simple string concatenation instead of DOM.create because: @@ -265,32 +305,22 @@ class DOM { * - it forces the icon to be appended using innerHTML which helps svg render * @param {String} name - icon name * @return {String} icon markup - * @todo remove document.getElementById */ - icon(name = null) { + icon(name = null, classNames = []) { if (!name) { return } - const iconPrefix = 'f-i-' - const iconLink = document.getElementById(iconPrefix + name) - let icon - const iconFontTemplates = { - glyphicons: icon => ``, - 'font-awesome': icon => { - const [style, name] = icon.split(' ') - return `` - }, - fontello: icon => `${icon}`, - } - if (iconLink) { - icon = `` - } else if (dom.options.iconFont) { - icon = iconFontTemplates[dom.options.iconFont](name) - } else { - icon = name + const icon = this.icons[name] + + if (icon) { + const iconClone = icon.cloneNode(true) + iconClone.classList.add(...classNames) + + return iconClone.outerHTML } - return icon + + return iconFontTemplates[dom.options.iconFont]?.(name) || name } /** @@ -366,7 +396,7 @@ class DOM { forEach(collection, elem => { const txt = elem.textContent.toLowerCase() const contains = txt.indexOf(term.toLowerCase()) !== -1 - cb && cb(elem, contains) + cb?.(elem, contains) contains && elementsContainingText.push(elem) }) return elementsContainingText @@ -473,7 +503,8 @@ class DOM { }, button: option => { const { type, label, className, id } = option - return Object.assign({}, elem, { + return { + ...elem, attrs: { type, }, @@ -482,13 +513,13 @@ class DOM { options: undefined, children: label, action: elem.action, - }) + } }, checkbox: defaultInput, radio: defaultInput, } - return optionMarkup[fieldType] && optionMarkup[fieldType](option) + return optionMarkup[fieldType]?.(option) } const mappedOptions = options.map(optionMap) @@ -574,7 +605,7 @@ class DOM { const fieldLabel = { tag: 'label', attrs: { - for: elemId || (attrs && attrs.id), + for: elemId || attrs?.id, }, className: [], children: [labelText, required && this.requiredMark()], @@ -604,7 +635,7 @@ class DOM { return [ ['array', content => Array.isArray(content)], ['node', content => content instanceof window.Node || content instanceof window.HTMLElement], - ['component', () => content && content.dom], + ['component', () => content?.dom], [typeof content, () => true], ].find(typeCondition => typeCondition[1](content))[0] } @@ -656,62 +687,6 @@ class DOM { return elem } - /** - * Clones an element, it's data and - * it's nested elements and data - * @param {Object} elem element we are cloning - * @param {Object} parent - * @todo move to Component - * @return {Object} cloned element - */ - clone(elem, parent) { - // remove - const formData = {} - const _this = this - const { id, fType } = elem - const dataClone = clone(formData[fType].get(id)) - const newIndex = indexOfNode(elem) + 1 - let noParent = false - dataClone.id = uuid() - formData[fType].set(dataClone.id, dataClone) - if (!parent) { - parent = elem.parentElement - noParent = true - } - const cloneType = { - rows: () => { - dataClone.columns = [] - const stage = Stages.active - const newRow = stage.addRow(null, dataClone.id) - const columns = elem.getElementsByClassName(COLUMN_CLASSNAME) - - stage.insertBefore(newRow, stage.childNodes[newIndex]) - h.forEach(columns, column => _this.clone(column, newRow)) - // data.saveRowOrder() - return newRow - }, - columns: () => { - dataClone.fields = [] - const newColumn = _this.addColumn(parent, dataClone.id) - parent.insertBefore(newColumn, parent.childNodes[newIndex]) - const fields = elem.getElementsByClassName(FIELD_CLASSNAME) - - if (noParent) { - dom.columnWidths(parent) - } - h.forEach(fields, field => _this.clone(field, newColumn)) - return newColumn - }, - fields: () => { - const newField = _this.addField(parent, dataClone.id) - parent.insertBefore(newField, parent.childNodes[newIndex]) - return newField - }, - } - - return cloneType[fType]() - } - /** * Remove elements without f children * @param {Object} element DOM element @@ -774,32 +749,32 @@ class DOM { h.forEach(nodeList, addClass[this.childType(className)]) } - /** - * Read columns and generate bootstrap cols - * @param {Object} row DOM element - */ - columnWidths(row) { - const columns = row.getElementsByClassName(COLUMN_CLASSNAME) - if (!columns.length) { - return - } - const width = parseFloat((100 / columns.length).toFixed(1)) / 1 + // /** + // * Read columns and generate bootstrap cols + // * @param {Object} row DOM element + // */ + // columnWidths(row) { + // const columns = row.getElementsByClassName(COLUMN_CLASSNAME) + // if (!columns.length) { + // return + // } + // const width = parseFloat((100 / columns.length).toFixed(1)) / 1 - this.removeClasses(columns, bsColRegExp) - h.forEach(columns, column => { - Columns.get(column.id).refreshFieldPanels() + // this.removeClasses(columns, bsColRegExp) + // h.forEach(columns, column => { + // Columns.get(column.id).refreshFieldPanels() - const newColWidth = numToPercent(width) + // const newColWidth = numToPercent(width) - column.style.width = newColWidth - column.style.float = 'left' - Columns.set(`${column.id}.config.width`, newColWidth) - column.dataset.colWidth = newColWidth - document.dispatchEvent(events.columnResized) - }) + // column.style.width = newColWidth + // column.style.float = 'left' + // Columns.set(`${column.id}.config.width`, newColWidth) + // column.dataset.colWidth = newColWidth + // document.dispatchEvent(events.columnResized) + // }) - dom.updateColumnPreset(row) - } + // dom.updateColumnPreset(row) + // } /** * Wrap content in a formGroup @@ -895,40 +870,6 @@ class DOM { elem.classList.toggle('empty', !children.length) } - /** - * Style Object - * Usage: - * - const rules = [['.css-class-selector', ['width', '100%', true]]] - dom.insertRule(rules) - * @param {Object} rules - * @return {Number} index of added rule - */ - insertRule(rules) { - const styleSheet = this.styleSheet - const rulesLength = styleSheet.cssRules.length - for (let i = 0, rl = rules.length; i < rl; i++) { - let j = 1 - let rule = rules[i] - const selector = rules[i][0] - let propStr = '' - // If the second argument of a rule is an array - // of arrays, correct our variables. - if (Object.prototype.toString.call(rule[1][0]) === '[object Array]') { - rule = rule[1] - j = 0 - } - - for (let pl = rule.length; j < pl; j++) { - const prop = rule[j] - const important = prop[2] ? ' !important' : '' - propStr += `${prop[0]}:${prop[1]}${important};` - } - - // Insert CSS Rule - return styleSheet.insertRule(`${selector} { ${propStr} }`, rulesLength) - } - } btnTemplate = ({ title = '', ...rest }) => ({ tag: 'button', attrs: { diff --git a/src/lib/js/common/helpers.mjs b/src/lib/js/common/helpers.mjs index d8584b13..d2a494d4 100644 --- a/src/lib/js/common/helpers.mjs +++ b/src/lib/js/common/helpers.mjs @@ -66,12 +66,23 @@ export const map = (arr, cb) => { return newArray } +const sanitizedAttributeNames = {} + export const safeAttrName = name => { - const safeAttr = { + const attributeMap = { className: 'class', } - return safeAttr[name] || slugify(name) + if (sanitizedAttributeNames[name]) { + return sanitizedAttributeNames[name] + } + + const attributeName = attributeMap[name] || name + const sanitizedAttributeName = attributeName.replace(/^\d/, '').replace(/[^a-zA-Z0-9-:]/g, '') + + sanitizedAttributeNames[name] = sanitizedAttributeName + + return sanitizedAttributeName } export const capitalize = str => str.replace(/\b\w/g, m => m.toUpperCase()) diff --git a/src/lib/js/common/utils/index.mjs b/src/lib/js/common/utils/index.mjs index c5d163de..31f4ffa7 100644 --- a/src/lib/js/common/utils/index.mjs +++ b/src/lib/js/common/utils/index.mjs @@ -158,8 +158,6 @@ export const clone = obj => { return copy } - debugger - throw new Error('Unable to copy Object, type not supported.') } diff --git a/src/lib/js/common/utils/object.mjs b/src/lib/js/common/utils/object.mjs index 765a6227..e13d8d52 100644 --- a/src/lib/js/common/utils/object.mjs +++ b/src/lib/js/common/utils/object.mjs @@ -1,82 +1,8 @@ -/** - * Retrieves the value at a given path within an object. - * - * @param {Object} obj - The object to query. - * @param {string|string[]} pathArg - The path of the property to get. If a string is provided, it will be converted to an array. - * @returns {*} - Returns the value at the specified path of the object. - * - * @example - * const obj = { foo: [{ bar: 'baz' }] }; - * get(obj, 'foo[0].bar'); // 'baz' - * get(obj, ['foo', '0', 'bar']); // 'baz' - */ -export function get(obj, pathArg) { - let path = pathArg - if (!Array.isArray(pathArg)) { - /* - If pathString is not an array, then this statement replaces any - instances of `[]` with `.` using a regular expression - (/\[(\d)\]/g) and then splits pathString into an array using the - delimiter `.`. The g flag in the regex indicates that we want to - replace all instances of [] (not just the first instance). - We're doing this because the walker needs each path segment to be - a valid dot notation property. - - For example, if we have a path string of 'foo[0].bar', we need to - convert it to 'foo.0.bar' so that we can split it into an array - of ['foo', '0', 'bar']. - */ - path = pathArg.replace(/\[(\d)\]/g, '.$1').split('.') - } - - return path.reduce((acc, part) => { - const currentVal = acc[part] - path.shift() - if (Array.isArray(currentVal)) { - const [nextPart] = path - const nextPartIndex = Number(nextPart) - path.shift() - if (nextPart) { - if (isNaN(nextPartIndex)) { - return get(currentVal.map(aObj => aObj[nextPart]).flat(), path) - } +import lodashSet from 'lodash/set.js' +import lodashGet from 'lodash/get.js' - return get(obj[part][nextPartIndex], path) - } - - return currentVal - } - - if (!currentVal) { - path.splice(0) - } - - return get(currentVal, path) - }, obj) -} - -/** - * Sets the value at the specified path of the object. If the path does not exist, it will be created. - * - * @param {Object} obj - The object to modify. - * @param {string|string[]} pathArg - The path of the property to set. Can be a string with dot notation or an array of strings/numbers. - * @param {*} value - The value to set at the specified path. - */ -export function set(obj, pathArg, value) { - let path = pathArg - if (!Array.isArray(pathArg)) { - path = pathArg.replace(/\[(\d)\]/g, '.$1').split('.') - } - - path.reduce((acc, part, index) => { - if (index === path.length - 1) { - acc[part] = value - } else if (!acc[part] || typeof acc[part] !== 'object') { - acc[part] = isNaN(Number(path[index + 1])) ? {} : [] - } - return acc[part] - }, obj) -} +export const get = lodashGet +export const set = lodashSet /** * Empty an objects contents @@ -100,3 +26,38 @@ export const cleanObj = obj => { return fresh } + +/** + * Determines if a value should be cloned. + * + * @param {*} value - The value to check. + * @returns {boolean} - Returns `true` if the value is an object and not null, otherwise `false`. + */ +export function shouldClone(value) { + return value !== null && typeof value === 'object' +} + +/** + * Deeply clones an object or array. + * + * This function recursively clones all nested objects and arrays within the provided object. + * If the input is not an object or array, it returns the input as is. + * + * @param {Object|Array} obj - The object or array to be deeply cloned. + * @returns {Object|Array} - A deep clone of the input object or array. + */ +export function deepClone(obj) { + if (!shouldClone(obj)) return obj + + if (Array.isArray(obj)) { + return obj.map(item => deepClone(item)) + } + + const cloned = {} + for (const key in obj) { + if (Object.prototype.hasOwnProperty.call(obj, key)) { + cloned[key] = deepClone(obj[key]) + } + } + return cloned +} diff --git a/src/lib/js/components/columns/column.js b/src/lib/js/components/columns/column.js index f58d37ad..38c04e3c 100644 --- a/src/lib/js/components/columns/column.js +++ b/src/lib/js/components/columns/column.js @@ -5,7 +5,7 @@ import h from '../../common/helpers.mjs' import events from '../../common/events.js' import dom from '../../common/dom.js' import { COLUMN_CLASSNAME, FIELD_CLASSNAME } from '../../constants.js' -import { resize } from './events.js' +import { ResizeColumn } from './event-handlers.js' const DEFAULT_DATA = () => Object.freeze({ @@ -13,14 +13,14 @@ const DEFAULT_DATA = () => width: '100%', }, children: [], - className: COLUMN_CLASSNAME, + className: [COLUMN_CLASSNAME], }) const DOM_CONFIGS = { - resizeHandle: () => ({ + resizeHandle: columnRisizer => ({ className: 'resize-x-handle', action: { - pointerdown: resize, + pointerdown: columnRisizer.onStart.bind(columnRisizer), }, content: [dom.icon('triangle-down'), dom.icon('triangle-up')], }), @@ -39,7 +39,7 @@ export default class Column extends Component { * @return {Object} Column config object */ constructor(columnData) { - super('column', Object.assign({}, DEFAULT_DATA(), columnData)) + super('column', { ...DEFAULT_DATA(), ...columnData }) const _this = this @@ -51,17 +51,14 @@ export default class Column extends Component { dataset: { hoverTag: i18n.get('column'), }, - action: { - mouseup: evt => { - const column = evt.target.parentElement - if (column.resizing) { - column.resizing = false - column.parentElement.classList.remove('resizing-columns') - } - }, - }, id: this.id, - content: [this.getActionButtons(), DOM_CONFIGS.editWindow(), DOM_CONFIGS.resizeHandle(), children], + content: [ + this.getComponentTag(), + this.getActionButtons(), + DOM_CONFIGS.editWindow(), + DOM_CONFIGS.resizeHandle(new ResizeColumn()), + children, + ], }) this.processConfig(this.dom) @@ -93,12 +90,9 @@ export default class Column extends Component { if (evt.from !== evt.to) { evt.from.classList.remove('hovering-column') } - // if (evt.related.parentElement.fType === 'columns') { - // evt.related.parentElement.classList.add('hovering-column') - // } }, draggable: `.${FIELD_CLASSNAME}`, - handle: '.item-handle', + handle: '.item-move', }) } @@ -111,30 +105,9 @@ export default class Column extends Component { if (columnWidth) { column.dataset.colWidth = columnWidth column.style.width = columnWidth - column.style.float = 'left' } } - /** - * Sets classes for legacy browsers to identify first and last fields in a block - * consider removing - * @param {DOM} column - */ - fieldOrderClasses = () => { - const fields = this.children.map(({ dom }) => dom) - - if (fields.length) { - this.removeClasses(['first-field', 'last-field']) - fields[0].classList.add('first-field') - fields[fields.length - 1].classList.add('last-field') - } - } - - addChild(...args) { - super.addChild(...args) - this.fieldOrderClasses() - } - // loops through children and refresh their edit panels refreshFieldPanels = () => { this.children.forEach(field => field.panels.nav.refresh()) diff --git a/src/lib/js/components/columns/event-handlers.js b/src/lib/js/components/columns/event-handlers.js new file mode 100644 index 00000000..587f2ee1 --- /dev/null +++ b/src/lib/js/components/columns/event-handlers.js @@ -0,0 +1,225 @@ +import dom from '../../common/dom.js' +import Components from '../index.js' +import { percent, numToPercent } from '../../common/utils/index.mjs' +import { + COLUMN_PRESET_CLASSNAME, + COLUMN_RESIZE_CLASSNAME, + CUSTOM_COLUMN_OPTION_CLASSNAME, + ROW_CLASSNAME, + bsColRegExp, +} from '../../constants.js' +import { map } from '../../common/helpers.mjs' + +export class ResizeColumn { + /** + * Binds the event handlers to the instance. + */ + constructor() { + this.onMove = this.onMove.bind(this) + this.onStop = this.onStop.bind(this) + this.cleanup = this.cleanup.bind(this) + } + + /** + * Calculates the total width of a row excluding the gaps between columns. + * @param {HTMLElement} row - The row element. + * @returns {number} - The total width of the row. + */ + getRowWidth(row) { + const rowChildren = row.querySelector('.children') + if (!rowChildren) return 0 + + const numberOfColumns = rowChildren.children.length + const gapSize = dom.getStyle(rowChildren, 'gap') || '0px' + const gapPixels = parseFloat(gapSize, 10) || 0 + + // Cache the total gap width for potential future use + this.totalGapWidth = gapPixels * (numberOfColumns - 1) + + return rowChildren.offsetWidth - this.totalGapWidth + } + + /** + * Validates if the resize target columns are valid. + * @param {HTMLElement} column - The column being resized. + * @param {HTMLElement} sibling - The sibling column. + * @returns {boolean} - True if both columns are valid, false otherwise. + */ + validateResizeTarget(column, sibling) { + return column && sibling && column.offsetWidth && sibling.offsetWidth + } + + /** + * Handles the start of the resize event. + * @param {Event} evt - The event object. + */ + onStart(evt) { + evt.preventDefault() + this.resized = false + + if (evt.button !== 0) return + + const column = evt.target.parentElement + const sibling = column.nextSibling || column.previousSibling + const row = column.closest(`.${ROW_CLASSNAME}`) + + // Validate resize targets + if (!this.validateResizeTarget(column, sibling)) { + console.warn('Invalid resize targets') + this.cleanup() + return + } + + this.startX = evt.type === 'touchstart' ? evt.touches[0].clientX : evt.clientX + + row.classList.add(COLUMN_RESIZE_CLASSNAME) + this.columnPreset = row.querySelector(`.${COLUMN_PRESET_CLASSNAME}`) + + // Store original classes in case we need to revert + this.originalColumnClass = column.className + this.originalSiblingClass = sibling.className + + // remove bootstrap column classes since we are custom sizing + column.className = column.className.replace(bsColRegExp, '') + sibling.className = sibling.className.replace(bsColRegExp, '') + + this.colStartWidth = column.offsetWidth + this.sibStartWidth = sibling.offsetWidth + this.rowWidth = this.getRowWidth(row) + + // Validate calculated width + if (this.rowWidth <= 0) { + console.warn('Invalid row width calculated') + this.cleanup() + return + } + + this.column = column + this.sibling = sibling + this.row = row + + try { + window.addEventListener('pointermove', this.onMove, false) + window.addEventListener('pointerup', this.onStop, false) + } catch (error) { + console.error('Failed to initialize resize listeners:', error) + this.cleanup() + } + } + + /** + * Calculates the new widths for the columns based on the mouse position. + * @param {number} clientX - The current X position of the mouse. + * @returns {Object|null} - The new widths for the columns or null if invalid. + */ + calculateNewWidths(clientX) { + const newColWidth = this.colStartWidth + clientX - this.startX + const newSibWidth = this.sibStartWidth - clientX + this.startX + + const colWidthPercent = parseFloat(percent(newColWidth, this.rowWidth)) + const sibWidthPercent = parseFloat(percent(newSibWidth, this.rowWidth)) + + // Add minimum width check + if (colWidthPercent < 10 || sibWidthPercent < 10) { + return null + } + + return { + colWidth: numToPercent(colWidthPercent.toFixed(1)), + siblingColWidth: numToPercent(sibWidthPercent.toFixed(1)), + } + } + + /** + * Handles the movement during the resize event. + * @param {Event} evt - The event object. + */ + onMove(evt) { + evt.preventDefault() + const { column, sibling } = this + + const clientX = evt.type === 'touchmove' ? evt.touches[0].clientX : evt.clientX + + const newWidths = this.calculateNewWidths(clientX) + if (!newWidths) return + + const { colWidth, siblingColWidth } = newWidths + + column.dataset.colWidth = colWidth + sibling.dataset.colWidth = siblingColWidth + column.style.width = colWidth + sibling.style.width = siblingColWidth + this.resized = true + } + + onStop() { + const { column, sibling } = this + + window.removeEventListener('pointermove', this.onMove) + window.removeEventListener('pointerup', this.onStop) + + if (!this.resized) { + return + } + + this.setCustomWidthValue() + + Components.setAddress(`columns.${column.id}.config.width`, column.dataset.colWidth) + Components.setAddress(`columns.${sibling.id}.config.width`, sibling.dataset.colWidth) + this.row.classList.remove(COLUMN_RESIZE_CLASSNAME) + + this.resized = false + + this.cleanup() + } + + // Helper method to clean up if resize fails + cleanup() { + if (this.column && this.originalColumnClass) { + this.column.className = this.originalColumnClass + } + if (this.sibling && this.originalSiblingClass) { + this.sibling.className = this.originalSiblingClass + } + if (this.row) { + this.row.classList.remove(COLUMN_RESIZE_CLASSNAME) + } + window.removeEventListener('pointermove', this.onMove) + window.removeEventListener('pointerup', this.onStop) + } + + /** + * Adds a custom option from the column width present selecy + * @param {Node} row + */ + setCustomWidthValue() { + const columnPreset = this.columnPreset + const customOption = columnPreset.querySelector(`.${CUSTOM_COLUMN_OPTION_CLASSNAME}`) + const cols = this.row.querySelector('.children').children + const widths = map(cols, col => percent(col.clientWidth, this.rowWidth).toFixed(1)) + const value = widths.join(',') + const content = widths.join(' | ') + + if (customOption) { + customOption.value = value + customOption.textContent = content + } else { + const newCustomOption = dom.create({ + tag: 'option', + attrs: { + className: CUSTOM_COLUMN_OPTION_CLASSNAME, + value, + selected: true, + }, + content, + }) + + columnPreset.append(newCustomOption) + } + + columnPreset.value = value + + return value + } +} + diff --git a/src/lib/js/components/columns/events.js b/src/lib/js/components/columns/events.js deleted file mode 100644 index e19143c8..00000000 --- a/src/lib/js/components/columns/events.js +++ /dev/null @@ -1,130 +0,0 @@ -import dom from '../../common/dom.js' -import Components from '../index.js' -import { percent, numToPercent } from '../../common/utils/index.mjs' -import { ROW_CLASSNAME, bsColRegExp } from '../../constants.js' -import { map } from '../../common/helpers.mjs' - -const CUSTOM_COLUMN_OPTION_CLASSNAME = 'custom-column-widths' -const COLUMN_PRESET_CLASSNAME = 'column-preset' -const COLUMN_RESIZE_CLASSNAME = 'resizing-columns' - -/** - * Handle column resizing - * @param {Object} evt resize event - */ -export function resize(evt) { - const resize = this - const column = evt.target.parentElement - const sibling = column.nextSibling || column.previousSibling - const row = column.closest(`.${ROW_CLASSNAME}`) - const rowStyle = dom.getStyle(row) - const rowPadding = parseFloat(rowStyle.paddingLeft) + parseFloat(rowStyle.paddingRight) - evt.target.removeEventListener('pointerdown', resize) - - /** - * Set the width before resizing so the column - * does not resize near window edges - * @param {Object} evt - */ - resize.move = ({ touches, type, clientX }) => { - if (type === 'touchmove') { - const [firstTouch] = touches - clientX = firstTouch.clientX - } - const newColWidth = resize.colStartWidth + clientX - resize.startX - const newSibWidth = resize.sibStartWidth - clientX + resize.startX - - const colWidthPercent = parseFloat(percent(newColWidth, resize.rowWidth)) - const sibWidthPercent = parseFloat(percent(newSibWidth, resize.rowWidth)) - - const colWidth = numToPercent(colWidthPercent.toFixed(1)) - const siblingColWidth = numToPercent(sibWidthPercent.toFixed(1)) - - column.dataset.colWidth = colWidth - sibling.dataset.colWidth = siblingColWidth - column.style.width = colWidth - sibling.style.width = siblingColWidth - resize.resized = true - } - - resize.stop = function() { - window.removeEventListener('pointermove', resize.move) - window.removeEventListener('pointerup', resize.stop) - window.removeEventListener('touchmove', resize.move) - window.removeEventListener('touchend', resize.stop) - if (!resize.resized) { - return - } - - setCustomWidthValue(row, resize.rowWidth) - row.classList.remove(COLUMN_RESIZE_CLASSNAME) - - Components.setAddress(`columns.${column.id}.config.width`, column.dataset.colWidth) - Components.setAddress(`columns.${sibling.id}.config.width`, sibling.dataset.colWidth) - resize.resized = false - } - - if (evt.type === 'touchstart') { - const [firstTouch] = evt.touches - resize.startX = firstTouch.clientX - } else { - resize.startX = evt.clientX - } - row.classList.add(COLUMN_RESIZE_CLASSNAME) - - // remove bootstrap column classes since we are custom sizing - column.className.replace(bsColRegExp, '') - sibling.className.replace(bsColRegExp, '') - - // eslint-disable-next-line - resize.colStartWidth = column.offsetWidth || dom.getStyle(column, 'width') - // eslint-disable-next-line - resize.sibStartWidth = sibling.offsetWidth || dom.getStyle(sibling, 'width') - resize.rowWidth = row.offsetWidth - rowPadding // compensate for padding - - window.addEventListener('pointerup', resize.stop, false) - window.addEventListener('pointermove', resize.move, false) - window.addEventListener('touchend', resize.stop, false) - window.addEventListener('touchmove', resize.move, false) -} - -/** - * Removes a custom option from the column width present selecy - * @param {Node} row - * @return {Node} columnPreset input || null - */ -export const removeCustomOption = (row, columnPreset = row.querySelector(`.${COLUMN_PRESET_CLASSNAME}`)) => { - const customOption = columnPreset.querySelector(`.${CUSTOM_COLUMN_OPTION_CLASSNAME}`) - return customOption && columnPreset.removeChild(customOption) -} - -/** - * Adds a custom option from the column width present selecy - * @param {Node} row - */ -export const setCustomWidthValue = (row, rowWidth) => { - const columnPreset = row.querySelector(`.${COLUMN_PRESET_CLASSNAME}`) - const customOption = columnPreset.querySelector(`.${CUSTOM_COLUMN_OPTION_CLASSNAME}`) - const cols = row.querySelector('.children').children - const widths = map(cols, col => percent(col.clientWidth, rowWidth).toFixed(1)) - const value = widths.join(',') - const content = widths.join(' | ') - - if (customOption) { - removeCustomOption(row, columnPreset) - } - - const newCustomOption = dom.create({ - tag: 'option', - attrs: { - className: CUSTOM_COLUMN_OPTION_CLASSNAME, - value, - }, - content, - }) - - columnPreset.add(newCustomOption) - columnPreset.value = value - - return value -} diff --git a/src/lib/js/components/columns/index.js b/src/lib/js/components/columns/index.js index 4ab41d45..52a38c77 100644 --- a/src/lib/js/components/columns/index.js +++ b/src/lib/js/components/columns/index.js @@ -3,7 +3,7 @@ import Column from './column.js' const DEFAULT_CONFIG = { actionButtons: { - buttons: ['clone', 'handle', 'remove'], + buttons: ['clone', 'move', 'remove'], disabled: [], }, } diff --git a/src/lib/js/components/component.js b/src/lib/js/components/component.js index 86ffc66c..842bfb00 100644 --- a/src/lib/js/components/component.js +++ b/src/lib/js/components/component.js @@ -1,5 +1,5 @@ /* global MutationObserver */ -import { uuid, componentType, merge, clone, remove, identity } from '../common/utils/index.mjs' +import { uuid, componentType, merge, clone, remove, identity, closest } from '../common/utils/index.mjs' import { isInt, map, forEach, indexOfNode } from '../common/helpers.mjs' import dom from '../common/dom.js' import { @@ -10,12 +10,14 @@ import { COMPONENT_TYPE_CLASSNAMES, COLUMN_CLASSNAME, CONTROL_GROUP_CLASSNAME, + COMPONENT_TYPES, } from '../constants.js' import Components from './index.js' import Data from './data.js' import animate from '../common/animation.js' import Controls from './controls/index.js' import { get } from '../common/utils/object.mjs' +import { toTitleCase } from '../common/utils/string.mjs' export default class Component extends Data { constructor(name, data = {}, render) { @@ -39,6 +41,7 @@ export default class Component extends Data { this.observer.disconnect() this.observer.observe(container, { childList: true }) } + get js() { return this.data } @@ -109,28 +112,46 @@ export default class Component extends Data { * @return {Object} element config object */ getActionButtons() { - let expandSize = '97px' - const hoverClassname = `hovering-${this.name}` + const hoverClassnames = [`hovering-${this.name}`, 'hovering'] return { - className: `${this.name}-actions group-actions`, + className: [`${this.name}-actions`, 'group-actions'], action: { - onRender: elem => (expandSize = `${elem.getElementsByTagName('button').length * 24 + 1}px`), mouseenter: ({ target }) => { - this.dom.classList.add(hoverClassname) - target.style[this.name === 'row' ? 'height' : 'width'] = expandSize + Components.stages.active.dom.classList.add(`active-hover-${this.name}`) + this.dom.classList.add(...hoverClassnames) }, mouseleave: ({ target }) => { - this.dom.classList.remove(hoverClassname) + this.dom.classList.remove(...hoverClassnames) + Components.stages.active.dom.classList.remove(`active-hover-${this.name}`) target.removeAttribute('style') }, }, - children: { - className: 'action-btn-wrap', - children: this.buttons, - }, + children: [ + { + ...dom.btnTemplate({ content: dom.icon(`handle-${this.name}`) }), + className: ['component-handle', `${this.name}-handle`], + }, + { + className: ['action-btn-wrap', `${this.name}-action-btn-wrap`], + children: this.buttons, + }, + ], } } + getComponentTag = () => { + return dom.create({ + tag: 'span', + className: ['component-tag', `${this.name}-tag`], + children: [ + (this.isColumn || this.isField) && dom.icon('component-corner', ['bottom-left']), + dom.icon(`handle-${this.name}`), + toTitleCase(this.name), + (this.isColumn || this.isRow) && dom.icon('component-corner', ['bottom-right']), + ].filter(Boolean), + }) + } + /** * Toggles the edit window * @param {Boolean} open whether to open or close the edit window @@ -138,34 +159,44 @@ export default class Component extends Data { toggleEdit(open = !this.isEditing) { this.isEditing = open const element = this.dom - const editClass = `editing-${this.name}` + const editingClassName = 'editing' + const editingComponentClassname = `${editingClassName}-${this.name}` const editWindow = this.dom.querySelector(`.${this.name}-edit`) animate.slideToggle(editWindow, ANIMATION_SPEED_BASE, open) if (this.name === 'field') { animate.slideToggle(this.preview, ANIMATION_SPEED_BASE, !open) - element.parentElement.classList.toggle(`column-${editClass}`, open) + element.parentElement.classList.toggle(`column-${editingComponentClassname}`, open) } - element.classList.toggle(editClass, open) + element.classList.toggle(editingClassName, open) + element.classList.toggle(editingComponentClassname, open) } get buttons() { const _this = this - const parseIcons = icons => icons.map(icon => dom.icon(icon)) + if (this.actionButtons) { + return this.actionButtons + } + + // const parseIcons = icons => icons.map(icon => dom.icon(icon)) const buttonConfig = { - handle: (icons = ['move', 'handle']) => { + handle: (icon = `handle-${this.name}`) => ({ + ...dom.btnTemplate({ content: dom.icon(icon) }), + className: ['component-handle'], + }), + move: (icon = 'move') => { return { - ...dom.btnTemplate({ content: parseIcons(icons) }), - className: ['item-handle'], + ...dom.btnTemplate({ content: dom.icon(icon) }), + className: ['item-move'], meta: { - id: 'handle', + id: 'move', }, } }, - edit: (icons = ['edit']) => { + edit: (icon = 'edit') => { return { - ...dom.btnTemplate({ content: parseIcons(icons) }), + ...dom.btnTemplate({ content: dom.icon(icon) }), className: ['item-edit-toggle'], meta: { id: 'edit', @@ -177,9 +208,9 @@ export default class Component extends Data { }, } }, - remove: (icons = ['remove']) => { + remove: (icon = 'remove') => { return { - ...dom.btnTemplate({ content: parseIcons(icons) }), + ...dom.btnTemplate({ content: dom.icon(icon) }), className: ['item-remove'], meta: { id: 'remove', @@ -200,15 +231,20 @@ export default class Component extends Data { }, } }, - clone: (icons = ['copy', 'handle']) => { + clone: (icon = 'copy') => { return { - ...dom.btnTemplate({ content: parseIcons(icons) }), + ...dom.btnTemplate({ content: dom.icon(icon) }), className: ['item-clone'], meta: { id: 'clone', }, action: { - click: () => this.clone(), + click: () => { + this.clone(this.parent) + if (this.name === 'column') { + this.parent.autoColumnWidths() + } + }, }, } }, @@ -216,8 +252,11 @@ export default class Component extends Data { const { buttons, disabled } = this.config.actionButtons const activeButtons = buttons.filter(btn => !disabled.includes(btn)) + const actionButtonsConfigs = activeButtons.map(btn => buttonConfig[btn]?.() || btn) - return activeButtons.map(btn => buttonConfig[btn]?.() || btn) + this.actionButtons = actionButtonsConfigs + + return this.actionButtons } /** @@ -562,27 +601,26 @@ export default class Component extends Data { }) } - cloneData = () => { + cloneData() { const clonedData = { ...clone(this.data), id: uuid() } if (this.name !== 'field') { clonedData.children = [] } + return clonedData } - clone = (parent = this.parent) => { + clone(parent = this.parent) { const newClone = parent.addChild(this.cloneData(), this.index + 1) if (this.name !== 'field') { this.cloneChildren(newClone) } - if (this.name === 'column') { - parent.autoColumnWidths() - } + return newClone } - cloneChildren = toParent => { - this.children.forEach(child => child && child.clone(toParent)) + cloneChildren(toParent) { + this.children.forEach(child => child?.clone(toParent)) } createChildWrap = children => @@ -593,4 +631,27 @@ export default class Component extends Data { }, children, }) + + get isRow() { + return this.name === COMPONENT_TYPES.row + } + get isColumn() { + return this.name === COMPONENT_TYPES.column + } + get isField() { + return this.name === COMPONENT_TYPES.field + } + + // set(path, val) { + // super.set(path, val) + // debugger + // const [key, ...rest] = path.split('.') + // const parent = this.get(rest.slice(0, rest.length - 1).join('.')) + // const property = rest.slice(rest.length - 1, rest.length).join('.') + // const value = val || this.get(path) + // if (parent) { + // parent[key] = { ...parent[key], [property]: value } + // } + // return this.get(path) + // } } diff --git a/src/lib/js/components/data.js b/src/lib/js/components/data.js index 8d9fdefa..dfbff878 100644 --- a/src/lib/js/components/data.js +++ b/src/lib/js/components/data.js @@ -2,6 +2,7 @@ import { uuid } from '../common/utils/index.mjs' import events from '../common/events.js' import { CHANGE_TYPES } from '../constants.js' import { get, set } from '../common/utils/object.mjs' +import isEqual from 'lodash/isEqual' export default class Data { constructor(name, data = Object.create(null)) { @@ -28,14 +29,14 @@ export default class Data { set(path, newVal) { const oldVal = get(this.data, path) - // if (isEqual(oldVal, newVal)) { - // return this.data - // } + if (isEqual(oldVal, newVal)) { + return this.data + } const data = set(this.data, path, newVal) - const callBackPath = Array.isArray(path) ? path.join('.') : path - const callBackGroups = Object.keys(this.setCallbacks).filter(setKey => new RegExp(setKey).test(callBackPath)) + const callbackPath = Array.isArray(path) ? path.join('.') : path + const callBackGroups = Object.keys(this.setCallbacks).filter(setKey => new RegExp(setKey).test(callbackPath)) const cbArgs = { newVal, oldVal, path } callBackGroups.forEach(cbGroup => this.setCallbacks[cbGroup].forEach(cb => cb(cbArgs))) diff --git a/src/lib/js/components/fields/field.js b/src/lib/js/components/fields/field.js index bd1d43e4..43117e25 100644 --- a/src/lib/js/components/fields/field.js +++ b/src/lib/js/components/fields/field.js @@ -29,16 +29,17 @@ export default class Field extends Component { this.editPanels = [] const actionButtons = this.getActionButtons() - const hasEditButton = actionButtons.children.children.some(child => child.meta?.id === 'edit') + const hasEditButton = this.actionButtons.some(child => child.meta?.id === 'edit') let field = { tag: 'li', attrs: { - className: FIELD_CLASSNAME, + className: [FIELD_CLASSNAME], }, id: this.id, children: [ this.label, + this.getComponentTag(), actionButtons, hasEditButton && this.fieldEdit, // fieldEdit window, this.preview, @@ -48,8 +49,8 @@ export default class Field extends Component { hoverTag: i18n.get('field'), }, action: { - mouseenter: () => this.dom.classList.add(`hovering-${this.name}`), - mouseleave: () => this.dom.classList.remove(`hovering-${this.name}`), + // mouseenter: () => this.dom.classList.add(`hovering-${this.name}`), + // mouseleave: () => this.dom.classList.remove(`hovering-${this.name}`), }, } diff --git a/src/lib/js/components/fields/index.js b/src/lib/js/components/fields/index.js index e0717be5..03ba52f5 100644 --- a/src/lib/js/components/fields/index.js +++ b/src/lib/js/components/fields/index.js @@ -5,7 +5,7 @@ import { get } from '../../common/utils/object.mjs' const DEFAULT_CONFIG = { actionButtons: { - buttons: ['handle', 'edit', 'clone', 'remove'], + buttons: ['move', 'edit', 'clone', 'remove'], disabled: [], }, panels: { diff --git a/src/lib/js/components/index.js b/src/lib/js/components/index.js index c8549fae..2f8051ff 100644 --- a/src/lib/js/components/index.js +++ b/src/lib/js/components/index.js @@ -46,11 +46,13 @@ export class Components extends Data { formData = JSON.parse(formData) } this.opts = opts - const { stages = { [uuid()]: {} }, rows, columns, fields, id = uuid() } = Object.assign( - {}, - this.sessionFormData(), - formData - ) + const { + stages = { [uuid()]: {} }, + rows, + columns, + fields, + id = uuid(), + } = { ...this.sessionFormData(), ...formData } this.set('id', id) this.add('stages', Stages.load(stages)) this.add('rows', Rows.load(rows)) @@ -103,13 +105,13 @@ export class Components extends Data { /** * call `set` on a component in memory */ - setAddress(address, value) { - const [type, id, ...path] = Array.isArray(address) ? address : address.split('.') + setAddress(fullAddress, value) { + const [type, id, ...localAddress] = Array.isArray(fullAddress) ? fullAddress : fullAddress.split('.') const componentType = type.replace(/s?$/, 's') const component = this[componentType].get(id) - if (component) { - component.set(path, value) - } + + component?.set(localAddress, value) + return component } diff --git a/src/lib/js/components/rows/index.js b/src/lib/js/components/rows/index.js index 98e186fc..19afa74f 100644 --- a/src/lib/js/components/rows/index.js +++ b/src/lib/js/components/rows/index.js @@ -3,7 +3,7 @@ import Row from './row.js' const DEFAULT_CONFIG = { actionButtons: { - buttons: ['handle', 'edit', 'clone', 'remove'], + buttons: ['move', 'edit', 'clone', 'remove'], disabled: [], }, } diff --git a/src/lib/js/components/rows/row.js b/src/lib/js/components/rows/row.js index 45e56549..fc412bb1 100644 --- a/src/lib/js/components/rows/row.js +++ b/src/lib/js/components/rows/row.js @@ -4,8 +4,19 @@ import Component from '../component.js' import dom from '../../common/dom.js' import events from '../../common/events.js' import { numToPercent } from '../../common/utils/index.mjs' -import { ROW_CLASSNAME, COLUMN_TEMPLATES, ANIMATION_SPEED_FAST, COLUMN_CLASSNAME, bsColRegExp } from '../../constants.js' -import { removeCustomOption } from '../columns/events.js' +import { + ROW_CLASSNAME, + COLUMN_TEMPLATES, + ANIMATION_SPEED_FAST, + COLUMN_CLASSNAME, + bsColRegExp, + CUSTOM_COLUMN_OPTION_CLASSNAME, + COLUMN_PRESET_CLASSNAME, +} from '../../constants.js' +import columnsData from '../columns/index.js' +import data from '../data.js' +import components from '../index.js' +import { forEach } from 'lodash' const DEFAULT_DATA = () => Object.freeze({ @@ -28,7 +39,7 @@ export default class Row extends Component { * @return {Object} */ constructor(rowData) { - super('row', Object.assign({}, DEFAULT_DATA(), rowData)) + super('row', { ...DEFAULT_DATA(), ...rowData }) const children = this.createChildWrap() @@ -40,7 +51,7 @@ export default class Row extends Component { editingHoverTag: i18n.get('editing.row'), }, id: this.id, - content: [this.getActionButtons(), this.editWindow, children], + content: [this.getComponentTag(), this.getActionButtons(), this.editWindow, children], }) this.sortable = Sortable.create(children, { @@ -58,12 +69,10 @@ export default class Row extends Component { onEnd: this.onEnd.bind(this), onAdd: this.onAdd.bind(this), onSort: this.onSort.bind(this), - filter: '.resize-x-handle', + // filter: '.resize-x-handle', // use filter for frozen columns draggable: `.${COLUMN_CLASSNAME}`, - handle: '.item-handle', + handle: '.item-move', }) - - this.onRender() } /** @@ -72,9 +81,7 @@ export default class Row extends Component { */ get editWindow() { const _this = this - const editWindow = { - className: `${this.name}-edit group-config`, - } + const fieldsetLabel = { tag: 'label', content: i18n.get('row.settings.fieldsetWrap'), @@ -111,9 +118,6 @@ export default class Row extends Component { }, } - // let fieldsetAddon = Object.assign({}, fieldsetLabel, { - // content: [fieldsetInput, ' Fieldset'] - // }); const inputAddon = { tag: 'span', className: 'input-group-addon', @@ -144,20 +148,31 @@ export default class Row extends Component { content: i18n.get('defineColumnWidths'), className: 'col-sm-4 form-control-label', } + this.columnPresetControl = dom.create(this.columnPresetControlConfig) const columnSettingsPresetSelect = { className: 'col-sm-8', - content: { - className: 'column-preset', - }, + content: this.columnPresetControl, action: { - onRender: evt => { + onRender: () => { this.updateColumnPreset() }, }, } const columnSettingsPreset = dom.formGroup([columnSettingsPresetLabel, columnSettingsPresetSelect], 'row') + const editWindowContents = [inputGroupInput, 'hr', fieldSetControls, 'hr', columnSettingsPreset] - editWindow.children = [inputGroupInput, dom.create('hr'), fieldSetControls, dom.create('hr'), columnSettingsPreset] + const editWindow = dom.create({ + className: `${this.name}-edit group-config`, + action: { + onRender: editWindow => { + const timeout = setTimeout(() => { + const elements = editWindowContents.map(elem => dom.create(elem)) + editWindow.append(...elements) + clearTimeout(timeout) + }, 1000) + }, + }, + }) return editWindow } @@ -199,6 +214,7 @@ export default class Row extends Component { }, ANIMATION_SPEED_FAST) document.dispatchEvent(events.columnResized) }) + this.updateColumnPreset() } @@ -207,13 +223,15 @@ export default class Row extends Component { * @return {Object} columnPresetConfig */ updateColumnPreset = () => { - const oldColumnPreset = this.dom.querySelector('.column-preset') - const rowEdit = oldColumnPreset.parentElement - const columnPresetConfig = this.columnPresetControl(this.id) - const newColumnPreset = dom.create(columnPresetConfig) - - rowEdit.replaceChild(newColumnPreset, oldColumnPreset) - return columnPresetConfig + this.columnPresetControl.innerHTML = '' + const presetOptions = this.getColumnPresetOptions.map(({ label, ...attrs }) => + dom.create({ + tag: 'option', + content: label, + attrs, + }), + ) + this.columnPresetControl.append(...presetOptions) } /** @@ -222,9 +240,6 @@ export default class Row extends Component { * @param {String} widths */ setColumnWidths = widths => { - if (widths === 'custom') { - return - } if (typeof widths === 'string') { widths = widths.split(',') } @@ -235,30 +250,17 @@ export default class Row extends Component { } /** - * Generates the element config for column layout in row - * @return {Object} columnPresetControlConfig + * Retrieves the preset options for columns based on the current configuration. + * + * @returns {Array} An array of option objects for column presets. Each object contains: + * - `value` {string}: The comma-separated string of column widths. + * - `label` {string}: The display label for the option, with widths separated by ' | '. + * - `className` {string}: The CSS class name for custom column options. + * - `selected` {boolean} [optional]: Indicates if the option is the current value. */ - columnPresetControl = () => { - const _this = this - const layoutPreset = { - tag: 'select', - attrs: { - ariaLabel: i18n.get('defineColumnLayout'), - className: 'column-preset', - }, - action: { - change: ({ target: { value } }) => { - if (value !== 'custom') { - removeCustomOption(this.dom) - _this.setColumnWidths(value) - } - }, - }, - options: [], - } - const pMap = COLUMN_TEMPLATES - + get getColumnPresetOptions() { const columns = this.children + const pMap = COLUMN_TEMPLATES const pMapVal = pMap.get(columns.length - 1) || [] const curVal = columns .map(Column => { @@ -266,6 +268,7 @@ export default class Row extends Component { return Number(width.replace('%', '')).toFixed(1) }) .join(',') + if (pMapVal.length) { const options = pMapVal.slice() const isCustomVal = !options.find(val => val.value === curVal) @@ -273,18 +276,48 @@ export default class Row extends Component { options.push({ value: curVal, label: curVal.replace(/,/g, ' | '), - className: 'custom-column-widths', + className: CUSTOM_COLUMN_OPTION_CLASSNAME, }) } - layoutPreset.options = options.map(val => { - const option = Object.assign({}, val) - if (val.value === curVal) { - option.selected = true - } + + return options.map(val => { + const option = { ...val } + option.selected = val.value === curVal return option }) } + return [] + } + + /** + * Generates the element config for column layout in row + * @return {Object} columnPresetControlConfig + */ + get columnPresetControlConfig() { + const _this = this + const layoutPreset = { + tag: 'select', + attrs: { + ariaLabel: i18n.get('defineColumnLayout'), + className: COLUMN_PRESET_CLASSNAME, + }, + action: { + change: ({ target }) => { + const { value } = target + + // forEach(target.children, option => { + // option.selected = option.value === value + // }) + // if (value !== 'custom') { + // removeCustomOption(this.dom) + _this.setColumnWidths(value) + // } + }, + }, + options: this.getColumnPresetOptions, + } + return layoutPreset } } diff --git a/src/lib/js/components/stages/stage.js b/src/lib/js/components/stages/stage.js index 049aa711..ef1eef1c 100644 --- a/src/lib/js/components/stages/stage.js +++ b/src/lib/js/components/stages/stage.js @@ -18,7 +18,7 @@ export default class Stage extends Component { * @return {Object} DOM element */ constructor(stageData, render) { - super('stage', Object.assign({}, DEFAULT_DATA(), stageData), render) + super('stage', { ...DEFAULT_DATA(), ...stageData }, render) // @todo move formSettings to its own component // const defaultOptions = { @@ -88,7 +88,7 @@ export default class Stage extends Component { onStart: () => (Stages.active = this), onSort: this.onSort.bind(this), draggable: `.${ROW_CLASSNAME}`, - handle: '.item-handle', + handle: '.item-move', }) } empty(isAnimated = true) { diff --git a/src/lib/js/constants.js b/src/lib/js/constants.js index 77d25f3e..660fe790 100644 --- a/src/lib/js/constants.js +++ b/src/lib/js/constants.js @@ -17,6 +17,11 @@ export const ROW_CLASSNAME = `${PACKAGE_NAME}-row` export const COLUMN_CLASSNAME = `${PACKAGE_NAME}-column` export const FIELD_CLASSNAME = `${PACKAGE_NAME}-field` +export const CUSTOM_COLUMN_OPTION_CLASSNAME = 'custom-column-widths' +export const COLUMN_PRESET_CLASSNAME = 'column-preset' +export const COLUMN_RESIZE_CLASSNAME = 'resizing-columns' + + export const CHILD_CLASSNAME_MAP = new Map([ [STAGE_CLASSNAME, ROW_CLASSNAME], [ROW_CLASSNAME, COLUMN_CLASSNAME], @@ -25,7 +30,9 @@ export const CHILD_CLASSNAME_MAP = new Map([ export const COMPONENT_INDEX_TYPES = ['external', 'stages', 'rows', 'columns', 'fields'] -export const COMPONENT_TYPES = [ +export const COMPONENT_TYPES = ['stage', 'row', 'column', 'field'].reduce((acc, type) => ({ ...acc, [type]: type }), {}) + +export const COMPONENT_TYPE_CONFIGS = [ { name: 'controls', className: CONTROL_GROUP_CLASSNAME }, { name: 'stage', className: STAGE_CLASSNAME }, { name: 'row', className: ROW_CLASSNAME }, @@ -46,13 +53,13 @@ export const COMPONENT_TYPE_CLASSNAMES_LOOKUP = Object.entries(COMPONENT_TYPE_CL ...acc, [className]: type, }), - {} + {}, ) export const COMPONENT_TYPE_CLASSNAMES_ARRAY = Object.values(COMPONENT_TYPE_CLASSNAMES) export const COMPONENT_TYPE_CLASSNAMES_REGEXP = new RegExp(`${COMPONENT_TYPE_CLASSNAMES_ARRAY.join('|')}`, 'g') -const childTypeMap = COMPONENT_TYPES.map(({ name }, index, arr) => { +const childTypeMap = COMPONENT_TYPE_CONFIGS.map(({ name }, index, arr) => { const { name: childName } = arr[index + 1] || {} return childName && [name, childName] }).filter(Boolean) @@ -94,7 +101,7 @@ export const COLUMN_TEMPLATES = new Map( columnTemplates.reduce((acc, cur, idx) => { acc.push([idx, cur]) return acc - }) + }), ) export const CHANGE_TYPES = [{ type: 'added', condition: (o, n) => Boolean(o === undefined && n) }] @@ -185,3 +192,5 @@ export const CONDITION_TEMPLATE = () => ({ export const UUID_REGEXP = /(\b[0-9a-f]{8}\b-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-\b[0-9a-f]{12}\b)/gi export const bsColRegExp = /\bcol-\w+-\d+/g + +export const iconPrefix = 'f-i-' diff --git a/src/lib/js/renderer.js b/src/lib/js/renderer.js index 967ddf09..8c0f99d5 100644 --- a/src/lib/js/renderer.js +++ b/src/lib/js/renderer.js @@ -32,7 +32,7 @@ const createRemoveButton = () => mouseleave: ({ target }) => target.parentElement.classList.remove('will-remove'), click: ({ target }) => target.parentElement.remove(), }, - }) + }), ) export default class FormeoRenderer { @@ -76,17 +76,19 @@ export default class FormeoRenderer { * @param {Object} columnData * @return {Object} processed column data */ - processColumn = ({ id, ...columnData }) => - Object.assign({}, columnData, { + processColumn = ({ id, ...columnData }) => ({ + ...columnData, + ...{ id: this.prefixId(id), children: this.processFields(columnData.children), style: `width: ${columnData.config.width || '100%'}`, - }) + }, + }) processRows = stageId => this.orderChildren('rows', this.form.stages[stageId].children).reduce( (acc, row) => (row ? [...acc, this.processRow(row)] : acc), - [] + [], ) cacheComponent = data => { @@ -102,7 +104,7 @@ export default class FormeoRenderer { processRow = (data, type = 'row') => { const { config, id } = data const className = [`formeo-${type}-wrap`] - const rowData = Object.assign({}, data, { children: this.processColumns(data.id), id: this.prefixId(id) }) + const rowData = { ...data, children: this.processColumns(data.id), id: this.prefixId(id) } this.cacheComponent(rowData) const configConditions = [ @@ -153,13 +155,13 @@ export default class FormeoRenderer { processColumns = rowId => { return this.orderChildren('columns', this.form.rows[rowId].children).map(column => - this.cacheComponent(this.processColumn(column)) + this.cacheComponent(this.processColumn(column)), ) } processFields = fieldIds => { return this.orderChildren('fields', fieldIds).map(({ id, ...field }) => - this.cacheComponent(Object.assign({}, field, { id: this.prefixId(id) })) + this.cacheComponent(Object.assign({}, field, { id: this.prefixId(id) })), ) } @@ -196,7 +198,7 @@ export default class FormeoRenderer { evt => this.evaluateCondition(ifRest, evt) && thenConditions.forEach(thenCondition => this.execResult(thenCondition, evt)), - false + false, ) } diff --git a/src/lib/sass/base/_animation.scss b/src/lib/sass/base/_animation.scss index e46e1dff..58bd4fee 100644 --- a/src/lib/sass/base/_animation.scss +++ b/src/lib/sass/base/_animation.scss @@ -1,8 +1,8 @@ @use "sass:color" as sass-color; @use "mixins"; -@use 'colors' as color; -@use 'variables' as var; +@use "colors" as color; +@use "variables" as var; /* Animations @@ -56,3 +56,14 @@ box-shadow: 0 0 0 0 color.$brand-info; } } + +@keyframes SLIDE_UP { + 0% { + transform: translateY(100%); + clip-path: inset(0 0 100% -20%); + } + 100% { + transform: translateY(0); + clip-path: inset(0 0 0 -20%); + } +} diff --git a/src/lib/sass/base/_colors.scss b/src/lib/sass/base/_colors.scss index 47f390d8..e3f219b8 100644 --- a/src/lib/sass/base/_colors.scss +++ b/src/lib/sass/base/_colors.scss @@ -13,10 +13,12 @@ $brand-info: rgb(153, 84, 187) !default; $remove-bg: rgba($brand-error, 0.25) !default; -$component-outline-color: #00ffff; -$row-outline-color: $component-outline-color; -$column-outline-color: color.adjust($component-outline-color, $lightness: -15%); -$field-outline-color: color.adjust($component-outline-color, $lightness: -30%); +$row-outline-color: rgb(239, 71, 111); +$column-outline-color: rgb(6, 214, 160); +$field-outline-color: rgb(38, 84, 124); +$row-highlight-color: color.adjust($row-outline-color, $lightness: 36%) !default; +$column-highlight-color: color.adjust($column-outline-color, $lightness: 50%) !default; +$field-highlight-color: color.adjust($field-outline-color, $lightness: 50%) !default; $input-focus: rgba(102, 175, 233, 1); diff --git a/src/lib/sass/base/_icons.scss b/src/lib/sass/base/_icons.scss index e6f14713..b1922ba6 100644 --- a/src/lib/sass/base/_icons.scss +++ b/src/lib/sass/base/_icons.scss @@ -14,6 +14,15 @@ fill: color.$brand-error; } } +button[class*='-move'] { + &:hover { + background-color: color.$brand-info !important; + + .svg-icon { + fill: color.$white; + } + } +} button[class*='-remove'] { &:hover { diff --git a/src/lib/sass/base/_mixins.scss b/src/lib/sass/base/_mixins.scss index 9b728d81..03d909df 100644 --- a/src/lib/sass/base/_mixins.scss +++ b/src/lib/sass/base/_mixins.scss @@ -1,6 +1,6 @@ @use "sass:color" as sass-color; -@use 'colors' as color; -@use 'variables' as var; +@use "colors" as color; +@use "variables" as var; /* Mixins @@ -81,6 +81,12 @@ align-items: stretch; } +@mixin display-column { + display: flex; + flex-direction: column; + justify-content: flex-start; +} + @mixin input-style { font-size: 100%; font-family: inherit; @@ -106,23 +112,6 @@ list-style: none; } -@mixin element-tag { - font-size: 12px; - position: absolute; - top: 0; - width: 0; - padding: 0; - height: var.$action-btn-width - 2; - line-height: var.$action-btn-width; - text-align: center; - overflow: hidden; - z-index: 100; - transition-property: width; - transition-duration: 150ms; - content: attr(data-hover-tag); - background-color: color.$white; -} - @mixin field-control { cursor: move; list-style: none; @@ -151,7 +140,7 @@ box-sizing: border-box; font-size: 1em; line-height: 1.8em; - display: block; + display: flex; height: 100%; width: 100%; background: transparent; @@ -177,14 +166,16 @@ } .control-icon { - float: left; margin-right: space(1); text-align: center; width: var.$icon-size; height: var.$icon-size; + display: flex; + align-items: center; + justify-content: center; } - [dir='rtl'] & { + [dir="rtl"] & { button { text-align: right !important; } @@ -257,7 +248,7 @@ @mixin clearfix { &::after { - content: ''; + content: ""; display: table; clear: both; } diff --git a/src/lib/sass/base/_variables.scss b/src/lib/sass/base/_variables.scss index b8427fb6..4a18d20f 100644 --- a/src/lib/sass/base/_variables.scss +++ b/src/lib/sass/base/_variables.scss @@ -1,4 +1,4 @@ -@use 'colors' as color; +@use "colors" as color; // Sizing $base-space: 8px; @@ -11,7 +11,8 @@ $input-border-radius: $half-space; $input-btn-border-width: 1px; $input-padding: 0.3em 0.6em; $input-border: $input-btn-border-width solid color.$gray-lighter; -$checkbox-character: '✔' !default; -$animation-speed-base: 333ms; +$checkbox-character: "✔" !default; +$animation-speed-base: 266ms; $animation-speed-slow: calc($animation-speed-base * 2); $animation-speed-fast: calc($animation-speed-base / 2); +$component-gap: calc($base-space * 2); diff --git a/src/lib/sass/base/rtl/_group-actions.scss b/src/lib/sass/base/rtl/_group-actions.scss index 467c438b..71992edd 100644 --- a/src/lib/sass/base/rtl/_group-actions.scss +++ b/src/lib/sass/base/rtl/_group-actions.scss @@ -2,43 +2,43 @@ @use '../variables' as var; -.row-actions { - right: -23px; - left: auto; - border-top-right-radius: var.$border-radius; - border-bottom-right-radius: var.$border-radius; - border-top-left-radius: 0; - border-bottom-left-radius: 0; - border-left: 1px solid color.$white; -} +// .row-actions { +// right: -23px; +// left: auto; +// border-top-right-radius: var.$border-radius; +// border-bottom-right-radius: var.$border-radius; +// border-top-left-radius: 0; +// border-bottom-left-radius: 0; +// border-left: 1px solid color.$white; +// } -.field-actions { - text-align: left; - left: 0; - right: auto; - border-bottom-right-radius: var.$input-border-radius; - border-bottom-left-radius: 0; +// .field-actions { +// text-align: left; +// left: 0; +// right: auto; +// border-bottom-right-radius: var.$input-border-radius; +// border-bottom-left-radius: 0; - button { - @for $i from 1 through 6 { - &:nth-of-type(#{$i}) { - left: $i * var.$action-btn-width - var.$action-btn-width; - right: auto; - } - } +// button { +// @for $i from 1 through 6 { +// &:nth-of-type(#{$i}) { +// left: $i * var.$action-btn-width - var.$action-btn-width; +// right: auto; +// } +// } - &:first-child { - left: 0; - } - } -} +// &:first-child { +// left: 0; +// } +// } +// } -.formeo-field { - &.hovering-field, - &.editing-field { - .field-actions { - box-shadow: 1px 1px 1px color.$gray-lighter; - border-width: 1px 0 0 1px; - } - } -} +// .formeo-field { +// &.hovering-field, +// &.editing-field { +// .field-actions { +// box-shadow: 1px 1px 1px color.$gray-lighter; +// border-width: 1px 0 0 1px; +// } +// } +// } diff --git a/src/lib/sass/base/rtl/_row.scss b/src/lib/sass/base/rtl/_row.scss index 0459ddef..c26a9d8d 100644 --- a/src/lib/sass/base/rtl/_row.scss +++ b/src/lib/sass/base/rtl/_row.scss @@ -1,30 +1,29 @@ -@use "../variables"; +@use "../mixins"; +@use "../variables" as var; .formeo-row { &::before { - border-bottom-left-radius: variables.$border-radius; + border-bottom-left-radius: var.$border-radius; border-bottom-right-radius: 0; right: 0; left: auto; } &:first-child { - border-top-left-radius: variables.$border-radius; + border-top-left-radius: var.$border-radius; border-top-right-radius: 0; } &:last-child { - border-bottom-left-radius: variables.$border-radius; - border-bottom-right-radius: variables.$border-radius; + border-bottom-left-radius: var.$border-radius; + border-bottom-right-radius: var.$border-radius; } &.hovering-row { &:first-child { border-top-left-radius: 0; } - } - &.hovering-row { &.editing-row { &::before { border-left-width: 0; diff --git a/src/lib/sass/components/_column.scss b/src/lib/sass/components/_column.scss index 27e8675b..a1e11259 100644 --- a/src/lib/sass/components/_column.scss +++ b/src/lib/sass/components/_column.scss @@ -5,24 +5,48 @@ @use "../base/variables" as var; .formeo-column { + @include mixins.no-list-style; + transition: background-color 125ms ease-in-out, box-shadow 125ms, width 250ms; position: relative; - background-color: color.$white; - max-width: none; + // background-color: color.$white; flex-direction: column; will-change: width; max-width: 100%; + > .children { + @include mixins.display-column; + gap: var.$component-gap; + } + + .column-tag { + .f-i-component-corner { + fill: color.$white; + stroke: color.$column-outline-color; + } + } + + .column-tag, + .column-actions { + transform: translateX(-50%); + } + &[class*="col-"] { padding: 0; } + &:first-child { + border-bottom-left-radius: var.$border-radius; + } - &:first-of-type { - border-top-right-radius: var.$border-radius; + &:last-child { + border-bottom-right-radius: var.$border-radius; + .resize-x-handle { + display: none !important; + } } - &:last-of-type { + &:only-child { border-bottom-right-radius: var.$border-radius; border-bottom-left-radius: var.$border-radius; @@ -34,7 +58,7 @@ .resize-x-handle { display: none; position: absolute; - right: -space(1); + right: -(var.$component-gap); top: 0; bottom: 0; width: mixins.space(2); @@ -44,7 +68,8 @@ &::before { width: 0; right: 6px; - border: 1px dashed sass-color.adjust(color.$column-outline-color, $lightness: 15%); + border: 1px dashed + sass-color.adjust(color.$column-outline-color, $lightness: 15%); border-width: 0 2px; display: block; top: 0; @@ -77,47 +102,6 @@ } } - &::before { - @include mixins.element-tag; - - transition-property: height; - transition-duration: 150ms; - padding: 0 10px; - left: 50%; - top: 1px; - transform: translate(-50%, -100%); - width: auto; - height: 0; - border-top-left-radius: var.$border-radius; - border-top-right-radius: var.$border-radius; - } - - &.hovering-column { - &:first-child { - border-top-left-radius: 0; - } - - .formeo-field { - opacity: 0.5; - } - - &::after { - opacity: 0; - } - } - - &.editing-column, - &.hovering-column { - box-shadow: 0 0 0 1px color.$column-outline-color inset; - - &::before { - height: 23px; - border-right: 1px solid color.$column-outline-color; - border-left: 1px solid color.$column-outline-color; - border-top: 1px solid color.$column-outline-color; - } - } - &.column-moving { box-shadow: 0 0 0 1px color.$column-outline-color inset, 0 0 30px 0 color.$gray-light; @@ -130,10 +114,9 @@ display: block; } } - - @include mixins.no-list-style; } +// this is column styleing in the row edit window .editing-row { .formeo-column, .empty { @@ -176,3 +159,104 @@ display: none; } } + +.hovering-column { + background-color: color.$white; + + .column-tag { + display: flex; + border-color: color.$column-outline-color; + // background-color: color.$column-highlight-color; + } + .column-handle { + display: none; + } + .column-action-btn-wrap { + display: flex; + } + + &:first-child { + border-top-left-radius: 0; + } + + &::after { + opacity: 0; + } +} + +.hovering-column, +.editing-column { + box-shadow: 0 0 0 1px color.$column-outline-color; + + .column-actions { + // border-bottom-right-radius: 0; + // border-bottom-left-radius: var.$input-border-radius; + + .column-action-btn-wrap { + // box-shadow: 0 0 0 1px color.$column-outline-color inset; + + // border: 1px solid color.$column-outline-color; + // border-radius: var.$base-space; + } + + button { + &:first-child { + border-bottom-right-radius: var.$input-border-radius; + right: 0; + } + + &:last-child { + border-bottom-left-radius: var.$input-border-radius; + } + } + } + + &::before { + display: block; + border-right: 1px solid color.$column-outline-color; + border-left: 1px solid color.$column-outline-color; + border-top: 1px solid color.$column-outline-color; + } +} + +.column-actions { + padding: 0; + left: 50%; + z-index: 1; + transition: width 0.15s; + + .f-i-handle { + transform: rotate(90deg); + } + + .action-btn-wrap { + white-space: nowrap; + } + + button { + background-color: transparent; + border-radius: 0; + + @for $i from 1 through 6 { + &:nth-of-type(#{$i}) { + right: $i * var.$action-btn-width - var.$action-btn-width; + } + } + + &:first-child { + border-bottom-right-radius: 0; + right: 0; + + .hovering-column & { + border-bottom-right-radius: 0; + border-bottom-left-radius: 0; + } + } + + &:last-child { + .hovering-column & { + border-bottom-left-radius: var.$input-border-radius; + } + } + } +} diff --git a/src/lib/sass/components/_controls.scss b/src/lib/sass/components/_controls.scss index 33092adc..23049ded 100644 --- a/src/lib/sass/components/_controls.scss +++ b/src/lib/sass/components/_controls.scss @@ -252,6 +252,10 @@ .control-icon { pointer-events: none; + + .f-i-hash { + padding: calc(var.$half-space/2); + } } } } diff --git a/src/lib/sass/components/_field.scss b/src/lib/sass/components/_field.scss index c32f1ac8..6bd33929 100644 --- a/src/lib/sass/components/_field.scss +++ b/src/lib/sass/components/_field.scss @@ -1,17 +1,15 @@ @use "../base/mixins"; - -@use '../base/colors' as color; -@use '../base/variables' as var; - +@use "../base/colors" as color; +@use "../base/variables" as var; .formeo-field { min-height: var.$icon-size; position: relative; - padding: mixins.space(); - transition: background-color var.$animation-speed-base ease-in-out, box-shadow var.$animation-speed-base ease-in-out; + transition: background-color var.$animation-speed-base ease-in-out, + box-shadow var.$animation-speed-base ease-in-out; list-style: none; margin: 0; - will-change: box-shadow; // for highlight + will-change: box-shadow; &:last-child { border-bottom-right-radius: var.$input-border-radius; @@ -26,11 +24,6 @@ } } - &.last-field { - border-bottom-right-radius: var.$input-border-radius; - border-bottom-left-radius: var.$input-border-radius; - } - .prev-label { min-height: var.$icon-size; max-width: calc(100% - #{var.$icon-size}); @@ -47,7 +40,7 @@ min-width: var.$icon-size; &::after { - content: ''; + content: ""; width: 100%; position: absolute; bottom: 0; @@ -74,43 +67,72 @@ position: absolute; } - &::before { - @include mixins.element-tag; + &.field-type-hidden { + border: 1px dashed color.$gray-lighter; + } +} - display: none; +.field-tag { + // display: flex; + right: -1px; + left: auto; + top: -(var.$icon-size); - // remove to animate field title - position: absolute; - top: 0; - padding: 0 10px; - right: 0; - transform: translateX(-(3 * var.$action-btn-width)); - border-bottom-right-radius: 0; - border-bottom-left-radius: var.$border-radius; - } + border-color: color.$field-outline-color; + // background-color: color.$field-highlight-color; + background-color: color.$white; - &.field-type-hidden { - border: 1px dashed color.$gray-lighter; + .f-i-component-corner { + fill: color.$white; + stroke: color.$field-outline-color; } } -.editing-field, .hovering-field { - box-shadow: 0 0 0 1px color.$field-outline-color inset; + background-color: color.$white; + + .field-action-btn-wrap { + display: flex; + } - &::before { - border-left: 1px solid color.$field-outline-color; - border-bottom: 1px solid color.$field-outline-color; + .field-handle { + display: none; } + + // .field-tag { + // display: flex; + // right: -1px; + // left: auto; + // top: -(var.$icon-size); + + // border-color: color.$field-outline-color; + // background-color: color.$field-highlight-color; + // } +} + +.editing-field, +.hovering-field { + box-shadow: 0 0 0 1px color.$field-outline-color; } .field-actions { border-color: transparent; border-width: 1px 1px 0 0; border-style: solid; + + right: 0; + text-align: right; + transition: width 0.166s; + border-bottom-left-radius: var.$input-border-radius; + border-bottom-right-radius: 0; + will-change: width; + + .action-btn-wrap { + flex-direction: row-reverse; + } } .field-moving { - box-shadow: 0 0 0 1px color.$field-outline-color inset, 0 0 30px 0 color.$gray-light; + box-shadow: 0 0 0 1px color.$field-outline-color, 0 0 30px 0 color.$gray-light; background-color: color.$white; } @@ -130,3 +152,13 @@ white-space: normal; } } + +// .hovering-field, +// .editing-field { +// .field-actions { +// box-shadow: -1px 1px 1px color.$gray-lighter; +// border-color: color.$field-outline-color; +// border-width: 1px 1px 0 0; +// border-style: solid; +// } +// } diff --git a/src/lib/sass/components/_group-actions.scss b/src/lib/sass/components/_group-actions.scss index a8639c85..36bad34a 100644 --- a/src/lib/sass/components/_group-actions.scss +++ b/src/lib/sass/components/_group-actions.scss @@ -2,23 +2,42 @@ @use "../base/variables" as var; .group-actions { - min-width: var.$action-btn-width; - width: var.$action-btn-width; - height: var.$action-btn-width; - overflow: hidden; + display: flex; + transition: opacity 0.3s ease-in-out allow-discrete; + position: absolute; top: 0; line-height: 0; z-index: 2; + align-items: center; + justify-content: center; + flex-direction: row; + border-radius: var.$border-radius; + + .action-btn-wrap { + display: none; + align-items: center; + justify-content: center; + + border-top-right-radius: var.$border-radius; + border-bottom-left-radius: var.$border-radius; + border-bottom-right-radius: var.$border-radius; + + transition: opacity 1s ease-in-out allow-discrete; + + .component-handle { + opacity: 0.65; + } + } + button { + background-color: transparent; width: var.$action-btn-width; height: var.$action-btn-width; padding: 6px; border: 0 none; line-height: 0; - overflow: hidden; - background-color: color.$white; &:focus { border: 0 none; @@ -32,10 +51,6 @@ height: calc(var.$action-btn-width / 2); } - .f-i-handle { - opacity: 0.5; - } - .last-field & { button { &:last-child { @@ -43,13 +58,6 @@ } } } - - .f-i-menu, - .f-i-copy, - .f-i-move, - .f-i-move-vertical { - display: none; - } } .column-editing-field { @@ -58,18 +66,6 @@ } } -.formeo-field { - &.hovering-field, - &.editing-field { - .field-actions { - box-shadow: -1px 1px 1px color.$gray-lighter; - border-color: color.$field-outline-color; - border-width: 1px 1px 0 0; - border-style: solid; - } - } -} - .hovering-column, .hovering-row { .field-actions { @@ -81,31 +77,6 @@ } } -.field-actions { - right: 0; - text-align: right; - transition: width 0.166s; - border-bottom-left-radius: var.$input-border-radius; - border-bottom-right-radius: 0; - will-change: width; - overflow: hidden; - - button { - border-radius: 0; - position: absolute; - - @for $i from 1 through 6 { - &:nth-of-type(#{$i}) { - right: $i * var.$action-btn-width - var.$action-btn-width; - } - } - - &:first-child { - right: 0; - } - } -} - .group-config { display: none; padding: 0.5rem; @@ -117,119 +88,16 @@ } } -.column-actions { - width: var.$action-btn-width; - height: var.$action-btn-width; - padding: 0; - right: 50%; - transform: translateX(12px); - z-index: 1; - transition: width 0.15s; - - .action-btn-wrap { - position: relative; - white-space: nowrap; - } - - button { - position: absolute; - background-color: transparent; - border-radius: 0; - - @for $i from 1 through 6 { - &:nth-of-type(#{$i}) { - right: $i * var.$action-btn-width - var.$action-btn-width; - } - } - - &:first-child { - border-bottom-right-radius: 0; - right: 0; - - .hovering-column & { - border-bottom-right-radius: 0; - border-bottom-left-radius: 0; - } - } - - &:last-child { - .hovering-column & { - border-bottom-left-radius: var.$input-border-radius; - } - } - } -} - -.hovering-column, -.editing-column { - .column-actions { - transform: translateX(50%); - width: auto; - border-bottom-right-radius: 0; - border-bottom-left-radius: var.$input-border-radius; - - button { - &:first-child { - border-bottom-right-radius: var.$input-border-radius; - right: 0; - } - - &:last-child { - border-bottom-left-radius: var.$input-border-radius; - } - } +.hovering { + > .children, + > .field-preview, + > .prev-label { + opacity: 0.65; } -} -.row-actions { - width: var.$action-btn-width; - height: var.$action-btn-width; - left: -(var.$action-btn-width - 1); - text-align: right; - border-top-left-radius: var.$border-radius; - border-bottom-left-radius: var.$border-radius; - transition: height 150ms ease-in-out; - white-space: normal; - border: 1px solid color.$gray-lighter; - border-right: 1px solid color.$white; - - .item-handle { + .group-actions { .f-i-handle { - transform: rotate(90deg); - } - } - - button { - border-radius: 0; - } -} - -.hovering-row, -.editing-row { - .row-actions { - border: 1px solid color.$row-outline-color; - - button { - &:first-child { - border-bottom-left-radius: 0; - } - } - } -} - -[class*="hovering-"] { - > .group-actions { - .svg-icon { - &.f-i-menu, - &.f-i-move, - &.f-i-copy, - &.f-i-move-vertical { - display: inline-block !important; - } - - &.f-i-handle { - display: none !important; - } + display: none; } } } diff --git a/src/lib/sass/components/_row.scss b/src/lib/sass/components/_row.scss index f70bce96..1c9cedbd 100644 --- a/src/lib/sass/components/_row.scss +++ b/src/lib/sass/components/_row.scss @@ -1,34 +1,21 @@ @use "../base/mixins"; -@use '../base/colors' as color; -@use '../base/variables' as var; - +@use "../base/colors" as color; +@use "../base/variables" as var; .formeo-row { > .children { @include mixins.display-row; + gap: var.$component-gap; min-height: mixins.space(4); } - @include mixins.clearfix; - transition: background-color 125ms ease-in-out; position: relative; clear: both; - margin-left: 0; - margin-bottom: mixins.space(); background-color: color.$white; - padding: mixins.space(); box-shadow: 0 0 0 1px color.$gray-lighter inset; - - &::before { - @include mixins.element-tag; - - border-bottom-right-radius: var.$border-radius; - border: 1px solid color.$row-outline-color; - border-width: 1px 0; - left: 0; - } + padding: mixins.space(2); &::after { clear: both; @@ -39,89 +26,110 @@ } &:first-child { + border-top-left-radius: var.$border-radius; border-top-right-radius: var.$border-radius; - border-top-left-radius: 0; } &:last-child { - border-bottom-right-radius: var.$border-radius; border-bottom-left-radius: var.$border-radius; + border-bottom-right-radius: var.$border-radius; } - &.hovering-row { - &:first-child { - border-top-left-radius: 0; - } - - &::before { - width: 100px; - } + &:only-child { + border-radius: var.$border-radius; + } + &.resizing-columns { .formeo-column { - opacity: 0.5; + transition: none; } } - &.editing-row { - display: block; + &.row-moving { + box-shadow: 0 0 0 1px color.$row-outline-color inset, + 0 0 30px 0 color.$gray-light; + } - .row-edit { - display: block; + &.empty { + &::after { + left: 0; + transform: translate(mixins.space(1), -50%); } } - &.resizing-columns { - .formeo-column { - transition: none; - } + .layout-row-control { + display: none; } - &.editing-row { - &.hovering-row { - .formeo-column { - opacity: 1; - } + .row-tag { + left: -1px; + border-color: color.$row-outline-color; + + .f-i-component-corner { + fill: color.$white; + stroke: color.$row-outline-color; } } +} - &.editing-row { - box-shadow: 0 0 0 1px color.$row-outline-color inset; +.row-actions { + left: 0; - &::before { - border-width: 1px 0 0; - width: 80px !important; - content: attr(data-editing-hover-tag); - } + // transition: height 150ms ease-in-out; + white-space: normal; + // border-width: 1px 0 1px 1px; + justify-content: flex-start; + align-items: flex-start; + + button { + border-radius: 0; } +} - &.hovering-row { - box-shadow: 0 0 0 1px color.$row-outline-color inset; +.hovering-row { + // background-color: color.$row-highlight-color; - &.editing-row { - &::before { - border-right-width: 0; - } - } + &:first-child { + border-top-left-radius: 0; + } + + box-shadow: 0 0 0 1px color.$row-outline-color; + &.editing-row { &::before { - box-shadow: 1px 1px 1px color.$gray-lighter; - border-right-width: 1px; - width: 80px !important; + border-right-width: 0; } } +} - &.row-moving { - box-shadow: 0 0 0 1px color.$row-outline-color inset, 0 0 30px 0 color.$gray-light; +.editing-row { + display: block; + box-shadow: 0 0 0 1px color.$row-outline-color inset; + + .row-edit { + display: block; } - &.empty { - &::after { - left: 0; - transform: translate(mixins.space(1), -50%); + &.hovering-row { + .formeo-column { + opacity: 1; } } +} - .layout-row-control { +.hovering-row, +.editing-row { + .row-handle { display: none; } + .row-tag { + display: flex; + } + + .row-action-btn-wrap { + display: flex; + button:last-child { + border-bottom-right-radius: var.$border-radius; + } + } } diff --git a/src/lib/sass/components/_stage.scss b/src/lib/sass/components/_stage.scss index a50c6b30..ff7d9c5d 100644 --- a/src/lib/sass/components/_stage.scss +++ b/src/lib/sass/components/_stage.scss @@ -1,5 +1,5 @@ -@use '../base/colors' as color; -@use '../base/variables' as var; +@use "../base/colors" as color; +@use "../base/variables" as var; @use "group-actions"; @use "row"; @@ -9,14 +9,11 @@ @use "field-edit"; @use "../base/mixins"; - - .highlight-component { box-shadow: 0 0 mixins.space() 2px color.$brand-info; } .formeo-stage { - width: 73%; box-sizing: border-box; transition: width 250ms; @@ -31,6 +28,11 @@ padding-bottom: mixins.space(1); overflow: visible; + > .children { + @include mixins.display-column; + gap: var.$component-gap; + } + &.empty { border: 3px dashed color.$gray-lighter; background-color: color.$shadow; @@ -46,7 +48,6 @@ background-color: color.$white; } - @include mixins.no-list-style; @include mixins.breakpoint("phone-lrg") { @@ -95,3 +96,24 @@ .formeo-settings { display: none; } + +.active-hover-row { + .group-actions:not(.hovering .row-actions) { + display: none; + opacity: 0; + } +} + +.active-hover-column { + .group-actions:not(.hovering .column-actions) { + display: none; + opacity: 0; + } +} + +.active-hover-field { + .group-actions:not(.hovering .field-actions) { + display: none; + opacity: 0; + } +} diff --git a/src/lib/sass/components/component.scss b/src/lib/sass/components/component.scss index 11a1efcc..67eb367a 100644 --- a/src/lib/sass/components/component.scss +++ b/src/lib/sass/components/component.scss @@ -1,4 +1,62 @@ -@use '../base/mixins'; +@use "../base/mixins"; +@use "../base/colors" as color; +@use "../base/variables" as var; +@use "../base/animation"; + +.component-tag { + display: none; + height: var.$icon-size; + z-index: 200; + + flex-direction: row; + gap: var.$half-space; + align-items: center; + position: absolute; + font-size: 0.8em; + + padding: 0 var.$base-space; + left: 50%; + top: -(var.$icon-size); + border-top-left-radius: var.$border-radius; + border-top-right-radius: var.$border-radius; + background-color: color.$white; + border-color: color.$gray-lighter; + border-style: solid; + border-width: 1px 1px 0 1px; + + // transform: translateY(100%); + // clip-path: inset(0 -#{var.$icon-size} 100% -#{var.$icon-size}); + // will-change: transform, clip-path; + // transition: transform var.$animation-speed-fast, + // clip-path var.$animation-speed-fast; + + [class*="-handle-"] { + width: calc(var.$half-space * 3); + height: calc(var.$half-space * 3); + } + + .f-i-component-corner { + position: absolute; + width: var.$base-space; + height: var.$base-space; + &.bottom-right { + bottom: 0; + right: -(var.$base-space); + } + &.bottom-left { + bottom: 0; + left: -(var.$base-space); + transform: scaleX(-1); + } + } +} + +.hovering { + > .component-tag { + // transform: translateY(0); + // clip-path: inset(0 -#{var.$icon-size} 0 -#{var.$icon-size}); + } +} .children { @include mixins.no-list-style; diff --git a/src/lib/sass/formeo.scss b/src/lib/sass/formeo.scss index c2435bcf..40f27ca1 100644 --- a/src/lib/sass/formeo.scss +++ b/src/lib/sass/formeo.scss @@ -1,10 +1,9 @@ @use "sass:meta"; -@use 'base/variables'; -@use 'base/animation'; -@use 'base/mixins'; -@use 'base/icons'; -@use 'components/autocomplete'; -@use 'components/panels'; +@use "base/variables"; +@use "base/mixins"; +@use "base/icons"; +@use "components/autocomplete"; +@use "components/panels"; .formeo-sprite { display: none !important; @@ -15,26 +14,26 @@ box-sizing: inherit; } - @include meta.load-css('base/bs'); - - @include mixins.clearfix; + @include meta.load-css("base/bs"); &.formeo-editor { display: flex; flex-direction: row; text-align: left; - @include meta.load-css('components/component'); - @include meta.load-css('components/stage'); + gap: variables.$component-gap; + + @include meta.load-css("components/component"); + @include meta.load-css("components/stage"); - @include meta.load-css('base/rtl'); + @include meta.load-css("base/rtl"); } &.formeo-render { - @include meta.load-css('render'); + @include meta.load-css("render"); } } -@include meta.load-css('components/controls'); +@include meta.load-css("components/controls"); .field-control { @include mixins.field-control; diff --git a/tools/generate-sprite.js b/tools/generate-sprite.js index 75aa5f29..e8b352d1 100644 --- a/tools/generate-sprite.js +++ b/tools/generate-sprite.js @@ -42,7 +42,7 @@ function generateSprite() { 'removeNonInheritableGroupAttrs', { name: 'removeAttrs', - params: { attrs: '(stroke|fill|style|^font-*)' }, + params: { attrs: '(style|^font-*)' }, }, ], },