Enhance SelectionOption component with hover state and caret visibility logic

- Refactored the `SelectionOption` to be a Roact component that tracks hover state.
- Added logic to determine caret visibility based on node properties (parent-only, has children, and selection state).
- Updated background and text transparency based on hover state for improved UX.
- Adjusted event handling for mouse enter, mouse leave, and click actions to accommodate new behavior.
- Modified `SelectionRadio` to pass necessary props to `SelectionOption` for consistent behavior.
This commit is contained in:
Joe 2026-02-01 09:51:48 -06:00
parent 2b2ada219e
commit 6ed96f1f7e
27 changed files with 99 additions and 56 deletions

View file

@ -50,10 +50,32 @@ local function ChangeTag(props)
end)
end
-- Radio button option for selection
local function SelectionOption(props)
-- Radio button option for selection (as a component to track hover state)
local SelectionOption = Roact.Component:extend("SelectionOption")
function SelectionOption:init()
self:setState({
isHovered = false,
})
end
function SelectionOption:render()
local props = self.props
local isHovered = self.state.isHovered
return Theme.with(function(theme)
local isSelected = props.isSelected
local isParentOnly = props.isParentOnly
local hasChildren = props.hasChildren
-- Determine if we should show the caret:
-- - Parent-only nodes: always show caret (they only do subtree operations)
-- - Normal nodes with children: show caret when already selected AND hovering
local showCaret = if isParentOnly
then true
elseif hasChildren and isSelected and isHovered then true
else false
local bgColor = if isSelected
then (if props.optionType == "push"
then Color3.fromHex("27AE60")
@ -61,37 +83,60 @@ local function SelectionOption(props)
else Color3.fromHex("7F8C8D"))
else theme.BorderedContainer.BackgroundColor
local textColor = if isSelected then Color3.new(1, 1, 1) else theme.TextColor
-- Add caret if this button can cascade to children
local displayText = if props.showCaret then props.text .. " ▼" else props.text
local buttonWidth = if props.showCaret then 48 else 36
local displayText = if showCaret then props.text .. " ▼" else props.text
local buttonWidth = if showCaret then 48 else 36
-- Transparency logic:
-- - Parent-only nodes: more transparent when not hovered, less so when hovering button
-- - Normal nodes: standard transparency based on selection
local bgTransparency, txtTransparency
if isParentOnly then
-- Parent-only: partially transparent, less so when hovering the button
bgTransparency = props.transparency:map(function(t)
return if isHovered then 0.2 + (0.8 * t) else 0.5 + (0.5 * t)
end)
txtTransparency = props.transparency:map(function(t)
return if isHovered then 0 + t else 0.3 + (0.7 * t)
end)
else
bgTransparency = props.transparency:map(function(t)
return if isSelected then 0.1 + (0.9 * t) else 0.7 + (0.3 * t)
end)
txtTransparency = props.transparency
end
return e("TextButton", {
Size = UDim2.new(0, buttonWidth, 0, 18),
BackgroundColor3 = bgColor,
BackgroundTransparency = props.transparency:map(function(t)
return if isSelected then 0.1 + (0.9 * t) else 0.7 + (0.3 * t)
end),
BackgroundTransparency = bgTransparency,
BorderSizePixel = 0,
Text = displayText,
FontFace = if isSelected then theme.Font.Bold else theme.Font.Main,
TextSize = theme.TextSize.Small,
TextColor3 = textColor,
TextTransparency = props.transparency,
TextTransparency = txtTransparency,
LayoutOrder = props.layoutOrder,
ZIndex = 10,
[Roact.Event.Activated] = function(_rbx, _input, clickCount)
-- For parent-only nodes, any click triggers subtree selection
if props.isParentOnly then
if props.onDoubleClick then
props.onDoubleClick()
[Roact.Event.MouseEnter] = function()
self:setState({ isHovered = true })
end,
[Roact.Event.MouseLeave] = function()
self:setState({ isHovered = false })
end,
[Roact.Event.Activated] = function()
if isParentOnly then
-- Parent-only: single click applies to subtree
if props.onSubtreeClick then
props.onSubtreeClick()
end
elseif clickCount == 1 then
-- Double click: apply to subtree
if props.onDoubleClick then
props.onDoubleClick()
elseif isSelected and hasChildren then
-- Already selected with children: click applies to subtree
if props.onSubtreeClick then
props.onSubtreeClick()
end
else
-- Single click: apply to this node only
-- Normal click: select this node only
if props.onClick then
props.onClick()
end
@ -111,12 +156,12 @@ local function SelectionRadio(props)
return nil
end
local showCaret = props.hasChildren
local frameWidth = if showCaret then 154 else 118
local hasChildren = props.hasChildren
local isParentOnly = props.isParentOnly
return e("Frame", {
Size = UDim2.new(0, frameWidth, 0, 18),
Size = UDim2.new(0, 0, 0, 18),
AutomaticSize = Enum.AutomaticSize.X,
BackgroundTransparency = 1,
Position = UDim2.new(1, -5, 0, 3),
AnchorPoint = Vector2.new(1, 0),
@ -135,14 +180,14 @@ local function SelectionRadio(props)
isSelected = props.selection == "pull",
transparency = props.transparency,
layoutOrder = 1,
showCaret = showCaret,
hasChildren = hasChildren,
isParentOnly = isParentOnly,
onClick = function()
if props.onSelectionChange then
props.onSelectionChange(props.nodeId, "pull")
end
end,
onDoubleClick = function()
onSubtreeClick = function()
if props.onSubtreeSelectionChange then
props.onSubtreeSelectionChange(props.nodeId, "pull")
end
@ -154,14 +199,14 @@ local function SelectionRadio(props)
isSelected = props.selection == "ignore",
transparency = props.transparency,
layoutOrder = 2,
showCaret = showCaret,
hasChildren = hasChildren,
isParentOnly = isParentOnly,
onClick = function()
if props.onSelectionChange then
props.onSelectionChange(props.nodeId, "ignore")
end
end,
onDoubleClick = function()
onSubtreeClick = function()
if props.onSubtreeSelectionChange then
props.onSubtreeSelectionChange(props.nodeId, "ignore")
end
@ -173,14 +218,14 @@ local function SelectionRadio(props)
isSelected = props.selection == "push",
transparency = props.transparency,
layoutOrder = 3,
showCaret = showCaret,
hasChildren = hasChildren,
isParentOnly = isParentOnly,
onClick = function()
if props.onSelectionChange then
props.onSelectionChange(props.nodeId, "push")
end
end,
onDoubleClick = function()
onSubtreeClick = function()
if props.onSubtreeSelectionChange then
props.onSubtreeSelectionChange(props.nodeId, "push")
end

View file

@ -4,4 +4,3 @@ expression: normalized_stdout
---
Writing OnlyOneCopy/child_of_one.luau
Writing ReplicatedStorage/child_replicated_storage.luau
Created sourcemap at <TEMP>/child_but_not\sourcemap.json

View file

@ -5,4 +5,3 @@ expression: normalized_stdout
Writing src/csv.csv
Writing src/csv_init/init.csv
Writing src/csv_init
Created sourcemap at <TEMP>/csv\sourcemap.json

View file

@ -3,4 +3,3 @@ source: tests/rojo_test/syncback_util.rs
expression: normalized_stdout
---
Writing container.model.json5
Created sourcemap at <TEMP>/duplicate_rojo_id\sourcemap.json

View file

@ -6,4 +6,3 @@ Writing src/ignored_part.model.json5
Writing src/int_value.model.json5
Writing src/subfolder/string_value.txt
Writing src/subfolder
Created sourcemap at <TEMP>/ignore_paths_adding\sourcemap.json

View file

@ -4,4 +4,3 @@ expression: normalized_stdout
---
Writing src/non-init.luau
Writing src/init-file
Created sourcemap at <TEMP>/ignore_paths_init\sourcemap.json

View file

@ -3,4 +3,3 @@ source: tests/rojo_test/syncback_util.rs
expression: normalized_stdout
---
Writing src/Message.model.json5
Created sourcemap at <TEMP>/ignore_paths_removing\sourcemap.json

View file

@ -4,4 +4,3 @@ expression: normalized_stdout
---
Writing src/IncludeMe/.gitkeep
Writing src/IncludeMe
Created sourcemap at <TEMP>/ignore_trees_adding\sourcemap.json

View file

@ -3,4 +3,3 @@ source: tests/rojo_test/syncback_util.rs
expression: normalized_stdout
---
Removing src
Created sourcemap at <TEMP>/ignore_trees_removing\sourcemap.json

View file

@ -6,4 +6,3 @@ Writing src/dir_with_meta/init.meta.json5
Writing src/model_json.model.json5
Writing src/project_json.project.json5
Writing src/dir_with_meta
Created sourcemap at <TEMP>/json_middlewares\sourcemap.json

View file

@ -4,4 +4,3 @@ expression: normalized_stdout
---
Writing nested.project.json5
Writing string_value.txt
Created sourcemap at <TEMP>/nested_projects\sourcemap.json

View file

@ -6,4 +6,3 @@ Writing src/modules/ClientModule.luau
Writing src/modules/ServerModule.luau
Removing client-only.project.json5
Removing server-only.project.json5
Created sourcemap at <TEMP>/nested_projects_weird\sourcemap.json

View file

@ -4,4 +4,3 @@ expression: normalized_stdout
---
Writing src/init.luau
Writing src
Created sourcemap at <TEMP>/project_init\sourcemap.json

View file

@ -4,4 +4,3 @@ expression: normalized_stdout
---
Writing attribute_mismatch.luau
Writing property_mismatch.project.json5
Created sourcemap at <TEMP>/project_reserialize\sourcemap.json

View file

@ -6,4 +6,3 @@ Writing src/ChildWithoutDuplicates/Child/.gitkeep
Writing src/ChildWithDuplicates
Writing src/ChildWithoutDuplicates
Writing src/ChildWithoutDuplicates/Child
Created sourcemap at <TEMP>/rbxm_fallback\sourcemap.json

View file

@ -4,4 +4,3 @@ expression: normalized_stdout
---
Writing src/pointer.model.json5
Writing src/target.model.json5
Created sourcemap at <TEMP>/ref_properties\sourcemap.json

View file

@ -4,4 +4,3 @@ expression: normalized_stdout
---
Writing src/pointer.model.json5
Writing src/target.txt
Created sourcemap at <TEMP>/ref_properties_blank\sourcemap.json

View file

@ -6,4 +6,3 @@ Writing src/Pointer_1.model.json5
Writing src/Pointer_2.model.json5
Writing src/Target_1.model.json5
Writing src/Target_2.model.json5
Created sourcemap at <TEMP>/ref_properties_conflict\sourcemap.json

View file

@ -7,4 +7,3 @@ Writing src/Pointer_2.model.json5
Writing src/Target/init.meta.json5
Writing src/Target
Removing src/Target.model.json5
Created sourcemap at <TEMP>/ref_properties_duplicate\sourcemap.json

View file

@ -5,4 +5,3 @@ expression: normalized_stdout
Writing src/Pointer1.model.json5
Writing src/Pointer2.model.json5
Writing src/Pointer3.model.json5
Created sourcemap at <TEMP>/ref_properties_pruned\sourcemap.json

View file

@ -6,4 +6,3 @@ Writing default.project.json5
Writing src/model_json.model.json5
Writing src/rbxm.rbxm
Writing src/rbxmx.rbxmx
Created sourcemap at <TEMP>/respect_old_middleware\sourcemap.json

View file

@ -8,4 +8,3 @@ Writing src/adjacent.meta.json5
Writing src/init/init.meta.json5
Writing src/model.model.json5
Writing src/init
Created sourcemap at <TEMP>/schema_roundtrip\sourcemap.json

View file

@ -4,4 +4,3 @@ expression: normalized_stdout
---
Writing default.project.json5
Writing string_value.txt
Created sourcemap at <TEMP>/string_value_project\sourcemap.json

View file

@ -4,4 +4,3 @@ expression: normalized_stdout
---
Writing src/module.modulescript
Writing src/text.text
Created sourcemap at <TEMP>/sync_rules\sourcemap.json

View file

@ -3,4 +3,3 @@ source: tests/rojo_test/syncback_util.rs
expression: normalized_stdout
---
Writing default.project.json5
Created sourcemap at <TEMP>/unscriptable_properties\sourcemap.json

View file

@ -91,7 +91,13 @@ impl SourcemapCommand {
.build_global()
.unwrap();
write_sourcemap(&session, self.output.as_deref(), filter, self.absolute)?;
write_sourcemap(
&session,
self.output.as_deref(),
filter,
self.absolute,
false,
)?;
if self.watch {
let rt = Runtime::new().unwrap();
@ -102,7 +108,13 @@ impl SourcemapCommand {
cursor = new_cursor;
if patch_set_affects_sourcemap(&session, &patch_set, filter) {
write_sourcemap(&session, self.output.as_deref(), filter, self.absolute)?;
write_sourcemap(
&session,
self.output.as_deref(),
filter,
self.absolute,
false,
)?;
}
}
}
@ -234,6 +246,7 @@ pub(crate) fn write_sourcemap(
output: Option<&Path>,
filter: fn(&InstanceWithMeta) -> bool,
use_absolute_paths: bool,
quiet: bool,
) -> anyhow::Result<()> {
let tree = session.tree();
@ -252,7 +265,9 @@ pub(crate) fn write_sourcemap(
file.write_all(json_output.as_bytes())?;
file.flush()?;
if !quiet {
println!("Created sourcemap at {}", output_path.display());
}
} else {
// Use standard JSON (not JSON5) for sourcemaps - required by external tools like LSPs
let output = serde_json::to_string(&root_node)?;

View file

@ -214,7 +214,14 @@ impl SyncbackCommand {
// Generate sourcemap after successful syncback
let sourcemap_path = base_path.join("sourcemap.json");
write_sourcemap(&session_old, Some(&sourcemap_path), filter_nothing, false)?;
write_sourcemap(
&session_old,
Some(&sourcemap_path),
filter_nothing,
false,
true,
)?;
eprintln!("Generated sourcemap at {}", sourcemap_path.display());
// Refresh git index if in a git repository
refresh_git_index(base_path);