├── src ├── Bridge │ └── Symfony │ │ ├── Resources │ │ ├── public │ │ │ ├── manifest.json │ │ │ ├── entrypoints.json │ │ │ └── app.css │ │ ├── config │ │ │ ├── date.php │ │ │ ├── validator.php │ │ │ └── form_types.php │ │ ├── translations │ │ │ ├── SonataFormBundle.de.xliff │ │ │ ├── SonataFormBundle.ja.xliff │ │ │ ├── SonataFormBundle.pl.xliff │ │ │ ├── SonataFormBundle.sl.xliff │ │ │ ├── SonataFormBundle.cs.xliff │ │ │ ├── SonataFormBundle.en.xliff │ │ │ ├── SonataFormBundle.fr.xliff │ │ │ ├── SonataFormBundle.it.xliff │ │ │ ├── SonataFormBundle.sk.xliff │ │ │ ├── SonataFormBundle.fi.xliff │ │ │ ├── SonataFormBundle.pt_BR.xliff │ │ │ ├── SonataFormBundle.ca.xliff │ │ │ ├── SonataFormBundle.bg.xliff │ │ │ ├── SonataFormBundle.lb.xliff │ │ │ ├── SonataFormBundle.pt.xliff │ │ │ ├── SonataFormBundle.zh_CN.xliff │ │ │ ├── SonataFormBundle.es.xliff │ │ │ ├── SonataFormBundle.hr.xliff │ │ │ ├── SonataFormBundle.nl.xliff │ │ │ ├── SonataFormBundle.ro.xliff │ │ │ ├── SonataFormBundle.eu.xliff │ │ │ ├── SonataFormBundle.lt.xliff │ │ │ ├── SonataFormBundle.uk.xliff │ │ │ ├── SonataFormBundle.ar.xliff │ │ │ ├── SonataFormBundle.fa.xliff │ │ │ ├── SonataFormBundle.hu.xliff │ │ │ └── SonataFormBundle.ru.xliff │ │ ├── meta │ │ │ └── LICENSE │ │ └── views │ │ │ └── Form │ │ │ └── datepicker.html.twig │ │ ├── SonataFormBundle.php │ │ └── DependencyInjection │ │ ├── Configuration.php │ │ └── SonataFormExtension.php ├── Fixtures │ └── StubTranslator.php ├── Type │ ├── DateRangePickerType.php │ ├── DateTimeRangePickerType.php │ ├── DatePickerType.php │ ├── DateTimePickerType.php │ ├── BaseStatusType.php │ ├── BooleanType.php │ ├── ImmutableArrayType.php │ ├── DateRangeType.php │ ├── DateTimeRangeType.php │ ├── CollectionType.php │ └── BasePickerType.php ├── EventListener │ ├── FixCheckboxDataListener.php │ └── ResizeFormListener.php ├── DataTransformer │ └── BooleanTypeToBooleanTransformer.php ├── Validator │ ├── InlineValidator.php │ ├── Constraints │ │ └── InlineConstraint.php │ └── ErrorElement.php ├── Date │ └── JavaScriptFormatConverter.php └── Test │ └── AbstractWidgetTestCase.php ├── assets ├── scss │ └── app.scss └── js │ ├── app.js │ ├── setup.test.js │ └── controllers │ ├── datepicker_controller.js │ └── datepicker_controller.test.js ├── vite.config.js ├── LICENSE └── composer.json /src/Bridge/Symfony/Resources/public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "bundles/sonataform/app.css": "./app.css", 3 | "bundles/sonataform/app.js": "./app.js" 4 | } -------------------------------------------------------------------------------- /src/Bridge/Symfony/Resources/public/entrypoints.json: -------------------------------------------------------------------------------- 1 | { 2 | "entrypoints": { 3 | "app": { 4 | "css": [ 5 | "./app.css" 6 | ], 7 | "js": [ 8 | "./app.js" 9 | ] 10 | } 11 | } 12 | } -------------------------------------------------------------------------------- /assets/scss/app.scss: -------------------------------------------------------------------------------- 1 | /*! 2 | * This file is part of the Sonata Project package. 3 | * 4 | * (c) Thomas Rabaix 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | @import '@eonasdan/tempus-dominus'; 11 | -------------------------------------------------------------------------------- /assets/js/app.js: -------------------------------------------------------------------------------- 1 | /*! 2 | * This file is part of the Sonata Project package. 3 | * 4 | * (c) Thomas Rabaix 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | // Any SCSS/CSS you require will output into a single css file (app.css in this case) 11 | import '../scss/app.scss'; 12 | 13 | // eslint-disable-next-line import/no-unresolved 14 | import DatePicker from '@symfony/stimulus-bridge/lazy-controller-loader?lazy=true!./controllers/datepicker_controller.js'; 15 | 16 | const { sonataApplication } = global; 17 | 18 | sonataApplication.register('datepicker', DatePicker); 19 | -------------------------------------------------------------------------------- /src/Bridge/Symfony/SonataFormBundle.php: -------------------------------------------------------------------------------- 1 | 9 | * 10 | * For the full copyright and license information, please view the LICENSE 11 | * file that was distributed with this source code. 12 | */ 13 | 14 | namespace Sonata\Form\Bridge\Symfony; 15 | 16 | use Sonata\Form\Bridge\Symfony\DependencyInjection\SonataFormExtension; 17 | use Symfony\Component\HttpKernel\Bundle\Bundle; 18 | 19 | final class SonataFormBundle extends Bundle 20 | { 21 | protected function getContainerExtensionClass(): string 22 | { 23 | return SonataFormExtension::class; 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/Bridge/Symfony/Resources/config/date.php: -------------------------------------------------------------------------------- 1 | 9 | * 10 | * For the full copyright and license information, please view the LICENSE 11 | * file that was distributed with this source code. 12 | */ 13 | 14 | namespace Symfony\Component\DependencyInjection\Loader\Configurator; 15 | 16 | use Sonata\Form\Date\JavaScriptFormatConverter; 17 | 18 | return static function (ContainerConfigurator $containerConfigurator): void { 19 | $containerConfigurator->services() 20 | 21 | ->set('sonata.form.date.javascript_format_converter', JavaScriptFormatConverter::class); 22 | }; 23 | -------------------------------------------------------------------------------- /assets/js/setup.test.js: -------------------------------------------------------------------------------- 1 | /*! 2 | * This file is part of the Sonata Project package. 3 | * 4 | * (c) Thomas Rabaix 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | import matchers from '@testing-library/jest-dom/matchers'; 11 | import { vi, expect } from 'vitest'; 12 | 13 | expect.extend(matchers); 14 | 15 | Object.defineProperty(window, 'matchMedia', { 16 | writable: true, 17 | value: vi.fn().mockImplementation((query) => ({ 18 | matches: false, 19 | media: query, 20 | onchange: null, 21 | addListener: vi.fn(), // deprecated 22 | removeListener: vi.fn(), // deprecated 23 | addEventListener: vi.fn(), 24 | removeEventListener: vi.fn(), 25 | dispatchEvent: vi.fn(), 26 | })), 27 | }); 28 | -------------------------------------------------------------------------------- /src/Fixtures/StubTranslator.php: -------------------------------------------------------------------------------- 1 | 9 | * 10 | * For the full copyright and license information, please view the LICENSE 11 | * file that was distributed with this source code. 12 | */ 13 | 14 | namespace Sonata\Form\Fixtures; 15 | 16 | use Symfony\Contracts\Translation\TranslatorInterface; 17 | 18 | final class StubTranslator implements TranslatorInterface 19 | { 20 | /** 21 | * @param mixed[] $parameters 22 | */ 23 | public function trans(string $id, array $parameters = [], ?string $domain = null, ?string $locale = null): string 24 | { 25 | return '[trans]'.strtr($id, $parameters).'[/trans]'; 26 | } 27 | 28 | public function getLocale(): string 29 | { 30 | return 'en'; 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /vite.config.js: -------------------------------------------------------------------------------- 1 | /*! 2 | * This file is part of the Sonata Project package. 3 | * 4 | * (c) Thomas Rabaix 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | // eslint-disable-next-line import/no-unresolved 11 | import { defineConfig } from 'vitest/config'; 12 | 13 | export default defineConfig({ 14 | test: { 15 | setupFiles: ['./assets/js/setup.test.js'], 16 | include: ['assets/js/**/*.test.js', '!assets/js/setup.test.js'], 17 | coverage: { 18 | all: true, 19 | include: ['assets/js/**/*.js'], 20 | reporter: ['text', 'json', 'html', 'lcovonly'], 21 | exclude: ['assets/js/**/*.test.js'], 22 | }, 23 | reporters: process.env.GITHUB_ACTIONS ? ['default', 'github-actions'] : 'default', 24 | environment: 'jsdom', 25 | }, 26 | }); 27 | -------------------------------------------------------------------------------- /src/Bridge/Symfony/Resources/config/validator.php: -------------------------------------------------------------------------------- 1 | 9 | * 10 | * For the full copyright and license information, please view the LICENSE 11 | * file that was distributed with this source code. 12 | */ 13 | 14 | namespace Symfony\Component\DependencyInjection\Loader\Configurator; 15 | 16 | use Sonata\Form\Validator\InlineValidator; 17 | 18 | return static function (ContainerConfigurator $containerConfigurator): void { 19 | $containerConfigurator->services() 20 | ->set('sonata.form.validator.inline', InlineValidator::class) 21 | ->tag('validator.constraint_validator', [ 22 | 'alias' => 'sonata.form.validator.inline', 23 | ]) 24 | ->args([ 25 | service('service_container'), 26 | ]); 27 | }; 28 | -------------------------------------------------------------------------------- /src/Bridge/Symfony/Resources/translations/SonataFormBundle.de.xliff: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | link_add 7 | Neu 8 | 9 | 10 | label_type_yes 11 | ja 12 | 13 | 14 | label_type_no 15 | nein 16 | 17 | 18 | date_range_start 19 | Von 20 | 21 | 22 | date_range_end 23 | Bis 24 | 25 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /src/Bridge/Symfony/Resources/translations/SonataFormBundle.ja.xliff: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | link_add 7 | 追加 8 | 9 | 10 | label_type_yes 11 | はい 12 | 13 | 14 | label_type_no 15 | いいえ 16 | 17 | 18 | date_range_start 19 | From 20 | 21 | 22 | date_range_end 23 | To 24 | 25 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /src/Bridge/Symfony/Resources/translations/SonataFormBundle.pl.xliff: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | link_add 7 | Dodaj 8 | 9 | 10 | label_type_yes 11 | tak 12 | 13 | 14 | label_type_no 15 | nie 16 | 17 | 18 | date_range_start 19 | od 20 | 21 | 22 | date_range_end 23 | do 24 | 25 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /src/Bridge/Symfony/Resources/translations/SonataFormBundle.sl.xliff: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | link_add 7 | Dodaj 8 | 9 | 10 | label_type_yes 11 | da 12 | 13 | 14 | label_type_no 15 | ne 16 | 17 | 18 | date_range_start 19 | Od 20 | 21 | 22 | date_range_end 23 | Do 24 | 25 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /src/Bridge/Symfony/Resources/translations/SonataFormBundle.cs.xliff: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | link_add 7 | Přidat nový 8 | 9 | 10 | label_type_yes 11 | ano 12 | 13 | 14 | label_type_no 15 | ne 16 | 17 | 18 | date_range_start 19 | Od 20 | 21 | 22 | date_range_end 23 | Do 24 | 25 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /src/Bridge/Symfony/Resources/translations/SonataFormBundle.en.xliff: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | link_add 7 | Add new 8 | 9 | 10 | label_type_yes 11 | yes 12 | 13 | 14 | label_type_no 15 | no 16 | 17 | 18 | date_range_start 19 | From 20 | 21 | 22 | date_range_end 23 | To 24 | 25 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /src/Bridge/Symfony/Resources/translations/SonataFormBundle.fr.xliff: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | link_add 7 | Ajouter 8 | 9 | 10 | label_type_yes 11 | oui 12 | 13 | 14 | label_type_no 15 | non 16 | 17 | 18 | date_range_start 19 | Du 20 | 21 | 22 | date_range_end 23 | Au 24 | 25 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /src/Bridge/Symfony/Resources/translations/SonataFormBundle.it.xliff: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | link_add 7 | Aggiungi nuovo 8 | 9 | 10 | label_type_yes 11 | 12 | 13 | 14 | label_type_no 15 | no 16 | 17 | 18 | date_range_start 19 | da 20 | 21 | 22 | date_range_end 23 | a 24 | 25 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /src/Bridge/Symfony/Resources/translations/SonataFormBundle.sk.xliff: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | link_add 7 | Pridať nový 8 | 9 | 10 | label_type_yes 11 | áno 12 | 13 | 14 | label_type_no 15 | nie 16 | 17 | 18 | date_range_start 19 | Od 20 | 21 | 22 | date_range_end 23 | Do 24 | 25 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /src/Bridge/Symfony/Resources/translations/SonataFormBundle.fi.xliff: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | link_add 7 | Lisää uusi 8 | 9 | 10 | label_type_yes 11 | kyllä 12 | 13 | 14 | label_type_no 15 | ei 16 | 17 | 18 | date_range_start 19 | Alkaen 20 | 21 | 22 | date_range_end 23 | Saakka 24 | 25 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /src/Bridge/Symfony/Resources/translations/SonataFormBundle.pt_BR.xliff: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | link_add 7 | Adicionar novo 8 | 9 | 10 | label_type_yes 11 | sim 12 | 13 | 14 | label_type_no 15 | não 16 | 17 | 18 | date_range_start 19 | De 20 | 21 | 22 | date_range_end 23 | Até 24 | 25 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /src/Bridge/Symfony/Resources/translations/SonataFormBundle.ca.xliff: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | link_add 7 | Afegeix 8 | 9 | 10 | label_type_yes 11 | 12 | 13 | 14 | label_type_no 15 | no 16 | 17 | 18 | date_range_start 19 | Data inicial 20 | 21 | 22 | date_range_end 23 | Data final 24 | 25 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /src/Bridge/Symfony/Resources/translations/SonataFormBundle.bg.xliff: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | link_add 7 | Добавяне 8 | 9 | 10 | label_type_yes 11 | да 12 | 13 | 14 | label_type_no 15 | не 16 | 17 | 18 | date_range_start 19 | От 20 | 21 | 22 | date_range_end 23 | до 24 | 25 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /src/Bridge/Symfony/Resources/translations/SonataFormBundle.lb.xliff: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | link_add 7 | Nei 8 | 9 | 10 | label_type_yes 11 | jo 12 | 13 | 14 | label_type_no 15 | nee 16 | 17 | 18 | date_range_start 19 | date_range_start 20 | 21 | 22 | date_range_end 23 | date_range_end 24 | 25 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /src/Bridge/Symfony/Resources/translations/SonataFormBundle.pt.xliff: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | link_add 7 | Novo 8 | 9 | 10 | label_type_yes 11 | sim 12 | 13 | 14 | label_type_no 15 | não 16 | 17 | 18 | date_range_start 19 | date_range_start 20 | 21 | 22 | date_range_end 23 | date_range_end 24 | 25 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /src/Bridge/Symfony/Resources/translations/SonataFormBundle.zh_CN.xliff: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | link_add 7 | 添加 8 | 9 | 10 | label_type_yes 11 | 12 | 13 | 14 | label_type_no 15 | 16 | 17 | 18 | date_range_start 19 | date_range_start 20 | 21 | 22 | date_range_end 23 | date_range_end 24 | 25 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /src/Bridge/Symfony/Resources/translations/SonataFormBundle.es.xliff: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | link_add 7 | Agregar nuevo 8 | 9 | 10 | label_type_yes 11 | 12 | 13 | 14 | label_type_no 15 | no 16 | 17 | 18 | date_range_start 19 | Fecha inicial 20 | 21 | 22 | date_range_end 23 | Fecha final 24 | 25 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /src/Bridge/Symfony/Resources/translations/SonataFormBundle.hr.xliff: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | link_add 7 | Novi unos 8 | 9 | 10 | label_type_yes 11 | da 12 | 13 | 14 | label_type_no 15 | ne 16 | 17 | 18 | date_range_start 19 | date_range_start 20 | 21 | 22 | date_range_end 23 | date_range_end 24 | 25 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /src/Bridge/Symfony/Resources/translations/SonataFormBundle.nl.xliff: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | link_add 7 | Nieuwe toevoegen 8 | 9 | 10 | label_type_yes 11 | ja 12 | 13 | 14 | label_type_no 15 | nee 16 | 17 | 18 | date_range_start 19 | vanaf datum 20 | 21 | 22 | date_range_end 23 | tot datum 24 | 25 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /src/Bridge/Symfony/Resources/translations/SonataFormBundle.ro.xliff: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | link_add 7 | Adăugați 8 | 9 | 10 | label_type_yes 11 | da 12 | 13 | 14 | label_type_no 15 | nu 16 | 17 | 18 | date_range_start 19 | date_range_start 20 | 21 | 22 | date_range_end 23 | date_range_end 24 | 25 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /src/Bridge/Symfony/Resources/translations/SonataFormBundle.eu.xliff: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | link_add 7 | Berria gehitu 8 | 9 | 10 | label_type_yes 11 | Bai 12 | 13 | 14 | label_type_no 15 | Ez 16 | 17 | 18 | date_range_start 19 | date_range_start 20 | 21 | 22 | date_range_end 23 | date_range_end 24 | 25 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /src/Bridge/Symfony/Resources/translations/SonataFormBundle.lt.xliff: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | link_add 7 | Sukurti naują 8 | 9 | 10 | label_type_yes 11 | taip 12 | 13 | 14 | label_type_no 15 | ne 16 | 17 | 18 | date_range_start 19 | date_range_start 20 | 21 | 22 | date_range_end 23 | date_range_end 24 | 25 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /src/Bridge/Symfony/Resources/translations/SonataFormBundle.uk.xliff: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | link_add 7 | Додати новий 8 | 9 | 10 | label_type_yes 11 | так 12 | 13 | 14 | label_type_no 15 | немає 16 | 17 | 18 | date_range_start 19 | date_range_start 20 | 21 | 22 | date_range_end 23 | date_range_end 24 | 25 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /src/Bridge/Symfony/Resources/translations/SonataFormBundle.ar.xliff: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | link_add 7 | إضافة جديدة 8 | 9 | 10 | label_type_yes 11 | نعم 12 | 13 | 14 | label_type_no 15 | لا يحتوي 16 | 17 | 18 | date_range_start 19 | date_range_start 20 | 21 | 22 | date_range_end 23 | date_range_end 24 | 25 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /src/Bridge/Symfony/Resources/translations/SonataFormBundle.fa.xliff: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | link_add 7 | اضافه کردن جدید 8 | 9 | 10 | label_type_yes 11 | بله 12 | 13 | 14 | label_type_no 15 | خیر 16 | 17 | 18 | date_range_start 19 | date_range_start 20 | 21 | 22 | date_range_end 23 | date_range_end 24 | 25 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /src/Bridge/Symfony/Resources/translations/SonataFormBundle.hu.xliff: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | link_add 7 | Új hozzáadása 8 | 9 | 10 | label_type_yes 11 | igen 12 | 13 | 14 | label_type_no 15 | nem 16 | 17 | 18 | date_range_start 19 | Intervallum kezdete 20 | 21 | 22 | date_range_end 23 | Intervallum vége 24 | 25 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /src/Type/DateRangePickerType.php: -------------------------------------------------------------------------------- 1 | 9 | * 10 | * For the full copyright and license information, please view the LICENSE 11 | * file that was distributed with this source code. 12 | */ 13 | 14 | namespace Sonata\Form\Type; 15 | 16 | use Symfony\Component\OptionsResolver\OptionsResolver; 17 | 18 | /** 19 | * @author Andrej Hudec 20 | */ 21 | final class DateRangePickerType extends DateRangeType 22 | { 23 | public function configureOptions(OptionsResolver $resolver): void 24 | { 25 | parent::configureOptions($resolver); 26 | 27 | $resolver->setDefault('field_type', DatePickerType::class); 28 | $resolver->setDefault('field_options_end', [ 29 | 'datepicker_options' => [ 30 | 'useCurrent' => false, 31 | ], 32 | ]); 33 | } 34 | 35 | public function getBlockPrefix(): string 36 | { 37 | return 'sonata_type_datetime_range_picker'; 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2010 Thomas Rabaix 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/Bridge/Symfony/Resources/meta/LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License 2 | 3 | Copyright (c) 2010-2015 thomas.rabaix@sonata-project.org 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/Bridge/Symfony/Resources/translations/SonataFormBundle.ru.xliff: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | link_add 7 | Добавить новый 8 | 9 | 10 | label_type_yes 11 | да 12 | 13 | 14 | label_type_no 15 | нет 16 | 17 | 18 | sonata_form_template_box_file_found_in 19 | Этот файл можно найти в 20 | 21 | 22 | date_range_start 23 | С 24 | 25 | 26 | date_range_end 27 | По 28 | 29 | 30 | 31 | 32 | -------------------------------------------------------------------------------- /src/Type/DateTimeRangePickerType.php: -------------------------------------------------------------------------------- 1 | 9 | * 10 | * For the full copyright and license information, please view the LICENSE 11 | * file that was distributed with this source code. 12 | */ 13 | 14 | namespace Sonata\Form\Type; 15 | 16 | use Symfony\Component\OptionsResolver\OptionsResolver; 17 | 18 | /** 19 | * @author Andrej Hudec 20 | * 21 | * NEXT_MAJOR: Declare the class as final. 22 | * 23 | * @final since 2.5.0. 24 | */ 25 | class DateTimeRangePickerType extends DateTimeRangeType 26 | { 27 | public function configureOptions(OptionsResolver $resolver): void 28 | { 29 | parent::configureOptions($resolver); 30 | 31 | $resolver->setDefault('field_type', DateTimePickerType::class); 32 | $resolver->setDefault('field_options_end', [ 33 | 'datepicker_options' => [ 34 | 'useCurrent' => false, 35 | ], 36 | ]); 37 | } 38 | 39 | public function getBlockPrefix(): string 40 | { 41 | return 'sonata_type_datetime_range_picker'; 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/Type/DatePickerType.php: -------------------------------------------------------------------------------- 1 | 9 | * 10 | * For the full copyright and license information, please view the LICENSE 11 | * file that was distributed with this source code. 12 | */ 13 | 14 | namespace Sonata\Form\Type; 15 | 16 | use Symfony\Component\Form\Extension\Core\Type\DateType; 17 | 18 | /** 19 | * @author Hugo Briand 20 | */ 21 | final class DatePickerType extends BasePickerType 22 | { 23 | public function getParent(): string 24 | { 25 | return DateType::class; 26 | } 27 | 28 | public function getBlockPrefix(): string 29 | { 30 | return 'sonata_type_datetime_picker'; 31 | } 32 | 33 | protected function getCommonDefaults(): array 34 | { 35 | return array_merge(parent::getCommonDefaults(), [ 36 | 'format' => DateType::DEFAULT_FORMAT, 37 | ]); 38 | } 39 | 40 | protected function getCommonDatepickerDefaults(): array 41 | { 42 | return array_merge_recursive(parent::getCommonDatepickerDefaults(), [ 43 | 'display' => [ 44 | 'components' => [ 45 | 'clock' => false, 46 | ], 47 | ], 48 | ]); 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/Type/DateTimePickerType.php: -------------------------------------------------------------------------------- 1 | 9 | * 10 | * For the full copyright and license information, please view the LICENSE 11 | * file that was distributed with this source code. 12 | */ 13 | 14 | namespace Sonata\Form\Type; 15 | 16 | use Symfony\Component\Form\Extension\Core\Type\DateTimeType; 17 | 18 | /** 19 | * @author Hugo Briand 20 | */ 21 | final class DateTimePickerType extends BasePickerType 22 | { 23 | public function getParent(): string 24 | { 25 | return DateTimeType::class; 26 | } 27 | 28 | public function getBlockPrefix(): string 29 | { 30 | return 'sonata_type_datetime_picker'; 31 | } 32 | 33 | protected function getCommonDefaults(): array 34 | { 35 | return array_merge(parent::getCommonDefaults(), [ 36 | 'format' => DateTimeType::DEFAULT_DATE_FORMAT, 37 | ]); 38 | } 39 | 40 | protected function getCommonDatepickerDefaults(): array 41 | { 42 | return array_merge_recursive(parent::getCommonDatepickerDefaults(), [ 43 | 'display' => [ 44 | 'components' => [ 45 | 'seconds' => true, 46 | ], 47 | ], 48 | ]); 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/EventListener/FixCheckboxDataListener.php: -------------------------------------------------------------------------------- 1 | 9 | * 10 | * For the full copyright and license information, please view the LICENSE 11 | * file that was distributed with this source code. 12 | */ 13 | 14 | namespace Sonata\Form\EventListener; 15 | 16 | use Symfony\Component\EventDispatcher\EventSubscriberInterface; 17 | use Symfony\Component\Form\Extension\Core\DataTransformer\BooleanToStringTransformer; 18 | use Symfony\Component\Form\FormEvent; 19 | use Symfony\Component\Form\FormEvents; 20 | 21 | /** 22 | * Using BooleanToStringTransform in a checkbox form type 23 | * will set false value to '0' instead of null which will end up 24 | * returning true value when the form is bind. 25 | * 26 | * @author Sylvain Rascar 27 | */ 28 | final class FixCheckboxDataListener implements EventSubscriberInterface 29 | { 30 | public static function getSubscribedEvents(): array 31 | { 32 | return [FormEvents::PRE_SUBMIT => 'preSubmit']; 33 | } 34 | 35 | public function preSubmit(FormEvent $event): void 36 | { 37 | $data = $event->getData(); 38 | $transformers = $event->getForm()->getConfig()->getViewTransformers(); 39 | 40 | if (1 === \count($transformers) && $transformers[0] instanceof BooleanToStringTransformer && '0' === $data) { 41 | $event->setData(null); 42 | } 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/DataTransformer/BooleanTypeToBooleanTransformer.php: -------------------------------------------------------------------------------- 1 | 9 | * 10 | * For the full copyright and license information, please view the LICENSE 11 | * file that was distributed with this source code. 12 | */ 13 | 14 | namespace Sonata\Form\DataTransformer; 15 | 16 | use Sonata\Form\Type\BooleanType; 17 | use Symfony\Component\Form\DataTransformerInterface; 18 | 19 | /** 20 | * @phpstan-implements DataTransformerInterface 21 | */ 22 | final class BooleanTypeToBooleanTransformer implements DataTransformerInterface 23 | { 24 | /** 25 | * @phpstan-throws void 26 | * 27 | * @phpstan-param mixed $value 28 | */ 29 | public function transform(mixed $value): ?int 30 | { 31 | if (true === $value || BooleanType::TYPE_YES === (int) $value) { 32 | return BooleanType::TYPE_YES; 33 | } 34 | if (false === $value || BooleanType::TYPE_NO === (int) $value) { 35 | return BooleanType::TYPE_NO; 36 | } 37 | 38 | return null; 39 | } 40 | 41 | /** 42 | * @phpstan-throws void 43 | * 44 | * @phpstan-param mixed $value 45 | */ 46 | public function reverseTransform(mixed $value): ?bool 47 | { 48 | if (BooleanType::TYPE_YES === $value) { 49 | return true; 50 | } 51 | if (BooleanType::TYPE_NO === $value) { 52 | return false; 53 | } 54 | 55 | return null; 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/Type/BaseStatusType.php: -------------------------------------------------------------------------------- 1 | 9 | * 10 | * For the full copyright and license information, please view the LICENSE 11 | * file that was distributed with this source code. 12 | */ 13 | 14 | namespace Sonata\Form\Type; 15 | 16 | use Symfony\Component\Form\AbstractType; 17 | use Symfony\Component\Form\Extension\Core\Type\ChoiceType; 18 | use Symfony\Component\OptionsResolver\OptionsResolver; 19 | 20 | abstract class BaseStatusType extends AbstractType 21 | { 22 | /** 23 | * @phpstan-param class-string $class 24 | */ 25 | public function __construct( 26 | protected string $class, 27 | protected string $getter, 28 | protected string $name, 29 | ) { 30 | } 31 | 32 | public function getParent(): string 33 | { 34 | return ChoiceType::class; 35 | } 36 | 37 | public function getBlockPrefix(): string 38 | { 39 | return $this->name; 40 | } 41 | 42 | public function configureOptions(OptionsResolver $resolver): void 43 | { 44 | $callable = [$this->class, $this->getter]; 45 | if (!\is_callable($callable)) { 46 | throw new \RuntimeException(\sprintf( 47 | 'The class "%s" has no method "%s()".', 48 | $this->class, 49 | $this->getter 50 | )); 51 | } 52 | 53 | $resolver->setDefaults([ 54 | 'choices' => \call_user_func($callable), 55 | ]); 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/Type/BooleanType.php: -------------------------------------------------------------------------------- 1 | 9 | * 10 | * For the full copyright and license information, please view the LICENSE 11 | * file that was distributed with this source code. 12 | */ 13 | 14 | namespace Sonata\Form\Type; 15 | 16 | use Sonata\Form\DataTransformer\BooleanTypeToBooleanTransformer; 17 | use Symfony\Component\Form\AbstractType; 18 | use Symfony\Component\Form\Extension\Core\Type\ChoiceType; 19 | use Symfony\Component\Form\FormBuilderInterface; 20 | use Symfony\Component\OptionsResolver\OptionsResolver; 21 | 22 | final class BooleanType extends AbstractType 23 | { 24 | public const TYPE_YES = 1; 25 | 26 | public const TYPE_NO = 2; 27 | 28 | public function buildForm(FormBuilderInterface $builder, array $options): void 29 | { 30 | if (true === $options['transform']) { 31 | $builder->addModelTransformer(new BooleanTypeToBooleanTransformer()); 32 | } 33 | } 34 | 35 | public function configureOptions(OptionsResolver $resolver): void 36 | { 37 | $resolver->setDefaults([ 38 | 'transform' => false, 39 | 'choice_translation_domain' => 'SonataFormBundle', 40 | 'choices' => [ 41 | 'label_type_yes' => self::TYPE_YES, 42 | 'label_type_no' => self::TYPE_NO, 43 | ], 44 | 'translation_domain' => 'SonataFormBundle', 45 | ]); 46 | 47 | $resolver->setAllowedTypes('transform', 'bool'); 48 | } 49 | 50 | public function getParent(): string 51 | { 52 | return ChoiceType::class; 53 | } 54 | 55 | public function getBlockPrefix(): string 56 | { 57 | return 'sonata_type_boolean'; 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/Bridge/Symfony/Resources/views/Form/datepicker.html.twig: -------------------------------------------------------------------------------- 1 | {# 2 | 3 | This file is part of the Sonata package. 4 | 5 | (c) Thomas Rabaix 6 | 7 | For the full copyright and license information, please view the LICENSE 8 | file that was distributed with this source code. 9 | 10 | #} 11 | 12 | {% block sonata_type_datetime_picker_widget_html %} 13 |
22 | {% set attr = attr|merge({ 23 | 'data-td-target': '#' ~ id ~ '_controller', 24 | }) %} 25 | 26 | {{ block('datetime_widget') }} 27 | 28 | {% if datepicker_use_button %} 29 | 34 | 35 | 36 | {% endif %} 37 |
38 | {% endblock sonata_type_datetime_picker_widget_html %} 39 | 40 | {% block sonata_type_datetime_picker_widget %} 41 | {% if wrap_fields_with_addons %} 42 |
43 | {{ block('sonata_type_datetime_picker_widget_html') }} 44 |
45 | {% else %} 46 | {{ block('sonata_type_datetime_picker_widget_html') }} 47 | {% endif %} 48 | {% endblock sonata_type_datetime_picker_widget %} 49 | -------------------------------------------------------------------------------- /src/Validator/InlineValidator.php: -------------------------------------------------------------------------------- 1 | 9 | * 10 | * For the full copyright and license information, please view the LICENSE 11 | * file that was distributed with this source code. 12 | */ 13 | 14 | namespace Sonata\Form\Validator; 15 | 16 | use Sonata\Form\Validator\Constraints\InlineConstraint; 17 | use Symfony\Component\DependencyInjection\ContainerInterface; 18 | use Symfony\Component\Validator\Constraint; 19 | use Symfony\Component\Validator\ConstraintValidator; 20 | use Symfony\Component\Validator\Exception\UnexpectedTypeException; 21 | 22 | final class InlineValidator extends ConstraintValidator 23 | { 24 | public function __construct(private ContainerInterface $container) 25 | { 26 | } 27 | 28 | /** 29 | * @param mixed $value 30 | */ 31 | public function validate($value, Constraint $constraint): void 32 | { 33 | if (!$constraint instanceof InlineConstraint) { 34 | throw new UnexpectedTypeException($constraint, InlineConstraint::class); 35 | } 36 | 37 | if ($constraint->isClosure()) { 38 | $function = $constraint->getClosure(); 39 | } else { 40 | if (\is_string($constraint->getService())) { 41 | $service = $this->container->get($constraint->getService()); 42 | } else { 43 | $service = $constraint->getService(); 44 | } 45 | 46 | $function = [$service, $constraint->getMethod()]; 47 | } 48 | 49 | \call_user_func($function, $this->getErrorElement($value), $value); 50 | } 51 | 52 | protected function getErrorElement(mixed $value): ErrorElement 53 | { 54 | return new ErrorElement( 55 | $value, 56 | $this->context, 57 | $this->context->getGroup() 58 | ); 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src/Bridge/Symfony/DependencyInjection/Configuration.php: -------------------------------------------------------------------------------- 1 | 9 | * 10 | * For the full copyright and license information, please view the LICENSE 11 | * file that was distributed with this source code. 12 | */ 13 | 14 | namespace Sonata\Form\Bridge\Symfony\DependencyInjection; 15 | 16 | use Symfony\Component\Config\Definition\Builder\ArrayNodeDefinition; 17 | use Symfony\Component\Config\Definition\Builder\TreeBuilder; 18 | use Symfony\Component\Config\Definition\ConfigurationInterface; 19 | 20 | /** 21 | * This is the class that validates and merges configuration from your app/config files. 22 | * 23 | * @author Thomas Rabaix 24 | * @author Alexander 25 | */ 26 | final class Configuration implements ConfigurationInterface 27 | { 28 | public function getConfigTreeBuilder(): TreeBuilder 29 | { 30 | $treeBuilder = new TreeBuilder('sonata_form'); 31 | $rootNode = $treeBuilder->getRootNode(); 32 | 33 | $this->addFlashMessageSection($rootNode); 34 | 35 | return $treeBuilder; 36 | } 37 | 38 | /** 39 | * Returns configuration for flash messages. 40 | * 41 | * @see https://github.com/psalm/psalm-plugin-symfony/issues/174 42 | */ 43 | private function addFlashMessageSection(ArrayNodeDefinition $node): void 44 | { 45 | $node 46 | ->children() 47 | ->scalarNode('form_type') 48 | ->defaultValue('standard') 49 | ->validate() 50 | ->ifNotInArray($validFormTypes = ['standard', 'horizontal']) 51 | ->thenInvalid(\sprintf( 52 | 'The form_type option value must be one of %s', 53 | $validFormTypesString = implode(', ', $validFormTypes) 54 | )) 55 | ->end() 56 | ->info(\sprintf('Must be one of %s', $validFormTypesString)) 57 | ->end() 58 | ->end(); 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src/Type/ImmutableArrayType.php: -------------------------------------------------------------------------------- 1 | 9 | * 10 | * For the full copyright and license information, please view the LICENSE 11 | * file that was distributed with this source code. 12 | */ 13 | 14 | namespace Sonata\Form\Type; 15 | 16 | use Symfony\Component\Form\AbstractType; 17 | use Symfony\Component\Form\FormBuilderInterface; 18 | use Symfony\Component\OptionsResolver\OptionsResolver; 19 | 20 | final class ImmutableArrayType extends AbstractType 21 | { 22 | public function buildForm(FormBuilderInterface $builder, array $options): void 23 | { 24 | foreach ($options['keys'] as $infos) { 25 | if ($infos instanceof FormBuilderInterface) { 26 | $builder->add($infos); 27 | } else { 28 | [$name, $type, $options] = $infos; 29 | 30 | if (\is_callable($options)) { 31 | $extra = \array_slice($infos, 3); 32 | 33 | $options = $options($builder, $name, $type, $extra); 34 | 35 | if (null === $options) { 36 | $options = []; 37 | } elseif (!\is_array($options)) { 38 | throw new \RuntimeException('the closure must return null or an array'); 39 | } 40 | } 41 | 42 | $builder->add($name, $type, $options); 43 | } 44 | } 45 | } 46 | 47 | public function configureOptions(OptionsResolver $resolver): void 48 | { 49 | $resolver->setDefaults([ 50 | 'keys' => [], 51 | ]); 52 | 53 | $resolver->setAllowedValues('keys', static function (iterable $value): bool { 54 | foreach ($value as $subValue) { 55 | if (!$subValue instanceof FormBuilderInterface && (!\is_array($subValue) || 3 !== \count($subValue))) { 56 | return false; 57 | } 58 | } 59 | 60 | return true; 61 | }); 62 | } 63 | 64 | public function getBlockPrefix(): string 65 | { 66 | return 'sonata_type_immutable_array'; 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /src/Bridge/Symfony/DependencyInjection/SonataFormExtension.php: -------------------------------------------------------------------------------- 1 | 9 | * 10 | * For the full copyright and license information, please view the LICENSE 11 | * file that was distributed with this source code. 12 | */ 13 | 14 | namespace Sonata\Form\Bridge\Symfony\DependencyInjection; 15 | 16 | use Symfony\Component\Config\Definition\Processor; 17 | use Symfony\Component\Config\FileLocator; 18 | use Symfony\Component\DependencyInjection\ContainerBuilder; 19 | use Symfony\Component\DependencyInjection\Extension\Extension; 20 | use Symfony\Component\DependencyInjection\Extension\PrependExtensionInterface; 21 | use Symfony\Component\DependencyInjection\Loader\PhpFileLoader; 22 | 23 | /** 24 | * @author Thomas Rabaix 25 | */ 26 | final class SonataFormExtension extends Extension implements PrependExtensionInterface 27 | { 28 | public function prepend(ContainerBuilder $container): void 29 | { 30 | $configs = $container->getExtensionConfig('sonata_admin'); 31 | 32 | foreach ($configs as $config) { 33 | if (isset($config['options']['form_type'])) { 34 | $container->prependExtensionConfig( 35 | $this->getAlias(), 36 | ['form_type' => $config['options']['form_type']] 37 | ); 38 | } 39 | } 40 | 41 | // add custom form widgets 42 | if ($container->hasExtension('twig')) { 43 | $container->prependExtensionConfig('twig', [ 44 | 'form_themes' => ['@SonataForm/Form/datepicker.html.twig'], 45 | ]); 46 | } 47 | } 48 | 49 | public function load(array $configs, ContainerBuilder $container): void 50 | { 51 | $processor = new Processor(); 52 | $configuration = new Configuration(); 53 | $config = $processor->processConfiguration($configuration, $configs); 54 | 55 | $loader = new PhpFileLoader($container, new FileLocator(__DIR__.'/../Resources/config')); 56 | $loader->load('date.php'); 57 | $loader->load('form_types.php'); 58 | $loader->load('validator.php'); 59 | 60 | $container->setParameter('sonata.form.form_type', $config['form_type']); 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/Type/DateRangeType.php: -------------------------------------------------------------------------------- 1 | 9 | * 10 | * For the full copyright and license information, please view the LICENSE 11 | * file that was distributed with this source code. 12 | */ 13 | 14 | namespace Sonata\Form\Type; 15 | 16 | use Symfony\Component\Form\AbstractType; 17 | use Symfony\Component\Form\Extension\Core\Type\DateType; 18 | use Symfony\Component\Form\FormBuilderInterface; 19 | use Symfony\Component\Form\FormInterface; 20 | use Symfony\Component\Form\FormView; 21 | use Symfony\Component\OptionsResolver\OptionsResolver; 22 | 23 | class DateRangeType extends AbstractType 24 | { 25 | public function buildForm(FormBuilderInterface $builder, array $options): void 26 | { 27 | $options['field_options_start'] = array_merge( 28 | [ 29 | 'label' => 'date_range_start', 30 | 'translation_domain' => 'SonataFormBundle', 31 | ], 32 | $options['field_options_start'] 33 | ); 34 | 35 | $options['field_options_end'] = array_merge( 36 | [ 37 | 'label' => 'date_range_end', 38 | 'translation_domain' => 'SonataFormBundle', 39 | ], 40 | $options['field_options_end'] 41 | ); 42 | 43 | $builder->add( 44 | 'start', 45 | $options['field_type'], 46 | array_merge(['required' => false], $options['field_options'], $options['field_options_start']) 47 | ); 48 | $builder->add( 49 | 'end', 50 | $options['field_type'], 51 | array_merge(['required' => false], $options['field_options'], $options['field_options_end']) 52 | ); 53 | } 54 | 55 | public function finishView(FormView $view, FormInterface $form, array $options): void 56 | { 57 | $view->children['start']->vars['linked_to'] = $view->children['end']->vars['id']; 58 | } 59 | 60 | public function getBlockPrefix(): string 61 | { 62 | return 'sonata_type_date_range'; 63 | } 64 | 65 | public function configureOptions(OptionsResolver $resolver): void 66 | { 67 | $resolver->setDefaults([ 68 | 'field_options' => [], 69 | 'field_options_start' => [], 70 | 'field_options_end' => [], 71 | 'field_type' => DateType::class, 72 | ]); 73 | 74 | $resolver->setAllowedTypes('field_options', 'array'); 75 | $resolver->setAllowedTypes('field_options_start', 'array'); 76 | $resolver->setAllowedTypes('field_options_end', 'array'); 77 | $resolver->setAllowedTypes('field_type', 'string'); 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /src/Type/DateTimeRangeType.php: -------------------------------------------------------------------------------- 1 | 9 | * 10 | * For the full copyright and license information, please view the LICENSE 11 | * file that was distributed with this source code. 12 | */ 13 | 14 | namespace Sonata\Form\Type; 15 | 16 | use Symfony\Component\Form\AbstractType; 17 | use Symfony\Component\Form\Extension\Core\Type\DateTimeType; 18 | use Symfony\Component\Form\FormBuilderInterface; 19 | use Symfony\Component\Form\FormInterface; 20 | use Symfony\Component\Form\FormView; 21 | use Symfony\Component\OptionsResolver\OptionsResolver; 22 | 23 | class DateTimeRangeType extends AbstractType 24 | { 25 | public function buildForm(FormBuilderInterface $builder, array $options): void 26 | { 27 | $options['field_options_start'] = array_merge( 28 | [ 29 | 'label' => 'date_range_start', 30 | 'translation_domain' => 'SonataFormBundle', 31 | ], 32 | $options['field_options_start'] 33 | ); 34 | 35 | $options['field_options_end'] = array_merge( 36 | [ 37 | 'label' => 'date_range_end', 38 | 'translation_domain' => 'SonataFormBundle', 39 | ], 40 | $options['field_options_end'] 41 | ); 42 | 43 | $builder->add( 44 | 'start', 45 | $options['field_type'], 46 | array_merge(['required' => false], $options['field_options'], $options['field_options_start']) 47 | ); 48 | $builder->add( 49 | 'end', 50 | $options['field_type'], 51 | array_merge(['required' => false], $options['field_options'], $options['field_options_end']) 52 | ); 53 | } 54 | 55 | public function finishView(FormView $view, FormInterface $form, array $options): void 56 | { 57 | $view->children['start']->vars['linked_to'] = $view->children['end']->vars['id']; 58 | } 59 | 60 | public function getBlockPrefix(): string 61 | { 62 | return 'sonata_type_datetime_range'; 63 | } 64 | 65 | public function configureOptions(OptionsResolver $resolver): void 66 | { 67 | $resolver->setDefaults([ 68 | 'field_options' => [], 69 | 'field_options_start' => [], 70 | 'field_options_end' => [], 71 | 'field_type' => DateTimeType::class, 72 | ]); 73 | 74 | $resolver->setAllowedTypes('field_options', 'array'); 75 | $resolver->setAllowedTypes('field_options_start', 'array'); 76 | $resolver->setAllowedTypes('field_options_end', 'array'); 77 | $resolver->setAllowedTypes('field_type', 'string'); 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /src/Bridge/Symfony/Resources/config/form_types.php: -------------------------------------------------------------------------------- 1 | 9 | * 10 | * For the full copyright and license information, please view the LICENSE 11 | * file that was distributed with this source code. 12 | */ 13 | 14 | namespace Symfony\Component\DependencyInjection\Loader\Configurator; 15 | 16 | use Sonata\Form\Type\BooleanType; 17 | use Sonata\Form\Type\CollectionType; 18 | use Sonata\Form\Type\DatePickerType; 19 | use Sonata\Form\Type\DateRangePickerType; 20 | use Sonata\Form\Type\DateRangeType; 21 | use Sonata\Form\Type\DateTimePickerType; 22 | use Sonata\Form\Type\DateTimeRangePickerType; 23 | use Sonata\Form\Type\DateTimeRangeType; 24 | use Sonata\Form\Type\ImmutableArrayType; 25 | 26 | return static function (ContainerConfigurator $containerConfigurator): void { 27 | $containerConfigurator->services() 28 | 29 | ->set('sonata.form.type.array', ImmutableArrayType::class) 30 | ->tag('form.type', ['alias' => 'sonata_type_immutable_array']) 31 | 32 | ->set('sonata.form.type.boolean', BooleanType::class) 33 | ->tag('form.type', ['alias' => 'sonata_type_boolean']) 34 | 35 | ->set('sonata.form.type.collection', CollectionType::class) 36 | ->tag('form.type', ['alias' => 'sonata_type_collection']) 37 | 38 | ->set('sonata.form.type.date_range', DateRangeType::class) 39 | ->tag('form.type', ['alias' => 'sonata_type_date_range']) 40 | 41 | ->set('sonata.form.type.datetime_range', DateTimeRangeType::class) 42 | ->tag('form.type', ['alias' => 'sonata_type_datetime_range']) 43 | 44 | ->set('sonata.form.type.date_picker', DatePickerType::class) 45 | ->tag('kernel.locale_aware') 46 | ->tag('form.type', ['alias' => 'sonata_type_date_picker']) 47 | ->args([ 48 | service('sonata.form.date.javascript_format_converter'), 49 | param('kernel.default_locale'), 50 | ]) 51 | 52 | ->set('sonata.form.type.datetime_picker', DateTimePickerType::class) 53 | ->tag('kernel.locale_aware') 54 | ->tag('form.type', ['alias' => 'sonata_type_datetime_picker']) 55 | ->args([ 56 | service('sonata.form.date.javascript_format_converter'), 57 | param('kernel.default_locale'), 58 | ]) 59 | 60 | ->set('sonata.form.type.date_range_picker', DateRangePickerType::class) 61 | ->tag('form.type', ['alias' => 'sonata_type_date_range_picker']) 62 | 63 | ->set('sonata.form.type.datetime_range_picker', DateTimeRangePickerType::class) 64 | ->tag('form.type', ['alias' => 'sonata_type_datetime_range_picker']); 65 | }; 66 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "sonata-project/form-extensions", 3 | "description": "Symfony form extensions", 4 | "license": "MIT", 5 | "type": "symfony-bundle", 6 | "keywords": [ 7 | "symfony", 8 | "form" 9 | ], 10 | "authors": [ 11 | { 12 | "name": "Thomas Rabaix", 13 | "email": "thomas.rabaix@sonata-project.org", 14 | "homepage": "https://sonata-project.org" 15 | }, 16 | { 17 | "name": "Sonata Community", 18 | "homepage": "https://github.com/sonata-project/form-extensions/contributors" 19 | } 20 | ], 21 | "homepage": "https://docs.sonata-project.org/projects/form-extensions", 22 | "require": { 23 | "php": "^8.2", 24 | "symfony/deprecation-contracts": "^3.6", 25 | "symfony/event-dispatcher": "^6.4 || ^7.3 || ^8.0", 26 | "symfony/form": "^6.4 || ^7.3 || ^8.0", 27 | "symfony/options-resolver": "^6.4 || ^7.3 || ^8.0", 28 | "symfony/property-access": "^6.4 || ^7.3 || ^8.0", 29 | "symfony/security-csrf": "^6.4 || ^7.3 || ^8.0", 30 | "symfony/translation": "^6.4 || ^7.3 || ^8.0", 31 | "symfony/translation-contracts": "^2.5 || ^3.0", 32 | "symfony/validator": "^6.4 || ^7.3 || ^8.0", 33 | "twig/twig": "^3.0" 34 | }, 35 | "require-dev": { 36 | "friendsofphp/php-cs-fixer": "^3.4", 37 | "matthiasnoback/symfony-config-test": "^6.1.0", 38 | "matthiasnoback/symfony-dependency-injection-test": "dev-ci-updates as 6.1.0.1", 39 | "phpstan/extension-installer": "^1.0", 40 | "phpstan/phpdoc-parser": "^1.0", 41 | "phpstan/phpstan": "^1.0 || ^2.0", 42 | "phpstan/phpstan-phpunit": "^1.0 || ^2.0", 43 | "phpstan/phpstan-strict-rules": "^1.0 || ^2.0", 44 | "phpstan/phpstan-symfony": "^1.0 || ^2.0", 45 | "phpunit/phpunit": "^11.5.38 || ^12.3.10", 46 | "rector/rector": "^1.1 || ^2.0", 47 | "symfony/config": "^6.4 || ^7.3 || ^8.0", 48 | "symfony/dependency-injection": "^6.4 || ^7.3 || ^8.0", 49 | "symfony/framework-bundle": "^6.4 || ^7.3 || ^8.0", 50 | "symfony/http-foundation": "^6.4 || ^7.3 || ^8.0", 51 | "symfony/http-kernel": "^6.4 || ^7.3 || ^8.0", 52 | "symfony/twig-bridge": "^6.4.3 || ^7.1", 53 | "symfony/twig-bundle": "^6.4 || ^7.3 || ^8.0" 54 | }, 55 | "repositories": [ 56 | { 57 | "type": "git", 58 | "url": "git@github.com:dmaicher/SymfonyDependencyInjectionTest.git" 59 | } 60 | ], 61 | "minimum-stability": "dev", 62 | "prefer-stable": true, 63 | "autoload": { 64 | "psr-4": { 65 | "Sonata\\Form\\": "src/" 66 | } 67 | }, 68 | "autoload-dev": { 69 | "psr-4": { 70 | "Sonata\\Form\\Tests\\": "tests/" 71 | } 72 | }, 73 | "config": { 74 | "allow-plugins": { 75 | "composer/package-versions-deprecated": true, 76 | "phpstan/extension-installer": true 77 | }, 78 | "sort-packages": true 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /src/Date/JavaScriptFormatConverter.php: -------------------------------------------------------------------------------- 1 | 9 | * 10 | * For the full copyright and license information, please view the LICENSE 11 | * file that was distributed with this source code. 12 | */ 13 | 14 | namespace Sonata\Form\Date; 15 | 16 | /** 17 | * Handles JavaScript <-> PHP date format conversion. 18 | * 19 | * @author Hugo Briand 20 | * @author Andrej Hudec 21 | */ 22 | final class JavaScriptFormatConverter 23 | { 24 | /** 25 | * This defines the mapping between PHP ICU date format (key) and JavaScript date format (value) 26 | * For ICU formats see http://userguide.icu-project.org/formatparse/datetime#TOC-Date-Time-Format-Syntax 27 | * For JavaScript formats see https://github.com/Eonasdan/tempus-dominus/blob/master/src/js/datetime.ts#L922-L947. 28 | */ 29 | private const FORMAT_CONVERT_RULES = [ 30 | 'yyyy' => 'yyyy', 'yy' => 'yy', 'y' => 'yyyy', 31 | 'EEEE' => 'dddd', 'EE' => 'ddd', 'E' => 'ddd', 32 | 'a' => 'T', 33 | ]; 34 | 35 | /** 36 | * Returns associated JavaScript format. 37 | * 38 | * @param string $format PHP Date format 39 | * 40 | * @return string JavaScript date format 41 | */ 42 | public function convert(string $format): string 43 | { 44 | $size = \strlen($format); 45 | 46 | $output = ''; 47 | // process the format string letter by letter 48 | for ($i = 0; $i < $size; ++$i) { 49 | // if finds a ' 50 | if ("'" === $format[$i]) { 51 | // if the next character are T' forming 'T', send a T to the 52 | // output 53 | if ('T' === $format[$i + 1] && '\'' === $format[$i + 2]) { 54 | $output .= 'T'; 55 | $i += 2; 56 | } else { 57 | // if it's no a 'T' then send whatever is inside the '' to 58 | // the output, but send it inside [] (useful for cases like 59 | // the brazilian translation that uses a 'de' in the date) 60 | $output .= '['; 61 | $temp = current(explode("'", substr($format, $i + 1))); 62 | $output .= $temp; 63 | $output .= ']'; 64 | $i += \strlen($temp) + 1; 65 | } 66 | } else { 67 | // if no ' is found, then search all the rules, see if any of 68 | // them matchs 69 | $foundOne = false; 70 | foreach (self::FORMAT_CONVERT_RULES as $key => $value) { 71 | if (substr($format, $i, \strlen($key)) === $key) { 72 | $output .= $value; 73 | $foundOne = true; 74 | $i += \strlen($key) - 1; 75 | 76 | break; 77 | } 78 | } 79 | // if no rule is matched, then just add the character to the 80 | // output 81 | if (!$foundOne) { 82 | $output .= $format[$i]; 83 | } 84 | } 85 | } 86 | 87 | return $output; 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /src/Type/CollectionType.php: -------------------------------------------------------------------------------- 1 | 9 | * 10 | * For the full copyright and license information, please view the LICENSE 11 | * file that was distributed with this source code. 12 | */ 13 | 14 | namespace Sonata\Form\Type; 15 | 16 | use Sonata\Form\EventListener\ResizeFormListener; 17 | use Symfony\Component\Form\AbstractType; 18 | use Symfony\Component\Form\Extension\Core\Type\TextType; 19 | use Symfony\Component\Form\FormBuilderInterface; 20 | use Symfony\Component\Form\FormInterface; 21 | use Symfony\Component\Form\FormView; 22 | use Symfony\Component\OptionsResolver\Options; 23 | use Symfony\Component\OptionsResolver\OptionsResolver; 24 | 25 | final class CollectionType extends AbstractType 26 | { 27 | public function buildForm(FormBuilderInterface $builder, array $options): void 28 | { 29 | $builder->addEventSubscriber(new ResizeFormListener( 30 | $options['type'], 31 | $options['type_options'], 32 | $options['modifiable'], 33 | $options['pre_bind_data_callback'] 34 | )); 35 | } 36 | 37 | public function buildView(FormView $view, FormInterface $form, array $options): void 38 | { 39 | $view->vars['btn_add'] = $options['btn_add']; 40 | 41 | // NEXT_MAJOR: Remove the btn_catalogue usage. 42 | $view->vars['btn_translation_domain'] = 43 | 'SonataFormBundle' !== $options['btn_translation_domain'] 44 | ? $options['btn_translation_domain'] 45 | : $options['btn_catalogue']; 46 | $view->vars['btn_catalogue'] = $options['btn_catalogue']; 47 | } 48 | 49 | public function configureOptions(OptionsResolver $resolver): void 50 | { 51 | $resolver->setDefaults([ 52 | 'modifiable' => false, 53 | 'type' => TextType::class, 54 | 'type_options' => [], 55 | 'pre_bind_data_callback' => null, 56 | 'btn_add' => 'link_add', 57 | 'btn_catalogue' => 'SonataFormBundle', // NEXT_MAJOR: Remove this option. 58 | 'btn_translation_domain' => 'SonataFormBundle', 59 | ]); 60 | 61 | $resolver->setDeprecated( 62 | 'btn_catalogue', 63 | 'sonata-project/form-extensions', 64 | '2.1', 65 | static function (Options $options, mixed $value): string { 66 | if ('SonataFormBundle' !== $value) { 67 | return 'Passing a value to option "btn_catalogue" is deprecated! Use "btn_translation_domain" instead!'; 68 | } 69 | 70 | return ''; 71 | }, 72 | ); // NEXT_MAJOR: Remove this deprecation notice. 73 | 74 | $resolver->setAllowedTypes('modifiable', 'bool'); 75 | $resolver->setAllowedTypes('type', 'string'); 76 | $resolver->setAllowedTypes('type_options', 'array'); 77 | $resolver->setAllowedTypes('pre_bind_data_callback', ['null', 'callable']); 78 | $resolver->setAllowedTypes('btn_add', ['null', 'bool', 'string']); 79 | $resolver->setAllowedTypes('btn_catalogue', ['null', 'bool', 'string']); 80 | $resolver->setAllowedTypes('btn_translation_domain', ['null', 'bool', 'string']); 81 | } 82 | 83 | public function getBlockPrefix(): string 84 | { 85 | return 'sonata_type_collection'; 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /assets/js/controllers/datepicker_controller.js: -------------------------------------------------------------------------------- 1 | /*! 2 | * This file is part of the Sonata Project package. 3 | * 4 | * (c) Thomas Rabaix 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | import { Controller } from '@hotwired/stimulus'; 11 | import { TempusDominus, Namespace, loadLocale } from '@eonasdan/tempus-dominus'; 12 | import { faFiveIcons } from '@eonasdan/tempus-dominus/dist/plugins/fa-five'; 13 | 14 | export default class extends Controller { 15 | static outlets = ['datepicker']; 16 | 17 | static values = { 18 | options: Object, 19 | }; 20 | 21 | #allowedLocales = [ 22 | 'ar', 23 | 'ar-SA', 24 | 'ca', 25 | 'de', 26 | 'es', 27 | 'fi', 28 | 'fr', 29 | 'it', 30 | 'nl', 31 | 'pl', 32 | 'ro', 33 | 'ru', 34 | 'sl', 35 | 'tr', 36 | ]; 37 | 38 | datePicker = null; 39 | 40 | connect() { 41 | const options = this.processOptions(); 42 | const locale = this.processLocale(options); 43 | 44 | this.dispatchEvent('pre-connect', { options, locale }); 45 | 46 | this.datePicker = new TempusDominus(this.element, options); 47 | 48 | if (locale !== null) { 49 | import(`@eonasdan/tempus-dominus/dist/locales/${locale}`).then((data) => { 50 | loadLocale(data); 51 | 52 | this.datePicker.locale(data.name); 53 | this.datePicker.updateOptions(options); 54 | 55 | this.dispatchEvent('post-connect-changed-locale'); 56 | }); 57 | } 58 | 59 | this.dispatchEvent('connect', { datePicker: this.datePicker }); 60 | } 61 | 62 | datepickerOutletConnected(outlet, element) { 63 | this.element.addEventListener(Namespace.events.change, (event) => { 64 | outlet.datePicker.updateOptions({ 65 | restrictions: { 66 | minDate: event.detail.date, 67 | }, 68 | }); 69 | }); 70 | 71 | element.addEventListener(Namespace.events.change, (event) => { 72 | this.datePicker.updateOptions({ 73 | restrictions: { 74 | maxDate: event.detail.date, 75 | }, 76 | }); 77 | }); 78 | } 79 | 80 | processOptions() { 81 | const options = this.optionsValue; 82 | const { restrictions = {}, display = {} } = options; 83 | 84 | if (options?.defaultDate) { 85 | options.defaultDate = new Date(options.defaultDate); 86 | } 87 | 88 | if (options?.viewDate) { 89 | options.viewDate = new Date(options.viewDate); 90 | } 91 | 92 | if (restrictions?.minDate) { 93 | restrictions.minDate = new Date(restrictions.minDate); 94 | } 95 | 96 | if (restrictions?.maxDate) { 97 | restrictions.maxDate = new Date(restrictions.maxDate); 98 | } 99 | 100 | if (restrictions?.disabledDates) { 101 | restrictions.disabledDates = restrictions.disabledDates.map((date) => new Date(date)); 102 | } 103 | 104 | if (restrictions?.enabledDates) { 105 | restrictions.enabledDates = restrictions.enabledDates.map((date) => new Date(date)); 106 | } 107 | 108 | if (!display?.icons) { 109 | display.icons = faFiveIcons; 110 | } 111 | 112 | return options; 113 | } 114 | 115 | processLocale(options) { 116 | const { localization: { locale } = {} } = options; 117 | 118 | if (!locale) { 119 | return null; 120 | } 121 | 122 | if (this.#allowedLocales.includes(locale)) { 123 | return locale; 124 | } 125 | 126 | if (!locale.includes('-')) { 127 | return null; 128 | } 129 | 130 | const localeCode = locale.split('-')[0]; 131 | 132 | if (this.#allowedLocales.includes(localeCode)) { 133 | return localeCode; 134 | } 135 | 136 | return null; 137 | } 138 | 139 | dispatchEvent(name, payload) { 140 | this.dispatch(name, { detail: payload, prefix: 'datepicker' }); 141 | } 142 | } 143 | -------------------------------------------------------------------------------- /src/Test/AbstractWidgetTestCase.php: -------------------------------------------------------------------------------- 1 | 9 | * 10 | * For the full copyright and license information, please view the LICENSE 11 | * file that was distributed with this source code. 12 | */ 13 | 14 | namespace Sonata\Form\Test; 15 | 16 | use Sonata\Form\Fixtures\StubTranslator; 17 | use Symfony\Bridge\Twig\Extension\FormExtension; 18 | use Symfony\Bridge\Twig\Extension\TranslationExtension; 19 | use Symfony\Bridge\Twig\Form\TwigRendererEngine; 20 | use Symfony\Component\Form\FormRenderer; 21 | use Symfony\Component\Form\FormView; 22 | use Symfony\Component\Form\Test\TypeTestCase; 23 | use Symfony\Component\Security\Csrf\CsrfTokenManagerInterface; 24 | use Twig\Environment; 25 | use Twig\Loader\FilesystemLoader; 26 | use Twig\RuntimeLoader\FactoryRuntimeLoader; 27 | 28 | /** 29 | * Base class for tests checking rendering of form widgets. 30 | * 31 | * @author Christian Gripp 32 | */ 33 | abstract class AbstractWidgetTestCase extends TypeTestCase 34 | { 35 | private FormRenderer $renderer; 36 | 37 | protected function setUp(): void 38 | { 39 | parent::setUp(); 40 | 41 | $environment = $this->getEnvironment(); 42 | 43 | $this->renderer = new FormRenderer( 44 | $this->getRenderingEngine($environment), 45 | $this->createMock(CsrfTokenManagerInterface::class) 46 | ); 47 | 48 | $environment->addRuntimeLoader(new FactoryRuntimeLoader([ 49 | FormRenderer::class => fn (): FormRenderer => $this->renderer, 50 | ])); 51 | $environment->addExtension(new FormExtension()); 52 | } 53 | 54 | final public function getRenderer(): FormRenderer 55 | { 56 | return $this->renderer; 57 | } 58 | 59 | protected function getEnvironment(): Environment 60 | { 61 | $loader = new FilesystemLoader($this->getTemplatePaths()); 62 | 63 | $environment = new Environment($loader, [ 64 | 'strict_variables' => true, 65 | ]); 66 | $environment->addExtension(new TranslationExtension(new StubTranslator())); 67 | 68 | return $environment; 69 | } 70 | 71 | /** 72 | * Returns a list of template paths. 73 | * 74 | * @return string[] 75 | */ 76 | protected function getTemplatePaths(): array 77 | { 78 | // this is an workaround for different composer requirements and different TwigBridge installation directories 79 | $twigPaths = array_filter([ 80 | // symfony/twig-bridge (running from this bundle) 81 | __DIR__.'/../../vendor/symfony/twig-bridge/Resources/views/Form', 82 | // symfony/twig-bridge (running from other bundles) 83 | __DIR__.'/../../../../symfony/twig-bridge/Resources/views/Form', 84 | // symfony/symfony (running from this bundle) 85 | __DIR__.'/../../vendor/symfony/symfony/src/Symfony/Bridge/Twig/Resources/views/Form', 86 | // symfony/symfony (running from other bundles) 87 | __DIR__.'/../../../../symfony/symfony/src/Symfony/Bridge/Twig/Resources/views/Form', 88 | ], is_dir(...)); 89 | 90 | $twigPaths[] = __DIR__.'/../Bridge/Symfony/Resources/views/Form'; 91 | 92 | return $twigPaths; 93 | } 94 | 95 | protected function getRenderingEngine(Environment $environment): TwigRendererEngine 96 | { 97 | return new TwigRendererEngine(['form_div_layout.html.twig'], $environment); 98 | } 99 | 100 | /** 101 | * Renders widget from FormView, in SonataAdmin context, with optional view variables $vars. Returns plain HTML. 102 | * 103 | * @param array $vars 104 | */ 105 | final protected function renderWidget(FormView $view, array $vars = []): string 106 | { 107 | return $this->renderer->searchAndRenderBlock($view, 'widget', $vars); 108 | } 109 | 110 | /** 111 | * Helper method to strip newline and space characters from html string to make comparing easier. 112 | */ 113 | final protected function cleanHtmlWhitespace(string $html): string 114 | { 115 | return preg_replace_callback( 116 | '/\s*>([^<]+) '>'.trim($value[1]).'<', 118 | $html 119 | ) ?? ''; 120 | } 121 | 122 | final protected function cleanHtmlAttributeWhitespace(string $html): string 123 | { 124 | return preg_replace_callback( 125 | '~<([A-Z0-9]+) \K(.*?)>~i', 126 | static fn (array $m): string => preg_replace('~\s*~', '', $m[0]) ?? '', 127 | $html 128 | ) ?? ''; 129 | } 130 | } 131 | -------------------------------------------------------------------------------- /src/Validator/Constraints/InlineConstraint.php: -------------------------------------------------------------------------------- 1 | 9 | * 10 | * For the full copyright and license information, please view the LICENSE 11 | * file that was distributed with this source code. 12 | */ 13 | 14 | namespace Sonata\Form\Validator\Constraints; 15 | 16 | use Symfony\Component\Validator\Attribute\HasNamedArguments; 17 | use Symfony\Component\Validator\Constraint; 18 | use Symfony\Component\Validator\Exception\MissingOptionsException; 19 | 20 | /** 21 | * Constraint which allows inline-validation inside services. 22 | * 23 | * @Annotation 24 | * 25 | * @Target({"CLASS"}) 26 | */ 27 | final class InlineConstraint extends Constraint 28 | { 29 | /** 30 | * @param array|null $options 31 | */ 32 | #[HasNamedArguments] 33 | public function __construct( 34 | protected mixed $service = null, // NEXT_MAJOR: make private and non-nullable (and narrow the type?) 35 | protected mixed $method = null, // NEXT_MAJOR: make private and non-nullable (and narrow the type?) 36 | protected bool $serializingWarning = false, // NEXT_MAJOR: make private 37 | ?array $groups = null, 38 | ?array $options = null, // NEXT_MAJOR: remove 39 | ) { 40 | if (\is_array($service) || \is_array($options)) { 41 | trigger_deprecation( 42 | 'sonata-project/form-extensions', 43 | '2.6.0', 44 | 'Passing an array of options to configure the "%s" constraint is deprecated. Use named arguments instead.', 45 | self::class, 46 | ); 47 | 48 | $options ??= []; 49 | if (!\is_array($service)) { 50 | $this->service = $service; 51 | } else { 52 | $options = array_merge($options, $service); 53 | } 54 | parent::__construct($options, groups: $groups); 55 | } else { 56 | $this->service = $service; 57 | parent::__construct(groups: $groups); 58 | } 59 | 60 | if (null === $this->service || null === $this->method) { 61 | throw new MissingOptionsException( 62 | \sprintf('The required options/arguments "service" and "method" must be set for constraint "%s"', self::class), 63 | ['service', 'method'], 64 | ); 65 | } 66 | 67 | if ((!\is_string($this->service) || !\is_string($this->method)) && true !== $this->serializingWarning) { 68 | throw new \RuntimeException('You are using a closure with the `InlineConstraint`, this constraint'. 69 | ' cannot be serialized. You need to re-attach the `InlineConstraint` on each request.'. 70 | ' Once done, you can set the `serializingWarning` option to `true` to avoid this message.'); 71 | } 72 | } 73 | 74 | // TODO: remove when support for Symfony < 7.4 is dropped 75 | public function __sleep(): array 76 | { 77 | // @phpstan-ignore-next-line to initialize "groups" option if it is not set 78 | $this->groups; 79 | 80 | if (!\is_string($this->getService()) || !\is_string($this->getMethod())) { 81 | return []; 82 | } 83 | 84 | return array_keys(get_object_vars($this)); 85 | } 86 | 87 | public function __serialize(): array 88 | { 89 | if (!\is_string($this->getService()) || !\is_string($this->getMethod())) { 90 | return []; 91 | } 92 | 93 | return get_object_vars($this); 94 | } 95 | 96 | public function __wakeup(): void 97 | { 98 | if (\is_string($this->getService()) && \is_string($this->getMethod())) { 99 | return; 100 | } 101 | 102 | $this->method = static function (): void { 103 | }; 104 | 105 | $this->serializingWarning = true; 106 | } 107 | 108 | public function validatedBy(): string 109 | { 110 | return 'sonata.form.validator.inline'; 111 | } 112 | 113 | public function isClosure(): bool 114 | { 115 | return $this->getMethod() instanceof \Closure; 116 | } 117 | 118 | public function getClosure(): mixed 119 | { 120 | return $this->method ?? null; 121 | } 122 | 123 | public function getTargets(): string 124 | { 125 | return self::CLASS_CONSTRAINT; 126 | } 127 | 128 | public function getMethod(): mixed 129 | { 130 | return $this->method ?? null; 131 | } 132 | 133 | public function getService(): mixed 134 | { 135 | return $this->service ?? null; 136 | } 137 | 138 | public function getSerializingWarning(): bool 139 | { 140 | return $this->serializingWarning; 141 | } 142 | } 143 | -------------------------------------------------------------------------------- /src/EventListener/ResizeFormListener.php: -------------------------------------------------------------------------------- 1 | 9 | * 10 | * For the full copyright and license information, please view the LICENSE 11 | * file that was distributed with this source code. 12 | */ 13 | 14 | namespace Sonata\Form\EventListener; 15 | 16 | use Symfony\Component\EventDispatcher\EventSubscriberInterface; 17 | use Symfony\Component\Form\Exception\UnexpectedTypeException; 18 | use Symfony\Component\Form\FormEvent; 19 | use Symfony\Component\Form\FormEvents; 20 | 21 | /** 22 | * Resize a collection form element based on the data sent from the client. 23 | * 24 | * @author Bernhard Schussek 25 | */ 26 | final class ResizeFormListener implements EventSubscriberInterface 27 | { 28 | /** 29 | * @param array $typeOptions 30 | */ 31 | public function __construct( 32 | private string $type, 33 | private array $typeOptions = [], 34 | private bool $resizeOnSubmit = false, 35 | private ?\Closure $preSubmitDataCallback = null, 36 | ) { 37 | } 38 | 39 | public static function getSubscribedEvents(): array 40 | { 41 | return [ 42 | FormEvents::PRE_SET_DATA => 'preSetData', 43 | FormEvents::PRE_SUBMIT => 'preSubmit', 44 | FormEvents::SUBMIT => 'onSubmit', 45 | ]; 46 | } 47 | 48 | /** 49 | * @throws UnexpectedTypeException 50 | */ 51 | public function preSetData(FormEvent $event): void 52 | { 53 | $form = $event->getForm(); 54 | $data = $event->getData(); 55 | 56 | if (null === $data) { 57 | $data = []; 58 | } 59 | 60 | if (!\is_array($data) && !$data instanceof \Traversable) { 61 | throw new UnexpectedTypeException($data, 'array or \Traversable'); 62 | } 63 | 64 | // First remove all rows except for the prototype row 65 | // Type cast to string, because Symfony form can return integer keys 66 | foreach ($form as $name => $child) { 67 | // @phpstan-ignore-next-line -- Remove this and the casting when dropping support of < Symfony 6.2 68 | $form->remove((string) $name); 69 | } 70 | 71 | // Then add all rows again in the correct order 72 | foreach ($data as $name => $value) { 73 | $options = array_merge($this->typeOptions, [ 74 | 'property_path' => '['.$name.']', 75 | 'data' => $value, 76 | ]); 77 | 78 | $name = \is_int($name) ? (string) $name : $name; 79 | 80 | $form->add($name, $this->type, $options); 81 | } 82 | } 83 | 84 | /** 85 | * @throws UnexpectedTypeException 86 | */ 87 | public function preSubmit(FormEvent $event): void 88 | { 89 | if (!$this->resizeOnSubmit) { 90 | return; 91 | } 92 | 93 | $form = $event->getForm(); 94 | $data = $event->getData(); 95 | 96 | if (null === $data || '' === $data) { 97 | $data = []; 98 | } 99 | 100 | if ( 101 | !\is_array($data) 102 | && (!$data instanceof \Traversable || !$data instanceof \ArrayAccess) 103 | ) { 104 | throw new UnexpectedTypeException($data, 'array or \Traversable&\ArrayAccess'); 105 | } 106 | 107 | // Remove all empty rows except for the prototype row 108 | // Type cast to string, because Symfony form can return integer keys 109 | foreach ($form as $name => $child) { 110 | // @phpstan-ignore-next-line -- Remove this and the casting when dropping support of < Symfony 6.2 111 | $form->remove((string) $name); 112 | } 113 | 114 | // Add all additional rows 115 | foreach ($data as $name => $value) { 116 | // remove selected elements before adding them again 117 | if (isset($value['_delete'])) { 118 | unset($data[$name]); 119 | 120 | continue; 121 | } 122 | 123 | // Type cast to string, because Symfony form can returns integer keys 124 | if (!$form->has((string) $name)) { 125 | $buildOptions = [ 126 | 'property_path' => '['.$name.']', 127 | ]; 128 | 129 | if (null !== $this->preSubmitDataCallback) { 130 | $buildOptions['data'] = \call_user_func($this->preSubmitDataCallback, $value); 131 | } 132 | 133 | $options = array_merge($this->typeOptions, $buildOptions); 134 | 135 | $name = \is_int($name) ? (string) $name : $name; 136 | 137 | $form->add($name, $this->type, $options); 138 | } 139 | } 140 | 141 | $event->setData($data); 142 | } 143 | 144 | /** 145 | * @throws UnexpectedTypeException 146 | */ 147 | public function onSubmit(FormEvent $event): void 148 | { 149 | if (!$this->resizeOnSubmit) { 150 | return; 151 | } 152 | 153 | $form = $event->getForm(); 154 | $data = $event->getData(); 155 | 156 | if (null === $data) { 157 | $data = []; 158 | } 159 | 160 | if ( 161 | !\is_array($data) 162 | && (!$data instanceof \Traversable || !$data instanceof \ArrayAccess) 163 | ) { 164 | throw new UnexpectedTypeException($data, 'array or \Traversable&\ArrayAccess'); 165 | } 166 | 167 | foreach ($data as $name => $child) { 168 | // Type cast to string, because Symfony form can returns integer keys 169 | if (!$form->has((string) $name)) { 170 | unset($data[$name]); 171 | } 172 | } 173 | 174 | $event->setData($data); 175 | } 176 | } 177 | -------------------------------------------------------------------------------- /src/Validator/ErrorElement.php: -------------------------------------------------------------------------------- 1 | 9 | * 10 | * For the full copyright and license information, please view the LICENSE 11 | * file that was distributed with this source code. 12 | */ 13 | 14 | namespace Sonata\Form\Validator; 15 | 16 | use Symfony\Component\PropertyAccess\PropertyAccess; 17 | use Symfony\Component\PropertyAccess\PropertyPath; 18 | use Symfony\Component\PropertyAccess\PropertyPathInterface; 19 | use Symfony\Component\Validator\Constraint; 20 | use Symfony\Component\Validator\Context\ExecutionContextInterface; 21 | 22 | /** 23 | * @method self assertBic(mixed[] $options = []) 24 | * @method self assertBlank(mixed[] $options = []) 25 | * @method self assertCallback(mixed[] $options = []) 26 | * @method self assertCardScheme(mixed[] $options = []) 27 | * @method self assertChoice(mixed[] $options = []) 28 | * @method self assertCollection(mixed[] $options = []) 29 | * @method self assertCount(mixed[] $options = []) 30 | * @method self assertCountry(mixed[] $options = []) 31 | * @method self assertCurrency(mixed[] $options = []) 32 | * @method self assertDate(mixed[] $options = []) 33 | * @method self assertDateTime(mixed[] $options = []) 34 | * @method self assertDisableAutoMapping(mixed[] $options = []) 35 | * @method self assertDivisibleBy(mixed[] $options = []) 36 | * @method self assertEmail(mixed[] $options = []) 37 | * @method self assertEnableAutoMapping(mixed[] $options = []) 38 | * @method self assertEqualTo(mixed[] $options = []) 39 | * @method self assertExpression(mixed[] $options = []) 40 | * @method self assertFile(mixed[] $options = []) 41 | * @method self assertGreaterThan(mixed[] $options = []) 42 | * @method self assertGreaterThanOrEqual(mixed[] $options = []) 43 | * @method self assertIban(mixed[] $options = []) 44 | * @method self assertIdenticalTo(mixed[] $options = []) 45 | * @method self assertImage(mixed[] $options = []) 46 | * @method self assertIp(mixed[] $options = []) 47 | * @method self assertIsbn(mixed[] $options = []) 48 | * @method self assertIsFalse(mixed[] $options = []) 49 | * @method self assertIsNull(mixed[] $options = []) 50 | * @method self assertIssn(mixed[] $options = []) 51 | * @method self assertIsTrue(mixed[] $options = []) 52 | * @method self assertJson(mixed[] $options = []) 53 | * @method self assertLanguage(mixed[] $options = []) 54 | * @method self assertLength(mixed[] $options = []) 55 | * @method self assertLessThan(mixed[] $options = []) 56 | * @method self assertLessThanOrEqual(mixed[] $options = []) 57 | * @method self assertLocale(mixed[] $options = []) 58 | * @method self assertLuhn(mixed[] $options = []) 59 | * @method self assertNegative(mixed[] $options = []) 60 | * @method self assertNegativeOrZero(mixed[] $options = []) 61 | * @method self assertNotBlank(mixed[] $options = []) 62 | * @method self assertNotCompromisedPassword(mixed[] $options = []) 63 | * @method self assertNotEqualTo(mixed[] $options = []) 64 | * @method self assertNotIdentificalTo(mixed[] $options = []) 65 | * @method self assertNotNull(mixed[] $options = []) 66 | * @method self assertPositive(mixed[] $options = []) 67 | * @method self assertPositiveOrZero(mixed[] $options = []) 68 | * @method self assertRange(mixed[] $options = []) 69 | * @method self assertRegex(mixed[] $options = []) 70 | * @method self assertTime(mixed[] $options = []) 71 | * @method self assertTimezone(mixed[] $options = []) 72 | * @method self assertTraverse(mixed[] $options = []) 73 | * @method self assertType(mixed[] $options = []) 74 | * @method self assertUnique(mixed[] $options = []) 75 | * @method self assertUrl(mixed[] $options = []) 76 | * @method self assertUuid(mixed[] $options = []) 77 | * @method self assertValid(mixed[] $options = []) 78 | */ 79 | final class ErrorElement 80 | { 81 | private const DEFAULT_TRANSLATION_DOMAIN = 'validators'; 82 | 83 | /** 84 | * @var string[] 85 | */ 86 | private array $stack = []; 87 | 88 | /** 89 | * @var PropertyPathInterface[] 90 | */ 91 | private array $propertyPaths = []; 92 | 93 | private string $current = ''; 94 | 95 | private string $basePropertyPath; 96 | 97 | /** 98 | * @var array, mixed}> 99 | */ 100 | private array $errors = []; 101 | 102 | public function __construct( 103 | private mixed $subject, 104 | private ExecutionContextInterface $context, 105 | private ?string $group, 106 | ) { 107 | $this->basePropertyPath = $this->context->getPropertyPath(); 108 | } 109 | 110 | /** 111 | * @param mixed[] $arguments 112 | * 113 | * @throws \RuntimeException 114 | */ 115 | public function __call(string $name, array $arguments = []): self 116 | { 117 | if (str_starts_with($name, 'assert')) { 118 | $this->validate($this->newConstraint(substr($name, 6), $arguments[0] ?? [])); 119 | } else { 120 | throw new \RuntimeException('Unable to recognize the command'); 121 | } 122 | 123 | return $this; 124 | } 125 | 126 | public function addConstraint(Constraint $constraint): self 127 | { 128 | $this->validate($constraint); 129 | 130 | return $this; 131 | } 132 | 133 | public function with(string $name, bool $key = false): self 134 | { 135 | /* 136 | * Existing code was 137 | * $key = $key ? $name.'.'.$key : $name; 138 | * 139 | * There is certainly a bug here or we should deprecate the key param. 140 | */ 141 | $this->stack[] = $key ? $name.'.1' : $name; 142 | 143 | $this->current = implode('.', $this->stack); 144 | 145 | if (!isset($this->propertyPaths[$this->current])) { 146 | $this->propertyPaths[$this->current] = new PropertyPath($this->current); 147 | } 148 | 149 | return $this; 150 | } 151 | 152 | public function end(): self 153 | { 154 | array_pop($this->stack); 155 | 156 | $this->current = implode('.', $this->stack); 157 | 158 | return $this; 159 | } 160 | 161 | public function getFullPropertyPath(): string 162 | { 163 | $propertyPath = $this->getCurrentPropertyPath(); 164 | if (null !== $propertyPath) { 165 | return \sprintf('%s.%s', $this->basePropertyPath, (string) $propertyPath); 166 | } 167 | 168 | return $this->basePropertyPath; 169 | } 170 | 171 | public function getSubject(): mixed 172 | { 173 | return $this->subject; 174 | } 175 | 176 | /** 177 | * @param array $parameters 178 | * 179 | * @return $this 180 | */ 181 | public function addViolation(string $message, array $parameters = [], mixed $value = null, string $translationDomain = self::DEFAULT_TRANSLATION_DOMAIN): self 182 | { 183 | $subPath = (string) $this->getCurrentPropertyPath(); 184 | 185 | $this->context->buildViolation($message) 186 | ->atPath($subPath) 187 | ->setParameters($parameters) 188 | ->setTranslationDomain($translationDomain) 189 | ->setInvalidValue($value) 190 | ->addViolation(); 191 | 192 | $this->errors[] = [$message, $parameters, $value]; 193 | 194 | return $this; 195 | } 196 | 197 | /** 198 | * @return array, mixed}> 199 | */ 200 | public function getErrors(): array 201 | { 202 | return $this->errors; 203 | } 204 | 205 | private function validate(Constraint $constraint): void 206 | { 207 | $this->context->getValidator() 208 | ->inContext($this->context) 209 | ->atPath((string) $this->getCurrentPropertyPath()) 210 | ->validate($this->getValue(), $constraint, $this->group); 211 | } 212 | 213 | /** 214 | * Return the value linked to. 215 | */ 216 | private function getValue(): mixed 217 | { 218 | if ('' === $this->current) { 219 | return $this->subject; 220 | } 221 | 222 | $propertyPath = $this->getCurrentPropertyPath(); 223 | \assert(null !== $propertyPath); 224 | 225 | $propertyAccessor = PropertyAccess::createPropertyAccessor(); 226 | 227 | return $propertyAccessor->getValue($this->subject, $propertyPath); 228 | } 229 | 230 | /** 231 | * @param array $options 232 | * 233 | * @throws \RuntimeException 234 | */ 235 | private function newConstraint(string $name, array $options = []): Constraint 236 | { 237 | if (str_contains($name, '\\') && class_exists($name)) { 238 | $className = $name; 239 | } else { 240 | $className = 'Symfony\\Component\\Validator\\Constraints\\'.$name; 241 | if (!class_exists($className)) { 242 | throw new \RuntimeException(\sprintf( 243 | 'Cannot find the class "%s".', 244 | $className 245 | )); 246 | } 247 | } 248 | 249 | if (!is_a($className, Constraint::class, true)) { 250 | throw new \RuntimeException(\sprintf( 251 | 'The class "%s" MUST implement "%s".', 252 | $className, 253 | Constraint::class 254 | )); 255 | } 256 | 257 | return new $className(...$options); 258 | } 259 | 260 | private function getCurrentPropertyPath(): ?PropertyPathInterface 261 | { 262 | if (!isset($this->propertyPaths[$this->current])) { 263 | return null; // global error 264 | } 265 | 266 | return $this->propertyPaths[$this->current]; 267 | } 268 | } 269 | -------------------------------------------------------------------------------- /assets/js/controllers/datepicker_controller.test.js: -------------------------------------------------------------------------------- 1 | /*! 2 | * This file is part of the Sonata Project package. 3 | * 4 | * (c) Thomas Rabaix 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | import { Application } from '@hotwired/stimulus'; 11 | import { getByTestId, waitFor } from '@testing-library/dom'; 12 | import userEvent from '@testing-library/user-event'; 13 | import { afterEach, beforeAll, describe, expect, it } from 'vitest'; 14 | import DatepickerController from './datepicker_controller'; 15 | 16 | const startDatepickerTest = async (html) => { 17 | let datePicker = null; 18 | let datePicker2 = null; 19 | 20 | document.body.addEventListener('datepicker:pre-connect', () => { 21 | document.body.classList.add('pre-connected'); 22 | }); 23 | 24 | document.body.addEventListener('datepicker:connect', (event) => { 25 | if (!datePicker) datePicker = event.detail.datePicker; 26 | else datePicker2 = event.detail.datePicker; 27 | 28 | document.body.classList.add('connected'); 29 | }); 30 | 31 | document.body.addEventListener('datepicker:post-connect-changed-locale', () => { 32 | document.body.classList.add('post-connect-changed-locale'); 33 | }); 34 | 35 | document.body.innerHTML = html; 36 | 37 | await waitFor(() => { 38 | expect(document.body).toHaveClass('pre-connected'); 39 | expect(document.body).toHaveClass('connected'); 40 | }); 41 | 42 | if (!datePicker) { 43 | throw new Error('Missing DatePicker instance'); 44 | } 45 | 46 | return { datePicker, datePicker2 }; 47 | }; 48 | 49 | const innerDatepickerTest = ` 50 | 55 | 60 | `; 61 | 62 | const tempusDominusCalendar = () => document.querySelector('.tempus-dominus-widget.show'); 63 | 64 | describe('DatepickerController', () => { 65 | beforeAll(() => { 66 | const application = Application.start(); 67 | 68 | application.register('datepicker', DatepickerController); 69 | }); 70 | 71 | afterEach(() => { 72 | document.body.innerHTML = ''; 73 | }); 74 | 75 | it('connects without options', async () => { 76 | const { datePicker } = await startDatepickerTest(` 77 |
${innerDatepickerTest}
83 | `); 84 | 85 | expect(datePicker).toBeDefined(); 86 | }); 87 | 88 | it('can be opened', async () => { 89 | await startDatepickerTest(` 90 |
${innerDatepickerTest}
96 | `); 97 | 98 | const icon = getByTestId(document, 'icon'); 99 | 100 | expect(tempusDominusCalendar()).toBeNull(); 101 | 102 | await userEvent.click(icon); 103 | 104 | expect(tempusDominusCalendar()).toHaveClass('show'); 105 | }); 106 | 107 | it('can receive options', async () => { 108 | const { datePicker } = await startDatepickerTest(` 109 |
${innerDatepickerTest}
118 | `); 119 | 120 | const icon = getByTestId(document, 'icon'); 121 | 122 | await userEvent.click(icon); 123 | 124 | expect(tempusDominusCalendar().querySelector('.date-container')).toBeNull(); 125 | expect(datePicker.optionsStore.options.display.components.calendar).toBe(false); 126 | }); 127 | 128 | it('can be localized', async () => { 129 | const { datePicker } = await startDatepickerTest(` 130 |
${innerDatepickerTest}
139 | `); 140 | 141 | expect(datePicker.optionsStore.options.localization.locale).toBe('fr'); 142 | 143 | const icon = getByTestId(document, 'icon'); 144 | 145 | await userEvent.click(icon); 146 | 147 | await waitFor(() => { 148 | expect(document.body).toHaveClass('post-connect-changed-locale'); 149 | }); 150 | 151 | expect(tempusDominusCalendar().querySelector('.picker-switch')).toHaveProperty( 152 | 'title', 153 | 'Sélectionner le mois' 154 | ); 155 | }); 156 | 157 | it('can be localized for locales with dash', async () => { 158 | const { datePicker } = await startDatepickerTest(` 159 |
${innerDatepickerTest}
168 | `); 169 | 170 | expect(datePicker.optionsStore.options.localization.locale).toBe('fr-FR'); 171 | 172 | const icon = getByTestId(document, 'icon'); 173 | 174 | await userEvent.click(icon); 175 | 176 | await waitFor(() => { 177 | expect(document.body).toHaveClass('post-connect-changed-locale'); 178 | }); 179 | 180 | expect(tempusDominusCalendar().querySelector('.picker-switch')).toHaveProperty( 181 | 'title', 182 | 'Sélectionner le mois' 183 | ); 184 | }); 185 | 186 | it('it does not localize for non supported locales', async () => { 187 | const { datePicker } = await startDatepickerTest(` 188 |
${innerDatepickerTest}
197 | `); 198 | 199 | expect(datePicker.optionsStore.options.localization.locale).toBe('zh'); 200 | 201 | const icon = getByTestId(document, 'icon'); 202 | 203 | await userEvent.click(icon); 204 | 205 | await waitFor(() => { 206 | expect(document.body).toHaveClass('post-connect-changed-locale'); 207 | }); 208 | 209 | expect(tempusDominusCalendar().querySelector('.picker-switch')).toHaveProperty( 210 | 'title', 211 | 'Select Month' 212 | ); 213 | }); 214 | 215 | it('it does not localize for non supported locales with dash', async () => { 216 | const { datePicker } = await startDatepickerTest(` 217 |
${innerDatepickerTest}
226 | `); 227 | 228 | expect(datePicker.optionsStore.options.localization.locale).toBe('zh-CN'); 229 | 230 | const icon = getByTestId(document, 'icon'); 231 | 232 | await userEvent.click(icon); 233 | 234 | await waitFor(() => { 235 | expect(document.body).toHaveClass('post-connect-changed-locale'); 236 | }); 237 | 238 | expect(tempusDominusCalendar().querySelector('.picker-switch')).toHaveProperty( 239 | 'title', 240 | 'Select Month' 241 | ); 242 | }); 243 | 244 | it('can select a date', async () => { 245 | await startDatepickerTest(` 246 |
${innerDatepickerTest}
255 | `); 256 | 257 | const icon = getByTestId(document, 'icon'); 258 | const input = getByTestId(document, 'input'); 259 | 260 | await userEvent.click(icon); 261 | 262 | const calendar = tempusDominusCalendar(); 263 | 264 | expect(calendar).toHaveClass('show'); 265 | expect(input.value).toBe(''); 266 | 267 | await userEvent.click(calendar.querySelector('.day')); 268 | 269 | expect(input.value).not.toBe(''); 270 | expect(calendar).toHaveClass('show'); 271 | 272 | await userEvent.click(document.body); 273 | 274 | expect(calendar).not.toHaveClass('show'); 275 | }); 276 | 277 | it('can be used without icon', async () => { 278 | await startDatepickerTest(` 279 | 285 | `); 286 | 287 | const input = getByTestId(document, 'input'); 288 | 289 | expect(tempusDominusCalendar()).toBeNull(); 290 | 291 | await userEvent.click(input); 292 | 293 | expect(tempusDominusCalendar()).toHaveClass('show'); 294 | }); 295 | 296 | it('can receive multiple options', async () => { 297 | await startDatepickerTest(` 298 |
${innerDatepickerTest}
368 | `); 369 | 370 | await waitFor(() => { 371 | expect(document.body).toHaveClass('post-connect-changed-locale'); 372 | }); 373 | }); 374 | 375 | it('can be used with related pickers', async () => { 376 | const { datePicker, datePicker2 } = await startDatepickerTest(` 377 |
${innerDatepickerTest}
384 | 390 | `); 391 | 392 | expect(datePicker.optionsStore.options.restrictions.maxDate).toBeUndefined(); 393 | expect(datePicker2.optionsStore.options.restrictions.minDate).toBeUndefined(); 394 | 395 | const firstIcon = getByTestId(document, 'icon'); 396 | const secondInput = getByTestId(document, 'input2'); 397 | 398 | await userEvent.click(firstIcon); 399 | 400 | const calendar = tempusDominusCalendar(); 401 | 402 | await userEvent.click(calendar.querySelector('.day')); 403 | await userEvent.click(secondInput); 404 | 405 | const secondCalendar = tempusDominusCalendar(); 406 | 407 | await userEvent.click(secondCalendar.querySelector('.day')); 408 | 409 | expect(datePicker.optionsStore.options.restrictions.maxDate).not.toBeUndefined(); 410 | expect(datePicker2.optionsStore.options.restrictions.minDate).not.toBeUndefined(); 411 | expect( 412 | datePicker.optionsStore.options.restrictions.maxDate >= 413 | datePicker2.optionsStore.options.restrictions.minDate 414 | ).toBe(true); 415 | }); 416 | }); 417 | -------------------------------------------------------------------------------- /src/Type/BasePickerType.php: -------------------------------------------------------------------------------- 1 | 9 | * 10 | * For the full copyright and license information, please view the LICENSE 11 | * file that was distributed with this source code. 12 | */ 13 | 14 | namespace Sonata\Form\Type; 15 | 16 | use Sonata\Form\Date\JavaScriptFormatConverter; 17 | use Symfony\Component\Form\AbstractType; 18 | use Symfony\Component\Form\Extension\Core\Type\DateTimeType; 19 | use Symfony\Component\Form\FormInterface; 20 | use Symfony\Component\Form\FormView; 21 | use Symfony\Component\OptionsResolver\Options; 22 | use Symfony\Component\OptionsResolver\OptionsResolver; 23 | use Symfony\Contracts\Translation\LocaleAwareInterface; 24 | 25 | /** 26 | * Class BasePickerType (to factorize DatePickerType and DateTimePickerType code. 27 | * 28 | * @author Hugo Briand 29 | */ 30 | abstract class BasePickerType extends AbstractType implements LocaleAwareInterface 31 | { 32 | /** 33 | * @var array|string> 34 | */ 35 | private const DATEPICKER_ALLOWED_OPTIONS = [ 36 | 'allowInputToggle' => 'bool', 37 | 'dateRange' => 'bool', 38 | 'debug' => 'bool', 39 | 'defaultDate' => ['string', \DateTimeInterface::class], 40 | 'keepInvalid' => 'bool', 41 | 'multipleDates' => 'bool', 42 | 'multipleDatesSeparator' => 'string', 43 | 'promptTimeOnDateChange' => 'bool', 44 | 'promptTimeOnDateChangeTransitionDelay' => 'integer', 45 | 'stepping' => 'integer', 46 | 'useCurrent' => 'bool', 47 | 'viewDate' => ['string', \DateTimeInterface::class], 48 | ]; 49 | 50 | /** 51 | * @var array|string> 52 | */ 53 | private const RESTRICTIONS_OPTIONS = [ 54 | 'minDate' => ['string', \DateTimeInterface::class], 55 | 'maxDate' => ['string', \DateTimeInterface::class], 56 | 'disabledDates' => ['string[]', 'DateTimeInterface[]'], 57 | 'enabledDates' => ['string[]', 'DateTimeInterface[]'], 58 | 'daysOfWeekDisabled' => 'integer[]', 59 | 'disabledHours' => 'integer[]', 60 | 'enabledHours' => 'integer[]', 61 | ]; 62 | 63 | /** 64 | * @var array|string> 65 | */ 66 | private const LOCALIZATION_OPTIONS = [ 67 | 'locale' => 'string', 68 | 'hourCycle' => 'string', 69 | ]; 70 | 71 | /** 72 | * @var array|string> 73 | */ 74 | private const DISPLAY_OPTIONS = [ 75 | 'sideBySide' => 'bool', 76 | 'calendarWeeks' => 'bool', 77 | 'viewMode' => 'string', 78 | 'toolbarPlacement' => 'string', 79 | 'keepOpen' => 'bool', 80 | 'inline' => 'bool', 81 | 'theme' => 'string', 82 | ]; 83 | 84 | /** 85 | * @var array|string> 86 | */ 87 | private const DISPLAY_ICONS_OPTIONS = [ 88 | 'time' => 'string', 89 | 'date' => 'string', 90 | 'up' => 'string', 91 | 'down' => 'string', 92 | 'previous' => 'string', 93 | 'next' => 'string', 94 | 'today' => 'string', 95 | 'clear' => 'string', 96 | 'close' => 'string', 97 | ]; 98 | 99 | /** 100 | * @var array|string> 101 | */ 102 | private const DISPLAY_BUTTONS_OPTIONS = [ 103 | 'today' => 'bool', 104 | 'clear' => 'bool', 105 | 'close' => 'bool', 106 | ]; 107 | 108 | /** 109 | * @var array|string> 110 | */ 111 | private const DISPLAY_COMPONENTS_OPTIONS = [ 112 | 'calendar' => 'bool', 113 | 'date' => 'bool', 114 | 'month' => 'bool', 115 | 'year' => 'bool', 116 | 'decades' => 'bool', 117 | 'clock' => 'bool', 118 | 'hours' => 'bool', 119 | 'minutes' => 'bool', 120 | 'seconds' => 'bool', 121 | ]; 122 | 123 | public function __construct( 124 | private JavaScriptFormatConverter $formatConverter, 125 | private string $locale, 126 | ) { 127 | } 128 | 129 | public function getLocale(): string 130 | { 131 | return $this->locale; 132 | } 133 | 134 | public function setLocale(string $locale): void 135 | { 136 | $this->locale = $locale; 137 | } 138 | 139 | public function configureOptions(OptionsResolver $resolver): void 140 | { 141 | $resolver->setDefaults($this->getCommonDefaults()); 142 | 143 | /** 144 | * TODO: use `setOptions` directly once we drop support for Symfony < 7.3. 145 | * 146 | * @phpstan-ignore function.alreadyNarrowedType 147 | */ 148 | $resolverSetOptionsMethod = method_exists($resolver, 'setOptions') ? 'setOptions' : 'setDefault'; 149 | /* @phpstan-ignore method.dynamicName */ 150 | $resolver->{$resolverSetOptionsMethod}('datepicker_options', function (OptionsResolver $datePickerResolver) use ($resolverSetOptionsMethod) { 151 | $datePickerResolver->setDefined(array_keys(self::DATEPICKER_ALLOWED_OPTIONS)); 152 | 153 | foreach (self::DATEPICKER_ALLOWED_OPTIONS as $option => $allowedTypes) { 154 | $datePickerResolver->setAllowedTypes($option, $allowedTypes); 155 | } 156 | 157 | $datePickerResolver->setNormalizer('defaultDate', $this->dateTimeNormalizer()); 158 | $datePickerResolver->setNormalizer('viewDate', $this->dateTimeNormalizer()); 159 | 160 | $defaults = $this->getCommonDatepickerDefaults(); 161 | 162 | $datePickerResolver->setDefaults($defaults); 163 | /* @phpstan-ignore method.dynamicName */ 164 | $datePickerResolver->{$resolverSetOptionsMethod}('localization', $this->defineLocalizationOptions($defaults['localization'] ?? [])); 165 | /* @phpstan-ignore method.dynamicName */ 166 | $datePickerResolver->{$resolverSetOptionsMethod}('restrictions', $this->defineRestrictionsOptions($defaults['restrictions'] ?? [])); 167 | /* @phpstan-ignore method.dynamicName */ 168 | $datePickerResolver->{$resolverSetOptionsMethod}('display', $this->defineDisplayOptions($defaults['display'] ?? [])); 169 | }); 170 | 171 | $resolver->setNormalizer( 172 | 'format', 173 | function (Options $options, int|string $format): string { 174 | if (\is_int($format)) { 175 | $timeFormat = \IntlDateFormatter::NONE; 176 | 177 | if (true === ($options['datepicker_options']['display']['components']['clock'] ?? true)) { 178 | $timeFormat = true === ($options['datepicker_options']['display']['components']['seconds'] ?? false) ? 179 | DateTimeType::DEFAULT_TIME_FORMAT : 180 | \IntlDateFormatter::SHORT; 181 | } 182 | 183 | return (new \IntlDateFormatter( 184 | $this->locale, 185 | $format, 186 | $timeFormat, 187 | null, 188 | \IntlDateFormatter::GREGORIAN 189 | ))->getPattern(); 190 | } 191 | 192 | return $format; 193 | } 194 | ); 195 | 196 | $resolver->setAllowedTypes('datepicker_use_button', 'bool'); 197 | } 198 | 199 | public function finishView(FormView $view, FormInterface $form, array $options): void 200 | { 201 | $datePickerOptions = $options['datepicker_options'] ?? []; 202 | 203 | if (isset($datePickerOptions['display']['icons']) 204 | && [] === $datePickerOptions['display']['icons']) { 205 | unset($datePickerOptions['display']['icons']); 206 | } 207 | 208 | if (isset($datePickerOptions['display']['buttons']) 209 | && [] === $datePickerOptions['display']['buttons']) { 210 | unset($datePickerOptions['display']['buttons']); 211 | } 212 | 213 | if (isset($datePickerOptions['display']['components']) 214 | && [] === $datePickerOptions['display']['components']) { 215 | unset($datePickerOptions['display']['components']); 216 | } 217 | 218 | if (isset($datePickerOptions['display']) 219 | && [] === $datePickerOptions['display']) { 220 | unset($datePickerOptions['display']); 221 | } 222 | 223 | if (isset($datePickerOptions['restrictions']) 224 | && [] === $datePickerOptions['restrictions']) { 225 | unset($datePickerOptions['restrictions']); 226 | } 227 | 228 | if (!isset($datePickerOptions['localization'])) { 229 | $datePickerOptions['localization'] = []; 230 | } 231 | 232 | $datePickerOptions['localization']['format'] = $this->formatConverter->convert($options['format'] ?? ''); 233 | 234 | $view->vars['datepicker_options'] = $datePickerOptions; 235 | $view->vars['datepicker_use_button'] = $options['datepicker_use_button'] ?? false; 236 | } 237 | 238 | /** 239 | * Gets base default options for the form types 240 | * (except `datepicker_options` which should be handled with `getCommonDatepickerDefaults()`). 241 | * 242 | * @return array 243 | */ 244 | protected function getCommonDefaults(): array 245 | { 246 | return [ 247 | 'widget' => 'single_text', 248 | 'datepicker_use_button' => true, 249 | 'html5' => false, 250 | ]; 251 | } 252 | 253 | /** 254 | * Gets base default options for the `datepicker_options` option. 255 | * 256 | * @return array 257 | */ 258 | protected function getCommonDatepickerDefaults(): array 259 | { 260 | return [ 261 | 'display' => [ 262 | 'theme' => 'light', 263 | ], 264 | 'localization' => [ 265 | 'locale' => str_replace('_', '-', $this->locale), 266 | ], 267 | ]; 268 | } 269 | 270 | /** 271 | * @param array $defaults 272 | * 273 | * @return \Closure(OptionsResolver): void 274 | */ 275 | private function defineLocalizationOptions(array $defaults): callable 276 | { 277 | return static function (OptionsResolver $resolver) use ($defaults): void { 278 | $resolver->setDefined(array_keys(self::LOCALIZATION_OPTIONS)); 279 | 280 | foreach (self::LOCALIZATION_OPTIONS as $option => $allowedTypes) { 281 | $resolver->setAllowedTypes($option, $allowedTypes); 282 | } 283 | 284 | $resolver->setDefaults($defaults); 285 | }; 286 | } 287 | 288 | /** 289 | * @param array $defaults 290 | * 291 | * @return \Closure(OptionsResolver): void 292 | */ 293 | private function defineRestrictionsOptions(array $defaults): callable 294 | { 295 | return function (OptionsResolver $resolver) use ($defaults): void { 296 | $resolver->setDefined(array_keys(self::RESTRICTIONS_OPTIONS)); 297 | 298 | foreach (self::RESTRICTIONS_OPTIONS as $option => $allowedTypes) { 299 | $resolver->setAllowedTypes($option, $allowedTypes); 300 | } 301 | 302 | $resolver->setAllowedValues( 303 | 'daysOfWeekDisabled', 304 | static fn (array $value) => array_filter( 305 | $value, 306 | static fn ($day) => \is_int($day) && $day >= 0 && $day <= 6 307 | ) === $value 308 | ); 309 | 310 | $resolver->setAllowedValues( 311 | 'enabledHours', 312 | static fn (array $value) => array_filter( 313 | $value, 314 | static fn ($hour) => \is_int($hour) && $hour >= 0 && $hour <= 23 315 | ) === $value 316 | ); 317 | 318 | $resolver->setAllowedValues( 319 | 'disabledHours', 320 | static fn (array $value) => array_filter( 321 | $value, 322 | static fn ($hour) => \is_int($hour) && $hour >= 0 && $hour <= 23 323 | ) === $value 324 | ); 325 | 326 | $resolver->setNormalizer('minDate', $this->dateTimeNormalizer()); 327 | $resolver->setNormalizer('maxDate', $this->dateTimeNormalizer()); 328 | $resolver->setNormalizer('disabledDates', $this->dateTimeNormalizer()); 329 | $resolver->setNormalizer('enabledDates', $this->dateTimeNormalizer()); 330 | 331 | $resolver->setDefaults($defaults); 332 | }; 333 | } 334 | 335 | /** 336 | * @param array $defaults 337 | * 338 | * @return \Closure(OptionsResolver): void 339 | */ 340 | private function defineDisplayOptions(array $defaults): callable 341 | { 342 | return function (OptionsResolver $resolver) use ($defaults): void { 343 | $resolver->setDefined(array_keys(self::DISPLAY_OPTIONS)); 344 | 345 | foreach (self::DISPLAY_OPTIONS as $option => $allowedTypes) { 346 | $resolver->setAllowedTypes($option, $allowedTypes); 347 | } 348 | 349 | $resolver->setAllowedValues('viewMode', ['clock', 'calendar', 'months', 'years', 'decades']); 350 | $resolver->setAllowedValues('toolbarPlacement', ['top', 'bottom']); 351 | $resolver->setAllowedValues('theme', ['light', 'dark', 'auto']); 352 | 353 | $resolver->setDefaults($defaults); 354 | 355 | /** 356 | * TODO: use `setOptions` directly once we drop support for Symfony < 7.3. 357 | * 358 | * @phpstan-ignore function.alreadyNarrowedType 359 | */ 360 | $resolverSetOptionsMethod = method_exists($resolver, 'setOptions') ? 'setOptions' : 'setDefault'; 361 | 362 | /* @phpstan-ignore method.dynamicName */ 363 | $resolver->{$resolverSetOptionsMethod}('icons', $this->defineDisplayIconsOptions($defaults['icons'] ?? [])); 364 | /* @phpstan-ignore method.dynamicName */ 365 | $resolver->{$resolverSetOptionsMethod}('buttons', $this->defineDisplayButtonsOptions($defaults['buttons'] ?? [])); 366 | /* @phpstan-ignore method.dynamicName */ 367 | $resolver->{$resolverSetOptionsMethod}('components', $this->defineDisplayComponentsOptions($defaults['components'] ?? [])); 368 | }; 369 | } 370 | 371 | /** 372 | * @param array $defaults 373 | * 374 | * @return \Closure(OptionsResolver): void 375 | */ 376 | private function defineDisplayIconsOptions(array $defaults): callable 377 | { 378 | return static function (OptionsResolver $resolver) use ($defaults): void { 379 | $resolver->setDefined(array_keys(self::DISPLAY_ICONS_OPTIONS)); 380 | 381 | foreach (self::DISPLAY_ICONS_OPTIONS as $option => $allowedTypes) { 382 | $resolver->setAllowedTypes($option, $allowedTypes); 383 | } 384 | 385 | $resolver->setDefaults($defaults); 386 | }; 387 | } 388 | 389 | /** 390 | * @param array $defaults 391 | * 392 | * @return \Closure(OptionsResolver): void 393 | */ 394 | private function defineDisplayButtonsOptions(array $defaults): callable 395 | { 396 | return static function (OptionsResolver $resolver) use ($defaults): void { 397 | $resolver->setDefined(array_keys(self::DISPLAY_BUTTONS_OPTIONS)); 398 | 399 | foreach (self::DISPLAY_BUTTONS_OPTIONS as $option => $allowedTypes) { 400 | $resolver->setAllowedTypes($option, $allowedTypes); 401 | } 402 | 403 | $resolver->setDefaults($defaults); 404 | }; 405 | } 406 | 407 | /** 408 | * @param array $defaults 409 | * 410 | * @return \Closure(OptionsResolver): void 411 | */ 412 | private function defineDisplayComponentsOptions(array $defaults): callable 413 | { 414 | return static function (OptionsResolver $resolver) use ($defaults): void { 415 | $resolver->setDefined(array_keys(self::DISPLAY_COMPONENTS_OPTIONS)); 416 | 417 | foreach (self::DISPLAY_COMPONENTS_OPTIONS as $option => $allowedTypes) { 418 | $resolver->setAllowedTypes($option, $allowedTypes); 419 | } 420 | 421 | $resolver->setDefaults($defaults); 422 | }; 423 | } 424 | 425 | private function dateTimeNormalizer(): \Closure 426 | { 427 | return static function (OptionsResolver $options, string|array|\DateTimeInterface $value): string|array { 428 | if ($value instanceof \DateTimeInterface) { 429 | return $value->format(\DateTimeInterface::ATOM); 430 | } 431 | 432 | if (\is_array($value)) { 433 | foreach ($value as $key => $singleValue) { 434 | if ($singleValue instanceof \DateTimeInterface) { 435 | $value[$key] = $singleValue->format(\DateTimeInterface::ATOM); 436 | } 437 | } 438 | } 439 | 440 | return $value; 441 | }; 442 | } 443 | } 444 | -------------------------------------------------------------------------------- /src/Bridge/Symfony/Resources/public/app.css: -------------------------------------------------------------------------------- 1 | .tempus-dominus-widget [data-action]:after,.visually-hidden{height:1px!important;margin:-1px!important;overflow:hidden!important;padding:0!important;position:absolute!important;width:1px!important;clip:rect(0,0,0,0)!important;border:0!important;white-space:nowrap!important}.tempus-dominus-widget{border-radius:4px;box-shadow:0 2px 4px -1px rgba(0,0,0,.2),0 4px 5px 0 rgba(0,0,0,.14),0 1px 10px 0 rgba(0,0,0,.12);display:none;list-style:none;padding:4px;width:19rem;z-index:9999}.tempus-dominus-widget.calendarWeeks{width:21rem}.tempus-dominus-widget.calendarWeeks .date-container-days{grid-auto-columns:12.5%;grid-template-areas:"a a a a a a a a"}.tempus-dominus-widget [data-action]{cursor:pointer}.tempus-dominus-widget [data-action]:after{content:attr(title)}.tempus-dominus-widget [data-action].disabled,.tempus-dominus-widget [data-action].disabled:hover{background:none;cursor:not-allowed}.tempus-dominus-widget .arrow{display:none}.tempus-dominus-widget.show{display:block}.tempus-dominus-widget.show.date-container{min-height:315px}.tempus-dominus-widget.show.time-container{min-height:217px}.tempus-dominus-widget .td-collapse:not(.show){display:none}.tempus-dominus-widget .td-collapsing{height:0;overflow:hidden;transition:height .35s ease}@media(min-width:576px){.tempus-dominus-widget.timepicker-sbs{width:38em}}@media(min-width:768px){.tempus-dominus-widget.timepicker-sbs{width:38em}}@media(min-width:992px){.tempus-dominus-widget.timepicker-sbs{width:38em}}.tempus-dominus-widget.timepicker-sbs .td-row{display:flex}.tempus-dominus-widget.timepicker-sbs .td-row .td-half{flex:0 0 auto;width:50%}.tempus-dominus-widget div[data-action]:active{box-shadow:none}.tempus-dominus-widget .timepicker-hour,.tempus-dominus-widget .timepicker-minute,.tempus-dominus-widget .timepicker-second{font-size:1.2em;font-weight:700;margin:0;width:54px}.tempus-dominus-widget button[data-action]{padding:6px}.tempus-dominus-widget .toggleMeridiem{height:38px;text-align:center}.tempus-dominus-widget .calendar-header{display:grid;font-weight:700;grid-template-areas:"a a a";margin-bottom:10px}.tempus-dominus-widget .calendar-header .next{padding-right:10px;text-align:right}.tempus-dominus-widget .calendar-header .previous{padding-left:10px;text-align:left}.tempus-dominus-widget .calendar-header .picker-switch{text-align:center}.tempus-dominus-widget .toolbar{display:grid;grid-auto-flow:column;grid-auto-rows:40px}.tempus-dominus-widget .toolbar div{align-items:center;border-radius:999px;box-sizing:border-box;display:flex;justify-content:center}.tempus-dominus-widget .date-container-days{display:grid;grid-auto-columns:14.2857142857%;grid-auto-rows:40px;grid-template-areas:"a a a a a a a"}.tempus-dominus-widget .date-container-days .range-in{background-color:#01419e!important;border:none;border-radius:0!important;box-shadow:-5px 0 0 #01419e,5px 0 0 #01419e}.tempus-dominus-widget .date-container-days .range-end{border-radius:0 50px 50px 0!important}.tempus-dominus-widget .date-container-days .range-start{border-radius:50px 0 0 50px!important}.tempus-dominus-widget .date-container-days .dow{align-items:center;justify-content:center;text-align:center}.tempus-dominus-widget .date-container-days .cw{align-items:center;cursor:default;display:flex;font-size:.8em;height:90%;justify-content:center;line-height:20px;width:90%}.tempus-dominus-widget .date-container-decades,.tempus-dominus-widget .date-container-months,.tempus-dominus-widget .date-container-years{display:grid;grid-auto-rows:calc(2.71429rem - 1.14286px);grid-template-areas:"a a a"}.tempus-dominus-widget .time-container-hour,.tempus-dominus-widget .time-container-minute,.tempus-dominus-widget .time-container-second{display:grid;grid-auto-rows:calc(2.71429rem - 1.14286px);grid-template-areas:"a a a a"}.tempus-dominus-widget .time-container-clock{display:grid;grid-auto-rows:calc(2.71429rem - 1.14286px)}.tempus-dominus-widget .time-container-clock .no-highlight{align-items:center;display:flex;height:90%;justify-content:center;width:90%}.tempus-dominus-widget .date-container-days div:not(.no-highlight),.tempus-dominus-widget .date-container-decades div:not(.no-highlight),.tempus-dominus-widget .date-container-months div:not(.no-highlight),.tempus-dominus-widget .date-container-years div:not(.no-highlight),.tempus-dominus-widget .time-container-clock div:not(.no-highlight),.tempus-dominus-widget .time-container-hour div:not(.no-highlight),.tempus-dominus-widget .time-container-minute div:not(.no-highlight),.tempus-dominus-widget .time-container-second div:not(.no-highlight){align-items:center;border-radius:999px;box-sizing:border-box;display:flex;height:90%;justify-content:center;width:90%}.tempus-dominus-widget .date-container-days div:not(.no-highlight).disabled,.tempus-dominus-widget .date-container-days div:not(.no-highlight).disabled:hover,.tempus-dominus-widget .date-container-decades div:not(.no-highlight).disabled,.tempus-dominus-widget .date-container-decades div:not(.no-highlight).disabled:hover,.tempus-dominus-widget .date-container-months div:not(.no-highlight).disabled,.tempus-dominus-widget .date-container-months div:not(.no-highlight).disabled:hover,.tempus-dominus-widget .date-container-years div:not(.no-highlight).disabled,.tempus-dominus-widget .date-container-years div:not(.no-highlight).disabled:hover,.tempus-dominus-widget .time-container-clock div:not(.no-highlight).disabled,.tempus-dominus-widget .time-container-clock div:not(.no-highlight).disabled:hover,.tempus-dominus-widget .time-container-hour div:not(.no-highlight).disabled,.tempus-dominus-widget .time-container-hour div:not(.no-highlight).disabled:hover,.tempus-dominus-widget .time-container-minute div:not(.no-highlight).disabled,.tempus-dominus-widget .time-container-minute div:not(.no-highlight).disabled:hover,.tempus-dominus-widget .time-container-second div:not(.no-highlight).disabled,.tempus-dominus-widget .time-container-second div:not(.no-highlight).disabled:hover{background:none;cursor:not-allowed}.tempus-dominus-widget .date-container-days div:not(.no-highlight).today,.tempus-dominus-widget .date-container-decades div:not(.no-highlight).today,.tempus-dominus-widget .date-container-months div:not(.no-highlight).today,.tempus-dominus-widget .date-container-years div:not(.no-highlight).today,.tempus-dominus-widget .time-container-clock div:not(.no-highlight).today,.tempus-dominus-widget .time-container-hour div:not(.no-highlight).today,.tempus-dominus-widget .time-container-minute div:not(.no-highlight).today,.tempus-dominus-widget .time-container-second div:not(.no-highlight).today{position:relative}.tempus-dominus-widget .date-container-days div:not(.no-highlight).today:before,.tempus-dominus-widget .date-container-decades div:not(.no-highlight).today:before,.tempus-dominus-widget .date-container-months div:not(.no-highlight).today:before,.tempus-dominus-widget .date-container-years div:not(.no-highlight).today:before,.tempus-dominus-widget .time-container-clock div:not(.no-highlight).today:before,.tempus-dominus-widget .time-container-hour div:not(.no-highlight).today:before,.tempus-dominus-widget .time-container-minute div:not(.no-highlight).today:before,.tempus-dominus-widget .time-container-second div:not(.no-highlight).today:before{border:solid transparent;border-width:0 0 7px 7px;bottom:6px;content:"";display:inline-block;position:absolute;right:6px}.tempus-dominus-widget .time-container{margin-bottom:.5rem}.tempus-dominus-widget button{border-radius:.25rem;cursor:pointer;display:inline-block;font-size:1rem;font-weight:400;line-height:1.5;padding:.375rem .75rem;text-align:center;text-decoration:none;transition:color .15s ease-in-out,background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out;-webkit-user-select:none;-moz-user-select:none;user-select:none;vertical-align:middle}.tempus-dominus-widget.tempus-dominus-widget-readonly table td [data-action=decrementHours],.tempus-dominus-widget.tempus-dominus-widget-readonly table td [data-action=decrementMinutes],.tempus-dominus-widget.tempus-dominus-widget-readonly table td [data-action=decrementSeconds],.tempus-dominus-widget.tempus-dominus-widget-readonly table td [data-action=incrementHours],.tempus-dominus-widget.tempus-dominus-widget-readonly table td [data-action=incrementMinutes],.tempus-dominus-widget.tempus-dominus-widget-readonly table td [data-action=incrementSeconds],.tempus-dominus-widget.tempus-dominus-widget-readonly table td [data-action=showHours],.tempus-dominus-widget.tempus-dominus-widget-readonly table td [data-action=showMinutes],.tempus-dominus-widget.tempus-dominus-widget-readonly table td [data-action=showSeconds],.tempus-dominus-widget.tempus-dominus-widget-readonly table td [data-action=togglePeriod],.tempus-dominus-widget.tempus-dominus-widget-readonly table td.day,.tempus-dominus-widget.tempus-dominus-widget-readonly table td.hour,.tempus-dominus-widget.tempus-dominus-widget-readonly table td.minute,.tempus-dominus-widget.tempus-dominus-widget-readonly table td.second{cursor:default;pointer-events:none}.tempus-dominus-widget.tempus-dominus-widget-readonly table td [data-action=decrementHours]:hover,.tempus-dominus-widget.tempus-dominus-widget-readonly table td [data-action=decrementMinutes]:hover,.tempus-dominus-widget.tempus-dominus-widget-readonly table td [data-action=decrementSeconds]:hover,.tempus-dominus-widget.tempus-dominus-widget-readonly table td [data-action=incrementHours]:hover,.tempus-dominus-widget.tempus-dominus-widget-readonly table td [data-action=incrementMinutes]:hover,.tempus-dominus-widget.tempus-dominus-widget-readonly table td [data-action=incrementSeconds]:hover,.tempus-dominus-widget.tempus-dominus-widget-readonly table td [data-action=showHours]:hover,.tempus-dominus-widget.tempus-dominus-widget-readonly table td [data-action=showMinutes]:hover,.tempus-dominus-widget.tempus-dominus-widget-readonly table td [data-action=showSeconds]:hover,.tempus-dominus-widget.tempus-dominus-widget-readonly table td [data-action=togglePeriod]:hover,.tempus-dominus-widget.tempus-dominus-widget-readonly table td.day:hover,.tempus-dominus-widget.tempus-dominus-widget-readonly table td.hour:hover,.tempus-dominus-widget.tempus-dominus-widget-readonly table td.minute:hover,.tempus-dominus-widget.tempus-dominus-widget-readonly table td.second:hover{background:none}.tempus-dominus-widget.light{background-color:#fff;color:#000}.tempus-dominus-widget.light [data-action].disabled,.tempus-dominus-widget.light [data-action].disabled:hover{color:#6c757d}.tempus-dominus-widget.light .toolbar div:hover{background:#e9ecef}.tempus-dominus-widget.light .date-container-days .dow{color:rgba(0,0,0,.5)}.tempus-dominus-widget.light .date-container-days .cw{color:rgba(0,0,0,.38)}.tempus-dominus-widget.light .date-container-days div:not(.no-highlight):hover,.tempus-dominus-widget.light .date-container-decades div:not(.no-highlight):hover,.tempus-dominus-widget.light .date-container-months div:not(.no-highlight):hover,.tempus-dominus-widget.light .date-container-years div:not(.no-highlight):hover,.tempus-dominus-widget.light .time-container-clock div:not(.no-highlight):hover,.tempus-dominus-widget.light .time-container-hour div:not(.no-highlight):hover,.tempus-dominus-widget.light .time-container-minute div:not(.no-highlight):hover,.tempus-dominus-widget.light .time-container-second div:not(.no-highlight):hover{background:#e9ecef}.tempus-dominus-widget.light .date-container-days div.range-end:not(.no-highlight),.tempus-dominus-widget.light .date-container-days div.range-in:not(.no-highlight),.tempus-dominus-widget.light .date-container-days div.range-start:not(.no-highlight),.tempus-dominus-widget.light .date-container-days div:not(.no-highlight).active,.tempus-dominus-widget.light .date-container-decades div:not(.no-highlight).active,.tempus-dominus-widget.light .date-container-months div:not(.no-highlight).active,.tempus-dominus-widget.light .date-container-years div:not(.no-highlight).active,.tempus-dominus-widget.light .time-container-clock div:not(.no-highlight).active,.tempus-dominus-widget.light .time-container-hour div:not(.no-highlight).active,.tempus-dominus-widget.light .time-container-minute div:not(.no-highlight).active,.tempus-dominus-widget.light .time-container-second div:not(.no-highlight).active{background-color:#0d6efd;color:#fff;text-shadow:0 -1px 0 rgba(0,0,0,.25)}.tempus-dominus-widget.light .date-container-days .date-container-decades div.range-end:not(.no-highlight).new,.tempus-dominus-widget.light .date-container-days .date-container-decades div.range-end:not(.no-highlight).old,.tempus-dominus-widget.light .date-container-days .date-container-decades div.range-in:not(.no-highlight).new,.tempus-dominus-widget.light .date-container-days .date-container-decades div.range-in:not(.no-highlight).old,.tempus-dominus-widget.light .date-container-days .date-container-decades div.range-start:not(.no-highlight).new,.tempus-dominus-widget.light .date-container-days .date-container-decades div.range-start:not(.no-highlight).old,.tempus-dominus-widget.light .date-container-days .date-container-months div.range-end:not(.no-highlight).new,.tempus-dominus-widget.light .date-container-days .date-container-months div.range-end:not(.no-highlight).old,.tempus-dominus-widget.light .date-container-days .date-container-months div.range-in:not(.no-highlight).new,.tempus-dominus-widget.light .date-container-days .date-container-months div.range-in:not(.no-highlight).old,.tempus-dominus-widget.light .date-container-days .date-container-months div.range-start:not(.no-highlight).new,.tempus-dominus-widget.light .date-container-days .date-container-months div.range-start:not(.no-highlight).old,.tempus-dominus-widget.light .date-container-days .date-container-years div.range-end:not(.no-highlight).new,.tempus-dominus-widget.light .date-container-days .date-container-years div.range-end:not(.no-highlight).old,.tempus-dominus-widget.light .date-container-days .date-container-years div.range-in:not(.no-highlight).new,.tempus-dominus-widget.light .date-container-days .date-container-years div.range-in:not(.no-highlight).old,.tempus-dominus-widget.light .date-container-days .date-container-years div.range-start:not(.no-highlight).new,.tempus-dominus-widget.light .date-container-days .date-container-years div.range-start:not(.no-highlight).old,.tempus-dominus-widget.light .date-container-days .time-container-clock div.range-end:not(.no-highlight).new,.tempus-dominus-widget.light .date-container-days .time-container-clock div.range-end:not(.no-highlight).old,.tempus-dominus-widget.light .date-container-days .time-container-clock div.range-in:not(.no-highlight).new,.tempus-dominus-widget.light .date-container-days .time-container-clock div.range-in:not(.no-highlight).old,.tempus-dominus-widget.light .date-container-days .time-container-clock div.range-start:not(.no-highlight).new,.tempus-dominus-widget.light .date-container-days .time-container-clock div.range-start:not(.no-highlight).old,.tempus-dominus-widget.light .date-container-days .time-container-hour div.range-end:not(.no-highlight).new,.tempus-dominus-widget.light .date-container-days .time-container-hour div.range-end:not(.no-highlight).old,.tempus-dominus-widget.light .date-container-days .time-container-hour div.range-in:not(.no-highlight).new,.tempus-dominus-widget.light .date-container-days .time-container-hour div.range-in:not(.no-highlight).old,.tempus-dominus-widget.light .date-container-days .time-container-hour div.range-start:not(.no-highlight).new,.tempus-dominus-widget.light .date-container-days .time-container-hour div.range-start:not(.no-highlight).old,.tempus-dominus-widget.light .date-container-days .time-container-minute div.range-end:not(.no-highlight).new,.tempus-dominus-widget.light .date-container-days .time-container-minute div.range-end:not(.no-highlight).old,.tempus-dominus-widget.light .date-container-days .time-container-minute div.range-in:not(.no-highlight).new,.tempus-dominus-widget.light .date-container-days .time-container-minute div.range-in:not(.no-highlight).old,.tempus-dominus-widget.light .date-container-days .time-container-minute div.range-start:not(.no-highlight).new,.tempus-dominus-widget.light .date-container-days .time-container-minute div.range-start:not(.no-highlight).old,.tempus-dominus-widget.light .date-container-days .time-container-second div.range-end:not(.no-highlight).new,.tempus-dominus-widget.light .date-container-days .time-container-second div.range-end:not(.no-highlight).old,.tempus-dominus-widget.light .date-container-days .time-container-second div.range-in:not(.no-highlight).new,.tempus-dominus-widget.light .date-container-days .time-container-second div.range-in:not(.no-highlight).old,.tempus-dominus-widget.light .date-container-days .time-container-second div.range-start:not(.no-highlight).new,.tempus-dominus-widget.light .date-container-days .time-container-second div.range-start:not(.no-highlight).old,.tempus-dominus-widget.light .date-container-days div.range-end:not(.no-highlight).new,.tempus-dominus-widget.light .date-container-days div.range-end:not(.no-highlight).old,.tempus-dominus-widget.light .date-container-days div.range-in:not(.no-highlight).new,.tempus-dominus-widget.light .date-container-days div.range-in:not(.no-highlight).old,.tempus-dominus-widget.light .date-container-days div.range-start:not(.no-highlight).new,.tempus-dominus-widget.light .date-container-days div.range-start:not(.no-highlight).old,.tempus-dominus-widget.light .date-container-days div:not(.no-highlight).active.new,.tempus-dominus-widget.light .date-container-days div:not(.no-highlight).active.old,.tempus-dominus-widget.light .date-container-decades .date-container-days div.range-end:not(.no-highlight).new,.tempus-dominus-widget.light .date-container-decades .date-container-days div.range-end:not(.no-highlight).old,.tempus-dominus-widget.light .date-container-decades .date-container-days div.range-in:not(.no-highlight).new,.tempus-dominus-widget.light .date-container-decades .date-container-days div.range-in:not(.no-highlight).old,.tempus-dominus-widget.light .date-container-decades .date-container-days div.range-start:not(.no-highlight).new,.tempus-dominus-widget.light .date-container-decades .date-container-days div.range-start:not(.no-highlight).old,.tempus-dominus-widget.light .date-container-decades div:not(.no-highlight).active.new,.tempus-dominus-widget.light .date-container-decades div:not(.no-highlight).active.old,.tempus-dominus-widget.light .date-container-months .date-container-days div.range-end:not(.no-highlight).new,.tempus-dominus-widget.light .date-container-months .date-container-days div.range-end:not(.no-highlight).old,.tempus-dominus-widget.light .date-container-months .date-container-days div.range-in:not(.no-highlight).new,.tempus-dominus-widget.light .date-container-months .date-container-days div.range-in:not(.no-highlight).old,.tempus-dominus-widget.light .date-container-months .date-container-days div.range-start:not(.no-highlight).new,.tempus-dominus-widget.light .date-container-months .date-container-days div.range-start:not(.no-highlight).old,.tempus-dominus-widget.light .date-container-months div:not(.no-highlight).active.new,.tempus-dominus-widget.light .date-container-months div:not(.no-highlight).active.old,.tempus-dominus-widget.light .date-container-years .date-container-days div.range-end:not(.no-highlight).new,.tempus-dominus-widget.light .date-container-years .date-container-days div.range-end:not(.no-highlight).old,.tempus-dominus-widget.light .date-container-years .date-container-days div.range-in:not(.no-highlight).new,.tempus-dominus-widget.light .date-container-years .date-container-days div.range-in:not(.no-highlight).old,.tempus-dominus-widget.light .date-container-years .date-container-days div.range-start:not(.no-highlight).new,.tempus-dominus-widget.light .date-container-years .date-container-days div.range-start:not(.no-highlight).old,.tempus-dominus-widget.light .date-container-years div:not(.no-highlight).active.new,.tempus-dominus-widget.light .date-container-years div:not(.no-highlight).active.old,.tempus-dominus-widget.light .time-container-clock .date-container-days div.range-end:not(.no-highlight).new,.tempus-dominus-widget.light .time-container-clock .date-container-days div.range-end:not(.no-highlight).old,.tempus-dominus-widget.light .time-container-clock .date-container-days div.range-in:not(.no-highlight).new,.tempus-dominus-widget.light .time-container-clock .date-container-days div.range-in:not(.no-highlight).old,.tempus-dominus-widget.light .time-container-clock .date-container-days div.range-start:not(.no-highlight).new,.tempus-dominus-widget.light .time-container-clock .date-container-days div.range-start:not(.no-highlight).old,.tempus-dominus-widget.light .time-container-clock div:not(.no-highlight).active.new,.tempus-dominus-widget.light .time-container-clock div:not(.no-highlight).active.old,.tempus-dominus-widget.light .time-container-hour .date-container-days div.range-end:not(.no-highlight).new,.tempus-dominus-widget.light .time-container-hour .date-container-days div.range-end:not(.no-highlight).old,.tempus-dominus-widget.light .time-container-hour .date-container-days div.range-in:not(.no-highlight).new,.tempus-dominus-widget.light .time-container-hour .date-container-days div.range-in:not(.no-highlight).old,.tempus-dominus-widget.light .time-container-hour .date-container-days div.range-start:not(.no-highlight).new,.tempus-dominus-widget.light .time-container-hour .date-container-days div.range-start:not(.no-highlight).old,.tempus-dominus-widget.light .time-container-hour div:not(.no-highlight).active.new,.tempus-dominus-widget.light .time-container-hour div:not(.no-highlight).active.old,.tempus-dominus-widget.light .time-container-minute .date-container-days div.range-end:not(.no-highlight).new,.tempus-dominus-widget.light .time-container-minute .date-container-days div.range-end:not(.no-highlight).old,.tempus-dominus-widget.light .time-container-minute .date-container-days div.range-in:not(.no-highlight).new,.tempus-dominus-widget.light .time-container-minute .date-container-days div.range-in:not(.no-highlight).old,.tempus-dominus-widget.light .time-container-minute .date-container-days div.range-start:not(.no-highlight).new,.tempus-dominus-widget.light .time-container-minute .date-container-days div.range-start:not(.no-highlight).old,.tempus-dominus-widget.light .time-container-minute div:not(.no-highlight).active.new,.tempus-dominus-widget.light .time-container-minute div:not(.no-highlight).active.old,.tempus-dominus-widget.light .time-container-second .date-container-days div.range-end:not(.no-highlight).new,.tempus-dominus-widget.light .time-container-second .date-container-days div.range-end:not(.no-highlight).old,.tempus-dominus-widget.light .time-container-second .date-container-days div.range-in:not(.no-highlight).new,.tempus-dominus-widget.light .time-container-second .date-container-days div.range-in:not(.no-highlight).old,.tempus-dominus-widget.light .time-container-second .date-container-days div.range-start:not(.no-highlight).new,.tempus-dominus-widget.light .time-container-second .date-container-days div.range-start:not(.no-highlight).old,.tempus-dominus-widget.light .time-container-second div:not(.no-highlight).active.new,.tempus-dominus-widget.light .time-container-second div:not(.no-highlight).active.old{color:#fff}.tempus-dominus-widget.light .date-container-days div.range-end:not(.no-highlight).today:before,.tempus-dominus-widget.light .date-container-days div.range-in:not(.no-highlight).today:before,.tempus-dominus-widget.light .date-container-days div.range-start:not(.no-highlight).today:before,.tempus-dominus-widget.light .date-container-days div:not(.no-highlight).active.today:before,.tempus-dominus-widget.light .date-container-decades div:not(.no-highlight).active.today:before,.tempus-dominus-widget.light .date-container-months div:not(.no-highlight).active.today:before,.tempus-dominus-widget.light .date-container-years div:not(.no-highlight).active.today:before,.tempus-dominus-widget.light .time-container-clock div:not(.no-highlight).active.today:before,.tempus-dominus-widget.light .time-container-hour div:not(.no-highlight).active.today:before,.tempus-dominus-widget.light .time-container-minute div:not(.no-highlight).active.today:before,.tempus-dominus-widget.light .time-container-second div:not(.no-highlight).active.today:before{border-bottom-color:#fff}.tempus-dominus-widget.light .date-container-days div:not(.no-highlight).new,.tempus-dominus-widget.light .date-container-days div:not(.no-highlight).old,.tempus-dominus-widget.light .date-container-decades div:not(.no-highlight).new,.tempus-dominus-widget.light .date-container-decades div:not(.no-highlight).old,.tempus-dominus-widget.light .date-container-months div:not(.no-highlight).new,.tempus-dominus-widget.light .date-container-months div:not(.no-highlight).old,.tempus-dominus-widget.light .date-container-years div:not(.no-highlight).new,.tempus-dominus-widget.light .date-container-years div:not(.no-highlight).old,.tempus-dominus-widget.light .time-container-clock div:not(.no-highlight).new,.tempus-dominus-widget.light .time-container-clock div:not(.no-highlight).old,.tempus-dominus-widget.light .time-container-hour div:not(.no-highlight).new,.tempus-dominus-widget.light .time-container-hour div:not(.no-highlight).old,.tempus-dominus-widget.light .time-container-minute div:not(.no-highlight).new,.tempus-dominus-widget.light .time-container-minute div:not(.no-highlight).old,.tempus-dominus-widget.light .time-container-second div:not(.no-highlight).new,.tempus-dominus-widget.light .time-container-second div:not(.no-highlight).old{color:rgba(0,0,0,.38)}.tempus-dominus-widget.light .date-container-days div:not(.no-highlight).disabled,.tempus-dominus-widget.light .date-container-days div:not(.no-highlight).disabled:hover,.tempus-dominus-widget.light .date-container-decades div:not(.no-highlight).disabled,.tempus-dominus-widget.light .date-container-decades div:not(.no-highlight).disabled:hover,.tempus-dominus-widget.light .date-container-months div:not(.no-highlight).disabled,.tempus-dominus-widget.light .date-container-months div:not(.no-highlight).disabled:hover,.tempus-dominus-widget.light .date-container-years div:not(.no-highlight).disabled,.tempus-dominus-widget.light .date-container-years div:not(.no-highlight).disabled:hover,.tempus-dominus-widget.light .time-container-clock div:not(.no-highlight).disabled,.tempus-dominus-widget.light .time-container-clock div:not(.no-highlight).disabled:hover,.tempus-dominus-widget.light .time-container-hour div:not(.no-highlight).disabled,.tempus-dominus-widget.light .time-container-hour div:not(.no-highlight).disabled:hover,.tempus-dominus-widget.light .time-container-minute div:not(.no-highlight).disabled,.tempus-dominus-widget.light .time-container-minute div:not(.no-highlight).disabled:hover,.tempus-dominus-widget.light .time-container-second div:not(.no-highlight).disabled,.tempus-dominus-widget.light .time-container-second div:not(.no-highlight).disabled:hover{color:#6c757d}.tempus-dominus-widget.light .date-container-days div:not(.no-highlight).today:before,.tempus-dominus-widget.light .date-container-decades div:not(.no-highlight).today:before,.tempus-dominus-widget.light .date-container-months div:not(.no-highlight).today:before,.tempus-dominus-widget.light .date-container-years div:not(.no-highlight).today:before,.tempus-dominus-widget.light .time-container-clock div:not(.no-highlight).today:before,.tempus-dominus-widget.light .time-container-hour div:not(.no-highlight).today:before,.tempus-dominus-widget.light .time-container-minute div:not(.no-highlight).today:before,.tempus-dominus-widget.light .time-container-second div:not(.no-highlight).today:before{border-bottom-color:#0d6efd;border-top-color:rgba(0,0,0,.2)}.tempus-dominus-widget.light button{background-color:#0d6efd;border-color:#0d6efd;color:#fff}.tempus-dominus-widget.dark{background-color:#1b1b1b;color:#e3e3e3}.tempus-dominus-widget.dark [data-action].disabled,.tempus-dominus-widget.dark [data-action].disabled:hover{color:#6c757d}.tempus-dominus-widget.dark .toolbar div:hover{background:#232627}.tempus-dominus-widget.dark .date-container-days .dow{color:hsla(36,10%,90%,.5)}.tempus-dominus-widget.dark .date-container-days .range-in{background-color:#0071c7!important;box-shadow:-5px 0 0 #0071c7,5px 0 0 #0071c7}.tempus-dominus-widget.dark .date-container-days .cw{color:hsla(36,10%,90%,.38)}.tempus-dominus-widget.dark .date-container-days div:not(.no-highlight):hover,.tempus-dominus-widget.dark .date-container-decades div:not(.no-highlight):hover,.tempus-dominus-widget.dark .date-container-months div:not(.no-highlight):hover,.tempus-dominus-widget.dark .date-container-years div:not(.no-highlight):hover,.tempus-dominus-widget.dark .time-container-clock div:not(.no-highlight):hover,.tempus-dominus-widget.dark .time-container-hour div:not(.no-highlight):hover,.tempus-dominus-widget.dark .time-container-minute div:not(.no-highlight):hover,.tempus-dominus-widget.dark .time-container-second div:not(.no-highlight):hover{background:#232627}.tempus-dominus-widget.dark .date-container-days div.range-end:not(.no-highlight),.tempus-dominus-widget.dark .date-container-days div.range-in:not(.no-highlight),.tempus-dominus-widget.dark .date-container-days div.range-start:not(.no-highlight),.tempus-dominus-widget.dark .date-container-days div:not(.no-highlight).active,.tempus-dominus-widget.dark .date-container-decades div:not(.no-highlight).active,.tempus-dominus-widget.dark .date-container-months div:not(.no-highlight).active,.tempus-dominus-widget.dark .date-container-years div:not(.no-highlight).active,.tempus-dominus-widget.dark .time-container-clock div:not(.no-highlight).active,.tempus-dominus-widget.dark .time-container-hour div:not(.no-highlight).active,.tempus-dominus-widget.dark .time-container-minute div:not(.no-highlight).active,.tempus-dominus-widget.dark .time-container-second div:not(.no-highlight).active{background-color:#4db2ff;color:#fff;text-shadow:0 -1px 0 hsla(36,10%,90%,.25)}.tempus-dominus-widget.dark .date-container-days .date-container-decades div.range-end:not(.no-highlight).new,.tempus-dominus-widget.dark .date-container-days .date-container-decades div.range-end:not(.no-highlight).old,.tempus-dominus-widget.dark .date-container-days .date-container-decades div.range-in:not(.no-highlight).new,.tempus-dominus-widget.dark .date-container-days .date-container-decades div.range-in:not(.no-highlight).old,.tempus-dominus-widget.dark .date-container-days .date-container-decades div.range-start:not(.no-highlight).new,.tempus-dominus-widget.dark .date-container-days .date-container-decades div.range-start:not(.no-highlight).old,.tempus-dominus-widget.dark .date-container-days .date-container-months div.range-end:not(.no-highlight).new,.tempus-dominus-widget.dark .date-container-days .date-container-months div.range-end:not(.no-highlight).old,.tempus-dominus-widget.dark .date-container-days .date-container-months div.range-in:not(.no-highlight).new,.tempus-dominus-widget.dark .date-container-days .date-container-months div.range-in:not(.no-highlight).old,.tempus-dominus-widget.dark .date-container-days .date-container-months div.range-start:not(.no-highlight).new,.tempus-dominus-widget.dark .date-container-days .date-container-months div.range-start:not(.no-highlight).old,.tempus-dominus-widget.dark .date-container-days .date-container-years div.range-end:not(.no-highlight).new,.tempus-dominus-widget.dark .date-container-days .date-container-years div.range-end:not(.no-highlight).old,.tempus-dominus-widget.dark .date-container-days .date-container-years div.range-in:not(.no-highlight).new,.tempus-dominus-widget.dark .date-container-days .date-container-years div.range-in:not(.no-highlight).old,.tempus-dominus-widget.dark .date-container-days .date-container-years div.range-start:not(.no-highlight).new,.tempus-dominus-widget.dark .date-container-days .date-container-years div.range-start:not(.no-highlight).old,.tempus-dominus-widget.dark .date-container-days .time-container-clock div.range-end:not(.no-highlight).new,.tempus-dominus-widget.dark .date-container-days .time-container-clock div.range-end:not(.no-highlight).old,.tempus-dominus-widget.dark .date-container-days .time-container-clock div.range-in:not(.no-highlight).new,.tempus-dominus-widget.dark .date-container-days .time-container-clock div.range-in:not(.no-highlight).old,.tempus-dominus-widget.dark .date-container-days .time-container-clock div.range-start:not(.no-highlight).new,.tempus-dominus-widget.dark .date-container-days .time-container-clock div.range-start:not(.no-highlight).old,.tempus-dominus-widget.dark .date-container-days .time-container-hour div.range-end:not(.no-highlight).new,.tempus-dominus-widget.dark .date-container-days .time-container-hour div.range-end:not(.no-highlight).old,.tempus-dominus-widget.dark .date-container-days .time-container-hour div.range-in:not(.no-highlight).new,.tempus-dominus-widget.dark .date-container-days .time-container-hour div.range-in:not(.no-highlight).old,.tempus-dominus-widget.dark .date-container-days .time-container-hour div.range-start:not(.no-highlight).new,.tempus-dominus-widget.dark .date-container-days .time-container-hour div.range-start:not(.no-highlight).old,.tempus-dominus-widget.dark .date-container-days .time-container-minute div.range-end:not(.no-highlight).new,.tempus-dominus-widget.dark .date-container-days .time-container-minute div.range-end:not(.no-highlight).old,.tempus-dominus-widget.dark .date-container-days .time-container-minute div.range-in:not(.no-highlight).new,.tempus-dominus-widget.dark .date-container-days .time-container-minute div.range-in:not(.no-highlight).old,.tempus-dominus-widget.dark .date-container-days .time-container-minute div.range-start:not(.no-highlight).new,.tempus-dominus-widget.dark .date-container-days .time-container-minute div.range-start:not(.no-highlight).old,.tempus-dominus-widget.dark .date-container-days .time-container-second div.range-end:not(.no-highlight).new,.tempus-dominus-widget.dark .date-container-days .time-container-second div.range-end:not(.no-highlight).old,.tempus-dominus-widget.dark .date-container-days .time-container-second div.range-in:not(.no-highlight).new,.tempus-dominus-widget.dark .date-container-days .time-container-second div.range-in:not(.no-highlight).old,.tempus-dominus-widget.dark .date-container-days .time-container-second div.range-start:not(.no-highlight).new,.tempus-dominus-widget.dark .date-container-days .time-container-second div.range-start:not(.no-highlight).old,.tempus-dominus-widget.dark .date-container-days div.range-end:not(.no-highlight).new,.tempus-dominus-widget.dark .date-container-days div.range-end:not(.no-highlight).old,.tempus-dominus-widget.dark .date-container-days div.range-in:not(.no-highlight).new,.tempus-dominus-widget.dark .date-container-days div.range-in:not(.no-highlight).old,.tempus-dominus-widget.dark .date-container-days div.range-start:not(.no-highlight).new,.tempus-dominus-widget.dark .date-container-days div.range-start:not(.no-highlight).old,.tempus-dominus-widget.dark .date-container-days div:not(.no-highlight).active.new,.tempus-dominus-widget.dark .date-container-days div:not(.no-highlight).active.old,.tempus-dominus-widget.dark .date-container-decades .date-container-days div.range-end:not(.no-highlight).new,.tempus-dominus-widget.dark .date-container-decades .date-container-days div.range-end:not(.no-highlight).old,.tempus-dominus-widget.dark .date-container-decades .date-container-days div.range-in:not(.no-highlight).new,.tempus-dominus-widget.dark .date-container-decades .date-container-days div.range-in:not(.no-highlight).old,.tempus-dominus-widget.dark .date-container-decades .date-container-days div.range-start:not(.no-highlight).new,.tempus-dominus-widget.dark .date-container-decades .date-container-days div.range-start:not(.no-highlight).old,.tempus-dominus-widget.dark .date-container-decades div:not(.no-highlight).active.new,.tempus-dominus-widget.dark .date-container-decades div:not(.no-highlight).active.old,.tempus-dominus-widget.dark .date-container-months .date-container-days div.range-end:not(.no-highlight).new,.tempus-dominus-widget.dark .date-container-months .date-container-days div.range-end:not(.no-highlight).old,.tempus-dominus-widget.dark .date-container-months .date-container-days div.range-in:not(.no-highlight).new,.tempus-dominus-widget.dark .date-container-months .date-container-days div.range-in:not(.no-highlight).old,.tempus-dominus-widget.dark .date-container-months .date-container-days div.range-start:not(.no-highlight).new,.tempus-dominus-widget.dark .date-container-months .date-container-days div.range-start:not(.no-highlight).old,.tempus-dominus-widget.dark .date-container-months div:not(.no-highlight).active.new,.tempus-dominus-widget.dark .date-container-months div:not(.no-highlight).active.old,.tempus-dominus-widget.dark .date-container-years .date-container-days div.range-end:not(.no-highlight).new,.tempus-dominus-widget.dark .date-container-years .date-container-days div.range-end:not(.no-highlight).old,.tempus-dominus-widget.dark .date-container-years .date-container-days div.range-in:not(.no-highlight).new,.tempus-dominus-widget.dark .date-container-years .date-container-days div.range-in:not(.no-highlight).old,.tempus-dominus-widget.dark .date-container-years .date-container-days div.range-start:not(.no-highlight).new,.tempus-dominus-widget.dark .date-container-years .date-container-days div.range-start:not(.no-highlight).old,.tempus-dominus-widget.dark .date-container-years div:not(.no-highlight).active.new,.tempus-dominus-widget.dark .date-container-years div:not(.no-highlight).active.old,.tempus-dominus-widget.dark .time-container-clock .date-container-days div.range-end:not(.no-highlight).new,.tempus-dominus-widget.dark .time-container-clock .date-container-days div.range-end:not(.no-highlight).old,.tempus-dominus-widget.dark .time-container-clock .date-container-days div.range-in:not(.no-highlight).new,.tempus-dominus-widget.dark .time-container-clock .date-container-days div.range-in:not(.no-highlight).old,.tempus-dominus-widget.dark .time-container-clock .date-container-days div.range-start:not(.no-highlight).new,.tempus-dominus-widget.dark .time-container-clock .date-container-days div.range-start:not(.no-highlight).old,.tempus-dominus-widget.dark .time-container-clock div:not(.no-highlight).active.new,.tempus-dominus-widget.dark .time-container-clock div:not(.no-highlight).active.old,.tempus-dominus-widget.dark .time-container-hour .date-container-days div.range-end:not(.no-highlight).new,.tempus-dominus-widget.dark .time-container-hour .date-container-days div.range-end:not(.no-highlight).old,.tempus-dominus-widget.dark .time-container-hour .date-container-days div.range-in:not(.no-highlight).new,.tempus-dominus-widget.dark .time-container-hour .date-container-days div.range-in:not(.no-highlight).old,.tempus-dominus-widget.dark .time-container-hour .date-container-days div.range-start:not(.no-highlight).new,.tempus-dominus-widget.dark .time-container-hour .date-container-days div.range-start:not(.no-highlight).old,.tempus-dominus-widget.dark .time-container-hour div:not(.no-highlight).active.new,.tempus-dominus-widget.dark .time-container-hour div:not(.no-highlight).active.old,.tempus-dominus-widget.dark .time-container-minute .date-container-days div.range-end:not(.no-highlight).new,.tempus-dominus-widget.dark .time-container-minute .date-container-days div.range-end:not(.no-highlight).old,.tempus-dominus-widget.dark .time-container-minute .date-container-days div.range-in:not(.no-highlight).new,.tempus-dominus-widget.dark .time-container-minute .date-container-days div.range-in:not(.no-highlight).old,.tempus-dominus-widget.dark .time-container-minute .date-container-days div.range-start:not(.no-highlight).new,.tempus-dominus-widget.dark .time-container-minute .date-container-days div.range-start:not(.no-highlight).old,.tempus-dominus-widget.dark .time-container-minute div:not(.no-highlight).active.new,.tempus-dominus-widget.dark .time-container-minute div:not(.no-highlight).active.old,.tempus-dominus-widget.dark .time-container-second .date-container-days div.range-end:not(.no-highlight).new,.tempus-dominus-widget.dark .time-container-second .date-container-days div.range-end:not(.no-highlight).old,.tempus-dominus-widget.dark .time-container-second .date-container-days div.range-in:not(.no-highlight).new,.tempus-dominus-widget.dark .time-container-second .date-container-days div.range-in:not(.no-highlight).old,.tempus-dominus-widget.dark .time-container-second .date-container-days div.range-start:not(.no-highlight).new,.tempus-dominus-widget.dark .time-container-second .date-container-days div.range-start:not(.no-highlight).old,.tempus-dominus-widget.dark .time-container-second div:not(.no-highlight).active.new,.tempus-dominus-widget.dark .time-container-second div:not(.no-highlight).active.old{color:#fff}.tempus-dominus-widget.dark .date-container-days div.range-end:not(.no-highlight).today:before,.tempus-dominus-widget.dark .date-container-days div.range-in:not(.no-highlight).today:before,.tempus-dominus-widget.dark .date-container-days div.range-start:not(.no-highlight).today:before,.tempus-dominus-widget.dark .date-container-days div:not(.no-highlight).active.today:before,.tempus-dominus-widget.dark .date-container-decades div:not(.no-highlight).active.today:before,.tempus-dominus-widget.dark .date-container-months div:not(.no-highlight).active.today:before,.tempus-dominus-widget.dark .date-container-years div:not(.no-highlight).active.today:before,.tempus-dominus-widget.dark .time-container-clock div:not(.no-highlight).active.today:before,.tempus-dominus-widget.dark .time-container-hour div:not(.no-highlight).active.today:before,.tempus-dominus-widget.dark .time-container-minute div:not(.no-highlight).active.today:before,.tempus-dominus-widget.dark .time-container-second div:not(.no-highlight).active.today:before{border-bottom-color:#1b1b1b}.tempus-dominus-widget.dark .date-container-days div:not(.no-highlight).new,.tempus-dominus-widget.dark .date-container-days div:not(.no-highlight).old,.tempus-dominus-widget.dark .date-container-decades div:not(.no-highlight).new,.tempus-dominus-widget.dark .date-container-decades div:not(.no-highlight).old,.tempus-dominus-widget.dark .date-container-months div:not(.no-highlight).new,.tempus-dominus-widget.dark .date-container-months div:not(.no-highlight).old,.tempus-dominus-widget.dark .date-container-years div:not(.no-highlight).new,.tempus-dominus-widget.dark .date-container-years div:not(.no-highlight).old,.tempus-dominus-widget.dark .time-container-clock div:not(.no-highlight).new,.tempus-dominus-widget.dark .time-container-clock div:not(.no-highlight).old,.tempus-dominus-widget.dark .time-container-hour div:not(.no-highlight).new,.tempus-dominus-widget.dark .time-container-hour div:not(.no-highlight).old,.tempus-dominus-widget.dark .time-container-minute div:not(.no-highlight).new,.tempus-dominus-widget.dark .time-container-minute div:not(.no-highlight).old,.tempus-dominus-widget.dark .time-container-second div:not(.no-highlight).new,.tempus-dominus-widget.dark .time-container-second div:not(.no-highlight).old{color:hsla(36,10%,90%,.38)}.tempus-dominus-widget.dark .date-container-days div:not(.no-highlight).disabled,.tempus-dominus-widget.dark .date-container-days div:not(.no-highlight).disabled:hover,.tempus-dominus-widget.dark .date-container-decades div:not(.no-highlight).disabled,.tempus-dominus-widget.dark .date-container-decades div:not(.no-highlight).disabled:hover,.tempus-dominus-widget.dark .date-container-months div:not(.no-highlight).disabled,.tempus-dominus-widget.dark .date-container-months div:not(.no-highlight).disabled:hover,.tempus-dominus-widget.dark .date-container-years div:not(.no-highlight).disabled,.tempus-dominus-widget.dark .date-container-years div:not(.no-highlight).disabled:hover,.tempus-dominus-widget.dark .time-container-clock div:not(.no-highlight).disabled,.tempus-dominus-widget.dark .time-container-clock div:not(.no-highlight).disabled:hover,.tempus-dominus-widget.dark .time-container-hour div:not(.no-highlight).disabled,.tempus-dominus-widget.dark .time-container-hour div:not(.no-highlight).disabled:hover,.tempus-dominus-widget.dark .time-container-minute div:not(.no-highlight).disabled,.tempus-dominus-widget.dark .time-container-minute div:not(.no-highlight).disabled:hover,.tempus-dominus-widget.dark .time-container-second div:not(.no-highlight).disabled,.tempus-dominus-widget.dark .time-container-second div:not(.no-highlight).disabled:hover{color:#6c757d}.tempus-dominus-widget.dark .date-container-days div:not(.no-highlight).today:before,.tempus-dominus-widget.dark .date-container-decades div:not(.no-highlight).today:before,.tempus-dominus-widget.dark .date-container-months div:not(.no-highlight).today:before,.tempus-dominus-widget.dark .date-container-years div:not(.no-highlight).today:before,.tempus-dominus-widget.dark .time-container-clock div:not(.no-highlight).today:before,.tempus-dominus-widget.dark .time-container-hour div:not(.no-highlight).today:before,.tempus-dominus-widget.dark .time-container-minute div:not(.no-highlight).today:before,.tempus-dominus-widget.dark .time-container-second div:not(.no-highlight).today:before{border-bottom-color:#4db2ff;border-top-color:hsla(36,10%,90%,.2)}.tempus-dominus-widget.dark button{background-color:#4db2ff;border-color:#4db2ff;color:#fff} --------------------------------------------------------------------------------