makallio85 / rocksoftware-cakephp-ui
CakePHP 5 plugin with the shared screen frame of a Rock Software application: page layout, top bar, slide-out main menu, user menu, sign-in and error layouts, flash messages, styles and icons.
Package info
github.com/makallio85/rocksoftware-cakephp-ui
Type:cakephp-plugin
pkg:composer/makallio85/rocksoftware-cakephp-ui
Requires
- php: >=8.3
- cakephp/cakephp: ^5.2
- makallio85/rocksoftware-cakephp-settings: ^1.0
Requires (Dev)
- cakephp/cakephp-codesniffer: ^5.0
- phpstan/phpstan: ^2.1
- phpunit/phpunit: ^11.5 || ^12.0 || ^13.0
Suggests
- makallio85/rocksoftware-cakephp-users: Fills the user menu: the signed-in person, the other applications and sign-out
Provides
None
Conflicts
None
Replaces
None
- dev-dev
- v2.3.0
- v2.2.0
- v2.1.0
- v2.0.0
- v1.3.0
- v1.2.0
- v1.1.0
- v1.0.1
- v1.0.0
- dev-feature/vue-boot-kebab-button-zero
- dev-feature/format-percent
- dev-feature/shared-components-part2
- dev-feature/settings-app-name
- dev-feature/indigo-accent
- dev-feature/shared-components-part1
- dev-feature/topbar-slot-single-links
- dev-fix/sign-out-strict-csp
This package is auto-updated.
Last update: 2026-10-26 12:35:25 UTC
README
The shared screen frame for CakePHP 5 applications: the page layout, the top bar, the slide-out main menu, the user menu, the sign-in and error layouts, flash messages, styles and icons. Each application supplies only its icon, language and menu, and its name comes from the shared settings plugin, so every application shows the same frame and none keeps its own copy.
| Name | |
|---|---|
| Composer package | makallio85/rocksoftware-cakephp-ui |
| CakePHP plugin, namespace | RockSoftware/Ui, RockSoftware\Ui\ |
| Settings | Ui.*; the name from makallio85/rocksoftware-cakephp-settings |
| Translation domain | rock_software_ui (English msgids, Finnish .po) |
| Assets | /rock_software/ui/… (the plugin's webroot/) |
What the frame is
-
defaultlayout (signed in): a fixed white top bar with the hamburger, the brand, the application's own controls and the user menu; an off-canvas drawer with the main menu (hover flyouts on desktop, an accordion on phones, a pin that docks it open); an optional banner under the top bar; the page content. -
authlayout: a centred card with the brand mark and the application's name, for sign-in pages. -
errorlayout: the same card with the error and a link back to the start page. -
ajaxlayout: the content alone, for fragments a script swaps into the page. -
Elements:
topbar,navigation,breadcrumbandflash/*(default,success,error,warning,info, as Bootstrap alerts). -
Assets: Bootstrap 5.3 and Bootstrap Icons,
app-chrome.css(indigo accent, tile grid.kk-tile-grid, narrow page.kk-page-narrow) andapp-chrome.js(the drawer's pin, the phone accordion and the flyout position). -
Optional components, each switched on by the application:
breadcrumb: the trail of pages in the top bar, in place of the brand;modal:<kk-modal>, the one modal, aswindow.KkModal;listTable:<kk-list-table>, the one list table (table on desktop, cards on phones, filters, selection, a pager), aswindow.KkListTable;dateTimePicker:<kk-date-time-picker>, the one date and time picker, aswindow.KkDateTimePicker;fileDrop:<kk-file-drop>, the one file input, aswindow.KkFileDrop;vue: Vue 3's runtime-only build aswindow.Vue, for an application without Vue of its own;format: the date and number formats in a script, aswindow.KkFormat.
modal,listTable,dateTimePickerandfileDropare Vue 3 components, precompiled into one plain script that needs no template compiler (see Vue components).confirm: the confirmation dialog of buttons and links withdata-confirm, andwindow.KkConfirm.ask(); loaded by itself on a page that renders an askingUi->postButton();toast:window.appBanner, short messages in a corner.
confirmandtoastare plain scripts: no Vue, no build step. -
Shared in-page pieces (below): badges, the page header, the kebab menu, the confirmation, pagination, toasts, the empty state, form help texts, scrolling tab strips and the date and number formats.
Installing in an application
-
Composer. Run
composer require makallio85/rocksoftware-cakephp-ui:^2.3. It bringsmakallio85/rocksoftware-cakephp-settings, which holds the application's name. -
Plugins. Add both to
Application::bootstrap():$this->addPlugin(\RockSoftware\Settings\SettingsPlugin::class); $this->addPlugin(\RockSoftware\Ui\UiPlugin::class);
Install the settings plugin as its README says: its migrations, its
accessand its menu link. -
Theme. Use the plugin as the view theme, in
src/View/AppView.php:public function initialize(): void { $this->setTheme(\RockSoftware\Ui\UiPlugin::NAME); }
The theme's layouts, elements and assets come before the application's own, so
setLayout('auth'),setLayout('error')and$this->Flash->render()find the frame's. Delete the application's own copies. -
Assets. The files are served from
/rock_software/ui/…by CakePHP'sAssetMiddleware(or symlinked withbin/cake plugin assets symlink). -
Name. The top bar, the sign-in card and every page title show the setting
application.name; the sign-in card showsapplication.organization_nameunder it when it is not empty. Both are on the settings page (Settings › Application). Seed them in a migration of the application's own, after the settings plugin's, so the first deploy shows the right name:use RockSoftware\Settings\Settings; use RockSoftware\Settings\SettingsPlugin; public function up(): void { Settings::set(SettingsPlugin::APPLICATION_NAME, 'Example App'); Settings::set(SettingsPlugin::APPLICATION_ORGANIZATION_NAME, 'Example Ltd'); }
In the application's tests, seed the name the same way, or with the settings plugin's fixtures and
Settings::set(). -
Settings. In
config/app.php:'Ui' => [ 'brandIcon' => 'bi bi-rocket', ],
-
Main menu. Implement
RockSoftware\Ui\View\MainMenuInterfaceand set it as themainMenuview variable, usually inAppController::beforeRender():$this->set('mainMenu', new MainMenu($permissions, $badgeCounts));
groups()returns the groups the person sees, in order:[ [ 'title' => 'Work', 'icon' => 'bi bi-briefcase', 'items' => [ [ 'title' => 'Reports', 'url' => ['plugin' => null, 'controller' => 'Reports', 'action' => 'index'], 'badge' => 'pendingReports', // optional 'badgeLabel' => 'waiting for review', // optional, read by screen readers ], ], ], ]An item is active when the request's controller and plugin are its URL's.
badgeCounts()returns the count of each badge key; a badge shows when its count is above zero.A group with a
urland noitemsis one direct link: it has the group's look (icon and title), no caret and no flyout or accordion, and it is active the way an item is. Use it for a menu entry that would otherwise be a group of one:[ 'title' => 'Dashboard', 'icon' => 'bi bi-house', 'url' => ['plugin' => null, 'controller' => 'Dashboard', 'action' => 'index'], ]
Settings
Every setting has a default in UiPlugin::DEFAULTS.
| Setting | Default | |
|---|---|---|
brandIcon |
'bi bi-grid-1x2' |
The Bootstrap Icons class of the brand mark |
htmlLang |
null |
The <html lang>; null takes it from the locale |
bodyClass |
null |
Classes added to <body> beside the frame's kk-app / kk-auth |
titleSuffix |
null |
What follows the page title; null for · and application.name |
homeUrl |
'/' |
Where the brand and the error page's back link go |
storageKeyPrefix |
'ui' |
Prefix of the browser storage keys that keep the menu's pin and collapsed groups |
components |
all false |
['breadcrumb' => …, 'modal' => …, 'listTable' => …, 'dateTimePicker' => …, 'fileDrop' => …, 'vue' => …, 'format' => …, 'confirm' => …, 'toast' => …] |
elements |
[] |
The application's own elements, by layout and place (below) |
locale |
null |
The language of the frame's texts when it differs from the application's |
numberLocale |
null |
The locale of numbers, money and percentages when it differs from the texts' (fi_FI); null follows locale |
displayTimezone |
'Europe/Helsinki' |
The time zone Ui->date() and Ui->dateTime() show moments in |
In debug mode, the default and auth layouts prefix the title with [DEV] .
What the application adds to a page
-
Blocks:
title,meta,cssandscriptas usual, andbannerfor a notice under the top bar on one page. -
Elements by layout and place, for what every page of a layout needs, such as a Vite entry or data the application's scripts read:
'elements' => [ 'default' => [ 'head' => ['vite_css'], 'topbarEnd' => ['workspace_switcher'], 'banner' => ['maintenance_notice'], 'bodyEnd' => ['vite_js'], ], 'auth' => ['head' => ['vite_css'], 'bodyEnd' => ['vite_js']], ],
headrenders after the frame's styles and before themetaandcssblocks;topbarEndin the top bar, right-aligned just before the user menu (defaultonly), for a small control such as a switcher;bannerunder the top bar (defaultonly);bodyEndafter the frame's scripts and before thescriptblock. The top bar keeps one line: on a narrow screen thetopbarEndcontent shrinks and is clipped rather than wrapped, so keep it compact there (hide labels below 576px, let aform-selectshrink). -
User menu: the signed-in person, the other applications, the account link and sign-out come from
makallio85/rocksoftware-cakephp-users(UserMenuhelper) when it is loaded. An application without it sets theuserMenuItemsview variable in the same shape: entries of typeheader(label,detail),app(label,url,current),link(label,url) andsign_out(label,url, posted by a plain form, no script). No entries, no user menu. -
Content-Security-Policy: the frame renders no inline scripts, inline event handlers or
javascript:URLs, so it works underscript-src 'self'. -
Breadcrumb: with the component on, the
breadcrumbview variable lists the pages from the top down, each['title' => …, 'url' => array|string|null]; the last is the page itself. On phones only the page above shows, as a back link.
Upgrading from 1.x
Version 2.0 takes the application's name and the line under it from the settings plugin. The
settings Ui.appName and Ui.organizationName are gone; a value still set there is ignored.
- Require
makallio85/rocksoftware-cakephp-ui:^2.0, addSettingsPlugintoApplication::bootstrap()and install it as its README says (migrations,access, menu link). - Add a migration that seeds
application.nameandapplication.organization_namewithSettings::set(), as above, with the valuesUi.appNameandUi.organizationNameheld. - Remove
appNameandorganizationNamefromUiinconfig/app.php.
Shared in-page pieces
Everything here works without Vue or a build step and under a Content-Security-Policy of
script-src 'self' (no inline script, no inline handlers). Their styles (kk-components.css)
load on every layout. A template that uses the Ui helper before the layout runs loads it in
AppView::initialize():
$this->addHelper('RockSoftware/Ui.Ui');
The test application's components page (/components in the sandbox) shows all of them.
Badges
<?= $this->Ui->badge('Delivered', 'ok') ?> <?= $this->Ui->badge('Queued', 'pending', ['title' => 'Waiting for the gateway']) ?>
Tones: ok, pending, danger, muted (the default), info, tag
(UiHelper::BADGE_TONES); any other throws. The application maps its own states to a tone. The
markup is <span class="kk-badge kk-badge-ok">; the colours are set explicitly, as Bootstrap's
text-bg-* does not render in this theme. Options: title, class, escape.
Page header
<?= $this->element('ui/page_header', [ 'title' => 'Messages', 'subtitle' => 'Everything sent in the last 90 days.', // optional 'badges' => $this->Ui->badge('Live', 'ok'), // optional markup beside the title 'actions' => [ // optional; or a string of markup ['title' => 'New message', 'url' => ['action' => 'add'], 'icon' => 'bi bi-plus-lg', 'isPrimary' => true], ['title' => 'Refresh', 'type' => 'button', 'attributes' => ['id' => 'refresh']], ['title' => 'Purge', 'type' => 'post', 'url' => ['action' => 'purge'], 'confirm' => 'Purge every message?'], ], 'menu' => [['title' => 'Export', 'url' => ['action' => 'export']]], // optional kebab 'menuLabel' => 'More actions', // optional ]) ?>
An action is a link (url), a post (an asking Ui->postButton(), with confirm,
confirmTitle, confirmButton, confirmTone, data, method) or a button for a script.
On phones an action with an icon shows the icon alone (its title stays the accessible name).
Kebab menu
The row's and the page's actions, in one ⋮ dropdown aligned right. Nothing renders when no
item is left.
<?= $this->element('ui/kebab', ['items' => [ ['type' => 'header', 'title' => 'View'], ['type' => 'text', 'html' => $switchMarkup], // e.g. a form-switch ['title' => 'Edit', 'url' => ['action' => 'edit', $id], 'icon' => 'bi bi-pencil'], ['title' => 'Resend', 'url' => ['action' => 'resend', $id], 'isDisabled' => true], ['type' => 'button', 'title' => 'Archive…', 'icon' => 'bi bi-archive', // a script acts on it 'attributes' => ['data-kk-modal-open' => 'archive-' . $id]], ['type' => 'divider'], ['type' => 'post', 'title' => 'Delete', 'url' => ['action' => 'delete', $id], 'confirm' => 'Delete this message?', 'isDestructive' => true], ], 'label' => 'Actions', 'size' => 'sm']) ?>
size is sm (the default, for a row) or md.
A button item is a <button type="button" class="dropdown-item"> with no address: its
attributes (any HTML attributes) say what a script does with it, such as
data-kk-modal-open opening a markup modal. It takes icon, isDestructive and isDisabled
like a link. A text item is for markup that is no action (a switch), not for a button.
Confirmation before a destructive action
<?= $this->Ui->postButton('Delete', ['action' => 'delete', $id], [ 'class' => 'btn btn-outline-danger', 'confirm' => 'Messages already sent stay in the log. This cannot be undone.', 'confirmTitle' => 'Delete this provider?', // default: Are you sure? 'confirmButton' => 'Delete', // default: Confirm 'confirmTone' => 'danger', // or primary ]) ?>
This replaces postLink(…, ['confirm' => …]) and postButton(…, ['confirm' => …]), whose
prompt is an inline onclick that a strict policy refuses. The question goes into
data-confirm, data-confirm-title, data-confirm-button and data-confirm-tone, and
kk-confirm.js shows the dialog; the page loads it by itself. The form is d-inline unless
form.class says otherwise. The same attributes work on any submit button, form or link the
application writes itself (with the confirm component on). Only the button that submitted asks:
a form with a Save and a Delete button asks for Delete only.
From a script: window.KkConfirm.ask({message, title, button, tone}).then((isConfirmed) => …).
Pagination
<?= $this->element('ui/pagination') ?> <?= $this->element('ui/pagination', ['results' => $messages, 'modulus' => 6, 'showsCounter' => false]) ?>
"Showing 1–20 of 240" and the page links with Bootstrap 5 markup, separated squares, the current
page in the accent colour; the edges and ellipses drop on phones. Nothing renders for an empty
list, the counter alone for one page. The templates (UiHelper::PAGINATOR_TEMPLATES) apply to
the element only; for the table's sort links too, load the paginator with them:
$this->loadHelper('Paginator', ['templates' => \RockSoftware\Ui\View\Helper\UiHelper::PAGINATOR_TEMPLATES]);
Toasts
With the toast component on (or $this->Ui->requireComponent('toast') in one page's template):
window.appBanner.success('Saved.'); window.appBanner.error({ title: 'Could not send.', text: 'The gateway did not answer.' }); window.appBanner.warning('Check the address.'); window.appBanner.info('Refreshing…'); window.appBanner('success', 'Saved.', 0); // kind, message, ms until it closes (0 = until closed)
Top-right under the top bar on desktop, at the bottom on phones. Errors stay 6 s, the rest 3.5 s. The text is set as text, never as markup.
Empty state
<?= $this->element('ui/empty_state', [ 'title' => 'No messages yet', 'text' => 'Messages appear here once an application sends one.', // optional 'icon' => 'bi bi-inbox', // optional 'action' => $this->Html->link('New message', ['action' => 'add'], ['class' => 'btn btn-primary']), // optional ]) ?>
Form help texts and validation
An opt-in form helper, loaded as the application's Form:
$this->addHelper('Form', ['className' => 'RockSoftware/Ui.Form']);
$this->Form->control('sender_name', ['help' => 'Shown to the recipient.'])renders the text under the field asdiv.form-text(escaped) and pointsaria-describedbyat it.- A field with an error gets
is-invalidand its message.invalid-feedback. - Required fields carry no inline
oninvalid/oninputhandlers (core'sautoSetCustomValidity, which a strict policy refuses); pass'autoSetCustomValidity' => trueto have them back.
Tab strips that scroll
<ul class="nav nav-tabs nav-tabs-scrollable">: the tabs stay on one line and scroll sideways
on a narrow screen.
Dates and numbers
$this->Ui->date($value); // 26.10.2026 $this->Ui->dateTime($value); // 26.10.2026 08.14 (in the display time zone) $this->Ui->number(1842.3, 2); // 1 842,30 in Finnish, 1,842.30 in English $this->Ui->money('1842.30'); // 1 842,30 € in Finnish, €1,842.30 in English $this->Ui->money(7000, 'USD', 0); $this->Ui->percent(0.75); // 75 % in Finnish, 75% in English (a fraction) $this->Ui->percent('0.0425', 1); // 4,3 % / 4.3% $this->Ui->money('-0.001'); // 0,00 € / €0.00: what rounds to zero has no minus
A calendar day (Cake\I18n\Date, a YYYY-MM-DD string) shows as it is; a moment
(DateTimeInterface, an ISO string, a Unix timestamp) shows in the displayTimezone setting
(default Europe/Helsinki). A missing or unreadable value shows —, or the last argument.
Numbers, money and percentages follow the numberLocale setting, else the locale setting, else
the application's locale: an application with English texts and Finnish numbers sets
'numberLocale' => 'fi_FI'.
PHP's intl writes a locale only when its ICU data is installed: Alpine's icu-libs carries
English alone (icu-data-en), so an Alpine image that shows Finnish numbers adds
icu-data-full; without it they come out in English.
Vue components
<kk-list-table>, <kk-modal>, <kk-date-time-picker>, <kk-file-drop> and <kk-pager> are
Vue 3 single-file components in resources/js/components/, built into one plain script,
webroot/js/components/kk-vue-components.js, with render functions compiled ahead of time. No
template is compiled in the browser and nothing uses eval, so they run under a
Content-Security-Policy of script-src 'self'. The calendar (@vuepic/vue-datepicker) is
bundled into that script; nothing loads from a CDN. Their styles are plain files in
webroot/css/ the layout loads.
The script reads Vue from window.Vue the first time a component is used, so one copy of Vue
serves the application and the components, and the application's Vue may load after the
plugin's scripts. It defines window.KkUi (a Vue plugin that registers every component),
window.KkListTable, window.KkModal, window.KkDateTimePicker, window.KkFileDrop and
window.KkPager.
Mounting them in an application
Without Vue of its own (server-rendered pages): switch on vue beside the components, and
write the components with the plugin's elements. Each element writes plain markup with
data-kk-* attributes, which the script turns into the component once the page is parsed:
'components' => ['vue' => true, 'listTable' => true, 'modal' => true, 'dateTimePicker' => true, 'fileDrop' => true],
An element loads its own component on the page (ui/list_table → listTable and so on), so
only vue has to be on. Markup a script adds later is mounted with
window.KkUi.mount(element).
The markup mounts once the page is parsed and window.Vue is there: as parsing ends when Vue
is already loaded (the plugin's vue, or Vue as a plain script before the plugin's), else after
DOMContentLoaded, which comes after deferred and module scripts, so a Vite bundle that sets
window.Vue (below) gets its elements mounted too. Should Vue come later still, the script
retries for a few seconds after the window's load, then warns in the console; such a page
calls window.KkUi.mount(document) itself once it has set window.Vue.
With Vue loaded as a plain script (window.Vue, the global build): leave vue off, switch
on the components, and register them in the application's own apps:
const app = Vue.createApp({ … }); app.use(window.KkUi); // or app.component('kk-list-table', window.KkListTable) app.mount('#suppliers');
A template written in the page's HTML (an in-DOM template) is compiled in the browser, which
needs Vue's full build and 'unsafe-eval'. Under a strict policy, use the elements above or
render functions (Vue.h(window.KkListTable, {…}), as the components page's demo.js does).
With Vite (Vue in the application's bundle): leave vue off and switch on the components,
so the layout loads their styles and texts. Then either
-
hand the bundle's Vue to the plugin's script before the first component is used, and register them:
import * as Vue from 'vue'; window.Vue = Vue; createApp(Root).use(window.KkUi).mount('#app');
-
or import the sources and let Vite build them into the application's bundle (the application then installs
@vuepic/vue-datepickerat the version in the plugin'spackage.json):import { KkUi } from '../../vendor/makallio85/rocksoftware-cakephp-ui/resources/js/index.js'; createApp(Root).use(KkUi).mount('#app');
Every component reads its texts from the layout's kk-ui-texts block (English defaults in
resources/js/texts.js), so it speaks the page's language either way.
List table
A sortable table on desktop, one card per row below Bootstrap lg, the filters above it,
optional checkboxes with a bulk bar, and a pager. It never fetches.
From PHP (no Vue of its own), a server-paged list:
<?= $this->element('ui/list_table', [ 'results' => $messages, // the paginated rows 'columns' => [ ['key' => 'sentAt', 'label' => 'Sent', 'sortField' => 'sent_at'], ['key' => 'recipient', 'label' => 'Recipient', 'sortable' => false], ['key' => 'status', 'label' => 'Status', 'sortable' => false, 'isHtml' => true], ], 'rows' => array_map(fn($message) => [ 'id' => $message->id, 'sentAt' => $this->Ui->dateTime($message->sent_at), 'recipient' => $message->recipient, 'status' => $this->Ui->badge($message->status_label, $message->tone), // markup: isHtml '_actions' => $this->element('ui/kebab', ['items' => $rowMenu($message)]), ], $messages->toArray()), 'filters' => [ ['key' => 'q', 'label' => 'Search', 'type' => 'text', 'param' => 'q'], ['key' => 'status', 'label' => 'Status', 'type' => 'select', 'param' => 'status', 'options' => [['value' => 'failed', 'label' => 'Failed']]], ['key' => 'sent', 'label' => 'Sent', 'type' => 'date_range', 'paramFrom' => 'from', 'paramTo' => 'to'], ], 'filterValues' => ['q' => $q, 'status' => $status, 'sent' => ['from' => $from, 'to' => $to]], 'selectable' => true, 'selectionName' => 'ids', // ticked rows post as ids[] … 'selectionForm' => 'bulk-form', // … with this form 'bulk' => '<button type="submit" form="bulk-form" class="btn btn-sm btn-outline-danger">Delete</button>', ]) ?>
With results, the order is the paginator's: a header click reloads the page with
?sort=<sortField or key>&direction=asc|desc (allow the field in the paginator's
sortableFields), the ui/pagination element shows under the rows, and a filter with param
reloads the page with its value (a text filter on Enter or when the field is left). Without
results, the rows are all on the page and are sorted and filtered in the browser: a filter
with matchField compares that row field, a text filter searches the plain columns (or a row's
_search). A row with url opens it when clicked (rowClickable).
In a Vue app:
<kk-list-table :rows="rows" :columns="columns" :filters="filters" v-model:filter-values="filterValues" v-model:sort-key="sortKey" v-model:sort-asc="sortAsc" selectable v-model:selected="selected" row-clickable @row-click="open" is-server-sorted :pagination="{ page, perPage: 20, count }" @update:page="load"> <template #cell-status="{ row }"><span class="kk-badge kk-badge-ok">{{ row.status }}</span></template> <template #actions="{ row }">…</template> <template #bulk="{ selected }">…</template> <template #pager>…a pager of your own…</template> </kk-list-table>
Props: rows, rowKey, columns (key, label, sortable, sortValue(row), class),
filters (select with options and multiple, text, date_range; match(row, value)
filters here, none means the server's), filterValues, sortKey, sortAsc, isServerSorted,
pagination (page, perPage, count, optional url with {page}), selectable,
selected, rowClickable, loading, error, emptyText, countLabel(n). Events: the
update:* of each, row-click, and filter-commit (key, value) when a filter's value is settled.
<kk-pager> is the pager alone: page, perPage, count, modulus, url, showsCounter.
Modal
The one modal. The header holds the title, the move handle (desktop) and the close button, and nothing else; the body scrolls; the footer holds the buttons. Esc, the close button and the backdrop close it; on phones it fills the screen.
From PHP: the body and footer are the page's own markup, so a form in it posts as usual.
<?= $this->element('ui/modal', [ 'name' => 'reject-12', 'title' => 'Reject the request?', 'body' => $this->Form->create(null, ['url' => ['action' => 'reject', 12], 'id' => 'reject-12-form']) . $this->Form->control('reason') . $this->Form->end(), 'footer' => '<button type="button" class="btn btn-outline-secondary" data-kk-modal-close>Cancel</button>' . '<button type="submit" form="reject-12-form" class="btn btn-danger">Reject</button>', 'size' => 'md', // sm, md, lg, xl ]) ?> <button type="button" class="btn btn-outline-danger" data-kk-modal-open="reject-12">Reject</button>
From a script: window.KkUi.openModal('reject-12'), window.KkUi.closeModal('reject-12').
In a Vue app: <kk-modal :open="isOpen" title="…" @close="isOpen = false">…<template #footer>…</template></kk-modal>
(props open, title, size, closable; slots default, title, tabs, footer).
The older <div data-kk-modals hidden> blocks (kk-modal-markup.js) still work, but their
template is compiled in the browser: they need Vue's full build and 'unsafe-eval'.
Date and time picker
A field showing DD.MM.YYYY HH.mm that opens the calendar with a time list. Its value is
wall-clock time in the display time zone (displayTimezone, default Europe/Helsinki):
Y-m-d H:i (mode datetime), Y-m-d (date), H:i (time) or Y-m (month).
<?= $this->element('ui/date_time_picker', [ 'name' => 'send_after', 'label' => 'Send after', 'value' => $message->send_after, // a moment is shown in Helsinki time 'help' => 'Helsinki time.', 'error' => $message->getError('send_after')[0] ?? null, 'mode' => 'datetime', // date, time, month 'timeStep' => 30, 'min' => '2026-01-01', 'max' => null, ]) ?>
The form posts the value as before; read it back in the same zone:
new DateTime($data['send_after'], $this->Ui->displayTimezone()) (or the zone setting in a
controller). $this->Ui->pickerValue($moment, $mode) turns a day or moment into the value. In a
Vue app: <kk-date-time-picker v-model="form.sendAfter" mode="datetime" size="sm"></kk-date-time-picker>
(props mode, modelType, timeStep, size, clearable, min, max, inputClass,
disabled, placeholder, inputId, ariaLabel, describedBy).
File drop
A dashed target to click or drop files on, the chosen files listed with their sizes; a file of the wrong type or too large is refused with a message.
<?= $this->element('ui/file_drop', [ 'name' => 'attachment', 'label' => 'Attachment', 'accept' => '.pdf,image/*', 'maxBytes' => 10 * 1024 * 1024, 'hint' => 'PDF or an image, at most 10 MB.', 'isMultiple' => false, ]) ?>
The file input stays in the form (create it with 'type' => 'file') and holds the files
chosen, so the form posts them as before. In a Vue app:
<kk-file-drop v-model="files" accept="image/*" :max-bytes="…" hint="…" @change="upload"></kk-file-drop>
(events update:modelValue, change, rejected).
Formats in a script
With the format component on (no Vue needed), window.KkFormat writes what the PHP helper
writes, in the page's number locale and display time zone:
KkFormat.formatDate('2026-10-26T22:30:00Z'); // 27.10.2026 KkFormat.formatDateTime('2026-10-26T06:14:00Z'); // 26.10.2026 08.14 KkFormat.formatNumber(1842.3, 2); // 1 842,30 in Finnish, 1,842.30 in English KkFormat.formatMoney('1842.30'); // 1 842,30 € in Finnish, €1,842.30 in English KkFormat.formatPercent(0.75); // 75 % in Finnish, 75% in English KkFormat.formatFileSize(2516582); // 2.4 MB
A string without an offset is read as UTC; a missing value shows — or the last argument.
A Vite app imports the same functions from resources/js/format.js.
Building the components
The built files in webroot/ are committed with their sources. After changing anything in
resources/js/ or resources/css/:
npm ci npm test # the components' unit tests (Vitest) npm run build # writes webroot/js/components/kk-vue-components.js, kk-format.js, # vue.runtime.global.prod.js and webroot/css/kk-date-time-picker.css
CI builds again and fails when the output differs from what is committed, and opens the
components page in Chromium behind the test application's strict policy
(tests/browser/demo-page.mjs), failing on any console error or policy violation.
Translations
Every text of the frame is in English (the msgids) and Finnish
(resources/locales/fi/rock_software_ui.po). The application's locale picks one; the locale
setting overrides it. The optional components' scripts read their texts from a JSON block the
layout writes (#kk-ui-texts).
Developing
docker compose up -d # the test application on http://127.0.0.1:8085 docker compose exec app vendor/bin/phpunit docker compose exec app composer cs-check docker compose exec app composer stan
The sandbox serves tests/test_app with a fictional menu and user so the frame can be looked at
in a browser.