From 14d0108cd8c9b279a9f66c706aaf51cde019334e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 10 Nov 2025 21:32:31 +0000 Subject: [PATCH 1/6] Initial plan From f61fb6791efee4493658f767846322a4bd2189d1 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 10 Nov 2025 21:45:01 +0000 Subject: [PATCH 2/6] Add dropdown component to Tsunami framework Co-authored-by: sawka <2722291+sawka@users.noreply.github.com> --- tsunami/demo/dropdowntest/app.go | 206 ++++++++++++++++++++++ tsunami/demo/dropdowntest/go.mod | 12 ++ tsunami/demo/dropdowntest/go.sum | 4 + tsunami/frontend/src/element/dropdown.tsx | 64 +++++++ tsunami/frontend/src/vdom.tsx | 17 ++ 5 files changed, 303 insertions(+) create mode 100644 tsunami/demo/dropdowntest/app.go create mode 100644 tsunami/demo/dropdowntest/go.mod create mode 100644 tsunami/demo/dropdowntest/go.sum create mode 100644 tsunami/frontend/src/element/dropdown.tsx diff --git a/tsunami/demo/dropdowntest/app.go b/tsunami/demo/dropdowntest/app.go new file mode 100644 index 0000000000..132e94cf93 --- /dev/null +++ b/tsunami/demo/dropdowntest/app.go @@ -0,0 +1,206 @@ +package main + +import ( + "github.com/wavetermdev/waveterm/tsunami/app" + "github.com/wavetermdev/waveterm/tsunami/vdom" +) + +const AppTitle = "Dropdown Test (Tsunami Demo)" +const AppShortDesc = "Test dropdown element in Tsunami" + +// DropdownOption represents a single option in the dropdown +type DropdownOption struct { + Label string `json:"label"` + Value string `json:"value"` + Disabled bool `json:"disabled,omitempty"` +} + +var App = app.DefineComponent("App", func(_ struct{}) any { + // State for different dropdown values + basicDropdown := app.UseLocal("option2") + fruitDropdown := app.UseLocal("") + colorDropdown := app.UseLocal("blue") + disabledDropdown := app.UseLocal("disabled-value") + + // Options for different dropdowns + basicOptions := []DropdownOption{ + {Label: "Option 1", Value: "option1"}, + {Label: "Option 2", Value: "option2"}, + {Label: "Option 3", Value: "option3"}, + {Label: "Option 4", Value: "option4"}, + } + + fruitOptions := []DropdownOption{ + {Label: "Apple 🍎", Value: "apple"}, + {Label: "Banana 🍌", Value: "banana"}, + {Label: "Cherry 🍒", Value: "cherry"}, + {Label: "Durian 🍈", Value: "durian", Disabled: true}, + {Label: "Elderberry 🫐", Value: "elderberry"}, + {Label: "Fig 🌰", Value: "fig"}, + } + + colorOptions := []DropdownOption{ + {Label: "Red", Value: "red"}, + {Label: "Green", Value: "green"}, + {Label: "Blue", Value: "blue"}, + {Label: "Yellow", Value: "yellow"}, + {Label: "Purple", Value: "purple"}, + } + + // Event handlers + handleBasicChange := func(e vdom.VDomEvent) { + basicDropdown.Set(e.TargetValue) + } + + handleFruitChange := func(e vdom.VDomEvent) { + fruitDropdown.Set(e.TargetValue) + } + + handleColorChange := func(e vdom.VDomEvent) { + colorDropdown.Set(e.TargetValue) + } + + return vdom.H("div", map[string]any{ + "className": "max-w-4xl mx-auto p-8", + }, + vdom.H("h1", map[string]any{ + "className": "text-3xl font-bold mb-6 text-white", + }, "Tsunami Dropdown Test"), + + vdom.H("div", map[string]any{ + "className": "space-y-8", + }, + // Basic Dropdown + vdom.H("div", map[string]any{ + "className": "p-6 bg-gray-800 rounded-lg border border-gray-700", + }, + vdom.H("h2", map[string]any{ + "className": "text-2xl font-semibold mb-4 text-white", + }, "Basic Dropdown"), + vdom.H("div", map[string]any{ + "className": "mb-4", + }, + vdom.H("label", map[string]any{ + "className": "block text-gray-300 mb-2", + }, "Select an option:"), + vdom.H("wave:dropdown", map[string]any{ + "options": basicOptions, + "value": basicDropdown.Get(), + "placeholder": "Choose an option...", + "onChange": handleBasicChange, + }), + ), + vdom.H("div", map[string]any{ + "className": "mt-4 p-3 bg-gray-700 rounded text-gray-200", + }, "Selected Value: ", basicDropdown.Get()), + ), + + // Fruit Dropdown with Disabled Option + vdom.H("div", map[string]any{ + "className": "p-6 bg-gray-800 rounded-lg border border-gray-700", + }, + vdom.H("h2", map[string]any{ + "className": "text-2xl font-semibold mb-4 text-white", + }, "Dropdown with Icons and Disabled Option"), + vdom.H("div", map[string]any{ + "className": "mb-4", + }, + vdom.H("label", map[string]any{ + "className": "block text-gray-300 mb-2", + }, "Pick a fruit (Durian is disabled):"), + vdom.H("wave:dropdown", map[string]any{ + "options": fruitOptions, + "value": fruitDropdown.Get(), + "placeholder": "Select a fruit...", + "onChange": handleFruitChange, + }), + ), + vdom.H("div", map[string]any{ + "className": "mt-4 p-3 bg-gray-700 rounded text-gray-200", + }, vdom.IfElse( + fruitDropdown.Get() != "", + "Selected Fruit: "+fruitDropdown.Get(), + "No fruit selected", + )), + ), + + // Color Dropdown with Pre-selected Value + vdom.H("div", map[string]any{ + "className": "p-6 bg-gray-800 rounded-lg border border-gray-700", + }, + vdom.H("h2", map[string]any{ + "className": "text-2xl font-semibold mb-4 text-white", + }, "Dropdown with Default Value"), + vdom.H("div", map[string]any{ + "className": "mb-4", + }, + vdom.H("label", map[string]any{ + "className": "block text-gray-300 mb-2", + }, "Choose your favorite color:"), + vdom.H("wave:dropdown", map[string]any{ + "options": colorOptions, + "value": colorDropdown.Get(), + "onChange": handleColorChange, + }), + ), + vdom.H("div", map[string]any{ + "className": "mt-4 p-3 rounded text-gray-200", + "style": map[string]any{ + "backgroundColor": colorDropdown.Get(), + }, + }, "Selected Color: ", colorDropdown.Get()), + ), + + // Disabled Dropdown + vdom.H("div", map[string]any{ + "className": "p-6 bg-gray-800 rounded-lg border border-gray-700", + }, + vdom.H("h2", map[string]any{ + "className": "text-2xl font-semibold mb-4 text-white", + }, "Disabled Dropdown"), + vdom.H("div", map[string]any{ + "className": "mb-4", + }, + vdom.H("label", map[string]any{ + "className": "block text-gray-300 mb-2", + }, "This dropdown is disabled:"), + vdom.H("wave:dropdown", map[string]any{ + "options": basicOptions, + "value": disabledDropdown.Get(), + "placeholder": "Can't select...", + "disabled": true, + }), + ), + vdom.H("div", map[string]any{ + "className": "mt-4 p-3 bg-gray-700 rounded text-gray-200", + }, "This dropdown cannot be changed"), + ), + + // Custom Styled Dropdown + vdom.H("div", map[string]any{ + "className": "p-6 bg-gray-800 rounded-lg border border-gray-700", + }, + vdom.H("h2", map[string]any{ + "className": "text-2xl font-semibold mb-4 text-white", + }, "Custom Styled Dropdown"), + vdom.H("div", map[string]any{ + "className": "mb-4", + }, + vdom.H("label", map[string]any{ + "className": "block text-gray-300 mb-2", + }, "Dropdown with custom styling:"), + vdom.H("wave:dropdown", map[string]any{ + "options": colorOptions, + "value": colorDropdown.Get(), + "onChange": handleColorChange, + "className": "text-lg font-bold", + "style": map[string]any{ + "borderWidth": "2px", + "borderColor": "#10b981", + }, + }), + ), + ), + ), + ) +}) diff --git a/tsunami/demo/dropdowntest/go.mod b/tsunami/demo/dropdowntest/go.mod new file mode 100644 index 0000000000..2c8b05cf13 --- /dev/null +++ b/tsunami/demo/dropdowntest/go.mod @@ -0,0 +1,12 @@ +module github.com/wavetermdev/waveterm/tsunami/demo/dropdowntest + +go 1.24.6 + +require github.com/wavetermdev/waveterm/tsunami v0.0.0-00010101000000-000000000000 + +require ( + github.com/google/uuid v1.6.0 // indirect + github.com/outrigdev/goid v0.3.0 // indirect +) + +replace github.com/wavetermdev/waveterm/tsunami => ../../ diff --git a/tsunami/demo/dropdowntest/go.sum b/tsunami/demo/dropdowntest/go.sum new file mode 100644 index 0000000000..4c44991dfc --- /dev/null +++ b/tsunami/demo/dropdowntest/go.sum @@ -0,0 +1,4 @@ +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/outrigdev/goid v0.3.0 h1:t/otQD3EXc45cLtQVPUnNgEyRaTQA4cPeu3qVcrsIws= +github.com/outrigdev/goid v0.3.0/go.mod h1:hEH7f27ypN/GHWt/7gvkRoFYR0LZizfUBIAbak4neVE= diff --git a/tsunami/frontend/src/element/dropdown.tsx b/tsunami/frontend/src/element/dropdown.tsx new file mode 100644 index 0000000000..64717ca551 --- /dev/null +++ b/tsunami/frontend/src/element/dropdown.tsx @@ -0,0 +1,64 @@ +// Copyright 2025, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import React from 'react'; +import { twMerge } from 'tailwind-merge'; + +export interface DropdownOption { + label: string; + value: string; + disabled?: boolean; +} + +export interface DropdownProps { + options?: DropdownOption[]; + value?: string; + placeholder?: string; + disabled?: boolean; + style?: React.CSSProperties; + className?: string; + multiple?: boolean; +} + +export function Dropdown({ + options = [], + value, + placeholder = "Select an option...", + disabled = false, + style, + className, + multiple = false +}: DropdownProps) { + const baseClasses = twMerge( + "w-full px-3 py-2 rounded border bg-panel text-foreground border-border", + "focus:outline-none focus:ring-2 focus:ring-accent focus:border-accent", + "disabled:opacity-50 disabled:cursor-not-allowed", + "cursor-pointer", + className + ); + + return ( + + ); +} diff --git a/tsunami/frontend/src/vdom.tsx b/tsunami/frontend/src/vdom.tsx index 37de4c0f1c..4d103f29cd 100644 --- a/tsunami/frontend/src/vdom.tsx +++ b/tsunami/frontend/src/vdom.tsx @@ -9,6 +9,7 @@ import { twMerge } from "tailwind-merge"; import { AlertModal, ConfirmModal } from "@/element/modals"; import { Markdown } from "@/element/markdown"; +import { Dropdown } from "@/element/dropdown"; import { getTextChildren } from "@/model/model-utils"; import type { TsunamiModel } from "@/model/tsunami-model"; import { RechartsTag } from "@/recharts/recharts"; @@ -30,6 +31,7 @@ type VDomReactTagType = (props: { elem: VDomElem; model: TsunamiModel }) => Reac const WaveTagMap: Record = { "wave:markdown": WaveMarkdown, + "wave:dropdown": WaveDropdown, }; const AllowedSimpleTags: { [tagName: string]: boolean } = { @@ -278,6 +280,21 @@ function WaveMarkdown({ elem, model }: { elem: VDomElem; model: TsunamiModel }) ); } +function WaveDropdown({ elem, model }: { elem: VDomElem; model: TsunamiModel }) { + const props = useVDom(model, elem); + return ( + + ); +} + function StyleTag({ elem, model }: { elem: VDomElem; model: TsunamiModel }) { const styleText = getTextChildren(elem); if (styleText == null) { From 7c7e3b677d2771a0f1766efc3941985688cd94d7 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 10 Nov 2025 21:47:00 +0000 Subject: [PATCH 3/6] Add dropdown component documentation Co-authored-by: sawka <2722291+sawka@users.noreply.github.com> --- .../frontend/src/element/README-DROPDOWN.md | 130 ++++++++++++++++++ 1 file changed, 130 insertions(+) create mode 100644 tsunami/frontend/src/element/README-DROPDOWN.md diff --git a/tsunami/frontend/src/element/README-DROPDOWN.md b/tsunami/frontend/src/element/README-DROPDOWN.md new file mode 100644 index 0000000000..88babffd21 --- /dev/null +++ b/tsunami/frontend/src/element/README-DROPDOWN.md @@ -0,0 +1,130 @@ +# Dropdown Component + +A best-practice dropdown component for the Tsunami framework that can be easily used with JSON-serializable props from the backend. + +## Features + +- ✅ Pure JSON-serializable props (no functions or render functions in props) +- ✅ Consistent dark theme styling with Tailwind CSS +- ✅ Support for disabled options +- ✅ Placeholder text +- ✅ Default selected values +- ✅ Custom styling via className and style props +- ✅ Multiple selection mode +- ✅ Keyboard navigation support +- ✅ Accessible design + +## Usage + +### Basic Dropdown + +```go +vdom.H("wave:dropdown", map[string]any{ + "options": []DropdownOption{ + {Label: "Option 1", Value: "option1"}, + {Label: "Option 2", Value: "option2"}, + {Label: "Option 3", Value: "option3"}, + }, + "value": "option1", + "onChange": func(e vdom.VDomEvent) { + fmt.Println("Selected:", e.TargetValue) + }, +}) +``` + +### With Placeholder + +```go +vdom.H("wave:dropdown", map[string]any{ + "options": options, + "placeholder": "Choose an option...", + "onChange": handleChange, +}) +``` + +### With Disabled Options + +```go +options := []DropdownOption{ + {Label: "Available", Value: "available"}, + {Label: "Unavailable", Value: "unavailable", Disabled: true}, +} + +vdom.H("wave:dropdown", map[string]any{ + "options": options, + "onChange": handleChange, +}) +``` + +### Disabled Dropdown + +```go +vdom.H("wave:dropdown", map[string]any{ + "options": options, + "value": "locked-value", + "disabled": true, +}) +``` + +### Custom Styling + +```go +vdom.H("wave:dropdown", map[string]any{ + "options": options, + "className": "text-lg font-bold", + "style": map[string]any{ + "borderWidth": "2px", + "borderColor": "#10b981", + }, + "onChange": handleChange, +}) +``` + +## Props + +| Prop | Type | Default | Description | +|------|------|---------|-------------| +| `options` | `[]DropdownOption` | `[]` | Array of options to display | +| `value` | `string` | `""` | Currently selected value | +| `placeholder` | `string` | `"Select an option..."` | Placeholder text when no value selected | +| `disabled` | `boolean` | `false` | Whether the dropdown is disabled | +| `className` | `string` | `""` | Additional CSS classes | +| `style` | `React.CSSProperties` | `{}` | Inline styles | +| `multiple` | `boolean` | `false` | Allow multiple selections | +| `onChange` | `func(e vdom.VDomEvent)` | - | Handler called when selection changes | + +## DropdownOption Type + +```go +type DropdownOption struct { + Label string `json:"label"` + Value string `json:"value"` + Disabled bool `json:"disabled,omitempty"` +} +``` + +## Example Component + +See the complete working example in `/tsunami/demo/dropdowntest/app.go` + +## Design Principles + +The dropdown component follows these Tsunami best practices: + +1. **JSON-Serializable Props**: All props can be serialized to JSON for backend-to-frontend communication +2. **No Functions in Props**: Instead of render functions, uses simple data structures (DropdownOption) +3. **Consistent Styling**: Uses Tailwind CSS classes matching other Tsunami elements +4. **Accessibility**: Built on semantic HTML `` elements: + +1. **Consistent cross-browser styling** - Native selects look different on every platform +2. **Custom animations** - Smooth open/close transitions +3. **Better visual design** - Matches the dark theme and overall design system +4. **Enhanced keyboard navigation** - Skip disabled items automatically +5. **Flexible styling** - Complete control over appearance +6. **Better accessibility** - Proper ARIA labels and roles ## Usage @@ -90,7 +104,6 @@ vdom.H("wave:dropdown", map[string]any{ | `disabled` | `boolean` | `false` | Whether the dropdown is disabled | | `className` | `string` | `""` | Additional CSS classes | | `style` | `React.CSSProperties` | `{}` | Inline styles | -| `multiple` | `boolean` | `false` | Allow multiple selections | | `onChange` | `func(e vdom.VDomEvent)` | - | Handler called when selection changes | ## DropdownOption Type @@ -103,6 +116,13 @@ type DropdownOption struct { } ``` +## Keyboard Navigation + +- **Arrow Down** - Move to next enabled option +- **Arrow Up** - Move to previous enabled option +- **Enter** - Select the highlighted option +- **Escape** - Close the dropdown without selecting + ## Example Component See the complete working example in `/tsunami/demo/dropdowntest/app.go` @@ -113,9 +133,10 @@ The dropdown component follows these Tsunami best practices: 1. **JSON-Serializable Props**: All props can be serialized to JSON for backend-to-frontend communication 2. **No Functions in Props**: Instead of render functions, uses simple data structures (DropdownOption) -3. **Consistent Styling**: Uses Tailwind CSS classes matching other Tsunami elements -4. **Accessibility**: Built on semantic HTML ` - {!multiple && placeholder && ( - - )} - {options.map((option, index) => ( -