Skip to content

Commit 015210a

Browse files
dummdidummRich-HarrisConduitry
authored
feat: allow objects/arrays for class attribute (#14714)
* 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]>
1 parent 38a3ae3 commit 015210a

File tree

35 files changed

+433
-55
lines changed

35 files changed

+433
-55
lines changed

.changeset/thin-panthers-sing.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'svelte': minor
3+
---
4+
5+
feat: allow `class` attribute to be an object or array, using `clsx`

documentation/docs/03-template-syntax/16-class.md

Lines changed: 0 additions & 23 deletions
This file was deleted.
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
---
2+
title: class
3+
---
4+
5+
There are two ways to set classes on elements: the `class` attribute, and the `class:` directive.
6+
7+
## Attributes
8+
9+
Primitive values are treated like any other attribute:
10+
11+
```svelte
12+
<div class={large ? 'large' : 'small'}>...</div>
13+
```
14+
15+
> [!NOTE]
16+
> 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.
17+
18+
### Objects and arrays
19+
20+
Since Svelte 5.16, `class` can be an object or array, and is converted to a string using [clsx](https://github.com/lukeed/clsx).
21+
22+
If the value is an object, the truthy keys are added:
23+
24+
```svelte
25+
<script>
26+
let { cool } = $props();
27+
</script>
28+
29+
<!-- results in `class="cool"` if `cool` is truthy,
30+
`class="lame"` otherwise -->
31+
<div class={{ cool, lame: !cool }}>...</div>
32+
```
33+
34+
If the value is an array, the truthy values are combined:
35+
36+
```svelte
37+
<!-- if `faded` and `large` are both truthy, results in
38+
`class="saturate-0 opacity-50 scale-200"` -->
39+
<div class={[faded && 'saturate-0 opacity-50', large && 'scale-200']}>...</div>
40+
```
41+
42+
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.
43+
44+
Arrays can contain arrays and objects, and clsx will flatten them. This is useful for combining local classes with props, for example:
45+
46+
```svelte
47+
<!--- file: Button.svelte --->
48+
<script>
49+
let props = $props();
50+
</script>
51+
52+
<button {...props} class={['cool-button', props.class]}>
53+
{@render props.children?.()}
54+
</button>
55+
```
56+
57+
The user of this component has the same flexibility to use a mixture of objects, arrays and strings:
58+
59+
```svelte
60+
<!--- file: App.svelte --->
61+
<script>
62+
import Button from './Button.svelte';
63+
let useTailwind = $state(false);
64+
</script>
65+
66+
<Button
67+
onclick={() => useTailwind = true}
68+
class={{ 'bg-blue-700 sm:w-1/2': useTailwind }}
69+
>
70+
Accept the inevitability of Tailwind
71+
</Button>
72+
```
73+
74+
## The `class:` directive
75+
76+
Prior to Svelte 5.16, the `class:` directive was the most convenient way to set classes on elements conditionally.
77+
78+
```svelte
79+
<!-- These are equivalent -->
80+
<div class={{ cool, lame: !cool }}>...</div>
81+
<div class:cool={cool} class:lame={!cool}>...</div>
82+
```
83+
84+
As with other directives, we can use a shorthand when the name of the class coincides with the value:
85+
86+
```svelte
87+
<div class:cool class:lame={!cool}>...</div>
88+
```
89+
90+
> [!NOTE] Unless you're using an older version of Svelte, consider avoiding `class:`, since the attribute is more powerful and composable.

packages/svelte/elements.d.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -741,7 +741,7 @@ export interface HTMLAttributes<T extends EventTarget> extends AriaAttributes, D
741741
accesskey?: string | undefined | null;
742742
autocapitalize?: 'characters' | 'off' | 'on' | 'none' | 'sentences' | 'words' | undefined | null;
743743
autofocus?: boolean | undefined | null;
744-
class?: string | undefined | null;
744+
class?: string | import('clsx').ClassArray | import('clsx').ClassDictionary | undefined | null;
745745
contenteditable?: Booleanish | 'inherit' | 'plaintext-only' | undefined | null;
746746
contextmenu?: string | undefined | null;
747747
dir?: 'ltr' | 'rtl' | 'auto' | undefined | null;
@@ -1522,7 +1522,7 @@ export interface SvelteWindowAttributes extends HTMLAttributes<Window> {
15221522
export interface SVGAttributes<T extends EventTarget> extends AriaAttributes, DOMAttributes<T> {
15231523
// Attributes which also defined in HTMLAttributes
15241524
className?: string | undefined | null;
1525-
class?: string | undefined | null;
1525+
class?: string | import('clsx').ClassArray | import('clsx').ClassDictionary | undefined | null;
15261526
color?: string | undefined | null;
15271527
height?: number | string | undefined | null;
15281528
id?: string | undefined | null;

packages/svelte/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,7 @@
153153
"acorn-typescript": "^1.4.13",
154154
"aria-query": "^5.3.1",
155155
"axobject-query": "^4.1.0",
156+
"clsx": "^2.1.1",
156157
"esm-env": "^1.2.1",
157158
"esrap": "^1.3.2",
158159
"is-reference": "^3.0.3",

packages/svelte/src/compiler/phases/2-analyze/css/css-prune.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -731,7 +731,7 @@ function attribute_matches(node, name, expected_value, operator, case_insensitiv
731731
/** @type {string[]} */
732732
let prev_values = [];
733733
for (const chunk of chunks) {
734-
const current_possible_values = get_possible_values(chunk);
734+
const current_possible_values = get_possible_values(chunk, name === 'class');
735735

736736
// impossible to find out all combinations
737737
if (!current_possible_values) return true;
@@ -784,7 +784,7 @@ function attribute_matches(node, name, expected_value, operator, case_insensitiv
784784
prev_values.push(current_possible_value);
785785
}
786786
});
787-
if (prev_values.length < current_possible_values.size) {
787+
if (prev_values.length < current_possible_values.length) {
788788
prev_values.push(' ');
789789
}
790790
if (prev_values.length > 20) {

packages/svelte/src/compiler/phases/2-analyze/css/utils.js

Lines changed: 68 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,34 +4,95 @@ const UNKNOWN = {};
44

55
/**
66
* @param {Node} node
7+
* @param {boolean} is_class
78
* @param {Set<any>} set
9+
* @param {boolean} is_nested
810
*/
9-
function gather_possible_values(node, set) {
11+
function gather_possible_values(node, is_class, set, is_nested = false) {
12+
if (set.has(UNKNOWN)) {
13+
// no point traversing any further
14+
return;
15+
}
16+
1017
if (node.type === 'Literal') {
1118
set.add(String(node.value));
1219
} else if (node.type === 'ConditionalExpression') {
13-
gather_possible_values(node.consequent, set);
14-
gather_possible_values(node.alternate, set);
20+
gather_possible_values(node.consequent, is_class, set, is_nested);
21+
gather_possible_values(node.alternate, is_class, set, is_nested);
22+
} else if (node.type === 'LogicalExpression') {
23+
if (node.operator === '&&') {
24+
// && is a special case, because the only way the left
25+
// hand value can be included is if it's falsy. this is
26+
// a bit of extra work but it's worth it because
27+
// `class={[condition && 'blah']}` is common,
28+
// and we don't want to deopt on `condition`
29+
const left = new Set();
30+
gather_possible_values(node.left, is_class, left, is_nested);
31+
32+
if (left.has(UNKNOWN)) {
33+
// add all non-nullish falsy values, unless this is a `class` attribute that
34+
// will be processed by cslx, in which case falsy values are removed, unless
35+
// they're not inside an array/object (TODO 6.0 remove that last part)
36+
if (!is_class || !is_nested) {
37+
set.add('');
38+
set.add(false);
39+
set.add(NaN);
40+
set.add(0); // -0 and 0n are also falsy, but stringify to '0'
41+
}
42+
} else {
43+
for (const value of left) {
44+
if (!value && value != undefined && (!is_class || !is_nested)) {
45+
set.add(value);
46+
}
47+
}
48+
}
49+
50+
gather_possible_values(node.right, is_class, set, is_nested);
51+
} else {
52+
gather_possible_values(node.left, is_class, set, is_nested);
53+
gather_possible_values(node.right, is_class, set, is_nested);
54+
}
55+
} else if (is_class && node.type === 'ArrayExpression') {
56+
for (const entry of node.elements) {
57+
if (entry) {
58+
gather_possible_values(entry, is_class, set, true);
59+
}
60+
}
61+
} else if (is_class && node.type === 'ObjectExpression') {
62+
for (const property of node.properties) {
63+
if (
64+
property.type === 'Property' &&
65+
!property.computed &&
66+
(property.key.type === 'Identifier' || property.key.type === 'Literal')
67+
) {
68+
set.add(
69+
property.key.type === 'Identifier' ? property.key.name : String(property.key.value)
70+
);
71+
} else {
72+
set.add(UNKNOWN);
73+
}
74+
}
1575
} else {
1676
set.add(UNKNOWN);
1777
}
1878
}
1979

2080
/**
2181
* @param {AST.Text | AST.ExpressionTag} chunk
22-
* @returns {Set<string> | null}
82+
* @param {boolean} is_class
83+
* @returns {string[] | null}
2384
*/
24-
export function get_possible_values(chunk) {
85+
export function get_possible_values(chunk, is_class) {
2586
const values = new Set();
2687

2788
if (chunk.type === 'Text') {
2889
values.add(chunk.data);
2990
} else {
30-
gather_possible_values(chunk.expression, values);
91+
gather_possible_values(chunk.expression, is_class, values);
3192
}
3293

3394
if (values.has(UNKNOWN)) return null;
34-
return values;
95+
return [...values].map((value) => String(value));
3596
}
3697

3798
/**

packages/svelte/src/compiler/phases/2-analyze/index.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -773,6 +773,8 @@ export function analyze_component(root, source, options) {
773773

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

777779
class_attribute = attribute;
778780
}

packages/svelte/src/compiler/phases/2-analyze/visitors/Attribute.js

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,19 @@ export function Attribute(node, context) {
3838
mark_subtree_dynamic(context.path);
3939
}
4040

41+
// class={[...]} or class={{...}} or `class={x}` need clsx to resolve the classes
42+
if (
43+
node.name === 'class' &&
44+
!Array.isArray(node.value) &&
45+
node.value !== true &&
46+
node.value.expression.type !== 'Literal' &&
47+
node.value.expression.type !== 'TemplateLiteral' &&
48+
node.value.expression.type !== 'BinaryExpression'
49+
) {
50+
mark_subtree_dynamic(context.path);
51+
node.metadata.needs_clsx = true;
52+
}
53+
4154
if (node.value !== true) {
4255
for (const chunk of get_attribute_chunks(node.value)) {
4356
if (chunk.type !== 'ExpressionTag') continue;

packages/svelte/src/compiler/phases/3-transform/client/visitors/RegularElement.js

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -553,6 +553,10 @@ function build_element_attribute_update_assignment(
553553
let update;
554554

555555
if (name === 'class') {
556+
if (attribute.metadata.needs_clsx) {
557+
value = b.call('$.clsx', value);
558+
}
559+
556560
if (attribute.metadata.expression.has_state && has_call) {
557561
// ensure we're not creating a separate template effect for this so that
558562
// potential class directives are added to the same effect and therefore always apply
@@ -561,11 +565,13 @@ function build_element_attribute_update_assignment(
561565
value = b.call('$.get', id);
562566
has_call = false;
563567
}
568+
564569
update = b.stmt(
565570
b.call(
566571
is_svg ? '$.set_svg_class' : is_mathml ? '$.set_mathml_class' : '$.set_class',
567572
node_id,
568-
value
573+
value,
574+
attribute.metadata.needs_clsx ? b.literal(context.state.analysis.css.hash) : undefined
569575
)
570576
);
571577
} else if (name === 'value') {

packages/svelte/src/compiler/phases/3-transform/server/visitors/shared/element.js

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -86,10 +86,35 @@ export function build_element_attributes(node, context) {
8686
} else if (attribute.name !== 'defaultValue' && attribute.name !== 'defaultChecked') {
8787
if (attribute.name === 'class') {
8888
class_index = attributes.length;
89-
} else if (attribute.name === 'style') {
90-
style_index = attributes.length;
89+
90+
if (attribute.metadata.needs_clsx) {
91+
const clsx_value = b.call(
92+
'$.clsx',
93+
/** @type {AST.ExpressionTag} */ (attribute.value).expression
94+
);
95+
attributes.push({
96+
...attribute,
97+
value: {
98+
.../** @type {AST.ExpressionTag} */ (attribute.value),
99+
expression: context.state.analysis.css.hash
100+
? b.binary(
101+
'+',
102+
b.binary('+', clsx_value, b.literal(' ')),
103+
b.literal(context.state.analysis.css.hash)
104+
)
105+
: clsx_value
106+
}
107+
});
108+
} else {
109+
attributes.push(attribute);
110+
}
111+
} else {
112+
if (attribute.name === 'style') {
113+
style_index = attributes.length;
114+
}
115+
116+
attributes.push(attribute);
91117
}
92-
attributes.push(attribute);
93118
}
94119
} else if (attribute.type === 'BindDirective') {
95120
if (attribute.name === 'value' && node.name === 'select') continue;

packages/svelte/src/compiler/phases/nodes.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,8 @@ export function create_attribute(name, start, end, value) {
4545
value,
4646
metadata: {
4747
expression: create_expression_metadata(),
48-
delegated: null
48+
delegated: null,
49+
needs_clsx: false
4950
}
5051
};
5152
}

packages/svelte/src/compiler/types/template.d.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -482,6 +482,8 @@ export namespace AST {
482482
expression: ExpressionMetadata;
483483
/** May be set if this is an event attribute */
484484
delegated: null | DelegatedEvent;
485+
/** May be `true` if this is a `class` attribute that needs `clsx` */
486+
needs_clsx: boolean;
485487
};
486488
}
487489

packages/svelte/src/internal/client/dom/elements/attributes.js

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {
1313
set_active_effect,
1414
set_active_reaction
1515
} from '../../runtime.js';
16+
import { clsx } from '../../../shared/attributes.js';
1617

1718
/**
1819
* The value/checked attribute in the template actually corresponds to the defaultValue property, so we need
@@ -267,6 +268,10 @@ export function set_attributes(
267268
}
268269
}
269270

271+
if (next.class) {
272+
next.class = clsx(next.class);
273+
}
274+
270275
if (css_hash !== undefined) {
271276
next.class = next.class ? next.class + ' ' + css_hash : css_hash;
272277
}

0 commit comments

Comments
 (0)