Skip to content
Open
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
798 changes: 798 additions & 0 deletions .opencode/plans/link-component-plan.md

Large diffs are not rendered by default.

98 changes: 98 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
# CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

## Package Overview

`ibexa/design-system-twig` provides reusable UI components for the Ibexa DXP admin interface, built on Symfony UX TwigComponent. Components are used in templates as `<twig:ibexa:component_name>`.

## Commands

### PHP (run from package root)

```bash
composer test # Run all tests (PHPUnit: bundle, integration, lib suites)
composer phpstan # Static analysis (level 8)
composer check-cs # Check code style (dry-run)
composer fix-cs # Auto-fix code style
composer deptrac # Architecture layer validation
```

Run a single test file or filter:
```bash
./vendor/bin/phpunit -c phpunit.xml.dist --filter TestClassName
./vendor/bin/phpunit -c phpunit.xml.dist tests/integration/path/to/TestFile.php
```

### Frontend

```bash
yarn install
yarn ids-prepare-dev-env # Required before running frontend tests
yarn test # Prettier + ESLint checks
yarn fix # Auto-fix Prettier + ESLint issues
```

## Architecture

### Three-Layer Structure (enforced by Deptrac)

- **`src/contracts/`** (`Ibexa\Contracts\DesignSystemTwig\`) — Public API (currently empty placeholder)
- **`src/lib/`** (`Ibexa\DesignSystemTwig\`) — Component PHP classes and business logic
- **`src/bundle/`** (`Ibexa\Bundle\DesignSystemTwig\`) — Symfony bundle, DI config, Twig templates, frontend assets

Dependency rules: Bundle → Library → Contracts. Contracts has no internal dependencies.

### Component System

Each UI component has up to three parts:

1. **PHP class** (`src/lib/Twig/Components/`) — Registered via `#[AsTwigComponent('ibexa:name')]` attribute
2. **Twig template** (`src/bundle/Resources/views/themes/standard/design_system/components/`) — Renders the HTML
3. **TypeScript** (`src/bundle/Resources/public/ts/components/`) — Optional interactive behavior

Component registration is configured in `src/bundle/Resources/config/ibexa_twig_component.yaml` with namespace prefix `ibexa` and template directory `@ibexadesign/design_system/components/`.

### Component PHP Patterns

- **`#[PreMount]`** method uses Symfony `OptionsResolver` to validate props with allowed values/types. Always calls `$resolver->setIgnoreUndefined()` and returns `$resolver->resolve($props) + $props`.
- **`#[ExposeInTemplate]`** exposes computed values to Twig (e.g., derived CSS classes, icon sizes).
- **`#[PostMount]`** for post-initialization logic (used in dropdowns for default value selection).

### Component Class Hierarchy

- **`AbstractField`** — Base for all field components (name, required, label, helper text)
- **`AbstractSingleInputField`** — Single input fields with auto-generated IDs and input attributes
- **`AbstractDropdown`** — Dropdown components with items, search, translation
- **`AbstractChoiceInput`** — Choice inputs (checkbox, radio, toggle) with name, value, checked, disabled, error
- **`ListFieldTrait`** — Mixin for multi-item list fields (radio lists, checkbox lists)
- **`LabelledChoiceInputTrait`** — Mixin for choice inputs that need labels (adds id, wrapper/label classes)

Components follow a dual pattern: bare **Input** components (just the control) and **Field** wrappers (add label, helper text, validation).

### Twig Template Patterns

- Templates use `html_cva()` for CSS class variant composition and `html_classes()` for conditional classes
- Shared base templates in `partials/` (e.g., `base_field.html.twig`, `base_choice_input.html.twig`) are extended by specific component templates
- `attributes` variable handles dynamic HTML attribute pass-through
- `ids_get(template_name)` Twig function resolves design system template paths

### TypeScript

- Components extend a `Base` class in `partials/base.ts` with `_container` and `init()` lifecycle
- Auto-initialization in `init_components.ts` registers components by CSS selector on DOM ready
- Custom init can be disabled with `data-ids-custom-init` attribute on the element
- Webpack Encore entry points defined in `ibexa.config.js`

## Testing

Integration tests use Symfony `KernelTestCase` with a custom `IbexaTestKernel` (defined in `tests/integration/`). The `InteractsWithTwigComponents` trait provides:
- `mountTwigComponent()` — Mount a component and inspect its PHP state
- `renderTwigComponent()` — Render to HTML for DOM assertions via `Symfony\Component\DomCrawler\Crawler`

Tests cover mount validation (OptionsResolver behavior), rendered HTML structure, CSS classes, and error cases with `#[DataProvider]` for multiple scenarios.

## Code Style

- PHP: `declare(strict_types=1)` in all files, PHPStan level 8, `ibexa/code-style` (php-cs-fixer)
- TypeScript: `@ibexa/eslint-config` (no React), Prettier, `no-magic-numbers` rule (allows -1, 0)
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { BaseChoiceInput } from '../../partials';

export class CheckboxInput extends BaseChoiceInput {
constructor(container: HTMLDivElement) {
super(container);
constructor(inputElement: HTMLInputElement) {
super(inputElement);

this.setIndeterminate(this._inputElement.classList.contains('ids-input--indeterminate'));
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,15 @@ export class ToggleButtonInput extends BaseChoiceInput {
};

constructor(container: HTMLDivElement) {
super(container);
const inputElement = container.querySelector<HTMLInputElement>('.ids-toggle__source input');

if (!inputElement) {
throw new Error('ToggleButtonInput: Input element is missing in the container.');
}

super(inputElement);

this._container = container;

const widgetNode = this._container.querySelector<HTMLDivElement>('.ids-toggle__widget');
const toggleLabelNode = this._container.querySelector<HTMLLabelElement>('.ids-toggle__label');
Expand Down Expand Up @@ -40,6 +48,10 @@ export class ToggleButtonInput extends BaseChoiceInput {

protected initWidgets(): void {
this.widgetNode.addEventListener('click', () => {
if (this._inputElement.disabled) {
return;
}

this._inputElement.focus();
this._inputElement.checked = !this._inputElement.checked;
this._inputElement.dispatchEvent(new Event('change', { bubbles: true }));
Expand Down
21 changes: 19 additions & 2 deletions src/bundle/Resources/public/ts/init_components.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { AltRadioInput, AltRadiosListField } from './components/alt_radio';
import { CheckboxInput, CheckboxesListField } from './components/checkbox';
import { DropdownMultiInput, DropdownSingleInput } from './components/dropdown';
import { InputTextField, InputTextInput } from './components/input_text';
import { RadioButtonInput, RadioButtonsListField } from './components/radio_button';
import { ToggleButtonField, ToggleButtonInput } from './components/toggle_button';
import { Accordion } from './components/accordion';
import { OverflowList } from './components/overflow_list';
Expand Down Expand Up @@ -30,9 +31,9 @@ altRadiosListContainers.forEach((altRadiosListContainer: HTMLDivElement) => {
altRadiosListInstance.init();
});

const checkboxContainers = document.querySelectorAll<HTMLDivElement>('.ids-checkbox:not([data-ids-custom-init])');
const checkboxContainers = document.querySelectorAll<HTMLInputElement>('.ids-input--checkbox:not([data-ids-custom-init])');

checkboxContainers.forEach((checkboxContainer: HTMLDivElement) => {
checkboxContainers.forEach((checkboxContainer: HTMLInputElement) => {
const checkboxInstance = new CheckboxInput(checkboxContainer);

checkboxInstance.init();
Expand Down Expand Up @@ -78,6 +79,22 @@ inputTextContainers.forEach((inputTextContainer: HTMLDivElement) => {
inputTextInstance.init();
});

const radioButtonContainers = document.querySelectorAll<HTMLInputElement>('.ids-input--radio:not([data-ids-custom-init])');

radioButtonContainers.forEach((radioButtonContainer: HTMLInputElement) => {
const radioButtonInstance = new RadioButtonInput(radioButtonContainer);

radioButtonInstance.init();
});

const radioButtonsListFieldContainers = document.querySelectorAll<HTMLDivElement>('.ids-radio-buttons-list-field:not([data-ids-custom-init])');

radioButtonsListFieldContainers.forEach((radioButtonsListFieldContainer: HTMLDivElement) => {
const radioButtonsListFieldInstance = new RadioButtonsListField(radioButtonsListFieldContainer);

radioButtonsListFieldInstance.init();
});

const overflowListContainers = document.querySelectorAll<HTMLDivElement>('.ids-overflow-list:not([data-ids-custom-init])');

overflowListContainers.forEach((overflowListContainer: HTMLDivElement) => {
Expand Down
10 changes: 2 additions & 8 deletions src/bundle/Resources/public/ts/partials/base_choice_input.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,8 @@ import { Base } from './base';
export abstract class BaseChoiceInput extends Base {
protected _inputElement: HTMLInputElement;

constructor(container: HTMLDivElement) {
super(container);

const inputElement = container.querySelector<HTMLInputElement>('.ids-input');

if (!inputElement) {
throw new Error('Checkbox: Required elements are missing in the container.');
}
constructor(inputElement: HTMLInputElement) {
super(inputElement);

this._inputElement = inputElement;
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { ModifierArguments, Options, createPopper } from '@popperjs/core';
import { ModifierArguments, Options, createPopper } from '@popperjs/core/index';

import { Base } from '../base';
import { Expander } from '../../components/expander';
Expand Down
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
{% set _content %}{% block content %}{% endblock %}{% endset %}
{% set _content = _content|striptags|trim is not empty ? _content : label %}
{% set has_content = _content|striptags|trim is not empty %}
{% set icon_only = (not has_content) and icon is not empty %}
{% set button_classes =
html_cva(
base: html_classes(
'ids-btn',
{
'ids-btn--disabled': disabled,
'ids-btn--icon-only': icon_only,
},
),
Expand All @@ -27,7 +27,7 @@
%}

<button
type="button"
type="{{ html_type|default('button') }}"
class="{{
button_classes.apply(
{
Expand Down
Original file line number Diff line number Diff line change
@@ -1,19 +1,12 @@
{% extends '@IbexaDesignSystemTwig/themes/standard/design_system/partials/base_choice_input.html.twig' %}

{% set class = html_classes('ids-checkbox', attributes.render('class') ?? '') %}
{% set class = html_classes(class, attributes.render('class') ?? '') %}

{% block content %}
{% set input_classes =
html_classes(
input_classes,
{
'ids-input--indeterminate': indeterminate
},
)
%}
{% set class = html_classes(class, { 'ids-input--indeterminate': indeterminate }) %}

<input
class="{{ input_classes }}"
class="{{ class }}"
{{
attributes.defaults({
checked,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
<twig:ibexa:checkbox:input
name="{{ name }}-checkbox"
:checked="item.id in value"
:value="item.id"
value="{{ item.id ~ '' }}"
data-ids-custom-init="true"
/>
{{ item.label }}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
{% set _content %}{% block content %}{% endblock %}{% endset %}
{% set _content = _content|striptags|trim is not empty ? _content : label %}
{% set has_content = _content|striptags|trim is not empty %}

{% if variant == 'button' %}
{% set icon_only = (not has_content) and icon is not empty %}
{% set link_classes =
html_cva(
base: html_classes(
'ids-btn',
{
'ids-btn--icon-only': icon_only,
'ids-link--disabled': disabled,
},
),
variants: {
type: {
primary: 'ids-btn--primary',
secondary: 'ids-btn--secondary',
tertiary: 'ids-btn--tertiary',
'secondary-alt': 'ids-btn--secondary-alt',
'tertiary-alt': 'ids-btn--tertiary-alt',
},
size: {
medium: 'ids-btn--medium',
small: 'ids-btn--small',
},
},
)
%}

<a
href="{{ href }}"
class="{{
link_classes.apply(
{
type,
size
},
attributes.render('class')
)
}}"
{{ attributes }}
>
{% block link_button_inner %}
{% if icon %}
<div class="ids-btn__icon">
<twig:ibexa:icon name="{{ icon }}" size="{{ icon_size }}" />
</div>
{% endif %}
{% if not icon_only %}
<div class="ids-btn__label">
{{ _content|raw }}
</div>
{% endif %}
{% endblock link_button_inner %}
</a>
{% else %}
<a
href="{{ href }}"
class="{{ html_classes('ids-link', {'ids-link--disabled': disabled}, attributes.render('class')|default('')) }}"
{{ attributes }}
>
{% block link_text_inner %}
{{ _content|raw }}
{% endblock link_text_inner %}
</a>
{% endif %}
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
{% extends '@IbexaDesignSystemTwig/themes/standard/design_system/partials/base_choice_input.html.twig' %}

{% set class = html_classes('ids-radio-button', attributes.render('class') ?? '') %}
{% set class = html_classes(class, attributes.render('class') ?? '') %}

{% block content %}
<input
class="{{ input_classes }}"
class="{{ class }}"
{{
attributes.defaults({
checked,
disabled,
name,
required,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,15 +18,17 @@

<div class="{{ component_classes.apply({ size }, attributes.render('class')) }}" {{ attributes }}>
<div class="ids-toggle__source">
<twig:ibexa:checkbox:input
:id="id"
:name="name"
:value="value"
:checked="checked"
:disabled="disabled"
:required="required"
data-ids-custom-init="1"
/>
{% block input %}
<twig:ibexa:checkbox:input
:id="id"
:name="name"
:value="value"
:checked="checked"
:disabled="disabled"
:required="required"
data-ids-custom-init="1"
/>
{% endblock input %}
</div>
<div class="ids-toggle__widget" role="button">
<div class="ids-toggle__indicator"></div>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
{% set component_classes = html_classes('ids-choice-input', class) %}
{% set input_classes =
{% set class =
html_classes(
'ids-input',
'ids-input--' ~ type,
Expand All @@ -9,10 +8,8 @@
'ids-input--error': error,
'ids-input--required': required
},
input_class
class
)
%}

<div class="{{ component_classes }}">
{{ block('content')}}
</div>
{{ block('content')}}
2 changes: 1 addition & 1 deletion src/lib/Twig/Components/AbstractChoiceInput.php
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ abstract class AbstractChoiceInput

public bool $error = false;

#[ExposeInTemplate('input_class')]
#[ExposeInTemplate('class')]
public string $inputClass = '';

public bool $required = false;
Expand Down
Loading
Loading