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. +

+

+ + license + +   + + go report card + +   + + go reference + +

+ +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. + +
+ Image +
+ +## 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) { + +} + +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 !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) { + +} + +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 !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) { + +} + +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 + } + 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 { + + } +
  • +} + +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 + } + } + _, 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) { + +} + +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)) +}