From df64a47a914e543bd66d5cf3460e1e4da72587fe Mon Sep 17 00:00:00 2001 From: danielungu Date: Fri, 27 Feb 2026 10:53:50 +0100 Subject: [PATCH 01/12] - Add `QuickCopy` trait for quick copy button functionality in text controls (e.g., TextArea, TextInput). - Improve checkbox and radio list rendering: - Refactor inline check classes and layout logic. - Add support for specifying items per row. - Extend button customization: - Introduce `getFormButtonClass()` for unified button styling. - Add rounded styling utilities (via `Helper\ButtonRounded`). - Ensure consistency in `assert()` usage by adopting global function. - Enhance CSS for better input alignment, floating labels, and button styles. - Refactor test layouts to align with new rendering logic. - Add new unit tests for quick copy functionality and items-per-row rendering. --- src/Form.php | 31 +- src/control/Button.php | 6 +- src/control/DuplicatorCreateSubmit.php | 25 +- src/control/DuplicatorRemoveSubmit.php | 28 +- src/control/Link.php | 3 +- src/control/SubmitButton.php | 3 +- src/control/TextArea.php | 1 + src/control/TextInput.php | 1 + src/css/form.css | 464 +++++++++++++++++- src/helper/ButtonRounded.php | 35 ++ src/helper/CoreList.php | 14 +- src/helper/InputCoreControl.php | 7 +- src/helper/InputGroup.php | 11 - src/helper/Label.php | 25 +- src/helper/QuickCopy.php | 45 ++ src/helper/RenderFloating.php | 7 +- src/helper/WrapControl.php | 8 +- src/js/form.js | 210 +++++++- .../CheckboxlistTest/CheckboxlistTest.php | 13 + tests/InputTest/CheckboxlistTest/basic.latte | 4 +- tests/InputTest/CheckboxlistTest/id.latte | 4 +- .../CheckboxlistTest/itemsPerRow.latte | 21 + tests/InputTest/ContainerTest/basic.latte | 2 +- tests/InputTest/ContainerTest/card.latte | 2 +- tests/InputTest/DuplicatorTest/basic.latte | 4 +- .../InputTest/RadiolistTest/RadiolistTest.php | 13 + tests/InputTest/RadiolistTest/basic.latte | 4 +- tests/InputTest/RadiolistTest/id.latte | 4 +- .../InputTest/RadiolistTest/itemsPerRow.latte | 21 + 29 files changed, 930 insertions(+), 86 deletions(-) create mode 100644 src/helper/ButtonRounded.php create mode 100644 src/helper/QuickCopy.php create mode 100644 tests/InputTest/CheckboxlistTest/itemsPerRow.latte create mode 100644 tests/InputTest/RadiolistTest/itemsPerRow.latte diff --git a/src/Form.php b/src/Form.php index 46e312f..801df3c 100644 --- a/src/Form.php +++ b/src/Form.php @@ -5,10 +5,13 @@ namespace ModulIS\Form; use Nette\Application\UI\Form as UIForm; +use Nette\ComponentModel\IContainer; use Nette\Forms\Controls\DateTimeControl; +use Nette\Forms\Controls\HiddenField; use Nette\Utils\DateTime; use Nette\Utils\Html; use Stringable; +use function assert; class Form extends UIForm { @@ -30,6 +33,8 @@ class Form extends UIForm public bool $ajax = false; + private ?string $buttonClass = null; + public Html|string|null $title = null; public ?string $icon = null; @@ -49,7 +54,7 @@ class Form extends UIForm private array $dividerArray = []; - public function __construct(?\Nette\ComponentModel\IContainer $parent = null, $name = null) + public function __construct(?IContainer $parent = null, $name = null) { parent::__construct($parent, $name); @@ -79,7 +84,7 @@ public function renderForm() foreach($groupArray as $groupTitle => $group) { - \assert($group instanceof ControlGroup); + assert($group instanceof ControlGroup); $inputs = null; foreach($group->getInputArray() as $input) @@ -95,7 +100,7 @@ public function renderForm() /** * Nette form hidden input */ - $inputs .= $input instanceof \Nette\Forms\Controls\HiddenField ? $input->getControl() : $input->render(); + $inputs .= $input instanceof HiddenField ? $input->getControl() : $input->render(); if(array_key_exists($input->getName(), $this->dividerArray)) { @@ -213,7 +218,7 @@ public function getSubmitterArray(): array foreach($this->getGroups() as $group) { - \assert($group instanceof ControlGroup); + assert($group instanceof ControlGroup); $submitterArray = array_merge($submitterArray, $group->getSubmitterArray()); } @@ -515,6 +520,24 @@ public function setIcon(string $icon): self } + /** + * Default class(es) for all form buttons (e.g. "rounded rounded-4"). + * Priority: setClass() on the button overrides this. + */ + public function setButtonClass(string $class): self + { + $this->buttonClass = $class; + + return $this; + } + + + public function getButtonClass(): ?string + { + return $this->buttonClass; + } + + public function setNoValidate(bool $noValidate = true): self { $this->noValidate = $noValidate; diff --git a/src/control/Button.php b/src/control/Button.php index 23225bb..b2bbf6e 100644 --- a/src/control/Button.php +++ b/src/control/Button.php @@ -4,6 +4,7 @@ namespace ModulIS\Form\Control; +use Kravcik\LatteFontAwesomeIcon\Extension; use ModulIS\Form\Helper; use Nette\Utils\Html; @@ -13,6 +14,7 @@ class Button extends \Nette\Forms\Controls\Button implements Renderable use Helper\Color; use Helper\AutoRenderSkip; use Helper\ControlClass; + use Helper\ButtonRounded; public function getCoreControl(): Html|string { @@ -24,8 +26,8 @@ public function getCoreControl(): Html|string $button = Html::el('button') ->name($this->getName()) - ->class('btn btn-' . $color . ($input->getAttribute('class') ? ' ' . $input->getAttribute('class') : '')) - ->addHtml($this->icon ? \Kravcik\LatteFontAwesomeIcon\Extension::render($this->icon) : '') + ->class('btn' . $this->getFormButtonClass() . ' px-4 btn-' . $color . (trim((string) $input->getAttribute('class')) !== '' ? ' ' . $input->getAttribute('class') : '')) + ->addHtml($this->icon ? Extension::render($this->icon) : '') ->addHtml($label); if($this->getOption('id')) diff --git a/src/control/DuplicatorCreateSubmit.php b/src/control/DuplicatorCreateSubmit.php index 7ebdf70..16ee47f 100644 --- a/src/control/DuplicatorCreateSubmit.php +++ b/src/control/DuplicatorCreateSubmit.php @@ -4,19 +4,23 @@ namespace ModulIS\Form\Control; +use Kravcik\LatteFontAwesomeIcon\Extension; +use ModulIS\Form\Form; +use ModulIS\Form\FormComponent; use Nette\Utils\Html; +use function assert; class DuplicatorCreateSubmit extends SubmitButton { - public function addCreateOnClick(bool $allowEmpty = true, ?callable $callback = null) + public function addCreateOnClick(bool $allowEmpty = true, ?callable $callback = null): void { $this->onClick[] = function(\Nette\Forms\Controls\SubmitButton $button) use ($allowEmpty, $callback): void { $form = $button->getForm(); - \assert($form instanceof \ModulIS\Form\Form); + assert($form instanceof Form); $duplicator = $button->lookup(Duplicator::class); - \assert($duplicator instanceof Duplicator); + assert($duplicator instanceof Duplicator); if($allowEmpty === true || $duplicator->isAllFilled() === true) { @@ -24,8 +28,8 @@ public function addCreateOnClick(bool $allowEmpty = true, ?callable $callback = if($form->getPresenter()->isAjax()) { - $component = $button->lookup(\ModulIS\Form\FormComponent::class); - \assert($component instanceof \ModulIS\Form\FormComponent); + $component = $button->lookup(FormComponent::class); + assert($component instanceof FormComponent); $component->redrawControl('form'); } @@ -54,13 +58,18 @@ public function getCoreControl(): Html $currentClass = $this->getControl()->getAttribute('class'); - $icon = \Kravcik\LatteFontAwesomeIcon\Extension::render($this->isDisabled() ? 'info' : 'plus'); + $icon = Extension::render($this->isDisabled() ? 'info' : 'plus'); $form = $this->getForm(); - \assert($form instanceof \ModulIS\Form\Form); + assert($form instanceof Form); + + $class = 'btn' . $this->getFormButtonClass() + . ' btn-outline-primary btn-sm float-start' + . ($form->ajax ? ' ajax' : '') + . ($currentClass ? ' ' . $currentClass : ''); return Html::el('button') - ->class('btn btn-outline-primary float-left btn-xs ' . ($form->ajax ? 'ajax' : '') . ($currentClass ? ' ' . $currentClass : '')) + ->class($class) ->addAttributes($attributes) ->disabled($this->isDisabled()) ->addHtml($icon . $this->translate($this->getCaption())); diff --git a/src/control/DuplicatorRemoveSubmit.php b/src/control/DuplicatorRemoveSubmit.php index cfa65a0..0f13111 100644 --- a/src/control/DuplicatorRemoveSubmit.php +++ b/src/control/DuplicatorRemoveSubmit.php @@ -4,16 +4,20 @@ namespace ModulIS\Form\Control; +use Kravcik\LatteFontAwesomeIcon\Extension; +use ModulIS\Form\Form; +use ModulIS\Form\FormComponent; use Nette\Utils\Html; +use function assert; class DuplicatorRemoveSubmit extends SubmitButton { - public function addRemoveOnClick(?callable $callback = null) + public function addRemoveOnClick(?callable $callback = null): void { $this->onClick[] = function(\Nette\Forms\Controls\SubmitButton $button) use ($callback): void { $duplicator = $button->lookup(Duplicator::class); - \assert($duplicator instanceof Duplicator); + assert($duplicator instanceof Duplicator); if(is_callable($callback)) { @@ -21,12 +25,12 @@ public function addRemoveOnClick(?callable $callback = null) } $form = $button->getForm(false); - \assert($form instanceof \ModulIS\Form\Form); + assert($form instanceof Form); if($form->getPresenter()->isAjax()) { - $component = $button->lookup(\ModulIS\Form\FormComponent::class); - \assert($component instanceof \ModulIS\Form\FormComponent); + $component = $button->lookup(FormComponent::class); + assert($component instanceof FormComponent); $component->redrawControl('form'); } @@ -47,16 +51,18 @@ public function getCoreControl(): Html ]; $form = $this->getForm(); - \assert($form instanceof \ModulIS\Form\Form); + assert($form instanceof Form); $currentClass = $this->getControl()->getAttribute('class'); + $class = 'btn' . $this->getFormButtonClass() + . ' btn-sm btn-outline-danger float-end' + . ($form->ajax ? ' ajax' : '') + . ($currentClass ? ' ' . $currentClass : ''); - $button = Html::el('button') - ->class('btn btn-xs btn-outline-danger float-end' . ($form->ajax ? ' ajax' : '') . ($currentClass ? ' ' . $currentClass : '')) + return Html::el('button') + ->class($class) ->addAttributes($attributes) ->disabled($this->isDisabled()) - ->addHtml(\Kravcik\LatteFontAwesomeIcon\Extension::render('times') . $this->translate($this->getCaption())); - - return $button; + ->addHtml(Extension::render('times') . $this->translate($this->getCaption())); } } diff --git a/src/control/Link.php b/src/control/Link.php index 673e9bd..4e4ef53 100644 --- a/src/control/Link.php +++ b/src/control/Link.php @@ -13,6 +13,7 @@ class Link extends \Nette\Forms\Controls\BaseControl implements Renderable use Helper\Color; use Helper\AutoRenderSkip; use Helper\ControlClass; + use Helper\ButtonRounded; protected string|null $link = null; @@ -41,7 +42,7 @@ public function getControl(): Html } $el->setHtml(trim($btnIcon . ' ' . $this->caption)); - $el->class('btn' . $btnColor . $currentClass); + $el->class('btn' . $this->getFormButtonClass() . $btnColor . $currentClass); foreach($control->attrs as $name => $value) { diff --git a/src/control/SubmitButton.php b/src/control/SubmitButton.php index 90eb0cd..75128c4 100644 --- a/src/control/SubmitButton.php +++ b/src/control/SubmitButton.php @@ -13,6 +13,7 @@ class SubmitButton extends \Nette\Forms\Controls\SubmitButton implements Rendera use Helper\Color; use Helper\AutoRenderSkip; use Helper\ControlClass; + use Helper\ButtonRounded; public function getCoreControl(): Html { @@ -22,7 +23,7 @@ public function getCoreControl(): Html $button = Html::el('button') ->name($this->getName()) - ->class('btn rounded-pill px-4 ' . $input->getAttribute('class') . ' btn-' . $color) + ->class('btn' . $this->getFormButtonClass() . ' px-4' . (trim((string) $input->getAttribute('class')) !== '' ? ' ' . $input->getAttribute('class') : '') . ' btn-' . $color) ->type('submit') ->formnovalidate(true) ->addHtml($this->icon ? \Kravcik\LatteFontAwesomeIcon\Extension::render($this->icon) . ' ' : '') diff --git a/src/control/TextArea.php b/src/control/TextArea.php index 64c1d85..e29ea11 100644 --- a/src/control/TextArea.php +++ b/src/control/TextArea.php @@ -9,6 +9,7 @@ class TextArea extends \Nette\Forms\Controls\TextArea implements Renderable, FloatingRenderable, Signalable, \Nette\Application\UI\SignalReceiver { use Helper\InputGroup; + use Helper\QuickCopy; use Helper\Color; use Helper\Tooltip; use Helper\ControlPart; diff --git a/src/control/TextInput.php b/src/control/TextInput.php index d1b5c9f..7191a34 100644 --- a/src/control/TextInput.php +++ b/src/control/TextInput.php @@ -9,6 +9,7 @@ class TextInput extends \Nette\Forms\Controls\TextInput implements Renderable, FloatingRenderable, Signalable, \Nette\Application\UI\SignalReceiver { use Helper\InputGroup; + use Helper\QuickCopy; use Helper\Color; use Helper\Tooltip; use Helper\ControlPart; diff --git a/src/css/form.css b/src/css/form.css index c3f546a..edbee7d 100644 --- a/src/css/form.css +++ b/src/css/form.css @@ -7,6 +7,14 @@ label{ cursor: pointer; } +/* In non-floating layout keep label at top for tall controls (textarea/summernote). */ +.row > .align-self-center:has(> .col-form-label), +.row > .align-self-center:has(> .form-label) +{ + align-self: flex-start !important; + padding-top: 0.4rem; +} + /*Check box*/ input[type="checkbox"] + .label-text:before, /* summernote checkboxes */ @@ -376,6 +384,128 @@ input[type="checkbox"]:checked.checkbox-navy-dark, input[type="radio"]:checked.c color: #212529!important; } +/* Input-group append: same border as form-control so it aligns visually */ +.input-group .input-group-text:not(.quick-copy-wrap) +{ + border: var(--bs-border-width, 1px) solid var(--bs-border-color, #dee2e6); +} + +/* Quick copy button: match input height, no border, no extra space */ +.input-group:has(.quick-copy-wrap) +{ + overflow: visible; +} +.input-group .input-group-text.quick-copy-wrap +{ + padding: 0; + border: 0; + display: flex; + align-items: center; + flex: 0 0 auto; + position: relative; + overflow: visible; +} +.input-group .input-group-text.quick-copy-wrap .quick-copy-btn +{ + min-height: 0; + height: 100%; + width: 2.5rem; + min-width: 2.5rem; + flex: 0 0 auto; + overflow: hidden; + display: inline-flex; + align-items: center; + justify-content: center; + font-size: 0; + border: 0; + background: #e7f1ff; + border-radius: 0 var(--bs-border-radius, 0.375rem) var(--bs-border-radius, 0.375rem) 0; + color: #0d6efd; +} +.input-group .input-group-text.quick-copy-wrap .quick-copy-btn:hover +{ + background: #cfe2ff; + color: #0a58ca; +} +.input-group .input-group-text.quick-copy-wrap .quick-copy-btn .fa, +.input-group .input-group-text.quick-copy-wrap .quick-copy-btn i +{ + font-size: 1rem; + flex-shrink: 0; + color: inherit; +} + +/* Quick copy popup – floating in the gap left of the button */ +.quick-copy-popup +{ + position: absolute; + right: 100%; + top: 50%; + transform: translateY(-50%); + margin-right: 0.35rem; + z-index: 1060; + padding: 0.35rem 0.6rem; + font-size: 0.8125rem; + color: #fff; + background: var(--bs-success, #198754); + border-radius: var(--bs-border-radius, 0.375rem); + box-shadow: 0 0.2rem 0.4rem rgba(0, 0, 0, 0.12); + white-space: nowrap; + pointer-events: none; + animation: quickCopyFadeIn 0.2s ease-out; +} +.quick-copy-popup.quick-copy-popup-out +{ + animation: quickCopyFadeOut 0.3s ease-in forwards; +} +@keyframes quickCopyFadeIn +{ + from { opacity: 0; transform: translateY(-50%) scale(0.95); } + to { opacity: 1; transform: translateY(-50%) scale(1); } +} +@keyframes quickCopyFadeOut +{ + from { opacity: 1; transform: translateY(-50%) scale(1); } + to { opacity: 0; transform: translateY(-50%) scale(0.95); } +} + +/* Align whisperer (chosen) visuals with Bootstrap 5 select in non-floating mode */ +.input-group > .chosen-container +{ + width: 100% !important; +} + +.input-group > .chosen-container .chosen-single +{ + display: flex; + align-items: center; + height: calc(1.5em + 0.75rem + 2px); + padding: 0.375rem 2.25rem 0.375rem 0.75rem; + border: var(--bs-border-width) solid var(--bs-border-color); + border-radius: var(--bs-border-radius); + background-color: var(--bs-form-control-bg, #fff) !important; + background-image: none !important; + box-shadow: none; +} + +.input-group > .chosen-container .chosen-single.chosen-default +{ + background-color: var(--bs-form-control-bg, #fff) !important; + background-image: none !important; +} + +.input-group > .chosen-container .chosen-single span +{ + line-height: 1.5; + margin-right: 0; + color: var(--bs-body-color); +} + +.input-group > .chosen-container .chosen-single.chosen-default span +{ + color: #6c757d; +} + .form-floating .chosen-single { min-width: 100%; @@ -399,13 +529,345 @@ input[type="checkbox"]:checked.checkbox-navy-dark, input[type="radio"]:checked.c padding-top: 0.5rem; } -label.required::after +/* Keep floating input and prepend/tooltip in one row inside input-group */ +.input-group > .form-floating +{ + flex: 1 1 auto; + width: 1%; + min-width: 0; +} + +.form-floating > label [data-bs-toggle="tooltip"] +{ + pointer-events: auto; + margin-left: 0.03rem; + display: inline-flex; + align-items: center; + vertical-align: middle; +} + +.col-form-label.required::after, +.form-label.required::after { color: #cc1d24; content: " ★"; } +.btn-group-active .card-body > h6 > label.required::after +{ + color: #cc1d24; + content: " ★"; +} + +.col-form-label.required.has-inline-required-star::after, +.form-label.required.has-inline-required-star::after +{ + content: ""; +} + +.col-form-label .required-inline-star, +.form-label .required-inline-star +{ + color: #cc1d24; + margin-left: 0.06rem; + margin-right: 0.02rem; +} + +/* Hide inline required star when floating field is focused or has value */ +.form-floating > .form-control:focus ~ label .required-inline-star, +.form-floating > .form-control:not(:placeholder-shown) ~ label .required-inline-star, +.form-floating > .form-select ~ label .required-inline-star +{ + display: none; +} + .form-check-inline { margin-right: 0 !important; } + +/* Compact spacing for checkbox/radio lists rendered in row columns */ +.container > .row > .form-check +{ + margin-bottom: 0.15rem; + padding-top: 0; + padding-bottom: 0; + min-height: 0; +} + +.container > .row > .form-check .form-check-label +{ + line-height: 1.25; +} + +.container > .row +{ + row-gap: 0.1rem; +} + +.container > .row > .form-check +{ + margin-bottom: 0; + min-height: 0; +} + +/* Strong override for checkbox/radio list layout rendered by CoreList */ +.mb-3.col-12 > .row > .align-self-center.col-sm-12 > .container > .row > .form-check +{ + margin-top: 0 !important; + margin-bottom: 0 !important; + padding-top: 0 !important; + padding-bottom: 0 !important; + min-height: 0 !important; + line-height: 1.2 !important; +} + +.mb-3.col-12 > .row > .align-self-center.col-sm-12 > .container > .row +{ + --bs-gutter-x: 0.5rem !important; + row-gap: 0 !important; +} + +/* Filter row: variables – input height, select height */ +.datagrid table thead, +table thead +{ + --dg-filter-height: 2.5em; + /* Only text/date inputs use this; increase to match select visual height (selects keep natural size) */ + --dg-filter-input-height: 3em; +} + +/* Text and date inputs only: raised height (selects unchanged) */ +.datagrid table thead .form-control, +.datagrid table thead .form-control.form-control-sm, +.datagrid table thead th .form-control, +table thead .form-control, +table thead th .form-control, +table thead .form-control.form-control-sm +{ + height: var(--dg-filter-input-height) !important; + min-height: var(--dg-filter-input-height) !important; + padding-top: 0.28rem !important; + padding-bottom: 0.28rem !important; + box-sizing: border-box !important; +} + +/* Native select (when Tom Select not used) – uses baseline height */ +.datagrid table thead .form-select:not(.ts-hidden-accessible) +{ + height: var(--dg-filter-height); + min-height: var(--dg-filter-height); + padding-top: 0.28rem; + padding-bottom: 0.28rem; +} + +/* Native multiselect when Tom Select didn't run: allow one full line so text isn't cut off */ +.datagrid table thead select.form-select[multiple]:not(.ts-hidden-accessible) +{ + min-height: 2.5em; +} + +/* Tom Select: hide original select so only .ts-control is visible */ +.datagrid table thead select.form-select.ts-hidden-accessible, +.datagrid table thead .ts-wrapper .ts-hidden-accessible +{ + display: none !important; + position: absolute !important; + width: 0 !important; + height: 0 !important; + min-height: 0 !important; + opacity: 0 !important; + pointer-events: none !important; + clip: rect(0,0,0,0) !important; +} + +/* Datagrid filter: use only Tom Select, hide Chosen if both were applied */ +.datagrid table thead .chosen-container +{ + display: none !important; +} + +.datagrid table thead .ts-wrapper.form-select-sm +{ + min-height: var(--dg-filter-height) !important; + height: auto !important; +} +.datagrid table thead .ts-wrapper.form-select-sm .ts-control +{ + min-height: var(--dg-filter-height) !important; + height: auto !important; + padding-top: 0.28rem !important; + padding-bottom: 0.28rem !important; + box-sizing: border-box !important; +} + +/* Multi only: no Bootstrap dropdown arrow, wrap tags */ +.datagrid table thead .ts-wrapper.form-select-sm.multi +{ + background-image: none !important; + --bs-form-select-bg-img: none !important; +} +.datagrid table thead .ts-wrapper.form-select-sm.multi .ts-control +{ + flex-wrap: wrap !important; + background-image: none !important; +} +.datagrid table thead .ts-wrapper.form-select-sm.multi .ts-control::after +{ + display: none !important; +} + +/* Single select: no remove (×) button – keep only dropdown arrow */ +.datagrid table thead .ts-wrapper.form-select-sm:not(.multi) .ts-control .remove +{ + display: none !important; +} + +.datagrid table thead .ts-wrapper.form-select-sm .ts-control > input +{ + line-height: 1.5 !important; + padding-top: 0 !important; + padding-bottom: 0 !important; + min-height: 0 !important; + height: 1.5em !important; + margin: 0 !important; +} + +/* Multi only: when items are selected, hide placeholder "Vyberte" below the tags */ +.datagrid table thead .ts-wrapper.form-select-sm.multi.has-items .ts-control > input::placeholder +{ + color: transparent !important; + opacity: 0 !important; +} + +/* Multi only: .item (tags) – no pseudo-element ×, subtle active state, remove button on top */ +.datagrid table thead .ts-wrapper.form-select-sm.multi .ts-control .item::before, +.datagrid table thead .ts-wrapper.form-select-sm.multi .ts-control .item::after +{ + content: none !important; + display: none !important; +} +.datagrid table thead .ts-wrapper.form-select-sm.multi .ts-control .item.active +{ + background: rgba(0, 0, 0, 0.06) none !important; + border-color: rgba(0, 0, 0, 0.1) !important; + box-shadow: none !important; +} +.datagrid table thead .ts-wrapper.form-select-sm.multi .ts-control .item .remove +{ + position: relative !important; + z-index: 1 !important; +} + +/* Datagrid date filter: open by click into input, hide calendar addon */ +.datagrid table thead .input-group.input-group-sm .form-control, +.datagrid table thead th .input-group .form-control, +table thead .input-group.input-group-sm .form-control, +table thead th .input-group .form-control +{ + height: var(--dg-filter-input-height) !important; + min-height: var(--dg-filter-input-height) !important; +} + +.datagrid table thead .input-group.input-group-sm +{ + flex-wrap: nowrap; +} + +/* Same rounding on all corners so left and right match (Bootstrap often uses -sm on right only) */ +.datagrid table thead .input-group.input-group-sm > .form-control.datepicker-input, +.datagrid table thead .input-group.input-group-sm > .form-control, +.datagrid table thead .input-group .form-control +{ + flex: 1 1 auto; + min-width: 0; + border-radius: var(--bs-border-radius) !important; +} + +.datagrid table thead .input-group.input-group-sm > .input-group-text +{ + display: none !important; +} + +/* Vanilla datepicker in datagrid: keep full calendar visible and above table */ +.datepicker-dropdown +{ + z-index: 1080; +} + +.datepicker-dropdown .datepicker-picker +{ + min-width: 17.5rem; + width: max-content; + border: 1px solid var(--bs-border-color); + border-radius: var(--bs-border-radius); + background-color: var(--bs-body-bg, #fff); +} + +.datepicker-dropdown .datepicker-header .datepicker-controls .btn, +.datepicker-dropdown .datepicker-picker .datepicker-header .datepicker-controls .btn +{ + background: transparent none !important; + border: 0 !important; + border-radius: 0 !important; + box-shadow: none !important; + min-width: auto !important; + padding: 0.2rem 0.45rem !important; +} + +.datepicker-dropdown .datepicker-header .datepicker-controls .btn:hover, +.datepicker-dropdown .datepicker-header .datepicker-controls .btn:focus, +.datepicker-dropdown .datepicker-picker .datepicker-header .datepicker-controls .btn:hover, +.datepicker-dropdown .datepicker-picker .datepicker-header .datepicker-controls .btn:focus +{ + background: rgba(0, 0, 0, 0.04) !important; + border-radius: var(--bs-border-radius-sm) !important; +} + +/* Summernote rendered inside bootstrap floating wrapper */ +.form-floating > textarea.form-control[style*="display: none"] + .note-editor +{ + margin-top: 1.25rem; +} + +.form-floating > textarea.form-control[style*="display: none"] + .note-editor + label.col-form-label, +.form-floating > textarea.form-control[style*="display: none"] + .note-editor + label.form-label +{ + position: absolute !important; + top: 0 !important; + height: auto !important; + padding: 0 !important; + margin: 0 !important; + transform: translateY(-30%) !important; + transform-origin: left center !important; + pointer-events: none; + z-index: 3; + overflow: visible !important; + white-space: nowrap !important; + background: none !important; + border: 0 !important; + box-shadow: none !important; + font-size: 0.95rem !important; + font-weight: 500; +} + +.form-floating > textarea.form-control[style*="display: none"] + .note-editor + label.required::after +{ + content: "" !important; +} + +.form-floating > textarea.form-control[style*="display: none"] + .note-editor + label .summernote-required-star +{ + display: inline-block; + margin-left: 0.2rem; + color: #cc1d24; + opacity: 1; + visibility: visible; +} + +.form-floating > textarea.form-control[style*="display: none"] + .note-editor + label.required.summernote-has-content .summernote-required-star, +.form-floating > textarea.form-control[style*="display: none"] + .note-editor + label.required.summernote-focused .summernote-required-star +{ + opacity: 0; + visibility: hidden; +} diff --git a/src/helper/ButtonRounded.php b/src/helper/ButtonRounded.php new file mode 100644 index 0000000..0149c16 --- /dev/null +++ b/src/helper/ButtonRounded.php @@ -0,0 +1,35 @@ +getForm(false); + + if(!$form instanceof Form) + { + return ''; + } + + $userClass = trim((string) $this->getControl()->getAttribute('class')); + + if($userClass !== '') + { + return ''; + } + + $class = $form->getButtonClass(); + + return $class !== null && $class !== '' ? ' ' . $class : ''; + } +} diff --git a/src/helper/CoreList.php b/src/helper/CoreList.php index 635b4c3..1b07a24 100644 --- a/src/helper/CoreList.php +++ b/src/helper/CoreList.php @@ -4,7 +4,11 @@ namespace ModulIS\Form\Helper; +use Kravcik\LatteFontAwesomeIcon\Extension; +use ModulIS\Form\Control\Signalable; +use ModulIS\Form\Form; use Nette\Utils\Html; +use function assert; trait CoreList { @@ -22,7 +26,7 @@ public function getCoreControl(): Html|string $inputs = null; $form = $this->getForm(); - \assert($form instanceof \ModulIS\Form\Form); + assert($form instanceof Form); foreach($this->getItems() as $key => $input) { @@ -113,23 +117,23 @@ public function renderItem(string|int $itemName) $tooltip = Html::el('span') ->title($this->tooltips[$itemName]) ->addAttributes(['data-bs-placement' => 'top', 'data-bs-toggle' => 'tooltip', 'data-bs-html' => 'true']) - ->addHtml(\Kravcik\LatteFontAwesomeIcon\Extension::render('question-circle', color: 'blue')); + ->addHtml(Extension::render('question-circle', color: 'blue')); } - $class = 'form-check-inline mr-0 col-' . 12 / $this->itemsPerRow; + $class = 'form-check col-' . (12 / $this->itemsPerRow); if($this->itemClass) { $class .= ' ' . $this->itemClass; } - if($this instanceof \ModulIS\Form\Control\Signalable && $this->hasSignal()) + if($this instanceof Signalable && $this->hasSignal()) { $this->addSignalsToInput($input); } return Html::el('div') - ->class(($this->toggleButton ? 'p-0 ' : 'form-check ') . $class) + ->class(($this->toggleButton ? 'p-0 ' : '') . $class) ->addHtml($input . $label . $tooltip); } diff --git a/src/helper/InputCoreControl.php b/src/helper/InputCoreControl.php index 85e6e93..94f2e77 100644 --- a/src/helper/InputCoreControl.php +++ b/src/helper/InputCoreControl.php @@ -26,8 +26,13 @@ public function getCoreControl() $hasValidationClass = $this->getValidationClass() && $this->hasErrors() ? ' has-validation' : null; + $quickCopyHtml = method_exists($this, 'getQuickCopy') && method_exists($this, 'getQuickCopyButton') + && $this->getQuickCopy() + ? $this->getQuickCopyButton() + : null; + return Html::el('div') ->class('input-group' . $hasValidationClass) - ->addHtml($this->getPrepend() . $input . $this->getAppend() . $validationFeedBack); + ->addHtml($this->getPrepend() . $input . $this->getAppend() . $quickCopyHtml . $validationFeedBack); } } diff --git a/src/helper/InputGroup.php b/src/helper/InputGroup.php index 08a142f..3e2f14c 100644 --- a/src/helper/InputGroup.php +++ b/src/helper/InputGroup.php @@ -23,17 +23,6 @@ public function getPrepend(): ?Html ->class('input-group-text' . ($this->prependClass ? ' ' . $this->prependClass : null)) ->addHtml($this->prepend); - if(!empty($this->renderFloating) && $this->tooltip) - { - $tooltip = Html::el('span') - ->title($this->tooltip) - ->class('input-group-text') - ->addAttributes(['data-bs-placement' => 'right', 'data-bs-toggle' => 'tooltip', 'data-bs-html' => 'true']) - ->addHtml(\Kravcik\LatteFontAwesomeIcon\Extension::render('question-circle', color: 'blue')); - - return Html::el()->addHtml($this->prepend ? $tooltip . $prepend : $tooltip); - } - if(!$this->prepend) { return null; diff --git a/src/helper/Label.php b/src/helper/Label.php index f332343..f658c20 100644 --- a/src/helper/Label.php +++ b/src/helper/Label.php @@ -4,7 +4,10 @@ namespace ModulIS\Form\Helper; +use Kravcik\LatteFontAwesomeIcon\Extension; +use ModulIS\Form\Form; use Nette\Utils\Html; +use function assert; trait Label { @@ -22,8 +25,26 @@ public function getCoreLabel() $tooltip = Html::el('span') ->title($this->tooltip) ->addAttributes(['data-bs-placement' => 'right', 'data-bs-toggle' => 'tooltip', 'data-bs-html' => 'true']) - ->addHtml(\Kravcik\LatteFontAwesomeIcon\Extension::render('question-circle', color: 'blue')); + ->addHtml(Extension::render('question-circle', color: 'blue')); - return !empty($this->renderFloating) ? $label : $label . $tooltip; + $form = $this->getForm(); + assert($form instanceof Form); + + if($form->getRenderFloating() === true) + { + $label->addHtml(' '); + if($this->isRequired()) + { + $label->addHtml(Html::el('span') + ->class('required-inline-star') + ->setText('★')); + $label->class($label->getAttribute('class') . ' has-inline-required-star'); + } + $label->addHtml($tooltip); + + return $label; + } + + return $label . $tooltip; } } diff --git a/src/helper/QuickCopy.php b/src/helper/QuickCopy.php new file mode 100644 index 0000000..8ee8c3e --- /dev/null +++ b/src/helper/QuickCopy.php @@ -0,0 +1,45 @@ +isSummernote()) + { + throw new \LogicException('QuickCopy cannot be used with Summernote.'); + } + + $this->quickCopy = $value; + + return $this; + } + + + public function getQuickCopy(): bool + { + return $this->quickCopy; + } + + + public function getQuickCopyButton(): Html + { + return Html::el('span') + ->class('input-group-text quick-copy-wrap') + ->addHtml( + Html::el('button') + ->type('button') + ->class('btn quick-copy-btn') + ->setAttribute('title', 'Zkopírovat do schránky') + ->addHtml(Html::el('i')->class('fal fa-copy fa-fw')) + ); + } +} diff --git a/src/helper/RenderFloating.php b/src/helper/RenderFloating.php index 2db1bcc..16500df 100644 --- a/src/helper/RenderFloating.php +++ b/src/helper/RenderFloating.php @@ -50,9 +50,14 @@ public function renderFloating(): Html ->class('form-floating') ->addHtml($input . $label . $validationFeedBack); + $quickCopyHtml = method_exists($this, 'getQuickCopy') && method_exists($this, 'getQuickCopyButton') + && $this->getQuickCopy() + ? $this->getQuickCopyButton() + : null; + $inputGroup = Html::el('div') ->class('input-group') - ->addHtml($this->getPrepend() . $floatingDiv . $this->getAppend()); + ->addHtml($this->getPrepend() . $floatingDiv . $this->getAppend() . $quickCopyHtml); return Html::el('div') ->class($wrapClass) diff --git a/src/helper/WrapControl.php b/src/helper/WrapControl.php index e839d2e..230dd3d 100644 --- a/src/helper/WrapControl.php +++ b/src/helper/WrapControl.php @@ -4,7 +4,9 @@ namespace ModulIS\Form\Helper; +use ModulIS\Form\Form; use Nette\Utils\Html; +use function assert; trait WrapControl { @@ -76,7 +78,7 @@ public function getWrapControl(): Html if(!$this->wrapControl) { $form = $this->getForm(); - \assert($form instanceof \ModulIS\Form\Form); + assert($form instanceof Form); $this->wrapControl = Html::el('div') ->class($form->getDefaultInputWrapClass()); @@ -89,7 +91,7 @@ public function getWrapControl(): Html public function renderWrap(): Html { $form = $this->getForm(); - \assert($form instanceof \ModulIS\Form\Form); + assert($form instanceof Form); $label = $this->getCoreLabel(); $input = $this->getCoreControl(); @@ -97,7 +99,7 @@ public function renderWrap(): Html $inputClass = 'align-self-center'; $labelClass = 'align-self-center'; - if($this->getRenderInline() ?? $form->getRenderInline()) + if(($this->getRenderInline() ?? $form->getRenderInline()) === true) { $inputClass .= $this->inputClass ? ' ' . $this->inputClass : ' col-sm-12'; $labelClass .= $this->labelClass ? ' ' . $this->labelClass : ' col-sm-12'; diff --git a/src/js/form.js b/src/js/form.js index eabe906..784acd0 100644 --- a/src/js/form.js +++ b/src/js/form.js @@ -242,6 +242,104 @@ function formatSelectData(data) return image; }; +function summernoteIsEmpty(noteEditable) +{ + if(!noteEditable) + { + return true; + } + + let html = (noteEditable.innerHTML || '').trim().toLowerCase(); + + if(html === '' || html === '
' || html === '


') + { + return true; + } + + let text = (noteEditable.textContent || '').replace(/\u200B/g, '').trim(); + + if(text.length > 0) + { + return false; + } + + return noteEditable.querySelector('img,video,audio,iframe,table,hr,ul,ol,li,blockquote') === null; +} + +function syncSummernoteRequiredLabels() +{ + $('.form-floating textarea.form-control').each(function() + { + let $textarea = $(this); + let $wrapper = $textarea.closest('.form-floating'); + let $noteEditor = $wrapper.find('> .note-editor'); + + if($noteEditor.length === 0) + { + return; + } + + let $label = $wrapper.find('> label.required'); + + if($label.length === 0) + { + return; + } + + if($label.find('.summernote-required-star').length === 0) + { + $label.append(''); + } + + let update = function() + { + let editable = $noteEditor.find('.note-editable').get(0); + let hasContent = !summernoteIsEmpty(editable); + let isFocused = !!(editable && editable === document.activeElement); + + $label.toggleClass('summernote-has-content', hasContent); + $label.toggleClass('summernote-focused', isFocused); + }; + + $textarea.off('.summernoteRequired'); + $textarea.on('summernote.change.summernoteRequired summernote.blur.summernoteRequired', update); + + update(); + }); + + $(document).off('.summernoteRequiredEditable'); + $(document).on('input.summernoteRequiredEditable keyup.summernoteRequiredEditable paste.summernoteRequiredEditable blur.summernoteRequiredEditable', '.form-floating .note-editor .note-editable', function() + { + let $editable = $(this); + let $wrapper = $editable.closest('.form-floating'); + let $label = $wrapper.find('> label.required'); + + if($label.length === 0) + { + return; + } + + let hasContent = !summernoteIsEmpty(this); + $label.toggleClass('summernote-has-content', hasContent); + $label.toggleClass('summernote-focused', this === document.activeElement); + }); + + $(document).on('focusin.summernoteRequiredEditable focusout.summernoteRequiredEditable', '.form-floating .note-editor .note-editable', function(e) + { + let $editable = $(this); + let $wrapper = $editable.closest('.form-floating'); + let $label = $wrapper.find('> label.required'); + + if($label.length === 0) + { + return; + } + + let focused = e.type === 'focusin'; + $label.toggleClass('summernote-focused', focused); + }); +} + function initForm() { $('[data-on-focusout]').unbind(); @@ -260,16 +358,32 @@ function initForm() $('.form-control-chosen, .form-control-chosen-required').each(function() { - let noResultMessage = $(this).attr('no-result-message') ?? 'Nebyla nalezena žádná položka - '; - - $(this).chosen('destroy'); - - $(this).chosen({ - allow_single_deselect: true, - no_results_text: noResultMessage, - search_contains: true, - width: '100%' - }); + let $el = $(this); + if(!$el.length || $el[0].tagName !== 'SELECT') + { + return; + } + if($el.hasClass('selectpicker')) + { + return; + } + try + { + if($el.data('chosen')) + { + $el.chosen('destroy'); + } + $el.chosen({ + allow_single_deselect: true, + no_results_text: $el.attr('no-result-message') ?? 'Nebyla nalezena žádná položka - ', + search_contains: true, + width: '100%' + }); + } + catch(e) + { + console.warn('Chosen init failed for', $el[0], e); + } }); $('.form-control-chosen, .form-control-chosen-required').on('change', function() @@ -312,25 +426,75 @@ function initForm() { registerAutocomplete(input); }; -} -$(document).ready(function() -{ - initForm(); -}); + $('.datepicker-input').each(function() + { + let $input = $(this); + let placeholder = ($input.attr('placeholder') || '').trim(); + + if(placeholder === '') + { + $input.attr('placeholder', 'dd.mm.rrrr'); + } + }); + + /* Datagrid date filter: default placeholder when empty */ + $('.datagrid table thead .input-group .form-control').each(function() + { + let $input = $(this); + if(($input.attr('placeholder') || '').trim() === '') + { + $input.attr('placeholder', 'dd.mm.rrrr'); + } + }); -if(typeof naja !== "undefined") + syncSummernoteRequiredLabels(); +} + +function initQuickCopy() { - const formExtension = + $(document).off('click.quickCopy', '.quick-copy-btn'); + $(document).on('click.quickCopy', '.quick-copy-btn', function() { - initialize(naja) + let $btn = $(this); + let $group = $btn.closest('.input-group'); + let $input = $group.find('input:not([type="hidden"]), textarea').first(); + let value = $input.length ? ($input.val() || '').trim() : ''; + if (value && navigator.clipboard && navigator.clipboard.writeText) { - naja.snippetHandler.addEventListener('afterUpdate', (event) => + navigator.clipboard.writeText(value).then(function() { - initForm(); + let $wrap = $btn.closest('.quick-copy-wrap'); + let $popup = $('
Zkopírováno
'); + $wrap.append($popup); + setTimeout(function() + { + $popup.addClass('quick-copy-popup-out'); + setTimeout(function() { $popup.remove(); }, 300); + }, 1200); }); } - }; + }); +} - naja.registerExtension(formExtension); -} \ No newline at end of file +$(document).ready(function() +{ + initQuickCopy(); + initForm(); + + if(typeof naja !== "undefined") + { + const formExtension = + { + initialize(naja) + { + naja.snippetHandler.addEventListener('afterUpdate', () => + { + initForm(); + }); + } + }; + + naja.registerExtension(formExtension); + } +}); diff --git a/tests/InputTest/CheckboxlistTest/CheckboxlistTest.php b/tests/InputTest/CheckboxlistTest/CheckboxlistTest.php index 41f0753..2217f1d 100644 --- a/tests/InputTest/CheckboxlistTest/CheckboxlistTest.php +++ b/tests/InputTest/CheckboxlistTest/CheckboxlistTest.php @@ -35,6 +35,19 @@ public function testRenderOptionId() } + public function testRenderItemsPerRow() + { + $form = $this->getForm(); + + $form->addCheckboxList('checkboxlist', 'Checklist', ['first' => 'First', 'second' => 'Second']) + ->setItemsPerRow(2); + + $html = str_replace(["\t", "\n", "\r"], '', file_get_contents(__DIR__ . '/itemsPerRow.latte')); + + Assert::same($html, $form->getComponent('checkboxlist')->render()->__toString()); + } + + public function testRenderCustomTemplate() { $form = $this->getForm(); diff --git a/tests/InputTest/CheckboxlistTest/basic.latte b/tests/InputTest/CheckboxlistTest/basic.latte index e99c42a..6133f55 100644 --- a/tests/InputTest/CheckboxlistTest/basic.latte +++ b/tests/InputTest/CheckboxlistTest/basic.latte @@ -6,11 +6,11 @@
-
+
-
+
diff --git a/tests/InputTest/CheckboxlistTest/id.latte b/tests/InputTest/CheckboxlistTest/id.latte index e3abf8f..d1cf62f 100644 --- a/tests/InputTest/CheckboxlistTest/id.latte +++ b/tests/InputTest/CheckboxlistTest/id.latte @@ -6,11 +6,11 @@
-
+
-
+
diff --git a/tests/InputTest/CheckboxlistTest/itemsPerRow.latte b/tests/InputTest/CheckboxlistTest/itemsPerRow.latte new file mode 100644 index 0000000..c702fe6 --- /dev/null +++ b/tests/InputTest/CheckboxlistTest/itemsPerRow.latte @@ -0,0 +1,21 @@ +
+
+
+ +
+
+
+
+
+ + +
+
+ + +
+
+
+
+
+
diff --git a/tests/InputTest/ContainerTest/basic.latte b/tests/InputTest/ContainerTest/basic.latte index 559e310..bcb6cd4 100644 --- a/tests/InputTest/ContainerTest/basic.latte +++ b/tests/InputTest/ContainerTest/basic.latte @@ -12,6 +12,6 @@
- +
\ No newline at end of file diff --git a/tests/InputTest/ContainerTest/card.latte b/tests/InputTest/ContainerTest/card.latte index e24349c..e93d1b5 100644 --- a/tests/InputTest/ContainerTest/card.latte +++ b/tests/InputTest/ContainerTest/card.latte @@ -19,7 +19,7 @@
diff --git a/tests/InputTest/DuplicatorTest/basic.latte b/tests/InputTest/DuplicatorTest/basic.latte index 41fb739..08827b7 100644 --- a/tests/InputTest/DuplicatorTest/basic.latte +++ b/tests/InputTest/DuplicatorTest/basic.latte @@ -15,14 +15,14 @@
-