Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
210 changes: 210 additions & 0 deletions tsunami/demo/dropdowntest/app.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,210 @@
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 Custom Dropdown"),

vdom.H("p", map[string]any{
"className": "text-gray-300 mb-8",
}, "This is a custom-styled dropdown component (not native HTML select). Features include: keyboard navigation (↑↓ arrows, Enter, Escape), click-outside to close, disabled options, and smooth animations."),

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",
},
}),
),
),
),
)
})
12 changes: 12 additions & 0 deletions tsunami/demo/dropdowntest/go.mod
Original file line number Diff line number Diff line change
@@ -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 => ../../
4 changes: 4 additions & 0 deletions tsunami/demo/dropdowntest/go.sum
Original file line number Diff line number Diff line change
@@ -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=
162 changes: 162 additions & 0 deletions tsunami/frontend/src/element/README-DROPDOWN.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
# Dropdown Component

A custom-styled dropdown component for the Tsunami framework that provides significant value over native HTML select elements. This component features a shadcn-inspired design with custom styling, keyboard navigation, and smooth animations.

## Features

- ✅ **Custom styling** - No native browser controls, fully styled dropdown
- ✅ **Pure JSON-serializable props** - No functions or render functions in props
- ✅ **Keyboard navigation** - Arrow keys, Enter, and Escape support
- ✅ **Click-outside to close** - Intuitive UX
- ✅ **Disabled options** - Individual options can be disabled
- ✅ **Placeholder text** - Clear indication when no selection is made
- ✅ **Default selected values** - Pre-select options
- ✅ **Custom styling** - Via className and style props
- ✅ **Accessible design** - Proper ARIA attributes
- ✅ **Dark theme** - Consistent with Tsunami design system
- ✅ **Smooth animations** - Dropdown open/close and hover states
- ✅ **Highlighted selection** - Visual feedback for current and selected items

## Why Not Native Select?

This component provides significant advantages over using native `<select>` 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

### 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 |
| `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"`
}
```

## 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`

## 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. **Custom Styling**: Provides real value by implementing a custom-styled dropdown (not just wrapping native elements)
4. **Consistent Theming**: Uses dark gray colors matching other Tsunami elements
5. **Accessibility**: Full keyboard navigation and ARIA support
6. **Type Safety**: Fully typed with TypeScript interfaces

## Integration

The dropdown is registered in the VDOM system as `wave:dropdown` and can be used like any other Tsunami element:

```typescript
// In vdom.tsx
const WaveTagMap: Record<string, VDomReactTagType> = {
"wave:markdown": WaveMarkdown,
"wave:dropdown": WaveDropdown,
};
```

## Styling Details

The dropdown uses the following color scheme:
- **Trigger**: `bg-gray-800` with `border-gray-700`
- **Hover**: `bg-gray-750` (slightly lighter)
- **Menu**: `bg-gray-800` with `border-gray-700`
- **Option hover**: `bg-gray-700`
- **Selected option**: `bg-gray-750` with bold font
- **Placeholder**: `text-gray-400`
- **Focus ring**: Blue (`ring-blue-500`)
Loading
Loading