import { isDataAttribute } from '../utilities'; import { ISVGAnalyzer } from '../observation/svg-analyzer'; import { createLookup, createImplementationRegister, resolve } from '@aurelia/kernel'; import { ErrorNames, createMappedError } from '../errors'; import { IAttrMapper, type IsTwoWayPredicate } from '@aurelia/template-compiler'; export class AttrMapper implements IAttrMapper { public static register = /*@__PURE__*/ createImplementationRegister(IAttrMapper); /** @internal */ private readonly fns: IsTwoWayPredicate[] = []; /** @internal */ private readonly _tagAttrMap: Record> = createLookup(); /** @internal */ private readonly _globalAttrMap: Record = createLookup(); private readonly svg = resolve(ISVGAnalyzer); public constructor() { this.useMapping({ LABEL: { for: 'htmlFor' }, IMG: { usemap: 'useMap' }, INPUT: { maxlength: 'maxLength', minlength: 'minLength', formaction: 'formAction', formenctype: 'formEncType', formmethod: 'formMethod', formnovalidate: 'formNoValidate', formtarget: 'formTarget', inputmode: 'inputMode', }, TEXTAREA: { maxlength: 'maxLength' }, TD: { rowspan: 'rowSpan', colspan: 'colSpan' }, TH: { rowspan: 'rowSpan', colspan: 'colSpan' }, }); this.useGlobalMapping({ accesskey: 'accessKey', contenteditable: 'contentEditable', tabindex: 'tabIndex', textcontent: 'textContent', innerhtml: 'innerHTML', scrolltop: 'scrollTop', scrollleft: 'scrollLeft', readonly: 'readOnly', }); } /** * Allow application to teach Aurelia how to define how to map attributes to properties * based on element tagName */ public useMapping(config: Record>): void { let newAttrMapping: Record; let targetAttrMapping: Record; let tagName: string; let attr: string; for (tagName in config) { newAttrMapping = config[tagName]; targetAttrMapping = this._tagAttrMap[tagName] ??= createLookup(); for (attr in newAttrMapping) { if (targetAttrMapping[attr] !== void 0) { throw createError(attr, tagName); } targetAttrMapping[attr] = newAttrMapping[attr]; } } } /** * Allow applications to teach Aurelia how to define how to map attributes to properties * for all elements */ public useGlobalMapping(config: Record): void { const mapper = this._globalAttrMap; for (const attr in config) { if (mapper[attr] !== void 0) { throw createError(attr, '*'); } mapper[attr] = config[attr]; } } /** * Add a given function to a list of fns that will be used * to check if `'bind'` command can be understood as `'two-way'` command. */ public useTwoWay(fn: IsTwoWayPredicate): void { this.fns.push(fn); } /** * Returns true if an attribute should be two way bound based on an element */ public isTwoWay(node: Element, attrName: string): boolean { return shouldDefaultToTwoWay(node, attrName) || this.fns.length > 0 && this.fns.some(fn => fn(node, attrName)); } /** * Retrieves the mapping information this mapper have for an attribute on an element */ public map(node: Element, attr: string): string | null { return this._tagAttrMap[node.nodeName]?.[attr] as string ?? this._globalAttrMap[attr] ?? (isDataAttribute(node, attr, this.svg) ? attr : null); } } function shouldDefaultToTwoWay(element: Element, attr: string): boolean { switch (element.nodeName) { case 'INPUT': switch ((element as HTMLInputElement).type) { case 'checkbox': case 'radio': return attr === 'checked'; // note: // ideally, it should check for corresponding input type first // as 'files' shouldn't be two way on a number input, for example // but doing it this way is acceptable-ish, as the common user expectations, // and the behavior of the control for these properties are the same, // regardless the type of the default: return attr === 'value' || attr === 'files' || attr === 'value-as-number' || attr === 'value-as-date'; } case 'TEXTAREA': case 'SELECT': return attr === 'value'; default: switch (attr) { case 'textcontent': case 'innerhtml': return element.hasAttribute('contenteditable'); case 'scrolltop': case 'scrollleft': return true; default: return false; } } } function createError(attr: string, tagName: string) { return createMappedError(ErrorNames.compiler_attr_mapper_duplicate_mapping, attr, tagName); }