diff --git a/CHANGELOG.md b/CHANGELOG.md index 3e584d2..345a0b6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,12 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ## [0.4.1] - 2026-07-11 +### 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 - `branch()` is now allowed to be used within a reactive scope. diff --git a/docs/api/creation.md b/docs/api/creation.md index 578b10f..34b5076 100644 --- a/docs/api/creation.md +++ b/docs/api/creation.md @@ -118,6 +118,56 @@ A wrapper for `action()` to listen for property changes. 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() STABLE Runs a function in a new stable scope and optionally applies its result to a diff --git a/docs/tut/crash-course/12-actions.md b/docs/tut/crash-course/12-actions.md index 60e5c30..33902b3 100644 --- a/docs/tut/crash-course/12-actions.md +++ b/docs/tut/crash-course/12-actions.md @@ -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 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. diff --git a/src/lib.luau b/src/lib.luau index a8a3885..7a94e9f 100644 --- a/src/lib.luau +++ b/src/lib.luau @@ -20,6 +20,7 @@ local values = require "./values" local spring, update_springs = require "./spring"() local action = require "./action"() local changed = require "./changed" +local tag = require "./tag" local timeout, update_timeouts = require "./timeout"() local flags = require "./flags" @@ -77,6 +78,7 @@ local vide = { -- actions action = action, changed = changed, + tag = tag, -- flags strict = (nil :: any) :: boolean, diff --git a/src/tag.luau b/src/tag.luau new file mode 100644 index 0000000..5081a51 --- /dev/null +++ b/src/tag.luau @@ -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) diff --git a/test/mock.luau b/test/mock.luau index a1f22a6..63cbbf5 100644 --- a/test/mock.luau +++ b/test/mock.luau @@ -66,6 +66,7 @@ local Instance = {} :: any do children: { Data }, changed: { [string]: RBXScriptSignal }, properties: { [string]: unknown }, + tags: { [string]: true }, destroying: RBXScriptSignal, class: string, type: "Instance" @@ -158,6 +159,7 @@ local Instance = {} :: any do children = {}, changed = {}, properties = {}, + tags = {}, class = class, destroying = Signal.new() :: any, type = "Instance" :: "Instance" @@ -211,6 +213,29 @@ local Instance = {} :: any do return data.changed[property] 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) local data = get_data(userdata); Signal.fire(data.destroying) diff --git a/test/tests.luau b/test/tests.luau index af6a7f4..7a0dcaa 100644 --- a/test/tests.luau +++ b/test/tests.luau @@ -24,6 +24,7 @@ local vide = require "../../vide" local spring = vide.spring local action = vide.action local changed = vide.changed + local tag = vide.tag local apply = vide.apply local step = vide.step local graph = require "../../vide/src/graph" @@ -2340,6 +2341,122 @@ TEST("changed()", wrap_root(function() 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() do CASE "evaluation deferred" local a = source(0)