Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions config/services.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
113 changes: 113 additions & 0 deletions docs/Layout/loginCard.md
Original file line number Diff line number Diff line change
@@ -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) }}
<button type="submit" class="btn btn-primary w-100">{{ 'auth.login'|trans }}</button>
{{ 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
<div class="enabel-login-card d-flex align-items-center justify-content-center min-vh-100 p-3 {{ wrapperClass }} …">
<div class="card shadow-lg border-0 w-100" style="max-width: {{ maxWidth }}; border-radius: 1rem;">
</div>
</div>
```

with the inner card only:

```twig
<div class="card shadow-lg border-0 w-100" style="max-width: {{ maxWidth }}; border-radius: 1rem;">
</div>
```

3. Wrap the component at the call site:

```twig
<div class="modal-dialog modal-lg modal-dialog-centered">
{% component 'Enabel:Ux:LoginCard' with { title: 'External login'|trans } %}
{% block content %}{# the form #}{% endblock %}
{% endcomponent %}
</div>
```

## 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.
1 change: 1 addition & 0 deletions docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
57 changes: 57 additions & 0 deletions src/Component/Layout/LoginCard.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
<?php

/*
* This file is part of the Enabel UX package.
* Copyright (c) Enabel <https://enabel.be/>
* 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<string, mixed> $data
*
* @return array<string, mixed>
*/
#[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']);
}
}
25 changes: 25 additions & 0 deletions templates/layout/login_card.html.twig
Original file line number Diff line number Diff line change
@@ -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 %}

<div class="enabel-login-card d-flex align-items-center justify-content-center min-vh-100 p-3 {{ wrapperClass }} {{ attributes.render('class') }}"
{% if wrapperStyle %}style="{{ wrapperStyle }}"{% endif %}
{{ attributes.defaults({}) }}>
<div class="card shadow-lg border-0 w-100" style="max-width: {{ maxWidth }}; border-radius: 1rem;">
<div class="card-body p-4 p-md-5">
{% if logo %}
<div class="text-center mb-4">
<img src="{{ asset(logo) }}" alt="{{ title|default('') }}" class="img-fluid" style="max-height: 80px;">
</div>
{% endif %}
{% if title %}
<h1 class="h3 text-center mb-4">{{ title }}</h1>
{% endif %}
{% block content %}{% endblock %}
</div>
</div>
</div>
120 changes: 120 additions & 0 deletions tests/Component/Layout/LoginCardTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
<?php

/*
* This file is part of the Enabel UX package.
* Copyright (c) Enabel <https://enabel.be/>
* 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']);
}
}
Loading