Packages
noora
0.64.2
0.84.1
0.84.0
0.83.2
0.83.1
0.83.0
0.82.7
0.82.6
0.82.5
0.82.4
0.82.3
0.82.2
0.82.1
0.82.0
0.81.4
0.81.3
0.81.2
0.81.1
0.81.0
0.80.1
0.80.0
0.79.1
0.79.0
0.78.1
0.78.0
0.77.4
0.77.3
0.77.2
0.77.1
0.77.0
0.76.0
0.75.0
0.74.0
0.73.0
0.72.0
0.71.0
0.70.0
0.69.1
0.69.0
0.68.0
0.67.0
0.66.0
0.65.0
0.64.2
0.64.1
0.64.0
0.63.2
0.63.1
0.63.0
0.62.0
0.61.0
0.60.0
0.59.0
0.58.0
0.57.0
0.56.1
0.56.0
0.55.0
0.54.0
0.53.1
0.53.0
0.52.1
0.52.0
0.51.0
0.50.1
0.50.0
0.49.0
0.48.0
0.47.0
0.46.0
0.45.0
0.44.3
0.44.2
0.44.1
0.44.0
0.43.0
0.42.0
0.41.0
0.40.6
0.40.5
0.40.4
0.40.3
0.40.2
0.40.1
0.40.0
0.39.1
0.39.0
0.38.0
0.37.0
0.36.0
0.35.0
0.34.0
0.33.0
0.32.1
0.32.0
0.31.0
0.30.0
0.29.2
0.29.1
0.29.0
0.28.5
0.28.4
0.28.3
0.28.2
0.28.1
0.28.0
0.27.0
0.26.1
0.26.0
0.25.0
0.24.0
0.23.1
0.23.0
0.22.1
0.22.0
0.21.0
0.20.0
0.19.0
0.18.0
0.17.0
0.16.0
0.15.0
0.14.0
0.13.0
0.12.1
0.12.0
0.11.2
0.11.1
0.11.0
0.10.0
0.9.0
0.8.0
0.7.0
0.6.1
0.6.0
0.5.1
0.5.0
0.4.0
0.3.2
0.3.1
0.3.0
0.2.2
0.2.0
0.1.0
0.1.0-rc.2
0.1.0-rc.1
0.1.0-alpha.6
0.1.0-alpha.5
0.1.0-alpha.4
0.1.0-alpha.3
0.1.0-alpha.2
0.1.0-alpha.1
A component library for Phoenix LiveView applications
Current section
Files
Jump to
Current section
Files
js/util.js
import { createNormalizer } from "@zag-js/types";
const propMap = {
onFocus: "onFocusin",
onBlur: "onFocusout",
onChange: "onInput",
onDoubleClick: "onDblclick",
htmlFor: "for",
className: "class",
defaultValue: "value",
defaultChecked: "checked",
};
const prevAttrsMap = new WeakMap();
/**
* Converts a style object to a CSS string
* @param {Object} style - Style object to convert
* @returns {string} CSS string
*/
const toStyleString = (style) => {
return Object.entries(style).reduce((styleString, [key, value]) => {
if (value === null || value === undefined) return styleString;
const formattedKey = key.startsWith("--")
? key
: key.replace(/[A-Z]/g, (match) => `-${match.toLowerCase()}`);
return `${styleString}${formattedKey}:${value};`;
}, "");
};
export const normalizeProps = createNormalizer((props) => {
return Object.entries(props).reduce((acc, [key, value]) => {
if (value === undefined) return acc;
key = propMap[key] || key;
if (key === "style" && typeof value === "object") {
acc.style = toStyleString(value);
} else {
acc[key.toLowerCase()] = value;
}
return acc;
}, {});
});
/**
* Spreads properties onto a DOM node
* @param {HTMLElement} node - Target DOM node
* @param {Object} attrs - Attributes to apply
* @returns {Function} Cleanup function
*/
export const spreadProps = (node, attrs) => {
const oldAttrs = prevAttrsMap.get(node) || {};
const attrKeys = Object.keys(attrs);
const addEvent = (event, callback) => {
node.addEventListener(event.toLowerCase(), callback);
};
const removeEvent = (event, callback) => {
node.removeEventListener(event.toLowerCase(), callback);
};
const onEvents = (attr) => attr.startsWith("on");
const others = (attr) => !attr.startsWith("on");
const setup = (attr) => addEvent(attr.substring(2), attrs[attr]);
const teardown = (attr) => removeEvent(attr.substring(2), attrs[attr]);
const apply = (attrName) => {
let value = attrs[attrName];
const oldValue = oldAttrs[attrName];
if (value === oldValue) return;
if (typeof value === "boolean") {
value = value || undefined;
}
if (value != null) {
if (["value", "checked", "htmlFor"].includes(attrName)) {
node[attrName] = value;
} else {
node.setAttribute(attrName.toLowerCase(), value);
}
return;
}
node.removeAttribute(attrName.toLowerCase());
};
for (const key in oldAttrs) {
if (attrs[key] == null) {
node.removeAttribute(key.toLowerCase());
}
}
const oldEvents = Object.keys(oldAttrs).filter(onEvents);
for (const oldEvent of oldEvents)
removeEvent(oldEvent.substring(2), oldAttrs[oldEvent]);
attrKeys.filter(onEvents).forEach(setup);
attrKeys.filter(others).forEach(apply);
prevAttrsMap.set(node, attrs);
return function cleanup() {
attrKeys.filter(onEvents).forEach(teardown);
};
};
/**
* Generates a CSS selector string for a part name, handling nesting.
*
* @param {string} name The name of the part. Use ':' as a separator for nested parts (e.g., "dialog:title").
* @returns {string} The CSS selector string (e.g., ":scope > [data-part='dialog'] > [data-part='title']").
*/
export const getPartSelector = (name) => {
if (typeof name !== "string" || name.trim() === "") {
console.warn("[getPartSelector] Invalid name provided.");
return;
}
if (name.includes(":")) {
const nameParts = name.split(":");
return (
":scope" + nameParts.map((part) => ` > [data-part='${part}']`).join("")
);
} else {
return `:scope > [data-part='${name}']`;
}
};
/**
* Spreads props onto a specific part element within a root container.
* Handles simple names (e.g., "button") and nested names (e.g., "root:list").
*
* @param {Element} root The root container element.
* @param {string} name The name of the part. Use ':' as a separator for nested parts (e.g., "dialog:title").
* @param {object} api An object containing getter functions for props (e.g., { getButtonProps: () => ({...}), getListProps: () => ({...}) }).
*/
export const renderPart = (root, name, api) => {
const selector = getPartSelector(name);
const getterNamePart = name.includes(":")
? name.substring(name.lastIndexOf(":") + 1)
: name;
const camelizedGetterPart = getterNamePart.replace(
/(^|-)([a-z])/g,
(_match, _prefix, letter) => letter.toUpperCase(),
);
const getterName = `get${camelizedGetterPart}Props`;
const part = root.querySelector(selector);
if (part && typeof api[getterName] === "function") {
spreadProps(part, api[getterName]());
} else if (part && typeof api[getterName] !== "function") {
console.warn(
`[renderPart] Getter function '${getterName}' not found in API for part name '${name}'.`,
);
}
};
/**
* Gets an option value from element's dataset
* @param {HTMLElement} el - Element to check
* @param {string} name - Option name
* @param {string[]} validOptions - Valid option values
* @returns {string|undefined}
*/
export const getOption = (el, name, validOptions) => {
const kebabName = name.replace(/([a-z])([A-Z])/g, "$1-$2").toLowerCase();
// We need to check both for `name` and `kebabName` due to differences between browser engines.
let initial = el.dataset[name] || el.dataset[kebabName];
if (
validOptions &&
initial !== undefined &&
!validOptions.includes(initial)
) {
console.error(
`Invalid '${name}' specified: '${initial}'. Expected one of '${validOptions.join("', '")}'.`,
);
initial = undefined;
}
return initial;
};
/**
* Gets a boolean option from element's dataset
* @param {HTMLElement} el - Element to check
* @param {string} name - Option name
* @returns {boolean}
*/
export const getBooleanOption = (el, name) => {
const kebabName = name.replace(/([a-z])([A-Z])/g, "$1-$2").toLowerCase();
return (
el.dataset[name] === "true" ||
el.dataset[name] === "" ||
el.dataset[kebabName] === "true" ||
el.dataset[kebabName] === ""
);
};
/**
* Gets attributes for a specific part
* @param {HTMLElement} root - Root element
* @param {string} name - Part name
* @returns {Object|undefined}
*/
export const getAttributes = (root, name) => {
const part = root.querySelector(`[data-part='${name}']`);
if (!part) return;
const attrs = [];
for (const attr of part.attributes) {
if (attr.name.startsWith("data-") || attr.name.startsWith("aria-")) {
attrs.push({ name: attr.name, value: attr.value });
}
}
return {
part: name,
cssText: part.style.cssText,
hasFocus: part === document.activeElement,
attrs,
};
};
/**
* Restores attributes to elements
* @param {HTMLElement} root - Root element
* @param {Array} attributeMaps - Array of attribute maps to restore
*/
export const restoreAttributes = (root, attributeMaps) => {
for (const attributeMap of attributeMaps.filter((val) => Boolean(val))) {
const part = root.querySelector(`[data-part='${attributeMap.part}']`);
if (!part) return;
for (const attr of attributeMap.attrs) {
part.setAttribute(attr.name, attr.value);
}
part.style.cssText = attributeMap.cssText;
if (attributeMap.hasFocus) part.focus();
}
};