diff --git a/config/services.yaml b/config/services.yaml index 2c885ea..9c29504 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -67,3 +67,7 @@ services: Enabel\Ux\Component\Layout\EmailLayout: tags: - { name: 'twig.component', key: 'Enabel:Ux:EmailLayout', template: '@EnabelUx/layout/email_layout.html.twig', expose_public_props: true } + + Enabel\Ux\Component\Layout\LoginCard: + tags: + - { name: 'twig.component', key: 'Enabel:Ux:LoginCard', template: '@EnabelUx/layout/login_card.html.twig', expose_public_props: true } diff --git a/docs/Layout/loginCard.md b/docs/Layout/loginCard.md new file mode 100644 index 0000000..4e02f29 --- /dev/null +++ b/docs/Layout/loginCard.md @@ -0,0 +1,113 @@ +# LoginCard Component + +## Description + +A full-screen layout for login, password reset, and similar small-form pages: a coloured (or image) background filling the viewport, with a centered Bootstrap card holding an optional logo, an optional title, and a `{% block content %}` for the form. + +Mutualises the markup recurring across Enabel apps (login, forgot password, reset password, external-login confirmation, …) where each project copied the same `card shadow-lg` + `border-radius: 1rem` + centered logo + h3 title structure. + +## Parameters + +| Parameter | Type | Description | Default | +|:------------------|:----------|:-----------------------------------------------------------------------------------------------------|:------------| +| `logo` | `?string` | Path passed to `asset()` for the top logo. Hidden when `null` | `null` | +| `title` | `?string` | Heading rendered inside the card (`h1.h3`). Hidden when `null` | `null` | +| `background` | `string` | Bootstrap theme colour: `primary`, `secondary`, `success`, `danger`, `warning`, `info`, `light`, `dark` | `'primary'` | +| `backgroundImage` | `?string` | Path passed to `asset()` for a background image. When set, overrides `background` (set to `cover`/`center`) | `null` | +| `size` | `string` | Card width: `sm` (360 px), `md` (480 px), `lg` (640 px), `xl` (800 px) | `'md'` | + +The component also forwards `class` and other HTML attributes onto the outer wrapper via the standard Twig Component attribute API. + +## Usage + +### Basic login page + +```twig +{# templates/auth/login.html.twig #} +{% extends 'base.html.twig' %} + +{% block header %}{% endblock %} +{% block footer %}{% endblock %} + +{% block body %} + {% component 'Enabel:Ux:LoginCard' with { + logo: 'images/enabel-logo.png', + title: 'app.name'|trans, + background: 'primary', + } %} + {% block content %} + {{ form_start(form) }} + {{ form_row(form.username) }} + {{ form_row(form.password) }} + + {{ form_end(form) }} + {% endblock %} + {% endcomponent %} +{% endblock %} +``` + +### Password reset (no logo, narrow size) + +```twig +{% component 'Enabel:Ux:LoginCard' with { + title: 'auth.reset.title'|trans, + size: 'sm', + background: 'light', +} %} + {% block content %} + {{ form(form) }} + {% endblock %} +{% endcomponent %} +``` + +### Photo background + +```twig +{% component 'Enabel:Ux:LoginCard' with { + logo: 'images/enabel-logo.png', + title: 'app.name'|trans, + backgroundImage: 'images/login-background.jpg', +} %} + {% block content %}{# form here #}{% endblock %} +{% endcomponent %} +``` + +When `backgroundImage` is set, the `background` colour is ignored — the image is rendered with `background-size: cover` and `background-position: center`. + +## Rendering inside a modal + +The component is designed for a full-viewport login page (`min-vh-100`). If you need the same card inside a Bootstrap modal (typical for an "external login" confirmation dialog), override the template via the standard bundle override mechanism (see the main [documentation](../index.md#how-to-override-templates)): + +1. Copy `vendor/enabel/ux/templates/layout/login_card.html.twig` to `templates/bundles/EnabelUx/layout/login_card.html.twig` +2. Drop the outer wrapper so only the card body is rendered. Replace: + + ```twig +
+
+ … +
+
+ ``` + + with the inner card only: + + ```twig +
+ … +
+ ``` + +3. Wrap the component at the call site: + + ```twig + + ``` + +## Notes + +- The card uses `shadow-lg` (Bootstrap utility) and an inline `border-radius: 1rem` — change via template override if your design system uses a different radius. +- Inner padding is `p-4 p-md-5` (Bootstrap utilities), matches the spacing used in the previous copy-pasted markup across Enabel apps. diff --git a/docs/index.md b/docs/index.md index c71ec38..ed470d3 100644 --- a/docs/index.md +++ b/docs/index.md @@ -28,6 +28,7 @@ https://github.com/Enabel/Ux - [Bootstrap](Layout/bootstrap.md) - A layout for rendering Bootstrap components with Enabel UX components & Enabel Bootstrap Theme - [EmailLayout](Layout/emailLayout.md) - A reusable HTML email layout for Symfony Mailer's TemplatedEmail - [ErrorPage](Layout/errorPage.md) - A full-page error layout for Symfony's TwigBundle exception templates +- [LoginCard](Layout/loginCard.md) - A centered Bootstrap card on a coloured or image background for login/auth pages ## Install diff --git a/src/Component/Layout/LoginCard.php b/src/Component/Layout/LoginCard.php new file mode 100644 index 0000000..8093e7b --- /dev/null +++ b/src/Component/Layout/LoginCard.php @@ -0,0 +1,57 @@ + + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Enabel\Ux\Component\Layout; + +use Symfony\Component\OptionsResolver\OptionsResolver; +use Symfony\UX\TwigComponent\Attribute\PreMount; + +class LoginCard +{ + public ?string $logo; + public ?string $title; + public string $background; + public ?string $backgroundImage; + public string $size; + + /** + * @param array $data + * + * @return array + */ + #[PreMount] + public function preMount(array $data): array + { + $resolver = new OptionsResolver(); + $this->configureOptions($resolver); + + return $resolver->resolve($data) + $data; + } + + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setIgnoreUndefined(); + $resolver->setDefaults([ + 'logo' => null, + 'title' => null, + 'background' => 'primary', + 'backgroundImage' => null, + 'size' => 'md', + ]); + + $resolver->setAllowedTypes('logo', ['string', 'null']); + $resolver->setAllowedTypes('title', ['string', 'null']); + $resolver->setAllowedTypes('background', 'string'); + $resolver->setAllowedTypes('backgroundImage', ['string', 'null']); + $resolver->setAllowedTypes('size', 'string'); + + $resolver->setAllowedValues('background', ['primary', 'secondary', 'success', 'danger', 'warning', 'info', 'light', 'dark']); + $resolver->setAllowedValues('size', ['sm', 'md', 'lg', 'xl']); + } +} diff --git a/templates/layout/login_card.html.twig b/templates/layout/login_card.html.twig new file mode 100644 index 0000000..e92f7cb --- /dev/null +++ b/templates/layout/login_card.html.twig @@ -0,0 +1,25 @@ +{% set sizes = { sm: '360px', md: '480px', lg: '640px', xl: '800px' } %} +{% set maxWidth = sizes[size] %} + +{% set wrapperStyle = backgroundImage + ? 'background-image: url(\'' ~ asset(backgroundImage) ~ '\'); background-size: cover; background-position: center; background-repeat: no-repeat;' + : '' %} +{% set wrapperClass = backgroundImage ? '' : 'bg-' ~ background %} + + diff --git a/tests/Component/Layout/LoginCardTest.php b/tests/Component/Layout/LoginCardTest.php new file mode 100644 index 0000000..58fac52 --- /dev/null +++ b/tests/Component/Layout/LoginCardTest.php @@ -0,0 +1,120 @@ + + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Enabel\Ux\Tests\Component\Layout; + +use Enabel\Ux\Component\Layout\LoginCard; +use PHPUnit\Framework\TestCase; +use Symfony\Component\OptionsResolver\Exception\InvalidOptionsException; + +class LoginCardTest extends TestCase +{ + public function testComponentCanBeInstantiatedWithDefaultParameters(): void + { + $component = new LoginCard(); + $data = $component->preMount([]); + + $component->logo = $data['logo']; + $component->title = $data['title']; + $component->background = $data['background']; + $component->backgroundImage = $data['backgroundImage']; + $component->size = $data['size']; + + $this->assertNull($component->logo); + $this->assertNull($component->title); + $this->assertSame('primary', $component->background); + $this->assertNull($component->backgroundImage); + $this->assertSame('md', $component->size); + } + + public function testComponentCanBeInstantiatedWithCustomParameters(): void + { + $component = new LoginCard(); + $data = $component->preMount([ + 'logo' => 'images/enabel-logo.png', + 'title' => 'Sign in', + 'background' => 'dark', + 'backgroundImage' => 'images/photo.jpg', + 'size' => 'lg', + ]); + + $this->assertSame('images/enabel-logo.png', $data['logo']); + $this->assertSame('Sign in', $data['title']); + $this->assertSame('dark', $data['background']); + $this->assertSame('images/photo.jpg', $data['backgroundImage']); + $this->assertSame('lg', $data['size']); + } + + public function testAllValidBackgrounds(): void + { + $component = new LoginCard(); + foreach (['primary', 'secondary', 'success', 'danger', 'warning', 'info', 'light', 'dark'] as $bg) { + $data = $component->preMount(['background' => $bg]); + $this->assertSame($bg, $data['background']); + } + } + + public function testAllValidSizes(): void + { + $component = new LoginCard(); + foreach (['sm', 'md', 'lg', 'xl'] as $size) { + $data = $component->preMount(['size' => $size]); + $this->assertSame($size, $data['size']); + } + } + + public function testInvalidBackgroundThrows(): void + { + $this->expectException(InvalidOptionsException::class); + + $component = new LoginCard(); + $component->preMount(['background' => 'rainbow']); + } + + public function testInvalidSizeThrows(): void + { + $this->expectException(InvalidOptionsException::class); + + $component = new LoginCard(); + $component->preMount(['size' => 'xxl']); + } + + public function testInvalidLogoTypeThrows(): void + { + $this->expectException(InvalidOptionsException::class); + + $component = new LoginCard(); + $component->preMount(['logo' => 123]); + } + + public function testInvalidTitleTypeThrows(): void + { + $this->expectException(InvalidOptionsException::class); + + $component = new LoginCard(); + $component->preMount(['title' => true]); + } + + public function testInvalidBackgroundImageTypeThrows(): void + { + $this->expectException(InvalidOptionsException::class); + + $component = new LoginCard(); + $component->preMount(['backgroundImage' => 42]); + } + + public function testPreMountPreservesAdditionalData(): void + { + $component = new LoginCard(); + $data = $component->preMount(['custom_attribute' => 'value']); + + $this->assertArrayHasKey('custom_attribute', $data); + $this->assertSame('value', $data['custom_attribute']); + } +}