HTML output
StaticPHP\Presentation\Models\Tables\Output\Html is the default output generator. It reads
a table and returns a string of markup; it holds no data of its own beyond a handful of
presentation settings.
<?php
use StaticPHP\Presentation\Models\Tables\Output\Html;
$table->outputGenerator = new Html($table);echo $table->makeOutput();It implements
OutputInterface and uses the
TableInstance trait, so the constructor is the trait’s - one by-reference table argument
and nothing else.
Settings
Section titled “Settings”| Property | Type | Default |
|---|---|---|
$type |
TableType |
TableType::FULL_HTML |
$classNames |
string |
'table' |
$tableAttributes |
array |
[] |
$editableTableType |
EditableTableType |
EditableTableType::WHOLE_TABLE |
$dataRowAttributes |
array |
[] |
$dataRowClasses |
array |
['data-row'] |
$dataColumnAttributes |
array |
[] |
$dataColumnClasses |
array |
['data-col'] |
$classNames is a single string written straight into class="..." on the <table>.
$tableAttributes is an array of complete attribute strings, imploded with a space;
elements may be closures taking ($table).
The four $dataRow* / $dataColumn* arrays are the table-wide defaults that each column’s
own $dataColumnAttributes and $dataColumnClasses are merged onto. Their elements may
be closures: the row pair receives ($rowIndex, $rowItem, $columnCount), the column pair
($column, $rowIndex, $rowItem, $columnCount). Anything that evaluates to a falsy value is
dropped by array_filter() before the implode, so a closure returning '' or null
contributes nothing:
<?php
$output = new Html($table);$output->classNames = 'table table-striped table-hover';$output->tableAttributes = ['data-url="/admin/users/update"'];$output->dataRowClasses[] = fn($rowIndex, $row) => empty($row['active']) ? 'text-muted' : '';TableType
Section titled “TableType”Enums/TableType.php, a backed string enum with 2 cases:
| Case | Value | Emits |
|---|---|---|
FULL_HTML |
full_html |
Wrapper divs, the table, and a footer holding the pagination |
TABLE_ONLY |
table_only |
The <table> element and nothing else |
FULL_HTML is the default and wraps the table in
<div class="block block-rounded"> <div class="block-content block-content-full"> <div class="table-responsive"> ...table... </div> </div> <div class="block-footer">...pagination...</div></div>TABLE_ONLY skips both the wrapper and the pagination call, which is what makes it the
right choice for an ajax refresh that replaces the table body in place - and the only way
to render a table that was built without a Pagination, since paginationLinks() throws
when there is none.
<table id="table_b3563e689c7425dd" class="table table-striped" data-url="/users/update" > <thead>
<tr><th class="" ><div class="d-flex align-items-center"><div class="hidden-print d-print-none"><a href="/users//name=desc" >Name</a></div><div class="visible-print d-none d-print-inline">Name</div> <span class="fa fas fa-sort-alpha-down sort-icon"></span></div></th><th class="" ><div class="d-flex align-items-center"><div class="hidden-print d-print-none"><a href="/users//active=asc" >Active</a></div><div class="visible-print d-none d-print-inline">Active</div></div></th></tr> <tr id="table_filters_b3563e689c7425dd"><td class="" ><div class="filter-input-wrap"><input type="text" class="form-control form-control-sm input-xs filter " id="filter_name" ><button type="button" class="btn-close filter-clear-btn" tabindex="-1" aria-label="Clear filter"></button></div></td><td class="" ><div class="filter-input-wrap"><input type="text" class="form-control form-control-sm input-xs filter " id="filter_active" ><button type="button" class="btn-close filter-clear-btn" tabindex="-1" aria-label="Clear filter"></button></div></td></tr>
</thead> <tbody>
<tr title="" class="data-row" ><td class="data-col field_name" >Anna</td><td class="data-col field_active" > <div class="form-check form-switch"> <input type="checkbox" name="active" id="active_7" value="1" class="form-check-input update_field " checked="checked"
> <label class="form-check-label" for="active_7"></label> </div></td></tr>
</tbody> <tfoot>
</tfoot> </table>That was produced with $classNames = 'table table-striped' and
$tableAttributes = ['data-url="/users/update"'], on a table with one editable
FieldType::SWITCH column.
The structure of makeOutput()
Section titled “The structure of makeOutput()”makeOutput() builds the markup from seven calls, in this order:
<table id="table_{tableId}" class="{classNames}"{tableAttributes}> <thead> {rowWithPosition(HEAD_TOP)} {titleRow()} {filtersRow()} {rowWithPosition(HEAD_BOTTOM)} </thead> <tbody> {rowWithPosition(BODY_TOP)} {tableBody()} {rowWithPosition(BODY_BOTTOM)} </tbody> <tfoot> {rowWithPosition(FOOT_TOP)} {rowWithPosition(FOOT_BOTTOM)} </tfoot></table>Three of those throw if the corresponding object was never built by initData():
Exception: Sort is not initializedException: Filter is not initializedException: Pagination is not initializedThe pagination one only fires in FULL_HTML mode, because that is the only branch that
calls paginationLinks().
Before any of it, makeOutput() walks the columns and adds a data-field_{id}_options
attribute to the <table> for every column whose $editFieldType is one of the three
select types, holding json_encode() of the options. Grouped options add
data-field_{id}_options_groupped="true". These exist for client-side editing scripts to
read; nothing server side uses them.
showOutput() sends Content-Type: text/html; charset=utf-8 and echoes the result.
Escaping
Section titled “Escaping”Html::escape() is a static wrapper on htmlspecialchars() with the flags that matter. It
is two lines, because the job of turning an arbitrary value into text belongs to
Html::text():
<?php
public static function escape($value): string{ return htmlspecialchars(self::text($value), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');}
public static function text(mixed $value): string{ if (is_string($value)) { return $value; }
if ($value instanceof \Stringable) { return (string) $value; }
return (is_scalar($value) ? (string) $value : '');}ENT_QUOTES covers both quote characters, so the result is safe in a double- or
single-quoted attribute as well as in text. ENT_SUBSTITUTE replaces invalid UTF-8 rather
than returning an empty string, which is the failure mode that turns a mis-encoded byte into
a silently blank cell. Outside a table,
html_escape() is the free function
with the same flags. An object gets a text form if it has one: Stringable is cast and then
escaped like any other string, and only a value with no text form at all - an array, a plain
object - becomes the empty string. null becomes '' and true becomes '1':
'<b>&"x"</b>' -> '<b>&"x"</b>'"O'Neil" -> 'O'Neil'null -> ''true -> '1'['a'] -> ''42 -> '42'Stringable -> '<b>obj</b>'stdClass -> ''Cell values are escaped at exactly one place, at the end of the per-cell work in
htmlDataRow():
<?php
if ($dataValueIsMarkup === false && $column->escapeDataHtml === true) { $dataValue = self::escape($dataValue);}$dataValueIsMarkup is set by any branch that replaced the value with markup it built
itself - the checkbox, switch, select, textarea and input branches - because those already
escaped what they interpolated and escaping again would show the tags. Everything else is
raw data and gets escaped unless the column opted out with
$escapeDataHtml: false.
Several things are not escaped, and must not carry user input. These are the ones the application controls:
Column::$description, spliced into the headertitle="..."attribute.Column::$sortLinkAttribute, spliced into the<a>tag.$columnAttributes,$columnClasses,$dataColumnAttributes,$dataColumnClasses,$dataRowAttributes,$dataRowClasses,$tableAttributes- all complete attribute or class fragments.Column::$dataColumnPrefixand$dataColumnAddon.- The
$titleargument ofhtmlDataRow(), written intotitle="...".
Two more carry request data, which is the difference that matters when auditing a table:
-
The sort link
href.sortLinkHtml()writes'<a href="' . $url . '" 'with no escaping at all.$urlisSort::url()with%sortsubstituted, andSort::url()is the stringinitData()built as"{$urlPrefix}{$filterData}/%sort"- so the filter string the request supplied is interpolated into an attribute verbatim. A filter value holding a"closes the attribute. -
The filter input value.
filterInputValue()writes' value="' . str_replace('"', '"', $parsedData[$field]['title']) . '"', and the same fordata-value=.
The header row
Section titled “The header row”titleRow() emits one <th> per visible column carrying sortLinkHtml(), plus that
column’s $columnAttributes and $columnClasses.
sortLinkHtml() returns the bare $title when $sortEnabled is false. Otherwise it builds
two copies of the label - one in <div class="hidden-print d-print-none"> wrapping an <a>,
one in <div class="visible-print d-none d-print-inline"> as plain text - so the link
disappears in print stylesheets. The active column gets a trailing
<span class="fa fas fa-sort-alpha-down sort-icon"> (-up when descending).
sortUrl() computes the target: see
sorting.
The filter row
Section titled “The filter row”filtersRow() emits one <td> per visible column carrying filterInputField(). Both
respect Column::$showColumn.
filterInputField() switches on $filterFieldType - the full mapping is on
columns. What every branch shares:
id="filter_{columnId}".- The classes
form-control form-control-sm input-xs filter, plus the column’s$filterInputClasses, plusform-select form-select-smfor the select branches. $filterInputAttributes, anddisabled="disabled"when$filterEnabledis false.- An empty string when
$filterHiddenis true - the cell is still emitted, the control is not.
The clear button
Section titled “The clear button”Every branch the user can type into or pick from - the text, number and date inputs and all
five select branches - sets a $clearable flag, and an enabled clearable control is wrapped
before it is returned:
<?php
if ($clearable === true && $forColumn->filterEnabled !== false) { $html = '<div class="filter-input-wrap' . ($hasActiveFilter ? ' has-value' : '') . '">' . $html . '<button type="button" class="btn-close filter-clear-btn"' . ' tabindex="-1" aria-label="Clear filter"></button>' . '</div>';}Two branches are not wrapped: SELECT_ALL_CHECKBOX, which is a header control rather than a
filter, and a column with $filterEnabled: false, whose control is disabled and has
nothing to clear. $filterHidden: true returns before any of it.
has-value is isset($parsedData[$forColumn->id]) - the column carries a filter entry, from
the filter string or from a $filterDefaultValue. It is a styling hook, not state: the
button is present either way, and clearing is the application’s script’s job. btn-close is
a Bootstrap 5 class name; filter-input-wrap, has-value and filter-clear-btn are this
package’s own and have no styling anywhere in it.
filterInputValue(string $field, ?string $compare = null, bool $checkbox = false): string
supplies the current value. With no $compare it returns value="{title}" for an input, plus
data-value="{value}" when title and value differ. With a $compare it returns
selected="selected" or checked="checked" when that option is the active one, matching
against value and handling the array case that a multiple select produces.
The select branches build their options from $filterSelectOptions, or from
[0 => 'No', 1 => 'Yes'] for SWITCH, CHECKBOX and SELECT_NO_YES. An empty option
carrying $filterTitle is prepended unless $filterSelectSkipEmptyDefault is set, and
$filterSelectDefaultDisabled makes that option disabled.
Grouped options
Section titled “Grouped options”$filterSelectOptionsGroups switches the branch to <optgroup>s. It is a second array
whose keys index into $filterSelectOptions: each group key is looked up there for that
group’s options, and a group with no matching key emits an empty <optgroup>. The four
properties work together:
| Property | Role |
|---|---|
$filterSelectOptionsGroups |
[groupKey => group], in render order |
$filterSelectOptionsGroupTitleKey |
Key to read the label out of a group; the group itself is the label when unset |
$filterSelectOptionsIdKey |
Key to read an option’s value; the array key is used when unset |
$filterSelectOptionsTitleKey |
Key to read an option’s label; the option itself is used when unset |
<?php
new Column( 'country', title: 'Country', dataKey: 'country', sortBy: 'country', sortDefaultColumn: true, filterTitle: 'Any country', filterFieldType: FieldType::SELECT, filterSelectOptions: [ 'baltics' => [['id' => 'lv', 'name' => 'Latvia'], ['id' => 'ee', 'name' => 'Estonia']], 'nordics' => [['id' => 'fi', 'name' => 'Finland']], ], filterSelectOptionsIdKey: 'id', filterSelectOptionsTitleKey: 'name', filterSelectOptionsGroups: [ 'baltics' => ['label' => 'Baltics'], 'nordics' => ['label' => 'Nordics'], ], filterSelectOptionsGroupTitleKey: 'label',);filterInputField() on that column, with country=ee in the filter, returns one line:
<div class="filter-input-wrap has-value"><select class="form-control form-control-sm input-xs filter form-select form-select-sm" id="filter_country" ><option value="">Any country</option><optgroup label="Baltics"><option value="lv">Latvia</option><option value="ee" selected="selected">Estonia</option></optgroup><optgroup label="Nordics"><option value="fi">Finland</option></optgroup></select><button type="button" class="btn-close filter-clear-btn" tabindex="-1" aria-label="Clear filter"></button></div>The empty $filterTitle option is emitted before the first <optgroup>, and the active
option carries selected="selected" exactly as in the flat case.
inputValue(string $value, ?string $compare = null, bool $checkbox = false): string is the
same idea without the filter lookup, used by the editable-cell branches.
Data rows
Section titled “Data rows”tableBody() iterates the rows, wrapping each htmlDataRow() call in the table’s
$beforeDataRow and $afterDataRow closures. With no rows at all it returns a single
placeholder row:
<tr><td colspan="{columnCount}" class="table-empty table-secondary">No record was found</td></tr>That string is not translated and there is no hook to replace it.
htmlDataRow(int $rowIndex, array $rowItem, string $title = '', array $rowClasses = []): string
does the per-cell work. For each visible column, in order:
- Read the value:
$dataKeyas a closure gets($column, $rowIndex, $rowItem, $columnCount), otherwise it is a key into the row array. - Apply
$dataFormatterthroughformatData(). - If
$rowIndex < 0- a meta row - emit a bare<td>and stop here. - Resolve the row id, trying
$column->idKey, then the row array, then$table->idKey, falling back to the row index. It is escaped once and reused in everyid=anddata-id=below. - Decide editability: the column’s
$isEditableand the table’s, both expanded if they are closures. - Merge the column’s data classes and attributes onto the generator’s.
- Apply the
ColumnTypeswitch (ROW_NUMBER,SELECT_ALL_CHECKBOX). - Apply the
$editFieldTypeswitch. - Escape, unless step 7 or 8 produced markup.
- Wrap in
$dataColumnPrefix/$dataColumnAddon,$dataColumnBage, and the expandable text divs.
The cell ends up as <td{attributes}>{prefix}{value}{addon}</td>. Every column gets a
field_{id} class unless it is being rendered as a by-field edit target.
rowWithPosition() renders the average, sum and custom meta rows at a given
RowPosition, in that order, with row
indexes -1, -2 and -3 and the class pairs table-avg-row, table-sum-row and
table-custom-row, each alongside table-meta-row.
Editable cells
Section titled “Editable cells”Two switches have to be on. Table::$isEditable is the table-wide one and
Column::$isEditable the per-column one; a cell is editable only if both expand to truthy.
Either may be a closure, and both are called with ($column, $rowIndex, $rowItem, $columnCount) - the row is in the arguments, because editability is usually a property of
the record rather than of the column. A closure ignoring its arguments is how “editable if
the current user has the permission” is expressed; one reading $rowItem is how “editable
while the invoice is still a draft” is.
Non-editable rows render bare text next to editable rows that render controls, which makes
column widths jump between rows. Table::$showReadonlyInputs
turns that off: every row renders the control, and the non-editable ones get
disabled="disabled" on a <select> or a checkbox and readonly="readonly" on a
<textarea> or an input. None of them carry update_field, so the client-side save handler
never binds to a row it must not write.
EditableTableType
Section titled “EditableTableType”Enums/EditableTableType.php, a backed string enum with 2 cases:
| Case | Value | Meaning |
|---|---|---|
WHOLE_TABLE |
whole_table |
Every editable cell renders its form control immediately |
BY_FIELD |
by_field |
Cells render as text and carry the data needed to build a control on click |
WHOLE_TABLE, the default, puts a real <input>, <select>, <textarea> or checkbox in
each editable cell, with an update_field class for a script to bind to. The switch
rendering from the TABLE_ONLY capture above is an example.
BY_FIELD takes the other branches early: the select, textarea and default-input branches
all break out before generating anything when $editableTableType === BY_FIELD. The cell
keeps its formatted text and instead gains
data-name="{columnId}",data-type="{editFieldType->value}",data-raw_value="{editValue}"with",<and>replaced,- a
table_edit_field_triggerclass, - a
<span class="table_edit_display field_{columnId}">wrapper around the value.
<table id="table_3fc87043b772590b" class="table" > <thead>
<tr><th class="" ><div class="d-flex align-items-center"><div class="hidden-print d-print-none"><a href="/users//name=desc" >Name</a></div><div class="visible-print d-none d-print-inline">Name</div> <span class="fa fas fa-sort-alpha-down sort-icon"></span></div></th></tr> <tr id="table_filters_3fc87043b772590b"><td class="" ><div class="filter-input-wrap"><input type="text" class="form-control form-control-sm input-xs filter " id="filter_name" ><button type="button" class="btn-close filter-clear-btn" tabindex="-1" aria-label="Clear filter"></button></div></td></tr>
</thead> <tbody>
<tr title="" class="data-row" ><td data-name="name" data-type="text" data-raw_value="Anna" class="data-col table_edit_field_trigger" ><span class="table_edit_display field_name">Anna</span></td></tr>
</tbody> <tfoot>
</tfoot> </table>Note that SWITCH and CHECKBOX have no BY_FIELD early exit, so those two render their
checkbox in both modes.
$editKey picks which row key supplies the value being edited; it defaults to $dataKey,
and the renderer assigns that default onto the column object the first time it renders a
row. A closure gets ($column, $rowIndex, $rowItem, $columnCount).
The client-side script that consumes update_field, table_edit_field_trigger,
data-raw_value, parent_checkbox / child_checkbox and the
data-field_{id}_options attributes is not part of this package. The markup is a contract
with an application asset that has to be written to match.
Pagination links
Section titled “Pagination links”paginationLinks() returns '' when $pageCount <= 1, and otherwise a
<nav aria-label="Page navigation"> wrapping a
<ul class="pagination justify-content-end"> with five kinds of item: first, previous, the
page window from $pagesFrom to $pagesTo, next, and last. The current page’s <li> gets
active and aria-current="page"; first and previous get disabled on page one, next and
last on the final page.
The four steppers are built by one protected method rather than inline, which is what keeps their accessible names consistent:
<?php
protected function paginationLink(string $url, string $symbol, string $label, bool $disabled): string$symbol is the « / ‹ / › / » entity and goes into a
<span aria-hidden="true">; $label is First, Previous, Next or Last and goes into
both an aria-label on the anchor and a <span class="visually-hidden"> beside the symbol.
A screen reader reads the word, not the chevron.
paginationUrl($url, $page) is the substitution, str_replace('%pagination', $page, $url).
Page one of a 613-row table, 50 per page, with $pagesToShow reduced to 3:
<nav aria-label="Page navigation"><ul class="pagination justify-content-end"><li class="page-item disabled"><a class="page-link" href="/users/name=an/name=asc/1" tabindex="-1" aria-label="First"><span aria-hidden="true">«</span><span class="visually-hidden">First</span></a></li><li class="page-item disabled"><a class="page-link" href="/users/name=an/name=asc/0" tabindex="-1" aria-label="Previous"><span aria-hidden="true">‹</span><span class="visually-hidden">Previous</span></a></li><li class="page-item active" aria-current="page"><a class="page-link" href="/users/name=an/name=asc/1">1</a></li><li class="page-item"><a class="page-link" href="/users/name=an/name=asc/2">2</a></li><li class="page-item"><a class="page-link" href="/users/name=an/name=asc/3">3</a></li><li class="page-item"><a class="page-link" href="/users/name=an/name=asc/2" aria-label="Next"><span aria-hidden="true">›</span><span class="visually-hidden">Next</span></a></li><li class="page-item"><a class="page-link" href="/users/name=an/name=asc/13" aria-label="Last"><span aria-hidden="true">»</span><span class="visually-hidden">Last</span></a></li></ul></nav>See pagination for how the window is computed.