diff --git a/README.md b/README.md index c865c3b5148..a59d6914bf5 100644 --- a/README.md +++ b/README.md @@ -82,9 +82,55 @@ docker compose up postgres At development time we recommend you use the test applications set up as `main()` methods in `PetClinicIntegrationTests` (using the default H2 database and also adding Spring Boot Devtools), `MySqlTestApplication` and `PostgresIntegrationTests`. These are set up so that you can run the apps in your IDE to get fast feedback and also run the same classes as integration tests against the respective database. The MySql integration tests use Testcontainers to start the database in a Docker container, and the Postgres tests use Docker Compose to do the same thing. +## UI with Tailwind CSS and shadcn Components + +The UI has been updated to use modern web technologies: + +- **Tailwind CSS**: A utility-first CSS framework for rapid UI development +- **shadcn components**: A collection of accessible and customizable UI components + +### Prerequisites for UI Development + +- Node.js 18 or newer +- npm 9 or newer + +### Building the UI + +1. Install Node.js dependencies: + +```bash +npm install +``` + +2. Build the Tailwind CSS: + +```bash +npm run build:css +``` + +For development, you can use watch mode to automatically rebuild the CSS when files change: + +```bash +npm run watch:css +``` + +### UI Components + +The application uses the following shadcn components: + +- **Button**: Used for all buttons and links with button styling +- **Input**: Used for all text and date input fields +- **Select**: Used for all dropdown select fields + +These components are implemented as vanilla JavaScript classes that apply Tailwind CSS classes based on data attributes. + ## Compiling the CSS -There is a `petclinic.css` in `src/main/resources/static/resources/css`. It was generated from the `petclinic.scss` source, combined with the [Bootstrap](https://getbootstrap.com/) library. If you make changes to the `scss`, or upgrade Bootstrap, you will need to re-compile the CSS resources using the Maven profile "css", i.e. `./mvnw package -P css`. There is no build profile for Gradle to compile the CSS. +There are two options for CSS in this project: + +1. **Tailwind CSS (Recommended)**: The `petclinic.css` in `src/main/resources/static/resources/css` is generated from the `tailwind-input.css` source using Tailwind CSS. To compile it, run `npm run build:css`. + +2. **Legacy SCSS**: The original CSS was generated from the `petclinic.scss` source, combined with the [Bootstrap](https://getbootstrap.com/) library. If you prefer to use this approach, you will need to re-compile the CSS resources using the Maven profile "css", i.e. `./mvnw package -P css`. There is no build profile for Gradle to compile the CSS. ## Working with Petclinic in your IDE diff --git a/package.json b/package.json new file mode 100644 index 00000000000..55eea41a99a --- /dev/null +++ b/package.json @@ -0,0 +1,26 @@ +{ + "name": "spring-petclinic", + "version": "1.0.0", + "description": "Spring PetClinic with Tailwind CSS and shadcn components", + "scripts": { + "build:css": "tailwindcss -i ./src/main/resources/static/resources/css/tailwind-input.css -o ./src/main/resources/static/resources/css/petclinic.css", + "watch:css": "tailwindcss -i ./src/main/resources/static/resources/css/tailwind-input.css -o ./src/main/resources/static/resources/css/petclinic.css --watch" + }, + "dependencies": { + "@radix-ui/react-dialog": "^1.0.5", + "@radix-ui/react-dropdown-menu": "^2.0.6", + "@radix-ui/react-label": "^2.0.2", + "@radix-ui/react-select": "^2.0.0", + "@radix-ui/react-slot": "^1.0.2", + "class-variance-authority": "^0.7.0", + "clsx": "^2.1.0", + "lucide-react": "^0.363.0", + "tailwind-merge": "^2.2.2", + "tailwindcss-animate": "^1.0.7" + }, + "devDependencies": { + "autoprefixer": "^10.4.19", + "postcss": "^8.4.38", + "tailwindcss": "^3.4.1" + } +} diff --git a/postcss.config.js b/postcss.config.js new file mode 100644 index 00000000000..33ad091d26d --- /dev/null +++ b/postcss.config.js @@ -0,0 +1,6 @@ +module.exports = { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +} diff --git a/src/main/resources/static/resources/js/components/ui/button.js b/src/main/resources/static/resources/js/components/ui/button.js new file mode 100644 index 00000000000..9a519175b1f --- /dev/null +++ b/src/main/resources/static/resources/js/components/ui/button.js @@ -0,0 +1,78 @@ +/** + * Button component based on shadcn/ui + * This is a client-side component that can be used with data-* attributes + */ +class Button { + constructor(element) { + this.element = element; + this.variant = element.dataset.variant || 'default'; + this.size = element.dataset.size || 'default'; + this.init(); + } + + init() { + // Apply base styles + this.element.classList.add( + 'inline-flex', + 'items-center', + 'justify-center', + 'whitespace-nowrap', + 'rounded-md', + 'text-sm', + 'font-medium', + 'ring-offset-background', + 'transition-colors', + 'focus-visible:outline-none', + 'focus-visible:ring-2', + 'focus-visible:ring-ring', + 'focus-visible:ring-offset-2', + 'disabled:pointer-events-none', + 'disabled:opacity-50' + ); + + // Apply variant styles + switch (this.variant) { + case 'default': + this.element.classList.add('bg-primary', 'text-primary-foreground', 'hover:bg-primary/90'); + break; + case 'destructive': + this.element.classList.add('bg-destructive', 'text-destructive-foreground', 'hover:bg-destructive/90'); + break; + case 'outline': + this.element.classList.add('border', 'border-input', 'bg-background', 'hover:bg-accent', 'hover:text-accent-foreground'); + break; + case 'secondary': + this.element.classList.add('bg-secondary', 'text-secondary-foreground', 'hover:bg-secondary/80'); + break; + case 'ghost': + this.element.classList.add('hover:bg-accent', 'hover:text-accent-foreground'); + break; + case 'link': + this.element.classList.add('text-primary', 'underline-offset-4', 'hover:underline'); + break; + } + + // Apply size styles + switch (this.size) { + case 'default': + this.element.classList.add('h-10', 'px-4', 'py-2'); + break; + case 'sm': + this.element.classList.add('h-9', 'rounded-md', 'px-3'); + break; + case 'lg': + this.element.classList.add('h-11', 'rounded-md', 'px-8'); + break; + case 'icon': + this.element.classList.add('h-10', 'w-10'); + break; + } + } +} + +// Initialize all buttons on page load +document.addEventListener('DOMContentLoaded', () => { + document.querySelectorAll('[data-component="button"]').forEach(element => { + new Button(element); + }); +}); diff --git a/src/main/resources/static/resources/js/components/ui/input.js b/src/main/resources/static/resources/js/components/ui/input.js new file mode 100644 index 00000000000..2a93e095b32 --- /dev/null +++ b/src/main/resources/static/resources/js/components/ui/input.js @@ -0,0 +1,64 @@ +/** + * Input component based on shadcn/ui + * This is a client-side component that can be used with data-* attributes + */ +class Input { + constructor(element) { + this.element = element; + this.init(); + } + + init() { + // Apply base styles + this.element.classList.add( + 'flex', + 'h-10', + 'w-full', + 'rounded-md', + 'border', + 'border-input', + 'bg-background', + 'px-3', + 'py-2', + 'text-sm', + 'ring-offset-background', + 'file:border-0', + 'file:bg-transparent', + 'file:text-sm', + 'file:font-medium', + 'placeholder:text-muted-foreground', + 'focus-visible:outline-none', + 'focus-visible:ring-2', + 'focus-visible:ring-ring', + 'focus-visible:ring-offset-2', + 'disabled:cursor-not-allowed', + 'disabled:opacity-50' + ); + + // Add label styling if there's a label + const label = this.element.closest('.form-group')?.querySelector('label'); + if (label) { + label.classList.add( + 'text-sm', + 'font-medium', + 'leading-none', + 'peer-disabled:cursor-not-allowed', + 'peer-disabled:opacity-70' + ); + } + + // Add error styling if there's an error + const error = this.element.closest('.form-group')?.querySelector('.help-inline'); + if (error) { + error.classList.add('text-destructive', 'text-sm', 'mt-1'); + this.element.classList.add('border-destructive'); + } + } +} + +// Initialize all inputs on page load +document.addEventListener('DOMContentLoaded', () => { + document.querySelectorAll('[data-component="input"]').forEach(element => { + new Input(element); + }); +}); diff --git a/src/main/resources/static/resources/js/components/ui/select.js b/src/main/resources/static/resources/js/components/ui/select.js new file mode 100644 index 00000000000..ae3ee07467f --- /dev/null +++ b/src/main/resources/static/resources/js/components/ui/select.js @@ -0,0 +1,75 @@ +/** + * Select component based on shadcn/ui + * This is a client-side component that can be used with data-* attributes + */ +class Select { + constructor(element) { + this.element = element; + this.init(); + } + + init() { + // Apply base styles to the select element + this.element.classList.add( + 'flex', + 'h-10', + 'w-full', + 'rounded-md', + 'border', + 'border-input', + 'bg-background', + 'px-3', + 'py-2', + 'text-sm', + 'ring-offset-background', + 'focus-visible:outline-none', + 'focus-visible:ring-2', + 'focus-visible:ring-ring', + 'focus-visible:ring-offset-2', + 'disabled:cursor-not-allowed', + 'disabled:opacity-50' + ); + + // Add label styling if there's a label + const label = this.element.closest('.form-group')?.querySelector('label'); + if (label) { + label.classList.add( + 'text-sm', + 'font-medium', + 'leading-none', + 'peer-disabled:cursor-not-allowed', + 'peer-disabled:opacity-70' + ); + } + + // Add error styling if there's an error + const error = this.element.closest('.form-group')?.querySelector('.help-inline'); + if (error) { + error.classList.add('text-destructive', 'text-sm', 'mt-1'); + this.element.classList.add('border-destructive'); + } + + // Style option elements + Array.from(this.element.querySelectorAll('option')).forEach(option => { + option.classList.add( + 'relative', + 'cursor-default', + 'select-none', + 'py-1.5', + 'pl-8', + 'pr-2', + 'text-sm', + 'outline-none', + 'data-[disabled]:pointer-events-none', + 'data-[disabled]:opacity-50' + ); + }); + } +} + +// Initialize all selects on page load +document.addEventListener('DOMContentLoaded', () => { + document.querySelectorAll('[data-component="select"]').forEach(element => { + new Select(element); + }); +}); diff --git a/src/main/resources/static/resources/js/main.js b/src/main/resources/static/resources/js/main.js new file mode 100644 index 00000000000..7bb0eee7012 --- /dev/null +++ b/src/main/resources/static/resources/js/main.js @@ -0,0 +1,37 @@ +/** + * Main JavaScript file for Spring PetClinic + * Initializes shadcn components and other functionality + */ + +// Import component scripts +document.addEventListener('DOMContentLoaded', () => { + // Load component scripts + const componentScripts = [ + '/resources/js/components/ui/button.js', + '/resources/js/components/ui/input.js', + '/resources/js/components/ui/select.js' + ]; + + componentScripts.forEach(script => { + const scriptElement = document.createElement('script'); + scriptElement.src = script; + document.body.appendChild(scriptElement); + }); + + // Initialize mobile menu toggle + const menuToggle = document.querySelector('.navbar-toggler'); + const mainNavbar = document.querySelector('#main-navbar'); + + if (menuToggle && mainNavbar) { + menuToggle.addEventListener('click', () => { + const isExpanded = menuToggle.getAttribute('aria-expanded') === 'true'; + menuToggle.setAttribute('aria-expanded', !isExpanded); + + if (isExpanded) { + mainNavbar.classList.add('hidden', 'md:block'); + } else { + mainNavbar.classList.remove('hidden'); + } + }); + } +}); diff --git a/src/main/resources/templates/fragments/inputField.html b/src/main/resources/templates/fragments/inputField.html index de456d25082..5aa8487602d 100644 --- a/src/main/resources/templates/fragments/inputField.html +++ b/src/main/resources/templates/fragments/inputField.html @@ -4,21 +4,21 @@
- -
+ class="form-group space-y-2 mb-4"> + +
- - + +
- Error + Error
diff --git a/src/main/resources/templates/fragments/layout.html b/src/main/resources/templates/fragments/layout.html index e0d82b00390..51f5afdb5a7 100644 --- a/src/main/resources/templates/fragments/layout.html +++ b/src/main/resources/templates/fragments/layout.html @@ -1,8 +1,7 @@ - + - @@ -19,32 +18,28 @@ - - - -