From ba2b6e672a45dfa7fc471f21719b1ff2ad226728 Mon Sep 17 00:00:00 2001 From: Kodjo Sossouvi Date: Sat, 24 Jan 2026 12:06:22 +0100 Subject: [PATCH] I can click on the grid to select a cell --- docs/DataGrid.md | 601 ++++++++++++++++++++ docs/Keyboard Support.md | 43 ++ docs/Mouse Support.md | 243 ++++++-- src/myfasthtml/assets/myfasthtml.css | 32 +- src/myfasthtml/assets/myfasthtml.js | 345 +++++------ src/myfasthtml/controls/DataGrid.py | 105 +++- src/myfasthtml/controls/DataGridQuery.py | 2 +- src/myfasthtml/controls/Mouse.py | 105 +++- src/myfasthtml/controls/datagrid_objects.py | 3 +- 9 files changed, 1268 insertions(+), 211 deletions(-) create mode 100644 docs/DataGrid.md diff --git a/docs/DataGrid.md b/docs/DataGrid.md new file mode 100644 index 0000000..e29876f --- /dev/null +++ b/docs/DataGrid.md @@ -0,0 +1,601 @@ +# DataGrid Component + +## Introduction + +The DataGrid component provides a high-performance tabular data display for your FastHTML application. It renders pandas +DataFrames with interactive features like column resizing, reordering, and filtering, all powered by HTMX for seamless +updates without page reloads. + +**Key features:** + +- Display tabular data from pandas DataFrames +- Resizable columns with drag handles +- Draggable columns for reordering +- Real-time filtering with search bar +- Virtual scrolling for large datasets (pagination with lazy loading) +- Custom scrollbars for consistent cross-browser appearance +- Optional state persistence per session + +**Common use cases:** + +- Data exploration and analysis dashboards +- Admin interfaces with tabular data +- Report viewers +- Database table browsers +- CSV/Excel file viewers + +## Quick Start + +Here's a minimal example showing a data table with a pandas DataFrame: + +```python +import pandas as pd +from fasthtml.common import * +from myfasthtml.controls.DataGrid import DataGrid +from myfasthtml.core.instances import RootInstance + +# Create sample data +df = pd.DataFrame({ + "Name": ["Alice", "Bob", "Charlie", "Diana"], + "Age": [25, 30, 35, 28], + "City": ["Paris", "London", "Berlin", "Madrid"] +}) + +# Create root instance and data grid +root = RootInstance(session) +grid = DataGrid(parent=root) +grid.init_from_dataframe(df) + +# Render the grid +return grid +``` + +This creates a complete data grid with: + +- A header row with column names ("Name", "Age", "City") +- Data rows displaying the DataFrame content +- A search bar for filtering data +- Resizable column borders (drag to resize) +- Draggable columns (drag headers to reorder) +- Custom scrollbars for horizontal and vertical scrolling + +**Note:** The DataGrid automatically detects column types (Text, Number, Bool, Datetime) from the DataFrame dtypes and +applies appropriate formatting. + +## Basic Usage + +### Visual Structure + +The DataGrid component consists of a filter bar, a table with header/body/footer, and custom scrollbars: + +``` +┌────────────────────────────────────────────────────────────┐ +│ Filter Bar │ +│ ┌─────────────────────────────────────────────┐ ┌────┐ │ +│ │ 🔍 Search... │ │ ✕ │ │ +│ └─────────────────────────────────────────────┘ └────┘ │ +├────────────────────────────────────────────────────────────┤ +│ Header Row ▲ │ +│ ┌──────────┬──────────┬──────────┬──────────┐ │ │ +│ │ Column 1 │ Column 2 │ Column 3 │ Column 4 │ █ │ +│ └──────────┴──────────┴──────────┴──────────┘ █ │ +├────────────────────────────────────────────────────────█───┤ +│ Body (scrollable) █ │ +│ ┌──────────┬──────────┬──────────┬──────────┐ █ │ +│ │ Value │ Value │ Value │ Value │ █ │ +│ ├──────────┼──────────┼──────────┼──────────┤ │ │ +│ │ Value │ Value │ Value │ Value │ │ │ +│ ├──────────┼──────────┼──────────┼──────────┤ ▼ │ +│ │ Value │ Value │ Value │ Value │ │ +│ └──────────┴──────────┴──────────┴──────────┘ │ +│ ◄═══════════════════════════════════════════════════════► │ +└────────────────────────────────────────────────────────────┘ +``` + +**Component details:** + +| Element | Description | +|------------|-------------------------------------------------------| +| Filter bar | Search input with filter mode toggle and clear button | +| Header row | Column names with resize handles and drag support | +| Body | Scrollable data rows with virtual pagination | +| Scrollbars | Custom vertical and horizontal scrollbars | + +### Creating a DataGrid + +The DataGrid is a `MultipleInstance`, meaning you can create multiple independent grids in your application. Create it +by providing a parent instance: + +```python +grid = DataGrid(parent=root_instance) + +# Or with a custom ID +grid = DataGrid(parent=root_instance, _id="my-grid") + +# Or with state persistence enabled +grid = DataGrid(parent=root_instance, save_state=True) +``` + +**Parameters:** + +- `parent`: Parent instance (required) +- `_id` (str, optional): Custom identifier for the grid +- `save_state` (bool, optional): Enable state persistence (column widths, order, filters) + +### Loading Data + +Use the `init_from_dataframe()` method to load data into the grid: + +```python +import pandas as pd + +# Create a DataFrame +df = pd.DataFrame({ + "Product": ["Laptop", "Phone", "Tablet"], + "Price": [999.99, 699.99, 449.99], + "In Stock": [True, False, True] +}) + +# Load into grid +grid.init_from_dataframe(df) +``` + +**Column type detection:** + +The DataGrid automatically detects column types from pandas dtypes: + +| pandas dtype | DataGrid type | Display | +|--------------------|---------------|-------------------------| +| `int64`, `float64` | Number | Right-aligned | +| `bool` | Bool | Checkbox icon | +| `datetime64` | Datetime | Formatted date | +| `object`, others | Text | Left-aligned, truncated | + +### Row Index Column + +By default, the DataGrid displays a row index column on the left. This can be useful for identifying rows: + +```python +# Row index is enabled by default +grid._state.row_index = True + +# To disable the row index column +grid._state.row_index = False +grid.init_from_dataframe(df) +``` + +## Column Features + +### Resizing Columns + +Users can resize columns by dragging the border between column headers: + +- **Drag handle location**: Right edge of each column header +- **Minimum width**: 30 pixels +- **Persistence**: Resized widths are automatically saved when `save_state=True` + +The resize interaction: + +1. Hover over the right edge of a column header (cursor changes) +2. Click and drag to resize +3. Release to confirm the new width +4. Double-click to reset to default width + +**Programmatic width control:** + +```python +# Set a specific column width +for col in grid._state.columns: + if col.col_id == "my_column": + col.width = 200 # pixels + break +``` + +### Moving Columns + +Users can reorder columns by dragging column headers: + +1. Click and hold a column header +2. Drag to the desired position +3. Release to drop the column + +The columns animate smoothly during the move, and other columns shift to accommodate the new position. + +**Note:** Column order is persisted when `save_state=True`. + +### Column Visibility + +Columns can be hidden programmatically: + +```python +# Hide a specific column +for col in grid._state.columns: + if col.col_id == "internal_id": + col.visible = False + break +``` + +Hidden columns are not rendered but remain in the state, allowing them to be shown again later. + +## Filtering + +### Using the Search Bar + +The DataGrid includes a built-in search bar that filters rows in real-time: + +``` +┌─────────────────────────────────────────────┐ ┌────┐ +│ 🔍 Search... │ │ ✕ │ +└─────────────────────────────────────────────┘ └────┘ + │ │ + │ └── Clear button + └── Filter mode icon (click to cycle) +``` + +**How filtering works:** + +1. Type in the search box +2. The grid filters rows where ANY visible column contains the search text +3. Matching text is highlighted in the results +4. Click the ✕ button to clear the filter + +### Filter Modes + +Click the filter icon to cycle through three modes: + +| Mode | Icon | Description | +|------------|------|------------------------------------| +| **Filter** | 🔍 | Hides non-matching rows | +| **Search** | 🔎 | Highlights matches, shows all rows | +| **AI** | 🧠 | AI-powered search (future feature) | + +The current mode affects how results are displayed: + +- **Filter mode**: Only matching rows are shown +- **Search mode**: All rows shown, matches highlighted + +## Advanced Features + +### State Persistence + +Enable state persistence to save user preferences across sessions: + +```python +# Enable state persistence +grid = DataGrid(parent=root, save_state=True) +``` + +**What gets persisted:** + +| State | Description | +|-------------------|---------------------------------| +| Column widths | User-resized column sizes | +| Column order | User-defined column arrangement | +| Column visibility | Which columns are shown/hidden | +| Sort order | Current sort configuration | +| Filter state | Active filters | + +### Virtual Scrolling + +For large datasets, the DataGrid uses virtual scrolling with lazy loading: + +- Only a subset of rows (page) is rendered initially +- As the user scrolls down, more rows are loaded automatically +- Uses Intersection Observer API for efficient scroll detection +- Default page size: configurable via `DATAGRID_PAGE_SIZE` + +This allows smooth performance even with thousands of rows. + +### Text Size + +Customize the text size for the grid body: + +```python +# Available sizes: "xs", "sm", "md", "lg" +grid._settings.text_size = "sm" # default +``` + +### CSS Customization + +The DataGrid uses CSS classes that you can customize: + +| Class | Element | +|-----------------------------|-------------------------| +| `dt2-table-wrapper` | Root table container | +| `dt2-table` | Table element | +| `dt2-header-container` | Header wrapper | +| `dt2-body-container` | Scrollable body wrapper | +| `dt2-footer-container` | Footer wrapper | +| `dt2-row` | Table row | +| `dt2-cell` | Table cell | +| `dt2-resize-handle` | Column resize handle | +| `dt2-scrollbars-vertical` | Vertical scrollbar | +| `dt2-scrollbars-horizontal` | Horizontal scrollbar | +| `dt2-highlight-1` | Search match highlight | + +**Example customization:** + +```css +/* Change highlight color */ +.dt2-highlight-1 { + background-color: #fef08a; + font-weight: bold; +} + +/* Customize row hover */ +.dt2-row:hover { + background-color: #f3f4f6; +} + +/* Style the scrollbars */ +.dt2-scrollbars-vertical, +.dt2-scrollbars-horizontal { + background-color: #3b82f6; + border-radius: 4px; +} +``` + +## Examples + +### Example 1: Simple Data Table + +A basic data table displaying product information: + +```python +import pandas as pd +from fasthtml.common import * +from myfasthtml.controls.DataGrid import DataGrid +from myfasthtml.core.instances import RootInstance + +# Sample product data +df = pd.DataFrame({ + "Product": ["Laptop Pro", "Wireless Mouse", "USB-C Hub", "Monitor 27\"", "Keyboard"], + "Category": ["Computers", "Accessories", "Accessories", "Displays", "Accessories"], + "Price": [1299.99, 49.99, 79.99, 399.99, 129.99], + "In Stock": [True, True, False, True, True], + "Rating": [4.5, 4.2, 4.8, 4.6, 4.3] +}) + +# Create and configure grid +root = RootInstance(session) +grid = DataGrid(parent=root, _id="products-grid") +grid.init_from_dataframe(df) + +# Render +return Div( + H1("Product Catalog"), + grid, + cls="p-4" +) +``` + +### Example 2: Large Dataset with Filtering + +Handling a large dataset with virtual scrolling and filtering: + +```python +import pandas as pd +import numpy as np +from fasthtml.common import * +from myfasthtml.controls.DataGrid import DataGrid +from myfasthtml.core.instances import RootInstance + +# Generate large dataset (10,000 rows) +np.random.seed(42) +n_rows = 10000 + +df = pd.DataFrame({ + "ID": range(1, n_rows + 1), + "Name": [f"Item_{i}" for i in range(n_rows)], + "Value": np.random.uniform(10, 1000, n_rows).round(2), + "Category": np.random.choice(["A", "B", "C", "D"], n_rows), + "Active": np.random.choice([True, False], n_rows), + "Created": pd.date_range("2024-01-01", periods=n_rows, freq="h") +}) + +# Create grid with state persistence +root = RootInstance(session) +grid = DataGrid(parent=root, _id="large-dataset", save_state=True) +grid.init_from_dataframe(df) + +return Div( + H1("Large Dataset Explorer"), + P(f"Displaying {n_rows:,} rows with virtual scrolling"), + grid, + cls="p-4", + style="height: 100vh;" +) +``` + +**Note:** Virtual scrolling loads rows on demand as you scroll, ensuring smooth performance even with 10,000+ rows. + +### Example 3: Dashboard with Multiple Grids + +An application with multiple data grids in different tabs: + +```python +import pandas as pd +from fasthtml.common import * +from myfasthtml.controls.DataGrid import DataGrid +from myfasthtml.controls.TabsManager import TabsManager +from myfasthtml.core.instances import RootInstance + +# Create data for different views +sales_df = pd.DataFrame({ + "Date": pd.date_range("2024-01-01", periods=30, freq="D"), + "Revenue": [1000 + i * 50 for i in range(30)], + "Orders": [10 + i for i in range(30)] +}) + +customers_df = pd.DataFrame({ + "Customer": ["Acme Corp", "Tech Inc", "Global Ltd"], + "Country": ["USA", "UK", "Germany"], + "Total Spent": [15000, 12000, 8500] +}) + +# Create instances +root = RootInstance(session) +tabs = TabsManager(parent=root, _id="dashboard-tabs") + +# Create grids +sales_grid = DataGrid(parent=root, _id="sales-grid") +sales_grid.init_from_dataframe(sales_df) + +customers_grid = DataGrid(parent=root, _id="customers-grid") +customers_grid.init_from_dataframe(customers_df) + +# Add to tabs +tabs.create_tab("Sales", sales_grid) +tabs.create_tab("Customers", customers_grid) + +return Div( + H1("Sales Dashboard"), + tabs, + cls="p-4" +) +``` + +--- + +## Developer Reference + +This section contains technical details for developers working on the DataGrid component itself. + +### State + +The DataGrid uses two state objects: + +**DatagridState** - Main state for grid data and configuration: + +| Name | Type | Description | Default | +|-------------------|---------------------------|----------------------------|---------| +| `sidebar_visible` | bool | Whether sidebar is visible | `False` | +| `row_index` | bool | Show row index column | `True` | +| `columns` | list[DataGridColumnState] | Column definitions | `[]` | +| `rows` | list[DataGridRowState] | Row-specific states | `[]` | +| `sorted` | list | Sort configuration | `[]` | +| `filtered` | dict | Active filters | `{}` | +| `selection` | DatagridSelectionState | Selection state | - | +| `ne_df` | DataFrame | The data (non-persisted) | `None` | + +**DatagridSettings** - User preferences: + +| Name | Type | Description | Default | +|----------------------|------|--------------------|---------| +| `save_state` | bool | Enable persistence | `False` | +| `header_visible` | bool | Show header row | `True` | +| `filter_all_visible` | bool | Show filter bar | `True` | +| `text_size` | str | Body text size | `"sm"` | + +### Column State + +Each column is represented by `DataGridColumnState`: + +| Name | Type | Description | Default | +|-------------|------------|--------------------|---------| +| `col_id` | str | Column identifier | - | +| `col_index` | int | Index in DataFrame | - | +| `title` | str | Display title | `None` | +| `type` | ColumnType | Data type | `Text` | +| `visible` | bool | Is column visible | `True` | +| `usable` | bool | Is column usable | `True` | +| `width` | int | Width in pixels | `150` | + +### Commands + +Available commands for programmatic control: + +| Name | Description | +|------------------------|---------------------------------------------| +| `get_page(page_index)` | Load a specific page of data (lazy loading) | +| `set_column_width()` | Update column width after resize | +| `move_column()` | Move column to new position | +| `filter()` | Apply current filter to grid | + +### Public Methods + +| Method | Description | +|-----------------------------------------------|----------------------------------------| +| `init_from_dataframe(df, init_state=True)` | Load data from pandas DataFrame | +| `set_column_width(col_id, width)` | Set column width programmatically | +| `move_column(source_col_id, target_col_id)` | Move column to new position | +| `filter()` | Apply filter and return partial render | +| `render()` | Render the complete grid | +| `render_partial(fragment, redraw_scrollbars)` | Render only part of the grid | + +### High Level Hierarchical Structure + +``` +Div(id="{id}", cls="grid") +├── Div (filter bar) +│ └── DataGridQuery # Filter/search component +├── Div(id="tw_{id}", cls="dt2-table-wrapper") +│ ├── Div(id="t_{id}", cls="dt2-table") +│ │ ├── Div (dt2-header-container) +│ │ │ └── Div(id="th_{id}", cls="dt2-row dt2-header") +│ │ │ ├── Div (dt2-cell) # Column 1 header +│ │ │ ├── Div (dt2-cell) # Column 2 header +│ │ │ └── ... +│ │ ├── Div(id="tb_{id}", cls="dt2-body-container") +│ │ │ └── Div (dt2-body) +│ │ │ ├── Div (dt2-row) # Data row 1 +│ │ │ ├── Div (dt2-row) # Data row 2 +│ │ │ └── ... +│ │ └── Div (dt2-footer-container) +│ │ └── Div (dt2-row dt2-header) # Footer row +│ └── Div (dt2-scrollbars) +│ ├── Div (dt2-scrollbars-vertical-wrapper) +│ │ └── Div (dt2-scrollbars-vertical) +│ └── Div (dt2-scrollbars-horizontal-wrapper) +│ └── Div (dt2-scrollbars-horizontal) +└── Script # Initialization script +``` + +### Element IDs + +| Pattern | Description | +|-----------------------|-------------------------------------| +| `{id}` | Root grid container | +| `tw_{id}` | Table wrapper (scrollbar container) | +| `t_{id}` | Table element | +| `th_{id}` | Header row | +| `tb_{id}` | Body container | +| `tf_{id}` | Footer row | +| `tsm_{id}` | Selection Manager | +| `tr_{id}-{row_index}` | Individual data row | + +### Internal Methods + +These methods are used internally for rendering: + +| Method | Description | +|---------------------------------------------|----------------------------------------| +| `mk_headers()` | Renders the header row | +| `mk_body()` | Renders the body with first page | +| `mk_body_container()` | Renders the scrollable body container | +| `mk_body_content_page(page_index)` | Renders a specific page of rows | +| `mk_body_cell(col_pos, row_index, col_def)` | Renders a single cell | +| `mk_body_cell_content(...)` | Renders cell content with highlighting | +| `mk_footers()` | Renders the footer row | +| `mk_table()` | Renders the complete table structure | +| `mk_aggregation_cell(...)` | Renders footer aggregation cell | +| `_get_filtered_df()` | Returns filtered and sorted DataFrame | +| `_apply_sort(df)` | Applies sort configuration | +| `_apply_filter(df)` | Applies filter configuration | + +### DataGridQuery Component + +The filter bar is a separate component (`DataGridQuery`) with its own state: + +| State Property | Type | Description | Default | +|----------------|------|-----------------------------------------|------------| +| `filter_type` | str | Current mode ("filter", "search", "ai") | `"filter"` | +| `query` | str | Current search text | `None` | + +**Commands:** + +| Command | Description | +|------------------------|-----------------------------| +| `change_filter_type()` | Cycle through filter modes | +| `on_filter_changed()` | Handle search input changes | +| `on_cancel_query()` | Clear the search query | diff --git a/docs/Keyboard Support.md b/docs/Keyboard Support.md index 11a5802..2a2e747 100644 --- a/docs/Keyboard Support.md +++ b/docs/Keyboard Support.md @@ -176,12 +176,55 @@ You can use any HTMX attribute in the configuration object: - `hx-target` - Target element selector - `hx-swap` - Swap strategy (innerHTML, outerHTML, etc.) - `hx-vals` - Additional values to send (object) +- `hx-vals-extra` - Extra values to merge (see below) - `hx-headers` - Custom headers (object) - `hx-select` - Select specific content from response - `hx-confirm` - Confirmation message All other `hx-*` attributes are supported and will be converted to the appropriate htmx.ajax() parameters. +### Dynamic Values with hx-vals-extra + +The `hx-vals-extra` attribute allows adding dynamic values computed at event time, without overwriting the static `hx-vals`. + +**Format:** +```javascript +{ + "hx-vals": {"c_id": "command_id"}, // Static values (preserved) + "hx-vals-extra": { + "dict": {"key": "value"}, // Additional static values (merged) + "js": "functionName" // JS function to call (merged) + } +} +``` + +**How values are merged:** +1. `hx-vals` - static values (e.g., `c_id` from Command) +2. `hx-vals-extra.dict` - additional static values +3. `hx-vals-extra.js` - function called with `(event, element, combinationStr)`, result merged + +**JavaScript function example:** +```javascript +function getKeyboardContext(event, element, combination) { + return { + key: event.key, + shift: event.shiftKey, + timestamp: Date.now() + }; +} +``` + +**Configuration example:** +```javascript +const combinations = { + "Ctrl+S": { + "hx-post": "/save", + "hx-vals": {"c_id": "save_cmd"}, + "hx-vals-extra": {"js": "getKeyboardContext"} + } +}; +``` + ### Automatic Parameters The library automatically adds these parameters to every request: diff --git a/docs/Mouse Support.md b/docs/Mouse Support.md index d476659..37fbbed 100644 --- a/docs/Mouse Support.md +++ b/docs/Mouse Support.md @@ -64,6 +64,70 @@ const combinations = { add_mouse_support('my-element', JSON.stringify(combinations)); ``` +### Dynamic Values with JavaScript Functions + +You can add dynamic values computed at click time using `hx-vals-extra`. This is useful when combined with a Command (which provides `hx-vals` with `c_id`). + +**Configuration format:** +```javascript +const combinations = { + "click": { + "hx-post": "/myfasthtml/commands", + "hx-vals": {"c_id": "command_id"}, // Static values from Command + "hx-vals-extra": {"js": "getClickData"} // Dynamic values via JS function + } +}; +``` + +**How it works:** +1. `hx-vals` contains static values (e.g., `c_id` from Command) +2. `hx-vals-extra.dict` contains additional static values (merged) +3. `hx-vals-extra.js` specifies a function to call for dynamic values (merged) + +**JavaScript function definition:** +```javascript +// Function receives (event, element, combinationStr) +function getClickData(event, element, combination) { + return { + x: event.clientX, + y: event.clientY, + target_id: event.target.id, + timestamp: Date.now() + }; +} +``` + +The function parameters are optional - use what you need: + +```javascript +// Full context +function getFullContext(event, element, combination) { + return { x: event.clientX, elem: element.id, combo: combination }; +} + +// Just the event +function getPosition(event) { + return { x: event.clientX, y: event.clientY }; +} + +// No parameters needed +function getTimestamp() { + return { ts: Date.now() }; +} +``` + +**Built-in helper function:** +```javascript +// getCellId() - finds parent with .dt2-cell class and returns its id +function getCellId(event) { + const cell = event.target.closest('.dt2-cell'); + if (cell && cell.id) { + return { cell_id: cell.id }; + } + return {}; +} +``` + ## API Reference ### add_mouse_support(elementId, combinationsJson) @@ -150,16 +214,155 @@ The library automatically adds these parameters to every HTMX request: ## Python Integration -### Basic Usage +### Mouse Class + +The `Mouse` class provides a convenient way to add mouse support to elements. ```python +from myfasthtml.controls.Mouse import Mouse +from myfasthtml.core.commands import Command + +# Create mouse support for an element +mouse = Mouse(parent_element) + +# Add combinations +mouse.add("click", select_command) +mouse.add("ctrl+click", toggle_command) +mouse.add("right_click", context_menu_command) +``` + +### Mouse.add() Method + +```python +def add(self, sequence: str, command: Command = None, *, + hx_post: str = None, hx_get: str = None, hx_put: str = None, + hx_delete: str = None, hx_patch: str = None, + hx_target: str = None, hx_swap: str = None, hx_vals=None) +``` + +**Parameters**: +- `sequence`: Mouse event sequence (e.g., "click", "ctrl+click", "click right_click") +- `command`: Optional Command object for server-side action +- `hx_post`, `hx_get`, etc.: HTMX URL parameters (override command) +- `hx_target`: HTMX target selector (overrides command) +- `hx_swap`: HTMX swap strategy (overrides command) +- `hx_vals`: Additional HTMX values - dict or "js:functionName()" for dynamic values + +**Note**: +- Named parameters (except `hx_vals`) override the command's parameters. +- `hx_vals` is **merged** with command's values (stored in `hx-vals-extra`), preserving `c_id`. + +### Usage Patterns + +**With Command only**: +```python +mouse.add("click", my_command) +``` + +**With Command and overrides**: +```python +# Command provides hx-post, but we override the target +mouse.add("ctrl+click", my_command, hx_target="#other-result") +``` + +**Without Command (direct HTMX)**: +```python +mouse.add("right_click", hx_post="/context-menu", hx_target="#menu", hx_swap="innerHTML") +``` + +**With dynamic values**: +```python +mouse.add("shift+click", my_command, hx_vals="js:getClickPosition()") +``` + +### Sequences + +```python +mouse = Mouse(element) +mouse.add("click", single_click_command) +mouse.add("click click", double_click_command) +mouse.add("click right_click", special_action_command) +``` + +### Multiple Elements + +```python +# Each element gets its own Mouse instance +for item in items: + mouse = Mouse(item) + mouse.add("click", Command("select", "Select item", lambda i=item: select(i))) + mouse.add("ctrl+click", Command("toggle", "Toggle item", lambda i=item: toggle(i))) +``` + +### Dynamic hx-vals with JavaScript + +You can use `"js:functionName()"` to call a client-side JavaScript function that returns additional values to send with the request. The command's `c_id` is preserved. + +**Python**: +```python +mouse.add("click", my_command, hx_vals="js:getClickContext()") +``` + +**Generated config** (internally): +```json +{ + "hx-post": "/myfasthtml/commands", + "hx-vals": {"c_id": "command_id"}, + "hx-vals-extra": {"js": "getClickContext"} +} +``` + +**JavaScript** (client-side): +```javascript +// Function receives (event, element, combinationStr) +function getClickContext(event, element, combination) { + return { + x: event.clientX, + y: event.clientY, + elementId: element.id, + combo: combination + }; +} + +// Simple function - parameters are optional +function getTimestamp() { + return { ts: Date.now() }; +} +``` + +**Values sent to server**: +```json +{ + "c_id": "command_id", + "x": 150, + "y": 200, + "elementId": "my-element", + "combo": "click", + "combination": "click", + "has_focus": false, + "is_inside": true +} +``` + +You can also pass a static dict: +```python +mouse.add("click", my_command, hx_vals={"extra_key": "extra_value"}) +``` + +### Low-Level Usage (without Mouse class) + +For advanced use cases, you can generate the JavaScript directly: + +```python +import json + combinations = { "click": { "hx-post": "/item/select" }, "ctrl+click": { "hx-post": "/item/select-multiple", - "hx-vals": json.dumps({"mode": "multi"}) + "hx-vals": {"mode": "multi"} }, "right_click": { "hx-post": "/item/context-menu", @@ -168,41 +371,7 @@ combinations = { } } -f"add_mouse_support('{element_id}', '{json.dumps(combinations)}')" -``` - -### Sequences - -```python -combinations = { - "click": { - "hx-post": "/single-click" - }, - "click click": { - "hx-post": "/double-click-sequence" - }, - "click right_click": { - "hx-post": "/click-then-right-click" - } -} -``` - -### Multiple Elements - -```python -# Item 1 -item1_combinations = { - "click": {"hx-post": f"/item/1/select"}, - "ctrl+click": {"hx-post": f"/item/1/toggle"} -} -f"add_mouse_support('item-1', '{json.dumps(item1_combinations)}')" - -# Item 2 -item2_combinations = { - "click": {"hx-post": f"/item/2/select"}, - "ctrl+click": {"hx-post": f"/item/2/toggle"} -} -f"add_mouse_support('item-2', '{json.dumps(item2_combinations)}')" +Script(f"add_mouse_support('{element_id}', '{json.dumps(combinations)}')") ``` ## Behavior Details diff --git a/src/myfasthtml/assets/myfasthtml.css b/src/myfasthtml/assets/myfasthtml.css index 44dba99..5fcfbcd 100644 --- a/src/myfasthtml/assets/myfasthtml.css +++ b/src/myfasthtml/assets/myfasthtml.css @@ -2,6 +2,8 @@ --color-border-primary: color-mix(in oklab, var(--color-primary) 40%, #0000); --color-border: color-mix(in oklab, var(--color-base-content) 20%, #0000); --color-resize: color-mix(in oklab, var(--color-base-content) 50%, #0000); + --color-selection: color-mix(in oklab, var(--color-primary) 20%, #0000); + --datagrid-resize-zindex: 1; --font-sans: ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji'; --font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace; @@ -851,7 +853,7 @@ .dt2-row { display: flex; width: 100%; - height: 22px; + height: 20px; } /* Cell */ @@ -928,6 +930,34 @@ color: var(--color-accent); } + +.dt2-selected-focus { + outline: 2px solid var(--color-primary); + outline-offset: -3px; /* Ensure the outline is snug to the cell */ +} + +.dt2-cell:hover, +.dt2-selected-cell { + background-color: var(--color-selection); +} + +.dt2-selected-row { + background-color: var(--color-selection); +} + +.dt2-selected-column { + background-color: var(--color-selection); +} + +.dt2-hover-row { + background-color: var(--color-selection); +} + +.dt2-hover-column { + background-color: var(--color-selection); +} + + /* *********************************************** */ /* ******** DataGrid Fixed Header/Footer ******** */ /* *********************************************** */ diff --git a/src/myfasthtml/assets/myfasthtml.js b/src/myfasthtml/assets/myfasthtml.js index 33b88ef..a431335 100644 --- a/src/myfasthtml/assets/myfasthtml.js +++ b/src/myfasthtml/assets/myfasthtml.js @@ -395,6 +395,128 @@ function updateTabs(controllerId) { } } +/** + * Find the parent element with .dt2-cell class and return its id. + * Used with hx-vals="js:getCellId()" for DataGrid cell identification. + * + * @param {MouseEvent} event - The mouse event + * @returns {Object} Object with cell_id property, or empty object if not found + */ +function getCellId(event) { + const cell = event.target.closest('.dt2-cell'); + if (cell && cell.id) { + return {cell_id: cell.id}; + } + return {cell_id: null}; +} + +/** + * Shared utility function for triggering HTMX actions from keyboard/mouse bindings. + * Handles dynamic hx-vals with "js:functionName()" syntax. + * + * @param {string} elementId - ID of the element + * @param {Object} config - HTMX configuration object + * @param {string} combinationStr - The matched combination string + * @param {boolean} isInside - Whether the focus/click is inside the element + * @param {Event} event - The event that triggered this action (KeyboardEvent or MouseEvent) + */ +function triggerHtmxAction(elementId, config, combinationStr, isInside, event) { + const element = document.getElementById(elementId); + if (!element) return; + + const hasFocus = document.activeElement === element; + + // Extract HTTP method and URL from hx-* attributes + let method = 'POST'; // default + let url = null; + + const methodMap = { + 'hx-post': 'POST', + 'hx-get': 'GET', + 'hx-put': 'PUT', + 'hx-delete': 'DELETE', + 'hx-patch': 'PATCH' + }; + + for (const [attr, httpMethod] of Object.entries(methodMap)) { + if (config[attr]) { + method = httpMethod; + url = config[attr]; + break; + } + } + + if (!url) { + console.error('No HTTP method attribute found in config:', config); + return; + } + + // Build htmx.ajax options + const htmxOptions = {}; + + // Map hx-target to target + if (config['hx-target']) { + htmxOptions.target = config['hx-target']; + } + + // Map hx-swap to swap + if (config['hx-swap']) { + htmxOptions.swap = config['hx-swap']; + } + + // Map hx-vals to values and add combination, has_focus, and is_inside + const values = {}; + + // 1. Merge static hx-vals from command (if present) + if (config['hx-vals'] && typeof config['hx-vals'] === 'object') { + Object.assign(values, config['hx-vals']); + } + + // 2. Merge hx-vals-extra (user overrides) + if (config['hx-vals-extra']) { + const extra = config['hx-vals-extra']; + + // Merge static dict values + if (extra.dict && typeof extra.dict === 'object') { + Object.assign(values, extra.dict); + } + + // Call dynamic JS function and merge result + if (extra.js) { + try { + const func = window[extra.js]; + if (typeof func === 'function') { + const dynamicValues = func(event, element, combinationStr); + if (dynamicValues && typeof dynamicValues === 'object') { + Object.assign(values, dynamicValues); + } + } else { + console.error(`Function "${extra.js}" not found on window`); + } + } catch (e) { + console.error('Error calling dynamic hx-vals function:', e); + } + } + } + + values.combination = combinationStr; + values.has_focus = hasFocus; + values.is_inside = isInside; + htmxOptions.values = values; + + // Add any other hx-* attributes (like hx-headers, hx-select, etc.) + for (const [key, value] of Object.entries(config)) { + if (key.startsWith('hx-') && !['hx-post', 'hx-get', 'hx-put', 'hx-delete', 'hx-patch', 'hx-target', 'hx-swap', 'hx-vals'].includes(key)) { + // Remove 'hx-' prefix and convert to camelCase + const optionKey = key.substring(3).replace(/-([a-z])/g, (g) => g[1].toUpperCase()); + htmxOptions[optionKey] = value; + } + } + + // Make AJAX call with htmx + htmx.ajax(method, url, htmxOptions); +} + /** * Create keyboard bindings */ @@ -553,80 +675,6 @@ function updateTabs(controllerId) { return false; } - /** - * Trigger an action for a matched combination - * @param {string} elementId - ID of the element - * @param {Object} config - HTMX configuration object - * @param {string} combinationStr - The matched combination string - * @param {boolean} isInside - Whether the focus is inside the element - */ - function triggerAction(elementId, config, combinationStr, isInside) { - const element = document.getElementById(elementId); - if (!element) return; - - const hasFocus = document.activeElement === element; - - // Extract HTTP method and URL from hx-* attributes - let method = 'POST'; // default - let url = null; - - const methodMap = { - 'hx-post': 'POST', - 'hx-get': 'GET', - 'hx-put': 'PUT', - 'hx-delete': 'DELETE', - 'hx-patch': 'PATCH' - }; - - for (const [attr, httpMethod] of Object.entries(methodMap)) { - if (config[attr]) { - method = httpMethod; - url = config[attr]; - break; - } - } - - if (!url) { - console.error('No HTTP method attribute found in config:', config); - return; - } - - // Build htmx.ajax options - const htmxOptions = {}; - - // Map hx-target to target - if (config['hx-target']) { - htmxOptions.target = config['hx-target']; - } - - // Map hx-swap to swap - if (config['hx-swap']) { - htmxOptions.swap = config['hx-swap']; - } - - // Map hx-vals to values and add combination, has_focus, and is_inside - const values = {}; - if (config['hx-vals']) { - Object.assign(values, config['hx-vals']); - } - values.combination = combinationStr; - values.has_focus = hasFocus; - values.is_inside = isInside; - htmxOptions.values = values; - - // Add any other hx-* attributes (like hx-headers, hx-select, etc.) - for (const [key, value] of Object.entries(config)) { - if (key.startsWith('hx-') && !['hx-post', 'hx-get', 'hx-put', 'hx-delete', 'hx-patch', 'hx-target', 'hx-swap', 'hx-vals'].includes(key)) { - // Remove 'hx-' prefix and convert to camelCase - const optionKey = key.substring(3).replace(/-([a-z])/g, (g) => g[1].toUpperCase()); - htmxOptions[optionKey] = value; - } - } - - // Make AJAX call with htmx - htmx.ajax(method, url, htmxOptions); - } - /** * Handle keyboard events and trigger matching combinations * @param {KeyboardEvent} event - The keyboard event @@ -710,7 +758,7 @@ function updateTabs(controllerId) { // We have matches and NO element has longer sequences possible // Trigger ALL matches immediately for (const match of currentMatches) { - triggerAction(match.elementId, match.config, match.combinationStr, match.isInside); + triggerHtmxAction(match.elementId, match.config, match.combinationStr, match.isInside, event); } // Clear history after triggering @@ -721,11 +769,12 @@ function updateTabs(controllerId) { // Wait for timeout - ALL current matches will be triggered if timeout expires KeyboardRegistry.pendingMatches = currentMatches; + const savedEvent = event; // Save event for timeout callback KeyboardRegistry.pendingTimeout = setTimeout(() => { // Timeout expired, trigger ALL pending matches for (const match of KeyboardRegistry.pendingMatches) { - triggerAction(match.elementId, match.config, match.combinationStr, match.isInside); + triggerHtmxAction(match.elementId, match.config, match.combinationStr, match.isInside, savedEvent); } // Clear state @@ -1051,80 +1100,6 @@ function updateTabs(controllerId) { return actions; } - /** - * Trigger an action for a matched combination - * @param {string} elementId - ID of the element - * @param {Object} config - HTMX configuration object - * @param {string} combinationStr - The matched combination string - * @param {boolean} isInside - Whether the click was inside the element - */ - function triggerAction(elementId, config, combinationStr, isInside) { - const element = document.getElementById(elementId); - if (!element) return; - - const hasFocus = document.activeElement === element; - - // Extract HTTP method and URL from hx-* attributes - let method = 'POST'; // default - let url = null; - - const methodMap = { - 'hx-post': 'POST', - 'hx-get': 'GET', - 'hx-put': 'PUT', - 'hx-delete': 'DELETE', - 'hx-patch': 'PATCH' - }; - - for (const [attr, httpMethod] of Object.entries(methodMap)) { - if (config[attr]) { - method = httpMethod; - url = config[attr]; - break; - } - } - - if (!url) { - console.error('No HTTP method attribute found in config:', config); - return; - } - - // Build htmx.ajax options - const htmxOptions = {}; - - // Map hx-target to target - if (config['hx-target']) { - htmxOptions.target = config['hx-target']; - } - - // Map hx-swap to swap - if (config['hx-swap']) { - htmxOptions.swap = config['hx-swap']; - } - - // Map hx-vals to values and add combination, has_focus, and is_inside - const values = {}; - if (config['hx-vals']) { - Object.assign(values, config['hx-vals']); - } - values.combination = combinationStr; - values.has_focus = hasFocus; - values.is_inside = isInside; - htmxOptions.values = values; - - // Add any other hx-* attributes (like hx-headers, hx-select, etc.) - for (const [key, value] of Object.entries(config)) { - if (key.startsWith('hx-') && !['hx-post', 'hx-get', 'hx-put', 'hx-delete', 'hx-patch', 'hx-target', 'hx-swap', 'hx-vals'].includes(key)) { - // Remove 'hx-' prefix and convert to camelCase - const optionKey = key.substring(3).replace(/-([a-z])/g, (g) => g[1].toUpperCase()); - htmxOptions[optionKey] = value; - } - } - - // Make AJAX call with htmx - htmx.ajax(method, url, htmxOptions); - } - /** * Handle mouse events and trigger matching combinations * @param {MouseEvent} event - The mouse event @@ -1223,7 +1198,7 @@ function updateTabs(controllerId) { // We have matches and NO longer sequences possible // Trigger ALL matches immediately for (const match of currentMatches) { - triggerAction(match.elementId, match.config, match.combinationStr, match.isInside); + triggerHtmxAction(match.elementId, match.config, match.combinationStr, match.isInside, event); } // Clear history after triggering @@ -1234,11 +1209,12 @@ function updateTabs(controllerId) { // Wait for timeout - ALL current matches will be triggered if timeout expires MouseRegistry.pendingMatches = currentMatches; + const savedEvent = event; // Save event for timeout callback MouseRegistry.pendingTimeout = setTimeout(() => { // Timeout expired, trigger ALL pending matches for (const match of MouseRegistry.pendingMatches) { - triggerAction(match.elementId, match.config, match.combinationStr, match.isInside); + triggerHtmxAction(match.elementId, match.config, match.combinationStr, match.isInside, savedEvent); } // Clear state @@ -1267,9 +1243,8 @@ function updateTabs(controllerId) { MouseRegistry.snapshotHistory = []; } - // DEBUG: Log click handler performance + // Warn if click handler is slow const clickDuration = performance.now() - clickStart; - console.warn(`🖱️ Click handler DONE: ${clickDuration.toFixed(2)}ms (${iterationCount} iterations, ${currentMatches.length} matches)`); if (clickDuration > 100) { console.warn(`⚠️ SLOW CLICK HANDLER: ${clickDuration.toFixed(2)}ms for ${elementCount} elements`); } @@ -1361,7 +1336,7 @@ function updateTabs(controllerId) { // We have matches and NO longer sequences possible // Trigger ALL matches immediately for (const match of currentMatches) { - triggerAction(match.elementId, match.config, match.combinationStr, match.isInside); + triggerHtmxAction(match.elementId, match.config, match.combinationStr, match.isInside, event); } // Clear history after triggering @@ -1372,11 +1347,12 @@ function updateTabs(controllerId) { // Wait for timeout - ALL current matches will be triggered if timeout expires MouseRegistry.pendingMatches = currentMatches; + const savedEvent = event; // Save event for timeout callback MouseRegistry.pendingTimeout = setTimeout(() => { // Timeout expired, trigger ALL pending matches for (const match of MouseRegistry.pendingMatches) { - triggerAction(match.elementId, match.config, match.combinationStr, match.isInside); + triggerHtmxAction(match.elementId, match.config, match.combinationStr, match.isInside, savedEvent); } // Clear state @@ -1495,8 +1471,14 @@ function updateTabs(controllerId) { function initDataGrid(gridId) { initDataGridScrollbars(gridId); + initDataGridMouseOver(gridId); makeDatagridColumnsResizable(gridId); makeDatagridColumnsMovable(gridId); + updateDatagridSelection(gridId) +} + +function initDataGridMouseOver(gridId) { + } /** @@ -1656,7 +1638,7 @@ function initDataGridScrollbars(gridId) { dragStartY = e.clientY; dragStartScrollTop = cachedBodyScrollTop; wrapper.setAttribute("mf-no-tooltip", ""); - }, { signal }); + }, {signal}); // Horizontal scrollbar mousedown horizontalScrollbar.addEventListener("mousedown", (e) => { @@ -1664,7 +1646,7 @@ function initDataGridScrollbars(gridId) { dragStartX = e.clientX; dragStartScrollLeft = cachedTableScrollLeft; wrapper.setAttribute("mf-no-tooltip", ""); - }, { signal }); + }, {signal}); // Consolidated mousemove listener document.addEventListener("mousemove", (e) => { @@ -1695,7 +1677,7 @@ function initDataGridScrollbars(gridId) { }); } } - }, { signal }); + }, {signal}); // Consolidated mouseup listener document.addEventListener("mouseup", () => { @@ -1706,7 +1688,7 @@ function initDataGridScrollbars(gridId) { isDraggingHorizontal = false; wrapper.removeAttribute("mf-no-tooltip"); } - }, { signal }); + }, {signal}); // Wheel scrolling - OPTIMIZED with RAF throttling let rafScheduledWheel = false; @@ -1759,7 +1741,7 @@ function initDataGridScrollbars(gridId) { updateScrollbars(); }); } - }, { signal }); + }, {signal}); } function makeDatagridColumnsResizable(datagridId) { @@ -2022,3 +2004,44 @@ function moveColumn(table, sourceColId, targetColId) { }); }, ANIMATION_DURATION); } + +function updateDatagridSelection(datagridId) { + const selectionManager = document.getElementById(`tsm_${datagridId}`); + if (!selectionManager) return; + + // Clear previous selections + document.querySelectorAll('.dt2-selected-focus, .dt2-selected-cell, .dt2-selected-row, .dt2-selected-column').forEach((element) => { + element.classList.remove('dt2-selected-focus', 'dt2-selected-cell', 'dt2-selected-row', 'dt2-selected-column'); + element.style.userSelect = 'none'; + }); + + // Loop through the children of the selection manager + Array.from(selectionManager.children).forEach((selection) => { + const selectionType = selection.getAttribute('selection-type'); + const elementId = selection.getAttribute('element-id'); + + if (selectionType === 'focus') { + const cellElement = document.getElementById(`${elementId}`); + if (cellElement) { + cellElement.classList.add('dt2-selected-focus'); + cellElement.style.userSelect = 'text'; + } + } else if (selectionType === 'cell') { + const cellElement = document.getElementById(`${elementId}`); + if (cellElement) { + cellElement.classList.add('dt2-selected-cell'); + cellElement.style.userSelect = 'text'; + } + } else if (selectionType === 'row') { + const rowElement = document.getElementById(`${elementId}`); + if (rowElement) { + rowElement.classList.add('dt2-selected-row'); + } + } else if (selectionType === 'column') { + // Select all elements in the specified column + document.querySelectorAll(`[data-col="${elementId}"]`).forEach((columnElement) => { + columnElement.classList.add('dt2-selected-column'); + }); + } + }); +} \ No newline at end of file diff --git a/src/myfasthtml/controls/DataGrid.py b/src/myfasthtml/controls/DataGrid.py index 12dee37..83acc0e 100644 --- a/src/myfasthtml/controls/DataGrid.py +++ b/src/myfasthtml/controls/DataGrid.py @@ -11,6 +11,7 @@ from pandas import DataFrame from myfasthtml.controls.BaseCommands import BaseCommands from myfasthtml.controls.DataGridQuery import DataGridQuery, DG_QUERY_FILTER +from myfasthtml.controls.Mouse import Mouse from myfasthtml.controls.datagrid_objects import DataGridColumnState, DataGridRowState, \ DatagridSelectionState, DataGridHeaderFooterConf, DatagridEditionState from myfasthtml.controls.helpers import mk @@ -110,6 +111,13 @@ class Commands(BaseCommands): self._owner, self._owner.filter ) + + def on_click(self): + return Command("OnClick", + "Click on the table", + self._owner, + self._owner.on_click + ).htmx(target=f"#tsm_{self._id}") class DataGrid(MultipleInstance): @@ -121,6 +129,11 @@ class DataGrid(MultipleInstance): self.init_from_dataframe(self._state.ne_df, init_state=False) # state comes from DatagridState self._datagrid_filter = DataGridQuery(self) self._datagrid_filter.bind_command("QueryChanged", self.commands.filter()) + self._datagrid_filter.bind_command("CancelQuery", self.commands.filter()) + self._datagrid_filter.bind_command("ChangeFilterType", self.commands.filter()) + + # update the filter + self._state.filtered[FILTER_INPUT_CID] = self._datagrid_filter.get_query() @property def _df(self): @@ -169,6 +182,31 @@ class DataGrid(MultipleInstance): return df + def _get_element_id_from_pos(self, selection_mode, pos): + if pos is None or pos == (None, None): + return None + elif selection_mode == "row": + return f"trow_{self._id}-{pos[0]}" + elif selection_mode == "column": + return f"tcol_{self._id}-{pos[1]}" + else: + return f"tcell_{self._id}-{pos[0]}-{pos[1]}" + + def _get_pos_from_element_id(self, element_id): + if element_id is None: + return None + + if element_id.startswith("tcell_"): + parts = element_id.split("-") + return int(parts[-2]), int(parts[-1]) + + return None + + def _update_current_position(self, pos): + self._state.selection.last_selected = self._state.selection.selected + self._state.selection.selected = pos + self._state.save() + def init_from_dataframe(self, df, init_state=True): def _get_column_type(dtype): @@ -271,7 +309,16 @@ class DataGrid(MultipleInstance): def filter(self): logger.debug("filter") self._state.filtered[FILTER_INPUT_CID] = self._datagrid_filter.get_query() - return self.mk_body_container(redraw_scrollbars=True) + return self.render_partial("body", redraw_scrollbars=True) + + def on_click(self, combination, is_inside, cell_id): + logger.debug(f"on_click {combination=} {is_inside=} {cell_id=}") + if is_inside and cell_id: + if cell_id.startswith("tcell_"): + pos = self._get_pos_from_element_id(cell_id) + self._update_current_position(pos) + + return self.render_partial() def mk_headers(self): resize_cmd = self.commands.set_column_width() @@ -375,6 +422,7 @@ class DataGrid(MultipleInstance): data_col=col_def.col_id, data_tooltip=str(value), style=f"width:{col_def.width}px;", + id=self._get_element_id_from_pos("cell", (row_index, col_pos)), cls="dt2-cell") def mk_body_content_page(self, page_index: int): @@ -404,12 +452,11 @@ class DataGrid(MultipleInstance): return rows - def mk_body_container(self, redraw_scrollbars=False): + def mk_body_container(self): return Div( self.mk_body(), - Script(f"initDataGridScrollbars('{self._id}');") if redraw_scrollbars else None, cls="dt2-body-container", - id=f"tb_{self._id}" + id=f"tb_{self._id}", ) def mk_body(self): @@ -433,6 +480,8 @@ class DataGrid(MultipleInstance): def mk_table(self): return Div( + self.mk_selection_manager(), + # Grid table with header, body, footer Div( # Header container - no scroll @@ -469,6 +518,25 @@ class DataGrid(MultipleInstance): id=f"tw_{self._id}" ) + def mk_selection_manager(self): + + extra_attr = { + "hx-on::after-settle": f"updateDatagridSelection('{self._id}');", + } + + selected = [] + + if self._state.selection.selected: + #selected.append(("cell", self._get_element_id_from_pos("cell", self._state.selection.selected))) + selected.append(("focus", self._get_element_id_from_pos("cell", self._state.selection.selected))) + + return Div( + *[Div(selection_type=s_type, element_id=f"{elt_id}") for s_type, elt_id in selected], + id=f"tsm_{self._id}", + selection_mode=f"{self._state.selection.selection_mode}", + **extra_attr, + ) + def mk_aggregation_cell(self, col_def, row_index: int, footer_conf, oob=False): """ Generates a footer cell for a data table based on the provided column definition, @@ -535,14 +603,43 @@ class DataGrid(MultipleInstance): if self._state.ne_df is None: return Div("No data to display !") + mouse_support = { + "click": {"command": self.commands.on_click(), "hx_vals": "js:getCellId()"}, + "ctrl+click": {"command": self.commands.on_click(), "hx_vals": "js:getCellId()"}, + "shift+click": {"command": self.commands.on_click(), "hx_vals": "js:getCellId()"}, + } + return Div( Div(self._datagrid_filter, cls="mb-2"), self.mk_table(), Script(f"initDataGrid('{self._id}');"), + Mouse(self, combinations=mouse_support), id=self._id, cls="grid", style="height: 100%; grid-template-rows: auto 1fr;" ) + def render_partial(self, fragment="cell", redraw_scrollbars=False): + """ + + :param fragment: cell | body + :param redraw_scrollbars: + :return: + """ + res = [] + + extra_attr = { + "hx-on::after-settle": f"initDataGridScrollbars('{self._id}');", + } + + if fragment == "body": + body_container = self.mk_body_container() + body_container.attrs.update(extra_attr) + res.append(body_container) + + res.append(self.mk_selection_manager()) + + return tuple(res) + def __ft__(self): return self.render() diff --git a/src/myfasthtml/controls/DataGridQuery.py b/src/myfasthtml/controls/DataGridQuery.py index d9362a4..9f09fdb 100644 --- a/src/myfasthtml/controls/DataGridQuery.py +++ b/src/myfasthtml/controls/DataGridQuery.py @@ -75,7 +75,7 @@ class DataGridQuery(MultipleInstance): def query_changed(self, query): logger.debug(f"query_changed {query=}") - self._state.query = query + self._state.query = query.strip() if query is not None else None return self def render(self): diff --git a/src/myfasthtml/controls/Mouse.py b/src/myfasthtml/controls/Mouse.py index 26a3625..4282d6f 100644 --- a/src/myfasthtml/controls/Mouse.py +++ b/src/myfasthtml/controls/Mouse.py @@ -12,17 +12,112 @@ class Mouse(MultipleInstance): This class is used to add, manage, and render mouse event sequences with corresponding commands, providing a flexible way to handle mouse interactions programmatically. + + Combinations can be defined with: + - A Command object: mouse.add("click", command) + - HTMX parameters: mouse.add("click", hx_post="/url", hx_vals={...}) + - Both (named params override command): mouse.add("click", command, hx_target="#other") + + For dynamic hx_vals, use "js:functionName()" to call a client-side function. """ def __init__(self, parent, _id=None, combinations=None): super().__init__(parent, _id=_id) self.combinations = combinations or {} - - def add(self, sequence: str, command: Command): - self.combinations[sequence] = command + + def add(self, sequence: str, command: Command = None, *, + hx_post: str = None, hx_get: str = None, hx_put: str = None, + hx_delete: str = None, hx_patch: str = None, + hx_target: str = None, hx_swap: str = None, hx_vals=None): + """ + Add a mouse combination with optional command and HTMX parameters. + + Args: + sequence: Mouse event sequence (e.g., "click", "ctrl+click", "click right_click") + command: Optional Command object for server-side action + hx_post: HTMX post URL (overrides command) + hx_get: HTMX get URL (overrides command) + hx_put: HTMX put URL (overrides command) + hx_delete: HTMX delete URL (overrides command) + hx_patch: HTMX patch URL (overrides command) + hx_target: HTMX target selector (overrides command) + hx_swap: HTMX swap strategy (overrides command) + hx_vals: HTMX values dict or "js:functionName()" for dynamic values + + Returns: + self for method chaining + """ + self.combinations[sequence] = { + "command": command, + "hx_post": hx_post, + "hx_get": hx_get, + "hx_put": hx_put, + "hx_delete": hx_delete, + "hx_patch": hx_patch, + "hx_target": hx_target, + "hx_swap": hx_swap, + "hx_vals": hx_vals, + } return self - + + def _build_htmx_params(self, combination_data: dict) -> dict: + """ + Build HTMX parameters by merging command params with named overrides. + + Named parameters take precedence over command parameters. + hx_vals is handled separately via hx-vals-extra to preserve command's hx-vals. + """ + command = combination_data.get("command") + + # Start with command params if available + if command is not None: + params = command.get_htmx_params().copy() + else: + params = {} + + # Override with named parameters (only if explicitly set) + # Note: hx_vals is handled separately below + param_mapping = { + "hx_post": "hx-post", + "hx_get": "hx-get", + "hx_put": "hx-put", + "hx_delete": "hx-delete", + "hx_patch": "hx-patch", + "hx_target": "hx-target", + "hx_swap": "hx-swap", + } + + for py_name, htmx_name in param_mapping.items(): + value = combination_data.get(py_name) + if value is not None: + params[htmx_name] = value + + # Handle hx_vals separately - store in hx-vals-extra to not overwrite command's hx-vals + hx_vals = combination_data.get("hx_vals") + if hx_vals is not None: + if isinstance(hx_vals, str) and hx_vals.startswith("js:"): + # Dynamic values: extract function name + func_name = hx_vals[3:].rstrip("()") + params["hx-vals-extra"] = {"js": func_name} + elif isinstance(hx_vals, dict): + # Static dict values + params["hx-vals-extra"] = {"dict": hx_vals} + else: + # Other string values - try to parse as JSON + try: + parsed = json.loads(hx_vals) + if not isinstance(parsed, dict): + raise ValueError(f"hx_vals must be a dict, got {type(parsed).__name__}") + params["hx-vals-extra"] = {"dict": parsed} + except json.JSONDecodeError as e: + raise ValueError(f"hx_vals must be a dict or 'js:functionName()', got invalid JSON: {e}") + + return params + def render(self): - str_combinations = {sequence: command.get_htmx_params() for sequence, command in self.combinations.items()} + str_combinations = { + sequence: self._build_htmx_params(data) + for sequence, data in self.combinations.items() + } return Script(f"add_mouse_support('{self._parent.get_id()}', '{json.dumps(str_combinations)}')") def __ft__(self): diff --git a/src/myfasthtml/controls/datagrid_objects.py b/src/myfasthtml/controls/datagrid_objects.py index 368e2bf..43ce23b 100644 --- a/src/myfasthtml/controls/datagrid_objects.py +++ b/src/myfasthtml/controls/datagrid_objects.py @@ -9,6 +9,7 @@ class DataGridRowState: visible: bool = True height: int | None = None + @dataclass class DataGridColumnState: col_id: str # name of the column: cannot be changed @@ -40,8 +41,6 @@ class DataGridHeaderFooterConf: conf: dict[str, str] = field(default_factory=dict) # first 'str' is the column id - - @dataclass class DatagridView: name: str