diff --git a/.gitconfig b/.gitconfig
new file mode 100644
index 0000000..1c8d4a7
--- /dev/null
+++ b/.gitconfig
@@ -0,0 +1,2 @@
+[core]
+ hooksPath = .githooks
diff --git a/.githooks/commit-msg b/.githooks/commit-msg
new file mode 100755
index 0000000..810576f
--- /dev/null
+++ b/.githooks/commit-msg
@@ -0,0 +1,13 @@
+#!/bin/sh
+
+# https://github.com/leoroese/blog-tube/blob/main/.husky/commit-msg
+if ! head -1 "$1" | grep -qE "^(feat|fix|ci|chore|docs|test|style|refactor|perf|build|revert|breaking)(\(.+?\))?: .{1,}$"; then
+ echo ""
+ printf "\n\x1b[31;1m✘ Aborting commit! Your commit message is not valid.\x1b[0m\n" >&2
+ exit 1
+fi
+if ! head -1 "$1" | grep -qE "^.{1,88}$"; then
+ echo ""
+ printf "\x1b[31;1m✘ Aborting commit! Your commit message is too long.\x1b[0m\n" >&2
+ exit 1
+fi
diff --git a/.githooks/pre-commit b/.githooks/pre-commit
new file mode 100755
index 0000000..ff10f06
--- /dev/null
+++ b/.githooks/pre-commit
@@ -0,0 +1,3 @@
+#!/bin/sh
+
+make test
diff --git a/.githooks/pre-push b/.githooks/pre-push
new file mode 100755
index 0000000..e2f4b01
--- /dev/null
+++ b/.githooks/pre-push
@@ -0,0 +1,3 @@
+#!/bin/sh
+
+make _go_tools
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..2738ce9
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,26 @@
+# Go
+go.work
+*.exe
+*.so
+*.test
+*.out
+
+# GoLand #
+.idea
+
+# OSX
+.DS_Store
+.AppleDouble
+.LSOverride
+Icon
+
+# VisualStudioCode
+.vscode/*
+.history/
+
+#
+*.log
+tmp
+.air.toml
+_examples/**/*_templ.go
+sync.sh
\ No newline at end of file
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..29270d4
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2024 indaco (Mirco Veltri)
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/Makefile b/Makefile
new file mode 100644
index 0000000..1fbea07
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,93 @@
+color_red := $(shell printf "\e[1;31m")
+color_green := $(shell printf "\e[1;32m")
+color_yellow := $(shell printf "\e[1;33m")
+color_blue := $(shell printf "\e[1;34m")
+color_magenta := $(shell printf "\e[1;35m")
+color_cyan := $(shell printf "\e[1;36m")
+color_reset := $(shell printf "\e[0m")
+
+EXAMPLES := \
+ _examples/a-h-templ \
+ _examples/custom-animations \
+ _examples/custom-button-icon \
+ _examples/go-html-template \
+ _examples/icon-only-button \
+ _examples/positioning \
+ _examples/theming \
+
+# ==================================================================================== #
+# HELPERS
+# ==================================================================================== #
+.PHONY: help
+help: ## Print this help message
+ @echo ""
+ @echo "Usage: make [action]"
+ @echo ""
+ @echo "Available Actions:"
+ @echo ""
+ @awk -F ':|##' '/^[^\t].+?:.*?##/ {printf " \033[36m%-15s\033[0m %s\n", $$1, $$NF}' $(MAKEFILE_LIST) | sort
+ @echo ""
+
+# ==================================================================================== #
+# PRIVATE BUILDERS
+# ==================================================================================== #
+_process_templ_files:
+ @echo "$(color_magenta)Process TEMPL files$(color_reset)"
+
+ @echo "$(color_cyan) * Formatting templ files...$(color_reset)"
+ @templ fmt .
+
+ @echo "$(color_cyan) * Generating templ funcs...$(color_reset)"
+ @templ generate
+
+ @echo "$(color_green)Done!$(color_reset)"
+
+_go_tools:
+ @echo ""
+ @echo "$(color_magenta)Run go tools...$(color_reset)"
+
+ @echo "$(color_cyan) * Downloading modules...$(color_reset)"
+ @go mod download; go mod tidy
+ @go build -v ./...
+ @echo "$(color_cyan) * Running golangci-lint...$(color_reset)"
+ @golangci-lint run ./...
+ @echo "$(color_cyan) * Running tests...$(color_reset)"
+ @$(MAKE) _test
+
+ @echo "$(color_green)Done!$(color_reset)"
+
+_test:
+ @go test -race -covermode=atomic ./...
+
+# ==================================================================================== #
+# BUILDERS
+# ==================================================================================== #
+
+examples: ## Process templ files in the _examples folder
+ @for example in $(EXAMPLES); do \
+ pushd $$example && templ fmt . && templ generate && popd; \
+ done
+
+test: ## Run go tests
+ @$(MAKE) _test
+
+templ: ## Process TEMPL files
+ @$(MAKE) _process_templ_files
+
+report-card: ## Run goreportcard-cli
+ @$(MAKE) build
+ @if [ -x $$(command -v goreportcard-cli) ]; then \
+ echo "$(color_cyan) * Running goreportcard-cli...$(color_reset)"; \
+ goreportcard-cli -v; \
+ fi
+ @$(MAKE) clean
+
+clean:
+ @echo ""
+ @echo "$(color_magenta)Clean up$(color_reset)"
+ @find . -type f -name '*.grc.bk' -exec rm -f {} +
+ @find . -type f -name '*_templ.txt' -exec rm -f {} +
+ @echo "$(color_green)Done!$(color_reset)"
+
+build: ## The main build target
+ @$(MAKE) templ _go_tools
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..be90316
--- /dev/null
+++ b/README.md
@@ -0,0 +1,204 @@
+
+ gropdown
+
+
+A fully accessible, configurable and themeable server-rendered dropdown component for Go web applications.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+Built with [templ](https://github.com/a-h/templ) library for seamless integration with Go-based web frontends.
+
+## Features
+
+- **Accessible**: Fully compliant with the [WAI-ARIA Menu Button Design Pattern](https://www.w3.org/WAI/ARIA/apg/patterns/menu-button/), to ensure accessibility for all users.
+- **No External Dependencies**: Built without relying on any external libraries or frameworks.
+- **Configurable**: The component offers various configuration options to customize its behavior (e.g. positioning, open by default...)
+- **Themeable**: Supports theming via CSS variables, allowing easy customization of appearance. Comes with built-in support for light and dark modes, as well as the ability to define custom themes using the `data-theme` attribute.
+- **Versatile**: Items can be buttons or links (``). When a link item is marked as _external_, a visual icon will be added to indicate it.
+
+
+
+
+
+## Installation
+
+To install the Dropdown module, use the `go get` command:
+
+```sh
+go get github.com/indaco/gropdown
+```
+
+Ensure your project is using Go Modules (it will have a go.mod file in its root if it already does).
+
+## Usage
+
+Import the Dropdown module into your project:
+
+```go
+import "github.com/indaco/gropdown"
+```
+
+### Creating a Dropdown
+
+```go
+// Set the button label.
+button := gropdown.DropdownButton{Label: "Menu"}
+
+// Set the items for the Dropdown menu.
+items := []gropdown.DropdownItem{
+ {Label: "Settings", Href: "/settings"},
+ {Label: "GitHub", Href: "https://github.com", External: true},
+ {Divider: true},
+ {Label: "Button", Attrs: templ.Attributes{"onclick": "alert('Hello gropdown');"}},
+}
+
+// Build the Dropdown component.
+dropdown := gropdown.NewDropdownBuilder().WithButton(button).WithItems(items)
+```
+
+or customize the dropdown with options:
+
+```go
+// Here we set the Dropdown menu opened as default and the content positioned as absolute instead of relative.
+dropdown := gropdown.NewDropdownBuilder().SetOpen(true).SetPositionAbsolute(true)
+dropdown.WithButton(button).WithItems(items)
+```
+
+## A11Y
+
+The dropdown component is designed to be accessible to screen readers and supports keyboard navigation according to the [WAI-ARIA pattern for menu buttons](https://www.w3.org/WAI/ARIA/apg/patterns/menu-button/examples/menu-button-actions/#kbd_label).
+
+### Screen Reader Support
+
+Ensure that proper ARIA attributes are used to convey the state and role of the dropdown elements.
+
+### Keyboard Interaction
+
+- **Focusing the Dropdown**:
+ - Use the `Tab` key to navigate to the dropdown button. Pressing `Enter` or `Space` will open the dropdown menu.
+- **Navigating within the Dropdown Menu**:
+ - Use the `Arrow` keys to move focus between items within the dropdown menu.
+ - Pressing `Home` or `End` keys will move focus to the first or last item respectively.
+ - Use `A-Z` or `a-z` keys to move focus to the next menu item with a label that starts with the typed character if such a menu item exists. Otherwise, focus does not move.
+- **Selecting an Item**:
+ - Press `Enter` to select the currently focused item in the dropdown menu.
+- **Closing the Dropdown**:
+ - Press `Escape` to close the dropdown and sets focus to the menu button.
+
+## Theming
+
+Dropdown is themeable using CSS variables (prefix `gdd`) to customize the appearance according to your design.
+
+By default, it supports both light and dark modes. In addition to the built-in modes, you can define your
+own custom themes using the `data-theme` attribute. Simply add a `data-theme` attribute to the root element of your application
+and define the corresponding CSS variables for your custom theme.
+
+Here below is the list of all CSS variables defined and their default values:
+
+### Dropdown Button
+
+| CSS Variable | Default Value | Description |
+|-------------------------------------------|------------------------------------------------------------------------|------------------------------------------------------------------|
+| `--gdd-button-min-w` | 4.5em | Minimum width of the dropdown button |
+| `--gdd-button-py` | 1ch | Padding on the y-axis of the dropdown button |
+| `--gdd-button-px` | 2ch | Padding on the x-axis of the dropdown button |
+| `--gdd-button-icon-space` | 0.5ch | Space between the dropdown button label and icon |
+| `--gdd-button-color` | ![Color Preview](https://via.placeholder.com/20/1f2937?text=+) #1f2937 | Text color of the dropdown button |
+| `--gdd-button-color-hover` | ![Color Preview](https://via.placeholder.com/20/1f2937?text=+) #1f2937 | Text color of the dropdown button on hover |
+| `--gdd-button-font-size` | 1rem | Font size of the dropdown button label |
+| `--gdd-button-font-family` | inherit | Font family of the dropdown button label |
+| `--gdd-button-font-weight` | 500 | Font weight of the dropdown button label |
+| `--gdd-button-line-height` | 1.25 | Line height of the dropdown button label |
+| `--gdd-button-letter-spacing` | 0.025em | Letter spacing of the dropdown button label |
+| `--gdd-button-bg-color` | ![Color Preview](https://via.placeholder.com/20/f9fafb?text=+) #f9fafb | Background color of the dropdown button |
+| `--gdd-button-bg-color-hover` | ![Color Preview](https://via.placeholder.com/20/f3f4f6?text=+) #f3f4f6 | Background color of the dropdown button on hover |
+| `--gdd-button-border-width` | 1px | Border width of the dropdown button |
+| `--gdd-button-border-style` | solid | Border style of the dropdown button |
+| `--gdd-button-border-color` | transparent | Border color of the dropdown button |
+| `--gdd-button-ring-color` | ![Color Preview](https://via.placeholder.com/20/e5e7eb?text=+) #e5e7eb | Color of the focus ring around the dropdown button |
+| `--gdd-button-border-radius` | 0.25rem | Border radius of the dropdown button |
+| `--gdd-button-transition-property` | background | CSS property to transition for the dropdown button |
+| `--gdd-button-transition-duration` | 300ms | Duration of the transition for the dropdown button |
+| `--gdd-button-transition-timing-function` | cubic-bezier(0.4, 0, 0.2, 1) | Timing function of the transition for the dropdown button |
+| `--gdd-button-ring-width` | 1px | Width of the focus ring around the dropdown button |
+| `--gdd-button-ring-style` | solid | Style of the focus ring around the dropdown button |
+| `--gdd-button-ring-offset` | 1px | Offset of the focus ring around the dropdown button |
+| `--gdd-button-animation-open-name` | flipOutX | Animation property (name) for button icon when dropdown is open |
+| `--gdd-button-animation-close-name` | flipInX | Animation property (name) for button icon when dropdown is close |
+
+### Dropdown Content
+
+| CSS Variable | Default Value | Description |
+|----------------------------------------------------|------------------------------------------------------------------------|--------------------------------------------------------------------|
+| `--gdd-content-w` | 13rem | Width of the dropdown content |
+| `--gdd-content-max-w` | 16rem | Maximum width of the dropdown content |
+| `--gdd-content-mx` | 0 | Margin on the x-axis of the dropdown content |
+| `--gdd-content-my` | 0.25rem | Margin on the y-axis of the dropdown content |
+| `--gdd-content-px` | 0.375rem | Padding on the x-axis of the dropdown content |
+| `--gdd-content-py` | 0.5rem | Padding on the y-axis of the dropdown content |
+| `--gdd-content-bg-color` | ![Color Preview](https://via.placeholder.com/20/ffffff?text=+) #ffffff | Background color of the dropdown content |
+| `--gdd-content-border-width` | 1px | Border width of the dropdown content |
+| `--gdd-content-border-style` | solid | Border style of the dropdown content |
+| `--gdd-content-border-color` | ![Color Preview](https://via.placeholder.com/20/030712?text=+) #030712 | Border color of the dropdown content |
+| `--gdd-content-border-radius` | 0.25rem | Border radius of the dropdown content |
+| `--gdd-content-animation-entrance-duration` | 0.3s | Duration of the entrance animation for the dropdown content |
+| `--gdd-content-animation-entrance-timing-function` | ease-in-out | Timing function of the entrance animation for the dropdown content |
+
+### Dropdown Item
+
+| CSS Variable | Default Value | Description |
+|-----------------------------|------------------------------------------------------------------------|-------------------------------------------------------------------|
+| `--gdd-item-px` | 0.375rem | Padding on the x-axis of the dropdown item |
+| `--gdd-item-py` | 0.375rem | Padding on the y-axis of the dropdown item |
+| `--gdd-item-icon-space` | 1ch | Space between the dropdown item label and icon |
+| `--gdd-item-color` | ![Color Preview](https://via.placeholder.com/20/f3f4f6?text=+) #f3f4f6 | Color of the item text |
+| `--gdd-item-color-hover` | ![Color Preview](https://via.placeholder.com/20/f3f4f6?text=+) #f3f4f6 | Color of the item text on hover |
+| `--gdd-item-font-family` | inherit | Font family of the dropdown item label |
+| `--gdd-item-font-size` | 1rem | Font size of the dropdown item label |
+| `--gdd-item-font-weight` | 500 | Font weight of the dropdown item label |
+| `--gdd-item-line-height` | 1.25 | Line height of the dropdown item label |
+| `--gdd-item-letter-spacing` | 0.025em | Letter spacing of the dropdown item label |
+| `--gdd-item-bg-color` | transparent | Background color of the item |
+| `--gdd-item-bg-color-hover` | ![Color Preview](https://via.placeholder.com/20/030712?text=+) #030712 | Background color of the item on hover |
+| `--gdd-item-border-width` | 1px | Border width of the dropdown item |
+| `--gdd-item-border-style` | solid | Border style of the dropdown item |
+| `--gdd-item-border-color` | transparent | Border color of the dropdown item |
+| `--gdd-item-border-radius` | 0.25rem | Border radius of the dropdown item |
+| `--gdd-item-ring-width` | 1px | Width of the focus ring around the dropdown item |
+| `--gdd-item-ring-style` | solid | Style of the focus ring around the dropdown item |
+| `--gdd-item-ring-offset` | 0 | Offset of the focus ring around the dropdown item |
+| `--gdd-item-ring-color` | transparent | Color of the focus ring around the dropdown item |
+| `--gdd-item-divider-width` | 1px | Width of the divider between dropdown items |
+| `--gdd-item-divider-style` | solid | Style of the divider between dropdown items (e.g., solid, dashed) |
+| `--gdd-item-divider-color` | ![Color Preview](https://via.placeholder.com/20/030712?text=+) #4b5563 | Color of the item divider |
+
+
+## Examples
+
+- [use with `a-h/templ`](_examples/a-h-templ)
+- [icon only button](_examples/icon-only-button)
+- [theming](_examples/theming)
+- [positioning](_examples/positioning/)
+- [custom animations](_examples/custom-animations)
+- [custom-button-icon](_examples/custom-button-icon)
+- [use with `template/html`](_examples/go-html-template)
+
+## Contributing
+
+Contributions are welcome! Feel free to open an issue or submit a pull request.
+
+## License
+
+This project is licensed under the MIT License - see the LICENSE file for details.
diff --git a/_examples/a-h-templ/home.templ b/_examples/a-h-templ/home.templ
new file mode 100644
index 0000000..04405d4
--- /dev/null
+++ b/_examples/a-h-templ/home.templ
@@ -0,0 +1,33 @@
+package main
+
+import "github.com/indaco/gropdown"
+
+templ HomePage(dropdown *gropdown.DropdownBuilder) {
+
+
+
+
+
+ gropdown - a-h/templ
+
+ @gropdown.GropdownCSS()
+
+
+
+
+
+
+ @dropdown.Render()
+
+
+ @gropdown.GropdownJS(dropdown.Dropdown())
+
+
+}
diff --git a/_examples/a-h-templ/main.go b/_examples/a-h-templ/main.go
new file mode 100644
index 0000000..29aa884
--- /dev/null
+++ b/_examples/a-h-templ/main.go
@@ -0,0 +1,57 @@
+package main
+
+import (
+ "log"
+ "net/http"
+ "os"
+
+ "github.com/a-h/templ"
+ "github.com/indaco/gropdown"
+)
+
+const (
+ profileIcon = `
+
+
+`
+ settingsIcon = `
+
+
+`
+
+ globeIcon = `
+
+ `
+
+ clickIcon = `
+
+
+`
+)
+
+func HandleHome(w http.ResponseWriter, r *http.Request) {
+ button := gropdown.DropdownButton{Label: "Menu"}
+ items := []gropdown.DropdownItem{
+ {Label: "Profile", Href: "/profile", Icon: profileIcon},
+ {Label: "Settings", Href: "/settings", Icon: settingsIcon},
+ {Divider: true},
+ {Label: "GitHub", Href: "https://github.com", External: true, Icon: globeIcon},
+ {Divider: true},
+ {Label: "Button", Icon: clickIcon, Attrs: templ.Attributes{"onclick": "alert('Hello gropdown');"}},
+ }
+ dropdown := gropdown.NewDropdownBuilder().WithButton(button).WithItems(items)
+
+ templ.Handler(HomePage(dropdown)).ServeHTTP(w, r)
+}
+
+func main() {
+ mux := http.NewServeMux()
+ mux.HandleFunc("GET /", HandleHome)
+
+ port := ":3300"
+ log.Printf("Listening on %s", port)
+ if err := http.ListenAndServe(port, mux); err != nil {
+ log.Printf("failed to start server: %v", err)
+ os.Exit(1)
+ }
+}
diff --git a/_examples/button-icon/home.templ b/_examples/button-icon/home.templ
new file mode 100644
index 0000000..266ccc3
--- /dev/null
+++ b/_examples/button-icon/home.templ
@@ -0,0 +1,22 @@
+package main
+
+import "github.com/indaco/gropdown"
+
+templ HomePage(dropdown *gropdown.DropdownBuilder) {
+
+
+
+
+
+ gropdown - a-h/templ
+
+ @gropdown.GropdownCSS()
+
+
+
+ @dropdown.Render()
+
+ @gropdown.GropdownJS(dropdown)
+
+
+}
diff --git a/_examples/button-icon/main.go b/_examples/button-icon/main.go
new file mode 100644
index 0000000..b1516d2
--- /dev/null
+++ b/_examples/button-icon/main.go
@@ -0,0 +1,36 @@
+package main
+
+import (
+ "github.com/a-h/templ"
+ "github.com/indaco/gropdown"
+ "log"
+ "net/http"
+ "os"
+)
+
+func HandleHome(w http.ResponseWriter, r *http.Request) {
+ buttonIcon := `
+
+ `
+ button := gropdown.DropdownButton{Label: "Menu", Icon: buttonIcon}
+ items := []gropdown.DropdownItem{
+ {Label: "Google", Href: "https://www.google.com"},
+ {Label: "GitHub", Href: "https://github.com"},
+ {Label: "Button"},
+ }
+ dropdown := gropdown.NewDropdownBuilder().WithButton(button).WithItems(items)
+
+ templ.Handler(HomePage(dropdown)).ServeHTTP(w, r)
+}
+
+func main() {
+ mux := http.NewServeMux()
+ mux.HandleFunc("GET /", HandleHome)
+
+ port := ":3300"
+ log.Printf("Listening on %s", port)
+ if err := http.ListenAndServe(port, mux); err != nil {
+ log.Printf("failed to start server: %v", err)
+ os.Exit(1)
+ }
+}
diff --git a/_examples/custom-animations/home.templ b/_examples/custom-animations/home.templ
new file mode 100644
index 0000000..77d5207
--- /dev/null
+++ b/_examples/custom-animations/home.templ
@@ -0,0 +1,46 @@
+package main
+
+import "github.com/indaco/gropdown"
+
+templ HomePage(dropdown *gropdown.DropdownBuilder) {
+
+
+
+
+
+ gropdown - custom animations
+
+ @gropdown.GropdownCSS()
+
+
+
+
+
+
+
+
+ @dropdown.Render()
+
+
+ @gropdown.GropdownJS(dropdown.Dropdown())
+
+
+}
diff --git a/_examples/custom-animations/main.go b/_examples/custom-animations/main.go
new file mode 100644
index 0000000..5365f39
--- /dev/null
+++ b/_examples/custom-animations/main.go
@@ -0,0 +1,35 @@
+package main
+
+import (
+ "log"
+ "net/http"
+ "os"
+
+ "github.com/a-h/templ"
+ "github.com/indaco/gropdown"
+)
+
+func HandleHome(w http.ResponseWriter, r *http.Request) {
+ button := gropdown.DropdownButton{Label: "Menu"}
+ items := []gropdown.DropdownItem{
+ {Label: "Settings", Href: "/settings"},
+ {Label: "GitHub", Href: "https://github.com", External: true},
+ {Divider: true},
+ {Label: "Button", Attrs: templ.Attributes{"onclick": "alert('Hello gropdown');"}},
+ }
+ dropdown := gropdown.NewDropdownBuilder().WithButton(button).WithItems(items)
+
+ templ.Handler(HomePage(dropdown)).ServeHTTP(w, r)
+}
+
+func main() {
+ mux := http.NewServeMux()
+ mux.HandleFunc("GET /", HandleHome)
+
+ port := ":3300"
+ log.Printf("Listening on %s", port)
+ if err := http.ListenAndServe(port, mux); err != nil {
+ log.Printf("failed to start server: %v", err)
+ os.Exit(1)
+ }
+}
diff --git a/_examples/custom-button-icon/home.templ b/_examples/custom-button-icon/home.templ
new file mode 100644
index 0000000..8ad8b41
--- /dev/null
+++ b/_examples/custom-button-icon/home.templ
@@ -0,0 +1,33 @@
+package main
+
+import "github.com/indaco/gropdown"
+
+templ HomePage(dropdown *gropdown.DropdownBuilder) {
+
+
+
+
+
+ gropdown - custom button icon
+
+ @gropdown.GropdownCSS()
+
+
+
+
+
+
+ @dropdown.Render()
+
+
+ @gropdown.GropdownJS(dropdown.Dropdown())
+
+
+}
diff --git a/_examples/custom-button-icon/main.go b/_examples/custom-button-icon/main.go
new file mode 100644
index 0000000..b1e4af2
--- /dev/null
+++ b/_examples/custom-button-icon/main.go
@@ -0,0 +1,38 @@
+package main
+
+import (
+ "log"
+ "net/http"
+ "os"
+
+ "github.com/a-h/templ"
+ "github.com/indaco/gropdown"
+)
+
+func HandleHome(w http.ResponseWriter, r *http.Request) {
+ buttonIcon := `
+
+ `
+ button := gropdown.DropdownButton{Label: "Menu", Icon: buttonIcon}
+ items := []gropdown.DropdownItem{
+ {Label: "Settings", Href: "/settings"},
+ {Label: "GitHub", Href: "https://github.com", External: true},
+ {Divider: true},
+ {Label: "Button", Attrs: templ.Attributes{"onclick": "alert('Hello gropdown');"}},
+ }
+ dropdown := gropdown.NewDropdownBuilder().WithButton(button).WithItems(items)
+
+ templ.Handler(HomePage(dropdown)).ServeHTTP(w, r)
+}
+
+func main() {
+ mux := http.NewServeMux()
+ mux.HandleFunc("GET /", HandleHome)
+
+ port := ":3300"
+ log.Printf("Listening on %s", port)
+ if err := http.ListenAndServe(port, mux); err != nil {
+ log.Printf("failed to start server: %v", err)
+ os.Exit(1)
+ }
+}
diff --git a/_examples/custom-icons/main.go b/_examples/custom-icons/main.go
new file mode 100644
index 0000000..41e7f16
--- /dev/null
+++ b/_examples/custom-icons/main.go
@@ -0,0 +1,7 @@
+package main
+
+import "fmt"
+
+func main() {
+ fmt.Println("main")
+}
diff --git a/_examples/events/home.templ b/_examples/events/home.templ
new file mode 100644
index 0000000..2c04704
--- /dev/null
+++ b/_examples/events/home.templ
@@ -0,0 +1,26 @@
+package main
+
+import "github.com/indaco/gropdown"
+
+script console() {
+ console.log("I'm the console script in templ file")
+}
+
+templ HomePage(dropdown *gropdown.DropdownBuilder) {
+
+
+
+
+
+ gropdown - a-h/templ
+
+ @gropdown.GropdownCSS()
+
+
+
+ @dropdown.Render()
+
+ @gropdown.GropdownJS(dropdown)
+
+
+}
diff --git a/_examples/events/main.go b/_examples/events/main.go
new file mode 100644
index 0000000..751dee1
--- /dev/null
+++ b/_examples/events/main.go
@@ -0,0 +1,34 @@
+package main
+
+import (
+ "github.com/a-h/templ"
+ "github.com/indaco/gropdown"
+ "log"
+ "net/http"
+ "os"
+)
+
+func HandleHome(w http.ResponseWriter, r *http.Request) {
+ button := gropdown.DropdownButton{Label: "Menu"}
+ items := []gropdown.DropdownItem{
+ {Label: "Settings", Href: "/settings"},
+ {Label: "Profile", Href: "https://github.com", External: true},
+ {Label: "Button", Attrs: templ.Attributes{"onclick": "alert('Hello gropdown');"}},
+ }
+ dropdown := gropdown.NewDropdownBuilder().WithButton(button).WithItems(items)
+
+ templ.Handler(HomePage(dropdown)).ServeHTTP(w, r)
+
+}
+
+func main() {
+ mux := http.NewServeMux()
+ mux.HandleFunc("GET /", HandleHome)
+
+ port := ":3300"
+ log.Printf("Listening on %s", port)
+ if err := http.ListenAndServe(port, mux); err != nil {
+ log.Printf("failed to start server: %v", err)
+ os.Exit(1)
+ }
+}
diff --git a/_examples/go-html-template/main.go b/_examples/go-html-template/main.go
new file mode 100644
index 0000000..a54ae11
--- /dev/null
+++ b/_examples/go-html-template/main.go
@@ -0,0 +1,104 @@
+package main
+
+import (
+ "html/template"
+ "log"
+ "net/http"
+ "os"
+
+ "github.com/a-h/templ"
+ "github.com/indaco/gropdown"
+)
+
+// PageData represents the data to be rendered in the HTML template
+type PageData struct {
+ Dropdown DropdownComponent
+}
+
+type DropdownComponent struct {
+ CSS template.HTML
+ HTML template.HTML
+ JS template.HTML
+}
+
+// HandleHome is the handler function for the home page "/"
+func HandleHome(w http.ResponseWriter, r *http.Request) {
+ button := gropdown.DropdownButton{Label: "Menu"}
+ items := []gropdown.DropdownItem{
+ {Label: "Settings", Href: "/settings"},
+ {Label: "GitHub", Href: "https://github.com", External: true},
+ {Divider: true},
+ {Label: "Button", Attrs: templ.Attributes{"onclick": "alert('Hello gropdown');"}},
+ }
+ dropdownBuilder := gropdown.NewDropdownBuilder().WithButton(button).WithItems(items)
+
+ dropdownHTMLGenerator := gropdown.NewHTMLGenerator()
+
+ // Render the needed CSS for dropdown component as template.HTML
+ dropdownCSS, _ := dropdownHTMLGenerator.GropdownCSSToGoHTML()
+
+ // Render the dropdown component into a template.HTML
+ dropdownHtml, err := dropdownHTMLGenerator.Render(dropdownBuilder)
+
+ // Render the needed JS for dropdown component as template.HTML
+ dropdownJS, _ := dropdownHTMLGenerator.GropdownJSToGoHTML(dropdownBuilder.Dropdown())
+
+ data := PageData{
+ Dropdown: DropdownComponent{
+ CSS: dropdownCSS,
+ HTML: dropdownHtml,
+ JS: dropdownJS,
+ },
+ }
+
+ // Parse the HTML template
+ tmpl := template.Must(template.New("index").Parse(`
+
+
+
+
+
+ gropdown - template/html
+
+ {{ .Dropdown.CSS }}
+
+
+
+
+
+
+ {{ .Dropdown.HTML }}
+
+
+ {{ .Dropdown.JS }}
+
+
+ `))
+
+ // Execute the template with the provided data and write the output to the response writer
+ err = tmpl.Execute(w, data)
+ if err != nil {
+ http.Error(w, "failed to render template", http.StatusInternalServerError)
+ log.Printf("failed to render template: %v", err)
+ return
+ }
+}
+
+func main() {
+ mux := http.NewServeMux()
+ mux.HandleFunc("GET /", HandleHome)
+
+ port := ":3300"
+ log.Printf("Listening on %s", port)
+ if err := http.ListenAndServe(port, mux); err != nil {
+ log.Printf("failed to start server: %v", err)
+ os.Exit(1)
+ }
+}
diff --git a/_examples/icon-only-button/home.templ b/_examples/icon-only-button/home.templ
new file mode 100644
index 0000000..9fe6d93
--- /dev/null
+++ b/_examples/icon-only-button/home.templ
@@ -0,0 +1,33 @@
+package main
+
+import "github.com/indaco/gropdown"
+
+templ HomePage(dropdown *gropdown.DropdownBuilder) {
+
+
+
+
+
+ gropdown - icon only button
+
+ @gropdown.GropdownCSS()
+
+
+
+
+
+
+ @dropdown.Render()
+
+
+ @gropdown.GropdownJS(dropdown.Dropdown())
+
+
+}
diff --git a/_examples/icon-only-button/main.go b/_examples/icon-only-button/main.go
new file mode 100644
index 0000000..b3c0186
--- /dev/null
+++ b/_examples/icon-only-button/main.go
@@ -0,0 +1,35 @@
+package main
+
+import (
+ "log"
+ "net/http"
+ "os"
+
+ "github.com/a-h/templ"
+ "github.com/indaco/gropdown"
+)
+
+func HandleHome(w http.ResponseWriter, r *http.Request) {
+ button := gropdown.DropdownButton{}
+ items := []gropdown.DropdownItem{
+ {Label: "Settings", Href: "/settings"},
+ {Label: "GitHub", Href: "https://github.com", External: true},
+ {Divider: true},
+ {Label: "Button", Attrs: templ.Attributes{"onclick": "alert('Hello gropdown');"}},
+ }
+ dropdown := gropdown.NewDropdownBuilder().WithButton(button).WithItems(items)
+
+ templ.Handler(HomePage(dropdown)).ServeHTTP(w, r)
+}
+
+func main() {
+ mux := http.NewServeMux()
+ mux.HandleFunc("GET /", HandleHome)
+
+ port := ":3300"
+ log.Printf("Listening on %s", port)
+ if err := http.ListenAndServe(port, mux); err != nil {
+ log.Printf("failed to start server: %v", err)
+ os.Exit(1)
+ }
+}
diff --git a/_examples/item-icons/home.templ b/_examples/item-icons/home.templ
new file mode 100644
index 0000000..5ddec88
--- /dev/null
+++ b/_examples/item-icons/home.templ
@@ -0,0 +1,22 @@
+package main
+
+import "github.com/indaco/gropdown"
+
+templ HomePage(dropdown *gropdown.DropdownBuilder) {
+
+
+
+
+
+ gropdown - item icons
+
+ @gropdown.GropdownCSS()
+
+
+
+ @dropdown.Render()
+
+ @gropdown.GropdownJS(dropdown.Dropdown())
+
+
+}
diff --git a/_examples/item-icons/main.go b/_examples/item-icons/main.go
new file mode 100644
index 0000000..ffd3c47
--- /dev/null
+++ b/_examples/item-icons/main.go
@@ -0,0 +1,50 @@
+package main
+
+import (
+ "log"
+ "net/http"
+ "os"
+
+ "github.com/a-h/templ"
+ "github.com/indaco/gropdown"
+)
+
+const (
+ profileIcon = `
+
+
+`
+ settingsIcon = `
+
+
+`
+
+ globeIcon = `
+
+ `
+)
+
+func HandleHome(w http.ResponseWriter, r *http.Request) {
+ button := gropdown.DropdownButton{Label: "Menu"}
+ items := []gropdown.DropdownItem{
+ {Label: "Profile", Href: "/profile", Icon: profileIcon},
+ {Label: "Settings", Href: "/settings", Icon: settingsIcon},
+ {Divider: true},
+ {Label: "GitHub", Href: "https://github.com", External: true, Icon: globeIcon},
+ }
+ dropdown := gropdown.NewDropdownBuilder().WithButton(button).WithItems(items)
+
+ templ.Handler(HomePage(dropdown)).ServeHTTP(w, r)
+}
+
+func main() {
+ mux := http.NewServeMux()
+ mux.HandleFunc("GET /", HandleHome)
+
+ port := ":3300"
+ log.Printf("Listening on %s", port)
+ if err := http.ListenAndServe(port, mux); err != nil {
+ log.Printf("failed to start server: %v", err)
+ os.Exit(1)
+ }
+}
diff --git a/_examples/positioning/home.templ b/_examples/positioning/home.templ
new file mode 100644
index 0000000..28f095e
--- /dev/null
+++ b/_examples/positioning/home.templ
@@ -0,0 +1,33 @@
+package main
+
+import "github.com/indaco/gropdown"
+
+templ HomePage(dropdown *gropdown.DropdownBuilder) {
+
+
+
+
+
+ gropdown - positioning
+
+ @gropdown.GropdownCSS()
+
+
+
+
+
+
+ @dropdown.Render()
+
+
+ @gropdown.GropdownJS(dropdown.Dropdown())
+
+
+}
diff --git a/_examples/positioning/main.go b/_examples/positioning/main.go
new file mode 100644
index 0000000..3c6841a
--- /dev/null
+++ b/_examples/positioning/main.go
@@ -0,0 +1,38 @@
+package main
+
+import (
+ "log"
+ "net/http"
+ "os"
+
+ "github.com/a-h/templ"
+ "github.com/indaco/gropdown"
+)
+
+func HandleHome(w http.ResponseWriter, r *http.Request) {
+ button := gropdown.DropdownButton{Label: "Menu"}
+ items := []gropdown.DropdownItem{
+ {Label: "Settings", Href: "/settings"},
+ {Label: "GitHub", Href: "https://github.com", External: true},
+ {Divider: true},
+ {Label: "Button", Attrs: templ.Attributes{"onclick": "alert('Hello gropdown');"}},
+ }
+ dropdown := gropdown.NewDropdownBuilder().
+ SetPosition(gropdown.Right).
+ WithButton(button).
+ WithItems(items)
+
+ templ.Handler(HomePage(dropdown)).ServeHTTP(w, r)
+}
+
+func main() {
+ mux := http.NewServeMux()
+ mux.HandleFunc("GET /", HandleHome)
+
+ port := ":3300"
+ log.Printf("Listening on %s", port)
+ if err := http.ListenAndServe(port, mux); err != nil {
+ log.Printf("failed to start server: %v", err)
+ os.Exit(1)
+ }
+}
diff --git a/_examples/theming/home.templ b/_examples/theming/home.templ
new file mode 100644
index 0000000..283dd5d
--- /dev/null
+++ b/_examples/theming/home.templ
@@ -0,0 +1,50 @@
+package main
+
+import "github.com/indaco/gropdown"
+
+templ HomePage(dropdown *gropdown.DropdownBuilder) {
+
+
+
+
+
+ gropdown - theming
+
+ @gropdown.GropdownCSS()
+
+
+
+
+
+
+ @dropdown.Render()
+
+
+ @gropdown.GropdownJS(dropdown.Dropdown())
+
+
+}
diff --git a/_examples/theming/main.go b/_examples/theming/main.go
new file mode 100644
index 0000000..ffd3c47
--- /dev/null
+++ b/_examples/theming/main.go
@@ -0,0 +1,50 @@
+package main
+
+import (
+ "log"
+ "net/http"
+ "os"
+
+ "github.com/a-h/templ"
+ "github.com/indaco/gropdown"
+)
+
+const (
+ profileIcon = `
+
+
+`
+ settingsIcon = `
+
+
+`
+
+ globeIcon = `
+
+ `
+)
+
+func HandleHome(w http.ResponseWriter, r *http.Request) {
+ button := gropdown.DropdownButton{Label: "Menu"}
+ items := []gropdown.DropdownItem{
+ {Label: "Profile", Href: "/profile", Icon: profileIcon},
+ {Label: "Settings", Href: "/settings", Icon: settingsIcon},
+ {Divider: true},
+ {Label: "GitHub", Href: "https://github.com", External: true, Icon: globeIcon},
+ }
+ dropdown := gropdown.NewDropdownBuilder().WithButton(button).WithItems(items)
+
+ templ.Handler(HomePage(dropdown)).ServeHTTP(w, r)
+}
+
+func main() {
+ mux := http.NewServeMux()
+ mux.HandleFunc("GET /", HandleHome)
+
+ port := ":3300"
+ log.Printf("Listening on %s", port)
+ if err := http.ListenAndServe(port, mux); err != nil {
+ log.Printf("failed to start server: %v", err)
+ os.Exit(1)
+ }
+}
diff --git a/constants.go b/constants.go
new file mode 100644
index 0000000..5e7029d
--- /dev/null
+++ b/constants.go
@@ -0,0 +1,9 @@
+package gropdown
+
+// Position constants define where the dropdown content will appear on the screen.
+const (
+ Top Position = "top"
+ Right Position = "right"
+ Bottom Position = "bottom"
+ Left Position = "left"
+)
diff --git a/go.mod b/go.mod
new file mode 100644
index 0000000..2c5cf9a
--- /dev/null
+++ b/go.mod
@@ -0,0 +1,5 @@
+module github.com/indaco/gropdown
+
+go 1.22.1
+
+require github.com/a-h/templ v0.2.598
diff --git a/go.sum b/go.sum
new file mode 100644
index 0000000..0e72287
--- /dev/null
+++ b/go.sum
@@ -0,0 +1,4 @@
+github.com/a-h/templ v0.2.598 h1:6jMIHv6wQZvdPxTuv87erW4RqN/FPU0wk7ZHN5wVuuo=
+github.com/a-h/templ v0.2.598/go.mod h1:SA7mtYwVEajbIXFRh3vKdYm/4FYyLQAtPH1+KxzGPA8=
+github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
+github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
diff --git a/gohtml.go b/gohtml.go
new file mode 100644
index 0000000..9df6d39
--- /dev/null
+++ b/gohtml.go
@@ -0,0 +1,45 @@
+// Package gropdown - Helpers for using dropdown with `template/html`
+package gropdown
+
+import (
+ "context"
+ "fmt"
+ "github.com/a-h/templ"
+ "html/template"
+)
+
+// HTMLGenerator provides functions for generating HTML code for dropdown.
+type HTMLGenerator struct{}
+
+// NewHTMLGenerator creates a new instance of HTMLGenerator.
+func NewHTMLGenerator() *HTMLGenerator {
+ return &HTMLGenerator{}
+}
+
+// GropdownCSSToGoHTML generates HTML code for the dropdown CSS and returns it as a template.HTML.
+func (g *HTMLGenerator) GropdownCSSToGoHTML() (template.HTML, error) {
+ html, err := templ.ToGoHTML(context.Background(), GropdownCSS())
+ if err != nil {
+ return "", fmt.Errorf("failed to generate dropdown CSS: %v", err)
+ }
+ return html, nil
+}
+
+// GropdownJSToGoHTML generates HTML code for the dropdown JS and returns it as a template.HTML.
+func (g *HTMLGenerator) GropdownJSToGoHTML(dropdown *Dropdown) (template.HTML, error) {
+ html, err := templ.ToGoHTML(context.Background(), GropdownJS(dropdown))
+ if err != nil {
+ return "", fmt.Errorf("failed to generate dropdown JS: %v", err)
+ }
+ return html, nil
+}
+
+// Render generates HTML code for displaying the dropdown and returns it as a template.HTML.
+func (g *HTMLGenerator) Render(dd *DropdownBuilder) (template.HTML, error) {
+ // Generate HTML code for displaying the toast.
+ html, err := templ.ToGoHTML(context.Background(), dd.Render())
+ if err != nil {
+ return "", fmt.Errorf("failed to generate dropdown HTML: %v", err)
+ }
+ return html, nil
+}
diff --git a/gropdown-button.templ b/gropdown-button.templ
new file mode 100644
index 0000000..0c23c85
--- /dev/null
+++ b/gropdown-button.templ
@@ -0,0 +1,72 @@
+package gropdown
+
+const defaultButtonIcon = `
+
+
+`
+
+templ button(btn DropdownButton) {
+
+ if btn.Label != "" {
+ { btn.Label }
+ }
+ Open/Close icon
+
+ if btn.Icon != "" {
+ @templ.Raw(btn.Icon)
+ } else {
+ @templ.Raw(defaultButtonIcon)
+ }
+
+
+}
+
+css gddButton() {
+ cursor: pointer;
+ min-width: var(--gdd-button-min-w);
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ gap: var(--gdd-button-icon-space);
+ margin: 0;
+ padding: var(--gdd-button-py) var(--gdd-button-px);
+ color: var(--gdd-button-color);
+ border-width: var(--gdd-button-border-width);
+ border-style: var(--gdd-button-border-style);
+ border-color: var(--gdd-button-border-color);
+ border-radius: var(--gdd-button-border-radius);
+ background-color: var(--gdd-button-bg-color);
+ font-family: var(--gdd-button-font-family);
+ font-size: var(--gdd-button-font-size);
+ font-weight: var(--gdd-button-font-weight);
+ line-height: var(--gdd-button-line-height);
+ letter-spacing: var(--gdd-button-letter-spacing);
+ text-transform: none;
+ text-decoration-line: var(--_text-decoration-line);
+ transition: var(--gdd-button-transition-property) var(--gdd-button-transition-duration) var(--gdd-button-transition-timing-function);
+}
+
+css gddButton_Icon() {
+ display: inline-flex;
+ align-items: center;
+ flex-shrink: 0;
+ width: 1em;
+ height: 1em;
+}
+
+css gddButton_IconOnly() {
+ min-width: 2.5rem;
+ padding: 0;
+ width: 2.5rem;
+ height: 2.5rem;
+ border-radius: 1e5px;
+ aspect-ratio: 1;
+}
diff --git a/gropdown-button_templ.go b/gropdown-button_templ.go
new file mode 100644
index 0000000..1fb43ce
--- /dev/null
+++ b/gropdown-button_templ.go
@@ -0,0 +1,210 @@
+// Code generated by templ - DO NOT EDIT.
+
+// templ: version: v0.2.598
+package gropdown
+
+//lint:file-ignore SA4006 This context is only used if a nested component is present.
+
+import "github.com/a-h/templ"
+import "context"
+import "io"
+import "bytes"
+import "strings"
+
+const defaultButtonIcon = `
+
+
+`
+
+func button(btn DropdownButton) templ.Component {
+ return templ.ComponentFunc(func(ctx context.Context, templ_7745c5c3_W io.Writer) (templ_7745c5c3_Err error) {
+ templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templ_7745c5c3_W.(*bytes.Buffer)
+ if !templ_7745c5c3_IsBuffer {
+ templ_7745c5c3_Buffer = templ.GetBuffer()
+ defer templ.ReleaseBuffer(templ_7745c5c3_Buffer)
+ }
+ ctx = templ.InitializeContext(ctx)
+ templ_7745c5c3_Var1 := templ.GetChildren(ctx)
+ if templ_7745c5c3_Var1 == nil {
+ templ_7745c5c3_Var1 = templ.NopComponent
+ }
+ ctx = templ.ClearChildren(ctx)
+ var templ_7745c5c3_Var2 = []any{gddButton(), templ.KV(gddButton_IconOnly(), btn.Label == "")}
+ templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var2...)
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ if btn.Label != "" {
+ var templ_7745c5c3_Var3 string
+ templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(btn.Label)
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `gropdown-button.templ`, Line: 18, Col: 14}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(" ")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ }
+ var templ_7745c5c3_Var4 = []any{gttSrOnly()}
+ templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var4...)
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("Open/Close icon ")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var5 = []any{gddButton_Icon()}
+ templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var5...)
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ if btn.Icon != "" {
+ templ_7745c5c3_Err = templ.Raw(btn.Icon).Render(ctx, templ_7745c5c3_Buffer)
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ } else {
+ templ_7745c5c3_Err = templ.Raw(defaultButtonIcon).Render(ctx, templ_7745c5c3_Buffer)
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(" ")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ if !templ_7745c5c3_IsBuffer {
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteTo(templ_7745c5c3_W)
+ }
+ return templ_7745c5c3_Err
+ })
+}
+
+func gddButton() templ.CSSClass {
+ var templ_7745c5c3_CSSBuilder strings.Builder
+ templ_7745c5c3_CSSBuilder.WriteString(`cursor:pointer;`)
+ templ_7745c5c3_CSSBuilder.WriteString(`min-width:var(--gdd-button-min-w);`)
+ templ_7745c5c3_CSSBuilder.WriteString(`display:inline-flex;`)
+ templ_7745c5c3_CSSBuilder.WriteString(`align-items:center;`)
+ templ_7745c5c3_CSSBuilder.WriteString(`justify-content:center;`)
+ templ_7745c5c3_CSSBuilder.WriteString(`gap:var(--gdd-button-icon-space);`)
+ templ_7745c5c3_CSSBuilder.WriteString(`margin:0;`)
+ templ_7745c5c3_CSSBuilder.WriteString(`padding:var(--gdd-button-py) var(--gdd-button-px);`)
+ templ_7745c5c3_CSSBuilder.WriteString(`color:var(--gdd-button-color);`)
+ templ_7745c5c3_CSSBuilder.WriteString(`border-width:var(--gdd-button-border-width);`)
+ templ_7745c5c3_CSSBuilder.WriteString(`border-style:var(--gdd-button-border-style);`)
+ templ_7745c5c3_CSSBuilder.WriteString(`border-color:var(--gdd-button-border-color);`)
+ templ_7745c5c3_CSSBuilder.WriteString(`border-radius:var(--gdd-button-border-radius);`)
+ templ_7745c5c3_CSSBuilder.WriteString(`background-color:var(--gdd-button-bg-color);`)
+ templ_7745c5c3_CSSBuilder.WriteString(`font-family:var(--gdd-button-font-family);`)
+ templ_7745c5c3_CSSBuilder.WriteString(`font-size:var(--gdd-button-font-size);`)
+ templ_7745c5c3_CSSBuilder.WriteString(`font-weight:var(--gdd-button-font-weight);`)
+ templ_7745c5c3_CSSBuilder.WriteString(`line-height:var(--gdd-button-line-height);`)
+ templ_7745c5c3_CSSBuilder.WriteString(`letter-spacing:var(--gdd-button-letter-spacing);`)
+ templ_7745c5c3_CSSBuilder.WriteString(`text-transform:none;`)
+ templ_7745c5c3_CSSBuilder.WriteString(`text-decoration-line:var(--_text-decoration-line);`)
+ templ_7745c5c3_CSSBuilder.WriteString(`transition:var(--gdd-button-transition-property) var(--gdd-button-transition-duration) var(--gdd-button-transition-timing-function);`)
+ templ_7745c5c3_CSSID := templ.CSSID(`gddButton`, templ_7745c5c3_CSSBuilder.String())
+ return templ.ComponentCSSClass{
+ ID: templ_7745c5c3_CSSID,
+ Class: templ.SafeCSS(`.` + templ_7745c5c3_CSSID + `{` + templ_7745c5c3_CSSBuilder.String() + `}`),
+ }
+}
+
+func gddButton_Icon() templ.CSSClass {
+ var templ_7745c5c3_CSSBuilder strings.Builder
+ templ_7745c5c3_CSSBuilder.WriteString(`display:inline-flex;`)
+ templ_7745c5c3_CSSBuilder.WriteString(`align-items:center;`)
+ templ_7745c5c3_CSSBuilder.WriteString(`flex-shrink:0;`)
+ templ_7745c5c3_CSSBuilder.WriteString(`width:1em;`)
+ templ_7745c5c3_CSSBuilder.WriteString(`height:1em;`)
+ templ_7745c5c3_CSSID := templ.CSSID(`gddButton_Icon`, templ_7745c5c3_CSSBuilder.String())
+ return templ.ComponentCSSClass{
+ ID: templ_7745c5c3_CSSID,
+ Class: templ.SafeCSS(`.` + templ_7745c5c3_CSSID + `{` + templ_7745c5c3_CSSBuilder.String() + `}`),
+ }
+}
+
+func gddButton_IconOnly() templ.CSSClass {
+ var templ_7745c5c3_CSSBuilder strings.Builder
+ templ_7745c5c3_CSSBuilder.WriteString(`min-width:2.5rem;`)
+ templ_7745c5c3_CSSBuilder.WriteString(`padding:0;`)
+ templ_7745c5c3_CSSBuilder.WriteString(`width:2.5rem;`)
+ templ_7745c5c3_CSSBuilder.WriteString(`height:2.5rem;`)
+ templ_7745c5c3_CSSBuilder.WriteString(`border-radius:1e5px;`)
+ templ_7745c5c3_CSSBuilder.WriteString(`aspect-ratio:1;`)
+ templ_7745c5c3_CSSID := templ.CSSID(`gddButton_IconOnly`, templ_7745c5c3_CSSBuilder.String())
+ return templ.ComponentCSSClass{
+ ID: templ_7745c5c3_CSSID,
+ Class: templ.SafeCSS(`.` + templ_7745c5c3_CSSID + `{` + templ_7745c5c3_CSSBuilder.String() + `}`),
+ }
+}
diff --git a/gropdown-content-item.templ b/gropdown-content-item.templ
new file mode 100644
index 0000000..d42d2cf
--- /dev/null
+++ b/gropdown-content-item.templ
@@ -0,0 +1,87 @@
+package gropdown
+
+const defaultExternalLinkIcon = ` `
+
+templ contentItem(item DropdownItem) {
+
+ if item.Divider {
+ @divider()
+ } else if item.Href != "" {
+ @linkItem(item)
+ } else {
+ @buttonItem(item)
+ }
+
+}
+
+templ linkItem(item DropdownItem) {
+
+ if item.Icon != "" {
+
+ @templ.Raw(item.Icon)
+
+ }
+ { item.Label }
+ if item.External {
+ @templ.Raw(defaultExternalLinkIcon)
+ }
+
+}
+
+templ buttonItem(item DropdownItem) {
+
+ if item.Icon != "" {
+
+ @templ.Raw(item.Icon)
+
+ }
+ { item.Label }
+
+}
+
+templ divider() {
+
+}
+
+css gddContent_Item() {
+ position: relative;
+ display: flex;
+ width: 100%;
+ text-decoration: none;
+ gap: var(--gdd-item-icon-space);
+ padding: var(--gdd-item-py) var(--gdd-item-px);
+ color: var(--gdd-item-color);
+ font-family: var(--gdd-item-font-family);
+ font-size: var(--gdd-item-font-size);
+ line-height: var(--gdd-item-line-height);
+ letter-spacing: var(--gdd-item-letter-spacing);
+ background-color: var(--gdd-item-bg-color);
+ border-width: var(--gdd-item-border-width);
+ border-style: var(--gdd-item-border-style);
+ border-color: var(--gdd-item-border-color);
+ border-radius: var(--gdd-item-border-radius);
+}
+
+css gddContent_ItemIcon() {
+ display: inline-flex;
+ align-items: center;
+ flex-shrink: 0;
+ width: 1.25em;
+ height: 1.25em;
+}
+
+css gddContent_ItemDivider() {
+ height: 0;
+ margin: 0.125rem 0;
+ overflow: hidden;
+ border-top: var(--gdd-item-divider-width) var(--gdd-item-divider-style) var(--gdd-item-divider-color);
+}
diff --git a/gropdown-content-item_templ.go b/gropdown-content-item_templ.go
new file mode 100644
index 0000000..d62dedf
--- /dev/null
+++ b/gropdown-content-item_templ.go
@@ -0,0 +1,342 @@
+// Code generated by templ - DO NOT EDIT.
+
+// templ: version: v0.2.598
+package gropdown
+
+//lint:file-ignore SA4006 This context is only used if a nested component is present.
+
+import "github.com/a-h/templ"
+import "context"
+import "io"
+import "bytes"
+import "strings"
+
+const defaultExternalLinkIcon = ` `
+
+func contentItem(item DropdownItem) templ.Component {
+ return templ.ComponentFunc(func(ctx context.Context, templ_7745c5c3_W io.Writer) (templ_7745c5c3_Err error) {
+ templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templ_7745c5c3_W.(*bytes.Buffer)
+ if !templ_7745c5c3_IsBuffer {
+ templ_7745c5c3_Buffer = templ.GetBuffer()
+ defer templ.ReleaseBuffer(templ_7745c5c3_Buffer)
+ }
+ ctx = templ.InitializeContext(ctx)
+ templ_7745c5c3_Var1 := templ.GetChildren(ctx)
+ if templ_7745c5c3_Var1 == nil {
+ templ_7745c5c3_Var1 = templ.NopComponent
+ }
+ ctx = templ.ClearChildren(ctx)
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ if item.Divider {
+ templ_7745c5c3_Err = divider().Render(ctx, templ_7745c5c3_Buffer)
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ } else if item.Href != "" {
+ templ_7745c5c3_Err = linkItem(item).Render(ctx, templ_7745c5c3_Buffer)
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ } else {
+ templ_7745c5c3_Err = buttonItem(item).Render(ctx, templ_7745c5c3_Buffer)
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(" ")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ if !templ_7745c5c3_IsBuffer {
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteTo(templ_7745c5c3_W)
+ }
+ return templ_7745c5c3_Err
+ })
+}
+
+func linkItem(item DropdownItem) templ.Component {
+ return templ.ComponentFunc(func(ctx context.Context, templ_7745c5c3_W io.Writer) (templ_7745c5c3_Err error) {
+ templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templ_7745c5c3_W.(*bytes.Buffer)
+ if !templ_7745c5c3_IsBuffer {
+ templ_7745c5c3_Buffer = templ.GetBuffer()
+ defer templ.ReleaseBuffer(templ_7745c5c3_Buffer)
+ }
+ ctx = templ.InitializeContext(ctx)
+ templ_7745c5c3_Var2 := templ.GetChildren(ctx)
+ if templ_7745c5c3_Var2 == nil {
+ templ_7745c5c3_Var2 = templ.NopComponent
+ }
+ ctx = templ.ClearChildren(ctx)
+ var templ_7745c5c3_Var3 = []any{gddContent_Item()}
+ templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var3...)
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ if item.Icon != "" {
+ var templ_7745c5c3_Var5 = []any{gddContent_ItemIcon()}
+ templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var5...)
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templ.Raw(item.Icon).Render(ctx, templ_7745c5c3_Buffer)
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("
")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ }
+ var templ_7745c5c3_Var6 string
+ templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(item.Label)
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `gropdown-content-item.templ`, Line: 32, Col: 14}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(" ")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ if item.External {
+ templ_7745c5c3_Err = templ.Raw(defaultExternalLinkIcon).Render(ctx, templ_7745c5c3_Buffer)
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(" ")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ if !templ_7745c5c3_IsBuffer {
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteTo(templ_7745c5c3_W)
+ }
+ return templ_7745c5c3_Err
+ })
+}
+
+func buttonItem(item DropdownItem) templ.Component {
+ return templ.ComponentFunc(func(ctx context.Context, templ_7745c5c3_W io.Writer) (templ_7745c5c3_Err error) {
+ templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templ_7745c5c3_W.(*bytes.Buffer)
+ if !templ_7745c5c3_IsBuffer {
+ templ_7745c5c3_Buffer = templ.GetBuffer()
+ defer templ.ReleaseBuffer(templ_7745c5c3_Buffer)
+ }
+ ctx = templ.InitializeContext(ctx)
+ templ_7745c5c3_Var7 := templ.GetChildren(ctx)
+ if templ_7745c5c3_Var7 == nil {
+ templ_7745c5c3_Var7 = templ.NopComponent
+ }
+ ctx = templ.ClearChildren(ctx)
+ var templ_7745c5c3_Var8 = []any{gddContent_Item()}
+ templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var8...)
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ if item.Icon != "" {
+ var templ_7745c5c3_Var9 = []any{gddContent_ItemIcon()}
+ templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var9...)
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templ.Raw(item.Icon).Render(ctx, templ_7745c5c3_Buffer)
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("
")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ }
+ var templ_7745c5c3_Var10 string
+ templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(item.Label)
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `gropdown-content-item.templ`, Line: 46, Col: 14}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(" ")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ if !templ_7745c5c3_IsBuffer {
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteTo(templ_7745c5c3_W)
+ }
+ return templ_7745c5c3_Err
+ })
+}
+
+func divider() templ.Component {
+ return templ.ComponentFunc(func(ctx context.Context, templ_7745c5c3_W io.Writer) (templ_7745c5c3_Err error) {
+ templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templ_7745c5c3_W.(*bytes.Buffer)
+ if !templ_7745c5c3_IsBuffer {
+ templ_7745c5c3_Buffer = templ.GetBuffer()
+ defer templ.ReleaseBuffer(templ_7745c5c3_Buffer)
+ }
+ ctx = templ.InitializeContext(ctx)
+ templ_7745c5c3_Var11 := templ.GetChildren(ctx)
+ if templ_7745c5c3_Var11 == nil {
+ templ_7745c5c3_Var11 = templ.NopComponent
+ }
+ ctx = templ.ClearChildren(ctx)
+ var templ_7745c5c3_Var12 = []any{gddContent_ItemDivider()}
+ templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var12...)
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("
")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ if !templ_7745c5c3_IsBuffer {
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteTo(templ_7745c5c3_W)
+ }
+ return templ_7745c5c3_Err
+ })
+}
+
+func gddContent_Item() templ.CSSClass {
+ var templ_7745c5c3_CSSBuilder strings.Builder
+ templ_7745c5c3_CSSBuilder.WriteString(`position:relative;`)
+ templ_7745c5c3_CSSBuilder.WriteString(`display:flex;`)
+ templ_7745c5c3_CSSBuilder.WriteString(`width:100%;`)
+ templ_7745c5c3_CSSBuilder.WriteString(`text-decoration:none;`)
+ templ_7745c5c3_CSSBuilder.WriteString(`gap:var(--gdd-item-icon-space);`)
+ templ_7745c5c3_CSSBuilder.WriteString(`padding:var(--gdd-item-py) var(--gdd-item-px);`)
+ templ_7745c5c3_CSSBuilder.WriteString(`color:var(--gdd-item-color);`)
+ templ_7745c5c3_CSSBuilder.WriteString(`font-family:var(--gdd-item-font-family);`)
+ templ_7745c5c3_CSSBuilder.WriteString(`font-size:var(--gdd-item-font-size);`)
+ templ_7745c5c3_CSSBuilder.WriteString(`line-height:var(--gdd-item-line-height);`)
+ templ_7745c5c3_CSSBuilder.WriteString(`letter-spacing:var(--gdd-item-letter-spacing);`)
+ templ_7745c5c3_CSSBuilder.WriteString(`background-color:var(--gdd-item-bg-color);`)
+ templ_7745c5c3_CSSBuilder.WriteString(`border-width:var(--gdd-item-border-width);`)
+ templ_7745c5c3_CSSBuilder.WriteString(`border-style:var(--gdd-item-border-style);`)
+ templ_7745c5c3_CSSBuilder.WriteString(`border-color:var(--gdd-item-border-color);`)
+ templ_7745c5c3_CSSBuilder.WriteString(`border-radius:var(--gdd-item-border-radius);`)
+ templ_7745c5c3_CSSID := templ.CSSID(`gddContent_Item`, templ_7745c5c3_CSSBuilder.String())
+ return templ.ComponentCSSClass{
+ ID: templ_7745c5c3_CSSID,
+ Class: templ.SafeCSS(`.` + templ_7745c5c3_CSSID + `{` + templ_7745c5c3_CSSBuilder.String() + `}`),
+ }
+}
+
+func gddContent_ItemIcon() templ.CSSClass {
+ var templ_7745c5c3_CSSBuilder strings.Builder
+ templ_7745c5c3_CSSBuilder.WriteString(`display:inline-flex;`)
+ templ_7745c5c3_CSSBuilder.WriteString(`align-items:center;`)
+ templ_7745c5c3_CSSBuilder.WriteString(`flex-shrink:0;`)
+ templ_7745c5c3_CSSBuilder.WriteString(`width:1.25em;`)
+ templ_7745c5c3_CSSBuilder.WriteString(`height:1.25em;`)
+ templ_7745c5c3_CSSID := templ.CSSID(`gddContent_ItemIcon`, templ_7745c5c3_CSSBuilder.String())
+ return templ.ComponentCSSClass{
+ ID: templ_7745c5c3_CSSID,
+ Class: templ.SafeCSS(`.` + templ_7745c5c3_CSSID + `{` + templ_7745c5c3_CSSBuilder.String() + `}`),
+ }
+}
+
+func gddContent_ItemDivider() templ.CSSClass {
+ var templ_7745c5c3_CSSBuilder strings.Builder
+ templ_7745c5c3_CSSBuilder.WriteString(`height:0;`)
+ templ_7745c5c3_CSSBuilder.WriteString(`margin:0.125rem 0;`)
+ templ_7745c5c3_CSSBuilder.WriteString(`overflow:hidden;`)
+ templ_7745c5c3_CSSBuilder.WriteString(`border-top:var(--gdd-item-divider-width) var(--gdd-item-divider-style) var(--gdd-item-divider-color);`)
+ templ_7745c5c3_CSSID := templ.CSSID(`gddContent_ItemDivider`, templ_7745c5c3_CSSBuilder.String())
+ return templ.ComponentCSSClass{
+ ID: templ_7745c5c3_CSSID,
+ Class: templ.SafeCSS(`.` + templ_7745c5c3_CSSID + `{` + templ_7745c5c3_CSSBuilder.String() + `}`),
+ }
+}
diff --git a/gropdown-content.templ b/gropdown-content.templ
new file mode 100644
index 0000000..305875b
--- /dev/null
+++ b/gropdown-content.templ
@@ -0,0 +1,39 @@
+package gropdown
+
+templ content(dropdown *Dropdown) {
+
+ for _, item := range dropdown.Items {
+ @contentItem(item)
+ }
+
+}
+
+css gddContent() {
+ position: absolute;
+ z-index: 10;
+ overflow: hidden;
+ list-style: none;
+ width: var(--gdd-content-w);
+ max-width: var(--gdd-content-max-w);
+ margin: var(--gdd-content-my) var(--gdd-content-mx);
+ padding: var(--gdd-content-py) var(--gdd-content-px);
+ background-color: var(--gdd-content-bg-color);
+ border-width: var(--gdd-content-border-width);
+ border-style: var(--gdd-content-border-style);
+ border-color: var(--gdd-content-border-color);
+ border-radius: var(--gdd-content-border-radius);
+ transition: opacity var(--gdd-content-animation-duration) var(--gdd-content-animation-timing-function);
+ animation-duration: var(--gdd-content-animation-duration);
+ animation-direction: var(--gdd-content-animation-direction);
+ animation-timing-function: var(--gdd-content-animation-timing-function);
+}
diff --git a/gropdown-content_templ.go b/gropdown-content_templ.go
new file mode 100644
index 0000000..5289374
--- /dev/null
+++ b/gropdown-content_templ.go
@@ -0,0 +1,116 @@
+// Code generated by templ - DO NOT EDIT.
+
+// templ: version: v0.2.598
+package gropdown
+
+//lint:file-ignore SA4006 This context is only used if a nested component is present.
+
+import "github.com/a-h/templ"
+import "context"
+import "io"
+import "bytes"
+import "strings"
+
+func content(dropdown *Dropdown) templ.Component {
+ return templ.ComponentFunc(func(ctx context.Context, templ_7745c5c3_W io.Writer) (templ_7745c5c3_Err error) {
+ templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templ_7745c5c3_W.(*bytes.Buffer)
+ if !templ_7745c5c3_IsBuffer {
+ templ_7745c5c3_Buffer = templ.GetBuffer()
+ defer templ.ReleaseBuffer(templ_7745c5c3_Buffer)
+ }
+ ctx = templ.InitializeContext(ctx)
+ templ_7745c5c3_Var1 := templ.GetChildren(ctx)
+ if templ_7745c5c3_Var1 == nil {
+ templ_7745c5c3_Var1 = templ.NopComponent
+ }
+ ctx = templ.ClearChildren(ctx)
+ var templ_7745c5c3_Var2 = []any{gddContent()}
+ templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var2...)
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ for _, item := range dropdown.Items {
+ templ_7745c5c3_Err = contentItem(item).Render(ctx, templ_7745c5c3_Buffer)
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(" ")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ if !templ_7745c5c3_IsBuffer {
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteTo(templ_7745c5c3_W)
+ }
+ return templ_7745c5c3_Err
+ })
+}
+
+func gddContent() templ.CSSClass {
+ var templ_7745c5c3_CSSBuilder strings.Builder
+ templ_7745c5c3_CSSBuilder.WriteString(`position:absolute;`)
+ templ_7745c5c3_CSSBuilder.WriteString(`z-index:10;`)
+ templ_7745c5c3_CSSBuilder.WriteString(`overflow:hidden;`)
+ templ_7745c5c3_CSSBuilder.WriteString(`list-style:none;`)
+ templ_7745c5c3_CSSBuilder.WriteString(`width:var(--gdd-content-w);`)
+ templ_7745c5c3_CSSBuilder.WriteString(`max-width:var(--gdd-content-max-w);`)
+ templ_7745c5c3_CSSBuilder.WriteString(`margin:var(--gdd-content-my) var(--gdd-content-mx);`)
+ templ_7745c5c3_CSSBuilder.WriteString(`padding:var(--gdd-content-py) var(--gdd-content-px);`)
+ templ_7745c5c3_CSSBuilder.WriteString(`background-color:var(--gdd-content-bg-color);`)
+ templ_7745c5c3_CSSBuilder.WriteString(`border-width:var(--gdd-content-border-width);`)
+ templ_7745c5c3_CSSBuilder.WriteString(`border-style:var(--gdd-content-border-style);`)
+ templ_7745c5c3_CSSBuilder.WriteString(`border-color:var(--gdd-content-border-color);`)
+ templ_7745c5c3_CSSBuilder.WriteString(`border-radius:var(--gdd-content-border-radius);`)
+ templ_7745c5c3_CSSBuilder.WriteString(`transition:opacity var(--gdd-content-animation-duration) var(--gdd-content-animation-timing-function);`)
+ templ_7745c5c3_CSSBuilder.WriteString(`animation-duration:var(--gdd-content-animation-duration);`)
+ templ_7745c5c3_CSSBuilder.WriteString(`animation-direction:var(--gdd-content-animation-direction);`)
+ templ_7745c5c3_CSSBuilder.WriteString(`animation-timing-function:var(--gdd-content-animation-timing-function);`)
+ templ_7745c5c3_CSSID := templ.CSSID(`gddContent`, templ_7745c5c3_CSSBuilder.String())
+ return templ.ComponentCSSClass{
+ ID: templ_7745c5c3_CSSID,
+ Class: templ.SafeCSS(`.` + templ_7745c5c3_CSSID + `{` + templ_7745c5c3_CSSBuilder.String() + `}`),
+ }
+}
diff --git a/gropdown-css.templ b/gropdown-css.templ
new file mode 100644
index 0000000..5f1e3c0
--- /dev/null
+++ b/gropdown-css.templ
@@ -0,0 +1,299 @@
+package gropdown
+
+templ GropdownCSS() {
+
+}
+
+css gttSrOnly() {
+ position: absolute;
+ width: 1px;
+ height: 1px;
+ padding: 0;
+ margin: -1px;
+ overflow: hidden;
+ clip: rect(0, 0, 0, 0);
+ white-space: nowrap;
+ border-width: 0;
+}
diff --git a/gropdown-css_templ.go b/gropdown-css_templ.go
new file mode 100644
index 0000000..ac606aa
--- /dev/null
+++ b/gropdown-css_templ.go
@@ -0,0 +1,54 @@
+// Code generated by templ - DO NOT EDIT.
+
+// templ: version: v0.2.598
+package gropdown
+
+//lint:file-ignore SA4006 This context is only used if a nested component is present.
+
+import "github.com/a-h/templ"
+import "context"
+import "io"
+import "bytes"
+import "strings"
+
+func GropdownCSS() templ.Component {
+ return templ.ComponentFunc(func(ctx context.Context, templ_7745c5c3_W io.Writer) (templ_7745c5c3_Err error) {
+ templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templ_7745c5c3_W.(*bytes.Buffer)
+ if !templ_7745c5c3_IsBuffer {
+ templ_7745c5c3_Buffer = templ.GetBuffer()
+ defer templ.ReleaseBuffer(templ_7745c5c3_Buffer)
+ }
+ ctx = templ.InitializeContext(ctx)
+ templ_7745c5c3_Var1 := templ.GetChildren(ctx)
+ if templ_7745c5c3_Var1 == nil {
+ templ_7745c5c3_Var1 = templ.NopComponent
+ }
+ ctx = templ.ClearChildren(ctx)
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ if !templ_7745c5c3_IsBuffer {
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteTo(templ_7745c5c3_W)
+ }
+ return templ_7745c5c3_Err
+ })
+}
+
+func gttSrOnly() templ.CSSClass {
+ var templ_7745c5c3_CSSBuilder strings.Builder
+ templ_7745c5c3_CSSBuilder.WriteString(`position:absolute;`)
+ templ_7745c5c3_CSSBuilder.WriteString(`width:1px;`)
+ templ_7745c5c3_CSSBuilder.WriteString(`height:1px;`)
+ templ_7745c5c3_CSSBuilder.WriteString(`padding:0;`)
+ templ_7745c5c3_CSSBuilder.WriteString(`margin:-1px;`)
+ templ_7745c5c3_CSSBuilder.WriteString(`overflow:hidden;`)
+ templ_7745c5c3_CSSBuilder.WriteString(`clip:rect(0, 0, 0, 0);`)
+ templ_7745c5c3_CSSBuilder.WriteString(`white-space:nowrap;`)
+ templ_7745c5c3_CSSBuilder.WriteString(`border-width:0;`)
+ templ_7745c5c3_CSSID := templ.CSSID(`gttSrOnly`, templ_7745c5c3_CSSBuilder.String())
+ return templ.ComponentCSSClass{
+ ID: templ_7745c5c3_CSSID,
+ Class: templ.SafeCSS(`.` + templ_7745c5c3_CSSID + `{` + templ_7745c5c3_CSSBuilder.String() + `}`),
+ }
+}
diff --git a/gropdown-entry.templ b/gropdown-entry.templ
new file mode 100644
index 0000000..2a1c181
--- /dev/null
+++ b/gropdown-entry.templ
@@ -0,0 +1,59 @@
+package gropdown
+
+templ entry(item DropdownItem) {
+
+ if item.Href != "" {
+
+ if item.Icon != "" {
+
+ @templ.Raw(item.Icon)
+
+ }
+ { item.Label }
+
+ } else {
+
+ if item.Icon != "" {
+
+ @templ.Raw(item.Icon)
+
+ }
+ { item.Label }
+
+ }
+
+}
+
+css gddLI_Item() {
+ position: relative;
+ display: flex;
+ width: 100%;
+ text-decoration: none;
+ gap: var(--gdd-item-icon-space);
+ padding: var(--gdd-item-py) var(--gdd-item-px);
+ color: var(--gdd-item-color);
+ font-size: var(--gdd-item-font-size);
+ line-height: var(--gdd-item-line-height);
+ letter-spacing: var(--gdd-item-letter-spacing);
+ background-color: var(--gdd-item-bg-color);
+ border-width: var(--gdd-item-border-width);
+ border-style: var(--gdd-item-border-style);
+ border-color: var(--gdd-item-border-color);
+ border-radius: var(--gdd-item-border-radius);
+}
+
+css gddLI_ItemIcon() {
+ display: inline-flex;
+ align-items: center;
+ flex-shrink: 0;
+ width: 1.25em;
+ height: 1.25em;
+}
diff --git a/gropdown-entry_templ.go b/gropdown-entry_templ.go
new file mode 100644
index 0000000..71cfc20
--- /dev/null
+++ b/gropdown-entry_templ.go
@@ -0,0 +1,226 @@
+// Code generated by templ - DO NOT EDIT.
+
+// templ: version: v0.2.598
+package gropdown
+
+//lint:file-ignore SA4006 This context is only used if a nested component is present.
+
+import "github.com/a-h/templ"
+import "context"
+import "io"
+import "bytes"
+import "strings"
+
+func entry(item DropdownItem) templ.Component {
+ return templ.ComponentFunc(func(ctx context.Context, templ_7745c5c3_W io.Writer) (templ_7745c5c3_Err error) {
+ templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templ_7745c5c3_W.(*bytes.Buffer)
+ if !templ_7745c5c3_IsBuffer {
+ templ_7745c5c3_Buffer = templ.GetBuffer()
+ defer templ.ReleaseBuffer(templ_7745c5c3_Buffer)
+ }
+ ctx = templ.InitializeContext(ctx)
+ templ_7745c5c3_Var1 := templ.GetChildren(ctx)
+ if templ_7745c5c3_Var1 == nil {
+ templ_7745c5c3_Var1 = templ.NopComponent
+ }
+ ctx = templ.ClearChildren(ctx)
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ if item.Href != "" {
+ var templ_7745c5c3_Var2 = []any{gddLI_Item()}
+ templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var2...)
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ if item.Icon != "" {
+ var templ_7745c5c3_Var4 = []any{gddLI_ItemIcon()}
+ templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var4...)
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templ.Raw(item.Icon).Render(ctx, templ_7745c5c3_Buffer)
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("
")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ }
+ var templ_7745c5c3_Var5 string
+ templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(item.Label)
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `gropdown-entry.templ`, Line: 19, Col: 16}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(" ")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ } else {
+ var templ_7745c5c3_Var6 = []any{gddLI_Item()}
+ templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var6...)
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ if item.Icon != "" {
+ var templ_7745c5c3_Var7 = []any{gddLI_ItemIcon()}
+ templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var7...)
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templ.Raw(item.Icon).Render(ctx, templ_7745c5c3_Buffer)
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("
")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ }
+ var templ_7745c5c3_Var8 string
+ templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(item.Label)
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `gropdown-entry.templ`, Line: 28, Col: 16}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(" ")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(" ")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ if !templ_7745c5c3_IsBuffer {
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteTo(templ_7745c5c3_W)
+ }
+ return templ_7745c5c3_Err
+ })
+}
+
+func gddLI_Item() templ.CSSClass {
+ var templ_7745c5c3_CSSBuilder strings.Builder
+ templ_7745c5c3_CSSBuilder.WriteString(`position:relative;`)
+ templ_7745c5c3_CSSBuilder.WriteString(`display:flex;`)
+ templ_7745c5c3_CSSBuilder.WriteString(`width:100%;`)
+ templ_7745c5c3_CSSBuilder.WriteString(`text-decoration:none;`)
+ templ_7745c5c3_CSSBuilder.WriteString(`gap:var(--gdd-item-icon-space);`)
+ templ_7745c5c3_CSSBuilder.WriteString(`padding:var(--gdd-item-py) var(--gdd-item-px);`)
+ templ_7745c5c3_CSSBuilder.WriteString(`color:var(--gdd-item-color);`)
+ templ_7745c5c3_CSSBuilder.WriteString(`font-size:var(--gdd-item-font-size);`)
+ templ_7745c5c3_CSSBuilder.WriteString(`line-height:var(--gdd-item-line-height);`)
+ templ_7745c5c3_CSSBuilder.WriteString(`letter-spacing:var(--gdd-item-letter-spacing);`)
+ templ_7745c5c3_CSSBuilder.WriteString(`background-color:var(--gdd-item-bg-color);`)
+ templ_7745c5c3_CSSBuilder.WriteString(`border-width:var(--gdd-item-border-width);`)
+ templ_7745c5c3_CSSBuilder.WriteString(`border-style:var(--gdd-item-border-style);`)
+ templ_7745c5c3_CSSBuilder.WriteString(`border-color:var(--gdd-item-border-color);`)
+ templ_7745c5c3_CSSBuilder.WriteString(`border-radius:var(--gdd-item-border-radius);`)
+ templ_7745c5c3_CSSID := templ.CSSID(`gddLI_Item`, templ_7745c5c3_CSSBuilder.String())
+ return templ.ComponentCSSClass{
+ ID: templ_7745c5c3_CSSID,
+ Class: templ.SafeCSS(`.` + templ_7745c5c3_CSSID + `{` + templ_7745c5c3_CSSBuilder.String() + `}`),
+ }
+}
+
+func gddLI_ItemIcon() templ.CSSClass {
+ var templ_7745c5c3_CSSBuilder strings.Builder
+ templ_7745c5c3_CSSBuilder.WriteString(`display:inline-flex;`)
+ templ_7745c5c3_CSSBuilder.WriteString(`align-items:center;`)
+ templ_7745c5c3_CSSBuilder.WriteString(`flex-shrink:0;`)
+ templ_7745c5c3_CSSBuilder.WriteString(`width:1.25em;`)
+ templ_7745c5c3_CSSBuilder.WriteString(`height:1.25em;`)
+ templ_7745c5c3_CSSID := templ.CSSID(`gddLI_ItemIcon`, templ_7745c5c3_CSSBuilder.String())
+ return templ.ComponentCSSClass{
+ ID: templ_7745c5c3_CSSID,
+ Class: templ.SafeCSS(`.` + templ_7745c5c3_CSSID + `{` + templ_7745c5c3_CSSBuilder.String() + `}`),
+ }
+}
diff --git a/gropdown-js.templ b/gropdown-js.templ
new file mode 100644
index 0000000..99a3875
--- /dev/null
+++ b/gropdown-js.templ
@@ -0,0 +1,564 @@
+package gropdown
+
+script GropdownJS(dropdown *Dropdown) {
+ // Utility function to check if a value is null or undefined
+ function isNullish(value) {
+ return value === null || value === undefined;
+ }
+
+ // Utility function to checks if a given value is of boolean type and has a value of `true` or `false`.
+ function isBool(value) {
+ return typeof value === 'boolean' && (value === true || value === false);
+ }
+
+ // Utility function to check if a given string is a single character.
+ function isChar(txt) {
+ const charsRegex = /\S/;
+ return txt.length === 1 && charsRegex.test(txt);
+ }
+
+ /**
+ * Extracts a string ID from the aria-label attribute of the provided HTML element.
+ * @param {HTMLElement} node - The HTML element from which to extract the ID.
+ * @returns {string} The extracted string ID, or an empty string if no aria-label attribute is present.
+ */
+ function getIdFromAriaLabel(node) {
+ const ariaLabel = node.getAttribute('aria-label')
+
+ if (ariaLabel) {
+ return ariaLabel.trim().toLowerCase().replace(/[\s/]/g, '-')
+ }
+
+ return ''
+ }
+
+ /**
+ * Generates a component ID based on the provided node\'s role and aria-label attributes.
+ * @param {HTMLElement} node - The HTML element for which to generate the component ID.
+ * @returns {string} The generated component ID.
+ */
+ function getComponentId(node) {
+ const role = node.getAttribute('role');
+ const ariaLabel = node.getAttribute('aria-label');
+
+ if (role && ariaLabel) {
+ return `${role}-${ariaLabel.toLowerCase().replace(/\s+/g, '-')}`
+ } else if (role) {
+ return role
+ } else {
+ return ''
+ }
+ }
+
+ /**
+ * The `ComponentFocusManager` class provides focus management for components with multiple
+ * interactive items. It enables navigation between items, setting focus to specific items, and
+ * handling keyboard interactions.
+ *
+ * Use it to ensure keyboard accessibility as per [WAI ARIA Patterns](https://www.w3.org/WAI/ARIA/apg/patterns/)
+ * and improve user experience in menus, dropdowns, and other interactive components.
+ */
+ class ComponentFocusManager {
+ /**
+ * Constructs an instance of ComponentFocusManager.
+ * @param {string} componentId - The ID of the component.
+ */
+ constructor(componentId) {
+ this._id = componentId
+ this._items = {}
+ this._firstChars = {}
+ this._firstItem = {}
+ this._lastItem = {}
+ this._applyDOMChangesFn = async () => {
+ // Default implementation: no pending state changes, resolves immediately
+ return Promise.resolve()
+ }
+
+ this._items[componentId] = []
+ this._firstChars[componentId] = []
+ this._firstItem[componentId] = null
+ this._lastItem[componentId] = null
+ }
+
+ /**
+ * Gets or sets the collection of items.
+ * @type {Object}
+ */
+ get items() {
+ const {_items} = this
+ return _items
+ }
+
+ set items(value) {
+ this._items = value
+ }
+
+ /**
+ * Gets or sets the collection of first characters.
+ * @type {Object}
+ */
+ get firstChars() {
+ const {_firstChars} = this
+ return _firstChars
+ }
+
+ set firstChars(value) {
+ this._firstChars = value
+ }
+
+ /**
+ * Gets or sets the first item in each collection.
+ * @type {Object}
+ */
+ get firstItem() {
+ const {_firstItem} = this
+ return _firstItem
+ }
+
+ set firstItem(value) {
+ this._firstItem = value
+ }
+
+ /**
+ * Gets or sets the last item in each collection.
+ * @type {Object}
+ */
+ get lastItem() {
+ const {_lastItem} = this
+ return _lastItem
+ }
+
+ set lastItem(value) {
+ this._lastItem = value
+ }
+
+ /**
+ * Gets or sets the applyDOMChanges promise.
+ * @type {() => Promise}
+ */
+ get applyDOMChangesFn() {
+ return this._applyDOMChangesFn
+ }
+
+ set applyDOMChangesFn(value) {
+ this._applyDOMChangesFn = value
+ }
+
+ /** methods */
+
+ /**
+ * Runs the focus manager to initialize the collections.
+ */
+ run() {
+ this._items[this._id].forEach((item) => {
+ const menuItemContent = item.textContent?.trim().toLowerCase()[0]
+ if (menuItemContent) this._firstChars[this._id].push(menuItemContent)
+
+ if (!this._firstItem[this._id]) {
+ this._firstItem[this._id] = item
+ }
+ this._lastItem[this._id] = item
+ })
+ }
+
+ /**
+ * Sets the focus to the first item.
+ * @param {string} [cId] - Optional ID of the component.
+ */
+ setFocusToFirstItem(cId) {
+ const id = this._resolveId(cId)
+ this._setFocusToItem(this._firstItem[id])
+ }
+
+ /**
+ * Sets the focus to the last item.
+ * @param {string} [cId] - Optional ID of the component.
+ */
+ setFocusToLastItem(cId) {
+ const id = this._resolveId(cId)
+ this._setFocusToItem(this._lastItem[id])
+ }
+
+ /**
+ * Sets the focus to the previous item relative to the current item.
+ * @param {HTMLElement} currentItem - The current item.
+ * @param {string} [cId] - Optional ID of the component.
+ * @returns {HTMLElement} The new focused item.
+ */
+ setFocusToPreviousItem(currentItem, cId) {
+ const id = this._resolveId(cId)
+ let newMenuItem, index
+
+ if (currentItem === this._firstItem[id]) {
+ newMenuItem = this._lastItem[id]
+ } else {
+ index = this._items[id].indexOf(currentItem)
+ newMenuItem = this._items[id][index - 1]
+ }
+
+ this._setFocusToItem(newMenuItem)
+ return newMenuItem
+ }
+
+ /**
+ * Sets the focus to the next item relative to the current item.
+ * @param {HTMLElement} currentItem - The current item.
+ * @param {string} [cId] - Optional ID of the component.
+ * @returns {HTMLElement} The new focused item.
+ */
+ setFocusToNextItem(currentItem, cId) {
+ const id = this._resolveId(cId)
+ let newMenuItem, index
+
+ if (currentItem === this._lastItem[id]) {
+ newMenuItem = this._firstItem[id]
+ } else {
+ index = this._items[id].indexOf(currentItem)
+ newMenuItem = this._items[id][index + 1]
+ }
+
+ this._setFocusToItem(newMenuItem)
+ return newMenuItem
+ }
+
+ /**
+ * Sets the focus to the item whose content starts with the specified character.
+ * @param {HTMLElement} currentItem - The current item.
+ * @param {string} c - The character to match.
+ */
+ setFocusByFirstChar(currentItem, c) {
+ let start, index
+
+ if (c.length > 1) return
+ c = c.toLowerCase()
+ start = this._items[this._id].indexOf(currentItem) + 1
+ if (start >= this._items[this._id].length) {
+ start = 0
+ }
+ index = this._firstChars[this._id].indexOf(c, start)
+ if (index === -1) {
+ index = this._firstChars[this._id].indexOf(c, 0)
+ }
+ if (index > -1) {
+ this._setFocusToItem(this._items[this._id][index])
+ }
+ }
+
+ /**
+ * Checks if the given node is a submenu.
+ * @param {HTMLElement} node - The node to check.
+ * @returns {boolean} A boolean indicating whether the node is a submenu.
+ */
+ isSubMenu(node) {
+ return node.getAttribute('aria-haspopup') === 'true'
+ }
+
+ /**
+ * Resolves the ID to use based on the provided ID or the default ID of the component.
+ * @param {string} [id] - The optional ID to resolve.
+ * @returns {string} The resolved ID.
+ * @private
+ */
+ _resolveId(id) {
+ return !isNullish(id) ? id : this._id
+ }
+
+ /**
+ * Sets the focus to the given item.
+ * @param {HTMLElement | null} item - The item to set focus to.
+ * @private
+ */
+ async _setFocusToItem(item) {
+ if (this._items[this._id] && item) {
+ await Promise.all(
+ this._items[this._id].map(async (itemNode) => {
+ if (itemNode === item) {
+ itemNode.tabIndex = 0
+ await this.applyDOMChangesFn()
+ itemNode.focus()
+ } else {
+ itemNode.tabIndex = -1
+ }
+ })
+ )
+ }
+ }
+ }
+
+ /**
+ * Initializes accessibility actions for a dropdown component.
+ * @param {HTMLElement} node - The root element of the dropdown component.
+ * @param {object} options - Options for configuring the accessibility actions.
+ * @param {boolean} options.enabled - Flag indicating if the accessibility actions on the dropdown should be enabled.
+ * @param {boolean} options.open - Flag indicating if the dropdown is initially open.
+ * @param {boolean} options.animated - Flag indicating if the dropdown menu button should use icon animations.
+ */
+ function a11yActions(node, options) {
+ let open = options?.open
+ const animated = options?.animated
+ const componentId = getComponentId(node)
+ const focusManager = new ComponentFocusManager(componentId)
+ const listGroups = {}
+ const firstChars = {}
+ const firstItem = {}
+ const lastItem = {}
+
+ // Initializes the dropdown component.
+ const initialize = () => {
+ listGroups[componentId] = []
+ firstChars[componentId] = []
+ firstItem[componentId] = null
+ lastItem[componentId] = null
+ }
+
+ /**
+ * Checks if the given target element is a link (anchor tag).
+ *
+ * @param {HTMLElement} target - The target element to check.
+ * @returns {boolean} Returns true if the target element is a link, otherwise false.
+ */
+ const isLinkElement = (target) => {
+ return target.tagName === 'A';
+ };
+
+ /**
+ * Handles click events on dropdown items, performing appropriate actions based on the type of item.
+ *
+ * @param {Event} e - The click event.
+ */
+ const handleClickOnItem = (e) => {
+ e.stopPropagation();
+ e.preventDefault();
+
+ if (isLinkElement(e.target)) {
+ handleLinkClick(e.target);
+ } else {
+ handleButtonClick();
+ }
+ };
+
+ /**
+ * Handles click events on link elements, navigating to the specified URL and closing the dropdown if necessary.
+ *
+ * @param {HTMLElement} linkElement - The link element that was clicked.
+ */
+ const handleLinkClick = (linkElement) => {
+ const href = linkElement.getAttribute('href');
+ const isExternal = linkElement.getAttribute('data-external') === 'true';
+
+ if (href) {
+ if (isExternal) {
+ window.open(href, '_blank');
+ } else {
+ window.location.href = href;
+ }
+ }
+ closeMenu();
+ };
+
+ // Handles click events on button items, closing the dropdown if necessary.
+ const handleButtonClick = () => {
+ closeMenu();
+ };
+
+ // Toggles the dropdown open/close state.
+ const toggleDropdown = () => {
+ open = !open
+ const ariaExpanded = open ? 'true' : 'false'
+ node
+ .querySelector('[class*="gddButton"]')
+ .setAttribute('aria-expanded', ariaExpanded)
+
+ const container = node.closest('[class*="gddContainer"]')
+ if (!container) return
+
+ const ulElement = container.querySelector('ul[role="menu"]')
+ if (!ulElement) return
+
+ const currentState = ulElement.getAttribute('data-state')
+ ulElement.setAttribute(
+ 'data-state',
+ currentState === 'open' ? 'close' : 'open'
+ )
+
+ if(animated) {
+ const svgElement = node.querySelector('svg');
+ svgElement.classList.toggle('iconToOpen', currentState === 'close');
+ svgElement.classList.toggle('iconToClose', currentState === 'open');
+ }
+ }
+
+ const closeMenu = () => {
+ toggleDropdown()
+ node.focus()
+ }
+
+ // Button click event handler.
+ const onButtonClick = (e) => {
+ e.stopPropagation()
+ e.preventDefault()
+ toggleDropdown()
+ if (open) focusManager.setFocusToFirstItem()
+ }
+
+ // Button keydown event handler.
+ const onButtonKeydown = (e) => {
+ e.stopPropagation()
+ e.preventDefault()
+ switch (e.code) {
+ case 'Space':
+ case 'Enter':
+ case 'Down':
+ case 'ArrowDown':
+ if(!open){ // handle not open by default
+ toggleDropdown()
+ }
+ focusManager.setFocusToFirstItem()
+ break
+ case 'Up':
+ case 'ArrowUp':
+ if(!open){ // handle not open by default
+ toggleDropdown()
+ }
+ focusManager.setFocusToLastItem()
+ break
+ case 'Tab':
+ node.blur()
+ break
+ default:
+ break
+ }
+ }
+
+ // Item click event handler.
+ const onItemClick = handleClickOnItem;
+
+ // Item keydown event handler.
+ const onItemKeydown = (e) => {
+ e.stopPropagation()
+ e.preventDefault()
+ const target = e.currentTarget
+
+ if (e.shiftKey) {
+ if (isChar(e.key)) {
+ focusManager.setFocusByFirstChar(target, e.key)
+ }
+ } else {
+ switch (e.code) {
+ case 'Space':
+ case 'Enter':
+ handleClickOnItem(e)
+ break
+ case 'Esc':
+ case 'Escape':
+ closeMenu()
+ dropdownBtn.focus()
+ break
+ case 'Up':
+ case 'ArrowUp':
+ focusManager.setFocusToPreviousItem(target)
+ break
+ case 'Down':
+ case 'ArrowDown':
+ focusManager.setFocusToNextItem(target)
+ break
+ case 'Home':
+ case 'PageUp':
+ focusManager.setFocusToFirstItem()
+ break
+ case 'End':
+ case 'PageDown':
+ focusManager.setFocusToLastItem()
+ break
+ default:
+ if (isChar(e.key)) {
+ focusManager.setFocusByFirstChar(target, e.key)
+ }
+ }
+ }
+ }
+
+ // Item mouseover event handler.
+ const onItemMouseOver = (e) => {
+ const target = e.currentTarget
+ target.focus()
+ }
+
+ initialize()
+
+ const dropdownBtn = node.querySelector('[class*="gddButton"]')
+ if (options?.enabled) {
+ node.addEventListener('click', onButtonClick)
+ node.addEventListener('keydown', onButtonKeydown)
+
+ const menuItemNodes = Array.from(node.querySelectorAll('[role="menuitem"]'))
+
+ menuItemNodes.forEach((item) => {
+ item.addEventListener('keydown', onItemKeydown)
+ item.addEventListener('mouseover', onItemMouseOver)
+ item.addEventListener('click', onItemClick)
+ listGroups[componentId].push(item)
+
+ const itemContent = item.textContent?.trim().toLowerCase()[0]
+ if (itemContent) firstChars[componentId].push(itemContent)
+
+ if (!firstItem[componentId]) {
+ firstItem[componentId] = item
+ }
+ lastItem[componentId] = item
+ })
+
+ focusManager.items = listGroups
+ focusManager.firstChars = firstChars
+ focusManager.firstItem = firstItem
+ focusManager.lastItem = lastItem
+
+ // set the focus on the button when open by default.
+ if (open) {
+ dropdownBtn.focus()
+ }
+ }
+ }
+
+ /**
+ * Handles the action to close the dropdown when clicked outside of it.
+ * @param {MouseEvent} e - The event object generated by the click action.
+ * @param {object} options - Options for configuring the accessibility actions.
+ * @param {boolean} options.animated - Flag indicating if the dropdown menu button should use icon animations.
+ */
+ function clickOutsideAction(e, options) {
+ const animated = options?.animated || true
+ const dropdownContainer = document.querySelector('[class*="gddContainer"]')
+ if (!dropdownContainer) return
+
+ const isClickedInsideDropdown = dropdownContainer.contains(e.target)
+ if (!isClickedInsideDropdown) {
+ dropdownBtn = dropdownContainer.querySelector('[class*="gddButton"]')
+ dropdownBtn.setAttribute('aria-expanded', false)
+
+ if (animated) {
+ const svgElement = dropdownBtn.querySelector('svg')
+ svgElement.classList.remove('iconToOpen')
+ svgElement.classList.remove('iconToClose')
+ }
+
+ const ulElement = dropdownContainer.querySelector('ul[role="menu"]')
+ if (!ulElement) return
+
+ ulElement.setAttribute('data-state','close' )
+ }
+ }
+
+ document.body.addEventListener('click', clickOutsideAction);
+
+ document.addEventListener('DOMContentLoaded', function () {
+ const dropdownContainers = document.querySelectorAll('[class*="gddContainer"]')
+ for (let i = 0; i < dropdownContainers.length; i++) {
+ a11yActions(dropdownContainers[i], {
+ enabled: true,
+ open: dropdown.Open,
+ animated: dropdown.Animation,
+ })
+ }
+ });
+}
diff --git a/gropdown-js_templ.go b/gropdown-js_templ.go
new file mode 100644
index 0000000..1d24a5d
--- /dev/null
+++ b/gropdown-js_templ.go
@@ -0,0 +1,577 @@
+// Code generated by templ - DO NOT EDIT.
+
+// templ: version: v0.2.598
+package gropdown
+
+//lint:file-ignore SA4006 This context is only used if a nested component is present.
+
+import "github.com/a-h/templ"
+
+func GropdownJS(dropdown *Dropdown) templ.ComponentScript {
+ return templ.ComponentScript{
+ Name: `__templ_GropdownJS_5151`,
+ Function: `function __templ_GropdownJS_5151(dropdown){// Utility function to check if a value is null or undefined
+ function isNullish(value) {
+ return value === null || value === undefined;
+ }
+
+ // Utility function to checks if a given value is of boolean type and has a value of ` + "`" + `true` + "`" + ` or ` + "`" + `false` + "`" + `.
+ function isBool(value) {
+ return typeof value === 'boolean' && (value === true || value === false);
+ }
+
+ // Utility function to check if a given string is a single character.
+ function isChar(txt) {
+ const charsRegex = /\S/;
+ return txt.length === 1 && charsRegex.test(txt);
+ }
+
+ /**
+ * Extracts a string ID from the aria-label attribute of the provided HTML element.
+ * @param {HTMLElement} node - The HTML element from which to extract the ID.
+ * @returns {string} The extracted string ID, or an empty string if no aria-label attribute is present.
+ */
+ function getIdFromAriaLabel(node) {
+ const ariaLabel = node.getAttribute('aria-label')
+
+ if (ariaLabel) {
+ return ariaLabel.trim().toLowerCase().replace(/[\s/]/g, '-')
+ }
+
+ return ''
+ }
+
+ /**
+ * Generates a component ID based on the provided node\'s role and aria-label attributes.
+ * @param {HTMLElement} node - The HTML element for which to generate the component ID.
+ * @returns {string} The generated component ID.
+ */
+ function getComponentId(node) {
+ const role = node.getAttribute('role');
+ const ariaLabel = node.getAttribute('aria-label');
+
+ if (role && ariaLabel) {
+ return ` + "`" + `${role}-${ariaLabel.toLowerCase().replace(/\s+/g, '-')}` + "`" + `
+ } else if (role) {
+ return role
+ } else {
+ return ''
+ }
+ }
+
+ /**
+ * The ` + "`" + `ComponentFocusManager` + "`" + ` class provides focus management for components with multiple
+ * interactive items. It enables navigation between items, setting focus to specific items, and
+ * handling keyboard interactions.
+ *
+ * Use it to ensure keyboard accessibility as per [WAI ARIA Patterns](https://www.w3.org/WAI/ARIA/apg/patterns/)
+ * and improve user experience in menus, dropdowns, and other interactive components.
+ */
+ class ComponentFocusManager {
+ /**
+ * Constructs an instance of ComponentFocusManager.
+ * @param {string} componentId - The ID of the component.
+ */
+ constructor(componentId) {
+ this._id = componentId
+ this._items = {}
+ this._firstChars = {}
+ this._firstItem = {}
+ this._lastItem = {}
+ this._applyDOMChangesFn = async () => {
+ // Default implementation: no pending state changes, resolves immediately
+ return Promise.resolve()
+ }
+
+ this._items[componentId] = []
+ this._firstChars[componentId] = []
+ this._firstItem[componentId] = null
+ this._lastItem[componentId] = null
+ }
+
+ /**
+ * Gets or sets the collection of items.
+ * @type {Object}
+ */
+ get items() {
+ const {_items} = this
+ return _items
+ }
+
+ set items(value) {
+ this._items = value
+ }
+
+ /**
+ * Gets or sets the collection of first characters.
+ * @type {Object}
+ */
+ get firstChars() {
+ const {_firstChars} = this
+ return _firstChars
+ }
+
+ set firstChars(value) {
+ this._firstChars = value
+ }
+
+ /**
+ * Gets or sets the first item in each collection.
+ * @type {Object}
+ */
+ get firstItem() {
+ const {_firstItem} = this
+ return _firstItem
+ }
+
+ set firstItem(value) {
+ this._firstItem = value
+ }
+
+ /**
+ * Gets or sets the last item in each collection.
+ * @type {Object}
+ */
+ get lastItem() {
+ const {_lastItem} = this
+ return _lastItem
+ }
+
+ set lastItem(value) {
+ this._lastItem = value
+ }
+
+ /**
+ * Gets or sets the applyDOMChanges promise.
+ * @type {() => Promise}
+ */
+ get applyDOMChangesFn() {
+ return this._applyDOMChangesFn
+ }
+
+ set applyDOMChangesFn(value) {
+ this._applyDOMChangesFn = value
+ }
+
+ /** methods */
+
+ /**
+ * Runs the focus manager to initialize the collections.
+ */
+ run() {
+ this._items[this._id].forEach((item) => {
+ const menuItemContent = item.textContent?.trim().toLowerCase()[0]
+ if (menuItemContent) this._firstChars[this._id].push(menuItemContent)
+
+ if (!this._firstItem[this._id]) {
+ this._firstItem[this._id] = item
+ }
+ this._lastItem[this._id] = item
+ })
+ }
+
+ /**
+ * Sets the focus to the first item.
+ * @param {string} [cId] - Optional ID of the component.
+ */
+ setFocusToFirstItem(cId) {
+ const id = this._resolveId(cId)
+ this._setFocusToItem(this._firstItem[id])
+ }
+
+ /**
+ * Sets the focus to the last item.
+ * @param {string} [cId] - Optional ID of the component.
+ */
+ setFocusToLastItem(cId) {
+ const id = this._resolveId(cId)
+ this._setFocusToItem(this._lastItem[id])
+ }
+
+ /**
+ * Sets the focus to the previous item relative to the current item.
+ * @param {HTMLElement} currentItem - The current item.
+ * @param {string} [cId] - Optional ID of the component.
+ * @returns {HTMLElement} The new focused item.
+ */
+ setFocusToPreviousItem(currentItem, cId) {
+ const id = this._resolveId(cId)
+ let newMenuItem, index
+
+ if (currentItem === this._firstItem[id]) {
+ newMenuItem = this._lastItem[id]
+ } else {
+ index = this._items[id].indexOf(currentItem)
+ newMenuItem = this._items[id][index - 1]
+ }
+
+ this._setFocusToItem(newMenuItem)
+ return newMenuItem
+ }
+
+ /**
+ * Sets the focus to the next item relative to the current item.
+ * @param {HTMLElement} currentItem - The current item.
+ * @param {string} [cId] - Optional ID of the component.
+ * @returns {HTMLElement} The new focused item.
+ */
+ setFocusToNextItem(currentItem, cId) {
+ const id = this._resolveId(cId)
+ let newMenuItem, index
+
+ if (currentItem === this._lastItem[id]) {
+ newMenuItem = this._firstItem[id]
+ } else {
+ index = this._items[id].indexOf(currentItem)
+ newMenuItem = this._items[id][index + 1]
+ }
+
+ this._setFocusToItem(newMenuItem)
+ return newMenuItem
+ }
+
+ /**
+ * Sets the focus to the item whose content starts with the specified character.
+ * @param {HTMLElement} currentItem - The current item.
+ * @param {string} c - The character to match.
+ */
+ setFocusByFirstChar(currentItem, c) {
+ let start, index
+
+ if (c.length > 1) return
+ c = c.toLowerCase()
+ start = this._items[this._id].indexOf(currentItem) + 1
+ if (start >= this._items[this._id].length) {
+ start = 0
+ }
+ index = this._firstChars[this._id].indexOf(c, start)
+ if (index === -1) {
+ index = this._firstChars[this._id].indexOf(c, 0)
+ }
+ if (index > -1) {
+ this._setFocusToItem(this._items[this._id][index])
+ }
+ }
+
+ /**
+ * Checks if the given node is a submenu.
+ * @param {HTMLElement} node - The node to check.
+ * @returns {boolean} A boolean indicating whether the node is a submenu.
+ */
+ isSubMenu(node) {
+ return node.getAttribute('aria-haspopup') === 'true'
+ }
+
+ /**
+ * Resolves the ID to use based on the provided ID or the default ID of the component.
+ * @param {string} [id] - The optional ID to resolve.
+ * @returns {string} The resolved ID.
+ * @private
+ */
+ _resolveId(id) {
+ return !isNullish(id) ? id : this._id
+ }
+
+ /**
+ * Sets the focus to the given item.
+ * @param {HTMLElement | null} item - The item to set focus to.
+ * @private
+ */
+ async _setFocusToItem(item) {
+ if (this._items[this._id] && item) {
+ await Promise.all(
+ this._items[this._id].map(async (itemNode) => {
+ if (itemNode === item) {
+ itemNode.tabIndex = 0
+ await this.applyDOMChangesFn()
+ itemNode.focus()
+ } else {
+ itemNode.tabIndex = -1
+ }
+ })
+ )
+ }
+ }
+ }
+
+ /**
+ * Initializes accessibility actions for a dropdown component.
+ * @param {HTMLElement} node - The root element of the dropdown component.
+ * @param {object} options - Options for configuring the accessibility actions.
+ * @param {boolean} options.enabled - Flag indicating if the accessibility actions on the dropdown should be enabled.
+ * @param {boolean} options.open - Flag indicating if the dropdown is initially open.
+ * @param {boolean} options.animated - Flag indicating if the dropdown menu button should use icon animations.
+ */
+ function a11yActions(node, options) {
+ let open = options?.open
+ const animated = options?.animated
+ const componentId = getComponentId(node)
+ const focusManager = new ComponentFocusManager(componentId)
+ const listGroups = {}
+ const firstChars = {}
+ const firstItem = {}
+ const lastItem = {}
+
+ // Initializes the dropdown component.
+ const initialize = () => {
+ listGroups[componentId] = []
+ firstChars[componentId] = []
+ firstItem[componentId] = null
+ lastItem[componentId] = null
+ }
+
+ /**
+ * Checks if the given target element is a link (anchor tag).
+ *
+ * @param {HTMLElement} target - The target element to check.
+ * @returns {boolean} Returns true if the target element is a link, otherwise false.
+ */
+ const isLinkElement = (target) => {
+ return target.tagName === 'A';
+ };
+
+ /**
+ * Handles click events on dropdown items, performing appropriate actions based on the type of item.
+ *
+ * @param {Event} e - The click event.
+ */
+ const handleClickOnItem = (e) => {
+ e.stopPropagation();
+ e.preventDefault();
+
+ if (isLinkElement(e.target)) {
+ handleLinkClick(e.target);
+ } else {
+ handleButtonClick();
+ }
+ };
+
+ /**
+ * Handles click events on link elements, navigating to the specified URL and closing the dropdown if necessary.
+ *
+ * @param {HTMLElement} linkElement - The link element that was clicked.
+ */
+ const handleLinkClick = (linkElement) => {
+ const href = linkElement.getAttribute('href');
+ const isExternal = linkElement.getAttribute('data-external') === 'true';
+
+ if (href) {
+ if (isExternal) {
+ window.open(href, '_blank');
+ } else {
+ window.location.href = href;
+ }
+ }
+ closeMenu();
+ };
+
+ // Handles click events on button items, closing the dropdown if necessary.
+ const handleButtonClick = () => {
+ closeMenu();
+ };
+
+ // Toggles the dropdown open/close state.
+ const toggleDropdown = () => {
+ open = !open
+ const ariaExpanded = open ? 'true' : 'false'
+ node
+ .querySelector('[class*="gddButton"]')
+ .setAttribute('aria-expanded', ariaExpanded)
+
+ const container = node.closest('[class*="gddContainer"]')
+ if (!container) return
+
+ const ulElement = container.querySelector('ul[role="menu"]')
+ if (!ulElement) return
+
+ const currentState = ulElement.getAttribute('data-state')
+ ulElement.setAttribute(
+ 'data-state',
+ currentState === 'open' ? 'close' : 'open'
+ )
+
+ if(animated) {
+ const svgElement = node.querySelector('svg');
+ svgElement.classList.toggle('iconToOpen', currentState === 'close');
+ svgElement.classList.toggle('iconToClose', currentState === 'open');
+ }
+ }
+
+ const closeMenu = () => {
+ toggleDropdown()
+ node.focus()
+ }
+
+ // Button click event handler.
+ const onButtonClick = (e) => {
+ e.stopPropagation()
+ e.preventDefault()
+ toggleDropdown()
+ if (open) focusManager.setFocusToFirstItem()
+ }
+
+ // Button keydown event handler.
+ const onButtonKeydown = (e) => {
+ e.stopPropagation()
+ e.preventDefault()
+ switch (e.code) {
+ case 'Space':
+ case 'Enter':
+ case 'Down':
+ case 'ArrowDown':
+ if(!open){ // handle not open by default
+ toggleDropdown()
+ }
+ focusManager.setFocusToFirstItem()
+ break
+ case 'Up':
+ case 'ArrowUp':
+ if(!open){ // handle not open by default
+ toggleDropdown()
+ }
+ focusManager.setFocusToLastItem()
+ break
+ case 'Tab':
+ node.blur()
+ break
+ default:
+ break
+ }
+ }
+
+ // Item click event handler.
+ const onItemClick = handleClickOnItem;
+
+ // Item keydown event handler.
+ const onItemKeydown = (e) => {
+ e.stopPropagation()
+ e.preventDefault()
+ const target = e.currentTarget
+
+ if (e.shiftKey) {
+ if (isChar(e.key)) {
+ focusManager.setFocusByFirstChar(target, e.key)
+ }
+ } else {
+ switch (e.code) {
+ case 'Space':
+ case 'Enter':
+ handleClickOnItem(e)
+ break
+ case 'Esc':
+ case 'Escape':
+ closeMenu()
+ dropdownBtn.focus()
+ break
+ case 'Up':
+ case 'ArrowUp':
+ focusManager.setFocusToPreviousItem(target)
+ break
+ case 'Down':
+ case 'ArrowDown':
+ focusManager.setFocusToNextItem(target)
+ break
+ case 'Home':
+ case 'PageUp':
+ focusManager.setFocusToFirstItem()
+ break
+ case 'End':
+ case 'PageDown':
+ focusManager.setFocusToLastItem()
+ break
+ default:
+ if (isChar(e.key)) {
+ focusManager.setFocusByFirstChar(target, e.key)
+ }
+ }
+ }
+ }
+
+ // Item mouseover event handler.
+ const onItemMouseOver = (e) => {
+ const target = e.currentTarget
+ target.focus()
+ }
+
+ initialize()
+
+ const dropdownBtn = node.querySelector('[class*="gddButton"]')
+ if (options?.enabled) {
+ node.addEventListener('click', onButtonClick)
+ node.addEventListener('keydown', onButtonKeydown)
+
+ const menuItemNodes = Array.from(node.querySelectorAll('[role="menuitem"]'))
+
+ menuItemNodes.forEach((item) => {
+ item.addEventListener('keydown', onItemKeydown)
+ item.addEventListener('mouseover', onItemMouseOver)
+ item.addEventListener('click', onItemClick)
+ listGroups[componentId].push(item)
+
+ const itemContent = item.textContent?.trim().toLowerCase()[0]
+ if (itemContent) firstChars[componentId].push(itemContent)
+
+ if (!firstItem[componentId]) {
+ firstItem[componentId] = item
+ }
+ lastItem[componentId] = item
+ })
+
+ focusManager.items = listGroups
+ focusManager.firstChars = firstChars
+ focusManager.firstItem = firstItem
+ focusManager.lastItem = lastItem
+
+ // set the focus on the button when open by default.
+ if (open) {
+ dropdownBtn.focus()
+ }
+ }
+ }
+
+ /**
+ * Handles the action to close the dropdown when clicked outside of it.
+ * @param {MouseEvent} e - The event object generated by the click action.
+ * @param {object} options - Options for configuring the accessibility actions.
+ * @param {boolean} options.animated - Flag indicating if the dropdown menu button should use icon animations.
+ */
+ function clickOutsideAction(e, options) {
+ const animated = options?.animated || true
+ const dropdownContainer = document.querySelector('[class*="gddContainer"]')
+ if (!dropdownContainer) return
+
+ const isClickedInsideDropdown = dropdownContainer.contains(e.target)
+ if (!isClickedInsideDropdown) {
+ dropdownBtn = dropdownContainer.querySelector('[class*="gddButton"]')
+ dropdownBtn.setAttribute('aria-expanded', false)
+
+ if (animated) {
+ const svgElement = dropdownBtn.querySelector('svg')
+ svgElement.classList.remove('iconToOpen')
+ svgElement.classList.remove('iconToClose')
+ }
+
+ const ulElement = dropdownContainer.querySelector('ul[role="menu"]')
+ if (!ulElement) return
+
+ ulElement.setAttribute('data-state','close' )
+ }
+ }
+
+ document.body.addEventListener('click', clickOutsideAction);
+
+ document.addEventListener('DOMContentLoaded', function () {
+ const dropdownContainers = document.querySelectorAll('[class*="gddContainer"]')
+ for (let i = 0; i < dropdownContainers.length; i++) {
+ a11yActions(dropdownContainers[i], {
+ enabled: true,
+ open: dropdown.Open,
+ animated: dropdown.Animation,
+ })
+ }
+ });
+}`,
+ Call: templ.SafeScript(`__templ_GropdownJS_5151`, dropdown),
+ CallInline: templ.SafeScriptInline(`__templ_GropdownJS_5151`, dropdown),
+ }
+}
diff --git a/gropdown-list.templ b/gropdown-list.templ
new file mode 100644
index 0000000..7fe6526
--- /dev/null
+++ b/gropdown-list.templ
@@ -0,0 +1,36 @@
+package gropdown
+
+templ itemsList(dropdown *Dropdown) {
+
+ for _, item := range dropdown.Items {
+ @entry(item)
+ }
+
+}
+
+css gddUL() {
+ overflow: hidden;
+ list-style: none;
+ width: var(--gdd-list-w);
+ max-width: var(--gdd-list-max-w);
+ margin: var(--gdd-list-my) var(--gdd-list-mx);
+ padding: var(--gdd-list-py) var(--gdd-list-px);
+ background-color: var(--gdd-list-bg-color);
+ border-width: var(--gdd-list-border-width);
+ border-style: var(--gdd-list-border-style);
+ border-color: var(--gdd-list-border-color);
+ border-radius: var(--gdd-list-border-radius);
+}
diff --git a/gropdown-list_templ.go b/gropdown-list_templ.go
new file mode 100644
index 0000000..add28dd
--- /dev/null
+++ b/gropdown-list_templ.go
@@ -0,0 +1,105 @@
+// Code generated by templ - DO NOT EDIT.
+
+// templ: version: v0.2.598
+package gropdown
+
+//lint:file-ignore SA4006 This context is only used if a nested component is present.
+
+import "github.com/a-h/templ"
+import "context"
+import "io"
+import "bytes"
+import "strings"
+
+func itemsList(dropdown *Dropdown) templ.Component {
+ return templ.ComponentFunc(func(ctx context.Context, templ_7745c5c3_W io.Writer) (templ_7745c5c3_Err error) {
+ templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templ_7745c5c3_W.(*bytes.Buffer)
+ if !templ_7745c5c3_IsBuffer {
+ templ_7745c5c3_Buffer = templ.GetBuffer()
+ defer templ.ReleaseBuffer(templ_7745c5c3_Buffer)
+ }
+ ctx = templ.InitializeContext(ctx)
+ templ_7745c5c3_Var1 := templ.GetChildren(ctx)
+ if templ_7745c5c3_Var1 == nil {
+ templ_7745c5c3_Var1 = templ.NopComponent
+ }
+ ctx = templ.ClearChildren(ctx)
+ var templ_7745c5c3_Var2 = []any{gddUL()}
+ templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var2...)
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ for _, item := range dropdown.Items {
+ templ_7745c5c3_Err = entry(item).Render(ctx, templ_7745c5c3_Buffer)
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(" ")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ if !templ_7745c5c3_IsBuffer {
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteTo(templ_7745c5c3_W)
+ }
+ return templ_7745c5c3_Err
+ })
+}
+
+func gddUL() templ.CSSClass {
+ var templ_7745c5c3_CSSBuilder strings.Builder
+ templ_7745c5c3_CSSBuilder.WriteString(`overflow:hidden;`)
+ templ_7745c5c3_CSSBuilder.WriteString(`list-style:none;`)
+ templ_7745c5c3_CSSBuilder.WriteString(`width:var(--gdd-list-w);`)
+ templ_7745c5c3_CSSBuilder.WriteString(`max-width:var(--gdd-list-max-w);`)
+ templ_7745c5c3_CSSBuilder.WriteString(`margin:var(--gdd-list-my) var(--gdd-list-mx);`)
+ templ_7745c5c3_CSSBuilder.WriteString(`padding:var(--gdd-list-py) var(--gdd-list-px);`)
+ templ_7745c5c3_CSSBuilder.WriteString(`background-color:var(--gdd-list-bg-color);`)
+ templ_7745c5c3_CSSBuilder.WriteString(`border-width:var(--gdd-list-border-width);`)
+ templ_7745c5c3_CSSBuilder.WriteString(`border-style:var(--gdd-list-border-style);`)
+ templ_7745c5c3_CSSBuilder.WriteString(`border-color:var(--gdd-list-border-color);`)
+ templ_7745c5c3_CSSBuilder.WriteString(`border-radius:var(--gdd-list-border-radius);`)
+ templ_7745c5c3_CSSID := templ.CSSID(`gddUL`, templ_7745c5c3_CSSBuilder.String())
+ return templ.ComponentCSSClass{
+ ID: templ_7745c5c3_CSSID,
+ Class: templ.SafeCSS(`.` + templ_7745c5c3_CSSID + `{` + templ_7745c5c3_CSSBuilder.String() + `}`),
+ }
+}
diff --git a/gropdown.go b/gropdown.go
new file mode 100644
index 0000000..5d69ac0
--- /dev/null
+++ b/gropdown.go
@@ -0,0 +1,88 @@
+package gropdown
+
+import "github.com/a-h/templ"
+
+// Position represents a position on the screen.
+type Position string
+
+func (p Position) String() string {
+ return string(p)
+}
+
+// DropdownButton represents the main button for the dropdown menu.
+type DropdownButton struct {
+ Label string // Label is the text displayed for the dropdown button.
+ Icon string // Icon is the icon displayed next to the dropdown button.
+ Attrs templ.Attributes // Attrs is a map of attributes to be added to the button.
+}
+
+// DropdownItem represents an item in the dropdown menu.
+type DropdownItem struct {
+ Label string // Label is the text displayed for the dropdown item.
+ Icon string // Icon is the icon displayed next to the dropdown item.
+ Href string // Href is the URL associated with the dropdown item (optional).
+ External bool // External if the URL associated with the dropdown item is an external URL (optional).
+ Divider bool // Divider indicates whether the item is a divider.
+ Attrs templ.Attributes // Attrs is a map of attributes to be added to the element.
+}
+
+// Dropdown represents a dropdown menu component.
+type Dropdown struct {
+ Open bool // Open indicates whether the dropdown menu is currently open.
+ Animation bool // Animation indicates whether the dropdown button should use animations on open and close.
+ Position Position // Position indicates the position of the dropdown content relative to the button.
+ Button DropdownButton // Button is the dropdown button configuration.
+ Items []DropdownItem // Items is a slice of dropdown menu items.
+}
+
+// DropdownBuilder is used to construct Dropdown instances with options.
+type DropdownBuilder struct {
+ dropdown *Dropdown
+}
+
+func (b *DropdownBuilder) Dropdown() *Dropdown {
+ return b.dropdown
+}
+
+// NewDropdownBuilder creates a new DropdownBuilder instance with default settings.
+func NewDropdownBuilder() *DropdownBuilder {
+ return &DropdownBuilder{dropdown: &Dropdown{
+ Open: false,
+ Animation: true,
+ Position: Bottom,
+ }}
+}
+
+// SetOpen sets the Open field of the dropdown.
+func (b *DropdownBuilder) SetOpen(open bool) *DropdownBuilder {
+ b.dropdown.Open = open
+ return b
+}
+
+// SetAnimation sets the animations for the dropdown button icon when open/close.
+func (b *DropdownBuilder) SetAnimation(animation bool) *DropdownBuilder {
+ b.dropdown.Animation = animation
+ return b
+}
+
+func (b *DropdownBuilder) SetPosition(position Position) *DropdownBuilder {
+ b.dropdown.Position = position
+ return b
+}
+
+// WithButton sets the Button field of the dropdown.
+func (b *DropdownBuilder) WithButton(button DropdownButton) *DropdownBuilder {
+ b.dropdown.Button = button
+ return b
+}
+
+// WithItems sets the Items field of the dropdown.
+func (b *DropdownBuilder) WithItems(items []DropdownItem) *DropdownBuilder {
+ b.dropdown.Items = items
+ return b
+}
+
+// Render constructs and returns a templ.Component representing the dropdown.
+func (b *DropdownBuilder) Render() templ.Component {
+ return container(b.dropdown)
+}
diff --git a/gropdown.templ b/gropdown.templ
new file mode 100644
index 0000000..e4dbb67
--- /dev/null
+++ b/gropdown.templ
@@ -0,0 +1,19 @@
+package gropdown
+
+templ container(dropdown *Dropdown) {
+
+ @button(dropdown.Button)
+ @content(dropdown)
+
+}
+
+css gddContainer() {
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+ font-smooth: auto;
+ position: relative;
+ display: inline-block;
+}
diff --git a/gropdown_templ.go b/gropdown_templ.go
new file mode 100644
index 0000000..4d32a0d
--- /dev/null
+++ b/gropdown_templ.go
@@ -0,0 +1,83 @@
+// Code generated by templ - DO NOT EDIT.
+
+// templ: version: v0.2.598
+package gropdown
+
+//lint:file-ignore SA4006 This context is only used if a nested component is present.
+
+import "github.com/a-h/templ"
+import "context"
+import "io"
+import "bytes"
+import "strings"
+
+func container(dropdown *Dropdown) templ.Component {
+ return templ.ComponentFunc(func(ctx context.Context, templ_7745c5c3_W io.Writer) (templ_7745c5c3_Err error) {
+ templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templ_7745c5c3_W.(*bytes.Buffer)
+ if !templ_7745c5c3_IsBuffer {
+ templ_7745c5c3_Buffer = templ.GetBuffer()
+ defer templ.ReleaseBuffer(templ_7745c5c3_Buffer)
+ }
+ ctx = templ.InitializeContext(ctx)
+ templ_7745c5c3_Var1 := templ.GetChildren(ctx)
+ if templ_7745c5c3_Var1 == nil {
+ templ_7745c5c3_Var1 = templ.NopComponent
+ }
+ ctx = templ.ClearChildren(ctx)
+ var templ_7745c5c3_Var2 = []any{gddContainer()}
+ templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var2...)
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = button(dropdown.Button).Render(ctx, templ_7745c5c3_Buffer)
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = content(dropdown).Render(ctx, templ_7745c5c3_Buffer)
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("
")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ if !templ_7745c5c3_IsBuffer {
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteTo(templ_7745c5c3_W)
+ }
+ return templ_7745c5c3_Err
+ })
+}
+
+func gddContainer() templ.CSSClass {
+ var templ_7745c5c3_CSSBuilder strings.Builder
+ templ_7745c5c3_CSSBuilder.WriteString(`-webkit-font-smoothing:antialiased;`)
+ templ_7745c5c3_CSSBuilder.WriteString(`-moz-osx-font-smoothing:grayscale;`)
+ templ_7745c5c3_CSSBuilder.WriteString(`font-smooth:auto;`)
+ templ_7745c5c3_CSSBuilder.WriteString(`position:relative;`)
+ templ_7745c5c3_CSSBuilder.WriteString(`display:inline-block;`)
+ templ_7745c5c3_CSSID := templ.CSSID(`gddContainer`, templ_7745c5c3_CSSBuilder.String())
+ return templ.ComponentCSSClass{
+ ID: templ_7745c5c3_CSSID,
+ Class: templ.SafeCSS(`.` + templ_7745c5c3_CSSID + `{` + templ_7745c5c3_CSSBuilder.String() + `}`),
+ }
+}
diff --git a/gropdown_test.go b/gropdown_test.go
new file mode 100644
index 0000000..cc0d964
--- /dev/null
+++ b/gropdown_test.go
@@ -0,0 +1,136 @@
+package gropdown
+
+import (
+ "testing"
+
+ "github.com/a-h/templ"
+)
+
+func TestDropdownBuilder(t *testing.T) {
+ tests := []struct {
+ name string
+ builder *DropdownBuilder
+ expectedOpenState bool
+ expectedAnimation bool
+ expectedPosition Position
+ expectedButtonLabel string
+ expectedButtonIcon string
+ expectedButtonAttrs map[string]string
+ expectedItems []DropdownItem
+ expectedDividerExists bool
+ }{
+ // Test cases for options and config settings
+ {
+ name: "Dropdown with Open State Set to True",
+ builder: NewDropdownBuilder().
+ SetOpen(true).
+ SetAnimation(false).
+ SetPosition(Bottom).
+ WithButton(DropdownButton{
+ Label: "Dropdown",
+ Icon: "icon",
+ }).
+ WithItems([]DropdownItem{
+ {Label: "Item 1"},
+ {Label: "Item 2"},
+ {Label: "Item 3"},
+ }),
+ expectedOpenState: true,
+ expectedPosition: Bottom,
+ },
+ {
+ name: "Dropdown with Animation Disabled",
+ builder: NewDropdownBuilder().
+ SetOpen(false).
+ SetAnimation(false).
+ SetPosition(Bottom).
+ WithButton(DropdownButton{
+ Label: "Dropdown",
+ Icon: "icon",
+ }).
+ WithItems([]DropdownItem{
+ {Label: "Item 1"},
+ {Label: "Item 2"},
+ {Label: "Item 3"},
+ }),
+ expectedAnimation: false,
+ expectedPosition: Bottom,
+ },
+ {
+ name: "Dropdown with Custom Button Configuration",
+ builder: NewDropdownBuilder().
+ SetOpen(false).
+ SetAnimation(false).
+ SetPosition(Left).
+ WithButton(DropdownButton{
+ Label: "Custom Button",
+ Icon: "custom-icon",
+ Attrs: templ.Attributes{
+ "id": "custom-button-id",
+ "class": "custom-button-class",
+ },
+ }).
+ WithItems([]DropdownItem{
+ {Label: "Item 1"},
+ {Label: "Item 2"},
+ {Label: "Item 3"},
+ }),
+ expectedPosition: Left,
+ },
+ {
+ name: "Dropdown with Custom Item Configuration",
+ builder: NewDropdownBuilder().
+ SetOpen(false).
+ SetAnimation(false).
+ SetPosition(Right).
+ WithButton(DropdownButton{
+ Label: "Dropdown",
+ Icon: "icon",
+ }).
+ WithItems([]DropdownItem{
+ {Label: "Item 1", Icon: "item-icon-1", Href: "/item1", External: true},
+ {Label: "Item 2", Icon: "item-icon-2", Href: "/item2"},
+ {Label: "Item 3", Icon: "item-icon-3"},
+ }),
+ expectedPosition: Right,
+ },
+ {
+ name: "Dropdown with Divider",
+ builder: NewDropdownBuilder().
+ SetOpen(false).
+ SetAnimation(false).
+ SetPosition(Right).
+ WithButton(DropdownButton{
+ Label: "Dropdown",
+ Icon: "icon",
+ }).
+ WithItems([]DropdownItem{
+ {Label: "Item 1", Icon: "item-icon-1", Href: "/item1", External: true},
+ {Label: "Item 2", Icon: "item-icon-2", Href: "/item2"},
+ {Label: "Item 3", Icon: "item-icon-3"},
+ {},
+ {Label: "Item 4", Icon: "item-icon-4", Href: "/item4"},
+ }),
+ expectedPosition: Right,
+ expectedDividerExists: true,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ dropdown := tt.builder.Dropdown()
+
+ if tt.expectedOpenState != dropdown.Open {
+ t.Errorf("unexpected open state: got %v, want %v", dropdown.Open, tt.expectedOpenState)
+ }
+
+ if tt.expectedAnimation != dropdown.Animation {
+ t.Errorf("unexpected animation setting: got %v, want %v", dropdown.Animation, tt.expectedAnimation)
+ }
+
+ if tt.expectedPosition != dropdown.Position {
+ t.Errorf("unexpected position: got %v, want %v", dropdown.Position, tt.expectedPosition)
+ }
+ })
+ }
+}
diff --git a/statics/demo.gif b/statics/demo.gif
new file mode 100644
index 0000000..7e44da6
Binary files /dev/null and b/statics/demo.gif differ
diff --git a/types.go b/types.go
new file mode 100644
index 0000000..fdce157
--- /dev/null
+++ b/types.go
@@ -0,0 +1,36 @@
+package gropdown
+
+import "github.com/a-h/templ"
+
+// Position represents a position on the screen.
+type Position string
+
+func (p Position) String() string {
+ return string(p)
+}
+
+// DropdownButton represents the main button for the dropdown menu.
+type DropdownButton struct {
+ Label string // Label is the text displayed for the dropdown button.
+ Icon string // Icon is the icon displayed next to the dropdown button.
+ Attrs templ.Attributes // Attrs is a map of attributes to be added to the button.
+}
+
+// DropdownItem represents an item in the dropdown menu.
+type DropdownItem struct {
+ Label string // Label is the text displayed for the dropdown item.
+ Icon string // Icon is the icon displayed next to the dropdown item.
+ Href string // Href is the URL associated with the dropdown item (optional).
+ External bool // External if the URL associated with the dropdown item is an external URL (optional).
+ Divider bool // Divider indicates whether the item is a divider.
+ Attrs templ.Attributes // Attrs is a map of attributes to be added to the element.
+}
+
+// Dropdown represents a dropdown menu component.
+type Dropdown struct {
+ Open bool // Open indicates whether the dropdown menu is currently open.
+ Animation bool // Animation indicates whether the dropdown button should use animations on open and close.
+ Position Position // Position indicates the position of the dropdown content relative to the button.
+ Button DropdownButton // Button is the dropdown button configuration.
+ Items []DropdownItem // Items is a slice of dropdown menu items.
+}
diff --git a/utils.go b/utils.go
new file mode 100644
index 0000000..c77ba2a
--- /dev/null
+++ b/utils.go
@@ -0,0 +1,14 @@
+package gropdown
+
+import (
+ "fmt"
+ "strings"
+)
+
+func buttonId(label string) string {
+ return fmt.Sprintf("dropdown-button-%s", strings.ToLower(label))
+}
+
+func menuId(label string) string {
+ return fmt.Sprintf("dropdown-menu-%s", strings.ToLower(label))
+}