diff --git a/app-test/_includes/AppTest.php b/app-test/_includes/AppTest.php
index 4d65c20..6053f37 100644
--- a/app-test/_includes/AppTest.php
+++ b/app-test/_includes/AppTest.php
@@ -110,6 +110,8 @@ private static function getNavigationGroup(string $baseUrl = '/app-test/'): arra
'icon' => 'bi bi-chat-left',
'url' => $baseUrl . 'interactive/virtual.php',
'items' => [
+ new Item(['name' => 'Tabs', 'url' => $baseUrl . 'interactive/tabs.php']),
+ new Item(['name' => 'Tabs Menu', 'url' => $baseUrl . 'interactive/tabs-menu.php']),
new Item(['name' => 'Modal', 'url' => $baseUrl . 'interactive/modal.php']),
new Item(['name' => 'Toast', 'url' => $baseUrl . 'interactive/toast.php']),
new Item(['name' => 'Virtual Page', 'url' => $baseUrl . 'interactive/virtual.php']),
diff --git a/app-test/basic/button.php b/app-test/basic/button.php
index 19efa5b..f8676c8 100644
--- a/app-test/basic/button.php
+++ b/app-test/basic/button.php
@@ -12,14 +12,37 @@
require_once __DIR__ . '/../init-ui.php';
$bar = View::addTo(Ui::layout(), ['defaultTailwind' => ['inline-block, my-4']]);
+Button::addTo($bar, ['label' => 'Link', 'type' => 'link']);
-Button::addTo($bar, ['label' => 'Primary']);
-Button::addTo($bar, ['label' => 'Secondary', 'color' => 'secondary', 'type' => 'outline']);
-Button::addTo($bar, ['label' => 'Info', 'color' => 'info', 'type' => 'link']);
-Button::addTo($bar, ['label' => 'Success', 'color' => 'success'])->setType('link');
-Button::addTo($bar, ['label' => 'Warning', 'color' => 'warning']);
-Button::addTo($bar, ['label' => 'Error', 'color' => 'error']);
-Button::addTo($bar, ['label' => 'Neutral', 'color' => 'neutral']);
+$type = 'contained';
+$bar = View::addTo(Ui::layout(), ['defaultTailwind' => ['inline-block, my-4']]);
+Button::addTo($bar, ['label' => 'Primary', 'type' => $type]);
+Button::addTo($bar, ['label' => 'Secondary', 'color' => 'secondary', 'type' => $type]);
+Button::addTo($bar, ['label' => 'Info', 'color' => 'info', 'type' => $type]);
+Button::addTo($bar, ['label' => 'Success', 'color' => 'success'])->setType($type);
+Button::addTo($bar, ['label' => 'Warning', 'color' => 'warning', 'type' => $type]);
+Button::addTo($bar, ['label' => 'Error', 'color' => 'error', 'type' => $type]);
+Button::addTo($bar, ['label' => 'Neutral', 'color' => 'neutral', 'type' => $type]);
+
+$type = 'outline';
+$bar = View::addTo(Ui::layout(), ['defaultTailwind' => ['inline-block, my-4']]);
+Button::addTo($bar, ['label' => 'Primary', 'type' => $type]);
+Button::addTo($bar, ['label' => 'Secondary', 'color' => 'secondary', 'type' => $type]);
+Button::addTo($bar, ['label' => 'Info', 'color' => 'info', 'type' => $type]);
+Button::addTo($bar, ['label' => 'Success', 'color' => 'success'])->setType($type);
+Button::addTo($bar, ['label' => 'Warning', 'color' => 'warning', 'type' => $type]);
+Button::addTo($bar, ['label' => 'Error', 'color' => 'error', 'type' => $type]);
+Button::addTo($bar, ['label' => 'Neutral', 'color' => 'neutral', 'type' => $type]);
+
+$type = 'text';
+$bar = View::addTo(Ui::layout(), ['defaultTailwind' => ['inline-block, my-4']]);
+Button::addTo($bar, ['label' => 'Primary', 'type' => $type]);
+Button::addTo($bar, ['label' => 'Secondary', 'color' => 'secondary', 'type' => $type]);
+Button::addTo($bar, ['label' => 'Info', 'color' => 'info', 'type' => $type]);
+Button::addTo($bar, ['label' => 'Success', 'color' => 'success'])->setType($type);
+Button::addTo($bar, ['label' => 'Warning', 'color' => 'warning', 'type' => $type]);
+Button::addTo($bar, ['label' => 'Error', 'color' => 'error', 'type' => $type]);
+Button::addTo($bar, ['label' => 'Neutral', 'color' => 'neutral', 'type' => $type]);
$bar = View::addTo(Ui::layout(), ['defaultTailwind' => ['inline-block, my-4']]);
Button::addTo($bar, ['label' => 'Home'])->addIcon(new Icon(['iconName' => 'bi-house-fill']));
diff --git a/app-test/form/form-model-controller.php b/app-test/form/form-model-controller.php
index 97c1cec..64fa328 100644
--- a/app-test/form/form-model-controller.php
+++ b/app-test/form/form-model-controller.php
@@ -85,8 +85,8 @@
)
), 500);
-$c = Form\Control\Input::addTo($formContainer, ['controlName' => 'std1'])->setValue('test');
-$c->onChange(JsFunction::anonymous()
+$c1 = Form\Control\Input::addTo($formContainer, ['controlName' => 'std1'])->setValue('test');
+$c1->onChange(JsFunction::anonymous()
->execute(
JsToast::notifyWithJs(
Jquery::withSelector('input[name=\'std1\']')->val()
@@ -94,3 +94,4 @@
), 500);
Ui::viewDump($f, 'form');
+Ui::viewDump($c, 'ctl');
diff --git a/app-test/interactive/tabs-menu.php b/app-test/interactive/tabs-menu.php
new file mode 100644
index 0000000..ceb8e6b
--- /dev/null
+++ b/app-test/interactive/tabs-menu.php
@@ -0,0 +1,45 @@
+setTabsMenuTemplate(Ui::templateFromFile(__DIR__ . '/template/tabs-menu.html'));
+
+$homeTab = $tabs->addTab(new Tab(['name' => 'home']));
+View::addTo($homeTab)->setTextContent('This is Home content.');
+
+$profileTab = $tabs->addTab(new Tab(['name' => 'profile']));
+View::addTo($profileTab)->setTextContent('This is Profile content.');
+
+$userTab = $tabs->addTab(new Tab(['name' => 'preferences']));
+View::addTo($userTab)->setTextContent('This is Preferences content.');
+
+$adminTab = $tabs->addTab(new Tab(['name' => 'admin']))->disabled();
+View::addTo($adminTab)->setTextContent('This is Admin content.');
+
+View\Divider::addTo(Ui::layout(), ['verticalSpace' => '12']);
+
+View::addTo(Ui::layout())->setTextContent('Show how a Vue property, like an icon name, can be added to Tab component and be available
+within the menu template.');
+
+$tabs = Tabs::addTo(Ui::layout());
+$tabs->setTabsMenuTemplate(Ui::templateFromFile(__DIR__ . '/template/tabs-menu-icon.html'));
+
+$homeTab = $tabs->addTab(new Tab(['name' => 'home']))->addProperty('icon', 'bi-house-fill');
+View::addTo($homeTab)->setTextContent('This is Home content.');
+
+$profileTab = $tabs->addTab(new Tab(['name' => 'profile']))->addProperty('icon', 'bi-person-fill');
+View::addTo($profileTab)->setTextContent('This is Profile content.');
+
+$userTab = $tabs->addTab(new Tab(['name' => 'preferences']))->addProperty('icon', 'bi-gear-fill');
+View::addTo($userTab)->setTextContent('This is Preferences content.');
+
+$adminTab = $tabs->addTab(new Tab(['name' => 'admin']))->disabled()->addProperty('icon', 'bi-person-fill-lock');
+View::addTo($adminTab)->setTextContent('This is Admin content.');
diff --git a/app-test/interactive/tabs.php b/app-test/interactive/tabs.php
new file mode 100644
index 0000000..d2ddae2
--- /dev/null
+++ b/app-test/interactive/tabs.php
@@ -0,0 +1,63 @@
+getModel()->tryLoadAny()->get('id');
+
+$btnGoTo = Button::addTo(Ui::layout(), ['label' => 'Go to country tab', 'type' => 'text']);
+$btnEnableUser = Button::addTo(Ui::layout(), ['label' => 'Enable User Tab', 'type' => 'text']);
+$btnDisableUser = Button::addTo(Ui::layout(), ['label' => 'Disable User Tab', 'type' => 'text']);
+
+$tabs = Tabs::addTo(Ui::layout());
+Jquery::addEventTo($btnGoTo, 'click')->execute($tabs->jsActivateTabName('country'));
+Jquery::addEventTo($btnEnableUser, 'click')->execute($tabs->jsEnableTabName('user'));
+Jquery::addEventTo($btnDisableUser, 'click')->execute($tabs->jsDisableTabName('user'));
+
+$homeTab = $tabs->addTab(new Tab(['name' => 'home']));
+$fn = $homeTab->jsOnInitTab(\Fohn\Ui\Js\JsFunction::arrow());
+$fn->execute(\Fohn\Ui\Js\Js::from('console.log(\'homeTab on init\')'));
+
+$fn = $homeTab->jsOnShowTab(\Fohn\Ui\Js\JsFunction::arrow());
+$fn->execute(\Fohn\Ui\Js\Js::from('console.log(\'homeTab on show\')'));
+
+$fn = $homeTab->jsOnHideTab(\Fohn\Ui\Js\JsFunction::arrow());
+$fn->execute(\Fohn\Ui\Js\Js::from('console.log(\'homeTab on hide\')'));
+
+View::addTo($homeTab)->setTextContent('This is home tab content.');
+
+$profileTab = $tabs->addTab(new Tab(['name' => 'country']));
+
+$form = Form::addTo($profileTab);
+$form->addControls($modelCtrl->factoryFormControls($id));
+$form->onSubmit(function (Form $f) use ($modelCtrl, $id) {
+ if ($errors = $modelCtrl->saveModelUsingForm($id, $f->getControls())) {
+ $f->addValidationErrors($errors);
+ }
+
+ return JsToast::success('Saved!');
+});
+
+$userTab = $tabs->addTab(new Tab(['name' => 'user']));
+View::addTo($userTab)->setTextContent('This is user tab content.');
+$b = Button::addTo($userTab, ['label' => 'Reload ' . ($_GET['test'] ?? 0), 'type' => 'text', 'size' => 'small']);
+Jquery::addEventTo($b, 'click')->execute(JsReload::view($b, ['test ' => random_int(0, 100)]));
+
+$tabs->activateTabName('user');
+
+Ui::viewDump($tabs, 'tab');
diff --git a/app-test/interactive/template/tabs-menu-icon.html b/app-test/interactive/template/tabs-menu-icon.html
new file mode 100644
index 0000000..212caae
--- /dev/null
+++ b/app-test/interactive/template/tabs-menu-icon.html
@@ -0,0 +1,16 @@
+
+
+
+ -
+
+
+
+
+
diff --git a/app-test/interactive/template/tabs-menu.html b/app-test/interactive/template/tabs-menu.html
new file mode 100644
index 0000000..321363e
--- /dev/null
+++ b/app-test/interactive/template/tabs-menu.html
@@ -0,0 +1,16 @@
+
+
+
+ -
+
+
+
+
+
diff --git a/src/Component/Form.php b/src/Component/Form.php
index a9b03a5..51c9933 100644
--- a/src/Component/Form.php
+++ b/src/Component/Form.php
@@ -186,7 +186,7 @@ public function addControl(Form\Control $control, string $layoutName = self::MAI
protected function registerControl(Control $control): void
{
- $this->assertControlAsName($control->getControlName());
+ $this->assertControlHasName($control->getControlName());
$this->assertControlIsUnique($control->getControlName());
$control->formStoreId = $this->getPiniaStoreId(self::PINIA_PREFIX);
$this->controls[$control->getControlName()] = $control;
@@ -282,7 +282,7 @@ private function assertControlIsUnique(string $controlName): void
}
}
- private function assertControlAsName(string $controlName): void
+ private function assertControlHasName(string $controlName): void
{
if (!$controlName) {
throw new Exception('Trying to add a form control without name.');
diff --git a/src/Component/Tabs.php b/src/Component/Tabs.php
new file mode 100644
index 0000000..07eb8fe
--- /dev/null
+++ b/src/Component/Tabs.php
@@ -0,0 +1,136 @@
+ */
+ protected array $tabs = [];
+
+ protected string $activeTabName = '';
+
+ protected function initRenderTree(): void
+ {
+ parent::initRenderTree();
+ }
+
+ public function addTab(Tab $tab): Tab
+ {
+ $this->registerTab($tab);
+ $this->addView($tab, self::TAB_REGION_NAME);
+
+ return $tab;
+ }
+
+ public function activateTabName(string $name): self
+ {
+ $this->activeTabName = $name;
+
+ return $this;
+ }
+
+ public function jsActivateTabName(string $name): JsRenderInterface
+ {
+ // @phpstan-ignore-next-line
+ return $this->jsGetStore(self::PINIA_PREFIX)->activateByName($name);
+ }
+
+ public function jsEnableTabName(string $name): JsRenderInterface
+ {
+ // @phpstan-ignore-next-line
+ return $this->jsGetStore(self::PINIA_PREFIX)->enableByName($name);
+ }
+
+ public function jsDisableTabName(string $name): JsRenderInterface
+ {
+ // @phpstan-ignore-next-line
+ return $this->jsGetStore(self::PINIA_PREFIX)->disableByName($name);
+ }
+
+ /**
+ * Replace Tabs menu template.
+ * The template must use Tab component template props:
+ * - tabs, an array of {name: '', caption: '', disabled: false...}
+ * - currentIndex, an integer value representing the current activated tab.
+ * - activate, a function with an index argument that activate the tab.
+ */
+ public function setTabsMenuTemplate(HtmlTemplate $template, string $region = 'TabMenu'): self
+ {
+ $this->getTemplate()->dangerouslySetHtml($region, $template->renderToHtml());
+
+ return $this;
+ }
+
+ protected function registerTab(Tab $tab): void
+ {
+ $this->assertTabHasName($tab->getName());
+ $this->assertTabIsUnique($tab->getName());
+
+ if (!$tab->getCaption()) {
+ $tab->setCaption(ucfirst($tab->getName()));
+ }
+
+ $tab->tabStoreId = $this->getPiniaStoreId(self::PINIA_PREFIX);
+ $this->tabs[$tab->getName()] = $tab;
+ }
+
+ private function assertTabHasName(string $tabName): void
+ {
+ if (!$tabName) {
+ throw new Exception('Tab must have a name.');
+ }
+ }
+
+ private function assertTabIsUnique(string $tabName): void
+ {
+ if (array_key_exists($tabName, $this->tabs)) {
+ throw (new Exception('This tab name is already added.'))
+ ->addMoreInfo('Tab name:', $tabName);
+ }
+ }
+
+ protected function setTemplateProps(): void
+ {
+ $props['storeId'] = $this->getPiniaStoreId(self::PINIA_PREFIX);
+
+ $tabList = [];
+ foreach ($this->tabs as $tab) {
+ $tabList[] = $tab->getProperties();
+ }
+ $props['tabList'] = $tabList;
+ $props['activeTabIdx'] = array_search($this->activeTabName, array_keys($this->tabs), true) ?: 0;
+
+ foreach ($props as $key => $value) {
+ $this->getTemplate()->setJs($key, Type::factory($value));
+ }
+ }
+
+ protected function beforeHtmlRender(): void
+ {
+ $this->setTemplateProps();
+
+ $this->createVueApp(self::COMP_NAME, [], $this->getDefaultSelector());
+ parent::beforeHtmlRender();
+ }
+}
diff --git a/src/Component/Tabs/Tab.php b/src/Component/Tabs/Tab.php
new file mode 100644
index 0000000..c788fd1
--- /dev/null
+++ b/src/Component/Tabs/Tab.php
@@ -0,0 +1,146 @@
+.
+ */
+
+namespace Fohn\Ui\Component\Tabs;
+
+use Fohn\Ui\Component\VueInterface;
+use Fohn\Ui\Component\VueTrait;
+use Fohn\Ui\Js\Js;
+use Fohn\Ui\Js\JsFunction;
+use Fohn\Ui\View;
+
+class Tab extends View implements VueInterface
+{
+ use VueTrait;
+
+ private const FN_INIT_KEY = 'init';
+ private const FN_SHOW_KEY = 'show';
+ private const FN_HIDE_KEY = 'hide';
+
+ public string $defaultTemplate = 'vue-component/tabs/tab.html';
+
+ public string $tabStoreId;
+
+ protected string $name = '';
+ protected string $caption = '';
+ protected bool $isDisabled = false;
+
+ /** @var array Tab properties that can be used in template within parent tabs property. */
+ protected array $properties = [];
+
+ /** @var array JsFunction to execute when corresponding event occurs. */
+ private array $onActiveHandlers = [];
+
+ protected function initRenderTree(): void
+ {
+ parent::initRenderTree();
+ }
+
+ public function getName(): string
+ {
+ return $this->name;
+ }
+
+ public function getCaption(): string
+ {
+ return $this->caption;
+ }
+
+ public function disabled(): self
+ {
+ $this->isDisabled = true;
+
+ return $this;
+ }
+
+ public function enable(): self
+ {
+ $this->isDisabled = false;
+
+ return $this;
+ }
+
+ public function isDisabled(): bool
+ {
+ return $this->isDisabled;
+ }
+
+ public function setName(string $name): self
+ {
+ $this->name = $name;
+
+ return $this;
+ }
+
+ public function setCaption(string $caption): self
+ {
+ $this->caption = $caption;
+
+ return $this;
+ }
+
+ public function addProperty(string $key, string $value): self
+ {
+ $this->properties[$key] = $value;
+
+ return $this;
+ }
+
+ /**
+ * Execute javascript when tab is initialised (mount) by Vue.
+ */
+ public function jsOnInitTab(JsFunction $fn): JsFunction
+ {
+ $this->appendJsActiveHandlerFn(self::FN_INIT_KEY, $fn);
+
+ return $fn;
+ }
+
+ /**
+ * Execute javascript each time tab is show, i.e. become active.
+ */
+ public function jsOnShowTab(JsFunction $fn): JsFunction
+ {
+ $this->appendJsActiveHandlerFn(self::FN_SHOW_KEY, $fn);
+
+ return $fn;
+ }
+
+ /**
+ * Execute javascript each time tab is hide, i.e. become inactive.
+ */
+ public function jsOnHideTab(JsFunction $fn): JsFunction
+ {
+ $this->appendJsActiveHandlerFn(self::FN_HIDE_KEY, $fn);
+
+ return $fn;
+ }
+
+ public function getProperties(): array
+ {
+ return array_merge($this->properties, ['name' => $this->getName(), 'caption' => $this->getCaption(), 'disabled' => $this->isDisabled()]);
+ }
+
+ /**
+ * Add a Js Function to be executed when associate event occur.
+ */
+ private function appendJsActiveHandlerFn(string $key, JsFunction $fn): void
+ {
+ $this->onActiveHandlers[] = [$key => $fn];
+ }
+
+ protected function beforeHtmlRender(): void
+ {
+ $this->getTemplate()->trySetJs('tabName', Js::string($this->getName()));
+ $this->getTemplate()->trySetJs('tabStoreId', Js::string($this->tabStoreId));
+ $this->getTemplate()->trySetJs('onActiveHandlers', Js::array($this->onActiveHandlers));
+
+ parent::beforeHtmlRender();
+ }
+}
diff --git a/src/Component/VueTrait.php b/src/Component/VueTrait.php
index 0c7c4f6..4f22520 100644
--- a/src/Component/VueTrait.php
+++ b/src/Component/VueTrait.php
@@ -49,6 +49,8 @@ public function isRootComponent(): bool
foreach ($this->getOwners() as $owner) {
if ($owner instanceof VueInterface) {
$isRoot = false;
+
+ break;
}
}
@@ -72,6 +74,8 @@ protected function createVueApp(string $component, array $rootData, string $sele
$chain = JsChain::withUiLibrary()->vueService->createVueApp($selector, $component, $rootData); // @phpstan-ignore-line
$this->unshiftJsActions($chain);
+ // make root component invisible until mounted by VueService.
+ $this->appendTailwinds(['invisible', 'data-[v-app]:visible']);
}
return $this;
diff --git a/src/Page.php b/src/Page.php
index 4a4d8f7..b5f846f 100644
--- a/src/Page.php
+++ b/src/Page.php
@@ -37,7 +37,7 @@ class Page extends View
'url' => 'https://cdnjs.cloudflare.com/ajax/libs/jquery/3.5.1/jquery.min.js',
],
'fohn-js' => [
- 'url' => 'https://unpkg.com/fohn-ui@1.3.1/dist/fohn-ui.min.js',
+ 'url' => 'https://unpkg.com/fohn-ui@1.4.0/dist/fohn-ui.min.js',
],
];
@@ -47,10 +47,10 @@ class Page extends View
'url' => 'https://cdnjs.cloudflare.com/ajax/libs/flatpickr/4.6.6/flatpickr.min.css',
],
'icons' => [
- 'url' => 'https://cdn.jsdelivr.net/npm/bootstrap-icons@1.9.1/font/bootstrap-icons.css',
+ 'url' => 'https://cdn.jsdelivr.net/npm/bootstrap-icons@1.10.5/font/bootstrap-icons.css',
],
'fohn-css' => [
- 'url' => 'https://unpkg.com/fohn-ui-css@1.2.0/dist/fohn-ui.min.css',
+ 'url' => 'https://unpkg.com/fohn-ui-css@1.3.0/dist/fohn-ui.min.css',
],
];
diff --git a/src/View/Divider.php b/src/View/Divider.php
index 56b4ba3..91c74eb 100644
--- a/src/View/Divider.php
+++ b/src/View/Divider.php
@@ -8,17 +8,24 @@
class Divider extends View
{
+ public string $verticalSpace = '4';
+
protected function initRenderTree(): void
{
parent::initRenderTree();
$this->htmlTag = 'hr';
$this->setIdAttribute('');
+ }
+ protected function beforeHtmlRender(): void
+ {
$this->appendTailwinds(
[
- 'my-4',
+ 'my-' . $this->verticalSpace,
]
);
+
+ parent::beforeHtmlRender();
}
}
diff --git a/template/tailwind/vue-component/form.html b/template/tailwind/vue-component/form.html
index aed6a44..ff49153 100644
--- a/template/tailwind/vue-component/form.html
+++ b/template/tailwind/vue-component/form.html
@@ -6,7 +6,7 @@
:can-leave="{$canLeave}"
:submit-url="{$submitUrl}"
:values-url="{$valuesUrl}"
- #default="@{isSubmitting, canLeave, formErrors, submitForm, setControlValue, isDirty, storeId}" ref="root" class="invisible">
+ #default="@{isSubmitting, canLeave, formErrors, submitForm, setControlValue, isDirty, storeId}">
diff --git a/template/tailwind/vue-component/navigation.html b/template/tailwind/vue-component/navigation.html
index 2b835bc..7a368cf 100644
--- a/template/tailwind/vue-component/navigation.html
+++ b/template/tailwind/vue-component/navigation.html
@@ -5,8 +5,7 @@
menu-width="{width}52{/width}"
break-point="{breakPoint}lg{/breakPoint}"
#default="@{title, icons, groups, navigationCss, isOpen, inMobileMode, fn}"
- class="fohn-admin-side-navigation text-sm invisible"
- ref="root">
+ class="fohn-admin-side-navigation text-sm">
diff --git a/template/tailwind/vue-component/table.html b/template/tailwind/vue-component/table.html
index affea06..5799cd5 100644
--- a/template/tailwind/vue-component/table.html
+++ b/template/tailwind/vue-component/table.html
@@ -5,8 +5,6 @@
data-url="{$dataUrl}"
:items-per-page="{$itemsPerPage}"
:columns="{$columns}"
- ref="root"
- class="invisible"
:actions="{$tableActions}"
:keep-table-state="{$keepTableState}"
#default="@{isFetching,
@@ -142,7 +140,7 @@
-
{{fromItem}} - {{toItem}} / {{totalItems}}
+
{{fromItem}} - {{toItem}} / {{totalItems}}