Introduce CVA to html-extra

This commit is contained in:
Matheo Daninos
2024-03-09 17:07:31 +01:00
committed by Fabien Potencier
parent 21df1ad782
commit e3ac14e1c8
7 changed files with 1055 additions and 0 deletions
+185
View File
@@ -0,0 +1,185 @@
``html_cva``
============
`CVA (Class Variant Authority)`_ is a concept from the JavaScript world and used
by the well-known `shadcn/ui`_ library.
The CVA concept is used to render multiple variations of components, applying
a set of conditions and recipes to dynamically compose CSS class strings (color, size, etc.),
to create highly reusable and customizable templates.
The concept of CVA is powered by a ``html_cva()`` Twig
function where you define ``base`` classes that should always be present and then different
``variants`` and the corresponding classes:
.. code-block:: html+twig
{# templates/alert.html.twig #}
{% set alert = html_cva(
base='alert ',
variants={
color: {
blue: 'bg-blue',
red: 'bg-red',
green: 'bg-green',
},
size: {
sm: 'text-sm',
md: 'text-md',
lg: 'text-lg',
}
}
) %}
<div class="{{ alert.apply({color, size}, class) }}">
...
</div>
Then use the ``color`` and ``size`` variants to select the needed classes:
.. code-block:: twig
{# index.html.twig #}
{{ include('alert.html.twig', {'color': 'blue', 'size': 'md'}) }}
// class="alert bg-red text-lg"
{{ include('alert.html.twig', {'color': 'green', 'size': 'sm'}) }}
// class="alert bg-green text-sm"
{{ include('alert.html.twig', {'color': 'red', 'class': 'flex items-center justify-center'}) }}
// class="alert bg-red text-md flex items-center justify-center"
CVA and Tailwind CSS
--------------------
CVA work perfectly with Tailwind CSS. The only drawback is that you can have class conflicts.
To "merge" conflicting classes together and keep only the ones you need, use the
``tailwind_merge()`` filter from `tales-from-a-dev/twig-tailwind-extra`_
with the ``html_cva()`` function:
.. code-block:: terminal
$ composer require tales-from-a-dev/twig-tailwind-extra
.. code-block:: html+twig
{% set alert = html_cva(
// ...
) %}
<div class="{{ alert.apply({color, size}, class)|tailwind_merge }}">
...
</div>
Compound Variants
-----------------
You can define compound variants. A compound variant is a variant that applies
when multiple other variant conditions are met:
.. code-block:: html+twig
{% set alert = html_cva(
base='alert',
variants={
color: {
blue: 'bg-blue',
red: 'bg-red',
green: 'bg-green',
},
size: {
sm: 'text-sm',
md: 'text-md',
lg: 'text-lg',
}
},
compoundVariants=[{
// if color = red AND size = (md or lg), add the `font-bold` class
color: ['red'],
size: ['md', 'lg'],
class: 'font-bold'
}]
) %}
<div class="{{ alert.apply({color, size}) }}">
...
</div>
{# index.html.twig #}
{{ include('alert.html.twig', {color: 'red', size: 'lg'}) }}
// class="alert bg-red text-lg font-bold"
{{ include('alert.html.twig', {color: 'green', size: 'sm'}) }}
// class="alert bg-green text-sm"
{{ include('alert.html.twig', {color: 'red', size: 'md'}) }}
// class="alert bg-green text-lg font-bold"
Default Variants
----------------
If no variants match, you can define a default set of classes to apply:
.. code-block:: html+twig
{% set alert = html_cva(
base='alert ',
variants={
color: {
blue: 'bg-blue',
red: 'bg-red',
green: 'bg-green',
},
size: {
sm: 'text-sm',
md: 'text-md',
lg: 'text-lg',
},
rounded: {
sm: 'rounded-sm',
md: 'rounded-md',
lg: 'rounded-lg',
}
},
defaultVariants={
rounded: 'md',
}
) %}
<div class="{{ alert.apply({color, size}) }}">
...
</div>
{# index.html.twig #}
{{ include('alert.html.twig', {color: 'red', size: 'lg'}) }}
// class="alert bg-red text-lg font-bold rounded-md"
.. note::
The ``html_cva`` function is part of the ``HtmlExtension`` which is not
installed by default. Install it first:
.. code-block:: bash
$ composer require twig/html-extra
Then, on Symfony projects, install the ``twig/extra-bundle``:
.. code-block:: bash
$ composer require twig/extra-bundle
Otherwise, add the extension explicitly on the Twig environment::
use Twig\Extra\Html\HtmlExtension;
$twig = new \Twig\Environment(...);
$twig->addExtension(new HtmlExtension());
This function works best when used with `TwigComponent`_.
.. _`CVA (Class Variant Authority)`: https://cva.style/docs/getting-started/variants
.. _`shadcn/ui`: https://ui.shadcn.com
.. _`tales-from-a-dev/twig-tailwind-extra`: https://github.com/tales-from-a-dev/twig-tailwind-extra
.. _`TwigComponent`: https://symfony.com/bundles/ux-twig-component/current/index.html
+129
View File
@@ -0,0 +1,129 @@
<?php
/*
* This file is part of Twig.
*
* (c) Fabien Potencier
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Twig\Extra\Html;
/**
* Class Variant Authority (CVA) resolver.
*
* @author Mathéo Daninos <matheo.daninos@gmail.com>
*/
final class Cva
{
/**
* @var list<string|null>
*/
private array $base;
/**
* @param string|list<string|null> $base The base classes to apply to the component
*/
public function __construct(
string|array $base = [],
/**
* The variants to apply based on recipes.
*
* Format: [variantCategory => [variantName => classes]]
*
* Example:
* 'colors' => [
* 'primary' => 'bleu-8000',
* 'danger' => 'red-800 text-bold',
* ],
* 'size' => [...],
*
* @var array<string, array<string, string|list<string>>>
*/
private array $variants = [],
/**
* The compound variants to apply based on recipes.
*
* Format: [variantsCategory => ['variantName', 'variantName'], class: classes]
*
* Example:
* [
* 'colors' => ['primary'],
* 'size' => ['small'],
* 'class' => 'text-red-500',
* ],
* [
* 'size' => ['large'],
* 'class' => 'font-weight-500',
* ]
*
* @var array<array<string, string|array<string>>>
*/
private array $compoundVariants = [],
/**
* The default variants to apply if specific recipes aren't provided.
*
* Format: [variantCategory => variantName]
*
* Example:
* 'colors' => 'primary',
*
* @var array<string, string>
*/
private array $defaultVariants = [],
) {
$this->base = (array) $base;
}
public function apply(array $recipes, ?string ...$additionalClasses): string
{
$classes = $this->base;
// Resolve recipes against variants
foreach ($recipes as $recipeName => $recipeValue) {
if (\is_bool($recipeValue)) {
$recipeValue = $recipeValue ? 'true' : 'false';
}
$recipeClasses = $this->variants[$recipeName][$recipeValue] ?? [];
$classes = [...$classes, ...(array) $recipeClasses];
}
// Resolve compound variants
foreach ($this->compoundVariants as $compound) {
$compoundClasses = $this->resolveCompoundVariant($compound, $recipes) ?? [];
$classes = [...$classes, ...$compoundClasses];
}
// Apply default variants if specific recipes aren't provided
foreach ($this->defaultVariants as $defaultVariantName => $defaultVariantValue) {
if (!isset($recipes[$defaultVariantName])) {
$variantClasses = $this->variants[$defaultVariantName][$defaultVariantValue] ?? [];
$classes = [...$classes, ...(array) $variantClasses];
}
}
$classes = [...$classes, ...array_values($additionalClasses)];
$classes = implode(' ', array_filter($classes, 'is_string'));
$classes = preg_split('#\s+#', $classes, -1, \PREG_SPLIT_NO_EMPTY) ?: [];
return implode(' ', array_unique($classes));
}
private function resolveCompoundVariant(array $compound, array $recipes): array
{
foreach ($compound as $compoundName => $compoundValues) {
if ('class' === $compoundName) {
continue;
}
if (!isset($recipes[$compoundName]) || !\in_array($recipes[$compoundName], (array) $compoundValues)) {
return [];
}
}
return (array) ($compound['class'] ?? []);
}
}
+14
View File
@@ -37,6 +37,7 @@ final class HtmlExtension extends AbstractExtension
{
return [
new TwigFunction('html_classes', [self::class, 'htmlClasses']),
new TwigFunction('html_cva', [self::class, 'htmlCva']),
];
}
@@ -110,4 +111,17 @@ final class HtmlExtension extends AbstractExtension
return implode(' ', array_unique(array_filter($classes, static function ($v) { return '' !== $v; })));
}
/**
* @param string|list<string|null> $base
* @param array<string, array<string, string|array<string>> $variants
* @param array<array<string, string|array<string>>> $compoundVariants
* @param array<string, string> $defaultVariant
*
* @internal
*/
public static function htmlCva(array|string $base = [], array $variants = [], array $compoundVariants = [], array $defaultVariant = []): Cva
{
return new Cva($base, $variants, $compoundVariants, $defaultVariant);
}
}
+3
View File
@@ -9,5 +9,8 @@ This package is a Twig extension that provides the following:
* [`html_classes`][2] function: returns a string by conditionally joining class
names together.
* [`html_cva`][3] function: returns a `Cva` object to handle class variants.
[1]: https://twig.symfony.com/data_uri
[2]: https://twig.symfony.com/html_classes
[3]: https://twig.symfony.com/html_cva
+668
View File
@@ -0,0 +1,668 @@
<?php
/*
* This file is part of Twig.
*
* (c) Fabien Potencier
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Twig\Extra\Html\Tests;
use PHPUnit\Framework\TestCase;
use Twig\Extra\Html\Cva;
class CvaTest extends TestCase
{
/**
* @dataProvider recipeProvider
*/
public function testRecipes(array $recipe, array $recipes, string $expected)
{
$recipeClass = new Cva($recipe['base'] ?? '', $recipe['variants'] ?? [], $recipe['compounds'] ?? [], $recipe['defaultVariants'] ?? []);
$this->assertEquals($expected, $recipeClass->apply($recipes));
}
public function testApply()
{
$recipe = new Cva('font-semibold border rounded', [
'colors' => [
'primary' => 'text-primary',
'secondary' => 'text-secondary',
],
'sizes' => [
'sm' => 'text-sm',
'md' => 'text-md',
'lg' => 'text-lg',
],
], [
[
'colors' => ['primary'],
'sizes' => ['sm'],
'class' => 'text-red-500',
],
]);
$this->assertEquals('font-semibold border rounded text-primary text-sm text-red-500', $recipe->apply(['colors' => 'primary', 'sizes' => 'sm']));
}
public function testApplyWithNullString()
{
$recipe = new Cva('font-semibold border rounded', [
'colors' => [
'primary' => 'text-primary',
'secondary' => 'text-secondary',
],
'sizes' => [
'sm' => 'text-sm',
'md' => 'text-md',
'lg' => 'text-lg',
],
], [
[
'colors' => ['primary'],
'sizes' => ['sm'],
'class' => 'text-red-500',
],
]);
$this->assertEquals('font-semibold border rounded text-primary text-sm text-red-500 flex justify-center', $recipe->apply(['colors' => 'primary', 'sizes' => 'sm'], 'flex', null, 'justify-center'));
}
public static function recipeProvider(): iterable
{
yield 'base null' => [
['variants' => [
'colors' => [
'primary' => 'text-primary',
'secondary' => 'text-secondary',
],
'sizes' => [
'sm' => 'text-sm',
'md' => 'text-md',
'lg' => 'text-lg',
],
]],
['colors' => 'primary', 'sizes' => 'sm'],
'text-primary text-sm',
];
yield 'base empty' => [
[
'base' => '',
'variants' => [
'colors' => [
'primary' => 'text-primary',
'secondary' => 'text-secondary',
],
'sizes' => [
'sm' => 'text-sm',
'md' => 'text-md',
'lg' => 'text-lg',
],
]],
['colors' => 'primary', 'sizes' => 'sm'],
'text-primary text-sm',
];
yield 'base array' => [
[
'base' => ['font-semibold', 'border', 'rounded'],
'variants' => [
'colors' => [
'primary' => 'text-primary',
'secondary' => 'text-secondary',
],
'sizes' => [
'sm' => 'text-sm',
'md' => 'text-md',
'lg' => 'text-lg',
],
]],
['colors' => 'primary', 'sizes' => 'sm'],
'font-semibold border rounded text-primary text-sm',
];
yield 'no recipes match' => [
[
'base' => 'font-semibold border rounded',
'variants' => [
'colors' => [
'primary' => 'text-primary',
'secondary' => 'text-secondary',
],
'sizes' => [
'sm' => 'text-sm',
'md' => 'text-md',
'lg' => 'text-lg',
],
],
],
['colors' => 'red', 'sizes' => 'test'],
'font-semibold border rounded',
];
yield 'simple variants' => [
[
'base' => 'font-semibold border rounded',
'variants' => [
'colors' => [
'primary' => 'text-primary',
'secondary' => 'text-secondary',
],
'sizes' => [
'sm' => 'text-sm',
'md' => 'text-md',
'lg' => 'text-lg',
],
],
],
['colors' => 'primary', 'sizes' => 'sm'],
'font-semibold border rounded text-primary text-sm',
];
yield 'simple variants as array' => [
[
'base' => 'font-semibold border rounded',
'variants' => [
'colors' => [
'primary' => ['text-primary', 'uppercase'],
'secondary' => ['text-secondary', 'uppercase'],
],
'sizes' => [
'sm' => 'text-sm',
'md' => 'text-md',
'lg' => 'text-lg',
],
],
],
['colors' => 'primary', 'sizes' => 'sm'],
'font-semibold border rounded text-primary uppercase text-sm',
];
yield 'simple variants with custom' => [
[
'base' => 'font-semibold border rounded',
'variants' => [
'colors' => [
'primary' => 'text-primary',
'secondary' => 'text-secondary',
],
'sizes' => [
'sm' => 'text-sm',
'md' => 'text-md',
'lg' => 'text-lg',
],
],
],
['colors' => 'secondary', 'sizes' => 'md'],
'font-semibold border rounded text-secondary text-md',
];
yield 'compound variants' => [
[
'base' => 'font-semibold border rounded',
'variants' => [
'colors' => [
'primary' => 'text-primary',
'secondary' => 'text-secondary',
],
'sizes' => [
'sm' => 'text-sm',
'md' => 'text-md',
'lg' => 'text-lg',
],
],
'compounds' => [
[
'colors' => 'primary',
'sizes' => ['sm'],
'class' => 'text-red-100',
],
],
],
['colors' => 'primary', 'sizes' => 'sm'],
'font-semibold border rounded text-primary text-sm text-red-100',
];
yield 'compound variants with true' => [
[
'base' => 'button',
'variants' => [
'colors' => [
'blue' => 'btn-blue',
'red' => 'btn-red',
],
'disabled' => [
'true' => 'disabled',
],
],
'compounds' => [
[
'colors' => 'blue',
'disabled' => ['true'],
'class' => 'font-bold',
],
],
],
['colors' => 'blue', 'disabled' => 'true'],
'button btn-blue disabled font-bold',
];
yield 'compound variants as array' => [
[
'base' => 'font-semibold border rounded',
'variants' => [
'colors' => [
'primary' => 'text-primary',
'secondary' => 'text-secondary',
],
'sizes' => [
'sm' => 'text-sm',
'md' => 'text-md',
'lg' => 'text-lg',
],
],
'compounds' => [
[
'colors' => ['primary'],
'sizes' => ['sm'],
'class' => ['text-red-900', 'bold'],
],
],
],
['colors' => 'primary', 'sizes' => 'sm'],
'font-semibold border rounded text-primary text-sm text-red-900 bold',
];
yield 'multiple compound variants' => [
[
'base' => 'font-semibold border rounded',
'variants' => [
'colors' => [
'primary' => 'text-primary',
'secondary' => 'text-secondary',
],
'sizes' => [
'sm' => 'text-sm',
'md' => 'text-md',
'lg' => 'text-lg',
],
],
'compounds' => [
[
'colors' => ['primary'],
'sizes' => ['sm'],
'class' => 'text-red-300',
],
[
'colors' => ['primary'],
'sizes' => ['md'],
'class' => 'text-blue-300',
],
],
],
['colors' => 'primary', 'sizes' => 'sm'],
'font-semibold border rounded text-primary text-sm text-red-300',
];
yield 'compound with multiple variants' => [
[
'base' => 'font-semibold border rounded',
'variants' => [
'colors' => [
'primary' => 'text-primary',
'secondary' => 'text-secondary',
],
'sizes' => [
'sm' => 'text-sm',
'md' => 'text-md',
'lg' => 'text-lg',
],
],
'compounds' => [
[
'colors' => ['primary', 'secondary'],
'sizes' => ['sm'],
'class' => 'text-red-800',
],
],
],
['colors' => 'primary', 'sizes' => 'sm'],
'font-semibold border rounded text-primary text-sm text-red-800',
];
yield 'compound doesn\'t match' => [
[
'base' => 'font-semibold border rounded',
'variants' => [
'colors' => [
'primary' => 'text-primary',
'secondary' => 'text-secondary',
],
'sizes' => [
'sm' => 'text-sm',
'md' => 'text-md',
'lg' => 'text-lg',
],
],
'compounds' => [
[
'colors' => ['danger', 'secondary'],
'sizes' => ['sm'],
'class' => 'text-red-500',
],
],
],
['colors' => 'primary', 'sizes' => 'sm'],
'font-semibold border rounded text-primary text-sm',
];
yield 'default variables' => [
[
'base' => 'font-semibold border rounded',
'variants' => [
'colors' => [
'primary' => 'text-primary',
'secondary' => 'text-secondary',
],
'sizes' => [
'sm' => 'text-sm',
'md' => 'text-md',
'lg' => 'text-lg',
],
'rounded' => [
'sm' => 'rounded-sm',
'md' => 'rounded-md',
'lg' => 'rounded-lg',
],
],
'compounds' => [
[
'colors' => ['danger', 'secondary'],
'sizes' => 'sm',
'class' => 'text-red-500',
],
],
'defaultVariants' => [
'colors' => 'primary',
'sizes' => 'sm',
'rounded' => 'md',
],
],
['colors' => 'primary', 'sizes' => 'sm'],
'font-semibold border rounded text-primary text-sm rounded-md',
];
yield 'default variables all overwrite' => [
[
'base' => 'font-semibold border rounded',
'variants' => [
'colors' => [
'primary' => 'text-primary',
'secondary' => 'text-secondary',
],
'sizes' => [
'sm' => 'text-sm',
'md' => 'text-md',
'lg' => 'text-lg',
],
'rounded' => [
'sm' => 'rounded-sm',
'md' => 'rounded-md',
'lg' => 'rounded-lg',
],
],
'compounds' => [
[
'colors' => ['danger', 'secondary'],
'sizes' => ['sm'],
'class' => 'text-red-500',
],
],
'defaultVariants' => [
'colors' => 'primary',
'sizes' => 'sm',
'rounded' => 'md',
],
],
['colors' => 'primary', 'sizes' => 'sm', 'rounded' => 'lg'],
'font-semibold border rounded text-primary text-sm rounded-lg',
];
yield 'default variables without matching variants' => [
[
'base' => 'font-semibold border rounded',
'variants' => [
'colors' => [
'primary' => 'text-primary',
'secondary' => 'text-secondary',
],
'sizes' => [
'sm' => 'text-sm',
'md' => 'text-md',
'lg' => 'text-lg',
],
'rounded' => [
'sm' => 'rounded-sm',
'md' => 'rounded-md',
'lg' => 'rounded-lg',
],
],
'compounds' => [
[
'colors' => ['danger', 'secondary'],
'sizes' => ['sm'],
'class' => 'text-red-500',
],
],
'defaultVariants' => [
'colors' => 'primary',
'sizes' => 'sm',
'rounded' => 'md',
],
],
[],
'font-semibold border rounded text-primary text-sm rounded-md',
];
yield 'default variables with boolean' => [
[
'base' => 'button',
'variants' => [
'colors' => [
'blue' => 'btn-blue',
'red' => 'btn-red',
],
'disabled' => [
'true' => 'disabled',
'false' => 'opacity-100',
],
],
'defaultVariants' => [
'colors' => 'blue',
'disabled' => 'false',
],
],
[],
'button btn-blue opacity-100',
];
yield 'boolean string variants true / true' => [
[
'variants' => [
'colors' => [
'primary' => 'text-primary',
'secondary' => 'text-secondary',
],
'disabled' => [
'true' => 'disable',
],
],
],
['colors' => 'primary', 'disabled' => true],
'text-primary disable',
];
yield 'boolean string variants true / false' => [
[
'variants' => [
'colors' => [
'primary' => 'text-primary',
'secondary' => 'text-secondary',
],
'disabled' => [
'true' => 'disable',
],
],
],
['colors' => 'primary', 'disabled' => false],
'text-primary',
];
yield 'boolean string variants false / true' => [
[
'variants' => [
'colors' => [
'primary' => 'text-primary',
'secondary' => 'text-secondary',
],
'disabled' => [
'false' => 'disable',
],
],
],
['colors' => 'primary', 'disabled' => true],
'text-primary',
];
yield 'boolean string variants false / false' => [
[
'variants' => [
'colors' => [
'primary' => 'text-primary',
'secondary' => 'text-secondary',
],
'disabled' => [
'false' => 'disable',
],
],
],
['colors' => 'primary', 'disabled' => false],
'text-primary disable',
];
yield 'boolean string variants missing' => [
[
'variants' => [
'colors' => [
'primary' => 'text-primary',
'secondary' => 'text-secondary',
],
'disabled' => [
'true' => 'disable',
],
],
],
['colors' => 'primary'],
'text-primary',
];
yield 'boolean list variants true' => [
[
'variants' => [
'colors' => [
'primary' => 'text-primary',
'secondary' => 'text-secondary',
],
'disabled' => [
'true' => ['disable', 'opacity-50'],
],
],
],
['colors' => 'primary', 'disabled' => true],
'text-primary disable opacity-50',
];
yield 'boolean list variants false' => [
[
'variants' => [
'colors' => [
'primary' => 'text-primary',
'secondary' => 'text-secondary',
],
'disabled' => [
'true' => ['disable', 'opacity-50'],
],
],
],
['colors' => 'primary', 'disabled' => false],
'text-primary',
];
yield 'boolean list variants missing' => [
[
'variants' => [
'colors' => [
'primary' => 'text-primary',
'secondary' => 'text-secondary',
],
'disabled' => [
'true' => ['disable', 'opacity-50'],
],
],
],
['colors' => 'primary'],
'text-primary',
];
}
/**
* @dataProvider provideAdditionalClassesCases
*/
public function testAdditionalClasses(string|array $base, array|string $additionals, string $expected)
{
$cva = new Cva($base);
if (!$additionals) {
$this->assertEquals($expected, $cva->apply([]));
} else {
$this->assertEquals($expected, $cva->apply([], ...(array) $additionals));
}
}
public static function provideAdditionalClassesCases(): iterable
{
yield 'additionals_are_optional' => [
'',
'foo',
'foo',
];
yield 'additional_are_used' => [
'',
'foo',
'foo',
];
yield 'additionals_are_used' => [
'',
['foo', 'bar'],
'foo bar',
];
yield 'additionals_preserve_order' => [
['foo'],
['bar', 'foo'],
'foo bar',
];
yield 'additional_are_deduplicated' => [
'',
['bar', 'bar'],
'bar',
];
}
}
@@ -0,0 +1,37 @@
--TEST--
"html_cva" function
--TEMPLATE--
{% set alert = html_cva(
['alert'],
{
color: {
blue: 'alert-blue',
red: 'alert-red',
green: 'alert-green',
yellow: 'alert-yellow',
},
size: {
sm: 'alert-sm',
md: 'alert-md',
lg: 'alert-lg',
},
rounded: {
sm: 'rounded-sm',
md: 'rounded-md',
lg: 'rounded-lg',
}
},
[{
color: ['red'],
size: ['lg'],
class: 'font-semibold'
}],
{
rounded: 'md'
}
) %}
{{ alert.apply({color: 'blue', size: 'sm'}) }}
--DATA--
return []
--EXPECT--
alert alert-blue alert-sm rounded-md
@@ -0,0 +1,19 @@
--TEST--
pass Cva object to template
--TEMPLATE--
{{ alert.apply({colors: 'primary', sizes: 'sm'}) }}
--DATA--
return [
'alert' => new Twig\Extra\Html\Cva('font-semibold border rounded', [
'colors' => [
'primary' => 'text-primary',
'secondary' => 'text-secondary'
],
'sizes' => [
'sm' => 'text-sm',
'lg' => 'text-lg'
]
])
];
--EXPECT--
font-semibold border rounded text-primary text-sm