Skip to content
V
For developers
Architecture, conventions, the core and security
UI layer / Components

Components#

A reusable piece of a page with its own logic, template and possibly its own signals. The base: app/Core/Base/BaseComponent.php.

When to make a component at all#

Situation What to do
A chunk of markup repeats but has no logic a partial in the template (_something.latte)
It repeats together with logic (loads data, reacts to clicks) a component
I need to redraw part of the page over AJAX a component with a {snippet}
It is one page and nowhere else leave it in the presenter
State in the URL (a filter, a date range) that must not leak into the presenter's other links a component with #[Persistent] properties (pattern ProductStatisticsControl) — a persistent presenter parameter is added to every link the presenter builds

A component is not an extra folder — it is a boundary

What is inside may have its own state and signals and can be redrawn on its own. When you do not need that, a partial is enough and cheaper to understand.

Folder structure#

Components/AdvertListComponent/
  AdvertList.php            the component
  AdvertListFactory.php     the factory (extends BaseComponentFactory)
  Traits/                   logic split up (loading, filters, actions)
  Templates/
    Default/
      Style_1.latte         the default look
      Style_2.latte         another look of the same thing
      Elements/
        Actions.latte       a piece shared between the styles

Shared pieces into Elements/, not three copies

Whatever repeats across all styles (price, buttons, the user badge) belongs in Elements/. Three copies announce themselves by a change being made in two places out of three — and it looks right until somebody switches the style.

How one comes into being#

A component is never constructed directly. Next to it sits a factory class that extends App\Core\Base\BaseComponentFactory, receives its dependencies through the constructor and hands the component over through initialized() (which stores $control and runs initControl()):

// ArticleListProviderFactory.php
class ArticleListProviderFactory extends BaseComponentFactory
{
    public function __construct(
        private readonly ArticleManager $articleManager,
        private readonly ArticleListRendererFactory $articleListFactory,
    ) {}

    public function create(): ArticleListProvider
    {
        return $this->initialized(new ArticleListProvider($this->articleManager, $this->articleListFactory));
    }
}

The factory does not have to be registered anywhere: search in config/common.neon registers every class in app/UI as a service. Neither the presenter nor createComponentXxx() is needed — the template asks for the component by name and ComponentTrait::createStandardComponent() translates it to the class:

Name in the template Class
frontBlogArticleListProvider App\UI\Front\Blog\Components\ArticleListProviderComponent\ArticleListProvider
frontComcatLastAdvertList App\UI\Front\Comcat\Components\LastAdvertListComponent\LastAdvertList

The front/admin prefix and the second word give the namespace, the rest is the component name. Lookup order: a createComponent<Name>() method in the presenter (wins), the service <Class>Factory, the service <Class>. A name starting with multi goes to the Multiplier.

In the template it is then {control frontBlogArticleListProvider}, or {control frontBlogArticleListProvider:row $orderBy, 5}; the part after the colon calls renderRow().

Two traits injecting a property of the same name will NOT merge

It surfaces as an error while the container is being built, not while you write the code — so it looks like a configuration problem even though it is a name clash in traits.

How a component finds its template#

In three steps, first hit wins:

1. Templates/<Theme>/<Variant>/Style_1.latte    (exactly this theme and device)
2. Templates/<Theme>/default/Style_1.latte      (this theme, all devices)
3. Templates/Default/Style_1.latte              (the shared default)

The look is chosen by an argument when rendering; renderStyle2() resolves to Style_2.latte automatically, so no method has to be written for it.

A new theme need not copy components

It inherits Templates/Default/ and overrides only what it wants different.

A component template has NO domain-aware translator

Unlike a presenter. Texts are translated in PHP and arrive in the template ready.

Paths in {include} are relative to the file

Moving a template breaks every nested include it has.

The theme is the theme name (Theme::getName(), directory template9 → Template9), the variant is the device with a capital letter (Desktop). This holds for front and administration themes alike: a component template for the admin theme template1 would live in Templates/Template1/default/ next to Templates/Default/.

A template part per theme#

The same lookup is available for single parts the main template pulls in with {include}. BaseComponent::resolveThemeTemplatePath($dir, $file) returns the path in the same order (and finally $file directly in $dir); the component passes it to the template in a variable:

// the component's render()
$this->template->footTemplate = $this->resolveThemeTemplatePath(__DIR__, 'Parts/_foot.latte');
{include $footTemplate}

A theme then replaces just that one part and takes the rest of the main template from Templates/Default/. DataGrid does this with its row actions and footer, see Working with tables.

A hard-coded path in {include} bypasses the theme

{include 'Parts/_foot.latte'} always includes the file next to the main template. A part meant to be overridable in a theme must go through a variable from resolveThemeTemplatePath().

The shared modal window#

The modal is not drawn in Latte. A shared shell is mounted once in the layout and components merely put content into it and open it.

$this->getModalWindow()          // from ModalAwareTrait
    ->setTitle('Reply to the ad')
    ->setSize('modal-lg')
    ->setContent($html)          // finished HTML, typically a rendered partial
    ->open();
Property How it is
Where the shell is once in the admin @layout.latte as {control modalWindow}
Who fills it any presenter or component beneath it
State none — neither title nor content is stored in the session, they live for this request only
Opening over AJAX only {snippet modalBody} is redrawn and modalShow with an id goes into the response; modals.js does the showing
Several windows at once not supported — it is one shared shell

That is why a form from another component can go into it

The content is an ordinary HTML string. A component renders its own partial into a string and passes it to the modal; the modal knows nothing about it and does not need to.

The component must run under a presenter that provides the modal

getModalWindow() checks this and otherwise throws, naming the component. It is not pedantry — without the shell in the layout the window would have nowhere to appear.

A required field in a hidden modal blocks the WHOLE form from submitting

The browser refuses to submit because of a field the user cannot see, and does not say why — the page simply stops responding to the submit. Requiredness therefore has to be toggled by whether the modal is open.

Signals#

A handle*() method serves a component action (delete, toggle, load more). It is called by an ordinary URL the component generates for itself.

A signal is a public URL — it must verify the request's origin

Without that check it can be triggered from a foreign page in the name of the signed-in user. Same-origin checking on handle*() is mandatory, not optional.

AJAX redraws only the INSIDE of a snippet

Attributes of the element the snippet sits on (classes, data-) keep their original values. Whatever must change has to be inside.

In an AJAX response Nette renders only the invalidated snippets and calls a component's render() without arguments. Neither the template that passes the arguments ({control x:tile, 3, false, true}) nor the layout runs then. A component with render arguments must therefore secure them itself:

When Where the arguments come from
full render from the template; the component remembers them (CategoryList::$lastRenderArgs)
AJAX signal from the signal: the link passes them as parameters (showAll!, tab!), the handler checks and stores them
render without arguments func_num_args() === 0 → the stored ones are used

Procedure: a component with render arguments and an AJAX signal

  1. Store the full-render arguments in a component property.
  2. Pass the same arguments as signal parameters in the signal link.
  3. In handle*() check them (style only from an allowed list, because it goes into the template path; cap the counts), store them and call redrawControl() on a named snippet.
  4. In render() called without arguments, use the stored ones.
  5. Put state that must survive a full reload without JS into a #[Persistent] parameter.

Without stored arguments the AJAX response comes out empty

render() without arguments draws the default style, which does not contain the named snippet, and the payload has nothing to send. A click on "All categories" then does nothing and the error shows nowhere.

Two mounts of one instance break the snippet and the persistent parameter

Insert a component that appears on the page several times with different state through {multicontrol frontBazaarCategoryList-<key>:…}: each key is its own instance. Plain {control} twice is one instance with shared state and one snippet name.

The form is {multicontrol <component>-<key>:<style> <arguments>}: the component name is the part before the first dash, the instance key is one word without a dash (Nette reads a further dash as a path to a nested component and fails with "Component with name '…' does not exist.") and the render arguments follow after a space, not a comma (Unexpected ',' at compile time).

Delete links and other writing links in the administration are confirmed by a styled dialog from datagrid.js instead of window.confirm. It works inside a grid and outside it (row actions in a component): the dialog texts come from the .data-grid ancestor, outside a grid from the attributes of the link itself.

Attribute on <a> What happens after confirmation
data-grid-confirm="…" alone question text; lets the element's own action through (the link opens, the form submits with the right button)
data-grid-post="page" POST of an empty form to href, the server answers with a redirect
data-grid-post="ajax" POST through naja, redraws snippets
data-grid-confirm-ajax the same through naja (a confirmed AJAX signal, e.g. sorting tree children)
data-confirm-title, data-confirm-yes, data-confirm-no, data-confirm-variant custom texts and colour of the confirm button (default danger)

A form in the modal receives the id of the edited record in a hidden field, not in a signal parameter: the component form action does not carry the parameters of the add! or edit! signal that opened the modal. A hidden field can be rewritten, so the component checks it against the owner context (see Admin presenter).

Procedure: deleting an item in an edit tab

  1. Mark the handleDelete() signal #[Requires(methods: 'POST')] and start it with the requirePrivilege() and requireContextOwner() gates.
  2. Render the link without the ajax class: href="{link delete! …}#<tab id>", data-grid-post="page" and data-grid-confirm="{_'confirmDelete'}".
  3. After deleting, set a flash and redirect to the record's edit page. The browser keeps the fragment from href across the redirect, so the same tab stays open.

Example: app/UI/Admin/Bazaar/Components/PaymentItemsComponent/ (template and handleDelete()).

The ajax class on a confirmed link sends the request before the dialog asks

Naja binds its .ajax handler directly to the element, so it runs before the delegated confirmation. The delete happens even when the user clicks Cancel in the dialog. For a confirmed AJAX request use data-grid-confirm-ajax or data-grid-post="ajax", never the ajax class.

A writing signal that opens with GET gets triggered by a robot or prefetch

A link in a signed-in session can be fired by browser prefetching too. That is why a writing signal accepts only POST and the link sends it through data-grid-post.

A new component, step by step#

  1. Create the folder on the pattern above — the component, the factory, Templates/Default/.
  2. Split the logic into traits once it outgrows one file (loading apart from actions).
  3. Name it by the convention (front<Module><Name>) so that the template finds it without registration; write createComponent<Name>() in the presenter only when the component needs settings from the presenter.
  4. Write the template into Templates/Default/ — so every theme inherits it.
  5. Translate texts in PHP, send them to the template ready.
  6. Click through it — including the AJAX path if the component redraws.

Fixing a shared base is a change in every module that uses it

A component with a shared ancestor for three modules means a fix for one hits all three. Walk all of them — not just the one you are fixing.

The side panel with categories and regions used to draw the whole tree: CategoryList and RegionList loaded and hydrated every node on every request (461 classifieds categories, about 9.5 thousand regions) to show one level. The replacement always shows one level and reads each level with a small cached query by parent.

Component Control in the template
Bazaar\CategoryChildrenList, Bazaar\RegionChildrenList {control frontBazaarCategoryChildrenList}, {control frontBazaarRegionChildrenList}
Comcat\… frontComcat…ChildrenList
Blog\CategoryChildrenList, Blog\RegionChildrenList frontBlogCategoryChildrenList, frontBlogRegionChildrenList
Discussion\CategoryChildrenList frontDiscussionCategoryChildrenList
Eshop\ProductCategoryChildrenList (no counts — the subtree total would need the whole tree) frontEshopProductCategoryChildrenList
the region base System\RegionChildrenList a module is a thin subclass (pattern RegionList)

It is wired in aside-left.latte of all 18 themes for five modules (about 90 files); none of them calls the full-tree drawing any more.

What it consists of#

Part Job
ChildrenNavigationTrait loads one level (parentId = <current> or IS NULL for roots) and the parent for the way back; selects the template — a module's or theme's copy takes precedence over the shared one
CategoryChildrenTrait data preparation for categories over CategoryRailTrait; the module supplies the manager, entity, address translator, categoryRailHref() and categoryChildrenRootHref()
Base/Front/Components/ChildrenNavigation/Templates/Default/Style_1.latte the shared flat template
RegionChildrenListComponent/Traits/InitTrait the same for regions

The result has the shape li.active > a.active: the way back (.menu-item--parent), the selected node and its children in a nested <ul>. It is the same structure as the original tree, so theme styles apply without touching CSS. The component finds its node in the presenter (getCurrentCategory() / getCurrentRegion()) and accepts it only if it belongs to its module — a component placed on another module's page does not adopt a foreign category.

Categories support the styles Style_1 (flat), Style_4 and Style_6 (rail); another style throws InvalidArgumentException.

A link keeps the selected region when the category changes and the category when the region changes; a change always starts on page 1. In Bazaar and Comcat the address segments are <category>/<region>, so both must be explicit: if the category is missing, - is written, otherwise the region would shift into the category's place. Discussion and the e-shop do not need -. A root category has no parent, so the back link leads to the overview of roots.

Every translator must handle the placeholder -

The category translators answer - without a warmup and without a query. A new translator that does not know - sends it to the database on every link, or produces a wrong link.

Factory: return $this->control is not let through by PHPStan

The type is BaseComponent|null. Return the result of initialized(), or use a local variable $control.

How to wire it into your own theme

Put {control front<Module>CategoryChildrenList} and {control front<Module>RegionChildrenList} into aside-left.latte. The look (the back link, the active row) is decided by the theme with the styles .menu-item--parent and .active; the styles for details/summary/.has-children from the old tree are no longer used.

Shared category lists#

The CategoryList component (ProductCategoryList in the e-shop) has one shared set of templates for five modules (Blog, Bazaar, Comcat, Discussion, Eshop) in app/Core/Base/Front/Components/CategoryList/Templates/Default/. CategoryRailTrait prepares their data: it precomputes the differences between modules (link target, icon, item count) into the plain arrays $railRows and $railParent, so the template knows no entity.

Style What it shows Nodes
Style_2 tiles without children roots (sortedItems)
Style_4 subcategory strip with an icon and a way back children of the current category, roots outside a category
Style_5 tiles with three children roots (sortedItems)
Style_6 row of links with a way back like Style_4

For Style_4 and Style_6 the difference between null and [] in currentBranch matters: null means "not in a category, show the roots", an empty array "in a leaf category, nothing to show".

The template is looked up in this order, the first existing one wins:

1. <module>/Templates/<Theme>/<Variant>/Style_X.latte   (copy for the current theme)
2. <module>/Templates/Default/Style_X.latte             (module copy for all themes)
3. Base/Front/Components/CategoryList/Templates/Default/Style_X.latte

Bazaar, Comcat, Discussion and Blog all have their own Templates/Default/Style_2.latte, so the shared Style_2 is drawn only by the e-shop today (counted with find app/UI/Front -path '*CategoryList*' -name 'Style_2.latte').

Procedure: wiring the shared styles into a module#

  1. Use CategoryRailTrait in the category list component.
  2. Implement categoryRailHref(int $categoryId) — the only thing a module supplies. Build the link with $this->getPresenter()->link(); a link to a foreign page needs the slug of the target page, otherwise it leads to a 404.
  3. In render() fill sortedItems, currentBranch and currentParent, then call prepareCategoryRail($style) and right after it setTemplateFile($this->categoryRailTemplateDir(__DIR__, $style), $style . '.latte').
  4. The item count comes from the default categoryRailCount() via getAdvertCount(); otherwise override it (the e-shop counts the subtree), or return null and no count is drawn.

Call prepareCategoryRail() only right before setTemplateFile()

Bazaar still filters sortedItems (preferred categories) in between. An earlier call hands the tiles the unfiltered tree.

Never look the template up with a glob across all of the module's themes

A copy from another theme (Templates/Template15/Desktop/Style_4.latte) would be found while rendering a different theme too; when the module has no Templates/Default/Style_4.latte, the file does not exist and the page fails with a 500 in every theme without its own copy. The lookup therefore copies the steps of BaseComponent::setTemplateFile() for the current theme only.

Finished components worth reading#

Component Where What makes it interesting
DataGrid app/UI/Admin/System/Components/ a table with filters, ordering and bulk actions
Picker same place picking an entity instead of a <select>
Modal window same place the shared shell described above
Advert listing app/UI/Front/Bazaar/Components/AdvertRendererComponent/ three looks plus Elements/
Navigation by level app/UI/Front/Bazaar/Components/CategoryChildrenListComponent/ a thin subclass over a shared base

The visual side of the front-end components is in the component catalogue.

Cookie settings