diff --git a/.changeset/style-attribute-objects.md b/.changeset/style-attribute-objects.md new file mode 100644 index 0000000000..63a65575a6 --- /dev/null +++ b/.changeset/style-attribute-objects.md @@ -0,0 +1,5 @@ +--- +'svelte': minor +--- + +feat: allow `style` attribute to be an object or array, mirroring the `class` attribute diff --git a/documentation/docs/03-template-syntax/17-style.md b/documentation/docs/03-template-syntax/17-style.md index 6ddb128f4a..940bec5f34 100644 --- a/documentation/docs/03-template-syntax/17-style.md +++ b/documentation/docs/03-template-syntax/17-style.md @@ -1,8 +1,99 @@ --- -title: style: +title: style tags: template-style --- +There are two ways to set inline styles on elements: the `style` attribute, and the `style:` directive. + +## Attributes + +Primitive values are treated like any other attribute: + +```svelte +
...
+
...
+``` + +### Objects and arrays + +Since Svelte 5.56, `style` can be an object or array, and is converted to a CSS declaration string using the same rules as the [`class` attribute](class). + +If the value is an object, each entry becomes a declaration: + +```svelte + +
...
+``` + +> [!NOTE] +> Object keys are written as the literal CSS property name — `'background-color'`, not `backgroundColor`. Svelte does not convert between `camelCase` and `kebab-case`. + +Entries whose value is `false`, `null`, `undefined` or the empty string are skipped, which is useful for conditional styles: + +```svelte + +
...
+``` + +If the value is an array, the truthy entries are combined: + +```svelte + +
...
+``` + +Arrays can contain arrays, objects and strings, which Svelte flattens. This is useful for combining local styles with props, for example: + +```svelte + + + + +``` + +The user of this component has the same flexibility to use a mixture of objects, arrays and strings: + +```svelte + + + + +``` + +CSS custom properties work the same way: + +```svelte +
...
+``` + +Since Svelte 5.56, Svelte also exposes the `StyleValue` type, which is the type of value that the `style` attribute on elements accepts. This is useful if you want to use a type-safe style value in component props: + +```svelte + + +
...
+``` + +## The `style:` directive + The `style:` directive provides a shorthand for setting multiple styles on an element. ```svelte @@ -35,12 +126,13 @@ To mark a style as important, use the `|important` modifier:
...
``` -When `style:` directives are combined with `style` attributes, the directives will take precedence, -even over `!important` properties: +When `style:` directives are combined with the `style` attribute, the directives take precedence, +even over `!important` properties, and regardless of whether the attribute is a string or an object: ```svelte
This will be red
This will still be red
+
This will be red
``` You can set CSS custom properties: diff --git a/packages/svelte/elements.d.ts b/packages/svelte/elements.d.ts index 15837f831a..810f24e9dc 100644 --- a/packages/svelte/elements.d.ts +++ b/packages/svelte/elements.d.ts @@ -776,7 +776,7 @@ export interface HTMLAttributes extends AriaAttributes, D placeholder?: string | undefined | null; slot?: string | undefined | null; spellcheck?: Booleanish | undefined | null; - style?: string | undefined | null; + style?: StyleValue | undefined | null; tabindex?: number | undefined | null; title?: string | undefined | null; translate?: 'yes' | 'no' | '' | undefined | null; @@ -1550,7 +1550,7 @@ export interface SVGAttributes extends AriaAttributes, DO method?: 'align' | 'stretch' | undefined | null; min?: number | string | undefined | null; name?: string | undefined | null; - style?: string | undefined | null; + style?: StyleValue | undefined | null; target?: string | undefined | null; type?: string | undefined | null; width?: number | string | undefined | null; @@ -2079,3 +2079,6 @@ export interface SvelteHTMLElements { } export type ClassValue = string | import('clsx').ClassArray | import('clsx').ClassDictionary; + +type StylePrimitive = string | number | false | null | undefined; +export type StyleValue = StylePrimitive | StyleValue[] | { [key: string]: StylePrimitive }; diff --git a/packages/svelte/src/internal/shared/attributes.js b/packages/svelte/src/internal/shared/attributes.js index 68994bf85e..1d1365fc52 100644 --- a/packages/svelte/src/internal/shared/attributes.js +++ b/packages/svelte/src/internal/shared/attributes.js @@ -109,6 +109,39 @@ function append_styles(styles, important = false) { return css; } +/** + * Convert a style attribute value (string | object | array | falsy) into a CSS + * declaration string. Mirrors clsx's behaviour for `class`: arrays are flattened, + * falsy entries are dropped, object keys are emitted verbatim (no camelCase ↔ + * kebab-case transform), and pre-formatted strings pass through unchanged. + * @param {any} value + * @returns {string} + */ +function style_value_to_string(value) { + if (value == null || value === false) return ''; + if (typeof value === 'string') return value; + if (Array.isArray(value)) { + var array_result = ''; + for (var i = 0; i < value.length; i++) { + // Strip trailing `;` so concatenating user-authored strings (which often end + // with one) doesn't produce `;;` which can confuse strict CSS parsers. + var part = style_value_to_string(value[i]).replace(/\s*;\s*$/, ''); + if (part) array_result += (array_result ? '; ' : '') + part; + } + return array_result; + } + if (typeof value === 'object') { + var object_result = ''; + for (var key of Object.keys(value)) { + var v = value[key]; + if (v == null || v === false || v === '') continue; + object_result += (object_result ? '; ' : '') + key + ': ' + v; + } + return object_result; + } + return String(value); +} + /** * @param {string} name * @returns {string} @@ -126,6 +159,10 @@ function to_css_name(name) { * @returns {string | null} */ export function to_style(value, styles) { + if (value != null && typeof value !== 'string') { + value = style_value_to_string(value); + } + if (styles) { var new_style = ''; @@ -223,5 +260,7 @@ export function to_style(value, styles) { return new_style === '' ? null : new_style; } - return value == null ? null : String(value); + // Empty results drop the attribute entirely so that client and SSR agree + // (the directive path above already returns `null` for empty output). + return value == null || value === '' ? null : value; } diff --git a/packages/svelte/tests/runtime-runes/samples/async-fork-attributes/_config.js b/packages/svelte/tests/runtime-runes/samples/async-fork-attributes/_config.js index 59bcdeb7f5..7c64866995 100644 --- a/packages/svelte/tests/runtime-runes/samples/async-fork-attributes/_config.js +++ b/packages/svelte/tests/runtime-runes/samples/async-fork-attributes/_config.js @@ -12,8 +12,8 @@ export default test({ ` -

foo

-

foo

+

foo

+

foo

foo

` ); @@ -51,8 +51,8 @@ export default test({ ` -

foo

-

foo

+

foo

+

foo

foo

` ); diff --git a/packages/svelte/tests/runtime-runes/samples/style-object/_config.js b/packages/svelte/tests/runtime-runes/samples/style-object/_config.js new file mode 100644 index 0000000000..b361fe7487 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/style-object/_config.js @@ -0,0 +1,144 @@ +import { flushSync } from 'svelte'; +import { test } from '../../test'; + +export default test({ + mode: ['client', 'hydrate', 'server'], + + async test({ assert, target }) { + const get = (/** @type {string} */ id) => + /** @type {HTMLElement} */ (target.querySelector('#' + id)); + + // --- inline literal cases --- + + // inline object literal + assert.equal(get('inline-object').style.color, 'red'); + assert.equal(get('inline-object').style.backgroundColor, 'blue'); + + // inline array of strings + assert.equal(get('inline-array-strings').style.color, 'red'); + assert.equal(get('inline-array-strings').style.backgroundColor, 'blue'); + + // inline array mixing strings and objects (and stray trailing semicolons) + assert.equal(get('inline-array-mixed').style.color, 'red'); + assert.equal(get('inline-array-mixed').style.padding, '4px'); + assert.equal(get('inline-array-mixed').style.margin, '2px'); + + // numeric values, including 0 + assert.equal(get('numeric').style.zIndex, '0'); + assert.equal(get('numeric').style.opacity, '1'); + assert.equal(get('numeric').style.lineHeight, '1.5'); + + // nested arrays are flattened + assert.equal(get('nested').style.color, 'red'); + assert.equal(get('nested').style.padding, '4px'); + assert.equal(get('nested').style.margin, '2px'); + + // all entries falsy → attribute absent + assert.equal(get('all-falsy').getAttribute('style'), null); + + // empty object → attribute absent + assert.equal(get('empty-object').getAttribute('style'), null); + + // conditional inline object: dropped when the gate is false + assert.equal(get('conditional').style.padding, '4px'); + assert.equal(get('conditional').style.color, ''); + + // `false`/null/undefined values inside an object are skipped per-property + assert.equal(get('falsy-property').style.color, 'red'); + assert.equal(get('falsy-property').style.backgroundColor, ''); + assert.equal(get('falsy-property').style.padding, ''); + assert.equal(get('falsy-property').style.margin, ''); + + // reactive cases via direct $state reads: initial render + assert.equal(get('reactive-object').style.color, 'red'); + assert.equal(get('reactive-object').style.backgroundColor, ''); + assert.equal(get('reactive-array').style.padding, '2px'); + assert.equal(get('reactive-array').style.color, 'red'); + assert.equal(get('reactive-array').style.borderColor, ''); + + // CSS custom properties are emitted verbatim + assert.equal(get('custom-prop').style.getPropertyValue('--my-color'), 'red'); + assert.equal(get('custom-prop').style.getPropertyValue('--scale'), '1'); + + // style: directive wins on overlapping property + assert.equal(get('directive-precedence').style.color, 'blue'); + assert.equal(get('directive-precedence').style.padding, '4px'); + + // spread + assert.equal(get('spread').style.color, 'red'); + assert.equal(get('spread').style.padding, '1px'); + + // --- $derived cases --- + + // $derived returning an object + assert.equal(get('derived-object').style.color, 'red'); + assert.equal(get('derived-object').style.padding, '2px'); + + // $derived returning a mixed array + assert.equal(get('derived-array').style.margin, '2px'); + assert.equal(get('derived-array').style.color, 'red'); + assert.equal(get('derived-array').style.borderWidth, '2px'); + + // $derived returning a string + assert.equal(get('derived-string').style.color, 'red'); + assert.equal(get('derived-string').style.opacity, '1'); + + // $derived nested inside an inline array, alongside a literal + assert.equal(get('derived-in-array').style.outline, '1px solid red'); + assert.equal(get('derived-in-array').style.color, 'red'); + assert.equal(get('derived-in-array').style.padding, '2px'); + + // $derived gated by a condition: when falsy, no attribute should be emitted + assert.notOk(get('derived-conditional').getAttribute('style')); + + // $derived combined with style: directive, directive wins + assert.equal(get('derived-directive').style.color, 'blue'); + assert.equal(get('derived-directive').style.padding, '2px'); + + // $derived inside spread + assert.equal(get('derived-spread').style.color, 'red'); + assert.equal(get('derived-spread').style.padding, '2px'); + + // $derived object with conditional falsy values + assert.equal(get('derived-falsy').style.color, 'red'); + assert.equal(get('derived-falsy').style.backgroundColor, ''); + assert.equal(get('derived-falsy').style.borderColor, 'black'); + + // --- reactivity --- + + const button = /** @type {HTMLButtonElement} */ (target.querySelector('button')); + button.click(); + flushSync(); + + // inline reactive: $state reads recompute + assert.equal(get('reactive-object').style.color, 'green'); + assert.equal(get('reactive-object').style.backgroundColor, 'yellow'); + assert.equal(get('reactive-array').style.color, 'green'); + assert.equal(get('reactive-array').style.borderColor, 'green'); + assert.equal(get('conditional').style.color, 'green'); + assert.equal(get('custom-prop').style.getPropertyValue('--my-color'), 'green'); + assert.equal(get('directive-precedence').style.color, 'blue'); // still wins + assert.equal(get('spread').style.color, 'green'); + + // $derived cases recompute + assert.equal(get('derived-object').style.color, 'green'); + assert.equal(get('derived-object').style.padding, '8px'); + assert.equal(get('derived-array').style.color, 'green'); + assert.equal(get('derived-array').style.borderWidth, '8px'); + assert.equal(get('derived-string').style.color, 'green'); + assert.equal(get('derived-string').style.opacity, '0.5'); + assert.equal(get('derived-in-array').style.color, 'green'); + assert.equal(get('derived-in-array').style.padding, '8px'); + // derived-conditional now resolves to a real object + assert.equal(get('derived-conditional').style.backgroundColor, 'yellow'); + // derived-directive: directive still wins, but padding tracks the derived + assert.equal(get('derived-directive').style.color, 'blue'); + assert.equal(get('derived-directive').style.padding, '8px'); + assert.equal(get('derived-spread').style.color, 'green'); + assert.equal(get('derived-spread').style.padding, '8px'); + // derived-falsy: now `background-color` shows up and `border-color` drops + assert.equal(get('derived-falsy').style.color, 'green'); + assert.equal(get('derived-falsy').style.backgroundColor, 'yellow'); + assert.equal(get('derived-falsy').style.borderColor, ''); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/style-object/main.svelte b/packages/svelte/tests/runtime-runes/samples/style-object/main.svelte new file mode 100644 index 0000000000..e07cb63e18 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/style-object/main.svelte @@ -0,0 +1,82 @@ + + + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + diff --git a/packages/svelte/tests/runtime-runes/samples/style-update/_config.js b/packages/svelte/tests/runtime-runes/samples/style-update/_config.js index 52690a431a..f7ab5ab5ba 100644 --- a/packages/svelte/tests/runtime-runes/samples/style-update/_config.js +++ b/packages/svelte/tests/runtime-runes/samples/style-update/_config.js @@ -40,11 +40,11 @@ export default test({ assert.htmlEqual( target.innerHTML, ` -
-
+
+
- - + + ` );