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)
end end
-- Radio button option for selection -- Radio button option for selection (as a component to track hover state)
local function SelectionOption(props) 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) return Theme.with(function(theme)
local isSelected = props.isSelected 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 local bgColor = if isSelected
then (if props.optionType == "push" then (if props.optionType == "push"
then Color3.fromHex("27AE60") then Color3.fromHex("27AE60")
@ -61,37 +83,60 @@ local function SelectionOption(props)
else Color3.fromHex("7F8C8D")) else Color3.fromHex("7F8C8D"))
else theme.BorderedContainer.BackgroundColor else theme.BorderedContainer.BackgroundColor
local textColor = if isSelected then Color3.new(1, 1, 1) else theme.TextColor 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 displayText = if showCaret then props.text .. " ▼" else props.text
local buttonWidth = if props.showCaret then 48 else 36 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", { return e("TextButton", {
Size = UDim2.new(0, buttonWidth, 0, 18), Size = UDim2.new(0, buttonWidth, 0, 18),
BackgroundColor3 = bgColor, BackgroundColor3 = bgColor,
BackgroundTransparency = props.transparency:map(function(t) BackgroundTransparency = bgTransparency,
return if isSelected then 0.1 + (0.9 * t) else 0.7 + (0.3 * t)
end),
BorderSizePixel = 0, BorderSizePixel = 0,
Text = displayText, Text = displayText,
FontFace = if isSelected then theme.Font.Bold else theme.Font.Main, FontFace = if isSelected then theme.Font.Bold else theme.Font.Main,
TextSize = theme.TextSize.Small, TextSize = theme.TextSize.Small,
TextColor3 = textColor, TextColor3 = textColor,
TextTransparency = props.transparency, TextTransparency = txtTransparency,
LayoutOrder = props.layoutOrder, LayoutOrder = props.layoutOrder,
ZIndex = 10, ZIndex = 10,
[Roact.Event.Activated] = function(_rbx, _input, clickCount) [Roact.Event.MouseEnter] = function()
-- For parent-only nodes, any click triggers subtree selection self:setState({ isHovered = true })
if props.isParentOnly then end,
if props.onDoubleClick then [Roact.Event.MouseLeave] = function()
props.onDoubleClick() 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 end
elseif clickCount == 1 then elseif isSelected and hasChildren then
-- Double click: apply to subtree -- Already selected with children: click applies to subtree
if props.onDoubleClick then if props.onSubtreeClick then
props.onDoubleClick() props.onSubtreeClick()
end end
else else
-- Single click: apply to this node only -- Normal click: select this node only
if props.onClick then if props.onClick then
props.onClick() props.onClick()
end end
@ -111,12 +156,12 @@ local function SelectionRadio(props)
return nil return nil
end end
local showCaret = props.hasChildren local hasChildren = props.hasChildren
local frameWidth = if showCaret then 154 else 118
local isParentOnly = props.isParentOnly local isParentOnly = props.isParentOnly
return e("Frame", { return e("Frame", {
Size = UDim2.new(0, frameWidth, 0, 18), Size = UDim2.new(0, 0, 0, 18),
AutomaticSize = Enum.AutomaticSize.X,
BackgroundTransparency = 1, BackgroundTransparency = 1,
Position = UDim2.new(1, -5, 0, 3), Position = UDim2.new(1, -5, 0, 3),
AnchorPoint = Vector2.new(1, 0), AnchorPoint = Vector2.new(1, 0),
@ -135,14 +180,14 @@ local function SelectionRadio(props)
isSelected = props.selection == "pull", isSelected = props.selection == "pull",
transparency = props.transparency, transparency = props.transparency,
layoutOrder = 1, layoutOrder = 1,
showCaret = showCaret, hasChildren = hasChildren,
isParentOnly = isParentOnly, isParentOnly = isParentOnly,
onClick = function() onClick = function()
if props.onSelectionChange then if props.onSelectionChange then
props.onSelectionChange(props.nodeId, "pull") props.onSelectionChange(props.nodeId, "pull")
end end
end, end,
onDoubleClick = function() onSubtreeClick = function()
if props.onSubtreeSelectionChange then if props.onSubtreeSelectionChange then
props.onSubtreeSelectionChange(props.nodeId, "pull") props.onSubtreeSelectionChange(props.nodeId, "pull")
end end
@ -154,14 +199,14 @@ local function SelectionRadio(props)
isSelected = props.selection == "ignore", isSelected = props.selection == "ignore",
transparency = props.transparency, transparency = props.transparency,
layoutOrder = 2, layoutOrder = 2,
showCaret = showCaret, hasChildren = hasChildren,
isParentOnly = isParentOnly, isParentOnly = isParentOnly,
onClick = function() onClick = function()
if props.onSelectionChange then if props.onSelectionChange then
props.onSelectionChange(props.nodeId, "ignore") props.onSelectionChange(props.nodeId, "ignore")
end end
end, end,
onDoubleClick = function() onSubtreeClick = function()
if props.onSubtreeSelectionChange then if props.onSubtreeSelectionChange then
props.onSubtreeSelectionChange(props.nodeId, "ignore") props.onSubtreeSelectionChange(props.nodeId, "ignore")
end end
@ -173,14 +218,14 @@ local function SelectionRadio(props)
isSelected = props.selection == "push", isSelected = props.selection == "push",
transparency = props.transparency, transparency = props.transparency,
layoutOrder = 3, layoutOrder = 3,
showCaret = showCaret, hasChildren = hasChildren,
isParentOnly = isParentOnly, isParentOnly = isParentOnly,
onClick = function() onClick = function()
if props.onSelectionChange then if props.onSelectionChange then
props.onSelectionChange(props.nodeId, "push") props.onSelectionChange(props.nodeId, "push")
end end
end, end,
onDoubleClick = function() onSubtreeClick = function()
if props.onSubtreeSelectionChange then if props.onSubtreeSelectionChange then
props.onSubtreeSelectionChange(props.nodeId, "push") props.onSubtreeSelectionChange(props.nodeId, "push")
end end

View file

@ -4,4 +4,3 @@ expression: normalized_stdout
--- ---
Writing OnlyOneCopy/child_of_one.luau Writing OnlyOneCopy/child_of_one.luau
Writing ReplicatedStorage/child_replicated_storage.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.csv
Writing src/csv_init/init.csv Writing src/csv_init/init.csv
Writing src/csv_init 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 expression: normalized_stdout
--- ---
Writing container.model.json5 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/int_value.model.json5
Writing src/subfolder/string_value.txt Writing src/subfolder/string_value.txt
Writing src/subfolder 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/non-init.luau
Writing src/init-file 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 expression: normalized_stdout
--- ---
Writing src/Message.model.json5 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/.gitkeep
Writing src/IncludeMe 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 expression: normalized_stdout
--- ---
Removing src 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/model_json.model.json5
Writing src/project_json.project.json5 Writing src/project_json.project.json5
Writing src/dir_with_meta 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 nested.project.json5
Writing string_value.txt 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 Writing src/modules/ServerModule.luau
Removing client-only.project.json5 Removing client-only.project.json5
Removing server-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/init.luau
Writing src Writing src
Created sourcemap at <TEMP>/project_init\sourcemap.json

View file

@ -4,4 +4,3 @@ expression: normalized_stdout
--- ---
Writing attribute_mismatch.luau Writing attribute_mismatch.luau
Writing property_mismatch.project.json5 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/ChildWithDuplicates
Writing src/ChildWithoutDuplicates Writing src/ChildWithoutDuplicates
Writing src/ChildWithoutDuplicates/Child 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/pointer.model.json5
Writing src/target.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/pointer.model.json5
Writing src/target.txt 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/Pointer_2.model.json5
Writing src/Target_1.model.json5 Writing src/Target_1.model.json5
Writing src/Target_2.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/init.meta.json5
Writing src/Target Writing src/Target
Removing src/Target.model.json5 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/Pointer1.model.json5
Writing src/Pointer2.model.json5 Writing src/Pointer2.model.json5
Writing src/Pointer3.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/model_json.model.json5
Writing src/rbxm.rbxm Writing src/rbxm.rbxm
Writing src/rbxmx.rbxmx 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/init/init.meta.json5
Writing src/model.model.json5 Writing src/model.model.json5
Writing src/init 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 default.project.json5
Writing string_value.txt 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/module.modulescript
Writing src/text.text 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 expression: normalized_stdout
--- ---
Writing default.project.json5 Writing default.project.json5
Created sourcemap at <TEMP>/unscriptable_properties\sourcemap.json

View file

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

View file

@ -214,7 +214,14 @@ impl SyncbackCommand {
// Generate sourcemap after successful syncback // Generate sourcemap after successful syncback
let sourcemap_path = base_path.join("sourcemap.json"); 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 if in a git repository
refresh_git_index(base_path); refresh_git_index(base_path);