Skip to content
Merged
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
51 changes: 51 additions & 0 deletions docs/api-migrations.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,56 @@
# LUI API migration guide

## Unreleased: opt-in container focus-within observation (Web and MyGo)

`row`, `column` and `box` accept `?on_focus_within:(bool -> unit)` on
Web/Melange and MyGo. Installing the callback enables the new boolean
`focus-within-enabled` property and receives `FocusWithinChanged (node, within)`.
Without the callback/property, observation is off and existing views are unchanged.
The callback does not make the container focusable or change keyboard traversal.

After mount/enable settles, the observer publishes one current snapshot, then
only distinct transitions. True means focus is on the container or inside its
rendered subtree. Moving between descendants does not emit an exit/reentry pair.
Web coalesces local `focusin`/`focusout` checks at the microtask boundary, also
checking after retained child removal. MyGo shares its existing `FocusWithin`
read with appearance styling and delivers through the committed event queue.
Neither backend treats a popup/portal outside the subtree as inside merely
because it is attached to a trigger. Window activation is not a second condition
on retained focus membership; this is not an application-activation callback.

MyGo rejects `~on_focus_within` on ``box ~display:`contents``: a transparent
box has no native element to observe. The same restriction applies to raw wire
nodes with `display="contents"` and `focus-within-enabled=true`. Observe a real
row, column or non-transparent box instead. Web can observe this combination
through its retained DOM subtree.

Disabled/inert ancestors normalize membership to false, including a terminal
false for a previously focused subtree. Re-enabling observes current membership
again. The last value is recorded before dispatch, so reducer patches do not
cause repeated feedback. Same-node reconciliation retains the observation;
opt-out or node retirement discards it. A new incarnation receives a fresh
snapshot. Unmount cancels delayed checks without dispatching to a dead node.

SwiftUI, Kotlin and Generic host profiles explicitly reject this property,
including explicit false; Apple and Android raw-wire hosts reject it too.
Cross-platform applications must omit the callback on those paths, not expect
silent no-event support. No Apple/Android focus framework or focusable group is
introduced. Their existing focus behavior and appearance properties are unchanged.
Rebuild the shared OCaml application and Web/MyGo host together for the added
event constructor and native `lui_ocaml_focus_within_changed` bool callback.
Older native libraries remain usable without this opt-in; attempting to deliver
the new event to a library without its callback fails explicitly.

For example, replace per-control focus handlers with one row-owned signal:

```ocaml
row ~on_focus_within:(fun within -> Signal.set toolbar_focused within)
[comment_body; reactions; toolbar]
```

Combine that signal with the existing hover state to drive typed toolbar opacity.
No new focus-within opacity property or data migration is needed.

## Unreleased: MyGo links in text flow

Text-content `link` nodes inside a native text flow now use MyGo's inline Link
Expand Down
1 change: 1 addition & 0 deletions examples/gallery/view.ml
Original file line number Diff line number Diff line change
Expand Up @@ -1902,6 +1902,7 @@ let event_payload = function
| VirtualWindow (_, generation, first, last, _) ->
Printf.sprintf "virtual-window:%d:%d:%d" generation first last
| ExtensionEvent (_, identifier, name, _) -> "extension:" ^ identifier ^ ":" ^ name
| FocusWithinChanged (_, within) -> Printf.sprintf "focus-within:%b" within
| PointerMove _ -> "pointer-move"
| PointerCancel _ -> "pointer-cancel"
| ToggleChangedDetail (_, checked, modifiers) ->
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -315,6 +315,7 @@ internal object LuiKindRules {
/** Ported from the Flutter backend's `_supports`: whether a `set-prop`
* op is admissible for the node's kind. */
fun supports(kind: LuiNodeKind, property: String, value: LuiWireValue): Boolean {
if (property == "focus-within-enabled") return false
when (property) {
"virtual-total-count", "virtual-generation", "virtual-indices", "virtual-scroll-index" ->
return kind == LuiNodeKind.virtualList && schemaValueSupported(property, value)
Expand Down
6 changes: 4 additions & 2 deletions platform/android/lui/src/main/kotlin/dev/lui/LuiWireSchema.kt
Original file line number Diff line number Diff line change
Expand Up @@ -295,6 +295,7 @@ enum class LuiProperty(val wireName: String) {
pointerCapture("pointer-capture"),
indeterminate("indeterminate"),
toggleDetailEnabled("toggle-detail-enabled"),
focusWithinEnabled("focus-within-enabled"),
;

companion object {
Expand Down Expand Up @@ -337,12 +338,13 @@ object LuiSchemaMatrix {
LuiNodeKind.checkbox to setOf(LuiProperty.gap, LuiProperty.labelOpacity, LuiProperty.indicatorBorderColor, LuiProperty.checkedIndicatorBorderColor),
LuiNodeKind.radio to setOf(LuiProperty.gap, LuiProperty.labelOpacity),
LuiNodeKind.grid to setOf(LuiProperty.main, LuiProperty.cross),
LuiNodeKind.row to setOf(LuiProperty.orientation),
LuiNodeKind.row to setOf(LuiProperty.orientation, LuiProperty.focusWithinEnabled),
LuiNodeKind.column to setOf(LuiProperty.focusWithinEnabled),
LuiNodeKind.listItem to setOf(LuiProperty.display),
LuiNodeKind.dialog to setOf(LuiProperty.description, LuiProperty.background, LuiProperty.foreground, LuiProperty.borderColor, LuiProperty.borderWidth, LuiProperty.cornerRadius, LuiProperty.gap, LuiProperty.transition),
LuiNodeKind.drawer to setOf(LuiProperty.transition),
LuiNodeKind.sheet to setOf(LuiProperty.transition),
LuiNodeKind.box to setOf(LuiProperty.inert),
LuiNodeKind.box to setOf(LuiProperty.inert, LuiProperty.focusWithinEnabled),
LuiNodeKind.popover to setOf(LuiProperty.collisionPadding),
LuiNodeKind.dropdownMenu to setOf(LuiProperty.collisionPadding),
LuiNodeKind.tooltip to setOf(LuiProperty.collisionPadding),
Expand Down
4 changes: 4 additions & 0 deletions platform/apple/Sources/LUIAppleBackend/LUIWireProtocol.swift
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,8 @@ enum LUIWireValue: Decodable, Equatable {

func matches(_ property: LUIProperty, on kind: LUINodeKind) -> Bool {
switch property {
case .focusWithinEnabled:
return false
case .virtualTotalCount:
return intValue.map { $0 >= 0 } ?? false
case .virtualGeneration:
Expand Down Expand Up @@ -1086,6 +1088,7 @@ struct LUIRetainedTree {
}

private static func supports(_ property: LUIProperty, on kind: LUINodeKind) -> Bool {
if property == .focusWithinEnabled { return false }
switch property {
case .virtualTotalCount, .virtualGeneration, .virtualIndices, .virtualScrollIndex:
return kind == .virtualList
Expand All @@ -1106,6 +1109,7 @@ struct LUIRetainedTree {
if kind == .root { return property == .theme || property == .themeMode }
if kind == .contextMenu { return false }
return switch property {
case .focusWithinEnabled: false
case .virtualTotalCount, .virtualGeneration, .virtualIndices, .virtualScrollIndex: kind == .virtualList
case .readOnly: isTextEntry(kind)
case .overscan, .itemExtent: kind == .virtualList
Expand Down
6 changes: 4 additions & 2 deletions platform/apple/Sources/LUIAppleBackend/LUIWireSchema.swift
Original file line number Diff line number Diff line change
Expand Up @@ -288,6 +288,7 @@ enum LUIProperty: String, Decodable, Hashable {
case pointerCapture = "pointer-capture"
case indeterminate = "indeterminate"
case toggleDetailEnabled = "toggle-detail-enabled"
case focusWithinEnabled = "focus-within-enabled"
}

enum LUISchemaMatrix {
Expand Down Expand Up @@ -317,12 +318,13 @@ enum LUISchemaMatrix {
.checkbox: [.gap, .labelOpacity, .indicatorBorderColor, .checkedIndicatorBorderColor],
.radio: [.gap, .labelOpacity],
.grid: [.main, .cross],
.row: [.orientation],
.row: [.orientation, .focusWithinEnabled],
.column: [.focusWithinEnabled],
.listItem: [.display],
.dialog: [.description, .background, .foreground, .borderColor, .borderWidth, .cornerRadius, .gap, .transition],
.drawer: [.transition],
.sheet: [.transition],
.box: [.inert],
.box: [.inert, .focusWithinEnabled],
.popover: [.collisionPadding],
.dropdownMenu: [.collisionPadding],
.tooltip: [.collisionPadding],
Expand Down
9 changes: 9 additions & 0 deletions platform/bridge/lui_ocaml_bridge.c
Original file line number Diff line number Diff line change
Expand Up @@ -273,6 +273,15 @@ LUI_EXPORT int32_t lui_ocaml_picked(int64_t node, const char *payload) {
return lui_ocaml_picked_utf8(node, payload, (int32_t)strlen(payload));
}

LUI_EXPORT int32_t lui_ocaml_focus_within_changed(int64_t node, int32_t within) {
int32_t accepted;
caml_leave_blocking_section();
accepted = lui_dispatch_node_bool("lui_ocaml_focus_within_changed", node, within,
deliver);
caml_enter_blocking_section();
return accepted;
}

LUI_EXPORT int32_t lui_ocaml_toggle_changed(int64_t node, int32_t checked) {
int32_t accepted;
caml_leave_blocking_section();
Expand Down
8 changes: 5 additions & 3 deletions platform/mygo/SUPPORT.md

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

18 changes: 18 additions & 0 deletions platform/mygo/backend.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ package luimygo

import (
"bytes"
"encoding/json"
"fmt"
"io"
"maps"
Expand Down Expand Up @@ -61,6 +62,7 @@ type Event struct {
Node int64 `json:"node"`
Text string `json:"text,omitempty"`
Checked bool `json:"checked,omitempty"`
Within bool `json:"within,omitempty"`
Value float64 `json:"value,omitempty"`
Modifiers int32 `json:"modifiers,omitempty"`
Identifier string `json:"identifier,omitempty"`
Expand All @@ -82,6 +84,20 @@ type Event struct {
Shift bool `json:"shift,omitempty"`
Payload string `json:"payload,omitempty"`
incarnation uint64

focusContext *ui.Context
}

func (e Event) MarshalJSON() ([]byte, error) {
type wireEvent Event
var within *bool
if e.Kind == "focus-within-changed" {
within = &e.Within
}
return json.Marshal(struct {
wireEvent
Within *bool `json:"within,omitempty"`
}{wireEvent(e), within})
}

// Renderer builds a custom control, calling children within its container.
Expand Down Expand Up @@ -121,6 +137,8 @@ type controlState struct {
checked bool
value float64
appeared, focused bool
focusWithinKnown bool
focusWithinLast bool
identity uint64
handle ui.Handle
resource nodeResourceState
Expand Down
17 changes: 17 additions & 0 deletions platform/mygo/backend_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,23 @@ func TestRemovedLineBreakKind(t *testing.T) {
}
}

func TestFocusWithinRejectsTransparentBox(t *testing.T) {
for _, properties := range [][]Operation{
{prop(3, "display", "contents"), prop(3, "focus-within-enabled", true)},
{prop(3, "focus-within-enabled", true), prop(3, "display", "contents")},
} {
b := mounted(t, nil)
ops := append([]Operation{create(3, "box"), insert(2, 3, 0)}, properties...)
err := b.ApplyBatch(Batch{Generation: 2, Ops: ops})
if err == nil || !strings.Contains(err.Error(), "focus-within") {
t.Fatalf("transparent box with focus observer must be rejected: %v", err)
}
if _, ok := b.Node(3); ok || b.Generation() != 1 {
t.Fatal("rejected batch changed the retained tree")
}
}
}

func TestMalformedWire(t *testing.T) {
for _, s := range []string{
`{}`, `null`, `[]`, `{"generation":1}`, `{"generation":1,"ops":null}`, `{"generation":1.5,"ops":[]}`,
Expand Down
14 changes: 11 additions & 3 deletions platform/mygo/capabilities.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,15 +30,15 @@
"extensions_test.go:TestLayoutGeometry",
"layout_test.go:TestLayoutSharedTypographyAndSpacingRestoreInheritedValues"
],
"contractHash": "8607888dd0b74341aaf93b9a2007d4400d8654129b0a6bca393b82eb27b53b13"
"contractHash": "5b37ca2312b59c73d5ca14e59389ed49a11b1dc3710bdee1409ce9675f9d8c50"
},
"column": {
"route": "render.go; style.go",
"evidence": [
"extensions_test.go:TestLayoutGeometry",
"layout_test.go:TestLayoutSharedTypographyAndSpacingRestoreInheritedValues"
],
"contractHash": "d1625463275eac1a39011cec61b6bf07997f116c26977792579c7fdb6f2a1507"
"contractHash": "6f35e51043ab8ae815796b2dd3a147f0a5e01b0fe15b9bab93f588710264f720"
},
"grid": {
"route": "render.go; style.go",
Expand Down Expand Up @@ -117,7 +117,7 @@
"style_test.go:TestLayoutStyleViewportFractionsAndContainerFrameRemoval",
"style_test.go:TestLayoutStyleShadowOpacityStatesAndRemoval"
],
"contractHash": "53c3f165be3a92e51235e68b433156fc4b1594d4ee6845cc2ab1d4dc1a270b64"
"contractHash": "028237d9cea015c951093e45aa35dc7c3493f8a22f27106e43dfc506da741866"
},
"text": {
"route": "render.go; style.go",
Expand Down Expand Up @@ -692,6 +692,10 @@
}
},
"properties": {
"focus-within-enabled": {
"route": "render.go; events.go",
"evidence": ["events_test.go:TestEventsFocusWithinTransitions"]
},
"text": {
"route": "collections.go; composites.go; controls.go; layout.go; menus.go; overlays.go; resources.go",
"evidence": [
Expand Down Expand Up @@ -2337,6 +2341,10 @@
}
},
"events": {
"focus-within-changed": {
"route": "render.go; events.go; runtime_events.go; runtime.go",
"evidence": ["events_test.go:TestEventsFocusWithinTransitions"]
},
"appear": {
"route": "events.go; runtime_events.go; runtime.go",
"evidence": [
Expand Down
3 changes: 3 additions & 0 deletions platform/mygo/contract.go
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,9 @@ func validateNode(n Node) error {
return fmt.Errorf("lui: invalid %s property %q on node %d", n.Kind, name, n.ID)
}
}
if n.Kind == "box" && n.String("display") == "contents" && n.Bool("focus-within-enabled") {
return fmt.Errorf("lui: focus-within observation is unsupported on box display=contents (node %d)", n.ID)
}
if !contract_node_properties_supported(n.Kind, n.Props) {
return fmt.Errorf("lui: invalid final properties for %s node %d", n.Kind, n.ID)
}
Expand Down
Loading
Loading