You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
svelte/src/compiler/compile/render_dom/wrappers/Element/Attribute.js

422 lines
14 KiB

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

import fix_attribute_casing from './fix_attribute_casing.js';
import { string_literal } from '../../../utils/stringify.js';
import { b, x } from 'code-red';
import handle_select_value_binding from './handle_select_value_binding.js';
import { namespaces } from '../../../../utils/namespaces.js';
import { boolean_attributes } from '../../../../../shared/boolean_attributes.js';
import { regex_double_quotes } from '../../../../utils/patterns.js';
const non_textlike_input_types = new Set([
'button',
'checkbox',
'color',
'date',
'datetime-local',
'file',
'hidden',
'image',
'radio',
'range',
'reset',
'submit'
]);
export class BaseAttributeWrapper {
/** @type {import('../../../nodes/Attribute.js').default} */
node;
/** @type {import('./index.js').default} */
parent;
/**
* @param {import('./index.js').default} parent
* @param {import('../../Block.js').default} block
* @param {import('../../../nodes/Attribute.js').default} node
*/
constructor(parent, block, node) {
this.node = node;
this.parent = parent;
if (node.dependencies.size > 0) {
block.add_dependencies(node.dependencies);
}
}
/** @param {import('../../Block.js').default} _block */
render(_block) {}
}
const regex_minus_sign = /-/;
const regex_invalid_variable_identifier_characters = /[^a-zA-Z_$]/g;
/** @extends BaseAttributeWrapper */
export default class AttributeWrapper extends BaseAttributeWrapper {
/** @type {any} */
metadata;
/** @type {string} */
name;
/** @type {string} */
property_name;
/** @type {boolean} */
is_indirectly_bound_value;
/** @type {boolean} */
is_src;
/** @type {boolean} */
is_select_value_attribute;
/** @type {boolean} */
is_input_value;
/** @type {boolean} */
should_cache;
/** @type {import('estree').Identifier} */
last;
constructor(parent, block, node) {
super(parent, block, node);
if (node.dependencies.size > 0) {
// special case — <option value={foo}> — see below
if (this.parent.node.name === 'option' && node.name === 'value') {
let select = this.parent;
while (select && (select.node.type !== 'Element' || select.node.name !== 'select')) {
// @ts-ignore todo: doublecheck this, but looks to be correct
select = select.parent;
}
if (select && select.select_binding_dependencies) {
select.select_binding_dependencies.forEach((prop) => {
this.node.dependencies.forEach((dependency) => {
this.parent.renderer.component.indirect_dependencies.get(prop).add(dependency);
});
});
}
}
if (node.name === 'value') {
handle_select_value_binding(this, node.dependencies);
this.parent.has_dynamic_value = true;
}
}
if (this.parent.node.namespace == namespaces.foreign) {
// leave attribute case alone for elements in the "foreign" namespace
this.name = this.node.name;
this.metadata = this.get_metadata();
this.is_indirectly_bound_value = false;
this.property_name = null;
this.is_select_value_attribute = false;
this.is_input_value = false;
} else {
this.name = fix_attribute_casing(this.node.name);
this.metadata = this.get_metadata();
this.is_indirectly_bound_value = is_indirectly_bound_value(this);
this.property_name = this.is_indirectly_bound_value
? '__value'
: this.metadata && this.metadata.property_name;
this.is_select_value_attribute = this.name === 'value' && this.parent.node.name === 'select';
this.is_input_value = this.name === 'value' && this.parent.node.name === 'input';
}
// TODO retire this exception in favour of https://github.com/sveltejs/svelte/issues/3750
this.is_src =
this.name === 'src' &&
(!this.parent.node.namespace || this.parent.node.namespace === namespaces.html);
this.should_cache = should_cache(this);
}
/** @param {import('../../Block.js').default} block */
render(block) {
const element = this.parent;
const { name, property_name, should_cache, is_indirectly_bound_value } = this;
// xlink is a special case... we could maybe extend this to generic
// namespaced attributes but I'm not sure that's applicable in
// HTML5?
const method = regex_minus_sign.test(element.node.name)
? '@set_custom_element_data'
: name.slice(0, 6) === 'xlink:'
? '@xlink_attr'
: '@attr';
const is_legacy_input_type =
element.renderer.component.compile_options.legacy &&
name === 'type' &&
this.parent.node.name === 'input';
const dependencies = this.get_dependencies();
const value = this.get_value(block);
/** @type {import('estree').Node[]} */
let updater;
const init = this.get_init(block, value);
if (is_legacy_input_type) {
block.chunks.hydrate.push(b`@set_input_type(${element.var}, ${init});`);
updater = b`@set_input_type(${element.var}, ${should_cache ? this.last : value});`;
} else if (this.is_select_value_attribute) {
// annoying special case
const is_multiple_select = element.node.get_static_attribute_value('multiple');
if (is_multiple_select) {
updater = b`@select_options(${element.var}, ${value});`;
} else {
updater = b`@select_option(${element.var}, ${value});`;
}
block.chunks.mount.push(b`
${updater}
`);
} else if (this.is_src) {
block.chunks.hydrate.push(
b`if (!@src_url_equal(${element.var}.src, ${init})) ${method}(${element.var}, "${name}", ${this.last});`
);
updater = b`${method}(${element.var}, "${name}", ${should_cache ? this.last : value});`;
} else if (property_name) {
block.chunks.hydrate.push(b`${element.var}.${property_name} = ${init};`);
updater = block.renderer.options.dev
? b`@prop_dev(${element.var}, "${property_name}", ${should_cache ? this.last : value});`
: b`${element.var}.${property_name} = ${should_cache ? this.last : value};`;
} else {
block.chunks.hydrate.push(b`${method}(${element.var}, "${name}", ${init});`);
updater = b`${method}(${element.var}, "${name}", ${should_cache ? this.last : value});`;
}
if (is_indirectly_bound_value) {
const update_value = b`@set_input_value(${element.var}, ${element.var}.__value);`;
block.chunks.hydrate.push(update_value);
updater = b`
${updater}
${update_value};
`;
}
if (this.node.name === 'value' && dependencies.length > 0) {
if (this.parent.bindings.some((binding) => binding.node.name === 'group')) {
this.parent.dynamic_value_condition = block.get_unique_name('value_has_changed');
block.add_variable(this.parent.dynamic_value_condition, x`false`);
updater = b`
${updater}
${this.parent.dynamic_value_condition} = true;
`;
}
}
if (dependencies.length > 0) {
const condition = this.get_dom_update_conditions(block, block.renderer.dirty(dependencies));
block.chunks.update.push(b`
if (${condition}) {
${updater}
}`);
}
// special case autofocus. has to be handled in a bit of a weird way
if (name === 'autofocus') {
block.autofocus = {
element_var: element.var,
condition_expression: this.node.is_true ? undefined : value
};
}
}
/**
* @param {import('../../Block.js').default} block
* @param {any} value
*/
get_init(block, value) {
this.last =
this.should_cache &&
block.get_unique_name(
`${this.parent.var.name}_${this.name.replace(
regex_invalid_variable_identifier_characters,
'_'
)}_value`
);
if (this.should_cache) block.add_variable(this.last);
return this.should_cache ? x`${this.last} = ${value}` : value;
}
/**
* @param {import('../../Block.js').default} block
* @param {import('estree').Node} dependency_condition
*/
get_dom_update_conditions(block, dependency_condition) {
const { property_name, should_cache, last } = this;
const element = this.parent;
const value = this.get_value(block);
let condition = dependency_condition;
if (should_cache) {
condition = this.is_src
? x`${condition} && (!@src_url_equal(${element.var}.src, (${last} = ${value})))`
: x`${condition} && (${last} !== (${last} = ${value}))`;
}
if (this.is_input_value) {
const type = element.node.get_static_attribute_value('type');
if (type !== true && !non_textlike_input_types.has(type)) {
condition = x`${condition} && ${element.var}.${property_name} !== ${
should_cache ? last : value
}`;
}
}
if (block.has_outros) {
condition = x`!#current || ${condition}`;
}
return condition;
}
get_dependencies() {
const node_dependencies = this.node.get_dependencies();
const dependencies = new Set(node_dependencies);
node_dependencies.forEach((prop) => {
const indirect_dependencies = this.parent.renderer.component.indirect_dependencies.get(prop);
if (indirect_dependencies) {
indirect_dependencies.forEach((indirect_dependency) => {
dependencies.add(indirect_dependency);
});
}
});
return Array.from(dependencies);
}
get_metadata() {
if (this.parent.node.namespace) return null;
const metadata = attribute_lookup[this.name];
if (metadata && metadata.applies_to && !metadata.applies_to.includes(this.parent.node.name))
return null;
return metadata;
}
/** @param {import('../../Block.js').default} block */
get_value(block) {
if (this.node.is_true) {
if (this.metadata && boolean_attributes.has(this.metadata.property_name.toLowerCase())) {
return x`true`;
}
return x`""`;
}
if (this.node.chunks.length === 0) return x`""`;
// TODO some of this code is repeated in Tag.ts — would be good to
// DRY it out if that's possible without introducing crazy indirection
if (this.node.chunks.length === 1) {
return this.node.chunks[0].type === 'Text'
? string_literal(
/** @type {import('../../../nodes/Text.js').default} */ (this.node.chunks[0]).data
)
: /** @type {import('../../../nodes/shared/Expression.js').default} */ (
this.node.chunks[0]
).manipulate(block);
}
let value =
this.node.name === 'class'
? this.get_class_name_text(block)
: this.render_chunks(block).reduce((lhs, rhs) => x`${lhs} + ${rhs}`);
// '{foo} {bar}' — treat as string concatenation
if (this.node.chunks[0].type !== 'Text') {
value = x`"" + ${value}`;
}
return value;
}
/** @param {import('../../Block.js').default} block */
get_class_name_text(block) {
const scoped_css = this.node.chunks.some(
(/** @type {import('../../../nodes/Text.js').default} */ chunk) => chunk.synthetic
);
const rendered = this.render_chunks(block);
if (scoped_css && rendered.length === 2) {
// we have a situation like class={possiblyUndefined}
rendered[0] = x`@null_to_empty(${rendered[0]})`;
}
return rendered.reduce((lhs, rhs) => x`${lhs} + ${rhs}`);
}
/** @param {import('../../Block.js').default} block */
render_chunks(block) {
return this.node.chunks.map((chunk) => {
if (chunk.type === 'Text') {
return string_literal(chunk.data);
}
return chunk.manipulate(block);
});
}
stringify() {
if (this.node.is_true) return '';
const value = this.node.chunks;
if (value.length === 0) return '=""';
return `="${value
.map((chunk) => {
return chunk.type === 'Text'
? chunk.data.replace(regex_double_quotes, '\\"')
: `\${${chunk.manipulate()}}`;
})
.join('')}"`;
}
}
/**
* @type {{
* [key in import('../../../../../shared/boolean_attributes.js').BooleanAttributes]: { property_name?: string; applies_to?: string[] } } &
* { [key in string]: { property_name?: string; applies_to?: string[] }; }
* }
*/
const attribute_lookup = {
allowfullscreen: { property_name: 'allowFullscreen', applies_to: ['iframe'] },
allowpaymentrequest: { property_name: 'allowPaymentRequest', applies_to: ['iframe'] },
async: { applies_to: ['script'] },
autofocus: { applies_to: ['button', 'input', 'keygen', 'select', 'textarea'] },
autoplay: { applies_to: ['audio', 'video'] },
checked: { applies_to: ['input'] },
controls: { applies_to: ['audio', 'video'] },
default: { applies_to: ['track'] },
defer: { applies_to: ['script'] },
disabled: {
applies_to: [
'button',
'fieldset',
'input',
'keygen',
'optgroup',
'option',
'select',
'textarea'
]
},
formnovalidate: { property_name: 'formNoValidate', applies_to: ['button', 'input'] },
hidden: {},
indeterminate: { applies_to: ['input'] },
inert: {},
ismap: { property_name: 'isMap', applies_to: ['img'] },
loop: { applies_to: ['audio', 'bgsound', 'video'] },
multiple: { applies_to: ['input', 'select'] },
muted: { applies_to: ['audio', 'video'] },
nomodule: { property_name: 'noModule', applies_to: ['script'] },
novalidate: { property_name: 'noValidate', applies_to: ['form'] },
open: { applies_to: ['details', 'dialog'] },
playsinline: { property_name: 'playsInline', applies_to: ['video'] },
readonly: { property_name: 'readOnly', applies_to: ['input', 'textarea'] },
required: { applies_to: ['input', 'select', 'textarea'] },
reversed: { applies_to: ['ol'] },
selected: { applies_to: ['option'] },
value: {
applies_to: [
'button',
'option',
'input',
'li',
'meter',
'progress',
'param',
'select',
'textarea'
]
}
};
Object.keys(attribute_lookup).forEach((name) => {
const metadata = attribute_lookup[name];
if (!metadata.property_name) metadata.property_name = name;
});
/** @param {AttributeWrapper} attribute */
function should_cache(attribute) {
return attribute.is_src || attribute.node.should_cache();
}
const regex_contains_checked_or_group = /checked|group/;
/** @param {AttributeWrapper} attribute */
function is_indirectly_bound_value(attribute) {
const element = attribute.parent;
return (
attribute.name === 'value' &&
(element.node.name === 'option' || // TODO check it's actually bound
(element.node.name === 'input' &&
element.node.bindings.some((binding) =>
regex_contains_checked_or_group.test(binding.name)
)))
);
}