Skip to content

Commit

Permalink
feat: allow objects/arrays for class attribute (#14714)
Browse files Browse the repository at this point in the history
* WIP

* missed

* fix

* fix

* rename, smooth over incompatibilities

* spread support + test

* docs

* types

* implement CSS pruning for array/object expressions

* beefier static analysis

* lint

* rename doc

* move class after all directive docs

* tweak docs - clarify top-level falsy values, stagger examples, demonstrate composition, discourage class: more strongly

* changeset

* fix

* Update documentation/docs/03-template-syntax/18-class.md

Co-authored-by: Conduitry <[email protected]>

* Apply suggestions from code review

---------

Co-authored-by: Rich Harris <[email protected]>
Co-authored-by: Conduitry <[email protected]>
  • Loading branch information
3 people authored Dec 24, 2024
1 parent 38a3ae3 commit 015210a
Show file tree
Hide file tree
Showing 35 changed files with 433 additions and 55 deletions.
5 changes: 5 additions & 0 deletions .changeset/thin-panthers-sing.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'svelte': minor
---

feat: allow `class` attribute to be an object or array, using `clsx`
23 changes: 0 additions & 23 deletions documentation/docs/03-template-syntax/16-class.md

This file was deleted.

90 changes: 90 additions & 0 deletions documentation/docs/03-template-syntax/18-class.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
---
title: class
---

There are two ways to set classes on elements: the `class` attribute, and the `class:` directive.

## Attributes

Primitive values are treated like any other attribute:

```svelte
<div class={large ? 'large' : 'small'}>...</div>
```

> [!NOTE]
> For historical reasons, falsy values (like `false` and `NaN`) are stringified (`class="false"`), though `class={undefined}` (or `null`) cause the attribute to be omitted altogether. In a future version of Svelte, all falsy values will cause `class` to be omitted.
### Objects and arrays

Since Svelte 5.16, `class` can be an object or array, and is converted to a string using [clsx](https://github.com/lukeed/clsx).

If the value is an object, the truthy keys are added:

```svelte
<script>
let { cool } = $props();
</script>
<!-- results in `class="cool"` if `cool` is truthy,
`class="lame"` otherwise -->
<div class={{ cool, lame: !cool }}>...</div>
```

If the value is an array, the truthy values are combined:

```svelte
<!-- if `faded` and `large` are both truthy, results in
`class="saturate-0 opacity-50 scale-200"` -->
<div class={[faded && 'saturate-0 opacity-50', large && 'scale-200']}>...</div>
```

Note that whether we're using the array or object form, we can set multiple classes simultaneously with a single condition, which is particularly useful if you're using things like Tailwind.

Arrays can contain arrays and objects, and clsx will flatten them. This is useful for combining local classes with props, for example:

```svelte
<!--- file: Button.svelte --->
<script>
let props = $props();
</script>
<button {...props} class={['cool-button', props.class]}>
{@render props.children?.()}
</button>
```

The user of this component has the same flexibility to use a mixture of objects, arrays and strings:

```svelte
<!--- file: App.svelte --->
<script>
import Button from './Button.svelte';
let useTailwind = $state(false);
</script>
<Button
onclick={() => useTailwind = true}
class={{ 'bg-blue-700 sm:w-1/2': useTailwind }}
>
Accept the inevitability of Tailwind
</Button>
```

## The `class:` directive

Prior to Svelte 5.16, the `class:` directive was the most convenient way to set classes on elements conditionally.

```svelte
<!-- These are equivalent -->
<div class={{ cool, lame: !cool }}>...</div>
<div class:cool={cool} class:lame={!cool}>...</div>
```

As with other directives, we can use a shorthand when the name of the class coincides with the value:

```svelte
<div class:cool class:lame={!cool}>...</div>
```

> [!NOTE] Unless you're using an older version of Svelte, consider avoiding `class:`, since the attribute is more powerful and composable.
4 changes: 2 additions & 2 deletions packages/svelte/elements.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -741,7 +741,7 @@ export interface HTMLAttributes<T extends EventTarget> extends AriaAttributes, D
accesskey?: string | undefined | null;
autocapitalize?: 'characters' | 'off' | 'on' | 'none' | 'sentences' | 'words' | undefined | null;
autofocus?: boolean | undefined | null;
class?: string | undefined | null;
class?: string | import('clsx').ClassArray | import('clsx').ClassDictionary | undefined | null;
contenteditable?: Booleanish | 'inherit' | 'plaintext-only' | undefined | null;
contextmenu?: string | undefined | null;
dir?: 'ltr' | 'rtl' | 'auto' | undefined | null;
Expand Down Expand Up @@ -1522,7 +1522,7 @@ export interface SvelteWindowAttributes extends HTMLAttributes<Window> {
export interface SVGAttributes<T extends EventTarget> extends AriaAttributes, DOMAttributes<T> {
// Attributes which also defined in HTMLAttributes
className?: string | undefined | null;
class?: string | undefined | null;
class?: string | import('clsx').ClassArray | import('clsx').ClassDictionary | undefined | null;
color?: string | undefined | null;
height?: number | string | undefined | null;
id?: string | undefined | null;
Expand Down
1 change: 1 addition & 0 deletions packages/svelte/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,7 @@
"acorn-typescript": "^1.4.13",
"aria-query": "^5.3.1",
"axobject-query": "^4.1.0",
"clsx": "^2.1.1",
"esm-env": "^1.2.1",
"esrap": "^1.3.2",
"is-reference": "^3.0.3",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -731,7 +731,7 @@ function attribute_matches(node, name, expected_value, operator, case_insensitiv
/** @type {string[]} */
let prev_values = [];
for (const chunk of chunks) {
const current_possible_values = get_possible_values(chunk);
const current_possible_values = get_possible_values(chunk, name === 'class');

// impossible to find out all combinations
if (!current_possible_values) return true;
Expand Down Expand Up @@ -784,7 +784,7 @@ function attribute_matches(node, name, expected_value, operator, case_insensitiv
prev_values.push(current_possible_value);
}
});
if (prev_values.length < current_possible_values.size) {
if (prev_values.length < current_possible_values.length) {
prev_values.push(' ');
}
if (prev_values.length > 20) {
Expand Down
75 changes: 68 additions & 7 deletions packages/svelte/src/compiler/phases/2-analyze/css/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,34 +4,95 @@ const UNKNOWN = {};

/**
* @param {Node} node
* @param {boolean} is_class
* @param {Set<any>} set
* @param {boolean} is_nested
*/
function gather_possible_values(node, set) {
function gather_possible_values(node, is_class, set, is_nested = false) {
if (set.has(UNKNOWN)) {
// no point traversing any further
return;
}

if (node.type === 'Literal') {
set.add(String(node.value));
} else if (node.type === 'ConditionalExpression') {
gather_possible_values(node.consequent, set);
gather_possible_values(node.alternate, set);
gather_possible_values(node.consequent, is_class, set, is_nested);
gather_possible_values(node.alternate, is_class, set, is_nested);
} else if (node.type === 'LogicalExpression') {
if (node.operator === '&&') {
// && is a special case, because the only way the left
// hand value can be included is if it's falsy. this is
// a bit of extra work but it's worth it because
// `class={[condition && 'blah']}` is common,
// and we don't want to deopt on `condition`
const left = new Set();
gather_possible_values(node.left, is_class, left, is_nested);

if (left.has(UNKNOWN)) {
// add all non-nullish falsy values, unless this is a `class` attribute that
// will be processed by cslx, in which case falsy values are removed, unless
// they're not inside an array/object (TODO 6.0 remove that last part)
if (!is_class || !is_nested) {
set.add('');
set.add(false);
set.add(NaN);
set.add(0); // -0 and 0n are also falsy, but stringify to '0'
}
} else {
for (const value of left) {
if (!value && value != undefined && (!is_class || !is_nested)) {
set.add(value);
}
}
}

gather_possible_values(node.right, is_class, set, is_nested);
} else {
gather_possible_values(node.left, is_class, set, is_nested);
gather_possible_values(node.right, is_class, set, is_nested);
}
} else if (is_class && node.type === 'ArrayExpression') {
for (const entry of node.elements) {
if (entry) {
gather_possible_values(entry, is_class, set, true);
}
}
} else if (is_class && node.type === 'ObjectExpression') {
for (const property of node.properties) {
if (
property.type === 'Property' &&
!property.computed &&
(property.key.type === 'Identifier' || property.key.type === 'Literal')
) {
set.add(
property.key.type === 'Identifier' ? property.key.name : String(property.key.value)
);
} else {
set.add(UNKNOWN);
}
}
} else {
set.add(UNKNOWN);
}
}

/**
* @param {AST.Text | AST.ExpressionTag} chunk
* @returns {Set<string> | null}
* @param {boolean} is_class
* @returns {string[] | null}
*/
export function get_possible_values(chunk) {
export function get_possible_values(chunk, is_class) {
const values = new Set();

if (chunk.type === 'Text') {
values.add(chunk.data);
} else {
gather_possible_values(chunk.expression, values);
gather_possible_values(chunk.expression, is_class, values);
}

if (values.has(UNKNOWN)) return null;
return values;
return [...values].map((value) => String(value));
}

/**
Expand Down
2 changes: 2 additions & 0 deletions packages/svelte/src/compiler/phases/2-analyze/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -773,6 +773,8 @@ export function analyze_component(root, source, options) {

if (attribute.type !== 'Attribute') continue;
if (attribute.name.toLowerCase() !== 'class') continue;
// The dynamic class method appends the hash to the end of the class attribute on its own
if (attribute.metadata.needs_clsx) continue outer;

class_attribute = attribute;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,19 @@ export function Attribute(node, context) {
mark_subtree_dynamic(context.path);
}

// class={[...]} or class={{...}} or `class={x}` need clsx to resolve the classes
if (
node.name === 'class' &&
!Array.isArray(node.value) &&
node.value !== true &&
node.value.expression.type !== 'Literal' &&
node.value.expression.type !== 'TemplateLiteral' &&
node.value.expression.type !== 'BinaryExpression'
) {
mark_subtree_dynamic(context.path);
node.metadata.needs_clsx = true;
}

if (node.value !== true) {
for (const chunk of get_attribute_chunks(node.value)) {
if (chunk.type !== 'ExpressionTag') continue;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -553,6 +553,10 @@ function build_element_attribute_update_assignment(
let update;

if (name === 'class') {
if (attribute.metadata.needs_clsx) {
value = b.call('$.clsx', value);
}

if (attribute.metadata.expression.has_state && has_call) {
// ensure we're not creating a separate template effect for this so that
// potential class directives are added to the same effect and therefore always apply
Expand All @@ -561,11 +565,13 @@ function build_element_attribute_update_assignment(
value = b.call('$.get', id);
has_call = false;
}

update = b.stmt(
b.call(
is_svg ? '$.set_svg_class' : is_mathml ? '$.set_mathml_class' : '$.set_class',
node_id,
value
value,
attribute.metadata.needs_clsx ? b.literal(context.state.analysis.css.hash) : undefined
)
);
} else if (name === 'value') {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -86,10 +86,35 @@ export function build_element_attributes(node, context) {
} else if (attribute.name !== 'defaultValue' && attribute.name !== 'defaultChecked') {
if (attribute.name === 'class') {
class_index = attributes.length;
} else if (attribute.name === 'style') {
style_index = attributes.length;

if (attribute.metadata.needs_clsx) {
const clsx_value = b.call(
'$.clsx',
/** @type {AST.ExpressionTag} */ (attribute.value).expression
);
attributes.push({
...attribute,
value: {
.../** @type {AST.ExpressionTag} */ (attribute.value),
expression: context.state.analysis.css.hash
? b.binary(
'+',
b.binary('+', clsx_value, b.literal(' ')),
b.literal(context.state.analysis.css.hash)
)
: clsx_value
}
});
} else {
attributes.push(attribute);
}
} else {
if (attribute.name === 'style') {
style_index = attributes.length;
}

attributes.push(attribute);
}
attributes.push(attribute);
}
} else if (attribute.type === 'BindDirective') {
if (attribute.name === 'value' && node.name === 'select') continue;
Expand Down
3 changes: 2 additions & 1 deletion packages/svelte/src/compiler/phases/nodes.js
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,8 @@ export function create_attribute(name, start, end, value) {
value,
metadata: {
expression: create_expression_metadata(),
delegated: null
delegated: null,
needs_clsx: false
}
};
}
Expand Down
2 changes: 2 additions & 0 deletions packages/svelte/src/compiler/types/template.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -482,6 +482,8 @@ export namespace AST {
expression: ExpressionMetadata;
/** May be set if this is an event attribute */
delegated: null | DelegatedEvent;
/** May be `true` if this is a `class` attribute that needs `clsx` */
needs_clsx: boolean;
};
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
set_active_effect,
set_active_reaction
} from '../../runtime.js';
import { clsx } from '../../../shared/attributes.js';

/**
* The value/checked attribute in the template actually corresponds to the defaultValue property, so we need
Expand Down Expand Up @@ -267,6 +268,10 @@ export function set_attributes(
}
}

if (next.class) {
next.class = clsx(next.class);
}

if (css_hash !== undefined) {
next.class = next.class ? next.class + ' ' + css_hash : css_hash;
}
Expand Down
Loading

0 comments on commit 015210a

Please sign in to comment.