Tempest-Pico

Components for Tempest Framework with Pico CSS + UnoCSS

Components Overview

A List of the components I have created so far.

Accordion

Use <details> Element to toggle sections of content without JavaScript.

Live Example

Section #1

Markdown

This is Markdown!

Section #2

This is a Component

Section #3 # Markdown This is **NOT** *Markdown*!
Button (primary)

Markdown

This is Markdown!

Button (contrast outline)

This is a Component

Button (secondary) # Markdown This is **NOT** *Markdown*!

h2

Markdown

This is Markdown!

h3

This is a Component

h4

# Markdown This is **NOT** *Markdown*!

Example Code

<?php

declare(strict_types=1);

namespace TempestPico\Components\Examples;

use TempestPico\Components\Accordion;
use TempestPico\Components\Messages;

use function TempestPico\Support\Html\VT;

$content = [
    'Section #*1*' => "# Markdown\n\nThis is *Markdown*!",
    'Section #*2*' => new Messages(['variant' => 'info', 'md' => 'This is a Component']),
    'Section #*3*' => VT("# Markdown\n\nThis is **NOT** *Markdown*!"),
];

return VT(
    new Accordion('acco', $content),

    new Accordion(
        'acco',
        ['Button (primary)' => $content['Section #*1*']],
        Accordion::Options('button', 'primary'),
    ),
    new Accordion(
        'acco',
        ['Button (contrast outline)' => $content['Section #*2*']],
        Accordion::Options('button-outline', 'contrast'),
    ),
    new Accordion(
        'acco',
        ['Button (secondary)' => $content['Section #*3*']],
        Accordion::Options(btnColor: 'secondary'),
    ),

    new Accordion(
        'acco',
        ['h2' => $content['Section #*1*']],
        Accordion::Options('h2'),
    ),
    new Accordion(
        'acco',
        ['h3' => $content['Section #*2*']],
        Accordion::Options('h3'),
    ),
    new Accordion(
        'acco',
        ['h4' => $content['Section #*3*']],
        Accordion::Options('h4'),
    ),
);

HTML Output

<details name="acco"><summary>Section #<em>1</em>
</summary><h1>Markdown</h1>
<p>This is <em>Markdown</em>!</p>
</details><details name="acco"><summary>Section #<em>2</em>
</summary><article style="--pico-card-background-color: light-dark(#9bccfd, #1343a0)"><p>This is a Component</p>
</article></details><details name="acco"><summary>Section #<em>3</em>
</summary># Markdown

This is **NOT** *Markdown*!</details><details name="acco"><summary role="button">Button (primary)
</summary><h1>Markdown</h1>
<p>This is <em>Markdown</em>!</p>
</details><details name="acco"><summary role="button">Button (contrast outline)
</summary><article style="--pico-card-background-color: light-dark(#9bccfd, #1343a0)"><p>This is a Component</p>
</article></details><details name="acco"><summary>Button (secondary)
</summary># Markdown

This is **NOT** *Markdown*!</details><details name="acco"><summary><h2>h2
</h2></summary><h1>Markdown</h1>
<p>This is <em>Markdown</em>!</p>
</details><details name="acco"><summary><h3>h3
</h3></summary><article style="--pico-card-background-color: light-dark(#9bccfd, #1343a0)"><p>This is a Component</p>
</article></details><details name="acco"><summary><h4>h4
</h4></summary># Markdown

This is **NOT** *Markdown*!</details>

Code: Accordion.php

<?php

declare(strict_types=1);

namespace TempestPico\Components;

use TempestPico\Support\Html\HtmlViewTree;

use function Tempest\Support\Arr\map_iterable;
use function TempestPico\Support\Html\composeStr;
use function TempestPico\Support\Html\Html;
use function TempestPico\Support\Html\IMD;
use function TempestPico\Support\Html\MD;
use function TempestPico\Support\Html\VT;

/**
 *
 * @phpstan-type Opt = array{
 *      variant: 'default'|'button-outline'|'button'|'h6'|'h5'|'h4'|'h3'|'h2',
 *      btn-color: 'primary'|'secondary'|'contrast',
 *      open: false|IMD,
 * }
 *
 */
#[Doc('Use `<details>` Element to toggle sections of content without JavaScript.', ['Pico'])]
final class Accordion implements Component
{
    use IsComponent;

    /**
     * @param Opt $options
     * @param array<IDM, MD|Content> $content
     */
    public function __construct(
        protected string $name,
        protected array $content,
        protected ?array $options = null,
    ) {
        $this->options ??= self::Options();
        $this->setPaths();
    }

    /**
     * Create an options array for the constructor.
     *
     * @param Opt['variant'] $variant
     * @param Opt['btn-color'] $btnColor
     * @param Opt['open'] $open The index (IMD) of the open section
     *
     * @return Opt
     *
     */
    static function Options(
        string $variant = 'default',
        string $btnColor = 'primary',
        false|string $open = false,
    ): array {
        return [
            'variant' => $variant,
            'btn-color' => $btnColor,
            'open' => $open,
        ];
    }

    private function subViewSummary(string $summary): HtmlViewTree
    {
        return Html(
            element: in_array(
                $this->options['variant'],
                ['h6', 'h5', 'h4', 'h3', 'h2'],
                true,
            )
                ? $this->options['variant']
                : null,
            content: IMD($summary),
        );
    }

    public function getViewTree(): HtmlViewTree
    {
        return VT(...map_iterable(
            $this->content,
            fn ($content, $summary) => Html(
                element: 'details',
                content: [
                    Html(
                        'summary',
                        [$this->subViewSummary((string) $summary)], // FIXME:  PhpStan says $summary is `int|string`
                        [
                            'role' => in_array(
                                $this->options['variant'],
                                ['button', 'button-outline'],
                                true,
                            )
                                ? 'button'
                                : false,
                            'class' => composeStr([
                                'outline' => $this->options['variant'] === 'button-outline',
                                $this->options['btn-color'] => $this->options['variant'] !== 'default',
                            ]),
                        ],
                    ),
                    is_string($content) ? MD($content) : $content,
                ],
                attributes: [
                    'name' => $this->name,
                    'open' => $this->options['open'] !== false && $this->options['open'] === $summary,
                ],
            ),
        ));
    }
}
#Pico

Card

Puts the content inside a <article> tag, Pico styles it card-like.

Sorry, there is no Example

Code: Card.php

<?php

declare(strict_types=1);

namespace TempestPico\Components;

use Tempest\Support\Html\HtmlString;
use TempestPico\Support\Html\HtmlViewTree;

use function TempestPico\Support\Html\composeStr;
use function TempestPico\Support\Html\Html;

#[Doc('Puts the content inside a `<article>` tag, Pico styles it card-like.', ['Pico'])]
final class Card implements Component
{
    use IsComponent;

    /**
     * @param null|string|array<string, bool|callable(): bool> $class
     * @param null|string|array<string, bool|callable(): bool> $style
     **/
    public function __construct(
        public HtmlString|Component|HtmlViewTree $content,
        public null|HtmlString|Component|HtmlViewTree $header = null,
        public null|HtmlString|Component|HtmlViewTree $footer = null,
        public null|string|array $class = null,
        public null|string|array $style = null,
    ) {
        $this->setPaths();
    }

    public function getViewTree(): HtmlViewTree
    {
        return Html(
            element: 'article',
            content: [
                $this->header ? Html('header', $this->header) : null,
                $this->content,
                $this->footer ? Html('footer', $this->footer) : null,
            ],
            attributes: [
                'class' => composeStr($this->class),
                'style' => composeStr($this->style),
            ],
        );
    }
}
#Pico

CodeBlock

A semantic component for displaying syntax-highlighted code blocks.

Renders syntax-highlighted code in a <pre><code> block.

Use Tempest's highlighter.

TODO: use config

Live Example

// best: avoid appendContent()
Html('main', [
    Html('h1', 'Hello World'),
    Html('p', 'This is a paragraph'),
]);

// ugly:
Html('main', Html('h1', 'Hello World'))
    ->appendContent()('p', 'This is a paragraph'); 

<main><h1>Hello World</h1><p>This is a paragraph</p></main>

// Pitfall: appending to the wrong Element
Html('main')
    ('h1', 'Hello World')
    ->appendContent()('p', 'This is a paragraph');

<main><h1>Hello World<p>This is a paragraph</p></h1></main>

// If you really want `p` inside `h1` simply use
Html('main')('h1', 'Hello World')('p', 'This is a paragraph'); 

Example Code

<?php

declare(strict_types=1);

namespace TempestPico\Components\Examples;

use TempestPico\Components\CodeBlock;

use function TempestPico\Support\Html\Html;
use function TempestPico\Support\Html\VT;

$codeWrong = <<<'PHP'
    // Pitfall: appending to the wrong Element
    Html('main')
        ('h1', 'Hello World')
        ->appendContent()('p', 'This is a paragraph');
    PHP;

$htmlWrong = Html('main')('h1', 'Hello World')
    ->appendContent()('p', 'This is a paragraph');

$codeUgly = <<<'PHP'
    // ugly:
    Html('main', Html('h1', 'Hello World'))
        ->appendContent()('p', 'This is a paragraph'); 
    PHP;

$htmlUgly = Html('main', Html('h1', 'Hello World'))
    ->appendContent()('p', 'This is a paragraph');

$php = <<<'PHP'
    // best: avoid appendContent()
    Html('main', [
        Html('h1', 'Hello World'),
        Html('p', 'This is a paragraph'),
    ]);
    PHP;

$php2 = <<<'PHP'
    // If you really want `p` inside `h1` simply use
    Html('main')('h1', 'Hello World')('p', 'This is a paragraph'); 
    PHP;

return VT(
    new CodeBlock($php, 'php'),
    Html('hr'),
    new CodeBlock($codeUgly, 'php'),
    Html('hr'),
    new CodeBlock($htmlUgly->render()->toString(), 'html'),
    Html('hr'),
    new CodeBlock($codeWrong, 'sql'), // sql is semantic wrong but looks OK
    Html('hr'),
    new CodeBlock($htmlWrong->render()->toString(), 'html'),
    Html('hr'),
    new CodeBlock($php2, 'php'),
);

HTML Output

<pre data-theme="dark"><code data-lang="php"><span style="color: #6A9955;">// best: avoid appendContent()</span>
<span style="color: #4EC9B0;">Html</span>(<span style="color: #ce9178;">'main'</span>, [
    <span style="color: #4EC9B0;">Html</span>(<span style="color: #ce9178;">'h1'</span>, <span style="color: #ce9178;">'Hello World'</span>),
    <span style="color: #4EC9B0;">Html</span>(<span style="color: #ce9178;">'p'</span>, <span style="color: #ce9178;">'This is a paragraph'</span>),
]);</code></pre><hr /><pre data-theme="dark"><code data-lang="php"><span style="color: #6A9955;">// ugly:</span>
<span style="color: #4EC9B0;">Html</span>(<span style="color: #ce9178;">'main'</span>, <span style="color: #DCDCAA;">Html</span>(<span style="color: #ce9178;">'h1'</span>, <span style="color: #ce9178;">'Hello World'</span>))
    -&gt;<span style="color: #DCDCAA;">appendContent</span>()(<span style="color: #ce9178;">'p'</span>, <span style="color: #ce9178;">'This is a paragraph'</span>); </code></pre><hr /><pre data-theme="dark"><code data-lang="html">&lt;<span style="color: #569cd6;">main</span>&gt;&lt;<span style="color: #569cd6;">h1</span>&gt;Hello World&lt;/<span style="color: #569cd6;">h1</span>&gt;&lt;<span style="color: #569cd6;">p</span>&gt;This is a paragraph&lt;/<span style="color: #569cd6;">p</span>&gt;&lt;/<span style="color: #569cd6;">main</span>&gt;</code></pre><hr /><pre data-theme="dark"><code data-lang="sql">// Pitfall: appending to the wrong Element
<span style="color: #DCDCAA;">Html</span>('<span style="color: #ce9178;">main</span>')
    ('<span style="color: #ce9178;">h1</span>', '<span style="color: #ce9178;">Hello World</span>')
    -&gt;<span style="color: #DCDCAA;">appendContent</span>()('<span style="color: #ce9178;">p</span>', '<span style="color: #ce9178;">This is a paragraph</span>');</code></pre><hr /><pre data-theme="dark"><code data-lang="html">&lt;<span style="color: #569cd6;">main</span>&gt;&lt;<span style="color: #569cd6;">h1</span>&gt;Hello World&lt;<span style="color: #569cd6;">p</span>&gt;This is a paragraph&lt;/<span style="color: #569cd6;">p</span>&gt;&lt;/<span style="color: #569cd6;">h1</span>&gt;&lt;/<span style="color: #569cd6;">main</span>&gt;</code></pre><hr /><pre data-theme="dark"><code data-lang="php"><span style="color: #6A9955;">// If you really want `p` inside `h1` simply use</span>
<span style="color: #4EC9B0;">Html</span>(<span style="color: #ce9178;">'main'</span>)(<span style="color: #ce9178;">'h1'</span>, <span style="color: #ce9178;">'Hello World'</span>)(<span style="color: #ce9178;">'p'</span>, <span style="color: #ce9178;">'This is a paragraph'</span>); </code></pre>

Code: CodeBlock.php

<?php

declare(strict_types=1);

namespace TempestPico\Components;

use Tempest\Highlight\Highlighter;
use Tempest\Highlight\Themes\InlineTheme;
use Tempest\Support\Html\HtmlString;
use Tempest\Support\Path\Path;
use TempestPico\Support\Html\HtmlViewTree;

use function Tempest\Support\Filesystem\read_file;
use function Tempest\Support\path;
use function TempestPico\Support\Html\Html;

#[Doc('A semantic component for displaying syntax-highlighted code blocks.', ['Custom'])]
final class CodeBlock implements Component
{
    use IsComponent;

    private string $code;

    /**
     * @param 'php'|'html'|'js'|'text'|string $language
     */
    public function __construct(
        string|Path $codeOrFile,
        public string $language,
    ) {
        $this->code = match (true) {
            is_string($codeOrFile) => $codeOrFile,
            default => read_file($codeOrFile->toString()),
        };

        $this->setPaths();
    }

    public function getViewTree(): HtmlViewTree
    {
        //TODO: use Initializer + config
        /** @var null|'dark'|'light' sets data-theme attribute for styling */
        $picoTheme = 'dark';
        $HL = new Highlighter(new InlineTheme(
            path(__DIR__)
                ->dirname()
                ->dirname()
                ->append('/vendor/tempest/highlight/src/Themes/Css/dark-plus.css')
                ->toString(),
        ));

        $code = new HtmlString($HL->parse($this->code, $this->language));

        return Html('pre', [], ['data-theme' => $picoTheme])(
            'code',
            [$code],
            ['data-lang' => $this->language],
        );
    }
}
#Custom

Markdown

Renders the given GitHub flavored Markdown as HTML. Uses league/commonmark with close to all Extensions.

Sorry, there is no Example

Code: Markdown.php

<?php

declare(strict_types=1);

namespace TempestPico\Components;

use League\CommonMark\MarkdownConverter;
use Tempest\Support\Html\HtmlString;
use TempestPico\Support\Html\HtmlViewTree;

use function Tempest\get;
use function TempestPico\Support\Html\VT;

#[Doc('Renders the given GitHub flavored Markdown as HTML. Uses [league/commonmark](https://commonmark.thephpleague.com/2.x/) with close to all Extensions.', ['Helper'])]
final class Markdown implements Component
{
    use IsComponent;

    public function __construct(
        public string $md,
    ) {
        $this->setPaths();
    }

    public function getViewTree(): HtmlViewTree
    {
        $markdown = get(MarkdownConverter::class);
        /* Using directly HtmlString is dangerous,
         * but since the HTML in the markdown is escaped by league/commonmark, it should be safe to use here. */
        $content = new HtmlString($markdown->convert($this->md)->getContent());

        return VT($content);
    }
}
#Helper

Messages

Renders the given messages card-like. Type can be error, warning or info.

Sorry, there is no Example

Code: Messages.php

<?php

declare(strict_types=1);

namespace TempestPico\Components;

use Tempest\Support\Arr\ImmutableArray;
use TempestPico\Support\Html\HtmlViewTree;

use function Tempest\Support\arr;
use function TempestPico\Support\Html\Html;

#[Doc('Renders the given messages card-like. Type can be error, warning or info.', ['Custom'])]
final class Messages implements Component
{
    use IsComponent;

    /**
     * @var ImmutableArray<array-key, array{variant: 'error' | 'warning' | 'info' , md: string}> $msgs
     **/
    public ImmutableArray $msgs;

    /** @param array{variant: 'error' | 'warning' | 'info' , md: string} $msgs */
    public function __construct(
        array ...$msgs,
    ) {
        $this->msgs = arr($msgs);
        $this->setPaths();
    }

    public function getViewTree(): HtmlViewTree
    {
        return Html(
            element: null,
            content: $this->msgs
                ->map(static fn (array $msg) => match ($msg['variant']) {
                    'error' => new Card(
                        content: new Markdown($msg['md']),
                        header: new Markdown('**Error**'),
                        class: 'outline-4 outline-double outline-red',
                        style: '--pico-card-background-color: #ff000060;
                                --pico-card-border-color: rgb(248 113 113);
                                --pico-card-sectioning-background-color: #ff0000c0;
                                ',
                    ),
                    'warning' => new Card(
                        content: new Markdown($msg['md']),
                        header: new Markdown('**Warning**'),
                        class: ' border border-solid border-amber dark:border-amber-600',
                        style: '--pico-card-border-color: light-dark(#f2df0d, #e17100);
                                --pico-card-background-color: light-dark(#f2df0d33, #ffbf0033);
                                --pico-card-sectioning-background-color: light-dark(#f2df0d, #e17100);
                                ',
                    ),
                    'info' => new Card(
                        // @mago-expect analysis:mixed-argument
                        content: new Markdown($msg['md']),
                        style: '--pico-card-background-color: light-dark(#9bccfd, #1343a0)',
                    ),
                    default => new Card(
                        content: new Markdown($msg['md']),
                    ),
                })->toArray(),
        );
    }
}
#Custom

Stack

Deprecated: Use Helper fun VT();!

Stack multiple components on top of each other to put it in a single View "slot".

Sorry, there is no Example

Code: Stack.php

<?php

declare(strict_types=1);

namespace TempestPico\Components;

use Deprecated;
use TempestPico\Support\Html\HtmlViewTree;

use function TempestPico\Support\Html\Html;

#[Doc("Deprecated: Use Helper fun `VT();`!\n\nStack multiple components on top of each other to put it in a single View \"slot\".", ['Helper'])]
final class Stack implements Component
{
    use IsComponent;

    /**
     * @var array<array-key, Component> $components
     **/
    public array $components;

    #[Deprecated('Use `Html(null, $components);`')]
    public function __construct(
        Component ...$components,
    ) {
        $this->components = $components;
        $this->setPaths();
    }

    public function getViewTree(): HtmlViewTree
    {
        return Html(null, $this->components);
    }
}
#Helper

Table

A component that allows you to create tables.

Live Example

Example using the Component
CSSACSS classTailwind class
color:red;C(red)text-red
text-align: justify;Ta(j)text-justify
text-align-last: center; Tal(c)> I don’t know

Example Code

<?php

declare(strict_types=1);

namespace TempestPico\Components\Examples;

use TempestPico\Components\Table;

use function TempestPico\Support\Html\IMD;
use function TempestPico\Support\Html\VT;

return VT(
    new Table(
        // rowId => Header Text
        head: [
            'CSS' => 'CSS',
            'ACSS' => 'ACSS class',
            'TW' => 'Tailwind class',
        ],
        cells: [
            [
                'ID' => '34', // ignored (RowId is not in Head)
                'CSS' => 'color:red;',
                'ACSS' => 'C(red)',
                'TW' => 'text-red',
            ],
            [
                // no need to order
                'ACSS' => 'Ta(j)',
                'CSS' => 'text-align: justify;',
                'TW' => 'text-justify',
            ],
            [
                // you can use Views
                'CSS' => IMD('**t**ext-**a**lign-**l**ast: **c**enter;'),
                'ACSS' => 'Tal(c)',
                // missing TW => fallback
            ],
        ],
        primaryRow: 'CSS',
        options: Table::Options(
            caption: 'Example using the Component',
            fallback: IMD("> I don't know"),
        ),
    ),
);

HTML Output

<table><caption>Example using the Component</caption><thead><tr><th>CSS</th><th>ACSS class</th><th>Tailwind class</th></tr></thead><tbody><tr><th scope="row">color:red;</th><td>C(red)</td><td>text-red</td></tr><tr><th scope="row">text-align: justify;</th><td>Ta(j)</td><td>text-justify</td></tr><tr><th scope="row"><strong>t</strong>ext-<strong>a</strong>lign-<strong>l</strong>ast: <strong>c</strong>enter;
</th><td>Tal(c)</td><td>&gt; I don’t know
</td></tr></tbody></table>

Code: Table.php

<?php

declare(strict_types=1);

namespace TempestPico\Components;

use Stringable;
use Tempest\Support\Html\HtmlString;
use Tempest\View\View;
use TempestPico\Support\Html\HtmlViewTree;

use function Tempest\Support\Arr\has_key;
use function Tempest\Support\Arr\keys;
use function Tempest\Support\Arr\map_iterable;
use function TempestPico\Support\Html\composeStr;
use function TempestPico\Support\Html\Html;

/**
 * Generate a Table
 *
 * @mago-expect analysis:malformed-docblock-comment
 * @phpstan-type Opt = array{
 *      caption: null|string,
 *      fallback: Content,
 *
 *      striped: bool,
 *      scrollable: bool,
 *
 *      vertical: bool, // UNIMPLEMENTED
 * }
 */
#[Doc('A component that allows you to create tables.', ['Helper', 'Pico'])]
final class Table implements Component
{
    use IsComponent;

    /**
     *
     * @param array<string, Content> $head
     * @param array<string, Content>[] $cells
     * @param Opt $options
     */
    public function __construct(
        public array $head,
        public array $cells,
        public string $primaryRow,
        public array $options,
    ) {
        $this->setPaths();
    }

    /**
     * @param Opt['caption'] $caption
     * @param Opt['fallback'] $fallback
     * @param Opt['striped'] $striped
     * @param Opt['scrollable'] $scrollable
     * @param Opt['vertical'] $vertical
     *
     * @return Opt
     *
     *  @mago-expect lint:no-boolean-flag-parameter
     */
    static function Options(
        ?string $caption = null,
        // Cell content if unset or null
        string|Stringable|HtmlString|View|HtmlViewTree|null $fallback = '',

        bool $striped = true,
        bool $scrollable = true,

        // swap rows and cols
        bool $vertical = false, // UNIMPLEMENTED
    ): array {
        return [
            'caption' => $caption,
            'fallback' => $fallback,

            'striped' => $striped,
            'scrollable' => $scrollable,

            'vertical' => $vertical,
        ];
    }

    public function getViewTree(): HtmlViewTree
    {
        $getCellContent = fn (array $row, string $rowId) => has_key($row, $rowId)
            ? $row[$rowId]
            : $this->options['fallback'];

        $rowIds = keys($this->head);

        return Html(
            element: 'table',
            content: [
                // TODO: `slot(name: 'caption', ?wrapper: 'caption', ?if_unset = null)`
                $this->options['caption'] ? Html('caption', $this->options['caption']) : null,
                Html(
                    'thead',
                    [Html(
                        'tr',
                        map_iterable(
                            $this->head,
                            static fn ($cell) => $cell instanceof View ? $cell : Html('th', [$cell]),
                        ),
                    )],
                ),
                Html(
                    'tbody',
                    map_iterable($this->cells, fn ($row, int $colId) => Html(
                        'tr',
                        map_iterable(
                            $rowIds, // not $row to force the right order
                            fn (string $rowId) => (
                                $rowId === $this->primaryRow
                                    ? Html('th', $getCellContent($row, $rowId), ['scope' => 'row'])
                                    : Html('td', $getCellContent($row, $rowId))
                            ),
                        ),
                    )
                        // TODO: slot('footer', 'tfoot', fn …)
                    ),
                ),
            ],
            attributes: [
                'class' => composeStr([
                    'striped' => $this->options['striped'],
                    'scrollable' => $this->options['scrollable'],
                ]),
            ],
        );
    }
}