277 lines
9.0 KiB
PHP
277 lines
9.0 KiB
PHP
<?php
|
|
|
|
namespace PHPNative\Ui\Widget;
|
|
|
|
use PHPNative\Framework\TextRenderer;
|
|
|
|
class Table extends Container
|
|
{
|
|
private array $columns = [];
|
|
private array $rows = [];
|
|
private Container $headerContainer;
|
|
private Container $bodyContainer;
|
|
private null|int $selectedRowIndex = null;
|
|
private $onRowSelect = null;
|
|
private null|string $sortColumn = null;
|
|
private bool $sortAscending = true;
|
|
|
|
public function __construct(string $style = '')
|
|
{
|
|
// Table selbst ist kein Flex-Container; sie wird als Kind
|
|
// in einem flex-Layout benutzt (z.B. flex-1), aber intern
|
|
// werden Header und Body ganz normal vertikal gestapelt.
|
|
parent::__construct('w-full' . $style);
|
|
|
|
// Create header container
|
|
$this->headerContainer = new Container('flex flex-row w-full bg-gray-200 border-b-2 border-gray-400');
|
|
$this->addComponent($this->headerContainer);
|
|
|
|
// Create body container (scrollable)
|
|
// Use flex-1 to fill available space, overflow-auto to enable scrolling
|
|
$this->bodyContainer = new Container('flex flex-col w-full overflow-auto flex-1');
|
|
$this->addComponent($this->bodyContainer);
|
|
}
|
|
|
|
public function layout(null|TextRenderer $textRenderer = null): void
|
|
{
|
|
$this->headerContainer->layout($textRenderer);
|
|
$this->bodyContainer->layout($textRenderer);
|
|
|
|
parent::layout($textRenderer);
|
|
}
|
|
|
|
/**
|
|
* Define columns
|
|
*
|
|
* @param array $columns Array of column definitions ['key' => string, 'title' => string, 'width' => int|null]
|
|
*/
|
|
public function setColumns(array $columns): void
|
|
{
|
|
$this->columns = $columns;
|
|
$this->headerContainer->clearChildren();
|
|
|
|
foreach ($columns as $column) {
|
|
$key = $column['key'];
|
|
$title = $column['title'] ?? $key;
|
|
$width = $column['width'] ?? null;
|
|
|
|
$style = 'px-4 py-2 text-black font-bold border-r border-gray-300 hover:bg-gray-300 cursor-pointer';
|
|
if ($width) {
|
|
$style .= ' w-' . ((int) ($width / 4));
|
|
} else {
|
|
$style .= ' flex-1';
|
|
}
|
|
|
|
$headerLabel = new ClickableHeaderLabel($key, $this, $title, $style);
|
|
$this->headerContainer->addComponent($headerLabel);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Set table data
|
|
*
|
|
* @param array $data Array of row data (associative arrays)
|
|
* @param bool $preserveScroll Whether to preserve scroll position
|
|
*/
|
|
public function setData(array $data, bool $preserveScroll = false): void
|
|
{
|
|
$this->rows = $data;
|
|
|
|
$scrollPosition = null;
|
|
if ($preserveScroll) {
|
|
$scrollPosition = $this->bodyContainer->getScrollPosition();
|
|
}
|
|
|
|
$this->bodyContainer->clearChildren();
|
|
|
|
foreach ($data as $rowIndex => $row) {
|
|
$this->addRow($row, $rowIndex);
|
|
}
|
|
|
|
if ($preserveScroll && $scrollPosition !== null) {
|
|
$this->bodyContainer->setScrollPosition($scrollPosition['x'], $scrollPosition['y']);
|
|
}
|
|
|
|
// Debug-Ausgabe: Größe des Tabellen-Body und Viewport
|
|
$bodySize = $this->bodyContainer->getContentSize();
|
|
$bodyViewport = $this->bodyContainer->getContentViewport();
|
|
error_log(
|
|
sprintf(
|
|
'Table body debug: rows=%d, contentHeight=%d, viewportHeight=%d',
|
|
count($data),
|
|
(int) ($bodySize['height'] ?? 0),
|
|
(int) $bodyViewport->height,
|
|
),
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Add a single row
|
|
*/
|
|
private function addRow(array $rowData, int $rowIndex): void
|
|
{
|
|
$isSelected = $rowIndex === $this->selectedRowIndex;
|
|
$rowStyle = 'flex flex-row border-b border-gray-200 hover:bg-gray-400';
|
|
if ($isSelected) {
|
|
$rowStyle .= ' bg-blue-100';
|
|
}
|
|
|
|
$rowContainer = new Container($rowStyle);
|
|
|
|
// Check if any column has custom render (interactive components)
|
|
$hasCustomRender = false;
|
|
foreach ($this->columns as $column) {
|
|
if (isset($column['render']) && is_callable($column['render'])) {
|
|
$hasCustomRender = true;
|
|
break;
|
|
}
|
|
}
|
|
|
|
// Enable texture caching for table rows only if no custom render
|
|
// Custom renders may contain interactive components (buttons) that need event handling
|
|
if (!$hasCustomRender) {
|
|
$rowContainer->setUseTextureCache(true);
|
|
}
|
|
|
|
foreach ($this->columns as $column) {
|
|
$key = $column['key'];
|
|
$value = $rowData[$key] ?? '';
|
|
$width = $column['width'] ?? null;
|
|
|
|
$cellStyle = 'px-4 py-2 text-black border-r border-gray-300';
|
|
if ($width) {
|
|
$cellStyle .= ' w-' . ((int) ($width / 4));
|
|
} else {
|
|
$cellStyle .= ' flex-1';
|
|
}
|
|
|
|
// Check if column has custom render function
|
|
if (isset($column['render']) && is_callable($column['render'])) {
|
|
// Wrap custom cell content in a container so that
|
|
// width/padding styles are applied consistently.
|
|
$cellContent = $column['render']($rowData, $rowIndex);
|
|
$cellContainer = new Container($cellStyle);
|
|
$cellContainer->addComponent($cellContent);
|
|
$rowContainer->addComponent($cellContainer);
|
|
} else {
|
|
$cellLabel = new Label((string) $value, $cellStyle);
|
|
$rowContainer->addComponent($cellLabel);
|
|
}
|
|
}
|
|
|
|
// Make row clickable
|
|
$clickHandler = new class($rowContainer, $rowIndex, $this) extends Container {
|
|
private int $rowIndex;
|
|
private Table $table;
|
|
|
|
public function __construct(Container $rowContainer, int $rowIndex, Table $table)
|
|
{
|
|
$this->rowIndex = $rowIndex;
|
|
$this->table = $table;
|
|
parent::__construct('');
|
|
$this->addComponent($rowContainer);
|
|
}
|
|
|
|
public function handleMouseClick(float $mouseX, float $mouseY, int $button): bool
|
|
{
|
|
// First, let children (like buttons) handle the click
|
|
$handled = parent::handleMouseClick($mouseX, $mouseY, $button);
|
|
if ($handled) {
|
|
return true;
|
|
}
|
|
|
|
// If no child handled it, check if click is within row bounds
|
|
if (
|
|
$mouseX >= $this->viewport->x &&
|
|
$mouseX <= ($this->viewport->x + $this->viewport->width) &&
|
|
$mouseY >= $this->viewport->y &&
|
|
$mouseY <= ($this->viewport->y + $this->viewport->height)
|
|
) {
|
|
$this->table->selectRow($this->rowIndex);
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
};
|
|
|
|
$this->bodyContainer->addComponent($clickHandler);
|
|
}
|
|
|
|
/**
|
|
* Sort by column
|
|
*/
|
|
public function sortByColumn(string $columnKey): void
|
|
{
|
|
// Toggle sort direction if clicking same column
|
|
if ($this->sortColumn === $columnKey) {
|
|
$this->sortAscending = !$this->sortAscending;
|
|
} else {
|
|
$this->sortColumn = $columnKey;
|
|
$this->sortAscending = true;
|
|
}
|
|
|
|
// Sort the data
|
|
$sortedRows = $this->rows;
|
|
usort($sortedRows, function ($a, $b) use ($columnKey) {
|
|
$aVal = $a[$columnKey] ?? '';
|
|
$bVal = $b[$columnKey] ?? '';
|
|
|
|
// Natural sort for strings/numbers
|
|
$result = strnatcasecmp((string) $aVal, (string) $bVal);
|
|
|
|
return $this->sortAscending ? $result : -$result;
|
|
});
|
|
|
|
// Re-render with sorted data (reset scroll)
|
|
$this->setData($sortedRows, false);
|
|
}
|
|
|
|
/**
|
|
* Select a row
|
|
*/
|
|
public function selectRow(int $rowIndex): void
|
|
{
|
|
// Remember current scroll position of the body so that selection
|
|
// does not reset scrolling back to the top.
|
|
$scrollPosition = $this->bodyContainer->getScrollPosition();
|
|
|
|
$this->selectedRowIndex = $rowIndex;
|
|
|
|
// Trigger callback
|
|
if ($this->onRowSelect !== null) {
|
|
($this->onRowSelect)($rowIndex, $this->rows[$rowIndex] ?? null);
|
|
}
|
|
|
|
// Re-render rows to update selection but keep scroll
|
|
$this->setData($this->rows, true);
|
|
|
|
// Restore scroll position
|
|
$this->bodyContainer->setScrollPosition($scrollPosition['x'], $scrollPosition['y']);
|
|
}
|
|
|
|
/**
|
|
* Set row select callback
|
|
*/
|
|
public function setOnRowSelect(callable $callback): void
|
|
{
|
|
$this->onRowSelect = $callback;
|
|
}
|
|
|
|
/**
|
|
* Get selected row index
|
|
*/
|
|
public function getSelectedRowIndex(): null|int
|
|
{
|
|
return $this->selectedRowIndex;
|
|
}
|
|
|
|
/**
|
|
* Get selected row data
|
|
*/
|
|
public function getSelectedRow(): null|array
|
|
{
|
|
return $this->selectedRowIndex !== null ? ($this->rows[$this->selectedRowIndex] ?? null) : null;
|
|
}
|
|
}
|