Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 6 additions & 5 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -71,15 +71,16 @@
"build:demo:watch": "vite build --mode demo --watch",
"build:icons": "node ./tools/generate-sprite",
"lint": "eslint ./src --ext .js || true",
"test": "node --experimental-test-snapshots --require ./tools/test-setup.cjs --test --no-warnings src/**/*.test.js",
"test:updateSnapshots": "node --experimental-test-snapshots --test-update-snapshots --require ./tools/test-setup.cjs --test --no-warnings src/**/*.test.js",
"test:ci": "yarn test --coverage",
"test": "node --experimental-test-snapshots --require ./tools/test-setup.cjs --test --no-warnings src/**/*.test.{js,mjs}",
"test:updateSnapshots": "node --experimental-test-snapshots --test-update-snapshots --require ./tools/test-setup.cjs --test --no-warnings src/**/*.test.{js,mjs}",
"test:ci": "npm test --coverage",
"start": "npm-run-all build:icons dev",
"semantic-release": "semantic-release --ci --debug",
"copy:lang": "node ./tools/copy-directory.mjs ./node_modules/formeo-i18n/dist/lang ./src/demo/assets/lang",
"travis-deploy-once": "travis-deploy-once --pro",
"prepush": "yarn test",
"defaults": "webpack-defaults"
"prepush": "npm test",
"prepare": "lefthook install",
"postmerge": "lefthook install"
},
"devDependencies": {
"@biomejs/biome": "^1.9.3",
Expand Down
91 changes: 28 additions & 63 deletions src/lib/js/common/dom.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ const iconFontTemplates = {
fontello: icon => `<i class="${iconPrefix}${icon}">${icon}</i>`,
}

const inputTags = new Set(['input', 'textarea', 'select'])

/**
* General purpose markup utilities and generator.
*/
Expand All @@ -31,35 +33,31 @@ class DOM {
* Set defaults, store references to key elements
* like stages, rows, columns etc
*/
constructor() {
this.options = Object.create(null)
this.styleSheet = (() => {
const style = document.createElement('style')
style.setAttribute('media', 'screen')
style.setAttribute('type', 'text/css')
style.appendChild(document.createTextNode(''))
document.head.appendChild(style)
return style.sheet
})()
constructor(options = Object.create(null)) {
this.options = options
}

set setOptions(options) {
this.options = merge(Object.assign({}, this.options, options))
this.options = merge(this.options, options)
}

/**
* Ensure elements have proper tagName
* @param {Object|String} elem
* @return {Object} valid element object
*/
processTagName(elem) {
processTagName(elemArg) {
let elem = elemArg
let tagName
if (typeof elem === 'string') {
tagName = elem
elem = { tag: tagName }
return elem
}

if (elem.attrs) {
const { tag, ...restAttrs } = elem.attrs
// this is used for interchangeable tagNames like h1, h2, h3 etc
if (tag) {
if (typeof tag === 'string') {
tagName = tag
Expand Down Expand Up @@ -92,12 +90,11 @@ class DOM {
* @return {Object} DOM Object
*/
create = (elemArg, isPreview = false) => {
let elem = elemArg
if (!elem) {
if (!elemArg) {
return
}

elem = this.processTagName(elem)
const { className, options, ...elem } = this.processTagName(elemArg)
const _this = this
let childType
const { tag } = elem
Expand Down Expand Up @@ -147,41 +144,29 @@ class DOM {
processed.push('tag')

// check for root className property
if (elem.className) {
const { className } = elem
elem.attrs = Object.assign({}, elem.attrs, { className })
delete elem.className
if (className) {
elem.attrs = { ...elem.attrs, className }
}

// Append Element Content
if (elem.options) {
let { options } = elem
options = this.processOptions(options, elem, isPreview)
if (options) {
const processedOptions = this.processOptions(options, elem, isPreview)
if (this.holdsContent(element) && tag !== 'button') {
// mainly used for <select> tag
appendChildren.array.call(this, options)
delete elem.content
appendChildren.array.call(this, processedOptions)
elem.content = undefined
} else {
h.forEach(options, option => {
h.forEach(processedOptions, option => {
wrap.children.push(_this.create(option, isPreview))
})
if (elem.attrs.className) {
wrap.className = elem.attrs.className
}
wrap.config = Object.assign({}, elem.config)
wrap.config = { ...elem.config }
return this.create(wrap, isPreview)
}
processed.push('options')
}

// disabling all initially selected options because we are setting them manualy at #330
if (element.tagName === 'OPTION') {
const timeout = setTimeout(() => {
element.selected = false
clearTimeout(timeout)
}, 0)
}

// Set element attributes
if (elem.attrs) {
_this.processAttrs(elem, element, isPreview)
Expand Down Expand Up @@ -361,17 +346,7 @@ class DOM {
}

if (value) {
// if multiple options are being used and they are being selected automatically, the browser wants the element
// to be rendered before setting the selected-attribute. Hence, we're setting the 'selected'-property at the beginning of
// the next iteration of the event-loop.
if (element.tagName === 'OPTION' && name === 'selected') {
const timeout = setTimeout(() => {
element.setAttribute(name, value)
clearTimeout(timeout)
}, 0)
} else {
element.setAttribute(name, value)
}
element.setAttribute(name, value === true ? '' : value)
}
}
}
Expand Down Expand Up @@ -429,17 +404,6 @@ class DOM {
}
}

makeOption = ([value, label], selected, i18nKey) => {
const option = {
value,
label: i18n.get(`${i18nKey}.${label}`) || label,
}
if (value === selected) {
option.selected = true
}
return option
}

/**
* Extend Array of option config objects
* @param {Array} options
Expand Down Expand Up @@ -558,11 +522,12 @@ class DOM {
* @param {String|Object} tag tagName or DOM element
* @return {Boolean} isInput
*/
isInput(tag) {
isInput(tagArg) {
let tag = tagArg
if (typeof tag !== 'string') {
tag = tag.tagName
}
return ['input', 'textarea', 'select'].indexOf(tag) !== -1
return inputTags.has(tag)
}

/**
Expand Down Expand Up @@ -621,7 +586,7 @@ class DOM {

if (fMap) {
// for attribute will prevent label focus
delete fieldLabel.attrs.for
fieldLabel.attrs.for = undefined
fieldLabel.attrs.contenteditable = true
fieldLabel.fMap = fMap
}
Expand Down Expand Up @@ -707,9 +672,8 @@ class DOM {
if (!children.length) {
if (!this.isStage(parent)) {
return this.removeEmpty(parent)
} else {
this.emptyClass(parent)
}
return this.emptyClass(parent)
}
}

Expand Down Expand Up @@ -833,7 +797,8 @@ class DOM {
* @param {Object} elem DOM element
* @param {Boolean} state
*/
toggleSortable(elem, state) {
toggleSortable(elem, stateArg) {
let state = stateArg
const fType = componentType(elem)
if (!fType) {
return
Expand Down
8 changes: 7 additions & 1 deletion src/lib/js/common/helpers.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,12 @@ export const map = (arr, cb) => {

const sanitizedAttributeNames = {}

/**
* Sanitizes an attribute name to ensure it is valid for use in HTML.
*
* @param {string} name - The attribute name to sanitize.
* @returns {string} - The sanitized attribute name.
*/
export const safeAttrName = name => {
const attributeMap = {
className: 'class',
Expand All @@ -76,7 +82,7 @@ export const safeAttrName = name => {
}

const attributeName = attributeMap[name] || name
const sanitizedAttributeName = attributeName.replace(/^\d/, '').replace(/[^a-zA-Z0-9-:]/g, '')
const sanitizedAttributeName = attributeName.replace(/^\d+/, '').replace(/[^a-zA-Z0-9-:]/g, '')

sanitizedAttributeNames[name] = sanitizedAttributeName

Expand Down
11 changes: 0 additions & 11 deletions src/lib/js/common/helpers.spec.js

This file was deleted.

32 changes: 32 additions & 0 deletions src/lib/js/common/helpers.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { test, suite } from 'node:test'
import { safeAttrName, subtract } from './helpers.mjs'

suite('Helpers', () => {
suite('subtract', () => {
test('should subtract the contents of one array from another', t => {
const remove = ['two']
const from = ['one', 'two', 'three']
t.assert.deepEqual(subtract(remove, from), ['one', 'three'])
})
})

suite('safeAttrName', () => {
test('should return "class" for "className"', t => {
t.assert.strictEqual(safeAttrName('className'), 'class')
})

test('should return sanitized attribute name', t => {
t.assert.strictEqual(safeAttrName('123data-name'), 'data-name')
})

test('should return the same name if no sanitization is needed', t => {
t.assert.strictEqual(safeAttrName('validName'), 'validName')
})

test('should cache sanitized attribute names', t => {
const firstCall = safeAttrName('123data-name')
const secondCall = safeAttrName('123data-name')
t.assert.strictEqual(firstCall, secondCall)
})
})
})
35 changes: 25 additions & 10 deletions src/lib/js/common/utils/index.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
COMPONENT_TYPE_CLASSNAMES_REGEXP,
COMPONENT_TYPE_CLASSNAMES_LOOKUP,
CHILD_TYPE_MAP,
ANIMATION_SPEED_SLOW,
} from '../../constants.js'
import mergeWith from 'lodash/mergeWith.js'

Expand Down Expand Up @@ -273,20 +274,34 @@ export const typeIsChildOf = (childType, parentType) => CHILD_TYPE_MAP.get(paren
* @param {number} limit - The number of milliseconds to throttle invocations to.
* @returns {Function} - Returns the new throttled function.
*/
export function throttle(callback, limit) {
let wait = false
return function () {
if (!wait) {
callback(...arguments)
wait = true
const timeout = setTimeout(() => {
wait = false
clearTimeout(timeout)
}, limit)
export function throttle(callback, limit = ANIMATION_SPEED_SLOW) {
let lastCall = 0
return function (...args) {
const now = Date.now()
if (now - lastCall >= limit) {
lastCall = now
callback.apply(this, args)
}
}
}

/**
* Creates a debounced function that delays invoking the provided function until after the specified delay.
*
* @param {Function} fn - The function to debounce.
* @param {number} [delay=ANIMATION_SPEED_SLOW] - The number of milliseconds to delay invocation.
* @returns {Function} - A new debounced function.
*/
export function debounce(fn, delay = ANIMATION_SPEED_SLOW) {
let timeoutID
return function (...args) {
if (timeoutID) {
clearTimeout(timeoutID)
}
timeoutID = setTimeout(() => fn.apply(this, args), delay)
}
}

export function identity(value) {
return value
}
Expand Down
21 changes: 15 additions & 6 deletions src/lib/js/common/utils/object.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -46,24 +46,33 @@ export function shouldClone(value) {
* @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) {
export function deepClone(obj, seen = new WeakMap()) {
if (!shouldClone(obj)) return obj

if (seen.has(obj)) {
return seen.get(obj)
}

if (Array.isArray(obj)) {
return obj.map(item => deepClone(item))
const clonedArray = obj.map(item => deepClone(item, seen))
seen.set(obj, clonedArray)
return clonedArray
}

const cloned = {}
const clonedObject = {}
seen.set(obj, clonedObject)

for (const key in obj) {
if (Object.hasOwn(obj, key)) {
cloned[key] = deepClone(obj[key])
clonedObject[key] = deepClone(obj[key], seen)
}
}
return cloned

return clonedObject
}

/**
* Merges two action objects. If a key already exists in the target object,
* Merges two action objects. If a key already exists in the target object,
* converts the value to an array and adds the value of the source object's key to the array.
*
* @param {Object} target - The target object to merge into.
Expand Down
Loading