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 f18b7dea98..d67890f3fc 100644 --- a/packages/svelte/elements.d.ts +++ b/packages/svelte/elements.d.ts @@ -1547,7 +1547,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; @@ -2076,3 +2076,7 @@ export interface SvelteHTMLElements { } export type ClassValue = string | import('clsx').ClassArray | import('clsx').ClassDictionary; + +export type StyleDictionary = Record; +export type StyleArray = Array; +export type StyleValue = string | number | StyleDictionary | StyleArray | false | null | undefined; diff --git a/packages/svelte/src/internal/shared/attributes.js b/packages/svelte/src/internal/shared/attributes.js index 487a40baf3..da54b897e1 100644 --- a/packages/svelte/src/internal/shared/attributes.js +++ b/packages/svelte/src/internal/shared/attributes.js @@ -108,6 +108,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 || value === '') 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} @@ -125,6 +158,13 @@ function to_css_name(name) { * @returns {string | null} */ export function to_style(value, styles) { + // `class` accepts strings, objects and arrays via clsx; mirror that for `style` + // by normalising non-string values into a CSS declaration string upfront so the + // existing parser (which expects a string) handles directive merging unchanged. + if (value != null && typeof value !== 'string') { + value = style_value_to_string(value); + } + if (styles) { var new_style = ''; 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..19d4ecf8cb --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/style-object/_config.js @@ -0,0 +1,145 @@ +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 (or empty) + assert.notOk(get('all-falsy').getAttribute('style')); + + // empty object → empty style attribute (parallels clsx({}) === '') + const empty_attr = get('empty-object').getAttribute('style'); + assert.ok(empty_attr === '' || empty_attr === 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..79c3d98b28 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/style-object/main.svelte @@ -0,0 +1,82 @@ + + + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ +