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
16 changes: 8 additions & 8 deletions composer.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

54 changes: 52 additions & 2 deletions src/Ease/TWB5/Navbar.php
Original file line number Diff line number Diff line change
Expand Up @@ -42,8 +42,20 @@ class Navbar extends NavTag
* Brand link destination.
*/
public string $mainpage = '#';

/**
* Render the collapsible menu as an Offcanvas drawer instead of a
* Bootstrap collapse. Enable in a subclass before finalize().
*/
public bool $offcanvas = false;

/**
* Offcanvas drawer placement when $offcanvas is enabled.
*/
public string $offcanvasPlacement = 'end';
private string $navBarName = 'nav';
private \Ease\Html\DivTag $containerFluid;
private ButtonTag $toggler;

/**
* App Menu.
Expand All @@ -70,7 +82,8 @@ public function __construct($brand = null, $name = 'navbar', $properties = [])
$this->leftContent = new UlTag(null, ['class' => 'navbar-nav flex-nowrap mb-2 mb-lg-0', 'style' => '--bs-scroll-height: 100px;']);
$this->rightContent = new UlTag(null, ['class' => 'navbar-nav ms-auto flex-nowrap mb-2 mb-lg-0']);

$this->containerFluid = $this->addItem(new \Ease\Html\DivTag([new ATag($this->mainpage, $brand, ['class' => 'navbar-brand']), $this->navBarToggler()], ['class' => 'container-fluid']));
$this->toggler = $this->navBarToggler();
$this->containerFluid = $this->addItem(new \Ease\Html\DivTag([new ATag($this->mainpage, $brand, ['class' => 'navbar-brand']), $this->toggler], ['class' => 'container-fluid']));
}

/**
Expand Down Expand Up @@ -136,12 +149,49 @@ public function navBarCollapse()
return new \Ease\Html\DivTag($this->leftContent, ['class' => 'collapse navbar-collapse', 'id' => $this->navBarName]);
}

/**
* Wrap the menu in an Offcanvas drawer (responsive offcanvas-in-navbar).
*
* @see https://getbootstrap.com/docs/5.3/components/navbar/#offcanvas
*
* @return \Ease\Html\DivTag Offcanvas drawer
*/
public function navBarOffcanvas()
{
$ocId = 'offcanvas'.$this->navBarName;

$header = new \Ease\Html\DivTag([
new \Ease\Html\H5Tag(_('Menu'), ['class' => 'offcanvas-title', 'id' => $ocId.'Label']),
new ButtonTag('', ['class' => 'btn-close', 'data-bs-dismiss' => 'offcanvas', 'aria-label' => _('Close')]),
], ['class' => 'offcanvas-header']);

$body = new \Ease\Html\DivTag($this->leftContent, ['class' => 'offcanvas-body']);

return new \Ease\Html\DivTag([$header, $body], [
'class' => 'offcanvas offcanvas-'.$this->offcanvasPlacement,
'tabindex' => '-1',
'id' => $ocId,
'aria-labelledby' => $ocId.'Label',
]);
}

/**
* Finalize NavBar.
*/
public function finalize(): void
{
$this->containerFluid->addItem($this->navbarCollapse());
if ($this->offcanvas) {
$ocId = 'offcanvas'.$this->navBarName;
$this->toggler->setTagProperties([
'data-bs-toggle' => 'offcanvas',
'data-bs-target' => '#'.$ocId,
'aria-controls' => $ocId,
]);
$this->containerFluid->addItem($this->navBarOffcanvas());
} else {
$this->containerFluid->addItem($this->navbarCollapse());
}

parent::finalize();
}

Expand Down
70 changes: 55 additions & 15 deletions src/Ease/TWB5/OffCanvas.php
Original file line number Diff line number Diff line change
Expand Up @@ -19,25 +19,65 @@
use Ease\Html\DivTag;
use Ease\Html\H5Tag;

/**
* Bootstrap Offcanvas slide-in panel.
*
* @see https://getbootstrap.com/docs/5.3/components/offcanvas/
*
* @author Vítězslav Dvořák <info@vitexsoftware.cz>
*/
class OffCanvas extends DivTag
{
public function __construct($id, $title, $bodyContent)
{
$header = new DivTag(
[
new H5Tag($title, ['class' => 'offcanvas-title', 'id' => $id.'Label']),
new ButtonTag('', ['class' => 'btn-close', 'data-bs-dismiss' => 'offcanvas', 'aria-label' => 'Close']),
],
['class' => 'offcanvas-header'],
);

$body = new DivTag($bodyContent, ['class' => 'offcanvas-body']);

parent::__construct([$header, $body], [
'class' => 'offcanvas offcanvas-start show',
public DivTag $header;
public DivTag $body;

/**
* Bootstrap Offcanvas.
*
* @param string $id Unique offcanvas ID
* @param mixed $title Header title
* @param mixed $bodyContent Offcanvas body content
* @param string $placement start|end|top|bottom
* @param array<string, string> $properties Additional offcanvas div properties
*/
public function __construct(
string $id,
$title = null,
$bodyContent = null,
string $placement = 'start',
array $properties = []
) {
parent::__construct(null, array_merge([
'tabindex' => '-1',
'id' => $id,
'aria-labelledby' => $id.'Label',
], $properties));
$this->addTagClass('offcanvas offcanvas-'.$placement);
$this->setTagID($id);

$this->header = new DivTag([
new H5Tag($title, ['class' => 'offcanvas-title', 'id' => $id.'Label']),
new ButtonTag('', ['class' => 'btn-close', 'data-bs-dismiss' => 'offcanvas', 'aria-label' => 'Close']),
], ['class' => 'offcanvas-header']);
Comment on lines +50 to +60

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Preserve an accessible name when $title is omitted.

$title is nullable, but this constructor always sets aria-labelledby and always renders the heading node. When callers pass null, the offcanvas ends up labelled by an empty element, which leaves it unnamed for assistive tech and can override a caller-supplied aria-label.

Suggested fix
     public function __construct(
         string $id,
         $title = null,
         $bodyContent = null,
         string $placement = 'start',
         array $properties = []
     ) {
-        parent::__construct(null, array_merge([
-            'tabindex' => '-1',
-            'aria-labelledby' => $id.'Label',
-        ], $properties));
+        $defaultProperties = ['tabindex' => '-1'];
+
+        if ($title !== null) {
+            $defaultProperties['aria-labelledby'] = $id.'Label';
+        } elseif (!isset($properties['aria-label'], $properties['aria-labelledby'])) {
+            $defaultProperties['aria-label'] = 'Offcanvas';
+        }
+
+        parent::__construct(null, array_merge($defaultProperties, $properties));
         $this->addTagClass('offcanvas offcanvas-'.$placement);
         $this->setTagID($id);
 
-        $this->header = new DivTag([
-            new H5Tag($title, ['class' => 'offcanvas-title', 'id' => $id.'Label']),
-            new ButtonTag('', ['class' => 'btn-close', 'data-bs-dismiss' => 'offcanvas', 'aria-label' => 'Close']),
-        ], ['class' => 'offcanvas-header']);
+        $headerItems = [];
+
+        if ($title !== null) {
+            $headerItems[] = new H5Tag($title, ['class' => 'offcanvas-title', 'id' => $id.'Label']);
+        }
+
+        $headerItems[] = new ButtonTag('', ['class' => 'btn-close', 'data-bs-dismiss' => 'offcanvas', 'aria-label' => 'Close']);
+
+        $this->header = new DivTag($headerItems, ['class' => 'offcanvas-header']);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/Ease/TWB5/OffCanvas.php` around lines 50 - 60, In the OffCanvas
constructor, avoid creating an empty accessible name when $title is null: only
add the 'aria-labelledby' attribute to parent::__construct(...) when $title is
non-null/non-empty, and only create the H5Tag($title, ...) (the heading node
with id $id.'Label') when $title is provided; if $title is omitted, still render
the close ButtonTag but do not render an empty H5Tag and do not set
aria-labelledby so any caller-supplied aria-label is preserved (adjust the
$this->header construction and the properties passed to parent::__construct
accordingly).

Comment on lines +57 to +60

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Localize the close button label.

aria-label => 'Close' hard-codes English in a reusable widget. That leaks untranslated UI text into every locale using this component.

Suggested fix
-            new ButtonTag('', ['class' => 'btn-close', 'data-bs-dismiss' => 'offcanvas', 'aria-label' => 'Close']),
+            new ButtonTag('', ['class' => 'btn-close', 'data-bs-dismiss' => 'offcanvas', 'aria-label' => _('Close')]),
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/Ease/TWB5/OffCanvas.php` around lines 57 - 60, The aria-label for the
close ButtonTag is hard-coded to 'Close' — update the OffCanvas header
construction to use the project's translation helper (e.g., t(), __(), or the
app's i18n function) instead of the literal string so the label is localized;
modify the ButtonTag attribute 'aria-label' to call the translation helper and
ensure the OffCanvas class (where $this->header is built) uses the correct
translation function and namespace so translated text is rendered at runtime.


$this->body = new DivTag($bodyContent, ['class' => 'offcanvas-body']);

$this->addItem($this->header);
$this->addItem($this->body);
}

/**
* Returns a button that toggles this offcanvas.
*
* @param mixed $label Button label
* @param string $type primary|secondary|success|danger|warning|info|light|dark
*/
public function triggerButton($label, string $type = 'primary'): ButtonTag
{
return new ButtonTag($label, [
'class' => 'btn btn-'.$type,
'data-bs-toggle' => 'offcanvas',
'data-bs-target' => '#'.$this->getTagID(),
'aria-controls' => $this->getTagID(),
]);
}
}
Loading