diff --git a/packages/svelte/src/compiler/phases/1-parse/state/element.js b/packages/svelte/src/compiler/phases/1-parse/state/element.js index 9082b76c49..cac0ed9678 100644 --- a/packages/svelte/src/compiler/phases/1-parse/state/element.js +++ b/packages/svelte/src/compiler/phases/1-parse/state/element.js @@ -496,7 +496,7 @@ function read_attribute(parser) { } } - const name = parser.read_until(regex_token_ending_character); + let name = parser.read_until(regex_token_ending_character); if (!name) return null; let end = parser.index; @@ -512,15 +512,32 @@ function read_attribute(parser) { parser.allow_whitespace(); value = read_attribute_value(parser); end = parser.index; - } else if (parser.match_regex(regex_starts_with_quote_characters)) { + } else if (parser.match_regex(regex_starts_with_quote_characters) && type !== 'ClassDirective') { e.expected_token(parser.index, '='); } if (type) { const [directive_name, ...modifiers] = name.slice(colon_index + 1).split('|'); + /** @type {Array | null} */ + let class_name_value = null; if (directive_name === '') { - e.directive_missing_name({ start, end: start + colon_index + 1 }, name); + if (type !== 'ClassDirective') { + e.directive_missing_name({ start, end: start + colon_index + 1 }, name); + } else { + // class:"..." case + const v = read_attribute_value(parser); + if (!Array.isArray(v)) e.expected_token(v.start, '" or ='); + class_name_value = v; + + if (parser.eat('=')) { + parser.allow_whitespace(); + value = read_attribute_value(parser); + end = parser.index; + } else if (parser.match_regex(regex_starts_with_quote_characters)) { + e.expected_token(parser.index, '='); + } + } } if (type === 'StyleDirective') { @@ -569,6 +586,10 @@ function read_attribute(parser) { } }; + if (directive.type === 'ClassDirective') { + directive.value = class_name_value; + } + if (directive.type === 'TransitionDirective') { const direction = name.slice(0, colon_index); directive.intro = direction === 'in' || direction === 'transition'; diff --git a/packages/svelte/src/compiler/phases/2-analyze/css/css-prune.js b/packages/svelte/src/compiler/phases/2-analyze/css/css-prune.js index a9ea9fe913..ade7906bab 100644 --- a/packages/svelte/src/compiler/phases/2-analyze/css/css-prune.js +++ b/packages/svelte/src/compiler/phases/2-analyze/css/css-prune.js @@ -627,7 +627,13 @@ function relative_selector_might_apply_to_node(relative_selector, rule, element, if ( !attribute_matches(element, 'class', name, '~=', false) && !element.attributes.some( - (attribute) => attribute.type === 'ClassDirective' && attribute.name === name + (attribute) => + attribute.type === 'ClassDirective' && + (attribute.name === name || + (attribute.value && + (attribute.value.length > 1 || + attribute.value[0].type !== 'Text' || + test_attribute('~=', name, false, attribute.value[0].data)))) ) ) { return false; diff --git a/packages/svelte/src/compiler/phases/3-transform/client/visitors/shared/element.js b/packages/svelte/src/compiler/phases/3-transform/client/visitors/shared/element.js index e0fbe11b73..e521f72341 100644 --- a/packages/svelte/src/compiler/phases/3-transform/client/visitors/shared/element.js +++ b/packages/svelte/src/compiler/phases/3-transform/client/visitors/shared/element.js @@ -1,9 +1,9 @@ -/** @import { Expression, Identifier, ObjectExpression } from 'estree' */ -/** @import { AST, Namespace } from '#compiler' */ +/** @import { Expression, ExpressionStatement, Identifier, ObjectExpression } from 'estree' */ +/** @import { AST } from '#compiler' */ /** @import { ComponentClientTransformState, ComponentContext } from '../../types' */ import { normalize_attribute } from '../../../../../../utils.js'; import { is_ignored } from '../../../../../state.js'; -import { get_attribute_expression, is_event_attribute } from '../../../../../utils/ast.js'; +import { is_event_attribute } from '../../../../../utils/ast.js'; import * as b from '../../../../../utils/builders.js'; import { build_getter, create_derived } from '../../utils.js'; import { build_template_literal, build_update } from './utils.js'; @@ -154,7 +154,7 @@ export function build_class_directives( ) { const state = context.state; for (const directive of class_directives) { - const { has_state, has_call } = directive.metadata.expression; + let { has_state, has_call } = directive.metadata.expression; let value = /** @type {Expression} */ (context.visit(directive.expression)); if (has_call) { @@ -164,7 +164,37 @@ export function build_class_directives( value = b.call('$.get', id); } - const update = b.stmt(b.call('$.toggle_class', element_id, b.literal(directive.name), value)); + /** @type {ExpressionStatement} */ + let update; + + if (!directive.value) { + update = b.stmt(b.call('$.toggle_class', element_id, b.literal(directive.name), value)); + } else { + let prev_id; + let { + has_call: h_c, + has_state: h_s, + value: name + } = build_attribute_value(directive.value, context); + + has_call ||= h_c; + has_state ||= h_s; + + if (h_c || h_s) { + prev_id = b.id(state.scope.generate('prev_class_names')); + state.init.push(b.let(prev_id, b.literal(''))); + } + + if (has_call) { + const id = b.id(state.scope.generate('class_directive')); + + state.init.push(b.const(id, create_derived(state, b.thunk(name)))); + name = b.call('$.get', id); + } + + const toggle = b.call('$.toggle_classes', element_id, name, value, prev_id); + update = b.stmt(prev_id ? b.assignment('=', prev_id, toggle) : toggle); + } if (!is_attributes_reactive && has_call) { state.init.push(build_update(update)); diff --git a/packages/svelte/src/compiler/types/template.d.ts b/packages/svelte/src/compiler/types/template.d.ts index fd1824d3b3..11667f6032 100644 --- a/packages/svelte/src/compiler/types/template.d.ts +++ b/packages/svelte/src/compiler/types/template.d.ts @@ -196,8 +196,10 @@ export namespace AST { /** A `class:` directive */ export interface ClassDirective extends BaseNode { type: 'ClassDirective'; - /** The 'x' in `class:x` */ - name: 'class'; + /** The 'x' in `class:x`, empty string in case of `class:"..."` */ + name: string; + /** The 'x' in `class:"x"`, `null` in case of `class:x` (i.e. when no quotes) */ + value: Array | null; /** The 'y' in `class:x={y}`, or the `x` in `class:x` */ expression: Expression; /** @internal */ diff --git a/packages/svelte/src/internal/client/dom/elements/class.js b/packages/svelte/src/internal/client/dom/elements/class.js index 22f3da0f44..57c6d3bee8 100644 --- a/packages/svelte/src/internal/client/dom/elements/class.js +++ b/packages/svelte/src/internal/client/dom/elements/class.js @@ -114,3 +114,21 @@ export function toggle_class(dom, class_name, value) { dom.classList.remove(class_name); } } + +/** + * @param {Element} dom + * @param {string} class_names + * @param {boolean} value + * @param {string} prev_class_names + * @returns {string} + */ +export function toggle_classes(dom, class_names, value, prev_class_names) { + const split_classes = class_names.split(' '); + split_classes.forEach((class_name) => toggle_class(dom, class_name, value)); + prev_class_names.split(' ').forEach((class_name) => { + if (!split_classes.includes(class_name)) { + toggle_class(dom, class_name, false); + } + }); + return class_names; +} diff --git a/packages/svelte/src/internal/client/index.js b/packages/svelte/src/internal/client/index.js index c401867a0f..dda92d896d 100644 --- a/packages/svelte/src/internal/client/index.js +++ b/packages/svelte/src/internal/client/index.js @@ -35,7 +35,13 @@ export { set_value, set_checked } from './dom/elements/attributes.js'; -export { set_class, set_svg_class, set_mathml_class, toggle_class } from './dom/elements/class.js'; +export { + set_class, + set_svg_class, + set_mathml_class, + toggle_class, + toggle_classes +} from './dom/elements/class.js'; export { apply, event, delegate, replay_events } from './dom/elements/events.js'; export { autofocus, remove_textarea_child } from './dom/elements/misc.js'; export { set_style } from './dom/elements/style.js';