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}">
{$Layouts}
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 @@
- +