@base44/vite-plugin 0.2.25 → 0.2.27
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/capabilities/inline-edit/controller.d.ts +3 -0
- package/dist/capabilities/inline-edit/controller.d.ts.map +1 -0
- package/dist/capabilities/inline-edit/controller.js +194 -0
- package/dist/capabilities/inline-edit/controller.js.map +1 -0
- package/dist/capabilities/inline-edit/dom-utils.d.ts +6 -0
- package/dist/capabilities/inline-edit/dom-utils.d.ts.map +1 -0
- package/dist/capabilities/inline-edit/dom-utils.js +49 -0
- package/dist/capabilities/inline-edit/dom-utils.js.map +1 -0
- package/dist/capabilities/inline-edit/index.d.ts +3 -0
- package/dist/capabilities/inline-edit/index.d.ts.map +1 -0
- package/dist/capabilities/inline-edit/index.js +2 -0
- package/dist/capabilities/inline-edit/index.js.map +1 -0
- package/dist/capabilities/inline-edit/types.d.ts +25 -0
- package/dist/capabilities/inline-edit/types.d.ts.map +1 -0
- package/dist/capabilities/inline-edit/types.js +2 -0
- package/dist/capabilities/inline-edit/types.js.map +1 -0
- package/dist/injections/layer-dropdown/consts.d.ts +19 -0
- package/dist/injections/layer-dropdown/consts.d.ts.map +1 -0
- package/dist/injections/layer-dropdown/consts.js +40 -0
- package/dist/injections/layer-dropdown/consts.js.map +1 -0
- package/dist/injections/layer-dropdown/controller.d.ts +4 -0
- package/dist/injections/layer-dropdown/controller.d.ts.map +1 -0
- package/dist/injections/layer-dropdown/controller.js +88 -0
- package/dist/injections/layer-dropdown/controller.js.map +1 -0
- package/dist/injections/layer-dropdown/dropdown-ui.d.ts +13 -0
- package/dist/injections/layer-dropdown/dropdown-ui.d.ts.map +1 -0
- package/dist/injections/layer-dropdown/dropdown-ui.js +176 -0
- package/dist/injections/layer-dropdown/dropdown-ui.js.map +1 -0
- package/dist/injections/layer-dropdown/types.d.ts +26 -0
- package/dist/injections/layer-dropdown/types.d.ts.map +1 -0
- package/dist/injections/layer-dropdown/types.js +3 -0
- package/dist/injections/layer-dropdown/types.js.map +1 -0
- package/dist/injections/layer-dropdown/utils.d.ts +25 -0
- package/dist/injections/layer-dropdown/utils.d.ts.map +1 -0
- package/dist/injections/layer-dropdown/utils.js +143 -0
- package/dist/injections/layer-dropdown/utils.js.map +1 -0
- package/dist/injections/utils.d.ts +4 -0
- package/dist/injections/utils.d.ts.map +1 -1
- package/dist/injections/utils.js +12 -0
- package/dist/injections/utils.js.map +1 -1
- package/dist/injections/visual-edit-agent.d.ts.map +1 -1
- package/dist/injections/visual-edit-agent.js +151 -76
- package/dist/injections/visual-edit-agent.js.map +1 -1
- package/dist/statics/index.mjs +5 -1
- package/dist/statics/index.mjs.map +1 -1
- package/package.json +1 -1
- package/src/capabilities/inline-edit/controller.ts +245 -0
- package/src/capabilities/inline-edit/dom-utils.ts +48 -0
- package/src/capabilities/inline-edit/index.ts +2 -0
- package/src/capabilities/inline-edit/types.ts +30 -0
- package/src/injections/layer-dropdown/LAYERS.md +258 -0
- package/src/injections/layer-dropdown/consts.ts +49 -0
- package/src/injections/layer-dropdown/controller.ts +109 -0
- package/src/injections/layer-dropdown/dropdown-ui.ts +230 -0
- package/src/injections/layer-dropdown/types.ts +30 -0
- package/src/injections/layer-dropdown/utils.ts +175 -0
- package/src/injections/utils.ts +18 -0
- package/src/injections/visual-edit-agent.ts +170 -82
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
# Layer Dropdown Feature
|
|
2
|
+
|
|
3
|
+
## Overview
|
|
4
|
+
|
|
5
|
+
The layer dropdown lets users navigate the instrumented DOM hierarchy around a selected element. Clicking the chevron (`▾`) on an element's label reveals a dropdown showing **ancestors**, **siblings**, **the selected element**, and its **children** — all with depth-based indentation.
|
|
6
|
+
|
|
7
|
+
```
|
|
8
|
+
grandparent (depth 0)
|
|
9
|
+
parent (depth 1)
|
|
10
|
+
sibling-1 (depth 2)
|
|
11
|
+
★ selected (depth 2) ← highlighted in blue
|
|
12
|
+
child-a (depth 3)
|
|
13
|
+
child-b (depth 3)
|
|
14
|
+
sibling-2 (depth 2)
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
Children are only expanded for the selected element, not for siblings.
|
|
18
|
+
|
|
19
|
+
---
|
|
20
|
+
|
|
21
|
+
## Architecture
|
|
22
|
+
|
|
23
|
+
```
|
|
24
|
+
┌─────────────┐ ┌────────────────┐ ┌────────────────┐
|
|
25
|
+
│ types.ts │◄────│ controller.ts │────►│ dropdown-ui.ts│
|
|
26
|
+
│ consts.ts │◄────│ (orchestrator)│ │ (rendering) │
|
|
27
|
+
└─────────────┘ └───────┬────────┘ └────────────────┘
|
|
28
|
+
│
|
|
29
|
+
┌─────▼──────┐
|
|
30
|
+
│ utils.ts │
|
|
31
|
+
│ (DOM walk) │
|
|
32
|
+
└────────────┘
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
| File | Role |
|
|
36
|
+
|------|------|
|
|
37
|
+
| **types.ts** | `LayerInfo`, callback types, `LayerControllerDeps`, `LayerController` |
|
|
38
|
+
| **consts.ts** | Style maps, depth limits, chevron character, attribute name |
|
|
39
|
+
| **utils.ts** | DOM traversal — parent walking, sibling/descendant collection, depth assignment |
|
|
40
|
+
| **dropdown-ui.ts** | Creates the dropdown DOM, positions it, handles hover/click/keyboard |
|
|
41
|
+
| **controller.ts** | Stateful factory that wires everything together: build chain, show dropdown, handle selection |
|
|
42
|
+
|
|
43
|
+
---
|
|
44
|
+
|
|
45
|
+
## Data Model
|
|
46
|
+
|
|
47
|
+
### `LayerInfo`
|
|
48
|
+
|
|
49
|
+
```typescript
|
|
50
|
+
interface LayerInfo {
|
|
51
|
+
element: Element; // DOM reference
|
|
52
|
+
tagName: string; // lowercase tag name ("div", "button")
|
|
53
|
+
selectorId: string | null; // data-source-location or data-visual-selector-id
|
|
54
|
+
depth?: number; // visual indentation level (set during chain building)
|
|
55
|
+
}
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
An element is **instrumented** if it has `dataset.sourceLocation` or `dataset.visualSelectorId`. Only instrumented elements appear in the dropdown.
|
|
59
|
+
|
|
60
|
+
### Key Constants
|
|
61
|
+
|
|
62
|
+
| Constant | Value | Purpose |
|
|
63
|
+
|----------|-------|---------|
|
|
64
|
+
| `MAX_PARENT_DEPTH` | `2` | Max ancestor levels shown above selected |
|
|
65
|
+
| `MAX_CHILD_DEPTH` | `2` | Max descendant levels shown below selected |
|
|
66
|
+
| `DEPTH_INDENT_PX` | `10` | Extra left-padding per depth level |
|
|
67
|
+
| `LABEL_CHEVRON` | `" ▾"` | Appended to label text when dropdown is available |
|
|
68
|
+
|
|
69
|
+
---
|
|
70
|
+
|
|
71
|
+
## How the Chain Is Built (`utils.ts`)
|
|
72
|
+
|
|
73
|
+
`buildLayerChain(selectedElement)` is the entry point. It delegates to focused helper functions:
|
|
74
|
+
|
|
75
|
+
```typescript
|
|
76
|
+
export function buildLayerChain(selectedElement: Element): LayerInfo[] {
|
|
77
|
+
const parents = collectInstrumentedParents(selectedElement);
|
|
78
|
+
const chain: LayerInfo[] = [];
|
|
79
|
+
const selfDepth = appendParentsWithDepth(chain, parents);
|
|
80
|
+
|
|
81
|
+
const instrParent = getImmediateInstrParent(parents);
|
|
82
|
+
if (instrParent) {
|
|
83
|
+
const siblings = collectSiblings(instrParent, selectedElement);
|
|
84
|
+
appendSiblingsWithSelected(chain, siblings, selectedElement, selfDepth);
|
|
85
|
+
} else {
|
|
86
|
+
appendSelfAndDescendants(chain, selectedElement, selfDepth);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return chain;
|
|
90
|
+
}
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
### Helper Functions
|
|
94
|
+
|
|
95
|
+
| Function | Purpose |
|
|
96
|
+
|----------|---------|
|
|
97
|
+
| `toLayerInfo(element, depth?)` | Convert a DOM element to a `LayerInfo` object |
|
|
98
|
+
| `collectInstrumentedParents(el)` | Walk up the DOM, collect up to `MAX_PARENT_DEPTH` instrumented ancestors, return outermost-first |
|
|
99
|
+
| `appendParentsWithDepth(chain, parents)` | Push parents into chain with `depth = index` (0, 1, ...), return count as `selfDepth` |
|
|
100
|
+
| `getImmediateInstrParent(parents)` | Return the innermost parent's DOM element, or `null` if `parents` is empty |
|
|
101
|
+
| `collectSiblings(parent, selectedEl)` | Call `getInstrumentedDescendants(parent, 1)` to get all first-level instrumented children in DOM order; safety-guard ensures `selectedEl` is always included |
|
|
102
|
+
| `appendSiblingsWithSelected(chain, siblings, selectedEl, depth)` | Iterate siblings at `selfDepth`; for the selected element, expand children via `appendSelfAndDescendants`; for others, just add at `selfDepth` |
|
|
103
|
+
| `appendSelfAndDescendants(chain, el, depth)` | Push element + its descendants (up to `MAX_CHILD_DEPTH` levels) with assigned depths |
|
|
104
|
+
| `getInstrumentedDescendants(parent, maxDepth)` | Recursive DOM walk — instrumented children increment depth, non-instrumented wrappers are transparent. Results in DOM order |
|
|
105
|
+
| `assignDescendantDepths(root, descendants, startDepth)` | Second-pass walk assigning `depth = startDepth + instrDepth - 1` using a `Set`/`Map` for O(1) lookups |
|
|
106
|
+
|
|
107
|
+
### Chain-Building Steps
|
|
108
|
+
|
|
109
|
+
**Step 1 — `collectInstrumentedParents`**: Walk up from `selectedElement.parentElement`, skipping `document.body` and `document.documentElement`. Collect up to `MAX_PARENT_DEPTH` instrumented ancestors, then reverse so outermost comes first.
|
|
110
|
+
|
|
111
|
+
**Step 2 — `appendParentsWithDepth`**: Each parent gets `depth = index` (0, 1, ...). The count becomes `selfDepth`.
|
|
112
|
+
|
|
113
|
+
**Step 3 — `getImmediateInstrParent`**: Extract the innermost instrumented parent's element. If `parents` is empty, returns `null` (root-level element).
|
|
114
|
+
|
|
115
|
+
**Step 4 — `collectSiblings`** (when parent exists): Call `getInstrumentedDescendants(instrParent, 1)` to get all first-level instrumented children of the parent in DOM order — this naturally includes the selected element among its siblings. A safety guard ensures the selected element is always present.
|
|
116
|
+
|
|
117
|
+
**Step 5 — `appendSiblingsWithSelected`**: Iterate siblings in DOM order, all at `selfDepth`. For the selected element only, delegate to `appendSelfAndDescendants` which also collects and appends children. For other siblings, just push at `selfDepth` with no child expansion.
|
|
118
|
+
|
|
119
|
+
**Fallback** (no parent): If no instrumented parent exists (root-level element), skip sibling collection and call `appendSelfAndDescendants` directly — just self + children.
|
|
120
|
+
|
|
121
|
+
---
|
|
122
|
+
|
|
123
|
+
## Controller Lifecycle (`controller.ts`)
|
|
124
|
+
|
|
125
|
+
`createLayerController(deps)` returns `{ attachToOverlay, cleanup }`.
|
|
126
|
+
|
|
127
|
+
### Dependency Injection
|
|
128
|
+
|
|
129
|
+
| Dep | Called When |
|
|
130
|
+
|-----|------------|
|
|
131
|
+
| `createPreviewOverlay(el)` | User hovers a dropdown item |
|
|
132
|
+
| `getSelectedElementId()` | Checking if hovered item is already selected |
|
|
133
|
+
| `selectElement(el)` | User clicks a dropdown item — performs selection, returns overlay |
|
|
134
|
+
| `onDeselect()` | Dropdown opens — temporarily clears selection for hover previews |
|
|
135
|
+
|
|
136
|
+
### `attachToOverlay(overlay, element)`
|
|
137
|
+
|
|
138
|
+
1. Extract the label `<div>` from the overlay.
|
|
139
|
+
2. Call `buildLayerChain(element)`.
|
|
140
|
+
3. **Guard**: if `layers.length <= 1`, return early — no chevron, no click handler. This is how the chevron is hidden when there's nothing to navigate to.
|
|
141
|
+
4. Append chevron via `enhanceLabelWithChevron(label)`.
|
|
142
|
+
5. Bind click handler to label (toggle behavior).
|
|
143
|
+
|
|
144
|
+
### Click Handler Flow
|
|
145
|
+
|
|
146
|
+
```
|
|
147
|
+
Label clicked
|
|
148
|
+
├─ Dropdown already open → close + reselect source element
|
|
149
|
+
└─ Dropdown closed →
|
|
150
|
+
1. Save source element (for Escape restore)
|
|
151
|
+
2. deps.onDeselect() (clear selection visual)
|
|
152
|
+
3. Register Escape key listener (capture phase)
|
|
153
|
+
4. showDropdown(label, layers, callbacks...)
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
### State Transitions
|
|
157
|
+
|
|
158
|
+
```
|
|
159
|
+
CLOSED ──[click chevron]──► OPEN
|
|
160
|
+
OPEN ──[click chevron]──► CLOSED (reselect original)
|
|
161
|
+
OPEN ──[Escape]──────────► CLOSED (reselect original)
|
|
162
|
+
OPEN ──[click item]─────► CLOSED → NEW ELEMENT SELECTED → reattach
|
|
163
|
+
OPEN ──[click outside]──► CLOSED
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
Selection is recursive — `selectElementFromLayer` calls `deps.selectElement()`, gets a new overlay, and calls `attachToOverlay()` again on it.
|
|
167
|
+
|
|
168
|
+
---
|
|
169
|
+
|
|
170
|
+
## Dropdown UI (`dropdown-ui.ts`)
|
|
171
|
+
|
|
172
|
+
### Global State (singleton)
|
|
173
|
+
|
|
174
|
+
Only one dropdown can be active at a time:
|
|
175
|
+
|
|
176
|
+
```typescript
|
|
177
|
+
let activeDropdown: HTMLDivElement | null = null;
|
|
178
|
+
let outsideMousedownHandler: ((e: MouseEvent) => void) | null = null;
|
|
179
|
+
let activeOnHoverEnd: OnLayerHoverEnd | null = null;
|
|
180
|
+
let activeKeydownHandler: ((e: KeyboardEvent) => void) | null = null;
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
### `showDropdown(label, layers, currentSelectorId, onSelect, onHover, onHoverEnd)`
|
|
184
|
+
|
|
185
|
+
1. Close any existing dropdown.
|
|
186
|
+
2. Create dropdown element with all layer items.
|
|
187
|
+
3. Position it below the label (`offsetTop + offsetHeight + 2px` gap).
|
|
188
|
+
4. Append to the overlay's parent element.
|
|
189
|
+
5. Set up keyboard navigation and outside-click handler.
|
|
190
|
+
|
|
191
|
+
### Dropdown Item Rendering
|
|
192
|
+
|
|
193
|
+
Each `LayerInfo` becomes a `<div>` row:
|
|
194
|
+
|
|
195
|
+
- **Display name**: `layer.tagName` (e.g., "div", "section")
|
|
196
|
+
- **Indentation**: `paddingLeft = 12 + depth * 10` px
|
|
197
|
+
- **Active (selected) item**: blue text (`#526cff`), light blue bg (`#DBEAFE`), semi-bold
|
|
198
|
+
- **Hover state**: light gray bg (`#f1f5f9`)
|
|
199
|
+
- **Events**: `mouseenter` → preview, `mouseleave` → clear preview, `click` → select
|
|
200
|
+
|
|
201
|
+
### Keyboard Navigation
|
|
202
|
+
|
|
203
|
+
- **Arrow Down/Up**: Circular focus movement through items
|
|
204
|
+
- **Enter**: Select focused item
|
|
205
|
+
- All listeners use capture phase to intercept before bubbling
|
|
206
|
+
|
|
207
|
+
### Outside Click
|
|
208
|
+
|
|
209
|
+
Registered via `setTimeout(..., 0)` to prevent the opening click from immediately closing. Uses `mousedown` in capture phase for responsive dismissal.
|
|
210
|
+
|
|
211
|
+
---
|
|
212
|
+
|
|
213
|
+
## Edge Cases
|
|
214
|
+
|
|
215
|
+
| Scenario | Behavior |
|
|
216
|
+
|----------|----------|
|
|
217
|
+
| No instrumented parent (root element) | Falls back to self + children only (no siblings) |
|
|
218
|
+
| Only child (no other siblings) | Siblings list contains just self — same result as before |
|
|
219
|
+
| Parent is `document.body`/`html` | Excluded by parent-walk guard, so `parents` is empty |
|
|
220
|
+
| `layers.length <= 1` | No chevron appended, no click handler attached |
|
|
221
|
+
| Element not in siblings list | Safety guard appends it |
|
|
222
|
+
| Non-instrumented wrapper elements | Walked through transparently during descendant collection |
|
|
223
|
+
|
|
224
|
+
---
|
|
225
|
+
|
|
226
|
+
## Data Flow Summary
|
|
227
|
+
|
|
228
|
+
```
|
|
229
|
+
User selects element
|
|
230
|
+
↓
|
|
231
|
+
attachToOverlay(overlay, element)
|
|
232
|
+
↓
|
|
233
|
+
buildLayerChain(element)
|
|
234
|
+
├── collectInstrumentedParents() → parents[] (outermost first)
|
|
235
|
+
├── appendParentsWithDepth() → chain gets parents at depth 0,1,...
|
|
236
|
+
├── getImmediateInstrParent() → instrParent or null
|
|
237
|
+
├── collectSiblings(instrParent) → siblings[] (DOM order)
|
|
238
|
+
└── appendSiblingsWithSelected()
|
|
239
|
+
├── sibling → chain at selfDepth (no expansion)
|
|
240
|
+
├── ★ selected → appendSelfAndDescendants()
|
|
241
|
+
│ ├── self at selfDepth
|
|
242
|
+
│ ├── getInstrumentedDescendants(self, 2) → children[]
|
|
243
|
+
│ └── assignDescendantDepths() → children get depth values
|
|
244
|
+
└── sibling → chain at selfDepth (no expansion)
|
|
245
|
+
↓
|
|
246
|
+
LayerInfo[] with depth values
|
|
247
|
+
↓
|
|
248
|
+
layers.length > 1 ? enhanceLabelWithChevron() : return
|
|
249
|
+
↓
|
|
250
|
+
User clicks chevron → showDropdown()
|
|
251
|
+
↓
|
|
252
|
+
createDropdownElement(layers, currentId, callbacks)
|
|
253
|
+
└── createDropdownItem() for each layer (indented by depth)
|
|
254
|
+
↓
|
|
255
|
+
User hovers item → showLayerPreview() → preview overlay
|
|
256
|
+
User clicks item → selectElementFromLayer() → new selection → reattach
|
|
257
|
+
User presses Escape → closeDropdown() → reselect original
|
|
258
|
+
```
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/** Style constants for the layer dropdown UI */
|
|
2
|
+
|
|
3
|
+
export const DROPDOWN_CONTAINER_STYLES: Record<string, string> = {
|
|
4
|
+
position: "absolute",
|
|
5
|
+
backgroundColor: "#ffffff",
|
|
6
|
+
border: "1px solid #e2e8f0",
|
|
7
|
+
borderRadius: "6px",
|
|
8
|
+
boxShadow: "0 4px 12px rgba(0, 0, 0, 0.15)",
|
|
9
|
+
fontSize: "12px",
|
|
10
|
+
minWidth: "120px",
|
|
11
|
+
maxHeight: "200px",
|
|
12
|
+
overflowY: "auto",
|
|
13
|
+
zIndex: "10001",
|
|
14
|
+
padding: "4px 0",
|
|
15
|
+
pointerEvents: "auto",
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export const DROPDOWN_ITEM_BASE_STYLES: Record<string, string> = {
|
|
19
|
+
padding: "4px 12px",
|
|
20
|
+
cursor: "pointer",
|
|
21
|
+
color: "#334155",
|
|
22
|
+
backgroundColor: "transparent",
|
|
23
|
+
whiteSpace: "nowrap",
|
|
24
|
+
lineHeight: "1.5",
|
|
25
|
+
fontWeight: "400",
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export const DROPDOWN_ITEM_ACTIVE_COLOR = "#526cff";
|
|
29
|
+
export const DROPDOWN_ITEM_ACTIVE_BG = "#DBEAFE";
|
|
30
|
+
export const DROPDOWN_ITEM_ACTIVE_FONT_WEIGHT = "600";
|
|
31
|
+
|
|
32
|
+
export const DROPDOWN_ITEM_HOVER_BG = "#f1f5f9";
|
|
33
|
+
|
|
34
|
+
export const DEPTH_INDENT_PX = 10;
|
|
35
|
+
|
|
36
|
+
/** Chevron shown when dropdown is collapsed (click to expand) */
|
|
37
|
+
export const CHEVRON_COLLAPSED = " \u25BE";
|
|
38
|
+
/** Chevron shown when dropdown is expanded (click to collapse) */
|
|
39
|
+
export const CHEVRON_EXPANDED = " \u25B4";
|
|
40
|
+
|
|
41
|
+
export const BASE_PADDING_PX = 12;
|
|
42
|
+
|
|
43
|
+
export const LAYER_DROPDOWN_ATTR = "data-layer-dropdown";
|
|
44
|
+
|
|
45
|
+
/** Max instrumented ancestors to show above the selected element */
|
|
46
|
+
export const MAX_PARENT_DEPTH = 2;
|
|
47
|
+
|
|
48
|
+
/** Max instrumented depth levels to show below the selected element */
|
|
49
|
+
export const MAX_CHILD_DEPTH = 2;
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/** Controller that encapsulates layer-dropdown integration logic */
|
|
2
|
+
|
|
3
|
+
import { getElementSelectorId } from "../utils.js";
|
|
4
|
+
import { buildLayerChain } from "./utils.js";
|
|
5
|
+
import {
|
|
6
|
+
enhanceLabelWithChevron,
|
|
7
|
+
showDropdown,
|
|
8
|
+
closeDropdown,
|
|
9
|
+
isDropdownOpen,
|
|
10
|
+
} from "./dropdown-ui.js";
|
|
11
|
+
import type { LayerInfo, LayerControllerConfig, LayerController } from "./types.js";
|
|
12
|
+
|
|
13
|
+
export function createLayerController(config: LayerControllerConfig): LayerController {
|
|
14
|
+
let layerPreviewOverlay: HTMLDivElement | null = null;
|
|
15
|
+
let escapeHandler: ((e: KeyboardEvent) => void) | null = null;
|
|
16
|
+
let dropdownSourceLayer: LayerInfo | null = null;
|
|
17
|
+
|
|
18
|
+
const clearLayerPreview = () => {
|
|
19
|
+
if (layerPreviewOverlay && layerPreviewOverlay.parentNode) {
|
|
20
|
+
layerPreviewOverlay.remove();
|
|
21
|
+
}
|
|
22
|
+
layerPreviewOverlay = null;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
const showLayerPreview = (layer: LayerInfo) => {
|
|
26
|
+
clearLayerPreview();
|
|
27
|
+
if (getElementSelectorId(layer.element) === config.getSelectedElementId()) return;
|
|
28
|
+
|
|
29
|
+
layerPreviewOverlay = config.createPreviewOverlay(layer.element);
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
const selectLayer = (layer: LayerInfo) => {
|
|
33
|
+
clearLayerPreview();
|
|
34
|
+
closeDropdown();
|
|
35
|
+
if (escapeHandler) {
|
|
36
|
+
document.removeEventListener("keydown", escapeHandler, true);
|
|
37
|
+
escapeHandler = null;
|
|
38
|
+
}
|
|
39
|
+
dropdownSourceLayer = null;
|
|
40
|
+
|
|
41
|
+
const firstOverlay = config.selectElement(layer.element);
|
|
42
|
+
attachToOverlay(firstOverlay, layer.element);
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
const restoreSelection = () => {
|
|
46
|
+
if (escapeHandler) {
|
|
47
|
+
document.removeEventListener("keydown", escapeHandler, true);
|
|
48
|
+
escapeHandler = null;
|
|
49
|
+
}
|
|
50
|
+
if (dropdownSourceLayer) {
|
|
51
|
+
selectLayer(dropdownSourceLayer);
|
|
52
|
+
dropdownSourceLayer = null;
|
|
53
|
+
}
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
const handleLabelClick = (e: MouseEvent, label: HTMLDivElement, element: Element, layers: LayerInfo[], currentId: string | null) => {
|
|
57
|
+
e.stopPropagation();
|
|
58
|
+
e.preventDefault();
|
|
59
|
+
if (isDropdownOpen()) {
|
|
60
|
+
closeDropdown();
|
|
61
|
+
restoreSelection();
|
|
62
|
+
} else {
|
|
63
|
+
dropdownSourceLayer = {
|
|
64
|
+
element,
|
|
65
|
+
tagName: element.tagName.toLowerCase(),
|
|
66
|
+
selectorId: currentId,
|
|
67
|
+
};
|
|
68
|
+
config.onDeselect();
|
|
69
|
+
|
|
70
|
+
escapeHandler = (ev: KeyboardEvent) => {
|
|
71
|
+
if (ev.key === "Escape") {
|
|
72
|
+
ev.stopPropagation();
|
|
73
|
+
closeDropdown();
|
|
74
|
+
restoreSelection();
|
|
75
|
+
}
|
|
76
|
+
};
|
|
77
|
+
document.addEventListener("keydown", escapeHandler, true);
|
|
78
|
+
|
|
79
|
+
showDropdown(label, layers, element, { onSelect: selectLayer, onHover: showLayerPreview, onHoverEnd: clearLayerPreview });
|
|
80
|
+
}
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
const attachToOverlay = (
|
|
84
|
+
overlay: HTMLDivElement | undefined,
|
|
85
|
+
element: Element
|
|
86
|
+
) => {
|
|
87
|
+
if (!overlay) return;
|
|
88
|
+
|
|
89
|
+
const label = overlay.querySelector("div") as HTMLDivElement | null;
|
|
90
|
+
if (!label) return;
|
|
91
|
+
|
|
92
|
+
const layers = buildLayerChain(element);
|
|
93
|
+
if (layers.length <= 1) return;
|
|
94
|
+
|
|
95
|
+
const currentId = getElementSelectorId(element);
|
|
96
|
+
enhanceLabelWithChevron(label);
|
|
97
|
+
|
|
98
|
+
label.addEventListener("click", (e: MouseEvent) => {
|
|
99
|
+
handleLabelClick(e, label, element, layers, currentId);
|
|
100
|
+
});
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
const cleanup = () => {
|
|
104
|
+
clearLayerPreview();
|
|
105
|
+
closeDropdown();
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
return { attachToOverlay, cleanup };
|
|
109
|
+
}
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
/** Dropdown UI component for layer navigation */
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
DROPDOWN_CONTAINER_STYLES,
|
|
5
|
+
DROPDOWN_ITEM_BASE_STYLES,
|
|
6
|
+
DROPDOWN_ITEM_ACTIVE_COLOR,
|
|
7
|
+
DROPDOWN_ITEM_ACTIVE_BG,
|
|
8
|
+
DROPDOWN_ITEM_ACTIVE_FONT_WEIGHT,
|
|
9
|
+
DROPDOWN_ITEM_HOVER_BG,
|
|
10
|
+
DEPTH_INDENT_PX,
|
|
11
|
+
BASE_PADDING_PX,
|
|
12
|
+
CHEVRON_COLLAPSED,
|
|
13
|
+
CHEVRON_EXPANDED,
|
|
14
|
+
LAYER_DROPDOWN_ATTR,
|
|
15
|
+
} from "./consts.js";
|
|
16
|
+
import { applyStyles, getLayerDisplayName } from "./utils.js";
|
|
17
|
+
import type { LayerInfo, DropdownCallbacks } from "./types.js";
|
|
18
|
+
|
|
19
|
+
let activeDropdown: HTMLDivElement | null = null;
|
|
20
|
+
let activeLabel: HTMLDivElement | null = null;
|
|
21
|
+
let outsideMousedownHandler: ((e: MouseEvent) => void) | null = null;
|
|
22
|
+
let activeOnHoverEnd: (() => void) | null = null;
|
|
23
|
+
let activeKeydownHandler: ((e: KeyboardEvent) => void) | null = null;
|
|
24
|
+
|
|
25
|
+
function createDropdownItem(
|
|
26
|
+
layer: LayerInfo,
|
|
27
|
+
isActive: boolean,
|
|
28
|
+
{ onSelect, onHover, onHoverEnd }: DropdownCallbacks
|
|
29
|
+
): HTMLDivElement {
|
|
30
|
+
const item = document.createElement("div");
|
|
31
|
+
item.textContent = getLayerDisplayName(layer);
|
|
32
|
+
applyStyles(item, DROPDOWN_ITEM_BASE_STYLES);
|
|
33
|
+
|
|
34
|
+
const depth = layer.depth ?? 0;
|
|
35
|
+
if (depth > 0) {
|
|
36
|
+
item.style.paddingLeft = `${BASE_PADDING_PX + depth * DEPTH_INDENT_PX}px`;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
if (isActive) {
|
|
40
|
+
item.style.color = DROPDOWN_ITEM_ACTIVE_COLOR;
|
|
41
|
+
item.style.backgroundColor = DROPDOWN_ITEM_ACTIVE_BG;
|
|
42
|
+
item.style.fontWeight = DROPDOWN_ITEM_ACTIVE_FONT_WEIGHT;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
item.addEventListener("mouseenter", () => {
|
|
46
|
+
if (!isActive) item.style.backgroundColor = DROPDOWN_ITEM_HOVER_BG;
|
|
47
|
+
if (onHover) onHover(layer);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
item.addEventListener("mouseleave", () => {
|
|
51
|
+
if (!isActive) item.style.backgroundColor = "transparent";
|
|
52
|
+
if (onHoverEnd) onHoverEnd();
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
item.addEventListener("click", (e: MouseEvent) => {
|
|
56
|
+
e.stopPropagation();
|
|
57
|
+
e.preventDefault();
|
|
58
|
+
onSelect(layer);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
return item;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Create the dropdown DOM element with layer items */
|
|
65
|
+
export function createDropdownElement(
|
|
66
|
+
layers: LayerInfo[],
|
|
67
|
+
currentElement: Element | null,
|
|
68
|
+
callbacks: DropdownCallbacks
|
|
69
|
+
): HTMLDivElement {
|
|
70
|
+
const container = document.createElement("div");
|
|
71
|
+
container.setAttribute(LAYER_DROPDOWN_ATTR, "true");
|
|
72
|
+
applyStyles(container, DROPDOWN_CONTAINER_STYLES);
|
|
73
|
+
|
|
74
|
+
layers.forEach((layer) => {
|
|
75
|
+
const isActive = layer.element === currentElement;
|
|
76
|
+
container.appendChild(createDropdownItem(layer, isActive, callbacks));
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
return container;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Add chevron indicator and pointer-events to the label */
|
|
83
|
+
export function enhanceLabelWithChevron(label: HTMLDivElement): void {
|
|
84
|
+
const t = label.textContent ?? "";
|
|
85
|
+
if (t.endsWith(CHEVRON_COLLAPSED) || t.endsWith(CHEVRON_EXPANDED)) return;
|
|
86
|
+
|
|
87
|
+
label.textContent = t + CHEVRON_COLLAPSED;
|
|
88
|
+
label.style.cursor = "pointer";
|
|
89
|
+
label.style.userSelect = "none";
|
|
90
|
+
label.style.whiteSpace = "nowrap";
|
|
91
|
+
label.style.pointerEvents = "auto";
|
|
92
|
+
label.setAttribute(LAYER_DROPDOWN_ATTR, "true");
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function setupKeyboardNavigation(
|
|
96
|
+
dropdown: HTMLDivElement,
|
|
97
|
+
layers: LayerInfo[],
|
|
98
|
+
currentElement: Element | null,
|
|
99
|
+
{ onSelect, onHover, onHoverEnd }: DropdownCallbacks
|
|
100
|
+
): void {
|
|
101
|
+
const items = Array.from(dropdown.children) as HTMLDivElement[];
|
|
102
|
+
let focusedIndex = layers.findIndex((l) => l.element === currentElement);
|
|
103
|
+
|
|
104
|
+
const setFocusedItem = (index: number) => {
|
|
105
|
+
if (focusedIndex >= 0 && focusedIndex < items.length) {
|
|
106
|
+
const prev = items[focusedIndex]!;
|
|
107
|
+
if (prev.style.color !== DROPDOWN_ITEM_ACTIVE_COLOR) {
|
|
108
|
+
prev.style.backgroundColor = "transparent";
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
focusedIndex = index;
|
|
112
|
+
if (focusedIndex >= 0 && focusedIndex < items.length) {
|
|
113
|
+
const cur = items[focusedIndex]!;
|
|
114
|
+
if (cur.style.color !== DROPDOWN_ITEM_ACTIVE_COLOR) {
|
|
115
|
+
cur.style.backgroundColor = DROPDOWN_ITEM_HOVER_BG;
|
|
116
|
+
}
|
|
117
|
+
cur.scrollIntoView({ block: "nearest" });
|
|
118
|
+
if (onHover && focusedIndex >= 0 && focusedIndex < layers.length) {
|
|
119
|
+
onHover(layers[focusedIndex]!);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
activeKeydownHandler = (e: KeyboardEvent) => {
|
|
125
|
+
if (e.key === "ArrowDown") {
|
|
126
|
+
e.preventDefault();
|
|
127
|
+
e.stopPropagation();
|
|
128
|
+
setFocusedItem(focusedIndex < items.length - 1 ? focusedIndex + 1 : 0);
|
|
129
|
+
} else if (e.key === "ArrowUp") {
|
|
130
|
+
e.preventDefault();
|
|
131
|
+
e.stopPropagation();
|
|
132
|
+
setFocusedItem(focusedIndex > 0 ? focusedIndex - 1 : items.length - 1);
|
|
133
|
+
} else if (e.key === "Enter" && focusedIndex >= 0 && focusedIndex < layers.length) {
|
|
134
|
+
e.preventDefault();
|
|
135
|
+
e.stopPropagation();
|
|
136
|
+
if (onHoverEnd) onHoverEnd();
|
|
137
|
+
onSelect(layers[focusedIndex]!);
|
|
138
|
+
closeDropdown();
|
|
139
|
+
}
|
|
140
|
+
};
|
|
141
|
+
document.addEventListener("keydown", activeKeydownHandler, true);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function setupOutsideClickHandler(
|
|
145
|
+
dropdown: HTMLDivElement,
|
|
146
|
+
label: HTMLDivElement
|
|
147
|
+
): void {
|
|
148
|
+
let skipFirst = true;
|
|
149
|
+
outsideMousedownHandler = (e: MouseEvent) => {
|
|
150
|
+
if (skipFirst) { skipFirst = false; return; }
|
|
151
|
+
const target = e.target as Node;
|
|
152
|
+
if (!dropdown.contains(target) && target !== label) {
|
|
153
|
+
closeDropdown();
|
|
154
|
+
}
|
|
155
|
+
};
|
|
156
|
+
document.addEventListener("mousedown", outsideMousedownHandler, true);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** Show the dropdown below the label element */
|
|
160
|
+
export function showDropdown(
|
|
161
|
+
label: HTMLDivElement,
|
|
162
|
+
layers: LayerInfo[],
|
|
163
|
+
currentElement: Element | null,
|
|
164
|
+
callbacks: DropdownCallbacks
|
|
165
|
+
): void {
|
|
166
|
+
closeDropdown();
|
|
167
|
+
|
|
168
|
+
const dropdown = createDropdownElement(
|
|
169
|
+
layers,
|
|
170
|
+
currentElement,
|
|
171
|
+
{
|
|
172
|
+
...callbacks,
|
|
173
|
+
onSelect: (layer) => {
|
|
174
|
+
if (callbacks.onHoverEnd) callbacks.onHoverEnd();
|
|
175
|
+
callbacks.onSelect(layer);
|
|
176
|
+
closeDropdown();
|
|
177
|
+
},
|
|
178
|
+
}
|
|
179
|
+
);
|
|
180
|
+
|
|
181
|
+
const overlay = label.parentElement;
|
|
182
|
+
if (!overlay) return;
|
|
183
|
+
|
|
184
|
+
dropdown.style.top = `${label.offsetTop + label.offsetHeight + 2}px`;
|
|
185
|
+
dropdown.style.left = `${label.offsetLeft}px`;
|
|
186
|
+
|
|
187
|
+
overlay.appendChild(dropdown);
|
|
188
|
+
activeDropdown = dropdown;
|
|
189
|
+
activeLabel = label;
|
|
190
|
+
if (label.textContent?.endsWith(CHEVRON_COLLAPSED.trim())) {
|
|
191
|
+
label.textContent = label.textContent.slice(0, -CHEVRON_COLLAPSED.length) + CHEVRON_EXPANDED;
|
|
192
|
+
}
|
|
193
|
+
activeOnHoverEnd = callbacks.onHoverEnd ?? null;
|
|
194
|
+
|
|
195
|
+
setupKeyboardNavigation(dropdown, layers, currentElement, callbacks);
|
|
196
|
+
setupOutsideClickHandler(dropdown, label);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** Close the active dropdown and clean up listeners */
|
|
200
|
+
export function closeDropdown(): void {
|
|
201
|
+
if (activeLabel?.textContent?.includes(CHEVRON_EXPANDED)) {
|
|
202
|
+
activeLabel.textContent = activeLabel.textContent.replace(CHEVRON_EXPANDED, CHEVRON_COLLAPSED);
|
|
203
|
+
}
|
|
204
|
+
activeLabel = null;
|
|
205
|
+
|
|
206
|
+
if (activeOnHoverEnd) {
|
|
207
|
+
activeOnHoverEnd();
|
|
208
|
+
activeOnHoverEnd = null;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
if (activeDropdown && activeDropdown.parentNode) {
|
|
212
|
+
activeDropdown.remove();
|
|
213
|
+
}
|
|
214
|
+
activeDropdown = null;
|
|
215
|
+
|
|
216
|
+
if (outsideMousedownHandler) {
|
|
217
|
+
document.removeEventListener("mousedown", outsideMousedownHandler, true);
|
|
218
|
+
outsideMousedownHandler = null;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
if (activeKeydownHandler) {
|
|
222
|
+
document.removeEventListener("keydown", activeKeydownHandler, true);
|
|
223
|
+
activeKeydownHandler = null;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** Check if a dropdown is currently visible */
|
|
228
|
+
export function isDropdownOpen(): boolean {
|
|
229
|
+
return activeDropdown !== null;
|
|
230
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/** Shared types for the layer-dropdown module */
|
|
2
|
+
|
|
3
|
+
export interface LayerInfo {
|
|
4
|
+
element: Element;
|
|
5
|
+
tagName: string;
|
|
6
|
+
selectorId: string | null;
|
|
7
|
+
depth?: number;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export type OnLayerSelect = (layer: LayerInfo) => void;
|
|
11
|
+
export type OnLayerHover = (layer: LayerInfo) => void;
|
|
12
|
+
export type OnLayerHoverEnd = () => void;
|
|
13
|
+
|
|
14
|
+
export interface DropdownCallbacks {
|
|
15
|
+
onSelect: OnLayerSelect;
|
|
16
|
+
onHover?: OnLayerHover;
|
|
17
|
+
onHoverEnd?: OnLayerHoverEnd;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface LayerControllerConfig {
|
|
21
|
+
createPreviewOverlay: (element: Element) => HTMLDivElement;
|
|
22
|
+
getSelectedElementId: () => string | null;
|
|
23
|
+
selectElement: (element: Element) => HTMLDivElement | undefined;
|
|
24
|
+
onDeselect: () => void;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface LayerController {
|
|
28
|
+
attachToOverlay: (overlay: HTMLDivElement | undefined, element: Element) => void;
|
|
29
|
+
cleanup: () => void;
|
|
30
|
+
}
|