mirror of
https://github.com/centau/vide.git
synced 2026-08-20 14:41:37 +00:00
Add tag() action for CollectionService tags
Adds a built-in `vide.tag()` action that wraps `Instance:AddTag()` / `Instance:RemoveTag()` so tags can be applied through `create()` props. It accepts a static string or array of strings, or a reactive source returning either. The reactive variant diffs the previous and new tag sets so only the delta is applied. All tags added by the action are removed when the surrounding scope is destroyed. Includes mock support for `AddTag`/`RemoveTag`/`HasTag`/`GetTags`, tests, API reference docs, and a closing note in the actions tutorial. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
19eaeb424f
commit
18c1d02d43
7 changed files with 276 additions and 0 deletions
|
|
@ -8,6 +8,12 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
|
||||||
|
|
||||||
## Unreleased
|
## Unreleased
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- `tag()` action for adding CollectionService tags to instances. Supports
|
||||||
|
static strings and arrays as well as reactive sources for dynamic tag sets.
|
||||||
|
Tags are automatically removed when the surrounding scope is destroyed.
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
- `branch()` is now allowed to be used within a reactive scope.
|
- `branch()` is now allowed to be used within a reactive scope.
|
||||||
|
|
|
||||||
|
|
@ -118,6 +118,56 @@ A wrapper for `action()` to listen for property changes.
|
||||||
|
|
||||||
Runs with an action priority of 1.
|
Runs with an action priority of 1.
|
||||||
|
|
||||||
|
## tag()
|
||||||
|
|
||||||
|
A wrapper for `action()` to add [CollectionService](https://create.roblox.com/docs/reference/engine/classes/CollectionService)
|
||||||
|
tags to an instance.
|
||||||
|
|
||||||
|
- **Type**
|
||||||
|
|
||||||
|
```luau
|
||||||
|
function tag(tag: string): Action
|
||||||
|
function tag(tags: { string }): Action
|
||||||
|
function tag(source: () -> string): Action
|
||||||
|
function tag(source: () -> { string }): Action
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Details**
|
||||||
|
|
||||||
|
Adds the given tag(s) to the instance using `Instance:AddTag()`. When the
|
||||||
|
surrounding scope is destroyed, any tags that were added are removed using
|
||||||
|
`Instance:RemoveTag()`.
|
||||||
|
|
||||||
|
A source (function) can be passed instead of a static value to reactively
|
||||||
|
update the applied tags. When the source updates, tags no longer present
|
||||||
|
are removed and new tags are added; tags present in both the previous and
|
||||||
|
new value are left untouched.
|
||||||
|
|
||||||
|
Multiple `tag()` actions can be composed on the same instance.
|
||||||
|
|
||||||
|
Runs with an action priority of 1.
|
||||||
|
|
||||||
|
- **Example**
|
||||||
|
|
||||||
|
Apply static tags:
|
||||||
|
|
||||||
|
```luau
|
||||||
|
create "Frame" {
|
||||||
|
tag("Clickable"),
|
||||||
|
tag({ "Draggable", "Highlightable" })
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Apply a reactive tag driven by a source:
|
||||||
|
|
||||||
|
```luau
|
||||||
|
local state = source "idle"
|
||||||
|
|
||||||
|
create "Frame" {
|
||||||
|
tag(function() return "state-" .. state() end)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
## mount() <Badge type="info" text="STABLE"><a href="/vide/api/reactivity-core#Scopes">STABLE</a></Badge>
|
## mount() <Badge type="info" text="STABLE"><a href="/vide/api/reactivity-core#Scopes">STABLE</a></Badge>
|
||||||
|
|
||||||
Runs a function in a new stable scope and optionally applies its result to a
|
Runs a function in a new stable scope and optionally applies its result to a
|
||||||
|
|
|
||||||
|
|
@ -53,3 +53,8 @@ instance.Text = "foo" -- "foo" will be printed by the effect
|
||||||
|
|
||||||
The source `output` will be updated with the new property value any time it is
|
The source `output` will be updated with the new property value any time it is
|
||||||
changed externally.
|
changed externally.
|
||||||
|
|
||||||
|
Vide also ships a few common actions out of the box, such as `vide.changed()`
|
||||||
|
for the pattern above and `vide.tag()` for adding
|
||||||
|
[CollectionService](https://create.roblox.com/docs/reference/engine/classes/CollectionService)
|
||||||
|
tags to an instance.
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@ local values = require "./values"
|
||||||
local spring, update_springs = require "./spring"()
|
local spring, update_springs = require "./spring"()
|
||||||
local action = require "./action"()
|
local action = require "./action"()
|
||||||
local changed = require "./changed"
|
local changed = require "./changed"
|
||||||
|
local tag = require "./tag"
|
||||||
local timeout, update_timeouts = require "./timeout"()
|
local timeout, update_timeouts = require "./timeout"()
|
||||||
local flags = require "./flags"
|
local flags = require "./flags"
|
||||||
|
|
||||||
|
|
@ -75,6 +76,7 @@ local vide = {
|
||||||
-- actions
|
-- actions
|
||||||
action = action,
|
action = action,
|
||||||
changed = changed,
|
changed = changed,
|
||||||
|
tag = tag,
|
||||||
|
|
||||||
-- flags
|
-- flags
|
||||||
strict = (nil :: any) :: boolean,
|
strict = (nil :: any) :: boolean,
|
||||||
|
|
|
||||||
71
src/tag.luau
Normal file
71
src/tag.luau
Normal file
|
|
@ -0,0 +1,71 @@
|
||||||
|
local action = require "./action"()
|
||||||
|
local cleanup = require "./cleanup"
|
||||||
|
local effect = require "./effect"
|
||||||
|
|
||||||
|
type Tags = string | { string }
|
||||||
|
|
||||||
|
local function build_set(value: Tags): { [string]: true }
|
||||||
|
local set: { [string]: true } = {}
|
||||||
|
if type(value) == "string" then
|
||||||
|
set[value] = true
|
||||||
|
else
|
||||||
|
for _, name in value do
|
||||||
|
if type(name) ~= "string" then
|
||||||
|
error("tag() expects a string or an array of strings", 0)
|
||||||
|
end
|
||||||
|
set[name] = true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return set
|
||||||
|
end
|
||||||
|
|
||||||
|
local function tag(value: Tags | () -> Tags)
|
||||||
|
return action(function(instance)
|
||||||
|
if type(value) == "function" then
|
||||||
|
local current: { [string]: true } = {}
|
||||||
|
|
||||||
|
effect(function()
|
||||||
|
local new_set = build_set((value :: () -> Tags)())
|
||||||
|
|
||||||
|
for name in current do
|
||||||
|
if not new_set[name] then
|
||||||
|
instance:RemoveTag(name)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
for name in new_set do
|
||||||
|
if not current[name] then
|
||||||
|
instance:AddTag(name)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
current = new_set
|
||||||
|
end)
|
||||||
|
|
||||||
|
cleanup(function()
|
||||||
|
for name in current do
|
||||||
|
instance:RemoveTag(name)
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
else
|
||||||
|
local set = build_set(value)
|
||||||
|
|
||||||
|
for name in set do
|
||||||
|
instance:AddTag(name)
|
||||||
|
end
|
||||||
|
|
||||||
|
cleanup(function()
|
||||||
|
for name in set do
|
||||||
|
instance:RemoveTag(name)
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
type Action = typeof(action(nil :: any))
|
||||||
|
|
||||||
|
return tag :: ((tag: string) -> Action)
|
||||||
|
& ((tags: { string }) -> Action)
|
||||||
|
& ((source: () -> string) -> Action)
|
||||||
|
& ((source: () -> { string }) -> Action)
|
||||||
|
|
@ -66,6 +66,7 @@ local Instance = {} :: any do
|
||||||
children: { Data },
|
children: { Data },
|
||||||
changed: { [string]: RBXScriptSignal },
|
changed: { [string]: RBXScriptSignal },
|
||||||
properties: { [string]: unknown },
|
properties: { [string]: unknown },
|
||||||
|
tags: { [string]: true },
|
||||||
destroying: RBXScriptSignal,
|
destroying: RBXScriptSignal,
|
||||||
class: string,
|
class: string,
|
||||||
type: "Instance"
|
type: "Instance"
|
||||||
|
|
@ -158,6 +159,7 @@ local Instance = {} :: any do
|
||||||
children = {},
|
children = {},
|
||||||
changed = {},
|
changed = {},
|
||||||
properties = {},
|
properties = {},
|
||||||
|
tags = {},
|
||||||
class = class,
|
class = class,
|
||||||
destroying = Signal.new() :: any,
|
destroying = Signal.new() :: any,
|
||||||
type = "Instance" :: "Instance"
|
type = "Instance" :: "Instance"
|
||||||
|
|
@ -211,6 +213,29 @@ local Instance = {} :: any do
|
||||||
return data.changed[property]
|
return data.changed[property]
|
||||||
end
|
end
|
||||||
|
|
||||||
|
function methods.AddTag(userdata: userdata, tag: string)
|
||||||
|
if type(tag) ~= "string" then error("tag must be a string", 2) end
|
||||||
|
get_data(userdata).tags[tag] = true
|
||||||
|
end
|
||||||
|
|
||||||
|
function methods.RemoveTag(userdata: userdata, tag: string)
|
||||||
|
if type(tag) ~= "string" then error("tag must be a string", 2) end
|
||||||
|
get_data(userdata).tags[tag] = nil
|
||||||
|
end
|
||||||
|
|
||||||
|
function methods.HasTag(userdata: userdata, tag: string): boolean
|
||||||
|
if type(tag) ~= "string" then error("tag must be a string", 2) end
|
||||||
|
return get_data(userdata).tags[tag] == true
|
||||||
|
end
|
||||||
|
|
||||||
|
function methods.GetTags(userdata: userdata): { string }
|
||||||
|
local out = {}
|
||||||
|
for name in get_data(userdata).tags do
|
||||||
|
table.insert(out, name)
|
||||||
|
end
|
||||||
|
return out
|
||||||
|
end
|
||||||
|
|
||||||
function methods.Destroy(userdata: userdata)
|
function methods.Destroy(userdata: userdata)
|
||||||
local data = get_data(userdata);
|
local data = get_data(userdata);
|
||||||
Signal.fire(data.destroying)
|
Signal.fire(data.destroying)
|
||||||
|
|
|
||||||
117
test/tests.luau
117
test/tests.luau
|
|
@ -24,6 +24,7 @@ local vide = require "../../vide"
|
||||||
local spring = vide.spring
|
local spring = vide.spring
|
||||||
local action = vide.action
|
local action = vide.action
|
||||||
local changed = vide.changed
|
local changed = vide.changed
|
||||||
|
local tag = vide.tag
|
||||||
local apply = vide.apply
|
local apply = vide.apply
|
||||||
local step = vide.step
|
local step = vide.step
|
||||||
local graph = require "../../vide/src/graph"
|
local graph = require "../../vide/src/graph"
|
||||||
|
|
@ -2340,6 +2341,122 @@ TEST("changed()", wrap_root(function()
|
||||||
end
|
end
|
||||||
end))
|
end))
|
||||||
|
|
||||||
|
TEST("tag()", wrap_root(function()
|
||||||
|
do CASE "apply single tag"
|
||||||
|
local frame = create "Frame" {
|
||||||
|
tag("MyTag")
|
||||||
|
}
|
||||||
|
|
||||||
|
CHECK(frame:HasTag("MyTag"))
|
||||||
|
CHECK(#frame:GetTags() == 1)
|
||||||
|
end
|
||||||
|
|
||||||
|
do CASE "apply multiple tags"
|
||||||
|
local frame = create "Frame" {
|
||||||
|
tag({ "A", "B", "C" })
|
||||||
|
}
|
||||||
|
|
||||||
|
CHECK(frame:HasTag("A"))
|
||||||
|
CHECK(frame:HasTag("B"))
|
||||||
|
CHECK(frame:HasTag("C"))
|
||||||
|
CHECK(#frame:GetTags() == 3)
|
||||||
|
end
|
||||||
|
|
||||||
|
do CASE "compose multiple tag() actions"
|
||||||
|
local frame = create "Frame" {
|
||||||
|
tag("A"),
|
||||||
|
tag("B")
|
||||||
|
}
|
||||||
|
|
||||||
|
CHECK(frame:HasTag("A"))
|
||||||
|
CHECK(frame:HasTag("B"))
|
||||||
|
end
|
||||||
|
|
||||||
|
do CASE "remove tags on scope destroy"
|
||||||
|
local _, frame, destroy = root(function(destroy)
|
||||||
|
return create "Frame" {
|
||||||
|
tag({ "A", "B" })
|
||||||
|
}, destroy
|
||||||
|
end)
|
||||||
|
|
||||||
|
CHECK(frame:HasTag("A"))
|
||||||
|
CHECK(frame:HasTag("B"))
|
||||||
|
|
||||||
|
destroy()
|
||||||
|
|
||||||
|
CHECK(not frame:HasTag("A"))
|
||||||
|
CHECK(not frame:HasTag("B"))
|
||||||
|
end
|
||||||
|
|
||||||
|
do CASE "reactive single tag"
|
||||||
|
local current = source("A")
|
||||||
|
|
||||||
|
local frame = create "Frame" {
|
||||||
|
tag(current)
|
||||||
|
}
|
||||||
|
|
||||||
|
CHECK(frame:HasTag("A"))
|
||||||
|
CHECK(#frame:GetTags() == 1)
|
||||||
|
|
||||||
|
current("B")
|
||||||
|
|
||||||
|
CHECK(not frame:HasTag("A"))
|
||||||
|
CHECK(frame:HasTag("B"))
|
||||||
|
CHECK(#frame:GetTags() == 1)
|
||||||
|
end
|
||||||
|
|
||||||
|
do CASE "reactive multiple tags"
|
||||||
|
local current = source({ "A", "B" })
|
||||||
|
|
||||||
|
local frame = create "Frame" {
|
||||||
|
tag(current)
|
||||||
|
}
|
||||||
|
|
||||||
|
CHECK(frame:HasTag("A"))
|
||||||
|
CHECK(frame:HasTag("B"))
|
||||||
|
CHECK(#frame:GetTags() == 2)
|
||||||
|
|
||||||
|
-- preserve "A", remove "B", add "C"
|
||||||
|
current({ "A", "C" })
|
||||||
|
|
||||||
|
CHECK(frame:HasTag("A"))
|
||||||
|
CHECK(not frame:HasTag("B"))
|
||||||
|
CHECK(frame:HasTag("C"))
|
||||||
|
CHECK(#frame:GetTags() == 2)
|
||||||
|
end
|
||||||
|
|
||||||
|
do CASE "reactive tag removed on scope destroy"
|
||||||
|
local _, frame, destroy = root(function(destroy)
|
||||||
|
local current = source("A")
|
||||||
|
return create "Frame" {
|
||||||
|
tag(current)
|
||||||
|
}, destroy
|
||||||
|
end)
|
||||||
|
|
||||||
|
CHECK(frame:HasTag("A"))
|
||||||
|
|
||||||
|
destroy()
|
||||||
|
|
||||||
|
CHECK(not frame:HasTag("A"))
|
||||||
|
CHECK(#frame:GetTags() == 0)
|
||||||
|
end
|
||||||
|
|
||||||
|
do CASE "reactive tag derived from source"
|
||||||
|
local name = source("hello")
|
||||||
|
|
||||||
|
local frame = create "Frame" {
|
||||||
|
tag(function() return "prefix-" .. name() end)
|
||||||
|
}
|
||||||
|
|
||||||
|
CHECK(frame:HasTag("prefix-hello"))
|
||||||
|
|
||||||
|
name("world")
|
||||||
|
|
||||||
|
CHECK(not frame:HasTag("prefix-hello"))
|
||||||
|
CHECK(frame:HasTag("prefix-world"))
|
||||||
|
end
|
||||||
|
end))
|
||||||
|
|
||||||
TEST("batch()", wrap_root(function()
|
TEST("batch()", wrap_root(function()
|
||||||
do CASE "evaluation deferred"
|
do CASE "evaluation deferred"
|
||||||
local a = source(0)
|
local a = source(0)
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue